mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-12 03:56:15 -08:00
feat: add recently added widget with filtering and view mode options
This commit is contained in:
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;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user