diff --git a/apps/web/src/app/shared/components/content-card/content-card.component.html b/apps/web/src/app/shared/components/content-card/content-card.component.html new file mode 100644 index 000000000..b8d3fa2b2 --- /dev/null +++ b/apps/web/src/app/shared/components/content-card/content-card.component.html @@ -0,0 +1,48 @@ +
+
+ @if (showRemoveButton()) { + + } + + @if (posterUrl()) { + + } @else if (showPlaceholder()) { +
+ {{ getPlaceholderIcon() }} +
+ } @else { + + } + + @if (type()) { + + {{ type() }} + + } +
+ +
+

{{ title() }}

+ @if (date()) { +

+ {{ date() | date: 'd MMM, HH:mm' }} +

+ } +
+
diff --git a/apps/web/src/app/shared/components/content-card/content-card.component.scss b/apps/web/src/app/shared/components/content-card/content-card.component.scss new file mode 100644 index 000000000..6cc36dea1 --- /dev/null +++ b/apps/web/src/app/shared/components/content-card/content-card.component.scss @@ -0,0 +1,21 @@ +@use '../../styles/content-grid' as grid; + +.content-card { + @include grid.content-card; + + .thumbnail-wrapper { + @include grid.thumbnail-wrapper; + + .remove-button { + @include grid.remove-button; + } + + .type-badge { + @include grid.type-badge; + } + } + + .card-info { + @include grid.card-info; + } +} diff --git a/apps/web/src/app/shared/components/content-card/content-card.component.ts b/apps/web/src/app/shared/components/content-card/content-card.component.ts new file mode 100644 index 000000000..9ee4049ed --- /dev/null +++ b/apps/web/src/app/shared/components/content-card/content-card.component.ts @@ -0,0 +1,73 @@ +import { DatePipe } from '@angular/common'; +import { + ChangeDetectionStrategy, + Component, + input, + output, +} from '@angular/core'; +import { MatIconButton } from '@angular/material/button'; +import { MatIcon } from '@angular/material/icon'; +import { MatTooltip } from '@angular/material/tooltip'; + +@Component({ + selector: 'app-content-card', + standalone: true, + imports: [DatePipe, MatIcon, MatIconButton, MatTooltip], + templateUrl: './content-card.component.html', + styleUrl: './content-card.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class ContentCardComponent { + /** URL of the poster image */ + readonly posterUrl = input(); + + /** Title to display */ + readonly title = input.required(); + + /** Content type (live, movie, series) */ + readonly type = input(); + + /** Optional date to display */ + readonly date = input(); + + /** Whether to show the remove button */ + readonly showRemoveButton = input(false); + + /** Tooltip text for the remove button */ + readonly removeTooltip = input('Remove'); + + /** Whether to show placeholder when no poster */ + readonly showPlaceholder = input(true); + + /** Emitted when the card is clicked */ + readonly cardClick = output(); + + /** Emitted when the remove button is clicked */ + readonly remove = output(); + + /** Get the icon for the placeholder based on type */ + getPlaceholderIcon(): string { + switch (this.type()) { + case 'live': + return 'live_tv'; + case 'series': + return 'tv'; + default: + return 'movie'; + } + } + + onCardClick(): void { + this.cardClick.emit(); + } + + onRemoveClick(event: Event): void { + event.stopPropagation(); + this.remove.emit(); + } + + onImageError(event: Event): void { + (event.target as HTMLImageElement).src = + './assets/images/default-poster.png'; + } +} diff --git a/apps/web/src/app/shared/styles/_content-grid.scss b/apps/web/src/app/shared/styles/_content-grid.scss new file mode 100644 index 000000000..0b94e1e3c --- /dev/null +++ b/apps/web/src/app/shared/styles/_content-grid.scss @@ -0,0 +1,118 @@ +// Shared content grid and card styles +// Usage: @use 'path/to/_content-grid.scss' as grid; +// @include grid.content-grid; +// @include grid.content-card; + +@mixin content-grid($min-width: 140px, $gap: 20px) { + display: grid; + grid-template-columns: repeat(auto-fill, minmax($min-width, 1fr)); + gap: $gap; +} + +@mixin content-card { + cursor: pointer; + border-radius: 8px; + overflow: hidden; + transition: transform 0.2s ease, box-shadow 0.2s ease; + + &:hover { + transform: translateY(-3px); + box-shadow: 0 6px 16px rgba(0, 0, 0, 0.12); + } +} + +@mixin thumbnail-wrapper { + position: relative; + + .poster { + width: 100%; + aspect-ratio: 2/3; + object-fit: cover; + display: block; + } + + .poster-placeholder { + width: 100%; + aspect-ratio: 2/3; + display: flex; + align-items: center; + justify-content: center; + background: rgba(128, 128, 128, 0.1); + + mat-icon { + font-size: 36px; + width: 36px; + height: 36px; + opacity: 0.25; + } + } +} + +@mixin type-badge { + position: absolute; + top: 6px; + left: 6px; + padding: 3px 6px; + border-radius: 4px; + font-size: 9px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.5px; + backdrop-filter: blur(8px); + -webkit-backdrop-filter: blur(8px); + background: rgba(0, 0, 0, 0.6); + color: #fff; + + &.live { + color: #ef5350; + } + &.movie { + color: #42a5f5; + } + &.series { + color: #66bb6a; + } +} + +@mixin remove-button { + position: absolute; + top: 4px; + right: 4px; + z-index: 1; + background: rgba(0, 0, 0, 0.5); + width: 28px; + height: 28px; + padding: 0; + + &:hover { + background: rgba(0, 0, 0, 0.8); + } + + mat-icon { + color: white; + font-size: 18px; + width: 18px; + height: 18px; + } +} + +@mixin card-info { + padding: 8px 10px; + + h3 { + margin: 0; + font-size: 0.8rem; + font-weight: 500; + line-height: 1.3; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; + } + + .date-info { + margin: 4px 0 0; + font-size: 0.7rem; + opacity: 0.6; + } +}