Merge remote-tracking branch 'origin/master' into claude/iptvnator-vod-hero-redesign-2c8950

# Conflicts:
#	apps/electron-backend-e2e/src/detail-back-lane.e2e.ts
#	apps/web-e2e/src/m3u-movie-details.e2e.ts
#	docs/architecture/iptvnator-ui-guidelines.md
#	libs/ui/components/src/lib/content-hero/content-hero.component.scss
#	libs/ui/components/src/lib/portal-detail-shell/portal-detail-shell.component.scss
This commit is contained in:
4gray committed 2026-10-03 11:25:57 +02:00
commit 87d7e1d46c
122 files changed
+2080 -1040

No files matched your search

@@ -33,7 +33,11 @@
}
</header>
<div class="rail__viewport">
<div
#viewport
class="rail__viewport"
[attr.data-test-id]="testId() ? testId() + '-viewport' : null"
>
<button
type="button"
class="rail__chev rail__chev--left"
@@ -49,7 +53,10 @@
#track
class="rail__track"
[attr.data-layout]="layout()"
[attr.data-test-id]="testId() ? testId() + '-track' : null"
(scroll)="onScroll()"
(focusin)="onTrackFocusIn($event)"
(pointerdown)="onTrackPointerDown($event)"
role="list"
>
@for (card of items(); track card.id) {
@@ -67,6 +74,9 @@
<a
class="rail__channel-link"
[routerLink]="card.link"
[attr.data-test-id]="
testId() ? testId() + '-card-link' : null
"
[queryParams]="card.queryParams ?? null"
[state]="card.state"
>
@@ -153,6 +163,9 @@
<a
class="rail__card-link"
[routerLink]="card.link"
[attr.data-test-id]="
testId() ? testId() + '-card-link' : null
"
[queryParams]="card.queryParams ?? null"
[state]="card.state"
>
@@ -439,4 +439,264 @@ describe('DashboardRailComponent', () => {
).toHaveLength(1);
});
});
describe('focus reveal', () => {
beforeEach(() => {
(
globalThis as unknown as { ResizeObserver: unknown }
).ResizeObserver = class {
observe = jest.fn();
unobserve = jest.fn();
disconnect = jest.fn();
};
});
const rect = (left: number, width: number) =>
({ left, right: left + width, top: 0, bottom: 100 }) as DOMRect;
/**
* Lays the cards out at `stride` px intervals in a 1000px viewport
* (jsdom has no layout) and records the track's `scrollTo` calls.
*/
const renderRail = async (options: {
count: number;
width: number;
stride: number;
scrollLeft?: number;
}) => {
await TestBed.configureTestingModule({
imports: [DashboardRailComponent, TranslateModule.forRoot()],
providers: [
provideRouter([]),
{
provide: SettingsStore,
useValue: { stripCountryPrefix: signal(false) },
},
],
}).compileComponents();
const fixture = TestBed.createComponent(DashboardRailComponent);
fixture.componentRef.setInput('label', 'Sources');
fixture.componentRef.setInput(
'items',
Array.from({ length: options.count }, (_, index) =>
card({
id: `card-${index}`,
actions: [
{ id: 'edit', labelKey: 'EDIT', icon: 'edit' },
],
})
)
);
fixture.detectChanges();
await fixture.whenStable();
const element = fixture.nativeElement as HTMLElement;
const scrollLeft = options.scrollLeft ?? 0;
const track = element.querySelector('.rail__track') as HTMLElement;
const contentWidth =
(options.count - 1) * options.stride + options.width;
Object.defineProperties(track, {
scrollLeft: { configurable: true, value: scrollLeft },
clientWidth: { configurable: true, value: 1000 },
scrollWidth: { configurable: true, value: contentWidth },
});
const scrollTo = jest.fn();
track.scrollTo = scrollTo;
jest.spyOn(
element.querySelector('.rail__viewport') as HTMLElement,
'getBoundingClientRect'
).mockReturnValue(rect(0, 1000));
element
.querySelectorAll<HTMLElement>('.rail__card')
.forEach((cardElement, index) =>
jest
.spyOn(cardElement, 'getBoundingClientRect')
.mockReturnValue(
rect(
index * options.stride - scrollLeft,
options.width
)
)
);
const focusLink = (index: number) =>
element
.querySelectorAll<HTMLElement>('.rail__card-link')
[index].focus();
return { element, scrollTo, focusLink };
};
it('scrolls to the end when the last card is cut off by an overflow smaller than a card', async () => {
// Max scroll 102px: the last card shows 70px, which Chromium
// already treats as visible enough to skip its focus scroll.
const { scrollTo, focusLink } = await renderRail({
count: 6,
width: 172,
stride: 186,
});
focusLink(5);
expect(scrollTo).toHaveBeenCalledWith({
left: 102,
behavior: 'auto',
});
});
it('moves to the next snap position that reveals the card rather than the nearest one', async () => {
// A "nearest" scroll would need 264px, which mandatory snapping
// rounds back to 0 — leaving the card cut off.
const { scrollTo, focusLink } = await renderRail({
count: 5,
width: 306,
stride: 316,
});
focusLink(3);
expect(scrollTo).toHaveBeenCalledWith({
left: 316,
behavior: 'auto',
});
});
it('keeps a card wider than the visible area at its own start instead of skipping past it', async () => {
// The 1000px viewport cannot hold a 1200px card, so the next
// card's snap point (2420) would move the focused one offscreen.
const { scrollTo, focusLink } = await renderRail({
count: 3,
width: 1200,
stride: 1210,
scrollLeft: 1210,
});
focusLink(1);
expect(scrollTo).toHaveBeenCalledWith({
left: 1210,
behavior: 'auto',
});
});
it('aligns a card cut off on the left with the start edge', async () => {
const { scrollTo, focusLink } = await renderRail({
count: 6,
width: 172,
stride: 186,
scrollLeft: 100,
});
focusLink(0);
expect(scrollTo).toHaveBeenCalledWith({
left: 0,
behavior: 'auto',
});
});
it('reveals the card when its actions button gains focus', async () => {
const { element, scrollTo } = await renderRail({
count: 6,
width: 172,
stride: 186,
});
element
.querySelectorAll<HTMLElement>('.rail__action-trigger')[5]
.focus();
expect(scrollTo).toHaveBeenCalledWith({
left: 102,
behavior: 'auto',
});
});
/**
* Presses a card link: a pointerdown (jsdom has no PointerEvent),
* followed for a mouse by its mousedown. A tap's compatibility
* mousedown only comes once the finger lifts.
*/
const pressCard = (
element: HTMLElement,
index: number,
pointerType: 'mouse' | 'touch'
) => {
const link = element.querySelectorAll('.rail__card-link')[index];
const press = new MouseEvent('pointerdown', { bubbles: true });
Object.defineProperty(press, 'pointerType', { value: pointerType });
link.dispatchEvent(press);
if (pointerType === 'mouse') {
link.dispatchEvent(
new MouseEvent('mousedown', {
bubbles: true,
buttons: 1,
detail: 1,
})
);
}
};
// Waits out a press's focus window. The rail's first-render reset to
// the start lands meanwhile, so its scrollTo call is forgotten.
const wait = async (ms: number, scrollTo: jest.Mock) => {
await new Promise((resolve) => setTimeout(resolve, ms));
scrollTo.mockClear();
};
it('keeps the rail still when a mouse press focuses a partly hidden card', async () => {
// Scrolling on mousedown would move the card from under the
// pointer, so the click would land elsewhere.
const { element, scrollTo, focusLink } = await renderRail({
count: 6,
width: 172,
stride: 186,
});
pressCard(element, 5, 'mouse');
focusLink(5);
expect(scrollTo).not.toHaveBeenCalled();
});
it('keeps the rail still when a tap focuses the card after the finger lifts', async () => {
const { element, scrollTo, focusLink } = await renderRail({
count: 6,
width: 172,
stride: 186,
});
pressCard(element, 5, 'touch');
await wait(150, scrollTo);
focusLink(5);
expect(scrollTo).not.toHaveBeenCalled();
});
it('still reveals a card focused from script after an earlier mouse press', async () => {
const { element, scrollTo, focusLink } = await renderRail({
count: 6,
width: 172,
stride: 186,
});
pressCard(element, 0, 'mouse');
await wait(150, scrollTo);
focusLink(5);
expect(scrollTo).toHaveBeenCalledWith({
left: 102,
behavior: 'auto',
});
});
it('leaves the scroll position alone for a fully visible card', async () => {
const { scrollTo, focusLink } = await renderRail({
count: 6,
width: 172,
stride: 186,
});
focusLink(4);
expect(scrollTo).not.toHaveBeenCalled();
});
});
});
@@ -156,6 +156,8 @@ export class DashboardRailComponent implements AfterViewInit, OnDestroy {
*/
readonly totalCount = input<number | null>(null);
private readonly viewport =
viewChild.required<ElementRef<HTMLDivElement>>('viewport');
private readonly track =
viewChild.required<ElementRef<HTMLDivElement>>('track');
private readonly cardElements =
@@ -171,6 +173,8 @@ export class DashboardRailComponent implements AfterViewInit, OnDestroy {
private readonly visibleCardIds = new Set<string>();
private lastVisibleSignature: string | null = null;
private resetFrameId: number | null = null;
/** Last mouse, pen or touch press inside the track. */
private pointerPress: { timeStamp: number; touch: boolean } | null = null;
private settleFrameId: number | null = null;
/**
@@ -281,6 +285,86 @@ export class DashboardRailComponent implements AfterViewInit, OnDestroy {
this.updateScrollState();
}
/**
* Brings a card that receives keyboard or programmatic focus (Tab or
* `focus()`) fully into view; focus caused by a mouse or touch press
* leaves the rail where it is.
* Chromium skips its own focus scroll when 32px or more of the element
* already shows, which left a card partly hidden under the edge fade
* when the rail overflows by less than a card. A plain "nearest" scroll
* is not enough either: mandatory snapping can round it back to where
* the card is still cut off. So the rail moves to the first card-start
* snap position that reveals the whole card. The visible area is the
* viewport, not the track, whose padding bleeds under the fades.
*/
onTrackFocusIn(event: FocusEvent): void {
if (this.isFocusFromPointerPress(event)) return;
const card =
event.target instanceof Element
? event.target.closest<HTMLElement>('.rail__card')
: null;
if (!card) return;
const track = this.track().nativeElement;
const visible = this.viewport().nativeElement.getBoundingClientRect();
const rect = card.getBoundingClientRect();
const hiddenLeft = rect.left < visible.left - 1;
if (!hiddenLeft && rect.right <= visible.right + 1) return;
// Scroll offset that aligns an element's start with the visible
// edge — where `scroll-padding-inline-start` makes each card snap.
const snapOffset = (element: HTMLElement) =>
track.scrollLeft +
element.getBoundingClientRect().left -
visible.left;
const maxLeft = track.scrollWidth - track.clientWidth;
let left = maxLeft;
// A card wider than the visible area (a narrow window, or zoom) can
// never fit: show its start rather than a later card's snap point,
// which would move it offscreen.
if (
hiddenLeft ||
rect.right - rect.left > visible.right - visible.left
) {
left = snapOffset(card);
} else {
const needed = track.scrollLeft + rect.right - visible.right;
for (const { nativeElement } of this.cardElements()) {
const offset = snapOffset(nativeElement);
if (offset >= needed - 1) {
left = offset;
break;
}
}
}
track.scrollTo({
left: Math.max(0, Math.min(left, maxLeft)),
behavior: 'auto',
});
}
onTrackPointerDown(event: PointerEvent): void {
this.pointerPress = {
timeStamp: event.timeStamp,
touch: event.pointerType === 'touch',
};
}
/**
* A press focuses the card it lands on too, but scrolling then would
* slide the card from under the pointer and lose the click. A mouse or
* pen focuses on the mousedown right after the pointerdown; a tap only
* with the compatibility mouse events once the finger lifts, so it gets
* the CDK FocusMonitor's 650ms touch buffer. Focus later than that, such
* as `focus()` after a click elsewhere, is not the press's. Only
* timestamps are compared: nothing in the DOM changes before the click.
*/
private isFocusFromPointerPress(event: FocusEvent): boolean {
const press = this.pointerPress;
if (!press) return false;
const elapsed = event.timeStamp - press.timeStamp;
return elapsed >= 0 && elapsed <= (press.touch ? 650 : 100);
}
scrollBy(direction: 1 | -1): void {
const el = this.track().nativeElement;
el.scrollBy({
@@ -9,6 +9,7 @@ import {
import { toSignal } from '@angular/core/rxjs-interop';
import { startWith } from 'rxjs';
import {
getPlaylistSourceIcon,
isStalkerAccountPlaylist,
isXtreamAccountPlaylist,
normalizeDashboardRailsSettings,
@@ -286,11 +287,7 @@ export class WorkspaceDashboardRailsComponent {
playlist.filename ||
this.t('WORKSPACE.DASHBOARD.UNTITLED_SOURCE'),
subtitle: this.data.getPlaylistProvider(playlist),
icon: playlist.serverUrl
? 'cloud'
: playlist.macAddress
? 'cast'
: 'folder_open',
icon: getPlaylistSourceIcon(playlist),
link: this.data.getPlaylistLink(playlist),
actions: buildDashboardSourceActions(
playlist,
@@ -675,6 +672,8 @@ export class WorkspaceDashboardRailsComponent {
message: this.translate.instant(
'HOME.PLAYLISTS.REMOVE_DIALOG.MESSAGE'
),
confirmLabel: this.translate.instant('HOME.PLAYLISTS.REMOVE'),
tone: 'destructive',
onConfirm: () => {
void this.removePlaylist(playlist);
},
@@ -1,8 +1,31 @@
<header class="workspace-header" [class.is-settings-route]="isSettingsRoute()">
<!-- The page's Back, at the leading edge where desktop apps and Material's
top app bar keep navigation (beside the macOS traffic lights). The
header never scrolls, so the control stays put over long pages. -->
@if (backTarget(); as back) {
@let backLabel = back.label() || ('BACK' | translate);
<button
type="button"
class="header-back"
mat-icon-button
data-test-id="workspace-header-back"
(click)="backRequested.emit()"
(keydown.escape)="onBackEscape($event)"
[matTooltip]="
back.escapeShortcut() ? backLabel + ' (Esc)' : backLabel
"
[attr.aria-label]="backLabel"
[attr.aria-keyshortcuts]="back.escapeShortcut() ? 'Escape' : null"
>
<mat-icon>arrow_back</mat-icon>
</button>
}
<!-- Phone-only (≤640px, CSS-hidden above): on phones the context panel
is an off-canvas drawer, and this button is the only way to open
it, so it must never be dropped while the route has a panel. -->
@if (showContextDrawerToggle()) {
it. A page with Back takes its slot (one navigation icon, as in
Material's top app bar); the list Back returns to shows it again. -->
@if (showContextDrawerToggle() && !backTarget()) {
<button
#contextDrawerToggle
type="button"
@@ -20,6 +20,14 @@
app-region: no-drag;
}
// Pulled toward the edge so the arrow glyph, not the button's padding, sits
// on the header's inset.
.header-back {
flex-shrink: 0;
margin-inline-start: -8px;
app-region: no-drag;
}
// Above the phone breakpoint the context panel is always visible beside the
// content, so its drawer toggle would open nothing — it exists only at ≤640px.
.context-drawer-toggle {
@@ -4,6 +4,7 @@ import {
Component,
input,
output,
signal,
ChangeDetectionStrategy,
} from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
@@ -14,6 +15,7 @@ import { MatTooltip } from '@angular/material/tooltip';
import { TranslateService } from '@ngx-translate/core';
import { of } from 'rxjs';
import { TranslatePipe } from '@ngx-translate/core';
import { WorkspaceBackTarget } from '@iptvnator/portal/shared/util';
import { WorkspaceShellHeaderComponent } from './workspace-shell-header.component';
@Component({
@@ -376,6 +378,109 @@ describe('WorkspaceShellHeaderComponent', () => {
);
});
describe('page Back', () => {
const backButton = (): HTMLButtonElement | null =>
fixture.nativeElement.querySelector(
'[data-test-id="workspace-header-back"]'
);
function setBackTarget(
label: string | null,
escapeShortcut: boolean
): WorkspaceBackTarget {
const target: WorkspaceBackTarget = {
label: signal(label),
escapeShortcut: signal(escapeShortcut),
run: jest.fn(),
};
fixture.componentRef.setInput('backTarget', target);
fixture.detectChanges();
return target;
}
it('renders nothing while the page has no Back', () => {
expect(backButton()).toBeNull();
});
it('leads the header and emits Back requests', () => {
const requested = jest.fn();
component.backRequested.subscribe(requested);
setBackTarget('Back to Downloads', true);
const button = backButton();
expect(
fixture.nativeElement.querySelector('header')?.firstElementChild
).toBe(button);
expect(button?.type).toBe('button');
button?.click();
expect(requested).toHaveBeenCalledTimes(1);
});
it('names the page Back and announces Escape only while it applies', () => {
setBackTarget('Back to Downloads', true);
expect(backButton()?.getAttribute('aria-label')).toBe(
'Back to Downloads'
);
expect(backButton()?.getAttribute('aria-keyshortcuts')).toBe(
'Escape'
);
setBackTarget(null, false);
// No host label: the generic translated Back.
expect(backButton()?.getAttribute('aria-label')).toBe('BACK');
expect(backButton()?.hasAttribute('aria-keyshortcuts')).toBe(false);
});
it('runs the advertised Escape from the focused Back only while it applies', () => {
const requested = jest.fn();
component.backRequested.subscribe(requested);
const press = (init: KeyboardEventInit = {}) => {
const event = new KeyboardEvent('keydown', {
key: 'Escape',
bubbles: true,
cancelable: true,
...init,
});
backButton()?.dispatchEvent(event);
return event;
};
setBackTarget(null, true);
press({ repeat: true });
press({ shiftKey: true });
expect(requested).not.toHaveBeenCalled();
expect(press().defaultPrevented).toBe(true);
expect(requested).toHaveBeenCalledTimes(1);
// In watch, Escape belongs to the player's close shortcut.
setBackTarget(null, false);
expect(press().defaultPrevented).toBe(false);
expect(requested).toHaveBeenCalledTimes(1);
});
it('takes the drawer toggle slot while the page offers Back', () => {
fixture.componentRef.setInput('showContextDrawerToggle', true);
setBackTarget(null, true);
expect(
fixture.nativeElement.querySelector(
'[data-test-id="context-drawer-toggle"]'
)
).toBeNull();
fixture.componentRef.setInput('backTarget', null);
fixture.detectChanges();
expect(backButton()).toBeNull();
expect(
fixture.nativeElement.querySelector(
'[data-test-id="context-drawer-toggle"]'
)
).not.toBeNull();
});
});
it('uses the paired Material primary tokens for the download badge', () => {
const styleSource = readFileSync(
join(__dirname, 'workspace-shell-header.component.scss'),
@@ -12,7 +12,10 @@ import { MatIcon } from '@angular/material/icon';
import { MatTooltip } from '@angular/material/tooltip';
import { TranslatePipe } from '@ngx-translate/core';
import { PlaylistSwitcherComponent } from '@iptvnator/playlist/shared/ui';
import { WorkspaceHeaderAction } from '@iptvnator/portal/shared/util';
import {
WorkspaceBackTarget,
WorkspaceHeaderAction,
} from '@iptvnator/portal/shared/util';
import { PlaylistMeta } from '@iptvnator/shared/interfaces';
import {
WorkspaceHeaderBulkAction,
@@ -46,6 +49,8 @@ export class WorkspaceShellHeaderComponent {
/Mac|iPhone|iPad|iPod/i.test(navigator.platform);
readonly commandShortcutLabel = this.isMac ? '⌘K' : 'Ctrl+K';
/** The current page's Back action; null when the page has none. */
readonly backTarget = input<WorkspaceBackTarget | null>(null);
readonly playlistTitle = input('');
readonly playlistSubtitle = input('');
readonly canOpenPlaylistInfo = input(false);
@@ -105,6 +110,7 @@ export class WorkspaceShellHeaderComponent {
'WORKSPACE.SHELL.CONTEXT_DRAWER_CATEGORIES_TOOLTIP'
);
readonly backRequested = output<void>();
readonly searchChanged = output<string>();
readonly searchSubmitted = output<string>();
readonly commandPaletteRequested = output<void>();
@@ -148,6 +154,27 @@ export class WorkspaceShellHeaderComponent {
this.searchSubmitted.emit(target?.value ?? this.searchQuery());
}
/**
* The tooltip advertises Escape for Back. The page only handles it with
* focus inside the page, so the button keeps that promise itself.
*/
onBackEscape(event: Event): void {
const keyboard = event as KeyboardEvent;
if (
!this.backTarget()?.escapeShortcut() ||
keyboard.defaultPrevented ||
keyboard.repeat ||
keyboard.altKey ||
keyboard.ctrlKey ||
keyboard.metaKey ||
keyboard.shiftKey
) {
return;
}
keyboard.preventDefault();
this.backRequested.emit();
}
onPlaylistInfoRequested(): void {
this.playlistInfoRequested.emit();
}
@@ -1,17 +1,6 @@
<!-- No brand mark: it linked to the page the first workspace link opens.
The page's Back lives in the header's leading slot. -->
<aside class="app-rail" [class.is-macos]="isMacOS()">
<a
class="brand"
[routerLink]="brandLink()"
[matTooltip]="brandTooltipKey() | translate"
[matTooltipPosition]="'right'"
[attr.aria-label]="brandAriaLabelKey() | translate"
>
<img
src="./assets/icons/icon-tv-256.png"
[attr.alt]="'WORKSPACE.SHELL.BRAND_ALT' | translate"
/>
</a>
<app-workspace-shell-rail-links [links]="workspaceLinks()" />
@if (primaryContextLinks().length > 0) {
@@ -98,22 +98,6 @@
}
}
.brand {
margin-bottom: 10px;
width: 40px;
height: 40px;
border-radius: 12px;
display: inline-flex;
align-items: center;
justify-content: center;
}
.brand img {
width: 24px;
height: 24px;
object-fit: contain;
}
.rail-context-region {
--region-accent: var(--mat-sys-outline-variant);
display: flex;
@@ -239,12 +223,6 @@
padding-top: 6px;
}
// The brand links to the dashboard, which the first workspace link
// already does. On a bar this narrow that duplicate costs a section.
.brand {
display: none;
}
a {
flex: 0 0 auto;
}
@@ -61,11 +61,6 @@ describe('WorkspaceShellRailComponent', () => {
});
it('renders provider context region and active settings shortcut state', () => {
fixture.componentRef.setInput('brandLink', '/workspace/sources');
fixture.componentRef.setInput(
'brandAriaLabelKey',
'WORKSPACE.SHELL.OPEN_SOURCES'
);
fixture.componentRef.setInput('primaryContextLinks', [
{
icon: 'movie',
@@ -87,13 +82,15 @@ describe('WorkspaceShellRailComponent', () => {
expect(
fixture.nativeElement.querySelector('.rail-shortcut.is-active')
).not.toBeNull();
});
it('has no brand mark duplicating the first workspace link', () => {
fixture.detectChanges();
expect(fixture.nativeElement.querySelector('img')).toBeNull();
expect(
fixture.nativeElement.querySelector('.brand')?.getAttribute('href')
).toContain('/workspace/sources');
expect(
fixture.nativeElement
.querySelector('.brand')
?.getAttribute('aria-label')
).toBe('WORKSPACE.SHELL.OPEN_SOURCES');
fixture.nativeElement.querySelector('.app-rail')?.firstElementChild
?.tagName
).toBe('APP-WORKSPACE-SHELL-RAIL-LINKS');
});
});
@@ -1,8 +1,4 @@
import {
ChangeDetectionStrategy,
Component,
input,
} from '@angular/core';
import { ChangeDetectionStrategy, Component, input } from '@angular/core';
import { MatIcon } from '@angular/material/icon';
import { MatTooltip } from '@angular/material/tooltip';
import { RouterLink } from '@angular/router';
@@ -28,9 +24,6 @@ import { WorkspaceShellRailLinksComponent } from '../workspace-shell-rail-links/
})
export class WorkspaceShellRailComponent {
readonly isMacOS = input(false);
readonly brandLink = input('/workspace/dashboard');
readonly brandTooltipKey = input('WORKSPACE.SHELL.RAIL_DASHBOARD');
readonly brandAriaLabelKey = input('WORKSPACE.SHELL.OPEN_DASHBOARD');
readonly workspaceLinks = input<PortalRailLink[]>([]);
readonly primaryContextLinks = input<PortalRailLink[]>([]);
readonly secondaryContextLinks = input<PortalRailLink[]>([]);
@@ -8,6 +8,7 @@ import {
WorkspacePortalContext,
WorkspaceShellRoute,
} from '@iptvnator/workspace/shell/util';
import { SOURCE_TYPE_ICONS } from '@iptvnator/shared/interfaces';
import { TranslateFn } from './workspace-shell-search-labels';
export interface CommandBuilderActions {
@@ -275,7 +276,7 @@ export function getGlobalCommandDefinitions(
{
id: 'add-playlist-m3u',
group: 'global',
icon: 'folder_open',
icon: SOURCE_TYPE_ICONS.m3u,
labelKey: 'WORKSPACE.SHELL.COMMANDS.ADD_PLAYLIST_M3U_LABEL',
descriptionKey:
'WORKSPACE.SHELL.COMMANDS.ADD_PLAYLIST_M3U_DESCRIPTION',
@@ -286,7 +287,7 @@ export function getGlobalCommandDefinitions(
{
id: 'add-playlist-xtream',
group: 'global',
icon: 'cloud',
icon: SOURCE_TYPE_ICONS.xtream,
labelKey: 'WORKSPACE.SHELL.COMMANDS.ADD_PLAYLIST_XTREAM_LABEL',
descriptionKey:
'WORKSPACE.SHELL.COMMANDS.ADD_PLAYLIST_XTREAM_DESCRIPTION',
@@ -297,7 +298,7 @@ export function getGlobalCommandDefinitions(
{
id: 'add-playlist-stalker',
group: 'global',
icon: 'cast',
icon: SOURCE_TYPE_ICONS.stalker,
labelKey: 'WORKSPACE.SHELL.COMMANDS.ADD_PLAYLIST_STALKER_LABEL',
descriptionKey:
'WORKSPACE.SHELL.COMMANDS.ADD_PLAYLIST_STALKER_DESCRIPTION',
@@ -55,21 +55,6 @@ export class WorkspaceShellRouteStateService {
readonly showDashboard = computed(() =>
this.startupPreferences.showDashboard()
);
readonly brandLink = computed(() =>
this.startupPreferences.getFirstAvailableWorkspacePath(
this.showDashboard()
)
);
readonly brandTooltipKey = computed(() =>
this.showDashboard()
? 'WORKSPACE.SHELL.RAIL_DASHBOARD'
: 'WORKSPACE.SHELL.RAIL_SOURCES'
);
readonly brandAriaLabelKey = computed(() =>
this.showDashboard()
? 'WORKSPACE.SHELL.OPEN_DASHBOARD'
: 'WORKSPACE.SHELL.OPEN_SOURCES'
);
readonly workspaceLinks = computed<PortalRailLink[]>(() => {
this.languageTick();
@@ -777,7 +777,6 @@ describe('WorkspaceShellFacade', () => {
exact: true,
},
]);
expect(facade.brandLink()).toBe('/workspace/sources');
});
it('hides the Electron-only global search rail link in the web runtime', () => {
@@ -3,6 +3,7 @@ import { toSignal } from '@angular/core/rxjs-interop';
import { Router } from '@angular/router';
import { TranslateService } from '@ngx-translate/core';
import { startWith } from 'rxjs';
import { WorkspaceBackNavigationService } from '@iptvnator/portal/shared/data-access';
import {
PORTAL_EXTERNAL_PLAYBACK,
WorkspaceHeaderContextService,
@@ -47,6 +48,7 @@ export class WorkspaceShellFacade {
private readonly header = inject(WorkspaceShellHeaderService);
private readonly xtreamImport = inject(WorkspaceShellXtreamImportService);
readonly headerContext = inject(WorkspaceHeaderContextService);
private readonly backNavigation = inject(WorkspaceBackNavigationService);
private readonly languageTick = toSignal(
this.translate.onLangChange.pipe(startWith(null)),
@@ -84,9 +86,7 @@ export class WorkspaceShellFacade {
readonly currentUrl = this.routeState.currentUrl;
readonly currentRoute = this.routeState.currentRoute;
readonly showDashboard = this.routeState.showDashboard;
readonly brandLink = this.routeState.brandLink;
readonly brandTooltipKey = this.routeState.brandTooltipKey;
readonly brandAriaLabelKey = this.routeState.brandAriaLabelKey;
readonly backTarget = this.backNavigation.target;
readonly currentContext = this.routeState.currentContext;
readonly currentSection = this.routeState.currentSection;
readonly commandPaletteCommands = computed<WorkspaceResolvedCommandItem[]>(
@@ -218,6 +218,10 @@ export class WorkspaceShellFacade {
this.header.toggleLiveSidebar();
}
goBack(): void {
this.backNavigation.goBack();
}
navigateToGlobalFavorites(): void {
this.header.navigateToGlobalFavorites();
}
@@ -14,9 +14,6 @@
<app-workspace-shell-rail
[attr.inert]="contextDrawer.isOpen() ? '' : null"
[isMacOS]="facade.isMacOS"
[brandLink]="facade.brandLink()"
[brandTooltipKey]="facade.brandTooltipKey()"
[brandAriaLabelKey]="facade.brandAriaLabelKey()"
[workspaceLinks]="facade.workspaceLinks()"
[primaryContextLinks]="facade.primaryContextLinks()"
[secondaryContextLinks]="facade.secondaryContextLinks()"
@@ -28,6 +25,7 @@
<app-workspace-shell-header
#workspaceHeader
[attr.inert]="contextDrawer.isOpen() ? '' : null"
[backTarget]="facade.backTarget()"
[playlistTitle]="facade.playlistTitle()"
[playlistSubtitle]="facade.playlistSubtitle()"
[canOpenPlaylistInfo]="facade.canOpenPlaylistInfo()"
@@ -52,6 +50,7 @@
[isContextDrawerOpen]="contextDrawer.isOpen()"
[contextDrawerToggleAriaKey]="facade.contextDrawerLabelKeys().aria"
[contextDrawerTooltipKey]="facade.contextDrawerLabelKeys().tooltip"
(backRequested)="facade.goBack()"
(contextDrawerToggleRequested)="contextDrawer.toggle()"
(refreshPlaylistRequested)="facade.refreshCurrentPlaylist()"
(searchChanged)="facade.onSearchInput($event)"
@@ -33,9 +33,6 @@ import { WorkspaceKeyboardShortcutsService } from '../workspace-keyboard-shortcu
})
class MockWorkspaceShellRailComponent {
readonly isMacOS = input(false);
readonly brandLink = input('/workspace/dashboard');
readonly brandTooltipKey = input('WORKSPACE.SHELL.RAIL_DASHBOARD');
readonly brandAriaLabelKey = input('WORKSPACE.SHELL.OPEN_DASHBOARD');
readonly workspaceLinks = input<unknown[]>([]);
readonly primaryContextLinks = input<unknown[]>([]);
readonly secondaryContextLinks = input<unknown[]>([]);
@@ -51,6 +48,7 @@ class MockWorkspaceShellRailComponent {
standalone: true,
})
class MockWorkspaceShellHeaderComponent {
readonly backTarget = input<unknown>(null);
readonly playlistTitle = input('');
readonly playlistSubtitle = input('');
readonly canOpenPlaylistInfo = input(false);
@@ -75,6 +73,7 @@ class MockWorkspaceShellHeaderComponent {
readonly headerBulkAction = input<WorkspaceHeaderBulkAction | null>(null);
readonly headerSidebarToggle = input<unknown>(null);
readonly parentalLockState = input<'off' | 'locked' | 'unlocked'>('off');
readonly backRequested = output<void>();
readonly searchChanged = output<string>();
readonly searchSubmitted = output<string>();
readonly commandPaletteRequested = output<void>();
@@ -152,9 +151,8 @@ class MockWorkspaceKeyboardShortcutsService {
}
class MockWorkspaceShellFacade {
readonly brandLink = signal('/workspace/dashboard');
readonly brandTooltipKey = signal('WORKSPACE.SHELL.RAIL_DASHBOARD');
readonly brandAriaLabelKey = signal('WORKSPACE.SHELL.OPEN_DASHBOARD');
readonly backTarget = signal(null);
goBack = jest.fn();
readonly workspaceLinks = signal([]);
readonly primaryContextLinks = signal([]);
readonly secondaryContextLinks = signal([]);
@@ -14,6 +14,7 @@ import {
} from '@iptvnator/m3u-state';
import { TranslatePipe } from '@ngx-translate/core';
import { WorkspaceShellContextDrawerService } from '@iptvnator/workspace/shell/util';
import { SOURCE_TYPE_ICONS } from '@iptvnator/shared/interfaces';
type PlaylistFilterId = 'all' | 'm3u' | 'xtream' | 'stalker';
@@ -55,17 +56,17 @@ export class WorkspaceSourcesFiltersPanelComponent {
},
{
id: 'm3u',
icon: 'playlist_play',
icon: SOURCE_TYPE_ICONS.m3u,
translationKey: 'HOME.PLAYLIST_TYPES.M3U',
},
{
id: 'xtream',
icon: 'cloud',
icon: SOURCE_TYPE_ICONS.xtream,
translationKey: 'HOME.PLAYLIST_TYPES.XTREAM',
},
{
id: 'stalker',
icon: 'router',
icon: SOURCE_TYPE_ICONS.stalker,
translationKey: 'HOME.PLAYLIST_TYPES.STALKER',
},
];