diff --git a/.changes/settings-section-pages.md b/.changes/settings-section-pages.md new file mode 100644 index 000000000..c63e6f9b4 --- /dev/null +++ b/.changes/settings-section-pages.md @@ -0,0 +1,6 @@ +--- +type: feature +area: settings +--- + +Settings sections are now separate pages with shareable URLs and a floating Save/Discard bar that appears only when you have unsaved changes; leaving settings with pending edits asks whether to save, discard, or keep editing. The EPG panel's "Open EPG settings" button now jumps straight to the EPG settings page. diff --git a/CLAUDE.md b/CLAUDE.md index 2d4cd9a30..8eb867e36 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -514,7 +514,7 @@ See `docs/architecture/m3u-playlist-module.md` for complete documentation. `/workspace/xtreams/:id/downloads/:downloadId` and `/workspace/stalker/:id/downloads/:downloadId`. Focused download details hide the workspace context panel. -- Settings: `/workspace/settings` (`/settings` redirects there) +- Settings: `/workspace/settings/:section` — one page per section (`general`, `playback`, `epg`, `dashboard`, `remote-control`, `tmdb`, `backup`, `reset`, `about`); `/workspace/settings` redirects to `general`, unknown or capability-gated sections redirect there too, and `/settings` redirects into the workspace. The shared form lives on the parent `SettingsComponent`, so edits survive section switches; a floating unsaved-changes bar (Save/Discard) replaces the old always-visible footer Save button. Leaving the settings AREA with a dirty form triggers `settingsUnsavedChangesGuard` (canDeactivate) and a save/discard/stay dialog — section switches deliberately bypass it, and a failed save cancels the navigation **Service Architecture** (Factory Pattern): diff --git a/apps/electron-backend-e2e/src/backup-roundtrip.e2e.ts b/apps/electron-backend-e2e/src/backup-roundtrip.e2e.ts index 102cf35f6..d0e6df701 100644 --- a/apps/electron-backend-e2e/src/backup-roundtrip.e2e.ts +++ b/apps/electron-backend-e2e/src/backup-roundtrip.e2e.ts @@ -8,6 +8,7 @@ import { expect, launchElectronApp, openSettings, + openSettingsSection, openSources, openWorkspaceSection, resetMockServers, @@ -69,6 +70,7 @@ test.describe('Electron playlist backup round-trip', () => { }, exportPath); await openSettings(app.mainWindow); + await openSettingsSection(app.mainWindow, 'backup'); const backupSection = app.mainWindow.locator('#backup'); await backupSection .getByRole('button', { name: 'Export', exact: true }) @@ -113,6 +115,7 @@ test.describe('Electron playlist backup round-trip', () => { // Import the exported file back through the settings flow; the // renderer opens a browser file chooser for it. await openSettings(app.mainWindow); + await openSettingsSection(app.mainWindow, 'backup'); const fileChooserPromise = app.mainWindow.waitForEvent('filechooser'); await backupSection diff --git a/apps/electron-backend-e2e/src/downloads.e2e.ts b/apps/electron-backend-e2e/src/downloads.e2e.ts index bb3d2e47f..42ae1148b 100644 --- a/apps/electron-backend-e2e/src/downloads.e2e.ts +++ b/apps/electron-backend-e2e/src/downloads.e2e.ts @@ -123,6 +123,9 @@ async function selectDownloadCoverSize( ): Promise { await openSettings(page); await page.getByTestId(`cover-size-${size}`).click(); + // Cover size is staged like every other setting now — it reaches the + // store (and the document dataset) only once the save lands. + await saveSettings(page); await expect .poll(() => page.evaluate( @@ -130,7 +133,6 @@ async function selectDownloadCoverSize( ) ) .toBe(size); - await saveSettings(page); await openDownloadsPage(page); } diff --git a/apps/electron-backend-e2e/src/electron-test-fixtures.ts b/apps/electron-backend-e2e/src/electron-test-fixtures.ts index 818546505..2af39ac3e 100644 --- a/apps/electron-backend-e2e/src/electron-test-fixtures.ts +++ b/apps/electron-backend-e2e/src/electron-test-fixtures.ts @@ -983,10 +983,24 @@ export async function openSettings(page: Page): Promise { await expect(page.getByTestId('settings-container')).toBeVisible(); } +/** + * Settings render one section page at a time (`/workspace/settings/:section`), + * so a control can only be interacted with after its section page is open. + */ +export async function openSettingsSection( + page: Page, + sectionId: string +): Promise { + await page.getByTestId(`settings-section-${sectionId}`).click(); + await page.waitForURL(new RegExp(`/workspace/settings/${sectionId}$`)); +} + export async function enableRemoteControl( page: Page, port: number ): Promise { + await openSettingsSection(page, 'remote-control'); + const remoteControlCheckbox = page.locator( 'mat-checkbox[formcontrolname="remoteControl"] input[type="checkbox"]' ); @@ -1011,9 +1025,10 @@ export async function saveSettings(page: Page): Promise { // deterministic post-save state below. await saveButton.click({ noWaitAfter: true }); // `onSubmit()` calls `applyChangedSettings()` -> `markAsPristine()` once the - // settings write resolves, which disables the button. Awaiting that is a - // stronger, race-free confirmation that the save actually committed. - await expect(saveButton).toBeDisabled(); + // settings write resolves, which hides the whole unsaved-changes bar. + // Awaiting that is a stronger, race-free confirmation that the save + // actually committed. + await expect(saveButton).toBeHidden(); // Let the fire-and-forget `window.electron.updateSettings(...)` IPC flush to // the main process before callers may relaunch the app to assert persistence. await page.waitForTimeout(300); diff --git a/apps/electron-backend-e2e/src/epg.e2e.ts b/apps/electron-backend-e2e/src/epg.e2e.ts index 783daefdd..28c6f9a5c 100644 --- a/apps/electron-backend-e2e/src/epg.e2e.ts +++ b/apps/electron-backend-e2e/src/epg.e2e.ts @@ -9,6 +9,8 @@ import { launchElectronApp, openWorkspaceSection, openSettings, + openSettingsSection, + saveSettings, test, } from './electron-test-fixtures'; @@ -74,6 +76,7 @@ test.describe('Electron EPG', () => { try { await openSettings(app.mainWindow); + await openSettingsSection(app.mainWindow, 'epg'); await app.mainWindow .getByRole('button', { name: 'Add EPG source' }) .click(); @@ -254,6 +257,7 @@ test.describe('Electron EPG', () => { ); await openSettings(app.mainWindow); + await openSettingsSection(app.mainWindow, 'epg'); await app.mainWindow .getByRole('button', { name: 'Add EPG source' }) .click(); @@ -272,6 +276,11 @@ test.describe('Electron EPG', () => { }) .toBeGreaterThan(0); + // The added source is still only staged in the form — leaving + // settings without saving would surface the unsaved-changes + // dialog and block the navigation below. + await saveSettings(app.mainWindow); + await openWorkspaceSection(app.mainWindow, 'All channels'); const channelItem = channelItemByTitle( diff --git a/apps/electron-backend-e2e/src/remote-control.e2e.ts b/apps/electron-backend-e2e/src/remote-control.e2e.ts index 8807e2cc8..7fda35064 100644 --- a/apps/electron-backend-e2e/src/remote-control.e2e.ts +++ b/apps/electron-backend-e2e/src/remote-control.e2e.ts @@ -10,6 +10,7 @@ import { importM3uPlaylistFromNativeDialog, launchElectronApp, openSettings, + openSettingsSection, saveSettings, test, waitForM3uCatalog, @@ -47,6 +48,7 @@ test.describe('Electron Remote Control', () => { try { await openSettings(app.mainWindow); + await openSettingsSection(app.mainWindow, 'playback'); await selectSettingsOption( app.mainWindow, 'select-video-player', diff --git a/apps/electron-backend-e2e/src/settings.e2e.ts b/apps/electron-backend-e2e/src/settings.e2e.ts index 7e974cfcf..fe28297c1 100644 --- a/apps/electron-backend-e2e/src/settings.e2e.ts +++ b/apps/electron-backend-e2e/src/settings.e2e.ts @@ -16,6 +16,7 @@ import { m3uFixturePath, openGlobalRecent, openSettings, + openSettingsSection, openWorkspaceSection, resetMockServers, restartElectronApp, @@ -49,6 +50,7 @@ test.describe('Electron Settings', () => { try { await openSettings(app.mainWindow); + await openSettingsSection(app.mainWindow, 'about'); await expect(app.mainWindow.getByTestId('app-update-status')).toBeVisible(); await expect(app.mainWindow.getByTestId('app-update-check')).toBeVisible(); @@ -75,6 +77,7 @@ test.describe('Electron Settings', () => { await installExternalPlayerLaunchCapture(app); await openSettings(app.mainWindow); + await openSettingsSection(app.mainWindow, 'playback'); await expect( app.mainWindow.getByTestId( 'external-player-double-click-setting' @@ -117,6 +120,7 @@ test.describe('Electron Settings', () => { }); await openSettings(app.mainWindow); + await openSettingsSection(app.mainWindow, 'playback'); await expect(doubleClickSetting).toBeVisible(); await doubleClickCheckbox.check(); await saveSettings(app.mainWindow); @@ -229,6 +233,7 @@ test.describe('Electron Settings', () => { await firstLaunch.mainWindow .locator('[data-test-id="DARK_THEME"]') .click(); + await openSettingsSection(firstLaunch.mainWindow, 'playback'); await selectSettingsOption( firstLaunch.mainWindow, 'select-video-player', @@ -250,6 +255,7 @@ test.describe('Electron Settings', () => { ) .uncheck(); await enableRemoteControl(firstLaunch.mainWindow, 8877); + await openSettingsSection(firstLaunch.mainWindow, 'epg'); await firstLaunch.mainWindow .getByRole('button', { name: 'Add EPG source' }) .click(); @@ -273,6 +279,7 @@ test.describe('Electron Settings', () => { await expect( secondLaunch.mainWindow.locator('[data-test-id="DARK_THEME"]') ).toHaveAttribute('aria-checked', 'true'); + await openSettingsSection(secondLaunch.mainWindow, 'playback'); await expect( secondLaunch.mainWindow.getByTestId('select-video-player') ).toContainText(/HTML5/i); @@ -289,6 +296,7 @@ test.describe('Electron Settings', () => { 'mat-checkbox[formcontrolname="showExternalPlaybackBar"] input[type="checkbox"]' ) ).not.toBeChecked(); + await openSettingsSection(secondLaunch.mainWindow, 'remote-control'); await expect( secondLaunch.mainWindow.locator( 'mat-checkbox[formcontrolname="remoteControl"] input[type="checkbox"]' @@ -297,6 +305,7 @@ test.describe('Electron Settings', () => { await expect( secondLaunch.mainWindow.locator('#remoteControlPort') ).toHaveValue('8877'); + await openSettingsSection(secondLaunch.mainWindow, 'epg'); await expect( secondLaunch.mainWindow.locator('.epg-source-row input').first() ).toHaveValue(epgServer.resourceUrl); @@ -313,6 +322,7 @@ test.describe('Electron Settings', () => { try { await openSettings(app.mainWindow); + await openSettingsSection(app.mainWindow, 'playback'); await selectSettingsOption( app.mainWindow, 'select-video-player', @@ -478,6 +488,7 @@ test.describe('Electron Settings', () => { try { await openSettings(firstLaunch.mainWindow); + await openSettingsSection(firstLaunch.mainWindow, 'epg'); const listToggle = firstLaunch.mainWindow.locator( '[data-test-id="epg-view-mode-list"]' ); @@ -493,6 +504,7 @@ test.describe('Electron Settings', () => { try { await openSettings(secondLaunch.mainWindow); + await openSettingsSection(secondLaunch.mainWindow, 'epg'); await expect( secondLaunch.mainWindow.locator( '[data-test-id="epg-view-mode-list"]' @@ -508,6 +520,7 @@ test.describe('Electron Settings', () => { try { await openSettings(firstLaunch.mainWindow); + await openSettingsSection(firstLaunch.mainWindow, 'dashboard'); await firstLaunch.mainWindow .locator( 'mat-checkbox[formcontrolname="showDashboard"] input[type="checkbox"]' diff --git a/apps/electron-backend-e2e/src/xtream-epg.e2e.ts b/apps/electron-backend-e2e/src/xtream-epg.e2e.ts index f65926039..d337b6503 100644 --- a/apps/electron-backend-e2e/src/xtream-epg.e2e.ts +++ b/apps/electron-backend-e2e/src/xtream-epg.e2e.ts @@ -9,6 +9,7 @@ import { goToDashboard, launchElectronApp, openSettings, + openSettingsSection, openWorkspaceSection, resetMockServers, saveSettings, @@ -302,6 +303,7 @@ test('@epg @xtream @electron renders the vertical list view when the setting is // Opt into the list view first (from the fresh workspace) so the portal // → Live TV → channel flow afterwards mirrors the timeline test exactly. await openSettings(app.mainWindow); + await openSettingsSection(app.mainWindow, 'epg'); await app.mainWindow .locator('[data-test-id="epg-view-mode-list"]') .click(); diff --git a/apps/web-e2e/dist/.playwright/apps/web-e2e/screenshots/settings/@settings @web Change app language.png b/apps/web-e2e/dist/.playwright/apps/web-e2e/screenshots/settings/@settings @web Change app language.png new file mode 100644 index 000000000..9b09f0443 Binary files /dev/null and b/apps/web-e2e/dist/.playwright/apps/web-e2e/screenshots/settings/@settings @web Change app language.png differ diff --git a/apps/web-e2e/dist/.playwright/apps/web-e2e/screenshots/settings/@settings @web Change app theme.png b/apps/web-e2e/dist/.playwright/apps/web-e2e/screenshots/settings/@settings @web Change app theme.png new file mode 100644 index 000000000..ae5559a5e Binary files /dev/null and b/apps/web-e2e/dist/.playwright/apps/web-e2e/screenshots/settings/@settings @web Change app theme.png differ diff --git a/apps/web-e2e/dist/.playwright/apps/web-e2e/screenshots/settings/@settings @web Change video player.png b/apps/web-e2e/dist/.playwright/apps/web-e2e/screenshots/settings/@settings @web Change video player.png new file mode 100644 index 000000000..9e4c06376 Binary files /dev/null and b/apps/web-e2e/dist/.playwright/apps/web-e2e/screenshots/settings/@settings @web Change video player.png differ diff --git a/apps/web-e2e/dist/.playwright/apps/web-e2e/screenshots/settings/@settings @web Check settings page.png b/apps/web-e2e/dist/.playwright/apps/web-e2e/screenshots/settings/@settings @web Check settings page.png new file mode 100644 index 000000000..34a05696c Binary files /dev/null and b/apps/web-e2e/dist/.playwright/apps/web-e2e/screenshots/settings/@settings @web Check settings page.png differ diff --git a/apps/web-e2e/dist/.playwright/apps/web-e2e/screenshots/settings/@settings @web Deep links open one section page and unknown sections redirect.png b/apps/web-e2e/dist/.playwright/apps/web-e2e/screenshots/settings/@settings @web Deep links open one section page and unknown sections redirect.png new file mode 100644 index 000000000..18f7992d2 Binary files /dev/null and b/apps/web-e2e/dist/.playwright/apps/web-e2e/screenshots/settings/@settings @web Deep links open one section page and unknown sections redirect.png differ diff --git a/apps/web-e2e/dist/.playwright/apps/web-e2e/screenshots/settings/@settings @web Enable shared web player controls.png b/apps/web-e2e/dist/.playwright/apps/web-e2e/screenshots/settings/@settings @web Enable shared web player controls.png new file mode 100644 index 000000000..8f2faf1c4 Binary files /dev/null and b/apps/web-e2e/dist/.playwright/apps/web-e2e/screenshots/settings/@settings @web Enable shared web player controls.png differ diff --git a/apps/web-e2e/dist/.playwright/apps/web-e2e/screenshots/settings/@settings @web Leaving with unsaved edits asks for confirmation.png b/apps/web-e2e/dist/.playwright/apps/web-e2e/screenshots/settings/@settings @web Leaving with unsaved edits asks for confirmation.png new file mode 100644 index 000000000..b63a79e02 Binary files /dev/null and b/apps/web-e2e/dist/.playwright/apps/web-e2e/screenshots/settings/@settings @web Leaving with unsaved edits asks for confirmation.png differ diff --git a/apps/web-e2e/dist/.playwright/apps/web-e2e/screenshots/settings/@settings @web Save-and-leave persists the staged edit.png b/apps/web-e2e/dist/.playwright/apps/web-e2e/screenshots/settings/@settings @web Save-and-leave persists the staged edit.png new file mode 100644 index 000000000..06cb9eb42 Binary files /dev/null and b/apps/web-e2e/dist/.playwright/apps/web-e2e/screenshots/settings/@settings @web Save-and-leave persists the staged edit.png differ diff --git a/apps/web-e2e/dist/.playwright/apps/web-e2e/screenshots/settings/@settings @web Unsaved bar survives section switches and discard reverts.png b/apps/web-e2e/dist/.playwright/apps/web-e2e/screenshots/settings/@settings @web Unsaved bar survives section switches and discard reverts.png new file mode 100644 index 000000000..9f075ac73 Binary files /dev/null and b/apps/web-e2e/dist/.playwright/apps/web-e2e/screenshots/settings/@settings @web Unsaved bar survives section switches and discard reverts.png differ diff --git a/apps/web-e2e/src/playback-recommendations.e2e.ts b/apps/web-e2e/src/playback-recommendations.e2e.ts index dcd73cbc1..301e49216 100644 --- a/apps/web-e2e/src/playback-recommendations.e2e.ts +++ b/apps/web-e2e/src/playback-recommendations.e2e.ts @@ -64,10 +64,16 @@ async function servePlaybackFixtures(page: Page): Promise { async function openSettings(page: Page): Promise { await page.getByRole('link', { name: 'Open settings' }).click(); - await page.waitForURL(/\/workspace\/settings$/); + // The bare settings URL redirects to the default section page; the + // player select lives on the playback section page. + await page.waitForURL(/\/workspace\/settings\/general$/); await expect( page.getByRole('button', { name: 'Back', exact: true }) ).toBeVisible(); + await page + .locator('[data-test-id="settings-section-playback"]') + .click(); + await page.waitForURL(/\/workspace\/settings\/playback$/); } async function selectAndSaveHtml5(page: Page): Promise { @@ -79,7 +85,8 @@ async function selectAndSaveHtml5(page: Page): Promise { const saveButton = page.getByRole('button', { name: 'Save changes' }); await saveButton.click(); - await expect(saveButton).toBeDisabled(); + // A successful save removes the unsaved-changes bar with its button. + await expect(saveButton).toBeHidden(); } async function importFatalHlsPlaylist(page: Page): Promise { @@ -131,7 +138,7 @@ test('@web @m3u @playback temporarily switches to the recommended player', async await openSettings(page); await page.reload(); - await expect(page).toHaveURL(/\/workspace\/settings$/); + await expect(page).toHaveURL(/\/workspace\/settings\/playback$/); const persistedPlayerSelect = page.locator( '[data-test-id="select-video-player"]' ); diff --git a/apps/web-e2e/src/settings.e2e.ts b/apps/web-e2e/src/settings.e2e.ts index 113fe8278..1589335fc 100644 --- a/apps/web-e2e/src/settings.e2e.ts +++ b/apps/web-e2e/src/settings.e2e.ts @@ -4,16 +4,25 @@ import { expect, test } from './fixtures'; async function openSettings(page: Page) { await page.locator('a[href$="/workspace/settings"]').click(); - await page.waitForURL(/\/workspace\/settings$/); + // The bare settings URL redirects to the default section page. + await page.waitForURL(/\/workspace\/settings\/general$/); await expect(page.locator('.settings-container')).toBeVisible(); await expect(page.locator('.settings-back-button')).toBeVisible(); } +/** Settings render one section page at a time — open it via the rail. */ +async function openSettingsSection(page: Page, sectionId: string) { + await page.locator(`[data-test-id="settings-section-${sectionId}"]`).click(); + await page.waitForURL(new RegExp(`/workspace/settings/${sectionId}$`)); +} + async function saveSettings(page: Page) { const saveButton = page.locator('[data-test-id="save-settings"]'); await saveButton.click(); - await expect(saveButton).toBeDisabled(); + // A successful save marks the form pristine, which removes the whole + // unsaved-changes bar together with the save button. + await expect(saveButton).toBeHidden(); } test.describe('Settings', () => { @@ -30,6 +39,7 @@ test.describe('Settings', () => { test('@settings @web Change video player', async ({ page }) => { await openSettings(page); + await openSettingsSection(page, 'playback'); const playerSelect = page.locator('[data-test-id="select-video-player"]'); @@ -42,6 +52,7 @@ test.describe('Settings', () => { await saveSettings(page); await page.reload(); await openSettings(page); + await openSettingsSection(page, 'playback'); await expect(playerSelect).toContainText( /HTML5/i @@ -52,6 +63,7 @@ test.describe('Settings', () => { page, }) => { await openSettings(page); + await openSettingsSection(page, 'playback'); const setting = page.locator( '[data-test-id="web-player-shared-controls-setting"]' @@ -64,6 +76,7 @@ test.describe('Settings', () => { await saveSettings(page); await page.reload(); await openSettings(page); + await openSettingsSection(page, 'playback'); await expect(checkbox).toBeChecked(); }); @@ -93,6 +106,134 @@ test.describe('Settings', () => { ).toHaveAttribute('aria-checked', 'true'); }); + test('@settings @web Deep links open one section page and unknown sections redirect', async ({ + page, + }) => { + await page.goto('/workspace/settings/playback'); + + // Only the routed section renders — the playback controls are + // there, the general ones are not. + await expect( + page.locator('[data-test-id="select-video-player"]') + ).toBeVisible(); + await expect( + page.locator('[data-test-id="select-language"]') + ).toHaveCount(0); + + // A stale or mistyped section URL is rewritten to the default page. + await page.goto('/workspace/settings/nonsense'); + await page.waitForURL(/\/workspace\/settings\/general$/); + await expect( + page.locator('[data-test-id="select-language"]') + ).toBeVisible(); + }); + + test('@settings @web Unsaved bar survives section switches and discard reverts', async ({ + page, + }) => { + await openSettings(page); + + const unsavedBar = page.locator( + '[data-test-id="settings-unsaved-bar"]' + ); + await expect(unsavedBar).toBeHidden(); + + const themeGroup = page.locator( + '[data-test-id="select-theme"][role="radiogroup"]' + ); + await themeGroup + .getByRole('radio', { name: 'Dark', exact: true }) + .click(); + await expect(unsavedBar).toBeVisible(); + + // The staged edit belongs to the page, not the section — moving to + // another section page must keep the bar (and the pending change). + await openSettingsSection(page, 'playback'); + await expect(unsavedBar).toBeVisible(); + + await page.locator('[data-test-id="discard-settings"]').click(); + await expect(unsavedBar).toBeHidden(); + + await openSettingsSection(page, 'general'); + await expect( + themeGroup.getByRole('radio', { name: 'System', exact: true }) + ).toHaveAttribute('aria-checked', 'true'); + }); + + test('@settings @web Leaving with unsaved edits asks for confirmation', async ({ + page, + }) => { + await openSettings(page); + + const themeGroup = page.locator( + '[data-test-id="select-theme"][role="radiogroup"]' + ); + await themeGroup + .getByRole('radio', { name: 'Dark', exact: true }) + .click(); + + // Trying to leave the settings area surfaces the dialog. + await page + .getByRole('navigation') + .getByRole('link', { name: 'Dashboard', exact: true }) + .click(); + const dialog = page.getByRole('dialog'); + await expect(dialog).toBeVisible(); + + // Keep editing: navigation is cancelled, the edit survives. + await dialog.locator('[data-test-id="unsaved-dialog-stay"]').click(); + await expect(dialog).toBeHidden(); + await expect(page).toHaveURL(/\/workspace\/settings\/general$/); + await expect( + page.locator('[data-test-id="settings-unsaved-bar"]') + ).toBeVisible(); + + // Leave without saving: the staged edit is discarded. + await page + .getByRole('navigation') + .getByRole('link', { name: 'Dashboard', exact: true }) + .click(); + await page + .getByRole('dialog') + .locator('[data-test-id="unsaved-dialog-discard"]') + .click(); + await page.waitForURL(/\/workspace\/dashboard$/); + + await openSettings(page); + await expect( + themeGroup.getByRole('radio', { name: 'System', exact: true }) + ).toHaveAttribute('aria-checked', 'true'); + }); + + test('@settings @web Save-and-leave persists the staged edit', async ({ + page, + }) => { + await openSettings(page); + + const themeGroup = page.locator( + '[data-test-id="select-theme"][role="radiogroup"]' + ); + await themeGroup + .getByRole('radio', { name: 'Dark', exact: true }) + .click(); + + await page + .getByRole('navigation') + .getByRole('link', { name: 'Dashboard', exact: true }) + .click(); + await page + .getByRole('dialog') + .locator('[data-test-id="unsaved-dialog-save"]') + .click(); + await page.waitForURL(/\/workspace\/dashboard$/); + + await page.reload(); + await openSettings(page); + await expect( + themeGroup.getByRole('radio', { name: 'Dark', exact: true }) + ).toHaveAttribute('aria-checked', 'true'); + }); + test('@settings @web Change app language', async ({ page }) => { await openSettings(page); const languageSelect = page.locator('[data-test-id="select-language"]'); diff --git a/apps/web/src/app/app.config.ts b/apps/web/src/app/app.config.ts index b5b45d06c..73c53d1bd 100644 --- a/apps/web/src/app/app.config.ts +++ b/apps/web/src/app/app.config.ts @@ -26,7 +26,6 @@ import { PORTAL_EXTERNAL_PLAYBACK, PORTAL_PLAYER, } from '@iptvnator/portal/shared/util'; -import { PLAYLIST_PLAYER_ACTIONS } from '@iptvnator/playlist/shared/util'; import { provideXtreamDataSource } from '@iptvnator/portal/xtream/data-access'; import { DataService } from '@iptvnator/services'; import { dbConfig } from '@iptvnator/shared/interfaces'; @@ -35,10 +34,6 @@ import { routes } from './app.routes'; import { ElectronService } from './services/electron.service'; import { ExternalPlaybackService } from './services/external-playback.service'; import { PlayerService } from './services/player.service'; -import { - AppPortalNavigationActionsService, - providePortalNavigationActions, -} from './services/portal-navigation-actions.service'; import { providePortalPlaybackPositions } from './services/portal-playback-positions.service'; import { PwaService } from './services/pwa.service'; import { shouldEnableServiceWorker } from './services/runtime-config'; @@ -147,11 +142,6 @@ export const appConfig: ApplicationConfig = { useExisting: ExternalPlaybackService, }, ...providePortalPlaybackPositions(), - ...providePortalNavigationActions(), - { - provide: PLAYLIST_PLAYER_ACTIONS, - useExisting: AppPortalNavigationActionsService, - }, ...provideWorkspaceShellActions(), ...provideXtreamDataSource(), { diff --git a/apps/web/src/app/app.routes.ts b/apps/web/src/app/app.routes.ts index 0fd1f60c6..565bfd3eb 100644 --- a/apps/web/src/app/app.routes.ts +++ b/apps/web/src/app/app.routes.ts @@ -2,6 +2,7 @@ import { inject } from '@angular/core'; import { Router, Routes } from '@angular/router'; import { RuntimeCapabilitiesService, SettingsStore } from '@iptvnator/services'; import { WorkspaceStartupPreferencesService } from '@iptvnator/workspace/shell/util'; +import { settingsUnsavedChangesGuard } from './settings/settings-unsaved-changes.guard'; const settingsReadyResolver = () => inject(SettingsStore).loadSettings(); @@ -141,10 +142,27 @@ export const routes: Routes = [ }, { path: 'settings', - loadComponent: () => - import('./settings/settings.component').then( - (c) => c.SettingsComponent - ), + children: [ + { + path: '', + pathMatch: 'full', + redirectTo: 'general', + }, + { + // One routed component for every section: the same + // instance survives :section param changes (default + // route reuse), so the settings form — and its dirty + // state — persists while the user moves between + // section pages. The guard only intercepts leaving + // the settings area with unsaved edits. + path: ':section', + canDeactivate: [settingsUnsavedChangesGuard], + loadComponent: () => + import('./settings/settings.component').then( + (c) => c.SettingsComponent + ), + }, + ], }, ], }, diff --git a/apps/web/src/app/services/portal-navigation-actions.service.ts b/apps/web/src/app/services/portal-navigation-actions.service.ts deleted file mode 100644 index 863185e71..000000000 --- a/apps/web/src/app/services/portal-navigation-actions.service.ts +++ /dev/null @@ -1,64 +0,0 @@ -import { inject, Injectable, Provider } from '@angular/core'; -import { MatDialog } from '@angular/material/dialog'; -import { PlaylistInfoComponent } from '@iptvnator/playlist/shared/ui'; -import { - PORTAL_NAVIGATION_ACTIONS, - PortalNavigationActions, -} from '@iptvnator/portal/shared/util'; -import { XtreamStore } from '@iptvnator/portal/xtream/data-access'; -import { Playlist } from '@iptvnator/shared/interfaces'; -import { SettingsComponent } from '../settings/settings.component'; - -@Injectable({ providedIn: 'root' }) -export class AppPortalNavigationActionsService - implements PortalNavigationActions -{ - private readonly dialog = inject(MatDialog); - private readonly xtreamStore = inject(XtreamStore); - - openAccountInfo(): void { - const data = { - vodStreamsCount: this.xtreamStore.vodStreams().length, - liveStreamsCount: this.xtreamStore.liveStreams().length, - seriesCount: this.xtreamStore.serialStreams().length, - }; - - void import('@iptvnator/portal/xtream/feature').then( - ({ AccountInfoComponent }) => { - this.dialog.open(AccountInfoComponent, { - width: '80%', - maxWidth: '1200px', - maxHeight: '90vh', - data, - }); - } - ); - } - - openPlaylistInfo(playlist: Playlist | null | undefined): void { - this.dialog.open(PlaylistInfoComponent, { - data: playlist ?? null, - }); - } - - openSettings(): void { - this.dialog.open(SettingsComponent, { - width: '1200px', - maxWidth: '96vw', - maxHeight: '92vh', - data: { - isDialog: true, - }, - }); - } -} - -export function providePortalNavigationActions(): Provider[] { - return [ - AppPortalNavigationActionsService, - { - provide: PORTAL_NAVIGATION_ACTIONS, - useExisting: AppPortalNavigationActionsService, - }, - ]; -} diff --git a/apps/web/src/app/settings/settings-about-section.component.html b/apps/web/src/app/settings/settings-about-section.component.html index c5ab3ddaf..34881ab6d 100644 --- a/apps/web/src/app/settings/settings-about-section.component.html +++ b/apps/web/src/app/settings/settings-about-section.component.html @@ -1,6 +1,5 @@
diff --git a/apps/web/src/app/settings/settings-about-section.component.spec.ts b/apps/web/src/app/settings/settings-about-section.component.spec.ts index ce5c87265..689a93f38 100644 --- a/apps/web/src/app/settings/settings-about-section.component.spec.ts +++ b/apps/web/src/app/settings/settings-about-section.component.spec.ts @@ -16,7 +16,6 @@ function configureComponent( fixture: ComponentFixture, status: ElectronBridgeAppUpdateStatus ) { - fixture.componentRef.setInput('activeSection', 'about'); fixture.componentRef.setInput('isDesktop', true); fixture.componentRef.setInput('version', '0.22.0'); fixture.componentRef.setInput('updateMessage', ''); @@ -145,7 +144,6 @@ describe('SettingsAboutSectionComponent version display', () => { }).compileComponents(); fixture = TestBed.createComponent(SettingsAboutSectionComponent); - fixture.componentRef.setInput('activeSection', 'about'); fixture.componentRef.setInput('version', '0.23.0'); }); diff --git a/apps/web/src/app/settings/settings-about-section.component.ts b/apps/web/src/app/settings/settings-about-section.component.ts index eb02ce4e7..849803476 100644 --- a/apps/web/src/app/settings/settings-about-section.component.ts +++ b/apps/web/src/app/settings/settings-about-section.component.ts @@ -24,7 +24,6 @@ import { ], }) export class SettingsAboutSectionComponent { - readonly activeSection = input.required(); readonly isDesktop = input(false); readonly version = input(); readonly buildCommit = input(); diff --git a/apps/web/src/app/settings/settings-backup-section.component.html b/apps/web/src/app/settings/settings-backup-section.component.html index 0a9f7189d..bae147754 100644 --- a/apps/web/src/app/settings/settings-backup-section.component.html +++ b/apps/web/src/app/settings/settings-backup-section.component.html @@ -1,6 +1,5 @@
diff --git a/apps/web/src/app/settings/settings-backup-section.component.ts b/apps/web/src/app/settings/settings-backup-section.component.ts index 9cfb5af6c..5b092ca8b 100644 --- a/apps/web/src/app/settings/settings-backup-section.component.ts +++ b/apps/web/src/app/settings/settings-backup-section.component.ts @@ -17,7 +17,6 @@ import { TranslateModule } from '@ngx-translate/core'; styles: [':host { display: contents; }'], }) export class SettingsBackupSectionComponent { - readonly activeSection = input.required(); readonly isPwa = input(false); readonly isRemovingAllPlaylists = input(false); readonly isExportingData = input(false); diff --git a/apps/web/src/app/settings/settings-dashboard-section.component.html b/apps/web/src/app/settings/settings-dashboard-section.component.html index 8e2db7c31..1b35089af 100644 --- a/apps/web/src/app/settings/settings-dashboard-section.component.html +++ b/apps/web/src/app/settings/settings-dashboard-section.component.html @@ -1,6 +1,5 @@
diff --git a/apps/web/src/app/settings/settings-dashboard-section.component.ts b/apps/web/src/app/settings/settings-dashboard-section.component.ts index 11b544b4d..bba422dea 100644 --- a/apps/web/src/app/settings/settings-dashboard-section.component.ts +++ b/apps/web/src/app/settings/settings-dashboard-section.component.ts @@ -20,5 +20,4 @@ import { TranslateModule } from '@ngx-translate/core'; }) export class SettingsDashboardSectionComponent { readonly form = input.required(); - readonly activeSection = input.required(); } diff --git a/apps/web/src/app/settings/settings-epg-section.component.html b/apps/web/src/app/settings/settings-epg-section.component.html index d4153604e..43e3b3bdc 100644 --- a/apps/web/src/app/settings/settings-epg-section.component.html +++ b/apps/web/src/app/settings/settings-epg-section.component.html @@ -1,6 +1,5 @@
diff --git a/apps/web/src/app/settings/settings-epg-section.component.ts b/apps/web/src/app/settings/settings-epg-section.component.ts index 4fbd8aea0..38c415687 100644 --- a/apps/web/src/app/settings/settings-epg-section.component.ts +++ b/apps/web/src/app/settings/settings-epg-section.component.ts @@ -32,7 +32,6 @@ import { EpgViewModeOption } from './settings.models'; }) export class SettingsEpgSectionComponent { readonly form = input.required(); - readonly activeSection = input.required(); readonly epgUrl = input.required(); readonly isClearingEpgData = input(false); readonly epgViewModeOptions = input.required(); diff --git a/apps/web/src/app/settings/settings-form.facade.ts b/apps/web/src/app/settings/settings-form.facade.ts index 445b8e215..c64e46e3b 100644 --- a/apps/web/src/app/settings/settings-form.facade.ts +++ b/apps/web/src/app/settings/settings-form.facade.ts @@ -99,15 +99,18 @@ export class SettingsFormFacade { this.settingsService.changeTheme(theme); } + /** + * Staged like every other control — the write happens on Save. These two + * used to persist eagerly, which made Discard a lie: `hydrateFromStore()` + * would faithfully reload the just-persisted edit and the unsaved bar + * disappeared without anything being reverted. + */ selectCoverSize(coverSize: CoverSize): void { if (this.form.value.coverSize === coverSize) { return; } this.patchAndMarkDirty({ coverSize }, 'coverSize'); - this.settingsStore.updateSettings({ coverSize }).catch(() => { - this.settingsSnackbar.storageFailure('save'); - }); } selectEpgViewMode(epgViewMode: EpgViewMode): void { @@ -116,9 +119,6 @@ export class SettingsFormFacade { } this.patchAndMarkDirty({ epgViewMode }, 'epgViewMode'); - this.settingsStore.updateSettings({ epgViewMode }).catch(() => { - this.settingsSnackbar.storageFailure('save'); - }); } setRecordingFolder(recordingFolder: string): void { diff --git a/apps/web/src/app/settings/settings-general-section.component.html b/apps/web/src/app/settings/settings-general-section.component.html index dda4b45b5..84d8ba2f5 100644 --- a/apps/web/src/app/settings/settings-general-section.component.html +++ b/apps/web/src/app/settings/settings-general-section.component.html @@ -1,6 +1,5 @@
diff --git a/apps/web/src/app/settings/settings-general-section.component.ts b/apps/web/src/app/settings/settings-general-section.component.ts index 4125376dc..86dace21f 100644 --- a/apps/web/src/app/settings/settings-general-section.component.ts +++ b/apps/web/src/app/settings/settings-general-section.component.ts @@ -30,7 +30,6 @@ import { }) export class SettingsGeneralSectionComponent { readonly form = input.required(); - readonly activeSection = input.required(); readonly languageEnum = input.required(); readonly themeOptions = input.required(); readonly coverSizeOptions = input.required(); diff --git a/apps/web/src/app/settings/settings-options.spec.ts b/apps/web/src/app/settings/settings-options.spec.ts index 545c6b918..6b40049b3 100644 --- a/apps/web/src/app/settings/settings-options.spec.ts +++ b/apps/web/src/app/settings/settings-options.spec.ts @@ -1,81 +1,41 @@ -import { readFileSync, readdirSync } from 'fs'; +import { readFileSync } from 'fs'; import { resolve } from 'path'; import { buildSettingsSectionNavItems } from './settings-options'; /** - * Every nav-item id must match the `id="..."` attribute on its - * corresponding section component template, otherwise clicking the nav - * link silently no-ops (the scroll directive runs `document.getElementById` - * and falls through when no element is found). + * Every nav-item id must match a `@case ('...')` label in the settings page + * template (or the `@default` general section), otherwise clicking the nav + * link routes to `/workspace/settings/:id` and renders the fallback general + * page instead of the intended section. * - * This bug bit the Remote section once already — its nav id was set to - * the Nx library name (`@iptvnator/ui/remote-control`) instead of the - * template's `id="remote-control"`. The guard below catches future - * rename/copy-paste regressions before they ship. + * The id coupling bit the Remote section once already in the scroll-anchor + * era — its nav id was set to the Nx library name + * (`@iptvnator/ui/remote-control`) instead of `remote-control`. The guard + * below catches future rename/copy-paste regressions before they ship. */ describe('buildSettingsSectionNavItems', () => { // Anchor on the Nx workspace root (Jest runs from there); avoids // depending on __dirname which isn't defined under the project's // ESM Jest preset. - const settingsDir = resolve( - process.cwd(), - 'apps/web/src/app/settings' - ); + const settingsDir = resolve(process.cwd(), 'apps/web/src/app/settings'); - function collectSectionTemplateIds(): Set { - const ids = new Set(); - for (const fileName of readdirSync(settingsDir)) { - if (!/^settings-.+-section\.component\.html$/.test(fileName)) { - continue; - } - const html = readFileSync( - resolve(settingsDir, fileName), - 'utf-8' - ); - // Match the FIRST `id="…"` on the
root only — - // descendant elements (form controls, anchors) also use id= - // attributes and would pollute the set. - const rootMatch = /]*\sid="([^"]+)"/i.exec(html); - if (rootMatch) { - ids.add(rootMatch[1]); - } - } - return ids; - } - - function collectSettingsComponentSectionOrder(): string[] { + /** Section ids the page template can render: @case labels + @default. */ + function collectRenderableSectionIds(): Set { const html = readFileSync( resolve(settingsDir, 'settings.component.html'), 'utf-8' ); - const idsByComponent = new Map(); - - for (const fileName of readdirSync(settingsDir)) { - const componentMatch = - /^settings-(.+)-section\.component\.html$/.exec(fileName); - if (!componentMatch) { - continue; - } - const sectionHtml = readFileSync( - resolve(settingsDir, fileName), - 'utf-8' - ); - const rootMatch = /]*\sid="([^"]+)"/i.exec( - sectionHtml - ); - if (rootMatch) { - idsByComponent.set( - `app-settings-${componentMatch[1]}-section`, - rootMatch[1] - ); - } + const ids = new Set( + Array.from( + html.matchAll(/@case \('([a-z-]+)'\)/g), + (match) => match[1] + ) + ); + if (/@default\s*\{\s* idsByComponent.get(match[0].slice(1)) - ).filter((id): id is string => Boolean(id)); + return ids; } it('exposes feature-specific items only when their runtime capabilities are supported', () => { @@ -100,28 +60,34 @@ describe('buildSettingsSectionNavItems', () => { ).toBe(false); }); - it('every nav id matches an existing section template id (regression: remote-control nav no longer maps to the Nx lib name)', () => { + it('every nav id renders a section page in the settings template', () => { const navIds = new Set( buildSettingsSectionNavItems({ supportsEpg: true, supportsRemoteControl: true, }).map((item) => item.id) ); - const templateIds = collectSectionTemplateIds(); + const renderableIds = collectRenderableSectionIds(); - // Every nav id must exist as a section root id. - const orphans = [...navIds].filter((id) => !templateIds.has(id)); + // A nav id without a template case silently falls back to the + // general page. + const orphans = [...navIds].filter((id) => !renderableIds.has(id)); expect(orphans).toEqual([]); - // And every section template id must be reachable from the nav - // (catches the opposite drift — a new section added without a nav - // entry would never get scrolled to). - const unreachable = [...templateIds].filter((id) => !navIds.has(id)); + // And every template case must be reachable from the nav (catches + // the opposite drift — a new section page added without a nav entry + // could never be opened). + const unreachable = [...renderableIds].filter((id) => !navIds.has(id)); expect(unreachable).toEqual([]); }); - it('keeps Dashboard below EPG in both the settings rail and content order', () => { - const expectedOrder = [ + it('keeps the settings rail in the expected order', () => { + expect( + buildSettingsSectionNavItems({ + supportsEpg: true, + supportsRemoteControl: true, + }).map((item) => item.id) + ).toEqual([ 'general', 'playback', 'epg', @@ -131,14 +97,6 @@ describe('buildSettingsSectionNavItems', () => { 'backup', 'reset', 'about', - ]; - - expect( - buildSettingsSectionNavItems({ - supportsEpg: true, - supportsRemoteControl: true, - }).map((item) => item.id) - ).toEqual(expectedOrder); - expect(collectSettingsComponentSectionOrder()).toEqual(expectedOrder); + ]); }); }); diff --git a/apps/web/src/app/settings/settings-playback-section.auto-failover.spec.ts b/apps/web/src/app/settings/settings-playback-section.auto-failover.spec.ts index 82cbdc36e..a42f7c317 100644 --- a/apps/web/src/app/settings/settings-playback-section.auto-failover.spec.ts +++ b/apps/web/src/app/settings/settings-playback-section.auto-failover.spec.ts @@ -38,7 +38,6 @@ describe('SettingsPlaybackSectionComponent — VOD auto-failover', () => { }).compileComponents(); fixture = TestBed.createComponent(SettingsPlaybackSectionComponent); - fixture.componentRef.setInput('activeSection', 'playback'); fixture.componentRef.setInput('players', []); fixture.componentRef.setInput('streamFormatEnum', StreamFormat); }); diff --git a/apps/web/src/app/settings/settings-playback-section.component.html b/apps/web/src/app/settings/settings-playback-section.component.html index 453179207..243ebc1bf 100644 --- a/apps/web/src/app/settings/settings-playback-section.component.html +++ b/apps/web/src/app/settings/settings-playback-section.component.html @@ -1,6 +1,5 @@
diff --git a/apps/web/src/app/settings/settings-playback-section.component.spec.ts b/apps/web/src/app/settings/settings-playback-section.component.spec.ts index 8ad079ce4..4fdafeb9e 100644 --- a/apps/web/src/app/settings/settings-playback-section.component.spec.ts +++ b/apps/web/src/app/settings/settings-playback-section.component.spec.ts @@ -51,7 +51,6 @@ describe('SettingsPlaybackSectionComponent', () => { fixture = TestBed.createComponent(SettingsPlaybackSectionComponent); fixture.componentRef.setInput('form', createForm()); - fixture.componentRef.setInput('activeSection', 'playback'); fixture.componentRef.setInput('players', [ { id: VideoPlayer.VideoJs, diff --git a/apps/web/src/app/settings/settings-playback-section.component.ts b/apps/web/src/app/settings/settings-playback-section.component.ts index 52293eb2a..83ea439d0 100644 --- a/apps/web/src/app/settings/settings-playback-section.component.ts +++ b/apps/web/src/app/settings/settings-playback-section.component.ts @@ -45,7 +45,6 @@ export class SettingsPlaybackSectionComponent { ].join('\n'); readonly form = input.required(); - readonly activeSection = input.required(); readonly players = input.required(); readonly streamFormatEnum = input.required(); readonly isDesktop = input(false); diff --git a/apps/web/src/app/settings/settings-remote-control-section.component.html b/apps/web/src/app/settings/settings-remote-control-section.component.html index c49c44954..510c81ea4 100644 --- a/apps/web/src/app/settings/settings-remote-control-section.component.html +++ b/apps/web/src/app/settings/settings-remote-control-section.component.html @@ -1,6 +1,5 @@
diff --git a/apps/web/src/app/settings/settings-remote-control-section.component.ts b/apps/web/src/app/settings/settings-remote-control-section.component.ts index 5d243e36a..0d4d6d51d 100644 --- a/apps/web/src/app/settings/settings-remote-control-section.component.ts +++ b/apps/web/src/app/settings/settings-remote-control-section.component.ts @@ -28,7 +28,6 @@ import { QRCodeComponent } from 'angularx-qrcode'; }) export class SettingsRemoteControlSectionComponent { readonly form = input.required(); - readonly activeSection = input.required(); readonly localIpAddresses = input.required(); readonly visibleQrCodeIp = input(null); diff --git a/apps/web/src/app/settings/settings-reset-section.component.html b/apps/web/src/app/settings/settings-reset-section.component.html index 608dee007..18b4f441e 100644 --- a/apps/web/src/app/settings/settings-reset-section.component.html +++ b/apps/web/src/app/settings/settings-reset-section.component.html @@ -1,6 +1,5 @@
diff --git a/apps/web/src/app/settings/settings-reset-section.component.ts b/apps/web/src/app/settings/settings-reset-section.component.ts index 7e42e0f40..f9376a28b 100644 --- a/apps/web/src/app/settings/settings-reset-section.component.ts +++ b/apps/web/src/app/settings/settings-reset-section.component.ts @@ -18,7 +18,6 @@ import { SettingsPlaylistDeleteSummary } from './settings.models'; styles: [':host { display: contents; }'], }) export class SettingsResetSectionComponent { - readonly activeSection = input.required(); readonly isRemovingAllPlaylists = input(false); readonly canRemoveAllPlaylists = input(false); readonly playlistDeleteSummary = diff --git a/apps/web/src/app/settings/settings-section-scroll.directive.spec.ts b/apps/web/src/app/settings/settings-section-scroll.directive.spec.ts deleted file mode 100644 index f2c402094..000000000 --- a/apps/web/src/app/settings/settings-section-scroll.directive.spec.ts +++ /dev/null @@ -1,107 +0,0 @@ -import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; -import { By } from '@angular/platform-browser'; -import { EpgRuntimeBridgeService } from '@iptvnator/epg/data-access'; -import { SettingsContextService } from '@iptvnator/workspace/shell/util'; -import { SettingsComponent } from './settings.component'; -import { SettingsSectionScrollDirective } from './settings-section-scroll.directive'; -import { - configureSettingsComponentTestBed, - createElectronStub, - createEpgBridgeStub, - stubSettingsSideEffects, -} from './test-stubs/settings-test-harness.stub'; - -interface SettingsSectionScrollDirectiveTestApi { - getScrollRoot(): HTMLElement | null; -} - -/** - * The directive only makes sense against a real settings page, so this is an - * integration spec: it renders the component and drives the section - * navigation the workspace shell would trigger. - */ -describe('SettingsSectionScrollDirective', () => { - let fixture: ComponentFixture; - let epgBridge: Partial; - const originalElectron = window.electron; - - beforeEach(waitForAsync(() => { - epgBridge = createEpgBridgeStub(); - configureSettingsComponentTestBed(epgBridge); - })); - - beforeEach(() => { - window.electron = createElectronStub(); - }); - - afterEach(() => { - window.electron = originalElectron; - }); - - it('should scroll the selected navigation target within the workspace viewport', async () => { - jest.useFakeTimers(); - - try { - fixture = TestBed.createComponent(SettingsComponent); - const component = fixture.componentInstance; - const settingsContext = TestBed.inject(SettingsContextService); - const originalGetElementById = - document.getElementById.bind(document); - const scrollTo = jest.fn(); - const scrollRoot = { - scrollTop: 96, - clientHeight: 885, - scrollHeight: 2469, - getBoundingClientRect: () => - ({ - top: 56, - }) as DOMRect, - scrollTo, - } as unknown as HTMLElement; - - stubSettingsSideEffects(component); - fixture.detectChanges(); - const scrollDirective = fixture.debugElement - .query(By.directive(SettingsSectionScrollDirective)) - .injector.get(SettingsSectionScrollDirective); - jest.spyOn( - scrollDirective as unknown as SettingsSectionScrollDirectiveTestApi, - 'getScrollRoot' - ).mockReturnValue(scrollRoot); - - const getElementByIdSpy = jest - .spyOn(document, 'getElementById') - .mockImplementation((id: string) => { - if (id === 'about') { - return { - getBoundingClientRect: () => - ({ - top: 2050, - height: 159, - }) as DOMRect, - } as HTMLElement; - } - - return originalGetElementById(id); - }); - - await fixture.whenStable(); - fixture.detectChanges(); - settingsContext.navigateToSection('about'); - fixture.detectChanges(); - - expect(getElementByIdSpy).toHaveBeenCalledWith('about'); - expect(scrollTo).toHaveBeenCalledWith({ - behavior: 'smooth', - top: 1488, - }); - expect(settingsContext.pendingScrollTarget()).toBe('about'); - - jest.advanceTimersByTime(600); - - expect(settingsContext.pendingScrollTarget()).toBeNull(); - } finally { - jest.useRealTimers(); - } - }); -}); diff --git a/apps/web/src/app/settings/settings-section-scroll.directive.ts b/apps/web/src/app/settings/settings-section-scroll.directive.ts deleted file mode 100644 index d2207a61d..000000000 --- a/apps/web/src/app/settings/settings-section-scroll.directive.ts +++ /dev/null @@ -1,241 +0,0 @@ -import { - AfterViewInit, - Directive, - effect, - ElementRef, - inject, - Injector, - OnDestroy, -} from '@angular/core'; -import { SettingsContextService } from '@iptvnator/workspace/shell/util'; -import { ObservedSettingsSection } from './settings.models'; - -@Directive({ - selector: '[appSettingsSectionScroll]', -}) -export class SettingsSectionScrollDirective - implements AfterViewInit, OnDestroy -{ - private static readonly SECTION_SCROLL_TOP_GUTTER = 112; - private static readonly SECTION_SCROLL_BOTTOM_GUTTER = 124; - private static readonly PENDING_SCROLL_CLEAR_DELAY_MS = 600; - - private readonly elementRef = inject(ElementRef); - private readonly injector = inject(Injector); - private readonly settingsCtx = inject(SettingsContextService); - - private sectionObserver?: IntersectionObserver; - private pendingScrollClearTimer: ReturnType< - typeof window.setTimeout - > | null = null; - private pendingScrollClearRoot: HTMLElement | null = null; - private pendingScrollEndListener: (() => void) | null = null; - - constructor() { - effect( - () => { - const sectionId = this.settingsCtx.pendingScrollTarget(); - if (!sectionId || typeof document === 'undefined') { - return; - } - - const scrollRoot = this.scrollToSection(sectionId); - this.schedulePendingScrollTargetClear(scrollRoot); - }, - { injector: this.injector } - ); - - // The previous active-section change handler ran a 260ms box-shadow - // animation (inset 1px ring + soft glow) on whichever section - // became active during scroll. With the new flat layout (no card - // chrome, no static active ring) that pulse drew a brief 1px - // border around each block as the user scrolled past — what the - // user reported as "short border kind of highlight around blocks". - // The rail's left active state already announces the current - // section, so the inline pulse is redundant. Removed entirely. - } - - ngAfterViewInit(): void { - requestAnimationFrame(() => this.setupSectionObserver()); - } - - ngOnDestroy(): void { - this.cancelPendingScrollTargetClear(); - this.sectionObserver?.disconnect(); - } - - private setupSectionObserver(): void { - if (typeof IntersectionObserver === 'undefined') { - return; - } - - const scrollRoot = this.getScrollRoot(); - const contentSections = Array.from( - this.elementRef.nativeElement.querySelectorAll( - '.settings-group[id]' - ) - ) as HTMLElement[]; - const sections: ObservedSettingsSection[] = contentSections.map( - (section) => ({ - id: section.id, - element: section, - }) - ); - - if (sections.length === 0) { - return; - } - - this.sectionObserver?.disconnect(); - this.sectionObserver = new IntersectionObserver( - () => { - if (this.settingsCtx.pendingScrollTarget()) { - return; - } - - const activeSection = this.resolveActiveSection(sections); - if (activeSection) { - this.settingsCtx.setActiveSection(activeSection); - } - }, - { - root: scrollRoot, - threshold: [0.12, 0.24, 0.4, 0.6], - rootMargin: '-18% 0px -52% 0px', - } - ); - - sections.forEach((section) => - this.sectionObserver?.observe(section.element) - ); - - const initialSection = this.resolveActiveSection(sections); - if (initialSection) { - this.settingsCtx.setActiveSection(initialSection); - } - } - - private resolveActiveSection( - sections: ObservedSettingsSection[] - ): string | null { - const scrollRoot = this.getScrollRoot(); - const rootTop = scrollRoot?.getBoundingClientRect().top ?? 0; - const rootHeight = scrollRoot?.clientHeight ?? window.innerHeight; - const activationLine = rootTop + Math.min(rootHeight * 0.28, 220); - const sectionAtActivationLine = sections.find((section) => { - const rect = section.element.getBoundingClientRect(); - return rect.top <= activationLine && rect.bottom >= activationLine; - }); - - if (sectionAtActivationLine) { - return sectionAtActivationLine.id; - } - - const nearestSection = sections - .map((section) => ({ - id: section.id, - distance: Math.abs( - section.element.getBoundingClientRect().top - activationLine - ), - })) - .sort((a, b) => a.distance - b.distance)[0]; - - return nearestSection?.id ?? null; - } - - private getScrollRoot(): HTMLElement | null { - return this.elementRef.nativeElement.closest( - 'main.workspace-content' - ) as HTMLElement | null; - } - - private schedulePendingScrollTargetClear( - scrollRoot: HTMLElement | null - ): void { - const clearPendingScrollTarget = () => { - this.cancelPendingScrollTargetClear(); - this.settingsCtx.clearPendingScrollTarget(); - }; - - this.cancelPendingScrollTargetClear(); - this.pendingScrollClearTimer = window.setTimeout( - clearPendingScrollTarget, - SettingsSectionScrollDirective.PENDING_SCROLL_CLEAR_DELAY_MS - ); - this.pendingScrollClearRoot = scrollRoot; - this.pendingScrollEndListener = clearPendingScrollTarget; - scrollRoot?.addEventListener?.('scrollend', clearPendingScrollTarget, { - once: true, - }); - } - - private cancelPendingScrollTargetClear(): void { - if (this.pendingScrollClearTimer) { - clearTimeout(this.pendingScrollClearTimer); - this.pendingScrollClearTimer = null; - } - - if (this.pendingScrollClearRoot && this.pendingScrollEndListener) { - this.pendingScrollClearRoot.removeEventListener?.( - 'scrollend', - this.pendingScrollEndListener - ); - } - - this.pendingScrollClearRoot = null; - this.pendingScrollEndListener = null; - } - - private scrollToSection(sectionId: string): HTMLElement | null { - const sectionElement = document.getElementById(sectionId); - if (!sectionElement) { - return null; - } - - const scrollRoot = this.getScrollRoot(); - if (!scrollRoot) { - sectionElement.scrollIntoView({ - behavior: 'smooth', - block: 'start', - }); - return null; - } - - const rootRect = scrollRoot.getBoundingClientRect(); - const sectionRect = sectionElement.getBoundingClientRect(); - const sectionTop = - scrollRoot.scrollTop + (sectionRect.top - rootRect.top); - const sectionBottom = sectionTop + sectionRect.height; - const visibleTop = - scrollRoot.scrollTop + - SettingsSectionScrollDirective.SECTION_SCROLL_TOP_GUTTER; - const visibleBottom = - scrollRoot.scrollTop + - scrollRoot.clientHeight - - SettingsSectionScrollDirective.SECTION_SCROLL_BOTTOM_GUTTER; - let nextScrollTop = scrollRoot.scrollTop; - - if (sectionTop < visibleTop) { - nextScrollTop = - sectionTop - - SettingsSectionScrollDirective.SECTION_SCROLL_TOP_GUTTER; - } else if (sectionBottom > visibleBottom) { - nextScrollTop = - sectionBottom - - scrollRoot.clientHeight + - SettingsSectionScrollDirective.SECTION_SCROLL_BOTTOM_GUTTER; - } - - const maxScrollTop = Math.max( - 0, - scrollRoot.scrollHeight - scrollRoot.clientHeight - ); - - scrollRoot.scrollTo({ - top: Math.min(Math.max(nextScrollTop, 0), maxScrollTop), - behavior: 'smooth', - }); - - return scrollRoot; - } -} diff --git a/apps/web/src/app/settings/settings-tmdb-section.component.html b/apps/web/src/app/settings/settings-tmdb-section.component.html index 99e5fa6d5..a720c1832 100644 --- a/apps/web/src/app/settings/settings-tmdb-section.component.html +++ b/apps/web/src/app/settings/settings-tmdb-section.component.html @@ -1,6 +1,5 @@
diff --git a/apps/web/src/app/settings/settings-tmdb-section.component.spec.ts b/apps/web/src/app/settings/settings-tmdb-section.component.spec.ts index 9e09c60f3..b0c152a1b 100644 --- a/apps/web/src/app/settings/settings-tmdb-section.component.spec.ts +++ b/apps/web/src/app/settings/settings-tmdb-section.component.spec.ts @@ -27,14 +27,24 @@ describe('SettingsTmdbSectionComponent', () => { const clearButton = () => queryByTestId('tmdb-clear-cache') as HTMLButtonElement; - /** The stats effect only fires once the TMDB section is the active one */ - const activate = async (section = 'tmdb') => { - fixture.componentRef.setInput('activeSection', section); - fixture.detectChanges(); + /** Constructor kicks off the stats read; let it settle and render. */ + const settle = async () => { await fixture.whenStable(); fixture.detectChanges(); }; + /** + * The section component only exists while its settings page is open, so + * "reopening the section" means recreating the component. + */ + const reopenSection = async () => { + fixture.destroy(); + fixture = TestBed.createComponent(SettingsTmdbSectionComponent); + fixture.componentRef.setInput('form', createForm()); + fixture.detectChanges(); + await settle(); + }; + beforeEach(async () => { getStats = jest.fn().mockResolvedValue({ entries: 42, bytes: 2048 }); clear = jest.fn().mockResolvedValue(42); @@ -73,21 +83,18 @@ describe('SettingsTmdbSectionComponent', () => { fixture = TestBed.createComponent(SettingsTmdbSectionComponent); fixture.componentRef.setInput('form', createForm()); - fixture.componentRef.setInput('activeSection', 'general'); fixture.detectChanges(); }); - it('defers the full-table scan until the section is opened', async () => { - expect(getStats).not.toHaveBeenCalled(); - - await activate(); + it('sizes the cache as soon as the section page opens', async () => { + await settle(); expect(getStats).toHaveBeenCalledTimes(1); expect(queryByTestId('tmdb-cache-size')?.textContent).toContain('42'); }); it('clears the cache and re-reads the size', async () => { - await activate(); + await settle(); getStats.mockResolvedValue({ entries: 0, bytes: 0 }); clearButton().click(); @@ -101,7 +108,7 @@ describe('SettingsTmdbSectionComponent', () => { }); it('surfaces a failed clear instead of claiming an empty cache', async () => { - await activate(); + await settle(); clear.mockResolvedValue(null); clearButton().click(); @@ -116,9 +123,9 @@ describe('SettingsTmdbSectionComponent', () => { }); it('surfaces a failed size read the same way', async () => { + getStats.mockReset(); getStats.mockResolvedValue(null); - - await activate(); + await reopenSection(); expect(queryByTestId('tmdb-cache-size')?.textContent).toContain( CACHE_ERROR_LABEL @@ -126,17 +133,18 @@ describe('SettingsTmdbSectionComponent', () => { }); it('retries the size read next time the section is opened', async () => { + getStats.mockReset(); getStats.mockResolvedValueOnce(null); - await activate(); + getStats.mockResolvedValue({ entries: 42, bytes: 2048 }); + await reopenSection(); expect(queryByTestId('tmdb-cache-size')?.textContent).toContain( CACHE_ERROR_LABEL ); - await activate('general'); - await activate(); + // Otherwise a single transient failure sticks until the destructive + // Clear button shifts it — reopening the page must retry instead + await reopenSection(); - // Otherwise a single transient failure sticks for the life of the - // page, and only the destructive Clear button can shift it expect(getStats).toHaveBeenCalledTimes(2); expect(queryByTestId('tmdb-cache-size')?.textContent).toContain('42'); }); diff --git a/apps/web/src/app/settings/settings-tmdb-section.component.ts b/apps/web/src/app/settings/settings-tmdb-section.component.ts index e7a6f7e3c..f01087a17 100644 --- a/apps/web/src/app/settings/settings-tmdb-section.component.ts +++ b/apps/web/src/app/settings/settings-tmdb-section.component.ts @@ -1,10 +1,8 @@ import { Component, - effect, inject, input, signal, - untracked, ViewEncapsulation, } from '@angular/core'; import { FormGroup, ReactiveFormsModule } from '@angular/forms'; @@ -84,7 +82,6 @@ export class SettingsTmdbSectionComponent { private readonly tmdbCache = inject(TmdbCacheService); readonly form = input.required(); - readonly activeSection = input.required(); readonly keyTestState = signal('idle'); readonly cacheStats = signal(null); @@ -92,21 +89,13 @@ export class SettingsTmdbSectionComponent { readonly isClearing = signal(false); constructor() { - // Sizing the cache is a full table scan, so it waits until the - // user is actually looking at this section. - effect(() => { - if (this.activeSection() !== 'tmdb') { - return; - } - untracked(() => { - // A failed read leaves both signals in their "unknown" - // state, so reopening the section retries rather than - // showing the error until the page is rebuilt. - if (this.cacheStats() === null) { - void this.refreshCacheStats(); - } - }); - }); + // Sizing the cache is a full table scan, but this component only + // exists while its section page is open, so loading on construction + // preserves the old "wait until the user is actually looking" + // behaviour. A failed read leaves the signals in their "unknown" + // state and reopening the section recreates the component, which + // retries naturally. + void this.refreshCacheStats(); } get enteredApiKey(): string { diff --git a/apps/web/src/app/settings/settings-unsaved-changes-dialog.component.ts b/apps/web/src/app/settings/settings-unsaved-changes-dialog.component.ts new file mode 100644 index 000000000..37b8624d5 --- /dev/null +++ b/apps/web/src/app/settings/settings-unsaved-changes-dialog.component.ts @@ -0,0 +1,86 @@ +import { Component, inject } from '@angular/core'; +import { MatButtonModule } from '@angular/material/button'; +import { MAT_DIALOG_DATA, MatDialogModule } from '@angular/material/dialog'; +import { MatIconModule } from '@angular/material/icon'; +import { TranslateModule } from '@ngx-translate/core'; + +/** What the user picked; closing via backdrop/Esc yields `undefined` = stay. */ +export type SettingsUnsavedChangesChoice = 'save' | 'discard'; + +export interface SettingsUnsavedChangesDialogData { + /** Save-and-leave is offered only while the form can actually be saved. */ + canSave: boolean; +} + +/** + * Asked once, when the user is about to leave the settings area with staged + * edits. Section switches never show this — the shared form survives them. + * "Keep editing" carries the initial focus so Enter is always the safe + * choice. + */ +@Component({ + imports: [MatButtonModule, MatDialogModule, MatIconModule, TranslateModule], + styles: [ + ` + .unsaved-dialog__invalid-hint { + display: flex; + align-items: center; + gap: 8px; + margin: 12px 0 0; + color: var(--mat-sys-error); + font-size: 0.86rem; + + mat-icon { + font-size: 18px; + width: 18px; + height: 18px; + } + } + `, + ], + template: ` +

+ {{ 'SETTINGS.UNSAVED_DIALOG_TITLE' | translate }} +

+ +

{{ 'SETTINGS.UNSAVED_DIALOG_MESSAGE' | translate }}

+ @if (!data.canSave) { +

+ error_outline + {{ + 'SETTINGS.UNSAVED_DIALOG_INVALID_HINT' | translate + }} +

+ } +
+ + + + + + `, +}) +export class SettingsUnsavedChangesDialogComponent { + readonly data = inject(MAT_DIALOG_DATA); +} diff --git a/apps/web/src/app/settings/settings-unsaved-changes.guard.spec.ts b/apps/web/src/app/settings/settings-unsaved-changes.guard.spec.ts new file mode 100644 index 000000000..43fbd7683 --- /dev/null +++ b/apps/web/src/app/settings/settings-unsaved-changes.guard.spec.ts @@ -0,0 +1,58 @@ +import { ActivatedRouteSnapshot, RouterStateSnapshot } from '@angular/router'; +import { + SettingsLeaveConfirmation, + settingsUnsavedChangesGuard, +} from './settings-unsaved-changes.guard'; + +describe('settingsUnsavedChangesGuard', () => { + const runGuard = ( + nextUrl: string, + component: SettingsLeaveConfirmation + ): Promise | boolean => + settingsUnsavedChangesGuard( + component, + {} as ActivatedRouteSnapshot, + { url: '/workspace/settings/general' } as RouterStateSnapshot, + { url: nextUrl } as RouterStateSnapshot + ) as Promise | boolean; + + const componentSpy = (answer: boolean): SettingsLeaveConfirmation => ({ + confirmLeaveWithUnsavedChanges: jest.fn().mockResolvedValue(answer), + }); + + it.each([ + '/workspace/settings/playback', + '/workspace/settings/general?focus=theme', + '/workspace/settings', + ])('lets section-internal navigation to %s pass silently', (url) => { + const component = componentSpy(false); + + expect(runGuard(url, component)).toBe(true); + expect(component.confirmLeaveWithUnsavedChanges).not.toHaveBeenCalled(); + }); + + it('asks the component when actually leaving the settings area', async () => { + const component = componentSpy(false); + + await expect(runGuard('/workspace/dashboard', component)).resolves.toBe( + false + ); + expect(component.confirmLeaveWithUnsavedChanges).toHaveBeenCalledTimes( + 1 + ); + }); + + it('propagates an allowed leave', async () => { + await expect( + runGuard('/workspace/playlists/abc/all', componentSpy(true)) + ).resolves.toBe(true); + }); + + it('does not treat a lookalike prefix as inside the settings area', async () => { + const component = componentSpy(true); + + await runGuard('/workspace/settingsish', component); + + expect(component.confirmLeaveWithUnsavedChanges).toHaveBeenCalled(); + }); +}); diff --git a/apps/web/src/app/settings/settings-unsaved-changes.guard.ts b/apps/web/src/app/settings/settings-unsaved-changes.guard.ts new file mode 100644 index 000000000..3283ef2e4 --- /dev/null +++ b/apps/web/src/app/settings/settings-unsaved-changes.guard.ts @@ -0,0 +1,34 @@ +import { CanDeactivateFn } from '@angular/router'; + +/** + * Structural contract instead of importing the (lazy-loaded) settings + * component into the eager route config. + */ +export interface SettingsLeaveConfirmation { + confirmLeaveWithUnsavedChanges(): Promise | boolean; +} + +function isInsideSettingsArea(url: string): boolean { + const path = url.split('?')[0]; + return ( + path === '/workspace/settings' || + path.startsWith('/workspace/settings/') + ); +} + +/** + * Guards only the EXIT from the settings area. Section switches + * (`/settings/general` → `/settings/playback`) re-run deactivation checks + * because the route's `:section` param changes, but they share the one + * settings form and can never lose staged edits — asking there would be + * pure nagging, so they pass unconditionally. + */ +export const settingsUnsavedChangesGuard: CanDeactivateFn< + SettingsLeaveConfirmation +> = (component, _currentRoute, _currentState, nextState) => { + if (isInsideSettingsArea(nextState.url)) { + return true; + } + + return component.confirmLeaveWithUnsavedChanges(); +}; diff --git a/apps/web/src/app/settings/settings.component.form.spec.ts b/apps/web/src/app/settings/settings.component.form.spec.ts index c11db5c82..50508f394 100644 --- a/apps/web/src/app/settings/settings.component.form.spec.ts +++ b/apps/web/src/app/settings/settings.component.form.spec.ts @@ -18,6 +18,7 @@ import { DEFAULT_SETTINGS, MatSnackBarStub, MockSettingsStore, + setSettingsSection, stubSettingsSideEffects, } from './test-stubs/settings-test-harness.stub'; @@ -111,7 +112,7 @@ describe('SettingsComponent form', () => { expect(component.settingsForm.dirty).toBeTruthy(); }); - it('updates cover size through the general section output', () => { + it('stages cover size without writing to the store until Save', () => { const largeCoverButton = ( fixture.nativeElement as HTMLElement ).querySelector( @@ -122,12 +123,15 @@ describe('SettingsComponent form', () => { fixture.detectChanges(); expect(component.settingsForm.value.coverSize).toBe('large'); - expect(settingsStore.updateSettings).toHaveBeenCalledWith({ - coverSize: 'large', - }); + expect(component.settingsForm.dirty).toBe(true); + // An eager write here would make Discard unable to revert it + expect(settingsStore.updateSettings).not.toHaveBeenCalled(); }); - it('updates the EPG view mode through the epg section output', () => { + it('stages the EPG view mode without writing to the store until Save', () => { + setSettingsSection('epg'); + fixture.detectChanges(); + const listButton = ( fixture.nativeElement as HTMLElement ).querySelector( @@ -138,14 +142,16 @@ describe('SettingsComponent form', () => { fixture.detectChanges(); expect(component.settingsForm.value.epgViewMode).toBe('list'); - expect(settingsStore.updateSettings).toHaveBeenCalledWith({ - epgViewMode: 'list', - }); + expect(component.settingsForm.dirty).toBe(true); + expect(settingsStore.updateSettings).not.toHaveBeenCalled(); }); }); describe('Dashboard controls', () => { it('renders dashboard controls with the expected defaults', () => { + setSettingsSection('dashboard'); + fixture.detectChanges(); + const nativeElement = fixture.nativeElement as HTMLElement; expect( @@ -208,6 +214,78 @@ describe('SettingsComponent form', () => { }); }); + describe('Unsaved-changes bar', () => { + const unsavedBar = () => + (fixture.nativeElement as HTMLElement).querySelector( + '[data-test-id="settings-unsaved-bar"]' + ); + + it('stays hidden while the form is pristine', () => { + expect(unsavedBar()).toBeNull(); + }); + + it('appears once an edit lands and survives a section switch', () => { + component.settingsForm.get('theme')?.setValue(Theme.DarkTheme); + component.settingsForm.markAsDirty(); + fixture.detectChanges(); + + expect(unsavedBar()).not.toBeNull(); + + // The form lives on the page component, not the section pages — + // moving to another section must not swallow the pending edit. + setSettingsSection('about'); + fixture.detectChanges(); + + expect(unsavedBar()).not.toBeNull(); + expect(component.settingsForm.dirty).toBe(true); + }); + + it('hides again after a successful save', async () => { + settingsStore.updateSettings.mockResolvedValue(undefined); + component.settingsForm.get('theme')?.setValue(Theme.DarkTheme); + component.settingsForm.markAsDirty(); + fixture.detectChanges(); + + component.onSubmit(); + await fixture.whenStable(); + fixture.detectChanges(); + + expect(component.settingsForm.pristine).toBe(true); + expect(unsavedBar()).toBeNull(); + }); + + it('discard reverts a staged cover size (regression: eager persist made it stick)', () => { + const largeCoverButton = ( + fixture.nativeElement as HTMLElement + ).querySelector( + '[data-test-id="cover-size-large"]' + ) as HTMLButtonElement; + largeCoverButton.click(); + fixture.detectChanges(); + expect(component.settingsForm.value.coverSize).toBe('large'); + + component.discardChanges(); + + expect(component.settingsForm.value.coverSize).toBe('medium'); + expect(component.settingsForm.pristine).toBe(true); + expect(settingsStore.updateSettings).not.toHaveBeenCalled(); + }); + + it('discard reverts to the stored values and hides the bar', () => { + const storedTheme = component.settingsForm.value.theme; + component.settingsForm.get('theme')?.setValue(Theme.DarkTheme); + component.settingsForm.markAsDirty(); + fixture.detectChanges(); + + component.discardChanges(); + fixture.detectChanges(); + + expect(component.settingsForm.value.theme).toBe(storedTheme); + expect(component.settingsForm.pristine).toBe(true); + expect(unsavedBar()).toBeNull(); + }); + }); + describe('Saving', () => { it('shows the save confirmation snackbar at the bottom center with the settings offset class', () => { jest.spyOn(translate, 'instant').mockReturnValue('Settings saved'); diff --git a/apps/web/src/app/settings/settings.component.html b/apps/web/src/app/settings/settings.component.html index 6170972ad..fc4b3843f 100644 --- a/apps/web/src/app/settings/settings.component.html +++ b/apps/web/src/app/settings/settings.component.html @@ -1,13 +1,4 @@ -@if (isDialog) { -

{{ 'SETTINGS.GENERAL' | translate }}

-} - -
+
- @if (!isDialog) { - - - {{ 'SETTINGS.TITLE' | translate }} - - } + section below provides its own h3 + subtitle. --> + + {{ 'SETTINGS.TITLE' | translate }} +
- - - - - @if (supportsEpg) { - + + @switch (activeSection()) { + @case ('playback') { + + } + @case ('epg') { + + } + @case ('dashboard') { + + } + @case ('remote-control') { + + } + @case ('tmdb') { + + } + @case ('backup') { + + } + @case ('reset') { + + } + @case ('about') { + + } + @default { + + } } +
- - - @if (supportsRemoteControl) { - - } - - - - - - - - - -
- @if (isDialog) { + + @if (!settingsForm.pristine) { +
+
+ edit + {{ 'SETTINGS.UNSAVED_CHANGES' | translate }} +
+
- } - + +
-
+ }
diff --git a/apps/web/src/app/settings/settings.component.scss b/apps/web/src/app/settings/settings.component.scss index a29be1497..30522f62f 100644 --- a/apps/web/src/app/settings/settings.component.scss +++ b/apps/web/src/app/settings/settings.component.scss @@ -8,13 +8,6 @@ padding: 18px 20px calc(36px + var(--settings-safe-bottom)); } -.settings-container.dialog-mode { - height: auto; - max-height: none; - overflow: visible; - padding: 12px 8px calc(24px + var(--settings-safe-bottom)); -} - .settings-layout { display: flex; flex-direction: column; @@ -27,36 +20,6 @@ ); } -.settings-container.dialog-mode .settings-layout { - padding-bottom: 0; -} - -.settings-page-header { - padding: 14px 18px 8px; - background: transparent; - border: 0; - box-shadow: none; -} - -.settings-page-header__text { - max-width: 56ch; - - h1 { - margin: 0; - font-size: clamp(1.35rem, 2vw, 1.75rem); - line-height: 1.08; - letter-spacing: -0.03em; - font-weight: 700; - } - - p { - margin: 4px 0 0; - color: var(--settings-muted); - font-size: 0.86rem; - line-height: 1.45; - } -} - .settings-content { min-width: 0; display: flex; @@ -86,16 +49,6 @@ } } -// The active section no longer gets a ring/shadow — the rail's active -// state on the left already announces "you are here". Title still -// colours to the accent so users scanning the right pane don't lose the -// anchor. -.settings-group--active { - .settings-group__header-text h3 { - color: var(--app-selection-color); - } -} - .settings-group__header { display: flex; align-items: flex-start; @@ -738,13 +691,11 @@ ); } -// Sticky-footer Save bar — full content width so it never overlaps form -// rows. The previous chip-style bar (border-radius:18px, margin-left:auto) -// floated in the bottom-right corner and clipped fields underneath; the -// UX audit called this out as the worst of three available patterns. -// Now the bar acts as a proper footer: spans the column, separator line -// on top, sits flush with the bottom edge while still sticking on scroll. -.action-bar { +// Unsaved-changes bar — only present while the form is dirty, so unlike the +// previous always-visible footer Save button it doubles as the "you edited +// something on another section page" reminder. Sticky rather than fixed so +// it stays inside the settings column and never covers the shell rail. +.unsaved-bar { position: sticky; bottom: calc( var(--settings-action-bar-offset) + var(--settings-safe-bottom) @@ -756,7 +707,7 @@ box-sizing: border-box; z-index: 100; display: flex; - justify-content: flex-end; + justify-content: space-between; align-items: center; gap: 12px; flex-wrap: wrap; @@ -769,17 +720,45 @@ background: var(--settings-action-bar-bg); backdrop-filter: blur(12px); box-shadow: var(--settings-action-bar-shadow); + animation: unsaved-bar-enter 0.2s cubic-bezier(0.16, 1, 0.3, 1); + + @media (prefers-reduced-motion: reduce) { + animation: none; + } } -.settings-container.dialog-mode .action-bar { - position: static; - margin-top: 12px; - margin-bottom: 2px; - box-shadow: none; - border-radius: 16px; - padding: 12px; - border-top: 1px solid var(--settings-action-bar-border); - justify-content: space-between; +@keyframes unsaved-bar-enter { + from { + opacity: 0; + transform: translateY(8px); + } + + to { + opacity: 1; + transform: translateY(0); + } +} + +.unsaved-bar__message { + display: inline-flex; + align-items: center; + gap: 8px; + color: var(--settings-muted); + font-size: 0.88rem; + + mat-icon { + font-size: 18px; + width: 18px; + height: 18px; + } +} + +.unsaved-bar__actions { + display: inline-flex; + align-items: center; + gap: 8px; + margin-left: auto; + flex-wrap: wrap; } .settings-save-button.mat-mdc-button-base { @@ -882,13 +861,15 @@ flex-wrap: wrap; } - .action-bar { - position: static; - flex-direction: column-reverse; + .unsaved-bar { + flex-direction: column; align-items: stretch; border-radius: 16px; - box-shadow: none; - width: 100%; + } + + .unsaved-bar__actions { + flex-direction: column-reverse; + align-items: stretch; margin-left: 0; button { diff --git a/apps/web/src/app/settings/settings.component.spec.ts b/apps/web/src/app/settings/settings.component.spec.ts index 72442aec9..cc643487d 100644 --- a/apps/web/src/app/settings/settings.component.spec.ts +++ b/apps/web/src/app/settings/settings.component.spec.ts @@ -1,5 +1,7 @@ import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; +import { MatDialog } from '@angular/material/dialog'; import { Router } from '@angular/router'; +import { of } from 'rxjs'; import { EpgRuntimeBridgeService } from '@iptvnator/epg/data-access'; import { selectAllPlaylistsMeta } from '@iptvnator/m3u-state'; import { @@ -15,15 +17,17 @@ import { createEpgBridgeStub, createPlaylistMeta, DEFAULT_SETTINGS, + MockRouter, + setSettingsSection, stubSettingsSideEffects, } from './test-stubs/settings-test-harness.stub'; /** - * Page-shell behaviour: chrome, the facade lifecycle and the runtime - * capabilities that decide which sections and players are offered. Form - * editing and saving live in `settings.component.form.spec.ts`, section - * scrolling in `settings-section-scroll.directive.spec.ts`, and the - * per-section behaviour in the matching `*.facade.spec.ts` files. + * Page-shell behaviour: chrome, the `:section` page routing, the facade + * lifecycle and the runtime capabilities that decide which sections and + * players are offered. Form editing and saving live in + * `settings.component.form.spec.ts`, and the per-section behaviour in the + * matching `*.facade.spec.ts` files. */ describe('SettingsComponent', () => { let component: SettingsComponent; @@ -102,7 +106,7 @@ describe('SettingsComponent', () => { expect(appUpdateDispose).toHaveBeenCalledTimes(1); }); - it('should render a compact page header outside dialog mode', () => { + it('should render the hidden page header hook', () => { const nativeElement = fixture.nativeElement as HTMLElement; expect( @@ -111,32 +115,117 @@ describe('SettingsComponent', () => { expect(nativeElement.querySelector('.settings-intro')).toBeNull(); }); - it('should not render the page header in dialog mode', () => { - fixture.destroy(); + describe('Section pages', () => { + it('renders only the section named by the route param', () => { + const nativeElement = fixture.nativeElement as HTMLElement; - const dialogFixture = TestBed.createComponent(SettingsComponent); - const dialogComponent = dialogFixture.componentInstance; + expect( + nativeElement.querySelector('app-settings-general-section') + ).not.toBeNull(); + expect( + nativeElement.querySelector('app-settings-playback-section') + ).toBeNull(); - stubSettingsSideEffects(dialogComponent); - dialogComponent.isDialog = true; - dialogFixture.detectChanges(); + setSettingsSection('playback'); + fixture.detectChanges(); - const nativeElement = dialogFixture.nativeElement as HTMLElement; - expect( - nativeElement.querySelector('[data-test-id="settings-page-header"]') - ).toBeNull(); - expect( - nativeElement.querySelector('h2[mat-dialog-title]') - ).not.toBeNull(); + expect( + nativeElement.querySelector('app-settings-general-section') + ).toBeNull(); + expect( + nativeElement.querySelector('app-settings-playback-section') + ).not.toBeNull(); + }); + + it('falls back to the general page and rewrites unknown section URLs', () => { + const navigate = (router as unknown as MockRouter).navigate; + + setSettingsSection('nonsense'); + fixture.detectChanges(); + + expect(component.activeSection()).toBe('general'); + expect(navigate).toHaveBeenCalledWith( + ['/workspace/settings', 'general'], + { replaceUrl: true } + ); + expect( + (fixture.nativeElement as HTMLElement).querySelector( + 'app-settings-general-section' + ) + ).not.toBeNull(); + }); }); - it('should navigate back to home page', () => { - jest.spyOn(router, 'navigateByUrl'); - component.backToHome(); - expect(router.navigateByUrl).toHaveBeenCalledWith('/'); + describe('Leaving with unsaved changes', () => { + const answerDialogWith = ( + choice: 'save' | 'discard' | undefined + ): jest.Mock => { + const open = TestBed.inject(MatDialog).open as jest.Mock; + open.mockReturnValue({ afterClosed: () => of(choice) }); + return open; + }; + + it('lets a pristine form leave without asking', async () => { + const open = answerDialogWith(undefined); + + await expect( + component.confirmLeaveWithUnsavedChanges() + ).resolves.toBe(true); + expect(open).not.toHaveBeenCalled(); + }); + + it('stays when the dialog is dismissed', async () => { + component.settingsForm.markAsDirty(); + answerDialogWith(undefined); + + await expect( + component.confirmLeaveWithUnsavedChanges() + ).resolves.toBe(false); + expect(component.settingsForm.dirty).toBe(true); + }); + + it('discard-and-leave reverts the staged edits', async () => { + component.settingsForm.get('theme')?.setValue('DARK_THEME'); + component.settingsForm.markAsDirty(); + answerDialogWith('discard'); + + await expect( + component.confirmLeaveWithUnsavedChanges() + ).resolves.toBe(true); + expect(component.settingsForm.pristine).toBe(true); + }); + + it('save-and-leave persists before allowing the navigation', async () => { + component.settingsForm.markAsDirty(); + jest.spyOn(component.epg, 'fetchConfiguredEpg').mockImplementation(); + answerDialogWith('save'); + + await expect( + component.confirmLeaveWithUnsavedChanges() + ).resolves.toBe(true); + expect(component.settingsForm.pristine).toBe(true); + }); + + it('offers save-and-leave only while the form is valid', async () => { + component.settingsForm.markAsDirty(); + component.settingsForm.setErrors({ invalid: true }); + const open = answerDialogWith(undefined); + + await component.confirmLeaveWithUnsavedChanges(); + + expect(open).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + data: { canSave: false }, + }) + ); + }); }); it('enables the global wipe action only once a playlist exists', () => { + setSettingsSection('reset'); + fixture.detectChanges(); + const deleteButton = () => (fixture.nativeElement as HTMLElement).querySelector( '.danger-zone__button' diff --git a/apps/web/src/app/settings/settings.component.storage-failure.spec.ts b/apps/web/src/app/settings/settings.component.storage-failure.spec.ts index 94fe3a9a6..9213f1a97 100644 --- a/apps/web/src/app/settings/settings.component.storage-failure.spec.ts +++ b/apps/web/src/app/settings/settings.component.storage-failure.spec.ts @@ -1,6 +1,7 @@ import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; import { MatDialog } from '@angular/material/dialog'; import { MatSnackBar } from '@angular/material/snack-bar'; +import { of } from 'rxjs'; import { EpgRuntimeBridgeService } from '@iptvnator/epg/data-access'; import { SettingsStore } from '../services/settings-store.service'; import { SettingsComponent } from './settings.component'; @@ -18,14 +19,6 @@ const ERROR_SNACKBAR_CONFIG = expect.objectContaining({ panelClass: ['settings-snackbar', 'settings-snackbar--error'], }); -/** - * Assertions target the MatDialog the component actually calls: spying on the - * one returned by `TestBed.inject(MatDialog)` does not observe it. - */ -interface SettingsComponentPrivateTestApi { - matDialog: MatDialog; -} - /** * Settings live in the renderer's IndexedDB, and both halves of the round trip * can fail while the UI keeps looking healthy: a failed read shows defaults as @@ -39,8 +32,6 @@ describe('SettingsComponent storage failures', () => { let snackBar: MatSnackBarStub; let epgBridge: Partial; const originalElectron = window.electron; - const componentMatDialog = (): MatDialog => - (component as unknown as SettingsComponentPrivateTestApi).matDialog; beforeEach(waitForAsync(() => { epgBridge = createEpgBridgeStub(); @@ -77,12 +68,11 @@ describe('SettingsComponent storage failures', () => { ); }); - it('warns and keeps the dialog open when the settings write fails', async () => { + it('warns and keeps the form dirty when the settings write fails', async () => { settingsStore.updateSettings.mockRejectedValue( new Error('storage unavailable') ); - const closeAll = jest.spyOn(componentMatDialog(), 'closeAll'); - component.isDialog = true; + component.settingsForm.markAsDirty(); component.onSubmit(); await fixture.whenStable(); @@ -92,37 +82,47 @@ describe('SettingsComponent storage failures', () => { 'CLOSE', ERROR_SNACKBAR_CONFIG ); - expect(closeAll).not.toHaveBeenCalled(); + // The unsaved-changes bar keys off the dirty state, so the failed + // write must not mark the form pristine — that is what keeps the + // retry path on screen. + expect(component.settingsForm.dirty).toBe(true); }); - it('closes the dialog only after the settings write succeeded', async () => { + it('marks the form pristine only after the settings write succeeded', async () => { settingsStore.updateSettings.mockResolvedValue(undefined); - const closeAll = jest.spyOn(componentMatDialog(), 'closeAll'); jest.spyOn(component.epg, 'fetchConfiguredEpg').mockImplementation(); - component.isDialog = true; + component.settingsForm.markAsDirty(); component.onSubmit(); // save() resolves a tick after the store write: the callback and - // the Electron mirror run first, so the close lands on the next - // turn of the microtask queue. + // the Electron mirror run first, so the pristine flip lands on the + // next turn of the microtask queue. await fixture.whenStable(); await fixture.whenStable(); - expect(closeAll).toHaveBeenCalledTimes(1); + expect(component.settingsForm.pristine).toBe(true); }); - it('warns when a section write fails without leaving an unhandled rejection', async () => { + it('keeps the user in settings when save-and-leave cannot persist', async () => { settingsStore.updateSettings.mockRejectedValue( new Error('storage unavailable') ); + component.settingsForm.markAsDirty(); + (TestBed.inject(MatDialog).open as jest.Mock).mockReturnValue({ + afterClosed: () => of('save'), + }); - component.form.selectCoverSize('large'); - await fixture.whenStable(); - + // Allowing the navigation here would silently drop the edits the + // dialog just promised to save. + await expect(component.confirmLeaveWithUnsavedChanges()).resolves.toBe( + false + ); expect(snackBar.open).toHaveBeenCalledWith( 'SETTINGS.SETTINGS_SAVE_FAILED', 'CLOSE', ERROR_SNACKBAR_CONFIG ); + expect(component.settingsForm.dirty).toBe(true); }); + }); diff --git a/apps/web/src/app/settings/settings.component.ts b/apps/web/src/app/settings/settings.component.ts index c6be7539e..e136cc871 100644 --- a/apps/web/src/app/settings/settings.component.ts +++ b/apps/web/src/app/settings/settings.component.ts @@ -1,27 +1,25 @@ -import { CommonModule } from '@angular/common'; import { Component, computed, + effect, + ElementRef, inject, - Input, OnDestroy, OnInit, ViewEncapsulation, } from '@angular/core'; +import { toSignal } from '@angular/core/rxjs-interop'; import { ReactiveFormsModule } from '@angular/forms'; import { MatButtonModule } from '@angular/material/button'; -import { - MAT_DIALOG_DATA, - MatDialog, - MatDialogModule, -} from '@angular/material/dialog'; +import { MatDialog } from '@angular/material/dialog'; import { MatIconModule } from '@angular/material/icon'; -import { Router } from '@angular/router'; +import { ActivatedRoute, Router } from '@angular/router'; import { SettingsContextService } from '@iptvnator/workspace/shell/util'; import { TranslateModule, TranslateService } from '@ngx-translate/core'; import { RuntimeCapabilitiesService } from '@iptvnator/services'; import { VodSourceDiscoveryService } from '@iptvnator/portal/shared/data-access'; import { Language, StreamFormat } from '@iptvnator/shared/interfaces'; +import { firstValueFrom, map } from 'rxjs'; import { BUILD_COMMIT } from '../../environments/build-commit'; import { SettingsAboutSectionComponent } from './settings-about-section.component'; import { SettingsAppUpdateFacade } from './settings-app-update.facade'; @@ -45,17 +43,28 @@ import { SettingsPlaybackSectionComponent } from './settings-playback-section.co import { SettingsRemoteControlFacade } from './settings-remote-control.facade'; import { SettingsRemoteControlSectionComponent } from './settings-remote-control-section.component'; import { SettingsResetSectionComponent } from './settings-reset-section.component'; -import { SettingsSectionScrollDirective } from './settings-section-scroll.directive'; import { SettingsTmdbSectionComponent } from './settings-tmdb-section.component'; +import { + SettingsUnsavedChangesChoice, + SettingsUnsavedChangesDialogComponent, +} from './settings-unsaved-changes-dialog.component'; +import { SettingsLeaveConfirmation } from './settings-unsaved-changes.guard'; import { SettingsBackupFacade } from './settings-backup.facade'; import { SettingsPlaylistResetFacade } from './settings-playlist-reset.facade'; import { SettingsSnackbarService } from './settings-snackbar.service'; +export const SETTINGS_DEFAULT_SECTION = 'general'; + /** * Thin coordinator for the settings page. The behaviour of each section lives * in a dedicated facade the template binds to directly; what stays here is the - * page-level wiring: environment capabilities, section navigation, and the - * save/close flow that spans several facades. + * page-level wiring: environment capabilities, the `:section` route param that + * selects which section page renders, and the save/discard flow that spans + * several facades. + * + * Routed as `/workspace/settings/:section`. Navigating between sections only + * changes the param — the component instance (and with it the form and its + * dirty state) survives until the user leaves settings entirely. */ @Component({ templateUrl: './settings.component.html', @@ -65,12 +74,10 @@ import { SettingsSnackbarService } from './settings-snackbar.service'; }, encapsulation: ViewEncapsulation.None, imports: [ - CommonModule, MatButtonModule, MatIconModule, ReactiveFormsModule, TranslateModule, - MatDialogModule, SettingsAboutSectionComponent, SettingsBackupSectionComponent, SettingsDashboardSectionComponent, @@ -79,7 +86,6 @@ import { SettingsSnackbarService } from './settings-snackbar.service'; SettingsPlaybackSectionComponent, SettingsRemoteControlSectionComponent, SettingsResetSectionComponent, - SettingsSectionScrollDirective, SettingsTmdbSectionComponent, ], providers: [ @@ -93,7 +99,9 @@ import { SettingsSnackbarService } from './settings-snackbar.service'; SettingsSnackbarService, ], }) -export class SettingsComponent implements OnInit, OnDestroy { +export class SettingsComponent + implements OnInit, OnDestroy, SettingsLeaveConfirmation +{ readonly appUpdate = inject(SettingsAppUpdateFacade); readonly backup = inject(SettingsBackupFacade); readonly embeddedMpv = inject(SettingsEmbeddedMpvFacade); @@ -107,14 +115,10 @@ export class SettingsComponent implements OnInit, OnDestroy { private readonly runtime = inject(RuntimeCapabilitiesService); private readonly vodSourceDiscovery = inject(VodSourceDiscoveryService); private readonly matDialog = inject(MatDialog); + private readonly route = inject(ActivatedRoute); private readonly router = inject(Router); private readonly translate = inject(TranslateService); - private readonly dialogData = inject<{ isDialog: boolean } | null>( - MAT_DIALOG_DATA, - { optional: true } - ); - - @Input() isDialog = this.dialogData?.isDialog ?? false; + private readonly hostElement = inject(ElementRef); /** List with available languages as enum */ readonly languageEnum = Language; @@ -134,8 +138,6 @@ export class SettingsComponent implements OnInit, OnDestroy { readonly supportsVodMultiSource = this.vodSourceDiscovery.isAvailable; readonly supportsRemoteControl = this.runtime.supportsRemoteControl; - readonly activeSection = this.settingsCtx.activeSection; - /** Settings form object */ readonly settingsForm = this.form.form; @@ -164,6 +166,49 @@ export class SettingsComponent implements OnInit, OnDestroy { return this.sectionNavItems.filter((section) => section.visible); } + private readonly sectionParam = toSignal( + this.route.paramMap.pipe(map((params) => params.get('section'))), + { initialValue: null } + ); + + /** + * Section page currently rendered. Unknown or capability-gated params + * fall back to the default section while the redirect effect below + * rewrites the URL to match. + */ + readonly activeSection = computed(() => { + const section = this.sectionParam(); + return section && this.isNavigableSection(section) + ? section + : SETTINGS_DEFAULT_SECTION; + }); + + constructor() { + // A stale or hand-typed URL (`/settings/epg` in a runtime without + // EPG support, `/settings/nonsense`) must not leave the address bar + // lying about what is on screen. + effect(() => { + const section = this.sectionParam(); + if (section && !this.isNavigableSection(section)) { + void this.router.navigate( + ['/workspace/settings', SETTINGS_DEFAULT_SECTION], + { replaceUrl: true } + ); + } + }); + + // Section pages share one scroll container (`main.workspace-content`); + // without this, opening a long section, scrolling, and switching to a + // short one strands the viewport past the new page's content. Instant + // on purpose — this is navigation, not an animated transition. + effect(() => { + this.activeSection(); + this.hostElement.nativeElement + .closest('main.workspace-content') + ?.scrollTo({ top: 0 }); + }); + } + /** * Reads the config object from the browsers * storage (indexed db) @@ -178,9 +223,7 @@ export class SettingsComponent implements OnInit, OnDestroy { this.appUpdate.init(); void this.remoteControl.fetchLocalIpAddresses(); - if (!this.isDialog) { - this.settingsCtx.setSections(this.sectionNav); - } + this.settingsCtx.setSections(this.sectionNav); } ngOnDestroy(): void { @@ -202,26 +245,78 @@ export class SettingsComponent implements OnInit, OnDestroy { * the indexed db store */ onSubmit(): void { - this.form - .save(() => this.applyChangedSettings()) - .then(() => { - if (this.isDialog) { - this.matDialog.closeAll(); - } - }) - .catch(() => { - // The store already applied the change in memory, so without - // this the save looks successful until the next restart. The - // dialog stays open so it can be retried. - // - // The Electron-side pushes in SettingsFormFacade.save() stay in - // the success branch on purpose: main keeps its own copy of the - // player paths and remote-control state, and applying half the - // form while telling the user nothing was saved is worse than - // applying none of it. Once settings live in the main process - // (issue #1273) this split disappears. - this.settingsSnackbar.storageFailure('save'); - }); + void this.persistSettings(); + } + + /** + * Exit gate for `settingsUnsavedChangesGuard`: silently allows leaving + * while the form is pristine, otherwise lets the user save, discard, or + * stay. A failed save keeps the user in settings — navigating away on a + * write that did not land would silently lose the edits the dialog just + * promised to keep. + */ + async confirmLeaveWithUnsavedChanges(): Promise { + if (this.settingsForm.pristine) { + return true; + } + + const choice = await firstValueFrom( + this.matDialog + .open< + SettingsUnsavedChangesDialogComponent, + { canSave: boolean }, + SettingsUnsavedChangesChoice + >(SettingsUnsavedChangesDialogComponent, { + width: '440px', + data: { canSave: this.settingsForm.valid }, + }) + .afterClosed() + ); + + if (choice === 'save') { + return this.persistSettings(); + } + + if (choice === 'discard') { + // Also reverts the live theme preview — leaving must not keep a + // theme the store never saved. + this.discardChanges(); + return true; + } + + return false; + } + + /** @returns whether the write actually landed */ + private async persistSettings(): Promise { + try { + await this.form.save(() => this.applyChangedSettings()); + return true; + } catch { + // The store already applied the change in memory, so without + // this the save looks successful until the next restart. The + // unsaved-changes bar stays visible so it can be retried. + // + // The Electron-side pushes in SettingsFormFacade.save() stay in + // the success branch on purpose: main keeps its own copy of the + // player paths and remote-control state, and applying half the + // form while telling the user nothing was saved is worse than + // applying none of it. Once settings live in the main process + // (issue #1273) this split disappears. + this.settingsSnackbar.storageFailure('save'); + return false; + } + } + + /** + * Throws away every staged form edit and returns to the persisted + * settings. `applySavedSettings` also reverts the live theme preview + * (`selectTheme` applies immediately) and marks the form pristine, which + * hides the unsaved-changes bar. + */ + discardChanges(): void { + this.form.hydrateFromStore(); + this.form.applySavedSettings(); } /** @@ -235,17 +330,6 @@ export class SettingsComponent implements OnInit, OnDestroy { ); } - /** - * Navigates back to the applications homepage - */ - backToHome(): void { - if (this.isDialog) { - this.matDialog.closeAll(); - } else { - this.router.navigateByUrl('/'); - } - } - async exportData(): Promise { await this.backup.exportData(() => this.waitForUiFeedbackFrame()); } @@ -260,6 +344,10 @@ export class SettingsComponent implements OnInit, OnDestroy { ); } + private isNavigableSection(sectionId: string): boolean { + return this.sectionNav.some((section) => section.id === sectionId); + } + /** * Lets the browser paint the pending busy state before a long running * task blocks the main thread. diff --git a/apps/web/src/app/settings/settings.models.ts b/apps/web/src/app/settings/settings.models.ts index 36f4d18b4..2bfc8db82 100644 --- a/apps/web/src/app/settings/settings.models.ts +++ b/apps/web/src/app/settings/settings.models.ts @@ -13,11 +13,6 @@ export interface SettingsSection { visible: boolean; } -export interface ObservedSettingsSection { - id: string; - element: HTMLElement; -} - export interface ThemeOption { value: Theme; icon: string; diff --git a/apps/web/src/app/settings/test-stubs/settings-test-harness.stub.ts b/apps/web/src/app/settings/test-stubs/settings-test-harness.stub.ts index 5249bd628..2dd8cd1c5 100644 --- a/apps/web/src/app/settings/test-stubs/settings-test-harness.stub.ts +++ b/apps/web/src/app/settings/test-stubs/settings-test-harness.stub.ts @@ -16,7 +16,12 @@ import { MatSelectModule } from '@angular/material/select'; import { MatSnackBar } from '@angular/material/snack-bar'; import { MatTooltipModule } from '@angular/material/tooltip'; import { HttpClientTestingModule } from '@angular/common/http/testing'; -import { Router } from '@angular/router'; +import { + ActivatedRoute, + convertToParamMap, + ParamMap, + Router, +} from '@angular/router'; import { RouterTestingModule } from '@angular/router/testing'; import { EpgRuntimeBridgeService, @@ -47,7 +52,7 @@ import { provideMockStore } from '@ngrx/store/testing'; import { TranslateModule } from '@ngx-translate/core'; import { MockModule, MockProvider } from 'ng-mocks'; import { NgxIndexedDBService } from 'ngx-indexed-db'; -import { from, of } from 'rxjs'; +import { BehaviorSubject, from, of } from 'rxjs'; import { ElectronServiceStub } from '../../services/electron.service.stub'; import { SettingsStorageFailure } from '@iptvnator/services'; import { SettingsStore } from '../../services/settings-store.service'; @@ -121,11 +126,36 @@ export class MatSnackBarStub { } export class MockRouter { + navigate = jest.fn().mockResolvedValue(true); + navigateByUrl(url: string): string { return url; } } +/** + * Stands in for the `:section` route param the settings page renders from. + * Specs switch section pages with `setSettingsSection` below. + */ +export class MockActivatedRoute { + private readonly params = new BehaviorSubject( + convertToParamMap({ section: 'general' }) + ); + + readonly paramMap = this.params.asObservable(); + + setSection(section: string): void { + this.params.next(convertToParamMap({ section })); + } +} + +/** Routes the rendered settings page to the given section page. */ +export function setSettingsSection(section: string): void { + ( + TestBed.inject(ActivatedRoute) as unknown as MockActivatedRoute + ).setSection(section); +} + export class MockSettingsStore { private _settings = signal(DEFAULT_SETTINGS); @@ -273,6 +303,7 @@ export function settingsTestProviders( { provide: MatSnackBar, useClass: MatSnackBarStub }, { provide: DataService, useClass: ElectronServiceStub }, { provide: Router, useClass: MockRouter }, + { provide: ActivatedRoute, useClass: MockActivatedRoute }, provideMockStore({ selectors: [ { selector: selectAllPlaylistsMeta, value: [] }, diff --git a/apps/web/src/assets/i18n/ar.json b/apps/web/src/assets/i18n/ar.json index 0502c49da..751a1c228 100644 --- a/apps/web/src/assets/i18n/ar.json +++ b/apps/web/src/assets/i18n/ar.json @@ -296,6 +296,14 @@ "VIDEO_PLAYER_PLACEHOLDER": "اختر خيارًا", "VERSION": "الإصدار", "SAVE_CHANGES": "حفظ التغييرات", + "UNSAVED_CHANGES": "لديك تغييرات غير محفوظة", + "DISCARD_CHANGES": "تجاهل", + "UNSAVED_DIALOG_TITLE": "تغييرات غير محفوظة", + "UNSAVED_DIALOG_MESSAGE": "لديك تغييرات غير محفوظة في الإعدادات. هل تريد حفظها قبل المغادرة؟", + "UNSAVED_DIALOG_SAVE": "احفظ وغادر", + "UNSAVED_DIALOG_DISCARD": "غادر دون حفظ", + "UNSAVED_DIALOG_STAY": "متابعة التحرير", + "UNSAVED_DIALOG_INVALID_HINT": "بعض الحقول غير صالحة، لذلك لا يمكن حفظ التغييرات بعد.", "BACK_TO_HOME": "العودة للرئيسية", "NEW_VERSION_AVAILABLE": "إصدار جديد متاح", "LATEST_VERSION": "أنت تستخدم الإصدار الأحدث", diff --git a/apps/web/src/assets/i18n/ary.json b/apps/web/src/assets/i18n/ary.json index 581fcfc72..b49ab3d55 100644 --- a/apps/web/src/assets/i18n/ary.json +++ b/apps/web/src/assets/i18n/ary.json @@ -296,6 +296,14 @@ "VIDEO_PLAYER_PLACEHOLDER": "اختار خيار", "VERSION": "الإصدار", "SAVE_CHANGES": "حفظ التغييرات", + "UNSAVED_CHANGES": "عندك تغييرات ماتسجلوش", + "DISCARD_CHANGES": "تجاهل", + "UNSAVED_DIALOG_TITLE": "تغييرات ماتسجلاتش", + "UNSAVED_DIALOG_MESSAGE": "عندك تغييرات ماتسجلاتش فالإعدادات. بغيتي تسجلهم قبل ما تخرج؟", + "UNSAVED_DIALOG_SAVE": "سجّل و خرج", + "UNSAVED_DIALOG_DISCARD": "خرج بلا ما تسجل", + "UNSAVED_DIALOG_STAY": "كمّل التعديل", + "UNSAVED_DIALOG_INVALID_HINT": "شي خانات ماشي صحيحين، علاش مايمكنش تسجل التغييرات دابا.", "BACK_TO_HOME": "رجوع", "NEW_VERSION_AVAILABLE": "كاين إصدار جديد متوفر", "LATEST_VERSION": "راك تستعمل أحدث إصدار", diff --git a/apps/web/src/assets/i18n/by.json b/apps/web/src/assets/i18n/by.json index 84d57fdd6..9bef73dc4 100644 --- a/apps/web/src/assets/i18n/by.json +++ b/apps/web/src/assets/i18n/by.json @@ -296,6 +296,14 @@ "VIDEO_PLAYER_PLACEHOLDER": "Выберыце са спісу", "VERSION": "Версія праграмы", "SAVE_CHANGES": "Захаваць", + "UNSAVED_CHANGES": "У вас ёсць незахаваныя змены", + "DISCARD_CHANGES": "Адмяніць змены", + "UNSAVED_DIALOG_TITLE": "Незахаваныя змены", + "UNSAVED_DIALOG_MESSAGE": "У вас ёсць незахаваныя змены налад. Захаваць іх перад выхадам?", + "UNSAVED_DIALOG_SAVE": "Захаваць і выйсці", + "UNSAVED_DIALOG_DISCARD": "Выйсці без захавання", + "UNSAVED_DIALOG_STAY": "Працягнуць рэдагаванне", + "UNSAVED_DIALOG_INVALID_HINT": "Некаторыя палі запоўнены няправільна, таму змены пакуль нельга захаваць.", "BACK_TO_HOME": "Назад", "NEW_VERSION_AVAILABLE": "Даступная новая версія праграмы", "LATEST_VERSION": "Вы выкарыстоўваеце апошнюю версію праграмы", diff --git a/apps/web/src/assets/i18n/de.json b/apps/web/src/assets/i18n/de.json index ebf158524..f3999c9ca 100644 --- a/apps/web/src/assets/i18n/de.json +++ b/apps/web/src/assets/i18n/de.json @@ -296,6 +296,14 @@ "VIDEO_PLAYER_PLACEHOLDER": "Option auswählen", "VERSION": "Version", "SAVE_CHANGES": "Änderungen speichern", + "UNSAVED_CHANGES": "Du hast ungespeicherte Änderungen", + "DISCARD_CHANGES": "Verwerfen", + "UNSAVED_DIALOG_TITLE": "Ungespeicherte Änderungen", + "UNSAVED_DIALOG_MESSAGE": "Du hast ungespeicherte Änderungen an den Einstellungen. Möchtest du sie vor dem Verlassen speichern?", + "UNSAVED_DIALOG_SAVE": "Speichern und verlassen", + "UNSAVED_DIALOG_DISCARD": "Verlassen ohne zu speichern", + "UNSAVED_DIALOG_STAY": "Weiter bearbeiten", + "UNSAVED_DIALOG_INVALID_HINT": "Einige Felder sind ungültig, daher können die Änderungen noch nicht gespeichert werden.", "BACK_TO_HOME": "Zurück", "NEW_VERSION_AVAILABLE": "EIne neue Version der Anwendung ist verfügbar", "LATEST_VERSION": "Sie nutzen die neueste Version der Anwendung", diff --git a/apps/web/src/assets/i18n/el.json b/apps/web/src/assets/i18n/el.json index a7b2a8b99..7d236cfc9 100644 --- a/apps/web/src/assets/i18n/el.json +++ b/apps/web/src/assets/i18n/el.json @@ -296,6 +296,14 @@ "VIDEO_PLAYER_PLACEHOLDER": "Επιλέξτε μια επιλογή", "VERSION": "Έκδοση", "SAVE_CHANGES": "Αποθήκευση αλλαγών", + "UNSAVED_CHANGES": "Έχετε μη αποθηκευμένες αλλαγές", + "DISCARD_CHANGES": "Απόρριψη", + "UNSAVED_DIALOG_TITLE": "Μη αποθηκευμένες αλλαγές", + "UNSAVED_DIALOG_MESSAGE": "Έχετε μη αποθηκευμένες αλλαγές στις ρυθμίσεις. Θέλετε να τις αποθηκεύσετε πριν φύγετε;", + "UNSAVED_DIALOG_SAVE": "Αποθήκευση και έξοδος", + "UNSAVED_DIALOG_DISCARD": "Έξοδος χωρίς αποθήκευση", + "UNSAVED_DIALOG_STAY": "Συνέχεια επεξεργασίας", + "UNSAVED_DIALOG_INVALID_HINT": "Ορισμένα πεδία δεν είναι έγκυρα, επομένως οι αλλαγές δεν μπορούν να αποθηκευτούν ακόμη.", "BACK_TO_HOME": "Πίσω", "NEW_VERSION_AVAILABLE": "Υπάρχει διαθέσιμη μια νέα έκδοση", "LATEST_VERSION": "Χρησιμοποιείτε την πιο πρόσφατη έκδοση", diff --git a/apps/web/src/assets/i18n/en.json b/apps/web/src/assets/i18n/en.json index a5754aa74..24902616f 100644 --- a/apps/web/src/assets/i18n/en.json +++ b/apps/web/src/assets/i18n/en.json @@ -296,6 +296,14 @@ "VIDEO_PLAYER_PLACEHOLDER": "Select an option", "VERSION": "Version", "SAVE_CHANGES": "Save changes", + "UNSAVED_CHANGES": "You have unsaved changes", + "DISCARD_CHANGES": "Discard", + "UNSAVED_DIALOG_TITLE": "Unsaved changes", + "UNSAVED_DIALOG_MESSAGE": "You have unsaved settings changes. Do you want to save them before leaving?", + "UNSAVED_DIALOG_SAVE": "Save and leave", + "UNSAVED_DIALOG_DISCARD": "Leave without saving", + "UNSAVED_DIALOG_STAY": "Keep editing", + "UNSAVED_DIALOG_INVALID_HINT": "Some fields are invalid, so the changes cannot be saved yet.", "BACK_TO_HOME": "Back", "NEW_VERSION_AVAILABLE": "There is a new version available", "LATEST_VERSION": "You are using the latest version", diff --git a/apps/web/src/assets/i18n/es.json b/apps/web/src/assets/i18n/es.json index baad2e909..4a5ffd4f5 100644 --- a/apps/web/src/assets/i18n/es.json +++ b/apps/web/src/assets/i18n/es.json @@ -296,6 +296,14 @@ "VIDEO_PLAYER_PLACEHOLDER": "Selecciona una opción", "VERSION": "Versión", "SAVE_CHANGES": "Guardar cambios", + "UNSAVED_CHANGES": "Tienes cambios sin guardar", + "DISCARD_CHANGES": "Descartar", + "UNSAVED_DIALOG_TITLE": "Cambios sin guardar", + "UNSAVED_DIALOG_MESSAGE": "Tienes cambios sin guardar en los ajustes. ¿Quieres guardarlos antes de salir?", + "UNSAVED_DIALOG_SAVE": "Guardar y salir", + "UNSAVED_DIALOG_DISCARD": "Salir sin guardar", + "UNSAVED_DIALOG_STAY": "Seguir editando", + "UNSAVED_DIALOG_INVALID_HINT": "Algunos campos no son válidos, por lo que los cambios aún no se pueden guardar.", "BACK_TO_HOME": "Volver al inicio", "NEW_VERSION_AVAILABLE": "Hay una nueva versión disponible", "LATEST_VERSION": "Estás usando la versión reciente", diff --git a/apps/web/src/assets/i18n/fr.json b/apps/web/src/assets/i18n/fr.json index ecf59f1b3..f1663f46a 100644 --- a/apps/web/src/assets/i18n/fr.json +++ b/apps/web/src/assets/i18n/fr.json @@ -296,6 +296,14 @@ "VIDEO_PLAYER_PLACEHOLDER": "Choisir une option", "VERSION": "Version", "SAVE_CHANGES": "Enregistrer les modifications", + "UNSAVED_CHANGES": "Vous avez des modifications non enregistrées", + "DISCARD_CHANGES": "Annuler les modifications", + "UNSAVED_DIALOG_TITLE": "Modifications non enregistrées", + "UNSAVED_DIALOG_MESSAGE": "Vous avez des modifications non enregistrées dans les paramètres. Voulez-vous les enregistrer avant de quitter ?", + "UNSAVED_DIALOG_SAVE": "Enregistrer et quitter", + "UNSAVED_DIALOG_DISCARD": "Quitter sans enregistrer", + "UNSAVED_DIALOG_STAY": "Continuer la modification", + "UNSAVED_DIALOG_INVALID_HINT": "Certains champs sont invalides, les modifications ne peuvent donc pas encore être enregistrées.", "BACK_TO_HOME": "Retourner à l'accueil", "NEW_VERSION_AVAILABLE": "Une nouvelle version est disponible", "LATEST_VERSION": "Vous utilisez la dernière version", diff --git a/apps/web/src/assets/i18n/hu.json b/apps/web/src/assets/i18n/hu.json index cadfbb300..29b6aa342 100644 --- a/apps/web/src/assets/i18n/hu.json +++ b/apps/web/src/assets/i18n/hu.json @@ -296,6 +296,14 @@ "VIDEO_PLAYER_PLACEHOLDER": "Válasszon egy lehetőséget", "VERSION": "Verzió", "SAVE_CHANGES": "Változtatások mentése", + "UNSAVED_CHANGES": "Nem mentett módosításaid vannak", + "DISCARD_CHANGES": "Elvetés", + "UNSAVED_DIALOG_TITLE": "Nem mentett módosítások", + "UNSAVED_DIALOG_MESSAGE": "Nem mentett módosításaid vannak a beállításokban. Szeretnéd menteni őket, mielőtt kilépsz?", + "UNSAVED_DIALOG_SAVE": "Mentés és kilépés", + "UNSAVED_DIALOG_DISCARD": "Kilépés mentés nélkül", + "UNSAVED_DIALOG_STAY": "Szerkesztés folytatása", + "UNSAVED_DIALOG_INVALID_HINT": "Néhány mező érvénytelen, ezért a módosítások még nem menthetők.", "BACK_TO_HOME": "Vissza", "NEW_VERSION_AVAILABLE": "Új verzió érhető el", "LATEST_VERSION": "A legújabb verziót használja", diff --git a/apps/web/src/assets/i18n/it.json b/apps/web/src/assets/i18n/it.json index c3b25fc7b..8229ae330 100644 --- a/apps/web/src/assets/i18n/it.json +++ b/apps/web/src/assets/i18n/it.json @@ -296,6 +296,14 @@ "VIDEO_PLAYER_PLACEHOLDER": "Seleziona una opzione", "VERSION": "Versione", "SAVE_CHANGES": "Salva modifiche", + "UNSAVED_CHANGES": "Hai modifiche non salvate", + "DISCARD_CHANGES": "Annulla modifiche", + "UNSAVED_DIALOG_TITLE": "Modifiche non salvate", + "UNSAVED_DIALOG_MESSAGE": "Hai modifiche alle impostazioni non salvate. Vuoi salvarle prima di uscire?", + "UNSAVED_DIALOG_SAVE": "Salva ed esci", + "UNSAVED_DIALOG_DISCARD": "Esci senza salvare", + "UNSAVED_DIALOG_STAY": "Continua a modificare", + "UNSAVED_DIALOG_INVALID_HINT": "Alcuni campi non sono validi, quindi le modifiche non possono ancora essere salvate.", "BACK_TO_HOME": "Indietro", "NEW_VERSION_AVAILABLE": "È disponibile una nuova versione", "LATEST_VERSION": "Questa versione è aggiornata", diff --git a/apps/web/src/assets/i18n/ja.json b/apps/web/src/assets/i18n/ja.json index 3734536b3..a4be97d2c 100644 --- a/apps/web/src/assets/i18n/ja.json +++ b/apps/web/src/assets/i18n/ja.json @@ -296,6 +296,14 @@ "VIDEO_PLAYER_PLACEHOLDER": "オプションを選択", "VERSION": "バージョン", "SAVE_CHANGES": "変更を保存", + "UNSAVED_CHANGES": "保存されていない変更があります", + "DISCARD_CHANGES": "破棄", + "UNSAVED_DIALOG_TITLE": "未保存の変更", + "UNSAVED_DIALOG_MESSAGE": "設定に未保存の変更があります。移動する前に保存しますか?", + "UNSAVED_DIALOG_SAVE": "保存して移動", + "UNSAVED_DIALOG_DISCARD": "保存せずに移動", + "UNSAVED_DIALOG_STAY": "編集を続ける", + "UNSAVED_DIALOG_INVALID_HINT": "一部の項目が無効なため、変更をまだ保存できません。", "BACK_TO_HOME": "戻る", "NEW_VERSION_AVAILABLE": "新しいバージョンが利用可能です", "LATEST_VERSION": "最新バージョンを使用中です", diff --git a/apps/web/src/assets/i18n/ko.json b/apps/web/src/assets/i18n/ko.json index 6653602ad..00adcc3a9 100644 --- a/apps/web/src/assets/i18n/ko.json +++ b/apps/web/src/assets/i18n/ko.json @@ -296,6 +296,14 @@ "VIDEO_PLAYER_PLACEHOLDER": "옵션을 선택하세요", "VERSION": "버전", "SAVE_CHANGES": "변경 사항 저장", + "UNSAVED_CHANGES": "저장되지 않은 변경 사항이 있습니다", + "DISCARD_CHANGES": "취소", + "UNSAVED_DIALOG_TITLE": "저장되지 않은 변경 사항", + "UNSAVED_DIALOG_MESSAGE": "설정에 저장되지 않은 변경 사항이 있습니다. 나가기 전에 저장하시겠습니까?", + "UNSAVED_DIALOG_SAVE": "저장하고 나가기", + "UNSAVED_DIALOG_DISCARD": "저장하지 않고 나가기", + "UNSAVED_DIALOG_STAY": "계속 편집", + "UNSAVED_DIALOG_INVALID_HINT": "일부 항목이 유효하지 않아 변경 사항을 아직 저장할 수 없습니다.", "BACK_TO_HOME": "홈으로", "NEW_VERSION_AVAILABLE": "새로운 버전이 있습니다", "LATEST_VERSION": "최신 버전을 사용하고 있습니다", diff --git a/apps/web/src/assets/i18n/nl.json b/apps/web/src/assets/i18n/nl.json index 4f0b05448..0234ca867 100644 --- a/apps/web/src/assets/i18n/nl.json +++ b/apps/web/src/assets/i18n/nl.json @@ -296,6 +296,14 @@ "VIDEO_PLAYER_PLACEHOLDER": "Selecteer een optie", "VERSION": "Versie", "SAVE_CHANGES": "Wijzigingen opslaan", + "UNSAVED_CHANGES": "Je hebt niet-opgeslagen wijzigingen", + "DISCARD_CHANGES": "Verwerpen", + "UNSAVED_DIALOG_TITLE": "Niet-opgeslagen wijzigingen", + "UNSAVED_DIALOG_MESSAGE": "Je hebt niet-opgeslagen wijzigingen in de instellingen. Wil je ze opslaan voordat je vertrekt?", + "UNSAVED_DIALOG_SAVE": "Opslaan en verlaten", + "UNSAVED_DIALOG_DISCARD": "Verlaten zonder opslaan", + "UNSAVED_DIALOG_STAY": "Verder bewerken", + "UNSAVED_DIALOG_INVALID_HINT": "Sommige velden zijn ongeldig, dus de wijzigingen kunnen nog niet worden opgeslagen.", "BACK_TO_HOME": "Terug", "NEW_VERSION_AVAILABLE": "Een nieuwe versie is beschikbaar", "LATEST_VERSION": "Je gebruikt de nieuwste versie", diff --git a/apps/web/src/assets/i18n/pl.json b/apps/web/src/assets/i18n/pl.json index a3165f818..ff30589d7 100644 --- a/apps/web/src/assets/i18n/pl.json +++ b/apps/web/src/assets/i18n/pl.json @@ -296,6 +296,14 @@ "VIDEO_PLAYER_PLACEHOLDER": "Wybierz opcję", "VERSION": "Wersja", "SAVE_CHANGES": "Zapisz zmiany", + "UNSAVED_CHANGES": "Masz niezapisane zmiany", + "DISCARD_CHANGES": "Odrzuć", + "UNSAVED_DIALOG_TITLE": "Niezapisane zmiany", + "UNSAVED_DIALOG_MESSAGE": "Masz niezapisane zmiany ustawień. Czy chcesz je zapisać przed wyjściem?", + "UNSAVED_DIALOG_SAVE": "Zapisz i wyjdź", + "UNSAVED_DIALOG_DISCARD": "Wyjdź bez zapisywania", + "UNSAVED_DIALOG_STAY": "Kontynuuj edycję", + "UNSAVED_DIALOG_INVALID_HINT": "Niektóre pola są nieprawidłowe, więc zmian nie można jeszcze zapisać.", "BACK_TO_HOME": "Wróć", "NEW_VERSION_AVAILABLE": "Dostępna jest nowa wersja", "LATEST_VERSION": "Używasz najnowszej wersji", diff --git a/apps/web/src/assets/i18n/pt.json b/apps/web/src/assets/i18n/pt.json index 34ce3e6e3..08ebcc047 100644 --- a/apps/web/src/assets/i18n/pt.json +++ b/apps/web/src/assets/i18n/pt.json @@ -296,6 +296,14 @@ "VIDEO_PLAYER_PLACEHOLDER": "Selecione uma opção", "VERSION": "Versão", "SAVE_CHANGES": "Salvar alterações", + "UNSAVED_CHANGES": "Você tem alterações não salvas", + "DISCARD_CHANGES": "Descartar", + "UNSAVED_DIALOG_TITLE": "Alterações não salvas", + "UNSAVED_DIALOG_MESSAGE": "Você tem alterações não salvas nas configurações. Deseja salvá-las antes de sair?", + "UNSAVED_DIALOG_SAVE": "Salvar e sair", + "UNSAVED_DIALOG_DISCARD": "Sair sem salvar", + "UNSAVED_DIALOG_STAY": "Continuar editando", + "UNSAVED_DIALOG_INVALID_HINT": "Alguns campos são inválidos, então as alterações ainda não podem ser salvas.", "BACK_TO_HOME": "Voltar", "NEW_VERSION_AVAILABLE": "Há uma nova versão disponível", "LATEST_VERSION": "Você está usando a versão mais recente", diff --git a/apps/web/src/assets/i18n/ru.json b/apps/web/src/assets/i18n/ru.json index f95a46827..1652c870a 100644 --- a/apps/web/src/assets/i18n/ru.json +++ b/apps/web/src/assets/i18n/ru.json @@ -296,6 +296,14 @@ "VIDEO_PLAYER_PLACEHOLDER": "Выберите из списка", "VERSION": "Версия приложения", "SAVE_CHANGES": "Сохранить", + "UNSAVED_CHANGES": "У вас есть несохранённые изменения", + "DISCARD_CHANGES": "Отменить изменения", + "UNSAVED_DIALOG_TITLE": "Несохранённые изменения", + "UNSAVED_DIALOG_MESSAGE": "У вас есть несохранённые изменения настроек. Сохранить их перед выходом?", + "UNSAVED_DIALOG_SAVE": "Сохранить и выйти", + "UNSAVED_DIALOG_DISCARD": "Выйти без сохранения", + "UNSAVED_DIALOG_STAY": "Продолжить редактирование", + "UNSAVED_DIALOG_INVALID_HINT": "Некоторые поля заполнены неверно, поэтому изменения пока нельзя сохранить.", "BACK_TO_HOME": "Назад", "NEW_VERSION_AVAILABLE": "Доступна новая версия приложения", "LATEST_VERSION": "Вы используете последнюю версию приложения", diff --git a/apps/web/src/assets/i18n/tr.json b/apps/web/src/assets/i18n/tr.json index 14df41536..00cbbbfbf 100644 --- a/apps/web/src/assets/i18n/tr.json +++ b/apps/web/src/assets/i18n/tr.json @@ -296,6 +296,14 @@ "VIDEO_PLAYER_PLACEHOLDER": "Bir seçenek belirleyin", "VERSION": "Sürüm", "SAVE_CHANGES": "Değişiklikleri kaydet", + "UNSAVED_CHANGES": "Kaydedilmemiş değişiklikleriniz var", + "DISCARD_CHANGES": "Vazgeç", + "UNSAVED_DIALOG_TITLE": "Kaydedilmemiş değişiklikler", + "UNSAVED_DIALOG_MESSAGE": "Ayarlarda kaydedilmemiş değişiklikleriniz var. Çıkmadan önce kaydetmek ister misiniz?", + "UNSAVED_DIALOG_SAVE": "Kaydet ve çık", + "UNSAVED_DIALOG_DISCARD": "Kaydetmeden çık", + "UNSAVED_DIALOG_STAY": "Düzenlemeye devam et", + "UNSAVED_DIALOG_INVALID_HINT": "Bazı alanlar geçersiz olduğundan değişiklikler henüz kaydedilemiyor.", "BACK_TO_HOME": "Ana sayfaya dön", "NEW_VERSION_AVAILABLE": "Yeni bir sürüm mevcut", "LATEST_VERSION": "En son sürümü kullanıyorsunuz", diff --git a/apps/web/src/assets/i18n/zh.json b/apps/web/src/assets/i18n/zh.json index 983395365..c127aa976 100644 --- a/apps/web/src/assets/i18n/zh.json +++ b/apps/web/src/assets/i18n/zh.json @@ -296,6 +296,14 @@ "VIDEO_PLAYER_PLACEHOLDER": "选择一个选项", "VERSION": "版本", "SAVE_CHANGES": "保存更改", + "UNSAVED_CHANGES": "您有未保存的更改", + "DISCARD_CHANGES": "放弃更改", + "UNSAVED_DIALOG_TITLE": "未保存的更改", + "UNSAVED_DIALOG_MESSAGE": "设置中有未保存的更改。要在离开前保存吗?", + "UNSAVED_DIALOG_SAVE": "保存并离开", + "UNSAVED_DIALOG_DISCARD": "不保存直接离开", + "UNSAVED_DIALOG_STAY": "继续编辑", + "UNSAVED_DIALOG_INVALID_HINT": "部分字段无效,因此暂时无法保存更改。", "BACK_TO_HOME": "回到主页", "NEW_VERSION_AVAILABLE": "有新版本可用 ", "LATEST_VERSION": "您使用的是最新版本", diff --git a/apps/web/src/assets/i18n/zhtw.json b/apps/web/src/assets/i18n/zhtw.json index 09cfd812f..243129808 100644 --- a/apps/web/src/assets/i18n/zhtw.json +++ b/apps/web/src/assets/i18n/zhtw.json @@ -296,6 +296,14 @@ "VIDEO_PLAYER_PLACEHOLDER": "選擇一個選項", "VERSION": "版本", "SAVE_CHANGES": "儲存變更", + "UNSAVED_CHANGES": "您有未儲存的變更", + "DISCARD_CHANGES": "捨棄變更", + "UNSAVED_DIALOG_TITLE": "未儲存的變更", + "UNSAVED_DIALOG_MESSAGE": "設定中有未儲存的變更。要在離開前儲存嗎?", + "UNSAVED_DIALOG_SAVE": "儲存並離開", + "UNSAVED_DIALOG_DISCARD": "不儲存直接離開", + "UNSAVED_DIALOG_STAY": "繼續編輯", + "UNSAVED_DIALOG_INVALID_HINT": "部分欄位無效,因此暫時無法儲存變更。", "BACK_TO_HOME": "返回主頁", "NEW_VERSION_AVAILABLE": "有新版本可用", "LATEST_VERSION": "您正在使用最新版本", diff --git a/docs/architecture/workspace-shell.md b/docs/architecture/workspace-shell.md index 9b984e897..cb84a7dd4 100644 --- a/docs/architecture/workspace-shell.md +++ b/docs/architecture/workspace-shell.md @@ -51,7 +51,8 @@ Current workspace routes: 7. `/workspace/global-recent` 8. `/workspace/search` 9. `/workspace/downloads` -10. `/workspace/settings` +10. `/workspace/settings/:section` (`/workspace/settings` redirects to + `general`; the settings context panel links each section page) 11. `/workspace/xtreams/:id/...` 12. `/workspace/stalker/:id/...` @@ -130,7 +131,7 @@ The shell decides which secondary panel to show from the current route: 1. `WorkspaceContextPanelComponent` 3. Stalker category sections (`itv`, `radio`, `vod`, `series`) 1. `WorkspaceContextPanelComponent` -4. `/workspace/settings` +4. `/workspace/settings/:section` 1. `WorkspaceSettingsContextPanelComponent` 5. Downloads sections 1. `WorkspaceCollectionContextPanelComponent` diff --git a/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.html b/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.html index 3e50589e0..7e52028da 100644 --- a/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.html +++ b/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.html @@ -125,6 +125,7 @@ " [summary]="liveEpgPanelSummary()" [summaryLabelKey]="liveEpgPanelSummaryLabelKey()" + [emptyReason]="liveEpgEmptyReason()" (programActivated)=" onTimelineProgramActivated($event) " @@ -135,6 +136,7 @@ (collapsedChange)=" onLiveEpgPanelCollapsedChange($event) " + (openEpgSettings)="openEpgSettings()" /> } @else { } diff --git a/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.spec.ts b/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.spec.ts index 7bd2ab596..cb3bdc3f4 100644 --- a/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.spec.ts +++ b/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.spec.ts @@ -196,6 +196,7 @@ describe('VideoPlayerComponent', () => { const showCaptions = signal(false); const stripCountryPrefix = signal(false); const epgViewMode = signal<'timeline' | 'list'>('timeline'); + const epgUrlSetting = signal([]); const originalElectron = window.electron; const overlayRef = { @@ -397,6 +398,7 @@ describe('VideoPlayerComponent', () => { showCaptions, stripCountryPrefix, resolvedEpgViewMode: epgViewMode, + epgUrl: epgUrlSetting, }, }, { @@ -1174,6 +1176,45 @@ describe('VideoPlayerComponent', () => { EpgActions.resetActiveEpgProgram() ); }); + + describe('EPG needs-setup empty state', () => { + beforeEach(() => { + // Earlier tests leave programmes in the shared subject; the + // component keeps a live subscription, so this reset reaches it. + epgUrlSetting.set([]); + epgPrograms$.next([]); + }); + + afterEach(() => { + epgUrlSetting.set([]); + epgPrograms$.next([]); + }); + + it('claims needs-setup only while no EPG source exists anywhere', () => { + expect(component.liveEpgEmptyReason()).toBe('m3u-needs-setup'); + + epgUrlSetting.set(['https://example.org/guide.xml']); + + expect(component.liveEpgEmptyReason()).toBe('none'); + }); + + it('never overrides a channel that actually has programmes', () => { + // Uploaded XMLTV files produce programmes without any + // configured source URL — the ribbon must win over the hint. + epgPrograms$.next([buildProgram('Morning Bulletin')]); + + expect(component.liveEpgEmptyReason()).toBe('none'); + }); + + it('deep links the empty-state button to the EPG settings page', () => { + component.openEpgSettings(); + + expect(routerMock.navigate).toHaveBeenCalledWith([ + '/workspace/settings', + 'epg', + ]); + }); + }); }); function buildProgram(title: string): EpgProgram { diff --git a/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.ts b/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.ts index 72714b01c..b014929ae 100644 --- a/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.ts +++ b/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.ts @@ -37,6 +37,7 @@ import { EpgListViewComponent, EpgProgramActivationEvent, EpgTimelineComponent, + EpgTimelineEmptyReason, getTodayEpgDateKey, MultiEpgContainerComponent, shiftEpgDateKey, @@ -51,6 +52,7 @@ import { selectActive, selectActiveEpgProgram, selectActivePlaybackUrl, + selectActivePlaylist, selectChannels, selectChannelsLoading, selectCurrentEpgProgram, @@ -358,6 +360,34 @@ export class VideoPlayerComponent implements OnInit, OnDestroy { ? 'EPG.ARCHIVE_PLAYBACK' : 'EPG.CURRENT_PROGRAM' ); + private readonly activePlaylistForEpg = + this.store.selectSignal(selectActivePlaylist); + /** + * Without a single configured XMLTV source the whole playlist has no EPG, + * so the panel's empty state should point at the EPG settings page + * instead of implying that just this channel is unmapped. Only claims + * "needs setup" when nothing contradicts it: no programmes for the + * channel (uploaded XMLTV files produce programmes without any URL), no + * global source in settings (legacy values may be a plain string), and no + * playlist-scoped `url-tvg` source either. + */ + readonly liveEpgEmptyReason = computed(() => { + if (this.epgPrograms().length > 0) { + return 'none'; + } + + const globalSources = this.settingsStore.epgUrl?.() ?? []; + const hasGlobalSources = Array.isArray(globalSources) + ? globalSources.some((url) => Boolean(url?.trim?.())) + : Boolean(globalSources); + const hasPlaylistSources = ( + this.activePlaylistForEpg()?.epgUrls ?? [] + ).some((url) => Boolean(url?.trim?.())); + + return hasGlobalSources || hasPlaylistSources + ? 'none' + : 'm3u-needs-setup'; + }); readonly showReturnToLive = computed( () => this.activeEpgProgramOrNull() !== null ); @@ -705,6 +735,11 @@ export class VideoPlayerComponent implements OnInit, OnDestroy { this.store.dispatch(EpgActions.returnToLivePlayback()); } + /** Deep link from the EPG panel's "needs setup" empty state */ + openEpgSettings(): void { + void this.router.navigate(['/workspace/settings', 'epg']); + } + onTimelineProgramActivated(event: EpgProgramActivationEvent): void { if (event.type === 'live') { this.returnToLivePlayback(); diff --git a/libs/playlist/shared/util/src/index.ts b/libs/playlist/shared/util/src/index.ts index 317f2d1c9..7fc8eefe5 100644 --- a/libs/playlist/shared/util/src/index.ts +++ b/libs/playlist/shared/util/src/index.ts @@ -1,3 +1,2 @@ -export * from './lib/playlist-player-actions'; export * from './lib/playlist-context.facade'; export * from './lib/playlist-file-import.service'; diff --git a/libs/playlist/shared/util/src/lib/playlist-player-actions.ts b/libs/playlist/shared/util/src/lib/playlist-player-actions.ts deleted file mode 100644 index 2e52f330b..000000000 --- a/libs/playlist/shared/util/src/lib/playlist-player-actions.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { InjectionToken } from '@angular/core'; - -export interface PlaylistPlayerActions { - openSettings(): void; -} - -export const PLAYLIST_PLAYER_ACTIONS = - new InjectionToken('PLAYLIST_PLAYER_ACTIONS'); diff --git a/libs/portal/shared/util/src/index.ts b/libs/portal/shared/util/src/index.ts index 2ab7e80d7..ef9d78bc8 100644 --- a/libs/portal/shared/util/src/index.ts +++ b/libs/portal/shared/util/src/index.ts @@ -8,7 +8,6 @@ export * from './lib/portal-external-playback'; export * from './lib/portal-player'; export * from './lib/portal-playback-positions'; export * from './lib/series-quick-start'; -export * from './lib/portal-navigation-actions'; export * from './lib/portal-shell-actions'; export * from './lib/portal-collection-context'; export * from './lib/portal-collection-context.service'; diff --git a/libs/portal/shared/util/src/lib/portal-navigation-actions.ts b/libs/portal/shared/util/src/lib/portal-navigation-actions.ts deleted file mode 100644 index ed80f0a49..000000000 --- a/libs/portal/shared/util/src/lib/portal-navigation-actions.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { InjectionToken } from '@angular/core'; -import { Playlist } from '@iptvnator/shared/interfaces'; - -export interface PortalNavigationActions { - openAccountInfo(): void; - openPlaylistInfo(playlist: Playlist | null | undefined): void; - openSettings(): void; -} - -export const PORTAL_NAVIGATION_ACTIONS = - new InjectionToken('PORTAL_NAVIGATION_ACTIONS'); diff --git a/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-settings-context-panel.component.ts b/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-settings-context-panel.component.ts index 7e13edd56..568695847 100644 --- a/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-settings-context-panel.component.ts +++ b/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-settings-context-panel.component.ts @@ -1,31 +1,37 @@ import { Location } from '@angular/common'; import { Component, inject } from '@angular/core'; import { MatIconModule } from '@angular/material/icon'; +import { RouterLink, RouterLinkActive } from '@angular/router'; import { TranslateModule } from '@ngx-translate/core'; import { WorkspaceShellContextDrawerService, - SettingsContextService + SettingsContextService, } from '@iptvnator/workspace/shell/util'; @Component({ selector: 'app-workspace-settings-context-panel', - imports: [MatIconModule, TranslateModule], + imports: [MatIconModule, RouterLink, RouterLinkActive, TranslateModule], styleUrls: ['./workspace-settings-context-panel.component.scss'], template: `

{{ 'SETTINGS.TITLE' | translate }}

@@ -44,15 +50,15 @@ import { export class WorkspaceSettingsContextPanelComponent { readonly ctx = inject(SettingsContextService); private readonly location = inject(Location); - // Root-provided; optional keeps standalone unit tests light. Section clicks - // scroll the settings page without navigating, so the phone drawer's - // NavigationEnd auto-close never fires for them. + // Root-provided; optional keeps standalone unit tests light. Section + // links are real navigations now, so the phone drawer's NavigationEnd + // auto-close fires too — the explicit close just makes the drawer react + // immediately instead of waiting for the navigation to settle. private readonly contextDrawer = inject(WorkspaceShellContextDrawerService, { optional: true, }); - onSectionClicked(sectionId: string) { - this.ctx.navigateToSection(sectionId); + onSectionClicked() { this.contextDrawer?.close(); } diff --git a/libs/workspace/shell/util/src/lib/settings-context.service.ts b/libs/workspace/shell/util/src/lib/settings-context.service.ts index ead1e906d..c1029547b 100644 --- a/libs/workspace/shell/util/src/lib/settings-context.service.ts +++ b/libs/workspace/shell/util/src/lib/settings-context.service.ts @@ -6,32 +6,22 @@ export interface SettingsNavItem { icon: string; } +/** + * Bridge between the routed settings page and the workspace context panel: + * the page publishes which section pages exist for the current runtime, the + * panel renders them as router links to `/workspace/settings/:section`. + * Active-state highlighting comes from the router (`routerLinkActive`), not + * from this service. + */ @Injectable({ providedIn: 'root' }) export class SettingsContextService { readonly sections = signal([]); - readonly activeSection = signal('general'); - readonly pendingScrollTarget = signal(null); setSections(items: SettingsNavItem[]): void { this.sections.set(items); } - setActiveSection(id: string): void { - this.activeSection.set(id); - } - - navigateToSection(id: string): void { - this.activeSection.set(id); - this.pendingScrollTarget.set(id); - } - - clearPendingScrollTarget(): void { - this.pendingScrollTarget.set(null); - } - reset(): void { this.sections.set([]); - this.activeSection.set('general'); - this.pendingScrollTarget.set(null); } }