) {
+ const navigate = useNavigate();
+ const location = useLocation();
+ const [searchParams, setSearchParams] = useSearchParams();
+ const { params: savedParams, save, clear } = useSavedViewParams();
+
+ /**
+ * Save the params of the view we are leaving and resolve the destination,
+ * restoring any params previously saved for the target view.
+ */
+ const resolveDestination = () => {
+ const currentView = location.pathname.replace(/^\//, '');
+ save(currentView, stripReservedParams(location.search));
+ const restored = savedParams[to];
+ return `${to}${restored ? `?${restored}` : ''}`;
+ };
+
+ const isCustomised = current ? hasCustomParams(searchParams) : Boolean(savedParams[to]);
+
+ const navigateToLink = () => {
+ const destination = resolveDestination();
+ navigate(`/${destination}`);
+ postAction?.();
+ };
+
+ /**
+ * Clear the saved settings for this route without navigating to it.
+ * When it is the current view, also reset the live URL to its defaults.
+ */
+ const clearViewSettings = (event: MouseEvent) => {
+ event.stopPropagation();
+ clear(to);
+
+ // if mounted, clear the URL params
+ if (current) {
+ const preserved = new URLSearchParams();
+ reservedParams.forEach((key) => {
+ const value = searchParams.get(key);
+ if (value !== null) preserved.set(key, value);
+ });
+ setSearchParams(preserved);
+ }
+ };
+
+ return (
+
+ {children}
+ {isCustomised && (
+
+
+
+
+
+
+ )}
);
}
diff --git a/apps/client/src/common/components/navigation-menu/floating-navigation/FloatingNavigation.module.scss b/apps/client/src/common/components/navigation-menu/floating-navigation/FloatingNavigation.module.scss
index 21197cdaf..271951d47 100644
--- a/apps/client/src/common/components/navigation-menu/floating-navigation/FloatingNavigation.module.scss
+++ b/apps/client/src/common/components/navigation-menu/floating-navigation/FloatingNavigation.module.scss
@@ -22,3 +22,19 @@
top: 0;
z-index: $zindex-nav;
}
+
+.buttonWithIndicator {
+ position: relative;
+ display: inline-flex;
+}
+
+.indicator {
+ position: absolute;
+ top: -0.15rem;
+ right: -0.15rem;
+ width: 0.6rem;
+ height: 0.6rem;
+ border-radius: 50%;
+ background-color: $active-indicator;
+ pointer-events: none;
+}
diff --git a/apps/client/src/common/components/navigation-menu/floating-navigation/FloatingNavigation.tsx b/apps/client/src/common/components/navigation-menu/floating-navigation/FloatingNavigation.tsx
index 0efcea798..1a9620fdc 100644
--- a/apps/client/src/common/components/navigation-menu/floating-navigation/FloatingNavigation.tsx
+++ b/apps/client/src/common/components/navigation-menu/floating-navigation/FloatingNavigation.tsx
@@ -9,9 +9,10 @@ import style from './FloatingNavigation.module.scss';
interface FloatingNavigationProps {
toggleMenu?: () => void;
toggleSettings?: () => void;
+ hasSavedChanges?: boolean;
}
-export default function FloatingNavigation({ toggleMenu, toggleSettings }: FloatingNavigationProps) {
+export default function FloatingNavigation({ toggleMenu, toggleSettings, hasSavedChanges }: FloatingNavigationProps) {
const isButtonShown = useFadeOutOnInactivity(true);
return (
@@ -20,15 +21,18 @@ export default function FloatingNavigation({ toggleMenu, toggleSettings }: Float
className={cx([style.fadeable, style.buttonContainer, !isButtonShown && style.hidden])}
>
{toggleMenu && (
-
-
-
+
+
+
+
+ {hasSavedChanges && }
+
)}
{toggleSettings && (
{
+ beforeEach(() => {
+ useSavedViewParams.getState().clearAll();
+ });
+
+ it('saves and restores params per view', () => {
+ useSavedViewParams.getState().save('timer', 'hideSeconds=true');
+ useSavedViewParams.getState().save('backstage', 'showProgress=false');
+
+ expect(useSavedViewParams.getState().params.timer).toBe('hideSeconds=true');
+ expect(useSavedViewParams.getState().params.backstage).toBe('showProgress=false');
+ });
+
+ it('overwrites the saved params for a view on subsequent saves', () => {
+ useSavedViewParams.getState().save('timer', 'hideSeconds=true');
+ useSavedViewParams.getState().save('timer', 'hideSeconds=false');
+
+ expect(useSavedViewParams.getState().params.timer).toBe('hideSeconds=false');
+ });
+
+ it('does not store an entry for an empty search string', () => {
+ useSavedViewParams.getState().save('timer', '');
+
+ expect(useSavedViewParams.getState().params).toEqual({});
+ });
+
+ it('removes a previously saved entry when saved with an empty search string', () => {
+ useSavedViewParams.getState().save('timer', 'hideSeconds=true');
+ useSavedViewParams.getState().save('timer', '');
+
+ expect(useSavedViewParams.getState().params).toEqual({});
+ });
+
+ it('ignores empty view keys', () => {
+ useSavedViewParams.getState().save('', 'hideSeconds=true');
+
+ expect(useSavedViewParams.getState().params).toEqual({});
+ });
+
+ it('clears the saved params for a single view without touching others', () => {
+ useSavedViewParams.getState().save('timer', 'hideSeconds=true');
+ useSavedViewParams.getState().save('backstage', 'showProgress=false');
+ useSavedViewParams.getState().clear('timer');
+
+ expect(useSavedViewParams.getState().params).toEqual({ backstage: 'showProgress=false' });
+ });
+
+ it('is a no-op when clearing a view without saved params', () => {
+ useSavedViewParams.getState().save('backstage', 'showProgress=false');
+ useSavedViewParams.getState().clear('timer');
+
+ expect(useSavedViewParams.getState().params).toEqual({ backstage: 'showProgress=false' });
+ });
+
+ it('clears all saved params', () => {
+ useSavedViewParams.getState().save('timer', 'hideSeconds=true');
+ useSavedViewParams.getState().clearAll();
+
+ expect(useSavedViewParams.getState().params).toEqual({});
+ });
+});
+
+describe('stripReservedParams', () => {
+ it('removes reserved auth/preset params while keeping view customisation', () => {
+ expect(stripReservedParams('hideSeconds=true&token=abc&n=1&alias=my')).toBe('hideSeconds=true');
+ });
+
+ it('returns an empty string when only reserved params are present', () => {
+ expect(stripReservedParams('token=abc&n=1&alias=my')).toBe('');
+ });
+});
+
+describe('hasCustomParams', () => {
+ it('is true when a non-reserved param is present', () => {
+ expect(hasCustomParams(new URLSearchParams('hideSeconds=true&token=abc'))).toBe(true);
+ });
+
+ it('is false when only reserved params are present', () => {
+ expect(hasCustomParams(new URLSearchParams('token=abc&n=1&alias=my'))).toBe(false);
+ });
+
+ it('is false when there are no params', () => {
+ expect(hasCustomParams(new URLSearchParams(''))).toBe(false);
+ });
+});
diff --git a/apps/client/src/common/stores/savedViewParams.ts b/apps/client/src/common/stores/savedViewParams.ts
new file mode 100644
index 000000000..0c4e2597c
--- /dev/null
+++ b/apps/client/src/common/stores/savedViewParams.ts
@@ -0,0 +1,62 @@
+import { create } from 'zustand';
+
+// params that are auth/preset markers, not user view customisation (see common/utils/urlPresets.ts)
+export const reservedParams = new Set(['token', 'n', 'alias']);
+
+interface SavedViewParamsStore {
+ params: Record; // view key (e.g. "timer") -> search string without leading "?"
+ save: (view: string, search: string) => void;
+ clear: (view: string) => void;
+ clearAll: () => void;
+}
+
+/**
+ * Remembers the last view parameters used for each view so they can be
+ * restored when the user navigates back to that view through the menu.
+ * In-memory only: persists across SPA navigation, resets on a full page reload.
+ */
+export const useSavedViewParams = create((set) => ({
+ params: {},
+ save: (view, search) =>
+ set((state) => {
+ // ignore empty view keys and do not store empty entries, so the
+ // "saved changes" indicator only reflects genuine customisation
+ if (!view) return state;
+ const params = { ...state.params };
+ if (search) {
+ params[view] = search;
+ } else {
+ delete params[view];
+ }
+ return { params };
+ }),
+ clear: (view) =>
+ set((state) => {
+ if (!state.params[view]) return state;
+ const params = { ...state.params };
+ delete params[view];
+ return { params };
+ }),
+ clearAll: () => set({ params: {} }),
+}));
+
+/**
+ * Removes reserved (auth/preset) params from a search string, keeping only
+ * genuine view customisation.
+ */
+export function stripReservedParams(search: string): string {
+ const sp = new URLSearchParams(search);
+ reservedParams.forEach((key) => sp.delete(key));
+ return sp.toString();
+}
+
+/**
+ * Whether the search params contain any genuine view customisation,
+ * ignoring reserved params.
+ */
+export function hasCustomParams(searchParams: URLSearchParams): boolean {
+ for (const key of searchParams.keys()) {
+ if (!reservedParams.has(key)) return true;
+ }
+ return false;
+}
diff --git a/apps/client/src/common/utils/linkUtils.ts b/apps/client/src/common/utils/linkUtils.ts
index dcf2d8077..f81bd0db7 100644
--- a/apps/client/src/common/utils/linkUtils.ts
+++ b/apps/client/src/common/utils/linkUtils.ts
@@ -37,7 +37,12 @@ export function handleLinks(
event?.preventDefault();
const destination = new URL(externalServerUrl);
- destination.pathname = externalBaseURI ? `${externalBaseURI}/${location}` : location;
+ // split off any query string so it is not encoded into the pathname
+ const [pathname, search] = location.split('?');
+ destination.pathname = externalBaseURI ? `${externalBaseURI}/${pathname}` : pathname;
+ if (search) {
+ destination.search = search;
+ }
openLink(destination.toString());
}