Files
print-calculator/frontend/src/app/features/admin/pages/admin-home-media.component.ts
Joe Küng 2ace632022
Some checks failed
PR Checks / prettier-autofix (pull_request) Successful in 11s
PR Checks / test-frontend (pull_request) Failing after 57s
PR Checks / security-sast (pull_request) Successful in 30s
PR Checks / test-backend (pull_request) Successful in 27s
feat(front-end): ssr implementation
2026-03-11 16:37:08 +01:00

703 lines
19 KiB
TypeScript

import { CommonModule, isPlatformBrowser } from '@angular/common';
import {
Component,
PLATFORM_ID,
inject,
OnDestroy,
OnInit,
} from '@angular/core';
import { FormsModule } from '@angular/forms';
import { of, switchMap } from 'rxjs';
import {
AdminCreateMediaUsagePayload,
AdminMediaLanguage,
AdminMediaAsset,
AdminMediaService,
AdminMediaTranslation,
AdminMediaUsage,
} from '../services/admin-media.service';
type HomeSectionKey =
| 'shop-gallery'
| 'founders-gallery'
| 'capability-prototyping'
| 'capability-custom-parts'
| 'capability-small-series'
| 'capability-cad'
| 'joe'
| 'matteo';
type HomeMediaUsageType = 'HOME_SECTION' | 'ABOUT_MEMBER';
interface HomeMediaSectionConfig {
usageType: HomeMediaUsageType;
usageKey: HomeSectionKey;
groupId: 'galleries' | 'capabilities' | 'about-members';
title: string;
preferredVariantName: 'card' | 'hero';
}
interface HomeMediaFormState {
file: File | null;
previewUrl: string | null;
activeLanguage: AdminMediaLanguage;
translations: Record<AdminMediaLanguage, AdminMediaTranslation>;
sortOrder: number;
isPrimary: boolean;
replacingUsageId: string | null;
saving: boolean;
}
interface HomeMediaItem {
usageId: string;
mediaAssetId: string;
originalFilename: string;
translations: Record<AdminMediaLanguage, AdminMediaTranslation>;
sortOrder: number;
draftSortOrder: number;
isPrimary: boolean;
previewUrl: string | null;
createdAt: string;
}
interface HomeMediaSectionView extends HomeMediaSectionConfig {
items: HomeMediaItem[];
}
interface HomeMediaSectionGroup {
id: HomeMediaSectionConfig['groupId'];
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,
imports: [CommonModule, FormsModule],
templateUrl: './admin-home-media.component.html',
styleUrl: './admin-home-media.component.scss',
})
export class AdminHomeMediaComponent implements OnInit, OnDestroy {
private readonly isBrowser = isPlatformBrowser(inject(PLATFORM_ID));
private readonly adminMediaService = inject(AdminMediaService);
readonly mediaLanguages = SUPPORTED_MEDIA_LANGUAGES;
readonly mediaLanguageLabels = MEDIA_LANGUAGE_LABELS;
readonly sectionGroups: readonly HomeMediaSectionGroup[] = [
{
id: 'galleries',
title: 'Gallery e visual principali',
},
{
id: 'capabilities',
title: 'Cosa puoi ottenere',
},
{
id: 'about-members',
title: 'Chi siamo',
},
];
readonly sectionConfigs: readonly HomeMediaSectionConfig[] = [
{
usageType: 'HOME_SECTION',
usageKey: 'shop-gallery',
groupId: 'galleries',
title: 'Home: gallery shop',
preferredVariantName: 'card',
},
{
usageType: 'HOME_SECTION',
usageKey: 'founders-gallery',
groupId: 'galleries',
title: 'Home: gallery founders',
preferredVariantName: 'hero',
},
{
usageType: 'HOME_SECTION',
usageKey: 'capability-prototyping',
groupId: 'capabilities',
title: 'Home: prototipazione veloce',
preferredVariantName: 'card',
},
{
usageType: 'HOME_SECTION',
usageKey: 'capability-custom-parts',
groupId: 'capabilities',
title: 'Home: pezzi personalizzati',
preferredVariantName: 'card',
},
{
usageType: 'HOME_SECTION',
usageKey: 'capability-small-series',
groupId: 'capabilities',
title: 'Home: piccole serie',
preferredVariantName: 'card',
},
{
usageType: 'HOME_SECTION',
usageKey: 'capability-cad',
groupId: 'capabilities',
title: 'Home: consulenza e CAD',
preferredVariantName: 'card',
},
{
usageType: 'ABOUT_MEMBER',
usageKey: 'joe',
groupId: 'about-members',
title: 'Chi siamo: Joe',
preferredVariantName: 'card',
},
{
usageType: 'ABOUT_MEMBER',
usageKey: 'matteo',
groupId: 'about-members',
title: 'Chi siamo: Matteo',
preferredVariantName: 'card',
},
];
sections: HomeMediaSectionView[] = [];
loading = false;
errorMessage: string | null = null;
successMessage: string | null = null;
actingUsageIds = new Set<string>();
private readonly formStateByKey: Record<HomeSectionKey, HomeMediaFormState> =
{
'shop-gallery': this.createEmptyFormState(),
'founders-gallery': this.createEmptyFormState(),
'capability-prototyping': this.createEmptyFormState(),
'capability-custom-parts': this.createEmptyFormState(),
'capability-small-series': this.createEmptyFormState(),
'capability-cad': this.createEmptyFormState(),
joe: this.createEmptyFormState(),
matteo: this.createEmptyFormState(),
};
get configuredSectionCount(): number {
return this.sectionConfigs.length;
}
get activeImageCount(): number {
return this.sections.reduce(
(total, section) => total + section.items.length,
0,
);
}
ngOnInit(): void {
this.loadHomeMedia();
}
ngOnDestroy(): void {
(Object.keys(this.formStateByKey) as HomeSectionKey[]).forEach((key) => {
this.revokePreviewUrl(this.formStateByKey[key].previewUrl);
});
}
loadHomeMedia(): void {
this.loading = true;
this.errorMessage = null;
this.successMessage = null;
this.adminMediaService.listAssets().subscribe({
next: (assets) => {
this.sections = this.sectionConfigs.map((config) => ({
...config,
items: this.buildSectionItems(assets, config),
}));
this.loading = false;
(Object.keys(this.formStateByKey) as HomeSectionKey[]).forEach(
(key) => {
if (!this.formStateByKey[key].saving) {
this.resetForm(key);
}
},
);
},
error: (error) => {
this.loading = false;
this.errorMessage = this.extractErrorMessage(
error,
'Impossibile caricare i media della home.',
);
},
});
}
getFormState(sectionKey: HomeSectionKey): HomeMediaFormState {
return this.formStateByKey[sectionKey];
}
onFileSelected(sectionKey: HomeSectionKey, event: Event): void {
const input = event.target as HTMLInputElement | null;
const file = input?.files?.[0] ?? null;
const formState = this.getFormState(sectionKey);
this.revokePreviewUrl(formState.previewUrl);
formState.file = file;
formState.previewUrl =
file && this.isBrowser ? URL.createObjectURL(file) : null;
if (file && this.areAllTitlesBlank(formState.translations)) {
const nextTitle = this.deriveDefaultTitle(file.name);
for (const language of this.mediaLanguages) {
formState.translations[language].title = nextTitle;
}
}
}
prepareAdd(sectionKey: HomeSectionKey): void {
this.resetForm(sectionKey);
}
prepareReplace(sectionKey: HomeSectionKey, item: HomeMediaItem): void {
const formState = this.getFormState(sectionKey);
this.revokePreviewUrl(formState.previewUrl);
formState.file = null;
formState.previewUrl = item.previewUrl;
formState.translations = this.cloneTranslations(item.translations);
formState.sortOrder = item.sortOrder;
formState.isPrimary = item.isPrimary;
formState.replacingUsageId = item.usageId;
}
cancelReplace(sectionKey: HomeSectionKey): void {
this.resetForm(sectionKey);
}
uploadForSection(sectionKey: HomeSectionKey): void {
const section = this.sections.find((item) => item.usageKey === sectionKey);
const formState = this.getFormState(sectionKey);
if (!section || !formState.file || formState.saving) {
return;
}
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;
const createUsagePayload = (
mediaAssetId: string,
): AdminCreateMediaUsagePayload => ({
usageType: section.usageType,
usageKey: section.usageKey,
mediaAssetId,
sortOrder: formState.sortOrder,
isPrimary: formState.isPrimary,
isActive: true,
translations: normalizedTranslations,
});
this.adminMediaService
.uploadAsset(formState.file, {
title: normalizedTranslations.it.title,
altText: normalizedTranslations.it.altText,
visibility: 'PUBLIC',
})
.pipe(
switchMap((asset) =>
this.adminMediaService.createUsage(createUsagePayload(asset.id)),
),
switchMap(() => {
if (!formState.replacingUsageId) {
return of(null);
}
return this.adminMediaService.updateUsage(
formState.replacingUsageId,
{
isActive: false,
isPrimary: false,
},
);
}),
)
.subscribe({
next: () => {
formState.saving = false;
this.successMessage = formState.replacingUsageId
? 'Immagine home sostituita.'
: 'Immagine home caricata.';
this.loadHomeMedia();
},
error: (error) => {
formState.saving = false;
this.errorMessage = this.extractErrorMessage(
error,
'Upload immagine non riuscito.',
);
},
});
}
setPrimary(item: HomeMediaItem): void {
if (item.isPrimary || this.actingUsageIds.has(item.usageId)) {
return;
}
this.errorMessage = null;
this.successMessage = null;
this.actingUsageIds.add(item.usageId);
this.adminMediaService
.updateUsage(item.usageId, { isPrimary: true, isActive: true })
.subscribe({
next: () => {
this.actingUsageIds.delete(item.usageId);
this.successMessage = 'Immagine principale aggiornata.';
this.loadHomeMedia();
},
error: (error) => {
this.actingUsageIds.delete(item.usageId);
this.errorMessage = this.extractErrorMessage(
error,
'Aggiornamento immagine principale non riuscito.',
);
},
});
}
saveSortOrder(item: HomeMediaItem): void {
if (
this.actingUsageIds.has(item.usageId) ||
item.draftSortOrder === item.sortOrder
) {
return;
}
this.errorMessage = null;
this.successMessage = null;
this.actingUsageIds.add(item.usageId);
this.adminMediaService
.updateUsage(item.usageId, { sortOrder: item.draftSortOrder })
.subscribe({
next: () => {
this.actingUsageIds.delete(item.usageId);
this.successMessage = 'Ordine immagine aggiornato.';
this.loadHomeMedia();
},
error: (error) => {
this.actingUsageIds.delete(item.usageId);
this.errorMessage = this.extractErrorMessage(
error,
'Aggiornamento ordine non riuscito.',
);
},
});
}
removeFromHome(item: HomeMediaItem): void {
if (this.actingUsageIds.has(item.usageId)) {
return;
}
this.errorMessage = null;
this.successMessage = null;
this.actingUsageIds.add(item.usageId);
this.adminMediaService
.updateUsage(item.usageId, { isActive: false, isPrimary: false })
.subscribe({
next: () => {
this.actingUsageIds.delete(item.usageId);
this.successMessage = 'Immagine rimossa dalla home.';
this.loadHomeMedia();
},
error: (error) => {
this.actingUsageIds.delete(item.usageId);
this.errorMessage = this.extractErrorMessage(
error,
'Rimozione immagine dalla home non riuscita.',
);
},
});
}
isUsageBusy(usageId: string): boolean {
return this.actingUsageIds.has(usageId);
}
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],
);
}
isLanguageStarted(
sectionKey: HomeSectionKey,
language: AdminMediaLanguage,
): boolean {
return this.isTranslationStarted(
this.getFormState(sectionKey).translations[language],
);
}
isLanguageIncomplete(
sectionKey: HomeSectionKey,
language: AdminMediaLanguage,
): boolean {
return (
this.isLanguageStarted(sectionKey, language) &&
!this.isLanguageComplete(sectionKey, language)
);
}
getItemTranslation(
item: HomeMediaItem,
language: AdminMediaLanguage,
): AdminMediaTranslation {
return item.translations[language];
}
getSectionsForGroup(
groupId: HomeMediaSectionGroup['id'],
): HomeMediaSectionView[] {
return this.sections.filter((section) => section.groupId === groupId);
}
trackSection(_: number, section: HomeMediaSectionView): string {
return section.usageKey;
}
trackItem(_: number, item: HomeMediaItem): string {
return item.usageId;
}
private buildSectionItems(
assets: readonly AdminMediaAsset[],
config: HomeMediaSectionConfig,
): HomeMediaItem[] {
return assets
.flatMap((asset) =>
asset.usages
.filter(
(usage) =>
usage.isActive &&
usage.usageType === config.usageType &&
usage.usageKey === config.usageKey,
)
.map((usage) => this.toHomeMediaItem(asset, usage, config)),
)
.sort((left, right) => {
if (left.sortOrder !== right.sortOrder) {
return left.sortOrder - right.sortOrder;
}
return left.createdAt.localeCompare(right.createdAt);
});
}
private toHomeMediaItem(
asset: AdminMediaAsset,
usage: AdminMediaUsage,
config: HomeMediaSectionConfig,
): HomeMediaItem {
return {
usageId: usage.id,
mediaAssetId: asset.id,
originalFilename: asset.originalFilename,
translations: this.normalizeTranslations(usage.translations),
sortOrder: usage.sortOrder ?? 0,
draftSortOrder: usage.sortOrder ?? 0,
isPrimary: usage.isPrimary,
previewUrl: this.resolvePreviewUrl(asset, config.preferredVariantName),
createdAt: usage.createdAt,
};
}
private resolvePreviewUrl(
asset: AdminMediaAsset,
preferredVariantName: 'card' | 'hero',
): string | null {
const variantOrder =
preferredVariantName === 'hero'
? ['hero', 'card', 'thumb']
: ['card', 'thumb', 'hero'];
const formatOrder = ['JPEG', 'WEBP', 'AVIF'];
for (const variantName of variantOrder) {
for (const format of formatOrder) {
const match = asset.variants.find(
(variant) =>
variant.variantName === variantName &&
variant.format === format &&
!!variant.publicUrl,
);
if (match?.publicUrl) {
return match.publicUrl;
}
}
}
return null;
}
private resetForm(sectionKey: HomeSectionKey): void {
const formState = this.getFormState(sectionKey);
const section = this.sections.find((item) => item.usageKey === sectionKey);
const nextSortOrder = (section?.items.at(-1)?.sortOrder ?? -1) + 1;
this.revokePreviewUrl(formState.previewUrl);
this.formStateByKey[sectionKey] = {
file: null,
previewUrl: null,
activeLanguage: 'it',
translations: this.createEmptyTranslations(),
sortOrder: Math.max(0, nextSortOrder),
isPrimary: (section?.items.length ?? 0) === 0,
replacingUsageId: null,
saving: false,
};
}
private revokePreviewUrl(previewUrl: string | null): void {
if (!this.isBrowser) {
return;
}
if (!previewUrl?.startsWith('blob:')) {
return;
}
URL.revokeObjectURL(previewUrl);
}
private deriveDefaultTitle(filename: string): string {
const normalized = filename.replace(/\.[^.]+$/, '').replace(/[-_]+/g, ' ');
return normalized.trim();
}
private createEmptyFormState(): HomeMediaFormState {
return {
file: null,
previewUrl: null,
activeLanguage: 'it',
translations: this.createEmptyTranslations(),
sortOrder: 0,
isPrimary: false,
replacingUsageId: null,
saving: false,
};
}
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 isTranslationStarted(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 };
message?: string;
};
return candidate?.error?.message || candidate?.message || fallback;
}
}