mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-13 04:26:16 -08:00
feat: implement portal rail links component with dynamic navigation
This commit is contained in:
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',
|
||||
},
|
||||
]
|
||||
: [],
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user