From 8fabb881068a95d09e1e2862006044de4c9c2db2 Mon Sep 17 00:00:00 2001 From: 4gray <4gray@users.noreply.github.com> Date: Sat, 10 Oct 2026 09:45:21 +0200 Subject: [PATCH] fix(collections): one confirmed Clear recently viewed action (#1865) A playlist's own recently viewed page (/workspace///recent, opened from the dashboard's recently viewed rails) showed two clear buttons: the page's "Clear recently viewed ", which confirms through createClearCollectionAction, and a header delete_sweep button that cleared every tab of the playlist at once without asking. Remove the header bulk action end to end, and the unreachable, unconfirmed clear button of the M3U channel list's recent view, so every clear goes through createClearCollectionAction. Drop the two i18n keys only those buttons used. E2E: a shared helper asserts one clear control per page and one confirmation per press; the M3U, Xtream and Stalker recent tests cancel first (rows stay), then clear once on the playlist's own recently viewed page. Co-authored-by: Claude Opus 5.5 --- .changes/collections-clear-recently-viewed.md | 8 ++ .../electron-backend-e2e/src/downloads.e2e.ts | 23 +--- .../src/live-sidebar-collapse.e2e.ts | 9 +- .../src/recent-clear.e2e-support.ts | 74 ++++++++++++ apps/electron-backend-e2e/src/recent.e2e.ts | 53 ++++++-- .../src/workspace-route.e2e-support.ts | 28 +++++ apps/web/src/assets/i18n/ar.json | 2 - apps/web/src/assets/i18n/ary.json | 2 - apps/web/src/assets/i18n/by.json | 2 - apps/web/src/assets/i18n/de.json | 2 - apps/web/src/assets/i18n/el.json | 2 - apps/web/src/assets/i18n/en.json | 2 - apps/web/src/assets/i18n/es.json | 2 - apps/web/src/assets/i18n/fr.json | 2 - apps/web/src/assets/i18n/hu.json | 2 - apps/web/src/assets/i18n/it.json | 2 - apps/web/src/assets/i18n/ja.json | 2 - apps/web/src/assets/i18n/ko.json | 2 - apps/web/src/assets/i18n/nl.json | 2 - apps/web/src/assets/i18n/pl.json | 2 - apps/web/src/assets/i18n/pt.json | 2 - apps/web/src/assets/i18n/ru.json | 2 - apps/web/src/assets/i18n/tr.json | 2 - apps/web/src/assets/i18n/zh.json | 2 - apps/web/src/assets/i18n/zhtw.json | 2 - docs/architecture/iptvnator-ui-guidelines.md | 7 ++ docs/architecture/workspace-shell.md | 3 +- .../unified-collection-clear-action.spec.ts | 92 ++++++++++++++ .../channel-list-container.component.html | 10 -- .../channel-list-container.component.ts | 22 ---- .../workspace-shell-header.component.html | 15 +-- .../workspace-shell-header.component.ts | 13 +- .../helpers/workspace-shell-constants.ts | 12 -- .../helpers/workspace-shell-route-utils.ts | 12 -- .../workspace-shell-header.service.ts | 114 +----------------- .../services/workspace-shell.facade.spec.ts | 37 ------ .../services/workspace-shell.facade.ts | 7 -- .../workspace-shell.component.html | 2 - .../workspace-shell.component.spec.ts | 9 +- 39 files changed, 261 insertions(+), 327 deletions(-) create mode 100644 .changes/collections-clear-recently-viewed.md create mode 100644 apps/electron-backend-e2e/src/recent-clear.e2e-support.ts create mode 100644 apps/electron-backend-e2e/src/workspace-route.e2e-support.ts create mode 100644 libs/portal/shared/ui/src/lib/components/unified-collection/unified-collection-clear-action.spec.ts diff --git a/.changes/collections-clear-recently-viewed.md b/.changes/collections-clear-recently-viewed.md new file mode 100644 index 000000000..401694448 --- /dev/null +++ b/.changes/collections-clear-recently-viewed.md @@ -0,0 +1,8 @@ +--- +type: fix +area: collections +--- + +A playlist's recently viewed page now has a single "Clear recently viewed" +button, and it always asks before clearing. The second button in the header, +which cleared every tab at once without asking, is gone. diff --git a/apps/electron-backend-e2e/src/downloads.e2e.ts b/apps/electron-backend-e2e/src/downloads.e2e.ts index d21501779..b8b42a983 100644 --- a/apps/electron-backend-e2e/src/downloads.e2e.ts +++ b/apps/electron-backend-e2e/src/downloads.e2e.ts @@ -32,34 +32,13 @@ import { RANGE_SERVER_ETAG, startDownload, } from './downloads.e2e-support'; +import { navigateWithinWorkspace } from './workspace-route.e2e-support'; async function openDownloadsPage(page: Page): Promise { await page.getByRole('button', { name: 'Open downloads' }).click(); await page.waitForURL(/\/workspace\/downloads(?:\?.*)?$/); } -async function navigateWithinWorkspace( - page: Page, - path: string -): Promise { - const targetPathname = await page.evaluate((target) => { - const targetUrl = new URL(window.location.href); - const workspaceIndex = targetUrl.pathname.lastIndexOf('/workspace'); - const rendererPath = - workspaceIndex >= 0 - ? targetUrl.pathname.slice(0, workspaceIndex) - : targetUrl.pathname.replace(/\/$/, ''); - - targetUrl.pathname = `${rendererPath}${target}`; - targetUrl.search = ''; - targetUrl.hash = ''; - window.history.pushState(null, '', targetUrl); - window.dispatchEvent(new PopStateEvent('popstate')); - return targetUrl.pathname; - }, path); - await page.waitForURL((url) => url.pathname === targetPathname); -} - async function getPlaylistId(page: Page, title: string): Promise { const playlists = await page.evaluate( async () => (await window.electron?.dbGetAppPlaylistMetas?.()) ?? [] 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 40084c0a6..e32ba3ddb 100644 --- a/apps/electron-backend-e2e/src/live-sidebar-collapse.e2e.ts +++ b/apps/electron-backend-e2e/src/live-sidebar-collapse.e2e.ts @@ -24,6 +24,7 @@ import { routePlayableStreams, startAndConfirmPlayback, } from './playable-stream-fixture'; +import { clearRecentItems } from './recent-clear.e2e-support'; /** * Issue #1458, second report: "all channels disappear after clearing the @@ -160,13 +161,7 @@ test.describe('Live channel rail collapse (#1458)', () => { channelItemByTitle(app.mainWindow, 'Channel Alpha').first() ).toBeVisible({ timeout: 20000 }); - await app.mainWindow - .getByRole('button', { name: 'Clear recently viewed Live TV' }) - .click(); - await app.mainWindow - .locator('mat-dialog-container') - .getByRole('button', { name: 'Clear', exact: true }) - .click(); + await clearRecentItems(app.mainWindow, 'Live TV'); await expect( channelItemByTitle(app.mainWindow, 'Channel Alpha') ).toHaveCount(0); diff --git a/apps/electron-backend-e2e/src/recent-clear.e2e-support.ts b/apps/electron-backend-e2e/src/recent-clear.e2e-support.ts new file mode 100644 index 000000000..31ad0d0c0 --- /dev/null +++ b/apps/electron-backend-e2e/src/recent-clear.e2e-support.ts @@ -0,0 +1,74 @@ +import type { Locator, Page } from '@playwright/test'; +import { expect } from './electron-test-fixtures'; +import { navigateWithinWorkspace } from './workspace-route.e2e-support'; + +/** + * `/workspace///recent` of the source the page is in: the + * source's own recently viewed page, which the dashboard's recently viewed + * rails open. + */ +export function sourceRecentPath(page: Page): string { + const source = /\/workspace\/(?:xtreams|stalker|playlists)\/[^/]+/.exec( + new URL(page.url()).pathname + ); + if (!source) { + throw new Error(`The page is not inside a source: ${page.url()}`); + } + return `${source[0]}/recent`; +} + +export async function openSourceRecent( + page: Page, + recentPath: string +): Promise { + await navigateWithinWorkspace(page, recentPath); +} + +/** Every control on the page that clears recently viewed items. */ +function clearRecentControls(page: Page): Locator { + return page.getByRole('button', { name: /^Clear recently viewed\b/ }); +} + +/** + * Presses the page's only "Clear recently viewed " control and returns + * the confirmation it opens: one control per page, one question per press. + */ +async function askToClearRecent( + page: Page, + typeLabel: string +): Promise { + await expect(clearRecentControls(page)).toHaveCount(1); + await page + .getByRole('button', { + name: `Clear recently viewed ${typeLabel}`, + exact: true, + }) + .click(); + + const dialogs = page.locator('mat-dialog-container'); + await expect(dialogs).toHaveCount(1); + await expect( + dialogs.getByRole('heading', { + name: `Clear recently viewed ${typeLabel}?`, + }) + ).toBeVisible(); + return dialogs; +} + +export async function cancelClearRecentItems( + page: Page, + typeLabel: string +): Promise { + const dialog = await askToClearRecent(page, typeLabel); + await dialog.getByRole('button', { name: 'Cancel', exact: true }).click(); + await expect(page.locator('mat-dialog-container')).toHaveCount(0); +} + +export async function clearRecentItems( + page: Page, + typeLabel: string +): Promise { + const dialog = await askToClearRecent(page, typeLabel); + await dialog.getByRole('button', { name: 'Clear', exact: true }).click(); + await expect(page.locator('mat-dialog-container')).toHaveCount(0); +} diff --git a/apps/electron-backend-e2e/src/recent.e2e.ts b/apps/electron-backend-e2e/src/recent.e2e.ts index 56f95b84f..4ae1df9ca 100644 --- a/apps/electron-backend-e2e/src/recent.e2e.ts +++ b/apps/electron-backend-e2e/src/recent.e2e.ts @@ -45,6 +45,12 @@ import { routePlayableStreams, startAndConfirmPlayback, } from './playable-stream-fixture'; +import { + cancelClearRecentItems, + clearRecentItems, + openSourceRecent, + sourceRecentPath, +} from './recent-clear.e2e-support'; test.describe('Electron Recently Viewed', () => { test('keeps unified live detail open when re-clicking the active M3U recent item', async ({ @@ -98,7 +104,7 @@ test.describe('Electron Recently Viewed', () => { } }); - test('@persistence @m3u @electron tracks M3U recent channels in newest-first order, supports all-playlists scope, and persists favorites after restart', async ({ + test('@persistence @m3u @electron tracks M3U recent channels in newest-first order, supports all-playlists scope, persists favorites after restart, and clears only after one confirmation', async ({ dataDir, }) => { const playlistTitle = 'm3u-recent-source.m3u'; @@ -165,6 +171,7 @@ test.describe('Electron Recently Viewed', () => { .first() .click(); await waitForM3uCatalog(app.mainWindow); + const recentPath = sourceRecentPath(app.mainWindow); await openPlaylistRecent(app.mainWindow); await switchUnifiedCollectionScope(app.mainWindow, 'All playlists'); await expect @@ -175,6 +182,19 @@ test.describe('Electron Recently Viewed', () => { await expect( channelItemByTitle(app.mainWindow, 'Recent Channel Two').first() ).toBeVisible({ timeout: 20000 }); + + await openSourceRecent(app.mainWindow, recentPath); + await expect + .poll(() => visibleLiveTitles(app.mainWindow)) + .toEqual(['Recent Channel Two', 'Recent Channel One']); + await cancelClearRecentItems(app.mainWindow, 'Live TV'); + await expect + .poll(() => visibleLiveTitles(app.mainWindow)) + .toEqual(['Recent Channel Two', 'Recent Channel One']); + await clearRecentItems(app.mainWindow, 'Live TV'); + await expect + .poll(() => visibleLiveTitles(app.mainWindow)) + .toEqual([]); } finally { await closeElectronApp(app); } @@ -340,6 +360,7 @@ test.describe('Electron Recently Viewed', () => { await openSources(app.mainWindow); await sourceRowByTitle(app.mainWindow, portalTitle).first().click(); await waitForXtreamWorkspaceReady(app.mainWindow); + const recentPath = sourceRecentPath(app.mainWindow); await openPlaylistRecent(app.mainWindow); await switchUnifiedCollectionScope(app.mainWindow, 'All playlists'); await switchUnifiedCollectionContent(app.mainWindow, 'Live TV'); @@ -351,7 +372,12 @@ test.describe('Electron Recently Viewed', () => { await switchUnifiedCollectionContent(app.mainWindow, 'Series'); await expectVisibleContentCardTitle(app.mainWindow, seriesTitle); + await openSourceRecent(app.mainWindow, recentPath); await switchUnifiedCollectionContent(app.mainWindow, 'Live TV'); + await cancelClearRecentItems(app.mainWindow, 'Live TV'); + await expect( + channelItemByTitle(app.mainWindow, liveTitle) + ).toHaveCount(1); await clearRecentItems(app.mainWindow, 'Live TV'); await expect( channelItemByTitle(app.mainWindow, liveTitle) @@ -496,7 +522,7 @@ test.describe('Electron Recently Viewed', () => { } }); - test('@persistence @stalker @electron tracks Stalker live, movie, and series history across playlist and all-playlists scope, and preserves it after restart', async ({ + test('@persistence @stalker @electron tracks Stalker live, movie, and series history across playlist and all-playlists scope, preserves it after restart, and clears only after one confirmation', async ({ dataDir, request, }) => { @@ -593,6 +619,7 @@ test.describe('Electron Recently Viewed', () => { await openSources(app.mainWindow); await sourceRowByTitle(app.mainWindow, portalTitle).first().click(); await waitForStalkerCatalog(app.mainWindow); + const recentPath = sourceRecentPath(app.mainWindow); await openPlaylistRecent(app.mainWindow); await switchUnifiedCollectionScope(app.mainWindow, 'All playlists'); await switchUnifiedCollectionContent(app.mainWindow, 'Live TV'); @@ -607,6 +634,18 @@ test.describe('Electron Recently Viewed', () => { await expect( contentCardByTitle(app.mainWindow, seriesTitle) ).toHaveCount(1); + + await openSourceRecent(app.mainWindow, recentPath); + await switchUnifiedCollectionContent(app.mainWindow, 'Live TV'); + await cancelClearRecentItems(app.mainWindow, 'Live TV'); + await expect( + channelItemByTitle(app.mainWindow, liveTitle) + ).toHaveCount(1); + await clearRecentItems(app.mainWindow, 'Live TV'); + await expect( + channelItemByTitle(app.mainWindow, liveTitle) + ).toHaveCount(0); + await expectVisibleContentCardTitle(app.mainWindow, movieTitle); } finally { await closeElectronApp(app); } @@ -709,16 +748,6 @@ const xtreamCredentials = { password: defaultXtreamPassword, }; -async function clearRecentItems(page: Page, typeLabel: string): Promise { - await page - .getByRole('button', { name: `Clear recently viewed ${typeLabel}` }) - .click(); - await page - .locator('mat-dialog-container') - .getByRole('button', { name: 'Clear', exact: true }) - .click(); -} - async function expectUnifiedLiveDetailOpen( page: Page, title: string diff --git a/apps/electron-backend-e2e/src/workspace-route.e2e-support.ts b/apps/electron-backend-e2e/src/workspace-route.e2e-support.ts new file mode 100644 index 000000000..a33b846c9 --- /dev/null +++ b/apps/electron-backend-e2e/src/workspace-route.e2e-support.ts @@ -0,0 +1,28 @@ +import type { Page } from '@playwright/test'; + +/** + * Moves the renderer to a workspace route without reloading it, as a router + * link would. The packaged renderer lives below its own path, so only the + * part from `/workspace` on is replaced. + */ +export async function navigateWithinWorkspace( + page: Page, + path: string +): Promise { + const targetPathname = await page.evaluate((target) => { + const targetUrl = new URL(window.location.href); + const workspaceIndex = targetUrl.pathname.lastIndexOf('/workspace'); + const rendererPath = + workspaceIndex >= 0 + ? targetUrl.pathname.slice(0, workspaceIndex) + : targetUrl.pathname.replace(/\/$/, ''); + + targetUrl.pathname = `${rendererPath}${target}`; + targetUrl.search = ''; + targetUrl.hash = ''; + window.history.pushState(null, '', targetUrl); + window.dispatchEvent(new PopStateEvent('popstate')); + return targetUrl.pathname; + }, path); + await page.waitForURL((url) => url.pathname === targetPathname); +} diff --git a/apps/web/src/assets/i18n/ar.json b/apps/web/src/assets/i18n/ar.json index 949a90c15..0d1ab62b0 100644 --- a/apps/web/src/assets/i18n/ar.json +++ b/apps/web/src/assets/i18n/ar.json @@ -1701,8 +1701,6 @@ "CLEAR_COMPLETED_DOWNLOADS_ALL_ARIA": "مسح التنزيلات المكتملة لجميع قوائم التشغيل", "CLEAR_COMPLETED_DOWNLOADS_THIS_PLAYLIST": "مسح التنزيلات المكتملة (قائمة التشغيل هذه)", "CLEAR_COMPLETED_DOWNLOADS_THIS_PLAYLIST_ARIA": "مسح التنزيلات المكتملة لقائمة التشغيل هذه", - "CLEAR_RECENTLY_VIEWED_SECTION": "مسح ما تمت مشاهدته مؤخرًا", - "CLEAR_RECENTLY_VIEWED_SECTION_ARIA": "مسح العناصر المشاهدة مؤخرًا في هذا القسم", "CLEAR_RECENTLY_VIEWED_TYPE": "مسح ما تمت مشاهدته مؤخرًا من {{type}}", "CLEAR_RECENTLY_VIEWED_DIALOG_TITLE": "مسح ما تمت مشاهدته مؤخرًا من {{type}}؟", "CLEAR_RECENTLY_VIEWED_DIALOG_MESSAGE_PLAYLIST": "ستتم إزالة جميع عناصر {{type}} من قائمة التشغيل هذه من سجل ما تمت مشاهدته مؤخرًا. لا يمكن التراجع عن هذا الإجراء.", diff --git a/apps/web/src/assets/i18n/ary.json b/apps/web/src/assets/i18n/ary.json index 7bcc24fc2..3720b8271 100644 --- a/apps/web/src/assets/i18n/ary.json +++ b/apps/web/src/assets/i18n/ary.json @@ -1701,8 +1701,6 @@ "CLEAR_COMPLETED_DOWNLOADS_ALL_ARIA": "مسح التحميلات المكتملة لجميع قوائم التشغيل", "CLEAR_COMPLETED_DOWNLOADS_THIS_PLAYLIST": "مسح التحميلات المكتملة (هاد قائمة التشغيل)", "CLEAR_COMPLETED_DOWNLOADS_THIS_PLAYLIST_ARIA": "مسح التحميلات المكتملة لهاد قائمة التشغيل", - "CLEAR_RECENTLY_VIEWED_SECTION": "مسح المتشافين مؤخراً", - "CLEAR_RECENTLY_VIEWED_SECTION_ARIA": "مسح المتشافين مؤخراً لهاد القسم", "CLEAR_RECENTLY_VIEWED_TYPE": "مسح {{type}} المتشافين مؤخراً", "CLEAR_RECENTLY_VIEWED_DIALOG_TITLE": "مسح {{type}} المتشافين مؤخراً؟", "CLEAR_RECENTLY_VIEWED_DIALOG_MESSAGE_PLAYLIST": "جميع عناصر {{type}} من هاد قائمة التشغيل غادي يتحيدو من سجل المتشافين مؤخراً ديالك. هاد العملية ما تقدرش ترجع.", diff --git a/apps/web/src/assets/i18n/by.json b/apps/web/src/assets/i18n/by.json index 48e152555..c127d968a 100644 --- a/apps/web/src/assets/i18n/by.json +++ b/apps/web/src/assets/i18n/by.json @@ -1701,8 +1701,6 @@ "CLEAR_COMPLETED_DOWNLOADS_ALL_ARIA": "Ачысціць завершаныя спампаванні для ўсіх плэйлістоў", "CLEAR_COMPLETED_DOWNLOADS_THIS_PLAYLIST": "Ачысціць завершаныя спампаванні (гэты плэйліст)", "CLEAR_COMPLETED_DOWNLOADS_THIS_PLAYLIST_ARIA": "Ачысціць завершаныя спампаванні для гэтага плэйліста", - "CLEAR_RECENTLY_VIEWED_SECTION": "Ачысціць нядаўна праглянутае", - "CLEAR_RECENTLY_VIEWED_SECTION_ARIA": "Ачысціць нядаўна прагледжанае ў гэтым раздзеле", "CLEAR_RECENTLY_VIEWED_TYPE": "Ачысціць нядаўна праглянутае: {{type}}", "CLEAR_RECENTLY_VIEWED_DIALOG_TITLE": "Ачысціць нядаўна праглянутае: {{type}}?", "CLEAR_RECENTLY_VIEWED_DIALOG_MESSAGE_PLAYLIST": "Усе элементы тыпу {{type}} з гэтага плэйліста будуць выдалены з гісторыі нядаўна праглянутага. Гэта дзеянне нельга адмяніць.", diff --git a/apps/web/src/assets/i18n/de.json b/apps/web/src/assets/i18n/de.json index e79d007c1..8475d6bf2 100644 --- a/apps/web/src/assets/i18n/de.json +++ b/apps/web/src/assets/i18n/de.json @@ -1701,8 +1701,6 @@ "CLEAR_COMPLETED_DOWNLOADS_ALL_ARIA": "Abgeschlossene Downloads für alle Playlists löschen", "CLEAR_COMPLETED_DOWNLOADS_THIS_PLAYLIST": "Abgeschlossene Downloads löschen (diese Playlist)", "CLEAR_COMPLETED_DOWNLOADS_THIS_PLAYLIST_ARIA": "Abgeschlossene Downloads für diese Playlist löschen", - "CLEAR_RECENTLY_VIEWED_SECTION": "Zuletzt angesehen löschen", - "CLEAR_RECENTLY_VIEWED_SECTION_ARIA": "Kürzlich angesehene Elemente für diesen Abschnitt löschen", "CLEAR_RECENTLY_VIEWED_TYPE": "Zuletzt angesehen ({{type}}) löschen", "CLEAR_RECENTLY_VIEWED_DIALOG_TITLE": "Zuletzt angesehen ({{type}}) löschen?", "CLEAR_RECENTLY_VIEWED_DIALOG_MESSAGE_PLAYLIST": "Alle {{type}}-Einträge aus dieser Playlist werden aus dem Verlauf entfernt. Diese Aktion kann nicht rückgängig gemacht werden.", diff --git a/apps/web/src/assets/i18n/el.json b/apps/web/src/assets/i18n/el.json index 62deab7ee..83d5f05ed 100644 --- a/apps/web/src/assets/i18n/el.json +++ b/apps/web/src/assets/i18n/el.json @@ -1701,8 +1701,6 @@ "CLEAR_COMPLETED_DOWNLOADS_ALL_ARIA": "Εκκαθάριση ολοκληρωμένων λήψεων για όλες τις λίστες αναπαραγωγής", "CLEAR_COMPLETED_DOWNLOADS_THIS_PLAYLIST": "Εκκαθάριση ολοκληρωμένων λήψεων (αυτή η λίστα αναπαραγωγής)", "CLEAR_COMPLETED_DOWNLOADS_THIS_PLAYLIST_ARIA": "Εκκαθάριση ολοκληρωμένων λήψεων για αυτή τη λίστα αναπαραγωγής", - "CLEAR_RECENTLY_VIEWED_SECTION": "Εκκαθάριση πρόσφατα προβληθέντων", - "CLEAR_RECENTLY_VIEWED_SECTION_ARIA": "Εκκαθάριση πρόσφατων προβολών για αυτή την ενότητα", "CLEAR_RECENTLY_VIEWED_TYPE": "Εκκαθάριση πρόσφατα προβληθέντων {{type}}", "CLEAR_RECENTLY_VIEWED_DIALOG_TITLE": "Εκκαθάριση πρόσφατα προβληθέντων {{type}};", "CLEAR_RECENTLY_VIEWED_DIALOG_MESSAGE_PLAYLIST": "Όλα τα στοιχεία {{type}} από αυτή τη λίστα αναπαραγωγής θα αφαιρεθούν από το ιστορικό πρόσφατα προβληθέντων. Αυτή η ενέργεια δεν μπορεί να αναιρεθεί.", diff --git a/apps/web/src/assets/i18n/en.json b/apps/web/src/assets/i18n/en.json index dfbf1a5d4..95277e829 100644 --- a/apps/web/src/assets/i18n/en.json +++ b/apps/web/src/assets/i18n/en.json @@ -1701,8 +1701,6 @@ "CLEAR_COMPLETED_DOWNLOADS_ALL_ARIA": "Clear completed downloads for all playlists", "CLEAR_COMPLETED_DOWNLOADS_THIS_PLAYLIST": "Clear completed downloads (this playlist)", "CLEAR_COMPLETED_DOWNLOADS_THIS_PLAYLIST_ARIA": "Clear completed downloads for this playlist", - "CLEAR_RECENTLY_VIEWED_SECTION": "Clear recently viewed", - "CLEAR_RECENTLY_VIEWED_SECTION_ARIA": "Clear recently viewed for this section", "CLEAR_RECENTLY_VIEWED_TYPE": "Clear recently viewed {{type}}", "CLEAR_RECENTLY_VIEWED_DIALOG_TITLE": "Clear recently viewed {{type}}?", "CLEAR_RECENTLY_VIEWED_DIALOG_MESSAGE_PLAYLIST": "All {{type}} items from this playlist will be removed from your recently viewed history. This action cannot be undone.", diff --git a/apps/web/src/assets/i18n/es.json b/apps/web/src/assets/i18n/es.json index 4fbc4edad..bf6d47b02 100644 --- a/apps/web/src/assets/i18n/es.json +++ b/apps/web/src/assets/i18n/es.json @@ -1701,8 +1701,6 @@ "CLEAR_COMPLETED_DOWNLOADS_ALL_ARIA": "Borrar descargas completadas de todas las listas", "CLEAR_COMPLETED_DOWNLOADS_THIS_PLAYLIST": "Borrar descargas completadas (esta lista)", "CLEAR_COMPLETED_DOWNLOADS_THIS_PLAYLIST_ARIA": "Borrar descargas completadas de esta lista", - "CLEAR_RECENTLY_VIEWED_SECTION": "Borrar vistos recientemente", - "CLEAR_RECENTLY_VIEWED_SECTION_ARIA": "Borrar vistos recientemente de esta sección", "CLEAR_RECENTLY_VIEWED_TYPE": "Borrar vistos recientemente {{type}}", "CLEAR_RECENTLY_VIEWED_DIALOG_TITLE": "¿Borrar vistos recientemente {{type}}?", "CLEAR_RECENTLY_VIEWED_DIALOG_MESSAGE_PLAYLIST": "Todos los elementos {{type}} de esta lista se eliminarán de tu historial de vistos recientemente. Esta acción no se puede deshacer.", diff --git a/apps/web/src/assets/i18n/fr.json b/apps/web/src/assets/i18n/fr.json index 24b1e3724..6e3698fd2 100644 --- a/apps/web/src/assets/i18n/fr.json +++ b/apps/web/src/assets/i18n/fr.json @@ -1701,8 +1701,6 @@ "CLEAR_COMPLETED_DOWNLOADS_ALL_ARIA": "Effacer les téléchargements terminés pour toutes les listes de lecture", "CLEAR_COMPLETED_DOWNLOADS_THIS_PLAYLIST": "Effacer les téléchargements terminés (cette liste)", "CLEAR_COMPLETED_DOWNLOADS_THIS_PLAYLIST_ARIA": "Effacer les téléchargements terminés pour cette liste de lecture", - "CLEAR_RECENTLY_VIEWED_SECTION": "Effacer les éléments récemment consultés", - "CLEAR_RECENTLY_VIEWED_SECTION_ARIA": "Effacer les éléments récemment consultés de cette section", "CLEAR_RECENTLY_VIEWED_TYPE": "Effacer les {{type}} récemment consultés", "CLEAR_RECENTLY_VIEWED_DIALOG_TITLE": "Effacer les {{type}} récemment consultés ?", "CLEAR_RECENTLY_VIEWED_DIALOG_MESSAGE_PLAYLIST": "Tous les éléments {{type}} de cette liste de lecture seront retirés de votre historique récemment consulté. Cette action est irréversible.", diff --git a/apps/web/src/assets/i18n/hu.json b/apps/web/src/assets/i18n/hu.json index 76cc394a6..adf6532df 100644 --- a/apps/web/src/assets/i18n/hu.json +++ b/apps/web/src/assets/i18n/hu.json @@ -1701,8 +1701,6 @@ "CLEAR_COMPLETED_DOWNLOADS_ALL_ARIA": "Az összes lejátszási listához tartozó befejezett letöltési bejegyzések eltávolítása", "CLEAR_COMPLETED_DOWNLOADS_THIS_PLAYLIST": "Befejezett letöltési bejegyzések eltávolítása – jelenlegi lejátszási lista", "CLEAR_COMPLETED_DOWNLOADS_THIS_PLAYLIST_ARIA": "A jelenlegi lejátszási listához tartozó befejezett letöltési bejegyzések eltávolítása", - "CLEAR_RECENTLY_VIEWED_SECTION": "Megtekintési előzmények törlése", - "CLEAR_RECENTLY_VIEWED_SECTION_ARIA": "A szakasz legutóbbi megtekintéseinek törlése", "CLEAR_RECENTLY_VIEWED_TYPE": "Megtekintési előzmények törlése – {{type}}", "CLEAR_RECENTLY_VIEWED_DIALOG_TITLE": "Törli ezeket a megtekintési előzményeket: {{type}}?", "CLEAR_RECENTLY_VIEWED_DIALOG_MESSAGE_PLAYLIST": "A lejátszási lista következő tartalomtípusba tartozó összes eleme törlődik a megtekintési előzményekből: {{type}}. A művelet nem vonható vissza.", diff --git a/apps/web/src/assets/i18n/it.json b/apps/web/src/assets/i18n/it.json index 0351826a2..a497a72e1 100644 --- a/apps/web/src/assets/i18n/it.json +++ b/apps/web/src/assets/i18n/it.json @@ -1701,8 +1701,6 @@ "CLEAR_COMPLETED_DOWNLOADS_ALL_ARIA": "Cancella i download completati per tutte le playlist", "CLEAR_COMPLETED_DOWNLOADS_THIS_PLAYLIST": "Cancella download completati (questa playlist)", "CLEAR_COMPLETED_DOWNLOADS_THIS_PLAYLIST_ARIA": "Cancella i download completati per questa playlist", - "CLEAR_RECENTLY_VIEWED_SECTION": "Cancella visti di recente", - "CLEAR_RECENTLY_VIEWED_SECTION_ARIA": "Cancella visti di recente per questa sezione", "CLEAR_RECENTLY_VIEWED_TYPE": "Cancella visti di recente: {{type}}", "CLEAR_RECENTLY_VIEWED_DIALOG_TITLE": "Cancellare visti di recente: {{type}}?", "CLEAR_RECENTLY_VIEWED_DIALOG_MESSAGE_PLAYLIST": "Tutti gli elementi {{type}} di questa playlist verranno rimossi dalla cronologia dei visti di recente. L'azione non può essere annullata.", diff --git a/apps/web/src/assets/i18n/ja.json b/apps/web/src/assets/i18n/ja.json index acd30eb5d..edd8be7a3 100644 --- a/apps/web/src/assets/i18n/ja.json +++ b/apps/web/src/assets/i18n/ja.json @@ -1701,8 +1701,6 @@ "CLEAR_COMPLETED_DOWNLOADS_ALL_ARIA": "すべてのプレイリストの完了済みダウンロードをクリア", "CLEAR_COMPLETED_DOWNLOADS_THIS_PLAYLIST": "完了済みダウンロードをクリア(このプレイリスト)", "CLEAR_COMPLETED_DOWNLOADS_THIS_PLAYLIST_ARIA": "このプレイリストの完了済みダウンロードをクリア", - "CLEAR_RECENTLY_VIEWED_SECTION": "最近表示した項目をクリア", - "CLEAR_RECENTLY_VIEWED_SECTION_ARIA": "このセクションの最近視聴した項目をクリア", "CLEAR_RECENTLY_VIEWED_TYPE": "最近表示した{{type}}をクリア", "CLEAR_RECENTLY_VIEWED_DIALOG_TITLE": "最近表示した{{type}}をクリアしますか?", "CLEAR_RECENTLY_VIEWED_DIALOG_MESSAGE_PLAYLIST": "このプレイリストのすべての{{type}}項目が、最近表示した履歴から削除されます。この操作は取り消せません。", diff --git a/apps/web/src/assets/i18n/ko.json b/apps/web/src/assets/i18n/ko.json index 4a52f1694..b85d35f3a 100644 --- a/apps/web/src/assets/i18n/ko.json +++ b/apps/web/src/assets/i18n/ko.json @@ -1701,8 +1701,6 @@ "CLEAR_COMPLETED_DOWNLOADS_ALL_ARIA": "모든 재생목록의 완료된 다운로드 지우기", "CLEAR_COMPLETED_DOWNLOADS_THIS_PLAYLIST": "완료된 다운로드 지우기 (이 재생목록)", "CLEAR_COMPLETED_DOWNLOADS_THIS_PLAYLIST_ARIA": "이 재생목록의 완료된 다운로드 지우기", - "CLEAR_RECENTLY_VIEWED_SECTION": "최근 본 항목 지우기", - "CLEAR_RECENTLY_VIEWED_SECTION_ARIA": "이 섹션의 최근 시청 항목 지우기", "CLEAR_RECENTLY_VIEWED_TYPE": "최근 본 {{type}} 지우기", "CLEAR_RECENTLY_VIEWED_DIALOG_TITLE": "최근 본 {{type}}을(를) 지우시겠습니까?", "CLEAR_RECENTLY_VIEWED_DIALOG_MESSAGE_PLAYLIST": "이 재생목록의 모든 {{type}} 항목이 최근 본 기록에서 제거됩니다. 이 작업은 되돌릴 수 없습니다.", diff --git a/apps/web/src/assets/i18n/nl.json b/apps/web/src/assets/i18n/nl.json index e40ada1a4..7b81617fc 100644 --- a/apps/web/src/assets/i18n/nl.json +++ b/apps/web/src/assets/i18n/nl.json @@ -1701,8 +1701,6 @@ "CLEAR_COMPLETED_DOWNLOADS_ALL_ARIA": "Voltooide downloads wissen voor alle afspeellijsten", "CLEAR_COMPLETED_DOWNLOADS_THIS_PLAYLIST": "Voltooide downloads wissen (deze afspeellijst)", "CLEAR_COMPLETED_DOWNLOADS_THIS_PLAYLIST_ARIA": "Voltooide downloads wissen voor deze afspeellijst", - "CLEAR_RECENTLY_VIEWED_SECTION": "Recent bekeken wissen", - "CLEAR_RECENTLY_VIEWED_SECTION_ARIA": "Recent bekeken voor deze sectie wissen", "CLEAR_RECENTLY_VIEWED_TYPE": "Recent bekeken {{type}} wissen", "CLEAR_RECENTLY_VIEWED_DIALOG_TITLE": "Recent bekeken {{type}} wissen?", "CLEAR_RECENTLY_VIEWED_DIALOG_MESSAGE_PLAYLIST": "Alle {{type}}-items uit deze afspeellijst worden verwijderd uit je recent bekeken-geschiedenis. Deze actie kan niet ongedaan worden gemaakt.", diff --git a/apps/web/src/assets/i18n/pl.json b/apps/web/src/assets/i18n/pl.json index f6ded36ec..2f4b1cf83 100644 --- a/apps/web/src/assets/i18n/pl.json +++ b/apps/web/src/assets/i18n/pl.json @@ -1701,8 +1701,6 @@ "CLEAR_COMPLETED_DOWNLOADS_ALL_ARIA": "Wyczyść ukończone pobrania dla wszystkich list odtwarzania", "CLEAR_COMPLETED_DOWNLOADS_THIS_PLAYLIST": "Wyczyść ukończone pobrania (ta lista odtwarzania)", "CLEAR_COMPLETED_DOWNLOADS_THIS_PLAYLIST_ARIA": "Wyczyść ukończone pobrania dla tej listy odtwarzania", - "CLEAR_RECENTLY_VIEWED_SECTION": "Wyczyść ostatnio oglądane", - "CLEAR_RECENTLY_VIEWED_SECTION_ARIA": "Wyczyść ostatnio oglądane dla tej sekcji", "CLEAR_RECENTLY_VIEWED_TYPE": "Wyczyść ostatnio oglądane {{type}}", "CLEAR_RECENTLY_VIEWED_DIALOG_TITLE": "Wyczyścić ostatnio oglądane {{type}}?", "CLEAR_RECENTLY_VIEWED_DIALOG_MESSAGE_PLAYLIST": "Wszystkie elementy {{type}} z tej listy odtwarzania zostaną usunięte z historii ostatnio oglądanych. Tej akcji nie można cofnąć.", diff --git a/apps/web/src/assets/i18n/pt.json b/apps/web/src/assets/i18n/pt.json index 893e4bd40..6c49bead8 100644 --- a/apps/web/src/assets/i18n/pt.json +++ b/apps/web/src/assets/i18n/pt.json @@ -1701,8 +1701,6 @@ "CLEAR_COMPLETED_DOWNLOADS_ALL_ARIA": "Limpar downloads concluídos para todas as playlists", "CLEAR_COMPLETED_DOWNLOADS_THIS_PLAYLIST": "Limpar downloads concluídos (esta playlist)", "CLEAR_COMPLETED_DOWNLOADS_THIS_PLAYLIST_ARIA": "Limpar downloads concluídos para esta playlist", - "CLEAR_RECENTLY_VIEWED_SECTION": "Limpar visualizados recentemente", - "CLEAR_RECENTLY_VIEWED_SECTION_ARIA": "Limpar assistidos recentemente nesta seção", "CLEAR_RECENTLY_VIEWED_TYPE": "Limpar visualizados recentemente {{type}}", "CLEAR_RECENTLY_VIEWED_DIALOG_TITLE": "Limpar visualizados recentemente {{type}}?", "CLEAR_RECENTLY_VIEWED_DIALOG_MESSAGE_PLAYLIST": "Todos os itens de {{type}} desta playlist serão removidos do seu histórico de visualizados recentemente. Esta ação não pode ser desfeita.", diff --git a/apps/web/src/assets/i18n/ru.json b/apps/web/src/assets/i18n/ru.json index 8ec6c5821..dac6e8569 100644 --- a/apps/web/src/assets/i18n/ru.json +++ b/apps/web/src/assets/i18n/ru.json @@ -1701,8 +1701,6 @@ "CLEAR_COMPLETED_DOWNLOADS_ALL_ARIA": "Очистить завершенные загрузки для всех плейлистов", "CLEAR_COMPLETED_DOWNLOADS_THIS_PLAYLIST": "Очистить завершенные загрузки (этот плейлист)", "CLEAR_COMPLETED_DOWNLOADS_THIS_PLAYLIST_ARIA": "Очистить завершенные загрузки для этого плейлиста", - "CLEAR_RECENTLY_VIEWED_SECTION": "Очистить недавно просмотренное", - "CLEAR_RECENTLY_VIEWED_SECTION_ARIA": "Очистить недавно просмотренное для этого раздела", "CLEAR_RECENTLY_VIEWED_TYPE": "Очистить недавно просмотренное ({{type}})", "CLEAR_RECENTLY_VIEWED_DIALOG_TITLE": "Очистить недавно просмотренное ({{type}})?", "CLEAR_RECENTLY_VIEWED_DIALOG_MESSAGE_PLAYLIST": "Все элементы «{{type}}» из этого плейлиста будут удалены из истории просмотров. Это действие нельзя отменить.", diff --git a/apps/web/src/assets/i18n/tr.json b/apps/web/src/assets/i18n/tr.json index 4f3836b0c..340af8097 100644 --- a/apps/web/src/assets/i18n/tr.json +++ b/apps/web/src/assets/i18n/tr.json @@ -1701,8 +1701,6 @@ "CLEAR_COMPLETED_DOWNLOADS_ALL_ARIA": "Tüm oynatma listeleri için tamamlanan indirmeleri temizle", "CLEAR_COMPLETED_DOWNLOADS_THIS_PLAYLIST": "Tamamlanan indirmeleri temizle (bu oynatma listesi)", "CLEAR_COMPLETED_DOWNLOADS_THIS_PLAYLIST_ARIA": "Bu oynatma listesi için tamamlanan indirmeleri temizle", - "CLEAR_RECENTLY_VIEWED_SECTION": "Son izlenenleri temizle", - "CLEAR_RECENTLY_VIEWED_SECTION_ARIA": "Bu bölüm için son görüntülenenleri temizle", "CLEAR_RECENTLY_VIEWED_TYPE": "Son izlenen {{type}} öğelerini temizle", "CLEAR_RECENTLY_VIEWED_DIALOG_TITLE": "Son izlenen {{type}} öğeleri temizlensin mi?", "CLEAR_RECENTLY_VIEWED_DIALOG_MESSAGE_PLAYLIST": "Bu oynatma listesindeki tüm {{type}} öğeleri son izlenenler geçmişinizden kaldırılacaktır. Bu işlem geri alınamaz.", diff --git a/apps/web/src/assets/i18n/zh.json b/apps/web/src/assets/i18n/zh.json index e9573c121..b7accbce1 100644 --- a/apps/web/src/assets/i18n/zh.json +++ b/apps/web/src/assets/i18n/zh.json @@ -1701,8 +1701,6 @@ "CLEAR_COMPLETED_DOWNLOADS_ALL_ARIA": "清除所有播放列表中已完成的下载", "CLEAR_COMPLETED_DOWNLOADS_THIS_PLAYLIST": "清除已完成的下载(本播放列表)", "CLEAR_COMPLETED_DOWNLOADS_THIS_PLAYLIST_ARIA": "清除本播放列表中已完成的下载", - "CLEAR_RECENTLY_VIEWED_SECTION": "清除最近观看", - "CLEAR_RECENTLY_VIEWED_SECTION_ARIA": "清除当前区域的最近观看", "CLEAR_RECENTLY_VIEWED_TYPE": "清除最近观看的{{type}}", "CLEAR_RECENTLY_VIEWED_DIALOG_TITLE": "清除最近观看的{{type}}?", "CLEAR_RECENTLY_VIEWED_DIALOG_MESSAGE_PLAYLIST": "本播放列表中所有的{{type}}项目将从您的最近观看历史中移除。此操作无法撤销。", diff --git a/apps/web/src/assets/i18n/zhtw.json b/apps/web/src/assets/i18n/zhtw.json index 11834fe3d..112b787f6 100644 --- a/apps/web/src/assets/i18n/zhtw.json +++ b/apps/web/src/assets/i18n/zhtw.json @@ -1701,8 +1701,6 @@ "CLEAR_COMPLETED_DOWNLOADS_ALL_ARIA": "清除所有播放清單中已完成的下載", "CLEAR_COMPLETED_DOWNLOADS_THIS_PLAYLIST": "清除已完成的下載(此播放清單)", "CLEAR_COMPLETED_DOWNLOADS_THIS_PLAYLIST_ARIA": "清除此播放清單中已完成的下載", - "CLEAR_RECENTLY_VIEWED_SECTION": "清除最近觀看", - "CLEAR_RECENTLY_VIEWED_SECTION_ARIA": "清除此區段的最近觀看記錄", "CLEAR_RECENTLY_VIEWED_TYPE": "清除最近觀看的{{type}}", "CLEAR_RECENTLY_VIEWED_DIALOG_TITLE": "清除最近觀看的{{type}}?", "CLEAR_RECENTLY_VIEWED_DIALOG_MESSAGE_PLAYLIST": "此播放清單中所有的{{type}}項目都將從您的最近觀看記錄中移除。此操作無法復原。", diff --git a/docs/architecture/iptvnator-ui-guidelines.md b/docs/architecture/iptvnator-ui-guidelines.md index d15e84249..292ddbc6e 100644 --- a/docs/architecture/iptvnator-ui-guidelines.md +++ b/docs/architecture/iptvnator-ui-guidelines.md @@ -842,6 +842,13 @@ per-theme tokens in `apps/web/src/_settings-theme.scss`. is "Cancel …", pass `cancelLabel` "Close" so the two buttons do not read alike. `theme-tokens.e2e.ts` checks the label and the error fill in both themes. +- **One control per action.** A page offers one control for a destructive + action. Clearing a favorites or recently viewed tab goes through + `createClearCollectionAction` (`unified-collection-clear-action.ts` in + `portal/shared/ui`), from the collection page's button and from the command + palette alike; the shell + header does not repeat it. `recent.e2e.ts` checks the single control and + the single confirmation on M3U, Xtream and Stalker recently viewed pages. ## Forms diff --git a/docs/architecture/workspace-shell.md b/docs/architecture/workspace-shell.md index e7f7239b5..5566f9659 100644 --- a/docs/architecture/workspace-shell.md +++ b/docs/architecture/workspace-shell.md @@ -139,7 +139,8 @@ services: 3. `WorkspaceShellSearchSyncService` owns the search query signals, debounced application, provider-store synchronization, and query-param sync. 4. `WorkspaceShellHeaderService` owns playlist title/subtitle, account/info - actions, refresh action state, and recent-items bulk cleanup. + actions, and refresh action state. It has no clear action: clearing + recently viewed or favorites belongs to the collection page. 5. `WorkspaceShellCommandPaletteService` owns command-palette dialog lifecycle and recent-command recording. 6. `WorkspaceShellXtreamImportService` owns Xtream import/refresh overlay diff --git a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-collection-clear-action.spec.ts b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-collection-clear-action.spec.ts new file mode 100644 index 000000000..5e7453664 --- /dev/null +++ b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-collection-clear-action.spec.ts @@ -0,0 +1,92 @@ +import { signal } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { TranslateService } from '@ngx-translate/core'; +import { + CollectionMode, + UnifiedCollectionDataService, +} from '@iptvnator/portal/shared/data-access'; +import { UnifiedCollectionItem } from '@iptvnator/portal/shared/util'; +import { ConfirmDialogData, DialogService } from '@iptvnator/ui/components'; +import { createClearCollectionAction } from './unified-collection-clear-action'; + +const recentItem: UnifiedCollectionItem = { + uid: 'm3u::playlist-1::one', + name: 'Channel One', + contentType: 'live', + sourceType: 'm3u', + playlistId: 'playlist-1', + playlistName: 'Playlist One', + streamUrl: 'https://example.com/one.m3u8', +}; + +describe('createClearCollectionAction', () => { + const openConfirmDialog = jest.fn(); + const data = { + clearFavorites: jest.fn().mockResolvedValue(undefined), + removeRecentItemsBatch: jest.fn(), + }; + const dropCurrentType = jest.fn(); + + function createAction( + mode: CollectionMode, + items: UnifiedCollectionItem[] = [recentItem] + ) { + TestBed.configureTestingModule({ + providers: [ + { provide: DialogService, useValue: { openConfirmDialog } }, + { + provide: TranslateService, + useValue: { instant: (key: string) => key }, + }, + ], + }); + return TestBed.runInInjectionContext(() => + createClearCollectionAction({ + mode: signal(mode), + items: signal(items), + typeLabelKey: signal('PORTALS.LIVE_TV'), + isPlaylistScope: () => true, + data: data as unknown as UnifiedCollectionDataService, + dropCurrentType, + reload: jest.fn().mockResolvedValue(undefined), + }) + ); + } + + function lastDialog(): ConfirmDialogData { + return openConfirmDialog.mock.calls.at(-1)?.[0]; + } + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('asks once, as a destructive Clear, before removing recently viewed rows', () => { + createAction('recent').run(); + + expect(openConfirmDialog).toHaveBeenCalledTimes(1); + expect(lastDialog()).toMatchObject({ + title: 'WORKSPACE.SHELL.CLEAR_RECENTLY_VIEWED_DIALOG_TITLE', + message: + 'WORKSPACE.SHELL.CLEAR_RECENTLY_VIEWED_DIALOG_MESSAGE_PLAYLIST', + confirmLabel: 'CLEAR', + tone: 'destructive', + }); + expect(dropCurrentType).not.toHaveBeenCalled(); + expect(data.removeRecentItemsBatch).not.toHaveBeenCalled(); + + lastDialog().onConfirm(); + + expect(dropCurrentType).toHaveBeenCalledTimes(1); + expect(data.removeRecentItemsBatch).toHaveBeenCalledTimes(1); + expect(data.removeRecentItemsBatch).toHaveBeenCalledWith([recentItem]); + expect(data.clearFavorites).not.toHaveBeenCalled(); + }); + + it('asks nothing when the tab on screen has no rows', () => { + createAction('recent', []).run(); + + expect(openConfirmDialog).not.toHaveBeenCalled(); + expect(data.removeRecentItemsBatch).not.toHaveBeenCalled(); + }); +}); diff --git a/libs/ui/components/src/lib/channel-list-container/channel-list-container.component.html b/libs/ui/components/src/lib/channel-list-container/channel-list-container.component.html index f3ccb20bf..ccd0f7ac7 100644 --- a/libs/ui/components/src/lib/channel-list-container/channel-list-container.component.html +++ b/libs/ui/components/src/lib/channel-list-container/channel-list-container.component.html @@ -13,16 +13,6 @@

{{ viewTitle() | translate }}

- @if ( - activeView() === 'recent' && recentChannelItems().length > 0 - ) { - - }
diff --git a/libs/ui/components/src/lib/channel-list-container/channel-list-container.component.ts b/libs/ui/components/src/lib/channel-list-container/channel-list-container.component.ts index ba8f8681f..8e94db1f7 100644 --- a/libs/ui/components/src/lib/channel-list-container/channel-list-container.component.ts +++ b/libs/ui/components/src/lib/channel-list-container/channel-list-container.component.ts @@ -14,7 +14,6 @@ import { untracked, } from '@angular/core'; import { toObservable, toSignal } from '@angular/core/rxjs-interop'; -import { MatButtonModule } from '@angular/material/button'; import { MatIconModule } from '@angular/material/icon'; import { ActivatedRoute, NavigationEnd, Router } from '@angular/router'; import { Store } from '@ngrx/store'; @@ -111,7 +110,6 @@ const EPG_AVAILABILITY_REFRESH_DEBOUNCE_MS = 2000; CommonModule, FavoritesViewComponent, GroupsViewComponent, - MatButtonModule, MatIconModule, RecentViewComponent, TranslatePipe, @@ -720,26 +718,6 @@ export class ChannelListContainerComponent implements OnInit, OnDestroy { ); } - async clearRecentChannels(): Promise { - const playlistId = this.resolvedPlaylistId(); - if (!playlistId) { - return; - } - - const updatedPlaylist = await firstValueFrom( - this.playlistsService.clearM3uRecentlyViewed(playlistId) - ); - - this.store.dispatch( - PlaylistActions.updatePlaylistMeta({ - playlist: { - _id: playlistId, - recentlyViewed: updatedPlaylist?.recentlyViewed ?? [], - } as PlaylistMeta, - }) as any - ); - } - onSidebarWidthRequested(width: number): void { this.sidebarWidthRequested.emit(width); } 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 65c586af5..b4b21380f 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 @@ -121,7 +121,7 @@ playlist is a global action, not playlist-scoped, so it must remain reachable (e.g. a fresh user configuring the app before importing their first source). Only the per-playlist shortcut - and bulk actions are hidden on Settings. --> + is hidden on Settings. -->
} - @if (headerBulkAction(); as bulk) { - - } }
diff --git a/libs/workspace/shell/feature/src/lib/workspace-shell/components/workspace-shell-header/workspace-shell-header.component.ts b/libs/workspace/shell/feature/src/lib/workspace-shell/components/workspace-shell-header/workspace-shell-header.component.ts index 1592b03f1..48b2cacab 100644 --- a/libs/workspace/shell/feature/src/lib/workspace-shell/components/workspace-shell-header/workspace-shell-header.component.ts +++ b/libs/workspace/shell/feature/src/lib/workspace-shell/components/workspace-shell-header/workspace-shell-header.component.ts @@ -17,10 +17,7 @@ import { WorkspaceHeaderAction, } from '@iptvnator/portal/shared/util'; import { PlaylistMeta } from '@iptvnator/shared/interfaces'; -import { - WorkspaceHeaderBulkAction, - WorkspaceHeaderSidebarToggle, -} from '../../services/helpers/workspace-shell-constants'; +import { WorkspaceHeaderSidebarToggle } from '../../services/helpers/workspace-shell-constants'; @Component({ selector: 'app-workspace-shell-header', @@ -61,7 +58,6 @@ export class WorkspaceShellHeaderComponent { readonly searchScopeLabel = input(''); readonly searchStatusLabel = input(''); readonly headerShortcut = input(null); - readonly headerBulkAction = input(null); readonly headerSidebarToggle = input( null ); @@ -76,7 +72,7 @@ export class WorkspaceShellHeaderComponent { ); /** * When true the playlist switcher + the "+ Add source" / refresh / - * bulk-action buttons are hidden — those controls scope to a + * shortcut buttons are hidden — those controls scope to a * playlist, but Settings is a global page, so leaving them visible * implies (falsely) that switching the playlist changes which * settings you're editing. Driven from the shell facade's existing @@ -131,7 +127,6 @@ export class WorkspaceShellHeaderComponent { readonly shortcutsRequested = output(); readonly addPlaylistRequested = output(); readonly headerShortcutRequested = output(); - readonly headerBulkActionRequested = output(); readonly headerSidebarToggleRequested = output(); readonly parentalLockToggleRequested = output(); readonly refreshPlaylistRequested = output(); @@ -217,10 +212,6 @@ export class WorkspaceShellHeaderComponent { this.headerShortcutRequested.emit(); } - onHeaderBulkActionRequested(): void { - this.headerBulkActionRequested.emit(); - } - onHeaderSidebarToggleRequested(): void { this.headerSidebarToggleRequested.emit(); } diff --git a/libs/workspace/shell/feature/src/lib/workspace-shell/services/helpers/workspace-shell-constants.ts b/libs/workspace/shell/feature/src/lib/workspace-shell/services/helpers/workspace-shell-constants.ts index d3c9ae74d..7464a2552 100644 --- a/libs/workspace/shell/feature/src/lib/workspace-shell/services/helpers/workspace-shell-constants.ts +++ b/libs/workspace/shell/feature/src/lib/workspace-shell/services/helpers/workspace-shell-constants.ts @@ -15,11 +15,6 @@ export const SEARCH_SETTINGS_PLACEHOLDER = export const SEARCH_LOADED_ONLY_STATUS = 'WORKSPACE.SHELL.SEARCH_STATUS_LOADED_ONLY'; -export const CLEAR_RECENTLY_VIEWED_TOOLTIP = - 'WORKSPACE.SHELL.CLEAR_RECENTLY_VIEWED_SECTION'; -export const CLEAR_RECENTLY_VIEWED_ARIA = - 'WORKSPACE.SHELL.CLEAR_RECENTLY_VIEWED_SECTION_ARIA'; - export const RAIL_TOOLTIP_KEYS: Readonly< Partial> > = { @@ -39,13 +34,6 @@ export const RAIL_TOOLTIP_KEYS: Readonly< export type XtreamImportPhaseTone = 'remote' | 'local' | null; -export interface WorkspaceHeaderBulkAction { - icon: string; - tooltip: string; - ariaLabel: string; - disabled: boolean; -} - /** * Header toggle for the live-channel rail of the current route. It exists in * both rail states so the control never moves: inside the rail the chevron diff --git a/libs/workspace/shell/feature/src/lib/workspace-shell/services/helpers/workspace-shell-route-utils.ts b/libs/workspace/shell/feature/src/lib/workspace-shell/services/helpers/workspace-shell-route-utils.ts index 9e7e103d7..ce63c9c99 100644 --- a/libs/workspace/shell/feature/src/lib/workspace-shell/services/helpers/workspace-shell-route-utils.ts +++ b/libs/workspace/shell/feature/src/lib/workspace-shell/services/helpers/workspace-shell-route-utils.ts @@ -65,18 +65,6 @@ export function syncSearchQueryParam( return true; } -export function bumpRefreshQueryParam(router: Router, currentUrl: string): void { - const routePath = getRoutePath(currentUrl); - const queryParams = { - ...router.parseUrl(currentUrl).queryParams, - refresh: Date.now().toString(), - }; - - const queryString = toQueryString(queryParams); - const nextUrl = queryString ? `${routePath}?${queryString}` : routePath; - void router.navigateByUrl(nextUrl, { replaceUrl: true }); -} - export function getProviderFromPlaylist(playlist: { serverUrl?: string; macAddress?: string; diff --git a/libs/workspace/shell/feature/src/lib/workspace-shell/services/workspace-shell-header.service.ts b/libs/workspace/shell/feature/src/lib/workspace-shell/services/workspace-shell-header.service.ts index f52867499..1de73dcb6 100644 --- a/libs/workspace/shell/feature/src/lib/workspace-shell/services/workspace-shell-header.service.ts +++ b/libs/workspace/shell/feature/src/lib/workspace-shell/services/workspace-shell-header.service.ts @@ -1,10 +1,9 @@ import { computed, inject, Injectable } from '@angular/core'; import { MatDialog } from '@angular/material/dialog'; import { Router } from '@angular/router'; -import { Store } from '@ngrx/store'; import { toSignal } from '@angular/core/rxjs-interop'; import { TranslateService } from '@ngx-translate/core'; -import { firstValueFrom, startWith } from 'rxjs'; +import { startWith } from 'rxjs'; import { PlaylistInfoComponent, PlaylistRefreshActionService, @@ -16,8 +15,6 @@ import { WorkspaceHeaderContextService, } from '@iptvnator/portal/shared/util'; import { XtreamStore } from '@iptvnator/portal/xtream/data-access'; -import { PlaylistsService } from '@iptvnator/services'; -import { PlaylistActions } from '@iptvnator/m3u-state'; import { isStalkerAccountPlaylist, isXtreamAccountPlaylist, @@ -25,24 +22,15 @@ import { } from '@iptvnator/shared/interfaces'; import { WorkspaceAccountInfoData, - WorkspacePortalContext, WORKSPACE_SHELL_ACTIONS, } from '@iptvnator/workspace/shell/util'; -import { - CLEAR_RECENTLY_VIEWED_ARIA, - CLEAR_RECENTLY_VIEWED_TOOLTIP, - WorkspaceHeaderBulkAction, - WorkspaceHeaderSidebarToggle, -} from './helpers/workspace-shell-constants'; -import { bumpRefreshQueryParam } from './helpers/workspace-shell-route-utils'; +import { WorkspaceHeaderSidebarToggle } from './helpers/workspace-shell-constants'; import { WorkspaceShellRouteStateService } from './workspace-shell-route-state.service'; @Injectable() export class WorkspaceShellHeaderService { private readonly router = inject(Router); - private readonly store = inject(Store); private readonly xtreamStore = inject(XtreamStore); - private readonly playlistsService = inject(PlaylistsService); private readonly workspaceActions = inject(WORKSPACE_SHELL_ACTIONS); private readonly translate = inject(TranslateService); private readonly dialog = inject(MatDialog); @@ -128,33 +116,6 @@ export class WorkspaceShellHeaderService { ) ); readonly isRefreshingPlaylist = this.playlistRefreshAction.isRefreshing; - readonly headerBulkAction = computed( - () => { - this.languageTick(); - - const context = this.routeState.currentContext(); - const section = this.routeState.currentSection(); - - if (!context || section !== 'recent') { - return null; - } - - if ( - context.provider !== 'xtreams' && - context.provider !== 'stalker' && - context.provider !== 'playlists' - ) { - return null; - } - - return { - icon: 'delete_sweep', - tooltip: this.translateText(CLEAR_RECENTLY_VIEWED_TOOLTIP), - ariaLabel: this.translateText(CLEAR_RECENTLY_VIEWED_ARIA), - disabled: this.isRecentCleanupDisabled(context.provider), - }; - } - ); readonly playlistSubtitle = computed(() => { this.languageTick(); @@ -200,61 +161,6 @@ export class WorkspaceShellHeaderService { this.workspaceActions.openGlobalRecent(); } - async runHeaderBulkAction(): Promise { - const context = this.routeState.currentContext(); - const section = this.routeState.currentSection(); - - if (!context || section !== 'recent') { - return; - } - - if (context.provider === 'xtreams') { - this.xtreamStore.clearRecentItems({ id: context.playlistId }); - return; - } - - if (context.provider === 'stalker') { - const updatedPlaylist = await firstValueFrom( - this.playlistsService.clearPortalRecentlyViewed( - context.playlistId - ) - ); - this.store.dispatch( - PlaylistActions.updatePlaylistMeta({ - playlist: { - _id: context.playlistId, - recentlyViewed: updatedPlaylist?.recentlyViewed ?? [], - } as PlaylistMeta, - }) - ); - bumpRefreshQueryParam( - this.router, - this.routeState.currentUrl() - ); - return; - } - - if (context.provider === 'playlists') { - const updatedPlaylist = await firstValueFrom( - this.playlistsService.clearM3uRecentlyViewed( - context.playlistId - ) - ); - this.store.dispatch( - PlaylistActions.updatePlaylistMeta({ - playlist: { - _id: context.playlistId, - recentlyViewed: updatedPlaylist?.recentlyViewed ?? [], - } as PlaylistMeta, - }) - ); - bumpRefreshQueryParam( - this.router, - this.routeState.currentUrl() - ); - } - } - toggleLiveSidebar(): void { const surface = this.liveSidebarSurface(); if (surface) { @@ -343,22 +249,6 @@ export class WorkspaceShellHeaderService { this.playlistRefreshAction.refresh(playlist); } - private isRecentCleanupDisabled( - provider: WorkspacePortalContext['provider'] - ): boolean { - if (provider === 'xtreams') { - return this.xtreamStore.recentItems().length === 0; - } - - if (provider === 'playlists') { - return ( - this.routeState.activePlaylist()?.recentlyViewed?.length ?? 0 - ) === 0; - } - - return false; - } - private translateText( key: string, params?: Record diff --git a/libs/workspace/shell/feature/src/lib/workspace-shell/services/workspace-shell.facade.spec.ts b/libs/workspace/shell/feature/src/lib/workspace-shell/services/workspace-shell.facade.spec.ts index 1ff344ec2..72957b928 100644 --- a/libs/workspace/shell/feature/src/lib/workspace-shell/services/workspace-shell.facade.spec.ts +++ b/libs/workspace/shell/feature/src/lib/workspace-shell/services/workspace-shell.facade.spec.ts @@ -19,7 +19,6 @@ import { StalkerStore } from '@iptvnator/portal/stalker/data-access'; import { XtreamStore } from '@iptvnator/portal/xtream/data-access'; import { DownloadsService, - PlaylistsService, RuntimeCapabilitiesService, SettingsStore, } from '@iptvnator/services'; @@ -138,10 +137,6 @@ describe('WorkspaceShellFacade', () => { isActive: jest.Mock; lastSuccessfulNavigation: () => { trigger: string }; }; - let playlistsService: { - clearPortalRecentlyViewed: jest.Mock; - clearM3uRecentlyViewed: jest.Mock; - }; let workspaceActions: { openAddPlaylistDialog: jest.Mock; openGlobalSearch: jest.Mock; @@ -225,14 +220,6 @@ describe('WorkspaceShellFacade', () => { isActive: jest.fn(), lastSuccessfulNavigation: () => ({ trigger: navigationTrigger }), }; - playlistsService = { - clearPortalRecentlyViewed: jest - .fn() - .mockReturnValue(of({ recentlyViewed: [] })), - clearM3uRecentlyViewed: jest - .fn() - .mockReturnValue(of({ recentlyViewed: [] })), - }; workspaceActions = { openAddPlaylistDialog: jest.fn(), openGlobalSearch: jest.fn(), @@ -328,10 +315,6 @@ describe('WorkspaceShellFacade', () => { provide: RuntimeCapabilitiesService, useValue: runtime, }, - { - provide: PlaylistsService, - useValue: playlistsService, - }, { provide: DownloadsService, useValue: { @@ -647,26 +630,6 @@ describe('WorkspaceShellFacade', () => { ]); }); - it('clears stalker recent items and refreshes the route', async () => { - facade.currentUrl.set('/workspace/stalker/pl-1/recent'); - router.navigateByUrl.mockClear(); - - await facade.runHeaderBulkAction(); - - expect(playlistsService.clearPortalRecentlyViewed).toHaveBeenCalledWith( - 'pl-1' - ); - expect(storeDispatch).toHaveBeenCalled(); - expect(router.navigateByUrl).toHaveBeenCalledWith( - expect.stringMatching( - /^\/workspace\/stalker\/pl-1\/recent\?refresh=/ - ), - { - replaceUrl: true, - } - ); - }); - it('exposes loaded-only status for stalker itv searches', () => { facade.currentUrl.set('/workspace/stalker/pl-1/itv?q=cnn'); searchSync.syncSearchFromRoute(); diff --git a/libs/workspace/shell/feature/src/lib/workspace-shell/services/workspace-shell.facade.ts b/libs/workspace/shell/feature/src/lib/workspace-shell/services/workspace-shell.facade.ts index d1f53bf6e..276abcf58 100644 --- a/libs/workspace/shell/feature/src/lib/workspace-shell/services/workspace-shell.facade.ts +++ b/libs/workspace/shell/feature/src/lib/workspace-shell/services/workspace-shell.facade.ts @@ -28,8 +28,6 @@ import { WorkspaceShellSearchService } from './workspace-shell-search.service'; import { WorkspaceShellXtreamImportService } from './workspace-shell-xtream-import.service'; import { WorkspaceShellContextDrawerService } from '@iptvnator/workspace/shell/util'; -export type { WorkspaceHeaderBulkAction } from './helpers/workspace-shell-constants'; - @Injectable() export class WorkspaceShellFacade { private readonly router = inject(Router); @@ -134,7 +132,6 @@ export class WorkspaceShellFacade { readonly canOpenAccountInfo = this.header.canOpenAccountInfo; readonly canRefreshPlaylist = this.header.canRefreshPlaylist; readonly isRefreshingPlaylist = this.header.isRefreshingPlaylist; - readonly headerBulkAction = this.header.headerBulkAction; readonly headerSidebarToggle = this.header.headerSidebarToggle; readonly playlistSubtitle = this.header.playlistSubtitle; readonly activeDownloadsCount = computed(() => @@ -210,10 +207,6 @@ export class WorkspaceShellFacade { ); } - runHeaderBulkAction(): Promise { - return this.header.runHeaderBulkAction(); - } - toggleLiveSidebar(): void { this.header.toggleLiveSidebar(); } diff --git a/libs/workspace/shell/feature/src/lib/workspace-shell/workspace-shell.component.html b/libs/workspace/shell/feature/src/lib/workspace-shell/workspace-shell.component.html index 3b61bd472..566ecd334 100644 --- a/libs/workspace/shell/feature/src/lib/workspace-shell/workspace-shell.component.html +++ b/libs/workspace/shell/feature/src/lib/workspace-shell/workspace-shell.component.html @@ -41,7 +41,6 @@ [searchScopeLabel]="facade.searchScopeLabel()" [searchStatusLabel]="facade.searchStatusLabel()" [headerShortcut]="facade.headerShortcut()" - [headerBulkAction]="facade.headerBulkAction()" [headerSidebarToggle]="facade.headerSidebarToggle()" [canRefreshPlaylist]="facade.canRefreshPlaylist()" [isRefreshingPlaylist]="facade.isRefreshingPlaylist()" @@ -64,7 +63,6 @@ (shortcutsRequested)="keyboardShortcuts.openShortcutsDialog()" (addPlaylistRequested)="facade.openAddPlaylistDialog()" (headerShortcutRequested)="facade.runHeaderShortcut()" - (headerBulkActionRequested)="facade.runHeaderBulkAction()" (headerSidebarToggleRequested)="facade.toggleLiveSidebar()" (parentalLockToggleRequested)="facade.toggleParentalLock()" (downloadsRequested)="facade.openDownloadsShortcut()" diff --git a/libs/workspace/shell/feature/src/lib/workspace-shell/workspace-shell.component.spec.ts b/libs/workspace/shell/feature/src/lib/workspace-shell/workspace-shell.component.spec.ts index 5624bb823..cc4cc20d7 100644 --- a/libs/workspace/shell/feature/src/lib/workspace-shell/workspace-shell.component.spec.ts +++ b/libs/workspace/shell/feature/src/lib/workspace-shell/workspace-shell.component.spec.ts @@ -20,10 +20,7 @@ import { } from '@iptvnator/workspace/shell/util'; import { TrafficLightsClearanceDirective } from './traffic-lights-clearance.directive'; import { WorkspaceShellComponent } from './workspace-shell.component'; -import { - WorkspaceHeaderBulkAction, - WorkspaceShellFacade, -} from './services/workspace-shell.facade'; +import { WorkspaceShellFacade } from './services/workspace-shell.facade'; import { WorkspaceKeyboardShortcutsService } from '../workspace-keyboard-shortcuts/workspace-keyboard-shortcuts.service'; @Component({ @@ -71,7 +68,6 @@ class MockWorkspaceShellHeaderComponent { readonly isContextDrawerOpen = input(false); readonly contextDrawerToggleAriaKey = input(''); readonly contextDrawerTooltipKey = input(''); - readonly headerBulkAction = input(null); readonly headerSidebarToggle = input(null); readonly parentalLockState = input<'off' | 'locked' | 'unlocked'>('off'); readonly backRequested = output(); @@ -83,7 +79,6 @@ class MockWorkspaceShellHeaderComponent { readonly headerShortcutRequested = output(); readonly refreshPlaylistRequested = output(); readonly downloadsRequested = output(); - readonly headerBulkActionRequested = output(); readonly headerSidebarToggleRequested = output(); readonly parentalLockToggleRequested = output(); readonly playlistInfoRequested = output(); @@ -177,7 +172,6 @@ class MockWorkspaceShellFacade { readonly hasNoPlaylists = signal(false); readonly isDownloadsView = signal(false); readonly activeDownloadsCount = signal(3); - readonly headerBulkAction = signal(null); readonly headerSidebarToggle = signal(null); toggleLiveSidebar = jest.fn(); readonly parentalLockState = signal<'off' | 'locked' | 'unlocked'>('off'); @@ -226,7 +220,6 @@ class MockWorkspaceShellFacade { runHeaderShortcut = jest.fn(); refreshCurrentPlaylist = jest.fn(); openDownloadsShortcut = jest.fn(); - runHeaderBulkAction = jest.fn(); openPlaylistInfo = jest.fn(); openAccountInfo = jest.fn(); openAccountInfoFor = jest.fn();