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,241 @@
import { inject, Injectable } from '@angular/core';
import { HttpClient, HttpParams } from '@angular/common/http';
import { Observable, combineLatest, map, of, catchError } from 'rxjs';
import { environment } from '../../../environments/environment';
export type PublicMediaUsageType = string;
export type PublicMediaPreset = 'thumb' | 'card' | 'hero';
export interface PublicMediaVariantDto {
avifUrl: string | null;
webpUrl: string | null;
jpegUrl: string | null;
}
export interface PublicMediaUsageDto {
mediaAssetId: string;
title: string | null;
altText: string | null;
usageType: string;
usageKey: string;
sortOrder: number;
isPrimary: boolean;
thumb: PublicMediaVariantDto | null;
card: PublicMediaVariantDto | null;
hero: PublicMediaVariantDto | null;
}
export interface PublicMediaSourceSet {
preset: PublicMediaPreset;
avifUrl: string | null;
webpUrl: string | null;
jpegUrl: string | null;
fallbackUrl: string | null;
}
export interface PublicMediaResolvedSourceSet
extends Omit<PublicMediaSourceSet, 'fallbackUrl'> {
fallbackUrl: string;
}
export interface PublicMediaImage {
mediaAssetId: string;
title: string | null;
altText: string | null;
usageType: string;
usageKey: string;
sortOrder: number;
isPrimary: boolean;
thumb: PublicMediaSourceSet;
card: PublicMediaSourceSet;
hero: PublicMediaSourceSet;
}
export interface PublicMediaDisplayImage
extends Omit<PublicMediaImage, 'thumb' | 'card' | 'hero'> {
source: PublicMediaResolvedSourceSet;
}
export interface PublicMediaUsageRequest {
usageType: PublicMediaUsageType;
usageKey: string;
}
export type PublicMediaUsageCollectionMap = Record<
string,
readonly PublicMediaImage[]
>;
export function buildPublicMediaUsageScopeKey(
usageType: string,
usageKey: string,
): string {
return `${usageType}::${usageKey}`;
}
@Injectable({
providedIn: 'root',
})
export class PublicMediaService {
private readonly http = inject(HttpClient);
private readonly baseUrl = `${environment.apiUrl}/api/public/media`;
getUsageMedia(
usageType: PublicMediaUsageType,
usageKey: string,
): Observable<readonly PublicMediaImage[]> {
const params = new HttpParams()
.set('usageType', usageType)
.set('usageKey', usageKey);
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(
requests: readonly PublicMediaUsageRequest[],
): Observable<PublicMediaUsageCollectionMap> {
if (requests.length === 0) {
return of({});
}
return combineLatest(
requests.map((request) =>
this.getUsageMedia(request.usageType, request.usageKey).pipe(
map(
(items) =>
[
buildPublicMediaUsageScopeKey(
request.usageType,
request.usageKey,
),
items,
] as const,
),
),
),
).pipe(
map((entries) =>
entries.reduce<PublicMediaUsageCollectionMap>((acc, [key, value]) => {
acc[key] = value;
return acc;
}, {}),
),
);
}
pickPrimaryUsage(
items: readonly PublicMediaImage[],
): PublicMediaImage | null {
if (items.length === 0) {
return null;
}
return items.find((item) => item.isPrimary) ?? items[0] ?? null;
}
toDisplayImage(
item: PublicMediaImage,
preferredPreset: PublicMediaPreset,
): PublicMediaDisplayImage | null {
const source = this.pickPresetSource(item, preferredPreset);
if (!source) {
return null;
}
return {
mediaAssetId: item.mediaAssetId,
title: item.title,
altText: item.altText,
usageType: item.usageType,
usageKey: item.usageKey,
sortOrder: item.sortOrder,
isPrimary: item.isPrimary,
source,
};
}
private mapUsageDto(item: PublicMediaUsageDto): PublicMediaImage {
return {
mediaAssetId: item.mediaAssetId,
title: item.title ?? null,
altText: item.altText ?? null,
usageType: item.usageType,
usageKey: item.usageKey,
sortOrder: item.sortOrder,
isPrimary: item.isPrimary,
thumb: this.mapPreset(item.thumb, 'thumb'),
card: this.mapPreset(item.card, 'card'),
hero: this.mapPreset(item.hero, 'hero'),
};
}
private mapPreset(
preset: PublicMediaVariantDto | null | undefined,
presetName: PublicMediaPreset,
): PublicMediaSourceSet {
const avifUrl = this.normalizeUrl(preset?.avifUrl);
const webpUrl = this.normalizeUrl(preset?.webpUrl);
const jpegUrl = this.normalizeUrl(preset?.jpegUrl);
return {
preset: presetName,
avifUrl,
webpUrl,
jpegUrl,
fallbackUrl: jpegUrl ?? webpUrl ?? avifUrl,
};
}
private pickPresetSource(
item: PublicMediaImage,
preferredPreset: PublicMediaPreset,
): PublicMediaResolvedSourceSet | null {
const presetOrder = this.buildPresetFallbackOrder(preferredPreset);
const source = presetOrder
.map((preset) => item[preset])
.find((sourceSet) => sourceSet.fallbackUrl !== null);
if (!source || source.fallbackUrl === null) {
return null;
}
return {
preset: source.preset,
avifUrl: source.avifUrl,
webpUrl: source.webpUrl,
jpegUrl: source.jpegUrl,
fallbackUrl: source.fallbackUrl,
};
}
private buildPresetFallbackOrder(
preferredPreset: PublicMediaPreset,
): readonly PublicMediaPreset[] {
switch (preferredPreset) {
case 'thumb':
return ['thumb', 'card', 'hero'];
case 'card':
return ['card', 'thumb', 'hero'];
case 'hero':
return ['hero', 'card', 'thumb'];
}
}
private hasAnyFallback(item: PublicMediaImage): boolean {
return [item.thumb, item.card, item.hero].some(
(preset) => preset.fallbackUrl !== null,
);
}
private normalizeUrl(value: string | null | undefined): string | null {
return value && value.trim() ? value : null;
}
}

View File

@@ -39,10 +39,20 @@
(keydown.space)="toggleSelectedMember('joe'); $event.preventDefault()"
>
<div class="placeholder-img">
<img
src="assets/images/joe.jpg"
[attr.alt]="'ABOUT.MEMBER_JOE_ALT' | translate"
/>
@if (joeImage(); as image) {
<picture>
@if (image.source.avifUrl) {
<source [srcset]="image.source.avifUrl" type="image/avif" />
}
@if (image.source.webpUrl) {
<source [srcset]="image.source.webpUrl" type="image/webp" />
}
<img
[src]="image.source.fallbackUrl"
[attr.alt]="image.altText || ('ABOUT.MEMBER_JOE_ALT' | translate)"
/>
</picture>
}
</div>
<div class="member-info">
<span class="member-name">{{
@@ -71,10 +81,22 @@
"
>
<div class="placeholder-img">
<img
src="assets/images/matteo.jpg"
[attr.alt]="'ABOUT.MEMBER_MATTEO_ALT' | translate"
/>
@if (matteoImage(); as image) {
<picture>
@if (image.source.avifUrl) {
<source [srcset]="image.source.avifUrl" type="image/avif" />
}
@if (image.source.webpUrl) {
<source [srcset]="image.source.webpUrl" type="image/webp" />
}
<img
[src]="image.source.fallbackUrl"
[attr.alt]="
image.altText || ('ABOUT.MEMBER_MATTEO_ALT' | translate)
"
/>
</picture>
}
</div>
<div class="member-info">
<span class="member-name">{{

View File

@@ -193,6 +193,12 @@ h1 {
object-fit: cover;
}
.placeholder-img picture {
width: 100%;
height: 100%;
display: block;
}
.member-info {
text-align: center;
}

View File

@@ -1,6 +1,15 @@
import { Component } from '@angular/core';
import { Component, computed, inject } from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
import { TranslateModule } from '@ngx-translate/core';
import { AppLocationsComponent } from '../../shared/components/app-locations/app-locations.component';
import {
buildPublicMediaUsageScopeKey,
PublicMediaDisplayImage,
PublicMediaService,
PublicMediaUsageCollectionMap,
} from '../../core/services/public-media.service';
const EMPTY_MEDIA_COLLECTIONS: PublicMediaUsageCollectionMap = {};
type MemberId = 'joe' | 'matteo';
type PassionId =
@@ -32,6 +41,39 @@ interface PassionChip {
styleUrl: './about-page.component.scss',
})
export class AboutPageComponent {
private readonly publicMediaService = inject(PublicMediaService);
private readonly mediaByUsage = toSignal(
this.publicMediaService.getUsageCollections([
{
usageType: 'ABOUT_MEMBER',
usageKey: 'joe',
},
{
usageType: 'ABOUT_MEMBER',
usageKey: 'matteo',
},
]),
{ initialValue: EMPTY_MEDIA_COLLECTIONS },
);
readonly joeImage = computed<PublicMediaDisplayImage | null>(() => {
const image = this.publicMediaService.pickPrimaryUsage(
this.mediaByUsage()[
buildPublicMediaUsageScopeKey('ABOUT_MEMBER', 'joe')
] ?? [],
);
return image ? this.publicMediaService.toDisplayImage(image, 'card') : null;
});
readonly matteoImage = computed<PublicMediaDisplayImage | null>(() => {
const image = this.publicMediaService.pickPrimaryUsage(
this.mediaByUsage()[
buildPublicMediaUsageScopeKey('ABOUT_MEMBER', 'matteo')
] ?? [],
);
return image ? this.publicMediaService.toDisplayImage(image, 'card') : null;
});
selectedMember: MemberId | null = null;
hoveredMember: MemberId | null = null;

View File

@@ -57,6 +57,13 @@ export const ADMIN_ROUTES: Routes = [
(m) => m.AdminCadInvoicesComponent,
),
},
{
path: 'home-media',
loadComponent: () =>
import('./pages/admin-home-media.component').then(
(m) => m.AdminHomeMediaComponent,
),
},
],
},
];

View File

@@ -0,0 +1,299 @@
<section class="section-card">
<header class="section-header">
<div class="header-copy">
<p class="eyebrow">Back-office media</p>
<h2>Media home</h2>
<p>
Gestisci gallery, founders e le card "Cosa puoi ottenere" senza
toccare codice o asset statici locali.
</p>
</div>
<div class="header-side">
<div class="header-stats">
<article class="stat-chip">
<strong>{{ configuredSectionCount }}</strong>
<span>sezioni gestite</span>
</article>
<article class="stat-chip">
<strong>{{ activeImageCount }}</strong>
<span>immagini attive</span>
</article>
</div>
<button type="button" (click)="loadHomeMedia()" [disabled]="loading">
Aggiorna
</button>
</div>
</header>
<p class="status-banner status-banner-error" *ngIf="errorMessage">
{{ errorMessage }}
</p>
<p class="status-banner status-banner-success" *ngIf="successMessage">
{{ successMessage }}
</p>
<div class="group-stack" *ngIf="!loading; else loadingTpl">
<section class="group-card" *ngFor="let group of sectionGroups">
<header class="group-header">
<div>
<h3>{{ group.title }}</h3>
<p>{{ group.description }}</p>
</div>
</header>
<div class="sections">
<section
class="media-panel"
*ngFor="
let section of getSectionsForGroup(group.id);
trackBy: trackSection
"
>
<header class="media-panel-header">
<div class="media-panel-copy">
<div class="title-row">
<h4>{{ section.title }}</h4>
<span class="count-pill">
{{ section.items.length }}
{{ section.items.length === 1 ? "attiva" : "attive" }}
</span>
</div>
<p>{{ section.description }}</p>
</div>
<div class="media-panel-meta">
<span class="usage-pill"
>{{ section.usageType }} / {{ section.usageKey }}</span
>
<span class="layout-pill">
Variante {{ section.preferredVariantName }}
</span>
</div>
</header>
<div class="workspace">
<div class="upload-panel">
<div class="panel-heading">
<div>
<h5>
{{
getFormState(section.usageKey).replacingUsageId
? "Sostituisci immagine"
: "Carica immagine"
}}
</h5>
<p>{{ section.collectionHint }}</p>
</div>
</div>
<div class="form-grid">
<label class="form-field form-field--wide">
<span>File immagine</span>
<input
type="file"
accept=".jpg,.jpeg,.png,.webp"
(change)="onFileSelected(section.usageKey, $event)"
/>
</label>
<div
class="preview-card form-field--wide"
*ngIf="
getFormState(section.usageKey).previewUrl as previewUrl
"
>
<img [src]="previewUrl" alt="" />
</div>
<label class="form-field">
<span>Titolo</span>
<input
type="text"
[(ngModel)]="getFormState(section.usageKey).title"
placeholder="Titolo immagine"
/>
</label>
<label class="form-field">
<span>Alt text</span>
<input
type="text"
[(ngModel)]="getFormState(section.usageKey).altText"
placeholder="Testo alternativo"
/>
</label>
<label class="form-field">
<span>Sort order</span>
<input
type="number"
[(ngModel)]="getFormState(section.usageKey).sortOrder"
min="0"
/>
</label>
<label class="toggle">
<input
type="checkbox"
[(ngModel)]="getFormState(section.usageKey).isPrimary"
/>
<span>Immagine primaria</span>
</label>
</div>
<div class="upload-actions">
<button
type="button"
(click)="uploadForSection(section.usageKey)"
[disabled]="
getFormState(section.usageKey).saving ||
!getFormState(section.usageKey).file
"
>
{{
getFormState(section.usageKey).saving
? "Salvataggio..."
: getFormState(section.usageKey).replacingUsageId
? "Sostituisci immagine"
: "Carica in home"
}}
</button>
<button
type="button"
class="ghost"
(click)="prepareAdd(section.usageKey)"
[disabled]="getFormState(section.usageKey).saving"
>
Nuova immagine
</button>
<button
type="button"
class="ghost"
*ngIf="getFormState(section.usageKey).replacingUsageId"
(click)="cancelReplace(section.usageKey)"
[disabled]="getFormState(section.usageKey).saving"
>
Annulla sostituzione
</button>
</div>
</div>
<div class="list-panel">
<div class="panel-heading">
<div>
<h5>Immagini attive</h5>
<p>Ordina, sostituisci o rimuovi i media attualmente collegati.</p>
</div>
</div>
<div
class="media-list"
*ngIf="section.items.length; else emptySectionState"
>
<article
class="media-item"
*ngFor="let item of section.items; trackBy: trackItem"
>
<div class="thumb-wrap">
<div class="thumb">
<img
*ngIf="item.previewUrl; else noPreviewTpl"
[src]="item.previewUrl"
alt=""
/>
</div>
</div>
<div class="media-copy">
<div class="media-copy-top">
<div>
<h6>{{ item.title || item.originalFilename }}</h6>
<p class="meta">
{{ item.originalFilename }} | asset
{{ item.mediaAssetId }}
</p>
</div>
<span class="primary-badge" *ngIf="item.isPrimary"
>Primaria</span
>
</div>
<p class="meta">Alt: {{ item.altText || "-" }}</p>
<p class="meta">
Sort order: {{ item.sortOrder }} | Inserita:
{{ item.createdAt | date: "short" }}
</p>
<div class="sort-editor">
<label>
<span>Nuovo ordine</span>
<input
type="number"
[(ngModel)]="item.draftSortOrder"
min="0"
/>
</label>
<button
type="button"
class="ghost"
(click)="saveSortOrder(item)"
[disabled]="
isUsageBusy(item.usageId) ||
item.draftSortOrder === item.sortOrder
"
>
Salva ordine
</button>
</div>
<div class="item-actions">
<button
type="button"
class="ghost"
(click)="prepareReplace(section.usageKey, item)"
[disabled]="isUsageBusy(item.usageId)"
>
Sostituisci
</button>
<button
type="button"
class="ghost"
(click)="setPrimary(item)"
[disabled]="isUsageBusy(item.usageId) || item.isPrimary"
>
Rendi primaria
</button>
<button
type="button"
class="ghost danger"
(click)="removeFromHome(item)"
[disabled]="isUsageBusy(item.usageId)"
>
Rimuovi dalla home
</button>
</div>
</div>
</article>
</div>
</div>
</div>
</section>
</div>
</section>
</div>
<ng-template #emptySectionState>
<p class="empty-state">
Nessuna immagine attiva collegata a questa sezione home.
</p>
</ng-template>
<ng-template #noPreviewTpl>
<div class="thumb thumb-empty">
<span>Preview non disponibile</span>
</div>
</ng-template>
</section>
<ng-template #loadingTpl>
<p class="loading-state">Caricamento media home...</p>
</ng-template>

View File

@@ -0,0 +1,428 @@
.section-card {
background: var(--color-bg-card);
border: 1px solid var(--color-border);
border-radius: calc(var(--radius-lg) + 6px);
padding: clamp(16px, 2vw, 28px);
box-shadow: var(--shadow-sm);
display: grid;
gap: var(--space-5);
background:
radial-gradient(circle at top right, rgba(239, 196, 61, 0.08), transparent 28%),
var(--color-bg-card);
}
.section-header,
.media-panel-header,
.media-copy-top,
.upload-actions,
.item-actions,
.sort-editor {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: var(--space-3);
}
.section-header {
align-items: center;
}
.eyebrow {
margin: 0 0 var(--space-2);
text-transform: uppercase;
letter-spacing: 0.14em;
font-size: 0.72rem;
font-weight: 700;
color: var(--color-secondary-600);
}
.header-copy h2,
.media-panel-header h4,
.panel-heading h5,
.media-copy h6,
.group-header h3 {
margin: 0;
}
.header-copy p,
.media-panel-header p,
.group-header p,
.panel-heading p,
.empty-state,
.meta {
margin: var(--space-2) 0 0;
color: var(--color-text-muted);
}
.header-side {
display: grid;
gap: var(--space-3);
justify-items: end;
}
.header-stats {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
gap: var(--space-2);
}
.stat-chip {
min-width: 128px;
padding: 0.75rem 0.9rem;
border-radius: var(--radius-md);
border: 1px solid var(--color-border);
background: linear-gradient(180deg, #fffdf5 0%, #ffffff 100%);
display: grid;
gap: 0.15rem;
}
.stat-chip strong {
font-size: 1.15rem;
line-height: 1;
}
.stat-chip span {
font-size: 0.8rem;
color: var(--color-text-muted);
}
.status-banner {
margin: 0;
padding: 0.85rem 1rem;
border-radius: var(--radius-md);
border: 1px solid transparent;
font-weight: 600;
}
.status-banner-error {
color: #8a241d;
background: #fff1f0;
border-color: #f2c3bf;
}
.status-banner-success {
color: #20613a;
background: #eef9f1;
border-color: #b7e3c4;
}
.group-stack {
display: grid;
gap: var(--space-5);
}
.group-card {
border: 1px solid var(--color-border);
border-radius: calc(var(--radius-lg) + 2px);
padding: clamp(14px, 2vw, 22px);
background: linear-gradient(180deg, #fcfbf8 0%, #ffffff 100%);
}
.group-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: var(--space-4);
margin-bottom: var(--space-4);
}
.sections {
display: grid;
gap: var(--space-4);
}
.media-panel {
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
background: #ffffff;
padding: var(--space-4);
display: grid;
gap: var(--space-4);
box-shadow: 0 10px 24px rgba(15, 23, 42, 0.04);
}
.usage-pill,
.primary-badge,
.count-pill,
.layout-pill {
border-radius: 999px;
border: 1px solid var(--color-border);
padding: 6px 10px;
font-size: 0.78rem;
font-weight: 700;
line-height: 1;
}
.usage-pill {
background: var(--color-neutral-100);
color: var(--color-text-muted);
}
.layout-pill {
background: #f7f4e7;
color: var(--color-neutral-900);
}
.count-pill {
background: #f8f9fb;
color: var(--color-neutral-900);
}
.primary-badge {
background: #fff5b8;
color: var(--color-text);
border-color: #f1d65c;
}
.media-panel-copy,
.media-panel-meta,
.panel-heading {
display: grid;
gap: var(--space-2);
}
.media-panel-meta {
justify-items: end;
}
.title-row {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--space-2);
}
.workspace {
display: grid;
grid-template-columns: minmax(320px, 390px) minmax(0, 1fr);
gap: var(--space-4);
align-items: start;
}
.upload-panel,
.list-panel {
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
background: linear-gradient(180deg, #fcfcfb 0%, #f7f7f4 100%);
padding: var(--space-4);
}
.form-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: var(--space-3);
margin-top: var(--space-3);
}
.form-field {
display: grid;
gap: var(--space-1);
}
.form-field--wide {
grid-column: 1 / -1;
}
.form-field > span,
.sort-editor span {
font-size: 0.8rem;
color: var(--color-text-muted);
font-weight: 600;
}
input {
width: 100%;
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
padding: var(--space-2) var(--space-3);
background: var(--color-bg-card);
font: inherit;
color: var(--color-text);
}
.toggle {
display: inline-flex;
align-items: center;
gap: 0.5rem;
border: 1px solid var(--color-border);
border-radius: 999px;
padding: 0.5rem 0.75rem;
background: var(--color-bg-card);
align-self: end;
}
.toggle input {
width: 16px;
height: 16px;
margin: 0;
}
.toggle span {
font-size: 0.88rem;
font-weight: 600;
}
.preview-card {
aspect-ratio: 16 / 10;
overflow: hidden;
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
background: var(--color-bg-card);
}
.preview-card img,
.thumb img {
width: 100%;
height: 100%;
display: block;
object-fit: cover;
}
.media-list {
display: grid;
gap: var(--space-3);
}
.media-item {
display: grid;
grid-template-columns: 168px minmax(0, 1fr);
gap: var(--space-3);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
padding: var(--space-3);
background: var(--color-bg-card);
}
.thumb-wrap {
min-width: 0;
}
.thumb {
aspect-ratio: 16 / 10;
border-radius: var(--radius-md);
overflow: hidden;
background: var(--color-neutral-200);
border: 1px solid var(--color-border);
}
.thumb-empty {
display: grid;
place-items: center;
text-align: center;
color: var(--color-text-muted);
padding: var(--space-3);
}
.media-copy {
min-width: 0;
display: grid;
gap: var(--space-2);
}
.media-copy-top {
align-items: center;
}
.media-copy h5 {
font-size: 1rem;
}
.media-copy h6 {
font-size: 1rem;
}
.meta {
overflow-wrap: anywhere;
}
.sort-editor {
align-items: end;
flex-wrap: wrap;
}
.sort-editor label {
display: grid;
gap: var(--space-1);
}
button {
border: 0;
border-radius: var(--radius-md);
background: var(--color-brand);
color: var(--color-neutral-900);
padding: var(--space-2) var(--space-4);
font-weight: 600;
cursor: pointer;
transition:
background-color 0.2s ease,
opacity 0.2s ease,
border-color 0.2s ease;
}
button:hover:not(:disabled) {
background: var(--color-brand-hover);
}
button:disabled {
opacity: 0.65;
cursor: default;
}
button.ghost {
background: var(--color-bg-card);
color: var(--color-text);
border: 1px solid var(--color-border);
}
button.ghost:hover:not(:disabled) {
background: #fff8cc;
border-color: var(--color-brand);
}
button.ghost.danger:hover:not(:disabled) {
background: #fff0f0;
border-color: #d9534f;
}
.loading-state {
margin: 0;
color: var(--color-text-muted);
}
@media (max-width: 1200px) {
.workspace {
grid-template-columns: 1fr;
}
}
@media (max-width: 720px) {
.form-grid,
.media-item {
grid-template-columns: 1fr;
}
.section-header,
.header-side,
.header-stats,
.group-header,
.media-panel-header,
.media-copy-top,
.upload-actions,
.item-actions,
.sort-editor {
flex-direction: column;
align-items: stretch;
}
.usage-pill,
.primary-badge,
.count-pill,
.layout-pill {
width: fit-content;
}
.media-panel-meta {
justify-items: start;
}
}

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

View File

@@ -17,6 +17,7 @@
>
<a routerLink="sessions" routerLinkActive="active">Sessioni</a>
<a routerLink="cad-invoices" routerLinkActive="active">Fatture CAD</a>
<a routerLink="home-media" routerLinkActive="active">Media home</a>
</nav>
</div>

View File

@@ -0,0 +1,135 @@
import { inject, Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable } from 'rxjs';
import { environment } from '../../../../environments/environment';
export interface AdminMediaVariant {
id: string;
variantName: string;
format: string;
storageKey: string;
mimeType: string;
widthPx: number;
heightPx: number;
fileSizeBytes: number;
isGenerated: boolean;
publicUrl: string | null;
createdAt: string;
}
export interface AdminMediaUsage {
id: string;
usageType: string;
usageKey: string;
ownerId: string | null;
mediaAssetId: string;
sortOrder: number;
isPrimary: boolean;
isActive: boolean;
createdAt: string;
}
export interface AdminMediaAsset {
id: string;
originalFilename: string;
storageKey: string;
mimeType: string;
fileSizeBytes: number;
sha256Hex: string;
widthPx: number | null;
heightPx: number | null;
status: string;
visibility: string;
title: string | null;
altText: string | null;
createdAt: string;
updatedAt: string;
variants: AdminMediaVariant[];
usages: AdminMediaUsage[];
}
export interface AdminMediaUploadPayload {
title?: string;
altText?: string;
visibility?: 'PUBLIC' | 'PRIVATE';
}
export interface AdminCreateMediaUsagePayload {
usageType: string;
usageKey: string;
ownerId?: string | null;
mediaAssetId: string;
sortOrder?: number;
isPrimary?: boolean;
isActive?: boolean;
}
export interface AdminUpdateMediaUsagePayload {
usageType?: string;
usageKey?: string;
ownerId?: string | null;
mediaAssetId?: string;
sortOrder?: number;
isPrimary?: boolean;
isActive?: boolean;
}
@Injectable({
providedIn: 'root',
})
export class AdminMediaService {
private readonly http = inject(HttpClient);
private readonly baseUrl = `${environment.apiUrl}/api/admin/media`;
listAssets(): Observable<AdminMediaAsset[]> {
return this.http.get<AdminMediaAsset[]>(`${this.baseUrl}/assets`, {
withCredentials: true,
});
}
uploadAsset(
file: File,
payload: AdminMediaUploadPayload,
): Observable<AdminMediaAsset> {
const formData = new FormData();
formData.append('file', file);
if (payload.title?.trim()) {
formData.append('title', payload.title.trim());
}
if (payload.altText?.trim()) {
formData.append('altText', payload.altText.trim());
}
if (payload.visibility?.trim()) {
formData.append('visibility', payload.visibility.trim());
}
return this.http.post<AdminMediaAsset>(`${this.baseUrl}/assets`, formData, {
withCredentials: true,
});
}
createUsage(
payload: AdminCreateMediaUsagePayload,
): Observable<AdminMediaUsage> {
return this.http.post<AdminMediaUsage>(`${this.baseUrl}/usages`, payload, {
withCredentials: true,
});
}
updateUsage(
usageId: string,
payload: AdminUpdateMediaUsagePayload,
): Observable<AdminMediaUsage> {
return this.http.patch<AdminMediaUsage>(
`${this.baseUrl}/usages/${usageId}`,
payload,
{ withCredentials: true },
);
}
deleteUsage(usageId: string): Observable<void> {
return this.http.delete<void>(`${this.baseUrl}/usages/${usageId}`, {
withCredentials: true,
});
}
}

View File

@@ -35,45 +35,33 @@
</p>
</div>
<div class="cap-cards">
<app-card>
<app-card
*ngFor="let card of capabilityCards(); trackBy: trackCapability"
>
<div class="card-image-placeholder">
<img
src="assets/images/home/prototipi.jpg"
[attr.alt]="'HOME.CAP_1_TITLE' | translate"
/>
@if (card.image; as image) {
<picture>
@if (image.source.avifUrl) {
<source [srcset]="image.source.avifUrl" type="image/avif" />
}
@if (image.source.webpUrl) {
<source [srcset]="image.source.webpUrl" type="image/webp" />
}
<img
[src]="image.source.fallbackUrl"
[attr.alt]="image.altText || (card.titleKey | translate)"
width="640"
height="400"
/>
</picture>
} @else {
<div class="card-image-fallback">
<span>{{ card.titleKey | translate }}</span>
</div>
}
</div>
<h3>{{ "HOME.CAP_1_TITLE" | translate }}</h3>
<p class="text-muted">{{ "HOME.CAP_1_TEXT" | translate }}</p>
</app-card>
<app-card>
<div class="card-image-placeholder">
<img
src="assets/images/home/original-vs-3dprinted.jpg"
[attr.alt]="'HOME.CAP_2_TITLE' | translate"
/>
</div>
<h3>{{ "HOME.CAP_2_TITLE" | translate }}</h3>
<p class="text-muted">{{ "HOME.CAP_2_TEXT" | translate }}</p>
</app-card>
<app-card>
<div class="card-image-placeholder">
<img
src="assets/images/home/serie.jpg"
[attr.alt]="'HOME.CAP_3_TITLE' | translate"
/>
</div>
<h3>{{ "HOME.CAP_3_TITLE" | translate }}</h3>
<p class="text-muted">{{ "HOME.CAP_3_TEXT" | translate }}</p>
</app-card>
<app-card>
<div class="card-image-placeholder">
<img
src="assets/images/home/cad.jpg"
[attr.alt]="'HOME.CAP_4_TITLE' | translate"
/>
</div>
<h3>{{ "HOME.CAP_4_TITLE" | translate }}</h3>
<p class="text-muted">{{ "HOME.CAP_4_TEXT" | translate }}</p>
<h3>{{ card.titleKey | translate }}</h3>
<p class="text-muted">{{ card.textKey | translate }}</p>
</app-card>
</div>
</div>
@@ -153,9 +141,24 @@
>
<figure
class="shop-gallery-item"
*ngFor="let image of shopGalleryImages"
*ngFor="let image of shopGalleryImages(); trackBy: trackMediaAsset"
>
<img [src]="image.src" [alt]="image.alt | translate" />
<picture>
<source
*ngIf="image.source.avifUrl"
[srcset]="image.source.avifUrl"
type="image/avif"
/>
<source
*ngIf="image.source.webpUrl"
[srcset]="image.source.webpUrl"
type="image/webp"
/>
<img
[src]="image.source.fallbackUrl"
[attr.alt]="image.altText || ('HOME.SEC_SHOP_TITLE' | translate)"
/>
</picture>
</figure>
</div>
<div class="shop-cards">
@@ -193,29 +196,41 @@
</div>
<div class="about-media">
<div class="about-feature-image">
<img
class="about-feature-photo"
[src]="founderImages[founderImageIndex].src"
[alt]="founderImages[founderImageIndex].alt | translate"
width="1200"
height="900"
/>
<button
type="button"
class="founder-nav founder-nav-prev"
(click)="prevFounderImage()"
[attr.aria-label]="'HOME.FOUNDER_PREV_ARIA' | translate"
>
</button>
<button
type="button"
class="founder-nav founder-nav-next"
(click)="nextFounderImage()"
[attr.aria-label]="'HOME.FOUNDER_NEXT_ARIA' | translate"
>
</button>
@if (currentFounderImage(); as image) {
<picture>
@if (image.source.avifUrl) {
<source [srcset]="image.source.avifUrl" type="image/avif" />
}
@if (image.source.webpUrl) {
<source [srcset]="image.source.webpUrl" type="image/webp" />
}
<img
class="about-feature-photo"
[src]="image.source.fallbackUrl"
[attr.alt]="image.altText || ('HOME.SEC_ABOUT_TITLE' | translate)"
width="1200"
height="900"
/>
</picture>
}
@if (founderImages().length > 1) {
<button
type="button"
class="founder-nav founder-nav-prev"
(click)="prevFounderImage()"
[attr.aria-label]="'HOME.FOUNDER_PREV_ARIA' | translate"
>
</button>
<button
type="button"
class="founder-nav founder-nav-next"
(click)="nextFounderImage()"
[attr.aria-label]="'HOME.FOUNDER_NEXT_ARIA' | translate"
>
</button>
}
</div>
</div>
</div>

View File

@@ -278,6 +278,36 @@
object-fit: cover;
}
.card-image-placeholder picture {
width: 100%;
height: 100%;
display: block;
}
.card-image-fallback {
width: 100%;
height: 100%;
display: grid;
place-items: end start;
padding: var(--space-4);
background:
linear-gradient(135deg, rgba(239, 196, 61, 0.22), rgba(255, 255, 255, 0)),
linear-gradient(180deg, #f8f5eb 0%, #efede6 100%);
}
.card-image-fallback span {
display: inline-flex;
align-items: center;
min-height: 2rem;
padding: 0.35rem 0.7rem;
border-radius: 999px;
background: rgba(255, 255, 255, 0.88);
border: 1px solid rgba(17, 24, 39, 0.08);
color: var(--color-neutral-900);
font-size: 0.78rem;
font-weight: 700;
}
.shop {
background: var(--home-bg);
position: relative;
@@ -336,6 +366,13 @@
object-fit: cover;
}
.shop-gallery-item picture,
.about-feature-image picture {
width: 100%;
height: 100%;
display: block;
}
.shop-cards {
display: grid;
gap: var(--space-4);

View File

@@ -1,9 +1,58 @@
import { Component } from '@angular/core';
import { Component, computed, effect, inject, signal } from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
import { CommonModule } from '@angular/common';
import { RouterLink } from '@angular/router';
import { TranslateModule } from '@ngx-translate/core';
import { AppButtonComponent } from '../../shared/components/app-button/app-button.component';
import { AppCardComponent } from '../../shared/components/app-card/app-card.component';
import {
buildPublicMediaUsageScopeKey,
PublicMediaDisplayImage,
PublicMediaImage,
PublicMediaService,
PublicMediaUsageCollectionMap,
} from '../../core/services/public-media.service';
const EMPTY_MEDIA_COLLECTIONS: PublicMediaUsageCollectionMap = {};
type HomeCapabilityUsageKey =
| 'capability-prototyping'
| 'capability-custom-parts'
| 'capability-small-series'
| 'capability-cad';
interface HomeCapabilityConfig {
usageKey: HomeCapabilityUsageKey;
titleKey: string;
textKey: string;
}
interface HomeCapabilityCard extends HomeCapabilityConfig {
image: PublicMediaDisplayImage | null;
}
const HOME_CAPABILITY_CONFIGS: readonly HomeCapabilityConfig[] = [
{
usageKey: 'capability-prototyping',
titleKey: 'HOME.CAP_1_TITLE',
textKey: 'HOME.CAP_1_TEXT',
},
{
usageKey: 'capability-custom-parts',
titleKey: 'HOME.CAP_2_TITLE',
textKey: 'HOME.CAP_2_TEXT',
},
{
usageKey: 'capability-small-series',
titleKey: 'HOME.CAP_3_TITLE',
textKey: 'HOME.CAP_3_TEXT',
},
{
usageKey: 'capability-cad',
titleKey: 'HOME.CAP_4_TITLE',
textKey: 'HOME.CAP_4_TEXT',
},
];
@Component({
selector: 'app-home-page',
@@ -19,37 +68,147 @@ import { AppCardComponent } from '../../shared/components/app-card/app-card.comp
styleUrls: ['./home.component.scss'],
})
export class HomeComponent {
readonly shopGalleryImages = [
{
src: 'assets/images/home/supporto-bici.jpg',
alt: 'HOME.SHOP_IMAGE_ALT_1',
},
];
private readonly publicMediaService = inject(PublicMediaService);
readonly founderImages = [
{
src: 'assets/images/home/da-cambiare.jpg',
alt: 'HOME.FOUNDER_IMAGE_ALT_1',
},
{
src: 'assets/images/home/vino.JPG',
alt: 'HOME.FOUNDER_IMAGE_ALT_2',
},
];
private readonly mediaByUsage = toSignal(
this.publicMediaService.getUsageCollections([
{
usageType: 'HOME_SECTION',
usageKey: 'shop-gallery',
},
{
usageType: 'HOME_SECTION',
usageKey: 'founders-gallery',
},
{
usageType: 'HOME_SECTION',
usageKey: 'capability-prototyping',
},
{
usageType: 'HOME_SECTION',
usageKey: 'capability-custom-parts',
},
{
usageType: 'HOME_SECTION',
usageKey: 'capability-small-series',
},
{
usageType: 'HOME_SECTION',
usageKey: 'capability-cad',
},
]),
{ initialValue: EMPTY_MEDIA_COLLECTIONS },
);
founderImageIndex = 0;
readonly shopGalleryImages = computed<readonly PublicMediaDisplayImage[]>(
() =>
(
this.mediaByUsage()[
buildPublicMediaUsageScopeKey('HOME_SECTION', 'shop-gallery')
] ?? []
)
.map((item: PublicMediaImage) =>
this.publicMediaService.toDisplayImage(item, 'card'),
)
.filter(
(
item: PublicMediaDisplayImage | null,
): item is PublicMediaDisplayImage => item !== null,
),
);
readonly founderImages = computed<readonly PublicMediaDisplayImage[]>(
() =>
(
this.mediaByUsage()[
buildPublicMediaUsageScopeKey('HOME_SECTION', 'founders-gallery')
] ?? []
)
.map((item: PublicMediaImage) =>
this.publicMediaService.toDisplayImage(item, 'hero'),
)
.filter(
(
item: PublicMediaDisplayImage | null,
): item is PublicMediaDisplayImage => item !== null,
),
);
readonly capabilityCards = computed<readonly HomeCapabilityCard[]>(() =>
HOME_CAPABILITY_CONFIGS.map((config) => this.buildCapabilityCard(config)),
);
readonly founderImageIndex = signal(0);
readonly currentFounderImage = computed<PublicMediaDisplayImage | null>(() => {
const images = this.founderImages();
if (images.length === 0) {
return null;
}
return images[this.founderImageIndex()] ?? images[0] ?? null;
});
constructor() {
effect(() => {
const images = this.founderImages();
const currentIndex = this.founderImageIndex();
if (images.length === 0) {
if (currentIndex !== 0) {
this.founderImageIndex.set(0);
}
return;
}
if (currentIndex >= images.length) {
this.founderImageIndex.set(0);
}
});
}
prevFounderImage(): void {
this.founderImageIndex =
this.founderImageIndex === 0
? this.founderImages.length - 1
: this.founderImageIndex - 1;
const totalImages = this.founderImages().length;
if (totalImages <= 1) {
return;
}
this.founderImageIndex.set(
this.founderImageIndex() === 0
? totalImages - 1
: this.founderImageIndex() - 1,
);
}
nextFounderImage(): void {
this.founderImageIndex =
this.founderImageIndex === this.founderImages.length - 1
const totalImages = this.founderImages().length;
if (totalImages <= 1) {
return;
}
this.founderImageIndex.set(
this.founderImageIndex() === totalImages - 1
? 0
: this.founderImageIndex + 1;
: this.founderImageIndex() + 1,
);
}
trackMediaAsset(_: number, image: PublicMediaDisplayImage): string {
return image.mediaAssetId;
}
trackCapability(_: number, card: HomeCapabilityCard): string {
return card.usageKey;
}
private buildCapabilityCard(
config: HomeCapabilityConfig,
): HomeCapabilityCard {
const items =
this.mediaByUsage()[
buildPublicMediaUsageScopeKey('HOME_SECTION', config.usageKey)
] ?? [];
const primaryImage = this.publicMediaService.pickPrimaryUsage(items);
return {
...config,
image: primaryImage
? this.publicMediaService.toDisplayImage(primaryImage, 'card')
: null,
};
}
}