Merge remote-tracking branch 'origin/master' into agent/stalker-smart-discovery
# Conflicts: # apps/web/src/app/app.config.ts
No files matched your search
@@ -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.
|
||||
@@ -18,9 +18,9 @@ This file provides guidance to coding agents working in this repository.
|
||||
- See `docs/architecture/nx-workspace-boundaries.md` for the current Nx tag and alias policy.
|
||||
- Keep `nx` and every official `@nx/*` package on the same exact version; run
|
||||
`pnpm run deps:nx:validate` after dependency updates.
|
||||
- Vite `7.3.5`, resolved through Angular's build tooling, is patched with
|
||||
- Vite `7.3.6`, resolved through Angular's build tooling, is patched with
|
||||
bounded transform prefilters and the upstream precise matchers in
|
||||
`patches/vite@7.3.5.patch`. Keep the patch until supported Angular tooling
|
||||
`patches/vite@7.3.6.patch`. Keep the patch until supported Angular tooling
|
||||
resolves a Vite version containing the fix, and run `pnpm run deps:vite:test`
|
||||
after related dependency updates.
|
||||
- A directory holding files consumed by other projects must be an Nx project.
|
||||
|
||||
@@ -76,9 +76,9 @@ pnpm nx show projects
|
||||
- See `docs/architecture/nx-workspace-boundaries.md` for the current Nx tag and alias policy.
|
||||
- Keep `nx` and every official `@nx/*` package on the same exact version; run
|
||||
`pnpm run deps:nx:validate` after dependency updates.
|
||||
- Vite `7.3.5`, resolved through Angular's build tooling, is patched with
|
||||
- Vite `7.3.6`, resolved through Angular's build tooling, is patched with
|
||||
bounded transform prefilters and the upstream precise matchers in
|
||||
`patches/vite@7.3.5.patch`. Keep the patch until supported Angular tooling
|
||||
`patches/vite@7.3.6.patch`. Keep the patch until supported Angular tooling
|
||||
resolves a Vite version containing the fix, and run `pnpm run deps:vite:test`
|
||||
after related dependency updates.
|
||||
- A directory holding files consumed by other projects must be an Nx project.
|
||||
@@ -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):
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -123,6 +123,9 @@ async function selectDownloadCoverSize(
|
||||
): Promise<void> {
|
||||
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);
|
||||
}
|
||||
|
||||
|
||||
@@ -983,10 +983,24 @@ export async function openSettings(page: Page): Promise<void> {
|
||||
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<void> {
|
||||
await page.getByTestId(`settings-section-${sectionId}`).click();
|
||||
await page.waitForURL(new RegExp(`/workspace/settings/${sectionId}$`));
|
||||
}
|
||||
|
||||
export async function enableRemoteControl(
|
||||
page: Page,
|
||||
port: number
|
||||
): Promise<void> {
|
||||
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<void> {
|
||||
// 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);
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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"]'
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
After Width: | Height: | Size: 92 KiB |
|
After Width: | Height: | Size: 85 KiB |
|
After Width: | Height: | Size: 80 KiB |
|
After Width: | Height: | Size: 160 KiB |
|
After Width: | Height: | Size: 82 KiB |
|
After Width: | Height: | Size: 80 KiB |
|
After Width: | Height: | Size: 84 KiB |
|
After Width: | Height: | Size: 86 KiB |
|
After Width: | Height: | Size: 84 KiB |
@@ -64,10 +64,16 @@ async function servePlaybackFixtures(page: Page): Promise<void> {
|
||||
|
||||
async function openSettings(page: Page): Promise<void> {
|
||||
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<void> {
|
||||
@@ -79,7 +85,8 @@ async function selectAndSaveHtml5(page: Page): Promise<void> {
|
||||
|
||||
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<void> {
|
||||
@@ -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"]'
|
||||
);
|
||||
|
||||
@@ -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"]');
|
||||
|
||||
@@ -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 { STALKER_PLAYLIST_CONNECTION_EDITOR } from '@iptvnator/playlist/shared/ui';
|
||||
import { provideXtreamDataSource } from '@iptvnator/portal/xtream/data-access';
|
||||
import { DataService } from '@iptvnator/services';
|
||||
@@ -36,10 +35,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';
|
||||
@@ -149,11 +144,6 @@ export const appConfig: ApplicationConfig = {
|
||||
useExisting: ExternalPlaybackService,
|
||||
},
|
||||
...providePortalPlaybackPositions(),
|
||||
...providePortalNavigationActions(),
|
||||
{
|
||||
provide: PLAYLIST_PLAYER_ACTIONS,
|
||||
useExisting: AppPortalNavigationActionsService,
|
||||
},
|
||||
{
|
||||
provide: STALKER_PLAYLIST_CONNECTION_EDITOR,
|
||||
useExisting: AppStalkerPlaylistConnectionEditorService,
|
||||
|
||||
@@ -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
|
||||
),
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
<section
|
||||
class="settings-group"
|
||||
[class.settings-group--active]="activeSection() === 'about'"
|
||||
id="about"
|
||||
>
|
||||
<header class="settings-group__header">
|
||||
|
||||
@@ -16,7 +16,6 @@ function configureComponent(
|
||||
fixture: ComponentFixture<SettingsAboutSectionComponent>,
|
||||
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');
|
||||
});
|
||||
|
||||
|
||||
@@ -24,7 +24,6 @@ import {
|
||||
],
|
||||
})
|
||||
export class SettingsAboutSectionComponent {
|
||||
readonly activeSection = input.required<string>();
|
||||
readonly isDesktop = input(false);
|
||||
readonly version = input<string | undefined>();
|
||||
readonly buildCommit = input<string | undefined>();
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
<section
|
||||
class="settings-group"
|
||||
[class.settings-group--active]="activeSection() === 'backup'"
|
||||
id="backup"
|
||||
>
|
||||
<header class="settings-group__header">
|
||||
|
||||
@@ -17,7 +17,6 @@ import { TranslateModule } from '@ngx-translate/core';
|
||||
styles: [':host { display: contents; }'],
|
||||
})
|
||||
export class SettingsBackupSectionComponent {
|
||||
readonly activeSection = input.required<string>();
|
||||
readonly isPwa = input(false);
|
||||
readonly isRemovingAllPlaylists = input(false);
|
||||
readonly isExportingData = input(false);
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
<section
|
||||
class="settings-group"
|
||||
[class.settings-group--active]="activeSection() === 'dashboard'"
|
||||
id="dashboard"
|
||||
[formGroup]="form()"
|
||||
>
|
||||
|
||||
@@ -20,5 +20,4 @@ import { TranslateModule } from '@ngx-translate/core';
|
||||
})
|
||||
export class SettingsDashboardSectionComponent {
|
||||
readonly form = input.required<FormGroup>();
|
||||
readonly activeSection = input.required<string>();
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
<section
|
||||
class="settings-group"
|
||||
[class.settings-group--active]="activeSection() === 'epg'"
|
||||
id="epg"
|
||||
[formGroup]="form()"
|
||||
>
|
||||
|
||||
@@ -32,7 +32,6 @@ import { EpgViewModeOption } from './settings.models';
|
||||
})
|
||||
export class SettingsEpgSectionComponent {
|
||||
readonly form = input.required<FormGroup>();
|
||||
readonly activeSection = input.required<string>();
|
||||
readonly epgUrl = input.required<FormArray>();
|
||||
readonly isClearingEpgData = input(false);
|
||||
readonly epgViewModeOptions = input.required<EpgViewModeOption[]>();
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
<section
|
||||
class="settings-group"
|
||||
[class.settings-group--active]="activeSection() === 'general'"
|
||||
id="general"
|
||||
[formGroup]="form()"
|
||||
>
|
||||
|
||||
@@ -30,7 +30,6 @@ import {
|
||||
})
|
||||
export class SettingsGeneralSectionComponent {
|
||||
readonly form = input.required<FormGroup>();
|
||||
readonly activeSection = input.required<string>();
|
||||
readonly languageEnum = input.required<typeof Language>();
|
||||
readonly themeOptions = input.required<ThemeOption[]>();
|
||||
readonly coverSizeOptions = input.required<CoverSizeOption[]>();
|
||||
|
||||
@@ -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<string> {
|
||||
const ids = new Set<string>();
|
||||
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 <section> root only —
|
||||
// descendant elements (form controls, anchors) also use id=
|
||||
// attributes and would pollute the set.
|
||||
const rootMatch = /<section[^>]*\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<string> {
|
||||
const html = readFileSync(
|
||||
resolve(settingsDir, 'settings.component.html'),
|
||||
'utf-8'
|
||||
);
|
||||
const idsByComponent = new Map<string, string>();
|
||||
|
||||
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 = /<section[^>]*\sid="([^"]+)"/i.exec(
|
||||
sectionHtml
|
||||
);
|
||||
if (rootMatch) {
|
||||
idsByComponent.set(
|
||||
`app-settings-${componentMatch[1]}-section`,
|
||||
rootMatch[1]
|
||||
);
|
||||
}
|
||||
const ids = new Set<string>(
|
||||
Array.from(
|
||||
html.matchAll(/@case \('([a-z-]+)'\)/g),
|
||||
(match) => match[1]
|
||||
)
|
||||
);
|
||||
if (/@default\s*\{\s*<app-settings-general-section/.test(html)) {
|
||||
ids.add('general');
|
||||
}
|
||||
|
||||
return Array.from(
|
||||
html.matchAll(/<app-settings-[a-z-]+-section\b/g),
|
||||
(match) => 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);
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
<section
|
||||
class="settings-group"
|
||||
[class.settings-group--active]="activeSection() === 'playback'"
|
||||
id="playback"
|
||||
[formGroup]="form()"
|
||||
>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -45,7 +45,6 @@ export class SettingsPlaybackSectionComponent {
|
||||
].join('\n');
|
||||
|
||||
readonly form = input.required<FormGroup>();
|
||||
readonly activeSection = input.required<string>();
|
||||
readonly players = input.required<SettingsPlayerOption[]>();
|
||||
readonly streamFormatEnum = input.required<typeof StreamFormat>();
|
||||
readonly isDesktop = input(false);
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
<section
|
||||
class="settings-group"
|
||||
[class.settings-group--active]="activeSection() === 'remote-control'"
|
||||
id="remote-control"
|
||||
[formGroup]="form()"
|
||||
>
|
||||
|
||||
@@ -28,7 +28,6 @@ import { QRCodeComponent } from 'angularx-qrcode';
|
||||
})
|
||||
export class SettingsRemoteControlSectionComponent {
|
||||
readonly form = input.required<FormGroup>();
|
||||
readonly activeSection = input.required<string>();
|
||||
readonly localIpAddresses = input.required<string[]>();
|
||||
readonly visibleQrCodeIp = input<string | null>(null);
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
<section
|
||||
class="settings-group"
|
||||
[class.settings-group--active]="activeSection() === 'reset'"
|
||||
id="reset"
|
||||
>
|
||||
<header class="settings-group__header">
|
||||
|
||||
@@ -18,7 +18,6 @@ import { SettingsPlaylistDeleteSummary } from './settings.models';
|
||||
styles: [':host { display: contents; }'],
|
||||
})
|
||||
export class SettingsResetSectionComponent {
|
||||
readonly activeSection = input.required<string>();
|
||||
readonly isRemovingAllPlaylists = input(false);
|
||||
readonly canRemoveAllPlaylists = input(false);
|
||||
readonly playlistDeleteSummary =
|
||||
|
||||
@@ -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<SettingsComponent>;
|
||||
let epgBridge: Partial<EpgRuntimeBridgeService>;
|
||||
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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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<HTMLElement>);
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
<section
|
||||
class="settings-group"
|
||||
[class.settings-group--active]="activeSection() === 'tmdb'"
|
||||
id="tmdb"
|
||||
[formGroup]="form()"
|
||||
>
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
@@ -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<FormGroup>();
|
||||
readonly activeSection = input.required<string>();
|
||||
|
||||
readonly keyTestState = signal<TmdbKeyTestState>('idle');
|
||||
readonly cacheStats = signal<TmdbCacheStats | null>(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 {
|
||||
|
||||
@@ -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: `
|
||||
<h2 mat-dialog-title>
|
||||
{{ 'SETTINGS.UNSAVED_DIALOG_TITLE' | translate }}
|
||||
</h2>
|
||||
<mat-dialog-content class="mat-typography">
|
||||
<p>{{ 'SETTINGS.UNSAVED_DIALOG_MESSAGE' | translate }}</p>
|
||||
@if (!data.canSave) {
|
||||
<p class="unsaved-dialog__invalid-hint">
|
||||
<mat-icon>error_outline</mat-icon>
|
||||
<span>{{
|
||||
'SETTINGS.UNSAVED_DIALOG_INVALID_HINT' | translate
|
||||
}}</span>
|
||||
</p>
|
||||
}
|
||||
</mat-dialog-content>
|
||||
<mat-dialog-actions align="end">
|
||||
<button
|
||||
mat-button
|
||||
[mat-dialog-close]="'discard'"
|
||||
data-test-id="unsaved-dialog-discard"
|
||||
>
|
||||
{{ 'SETTINGS.UNSAVED_DIALOG_DISCARD' | translate }}
|
||||
</button>
|
||||
<button
|
||||
mat-button
|
||||
mat-dialog-close
|
||||
cdkFocusInitial
|
||||
data-test-id="unsaved-dialog-stay"
|
||||
>
|
||||
{{ 'SETTINGS.UNSAVED_DIALOG_STAY' | translate }}
|
||||
</button>
|
||||
<button
|
||||
mat-flat-button
|
||||
color="primary"
|
||||
[disabled]="!data.canSave"
|
||||
[mat-dialog-close]="'save'"
|
||||
data-test-id="unsaved-dialog-save"
|
||||
>
|
||||
{{ 'SETTINGS.UNSAVED_DIALOG_SAVE' | translate }}
|
||||
</button>
|
||||
</mat-dialog-actions>
|
||||
`,
|
||||
})
|
||||
export class SettingsUnsavedChangesDialogComponent {
|
||||
readonly data = inject<SettingsUnsavedChangesDialogData>(MAT_DIALOG_DATA);
|
||||
}
|
||||
@@ -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> | boolean =>
|
||||
settingsUnsavedChangesGuard(
|
||||
component,
|
||||
{} as ActivatedRouteSnapshot,
|
||||
{ url: '/workspace/settings/general' } as RouterStateSnapshot,
|
||||
{ url: nextUrl } as RouterStateSnapshot
|
||||
) as Promise<boolean> | 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();
|
||||
});
|
||||
});
|
||||
@@ -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> | 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();
|
||||
};
|
||||
@@ -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');
|
||||
|
||||
@@ -1,13 +1,4 @@
|
||||
@if (isDialog) {
|
||||
<h2 mat-dialog-title>{{ 'SETTINGS.GENERAL' | translate }}</h2>
|
||||
}
|
||||
|
||||
<div
|
||||
class="settings-container"
|
||||
data-test-id="settings-container"
|
||||
[class.dialog-mode]="isDialog"
|
||||
appSettingsSectionScroll
|
||||
>
|
||||
<div class="settings-container" data-test-id="settings-container">
|
||||
<form
|
||||
[formGroup]="settingsForm"
|
||||
(ngSubmit)="onSubmit()"
|
||||
@@ -16,137 +7,147 @@
|
||||
>
|
||||
<!-- Page-level "Settings" h1 intentionally removed: the left rail
|
||||
already labels the page "Settings" (sg-nav-title), and each
|
||||
section below provides its own h3 + subtitle. Stacking another
|
||||
h1 in the right pane reproduced the audit's "vertical ladder
|
||||
of identical labels" complaint. -->
|
||||
@if (!isDialog) {
|
||||
<!-- Reserved hook so the page test selector still resolves,
|
||||
even though there's no visible chrome here. -->
|
||||
<span
|
||||
class="visually-hidden"
|
||||
data-test-id="settings-page-header"
|
||||
>
|
||||
{{ 'SETTINGS.TITLE' | translate }}
|
||||
</span>
|
||||
}
|
||||
section below provides its own h3 + subtitle. -->
|
||||
<span class="visually-hidden" data-test-id="settings-page-header">
|
||||
{{ 'SETTINGS.TITLE' | translate }}
|
||||
</span>
|
||||
|
||||
<div class="settings-content">
|
||||
<app-settings-general-section
|
||||
[form]="settingsForm"
|
||||
[activeSection]="activeSection()"
|
||||
[languageEnum]="languageEnum"
|
||||
[themeOptions]="themeOptions"
|
||||
[coverSizeOptions]="coverSizeOptions"
|
||||
[startupBehaviorOptions]="startupBehaviorOptions"
|
||||
(selectTheme)="form.selectTheme($event)"
|
||||
(selectCoverSize)="form.selectCoverSize($event)"
|
||||
/>
|
||||
|
||||
<app-settings-playback-section
|
||||
[form]="settingsForm"
|
||||
[activeSection]="activeSection()"
|
||||
[players]="players()"
|
||||
[streamFormatEnum]="streamFormatEnum"
|
||||
[isDesktop]="isDesktop"
|
||||
[supportsManagedExternalPlayers]="supportsManagedExternalPlayers"
|
||||
[supportsExternalPlayerPathSettings]="supportsExternalPlayerPathSettings"
|
||||
[supportsVodMultiSource]="supportsVodMultiSource"
|
||||
[frameCopyAvailable]="embeddedMpv.frameCopyAvailable()"
|
||||
[frameCopyActive]="embeddedMpv.frameCopyActive()"
|
||||
(selectRecordingFolder)="selectRecordingFolder()"
|
||||
/>
|
||||
|
||||
@if (supportsEpg) {
|
||||
<app-settings-epg-section
|
||||
[form]="settingsForm"
|
||||
[activeSection]="activeSection()"
|
||||
[epgUrl]="form.epgUrl"
|
||||
[isClearingEpgData]="epg.isClearing()"
|
||||
[epgViewModeOptions]="epgViewModeOptions"
|
||||
(refreshEpg)="epg.refresh($event)"
|
||||
(removeEpgSource)="form.removeEpgSource($event)"
|
||||
(addEpgSource)="form.addEpgSource()"
|
||||
(refreshAllEpg)="epg.refreshAll()"
|
||||
(clearEpgData)="epg.clear()"
|
||||
(selectEpgViewMode)="form.selectEpgViewMode($event)"
|
||||
/>
|
||||
<!-- Only the routed section renders. The parent keeps the shared
|
||||
form alive across section switches, so edits staged on one
|
||||
page survive a visit to another. -->
|
||||
@switch (activeSection()) {
|
||||
@case ('playback') {
|
||||
<app-settings-playback-section
|
||||
[form]="settingsForm"
|
||||
[players]="players()"
|
||||
[streamFormatEnum]="streamFormatEnum"
|
||||
[isDesktop]="isDesktop"
|
||||
[supportsManagedExternalPlayers]="
|
||||
supportsManagedExternalPlayers
|
||||
"
|
||||
[supportsExternalPlayerPathSettings]="
|
||||
supportsExternalPlayerPathSettings
|
||||
"
|
||||
[supportsVodMultiSource]="supportsVodMultiSource"
|
||||
[frameCopyAvailable]="embeddedMpv.frameCopyAvailable()"
|
||||
[frameCopyActive]="embeddedMpv.frameCopyActive()"
|
||||
(selectRecordingFolder)="selectRecordingFolder()"
|
||||
/>
|
||||
}
|
||||
@case ('epg') {
|
||||
<app-settings-epg-section
|
||||
[form]="settingsForm"
|
||||
[epgUrl]="form.epgUrl"
|
||||
[isClearingEpgData]="epg.isClearing()"
|
||||
[epgViewModeOptions]="epgViewModeOptions"
|
||||
(refreshEpg)="epg.refresh($event)"
|
||||
(removeEpgSource)="form.removeEpgSource($event)"
|
||||
(addEpgSource)="form.addEpgSource()"
|
||||
(refreshAllEpg)="epg.refreshAll()"
|
||||
(clearEpgData)="epg.clear()"
|
||||
(selectEpgViewMode)="form.selectEpgViewMode($event)"
|
||||
/>
|
||||
}
|
||||
@case ('dashboard') {
|
||||
<app-settings-dashboard-section [form]="settingsForm" />
|
||||
}
|
||||
@case ('remote-control') {
|
||||
<app-settings-remote-control-section
|
||||
[form]="settingsForm"
|
||||
[localIpAddresses]="remoteControl.localIpAddresses()"
|
||||
[visibleQrCodeIp]="remoteControl.visibleQrCodeIp()"
|
||||
(toggleQrCode)="remoteControl.toggleQrCode($event)"
|
||||
/>
|
||||
}
|
||||
@case ('tmdb') {
|
||||
<app-settings-tmdb-section [form]="settingsForm" />
|
||||
}
|
||||
@case ('backup') {
|
||||
<app-settings-backup-section
|
||||
[isPwa]="isPwa"
|
||||
[isRemovingAllPlaylists]="
|
||||
playlistReset.isRemovingAllPlaylists()
|
||||
"
|
||||
[isExportingData]="backup.isExportingData()"
|
||||
(importData)="importData()"
|
||||
(exportData)="exportData()"
|
||||
/>
|
||||
}
|
||||
@case ('reset') {
|
||||
<app-settings-reset-section
|
||||
[isRemovingAllPlaylists]="
|
||||
playlistReset.isRemovingAllPlaylists()
|
||||
"
|
||||
[canRemoveAllPlaylists]="playlistReset.canRemoveAll()"
|
||||
[playlistDeleteSummary]="playlistReset.deleteSummary()"
|
||||
[removeAllProgressLabel]="
|
||||
playlistReset.removeAllProgressLabel()
|
||||
"
|
||||
(removeAll)="removeAll()"
|
||||
/>
|
||||
}
|
||||
@case ('about') {
|
||||
<app-settings-about-section
|
||||
[isDesktop]="isDesktop"
|
||||
[version]="appUpdate.version()"
|
||||
[buildCommit]="buildCommit"
|
||||
[updateMessage]="appUpdate.updateMessage()"
|
||||
[appUpdateStatus]="appUpdate.status()"
|
||||
(checkForAppUpdate)="appUpdate.checkForAppUpdate()"
|
||||
(downloadAppUpdate)="appUpdate.downloadAppUpdate()"
|
||||
(installAppUpdate)="appUpdate.installAppUpdate()"
|
||||
(openManualAppUpdate)="appUpdate.openManualAppUpdate()"
|
||||
(openAppUpdateReleaseNotes)="
|
||||
appUpdate.openReleaseNotes()
|
||||
"
|
||||
/>
|
||||
}
|
||||
@default {
|
||||
<app-settings-general-section
|
||||
[form]="settingsForm"
|
||||
[languageEnum]="languageEnum"
|
||||
[themeOptions]="themeOptions"
|
||||
[coverSizeOptions]="coverSizeOptions"
|
||||
[startupBehaviorOptions]="startupBehaviorOptions"
|
||||
(selectTheme)="form.selectTheme($event)"
|
||||
(selectCoverSize)="form.selectCoverSize($event)"
|
||||
/>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
|
||||
<app-settings-dashboard-section
|
||||
[form]="settingsForm"
|
||||
[activeSection]="activeSection()"
|
||||
/>
|
||||
|
||||
@if (supportsRemoteControl) {
|
||||
<app-settings-remote-control-section
|
||||
[form]="settingsForm"
|
||||
[activeSection]="activeSection()"
|
||||
[localIpAddresses]="remoteControl.localIpAddresses()"
|
||||
[visibleQrCodeIp]="remoteControl.visibleQrCodeIp()"
|
||||
(toggleQrCode)="remoteControl.toggleQrCode($event)"
|
||||
/>
|
||||
}
|
||||
|
||||
<app-settings-tmdb-section
|
||||
[form]="settingsForm"
|
||||
[activeSection]="activeSection()"
|
||||
/>
|
||||
|
||||
<app-settings-backup-section
|
||||
[activeSection]="activeSection()"
|
||||
[isPwa]="isPwa"
|
||||
[isRemovingAllPlaylists]="playlistReset.isRemovingAllPlaylists()"
|
||||
[isExportingData]="backup.isExportingData()"
|
||||
(importData)="importData()"
|
||||
(exportData)="exportData()"
|
||||
/>
|
||||
|
||||
<app-settings-reset-section
|
||||
[activeSection]="activeSection()"
|
||||
[isRemovingAllPlaylists]="playlistReset.isRemovingAllPlaylists()"
|
||||
[canRemoveAllPlaylists]="playlistReset.canRemoveAll()"
|
||||
[playlistDeleteSummary]="playlistReset.deleteSummary()"
|
||||
[removeAllProgressLabel]="playlistReset.removeAllProgressLabel()"
|
||||
(removeAll)="removeAll()"
|
||||
/>
|
||||
|
||||
<app-settings-about-section
|
||||
[activeSection]="activeSection()"
|
||||
[isDesktop]="isDesktop"
|
||||
[version]="appUpdate.version()"
|
||||
[buildCommit]="buildCommit"
|
||||
[updateMessage]="appUpdate.updateMessage()"
|
||||
[appUpdateStatus]="appUpdate.status()"
|
||||
(checkForAppUpdate)="appUpdate.checkForAppUpdate()"
|
||||
(downloadAppUpdate)="appUpdate.downloadAppUpdate()"
|
||||
(installAppUpdate)="appUpdate.installAppUpdate()"
|
||||
(openManualAppUpdate)="appUpdate.openManualAppUpdate()"
|
||||
(openAppUpdateReleaseNotes)="appUpdate.openReleaseNotes()"
|
||||
/>
|
||||
|
||||
<footer class="action-bar" data-test-id="settings-action-bar">
|
||||
@if (isDialog) {
|
||||
<!-- Discord-style unsaved-changes bar: appears the moment any edit
|
||||
makes the form dirty — on whichever section page — and vanishes
|
||||
once saved or discarded. It replaces the always-visible footer
|
||||
Save button of the single-scroll layout. -->
|
||||
@if (!settingsForm.pristine) {
|
||||
<footer class="unsaved-bar" data-test-id="settings-unsaved-bar">
|
||||
<div class="unsaved-bar__message">
|
||||
<mat-icon>edit</mat-icon>
|
||||
<span>{{ 'SETTINGS.UNSAVED_CHANGES' | translate }}</span>
|
||||
</div>
|
||||
<div class="unsaved-bar__actions">
|
||||
<button
|
||||
mat-button
|
||||
type="button"
|
||||
(click)="backToHome()"
|
||||
data-test-id="back-to-home"
|
||||
(click)="discardChanges()"
|
||||
data-test-id="discard-settings"
|
||||
>
|
||||
<mat-icon>close</mat-icon>
|
||||
{{ 'CLOSE' | translate }}
|
||||
{{ 'SETTINGS.DISCARD_CHANGES' | translate }}
|
||||
</button>
|
||||
}
|
||||
<button
|
||||
mat-flat-button
|
||||
type="submit"
|
||||
class="settings-save-button"
|
||||
[disabled]="settingsForm.pristine || !settingsForm.valid"
|
||||
data-test-id="save-settings"
|
||||
>
|
||||
<mat-icon>save</mat-icon>
|
||||
{{ 'SETTINGS.SAVE_CHANGES' | translate }}
|
||||
</button>
|
||||
<button
|
||||
mat-flat-button
|
||||
type="submit"
|
||||
class="settings-save-button"
|
||||
[disabled]="!settingsForm.valid"
|
||||
data-test-id="save-settings"
|
||||
>
|
||||
<mat-icon>save</mat-icon>
|
||||
{{ 'SETTINGS.SAVE_CHANGES' | translate }}
|
||||
</button>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
}
|
||||
</form>
|
||||
</div>
|
||||
@@ -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 {
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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<EpgRuntimeBridgeService>;
|
||||
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);
|
||||
});
|
||||
|
||||
});
|
||||
@@ -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<HTMLElement>);
|
||||
|
||||
/** 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<boolean> {
|
||||
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<boolean> {
|
||||
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<void> {
|
||||
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.
|
||||
|
||||
@@ -13,11 +13,6 @@ export interface SettingsSection {
|
||||
visible: boolean;
|
||||
}
|
||||
|
||||
export interface ObservedSettingsSection {
|
||||
id: string;
|
||||
element: HTMLElement;
|
||||
}
|
||||
|
||||
export interface ThemeOption {
|
||||
value: Theme;
|
||||
icon: string;
|
||||
|
||||
@@ -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<ParamMap>(
|
||||
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: [] },
|
||||
|
||||
@@ -298,6 +298,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": "أنت تستخدم الإصدار الأحدث",
|
||||
|
||||
@@ -298,6 +298,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": "راك تستعمل أحدث إصدار",
|
||||
|
||||
@@ -298,6 +298,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": "Вы выкарыстоўваеце апошнюю версію праграмы",
|
||||
|
||||
@@ -298,6 +298,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",
|
||||
|
||||
@@ -298,6 +298,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": "Χρησιμοποιείτε την πιο πρόσφατη έκδοση",
|
||||
|
||||
@@ -298,6 +298,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",
|
||||
|
||||
@@ -298,6 +298,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",
|
||||
|
||||
@@ -298,6 +298,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",
|
||||
|
||||
@@ -298,6 +298,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",
|
||||
|
||||
@@ -298,6 +298,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",
|
||||
|
||||
@@ -298,6 +298,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": "最新バージョンを使用中です",
|
||||
|
||||
@@ -298,6 +298,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": "최신 버전을 사용하고 있습니다",
|
||||
|
||||
@@ -298,6 +298,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",
|
||||
|
||||
@@ -298,6 +298,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",
|
||||
|
||||
@@ -298,6 +298,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",
|
||||
|
||||
@@ -298,6 +298,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": "Вы используете последнюю версию приложения",
|
||||
|
||||
@@ -298,6 +298,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",
|
||||
|
||||
@@ -298,6 +298,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": "您使用的是最新版本",
|
||||
|
||||
@@ -298,6 +298,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": "您正在使用最新版本",
|
||||
|
||||
@@ -52,7 +52,7 @@ resulting PR runs the full CI pipeline.
|
||||
|
||||
## Vite Dev-Server Patch
|
||||
|
||||
Angular's development builder currently resolves Vite `7.3.5`. That release's
|
||||
Angular's development builder currently resolves Vite `7.3.6`. That release's
|
||||
asset and worker transform prefilters can catastrophically backtrack on a large
|
||||
generated chunk containing unrelated `new URL...` expressions while searching
|
||||
for a valid `new URL(..., import.meta.url)` construct. The Electron development
|
||||
@@ -60,12 +60,12 @@ server can then fail a lazy chunk request with `Maximum call stack size
|
||||
exceeded`, even though static builds succeed because they do not pass emitted
|
||||
chunks through Vite's request-time plugin container.
|
||||
|
||||
`patches/vite@7.3.5.patch` backports Vite's upstream precise-matcher fix from
|
||||
`patches/vite@7.3.6.patch` backports Vite's upstream precise-matcher fix from
|
||||
[vitejs/vite#21800](https://github.com/vitejs/vite/pull/21800). Bounded
|
||||
prefilters keep the request-time scan linear while allowing comment-bearing
|
||||
asset and worker expressions to reach the precise matcher after Vite strips
|
||||
comments. Keep the patch while the supported Angular toolchain resolves Vite
|
||||
`7.3.5`; remove it only after the resolved Vite contains the upstream fix. Run
|
||||
`7.3.6`; remove it only after the resolved Vite contains the upstream fix. Run
|
||||
the regression check after any related manifest or lockfile update:
|
||||
|
||||
```bash
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -125,6 +125,7 @@
|
||||
"
|
||||
[summary]="liveEpgPanelSummary()"
|
||||
[summaryLabelKey]="liveEpgPanelSummaryLabelKey()"
|
||||
[emptyReason]="liveEpgEmptyReason()"
|
||||
(programActivated)="
|
||||
onTimelineProgramActivated($event)
|
||||
"
|
||||
@@ -135,6 +136,7 @@
|
||||
(collapsedChange)="
|
||||
onLiveEpgPanelCollapsedChange($event)
|
||||
"
|
||||
(openEpgSettings)="openEpgSettings()"
|
||||
/>
|
||||
} @else {
|
||||
<app-epg-timeline
|
||||
@@ -153,6 +155,7 @@
|
||||
"
|
||||
[summary]="liveEpgPanelSummary()"
|
||||
[summaryLabelKey]="liveEpgPanelSummaryLabelKey()"
|
||||
[emptyReason]="liveEpgEmptyReason()"
|
||||
(programActivated)="
|
||||
onTimelineProgramActivated($event)
|
||||
"
|
||||
@@ -163,6 +166,7 @@
|
||||
(collapsedChange)="
|
||||
onLiveEpgPanelCollapsedChange($event)
|
||||
"
|
||||
(openEpgSettings)="openEpgSettings()"
|
||||
/>
|
||||
}
|
||||
</div>
|
||||
|
||||
@@ -196,6 +196,7 @@ describe('VideoPlayerComponent', () => {
|
||||
const showCaptions = signal(false);
|
||||
const stripCountryPrefix = signal(false);
|
||||
const epgViewMode = signal<'timeline' | 'list'>('timeline');
|
||||
const epgUrlSetting = signal<string[]>([]);
|
||||
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 {
|
||||
|
||||
@@ -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<EpgTimelineEmptyReason>(() => {
|
||||
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();
|
||||
|
||||
@@ -1,3 +1,2 @@
|
||||
export * from './lib/playlist-player-actions';
|
||||
export * from './lib/playlist-context.facade';
|
||||
export * from './lib/playlist-file-import.service';
|
||||
@@ -1,8 +0,0 @@
|
||||
import { InjectionToken } from '@angular/core';
|
||||
|
||||
export interface PlaylistPlayerActions {
|
||||
openSettings(): void;
|
||||
}
|
||||
|
||||
export const PLAYLIST_PLAYER_ACTIONS =
|
||||
new InjectionToken<PlaylistPlayerActions>('PLAYLIST_PLAYER_ACTIONS');
|
||||
@@ -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';
|
||||
|
||||
@@ -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<PortalNavigationActions>('PORTAL_NAVIGATION_ACTIONS');
|
||||
@@ -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: `
|
||||
<h2 class="panel-title">{{ 'SETTINGS.TITLE' | translate }}</h2>
|
||||
<div class="settings-panel-body">
|
||||
<div class="nav-list settings-sections-list">
|
||||
@for (section of ctx.sections(); track section.id) {
|
||||
<button
|
||||
type="button"
|
||||
<!-- replaceUrl keeps a single settings entry in the
|
||||
browser history: switching sections must not turn
|
||||
"Back" (footer or browser) into a walk through every
|
||||
visited section page before finally leaving. -->
|
||||
<a
|
||||
class="nav-item settings-section-item"
|
||||
[class.active]="ctx.activeSection() === section.id"
|
||||
routerLinkActive="active"
|
||||
[routerLink]="['/workspace/settings', section.id]"
|
||||
[replaceUrl]="true"
|
||||
[attr.data-test-id]="'settings-section-' + section.id"
|
||||
(click)="onSectionClicked(section.id)"
|
||||
(click)="onSectionClicked()"
|
||||
>
|
||||
<mat-icon>{{ section.icon }}</mat-icon>
|
||||
<span>{{ section.label | translate }}</span>
|
||||
</button>
|
||||
</a>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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<SettingsNavItem[]>([]);
|
||||
readonly activeSection = signal<string>('general');
|
||||
readonly pendingScrollTarget = signal<string | null>(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);
|
||||
}
|
||||
}
|
||||
@@ -81,16 +81,16 @@
|
||||
"private": true,
|
||||
"packageManager": "pnpm@10.33.0+sha512.10568bb4a6afb58c9eb3630da90cc9516417abebd3fabbe6739f0ae795728da1491e9db5a544c76ad8eb7570f5c4bb3d6c637b2cb41bfdcdb47fa823c8649319",
|
||||
"dependencies": {
|
||||
"@angular/animations": "21.2.17",
|
||||
"@angular/animations": "21.2.19",
|
||||
"@angular/cdk": "21.2.14",
|
||||
"@angular/common": "21.2.17",
|
||||
"@angular/compiler": "21.2.17",
|
||||
"@angular/core": "21.2.17",
|
||||
"@angular/forms": "21.2.17",
|
||||
"@angular/common": "21.2.19",
|
||||
"@angular/compiler": "21.2.19",
|
||||
"@angular/core": "21.2.19",
|
||||
"@angular/forms": "21.2.19",
|
||||
"@angular/material": "21.2.14",
|
||||
"@angular/platform-browser": "21.2.17",
|
||||
"@angular/platform-browser-dynamic": "21.2.17",
|
||||
"@angular/router": "21.2.17",
|
||||
"@angular/platform-browser": "21.2.19",
|
||||
"@angular/platform-browser-dynamic": "21.2.19",
|
||||
"@angular/router": "21.2.19",
|
||||
"@ngrx/effects": "21.1.1",
|
||||
"@ngrx/entity": "21.1.1",
|
||||
"@ngrx/router-store": "21.1.1",
|
||||
@@ -125,25 +125,25 @@
|
||||
"zone.js": "~0.16.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@angular-devkit/core": "21.2.17",
|
||||
"@angular-devkit/schematics": "21.2.17",
|
||||
"@angular-devkit/core": "21.2.19",
|
||||
"@angular-devkit/schematics": "21.2.19",
|
||||
"@angular-eslint/builder": "21.3.1",
|
||||
"@angular-eslint/eslint-plugin": "21.3.1",
|
||||
"@angular-eslint/eslint-plugin-template": "21.3.1",
|
||||
"@angular-eslint/schematics": "21.3.1",
|
||||
"@angular-eslint/template-parser": "21.3.1",
|
||||
"@angular/build": "21.2.17",
|
||||
"@angular/cli": "21.2.17",
|
||||
"@angular/common": "21.2.17",
|
||||
"@angular/compiler": "21.2.17",
|
||||
"@angular/compiler-cli": "21.2.17",
|
||||
"@angular/core": "21.2.17",
|
||||
"@angular/forms": "21.2.17",
|
||||
"@angular/language-service": "21.2.17",
|
||||
"@angular/platform-browser": "21.2.17",
|
||||
"@angular/platform-browser-dynamic": "21.2.17",
|
||||
"@angular/router": "21.2.17",
|
||||
"@angular/service-worker": "21.2.17",
|
||||
"@angular/build": "21.2.19",
|
||||
"@angular/cli": "21.2.19",
|
||||
"@angular/common": "21.2.19",
|
||||
"@angular/compiler": "21.2.19",
|
||||
"@angular/compiler-cli": "21.2.19",
|
||||
"@angular/core": "21.2.19",
|
||||
"@angular/forms": "21.2.19",
|
||||
"@angular/language-service": "21.2.19",
|
||||
"@angular/platform-browser": "21.2.19",
|
||||
"@angular/platform-browser-dynamic": "21.2.19",
|
||||
"@angular/router": "21.2.19",
|
||||
"@angular/service-worker": "21.2.19",
|
||||
"@astrojs/mdx": "4.3.13",
|
||||
"@astrojs/sitemap": "3.7.3",
|
||||
"@astrojs/tailwind": "6.0.2",
|
||||
@@ -169,7 +169,7 @@
|
||||
"@nx/web": "22.7.2",
|
||||
"@nx/workspace": "22.7.2",
|
||||
"@playwright/test": "^1.62.0",
|
||||
"@schematics/angular": "21.2.9",
|
||||
"@schematics/angular": "21.2.19",
|
||||
"@swc-node/register": "1.12.1",
|
||||
"@swc/core": "1.15.46",
|
||||
"@swc/helpers": "0.5.23",
|
||||
@@ -252,7 +252,7 @@
|
||||
},
|
||||
"patchedDependencies": {
|
||||
"nx-electron@22.0.0": "patches/nx-electron@22.0.0.patch",
|
||||
"vite@7.3.5": "patches/vite@7.3.5.patch"
|
||||
"vite@7.3.6": "patches/vite@7.3.6.patch"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
diff --git a/dist/node/chunks/config.js b/dist/node/chunks/config.js
|
||||
index 9a45043275249a25101fd7991ffcfc316478840e..5435b17de9ca65b64a8abacb609b75587226b106 100644
|
||||
--- a/dist/node/chunks/config.js
|
||||
+++ b/dist/node/chunks/config.js
|
||||
@@ -27667,7 +27667,8 @@ async function getWorkerType(raw, clean, i$1) {
|
||||
@@ -27,7 +27,7 @@ function extractRegExp(name) {
|
||||
}
|
||||
|
||||
test('pins the Vite version carrying the local transform-filter backport', () => {
|
||||
assert.equal(vitePackage.version, '7.3.5');
|
||||
assert.equal(vitePackage.version, '7.3.6');
|
||||
});
|
||||
|
||||
test('uses bounded Vite prefilters with precise handler matchers', () => {
|
||||
|
||||