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();