feat(ui): add ContentHero component and detail view styles

Add a new standalone ContentHero component (template and TS) that
provides a cinematic hero section with backdrop, poster, title,
description, tags/meta/action slots, and a back button event emitter.
The component chooses backdrop or poster as the background, falls back
to a default poster image on load error, and exposes inputs for title,
description, posterUrl, and backdropUrl.

Introduce extensive cinematic styles for the detail view:
- define CSS custom properties for light and dark themes, poster size,
  shadows, and color tokens
- implement gradient vignette, layout sizing, and theming via
  :host and :host-context(.dark-theme)
- change container sizing behavior to full-height and hide overflow

These changes create a reusable hero UI and themed detail-page styles
to support a Netflix-style streaming detail experience.
This commit is contained in:
4gray committed 2026-01-05 21:54:54 +01:00
1 parent ad9bf45518
commit afad80e61b
5 files changed
+900 -110

No files matched your search

+330 -110
View File
@@ -1,140 +1,360 @@
// ============================================================================
// Netflix-style Detail View - Cinematic Streaming Design
// ============================================================================
:host {
--poster-width: 220px;
--poster-shadow: 0 8px 40px rgba(0, 0, 0, 0.15);
--content-max-width: 1400px;
// Light theme colors (default - no .dark-theme class on body)
--hero-gradient-start: rgba(255, 250, 245, 0);
--hero-gradient-mid: rgba(255, 250, 245, 0.75);
--hero-gradient-end: rgba(255, 250, 245, 1);
--surface-bg: #fffaf5;
--text-primary: #1a1a1a;
--text-secondary: rgba(26, 26, 26, 0.75);
--text-muted: rgba(26, 26, 26, 0.5);
--accent-color: #b81d24;
--tag-bg: rgba(0, 0, 0, 0.06);
--tag-border: rgba(0, 0, 0, 0.12);
width: 100%;
display: flex;
flex-direction: column;
overflow: auto;
height: 100%;
display: block;
overflow: hidden;
background: var(--surface-bg);
color: var(--text-primary);
}
// Dark theme overrides - cinematic dark mode
:host-context(.dark-theme) {
--hero-gradient-start: rgba(20, 20, 20, 0);
--hero-gradient-mid: rgba(20, 20, 20, 0.7);
--hero-gradient-end: rgba(20, 20, 20, 1);
--surface-bg: #141414;
--text-primary: #fff;
--text-secondary: rgba(255, 255, 255, 0.7);
--text-muted: rgba(255, 255, 255, 0.5);
--accent-color: #e50914;
--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);
}
// ============================================================================
// Details Section - Content Elements
// ============================================================================
.details {
h2 {
word-wrap: break-word;
margin-top: 0;
}
}
// Styles for projected content elements
.container {
display: grid;
grid-gap: 20px;
grid-template-columns: minmax(250px, 30%) minmax(300px, 1fr);
grid-template-areas:
'image details'
'seasons seasons';
padding: 20px;
max-width: 100%;
box-sizing: border-box;
height: auto;
&__tag {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 4px 12px;
font-size: 0.8125rem;
font-weight: 500;
background: var(--tag-bg);
border: 1px solid var(--tag-border);
border-radius: 4px;
backdrop-filter: blur(8px);
&::before {
content: '';
position: absolute;
top: 0;
right: 0;
bottom: 0;
left: 0;
background-size: cover;
z-index: -1;
background-repeat: no-repeat;
}
}
.seasons {
padding: 20px;
.episodes {
margin-bottom: 10px;
button {
margin-top: 5px;
&--rating {
color: #ffd700;
border-color: rgba(255, 215, 0, 0.3);
}
}
&__meta-item {
font-size: 0.875rem;
.label {
color: var(--text-muted);
font-weight: 400;
margin-bottom: 2px;
text-decoration: none;
display: block;
}
.value {
color: var(--text-primary);
font-weight: 500;
}
}
}
// ============================================================================
// Action Buttons - Play, Favorite
// ============================================================================
.play-btn {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 12px 28px;
font-size: 1rem;
font-weight: 600;
background: #fff;
color: #141414;
border: none;
border-radius: 4px;
cursor: pointer;
transition: all 0.2s ease;
&:hover {
background: rgba(255, 255, 255, 0.85);
transform: scale(1.02);
}
mat-icon {
font-size: 24px;
width: 24px;
height: 24px;
}
}
.favorite-btn {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 12px 20px;
font-size: 0.9375rem;
font-weight: 500;
background: rgba(109, 109, 110, 0.7);
color: #fff;
border: none;
border-radius: 4px;
cursor: pointer;
transition: all 0.2s ease;
&:hover {
background: rgba(109, 109, 110, 0.5);
}
&--active {
background: rgba(229, 9, 20, 0.8);
&:hover {
background: rgba(229, 9, 20, 0.6);
}
}
mat-icon {
font-size: 20px;
width: 20px;
height: 20px;
}
}
// ============================================================================
// Content Sections - Below the hero fold
// ============================================================================
.content-section {
padding: 20px 0;
width: 100%;
box-sizing: border-box;
&__title {
font-size: 1.375rem;
font-weight: 600;
margin: 0 0 20px 0;
color: var(--text-primary);
}
}
// ============================================================================
// Seasons & Episodes - Inside details section
// ============================================================================
.seasons {
margin-top: 24px;
width: 100%;
.season-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 0;
cursor: pointer;
transition: opacity 0.2s ease;
&:hover {
opacity: 0.8;
}
h4 {
margin: 0;
font-size: 1rem;
font-weight: 600;
}
.expand-icon {
font-size: 0.75rem;
color: var(--text-muted);
}
}
.episodes {
display: flex;
flex-wrap: wrap;
gap: 10px;
padding: 8px 0 16px;
button {
margin: 0;
font-weight: 500;
}
}
.episodes-loading,
.seasons-loading {
display: flex;
align-items: center;
gap: 12px;
padding: 20px 0;
color: var(--text-secondary);
}
.no-episodes {
color: var(--text-muted);
font-style: italic;
padding: 8px 0;
}
}
// Seasons loading when inside details
.details .seasons-loading {
display: flex;
align-items: center;
gap: 12px;
padding: 20px 0;
color: var(--text-secondary);
margin-top: 24px;
}
// Season container when inside details section
.details app-season-container {
display: block;
width: 100%;
margin-top: 24px;
box-sizing: border-box;
}
// ============================================================================
// YouTube Trailer Section - Inside details
// ============================================================================
.youtube-trailer {
margin-top: 24px;
width: 100%;
h3 {
font-size: 1.125rem;
font-weight: 600;
margin: 0 0 12px 0;
color: var(--text-primary);
}
.trailer-wrapper {
position: relative;
width: 100%;
max-width: 560px;
aspect-ratio: 16 / 9;
border-radius: 8px;
overflow: hidden;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
iframe {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
border: none;
}
}
}
// ============================================================================
// Legacy container support (for gradual migration)
// ============================================================================
.container {
display: contents;
}
.image {
grid-area: image;
margin: 0;
border-radius: 12px;
max-width: 100%;
img {
width: 100%;
height: auto;
border-radius: 12px;
object-fit: cover;
max-width: 450px;
}
.placeholder-cover {
width: 100%;
aspect-ratio: 2/3;
background-color: #999;
border-radius: 12px;
&::after {
content: 'No cover';
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
}
}
display: none; // Hidden - now using .poster in hero
}
app-season-container {
grid-area: seasons;
width: 100%;
}
// ============================================================================
// Mobile Responsive - Option A: Keep poster grid layout
// ============================================================================
@media (max-width: 800px) {
.container {
grid-template-columns: 1fr;
grid-template-areas:
'image'
'details'
'seasons';
padding: 10px;
:host {
--poster-width: 120px;
}
.image,
.details {
.action-buttons {
width: 100%;
flex: 1 1 100%;
display: flex;
flex-direction: column;
padding: 0;
img {
max-height: 500px;
object-fit: contain;
border-radius: 0;
}
// This class is in ContentHeroComponent now, but these rules apply to children?
// No, .play-btn is a child of the PROJECTED content.
// So we should target the buttons directly for mobile
}
.details {
padding: 0 10px;
.play-btn,
.favorite-btn {
flex: 1;
justify-content: center;
padding: 10px 16px;
}
.youtube-trailer {
width: 100% !important;
margin-top: 16px;
.trailer-wrapper {
max-width: 100%;
border-radius: 4px;
}
}
.seasons {
padding: 16px 0 0;
}
}
.label {
font-weight: 100;
font-size: 0.9em;
padding: 5px 0;
text-decoration: underline;
opacity: 0.6;
}
.action-buttons {
margin-top: 10px;
}
// ============================================================================
// Animations
// ============================================================================
.youtube-trailer {
display: flex;
flex-wrap: wrap;
align-self: center;
width: 100%;
max-width: 650px;
margin: 0 auto;
animation: fadeInUp 0.5s ease-out 0.4s both;
}
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
// ============================================================================
// Utility - keep .label for backwards compatibility
// ============================================================================
.label {
font-weight: 400;
font-size: 0.8125rem;
color: var(--text-muted);
text-decoration: none;
padding: 0;
opacity: 1;
}
+1
View File
@@ -4,6 +4,7 @@ export * from './lib/audio-player/audio-player.component';
export * from './lib/channel-list-container/channel-list-container.component';
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/epg-list/epg-item-description/epg-item-description.component';
export * from './lib/epg-list/epg-list-item/epg-list-item.component';
export * from './lib/epg-list/epg-list.component';
@@ -0,0 +1,72 @@
<!-- Hero Section with Backdrop -->
<section class="hero">
<!-- Back Button -->
<button class="hero__back-button" (click)="onBack()">
<mat-icon>arrow_back</mat-icon>
</button>
<!-- Backdrop Image -->
<div
class="hero__backdrop"
[class.hero__backdrop--blurred]="!backdropUrl"
[style.backgroundImage]="
backdropUrl
? 'url(' + backdropUrl + ')'
: posterUrl
? 'url(' + posterUrl + ')'
: 'none'
"
></div>
<!-- Gradient Vignette Overlay -->
<div class="hero__vignette"></div>
<!-- Hero Content -->
<div class="hero__content">
<!-- Poster Card -->
<div class="poster">
@if (posterUrl) {
<img
class="poster__image"
[src]="posterUrl"
(error)="
$event.target.src = './assets/images/default-poster.png'
"
alt="Poster"
/>
} @else {
<div class="poster__placeholder">No Poster</div>
}
</div>
<!-- Details -->
<div class="details">
<h1 class="details__title">{{ title }}</h1>
<!-- Tags Slot -->
<div class="details__tags">
<ng-content select="[hero-tags]"></ng-content>
</div>
<!-- Description -->
@if (description) {
<p class="details__description">
{{ description }}
</p>
}
<!-- Metadata Grid Slot -->
<div class="details__meta">
<ng-content select="[hero-meta]"></ng-content>
</div>
<!-- Action Buttons Slot -->
<div class="action-buttons">
<ng-content select="[hero-actions]"></ng-content>
</div>
<!-- Additional Content (Seasons, Trailers, etc.) -->
<ng-content></ng-content>
</div>
</div>
</section>
@@ -0,0 +1,473 @@
// ============================================================================
// Netflix-style Detail View - Cinematic Streaming Design
// ============================================================================
:host {
--poster-width: 220px;
--poster-shadow: 0 8px 40px rgba(0, 0, 0, 0.15);
--content-max-width: 1400px;
// Light theme colors (default - no .dark-theme class on body)
--hero-gradient-start: rgba(255, 250, 245, 0);
--hero-gradient-mid: rgba(255, 250, 245, 0.75);
--hero-gradient-end: rgba(255, 250, 245, 1);
--surface-bg: #fffaf5;
--text-primary: #1a1a1a;
--text-secondary: rgba(26, 26, 26, 0.75);
--text-muted: rgba(26, 26, 26, 0.5);
--accent-color: #b81d24;
--tag-bg: rgba(0, 0, 0, 0.06);
--tag-border: rgba(0, 0, 0, 0.12);
width: 100%;
height: 100%;
display: block;
overflow: hidden;
background: var(--surface-bg);
color: var(--text-primary);
}
// Dark theme overrides - cinematic dark mode
:host-context(.dark-theme) {
--hero-gradient-start: rgba(20, 20, 20, 0);
--hero-gradient-mid: rgba(20, 20, 20, 0.7);
--hero-gradient-end: rgba(20, 20, 20, 1);
--surface-bg: #141414;
--text-primary: #fff;
--text-secondary: rgba(255, 255, 255, 0.7);
--text-muted: rgba(255, 255, 255, 0.5);
--accent-color: #e50914;
--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 Section - Full container with backdrop
// ============================================================================
.hero {
position: relative;
width: 100%;
height: 100%;
overflow: hidden;
background: var(
--surface-bg
); // Solid background prevents any bleed-through
// Floating back button over backdrop
&__back-button {
position: absolute;
top: 16px;
left: 16px;
z-index: 10;
display: flex;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
border-radius: 50%;
background: rgba(0, 0, 0, 0.5);
backdrop-filter: blur(8px);
border: none;
cursor: pointer;
transition: all 0.2s ease;
color: #fff;
&:hover {
background: rgba(0, 0, 0, 0.7);
transform: scale(1.05);
}
mat-icon {
font-size: 24px;
width: 24px;
height: 24px;
}
}
// Backdrop image layer - fixed at top, never repeats
&__backdrop {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: auto; // Only use height, not bottom
height: 60%;
background-size: cover;
background-position: center 20%;
background-repeat: no-repeat;
z-index: 0;
overflow: hidden;
// Fallback blur effect when using poster as backdrop
&--blurred {
filter: blur(40px) saturate(1.3);
transform: scale(1.4);
height: 65%;
}
}
// Gradient overlays for text legibility
&__vignette {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0; // Full height to cover everything
z-index: 1;
pointer-events: none;
background: linear-gradient(
to bottom,
transparent 0%,
transparent 35%,
var(--surface-bg) 55%,
var(--surface-bg) 100%
);
// Top area gradient for text legibility over backdrop
&::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 60%;
background: linear-gradient(
to top,
var(--hero-gradient-end) 0%,
var(--hero-gradient-mid) 50%,
var(--hero-gradient-start) 100%
);
}
// Side fade for poster area
&::after {
content: '';
position: absolute;
top: 0;
left: 0;
height: 50%;
width: 40%;
background: linear-gradient(
to right,
var(--hero-gradient-mid) 0%,
var(--hero-gradient-start) 100%
);
}
}
// Scrollable content area
&__content {
position: absolute;
inset: 0;
z-index: 2;
display: flex;
gap: 32px;
align-items: flex-start;
padding: 140px 40px 60px;
max-width: var(--content-max-width);
margin: 0 auto;
overflow-y: auto;
overflow-x: hidden;
box-sizing: border-box;
// Hide scrollbar but keep functionality
scrollbar-width: none;
-ms-overflow-style: none;
&::-webkit-scrollbar {
display: none;
}
}
}
// ============================================================================
// Poster Card - Floating with shadow
// ============================================================================
.poster {
flex-shrink: 0;
width: var(--poster-width);
align-self: flex-start;
&:hover .poster__image {
transform: scale(1.02);
}
&__image {
width: 100%;
aspect-ratio: 2 / 3;
object-fit: cover;
border-radius: 8px;
box-shadow: var(--poster-shadow);
background: linear-gradient(135deg, #2a2a2a 0%, #1a1a1a 100%);
transition: transform 0.3s ease;
}
&__placeholder {
width: 100%;
aspect-ratio: 2 / 3;
border-radius: 8px;
background: linear-gradient(135deg, #3a3a3a 0%, #2a2a2a 100%);
display: flex;
align-items: center;
justify-content: center;
color: var(--text-muted);
font-size: 0.875rem;
box-shadow: var(--poster-shadow);
}
}
// ============================================================================
// Details Section - Title, metadata, description
// ============================================================================
.details {
flex: 1;
min-width: 0;
padding-bottom: 24px;
&__title {
font-size: clamp(1.75rem, 4vw, 3rem);
font-weight: 700;
line-height: 1.1;
margin: 0 0 16px 0;
letter-spacing: -0.02em;
text-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
}
&__tags {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 16px;
}
// These styles are for Projected Content, we need to ensure they apply
// using ::ng-deep or just assume the global styles will handle them?
// Actually, since we project content, the styles defined here might NOT apply to the projected content
// unless we use ::ng-deep or the content itself has these styles.
// However, since we are moving the "shell", the container classes (.details__tags) are here.
// The inner items (.details__tag) will be in the projected content.
// So we need ::ng-deep here for .details__tag.
::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);
border: 1px solid var(--tag-border);
border-radius: 4px;
backdrop-filter: blur(8px);
&--rating {
color: #ffd700;
border-color: rgba(255, 215, 0, 0.3);
}
}
&__description {
font-size: 0.9375rem;
line-height: 1.6;
color: var(--text-secondary);
margin-bottom: 20px;
max-width: 600px;
// Truncate long descriptions
display: -webkit-box;
-webkit-line-clamp: 4;
-webkit-box-orient: vertical;
overflow: hidden;
}
&__meta {
display: flex;
flex-wrap: wrap;
gap: 16px 32px;
margin-bottom: 24px;
}
// Similar issue for meta items
::ng-deep .details__meta-item {
font-size: 0.875rem;
.label {
color: var(--text-muted);
font-weight: 400;
margin-bottom: 2px;
text-decoration: none;
display: block;
}
.value {
color: var(--text-primary);
font-weight: 500;
}
}
}
// ============================================================================
// Action Buttons - Play, Favorite
// ============================================================================
.action-buttons {
display: flex;
gap: 12px;
flex-wrap: wrap;
margin-top: 0;
// Use ::ng-deep to style the projected buttons
::ng-deep .play-btn {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 12px 28px;
font-size: 1rem;
font-weight: 600;
background: #fff;
color: #141414;
border: none;
border-radius: 4px;
cursor: pointer;
transition: all 0.2s ease;
&:hover {
background: rgba(255, 255, 255, 0.85);
transform: scale(1.02);
}
mat-icon {
font-size: 24px;
width: 24px;
height: 24px;
}
}
::ng-deep .favorite-btn {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 12px 20px;
font-size: 0.9375rem;
font-weight: 500;
background: rgba(109, 109, 110, 0.7);
color: #fff;
border: none;
border-radius: 4px;
cursor: pointer;
transition: all 0.2s ease;
&:hover {
background: rgba(109, 109, 110, 0.5);
}
&--active {
background: rgba(229, 9, 20, 0.8);
&:hover {
background: rgba(229, 9, 20, 0.6);
}
}
mat-icon {
font-size: 20px;
width: 20px;
height: 20px;
}
}
}
// ============================================================================
// Mobile Responsive - Option A: Keep poster grid layout
// ============================================================================
@media (max-width: 800px) {
:host {
--poster-width: 120px;
}
.hero {
&__backdrop {
height: 45%;
&--blurred {
height: 50%;
}
}
&__vignette {
background: linear-gradient(
to bottom,
transparent 0%,
transparent 25%,
var(--surface-bg) 45%,
var(--surface-bg) 100%
);
&::before {
height: 50%;
}
&::after {
display: none;
}
}
&__content {
padding: 60px 20px 40px;
gap: 16px;
}
}
.poster {
// Mobile poster adjustments - none needed
}
.details {
&__title {
font-size: 1.5rem;
}
&__description {
-webkit-line-clamp: 3;
}
&__meta {
gap: 12px 20px;
}
}
.action-buttons {
width: 100%;
::ng-deep .play-btn,
::ng-deep .favorite-btn {
flex: 1;
justify-content: center;
padding: 10px 16px;
}
}
}
// ============================================================================
// Animations
// ============================================================================
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.hero__content {
animation: fadeInUp 0.6s ease-out 0.2s both;
}
@@ -0,0 +1,24 @@
import { CommonModule } from '@angular/common';
import { Component, EventEmitter, Input, Output } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatIconModule } from '@angular/material/icon';
@Component({
selector: 'app-content-hero',
standalone: true,
imports: [CommonModule, MatIconModule, MatButtonModule],
templateUrl: './content-hero.component.html',
styleUrls: ['./content-hero.component.scss'],
})
export class ContentHeroComponent {
@Input() title: string | undefined;
@Input() description: string | undefined;
@Input() posterUrl: string | undefined;
@Input() backdropUrl: string | undefined;
@Output() backClicked = new EventEmitter<void>();
onBack(): void {
this.backClicked.emit();
}
}