feat: enhance workspace command palette and context panel

- Added new commands to the workspace command palette including open-settings, open-sources, open-dashboard, add-playlist, refresh-playlist, playlist-info, account-info, and navigation commands for vod, live, and series.
- Updated command icons for new commands in workspace-command-palette.component.ts.
- Improved workspace context category view with inline status messages and loading indicators for item counts.
- Refactored category view to use buttons instead of links for better accessibility and interaction handling.
- Introduced search functionality in the workspace context panel to filter categories.
- Enhanced styling for the context panel and header components, including a download activity indicator.
- Added tests for the workspace context panel to ensure proper functionality and interaction handling.

Entire-Checkpoint: 271bd0162027
This commit is contained in:
4gray committed 2026-04-04 20:22:24 +02:00
1 parent c62e8859ea
commit 70b7f570ef
15 files changed
+1086 -260

No files matched your search

@@ -1,72 +1,61 @@
<div class="palette-shell">
<header class="palette-header">
<h2>{{ 'WORKSPACE.COMMAND_PALETTE.TITLE' | translate }}</h2>
<p>{{ 'WORKSPACE.COMMAND_PALETTE.SUBTITLE' | translate }}</p>
</header>
<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">&#8984;K</kbd>
</label>
<div class="palette-search-panel">
<label class="palette-search">
<mat-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
"
/>
<span class="palette-search__hint">Ctrl/Cmd+K</span>
</label>
</div>
@if (commandGroups().length > 0) {
<div class="palette-divider"></div>
<div class="palette-results">
@if (commandGroups().length === 0) {
<div class="palette-empty">
{{ 'WORKSPACE.COMMAND_PALETTE.EMPTY' | translate }}
</div>
} @else {
<div class="palette-results">
@for (group of commandGroups(); track group.scope) {
<section class="palette-group">
<h3>{{ getScopeTitleKey(group.scope) | translate }}</h3>
<div class="palette-group__items">
@for (command of group.items; track command.id) {
<button
type="button"
class="palette-command"
[class.palette-command--with-icon]="
!!getCommandIcon(command)
"
[class.is-selected]="isCommandSelected(command)"
[disabled]="!command.enabled"
(mouseenter)="onCommandHover(command)"
(click)="onCommandClick(command)"
>
@if (getCommandIcon(command); as icon) {
<span class="palette-command__icon">
<mat-icon>{{ icon }}</mat-icon>
</span>
}
<span class="palette-command__content">
<span class="palette-command__label">{{
command.label
}}</span>
<span
class="palette-command__description"
>
{{ command.description }}
</span>
</span>
</button>
}
</div>
<h3 class="palette-group__title">
{{ getScopeTitleKey(group.scope) | 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>{{
getCommandIcon(command)
}}</mat-icon>
</span>
<span class="palette-command__label">{{
command.label
}}</span>
<span class="palette-command__desc">{{
command.description
}}</span>
</button>
}
</section>
}
}
</div>
</div>
} @else {
<div class="palette-divider"></div>
<div class="palette-empty">
{{ 'WORKSPACE.COMMAND_PALETTE.EMPTY' | translate }}
</div>
}
</div>
@@ -5,8 +5,8 @@
app-region: no-drag;
}
// ─── Shell ───────────────────────────────────────────────────────────────────
.palette-shell {
padding: 14px;
background: var(--app-header-bg, var(--mat-sys-surface-container-low));
color: var(--mat-sys-on-surface);
border: 1px solid var(--mat-sys-outline-variant);
@@ -14,205 +14,207 @@
box-shadow:
0 24px 48px -28px rgba(0, 0, 0, 0.5),
0 10px 24px -18px rgba(0, 0, 0, 0.35);
overflow: hidden;
}
.palette-header {
display: grid;
gap: 2px;
margin-bottom: 10px;
h2 {
margin: 0;
font-size: 1rem;
letter-spacing: -0.01em;
font-weight: 640;
}
p {
margin: 0;
font-size: 0.78rem;
color: var(--mat-sys-on-surface-variant);
}
}
.palette-search-panel {
margin: 0 -2px;
padding: 10px 12px 12px;
border: 1px solid var(--app-separator, var(--mat-sys-outline-variant));
border-radius: 12px;
background: var(
--app-widget-header-bg,
var(--mat-sys-surface-container-high)
);
}
// ─── Search ──────────────────────────────────────────────────────────────────
.palette-search {
width: 100%;
display: flex;
align-items: center;
gap: 8px;
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));
min-height: 40px;
padding: 0 12px;
box-sizing: border-box;
transition:
border-color 0.15s ease,
background-color 0.15s ease,
box-shadow 0.15s ease;
gap: 10px;
padding: 0 14px;
min-height: 44px;
cursor: text;
}
&:focus-within {
border-color: var(--mat-sys-primary);
box-shadow: 0 0 0 3px
color-mix(in srgb, var(--mat-sys-primary) 12%, transparent);
}
.palette-search__icon {
font-size: 20px;
width: 20px;
height: 20px;
color: var(--mat-sys-on-surface-variant);
flex-shrink: 0;
opacity: 0.7;
}
mat-icon {
font-size: 18px;
width: 18px;
height: 18px;
.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);
flex-shrink: 0;
opacity: 0.55;
}
input {
flex: 1;
border: none;
outline: none;
background: transparent;
color: var(--mat-sys-on-surface);
font: inherit;
font-size: 0.875rem;
width: 100%;
&::placeholder {
color: var(--mat-sys-on-surface-variant);
opacity: 0.7;
}
&::-webkit-search-cancel-button {
display: none;
}
}
.palette-search__hint {
font-size: 0.72rem;
.palette-search__kbd {
font-family: inherit;
font-size: 0.68rem;
font-weight: 500;
color: var(--mat-sys-on-surface-variant);
border: 1px solid var(--app-separator, var(--mat-sys-outline-variant));
border-radius: 6px;
padding: 2px 6px;
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;
background: var(
--app-widget-header-bg,
var(--mat-sys-surface-container-high)
);
opacity: 0.7;
}
// ─── Divider ─────────────────────────────────────────────────────────────────
.palette-divider {
height: 1px;
background: var(--mat-sys-outline-variant);
opacity: 0.6;
}
// ─── Results ─────────────────────────────────────────────────────────────────
.palette-results {
max-height: min(56vh, 520px);
overflow: auto;
margin-top: 12px;
display: grid;
gap: 10px;
padding-right: 2px;
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 {
display: grid;
gap: 6px;
h3 {
margin: 0;
font-size: 0.74rem;
color: var(--mat-sys-on-surface-variant);
text-transform: uppercase;
letter-spacing: 0.05em;
font-weight: 620;
&:not(:first-child) {
margin-top: 4px;
}
}
.palette-group__items {
display: grid;
gap: 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: grid;
gap: 1px;
display: flex;
align-items: center;
gap: 10px;
width: 100%;
text-align: left;
padding: 7px 8px;
border: 1px solid transparent;
border-radius: 10px;
border-radius: 9px;
background: transparent;
color: inherit;
padding: 8px 10px;
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(
145deg,
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.45;
opacity: 0.4;
}
}
.palette-command--with-icon {
grid-template-columns: auto minmax(0, 1fr);
align-items: center;
gap: 10px;
}
.palette-command__icon {
width: 30px;
height: 30px;
width: 28px;
height: 28px;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 9px;
border-radius: 7px;
background: color-mix(
in srgb,
var(--app-selection-surface-strong) 72%,
var(--mat-sys-on-surface-variant) 10%,
transparent
);
color: var(--app-selection-color);
color: var(--mat-sys-on-surface-variant);
flex-shrink: 0;
transition:
background 0.12s ease,
color 0.12s ease;
mat-icon {
font-size: 18px;
width: 18px;
height: 18px;
font-size: 17px;
width: 17px;
height: 17px;
}
}
.palette-command__content {
min-width: 0;
display: grid;
gap: 1px;
}
.palette-command__label {
font-size: 0.9rem;
font-weight: 560;
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__description {
font-size: 0.76rem;
.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 {
border: 1px dashed var(--mat-sys-outline-variant);
border-radius: 10px;
padding: 18px;
padding: 20px 14px;
text-align: center;
color: var(--mat-sys-on-surface-variant);
font-size: 0.88rem;
font-size: 0.82rem;
opacity: 0.7;
}
@@ -23,7 +23,17 @@ export type WorkspaceCommandId =
| 'playlist-search'
| 'open-global-favorites'
| 'open-downloads'
| 'open-global-recent';
| 'open-global-recent'
| 'open-settings'
| 'open-sources'
| 'open-dashboard'
| 'add-playlist'
| 'refresh-playlist'
| 'playlist-info'
| 'account-info'
| 'go-to-vod'
| 'go-to-live'
| 'go-to-series';
export interface WorkspaceCommandItem {
id: WorkspaceCommandId;
@@ -229,20 +239,38 @@ export class WorkspaceCommandPaletteComponent implements AfterViewInit {
return 'WORKSPACE.COMMAND_PALETTE.GROUP_SECTION';
}
getCommandIcon(command: WorkspaceCommandItem): string | null {
if (command.scope !== 'global') {
return null;
}
getCommandIcon(command: WorkspaceCommandItem): string {
switch (command.id) {
case 'global-search':
return 'search';
case 'playlist-search':
return 'playlist_play';
case 'open-global-favorites':
return 'star';
case 'open-downloads':
return 'download';
case 'open-global-recent':
return 'history';
case 'open-settings':
return 'settings';
case 'open-sources':
return 'library_books';
case 'open-dashboard':
return 'dashboard';
case 'add-playlist':
return 'add_circle_outline';
case 'refresh-playlist':
return 'sync';
case 'playlist-info':
return 'info';
case 'account-info':
return 'account_circle';
case 'go-to-vod':
return 'movie';
case 'go-to-live':
return 'live_tv';
case 'go-to-series':
return 'video_library';
default:
return 'bolt';
}
@@ -1,15 +1,24 @@
@if (items()?.length > 0) {
<div class="nav-list">
@if (statusText()) {
<div class="context-inline-status">
{{ statusText() }}
</div>
}
@for (item of items(); track $index) {
<a
<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"
(click)="categoryClicked.emit(item)"
[attr.aria-disabled]="!interactionEnabled()"
[disabled]="!interactionEnabled()"
(click)="onCategoryClick(item)"
>
<span class="nav-item-label">
{{
@@ -19,15 +28,22 @@
}}
</span>
@if (showCounts()) {
<div class="nav-item-meta item-count">
{{ getItemCount(item) }}
</div>
@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>
}
</a>
</button>
} @empty {
<app-workspace-context-error-view
[title]="'PORTALS.EMPTY_LIST_VIEW.TITLE' | translate"
@@ -10,7 +10,29 @@
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;
@@ -21,6 +43,45 @@
}
}
.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;
@@ -51,6 +112,12 @@ mat-card-content {
}
}
@keyframes context-item-count-shimmer {
100% {
transform: translateX(120%);
}
}
@media (max-width: 800px) {
.category-item {
width: 100%;
@@ -31,6 +31,9 @@ export class WorkspaceContextCategoryViewComponent {
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>);
@@ -49,7 +52,8 @@ export class WorkspaceContextCategoryViewComponent {
container.querySelectorAll('[data-category-id]')
) as HTMLElement[];
const selected = candidates.find(
(el) => el.dataset.categoryId === String(selectedCategory)
(el) =>
el.dataset['categoryId'] === String(selectedCategory)
);
if (!selected) {
return;
@@ -88,4 +92,12 @@ export class WorkspaceContextCategoryViewComponent {
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);
}
}
@@ -2,36 +2,103 @@
<header class="context-header">
<div class="context-header__top">
<h2>{{ title() | translate }}</h2>
@if (isXtreamCategories()) {
<button
type="button"
mat-icon-button
class="context-header__action"
(click)="openManageCategories()"
[matTooltip]="'WORKSPACE.CONTEXT.MANAGE_CATEGORIES' | translate"
[attr.aria-label]="
'WORKSPACE.CONTEXT.MANAGE_CATEGORIES' | translate
"
>
<mat-icon>tune</mat-icon>
</button>
}
<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>
<div class="context-divider"></div>
@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()) {
<app-workspace-context-category-view
[items]="xtreamCategories()"
[selectedCategoryId]="xtreamSelectedCategoryId()"
[itemCounts]="xtreamCategoryItemCounts()"
[showCounts]="true"
(categoryClicked)="onXtreamCategoryClicked($event)"
/>
@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">
@@ -56,7 +123,7 @@
/>
} @else {
<app-workspace-context-category-view
[items]="stalkerCategories()"
[items]="filteredStalkerCategories()"
[selectedCategoryId]="$any(stalkerSelectedCategoryId())"
(categoryClicked)="onStalkerCategoryClicked($any($event))"
/>
@@ -35,11 +35,27 @@
}
}
.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;
@@ -70,6 +86,74 @@
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;
@@ -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,
]);
});
});
@@ -1,11 +1,21 @@
import { Component, computed, DestroyRef, inject, input } from '@angular/core';
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 } from '@ngx-translate/core';
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';
@@ -42,6 +52,7 @@ export class WorkspaceContextPanelComponent {
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>();
@@ -64,6 +75,52 @@ export class WorkspaceContextPanelComponent {
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;
@@ -76,6 +133,45 @@ export class WorkspaceContextPanelComponent {
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') {
@@ -110,7 +206,39 @@ export class WorkspaceContextPanelComponent {
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 =
@@ -131,7 +259,7 @@ export class WorkspaceContextPanelComponent {
itemCounts: this.xtreamStore.getCategoryItemCounts(),
},
width: '500px',
maxHeight: '80vh',
maxHeight: '90vh',
}
);
@@ -148,6 +276,10 @@ export class WorkspaceContextPanelComponent {
}
onXtreamCategoryClicked(category: XtreamCategoryLike): void {
if (!this.isXtreamCategoryInteractionEnabled()) {
return;
}
const context = this.context();
const section = this.section();
const rawCategoryId = category.category_id ?? category.id;
@@ -207,4 +339,37 @@ export class WorkspaceContextPanelComponent {
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 '';
}
}
}
@@ -77,7 +77,7 @@
<span class="header-actions-divider" aria-hidden="true"></span>
<button
type="button"
class="header-shortcut header-shortcut--global"
class="header-shortcut"
mat-icon-button
[class.is-active]="isGlobalFavoritesActive()"
(click)="onGlobalFavoritesRequested()"
@@ -103,17 +103,29 @@
</button>
}
@if (isElectron()) {
<button
type="button"
class="header-shortcut"
mat-icon-button
[class.is-active]="isDownloadsView()"
(click)="onDownloadsRequested()"
[matTooltip]="'WORKSPACE.SHELL.RAIL_DOWNLOADS' | translate"
[attr.aria-label]="'WORKSPACE.SHELL.OPEN_DOWNLOADS' | translate"
>
<mat-icon>download</mat-icon>
</button>
<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>
@@ -169,21 +169,51 @@
}
}
.header-shortcut--global {
// ─── Download Activity Indicator ─────────────────────────────────────────────
.download-btn-wrap {
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
}
&::before {
content: '';
position: absolute;
bottom: 5px;
right: 5px;
width: 7px;
height: 7px;
border-radius: 50%;
background: var(--mat-sys-tertiary);
border: 1.5px solid
var(--app-header-bg, var(--mat-sys-surface-container-low));
z-index: 1;
.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%;
}
}
@@ -40,6 +40,7 @@ export class WorkspaceShellHeaderComponent {
readonly isRefreshingPlaylist = input(false);
readonly isElectron = input(false);
readonly isDownloadsView = input(false);
readonly hasActiveDownloads = input(false);
readonly searchChanged = output<string>();
readonly searchSubmitted = output<string>();
@@ -34,7 +34,7 @@ import {
WorkspaceSearchCapability,
WorkspaceStartupPreferencesService,
} from '@iptvnator/workspace/shell/util';
import { PlaylistsService, SettingsStore } from 'services';
import { DownloadsService, PlaylistsService, SettingsStore } from 'services';
import { PlaylistActions, selectAllPlaylistsMeta } from 'm3u-state';
import { PlaylistMeta } from 'shared-interfaces';
import {
@@ -61,6 +61,10 @@ interface WorkspaceCommandAvailability {
dashboardXtreamContext: WorkspacePortalContext | null;
hasXtreamPlaylists: boolean;
isElectron: boolean;
canRefreshPlaylist: boolean;
hasActivePlaylist: boolean;
isXtreamPlaylist: boolean;
isPortalPlaylist: boolean;
}
interface WorkspaceCommandDefinition {
@@ -144,6 +148,77 @@ const WORKSPACE_COMMAND_DEFINITIONS: readonly WorkspaceCommandDefinition[] = [
scope: 'global',
isEnabled: () => true,
},
{
id: 'open-settings',
labelKey: 'WORKSPACE.SHELL.COMMANDS.OPEN_SETTINGS_LABEL',
descriptionKey: 'WORKSPACE.SHELL.COMMANDS.OPEN_SETTINGS_DESCRIPTION',
scope: 'global',
isEnabled: () => true,
},
{
id: 'open-sources',
labelKey: 'WORKSPACE.SHELL.COMMANDS.OPEN_SOURCES_LABEL',
descriptionKey: 'WORKSPACE.SHELL.COMMANDS.OPEN_SOURCES_DESCRIPTION',
scope: 'global',
isEnabled: () => true,
},
{
id: 'open-dashboard',
labelKey: 'WORKSPACE.SHELL.COMMANDS.OPEN_DASHBOARD_LABEL',
descriptionKey: 'WORKSPACE.SHELL.COMMANDS.OPEN_DASHBOARD_DESCRIPTION',
scope: 'global',
isEnabled: () => true,
},
{
id: 'add-playlist',
labelKey: 'WORKSPACE.SHELL.COMMANDS.ADD_PLAYLIST_LABEL',
descriptionKey: 'WORKSPACE.SHELL.COMMANDS.ADD_PLAYLIST_DESCRIPTION',
scope: 'global',
isEnabled: () => true,
},
{
id: 'refresh-playlist',
labelKey: 'WORKSPACE.SHELL.COMMANDS.REFRESH_PLAYLIST_LABEL',
descriptionKey:
'WORKSPACE.SHELL.COMMANDS.REFRESH_PLAYLIST_DESCRIPTION',
scope: 'playlist',
isEnabled: (state) => state.canRefreshPlaylist,
},
{
id: 'playlist-info',
labelKey: 'WORKSPACE.SHELL.COMMANDS.PLAYLIST_INFO_LABEL',
descriptionKey: 'WORKSPACE.SHELL.COMMANDS.PLAYLIST_INFO_DESCRIPTION',
scope: 'playlist',
isEnabled: (state) => state.hasActivePlaylist,
},
{
id: 'account-info',
labelKey: 'WORKSPACE.SHELL.COMMANDS.ACCOUNT_INFO_LABEL',
descriptionKey: 'WORKSPACE.SHELL.COMMANDS.ACCOUNT_INFO_DESCRIPTION',
scope: 'playlist',
isEnabled: (state) => state.isXtreamPlaylist,
},
{
id: 'go-to-vod',
labelKey: 'WORKSPACE.SHELL.COMMANDS.GO_TO_VOD_LABEL',
descriptionKey: 'WORKSPACE.SHELL.COMMANDS.GO_TO_VOD_DESCRIPTION',
scope: 'section',
isEnabled: (state) => state.isPortalPlaylist,
},
{
id: 'go-to-live',
labelKey: 'WORKSPACE.SHELL.COMMANDS.GO_TO_LIVE_LABEL',
descriptionKey: 'WORKSPACE.SHELL.COMMANDS.GO_TO_LIVE_DESCRIPTION',
scope: 'section',
isEnabled: (state) => state.isPortalPlaylist,
},
{
id: 'go-to-series',
labelKey: 'WORKSPACE.SHELL.COMMANDS.GO_TO_SERIES_LABEL',
descriptionKey: 'WORKSPACE.SHELL.COMMANDS.GO_TO_SERIES_DESCRIPTION',
scope: 'section',
isEnabled: (state) => state.isPortalPlaylist,
},
];
@Injectable()
@@ -167,6 +242,10 @@ export class WorkspaceShellFacade {
private readonly playlistRefreshAction = inject(
PlaylistRefreshActionService
);
private readonly downloadsService = inject(DownloadsService);
readonly hasActiveDownloads = computed(
() => this.isElectron && this.downloadsService.activeCount() > 0
);
private readonly languageTick = toSignal(
this.translate.onLangChange.pipe(startWith(null)),
{ initialValue: null }
@@ -916,13 +995,20 @@ export class WorkspaceShellFacade {
}
private getCommandPaletteItems(): WorkspaceCommandItem[] {
const context = this.currentContext();
const provider = context?.provider;
const availability: WorkspaceCommandAvailability = {
context: this.currentContext(),
context,
dashboardXtreamContext: this.dashboardXtreamContext(),
hasXtreamPlaylists: this.playlists().some(
(playlist) => !!playlist.serverUrl
),
isElectron: this.isElectron,
canRefreshPlaylist: this.canRefreshPlaylist(),
hasActivePlaylist: !!this.activePlaylist(),
isXtreamPlaylist: provider === 'xtreams',
isPortalPlaylist:
provider === 'xtreams' || provider === 'stalker',
};
return WORKSPACE_COMMAND_DEFINITIONS.map((definition) => ({
@@ -961,7 +1047,76 @@ export class WorkspaceShellFacade {
if (selection.commandId === 'open-global-recent') {
this.openGlobalRecent();
return;
}
if (selection.commandId === 'open-settings') {
void this.router.navigate(['/workspace', 'settings']);
return;
}
if (selection.commandId === 'open-sources') {
void this.router.navigate(['/workspace', 'sources']);
return;
}
if (selection.commandId === 'open-dashboard') {
void this.router.navigate(['/workspace', 'dashboard']);
return;
}
if (selection.commandId === 'add-playlist') {
this.openAddPlaylistDialog();
return;
}
if (selection.commandId === 'refresh-playlist') {
this.refreshCurrentPlaylist();
return;
}
if (selection.commandId === 'playlist-info') {
this.openPlaylistInfo();
return;
}
if (selection.commandId === 'account-info') {
this.openAccountInfo();
return;
}
this.navigateToPortalSection(selection.commandId);
}
private navigateToPortalSection(commandId: WorkspaceCommandId): void {
const context = this.currentContext();
if (!context) {
return;
}
const isStalker = context.provider === 'stalker';
let section: string;
switch (commandId) {
case 'go-to-vod':
section = 'vod';
break;
case 'go-to-live':
section = isStalker ? 'itv' : 'live';
break;
case 'go-to-series':
section = 'series';
break;
default:
return;
}
void this.router.navigate([
'/workspace',
context.provider,
context.playlistId,
section,
]);
}
private openPlaylistSearchFromPalette(query: string): void {
@@ -98,6 +98,7 @@
[isRefreshingPlaylist]="facade.isRefreshingPlaylist()"
[isElectron]="facade.isElectron"
[isDownloadsView]="facade.isDownloadsView()"
[hasActiveDownloads]="facade.hasActiveDownloads()"
(refreshPlaylistRequested)="facade.refreshCurrentPlaylist()"
(searchChanged)="facade.onSearchInput($event)"
(searchSubmitted)="facade.onSearchEnter($event)"