feat(back-end): admin home edit image page
This commit is contained in:
4
.gitignore
vendored
4
.gitignore
vendored
@@ -44,8 +44,12 @@ build/
|
||||
|
||||
./storage_orders
|
||||
./storage_quotes
|
||||
./storage_requests
|
||||
./storage_media
|
||||
storage_orders
|
||||
storage_quotes
|
||||
storage_requests
|
||||
storage_media
|
||||
|
||||
# Qodana local reports/artifacts
|
||||
backend/.qodana/
|
||||
|
||||
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
|
||||
|
||||
### Percorso OrcaSlicer
|
||||
|
||||
@@ -13,8 +13,11 @@ FROM eclipse-temurin:21-jre-jammy
|
||||
ARG ORCA_VERSION=2.3.1
|
||||
ARG ORCA_DOWNLOAD_URL
|
||||
|
||||
# Install system dependencies for OrcaSlicer (same as before)
|
||||
RUN apt-get update && apt-get install -y \
|
||||
# Install system dependencies for OrcaSlicer and media processing.
|
||||
# The build fails fast if the packaged ffmpeg lacks JPEG/WebP/AVIF encoders.
|
||||
RUN set -eux; \
|
||||
apt-get update; \
|
||||
DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
|
||||
ffmpeg \
|
||||
wget \
|
||||
assimp-utils \
|
||||
@@ -22,8 +25,13 @@ RUN apt-get update && apt-get install -y \
|
||||
libglib2.0-0 \
|
||||
libgtk-3-0 \
|
||||
libdbus-1-3 \
|
||||
libwebkit2gtk-4.0-37 \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
libwebkit2gtk-4.0-37; \
|
||||
ffmpeg -hide_banner -encoders > /tmp/ffmpeg-encoders.txt; \
|
||||
grep -Eq '[[:space:]]mjpeg[[:space:]]' /tmp/ffmpeg-encoders.txt; \
|
||||
grep -Eq '[[:space:]](libwebp|webp)[[:space:]]' /tmp/ffmpeg-encoders.txt; \
|
||||
grep -Eq '[[:space:]](libaom-av1|librav1e|libsvtav1)[[:space:]]' /tmp/ffmpeg-encoders.txt; \
|
||||
rm -f /tmp/ffmpeg-encoders.txt; \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Install OrcaSlicer
|
||||
WORKDIR /opt
|
||||
|
||||
@@ -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> findByUsageTypeAndUsageKeyAndIsActiveTrueOrderBySortOrderAscCreatedAtAsc(String usageType,
|
||||
String usageKey);
|
||||
|
||||
@Query("""
|
||||
select usage from MediaUsage usage
|
||||
where usage.usageType = :usageType
|
||||
|
||||
@@ -328,6 +328,7 @@ public class AdminMediaControllerService {
|
||||
String storageFolder = extractStorageFolder(asset.getStorageKey());
|
||||
|
||||
List<PendingGeneratedVariant> pendingVariants = new ArrayList<>();
|
||||
Set<String> skippedFormats = new LinkedHashSet<>();
|
||||
for (PresetDefinition preset : PRESETS) {
|
||||
VariantDimensions dimensions = computeVariantDimensions(
|
||||
asset.getWidthPx(),
|
||||
@@ -336,6 +337,10 @@ public class AdminMediaControllerService {
|
||||
);
|
||||
|
||||
for (String format : List.of(FORMAT_JPEG, FORMAT_WEBP, FORMAT_AVIF)) {
|
||||
if (!mediaFfmpegService.canEncode(format)) {
|
||||
skippedFormats.add(format);
|
||||
continue;
|
||||
}
|
||||
String extension = GENERATED_FORMAT_EXTENSIONS.get(format);
|
||||
Path outputFile = generatedDirectory.resolve(preset.name() + "." + extension);
|
||||
mediaFfmpegService.generateVariant(sourceFile, outputFile, dimensions.widthPx(), dimensions.heightPx(), format);
|
||||
@@ -356,6 +361,14 @@ public class AdminMediaControllerService {
|
||||
}
|
||||
}
|
||||
|
||||
if (!skippedFormats.isEmpty()) {
|
||||
logger.warn(
|
||||
"Skipping media formats for asset {} because FFmpeg encoders are unavailable: {}",
|
||||
asset.getId(),
|
||||
String.join(", ", skippedFormats)
|
||||
);
|
||||
}
|
||||
|
||||
List<String> storedKeys = new ArrayList<>();
|
||||
try {
|
||||
for (PendingGeneratedVariant pendingVariant : pendingVariants) {
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
package com.printcalculator.service.media;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
@@ -9,15 +11,31 @@ import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
@Service
|
||||
public class MediaFfmpegService {
|
||||
|
||||
private static final Logger logger = LoggerFactory.getLogger(MediaFfmpegService.class);
|
||||
|
||||
private static final Map<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 Set<String> availableEncoders;
|
||||
|
||||
public MediaFfmpegService(@Value("${media.ffmpeg.path:ffmpeg}") String ffmpegPath) {
|
||||
this.ffmpegPath = ffmpegPath;
|
||||
this.availableEncoders = Collections.unmodifiableSet(loadAvailableEncoders());
|
||||
}
|
||||
|
||||
public void generateVariant(Path source, Path target, int widthPx, int heightPx, String format) throws IOException {
|
||||
@@ -25,6 +43,11 @@ public class MediaFfmpegService {
|
||||
throw new IllegalArgumentException("Variant dimensions must be positive.");
|
||||
}
|
||||
|
||||
String encoder = resolveEncoder(format);
|
||||
if (encoder == null) {
|
||||
throw new IOException("FFmpeg encoder not available for media format " + format + ".");
|
||||
}
|
||||
|
||||
List<String> command = new ArrayList<>();
|
||||
command.add(ffmpegPath);
|
||||
command.add("-y");
|
||||
@@ -42,19 +65,19 @@ public class MediaFfmpegService {
|
||||
switch (format) {
|
||||
case "JPEG" -> {
|
||||
command.add("-c:v");
|
||||
command.add("mjpeg");
|
||||
command.add(encoder);
|
||||
command.add("-q:v");
|
||||
command.add("2");
|
||||
}
|
||||
case "WEBP" -> {
|
||||
command.add("-c:v");
|
||||
command.add("libwebp");
|
||||
command.add(encoder);
|
||||
command.add("-quality");
|
||||
command.add("82");
|
||||
}
|
||||
case "AVIF" -> {
|
||||
command.add("-c:v");
|
||||
command.add("libaom-av1");
|
||||
command.add(encoder);
|
||||
command.add("-still-picture");
|
||||
command.add("1");
|
||||
command.add("-crf");
|
||||
@@ -86,6 +109,68 @@ public class MediaFfmpegService {
|
||||
}
|
||||
}
|
||||
|
||||
public boolean canEncode(String format) {
|
||||
return resolveEncoder(format) != null;
|
||||
}
|
||||
|
||||
private String resolveEncoder(String format) {
|
||||
if (format == null) {
|
||||
return null;
|
||||
}
|
||||
List<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) {
|
||||
if (output == null || output.isBlank()) {
|
||||
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(mediaFfmpegService.canEncode(anyString())).thenReturn(true);
|
||||
|
||||
when(mediaAssetRepository.save(any(MediaAsset.class))).thenAnswer(invocation -> {
|
||||
MediaAsset asset = invocation.getArgument(0);
|
||||
@@ -246,6 +247,33 @@ class AdminMediaControllerServiceTest {
|
||||
assertTrue(variants.isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void uploadAsset_withLimitedEncoders_shouldKeepAssetReadyAndExposeOnlySupportedVariants() throws Exception {
|
||||
when(mediaImageInspector.inspect(any(Path.class))).thenReturn(
|
||||
new MediaImageInspector.ImageMetadata("image/jpeg", "jpg", 1200, 800)
|
||||
);
|
||||
when(mediaFfmpegService.canEncode("JPEG")).thenReturn(true);
|
||||
when(mediaFfmpegService.canEncode("WEBP")).thenReturn(false);
|
||||
when(mediaFfmpegService.canEncode("AVIF")).thenReturn(false);
|
||||
|
||||
MockMultipartFile file = new MockMultipartFile(
|
||||
"file",
|
||||
"capability.jpg",
|
||||
"image/jpeg",
|
||||
"jpeg-image-content".getBytes(StandardCharsets.UTF_8)
|
||||
);
|
||||
|
||||
AdminMediaAssetDto dto = service.uploadAsset(file, "Capability", null, "PUBLIC");
|
||||
|
||||
assertEquals("READY", dto.getStatus());
|
||||
assertEquals(4, dto.getVariants().size());
|
||||
assertEquals(3, dto.getVariants().stream()
|
||||
.filter(variant -> "JPEG".equals(variant.getFormat()))
|
||||
.count());
|
||||
assertTrue(dto.getVariants().stream()
|
||||
.noneMatch(variant -> "WEBP".equals(variant.getFormat()) || "AVIF".equals(variant.getFormat())));
|
||||
}
|
||||
|
||||
@Test
|
||||
void uploadAsset_withOversizedFile_shouldFailValidationBeforePersistence() {
|
||||
service = new AdminMediaControllerService(
|
||||
|
||||
@@ -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()"
|
||||
>
|
||||
<div class="placeholder-img">
|
||||
@if (joeImage(); as image) {
|
||||
<picture>
|
||||
@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="assets/images/joe.jpg"
|
||||
[attr.alt]="'ABOUT.MEMBER_JOE_ALT' | translate"
|
||||
[src]="image.source.fallbackUrl"
|
||||
[attr.alt]="image.altText || ('ABOUT.MEMBER_JOE_ALT' | translate)"
|
||||
/>
|
||||
</picture>
|
||||
}
|
||||
</div>
|
||||
<div class="member-info">
|
||||
<span class="member-name">{{
|
||||
@@ -71,10 +81,22 @@
|
||||
"
|
||||
>
|
||||
<div class="placeholder-img">
|
||||
@if (matteoImage(); as image) {
|
||||
<picture>
|
||||
@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="assets/images/matteo.jpg"
|
||||
[attr.alt]="'ABOUT.MEMBER_MATTEO_ALT' | translate"
|
||||
[src]="image.source.fallbackUrl"
|
||||
[attr.alt]="
|
||||
image.altText || ('ABOUT.MEMBER_MATTEO_ALT' | translate)
|
||||
"
|
||||
/>
|
||||
</picture>
|
||||
}
|
||||
</div>
|
||||
<div class="member-info">
|
||||
<span class="member-name">{{
|
||||
|
||||
@@ -193,6 +193,12 @@ h1 {
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.placeholder-img picture {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.member-info {
|
||||
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 { AppLocationsComponent } from '../../shared/components/app-locations/app-locations.component';
|
||||
import {
|
||||
buildPublicMediaUsageScopeKey,
|
||||
PublicMediaDisplayImage,
|
||||
PublicMediaService,
|
||||
PublicMediaUsageCollectionMap,
|
||||
} from '../../core/services/public-media.service';
|
||||
|
||||
const EMPTY_MEDIA_COLLECTIONS: PublicMediaUsageCollectionMap = {};
|
||||
|
||||
type MemberId = 'joe' | 'matteo';
|
||||
type PassionId =
|
||||
@@ -32,6 +41,39 @@ interface PassionChip {
|
||||
styleUrl: './about-page.component.scss',
|
||||
})
|
||||
export class AboutPageComponent {
|
||||
private readonly publicMediaService = inject(PublicMediaService);
|
||||
private readonly mediaByUsage = toSignal(
|
||||
this.publicMediaService.getUsageCollections([
|
||||
{
|
||||
usageType: 'ABOUT_MEMBER',
|
||||
usageKey: 'joe',
|
||||
},
|
||||
{
|
||||
usageType: 'ABOUT_MEMBER',
|
||||
usageKey: 'matteo',
|
||||
},
|
||||
]),
|
||||
{ initialValue: EMPTY_MEDIA_COLLECTIONS },
|
||||
);
|
||||
|
||||
readonly joeImage = computed<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;
|
||||
hoveredMember: MemberId | null = null;
|
||||
|
||||
|
||||
@@ -57,6 +57,13 @@ export const ADMIN_ROUTES: Routes = [
|
||||
(m) => m.AdminCadInvoicesComponent,
|
||||
),
|
||||
},
|
||||
{
|
||||
path: 'home-media',
|
||||
loadComponent: () =>
|
||||
import('./pages/admin-home-media.component').then(
|
||||
(m) => m.AdminHomeMediaComponent,
|
||||
),
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
@@ -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="cad-invoices" routerLinkActive="active">Fatture CAD</a>
|
||||
<a routerLink="home-media" routerLinkActive="active">Media home</a>
|
||||
</nav>
|
||||
</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>
|
||||
</div>
|
||||
<div class="cap-cards">
|
||||
<app-card>
|
||||
<app-card
|
||||
*ngFor="let card of capabilityCards(); trackBy: trackCapability"
|
||||
>
|
||||
<div class="card-image-placeholder">
|
||||
@if (card.image; as image) {
|
||||
<picture>
|
||||
@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="assets/images/home/prototipi.jpg"
|
||||
[attr.alt]="'HOME.CAP_1_TITLE' | translate"
|
||||
[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>
|
||||
<h3>{{ "HOME.CAP_1_TITLE" | translate }}</h3>
|
||||
<p class="text-muted">{{ "HOME.CAP_1_TEXT" | 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>
|
||||
<h3>{{ card.titleKey | translate }}</h3>
|
||||
<p class="text-muted">{{ card.textKey | translate }}</p>
|
||||
</app-card>
|
||||
</div>
|
||||
</div>
|
||||
@@ -153,9 +141,24 @@
|
||||
>
|
||||
<figure
|
||||
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>
|
||||
</div>
|
||||
<div class="shop-cards">
|
||||
@@ -193,13 +196,24 @@
|
||||
</div>
|
||||
<div class="about-media">
|
||||
<div class="about-feature-image">
|
||||
@if (currentFounderImage(); as image) {
|
||||
<picture>
|
||||
@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
|
||||
class="about-feature-photo"
|
||||
[src]="founderImages[founderImageIndex].src"
|
||||
[alt]="founderImages[founderImageIndex].alt | translate"
|
||||
[src]="image.source.fallbackUrl"
|
||||
[attr.alt]="image.altText || ('HOME.SEC_ABOUT_TITLE' | translate)"
|
||||
width="1200"
|
||||
height="900"
|
||||
/>
|
||||
</picture>
|
||||
}
|
||||
@if (founderImages().length > 1) {
|
||||
<button
|
||||
type="button"
|
||||
class="founder-nav founder-nav-prev"
|
||||
@@ -216,6 +230,7 @@
|
||||
>
|
||||
›
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -278,6 +278,36 @@
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.card-image-placeholder picture {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.card-image-fallback {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: grid;
|
||||
place-items: end start;
|
||||
padding: var(--space-4);
|
||||
background:
|
||||
linear-gradient(135deg, rgba(239, 196, 61, 0.22), rgba(255, 255, 255, 0)),
|
||||
linear-gradient(180deg, #f8f5eb 0%, #efede6 100%);
|
||||
}
|
||||
|
||||
.card-image-fallback span {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: 2rem;
|
||||
padding: 0.35rem 0.7rem;
|
||||
border-radius: 999px;
|
||||
background: rgba(255, 255, 255, 0.88);
|
||||
border: 1px solid rgba(17, 24, 39, 0.08);
|
||||
color: var(--color-neutral-900);
|
||||
font-size: 0.78rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.shop {
|
||||
background: var(--home-bg);
|
||||
position: relative;
|
||||
@@ -336,6 +366,13 @@
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.shop-gallery-item picture,
|
||||
.about-feature-image picture {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.shop-cards {
|
||||
display: grid;
|
||||
gap: var(--space-4);
|
||||
|
||||
@@ -1,9 +1,58 @@
|
||||
import { Component } from '@angular/core';
|
||||
import { Component, computed, effect, inject, signal } from '@angular/core';
|
||||
import { toSignal } from '@angular/core/rxjs-interop';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { RouterLink } from '@angular/router';
|
||||
import { TranslateModule } from '@ngx-translate/core';
|
||||
import { AppButtonComponent } from '../../shared/components/app-button/app-button.component';
|
||||
import { AppCardComponent } from '../../shared/components/app-card/app-card.component';
|
||||
import {
|
||||
buildPublicMediaUsageScopeKey,
|
||||
PublicMediaDisplayImage,
|
||||
PublicMediaImage,
|
||||
PublicMediaService,
|
||||
PublicMediaUsageCollectionMap,
|
||||
} from '../../core/services/public-media.service';
|
||||
|
||||
const EMPTY_MEDIA_COLLECTIONS: PublicMediaUsageCollectionMap = {};
|
||||
|
||||
type HomeCapabilityUsageKey =
|
||||
| 'capability-prototyping'
|
||||
| 'capability-custom-parts'
|
||||
| 'capability-small-series'
|
||||
| 'capability-cad';
|
||||
|
||||
interface HomeCapabilityConfig {
|
||||
usageKey: HomeCapabilityUsageKey;
|
||||
titleKey: string;
|
||||
textKey: string;
|
||||
}
|
||||
|
||||
interface HomeCapabilityCard extends HomeCapabilityConfig {
|
||||
image: PublicMediaDisplayImage | null;
|
||||
}
|
||||
|
||||
const HOME_CAPABILITY_CONFIGS: readonly HomeCapabilityConfig[] = [
|
||||
{
|
||||
usageKey: 'capability-prototyping',
|
||||
titleKey: 'HOME.CAP_1_TITLE',
|
||||
textKey: 'HOME.CAP_1_TEXT',
|
||||
},
|
||||
{
|
||||
usageKey: 'capability-custom-parts',
|
||||
titleKey: 'HOME.CAP_2_TITLE',
|
||||
textKey: 'HOME.CAP_2_TEXT',
|
||||
},
|
||||
{
|
||||
usageKey: 'capability-small-series',
|
||||
titleKey: 'HOME.CAP_3_TITLE',
|
||||
textKey: 'HOME.CAP_3_TEXT',
|
||||
},
|
||||
{
|
||||
usageKey: 'capability-cad',
|
||||
titleKey: 'HOME.CAP_4_TITLE',
|
||||
textKey: 'HOME.CAP_4_TEXT',
|
||||
},
|
||||
];
|
||||
|
||||
@Component({
|
||||
selector: 'app-home-page',
|
||||
@@ -19,37 +68,147 @@ import { AppCardComponent } from '../../shared/components/app-card/app-card.comp
|
||||
styleUrls: ['./home.component.scss'],
|
||||
})
|
||||
export class HomeComponent {
|
||||
readonly shopGalleryImages = [
|
||||
{
|
||||
src: 'assets/images/home/supporto-bici.jpg',
|
||||
alt: 'HOME.SHOP_IMAGE_ALT_1',
|
||||
},
|
||||
];
|
||||
private readonly publicMediaService = inject(PublicMediaService);
|
||||
|
||||
readonly founderImages = [
|
||||
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 {
|
||||
this.founderImageIndex =
|
||||
this.founderImageIndex === 0
|
||||
? this.founderImages.length - 1
|
||||
: this.founderImageIndex - 1;
|
||||
const totalImages = this.founderImages().length;
|
||||
if (totalImages <= 1) {
|
||||
return;
|
||||
}
|
||||
this.founderImageIndex.set(
|
||||
this.founderImageIndex() === 0
|
||||
? totalImages - 1
|
||||
: this.founderImageIndex() - 1,
|
||||
);
|
||||
}
|
||||
|
||||
nextFounderImage(): void {
|
||||
this.founderImageIndex =
|
||||
this.founderImageIndex === this.founderImages.length - 1
|
||||
const totalImages = this.founderImages().length;
|
||||
if (totalImages <= 1) {
|
||||
return;
|
||||
}
|
||||
this.founderImageIndex.set(
|
||||
this.founderImageIndex() === totalImages - 1
|
||||
? 0
|
||||
: this.founderImageIndex + 1;
|
||||
: this.founderImageIndex() + 1,
|
||||
);
|
||||
}
|
||||
|
||||
trackMediaAsset(_: number, image: PublicMediaDisplayImage): string {
|
||||
return image.mediaAssetId;
|
||||
}
|
||||
|
||||
trackCapability(_: number, card: HomeCapabilityCard): string {
|
||||
return card.usageKey;
|
||||
}
|
||||
|
||||
private buildCapabilityCard(
|
||||
config: HomeCapabilityConfig,
|
||||
): HomeCapabilityCard {
|
||||
const items =
|
||||
this.mediaByUsage()[
|
||||
buildPublicMediaUsageScopeKey('HOME_SECTION', config.usageKey)
|
||||
] ?? [];
|
||||
const primaryImage = this.publicMediaService.pickPrimaryUsage(items);
|
||||
|
||||
return {
|
||||
...config,
|
||||
image: primaryImage
|
||||
? this.publicMediaService.toDisplayImage(primaryImage, 'card')
|
||||
: null,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user