diff --git a/.env b/.env index 2ea519e..5c7ad3b 100644 --- a/.env +++ b/.env @@ -1,5 +1,6 @@ APP_ENV=prod APP_SECRET=change_me_in_env_local +APP_PUBLIC_URL=https://ss3k.schaunwama.de POSTGRES_DB=superseller POSTGRES_USER=superseller diff --git a/config/services.yaml b/config/services.yaml index f7a7d3d..72d0d12 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -86,6 +86,10 @@ services: arguments: $uploadsDir: '%kernel.project_dir%/var/uploads' + App\Infrastructure\Http\Controller\PublicPhotoController: + arguments: + $uploadsDir: '%kernel.project_dir%/var/uploads' + App\Infrastructure\Search\WebSearchInterface: alias: App\Infrastructure\Search\TavilyWebSearch @@ -122,6 +126,7 @@ services: App\Infrastructure\Channel\Ebay\EbayAdapter: arguments: $marketplaceId: '%env(EBAY_MARKETPLACE_ID)%' + $publicBaseUrl: '%env(APP_PUBLIC_URL)%' tags: ['app.channel_adapter'] App\Infrastructure\Channel\Ebay\EbayOAuthClient: diff --git a/docs/superpowers/plans/2026-05-19-08-photo-management.md b/docs/superpowers/plans/2026-05-19-08-photo-management.md new file mode 100644 index 0000000..7f5d03f --- /dev/null +++ b/docs/superpowers/plans/2026-05-19-08-photo-management.md @@ -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 +``` diff --git a/src/Infrastructure/Channel/Ebay/EbayAdapter.php b/src/Infrastructure/Channel/Ebay/EbayAdapter.php index 1760e42..b0d1793 100644 --- a/src/Infrastructure/Channel/Ebay/EbayAdapter.php +++ b/src/Infrastructure/Channel/Ebay/EbayAdapter.php @@ -17,6 +17,7 @@ final class EbayAdapter implements ChannelAdapterInterface private readonly EbayInventoryApiClient $apiClient, private readonly ArticleTypePlatformConfigRepositoryInterface $platformConfigRepository, 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())); } + $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, [ 'availability' => [ 'shipToLocationAvailability' => [ @@ -45,11 +57,7 @@ final class EbayAdapter implements ChannelAdapterInterface ], 'condition' => $this->mapCondition($article->getCondition()), 'conditionDescription' => $article->getConditionNotes() ?? '', - 'product' => [ - 'title' => $article->getEbayTitle() ?? $article->getSku(), - 'description' => $article->getEbayDescription() ?? '', - 'aspects' => $this->buildAspects($article), - ], + 'product' => $product, ]); $listingPolicies = array_filter([ @@ -135,6 +143,25 @@ final class EbayAdapter implements ChannelAdapterInterface }; } + /** @return list */ + 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> */ private function buildAspects(Article $article): array { diff --git a/src/Infrastructure/Http/Controller/Admin/ArticleCrudController.php b/src/Infrastructure/Http/Controller/Admin/ArticleCrudController.php index 9a4ad25..5cf4169 100644 --- a/src/Infrastructure/Http/Controller/Admin/ArticleCrudController.php +++ b/src/Infrastructure/Http/Controller/Admin/ArticleCrudController.php @@ -24,7 +24,6 @@ use EasyCorp\Bundle\EasyAdminBundle\Field\AssociationField; use EasyCorp\Bundle\EasyAdminBundle\Field\ChoiceField; use EasyCorp\Bundle\EasyAdminBundle\Field\CollectionField; use EasyCorp\Bundle\EasyAdminBundle\Field\Field; -use EasyCorp\Bundle\EasyAdminBundle\Field\IdField; use EasyCorp\Bundle\EasyAdminBundle\Field\IntegerField; use EasyCorp\Bundle\EasyAdminBundle\Field\MoneyField; use EasyCorp\Bundle\EasyAdminBundle\Field\TextareaField; @@ -70,6 +69,10 @@ final class ArticleCrudController extends AbstractCrudController ->setCssClass('btn btn-sm btn-success') ->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') ->linkToCrudAction('markAsDraft') ->setCssClass('btn btn-sm btn-secondary') @@ -90,9 +93,11 @@ final class ArticleCrudController extends AbstractCrudController ->add(Crud::PAGE_INDEX, $activate) ->add(Crud::PAGE_INDEX, $markDraft) ->add(Crud::PAGE_INDEX, $rerunAi) + ->add(Crud::PAGE_INDEX, $managePhotos) ->add(Crud::PAGE_DETAIL, $activate) ->add(Crud::PAGE_DETAIL, $markDraft) ->add(Crud::PAGE_DETAIL, $rerunAi) + ->add(Crud::PAGE_DETAIL, $managePhotos) ->disable(Action::NEW, Action::DELETE); } diff --git a/src/Infrastructure/Http/Controller/Admin/PhotoManagementController.php b/src/Infrastructure/Http/Controller/Admin/PhotoManagementController.php new file mode 100644 index 0000000..4a9ec1e --- /dev/null +++ b/src/Infrastructure/Http/Controller/Admin/PhotoManagementController.php @@ -0,0 +1,100 @@ +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 $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]); + } +} diff --git a/src/Infrastructure/Http/Controller/PublicPhotoController.php b/src/Infrastructure/Http/Controller/PublicPhotoController.php new file mode 100644 index 0000000..9266105 --- /dev/null +++ b/src/Infrastructure/Http/Controller/PublicPhotoController.php @@ -0,0 +1,29 @@ + '[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); + } +} diff --git a/templates/admin/photo_management.html.twig b/templates/admin/photo_management.html.twig new file mode 100644 index 0000000..604e756 --- /dev/null +++ b/templates/admin/photo_management.html.twig @@ -0,0 +1,261 @@ +{% extends '@EasyAdmin/page/content.html.twig' %} + +{% block page_title %}Fotos — {{ article.inventoryNumber }}{% endblock %} + +{% block main %} + +
+ + Zum Artikel + +

+ + Fotos verwalten + {{ article.inventoryNumber }} +

+
+ +{% for label, messages in app.flashes %} + {% for message in messages %} + + {% endfor %} +{% endfor %} + +
+ + {# ── Current photos ────────────────────────────────────────────────── #} +
+
+
+
Aktuelle Fotos
+ {{ photos|length }} Fotos +
+
+ {% if photos is empty %} +

+ Noch keine Fotos vorhanden +

+ {% else %} + {# Drag-reorder form #} +
+ +
+ {% for photo in photos %} +
+ +
+ {% if photo.main %} + + Hauptfoto + + {% endif %} + Foto {{ loop.index }} +
+ {% if not photo.main %} + + + + + {% endif %} +
+ + +
+ + #{{ loop.index }} + +
+
+
+ {% endfor %} +
+ {% if photos|length > 1 %} +
+ + Reihenfolge per Drag & Drop ändern, dann speichern. +
+ + {% endif %} + + {% endif %} +
+
+
+ + {# ── Upload new photos ─────────────────────────────────────────────── #} +
+
+
+
Fotos hinzufügen
+
+
+
+ + +
+ + Fotos hier ablegen oder Schaltfläche nutzen +
+
+ + + +
+ + +
+
+
+
+
+ +
+ + + + + +{% endblock %} diff --git a/translations/admin.de.yaml b/translations/admin.de.yaml index a64b834..715dc9e 100644 --- a/translations/admin.de.yaml +++ b/translations/admin.de.yaml @@ -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.mark_as_draft: 'Als Entwurf markieren' 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.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.' diff --git a/translations/admin.en.yaml b/translations/admin.en.yaml index b86571b..851014f 100644 --- a/translations/admin.en.yaml +++ b/translations/admin.en.yaml @@ -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.mark_as_draft: 'Mark as Draft' 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.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.'