feat: implement portal rail links component with dynamic navigation

This commit is contained in:
4gray committed 2026-02-24 19:15:01 +01:00
1 parent 24581a2131
commit 8f799e338a
5 files changed
+479

No files matched your search

@@ -0,0 +1,40 @@
@if (variant() === 'list') {
<mat-nav-list class="p-0 m-0">
@for (link of links(); track link.path.join('/')) {
<a
mat-list-item
[routerLink]="link.path"
[routerLinkActive]="activeClass()"
[routerLinkActiveOptions]="{ exact: link.exact ?? false }"
[matTooltip]="link.tooltip"
matTooltipPosition="right"
[attr.aria-label]="link.tooltip"
[class.active]="activeClass() === 'active' && isActive(link)"
[class.is-active]="
activeClass() === 'is-active' && isActive(link)
"
>
<mat-icon matListItemIcon>{{ resolveIcon(link) }}</mat-icon>
</a>
}
</mat-nav-list>
} @else {
<nav class="flex flex-col items-center gap-2 w-full">
@for (link of links(); track link.path.join('/')) {
<a
[routerLink]="link.path"
[routerLinkActive]="activeClass()"
[routerLinkActiveOptions]="{ exact: link.exact ?? false }"
[matTooltip]="link.tooltip"
[matTooltipPosition]="'right'"
[attr.aria-label]="link.tooltip"
[class.active]="activeClass() === 'active' && isActive(link)"
[class.is-active]="
activeClass() === 'is-active' && isActive(link)
"
>
<mat-icon>{{ resolveIcon(link) }}</mat-icon>
</a>
}
</nav>
}
@@ -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);
}
@@ -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<PortalRailLink[]>([]);
readonly selectedSection = input<string | null | undefined>(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);
}
}
@@ -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',
},
]);
});
});
@@ -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',
},
]
: [],
};
}