import { Component, DestroyRef, computed, effect, inject, input, output, signal, untracked, viewChild, ElementRef, } from '@angular/core'; import { MatButtonModule } from '@angular/material/button'; import { MatIconModule } from '@angular/material/icon'; import { TranslateModule } from '@ngx-translate/core'; import { NgxSkeletonLoaderComponent } from 'ngx-skeleton-loader'; @Component({ selector: 'app-content-hero', standalone: true, imports: [ MatIconModule, MatButtonModule, NgxSkeletonLoaderComponent, TranslateModule, ], templateUrl: './content-hero.component.html', styleUrls: ['./content-hero.component.scss'], }) export class ContentHeroComponent { private readonly destroyRef = inject(DestroyRef); readonly title = input(); readonly description = input(); readonly posterUrl = input(); readonly backdropUrl = input(); readonly isLoading = input(false); readonly errorMessage = input(); readonly backLabel = input(); readonly backClicked = output(); readonly posterError = signal(false); private readonly failedBackdropUrl = signal(undefined); readonly backdropSourceUrl = computed( () => this.backdropUrl() || this.posterUrl() ); readonly backdropError = computed(() => { const source = this.backdropSourceUrl(); return !!source && this.failedBackdropUrl() === source; }); readonly backdropImageUrl = computed(() => this.backdropError() ? undefined : this.backdropSourceUrl() ); readonly usesPosterBackdrop = computed( () => !this.backdropUrl() && !!this.posterUrl() && !this.backdropError() ); readonly descriptionEl = viewChild>('descriptionEl'); readonly isDescriptionExpanded = signal(false); readonly hasDescriptionOverflow = signal(false); private resizeObserver?: ResizeObserver; constructor() { effect(() => { this.posterUrl(); untracked(() => this.posterError.set(false)); }); effect(() => { // Re-measure whenever description content or the element changes. this.description(); const el = this.descriptionEl()?.nativeElement; if (!el) return; // untracked: measureOverflow reads isDescriptionExpanded(); // tracking it would re-run this effect (and rebuild the // ResizeObserver) on every expand/collapse click. untracked(() => { this.measureOverflow(el); this.observeOverflow(el); }); }); this.destroyRef.onDestroy(() => this.resizeObserver?.disconnect()); } onPosterError(): void { this.posterError.set(true); } onBackdropError(): void { this.failedBackdropUrl.set(this.backdropSourceUrl()); } readonly formattedTitle = computed(() => { const t = this.title(); if (!t) return ''; // Replace underscores with spaces for cleaner UX on slug/filename style titles return t.replace(/_/g, ' ').replace(/\s+/g, ' ').trim(); }); private calculateHue(text: string): number { if (!text) return 0; let hash = 0; for (let i = 0; i < text.length; i++) { hash = text.charCodeAt(i) + ((hash << 5) - hash); hash = hash & hash; } return Math.abs(hash) % 360; } readonly fallbackPosterBackground = computed(() => { const hue = this.calculateHue(this.title() || 'placeholder'); const h2 = (hue + 40) % 360; return `linear-gradient(135deg, hsl(${hue}, 40%, 25%) 0%, hsl(${h2}, 50%, 15%) 100%)`; }); readonly fallbackBackdropBackground = computed(() => { const hue = this.calculateHue(this.title() || 'placeholder'); const h2 = (hue + 60) % 360; return `linear-gradient(135deg, hsl(${hue}, 50%, 15%) 0%, hsl(${h2}, 80%, 5%) 100%)`; }); onBack(): void { this.backClicked.emit(); } toggleDescription(): void { this.isDescriptionExpanded.update((v) => !v); } private measureOverflow(el: HTMLElement): void { // Measure only in the clamped state; if already expanded, clamped overflow // is implied when the element previously overflowed. if (this.isDescriptionExpanded()) return; this.hasDescriptionOverflow.set(el.scrollHeight > el.clientHeight + 1); } private observeOverflow(el: HTMLElement): void { this.resizeObserver?.disconnect(); if (typeof ResizeObserver === 'undefined') { this.measureOverflow(el); return; } this.resizeObserver = new ResizeObserver(() => this.measureOverflow(el) ); this.resizeObserver.observe(el); } }