diff --git a/.gitignore b/.gitignore index ab81c7a..fff0556 100644 --- a/.gitignore +++ b/.gitignore @@ -44,8 +44,12 @@ build/ ./storage_orders ./storage_quotes +./storage_requests +./storage_media storage_orders storage_quotes +storage_requests +storage_media # Qodana local reports/artifacts backend/.qodana/ diff --git a/README.md b/README.md index f7a89c4..7f45d6f 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ Un'applicazione Full Stack (Angular + Spring Boot) per calcolare preventivi di s ## Stack Tecnologico -- **Backend**: Java 21, Spring Boot 3.4, PostgreSQL, Flyway. +- **Backend**: Java 21, Spring Boot 3.4, PostgreSQL. - **Frontend**: Angular 19, Angular Material, Three.js. - **Slicer**: OrcaSlicer (invocato via CLI). @@ -21,14 +21,20 @@ Un'applicazione Full Stack (Angular + Spring Boot) per calcolare preventivi di s * **Node.js 22** e **npm** installati. * **PostgreSQL** attivo. * **OrcaSlicer** installato sul sistema. +* **FFmpeg** installato sul sistema o presente nell'immagine Docker del backend. ## Avvio Rapido ### 1. Database -Crea un database PostgreSQL chiamato `printcalc`. Le tabelle verranno create automaticamente al primo avvio tramite Flyway. +Crea un database PostgreSQL chiamato `printcalc`. Lo schema viene gestito dal progetto tramite configurazione JPA/SQL del repository. ### 2. Backend -Configura il percorso di OrcaSlicer in `backend/src/main/resources/application.properties` o tramite la variabile d'ambiente `SLICER_PATH`. +Configura il percorso di OrcaSlicer in `backend/src/main/resources/application.properties` o tramite la variabile d'ambiente `SLICER_PATH`. Per il media service pubblico puoi configurare anche: + +- `MEDIA_STORAGE_ROOT` per la root `storage_media` usata dal backend (`original/`, `public/`, `private/`) +- `MEDIA_PUBLIC_BASE_URL` per gli URL assoluti restituiti dalle API admin, ad esempio `https://example.com/media` +- `MEDIA_FFMPEG_PATH` per il binario `ffmpeg` +- `MEDIA_UPLOAD_MAX_FILE_SIZE_BYTES` per il limite per asset immagine ```bash cd backend @@ -57,11 +63,50 @@ I prezzi non sono più gestiti tramite variabili d'ambiente fisse ma tramite tab * `/backend`: API Spring Boot. * `/frontend`: Applicazione Angular. * `/backend/profiles`: Contiene i file di configurazione per OrcaSlicer. +* `/storage_media`: Originali e varianti media pubbliche/private su filesystem. + +## Media pubblici + +Il backend salva sempre l'originale in `storage_media/original/` e precomputa le varianti pubbliche in `storage_media/public/`. La cartella `storage_media/private/` è predisposta per asset non pubblici. + +Nel deploy Docker il volume media atteso è `/mnt/cache/appdata/print-calculator/${ENV}/storage_media:/app/storage_media`. + +Nginx non deve passare dal backend per i file pubblici. Configurazione attesa: + +```nginx +location /media/ { + alias /mnt/cache/appdata/print-calculator/${ENV}/storage_media/public/; +} +``` + +Usage key iniziali previste per frontend: + +- `HOME_SECTION / shop-gallery` +- `HOME_SECTION / founders-gallery` +- `HOME_SECTION / capability-prototyping` +- `HOME_SECTION / capability-custom-parts` +- `HOME_SECTION / capability-small-series` +- `HOME_SECTION / capability-cad` +- `ABOUT_MEMBER / joe` +- `ABOUT_MEMBER / matteo` +- riservati per estensioni future: `SHOP_PRODUCT`, `SHOP_CATEGORY`, `SHOP_GALLERY` + +Operativamente: + +- carica i file dal media admin endpoint del backend +- associa ogni asset con `POST /api/admin/media/usages` +- per `ABOUT_MEMBER` imposta `isPrimary=true` sulla foto principale del membro +- home e about leggono da `GET /api/public/media/usages?usageType=...&usageKey=...` +- il frontend usa `` e preferisce AVIF/WEBP con fallback JPEG, senza usare l'originale +- nel back-office frontend la gestione operativa della home passa dalla pagina `admin/home-media` ## Troubleshooting ### Percorso OrcaSlicer Assicurati che `slicer.path` punti al binario corretto. Su macOS è solitamente `/Applications/OrcaSlicer.app/Contents/MacOS/OrcaSlicer`. Su Linux è il percorso all'AppImage (estratta o meno). +### FFmpeg e media pubblici +Verifica che `MEDIA_FFMPEG_PATH` punti a un `ffmpeg` con supporto JPEG, WebP e AVIF. Se gli URL media restituiti dalle API admin non sono raggiungibili, controlla che `MEDIA_PUBLIC_BASE_URL` corrisponda al `location /media/` esposto da Nginx e che il volume `storage_media` sia montato correttamente. + ### Database connection Verifica le credenziali in `application.properties`. Se usi Docker, puoi passare `DB_URL`, `DB_USERNAME` e `DB_PASSWORD` come variabili d'ambiente. diff --git a/backend/Dockerfile b/backend/Dockerfile index 6067fdf..f9ce411 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -13,16 +13,25 @@ FROM eclipse-temurin:21-jre-jammy ARG ORCA_VERSION=2.3.1 ARG ORCA_DOWNLOAD_URL -# Install system dependencies for OrcaSlicer (same as before) -RUN apt-get update && apt-get install -y \ +# Install system dependencies for OrcaSlicer and media processing. +# The build fails fast if the packaged ffmpeg lacks JPEG/WebP/AVIF encoders. +RUN set -eux; \ + apt-get update; \ + DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \ + ffmpeg \ wget \ assimp-utils \ libgl1 \ libglib2.0-0 \ libgtk-3-0 \ libdbus-1-3 \ - libwebkit2gtk-4.0-37 \ - && rm -rf /var/lib/apt/lists/* + libwebkit2gtk-4.0-37; \ + ffmpeg -hide_banner -encoders > /tmp/ffmpeg-encoders.txt; \ + grep -Eq '[[:space:]]mjpeg[[:space:]]' /tmp/ffmpeg-encoders.txt; \ + grep -Eq '[[:space:]](libwebp|webp)[[:space:]]' /tmp/ffmpeg-encoders.txt; \ + grep -Eq '[[:space:]](libaom-av1|librav1e|libsvtav1)[[:space:]]' /tmp/ffmpeg-encoders.txt; \ + rm -f /tmp/ffmpeg-encoders.txt; \ + rm -rf /var/lib/apt/lists/* # Install OrcaSlicer WORKDIR /opt diff --git a/backend/src/main/java/com/printcalculator/controller/PublicMediaController.java b/backend/src/main/java/com/printcalculator/controller/PublicMediaController.java new file mode 100644 index 0000000..ec3523b --- /dev/null +++ b/backend/src/main/java/com/printcalculator/controller/PublicMediaController.java @@ -0,0 +1,31 @@ +package com.printcalculator.controller; + +import com.printcalculator.dto.PublicMediaUsageDto; +import com.printcalculator.service.media.PublicMediaQueryService; +import org.springframework.http.ResponseEntity; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; + +@RestController +@RequestMapping("/api/public/media") +@Transactional(readOnly = true) +public class PublicMediaController { + + private final PublicMediaQueryService publicMediaQueryService; + + public PublicMediaController(PublicMediaQueryService publicMediaQueryService) { + this.publicMediaQueryService = publicMediaQueryService; + } + + @GetMapping("/usages") + public ResponseEntity> getUsageMedia(@RequestParam String usageType, + @RequestParam String usageKey, + @RequestParam(required = false) String lang) { + return ResponseEntity.ok(publicMediaQueryService.getUsageMedia(usageType, usageKey, lang)); + } +} diff --git a/backend/src/main/java/com/printcalculator/controller/admin/AdminMediaController.java b/backend/src/main/java/com/printcalculator/controller/admin/AdminMediaController.java new file mode 100644 index 0000000..df5d06f --- /dev/null +++ b/backend/src/main/java/com/printcalculator/controller/admin/AdminMediaController.java @@ -0,0 +1,82 @@ +package com.printcalculator.controller.admin; + +import com.printcalculator.dto.AdminCreateMediaUsageRequest; +import com.printcalculator.dto.AdminMediaAssetDto; +import com.printcalculator.dto.AdminMediaUsageDto; +import com.printcalculator.dto.AdminUpdateMediaAssetRequest; +import com.printcalculator.dto.AdminUpdateMediaUsageRequest; +import com.printcalculator.service.admin.AdminMediaControllerService; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.multipart.MultipartFile; + +import java.util.List; +import java.util.UUID; + +@RestController +@RequestMapping("/api/admin/media") +@Transactional(readOnly = true) +public class AdminMediaController { + + private final AdminMediaControllerService adminMediaControllerService; + + public AdminMediaController(AdminMediaControllerService adminMediaControllerService) { + this.adminMediaControllerService = adminMediaControllerService; + } + + @PostMapping(value = "/assets", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + @Transactional + public ResponseEntity uploadAsset(@RequestParam("file") MultipartFile file, + @RequestParam(value = "title", required = false) String title, + @RequestParam(value = "altText", required = false) String altText, + @RequestParam(value = "visibility", required = false) String visibility) { + return ResponseEntity.ok(adminMediaControllerService.uploadAsset(file, title, altText, visibility)); + } + + @GetMapping("/assets") + public ResponseEntity> listAssets() { + return ResponseEntity.ok(adminMediaControllerService.listAssets()); + } + + @GetMapping("/assets/{mediaAssetId}") + public ResponseEntity getAsset(@PathVariable UUID mediaAssetId) { + return ResponseEntity.ok(adminMediaControllerService.getAsset(mediaAssetId)); + } + + @PatchMapping("/assets/{mediaAssetId}") + @Transactional + public ResponseEntity updateAsset(@PathVariable UUID mediaAssetId, + @RequestBody AdminUpdateMediaAssetRequest payload) { + return ResponseEntity.ok(adminMediaControllerService.updateAsset(mediaAssetId, payload)); + } + + @PostMapping("/usages") + @Transactional + public ResponseEntity createUsage(@RequestBody AdminCreateMediaUsageRequest payload) { + return ResponseEntity.ok(adminMediaControllerService.createUsage(payload)); + } + + @PatchMapping("/usages/{mediaUsageId}") + @Transactional + public ResponseEntity updateUsage(@PathVariable UUID mediaUsageId, + @RequestBody AdminUpdateMediaUsageRequest payload) { + return ResponseEntity.ok(adminMediaControllerService.updateUsage(mediaUsageId, payload)); + } + + @DeleteMapping("/usages/{mediaUsageId}") + @Transactional + public ResponseEntity deleteUsage(@PathVariable UUID mediaUsageId) { + adminMediaControllerService.deleteUsage(mediaUsageId); + return ResponseEntity.noContent().build(); + } +} diff --git a/backend/src/main/java/com/printcalculator/dto/AdminCreateMediaUsageRequest.java b/backend/src/main/java/com/printcalculator/dto/AdminCreateMediaUsageRequest.java new file mode 100644 index 0000000..9580b29 --- /dev/null +++ b/backend/src/main/java/com/printcalculator/dto/AdminCreateMediaUsageRequest.java @@ -0,0 +1,79 @@ +package com.printcalculator.dto; + +import java.util.UUID; +import java.util.Map; + +public class AdminCreateMediaUsageRequest { + private String usageType; + private String usageKey; + private UUID ownerId; + private UUID mediaAssetId; + private Integer sortOrder; + private Boolean isPrimary; + private Boolean isActive; + private Map translations; + + public String getUsageType() { + return usageType; + } + + public void setUsageType(String usageType) { + this.usageType = usageType; + } + + public String getUsageKey() { + return usageKey; + } + + public void setUsageKey(String usageKey) { + this.usageKey = usageKey; + } + + public UUID getOwnerId() { + return ownerId; + } + + public void setOwnerId(UUID ownerId) { + this.ownerId = ownerId; + } + + public UUID getMediaAssetId() { + return mediaAssetId; + } + + public void setMediaAssetId(UUID mediaAssetId) { + this.mediaAssetId = mediaAssetId; + } + + public Integer getSortOrder() { + return sortOrder; + } + + public void setSortOrder(Integer sortOrder) { + this.sortOrder = sortOrder; + } + + public Boolean getIsPrimary() { + return isPrimary; + } + + public void setIsPrimary(Boolean primary) { + isPrimary = primary; + } + + public Boolean getIsActive() { + return isActive; + } + + public void setIsActive(Boolean active) { + isActive = active; + } + + public Map getTranslations() { + return translations; + } + + public void setTranslations(Map translations) { + this.translations = translations; + } +} diff --git a/backend/src/main/java/com/printcalculator/dto/AdminMediaAssetDto.java b/backend/src/main/java/com/printcalculator/dto/AdminMediaAssetDto.java new file mode 100644 index 0000000..d934610 --- /dev/null +++ b/backend/src/main/java/com/printcalculator/dto/AdminMediaAssetDto.java @@ -0,0 +1,153 @@ +package com.printcalculator.dto; + +import java.time.OffsetDateTime; +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; + +public class AdminMediaAssetDto { + private UUID id; + private String originalFilename; + private String storageKey; + private String mimeType; + private Long fileSizeBytes; + private String sha256Hex; + private Integer widthPx; + private Integer heightPx; + private String status; + private String visibility; + private String title; + private String altText; + private OffsetDateTime createdAt; + private OffsetDateTime updatedAt; + private List variants = new ArrayList<>(); + private List usages = new ArrayList<>(); + + public UUID getId() { + return id; + } + + public void setId(UUID id) { + this.id = id; + } + + public String getOriginalFilename() { + return originalFilename; + } + + public void setOriginalFilename(String originalFilename) { + this.originalFilename = originalFilename; + } + + public String getStorageKey() { + return storageKey; + } + + public void setStorageKey(String storageKey) { + this.storageKey = storageKey; + } + + public String getMimeType() { + return mimeType; + } + + public void setMimeType(String mimeType) { + this.mimeType = mimeType; + } + + public Long getFileSizeBytes() { + return fileSizeBytes; + } + + public void setFileSizeBytes(Long fileSizeBytes) { + this.fileSizeBytes = fileSizeBytes; + } + + public String getSha256Hex() { + return sha256Hex; + } + + public void setSha256Hex(String sha256Hex) { + this.sha256Hex = sha256Hex; + } + + public Integer getWidthPx() { + return widthPx; + } + + public void setWidthPx(Integer widthPx) { + this.widthPx = widthPx; + } + + public Integer getHeightPx() { + return heightPx; + } + + public void setHeightPx(Integer heightPx) { + this.heightPx = heightPx; + } + + public String getStatus() { + return status; + } + + public void setStatus(String status) { + this.status = status; + } + + public String getVisibility() { + return visibility; + } + + public void setVisibility(String visibility) { + this.visibility = visibility; + } + + public String getTitle() { + return title; + } + + public void setTitle(String title) { + this.title = title; + } + + public String getAltText() { + return altText; + } + + public void setAltText(String altText) { + this.altText = altText; + } + + public OffsetDateTime getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(OffsetDateTime createdAt) { + this.createdAt = createdAt; + } + + public OffsetDateTime getUpdatedAt() { + return updatedAt; + } + + public void setUpdatedAt(OffsetDateTime updatedAt) { + this.updatedAt = updatedAt; + } + + public List getVariants() { + return variants; + } + + public void setVariants(List variants) { + this.variants = variants; + } + + public List getUsages() { + return usages; + } + + public void setUsages(List usages) { + this.usages = usages; + } +} diff --git a/backend/src/main/java/com/printcalculator/dto/AdminMediaUsageDto.java b/backend/src/main/java/com/printcalculator/dto/AdminMediaUsageDto.java new file mode 100644 index 0000000..7d16330 --- /dev/null +++ b/backend/src/main/java/com/printcalculator/dto/AdminMediaUsageDto.java @@ -0,0 +1,98 @@ +package com.printcalculator.dto; + +import java.time.OffsetDateTime; +import java.util.Map; +import java.util.UUID; + +public class AdminMediaUsageDto { + private UUID id; + private String usageType; + private String usageKey; + private UUID ownerId; + private UUID mediaAssetId; + private Integer sortOrder; + private Boolean isPrimary; + private Boolean isActive; + private Map translations; + private OffsetDateTime createdAt; + + public UUID getId() { + return id; + } + + public void setId(UUID id) { + this.id = id; + } + + public String getUsageType() { + return usageType; + } + + public void setUsageType(String usageType) { + this.usageType = usageType; + } + + public String getUsageKey() { + return usageKey; + } + + public void setUsageKey(String usageKey) { + this.usageKey = usageKey; + } + + public UUID getOwnerId() { + return ownerId; + } + + public void setOwnerId(UUID ownerId) { + this.ownerId = ownerId; + } + + public UUID getMediaAssetId() { + return mediaAssetId; + } + + public void setMediaAssetId(UUID mediaAssetId) { + this.mediaAssetId = mediaAssetId; + } + + public Integer getSortOrder() { + return sortOrder; + } + + public void setSortOrder(Integer sortOrder) { + this.sortOrder = sortOrder; + } + + public Boolean getIsPrimary() { + return isPrimary; + } + + public void setIsPrimary(Boolean primary) { + isPrimary = primary; + } + + public Boolean getIsActive() { + return isActive; + } + + public void setIsActive(Boolean active) { + isActive = active; + } + + public Map getTranslations() { + return translations; + } + + public void setTranslations(Map translations) { + this.translations = translations; + } + + public OffsetDateTime getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(OffsetDateTime createdAt) { + this.createdAt = createdAt; + } +} diff --git a/backend/src/main/java/com/printcalculator/dto/AdminMediaVariantDto.java b/backend/src/main/java/com/printcalculator/dto/AdminMediaVariantDto.java new file mode 100644 index 0000000..6a200bf --- /dev/null +++ b/backend/src/main/java/com/printcalculator/dto/AdminMediaVariantDto.java @@ -0,0 +1,106 @@ +package com.printcalculator.dto; + +import java.time.OffsetDateTime; +import java.util.UUID; + +public class AdminMediaVariantDto { + private UUID id; + private String variantName; + private String format; + private String storageKey; + private String mimeType; + private Integer widthPx; + private Integer heightPx; + private Long fileSizeBytes; + private Boolean isGenerated; + private String publicUrl; + private OffsetDateTime createdAt; + + public UUID getId() { + return id; + } + + public void setId(UUID id) { + this.id = id; + } + + public String getVariantName() { + return variantName; + } + + public void setVariantName(String variantName) { + this.variantName = variantName; + } + + public String getFormat() { + return format; + } + + public void setFormat(String format) { + this.format = format; + } + + public String getStorageKey() { + return storageKey; + } + + public void setStorageKey(String storageKey) { + this.storageKey = storageKey; + } + + public String getMimeType() { + return mimeType; + } + + public void setMimeType(String mimeType) { + this.mimeType = mimeType; + } + + public Integer getWidthPx() { + return widthPx; + } + + public void setWidthPx(Integer widthPx) { + this.widthPx = widthPx; + } + + public Integer getHeightPx() { + return heightPx; + } + + public void setHeightPx(Integer heightPx) { + this.heightPx = heightPx; + } + + public Long getFileSizeBytes() { + return fileSizeBytes; + } + + public void setFileSizeBytes(Long fileSizeBytes) { + this.fileSizeBytes = fileSizeBytes; + } + + public Boolean getIsGenerated() { + return isGenerated; + } + + public void setIsGenerated(Boolean generated) { + isGenerated = generated; + } + + public String getPublicUrl() { + return publicUrl; + } + + public void setPublicUrl(String publicUrl) { + this.publicUrl = publicUrl; + } + + public OffsetDateTime getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(OffsetDateTime createdAt) { + this.createdAt = createdAt; + } +} diff --git a/backend/src/main/java/com/printcalculator/dto/AdminUpdateMediaAssetRequest.java b/backend/src/main/java/com/printcalculator/dto/AdminUpdateMediaAssetRequest.java new file mode 100644 index 0000000..2d3d8ff --- /dev/null +++ b/backend/src/main/java/com/printcalculator/dto/AdminUpdateMediaAssetRequest.java @@ -0,0 +1,40 @@ +package com.printcalculator.dto; + +public class AdminUpdateMediaAssetRequest { + private String title; + private String altText; + private String visibility; + private String status; + + public String getTitle() { + return title; + } + + public void setTitle(String title) { + this.title = title; + } + + public String getAltText() { + return altText; + } + + public void setAltText(String altText) { + this.altText = altText; + } + + public String getVisibility() { + return visibility; + } + + public void setVisibility(String visibility) { + this.visibility = visibility; + } + + public String getStatus() { + return status; + } + + public void setStatus(String status) { + this.status = status; + } +} diff --git a/backend/src/main/java/com/printcalculator/dto/AdminUpdateMediaUsageRequest.java b/backend/src/main/java/com/printcalculator/dto/AdminUpdateMediaUsageRequest.java new file mode 100644 index 0000000..aa4c8bc --- /dev/null +++ b/backend/src/main/java/com/printcalculator/dto/AdminUpdateMediaUsageRequest.java @@ -0,0 +1,79 @@ +package com.printcalculator.dto; + +import java.util.UUID; +import java.util.Map; + +public class AdminUpdateMediaUsageRequest { + private String usageType; + private String usageKey; + private UUID ownerId; + private UUID mediaAssetId; + private Integer sortOrder; + private Boolean isPrimary; + private Boolean isActive; + private Map translations; + + public String getUsageType() { + return usageType; + } + + public void setUsageType(String usageType) { + this.usageType = usageType; + } + + public String getUsageKey() { + return usageKey; + } + + public void setUsageKey(String usageKey) { + this.usageKey = usageKey; + } + + public UUID getOwnerId() { + return ownerId; + } + + public void setOwnerId(UUID ownerId) { + this.ownerId = ownerId; + } + + public UUID getMediaAssetId() { + return mediaAssetId; + } + + public void setMediaAssetId(UUID mediaAssetId) { + this.mediaAssetId = mediaAssetId; + } + + public Integer getSortOrder() { + return sortOrder; + } + + public void setSortOrder(Integer sortOrder) { + this.sortOrder = sortOrder; + } + + public Boolean getIsPrimary() { + return isPrimary; + } + + public void setIsPrimary(Boolean primary) { + isPrimary = primary; + } + + public Boolean getIsActive() { + return isActive; + } + + public void setIsActive(Boolean active) { + isActive = active; + } + + public Map getTranslations() { + return translations; + } + + public void setTranslations(Map translations) { + this.translations = translations; + } +} diff --git a/backend/src/main/java/com/printcalculator/dto/MediaTextTranslationDto.java b/backend/src/main/java/com/printcalculator/dto/MediaTextTranslationDto.java new file mode 100644 index 0000000..3771af3 --- /dev/null +++ b/backend/src/main/java/com/printcalculator/dto/MediaTextTranslationDto.java @@ -0,0 +1,22 @@ +package com.printcalculator.dto; + +public class MediaTextTranslationDto { + private String title; + private String altText; + + public String getTitle() { + return title; + } + + public void setTitle(String title) { + this.title = title; + } + + public String getAltText() { + return altText; + } + + public void setAltText(String altText) { + this.altText = altText; + } +} diff --git a/backend/src/main/java/com/printcalculator/dto/PublicMediaUsageDto.java b/backend/src/main/java/com/printcalculator/dto/PublicMediaUsageDto.java new file mode 100644 index 0000000..6672c84 --- /dev/null +++ b/backend/src/main/java/com/printcalculator/dto/PublicMediaUsageDto.java @@ -0,0 +1,96 @@ +package com.printcalculator.dto; + +import java.util.UUID; + +public class PublicMediaUsageDto { + private UUID mediaAssetId; + private String title; + private String altText; + private String usageType; + private String usageKey; + private Integer sortOrder; + private Boolean isPrimary; + private PublicMediaVariantDto thumb; + private PublicMediaVariantDto card; + private PublicMediaVariantDto hero; + + public UUID getMediaAssetId() { + return mediaAssetId; + } + + public void setMediaAssetId(UUID mediaAssetId) { + this.mediaAssetId = mediaAssetId; + } + + public String getTitle() { + return title; + } + + public void setTitle(String title) { + this.title = title; + } + + public String getAltText() { + return altText; + } + + public void setAltText(String altText) { + this.altText = altText; + } + + public String getUsageType() { + return usageType; + } + + public void setUsageType(String usageType) { + this.usageType = usageType; + } + + public String getUsageKey() { + return usageKey; + } + + public void setUsageKey(String usageKey) { + this.usageKey = usageKey; + } + + public Integer getSortOrder() { + return sortOrder; + } + + public void setSortOrder(Integer sortOrder) { + this.sortOrder = sortOrder; + } + + public Boolean getIsPrimary() { + return isPrimary; + } + + public void setIsPrimary(Boolean primary) { + isPrimary = primary; + } + + public PublicMediaVariantDto getThumb() { + return thumb; + } + + public void setThumb(PublicMediaVariantDto thumb) { + this.thumb = thumb; + } + + public PublicMediaVariantDto getCard() { + return card; + } + + public void setCard(PublicMediaVariantDto card) { + this.card = card; + } + + public PublicMediaVariantDto getHero() { + return hero; + } + + public void setHero(PublicMediaVariantDto hero) { + this.hero = hero; + } +} diff --git a/backend/src/main/java/com/printcalculator/dto/PublicMediaVariantDto.java b/backend/src/main/java/com/printcalculator/dto/PublicMediaVariantDto.java new file mode 100644 index 0000000..173bd75 --- /dev/null +++ b/backend/src/main/java/com/printcalculator/dto/PublicMediaVariantDto.java @@ -0,0 +1,31 @@ +package com.printcalculator.dto; + +public class PublicMediaVariantDto { + private String avifUrl; + private String webpUrl; + private String jpegUrl; + + public String getAvifUrl() { + return avifUrl; + } + + public void setAvifUrl(String avifUrl) { + this.avifUrl = avifUrl; + } + + public String getWebpUrl() { + return webpUrl; + } + + public void setWebpUrl(String webpUrl) { + this.webpUrl = webpUrl; + } + + public String getJpegUrl() { + return jpegUrl; + } + + public void setJpegUrl(String jpegUrl) { + this.jpegUrl = jpegUrl; + } +} diff --git a/backend/src/main/java/com/printcalculator/entity/MediaAsset.java b/backend/src/main/java/com/printcalculator/entity/MediaAsset.java new file mode 100644 index 0000000..9c26d00 --- /dev/null +++ b/backend/src/main/java/com/printcalculator/entity/MediaAsset.java @@ -0,0 +1,177 @@ +package com.printcalculator.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Index; +import jakarta.persistence.Table; +import org.hibernate.annotations.ColumnDefault; + +import java.time.OffsetDateTime; +import java.util.UUID; + +@Entity +@Table(name = "media_asset", indexes = { + @Index(name = "ix_media_asset_status_visibility_created_at", columnList = "status, visibility, created_at") +}) +public class MediaAsset { + @Id + @GeneratedValue(strategy = GenerationType.AUTO) + @Column(name = "media_asset_id", nullable = false) + private UUID id; + + @Column(name = "original_filename", nullable = false, length = Integer.MAX_VALUE) + private String originalFilename; + + @Column(name = "storage_key", nullable = false, length = Integer.MAX_VALUE, unique = true) + private String storageKey; + + @Column(name = "mime_type", nullable = false, length = Integer.MAX_VALUE) + private String mimeType; + + @Column(name = "file_size_bytes", nullable = false) + private Long fileSizeBytes; + + @Column(name = "sha256_hex", nullable = false, length = Integer.MAX_VALUE) + private String sha256Hex; + + @Column(name = "width_px") + private Integer widthPx; + + @Column(name = "height_px") + private Integer heightPx; + + @Column(name = "status", nullable = false, length = Integer.MAX_VALUE) + private String status; + + @Column(name = "visibility", nullable = false, length = Integer.MAX_VALUE) + private String visibility; + + @Column(name = "title", length = Integer.MAX_VALUE) + private String title; + + @Column(name = "alt_text", length = Integer.MAX_VALUE) + private String altText; + + @ColumnDefault("now()") + @Column(name = "created_at", nullable = false) + private OffsetDateTime createdAt; + + @ColumnDefault("now()") + @Column(name = "updated_at", nullable = false) + private OffsetDateTime updatedAt; + + public UUID getId() { + return id; + } + + public void setId(UUID id) { + this.id = id; + } + + public String getOriginalFilename() { + return originalFilename; + } + + public void setOriginalFilename(String originalFilename) { + this.originalFilename = originalFilename; + } + + public String getStorageKey() { + return storageKey; + } + + public void setStorageKey(String storageKey) { + this.storageKey = storageKey; + } + + public String getMimeType() { + return mimeType; + } + + public void setMimeType(String mimeType) { + this.mimeType = mimeType; + } + + public Long getFileSizeBytes() { + return fileSizeBytes; + } + + public void setFileSizeBytes(Long fileSizeBytes) { + this.fileSizeBytes = fileSizeBytes; + } + + public String getSha256Hex() { + return sha256Hex; + } + + public void setSha256Hex(String sha256Hex) { + this.sha256Hex = sha256Hex; + } + + public Integer getWidthPx() { + return widthPx; + } + + public void setWidthPx(Integer widthPx) { + this.widthPx = widthPx; + } + + public Integer getHeightPx() { + return heightPx; + } + + public void setHeightPx(Integer heightPx) { + this.heightPx = heightPx; + } + + public String getStatus() { + return status; + } + + public void setStatus(String status) { + this.status = status; + } + + public String getVisibility() { + return visibility; + } + + public void setVisibility(String visibility) { + this.visibility = visibility; + } + + public String getTitle() { + return title; + } + + public void setTitle(String title) { + this.title = title; + } + + public String getAltText() { + return altText; + } + + public void setAltText(String altText) { + this.altText = altText; + } + + public OffsetDateTime getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(OffsetDateTime createdAt) { + this.createdAt = createdAt; + } + + public OffsetDateTime getUpdatedAt() { + return updatedAt; + } + + public void setUpdatedAt(OffsetDateTime updatedAt) { + this.updatedAt = updatedAt; + } +} diff --git a/backend/src/main/java/com/printcalculator/entity/MediaUsage.java b/backend/src/main/java/com/printcalculator/entity/MediaUsage.java new file mode 100644 index 0000000..4231a7b --- /dev/null +++ b/backend/src/main/java/com/printcalculator/entity/MediaUsage.java @@ -0,0 +1,273 @@ +package com.printcalculator.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Index; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import org.hibernate.annotations.ColumnDefault; +import org.hibernate.annotations.OnDelete; +import org.hibernate.annotations.OnDeleteAction; + +import java.time.OffsetDateTime; +import java.util.UUID; + +@Entity +@Table(name = "media_usage", indexes = { + @Index(name = "ix_media_usage_scope", columnList = "usage_type, usage_key, is_active, sort_order") +}) +public class MediaUsage { + @Id + @GeneratedValue(strategy = GenerationType.AUTO) + @Column(name = "media_usage_id", nullable = false) + private UUID id; + + @Column(name = "usage_type", nullable = false, length = Integer.MAX_VALUE) + private String usageType; + + @Column(name = "usage_key", nullable = false, length = Integer.MAX_VALUE) + private String usageKey; + + @Column(name = "owner_id") + private UUID ownerId; + + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @OnDelete(action = OnDeleteAction.CASCADE) + @JoinColumn(name = "media_asset_id", nullable = false) + private MediaAsset mediaAsset; + + @ColumnDefault("0") + @Column(name = "sort_order", nullable = false) + private Integer sortOrder; + + @ColumnDefault("false") + @Column(name = "is_primary", nullable = false) + private Boolean isPrimary; + + @ColumnDefault("true") + @Column(name = "is_active", nullable = false) + private Boolean isActive; + + @Column(name = "title_it", length = Integer.MAX_VALUE) + private String titleIt; + + @Column(name = "title_en", length = Integer.MAX_VALUE) + private String titleEn; + + @Column(name = "title_de", length = Integer.MAX_VALUE) + private String titleDe; + + @Column(name = "title_fr", length = Integer.MAX_VALUE) + private String titleFr; + + @Column(name = "alt_text_it", length = Integer.MAX_VALUE) + private String altTextIt; + + @Column(name = "alt_text_en", length = Integer.MAX_VALUE) + private String altTextEn; + + @Column(name = "alt_text_de", length = Integer.MAX_VALUE) + private String altTextDe; + + @Column(name = "alt_text_fr", length = Integer.MAX_VALUE) + private String altTextFr; + + @ColumnDefault("now()") + @Column(name = "created_at", nullable = false) + private OffsetDateTime createdAt; + + public UUID getId() { + return id; + } + + public void setId(UUID id) { + this.id = id; + } + + public String getUsageType() { + return usageType; + } + + public void setUsageType(String usageType) { + this.usageType = usageType; + } + + public String getUsageKey() { + return usageKey; + } + + public void setUsageKey(String usageKey) { + this.usageKey = usageKey; + } + + public UUID getOwnerId() { + return ownerId; + } + + public void setOwnerId(UUID ownerId) { + this.ownerId = ownerId; + } + + public MediaAsset getMediaAsset() { + return mediaAsset; + } + + public void setMediaAsset(MediaAsset mediaAsset) { + this.mediaAsset = mediaAsset; + } + + public Integer getSortOrder() { + return sortOrder; + } + + public void setSortOrder(Integer sortOrder) { + this.sortOrder = sortOrder; + } + + public Boolean getIsPrimary() { + return isPrimary; + } + + public void setIsPrimary(Boolean primary) { + isPrimary = primary; + } + + public Boolean getIsActive() { + return isActive; + } + + public void setIsActive(Boolean active) { + isActive = active; + } + + public String getTitleIt() { + return titleIt; + } + + public void setTitleIt(String titleIt) { + this.titleIt = titleIt; + } + + public String getTitleEn() { + return titleEn; + } + + public void setTitleEn(String titleEn) { + this.titleEn = titleEn; + } + + public String getTitleDe() { + return titleDe; + } + + public void setTitleDe(String titleDe) { + this.titleDe = titleDe; + } + + public String getTitleFr() { + return titleFr; + } + + public void setTitleFr(String titleFr) { + this.titleFr = titleFr; + } + + public String getAltTextIt() { + return altTextIt; + } + + public void setAltTextIt(String altTextIt) { + this.altTextIt = altTextIt; + } + + public String getAltTextEn() { + return altTextEn; + } + + public void setAltTextEn(String altTextEn) { + this.altTextEn = altTextEn; + } + + public String getAltTextDe() { + return altTextDe; + } + + public void setAltTextDe(String altTextDe) { + this.altTextDe = altTextDe; + } + + public String getAltTextFr() { + return altTextFr; + } + + public void setAltTextFr(String altTextFr) { + this.altTextFr = altTextFr; + } + + public OffsetDateTime getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(OffsetDateTime createdAt) { + this.createdAt = createdAt; + } + + public String getTitleForLanguage(String language) { + if (language == null) { + return null; + } + return switch (language.trim().toLowerCase()) { + case "it" -> titleIt; + case "en" -> titleEn; + case "de" -> titleDe; + case "fr" -> titleFr; + default -> null; + }; + } + + public void setTitleForLanguage(String language, String value) { + if (language == null) { + return; + } + switch (language.trim().toLowerCase()) { + case "it" -> titleIt = value; + case "en" -> titleEn = value; + case "de" -> titleDe = value; + case "fr" -> titleFr = value; + default -> { + } + } + } + + public String getAltTextForLanguage(String language) { + if (language == null) { + return null; + } + return switch (language.trim().toLowerCase()) { + case "it" -> altTextIt; + case "en" -> altTextEn; + case "de" -> altTextDe; + case "fr" -> altTextFr; + default -> null; + }; + } + + public void setAltTextForLanguage(String language, String value) { + if (language == null) { + return; + } + switch (language.trim().toLowerCase()) { + case "it" -> altTextIt = value; + case "en" -> altTextEn = value; + case "de" -> altTextDe = value; + case "fr" -> altTextFr = value; + default -> { + } + } + } +} diff --git a/backend/src/main/java/com/printcalculator/entity/MediaVariant.java b/backend/src/main/java/com/printcalculator/entity/MediaVariant.java new file mode 100644 index 0000000..e5a757d --- /dev/null +++ b/backend/src/main/java/com/printcalculator/entity/MediaVariant.java @@ -0,0 +1,154 @@ +package com.printcalculator.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Index; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; +import org.hibernate.annotations.ColumnDefault; +import org.hibernate.annotations.OnDelete; +import org.hibernate.annotations.OnDeleteAction; + +import java.time.OffsetDateTime; +import java.util.UUID; + +@Entity +@Table(name = "media_variant", indexes = { + @Index(name = "ix_media_variant_asset", columnList = "media_asset_id") +}, uniqueConstraints = { + @UniqueConstraint(name = "uq_media_variant_asset_name_format", columnNames = {"media_asset_id", "variant_name", "format"}) +}) +public class MediaVariant { + @Id + @GeneratedValue(strategy = GenerationType.AUTO) + @Column(name = "media_variant_id", nullable = false) + private UUID id; + + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @OnDelete(action = OnDeleteAction.CASCADE) + @JoinColumn(name = "media_asset_id", nullable = false) + private MediaAsset mediaAsset; + + @Column(name = "variant_name", nullable = false, length = Integer.MAX_VALUE) + private String variantName; + + @Column(name = "format", nullable = false, length = Integer.MAX_VALUE) + private String format; + + @Column(name = "storage_key", nullable = false, length = Integer.MAX_VALUE, unique = true) + private String storageKey; + + @Column(name = "mime_type", nullable = false, length = Integer.MAX_VALUE) + private String mimeType; + + @Column(name = "width_px", nullable = false) + private Integer widthPx; + + @Column(name = "height_px", nullable = false) + private Integer heightPx; + + @Column(name = "file_size_bytes", nullable = false) + private Long fileSizeBytes; + + @ColumnDefault("true") + @Column(name = "is_generated", nullable = false) + private Boolean isGenerated; + + @ColumnDefault("now()") + @Column(name = "created_at", nullable = false) + private OffsetDateTime createdAt; + + public UUID getId() { + return id; + } + + public void setId(UUID id) { + this.id = id; + } + + public MediaAsset getMediaAsset() { + return mediaAsset; + } + + public void setMediaAsset(MediaAsset mediaAsset) { + this.mediaAsset = mediaAsset; + } + + public String getVariantName() { + return variantName; + } + + public void setVariantName(String variantName) { + this.variantName = variantName; + } + + public String getFormat() { + return format; + } + + public void setFormat(String format) { + this.format = format; + } + + public String getStorageKey() { + return storageKey; + } + + public void setStorageKey(String storageKey) { + this.storageKey = storageKey; + } + + public String getMimeType() { + return mimeType; + } + + public void setMimeType(String mimeType) { + this.mimeType = mimeType; + } + + public Integer getWidthPx() { + return widthPx; + } + + public void setWidthPx(Integer widthPx) { + this.widthPx = widthPx; + } + + public Integer getHeightPx() { + return heightPx; + } + + public void setHeightPx(Integer heightPx) { + this.heightPx = heightPx; + } + + public Long getFileSizeBytes() { + return fileSizeBytes; + } + + public void setFileSizeBytes(Long fileSizeBytes) { + this.fileSizeBytes = fileSizeBytes; + } + + public Boolean getIsGenerated() { + return isGenerated; + } + + public void setIsGenerated(Boolean generated) { + isGenerated = generated; + } + + public OffsetDateTime getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(OffsetDateTime createdAt) { + this.createdAt = createdAt; + } +} diff --git a/backend/src/main/java/com/printcalculator/repository/MediaAssetRepository.java b/backend/src/main/java/com/printcalculator/repository/MediaAssetRepository.java new file mode 100644 index 0000000..e41b95a --- /dev/null +++ b/backend/src/main/java/com/printcalculator/repository/MediaAssetRepository.java @@ -0,0 +1,11 @@ +package com.printcalculator.repository; + +import com.printcalculator.entity.MediaAsset; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; +import java.util.UUID; + +public interface MediaAssetRepository extends JpaRepository { + List findAllByOrderByCreatedAtDesc(); +} diff --git a/backend/src/main/java/com/printcalculator/repository/MediaUsageRepository.java b/backend/src/main/java/com/printcalculator/repository/MediaUsageRepository.java new file mode 100644 index 0000000..08bea80 --- /dev/null +++ b/backend/src/main/java/com/printcalculator/repository/MediaUsageRepository.java @@ -0,0 +1,30 @@ +package com.printcalculator.repository; + +import com.printcalculator.entity.MediaUsage; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.Collection; +import java.util.List; +import java.util.UUID; + +public interface MediaUsageRepository extends JpaRepository { + List findByMediaAsset_IdOrderBySortOrderAscCreatedAtAsc(UUID mediaAssetId); + + List findByMediaAsset_IdIn(Collection mediaAssetIds); + + List findByUsageTypeAndUsageKeyAndIsActiveTrueOrderBySortOrderAscCreatedAtAsc(String usageType, + String usageKey); + + @Query(""" + select usage from MediaUsage usage + where usage.usageType = :usageType + and usage.usageKey = :usageKey + and ((:ownerId is null and usage.ownerId is null) or usage.ownerId = :ownerId) + order by usage.sortOrder asc, usage.createdAt asc + """) + List findByUsageScope(@Param("usageType") String usageType, + @Param("usageKey") String usageKey, + @Param("ownerId") UUID ownerId); +} diff --git a/backend/src/main/java/com/printcalculator/repository/MediaVariantRepository.java b/backend/src/main/java/com/printcalculator/repository/MediaVariantRepository.java new file mode 100644 index 0000000..013346d --- /dev/null +++ b/backend/src/main/java/com/printcalculator/repository/MediaVariantRepository.java @@ -0,0 +1,14 @@ +package com.printcalculator.repository; + +import com.printcalculator.entity.MediaVariant; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Collection; +import java.util.List; +import java.util.UUID; + +public interface MediaVariantRepository extends JpaRepository { + List findByMediaAsset_IdOrderByCreatedAtAsc(UUID mediaAssetId); + + List findByMediaAsset_IdIn(Collection mediaAssetIds); +} diff --git a/backend/src/main/java/com/printcalculator/service/admin/AdminMediaControllerService.java b/backend/src/main/java/com/printcalculator/service/admin/AdminMediaControllerService.java new file mode 100644 index 0000000..0e30a60 --- /dev/null +++ b/backend/src/main/java/com/printcalculator/service/admin/AdminMediaControllerService.java @@ -0,0 +1,838 @@ +package com.printcalculator.service.admin; + +import com.printcalculator.dto.AdminCreateMediaUsageRequest; +import com.printcalculator.dto.AdminMediaAssetDto; +import com.printcalculator.dto.AdminMediaUsageDto; +import com.printcalculator.dto.AdminMediaVariantDto; +import com.printcalculator.dto.MediaTextTranslationDto; +import com.printcalculator.dto.AdminUpdateMediaAssetRequest; +import com.printcalculator.dto.AdminUpdateMediaUsageRequest; +import com.printcalculator.entity.MediaAsset; +import com.printcalculator.entity.MediaUsage; +import com.printcalculator.entity.MediaVariant; +import com.printcalculator.repository.MediaAssetRepository; +import com.printcalculator.repository.MediaUsageRepository; +import com.printcalculator.repository.MediaVariantRepository; +import com.printcalculator.service.media.MediaFfmpegService; +import com.printcalculator.service.media.MediaImageInspector; +import com.printcalculator.service.media.MediaStorageService; +import com.printcalculator.service.storage.ClamAVService; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.StringUtils; +import org.springframework.web.multipart.MultipartFile; +import org.springframework.web.server.ResponseStatusException; + +import java.io.IOException; +import java.io.InputStream; +import java.io.UncheckedIOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.time.LocalDate; +import java.time.OffsetDateTime; +import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Comparator; +import java.util.HexFormat; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.UUID; +import java.util.stream.Collectors; + +@Service +@Transactional(readOnly = true) +public class AdminMediaControllerService { + + private static final Logger logger = LoggerFactory.getLogger(AdminMediaControllerService.class); + + private static final String STATUS_UPLOADED = "UPLOADED"; + private static final String STATUS_PROCESSING = "PROCESSING"; + private static final String STATUS_READY = "READY"; + private static final String STATUS_FAILED = "FAILED"; + private static final String STATUS_ARCHIVED = "ARCHIVED"; + + private static final String VISIBILITY_PUBLIC = "PUBLIC"; + private static final String VISIBILITY_PRIVATE = "PRIVATE"; + + private static final String FORMAT_ORIGINAL = "ORIGINAL"; + private static final String FORMAT_JPEG = "JPEG"; + private static final String FORMAT_WEBP = "WEBP"; + private static final String FORMAT_AVIF = "AVIF"; + + private static final Set ALLOWED_STATUSES = Set.of( + STATUS_UPLOADED, STATUS_PROCESSING, STATUS_READY, STATUS_FAILED, STATUS_ARCHIVED + ); + private static final Set ALLOWED_VISIBILITIES = Set.of(VISIBILITY_PUBLIC, VISIBILITY_PRIVATE); + private static final Set ALLOWED_UPLOAD_MIME_TYPES = Set.of( + "image/jpeg", "image/png", "image/webp" + ); + private static final List SUPPORTED_MEDIA_LANGUAGES = List.of("it", "en", "de", "fr"); + private static final Map GENERATED_FORMAT_MIME_TYPES = Map.of( + FORMAT_JPEG, "image/jpeg", + FORMAT_WEBP, "image/webp", + FORMAT_AVIF, "image/avif" + ); + private static final Map GENERATED_FORMAT_EXTENSIONS = Map.of( + FORMAT_JPEG, "jpg", + FORMAT_WEBP, "webp", + FORMAT_AVIF, "avif" + ); + private static final List PRESETS = List.of( + new PresetDefinition("thumb", 320), + new PresetDefinition("card", 640), + new PresetDefinition("hero", 1280) + ); + private static final DateTimeFormatter STORAGE_FOLDER_FORMATTER = DateTimeFormatter.ofPattern("yyyy/MM"); + + private final MediaAssetRepository mediaAssetRepository; + private final MediaVariantRepository mediaVariantRepository; + private final MediaUsageRepository mediaUsageRepository; + private final MediaStorageService mediaStorageService; + private final MediaImageInspector mediaImageInspector; + private final MediaFfmpegService mediaFfmpegService; + private final ClamAVService clamAVService; + private final long maxUploadFileSizeBytes; + + public AdminMediaControllerService(MediaAssetRepository mediaAssetRepository, + MediaVariantRepository mediaVariantRepository, + MediaUsageRepository mediaUsageRepository, + MediaStorageService mediaStorageService, + MediaImageInspector mediaImageInspector, + MediaFfmpegService mediaFfmpegService, + ClamAVService clamAVService, + @Value("${media.upload.max-file-size-bytes:26214400}") long maxUploadFileSizeBytes) { + this.mediaAssetRepository = mediaAssetRepository; + this.mediaVariantRepository = mediaVariantRepository; + this.mediaUsageRepository = mediaUsageRepository; + this.mediaStorageService = mediaStorageService; + this.mediaImageInspector = mediaImageInspector; + this.mediaFfmpegService = mediaFfmpegService; + this.clamAVService = clamAVService; + this.maxUploadFileSizeBytes = maxUploadFileSizeBytes; + } + + @Transactional(noRollbackFor = ResponseStatusException.class) + public AdminMediaAssetDto uploadAsset(MultipartFile file, + String title, + String altText, + String visibility) { + validateUpload(file); + + Path tempDirectory = null; + MediaAsset asset = null; + + try { + String normalizedVisibility = normalizeVisibility(visibility, true); + tempDirectory = Files.createTempDirectory("media-asset-"); + Path uploadFile = tempDirectory.resolve("upload.bin"); + file.transferTo(uploadFile); + + try (InputStream inputStream = Files.newInputStream(uploadFile)) { + clamAVService.scan(inputStream); + } + + MediaImageInspector.ImageMetadata metadata = mediaImageInspector.inspect(uploadFile); + if (!ALLOWED_UPLOAD_MIME_TYPES.contains(metadata.mimeType())) { + throw new ResponseStatusException( + HttpStatus.BAD_REQUEST, + "Unsupported image type. Allowed: jpg, jpeg, png, webp." + ); + } + + String storageFolder = buildStorageFolder(); + String originalStorageKey = storageFolder + "/original." + metadata.fileExtension(); + String normalizedFilename = sanitizeOriginalFilename(file.getOriginalFilename(), metadata.fileExtension()); + String normalizedTitle = normalizeText(title); + String normalizedAltText = normalizeText(altText); + long originalFileSize = Files.size(uploadFile); + String sha256Hex = computeSha256(uploadFile); + + mediaStorageService.storeOriginal(uploadFile, originalStorageKey); + + OffsetDateTime now = OffsetDateTime.now(); + asset = new MediaAsset(); + asset.setOriginalFilename(normalizedFilename); + asset.setStorageKey(originalStorageKey); + asset.setMimeType(metadata.mimeType()); + asset.setFileSizeBytes(originalFileSize); + asset.setSha256Hex(sha256Hex); + asset.setWidthPx(metadata.widthPx()); + asset.setHeightPx(metadata.heightPx()); + asset.setStatus(STATUS_UPLOADED); + asset.setVisibility(normalizedVisibility); + asset.setTitle(normalizedTitle); + asset.setAltText(normalizedAltText); + asset.setCreatedAt(now); + asset.setUpdatedAt(now); + asset = mediaAssetRepository.save(asset); + + MediaVariant originalVariant = new MediaVariant(); + originalVariant.setMediaAsset(asset); + originalVariant.setVariantName("original"); + originalVariant.setFormat(FORMAT_ORIGINAL); + originalVariant.setStorageKey(originalStorageKey); + originalVariant.setMimeType(metadata.mimeType()); + originalVariant.setWidthPx(metadata.widthPx()); + originalVariant.setHeightPx(metadata.heightPx()); + originalVariant.setFileSizeBytes(originalFileSize); + originalVariant.setIsGenerated(false); + originalVariant.setCreatedAt(now); + mediaVariantRepository.save(originalVariant); + + asset.setStatus(STATUS_PROCESSING); + asset.setUpdatedAt(OffsetDateTime.now()); + asset = mediaAssetRepository.save(asset); + + List generatedVariants = generateDerivedVariants(asset, uploadFile, tempDirectory); + mediaVariantRepository.saveAll(generatedVariants); + + asset.setStatus(STATUS_READY); + asset.setUpdatedAt(OffsetDateTime.now()); + mediaAssetRepository.save(asset); + + return getAsset(asset.getId()); + } catch (ResponseStatusException e) { + markFailed(asset, e.getReason(), e); + throw e; + } catch (IOException e) { + markFailed(asset, "Media processing failed.", e); + throw new ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "Media processing failed."); + } finally { + deleteRecursively(tempDirectory); + } + } + + public List listAssets() { + return toAssetDtos(mediaAssetRepository.findAllByOrderByCreatedAtDesc()); + } + + public AdminMediaAssetDto getAsset(UUID mediaAssetId) { + MediaAsset asset = getAssetOrThrow(mediaAssetId); + return toAssetDtos(List.of(asset)).getFirst(); + } + + @Transactional(noRollbackFor = ResponseStatusException.class) + public AdminMediaAssetDto updateAsset(UUID mediaAssetId, AdminUpdateMediaAssetRequest payload) { + if (payload == null) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Payload is required."); + } + + MediaAsset asset = getAssetOrThrow(mediaAssetId); + String requestedVisibility = normalizeVisibility(payload.getVisibility(), false); + String requestedStatus = normalizeStatus(payload.getStatus(), false); + + if (requestedVisibility != null && !requestedVisibility.equals(asset.getVisibility())) { + moveGeneratedVariants(asset, requestedVisibility); + asset.setVisibility(requestedVisibility); + } + if (requestedStatus != null) { + asset.setStatus(requestedStatus); + } + if (payload.getTitle() != null) { + asset.setTitle(normalizeText(payload.getTitle())); + } + if (payload.getAltText() != null) { + asset.setAltText(normalizeText(payload.getAltText())); + } + + asset.setUpdatedAt(OffsetDateTime.now()); + mediaAssetRepository.save(asset); + return getAsset(asset.getId()); + } + + @Transactional + public AdminMediaUsageDto createUsage(AdminCreateMediaUsageRequest payload) { + if (payload == null) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Payload is required."); + } + + MediaAsset asset = getAssetOrThrow(payload.getMediaAssetId()); + String usageType = requireUsageType(payload.getUsageType()); + String usageKey = requireUsageKey(payload.getUsageKey()); + boolean isPrimary = Boolean.TRUE.equals(payload.getIsPrimary()); + Map translations = requireTranslations(payload.getTranslations()); + + if (isPrimary) { + unsetPrimaryForScope(usageType, usageKey, payload.getOwnerId(), null); + } + + MediaUsage usage = new MediaUsage(); + usage.setUsageType(usageType); + usage.setUsageKey(usageKey); + usage.setOwnerId(payload.getOwnerId()); + usage.setMediaAsset(asset); + usage.setSortOrder(payload.getSortOrder() != null ? payload.getSortOrder() : 0); + usage.setIsPrimary(isPrimary); + usage.setIsActive(payload.getIsActive() == null || payload.getIsActive()); + usage.setCreatedAt(OffsetDateTime.now()); + applyTranslations(usage, translations); + + MediaUsage saved = mediaUsageRepository.save(usage); + return toUsageDto(saved); + } + + @Transactional + public AdminMediaUsageDto updateUsage(UUID mediaUsageId, AdminUpdateMediaUsageRequest payload) { + if (payload == null) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Payload is required."); + } + + MediaUsage usage = getUsageOrThrow(mediaUsageId); + + if (payload.getUsageType() != null) { + usage.setUsageType(requireUsageType(payload.getUsageType())); + } + if (payload.getUsageKey() != null) { + usage.setUsageKey(requireUsageKey(payload.getUsageKey())); + } + if (payload.getOwnerId() != null) { + usage.setOwnerId(payload.getOwnerId()); + } + if (payload.getMediaAssetId() != null) { + usage.setMediaAsset(getAssetOrThrow(payload.getMediaAssetId())); + } + if (payload.getSortOrder() != null) { + usage.setSortOrder(payload.getSortOrder()); + } + if (payload.getIsActive() != null) { + usage.setIsActive(payload.getIsActive()); + } + if (payload.getIsPrimary() != null) { + usage.setIsPrimary(payload.getIsPrimary()); + } + if (payload.getTranslations() != null) { + applyTranslations(usage, requireTranslations(payload.getTranslations())); + } + + if (Boolean.TRUE.equals(usage.getIsPrimary())) { + unsetPrimaryForScope(usage.getUsageType(), usage.getUsageKey(), usage.getOwnerId(), usage.getId()); + } + + MediaUsage saved = mediaUsageRepository.save(usage); + return toUsageDto(saved); + } + + @Transactional + public void deleteUsage(UUID mediaUsageId) { + mediaUsageRepository.delete(getUsageOrThrow(mediaUsageId)); + } + + private List generateDerivedVariants(MediaAsset asset, Path sourceFile, Path tempDirectory) throws IOException { + Path generatedDirectory = Files.createDirectories(tempDirectory.resolve("generated")); + String storageFolder = extractStorageFolder(asset.getStorageKey()); + + List pendingVariants = new ArrayList<>(); + Set skippedFormats = new LinkedHashSet<>(); + for (PresetDefinition preset : PRESETS) { + VariantDimensions dimensions = computeVariantDimensions( + asset.getWidthPx(), + asset.getHeightPx(), + preset.maxDimension() + ); + + for (String format : List.of(FORMAT_JPEG, FORMAT_WEBP, FORMAT_AVIF)) { + if (!mediaFfmpegService.canEncode(format)) { + skippedFormats.add(format); + continue; + } + String extension = GENERATED_FORMAT_EXTENSIONS.get(format); + Path outputFile = generatedDirectory.resolve(preset.name() + "." + extension); + mediaFfmpegService.generateVariant(sourceFile, outputFile, dimensions.widthPx(), dimensions.heightPx(), format); + + MediaVariant variant = new MediaVariant(); + variant.setMediaAsset(asset); + variant.setVariantName(preset.name()); + variant.setFormat(format); + variant.setStorageKey(storageFolder + "/" + preset.name() + "." + extension); + variant.setMimeType(GENERATED_FORMAT_MIME_TYPES.get(format)); + variant.setWidthPx(dimensions.widthPx()); + variant.setHeightPx(dimensions.heightPx()); + variant.setFileSizeBytes(Files.size(outputFile)); + variant.setIsGenerated(true); + variant.setCreatedAt(OffsetDateTime.now()); + + pendingVariants.add(new PendingGeneratedVariant(variant, outputFile)); + } + } + + if (!skippedFormats.isEmpty()) { + logger.warn( + "Skipping media formats for asset {} because FFmpeg encoders are unavailable: {}", + asset.getId(), + String.join(", ", skippedFormats) + ); + } + + List storedKeys = new ArrayList<>(); + try { + for (PendingGeneratedVariant pendingVariant : pendingVariants) { + storeGeneratedVariant(asset.getVisibility(), pendingVariant); + storedKeys.add(pendingVariant.variant().getStorageKey()); + } + } catch (IOException e) { + cleanupStoredGeneratedVariants(asset.getVisibility(), storedKeys); + throw e; + } + + return pendingVariants.stream() + .map(PendingGeneratedVariant::variant) + .toList(); + } + + private void storeGeneratedVariant(String visibility, PendingGeneratedVariant pendingVariant) throws IOException { + if (VISIBILITY_PUBLIC.equals(visibility)) { + mediaStorageService.storePublic(pendingVariant.file(), pendingVariant.variant().getStorageKey()); + return; + } + mediaStorageService.storePrivate(pendingVariant.file(), pendingVariant.variant().getStorageKey()); + } + + private void cleanupStoredGeneratedVariants(String visibility, Collection storageKeys) { + for (String storageKey : storageKeys) { + try { + mediaStorageService.deleteGenerated(visibility, storageKey); + } catch (IOException cleanupException) { + logger.warn("Failed to clean up media variant {}", storageKey, cleanupException); + } + } + } + + private void moveGeneratedVariants(MediaAsset asset, String requestedVisibility) { + List variants = mediaVariantRepository.findByMediaAsset_IdOrderByCreatedAtAsc(asset.getId()); + List movedStorageKeys = new ArrayList<>(); + try { + for (MediaVariant variant : variants) { + if (FORMAT_ORIGINAL.equals(variant.getFormat())) { + continue; + } + mediaStorageService.moveGenerated(variant.getStorageKey(), asset.getVisibility(), requestedVisibility); + movedStorageKeys.add(variant.getStorageKey()); + } + } catch (IOException e) { + reverseMovedVariants(asset.getVisibility(), requestedVisibility, movedStorageKeys); + throw new ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "Failed to move media files."); + } + } + + private void reverseMovedVariants(String originalVisibility, String requestedVisibility, List movedStorageKeys) { + List reversedOrder = new ArrayList<>(movedStorageKeys); + java.util.Collections.reverse(reversedOrder); + for (String storageKey : reversedOrder) { + try { + mediaStorageService.moveGenerated(storageKey, requestedVisibility, originalVisibility); + } catch (IOException reverseException) { + logger.error("Failed to restore media variant {}", storageKey, reverseException); + } + } + } + + private void unsetPrimaryForScope(String usageType, String usageKey, UUID ownerId, UUID excludeUsageId) { + List existingUsages = mediaUsageRepository.findByUsageScope(usageType, usageKey, ownerId); + List usagesToUpdate = existingUsages.stream() + .filter(existing -> excludeUsageId == null || !existing.getId().equals(excludeUsageId)) + .filter(existing -> Boolean.TRUE.equals(existing.getIsPrimary())) + .peek(existing -> existing.setIsPrimary(false)) + .toList(); + + if (!usagesToUpdate.isEmpty()) { + mediaUsageRepository.saveAll(usagesToUpdate); + } + } + + private List toAssetDtos(List assets) { + if (assets == null || assets.isEmpty()) { + return List.of(); + } + + List assetIds = assets.stream() + .map(MediaAsset::getId) + .filter(Objects::nonNull) + .toList(); + + Map> variantsByAssetId = mediaVariantRepository.findByMediaAsset_IdIn(assetIds) + .stream() + .sorted(this::compareVariants) + .collect(Collectors.groupingBy(variant -> variant.getMediaAsset().getId(), LinkedHashMap::new, Collectors.toList())); + + Map> usagesByAssetId = mediaUsageRepository.findByMediaAsset_IdIn(assetIds) + .stream() + .sorted(Comparator + .comparing(MediaUsage::getSortOrder, Comparator.nullsLast(Integer::compareTo)) + .thenComparing(MediaUsage::getCreatedAt, Comparator.nullsLast(OffsetDateTime::compareTo))) + .collect(Collectors.groupingBy(usage -> usage.getMediaAsset().getId(), LinkedHashMap::new, Collectors.toList())); + + return assets.stream() + .map(asset -> toAssetDto( + asset, + variantsByAssetId.getOrDefault(asset.getId(), List.of()), + usagesByAssetId.getOrDefault(asset.getId(), List.of()) + )) + .toList(); + } + + private AdminMediaAssetDto toAssetDto(MediaAsset asset, List variants, List usages) { + AdminMediaAssetDto dto = new AdminMediaAssetDto(); + dto.setId(asset.getId()); + dto.setOriginalFilename(asset.getOriginalFilename()); + dto.setStorageKey(asset.getStorageKey()); + dto.setMimeType(asset.getMimeType()); + dto.setFileSizeBytes(asset.getFileSizeBytes()); + dto.setSha256Hex(asset.getSha256Hex()); + dto.setWidthPx(asset.getWidthPx()); + dto.setHeightPx(asset.getHeightPx()); + dto.setStatus(asset.getStatus()); + dto.setVisibility(asset.getVisibility()); + dto.setTitle(asset.getTitle()); + dto.setAltText(asset.getAltText()); + dto.setCreatedAt(asset.getCreatedAt()); + dto.setUpdatedAt(asset.getUpdatedAt()); + dto.setVariants(variants.stream().map(variant -> toVariantDto(asset, variant)).toList()); + dto.setUsages(usages.stream().map(this::toUsageDto).toList()); + return dto; + } + + private AdminMediaVariantDto toVariantDto(MediaAsset asset, MediaVariant variant) { + AdminMediaVariantDto dto = new AdminMediaVariantDto(); + dto.setId(variant.getId()); + dto.setVariantName(variant.getVariantName()); + dto.setFormat(variant.getFormat()); + dto.setStorageKey(variant.getStorageKey()); + dto.setMimeType(variant.getMimeType()); + dto.setWidthPx(variant.getWidthPx()); + dto.setHeightPx(variant.getHeightPx()); + dto.setFileSizeBytes(variant.getFileSizeBytes()); + dto.setIsGenerated(variant.getIsGenerated()); + dto.setCreatedAt(variant.getCreatedAt()); + if (VISIBILITY_PUBLIC.equals(asset.getVisibility()) && !FORMAT_ORIGINAL.equals(variant.getFormat())) { + dto.setPublicUrl(mediaStorageService.buildPublicUrl(variant.getStorageKey())); + } + return dto; + } + + private AdminMediaUsageDto toUsageDto(MediaUsage usage) { + AdminMediaUsageDto dto = new AdminMediaUsageDto(); + dto.setId(usage.getId()); + dto.setUsageType(usage.getUsageType()); + dto.setUsageKey(usage.getUsageKey()); + dto.setOwnerId(usage.getOwnerId()); + dto.setMediaAssetId(usage.getMediaAsset().getId()); + dto.setSortOrder(usage.getSortOrder()); + dto.setIsPrimary(usage.getIsPrimary()); + dto.setIsActive(usage.getIsActive()); + dto.setTranslations(extractTranslations(usage)); + dto.setCreatedAt(usage.getCreatedAt()); + return dto; + } + + private int compareVariants(MediaVariant left, MediaVariant right) { + return Comparator + .comparingInt((MediaVariant variant) -> variantNameOrder(variant.getVariantName())) + .thenComparingInt(variant -> formatOrder(variant.getFormat())) + .thenComparing(MediaVariant::getCreatedAt, Comparator.nullsLast(OffsetDateTime::compareTo)) + .compare(left, right); + } + + private int variantNameOrder(String variantName) { + if ("original".equalsIgnoreCase(variantName)) { + return 0; + } + if ("thumb".equalsIgnoreCase(variantName)) { + return 10; + } + if ("card".equalsIgnoreCase(variantName)) { + return 20; + } + if ("hero".equalsIgnoreCase(variantName)) { + return 30; + } + return 100; + } + + private int formatOrder(String format) { + return switch (format) { + case FORMAT_ORIGINAL -> 0; + case FORMAT_JPEG -> 10; + case FORMAT_WEBP -> 20; + case FORMAT_AVIF -> 30; + default -> 100; + }; + } + + private MediaAsset getAssetOrThrow(UUID mediaAssetId) { + if (mediaAssetId == null) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Media asset id is required."); + } + return mediaAssetRepository.findById(mediaAssetId) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Media asset not found.")); + } + + private MediaUsage getUsageOrThrow(UUID mediaUsageId) { + return mediaUsageRepository.findById(mediaUsageId) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Media usage not found.")); + } + + private void validateUpload(MultipartFile file) { + if (file == null || file.isEmpty()) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Image file is required."); + } + if (file.getSize() < 0 || file.getSize() > maxUploadFileSizeBytes) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Image file exceeds the maximum allowed size."); + } + } + + private String requireUsageType(String usageType) { + if (usageType == null || usageType.isBlank()) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "usageType is required."); + } + return usageType.trim().toUpperCase(Locale.ROOT); + } + + private String requireUsageKey(String usageKey) { + if (usageKey == null || usageKey.isBlank()) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "usageKey is required."); + } + return usageKey.trim(); + } + + private String normalizeVisibility(String visibility, boolean defaultPublic) { + if (visibility == null) { + return defaultPublic ? VISIBILITY_PUBLIC : null; + } + String normalized = visibility.trim().toUpperCase(Locale.ROOT); + if (normalized.isBlank()) { + return defaultPublic ? VISIBILITY_PUBLIC : null; + } + if (!ALLOWED_VISIBILITIES.contains(normalized)) { + throw new ResponseStatusException( + HttpStatus.BAD_REQUEST, + "Invalid visibility. Allowed: " + String.join(", ", new LinkedHashSet<>(ALLOWED_VISIBILITIES)) + ); + } + return normalized; + } + + private String normalizeStatus(String status, boolean required) { + if (status == null) { + if (required) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Status is required."); + } + return null; + } + String normalized = status.trim().toUpperCase(Locale.ROOT); + if (normalized.isBlank()) { + if (required) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Status is required."); + } + return null; + } + if (!ALLOWED_STATUSES.contains(normalized)) { + throw new ResponseStatusException( + HttpStatus.BAD_REQUEST, + "Invalid status. Allowed: " + String.join(", ", new LinkedHashSet<>(ALLOWED_STATUSES)) + ); + } + return normalized; + } + + private Map requireTranslations(Map translations) { + if (translations == null || translations.isEmpty()) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "translations are required."); + } + + Map normalized = new LinkedHashMap<>(); + for (Map.Entry entry : translations.entrySet()) { + String language = normalizeTranslationLanguage(entry.getKey()); + if (normalized.containsKey(language)) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Duplicate translation language: " + language + "."); + } + normalized.put(language, entry.getValue()); + } + + if (!normalized.keySet().equals(new LinkedHashSet<>(SUPPORTED_MEDIA_LANGUAGES))) { + throw new ResponseStatusException( + HttpStatus.BAD_REQUEST, + "translations must include exactly: " + String.join(", ", SUPPORTED_MEDIA_LANGUAGES) + "." + ); + } + + LinkedHashMap result = new LinkedHashMap<>(); + for (String language : SUPPORTED_MEDIA_LANGUAGES) { + MediaTextTranslationDto translation = normalized.get(language); + if (translation == null) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Missing translation for language " + language + "."); + } + + String title = normalizeRequiredTranslationValue(translation.getTitle(), language, "title"); + String altText = normalizeRequiredTranslationValue(translation.getAltText(), language, "altText"); + + MediaTextTranslationDto dto = new MediaTextTranslationDto(); + dto.setTitle(title); + dto.setAltText(altText); + result.put(language, dto); + } + return result; + } + + private String normalizeTranslationLanguage(String language) { + if (language == null || language.isBlank()) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Translation language is required."); + } + String normalized = language.trim().toLowerCase(Locale.ROOT); + if (!SUPPORTED_MEDIA_LANGUAGES.contains(normalized)) { + throw new ResponseStatusException( + HttpStatus.BAD_REQUEST, + "Unsupported translation language: " + normalized + "." + ); + } + return normalized; + } + + private String normalizeRequiredTranslationValue(String value, String language, String fieldName) { + String normalized = normalizeText(value); + if (normalized == null || normalized.isBlank()) { + throw new ResponseStatusException( + HttpStatus.BAD_REQUEST, + "Translation " + fieldName + " is required for language " + language + "." + ); + } + return normalized; + } + + private void applyTranslations(MediaUsage usage, Map translations) { + for (String language : SUPPORTED_MEDIA_LANGUAGES) { + MediaTextTranslationDto translation = translations.get(language); + usage.setTitleForLanguage(language, translation.getTitle()); + usage.setAltTextForLanguage(language, translation.getAltText()); + } + } + + private Map extractTranslations(MediaUsage usage) { + LinkedHashMap translations = new LinkedHashMap<>(); + String fallbackTitle = usage.getMediaAsset() != null ? usage.getMediaAsset().getTitle() : null; + String fallbackAltText = usage.getMediaAsset() != null ? usage.getMediaAsset().getAltText() : null; + + for (String language : SUPPORTED_MEDIA_LANGUAGES) { + MediaTextTranslationDto dto = new MediaTextTranslationDto(); + dto.setTitle(firstNonBlank(usage.getTitleForLanguage(language), fallbackTitle)); + dto.setAltText(firstNonBlank(usage.getAltTextForLanguage(language), fallbackAltText)); + translations.put(language, dto); + } + return translations; + } + + private String firstNonBlank(String preferred, String fallback) { + return StringUtils.hasText(preferred) ? preferred : normalizeText(fallback); + } + + private String normalizeText(String value) { + if (value == null) { + return null; + } + String normalized = value.trim(); + return normalized.isBlank() ? null : normalized; + } + + private String sanitizeOriginalFilename(String originalFilename, String extension) { + String cleaned = StringUtils.cleanPath(originalFilename == null ? "" : originalFilename); + int separatorIndex = Math.max(cleaned.lastIndexOf('/'), cleaned.lastIndexOf('\\')); + String basename = separatorIndex >= 0 ? cleaned.substring(separatorIndex + 1) : cleaned; + basename = basename.replace("\r", "_").replace("\n", "_"); + if (basename.isBlank()) { + return "upload." + extension; + } + return basename; + } + + private String buildStorageFolder() { + return STORAGE_FOLDER_FORMATTER.format(LocalDate.now()) + "/" + UUID.randomUUID(); + } + + private String extractStorageFolder(String originalStorageKey) { + Path path = Paths.get(originalStorageKey).normalize(); + Path parent = path.getParent(); + if (parent == null) { + throw new ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "Invalid media storage key."); + } + return parent.toString().replace('\\', '/'); + } + + private VariantDimensions computeVariantDimensions(Integer widthPx, Integer heightPx, int maxDimension) { + if (widthPx == null || heightPx == null || widthPx <= 0 || heightPx <= 0) { + throw new ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "Invalid image dimensions."); + } + double scale = Math.min(1.0d, (double) maxDimension / Math.max(widthPx, heightPx)); + int targetWidth = Math.max(1, (int) Math.round(widthPx * scale)); + int targetHeight = Math.max(1, (int) Math.round(heightPx * scale)); + return new VariantDimensions(targetWidth, targetHeight); + } + + private String computeSha256(Path file) throws IOException { + MessageDigest digest; + try { + digest = MessageDigest.getInstance("SHA-256"); + } catch (NoSuchAlgorithmException e) { + throw new IllegalStateException("SHA-256 not available.", e); + } + + try (InputStream inputStream = Files.newInputStream(file)) { + byte[] buffer = new byte[8192]; + int read; + while ((read = inputStream.read(buffer)) >= 0) { + digest.update(buffer, 0, read); + } + } + return HexFormat.of().formatHex(digest.digest()); + } + + private void markFailed(MediaAsset asset, String message, Exception exception) { + if (asset == null || asset.getId() == null) { + logger.warn("Media upload failed before asset persistence: {}", message, exception); + return; + } + asset.setStatus(STATUS_FAILED); + asset.setUpdatedAt(OffsetDateTime.now()); + mediaAssetRepository.save(asset); + logger.warn("Media asset {} marked as FAILED: {}", asset.getId(), message, exception); + } + + private void deleteRecursively(Path directory) { + if (directory == null || !Files.exists(directory)) { + return; + } + try (var walk = Files.walk(directory)) { + walk.sorted(Comparator.reverseOrder()).forEach(path -> { + try { + Files.deleteIfExists(path); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + }); + } catch (IOException e) { + logger.warn("Failed to clean temporary media directory {}", directory, e); + } catch (UncheckedIOException e) { + logger.warn("Failed to clean temporary media directory {}", directory, e); + } + } + + private record PresetDefinition(String name, int maxDimension) { + } + + private record VariantDimensions(int widthPx, int heightPx) { + } + + private record PendingGeneratedVariant(MediaVariant variant, Path file) { + } +} diff --git a/backend/src/main/java/com/printcalculator/service/media/MediaFfmpegService.java b/backend/src/main/java/com/printcalculator/service/media/MediaFfmpegService.java new file mode 100644 index 0000000..4e50785 --- /dev/null +++ b/backend/src/main/java/com/printcalculator/service/media/MediaFfmpegService.java @@ -0,0 +1,276 @@ +package com.printcalculator.service.media; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.InvalidPathException; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; + +@Service +public class MediaFfmpegService { + + private static final Logger logger = LoggerFactory.getLogger(MediaFfmpegService.class); + + private static final Map> ENCODER_CANDIDATES = Map.of( + "JPEG", List.of("mjpeg"), + "WEBP", List.of("libwebp", "webp"), + "AVIF", List.of("libaom-av1", "librav1e", "libsvtav1") + ); + + private final String ffmpegExecutable; + private final Set availableEncoders; + + public MediaFfmpegService(@Value("${media.ffmpeg.path:ffmpeg}") String ffmpegPath) { + this.ffmpegExecutable = resolveExecutable(ffmpegPath); + this.availableEncoders = Collections.unmodifiableSet(loadAvailableEncoders()); + } + + public void generateVariant(Path source, Path target, int widthPx, int heightPx, String format) throws IOException { + if (widthPx <= 0 || heightPx <= 0) { + throw new IllegalArgumentException("Variant dimensions must be positive."); + } + + Path sourcePath = sanitizeMediaPath(source, "source", true); + Path targetPath = sanitizeMediaPath(target, "target", false); + Files.createDirectories(targetPath.getParent()); + + String encoder = resolveEncoder(format); + if (encoder == null) { + throw new IOException("FFmpeg encoder not available for media format " + format + "."); + } + + List command = new ArrayList<>(); + command.add(ffmpegExecutable); + command.add("-y"); + command.add("-hide_banner"); + command.add("-loglevel"); + command.add("error"); + command.add("-i"); + command.add(sourcePath.toString()); + command.add("-vf"); + command.add("scale=" + widthPx + ":" + heightPx + ":flags=lanczos,setsar=1"); + command.add("-frames:v"); + command.add("1"); + command.add("-an"); + + switch (format) { + case "JPEG" -> { + command.add("-c:v"); + command.add(encoder); + command.add("-q:v"); + command.add("2"); + } + case "WEBP" -> { + command.add("-c:v"); + command.add(encoder); + command.add("-quality"); + command.add("82"); + } + case "AVIF" -> { + command.add("-c:v"); + command.add(encoder); + command.add("-still-picture"); + command.add("1"); + command.add("-crf"); + command.add("30"); + command.add("-b:v"); + command.add("0"); + } + default -> throw new IllegalArgumentException("Unsupported media format: " + format); + } + + command.add(targetPath.toString()); + + Process process = startValidatedProcess(command); + String output; + try (InputStream processStream = process.getInputStream()) { + output = new String(processStream.readAllBytes(), StandardCharsets.UTF_8); + } + + int exitCode; + try { + exitCode = process.waitFor(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new IOException("FFmpeg execution interrupted.", e); + } + + if (exitCode != 0 || !Files.exists(targetPath) || Files.size(targetPath) == 0) { + throw new IOException("FFmpeg failed to generate media variant. " + truncate(output)); + } + } + + public boolean canEncode(String format) { + return resolveEncoder(format) != null; + } + + private String resolveEncoder(String format) { + if (format == null) { + return null; + } + List candidates = ENCODER_CANDIDATES.get(format.trim().toUpperCase(Locale.ROOT)); + if (candidates == null) { + return null; + } + return candidates.stream() + .filter(availableEncoders::contains) + .findFirst() + .orElse(null); + } + + private Set loadAvailableEncoders() { + List command = List.of(ffmpegExecutable, "-hide_banner", "-encoders"); + try { + Process process = startValidatedProcess(command); + String output; + try (InputStream processStream = process.getInputStream()) { + output = new String(processStream.readAllBytes(), StandardCharsets.UTF_8); + } + + int exitCode = process.waitFor(); + if (exitCode != 0) { + logger.warn("Unable to inspect FFmpeg encoders. Falling back to empty encoder list."); + return Set.of(); + } + return parseAvailableEncoders(output); + } catch (Exception e) { + logger.warn( + "Unable to inspect FFmpeg encoders for executable '{}'. Falling back to empty encoder list. {}", + ffmpegExecutable, + e.getMessage() + ); + return Set.of(); + } + } + + private Process startValidatedProcess(List command) throws IOException { + // nosemgrep: java.lang.security.audit.command-injection-process-builder.command-injection-process-builder + return new ProcessBuilder(List.copyOf(command)) + .redirectErrorStream(true) + .start(); + } + + static String sanitizeExecutable(String configuredExecutable) { + if (configuredExecutable == null) { + throw new IllegalArgumentException("media.ffmpeg.path must not be null."); + } + + String candidate = configuredExecutable.trim(); + if (candidate.isEmpty()) { + throw new IllegalArgumentException("media.ffmpeg.path must point to an FFmpeg executable."); + } + if (candidate.chars().anyMatch(Character::isISOControl)) { + throw new IllegalArgumentException("media.ffmpeg.path contains control characters."); + } + + try { + Path executablePath = Path.of(candidate); + Path filename = executablePath.getFileName(); + String executableName = filename == null ? candidate : filename.toString(); + if (executableName.isBlank() || executableName.startsWith("-")) { + throw new IllegalArgumentException("media.ffmpeg.path must be an executable path, not an option."); + } + + return executablePath.normalize().toString(); + } catch (InvalidPathException e) { + throw new IllegalArgumentException("media.ffmpeg.path is not a valid executable path.", e); + } + } + + static String resolveExecutable(String configuredExecutable) { + String candidate = sanitizeExecutable(configuredExecutable); + + try { + Path configuredPath = Path.of(candidate); + if (!configuredPath.isAbsolute()) { + return candidate; + } + if (Files.isExecutable(configuredPath)) { + return configuredPath.toString(); + } + + Path filename = configuredPath.getFileName(); + String fallbackExecutable = filename == null ? null : filename.toString(); + if (fallbackExecutable != null && !fallbackExecutable.isBlank()) { + logger.warn( + "Configured FFmpeg executable '{}' not found or not executable. Falling back to '{}' from PATH.", + configuredPath, + fallbackExecutable + ); + return fallbackExecutable; + } + return candidate; + } catch (InvalidPathException e) { + throw new IllegalArgumentException("media.ffmpeg.path is not a valid executable path.", e); + } + } + + private Path sanitizeMediaPath(Path path, String label, boolean requireExistingFile) throws IOException { + if (path == null) { + throw new IllegalArgumentException("Media " + label + " path is required."); + } + + Path normalized = path.toAbsolutePath().normalize(); + Path filename = normalized.getFileName(); + if (filename == null || filename.toString().isBlank()) { + throw new IOException("Media " + label + " path must include a file name."); + } + if (filename.toString().startsWith("-")) { + throw new IOException("Media " + label + " file name must not start with '-'."); + } + + if (requireExistingFile) { + if (!Files.isRegularFile(normalized) || !Files.isReadable(normalized)) { + throw new IOException("Media " + label + " file is not readable."); + } + } else if (normalized.getParent() == null) { + throw new IOException("Media " + label + " path must include a parent directory."); + } + + return normalized; + } + + private Set parseAvailableEncoders(String output) { + if (output == null || output.isBlank()) { + return Set.of(); + } + + Set encoders = new LinkedHashSet<>(); + for (String line : output.split("\\R")) { + String trimmed = line.trim(); + if (trimmed.isBlank() || trimmed.startsWith("--") || trimmed.startsWith("Encoders:")) { + continue; + } + if (trimmed.length() < 7) { + continue; + } + String[] parts = trimmed.split("\\s+", 3); + if (parts.length < 2) { + continue; + } + encoders.add(parts[1]); + } + return encoders; + } + + private String truncate(String output) { + if (output == null || output.isBlank()) { + return ""; + } + String normalized = output.trim().replace('\n', ' '); + return normalized.length() <= 300 ? normalized : normalized.substring(0, 300); + } +} diff --git a/backend/src/main/java/com/printcalculator/service/media/MediaImageInspector.java b/backend/src/main/java/com/printcalculator/service/media/MediaImageInspector.java new file mode 100644 index 0000000..55449ed --- /dev/null +++ b/backend/src/main/java/com/printcalculator/service/media/MediaImageInspector.java @@ -0,0 +1,122 @@ +package com.printcalculator.service.media; + +import org.springframework.stereotype.Service; + +import javax.imageio.ImageIO; +import java.awt.image.BufferedImage; +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; + +@Service +public class MediaImageInspector { + + private static final byte[] PNG_SIGNATURE = new byte[]{ + (byte) 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A + }; + + public ImageMetadata inspect(Path file) throws IOException { + try (InputStream inputStream = Files.newInputStream(file)) { + byte[] header = inputStream.readNBytes(64); + if (isJpeg(header)) { + return readWithImageIo(file, "image/jpeg", "jpg"); + } + if (isPng(header)) { + return readWithImageIo(file, "image/png", "png"); + } + if (isWebp(header)) { + Dimensions dimensions = readWebpDimensions(header); + return new ImageMetadata("image/webp", "webp", dimensions.width(), dimensions.height()); + } + } + + throw new IllegalArgumentException("Unsupported image type. Allowed: jpg, jpeg, png, webp."); + } + + private ImageMetadata readWithImageIo(Path file, String mimeType, String extension) throws IOException { + BufferedImage image = ImageIO.read(file.toFile()); + if (image == null || image.getWidth() <= 0 || image.getHeight() <= 0) { + throw new IllegalArgumentException("Uploaded image is invalid or unreadable."); + } + return new ImageMetadata(mimeType, extension, image.getWidth(), image.getHeight()); + } + + private boolean isJpeg(byte[] header) { + return header.length >= 3 + && (header[0] & 0xFF) == 0xFF + && (header[1] & 0xFF) == 0xD8 + && (header[2] & 0xFF) == 0xFF; + } + + private boolean isPng(byte[] header) { + if (header.length < PNG_SIGNATURE.length) { + return false; + } + for (int i = 0; i < PNG_SIGNATURE.length; i++) { + if (header[i] != PNG_SIGNATURE[i]) { + return false; + } + } + return true; + } + + private boolean isWebp(byte[] header) { + return header.length >= 16 + && "RIFF".equals(ascii(header, 0, 4)) + && "WEBP".equals(ascii(header, 8, 4)); + } + + private Dimensions readWebpDimensions(byte[] header) { + if (header.length < 30) { + throw new IllegalArgumentException("Uploaded WebP image is invalid."); + } + + String chunkType = ascii(header, 12, 4); + return switch (chunkType) { + case "VP8X" -> new Dimensions( + littleEndian24(header, 24) + 1, + littleEndian24(header, 27) + 1 + ); + case "VP8 " -> new Dimensions( + littleEndian16(header, 26) & 0x3FFF, + littleEndian16(header, 28) & 0x3FFF + ); + case "VP8L" -> { + int packed = littleEndian32(header, 21); + int width = (packed & 0x3FFF) + 1; + int height = ((packed >> 14) & 0x3FFF) + 1; + yield new Dimensions(width, height); + } + default -> throw new IllegalArgumentException("Uploaded WebP image is invalid."); + }; + } + + private String ascii(byte[] header, int offset, int length) { + return new String(header, offset, length, StandardCharsets.US_ASCII); + } + + private int littleEndian16(byte[] header, int offset) { + return (header[offset] & 0xFF) | ((header[offset + 1] & 0xFF) << 8); + } + + private int littleEndian24(byte[] header, int offset) { + return (header[offset] & 0xFF) + | ((header[offset + 1] & 0xFF) << 8) + | ((header[offset + 2] & 0xFF) << 16); + } + + private int littleEndian32(byte[] header, int offset) { + return (header[offset] & 0xFF) + | ((header[offset + 1] & 0xFF) << 8) + | ((header[offset + 2] & 0xFF) << 16) + | ((header[offset + 3] & 0xFF) << 24); + } + + private record Dimensions(int width, int height) { + } + + public record ImageMetadata(String mimeType, String fileExtension, int widthPx, int heightPx) { + } +} diff --git a/backend/src/main/java/com/printcalculator/service/media/MediaStorageService.java b/backend/src/main/java/com/printcalculator/service/media/MediaStorageService.java new file mode 100644 index 0000000..1a282d6 --- /dev/null +++ b/backend/src/main/java/com/printcalculator/service/media/MediaStorageService.java @@ -0,0 +1,130 @@ +package com.printcalculator.service.media; + +import com.printcalculator.exception.StorageException; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.nio.file.StandardCopyOption; +import java.util.Locale; + +@Service +public class MediaStorageService { + + private final Path normalizedRootLocation; + private final Path originalRootLocation; + private final Path publicRootLocation; + private final Path privateRootLocation; + private final String publicBaseUrl; + + public MediaStorageService(@Value("${media.storage.root:storage_media}") String storageRoot, + @Value("${media.public.base-url:http://localhost:8080/media}") String publicBaseUrl) { + this.normalizedRootLocation = Paths.get(storageRoot).toAbsolutePath().normalize(); + this.originalRootLocation = normalizedRootLocation.resolve("original").normalize(); + this.publicRootLocation = normalizedRootLocation.resolve("public").normalize(); + this.privateRootLocation = normalizedRootLocation.resolve("private").normalize(); + this.publicBaseUrl = publicBaseUrl; + init(); + } + + public void init() { + try { + Files.createDirectories(originalRootLocation); + Files.createDirectories(publicRootLocation); + Files.createDirectories(privateRootLocation); + } catch (IOException e) { + throw new StorageException("Could not initialize media storage.", e); + } + } + + public void storeOriginal(Path source, String storageKey) throws IOException { + copy(source, resolveOriginal(storageKey)); + } + + public void storePublic(Path source, String storageKey) throws IOException { + copy(source, resolvePublic(storageKey)); + } + + public void storePrivate(Path source, String storageKey) throws IOException { + copy(source, resolvePrivate(storageKey)); + } + + public void deleteGenerated(String visibility, String storageKey) throws IOException { + Files.deleteIfExists(resolve(resolveVariantRoot(normalizeVisibility(visibility)), storageKey)); + } + + public void moveGenerated(String storageKey, String fromVisibility, String toVisibility) throws IOException { + String normalizedFrom = normalizeVisibility(fromVisibility); + String normalizedTo = normalizeVisibility(toVisibility); + if (normalizedFrom.equals(normalizedTo)) { + return; + } + + Path source = resolve(resolveVariantRoot(normalizedFrom), storageKey); + Path target = resolve(resolveVariantRoot(normalizedTo), storageKey); + Files.createDirectories(target.getParent()); + Files.move(source, target, StandardCopyOption.REPLACE_EXISTING); + } + + public String buildPublicUrl(String storageKey) { + if (storageKey == null || storageKey.isBlank()) { + return null; + } + String normalizedKey = storageKey.startsWith("/") ? storageKey.substring(1) : storageKey; + if (publicBaseUrl.endsWith("/")) { + return publicBaseUrl + normalizedKey; + } + return publicBaseUrl + "/" + normalizedKey; + } + + private void copy(Path source, Path destination) throws IOException { + Files.createDirectories(destination.getParent()); + Files.copy(source, destination, StandardCopyOption.REPLACE_EXISTING); + } + + private Path resolveOriginal(String storageKey) { + return resolve(originalRootLocation, storageKey); + } + + private Path resolvePublic(String storageKey) { + return resolve(publicRootLocation, storageKey); + } + + private Path resolvePrivate(String storageKey) { + return resolve(privateRootLocation, storageKey); + } + + private Path resolveVariantRoot(String visibility) { + return switch (visibility) { + case "PUBLIC" -> publicRootLocation; + case "PRIVATE" -> privateRootLocation; + default -> throw new StorageException("Unsupported media visibility: " + visibility); + }; + } + + private Path resolve(Path baseRoot, String storageKey) { + if (storageKey == null || storageKey.isBlank()) { + throw new StorageException("Storage key is required."); + } + Path relativePath = Paths.get(storageKey).normalize(); + if (relativePath.isAbsolute()) { + throw new StorageException("Absolute paths are not allowed."); + } + + Path resolved = baseRoot.resolve(relativePath).normalize(); + if (!resolved.startsWith(baseRoot)) { + throw new StorageException("Cannot access files outside media storage root."); + } + return resolved; + } + + private String normalizeVisibility(String visibility) { + if (visibility == null || visibility.isBlank()) { + throw new StorageException("Visibility is required."); + } + return visibility.trim().toUpperCase(Locale.ROOT); + } +} diff --git a/backend/src/main/java/com/printcalculator/service/media/PublicMediaQueryService.java b/backend/src/main/java/com/printcalculator/service/media/PublicMediaQueryService.java new file mode 100644 index 0000000..bbf0fe7 --- /dev/null +++ b/backend/src/main/java/com/printcalculator/service/media/PublicMediaQueryService.java @@ -0,0 +1,170 @@ +package com.printcalculator.service.media; + +import com.printcalculator.dto.PublicMediaUsageDto; +import com.printcalculator.dto.PublicMediaVariantDto; +import com.printcalculator.entity.MediaAsset; +import com.printcalculator.entity.MediaUsage; +import com.printcalculator.entity.MediaVariant; +import com.printcalculator.repository.MediaUsageRepository; +import com.printcalculator.repository.MediaVariantRepository; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.server.ResponseStatusException; + +import java.time.OffsetDateTime; +import java.util.Comparator; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Objects; +import java.util.UUID; +import java.util.function.Function; +import java.util.stream.Collectors; + +@Service +@Transactional(readOnly = true) +public class PublicMediaQueryService { + + private static final String STATUS_READY = "READY"; + private static final String VISIBILITY_PUBLIC = "PUBLIC"; + private static final String FORMAT_JPEG = "JPEG"; + private static final String FORMAT_WEBP = "WEBP"; + private static final String FORMAT_AVIF = "AVIF"; + private static final List SUPPORTED_MEDIA_LANGUAGES = List.of("it", "en", "de", "fr"); + + private final MediaUsageRepository mediaUsageRepository; + private final MediaVariantRepository mediaVariantRepository; + private final MediaStorageService mediaStorageService; + + public PublicMediaQueryService(MediaUsageRepository mediaUsageRepository, + MediaVariantRepository mediaVariantRepository, + MediaStorageService mediaStorageService) { + this.mediaUsageRepository = mediaUsageRepository; + this.mediaVariantRepository = mediaVariantRepository; + this.mediaStorageService = mediaStorageService; + } + + public List getUsageMedia(String usageType, String usageKey, String language) { + String normalizedUsageType = normalizeUsageType(usageType); + String normalizedUsageKey = normalizeUsageKey(usageKey); + String normalizedLanguage = normalizeLanguage(language); + + List usages = mediaUsageRepository + .findByUsageTypeAndUsageKeyAndIsActiveTrueOrderBySortOrderAscCreatedAtAsc( + normalizedUsageType, + normalizedUsageKey + ) + .stream() + .filter(this::isPublicReadyUsage) + .sorted(Comparator + .comparing(MediaUsage::getSortOrder, Comparator.nullsLast(Integer::compareTo)) + .thenComparing(MediaUsage::getCreatedAt, Comparator.nullsLast(OffsetDateTime::compareTo))) + .toList(); + + if (usages.isEmpty()) { + return List.of(); + } + + List assetIds = usages.stream() + .map(MediaUsage::getMediaAsset) + .filter(Objects::nonNull) + .map(MediaAsset::getId) + .filter(Objects::nonNull) + .distinct() + .toList(); + + Map> variantsByAssetId = mediaVariantRepository.findByMediaAsset_IdIn(assetIds) + .stream() + .filter(variant -> !Objects.equals("ORIGINAL", variant.getFormat())) + .collect(Collectors.groupingBy(variant -> variant.getMediaAsset().getId())); + + return usages.stream() + .map(usage -> toDto( + usage, + variantsByAssetId.getOrDefault(usage.getMediaAsset().getId(), List.of()), + normalizedLanguage + )) + .toList(); + } + + private boolean isPublicReadyUsage(MediaUsage usage) { + MediaAsset asset = usage.getMediaAsset(); + return asset != null + && STATUS_READY.equals(asset.getStatus()) + && VISIBILITY_PUBLIC.equals(asset.getVisibility()); + } + + private PublicMediaUsageDto toDto(MediaUsage usage, List variants, String language) { + Map> variantsByPresetAndFormat = variants.stream() + .collect(Collectors.groupingBy( + MediaVariant::getVariantName, + Collectors.toMap(MediaVariant::getFormat, Function.identity(), (left, right) -> right) + )); + + PublicMediaUsageDto dto = new PublicMediaUsageDto(); + dto.setMediaAssetId(usage.getMediaAsset().getId()); + dto.setTitle(resolveLocalizedValue(usage.getTitleForLanguage(language), usage.getMediaAsset().getTitle())); + dto.setAltText(resolveLocalizedValue(usage.getAltTextForLanguage(language), usage.getMediaAsset().getAltText())); + dto.setUsageType(usage.getUsageType()); + dto.setUsageKey(usage.getUsageKey()); + dto.setSortOrder(usage.getSortOrder()); + dto.setIsPrimary(usage.getIsPrimary()); + dto.setThumb(buildPresetDto(variantsByPresetAndFormat.get("thumb"))); + dto.setCard(buildPresetDto(variantsByPresetAndFormat.get("card"))); + dto.setHero(buildPresetDto(variantsByPresetAndFormat.get("hero"))); + return dto; + } + + private PublicMediaVariantDto buildPresetDto(Map variantsByFormat) { + PublicMediaVariantDto dto = new PublicMediaVariantDto(); + if (variantsByFormat == null || variantsByFormat.isEmpty()) { + return dto; + } + + dto.setAvifUrl(buildVariantUrl(variantsByFormat.get(FORMAT_AVIF))); + dto.setWebpUrl(buildVariantUrl(variantsByFormat.get(FORMAT_WEBP))); + dto.setJpegUrl(buildVariantUrl(variantsByFormat.get(FORMAT_JPEG))); + return dto; + } + + private String buildVariantUrl(MediaVariant variant) { + if (variant == null || variant.getStorageKey() == null || variant.getStorageKey().isBlank()) { + return null; + } + return mediaStorageService.buildPublicUrl(variant.getStorageKey()); + } + + private String normalizeUsageType(String usageType) { + if (usageType == null || usageType.isBlank()) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "usageType is required."); + } + return usageType.trim().toUpperCase(Locale.ROOT); + } + + private String normalizeUsageKey(String usageKey) { + if (usageKey == null || usageKey.isBlank()) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "usageKey is required."); + } + return usageKey.trim(); + } + + private String normalizeLanguage(String language) { + if (language == null || language.isBlank()) { + return "it"; + } + + String normalized = language.trim().toLowerCase(Locale.ROOT); + return SUPPORTED_MEDIA_LANGUAGES.contains(normalized) ? normalized : "it"; + } + + private String resolveLocalizedValue(String preferred, String fallback) { + if (preferred != null && !preferred.isBlank()) { + return preferred; + } + if (fallback != null && !fallback.isBlank()) { + return fallback.trim(); + } + return null; + } +} diff --git a/backend/src/main/resources/application-local.properties b/backend/src/main/resources/application-local.properties index 04cf953..2366bea 100644 --- a/backend/src/main/resources/application-local.properties +++ b/backend/src/main/resources/application-local.properties @@ -3,6 +3,11 @@ app.mail.admin.enabled=false app.mail.contact-request.admin.enabled=false # Admin back-office local test credentials -admin.password=local-admin-password +admin.password=ciaociao admin.session.secret=local-session-secret-for-dev-only-000000000000000000000000 admin.session.ttl-minutes=480 + +# Local media storage served by a local static server on port 8081. +media.storage.root=/Users/joe/IdeaProjects/print-calculator/storage_media +media.public.base-url=http://localhost:8081 +media.ffmpeg.path=ffmpeg diff --git a/backend/src/main/resources/application.properties b/backend/src/main/resources/application.properties index 0915c7e..4edba9a 100644 --- a/backend/src/main/resources/application.properties +++ b/backend/src/main/resources/application.properties @@ -26,6 +26,12 @@ clamav.host=${CLAMAV_HOST:clamav} clamav.port=${CLAMAV_PORT:3310} clamav.enabled=${CLAMAV_ENABLED:false} +# Media configuration +media.storage.root=${MEDIA_STORAGE_ROOT:storage_media} +media.public.base-url=${MEDIA_PUBLIC_BASE_URL:http://localhost:8080/media} +media.ffmpeg.path=${MEDIA_FFMPEG_PATH:ffmpeg} +media.upload.max-file-size-bytes=${MEDIA_UPLOAD_MAX_FILE_SIZE_BYTES:26214400} + # TWINT Configuration payment.twint.url=${TWINT_PAYMENT_URL:https://go.twint.ch/1/e/tw?tw=acq.gERQQytOTnyIMuQHUqn4hlxgciHE5X7nnqHnNSPAr2OF2K3uBlXJDr2n9JU3sgxa.} diff --git a/backend/src/test/java/com/printcalculator/service/admin/AdminMediaControllerServiceTest.java b/backend/src/test/java/com/printcalculator/service/admin/AdminMediaControllerServiceTest.java new file mode 100644 index 0000000..861996b --- /dev/null +++ b/backend/src/test/java/com/printcalculator/service/admin/AdminMediaControllerServiceTest.java @@ -0,0 +1,470 @@ +package com.printcalculator.service.admin; + +import com.printcalculator.dto.AdminCreateMediaUsageRequest; +import com.printcalculator.dto.AdminMediaAssetDto; +import com.printcalculator.dto.AdminMediaUsageDto; +import com.printcalculator.dto.AdminUpdateMediaAssetRequest; +import com.printcalculator.dto.MediaTextTranslationDto; +import com.printcalculator.entity.MediaAsset; +import com.printcalculator.entity.MediaUsage; +import com.printcalculator.entity.MediaVariant; +import com.printcalculator.repository.MediaAssetRepository; +import com.printcalculator.repository.MediaUsageRepository; +import com.printcalculator.repository.MediaVariantRepository; +import com.printcalculator.service.media.MediaFfmpegService; +import com.printcalculator.service.media.MediaImageInspector; +import com.printcalculator.service.media.MediaStorageService; +import com.printcalculator.service.storage.ClamAVService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.api.io.TempDir; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; +import org.springframework.http.HttpStatus; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.web.server.ResponseStatusException; + +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.OffsetDateTime; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Comparator; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyCollection; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.nullable; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) +class AdminMediaControllerServiceTest { + + @Mock + private MediaAssetRepository mediaAssetRepository; + @Mock + private MediaVariantRepository mediaVariantRepository; + @Mock + private MediaUsageRepository mediaUsageRepository; + @Mock + private MediaImageInspector mediaImageInspector; + @Mock + private MediaFfmpegService mediaFfmpegService; + @Mock + private ClamAVService clamAVService; + + @TempDir + Path tempDir; + + private AdminMediaControllerService service; + private Path storageRoot; + + private final Map assets = new LinkedHashMap<>(); + private final Map variants = new LinkedHashMap<>(); + private final Map usages = new LinkedHashMap<>(); + + @BeforeEach + void setUp() throws Exception { + storageRoot = tempDir.resolve("storage_media"); + MediaStorageService mediaStorageService = new MediaStorageService( + storageRoot.toString(), + "https://cdn.example/media" + ); + + service = new AdminMediaControllerService( + mediaAssetRepository, + mediaVariantRepository, + mediaUsageRepository, + mediaStorageService, + mediaImageInspector, + mediaFfmpegService, + clamAVService, + 1024 * 1024 + ); + + when(clamAVService.scan(any())).thenReturn(true); + when(mediaFfmpegService.canEncode(anyString())).thenReturn(true); + + when(mediaAssetRepository.save(any(MediaAsset.class))).thenAnswer(invocation -> { + MediaAsset asset = invocation.getArgument(0); + if (asset.getId() == null) { + asset.setId(UUID.randomUUID()); + } + assets.put(asset.getId(), asset); + return asset; + }); + when(mediaAssetRepository.findById(any(UUID.class))).thenAnswer(invocation -> + Optional.ofNullable(assets.get(invocation.getArgument(0))) + ); + when(mediaAssetRepository.findAllByOrderByCreatedAtDesc()).thenAnswer(invocation -> assets.values().stream() + .sorted(Comparator.comparing(MediaAsset::getCreatedAt, Comparator.nullsLast(OffsetDateTime::compareTo)).reversed()) + .toList()); + + when(mediaVariantRepository.save(any(MediaVariant.class))).thenAnswer(invocation -> persistVariant(invocation.getArgument(0))); + when(mediaVariantRepository.saveAll(any())).thenAnswer(invocation -> { + Iterable iterable = invocation.getArgument(0); + List saved = new ArrayList<>(); + for (MediaVariant variant : iterable) { + saved.add(persistVariant(variant)); + } + return saved; + }); + when(mediaVariantRepository.findByMediaAsset_IdIn(anyCollection())).thenAnswer(invocation -> + variantsForAssets(invocation.getArgument(0)) + ); + when(mediaVariantRepository.findByMediaAsset_IdOrderByCreatedAtAsc(any(UUID.class))).thenAnswer(invocation -> + variants.values().stream() + .filter(variant -> variant.getMediaAsset().getId().equals(invocation.getArgument(0))) + .sorted(Comparator.comparing(MediaVariant::getCreatedAt, Comparator.nullsLast(OffsetDateTime::compareTo))) + .toList() + ); + + when(mediaUsageRepository.save(any(MediaUsage.class))).thenAnswer(invocation -> persistUsage(invocation.getArgument(0))); + when(mediaUsageRepository.saveAll(any())).thenAnswer(invocation -> { + Iterable iterable = invocation.getArgument(0); + List saved = new ArrayList<>(); + for (MediaUsage usage : iterable) { + saved.add(persistUsage(usage)); + } + return saved; + }); + when(mediaUsageRepository.findByMediaAsset_IdIn(anyCollection())).thenAnswer(invocation -> + usagesForAssets(invocation.getArgument(0)) + ); + when(mediaUsageRepository.findByUsageScope(anyString(), anyString(), nullable(UUID.class))).thenAnswer(invocation -> + usages.values().stream() + .filter(usage -> usage.getUsageType().equals(invocation.getArgument(0))) + .filter(usage -> usage.getUsageKey().equals(invocation.getArgument(1))) + .filter(usage -> { + UUID ownerId = invocation.getArgument(2); + return ownerId == null ? usage.getOwnerId() == null : ownerId.equals(usage.getOwnerId()); + }) + .sorted(Comparator.comparing(MediaUsage::getSortOrder).thenComparing(MediaUsage::getCreatedAt)) + .toList() + ); + when(mediaUsageRepository.findById(any(UUID.class))).thenAnswer(invocation -> + Optional.ofNullable(usages.get(invocation.getArgument(0))) + ); + + doAnswer(invocation -> { + Path outputFile = invocation.getArgument(1); + String format = invocation.getArgument(4); + Files.createDirectories(outputFile.getParent()); + Files.writeString(outputFile, "generated-" + format, StandardCharsets.UTF_8); + return null; + }).when(mediaFfmpegService).generateVariant(any(Path.class), any(Path.class), anyInt(), anyInt(), anyString()); + } + + @Test + void uploadAsset_withValidImage_shouldPersistMetadataAndExposePublicUrls() throws Exception { + when(mediaImageInspector.inspect(any(Path.class))).thenReturn( + new MediaImageInspector.ImageMetadata("image/png", "png", 1600, 900) + ); + + MockMultipartFile file = new MockMultipartFile( + "file", + "landing-hero.png", + "image/png", + "png-image-content".getBytes(StandardCharsets.UTF_8) + ); + + AdminMediaAssetDto dto = service.uploadAsset(file, " Landing hero ", " Main headline ", null); + + assertEquals("READY", dto.getStatus()); + assertEquals("PUBLIC", dto.getVisibility()); + assertEquals("landing-hero.png", dto.getOriginalFilename()); + assertEquals("Landing hero", dto.getTitle()); + assertEquals("Main headline", dto.getAltText()); + assertEquals("image/png", dto.getMimeType()); + assertEquals(1600, dto.getWidthPx()); + assertEquals(900, dto.getHeightPx()); + assertEquals(file.getSize(), dto.getFileSizeBytes()); + assertEquals(64, dto.getSha256Hex().length()); + assertEquals(10, dto.getVariants().size()); + + long publicVariants = dto.getVariants().stream() + .filter(variant -> !"ORIGINAL".equals(variant.getFormat())) + .count(); + assertEquals(9, publicVariants); + assertTrue(dto.getVariants().stream() + .filter(variant -> "WEBP".equals(variant.getFormat()) && "hero".equals(variant.getVariantName())) + .allMatch(variant -> variant.getPublicUrl().startsWith("https://cdn.example/media/"))); + assertTrue(dto.getVariants().stream() + .filter(variant -> "ORIGINAL".equals(variant.getFormat())) + .allMatch(variant -> variant.getPublicUrl() == null)); + + MediaVariant heroWebp = variants.values().stream() + .filter(variant -> "hero".equals(variant.getVariantName())) + .filter(variant -> "WEBP".equals(variant.getFormat())) + .findFirst() + .orElseThrow(); + assertTrue(Files.exists(storageRoot.resolve("public").resolve(heroWebp.getStorageKey()))); + + MediaVariant originalVariant = variants.values().stream() + .filter(variant -> "ORIGINAL".equals(variant.getFormat())) + .findFirst() + .orElseThrow(); + assertTrue(Files.exists(storageRoot.resolve("original").resolve(originalVariant.getStorageKey()))); + } + + @Test + void uploadAsset_withUnsupportedImageType_shouldReturnBadRequest() throws Exception { + when(mediaImageInspector.inspect(any(Path.class))).thenReturn( + new MediaImageInspector.ImageMetadata("image/svg+xml", "svg", 400, 400) + ); + + MockMultipartFile file = new MockMultipartFile( + "file", + "logo.svg", + "image/svg+xml", + "".getBytes(StandardCharsets.UTF_8) + ); + + ResponseStatusException ex = assertThrows( + ResponseStatusException.class, + () -> service.uploadAsset(file, null, null, null) + ); + + assertEquals(HttpStatus.BAD_REQUEST, ex.getStatusCode()); + assertTrue(assets.isEmpty()); + assertTrue(variants.isEmpty()); + } + + @Test + void uploadAsset_withLimitedEncoders_shouldKeepAssetReadyAndExposeOnlySupportedVariants() throws Exception { + when(mediaImageInspector.inspect(any(Path.class))).thenReturn( + new MediaImageInspector.ImageMetadata("image/jpeg", "jpg", 1200, 800) + ); + when(mediaFfmpegService.canEncode("JPEG")).thenReturn(true); + when(mediaFfmpegService.canEncode("WEBP")).thenReturn(false); + when(mediaFfmpegService.canEncode("AVIF")).thenReturn(false); + + MockMultipartFile file = new MockMultipartFile( + "file", + "capability.jpg", + "image/jpeg", + "jpeg-image-content".getBytes(StandardCharsets.UTF_8) + ); + + AdminMediaAssetDto dto = service.uploadAsset(file, "Capability", null, "PUBLIC"); + + assertEquals("READY", dto.getStatus()); + assertEquals(4, dto.getVariants().size()); + assertEquals(3, dto.getVariants().stream() + .filter(variant -> "JPEG".equals(variant.getFormat())) + .count()); + assertTrue(dto.getVariants().stream() + .noneMatch(variant -> "WEBP".equals(variant.getFormat()) || "AVIF".equals(variant.getFormat()))); + } + + @Test + void uploadAsset_withOversizedFile_shouldFailValidationBeforePersistence() { + service = new AdminMediaControllerService( + mediaAssetRepository, + mediaVariantRepository, + mediaUsageRepository, + new MediaStorageService(storageRoot.toString(), "https://cdn.example/media"), + mediaImageInspector, + mediaFfmpegService, + clamAVService, + 4 + ); + + MockMultipartFile file = new MockMultipartFile( + "file", + "big.png", + "image/png", + "12345".getBytes(StandardCharsets.UTF_8) + ); + + ResponseStatusException ex = assertThrows( + ResponseStatusException.class, + () -> service.uploadAsset(file, null, null, null) + ); + + assertEquals(HttpStatus.BAD_REQUEST, ex.getStatusCode()); + verifyNoInteractions(mediaAssetRepository, mediaVariantRepository, mediaUsageRepository, mediaImageInspector, mediaFfmpegService); + } + + @Test + void createUsage_withPrimaryFlag_shouldUnsetExistingPrimaryAndMapUsageOnAsset() { + MediaAsset asset = persistAsset(seedAsset("PUBLIC")); + + MediaUsage existingPrimary = new MediaUsage(); + existingPrimary.setId(UUID.randomUUID()); + existingPrimary.setUsageType("HOME"); + existingPrimary.setUsageKey("landing"); + existingPrimary.setOwnerId(null); + existingPrimary.setMediaAsset(asset); + existingPrimary.setSortOrder(0); + existingPrimary.setIsPrimary(true); + existingPrimary.setIsActive(true); + existingPrimary.setCreatedAt(OffsetDateTime.now().minusDays(1)); + persistUsage(existingPrimary); + + AdminCreateMediaUsageRequest payload = new AdminCreateMediaUsageRequest(); + payload.setUsageType("home"); + payload.setUsageKey("landing"); + payload.setMediaAssetId(asset.getId()); + payload.setSortOrder(5); + payload.setIsPrimary(true); + payload.setTranslations(buildTranslations("Landing hero", "Hero home alt")); + + AdminMediaUsageDto created = service.createUsage(payload); + + assertEquals("HOME", created.getUsageType()); + assertEquals("landing", created.getUsageKey()); + assertEquals(asset.getId(), created.getMediaAssetId()); + assertEquals(5, created.getSortOrder()); + assertTrue(created.getIsPrimary()); + assertEquals("Landing hero IT", created.getTranslations().get("it").getTitle()); + assertEquals("Hero home alt EN", created.getTranslations().get("en").getAltText()); + assertFalse(usages.get(existingPrimary.getId()).getIsPrimary()); + + AdminMediaAssetDto assetDto = service.getAsset(asset.getId()); + assertEquals(2, assetDto.getUsages().size()); + assertTrue(assetDto.getUsages().stream().anyMatch(usage -> usage.getId().equals(created.getId()) && usage.getIsPrimary())); + } + + @Test + void createUsage_withoutAllTranslations_shouldFailValidation() { + MediaAsset asset = persistAsset(seedAsset("PUBLIC")); + + AdminCreateMediaUsageRequest payload = new AdminCreateMediaUsageRequest(); + payload.setUsageType("home"); + payload.setUsageKey("landing"); + payload.setMediaAssetId(asset.getId()); + payload.setTranslations(new LinkedHashMap<>(Map.of( + "it", translation("Titolo IT", "Alt IT"), + "en", translation("Title EN", "Alt EN") + ))); + + ResponseStatusException ex = assertThrows( + ResponseStatusException.class, + () -> service.createUsage(payload) + ); + + assertEquals(HttpStatus.BAD_REQUEST, ex.getStatusCode()); + assertTrue(ex.getReason().contains("translations must include exactly")); + } + + @Test + void updateAsset_withPrivateVisibility_shouldMoveGeneratedFilesAndHidePublicUrls() throws Exception { + when(mediaImageInspector.inspect(any(Path.class))).thenReturn( + new MediaImageInspector.ImageMetadata("image/jpeg", "jpg", 1200, 800) + ); + + MockMultipartFile file = new MockMultipartFile( + "file", + "gallery.jpg", + "image/jpeg", + "jpeg-image-content".getBytes(StandardCharsets.UTF_8) + ); + + AdminMediaAssetDto uploaded = service.uploadAsset(file, null, null, "PUBLIC"); + MediaVariant thumbJpeg = variants.values().stream() + .filter(variant -> "thumb".equals(variant.getVariantName())) + .filter(variant -> "JPEG".equals(variant.getFormat())) + .findFirst() + .orElseThrow(); + + Path publicFile = storageRoot.resolve("public").resolve(thumbJpeg.getStorageKey()); + assertTrue(Files.exists(publicFile)); + + AdminUpdateMediaAssetRequest payload = new AdminUpdateMediaAssetRequest(); + payload.setVisibility("PRIVATE"); + + AdminMediaAssetDto updated = service.updateAsset(uploaded.getId(), payload); + + assertEquals("PRIVATE", updated.getVisibility()); + assertFalse(Files.exists(publicFile)); + assertTrue(Files.exists(storageRoot.resolve("private").resolve(thumbJpeg.getStorageKey()))); + assertTrue(updated.getVariants().stream() + .filter(variant -> !"ORIGINAL".equals(variant.getFormat())) + .allMatch(variant -> variant.getPublicUrl() == null)); + } + + private MediaAsset seedAsset(String visibility) { + MediaAsset asset = new MediaAsset(); + asset.setId(UUID.randomUUID()); + asset.setOriginalFilename("asset.png"); + asset.setStorageKey("2026/03/" + UUID.randomUUID() + "/original.png"); + asset.setMimeType("image/png"); + asset.setFileSizeBytes(123L); + asset.setSha256Hex("a".repeat(64)); + asset.setWidthPx(1200); + asset.setHeightPx(800); + asset.setStatus("READY"); + asset.setVisibility(visibility); + asset.setCreatedAt(OffsetDateTime.now()); + asset.setUpdatedAt(OffsetDateTime.now()); + return asset; + } + + private MediaAsset persistAsset(MediaAsset asset) { + assets.put(asset.getId(), asset); + return asset; + } + + private MediaVariant persistVariant(MediaVariant variant) { + if (variant.getId() == null) { + variant.setId(UUID.randomUUID()); + } + variants.put(variant.getId(), variant); + return variant; + } + + private MediaUsage persistUsage(MediaUsage usage) { + if (usage.getId() == null) { + usage.setId(UUID.randomUUID()); + } + usages.put(usage.getId(), usage); + return usage; + } + + private Map buildTranslations(String titleBase, String altBase) { + LinkedHashMap translations = new LinkedHashMap<>(); + translations.put("it", translation(titleBase + " IT", altBase + " IT")); + translations.put("en", translation(titleBase + " EN", altBase + " EN")); + translations.put("de", translation(titleBase + " DE", altBase + " DE")); + translations.put("fr", translation(titleBase + " FR", altBase + " FR")); + return translations; + } + + private MediaTextTranslationDto translation(String title, String altText) { + MediaTextTranslationDto dto = new MediaTextTranslationDto(); + dto.setTitle(title); + dto.setAltText(altText); + return dto; + } + + private List variantsForAssets(Collection assetIds) { + return variants.values().stream() + .filter(variant -> assetIds.contains(variant.getMediaAsset().getId())) + .toList(); + } + + private List usagesForAssets(Collection assetIds) { + return usages.values().stream() + .filter(usage -> assetIds.contains(usage.getMediaAsset().getId())) + .toList(); + } +} diff --git a/backend/src/test/java/com/printcalculator/service/media/MediaFfmpegServiceTest.java b/backend/src/test/java/com/printcalculator/service/media/MediaFfmpegServiceTest.java new file mode 100644 index 0000000..d407a2e --- /dev/null +++ b/backend/src/test/java/com/printcalculator/service/media/MediaFfmpegServiceTest.java @@ -0,0 +1,64 @@ +package com.printcalculator.service.media; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class MediaFfmpegServiceTest { + + @TempDir + Path tempDir; + + @Test + void sanitizeExecutable_rejectsControlCharacters() { + IllegalArgumentException ex = assertThrows( + IllegalArgumentException.class, + () -> MediaFfmpegService.sanitizeExecutable("ffmpeg\n--help") + ); + + assertEquals("media.ffmpeg.path contains control characters.", ex.getMessage()); + } + + @Test + void resolveExecutable_shouldFallbackToPathWhenAbsoluteLocationIsMissing() { + String resolved = MediaFfmpegService.resolveExecutable("/opt/homebrew/bin/ffmpeg"); + + assertEquals("ffmpeg", resolved); + } + + @Test + void generateVariant_rejectsSourceNamesStartingWithDash() throws Exception { + MediaFfmpegService service = new MediaFfmpegService("missing-ffmpeg-binary"); + Path source = tempDir.resolve("-input.png"); + Path target = tempDir.resolve("output.jpg"); + Files.writeString(source, "image"); + + IOException ex = assertThrows( + IOException.class, + () -> service.generateVariant(source, target, 120, 80, "JPEG") + ); + + assertEquals("Media source file name must not start with '-'.", ex.getMessage()); + } + + @Test + void generateVariant_rejectsTargetNamesStartingWithDash() throws Exception { + MediaFfmpegService service = new MediaFfmpegService("missing-ffmpeg-binary"); + Path source = tempDir.resolve("input.png"); + Path target = tempDir.resolve("-output.jpg"); + Files.writeString(source, "image"); + + IOException ex = assertThrows( + IOException.class, + () -> service.generateVariant(source, target, 120, 80, "JPEG") + ); + + assertEquals("Media target file name must not start with '-'.", ex.getMessage()); + } +} diff --git a/backend/src/test/java/com/printcalculator/service/media/PublicMediaQueryServiceTest.java b/backend/src/test/java/com/printcalculator/service/media/PublicMediaQueryServiceTest.java new file mode 100644 index 0000000..85288c7 --- /dev/null +++ b/backend/src/test/java/com/printcalculator/service/media/PublicMediaQueryServiceTest.java @@ -0,0 +1,162 @@ +package com.printcalculator.service.media; + +import com.printcalculator.dto.PublicMediaUsageDto; +import com.printcalculator.dto.MediaTextTranslationDto; +import com.printcalculator.entity.MediaAsset; +import com.printcalculator.entity.MediaUsage; +import com.printcalculator.entity.MediaVariant; +import com.printcalculator.repository.MediaUsageRepository; +import com.printcalculator.repository.MediaVariantRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.api.io.TempDir; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.nio.file.Path; +import java.time.OffsetDateTime; +import java.util.List; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class PublicMediaQueryServiceTest { + + @Mock + private MediaUsageRepository mediaUsageRepository; + @Mock + private MediaVariantRepository mediaVariantRepository; + + @TempDir + Path tempDir; + + private PublicMediaQueryService service; + + @BeforeEach + void setUp() { + MediaStorageService mediaStorageService = new MediaStorageService( + tempDir.resolve("storage_media").toString(), + "https://cdn.example/media" + ); + service = new PublicMediaQueryService(mediaUsageRepository, mediaVariantRepository, mediaStorageService); + } + + @Test + void getUsageMedia_shouldReturnOnlyActiveReadyPublicUsagesOrderedBySortOrder() { + MediaAsset readyPublicAsset = buildAsset("READY", "PUBLIC", "Shop hero fallback", "Shop alt fallback"); + MediaAsset draftAsset = buildAsset("PROCESSING", "PUBLIC", "Draft", "Draft alt"); + MediaAsset privateAsset = buildAsset("READY", "PRIVATE", "Private", "Private alt"); + + MediaUsage usageSecond = buildUsage(readyPublicAsset, "HOME_SECTION", "shop-gallery", 2, false, true); + MediaUsage usageFirst = buildUsage(readyPublicAsset, "HOME_SECTION", "shop-gallery", 1, true, true); + applyTranslation(usageSecond, "it", "Shop hero IT", "Shop alt IT"); + applyTranslation(usageSecond, "en", "Shop hero EN", "Shop alt EN"); + applyTranslation(usageSecond, "de", "Shop hero DE", "Shop alt DE"); + applyTranslation(usageSecond, "fr", "Shop hero FR", "Shop alt FR"); + applyTranslation(usageFirst, "it", "Shop hero IT", "Shop alt IT"); + applyTranslation(usageFirst, "en", "Shop hero EN", "Shop alt EN"); + applyTranslation(usageFirst, "de", "Shop hero DE", "Shop alt DE"); + applyTranslation(usageFirst, "fr", "Shop hero FR", "Shop alt FR"); + MediaUsage usageDraft = buildUsage(draftAsset, "HOME_SECTION", "shop-gallery", 0, false, true); + MediaUsage usagePrivate = buildUsage(privateAsset, "HOME_SECTION", "shop-gallery", 3, false, true); + + when(mediaUsageRepository.findByUsageTypeAndUsageKeyAndIsActiveTrueOrderBySortOrderAscCreatedAtAsc( + "HOME_SECTION", "shop-gallery" + )).thenReturn(List.of(usageSecond, usageFirst, usageDraft, usagePrivate)); + when(mediaVariantRepository.findByMediaAsset_IdIn(List.of(readyPublicAsset.getId()))) + .thenReturn(List.of( + buildVariant(readyPublicAsset, "thumb", "JPEG", "asset/thumb.jpg"), + buildVariant(readyPublicAsset, "thumb", "WEBP", "asset/thumb.webp"), + buildVariant(readyPublicAsset, "hero", "AVIF", "asset/hero.avif"), + buildVariant(readyPublicAsset, "hero", "JPEG", "asset/hero.jpg") + )); + + List result = service.getUsageMedia("home_section", "shop-gallery", "en"); + + assertEquals(2, result.size()); + assertEquals(1, result.get(0).getSortOrder()); + assertEquals(Boolean.TRUE, result.get(0).getIsPrimary()); + assertEquals("Shop hero EN", result.get(0).getTitle()); + assertEquals("Shop alt EN", result.get(0).getAltText()); + assertEquals("https://cdn.example/media/asset/thumb.jpg", result.get(0).getThumb().getJpegUrl()); + assertEquals("https://cdn.example/media/asset/thumb.webp", result.get(0).getThumb().getWebpUrl()); + assertEquals("https://cdn.example/media/asset/hero.avif", result.get(0).getHero().getAvifUrl()); + } + + @Test + void getUsageMedia_shouldReturnNullForMissingFormatsOrPresetsAndFallbackToAssetMetadata() { + MediaAsset asset = buildAsset("READY", "PUBLIC", "Joe portrait", "Joe portrait fallback"); + MediaUsage usage = buildUsage(asset, "ABOUT_MEMBER", "joe", 0, true, true); + + when(mediaUsageRepository.findByUsageTypeAndUsageKeyAndIsActiveTrueOrderBySortOrderAscCreatedAtAsc( + "ABOUT_MEMBER", "joe" + )).thenReturn(List.of(usage)); + when(mediaVariantRepository.findByMediaAsset_IdIn(List.of(asset.getId()))) + .thenReturn(List.of(buildVariant(asset, "card", "JPEG", "joe/card.jpg"))); + + List result = service.getUsageMedia("ABOUT_MEMBER", "joe", "fr"); + + assertEquals(1, result.size()); + assertEquals("Joe portrait", result.get(0).getTitle()); + assertEquals("Joe portrait fallback", result.get(0).getAltText()); + assertNull(result.get(0).getThumb().getJpegUrl()); + assertNull(result.get(0).getCard().getAvifUrl()); + assertEquals("https://cdn.example/media/joe/card.jpg", result.get(0).getCard().getJpegUrl()); + assertNull(result.get(0).getHero().getWebpUrl()); + assertTrue(result.get(0).getIsPrimary()); + } + + private MediaAsset buildAsset(String status, String visibility, String title, String altText) { + MediaAsset asset = new MediaAsset(); + asset.setId(UUID.randomUUID()); + asset.setStatus(status); + asset.setVisibility(visibility); + asset.setTitle(title); + asset.setAltText(altText); + asset.setCreatedAt(OffsetDateTime.now()); + asset.setUpdatedAt(OffsetDateTime.now()); + return asset; + } + + private MediaUsage buildUsage(MediaAsset asset, + String usageType, + String usageKey, + int sortOrder, + boolean isPrimary, + boolean isActive) { + MediaUsage usage = new MediaUsage(); + usage.setId(UUID.randomUUID()); + usage.setMediaAsset(asset); + usage.setUsageType(usageType); + usage.setUsageKey(usageKey); + usage.setSortOrder(sortOrder); + usage.setIsPrimary(isPrimary); + usage.setIsActive(isActive); + usage.setCreatedAt(OffsetDateTime.now().plusSeconds(sortOrder)); + return usage; + } + + private MediaVariant buildVariant(MediaAsset asset, String variantName, String format, String storageKey) { + MediaVariant variant = new MediaVariant(); + variant.setId(UUID.randomUUID()); + variant.setMediaAsset(asset); + variant.setVariantName(variantName); + variant.setFormat(format); + variant.setStorageKey(storageKey); + variant.setCreatedAt(OffsetDateTime.now()); + return variant; + } + + private void applyTranslation(MediaUsage usage, String language, String title, String altText) { + MediaTextTranslationDto translation = new MediaTextTranslationDto(); + translation.setTitle(title); + translation.setAltText(altText); + usage.setTitleForLanguage(language, translation.getTitle()); + usage.setAltTextForLanguage(language, translation.getAltText()); + } +} diff --git a/db.sql b/db.sql index d7fd322..d587f31 100644 --- a/db.sql +++ b/db.sql @@ -919,6 +919,94 @@ CREATE TABLE IF NOT EXISTS custom_quote_request_attachments CREATE INDEX IF NOT EXISTS ix_custom_quote_attachments_request ON custom_quote_request_attachments (request_id); +CREATE TABLE IF NOT EXISTS media_asset +( + media_asset_id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + original_filename text NOT NULL, + storage_key text NOT NULL UNIQUE, + mime_type text NOT NULL, + file_size_bytes bigint NOT NULL CHECK (file_size_bytes >= 0), + sha256_hex text NOT NULL, + width_px integer, + height_px integer, + status text NOT NULL CHECK (status IN ('UPLOADED', 'PROCESSING', 'READY', 'FAILED', 'ARCHIVED')), + visibility text NOT NULL CHECK (visibility IN ('PUBLIC', 'PRIVATE')), + title text, + alt_text text, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now() +); + +CREATE INDEX IF NOT EXISTS ix_media_asset_status_visibility_created_at + ON media_asset (status, visibility, created_at DESC); + +CREATE TABLE IF NOT EXISTS media_variant +( + media_variant_id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + media_asset_id uuid NOT NULL REFERENCES media_asset (media_asset_id) ON DELETE CASCADE, + variant_name text NOT NULL, + format text NOT NULL CHECK (format IN ('ORIGINAL', 'JPEG', 'WEBP', 'AVIF')), + storage_key text NOT NULL UNIQUE, + mime_type text NOT NULL, + width_px integer NOT NULL, + height_px integer NOT NULL, + file_size_bytes bigint NOT NULL CHECK (file_size_bytes >= 0), + is_generated boolean NOT NULL DEFAULT true, + created_at timestamptz NOT NULL DEFAULT now(), + CONSTRAINT uq_media_variant_asset_name_format UNIQUE (media_asset_id, variant_name, format) +); + +CREATE INDEX IF NOT EXISTS ix_media_variant_asset + ON media_variant (media_asset_id); + +CREATE TABLE IF NOT EXISTS media_usage +( + media_usage_id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + usage_type text NOT NULL, + usage_key text NOT NULL, + owner_id uuid, + media_asset_id uuid NOT NULL REFERENCES media_asset (media_asset_id) ON DELETE CASCADE, + sort_order integer NOT NULL DEFAULT 0, + is_primary boolean NOT NULL DEFAULT false, + is_active boolean NOT NULL DEFAULT true, + title_it text, + title_en text, + title_de text, + title_fr text, + alt_text_it text, + alt_text_en text, + alt_text_de text, + alt_text_fr text, + created_at timestamptz NOT NULL DEFAULT now() +); + +CREATE INDEX IF NOT EXISTS ix_media_usage_scope + ON media_usage (usage_type, usage_key, is_active, sort_order); + +ALTER TABLE media_usage + ADD COLUMN IF NOT EXISTS title_it text; + +ALTER TABLE media_usage + ADD COLUMN IF NOT EXISTS title_en text; + +ALTER TABLE media_usage + ADD COLUMN IF NOT EXISTS title_de text; + +ALTER TABLE media_usage + ADD COLUMN IF NOT EXISTS title_fr text; + +ALTER TABLE media_usage + ADD COLUMN IF NOT EXISTS alt_text_it text; + +ALTER TABLE media_usage + ADD COLUMN IF NOT EXISTS alt_text_en text; + +ALTER TABLE media_usage + ADD COLUMN IF NOT EXISTS alt_text_de text; + +ALTER TABLE media_usage + ADD COLUMN IF NOT EXISTS alt_text_fr text; + ALTER TABLE quote_sessions DROP CONSTRAINT IF EXISTS fk_quote_sessions_source_request; diff --git a/docker-compose.deploy.yml b/docker-compose.deploy.yml index a3db5b0..3b2853c 100644 --- a/docker-compose.deploy.yml +++ b/docker-compose.deploy.yml @@ -1,6 +1,8 @@ services: backend: # L'immagine usa il tag specificato nel file .env o passato da riga di comando + # Nginx esterno deve servire /media/ con un alias verso + # /mnt/cache/appdata/print-calculator/${ENV}/storage_media/public/ image: ${REGISTRY_URL}/${REPO_OWNER}/print-calculator-backend:${TAG} container_name: print-calculator-backend-${ENV} ports: @@ -29,6 +31,10 @@ services: - ADMIN_SESSION_TTL_MINUTES=${ADMIN_SESSION_TTL_MINUTES:-480} - TEMP_DIR=/app/temp - PROFILES_DIR=/app/profiles + - MEDIA_STORAGE_ROOT=${MEDIA_STORAGE_ROOT:-/app/storage_media} + - MEDIA_PUBLIC_BASE_URL=${MEDIA_PUBLIC_BASE_URL:-http://localhost:8080/media} + - MEDIA_FFMPEG_PATH=${MEDIA_FFMPEG_PATH:-ffmpeg} + - MEDIA_UPLOAD_MAX_FILE_SIZE_BYTES=${MEDIA_UPLOAD_MAX_FILE_SIZE_BYTES:-26214400} restart: always logging: driver: "json-file" @@ -40,6 +46,7 @@ services: - /mnt/cache/appdata/print-calculator/${ENV}/storage_quotes:/app/storage_quotes - /mnt/cache/appdata/print-calculator/${ENV}/storage_orders:/app/storage_orders - /mnt/cache/appdata/print-calculator/${ENV}/storage_requests:/app/storage_requests + - /mnt/cache/appdata/print-calculator/${ENV}/storage_media:/app/storage_media extra_hosts: - "host.docker.internal:host-gateway" diff --git a/frontend/src/app/core/services/public-media.service.ts b/frontend/src/app/core/services/public-media.service.ts new file mode 100644 index 0000000..4d76622 --- /dev/null +++ b/frontend/src/app/core/services/public-media.service.ts @@ -0,0 +1,264 @@ +import { inject, Injectable, Injector } from '@angular/core'; +import { toObservable } from '@angular/core/rxjs-interop'; +import { HttpClient, HttpParams } from '@angular/common/http'; +import { + Observable, + combineLatest, + map, + of, + catchError, + distinctUntilChanged, + switchMap, +} from 'rxjs'; +import { environment } from '../../../environments/environment'; +import { LanguageService } from './language.service'; + +export type PublicMediaUsageType = string; +export type PublicMediaPreset = 'thumb' | 'card' | 'hero'; + +export interface PublicMediaVariantDto { + avifUrl: string | null; + webpUrl: string | null; + jpegUrl: string | null; +} + +export interface PublicMediaUsageDto { + mediaAssetId: string; + title: string | null; + altText: string | null; + usageType: string; + usageKey: string; + sortOrder: number; + isPrimary: boolean; + thumb: PublicMediaVariantDto | null; + card: PublicMediaVariantDto | null; + hero: PublicMediaVariantDto | null; +} + +export interface PublicMediaSourceSet { + preset: PublicMediaPreset; + avifUrl: string | null; + webpUrl: string | null; + jpegUrl: string | null; + fallbackUrl: string | null; +} + +export interface PublicMediaResolvedSourceSet + extends Omit { + fallbackUrl: string; +} + +export interface PublicMediaImage { + mediaAssetId: string; + title: string | null; + altText: string | null; + usageType: string; + usageKey: string; + sortOrder: number; + isPrimary: boolean; + thumb: PublicMediaSourceSet; + card: PublicMediaSourceSet; + hero: PublicMediaSourceSet; +} + +export interface PublicMediaDisplayImage + extends Omit { + source: PublicMediaResolvedSourceSet; +} + +export interface PublicMediaUsageRequest { + usageType: PublicMediaUsageType; + usageKey: string; +} + +export type PublicMediaUsageCollectionMap = Record< + string, + readonly PublicMediaImage[] +>; + +export function buildPublicMediaUsageScopeKey( + usageType: string, + usageKey: string, +): string { + return `${usageType}::${usageKey}`; +} + +@Injectable({ + providedIn: 'root', +}) +export class PublicMediaService { + private readonly http = inject(HttpClient); + private readonly injector = inject(Injector); + private readonly languageService = inject(LanguageService); + private readonly baseUrl = `${environment.apiUrl}/api/public/media`; + private readonly selectedLang$ = toObservable( + this.languageService.currentLang, + { + injector: this.injector, + }, + ).pipe(distinctUntilChanged()); + + getUsageMedia( + usageType: PublicMediaUsageType, + usageKey: string, + ): Observable { + return this.selectedLang$.pipe( + switchMap((lang) => { + const params = new HttpParams() + .set('usageType', usageType) + .set('usageKey', usageKey) + .set('lang', lang); + + return this.http + .get(`${this.baseUrl}/usages`, { params }) + .pipe( + map((items) => + items + .map((item) => this.mapUsageDto(item)) + .filter((item) => this.hasAnyFallback(item)), + ), + catchError(() => of([])), + ); + }), + ); + } + + getUsageCollections( + requests: readonly PublicMediaUsageRequest[], + ): Observable { + if (requests.length === 0) { + return of({}); + } + + return combineLatest( + requests.map((request) => + this.getUsageMedia(request.usageType, request.usageKey).pipe( + map( + (items) => + [ + buildPublicMediaUsageScopeKey( + request.usageType, + request.usageKey, + ), + items, + ] as const, + ), + ), + ), + ).pipe( + map((entries) => + entries.reduce((acc, [key, value]) => { + acc[key] = value; + return acc; + }, {}), + ), + ); + } + + pickPrimaryUsage( + items: readonly PublicMediaImage[], + ): PublicMediaImage | null { + if (items.length === 0) { + return null; + } + return items.find((item) => item.isPrimary) ?? items[0] ?? null; + } + + toDisplayImage( + item: PublicMediaImage, + preferredPreset: PublicMediaPreset, + ): PublicMediaDisplayImage | null { + const source = this.pickPresetSource(item, preferredPreset); + if (!source) { + return null; + } + + return { + mediaAssetId: item.mediaAssetId, + title: item.title, + altText: item.altText, + usageType: item.usageType, + usageKey: item.usageKey, + sortOrder: item.sortOrder, + isPrimary: item.isPrimary, + source, + }; + } + + private mapUsageDto(item: PublicMediaUsageDto): PublicMediaImage { + return { + mediaAssetId: item.mediaAssetId, + title: item.title ?? null, + altText: item.altText ?? null, + usageType: item.usageType, + usageKey: item.usageKey, + sortOrder: item.sortOrder, + isPrimary: item.isPrimary, + thumb: this.mapPreset(item.thumb, 'thumb'), + card: this.mapPreset(item.card, 'card'), + hero: this.mapPreset(item.hero, 'hero'), + }; + } + + private mapPreset( + preset: PublicMediaVariantDto | null | undefined, + presetName: PublicMediaPreset, + ): PublicMediaSourceSet { + const avifUrl = this.normalizeUrl(preset?.avifUrl); + const webpUrl = this.normalizeUrl(preset?.webpUrl); + const jpegUrl = this.normalizeUrl(preset?.jpegUrl); + + return { + preset: presetName, + avifUrl, + webpUrl, + jpegUrl, + fallbackUrl: jpegUrl ?? webpUrl ?? avifUrl, + }; + } + + private pickPresetSource( + item: PublicMediaImage, + preferredPreset: PublicMediaPreset, + ): PublicMediaResolvedSourceSet | null { + const presetOrder = this.buildPresetFallbackOrder(preferredPreset); + const source = presetOrder + .map((preset) => item[preset]) + .find((sourceSet) => sourceSet.fallbackUrl !== null); + + if (!source || source.fallbackUrl === null) { + return null; + } + + return { + preset: source.preset, + avifUrl: source.avifUrl, + webpUrl: source.webpUrl, + jpegUrl: source.jpegUrl, + fallbackUrl: source.fallbackUrl, + }; + } + + private buildPresetFallbackOrder( + preferredPreset: PublicMediaPreset, + ): readonly PublicMediaPreset[] { + switch (preferredPreset) { + case 'thumb': + return ['thumb', 'card', 'hero']; + case 'card': + return ['card', 'thumb', 'hero']; + case 'hero': + return ['hero', 'card', 'thumb']; + } + } + + private hasAnyFallback(item: PublicMediaImage): boolean { + return [item.thumb, item.card, item.hero].some( + (preset) => preset.fallbackUrl !== null, + ); + } + + private normalizeUrl(value: string | null | undefined): string | null { + return value && value.trim() ? value : null; + } +} diff --git a/frontend/src/app/features/about/about-page.component.html b/frontend/src/app/features/about/about-page.component.html index 0323287..fd9f03d 100644 --- a/frontend/src/app/features/about/about-page.component.html +++ b/frontend/src/app/features/about/about-page.component.html @@ -39,10 +39,22 @@ (keydown.space)="toggleSelectedMember('joe'); $event.preventDefault()" >
- + @if (joeImage(); as image) { + + @if (image.source.avifUrl) { + + } + @if (image.source.webpUrl) { + + } + + + }
{{ @@ -71,10 +83,22 @@ " >
- + @if (matteoImage(); as image) { + + @if (image.source.avifUrl) { + + } + @if (image.source.webpUrl) { + + } + + + }
{{ diff --git a/frontend/src/app/features/about/about-page.component.scss b/frontend/src/app/features/about/about-page.component.scss index 007ce8b..95366e6 100644 --- a/frontend/src/app/features/about/about-page.component.scss +++ b/frontend/src/app/features/about/about-page.component.scss @@ -193,6 +193,12 @@ h1 { object-fit: cover; } +.placeholder-img picture { + width: 100%; + height: 100%; + display: block; +} + .member-info { text-align: center; } diff --git a/frontend/src/app/features/about/about-page.component.ts b/frontend/src/app/features/about/about-page.component.ts index ddb7caa..a5b323c 100644 --- a/frontend/src/app/features/about/about-page.component.ts +++ b/frontend/src/app/features/about/about-page.component.ts @@ -1,6 +1,15 @@ -import { Component } from '@angular/core'; +import { Component, computed, inject } from '@angular/core'; +import { toSignal } from '@angular/core/rxjs-interop'; import { TranslateModule } from '@ngx-translate/core'; import { AppLocationsComponent } from '../../shared/components/app-locations/app-locations.component'; +import { + buildPublicMediaUsageScopeKey, + PublicMediaDisplayImage, + PublicMediaService, + PublicMediaUsageCollectionMap, +} from '../../core/services/public-media.service'; + +const EMPTY_MEDIA_COLLECTIONS: PublicMediaUsageCollectionMap = {}; type MemberId = 'joe' | 'matteo'; type PassionId = @@ -32,6 +41,39 @@ interface PassionChip { styleUrl: './about-page.component.scss', }) export class AboutPageComponent { + private readonly publicMediaService = inject(PublicMediaService); + private readonly mediaByUsage = toSignal( + this.publicMediaService.getUsageCollections([ + { + usageType: 'ABOUT_MEMBER', + usageKey: 'joe', + }, + { + usageType: 'ABOUT_MEMBER', + usageKey: 'matteo', + }, + ]), + { initialValue: EMPTY_MEDIA_COLLECTIONS }, + ); + + readonly joeImage = computed(() => { + const image = this.publicMediaService.pickPrimaryUsage( + this.mediaByUsage()[ + buildPublicMediaUsageScopeKey('ABOUT_MEMBER', 'joe') + ] ?? [], + ); + return image ? this.publicMediaService.toDisplayImage(image, 'card') : null; + }); + + readonly matteoImage = computed(() => { + const image = this.publicMediaService.pickPrimaryUsage( + this.mediaByUsage()[ + buildPublicMediaUsageScopeKey('ABOUT_MEMBER', 'matteo') + ] ?? [], + ); + return image ? this.publicMediaService.toDisplayImage(image, 'card') : null; + }); + selectedMember: MemberId | null = null; hoveredMember: MemberId | null = null; diff --git a/frontend/src/app/features/admin/admin.routes.ts b/frontend/src/app/features/admin/admin.routes.ts index fe4afb0..75b648e 100644 --- a/frontend/src/app/features/admin/admin.routes.ts +++ b/frontend/src/app/features/admin/admin.routes.ts @@ -57,6 +57,13 @@ export const ADMIN_ROUTES: Routes = [ (m) => m.AdminCadInvoicesComponent, ), }, + { + path: 'home-media', + loadComponent: () => + import('./pages/admin-home-media.component').then( + (m) => m.AdminHomeMediaComponent, + ), + }, ], }, ]; diff --git a/frontend/src/app/features/admin/pages/admin-cad-invoices.component.scss b/frontend/src/app/features/admin/pages/admin-cad-invoices.component.scss index abc3368..679f3b0 100644 --- a/frontend/src/app/features/admin/pages/admin-cad-invoices.component.scss +++ b/frontend/src/app/features/admin/pages/admin-cad-invoices.component.scss @@ -13,6 +13,7 @@ .page-header h1 { margin: 0; + font-size: 1.45rem; } .page-header p { @@ -43,7 +44,8 @@ button:disabled { } .create-box h2 { - margin-top: 0; + margin: 0 0 var(--space-3); + font-size: 1.05rem; } .form-grid { diff --git a/frontend/src/app/features/admin/pages/admin-contact-requests.component.scss b/frontend/src/app/features/admin/pages/admin-contact-requests.component.scss index 825c455..bc87b8e 100644 --- a/frontend/src/app/features/admin/pages/admin-contact-requests.component.scss +++ b/frontend/src/app/features/admin/pages/admin-contact-requests.component.scss @@ -1,9 +1,7 @@ .section-card { - background: var(--color-bg-card); - border: 1px solid var(--color-border); - border-radius: var(--radius-lg); - padding: clamp(12px, 2vw, 24px); - box-shadow: var(--shadow-sm); + display: flex; + flex-direction: column; + gap: var(--space-5); } .section-header { @@ -11,7 +9,6 @@ justify-content: space-between; align-items: flex-start; gap: var(--space-4); - margin-bottom: var(--space-4); } .section-header h2 { @@ -315,12 +312,12 @@ tbody tr.selected { .error { color: var(--color-danger-500); - margin-bottom: var(--space-3); + margin: 0; } .success { color: #157347; - margin-bottom: var(--space-3); + margin: 0; } .status-editor { @@ -411,7 +408,7 @@ button:disabled { @media (max-width: 760px) { .section-card { - padding: var(--space-4); + gap: var(--space-4); } .section-header { @@ -447,10 +444,6 @@ button:disabled { } @media (max-width: 520px) { - .section-card { - padding: var(--space-3); - } - th, td { padding: var(--space-2); diff --git a/frontend/src/app/features/admin/pages/admin-dashboard.component.scss b/frontend/src/app/features/admin/pages/admin-dashboard.component.scss index b0805b3..002ed94 100644 --- a/frontend/src/app/features/admin/pages/admin-dashboard.component.scss +++ b/frontend/src/app/features/admin/pages/admin-dashboard.component.scss @@ -1,9 +1,7 @@ .admin-dashboard { - background: var(--color-bg-card); - border: 1px solid var(--color-border); - border-radius: var(--radius-lg); - padding: clamp(12px, 2vw, 20px); - box-shadow: var(--shadow-sm); + display: flex; + flex-direction: column; + gap: var(--space-5); } .dashboard-header { @@ -11,7 +9,6 @@ justify-content: space-between; align-items: flex-start; gap: var(--space-4); - margin-bottom: var(--space-4); } .dashboard-header h1 { @@ -294,7 +291,7 @@ tbody tr.no-results:hover { .error { color: var(--color-danger-500); - margin-bottom: var(--space-3); + margin: 0; } .modal-backdrop { @@ -404,7 +401,7 @@ h4 { @media (max-width: 820px) { .admin-dashboard { - padding: var(--space-4); + gap: var(--space-4); } .list-toolbar { @@ -449,10 +446,6 @@ h4 { } @media (max-width: 520px) { - .admin-dashboard { - padding: var(--space-3); - } - th, td { padding: var(--space-2); diff --git a/frontend/src/app/features/admin/pages/admin-filament-stock.component.scss b/frontend/src/app/features/admin/pages/admin-filament-stock.component.scss index 49bdf6c..38275b1 100644 --- a/frontend/src/app/features/admin/pages/admin-filament-stock.component.scss +++ b/frontend/src/app/features/admin/pages/admin-filament-stock.component.scss @@ -1,9 +1,7 @@ .section-card { - background: var(--color-bg-card); - border: 1px solid var(--color-border); - border-radius: var(--radius-lg); - padding: clamp(12px, 2vw, 24px); - box-shadow: var(--shadow-sm); + display: flex; + flex-direction: column; + gap: var(--space-5); } .section-header { @@ -11,7 +9,6 @@ justify-content: space-between; align-items: flex-start; gap: var(--space-4); - margin-bottom: var(--space-4); } .section-header h2 { @@ -26,7 +23,6 @@ .alerts { display: grid; gap: var(--space-2); - margin-bottom: var(--space-3); } .content { @@ -374,7 +370,7 @@ button:disabled { @media (max-width: 760px) { .section-card { - padding: var(--space-4); + gap: var(--space-4); } .form-grid { @@ -404,9 +400,3 @@ button:disabled { width: 100%; } } - -@media (max-width: 520px) { - .section-card { - padding: var(--space-3); - } -} diff --git a/frontend/src/app/features/admin/pages/admin-home-media.component.html b/frontend/src/app/features/admin/pages/admin-home-media.component.html new file mode 100644 index 0000000..236e0a9 --- /dev/null +++ b/frontend/src/app/features/admin/pages/admin-home-media.component.html @@ -0,0 +1,359 @@ +
+
+
+

Back-office media

+

Media home

+
+
+
+
+ {{ configuredSectionCount }} + sezioni gestite +
+
+ {{ activeImageCount }} + immagini attive +
+
+ +
+
+ +

+ {{ errorMessage }} +

+

+ {{ successMessage }} +

+ +
+
+
+

{{ group.title }}

+
+ +
+
+
+
+
+

{{ section.title }}

+ + {{ section.items.length }} + {{ section.items.length === 1 ? "attiva" : "attive" }} + +
+
+
+ {{ section.usageType }} / {{ section.usageKey }} + + Variante {{ section.preferredVariantName }} + +
+
+ +
+
+
+
+ {{ + getFormState(section.usageKey).replacingUsageId + ? "Sostituisci immagine" + : "Carica immagine" + }} +
+
+ +
+
+ File immagine + + +
+ +
+ +
+ +
+
+ Testi localizzati +

IT / EN / DE / FR obbligatorie

+
+
+ +
+
+ + + + + + + + +
+ +
+ + + +
+
+ +
+
+
Immagini attive
+
+ +
+
+
+
+ +
+
+ +
+
+
+
+ {{ + getItemTranslation( + item, + getFormState(section.usageKey).activeLanguage + ).title || item.originalFilename + }} +
+

+ {{ item.originalFilename }} | asset + {{ item.mediaAssetId }} +

+
+ Primaria +
+ +

+ Alt + {{ + mediaLanguageLabels[ + getFormState(section.usageKey).activeLanguage + ] + }}: + {{ + getItemTranslation( + item, + getFormState(section.usageKey).activeLanguage + ).altText || "-" + }} +

+

+ Sort order: {{ item.sortOrder }} | Inserita: + {{ item.createdAt | date: "short" }} +

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

+ Nessuna immagine attiva collegata a questa sezione home. +

+
+ + +
+ Preview non disponibile +
+
+
+ + +

Caricamento media home...

+
diff --git a/frontend/src/app/features/admin/pages/admin-home-media.component.scss b/frontend/src/app/features/admin/pages/admin-home-media.component.scss new file mode 100644 index 0000000..84f0e04 --- /dev/null +++ b/frontend/src/app/features/admin/pages/admin-home-media.component.scss @@ -0,0 +1,608 @@ +.section-card { + display: grid; + gap: var(--space-5); +} + +.section-header, +.media-panel-header, +.media-copy-top, +.upload-actions, +.item-actions, +.sort-editor { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: var(--space-3); +} + +.section-header { + align-items: flex-start; +} + +.eyebrow { + margin: 0 0 var(--space-2); + text-transform: uppercase; + letter-spacing: 0.14em; + font-size: 0.72rem; + font-weight: 700; + color: var(--color-secondary-600); +} + +.header-copy h2, +.media-panel-header h4, +.panel-heading h5, +.media-copy h6, +.group-header h3 { + margin: 0; +} + +.header-copy p, +.media-panel-header p, +.group-header p, +.panel-heading p, +.empty-state, +.meta { + margin: var(--space-2) 0 0; + color: var(--color-text-muted); +} + +.header-side { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: var(--space-3); +} + +.header-stats { + display: flex; + flex-wrap: wrap; + justify-content: flex-start; + gap: var(--space-2); +} + +.header-side > button { + align-self: flex-start; +} + +.stat-chip { + min-width: 128px; + padding: 0.75rem 0.9rem; + border-radius: var(--radius-md); + border: 1px solid var(--color-border); + background: linear-gradient(180deg, #fffdf5 0%, #ffffff 100%); + display: grid; + gap: 0.15rem; +} + +.stat-chip strong { + font-size: 1.15rem; + line-height: 1; +} + +.stat-chip span { + font-size: 0.8rem; + color: var(--color-text-muted); +} + +.status-banner { + margin: 0; + padding: 0.85rem 1rem; + border-radius: var(--radius-md); + border: 1px solid transparent; + font-weight: 600; +} + +.status-banner-error { + color: #8a241d; + background: #fff1f0; + border-color: #f2c3bf; +} + +.status-banner-success { + color: #20613a; + background: #eef9f1; + border-color: #b7e3c4; +} + +.group-stack { + display: grid; + gap: var(--space-4); +} + +.group-card { + border: 1px solid var(--color-border); + border-radius: calc(var(--radius-lg) + 2px); + padding: clamp(12px, 1.8vw, 18px); + background: linear-gradient(180deg, #fcfbf8 0%, #ffffff 100%); +} + +.group-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: var(--space-4); + margin-bottom: var(--space-3); +} + +.sections { + display: grid; + gap: var(--space-3); +} + +.media-panel { + border: 1px solid var(--color-border); + border-radius: var(--radius-lg); + background: #ffffff; + padding: var(--space-3); + display: grid; + gap: var(--space-3); + box-shadow: 0 10px 24px rgba(15, 23, 42, 0.04); +} + +.usage-pill, +.primary-badge, +.count-pill, +.layout-pill { + border-radius: 999px; + border: 1px solid var(--color-border); + padding: 6px 10px; + font-size: 0.78rem; + font-weight: 700; + line-height: 1; +} + +.usage-pill { + background: var(--color-neutral-100); + color: var(--color-text-muted); +} + +.layout-pill { + background: #f7f4e7; + color: var(--color-neutral-900); +} + +.count-pill { + background: #f8f9fb; + color: var(--color-neutral-900); +} + +.primary-badge { + background: #fff5b8; + color: var(--color-text); + border-color: #f1d65c; +} + +.media-panel-copy, +.media-panel-meta, +.panel-heading { + display: grid; + gap: var(--space-1); +} + +.media-panel-meta { + display: flex; + flex-wrap: wrap; + justify-content: flex-end; + align-items: center; + gap: var(--space-2); +} + +.title-row { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: var(--space-2); +} + +.workspace { + display: grid; + grid-template-columns: minmax(280px, 340px) minmax(0, 1fr); + gap: var(--space-3); + align-items: start; +} + +.upload-panel, +.list-panel { + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + background: linear-gradient(180deg, #fcfcfb 0%, #f7f7f4 100%); + padding: var(--space-3); +} + +.form-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: var(--space-2); + margin-top: var(--space-2); + margin-bottom: var(--space-3); +} + +.form-field { + display: grid; + gap: var(--space-1); +} + +.language-toolbar { + display: flex; + justify-content: space-between; + align-items: center; + gap: var(--space-2); + padding: var(--space-2); + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + background: #fbfaf6; +} + +.language-copy { + display: grid; + gap: 2px; +} + +.language-copy span { + font-size: 0.76rem; + font-weight: 700; + color: var(--color-text); +} + +.language-copy p { + margin: 0; + font-size: 0.76rem; + color: var(--color-text-muted); +} + +.language-toggle { + display: inline-flex; + flex-wrap: wrap; + justify-content: flex-end; + gap: 0.35rem; +} + +.language-toggle-btn { + min-width: 2.8rem; + padding: 0.4rem 0.6rem; + border: 1px solid var(--color-border); + border-radius: 999px; + background: #ffffff; + color: var(--color-text-muted); + font-size: 0.78rem; + font-weight: 700; + line-height: 1; +} + +.language-toggle-btn.complete { + border-color: #c9d8c4; +} + +.language-toggle-btn.incomplete { + border-color: #e8c8c2; +} + +.language-toggle-btn.active { + background: #fff5b8; + border-color: var(--color-brand); + color: var(--color-text); +} + +.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; +} + +.form-field--wide { + grid-column: 1 / -1; +} + +.form-field > span, +.sort-editor span { + font-size: 0.76rem; + color: var(--color-text-muted); + font-weight: 600; +} + +.file-picker { + display: flex; + align-items: center; + gap: var(--space-2); + padding: var(--space-2); + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + background: var(--color-bg-card); + cursor: pointer; + transition: + border-color 0.2s ease, + background-color 0.2s ease, + box-shadow 0.2s ease; +} + +.file-picker:hover { + border-color: var(--color-brand); + background: #fffef8; +} + +.file-picker-button { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 6.25rem; + padding: var(--space-2) var(--space-3); + border: 1px solid var(--color-border); + border-radius: calc(var(--radius-md) - 2px); + background: #ffffff; + font-weight: 600; + font-size: 0.95rem; + color: var(--color-text); + white-space: nowrap; +} + +.file-picker-name { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: 0.95rem; + color: var(--color-text-muted); +} + +input { + width: 100%; + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + padding: var(--space-2) var(--space-3); + background: var(--color-bg-card); + font: inherit; + font-size: 0.95rem; + color: var(--color-text); +} + +.toggle { + display: inline-flex; + align-items: center; + gap: 0.45rem; + border: 1px solid var(--color-border); + border-radius: 999px; + padding: var(--space-2) var(--space-3); + background: var(--color-bg-card); + align-self: end; + justify-self: start; + width: auto; + cursor: pointer; +} + +.toggle input { + position: absolute; + opacity: 0; + pointer-events: none; +} + +.toggle-mark { + width: 1.05rem; + height: 1.05rem; + border-radius: 0.3rem; + border: 1px solid var(--color-border); + background: #ffffff; + position: relative; + flex: 0 0 auto; +} + +.toggle input:checked + .toggle-mark { + background: #b14fb8; + border-color: #b14fb8; +} + +.toggle input:checked + .toggle-mark::after { + content: ""; + position: absolute; + left: 0.31rem; + top: 0.12rem; + width: 0.22rem; + height: 0.46rem; + border: solid #ffffff; + border-width: 0 2px 2px 0; + transform: rotate(45deg); +} + +.toggle span { + font-size: 0.84rem; + font-weight: 600; +} + +.preview-card { + aspect-ratio: 16 / 10; + overflow: hidden; + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + background: var(--color-bg-card); +} + +.preview-card img, +.thumb img { + width: 100%; + height: 100%; + display: block; + object-fit: cover; +} + +.media-list { + display: grid; + gap: var(--space-2); +} + +.media-item { + display: grid; + grid-template-columns: 168px minmax(0, 1fr); + gap: var(--space-2); + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + padding: 0.75rem; + background: var(--color-bg-card); +} + +.thumb-wrap { + min-width: 0; +} + +.thumb { + aspect-ratio: 16 / 10; + border-radius: var(--radius-md); + overflow: hidden; + background: var(--color-neutral-200); + border: 1px solid var(--color-border); +} + +.thumb-empty { + display: grid; + place-items: center; + text-align: center; + color: var(--color-text-muted); + padding: var(--space-3); +} + +.media-copy { + min-width: 0; + display: grid; + gap: var(--space-1); +} + +.media-copy-top { + align-items: center; +} + +.media-copy h5 { + font-size: 1rem; +} + +.media-copy h6 { + font-size: 1rem; +} + +.meta { + overflow-wrap: anywhere; +} + +.sort-editor { + align-items: end; + flex-wrap: wrap; +} + +.sort-editor label { + display: grid; + gap: var(--space-1); +} + +.upload-actions { + justify-content: flex-start; + flex-wrap: wrap; + align-items: flex-start; + gap: var(--space-2); +} + +.upload-actions button { + min-width: 0; +} + +button { + border: 0; + border-radius: var(--radius-md); + background: var(--color-brand); + color: var(--color-neutral-900); + padding: var(--space-2) var(--space-4); + font-weight: 600; + line-height: 1.2; + cursor: pointer; + transition: + background-color 0.2s ease, + opacity 0.2s ease, + border-color 0.2s ease; +} + +button:hover:not(:disabled) { + background: var(--color-brand-hover); +} + +button:disabled { + opacity: 0.65; + cursor: default; +} + +button.ghost { + background: var(--color-bg-card); + color: var(--color-text); + border: 1px solid var(--color-border); +} + +button.ghost:hover:not(:disabled) { + background: #fff8cc; + border-color: var(--color-brand); +} + +button.ghost.danger:hover:not(:disabled) { + background: #fff0f0; + border-color: #d9534f; +} + +.loading-state { + margin: 0; + color: var(--color-text-muted); +} + +@media (max-width: 1200px) { + .workspace { + grid-template-columns: 1fr; + } +} + +@media (max-width: 720px) { + .form-grid, + .media-item { + grid-template-columns: 1fr; + } + + .language-toolbar { + flex-direction: column; + align-items: stretch; + } + + .language-toggle { + justify-content: flex-start; + } + + .file-picker { + flex-direction: column; + align-items: stretch; + } + + .file-picker-button { + width: 100%; + } + + .section-header, + .header-side, + .header-stats, + .group-header, + .media-panel-header, + .media-copy-top, + .upload-actions, + .item-actions, + .sort-editor { + flex-direction: column; + align-items: stretch; + } + + .usage-pill, + .primary-badge, + .count-pill, + .layout-pill { + width: fit-content; + } + + .media-panel-meta { + justify-content: flex-start; + } +} diff --git a/frontend/src/app/features/admin/pages/admin-home-media.component.ts b/frontend/src/app/features/admin/pages/admin-home-media.component.ts new file mode 100644 index 0000000..5358d24 --- /dev/null +++ b/frontend/src/app/features/admin/pages/admin-home-media.component.ts @@ -0,0 +1,644 @@ +import { CommonModule } from '@angular/common'; +import { Component, inject, OnDestroy, OnInit } from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { of, switchMap } from 'rxjs'; +import { + AdminCreateMediaUsagePayload, + AdminMediaLanguage, + AdminMediaAsset, + AdminMediaService, + AdminMediaTranslation, + AdminMediaUsage, +} from '../services/admin-media.service'; + +type HomeSectionKey = + | 'shop-gallery' + | 'founders-gallery' + | 'capability-prototyping' + | 'capability-custom-parts' + | 'capability-small-series' + | 'capability-cad'; + +interface HomeMediaSectionConfig { + usageType: 'HOME_SECTION'; + usageKey: HomeSectionKey; + groupId: 'galleries' | 'capabilities'; + title: string; + preferredVariantName: 'card' | 'hero'; +} + +interface HomeMediaFormState { + file: File | null; + previewUrl: string | null; + activeLanguage: AdminMediaLanguage; + translations: Record; + sortOrder: number; + isPrimary: boolean; + replacingUsageId: string | null; + saving: boolean; +} + +interface HomeMediaItem { + usageId: string; + mediaAssetId: string; + originalFilename: string; + translations: Record; + sortOrder: number; + draftSortOrder: number; + isPrimary: boolean; + previewUrl: string | null; + createdAt: string; +} + +interface HomeMediaSectionView extends HomeMediaSectionConfig { + items: HomeMediaItem[]; +} + +interface HomeMediaSectionGroup { + id: HomeMediaSectionConfig['groupId']; + title: string; +} + +const SUPPORTED_MEDIA_LANGUAGES: readonly AdminMediaLanguage[] = [ + 'it', + 'en', + 'de', + 'fr', +]; + +const MEDIA_LANGUAGE_LABELS: Readonly> = { + it: 'IT', + en: 'EN', + de: 'DE', + fr: 'FR', +}; + +@Component({ + selector: 'app-admin-home-media', + standalone: true, + imports: [CommonModule, FormsModule], + templateUrl: './admin-home-media.component.html', + styleUrl: './admin-home-media.component.scss', +}) +export class AdminHomeMediaComponent implements OnInit, OnDestroy { + private readonly adminMediaService = inject(AdminMediaService); + readonly mediaLanguages = SUPPORTED_MEDIA_LANGUAGES; + readonly mediaLanguageLabels = MEDIA_LANGUAGE_LABELS; + + readonly sectionGroups: readonly HomeMediaSectionGroup[] = [ + { + id: 'galleries', + title: 'Gallery e visual principali', + }, + { + id: 'capabilities', + title: 'Cosa puoi ottenere', + }, + ]; + + readonly sectionConfigs: readonly HomeMediaSectionConfig[] = [ + { + usageType: 'HOME_SECTION', + usageKey: 'shop-gallery', + groupId: 'galleries', + title: 'Home: gallery shop', + preferredVariantName: 'card', + }, + { + usageType: 'HOME_SECTION', + usageKey: 'founders-gallery', + groupId: 'galleries', + title: 'Home: gallery founders', + preferredVariantName: 'hero', + }, + { + usageType: 'HOME_SECTION', + usageKey: 'capability-prototyping', + groupId: 'capabilities', + title: 'Home: prototipazione veloce', + preferredVariantName: 'card', + }, + { + usageType: 'HOME_SECTION', + usageKey: 'capability-custom-parts', + groupId: 'capabilities', + title: 'Home: pezzi personalizzati', + preferredVariantName: 'card', + }, + { + usageType: 'HOME_SECTION', + usageKey: 'capability-small-series', + groupId: 'capabilities', + title: 'Home: piccole serie', + preferredVariantName: 'card', + }, + { + usageType: 'HOME_SECTION', + usageKey: 'capability-cad', + groupId: 'capabilities', + title: 'Home: consulenza e CAD', + preferredVariantName: 'card', + }, + ]; + + sections: HomeMediaSectionView[] = []; + loading = false; + errorMessage: string | null = null; + successMessage: string | null = null; + actingUsageIds = new Set(); + + private readonly formStateByKey: Record = + { + 'shop-gallery': this.createEmptyFormState(), + 'founders-gallery': this.createEmptyFormState(), + 'capability-prototyping': this.createEmptyFormState(), + 'capability-custom-parts': this.createEmptyFormState(), + 'capability-small-series': this.createEmptyFormState(), + 'capability-cad': this.createEmptyFormState(), + }; + + get configuredSectionCount(): number { + return this.sectionConfigs.length; + } + + get activeImageCount(): number { + return this.sections.reduce( + (total, section) => total + section.items.length, + 0, + ); + } + + ngOnInit(): void { + this.loadHomeMedia(); + } + + ngOnDestroy(): void { + (Object.keys(this.formStateByKey) as HomeSectionKey[]).forEach((key) => { + this.revokePreviewUrl(this.formStateByKey[key].previewUrl); + }); + } + + loadHomeMedia(): void { + this.loading = true; + this.errorMessage = null; + this.successMessage = null; + + this.adminMediaService.listAssets().subscribe({ + next: (assets) => { + this.sections = this.sectionConfigs.map((config) => ({ + ...config, + items: this.buildSectionItems(assets, config), + })); + this.loading = false; + (Object.keys(this.formStateByKey) as HomeSectionKey[]).forEach( + (key) => { + if (!this.formStateByKey[key].saving) { + this.resetForm(key); + } + }, + ); + }, + error: (error) => { + this.loading = false; + this.errorMessage = this.extractErrorMessage( + error, + 'Impossibile caricare i media della home.', + ); + }, + }); + } + + getFormState(sectionKey: HomeSectionKey): HomeMediaFormState { + return this.formStateByKey[sectionKey]; + } + + onFileSelected(sectionKey: HomeSectionKey, event: Event): void { + const input = event.target as HTMLInputElement | null; + const file = input?.files?.[0] ?? null; + const formState = this.getFormState(sectionKey); + + this.revokePreviewUrl(formState.previewUrl); + formState.file = file; + formState.previewUrl = file ? URL.createObjectURL(file) : null; + + if (file && this.areAllTitlesBlank(formState.translations)) { + const nextTitle = this.deriveDefaultTitle(file.name); + for (const language of this.mediaLanguages) { + formState.translations[language].title = nextTitle; + } + } + } + + prepareAdd(sectionKey: HomeSectionKey): void { + this.resetForm(sectionKey); + } + + prepareReplace(sectionKey: HomeSectionKey, item: HomeMediaItem): void { + const formState = this.getFormState(sectionKey); + this.revokePreviewUrl(formState.previewUrl); + formState.file = null; + formState.previewUrl = item.previewUrl; + formState.translations = this.cloneTranslations(item.translations); + formState.sortOrder = item.sortOrder; + formState.isPrimary = item.isPrimary; + formState.replacingUsageId = item.usageId; + } + + cancelReplace(sectionKey: HomeSectionKey): void { + this.resetForm(sectionKey); + } + + uploadForSection(sectionKey: HomeSectionKey): void { + const section = this.sections.find((item) => item.usageKey === sectionKey); + const formState = this.getFormState(sectionKey); + + if (!section || !formState.file || formState.saving) { + return; + } + + const validationError = this.validateTranslations(formState.translations); + if (validationError) { + this.errorMessage = validationError; + return; + } + + const normalizedTranslations = this.normalizeTranslations( + formState.translations, + ); + + formState.saving = true; + this.errorMessage = null; + this.successMessage = null; + + const createUsagePayload = ( + mediaAssetId: string, + ): AdminCreateMediaUsagePayload => ({ + usageType: section.usageType, + usageKey: section.usageKey, + mediaAssetId, + sortOrder: formState.sortOrder, + isPrimary: formState.isPrimary, + isActive: true, + translations: normalizedTranslations, + }); + + this.adminMediaService + .uploadAsset(formState.file, { + title: normalizedTranslations.it.title, + altText: normalizedTranslations.it.altText, + visibility: 'PUBLIC', + }) + .pipe( + switchMap((asset) => + this.adminMediaService.createUsage(createUsagePayload(asset.id)), + ), + switchMap(() => { + if (!formState.replacingUsageId) { + return of(null); + } + return this.adminMediaService.updateUsage( + formState.replacingUsageId, + { + isActive: false, + isPrimary: false, + }, + ); + }), + ) + .subscribe({ + next: () => { + formState.saving = false; + this.successMessage = formState.replacingUsageId + ? 'Immagine home sostituita.' + : 'Immagine home caricata.'; + this.loadHomeMedia(); + }, + error: (error) => { + formState.saving = false; + this.errorMessage = this.extractErrorMessage( + error, + 'Upload immagine non riuscito.', + ); + }, + }); + } + + setPrimary(item: HomeMediaItem): void { + if (item.isPrimary || this.actingUsageIds.has(item.usageId)) { + return; + } + + this.errorMessage = null; + this.successMessage = null; + this.actingUsageIds.add(item.usageId); + + this.adminMediaService + .updateUsage(item.usageId, { isPrimary: true, isActive: true }) + .subscribe({ + next: () => { + this.actingUsageIds.delete(item.usageId); + this.successMessage = 'Immagine principale aggiornata.'; + this.loadHomeMedia(); + }, + error: (error) => { + this.actingUsageIds.delete(item.usageId); + this.errorMessage = this.extractErrorMessage( + error, + 'Aggiornamento immagine principale non riuscito.', + ); + }, + }); + } + + saveSortOrder(item: HomeMediaItem): void { + if ( + this.actingUsageIds.has(item.usageId) || + item.draftSortOrder === item.sortOrder + ) { + return; + } + + this.errorMessage = null; + this.successMessage = null; + this.actingUsageIds.add(item.usageId); + + this.adminMediaService + .updateUsage(item.usageId, { sortOrder: item.draftSortOrder }) + .subscribe({ + next: () => { + this.actingUsageIds.delete(item.usageId); + this.successMessage = 'Ordine immagine aggiornato.'; + this.loadHomeMedia(); + }, + error: (error) => { + this.actingUsageIds.delete(item.usageId); + this.errorMessage = this.extractErrorMessage( + error, + 'Aggiornamento ordine non riuscito.', + ); + }, + }); + } + + removeFromHome(item: HomeMediaItem): void { + if (this.actingUsageIds.has(item.usageId)) { + return; + } + + this.errorMessage = null; + this.successMessage = null; + this.actingUsageIds.add(item.usageId); + + this.adminMediaService + .updateUsage(item.usageId, { isActive: false, isPrimary: false }) + .subscribe({ + next: () => { + this.actingUsageIds.delete(item.usageId); + this.successMessage = 'Immagine rimossa dalla home.'; + this.loadHomeMedia(); + }, + error: (error) => { + this.actingUsageIds.delete(item.usageId); + this.errorMessage = this.extractErrorMessage( + error, + 'Rimozione immagine dalla home non riuscita.', + ); + }, + }); + } + + isUsageBusy(usageId: string): boolean { + return this.actingUsageIds.has(usageId); + } + + setActiveLanguage( + sectionKey: HomeSectionKey, + language: AdminMediaLanguage, + ): void { + this.getFormState(sectionKey).activeLanguage = language; + } + + getActiveTranslation(sectionKey: HomeSectionKey): AdminMediaTranslation { + const formState = this.getFormState(sectionKey); + return formState.translations[formState.activeLanguage]; + } + + isLanguageComplete( + sectionKey: HomeSectionKey, + language: AdminMediaLanguage, + ): boolean { + return this.isTranslationComplete( + this.getFormState(sectionKey).translations[language], + ); + } + + getItemTranslation( + item: HomeMediaItem, + language: AdminMediaLanguage, + ): AdminMediaTranslation { + return item.translations[language]; + } + + getSectionsForGroup( + groupId: HomeMediaSectionGroup['id'], + ): HomeMediaSectionView[] { + return this.sections.filter((section) => section.groupId === groupId); + } + + trackSection(_: number, section: HomeMediaSectionView): string { + return section.usageKey; + } + + trackItem(_: number, item: HomeMediaItem): string { + return item.usageId; + } + + private buildSectionItems( + assets: readonly AdminMediaAsset[], + config: HomeMediaSectionConfig, + ): HomeMediaItem[] { + return assets + .flatMap((asset) => + asset.usages + .filter( + (usage) => + usage.isActive && + usage.usageType === config.usageType && + usage.usageKey === config.usageKey, + ) + .map((usage) => this.toHomeMediaItem(asset, usage, config)), + ) + .sort((left, right) => { + if (left.sortOrder !== right.sortOrder) { + return left.sortOrder - right.sortOrder; + } + return left.createdAt.localeCompare(right.createdAt); + }); + } + + private toHomeMediaItem( + asset: AdminMediaAsset, + usage: AdminMediaUsage, + config: HomeMediaSectionConfig, + ): HomeMediaItem { + return { + usageId: usage.id, + mediaAssetId: asset.id, + originalFilename: asset.originalFilename, + translations: this.normalizeTranslations(usage.translations), + sortOrder: usage.sortOrder ?? 0, + draftSortOrder: usage.sortOrder ?? 0, + isPrimary: usage.isPrimary, + previewUrl: this.resolvePreviewUrl(asset, config.preferredVariantName), + createdAt: usage.createdAt, + }; + } + + private resolvePreviewUrl( + asset: AdminMediaAsset, + preferredVariantName: 'card' | 'hero', + ): string | null { + const variantOrder = + preferredVariantName === 'hero' + ? ['hero', 'card', 'thumb'] + : ['card', 'thumb', 'hero']; + const formatOrder = ['JPEG', 'WEBP', 'AVIF']; + + for (const variantName of variantOrder) { + for (const format of formatOrder) { + const match = asset.variants.find( + (variant) => + variant.variantName === variantName && + variant.format === format && + !!variant.publicUrl, + ); + if (match?.publicUrl) { + return match.publicUrl; + } + } + } + + return null; + } + + private resetForm(sectionKey: HomeSectionKey): void { + const formState = this.getFormState(sectionKey); + const section = this.sections.find((item) => item.usageKey === sectionKey); + const nextSortOrder = (section?.items.at(-1)?.sortOrder ?? -1) + 1; + + this.revokePreviewUrl(formState.previewUrl); + this.formStateByKey[sectionKey] = { + file: null, + previewUrl: null, + activeLanguage: 'it', + translations: this.createEmptyTranslations(), + sortOrder: Math.max(0, nextSortOrder), + isPrimary: (section?.items.length ?? 0) === 0, + replacingUsageId: null, + saving: false, + }; + } + + private revokePreviewUrl(previewUrl: string | null): void { + if (!previewUrl?.startsWith('blob:')) { + return; + } + URL.revokeObjectURL(previewUrl); + } + + private deriveDefaultTitle(filename: string): string { + const normalized = filename.replace(/\.[^.]+$/, '').replace(/[-_]+/g, ' '); + return normalized.trim(); + } + + private createEmptyFormState(): HomeMediaFormState { + return { + file: null, + previewUrl: null, + activeLanguage: 'it', + translations: this.createEmptyTranslations(), + sortOrder: 0, + isPrimary: false, + replacingUsageId: null, + saving: false, + }; + } + + private createEmptyTranslations(): Record< + AdminMediaLanguage, + AdminMediaTranslation + > { + return { + it: { title: '', altText: '' }, + en: { title: '', altText: '' }, + de: { title: '', altText: '' }, + fr: { title: '', altText: '' }, + }; + } + + private cloneTranslations( + translations: Record, + ): Record { + return this.normalizeTranslations(translations); + } + + private normalizeTranslations( + translations: Partial< + Record> + >, + ): Record { + return { + it: { + title: translations.it?.title?.trim() ?? '', + altText: translations.it?.altText?.trim() ?? '', + }, + en: { + title: translations.en?.title?.trim() ?? '', + altText: translations.en?.altText?.trim() ?? '', + }, + de: { + title: translations.de?.title?.trim() ?? '', + altText: translations.de?.altText?.trim() ?? '', + }, + fr: { + title: translations.fr?.title?.trim() ?? '', + altText: translations.fr?.altText?.trim() ?? '', + }, + }; + } + + private areAllTitlesBlank( + translations: Record, + ): boolean { + return this.mediaLanguages.every( + (language) => !translations[language].title.trim(), + ); + } + + private isTranslationComplete(translation: AdminMediaTranslation): boolean { + return !!translation.title.trim() && !!translation.altText.trim(); + } + + private validateTranslations( + translations: Record, + ): string | null { + for (const language of this.mediaLanguages) { + const translation = translations[language]; + if (!translation.title.trim()) { + return `Compila il titolo per ${this.mediaLanguageLabels[language]}.`; + } + if (!translation.altText.trim()) { + return `Compila l'alt text per ${this.mediaLanguageLabels[language]}.`; + } + } + return null; + } + + private extractErrorMessage(error: unknown, fallback: string): string { + const candidate = error as { + error?: { message?: string }; + message?: string; + }; + return candidate?.error?.message || candidate?.message || fallback; + } +} diff --git a/frontend/src/app/features/admin/pages/admin-sessions.component.scss b/frontend/src/app/features/admin/pages/admin-sessions.component.scss index 7407132..acef8d3 100644 --- a/frontend/src/app/features/admin/pages/admin-sessions.component.scss +++ b/frontend/src/app/features/admin/pages/admin-sessions.component.scss @@ -1,9 +1,7 @@ .section-card { - background: var(--color-bg-card); - border: 1px solid var(--color-border); - border-radius: var(--radius-lg); - padding: clamp(12px, 2vw, 24px); - box-shadow: var(--shadow-sm); + display: flex; + flex-direction: column; + gap: var(--space-5); } .section-header { @@ -11,7 +9,6 @@ justify-content: space-between; align-items: flex-start; gap: var(--space-4); - margin-bottom: var(--space-5); } h2 { @@ -79,10 +76,12 @@ td { .error { color: var(--color-danger-500); + margin: 0; } .success { color: var(--color-success-500); + margin: 0; } .actions { @@ -169,7 +168,7 @@ td { @media (max-width: 520px) { .section-card { - padding: var(--space-3); + gap: var(--space-4); } th, diff --git a/frontend/src/app/features/admin/pages/admin-shell.component.html b/frontend/src/app/features/admin/pages/admin-shell.component.html index 35f5203..8587a84 100644 --- a/frontend/src/app/features/admin/pages/admin-shell.component.html +++ b/frontend/src/app/features/admin/pages/admin-shell.component.html @@ -17,6 +17,7 @@ > Sessioni Fatture CAD + Media home
diff --git a/frontend/src/app/features/admin/services/admin-media.service.ts b/frontend/src/app/features/admin/services/admin-media.service.ts new file mode 100644 index 0000000..5b99eb2 --- /dev/null +++ b/frontend/src/app/features/admin/services/admin-media.service.ts @@ -0,0 +1,145 @@ +import { inject, Injectable } from '@angular/core'; +import { HttpClient } from '@angular/common/http'; +import { Observable } from 'rxjs'; +import { environment } from '../../../../environments/environment'; + +export type AdminMediaLanguage = 'it' | 'en' | 'de' | 'fr'; + +export interface AdminMediaTranslation { + title: string; + altText: string; +} + +export interface AdminMediaVariant { + id: string; + variantName: string; + format: string; + storageKey: string; + mimeType: string; + widthPx: number; + heightPx: number; + fileSizeBytes: number; + isGenerated: boolean; + publicUrl: string | null; + createdAt: string; +} + +export interface AdminMediaUsage { + id: string; + usageType: string; + usageKey: string; + ownerId: string | null; + mediaAssetId: string; + sortOrder: number; + isPrimary: boolean; + isActive: boolean; + translations: Record; + createdAt: string; +} + +export interface AdminMediaAsset { + id: string; + originalFilename: string; + storageKey: string; + mimeType: string; + fileSizeBytes: number; + sha256Hex: string; + widthPx: number | null; + heightPx: number | null; + status: string; + visibility: string; + title: string | null; + altText: string | null; + createdAt: string; + updatedAt: string; + variants: AdminMediaVariant[]; + usages: AdminMediaUsage[]; +} + +export interface AdminMediaUploadPayload { + title?: string; + altText?: string; + visibility?: 'PUBLIC' | 'PRIVATE'; +} + +export interface AdminCreateMediaUsagePayload { + usageType: string; + usageKey: string; + ownerId?: string | null; + mediaAssetId: string; + sortOrder?: number; + isPrimary?: boolean; + isActive?: boolean; + translations: Record; +} + +export interface AdminUpdateMediaUsagePayload { + usageType?: string; + usageKey?: string; + ownerId?: string | null; + mediaAssetId?: string; + sortOrder?: number; + isPrimary?: boolean; + isActive?: boolean; + translations?: Record; +} + +@Injectable({ + providedIn: 'root', +}) +export class AdminMediaService { + private readonly http = inject(HttpClient); + private readonly baseUrl = `${environment.apiUrl}/api/admin/media`; + + listAssets(): Observable { + return this.http.get(`${this.baseUrl}/assets`, { + withCredentials: true, + }); + } + + uploadAsset( + file: File, + payload: AdminMediaUploadPayload, + ): Observable { + const formData = new FormData(); + formData.append('file', file); + if (payload.title?.trim()) { + formData.append('title', payload.title.trim()); + } + if (payload.altText?.trim()) { + formData.append('altText', payload.altText.trim()); + } + if (payload.visibility?.trim()) { + formData.append('visibility', payload.visibility.trim()); + } + + return this.http.post(`${this.baseUrl}/assets`, formData, { + withCredentials: true, + }); + } + + createUsage( + payload: AdminCreateMediaUsagePayload, + ): Observable { + return this.http.post(`${this.baseUrl}/usages`, payload, { + withCredentials: true, + }); + } + + updateUsage( + usageId: string, + payload: AdminUpdateMediaUsagePayload, + ): Observable { + return this.http.patch( + `${this.baseUrl}/usages/${usageId}`, + payload, + { withCredentials: true }, + ); + } + + deleteUsage(usageId: string): Observable { + return this.http.delete(`${this.baseUrl}/usages/${usageId}`, { + withCredentials: true, + }); + } +} diff --git a/frontend/src/app/features/home/home.component.html b/frontend/src/app/features/home/home.component.html index 9284fdf..f4e6b08 100644 --- a/frontend/src/app/features/home/home.component.html +++ b/frontend/src/app/features/home/home.component.html @@ -35,45 +35,33 @@

- +
- + @if (card.image; as image) { + + @if (image.source.avifUrl) { + + } + @if (image.source.webpUrl) { + + } + + + } @else { +
+ {{ card.titleKey | translate }} +
+ }
-

{{ "HOME.CAP_1_TITLE" | translate }}

-

{{ "HOME.CAP_1_TEXT" | translate }}

-
- -
- -
-

{{ "HOME.CAP_2_TITLE" | translate }}

-

{{ "HOME.CAP_2_TEXT" | translate }}

-
- -
- -
-

{{ "HOME.CAP_3_TITLE" | translate }}

-

{{ "HOME.CAP_3_TEXT" | translate }}

-
- -
- -
-

{{ "HOME.CAP_4_TITLE" | translate }}

-

{{ "HOME.CAP_4_TEXT" | translate }}

+

{{ card.titleKey | translate }}

+

{{ card.textKey | translate }}

@@ -153,9 +141,24 @@ >
@@ -193,29 +196,43 @@
- - - + @if (currentFounderImage(); as image) { + + @if (image.source.avifUrl) { + + } + @if (image.source.webpUrl) { + + } + + + } + @if (founderImages().length > 1) { + + + }
diff --git a/frontend/src/app/features/home/home.component.scss b/frontend/src/app/features/home/home.component.scss index 28c3267..fafb4a8 100644 --- a/frontend/src/app/features/home/home.component.scss +++ b/frontend/src/app/features/home/home.component.scss @@ -278,6 +278,36 @@ object-fit: cover; } +.card-image-placeholder picture { + width: 100%; + height: 100%; + display: block; +} + +.card-image-fallback { + width: 100%; + height: 100%; + display: grid; + place-items: end start; + padding: var(--space-4); + background: + linear-gradient(135deg, rgba(239, 196, 61, 0.22), rgba(255, 255, 255, 0)), + linear-gradient(180deg, #f8f5eb 0%, #efede6 100%); +} + +.card-image-fallback span { + display: inline-flex; + align-items: center; + min-height: 2rem; + padding: 0.35rem 0.7rem; + border-radius: 999px; + background: rgba(255, 255, 255, 0.88); + border: 1px solid rgba(17, 24, 39, 0.08); + color: var(--color-neutral-900); + font-size: 0.78rem; + font-weight: 700; +} + .shop { background: var(--home-bg); position: relative; @@ -336,6 +366,13 @@ object-fit: cover; } +.shop-gallery-item picture, +.about-feature-image picture { + width: 100%; + height: 100%; + display: block; +} + .shop-cards { display: grid; gap: var(--space-4); diff --git a/frontend/src/app/features/home/home.component.ts b/frontend/src/app/features/home/home.component.ts index 47a4b5f..d7b8fb8 100644 --- a/frontend/src/app/features/home/home.component.ts +++ b/frontend/src/app/features/home/home.component.ts @@ -1,9 +1,58 @@ -import { Component } from '@angular/core'; +import { Component, computed, effect, inject, signal } from '@angular/core'; +import { toSignal } from '@angular/core/rxjs-interop'; import { CommonModule } from '@angular/common'; import { RouterLink } from '@angular/router'; import { TranslateModule } from '@ngx-translate/core'; import { AppButtonComponent } from '../../shared/components/app-button/app-button.component'; import { AppCardComponent } from '../../shared/components/app-card/app-card.component'; +import { + buildPublicMediaUsageScopeKey, + PublicMediaDisplayImage, + PublicMediaImage, + PublicMediaService, + PublicMediaUsageCollectionMap, +} from '../../core/services/public-media.service'; + +const EMPTY_MEDIA_COLLECTIONS: PublicMediaUsageCollectionMap = {}; + +type HomeCapabilityUsageKey = + | 'capability-prototyping' + | 'capability-custom-parts' + | 'capability-small-series' + | 'capability-cad'; + +interface HomeCapabilityConfig { + usageKey: HomeCapabilityUsageKey; + titleKey: string; + textKey: string; +} + +interface HomeCapabilityCard extends HomeCapabilityConfig { + image: PublicMediaDisplayImage | null; +} + +const HOME_CAPABILITY_CONFIGS: readonly HomeCapabilityConfig[] = [ + { + usageKey: 'capability-prototyping', + titleKey: 'HOME.CAP_1_TITLE', + textKey: 'HOME.CAP_1_TEXT', + }, + { + usageKey: 'capability-custom-parts', + titleKey: 'HOME.CAP_2_TITLE', + textKey: 'HOME.CAP_2_TEXT', + }, + { + usageKey: 'capability-small-series', + titleKey: 'HOME.CAP_3_TITLE', + textKey: 'HOME.CAP_3_TEXT', + }, + { + usageKey: 'capability-cad', + titleKey: 'HOME.CAP_4_TITLE', + textKey: 'HOME.CAP_4_TEXT', + }, +]; @Component({ selector: 'app-home-page', @@ -19,37 +68,148 @@ import { AppCardComponent } from '../../shared/components/app-card/app-card.comp styleUrls: ['./home.component.scss'], }) export class HomeComponent { - readonly shopGalleryImages = [ - { - src: 'assets/images/home/supporto-bici.jpg', - alt: 'HOME.SHOP_IMAGE_ALT_1', - }, - ]; + private readonly publicMediaService = inject(PublicMediaService); - readonly founderImages = [ - { - src: 'assets/images/home/da-cambiare.jpg', - alt: 'HOME.FOUNDER_IMAGE_ALT_1', - }, - { - src: 'assets/images/home/vino.JPG', - alt: 'HOME.FOUNDER_IMAGE_ALT_2', - }, - ]; + private readonly mediaByUsage = toSignal( + this.publicMediaService.getUsageCollections([ + { + usageType: 'HOME_SECTION', + usageKey: 'shop-gallery', + }, + { + usageType: 'HOME_SECTION', + usageKey: 'founders-gallery', + }, + { + usageType: 'HOME_SECTION', + usageKey: 'capability-prototyping', + }, + { + usageType: 'HOME_SECTION', + usageKey: 'capability-custom-parts', + }, + { + usageType: 'HOME_SECTION', + usageKey: 'capability-small-series', + }, + { + usageType: 'HOME_SECTION', + usageKey: 'capability-cad', + }, + ]), + { initialValue: EMPTY_MEDIA_COLLECTIONS }, + ); - founderImageIndex = 0; + readonly shopGalleryImages = computed( + () => + ( + this.mediaByUsage()[ + buildPublicMediaUsageScopeKey('HOME_SECTION', 'shop-gallery') + ] ?? [] + ) + .map((item: PublicMediaImage) => + this.publicMediaService.toDisplayImage(item, 'card'), + ) + .filter( + ( + item: PublicMediaDisplayImage | null, + ): item is PublicMediaDisplayImage => item !== null, + ), + ); + + readonly founderImages = computed(() => + ( + this.mediaByUsage()[ + buildPublicMediaUsageScopeKey('HOME_SECTION', 'founders-gallery') + ] ?? [] + ) + .map((item: PublicMediaImage) => + this.publicMediaService.toDisplayImage(item, 'hero'), + ) + .filter( + ( + item: PublicMediaDisplayImage | null, + ): item is PublicMediaDisplayImage => item !== null, + ), + ); + + readonly capabilityCards = computed(() => + HOME_CAPABILITY_CONFIGS.map((config) => this.buildCapabilityCard(config)), + ); + + readonly founderImageIndex = signal(0); + readonly currentFounderImage = computed( + () => { + const images = this.founderImages(); + if (images.length === 0) { + return null; + } + return images[this.founderImageIndex()] ?? images[0] ?? null; + }, + ); + + constructor() { + effect(() => { + const images = this.founderImages(); + const currentIndex = this.founderImageIndex(); + if (images.length === 0) { + if (currentIndex !== 0) { + this.founderImageIndex.set(0); + } + return; + } + if (currentIndex >= images.length) { + this.founderImageIndex.set(0); + } + }); + } prevFounderImage(): void { - this.founderImageIndex = - this.founderImageIndex === 0 - ? this.founderImages.length - 1 - : this.founderImageIndex - 1; + const totalImages = this.founderImages().length; + if (totalImages <= 1) { + return; + } + this.founderImageIndex.set( + this.founderImageIndex() === 0 + ? totalImages - 1 + : this.founderImageIndex() - 1, + ); } nextFounderImage(): void { - this.founderImageIndex = - this.founderImageIndex === this.founderImages.length - 1 + const totalImages = this.founderImages().length; + if (totalImages <= 1) { + return; + } + this.founderImageIndex.set( + this.founderImageIndex() === totalImages - 1 ? 0 - : this.founderImageIndex + 1; + : this.founderImageIndex() + 1, + ); + } + + trackMediaAsset(_: number, image: PublicMediaDisplayImage): string { + return image.mediaAssetId; + } + + trackCapability(_: number, card: HomeCapabilityCard): string { + return card.usageKey; + } + + private buildCapabilityCard( + config: HomeCapabilityConfig, + ): HomeCapabilityCard { + const items = + this.mediaByUsage()[ + buildPublicMediaUsageScopeKey('HOME_SECTION', config.usageKey) + ] ?? []; + const primaryImage = this.publicMediaService.pickPrimaryUsage(items); + + return { + ...config, + image: primaryImage + ? this.publicMediaService.toDisplayImage(primaryImage, 'card') + : null, + }; } }