From 767b567bbae820d2f74d537553af8310d100e175 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 28 May 2026 06:51:24 +0000 Subject: [PATCH] Improve editor settings navigation, UX, and visual design MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Unsaved changes guard: confirm dialog when navigating away from dirty forms (GeneralSettings, ProjectData, ViewSettings, ServerPortSettings, AutomationSettingsForm) - Sticky breadcrumb header: replaces floating close button with a sticky top bar showing current panel › section context - Scroll-to-sidebar sync: IntersectionObserver in useScrollIntoView feeds active scroll section back into sidebar secondary item highlights - Rich empty states: ClientList now shows friendly messages for empty Ontime/Other client tables - Sidebar styling: distinct background, right border, left-accent active indicator (VS Code style) - Card elevation: raised opacity + box-shadow for visible depth hierarchy - Border radius: 8px cards and 6px sidebar items using existing theme variable https://claude.ai/code/session_017jeJF78B4eXcpXAj7KGmnF --- .../src/common/hooks/useScrollIntoView.tsx | 18 ++++++++++- .../app-settings/AppSettings.module.scss | 2 +- .../src/features/app-settings/AppSettings.tsx | 6 +++- .../app-settings/AppSettingsScrollContext.tsx | 31 +++++++++++++++++++ .../app-settings/appSettingsDirtyState.ts | 11 +++++++ .../panel-content/PanelContent.module.scss | 22 ++++++++++--- .../panel-content/PanelContent.tsx | 15 +++++++-- .../panel-list/PanelList.module.scss | 11 +++++-- .../app-settings/panel-list/PanelList.tsx | 22 ++++++++++--- .../panel-utils/PanelUtils.module.scss | 5 +-- .../automations-panel/AutomationPanel.tsx | 8 +++-- .../AutomationSettingsForm.tsx | 2 ++ .../panel/feature-panel/FeaturePanel.tsx | 8 +++-- .../panel/manage-panel/ManagePanel.tsx | 10 +++--- .../panel/network-panel/NetworkLogPanel.tsx | 6 ++-- .../client-control/ClientList.tsx | 2 ++ .../panel/project-panel/ProjectPanel.tsx | 4 ++- .../panel/settings-panel/GeneralSettings.tsx | 2 ++ .../panel/settings-panel/ProjectData.tsx | 2 ++ .../settings-panel/ServerPortSettings.tsx | 2 ++ .../panel/settings-panel/SettingsPanel.tsx | 12 ++++--- .../panel/settings-panel/ViewSettings.tsx | 2 ++ .../app-settings/useAppSettingsMenu.tsx | 9 ++++++ .../features/app-settings/useSettingsDirty.ts | 14 +++++++++ 24 files changed, 188 insertions(+), 38 deletions(-) create mode 100644 apps/client/src/features/app-settings/AppSettingsScrollContext.tsx create mode 100644 apps/client/src/features/app-settings/appSettingsDirtyState.ts create mode 100644 apps/client/src/features/app-settings/useSettingsDirty.ts diff --git a/apps/client/src/common/hooks/useScrollIntoView.tsx b/apps/client/src/common/hooks/useScrollIntoView.tsx index 1bbcdc26f..2236e1d19 100644 --- a/apps/client/src/common/hooks/useScrollIntoView.tsx +++ b/apps/client/src/common/hooks/useScrollIntoView.tsx @@ -1,6 +1,10 @@ import { useEffect, useRef } from 'react'; -export default function useScrollIntoView(name: string, location?: string) { +export default function useScrollIntoView( + name: string, + location?: string, + onVisible?: (name: string) => void, +) { const ref = useRef(null); useEffect(() => { @@ -11,5 +15,17 @@ export default function useScrollIntoView(name: string, l } }, [location, name]); + useEffect(() => { + if (!ref.current || !onVisible) return; + const observer = new IntersectionObserver( + ([entry]) => { + if (entry.isIntersecting) onVisible(name); + }, + { threshold: 0.3, rootMargin: '0px 0px -40% 0px' }, + ); + observer.observe(ref.current); + return () => observer.disconnect(); + }, [name, onVisible]); + return ref; } diff --git a/apps/client/src/features/app-settings/AppSettings.module.scss b/apps/client/src/features/app-settings/AppSettings.module.scss index 5fb17cc17..ce9575bf9 100644 --- a/apps/client/src/features/app-settings/AppSettings.module.scss +++ b/apps/client/src/features/app-settings/AppSettings.module.scss @@ -4,7 +4,7 @@ width: 100%; padding: 1rem; display: flex; - gap: 0.25rem; + gap: 0; overflow: hidden; background-color: $ui-black; diff --git a/apps/client/src/features/app-settings/AppSettings.tsx b/apps/client/src/features/app-settings/AppSettings.tsx index b3da5d21c..e90b8075e 100644 --- a/apps/client/src/features/app-settings/AppSettings.tsx +++ b/apps/client/src/features/app-settings/AppSettings.tsx @@ -1,6 +1,7 @@ import { ErrorBoundary } from '@sentry/react'; import { useKeyDown } from '../../common/hooks/useKeyDown'; +import { AppSettingsScrollContext, useScrollContextState } from './AppSettingsScrollContext'; import PanelContent from './panel-content/PanelContent'; import PanelList from './panel-list/PanelList'; import AboutPanel from './panel/about-panel/AboutPanel'; @@ -17,13 +18,15 @@ import style from './AppSettings.module.scss'; export default function AppSettings() { const { close, panel, location, setLocation } = useAppSettingsNavigation(); + const scrollContext = useScrollContextState(panel); useKeyDown(close, 'Escape'); return (
+ - + {panel === 'settings' && } {panel === 'project' && } {panel === 'manage' && } @@ -33,6 +36,7 @@ export default function AppSettings() { {panel === 'about' && } {panel === 'shutdown' && } +
); diff --git a/apps/client/src/features/app-settings/AppSettingsScrollContext.tsx b/apps/client/src/features/app-settings/AppSettingsScrollContext.tsx new file mode 100644 index 000000000..76fcac9c5 --- /dev/null +++ b/apps/client/src/features/app-settings/AppSettingsScrollContext.tsx @@ -0,0 +1,31 @@ +import { createContext, useCallback, useContext, useState } from 'react'; + +interface ScrollContextValue { + activeSection: string | undefined; + setActiveSection: (name: string) => void; +} + +export const AppSettingsScrollContext = createContext({ + activeSection: undefined, + setActiveSection: () => {}, +}); + +export function useAppSettingsScroll() { + return useContext(AppSettingsScrollContext); +} + +export function useScrollContextState(panel: string) { + const [activeSection, setActiveSectionRaw] = useState(undefined); + const [trackedPanel, setTrackedPanel] = useState(panel); + + if (panel !== trackedPanel) { + setTrackedPanel(panel); + setActiveSectionRaw(undefined); + } + + const setActiveSection = useCallback((name: string) => { + setActiveSectionRaw(name); + }, []); + + return { activeSection, setActiveSection }; +} diff --git a/apps/client/src/features/app-settings/appSettingsDirtyState.ts b/apps/client/src/features/app-settings/appSettingsDirtyState.ts new file mode 100644 index 000000000..c1f2d4d0d --- /dev/null +++ b/apps/client/src/features/app-settings/appSettingsDirtyState.ts @@ -0,0 +1,11 @@ +let _isDirty = false; + +export const markDirty = () => { + _isDirty = true; +}; + +export const markClean = () => { + _isDirty = false; +}; + +export const getIsDirty = () => _isDirty; diff --git a/apps/client/src/features/app-settings/panel-content/PanelContent.module.scss b/apps/client/src/features/app-settings/panel-content/PanelContent.module.scss index 265409b70..c0e68f664 100644 --- a/apps/client/src/features/app-settings/panel-content/PanelContent.module.scss +++ b/apps/client/src/features/app-settings/panel-content/PanelContent.module.scss @@ -1,8 +1,19 @@ -.corner { - position: fixed; - top: 6rem; - right: 4rem; - z-index: $zindex-floating; +.stickyHeader { + position: sticky; + top: 0; + z-index: $zindex-nav; + display: flex; + align-items: center; + justify-content: space-between; + padding: 0.5rem 1rem; + background-color: $gray-1250; + border-bottom: 1px solid $white-10; + flex-shrink: 0; +} + +.breadcrumb { + font-size: calc(1rem - 1px); + color: $gray-400; } .contentWrapper { @@ -12,6 +23,7 @@ flex: 1 1 auto; min-width: 0; position: relative; + overflow: hidden; } .content { diff --git a/apps/client/src/features/app-settings/panel-content/PanelContent.tsx b/apps/client/src/features/app-settings/panel-content/PanelContent.tsx index 42d028d9c..288dd3692 100644 --- a/apps/client/src/features/app-settings/panel-content/PanelContent.tsx +++ b/apps/client/src/features/app-settings/panel-content/PanelContent.tsx @@ -2,22 +2,31 @@ import { PropsWithChildren } from 'react'; import { IoClose } from 'react-icons/io5'; import Button from '../../../common/components/buttons/Button'; +import { getPanelLabel } from '../useAppSettingsMenu'; import style from './PanelContent.module.scss'; interface PanelContentProps { onClose: () => void; + panel: string; + location?: string; } -export default function PanelContent({ onClose, children }: PropsWithChildren) { +export default function PanelContent({ onClose, panel, location, children }: PropsWithChildren) { + const { panelLabel, sectionLabel } = getPanelLabel(panel, location); + return (
-
{children}
-
+
+ + {panelLabel} + {sectionLabel ? <> › {sectionLabel} : null} +
+
{children}
); } diff --git a/apps/client/src/features/app-settings/panel-list/PanelList.module.scss b/apps/client/src/features/app-settings/panel-list/PanelList.module.scss index 6326a1576..6b52e2aa8 100644 --- a/apps/client/src/features/app-settings/panel-list/PanelList.module.scss +++ b/apps/client/src/features/app-settings/panel-list/PanelList.module.scss @@ -12,6 +12,9 @@ ul { display: flex; flex-direction: column; overflow-y: auto; + background-color: $gray-1250; + border-right: 1px solid $white-10; + padding: 0.5rem 0; } .primary, @@ -32,14 +35,16 @@ ul { .primary { font-size: 1rem; - border-radius: 2px; + border-radius: 6px; display: flex; align-items: center; gap: 0.5rem; &.active { color: $blue-400; - background-color: $gray-1100; + border-left: 3px solid $blue-400; + padding-left: calc(1rem - 3px); + border-radius: 0 6px 6px 0; } &.highlight { @@ -63,5 +68,7 @@ ul { &.active { color: $blue-400; + border-left-color: $blue-400; + border-left-width: 2px; } } diff --git a/apps/client/src/features/app-settings/panel-list/PanelList.tsx b/apps/client/src/features/app-settings/panel-list/PanelList.tsx index 01dc977c0..5d35f2a59 100644 --- a/apps/client/src/features/app-settings/panel-list/PanelList.tsx +++ b/apps/client/src/features/app-settings/panel-list/PanelList.tsx @@ -3,6 +3,8 @@ import { Fragment } from 'react'; import Tooltip from '../../../common/components/tooltip/Tooltip'; import { isKeyEnter } from '../../../common/utils/keyEvent'; import { cx } from '../../../common/utils/styleUtils'; +import { getIsDirty, markClean } from '../appSettingsDirtyState'; +import { useAppSettingsScroll } from '../AppSettingsScrollContext'; import { SettingsOption, SettingsOptionId, useAppSettingsMenu } from '../useAppSettingsMenu'; import useAppSettingsNavigation from '../useAppSettingsNavigation'; @@ -42,18 +44,27 @@ interface PanelListItemProps { location?: string; } +function navigateWithGuard(navigate: (id: SettingsOptionId) => void, id: SettingsOptionId) { + if (getIsDirty()) { + if (!window.confirm('You have unsaved changes. Leave without saving?')) return; + markClean(); + } + navigate(id); +} + function PanelListItem({ panel, isSelected, location }: PanelListItemProps) { const { setLocation } = useAppSettingsNavigation(); + const { activeSection } = useAppSettingsScroll(); const classes = cx([style.primary, isSelected && style.active, panel.highlight && style.highlight]); return (
  • setLocation(panel.id as SettingsOptionId)} + onClick={() => navigateWithGuard(setLocation, panel.id as SettingsOptionId)} onKeyDown={(event) => { if (isKeyEnter(event)) { - setLocation(panel.id as SettingsOptionId); + navigateWithGuard(setLocation, panel.id as SettingsOptionId); } }} className={classes} @@ -64,14 +75,15 @@ function PanelListItem({ panel, isSelected, location }: PanelListItemProps) {
  • {panel.secondary?.map((secondary, index) => { const id = secondary.id.split('__')[1]; - const secondaryClasses = cx([style.secondary, isSelected && location === id ? style.active : null]); + const effectiveSection = isSelected ? (activeSection ?? location) : undefined; + const secondaryClasses = cx([style.secondary, effectiveSection === id ? style.active : null]); return (
  • setLocation(secondary.id as SettingsOptionId)} + onClick={() => navigateWithGuard(setLocation, secondary.id as SettingsOptionId)} onKeyDown={(event) => { if (isKeyEnter(event)) { - setLocation(secondary.id as SettingsOptionId); + navigateWithGuard(setLocation, secondary.id as SettingsOptionId); } }} className={secondaryClasses} diff --git a/apps/client/src/features/app-settings/panel-utils/PanelUtils.module.scss b/apps/client/src/features/app-settings/panel-utils/PanelUtils.module.scss index fbb3dda96..e2195211c 100644 --- a/apps/client/src/features/app-settings/panel-utils/PanelUtils.module.scss +++ b/apps/client/src/features/app-settings/panel-utils/PanelUtils.module.scss @@ -53,9 +53,10 @@ $inner-padding: 1rem; .card { position: relative; padding: 2rem; - background-color: $white-3; + background-color: $white-5; border: 1px solid $gray-1100; - border-radius: 3px; + border-radius: 8px; + box-shadow: $box-shadow-l1; } .highlight { diff --git a/apps/client/src/features/app-settings/panel/automations-panel/AutomationPanel.tsx b/apps/client/src/features/app-settings/panel/automations-panel/AutomationPanel.tsx index be25f9fb3..8dd19ffd5 100644 --- a/apps/client/src/features/app-settings/panel/automations-panel/AutomationPanel.tsx +++ b/apps/client/src/features/app-settings/panel/automations-panel/AutomationPanel.tsx @@ -1,5 +1,6 @@ import useAutomationSettings from '../../../../common/hooks-query/useAutomationSettings'; import useScrollIntoView from '../../../../common/hooks/useScrollIntoView'; +import { useAppSettingsScroll } from '../../AppSettingsScrollContext'; import type { PanelBaseProps } from '../../panel-list/PanelList'; import * as Panel from '../../panel-utils/PanelUtils'; import AutomationSettingsForm from './AutomationSettingsForm'; @@ -8,9 +9,10 @@ import TriggersList from './TriggersList'; export default function AutomationPanel({ location }: PanelBaseProps) { const { data, status } = useAutomationSettings(); - const settingsRef = useScrollIntoView('settings', location); - const triggersRef = useScrollIntoView('triggers', location); - const automationsRef = useScrollIntoView('automations', location); + const { setActiveSection } = useAppSettingsScroll(); + const settingsRef = useScrollIntoView('settings', location, setActiveSection); + const triggersRef = useScrollIntoView('triggers', location, setActiveSection); + const automationsRef = useScrollIntoView('automations', location, setActiveSection); const isLoading = status === 'pending'; const automationState = isLoading ? undefined : data.enabledAutomations; diff --git a/apps/client/src/features/app-settings/panel/automations-panel/AutomationSettingsForm.tsx b/apps/client/src/features/app-settings/panel/automations-panel/AutomationSettingsForm.tsx index ca745c128..31a07e03d 100644 --- a/apps/client/src/features/app-settings/panel/automations-panel/AutomationSettingsForm.tsx +++ b/apps/client/src/features/app-settings/panel/automations-panel/AutomationSettingsForm.tsx @@ -12,6 +12,7 @@ import { preventEscape } from '../../../../common/utils/keyEvent'; import { isOnlyNumbers } from '../../../../common/utils/regex'; import { isOntimeCloud } from '../../../../externals'; import * as Panel from '../../panel-utils/PanelUtils'; +import { useSettingsDirty } from '../../useSettingsDirty'; const oscApiDocsUrl = 'https://docs.getontime.no/api/protocols/osc/'; @@ -45,6 +46,7 @@ export default function AutomationSettingsForm({ keepDirtyValues: false, }, }); + useSettingsDirty(isDirty); const onSubmit = async (formData: AutomationSettingsProps) => { try { diff --git a/apps/client/src/features/app-settings/panel/feature-panel/FeaturePanel.tsx b/apps/client/src/features/app-settings/panel/feature-panel/FeaturePanel.tsx index f88024a3e..8a5c13a0c 100644 --- a/apps/client/src/features/app-settings/panel/feature-panel/FeaturePanel.tsx +++ b/apps/client/src/features/app-settings/panel/feature-panel/FeaturePanel.tsx @@ -1,5 +1,6 @@ import useScrollIntoView from '../../../../common/hooks/useScrollIntoView'; import { isOntimeCloud } from '../../../../externals'; +import { useAppSettingsScroll } from '../../AppSettingsScrollContext'; import GenerateLinkFormExport from '../../../sharing/GenerateLinkFormExport'; import type { PanelBaseProps } from '../../panel-list/PanelList'; import * as Panel from '../../panel-utils/PanelUtils'; @@ -8,9 +9,10 @@ import ReportSettings from './ReportSettings'; import URLPresets from './URLPresets'; export default function FeaturePanel({ location }: PanelBaseProps) { - const presetsRef = useScrollIntoView('presets', location); - const linkRef = useScrollIntoView('link', location); - const reportRef = useScrollIntoView('report', location); + const { setActiveSection } = useAppSettingsScroll(); + const presetsRef = useScrollIntoView('presets', location, setActiveSection); + const linkRef = useScrollIntoView('link', location, setActiveSection); + const reportRef = useScrollIntoView('report', location, setActiveSection); return ( <> diff --git a/apps/client/src/features/app-settings/panel/manage-panel/ManagePanel.tsx b/apps/client/src/features/app-settings/panel/manage-panel/ManagePanel.tsx index 1f4cadc80..905b87535 100644 --- a/apps/client/src/features/app-settings/panel/manage-panel/ManagePanel.tsx +++ b/apps/client/src/features/app-settings/panel/manage-panel/ManagePanel.tsx @@ -1,4 +1,5 @@ import useScrollIntoView from '../../../../common/hooks/useScrollIntoView'; +import { useAppSettingsScroll } from '../../AppSettingsScrollContext'; import type { PanelBaseProps } from '../../panel-list/PanelList'; import * as Panel from '../../panel-utils/PanelUtils'; import CustomFieldSettings from './CustomFields'; @@ -7,10 +8,11 @@ import RundownDefaultSettings from './RundownDefaultSettings'; import SourcesPanel from './sources-panel/SourcesPanel'; export default function ManagePanel({ location }: PanelBaseProps) { - const defaultsRef = useScrollIntoView('defaults', location); - const customRef = useScrollIntoView('custom', location); - const rundownsRef = useScrollIntoView('rundowns', location); - const sheetsRef = useScrollIntoView('sheets', location); + const { setActiveSection } = useAppSettingsScroll(); + const defaultsRef = useScrollIntoView('defaults', location, setActiveSection); + const customRef = useScrollIntoView('custom', location, setActiveSection); + const rundownsRef = useScrollIntoView('rundowns', location, setActiveSection); + const sheetsRef = useScrollIntoView('sheets', location, setActiveSection); return ( <> diff --git a/apps/client/src/features/app-settings/panel/network-panel/NetworkLogPanel.tsx b/apps/client/src/features/app-settings/panel/network-panel/NetworkLogPanel.tsx index 52c648b7f..89f247087 100644 --- a/apps/client/src/features/app-settings/panel/network-panel/NetworkLogPanel.tsx +++ b/apps/client/src/features/app-settings/panel/network-panel/NetworkLogPanel.tsx @@ -5,14 +5,16 @@ import useScrollIntoView from '../../../../common/hooks/useScrollIntoView'; import { usePing } from '../../../../common/hooks/useSocket'; import { sendSocket } from '../../../../common/utils/socket'; import { isDocker } from '../../../../externals'; +import { useAppSettingsScroll } from '../../AppSettingsScrollContext'; import type { PanelBaseProps } from '../../panel-list/PanelList'; import * as Panel from '../../panel-utils/PanelUtils'; import ClientControlPanel from './client-control/ClientControlPanel'; import LogExport from './NetworkLogExport'; export default function NetworkLogPanel({ location }: PanelBaseProps) { - const clientsRef = useScrollIntoView('clients', location); - const logRef = useScrollIntoView('log', location); + const { setActiveSection } = useAppSettingsScroll(); + const clientsRef = useScrollIntoView('clients', location, setActiveSection); + const logRef = useScrollIntoView('log', location, setActiveSection); return ( <> diff --git a/apps/client/src/features/app-settings/panel/network-panel/client-control/ClientList.tsx b/apps/client/src/features/app-settings/panel/network-panel/client-control/ClientList.tsx index 70e7a5778..3e31e2299 100644 --- a/apps/client/src/features/app-settings/panel/network-panel/client-control/ClientList.tsx +++ b/apps/client/src/features/app-settings/panel/network-panel/client-control/ClientList.tsx @@ -68,6 +68,7 @@ export default function ClientList() { + {ontimeClients.length === 0 && } {ontimeClients.map(([key, client]) => { const { identify, name, path } = client; const isCurrent = id === key; @@ -125,6 +126,7 @@ export default function ClientList() { + {otherClients.length === 0 && } {otherClients.map(([key, client]) => { const { name, type } = client; diff --git a/apps/client/src/features/app-settings/panel/project-panel/ProjectPanel.tsx b/apps/client/src/features/app-settings/panel/project-panel/ProjectPanel.tsx index df7aba708..72ffdcb2f 100644 --- a/apps/client/src/features/app-settings/panel/project-panel/ProjectPanel.tsx +++ b/apps/client/src/features/app-settings/panel/project-panel/ProjectPanel.tsx @@ -1,4 +1,5 @@ import useScrollIntoView from '../../../../common/hooks/useScrollIntoView'; +import { useAppSettingsScroll } from '../../AppSettingsScrollContext'; import type { PanelBaseProps } from '../../panel-list/PanelList'; import * as Panel from '../../panel-utils/PanelUtils'; import QuickStart from '../../quick-start/QuickStart'; @@ -10,7 +11,8 @@ interface ProjectPanelProps extends PanelBaseProps { } export default function ProjectPanel({ location, setLocation }: ProjectPanelProps) { - const manageProjectsRef = useScrollIntoView('list', location); + const { setActiveSection } = useAppSettingsScroll(); + const manageProjectsRef = useScrollIntoView('list', location, setActiveSection); const handleQuickClose = () => { setLocation('project'); diff --git a/apps/client/src/features/app-settings/panel/settings-panel/GeneralSettings.tsx b/apps/client/src/features/app-settings/panel/settings-panel/GeneralSettings.tsx index 1f402581c..6ea0d1a49 100644 --- a/apps/client/src/features/app-settings/panel/settings-panel/GeneralSettings.tsx +++ b/apps/client/src/features/app-settings/panel/settings-panel/GeneralSettings.tsx @@ -11,6 +11,7 @@ import Select from '../../../../common/components/select/Select'; import useSettings from '../../../../common/hooks-query/useSettings'; import { preventEscape } from '../../../../common/utils/keyEvent'; import * as Panel from '../../panel-utils/PanelUtils'; +import { useSettingsDirty } from '../../useSettingsDirty'; import GeneralPinInput from './composite/GeneralPinInput'; const TranslationModal = lazy(() => import('./composite/CustomTranslationModal')); @@ -32,6 +33,7 @@ export default function GeneralSettings() { keepDirtyValues: true, }, }); + useSettingsDirty(isDirty); const [isOpen, handler] = useDisclosure(); diff --git a/apps/client/src/features/app-settings/panel/settings-panel/ProjectData.tsx b/apps/client/src/features/app-settings/panel/settings-panel/ProjectData.tsx index f090879f4..359c31f64 100644 --- a/apps/client/src/features/app-settings/panel/settings-panel/ProjectData.tsx +++ b/apps/client/src/features/app-settings/panel/settings-panel/ProjectData.tsx @@ -14,6 +14,7 @@ import { preventEscape } from '../../../../common/utils/keyEvent'; import { validateLogo } from '../../../../common/utils/uploadUtils'; import { documentationUrl } from '../../../../externals'; import * as Panel from '../../panel-utils/PanelUtils'; +import { useSettingsDirty } from '../../useSettingsDirty'; import style from './SettingsPanel.module.scss'; @@ -38,6 +39,7 @@ export default function ProjectData() { }, mode: 'onChange', }); + useSettingsDirty(isDirty); const { fields, append, remove } = useFieldArray({ control, diff --git a/apps/client/src/features/app-settings/panel/settings-panel/ServerPortSettings.tsx b/apps/client/src/features/app-settings/panel/settings-panel/ServerPortSettings.tsx index 061e8a303..0ae3c271f 100644 --- a/apps/client/src/features/app-settings/panel/settings-panel/ServerPortSettings.tsx +++ b/apps/client/src/features/app-settings/panel/settings-panel/ServerPortSettings.tsx @@ -9,6 +9,7 @@ import useServerPort from '../../../../common/hooks-query/useServerPort'; import { preventEscape } from '../../../../common/utils/keyEvent'; import { isOnlyNumbers } from '../../../../common/utils/regex'; import * as Panel from '../../panel-utils/PanelUtils'; +import { useSettingsDirty } from '../../useSettingsDirty'; interface ServerPortForm { serverPort: number; @@ -27,6 +28,7 @@ export default function ServerPortSettings() { mode: 'onChange', defaultValues: { serverPort: 4001 }, }); + useSettingsDirty(isDirty); useEffect(() => { reset({ serverPort: data.port }); diff --git a/apps/client/src/features/app-settings/panel/settings-panel/SettingsPanel.tsx b/apps/client/src/features/app-settings/panel/settings-panel/SettingsPanel.tsx index ccbf86ffa..e9d5e25d6 100644 --- a/apps/client/src/features/app-settings/panel/settings-panel/SettingsPanel.tsx +++ b/apps/client/src/features/app-settings/panel/settings-panel/SettingsPanel.tsx @@ -1,5 +1,6 @@ import useScrollIntoView from '../../../../common/hooks/useScrollIntoView'; import { isDocker } from '../../../../externals'; +import { useAppSettingsScroll } from '../../AppSettingsScrollContext'; import type { PanelBaseProps } from '../../panel-list/PanelList'; import * as Panel from '../../panel-utils/PanelUtils'; import CustomViews from '../manage-panel/CustomViews'; @@ -9,11 +10,12 @@ import ServerPortSettings from './ServerPortSettings'; import ViewSettings from './ViewSettings'; export default function SettingsPanel({ location }: PanelBaseProps) { - const dataRef = useScrollIntoView('data', location); - const generalRef = useScrollIntoView('general', location); - const viewRef = useScrollIntoView('view', location); - const customViewsRef = useScrollIntoView('custom-views', location); - const portRef = useScrollIntoView('port', location); + const { setActiveSection } = useAppSettingsScroll(); + const dataRef = useScrollIntoView('data', location, setActiveSection); + const generalRef = useScrollIntoView('general', location, setActiveSection); + const viewRef = useScrollIntoView('view', location, setActiveSection); + const customViewsRef = useScrollIntoView('custom-views', location, setActiveSection); + const portRef = useScrollIntoView('port', location, setActiveSection); return ( <> diff --git a/apps/client/src/features/app-settings/panel/settings-panel/ViewSettings.tsx b/apps/client/src/features/app-settings/panel/settings-panel/ViewSettings.tsx index 0a4773243..d68c9044d 100644 --- a/apps/client/src/features/app-settings/panel/settings-panel/ViewSettings.tsx +++ b/apps/client/src/features/app-settings/panel/settings-panel/ViewSettings.tsx @@ -13,6 +13,7 @@ import Tag from '../../../../common/components/tag/Tag'; import useViewSettings from '../../../../common/hooks-query/useViewSettings'; import { preventEscape } from '../../../../common/utils/keyEvent'; import * as Panel from '../../panel-utils/PanelUtils'; +import { useSettingsDirty } from '../../useSettingsDirty'; import CodeEditorModal from './composite/StyleEditorModal'; const cssOverrideDocsUrl = 'https://docs.getontime.no/features/custom-styling/'; @@ -35,6 +36,7 @@ export default function ViewSettings() { keepDirtyValues: true, }, }); + useSettingsDirty(isDirty); // update form if we get new data from server useEffect(() => { diff --git a/apps/client/src/features/app-settings/useAppSettingsMenu.tsx b/apps/client/src/features/app-settings/useAppSettingsMenu.tsx index 3b34468cd..257eaaacb 100644 --- a/apps/client/src/features/app-settings/useAppSettingsMenu.tsx +++ b/apps/client/src/features/app-settings/useAppSettingsMenu.tsx @@ -86,6 +86,15 @@ const staticOptions = [ }, ] as const; +export function getPanelLabel(panel: string, location?: string): { panelLabel: string; sectionLabel?: string } { + const panelOption = staticOptions.find((o) => o.id === panel); + if (!panelOption) return { panelLabel: panel }; + const panelLabel = panelOption.label; + if (!location || !('secondary' in panelOption)) return { panelLabel }; + const match = panelOption.secondary.find((s) => s.id.split('__')[1] === location); + return { panelLabel, sectionLabel: match?.label }; +} + // a child of navigation or a child of secondary navigation export type SettingsOptionId = | (typeof staticOptions)[number]['id'] diff --git a/apps/client/src/features/app-settings/useSettingsDirty.ts b/apps/client/src/features/app-settings/useSettingsDirty.ts new file mode 100644 index 000000000..b5b0c3e4a --- /dev/null +++ b/apps/client/src/features/app-settings/useSettingsDirty.ts @@ -0,0 +1,14 @@ +import { useEffect } from 'react'; + +import { markClean, markDirty } from './appSettingsDirtyState'; + +export function useSettingsDirty(isDirty: boolean) { + useEffect(() => { + if (isDirty) { + markDirty(); + } else { + markClean(); + } + return () => markClean(); + }, [isDirty]); +}