From 23a18601197c42848e11242178a4b075753144eb Mon Sep 17 00:00:00 2001 From: 4gray <4gray@users.noreply.github.com> Date: Fri, 2 Oct 2026 20:36:16 +0200 Subject: [PATCH 1/4] fix(ui): destructive confirmations, verb labels and provider icons (#1783) * fix(ui): destructive confirmations, verb labels and provider icons Confirmations: ConfirmDialogData.confirmLabel is required, so no dialog can fall back to "Yes"/"No"; the dismiss defaults to "Cancel" and `tone: 'destructive'` styles the confirm with .app-destructive-button. Every caller names its action ("Remove playlist", "Clear", "Refresh playlist", "Cancel download" with a "Close" dismiss). The confirm button has the confirm-dialog-confirm test id and drops its no-op color="primary". The no-op `warn` color input becomes .app-destructive-button on the EPG mapping, playlist item, error view, EPG/reset settings, delete-all and source cleanup buttons, and on the unsaved-changes dialog's Discard. Provider icons come from SOURCE_TYPE_ICONS in shared/interfaces (Xtream cloud, Stalker cast, M3U playlist_play / link / description / subject) in the add dialog, auto-import, empty state, playlist switcher, playlist rows, dashboard source rail, command palette, Sources filters and both reset summaries. Stalker no longer borrows the Dashboard icon, and Xtream no longer shares a glyph with M3U URL playlists. The playlist error view removed a playlist through the stale PlaylistActions.removePlaylist: it dropped the playlist from state before the delete ran, swallowed failures, skipped the source activity guard and showed no toast. It now uses PlaylistDeleteActionService like every other removal, commits only a completed delete, toasts and goes home. The unused action and its effect are removed. Co-Authored-By: Claude Opus 5.5 * fix(ui): one provider icon per playlist row, imperative Korean remove label A restored Stalker or Xtream playlist can also carry a URL, and the row's independent checks then showed the M3U URL icon next to the provider icon. The row now switches on resolvePlaylistSourceIconKey(), the precedence every other surface uses, so each source shows exactly one icon. HOME.PLAYLISTS.REMOVE now names the confirm button and the row's delete tooltip; in Korean it read "the playlist has been removed". It now says "remove playlist", like every other locale. Co-Authored-By: Claude Opus 5.5 * fix(ui): keep the auto-refresh badge on playlist rows with one provider icon Showing one provider icon per row moved the auto-refresh badge into the M3U branches only, so a restored Stalker playlist with a URL and auto-refresh lost it although the URL is still re-fetched. The row now renders one icon container: the provider icon from the shared precedence, then the badge for any row with a URL or a local M3U, exactly the rows that showed it before. The Xtream portal-status dot, used without source health, keeps that corner. Co-Authored-By: Claude Opus 5.5 * fix(ui): let the playlist row's cancel action render in the error color The row's action buttons set `color: inherit`, and the selected row does so again with more specific selectors. Both beat Material's token-driven icon color, so the .app-destructive-button cancel action kept the row color (selection blue on the active row). Pin the cancel button to --mat-sys-error in both row states. The large-deletion Electron E2E now checks the cancel color in both themes; without this rule it reads rgb(47, 123, 255) instead of the error red. Co-Authored-By: Claude Opus 5.5 * test(ui): give the dialog service spec the now-required confirm labels ConfirmDialogData.confirmLabel became required, and the spec still built confirmations without one. Jest only transpiles, so the suite stayed green, but the "Typecheck Jest spec programs" CI step rejected it. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- .changes/ui-destructive-confirmations.md | 6 + .codex/skills/iptvnator-theme-style/SKILL.md | 4 +- .../src/category-management.e2e.ts | 4 +- .../src/electron-test-fixtures.ts | 8 +- .../src/live-sidebar-collapse.e2e.ts | 5 +- .../xtream-scenario-driver-wiring.spec.ts | 3 +- .../xtream-scenario-driver.spec.ts | 15 +- .../src/performance/xtream-scenario-ui.ts | 5 +- .../electron-backend-e2e/src/providers.e2e.ts | 4 +- apps/electron-backend-e2e/src/recent.e2e.ts | 5 +- .../src/theme-tokens.e2e.ts | 52 ++++++ .../src/xtream-responsiveness.e2e.ts | 23 ++- ...delete-all-playlists-dialog.component.html | 6 +- ...s-delete-all-playlists-dialog.component.ts | 7 +- .../settings-epg-section.component.html | 2 +- .../src/app/settings/settings-epg.facade.ts | 2 + .../settings-reset-section.component.html | 17 +- .../settings-reset-section.component.ts | 2 + ...s-unsaved-changes-dialog.component.spec.ts | 10 ++ ...ttings-unsaved-changes-dialog.component.ts | 1 + apps/web/src/assets/i18n/ko.json | 2 +- apps/web/src/m3-theme.scss | 4 +- docs/architecture/iptvnator-ui-guidelines.md | 21 +++ libs/m3u-state/src/lib/actions.ts | 1 - libs/m3u-state/src/lib/effects.ts | 15 -- .../src/lib/reducers/playlist.reducers.ts | 1 - .../add-playlist-dialog.component.ts | 11 +- .../lib/auto-import/auto-import.component.ts | 9 +- .../playlist-switcher.component.ts | 14 +- .../empty-state/empty-state.component.ts | 7 +- .../playlist-item.component.html | 119 +++++++------ .../playlist-item.component.scss | 7 + .../playlist-item.component.spec.ts | 37 +++++ .../playlist-item/playlist-item.component.ts | 31 +++- .../recent-playlists.component.ts | 2 + .../source-cleanup-dialog.component.ts | 1 + .../ui/src/lib/xtream-refresh-flow.service.ts | 1 + .../lib/download-manager-actions.service.ts | 2 + .../lib/recording-manager-actions.service.ts | 1 + .../playlist-error-view.component.html | 6 +- .../playlist-error-view.component.spec.ts | 156 ++++++++++++++++++ .../playlist-error-view.component.ts | 50 +++++- .../unified-collection-clear-action.ts | 2 + .../vod-details-route.component.ts | 6 + libs/shared/interfaces/src/index.ts | 1 + .../src/lib/source-type-icons.util.spec.ts | 43 +++++ .../src/lib/source-type-icons.util.ts | 50 ++++++ .../epg-mapping-dialog.component.html | 6 +- .../confirm-dialog.component.spec.ts | 46 ++++++ .../confirm-dialog.component.ts | 31 +++- .../lib/confirm-dialog/dialog.service.spec.ts | 4 + .../workspace-dashboard-rails.component.ts | 9 +- .../workspace-shell-command-builders.ts | 7 +- ...rkspace-sources-filters-panel.component.ts | 7 +- 54 files changed, 730 insertions(+), 161 deletions(-) create mode 100644 .changes/ui-destructive-confirmations.md create mode 100644 libs/portal/shared/ui/src/lib/components/playlist-error-view/playlist-error-view.component.spec.ts create mode 100644 libs/shared/interfaces/src/lib/source-type-icons.util.spec.ts create mode 100644 libs/shared/interfaces/src/lib/source-type-icons.util.ts diff --git a/.changes/ui-destructive-confirmations.md b/.changes/ui-destructive-confirmations.md new file mode 100644 index 000000000..3a5f8bb0f --- /dev/null +++ b/.changes/ui-destructive-confirmations.md @@ -0,0 +1,6 @@ +--- +type: fix +area: ui +--- + +Removing or clearing data now asks with a named action on a red button instead of "Yes"/"No", and delete buttons are red again. Each source type has one icon everywhere. Deleting a playlist from its error screen now confirms with a message and keeps the playlist if the delete fails. diff --git a/.codex/skills/iptvnator-theme-style/SKILL.md b/.codex/skills/iptvnator-theme-style/SKILL.md index bd52b37b7..a68bced22 100644 --- a/.codex/skills/iptvnator-theme-style/SKILL.md +++ b/.codex/skills/iptvnator-theme-style/SKILL.md @@ -29,8 +29,8 @@ consumers currently use relative `@use` paths to the needed partial. them outside Material components only for roles without an app token. - Set component tokens through `mat.*-overrides()`; retired `--mdc-*` names do nothing and `pnpm run styles:material-tokens:validate` rejects them. -- Destructive buttons use `.app-destructive-button` (`color="warn"` is a no-op - with M3). +- Destructive buttons use `.app-destructive-button` (the `warn` color input is + a no-op with M3); provider icons come from `SOURCE_TYPE_ICONS`. - Local semantic status colors are acceptable. Existing hard-coded layout, selection, and EPG surface colors are migration debt, not precedent. diff --git a/apps/electron-backend-e2e/src/category-management.e2e.ts b/apps/electron-backend-e2e/src/category-management.e2e.ts index 560dc1f33..7f4ae4d8e 100644 --- a/apps/electron-backend-e2e/src/category-management.e2e.ts +++ b/apps/electron-backend-e2e/src/category-management.e2e.ts @@ -551,7 +551,9 @@ async function refreshFromWorkspaceHeader(page: Page): Promise { const dialog = page.locator('mat-dialog-container'); await expect(dialog).toBeVisible(); - await dialog.getByRole('button', { name: 'Yes', exact: true }).click(); + await dialog + .getByRole('button', { name: 'Refresh playlist', exact: true }) + .click(); const refreshOverlay = page.locator('app-workspace-shell-import-overlay'); await expect(refreshOverlay).toBeVisible({ timeout: 5000 }); diff --git a/apps/electron-backend-e2e/src/electron-test-fixtures.ts b/apps/electron-backend-e2e/src/electron-test-fixtures.ts index fa6154625..661cceb0b 100644 --- a/apps/electron-backend-e2e/src/electron-test-fixtures.ts +++ b/apps/electron-backend-e2e/src/electron-test-fixtures.ts @@ -1266,7 +1266,7 @@ export async function clearCurrentUnifiedCollection(page: Page): Promise { const dialog = page.locator('mat-dialog-container').last(); await expect(dialog).toBeVisible(); - await dialog.getByRole('button', { name: /^Yes$/i }).click(); + await dialog.getByRole('button', { name: 'Clear', exact: true }).click(); await expect(dialog).toBeHidden(); } @@ -1664,7 +1664,7 @@ export async function deleteSource(page: Page, title: string): Promise { await expect(row).toBeVisible(); await row.locator('.delete-btn').click(); - await confirmDialog(page); + await confirmDialog(page, 'Remove playlist'); } export async function refreshSource( @@ -1681,7 +1681,7 @@ export async function refreshSource( await row.locator('.refresh-btn').click(); if (confirm) { - await confirmDialog(page); + await confirmDialog(page, 'Refresh playlist'); } } @@ -2098,7 +2098,7 @@ async function openCommandPalette(page: Page): Promise { return dialog; } -async function confirmDialog(page: Page, buttonLabel = 'Yes'): Promise { +async function confirmDialog(page: Page, buttonLabel: string): Promise { const dialog = page.locator('mat-dialog-container'); await expect(dialog).toBeVisible(); diff --git a/apps/electron-backend-e2e/src/live-sidebar-collapse.e2e.ts b/apps/electron-backend-e2e/src/live-sidebar-collapse.e2e.ts index 704a8b920..40084c0a6 100644 --- a/apps/electron-backend-e2e/src/live-sidebar-collapse.e2e.ts +++ b/apps/electron-backend-e2e/src/live-sidebar-collapse.e2e.ts @@ -163,7 +163,10 @@ test.describe('Live channel rail collapse (#1458)', () => { await app.mainWindow .getByRole('button', { name: 'Clear recently viewed Live TV' }) .click(); - await app.mainWindow.getByRole('button', { name: 'Yes' }).click(); + await app.mainWindow + .locator('mat-dialog-container') + .getByRole('button', { name: 'Clear', exact: true }) + .click(); await expect( channelItemByTitle(app.mainWindow, 'Channel Alpha') ).toHaveCount(0); diff --git a/apps/electron-backend-e2e/src/performance/xtream-scenario-driver-wiring.spec.ts b/apps/electron-backend-e2e/src/performance/xtream-scenario-driver-wiring.spec.ts index 2b0543746..31782aebc 100644 --- a/apps/electron-backend-e2e/src/performance/xtream-scenario-driver-wiring.spec.ts +++ b/apps/electron-backend-e2e/src/performance/xtream-scenario-driver-wiring.spec.ts @@ -35,7 +35,8 @@ describe('Xtream scenario driver source wiring', () => { "'app-playlist-item'", "'.refresh-btn'", "'.delete-btn'", - "'Yes'", + "'Refresh playlist'", + "'Remove playlist'", "'.busy-state__message'", "'.action-spinner'", "'.cancel-btn'", diff --git a/apps/electron-backend-e2e/src/performance/xtream-scenario-driver.spec.ts b/apps/electron-backend-e2e/src/performance/xtream-scenario-driver.spec.ts index fdd9df3b2..17e187503 100644 --- a/apps/electron-backend-e2e/src/performance/xtream-scenario-driver.spec.ts +++ b/apps/electron-backend-e2e/src/performance/xtream-scenario-driver.spec.ts @@ -157,10 +157,15 @@ describe('Xtream Playwright scenario driver', () => { }); it('does not require a misleading portal-status badge before refresh or delete', async () => { - for (const [scenarioId, selector] of [ - [XTREAM_SCENARIO_ID.REFRESH_LARGE, '.refresh-btn'], - [XTREAM_SCENARIO_ID.DELETE_LARGE, '.delete-btn'], + for (const [scenarioId, selector, confirmLabel] of [ + [ + XTREAM_SCENARIO_ID.REFRESH_LARGE, + '.refresh-btn', + 'Refresh playlist', + ], + [XTREAM_SCENARIO_ID.DELETE_LARGE, '.delete-btn', 'Remove playlist'], ] as const) { + const confirmClick = `name=${confirmLabel};exact=true`; const fake = new FakePage(); const prepared = await prepareXtreamScenario( fake.asPage(), @@ -168,7 +173,7 @@ describe('Xtream Playwright scenario driver', () => { ); assert.equal(fake.clicksMatching(selector), 1); - assert.equal(fake.clicksMatching('name=Yes;exact=true'), 0); + assert.equal(fake.clicksMatching(confirmClick), 0); const portalReadyIndex = fake.log.findIndex((entry) => entry.includes('[aria-label="Portal status: active"]:visible') ); @@ -179,7 +184,7 @@ describe('Xtream Playwright scenario driver', () => { assert.equal(portalReadyIndex, -1); assert.ok(sourceActionIndex >= 0); await prepared.trigger(); - assert.equal(fake.clicksMatching('name=Yes;exact=true'), 1); + assert.equal(fake.clicksMatching(confirmClick), 1); } }); diff --git a/apps/electron-backend-e2e/src/performance/xtream-scenario-ui.ts b/apps/electron-backend-e2e/src/performance/xtream-scenario-ui.ts index 457ee6554..e2c7b83e0 100644 --- a/apps/electron-backend-e2e/src/performance/xtream-scenario-ui.ts +++ b/apps/electron-backend-e2e/src/performance/xtream-scenario-ui.ts @@ -83,7 +83,10 @@ export async function prepareXtreamSourceActionTrigger( const dialog = page.locator('mat-dialog-container').last(); await dialog.waitFor({ state: 'visible' }); const confirmation = dialog - .getByRole('button', { name: 'Yes', exact: true }) + .getByRole('button', { + name: action === 'refresh' ? 'Refresh playlist' : 'Remove playlist', + exact: true, + }) .last(); await assertTriggerReady(confirmation, `xtream-source-${action}-confirm`); return confirmation; diff --git a/apps/electron-backend-e2e/src/providers.e2e.ts b/apps/electron-backend-e2e/src/providers.e2e.ts index e414cf818..af3ca8cab 100644 --- a/apps/electron-backend-e2e/src/providers.e2e.ts +++ b/apps/electron-backend-e2e/src/providers.e2e.ts @@ -245,7 +245,9 @@ async function refreshRecentXtreamSourceFromDashboard( const dialog = page.locator('mat-dialog-container'); await expect(dialog).toBeVisible(); - await dialog.getByRole('button', { name: 'Yes', exact: true }).click(); + await dialog + .getByRole('button', { name: 'Refresh playlist', exact: true }) + .click(); const refreshOverlay = page.locator('app-workspace-shell-import-overlay'); await expect(refreshOverlay).toBeVisible({ timeout: 5000 }); diff --git a/apps/electron-backend-e2e/src/recent.e2e.ts b/apps/electron-backend-e2e/src/recent.e2e.ts index a82a4f086..98f8fe6d7 100644 --- a/apps/electron-backend-e2e/src/recent.e2e.ts +++ b/apps/electron-backend-e2e/src/recent.e2e.ts @@ -676,7 +676,10 @@ async function clearRecentItems(page: Page, typeLabel: string): Promise { await page .getByRole('button', { name: `Clear recently viewed ${typeLabel}` }) .click(); - await page.getByRole('button', { name: 'Yes' }).click(); + await page + .locator('mat-dialog-container') + .getByRole('button', { name: 'Clear', exact: true }) + .click(); } async function expectUnifiedLiveDetailOpen( diff --git a/apps/electron-backend-e2e/src/theme-tokens.e2e.ts b/apps/electron-backend-e2e/src/theme-tokens.e2e.ts index bcadf0f9d..6782598e9 100644 --- a/apps/electron-backend-e2e/src/theme-tokens.e2e.ts +++ b/apps/electron-backend-e2e/src/theme-tokens.e2e.ts @@ -4,6 +4,7 @@ import { expect, launchElectronApp, openSettings, + openSettingsSection, test, } from './electron-test-fixtures'; import { applyTheme } from './theme-contrast'; @@ -193,4 +194,55 @@ test.describe('Theme tokens', () => { await closeElectronApp(app); } }); + + test('@theme @electron destructive actions name the action and use the error color', async ({ + dataDir, + }) => { + const app = await launchElectronApp(dataDir); + const page = app.mainWindow; + try { + await openSettings(page); + await openSettingsSection(page, 'epg'); + + for (const theme of ['light', 'dark'] as const) { + await applyTheme(page, theme); + const error = await resolveColor(page, 'var(--mat-sys-error)'); + const trigger = page.getByRole('button', { + name: 'Clear EPG data', + exact: true, + }); + // The `warn` color input was a no-op with M3: the trigger + // rendered in the primary color. + await expect + .poll(() => + trigger.evaluate((el) => getComputedStyle(el).color) + ) + .toBe(error); + await trigger.click(); + + const dialog = page.locator('mat-dialog-container'); + await expect(dialog).toBeVisible(); + await expect( + dialog.getByRole('button', { name: 'Yes' }) + ).toHaveCount(0); + const confirm = dialog.getByTestId('confirm-dialog-confirm'); + await expect(confirm).toHaveText('Clear EPG data'); + await expect(confirm).toHaveClass(/app-destructive-button/); + await expect + .poll(() => + confirm.evaluate( + (el) => getComputedStyle(el).backgroundColor + ) + ) + .toBe(error); + + await dialog + .getByRole('button', { name: 'Cancel', exact: true }) + .click(); + await expect(dialog).toBeHidden(); + } + } finally { + await closeElectronApp(app); + } + }); }); diff --git a/apps/electron-backend-e2e/src/xtream-responsiveness.e2e.ts b/apps/electron-backend-e2e/src/xtream-responsiveness.e2e.ts index 1b25035fc..2f0cc03f0 100644 --- a/apps/electron-backend-e2e/src/xtream-responsiveness.e2e.ts +++ b/apps/electron-backend-e2e/src/xtream-responsiveness.e2e.ts @@ -11,6 +11,7 @@ import { waitForXtreamCatalog, xtreamMockServer, } from './electron-test-fixtures'; +import { applyTheme } from './theme-contrast'; const stressPortalName = 'Stress Xtream Portal'; const stressXtreamUsername = 'stress'; @@ -292,7 +293,7 @@ test.describe('Electron Xtream Responsiveness', () => { ); await expect(confirmDialog).toBeVisible(); await confirmDialog - .getByRole('button', { name: 'Yes', exact: true }) + .getByRole('button', { name: 'Remove playlist', exact: true }) .click(); await waitForDbOperationEvent(app.mainWindow, { @@ -304,7 +305,25 @@ test.describe('Electron Xtream Responsiveness', () => { await expect( playlistRow.locator('.busy-state__message') ).toBeVisible({ timeout: 20000 }); - await expect(playlistRow.locator('.cancel-btn')).toBeVisible(); + const cancel = playlistRow.locator('.cancel-btn'); + await expect(cancel).toBeVisible(); + // The row's action buttons inherit its color; the cancel action + // must still read as destructive on the selected row. + for (const theme of ['light', 'dark'] as const) { + await applyTheme(app.mainWindow, theme); + const error = await app.mainWindow.evaluate(() => { + const probe = document.createElement('div'); + probe.style.color = 'var(--mat-sys-error)'; + document.body.appendChild(probe); + const color = getComputedStyle(probe).color; + probe.remove(); + return color; + }); + expect( + await cancel.evaluate((el) => getComputedStyle(el).color), + `cancel color in ${theme} theme` + ).toBe(error); + } await waitForDbOperationEvent(app.mainWindow, { operation: 'delete-playlist', diff --git a/apps/web/src/app/settings/settings-delete-all-playlists-dialog.component.html b/apps/web/src/app/settings/settings-delete-all-playlists-dialog.component.html index db5e7c365..f9e84765e 100644 --- a/apps/web/src/app/settings/settings-delete-all-playlists-dialog.component.html +++ b/apps/web/src/app/settings/settings-delete-all-playlists-dialog.component.html @@ -49,7 +49,11 @@ - diff --git a/apps/web/src/app/settings/settings-delete-all-playlists-dialog.component.ts b/apps/web/src/app/settings/settings-delete-all-playlists-dialog.component.ts index 759812dd1..87dd57aa2 100644 --- a/apps/web/src/app/settings/settings-delete-all-playlists-dialog.component.ts +++ b/apps/web/src/app/settings/settings-delete-all-playlists-dialog.component.ts @@ -9,6 +9,7 @@ import { MatButtonModule } from '@angular/material/button'; import { MatDialogModule, MAT_DIALOG_DATA } from '@angular/material/dialog'; import { MatIconModule } from '@angular/material/icon'; import { TranslateModule } from '@ngx-translate/core'; +import { SOURCE_TYPE_ICONS } from '@iptvnator/shared/interfaces'; export interface SettingsDeleteAllPlaylistsDialogData { summary: { @@ -48,19 +49,19 @@ export class SettingsDeleteAllPlaylistsDialogComponent { { id: 'm3u', count: this.dialogData.summary.m3u, - icon: 'playlist_play', + icon: SOURCE_TYPE_ICONS.m3u, labelKey: 'HOME.PLAYLIST_TYPES.M3U', }, { id: 'xtream', count: this.dialogData.summary.xtream, - icon: 'cloud', + icon: SOURCE_TYPE_ICONS.xtream, labelKey: 'HOME.PLAYLIST_TYPES.XTREAM', }, { id: 'stalker', count: this.dialogData.summary.stalker, - icon: 'router', + icon: SOURCE_TYPE_ICONS.stalker, labelKey: 'HOME.PLAYLIST_TYPES.STALKER', }, ]); diff --git a/apps/web/src/app/settings/settings-epg-section.component.html b/apps/web/src/app/settings/settings-epg-section.component.html index b82aa1643..523e92004 100644 --- a/apps/web/src/app/settings/settings-epg-section.component.html +++ b/apps/web/src/app/settings/settings-epg-section.component.html @@ -217,7 +217,7 @@ - diff --git a/libs/portal/shared/ui/src/lib/components/playlist-error-view/playlist-error-view.component.spec.ts b/libs/portal/shared/ui/src/lib/components/playlist-error-view/playlist-error-view.component.spec.ts new file mode 100644 index 000000000..be17a85f6 --- /dev/null +++ b/libs/portal/shared/ui/src/lib/components/playlist-error-view/playlist-error-view.component.spec.ts @@ -0,0 +1,156 @@ +import { signal } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { MatDialog } from '@angular/material/dialog'; +import { MatSnackBar } from '@angular/material/snack-bar'; +import { provideRouter, Router } from '@angular/router'; +import { Store } from '@ngrx/store'; +import { TranslateModule } from '@ngx-translate/core'; +import { Observable, of } from 'rxjs'; +import { PlaylistActions } from '@iptvnator/m3u-state'; +import { PlaylistContextFacade } from '@iptvnator/playlist/shared/util'; +import { + DatabaseService, + PlaylistsService, + RuntimeCapabilitiesService, + SourceActivityService, +} from '@iptvnator/services'; +import { PlaylistMeta } from '@iptvnator/shared/interfaces'; +import { ConfirmDialogData, DialogService } from '@iptvnator/ui/components'; +import { PlaylistErrorViewComponent } from './playlist-error-view.component'; + +const XTREAM_PLAYLIST = { + _id: 'xtream-1', + title: 'Portal', + serverUrl: 'http://portal.test', + username: 'user', + password: 'pass', + importDate: '2026-09-30T10:00:00.000Z', +} as PlaylistMeta; + +describe('PlaylistErrorViewComponent removal', () => { + let activity: SourceActivityService; + let busyDuringDelete: boolean[]; + let deleteResult: Observable<{ success: boolean }>; + let playlistsService: { deletePlaylist: jest.Mock }; + let dialogService: { openConfirmDialog: jest.Mock }; + let store: { dispatch: jest.Mock }; + let snackBar: { open: jest.Mock }; + let navigate: jest.SpyInstance; + + function setup(): PlaylistErrorViewComponent { + busyDuringDelete = []; + deleteResult = of({ success: true }); + playlistsService = { + deletePlaylist: jest.fn((id: string) => { + busyDuringDelete.push(activity.isBusy(id)); + return deleteResult; + }), + }; + dialogService = { openConfirmDialog: jest.fn() }; + store = { dispatch: jest.fn() }; + snackBar = { open: jest.fn() }; + + TestBed.configureTestingModule({ + imports: [PlaylistErrorViewComponent, TranslateModule.forRoot()], + providers: [ + { + provide: PlaylistContextFacade, + useValue: { activePlaylist: signal(XTREAM_PLAYLIST) }, + }, + { provide: PlaylistsService, useValue: playlistsService }, + { + provide: DatabaseService, + useValue: { createOperationId: () => 'op-1' }, + }, + { + provide: RuntimeCapabilitiesService, + useValue: { supportsXtreamSqliteDataSource: true }, + }, + { provide: DialogService, useValue: dialogService }, + { provide: MatDialog, useValue: { open: jest.fn() } }, + { provide: MatSnackBar, useValue: snackBar }, + { provide: Store, useValue: store }, + provideRouter([]), + ], + }); + activity = TestBed.inject(SourceActivityService); + navigate = jest + .spyOn(TestBed.inject(Router), 'navigate') + .mockResolvedValue(true); + return TestBed.createComponent(PlaylistErrorViewComponent) + .componentInstance; + } + + function confirmRemoval(component: PlaylistErrorViewComponent) { + component.removeClicked(); + const data = dialogService.openConfirmDialog.mock + .calls[0][0] as ConfirmDialogData; + data.onConfirm(); + return data; + } + + it('asks with a named destructive action', () => { + const component = setup(); + component.removeClicked(); + + expect(dialogService.openConfirmDialog).toHaveBeenCalledWith( + expect.objectContaining({ + confirmLabel: 'HOME.PLAYLISTS.REMOVE', + tone: 'destructive', + }) + ); + }); + + it('removes through the shared delete action: busy source, Xtream cache, commit, toast', async () => { + const component = setup(); + confirmRemoval(component); + + await new Promise((resolve) => setTimeout(resolve)); + + // The worker delete (with an operation id) drops the Xtream cache. + expect(playlistsService.deletePlaylist).toHaveBeenCalledWith( + 'xtream-1', + expect.objectContaining({ operationId: 'op-1' }) + ); + // The source is marked busy while it is deleted, then released. + expect(busyDuringDelete).toEqual([true]); + expect(activity.isBusy('xtream-1')).toBe(false); + expect(store.dispatch).toHaveBeenCalledWith( + PlaylistActions.playlistRemovalCommitted({ + playlistId: 'xtream-1', + }) + ); + expect(snackBar.open).toHaveBeenCalledWith( + 'HOME.PLAYLISTS.REMOVE_DIALOG.SUCCESS', + undefined, + { duration: 2000 } + ); + expect(navigate).toHaveBeenCalledWith(['/']); + }); + + it('keeps the playlist and stays put when the delete fails', async () => { + const component = setup(); + deleteResult = new Observable((subscriber) => + subscriber.error(new Error('worker failed')) + ); + + await component.removePlaylist(XTREAM_PLAYLIST); + + expect(store.dispatch).not.toHaveBeenCalled(); + expect(snackBar.open).not.toHaveBeenCalled(); + expect(navigate).not.toHaveBeenCalled(); + expect(activity.isBusy('xtream-1')).toBe(false); + }); + + it('does not start a second removal while the source is busy', async () => { + const component = setup(); + const release = activity.begin(['xtream-1']); + + component.removeClicked(); + await component.removePlaylist(XTREAM_PLAYLIST); + + expect(dialogService.openConfirmDialog).not.toHaveBeenCalled(); + expect(playlistsService.deletePlaylist).not.toHaveBeenCalled(); + release(); + }); +}); diff --git a/libs/portal/shared/ui/src/lib/components/playlist-error-view/playlist-error-view.component.ts b/libs/portal/shared/ui/src/lib/components/playlist-error-view/playlist-error-view.component.ts index aa12df8d7..cae2b8f39 100644 --- a/libs/portal/shared/ui/src/lib/components/playlist-error-view/playlist-error-view.component.ts +++ b/libs/portal/shared/ui/src/lib/components/playlist-error-view/playlist-error-view.component.ts @@ -7,13 +7,19 @@ import { import { MatButtonModule } from '@angular/material/button'; import { MatDialog } from '@angular/material/dialog'; import { MatIconModule } from '@angular/material/icon'; +import { MatSnackBar } from '@angular/material/snack-bar'; import { Router, RouterLink } from '@angular/router'; import { Store } from '@ngrx/store'; import { TranslateModule, TranslateService } from '@ngx-translate/core'; +import { PlaylistActions } from '@iptvnator/m3u-state'; import { PlaylistInfoComponent } from '@iptvnator/playlist/shared/ui'; import { PlaylistContextFacade } from '@iptvnator/playlist/shared/util'; +import { + PlaylistDeleteActionService, + SourceActivityService, +} from '@iptvnator/services'; +import { PlaylistMeta } from '@iptvnator/shared/interfaces'; import { DialogService } from '@iptvnator/ui/components'; -import { PlaylistActions } from '@iptvnator/m3u-state'; @Component({ selector: 'app-playlist-error-view', @@ -25,8 +31,11 @@ import { PlaylistActions } from '@iptvnator/m3u-state'; export class PlaylistErrorViewComponent { private dialog = inject(MatDialog); private dialogService = inject(DialogService); + private readonly activity = inject(SourceActivityService); private readonly playlistContext = inject(PlaylistContextFacade); + private readonly playlistDeleteAction = inject(PlaylistDeleteActionService); private router = inject(Router); + private readonly snackBar = inject(MatSnackBar); private store = inject(Store); private translate = inject(TranslateService); @@ -48,7 +57,10 @@ export class PlaylistErrorViewComponent { removeClicked(): void { const currentPlaylist = this.currentPlaylist(); - if (!currentPlaylist?._id) { + if ( + !currentPlaylist?._id || + this.activity.isBusy(currentPlaylist._id) + ) { return; } @@ -57,12 +69,38 @@ export class PlaylistErrorViewComponent { message: this.translate.instant( 'HOME.PLAYLISTS.REMOVE_DIALOG.MESSAGE' ), - onConfirm: (): void => this.removePlaylist(currentPlaylist._id), + confirmLabel: this.translate.instant('HOME.PLAYLISTS.REMOVE'), + tone: 'destructive', + onConfirm: (): void => void this.removePlaylist(currentPlaylist), }); } - removePlaylist(playlistId: string): void { - this.store.dispatch(PlaylistActions.removePlaylist({ playlistId })); - this.router.navigate(['/']); + /** + * Same path as every other source removal: the shared delete action marks + * the source busy, lets persistence drop the Xtream cache and cleanups, + * and only a completed delete is committed to the store. + */ + async removePlaylist(playlist: PlaylistMeta): Promise { + if (this.activity.isBusy(playlist._id)) { + return; + } + + const deleted = + await this.playlistDeleteAction.deletePlaylist(playlist); + if (!deleted) { + return; + } + + this.store.dispatch( + PlaylistActions.playlistRemovalCommitted({ + playlistId: playlist._id, + }) + ); + this.snackBar.open( + this.translate.instant('HOME.PLAYLISTS.REMOVE_DIALOG.SUCCESS'), + undefined, + { duration: 2000 } + ); + void this.router.navigate(['/']); } } diff --git a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-collection-clear-action.ts b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-collection-clear-action.ts index b86701537..d32f8ce18 100644 --- a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-collection-clear-action.ts +++ b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-collection-clear-action.ts @@ -67,6 +67,8 @@ export function createClearCollectionAction(options: { dialogService.openConfirmDialog({ title: translate.instant(titleKey, { type }), message: translate.instant(messageKey, { type }), + confirmLabel: translate.instant('CLEAR'), + tone: 'destructive', onConfirm: async () => { if (isFavorites) { await clearFavorites(itemsToRemove); diff --git a/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.component.ts b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.component.ts index 2d17c5b04..10a2e0b68 100644 --- a/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.component.ts +++ b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.component.ts @@ -747,6 +747,12 @@ export class VodDetailsRouteComponent implements OnInit, OnDestroy { message: this.translateService.instant( 'DOWNLOADS.CANCEL_CONFIRM_MESSAGE' ), + confirmLabel: this.translateService.instant( + 'DOWNLOADS.CANCEL_CONFIRM_TITLE' + ), + // "Cancel" next to "Cancel download" would read as the same action. + cancelLabel: this.translateService.instant('CLOSE'), + tone: 'destructive', onConfirm: () => void this.downloads.cancelActive(), }); } diff --git a/libs/shared/interfaces/src/index.ts b/libs/shared/interfaces/src/index.ts index 29853f3e1..6e8ee1abf 100644 --- a/libs/shared/interfaces/src/index.ts +++ b/libs/shared/interfaces/src/index.ts @@ -47,6 +47,7 @@ export * from './lib/portal-debug.interface'; export * from './lib/playlist-display-label.util'; export * from './lib/portal-playback.interface'; export * from './lib/provider-import-detection.util'; +export * from './lib/source-type-icons.util'; export * from './lib/provider-overview.util'; export * from './lib/random-id.util'; export * from './lib/recording-metadata.interface'; diff --git a/libs/shared/interfaces/src/lib/source-type-icons.util.spec.ts b/libs/shared/interfaces/src/lib/source-type-icons.util.spec.ts new file mode 100644 index 000000000..e5c056835 --- /dev/null +++ b/libs/shared/interfaces/src/lib/source-type-icons.util.spec.ts @@ -0,0 +1,43 @@ +import { + getPlaylistSourceIcon, + resolvePlaylistSourceIconKey, + SOURCE_TYPE_ICONS, +} from './source-type-icons.util'; + +describe('source type icons', () => { + it('never lets one icon stand for two providers', () => { + const byProvider = { + xtream: [SOURCE_TYPE_ICONS.xtream], + stalker: [SOURCE_TYPE_ICONS.stalker], + m3u: [ + SOURCE_TYPE_ICONS.m3u, + SOURCE_TYPE_ICONS['m3u-url'], + SOURCE_TYPE_ICONS['m3u-local'], + SOURCE_TYPE_ICONS['m3u-text'], + ], + }; + const owners = new Map(); + for (const [provider, icons] of Object.entries(byProvider)) { + for (const icon of icons) { + expect(owners.get(icon) ?? provider).toBe(provider); + owners.set(icon, provider); + } + } + }); + + it('does not reuse the Dashboard rail icon for a provider', () => { + expect(Object.values(SOURCE_TYPE_ICONS)).not.toContain('dashboard'); + }); + + it.each([ + [{ macAddress: '00:1A:79:00:00:01', url: 'http://portal' }, 'stalker'], + [{ serverUrl: 'http://xtream' }, 'xtream'], + [{ url: 'http://list.m3u' }, 'm3u-url'], + [{}, 'm3u-local'], + ] as const)('resolves %o to %s', (playlist, expected) => { + expect(resolvePlaylistSourceIconKey(playlist)).toBe(expected); + expect(getPlaylistSourceIcon(playlist)).toBe( + SOURCE_TYPE_ICONS[expected] + ); + }); +}); diff --git a/libs/shared/interfaces/src/lib/source-type-icons.util.ts b/libs/shared/interfaces/src/lib/source-type-icons.util.ts new file mode 100644 index 000000000..90084b579 --- /dev/null +++ b/libs/shared/interfaces/src/lib/source-type-icons.util.ts @@ -0,0 +1,50 @@ +import type { PlaylistMeta } from './playlist-meta.type'; + +/** + * One Material icon ligature per source type, used everywhere a source is + * shown: the add dialog, provider auto-detection, the Sources list and its + * filters, the playlist switcher, dashboard cards, the command palette and + * the reset summaries. A provider must look the same on every screen, and no + * icon may stand for two providers. + * + * `m3u` is the provider family (filters and summaries that count every M3U + * source); the other M3U keys name how a single playlist was added. Stored + * playlists cannot tell a local file from pasted text, so both render as + * `m3u-local`; only the add dialog and auto-detection show `m3u-text`. + */ +export const SOURCE_TYPE_ICONS = { + m3u: 'playlist_play', + 'm3u-url': 'link', + 'm3u-local': 'description', + 'm3u-text': 'subject', + xtream: 'cloud', + stalker: 'cast', +} as const; + +export type SourceTypeIconKey = keyof typeof SOURCE_TYPE_ICONS; + +export type PlaylistSourceIconKey = Extract< + SourceTypeIconKey, + 'm3u-url' | 'm3u-local' | 'xtream' | 'stalker' +>; + +export function resolvePlaylistSourceIconKey( + playlist: Pick +): PlaylistSourceIconKey { + if (playlist.macAddress) { + return 'stalker'; + } + if (playlist.serverUrl) { + return 'xtream'; + } + if (playlist.url) { + return 'm3u-url'; + } + return 'm3u-local'; +} + +export function getPlaylistSourceIcon( + playlist: Pick +): string { + return SOURCE_TYPE_ICONS[resolvePlaylistSourceIconKey(playlist)]; +} diff --git a/libs/ui/components/src/lib/channel-list-container/epg-mapping-dialog/epg-mapping-dialog.component.html b/libs/ui/components/src/lib/channel-list-container/epg-mapping-dialog/epg-mapping-dialog.component.html index e4be4b844..c6a8d16d3 100644 --- a/libs/ui/components/src/lib/channel-list-container/epg-mapping-dialog/epg-mapping-dialog.component.html +++ b/libs/ui/components/src/lib/channel-list-container/epg-mapping-dialog/epg-mapping-dialog.component.html @@ -59,7 +59,11 @@ @if (currentMapping()) { - } diff --git a/libs/ui/components/src/lib/confirm-dialog/confirm-dialog.component.spec.ts b/libs/ui/components/src/lib/confirm-dialog/confirm-dialog.component.spec.ts index 5efb3c49e..ed6e0b3f9 100644 --- a/libs/ui/components/src/lib/confirm-dialog/confirm-dialog.component.spec.ts +++ b/libs/ui/components/src/lib/confirm-dialog/confirm-dialog.component.spec.ts @@ -52,3 +52,49 @@ describe('ConfirmDialogComponent actions', () => { } ); }); + +describe('ConfirmDialogComponent labels and tone', () => { + async function render(data: Record) { + await TestBed.configureTestingModule({ + imports: [ + ConfirmDialogComponent, + NoopAnimationsModule, + TranslateModule.forRoot(), + ], + providers: [ + { + provide: MAT_DIALOG_DATA, + useValue: { + title: 'Remove playlist', + message: 'Delete it?', + confirmLabel: 'Remove playlist', + onConfirm: jest.fn(), + ...data, + }, + }, + { provide: MatDialogRef, useValue: { close: jest.fn() } }, + ], + }).compileComponents(); + const fixture = TestBed.createComponent(ConfirmDialogComponent); + fixture.detectChanges(); + const buttons = fixture.nativeElement.querySelectorAll( + 'button' + ) as NodeListOf; + return { cancel: buttons[0], confirm: buttons[1] }; + } + + it('names the action instead of answering "Yes", and cancels by default', async () => { + const { cancel, confirm } = await render({}); + + expect(confirm.textContent?.trim()).toBe('Remove playlist'); + // No translations are loaded, so the key is the rendered text. + expect(cancel.textContent?.trim()).toBe('CANCEL'); + expect(confirm.classList).not.toContain('app-destructive-button'); + }); + + it('styles destructive confirmations as destructive', async () => { + const { confirm } = await render({ tone: 'destructive' }); + + expect(confirm.classList).toContain('app-destructive-button'); + }); +}); diff --git a/libs/ui/components/src/lib/confirm-dialog/confirm-dialog.component.ts b/libs/ui/components/src/lib/confirm-dialog/confirm-dialog.component.ts index 28278b175..937c06e54 100644 --- a/libs/ui/components/src/lib/confirm-dialog/confirm-dialog.component.ts +++ b/libs/ui/components/src/lib/confirm-dialog/confirm-dialog.component.ts @@ -3,12 +3,25 @@ import { MatButtonModule } from '@angular/material/button'; import { MAT_DIALOG_DATA, MatDialogModule } from '@angular/material/dialog'; import { TranslateModule } from '@ngx-translate/core'; +/** + * `destructive` renders the confirm action with the app-wide error styling + * (`.app-destructive-button`). Use it whenever confirming removes or discards + * user data; Material's `warn` color input has no effect with the M3 theme. + */ +export type ConfirmDialogTone = 'default' | 'destructive'; + export interface ConfirmDialogData { title: string; message: string; width?: string; - confirmLabel?: string; + /** + * Translated verb that names the action ("Remove playlist", "Clear"). + * Required so a confirmation never falls back to an ambiguous "Yes". + */ + confirmLabel: string; + /** Translated dismiss label; defaults to "Cancel". */ cancelLabel?: string; + tone?: ConfirmDialogTone; /** Run the action in the dialog; only Close/backdrop/Escape dismiss it. */ keepOpenOnConfirm?: boolean; onConfirm: () => void; @@ -27,23 +40,25 @@ export interface ConfirmDialogData { @if (dialogData.keepOpenOnConfirm) { } @else { } @@ -56,4 +71,8 @@ export class ConfirmDialogComponent { constructor() { this.dialogData = this.data; } + + get isDestructive(): boolean { + return this.dialogData.tone === 'destructive'; + } } diff --git a/libs/ui/components/src/lib/confirm-dialog/dialog.service.spec.ts b/libs/ui/components/src/lib/confirm-dialog/dialog.service.spec.ts index 348d3ec79..3dda815f8 100644 --- a/libs/ui/components/src/lib/confirm-dialog/dialog.service.spec.ts +++ b/libs/ui/components/src/lib/confirm-dialog/dialog.service.spec.ts @@ -33,6 +33,7 @@ describe('DialogService', () => { service.openConfirmDialog({ title: 'Remove', message: 'Confirm removal?', + confirmLabel: 'Remove playlist', onConfirm: jest.fn(), }); @@ -57,6 +58,7 @@ describe('DialogService', () => { service.openConfirmDialog({ title: 'Refresh Xtream Playlist', message: 'Confirm refresh?', + confirmLabel: 'Refresh playlist', width: '400px', onConfirm: jest.fn(), }); @@ -78,6 +80,7 @@ describe('DialogService', () => { service.openConfirmDialog({ title: 'Remove', message: 'Confirm removal?', + confirmLabel: 'Remove playlist', onConfirm, }); @@ -89,6 +92,7 @@ describe('DialogService', () => { service.openConfirmDialog({ title: 'Recovery', message: 'Path', + confirmLabel: 'Copy path', keepOpenOnConfirm: true, onConfirm, }); diff --git a/libs/workspace/dashboard/feature/src/lib/rails/workspace-dashboard-rails.component.ts b/libs/workspace/dashboard/feature/src/lib/rails/workspace-dashboard-rails.component.ts index d9c15f75b..e00bd6fee 100644 --- a/libs/workspace/dashboard/feature/src/lib/rails/workspace-dashboard-rails.component.ts +++ b/libs/workspace/dashboard/feature/src/lib/rails/workspace-dashboard-rails.component.ts @@ -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); }, diff --git a/libs/workspace/shell/feature/src/lib/workspace-shell/services/helpers/workspace-shell-command-builders.ts b/libs/workspace/shell/feature/src/lib/workspace-shell/services/helpers/workspace-shell-command-builders.ts index ac158001f..6cd624198 100644 --- a/libs/workspace/shell/feature/src/lib/workspace-shell/services/helpers/workspace-shell-command-builders.ts +++ b/libs/workspace/shell/feature/src/lib/workspace-shell/services/helpers/workspace-shell-command-builders.ts @@ -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', diff --git a/libs/workspace/shell/feature/src/lib/workspace-sources-filters-panel/workspace-sources-filters-panel.component.ts b/libs/workspace/shell/feature/src/lib/workspace-sources-filters-panel/workspace-sources-filters-panel.component.ts index 86e3f2cbb..b11842096 100644 --- a/libs/workspace/shell/feature/src/lib/workspace-sources-filters-panel/workspace-sources-filters-panel.component.ts +++ b/libs/workspace/shell/feature/src/lib/workspace-sources-filters-panel/workspace-sources-filters-panel.component.ts @@ -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', }, ]; From c9d169e3dc55dfa3be1520edc4c6e3a2c2d45bb0 Mon Sep 17 00:00:00 2001 From: 4gray <4gray@users.noreply.github.com> Date: Sat, 3 Oct 2026 10:24:57 +0200 Subject: [PATCH 2/4] fix(dashboard): scroll a focused rail card fully into view (#1785) * fix(dashboard): scroll a focused rail card fully into view Chromium skips its focus scroll when 32px or more of the element already shows, so Tab onto the last source card of a rail that overflows by less than a card left it half-hidden under the edge fade. The rail track now handles focusin and scrolls to the first card-start snap position that reveals the whole card; a plain "nearest" scroll is not enough because mandatory snapping can round it back (seen on the live channel rail). Co-Authored-By: Claude Opus 5.5 * fix(dashboard): keep mouse clicks on partly hidden rail cards A mouse press focuses the card link on mousedown. Revealing the card at that moment could slide it from under the pointer when the target snap position overshoots (the live channel rail moves 316px for a 306px card), so the click landed elsewhere. The rail now reveals a card only for keyboard and programmatic focus, using the CDK FocusMonitor origin. Adds an Electron E2E that checks the final layout after snapping: Tab and focus() leave the last source card fully visible, and a mouse press keeps the rail still and still opens the source. Co-Authored-By: Claude Opus 5.5 * fix(dashboard): tell pointer focus apart without touching the DOM FocusMonitor toggles cdk-*-focused classes on the monitored track, so a mouse press on a source card mutated the DOM before the click. The J2 "open a source" performance journey rejects iterations with DOM activity between its settle snapshot and the click. Read the input modality from the CDK InputModalityDetector in a focusin handler instead: it only listens, so the rail stays untouched until the click. Co-Authored-By: Claude Opus 5.5 * fix(dashboard): reveal script-focused rail cards after a mouse click The input modality stays "mouse" after any click, so a later focus() on a partly hidden card left it clipped. The rail now skips the reveal only for focus caused by a press inside the track: the focus has to arrive within 100ms of that pointerdown (650ms for touch, whose focus comes with the tap's compatibility mouse events, as in the CDK FocusMonitor). Only event timestamps are compared, so the DOM still stays untouched before the click. The E2E now clicks elsewhere before the script focus, and unit tests cover a tap and focus() after an earlier mouse press. Co-Authored-By: Claude Opus 5.5 * fix(dashboard): keep an over-wide focused rail card in view In a window narrower than a card (or under zoom), a focused card could never fit, so its own snap offset fell short of the needed scroll and the rail jumped to the next card's snap point, moving the focused card offscreen. Such a card is now aligned at its own start instead. Co-Authored-By: Claude Opus 5.5 * test(dashboard): select rail internals through stable test ids The dashboard contract makes data-test-id hooks the supported Electron E2E selector surface. The rail now exposes -viewport, -track and -card-link hooks next to its existing ones, and the focus E2E selects those (and the rail heading by role) instead of internal class names. The dashboard doc lists the new hooks and records the focus-reveal contract. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: 4gray Co-authored-by: Claude Opus 5.5 --- .changes/dashboard-rail-focus-reveal.md | 8 + .../src/dashboard-rail-focus.e2e.ts | 175 ++++++++++++ docs/architecture/workspace-dashboard.md | 13 +- .../lib/rails/dashboard-rail.component.html | 15 +- .../rails/dashboard-rail.component.spec.ts | 260 ++++++++++++++++++ .../src/lib/rails/dashboard-rail.component.ts | 84 ++++++ 6 files changed, 551 insertions(+), 4 deletions(-) create mode 100644 .changes/dashboard-rail-focus-reveal.md create mode 100644 apps/electron-backend-e2e/src/dashboard-rail-focus.e2e.ts diff --git a/.changes/dashboard-rail-focus-reveal.md b/.changes/dashboard-rail-focus-reveal.md new file mode 100644 index 000000000..7c6368b10 --- /dev/null +++ b/.changes/dashboard-rail-focus-reveal.md @@ -0,0 +1,8 @@ +--- +type: fix +area: dashboard +--- + +Moving keyboard focus onto a dashboard rail card, such as a source in Recently +Used Sources, now scrolls the rail so the whole card shows instead of leaving +it half-hidden under the edge fade. diff --git a/apps/electron-backend-e2e/src/dashboard-rail-focus.e2e.ts b/apps/electron-backend-e2e/src/dashboard-rail-focus.e2e.ts new file mode 100644 index 000000000..0e9cf812f --- /dev/null +++ b/apps/electron-backend-e2e/src/dashboard-rail-focus.e2e.ts @@ -0,0 +1,175 @@ +import { Locator, Page } from '@playwright/test'; +import { + closeElectronApp, + expect, + goToDashboard, + importM3uPlaylistFromNativeDialog, + launchElectronApp, + test, + writeTemporaryM3uFile, +} from './electron-test-fixtures'; + +const sourcesRailId = 'dashboard-recent-sources-rail'; +const sourceCount = 4; +/** The rail's stable `data-test-id` hooks, as CSS selectors. */ +const hooks = { + viewport: `[data-test-id="${sourcesRailId}-viewport"]`, + track: `[data-test-id="${sourcesRailId}-track"]`, + card: `[data-test-id="${sourcesRailId}-card"]`, + cardLink: `[data-test-id="${sourcesRailId}-card-link"]`, +}; + +/** + * Sizes the sources rail's cards so the last one is half visible: the rail + * overflows by less than a card, and Chromium skips its own focus scroll for + * an element that already shows 32px or more. Returns the card width. + */ +async function makeLastCardHalfVisible(rail: Locator): Promise { + const width = await rail.evaluate( + (section, { count, selectors }) => { + const host = section.parentElement as HTMLElement; + const viewport = section.querySelector( + selectors.viewport + ) as HTMLElement; + const track = section.querySelector(selectors.track) as HTMLElement; + const gap = parseFloat(getComputedStyle(track).columnGap) || 0; + // count cards + (count - 1) gaps = viewport + half a card. + const cardWidth = Math.floor( + (viewport.clientWidth - gap * (count - 1)) / (count - 0.5) + ); + host.style.setProperty('--cover-rail-width', `${cardWidth}px`); + track.scrollTo({ left: 0, behavior: 'auto' }); + return cardWidth; + }, + { count: sourceCount, selectors: hooks } + ); + await expect + .poll(() => lastCardVisibleWidth(rail)) + .toBeGreaterThan(Math.min(64, width / 4)); + return width; +} + +/** Pixels of the last card inside the rail's visible viewport. */ +function lastCardVisibleWidth(rail: Locator): Promise { + return rail.evaluate((section, selectors) => { + const viewport = section + .querySelector(selectors.viewport) + ?.getBoundingClientRect(); + const cards = section.querySelectorAll(selectors.card); + const card = cards[cards.length - 1]?.getBoundingClientRect(); + if (!viewport || !card) return 0; + return ( + Math.min(card.right, viewport.right) - + Math.max(card.left, viewport.left) + ); + }, hooks); +} + +async function lastCardFullyVisible(rail: Locator): Promise { + return rail.evaluate((section, selectors) => { + const viewport = section + .querySelector(selectors.viewport) + ?.getBoundingClientRect(); + const cards = section.querySelectorAll(selectors.card); + const card = cards[cards.length - 1]?.getBoundingClientRect(); + if (!viewport || !card) return false; + return ( + card.left >= viewport.left - 1 && card.right <= viewport.right + 1 + ); + }, hooks); +} + +function lastCardLinkFocused(page: Page): Promise { + return page.evaluate((selector) => { + const links = document.querySelectorAll(selector); + return document.activeElement === links[links.length - 1]; + }, hooks.cardLink); +} + +test.describe('Dashboard rail focus', () => { + test('reveals a partly hidden card on keyboard and programmatic focus, and keeps mouse clicks on it', async ({ + dataDir, + }) => { + const app = await launchElectronApp(dataDir); + + try { + for (let index = 1; index <= sourceCount; index++) { + const filePath = writeTemporaryM3uFile( + dataDir, + `rail-focus-source-${index}.m3u`, + [ + { + groupTitle: 'News', + name: `Rail Focus Channel ${index}`, + url: `https://streams.example.test/rail-${index}.m3u8`, + }, + ] + ); + await importM3uPlaylistFromNativeDialog(app, filePath); + } + + await goToDashboard(app.mainWindow); + const rail = app.mainWindow.getByTestId(sourcesRailId); + await expect(rail.locator(hooks.card)).toHaveCount(sourceCount); + const cardLinks = rail.locator(hooks.cardLink); + + // Tab from the first card to the last card's link. + await makeLastCardHalfVisible(rail); + await cardLinks.first().focus(); + for ( + let presses = 0; + presses < sourceCount * 3 && + !(await lastCardLinkFocused(app.mainWindow)); + presses++ + ) { + await app.mainWindow.keyboard.press('Tab'); + } + expect(await lastCardLinkFocused(app.mainWindow)).toBe(true); + await expect.poll(() => lastCardFullyVisible(rail)).toBe(true); + + // `focus()` from script after a mouse click elsewhere, starting + // at the rail's start again. + await rail.getByRole('heading').click(); + await rail.evaluate((section, selector) => { + (document.activeElement as HTMLElement | null)?.blur(); + section + .querySelector(selector) + ?.scrollTo({ left: 0, behavior: 'auto' }); + }, hooks.track); + await expect.poll(() => lastCardFullyVisible(rail)).toBe(false); + await cardLinks.last().evaluate((link) => { + (link as HTMLElement).focus(); + }); + await expect.poll(() => lastCardFullyVisible(rail)).toBe(true); + + // A mouse press focuses the link too; the rail must not slide it + // away from under the pointer before the click lands. + await rail.evaluate((section, selector) => { + (document.activeElement as HTMLElement | null)?.blur(); + section + .querySelector(selector) + ?.scrollTo({ left: 0, behavior: 'auto' }); + }, hooks.track); + await expect.poll(() => lastCardFullyVisible(rail)).toBe(false); + const box = await cardLinks.last().boundingBox(); + expect(box).not.toBeNull(); + await app.mainWindow.mouse.move( + (box?.x ?? 0) + 24, + (box?.y ?? 0) + (box?.height ?? 0) / 2 + ); + await app.mainWindow.mouse.down(); + expect(await lastCardLinkFocused(app.mainWindow)).toBe(true); + expect( + await rail + .locator(hooks.track) + .evaluate((track) => track.scrollLeft) + ).toBe(0); + await app.mainWindow.mouse.up(); + await expect(app.mainWindow).not.toHaveURL( + /\/workspace\/dashboard$/ + ); + } finally { + await closeElectronApp(app); + } + }); +}); diff --git a/docs/architecture/workspace-dashboard.md b/docs/architecture/workspace-dashboard.md index db6c22753..e8527351d 100644 --- a/docs/architecture/workspace-dashboard.md +++ b/docs/architecture/workspace-dashboard.md @@ -164,11 +164,18 @@ and that pause holds the slide. hover lift are not clipped; the fades are offset by the same variable so they reach the track's clipping edge and no card strip shows beyond them. 4. Cards are keyboard-focusable router links; `scroll-snap-align: start` - means arrow-key nav lands on card boundaries. + means arrow-key nav lands on card boundaries. A card that receives + keyboard or script focus scrolls fully into the viewport: Chromium skips + its own focus scroll once 32px of an element shows, so the track's + `focusin` handler moves to the first card-start snap position revealing + the whole card (a card wider than the viewport aligns at its own start). + Focus caused by a press inside the track (within 100ms of `pointerdown`, + 650ms for touch) leaves the rail still, so the card does not slide from + under the pointer before the click. 5. Image handling: `loading="lazy"`, `decoding="async"`, fallback icon tile when `imageUrl` is missing or `error` fires. -6. Dashboard hero, rail containers, rail cards, and "Manage all" links expose - stable `data-test-id` hooks. Treat these as the supported Electron E2E +6. Dashboard hero, rail containers, rail viewports and tracks, rail cards and + their links, and "Manage all" links expose stable `data-test-id` hooks. Treat these as the supported Electron E2E selector surface; do not target internal CSS class names. ## Data Flow diff --git a/libs/workspace/dashboard/feature/src/lib/rails/dashboard-rail.component.html b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-rail.component.html index 649460d5b..a600e1599 100644 --- a/libs/workspace/dashboard/feature/src/lib/rails/dashboard-rail.component.html +++ b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-rail.component.html @@ -33,7 +33,11 @@ } -
+
-
-} -
diff --git a/libs/ui/components/src/lib/portal-detail-shell/portal-detail-shell.component.scss b/libs/ui/components/src/lib/portal-detail-shell/portal-detail-shell.component.scss index c60c3ada5..f06147ebe 100644 --- a/libs/ui/components/src/lib/portal-detail-shell/portal-detail-shell.component.scss +++ b/libs/ui/components/src/lib/portal-detail-shell/portal-detail-shell.component.scss @@ -13,13 +13,6 @@ --tag-border: rgba(0, 0, 0, 0.12); --poster-shadow: 0 8px 40px rgba(0, 0, 0, 0.15); --content-max-width: 1400px; - // The sticky Back control floats over the scroll owner. While it is shown, - // every content column (hero, player, episodes, About, extras) starts - // after its lane — inset + button + the same inset again — so nothing - // scrolls beneath it. content-hero and content-about read the lane too. - --shell-back-inset: 16px; - --shell-back-size: 40px; - --detail-back-lane: 0px; display: block; height: 100%; @@ -37,12 +30,6 @@ } } -:host(.shell-host--back) { - --detail-back-lane: calc( - 2 * var(--shell-back-inset) + var(--shell-back-size) - ); -} - :host-context(.dark-theme) { --surface-bg: #141414; --text-primary: #fff; @@ -53,48 +40,6 @@ --poster-shadow: 0 8px 40px rgba(0, 0, 0, 0.5); } -.shell__navigation { - // Direct child of the scroll owner: sticky lasts through the entire page. - position: sticky; - top: var(--shell-back-inset); - height: 0; - z-index: 10; - pointer-events: none; -} - -.shell__back-button { - position: absolute; - inset-inline-start: var(--shell-back-inset); - pointer-events: auto; - app-region: no-drag; - -webkit-app-region: no-drag; - display: flex; - align-items: center; - justify-content: center; - width: var(--shell-back-size); - height: var(--shell-back-size); - border-radius: 50%; - background: var(--app-widget-bg, var(--surface-bg)); - color: var(--app-on-surface, var(--text-primary)); - border: 1px solid var(--app-separator, var(--tag-border)); - cursor: pointer; - - &:hover { - background: var(--app-card-hover-bg, var(--surface-bg)); - } - - &:focus-visible { - outline: 2px solid var(--app-selection-color); - outline-offset: 2px; - } - - mat-icon { - font-size: 24px; - width: 24px; - height: 24px; - } -} - // ============================================================================ // Hero collapse (browse ↔ watch morph, ~300ms, no height measuring) // ============================================================================ @@ -135,7 +80,6 @@ &--active { padding: 16px 40px 0; - padding-inline-start: max(40px, var(--detail-back-lane)); animation: shell-fade-in 300ms ease 50ms both; } } @@ -144,7 +88,6 @@ max-width: var(--content-max-width); margin: 0 auto; padding: 0 40px; - padding-inline-start: max(40px, var(--detail-back-lane)); } .shell__about { @@ -158,7 +101,6 @@ max-width: var(--content-max-width); margin: 0 auto; padding: 0 40px 40px; - padding-inline-start: max(40px, var(--detail-back-lane)); } @keyframes shell-fade-in { @@ -175,38 +117,14 @@ @media (max-width: 800px) { .shell__player--active { padding: 8px 12px 0; - padding-inline-start: max(12px, var(--detail-back-lane)); } .shell__episodes { padding: 0 20px; - padding-inline-start: max(20px, var(--detail-back-lane)); } .shell__extras { padding: 0 20px 32px; - padding-inline-start: max(20px, var(--detail-back-lane)); - } -} - -// A pane too narrow to spare a 72px column (a phone, or a desktop pane beside -// the context panel; the component sets the class from the pane's width) -// would clip the player's controls. There the control takes a row instead — -// an opaque sticky bar in flow that content scrolls beneath, never alongside. -:host(.shell-host--compact) { - --detail-back-lane: 0px; - - .shell__navigation { - top: 0; - height: calc(var(--shell-back-size) + 16px); - background: var(--app-header-bg); - // The bar hides what scrolls beneath it, so it must not let taps - // through to that hidden content either. - pointer-events: auto; - } - - .shell__back-button { - top: 8px; } } diff --git a/libs/ui/components/src/lib/portal-detail-shell/portal-detail-shell.component.spec.ts b/libs/ui/components/src/lib/portal-detail-shell/portal-detail-shell.component.spec.ts index 55e51eeda..3b540b449 100644 --- a/libs/ui/components/src/lib/portal-detail-shell/portal-detail-shell.component.spec.ts +++ b/libs/ui/components/src/lib/portal-detail-shell/portal-detail-shell.component.spec.ts @@ -1,6 +1,7 @@ import { Component, signal } from '@angular/core'; import { ComponentFixture, TestBed } from '@angular/core/testing'; import { TranslateModule, TranslateService } from '@ngx-translate/core'; +import { WorkspaceBackNavigationService } from '@iptvnator/portal/shared/data-access'; import { DetailActionsTemplateDirective, DetailMetaTemplateDirective, @@ -69,6 +70,9 @@ describe('PortalDetailShellComponent', () => { return element; }; + const backTarget = () => + TestBed.inject(WorkspaceBackNavigationService).target(); + beforeEach(async () => { await TestBed.configureTestingModule({ imports: [HostComponent, TranslateModule.forRoot()], @@ -130,13 +134,13 @@ describe('PortalDetailShellComponent', () => { expect(query('.details__meta .details__meta-item')).toBeTruthy(); expect(query('.action-buttons .play-btn')).toBeTruthy(); expect(query('app-content-about')).toBeNull(); - expect(query('.shell__back-button')?.getAttribute('aria-label')).toBe( - 'Return to downloads' - ); + // The header renders Back; the page itself keeps no arrow. + expect(query('button[aria-label="Return to downloads"]')).toBeNull(); + expect(backTarget()?.label()).toBe('Return to downloads'); }); it.each([{ isLoading: true }, { errorMessage: 'Unavailable' }])( - 'keeps the translated fallback Back available in loading/error states', + 'keeps the generic Back available in loading/error states', (state) => { fixture.destroy(); const shellFixture = TestBed.createComponent( @@ -145,16 +149,14 @@ describe('PortalDetailShellComponent', () => { for (const [key, value] of Object.entries(state)) shellFixture.componentRef.setInput(key, value); shellFixture.detectChanges(); - const element = shellFixture.nativeElement as HTMLElement; - const button = element.querySelector( - '.shell__back-button' - ); - expect(button?.type).toBe('button'); - expect(button?.getAttribute('aria-label')).toBe('Go back'); + // No host label: the header falls back to the translated "Back". + expect(backTarget()?.label()).toBeNull(); const back = jest.fn(); shellFixture.componentInstance.backClicked.subscribe(back); - button?.click(); + backTarget()?.run(); expect(back).toHaveBeenCalledTimes(1); + shellFixture.destroy(); + expect(backTarget()).toBeNull(); } ); @@ -277,30 +279,33 @@ describe('PortalDetailShellComponent', () => { expect(host.closeRequests).toBe(0); }); - it('keeps one route-back control outside the collapsing hero in both states', () => { - const back = requiredQuery('.shell__back-button'); - expect(back.closest('app-content-hero')).toBeNull(); - expect(back.getAttribute('aria-label')).toBe('Return to downloads'); - expect(back.getAttribute('aria-keyshortcuts')).toBe('Escape'); - back.click(); + it('offers one route-level Back to the header in both states', () => { + const target = backTarget(); + expect(target?.label()).toBe('Return to downloads'); + expect(target?.escapeShortcut()).toBe(true); + target?.run(); expect(host.backRequests).toBe(1); host.playbackActive.set(true); fixture.detectChanges(); - expect(query('.shell__back-button')).toBe(back); + expect(backTarget()).toBe(target); // Watch keeps the arrow's meaning: it leaves the page, it does not // close the player. Escape is the close shortcut, so the hint goes. - expect(back.getAttribute('aria-label')).toBe('Return to downloads'); - expect(back.getAttribute('aria-keyshortcuts')).toBeNull(); - expect(back.getAttribute('title')).toBe('Return to downloads'); - back.click(); + expect(target?.escapeShortcut()).toBe(false); + target?.run(); expect(host.backRequests).toBe(2); expect(host.closeRequests).toBe(0); }); - it('moves lost focus to the back control after Escape closes the player', async () => { + it('releases the header Back when the page goes away', () => { + expect(backTarget()).not.toBeNull(); + fixture.destroy(); + expect(backTarget()).toBeNull(); + }); + + it('keeps focus on the page after Escape closes the player', async () => { host.playbackActive.set(true); fixture.detectChanges(); - const back = requiredQuery('.shell__back-button'); + const shell = requiredQuery('app-portal-detail-shell'); const player = requiredQuery('.fake-player'); player.tabIndex = 0; player.focus(); @@ -315,13 +320,13 @@ describe('PortalDetailShellComponent', () => { await fixture.whenStable(); expect(host.closeRequests).toBe(1); expect(host.backRequests).toBe(0); - expect(document.activeElement).toBe(back); + expect(document.activeElement).toBe(shell); }); it('has no dead-end browse action for a host without back navigation', () => { host.backAvailable.set(false); fixture.detectChanges(); - expect(query('.shell__back-button')).toBeNull(); + expect(backTarget()).toBeNull(); requiredQuery('app-portal-detail-shell').dispatchEvent( new KeyboardEvent('keydown', { key: 'Escape', @@ -334,7 +339,7 @@ describe('PortalDetailShellComponent', () => { fixture.detectChanges(); // No route to go back to, so no arrow in watch either; the player's // own Close button and Escape remain the exits. - expect(query('.shell__back-button')).toBeNull(); + expect(backTarget()).toBeNull(); requiredQuery('app-portal-detail-shell').dispatchEvent( new KeyboardEvent('keydown', { key: 'Escape', @@ -451,67 +456,3 @@ describe('PortalDetailShellComponent', () => { expect(host.closeRequests).toBe(0); }); }); - -describe('PortalDetailShellComponent pane width', () => { - const original = globalThis.ResizeObserver; - const disconnect = jest.fn(); - // The hero observes itself too, so keep each callback with its target. - let observers: { callback: ResizeObserverCallback; target?: Element }[]; - - beforeEach(() => { - observers = []; - disconnect.mockClear(); - globalThis.ResizeObserver = class { - private readonly entry: (typeof observers)[number]; - constructor(callback: ResizeObserverCallback) { - this.entry = { callback }; - observers.push(this.entry); - } - observe(target: Element): void { - this.entry.target = target; - } - unobserve = jest.fn(); - disconnect = disconnect; - } as unknown as typeof ResizeObserver; - TestBed.configureTestingModule({ - imports: [HostComponent, TranslateModule.forRoot()], - }); - }); - - afterEach(() => { - globalThis.ResizeObserver = original; - }); - - const resize = (shell: HTMLElement, inlineSize: number): void => { - const observer = observers.find(({ target }) => target === shell); - if (!observer) throw new Error('The shell does not observe itself.'); - observer.callback( - [ - { - borderBoxSize: [{ inlineSize, blockSize: 800 }], - } as unknown as ResizeObserverEntry, - ], - {} as ResizeObserver - ); - }; - - it('trades the Back lane for a bar when the pane itself is narrow', () => { - const fixture = TestBed.createComponent(HostComponent); - fixture.detectChanges(); - const shell = (fixture.nativeElement as HTMLElement).querySelector( - 'app-portal-detail-shell' - ) as HTMLElement; - - // A 780px window leaves the pane ~402px beside the context panel. - resize(shell, 402); - expect(shell.classList).not.toContain('shell-host--compact'); - // A 641px window leaves it ~260px: too narrow for the lane. - resize(shell, 260); - expect(shell.classList).toContain('shell-host--compact'); - resize(shell, 1280); - expect(shell.classList).not.toContain('shell-host--compact'); - - fixture.destroy(); - expect(disconnect).toHaveBeenCalled(); - }); -}); diff --git a/libs/ui/components/src/lib/portal-detail-shell/portal-detail-shell.component.ts b/libs/ui/components/src/lib/portal-detail-shell/portal-detail-shell.component.ts index eac7d90cf..9eab2c209 100644 --- a/libs/ui/components/src/lib/portal-detail-shell/portal-detail-shell.component.ts +++ b/libs/ui/components/src/lib/portal-detail-shell/portal-detail-shell.component.ts @@ -2,7 +2,6 @@ import { NgTemplateOutlet } from '@angular/common'; import { afterNextRender, Component, - DestroyRef, ElementRef, Injector, computed, @@ -11,11 +10,10 @@ import { inject, input, output, - viewChild, ChangeDetectionStrategy, } from '@angular/core'; -import { MatIconModule } from '@angular/material/icon'; -import { TranslateModule } from '@ngx-translate/core'; +import { WorkspaceBackNavigationService } from '@iptvnator/portal/shared/data-access'; +import { WorkspaceBackTarget } from '@iptvnator/portal/shared/util'; import { ContentHeroComponent } from '../content-hero/content-hero.component'; import { ContentAboutComponent } from './content-about.component'; import { @@ -24,14 +22,6 @@ import { DetailTagsTemplateDirective, } from './detail-template.directives'; -/** - * Below this pane width the Back lane (16 + 40 + 16px) would leave the player - * card under ~316px, where its control row clips, so the control takes a - * sticky bar instead. The pane decides, not the viewport: beside the context - * panel a desktop pane can be narrower than a phone. - */ -const COMPACT_SHELL_WIDTH = 400; - /** * Two-state layout shell for portal VOD/series detail pages. * @@ -41,22 +31,17 @@ const COMPACT_SHELL_WIDTH = 400; * About block below the episodes slot. * * The shell owns the page scroll, the browse↔watch animation, Escape - * handling, the one sticky Back control (route-level in both states; closing - * the player is the player's own Close button and Escape), and never - * conditionally wraps the `[detail-player]` slot — the - * host's own `@if (inlinePlayback())` is the only thing that creates or - * destroys the player, so shell state changes cannot recreate it. + * handling and the page's Back action, which the workspace header renders in + * its leading slot (route-level in both states; closing the player is the + * player's own Close button and Escape). It never conditionally wraps the + * `[detail-player]` slot — the host's own `@if (inlinePlayback())` is the + * only thing that creates or destroys the player, so shell state changes + * cannot recreate it. */ @Component({ selector: 'app-portal-detail-shell', standalone: true, - imports: [ - ContentHeroComponent, - ContentAboutComponent, - NgTemplateOutlet, - MatIconModule, - TranslateModule, - ], + imports: [ContentHeroComponent, ContentAboutComponent, NgTemplateOutlet], templateUrl: './portal-detail-shell.component.html', styleUrls: ['./portal-detail-shell.component.scss'], // eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection -- Preserve pre-Angular 22 eager checking during the framework upgrade. @@ -70,17 +55,13 @@ const COMPACT_SHELL_WIDTH = 400; // on body, while preserving already-handled events and overlay guards. '(keydown.escape)': 'onEscape($event)', '[class.shell-host--watch]': 'isWatch()', - // Content columns reserve the sticky Back control's lane. - '[class.shell-host--back]': 'backAvailable()', '(document:keydown.escape)': 'onEscape($event)', }, }) export class PortalDetailShellComponent { private readonly host = inject>(ElementRef); private readonly injector = inject(Injector); - private readonly destroyRef = inject(DestroyRef); - private readonly backButton = - viewChild>('backButton'); + private readonly backNavigation = inject(WorkspaceBackNavigationService); readonly title = input(); readonly description = input(); @@ -94,7 +75,7 @@ export class PortalDetailShellComponent { /** True while inline playback is active — flips the layout to watch state. */ readonly playbackActive = input(false); - /** The sticky control in either state, or Escape in browse. */ + /** The header's Back in either state, or Escape in browse. */ readonly backClicked = output(); /** Emitted by Escape during inline playback. */ readonly closePlayerRequested = output(); @@ -107,6 +88,12 @@ export class PortalDetailShellComponent { readonly isWatch = computed(() => this.playbackActive()); + private readonly backTarget: WorkspaceBackTarget = { + label: computed(() => this.backLabel() || null), + escapeShortcut: computed(() => !this.isWatch()), + run: () => this.backClicked.emit(), + }; + constructor() { afterNextRender(() => { const element = this.host.nativeElement; @@ -120,7 +107,10 @@ export class PortalDetailShellComponent { element.focus({ preventScroll: true }); } }); - this.observeCompactWidth(); + effect((onCleanup) => { + if (!this.backAvailable()) return; + onCleanup(this.backNavigation.register(this.backTarget)); + }); let wasWatch = false; effect(() => { const watch = this.isWatch(); @@ -227,15 +217,15 @@ export class PortalDetailShellComponent { afterNextRender( () => { const element = this.host.nativeElement; + // The page keeps focus, so the next Escape still unwinds it + // and the arrow keys still scroll it. if ( element.isConnected && !element.closest('[inert]') && element.ownerDocument.activeElement === element.ownerDocument.body ) { - (this.backButton()?.nativeElement ?? element).focus({ - preventScroll: true, - }); + element.focus({ preventScroll: true }); } }, { injector: this.injector } @@ -254,24 +244,4 @@ export class PortalDetailShellComponent { } element.scrollTo({ top: 0, behavior: 'auto' }); } - - /** - * Toggles `shell-host--compact` straight on the host, so the class lands - * in the same frame as the resize instead of after a change-detection - * pass. The border box keeps the threshold independent of scrollbar width. - */ - private observeCompactWidth(): void { - if (typeof ResizeObserver === 'undefined') return; - const element = this.host.nativeElement; - const observer = new ResizeObserver(([entry]) => { - const width = - entry?.borderBoxSize?.[0]?.inlineSize ?? element.offsetWidth; - element.classList.toggle( - 'shell-host--compact', - width < COMPACT_SHELL_WIDTH - ); - }); - observer.observe(element); - this.destroyRef.onDestroy(() => observer.disconnect()); - } } diff --git a/libs/workspace/shell/feature/src/lib/workspace-shell/components/workspace-shell-header/workspace-shell-header.component.html b/libs/workspace/shell/feature/src/lib/workspace-shell/components/workspace-shell-header/workspace-shell-header.component.html index 0427a6a6c..0fd5d1429 100644 --- a/libs/workspace/shell/feature/src/lib/workspace-shell/components/workspace-shell-header/workspace-shell-header.component.html +++ b/libs/workspace/shell/feature/src/lib/workspace-shell/components/workspace-shell-header/workspace-shell-header.component.html @@ -1,8 +1,31 @@
+ + @if (backTarget(); as back) { + @let backLabel = back.label() || ('BACK' | translate); + + } + - @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()) {