diff --git a/apps/web/src/app/workspace/dashboard-placeholder.component.html b/apps/web/src/app/workspace/dashboard-placeholder.component.html new file mode 100644 index 000000000..e280b0b6b --- /dev/null +++ b/apps/web/src/app/workspace/dashboard-placeholder.component.html @@ -0,0 +1,161 @@ +
+ +
+
+
+

+ + Workspace +

+

Your IPTV sources,
one place.

+

+ Jump back into your active source, switch providers + instantly, or explore what's new across your library. +

+ +
+ +
+
+ + +
+
+
+ folder_open +
+
+ {{ stats().total }} + Total Sources +
+
+ +
+
+ stream +
+
+ {{ stats().xtream }} + Xtream +
+
+ +
+
+ router +
+
+ {{ stats().stalker }} + Stalker +
+
+ +
+
+ playlist_play +
+
+ {{ stats().m3u }} + M3U +
+
+
+ + +
+
+

Recent Sources

+ + arrow_forward + View all + +
+ + @if (recentPlaylists().length > 0) { + + } @else { +
+
+ library_add +
+

No sources yet

+

Add a playlist in Library to start using the workspace.

+ + add + Add Source + +
+ } +
+
diff --git a/apps/web/src/app/workspace/dashboard-placeholder.component.scss b/apps/web/src/app/workspace/dashboard-placeholder.component.scss new file mode 100644 index 000000000..9c997d50e --- /dev/null +++ b/apps/web/src/app/workspace/dashboard-placeholder.component.scss @@ -0,0 +1,439 @@ +:host { + display: block; + min-height: 100%; + padding: 20px; + box-sizing: border-box; + background: var(--app-content-bg, var(--mat-sys-surface)); + color: var(--app-heading-color, var(--mat-sys-on-surface)); +} + +.dashboard-page { + max-width: 1280px; + margin: 0 auto; + display: flex; + flex-direction: column; + gap: 16px; +} + +// ─── Hero ───────────────────────────────────────────────────────────────────── +.hero { + border-radius: 16px; + padding: 28px; + background: var(--app-widget-bg, var(--mat-sys-surface-container-high)); + background-image: var(--app-accent-gradient); + border: none; + position: relative; + overflow: hidden; +} + +.hero-content { + display: flex; + align-items: center; + gap: 24px; +} + +.hero-text { + flex: 1; +} + +.eyebrow { + margin: 0 0 10px; + display: flex; + align-items: center; + gap: 7px; + text-transform: uppercase; + letter-spacing: 0.1em; + font-size: 0.72rem; + font-weight: 600; + color: var(--app-eyebrow-color, var(--mat-sys-on-surface-variant)); +} + +.eyebrow-dot { + width: 6px; + height: 6px; + border-radius: 50%; + background: var(--mat-sys-primary); + flex-shrink: 0; +} + +h1 { + margin: 0 0 12px; + font-size: clamp(1.5rem, 2.2vw, 2rem); + line-height: 1.2; + font-weight: 700; + letter-spacing: -0.02em; + color: var(--app-heading-color, var(--mat-sys-on-surface)); +} + +.description { + margin: 0; + max-width: 560px; + line-height: 1.55; + font-size: 0.9rem; + color: var(--app-body-color, var(--mat-sys-on-surface-variant)); +} + +.hero-actions { + margin-top: 20px; + display: flex; + flex-wrap: wrap; + gap: 10px; + + a { + text-decoration: none; + } +} + +// Visual accent element on the right +.hero-visual { + flex-shrink: 0; + display: flex; + align-items: center; + justify-content: center; +} + +.hero-icon-ring { + width: 80px; + height: 80px; + border-radius: 24px; + background: var(--mat-sys-primary-container); + display: flex; + align-items: center; + justify-content: center; + border: 1px solid var(--mat-sys-outline-variant); + + mat-icon { + font-size: 36px; + width: 36px; + height: 36px; + color: var(--mat-sys-on-primary-container); + } +} + +// ─── Stat Grid ──────────────────────────────────────────────────────────────── +.stat-grid { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 12px; +} + +.stat-card { + border-radius: 14px; + padding: 16px; + background: var(--app-widget-bg, var(--mat-sys-surface-container)); + border: none; + box-shadow: none; + display: flex; + align-items: center; + gap: 14px; + transition: + transform 0.15s ease, + background-color 0.15s ease; + overflow: hidden; + position: relative; + + // Top accent line per type + &::before { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + height: 2px; + } + + &:hover { + transform: translateY(-2px); + background: var(--app-card-hover-bg, var(--mat-sys-surface-container-high)); + } +} + +.stat-card--total::before { + background: var(--mat-sys-primary); +} +.stat-card--xtream::before { + background: var(--app-provider-xtream, #818cf8); +} +.stat-card--stalker::before { + background: var(--app-provider-stalker, #34d399); +} +.stat-card--m3u::before { + background: var(--app-provider-m3u, #fbbf24); +} + +.stat-card__icon { + width: 40px; + height: 40px; + border-radius: 10px; + display: flex; + align-items: center; + justify-content: center; + background: var(--mat-sys-surface-container-high); + flex-shrink: 0; + + mat-icon { + font-size: 20px; + width: 20px; + height: 20px; + color: var(--mat-sys-on-surface-variant); + } +} + +.stat-card--xtream .stat-card__icon mat-icon { + color: var(--app-provider-xtream, #818cf8); +} +.stat-card--stalker .stat-card__icon mat-icon { + color: var(--app-provider-stalker, #34d399); +} +.stat-card--m3u .stat-card__icon mat-icon { + color: var(--app-provider-m3u, #fbbf24); +} + +.stat-card__body { + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; +} + +.stat-card__value { + font-size: 1.6rem; + font-weight: 700; + line-height: 1; + letter-spacing: -0.03em; + color: var(--app-heading-color, var(--mat-sys-on-surface)); +} + +.stat-card__label { + font-size: 0.8rem; + color: var(--app-body-color, var(--mat-sys-on-surface-variant)); + font-weight: 500; +} + +// ─── Sources Section ────────────────────────────────────────────────────────── +.sources-section { + border-radius: 14px; + padding: 18px; + background: var(--app-widget-bg, var(--mat-sys-surface-container)); + border: none; + box-shadow: none; +} + +.section-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + margin-bottom: 14px; + + h2 { + margin: 0; + font-size: 0.94rem; + font-weight: 600; + letter-spacing: -0.01em; + color: var(--app-heading-color, var(--mat-sys-on-surface)); + } +} + +.section-link { + display: flex; + align-items: center; + gap: 4px; + color: var(--mat-sys-primary); + text-decoration: none; + font-size: 0.82rem; + font-weight: 500; + transition: opacity 0.15s ease; + + &:hover { + opacity: 0.75; + } + + mat-icon { + font-size: 16px; + width: 16px; + height: 16px; + } +} + +// ─── Source Cards ───────────────────────────────────────────────────────────── +.sources-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); + gap: 10px; +} + +.source-card { + border-radius: 12px; + padding: 14px; + border: 1px solid var(--mat-sys-outline-variant); + background: var(--mat-sys-surface-container-low); + text-decoration: none; + color: inherit; + display: flex; + flex-direction: column; + gap: 8px; + min-width: 0; + overflow: hidden; + transition: + border-color 0.15s ease, + background-color 0.15s ease, + transform 0.15s ease, + box-shadow 0.15s ease; + + &:hover { + border-color: var(--mat-sys-primary); + background: var(--app-card-hover-bg, var(--mat-sys-surface-container-high)); + transform: translateY(-1px); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); + } +} + +.source-card__header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + min-width: 0; +} + +// Provider Badges +.provider-badge { + display: inline-flex; + align-items: center; + padding: 2px 8px; + border-radius: 999px; + font-size: 0.68rem; + font-weight: 700; + letter-spacing: 0.06em; + text-transform: uppercase; + + &--xtream { + background: rgba(129, 140, 248, 0.14); + color: var(--app-provider-xtream, #818cf8); + } + + &--stalker { + background: rgba(52, 211, 153, 0.12); + color: var(--app-provider-stalker, #34d399); + } + + &--m3u { + background: rgba(251, 191, 36, 0.12); + color: var(--app-provider-m3u, #fbbf24); + } +} + +.source-card__count { + font-size: 0.72rem; + color: var(--mat-sys-on-surface-variant); +} + +.source-card h3 { + margin: 0; + font-size: 0.9rem; + font-weight: 500; + line-height: 1.25; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.source-card__date { + margin: 0; + display: flex; + align-items: center; + gap: 4px; + font-size: 0.75rem; + color: var(--mat-sys-on-surface-variant); + + mat-icon { + font-size: 13px; + width: 13px; + height: 13px; + flex-shrink: 0; + } +} + +// ─── Empty State ────────────────────────────────────────────────────────────── +.empty-state { + border-radius: 14px; + border: 1px dashed var(--mat-sys-outline); + padding: 32px 24px; + text-align: center; + display: flex; + flex-direction: column; + align-items: center; + gap: 10px; + background: var(--mat-sys-surface-container-low); + + h3 { + margin: 0; + font-size: 1rem; + font-weight: 600; + } + + p { + margin: 0 0 6px; + color: var(--mat-sys-on-surface-variant); + font-size: 0.875rem; + max-width: 340px; + line-height: 1.5; + } + + a { + text-decoration: none; + } +} + +.empty-state__icon { + width: 56px; + height: 56px; + border-radius: 16px; + background: var(--mat-sys-surface-container-high); + display: flex; + align-items: center; + justify-content: center; + + mat-icon { + font-size: 28px; + width: 28px; + height: 28px; + color: var(--mat-sys-on-surface-variant); + } +} + +// ─── Responsive ─────────────────────────────────────────────────────────────── +@media (max-width: 1024px) { + .stat-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} + +@media (max-width: 780px) { + .hero-visual { + display: none; + } + + .stat-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .sources-grid { + grid-template-columns: 1fr; + } +} + +@media (max-width: 560px) { + :host { + padding: 12px; + } + + .hero { + padding: 20px; + } + + .stat-grid { + grid-template-columns: 1fr 1fr; + } +} diff --git a/apps/web/src/app/workspace/dashboard-placeholder.component.ts b/apps/web/src/app/workspace/dashboard-placeholder.component.ts new file mode 100644 index 000000000..a7de9e4cf --- /dev/null +++ b/apps/web/src/app/workspace/dashboard-placeholder.component.ts @@ -0,0 +1,98 @@ +import { DecimalPipe } from '@angular/common'; +import { Component, computed, inject } from '@angular/core'; +import { MatButton } from '@angular/material/button'; +import { MatIcon } from '@angular/material/icon'; +import { RouterLink } from '@angular/router'; +import { Store } from '@ngrx/store'; +import { selectActivePlaylist, selectAllPlaylistsMeta } from 'm3u-state'; +import { PlaylistMeta } from 'shared-interfaces'; + +@Component({ + selector: 'app-dashboard-placeholder', + imports: [DecimalPipe, MatButton, MatIcon, RouterLink], + templateUrl: './dashboard-placeholder.component.html', + styleUrl: './dashboard-placeholder.component.scss', +}) +export class DashboardPlaceholderComponent { + private readonly store = inject(Store); + + readonly activePlaylist = this.store.selectSignal(selectActivePlaylist); + readonly playlists = this.store.selectSignal(selectAllPlaylistsMeta); + + readonly stats = computed(() => { + const items = this.playlists(); + return { + total: items.length, + xtream: items.filter((item) => !!item.serverUrl).length, + stalker: items.filter((item) => !!item.macAddress).length, + m3u: items.filter((item) => !item.serverUrl && !item.macAddress) + .length, + }; + }); + + readonly recentPlaylists = computed(() => + [...this.playlists()] + .sort( + (a, b) => + this.getRecentTimestamp(b) - this.getRecentTimestamp(a) + ) + .slice(0, 8) + ); + + getPlaylistLink(playlist: PlaylistMeta): string[] { + if (playlist.serverUrl) { + return ['/workspace', 'xtreams', playlist._id, 'vod']; + } + + if (playlist.macAddress) { + return ['/workspace', 'stalker', playlist._id, 'vod']; + } + + return ['/workspace', 'playlists', playlist._id]; + } + + getPlaylistProvider(playlist: PlaylistMeta): 'Xtream' | 'Stalker' | 'M3U' { + if (playlist.serverUrl) { + return 'Xtream'; + } + + if (playlist.macAddress) { + return 'Stalker'; + } + + return 'M3U'; + } + + trackByPlaylistId(_: number, item: PlaylistMeta): string { + return item._id; + } + + formatTimestamp(value?: string | number): string { + const timestamp = this.toTimestamp(value); + if (!timestamp) { + return 'Not yet synced'; + } + + return new Date(timestamp).toLocaleString(); + } + + private getRecentTimestamp(item: PlaylistMeta): number { + return ( + this.toTimestamp(item.updateDate) || + this.toTimestamp(item.importDate) + ); + } + + private toTimestamp(value?: string | number): number { + if (typeof value === 'number') { + return value; + } + + if (typeof value === 'string') { + const parsed = Date.parse(value); + return Number.isNaN(parsed) ? 0 : parsed; + } + + return 0; + } +} diff --git a/apps/web/src/app/workspace/favorites-context.service.ts b/apps/web/src/app/workspace/favorites-context.service.ts new file mode 100644 index 000000000..fc4186e4d --- /dev/null +++ b/apps/web/src/app/workspace/favorites-context.service.ts @@ -0,0 +1,35 @@ +import { Injectable, signal } from '@angular/core'; +import { XtreamCategory } from 'shared-interfaces'; + +/** + * Lightweight service that bridges the favorites/recently-viewed routed + * components (inside the router-outlet) with the WorkspaceShellComponent + * (the outer shell that renders the context panel aside). + * + * The routed component pushes its category list here via an effect; + * the workspace shell reads it to populate the context panel. + */ +@Injectable({ providedIn: 'root' }) +export class FavoritesContextService { + /** Category list managed by the active routed component (Favorites / RecentlyViewed). */ + readonly categories = signal([]); + + /** Currently selected category id — shared between panel and content. */ + readonly selectedCategoryId = signal('all'); + + /** Called by the context panel when the user clicks a category. */ + setCategoryId(id: string): void { + this.selectedCategoryId.set(id); + } + + /** Called by the routed component to push its category list into the panel. */ + setCategories(categories: XtreamCategory[]): void { + this.categories.set(categories); + } + + /** Reset to defaults when navigating away from favorites/recent sections. */ + reset(): void { + this.categories.set([]); + this.selectedCategoryId.set('all'); + } +} diff --git a/apps/web/src/app/workspace/settings-context.service.ts b/apps/web/src/app/workspace/settings-context.service.ts new file mode 100644 index 000000000..f06fe27ec --- /dev/null +++ b/apps/web/src/app/workspace/settings-context.service.ts @@ -0,0 +1,26 @@ +import { Injectable, signal } from '@angular/core'; + +export interface SettingsNavItem { + id: string; + label: string; + icon: string; +} + +@Injectable({ providedIn: 'root' }) +export class SettingsContextService { + readonly sections = signal([]); + readonly activeSection = signal('general'); + + setSections(items: SettingsNavItem[]): void { + this.sections.set(items); + } + + setActiveSection(id: string): void { + this.activeSection.set(id); + } + + reset(): void { + this.sections.set([]); + this.activeSection.set('general'); + } +} diff --git a/apps/web/src/app/workspace/workspace-context-panel.component.html b/apps/web/src/app/workspace/workspace-context-panel.component.html new file mode 100644 index 000000000..61bff7378 --- /dev/null +++ b/apps/web/src/app/workspace/workspace-context-panel.component.html @@ -0,0 +1,55 @@ +
+
+
+

{{ title() }}

+ @if (isXtreamCategories()) { + + } +
+ @if (subtitle(); as text) { + {{ text }} + } +
+ +
+ + @if (isXtreamCategories()) { + + } @else if (isStalkerCategories()) { + @if (isStalkerCategoryLoading()) { + + } @else if (isStalkerCategoryFailed()) { + + } @else { + + } + } +
diff --git a/apps/web/src/app/workspace/workspace-context-panel.component.scss b/apps/web/src/app/workspace/workspace-context-panel.component.scss new file mode 100644 index 000000000..6393e49bb --- /dev/null +++ b/apps/web/src/app/workspace/workspace-context-panel.component.scss @@ -0,0 +1,99 @@ +:host { + display: flex; + flex-direction: column; + min-height: 0; + height: 100%; + overflow: hidden; +} + +.context-column { + display: flex; + flex-direction: column; + min-height: 0; + height: 100%; + overflow: hidden; +} + +// ─── Header ─────────────────────────────────────────────────────────────────── +.context-header { + padding: 4px 6px 10px; + flex-shrink: 0; +} + +.context-header__top { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + + h2 { + margin: 0; + font-size: 0.94rem; + font-weight: 600; + letter-spacing: -0.01em; + color: var(--mat-sys-on-surface); + } + + button[mat-icon-button] { + width: 32px; + height: 32px; + line-height: 32px; + border-radius: 8px; + color: var(--mat-sys-on-surface-variant); + transition: background 0.15s ease, color 0.15s ease; + + &:hover { + background: var(--mat-sys-surface-container); + color: var(--mat-sys-on-surface); + } + + mat-icon { + font-size: 18px; + width: 18px; + height: 18px; + } + } +} + +.context-header__badge { + display: inline-flex; + align-items: center; + gap: 4px; + margin-top: 5px; + padding: 2px 8px; + border-radius: 999px; + background: var(--mat-sys-surface-container); + color: var(--mat-sys-on-surface-variant); + font-size: 0.72rem; + font-weight: 500; + letter-spacing: 0.04em; + text-transform: uppercase; +} + +.context-header p { + margin: 4px 0 0; + font-size: 0.78rem; + color: var(--mat-sys-on-surface-variant); +} + +// ─── Divider ────────────────────────────────────────────────────────────────── +.context-divider { + height: 1px; + background: var(--mat-sys-outline-variant); + margin: 0 0 6px; + opacity: 0.6; + flex-shrink: 0; +} + +// ─── Category List ──────────────────────────────────────────────────────────── +app-category-view { + display: block; + min-height: 0; + height: 100%; + flex: 1; + overflow-y: auto; + overflow-x: hidden; + overscroll-behavior: contain; + scrollbar-width: thin; + scrollbar-color: var(--mat-sys-outline-variant) transparent; +} diff --git a/apps/web/src/app/workspace/workspace-context-panel.component.ts b/apps/web/src/app/workspace/workspace-context-panel.component.ts new file mode 100644 index 000000000..c96c8474a --- /dev/null +++ b/apps/web/src/app/workspace/workspace-context-panel.component.ts @@ -0,0 +1,196 @@ +import { Component, computed, inject, input } from '@angular/core'; +import { MatDialog } from '@angular/material/dialog'; +import { MatIconButton } from '@angular/material/button'; +import { MatIcon } from '@angular/material/icon'; +import { MatTooltip } from '@angular/material/tooltip'; +import { Router } from '@angular/router'; +import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader'; +import { PlaylistErrorViewComponent } from '../xtream-electron/playlist-error-view/playlist-error-view.component'; +import { + CategoryManagementDialogComponent, + CategoryManagementDialogData, +} from '../xtream-electron/category-management-dialog/category-management-dialog.component'; +import { CategoryViewComponent } from '../xtream-electron/category-view/category-view.component'; +import { XtreamStore } from '../xtream-electron/stores/xtream.store'; +import { StalkerStore } from '../stalker/stalker.store'; + +type WorkspaceProvider = 'xtreams' | 'stalker' | 'playlists'; + +interface WorkspaceContextRoute { + provider: WorkspaceProvider; + playlistId: string; +} + +interface XtreamCategoryLike { + id?: number | string; + category_id?: string; +} + +@Component({ + selector: 'app-workspace-context-panel', + imports: [ + CategoryViewComponent, + MatIcon, + MatIconButton, + MatTooltip, + NgxSkeletonLoaderModule, + PlaylistErrorViewComponent, + ], + templateUrl: './workspace-context-panel.component.html', + styleUrl: './workspace-context-panel.component.scss', +}) +export class WorkspaceContextPanelComponent { + private readonly router = inject(Router); + private readonly dialog = inject(MatDialog); + private readonly xtreamStore = inject(XtreamStore); + private readonly stalkerStore = inject(StalkerStore); + + readonly context = input.required(); + readonly section = input.required(); + + readonly isXtreamCategories = computed( + () => + this.context().provider === 'xtreams' && + (this.section() === 'vod' || + this.section() === 'series' || + this.section() === 'live') + ); + readonly isStalkerCategories = computed( + () => + this.context().provider === 'stalker' && + (this.section() === 'vod' || + this.section() === 'series' || + this.section() === 'itv') + ); + + readonly xtreamCategories = this.xtreamStore.getCategoriesBySelectedType; + readonly xtreamCategoryItemCounts = this.xtreamStore.getCategoryItemCounts; + readonly xtreamSelectedCategoryId = this.xtreamStore.selectedCategoryId; + + readonly stalkerCategories = this.stalkerStore.getCategoryResource; + readonly stalkerSelectedCategoryId = this.stalkerStore.selectedCategoryId; + readonly isStalkerCategoryLoading = this.stalkerStore.isCategoryResourceLoading; + readonly isStalkerCategoryFailed = this.stalkerStore.isCategoryResourceFailed; + + readonly title = computed(() => { + if (this.isXtreamCategories()) { + if (this.section() === 'vod') return 'Movie Categories'; + if (this.section() === 'series') return 'Series Categories'; + return 'Live Categories'; + } + + if (this.isStalkerCategories()) { + if (this.section() === 'vod') return 'Movie Categories'; + if (this.section() === 'series') return 'Series Categories'; + return 'Live Categories'; + } + + return 'Categories'; + }); + + readonly subtitle = computed(() => { + if (this.isXtreamCategories()) { + return 'Xtream source'; + } + if (this.isStalkerCategories()) { + return 'Stalker portal'; + } + return ''; + }); + + onXtreamCategoryClicked(category: XtreamCategoryLike): void { + const context = this.context(); + const section = this.section(); + const rawCategoryId = category.category_id ?? category.id; + if (rawCategoryId === undefined || rawCategoryId === null) { + return; + } + const numericCategoryId = Number(rawCategoryId); + if (Number.isNaN(numericCategoryId)) { + return; + } + const categoryId = numericCategoryId; + + this.xtreamStore.setSelectedItem(null); + this.xtreamStore.setSelectedCategory(categoryId); + + if (section === 'live') { + return; + } + + this.router.navigate([ + '/workspace', + 'xtreams', + context.playlistId, + section, + categoryId, + ]); + } + + onStalkerCategoryClicked(item: { category_id?: string | number }): void { + const context = this.context(); + const section = this.section(); + const categoryId = String(item.category_id ?? '*'); + + this.stalkerStore.setSelectedCategory(categoryId); + this.stalkerStore.setPage(0); + this.stalkerStore.clearSelectedItem(); + + if (section === 'itv') { + return; + } + + if (categoryId === '*') { + this.router.navigate([ + '/workspace', + 'stalker', + context.playlistId, + section, + ]); + return; + } + + this.router.navigate([ + '/workspace', + 'stalker', + context.playlistId, + section, + categoryId, + ]); + } + + openXtreamCategoryManagement(): void { + const context = this.context(); + const section = this.section(); + if (!this.isXtreamCategories()) { + return; + } + + const contentType = + section === 'series' + ? 'series' + : section === 'live' + ? 'live' + : 'vod'; + + const dialogRef = this.dialog.open< + CategoryManagementDialogComponent, + CategoryManagementDialogData, + boolean + >(CategoryManagementDialogComponent, { + data: { + playlistId: context.playlistId, + contentType, + itemCounts: this.xtreamCategoryItemCounts(), + }, + width: '500px', + maxHeight: '80vh', + }); + + dialogRef.afterClosed().subscribe((result) => { + if (result) { + this.xtreamStore.reloadCategories(); + } + }); + } +} diff --git a/apps/web/src/app/workspace/workspace-favorites-context-panel.component.ts b/apps/web/src/app/workspace/workspace-favorites-context-panel.component.ts new file mode 100644 index 000000000..bacd5f08d --- /dev/null +++ b/apps/web/src/app/workspace/workspace-favorites-context-panel.component.ts @@ -0,0 +1,63 @@ +import { Component, computed, inject } from '@angular/core'; +import { CategoryViewComponent } from '../xtream-electron/category-view/category-view.component'; +import { FavoritesContextService } from './favorites-context.service'; + +/** + * Context panel rendered in the workspace-shell aside for + * favorites and recently-viewed sections. + * + * Reads categories from FavoritesContextService (populated by the + * routed FavoritesComponent / RecentlyViewedComponent) and emits + * category selections back through the same service. + */ +@Component({ + selector: 'app-workspace-favorites-context-panel', + imports: [CategoryViewComponent], + template: ` +
+
+
+

{{ title() }}

+
+ @if (countBadge(); as text) { + {{ text }} + } +
+ +
+ + +
+ `, + styleUrl: './workspace-context-panel.component.scss', +}) +export class WorkspaceFavoritesContextPanelComponent { + readonly ctx = inject(FavoritesContextService); + + readonly title = computed(() => { + const categories = this.ctx.categories(); + if (!categories.length) return 'Filter by type'; + const selected = categories.find( + (c: any) => c.category_id === this.ctx.selectedCategoryId() + ) as any; + return selected?.category_name ?? 'Filter by type'; + }); + + readonly countBadge = computed(() => { + const categories = this.ctx.categories(); + const selected = categories.find( + (c: any) => c.category_id === this.ctx.selectedCategoryId() + ) as any; + if (!selected || selected.count === undefined) return ''; + return `${selected.count} ${selected.count === 1 ? 'item' : 'items'}`; + }); + + onCategoryClicked(item: { category_id?: string | number }): void { + const id = String(item.category_id ?? 'all'); + this.ctx.setCategoryId(id); + } +} diff --git a/apps/web/src/app/workspace/workspace-settings-context-panel.component.ts b/apps/web/src/app/workspace/workspace-settings-context-panel.component.ts new file mode 100644 index 000000000..0d35cb29e --- /dev/null +++ b/apps/web/src/app/workspace/workspace-settings-context-panel.component.ts @@ -0,0 +1,77 @@ +import { Location } from '@angular/common'; +import { Component, inject } from '@angular/core'; +import { MatIconModule } from '@angular/material/icon'; +import { TranslateModule } from '@ngx-translate/core'; +import { SettingsContextService } from './settings-context.service'; + +@Component({ + selector: 'app-workspace-settings-context-panel', + imports: [MatIconModule, TranslateModule], + styles: [ + ` + :host { + display: flex; + flex-direction: column; + height: 100%; + overflow: hidden; + } + + .panel-title { + margin: 0; + padding: 16px 16px 0; + font-size: 20px; + font-weight: 500; + color: var(--mat-sys-on-surface); + } + + .panel-header { + padding: 16px 16px 10px; + font-size: 11px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.12em; + opacity: 0.6; + flex-shrink: 0; + } + `, + ], + template: ` +

{{ 'SETTINGS.TITLE' | translate }}

+ + `, +}) +export class WorkspaceSettingsContextPanelComponent { + readonly ctx = inject(SettingsContextService); + private readonly location = inject(Location); + + onBack() { + this.location.back(); + } +} diff --git a/apps/web/src/app/workspace/workspace-shell.component.html b/apps/web/src/app/workspace/workspace-shell.component.html new file mode 100644 index 000000000..a65b66dea --- /dev/null +++ b/apps/web/src/app/workspace/workspace-shell.component.html @@ -0,0 +1,253 @@ +
+ + +
+ + + + +
+ + + @if (canOpenPlaylistInfo()) { + + } + @if (canOpenAccountInfo()) { + + } + @if (downloadsContext()) { + + } + @if (headerBulkAction(); as action) { + + } + + @if (canSort()) { + + + + + + + + } +
+
+ +
+ @if (isSourcesRoute()) { + + } @else if (isCategoryContextRoute()) { + @if (currentContext(); as context) { + @if (currentSection(); as section) { + + } + } + } @else if (isSettingsRoute()) { + + } @else if (isFavoritesContextRoute()) { + + } + +
+ +
+
+
diff --git a/apps/web/src/app/workspace/workspace-shell.component.scss b/apps/web/src/app/workspace/workspace-shell.component.scss new file mode 100644 index 000000000..c1519fd33 --- /dev/null +++ b/apps/web/src/app/workspace/workspace-shell.component.scss @@ -0,0 +1,442 @@ +:host { + display: block; + width: 100%; + height: 100%; + min-height: 0; +} + +// ─── Shell Layout ───────────────────────────────────────────────────────────── +.workspace-shell { + display: grid; + grid-template-columns: 60px 1fr; + grid-template-rows: 56px 1fr; + width: 100%; + height: 100%; + min-height: 0; + overflow: hidden; + background: var(--app-shell-bg, var(--mat-sys-surface)); + color: var(--mat-sys-on-surface); +} + +// ─── App Rail (Sidebar) ─────────────────────────────────────────────────────── +.app-rail { + grid-column: 1; + grid-row: 1 / span 2; + display: flex; + flex-direction: column; + align-items: center; + gap: 2px; + padding: 10px 8px; + border-right: 1px solid var(--app-rail-border, var(--mat-sys-outline-variant)); + background: var(--app-rail-bg, var(--mat-sys-surface-container-low)); + app-region: drag; + overflow-y: auto; + overflow-x: hidden; + scrollbar-width: none; + + &.is-macos { + padding-top: 36px; + } + + &::-webkit-scrollbar { + display: none; + } +} + +.app-rail { + --rail-link-color: var(--mat-sys-on-surface-variant); + --rail-link-hover-color: var(--mat-sys-on-surface); + --rail-link-hover-bg: var(--mat-sys-surface-container); + --rail-link-active-bg: color-mix(in srgb, var(--mat-sys-primary) 12%, transparent); + --rail-link-active-color: var(--mat-sys-primary); + --rail-link-active-icon: var(--mat-sys-primary); + --rail-link-active-border: color-mix(in srgb, var(--mat-sys-primary) 42%, transparent); + --rail-link-active-glow: color-mix(in srgb, var(--mat-sys-primary) 24%, transparent); +} + +.app-rail a { + width: 44px; + height: 44px; + border-radius: 14px; + position: relative; + display: inline-flex; + align-items: center; + justify-content: center; + color: var(--rail-link-color); + text-decoration: none; + transition: background-color 0.2s ease, color 0.2s ease, box-shadow 0.2s ease; + app-region: no-drag; + + mat-icon { + font-size: 20px; + width: 20px; + height: 20px; + } +} + +.app-rail a:hover { + color: var(--rail-link-hover-color); + background: var(--rail-link-hover-bg); +} + +.app-rail a.is-active, +.app-rail a.active { + color: var(--rail-link-active-color); + background: var(--rail-link-active-bg); + box-shadow: + inset 0 0 0 1px var(--rail-link-active-border), + 0 10px 20px -16px var(--rail-link-active-glow); + + mat-icon { + color: var(--rail-link-active-icon); + } + + &::after { + content: ''; + position: absolute; + right: -4px; + top: 50%; + transform: translateY(-50%); + width: 3px; + height: 18px; + border-radius: 999px; + background: var(--rail-link-active-icon); + box-shadow: 0 0 10px var(--rail-link-active-glow); + } +} + +// ─── Brand Logo ─────────────────────────────────────────────────────────────── +.brand { + margin-bottom: 10px; + background: var(--mat-sys-primary-container); + color: var(--mat-sys-on-primary-container); + width: 40px; + height: 40px; + border-radius: 12px; + display: inline-flex; + align-items: center; + justify-content: center; +} + +.brand img { + width: 24px; + height: 24px; + object-fit: contain; +} + +// ─── Rail Divider ───────────────────────────────────────────────────────────── +.rail-divider { + width: 24px; + height: 1px; + background: var(--mat-sys-outline-variant); + margin: 6px 0; + opacity: 0.5; +} + +.rail-footer { + margin-top: auto; +} + +// ─── Workspace Header ───────────────────────────────────────────────────────── +.workspace-header { + grid-column: 2; + grid-row: 1; + display: flex; + align-items: center; + gap: 12px; + padding: 0 16px; + height: 56px; + border-bottom: 1px solid var(--mat-sys-outline-variant); + background: var(--app-header-bg, var(--mat-sys-surface-container-low)); + app-region: drag; +} + +.playlist-switcher { + width: min(260px, 32vw); + flex-shrink: 0; + app-region: no-drag; +} + +.settings-header-title { + margin: 0; + font-size: 1.15rem; + font-weight: 700; + color: var(--mat-sys-on-surface); + app-region: no-drag; +} + +// ─── Search Field ───────────────────────────────────────────────────────────── +.search-field { + flex: 1; + min-width: 160px; + height: 36px; + border-radius: 10px; + border: 1px solid var(--app-search-border, var(--mat-sys-outline-variant)); + background: var(--app-search-bg, var(--mat-sys-surface-container)); + display: flex; + align-items: center; + gap: 8px; + padding: 0 12px; + transition: + border-color 0.15s ease, + background-color 0.15s ease, + box-shadow 0.15s ease; + app-region: no-drag; + + &:focus-within { + border-color: var(--mat-sys-primary); + box-shadow: 0 0 0 3px rgba(var(--mat-sys-primary), 0.12); + } + + mat-icon { + font-size: 18px; + width: 18px; + height: 18px; + color: var(--mat-sys-on-surface-variant); + flex-shrink: 0; + } + + input { + flex: 1; + border: none; + outline: none; + background: transparent; + color: var(--mat-sys-on-surface); + font: inherit; + font-size: 0.875rem; + + &:disabled { + cursor: not-allowed; + opacity: 0.5; + } + + &::placeholder { + color: var(--mat-sys-on-surface-variant); + opacity: 0.7; + } + } +} + +// ─── Header Actions ─────────────────────────────────────────────────────────── +.header-actions { + display: flex; + align-items: center; + gap: 2px; + app-region: no-drag; + + button { + color: var(--mat-sys-on-surface-variant); + border-radius: 8px; + transition: + color 0.15s ease, + background 0.15s ease; + + &:hover { + color: var(--mat-sys-on-surface); + background: var(--mat-sys-surface-container); + } + + &.is-active { + color: var(--mat-sys-on-primary-container); + background: var(--mat-sys-primary-container); + } + } +} + +// ─── Workspace Body ─────────────────────────────────────────────────────────── +.workspace-body { + grid-column: 2; + grid-row: 2; + display: grid; + grid-template-columns: auto minmax(0, 1fr); + min-height: 0; + min-width: 0; + overflow: hidden; + background: var(--app-rail-bg, var(--mat-sys-surface-container-low)); + + &.single-pane { + grid-template-columns: 1fr; + } +} + +// ─── Context Panel ──────────────────────────────────────────────────────────── +.context-panel { + border-right: 1px solid var(--mat-sys-outline-variant); + background: var(--app-rail-bg, var(--mat-sys-surface-container-low)); + display: flex; + flex-direction: column; + gap: 6px; + padding: 12px; + width: 300px; + min-width: 0; + min-height: 0; + overflow: hidden; +} + +.context-panel--route { + padding: 12px 10px 10px 12px; + overflow: visible; +} + +.context-panel--sources { + padding: 12px 10px 10px 12px; + overflow: visible; +} + +.context-panel--route app-workspace-context-panel { + display: flex; + flex: 1; + min-height: 0; + overflow: hidden; +} + +.context-panel--sources app-workspace-sources-filters-panel { + display: flex; + flex: 1; + min-height: 0; + overflow: hidden; +} + +.context-panel__header { + padding: 6px 6px 12px; +} + +.context-panel__title { + font-size: 1rem; + font-weight: 600; + letter-spacing: -0.01em; +} + +.context-panel__subtitle { + margin-top: 2px; + font-size: 0.78rem; + color: var(--mat-sys-on-surface-variant); +} + +.context-item { + width: 100%; + min-height: 36px; + border: 1px solid transparent; + border-radius: 8px; + background: transparent; + color: var(--mat-sys-on-surface-variant); + text-align: left; + padding: 0 10px; + cursor: pointer; + font: inherit; + font-size: 0.875rem; + transition: + all 0.15s ease, + border-color 0.15s ease; + + &:hover { + border-color: var(--mat-sys-outline-variant); + color: var(--mat-sys-on-surface); + background: var(--mat-sys-surface-container); + } + + &.is-active { + border-color: transparent; + color: var(--mat-sys-on-primary-container); + background: var(--mat-sys-primary-container); + font-weight: 500; + } +} + +.context-panel__footer { + margin-top: auto; + border-top: 1px solid var(--mat-sys-outline-variant); + padding: 12px 6px 2px; + color: var(--mat-sys-on-surface-variant); + font-size: 0.82rem; + + p { + margin: 0 0 8px; + line-height: 1.4; + } + + a { + color: var(--mat-sys-primary); + text-decoration: none; + font-weight: 600; + } +} + +// ─── Main Content Area ──────────────────────────────────────────────────────── +.workspace-content { + min-width: 0; + min-height: 0; + overflow: auto; + background: var(--app-content-bg, var(--mat-sys-surface)); + // Distinctive top-left inset radius (GitButler-style panel inset) + border-radius: 16px 0 0 0; + box-shadow: -4px 8px 20px rgba(0, 0, 0, 0.18); +} + +// ─── Responsive ─────────────────────────────────────────────────────────────── +@media (max-width: 780px) { + .workspace-shell { + grid-template-columns: 56px 1fr; + grid-template-rows: 56px 1fr; + } + + .workspace-header { + padding-inline: 10px; + gap: 10px; + } + + .playlist-switcher { + width: 190px; + } + + .header-actions { + gap: 0; + } +} + +@media (max-width: 640px) { + .workspace-shell { + grid-template-columns: 1fr; + grid-template-rows: 52px 56px 1fr; + } + + .app-rail { + grid-column: 1; + grid-row: 1; + flex-direction: row; + justify-content: space-between; + padding: 6px 10px; + border-right: none; + border-bottom: 1px solid var(--mat-sys-outline-variant); + + .brand { + margin-bottom: 0; + } + } + + .rail-divider { + width: 1px; + height: 24px; + margin: 0 4px; + } + + .rail-footer { + margin-top: 0; + } + + .workspace-header { + grid-column: 1; + grid-row: 2; + } + + .playlist-switcher { + width: auto; + min-width: 140px; + flex: 1; + } + + .workspace-body { + grid-column: 1; + grid-row: 3; + } +} diff --git a/apps/web/src/app/workspace/workspace-shell.component.ts b/apps/web/src/app/workspace/workspace-shell.component.ts new file mode 100644 index 000000000..ee8f32b80 --- /dev/null +++ b/apps/web/src/app/workspace/workspace-shell.component.ts @@ -0,0 +1,897 @@ +import { + Component, + computed, + DestroyRef, + effect, + inject, + signal, + viewChild, +} from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { MatIconButton } from '@angular/material/button'; +import { MatDialog } from '@angular/material/dialog'; +import { MatIcon } from '@angular/material/icon'; +import { MatMenuModule } from '@angular/material/menu'; +import { MatTooltip } from '@angular/material/tooltip'; +import { + NavigationEnd, + Router, + RouterLink, + RouterLinkActive, + RouterOutlet, +} from '@angular/router'; +import { Store } from '@ngrx/store'; +import { TranslatePipe } from '@ngx-translate/core'; +import { + AddPlaylistMenuComponent, + PlaylistInfoComponent, + PlaylistSwitcherComponent, + PlaylistType, + ResizableDirective, +} from 'components'; +import { + selectActivePlaylist, + selectAllPlaylistsMeta, + selectPlaylistTitle, +} from 'm3u-state'; +import { filter, firstValueFrom } from 'rxjs'; +import { PlaylistsService } from 'services'; +import { GLOBAL_FAVORITES_PLAYLIST_ID } from 'shared-interfaces'; +import { DownloadsService } from '../services/downloads.service'; +import { AddPlaylistDialogComponent } from '../shared/components/add-playlist/add-playlist-dialog.component'; +import { + buildPortalRailLinks, + PortalRailLink, +} from '../shared/navigation/portal-rail-links'; +import { PortalRailLinksComponent } from '../shared/navigation/portal-rail-links.component'; +import { AccountInfoComponent } from '../xtream-electron/account-info/account-info.component'; +import { XtreamCategorySortMode } from '../xtream-electron/stores/features/with-selection.feature'; +import { XtreamStore } from '../xtream-electron/stores/xtream.store'; +import { FavoritesContextService } from './favorites-context.service'; +import { SettingsContextService } from './settings-context.service'; +import { WorkspaceContextPanelComponent } from './workspace-context-panel.component'; +import { WorkspaceFavoritesContextPanelComponent } from './workspace-favorites-context-panel.component'; +import { WorkspaceSettingsContextPanelComponent } from './workspace-settings-context-panel.component'; +import { WorkspaceSourcesFiltersPanelComponent } from './workspace-sources-filters-panel.component'; + +interface WorkspaceContext { + provider: 'xtreams' | 'stalker' | 'playlists'; + playlistId: string; +} + +interface WorkspaceHeaderBulkAction { + icon: string; + tooltip: string; + ariaLabel: string; + disabled: boolean; +} + +const XTREAM_CATEGORY_SORT_STORAGE_KEY = 'xtream-category-sort-mode'; +const SEARCH_INPUT_DEBOUNCE_MS = 350; + +@Component({ + selector: 'app-workspace-shell', + imports: [ + MatIcon, + MatIconButton, + MatMenuModule, + MatTooltip, + AddPlaylistMenuComponent, + PlaylistSwitcherComponent, + TranslatePipe, + RouterLink, + RouterLinkActive, + RouterOutlet, + WorkspaceContextPanelComponent, + WorkspaceFavoritesContextPanelComponent, + WorkspaceSettingsContextPanelComponent, + WorkspaceSourcesFiltersPanelComponent, + PortalRailLinksComponent, + ResizableDirective, + ], + templateUrl: './workspace-shell.component.html', + styleUrl: './workspace-shell.component.scss', +}) +export class WorkspaceShellComponent { + private readonly router = inject(Router); + private readonly store = inject(Store); + private readonly xtreamStore = inject(XtreamStore); + private readonly destroyRef = inject(DestroyRef); + private readonly downloadsService = inject(DownloadsService); + private readonly playlistsService = inject(PlaylistsService); + private readonly dialog = inject(MatDialog); + readonly favoritesCtx = inject(FavoritesContextService); + readonly settingsCtx = inject(SettingsContextService); + + private searchDebounceTimeoutId: ReturnType | null = + null; + + readonly addPlaylistMenu = viewChild.required(AddPlaylistMenuComponent); + + readonly playlistTitle = this.store.selectSignal(selectPlaylistTitle); + private readonly activePlaylist = + this.store.selectSignal(selectActivePlaylist); + private readonly playlists = this.store.selectSignal( + selectAllPlaylistsMeta + ); + + readonly searchQuery = signal(''); + readonly appliedSearchQuery = signal(''); + readonly lastM3uPlaylistId = signal(null); + readonly isElectron = !!window.electron; + readonly isMacOS = window.electron?.platform === 'darwin'; + readonly workspaceLinks: PortalRailLink[] = [ + { + icon: 'dashboard', + tooltip: 'Dashboard', + path: ['/workspace/dashboard'], + exact: true, + }, + { + icon: 'library_books', + tooltip: 'Sources', + path: ['/workspace/sources'], + }, + ]; + readonly currentUrl = signal(this.router.url); + readonly currentContext = computed(() => + this.parseWorkspaceContext(this.currentUrl()) + ); + readonly currentSection = computed(() => + this.parseWorkspaceSection(this.currentUrl()) + ); + readonly railContext = computed(() => { + const routeContext = this.currentContext(); + if (routeContext) { + return routeContext; + } + + // On dashboard, sources, and settings we still want provider-specific rail links + // for the currently active playlist selected in the switcher. + if ( + !this.isDashboardRoute() && + !this.isSourcesRoute() && + !this.isSettingsRoute() + ) { + return null; + } + + const activePlaylist = this.activePlaylist(); + if (!activePlaylist?._id) { + return null; + } + + return { + provider: this.getProviderFromPlaylist(activePlaylist), + playlistId: activePlaylist._id, + }; + }); + readonly isDashboardRoute = computed(() => + /^\/workspace(?:\/dashboard)?(?:\/)?(?:\?.*)?$/.test(this.currentUrl()) + ); + readonly isSourcesRoute = computed(() => + /^\/workspace\/sources(?:\/)?(?:\?.*)?$/.test(this.currentUrl()) + ); + readonly isSettingsRoute = computed(() => + /^\/workspace\/settings(?:\/)?(?:\?.*)?$/.test(this.currentUrl()) + ); + readonly dashboardXtreamContext = computed(() => { + if (!this.isDashboardRoute()) { + return null; + } + + const context = this.railContext(); + if (!context || context.provider !== 'xtreams') { + return null; + } + + return context; + }); + readonly isCategoryContextRoute = computed(() => { + const context = this.currentContext(); + const section = this.currentSection(); + return ( + !!context && + ((context.provider === 'xtreams' && + (section === 'vod' || + section === 'series' || + section === 'live')) || + (context.provider === 'stalker' && + (section === 'vod' || + section === 'series' || + section === 'itv'))) + ); + }); + readonly isFavoritesContextRoute = computed(() => { + const context = this.currentContext(); + const section = this.currentSection(); + if (!context) return false; + return ( + (context.provider === 'xtreams' || + context.provider === 'stalker') && + (section === 'favorites' || section === 'recent') + ); + }); + readonly showContextPanel = computed( + () => + this.isSourcesRoute() || + this.isSettingsRoute() || + this.isCategoryContextRoute() || + this.isFavoritesContextRoute() + ); + readonly isFavoritesView = computed(() => { + const context = this.currentContext(); + if ( + context?.provider === 'playlists' && + context.playlistId === GLOBAL_FAVORITES_PLAYLIST_ID + ) { + return true; + } + + return this.currentSection() === 'favorites'; + }); + readonly canUseSearch = computed(() => { + if (this.isSettingsRoute()) { + return false; + } + + if (this.isSourcesRoute()) { + return true; + } + + if (this.dashboardXtreamContext()) { + return true; + } + + const context = this.currentContext(); + const section = this.currentSection(); + if (context?.provider === 'xtreams') { + return ( + section === 'vod' || + section === 'series' || + section === 'search' || + this.usesRouteQuerySearch(context, section) + ); + } + + return context?.provider === 'stalker'; + }); + readonly searchPlaceholder = computed(() => { + if (this.isSourcesRoute()) { + return 'Search sources...'; + } + + const context = this.currentContext(); + const section = this.currentSection(); + + if (!context) { + if (this.dashboardXtreamContext()) { + return 'Search in active playlist...'; + } + return 'Search channels, movies, series...'; + } + + if (context.provider === 'xtreams') { + if (section === 'search') { + return 'Search in playlist content...'; + } + if (section === 'vod' || section === 'series') { + return 'Search in current category...'; + } + if (section === 'favorites') { + return 'Filter favorites...'; + } + if (section === 'recent') { + return 'Filter recently viewed...'; + } + if (section === 'recently-added') { + return 'Filter recently added...'; + } + if (section === 'downloads') { + return 'Filter downloads...'; + } + } + + if (context.provider === 'stalker') { + if (section === 'search') { + return 'Search movies and series in this portal...'; + } + if (section === 'favorites') { + return 'Filter favorites...'; + } + if (section === 'recent') { + return 'Filter recently viewed...'; + } + return 'Search this portal...'; + } + + return 'Search is not available for this section'; + }); + readonly canSort = computed(() => { + const context = this.currentContext(); + const section = this.currentSection(); + return context?.provider === 'xtreams' + ? section === 'vod' || section === 'series' + : false; + }); + readonly contentSortMode = this.xtreamStore.contentSortMode; + readonly contentSortLabel = computed(() => { + const mode = this.contentSortMode(); + if (mode === 'date-asc') return 'Date Added (Oldest First)'; + if (mode === 'name-asc') return 'Name A-Z'; + if (mode === 'name-desc') return 'Name Z-A'; + return 'Date Added (Latest First)'; + }); + readonly primaryContextLinks = computed(() => { + const context = this.railContext(); + if (!context) return []; + return buildPortalRailLinks({ + provider: context.provider, + playlistId: context.playlistId, + isElectron: this.isElectron, + workspace: true, + }).primary; + }); + readonly secondaryContextLinks = computed(() => { + const context = this.railContext(); + if (!context) return []; + return buildPortalRailLinks({ + provider: context.provider, + playlistId: context.playlistId, + isElectron: this.isElectron, + workspace: true, + }).secondary.filter((link) => link.section !== 'downloads'); + }); + readonly downloadsContext = computed(() => { + if (!this.isElectron) { + return null; + } + + const context = this.currentContext() ?? this.railContext(); + if ( + !context || + (context.provider !== 'xtreams' && context.provider !== 'stalker') + ) { + return null; + } + + return context; + }); + readonly isDownloadsView = computed( + () => this.currentSection() === 'downloads' + ); + readonly canOpenPlaylistInfo = computed(() => + Boolean(this.activePlaylist()) + ); + readonly canOpenAccountInfo = computed(() => + Boolean(this.activePlaylist()?.serverUrl) + ); + readonly headerBulkAction = computed( + () => { + const context = this.currentContext(); + const section = this.currentSection(); + if (!context || !section) { + return null; + } + + if ( + (context.provider === 'xtreams' || + context.provider === 'stalker') && + section === 'downloads' + ) { + const hasClearable = this.downloadsService + .downloads() + .some( + (item) => + item.playlistId === context.playlistId && + (item.status === 'completed' || + item.status === 'failed' || + item.status === 'canceled') + ); + return { + icon: 'delete_sweep', + tooltip: 'Clear completed downloads', + ariaLabel: 'Clear completed downloads', + disabled: !hasClearable, + }; + } + + if (context.provider === 'xtreams' && section === 'recent') { + return { + icon: 'delete_sweep', + tooltip: 'Clear recently viewed', + ariaLabel: 'Clear recently viewed', + disabled: this.xtreamStore.recentItems().length === 0, + }; + } + + if (context.provider === 'stalker' && section === 'recent') { + return { + icon: 'delete_sweep', + tooltip: 'Clear recently viewed', + ariaLabel: 'Clear recently viewed', + disabled: false, + }; + } + + return null; + } + ); + + constructor() { + this.destroyRef.onDestroy(() => { + if (this.searchDebounceTimeoutId !== null) { + clearTimeout(this.searchDebounceTimeoutId); + this.searchDebounceTimeoutId = null; + } + }); + + const savedSortMode = localStorage.getItem( + XTREAM_CATEGORY_SORT_STORAGE_KEY + ); + if ( + savedSortMode === 'date-desc' || + savedSortMode === 'date-asc' || + savedSortMode === 'name-asc' || + savedSortMode === 'name-desc' + ) { + this.xtreamStore.setContentSortMode(savedSortMode); + } + + this.router.events + .pipe( + filter( + (event): event is NavigationEnd => + event instanceof NavigationEnd + ), + takeUntilDestroyed() + ) + .subscribe((event) => { + this.currentUrl.set(event.urlAfterRedirects); + this.syncSearchFromRoute(); + }); + + this.syncSearchFromRoute(); + + effect(() => { + const context = this.currentContext(); + const section = this.currentSection(); + const term = this.appliedSearchQuery(); + + if (!context || context.provider !== 'xtreams') { + return; + } + + if (section === 'search') { + this.xtreamStore.setSearchTerm(term); + return; + } + + if (section === 'vod' || section === 'series') { + this.xtreamStore.setCategorySearchTerm(term); + } + }); + + effect(() => { + const context = this.currentContext(); + const section = this.currentSection(); + const term = this.appliedSearchQuery(); + + if (context?.provider === 'stalker' && section === 'search') { + this.syncStalkerSearchQueryParam(term); + } + }); + + effect(() => { + const context = this.currentContext(); + const section = this.currentSection(); + const useQuerySearch = + this.isSourcesRoute() || + this.usesRouteQuerySearch(context, section); + + if (!useQuerySearch) { + return; + } + + this.syncSearchQueryParam(this.appliedSearchQuery()); + }); + + effect(() => { + const context = this.currentContext(); + if (!context || context.provider !== 'playlists') { + return; + } + + if (context.playlistId !== GLOBAL_FAVORITES_PLAYLIST_ID) { + this.lastM3uPlaylistId.set(context.playlistId); + } + }); + } + + readonly playlistSubtitle = computed(() => { + const active = this.activePlaylist(); + if (active?.serverUrl) return 'Xtream Code'; + if (active?.macAddress) return 'Stalker Portal'; + if (active?.count) return `${active.count} channels`; + + const sourcesCount = this.playlists().length; + if (sourcesCount === 0) return 'No sources available'; + if (sourcesCount === 1) return '1 source available'; + return `${sourcesCount} sources available`; + }); + + onSearchInput(event: Event): void { + const target = event.target as HTMLInputElement | null; + const value = target?.value ?? ''; + this.searchQuery.set(value); + this.scheduleSearchApply(value); + } + + onSearchEnter(event: Event): void { + const target = event.target as HTMLInputElement | null; + const value = (target?.value ?? this.searchQuery()).trim(); + this.searchQuery.set(value); + + if (!value) { + this.applySearchQuery(''); + return; + } + + // Dashboard + active Xtream source should jump into global playlist search. + const dashboardXtream = this.dashboardXtreamContext(); + if (dashboardXtream) { + event.preventDefault(); + this.xtreamStore.setSearchTerm(value); + this.applySearchQuery(value); + this.router.navigate( + ['/workspace', 'xtreams', dashboardXtream.playlistId, 'search'], + { + queryParams: { q: value }, + } + ); + return; + } + + // For other contexts, Enter applies immediately instead of waiting debounce. + this.applySearchQuery(value); + } + + openAddPlaylistDialog(type: PlaylistType): void { + this.dialog.open( + AddPlaylistDialogComponent, + { + width: '600px', + data: { type }, + } + ); + } + + async runHeaderBulkAction(): Promise { + const context = this.currentContext(); + const section = this.currentSection(); + if (!context || !section) { + return; + } + + if ( + (context.provider === 'xtreams' || + context.provider === 'stalker') && + section === 'downloads' + ) { + await this.downloadsService.clearCompleted(context.playlistId); + await this.downloadsService.loadDownloads(context.playlistId); + return; + } + + if (context.provider === 'xtreams' && section === 'recent') { + this.xtreamStore.clearRecentItems({ id: context.playlistId }); + return; + } + + if (context.provider === 'stalker' && section === 'recent') { + await firstValueFrom( + this.playlistsService.clearPortalRecentlyViewed( + context.playlistId + ) + ); + this.bumpRefreshQueryParam(); + } + } + + toggleFavoritesShortcut(): void { + const context = this.currentContext(); + if (!context) return; + + if (context.provider === 'xtreams') { + if (!this.isElectron) return; + + const base = ['/workspace', 'xtreams', context.playlistId]; + this.router.navigate( + this.isFavoritesView() + ? [...base, 'vod'] + : [...base, 'favorites'] + ); + return; + } + + if (context.provider === 'stalker') { + const base = ['/workspace', 'stalker', context.playlistId]; + this.router.navigate( + this.isFavoritesView() + ? [...base, 'vod'] + : [...base, 'favorites'] + ); + return; + } + + if (context.provider === 'playlists') { + if (context.playlistId === GLOBAL_FAVORITES_PLAYLIST_ID) { + const fallbackPlaylist = this.lastM3uPlaylistId(); + if (fallbackPlaylist) { + this.router.navigate([ + '/workspace', + 'playlists', + fallbackPlaylist, + ]); + } else { + this.router.navigate(['/workspace', 'dashboard']); + } + return; + } + + this.router.navigate([ + '/workspace', + 'playlists', + GLOBAL_FAVORITES_PLAYLIST_ID, + ]); + } + } + + openDownloadsShortcut(): void { + const context = this.downloadsContext(); + if (!context) { + return; + } + + this.router.navigate([ + '/workspace', + context.provider, + context.playlistId, + 'downloads', + ]); + } + + openPlaylistInfo(): void { + const playlist = this.activePlaylist(); + if (!playlist) { + return; + } + + this.dialog.open(PlaylistInfoComponent, { + data: playlist, + }); + } + + openAccountInfo(): void { + if (!this.canOpenAccountInfo()) { + return; + } + + this.dialog.open(AccountInfoComponent, { + width: '80%', + maxWidth: '1200px', + maxHeight: '90vh', + data: { + vodStreamsCount: this.xtreamStore.vodStreams().length, + liveStreamsCount: this.xtreamStore.liveStreams().length, + seriesCount: this.xtreamStore.serialStreams().length, + }, + }); + } + + setContentSortMode(mode: XtreamCategorySortMode): void { + this.xtreamStore.setContentSortMode(mode); + localStorage.setItem(XTREAM_CATEGORY_SORT_STORAGE_KEY, mode); + } + + private parseWorkspaceContext(url: string): WorkspaceContext | null { + const match = url.match( + /^\/workspace\/(xtreams|stalker|playlists)\/([^\/\?]+)/ + ); + if (!match) return null; + + return { + provider: match[1] as WorkspaceContext['provider'], + playlistId: match[2], + }; + } + + private parseWorkspaceSection(url: string): string | null { + const match = url.match( + /^\/workspace\/(?:xtreams|stalker|playlists)\/[^\/\?]+\/([^\/\?]+)/ + ); + if (!match) return null; + return match[1]; + } + + private syncSearchFromRoute(): void { + const context = this.currentContext(); + const section = this.currentSection(); + + if (context?.provider === 'xtreams') { + if (section === 'search') { + const queryTerm = this.getRouteQueryParam('q'); + this.setSearchState(queryTerm || this.xtreamStore.searchTerm()); + return; + } + if (section === 'vod' || section === 'series') { + this.setSearchState(this.xtreamStore.categorySearchTerm()); + return; + } + } + + if (context?.provider === 'stalker' && section === 'search') { + this.setSearchState(this.getRouteQueryParam('q')); + return; + } + + if ( + this.isSourcesRoute() || + this.usesRouteQuerySearch(context, section) + ) { + this.setSearchState(this.getRouteQueryParam('q')); + return; + } + + this.setSearchState(''); + } + + private setSearchState(value: string): void { + if (this.searchDebounceTimeoutId !== null) { + clearTimeout(this.searchDebounceTimeoutId); + this.searchDebounceTimeoutId = null; + } + this.searchQuery.set(value); + this.appliedSearchQuery.set(value); + } + + private scheduleSearchApply(value: string): void { + if (this.searchDebounceTimeoutId !== null) { + clearTimeout(this.searchDebounceTimeoutId); + } + this.searchDebounceTimeoutId = setTimeout(() => { + this.searchDebounceTimeoutId = null; + this.applySearchQuery(value); + }, SEARCH_INPUT_DEBOUNCE_MS); + } + + private applySearchQuery(value: string): void { + this.appliedSearchQuery.set(value); + + const context = this.currentContext(); + if ( + context?.provider === 'stalker' && + this.currentSection() !== 'search' && + !this.usesRouteQuerySearch(context, this.currentSection()) && + value.trim().length > 0 + ) { + this.router.navigate( + ['/workspace', 'stalker', context.playlistId, 'search'], + { + queryParams: { + q: value.trim(), + }, + } + ); + } + } + + private syncStalkerSearchQueryParam(term: string): void { + const nextTerm = term.trim(); + const currentTerm = this.getRouteQueryParam('q'); + + if (nextTerm === currentTerm) { + return; + } + + this.syncSearchQueryParam(nextTerm); + } + + private syncSearchQueryParam(term: string): void { + const nextTerm = term.trim(); + const currentTerm = this.getRouteQueryParam('q'); + if (nextTerm === currentTerm) { + return; + } + + const routePath = this.currentUrl().split('?')[0]; + const queryParams = { + ...this.router.parseUrl(this.currentUrl()).queryParams, + }; + + if (nextTerm.length > 0) { + queryParams['q'] = nextTerm; + } else { + delete queryParams['q']; + } + + const queryString = this.toQueryString(queryParams); + const nextUrl = queryString ? `${routePath}?${queryString}` : routePath; + this.router.navigateByUrl(nextUrl, { replaceUrl: true }); + } + + private getRouteQueryParam(name: string): string { + const value = this.router.parseUrl(this.currentUrl()).queryParams[name]; + return typeof value === 'string' ? value : ''; + } + + private toQueryString(queryParams: Record): string { + const urlSearchParams = new URLSearchParams(); + + Object.entries(queryParams).forEach(([key, value]) => { + if (value == null) { + return; + } + + if (Array.isArray(value)) { + value.forEach((item) => + urlSearchParams.append(key, String(item)) + ); + return; + } + + urlSearchParams.set(key, String(value)); + }); + + return urlSearchParams.toString(); + } + + private bumpRefreshQueryParam(): void { + const routePath = this.currentUrl().split('?')[0]; + const queryParams = { + ...this.router.parseUrl(this.currentUrl()).queryParams, + refresh: Date.now().toString(), + }; + + const queryString = this.toQueryString(queryParams); + const nextUrl = queryString ? `${routePath}?${queryString}` : routePath; + this.router.navigateByUrl(nextUrl, { replaceUrl: true }); + } + + private usesRouteQuerySearch( + context: WorkspaceContext | null, + section: string | null + ): boolean { + if (!context || !section) { + return false; + } + + if (context.provider === 'xtreams') { + return ( + section === 'favorites' || + section === 'downloads' || + section === 'recent' || + section === 'recently-added' + ); + } + + if (context.provider === 'stalker') { + return section === 'favorites' || section === 'recent'; + } + + return false; + } + + private getProviderFromPlaylist(playlist: { + serverUrl?: string; + macAddress?: string; + }): WorkspaceContext['provider'] { + if (playlist.serverUrl) { + return 'xtreams'; + } + if (playlist.macAddress) { + return 'stalker'; + } + return 'playlists'; + } +} diff --git a/apps/web/src/app/workspace/workspace-sources-filters-panel.component.html b/apps/web/src/app/workspace/workspace-sources-filters-panel.component.html new file mode 100644 index 000000000..92b79550e --- /dev/null +++ b/apps/web/src/app/workspace/workspace-sources-filters-panel.component.html @@ -0,0 +1,57 @@ +
+
+
+

Sources

+
+

Filter and sort playlists

+
+ +
+ +
+ +
+ +
+ +
+
diff --git a/apps/web/src/app/workspace/workspace-sources-filters-panel.component.scss b/apps/web/src/app/workspace/workspace-sources-filters-panel.component.scss new file mode 100644 index 000000000..f48e08bc4 --- /dev/null +++ b/apps/web/src/app/workspace/workspace-sources-filters-panel.component.scss @@ -0,0 +1,72 @@ +:host { + display: flex; + flex-direction: column; + min-height: 0; + height: 100%; +} + +.context-column { + display: flex; + flex-direction: column; + min-height: 0; + height: 100%; + overflow: hidden; +} + +// ─── Header ─────────────────────────────────────────────────────────────────── +.context-header { + padding: 4px 6px 10px; + flex-shrink: 0; +} + +.context-header__top { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + + h2 { + margin: 0; + font-size: 0.94rem; + font-weight: 600; + letter-spacing: -0.01em; + color: var(--mat-sys-on-surface); + } +} + +.context-header p { + margin: 4px 0 0; + font-size: 0.78rem; + color: var(--mat-sys-on-surface-variant); +} + +// ─── Divider ────────────────────────────────────────────────────────────────── +.context-divider { + height: 1px; + background: var(--mat-sys-outline-variant); + margin: 0 0 6px; + opacity: 0.6; + flex-shrink: 0; +} + +.panel-section { + display: flex; + flex-direction: column; + min-height: 0; +} + +.option-list { + overflow-y: auto; + min-height: 0; +} + +.panel-section-title { + margin: 0; + padding: 10px 10px 6px; + font-size: 0.72rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--mat-sys-on-surface-variant); +} + diff --git a/apps/web/src/app/workspace/workspace-sources-filters-panel.component.ts b/apps/web/src/app/workspace/workspace-sources-filters-panel.component.ts new file mode 100644 index 000000000..c35374016 --- /dev/null +++ b/apps/web/src/app/workspace/workspace-sources-filters-panel.component.ts @@ -0,0 +1,162 @@ +import { computed, Component, inject } from '@angular/core'; +import { toSignal } from '@angular/core/rxjs-interop'; +import { MatIcon } from '@angular/material/icon'; +import { MatListModule } from '@angular/material/list'; +import { Store } from '@ngrx/store'; +import { + FilterActions, + selectActiveTypeFilters, + selectAllPlaylistsMeta, +} from 'm3u-state'; +import { TranslatePipe } from '@ngx-translate/core'; +import { SortBy, SortOrder, SortService } from 'services'; + +type PlaylistFilterId = 'all' | 'm3u' | 'xtream' | 'stalker'; + +interface PlaylistFilterOption { + id: PlaylistFilterId; + icon: string; + label: string; + translationKey?: string; +} + +interface SortOption { + by: SortBy; + order: SortOrder; + icon: string; + translationKey: string; +} + +const ALL_FILTERS = ['m3u', 'xtream', 'stalker']; + +@Component({ + selector: 'app-workspace-sources-filters-panel', + imports: [MatIcon, MatListModule, TranslatePipe], + templateUrl: './workspace-sources-filters-panel.component.html', + styleUrl: './workspace-sources-filters-panel.component.scss', +}) +export class WorkspaceSourcesFiltersPanelComponent { + private readonly store = inject(Store); + private readonly sortService = inject(SortService); + + private readonly activeTypeFilters = this.store.selectSignal( + selectActiveTypeFilters + ); + private readonly playlists = this.store.selectSignal(selectAllPlaylistsMeta); + + readonly currentSortOptions = toSignal(this.sortService.getSortOptions(), { + requireSync: true, + }); + + readonly typeOptions: PlaylistFilterOption[] = [ + { + id: 'all', + icon: 'layers', + label: 'All', + }, + { + id: 'm3u', + icon: 'playlist_play', + translationKey: 'HOME.PLAYLIST_TYPES.M3U', + label: 'M3U', + }, + { + id: 'xtream', + icon: 'cloud', + translationKey: 'HOME.PLAYLIST_TYPES.XTREAM', + label: 'Xtream', + }, + { + id: 'stalker', + icon: 'router', + translationKey: 'HOME.PLAYLIST_TYPES.STALKER', + label: 'Stalker', + }, + ]; + + readonly sortOptions: SortOption[] = [ + { + by: SortBy.DATE_ADDED, + order: SortOrder.DESC, + icon: 'schedule', + translationKey: 'HOME.SORT_OPTIONS.NEWEST', + }, + { + by: SortBy.DATE_ADDED, + order: SortOrder.ASC, + icon: 'history', + translationKey: 'HOME.SORT_OPTIONS.OLDEST', + }, + { + by: SortBy.NAME, + order: SortOrder.ASC, + icon: 'sort_by_alpha', + translationKey: 'HOME.SORT_OPTIONS.NAME_ASC', + }, + { + by: SortBy.NAME, + order: SortOrder.DESC, + icon: 'sort_by_alpha', + translationKey: 'HOME.SORT_OPTIONS.NAME_DESC', + }, + { + by: SortBy.CUSTOM, + order: SortOrder.ASC, + icon: 'drag_indicator', + translationKey: 'HOME.SORT_OPTIONS.CUSTOM_ORDER', + }, + ]; + + readonly typeCounts = computed(() => { + const items = this.playlists(); + return { + all: items.length, + m3u: items.filter((item) => !item.serverUrl && !item.macAddress) + .length, + xtream: items.filter((item) => !!item.serverUrl).length, + stalker: items.filter((item) => !!item.macAddress).length, + }; + }); + + isTypeActive(filterId: PlaylistFilterId): boolean { + const selected = this.activeTypeFilters(); + if (filterId === 'all') { + return ( + selected.length === ALL_FILTERS.length && + ALL_FILTERS.every((id) => selected.includes(id)) + ); + } + + return selected.length === 1 && selected[0] === filterId; + } + + selectType(filterId: PlaylistFilterId): void { + const selectedFilters = + filterId === 'all' ? ALL_FILTERS : [filterId]; + this.store.dispatch( + FilterActions.setSelectedFilters({ + selectedFilters, + }) + ); + } + + getTypeCount(filterId: PlaylistFilterId): number { + const counts = this.typeCounts(); + if (filterId === 'all') { + return counts.all; + } + return counts[filterId]; + } + + isSortActive(option: SortOption): boolean { + const current = this.currentSortOptions(); + return current.by === option.by && current.order === option.order; + } + + setSortOption(option: SortOption): void { + this.sortService.setSortOptions({ + by: option.by, + order: option.order, + }); + } +} diff --git a/apps/web/src/app/workspace/workspace-sources.component.html b/apps/web/src/app/workspace/workspace-sources.component.html new file mode 100644 index 000000000..40d20fe50 --- /dev/null +++ b/apps/web/src/app/workspace/workspace-sources.component.html @@ -0,0 +1,12 @@ +
+
+

{{ title() }}

+

{{ subtitle() }}

+
+
+ +
+
diff --git a/apps/web/src/app/workspace/workspace-sources.component.scss b/apps/web/src/app/workspace/workspace-sources.component.scss new file mode 100644 index 000000000..f07af68d0 --- /dev/null +++ b/apps/web/src/app/workspace/workspace-sources.component.scss @@ -0,0 +1,45 @@ +:host { + display: block; + height: 100%; + min-height: 100%; +} + +.sources-page { + display: flex; + flex-direction: column; + height: 100%; + min-height: 100%; + min-width: 0; + overflow: hidden; +} + +.sources-header { + position: sticky; + top: 0; + z-index: 2; + padding: 20px 20px 8px; +} + +.sources-title { + margin: 0; + font-size: 1.2rem; + font-weight: 600; +} + +.sources-subtitle { + margin: 4px 0 0; + font-size: 0.875rem; + color: var(--mat-sys-on-surface-variant, rgba(255, 255, 255, 0.6)); +} + +.sources-content { + flex: 1; + min-height: 0; + overflow-y: auto; + padding: 0 20px 20px; +} + +.sources-content app-recent-playlists { + display: block; + height: 100%; +} diff --git a/apps/web/src/app/workspace/workspace-sources.component.ts b/apps/web/src/app/workspace/workspace-sources.component.ts new file mode 100644 index 000000000..40b13b3f8 --- /dev/null +++ b/apps/web/src/app/workspace/workspace-sources.component.ts @@ -0,0 +1,92 @@ +import { map } from 'rxjs'; +import { toSignal } from '@angular/core/rxjs-interop'; +import { Component, computed, inject } from '@angular/core'; +import { ActivatedRoute } from '@angular/router'; +import { MatDialog } from '@angular/material/dialog'; +import { Store } from '@ngrx/store'; +import { + selectActiveTypeFilters, + selectAllPlaylistsMeta, +} from 'm3u-state'; +import { PlaylistType, RecentPlaylistsComponent } from 'components'; +import { AddPlaylistDialogComponent } from '../shared/components/add-playlist/add-playlist-dialog.component'; + +@Component({ + selector: 'app-workspace-sources', + imports: [RecentPlaylistsComponent], + templateUrl: './workspace-sources.component.html', + styleUrl: './workspace-sources.component.scss', +}) +export class WorkspaceSourcesComponent { + private readonly dialog = inject(MatDialog); + private readonly route = inject(ActivatedRoute); + private readonly store = inject(Store); + + private readonly activeTypeFilters = this.store.selectSignal( + selectActiveTypeFilters + ); + private readonly playlists = this.store.selectSignal(selectAllPlaylistsMeta); + + readonly searchQuery = toSignal( + this.route.queryParamMap.pipe(map((params) => params.get('q') ?? '')), + { initialValue: '' } + ); + readonly title = computed(() => { + const filters = this.activeTypeFilters(); + + if (filters.length === 1) { + if (filters[0] === 'm3u') { + return 'M3U Playlists'; + } + if (filters[0] === 'xtream') { + return 'Xtream Playlists'; + } + if (filters[0] === 'stalker') { + return 'Stalker Playlists'; + } + } + + return 'All Playlists'; + }); + + readonly visibleSourcesCount = computed(() => { + const query = this.searchQuery().trim().toLowerCase(); + const filters = this.activeTypeFilters(); + const allPlaylists = this.playlists(); + + return allPlaylists + .filter((item) => { + const isStalkerFilter = + !!item.macAddress && filters.includes('stalker'); + const isXtreamFilter = + !!item.username && + !!item.password && + !!item.serverUrl && + filters.includes('xtream'); + const isM3uFilter = + !item.username && + !item.password && + !item.serverUrl && + !item.macAddress && + filters.includes('m3u'); + + return isStalkerFilter || isXtreamFilter || isM3uFilter; + }) + .filter((item) => item.title.toLowerCase().includes(query)).length; + }); + + readonly subtitle = computed(() => { + const count = this.visibleSourcesCount(); + return `${count} ${count === 1 ? 'playlist' : 'playlists'}`; + }); + + onAddPlaylist(playlistType: PlaylistType): void { + this.dialog.open( + AddPlaylistDialogComponent, + { + width: '600px', + data: { type: playlistType }, + } + ); + } +} diff --git a/apps/web/src/typings.d.ts b/apps/web/src/typings.d.ts index 4eec305a4..7c950ddfb 100644 --- a/apps/web/src/typings.d.ts +++ b/apps/web/src/typings.d.ts @@ -174,6 +174,7 @@ declare global { playlistId: string ) => Promise; dbGetFavorites: (playlistId: string) => Promise; + dbGetGlobalFavorites: () => Promise; // Recently viewed (playlist-specific) dbGetRecentItems: (playlistId: string) => Promise; dbAddRecentItem: (