feat: add recently added widget with filtering and view mode options

This commit is contained in:
4gray committed 2026-03-28 15:35:19 +01:00
1 parent 40039d6650
commit 43426114d6
3 files changed
+356

No files matched your search

@@ -0,0 +1,102 @@
<app-dashboard-widget-shell
[eyebrow]="'WORKSPACE.DASHBOARD.LIBRARY' | translate"
[title]="'WORKSPACE.DASHBOARD.RECENTLY_ADDED' | translate"
>
<ng-container widget-actions>
<button
mat-icon-button
[matMenuTriggerFor]="filterMenu"
[attr.aria-label]="
'WORKSPACE.DASHBOARD.FILTER_RECENTLY_ADDED' | translate
"
[matTooltip]="'WORKSPACE.DASHBOARD.FILTER_ITEMS' | translate"
>
<mat-icon>filter_list</mat-icon>
</button>
<mat-menu #filterMenu="matMenu">
<button mat-menu-item (click)="onKindChange('all')">
<mat-icon>{{ selectedKind() === 'all' ? 'check' : '' }}</mat-icon>
<span [class.active-filter]="selectedKind() === 'all'">
{{ 'WORKSPACE.DASHBOARD.ALL' | translate }}
</span>
</button>
<button mat-menu-item (click)="onKindChange('vod')">
<mat-icon>{{ selectedKind() === 'vod' ? 'check' : '' }}</mat-icon>
<span [class.active-filter]="selectedKind() === 'vod'">
{{ 'WORKSPACE.DASHBOARD.VOD' | translate }}
</span>
</button>
<button mat-menu-item (click)="onKindChange('series')">
<mat-icon>{{
selectedKind() === 'series' ? 'check' : ''
}}</mat-icon>
<span [class.active-filter]="selectedKind() === 'series'">
{{ 'WORKSPACE.DASHBOARD.SERIES' | translate }}
</span>
</button>
</mat-menu>
<mat-button-toggle-group
class="view-toggle"
[value]="viewMode()"
hideSingleSelectionIndicator
[attr.aria-label]="
'WORKSPACE.DASHBOARD.RECENTLY_ADDED_VIEW_MODE' | translate
"
(change)="onViewModeChange($event.value)"
>
<mat-button-toggle
value="list"
[aria-label]="'WORKSPACE.DASHBOARD.LIST_VIEW' | translate"
>
<mat-icon>view_list</mat-icon>
</mat-button-toggle>
<mat-button-toggle
value="grid"
[aria-label]="'WORKSPACE.DASHBOARD.GRID_VIEW' | translate"
>
<mat-icon>grid_view</mat-icon>
</mat-button-toggle>
</mat-button-toggle-group>
</ng-container>
@if (loading()) {
<div class="feedback-state">
<mat-icon class="feedback-state__icon">schedule</mat-icon>
<p class="feedback-state__title">
{{ 'WORKSPACE.DASHBOARD.LOADING_RECENTLY_ADDED' | translate }}
</p>
</div>
} @else if (loadFailed()) {
<div class="feedback-state">
<mat-icon class="feedback-state__icon">error_outline</mat-icon>
<p class="feedback-state__title">
{{
'WORKSPACE.DASHBOARD.RECENTLY_ADDED_LOAD_ERROR'
| translate
}}
</p>
<p class="feedback-state__hint">
{{
'WORKSPACE.DASHBOARD.RECENTLY_ADDED_LOAD_ERROR_HINT'
| translate
}}
</p>
<button mat-stroked-button type="button" (click)="reloadItems()">
{{ 'DOWNLOADS.RETRY' | translate }}
</button>
</div>
} @else {
<app-dashboard-activity-items
[items]="activityItems()"
[emptyLabel]="'WORKSPACE.DASHBOARD.NO_RECENTLY_ADDED' | translate"
emptyIcon="movie_filter"
[emptyHint]="
'WORKSPACE.DASHBOARD.NO_RECENTLY_ADDED_HINT' | translate
"
[emptyActionLabel]="'WORKSPACE.DASHBOARD.ADD_SOURCE' | translate"
[emptyActionLink]="['/workspace/sources']"
[viewMode]="viewMode()"
/>
}
</app-dashboard-widget-shell>
@@ -0,0 +1,82 @@
:host {
// Recently added / discovery tint — fresh emerald
--app-eyebrow-color: oklch(68% 0.17 155);
--app-widget-border: color-mix(in srgb, oklch(68% 0.17 155) 22%, var(--mat-sys-outline-variant) 78%);
}
.view-toggle {
border-radius: 8px;
height: 32px;
align-items: center;
border: 1px solid var(--mat-sys-outline-variant);
background: var(--mat-sys-surface-container-high);
mat-icon {
font-size: 18px;
width: 18px;
height: 18px;
line-height: 18px;
}
}
::ng-deep .view-toggle {
.mat-button-toggle-label-content {
line-height: 30px !important;
padding: 0 8px !important;
}
.mat-button-toggle-checked .mat-button-toggle-button {
background-color: var(--mat-sys-secondary-container) !important;
}
.mat-button-toggle-checked mat-icon {
color: var(--mat-sys-on-secondary-container) !important;
}
}
.active-filter {
font-weight: 600;
color: var(--mat-sys-primary);
}
.feedback-state {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
padding: 24px 16px;
gap: 8px;
border: 1px dashed var(--mat-sys-outline-variant);
border-radius: 12px;
margin: 8px 0;
background: radial-gradient(
ellipse at 50% 0%,
color-mix(in srgb, var(--mat-sys-primary) 6%, transparent),
transparent 72%
);
color: var(--mat-sys-on-surface-variant);
}
.feedback-state__icon {
font-size: 34px;
width: 34px;
height: 34px;
color: var(--mat-sys-primary);
opacity: 0.6;
}
.feedback-state__title {
margin: 0;
font-size: 0.88rem;
font-weight: 500;
color: var(--mat-sys-on-surface);
}
.feedback-state__hint {
margin: 0;
max-width: 280px;
font-size: 0.78rem;
line-height: 1.45;
}
@@ -0,0 +1,172 @@
import {
Component,
OnInit,
computed,
inject,
input,
signal,
} from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
import { MatButtonModule } from '@angular/material/button';
import { MatButtonToggleModule } from '@angular/material/button-toggle';
import { MatIcon } from '@angular/material/icon';
import { MatMenuModule } from '@angular/material/menu';
import { MatTooltipModule } from '@angular/material/tooltip';
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
import { startWith } from 'rxjs';
import {
DashboardDataService,
DashboardRecentlyAddedFilterKind,
DashboardRecentlyAddedItem,
DashboardWidgetConfig,
} from 'workspace-dashboard-data-access';
import {
DashboardActivityItemViewModel,
DashboardActivityItemsComponent,
} from './dashboard-activity-items.component';
import { DashboardWidgetShellComponent } from './dashboard-widget-shell.component';
const RECENTLY_ADDED_KIND_STORAGE_KEY =
'workspace-dashboard-recently-added-kind';
const RECENTLY_ADDED_VIEW_MODE_STORAGE_KEY =
'workspace-dashboard-recently-added-view-mode';
type DashboardActivityViewMode = 'list' | 'grid';
const QUERY_LIMIT = 200;
const DISPLAY_LIMIT = 25;
@Component({
selector: 'app-recently-added-widget',
imports: [
MatButtonModule,
MatButtonToggleModule,
MatMenuModule,
MatIcon,
MatTooltipModule,
DashboardActivityItemsComponent,
DashboardWidgetShellComponent,
TranslatePipe,
],
templateUrl: './recently-added-widget.component.html',
styleUrl: './recently-added-widget.component.scss',
})
export class RecentlyAddedWidgetComponent implements OnInit {
readonly widget = input.required<DashboardWidgetConfig>();
readonly data = inject(DashboardDataService);
private readonly translate = inject(TranslateService);
private readonly languageTick = toSignal(
this.translate.onLangChange.pipe(startWith(null)),
{ initialValue: null }
);
readonly selectedKind = signal<DashboardRecentlyAddedFilterKind>(
this.readStoredKind() ?? 'all'
);
readonly viewMode = signal<DashboardActivityViewMode>(
this.readStoredViewMode() ?? 'list'
);
readonly items = signal<DashboardRecentlyAddedItem[]>([]);
readonly loading = signal(true);
readonly loadFailed = signal(false);
readonly activityItems = computed<DashboardActivityItemViewModel[]>(() =>
this.items()
.slice(0, DISPLAY_LIMIT)
.map((item) => ({
id: `${item.id}-${item.playlist_id}-${item.added_at}`,
title: item.title,
subtitle: `${this.data.getRecentlyAddedItemProviderLabel(item)} · ${this.data.getRecentlyAddedItemTypeLabel(item)} · ${this.formatTimestamp(item.added_at)}`,
type: item.type,
imageUrl: item.poster_url,
link: this.data.getRecentlyAddedLink(item),
navigationState:
this.data.getRecentlyAddedNavigationState(item),
}))
);
ngOnInit(): void {
void this.reloadItems();
}
onKindChange(kind: DashboardRecentlyAddedFilterKind): void {
this.selectedKind.set(kind);
this.persistKind(kind);
void this.reloadItems();
}
onViewModeChange(value: unknown): void {
if (value === 'grid' || value === 'list') {
this.viewMode.set(value);
this.persistViewMode(value);
}
}
async reloadItems(): Promise<void> {
this.loading.set(true);
this.loadFailed.set(false);
try {
const items = await this.data.getGlobalRecentlyAddedItems(
this.selectedKind(),
QUERY_LIMIT
);
this.items.set(items);
} catch (error) {
console.warn(
'[RecentlyAddedWidget] Failed to load recently added items',
error
);
this.items.set([]);
this.loadFailed.set(true);
} finally {
this.loading.set(false);
}
}
private readStoredKind(): DashboardRecentlyAddedFilterKind | null {
if (typeof localStorage === 'undefined') {
return null;
}
const value = localStorage.getItem(RECENTLY_ADDED_KIND_STORAGE_KEY);
return value === 'vod' || value === 'series' || value === 'all'
? value
: null;
}
private persistKind(kind: DashboardRecentlyAddedFilterKind): void {
if (typeof localStorage === 'undefined') {
return;
}
localStorage.setItem(RECENTLY_ADDED_KIND_STORAGE_KEY, kind);
}
private readStoredViewMode(): DashboardActivityViewMode | null {
if (typeof localStorage === 'undefined') {
return null;
}
const value = localStorage.getItem(
RECENTLY_ADDED_VIEW_MODE_STORAGE_KEY
);
return value === 'grid' || value === 'list' ? value : null;
}
private persistViewMode(viewMode: DashboardActivityViewMode): void {
if (typeof localStorage === 'undefined') {
return;
}
localStorage.setItem(RECENTLY_ADDED_VIEW_MODE_STORAGE_KEY, viewMode);
}
private formatTimestamp(value?: string | number): string {
this.languageTick();
const timestamp = this.data.formatTimestamp(value);
return timestamp ===
this.translate.instant('WORKSPACE.DASHBOARD.NOT_YET_SYNCED')
? this.translate.instant('WORKSPACE.DASHBOARD.RECENTLY')
: timestamp;
}
}