feat: article photo management + eBay image URLs
Some checks are pending
CI / test (push) Waiting to run
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:
parent
237b0e6d8e
commit
fc18958e0e
10 changed files with 477 additions and 6 deletions
1
.env
1
.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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
41
docs/superpowers/plans/2026-05-19-08-photo-management.md
Normal file
41
docs/superpowers/plans/2026-05-19-08-photo-management.md
Normal 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
|
||||
```
|
||||
|
|
@ -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<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>> */
|
||||
private function buildAspects(Article $article): array
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
}
|
||||
}
|
||||
29
src/Infrastructure/Http/Controller/PublicPhotoController.php
Normal file
29
src/Infrastructure/Http/Controller/PublicPhotoController.php
Normal 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);
|
||||
}
|
||||
}
|
||||
261
templates/admin/photo_management.html.twig
Normal file
261
templates/admin/photo_management.html.twig
Normal 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 & 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 %}
|
||||
|
|
@ -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.'
|
||||
|
|
|
|||
|
|
@ -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.'
|
||||
|
|
|
|||
Loading…
Reference in a new issue