diff --git a/backend/src/main/java/com/printcalculator/controller/PublicMediaController.java b/backend/src/main/java/com/printcalculator/controller/PublicMediaController.java index 57272da..ec3523b 100644 --- a/backend/src/main/java/com/printcalculator/controller/PublicMediaController.java +++ b/backend/src/main/java/com/printcalculator/controller/PublicMediaController.java @@ -24,7 +24,8 @@ public class PublicMediaController { @GetMapping("/usages") public ResponseEntity> getUsageMedia(@RequestParam String usageType, - @RequestParam String usageKey) { - return ResponseEntity.ok(publicMediaQueryService.getUsageMedia(usageType, usageKey)); + @RequestParam String usageKey, + @RequestParam(required = false) String lang) { + return ResponseEntity.ok(publicMediaQueryService.getUsageMedia(usageType, usageKey, lang)); } } diff --git a/backend/src/main/java/com/printcalculator/dto/AdminCreateMediaUsageRequest.java b/backend/src/main/java/com/printcalculator/dto/AdminCreateMediaUsageRequest.java index e5cf4b2..9580b29 100644 --- a/backend/src/main/java/com/printcalculator/dto/AdminCreateMediaUsageRequest.java +++ b/backend/src/main/java/com/printcalculator/dto/AdminCreateMediaUsageRequest.java @@ -1,6 +1,7 @@ package com.printcalculator.dto; import java.util.UUID; +import java.util.Map; public class AdminCreateMediaUsageRequest { private String usageType; @@ -10,6 +11,7 @@ public class AdminCreateMediaUsageRequest { private Integer sortOrder; private Boolean isPrimary; private Boolean isActive; + private Map translations; public String getUsageType() { return usageType; @@ -66,4 +68,12 @@ public class AdminCreateMediaUsageRequest { public void setIsActive(Boolean active) { isActive = active; } + + public Map getTranslations() { + return translations; + } + + public void setTranslations(Map translations) { + this.translations = translations; + } } diff --git a/backend/src/main/java/com/printcalculator/dto/AdminMediaUsageDto.java b/backend/src/main/java/com/printcalculator/dto/AdminMediaUsageDto.java index 23ff087..7d16330 100644 --- a/backend/src/main/java/com/printcalculator/dto/AdminMediaUsageDto.java +++ b/backend/src/main/java/com/printcalculator/dto/AdminMediaUsageDto.java @@ -1,6 +1,7 @@ package com.printcalculator.dto; import java.time.OffsetDateTime; +import java.util.Map; import java.util.UUID; public class AdminMediaUsageDto { @@ -12,6 +13,7 @@ public class AdminMediaUsageDto { private Integer sortOrder; private Boolean isPrimary; private Boolean isActive; + private Map translations; private OffsetDateTime createdAt; public UUID getId() { @@ -78,6 +80,14 @@ public class AdminMediaUsageDto { isActive = active; } + public Map getTranslations() { + return translations; + } + + public void setTranslations(Map translations) { + this.translations = translations; + } + public OffsetDateTime getCreatedAt() { return createdAt; } diff --git a/backend/src/main/java/com/printcalculator/dto/AdminUpdateMediaUsageRequest.java b/backend/src/main/java/com/printcalculator/dto/AdminUpdateMediaUsageRequest.java index 62af38b..aa4c8bc 100644 --- a/backend/src/main/java/com/printcalculator/dto/AdminUpdateMediaUsageRequest.java +++ b/backend/src/main/java/com/printcalculator/dto/AdminUpdateMediaUsageRequest.java @@ -1,6 +1,7 @@ package com.printcalculator.dto; import java.util.UUID; +import java.util.Map; public class AdminUpdateMediaUsageRequest { private String usageType; @@ -10,6 +11,7 @@ public class AdminUpdateMediaUsageRequest { private Integer sortOrder; private Boolean isPrimary; private Boolean isActive; + private Map translations; public String getUsageType() { return usageType; @@ -66,4 +68,12 @@ public class AdminUpdateMediaUsageRequest { public void setIsActive(Boolean active) { isActive = active; } + + public Map getTranslations() { + return translations; + } + + public void setTranslations(Map translations) { + this.translations = translations; + } } diff --git a/backend/src/main/java/com/printcalculator/dto/MediaTextTranslationDto.java b/backend/src/main/java/com/printcalculator/dto/MediaTextTranslationDto.java new file mode 100644 index 0000000..3771af3 --- /dev/null +++ b/backend/src/main/java/com/printcalculator/dto/MediaTextTranslationDto.java @@ -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; + } +} diff --git a/backend/src/main/java/com/printcalculator/entity/MediaUsage.java b/backend/src/main/java/com/printcalculator/entity/MediaUsage.java index 991ca0e..4231a7b 100644 --- a/backend/src/main/java/com/printcalculator/entity/MediaUsage.java +++ b/backend/src/main/java/com/printcalculator/entity/MediaUsage.java @@ -53,6 +53,30 @@ public class MediaUsage { @Column(name = "is_active", nullable = false) 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()") @Column(name = "created_at", nullable = false) private OffsetDateTime createdAt; @@ -121,6 +145,70 @@ public class MediaUsage { 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() { return createdAt; } @@ -128,4 +216,58 @@ public class MediaUsage { public void setCreatedAt(OffsetDateTime 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 -> { + } + } + } } diff --git a/backend/src/main/java/com/printcalculator/service/admin/AdminMediaControllerService.java b/backend/src/main/java/com/printcalculator/service/admin/AdminMediaControllerService.java index 7978d9f..0e30a60 100644 --- a/backend/src/main/java/com/printcalculator/service/admin/AdminMediaControllerService.java +++ b/backend/src/main/java/com/printcalculator/service/admin/AdminMediaControllerService.java @@ -4,6 +4,7 @@ import com.printcalculator.dto.AdminCreateMediaUsageRequest; import com.printcalculator.dto.AdminMediaAssetDto; import com.printcalculator.dto.AdminMediaUsageDto; import com.printcalculator.dto.AdminMediaVariantDto; +import com.printcalculator.dto.MediaTextTranslationDto; import com.printcalculator.dto.AdminUpdateMediaAssetRequest; import com.printcalculator.dto.AdminUpdateMediaUsageRequest; import com.printcalculator.entity.MediaAsset; @@ -78,6 +79,7 @@ public class AdminMediaControllerService { private static final Set ALLOWED_UPLOAD_MIME_TYPES = Set.of( "image/jpeg", "image/png", "image/webp" ); + private static final List SUPPORTED_MEDIA_LANGUAGES = List.of("it", "en", "de", "fr"); private static final Map GENERATED_FORMAT_MIME_TYPES = Map.of( FORMAT_JPEG, "image/jpeg", FORMAT_WEBP, "image/webp", @@ -261,6 +263,7 @@ public class AdminMediaControllerService { String usageType = requireUsageType(payload.getUsageType()); String usageKey = requireUsageKey(payload.getUsageKey()); boolean isPrimary = Boolean.TRUE.equals(payload.getIsPrimary()); + Map translations = requireTranslations(payload.getTranslations()); if (isPrimary) { unsetPrimaryForScope(usageType, usageKey, payload.getOwnerId(), null); @@ -275,6 +278,7 @@ public class AdminMediaControllerService { usage.setIsPrimary(isPrimary); usage.setIsActive(payload.getIsActive() == null || payload.getIsActive()); usage.setCreatedAt(OffsetDateTime.now()); + applyTranslations(usage, translations); MediaUsage saved = mediaUsageRepository.save(usage); return toUsageDto(saved); @@ -309,6 +313,9 @@ public class AdminMediaControllerService { if (payload.getIsPrimary() != null) { usage.setIsPrimary(payload.getIsPrimary()); } + if (payload.getTranslations() != null) { + applyTranslations(usage, requireTranslations(payload.getTranslations())); + } if (Boolean.TRUE.equals(usage.getIsPrimary())) { unsetPrimaryForScope(usage.getUsageType(), usage.getUsageKey(), usage.getOwnerId(), usage.getId()); @@ -525,6 +532,7 @@ public class AdminMediaControllerService { dto.setSortOrder(usage.getSortOrder()); dto.setIsPrimary(usage.getIsPrimary()); dto.setIsActive(usage.getIsActive()); + dto.setTranslations(extractTranslations(usage)); dto.setCreatedAt(usage.getCreatedAt()); return dto; } @@ -639,6 +647,96 @@ public class AdminMediaControllerService { return normalized; } + private Map requireTranslations(Map translations) { + if (translations == null || translations.isEmpty()) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "translations are required."); + } + + Map normalized = new LinkedHashMap<>(); + for (Map.Entry 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 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 translations) { + for (String language : SUPPORTED_MEDIA_LANGUAGES) { + MediaTextTranslationDto translation = translations.get(language); + usage.setTitleForLanguage(language, translation.getTitle()); + usage.setAltTextForLanguage(language, translation.getAltText()); + } + } + + private Map extractTranslations(MediaUsage usage) { + LinkedHashMap 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) { if (value == null) { return null; diff --git a/backend/src/main/java/com/printcalculator/service/media/MediaFfmpegService.java b/backend/src/main/java/com/printcalculator/service/media/MediaFfmpegService.java index d37b58b..4e50785 100644 --- a/backend/src/main/java/com/printcalculator/service/media/MediaFfmpegService.java +++ b/backend/src/main/java/com/printcalculator/service/media/MediaFfmpegService.java @@ -34,7 +34,7 @@ public class MediaFfmpegService { private final Set availableEncoders; public MediaFfmpegService(@Value("${media.ffmpeg.path:ffmpeg}") String ffmpegPath) { - this.ffmpegExecutable = sanitizeExecutable(ffmpegPath); + this.ffmpegExecutable = resolveExecutable(ffmpegPath); this.availableEncoders = Collections.unmodifiableSet(loadAvailableEncoders()); } @@ -147,7 +147,11 @@ public class MediaFfmpegService { } return parseAvailableEncoders(output); } 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(); } } @@ -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 { if (path == null) { throw new IllegalArgumentException("Media " + label + " path is required."); diff --git a/backend/src/main/java/com/printcalculator/service/media/PublicMediaQueryService.java b/backend/src/main/java/com/printcalculator/service/media/PublicMediaQueryService.java index 23db757..bbf0fe7 100644 --- a/backend/src/main/java/com/printcalculator/service/media/PublicMediaQueryService.java +++ b/backend/src/main/java/com/printcalculator/service/media/PublicMediaQueryService.java @@ -31,6 +31,7 @@ public class PublicMediaQueryService { private static final String FORMAT_JPEG = "JPEG"; private static final String FORMAT_WEBP = "WEBP"; private static final String FORMAT_AVIF = "AVIF"; + private static final List SUPPORTED_MEDIA_LANGUAGES = List.of("it", "en", "de", "fr"); private final MediaUsageRepository mediaUsageRepository; private final MediaVariantRepository mediaVariantRepository; @@ -44,9 +45,10 @@ public class PublicMediaQueryService { this.mediaStorageService = mediaStorageService; } - public List getUsageMedia(String usageType, String usageKey) { + public List getUsageMedia(String usageType, String usageKey, String language) { String normalizedUsageType = normalizeUsageType(usageType); String normalizedUsageKey = normalizeUsageKey(usageKey); + String normalizedLanguage = normalizeLanguage(language); List usages = mediaUsageRepository .findByUsageTypeAndUsageKeyAndIsActiveTrueOrderBySortOrderAscCreatedAtAsc( @@ -80,7 +82,8 @@ public class PublicMediaQueryService { return usages.stream() .map(usage -> toDto( usage, - variantsByAssetId.getOrDefault(usage.getMediaAsset().getId(), List.of()) + variantsByAssetId.getOrDefault(usage.getMediaAsset().getId(), List.of()), + normalizedLanguage )) .toList(); } @@ -92,7 +95,7 @@ public class PublicMediaQueryService { && VISIBILITY_PUBLIC.equals(asset.getVisibility()); } - private PublicMediaUsageDto toDto(MediaUsage usage, List variants) { + private PublicMediaUsageDto toDto(MediaUsage usage, List variants, String language) { Map> variantsByPresetAndFormat = variants.stream() .collect(Collectors.groupingBy( MediaVariant::getVariantName, @@ -101,8 +104,8 @@ public class PublicMediaQueryService { PublicMediaUsageDto dto = new PublicMediaUsageDto(); dto.setMediaAssetId(usage.getMediaAsset().getId()); - dto.setTitle(usage.getMediaAsset().getTitle()); - dto.setAltText(usage.getMediaAsset().getAltText()); + dto.setTitle(resolveLocalizedValue(usage.getTitleForLanguage(language), usage.getMediaAsset().getTitle())); + dto.setAltText(resolveLocalizedValue(usage.getAltTextForLanguage(language), usage.getMediaAsset().getAltText())); dto.setUsageType(usage.getUsageType()); dto.setUsageKey(usage.getUsageKey()); dto.setSortOrder(usage.getSortOrder()); @@ -145,4 +148,23 @@ public class PublicMediaQueryService { } 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; + } } diff --git a/backend/src/test/java/com/printcalculator/service/admin/AdminMediaControllerServiceTest.java b/backend/src/test/java/com/printcalculator/service/admin/AdminMediaControllerServiceTest.java index c5d3f9f..861996b 100644 --- a/backend/src/test/java/com/printcalculator/service/admin/AdminMediaControllerServiceTest.java +++ b/backend/src/test/java/com/printcalculator/service/admin/AdminMediaControllerServiceTest.java @@ -4,6 +4,7 @@ import com.printcalculator.dto.AdminCreateMediaUsageRequest; import com.printcalculator.dto.AdminMediaAssetDto; import com.printcalculator.dto.AdminMediaUsageDto; import com.printcalculator.dto.AdminUpdateMediaAssetRequest; +import com.printcalculator.dto.MediaTextTranslationDto; import com.printcalculator.entity.MediaAsset; import com.printcalculator.entity.MediaUsage; import com.printcalculator.entity.MediaVariant; @@ -325,6 +326,7 @@ class AdminMediaControllerServiceTest { payload.setMediaAssetId(asset.getId()); payload.setSortOrder(5); payload.setIsPrimary(true); + payload.setTranslations(buildTranslations("Landing hero", "Hero home alt")); AdminMediaUsageDto created = service.createUsage(payload); @@ -333,6 +335,8 @@ class AdminMediaControllerServiceTest { assertEquals(asset.getId(), created.getMediaAssetId()); assertEquals(5, created.getSortOrder()); 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()); 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())); } + @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 void updateAsset_withPrivateVisibility_shouldMoveGeneratedFilesAndHidePublicUrls() throws Exception { when(mediaImageInspector.inspect(any(Path.class))).thenReturn( @@ -414,6 +440,22 @@ class AdminMediaControllerServiceTest { return usage; } + private Map buildTranslations(String titleBase, String altBase) { + LinkedHashMap 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 variantsForAssets(Collection assetIds) { return variants.values().stream() .filter(variant -> assetIds.contains(variant.getMediaAsset().getId())) diff --git a/backend/src/test/java/com/printcalculator/service/media/MediaFfmpegServiceTest.java b/backend/src/test/java/com/printcalculator/service/media/MediaFfmpegServiceTest.java index 3b3c23b..d407a2e 100644 --- a/backend/src/test/java/com/printcalculator/service/media/MediaFfmpegServiceTest.java +++ b/backend/src/test/java/com/printcalculator/service/media/MediaFfmpegServiceTest.java @@ -25,6 +25,13 @@ class MediaFfmpegServiceTest { 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 void generateVariant_rejectsSourceNamesStartingWithDash() throws Exception { MediaFfmpegService service = new MediaFfmpegService("missing-ffmpeg-binary"); diff --git a/backend/src/test/java/com/printcalculator/service/media/PublicMediaQueryServiceTest.java b/backend/src/test/java/com/printcalculator/service/media/PublicMediaQueryServiceTest.java index 2359541..85288c7 100644 --- a/backend/src/test/java/com/printcalculator/service/media/PublicMediaQueryServiceTest.java +++ b/backend/src/test/java/com/printcalculator/service/media/PublicMediaQueryServiceTest.java @@ -1,6 +1,7 @@ package com.printcalculator.service.media; import com.printcalculator.dto.PublicMediaUsageDto; +import com.printcalculator.dto.MediaTextTranslationDto; import com.printcalculator.entity.MediaAsset; import com.printcalculator.entity.MediaUsage; import com.printcalculator.entity.MediaVariant; @@ -47,12 +48,20 @@ class PublicMediaQueryServiceTest { @Test 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 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); + 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 usagePrivate = buildUsage(privateAsset, "HOME_SECTION", "shop-gallery", 3, false, true); @@ -67,20 +76,21 @@ class PublicMediaQueryServiceTest { buildVariant(readyPublicAsset, "hero", "JPEG", "asset/hero.jpg") )); - List result = service.getUsageMedia("home_section", "shop-gallery"); + List result = service.getUsageMedia("home_section", "shop-gallery", "en"); 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("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.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); + void getUsageMedia_shouldReturnNullForMissingFormatsOrPresetsAndFallbackToAssetMetadata() { + MediaAsset asset = buildAsset("READY", "PUBLIC", "Joe portrait", "Joe portrait fallback"); MediaUsage usage = buildUsage(asset, "ABOUT_MEMBER", "joe", 0, true, true); when(mediaUsageRepository.findByUsageTypeAndUsageKeyAndIsActiveTrueOrderBySortOrderAscCreatedAtAsc( @@ -89,9 +99,11 @@ class PublicMediaQueryServiceTest { when(mediaVariantRepository.findByMediaAsset_IdIn(List.of(asset.getId()))) .thenReturn(List.of(buildVariant(asset, "card", "JPEG", "joe/card.jpg"))); - List result = service.getUsageMedia("ABOUT_MEMBER", "joe"); + List result = service.getUsageMedia("ABOUT_MEMBER", "joe", "fr"); 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).getCard().getAvifUrl()); assertEquals("https://cdn.example/media/joe/card.jpg", result.get(0).getCard().getJpegUrl()); @@ -139,4 +151,12 @@ class PublicMediaQueryServiceTest { variant.setCreatedAt(OffsetDateTime.now()); 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()); + } } diff --git a/db.sql b/db.sql index 64f2c47..d587f31 100644 --- a/db.sql +++ b/db.sql @@ -969,12 +969,44 @@ CREATE TABLE IF NOT EXISTS media_usage sort_order integer NOT NULL DEFAULT 0, is_primary boolean NOT NULL DEFAULT false, 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() ); CREATE INDEX IF NOT EXISTS ix_media_usage_scope 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 DROP CONSTRAINT IF EXISTS fk_quote_sessions_source_request; diff --git a/frontend/src/app/core/services/public-media.service.ts b/frontend/src/app/core/services/public-media.service.ts index e2cbc9d..c3928f8 100644 --- a/frontend/src/app/core/services/public-media.service.ts +++ b/frontend/src/app/core/services/public-media.service.ts @@ -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 { Observable, combineLatest, map, of, catchError } from 'rxjs'; +import { + Observable, + combineLatest, + map, + of, + catchError, + distinctUntilChanged, + switchMap, +} from 'rxjs'; import { environment } from '../../../environments/environment'; +import { LanguageService } from './language.service'; export type PublicMediaUsageType = string; export type PublicMediaPreset = 'thumb' | 'card' | 'hero'; @@ -78,26 +88,36 @@ export function buildPublicMediaUsageScopeKey( }) export class PublicMediaService { 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 selectedLang$ = toObservable(this.languageService.currentLang, { + injector: this.injector, + }).pipe(distinctUntilChanged()); getUsageMedia( usageType: PublicMediaUsageType, usageKey: string, ): Observable { - const params = new HttpParams() - .set('usageType', usageType) - .set('usageKey', usageKey); + return this.selectedLang$.pipe( + switchMap((lang) => { + const params = new HttpParams() + .set('usageType', usageType) + .set('usageKey', usageKey) + .set('lang', lang); - return this.http - .get(`${this.baseUrl}/usages`, { params }) - .pipe( - map((items) => - items - .map((item) => this.mapUsageDto(item)) - .filter((item) => this.hasAnyFallback(item)), - ), - catchError(() => of([])), - ); + return this.http + .get(`${this.baseUrl}/usages`, { params }) + .pipe( + map((items) => + items + .map((item) => this.mapUsageDto(item)) + .filter((item) => this.hasAnyFallback(item)), + ), + catchError(() => of([])), + ); + }), + ); } getUsageCollections( diff --git a/frontend/src/app/features/admin/pages/admin-home-media.component.html b/frontend/src/app/features/admin/pages/admin-home-media.component.html index 40e7196..0d83be7 100644 --- a/frontend/src/app/features/admin/pages/admin-home-media.component.html +++ b/frontend/src/app/features/admin/pages/admin-home-media.component.html @@ -107,20 +107,61 @@ +
+
+ Testi localizzati +

IT / EN / DE / FR obbligatorie

+
+
+ +
+
+ @@ -207,7 +248,14 @@
-
{{ item.title || item.originalFilename }}
+
+ {{ + getItemTranslation( + item, + getFormState(section.usageKey).activeLanguage + ).title || item.originalFilename + }} +

{{ item.originalFilename }} | asset {{ item.mediaAssetId }} @@ -218,7 +266,20 @@ >

-

Alt: {{ item.altText || "-" }}

+

+ Alt + {{ + mediaLanguageLabels[ + getFormState(section.usageKey).activeLanguage + ] + }}: + {{ + getItemTranslation( + item, + getFormState(section.usageKey).activeLanguage + ).altText || "-" + }} +

Sort order: {{ item.sortOrder }} | Inserita: {{ item.createdAt | date: "short" }} diff --git a/frontend/src/app/features/admin/pages/admin-home-media.component.scss b/frontend/src/app/features/admin/pages/admin-home-media.component.scss index 4b9d6ce..84f0e04 100644 --- a/frontend/src/app/features/admin/pages/admin-home-media.component.scss +++ b/frontend/src/app/features/admin/pages/admin-home-media.component.scss @@ -222,6 +222,67 @@ 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 { position: absolute; width: 1px; @@ -503,6 +564,15 @@ button.ghost.danger:hover:not(:disabled) { grid-template-columns: 1fr; } + .language-toolbar { + flex-direction: column; + align-items: stretch; + } + + .language-toggle { + justify-content: flex-start; + } + .file-picker { flex-direction: column; align-items: stretch; diff --git a/frontend/src/app/features/admin/pages/admin-home-media.component.ts b/frontend/src/app/features/admin/pages/admin-home-media.component.ts index 5f0901b..a293ebf 100644 --- a/frontend/src/app/features/admin/pages/admin-home-media.component.ts +++ b/frontend/src/app/features/admin/pages/admin-home-media.component.ts @@ -4,8 +4,10 @@ import { FormsModule } from '@angular/forms'; import { of, switchMap } from 'rxjs'; import { AdminCreateMediaUsagePayload, + AdminMediaLanguage, AdminMediaAsset, AdminMediaService, + AdminMediaTranslation, AdminMediaUsage, } from '../services/admin-media.service'; @@ -28,8 +30,8 @@ interface HomeMediaSectionConfig { interface HomeMediaFormState { file: File | null; previewUrl: string | null; - title: string; - altText: string; + activeLanguage: AdminMediaLanguage; + translations: Record; sortOrder: number; isPrimary: boolean; replacingUsageId: string | null; @@ -40,8 +42,7 @@ interface HomeMediaItem { usageId: string; mediaAssetId: string; originalFilename: string; - title: string | null; - altText: string | null; + translations: Record; sortOrder: number; draftSortOrder: number; isPrimary: boolean; @@ -58,6 +59,20 @@ interface HomeMediaSectionGroup { title: string; } +const SUPPORTED_MEDIA_LANGUAGES: readonly AdminMediaLanguage[] = [ + 'it', + 'en', + 'de', + 'fr', +]; + +const MEDIA_LANGUAGE_LABELS: Readonly> = { + it: 'IT', + en: 'EN', + de: 'DE', + fr: 'FR', +}; + @Component({ selector: 'app-admin-home-media', standalone: true, @@ -67,6 +82,8 @@ interface HomeMediaSectionGroup { }) export class AdminHomeMediaComponent implements OnInit, OnDestroy { private readonly adminMediaService = inject(AdminMediaService); + readonly mediaLanguages = SUPPORTED_MEDIA_LANGUAGES; + readonly mediaLanguageLabels = MEDIA_LANGUAGE_LABELS; readonly sectionGroups: readonly HomeMediaSectionGroup[] = [ { @@ -204,8 +221,11 @@ export class AdminHomeMediaComponent implements OnInit, OnDestroy { formState.file = file; formState.previewUrl = file ? URL.createObjectURL(file) : null; - if (file && !formState.title.trim()) { - formState.title = this.deriveDefaultTitle(file.name); + if (file && this.areAllTitlesBlank(formState.translations)) { + 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); formState.file = null; formState.previewUrl = item.previewUrl; - formState.title = item.title ?? ''; - formState.altText = item.altText ?? ''; + formState.translations = this.cloneTranslations(item.translations); formState.sortOrder = item.sortOrder; formState.isPrimary = item.isPrimary; formState.replacingUsageId = item.usageId; @@ -237,6 +256,16 @@ export class AdminHomeMediaComponent implements OnInit, OnDestroy { return; } + const validationError = this.validateTranslations(formState.translations); + if (validationError) { + this.errorMessage = validationError; + return; + } + + const normalizedTranslations = this.normalizeTranslations( + formState.translations, + ); + formState.saving = true; this.errorMessage = null; this.successMessage = null; @@ -250,12 +279,13 @@ export class AdminHomeMediaComponent implements OnInit, OnDestroy { sortOrder: formState.sortOrder, isPrimary: formState.isPrimary, isActive: true, + translations: normalizedTranslations, }); this.adminMediaService .uploadAsset(formState.file, { - title: formState.title, - altText: formState.altText, + title: normalizedTranslations.it.title, + altText: normalizedTranslations.it.altText, visibility: 'PUBLIC', }) .pipe( @@ -381,6 +411,36 @@ export class AdminHomeMediaComponent implements OnInit, OnDestroy { 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( groupId: HomeMediaSectionGroup['id'], ): HomeMediaSectionView[] { @@ -427,8 +487,7 @@ export class AdminHomeMediaComponent implements OnInit, OnDestroy { usageId: usage.id, mediaAssetId: asset.id, originalFilename: asset.originalFilename, - title: asset.title, - altText: asset.altText, + translations: this.normalizeTranslations(usage.translations), sortOrder: usage.sortOrder ?? 0, draftSortOrder: usage.sortOrder ?? 0, isPrimary: usage.isPrimary, @@ -473,8 +532,8 @@ export class AdminHomeMediaComponent implements OnInit, OnDestroy { this.formStateByKey[sectionKey] = { file: null, previewUrl: null, - title: '', - altText: '', + activeLanguage: 'it', + translations: this.createEmptyTranslations(), sortOrder: Math.max(0, nextSortOrder), isPrimary: (section?.items.length ?? 0) === 0, replacingUsageId: null, @@ -498,8 +557,8 @@ export class AdminHomeMediaComponent implements OnInit, OnDestroy { return { file: null, previewUrl: null, - title: '', - altText: '', + activeLanguage: 'it', + translations: this.createEmptyTranslations(), sortOrder: 0, isPrimary: false, 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, + ): Record { + return this.normalizeTranslations(translations); + } + + private normalizeTranslations( + translations: Partial>>, + ): Record { + 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, + ): 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, + ): 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 { const candidate = error as { error?: { message?: string }; diff --git a/frontend/src/app/features/admin/services/admin-media.service.ts b/frontend/src/app/features/admin/services/admin-media.service.ts index 6fc3409..5b99eb2 100644 --- a/frontend/src/app/features/admin/services/admin-media.service.ts +++ b/frontend/src/app/features/admin/services/admin-media.service.ts @@ -3,6 +3,13 @@ import { HttpClient } from '@angular/common/http'; import { Observable } from 'rxjs'; import { environment } from '../../../../environments/environment'; +export type AdminMediaLanguage = 'it' | 'en' | 'de' | 'fr'; + +export interface AdminMediaTranslation { + title: string; + altText: string; +} + export interface AdminMediaVariant { id: string; variantName: string; @@ -26,6 +33,7 @@ export interface AdminMediaUsage { sortOrder: number; isPrimary: boolean; isActive: boolean; + translations: Record; createdAt: string; } @@ -62,6 +70,7 @@ export interface AdminCreateMediaUsagePayload { sortOrder?: number; isPrimary?: boolean; isActive?: boolean; + translations: Record; } export interface AdminUpdateMediaUsagePayload { @@ -72,6 +81,7 @@ export interface AdminUpdateMediaUsagePayload { sortOrder?: number; isPrimary?: boolean; isActive?: boolean; + translations?: Record; } @Injectable({