Proyecto App Linux
This commit is contained in:
@@ -0,0 +1,22 @@
|
||||
export default {
|
||||
displayName: 'workspace-shell-feature',
|
||||
preset: '../../../../jest.preset.js',
|
||||
setupFilesAfterEnv: ['<rootDir>/src/test-setup.ts'],
|
||||
coverageDirectory: '../../../../coverage/libs/workspace/shell/feature',
|
||||
transform: {
|
||||
'^.+\\.(ts|mjs|js|html)$': [
|
||||
'jest-preset-angular',
|
||||
{
|
||||
tsconfig: '<rootDir>/tsconfig.spec.json',
|
||||
stringifyContentPathRegex: '\\.(html|svg)$',
|
||||
},
|
||||
],
|
||||
},
|
||||
transformIgnorePatterns: ['node_modules/(?!.*\\.mjs$)'],
|
||||
snapshotSerializers: [
|
||||
'jest-preset-angular/build/serializers/no-ng-attributes',
|
||||
'jest-preset-angular/build/serializers/ng-snapshot',
|
||||
'jest-preset-angular/build/serializers/html-comment',
|
||||
],
|
||||
moduleFileExtensions: ['ts', 'js', 'html'],
|
||||
};
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"name": "workspace-shell-feature",
|
||||
"$schema": "../../../../node_modules/nx/schemas/project-schema.json",
|
||||
"sourceRoot": "libs/workspace/shell/feature/src",
|
||||
"prefix": "lib",
|
||||
"projectType": "library",
|
||||
"tags": ["scope:workspace", "domain:workspace", "type:feature"],
|
||||
"targets": {
|
||||
"lint": {
|
||||
"executor": "@nx/eslint:lint"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export * from './lib/global-favorites/global-favorites-list.component';
|
||||
export * from './lib/global-favorites/unified-favorite-channel.interface';
|
||||
export * from './lib/workspace-shell/workspace-shell.component';
|
||||
export * from './lib/workspace-sources/workspace-sources.component';
|
||||
+59
@@ -0,0 +1,59 @@
|
||||
<div class="search-panel">
|
||||
<mat-form-field class="search-bar">
|
||||
<mat-icon matPrefix>search</mat-icon>
|
||||
<input
|
||||
matInput
|
||||
type="search"
|
||||
[placeholder]="
|
||||
'WORKSPACE.GLOBAL_FAVORITES.SEARCH_PLACEHOLDER' | translate
|
||||
"
|
||||
[value]="searchTerm()"
|
||||
(input)="searchTerm.set($any($event.target).value)"
|
||||
/>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
|
||||
<div class="nav-list" cdkDropList (cdkDropListDropped)="onDrop($event)">
|
||||
@if (enrichedChannels().length === 0) {
|
||||
<div class="empty-state">
|
||||
<mat-icon class="empty-icon">star_border</mat-icon>
|
||||
@if (searchTerm()) {
|
||||
<p class="empty-title">
|
||||
{{ 'WORKSPACE.GLOBAL_FAVORITES.NO_RESULTS' | translate }}
|
||||
</p>
|
||||
<p class="empty-hint">
|
||||
{{
|
||||
'WORKSPACE.GLOBAL_FAVORITES.NO_RESULTS_HINT'
|
||||
| translate
|
||||
}}
|
||||
</p>
|
||||
} @else {
|
||||
<p class="empty-title">
|
||||
{{
|
||||
'WORKSPACE.GLOBAL_FAVORITES.EMPTY_TITLE' | translate
|
||||
}}
|
||||
</p>
|
||||
<p class="empty-hint">
|
||||
{{ 'WORKSPACE.GLOBAL_FAVORITES.EMPTY_HINT' | translate }}
|
||||
</p>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
@for (ch of enrichedChannels(); track trackByUid($index, ch)) {
|
||||
<app-channel-list-item
|
||||
[isDraggable]="!searchTerm()"
|
||||
[logo]="ch.logo"
|
||||
[name]="ch.name"
|
||||
[selected]="activeUid() === ch.uid"
|
||||
[showEpg]="true"
|
||||
[epgProgram]="ch.currentEpgProgram"
|
||||
[progressPercentage]="ch.progressPercentage"
|
||||
[showFavoriteButton]="true"
|
||||
[isFavorite]="true"
|
||||
[showProgramInfoButton]="true"
|
||||
(clicked)="onChannelClick(ch)"
|
||||
(favoriteToggled)="onFavoriteToggled(ch)"
|
||||
/>
|
||||
}
|
||||
</div>
|
||||
+79
@@ -0,0 +1,79 @@
|
||||
@use '@angular/material' as mat;
|
||||
|
||||
:host {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
// ─── Search ────────────────────────────────────────────────────────────────────
|
||||
|
||||
.search-panel {
|
||||
margin-top: 10px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin-bottom: -10px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.search-bar {
|
||||
width: 90%;
|
||||
@include mat.form-field-density(-5);
|
||||
}
|
||||
|
||||
:host ::ng-deep .search-bar div {
|
||||
border-radius: 20px;
|
||||
}
|
||||
|
||||
:host ::ng-deep .search-bar div[matformfieldlineripple],
|
||||
:host ::ng-deep .search-bar .mat-mdc-form-field-subscript-wrapper {
|
||||
display: none;
|
||||
}
|
||||
|
||||
// ─── List ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
.nav-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
padding: 8px 8px 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
// ─── Empty state ───────────────────────────────────────────────────────────────
|
||||
|
||||
.empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 48px 24px;
|
||||
text-align: center;
|
||||
color: var(--mat-sys-on-surface-variant);
|
||||
|
||||
.empty-icon {
|
||||
font-size: 48px;
|
||||
height: 48px;
|
||||
width: 48px;
|
||||
margin-bottom: 16px;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.empty-title {
|
||||
font-size: 1.05rem;
|
||||
font-weight: 520;
|
||||
letter-spacing: -0.01em;
|
||||
margin: 0 0 8px;
|
||||
}
|
||||
|
||||
.empty-hint {
|
||||
font-size: 0.85rem;
|
||||
opacity: 0.65;
|
||||
margin: 0;
|
||||
max-width: 260px;
|
||||
}
|
||||
}
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
export {
|
||||
EnrichedUnifiedFavorite,
|
||||
GlobalFavoritesListComponent,
|
||||
} from '@iptvnator/portal/shared/ui';
|
||||
@@ -0,0 +1,367 @@
|
||||
/**
|
||||
* GlobalFavoritesService
|
||||
*
|
||||
* Aggregates live TV favorites from all three playlist source types:
|
||||
* - M3U – channels whose URL/id is listed in playlist.favorites[]
|
||||
* - Xtream – rows in the `favorites` DB table where content.type = 'live'
|
||||
* - Stalker – items in playlist.favorites[] that have a `cmd` (live channels)
|
||||
* and do NOT have movie_id / series_id
|
||||
*
|
||||
* Also handles reordering: persists position for Xtream rows via IPC.
|
||||
* For M3U and Stalker the list order is managed in the app_state table
|
||||
* as a JSON array of UIDs (keyed by GLOBAL_FAVORITES_ORDER_KEY).
|
||||
*/
|
||||
import { inject, Injectable } from '@angular/core';
|
||||
import { Store } from '@ngrx/store';
|
||||
import { TranslateService } from '@ngx-translate/core';
|
||||
import { selectAllPlaylistsMeta } from 'm3u-state';
|
||||
import { firstValueFrom, map } from 'rxjs';
|
||||
import { DatabaseService, PlaylistsService } from 'services';
|
||||
import {
|
||||
Channel,
|
||||
Playlist,
|
||||
PlaylistMeta,
|
||||
StalkerPortalItem,
|
||||
} from 'shared-interfaces';
|
||||
import {
|
||||
buildFavoriteUid,
|
||||
UnifiedFavoriteChannel,
|
||||
} from '@iptvnator/portal/shared/util';
|
||||
|
||||
const GLOBAL_FAVORITES_ORDER_KEY = 'global-favorites-channel-order-v1';
|
||||
|
||||
interface XtreamGlobalFavoriteRow {
|
||||
readonly added_at?: string | null;
|
||||
readonly id: number;
|
||||
readonly playlist_id: string;
|
||||
readonly playlist_name: string;
|
||||
readonly position?: number | null;
|
||||
readonly poster_url?: string | null;
|
||||
readonly title: string;
|
||||
readonly xtream_id: number;
|
||||
}
|
||||
|
||||
type PlaylistWithChannels = Playlist & {
|
||||
readonly playlist?: {
|
||||
readonly items?: Channel[];
|
||||
};
|
||||
};
|
||||
|
||||
function isStalkerFavoriteItem(
|
||||
value: string | StalkerPortalItem
|
||||
): value is StalkerPortalItem {
|
||||
return typeof value !== 'string';
|
||||
}
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class GlobalFavoritesService {
|
||||
private readonly store = inject(Store);
|
||||
private readonly dbService = inject(DatabaseService);
|
||||
private readonly playlistsService = inject(PlaylistsService);
|
||||
private readonly translate = inject(TranslateService);
|
||||
|
||||
/**
|
||||
* Fetch and merge live TV favorites from all source types.
|
||||
* The returned list is sorted by persisted position first,
|
||||
* then by addedAt descending for items without a position.
|
||||
*/
|
||||
async getUnifiedLiveFavorites(): Promise<UnifiedFavoriteChannel[]> {
|
||||
const [m3uItems, xtreamItems, stalkerItems, savedOrder] =
|
||||
await Promise.all([
|
||||
this.getM3uFavorites(),
|
||||
this.getXtreamLiveFavorites(),
|
||||
this.getStalkerLiveFavorites(),
|
||||
this.getSavedOrder(),
|
||||
]);
|
||||
|
||||
const all = [...m3uItems, ...xtreamItems, ...stalkerItems];
|
||||
|
||||
return this.applyOrder(all, savedOrder);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a channel from its source playlist's favorites.
|
||||
* Works for all three source types (M3U, Xtream, Stalker).
|
||||
*/
|
||||
async removeFavorite(channel: UnifiedFavoriteChannel): Promise<void> {
|
||||
switch (channel.sourceType) {
|
||||
case 'm3u': {
|
||||
const playlist = await firstValueFrom(
|
||||
this.playlistsService.getPlaylistById(channel.playlistId)
|
||||
);
|
||||
const currentFavs = (playlist.favorites as string[]) ?? [];
|
||||
const filtered = currentFavs.filter(
|
||||
(f) => f !== channel.streamUrl
|
||||
);
|
||||
await firstValueFrom(
|
||||
this.playlistsService.setFavorites(
|
||||
channel.playlistId,
|
||||
filtered
|
||||
)
|
||||
);
|
||||
break;
|
||||
}
|
||||
case 'xtream': {
|
||||
if (window.electron && channel.contentId != null) {
|
||||
await window.electron.dbRemoveFavorite(
|
||||
channel.contentId,
|
||||
channel.playlistId
|
||||
);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'stalker': {
|
||||
const sourceItemId = channel.uid.split('::')[2];
|
||||
await firstValueFrom(
|
||||
this.playlistsService.removeFromPortalFavorites(
|
||||
channel.playlistId,
|
||||
sourceItemId
|
||||
)
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist the new display order.
|
||||
* For Xtream items update the DB position column.
|
||||
* For all items, persist the UID order in app_state.
|
||||
*/
|
||||
async reorder(channels: UnifiedFavoriteChannel[]): Promise<void> {
|
||||
const uidOrder = channels.map((ch) => ch.uid);
|
||||
|
||||
// Persist xtream positions to DB
|
||||
const xtreamUpdates = channels
|
||||
.filter(
|
||||
(
|
||||
ch
|
||||
): ch is UnifiedFavoriteChannel & { contentId: number } =>
|
||||
ch.sourceType === 'xtream' && ch.contentId != null
|
||||
)
|
||||
.map((ch, index) => ({
|
||||
content_id: ch.contentId,
|
||||
position: index,
|
||||
}));
|
||||
|
||||
await Promise.all([
|
||||
xtreamUpdates.length > 0 && window.electron
|
||||
? window.electron.dbReorderGlobalFavorites(xtreamUpdates)
|
||||
: Promise.resolve(),
|
||||
this.saveOrder(uidOrder),
|
||||
]);
|
||||
}
|
||||
|
||||
// ─── Private helpers ───────────────────────────────────────────
|
||||
|
||||
private async getM3uFavorites(): Promise<UnifiedFavoriteChannel[]> {
|
||||
const allMeta = await firstValueFrom(
|
||||
this.store
|
||||
.select(selectAllPlaylistsMeta)
|
||||
.pipe(map((metas) => metas as PlaylistMeta[]))
|
||||
);
|
||||
const m3uPlaylists = allMeta.filter(
|
||||
(p) => p._id && !p.serverUrl && !p.macAddress
|
||||
);
|
||||
|
||||
const results: UnifiedFavoriteChannel[] = [];
|
||||
|
||||
for (const meta of m3uPlaylists) {
|
||||
if (!meta.favorites?.length) continue;
|
||||
const favoriteIds = new Set(
|
||||
(meta.favorites as string[]).map(String)
|
||||
);
|
||||
|
||||
// Load full playlist to access channel items
|
||||
let playlist: PlaylistWithChannels | undefined;
|
||||
try {
|
||||
playlist = (await firstValueFrom(
|
||||
this.playlistsService.getPlaylistById(meta._id)
|
||||
)) as PlaylistWithChannels | undefined;
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
|
||||
const items = playlist?.playlist?.items ?? [];
|
||||
for (const ch of items) {
|
||||
if (favoriteIds.has(ch.id) || favoriteIds.has(ch.url)) {
|
||||
const sourceItemId = ch.url || ch.id;
|
||||
results.push({
|
||||
uid: buildFavoriteUid('m3u', meta._id, sourceItemId),
|
||||
name: ch.name,
|
||||
logo: ch.tvg?.logo ?? null,
|
||||
sourceType: 'm3u',
|
||||
playlistId: meta._id,
|
||||
playlistName: meta.title || meta.filename || 'M3U',
|
||||
streamUrl: ch.url,
|
||||
tvgId: ch.tvg?.id || ch.tvg?.name || ch.name,
|
||||
addedAt: new Date(0).toISOString(),
|
||||
position: 0,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
private async getXtreamLiveFavorites(): Promise<UnifiedFavoriteChannel[]> {
|
||||
if (!window.electron) return [];
|
||||
|
||||
let items: XtreamGlobalFavoriteRow[] = [];
|
||||
try {
|
||||
items =
|
||||
(await this.dbService.getGlobalFavorites()) as XtreamGlobalFavoriteRow[];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
|
||||
// getGlobalFavorites now only returns type='live'
|
||||
return items.map((item) => ({
|
||||
uid: buildFavoriteUid('xtream', item.playlist_id, item.xtream_id),
|
||||
name: item.title,
|
||||
logo: item.poster_url ?? null,
|
||||
sourceType: 'xtream' as const,
|
||||
playlistId: item.playlist_id,
|
||||
playlistName: item.playlist_name,
|
||||
xtreamId: item.xtream_id,
|
||||
tvgId: String(item.xtream_id),
|
||||
addedAt: item.added_at ?? new Date(0).toISOString(),
|
||||
position: item.position ?? 0,
|
||||
contentId: item.id,
|
||||
}));
|
||||
}
|
||||
|
||||
private async getStalkerLiveFavorites(): Promise<UnifiedFavoriteChannel[]> {
|
||||
const allMeta = await firstValueFrom(
|
||||
this.store
|
||||
.select(selectAllPlaylistsMeta)
|
||||
.pipe(map((metas) => metas as PlaylistMeta[]))
|
||||
);
|
||||
|
||||
const stalkerPlaylists = allMeta.filter((p) => p._id && p.macAddress);
|
||||
|
||||
const results: UnifiedFavoriteChannel[] = [];
|
||||
|
||||
for (const meta of stalkerPlaylists) {
|
||||
if (!meta.favorites?.length) continue;
|
||||
|
||||
// Fetch full playlist to get credentials
|
||||
let playlist: Playlist | undefined;
|
||||
try {
|
||||
playlist = (await firstValueFrom(
|
||||
this.playlistsService.getPlaylistById(meta._id)
|
||||
)) as Playlist | undefined;
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
|
||||
const favorites = Array.isArray(playlist?.favorites)
|
||||
? playlist.favorites.filter(isStalkerFavoriteItem)
|
||||
: [];
|
||||
|
||||
for (const fav of favorites) {
|
||||
// Live channels: have a cmd, no movie_id / series_id
|
||||
const isLive =
|
||||
fav.cmd &&
|
||||
!fav.movie_id &&
|
||||
!fav.series_id &&
|
||||
(fav.stream_type === 'live' ||
|
||||
fav.category_id === 'itv' ||
|
||||
(!fav.stream_type && !fav.series_id && !fav.movie_id));
|
||||
if (!isLive) continue;
|
||||
|
||||
const streamId = fav.stream_id ?? fav.id;
|
||||
results.push({
|
||||
uid: buildFavoriteUid('stalker', meta._id, streamId),
|
||||
name:
|
||||
fav.o_name ||
|
||||
fav.name ||
|
||||
this.translate.instant(
|
||||
'WORKSPACE.GLOBAL_FAVORITES.UNKNOWN_CHANNEL'
|
||||
),
|
||||
logo: fav.logo ?? fav.cover ?? null,
|
||||
sourceType: 'stalker' as const,
|
||||
playlistId: meta._id,
|
||||
playlistName:
|
||||
meta.title ||
|
||||
meta.filename ||
|
||||
this.translate.instant(
|
||||
'WORKSPACE.DASHBOARD.STALKER_PORTAL'
|
||||
),
|
||||
stalkerCmd: fav.cmd,
|
||||
stalkerPortalUrl: playlist.portalUrl ?? playlist.url,
|
||||
stalkerMacAddress: playlist.macAddress,
|
||||
addedAt: fav.added_at
|
||||
? new Date(fav.added_at).toISOString()
|
||||
: new Date(0).toISOString(),
|
||||
position: 0,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
private async getSavedOrder(): Promise<string[]> {
|
||||
if (!window.electron?.dbGetAppState) return [];
|
||||
try {
|
||||
const raw = await window.electron.dbGetAppState(
|
||||
GLOBAL_FAVORITES_ORDER_KEY
|
||||
);
|
||||
if (!raw) return [];
|
||||
return JSON.parse(raw) as string[];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
private async saveOrder(uidOrder: string[]): Promise<void> {
|
||||
if (!window.electron?.dbSetAppState) return;
|
||||
try {
|
||||
await window.electron.dbSetAppState(
|
||||
GLOBAL_FAVORITES_ORDER_KEY,
|
||||
JSON.stringify(uidOrder)
|
||||
);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
private applyOrder(
|
||||
channels: UnifiedFavoriteChannel[],
|
||||
savedOrder: string[]
|
||||
): UnifiedFavoriteChannel[] {
|
||||
if (!savedOrder.length) {
|
||||
// Default: sort by addedAt descending, xtream position asc
|
||||
return channels.slice().sort((a, b) => {
|
||||
if (a.position !== b.position) return a.position - b.position;
|
||||
return (
|
||||
new Date(b.addedAt).getTime() -
|
||||
new Date(a.addedAt).getTime()
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
const orderMap = new Map(savedOrder.map((uid, i) => [uid, i]));
|
||||
const ordered: UnifiedFavoriteChannel[] = [];
|
||||
const unordered: UnifiedFavoriteChannel[] = [];
|
||||
|
||||
for (const ch of channels) {
|
||||
const position = orderMap.get(ch.uid);
|
||||
if (position != null) {
|
||||
ordered.push({ ...ch, position });
|
||||
} else {
|
||||
unordered.push(ch);
|
||||
}
|
||||
}
|
||||
|
||||
ordered.sort((a, b) => a.position - b.position);
|
||||
unordered.sort(
|
||||
(a, b) =>
|
||||
new Date(b.addedAt).getTime() - new Date(a.addedAt).getTime()
|
||||
);
|
||||
|
||||
return [...ordered, ...unordered];
|
||||
}
|
||||
}
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
export {
|
||||
buildFavoriteUid,
|
||||
FavoriteSourceType,
|
||||
UnifiedFavoriteChannel,
|
||||
} from '@iptvnator/portal/shared/util';
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from './playlist-drop-overlay.component';
|
||||
export * from './playlist-drop-zone.directive';
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
@if (isVisible()) {
|
||||
<div class="drop-overlay__backdrop" aria-hidden="true"></div>
|
||||
<div
|
||||
class="drop-overlay__card"
|
||||
[class.is-rejected]="isRejected()"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
>
|
||||
<div class="drop-overlay__icon-wrap">
|
||||
<mat-icon class="drop-overlay__icon">
|
||||
{{ isRejected() ? 'error_outline' : 'file_download' }}
|
||||
</mat-icon>
|
||||
</div>
|
||||
<h2 class="drop-overlay__title">
|
||||
{{
|
||||
(isRejected()
|
||||
? rejectionKey()
|
||||
: 'WORKSPACE.SHELL.DROP_OVERLAY_TITLE'
|
||||
) | translate
|
||||
}}
|
||||
</h2>
|
||||
<p class="drop-overlay__body">
|
||||
{{
|
||||
(isRejected()
|
||||
? 'WORKSPACE.SHELL.DROP_OVERLAY_REJECTED_BODY'
|
||||
: 'WORKSPACE.SHELL.DROP_OVERLAY_BODY'
|
||||
) | translate
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
+149
@@ -0,0 +1,149 @@
|
||||
:host {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 9999;
|
||||
display: none;
|
||||
pointer-events: none;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
:host.is-visible {
|
||||
display: flex;
|
||||
animation: drop-overlay-fade 0.15s ease-out;
|
||||
}
|
||||
|
||||
.drop-overlay__backdrop {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: rgba(2, 6, 23, 0.6);
|
||||
backdrop-filter: blur(8px);
|
||||
-webkit-backdrop-filter: blur(8px);
|
||||
}
|
||||
|
||||
.drop-overlay__card {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
gap: 12px;
|
||||
padding: clamp(28px, 5vh, 48px) clamp(28px, 5vw, 56px);
|
||||
min-width: 320px;
|
||||
max-width: min(520px, 88vw);
|
||||
background: linear-gradient(
|
||||
145deg,
|
||||
rgba(96, 165, 250, 0.12),
|
||||
rgba(37, 99, 235, 0.08)
|
||||
);
|
||||
border: 2px dashed rgba(96, 165, 250, 0.55);
|
||||
border-radius: 18px;
|
||||
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.45);
|
||||
color: #f1f5f9;
|
||||
animation: drop-overlay-scale 0.18s ease-out;
|
||||
|
||||
&.is-rejected {
|
||||
border-color: rgba(248, 113, 113, 0.55);
|
||||
background: linear-gradient(
|
||||
145deg,
|
||||
rgba(248, 113, 113, 0.12),
|
||||
rgba(220, 38, 38, 0.08)
|
||||
);
|
||||
|
||||
.drop-overlay__icon-wrap {
|
||||
background: linear-gradient(135deg, #f87171, #dc2626);
|
||||
box-shadow: 0 8px 22px rgba(220, 38, 38, 0.4);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.drop-overlay__icon-wrap {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
border-radius: 18px;
|
||||
background: linear-gradient(135deg, #3b82f6, #2563eb);
|
||||
box-shadow: 0 8px 22px rgba(59, 130, 246, 0.45);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.drop-overlay__icon {
|
||||
font-size: 36px;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.drop-overlay__title {
|
||||
font-family: Roboto, 'Helvetica Neue', sans-serif;
|
||||
font-weight: 600;
|
||||
font-size: 1.25rem;
|
||||
margin: 0;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
.drop-overlay__body {
|
||||
font-family: Roboto, 'Helvetica Neue', sans-serif;
|
||||
font-size: 0.9rem;
|
||||
margin: 0;
|
||||
opacity: 0.78;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
@keyframes drop-overlay-fade {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes drop-overlay-scale {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(6px) scale(0.97);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
:host.is-visible {
|
||||
animation: drop-overlay-fade 0.1s ease-out;
|
||||
}
|
||||
|
||||
.drop-overlay__card {
|
||||
animation: drop-overlay-fade 0.1s ease-out;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: light) {
|
||||
.drop-overlay__backdrop {
|
||||
background: rgba(15, 23, 42, 0.45);
|
||||
}
|
||||
|
||||
.drop-overlay__card {
|
||||
background: linear-gradient(
|
||||
145deg,
|
||||
rgba(255, 255, 255, 0.96),
|
||||
rgba(241, 245, 249, 0.96)
|
||||
);
|
||||
color: #0f172a;
|
||||
border-color: rgba(37, 99, 235, 0.55);
|
||||
|
||||
&.is-rejected {
|
||||
background: linear-gradient(
|
||||
145deg,
|
||||
rgba(254, 226, 226, 0.96),
|
||||
rgba(252, 165, 165, 0.92)
|
||||
);
|
||||
border-color: rgba(220, 38, 38, 0.6);
|
||||
}
|
||||
}
|
||||
}
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
import { ChangeDetectionStrategy, Component, input } from '@angular/core';
|
||||
import { MatIcon } from '@angular/material/icon';
|
||||
import { TranslatePipe } from '@ngx-translate/core';
|
||||
|
||||
export type PlaylistDropOverlayState =
|
||||
| { kind: 'idle' }
|
||||
| { kind: 'dragging' }
|
||||
| { kind: 'rejected'; reason: 'unsupported' | 'empty' | 'read-error' };
|
||||
|
||||
@Component({
|
||||
selector: 'app-playlist-drop-overlay',
|
||||
templateUrl: './playlist-drop-overlay.component.html',
|
||||
styleUrl: './playlist-drop-overlay.component.scss',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [MatIcon, TranslatePipe],
|
||||
host: {
|
||||
'[class.is-visible]': 'isVisible()',
|
||||
'[attr.aria-hidden]': '!isVisible()',
|
||||
},
|
||||
})
|
||||
export class PlaylistDropOverlayComponent {
|
||||
readonly state = input<PlaylistDropOverlayState>({ kind: 'idle' });
|
||||
|
||||
readonly isVisible = (): boolean => this.state().kind !== 'idle';
|
||||
|
||||
readonly isRejected = (): boolean => this.state().kind === 'rejected';
|
||||
|
||||
readonly rejectionKey = (): string => {
|
||||
const current = this.state();
|
||||
if (current.kind !== 'rejected') return '';
|
||||
switch (current.reason) {
|
||||
case 'unsupported':
|
||||
return 'WORKSPACE.SHELL.DROP_OVERLAY_REJECTED_UNSUPPORTED';
|
||||
case 'empty':
|
||||
return 'WORKSPACE.SHELL.DROP_OVERLAY_REJECTED_EMPTY';
|
||||
case 'read-error':
|
||||
return 'WORKSPACE.SHELL.DROP_OVERLAY_REJECTED_READ_ERROR';
|
||||
}
|
||||
};
|
||||
}
|
||||
+134
@@ -0,0 +1,134 @@
|
||||
import {
|
||||
DestroyRef,
|
||||
Directive,
|
||||
ElementRef,
|
||||
HostListener,
|
||||
OnInit,
|
||||
inject,
|
||||
signal,
|
||||
} from '@angular/core';
|
||||
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||
import { MatSnackBar } from '@angular/material/snack-bar';
|
||||
import { TranslateService } from '@ngx-translate/core';
|
||||
import { PlaylistFileImportService } from '@iptvnator/playlist/shared/util';
|
||||
import { fromEvent, merge } from 'rxjs';
|
||||
import type { PlaylistDropOverlayState } from './playlist-drop-overlay.component';
|
||||
|
||||
const REJECTED_DISMISS_MS = 1800;
|
||||
|
||||
@Directive({
|
||||
selector: '[appPlaylistDropZone]',
|
||||
exportAs: 'playlistDropZone',
|
||||
})
|
||||
export class PlaylistDropZoneDirective implements OnInit {
|
||||
private readonly host = inject(ElementRef<HTMLElement>);
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
private readonly importService = inject(PlaylistFileImportService);
|
||||
private readonly snackBar = inject(MatSnackBar);
|
||||
private readonly translate = inject(TranslateService);
|
||||
|
||||
private dragDepth = 0;
|
||||
private rejectionTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
readonly overlayState = signal<PlaylistDropOverlayState>({ kind: 'idle' });
|
||||
|
||||
ngOnInit(): void {
|
||||
merge(
|
||||
fromEvent<KeyboardEvent>(window, 'keydown'),
|
||||
fromEvent(window, 'blur')
|
||||
)
|
||||
.pipe(takeUntilDestroyed(this.destroyRef))
|
||||
.subscribe((event) => {
|
||||
if (event instanceof KeyboardEvent && event.key !== 'Escape') {
|
||||
return;
|
||||
}
|
||||
this.resetState();
|
||||
});
|
||||
}
|
||||
|
||||
@HostListener('dragenter', ['$event'])
|
||||
onDragEnter(event: DragEvent): void {
|
||||
if (!this.isFileDrag(event)) return;
|
||||
event.preventDefault();
|
||||
this.dragDepth += 1;
|
||||
if (this.overlayState().kind === 'rejected') return;
|
||||
this.overlayState.set({ kind: 'dragging' });
|
||||
}
|
||||
|
||||
@HostListener('dragover', ['$event'])
|
||||
onDragOver(event: DragEvent): void {
|
||||
if (!this.isFileDrag(event)) return;
|
||||
event.preventDefault();
|
||||
if (event.dataTransfer) {
|
||||
event.dataTransfer.dropEffect = 'copy';
|
||||
}
|
||||
}
|
||||
|
||||
@HostListener('dragleave', ['$event'])
|
||||
onDragLeave(event: DragEvent): void {
|
||||
if (!this.isFileDrag(event)) return;
|
||||
this.dragDepth = Math.max(0, this.dragDepth - 1);
|
||||
if (this.dragDepth === 0 && this.overlayState().kind === 'dragging') {
|
||||
this.overlayState.set({ kind: 'idle' });
|
||||
}
|
||||
}
|
||||
|
||||
@HostListener('drop', ['$event'])
|
||||
async onDrop(event: DragEvent): Promise<void> {
|
||||
if (!this.isFileDrag(event)) return;
|
||||
event.preventDefault();
|
||||
this.dragDepth = 0;
|
||||
|
||||
const file = event.dataTransfer?.files?.[0];
|
||||
if (!file) {
|
||||
this.overlayState.set({ kind: 'idle' });
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await this.importService.importFile(file);
|
||||
if (result.ok === true) {
|
||||
this.overlayState.set({ kind: 'idle' });
|
||||
this.snackBar.open(
|
||||
this.translate.instant(
|
||||
'WORKSPACE.SHELL.DROP_IMPORT_SUCCESS',
|
||||
{ title: result.title }
|
||||
),
|
||||
undefined,
|
||||
{ duration: 3000 }
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
this.flashRejection(result.reason);
|
||||
}
|
||||
|
||||
private isFileDrag(event: DragEvent): boolean {
|
||||
const types = event.dataTransfer?.types;
|
||||
if (!types) return false;
|
||||
return Array.from(types).includes('Files');
|
||||
}
|
||||
|
||||
private flashRejection(
|
||||
reason: 'unsupported' | 'empty' | 'read-error'
|
||||
): void {
|
||||
this.overlayState.set({ kind: 'rejected', reason });
|
||||
this.clearRejectionTimer();
|
||||
this.rejectionTimer = setTimeout(() => {
|
||||
this.overlayState.set({ kind: 'idle' });
|
||||
this.rejectionTimer = null;
|
||||
}, REJECTED_DISMISS_MS);
|
||||
}
|
||||
|
||||
private resetState(): void {
|
||||
this.dragDepth = 0;
|
||||
this.clearRejectionTimer();
|
||||
this.overlayState.set({ kind: 'idle' });
|
||||
}
|
||||
|
||||
private clearRejectionTimer(): void {
|
||||
if (this.rejectionTimer !== null) {
|
||||
clearTimeout(this.rejectionTimer);
|
||||
this.rejectionTimer = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
+61
@@ -0,0 +1,61 @@
|
||||
<div class="palette-shell">
|
||||
<label class="palette-search">
|
||||
<mat-icon class="palette-search__icon">search</mat-icon>
|
||||
<input
|
||||
#queryInput
|
||||
type="search"
|
||||
spellcheck="false"
|
||||
[value]="query()"
|
||||
(input)="onQueryInput($event)"
|
||||
(keydown)="onInputKeydown($event)"
|
||||
[placeholder]="
|
||||
'WORKSPACE.COMMAND_PALETTE.SEARCH_PLACEHOLDER' | translate
|
||||
"
|
||||
[attr.aria-label]="
|
||||
'WORKSPACE.COMMAND_PALETTE.SEARCH_LABEL' | translate
|
||||
"
|
||||
/>
|
||||
<kbd class="palette-search__kbd">⌘K</kbd>
|
||||
</label>
|
||||
|
||||
@if (commandGroups().length > 0) {
|
||||
<div class="palette-divider"></div>
|
||||
|
||||
<div class="palette-results">
|
||||
@for (group of commandGroups(); track group.group) {
|
||||
<section class="palette-group">
|
||||
<h3 class="palette-group__title">
|
||||
{{ getGroupTitleKey(group.group) | translate }}
|
||||
</h3>
|
||||
@for (command of group.items; track command.id) {
|
||||
<button
|
||||
type="button"
|
||||
class="palette-command"
|
||||
[class.is-selected]="isCommandSelected(command)"
|
||||
[disabled]="!command.enabled"
|
||||
(mouseenter)="onCommandHover(command)"
|
||||
(click)="onCommandClick(command)"
|
||||
>
|
||||
<span class="palette-command__icon">
|
||||
<mat-icon>{{ command.icon }}</mat-icon>
|
||||
</span>
|
||||
<span class="palette-command__label">{{
|
||||
command.label
|
||||
}}</span>
|
||||
@if (command.description) {
|
||||
<span class="palette-command__desc">{{
|
||||
command.description
|
||||
}}</span>
|
||||
}
|
||||
</button>
|
||||
}
|
||||
</section>
|
||||
}
|
||||
</div>
|
||||
} @else {
|
||||
<div class="palette-divider"></div>
|
||||
<div class="palette-empty">
|
||||
{{ 'WORKSPACE.COMMAND_PALETTE.EMPTY' | translate }}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
+219
@@ -0,0 +1,219 @@
|
||||
:host {
|
||||
display: block;
|
||||
width: 100%;
|
||||
max-height: 80vh;
|
||||
app-region: no-drag;
|
||||
}
|
||||
|
||||
// ─── Shell ───────────────────────────────────────────────────────────────────
|
||||
.palette-shell {
|
||||
background: var(--app-widget-bg);
|
||||
color: var(--mat-sys-on-surface);
|
||||
border: 1px solid var(--app-separator);
|
||||
border-radius: 14px;
|
||||
box-shadow:
|
||||
0 24px 48px -28px rgba(0, 0, 0, 0.5),
|
||||
0 10px 24px -18px rgba(0, 0, 0, 0.35);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
// ─── Search ──────────────────────────────────────────────────────────────────
|
||||
.palette-search {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 0 14px;
|
||||
min-height: 44px;
|
||||
cursor: text;
|
||||
}
|
||||
|
||||
.palette-search__icon {
|
||||
font-size: 20px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
color: var(--mat-sys-on-surface-variant);
|
||||
flex-shrink: 0;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.palette-search input {
|
||||
flex: 1;
|
||||
border: none;
|
||||
outline: none;
|
||||
background: transparent;
|
||||
color: var(--mat-sys-on-surface);
|
||||
font: inherit;
|
||||
font-size: 0.94rem;
|
||||
font-weight: 450;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
|
||||
&::placeholder {
|
||||
color: var(--mat-sys-on-surface-variant);
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
&::-webkit-search-cancel-button {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.palette-search__kbd {
|
||||
font-family: inherit;
|
||||
font-size: 0.68rem;
|
||||
font-weight: 500;
|
||||
color: var(--mat-sys-on-surface-variant);
|
||||
border: 1px solid
|
||||
color-mix(
|
||||
in srgb,
|
||||
var(--mat-sys-outline-variant) 80%,
|
||||
transparent
|
||||
);
|
||||
border-radius: 5px;
|
||||
padding: 2px 5px;
|
||||
line-height: 1;
|
||||
flex-shrink: 0;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
// ─── Divider ─────────────────────────────────────────────────────────────────
|
||||
.palette-divider {
|
||||
height: 1px;
|
||||
background: var(--app-separator);
|
||||
}
|
||||
|
||||
// ─── Results ─────────────────────────────────────────────────────────────────
|
||||
.palette-results {
|
||||
max-height: min(56vh, 480px);
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
padding: 6px;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--mat-sys-outline-variant) transparent;
|
||||
}
|
||||
|
||||
// ─── Group ───────────────────────────────────────────────────────────────────
|
||||
.palette-group {
|
||||
&:not(:first-child) {
|
||||
margin-top: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
.palette-group__title {
|
||||
margin: 0;
|
||||
padding: 6px 8px 4px;
|
||||
font-size: 0.68rem;
|
||||
font-weight: 600;
|
||||
color: var(--mat-sys-on-surface-variant);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
// ─── Command Row ─────────────────────────────────────────────────────────────
|
||||
.palette-command {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
padding: 7px 8px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 9px;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background 0.12s ease,
|
||||
border-color 0.12s ease;
|
||||
|
||||
&:hover:not(:disabled),
|
||||
&.is-selected:not(:disabled) {
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
var(--app-selection-surface-strong),
|
||||
var(--app-selection-surface)
|
||||
);
|
||||
border-color: var(--app-selection-border);
|
||||
}
|
||||
|
||||
&.is-selected:not(:disabled) {
|
||||
box-shadow: 0 1px 4px -2px var(--app-selection-glow);
|
||||
|
||||
.palette-command__icon {
|
||||
background: color-mix(
|
||||
in srgb,
|
||||
var(--app-selection-color) 22%,
|
||||
transparent
|
||||
);
|
||||
color: var(--app-selection-color);
|
||||
}
|
||||
|
||||
.palette-command__label {
|
||||
color: var(--app-selection-color);
|
||||
}
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.4;
|
||||
}
|
||||
}
|
||||
|
||||
.palette-command__icon {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 7px;
|
||||
background: color-mix(
|
||||
in srgb,
|
||||
var(--mat-sys-on-surface-variant) 10%,
|
||||
transparent
|
||||
);
|
||||
color: var(--mat-sys-on-surface-variant);
|
||||
flex-shrink: 0;
|
||||
transition:
|
||||
background 0.12s ease,
|
||||
color 0.12s ease;
|
||||
|
||||
mat-icon {
|
||||
font-size: 17px;
|
||||
width: 17px;
|
||||
height: 17px;
|
||||
}
|
||||
}
|
||||
|
||||
.palette-command__label {
|
||||
font-size: 0.84rem;
|
||||
font-weight: 530;
|
||||
letter-spacing: -0.006em;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
min-width: 0;
|
||||
transition: color 0.12s ease;
|
||||
}
|
||||
|
||||
.palette-command__desc {
|
||||
margin-left: auto;
|
||||
font-size: 0.74rem;
|
||||
color: var(--mat-sys-on-surface-variant);
|
||||
opacity: 0.65;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
flex-shrink: 1;
|
||||
min-width: 0;
|
||||
padding-left: 12px;
|
||||
}
|
||||
|
||||
// ─── Empty State ─────────────────────────────────────────────────────────────
|
||||
.palette-empty {
|
||||
padding: 20px 14px;
|
||||
text-align: center;
|
||||
color: var(--mat-sys-on-surface-variant);
|
||||
font-size: 0.82rem;
|
||||
opacity: 0.7;
|
||||
}
|
||||
+165
@@ -0,0 +1,165 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
|
||||
import { TranslateService } from '@ngx-translate/core';
|
||||
import { of } from 'rxjs';
|
||||
import { WorkspaceResolvedCommandItem } from '@iptvnator/portal/shared/util';
|
||||
import { WorkspaceCommandPaletteComponent } from './workspace-command-palette.component';
|
||||
|
||||
describe('WorkspaceCommandPaletteComponent', () => {
|
||||
let fixture: ComponentFixture<WorkspaceCommandPaletteComponent>;
|
||||
let component: WorkspaceCommandPaletteComponent;
|
||||
let dialogRef: { close: jest.Mock };
|
||||
let commands: WorkspaceResolvedCommandItem[];
|
||||
|
||||
beforeEach(async () => {
|
||||
dialogRef = {
|
||||
close: jest.fn(),
|
||||
};
|
||||
commands = [
|
||||
{
|
||||
id: 'global-search',
|
||||
label: 'Search all Xtream playlists',
|
||||
description: 'Open global search overlay',
|
||||
group: 'global',
|
||||
icon: 'search',
|
||||
keywords: ['xtream', 'global'],
|
||||
priority: 100,
|
||||
visible: true,
|
||||
enabled: false,
|
||||
run: () => undefined,
|
||||
},
|
||||
{
|
||||
id: 'playlist-search',
|
||||
label: 'Search this playlist',
|
||||
description: 'Open playlist search route',
|
||||
group: 'playlist',
|
||||
icon: 'playlist_play',
|
||||
keywords: ['playlist'],
|
||||
priority: 10,
|
||||
visible: true,
|
||||
enabled: true,
|
||||
run: () => undefined,
|
||||
},
|
||||
{
|
||||
id: 'multi-epg',
|
||||
label: 'Open Multi-EPG',
|
||||
description: '',
|
||||
group: 'view',
|
||||
icon: 'view_list',
|
||||
keywords: ['epg', 'guide'],
|
||||
priority: 0,
|
||||
visible: true,
|
||||
enabled: true,
|
||||
run: () => undefined,
|
||||
},
|
||||
{
|
||||
id: 'hidden-command',
|
||||
label: 'Hidden command',
|
||||
description: 'Should not appear',
|
||||
group: 'global',
|
||||
icon: 'visibility_off',
|
||||
keywords: [],
|
||||
priority: 200,
|
||||
visible: false,
|
||||
enabled: true,
|
||||
run: () => undefined,
|
||||
},
|
||||
];
|
||||
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [WorkspaceCommandPaletteComponent],
|
||||
providers: [
|
||||
{
|
||||
provide: MatDialogRef,
|
||||
useValue: dialogRef,
|
||||
},
|
||||
{
|
||||
provide: MAT_DIALOG_DATA,
|
||||
useValue: {
|
||||
query: 'search',
|
||||
commands,
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: TranslateService,
|
||||
useValue: {
|
||||
instant: (key: string) => key,
|
||||
get: (key: string) => of(key),
|
||||
stream: (key: string) => of(key),
|
||||
onLangChange: of(null),
|
||||
onTranslationChange: of(null),
|
||||
onDefaultLangChange: of(null),
|
||||
currentLang: 'en',
|
||||
defaultLang: 'en',
|
||||
},
|
||||
},
|
||||
],
|
||||
}).compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(WorkspaceCommandPaletteComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('creates and applies initial query', () => {
|
||||
expect(component).toBeTruthy();
|
||||
expect(component.query()).toBe('search');
|
||||
});
|
||||
|
||||
it('selects the first enabled command by default', () => {
|
||||
expect(component.selectedIndex()).toBe(0);
|
||||
expect(component.flatCommands()[0].id).toBe('playlist-search');
|
||||
});
|
||||
|
||||
it('hides non-visible commands and omits empty groups', () => {
|
||||
expect(component.flatCommands().map((command) => command.id)).toEqual([
|
||||
'playlist-search',
|
||||
'global-search',
|
||||
]);
|
||||
expect(component.commandGroups().map((group) => group.group)).toEqual([
|
||||
'playlist',
|
||||
'global',
|
||||
]);
|
||||
});
|
||||
|
||||
it('filters commands by keywords', () => {
|
||||
component.query.set('guide');
|
||||
|
||||
expect(component.flatCommands().map((command) => command.id)).toEqual([
|
||||
'multi-epg',
|
||||
]);
|
||||
});
|
||||
|
||||
it('skips disabled commands during keyboard navigation', () => {
|
||||
component.query.set('');
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(component.flatCommands().map((command) => command.id)).toEqual([
|
||||
'multi-epg',
|
||||
'playlist-search',
|
||||
'global-search',
|
||||
]);
|
||||
expect(component.selectedIndex()).toBe(0);
|
||||
|
||||
component.onInputKeydown(
|
||||
new KeyboardEvent('keydown', { key: 'ArrowDown' })
|
||||
);
|
||||
expect(component.selectedIndex()).toBe(1);
|
||||
|
||||
component.onInputKeydown(
|
||||
new KeyboardEvent('keydown', { key: 'ArrowDown' })
|
||||
);
|
||||
expect(component.selectedIndex()).toBe(0);
|
||||
});
|
||||
|
||||
it('closes with selected command and query on click', () => {
|
||||
component.query.set('');
|
||||
const command = component.flatCommands()[0];
|
||||
component.onCommandClick(command);
|
||||
|
||||
expect(dialogRef.close).toHaveBeenCalledWith({
|
||||
commandId: 'multi-epg',
|
||||
query: '',
|
||||
});
|
||||
});
|
||||
});
|
||||
+252
@@ -0,0 +1,252 @@
|
||||
import {
|
||||
AfterViewInit,
|
||||
Component,
|
||||
ElementRef,
|
||||
computed,
|
||||
effect,
|
||||
inject,
|
||||
signal,
|
||||
viewChild,
|
||||
} from '@angular/core';
|
||||
import {
|
||||
MAT_DIALOG_DATA,
|
||||
MatDialogModule,
|
||||
MatDialogRef,
|
||||
} from '@angular/material/dialog';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { TranslatePipe } from '@ngx-translate/core';
|
||||
import {
|
||||
WorkspaceCommandGroup,
|
||||
WorkspaceCommandSelection,
|
||||
WorkspaceResolvedCommandItem,
|
||||
} from '@iptvnator/portal/shared/util';
|
||||
|
||||
interface WorkspaceCommandPaletteData {
|
||||
commands: WorkspaceResolvedCommandItem[];
|
||||
query?: string;
|
||||
}
|
||||
|
||||
interface WorkspaceCommandGroupSection {
|
||||
group: WorkspaceCommandGroup;
|
||||
items: WorkspaceResolvedCommandItem[];
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-workspace-command-palette',
|
||||
imports: [MatDialogModule, MatIconModule, TranslatePipe],
|
||||
templateUrl: './workspace-command-palette.component.html',
|
||||
styleUrl: './workspace-command-palette.component.scss',
|
||||
})
|
||||
export class WorkspaceCommandPaletteComponent implements AfterViewInit {
|
||||
private readonly dialogRef = inject(
|
||||
MatDialogRef<
|
||||
WorkspaceCommandPaletteComponent,
|
||||
WorkspaceCommandSelection | undefined
|
||||
>
|
||||
);
|
||||
private readonly data =
|
||||
inject<WorkspaceCommandPaletteData>(MAT_DIALOG_DATA);
|
||||
|
||||
private readonly queryInputRef =
|
||||
viewChild<ElementRef<HTMLInputElement>>('queryInput');
|
||||
|
||||
readonly query = signal(this.data?.query ?? '');
|
||||
readonly selectedIndex = signal(0);
|
||||
|
||||
readonly visibleCommands = computed(() =>
|
||||
this.data.commands.filter((command) => command.visible)
|
||||
);
|
||||
|
||||
readonly filteredCommands = computed(() => {
|
||||
const term = this.query().trim().toLowerCase();
|
||||
const commands = this.visibleCommands();
|
||||
|
||||
if (!term) {
|
||||
return commands;
|
||||
}
|
||||
|
||||
return commands.filter((command) => {
|
||||
const haystack = [
|
||||
command.label,
|
||||
command.description,
|
||||
...(command.keywords ?? []),
|
||||
]
|
||||
.join(' ')
|
||||
.toLowerCase();
|
||||
|
||||
return haystack.includes(term);
|
||||
});
|
||||
});
|
||||
|
||||
readonly commandGroups = computed<WorkspaceCommandGroupSection[]>(() => {
|
||||
const commands = this.filteredCommands();
|
||||
const sections: WorkspaceCommandGroupSection[] = [];
|
||||
|
||||
const buildSection = (
|
||||
group: WorkspaceCommandGroup
|
||||
): WorkspaceCommandGroupSection | null => {
|
||||
const items = commands.filter((command) => command.group === group);
|
||||
|
||||
if (items.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return { group, items };
|
||||
};
|
||||
|
||||
const groups = [
|
||||
buildSection('view'),
|
||||
buildSection('playlist'),
|
||||
buildSection('global'),
|
||||
].filter(
|
||||
(group): group is WorkspaceCommandGroupSection => group !== null
|
||||
);
|
||||
|
||||
sections.push(...groups);
|
||||
return sections;
|
||||
});
|
||||
|
||||
readonly flatCommands = computed(() =>
|
||||
this.commandGroups().reduce<WorkspaceResolvedCommandItem[]>(
|
||||
(items, group) => items.concat(group.items),
|
||||
[]
|
||||
)
|
||||
);
|
||||
|
||||
readonly selectableIndices = computed(() =>
|
||||
this.flatCommands()
|
||||
.map((command, index) => (command.enabled ? index : -1))
|
||||
.filter((index) => index >= 0)
|
||||
);
|
||||
|
||||
constructor() {
|
||||
effect(() => {
|
||||
const items = this.flatCommands();
|
||||
if (items.length === 0) {
|
||||
this.selectedIndex.set(-1);
|
||||
return;
|
||||
}
|
||||
|
||||
const selectableIndices = this.selectableIndices();
|
||||
if (selectableIndices.length === 0) {
|
||||
this.selectedIndex.set(-1);
|
||||
return;
|
||||
}
|
||||
|
||||
const currentIndex = this.selectedIndex();
|
||||
if (!selectableIndices.includes(currentIndex)) {
|
||||
this.selectedIndex.set(selectableIndices[0]);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
ngAfterViewInit(): void {
|
||||
queueMicrotask(() => {
|
||||
this.queryInputRef()?.nativeElement.focus();
|
||||
this.queryInputRef()?.nativeElement.select();
|
||||
});
|
||||
}
|
||||
|
||||
onQueryInput(event: Event): void {
|
||||
const target = event.target as HTMLInputElement | null;
|
||||
this.query.set(target?.value ?? '');
|
||||
}
|
||||
|
||||
onInputKeydown(event: KeyboardEvent): void {
|
||||
if (event.key === 'ArrowDown') {
|
||||
event.preventDefault();
|
||||
this.moveSelection(1);
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key === 'ArrowUp') {
|
||||
event.preventDefault();
|
||||
this.moveSelection(-1);
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
this.selectCurrent();
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
this.dialogRef.close();
|
||||
}
|
||||
}
|
||||
|
||||
onCommandHover(command: WorkspaceResolvedCommandItem): void {
|
||||
if (!command.enabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
const index = this.flatCommands().findIndex(
|
||||
(item) => item.id === command.id
|
||||
);
|
||||
if (index >= 0) {
|
||||
this.selectedIndex.set(index);
|
||||
}
|
||||
}
|
||||
|
||||
onCommandClick(command: WorkspaceResolvedCommandItem): void {
|
||||
if (!command.enabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.dialogRef.close({
|
||||
commandId: command.id,
|
||||
query: this.query().trim(),
|
||||
});
|
||||
}
|
||||
|
||||
isCommandSelected(command: WorkspaceResolvedCommandItem): boolean {
|
||||
const index = this.flatCommands().findIndex(
|
||||
(item) => item.id === command.id
|
||||
);
|
||||
return index >= 0 && this.selectedIndex() === index;
|
||||
}
|
||||
|
||||
getGroupTitleKey(group: WorkspaceCommandGroup): string {
|
||||
if (group === 'view') {
|
||||
return 'WORKSPACE.COMMAND_PALETTE.GROUP_VIEW';
|
||||
}
|
||||
if (group === 'playlist') {
|
||||
return 'WORKSPACE.COMMAND_PALETTE.GROUP_PLAYLIST';
|
||||
}
|
||||
return 'WORKSPACE.COMMAND_PALETTE.GROUP_GLOBAL';
|
||||
}
|
||||
|
||||
private moveSelection(direction: 1 | -1): void {
|
||||
const selectableIndices = this.selectableIndices();
|
||||
if (selectableIndices.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const currentPosition = selectableIndices.indexOf(this.selectedIndex());
|
||||
const nextPosition =
|
||||
currentPosition === -1
|
||||
? direction === 1
|
||||
? 0
|
||||
: selectableIndices.length - 1
|
||||
: (currentPosition + direction + selectableIndices.length) %
|
||||
selectableIndices.length;
|
||||
|
||||
this.selectedIndex.set(selectableIndices[nextPosition]);
|
||||
}
|
||||
|
||||
private selectCurrent(): void {
|
||||
const items = this.flatCommands();
|
||||
if (items.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const index = this.selectedIndex();
|
||||
if (index < 0 || index >= items.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.onCommandClick(items[index]);
|
||||
}
|
||||
}
|
||||
+67
@@ -0,0 +1,67 @@
|
||||
@if (items()?.length > 0) {
|
||||
<div class="nav-list">
|
||||
@if (statusText()) {
|
||||
<div class="context-inline-status">
|
||||
{{ statusText() }}
|
||||
</div>
|
||||
}
|
||||
@for (item of items(); track $index) {
|
||||
<button
|
||||
type="button"
|
||||
class="nav-item category-item"
|
||||
[attr.data-category-id]="
|
||||
$any(item).category_id ?? $any(item).xtream_id ?? item.id
|
||||
"
|
||||
[class.selected]="isSelected(item)"
|
||||
[class.category-item--selected]="isSelected(item)"
|
||||
[class.category-item--disabled]="!interactionEnabled()"
|
||||
[attr.aria-current]="isSelected(item) ? 'true' : null"
|
||||
[attr.aria-disabled]="!interactionEnabled()"
|
||||
[disabled]="!interactionEnabled()"
|
||||
(click)="onCategoryClick(item)"
|
||||
>
|
||||
<span class="nav-item-label">
|
||||
{{
|
||||
item.category_name ||
|
||||
$any(item).name ||
|
||||
'No category name'
|
||||
}}
|
||||
</span>
|
||||
@if (showCounts()) {
|
||||
@if (countDisplayMode() === 'loading') {
|
||||
<div
|
||||
class="nav-item-meta item-count item-count--loading"
|
||||
aria-hidden="true"
|
||||
></div>
|
||||
} @else {
|
||||
<div class="nav-item-meta item-count">
|
||||
{{ getItemCount(item) }}
|
||||
</div>
|
||||
}
|
||||
} @else if ($any(item).count !== undefined) {
|
||||
<div class="nav-item-meta item-count">
|
||||
{{ $any(item).count }}
|
||||
</div>
|
||||
}
|
||||
</button>
|
||||
} @empty {
|
||||
<app-workspace-context-error-view
|
||||
[title]="'PORTALS.EMPTY_LIST_VIEW.TITLE' | translate"
|
||||
[description]="
|
||||
'PORTALS.EMPTY_LIST_VIEW.NO_SEARCH_RESULTS' | translate
|
||||
"
|
||||
[showActionButtons]="false"
|
||||
[viewType]="'NO_SEARCH_RESULTS'"
|
||||
/>
|
||||
}
|
||||
</div>
|
||||
} @else {
|
||||
<div class="category-empty-state">
|
||||
<app-workspace-context-error-view
|
||||
[title]="'No categories available'"
|
||||
[showIllustration]="false"
|
||||
[showActionButtons]="false"
|
||||
[viewType]="'EMPTY_CATEGORY'"
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
+135
@@ -0,0 +1,135 @@
|
||||
@use '../../../../../../../../apps/web/src/nav-list.scss';
|
||||
|
||||
:host {
|
||||
display: block;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.context-inline-status {
|
||||
margin: 0 4px 8px;
|
||||
padding: 8px 12px;
|
||||
border-radius: 12px;
|
||||
background: color-mix(
|
||||
in srgb,
|
||||
var(--app-selection-surface, var(--mat-sys-surface-container)) 72%,
|
||||
transparent
|
||||
);
|
||||
border: 1px solid
|
||||
color-mix(
|
||||
in srgb,
|
||||
var(--app-selection-border, var(--mat-sys-outline-variant)) 70%,
|
||||
transparent
|
||||
);
|
||||
color: var(--mat-sys-on-surface-variant);
|
||||
font-size: 0.78rem;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.category-item {
|
||||
appearance: none;
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
width: auto;
|
||||
|
||||
.item-count {
|
||||
opacity: 0.6;
|
||||
font-size: 0.85em;
|
||||
margin-left: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
.category-item--disabled,
|
||||
.category-item--disabled:hover {
|
||||
cursor: progress;
|
||||
transform: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.item-count--loading {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
width: 22px;
|
||||
height: 10px;
|
||||
border-radius: 999px;
|
||||
background: color-mix(
|
||||
in srgb,
|
||||
var(--app-widget-header-bg, var(--mat-sys-surface-container-high)) 52%,
|
||||
var(--app-body-color, var(--mat-sys-on-surface-variant)) 48%
|
||||
);
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: linear-gradient(
|
||||
100deg,
|
||||
transparent 8%,
|
||||
color-mix(
|
||||
in srgb,
|
||||
var(--app-heading-color, var(--mat-sys-on-surface)) 18%,
|
||||
transparent
|
||||
)
|
||||
44%,
|
||||
transparent 74%
|
||||
);
|
||||
transform: translateX(-120%);
|
||||
animation: context-item-count-shimmer 1.15s ease-in-out infinite;
|
||||
}
|
||||
}
|
||||
|
||||
.category-item--selected .item-count {
|
||||
color: var(--app-selection-color) !important;
|
||||
opacity: 0.95;
|
||||
}
|
||||
|
||||
mat-card-content {
|
||||
overflow-wrap: break-word;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.category-empty-state {
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: clamp(14px, 3vh, 28px) 12px clamp(20px, 4vh, 34px);
|
||||
box-sizing: border-box;
|
||||
|
||||
app-workspace-context-error-view {
|
||||
--error-empty-illustration-size: clamp(132px, 15vw, 180px);
|
||||
--error-empty-max-width: min(270px, 100%);
|
||||
--error-empty-title-size: clamp(1.15rem, 1.35vw, 1.4rem);
|
||||
--error-empty-title-weight: 610;
|
||||
--error-empty-title-spacing: -0.006em;
|
||||
--error-empty-copy-gap: 4px;
|
||||
--error-empty-description-opacity: 0.82;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes context-item-count-shimmer {
|
||||
100% {
|
||||
transform: translateX(120%);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 800px) {
|
||||
.category-item {
|
||||
width: 100%;
|
||||
margin: 4px;
|
||||
}
|
||||
|
||||
.category-empty-state {
|
||||
app-workspace-context-error-view {
|
||||
--error-empty-illustration-size: 142px;
|
||||
--error-empty-max-width: 230px;
|
||||
--error-empty-title-size: 1.05rem;
|
||||
--error-empty-title-weight: 600;
|
||||
}
|
||||
}
|
||||
}
|
||||
+103
@@ -0,0 +1,103 @@
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
effect,
|
||||
ElementRef,
|
||||
inject,
|
||||
input,
|
||||
output,
|
||||
} from '@angular/core';
|
||||
import { MatListModule } from '@angular/material/list';
|
||||
import { TranslatePipe } from '@ngx-translate/core';
|
||||
import { WorkspaceContextErrorViewComponent } from './workspace-context-error-view.component';
|
||||
|
||||
interface WorkspaceCategoryViewItem {
|
||||
readonly category_id?: string | number;
|
||||
readonly category_name?: string;
|
||||
readonly count?: number;
|
||||
readonly id?: string | number;
|
||||
readonly name?: string;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-workspace-context-category-view',
|
||||
imports: [MatListModule, TranslatePipe, WorkspaceContextErrorViewComponent],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
templateUrl: './workspace-context-category-view.component.html',
|
||||
styleUrl: './workspace-context-category-view.component.scss',
|
||||
})
|
||||
export class WorkspaceContextCategoryViewComponent {
|
||||
readonly items = input<WorkspaceCategoryViewItem[]>([]);
|
||||
readonly selectedCategoryId = input<string | number | null | undefined>();
|
||||
readonly itemCounts = input<Map<number, number>>(new Map());
|
||||
readonly showCounts = input(false);
|
||||
readonly countDisplayMode = input<'loading' | 'ready'>('ready');
|
||||
readonly interactionEnabled = input(true);
|
||||
readonly statusText = input('');
|
||||
|
||||
private readonly hostEl = inject(ElementRef<HTMLElement>);
|
||||
|
||||
readonly categoryClicked = output<WorkspaceCategoryViewItem>();
|
||||
|
||||
constructor() {
|
||||
effect(() => {
|
||||
const selectedCategory = this.selectedCategoryId();
|
||||
if (selectedCategory == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
queueMicrotask(() => {
|
||||
const container = this.hostEl.nativeElement;
|
||||
const candidates = Array.from(
|
||||
container.querySelectorAll('[data-category-id]')
|
||||
) as HTMLElement[];
|
||||
const selected = candidates.find(
|
||||
(el) =>
|
||||
el.dataset['categoryId'] === String(selectedCategory)
|
||||
);
|
||||
if (!selected) {
|
||||
return;
|
||||
}
|
||||
|
||||
const containerRect = container.getBoundingClientRect();
|
||||
const selectedRect = selected.getBoundingClientRect();
|
||||
const targetTop =
|
||||
container.scrollTop +
|
||||
(selectedRect.top - containerRect.top) -
|
||||
container.clientHeight / 2 +
|
||||
selectedRect.height / 2;
|
||||
const maxScrollTop = Math.max(
|
||||
0,
|
||||
container.scrollHeight - container.clientHeight
|
||||
);
|
||||
|
||||
container.scrollTo({
|
||||
behavior: 'smooth',
|
||||
top: Math.min(maxScrollTop, Math.max(0, targetTop)),
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
isSelected(item: WorkspaceCategoryViewItem): boolean {
|
||||
const selectedCategory = this.selectedCategoryId();
|
||||
const itemId = item.category_id ?? item.id;
|
||||
return (
|
||||
selectedCategory != null &&
|
||||
String(selectedCategory) === String(itemId)
|
||||
);
|
||||
}
|
||||
|
||||
getItemCount(item: WorkspaceCategoryViewItem): number {
|
||||
const itemId = Number(item.id ?? item.category_id);
|
||||
return this.itemCounts().get(itemId) ?? 0;
|
||||
}
|
||||
|
||||
onCategoryClick(item: WorkspaceCategoryViewItem): void {
|
||||
if (!this.interactionEnabled()) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.categoryClicked.emit(item);
|
||||
}
|
||||
}
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
<div
|
||||
class="error-view"
|
||||
[class.error-view--empty-category]="viewType() === 'EMPTY_CATEGORY'"
|
||||
[class.error-view--text-only]="!showIllustration()"
|
||||
>
|
||||
@if (showIllustration()) {
|
||||
@if (viewType() === 'ERROR') {
|
||||
<img
|
||||
alt="Something went wrong"
|
||||
src="./assets/images/broken-tv.svg"
|
||||
/>
|
||||
} @else if (viewType() === 'EMPTY_CATEGORY') {
|
||||
<img
|
||||
alt="empty category"
|
||||
class="error-view__illustration"
|
||||
src="./assets/images/empty-category.svg"
|
||||
/>
|
||||
} @else if (viewType() === 'NO_SEARCH_RESULTS') {
|
||||
<mat-icon class="icon">search</mat-icon>
|
||||
}
|
||||
}
|
||||
|
||||
<div class="error-view__copy">
|
||||
<h3 class="error-view__title">{{ title() }}</h3>
|
||||
@if (description()) {
|
||||
<p class="error-view__description">{{ description() }}</p>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
+103
@@ -0,0 +1,103 @@
|
||||
:host {
|
||||
display: block;
|
||||
width: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.error-view {
|
||||
--error-view-illustration-size: var(
|
||||
--error-empty-illustration-size,
|
||||
clamp(170px, 28vw, 260px)
|
||||
);
|
||||
--error-view-copy-width: var(--error-empty-max-width, 420px);
|
||||
--error-view-copy-gap: var(--error-empty-copy-gap, 8px);
|
||||
--error-view-title-size: var(--error-empty-title-size, 1.55rem);
|
||||
--error-view-title-weight: var(--error-empty-title-weight, 640);
|
||||
--error-view-title-spacing: var(--error-empty-title-spacing, -0.012em);
|
||||
--error-view-description-size: var(--error-empty-description-size, 0.98rem);
|
||||
--error-view-description-line-height: var(
|
||||
--error-empty-description-line-height,
|
||||
1.4
|
||||
);
|
||||
--error-view-description-opacity: var(
|
||||
--error-empty-description-opacity,
|
||||
0.88
|
||||
);
|
||||
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
min-height: 100%;
|
||||
padding: clamp(12px, 2.2vw, 26px);
|
||||
gap: 14px;
|
||||
text-align: center;
|
||||
box-sizing: border-box;
|
||||
color: var(--app-body-color, var(--mat-sys-on-surface-variant));
|
||||
|
||||
img {
|
||||
width: min(100%, var(--error-view-illustration-size));
|
||||
height: auto;
|
||||
filter: invert(80%);
|
||||
}
|
||||
}
|
||||
|
||||
.icon {
|
||||
font-size: 64px;
|
||||
height: 64px;
|
||||
width: 64px;
|
||||
}
|
||||
|
||||
.error-view--empty-category .error-view__illustration {
|
||||
margin-top: clamp(0px, 1vh, 8px);
|
||||
}
|
||||
|
||||
.error-view--text-only {
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.error-view__copy {
|
||||
max-width: var(--error-view-copy-width);
|
||||
display: grid;
|
||||
gap: var(--error-view-copy-gap);
|
||||
}
|
||||
|
||||
.error-view__title {
|
||||
margin: 0;
|
||||
font-size: var(--error-view-title-size);
|
||||
font-weight: var(--error-view-title-weight);
|
||||
line-height: 1.12;
|
||||
color: var(--app-heading-color, var(--mat-sys-on-surface));
|
||||
letter-spacing: var(--error-view-title-spacing);
|
||||
text-wrap: balance;
|
||||
}
|
||||
|
||||
.error-view__description {
|
||||
margin: 0;
|
||||
font-size: var(--error-view-description-size);
|
||||
line-height: var(--error-view-description-line-height);
|
||||
color: inherit;
|
||||
opacity: var(--error-view-description-opacity);
|
||||
text-wrap: balance;
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
.error-view {
|
||||
--error-view-title-size: var(--error-empty-title-size, 1.4rem);
|
||||
--error-view-description-size: var(
|
||||
--error-empty-description-size,
|
||||
0.92rem
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
::ng-deep {
|
||||
.dark-theme {
|
||||
.error-view {
|
||||
img {
|
||||
filter: invert(10%) !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
import { ChangeDetectionStrategy, Component, input } from '@angular/core';
|
||||
import { MatIcon } from '@angular/material/icon';
|
||||
|
||||
@Component({
|
||||
selector: 'app-workspace-context-error-view',
|
||||
imports: [MatIcon],
|
||||
templateUrl: './workspace-context-error-view.component.html',
|
||||
styleUrl: './workspace-context-error-view.component.scss',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class WorkspaceContextErrorViewComponent {
|
||||
readonly description = input<string | undefined>(undefined);
|
||||
readonly showIllustration = input(true);
|
||||
readonly showActionButtons = input(false);
|
||||
readonly title = input<string | undefined>(undefined);
|
||||
readonly viewType = input<'ERROR' | 'EMPTY_CATEGORY' | 'NO_SEARCH_RESULTS'>(
|
||||
'ERROR'
|
||||
);
|
||||
}
|
||||
+74
@@ -0,0 +1,74 @@
|
||||
import { Component, computed, inject } from '@angular/core';
|
||||
import { TranslatePipe } from '@ngx-translate/core';
|
||||
import { PortalCollectionContextService } from '@iptvnator/portal/shared/util';
|
||||
import { WorkspaceContextCategoryViewComponent } from './components/workspace-context-category-view.component';
|
||||
|
||||
/**
|
||||
* Context panel rendered in the workspace-shell aside for
|
||||
* collection-backed workspace pages such as downloads.
|
||||
*
|
||||
* Reads categories from PortalCollectionContextService (populated by the
|
||||
* active routed collection page) and emits
|
||||
* category selections back through the same service.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-workspace-collection-context-panel',
|
||||
imports: [TranslatePipe, WorkspaceContextCategoryViewComponent],
|
||||
template: `
|
||||
<div class="context-column">
|
||||
<header class="context-header">
|
||||
<div class="context-header__top">
|
||||
<h2>
|
||||
{{
|
||||
selectedCategory()?.category_name ||
|
||||
('WORKSPACE.CONTEXT.FILTER_BY_TYPE' | translate)
|
||||
}}
|
||||
</h2>
|
||||
</div>
|
||||
@if (selectedCount() !== null) {
|
||||
<span class="context-header__badge">
|
||||
@if (selectedCount() === 1) {
|
||||
{{ 'WORKSPACE.CONTEXT.ITEM_COUNT_ONE' | translate }}
|
||||
} @else {
|
||||
{{
|
||||
'WORKSPACE.CONTEXT.ITEM_COUNT_OTHER'
|
||||
| translate: { count: selectedCount() }
|
||||
}}
|
||||
}
|
||||
</span>
|
||||
}
|
||||
</header>
|
||||
|
||||
<div class="context-divider"></div>
|
||||
|
||||
<app-workspace-context-category-view
|
||||
[items]="ctx.categories()"
|
||||
[selectedCategoryId]="ctx.selectedCategoryId()"
|
||||
(categoryClicked)="onCategoryClicked($event)"
|
||||
/>
|
||||
</div>
|
||||
`,
|
||||
styleUrl: './workspace-context-panel.component.scss',
|
||||
})
|
||||
export class WorkspaceCollectionContextPanelComponent {
|
||||
readonly ctx = inject(PortalCollectionContextService);
|
||||
|
||||
readonly selectedCategory = computed(() => {
|
||||
const categories = this.ctx.categories();
|
||||
return categories.find(
|
||||
(c) => c.category_id === this.ctx.selectedCategoryId()
|
||||
);
|
||||
});
|
||||
|
||||
readonly selectedCount = computed(
|
||||
() => this.selectedCategory()?.count ?? null
|
||||
);
|
||||
|
||||
onCategoryClicked(item: {
|
||||
category_id?: string | number;
|
||||
id?: string | number;
|
||||
}): void {
|
||||
const id = String(item.category_id ?? item.id ?? 'all');
|
||||
this.ctx.setCategoryId(id);
|
||||
}
|
||||
}
|
||||
+132
@@ -0,0 +1,132 @@
|
||||
<div class="context-column">
|
||||
<header class="context-header">
|
||||
<div class="context-header__top">
|
||||
<h2>{{ title() | translate }}</h2>
|
||||
<div class="context-header__actions">
|
||||
@if (canSearchCategories()) {
|
||||
<button
|
||||
type="button"
|
||||
mat-icon-button
|
||||
class="context-header__action"
|
||||
[class.context-header__action--active]="isSearchOpen()"
|
||||
(click)="toggleCategorySearch()"
|
||||
[matTooltip]="'WORKSPACE.CONTEXT.SEARCH' | translate"
|
||||
[attr.aria-label]="
|
||||
'WORKSPACE.CONTEXT.SEARCH' | translate
|
||||
"
|
||||
>
|
||||
<mat-icon>search</mat-icon>
|
||||
</button>
|
||||
}
|
||||
@if (isXtreamCategories()) {
|
||||
<button
|
||||
type="button"
|
||||
mat-icon-button
|
||||
class="context-header__action"
|
||||
(click)="openManageCategories()"
|
||||
[disabled]="!canManageXtreamCategories()"
|
||||
[matTooltip]="
|
||||
'WORKSPACE.CONTEXT.MANAGE_CATEGORIES' | translate
|
||||
"
|
||||
[attr.aria-label]="
|
||||
'WORKSPACE.CONTEXT.MANAGE_CATEGORIES' | translate
|
||||
"
|
||||
>
|
||||
<mat-icon>tune</mat-icon>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<!-- @if (subtitle(); as text) {
|
||||
<span class="context-header__badge">{{ text }}</span>
|
||||
} -->
|
||||
</header>
|
||||
|
||||
@if (isSearchOpen()) {
|
||||
<label class="context-search">
|
||||
<mat-icon class="context-search__icon">search</mat-icon>
|
||||
<input
|
||||
#searchInput
|
||||
type="search"
|
||||
spellcheck="false"
|
||||
[value]="categorySearchTerm()"
|
||||
(input)="onSearchInput($event)"
|
||||
[placeholder]="
|
||||
'WORKSPACE.CONTEXT.SEARCH_CATEGORIES' | translate
|
||||
"
|
||||
[attr.aria-label]="
|
||||
'WORKSPACE.CONTEXT.SEARCH_CATEGORIES' | translate
|
||||
"
|
||||
/>
|
||||
@if (categorySearchTerm()) {
|
||||
<button
|
||||
type="button"
|
||||
mat-icon-button
|
||||
class="context-search__clear"
|
||||
(click)="clearCategorySearch()"
|
||||
>
|
||||
<mat-icon>close</mat-icon>
|
||||
</button>
|
||||
}
|
||||
</label>
|
||||
} @else {
|
||||
<div class="context-divider"></div>
|
||||
}
|
||||
|
||||
@if (isXtreamCategories()) {
|
||||
@if (isXtreamCategoryLoading()) {
|
||||
<div class="context-skeleton" aria-hidden="true">
|
||||
@for (row of skeletonRows; track row) {
|
||||
<div class="context-skeleton__row">
|
||||
<span class="context-skeleton__dot"></span>
|
||||
<span
|
||||
class="context-skeleton__label"
|
||||
[style.width.%]="skeletonLabelWidths[row]"
|
||||
></span>
|
||||
<span class="context-skeleton__count"></span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
} @else {
|
||||
<app-workspace-context-category-view
|
||||
[items]="filteredXtreamCategories()"
|
||||
[selectedCategoryId]="xtreamSelectedCategoryId()"
|
||||
[itemCounts]="xtreamCategoryItemCounts()"
|
||||
[showCounts]="true"
|
||||
[countDisplayMode]="xtreamCountDisplayMode()"
|
||||
[interactionEnabled]="isXtreamCategoryInteractionEnabled()"
|
||||
[statusText]="xtreamStatusText()"
|
||||
(categoryClicked)="onXtreamCategoryClicked($event)"
|
||||
/>
|
||||
}
|
||||
} @else if (isStalkerCategories()) {
|
||||
@if (isStalkerCategoryLoading()) {
|
||||
<div class="context-skeleton" aria-hidden="true">
|
||||
@for (row of skeletonRows; track row) {
|
||||
<div class="context-skeleton__row">
|
||||
<span class="context-skeleton__dot"></span>
|
||||
<span
|
||||
class="context-skeleton__label"
|
||||
[style.width.%]="skeletonLabelWidths[row]"
|
||||
></span>
|
||||
<span class="context-skeleton__count"></span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
} @else if (isStalkerCategoryFailed()) {
|
||||
<app-workspace-context-error-view
|
||||
[title]="'WORKSPACE.CONTEXT.LOAD_CATEGORIES_ERROR' | translate"
|
||||
[description]="
|
||||
'WORKSPACE.CONTEXT.LOAD_CATEGORIES_ERROR_HINT' | translate
|
||||
"
|
||||
[showActionButtons]="false"
|
||||
/>
|
||||
} @else {
|
||||
<app-workspace-context-category-view
|
||||
[items]="filteredStalkerCategories()"
|
||||
[selectedCategoryId]="$any(stalkerSelectedCategoryId())"
|
||||
(categoryClicked)="onStalkerCategoryClicked($any($event))"
|
||||
/>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
+260
@@ -0,0 +1,260 @@
|
||||
: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);
|
||||
}
|
||||
}
|
||||
|
||||
.context-header__actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.context-header__action {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
line-height: 28px;
|
||||
flex-shrink: 0;
|
||||
padding: 0;
|
||||
|
||||
&[disabled] {
|
||||
opacity: 0.48;
|
||||
}
|
||||
|
||||
&--active mat-icon {
|
||||
color: var(--mat-sys-primary);
|
||||
}
|
||||
|
||||
mat-icon {
|
||||
font-size: 18px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
color: var(--mat-sys-on-surface-variant);
|
||||
}
|
||||
}
|
||||
|
||||
.context-header__badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
margin-top: 5px;
|
||||
padding: 2px 0;
|
||||
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);
|
||||
}
|
||||
|
||||
// ─── Search Bar ──────────────────────────────────────────────────────────────
|
||||
.context-search {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin: 0 6px 6px;
|
||||
padding: 0 10px;
|
||||
min-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));
|
||||
flex-shrink: 0;
|
||||
cursor: text;
|
||||
transition:
|
||||
border-color 0.15s ease,
|
||||
box-shadow 0.15s ease;
|
||||
|
||||
&:focus-within {
|
||||
border-color: var(--mat-sys-primary);
|
||||
box-shadow: 0 0 0 3px
|
||||
color-mix(in srgb, var(--mat-sys-primary) 12%, transparent);
|
||||
}
|
||||
}
|
||||
|
||||
.context-search__icon {
|
||||
font-size: 18px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
color: var(--mat-sys-on-surface-variant);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.context-search input {
|
||||
flex: 1;
|
||||
border: none;
|
||||
outline: none;
|
||||
background: transparent;
|
||||
color: var(--mat-sys-on-surface);
|
||||
font: inherit;
|
||||
font-size: 0.84rem;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
|
||||
&::placeholder {
|
||||
color: var(--mat-sys-on-surface-variant);
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
&::-webkit-search-cancel-button {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.context-search__clear {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
min-width: 24px;
|
||||
line-height: 24px;
|
||||
flex-shrink: 0;
|
||||
padding: 0;
|
||||
|
||||
mat-icon {
|
||||
font-size: 16px;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Divider ──────────────────────────────────────────────────────────────────
|
||||
.context-divider {
|
||||
height: 1px;
|
||||
background: var(--mat-sys-outline-variant);
|
||||
margin: 0 0 6px;
|
||||
opacity: 0.6;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
// ─── Category List ────────────────────────────────────────────────────────────
|
||||
app-workspace-context-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;
|
||||
}
|
||||
|
||||
.context-skeleton {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
padding: 4px 6px 2px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.context-skeleton__row {
|
||||
display: grid;
|
||||
grid-template-columns: 12px minmax(0, 1fr) 28px;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
min-height: 36px;
|
||||
padding: 0 10px;
|
||||
border-radius: 12px;
|
||||
background: color-mix(
|
||||
in srgb,
|
||||
var(--app-widget-header-bg, var(--mat-sys-surface-container-high)) 88%,
|
||||
var(--app-widget-bg, var(--mat-sys-surface-container)) 12%
|
||||
);
|
||||
border: 1px solid
|
||||
color-mix(
|
||||
in srgb,
|
||||
var(--app-widget-header-border, var(--mat-sys-outline-variant)) 96%,
|
||||
transparent
|
||||
);
|
||||
}
|
||||
|
||||
.context-skeleton__dot,
|
||||
.context-skeleton__label,
|
||||
.context-skeleton__count {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
background: color-mix(
|
||||
in srgb,
|
||||
var(--app-widget-header-bg, var(--mat-sys-surface-container-high)) 52%,
|
||||
var(--app-body-color, var(--mat-sys-on-surface-variant)) 48%
|
||||
);
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: linear-gradient(
|
||||
100deg,
|
||||
transparent 8%,
|
||||
color-mix(
|
||||
in srgb,
|
||||
var(--app-heading-color, var(--mat-sys-on-surface)) 18%,
|
||||
transparent
|
||||
)
|
||||
44%,
|
||||
transparent 74%
|
||||
);
|
||||
transform: translateX(-120%);
|
||||
animation: context-skeleton-shimmer 1.15s ease-in-out infinite;
|
||||
}
|
||||
}
|
||||
|
||||
.context-skeleton__dot {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.context-skeleton__label {
|
||||
height: 9px;
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.context-skeleton__count {
|
||||
justify-self: end;
|
||||
width: 22px;
|
||||
height: 9px;
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
@keyframes context-skeleton-shimmer {
|
||||
100% {
|
||||
transform: translateX(120%);
|
||||
}
|
||||
}
|
||||
+197
@@ -0,0 +1,197 @@
|
||||
import { computed, signal } from '@angular/core';
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { NoopAnimationsModule } from '@angular/platform-browser/animations';
|
||||
import { Router } from '@angular/router';
|
||||
import { TranslateService } from '@ngx-translate/core';
|
||||
import { of } from 'rxjs';
|
||||
import { MatDialog } from '@angular/material/dialog';
|
||||
import { StalkerStore } from '@iptvnator/portal/stalker/data-access';
|
||||
import {
|
||||
XtreamContentLoadState,
|
||||
XtreamStore,
|
||||
} from '@iptvnator/portal/xtream/data-access';
|
||||
import { WorkspaceContextPanelComponent } from './workspace-context-panel.component';
|
||||
|
||||
const translations: Record<string, string> = {
|
||||
'WORKSPACE.CONTEXT.MANAGE_CATEGORIES': 'Manage categories',
|
||||
'WORKSPACE.CONTEXT.XTREAM_SYNCING_LIVE': 'Syncing live categories...',
|
||||
'WORKSPACE.CONTEXT.XTREAM_SYNCING_MOVIES': 'Syncing movie categories...',
|
||||
'WORKSPACE.SHELL.XTREAM_IMPORT_LOADING':
|
||||
'Fetching playlist data from source...',
|
||||
};
|
||||
|
||||
describe('WorkspaceContextPanelComponent', () => {
|
||||
let fixture: ComponentFixture<WorkspaceContextPanelComponent>;
|
||||
const xtreamCategories = signal([
|
||||
{ id: 1, name: 'News' },
|
||||
{ id: 2, name: 'Sports' },
|
||||
]);
|
||||
const xtreamCategoryItemCounts = signal(new Map<number, number>());
|
||||
const xtreamSelectedCategoryId = signal<number | null>(null);
|
||||
const xtreamSelectedTypeContentState =
|
||||
signal<XtreamContentLoadState>('loading');
|
||||
const xtreamImportPhase = signal<string | null>('loading-live');
|
||||
const xtreamIsLoadingCategories = signal(false);
|
||||
|
||||
const xtreamStore = {
|
||||
getCategoriesBySelectedType: xtreamCategories,
|
||||
getCategoryItemCounts: xtreamCategoryItemCounts,
|
||||
selectedCategoryId: xtreamSelectedCategoryId,
|
||||
selectedTypeContentState: xtreamSelectedTypeContentState,
|
||||
selectedTypeContentReady: computed(
|
||||
() => xtreamSelectedTypeContentState() === 'ready'
|
||||
),
|
||||
selectedTypeCountsReady: computed(
|
||||
() => xtreamSelectedTypeContentState() === 'ready'
|
||||
),
|
||||
currentImportPhase: xtreamImportPhase,
|
||||
isLoadingCategories: xtreamIsLoadingCategories,
|
||||
setSelectedItem: jest.fn(),
|
||||
setSelectedCategory: jest.fn(),
|
||||
reloadCategories: jest.fn(),
|
||||
};
|
||||
const stalkerStore = {
|
||||
getCategoryResource: signal([]),
|
||||
selectedCategoryId: signal<string | null>(null),
|
||||
isCategoryResourceLoading: signal(false),
|
||||
isCategoryResourceFailed: signal(false),
|
||||
setSelectedCategory: jest.fn(),
|
||||
setPage: jest.fn(),
|
||||
clearSelectedItem: jest.fn(),
|
||||
};
|
||||
const router = {
|
||||
navigate: jest.fn(),
|
||||
};
|
||||
const dialog = {
|
||||
open: jest.fn(),
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
xtreamCategories.set([
|
||||
{ id: 1, name: 'News' },
|
||||
{ id: 2, name: 'Sports' },
|
||||
]);
|
||||
xtreamCategoryItemCounts.set(new Map());
|
||||
xtreamSelectedCategoryId.set(null);
|
||||
xtreamSelectedTypeContentState.set('loading');
|
||||
xtreamImportPhase.set('loading-live');
|
||||
xtreamIsLoadingCategories.set(false);
|
||||
xtreamStore.setSelectedItem.mockClear();
|
||||
xtreamStore.setSelectedCategory.mockClear();
|
||||
xtreamStore.reloadCategories.mockClear();
|
||||
router.navigate.mockClear();
|
||||
dialog.open.mockClear();
|
||||
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [WorkspaceContextPanelComponent, NoopAnimationsModule],
|
||||
providers: [
|
||||
{
|
||||
provide: TranslateService,
|
||||
useValue: {
|
||||
instant: (key: string) => translations[key] ?? key,
|
||||
get: (key: string) => of(translations[key] ?? key),
|
||||
stream: (key: string) => of(translations[key] ?? key),
|
||||
onLangChange: of(null),
|
||||
onTranslationChange: of(null),
|
||||
onDefaultLangChange: of(null),
|
||||
currentLang: 'en',
|
||||
defaultLang: 'en',
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: XtreamStore,
|
||||
useValue: xtreamStore,
|
||||
},
|
||||
{
|
||||
provide: StalkerStore,
|
||||
useValue: stalkerStore,
|
||||
},
|
||||
{
|
||||
provide: Router,
|
||||
useValue: router,
|
||||
},
|
||||
{
|
||||
provide: MatDialog,
|
||||
useValue: dialog,
|
||||
},
|
||||
],
|
||||
}).compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(WorkspaceContextPanelComponent);
|
||||
fixture.componentRef.setInput('context', {
|
||||
provider: 'xtreams',
|
||||
playlistId: 'playlist-1',
|
||||
});
|
||||
});
|
||||
|
||||
it('renders loading meta and blocks xtream category clicks until counts are ready', () => {
|
||||
fixture.componentRef.setInput('section', 'live');
|
||||
fixture.detectChanges();
|
||||
|
||||
const countPlaceholders = fixture.nativeElement.querySelectorAll(
|
||||
'.item-count--loading'
|
||||
);
|
||||
const categoryButtons = Array.from(
|
||||
fixture.nativeElement.querySelectorAll('.category-item')
|
||||
) as HTMLButtonElement[];
|
||||
const status = fixture.nativeElement.querySelector(
|
||||
'.context-inline-status'
|
||||
) as HTMLElement | null;
|
||||
const manageButton = fixture.nativeElement.querySelector(
|
||||
'.context-header__action'
|
||||
) as HTMLButtonElement | null;
|
||||
|
||||
expect(countPlaceholders).toHaveLength(2);
|
||||
expect(categoryButtons.every((button) => button.disabled)).toBe(true);
|
||||
expect(status?.textContent).toContain('Syncing live categories...');
|
||||
expect(status?.textContent).toContain(
|
||||
'Fetching playlist data from source...'
|
||||
);
|
||||
expect(manageButton?.disabled).toBe(true);
|
||||
|
||||
categoryButtons[0]?.click();
|
||||
|
||||
expect(xtreamStore.setSelectedItem).not.toHaveBeenCalled();
|
||||
expect(xtreamStore.setSelectedCategory).not.toHaveBeenCalled();
|
||||
expect(router.navigate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('shows real counts and enables navigation once the selected xtream type is ready', () => {
|
||||
fixture.componentRef.setInput('section', 'vod');
|
||||
xtreamSelectedTypeContentState.set('ready');
|
||||
xtreamImportPhase.set(null);
|
||||
xtreamCategoryItemCounts.set(
|
||||
new Map([
|
||||
[1, 3],
|
||||
[2, 0],
|
||||
])
|
||||
);
|
||||
fixture.detectChanges();
|
||||
|
||||
const countTexts = Array.from(
|
||||
fixture.nativeElement.querySelectorAll('.item-count')
|
||||
).map((element: Element) => element.textContent?.trim());
|
||||
const categoryButtons = Array.from(
|
||||
fixture.nativeElement.querySelectorAll('.category-item')
|
||||
) as HTMLButtonElement[];
|
||||
const manageButton = fixture.nativeElement.querySelector(
|
||||
'.context-header__action'
|
||||
) as HTMLButtonElement | null;
|
||||
|
||||
expect(countTexts).toEqual(['3', '0']);
|
||||
expect(categoryButtons.every((button) => !button.disabled)).toBe(true);
|
||||
expect(manageButton?.disabled).toBe(false);
|
||||
|
||||
categoryButtons[1]?.click();
|
||||
|
||||
expect(xtreamStore.setSelectedItem).toHaveBeenCalledWith(null);
|
||||
expect(xtreamStore.setSelectedCategory).toHaveBeenCalledWith(2);
|
||||
expect(router.navigate).toHaveBeenCalledWith([
|
||||
'/workspace',
|
||||
'xtreams',
|
||||
'playlist-1',
|
||||
'vod',
|
||||
2,
|
||||
]);
|
||||
});
|
||||
});
|
||||
+375
@@ -0,0 +1,375 @@
|
||||
import {
|
||||
Component,
|
||||
computed,
|
||||
DestroyRef,
|
||||
effect,
|
||||
inject,
|
||||
input,
|
||||
signal,
|
||||
viewChild,
|
||||
ElementRef,
|
||||
} 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 { MatTooltip } from '@angular/material/tooltip';
|
||||
import { Router } from '@angular/router';
|
||||
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
|
||||
import { StalkerStore } from '@iptvnator/portal/stalker/data-access';
|
||||
import { XtreamStore } from '@iptvnator/portal/xtream/data-access';
|
||||
import { WorkspaceContextCategoryViewComponent } from './components/workspace-context-category-view.component';
|
||||
import { WorkspaceContextErrorViewComponent } from './components/workspace-context-error-view.component';
|
||||
|
||||
type WorkspaceProvider = 'xtreams' | 'stalker' | 'playlists';
|
||||
|
||||
interface WorkspaceContextRoute {
|
||||
provider: WorkspaceProvider;
|
||||
playlistId: string;
|
||||
}
|
||||
|
||||
interface XtreamCategoryLike {
|
||||
id?: number | string;
|
||||
category_id?: number | string;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-workspace-context-panel',
|
||||
imports: [
|
||||
MatIconButton,
|
||||
MatIcon,
|
||||
MatTooltip,
|
||||
TranslatePipe,
|
||||
WorkspaceContextCategoryViewComponent,
|
||||
WorkspaceContextErrorViewComponent,
|
||||
],
|
||||
templateUrl: './workspace-context-panel.component.html',
|
||||
styleUrl: './workspace-context-panel.component.scss',
|
||||
})
|
||||
export class WorkspaceContextPanelComponent {
|
||||
private readonly router = inject(Router);
|
||||
private readonly xtreamStore = inject(XtreamStore);
|
||||
private readonly stalkerStore = inject(StalkerStore);
|
||||
private readonly dialog = inject(MatDialog);
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
private readonly translate = inject(TranslateService);
|
||||
|
||||
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 xtreamSelectedTypeContentState =
|
||||
this.xtreamStore.selectedTypeContentState;
|
||||
readonly xtreamSelectedTypeContentReady =
|
||||
this.xtreamStore.selectedTypeContentReady;
|
||||
readonly xtreamSelectedTypeCountsReady =
|
||||
this.xtreamStore.selectedTypeCountsReady;
|
||||
readonly xtreamImportPhase = this.xtreamStore.currentImportPhase;
|
||||
readonly isXtreamCategoryLoading = computed(
|
||||
() =>
|
||||
this.isXtreamCategories() &&
|
||||
this.xtreamStore.isLoadingCategories() &&
|
||||
this.xtreamCategories().length === 0
|
||||
);
|
||||
readonly isXtreamCategoryInteractionEnabled = computed(
|
||||
() =>
|
||||
!this.isXtreamCategoryLoading() &&
|
||||
this.xtreamSelectedTypeContentReady()
|
||||
);
|
||||
readonly xtreamCountDisplayMode = computed<'loading' | 'ready'>(() =>
|
||||
this.isXtreamCategoryInteractionEnabled() ? 'ready' : 'loading'
|
||||
);
|
||||
readonly canManageXtreamCategories = computed(
|
||||
() =>
|
||||
this.isXtreamCategories() &&
|
||||
this.xtreamCategories().length > 0 &&
|
||||
this.xtreamSelectedTypeCountsReady()
|
||||
);
|
||||
readonly xtreamStatusText = computed(() => {
|
||||
if (
|
||||
!this.isXtreamCategories() ||
|
||||
this.isXtreamCategoryLoading() ||
|
||||
this.xtreamSelectedTypeContentState() !== 'loading'
|
||||
) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const syncLabel = this.translate.instant(
|
||||
this.getXtreamSyncLabelKey(this.section())
|
||||
);
|
||||
const phaseKey = this.getXtreamImportPhaseLabelKey(
|
||||
this.xtreamImportPhase()
|
||||
);
|
||||
const phaseLabel = phaseKey ? this.translate.instant(phaseKey) : '';
|
||||
|
||||
return phaseLabel ? `${syncLabel} ${phaseLabel}` : syncLabel;
|
||||
});
|
||||
|
||||
readonly stalkerCategories = this.stalkerStore.getCategoryResource;
|
||||
readonly stalkerSelectedCategoryId = this.stalkerStore.selectedCategoryId;
|
||||
readonly isStalkerCategoryLoading =
|
||||
this.stalkerStore.isCategoryResourceLoading;
|
||||
readonly isStalkerCategoryFailed =
|
||||
this.stalkerStore.isCategoryResourceFailed;
|
||||
readonly skeletonRows = Array.from({ length: 14 }, (_, index) => index);
|
||||
readonly skeletonLabelWidths = [
|
||||
78, 66, 74, 59, 83, 69, 76, 62, 81, 64, 72, 67, 79, 61,
|
||||
];
|
||||
|
||||
readonly searchInput =
|
||||
viewChild<ElementRef<HTMLInputElement>>('searchInput');
|
||||
readonly isSearchOpen = signal(false);
|
||||
readonly categorySearchTerm = signal('');
|
||||
|
||||
readonly canSearchCategories = computed(
|
||||
() =>
|
||||
(this.isXtreamCategories() &&
|
||||
this.xtreamCategories().length > 0) ||
|
||||
(this.isStalkerCategories() &&
|
||||
this.stalkerCategories().length > 0)
|
||||
);
|
||||
|
||||
readonly filteredXtreamCategories = computed(() => {
|
||||
const cats = this.xtreamCategories();
|
||||
const term = this.categorySearchTerm().trim().toLowerCase();
|
||||
if (!term) return cats;
|
||||
return cats.filter((c) => {
|
||||
const label =
|
||||
(c as { category_name?: string }).category_name ??
|
||||
(c as { name?: string }).name ??
|
||||
'';
|
||||
return label.toLowerCase().includes(term);
|
||||
});
|
||||
});
|
||||
|
||||
readonly filteredStalkerCategories = computed(() => {
|
||||
const cats = this.stalkerCategories();
|
||||
const term = this.categorySearchTerm().trim().toLowerCase();
|
||||
if (!term) return cats;
|
||||
return cats.filter((c) => {
|
||||
const label =
|
||||
(c as { name?: string }).name ??
|
||||
(c as { category_name?: string }).category_name ??
|
||||
'';
|
||||
return label.toLowerCase().includes(term);
|
||||
});
|
||||
});
|
||||
|
||||
readonly title = computed(() => {
|
||||
if (this.isXtreamCategories()) {
|
||||
if (this.section() === 'vod') {
|
||||
return 'WORKSPACE.CONTEXT.MOVIE_CATEGORIES';
|
||||
}
|
||||
if (this.section() === 'series') {
|
||||
return 'WORKSPACE.CONTEXT.SERIES_CATEGORIES';
|
||||
}
|
||||
return 'WORKSPACE.CONTEXT.LIVE_CATEGORIES';
|
||||
}
|
||||
|
||||
if (this.isStalkerCategories()) {
|
||||
if (this.section() === 'vod') {
|
||||
return 'WORKSPACE.CONTEXT.MOVIE_CATEGORIES';
|
||||
}
|
||||
if (this.section() === 'series') {
|
||||
return 'WORKSPACE.CONTEXT.SERIES_CATEGORIES';
|
||||
}
|
||||
return 'WORKSPACE.CONTEXT.LIVE_CATEGORIES';
|
||||
}
|
||||
|
||||
return 'WORKSPACE.CONTEXT.CATEGORIES';
|
||||
});
|
||||
|
||||
readonly subtitle = computed(() => {
|
||||
if (this.isXtreamCategories()) {
|
||||
return 'WORKSPACE.CONTEXT.XTREAM_SOURCE';
|
||||
}
|
||||
if (this.isStalkerCategories()) {
|
||||
return 'WORKSPACE.CONTEXT.STALKER_PORTAL';
|
||||
}
|
||||
return '';
|
||||
});
|
||||
|
||||
constructor() {
|
||||
effect(() => {
|
||||
if (this.isSearchOpen()) {
|
||||
queueMicrotask(() => {
|
||||
this.searchInput()?.nativeElement.focus();
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
toggleCategorySearch(): void {
|
||||
const opening = !this.isSearchOpen();
|
||||
this.isSearchOpen.set(opening);
|
||||
if (!opening) {
|
||||
this.categorySearchTerm.set('');
|
||||
}
|
||||
}
|
||||
|
||||
onSearchInput(event: Event): void {
|
||||
const value = (event.target as HTMLInputElement).value;
|
||||
this.categorySearchTerm.set(value);
|
||||
}
|
||||
|
||||
clearCategorySearch(): void {
|
||||
this.categorySearchTerm.set('');
|
||||
this.searchInput()?.nativeElement.focus();
|
||||
}
|
||||
|
||||
openManageCategories(): void {
|
||||
if (!this.canManageXtreamCategories()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const context = this.context();
|
||||
const section = this.section();
|
||||
const contentType =
|
||||
section === 'series'
|
||||
? 'series'
|
||||
: section === 'live'
|
||||
? 'live'
|
||||
: 'vod';
|
||||
|
||||
void import('@iptvnator/portal/xtream/feature').then(
|
||||
({ CategoryManagementDialogComponent }) => {
|
||||
const dialogRef = this.dialog.open(
|
||||
CategoryManagementDialogComponent,
|
||||
{
|
||||
data: {
|
||||
playlistId: context.playlistId,
|
||||
contentType,
|
||||
itemCounts: this.xtreamStore.getCategoryItemCounts(),
|
||||
},
|
||||
width: '500px',
|
||||
maxHeight: '90vh',
|
||||
}
|
||||
);
|
||||
|
||||
dialogRef
|
||||
.afterClosed()
|
||||
.pipe(takeUntilDestroyed(this.destroyRef))
|
||||
.subscribe((result) => {
|
||||
if (result) {
|
||||
this.xtreamStore.reloadCategories();
|
||||
}
|
||||
});
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
onXtreamCategoryClicked(category: XtreamCategoryLike): void {
|
||||
if (!this.isXtreamCategoryInteractionEnabled()) {
|
||||
return;
|
||||
}
|
||||
|
||||
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,
|
||||
]);
|
||||
}
|
||||
|
||||
private getXtreamSyncLabelKey(section: string): string {
|
||||
switch (section) {
|
||||
case 'live':
|
||||
return 'WORKSPACE.CONTEXT.XTREAM_SYNCING_LIVE';
|
||||
case 'series':
|
||||
return 'WORKSPACE.CONTEXT.XTREAM_SYNCING_SERIES';
|
||||
case 'vod':
|
||||
default:
|
||||
return 'WORKSPACE.CONTEXT.XTREAM_SYNCING_MOVIES';
|
||||
}
|
||||
}
|
||||
|
||||
private getXtreamImportPhaseLabelKey(phase: string | null): string {
|
||||
switch (phase) {
|
||||
case 'preparing-content':
|
||||
return 'WORKSPACE.SHELL.XTREAM_IMPORT_PREPARING';
|
||||
case 'loading-categories':
|
||||
case 'loading-live':
|
||||
case 'loading-movies':
|
||||
case 'loading-series':
|
||||
return 'WORKSPACE.SHELL.XTREAM_IMPORT_LOADING';
|
||||
case 'saving-categories':
|
||||
case 'saving-content':
|
||||
return 'WORKSPACE.SHELL.XTREAM_IMPORT_SAVING';
|
||||
case 'restoring-favorites':
|
||||
return 'WORKSPACE.SHELL.XTREAM_IMPORT_RESTORING_FAVORITES';
|
||||
case 'restoring-recently-viewed':
|
||||
return 'WORKSPACE.SHELL.XTREAM_IMPORT_RESTORING_RECENT';
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
}
|
||||
}
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
@use '../../nav-list.scss';
|
||||
|
||||
: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);
|
||||
}
|
||||
|
||||
.settings-panel-body {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
padding-top: 16px;
|
||||
}
|
||||
|
||||
.settings-sections-list {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.settings-panel-footer {
|
||||
flex-shrink: 0;
|
||||
padding: 12px 8px calc(20px + env(safe-area-inset-bottom, 0px));
|
||||
border-top: 1px solid var(--app-separator);
|
||||
}
|
||||
|
||||
.settings-section-item:hover:not(.active):not(.selected),
|
||||
.settings-back-button:hover:not(.active):not(.selected) {
|
||||
background: var(--app-selection-surface);
|
||||
}
|
||||
+47
@@ -0,0 +1,47 @@
|
||||
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 '@iptvnator/workspace/shell/util';
|
||||
|
||||
@Component({
|
||||
selector: 'app-workspace-settings-context-panel',
|
||||
imports: [MatIconModule, TranslateModule],
|
||||
styleUrls: ['./workspace-settings-context-panel.component.scss'],
|
||||
template: `
|
||||
<h2 class="panel-title">{{ 'SETTINGS.TITLE' | translate }}</h2>
|
||||
<div class="settings-panel-body">
|
||||
<div class="nav-list settings-sections-list">
|
||||
@for (section of ctx.sections(); track section.id) {
|
||||
<button
|
||||
type="button"
|
||||
class="nav-item settings-section-item"
|
||||
[class.active]="ctx.activeSection() === section.id"
|
||||
(click)="ctx.navigateToSection(section.id)"
|
||||
>
|
||||
<mat-icon>{{ section.icon }}</mat-icon>
|
||||
<span>{{ section.label | translate }}</span>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<div class="settings-panel-footer">
|
||||
<button
|
||||
type="button"
|
||||
class="nav-item settings-back-button"
|
||||
(click)="onBack()"
|
||||
>
|
||||
<mat-icon>arrow_back</mat-icon>
|
||||
<span>{{ 'SETTINGS.BACK_TO_HOME' | translate }}</span>
|
||||
</button>
|
||||
</div>
|
||||
`,
|
||||
})
|
||||
export class WorkspaceSettingsContextPanelComponent {
|
||||
readonly ctx = inject(SettingsContextService);
|
||||
private readonly location = inject(Location);
|
||||
|
||||
onBack() {
|
||||
this.location.back();
|
||||
}
|
||||
}
|
||||
+52
@@ -0,0 +1,52 @@
|
||||
@if (variant() === 'sources' && hasPlaylists()) {
|
||||
<aside
|
||||
class="context-panel context-panel--sources"
|
||||
appResizable
|
||||
[minWidth]="220"
|
||||
[maxWidth]="440"
|
||||
[defaultWidth]="290"
|
||||
storageKey="sidebar-width"
|
||||
>
|
||||
<app-workspace-sources-filters-panel />
|
||||
</aside>
|
||||
} @else if (variant() === 'category') {
|
||||
@if (context(); as context) {
|
||||
@if (section(); as section) {
|
||||
<aside
|
||||
class="context-panel context-panel--route"
|
||||
appResizable
|
||||
[minWidth]="240"
|
||||
[maxWidth]="560"
|
||||
[defaultWidth]="300"
|
||||
storageKey="sidebar-width"
|
||||
>
|
||||
<app-workspace-context-panel
|
||||
[context]="context"
|
||||
[section]="section"
|
||||
/>
|
||||
</aside>
|
||||
}
|
||||
}
|
||||
} @else if (variant() === 'settings') {
|
||||
<aside
|
||||
class="context-panel context-panel--settings"
|
||||
appResizable
|
||||
[minWidth]="200"
|
||||
[maxWidth]="400"
|
||||
[defaultWidth]="260"
|
||||
storageKey="sidebar-width"
|
||||
>
|
||||
<app-workspace-settings-context-panel />
|
||||
</aside>
|
||||
} @else if (variant() === 'collection') {
|
||||
<aside
|
||||
class="context-panel context-panel--route"
|
||||
appResizable
|
||||
[minWidth]="200"
|
||||
[maxWidth]="400"
|
||||
[defaultWidth]="260"
|
||||
storageKey="sidebar-width"
|
||||
>
|
||||
<app-workspace-collection-context-panel />
|
||||
</aside>
|
||||
}
|
||||
+43
@@ -0,0 +1,43 @@
|
||||
:host {
|
||||
display: block;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.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;
|
||||
height: 100%;
|
||||
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;
|
||||
}
|
||||
+109
@@ -0,0 +1,109 @@
|
||||
import {
|
||||
Component,
|
||||
Directive,
|
||||
input,
|
||||
} from '@angular/core';
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { WorkspaceShellContextSidebarComponent } from './workspace-shell-context-sidebar.component';
|
||||
|
||||
@Directive({
|
||||
selector: '[appResizable]',
|
||||
standalone: true,
|
||||
})
|
||||
class MockResizableDirective {
|
||||
readonly minWidth = input<number | null>(null);
|
||||
readonly maxWidth = input<number | null>(null);
|
||||
readonly defaultWidth = input<number | null>(null);
|
||||
readonly storageKey = input<string | null>(null);
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-workspace-context-panel',
|
||||
template: '',
|
||||
standalone: true,
|
||||
})
|
||||
class MockWorkspaceContextPanelComponent {
|
||||
readonly context = input.required<unknown>();
|
||||
readonly section = input.required<string>();
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-workspace-collection-context-panel',
|
||||
template: '',
|
||||
standalone: true,
|
||||
})
|
||||
class MockWorkspaceCollectionContextPanelComponent {}
|
||||
|
||||
@Component({
|
||||
selector: 'app-workspace-settings-context-panel',
|
||||
template: '',
|
||||
standalone: true,
|
||||
})
|
||||
class MockWorkspaceSettingsContextPanelComponent {}
|
||||
|
||||
@Component({
|
||||
selector: 'app-workspace-sources-filters-panel',
|
||||
template: '',
|
||||
standalone: true,
|
||||
})
|
||||
class MockWorkspaceSourcesFiltersPanelComponent {}
|
||||
|
||||
describe('WorkspaceShellContextSidebarComponent', () => {
|
||||
let fixture: ComponentFixture<WorkspaceShellContextSidebarComponent>;
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [WorkspaceShellContextSidebarComponent],
|
||||
})
|
||||
.overrideComponent(WorkspaceShellContextSidebarComponent, {
|
||||
set: {
|
||||
imports: [
|
||||
MockResizableDirective,
|
||||
MockWorkspaceContextPanelComponent,
|
||||
MockWorkspaceCollectionContextPanelComponent,
|
||||
MockWorkspaceSettingsContextPanelComponent,
|
||||
MockWorkspaceSourcesFiltersPanelComponent,
|
||||
],
|
||||
},
|
||||
})
|
||||
.compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(WorkspaceShellContextSidebarComponent);
|
||||
});
|
||||
|
||||
it('renders the settings panel for the settings variant', () => {
|
||||
fixture.componentRef.setInput('variant', 'settings');
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(
|
||||
fixture.nativeElement.querySelector(
|
||||
'app-workspace-settings-context-panel'
|
||||
)
|
||||
).not.toBeNull();
|
||||
});
|
||||
|
||||
it('renders the route context panel for category routes', () => {
|
||||
fixture.componentRef.setInput('variant', 'category');
|
||||
fixture.componentRef.setInput('context', {
|
||||
provider: 'xtreams',
|
||||
playlistId: 'pl-1',
|
||||
});
|
||||
fixture.componentRef.setInput('section', 'vod');
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(
|
||||
fixture.nativeElement.querySelector('app-workspace-context-panel')
|
||||
).not.toBeNull();
|
||||
});
|
||||
|
||||
it('renders the shared collection panel for collection routes', () => {
|
||||
fixture.componentRef.setInput('variant', 'collection');
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(
|
||||
fixture.nativeElement.querySelector(
|
||||
'app-workspace-collection-context-panel'
|
||||
)
|
||||
).not.toBeNull();
|
||||
});
|
||||
});
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
input,
|
||||
} from '@angular/core';
|
||||
import { ResizableDirective } from 'components';
|
||||
import { PortalRailSection } from '@iptvnator/portal/shared/util';
|
||||
import {
|
||||
WorkspacePortalContext,
|
||||
WorkspaceShellContextPanel,
|
||||
} from '@iptvnator/workspace/shell/util';
|
||||
import { WorkspaceCollectionContextPanelComponent } from '../../../workspace-context-panel/workspace-collection-context-panel.component';
|
||||
import { WorkspaceContextPanelComponent } from '../../../workspace-context-panel/workspace-context-panel.component';
|
||||
import { WorkspaceSettingsContextPanelComponent } from '../../../workspace-context-panel/workspace-settings-context-panel.component';
|
||||
import { WorkspaceSourcesFiltersPanelComponent } from '../../../workspace-sources-filters-panel/workspace-sources-filters-panel.component';
|
||||
|
||||
@Component({
|
||||
selector: 'app-workspace-shell-context-sidebar',
|
||||
imports: [
|
||||
ResizableDirective,
|
||||
WorkspaceCollectionContextPanelComponent,
|
||||
WorkspaceContextPanelComponent,
|
||||
WorkspaceSettingsContextPanelComponent,
|
||||
WorkspaceSourcesFiltersPanelComponent,
|
||||
],
|
||||
templateUrl: './workspace-shell-context-sidebar.component.html',
|
||||
styleUrl: './workspace-shell-context-sidebar.component.scss',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class WorkspaceShellContextSidebarComponent {
|
||||
readonly variant = input.required<WorkspaceShellContextPanel>();
|
||||
readonly context = input<WorkspacePortalContext | null>(null);
|
||||
readonly section = input<PortalRailSection | null>(null);
|
||||
readonly hasPlaylists = input(true);
|
||||
}
|
||||
+141
@@ -0,0 +1,141 @@
|
||||
<header class="workspace-header">
|
||||
<app-playlist-switcher
|
||||
class="playlist-switcher"
|
||||
[currentTitle]="playlistTitle()"
|
||||
[subtitle]="playlistSubtitle()"
|
||||
[showPlaylistInfo]="canOpenPlaylistInfo()"
|
||||
[showAccountInfo]="canOpenAccountInfo()"
|
||||
[showAddPlaylist]="true"
|
||||
[canRefreshActivePlaylist]="canRefreshPlaylist()"
|
||||
[isRefreshingActivePlaylist]="isRefreshingPlaylist()"
|
||||
(playlistInfoRequested)="onPlaylistInfoRequested()"
|
||||
(accountInfoRequested)="onAccountInfoRequested()"
|
||||
(addPlaylistRequested)="onAddPlaylistRequested()"
|
||||
(refreshPlaylistRequested)="onRefreshPlaylistRequested()"
|
||||
/>
|
||||
|
||||
@if (!hasNoPlaylists() || showQuickPlayerSelect()) {
|
||||
<div class="search-toolbar">
|
||||
@if (showQuickPlayerSelect()) {
|
||||
<mat-form-field
|
||||
class="quick-player-field"
|
||||
appearance="outline"
|
||||
subscriptSizing="dynamic"
|
||||
>
|
||||
<mat-label>{{
|
||||
'WORKSPACE.SHELL.QUICK_PLAYER_LABEL' | translate
|
||||
}}</mat-label>
|
||||
<mat-select
|
||||
[value]="quickHeaderPlayerValue()"
|
||||
(selectionChange)="
|
||||
onQuickPlayerChange($event.value)
|
||||
"
|
||||
[attr.aria-label]="
|
||||
'WORKSPACE.SHELL.QUICK_PLAYER_LABEL' | translate
|
||||
"
|
||||
>
|
||||
<mat-option [value]="VideoPlayer.ArtPlayer">{{
|
||||
'WORKSPACE.SHELL.QUICK_PLAYER_ARTPLAYER' | translate
|
||||
}}</mat-option>
|
||||
<mat-option [value]="VideoPlayer.VLC">{{
|
||||
'WORKSPACE.SHELL.QUICK_PLAYER_VLC' | translate
|
||||
}}</mat-option>
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
}
|
||||
@if (!hasNoPlaylists()) {
|
||||
<label class="search-field">
|
||||
<mat-icon>search</mat-icon>
|
||||
@if (searchScopeLabel()) {
|
||||
<span class="search-chip search-chip--scope">
|
||||
{{ searchScopeLabel() }}
|
||||
</span>
|
||||
}
|
||||
<input
|
||||
type="search"
|
||||
spellcheck="false"
|
||||
[value]="searchQuery()"
|
||||
(input)="onSearchInput($event)"
|
||||
(keydown.enter)="onSearchEnter($event)"
|
||||
[disabled]="!canUseSearch()"
|
||||
[placeholder]="searchPlaceholder() | translate"
|
||||
[attr.aria-label]="
|
||||
'WORKSPACE.SHELL.SEARCH_LABEL' | translate
|
||||
"
|
||||
/>
|
||||
@if (searchStatusLabel()) {
|
||||
<span class="search-chip search-chip--status">
|
||||
{{ searchStatusLabel() }}
|
||||
</span>
|
||||
}
|
||||
<button
|
||||
type="button"
|
||||
class="command-trigger"
|
||||
(click)="onCommandPaletteRequested()"
|
||||
[matTooltip]="
|
||||
'WORKSPACE.SHELL.COMMAND_PALETTE_SHORTCUT'
|
||||
| translate
|
||||
"
|
||||
[attr.aria-label]="
|
||||
'WORKSPACE.SHELL.OPEN_COMMAND_PALETTE' | translate
|
||||
"
|
||||
>
|
||||
{{ commandShortcutLabel }}
|
||||
</button>
|
||||
</label>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="header-actions header-actions--leading">
|
||||
<button
|
||||
type="button"
|
||||
mat-icon-button
|
||||
(click)="onAddPlaylistRequested()"
|
||||
[matTooltip]="'WORKSPACE.SHELL.ADD_PLAYLIST_ALL' | translate"
|
||||
[attr.aria-label]="'WORKSPACE.SHELL.ADD_PLAYLIST' | translate"
|
||||
>
|
||||
<mat-icon>add</mat-icon>
|
||||
</button>
|
||||
@if (headerShortcut(); as action) {
|
||||
<button
|
||||
type="button"
|
||||
class="header-shortcut"
|
||||
mat-icon-button
|
||||
(click)="onHeaderShortcutRequested()"
|
||||
[matTooltip]="action.tooltipKey | translate"
|
||||
[attr.aria-label]="action.ariaLabelKey | translate"
|
||||
>
|
||||
<mat-icon>{{ action.icon }}</mat-icon>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (isElectron()) {
|
||||
<div class="header-actions header-actions--trailing">
|
||||
<span class="download-btn-wrap">
|
||||
@if (hasActiveDownloads()) {
|
||||
<span class="download-activity-bar" aria-hidden="true">
|
||||
<span class="download-activity-bar__fill"></span>
|
||||
</span>
|
||||
}
|
||||
<button
|
||||
type="button"
|
||||
class="header-shortcut"
|
||||
mat-icon-button
|
||||
[class.is-active]="isDownloadsView()"
|
||||
[class.has-activity]="hasActiveDownloads()"
|
||||
(click)="onDownloadsRequested()"
|
||||
[matTooltip]="
|
||||
'WORKSPACE.SHELL.RAIL_DOWNLOADS' | translate
|
||||
"
|
||||
[attr.aria-label]="
|
||||
'WORKSPACE.SHELL.OPEN_DOWNLOADS' | translate
|
||||
"
|
||||
>
|
||||
<mat-icon>download</mat-icon>
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
</header>
|
||||
+337
@@ -0,0 +1,337 @@
|
||||
:host {
|
||||
display: block;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.workspace-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
height: 56px;
|
||||
padding: 0 24px;
|
||||
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;
|
||||
}
|
||||
|
||||
.search-toolbar {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
max-width: 720px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
app-region: no-drag;
|
||||
}
|
||||
|
||||
.quick-player-field {
|
||||
flex: 0 0 auto;
|
||||
width: min(168px, 28vw);
|
||||
|
||||
--mat-form-field-container-height: 40px;
|
||||
--mat-form-field-container-vertical-padding: 0px;
|
||||
|
||||
::ng-deep .mat-mdc-form-field-subscript-wrapper {
|
||||
display: none;
|
||||
}
|
||||
|
||||
::ng-deep .mat-mdc-text-field-wrapper {
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
::ng-deep .mat-mdc-form-field-infix {
|
||||
padding-top: 6px;
|
||||
padding-bottom: 6px;
|
||||
min-height: 36px;
|
||||
}
|
||||
}
|
||||
|
||||
.search-field {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
max-width: none;
|
||||
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
|
||||
color-mix(in srgb, var(--mat-sys-primary) 12%, transparent);
|
||||
}
|
||||
|
||||
mat-icon {
|
||||
font-size: 18px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
color: var(--mat-sys-on-surface-variant);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.search-chip {
|
||||
max-width: 200px;
|
||||
padding: 0 10px;
|
||||
height: 24px;
|
||||
border-radius: 999px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--mat-sys-on-surface-variant);
|
||||
background: color-mix(
|
||||
in srgb,
|
||||
var(--mat-sys-surface-container-high) 76%,
|
||||
transparent
|
||||
);
|
||||
border: 1px solid
|
||||
color-mix(in srgb, var(--mat-sys-outline-variant) 82%, transparent);
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
line-height: 1;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
flex-shrink: 1;
|
||||
min-width: 48px;
|
||||
}
|
||||
|
||||
.search-chip--scope {
|
||||
color: var(--mat-sys-on-surface);
|
||||
}
|
||||
|
||||
.search-chip--status {
|
||||
color: var(--app-selection-color, var(--mat-sys-primary));
|
||||
background: color-mix(
|
||||
in srgb,
|
||||
var(--app-selection-surface, var(--mat-sys-primary-container)) 70%,
|
||||
transparent
|
||||
);
|
||||
border-color: color-mix(
|
||||
in srgb,
|
||||
var(--app-selection-border, var(--mat-sys-primary)) 52%,
|
||||
transparent
|
||||
);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
.command-trigger {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 22px;
|
||||
min-width: 36px;
|
||||
padding: 0 6px;
|
||||
margin-right: -2px;
|
||||
color: var(--mat-sys-on-surface-variant);
|
||||
background: color-mix(
|
||||
in srgb,
|
||||
var(--mat-sys-surface-container-high) 76%,
|
||||
transparent
|
||||
);
|
||||
border: 1px solid
|
||||
color-mix(in srgb, var(--mat-sys-outline-variant) 70%, transparent);
|
||||
border-radius: 5px;
|
||||
font-family: inherit;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.02em;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
transition:
|
||||
color 0.15s ease,
|
||||
background 0.15s ease,
|
||||
border-color 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
background: var(--mat-sys-surface-container-highest);
|
||||
color: var(--mat-sys-on-surface);
|
||||
border-color: color-mix(
|
||||
in srgb,
|
||||
var(--mat-sys-outline-variant) 100%,
|
||||
transparent
|
||||
);
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--mat-sys-primary);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
app-region: no-drag;
|
||||
|
||||
button,
|
||||
a {
|
||||
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(--app-selection-color);
|
||||
background: linear-gradient(
|
||||
145deg,
|
||||
var(--app-selection-surface-strong),
|
||||
var(--app-selection-surface)
|
||||
);
|
||||
box-shadow:
|
||||
inset 0 0 0 1px var(--app-selection-border),
|
||||
0 10px 20px -16px var(--app-selection-glow);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.header-actions--leading {
|
||||
margin-left: -4px;
|
||||
}
|
||||
|
||||
.header-actions--trailing {
|
||||
margin-left: auto;
|
||||
padding-left: 8px;
|
||||
border-left: 1px solid var(--mat-sys-outline-variant);
|
||||
app-region: no-drag;
|
||||
}
|
||||
|
||||
// ─── Download Activity Indicator ─────────────────────────────────────────────
|
||||
.download-btn-wrap {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.download-activity-bar {
|
||||
position: absolute;
|
||||
bottom: 2px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: 18px;
|
||||
height: 2px;
|
||||
border-radius: 1px;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
overflow: hidden;
|
||||
pointer-events: none;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.download-activity-bar__fill {
|
||||
display: block;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: -50%;
|
||||
width: 50%;
|
||||
height: 100%;
|
||||
border-radius: inherit;
|
||||
background: var(--app-selection-color, #78adff);
|
||||
animation: download-bar-slide 1.4s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes download-bar-slide {
|
||||
0% {
|
||||
left: -50%;
|
||||
}
|
||||
|
||||
50% {
|
||||
left: 100%;
|
||||
}
|
||||
|
||||
100% {
|
||||
left: -50%;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 960px) {
|
||||
.search-field {
|
||||
min-width: 120px;
|
||||
flex: 1 1 150px;
|
||||
|
||||
.search-chip {
|
||||
max-width: 150px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 780px) {
|
||||
.workspace-header {
|
||||
padding-inline: 10px;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.playlist-switcher {
|
||||
width: 190px;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.search-field {
|
||||
gap: 6px;
|
||||
padding-inline: 10px;
|
||||
|
||||
.search-chip {
|
||||
max-width: 116px;
|
||||
padding-inline: 8px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.playlist-switcher {
|
||||
width: auto;
|
||||
min-width: 140px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.search-field {
|
||||
.search-chip--status {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
+147
@@ -0,0 +1,147 @@
|
||||
import {
|
||||
Component,
|
||||
input,
|
||||
output,
|
||||
} from '@angular/core';
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { NoopAnimationsModule } from '@angular/platform-browser/animations';
|
||||
import { MatIconButton } from '@angular/material/button';
|
||||
import { MatIcon } from '@angular/material/icon';
|
||||
import { MatTooltip } from '@angular/material/tooltip';
|
||||
import { TranslateService } from '@ngx-translate/core';
|
||||
import { of } from 'rxjs';
|
||||
import { TranslatePipe } from '@ngx-translate/core';
|
||||
import { WorkspaceShellHeaderComponent } from './workspace-shell-header.component';
|
||||
|
||||
@Component({
|
||||
selector: 'app-playlist-switcher',
|
||||
template: '',
|
||||
standalone: true,
|
||||
})
|
||||
class MockPlaylistSwitcherComponent {
|
||||
readonly currentTitle = input.required<string>();
|
||||
readonly subtitle = input('');
|
||||
readonly showPlaylistInfo = input(false);
|
||||
readonly showAccountInfo = input(false);
|
||||
readonly showAddPlaylist = input(false);
|
||||
readonly playlistInfoRequested = output<void>();
|
||||
readonly accountInfoRequested = output<void>();
|
||||
readonly addPlaylistRequested = output<void>();
|
||||
}
|
||||
|
||||
describe('WorkspaceShellHeaderComponent', () => {
|
||||
let fixture: ComponentFixture<WorkspaceShellHeaderComponent>;
|
||||
let component: WorkspaceShellHeaderComponent;
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [WorkspaceShellHeaderComponent, NoopAnimationsModule],
|
||||
providers: [
|
||||
{
|
||||
provide: TranslateService,
|
||||
useValue: {
|
||||
instant: (key: string) => key,
|
||||
get: (key: string) => of(key),
|
||||
stream: (key: string) => of(key),
|
||||
onLangChange: of(null),
|
||||
onTranslationChange: of(null),
|
||||
onDefaultLangChange: of(null),
|
||||
currentLang: 'en',
|
||||
defaultLang: 'en',
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
.overrideComponent(WorkspaceShellHeaderComponent, {
|
||||
set: {
|
||||
imports: [
|
||||
MatIcon,
|
||||
MatIconButton,
|
||||
MatTooltip,
|
||||
MockPlaylistSwitcherComponent,
|
||||
TranslatePipe,
|
||||
],
|
||||
},
|
||||
})
|
||||
.compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(WorkspaceShellHeaderComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.componentRef.setInput('playlistTitle', 'Playlist A');
|
||||
fixture.componentRef.setInput('playlistSubtitle', 'Subtitle');
|
||||
fixture.componentRef.setInput('searchQuery', 'neo');
|
||||
fixture.componentRef.setInput('canUseSearch', true);
|
||||
fixture.componentRef.setInput(
|
||||
'searchPlaceholder',
|
||||
'WORKSPACE.SHELL.SEARCH_PLAYLIST_PLACEHOLDER'
|
||||
);
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('emits search input changes as user types', () => {
|
||||
const emitted: string[] = [];
|
||||
component.searchChanged.subscribe((value) => emitted.push(value));
|
||||
const input: HTMLInputElement =
|
||||
fixture.nativeElement.querySelector('input[type="search"]');
|
||||
|
||||
input.value = 'matrix';
|
||||
input.dispatchEvent(new Event('input'));
|
||||
|
||||
expect(emitted).toEqual(['matrix']);
|
||||
});
|
||||
|
||||
it('emits add playlist requests when the toolbar add button is clicked', () => {
|
||||
const requested = jest.fn();
|
||||
component.addPlaylistRequested.subscribe(requested);
|
||||
|
||||
const button: HTMLButtonElement = fixture.nativeElement.querySelector(
|
||||
'button[aria-label="WORKSPACE.SHELL.ADD_PLAYLIST"]'
|
||||
);
|
||||
button.click();
|
||||
|
||||
expect(requested).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('does not render the removed global favorites shortcut', () => {
|
||||
const button: HTMLButtonElement | null =
|
||||
fixture.nativeElement.querySelector(
|
||||
'button[aria-label="WORKSPACE.SHELL.OPEN_GLOBAL_FAVORITES"]'
|
||||
);
|
||||
|
||||
expect(button).toBeNull();
|
||||
});
|
||||
|
||||
it('emits contextual header shortcut requests when configured', () => {
|
||||
const requested = jest.fn();
|
||||
component.headerShortcutRequested.subscribe(requested);
|
||||
fixture.componentRef.setInput('headerShortcut', {
|
||||
icon: 'tune',
|
||||
tooltipKey: 'shortcut.tooltip',
|
||||
ariaLabelKey: 'shortcut.aria',
|
||||
run: () => undefined,
|
||||
});
|
||||
fixture.detectChanges();
|
||||
|
||||
const button: HTMLButtonElement = fixture.nativeElement.querySelector(
|
||||
'button[aria-label="shortcut.aria"]'
|
||||
);
|
||||
button.click();
|
||||
|
||||
expect(requested).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('renders scope and status chips when search metadata is provided', () => {
|
||||
fixture.componentRef.setInput('searchScopeLabel', 'Movies / All Items');
|
||||
fixture.componentRef.setInput(
|
||||
'searchStatusLabel',
|
||||
'Loaded channels only'
|
||||
);
|
||||
fixture.detectChanges();
|
||||
|
||||
const chips = Array.from(
|
||||
fixture.nativeElement.querySelectorAll('.search-chip')
|
||||
).map((element: Element) => element.textContent?.trim());
|
||||
|
||||
expect(chips).toEqual(['Movies / All Items', 'Loaded channels only']);
|
||||
});
|
||||
});
|
||||
+111
@@ -0,0 +1,111 @@
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
input,
|
||||
output,
|
||||
} from '@angular/core';
|
||||
import { MatIconButton } from '@angular/material/button';
|
||||
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||
import { MatIcon } from '@angular/material/icon';
|
||||
import { MatSelectModule } from '@angular/material/select';
|
||||
import { MatTooltip } from '@angular/material/tooltip';
|
||||
import { TranslatePipe } from '@ngx-translate/core';
|
||||
import { PlaylistSwitcherComponent } from '@iptvnator/playlist/shared/ui';
|
||||
import { WorkspaceHeaderAction } from '@iptvnator/portal/shared/util';
|
||||
import { VideoPlayer } from 'shared-interfaces';
|
||||
|
||||
@Component({
|
||||
selector: 'app-workspace-shell-header',
|
||||
imports: [
|
||||
MatFormFieldModule,
|
||||
MatIcon,
|
||||
MatIconButton,
|
||||
MatSelectModule,
|
||||
MatTooltip,
|
||||
PlaylistSwitcherComponent,
|
||||
TranslatePipe,
|
||||
],
|
||||
templateUrl: './workspace-shell-header.component.html',
|
||||
styleUrl: './workspace-shell-header.component.scss',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class WorkspaceShellHeaderComponent {
|
||||
readonly VideoPlayer = VideoPlayer;
|
||||
|
||||
readonly isMac =
|
||||
typeof navigator !== 'undefined' &&
|
||||
/Mac|iPhone|iPad|iPod/i.test(navigator.platform);
|
||||
readonly commandShortcutLabel = this.isMac ? '⌘K' : 'Ctrl+K';
|
||||
|
||||
readonly playlistTitle = input('');
|
||||
readonly playlistSubtitle = input('');
|
||||
readonly canOpenPlaylistInfo = input(false);
|
||||
readonly canOpenAccountInfo = input(false);
|
||||
readonly searchQuery = input('');
|
||||
readonly canUseSearch = input(false);
|
||||
readonly searchPlaceholder = input('');
|
||||
readonly searchScopeLabel = input('');
|
||||
readonly searchStatusLabel = input('');
|
||||
readonly headerShortcut = input<WorkspaceHeaderAction | null>(null);
|
||||
readonly canRefreshPlaylist = input(false);
|
||||
readonly isRefreshingPlaylist = input(false);
|
||||
readonly isElectron = input(false);
|
||||
readonly hasNoPlaylists = input(false);
|
||||
readonly isDownloadsView = input(false);
|
||||
readonly hasActiveDownloads = input(false);
|
||||
readonly showQuickPlayerSelect = input(false);
|
||||
readonly quickHeaderPlayerValue = input(VideoPlayer.ArtPlayer);
|
||||
|
||||
readonly searchChanged = output<string>();
|
||||
readonly searchSubmitted = output<string>();
|
||||
readonly commandPaletteRequested = output<void>();
|
||||
readonly addPlaylistRequested = output<void>();
|
||||
readonly headerShortcutRequested = output<void>();
|
||||
readonly refreshPlaylistRequested = output<void>();
|
||||
readonly downloadsRequested = output<void>();
|
||||
readonly playlistInfoRequested = output<void>();
|
||||
readonly accountInfoRequested = output<void>();
|
||||
readonly quickHeaderPlayerChange = output<VideoPlayer>();
|
||||
|
||||
onSearchInput(event: Event): void {
|
||||
const target = event.target as HTMLInputElement | null;
|
||||
this.searchChanged.emit(target?.value ?? '');
|
||||
}
|
||||
|
||||
onSearchEnter(event: Event): void {
|
||||
const target = event.target as HTMLInputElement | null;
|
||||
this.searchSubmitted.emit(target?.value ?? this.searchQuery());
|
||||
}
|
||||
|
||||
onPlaylistInfoRequested(): void {
|
||||
this.playlistInfoRequested.emit();
|
||||
}
|
||||
|
||||
onAccountInfoRequested(): void {
|
||||
this.accountInfoRequested.emit();
|
||||
}
|
||||
|
||||
onAddPlaylistRequested(): void {
|
||||
this.addPlaylistRequested.emit();
|
||||
}
|
||||
|
||||
onCommandPaletteRequested(): void {
|
||||
this.commandPaletteRequested.emit();
|
||||
}
|
||||
|
||||
onHeaderShortcutRequested(): void {
|
||||
this.headerShortcutRequested.emit();
|
||||
}
|
||||
|
||||
onRefreshPlaylistRequested(): void {
|
||||
this.refreshPlaylistRequested.emit();
|
||||
}
|
||||
|
||||
onDownloadsRequested(): void {
|
||||
this.downloadsRequested.emit();
|
||||
}
|
||||
|
||||
onQuickPlayerChange(value: VideoPlayer): void {
|
||||
this.quickHeaderPlayerChange.emit(value);
|
||||
}
|
||||
}
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
<nav class="rail-links">
|
||||
@for (link of links(); track link.path.join('/')) {
|
||||
<a
|
||||
class="portal-rail-link nav-item"
|
||||
[routerLink]="link.path"
|
||||
[routerLinkActive]="activeClass()"
|
||||
[routerLinkActiveOptions]="{ exact: link.exact ?? false }"
|
||||
[matTooltip]="link.tooltip"
|
||||
[matTooltipPosition]="'right'"
|
||||
[attr.aria-label]="link.tooltip"
|
||||
[attr.aria-current]="isActive(link) ? 'page' : null"
|
||||
[class.active]="activeClass() === 'active' && isActive(link)"
|
||||
[class.is-active]="
|
||||
activeClass() === 'is-active' && isActive(link)
|
||||
"
|
||||
>
|
||||
<mat-icon>{{ resolveIcon(link) }}</mat-icon>
|
||||
</a>
|
||||
}
|
||||
</nav>
|
||||
+99
@@ -0,0 +1,99 @@
|
||||
:host {
|
||||
display: block;
|
||||
width: 100%;
|
||||
--rail-link-color: var(--mat-sys-on-surface-variant);
|
||||
}
|
||||
|
||||
.rail-links {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.portal-rail-link {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
padding: 0;
|
||||
gap: 0;
|
||||
border-radius: 14px;
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--rail-link-color);
|
||||
text-decoration: none;
|
||||
app-region: no-drag;
|
||||
animation: rail-link-enter 0.2s ease-out both;
|
||||
transition:
|
||||
background-color 0.2s ease,
|
||||
color 0.2s ease,
|
||||
box-shadow 0.2s ease,
|
||||
transform 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
color: var(--rail-link-hover-color, var(--mat-sys-on-surface));
|
||||
background: var(
|
||||
--rail-link-hover-bg,
|
||||
var(--mat-sys-surface-container)
|
||||
);
|
||||
}
|
||||
|
||||
mat-icon {
|
||||
font-size: 20px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
@for $i from 1 through 8 {
|
||||
.portal-rail-link:nth-child(#{$i}) {
|
||||
animation-delay: #{($i - 1) * 30}ms;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes rail-link-enter {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-6px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.portal-rail-link.is-active,
|
||||
.portal-rail-link.active {
|
||||
color: var(--rail-link-active-color, var(--app-selection-color));
|
||||
background: linear-gradient(
|
||||
145deg,
|
||||
var(--rail-link-active-bg-strong, var(--app-selection-surface-strong)),
|
||||
var(--rail-link-active-bg, var(--app-selection-surface))
|
||||
);
|
||||
box-shadow:
|
||||
inset 0 0 0 1px
|
||||
var(--rail-link-active-border, var(--app-selection-border)),
|
||||
0 10px 20px -16px
|
||||
var(--rail-link-active-glow, var(--app-selection-glow));
|
||||
transform: translateY(-1px);
|
||||
|
||||
mat-icon {
|
||||
color: var(--rail-link-active-icon, var(--app-selection-color));
|
||||
}
|
||||
}
|
||||
|
||||
.portal-rail-link.is-active::after,
|
||||
.portal-rail-link.active::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
right: -4px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
width: 4px;
|
||||
height: 20px;
|
||||
border-radius: 999px;
|
||||
background: var(--rail-link-active-icon, var(--app-selection-color));
|
||||
box-shadow: 0 0 10px var(--rail-link-active-glow, var(--app-selection-glow));
|
||||
}
|
||||
+56
@@ -0,0 +1,56 @@
|
||||
import { ChangeDetectionStrategy, Component, inject, input } from '@angular/core';
|
||||
import { MatIcon } from '@angular/material/icon';
|
||||
import { MatTooltip } from '@angular/material/tooltip';
|
||||
import {
|
||||
IsActiveMatchOptions,
|
||||
Router,
|
||||
RouterLink,
|
||||
RouterLinkActive,
|
||||
} from '@angular/router';
|
||||
import {
|
||||
PortalRailLink,
|
||||
PortalRailSection,
|
||||
} from '@iptvnator/portal/shared/util';
|
||||
|
||||
@Component({
|
||||
selector: 'app-workspace-shell-rail-links',
|
||||
imports: [MatIcon, MatTooltip, RouterLink, RouterLinkActive],
|
||||
templateUrl: './workspace-shell-rail-links.component.html',
|
||||
styleUrl: './workspace-shell-rail-links.component.scss',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class WorkspaceShellRailLinksComponent {
|
||||
private readonly router = inject(Router);
|
||||
|
||||
readonly links = input<PortalRailLink[]>([]);
|
||||
readonly selectedSection = input<
|
||||
PortalRailSection | string | null | undefined
|
||||
>(null);
|
||||
readonly activeClass = input<'active' | 'is-active'>('is-active');
|
||||
|
||||
resolveIcon(link: PortalRailLink): string {
|
||||
const normalizedPath = link.path.map((segment) => String(segment)).join('/');
|
||||
if (normalizedPath.includes('/workspace/sources')) {
|
||||
return 'playlist_play';
|
||||
}
|
||||
|
||||
return link.icon;
|
||||
}
|
||||
|
||||
isActive(link: PortalRailLink): boolean {
|
||||
const selectedSection = this.selectedSection();
|
||||
if (selectedSection && link.section) {
|
||||
return selectedSection === link.section;
|
||||
}
|
||||
|
||||
const commands = link.path.map((segment) => String(segment));
|
||||
const tree = this.router.createUrlTree(commands);
|
||||
const matchOptions: IsActiveMatchOptions = {
|
||||
paths: link.exact ? 'exact' : 'subset',
|
||||
queryParams: 'ignored',
|
||||
fragment: 'ignored',
|
||||
matrixParams: 'ignored',
|
||||
};
|
||||
return this.router.isActive(tree, matchOptions);
|
||||
}
|
||||
}
|
||||
+51
@@ -0,0 +1,51 @@
|
||||
<aside class="app-rail" [class.is-macos]="isMacOS()">
|
||||
<a
|
||||
class="brand"
|
||||
[routerLink]="brandLink()"
|
||||
[matTooltip]="brandTooltipKey() | translate"
|
||||
[matTooltipPosition]="'right'"
|
||||
[attr.aria-label]="brandAriaLabelKey() | translate"
|
||||
>
|
||||
<img
|
||||
src="./assets/icons/icon-tv-256.png"
|
||||
[attr.alt]="'WORKSPACE.SHELL.BRAND_ALT' | translate"
|
||||
/>
|
||||
</a>
|
||||
|
||||
<app-workspace-shell-rail-links [links]="workspaceLinks()" />
|
||||
|
||||
@if (primaryContextLinks().length > 0) {
|
||||
<div
|
||||
class="rail-context-region"
|
||||
[class]="railProviderClass()"
|
||||
>
|
||||
<app-workspace-shell-rail-links
|
||||
activeClass="is-active"
|
||||
[selectedSection]="selectedSection()"
|
||||
[links]="primaryContextLinks()"
|
||||
/>
|
||||
|
||||
@if (secondaryContextLinks().length > 0) {
|
||||
<div class="rail-divider"></div>
|
||||
<app-workspace-shell-rail-links
|
||||
activeClass="is-active"
|
||||
[selectedSection]="selectedSection()"
|
||||
[links]="secondaryContextLinks()"
|
||||
/>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="rail-footer">
|
||||
<a
|
||||
class="rail-shortcut nav-item"
|
||||
routerLink="/workspace/settings"
|
||||
[class.is-active]="isSettingsRoute()"
|
||||
[matTooltip]="'WORKSPACE.SHELL.RAIL_SETTINGS' | translate"
|
||||
[matTooltipPosition]="'right'"
|
||||
[attr.aria-label]="'WORKSPACE.SHELL.OPEN_SETTINGS' | translate"
|
||||
>
|
||||
<mat-icon>settings</mat-icon>
|
||||
</a>
|
||||
</div>
|
||||
</aside>
|
||||
+267
@@ -0,0 +1,267 @@
|
||||
:host {
|
||||
display: block;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.app-rail {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
height: 100%;
|
||||
box-sizing: border-box;
|
||||
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;
|
||||
--rail-link-color: color-mix(in srgb, var(--app-on-surface) 65%, transparent);
|
||||
--rail-link-hover-color: var(--app-on-surface);
|
||||
--rail-link-hover-bg: var(--app-hover-overlay);
|
||||
--rail-link-active-bg: var(--app-selection-surface);
|
||||
--rail-link-active-bg-strong: var(--app-selection-surface-strong);
|
||||
--rail-link-active-color: var(--app-selection-color);
|
||||
--rail-link-active-icon: var(--app-selection-color);
|
||||
--rail-link-active-border: var(--app-selection-border);
|
||||
--rail-link-active-glow: var(--app-selection-glow);
|
||||
|
||||
&.is-macos {
|
||||
padding-top: 36px;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.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,
|
||||
transform 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: linear-gradient(
|
||||
145deg,
|
||||
var(--rail-link-active-bg-strong),
|
||||
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);
|
||||
transform: translateY(-1px);
|
||||
|
||||
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 {
|
||||
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-context-region {
|
||||
--region-accent: var(--mat-sys-outline-variant);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
width: 100%;
|
||||
max-width: 48px;
|
||||
padding: 5px 2px;
|
||||
margin: 4px 0;
|
||||
border-radius: 14px;
|
||||
background: color-mix(in srgb, var(--app-on-surface) 3%, transparent);
|
||||
border: 1px solid color-mix(in srgb, var(--app-on-surface) 4%, transparent);
|
||||
position: relative;
|
||||
animation: region-enter 0.25s ease-out both;
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -1px;
|
||||
left: 10px;
|
||||
right: 10px;
|
||||
height: 2px;
|
||||
border-radius: 0 0 2px 2px;
|
||||
background: var(--region-accent);
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.rail-divider {
|
||||
width: 24px;
|
||||
margin: 3px 0;
|
||||
opacity: 0.25;
|
||||
}
|
||||
}
|
||||
|
||||
.rail-context-region--xtreams {
|
||||
--region-accent: #7c8cf5;
|
||||
}
|
||||
|
||||
.rail-context-region--stalker {
|
||||
--region-accent: #4ecdc4;
|
||||
}
|
||||
|
||||
.rail-context-region--playlists {
|
||||
--region-accent: #f5a623;
|
||||
}
|
||||
|
||||
@keyframes region-enter {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.95);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
.rail-divider {
|
||||
width: 44px;
|
||||
height: 1px;
|
||||
background: var(--mat-sys-outline-variant);
|
||||
margin: 6px 0;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.rail-footer {
|
||||
margin-top: auto;
|
||||
|
||||
.rail-shortcut {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
padding: 0;
|
||||
gap: 0;
|
||||
border-radius: 14px;
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: color-mix(in srgb, var(--app-on-surface) 65%, transparent);
|
||||
app-region: no-drag;
|
||||
|
||||
mat-icon {
|
||||
font-size: 20px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
&:hover:not(.is-active) {
|
||||
background: var(--app-hover-overlay);
|
||||
color: var(--app-on-surface);
|
||||
}
|
||||
|
||||
&.is-active::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
right: -4px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
width: 4px;
|
||||
height: 20px;
|
||||
border-radius: 999px;
|
||||
background: var(--app-selection-color);
|
||||
box-shadow: 0 0 10px var(--app-selection-glow);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.app-rail {
|
||||
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-context-region {
|
||||
width: auto;
|
||||
flex-direction: row;
|
||||
margin: 0;
|
||||
padding: 2px 4px;
|
||||
border-radius: 10px;
|
||||
|
||||
&::before {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.rail-divider {
|
||||
width: 1px;
|
||||
height: 24px;
|
||||
margin: 0 2px;
|
||||
}
|
||||
}
|
||||
|
||||
.rail-footer {
|
||||
margin-top: 0;
|
||||
}
|
||||
}
|
||||
+100
@@ -0,0 +1,100 @@
|
||||
import { Component, input } from '@angular/core';
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { MatIcon } from '@angular/material/icon';
|
||||
import { MatTooltip } from '@angular/material/tooltip';
|
||||
import { provideRouter } from '@angular/router';
|
||||
import { TranslateService } from '@ngx-translate/core';
|
||||
import { of } from 'rxjs';
|
||||
import { RouterLink } from '@angular/router';
|
||||
import { TranslatePipe } from '@ngx-translate/core';
|
||||
import { WorkspaceShellRailComponent } from './workspace-shell-rail.component';
|
||||
|
||||
@Component({
|
||||
selector: 'app-workspace-shell-rail-links',
|
||||
template: '',
|
||||
standalone: true,
|
||||
})
|
||||
class MockWorkspaceShellRailLinksComponent {
|
||||
readonly links = input<unknown[]>([]);
|
||||
readonly selectedSection = input<string | null>(null);
|
||||
readonly activeClass = input('active');
|
||||
}
|
||||
|
||||
describe('WorkspaceShellRailComponent', () => {
|
||||
let fixture: ComponentFixture<WorkspaceShellRailComponent>;
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [WorkspaceShellRailComponent],
|
||||
providers: [
|
||||
provideRouter([]),
|
||||
{
|
||||
provide: TranslateService,
|
||||
useValue: {
|
||||
instant: (key: string) => key,
|
||||
get: (key: string) => of(key),
|
||||
stream: (key: string) => of(key),
|
||||
onLangChange: of(null),
|
||||
onTranslationChange: of(null),
|
||||
onDefaultLangChange: of(null),
|
||||
currentLang: 'en',
|
||||
defaultLang: 'en',
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
.overrideComponent(WorkspaceShellRailComponent, {
|
||||
set: {
|
||||
imports: [
|
||||
MatIcon,
|
||||
MatTooltip,
|
||||
MockWorkspaceShellRailLinksComponent,
|
||||
RouterLink,
|
||||
TranslatePipe,
|
||||
],
|
||||
},
|
||||
})
|
||||
.compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(WorkspaceShellRailComponent);
|
||||
});
|
||||
|
||||
it('renders provider context region and active settings shortcut state', () => {
|
||||
fixture.componentRef.setInput('brandLink', '/workspace/sources');
|
||||
fixture.componentRef.setInput(
|
||||
'brandAriaLabelKey',
|
||||
'WORKSPACE.SHELL.OPEN_SOURCES'
|
||||
);
|
||||
fixture.componentRef.setInput('primaryContextLinks', [
|
||||
{
|
||||
icon: 'movie',
|
||||
tooltip: 'Movies',
|
||||
path: ['/workspace', 'xtreams', 'pl-1', 'vod'],
|
||||
section: 'vod',
|
||||
},
|
||||
]);
|
||||
fixture.componentRef.setInput(
|
||||
'railProviderClass',
|
||||
'rail-context-region rail-context-region--xtreams'
|
||||
);
|
||||
fixture.componentRef.setInput('isSettingsRoute', true);
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(
|
||||
fixture.nativeElement.querySelector('.rail-context-region--xtreams')
|
||||
).not.toBeNull();
|
||||
expect(
|
||||
fixture.nativeElement.querySelector('.rail-shortcut.is-active')
|
||||
).not.toBeNull();
|
||||
expect(
|
||||
fixture.nativeElement
|
||||
.querySelector('.brand')
|
||||
?.getAttribute('href')
|
||||
).toContain('/workspace/sources');
|
||||
expect(
|
||||
fixture.nativeElement
|
||||
.querySelector('.brand')
|
||||
?.getAttribute('aria-label')
|
||||
).toBe('WORKSPACE.SHELL.OPEN_SOURCES');
|
||||
});
|
||||
});
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
input,
|
||||
} from '@angular/core';
|
||||
import { MatIcon } from '@angular/material/icon';
|
||||
import { MatTooltip } from '@angular/material/tooltip';
|
||||
import { RouterLink } from '@angular/router';
|
||||
import { TranslatePipe } from '@ngx-translate/core';
|
||||
import {
|
||||
PortalRailLink,
|
||||
PortalRailSection,
|
||||
} from '@iptvnator/portal/shared/util';
|
||||
import { WorkspaceShellRailLinksComponent } from '../workspace-shell-rail-links/workspace-shell-rail-links.component';
|
||||
|
||||
@Component({
|
||||
selector: 'app-workspace-shell-rail',
|
||||
imports: [
|
||||
MatIcon,
|
||||
MatTooltip,
|
||||
RouterLink,
|
||||
TranslatePipe,
|
||||
WorkspaceShellRailLinksComponent,
|
||||
],
|
||||
templateUrl: './workspace-shell-rail.component.html',
|
||||
styleUrl: './workspace-shell-rail.component.scss',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class WorkspaceShellRailComponent {
|
||||
readonly isMacOS = input(false);
|
||||
readonly brandLink = input('/workspace/dashboard');
|
||||
readonly brandTooltipKey = input('WORKSPACE.SHELL.RAIL_DASHBOARD');
|
||||
readonly brandAriaLabelKey = input('WORKSPACE.SHELL.OPEN_DASHBOARD');
|
||||
readonly workspaceLinks = input<PortalRailLink[]>([]);
|
||||
readonly primaryContextLinks = input<PortalRailLink[]>([]);
|
||||
readonly secondaryContextLinks = input<PortalRailLink[]>([]);
|
||||
readonly selectedSection = input<
|
||||
PortalRailSection | string | null | undefined
|
||||
>(null);
|
||||
readonly railProviderClass = input('rail-context-region');
|
||||
readonly isSettingsRoute = input(false);
|
||||
}
|
||||
+578
@@ -0,0 +1,578 @@
|
||||
import { signal } from '@angular/core';
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { MatDialog } from '@angular/material/dialog';
|
||||
import { NavigationEnd, Router } from '@angular/router';
|
||||
import { Store } from '@ngrx/store';
|
||||
import { TranslateService } from '@ngx-translate/core';
|
||||
import { of } from 'rxjs';
|
||||
import { PlaylistContextFacade } from '@iptvnator/playlist/shared/util';
|
||||
import {
|
||||
PORTAL_EXTERNAL_PLAYBACK,
|
||||
WorkspaceHeaderContextService,
|
||||
WorkspaceViewCommandService,
|
||||
} from '@iptvnator/portal/shared/util';
|
||||
import { StalkerStore } from '@iptvnator/portal/stalker/data-access';
|
||||
import { XtreamStore } from '@iptvnator/portal/xtream/data-access';
|
||||
import {
|
||||
PlaylistsService,
|
||||
SettingsStore,
|
||||
WORKSPACE_PRODUCT_BRANDING,
|
||||
} from 'services';
|
||||
import { PlaylistMeta } from 'shared-interfaces';
|
||||
import {
|
||||
WorkspaceStartupPreferencesService,
|
||||
WORKSPACE_SHELL_ACTIONS,
|
||||
} from '@iptvnator/workspace/shell/util';
|
||||
import { WorkspaceShellFacade } from './workspace-shell.facade';
|
||||
|
||||
class MockXtreamStore {
|
||||
readonly recentItems = signal<unknown[]>([]);
|
||||
readonly searchTerm = signal('');
|
||||
readonly categorySearchTerm = signal('');
|
||||
readonly isImporting = signal(false);
|
||||
readonly isCancellingImport = signal(false);
|
||||
readonly activeImportSessionId = signal<string | null>(null);
|
||||
readonly currentImportPhase = signal<string | null>(null);
|
||||
readonly activeImportContentType = signal<'live' | 'vod' | 'series' | null>(
|
||||
null
|
||||
);
|
||||
readonly activeImportCurrentCount = signal(0);
|
||||
readonly activeImportTotalCount = signal(0);
|
||||
readonly getImportCount = signal(0);
|
||||
readonly itemsToImport = signal(0);
|
||||
readonly getSelectedCategory = signal<{
|
||||
category_name?: string;
|
||||
name?: string;
|
||||
} | null>(null);
|
||||
readonly vodStreams = signal<unknown[]>([{ id: 1 }]);
|
||||
readonly liveStreams = signal<unknown[]>([{ id: 2 }]);
|
||||
readonly serialStreams = signal<unknown[]>([{ id: 3 }]);
|
||||
|
||||
setSearchTerm = jest.fn((term: string) => this.searchTerm.set(term));
|
||||
setCategorySearchTerm = jest.fn((term: string) =>
|
||||
this.categorySearchTerm.set(term)
|
||||
);
|
||||
clearRecentItems = jest.fn();
|
||||
}
|
||||
|
||||
class MockStalkerStore {
|
||||
readonly searchPhrase = signal('');
|
||||
readonly getSelectedCategoryName = signal('All Items');
|
||||
|
||||
setSearchPhrase = jest.fn((term: string) => this.searchPhrase.set(term));
|
||||
}
|
||||
|
||||
function createParseUrl(url: string): { queryParams: Record<string, unknown> } {
|
||||
const parsed = new URL(url, 'http://localhost');
|
||||
const queryParams: Record<string, unknown> = {};
|
||||
|
||||
parsed.searchParams.forEach((value, key) => {
|
||||
const currentValue = queryParams[key];
|
||||
if (currentValue === undefined) {
|
||||
queryParams[key] = value;
|
||||
return;
|
||||
}
|
||||
|
||||
if (Array.isArray(currentValue)) {
|
||||
currentValue.push(value);
|
||||
return;
|
||||
}
|
||||
|
||||
queryParams[key] = [currentValue, value];
|
||||
});
|
||||
|
||||
return { queryParams };
|
||||
}
|
||||
|
||||
describe('WorkspaceShellFacade', () => {
|
||||
type PlaylistSignalMeta = PlaylistMeta & {
|
||||
serverUrl?: string;
|
||||
macAddress?: string;
|
||||
};
|
||||
|
||||
let facade: WorkspaceShellFacade;
|
||||
let router: {
|
||||
url: string;
|
||||
events: ReturnType<typeof of>;
|
||||
navigate: jest.Mock;
|
||||
navigateByUrl: jest.Mock;
|
||||
parseUrl: jest.Mock;
|
||||
createUrlTree: jest.Mock;
|
||||
isActive: jest.Mock;
|
||||
};
|
||||
let playlistsService: {
|
||||
clearPortalRecentlyViewed: jest.Mock;
|
||||
clearM3uRecentlyViewed: jest.Mock;
|
||||
};
|
||||
let workspaceActions: {
|
||||
openAddPlaylistDialog: jest.Mock;
|
||||
openGlobalSearch: jest.Mock;
|
||||
openGlobalRecent: jest.Mock;
|
||||
openAccountInfo: jest.Mock;
|
||||
};
|
||||
let storeDispatch: jest.Mock;
|
||||
let activePlaylistSignal: ReturnType<
|
||||
typeof signal<PlaylistSignalMeta | null>
|
||||
>;
|
||||
let playlistsSignal: ReturnType<typeof signal<PlaylistSignalMeta[]>>;
|
||||
let stalkerStore: MockStalkerStore;
|
||||
let showDashboardSignal: ReturnType<typeof signal<boolean>>;
|
||||
let startupPreferences: {
|
||||
getFirstAvailableWorkspacePath: jest.Mock;
|
||||
persistLastRestorablePath: jest.Mock;
|
||||
showDashboard: jest.Mock;
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
window.electron = { platform: 'darwin' } as typeof window.electron;
|
||||
showDashboardSignal = signal(true);
|
||||
|
||||
activePlaylistSignal = signal({
|
||||
_id: 'pl-1',
|
||||
serverUrl: 'http://example.com',
|
||||
title: 'Playlist A',
|
||||
recentlyViewed: ['recent-1'],
|
||||
});
|
||||
playlistsSignal = signal([
|
||||
{ _id: 'pl-1', serverUrl: 'http://example.com' },
|
||||
{ _id: 'pl-2', macAddress: '00:11:22:33' },
|
||||
]);
|
||||
|
||||
router = {
|
||||
url: '/workspace/xtreams/pl-1/vod',
|
||||
events: of(
|
||||
new NavigationEnd(
|
||||
1,
|
||||
'/workspace/xtreams/pl-1/vod',
|
||||
'/workspace/xtreams/pl-1/vod'
|
||||
)
|
||||
),
|
||||
navigate: jest.fn().mockResolvedValue(true),
|
||||
navigateByUrl: jest.fn().mockResolvedValue(true),
|
||||
parseUrl: jest.fn((url: string) => createParseUrl(url)),
|
||||
createUrlTree: jest.fn(),
|
||||
isActive: jest.fn(),
|
||||
};
|
||||
playlistsService = {
|
||||
clearPortalRecentlyViewed: jest
|
||||
.fn()
|
||||
.mockReturnValue(of({ recentlyViewed: [] })),
|
||||
clearM3uRecentlyViewed: jest
|
||||
.fn()
|
||||
.mockReturnValue(of({ recentlyViewed: [] })),
|
||||
};
|
||||
workspaceActions = {
|
||||
openAddPlaylistDialog: jest.fn(),
|
||||
openGlobalSearch: jest.fn(),
|
||||
openGlobalRecent: jest.fn(),
|
||||
openAccountInfo: jest.fn(),
|
||||
};
|
||||
startupPreferences = {
|
||||
getFirstAvailableWorkspacePath: jest.fn((showDashboard: boolean) =>
|
||||
showDashboard ? '/workspace/dashboard' : '/workspace/sources'
|
||||
),
|
||||
persistLastRestorablePath: jest.fn(),
|
||||
showDashboard: jest.fn(() => showDashboardSignal()),
|
||||
};
|
||||
storeDispatch = jest.fn();
|
||||
stalkerStore = new MockStalkerStore();
|
||||
|
||||
const selectSignal = jest.fn().mockReturnValue(playlistsSignal);
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
WorkspaceShellFacade,
|
||||
{
|
||||
provide: Router,
|
||||
useValue: router,
|
||||
},
|
||||
{
|
||||
provide: Store,
|
||||
useValue: {
|
||||
selectSignal,
|
||||
dispatch: storeDispatch,
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: XtreamStore,
|
||||
useClass: MockXtreamStore,
|
||||
},
|
||||
{
|
||||
provide: StalkerStore,
|
||||
useValue: stalkerStore,
|
||||
},
|
||||
{
|
||||
provide: PORTAL_EXTERNAL_PLAYBACK,
|
||||
useValue: {
|
||||
activeSession: signal(null),
|
||||
visibleSession: signal(null),
|
||||
closeSession: jest.fn(),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: PlaylistContextFacade,
|
||||
useValue: {
|
||||
activePlaylist: activePlaylistSignal,
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: SettingsStore,
|
||||
useValue: {
|
||||
showExternalPlaybackBar: signal(true),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: PlaylistsService,
|
||||
useValue: playlistsService,
|
||||
},
|
||||
{
|
||||
provide: MatDialog,
|
||||
useValue: {
|
||||
open: jest.fn().mockReturnValue({
|
||||
afterClosed: () => of(undefined),
|
||||
}),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: WORKSPACE_SHELL_ACTIONS,
|
||||
useValue: workspaceActions,
|
||||
},
|
||||
{
|
||||
provide: WorkspaceStartupPreferencesService,
|
||||
useValue: startupPreferences,
|
||||
},
|
||||
{
|
||||
provide: WORKSPACE_PRODUCT_BRANDING,
|
||||
useValue: null,
|
||||
},
|
||||
{
|
||||
provide: TranslateService,
|
||||
useValue: {
|
||||
instant: (
|
||||
key: string,
|
||||
params?: Record<string, string | number>
|
||||
) => {
|
||||
if (
|
||||
key === 'WORKSPACE.SHELL.XTREAM_IMPORT_PROGRESS' &&
|
||||
params
|
||||
) {
|
||||
return `${params.type} imported: ${params.current} / ${params.total}`;
|
||||
}
|
||||
|
||||
return key;
|
||||
},
|
||||
get: (key: string) => of(key),
|
||||
stream: (key: string) => of(key),
|
||||
onLangChange: of(null),
|
||||
onTranslationChange: of(null),
|
||||
onDefaultLangChange: of(null),
|
||||
currentLang: 'en',
|
||||
defaultLang: 'en',
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
facade = TestBed.inject(WorkspaceShellFacade);
|
||||
});
|
||||
|
||||
it('routes dashboard search Enter into the active Xtream playlist search', () => {
|
||||
const xtreamStore = TestBed.inject(XtreamStore) as unknown as MockXtreamStore;
|
||||
|
||||
facade.currentUrl.set('/workspace/dashboard');
|
||||
router.navigate.mockClear();
|
||||
|
||||
facade.onSearchEnter('Matrix');
|
||||
|
||||
expect(xtreamStore.setSearchTerm).toHaveBeenCalledWith('Matrix');
|
||||
expect(router.navigate).toHaveBeenCalledWith(
|
||||
['/workspace', 'xtreams', 'pl-1', 'search'],
|
||||
{
|
||||
queryParams: { q: 'Matrix' },
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
it('uses a loading label for remote Xtream fetch phases', () => {
|
||||
const xtreamStore = TestBed.inject(XtreamStore) as unknown as MockXtreamStore;
|
||||
|
||||
xtreamStore.currentImportPhase.set('loading-categories');
|
||||
expect(facade.xtreamImportPhaseLabel()).toBe(
|
||||
'WORKSPACE.SHELL.XTREAM_IMPORT_LOADING'
|
||||
);
|
||||
|
||||
xtreamStore.currentImportPhase.set('loading-live');
|
||||
expect(facade.xtreamImportPhaseLabel()).toBe(
|
||||
'WORKSPACE.SHELL.XTREAM_IMPORT_LOADING'
|
||||
);
|
||||
expect(facade.xtreamImportSourceLabel()).toBe(
|
||||
'WORKSPACE.SHELL.XTREAM_IMPORT_REMOTE_BADGE'
|
||||
);
|
||||
expect(facade.xtreamImportDetailLabel()).toBe(
|
||||
'WORKSPACE.SHELL.XTREAM_IMPORT_DETAIL_REMOTE'
|
||||
);
|
||||
|
||||
xtreamStore.currentImportPhase.set('saving-categories');
|
||||
expect(facade.xtreamImportPhaseLabel()).toBe(
|
||||
'WORKSPACE.SHELL.XTREAM_IMPORT_SAVING'
|
||||
);
|
||||
|
||||
xtreamStore.currentImportPhase.set('saving-content');
|
||||
expect(facade.xtreamImportPhaseLabel()).toBe(
|
||||
'WORKSPACE.SHELL.XTREAM_IMPORT_SAVING'
|
||||
);
|
||||
expect(facade.xtreamImportSourceLabel()).toBe(
|
||||
'WORKSPACE.SHELL.XTREAM_IMPORT_LOCAL_BADGE'
|
||||
);
|
||||
expect(facade.xtreamImportDetailLabel()).toBe(
|
||||
'WORKSPACE.SHELL.XTREAM_IMPORT_DETAIL_LOCAL'
|
||||
);
|
||||
});
|
||||
|
||||
it('builds a type-aware xtream import progress label', () => {
|
||||
const xtreamStore = TestBed.inject(XtreamStore) as unknown as MockXtreamStore;
|
||||
|
||||
xtreamStore.activeImportContentType.set('vod');
|
||||
xtreamStore.activeImportCurrentCount.set(20);
|
||||
xtreamStore.activeImportTotalCount.set(12323);
|
||||
|
||||
expect(facade.xtreamImportTypeLabel()).toBe(
|
||||
'WORKSPACE.SHELL.RAIL_MOVIES'
|
||||
);
|
||||
expect(facade.xtreamImportProgressLabel()).toBe(
|
||||
'WORKSPACE.SHELL.RAIL_MOVIES imported: 20 / 12,323'
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps stalker local-filter routes on the same page and syncs q', () => {
|
||||
router.navigate.mockClear();
|
||||
router.navigateByUrl.mockClear();
|
||||
facade.currentUrl.set('/workspace/stalker/pl-1/downloads');
|
||||
|
||||
facade.onSearchEnter('Neo');
|
||||
TestBed.flushEffects();
|
||||
|
||||
expect(router.navigate).not.toHaveBeenCalled();
|
||||
expect(router.navigateByUrl).toHaveBeenCalledWith(
|
||||
'/workspace/stalker/pl-1/downloads?q=Neo',
|
||||
{
|
||||
replaceUrl: true,
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
it('removes the q param when clearing a sources search', async () => {
|
||||
facade.appliedSearchQuery.set('matrix');
|
||||
facade.currentUrl.set('/workspace/sources?q=matrix');
|
||||
router.navigateByUrl.mockClear();
|
||||
|
||||
facade.onSearchEnter('');
|
||||
TestBed.flushEffects();
|
||||
|
||||
expect(router.navigateByUrl).toHaveBeenCalledWith('/workspace/sources', {
|
||||
replaceUrl: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('navigates to the global favorites route', () => {
|
||||
router.navigate.mockClear();
|
||||
facade.currentUrl.set('/workspace/xtreams/pl-1/vod');
|
||||
|
||||
facade.navigateToGlobalFavorites();
|
||||
|
||||
expect(router.navigate).toHaveBeenCalledWith([
|
||||
'/workspace/global-favorites',
|
||||
]);
|
||||
});
|
||||
|
||||
it('clears stalker recent items and refreshes the route', async () => {
|
||||
facade.currentUrl.set('/workspace/stalker/pl-1/recent');
|
||||
router.navigateByUrl.mockClear();
|
||||
|
||||
await facade.runHeaderBulkAction();
|
||||
|
||||
expect(playlistsService.clearPortalRecentlyViewed).toHaveBeenCalledWith(
|
||||
'pl-1'
|
||||
);
|
||||
expect(storeDispatch).toHaveBeenCalled();
|
||||
expect(router.navigateByUrl).toHaveBeenCalledWith(
|
||||
expect.stringMatching(/^\/workspace\/stalker\/pl-1\/recent\?refresh=/),
|
||||
{
|
||||
replaceUrl: true,
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
it('exposes loaded-only status for stalker itv searches', () => {
|
||||
facade.currentUrl.set('/workspace/stalker/pl-1/itv?q=cnn');
|
||||
(facade as { syncSearchFromRoute: () => void }).syncSearchFromRoute();
|
||||
TestBed.flushEffects();
|
||||
|
||||
expect(stalkerStore.setSearchPhrase).toHaveBeenCalledWith('cnn');
|
||||
expect(facade.canUseSearch()).toBe(true);
|
||||
expect(facade.searchStatusLabel()).toBe(
|
||||
'WORKSPACE.SHELL.SEARCH_STATUS_LOADED_ONLY'
|
||||
);
|
||||
});
|
||||
|
||||
it('applies q to Xtream category search on vod routes', () => {
|
||||
const xtreamStore = TestBed.inject(XtreamStore) as unknown as MockXtreamStore;
|
||||
|
||||
facade.currentUrl.set('/workspace/xtreams/pl-1/vod?q=neo');
|
||||
(facade as { syncSearchFromRoute: () => void }).syncSearchFromRoute();
|
||||
TestBed.flushEffects();
|
||||
|
||||
expect(xtreamStore.setCategorySearchTerm).toHaveBeenCalledWith('neo');
|
||||
expect(xtreamStore.setSearchTerm).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('enables local-filter search on playlist favorites routes', () => {
|
||||
facade.currentUrl.set('/workspace/playlists/pl-1/favorites?q=news');
|
||||
(facade as { syncSearchFromRoute: () => void }).syncSearchFromRoute();
|
||||
|
||||
expect(facade.canUseSearch()).toBe(true);
|
||||
expect(facade.searchQuery()).toBe('news');
|
||||
});
|
||||
|
||||
it('uses the translated global favorites scope label on the global favorites route', () => {
|
||||
facade.currentUrl.set('/workspace/global-favorites?q=news');
|
||||
(facade as { syncSearchFromRoute: () => void }).syncSearchFromRoute();
|
||||
|
||||
expect(facade.searchScopeLabel()).toBe(
|
||||
'HOME.PLAYLISTS.GLOBAL_FAVORITES'
|
||||
);
|
||||
});
|
||||
|
||||
it('removes the dashboard rail link when dashboard is hidden', () => {
|
||||
showDashboardSignal.set(false);
|
||||
|
||||
expect(facade.workspaceLinks()).toEqual([
|
||||
{
|
||||
icon: 'library_books',
|
||||
tooltip: 'WORKSPACE.SHELL.RAIL_SOURCES',
|
||||
path: ['/workspace/sources'],
|
||||
},
|
||||
{
|
||||
icon: 'favorite',
|
||||
tooltip: 'WORKSPACE.SHELL.RAIL_GLOBAL_FAVORITES',
|
||||
path: ['/workspace/global-favorites'],
|
||||
exact: true,
|
||||
},
|
||||
{
|
||||
icon: 'history',
|
||||
tooltip: 'WORKSPACE.SHELL.RAIL_GLOBAL_RECENT',
|
||||
path: ['/workspace/global-recent'],
|
||||
exact: true,
|
||||
},
|
||||
]);
|
||||
expect(facade.brandLink()).toBe('/workspace/sources');
|
||||
});
|
||||
|
||||
it('persists the last restorable route from navigation events', () => {
|
||||
expect(startupPreferences.persistLastRestorablePath).toHaveBeenCalledWith(
|
||||
'/workspace/xtreams/pl-1/vod'
|
||||
);
|
||||
});
|
||||
|
||||
it('uses the translated recent scope label on the global recent route', () => {
|
||||
facade.currentUrl.set('/workspace/global-recent?q=news');
|
||||
(facade as { syncSearchFromRoute: () => void }).syncSearchFromRoute();
|
||||
|
||||
expect(facade.searchScopeLabel()).toBe('PORTALS.RECENTLY_VIEWED');
|
||||
});
|
||||
|
||||
it('shows only actionable global commands on an empty dashboard', () => {
|
||||
activePlaylistSignal.set(null);
|
||||
playlistsSignal.set([]);
|
||||
facade.currentUrl.set('/workspace/dashboard');
|
||||
|
||||
const commands = facade.commandPaletteCommands();
|
||||
|
||||
expect(commands.map((command) => command.id)).toEqual([
|
||||
'open-global-favorites',
|
||||
'open-global-recent',
|
||||
'open-downloads',
|
||||
'open-settings',
|
||||
'open-sources',
|
||||
'add-playlist',
|
||||
]);
|
||||
expect(commands.every((command) => command.group === 'global')).toBe(
|
||||
true
|
||||
);
|
||||
expect(commands.every((command) => command.enabled)).toBe(true);
|
||||
});
|
||||
|
||||
it('includes M3U navigation, playlist actions, and Multi-EPG on playlist routes', () => {
|
||||
const headerContext = TestBed.inject(WorkspaceHeaderContextService);
|
||||
|
||||
activePlaylistSignal.set({
|
||||
_id: 'pl-m3u',
|
||||
title: 'Playlist M3U',
|
||||
count: 10,
|
||||
importDate: '2026-04-22T10:00:00.000Z',
|
||||
autoRefresh: false,
|
||||
});
|
||||
facade.currentUrl.set('/workspace/playlists/pl-m3u/groups');
|
||||
headerContext.setAction({
|
||||
id: 'm3u-multi-epg',
|
||||
icon: 'view_list',
|
||||
tooltipKey: 'TOP_MENU.OPEN_MULTI_EPG',
|
||||
ariaLabelKey: 'TOP_MENU.OPEN_MULTI_EPG',
|
||||
palette: {
|
||||
labelKey: 'TOP_MENU.OPEN_MULTI_EPG',
|
||||
descriptionKey:
|
||||
'WORKSPACE.SHELL.COMMANDS.OPEN_MULTI_EPG_DESCRIPTION',
|
||||
keywords: ['epg', 'guide', 'schedule'],
|
||||
priority: 10,
|
||||
},
|
||||
run: jest.fn(),
|
||||
});
|
||||
|
||||
const commands = facade.commandPaletteCommands();
|
||||
|
||||
expect(commands.map((command) => command.id)).toEqual(
|
||||
expect.arrayContaining([
|
||||
'm3u-multi-epg',
|
||||
'go-to-all',
|
||||
'go-to-favorites',
|
||||
'go-to-recent',
|
||||
'playlist-info',
|
||||
])
|
||||
);
|
||||
expect(
|
||||
commands.find((command) => command.id === 'm3u-multi-epg')?.group
|
||||
).toBe('view');
|
||||
expect(commands.some((command) => command.id === 'account-info')).toBe(
|
||||
false
|
||||
);
|
||||
});
|
||||
|
||||
it('places registered current-view commands ahead of globals on global favorites routes', () => {
|
||||
const viewCommands = TestBed.inject(WorkspaceViewCommandService);
|
||||
const clearCurrent = jest.fn();
|
||||
const unregister = viewCommands.registerCommand({
|
||||
id: 'clear-current-favorites',
|
||||
group: 'view',
|
||||
icon: 'delete_sweep',
|
||||
labelKey: 'WORKSPACE.SHELL.CLEAR_FAVORITES_TYPE',
|
||||
labelParams: () => ({ type: 'Live TV' }),
|
||||
descriptionKey:
|
||||
'WORKSPACE.SHELL.COMMANDS.CLEAR_CURRENT_VIEW_DESCRIPTION',
|
||||
descriptionParams: () => ({ type: 'Live TV' }),
|
||||
priority: 10,
|
||||
run: clearCurrent,
|
||||
});
|
||||
|
||||
facade.currentUrl.set('/workspace/global-favorites');
|
||||
|
||||
const commands = facade.commandPaletteCommands();
|
||||
|
||||
expect(commands[0]?.id).toBe('clear-current-favorites');
|
||||
expect(commands[0]?.group).toBe('view');
|
||||
expect(commands.some((command) => command.id === 'playlist-search')).toBe(
|
||||
false
|
||||
);
|
||||
|
||||
unregister();
|
||||
});
|
||||
});
|
||||
+1674
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,148 @@
|
||||
<div
|
||||
class="workspace-shell"
|
||||
appPlaylistDropZone
|
||||
#dropZone="playlistDropZone"
|
||||
>
|
||||
<app-playlist-drop-overlay [state]="dropZone.overlayState()" />
|
||||
@if (facade.showXtreamImportOverlay()) {
|
||||
<div class="workspace-loading-overlay">
|
||||
<div class="workspace-loading-overlay__card">
|
||||
<div class="workspace-loading-overlay__header">
|
||||
@if (facade.xtreamImportSourceLabel()) {
|
||||
<span
|
||||
class="workspace-loading-overlay__badge"
|
||||
[class.workspace-loading-overlay__badge--remote]="
|
||||
facade.xtreamImportPhaseTone() === 'remote'
|
||||
"
|
||||
>
|
||||
{{ facade.xtreamImportSourceLabel() }}
|
||||
</span>
|
||||
}
|
||||
<h3>{{ facade.xtreamImportTitleLabel() }}</h3>
|
||||
</div>
|
||||
@if (facade.xtreamImportPhaseLabel()) {
|
||||
<p
|
||||
class="workspace-loading-overlay__phase"
|
||||
aria-live="polite"
|
||||
>
|
||||
{{ facade.xtreamImportPhaseLabel() }}
|
||||
</p>
|
||||
}
|
||||
@if (facade.xtreamImportDetailLabel()) {
|
||||
<p class="workspace-loading-overlay__detail">
|
||||
{{ facade.xtreamImportDetailLabel() }}
|
||||
</p>
|
||||
}
|
||||
@if (
|
||||
facade.xtreamActiveImportCount() !== 0 &&
|
||||
facade.xtreamActiveItemsToImport() !== 0
|
||||
) {
|
||||
<mat-progress-bar
|
||||
mode="determinate"
|
||||
[value]="
|
||||
(facade.xtreamActiveImportCount() /
|
||||
facade.xtreamActiveItemsToImport()) *
|
||||
100
|
||||
"
|
||||
/>
|
||||
} @else {
|
||||
<mat-progress-bar mode="indeterminate" />
|
||||
}
|
||||
@if (facade.xtreamImportProgressLabel()) {
|
||||
<p class="workspace-loading-overlay__progress-copy">
|
||||
{{ facade.xtreamImportProgressLabel() }}
|
||||
</p>
|
||||
}
|
||||
@if (facade.canCancelXtreamImport()) {
|
||||
<button
|
||||
mat-stroked-button
|
||||
class="workspace-loading-overlay__action"
|
||||
type="button"
|
||||
[disabled]="facade.isCancellingXtreamImport()"
|
||||
(click)="facade.cancelXtreamImport()"
|
||||
>
|
||||
{{
|
||||
(facade.isCancellingXtreamImport()
|
||||
? 'WORKSPACE.SHELL.XTREAM_IMPORT_CANCELLING'
|
||||
: 'WORKSPACE.SHELL.XTREAM_IMPORT_CANCEL'
|
||||
) | translate
|
||||
}}
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<app-workspace-shell-rail
|
||||
[isMacOS]="facade.isMacOS"
|
||||
[brandLink]="facade.brandLink()"
|
||||
[brandTooltipKey]="facade.brandTooltipKey()"
|
||||
[brandAriaLabelKey]="facade.brandAriaLabelKey()"
|
||||
[workspaceLinks]="facade.workspaceLinks()"
|
||||
[primaryContextLinks]="facade.primaryContextLinks()"
|
||||
[secondaryContextLinks]="facade.secondaryContextLinks()"
|
||||
[selectedSection]="facade.currentSection()"
|
||||
[railProviderClass]="facade.railProviderClass()"
|
||||
[isSettingsRoute]="facade.isSettingsRoute()"
|
||||
/>
|
||||
|
||||
<app-workspace-shell-header
|
||||
[playlistTitle]="facade.playlistTitle()"
|
||||
[playlistSubtitle]="facade.playlistSubtitle()"
|
||||
[canOpenPlaylistInfo]="facade.canOpenPlaylistInfo()"
|
||||
[canOpenAccountInfo]="facade.canOpenAccountInfo()"
|
||||
[searchQuery]="facade.searchQuery()"
|
||||
[canUseSearch]="facade.canUseSearch()"
|
||||
[searchPlaceholder]="facade.searchPlaceholder()"
|
||||
[searchScopeLabel]="facade.searchScopeLabel()"
|
||||
[searchStatusLabel]="facade.searchStatusLabel()"
|
||||
[headerShortcut]="facade.headerShortcut()"
|
||||
[canRefreshPlaylist]="facade.canRefreshPlaylist()"
|
||||
[isRefreshingPlaylist]="facade.isRefreshingPlaylist()"
|
||||
[isElectron]="facade.isElectron"
|
||||
[hasNoPlaylists]="facade.hasNoPlaylists()"
|
||||
[isDownloadsView]="facade.isDownloadsView()"
|
||||
[hasActiveDownloads]="facade.hasActiveDownloads()"
|
||||
[showQuickPlayerSelect]="facade.isElectron"
|
||||
[quickHeaderPlayerValue]="facade.quickHeaderPlayerValue()"
|
||||
(quickHeaderPlayerChange)="facade.setQuickHeaderPlayer($event)"
|
||||
(refreshPlaylistRequested)="facade.refreshCurrentPlaylist()"
|
||||
(searchChanged)="facade.onSearchInput($event)"
|
||||
(searchSubmitted)="facade.onSearchEnter($event)"
|
||||
(commandPaletteRequested)="facade.openCommandPalette()"
|
||||
(addPlaylistRequested)="facade.openAddPlaylistDialog()"
|
||||
(headerShortcutRequested)="facade.runHeaderShortcut()"
|
||||
(downloadsRequested)="facade.openDownloadsShortcut()"
|
||||
(playlistInfoRequested)="facade.openPlaylistInfo()"
|
||||
(accountInfoRequested)="facade.openAccountInfo()"
|
||||
/>
|
||||
|
||||
<div class="workspace-body" [class.single-pane]="!facade.showContextPanel()">
|
||||
@if (facade.showContextPanel()) {
|
||||
<app-workspace-shell-context-sidebar
|
||||
[variant]="facade.contextPanel()"
|
||||
[context]="facade.currentContext()"
|
||||
[section]="facade.currentSection()"
|
||||
[hasPlaylists]="!facade.hasNoPlaylists()"
|
||||
/>
|
||||
}
|
||||
|
||||
<main class="workspace-content">
|
||||
<router-outlet />
|
||||
</main>
|
||||
</div>
|
||||
|
||||
@if (facade.showExternalPlaybackBar()) {
|
||||
@if (facade.externalPlaybackSession(); as session) {
|
||||
<footer class="workspace-now-playing">
|
||||
<app-external-playback-dock
|
||||
[session]="session"
|
||||
(closeClicked)="facade.closeActiveExternalSession()"
|
||||
(artworkClicked)="
|
||||
facade.openActiveExternalSessionTarget()
|
||||
"
|
||||
/>
|
||||
</footer>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
@@ -0,0 +1,225 @@
|
||||
:host {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.workspace-shell {
|
||||
display: grid;
|
||||
grid-template-columns: 60px 1fr;
|
||||
grid-template-rows: 56px minmax(0, 1fr) auto;
|
||||
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-workspace-shell-rail {
|
||||
grid-column: 1;
|
||||
grid-row: 1 / span 3;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
app-workspace-shell-header {
|
||||
grid-column: 2;
|
||||
grid-row: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
}
|
||||
|
||||
app-workspace-shell-context-sidebar {
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.workspace-now-playing {
|
||||
grid-column: 2;
|
||||
grid-row: 3;
|
||||
padding: 0;
|
||||
background: var(--app-content-bg, var(--mat-sys-surface));
|
||||
}
|
||||
|
||||
.workspace-content {
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
background: var(--app-content-bg, var(--mat-sys-surface));
|
||||
border-radius: 16px 0 0 0;
|
||||
box-shadow: -4px 8px 20px var(--app-content-shadow, rgba(0, 0, 0, 0.18));
|
||||
}
|
||||
|
||||
.workspace-loading-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 24px;
|
||||
background:
|
||||
radial-gradient(
|
||||
circle at top,
|
||||
rgba(99, 120, 255, 0.12),
|
||||
transparent 34%
|
||||
),
|
||||
rgba(8, 10, 16, 0.68);
|
||||
backdrop-filter: blur(14px);
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.workspace-loading-overlay__card {
|
||||
width: min(440px, 100%);
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
padding: 24px 24px 20px;
|
||||
border-radius: 24px;
|
||||
border: 1px solid var(--app-separator, rgba(255, 255, 255, 0.12));
|
||||
background:
|
||||
linear-gradient(
|
||||
180deg,
|
||||
rgba(255, 255, 255, 0.06),
|
||||
rgba(255, 255, 255, 0)
|
||||
),
|
||||
var(--mat-sys-surface-container-high);
|
||||
box-shadow:
|
||||
0 24px 64px rgba(0, 0, 0, 0.32),
|
||||
0 1px 0 rgba(255, 255, 255, 0.05) inset;
|
||||
text-align: center;
|
||||
animation: workspace-loading-overlay-enter 180ms ease-out;
|
||||
|
||||
h3 {
|
||||
margin: 0;
|
||||
font-size: 1.55rem;
|
||||
font-weight: 650;
|
||||
letter-spacing: -0.03em;
|
||||
color: var(--app-heading-color, var(--mat-sys-on-surface));
|
||||
}
|
||||
}
|
||||
|
||||
.workspace-loading-overlay__header {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
justify-items: center;
|
||||
}
|
||||
|
||||
.workspace-loading-overlay__badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 6px 12px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid rgba(120, 220, 255, 0.24);
|
||||
background: rgba(120, 220, 255, 0.08);
|
||||
color: rgba(164, 233, 255, 1);
|
||||
font-size: 0.72rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.workspace-loading-overlay__badge--remote {
|
||||
border-color: rgba(105, 195, 255, 0.34);
|
||||
background: rgba(83, 158, 255, 0.12);
|
||||
color: rgba(202, 230, 255, 1);
|
||||
}
|
||||
|
||||
.workspace-loading-overlay__phase,
|
||||
.workspace-loading-overlay__detail,
|
||||
.workspace-loading-overlay__progress-copy {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.workspace-loading-overlay__phase {
|
||||
font-size: 1rem;
|
||||
font-weight: 560;
|
||||
color: var(--app-body-color, var(--mat-sys-on-surface));
|
||||
}
|
||||
|
||||
.workspace-loading-overlay__detail {
|
||||
max-width: 32ch;
|
||||
justify-self: center;
|
||||
color: var(--app-muted-color, var(--mat-sys-on-surface-variant));
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.workspace-loading-overlay__progress-copy {
|
||||
max-width: 30ch;
|
||||
justify-self: center;
|
||||
line-height: 1.45;
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: var(--app-muted-color, var(--mat-sys-on-surface-variant));
|
||||
text-wrap: balance;
|
||||
}
|
||||
|
||||
.workspace-loading-overlay__action {
|
||||
justify-self: center;
|
||||
min-width: 180px;
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
:host ::ng-deep .workspace-loading-overlay__card .mat-mdc-progress-bar {
|
||||
overflow: hidden;
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
@keyframes workspace-loading-overlay-enter {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(10px) scale(0.985);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 780px) {
|
||||
.workspace-shell {
|
||||
grid-template-columns: 56px 1fr;
|
||||
grid-template-rows: 56px 1fr auto;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.workspace-shell {
|
||||
grid-template-columns: 1fr;
|
||||
grid-template-rows: 52px 56px minmax(0, 1fr) auto;
|
||||
}
|
||||
|
||||
app-workspace-shell-rail {
|
||||
grid-column: 1;
|
||||
grid-row: 1;
|
||||
}
|
||||
|
||||
app-workspace-shell-header {
|
||||
grid-column: 1;
|
||||
grid-row: 2;
|
||||
}
|
||||
|
||||
.workspace-body {
|
||||
grid-column: 1;
|
||||
grid-row: 3;
|
||||
}
|
||||
|
||||
.workspace-now-playing {
|
||||
grid-column: 1;
|
||||
grid-row: 4;
|
||||
}
|
||||
}
|
||||
+259
@@ -0,0 +1,259 @@
|
||||
import {
|
||||
Component,
|
||||
input,
|
||||
output,
|
||||
signal,
|
||||
} from '@angular/core';
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { MatProgressBarModule } from '@angular/material/progress-bar';
|
||||
import { RouterOutlet, provideRouter } from '@angular/router';
|
||||
import {
|
||||
WorkspacePortalContext,
|
||||
WorkspaceShellContextPanel,
|
||||
} from '@iptvnator/workspace/shell/util';
|
||||
import { WorkspaceShellComponent } from './workspace-shell.component';
|
||||
import {
|
||||
WorkspaceHeaderBulkAction,
|
||||
WorkspaceShellFacade,
|
||||
} from './services/workspace-shell.facade';
|
||||
import { VideoPlayer } from 'shared-interfaces';
|
||||
|
||||
@Component({
|
||||
selector: 'app-workspace-shell-rail',
|
||||
template: '',
|
||||
standalone: true,
|
||||
})
|
||||
class MockWorkspaceShellRailComponent {
|
||||
readonly isMacOS = input(false);
|
||||
readonly brandLink = input('/workspace/dashboard');
|
||||
readonly brandTooltipKey = input('WORKSPACE.SHELL.RAIL_DASHBOARD');
|
||||
readonly brandAriaLabelKey = input('WORKSPACE.SHELL.OPEN_DASHBOARD');
|
||||
readonly workspaceLinks = input<unknown[]>([]);
|
||||
readonly primaryContextLinks = input<unknown[]>([]);
|
||||
readonly secondaryContextLinks = input<unknown[]>([]);
|
||||
readonly selectedSection = input<string | null>(null);
|
||||
readonly railProviderClass = input('');
|
||||
readonly isSettingsRoute = input(false);
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-workspace-shell-header',
|
||||
template: '',
|
||||
standalone: true,
|
||||
})
|
||||
class MockWorkspaceShellHeaderComponent {
|
||||
readonly playlistTitle = input('');
|
||||
readonly playlistSubtitle = input('');
|
||||
readonly canOpenPlaylistInfo = input(false);
|
||||
readonly canOpenAccountInfo = input(false);
|
||||
readonly searchQuery = input('');
|
||||
readonly canUseSearch = input(false);
|
||||
readonly searchPlaceholder = input('');
|
||||
readonly searchScopeLabel = input('');
|
||||
readonly searchStatusLabel = input('');
|
||||
readonly headerShortcut = input<unknown>(null);
|
||||
readonly canRefreshPlaylist = input(false);
|
||||
readonly isRefreshingPlaylist = input(false);
|
||||
readonly isElectron = input(false);
|
||||
readonly isDownloadsView = input(false);
|
||||
readonly hasActiveDownloads = input(false);
|
||||
readonly hasNoPlaylists = input(false);
|
||||
readonly showQuickPlayerSelect = input(false);
|
||||
readonly quickHeaderPlayerValue = input(VideoPlayer.ArtPlayer);
|
||||
readonly headerBulkAction = input<WorkspaceHeaderBulkAction | null>(null);
|
||||
readonly searchChanged = output<string>();
|
||||
readonly searchSubmitted = output<string>();
|
||||
readonly commandPaletteRequested = output<void>();
|
||||
readonly addPlaylistRequested = output<void>();
|
||||
readonly headerShortcutRequested = output<void>();
|
||||
readonly refreshPlaylistRequested = output<void>();
|
||||
readonly downloadsRequested = output<void>();
|
||||
readonly headerBulkActionRequested = output<void>();
|
||||
readonly playlistInfoRequested = output<void>();
|
||||
readonly accountInfoRequested = output<void>();
|
||||
readonly quickHeaderPlayerChange = output<VideoPlayer>();
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-workspace-shell-context-sidebar',
|
||||
template: '',
|
||||
standalone: true,
|
||||
})
|
||||
class MockWorkspaceShellContextSidebarComponent {
|
||||
readonly variant = input<WorkspaceShellContextPanel>('none');
|
||||
readonly context = input<WorkspacePortalContext | null>(null);
|
||||
readonly section = input<string | null>(null);
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-external-playback-dock',
|
||||
template: '',
|
||||
standalone: true,
|
||||
})
|
||||
class MockExternalPlaybackDockComponent {
|
||||
readonly session = input<unknown>(null);
|
||||
readonly closeClicked = output<void>();
|
||||
}
|
||||
|
||||
class MockWorkspaceShellFacade {
|
||||
readonly brandLink = signal('/workspace/dashboard');
|
||||
readonly brandTooltipKey = signal('WORKSPACE.SHELL.RAIL_DASHBOARD');
|
||||
readonly brandAriaLabelKey = signal('WORKSPACE.SHELL.OPEN_DASHBOARD');
|
||||
readonly workspaceLinks = signal([]);
|
||||
readonly primaryContextLinks = signal([]);
|
||||
readonly secondaryContextLinks = signal([]);
|
||||
readonly currentSection = signal<string | null>(null);
|
||||
readonly railProviderClass = signal('rail-context-region');
|
||||
readonly isSettingsRoute = signal(false);
|
||||
readonly playlistTitle = signal('Playlist A');
|
||||
readonly playlistSubtitle = signal('Subtitle');
|
||||
readonly canOpenPlaylistInfo = signal(true);
|
||||
readonly canOpenAccountInfo = signal(true);
|
||||
readonly searchQuery = signal('');
|
||||
readonly canUseSearch = signal(true);
|
||||
readonly searchPlaceholder = signal(
|
||||
'WORKSPACE.SHELL.SEARCH_PLAYLIST_PLACEHOLDER'
|
||||
);
|
||||
readonly searchScopeLabel = signal('Movies / All Items');
|
||||
readonly searchStatusLabel = signal('');
|
||||
readonly headerShortcut = signal(null);
|
||||
readonly canRefreshPlaylist = signal(false);
|
||||
readonly isRefreshingPlaylist = signal(false);
|
||||
readonly isDownloadsView = signal(false);
|
||||
readonly hasActiveDownloads = signal(false);
|
||||
readonly headerBulkAction = signal<WorkspaceHeaderBulkAction | null>(null);
|
||||
readonly showContextPanel = signal(true);
|
||||
readonly contextPanel = signal<WorkspaceShellContextPanel>('settings');
|
||||
readonly currentContext = signal<WorkspacePortalContext | null>(null);
|
||||
readonly showExternalPlaybackBar = signal(true);
|
||||
readonly externalPlaybackSession = signal({ id: 'session-1' });
|
||||
readonly showXtreamImportOverlay = signal(false);
|
||||
readonly xtreamImportCount = signal(0);
|
||||
readonly xtreamItemsToImport = signal(0);
|
||||
readonly xtreamActiveImportCount = signal(0);
|
||||
readonly xtreamActiveItemsToImport = signal(0);
|
||||
readonly xtreamImportTitleLabel = signal(
|
||||
'WORKSPACE.SHELL.XTREAM_IMPORT_TITLE'
|
||||
);
|
||||
readonly xtreamImportSourceLabel = signal(
|
||||
'WORKSPACE.SHELL.XTREAM_IMPORT_REMOTE_BADGE'
|
||||
);
|
||||
readonly xtreamImportPhaseLabel = signal(
|
||||
'WORKSPACE.SHELL.XTREAM_IMPORT_LOADING'
|
||||
);
|
||||
readonly xtreamImportDetailLabel = signal(
|
||||
'WORKSPACE.SHELL.XTREAM_IMPORT_DETAIL_REMOTE'
|
||||
);
|
||||
readonly xtreamImportProgressLabel = signal('');
|
||||
readonly xtreamImportPhaseTone = signal<'remote' | 'local' | null>(
|
||||
'remote'
|
||||
);
|
||||
readonly canCancelXtreamImport = signal(false);
|
||||
readonly isCancellingXtreamImport = signal(false);
|
||||
readonly isMacOS = true;
|
||||
readonly isElectron = true;
|
||||
|
||||
onSearchInput = jest.fn();
|
||||
onSearchEnter = jest.fn();
|
||||
openCommandPalette = jest.fn();
|
||||
openAddPlaylistDialog = jest.fn();
|
||||
runHeaderShortcut = jest.fn();
|
||||
refreshCurrentPlaylist = jest.fn();
|
||||
openDownloadsShortcut = jest.fn();
|
||||
runHeaderBulkAction = jest.fn();
|
||||
openPlaylistInfo = jest.fn();
|
||||
openAccountInfo = jest.fn();
|
||||
closeActiveExternalSession = jest.fn();
|
||||
cancelXtreamImport = jest.fn();
|
||||
}
|
||||
|
||||
describe('WorkspaceShellComponent', () => {
|
||||
it('creates and renders the shell composition with mocked children', async () => {
|
||||
const facade = new MockWorkspaceShellFacade();
|
||||
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [WorkspaceShellComponent],
|
||||
providers: [provideRouter([])],
|
||||
})
|
||||
.overrideComponent(WorkspaceShellComponent, {
|
||||
set: {
|
||||
imports: [
|
||||
MatProgressBarModule,
|
||||
RouterOutlet,
|
||||
MockExternalPlaybackDockComponent,
|
||||
MockWorkspaceShellContextSidebarComponent,
|
||||
MockWorkspaceShellHeaderComponent,
|
||||
MockWorkspaceShellRailComponent,
|
||||
],
|
||||
providers: [
|
||||
{
|
||||
provide: WorkspaceShellFacade,
|
||||
useValue: facade,
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
.compileComponents();
|
||||
|
||||
const fixture = TestBed.createComponent(WorkspaceShellComponent);
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(fixture.componentInstance).toBeTruthy();
|
||||
expect(
|
||||
fixture.nativeElement.querySelector('app-workspace-shell-rail')
|
||||
).not.toBeNull();
|
||||
expect(
|
||||
fixture.nativeElement.querySelector('app-workspace-shell-header')
|
||||
).not.toBeNull();
|
||||
expect(
|
||||
fixture.nativeElement.querySelector(
|
||||
'app-workspace-shell-context-sidebar'
|
||||
)
|
||||
).not.toBeNull();
|
||||
expect(
|
||||
fixture.nativeElement.querySelector('app-external-playback-dock')
|
||||
).not.toBeNull();
|
||||
});
|
||||
|
||||
it('renders type-aware xtream import progress copy in the overlay', async () => {
|
||||
const facade = new MockWorkspaceShellFacade();
|
||||
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [WorkspaceShellComponent],
|
||||
providers: [provideRouter([])],
|
||||
})
|
||||
.overrideComponent(WorkspaceShellComponent, {
|
||||
set: {
|
||||
imports: [
|
||||
MatProgressBarModule,
|
||||
RouterOutlet,
|
||||
MockExternalPlaybackDockComponent,
|
||||
MockWorkspaceShellContextSidebarComponent,
|
||||
MockWorkspaceShellHeaderComponent,
|
||||
MockWorkspaceShellRailComponent,
|
||||
],
|
||||
providers: [
|
||||
{
|
||||
provide: WorkspaceShellFacade,
|
||||
useValue: facade,
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
.compileComponents();
|
||||
|
||||
facade.showXtreamImportOverlay.set(true);
|
||||
facade.xtreamActiveImportCount.set(20);
|
||||
facade.xtreamActiveItemsToImport.set(12323);
|
||||
facade.xtreamImportProgressLabel.set('Movies imported: 20 / 12,323');
|
||||
|
||||
const fixture = TestBed.createComponent(WorkspaceShellComponent);
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(
|
||||
fixture.nativeElement.querySelector(
|
||||
'.workspace-loading-overlay__progress-copy'
|
||||
)?.textContent
|
||||
).toContain('Movies imported: 20 / 12,323');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
import {
|
||||
Component,
|
||||
inject,
|
||||
} from '@angular/core';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatProgressBarModule } from '@angular/material/progress-bar';
|
||||
import { RouterOutlet } from '@angular/router';
|
||||
import { TranslatePipe } from '@ngx-translate/core';
|
||||
import { ExternalPlaybackDockComponent } from 'components';
|
||||
import {
|
||||
PlaylistDropOverlayComponent,
|
||||
PlaylistDropZoneDirective,
|
||||
} from '../playlist-drop-overlay';
|
||||
import { WorkspaceShellContextSidebarComponent } from './components/workspace-shell-context-sidebar/workspace-shell-context-sidebar.component';
|
||||
import { WorkspaceShellHeaderComponent } from './components/workspace-shell-header/workspace-shell-header.component';
|
||||
import { WorkspaceShellRailComponent } from './components/workspace-shell-rail/workspace-shell-rail.component';
|
||||
import { WorkspaceShellFacade } from './services/workspace-shell.facade';
|
||||
|
||||
@Component({
|
||||
selector: 'app-workspace-shell',
|
||||
imports: [
|
||||
ExternalPlaybackDockComponent,
|
||||
MatButtonModule,
|
||||
MatProgressBarModule,
|
||||
PlaylistDropOverlayComponent,
|
||||
PlaylistDropZoneDirective,
|
||||
RouterOutlet,
|
||||
TranslatePipe,
|
||||
WorkspaceShellContextSidebarComponent,
|
||||
WorkspaceShellHeaderComponent,
|
||||
WorkspaceShellRailComponent,
|
||||
],
|
||||
templateUrl: './workspace-shell.component.html',
|
||||
styleUrl: './workspace-shell.component.scss',
|
||||
providers: [WorkspaceShellFacade],
|
||||
})
|
||||
export class WorkspaceShellComponent {
|
||||
readonly facade = inject(WorkspaceShellFacade);
|
||||
}
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
<div class="context-column">
|
||||
<header class="context-header">
|
||||
<div class="context-header__top">
|
||||
<h2>{{ 'WORKSPACE.SOURCES.TITLE' | translate }}</h2>
|
||||
</div>
|
||||
<p>{{ 'WORKSPACE.SOURCES.SUBTITLE' | translate }}</p>
|
||||
</header>
|
||||
|
||||
<div class="context-divider"></div>
|
||||
|
||||
<section class="panel-section">
|
||||
<div class="nav-list option-list">
|
||||
<h3 class="panel-section-title">
|
||||
{{ 'WORKSPACE.SOURCES.TYPE' | translate }}
|
||||
</h3>
|
||||
@for (option of typeOptions; track option.id) {
|
||||
<button
|
||||
type="button"
|
||||
class="nav-item category-item option-row"
|
||||
[class.selected]="isTypeActive(option.id)"
|
||||
(click)="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>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
+102
@@ -0,0 +1,102 @@
|
||||
@use '../../nav-list.scss';
|
||||
|
||||
: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;
|
||||
}
|
||||
|
||||
.option-row {
|
||||
border-radius: 12px;
|
||||
transition:
|
||||
box-shadow 0.18s ease,
|
||||
transform 0.18s ease;
|
||||
|
||||
.option-label,
|
||||
.option-meta,
|
||||
mat-icon {
|
||||
transition: color 0.18s ease, opacity 0.18s ease;
|
||||
}
|
||||
|
||||
&.selected {
|
||||
box-shadow: 0 10px 20px -18px var(--app-selection-glow);
|
||||
transform: translateY(-1px);
|
||||
|
||||
.option-label,
|
||||
.option-meta,
|
||||
mat-icon {
|
||||
color: var(--app-selection-color) !important;
|
||||
}
|
||||
|
||||
.option-meta {
|
||||
opacity: 0.95;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.panel-section-title {
|
||||
margin: 0;
|
||||
padding: 10px 10px 6px;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--mat-sys-on-surface-variant);
|
||||
opacity: 0.85;
|
||||
}
|
||||
+100
@@ -0,0 +1,100 @@
|
||||
import { computed, Component, inject } from '@angular/core';
|
||||
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';
|
||||
|
||||
type PlaylistFilterId = 'all' | 'm3u' | 'xtream' | 'stalker';
|
||||
|
||||
interface PlaylistFilterOption {
|
||||
id: PlaylistFilterId;
|
||||
icon: string;
|
||||
label?: 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 activeTypeFilters = this.store.selectSignal(
|
||||
selectActiveTypeFilters
|
||||
);
|
||||
private readonly playlists = this.store.selectSignal(selectAllPlaylistsMeta);
|
||||
|
||||
readonly typeOptions: PlaylistFilterOption[] = [
|
||||
{
|
||||
id: 'all',
|
||||
icon: 'layers',
|
||||
translationKey: 'WORKSPACE.SOURCES.ALL',
|
||||
},
|
||||
{
|
||||
id: 'm3u',
|
||||
icon: 'playlist_play',
|
||||
translationKey: 'HOME.PLAYLIST_TYPES.M3U',
|
||||
},
|
||||
{
|
||||
id: 'xtream',
|
||||
icon: 'cloud',
|
||||
translationKey: 'HOME.PLAYLIST_TYPES.XTREAM',
|
||||
},
|
||||
{
|
||||
id: 'stalker',
|
||||
icon: 'router',
|
||||
translationKey: 'HOME.PLAYLIST_TYPES.STALKER',
|
||||
},
|
||||
];
|
||||
|
||||
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];
|
||||
}
|
||||
}
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
<section
|
||||
class="sources-page"
|
||||
[class.sources-page--empty]="visibleSourcesCount() === 0"
|
||||
>
|
||||
@if (visibleSourcesCount() > 0) {
|
||||
<header class="sources-header">
|
||||
<div class="sources-header__text">
|
||||
<h2 class="sources-title">{{ title() }}</h2>
|
||||
<p class="sources-subtitle">{{ subtitle() }}</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
mat-stroked-button
|
||||
class="sort-trigger"
|
||||
[matMenuTriggerFor]="sortMenu"
|
||||
>
|
||||
<mat-icon>sort</mat-icon>
|
||||
<span class="sort-trigger__label">{{ activeSortLabel() }}</span>
|
||||
<mat-icon class="sort-trigger__caret">arrow_drop_down</mat-icon>
|
||||
</button>
|
||||
<mat-menu #sortMenu="matMenu" xPosition="before">
|
||||
@for (option of sortOptions; track option.translationKey) {
|
||||
<button
|
||||
mat-menu-item
|
||||
type="button"
|
||||
[class.active-option]="isSortActive(option)"
|
||||
(click)="setSortOption(option)"
|
||||
>
|
||||
<mat-icon>{{ option.icon }}</mat-icon>
|
||||
<span>{{ option.translationKey | translate }}</span>
|
||||
@if (isSortActive(option)) {
|
||||
<mat-icon class="sort-menu__check">check</mat-icon>
|
||||
}
|
||||
</button>
|
||||
}
|
||||
</mat-menu>
|
||||
</header>
|
||||
}
|
||||
<div class="sources-content">
|
||||
<app-recent-playlists
|
||||
[searchQueryInput]="searchQuery()"
|
||||
(addPlaylistClicked)="onAddPlaylist($event)"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
+89
@@ -0,0 +1,89 @@
|
||||
: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;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.sources-header__text {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.sources-title {
|
||||
margin: 0;
|
||||
font-size: 1.2rem;
|
||||
font-weight: 650;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.sources-subtitle {
|
||||
margin: 4px 0 0;
|
||||
font-size: 0.875rem;
|
||||
color: var(--mat-sys-on-surface-variant, rgba(255, 255, 255, 0.6));
|
||||
}
|
||||
|
||||
.sort-trigger {
|
||||
flex-shrink: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
|
||||
.sort-trigger__label {
|
||||
font-size: 0.85rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.sort-trigger__caret {
|
||||
font-size: 18px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
margin-left: -2px;
|
||||
}
|
||||
}
|
||||
|
||||
.sources-content {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
padding: 0 20px 20px;
|
||||
}
|
||||
|
||||
.sources-content app-recent-playlists {
|
||||
display: block;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.sources-page--empty {
|
||||
.sources-content {
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.sources-content app-recent-playlists {
|
||||
flex: 1;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
}
|
||||
}
|
||||
+193
@@ -0,0 +1,193 @@
|
||||
import { Component, computed, inject } from '@angular/core';
|
||||
import { toSignal } from '@angular/core/rxjs-interop';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatMenuModule } from '@angular/material/menu';
|
||||
import { ActivatedRoute } from '@angular/router';
|
||||
import { Store } from '@ngrx/store';
|
||||
import { RecentPlaylistsComponent } from '@iptvnator/playlist/shared/ui';
|
||||
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
|
||||
import { selectActiveTypeFilters, selectAllPlaylistsMeta } from 'm3u-state';
|
||||
import { map, startWith } from 'rxjs';
|
||||
import { SortBy, SortOrder, SortService } from 'services';
|
||||
import {
|
||||
WORKSPACE_SHELL_ACTIONS,
|
||||
WorkspacePlaylistType,
|
||||
} from '@iptvnator/workspace/shell/util';
|
||||
|
||||
interface SortOption {
|
||||
by: SortBy;
|
||||
order: SortOrder;
|
||||
icon: string;
|
||||
translationKey: string;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-workspace-sources',
|
||||
imports: [
|
||||
MatButtonModule,
|
||||
MatIconModule,
|
||||
MatMenuModule,
|
||||
RecentPlaylistsComponent,
|
||||
TranslatePipe,
|
||||
],
|
||||
templateUrl: './workspace-sources.component.html',
|
||||
styleUrl: './workspace-sources.component.scss',
|
||||
})
|
||||
export class WorkspaceSourcesComponent {
|
||||
private readonly route = inject(ActivatedRoute);
|
||||
private readonly store = inject(Store);
|
||||
private readonly workspaceActions = inject(WORKSPACE_SHELL_ACTIONS);
|
||||
private readonly translate = inject(TranslateService);
|
||||
private readonly sortService = inject(SortService);
|
||||
|
||||
private readonly activeTypeFilters = this.store.selectSignal(
|
||||
selectActiveTypeFilters
|
||||
);
|
||||
private readonly playlists = this.store.selectSignal(
|
||||
selectAllPlaylistsMeta
|
||||
);
|
||||
private readonly languageTick = toSignal(
|
||||
this.translate.onLangChange.pipe(startWith(null)),
|
||||
{ initialValue: null }
|
||||
);
|
||||
|
||||
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',
|
||||
},
|
||||
];
|
||||
|
||||
private readonly currentSortOptions = toSignal(
|
||||
this.sortService.getSortOptions(),
|
||||
{ requireSync: true }
|
||||
);
|
||||
|
||||
readonly searchQuery = toSignal(
|
||||
this.route.queryParamMap.pipe(map((params) => params.get('q') ?? '')),
|
||||
{ initialValue: '' }
|
||||
);
|
||||
readonly title = computed(() => {
|
||||
this.languageTick();
|
||||
|
||||
const filters = this.activeTypeFilters();
|
||||
|
||||
if (filters.length === 1) {
|
||||
if (filters[0] === 'm3u') {
|
||||
return this.translateText('WORKSPACE.SOURCES.M3U_PLAYLISTS');
|
||||
}
|
||||
if (filters[0] === 'xtream') {
|
||||
return this.translateText('WORKSPACE.SOURCES.XTREAM_PLAYLISTS');
|
||||
}
|
||||
if (filters[0] === 'stalker') {
|
||||
return this.translateText(
|
||||
'WORKSPACE.SOURCES.STALKER_PLAYLISTS'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return this.translateText('WORKSPACE.SOURCES.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(() => {
|
||||
this.languageTick();
|
||||
|
||||
const count = this.visibleSourcesCount();
|
||||
if (count === 1) {
|
||||
return this.translateText('WORKSPACE.SOURCES.PLAYLIST_COUNT_ONE');
|
||||
}
|
||||
|
||||
return this.translateText('WORKSPACE.SOURCES.PLAYLIST_COUNT_OTHER', {
|
||||
count,
|
||||
});
|
||||
});
|
||||
|
||||
readonly activeSortLabel = computed(() => {
|
||||
this.languageTick();
|
||||
const current = this.currentSortOptions();
|
||||
const match = this.sortOptions.find(
|
||||
(option) =>
|
||||
option.by === current.by && option.order === current.order
|
||||
);
|
||||
return this.translateText(
|
||||
match?.translationKey ?? 'HOME.SORT_OPTIONS.NEWEST'
|
||||
);
|
||||
});
|
||||
|
||||
onAddPlaylist(type?: WorkspacePlaylistType): void {
|
||||
this.workspaceActions.openAddPlaylistDialog(type);
|
||||
}
|
||||
|
||||
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,
|
||||
});
|
||||
}
|
||||
|
||||
private translateText(
|
||||
key: string,
|
||||
params?: Record<string, string | number>
|
||||
): string {
|
||||
return this.translate.instant(key, params);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
@use '../../../../../apps/web/src/nav-list.scss';
|
||||
@@ -0,0 +1,6 @@
|
||||
import { setupZoneTestEnv } from 'jest-preset-angular/setup-env/zone';
|
||||
|
||||
setupZoneTestEnv({
|
||||
errorOnUnknownElements: true,
|
||||
errorOnUnknownProperties: true,
|
||||
});
|
||||
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"extends": "../../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"isolatedModules": true,
|
||||
"target": "es2022",
|
||||
"moduleResolution": "bundler",
|
||||
"strict": true,
|
||||
"noImplicitOverride": true,
|
||||
"noPropertyAccessFromIndexSignature": true,
|
||||
"noImplicitReturns": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"emitDecoratorMetadata": false,
|
||||
"module": "preserve"
|
||||
},
|
||||
"angularCompilerOptions": {
|
||||
"enableI18nLegacyMessageIdFormat": false,
|
||||
"strictInjectionParameters": true,
|
||||
"strictInputAccessModifiers": true,
|
||||
"strictTemplates": true
|
||||
},
|
||||
"files": [],
|
||||
"include": [],
|
||||
"references": [
|
||||
{
|
||||
"path": "./tsconfig.lib.json"
|
||||
},
|
||||
{
|
||||
"path": "./tsconfig.spec.json"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "../../../../dist/out-tsc",
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"inlineSources": true,
|
||||
"types": []
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["src/**/*.spec.ts", "src/**/*.test.ts"]
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "../../../../dist/out-tsc",
|
||||
"module": "commonjs",
|
||||
"target": "es2016",
|
||||
"types": ["jest", "node"],
|
||||
"moduleResolution": "node10"
|
||||
},
|
||||
"files": ["src/test-setup.ts"],
|
||||
"include": [
|
||||
"jest.config.ts",
|
||||
"src/**/*.test.ts",
|
||||
"src/**/*.spec.ts",
|
||||
"src/**/*.d.ts"
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user