feat(settings): per-section pages with unsaved-changes bar (#1384)

* feat(settings): split settings into per-section pages with an unsaved-changes bar

Replace the single scrolling settings page with routed section pages
(/workspace/settings/:section): the context-panel rail links each section,
only the active section renders, and unknown or capability-gated sections
redirect to General. The shared form lives on the parent component, so
staged edits survive section switches; a floating unsaved-changes bar
(Save/Discard) replaces the always-visible footer Save button. Rail links
navigate with replaceUrl so Back still leaves settings in one step.

Along the way:
- delete the unreachable settings dialog mode and the dead
  AppPortalNavigationActionsService with both of its never-injected DI
  tokens (PORTAL_NAVIGATION_ACTIONS, PLAYLIST_PLAYER_ACTIONS)
- delete the scroll-spy directive and pendingScrollTarget plumbing
- revive the EPG panel's "Open EPG settings" empty-state button as a deep
  link to /workspace/settings/epg; the M3U player now reports
  m3u-needs-setup only when the channel has no programmes and no EPG
  source exists in settings or on the playlist itself
- load TMDB cache stats when the Metadata page opens (the section
  component now only exists while its page is open)
- add SETTINGS.UNSAVED_CHANGES / SETTINGS.DISCARD_CHANGES to all 19 locales

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(settings): confirm before leaving with unsaved changes

Add settingsUnsavedChangesGuard (canDeactivate on the :section route) with
a three-action dialog: save and leave, leave without saving, keep editing.
The guard only intercepts leaving the settings AREA — section switches
share the one settings form and pass unconditionally, so the dialog can
never nag while moving between pages. A failed save cancels the navigation
instead of silently dropping the edits it promised to keep; leaving
without saving also reverts the live theme preview. Save-and-leave is
disabled while the form is invalid, with a hint explaining why.

New SETTINGS.UNSAVED_DIALOG_* keys in all 19 locales.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(settings): stage cover size and EPG view mode; adapt e2e to section pages

Cover size and EPG view mode were the only two controls that persisted
eagerly on click, which made Discard (and leave-without-saving) unable to
revert them: hydrateFromStore() faithfully reloaded the just-persisted
edit. They now stage in the form like every other setting and reach the
store on Save. Review finding by Greptile (P1) and Codex.

E2E suites that walk through settings are updated for one-section-page
rendering (epg, backup-roundtrip, xtream-epg, remote-control) and for the
staged cover size (downloads asserts the dataset after Save); the EPG icon
fallback test saves before leaving settings so the new unsaved-changes
dialog does not block its navigation.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Fable 5 authored and GitHub committed 2026-08-09 09:34:03 +02:00
1 parent c95f826739
commit 1a6af75761
89 files changed
+1331 -952

No files matched your search

+145 -57
View File
@@ -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.