dev #37

Merged
JoeKung merged 47 commits from dev into main 2026-03-10 17:43:46 +01:00
25 changed files with 2634 additions and 103 deletions
Showing only changes of commit 17df0c6b9b - Show all commits

4
.gitignore vendored
View File

@@ -44,8 +44,12 @@ build/
./storage_orders ./storage_orders
./storage_quotes ./storage_quotes
./storage_requests
./storage_media
storage_orders storage_orders
storage_quotes storage_quotes
storage_requests
storage_media
# Qodana local reports/artifacts # Qodana local reports/artifacts
backend/.qodana/ backend/.qodana/

View File

@@ -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 `<picture>` 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 ## Troubleshooting
### Percorso OrcaSlicer ### Percorso OrcaSlicer

View File

@@ -13,8 +13,11 @@ FROM eclipse-temurin:21-jre-jammy
ARG ORCA_VERSION=2.3.1 ARG ORCA_VERSION=2.3.1
ARG ORCA_DOWNLOAD_URL ARG ORCA_DOWNLOAD_URL
# Install system dependencies for OrcaSlicer (same as before) # Install system dependencies for OrcaSlicer and media processing.
RUN apt-get update && apt-get install -y \ # 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 \ ffmpeg \
wget \ wget \
assimp-utils \ assimp-utils \
@@ -22,8 +25,13 @@ RUN apt-get update && apt-get install -y \
libglib2.0-0 \ libglib2.0-0 \
libgtk-3-0 \ libgtk-3-0 \
libdbus-1-3 \ libdbus-1-3 \
libwebkit2gtk-4.0-37 \ libwebkit2gtk-4.0-37; \
&& rm -rf /var/lib/apt/lists/* 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 # Install OrcaSlicer
WORKDIR /opt WORKDIR /opt

View File

@@ -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<List<PublicMediaUsageDto>> getUsageMedia(@RequestParam String usageType,
@RequestParam String usageKey) {
return ResponseEntity.ok(publicMediaQueryService.getUsageMedia(usageType, usageKey));
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -14,6 +14,9 @@ public interface MediaUsageRepository extends JpaRepository<MediaUsage, UUID> {
List<MediaUsage> findByMediaAsset_IdIn(Collection<UUID> mediaAssetIds); List<MediaUsage> findByMediaAsset_IdIn(Collection<UUID> mediaAssetIds);
List<MediaUsage> findByUsageTypeAndUsageKeyAndIsActiveTrueOrderBySortOrderAscCreatedAtAsc(String usageType,
String usageKey);
@Query(""" @Query("""
select usage from MediaUsage usage select usage from MediaUsage usage
where usage.usageType = :usageType where usage.usageType = :usageType

View File

@@ -328,6 +328,7 @@ public class AdminMediaControllerService {
String storageFolder = extractStorageFolder(asset.getStorageKey()); String storageFolder = extractStorageFolder(asset.getStorageKey());
List<PendingGeneratedVariant> pendingVariants = new ArrayList<>(); List<PendingGeneratedVariant> pendingVariants = new ArrayList<>();
Set<String> skippedFormats = new LinkedHashSet<>();
for (PresetDefinition preset : PRESETS) { for (PresetDefinition preset : PRESETS) {
VariantDimensions dimensions = computeVariantDimensions( VariantDimensions dimensions = computeVariantDimensions(
asset.getWidthPx(), asset.getWidthPx(),
@@ -336,6 +337,10 @@ public class AdminMediaControllerService {
); );
for (String format : List.of(FORMAT_JPEG, FORMAT_WEBP, FORMAT_AVIF)) { 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); String extension = GENERATED_FORMAT_EXTENSIONS.get(format);
Path outputFile = generatedDirectory.resolve(preset.name() + "." + extension); Path outputFile = generatedDirectory.resolve(preset.name() + "." + extension);
mediaFfmpegService.generateVariant(sourceFile, outputFile, dimensions.widthPx(), dimensions.heightPx(), format); 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<String> storedKeys = new ArrayList<>(); List<String> storedKeys = new ArrayList<>();
try { try {
for (PendingGeneratedVariant pendingVariant : pendingVariants) { for (PendingGeneratedVariant pendingVariant : pendingVariants) {

View File

@@ -1,5 +1,7 @@
package com.printcalculator.service.media; package com.printcalculator.service.media;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
@@ -9,15 +11,31 @@ import java.nio.charset.StandardCharsets;
import java.nio.file.Files; import java.nio.file.Files;
import java.nio.file.Path; import java.nio.file.Path;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List; import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
@Service @Service
public class MediaFfmpegService { public class MediaFfmpegService {
private static final Logger logger = LoggerFactory.getLogger(MediaFfmpegService.class);
private static final Map<String, List<String>> 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 String ffmpegPath;
private final Set<String> availableEncoders;
public MediaFfmpegService(@Value("${media.ffmpeg.path:ffmpeg}") String ffmpegPath) { public MediaFfmpegService(@Value("${media.ffmpeg.path:ffmpeg}") String ffmpegPath) {
this.ffmpegPath = ffmpegPath; this.ffmpegPath = ffmpegPath;
this.availableEncoders = Collections.unmodifiableSet(loadAvailableEncoders());
} }
public void generateVariant(Path source, Path target, int widthPx, int heightPx, String format) throws IOException { 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."); 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<String> command = new ArrayList<>(); List<String> command = new ArrayList<>();
command.add(ffmpegPath); command.add(ffmpegPath);
command.add("-y"); command.add("-y");
@@ -42,19 +65,19 @@ public class MediaFfmpegService {
switch (format) { switch (format) {
case "JPEG" -> { case "JPEG" -> {
command.add("-c:v"); command.add("-c:v");
command.add("mjpeg"); command.add(encoder);
command.add("-q:v"); command.add("-q:v");
command.add("2"); command.add("2");
} }
case "WEBP" -> { case "WEBP" -> {
command.add("-c:v"); command.add("-c:v");
command.add("libwebp"); command.add(encoder);
command.add("-quality"); command.add("-quality");
command.add("82"); command.add("82");
} }
case "AVIF" -> { case "AVIF" -> {
command.add("-c:v"); command.add("-c:v");
command.add("libaom-av1"); command.add(encoder);
command.add("-still-picture"); command.add("-still-picture");
command.add("1"); command.add("1");
command.add("-crf"); 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<String> 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<String> loadAvailableEncoders() {
List<String> 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<String> parseAvailableEncoders(String output) {
if (output == null || output.isBlank()) {
return Set.of();
}
Set<String> 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) { private String truncate(String output) {
if (output == null || output.isBlank()) { if (output == null || output.isBlank()) {
return ""; return "";

View File

@@ -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<PublicMediaUsageDto> getUsageMedia(String usageType, String usageKey) {
String normalizedUsageType = normalizeUsageType(usageType);
String normalizedUsageKey = normalizeUsageKey(usageKey);
List<MediaUsage> 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<UUID> assetIds = usages.stream()
.map(MediaUsage::getMediaAsset)
.filter(Objects::nonNull)
.map(MediaAsset::getId)
.filter(Objects::nonNull)
.distinct()
.toList();
Map<UUID, List<MediaVariant>> 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<MediaVariant> variants) {
Map<String, Map<String, MediaVariant>> 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<String, MediaVariant> 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();
}
}

View File

@@ -100,6 +100,7 @@ class AdminMediaControllerServiceTest {
); );
when(clamAVService.scan(any())).thenReturn(true); when(clamAVService.scan(any())).thenReturn(true);
when(mediaFfmpegService.canEncode(anyString())).thenReturn(true);
when(mediaAssetRepository.save(any(MediaAsset.class))).thenAnswer(invocation -> { when(mediaAssetRepository.save(any(MediaAsset.class))).thenAnswer(invocation -> {
MediaAsset asset = invocation.getArgument(0); MediaAsset asset = invocation.getArgument(0);
@@ -246,6 +247,33 @@ class AdminMediaControllerServiceTest {
assertTrue(variants.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 @Test
void uploadAsset_withOversizedFile_shouldFailValidationBeforePersistence() { void uploadAsset_withOversizedFile_shouldFailValidationBeforePersistence() {
service = new AdminMediaControllerService( service = new AdminMediaControllerService(

View File

@@ -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<PublicMediaUsageDto> 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<PublicMediaUsageDto> 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;
}
}

View File

@@ -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<PublicMediaSourceSet, 'fallbackUrl'> {
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<PublicMediaImage, 'thumb' | 'card' | 'hero'> {
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<readonly PublicMediaImage[]> {
const params = new HttpParams()
.set('usageType', usageType)
.set('usageKey', usageKey);
return this.http
.get<PublicMediaUsageDto[]>(`${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<PublicMediaUsageCollectionMap> {
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<PublicMediaUsageCollectionMap>((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;
}
}

View File

@@ -39,10 +39,20 @@
(keydown.space)="toggleSelectedMember('joe'); $event.preventDefault()" (keydown.space)="toggleSelectedMember('joe'); $event.preventDefault()"
> >
<div class="placeholder-img"> <div class="placeholder-img">
<img @if (joeImage(); as image) {
src="assets/images/joe.jpg" <picture>
[attr.alt]="'ABOUT.MEMBER_JOE_ALT' | translate" @if (image.source.avifUrl) {
/> <source [srcset]="image.source.avifUrl" type="image/avif" />
}
@if (image.source.webpUrl) {
<source [srcset]="image.source.webpUrl" type="image/webp" />
}
<img
[src]="image.source.fallbackUrl"
[attr.alt]="image.altText || ('ABOUT.MEMBER_JOE_ALT' | translate)"
/>
</picture>
}
</div> </div>
<div class="member-info"> <div class="member-info">
<span class="member-name">{{ <span class="member-name">{{
@@ -71,10 +81,22 @@
" "
> >
<div class="placeholder-img"> <div class="placeholder-img">
<img @if (matteoImage(); as image) {
src="assets/images/matteo.jpg" <picture>
[attr.alt]="'ABOUT.MEMBER_MATTEO_ALT' | translate" @if (image.source.avifUrl) {
/> <source [srcset]="image.source.avifUrl" type="image/avif" />
}
@if (image.source.webpUrl) {
<source [srcset]="image.source.webpUrl" type="image/webp" />
}
<img
[src]="image.source.fallbackUrl"
[attr.alt]="
image.altText || ('ABOUT.MEMBER_MATTEO_ALT' | translate)
"
/>
</picture>
}
</div> </div>
<div class="member-info"> <div class="member-info">
<span class="member-name">{{ <span class="member-name">{{

View File

@@ -193,6 +193,12 @@ h1 {
object-fit: cover; object-fit: cover;
} }
.placeholder-img picture {
width: 100%;
height: 100%;
display: block;
}
.member-info { .member-info {
text-align: center; text-align: center;
} }

View File

@@ -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 { TranslateModule } from '@ngx-translate/core';
import { AppLocationsComponent } from '../../shared/components/app-locations/app-locations.component'; 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 MemberId = 'joe' | 'matteo';
type PassionId = type PassionId =
@@ -32,6 +41,39 @@ interface PassionChip {
styleUrl: './about-page.component.scss', styleUrl: './about-page.component.scss',
}) })
export class AboutPageComponent { 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<PublicMediaDisplayImage | null>(() => {
const image = this.publicMediaService.pickPrimaryUsage(
this.mediaByUsage()[
buildPublicMediaUsageScopeKey('ABOUT_MEMBER', 'joe')
] ?? [],
);
return image ? this.publicMediaService.toDisplayImage(image, 'card') : null;
});
readonly matteoImage = computed<PublicMediaDisplayImage | null>(() => {
const image = this.publicMediaService.pickPrimaryUsage(
this.mediaByUsage()[
buildPublicMediaUsageScopeKey('ABOUT_MEMBER', 'matteo')
] ?? [],
);
return image ? this.publicMediaService.toDisplayImage(image, 'card') : null;
});
selectedMember: MemberId | null = null; selectedMember: MemberId | null = null;
hoveredMember: MemberId | null = null; hoveredMember: MemberId | null = null;

View File

@@ -57,6 +57,13 @@ export const ADMIN_ROUTES: Routes = [
(m) => m.AdminCadInvoicesComponent, (m) => m.AdminCadInvoicesComponent,
), ),
}, },
{
path: 'home-media',
loadComponent: () =>
import('./pages/admin-home-media.component').then(
(m) => m.AdminHomeMediaComponent,
),
},
], ],
}, },
]; ];

View File

@@ -0,0 +1,299 @@
<section class="section-card">
<header class="section-header">
<div class="header-copy">
<p class="eyebrow">Back-office media</p>
<h2>Media home</h2>
<p>
Gestisci gallery, founders e le card "Cosa puoi ottenere" senza
toccare codice o asset statici locali.
</p>
</div>
<div class="header-side">
<div class="header-stats">
<article class="stat-chip">
<strong>{{ configuredSectionCount }}</strong>
<span>sezioni gestite</span>
</article>
<article class="stat-chip">
<strong>{{ activeImageCount }}</strong>
<span>immagini attive</span>
</article>
</div>
<button type="button" (click)="loadHomeMedia()" [disabled]="loading">
Aggiorna
</button>
</div>
</header>
<p class="status-banner status-banner-error" *ngIf="errorMessage">
{{ errorMessage }}
</p>
<p class="status-banner status-banner-success" *ngIf="successMessage">
{{ successMessage }}
</p>
<div class="group-stack" *ngIf="!loading; else loadingTpl">
<section class="group-card" *ngFor="let group of sectionGroups">
<header class="group-header">
<div>
<h3>{{ group.title }}</h3>
<p>{{ group.description }}</p>
</div>
</header>
<div class="sections">
<section
class="media-panel"
*ngFor="
let section of getSectionsForGroup(group.id);
trackBy: trackSection
"
>
<header class="media-panel-header">
<div class="media-panel-copy">
<div class="title-row">
<h4>{{ section.title }}</h4>
<span class="count-pill">
{{ section.items.length }}
{{ section.items.length === 1 ? "attiva" : "attive" }}
</span>
</div>
<p>{{ section.description }}</p>
</div>
<div class="media-panel-meta">
<span class="usage-pill"
>{{ section.usageType }} / {{ section.usageKey }}</span
>
<span class="layout-pill">
Variante {{ section.preferredVariantName }}
</span>
</div>
</header>
<div class="workspace">
<div class="upload-panel">
<div class="panel-heading">
<div>
<h5>
{{
getFormState(section.usageKey).replacingUsageId
? "Sostituisci immagine"
: "Carica immagine"
}}
</h5>
<p>{{ section.collectionHint }}</p>
</div>
</div>
<div class="form-grid">
<label class="form-field form-field--wide">
<span>File immagine</span>
<input
type="file"
accept=".jpg,.jpeg,.png,.webp"
(change)="onFileSelected(section.usageKey, $event)"
/>
</label>
<div
class="preview-card form-field--wide"
*ngIf="
getFormState(section.usageKey).previewUrl as previewUrl
"
>
<img [src]="previewUrl" alt="" />
</div>
<label class="form-field">
<span>Titolo</span>
<input
type="text"
[(ngModel)]="getFormState(section.usageKey).title"
placeholder="Titolo immagine"
/>
</label>
<label class="form-field">
<span>Alt text</span>
<input
type="text"
[(ngModel)]="getFormState(section.usageKey).altText"
placeholder="Testo alternativo"
/>
</label>
<label class="form-field">
<span>Sort order</span>
<input
type="number"
[(ngModel)]="getFormState(section.usageKey).sortOrder"
min="0"
/>
</label>
<label class="toggle">
<input
type="checkbox"
[(ngModel)]="getFormState(section.usageKey).isPrimary"
/>
<span>Immagine primaria</span>
</label>
</div>
<div class="upload-actions">
<button
type="button"
(click)="uploadForSection(section.usageKey)"
[disabled]="
getFormState(section.usageKey).saving ||
!getFormState(section.usageKey).file
"
>
{{
getFormState(section.usageKey).saving
? "Salvataggio..."
: getFormState(section.usageKey).replacingUsageId
? "Sostituisci immagine"
: "Carica in home"
}}
</button>
<button
type="button"
class="ghost"
(click)="prepareAdd(section.usageKey)"
[disabled]="getFormState(section.usageKey).saving"
>
Nuova immagine
</button>
<button
type="button"
class="ghost"
*ngIf="getFormState(section.usageKey).replacingUsageId"
(click)="cancelReplace(section.usageKey)"
[disabled]="getFormState(section.usageKey).saving"
>
Annulla sostituzione
</button>
</div>
</div>
<div class="list-panel">
<div class="panel-heading">
<div>
<h5>Immagini attive</h5>
<p>Ordina, sostituisci o rimuovi i media attualmente collegati.</p>
</div>
</div>
<div
class="media-list"
*ngIf="section.items.length; else emptySectionState"
>
<article
class="media-item"
*ngFor="let item of section.items; trackBy: trackItem"
>
<div class="thumb-wrap">
<div class="thumb">
<img
*ngIf="item.previewUrl; else noPreviewTpl"
[src]="item.previewUrl"
alt=""
/>
</div>
</div>
<div class="media-copy">
<div class="media-copy-top">
<div>
<h6>{{ item.title || item.originalFilename }}</h6>
<p class="meta">
{{ item.originalFilename }} | asset
{{ item.mediaAssetId }}
</p>
</div>
<span class="primary-badge" *ngIf="item.isPrimary"
>Primaria</span
>
</div>
<p class="meta">Alt: {{ item.altText || "-" }}</p>
<p class="meta">
Sort order: {{ item.sortOrder }} | Inserita:
{{ item.createdAt | date: "short" }}
</p>
<div class="sort-editor">
<label>
<span>Nuovo ordine</span>
<input
type="number"
[(ngModel)]="item.draftSortOrder"
min="0"
/>
</label>
<button
type="button"
class="ghost"
(click)="saveSortOrder(item)"
[disabled]="
isUsageBusy(item.usageId) ||
item.draftSortOrder === item.sortOrder
"
>
Salva ordine
</button>
</div>
<div class="item-actions">
<button
type="button"
class="ghost"
(click)="prepareReplace(section.usageKey, item)"
[disabled]="isUsageBusy(item.usageId)"
>
Sostituisci
</button>
<button
type="button"
class="ghost"
(click)="setPrimary(item)"
[disabled]="isUsageBusy(item.usageId) || item.isPrimary"
>
Rendi primaria
</button>
<button
type="button"
class="ghost danger"
(click)="removeFromHome(item)"
[disabled]="isUsageBusy(item.usageId)"
>
Rimuovi dalla home
</button>
</div>
</div>
</article>
</div>
</div>
</div>
</section>
</div>
</section>
</div>
<ng-template #emptySectionState>
<p class="empty-state">
Nessuna immagine attiva collegata a questa sezione home.
</p>
</ng-template>
<ng-template #noPreviewTpl>
<div class="thumb thumb-empty">
<span>Preview non disponibile</span>
</div>
</ng-template>
</section>
<ng-template #loadingTpl>
<p class="loading-state">Caricamento media home...</p>
</ng-template>

View File

@@ -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;
}
}

View File

@@ -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 lordine 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<string>();
private readonly formStateByKey: Record<HomeSectionKey, HomeMediaFormState> =
{
'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;
}
}

View File

@@ -17,6 +17,7 @@
> >
<a routerLink="sessions" routerLinkActive="active">Sessioni</a> <a routerLink="sessions" routerLinkActive="active">Sessioni</a>
<a routerLink="cad-invoices" routerLinkActive="active">Fatture CAD</a> <a routerLink="cad-invoices" routerLinkActive="active">Fatture CAD</a>
<a routerLink="home-media" routerLinkActive="active">Media home</a>
</nav> </nav>
</div> </div>

View File

@@ -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<AdminMediaAsset[]> {
return this.http.get<AdminMediaAsset[]>(`${this.baseUrl}/assets`, {
withCredentials: true,
});
}
uploadAsset(
file: File,
payload: AdminMediaUploadPayload,
): Observable<AdminMediaAsset> {
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<AdminMediaAsset>(`${this.baseUrl}/assets`, formData, {
withCredentials: true,
});
}
createUsage(
payload: AdminCreateMediaUsagePayload,
): Observable<AdminMediaUsage> {
return this.http.post<AdminMediaUsage>(`${this.baseUrl}/usages`, payload, {
withCredentials: true,
});
}
updateUsage(
usageId: string,
payload: AdminUpdateMediaUsagePayload,
): Observable<AdminMediaUsage> {
return this.http.patch<AdminMediaUsage>(
`${this.baseUrl}/usages/${usageId}`,
payload,
{ withCredentials: true },
);
}
deleteUsage(usageId: string): Observable<void> {
return this.http.delete<void>(`${this.baseUrl}/usages/${usageId}`, {
withCredentials: true,
});
}
}

View File

@@ -35,45 +35,33 @@
</p> </p>
</div> </div>
<div class="cap-cards"> <div class="cap-cards">
<app-card> <app-card
*ngFor="let card of capabilityCards(); trackBy: trackCapability"
>
<div class="card-image-placeholder"> <div class="card-image-placeholder">
<img @if (card.image; as image) {
src="assets/images/home/prototipi.jpg" <picture>
[attr.alt]="'HOME.CAP_1_TITLE' | translate" @if (image.source.avifUrl) {
/> <source [srcset]="image.source.avifUrl" type="image/avif" />
}
@if (image.source.webpUrl) {
<source [srcset]="image.source.webpUrl" type="image/webp" />
}
<img
[src]="image.source.fallbackUrl"
[attr.alt]="image.altText || (card.titleKey | translate)"
width="640"
height="400"
/>
</picture>
} @else {
<div class="card-image-fallback">
<span>{{ card.titleKey | translate }}</span>
</div>
}
</div> </div>
<h3>{{ "HOME.CAP_1_TITLE" | translate }}</h3> <h3>{{ card.titleKey | translate }}</h3>
<p class="text-muted">{{ "HOME.CAP_1_TEXT" | translate }}</p> <p class="text-muted">{{ card.textKey | translate }}</p>
</app-card>
<app-card>
<div class="card-image-placeholder">
<img
src="assets/images/home/original-vs-3dprinted.jpg"
[attr.alt]="'HOME.CAP_2_TITLE' | translate"
/>
</div>
<h3>{{ "HOME.CAP_2_TITLE" | translate }}</h3>
<p class="text-muted">{{ "HOME.CAP_2_TEXT" | translate }}</p>
</app-card>
<app-card>
<div class="card-image-placeholder">
<img
src="assets/images/home/serie.jpg"
[attr.alt]="'HOME.CAP_3_TITLE' | translate"
/>
</div>
<h3>{{ "HOME.CAP_3_TITLE" | translate }}</h3>
<p class="text-muted">{{ "HOME.CAP_3_TEXT" | translate }}</p>
</app-card>
<app-card>
<div class="card-image-placeholder">
<img
src="assets/images/home/cad.jpg"
[attr.alt]="'HOME.CAP_4_TITLE' | translate"
/>
</div>
<h3>{{ "HOME.CAP_4_TITLE" | translate }}</h3>
<p class="text-muted">{{ "HOME.CAP_4_TEXT" | translate }}</p>
</app-card> </app-card>
</div> </div>
</div> </div>
@@ -153,9 +141,24 @@
> >
<figure <figure
class="shop-gallery-item" class="shop-gallery-item"
*ngFor="let image of shopGalleryImages" *ngFor="let image of shopGalleryImages(); trackBy: trackMediaAsset"
> >
<img [src]="image.src" [alt]="image.alt | translate" /> <picture>
<source
*ngIf="image.source.avifUrl"
[srcset]="image.source.avifUrl"
type="image/avif"
/>
<source
*ngIf="image.source.webpUrl"
[srcset]="image.source.webpUrl"
type="image/webp"
/>
<img
[src]="image.source.fallbackUrl"
[attr.alt]="image.altText || ('HOME.SEC_SHOP_TITLE' | translate)"
/>
</picture>
</figure> </figure>
</div> </div>
<div class="shop-cards"> <div class="shop-cards">
@@ -193,29 +196,41 @@
</div> </div>
<div class="about-media"> <div class="about-media">
<div class="about-feature-image"> <div class="about-feature-image">
<img @if (currentFounderImage(); as image) {
class="about-feature-photo" <picture>
[src]="founderImages[founderImageIndex].src" @if (image.source.avifUrl) {
[alt]="founderImages[founderImageIndex].alt | translate" <source [srcset]="image.source.avifUrl" type="image/avif" />
width="1200" }
height="900" @if (image.source.webpUrl) {
/> <source [srcset]="image.source.webpUrl" type="image/webp" />
<button }
type="button" <img
class="founder-nav founder-nav-prev" class="about-feature-photo"
(click)="prevFounderImage()" [src]="image.source.fallbackUrl"
[attr.aria-label]="'HOME.FOUNDER_PREV_ARIA' | translate" [attr.alt]="image.altText || ('HOME.SEC_ABOUT_TITLE' | translate)"
> width="1200"
height="900"
</button> />
<button </picture>
type="button" }
class="founder-nav founder-nav-next" @if (founderImages().length > 1) {
(click)="nextFounderImage()" <button
[attr.aria-label]="'HOME.FOUNDER_NEXT_ARIA' | translate" type="button"
> class="founder-nav founder-nav-prev"
(click)="prevFounderImage()"
</button> [attr.aria-label]="'HOME.FOUNDER_PREV_ARIA' | translate"
>
</button>
<button
type="button"
class="founder-nav founder-nav-next"
(click)="nextFounderImage()"
[attr.aria-label]="'HOME.FOUNDER_NEXT_ARIA' | translate"
>
</button>
}
</div> </div>
</div> </div>
</div> </div>

View File

@@ -278,6 +278,36 @@
object-fit: cover; 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 { .shop {
background: var(--home-bg); background: var(--home-bg);
position: relative; position: relative;
@@ -336,6 +366,13 @@
object-fit: cover; object-fit: cover;
} }
.shop-gallery-item picture,
.about-feature-image picture {
width: 100%;
height: 100%;
display: block;
}
.shop-cards { .shop-cards {
display: grid; display: grid;
gap: var(--space-4); gap: var(--space-4);

View File

@@ -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 { CommonModule } from '@angular/common';
import { RouterLink } from '@angular/router'; import { RouterLink } from '@angular/router';
import { TranslateModule } from '@ngx-translate/core'; import { TranslateModule } from '@ngx-translate/core';
import { AppButtonComponent } from '../../shared/components/app-button/app-button.component'; import { AppButtonComponent } from '../../shared/components/app-button/app-button.component';
import { AppCardComponent } from '../../shared/components/app-card/app-card.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({ @Component({
selector: 'app-home-page', selector: 'app-home-page',
@@ -19,37 +68,147 @@ import { AppCardComponent } from '../../shared/components/app-card/app-card.comp
styleUrls: ['./home.component.scss'], styleUrls: ['./home.component.scss'],
}) })
export class HomeComponent { export class HomeComponent {
readonly shopGalleryImages = [ private readonly publicMediaService = inject(PublicMediaService);
{
src: 'assets/images/home/supporto-bici.jpg',
alt: 'HOME.SHOP_IMAGE_ALT_1',
},
];
readonly founderImages = [ private readonly mediaByUsage = toSignal(
{ this.publicMediaService.getUsageCollections([
src: 'assets/images/home/da-cambiare.jpg', {
alt: 'HOME.FOUNDER_IMAGE_ALT_1', usageType: 'HOME_SECTION',
}, usageKey: 'shop-gallery',
{ },
src: 'assets/images/home/vino.JPG', {
alt: 'HOME.FOUNDER_IMAGE_ALT_2', 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<readonly PublicMediaDisplayImage[]>(
() =>
(
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<readonly PublicMediaDisplayImage[]>(
() =>
(
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<readonly HomeCapabilityCard[]>(() =>
HOME_CAPABILITY_CONFIGS.map((config) => this.buildCapabilityCard(config)),
);
readonly founderImageIndex = signal(0);
readonly currentFounderImage = computed<PublicMediaDisplayImage | null>(() => {
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 { prevFounderImage(): void {
this.founderImageIndex = const totalImages = this.founderImages().length;
this.founderImageIndex === 0 if (totalImages <= 1) {
? this.founderImages.length - 1 return;
: this.founderImageIndex - 1; }
this.founderImageIndex.set(
this.founderImageIndex() === 0
? totalImages - 1
: this.founderImageIndex() - 1,
);
} }
nextFounderImage(): void { nextFounderImage(): void {
this.founderImageIndex = const totalImages = this.founderImages().length;
this.founderImageIndex === this.founderImages.length - 1 if (totalImages <= 1) {
return;
}
this.founderImageIndex.set(
this.founderImageIndex() === totalImages - 1
? 0 ? 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,
};
} }
} }