From f39892dc6e89d5686684d96448d1a45c406efd36 Mon Sep 17 00:00:00 2001 From: Carlos Valente <34649812+cpvalente@users.noreply.github.com> Date: Fri, 10 Jul 2026 17:32:52 +0000 Subject: [PATCH] feat(navigation): persist view params customisation --- .../navigation-menu/ViewNavigationMenu.tsx | 6 + .../client-link/ClientLink.module.scss | 27 +++- .../client-link/ClientLink.tsx | 115 +++++++++++++++--- .../FloatingNavigation.module.scss | 16 +++ .../FloatingNavigation.tsx | 24 ++-- .../stores/__tests__/savedViewParams.test.ts | 87 +++++++++++++ .../src/common/stores/savedViewParams.ts | 62 ++++++++++ apps/client/src/common/utils/linkUtils.ts | 7 +- 8 files changed, 312 insertions(+), 32 deletions(-) create mode 100644 apps/client/src/common/stores/__tests__/savedViewParams.test.ts create mode 100644 apps/client/src/common/stores/savedViewParams.ts diff --git a/apps/client/src/common/components/navigation-menu/ViewNavigationMenu.tsx b/apps/client/src/common/components/navigation-menu/ViewNavigationMenu.tsx index fc233b2b6..72a0af77a 100644 --- a/apps/client/src/common/components/navigation-menu/ViewNavigationMenu.tsx +++ b/apps/client/src/common/components/navigation-menu/ViewNavigationMenu.tsx @@ -1,6 +1,8 @@ import { useDisclosure, useHotkeys } from '@mantine/hooks'; import { memo } from 'react'; +import { useSearchParams } from 'react-router'; +import { hasCustomParams, useSavedViewParams } from '../../stores/savedViewParams'; import { useViewParamsEditorStore } from '../view-params-editor/viewParamsEditor.store'; import FloatingNavigation from './floating-navigation/FloatingNavigation'; import NavigationMenu from './NavigationMenu'; @@ -17,6 +19,9 @@ export default memo(ViewNavigationMenu); function ViewNavigationMenu({ isNavigationLocked, suppressSettings }: ViewNavigationMenuProps) { const [isMenuOpen, menuHandler] = useDisclosure(); const { open: showEditFormDrawer } = useViewParamsEditorStore(); + const [searchParams] = useSearchParams(); + const savedParams = useSavedViewParams((store) => store.params); + const hasSavedChanges = hasCustomParams(searchParams) || Object.keys(savedParams).length > 0; useHotkeys([ [ @@ -46,6 +51,7 @@ function ViewNavigationMenu({ isNavigationLocked, suppressSettings }: ViewNaviga {!isNavigationLocked && } diff --git a/apps/client/src/common/components/navigation-menu/client-link/ClientLink.module.scss b/apps/client/src/common/components/navigation-menu/client-link/ClientLink.module.scss index 431a04308..891f273d3 100644 --- a/apps/client/src/common/components/navigation-menu/client-link/ClientLink.module.scss +++ b/apps/client/src/common/components/navigation-menu/client-link/ClientLink.module.scss @@ -1,4 +1,29 @@ -.linkIcon { +.trailing { + display: inline-flex; + align-items: center; + gap: 0.5rem; margin-left: auto; +} + +.linkIcon { @include rotate-fourty-five; } + +.indicator { + width: 0.5rem; + height: 0.5rem; + border-radius: 50%; + background-color: $active-indicator; + pointer-events: none; + flex-shrink: 0; +} + +.clear { + opacity: 0.6; + transition: opacity $transition-time-action; + + &:hover, + &:focus-visible { + opacity: 1; + } +} diff --git a/apps/client/src/common/components/navigation-menu/client-link/ClientLink.tsx b/apps/client/src/common/components/navigation-menu/client-link/ClientLink.tsx index dec495067..f0f200a9d 100644 --- a/apps/client/src/common/components/navigation-menu/client-link/ClientLink.tsx +++ b/apps/client/src/common/components/navigation-menu/client-link/ClientLink.tsx @@ -1,9 +1,16 @@ -import { PropsWithChildren } from 'react'; -import { IoArrowUp } from 'react-icons/io5'; -import { useNavigate } from 'react-router'; +import { MouseEvent, PropsWithChildren } from 'react'; +import { IoArrowUp, IoCloseOutline } from 'react-icons/io5'; +import { useLocation, useNavigate, useSearchParams } from 'react-router'; import { useElectronEvent } from '../../../hooks/useElectronEvent'; +import { + hasCustomParams, + reservedParams, + stripReservedParams, + useSavedViewParams, +} from '../../../stores/savedViewParams'; import { handleLinks } from '../../../utils/linkUtils'; +import IconButton from '../../buttons/IconButton'; import NavigationMenuItem from '../navigation-menu-item/NavigationMenuItem'; import style from './ClientLink.module.scss'; @@ -16,32 +23,100 @@ interface ClientLinkProps { export default function ClientLink({ current, to, postAction, children }: PropsWithChildren) { const { isElectron } = useElectronEvent(); - const navigate = useNavigate(); if (isElectron) { return ( - { - handleLinks(to); - postAction?.(); - }} - > + {children} - - + ); } return ( - { - navigate(`/${to}`); - postAction?.(); - }} - > + {children} + + ); +} + +function ElectronNavigationItem({ current, to, postAction, children }: PropsWithChildren) { + const navigateToLink = () => { + handleLinks(to); + postAction?.(); + }; + + return ( + + {children} + + + + + ); +} + +function BrowserNavigationItem({ current, to, postAction, children }: PropsWithChildren) { + 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()); }