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

@@ -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',
},
];