dev #37
4
.gitignore
vendored
4
.gitignore
vendored
@@ -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/
|
||||||
|
|||||||
21
README.md
21
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 `<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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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 "";
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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(
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
241
frontend/src/app/core/services/public-media.service.ts
Normal file
241
frontend/src/app/core/services/public-media.service.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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">{{
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
),
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
135
frontend/src/app/features/admin/services/admin-media.service.ts
Normal file
135
frontend/src/app/features/admin/services/admin-media.service.ts
Normal 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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user