feat/shop #31
@@ -24,7 +24,8 @@ public class PublicMediaController {
|
|||||||
|
|
||||||
@GetMapping("/usages")
|
@GetMapping("/usages")
|
||||||
public ResponseEntity<List<PublicMediaUsageDto>> getUsageMedia(@RequestParam String usageType,
|
public ResponseEntity<List<PublicMediaUsageDto>> getUsageMedia(@RequestParam String usageType,
|
||||||
@RequestParam String usageKey) {
|
@RequestParam String usageKey,
|
||||||
return ResponseEntity.ok(publicMediaQueryService.getUsageMedia(usageType, usageKey));
|
@RequestParam(required = false) String lang) {
|
||||||
|
return ResponseEntity.ok(publicMediaQueryService.getUsageMedia(usageType, usageKey, lang));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package com.printcalculator.dto;
|
package com.printcalculator.dto;
|
||||||
|
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
public class AdminCreateMediaUsageRequest {
|
public class AdminCreateMediaUsageRequest {
|
||||||
private String usageType;
|
private String usageType;
|
||||||
@@ -10,6 +11,7 @@ public class AdminCreateMediaUsageRequest {
|
|||||||
private Integer sortOrder;
|
private Integer sortOrder;
|
||||||
private Boolean isPrimary;
|
private Boolean isPrimary;
|
||||||
private Boolean isActive;
|
private Boolean isActive;
|
||||||
|
private Map<String, MediaTextTranslationDto> translations;
|
||||||
|
|
||||||
public String getUsageType() {
|
public String getUsageType() {
|
||||||
return usageType;
|
return usageType;
|
||||||
@@ -66,4 +68,12 @@ public class AdminCreateMediaUsageRequest {
|
|||||||
public void setIsActive(Boolean active) {
|
public void setIsActive(Boolean active) {
|
||||||
isActive = active;
|
isActive = active;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public Map<String, MediaTextTranslationDto> getTranslations() {
|
||||||
|
return translations;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setTranslations(Map<String, MediaTextTranslationDto> translations) {
|
||||||
|
this.translations = translations;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package com.printcalculator.dto;
|
package com.printcalculator.dto;
|
||||||
|
|
||||||
import java.time.OffsetDateTime;
|
import java.time.OffsetDateTime;
|
||||||
|
import java.util.Map;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
|
||||||
public class AdminMediaUsageDto {
|
public class AdminMediaUsageDto {
|
||||||
@@ -12,6 +13,7 @@ public class AdminMediaUsageDto {
|
|||||||
private Integer sortOrder;
|
private Integer sortOrder;
|
||||||
private Boolean isPrimary;
|
private Boolean isPrimary;
|
||||||
private Boolean isActive;
|
private Boolean isActive;
|
||||||
|
private Map<String, MediaTextTranslationDto> translations;
|
||||||
private OffsetDateTime createdAt;
|
private OffsetDateTime createdAt;
|
||||||
|
|
||||||
public UUID getId() {
|
public UUID getId() {
|
||||||
@@ -78,6 +80,14 @@ public class AdminMediaUsageDto {
|
|||||||
isActive = active;
|
isActive = active;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public Map<String, MediaTextTranslationDto> getTranslations() {
|
||||||
|
return translations;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setTranslations(Map<String, MediaTextTranslationDto> translations) {
|
||||||
|
this.translations = translations;
|
||||||
|
}
|
||||||
|
|
||||||
public OffsetDateTime getCreatedAt() {
|
public OffsetDateTime getCreatedAt() {
|
||||||
return createdAt;
|
return createdAt;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package com.printcalculator.dto;
|
package com.printcalculator.dto;
|
||||||
|
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
public class AdminUpdateMediaUsageRequest {
|
public class AdminUpdateMediaUsageRequest {
|
||||||
private String usageType;
|
private String usageType;
|
||||||
@@ -10,6 +11,7 @@ public class AdminUpdateMediaUsageRequest {
|
|||||||
private Integer sortOrder;
|
private Integer sortOrder;
|
||||||
private Boolean isPrimary;
|
private Boolean isPrimary;
|
||||||
private Boolean isActive;
|
private Boolean isActive;
|
||||||
|
private Map<String, MediaTextTranslationDto> translations;
|
||||||
|
|
||||||
public String getUsageType() {
|
public String getUsageType() {
|
||||||
return usageType;
|
return usageType;
|
||||||
@@ -66,4 +68,12 @@ public class AdminUpdateMediaUsageRequest {
|
|||||||
public void setIsActive(Boolean active) {
|
public void setIsActive(Boolean active) {
|
||||||
isActive = active;
|
isActive = active;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public Map<String, MediaTextTranslationDto> getTranslations() {
|
||||||
|
return translations;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setTranslations(Map<String, MediaTextTranslationDto> translations) {
|
||||||
|
this.translations = translations;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
package com.printcalculator.dto;
|
||||||
|
|
||||||
|
public class MediaTextTranslationDto {
|
||||||
|
private String title;
|
||||||
|
private String altText;
|
||||||
|
|
||||||
|
public String getTitle() {
|
||||||
|
return title;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setTitle(String title) {
|
||||||
|
this.title = title;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getAltText() {
|
||||||
|
return altText;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setAltText(String altText) {
|
||||||
|
this.altText = altText;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -53,6 +53,30 @@ public class MediaUsage {
|
|||||||
@Column(name = "is_active", nullable = false)
|
@Column(name = "is_active", nullable = false)
|
||||||
private Boolean isActive;
|
private Boolean isActive;
|
||||||
|
|
||||||
|
@Column(name = "title_it", length = Integer.MAX_VALUE)
|
||||||
|
private String titleIt;
|
||||||
|
|
||||||
|
@Column(name = "title_en", length = Integer.MAX_VALUE)
|
||||||
|
private String titleEn;
|
||||||
|
|
||||||
|
@Column(name = "title_de", length = Integer.MAX_VALUE)
|
||||||
|
private String titleDe;
|
||||||
|
|
||||||
|
@Column(name = "title_fr", length = Integer.MAX_VALUE)
|
||||||
|
private String titleFr;
|
||||||
|
|
||||||
|
@Column(name = "alt_text_it", length = Integer.MAX_VALUE)
|
||||||
|
private String altTextIt;
|
||||||
|
|
||||||
|
@Column(name = "alt_text_en", length = Integer.MAX_VALUE)
|
||||||
|
private String altTextEn;
|
||||||
|
|
||||||
|
@Column(name = "alt_text_de", length = Integer.MAX_VALUE)
|
||||||
|
private String altTextDe;
|
||||||
|
|
||||||
|
@Column(name = "alt_text_fr", length = Integer.MAX_VALUE)
|
||||||
|
private String altTextFr;
|
||||||
|
|
||||||
@ColumnDefault("now()")
|
@ColumnDefault("now()")
|
||||||
@Column(name = "created_at", nullable = false)
|
@Column(name = "created_at", nullable = false)
|
||||||
private OffsetDateTime createdAt;
|
private OffsetDateTime createdAt;
|
||||||
@@ -121,6 +145,70 @@ public class MediaUsage {
|
|||||||
isActive = active;
|
isActive = active;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public String getTitleIt() {
|
||||||
|
return titleIt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setTitleIt(String titleIt) {
|
||||||
|
this.titleIt = titleIt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getTitleEn() {
|
||||||
|
return titleEn;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setTitleEn(String titleEn) {
|
||||||
|
this.titleEn = titleEn;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getTitleDe() {
|
||||||
|
return titleDe;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setTitleDe(String titleDe) {
|
||||||
|
this.titleDe = titleDe;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getTitleFr() {
|
||||||
|
return titleFr;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setTitleFr(String titleFr) {
|
||||||
|
this.titleFr = titleFr;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getAltTextIt() {
|
||||||
|
return altTextIt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setAltTextIt(String altTextIt) {
|
||||||
|
this.altTextIt = altTextIt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getAltTextEn() {
|
||||||
|
return altTextEn;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setAltTextEn(String altTextEn) {
|
||||||
|
this.altTextEn = altTextEn;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getAltTextDe() {
|
||||||
|
return altTextDe;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setAltTextDe(String altTextDe) {
|
||||||
|
this.altTextDe = altTextDe;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getAltTextFr() {
|
||||||
|
return altTextFr;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setAltTextFr(String altTextFr) {
|
||||||
|
this.altTextFr = altTextFr;
|
||||||
|
}
|
||||||
|
|
||||||
public OffsetDateTime getCreatedAt() {
|
public OffsetDateTime getCreatedAt() {
|
||||||
return createdAt;
|
return createdAt;
|
||||||
}
|
}
|
||||||
@@ -128,4 +216,58 @@ public class MediaUsage {
|
|||||||
public void setCreatedAt(OffsetDateTime createdAt) {
|
public void setCreatedAt(OffsetDateTime createdAt) {
|
||||||
this.createdAt = createdAt;
|
this.createdAt = createdAt;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public String getTitleForLanguage(String language) {
|
||||||
|
if (language == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return switch (language.trim().toLowerCase()) {
|
||||||
|
case "it" -> titleIt;
|
||||||
|
case "en" -> titleEn;
|
||||||
|
case "de" -> titleDe;
|
||||||
|
case "fr" -> titleFr;
|
||||||
|
default -> null;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setTitleForLanguage(String language, String value) {
|
||||||
|
if (language == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
switch (language.trim().toLowerCase()) {
|
||||||
|
case "it" -> titleIt = value;
|
||||||
|
case "en" -> titleEn = value;
|
||||||
|
case "de" -> titleDe = value;
|
||||||
|
case "fr" -> titleFr = value;
|
||||||
|
default -> {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getAltTextForLanguage(String language) {
|
||||||
|
if (language == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return switch (language.trim().toLowerCase()) {
|
||||||
|
case "it" -> altTextIt;
|
||||||
|
case "en" -> altTextEn;
|
||||||
|
case "de" -> altTextDe;
|
||||||
|
case "fr" -> altTextFr;
|
||||||
|
default -> null;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setAltTextForLanguage(String language, String value) {
|
||||||
|
if (language == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
switch (language.trim().toLowerCase()) {
|
||||||
|
case "it" -> altTextIt = value;
|
||||||
|
case "en" -> altTextEn = value;
|
||||||
|
case "de" -> altTextDe = value;
|
||||||
|
case "fr" -> altTextFr = value;
|
||||||
|
default -> {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import com.printcalculator.dto.AdminCreateMediaUsageRequest;
|
|||||||
import com.printcalculator.dto.AdminMediaAssetDto;
|
import com.printcalculator.dto.AdminMediaAssetDto;
|
||||||
import com.printcalculator.dto.AdminMediaUsageDto;
|
import com.printcalculator.dto.AdminMediaUsageDto;
|
||||||
import com.printcalculator.dto.AdminMediaVariantDto;
|
import com.printcalculator.dto.AdminMediaVariantDto;
|
||||||
|
import com.printcalculator.dto.MediaTextTranslationDto;
|
||||||
import com.printcalculator.dto.AdminUpdateMediaAssetRequest;
|
import com.printcalculator.dto.AdminUpdateMediaAssetRequest;
|
||||||
import com.printcalculator.dto.AdminUpdateMediaUsageRequest;
|
import com.printcalculator.dto.AdminUpdateMediaUsageRequest;
|
||||||
import com.printcalculator.entity.MediaAsset;
|
import com.printcalculator.entity.MediaAsset;
|
||||||
@@ -78,6 +79,7 @@ public class AdminMediaControllerService {
|
|||||||
private static final Set<String> ALLOWED_UPLOAD_MIME_TYPES = Set.of(
|
private static final Set<String> ALLOWED_UPLOAD_MIME_TYPES = Set.of(
|
||||||
"image/jpeg", "image/png", "image/webp"
|
"image/jpeg", "image/png", "image/webp"
|
||||||
);
|
);
|
||||||
|
private static final List<String> SUPPORTED_MEDIA_LANGUAGES = List.of("it", "en", "de", "fr");
|
||||||
private static final Map<String, String> GENERATED_FORMAT_MIME_TYPES = Map.of(
|
private static final Map<String, String> GENERATED_FORMAT_MIME_TYPES = Map.of(
|
||||||
FORMAT_JPEG, "image/jpeg",
|
FORMAT_JPEG, "image/jpeg",
|
||||||
FORMAT_WEBP, "image/webp",
|
FORMAT_WEBP, "image/webp",
|
||||||
@@ -261,6 +263,7 @@ public class AdminMediaControllerService {
|
|||||||
String usageType = requireUsageType(payload.getUsageType());
|
String usageType = requireUsageType(payload.getUsageType());
|
||||||
String usageKey = requireUsageKey(payload.getUsageKey());
|
String usageKey = requireUsageKey(payload.getUsageKey());
|
||||||
boolean isPrimary = Boolean.TRUE.equals(payload.getIsPrimary());
|
boolean isPrimary = Boolean.TRUE.equals(payload.getIsPrimary());
|
||||||
|
Map<String, MediaTextTranslationDto> translations = requireTranslations(payload.getTranslations());
|
||||||
|
|
||||||
if (isPrimary) {
|
if (isPrimary) {
|
||||||
unsetPrimaryForScope(usageType, usageKey, payload.getOwnerId(), null);
|
unsetPrimaryForScope(usageType, usageKey, payload.getOwnerId(), null);
|
||||||
@@ -275,6 +278,7 @@ public class AdminMediaControllerService {
|
|||||||
usage.setIsPrimary(isPrimary);
|
usage.setIsPrimary(isPrimary);
|
||||||
usage.setIsActive(payload.getIsActive() == null || payload.getIsActive());
|
usage.setIsActive(payload.getIsActive() == null || payload.getIsActive());
|
||||||
usage.setCreatedAt(OffsetDateTime.now());
|
usage.setCreatedAt(OffsetDateTime.now());
|
||||||
|
applyTranslations(usage, translations);
|
||||||
|
|
||||||
MediaUsage saved = mediaUsageRepository.save(usage);
|
MediaUsage saved = mediaUsageRepository.save(usage);
|
||||||
return toUsageDto(saved);
|
return toUsageDto(saved);
|
||||||
@@ -309,6 +313,9 @@ public class AdminMediaControllerService {
|
|||||||
if (payload.getIsPrimary() != null) {
|
if (payload.getIsPrimary() != null) {
|
||||||
usage.setIsPrimary(payload.getIsPrimary());
|
usage.setIsPrimary(payload.getIsPrimary());
|
||||||
}
|
}
|
||||||
|
if (payload.getTranslations() != null) {
|
||||||
|
applyTranslations(usage, requireTranslations(payload.getTranslations()));
|
||||||
|
}
|
||||||
|
|
||||||
if (Boolean.TRUE.equals(usage.getIsPrimary())) {
|
if (Boolean.TRUE.equals(usage.getIsPrimary())) {
|
||||||
unsetPrimaryForScope(usage.getUsageType(), usage.getUsageKey(), usage.getOwnerId(), usage.getId());
|
unsetPrimaryForScope(usage.getUsageType(), usage.getUsageKey(), usage.getOwnerId(), usage.getId());
|
||||||
@@ -525,6 +532,7 @@ public class AdminMediaControllerService {
|
|||||||
dto.setSortOrder(usage.getSortOrder());
|
dto.setSortOrder(usage.getSortOrder());
|
||||||
dto.setIsPrimary(usage.getIsPrimary());
|
dto.setIsPrimary(usage.getIsPrimary());
|
||||||
dto.setIsActive(usage.getIsActive());
|
dto.setIsActive(usage.getIsActive());
|
||||||
|
dto.setTranslations(extractTranslations(usage));
|
||||||
dto.setCreatedAt(usage.getCreatedAt());
|
dto.setCreatedAt(usage.getCreatedAt());
|
||||||
return dto;
|
return dto;
|
||||||
}
|
}
|
||||||
@@ -639,6 +647,96 @@ public class AdminMediaControllerService {
|
|||||||
return normalized;
|
return normalized;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private Map<String, MediaTextTranslationDto> requireTranslations(Map<String, MediaTextTranslationDto> translations) {
|
||||||
|
if (translations == null || translations.isEmpty()) {
|
||||||
|
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "translations are required.");
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, MediaTextTranslationDto> normalized = new LinkedHashMap<>();
|
||||||
|
for (Map.Entry<String, MediaTextTranslationDto> entry : translations.entrySet()) {
|
||||||
|
String language = normalizeTranslationLanguage(entry.getKey());
|
||||||
|
if (normalized.containsKey(language)) {
|
||||||
|
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Duplicate translation language: " + language + ".");
|
||||||
|
}
|
||||||
|
normalized.put(language, entry.getValue());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!normalized.keySet().equals(new LinkedHashSet<>(SUPPORTED_MEDIA_LANGUAGES))) {
|
||||||
|
throw new ResponseStatusException(
|
||||||
|
HttpStatus.BAD_REQUEST,
|
||||||
|
"translations must include exactly: " + String.join(", ", SUPPORTED_MEDIA_LANGUAGES) + "."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
LinkedHashMap<String, MediaTextTranslationDto> result = new LinkedHashMap<>();
|
||||||
|
for (String language : SUPPORTED_MEDIA_LANGUAGES) {
|
||||||
|
MediaTextTranslationDto translation = normalized.get(language);
|
||||||
|
if (translation == null) {
|
||||||
|
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Missing translation for language " + language + ".");
|
||||||
|
}
|
||||||
|
|
||||||
|
String title = normalizeRequiredTranslationValue(translation.getTitle(), language, "title");
|
||||||
|
String altText = normalizeRequiredTranslationValue(translation.getAltText(), language, "altText");
|
||||||
|
|
||||||
|
MediaTextTranslationDto dto = new MediaTextTranslationDto();
|
||||||
|
dto.setTitle(title);
|
||||||
|
dto.setAltText(altText);
|
||||||
|
result.put(language, dto);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String normalizeTranslationLanguage(String language) {
|
||||||
|
if (language == null || language.isBlank()) {
|
||||||
|
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Translation language is required.");
|
||||||
|
}
|
||||||
|
String normalized = language.trim().toLowerCase(Locale.ROOT);
|
||||||
|
if (!SUPPORTED_MEDIA_LANGUAGES.contains(normalized)) {
|
||||||
|
throw new ResponseStatusException(
|
||||||
|
HttpStatus.BAD_REQUEST,
|
||||||
|
"Unsupported translation language: " + normalized + "."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String normalizeRequiredTranslationValue(String value, String language, String fieldName) {
|
||||||
|
String normalized = normalizeText(value);
|
||||||
|
if (normalized == null || normalized.isBlank()) {
|
||||||
|
throw new ResponseStatusException(
|
||||||
|
HttpStatus.BAD_REQUEST,
|
||||||
|
"Translation " + fieldName + " is required for language " + language + "."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void applyTranslations(MediaUsage usage, Map<String, MediaTextTranslationDto> translations) {
|
||||||
|
for (String language : SUPPORTED_MEDIA_LANGUAGES) {
|
||||||
|
MediaTextTranslationDto translation = translations.get(language);
|
||||||
|
usage.setTitleForLanguage(language, translation.getTitle());
|
||||||
|
usage.setAltTextForLanguage(language, translation.getAltText());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private Map<String, MediaTextTranslationDto> extractTranslations(MediaUsage usage) {
|
||||||
|
LinkedHashMap<String, MediaTextTranslationDto> translations = new LinkedHashMap<>();
|
||||||
|
String fallbackTitle = usage.getMediaAsset() != null ? usage.getMediaAsset().getTitle() : null;
|
||||||
|
String fallbackAltText = usage.getMediaAsset() != null ? usage.getMediaAsset().getAltText() : null;
|
||||||
|
|
||||||
|
for (String language : SUPPORTED_MEDIA_LANGUAGES) {
|
||||||
|
MediaTextTranslationDto dto = new MediaTextTranslationDto();
|
||||||
|
dto.setTitle(firstNonBlank(usage.getTitleForLanguage(language), fallbackTitle));
|
||||||
|
dto.setAltText(firstNonBlank(usage.getAltTextForLanguage(language), fallbackAltText));
|
||||||
|
translations.put(language, dto);
|
||||||
|
}
|
||||||
|
return translations;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String firstNonBlank(String preferred, String fallback) {
|
||||||
|
return StringUtils.hasText(preferred) ? preferred : normalizeText(fallback);
|
||||||
|
}
|
||||||
|
|
||||||
private String normalizeText(String value) {
|
private String normalizeText(String value) {
|
||||||
if (value == null) {
|
if (value == null) {
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ public class MediaFfmpegService {
|
|||||||
private final Set<String> availableEncoders;
|
private final Set<String> availableEncoders;
|
||||||
|
|
||||||
public MediaFfmpegService(@Value("${media.ffmpeg.path:ffmpeg}") String ffmpegPath) {
|
public MediaFfmpegService(@Value("${media.ffmpeg.path:ffmpeg}") String ffmpegPath) {
|
||||||
this.ffmpegExecutable = sanitizeExecutable(ffmpegPath);
|
this.ffmpegExecutable = resolveExecutable(ffmpegPath);
|
||||||
this.availableEncoders = Collections.unmodifiableSet(loadAvailableEncoders());
|
this.availableEncoders = Collections.unmodifiableSet(loadAvailableEncoders());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -147,7 +147,11 @@ public class MediaFfmpegService {
|
|||||||
}
|
}
|
||||||
return parseAvailableEncoders(output);
|
return parseAvailableEncoders(output);
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
logger.warn("Unable to inspect FFmpeg encoders. Falling back to empty encoder list.", e);
|
logger.warn(
|
||||||
|
"Unable to inspect FFmpeg encoders for executable '{}'. Falling back to empty encoder list. {}",
|
||||||
|
ffmpegExecutable,
|
||||||
|
e.getMessage()
|
||||||
|
);
|
||||||
return Set.of();
|
return Set.of();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -186,6 +190,34 @@ public class MediaFfmpegService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static String resolveExecutable(String configuredExecutable) {
|
||||||
|
String candidate = sanitizeExecutable(configuredExecutable);
|
||||||
|
|
||||||
|
try {
|
||||||
|
Path configuredPath = Path.of(candidate);
|
||||||
|
if (!configuredPath.isAbsolute()) {
|
||||||
|
return candidate;
|
||||||
|
}
|
||||||
|
if (Files.isExecutable(configuredPath)) {
|
||||||
|
return configuredPath.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
Path filename = configuredPath.getFileName();
|
||||||
|
String fallbackExecutable = filename == null ? null : filename.toString();
|
||||||
|
if (fallbackExecutable != null && !fallbackExecutable.isBlank()) {
|
||||||
|
logger.warn(
|
||||||
|
"Configured FFmpeg executable '{}' not found or not executable. Falling back to '{}' from PATH.",
|
||||||
|
configuredPath,
|
||||||
|
fallbackExecutable
|
||||||
|
);
|
||||||
|
return fallbackExecutable;
|
||||||
|
}
|
||||||
|
return candidate;
|
||||||
|
} catch (InvalidPathException e) {
|
||||||
|
throw new IllegalArgumentException("media.ffmpeg.path is not a valid executable path.", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private Path sanitizeMediaPath(Path path, String label, boolean requireExistingFile) throws IOException {
|
private Path sanitizeMediaPath(Path path, String label, boolean requireExistingFile) throws IOException {
|
||||||
if (path == null) {
|
if (path == null) {
|
||||||
throw new IllegalArgumentException("Media " + label + " path is required.");
|
throw new IllegalArgumentException("Media " + label + " path is required.");
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ public class PublicMediaQueryService {
|
|||||||
private static final String FORMAT_JPEG = "JPEG";
|
private static final String FORMAT_JPEG = "JPEG";
|
||||||
private static final String FORMAT_WEBP = "WEBP";
|
private static final String FORMAT_WEBP = "WEBP";
|
||||||
private static final String FORMAT_AVIF = "AVIF";
|
private static final String FORMAT_AVIF = "AVIF";
|
||||||
|
private static final List<String> SUPPORTED_MEDIA_LANGUAGES = List.of("it", "en", "de", "fr");
|
||||||
|
|
||||||
private final MediaUsageRepository mediaUsageRepository;
|
private final MediaUsageRepository mediaUsageRepository;
|
||||||
private final MediaVariantRepository mediaVariantRepository;
|
private final MediaVariantRepository mediaVariantRepository;
|
||||||
@@ -44,9 +45,10 @@ public class PublicMediaQueryService {
|
|||||||
this.mediaStorageService = mediaStorageService;
|
this.mediaStorageService = mediaStorageService;
|
||||||
}
|
}
|
||||||
|
|
||||||
public List<PublicMediaUsageDto> getUsageMedia(String usageType, String usageKey) {
|
public List<PublicMediaUsageDto> getUsageMedia(String usageType, String usageKey, String language) {
|
||||||
String normalizedUsageType = normalizeUsageType(usageType);
|
String normalizedUsageType = normalizeUsageType(usageType);
|
||||||
String normalizedUsageKey = normalizeUsageKey(usageKey);
|
String normalizedUsageKey = normalizeUsageKey(usageKey);
|
||||||
|
String normalizedLanguage = normalizeLanguage(language);
|
||||||
|
|
||||||
List<MediaUsage> usages = mediaUsageRepository
|
List<MediaUsage> usages = mediaUsageRepository
|
||||||
.findByUsageTypeAndUsageKeyAndIsActiveTrueOrderBySortOrderAscCreatedAtAsc(
|
.findByUsageTypeAndUsageKeyAndIsActiveTrueOrderBySortOrderAscCreatedAtAsc(
|
||||||
@@ -80,7 +82,8 @@ public class PublicMediaQueryService {
|
|||||||
return usages.stream()
|
return usages.stream()
|
||||||
.map(usage -> toDto(
|
.map(usage -> toDto(
|
||||||
usage,
|
usage,
|
||||||
variantsByAssetId.getOrDefault(usage.getMediaAsset().getId(), List.of())
|
variantsByAssetId.getOrDefault(usage.getMediaAsset().getId(), List.of()),
|
||||||
|
normalizedLanguage
|
||||||
))
|
))
|
||||||
.toList();
|
.toList();
|
||||||
}
|
}
|
||||||
@@ -92,7 +95,7 @@ public class PublicMediaQueryService {
|
|||||||
&& VISIBILITY_PUBLIC.equals(asset.getVisibility());
|
&& VISIBILITY_PUBLIC.equals(asset.getVisibility());
|
||||||
}
|
}
|
||||||
|
|
||||||
private PublicMediaUsageDto toDto(MediaUsage usage, List<MediaVariant> variants) {
|
private PublicMediaUsageDto toDto(MediaUsage usage, List<MediaVariant> variants, String language) {
|
||||||
Map<String, Map<String, MediaVariant>> variantsByPresetAndFormat = variants.stream()
|
Map<String, Map<String, MediaVariant>> variantsByPresetAndFormat = variants.stream()
|
||||||
.collect(Collectors.groupingBy(
|
.collect(Collectors.groupingBy(
|
||||||
MediaVariant::getVariantName,
|
MediaVariant::getVariantName,
|
||||||
@@ -101,8 +104,8 @@ public class PublicMediaQueryService {
|
|||||||
|
|
||||||
PublicMediaUsageDto dto = new PublicMediaUsageDto();
|
PublicMediaUsageDto dto = new PublicMediaUsageDto();
|
||||||
dto.setMediaAssetId(usage.getMediaAsset().getId());
|
dto.setMediaAssetId(usage.getMediaAsset().getId());
|
||||||
dto.setTitle(usage.getMediaAsset().getTitle());
|
dto.setTitle(resolveLocalizedValue(usage.getTitleForLanguage(language), usage.getMediaAsset().getTitle()));
|
||||||
dto.setAltText(usage.getMediaAsset().getAltText());
|
dto.setAltText(resolveLocalizedValue(usage.getAltTextForLanguage(language), usage.getMediaAsset().getAltText()));
|
||||||
dto.setUsageType(usage.getUsageType());
|
dto.setUsageType(usage.getUsageType());
|
||||||
dto.setUsageKey(usage.getUsageKey());
|
dto.setUsageKey(usage.getUsageKey());
|
||||||
dto.setSortOrder(usage.getSortOrder());
|
dto.setSortOrder(usage.getSortOrder());
|
||||||
@@ -145,4 +148,23 @@ public class PublicMediaQueryService {
|
|||||||
}
|
}
|
||||||
return usageKey.trim();
|
return usageKey.trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private String normalizeLanguage(String language) {
|
||||||
|
if (language == null || language.isBlank()) {
|
||||||
|
return "it";
|
||||||
|
}
|
||||||
|
|
||||||
|
String normalized = language.trim().toLowerCase(Locale.ROOT);
|
||||||
|
return SUPPORTED_MEDIA_LANGUAGES.contains(normalized) ? normalized : "it";
|
||||||
|
}
|
||||||
|
|
||||||
|
private String resolveLocalizedValue(String preferred, String fallback) {
|
||||||
|
if (preferred != null && !preferred.isBlank()) {
|
||||||
|
return preferred;
|
||||||
|
}
|
||||||
|
if (fallback != null && !fallback.isBlank()) {
|
||||||
|
return fallback.trim();
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import com.printcalculator.dto.AdminCreateMediaUsageRequest;
|
|||||||
import com.printcalculator.dto.AdminMediaAssetDto;
|
import com.printcalculator.dto.AdminMediaAssetDto;
|
||||||
import com.printcalculator.dto.AdminMediaUsageDto;
|
import com.printcalculator.dto.AdminMediaUsageDto;
|
||||||
import com.printcalculator.dto.AdminUpdateMediaAssetRequest;
|
import com.printcalculator.dto.AdminUpdateMediaAssetRequest;
|
||||||
|
import com.printcalculator.dto.MediaTextTranslationDto;
|
||||||
import com.printcalculator.entity.MediaAsset;
|
import com.printcalculator.entity.MediaAsset;
|
||||||
import com.printcalculator.entity.MediaUsage;
|
import com.printcalculator.entity.MediaUsage;
|
||||||
import com.printcalculator.entity.MediaVariant;
|
import com.printcalculator.entity.MediaVariant;
|
||||||
@@ -325,6 +326,7 @@ class AdminMediaControllerServiceTest {
|
|||||||
payload.setMediaAssetId(asset.getId());
|
payload.setMediaAssetId(asset.getId());
|
||||||
payload.setSortOrder(5);
|
payload.setSortOrder(5);
|
||||||
payload.setIsPrimary(true);
|
payload.setIsPrimary(true);
|
||||||
|
payload.setTranslations(buildTranslations("Landing hero", "Hero home alt"));
|
||||||
|
|
||||||
AdminMediaUsageDto created = service.createUsage(payload);
|
AdminMediaUsageDto created = service.createUsage(payload);
|
||||||
|
|
||||||
@@ -333,6 +335,8 @@ class AdminMediaControllerServiceTest {
|
|||||||
assertEquals(asset.getId(), created.getMediaAssetId());
|
assertEquals(asset.getId(), created.getMediaAssetId());
|
||||||
assertEquals(5, created.getSortOrder());
|
assertEquals(5, created.getSortOrder());
|
||||||
assertTrue(created.getIsPrimary());
|
assertTrue(created.getIsPrimary());
|
||||||
|
assertEquals("Landing hero IT", created.getTranslations().get("it").getTitle());
|
||||||
|
assertEquals("Hero home alt EN", created.getTranslations().get("en").getAltText());
|
||||||
assertFalse(usages.get(existingPrimary.getId()).getIsPrimary());
|
assertFalse(usages.get(existingPrimary.getId()).getIsPrimary());
|
||||||
|
|
||||||
AdminMediaAssetDto assetDto = service.getAsset(asset.getId());
|
AdminMediaAssetDto assetDto = service.getAsset(asset.getId());
|
||||||
@@ -340,6 +344,28 @@ class AdminMediaControllerServiceTest {
|
|||||||
assertTrue(assetDto.getUsages().stream().anyMatch(usage -> usage.getId().equals(created.getId()) && usage.getIsPrimary()));
|
assertTrue(assetDto.getUsages().stream().anyMatch(usage -> usage.getId().equals(created.getId()) && usage.getIsPrimary()));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void createUsage_withoutAllTranslations_shouldFailValidation() {
|
||||||
|
MediaAsset asset = persistAsset(seedAsset("PUBLIC"));
|
||||||
|
|
||||||
|
AdminCreateMediaUsageRequest payload = new AdminCreateMediaUsageRequest();
|
||||||
|
payload.setUsageType("home");
|
||||||
|
payload.setUsageKey("landing");
|
||||||
|
payload.setMediaAssetId(asset.getId());
|
||||||
|
payload.setTranslations(new LinkedHashMap<>(Map.of(
|
||||||
|
"it", translation("Titolo IT", "Alt IT"),
|
||||||
|
"en", translation("Title EN", "Alt EN")
|
||||||
|
)));
|
||||||
|
|
||||||
|
ResponseStatusException ex = assertThrows(
|
||||||
|
ResponseStatusException.class,
|
||||||
|
() -> service.createUsage(payload)
|
||||||
|
);
|
||||||
|
|
||||||
|
assertEquals(HttpStatus.BAD_REQUEST, ex.getStatusCode());
|
||||||
|
assertTrue(ex.getReason().contains("translations must include exactly"));
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void updateAsset_withPrivateVisibility_shouldMoveGeneratedFilesAndHidePublicUrls() throws Exception {
|
void updateAsset_withPrivateVisibility_shouldMoveGeneratedFilesAndHidePublicUrls() throws Exception {
|
||||||
when(mediaImageInspector.inspect(any(Path.class))).thenReturn(
|
when(mediaImageInspector.inspect(any(Path.class))).thenReturn(
|
||||||
@@ -414,6 +440,22 @@ class AdminMediaControllerServiceTest {
|
|||||||
return usage;
|
return usage;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private Map<String, MediaTextTranslationDto> buildTranslations(String titleBase, String altBase) {
|
||||||
|
LinkedHashMap<String, MediaTextTranslationDto> translations = new LinkedHashMap<>();
|
||||||
|
translations.put("it", translation(titleBase + " IT", altBase + " IT"));
|
||||||
|
translations.put("en", translation(titleBase + " EN", altBase + " EN"));
|
||||||
|
translations.put("de", translation(titleBase + " DE", altBase + " DE"));
|
||||||
|
translations.put("fr", translation(titleBase + " FR", altBase + " FR"));
|
||||||
|
return translations;
|
||||||
|
}
|
||||||
|
|
||||||
|
private MediaTextTranslationDto translation(String title, String altText) {
|
||||||
|
MediaTextTranslationDto dto = new MediaTextTranslationDto();
|
||||||
|
dto.setTitle(title);
|
||||||
|
dto.setAltText(altText);
|
||||||
|
return dto;
|
||||||
|
}
|
||||||
|
|
||||||
private List<MediaVariant> variantsForAssets(Collection<UUID> assetIds) {
|
private List<MediaVariant> variantsForAssets(Collection<UUID> assetIds) {
|
||||||
return variants.values().stream()
|
return variants.values().stream()
|
||||||
.filter(variant -> assetIds.contains(variant.getMediaAsset().getId()))
|
.filter(variant -> assetIds.contains(variant.getMediaAsset().getId()))
|
||||||
|
|||||||
@@ -25,6 +25,13 @@ class MediaFfmpegServiceTest {
|
|||||||
assertEquals("media.ffmpeg.path contains control characters.", ex.getMessage());
|
assertEquals("media.ffmpeg.path contains control characters.", ex.getMessage());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void resolveExecutable_shouldFallbackToPathWhenAbsoluteLocationIsMissing() {
|
||||||
|
String resolved = MediaFfmpegService.resolveExecutable("/opt/homebrew/bin/ffmpeg");
|
||||||
|
|
||||||
|
assertEquals("ffmpeg", resolved);
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void generateVariant_rejectsSourceNamesStartingWithDash() throws Exception {
|
void generateVariant_rejectsSourceNamesStartingWithDash() throws Exception {
|
||||||
MediaFfmpegService service = new MediaFfmpegService("missing-ffmpeg-binary");
|
MediaFfmpegService service = new MediaFfmpegService("missing-ffmpeg-binary");
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package com.printcalculator.service.media;
|
package com.printcalculator.service.media;
|
||||||
|
|
||||||
import com.printcalculator.dto.PublicMediaUsageDto;
|
import com.printcalculator.dto.PublicMediaUsageDto;
|
||||||
|
import com.printcalculator.dto.MediaTextTranslationDto;
|
||||||
import com.printcalculator.entity.MediaAsset;
|
import com.printcalculator.entity.MediaAsset;
|
||||||
import com.printcalculator.entity.MediaUsage;
|
import com.printcalculator.entity.MediaUsage;
|
||||||
import com.printcalculator.entity.MediaVariant;
|
import com.printcalculator.entity.MediaVariant;
|
||||||
@@ -47,12 +48,20 @@ class PublicMediaQueryServiceTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
void getUsageMedia_shouldReturnOnlyActiveReadyPublicUsagesOrderedBySortOrder() {
|
void getUsageMedia_shouldReturnOnlyActiveReadyPublicUsagesOrderedBySortOrder() {
|
||||||
MediaAsset readyPublicAsset = buildAsset("READY", "PUBLIC", "Shop hero", "Shop alt");
|
MediaAsset readyPublicAsset = buildAsset("READY", "PUBLIC", "Shop hero fallback", "Shop alt fallback");
|
||||||
MediaAsset draftAsset = buildAsset("PROCESSING", "PUBLIC", "Draft", "Draft alt");
|
MediaAsset draftAsset = buildAsset("PROCESSING", "PUBLIC", "Draft", "Draft alt");
|
||||||
MediaAsset privateAsset = buildAsset("READY", "PRIVATE", "Private", "Private alt");
|
MediaAsset privateAsset = buildAsset("READY", "PRIVATE", "Private", "Private alt");
|
||||||
|
|
||||||
MediaUsage usageSecond = buildUsage(readyPublicAsset, "HOME_SECTION", "shop-gallery", 2, false, true);
|
MediaUsage usageSecond = buildUsage(readyPublicAsset, "HOME_SECTION", "shop-gallery", 2, false, true);
|
||||||
MediaUsage usageFirst = buildUsage(readyPublicAsset, "HOME_SECTION", "shop-gallery", 1, true, true);
|
MediaUsage usageFirst = buildUsage(readyPublicAsset, "HOME_SECTION", "shop-gallery", 1, true, true);
|
||||||
|
applyTranslation(usageSecond, "it", "Shop hero IT", "Shop alt IT");
|
||||||
|
applyTranslation(usageSecond, "en", "Shop hero EN", "Shop alt EN");
|
||||||
|
applyTranslation(usageSecond, "de", "Shop hero DE", "Shop alt DE");
|
||||||
|
applyTranslation(usageSecond, "fr", "Shop hero FR", "Shop alt FR");
|
||||||
|
applyTranslation(usageFirst, "it", "Shop hero IT", "Shop alt IT");
|
||||||
|
applyTranslation(usageFirst, "en", "Shop hero EN", "Shop alt EN");
|
||||||
|
applyTranslation(usageFirst, "de", "Shop hero DE", "Shop alt DE");
|
||||||
|
applyTranslation(usageFirst, "fr", "Shop hero FR", "Shop alt FR");
|
||||||
MediaUsage usageDraft = buildUsage(draftAsset, "HOME_SECTION", "shop-gallery", 0, false, true);
|
MediaUsage usageDraft = buildUsage(draftAsset, "HOME_SECTION", "shop-gallery", 0, false, true);
|
||||||
MediaUsage usagePrivate = buildUsage(privateAsset, "HOME_SECTION", "shop-gallery", 3, false, true);
|
MediaUsage usagePrivate = buildUsage(privateAsset, "HOME_SECTION", "shop-gallery", 3, false, true);
|
||||||
|
|
||||||
@@ -67,20 +76,21 @@ class PublicMediaQueryServiceTest {
|
|||||||
buildVariant(readyPublicAsset, "hero", "JPEG", "asset/hero.jpg")
|
buildVariant(readyPublicAsset, "hero", "JPEG", "asset/hero.jpg")
|
||||||
));
|
));
|
||||||
|
|
||||||
List<PublicMediaUsageDto> result = service.getUsageMedia("home_section", "shop-gallery");
|
List<PublicMediaUsageDto> result = service.getUsageMedia("home_section", "shop-gallery", "en");
|
||||||
|
|
||||||
assertEquals(2, result.size());
|
assertEquals(2, result.size());
|
||||||
assertEquals(1, result.get(0).getSortOrder());
|
assertEquals(1, result.get(0).getSortOrder());
|
||||||
assertEquals(Boolean.TRUE, result.get(0).getIsPrimary());
|
assertEquals(Boolean.TRUE, result.get(0).getIsPrimary());
|
||||||
assertEquals("Shop hero", result.get(0).getTitle());
|
assertEquals("Shop hero EN", result.get(0).getTitle());
|
||||||
|
assertEquals("Shop alt EN", result.get(0).getAltText());
|
||||||
assertEquals("https://cdn.example/media/asset/thumb.jpg", result.get(0).getThumb().getJpegUrl());
|
assertEquals("https://cdn.example/media/asset/thumb.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/thumb.webp", result.get(0).getThumb().getWebpUrl());
|
||||||
assertEquals("https://cdn.example/media/asset/hero.avif", result.get(0).getHero().getAvifUrl());
|
assertEquals("https://cdn.example/media/asset/hero.avif", result.get(0).getHero().getAvifUrl());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void getUsageMedia_shouldReturnNullForMissingFormatsOrPresets() {
|
void getUsageMedia_shouldReturnNullForMissingFormatsOrPresetsAndFallbackToAssetMetadata() {
|
||||||
MediaAsset asset = buildAsset("READY", "PUBLIC", "Joe portrait", null);
|
MediaAsset asset = buildAsset("READY", "PUBLIC", "Joe portrait", "Joe portrait fallback");
|
||||||
MediaUsage usage = buildUsage(asset, "ABOUT_MEMBER", "joe", 0, true, true);
|
MediaUsage usage = buildUsage(asset, "ABOUT_MEMBER", "joe", 0, true, true);
|
||||||
|
|
||||||
when(mediaUsageRepository.findByUsageTypeAndUsageKeyAndIsActiveTrueOrderBySortOrderAscCreatedAtAsc(
|
when(mediaUsageRepository.findByUsageTypeAndUsageKeyAndIsActiveTrueOrderBySortOrderAscCreatedAtAsc(
|
||||||
@@ -89,9 +99,11 @@ class PublicMediaQueryServiceTest {
|
|||||||
when(mediaVariantRepository.findByMediaAsset_IdIn(List.of(asset.getId())))
|
when(mediaVariantRepository.findByMediaAsset_IdIn(List.of(asset.getId())))
|
||||||
.thenReturn(List.of(buildVariant(asset, "card", "JPEG", "joe/card.jpg")));
|
.thenReturn(List.of(buildVariant(asset, "card", "JPEG", "joe/card.jpg")));
|
||||||
|
|
||||||
List<PublicMediaUsageDto> result = service.getUsageMedia("ABOUT_MEMBER", "joe");
|
List<PublicMediaUsageDto> result = service.getUsageMedia("ABOUT_MEMBER", "joe", "fr");
|
||||||
|
|
||||||
assertEquals(1, result.size());
|
assertEquals(1, result.size());
|
||||||
|
assertEquals("Joe portrait", result.get(0).getTitle());
|
||||||
|
assertEquals("Joe portrait fallback", result.get(0).getAltText());
|
||||||
assertNull(result.get(0).getThumb().getJpegUrl());
|
assertNull(result.get(0).getThumb().getJpegUrl());
|
||||||
assertNull(result.get(0).getCard().getAvifUrl());
|
assertNull(result.get(0).getCard().getAvifUrl());
|
||||||
assertEquals("https://cdn.example/media/joe/card.jpg", result.get(0).getCard().getJpegUrl());
|
assertEquals("https://cdn.example/media/joe/card.jpg", result.get(0).getCard().getJpegUrl());
|
||||||
@@ -139,4 +151,12 @@ class PublicMediaQueryServiceTest {
|
|||||||
variant.setCreatedAt(OffsetDateTime.now());
|
variant.setCreatedAt(OffsetDateTime.now());
|
||||||
return variant;
|
return variant;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void applyTranslation(MediaUsage usage, String language, String title, String altText) {
|
||||||
|
MediaTextTranslationDto translation = new MediaTextTranslationDto();
|
||||||
|
translation.setTitle(title);
|
||||||
|
translation.setAltText(altText);
|
||||||
|
usage.setTitleForLanguage(language, translation.getTitle());
|
||||||
|
usage.setAltTextForLanguage(language, translation.getAltText());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
32
db.sql
32
db.sql
@@ -969,12 +969,44 @@ CREATE TABLE IF NOT EXISTS media_usage
|
|||||||
sort_order integer NOT NULL DEFAULT 0,
|
sort_order integer NOT NULL DEFAULT 0,
|
||||||
is_primary boolean NOT NULL DEFAULT false,
|
is_primary boolean NOT NULL DEFAULT false,
|
||||||
is_active boolean NOT NULL DEFAULT true,
|
is_active boolean NOT NULL DEFAULT true,
|
||||||
|
title_it text,
|
||||||
|
title_en text,
|
||||||
|
title_de text,
|
||||||
|
title_fr text,
|
||||||
|
alt_text_it text,
|
||||||
|
alt_text_en text,
|
||||||
|
alt_text_de text,
|
||||||
|
alt_text_fr text,
|
||||||
created_at timestamptz NOT NULL DEFAULT now()
|
created_at timestamptz NOT NULL DEFAULT now()
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS ix_media_usage_scope
|
CREATE INDEX IF NOT EXISTS ix_media_usage_scope
|
||||||
ON media_usage (usage_type, usage_key, is_active, sort_order);
|
ON media_usage (usage_type, usage_key, is_active, sort_order);
|
||||||
|
|
||||||
|
ALTER TABLE media_usage
|
||||||
|
ADD COLUMN IF NOT EXISTS title_it text;
|
||||||
|
|
||||||
|
ALTER TABLE media_usage
|
||||||
|
ADD COLUMN IF NOT EXISTS title_en text;
|
||||||
|
|
||||||
|
ALTER TABLE media_usage
|
||||||
|
ADD COLUMN IF NOT EXISTS title_de text;
|
||||||
|
|
||||||
|
ALTER TABLE media_usage
|
||||||
|
ADD COLUMN IF NOT EXISTS title_fr text;
|
||||||
|
|
||||||
|
ALTER TABLE media_usage
|
||||||
|
ADD COLUMN IF NOT EXISTS alt_text_it text;
|
||||||
|
|
||||||
|
ALTER TABLE media_usage
|
||||||
|
ADD COLUMN IF NOT EXISTS alt_text_en text;
|
||||||
|
|
||||||
|
ALTER TABLE media_usage
|
||||||
|
ADD COLUMN IF NOT EXISTS alt_text_de text;
|
||||||
|
|
||||||
|
ALTER TABLE media_usage
|
||||||
|
ADD COLUMN IF NOT EXISTS alt_text_fr text;
|
||||||
|
|
||||||
ALTER TABLE quote_sessions
|
ALTER TABLE quote_sessions
|
||||||
DROP CONSTRAINT IF EXISTS fk_quote_sessions_source_request;
|
DROP CONSTRAINT IF EXISTS fk_quote_sessions_source_request;
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,17 @@
|
|||||||
import { inject, Injectable } from '@angular/core';
|
import { inject, Injectable, Injector } from '@angular/core';
|
||||||
|
import { toObservable } from '@angular/core/rxjs-interop';
|
||||||
import { HttpClient, HttpParams } from '@angular/common/http';
|
import { HttpClient, HttpParams } from '@angular/common/http';
|
||||||
import { Observable, combineLatest, map, of, catchError } from 'rxjs';
|
import {
|
||||||
|
Observable,
|
||||||
|
combineLatest,
|
||||||
|
map,
|
||||||
|
of,
|
||||||
|
catchError,
|
||||||
|
distinctUntilChanged,
|
||||||
|
switchMap,
|
||||||
|
} from 'rxjs';
|
||||||
import { environment } from '../../../environments/environment';
|
import { environment } from '../../../environments/environment';
|
||||||
|
import { LanguageService } from './language.service';
|
||||||
|
|
||||||
export type PublicMediaUsageType = string;
|
export type PublicMediaUsageType = string;
|
||||||
export type PublicMediaPreset = 'thumb' | 'card' | 'hero';
|
export type PublicMediaPreset = 'thumb' | 'card' | 'hero';
|
||||||
@@ -78,26 +88,36 @@ export function buildPublicMediaUsageScopeKey(
|
|||||||
})
|
})
|
||||||
export class PublicMediaService {
|
export class PublicMediaService {
|
||||||
private readonly http = inject(HttpClient);
|
private readonly http = inject(HttpClient);
|
||||||
|
private readonly injector = inject(Injector);
|
||||||
|
private readonly languageService = inject(LanguageService);
|
||||||
private readonly baseUrl = `${environment.apiUrl}/api/public/media`;
|
private readonly baseUrl = `${environment.apiUrl}/api/public/media`;
|
||||||
|
private readonly selectedLang$ = toObservable(this.languageService.currentLang, {
|
||||||
|
injector: this.injector,
|
||||||
|
}).pipe(distinctUntilChanged());
|
||||||
|
|
||||||
getUsageMedia(
|
getUsageMedia(
|
||||||
usageType: PublicMediaUsageType,
|
usageType: PublicMediaUsageType,
|
||||||
usageKey: string,
|
usageKey: string,
|
||||||
): Observable<readonly PublicMediaImage[]> {
|
): Observable<readonly PublicMediaImage[]> {
|
||||||
const params = new HttpParams()
|
return this.selectedLang$.pipe(
|
||||||
.set('usageType', usageType)
|
switchMap((lang) => {
|
||||||
.set('usageKey', usageKey);
|
const params = new HttpParams()
|
||||||
|
.set('usageType', usageType)
|
||||||
|
.set('usageKey', usageKey)
|
||||||
|
.set('lang', lang);
|
||||||
|
|
||||||
return this.http
|
return this.http
|
||||||
.get<PublicMediaUsageDto[]>(`${this.baseUrl}/usages`, { params })
|
.get<PublicMediaUsageDto[]>(`${this.baseUrl}/usages`, { params })
|
||||||
.pipe(
|
.pipe(
|
||||||
map((items) =>
|
map((items) =>
|
||||||
items
|
items
|
||||||
.map((item) => this.mapUsageDto(item))
|
.map((item) => this.mapUsageDto(item))
|
||||||
.filter((item) => this.hasAnyFallback(item)),
|
.filter((item) => this.hasAnyFallback(item)),
|
||||||
),
|
),
|
||||||
catchError(() => of([])),
|
catchError(() => of([])),
|
||||||
);
|
);
|
||||||
|
}),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
getUsageCollections(
|
getUsageCollections(
|
||||||
|
|||||||
@@ -107,20 +107,61 @@
|
|||||||
<img [src]="previewUrl" alt="" />
|
<img [src]="previewUrl" alt="" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="language-toolbar form-field--wide">
|
||||||
|
<div class="language-copy">
|
||||||
|
<span>Testi localizzati</span>
|
||||||
|
<p>IT / EN / DE / FR obbligatorie</p>
|
||||||
|
</div>
|
||||||
|
<div class="language-toggle">
|
||||||
|
<button
|
||||||
|
*ngFor="let language of mediaLanguages"
|
||||||
|
type="button"
|
||||||
|
class="language-toggle-btn"
|
||||||
|
[class.active]="
|
||||||
|
getFormState(section.usageKey).activeLanguage ===
|
||||||
|
language
|
||||||
|
"
|
||||||
|
[class.complete]="
|
||||||
|
isLanguageComplete(section.usageKey, language)
|
||||||
|
"
|
||||||
|
[class.incomplete]="
|
||||||
|
!isLanguageComplete(section.usageKey, language)
|
||||||
|
"
|
||||||
|
(click)="setActiveLanguage(section.usageKey, language)"
|
||||||
|
>
|
||||||
|
{{ mediaLanguageLabels[language] }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<label class="form-field">
|
<label class="form-field">
|
||||||
<span>Titolo</span>
|
<span>
|
||||||
|
Titolo ({{
|
||||||
|
mediaLanguageLabels[
|
||||||
|
getFormState(section.usageKey).activeLanguage
|
||||||
|
]
|
||||||
|
}})
|
||||||
|
</span>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
[(ngModel)]="getFormState(section.usageKey).title"
|
[(ngModel)]="getActiveTranslation(section.usageKey).title"
|
||||||
placeholder="Titolo immagine"
|
placeholder="Titolo immagine"
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<label class="form-field">
|
<label class="form-field">
|
||||||
<span>Alt text</span>
|
<span>
|
||||||
|
Alt text ({{
|
||||||
|
mediaLanguageLabels[
|
||||||
|
getFormState(section.usageKey).activeLanguage
|
||||||
|
]
|
||||||
|
}})
|
||||||
|
</span>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
[(ngModel)]="getFormState(section.usageKey).altText"
|
[(ngModel)]="
|
||||||
|
getActiveTranslation(section.usageKey).altText
|
||||||
|
"
|
||||||
placeholder="Testo alternativo"
|
placeholder="Testo alternativo"
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
@@ -207,7 +248,14 @@
|
|||||||
<div class="media-copy">
|
<div class="media-copy">
|
||||||
<div class="media-copy-top">
|
<div class="media-copy-top">
|
||||||
<div>
|
<div>
|
||||||
<h6>{{ item.title || item.originalFilename }}</h6>
|
<h6>
|
||||||
|
{{
|
||||||
|
getItemTranslation(
|
||||||
|
item,
|
||||||
|
getFormState(section.usageKey).activeLanguage
|
||||||
|
).title || item.originalFilename
|
||||||
|
}}
|
||||||
|
</h6>
|
||||||
<p class="meta">
|
<p class="meta">
|
||||||
{{ item.originalFilename }} | asset
|
{{ item.originalFilename }} | asset
|
||||||
{{ item.mediaAssetId }}
|
{{ item.mediaAssetId }}
|
||||||
@@ -218,7 +266,20 @@
|
|||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p class="meta">Alt: {{ item.altText || "-" }}</p>
|
<p class="meta">
|
||||||
|
Alt
|
||||||
|
{{
|
||||||
|
mediaLanguageLabels[
|
||||||
|
getFormState(section.usageKey).activeLanguage
|
||||||
|
]
|
||||||
|
}}:
|
||||||
|
{{
|
||||||
|
getItemTranslation(
|
||||||
|
item,
|
||||||
|
getFormState(section.usageKey).activeLanguage
|
||||||
|
).altText || "-"
|
||||||
|
}}
|
||||||
|
</p>
|
||||||
<p class="meta">
|
<p class="meta">
|
||||||
Sort order: {{ item.sortOrder }} | Inserita:
|
Sort order: {{ item.sortOrder }} | Inserita:
|
||||||
{{ item.createdAt | date: "short" }}
|
{{ item.createdAt | date: "short" }}
|
||||||
|
|||||||
@@ -222,6 +222,67 @@
|
|||||||
gap: var(--space-1);
|
gap: var(--space-1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.language-toolbar {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-2);
|
||||||
|
padding: var(--space-2);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
background: #fbfaf6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.language-copy {
|
||||||
|
display: grid;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.language-copy span {
|
||||||
|
font-size: 0.76rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.language-copy p {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.76rem;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.language-toggle {
|
||||||
|
display: inline-flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.language-toggle-btn {
|
||||||
|
min-width: 2.8rem;
|
||||||
|
padding: 0.4rem 0.6rem;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #ffffff;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-size: 0.78rem;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.language-toggle-btn.complete {
|
||||||
|
border-color: #c9d8c4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.language-toggle-btn.incomplete {
|
||||||
|
border-color: #e8c8c2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.language-toggle-btn.active {
|
||||||
|
background: #fff5b8;
|
||||||
|
border-color: var(--color-brand);
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
.sr-only {
|
.sr-only {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
width: 1px;
|
width: 1px;
|
||||||
@@ -503,6 +564,15 @@ button.ghost.danger:hover:not(:disabled) {
|
|||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.language-toolbar {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.language-toggle {
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
.file-picker {
|
.file-picker {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: stretch;
|
align-items: stretch;
|
||||||
|
|||||||
@@ -4,8 +4,10 @@ import { FormsModule } from '@angular/forms';
|
|||||||
import { of, switchMap } from 'rxjs';
|
import { of, switchMap } from 'rxjs';
|
||||||
import {
|
import {
|
||||||
AdminCreateMediaUsagePayload,
|
AdminCreateMediaUsagePayload,
|
||||||
|
AdminMediaLanguage,
|
||||||
AdminMediaAsset,
|
AdminMediaAsset,
|
||||||
AdminMediaService,
|
AdminMediaService,
|
||||||
|
AdminMediaTranslation,
|
||||||
AdminMediaUsage,
|
AdminMediaUsage,
|
||||||
} from '../services/admin-media.service';
|
} from '../services/admin-media.service';
|
||||||
|
|
||||||
@@ -28,8 +30,8 @@ interface HomeMediaSectionConfig {
|
|||||||
interface HomeMediaFormState {
|
interface HomeMediaFormState {
|
||||||
file: File | null;
|
file: File | null;
|
||||||
previewUrl: string | null;
|
previewUrl: string | null;
|
||||||
title: string;
|
activeLanguage: AdminMediaLanguage;
|
||||||
altText: string;
|
translations: Record<AdminMediaLanguage, AdminMediaTranslation>;
|
||||||
sortOrder: number;
|
sortOrder: number;
|
||||||
isPrimary: boolean;
|
isPrimary: boolean;
|
||||||
replacingUsageId: string | null;
|
replacingUsageId: string | null;
|
||||||
@@ -40,8 +42,7 @@ interface HomeMediaItem {
|
|||||||
usageId: string;
|
usageId: string;
|
||||||
mediaAssetId: string;
|
mediaAssetId: string;
|
||||||
originalFilename: string;
|
originalFilename: string;
|
||||||
title: string | null;
|
translations: Record<AdminMediaLanguage, AdminMediaTranslation>;
|
||||||
altText: string | null;
|
|
||||||
sortOrder: number;
|
sortOrder: number;
|
||||||
draftSortOrder: number;
|
draftSortOrder: number;
|
||||||
isPrimary: boolean;
|
isPrimary: boolean;
|
||||||
@@ -58,6 +59,20 @@ interface HomeMediaSectionGroup {
|
|||||||
title: string;
|
title: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const SUPPORTED_MEDIA_LANGUAGES: readonly AdminMediaLanguage[] = [
|
||||||
|
'it',
|
||||||
|
'en',
|
||||||
|
'de',
|
||||||
|
'fr',
|
||||||
|
];
|
||||||
|
|
||||||
|
const MEDIA_LANGUAGE_LABELS: Readonly<Record<AdminMediaLanguage, string>> = {
|
||||||
|
it: 'IT',
|
||||||
|
en: 'EN',
|
||||||
|
de: 'DE',
|
||||||
|
fr: 'FR',
|
||||||
|
};
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-admin-home-media',
|
selector: 'app-admin-home-media',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
@@ -67,6 +82,8 @@ interface HomeMediaSectionGroup {
|
|||||||
})
|
})
|
||||||
export class AdminHomeMediaComponent implements OnInit, OnDestroy {
|
export class AdminHomeMediaComponent implements OnInit, OnDestroy {
|
||||||
private readonly adminMediaService = inject(AdminMediaService);
|
private readonly adminMediaService = inject(AdminMediaService);
|
||||||
|
readonly mediaLanguages = SUPPORTED_MEDIA_LANGUAGES;
|
||||||
|
readonly mediaLanguageLabels = MEDIA_LANGUAGE_LABELS;
|
||||||
|
|
||||||
readonly sectionGroups: readonly HomeMediaSectionGroup[] = [
|
readonly sectionGroups: readonly HomeMediaSectionGroup[] = [
|
||||||
{
|
{
|
||||||
@@ -204,8 +221,11 @@ export class AdminHomeMediaComponent implements OnInit, OnDestroy {
|
|||||||
formState.file = file;
|
formState.file = file;
|
||||||
formState.previewUrl = file ? URL.createObjectURL(file) : null;
|
formState.previewUrl = file ? URL.createObjectURL(file) : null;
|
||||||
|
|
||||||
if (file && !formState.title.trim()) {
|
if (file && this.areAllTitlesBlank(formState.translations)) {
|
||||||
formState.title = this.deriveDefaultTitle(file.name);
|
const nextTitle = this.deriveDefaultTitle(file.name);
|
||||||
|
for (const language of this.mediaLanguages) {
|
||||||
|
formState.translations[language].title = nextTitle;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -218,8 +238,7 @@ export class AdminHomeMediaComponent implements OnInit, OnDestroy {
|
|||||||
this.revokePreviewUrl(formState.previewUrl);
|
this.revokePreviewUrl(formState.previewUrl);
|
||||||
formState.file = null;
|
formState.file = null;
|
||||||
formState.previewUrl = item.previewUrl;
|
formState.previewUrl = item.previewUrl;
|
||||||
formState.title = item.title ?? '';
|
formState.translations = this.cloneTranslations(item.translations);
|
||||||
formState.altText = item.altText ?? '';
|
|
||||||
formState.sortOrder = item.sortOrder;
|
formState.sortOrder = item.sortOrder;
|
||||||
formState.isPrimary = item.isPrimary;
|
formState.isPrimary = item.isPrimary;
|
||||||
formState.replacingUsageId = item.usageId;
|
formState.replacingUsageId = item.usageId;
|
||||||
@@ -237,6 +256,16 @@ export class AdminHomeMediaComponent implements OnInit, OnDestroy {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const validationError = this.validateTranslations(formState.translations);
|
||||||
|
if (validationError) {
|
||||||
|
this.errorMessage = validationError;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedTranslations = this.normalizeTranslations(
|
||||||
|
formState.translations,
|
||||||
|
);
|
||||||
|
|
||||||
formState.saving = true;
|
formState.saving = true;
|
||||||
this.errorMessage = null;
|
this.errorMessage = null;
|
||||||
this.successMessage = null;
|
this.successMessage = null;
|
||||||
@@ -250,12 +279,13 @@ export class AdminHomeMediaComponent implements OnInit, OnDestroy {
|
|||||||
sortOrder: formState.sortOrder,
|
sortOrder: formState.sortOrder,
|
||||||
isPrimary: formState.isPrimary,
|
isPrimary: formState.isPrimary,
|
||||||
isActive: true,
|
isActive: true,
|
||||||
|
translations: normalizedTranslations,
|
||||||
});
|
});
|
||||||
|
|
||||||
this.adminMediaService
|
this.adminMediaService
|
||||||
.uploadAsset(formState.file, {
|
.uploadAsset(formState.file, {
|
||||||
title: formState.title,
|
title: normalizedTranslations.it.title,
|
||||||
altText: formState.altText,
|
altText: normalizedTranslations.it.altText,
|
||||||
visibility: 'PUBLIC',
|
visibility: 'PUBLIC',
|
||||||
})
|
})
|
||||||
.pipe(
|
.pipe(
|
||||||
@@ -381,6 +411,36 @@ export class AdminHomeMediaComponent implements OnInit, OnDestroy {
|
|||||||
return this.actingUsageIds.has(usageId);
|
return this.actingUsageIds.has(usageId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setActiveLanguage(
|
||||||
|
sectionKey: HomeSectionKey,
|
||||||
|
language: AdminMediaLanguage,
|
||||||
|
): void {
|
||||||
|
this.getFormState(sectionKey).activeLanguage = language;
|
||||||
|
}
|
||||||
|
|
||||||
|
getActiveTranslation(
|
||||||
|
sectionKey: HomeSectionKey,
|
||||||
|
): AdminMediaTranslation {
|
||||||
|
const formState = this.getFormState(sectionKey);
|
||||||
|
return formState.translations[formState.activeLanguage];
|
||||||
|
}
|
||||||
|
|
||||||
|
isLanguageComplete(
|
||||||
|
sectionKey: HomeSectionKey,
|
||||||
|
language: AdminMediaLanguage,
|
||||||
|
): boolean {
|
||||||
|
return this.isTranslationComplete(
|
||||||
|
this.getFormState(sectionKey).translations[language],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
getItemTranslation(
|
||||||
|
item: HomeMediaItem,
|
||||||
|
language: AdminMediaLanguage,
|
||||||
|
): AdminMediaTranslation {
|
||||||
|
return item.translations[language];
|
||||||
|
}
|
||||||
|
|
||||||
getSectionsForGroup(
|
getSectionsForGroup(
|
||||||
groupId: HomeMediaSectionGroup['id'],
|
groupId: HomeMediaSectionGroup['id'],
|
||||||
): HomeMediaSectionView[] {
|
): HomeMediaSectionView[] {
|
||||||
@@ -427,8 +487,7 @@ export class AdminHomeMediaComponent implements OnInit, OnDestroy {
|
|||||||
usageId: usage.id,
|
usageId: usage.id,
|
||||||
mediaAssetId: asset.id,
|
mediaAssetId: asset.id,
|
||||||
originalFilename: asset.originalFilename,
|
originalFilename: asset.originalFilename,
|
||||||
title: asset.title,
|
translations: this.normalizeTranslations(usage.translations),
|
||||||
altText: asset.altText,
|
|
||||||
sortOrder: usage.sortOrder ?? 0,
|
sortOrder: usage.sortOrder ?? 0,
|
||||||
draftSortOrder: usage.sortOrder ?? 0,
|
draftSortOrder: usage.sortOrder ?? 0,
|
||||||
isPrimary: usage.isPrimary,
|
isPrimary: usage.isPrimary,
|
||||||
@@ -473,8 +532,8 @@ export class AdminHomeMediaComponent implements OnInit, OnDestroy {
|
|||||||
this.formStateByKey[sectionKey] = {
|
this.formStateByKey[sectionKey] = {
|
||||||
file: null,
|
file: null,
|
||||||
previewUrl: null,
|
previewUrl: null,
|
||||||
title: '',
|
activeLanguage: 'it',
|
||||||
altText: '',
|
translations: this.createEmptyTranslations(),
|
||||||
sortOrder: Math.max(0, nextSortOrder),
|
sortOrder: Math.max(0, nextSortOrder),
|
||||||
isPrimary: (section?.items.length ?? 0) === 0,
|
isPrimary: (section?.items.length ?? 0) === 0,
|
||||||
replacingUsageId: null,
|
replacingUsageId: null,
|
||||||
@@ -498,8 +557,8 @@ export class AdminHomeMediaComponent implements OnInit, OnDestroy {
|
|||||||
return {
|
return {
|
||||||
file: null,
|
file: null,
|
||||||
previewUrl: null,
|
previewUrl: null,
|
||||||
title: '',
|
activeLanguage: 'it',
|
||||||
altText: '',
|
translations: this.createEmptyTranslations(),
|
||||||
sortOrder: 0,
|
sortOrder: 0,
|
||||||
isPrimary: false,
|
isPrimary: false,
|
||||||
replacingUsageId: null,
|
replacingUsageId: null,
|
||||||
@@ -507,6 +566,74 @@ export class AdminHomeMediaComponent implements OnInit, OnDestroy {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private createEmptyTranslations(): Record<
|
||||||
|
AdminMediaLanguage,
|
||||||
|
AdminMediaTranslation
|
||||||
|
> {
|
||||||
|
return {
|
||||||
|
it: { title: '', altText: '' },
|
||||||
|
en: { title: '', altText: '' },
|
||||||
|
de: { title: '', altText: '' },
|
||||||
|
fr: { title: '', altText: '' },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private cloneTranslations(
|
||||||
|
translations: Record<AdminMediaLanguage, AdminMediaTranslation>,
|
||||||
|
): Record<AdminMediaLanguage, AdminMediaTranslation> {
|
||||||
|
return this.normalizeTranslations(translations);
|
||||||
|
}
|
||||||
|
|
||||||
|
private normalizeTranslations(
|
||||||
|
translations: Partial<Record<AdminMediaLanguage, Partial<AdminMediaTranslation>>>,
|
||||||
|
): Record<AdminMediaLanguage, AdminMediaTranslation> {
|
||||||
|
return {
|
||||||
|
it: {
|
||||||
|
title: translations.it?.title?.trim() ?? '',
|
||||||
|
altText: translations.it?.altText?.trim() ?? '',
|
||||||
|
},
|
||||||
|
en: {
|
||||||
|
title: translations.en?.title?.trim() ?? '',
|
||||||
|
altText: translations.en?.altText?.trim() ?? '',
|
||||||
|
},
|
||||||
|
de: {
|
||||||
|
title: translations.de?.title?.trim() ?? '',
|
||||||
|
altText: translations.de?.altText?.trim() ?? '',
|
||||||
|
},
|
||||||
|
fr: {
|
||||||
|
title: translations.fr?.title?.trim() ?? '',
|
||||||
|
altText: translations.fr?.altText?.trim() ?? '',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private areAllTitlesBlank(
|
||||||
|
translations: Record<AdminMediaLanguage, AdminMediaTranslation>,
|
||||||
|
): boolean {
|
||||||
|
return this.mediaLanguages.every(
|
||||||
|
(language) => !translations[language].title.trim(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private isTranslationComplete(translation: AdminMediaTranslation): boolean {
|
||||||
|
return !!translation.title.trim() && !!translation.altText.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
private validateTranslations(
|
||||||
|
translations: Record<AdminMediaLanguage, AdminMediaTranslation>,
|
||||||
|
): string | null {
|
||||||
|
for (const language of this.mediaLanguages) {
|
||||||
|
const translation = translations[language];
|
||||||
|
if (!translation.title.trim()) {
|
||||||
|
return `Compila il titolo per ${this.mediaLanguageLabels[language]}.`;
|
||||||
|
}
|
||||||
|
if (!translation.altText.trim()) {
|
||||||
|
return `Compila l'alt text per ${this.mediaLanguageLabels[language]}.`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
private extractErrorMessage(error: unknown, fallback: string): string {
|
private extractErrorMessage(error: unknown, fallback: string): string {
|
||||||
const candidate = error as {
|
const candidate = error as {
|
||||||
error?: { message?: string };
|
error?: { message?: string };
|
||||||
|
|||||||
@@ -3,6 +3,13 @@ import { HttpClient } from '@angular/common/http';
|
|||||||
import { Observable } from 'rxjs';
|
import { Observable } from 'rxjs';
|
||||||
import { environment } from '../../../../environments/environment';
|
import { environment } from '../../../../environments/environment';
|
||||||
|
|
||||||
|
export type AdminMediaLanguage = 'it' | 'en' | 'de' | 'fr';
|
||||||
|
|
||||||
|
export interface AdminMediaTranslation {
|
||||||
|
title: string;
|
||||||
|
altText: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface AdminMediaVariant {
|
export interface AdminMediaVariant {
|
||||||
id: string;
|
id: string;
|
||||||
variantName: string;
|
variantName: string;
|
||||||
@@ -26,6 +33,7 @@ export interface AdminMediaUsage {
|
|||||||
sortOrder: number;
|
sortOrder: number;
|
||||||
isPrimary: boolean;
|
isPrimary: boolean;
|
||||||
isActive: boolean;
|
isActive: boolean;
|
||||||
|
translations: Record<AdminMediaLanguage, AdminMediaTranslation>;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -62,6 +70,7 @@ export interface AdminCreateMediaUsagePayload {
|
|||||||
sortOrder?: number;
|
sortOrder?: number;
|
||||||
isPrimary?: boolean;
|
isPrimary?: boolean;
|
||||||
isActive?: boolean;
|
isActive?: boolean;
|
||||||
|
translations: Record<AdminMediaLanguage, AdminMediaTranslation>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AdminUpdateMediaUsagePayload {
|
export interface AdminUpdateMediaUsagePayload {
|
||||||
@@ -72,6 +81,7 @@ export interface AdminUpdateMediaUsagePayload {
|
|||||||
sortOrder?: number;
|
sortOrder?: number;
|
||||||
isPrimary?: boolean;
|
isPrimary?: boolean;
|
||||||
isActive?: boolean;
|
isActive?: boolean;
|
||||||
|
translations?: Record<AdminMediaLanguage, AdminMediaTranslation>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Injectable({
|
@Injectable({
|
||||||
|
|||||||
Reference in New Issue
Block a user