From 17df0c6b9bef322d9b5943fc9d68601da2a45fb2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joe=20K=C3=BCng?= Date: Mon, 9 Mar 2026 17:44:17 +0100 Subject: [PATCH] feat(back-end): admin home edit image page --- .gitignore | 4 + README.md | 21 + backend/Dockerfile | 16 +- .../controller/PublicMediaController.java | 30 + .../dto/PublicMediaUsageDto.java | 96 ++++ .../dto/PublicMediaVariantDto.java | 31 + .../repository/MediaUsageRepository.java | 3 + .../admin/AdminMediaControllerService.java | 13 + .../service/media/MediaFfmpegService.java | 91 ++- .../media/PublicMediaQueryService.java | 148 +++++ .../AdminMediaControllerServiceTest.java | 28 + .../media/PublicMediaQueryServiceTest.java | 142 +++++ .../app/core/services/public-media.service.ts | 241 ++++++++ .../features/about/about-page.component.html | 38 +- .../features/about/about-page.component.scss | 6 + .../features/about/about-page.component.ts | 44 +- .../src/app/features/admin/admin.routes.ts | 7 + .../pages/admin-home-media.component.html | 299 ++++++++++ .../pages/admin-home-media.component.scss | 428 ++++++++++++++ .../admin/pages/admin-home-media.component.ts | 530 ++++++++++++++++++ .../admin/pages/admin-shell.component.html | 1 + .../admin/services/admin-media.service.ts | 135 +++++ .../src/app/features/home/home.component.html | 139 +++-- .../src/app/features/home/home.component.scss | 37 ++ .../src/app/features/home/home.component.ts | 209 ++++++- 25 files changed, 2634 insertions(+), 103 deletions(-) create mode 100644 backend/src/main/java/com/printcalculator/controller/PublicMediaController.java create mode 100644 backend/src/main/java/com/printcalculator/dto/PublicMediaUsageDto.java create mode 100644 backend/src/main/java/com/printcalculator/dto/PublicMediaVariantDto.java create mode 100644 backend/src/main/java/com/printcalculator/service/media/PublicMediaQueryService.java create mode 100644 backend/src/test/java/com/printcalculator/service/media/PublicMediaQueryServiceTest.java create mode 100644 frontend/src/app/core/services/public-media.service.ts create mode 100644 frontend/src/app/features/admin/pages/admin-home-media.component.html create mode 100644 frontend/src/app/features/admin/pages/admin-home-media.component.scss create mode 100644 frontend/src/app/features/admin/pages/admin-home-media.component.ts create mode 100644 frontend/src/app/features/admin/services/admin-media.service.ts 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 7b05c94..7f45d6f 100644 --- a/README.md +++ b/README.md @@ -79,6 +79,27 @@ location /media/ { } ``` +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 diff --git a/backend/Dockerfile b/backend/Dockerfile index 870eb8d..f9ce411 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -13,8 +13,11 @@ 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 \ @@ -22,8 +25,13 @@ RUN apt-get update && apt-get install -y \ 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..57272da --- /dev/null +++ b/backend/src/main/java/com/printcalculator/controller/PublicMediaController.java @@ -0,0 +1,30 @@ +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) { + return ResponseEntity.ok(publicMediaQueryService.getUsageMedia(usageType, usageKey)); + } +} 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/repository/MediaUsageRepository.java b/backend/src/main/java/com/printcalculator/repository/MediaUsageRepository.java index 258e751..08bea80 100644 --- a/backend/src/main/java/com/printcalculator/repository/MediaUsageRepository.java +++ b/backend/src/main/java/com/printcalculator/repository/MediaUsageRepository.java @@ -14,6 +14,9 @@ public interface MediaUsageRepository extends JpaRepository { List findByMediaAsset_IdIn(Collection mediaAssetIds); + List findByUsageTypeAndUsageKeyAndIsActiveTrueOrderBySortOrderAscCreatedAtAsc(String usageType, + String usageKey); + @Query(""" select usage from MediaUsage usage where usage.usageType = :usageType diff --git a/backend/src/main/java/com/printcalculator/service/admin/AdminMediaControllerService.java b/backend/src/main/java/com/printcalculator/service/admin/AdminMediaControllerService.java index f70e075..7978d9f 100644 --- a/backend/src/main/java/com/printcalculator/service/admin/AdminMediaControllerService.java +++ b/backend/src/main/java/com/printcalculator/service/admin/AdminMediaControllerService.java @@ -328,6 +328,7 @@ public class AdminMediaControllerService { String storageFolder = extractStorageFolder(asset.getStorageKey()); List pendingVariants = new ArrayList<>(); + Set skippedFormats = new LinkedHashSet<>(); for (PresetDefinition preset : PRESETS) { VariantDimensions dimensions = computeVariantDimensions( asset.getWidthPx(), @@ -336,6 +337,10 @@ public class AdminMediaControllerService { ); 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); @@ -356,6 +361,14 @@ public class AdminMediaControllerService { } } + 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) { diff --git a/backend/src/main/java/com/printcalculator/service/media/MediaFfmpegService.java b/backend/src/main/java/com/printcalculator/service/media/MediaFfmpegService.java index 32188d1..29385be 100644 --- a/backend/src/main/java/com/printcalculator/service/media/MediaFfmpegService.java +++ b/backend/src/main/java/com/printcalculator/service/media/MediaFfmpegService.java @@ -1,5 +1,7 @@ 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; @@ -9,15 +11,31 @@ import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedHashMap; +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 ffmpegPath; + private final Set availableEncoders; public MediaFfmpegService(@Value("${media.ffmpeg.path:ffmpeg}") String ffmpegPath) { this.ffmpegPath = ffmpegPath; + this.availableEncoders = Collections.unmodifiableSet(loadAvailableEncoders()); } public void generateVariant(Path source, Path target, int widthPx, int heightPx, String format) throws IOException { @@ -25,6 +43,11 @@ public class MediaFfmpegService { throw new IllegalArgumentException("Variant dimensions must be positive."); } + String encoder = resolveEncoder(format); + if (encoder == null) { + throw new IOException("FFmpeg encoder not available for media format " + format + "."); + } + List command = new ArrayList<>(); command.add(ffmpegPath); command.add("-y"); @@ -42,19 +65,19 @@ public class MediaFfmpegService { switch (format) { case "JPEG" -> { command.add("-c:v"); - command.add("mjpeg"); + command.add(encoder); command.add("-q:v"); command.add("2"); } case "WEBP" -> { command.add("-c:v"); - command.add("libwebp"); + command.add(encoder); command.add("-quality"); command.add("82"); } case "AVIF" -> { command.add("-c:v"); - command.add("libaom-av1"); + command.add(encoder); command.add("-still-picture"); command.add("1"); command.add("-crf"); @@ -86,6 +109,68 @@ public class MediaFfmpegService { } } + 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(ffmpegPath, "-hide_banner", "-encoders"); + try { + Process process = new ProcessBuilder(command).redirectErrorStream(true).start(); + 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. Falling back to empty encoder list.", e); + return Set.of(); + } + } + + 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 ""; 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..23db757 --- /dev/null +++ b/backend/src/main/java/com/printcalculator/service/media/PublicMediaQueryService.java @@ -0,0 +1,148 @@ +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 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 normalizedUsageType = normalizeUsageType(usageType); + String normalizedUsageKey = normalizeUsageKey(usageKey); + + 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()) + )) + .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) { + 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(usage.getMediaAsset().getTitle()); + dto.setAltText(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(); + } +} diff --git a/backend/src/test/java/com/printcalculator/service/admin/AdminMediaControllerServiceTest.java b/backend/src/test/java/com/printcalculator/service/admin/AdminMediaControllerServiceTest.java index 6e78caf..c5d3f9f 100644 --- a/backend/src/test/java/com/printcalculator/service/admin/AdminMediaControllerServiceTest.java +++ b/backend/src/test/java/com/printcalculator/service/admin/AdminMediaControllerServiceTest.java @@ -100,6 +100,7 @@ class AdminMediaControllerServiceTest { ); 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); @@ -246,6 +247,33 @@ class AdminMediaControllerServiceTest { 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( 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..2359541 --- /dev/null +++ b/backend/src/test/java/com/printcalculator/service/media/PublicMediaQueryServiceTest.java @@ -0,0 +1,142 @@ +package com.printcalculator.service.media; + +import com.printcalculator.dto.PublicMediaUsageDto; +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", "Shop alt"); + 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); + 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"); + + assertEquals(2, result.size()); + assertEquals(1, result.get(0).getSortOrder()); + assertEquals(Boolean.TRUE, result.get(0).getIsPrimary()); + assertEquals("Shop hero", result.get(0).getTitle()); + 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_shouldReturnNullForMissingFormatsOrPresets() { + MediaAsset asset = buildAsset("READY", "PUBLIC", "Joe portrait", null); + 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"); + + assertEquals(1, result.size()); + 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; + } +} 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..e2cbc9d --- /dev/null +++ b/frontend/src/app/core/services/public-media.service.ts @@ -0,0 +1,241 @@ +import { inject, Injectable } from '@angular/core'; +import { HttpClient, HttpParams } from '@angular/common/http'; +import { Observable, combineLatest, map, of, catchError } from 'rxjs'; +import { environment } from '../../../environments/environment'; + +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 baseUrl = `${environment.apiUrl}/api/public/media`; + + getUsageMedia( + usageType: PublicMediaUsageType, + usageKey: string, + ): Observable { + const params = new HttpParams() + .set('usageType', usageType) + .set('usageKey', usageKey); + + 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..46fa9ea 100644 --- a/frontend/src/app/features/about/about-page.component.html +++ b/frontend/src/app/features/about/about-page.component.html @@ -39,10 +39,20 @@ (keydown.space)="toggleSelectedMember('joe'); $event.preventDefault()" >
- + @if (joeImage(); as image) { + + @if (image.source.avifUrl) { + + } + @if (image.source.webpUrl) { + + } + + + }
{{ @@ -71,10 +81,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-home-media.component.html b/frontend/src/app/features/admin/pages/admin-home-media.component.html new file mode 100644 index 0000000..ae0790a --- /dev/null +++ b/frontend/src/app/features/admin/pages/admin-home-media.component.html @@ -0,0 +1,299 @@ +
+
+
+

Back-office media

+

Media home

+

+ Gestisci gallery, founders e le card "Cosa puoi ottenere" senza + toccare codice o asset statici locali. +

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

+ {{ errorMessage }} +

+

+ {{ successMessage }} +

+ +
+
+
+
+

{{ group.title }}

+

{{ group.description }}

+
+
+ +
+
+
+
+
+

{{ section.title }}

+ + {{ section.items.length }} + {{ section.items.length === 1 ? "attiva" : "attive" }} + +
+

{{ section.description }}

+
+
+ {{ section.usageType }} / {{ section.usageKey }} + + Variante {{ section.preferredVariantName }} + +
+
+ +
+
+
+
+
+ {{ + getFormState(section.usageKey).replacingUsageId + ? "Sostituisci immagine" + : "Carica immagine" + }} +
+

{{ section.collectionHint }}

+
+
+ +
+ + +
+ +
+ + + + + + + + +
+ +
+ + + +
+
+ +
+
+
+
Immagini attive
+

Ordina, sostituisci o rimuovi i media attualmente collegati.

+
+
+ +
+
+
+
+ +
+
+ +
+
+
+
{{ item.title || item.originalFilename }}
+

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

+
+ Primaria +
+ +

Alt: {{ item.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..e715b2f --- /dev/null +++ b/frontend/src/app/features/admin/pages/admin-home-media.component.scss @@ -0,0 +1,428 @@ +.section-card { + background: var(--color-bg-card); + border: 1px solid var(--color-border); + border-radius: calc(var(--radius-lg) + 6px); + padding: clamp(16px, 2vw, 28px); + box-shadow: var(--shadow-sm); + display: grid; + gap: var(--space-5); + background: + radial-gradient(circle at top right, rgba(239, 196, 61, 0.08), transparent 28%), + var(--color-bg-card); +} + +.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: center; +} + +.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: grid; + gap: var(--space-3); + justify-items: end; +} + +.header-stats { + display: flex; + flex-wrap: wrap; + justify-content: flex-end; + gap: var(--space-2); +} + +.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-5); +} + +.group-card { + border: 1px solid var(--color-border); + border-radius: calc(var(--radius-lg) + 2px); + padding: clamp(14px, 2vw, 22px); + 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-4); +} + +.sections { + display: grid; + gap: var(--space-4); +} + +.media-panel { + border: 1px solid var(--color-border); + border-radius: var(--radius-lg); + background: #ffffff; + padding: var(--space-4); + display: grid; + gap: var(--space-4); + 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-2); +} + +.media-panel-meta { + justify-items: end; +} + +.title-row { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: var(--space-2); +} + +.workspace { + display: grid; + grid-template-columns: minmax(320px, 390px) minmax(0, 1fr); + gap: var(--space-4); + 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-4); +} + +.form-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: var(--space-3); + margin-top: var(--space-3); +} + +.form-field { + display: grid; + gap: var(--space-1); +} + +.form-field--wide { + grid-column: 1 / -1; +} + +.form-field > span, +.sort-editor span { + font-size: 0.8rem; + color: var(--color-text-muted); + font-weight: 600; +} + +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; + color: var(--color-text); +} + +.toggle { + display: inline-flex; + align-items: center; + gap: 0.5rem; + border: 1px solid var(--color-border); + border-radius: 999px; + padding: 0.5rem 0.75rem; + background: var(--color-bg-card); + align-self: end; +} + +.toggle input { + width: 16px; + height: 16px; + margin: 0; +} + +.toggle span { + font-size: 0.88rem; + 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-3); +} + +.media-item { + display: grid; + grid-template-columns: 168px minmax(0, 1fr); + gap: var(--space-3); + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + padding: var(--space-3); + 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-2); +} + +.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); +} + +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; + 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; + } + + .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-items: 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..e092dd0 --- /dev/null +++ b/frontend/src/app/features/admin/pages/admin-home-media.component.ts @@ -0,0 +1,530 @@ +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, + AdminMediaAsset, + AdminMediaService, + 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; + description: string; + preferredVariantName: 'card' | 'hero'; + collectionHint: string; +} + +interface HomeMediaFormState { + file: File | null; + previewUrl: string | null; + title: string; + altText: string; + sortOrder: number; + isPrimary: boolean; + replacingUsageId: string | null; + saving: boolean; +} + +interface HomeMediaItem { + usageId: string; + mediaAssetId: string; + originalFilename: string; + title: string | null; + altText: string | null; + sortOrder: number; + draftSortOrder: number; + isPrimary: boolean; + previewUrl: string | null; + createdAt: string; +} + +interface HomeMediaSectionView extends HomeMediaSectionConfig { + items: HomeMediaItem[]; +} + +interface HomeMediaSectionGroup { + id: HomeMediaSectionConfig['groupId']; + title: string; + description: string; +} + +@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 sectionGroups: readonly HomeMediaSectionGroup[] = [ + { + id: 'galleries', + title: 'Gallery e visual principali', + description: + 'Sezioni che possono avere piu immagini attive e che impattano slider o gallery della home.', + }, + { + id: 'capabilities', + title: 'Cosa puoi ottenere', + description: + 'Le quattro card della sezione servizi della home. Qui di solito e consigliata una sola immagine per card.', + }, + ]; + + readonly sectionConfigs: readonly HomeMediaSectionConfig[] = [ + { + usageType: 'HOME_SECTION', + usageKey: 'shop-gallery', + groupId: 'galleries', + title: 'Home: gallery shop', + description: + 'Immagini della sezione shop nella home. Non modifica il catalogo shop reale.', + preferredVariantName: 'card', + collectionHint: 'Gallery orizzontale. Consigliate piu immagini in formato card.', + }, + { + usageType: 'HOME_SECTION', + usageKey: 'founders-gallery', + groupId: 'galleries', + title: 'Home: gallery founders', + description: + 'Immagini del carousel founders nella home. Prev/next usa l’ordine configurato qui.', + preferredVariantName: 'hero', + collectionHint: 'Hero slider. Consigliate immagini ampie con soggetto centrale.', + }, + { + usageType: 'HOME_SECTION', + usageKey: 'capability-prototyping', + groupId: 'capabilities', + title: 'Home: prototipazione veloce', + description: + 'Card "Prototipazione veloce" nella sezione Cosa puoi ottenere.', + preferredVariantName: 'card', + collectionHint: 'Card singola. Consigliata una sola immagine 16:10.', + }, + { + usageType: 'HOME_SECTION', + usageKey: 'capability-custom-parts', + groupId: 'capabilities', + title: 'Home: pezzi personalizzati', + description: + 'Card "Pezzi personalizzati" nella sezione Cosa puoi ottenere.', + preferredVariantName: 'card', + collectionHint: 'Card singola. Consigliata una sola immagine 16:10.', + }, + { + usageType: 'HOME_SECTION', + usageKey: 'capability-small-series', + groupId: 'capabilities', + title: 'Home: piccole serie', + description: 'Card "Piccole serie" nella sezione Cosa puoi ottenere.', + preferredVariantName: 'card', + collectionHint: 'Card singola. Consigliata una sola immagine 16:10.', + }, + { + usageType: 'HOME_SECTION', + usageKey: 'capability-cad', + groupId: 'capabilities', + title: 'Home: consulenza e CAD', + description: + 'Card "Consulenza e CAD" nella sezione Cosa puoi ottenere.', + preferredVariantName: 'card', + collectionHint: 'Card singola. Consigliata una sola immagine 16:10.', + }, + ]; + + 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 && !formState.title.trim()) { + formState.title = this.deriveDefaultTitle(file.name); + } + } + + 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.title = item.title ?? ''; + formState.altText = item.altText ?? ''; + 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; + } + + 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, + }); + + this.adminMediaService + .uploadAsset(formState.file, { + title: formState.title, + altText: formState.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); + } + + 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, + title: asset.title, + altText: asset.altText, + 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, + title: '', + altText: '', + 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, + title: '', + altText: '', + sortOrder: 0, + isPrimary: false, + replacingUsageId: null, + saving: false, + }; + } + + 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-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..6fc3409 --- /dev/null +++ b/frontend/src/app/features/admin/services/admin-media.service.ts @@ -0,0 +1,135 @@ +import { inject, Injectable } from '@angular/core'; +import { HttpClient } from '@angular/common/http'; +import { Observable } from 'rxjs'; +import { environment } from '../../../../environments/environment'; + +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; + 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; +} + +export interface AdminUpdateMediaUsagePayload { + usageType?: string; + usageKey?: string; + ownerId?: string | null; + mediaAssetId?: string; + sortOrder?: number; + isPrimary?: boolean; + isActive?: boolean; +} + +@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..0bb653e 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,41 @@
- - - + @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..0f4fcef 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,147 @@ 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, + }; } }