feat(ui): add PortalDetailShellComponent with browse/watch states

Two-state detail layout shell: owns page scroll, hero collapse animation
(~300ms, reduced-motion aware), Escape-to-close-player handling, and an
About block that re-stamps host-provided *detailTags/*detailMeta
templates in watch state. The [detail-player] slot is never wrapped in a
shell conditional so the host-owned player subtree survives state
changes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Fable 5 committed 2026-07-04 18:13:38 +02:00
1 parent 3a0aa07840
commit 329be253eb
8 files changed
+653

No files matched your search

+3
View File
@@ -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';
@@ -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;
}
}
}
@@ -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: `
<section class="about">
<h3 class="about__heading">{{ 'PORTALS.ABOUT' | translate }}</h3>
<div class="about__body">
@if (posterUrl()) {
<img
class="about__poster"
[src]="posterUrl()"
[alt]="title() ?? ''"
loading="lazy"
/>
}
<div class="about__info">
@if (title()) {
<h4 class="about__title">{{ title() }}</h4>
}
@if (tagsTemplate(); as tags) {
<div class="about__tags details__tags">
<ng-container [ngTemplateOutlet]="tags" />
</div>
}
@if (description()) {
<p class="about__description">{{ description() }}</p>
}
@if (metaTemplate(); as meta) {
<div class="about__meta details__meta">
<ng-container [ngTemplateOutlet]="meta" />
</div>
}
</div>
</div>
</section>
`,
styleUrls: ['./content-about.component.scss'],
})
export class ContentAboutComponent {
readonly title = input<string>();
readonly posterUrl = input<string>();
readonly description = input<string>();
readonly tagsTemplate = input<TemplateRef<unknown> | null>(null);
readonly metaTemplate = input<TemplateRef<unknown> | null>(null);
}
@@ -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);
}
@@ -0,0 +1,67 @@
<!-- Hero (browse state) — collapses when playback is active -->
<div class="shell__hero" [class.shell__hero--collapsed]="isWatch()">
<div class="shell__hero-inner">
<app-content-hero
[title]="title()"
[description]="description()"
[posterUrl]="posterUrl()"
[backdropUrl]="backdropUrl()"
[isLoading]="isLoading()"
[errorMessage]="errorMessage()"
(backClicked)="backClicked.emit()"
>
<ng-container hero-tags>
@if (tagsTemplate(); as tags) {
<ng-container
[ngTemplateOutlet]="tags.templateRef"
></ng-container>
}
</ng-container>
<ng-container hero-meta>
@if (metaTemplate(); as meta) {
<ng-container
[ngTemplateOutlet]="meta.templateRef"
></ng-container>
}
</ng-container>
<ng-container hero-actions>
@if (actionsTemplate(); as actions) {
<ng-container
[ngTemplateOutlet]="actions.templateRef"
></ng-container>
}
</ng-container>
<ng-container hero-error-actions>
<ng-content select="[hero-error-actions]"></ng-content>
</ng-container>
</app-content-hero>
</div>
</div>
<!-- Player region (watch state) — the slot itself is never wrapped in @if,
so the host-owned player subtree survives every shell state change -->
<div class="shell__player" [class.shell__player--active]="isWatch()">
<ng-content select="[detail-player]"></ng-content>
</div>
<!-- Episodes / seasons — both states -->
<div class="shell__episodes">
<ng-content select="[detail-episodes]"></ng-content>
</div>
<!-- About block — watch state only, metadata moves here -->
@if (isWatch() && !isLoading() && !errorMessage()) {
<app-content-about
class="shell__about"
[title]="title()"
[posterUrl]="posterUrl()"
[description]="description()"
[tagsTemplate]="tagsTemplate()?.templateRef ?? null"
[metaTemplate]="metaTemplate()?.templateRef ?? null"
/>
}
<!-- Extras (trailer, similar rail) — both states -->
<div class="shell__extras">
<ng-content select="[detail-extras]"></ng-content>
</div>
@@ -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;
}
}
@@ -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: `
<app-portal-detail-shell
[title]="'Show Title'"
[description]="'Show description'"
[posterUrl]="'poster.jpg'"
[playbackActive]="playbackActive()"
(closePlayerRequested)="closeRequests = closeRequests + 1"
>
<ng-template detailTags>
<span class="details__tag">2026</span>
</ng-template>
<ng-template detailMeta>
<div class="details__meta-item">Cast entry</div>
</ng-template>
<ng-template detailActions>
<button class="play-btn">Play</button>
</ng-template>
@if (playbackActive()) {
<div detail-player class="fake-player">player</div>
}
<div detail-episodes class="fake-episodes">episodes</div>
<div detail-extras class="fake-extras">extras</div>
</app-portal-detail-shell>
`,
})
class HostComponent {
readonly playbackActive = signal(false);
closeRequests = 0;
}
describe('PortalDetailShellComponent', () => {
let fixture: ComponentFixture<HostComponent>;
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);
});
});
@@ -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<HTMLElement>>(ElementRef);
readonly title = input<string>();
readonly description = input<string>();
readonly posterUrl = input<string>();
readonly backdropUrl = input<string>();
readonly isLoading = input(false);
readonly errorMessage = input<string>();
/** True while inline playback is active — flips the layout to watch state. */
readonly playbackActive = input(false);
readonly backClicked = output<void>();
/** Emitted when Escape is pressed during inline playback. */
readonly closePlayerRequested = output<void>();
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',
});
}
}