feat: implement content card component with dynamic features and styles

This commit is contained in:
4gray committed 2026-02-04 22:12:51 +01:00
1 parent d64283e33d
commit e6923d3fff
4 files changed
+260

No files matched your search

@@ -0,0 +1,48 @@
<div class="content-card" (click)="onCardClick()">
<div class="thumbnail-wrapper">
@if (showRemoveButton()) {
<button
mat-icon-button
class="remove-button"
[matTooltip]="removeTooltip()"
(click)="onRemoveClick($event)"
>
<mat-icon>close</mat-icon>
</button>
}
@if (posterUrl()) {
<img
[src]="posterUrl()"
[alt]="title()"
(error)="onImageError($event)"
class="poster"
/>
} @else if (showPlaceholder()) {
<div class="poster-placeholder">
<mat-icon>{{ getPlaceholderIcon() }}</mat-icon>
</div>
} @else {
<img
src="./assets/images/default-poster.png"
[alt]="title()"
class="poster"
/>
}
@if (type()) {
<span class="type-badge" [class]="type()">
{{ type() }}
</span>
}
</div>
<div class="card-info">
<h3>{{ title() }}</h3>
@if (date()) {
<p class="date-info">
{{ date() | date: 'd MMM, HH:mm' }}
</p>
}
</div>
</div>
@@ -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;
}
}
@@ -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<string>();
/** Title to display */
readonly title = input.required<string>();
/** Content type (live, movie, series) */
readonly type = input<string>();
/** Optional date to display */
readonly date = input<Date | string>();
/** Whether to show the remove button */
readonly showRemoveButton = input<boolean>(false);
/** Tooltip text for the remove button */
readonly removeTooltip = input<string>('Remove');
/** Whether to show placeholder when no poster */
readonly showPlaceholder = input<boolean>(true);
/** Emitted when the card is clicked */
readonly cardClick = output<void>();
/** Emitted when the remove button is clicked */
readonly remove = output<void>();
/** 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';
}
}
@@ -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;
}
}