feat(back-end): admin home edit image page

This commit is contained in:
2026-03-09 17:44:17 +01:00
parent 9e306ea1d1
commit 17df0c6b9b
25 changed files with 2634 additions and 103 deletions

View File

@@ -0,0 +1,530 @@
import { CommonModule } from '@angular/common';
import { Component, inject, OnDestroy, OnInit } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { of, switchMap } from 'rxjs';
import {
AdminCreateMediaUsagePayload,
AdminMediaAsset,
AdminMediaService,
AdminMediaUsage,
} from '../services/admin-media.service';
type HomeSectionKey =
| 'shop-gallery'
| 'founders-gallery'
| 'capability-prototyping'
| 'capability-custom-parts'
| 'capability-small-series'
| 'capability-cad';
interface HomeMediaSectionConfig {
usageType: 'HOME_SECTION';
usageKey: HomeSectionKey;
groupId: 'galleries' | 'capabilities';
title: string;
description: string;
preferredVariantName: 'card' | 'hero';
collectionHint: string;
}
interface HomeMediaFormState {
file: File | null;
previewUrl: string | null;
title: string;
altText: string;
sortOrder: number;
isPrimary: boolean;
replacingUsageId: string | null;
saving: boolean;
}
interface HomeMediaItem {
usageId: string;
mediaAssetId: string;
originalFilename: string;
title: string | null;
altText: string | null;
sortOrder: number;
draftSortOrder: number;
isPrimary: boolean;
previewUrl: string | null;
createdAt: string;
}
interface HomeMediaSectionView extends HomeMediaSectionConfig {
items: HomeMediaItem[];
}
interface HomeMediaSectionGroup {
id: HomeMediaSectionConfig['groupId'];
title: string;
description: string;
}
@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 adminMediaService = inject(AdminMediaService);
readonly sectionGroups: readonly HomeMediaSectionGroup[] = [
{
id: 'galleries',
title: 'Gallery e visual principali',
description:
'Sezioni che possono avere piu immagini attive e che impattano slider o gallery della home.',
},
{
id: 'capabilities',
title: 'Cosa puoi ottenere',
description:
'Le quattro card della sezione servizi della home. Qui di solito e consigliata una sola immagine per card.',
},
];
readonly sectionConfigs: readonly HomeMediaSectionConfig[] = [
{
usageType: 'HOME_SECTION',
usageKey: 'shop-gallery',
groupId: 'galleries',
title: 'Home: gallery shop',
description:
'Immagini della sezione shop nella home. Non modifica il catalogo shop reale.',
preferredVariantName: 'card',
collectionHint: 'Gallery orizzontale. Consigliate piu immagini in formato card.',
},
{
usageType: 'HOME_SECTION',
usageKey: 'founders-gallery',
groupId: 'galleries',
title: 'Home: gallery founders',
description:
'Immagini del carousel founders nella home. Prev/next usa lordine configurato qui.',
preferredVariantName: 'hero',
collectionHint: 'Hero slider. Consigliate immagini ampie con soggetto centrale.',
},
{
usageType: 'HOME_SECTION',
usageKey: 'capability-prototyping',
groupId: 'capabilities',
title: 'Home: prototipazione veloce',
description:
'Card "Prototipazione veloce" nella sezione Cosa puoi ottenere.',
preferredVariantName: 'card',
collectionHint: 'Card singola. Consigliata una sola immagine 16:10.',
},
{
usageType: 'HOME_SECTION',
usageKey: 'capability-custom-parts',
groupId: 'capabilities',
title: 'Home: pezzi personalizzati',
description:
'Card "Pezzi personalizzati" nella sezione Cosa puoi ottenere.',
preferredVariantName: 'card',
collectionHint: 'Card singola. Consigliata una sola immagine 16:10.',
},
{
usageType: 'HOME_SECTION',
usageKey: 'capability-small-series',
groupId: 'capabilities',
title: 'Home: piccole serie',
description: 'Card "Piccole serie" nella sezione Cosa puoi ottenere.',
preferredVariantName: 'card',
collectionHint: 'Card singola. Consigliata una sola immagine 16:10.',
},
{
usageType: 'HOME_SECTION',
usageKey: 'capability-cad',
groupId: 'capabilities',
title: 'Home: consulenza e CAD',
description:
'Card "Consulenza e CAD" nella sezione Cosa puoi ottenere.',
preferredVariantName: 'card',
collectionHint: 'Card singola. Consigliata una sola immagine 16:10.',
},
];
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(),
};
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 ? URL.createObjectURL(file) : null;
if (file && !formState.title.trim()) {
formState.title = this.deriveDefaultTitle(file.name);
}
}
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.title = item.title ?? '';
formState.altText = item.altText ?? '';
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;
}
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,
});
this.adminMediaService
.uploadAsset(formState.file, {
title: formState.title,
altText: formState.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);
}
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,
title: asset.title,
altText: asset.altText,
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,
title: '',
altText: '',
sortOrder: Math.max(0, nextSortOrder),
isPrimary: (section?.items.length ?? 0) === 0,
replacingUsageId: null,
saving: false,
};
}
private revokePreviewUrl(previewUrl: string | null): void {
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,
title: '',
altText: '',
sortOrder: 0,
isPrimary: false,
replacingUsageId: null,
saving: false,
};
}
private extractErrorMessage(error: unknown, fallback: string): string {
const candidate = error as {
error?: { message?: string };
message?: string;
};
return candidate?.error?.message || candidate?.message || fallback;
}
}