feat(back-end front-end): traslate alt and description images
This commit is contained in:
@@ -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" }}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user