From 8f799e338aadec4a0e0d8a8e61fd8d9e25084562 Mon Sep 17 00:00:00 2001 From: 4gray Date: Tue, 24 Feb 2026 19:15:01 +0100 Subject: [PATCH] feat: implement portal rail links component with dynamic navigation --- .../portal-rail-links.component.html | 40 ++++ .../portal-rail-links.component.scss | 103 ++++++++++ .../navigation/portal-rail-links.component.ts | 52 +++++ .../navigation/portal-rail-links.spec.ts | 96 +++++++++ .../shared/navigation/portal-rail-links.ts | 188 ++++++++++++++++++ 5 files changed, 479 insertions(+) create mode 100644 apps/web/src/app/shared/navigation/portal-rail-links.component.html create mode 100644 apps/web/src/app/shared/navigation/portal-rail-links.component.scss create mode 100644 apps/web/src/app/shared/navigation/portal-rail-links.component.ts create mode 100644 apps/web/src/app/shared/navigation/portal-rail-links.spec.ts create mode 100644 apps/web/src/app/shared/navigation/portal-rail-links.ts diff --git a/apps/web/src/app/shared/navigation/portal-rail-links.component.html b/apps/web/src/app/shared/navigation/portal-rail-links.component.html new file mode 100644 index 000000000..8b9cbaec3 --- /dev/null +++ b/apps/web/src/app/shared/navigation/portal-rail-links.component.html @@ -0,0 +1,40 @@ +@if (variant() === 'list') { + + @for (link of links(); track link.path.join('/')) { + + {{ resolveIcon(link) }} + + } + +} @else { + +} diff --git a/apps/web/src/app/shared/navigation/portal-rail-links.component.scss b/apps/web/src/app/shared/navigation/portal-rail-links.component.scss new file mode 100644 index 000000000..5e2770b61 --- /dev/null +++ b/apps/web/src/app/shared/navigation/portal-rail-links.component.scss @@ -0,0 +1,103 @@ +:host { + display: block; + width: 100%; + --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); + /* Keep consistent with category-item.selected tone */ + --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); +} + +.p-0.m-0 { + + .mdc-list-item { + min-height: 28px; + height: 28px; + padding: 0 !important; + border-radius: 4px; + margin: 2px 0; + display: flex; + justify-content: center; + color: var(--rail-link-color); + + .mdc-list-item__start { + margin-inline-end: 0 !important; + margin-inline-start: 0 !important; + } + + .mdc-list-item__content { + display: none; + } + } + + mat-icon.mat-mdc-list-item-icon, + mat-icon { + font-size: 18px !important; + width: 18px !important; + height: 18px !important; + } + + .mdc-list-item.active, + .mdc-list-item.is-active { + background: var(--rail-link-active-bg); + color: var(--rail-link-active-color); + box-shadow: + inset 0 0 0 1px var(--rail-link-active-border), + 0 8px 18px -14px var(--rail-link-active-glow); + + mat-icon { + color: var(--rail-link-active-icon); + } + } +} + +nav.flex 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; +} + +nav.flex a:hover { + color: var(--rail-link-hover-color); + background: var(--rail-link-hover-bg); +} + +nav.flex a.is-active, +nav.flex 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); +} + +nav.flex a.is-active mat-icon, +nav.flex a.active mat-icon { + color: var(--rail-link-active-icon); +} + +nav.flex a.is-active::after, +nav.flex a.active::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); +} diff --git a/apps/web/src/app/shared/navigation/portal-rail-links.component.ts b/apps/web/src/app/shared/navigation/portal-rail-links.component.ts new file mode 100644 index 000000000..c77b8b395 --- /dev/null +++ b/apps/web/src/app/shared/navigation/portal-rail-links.component.ts @@ -0,0 +1,52 @@ +import { Component, inject, input } from '@angular/core'; +import { MatIcon } from '@angular/material/icon'; +import { MatListModule } from '@angular/material/list'; +import { MatTooltip } from '@angular/material/tooltip'; +import { + IsActiveMatchOptions, + Router, + RouterLink, + RouterLinkActive, +} from '@angular/router'; +import { PortalRailLink } from './portal-rail-links'; + +@Component({ + selector: 'app-portal-rail-links', + imports: [MatIcon, MatListModule, MatTooltip, RouterLink, RouterLinkActive], + templateUrl: './portal-rail-links.component.html', + styleUrl: './portal-rail-links.component.scss', +}) +export class PortalRailLinksComponent { + private readonly router = inject(Router); + + readonly links = input([]); + readonly selectedSection = input(null); + readonly activeClass = input<'active' | 'is-active'>('active'); + readonly variant = input<'list' | 'rail'>('list'); + + resolveIcon(link: PortalRailLink): string { + const normalizedPath = link.path.map((segment) => String(segment)).join('/'); + if (normalizedPath.includes('/workspace/sources')) { + return 'playlist_play'; + } + + return link.icon; + } + + isActive(link: PortalRailLink): boolean { + const selectedSection = this.selectedSection(); + if (selectedSection && link.section) { + return selectedSection === link.section; + } + + const commands = link.path.map((segment) => String(segment)); + const tree = this.router.createUrlTree(commands); + const matchOptions: IsActiveMatchOptions = { + paths: link.exact ? 'exact' : 'subset', + queryParams: 'ignored', + fragment: 'ignored', + matrixParams: 'ignored', + }; + return this.router.isActive(tree, matchOptions); + } +} diff --git a/apps/web/src/app/shared/navigation/portal-rail-links.spec.ts b/apps/web/src/app/shared/navigation/portal-rail-links.spec.ts new file mode 100644 index 000000000..5d89562c8 --- /dev/null +++ b/apps/web/src/app/shared/navigation/portal-rail-links.spec.ts @@ -0,0 +1,96 @@ +import { GLOBAL_FAVORITES_PLAYLIST_ID } from 'shared-interfaces'; +import { buildPortalRailLinks } from './portal-rail-links'; + +describe('buildPortalRailLinks', () => { + it('builds legacy Xtream links with downloads on Electron', () => { + const links = buildPortalRailLinks({ + provider: 'xtreams', + playlistId: 'xtream-1', + isElectron: true, + workspace: false, + }); + + expect(links.primary.map((link) => link.section)).toEqual([ + 'vod', + 'live', + 'series', + ]); + expect(links.secondary.map((link) => link.section)).toEqual([ + 'recently-added', + 'search', + 'recent', + 'favorites', + 'downloads', + ]); + expect(links.primary[0]?.path).toEqual(['/xtreams', 'xtream-1', 'vod']); + }); + + it('builds workspace Xtream links without downloads on web', () => { + const links = buildPortalRailLinks({ + provider: 'xtreams', + playlistId: 'xtream-web', + isElectron: false, + workspace: true, + }); + + expect(links.primary).toEqual([ + { + icon: 'movie', + tooltip: 'Xtream library', + path: ['/workspace', 'xtreams', 'xtream-web'], + exact: true, + section: 'library', + }, + ]); + expect(links.secondary).toEqual([]); + }); + + it('builds Stalker links with mapped itv section', () => { + const links = buildPortalRailLinks({ + provider: 'stalker', + playlistId: 'portal-1', + isElectron: false, + workspace: false, + }); + + expect(links.primary.map((link) => link.section)).toEqual([ + 'vod', + 'itv', + 'series', + ]); + expect(links.secondary.map((link) => link.section)).toEqual([ + 'search', + 'recent', + 'favorites', + ]); + expect(links.primary[1]?.path).toEqual(['/stalker', 'portal-1', 'itv']); + }); + + it('builds playlist links with global favorites in workspace', () => { + const links = buildPortalRailLinks({ + provider: 'playlists', + playlistId: 'm3u-1', + isElectron: true, + workspace: true, + }); + + expect(links.primary).toEqual([ + { + icon: 'play_circle', + tooltip: 'Player', + path: ['/workspace', 'playlists', 'm3u-1'], + exact: true, + section: 'player', + }, + ]); + + expect(links.secondary).toEqual([ + { + icon: 'favorite', + tooltip: 'Global favorites', + path: ['/workspace', 'playlists', GLOBAL_FAVORITES_PLAYLIST_ID], + section: 'favorites', + }, + ]); + }); +}); diff --git a/apps/web/src/app/shared/navigation/portal-rail-links.ts b/apps/web/src/app/shared/navigation/portal-rail-links.ts new file mode 100644 index 000000000..626532561 --- /dev/null +++ b/apps/web/src/app/shared/navigation/portal-rail-links.ts @@ -0,0 +1,188 @@ +import { GLOBAL_FAVORITES_PLAYLIST_ID } from 'shared-interfaces'; + +export type PortalProvider = 'xtreams' | 'stalker' | 'playlists'; + +export interface PortalRailLink { + icon: string; + tooltip: string; + path: (string | number)[]; + exact?: boolean; + section?: string; +} + +interface BuildPortalRailLinksOptions { + provider: PortalProvider; + playlistId: string; + isElectron: boolean; + workspace: boolean; +} + +interface PortalRailLinkGroups { + primary: PortalRailLink[]; + secondary: PortalRailLink[]; +} + +export function buildPortalRailLinks( + options: BuildPortalRailLinksOptions +): PortalRailLinkGroups { + const { provider, playlistId, isElectron, workspace } = options; + const root = workspace + ? ['/workspace', provider, playlistId] + : [`/${provider}`, playlistId]; + + if (provider === 'xtreams') { + const primary: PortalRailLink[] = []; + const secondary: PortalRailLink[] = []; + + if (workspace && !isElectron) { + primary.push({ + icon: 'movie', + tooltip: 'Xtream library', + path: root, + exact: true, + section: 'library', + }); + return { primary, secondary }; + } + + primary.push( + { + icon: 'movie', + tooltip: 'Movies', + path: [...root, 'vod'], + section: 'vod', + }, + { + icon: 'live_tv', + tooltip: 'Live TV', + path: [...root, 'live'], + section: 'live', + }, + { + icon: 'tv', + tooltip: 'Series', + path: [...root, 'series'], + section: 'series', + } + ); + + secondary.push( + { + icon: 'new_releases', + tooltip: 'Recently added', + path: [...root, 'recently-added'], + section: 'recently-added', + }, + { + icon: 'search', + tooltip: 'Search', + path: [...root, 'search'], + section: 'search', + }, + { + icon: 'history', + tooltip: 'Recently viewed', + path: [...root, 'recent'], + section: 'recent', + }, + { + icon: 'favorite', + tooltip: 'Favorites', + path: [...root, 'favorites'], + section: 'favorites', + } + ); + + if (isElectron) { + secondary.push({ + icon: 'download', + tooltip: 'Downloads', + path: [...root, 'downloads'], + section: 'downloads', + }); + } + + return { primary, secondary }; + } + + if (provider === 'stalker') { + const primary: PortalRailLink[] = [ + { + icon: 'movie', + tooltip: 'Movies', + path: [...root, 'vod'], + section: 'vod', + }, + { + icon: 'live_tv', + tooltip: 'Live TV', + path: [...root, 'itv'], + section: 'itv', + }, + { + icon: 'tv', + tooltip: 'Series', + path: [...root, 'series'], + section: 'series', + }, + ]; + + const secondary: PortalRailLink[] = [ + { + icon: 'search', + tooltip: 'Search', + path: [...root, 'search'], + section: 'search', + }, + { + icon: 'history', + tooltip: 'Recently viewed', + path: [...root, 'recent'], + section: 'recent', + }, + { + icon: 'favorite', + tooltip: 'Favorites', + path: [...root, 'favorites'], + section: 'favorites', + }, + ]; + + if (isElectron) { + secondary.push({ + icon: 'download', + tooltip: 'Downloads', + path: [...root, 'downloads'], + section: 'downloads', + }); + } + + return { primary, secondary }; + } + + return { + primary: [ + { + icon: 'play_circle', + tooltip: 'Player', + path: root, + exact: true, + section: 'player', + }, + ], + secondary: workspace + ? [ + { + icon: 'favorite', + tooltip: 'Global favorites', + path: [ + '/workspace', + 'playlists', + GLOBAL_FAVORITES_PLAYLIST_ID, + ], + section: 'favorites', + }, + ] + : [], + }; +}