From 43426114d61728f3d99c20e6719f2c4a4f7a005e Mon Sep 17 00:00:00 2001 From: 4gray Date: Sat, 28 Mar 2026 15:35:19 +0100 Subject: [PATCH] feat: add recently added widget with filtering and view mode options --- .../recently-added-widget.component.html | 102 +++++++++++ .../recently-added-widget.component.scss | 82 +++++++++ .../recently-added-widget.component.ts | 172 ++++++++++++++++++ 3 files changed, 356 insertions(+) create mode 100644 libs/workspace/dashboard/ui/src/lib/widgets/recently-added-widget.component.html create mode 100644 libs/workspace/dashboard/ui/src/lib/widgets/recently-added-widget.component.scss create mode 100644 libs/workspace/dashboard/ui/src/lib/widgets/recently-added-widget.component.ts diff --git a/libs/workspace/dashboard/ui/src/lib/widgets/recently-added-widget.component.html b/libs/workspace/dashboard/ui/src/lib/widgets/recently-added-widget.component.html new file mode 100644 index 000000000..535788fe1 --- /dev/null +++ b/libs/workspace/dashboard/ui/src/lib/widgets/recently-added-widget.component.html @@ -0,0 +1,102 @@ + + + + + + + + + + + + view_list + + + grid_view + + + + + @if (loading()) { + + } @else if (loadFailed()) { + + } @else { + + } + diff --git a/libs/workspace/dashboard/ui/src/lib/widgets/recently-added-widget.component.scss b/libs/workspace/dashboard/ui/src/lib/widgets/recently-added-widget.component.scss new file mode 100644 index 000000000..51fa43511 --- /dev/null +++ b/libs/workspace/dashboard/ui/src/lib/widgets/recently-added-widget.component.scss @@ -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; +} diff --git a/libs/workspace/dashboard/ui/src/lib/widgets/recently-added-widget.component.ts b/libs/workspace/dashboard/ui/src/lib/widgets/recently-added-widget.component.ts new file mode 100644 index 000000000..9f936daf4 --- /dev/null +++ b/libs/workspace/dashboard/ui/src/lib/widgets/recently-added-widget.component.ts @@ -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(); + readonly data = inject(DashboardDataService); + private readonly translate = inject(TranslateService); + private readonly languageTick = toSignal( + this.translate.onLangChange.pipe(startWith(null)), + { initialValue: null } + ); + + readonly selectedKind = signal( + this.readStoredKind() ?? 'all' + ); + readonly viewMode = signal( + this.readStoredViewMode() ?? 'list' + ); + readonly items = signal([]); + readonly loading = signal(true); + readonly loadFailed = signal(false); + + readonly activityItems = computed(() => + 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 { + 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; + } +}