mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-16 22:26:16 -08:00
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:
1 parent
c62e8859ea
commit
70b7f570ef
15 files changed
+1086
-260
No files matched your search
+53
-64
@@ -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">⌘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>
|
||||
+132
-130
@@ -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;
|
||||
}
|
||||
+34
-6
@@ -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';
|
||||
}
|
||||
|
||||
+22
-6
@@ -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"
|
||||
|
||||
+67
@@ -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%;
|
||||
|
||||
+13
-1
@@ -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);
|
||||
}
|
||||
}
|
||||
+90
-23
@@ -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))"
|
||||
/>
|
||||
|
||||
+84
@@ -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;
|
||||
|
||||
+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,
|
||||
]);
|
||||
});
|
||||
});
|
||||
+168
-3
@@ -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 '';
|
||||
}
|
||||
}
|
||||
}
|
||||
+24
-12
@@ -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>
|
||||
+43
-13
@@ -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%;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+1
@@ -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>();
|
||||
|
||||
+157
-2
@@ -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)"
|
||||
|
||||
Reference in new issue
Block a user