diff --git a/libs/ui/components/src/index.ts b/libs/ui/components/src/index.ts index 96a626368..ef4e8ecb9 100644 --- a/libs/ui/components/src/index.ts +++ b/libs/ui/components/src/index.ts @@ -8,6 +8,9 @@ export * from './lib/confirm-dialog/confirm-dialog.component'; export * from './lib/confirm-dialog/dialog.service'; export * from './lib/content-hero/content-hero.component'; export * from './lib/external-playback-dock/external-playback-dock.component'; +export * from './lib/portal-detail-shell/content-about.component'; +export * from './lib/portal-detail-shell/detail-template.directives'; +export * from './lib/portal-detail-shell/portal-detail-shell.component'; export * from './lib/progress-capsule/progress-capsule.component'; export * from './lib/resizable/resizable.directive'; export * from './lib/season-container/season-container.component'; diff --git a/libs/ui/components/src/lib/portal-detail-shell/content-about.component.scss b/libs/ui/components/src/lib/portal-detail-shell/content-about.component.scss new file mode 100644 index 000000000..0c45e378d --- /dev/null +++ b/libs/ui/components/src/lib/portal-detail-shell/content-about.component.scss @@ -0,0 +1,111 @@ +:host { + display: block; +} + +.about { + max-width: var(--content-max-width, 1400px); + margin: 0 auto; + padding: 24px 40px 40px; + border-top: 1px solid var(--tag-border, rgba(255, 255, 255, 0.12)); + + &__heading { + font-size: 1.125rem; + font-weight: 700; + margin: 0 0 16px 0; + color: var(--text-primary, #fff); + } + + &__body { + display: flex; + gap: 22px; + align-items: flex-start; + } + + &__poster { + flex-shrink: 0; + width: 120px; + aspect-ratio: 2 / 3; + object-fit: cover; + border-radius: 10px; + box-shadow: var(--poster-shadow, 0 8px 40px rgba(0, 0, 0, 0.5)); + } + + &__info { + flex: 1; + min-width: 0; + } + + &__title { + font-size: 1rem; + font-weight: 600; + margin: 0 0 10px 0; + color: var(--text-primary, #fff); + overflow-wrap: anywhere; + } + + &__tags { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-bottom: 12px; + } + + &__description { + font-size: 0.9375rem; + line-height: 1.6; + color: var(--text-secondary, rgba(255, 255, 255, 0.7)); + margin: 0 0 14px 0; + max-width: 720px; + } + + &__meta { + display: flex; + flex-wrap: wrap; + gap: 16px 32px; + } +} + +// The stamped host templates reuse the hero markup classes; style them here +// because component styles do not cross the template-outlet boundary. +.about ::ng-deep .details__tag { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 4px 12px; + font-size: 0.8125rem; + font-weight: 500; + background: var(--tag-bg, rgba(255, 255, 255, 0.1)); + border: 1px solid var(--tag-border, rgba(255, 255, 255, 0.2)); + border-radius: 4px; + + &--rating { + color: #ffd700; + border-color: rgba(255, 215, 0, 0.3); + } +} + +.about ::ng-deep .details__meta-item { + font-size: 0.875rem; + + .label { + color: var(--text-muted, rgba(255, 255, 255, 0.5)); + font-weight: 400; + margin-bottom: 2px; + display: block; + } + + .value { + color: var(--text-primary, #fff); + font-weight: 500; + } +} + +@media (max-width: 800px) { + .about { + padding: 20px 20px 32px; + + &__poster { + width: 92px; + } + } +} diff --git a/libs/ui/components/src/lib/portal-detail-shell/content-about.component.ts b/libs/ui/components/src/lib/portal-detail-shell/content-about.component.ts new file mode 100644 index 000000000..b9ffdfb84 --- /dev/null +++ b/libs/ui/components/src/lib/portal-detail-shell/content-about.component.ts @@ -0,0 +1,59 @@ +import { NgTemplateOutlet } from '@angular/common'; +import { Component, TemplateRef, input } from '@angular/core'; +import { TranslateModule } from '@ngx-translate/core'; + +/** + * Watch-state metadata block ("About the show / movie"). Rendered by + * PortalDetailShellComponent below the episodes slot so the hero metadata + * stays reachable while the player occupies the hero position. + * + * Degradation rule: anything missing simply is not rendered — no "N/A" + * placeholders. Chips and credits are stamped from the host-provided + * *detailTags / *detailMeta templates, so the host's own @if guards apply. + */ +@Component({ + selector: 'app-content-about', + standalone: true, + imports: [NgTemplateOutlet, TranslateModule], + template: ` + + {{ 'PORTALS.ABOUT' | translate }} + + @if (posterUrl()) { + + } + + @if (title()) { + {{ title() }} + } + @if (tagsTemplate(); as tags) { + + + + } + @if (description()) { + {{ description() }} + } + @if (metaTemplate(); as meta) { + + + + } + + + + `, + styleUrls: ['./content-about.component.scss'], +}) +export class ContentAboutComponent { + readonly title = input(); + readonly posterUrl = input(); + readonly description = input(); + readonly tagsTemplate = input | null>(null); + readonly metaTemplate = input | null>(null); +} diff --git a/libs/ui/components/src/lib/portal-detail-shell/detail-template.directives.ts b/libs/ui/components/src/lib/portal-detail-shell/detail-template.directives.ts new file mode 100644 index 000000000..9647b436c --- /dev/null +++ b/libs/ui/components/src/lib/portal-detail-shell/detail-template.directives.ts @@ -0,0 +1,33 @@ +import { Directive, TemplateRef, inject } from '@angular/core'; + +/** + * Structural template markers used by PortalDetailShellComponent. + * + * Hosts wrap their hero chips/meta/actions markup in these templates so the + * shell can stamp the same content twice: once into the hero (browse state) + * and once into the About block (watch state). A projected node can only + * appear once, a TemplateRef can be stamped any number of times. + */ +@Directive({ + selector: '[detailTags]', + standalone: true, +}) +export class DetailTagsTemplateDirective { + readonly templateRef = inject(TemplateRef); +} + +@Directive({ + selector: '[detailMeta]', + standalone: true, +}) +export class DetailMetaTemplateDirective { + readonly templateRef = inject(TemplateRef); +} + +@Directive({ + selector: '[detailActions]', + standalone: true, +}) +export class DetailActionsTemplateDirective { + readonly templateRef = inject(TemplateRef); +} diff --git a/libs/ui/components/src/lib/portal-detail-shell/portal-detail-shell.component.html b/libs/ui/components/src/lib/portal-detail-shell/portal-detail-shell.component.html new file mode 100644 index 000000000..4a0c1f51d --- /dev/null +++ b/libs/ui/components/src/lib/portal-detail-shell/portal-detail-shell.component.html @@ -0,0 +1,67 @@ + + + + + + @if (tagsTemplate(); as tags) { + + } + + + @if (metaTemplate(); as meta) { + + } + + + @if (actionsTemplate(); as actions) { + + } + + + + + + + + + + + + + + + + + + + +@if (isWatch() && !isLoading() && !errorMessage()) { + +} + + + + + diff --git a/libs/ui/components/src/lib/portal-detail-shell/portal-detail-shell.component.scss b/libs/ui/components/src/lib/portal-detail-shell/portal-detail-shell.component.scss new file mode 100644 index 000000000..53b228802 --- /dev/null +++ b/libs/ui/components/src/lib/portal-detail-shell/portal-detail-shell.component.scss @@ -0,0 +1,137 @@ +// ============================================================================ +// Portal detail shell — two-state (browse ↔ watch) layout + page scroll owner +// ============================================================================ + +:host { + // Shared surface palette (mirrors content-hero) so the area below the + // natural-height hero and the About block match the page background. + --surface-bg: #fffaf5; + --text-primary: #1a1a1a; + --text-secondary: rgba(26, 26, 26, 0.75); + --text-muted: rgba(26, 26, 26, 0.5); + --tag-bg: rgba(0, 0, 0, 0.06); + --tag-border: rgba(0, 0, 0, 0.12); + --poster-shadow: 0 8px 40px rgba(0, 0, 0, 0.15); + --content-max-width: 1400px; + + display: block; + height: 100%; + overflow-y: auto; + overflow-x: hidden; + background: var(--surface-bg); + color: var(--text-primary); + + // Hide scrollbar but keep functionality (parity with the old hero scroll) + scrollbar-width: none; + -ms-overflow-style: none; + &::-webkit-scrollbar { + display: none; + } +} + +:host-context(.dark-theme) { + --surface-bg: #141414; + --text-primary: #fff; + --text-secondary: rgba(255, 255, 255, 0.7); + --text-muted: rgba(255, 255, 255, 0.5); + --tag-bg: rgba(255, 255, 255, 0.1); + --tag-border: rgba(255, 255, 255, 0.2); + --poster-shadow: 0 8px 40px rgba(0, 0, 0, 0.5); +} + +// ============================================================================ +// Hero collapse (browse ↔ watch morph, ~300ms, no height measuring) +// ============================================================================ + +.shell__hero { + display: grid; + grid-template-rows: 1fr; + transition: + grid-template-rows 300ms ease, + opacity 250ms ease; + + &--collapsed { + grid-template-rows: 0fr; + opacity: 0; + visibility: hidden; + pointer-events: none; + // Keep the hero visible while it collapses, hide it afterwards so it + // cannot receive focus in watch state. + transition: + grid-template-rows 300ms ease, + opacity 250ms ease, + visibility 0s linear 300ms; + } +} + +.shell__hero-inner { + min-height: 0; + overflow: hidden; +} + +// ============================================================================ +// Player region (full content width in watch state) +// ============================================================================ + +.shell__player { + max-width: var(--content-max-width); + margin: 0 auto; + + &--active { + padding: 16px 40px 0; + animation: shell-fade-in 300ms ease 50ms both; + } +} + +.shell__episodes { + max-width: var(--content-max-width); + margin: 0 auto; + padding: 0 40px; +} + +.shell__about { + animation: shell-fade-in 250ms ease 100ms both; +} + +.shell__extras { + max-width: var(--content-max-width); + margin: 0 auto; + padding: 0 40px 40px; +} + +@keyframes shell-fade-in { + from { + opacity: 0; + transform: translateY(8px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +@media (max-width: 800px) { + .shell__player--active { + padding: 8px 12px 0; + } + + .shell__episodes { + padding: 0 20px; + } + + .shell__extras { + padding: 0 20px 32px; + } +} + +@media (prefers-reduced-motion: reduce) { + .shell__hero, + .shell__hero--collapsed { + transition: none; + } + + .shell__player--active, + .shell__about { + animation: none; + } +} diff --git a/libs/ui/components/src/lib/portal-detail-shell/portal-detail-shell.component.spec.ts b/libs/ui/components/src/lib/portal-detail-shell/portal-detail-shell.component.spec.ts new file mode 100644 index 000000000..f938a16e7 --- /dev/null +++ b/libs/ui/components/src/lib/portal-detail-shell/portal-detail-shell.component.spec.ts @@ -0,0 +1,134 @@ +import { Component, signal } from '@angular/core'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { TranslateModule } from '@ngx-translate/core'; +import { + DetailActionsTemplateDirective, + DetailMetaTemplateDirective, + DetailTagsTemplateDirective, +} from './detail-template.directives'; +import { PortalDetailShellComponent } from './portal-detail-shell.component'; + +@Component({ + standalone: true, + imports: [ + PortalDetailShellComponent, + DetailTagsTemplateDirective, + DetailMetaTemplateDirective, + DetailActionsTemplateDirective, + ], + template: ` + + + 2026 + + + Cast entry + + + Play + + @if (playbackActive()) { + player + } + episodes + extras + + `, +}) +class HostComponent { + readonly playbackActive = signal(false); + closeRequests = 0; +} + +describe('PortalDetailShellComponent', () => { + let fixture: ComponentFixture; + let host: HostComponent; + + const query = (selector: string): HTMLElement | null => + (fixture.nativeElement as HTMLElement).querySelector(selector); + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [HostComponent, TranslateModule.forRoot()], + }).compileComponents(); + + fixture = TestBed.createComponent(HostComponent); + host = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('renders hero with stamped tags/meta/actions in browse state', () => { + expect(query('.shell__hero--collapsed')).toBeNull(); + expect(query('app-content-hero')).toBeTruthy(); + expect(query('.details__tags .details__tag')?.textContent).toContain( + '2026' + ); + expect(query('.details__meta .details__meta-item')).toBeTruthy(); + expect(query('.action-buttons .play-btn')).toBeTruthy(); + expect(query('app-content-about')).toBeNull(); + }); + + it('collapses hero and shows About with re-stamped templates in watch state', () => { + host.playbackActive.set(true); + fixture.detectChanges(); + + expect(query('.shell__hero--collapsed')).toBeTruthy(); + const about = query('app-content-about'); + expect(about).toBeTruthy(); + expect(about?.querySelector('.details__tag')?.textContent).toContain( + '2026' + ); + expect(about?.querySelector('.details__meta-item')).toBeTruthy(); + // Actions are intentionally NOT repeated in About + expect(about?.querySelector('.play-btn')).toBeNull(); + expect(about?.textContent).toContain('Show description'); + }); + + it('keeps the player slot outside any shell conditional', () => { + host.playbackActive.set(true); + fixture.detectChanges(); + const player = query('.fake-player'); + expect(player).toBeTruthy(); + + // Toggling unrelated shell state must not recreate the projected node + host.playbackActive.set(false); + fixture.detectChanges(); + // player disappears only because the HOST @if removed it + expect(query('.fake-player')).toBeNull(); + }); + + it('emits closePlayerRequested on Escape only during playback', () => { + const escape = () => + document.dispatchEvent( + new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }) + ); + + escape(); + expect(host.closeRequests).toBe(0); + + host.playbackActive.set(true); + fixture.detectChanges(); + escape(); + expect(host.closeRequests).toBe(1); + }); + + it('ignores Escape when the event was already handled', () => { + host.playbackActive.set(true); + fixture.detectChanges(); + + const event = new KeyboardEvent('keydown', { + key: 'Escape', + bubbles: true, + cancelable: true, + }); + event.preventDefault(); + document.dispatchEvent(event); + expect(host.closeRequests).toBe(0); + }); +}); diff --git a/libs/ui/components/src/lib/portal-detail-shell/portal-detail-shell.component.ts b/libs/ui/components/src/lib/portal-detail-shell/portal-detail-shell.component.ts new file mode 100644 index 000000000..b46cd10d9 --- /dev/null +++ b/libs/ui/components/src/lib/portal-detail-shell/portal-detail-shell.component.ts @@ -0,0 +1,109 @@ +import { NgTemplateOutlet } from '@angular/common'; +import { + Component, + ElementRef, + computed, + contentChild, + effect, + inject, + input, + output, +} from '@angular/core'; +import { ContentHeroComponent } from '../content-hero/content-hero.component'; +import { ContentAboutComponent } from './content-about.component'; +import { + DetailActionsTemplateDirective, + DetailMetaTemplateDirective, + DetailTagsTemplateDirective, +} from './detail-template.directives'; + +/** + * Two-state layout shell for portal VOD/series detail pages. + * + * Browse state: hero (poster + metadata + actions) on top, episodes below. + * Watch state (`playbackActive`): the hero collapses, the host-projected + * player takes the full content width, and the metadata reappears in an + * About block below the episodes slot. + * + * The shell owns the page scroll, the browse↔watch animation, Escape + * handling, and never conditionally wraps the `[detail-player]` slot — the + * host's own `@if (inlinePlayback())` is the only thing that creates or + * destroys the player, so shell state changes cannot recreate it. + */ +@Component({ + selector: 'app-portal-detail-shell', + standalone: true, + imports: [ContentHeroComponent, ContentAboutComponent, NgTemplateOutlet], + templateUrl: './portal-detail-shell.component.html', + styleUrls: ['./portal-detail-shell.component.scss'], + host: { + '[class.shell-host--watch]': 'isWatch()', + '(document:keydown.escape)': 'onEscape($event)', + }, +}) +export class PortalDetailShellComponent { + private readonly host = inject>(ElementRef); + + readonly title = input(); + readonly description = input(); + readonly posterUrl = input(); + readonly backdropUrl = input(); + readonly isLoading = input(false); + readonly errorMessage = input(); + /** True while inline playback is active — flips the layout to watch state. */ + readonly playbackActive = input(false); + + readonly backClicked = output(); + /** Emitted when Escape is pressed during inline playback. */ + readonly closePlayerRequested = output(); + + protected readonly tagsTemplate = contentChild(DetailTagsTemplateDirective); + protected readonly metaTemplate = contentChild(DetailMetaTemplateDirective); + protected readonly actionsTemplate = contentChild( + DetailActionsTemplateDirective + ); + + readonly isWatch = computed(() => this.playbackActive()); + + constructor() { + let wasWatch = false; + effect(() => { + const watch = this.isWatch(); + if (watch && !wasWatch) { + this.scrollToTop(); + } + wasWatch = watch; + }); + } + + onEscape(event: KeyboardEvent): void { + if (!this.playbackActive()) return; + if (event.defaultPrevented) return; + // Browser fullscreen owns Escape (exits fullscreen first). + if (document.fullscreenElement) return; + const target = event.target as HTMLElement | null; + if ( + target && + (target.isContentEditable || + ['INPUT', 'TEXTAREA', 'SELECT'].includes(target.tagName)) + ) { + return; + } + this.closePlayerRequested.emit(); + } + + private scrollToTop(): void { + const element = this.host.nativeElement; + if (typeof element.scrollTo !== 'function') { + element.scrollTop = 0; + return; + } + const reducedMotion = + typeof window !== 'undefined' && + window.matchMedia?.('(prefers-reduced-motion: reduce)').matches; + element.scrollTo({ + top: 0, + behavior: reducedMotion ? 'auto' : 'smooth', + }); + } +}
{{ description() }}