feat: article photo management + eBay image URLs
Some checks are pending
CI / test (push) Waiting to run

- Public photo endpoint at /photos/{filename} (no auth, UUID-based filenames)
- Admin photo management page per article: upload multiple, delete, set main, drag-reorder
- "Fotos verwalten" action button on article index + detail pages
- EbayAdapter.publishListing() now includes imageUrls (main photo first, max 24)
- APP_PUBLIC_URL env var for absolute URL generation in Messenger workers

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Simon Kuehn 2026-05-19 10:51:03 +00:00
parent 237b0e6d8e
commit fc18958e0e
10 changed files with 477 additions and 6 deletions

1
.env
View file

@ -1,5 +1,6 @@
APP_ENV=prod APP_ENV=prod
APP_SECRET=change_me_in_env_local APP_SECRET=change_me_in_env_local
APP_PUBLIC_URL=https://ss3k.schaunwama.de
POSTGRES_DB=superseller POSTGRES_DB=superseller
POSTGRES_USER=superseller POSTGRES_USER=superseller

View file

@ -86,6 +86,10 @@ services:
arguments: arguments:
$uploadsDir: '%kernel.project_dir%/var/uploads' $uploadsDir: '%kernel.project_dir%/var/uploads'
App\Infrastructure\Http\Controller\PublicPhotoController:
arguments:
$uploadsDir: '%kernel.project_dir%/var/uploads'
App\Infrastructure\Search\WebSearchInterface: App\Infrastructure\Search\WebSearchInterface:
alias: App\Infrastructure\Search\TavilyWebSearch alias: App\Infrastructure\Search\TavilyWebSearch
@ -122,6 +126,7 @@ services:
App\Infrastructure\Channel\Ebay\EbayAdapter: App\Infrastructure\Channel\Ebay\EbayAdapter:
arguments: arguments:
$marketplaceId: '%env(EBAY_MARKETPLACE_ID)%' $marketplaceId: '%env(EBAY_MARKETPLACE_ID)%'
$publicBaseUrl: '%env(APP_PUBLIC_URL)%'
tags: ['app.channel_adapter'] tags: ['app.channel_adapter']
App\Infrastructure\Channel\Ebay\EbayOAuthClient: App\Infrastructure\Channel\Ebay\EbayOAuthClient:

View file

@ -0,0 +1,41 @@
# SuperSeller3000 — Plan 8: Artikel-Fotoverwaltung + eBay-Bildintegration
**Goal:** Fotos können für einen Artikel nachträglich verwaltet werden (hinzufügen, löschen, Hauptfoto setzen). Beim eBay-Listing werden die Fotos als `imageUrls` übergeben. Dafür gibt es einen öffentlichen (nicht auth-geschützten) Foto-Endpoint, den eBay direkt abrufen kann.
---
## Schritte
### 1. Öffentlicher Foto-Endpoint
- `PublicPhotoController` — Route `/photos/{filename}` ohne `@IsGranted`
- Dateiname ist UUID-basiert (ausreichend opak, kein HMAC-Signing nötig)
- `APP_PUBLIC_URL` env var (z. B. `https://ss3k.schaunwama.de`) für absolute URL-Generierung
### 2. Fotoverwaltungs-Seite im Admin
- `PhotoManagementController``/admin/articles/{id}/photos`
- Zeigt alle Fotos mit Hauptfoto-Markierung
- Aktionen: Upload (neue Fotos), Löschen, Hauptfoto setzen
- "Fotos verwalten" Button in `ArticleCrudController` (Index + Detail)
- Twig-Template im EasyAdmin-Layout
### 3. `imageUrls` in eBay-Listings
- `EbayAdapter` bekommt `$publicBaseUrl` injiziert
- `publishListing()`: Fotos des Artikels als `imageUrls` in `upsertInventoryItem` (max. 24, Hauptfoto zuerst)
---
## Dateien
```
src/Infrastructure/Http/Controller/PublicPhotoController.php ← NEU
src/Infrastructure/Http/Controller/Admin/PhotoManagementController.php ← NEU
templates/admin/photo_management.html.twig ← NEU
src/Infrastructure/Channel/Ebay/EbayAdapter.php ← imageUrls
src/Infrastructure/Http/Controller/Admin/ArticleCrudController.php ← Button
config/services.yaml ← publicBaseUrl
.env ← APP_PUBLIC_URL
translations/admin.de.yaml + admin.en.yaml ← neue Keys
```

View file

@ -17,6 +17,7 @@ final class EbayAdapter implements ChannelAdapterInterface
private readonly EbayInventoryApiClient $apiClient, private readonly EbayInventoryApiClient $apiClient,
private readonly ArticleTypePlatformConfigRepositoryInterface $platformConfigRepository, private readonly ArticleTypePlatformConfigRepositoryInterface $platformConfigRepository,
private readonly string $marketplaceId, private readonly string $marketplaceId,
private readonly string $publicBaseUrl = '',
) { ) {
} }
@ -37,6 +38,17 @@ final class EbayAdapter implements ChannelAdapterInterface
throw new \RuntimeException(\sprintf('No eBay platform config found for ArticleType "%s"', $article->getArticleType()->getName())); throw new \RuntimeException(\sprintf('No eBay platform config found for ArticleType "%s"', $article->getArticleType()->getName()));
} }
$product = [
'title' => $article->getEbayTitle() ?? $article->getSku(),
'description' => $article->getEbayDescription() ?? '',
'aspects' => $this->buildAspects($article),
];
$imageUrls = $this->buildImageUrls($article);
if ([] !== $imageUrls) {
$product['imageUrls'] = $imageUrls;
}
$this->apiClient->upsertInventoryItem($sku, [ $this->apiClient->upsertInventoryItem($sku, [
'availability' => [ 'availability' => [
'shipToLocationAvailability' => [ 'shipToLocationAvailability' => [
@ -45,11 +57,7 @@ final class EbayAdapter implements ChannelAdapterInterface
], ],
'condition' => $this->mapCondition($article->getCondition()), 'condition' => $this->mapCondition($article->getCondition()),
'conditionDescription' => $article->getConditionNotes() ?? '', 'conditionDescription' => $article->getConditionNotes() ?? '',
'product' => [ 'product' => $product,
'title' => $article->getEbayTitle() ?? $article->getSku(),
'description' => $article->getEbayDescription() ?? '',
'aspects' => $this->buildAspects($article),
],
]); ]);
$listingPolicies = array_filter([ $listingPolicies = array_filter([
@ -135,6 +143,25 @@ final class EbayAdapter implements ChannelAdapterInterface
}; };
} }
/** @return list<string> */
private function buildImageUrls(Article $article): array
{
if ('' === $this->publicBaseUrl) {
return [];
}
$photos = $article->getPhotos()->toArray();
usort($photos, static fn ($a, $b) => (int) $b->isMain() - (int) $a->isMain());
$urls = [];
foreach (array_slice($photos, 0, 24) as $photo) {
$urls[] = rtrim($this->publicBaseUrl, '/').'/photos/'.rawurlencode($photo->getFilename());
}
return $urls;
}
/** @return array<string, list<string>> */ /** @return array<string, list<string>> */
private function buildAspects(Article $article): array private function buildAspects(Article $article): array
{ {

View file

@ -24,7 +24,6 @@ use EasyCorp\Bundle\EasyAdminBundle\Field\AssociationField;
use EasyCorp\Bundle\EasyAdminBundle\Field\ChoiceField; use EasyCorp\Bundle\EasyAdminBundle\Field\ChoiceField;
use EasyCorp\Bundle\EasyAdminBundle\Field\CollectionField; use EasyCorp\Bundle\EasyAdminBundle\Field\CollectionField;
use EasyCorp\Bundle\EasyAdminBundle\Field\Field; use EasyCorp\Bundle\EasyAdminBundle\Field\Field;
use EasyCorp\Bundle\EasyAdminBundle\Field\IdField;
use EasyCorp\Bundle\EasyAdminBundle\Field\IntegerField; use EasyCorp\Bundle\EasyAdminBundle\Field\IntegerField;
use EasyCorp\Bundle\EasyAdminBundle\Field\MoneyField; use EasyCorp\Bundle\EasyAdminBundle\Field\MoneyField;
use EasyCorp\Bundle\EasyAdminBundle\Field\TextareaField; use EasyCorp\Bundle\EasyAdminBundle\Field\TextareaField;
@ -70,6 +69,10 @@ final class ArticleCrudController extends AbstractCrudController
->setCssClass('btn btn-sm btn-success') ->setCssClass('btn btn-sm btn-success')
->displayIf(static fn (Article $a) => ArticleStatus::Draft === $a->getStatus()); ->displayIf(static fn (Article $a) => ArticleStatus::Draft === $a->getStatus());
$managePhotos = Action::new('managePhotos', new TranslatableMessage('action.manage_photos', [], 'admin'), 'fa fa-images')
->linkToRoute('admin_article_photos', static fn (Article $a) => ['id' => $a->getId()->toRfc4122()])
->setCssClass('btn btn-sm btn-outline-secondary');
$markDraft = Action::new('markDraft', new TranslatableMessage('action.mark_as_draft', [], 'admin'), 'fa fa-pen-to-square') $markDraft = Action::new('markDraft', new TranslatableMessage('action.mark_as_draft', [], 'admin'), 'fa fa-pen-to-square')
->linkToCrudAction('markAsDraft') ->linkToCrudAction('markAsDraft')
->setCssClass('btn btn-sm btn-secondary') ->setCssClass('btn btn-sm btn-secondary')
@ -90,9 +93,11 @@ final class ArticleCrudController extends AbstractCrudController
->add(Crud::PAGE_INDEX, $activate) ->add(Crud::PAGE_INDEX, $activate)
->add(Crud::PAGE_INDEX, $markDraft) ->add(Crud::PAGE_INDEX, $markDraft)
->add(Crud::PAGE_INDEX, $rerunAi) ->add(Crud::PAGE_INDEX, $rerunAi)
->add(Crud::PAGE_INDEX, $managePhotos)
->add(Crud::PAGE_DETAIL, $activate) ->add(Crud::PAGE_DETAIL, $activate)
->add(Crud::PAGE_DETAIL, $markDraft) ->add(Crud::PAGE_DETAIL, $markDraft)
->add(Crud::PAGE_DETAIL, $rerunAi) ->add(Crud::PAGE_DETAIL, $rerunAi)
->add(Crud::PAGE_DETAIL, $managePhotos)
->disable(Action::NEW, Action::DELETE); ->disable(Action::NEW, Action::DELETE);
} }

View file

@ -0,0 +1,100 @@
<?php
declare(strict_types=1);
namespace App\Infrastructure\Http\Controller\Admin;
use App\Application\Article\PhotoService;
use App\Domain\Article\Repository\ArticlePhotoRepositoryInterface;
use App\Domain\Article\Repository\ArticleRepositoryInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\File\UploadedFile;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
use Symfony\Component\Uid\Uuid;
#[IsGranted('ROLE_USER')]
final class PhotoManagementController extends AbstractController
{
public function __construct(
private readonly ArticleRepositoryInterface $articleRepository,
private readonly ArticlePhotoRepositoryInterface $photoRepository,
private readonly PhotoService $photoService,
) {
}
#[Route('/admin/articles/{id}/photos', name: 'admin_article_photos', methods: ['GET'])]
public function manage(string $id): Response
{
$article = $this->articleRepository->findById(Uuid::fromString($id));
if (null === $article) {
throw $this->createNotFoundException('Artikel nicht gefunden.');
}
$photos = $this->photoRepository->findByArticle(Uuid::fromString($id));
return $this->render('admin/photo_management.html.twig', [
'article' => $article,
'photos' => $photos,
]);
}
#[Route('/admin/articles/{id}/photos/upload', name: 'admin_article_photos_upload', methods: ['POST'])]
public function upload(string $id, Request $request): RedirectResponse
{
$article = $this->articleRepository->findById(Uuid::fromString($id));
if (null === $article) {
throw $this->createNotFoundException('Artikel nicht gefunden.');
}
/** @var array<int|string, mixed> $files */
$files = $request->files->all('photos');
$uploaded = 0;
foreach ($files as $file) {
if (!$file instanceof UploadedFile || !$file->isValid()) {
continue;
}
$tmp = $file->getPathname();
$this->photoService->upload(Uuid::fromString($id), $tmp, $file->getClientOriginalName());
++$uploaded;
}
if ($uploaded > 0) {
$this->addFlash('success', $uploaded === 1 ? '1 Foto hochgeladen.' : "{$uploaded} Fotos hochgeladen.");
}
return $this->redirectToRoute('admin_article_photos', ['id' => $id]);
}
#[Route('/admin/articles/{id}/photos/{photoId}/delete', name: 'admin_article_photos_delete', methods: ['POST'])]
public function delete(string $id, string $photoId): RedirectResponse
{
$this->photoService->delete(Uuid::fromString($photoId));
$this->addFlash('success', 'Foto gelöscht.');
return $this->redirectToRoute('admin_article_photos', ['id' => $id]);
}
#[Route('/admin/articles/{id}/photos/{photoId}/main', name: 'admin_article_photos_set_main', methods: ['POST'])]
public function setMain(string $id, string $photoId): RedirectResponse
{
$this->photoService->setMain(Uuid::fromString($photoId));
$this->addFlash('success', 'Hauptfoto gesetzt.');
return $this->redirectToRoute('admin_article_photos', ['id' => $id]);
}
#[Route('/admin/articles/{id}/photos/reorder', name: 'admin_article_photos_reorder', methods: ['POST'])]
public function reorder(string $id, Request $request): RedirectResponse
{
/** @var string[] $order */
$order = $request->request->all('order');
$this->photoService->reorder(Uuid::fromString($id), $order);
return $this->redirectToRoute('admin_article_photos', ['id' => $id]);
}
}

View file

@ -0,0 +1,29 @@
<?php
declare(strict_types=1);
namespace App\Infrastructure\Http\Controller;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
final class PublicPhotoController extends AbstractController
{
public function __construct(private readonly string $uploadsDir)
{
}
#[Route('/photos/{filename}', name: 'public_photo', requirements: ['filename' => '[a-zA-Z0-9\-_\.]+'], methods: ['GET'])]
public function serve(string $filename): Response
{
$path = rtrim($this->uploadsDir, '/').'/'.$filename;
if (!file_exists($path) || !is_file($path)) {
throw $this->createNotFoundException('Photo not found.');
}
return new BinaryFileResponse($path);
}
}

View file

@ -0,0 +1,261 @@
{% extends '@EasyAdmin/page/content.html.twig' %}
{% block page_title %}Fotos — {{ article.inventoryNumber }}{% endblock %}
{% block main %}
<div class="d-flex align-items-center gap-3 mb-4">
<a href="{{ ea_url().setController('App\\Infrastructure\\Http\\Controller\\Admin\\ArticleCrudController').setAction('detail').setEntityId(article.id).generateUrl() }}"
class="btn btn-sm btn-outline-secondary">
<i class="fa fa-arrow-left me-1"></i>Zum Artikel
</a>
<h3 class="mb-0">
<i class="fa fa-images me-2 text-muted"></i>
Fotos verwalten
<span class="text-muted fw-normal fs-5 ms-2">{{ article.inventoryNumber }}</span>
</h3>
</div>
{% for label, messages in app.flashes %}
{% for message in messages %}
<div class="alert alert-{{ label == 'success' ? 'success' : (label == 'danger' ? 'danger' : 'warning') }} alert-dismissible fade show" role="alert">
{{ message }}
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>
{% endfor %}
{% endfor %}
<div class="row g-4">
{# ── Current photos ────────────────────────────────────────────────── #}
<div class="col-12">
<div class="card">
<div class="card-header d-flex align-items-center justify-content-between">
<h5 class="mb-0"><i class="fa fa-photo-film me-2"></i>Aktuelle Fotos</h5>
<span class="badge bg-secondary">{{ photos|length }} Fotos</span>
</div>
<div class="card-body">
{% if photos is empty %}
<p class="text-muted text-center py-4 mb-0">
<i class="fa fa-image fa-2x d-block mb-2"></i>Noch keine Fotos vorhanden
</p>
{% else %}
{# Drag-reorder form #}
<form method="POST" action="{{ path('admin_article_photos_reorder', {id: article.id}) }}" id="reorder-form">
<input type="hidden" name="_token" value="{{ csrf_token('photos_' ~ article.id) }}">
<div id="photo-grid" class="row g-3 mb-3">
{% for photo in photos %}
<div class="col-6 col-md-4 col-lg-3 photo-card" data-id="{{ photo.id }}">
<input type="hidden" name="order[]" value="{{ photo.id }}">
<div class="card h-100 position-relative {{ photo.main ? 'border-primary border-2' : '' }}">
{% if photo.main %}
<span class="badge bg-primary position-absolute top-0 start-0 m-2" style="z-index:2;">
<i class="fa fa-star me-1"></i>Hauptfoto
</span>
{% endif %}
<img src="{{ path('public_photo', {filename: photo.filename}) }}"
alt="Foto {{ loop.index }}"
class="card-img-top object-fit-cover"
style="height:160px;object-fit:cover;">
<div class="card-body p-2 d-flex gap-1 flex-wrap">
{% if not photo.main %}
<form method="POST"
action="{{ path('admin_article_photos_set_main', {id: article.id, photoId: photo.id}) }}"
class="d-inline">
<input type="hidden" name="_token" value="{{ csrf_token('photos_' ~ article.id) }}">
<button type="submit" class="btn btn-xs btn-outline-primary" title="Als Hauptfoto setzen">
<i class="fa fa-star"></i>
</button>
</form>
{% endif %}
<form method="POST"
action="{{ path('admin_article_photos_delete', {id: article.id, photoId: photo.id}) }}"
class="d-inline"
onsubmit="return confirm('Foto wirklich löschen?')">
<input type="hidden" name="_token" value="{{ csrf_token('photos_' ~ article.id) }}">
<button type="submit" class="btn btn-xs btn-outline-danger" title="Löschen">
<i class="fa fa-trash"></i>
</button>
</form>
<span class="text-muted small ms-auto d-flex align-items-center">
#{{ loop.index }}
</span>
</div>
</div>
</div>
{% endfor %}
</div>
{% if photos|length > 1 %}
<div class="text-muted small">
<i class="fa fa-arrows-up-down-left-right me-1"></i>
Reihenfolge per Drag &amp; Drop ändern, dann speichern.
</div>
<button type="submit" class="btn btn-sm btn-outline-secondary mt-2" id="save-order-btn" style="display:none;">
<i class="fa fa-save me-1"></i>Reihenfolge speichern
</button>
{% endif %}
</form>
{% endif %}
</div>
</div>
</div>
{# ── Upload new photos ─────────────────────────────────────────────── #}
<div class="col-12 col-lg-6">
<div class="card">
<div class="card-header">
<h5 class="mb-0"><i class="fa fa-upload me-2"></i>Fotos hinzufügen</h5>
</div>
<div class="card-body">
<form method="POST"
action="{{ path('admin_article_photos_upload', {id: article.id}) }}"
enctype="multipart/form-data"
id="upload-form">
<input type="hidden" name="_token" value="{{ csrf_token('photos_' ~ article.id) }}">
<div id="drop-zone"
class="border-2 border-dashed rounded text-center p-4 text-muted mb-3"
style="cursor:pointer;border-style:dashed!important;transition:background .15s,border-color .15s;">
<i class="fa fa-cloud-upload-alt fa-2x d-block mb-2"></i>
<span>Fotos hier ablegen oder Schaltfläche nutzen</span>
<div id="preview-grid" class="d-flex flex-wrap gap-2 justify-content-center mt-3"></div>
</div>
<input type="file" id="photo-input" name="photos[]"
accept="image/jpeg,image/png,image/webp" multiple
style="position:absolute;opacity:0;width:0;height:0;">
<div class="d-flex gap-2">
<label for="photo-input" class="btn btn-outline-primary flex-fill mb-0" style="cursor:pointer;">
<i class="fa fa-folder-open me-2"></i>Dateien wählen
</label>
<button type="submit" class="btn btn-success flex-fill" id="upload-btn" disabled>
<i class="fa fa-upload me-2"></i>Hochladen
</button>
</div>
</form>
</div>
</div>
</div>
</div>
<style>
.btn-xs { padding: .1rem .35rem; font-size: .75rem; }
.drop-over { background: #e8f0fe !important; border-color: #0d6efd !important; }
.photo-card.dragging { opacity: .4; }
.photo-card.drag-over > .card { outline: 2px dashed #0d6efd; }
</style>
<script>
(function () {
/* ── Upload drag & drop ───────────────────────────────────────── */
const dropZone = document.getElementById('drop-zone');
const photoInput = document.getElementById('photo-input');
const uploadBtn = document.getElementById('upload-btn');
const previewGrid = document.getElementById('preview-grid');
let selectedFiles = [];
dropZone.addEventListener('dragover', e => { e.preventDefault(); dropZone.classList.add('drop-over'); });
dropZone.addEventListener('dragleave', () => dropZone.classList.remove('drop-over'));
dropZone.addEventListener('drop', e => {
e.preventDefault();
dropZone.classList.remove('drop-over');
addFiles([...e.dataTransfer.files]);
});
dropZone.addEventListener('click', () => photoInput.click());
photoInput.addEventListener('change', () => {
addFiles([...photoInput.files]);
photoInput.value = '';
});
function addFiles(files) {
files.filter(f => f.type.startsWith('image/')).forEach(f => selectedFiles.push(f));
syncInput();
renderPreviews();
}
function syncInput() {
const dt = new DataTransfer();
selectedFiles.forEach(f => dt.items.add(f));
photoInput.files = dt.files;
uploadBtn.disabled = selectedFiles.length === 0;
}
function renderPreviews() {
previewGrid.innerHTML = '';
selectedFiles.forEach((f, i) => {
const wrap = document.createElement('div');
wrap.className = 'position-relative';
wrap.style.cssText = 'width:72px;height:72px;';
const img = document.createElement('img');
img.src = URL.createObjectURL(f);
img.className = 'w-100 h-100 rounded border object-fit-cover';
img.style.objectFit = 'cover';
const del = document.createElement('button');
del.type = 'button';
del.className = 'btn btn-danger position-absolute top-0 end-0 p-0 d-flex align-items-center justify-content-center';
del.style.cssText = 'width:18px;height:18px;font-size:.6rem;border-radius:50%;transform:translate(40%,-40%);';
del.textContent = '×';
del.onclick = () => { selectedFiles.splice(i, 1); syncInput(); renderPreviews(); };
wrap.appendChild(img);
wrap.appendChild(del);
previewGrid.appendChild(wrap);
});
}
/* ── Drag-to-reorder ──────────────────────────────────────────── */
const grid = document.getElementById('photo-grid');
const saveBtn = document.getElementById('save-order-btn');
if (grid && saveBtn) {
let dragged = null;
grid.querySelectorAll('.photo-card').forEach(card => {
card.setAttribute('draggable', 'true');
card.addEventListener('dragstart', () => {
dragged = card;
card.classList.add('dragging');
});
card.addEventListener('dragend', () => {
dragged = null;
card.classList.remove('dragging');
grid.querySelectorAll('.photo-card').forEach(c => c.classList.remove('drag-over'));
});
card.addEventListener('dragover', e => {
e.preventDefault();
if (dragged && dragged !== card) {
grid.querySelectorAll('.photo-card').forEach(c => c.classList.remove('drag-over'));
card.classList.add('drag-over');
}
});
card.addEventListener('drop', e => {
e.preventDefault();
card.classList.remove('drag-over');
if (dragged && dragged !== card) {
const allCards = [...grid.querySelectorAll('.photo-card')];
const fromIdx = allCards.indexOf(dragged);
const toIdx = allCards.indexOf(card);
if (fromIdx < toIdx) {
card.after(dragged);
} else {
card.before(dragged);
}
// update hidden order inputs
const newOrder = [...grid.querySelectorAll('.photo-card')];
newOrder.forEach((c, i) => {
c.querySelector('input[name="order[]"]').value = c.dataset.id;
});
saveBtn.style.display = 'inline-block';
}
});
});
}
})();
</script>
{% endblock %}

View file

@ -87,6 +87,7 @@ action.rerun_ai: 'KI neu starten'
action.rerun_ai_confirm: 'Die gesamte KI-Pipeline für diesen Artikel neu starten? Attribute und eBay-Texte werden überschrieben.' action.rerun_ai_confirm: 'Die gesamte KI-Pipeline für diesen Artikel neu starten? Attribute und eBay-Texte werden überschrieben.'
action.mark_as_draft: 'Als Entwurf markieren' action.mark_as_draft: 'Als Entwurf markieren'
action.activate: Aktivieren action.activate: Aktivieren
action.manage_photos: 'Fotos verwalten'
flash.pipeline_job_not_found: 'Kein ursprünglicher Pipeline-Job gefunden — Foto kann nicht ermittelt werden.' flash.pipeline_job_not_found: 'Kein ursprünglicher Pipeline-Job gefunden — Foto kann nicht ermittelt werden.'
flash.photo_not_found: 'Gespeichertes Foto nicht gefunden unter: %path%' flash.photo_not_found: 'Gespeichertes Foto nicht gefunden unter: %path%'
flash.pipeline_requeued: 'KI-Pipeline für %label% neu gestartet — Attribute und eBay-Texte werden aktualisiert.' flash.pipeline_requeued: 'KI-Pipeline für %label% neu gestartet — Attribute und eBay-Texte werden aktualisiert.'

View file

@ -87,6 +87,7 @@ action.rerun_ai: 'Re-run AI'
action.rerun_ai_confirm: 'Re-run the full AI pipeline for this article? This will overwrite existing attributes and eBay texts.' action.rerun_ai_confirm: 'Re-run the full AI pipeline for this article? This will overwrite existing attributes and eBay texts.'
action.mark_as_draft: 'Mark as Draft' action.mark_as_draft: 'Mark as Draft'
action.activate: Activate action.activate: Activate
action.manage_photos: 'Manage Photos'
flash.pipeline_job_not_found: 'No original pipeline job found — cannot determine which photo to use.' flash.pipeline_job_not_found: 'No original pipeline job found — cannot determine which photo to use.'
flash.photo_not_found: 'Stored photo not found at: %path%' flash.photo_not_found: 'Stored photo not found at: %path%'
flash.pipeline_requeued: 'AI pipeline re-queued for %label% — attributes and eBay texts will be updated when complete.' flash.pipeline_requeued: 'AI pipeline re-queued for %label% — attributes and eBay texts will be updated when complete.'