feat(back-end front-end): traslate alt and description images
All checks were successful
PR Checks / prettier-autofix (pull_request) Successful in 10s
PR Checks / security-sast (pull_request) Successful in 29s
PR Checks / test-backend (pull_request) Successful in 25s
PR Checks / test-frontend (pull_request) Successful in 1m4s

This commit is contained in:
2026-03-09 18:49:03 +01:00
parent 85598dee3b
commit e8ebef926e
18 changed files with 788 additions and 52 deletions

View File

@@ -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<readonly PublicMediaImage[]> {
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<PublicMediaUsageDto[]>(`${this.baseUrl}/usages`, { params })
.pipe(
map((items) =>
items
.map((item) => this.mapUsageDto(item))
.filter((item) => this.hasAnyFallback(item)),
),
catchError(() => of([])),
);
return this.http
.get<PublicMediaUsageDto[]>(`${this.baseUrl}/usages`, { params })
.pipe(
map((items) =>
items
.map((item) => this.mapUsageDto(item))
.filter((item) => this.hasAnyFallback(item)),
),
catchError(() => of([])),
);
}),
);
}
getUsageCollections(

View File

@@ -107,20 +107,61 @@
<img [src]="previewUrl" alt="" />
</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">
<span>Titolo</span>
<span>
Titolo ({{
mediaLanguageLabels[
getFormState(section.usageKey).activeLanguage
]
}})
</span>
<input
type="text"
[(ngModel)]="getFormState(section.usageKey).title"
[(ngModel)]="getActiveTranslation(section.usageKey).title"
placeholder="Titolo immagine"
/>
</label>
<label class="form-field">
<span>Alt text</span>
<span>
Alt text ({{
mediaLanguageLabels[
getFormState(section.usageKey).activeLanguage
]
}})
</span>
<input
type="text"
[(ngModel)]="getFormState(section.usageKey).altText"
[(ngModel)]="
getActiveTranslation(section.usageKey).altText
"
placeholder="Testo alternativo"
/>
</label>
@@ -207,7 +248,14 @@
<div class="media-copy">
<div class="media-copy-top">
<div>
<h6>{{ item.title || item.originalFilename }}</h6>
<h6>
{{
getItemTranslation(
item,
getFormState(section.usageKey).activeLanguage
).title || item.originalFilename
}}
</h6>
<p class="meta">
{{ item.originalFilename }} | asset
{{ item.mediaAssetId }}
@@ -218,7 +266,20 @@
>
</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">
Sort order: {{ item.sortOrder }} | Inserita:
{{ item.createdAt | date: "short" }}

View File

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

View File

@@ -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<AdminMediaLanguage, AdminMediaTranslation>;
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<AdminMediaLanguage, AdminMediaTranslation>;
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<Record<AdminMediaLanguage, string>> = {
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<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 {
const candidate = error as {
error?: { message?: string };

View File

@@ -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<AdminMediaLanguage, AdminMediaTranslation>;
createdAt: string;
}
@@ -62,6 +70,7 @@ export interface AdminCreateMediaUsagePayload {
sortOrder?: number;
isPrimary?: boolean;
isActive?: boolean;
translations: Record<AdminMediaLanguage, AdminMediaTranslation>;
}
export interface AdminUpdateMediaUsagePayload {
@@ -72,6 +81,7 @@ export interface AdminUpdateMediaUsagePayload {
sortOrder?: number;
isPrimary?: boolean;
isActive?: boolean;
translations?: Record<AdminMediaLanguage, AdminMediaTranslation>;
}
@Injectable({