mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-13 20:56:19 -08:00
feat(workspace): implement workspace shell and sources filters panel
- Added WorkspaceShellComponent to manage the main workspace layout and navigation. - Introduced WorkspaceSourcesFiltersPanelComponent for filtering and sorting playlists. - Created WorkspaceSourcesComponent to display playlists with search functionality. - Implemented HTML and SCSS for the new components to ensure proper styling and layout. - Updated typings to include new database method for fetching global favorites.
This commit is contained in:
1 parent
349fbcea47
commit
27bcc9d059
20 files changed
+3282
No files matched your search
@@ -0,0 +1,161 @@
|
||||
<section class="dashboard-page">
|
||||
<!-- ─── Hero ─────────────────────────────────────────────────────────── -->
|
||||
<header class="hero">
|
||||
<div class="hero-content">
|
||||
<div class="hero-text">
|
||||
<p class="eyebrow">
|
||||
<span class="eyebrow-dot"></span>
|
||||
Workspace
|
||||
</p>
|
||||
<h1>Your IPTV sources,<br />one place.</h1>
|
||||
<p class="description">
|
||||
Jump back into your active source, switch providers
|
||||
instantly, or explore what's new across your library.
|
||||
</p>
|
||||
<div class="hero-actions">
|
||||
@if (activePlaylist(); as active) {
|
||||
<a
|
||||
mat-flat-button
|
||||
[routerLink]="getPlaylistLink(active)"
|
||||
>
|
||||
<mat-icon>play_arrow</mat-icon>
|
||||
Continue with {{ active.title }}
|
||||
</a>
|
||||
} @else {
|
||||
<a mat-flat-button routerLink="/workspace/sources">
|
||||
<mat-icon>library_books</mat-icon>
|
||||
Open Sources
|
||||
</a>
|
||||
}
|
||||
<a mat-stroked-button routerLink="/settings">
|
||||
<mat-icon>settings</mat-icon>
|
||||
Settings
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="hero-visual" aria-hidden="true">
|
||||
<div class="hero-icon-ring">
|
||||
<mat-icon>tv</mat-icon>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- ─── Stats Row ─────────────────────────────────────────────────────── -->
|
||||
<div class="stat-grid">
|
||||
<article class="stat-card stat-card--total">
|
||||
<div class="stat-card__icon">
|
||||
<mat-icon>folder_open</mat-icon>
|
||||
</div>
|
||||
<div class="stat-card__body">
|
||||
<span class="stat-card__value">{{ stats().total }}</span>
|
||||
<span class="stat-card__label">Total Sources</span>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article class="stat-card stat-card--xtream">
|
||||
<div class="stat-card__icon">
|
||||
<mat-icon>stream</mat-icon>
|
||||
</div>
|
||||
<div class="stat-card__body">
|
||||
<span class="stat-card__value">{{ stats().xtream }}</span>
|
||||
<span class="stat-card__label">Xtream</span>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article class="stat-card stat-card--stalker">
|
||||
<div class="stat-card__icon">
|
||||
<mat-icon>router</mat-icon>
|
||||
</div>
|
||||
<div class="stat-card__body">
|
||||
<span class="stat-card__value">{{ stats().stalker }}</span>
|
||||
<span class="stat-card__label">Stalker</span>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article class="stat-card stat-card--m3u">
|
||||
<div class="stat-card__icon">
|
||||
<mat-icon>playlist_play</mat-icon>
|
||||
</div>
|
||||
<div class="stat-card__body">
|
||||
<span class="stat-card__value">{{ stats().m3u }}</span>
|
||||
<span class="stat-card__label">M3U</span>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<!-- ─── Recent Sources ────────────────────────────────────────────────── -->
|
||||
<section class="sources-section">
|
||||
<div class="section-header">
|
||||
<h2>Recent Sources</h2>
|
||||
<a routerLink="/workspace/sources" class="section-link">
|
||||
<mat-icon>arrow_forward</mat-icon>
|
||||
View all
|
||||
</a>
|
||||
</div>
|
||||
|
||||
@if (recentPlaylists().length > 0) {
|
||||
<div class="sources-grid">
|
||||
@for (
|
||||
playlist of recentPlaylists();
|
||||
track trackByPlaylistId($index, playlist)
|
||||
) {
|
||||
<a
|
||||
class="source-card"
|
||||
[routerLink]="getPlaylistLink(playlist)"
|
||||
>
|
||||
<div class="source-card__header">
|
||||
<span
|
||||
class="provider-badge"
|
||||
[class]="
|
||||
'provider-badge--' +
|
||||
getPlaylistProvider(playlist).toLowerCase()
|
||||
"
|
||||
>
|
||||
{{ getPlaylistProvider(playlist) }}
|
||||
</span>
|
||||
@if (playlist.count) {
|
||||
<span class="source-card__count">
|
||||
{{ playlist.count | number }} ch
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
<h3
|
||||
[attr.title]="
|
||||
playlist.title ||
|
||||
playlist.filename ||
|
||||
'Untitled source'
|
||||
"
|
||||
>
|
||||
{{
|
||||
playlist.title ||
|
||||
playlist.filename ||
|
||||
'Untitled source'
|
||||
}}
|
||||
</h3>
|
||||
<p class="source-card__date">
|
||||
<mat-icon>schedule</mat-icon>
|
||||
{{
|
||||
formatTimestamp(
|
||||
playlist.updateDate || playlist.importDate
|
||||
)
|
||||
}}
|
||||
</p>
|
||||
</a>
|
||||
}
|
||||
</div>
|
||||
} @else {
|
||||
<article class="empty-state">
|
||||
<div class="empty-state__icon">
|
||||
<mat-icon>library_add</mat-icon>
|
||||
</div>
|
||||
<h3>No sources yet</h3>
|
||||
<p>Add a playlist in Library to start using the workspace.</p>
|
||||
<a mat-flat-button routerLink="/workspace/sources">
|
||||
<mat-icon>add</mat-icon>
|
||||
Add Source
|
||||
</a>
|
||||
</article>
|
||||
}
|
||||
</section>
|
||||
</section>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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<XtreamCategory[]>([]);
|
||||
|
||||
/** Currently selected category id — shared between panel and content. */
|
||||
readonly selectedCategoryId = signal<string>('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');
|
||||
}
|
||||
}
|
||||
@@ -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<SettingsNavItem[]>([]);
|
||||
readonly activeSection = signal<string>('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');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
<div class="context-column">
|
||||
<header class="context-header">
|
||||
<div class="context-header__top">
|
||||
<h2>{{ title() }}</h2>
|
||||
@if (isXtreamCategories()) {
|
||||
<button
|
||||
mat-icon-button
|
||||
[matTooltip]="'Manage categories'"
|
||||
aria-label="Manage categories"
|
||||
(click)="openXtreamCategoryManagement()"
|
||||
>
|
||||
<mat-icon>tune</mat-icon>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
@if (subtitle(); as text) {
|
||||
<span class="context-header__badge">{{ text }}</span>
|
||||
}
|
||||
</header>
|
||||
|
||||
<div class="context-divider"></div>
|
||||
|
||||
@if (isXtreamCategories()) {
|
||||
<app-category-view
|
||||
[items]="xtreamCategories()"
|
||||
[selectedCategoryId]="xtreamSelectedCategoryId()"
|
||||
[itemCounts]="xtreamCategoryItemCounts()"
|
||||
[showCounts]="true"
|
||||
(categoryClicked)="onXtreamCategoryClicked($event)"
|
||||
/>
|
||||
} @else if (isStalkerCategories()) {
|
||||
@if (isStalkerCategoryLoading()) {
|
||||
<ngx-skeleton-loader
|
||||
count="18"
|
||||
[theme]="{
|
||||
width: '95%',
|
||||
height: '36px',
|
||||
borderRadius: '8px',
|
||||
}"
|
||||
/>
|
||||
} @else if (isStalkerCategoryFailed()) {
|
||||
<app-playlist-error-view
|
||||
[title]="'Unable to load categories'"
|
||||
[description]="'Please retry or verify portal settings.'"
|
||||
[showActionButtons]="false"
|
||||
/>
|
||||
} @else {
|
||||
<app-category-view
|
||||
[items]="stalkerCategories()"
|
||||
[selectedCategoryId]="$any(stalkerSelectedCategoryId())"
|
||||
(categoryClicked)="onStalkerCategoryClicked($any($event))"
|
||||
/>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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<WorkspaceContextRoute>();
|
||||
readonly section = input.required<string>();
|
||||
|
||||
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();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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: `
|
||||
<div class="context-column">
|
||||
<header class="context-header">
|
||||
<div class="context-header__top">
|
||||
<h2>{{ title() }}</h2>
|
||||
</div>
|
||||
@if (countBadge(); as text) {
|
||||
<span class="context-header__badge">{{ text }}</span>
|
||||
}
|
||||
</header>
|
||||
|
||||
<div class="context-divider"></div>
|
||||
|
||||
<app-category-view
|
||||
[items]="ctx.categories()"
|
||||
[selectedCategoryId]="ctx.selectedCategoryId()"
|
||||
(categoryClicked)="onCategoryClicked($any($event))"
|
||||
/>
|
||||
</div>
|
||||
`,
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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: `
|
||||
<h2 class="panel-title">{{ 'SETTINGS.TITLE' | translate }}</h2>
|
||||
<div class="nav-list" style="margin-top: 16px">
|
||||
<button
|
||||
type="button"
|
||||
class="nav-item"
|
||||
(click)="onBack()"
|
||||
style="margin-bottom: 8px;"
|
||||
>
|
||||
<mat-icon>arrow_back</mat-icon>
|
||||
<span>{{ 'SETTINGS.BACK_TO_HOME' | translate }}</span>
|
||||
</button>
|
||||
|
||||
<p class="panel-header" style="padding-top: 8px;">
|
||||
App preferences
|
||||
</p>
|
||||
<div class="nav-list">
|
||||
@for (section of ctx.sections(); track section.id) {
|
||||
<button
|
||||
type="button"
|
||||
class="nav-item"
|
||||
[class.active]="ctx.activeSection() === section.id"
|
||||
(click)="ctx.setActiveSection(section.id)"
|
||||
>
|
||||
<mat-icon>{{ section.icon }}</mat-icon>
|
||||
<span>{{ section.label | translate }}</span>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
})
|
||||
export class WorkspaceSettingsContextPanelComponent {
|
||||
readonly ctx = inject(SettingsContextService);
|
||||
private readonly location = inject(Location);
|
||||
|
||||
onBack() {
|
||||
this.location.back();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,253 @@
|
||||
<div class="workspace-shell">
|
||||
<aside class="app-rail" [class.is-macos]="isMacOS">
|
||||
<a
|
||||
class="brand"
|
||||
routerLink="/workspace/dashboard"
|
||||
matTooltip="Dashboard"
|
||||
[matTooltipPosition]="'right'"
|
||||
aria-label="Open dashboard"
|
||||
>
|
||||
<img src="./assets/icons/icon-tv-256.png" alt="IPTVnator logo" />
|
||||
</a>
|
||||
|
||||
<app-portal-rail-links
|
||||
variant="rail"
|
||||
activeClass="is-active"
|
||||
[links]="workspaceLinks"
|
||||
/>
|
||||
|
||||
@if (primaryContextLinks().length > 0) {
|
||||
<div class="rail-divider"></div>
|
||||
<app-portal-rail-links
|
||||
variant="rail"
|
||||
activeClass="is-active"
|
||||
[selectedSection]="currentSection()"
|
||||
[links]="primaryContextLinks()"
|
||||
/>
|
||||
}
|
||||
|
||||
@if (secondaryContextLinks().length > 0) {
|
||||
<div class="rail-divider"></div>
|
||||
<app-portal-rail-links
|
||||
variant="rail"
|
||||
activeClass="is-active"
|
||||
[selectedSection]="currentSection()"
|
||||
[links]="secondaryContextLinks()"
|
||||
/>
|
||||
}
|
||||
|
||||
<div class="rail-footer">
|
||||
<a
|
||||
routerLink="/workspace/settings"
|
||||
[class.is-active]="isSettingsRoute()"
|
||||
matTooltip="Settings"
|
||||
[matTooltipPosition]="'right'"
|
||||
aria-label="Open settings"
|
||||
>
|
||||
<mat-icon>settings</mat-icon>
|
||||
</a>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<header class="workspace-header">
|
||||
<app-playlist-switcher
|
||||
class="playlist-switcher"
|
||||
[currentTitle]="playlistTitle()"
|
||||
[subtitle]="playlistSubtitle()"
|
||||
/>
|
||||
|
||||
<label class="search-field">
|
||||
<mat-icon>search</mat-icon>
|
||||
<input
|
||||
type="search"
|
||||
spellcheck="false"
|
||||
[value]="searchQuery()"
|
||||
(input)="onSearchInput($event)"
|
||||
(keydown.enter)="onSearchEnter($event)"
|
||||
[disabled]="!canUseSearch()"
|
||||
[placeholder]="searchPlaceholder()"
|
||||
aria-label="Search"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div class="header-actions">
|
||||
<button
|
||||
type="button"
|
||||
mat-icon-button
|
||||
[matMenuTriggerFor]="addPlaylistMenu().menu()"
|
||||
[matTooltip]="'HOME.URL_UPLOAD.ADD_PLAYLIST' | translate"
|
||||
aria-label="Add playlist"
|
||||
>
|
||||
<mat-icon>add</mat-icon>
|
||||
</button>
|
||||
<app-add-playlist-menu
|
||||
(playlistTypeSelected)="openAddPlaylistDialog($event)"
|
||||
/>
|
||||
@if (canOpenPlaylistInfo()) {
|
||||
<button
|
||||
type="button"
|
||||
mat-icon-button
|
||||
(click)="openPlaylistInfo()"
|
||||
[matTooltip]="'PORTALS.SIDEBAR.PLAYLIST_INFO' | translate"
|
||||
aria-label="Open playlist info"
|
||||
>
|
||||
<mat-icon>playlist_play</mat-icon>
|
||||
</button>
|
||||
}
|
||||
@if (canOpenAccountInfo()) {
|
||||
<button
|
||||
type="button"
|
||||
mat-icon-button
|
||||
(click)="openAccountInfo()"
|
||||
[matTooltip]="'PORTALS.SIDEBAR.ACCOUNT_INFO' | translate"
|
||||
aria-label="Open account info"
|
||||
>
|
||||
<mat-icon>person</mat-icon>
|
||||
</button>
|
||||
}
|
||||
@if (downloadsContext()) {
|
||||
<button
|
||||
type="button"
|
||||
mat-icon-button
|
||||
[class.is-active]="isDownloadsView()"
|
||||
(click)="openDownloadsShortcut()"
|
||||
[matTooltip]="'Downloads'"
|
||||
aria-label="Open downloads"
|
||||
>
|
||||
<mat-icon>download</mat-icon>
|
||||
</button>
|
||||
}
|
||||
@if (headerBulkAction(); as action) {
|
||||
<button
|
||||
type="button"
|
||||
mat-icon-button
|
||||
(click)="runHeaderBulkAction()"
|
||||
[matTooltip]="action.tooltip"
|
||||
[disabled]="action.disabled"
|
||||
[attr.aria-label]="action.ariaLabel"
|
||||
>
|
||||
<mat-icon>{{ action.icon }}</mat-icon>
|
||||
</button>
|
||||
}
|
||||
<button
|
||||
type="button"
|
||||
mat-icon-button
|
||||
[class.is-active]="isFavoritesView()"
|
||||
(click)="toggleFavoritesShortcut()"
|
||||
[matTooltip]="'Favorites only'"
|
||||
[disabled]="!currentContext()"
|
||||
aria-label="Toggle favorites only"
|
||||
>
|
||||
<mat-icon>favorite</mat-icon>
|
||||
</button>
|
||||
@if (canSort()) {
|
||||
<button
|
||||
type="button"
|
||||
mat-icon-button
|
||||
[matMenuTriggerFor]="sortMenu"
|
||||
[matTooltip]="'Sort: ' + contentSortLabel()"
|
||||
aria-label="Sorting options"
|
||||
>
|
||||
<mat-icon>sort</mat-icon>
|
||||
</button>
|
||||
<mat-menu #sortMenu="matMenu">
|
||||
<button
|
||||
mat-menu-item
|
||||
(click)="setContentSortMode('date-desc')"
|
||||
>
|
||||
<span>Date Added (Latest First)</span>
|
||||
@if (contentSortMode() === 'date-desc') {
|
||||
<mat-icon>check</mat-icon>
|
||||
}
|
||||
</button>
|
||||
<button
|
||||
mat-menu-item
|
||||
(click)="setContentSortMode('date-asc')"
|
||||
>
|
||||
<span>Date Added (Oldest First)</span>
|
||||
@if (contentSortMode() === 'date-asc') {
|
||||
<mat-icon>check</mat-icon>
|
||||
}
|
||||
</button>
|
||||
<button
|
||||
mat-menu-item
|
||||
(click)="setContentSortMode('name-asc')"
|
||||
>
|
||||
<span>Name A-Z</span>
|
||||
@if (contentSortMode() === 'name-asc') {
|
||||
<mat-icon>check</mat-icon>
|
||||
}
|
||||
</button>
|
||||
<button
|
||||
mat-menu-item
|
||||
(click)="setContentSortMode('name-desc')"
|
||||
>
|
||||
<span>Name Z-A</span>
|
||||
@if (contentSortMode() === 'name-desc') {
|
||||
<mat-icon>check</mat-icon>
|
||||
}
|
||||
</button>
|
||||
</mat-menu>
|
||||
}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="workspace-body" [class.single-pane]="!showContextPanel()">
|
||||
@if (isSourcesRoute()) {
|
||||
<aside
|
||||
class="context-panel context-panel--sources"
|
||||
appResizable
|
||||
[minWidth]="220"
|
||||
[maxWidth]="440"
|
||||
[defaultWidth]="290"
|
||||
storageKey="workspace-sources-panel-width"
|
||||
>
|
||||
<app-workspace-sources-filters-panel />
|
||||
</aside>
|
||||
} @else if (isCategoryContextRoute()) {
|
||||
@if (currentContext(); as context) {
|
||||
@if (currentSection(); as section) {
|
||||
<aside
|
||||
class="context-panel context-panel--route"
|
||||
appResizable
|
||||
[minWidth]="240"
|
||||
[maxWidth]="560"
|
||||
[defaultWidth]="300"
|
||||
storageKey="workspace-context-panel-width"
|
||||
>
|
||||
<app-workspace-context-panel
|
||||
[context]="context"
|
||||
[section]="section"
|
||||
/>
|
||||
</aside>
|
||||
}
|
||||
}
|
||||
} @else if (isSettingsRoute()) {
|
||||
<aside
|
||||
class="context-panel context-panel--settings"
|
||||
appResizable
|
||||
[minWidth]="200"
|
||||
[maxWidth]="400"
|
||||
[defaultWidth]="260"
|
||||
storageKey="workspace-settings-panel-width"
|
||||
>
|
||||
<app-workspace-settings-context-panel />
|
||||
</aside>
|
||||
} @else if (isFavoritesContextRoute()) {
|
||||
<aside
|
||||
class="context-panel context-panel--route"
|
||||
appResizable
|
||||
[minWidth]="200"
|
||||
[maxWidth]="400"
|
||||
[defaultWidth]="260"
|
||||
storageKey="workspace-favorites-panel-width"
|
||||
>
|
||||
<app-workspace-favorites-context-panel />
|
||||
</aside>
|
||||
}
|
||||
|
||||
<main class="workspace-content">
|
||||
<router-outlet />
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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<typeof setTimeout> | 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<string | null>(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<WorkspaceContext | null>(() =>
|
||||
this.parseWorkspaceContext(this.currentUrl())
|
||||
);
|
||||
readonly currentSection = computed<string | null>(() =>
|
||||
this.parseWorkspaceSection(this.currentUrl())
|
||||
);
|
||||
readonly railContext = computed<WorkspaceContext | null>(() => {
|
||||
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<WorkspaceContext | null>(() => {
|
||||
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<PortalRailLink[]>(() => {
|
||||
const context = this.railContext();
|
||||
if (!context) return [];
|
||||
return buildPortalRailLinks({
|
||||
provider: context.provider,
|
||||
playlistId: context.playlistId,
|
||||
isElectron: this.isElectron,
|
||||
workspace: true,
|
||||
}).primary;
|
||||
});
|
||||
readonly secondaryContextLinks = computed<PortalRailLink[]>(() => {
|
||||
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<WorkspaceContext | null>(() => {
|
||||
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<WorkspaceHeaderBulkAction | null>(
|
||||
() => {
|
||||
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, { type: PlaylistType }>(
|
||||
AddPlaylistDialogComponent,
|
||||
{
|
||||
width: '600px',
|
||||
data: { type },
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
async runHeaderBulkAction(): Promise<void> {
|
||||
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, unknown>): 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';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
<div class="context-column">
|
||||
<header class="context-header">
|
||||
<div class="context-header__top">
|
||||
<h2>Sources</h2>
|
||||
</div>
|
||||
<p>Filter and sort playlists</p>
|
||||
</header>
|
||||
|
||||
<div class="context-divider"></div>
|
||||
|
||||
<section class="panel-section">
|
||||
<div class="nav-list option-list">
|
||||
<h3 class="panel-section-title">Type</h3>
|
||||
@for (option of typeOptions; track option.id) {
|
||||
<a
|
||||
href="#type-{{ option.id }}"
|
||||
class="nav-item category-item option-row"
|
||||
[class.selected]="isTypeActive(option.id)"
|
||||
(click)="$event.preventDefault(); selectType(option.id)"
|
||||
>
|
||||
<mat-icon>{{ option.icon }}</mat-icon>
|
||||
<span class="nav-item-label option-label">
|
||||
{{
|
||||
option.translationKey
|
||||
? (option.translationKey | translate)
|
||||
: option.label
|
||||
}}
|
||||
</span>
|
||||
<div class="nav-item-meta option-meta">
|
||||
{{ getTypeCount(option.id) }}
|
||||
</div>
|
||||
</a>
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel-section">
|
||||
<div class="nav-list option-list">
|
||||
<h3 class="panel-section-title">
|
||||
{{ 'HOME.SORT_BY' | translate }}
|
||||
</h3>
|
||||
@for (option of sortOptions; track option.translationKey) {
|
||||
<a
|
||||
href="#sort-{{ option.translationKey }}"
|
||||
class="nav-item category-item option-row"
|
||||
[class.selected]="isSortActive(option)"
|
||||
(click)="$event.preventDefault(); setSortOption(option)"
|
||||
>
|
||||
<mat-icon>{{ option.icon }}</mat-icon>
|
||||
<span class="nav-item-label option-label">{{
|
||||
option.translationKey | translate
|
||||
}}</span>
|
||||
</a>
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
<section class="sources-page">
|
||||
<header class="sources-header">
|
||||
<h2 class="sources-title">{{ title() }}</h2>
|
||||
<p class="sources-subtitle">{{ subtitle() }}</p>
|
||||
</header>
|
||||
<div class="sources-content">
|
||||
<app-recent-playlists
|
||||
[searchQueryInput]="searchQuery()"
|
||||
(addPlaylistClicked)="onAddPlaylist($event)"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
@@ -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%;
|
||||
}
|
||||
@@ -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, { type: PlaylistType }>(
|
||||
AddPlaylistDialogComponent,
|
||||
{
|
||||
width: '600px',
|
||||
data: { type: playlistType },
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
Vendored
+1
@@ -174,6 +174,7 @@ declare global {
|
||||
playlistId: string
|
||||
) => Promise<boolean>;
|
||||
dbGetFavorites: (playlistId: string) => Promise<any[]>;
|
||||
dbGetGlobalFavorites: () => Promise<any[]>;
|
||||
// Recently viewed (playlist-specific)
|
||||
dbGetRecentItems: (playlistId: string) => Promise<any[]>;
|
||||
dbAddRecentItem: (
|
||||
|
||||
Reference in new issue
Block a user