diff --git a/apps/client/src/common/api/constants.ts b/apps/client/src/common/api/constants.ts
index dafbf1a97..4751a3711 100644
--- a/apps/client/src/common/api/constants.ts
+++ b/apps/client/src/common/api/constants.ts
@@ -1,6 +1,7 @@
// keys in tanstack store
export const APP_INFO = ['appinfo'];
export const APP_SETTINGS = ['appSettings'];
+export const APP_VERSION = ['appVersion'];
export const CUSTOM_FIELDS = ['customFields'];
export const HTTP_SETTINGS = ['httpSettings'];
export const OSC_SETTINGS = ['oscSettings'];
diff --git a/apps/client/src/common/hooks-query/useAppVersion.ts b/apps/client/src/common/hooks-query/useAppVersion.ts
new file mode 100644
index 000000000..65136c58c
--- /dev/null
+++ b/apps/client/src/common/hooks-query/useAppVersion.ts
@@ -0,0 +1,33 @@
+import { useQuery } from '@tanstack/react-query';
+import { dayInMs } from 'ontime-utils';
+
+import { version } from '../../../../../package.json';
+import { APP_VERSION, isLocalhost } from '../api/constants';
+import { getLatestVersion, HasUpdate } from '../api/external';
+
+const placeholder: HasUpdate & { hasUpdates: boolean } = { url: '', version: '', hasUpdates: false };
+
+export default function useAppVersion() {
+ const {
+ data: fetchData,
+ status,
+ isFetching,
+ isError,
+ refetch,
+ } = useQuery({
+ queryKey: APP_VERSION,
+ queryFn: getLatestVersion,
+ placeholderData: (previousData, _previousQuery) => previousData,
+ refetchOnWindowFocus: false,
+ refetchOnReconnect: false,
+ retry: false,
+ staleTime: dayInMs,
+ enabled: isLocalhost,
+ });
+
+ const hasUpdates = fetchData?.version && !fetchData.version.includes(version);
+
+ const data = fetchData ? { ...fetchData, hasUpdates } : placeholder;
+
+ return { data, placeholder, status, isFetching, isError, refetch };
+}
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 a0faf09e2..327c0a735 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
@@ -40,12 +40,16 @@ ul {
background-color: $gray-1100;
}
- &.unsaved::before {
- content: '';
- width: 6px;
- height: 6px;
- border-radius: 3px;
- background-color: $blue-400;
+ &.highlight {
+ color: $red-400;
+
+ &::before {
+ content: '';
+ width: 0.5em;
+ height: 0.5em;
+ border-radius: 99px;
+ background-color: $red-400;
+ }
}
&.split {
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 34c939227..f0f6e7dbe 100644
--- a/apps/client/src/features/app-settings/panel-list/PanelList.tsx
+++ b/apps/client/src/features/app-settings/panel-list/PanelList.tsx
@@ -1,66 +1,90 @@
import { Fragment } from 'react';
+import { Tooltip } from '@chakra-ui/react';
import { isKeyEnter } from '../../../common/utils/keyEvent';
import { cx } from '../../../common/utils/styleUtils';
-import { PanelBaseProps, settingPanels, useSettingsStore } from '../settingsStore';
+import { tooltipDelayFast } from '../../../ontimeConfig';
+import { SettingsOption, SettingsOptionId, useAppSettingsMenu } from '../useAppSettingsMenu';
import useAppSettingsNavigation from '../useAppSettingsNavigation';
import style from './PanelList.module.scss';
+export interface PanelBaseProps {
+ location?: string;
+}
+
interface PanelListProps extends PanelBaseProps {
selectedPanel: string;
}
export default function PanelList({ selectedPanel, location }: PanelListProps) {
- const { setLocation } = useAppSettingsNavigation();
- const { hasUnsavedChanges } = useSettingsStore();
+ const { options } = useAppSettingsMenu();
return (
- {settingPanels.map((panel) => {
- const unsaved = hasUnsavedChanges(panel.id);
-
- const classes = cx([
- style.primary,
- selectedPanel === panel.id ? style.active : null,
- panel.split ? style.split : null,
- unsaved ? style.unsaved : null,
- ]);
-
- return (
-
- - setLocation(panel.id)}
- onKeyDown={(event) => {
- isKeyEnter(event) && setLocation(panel.id);
- }}
- className={classes}
- tabIndex={0}
- role='button'
- >
- {panel.label}
-
- {panel.secondary?.map((secondary) => {
- const id = secondary.id.split('__')[1];
- const secondaryClasses = cx([style.secondary, location === id ? style.active : null]);
- return (
- - setLocation(secondary.id)}
- onKeyDown={(event) => {
- isKeyEnter(event) && setLocation(secondary.id);
- }}
- className={secondaryClasses}
- role='button'
- >
- {secondary.label}
-
- );
- })}
-
- );
+ {options.map((panel) => {
+ const isSelected = selectedPanel === panel.id;
+ if (panel.highlight) {
+ return (
+
+
+
+ );
+ }
+ return ;
})}
);
}
+
+interface PanelListItemProps {
+ panel: SettingsOption;
+ isSelected: boolean;
+ location?: string;
+}
+
+function PanelListItem(props: PanelListItemProps) {
+ const { panel, isSelected, location } = props;
+ const { setLocation } = useAppSettingsNavigation();
+
+ const classes = cx([
+ style.primary,
+ isSelected && style.active,
+ panel.split && style.split,
+ panel.highlight && style.highlight,
+ ]);
+
+ return (
+
+ setLocation(panel.id as SettingsOptionId)}
+ onKeyDown={(event) => {
+ isKeyEnter(event) && setLocation(panel.id as SettingsOptionId);
+ }}
+ className={classes}
+ tabIndex={0}
+ role='button'
+ >
+ {panel.label}
+
+ {panel.secondary?.map((secondary) => {
+ const id = secondary.id.split('__')[1];
+ const secondaryClasses = cx([style.secondary, location === id ? style.active : null]);
+ return (
+ setLocation(secondary.id as SettingsOptionId)}
+ onKeyDown={(event) => {
+ isKeyEnter(event) && setLocation(secondary.id as SettingsOptionId);
+ }}
+ className={secondaryClasses}
+ role='button'
+ >
+ {secondary.label}
+
+ );
+ })}
+
+ );
+}
diff --git a/apps/client/src/features/app-settings/panel/about-panel/AboutPanel.tsx b/apps/client/src/features/app-settings/panel/about-panel/AboutPanel.tsx
index ab47622b4..2fca9de49 100644
--- a/apps/client/src/features/app-settings/panel/about-panel/AboutPanel.tsx
+++ b/apps/client/src/features/app-settings/panel/about-panel/AboutPanel.tsx
@@ -1,9 +1,8 @@
-import { version } from '../../../../../package.json';
import ExternalLink from '../../../../common/components/external-link/ExternalLink';
import { documentationUrl, githubUrl, websiteUrl } from '../../../../externals';
import * as Panel from '../PanelUtils';
-import CheckUpdatesButton from './CheckUpdatesButton';
+import AppVersion from './AppVersion';
export default function AboutPanel() {
return (
@@ -23,8 +22,7 @@ export default function AboutPanel() {
Current version
- {`You are currently using Ontime ${version}`}
-
+
>
);
diff --git a/apps/client/src/features/app-settings/panel/about-panel/AppVersion.tsx b/apps/client/src/features/app-settings/panel/about-panel/AppVersion.tsx
new file mode 100644
index 000000000..4938434b3
--- /dev/null
+++ b/apps/client/src/features/app-settings/panel/about-panel/AppVersion.tsx
@@ -0,0 +1,31 @@
+import { version } from '../../../../../package.json';
+import ExternalLink from '../../../../common/components/external-link/ExternalLink';
+import useAppVersion from '../../../../common/hooks-query/useAppVersion';
+import * as Panel from '../PanelUtils';
+
+export default function AppVersion() {
+ const { data, isError } = useAppVersion();
+
+ if (isError) {
+ return (
+
+ {`You are currently using Ontime version ${version}`}
+ {`Could not fetch version information: ${isError}`}
+
+ );
+ }
+
+ if (data.hasUpdates) {
+ return (
+ <>
+ {`You are currently using Ontime version ${version}.`}
+
+ {`A new version ${data.version} is available.`}
+ Please visit the release page to download
+
+ >
+ );
+ }
+
+ return {`You are currently using the latest version of Ontime: ${version}`};
+}
diff --git a/apps/client/src/features/app-settings/panel/about-panel/CheckUpdatesButton.tsx b/apps/client/src/features/app-settings/panel/about-panel/CheckUpdatesButton.tsx
deleted file mode 100644
index d5bdf47bb..000000000
--- a/apps/client/src/features/app-settings/panel/about-panel/CheckUpdatesButton.tsx
+++ /dev/null
@@ -1,80 +0,0 @@
-import { useState } from 'react';
-import { Button } from '@chakra-ui/react';
-
-import { getLatestVersion, HasUpdate } from '../../../../common/api/external';
-import ExternalLink from '../../../../common/components/external-link/ExternalLink';
-
-import style from '../Panel.module.scss';
-
-type CheckFail = {
- error: string;
-};
-
-type CheckIsLatest = {
- latest: true;
-};
-
-type CheckRemote = CheckFail | CheckIsLatest | HasUpdate;
-
-interface CheckUpdatesButtonProps {
- version: string;
-}
-
-export default function CheckUpdatesButton(props: CheckUpdatesButtonProps) {
- const { version } = props;
-
- const [updateMessage, setUpdateMessage] = useState(null);
- const [isFetching, setIsFetching] = useState(false);
-
- /**
- * Handles version comparison and returns component with message
- */
- const versionCheck = async () => {
- setIsFetching(true);
-
- try {
- const latest = await getLatestVersion();
-
- if (!latest.version.includes(version)) {
- // new version, pass data to component
- setUpdateMessage(latest);
- } else {
- setUpdateMessage({ latest: true });
- }
- } catch {
- setUpdateMessage({ error: 'Error reaching server' });
- } finally {
- setIsFetching(false);
- }
- };
-
- const disableButton = Boolean(updateMessage && 'version' in updateMessage);
-
- return (
- <>
-
-
- >
- );
-}
-
-function ResolveUpdateMessage(props: { updateMessage: CheckRemote | null }) {
- const { updateMessage } = props;
-
- if (updateMessage && 'error' in updateMessage) {
- return {updateMessage.error};
- }
- if (updateMessage && 'url' in updateMessage) {
- return {`New version available: ${updateMessage.version}`};
- }
- return null;
-}
diff --git a/apps/client/src/features/app-settings/panel/feature-settings-panel/FeatureSettingsPanel.tsx b/apps/client/src/features/app-settings/panel/feature-settings-panel/FeatureSettingsPanel.tsx
index af4756096..079978a10 100644
--- a/apps/client/src/features/app-settings/panel/feature-settings-panel/FeatureSettingsPanel.tsx
+++ b/apps/client/src/features/app-settings/panel/feature-settings-panel/FeatureSettingsPanel.tsx
@@ -1,5 +1,5 @@
import useScrollIntoView from '../../../../common/hooks/useScrollIntoView';
-import { PanelBaseProps } from '../../settingsStore';
+import type { PanelBaseProps } from '../../panel-list/PanelList';
import * as Panel from '../PanelUtils';
import CustomFields from './custom-fields/CustomFields';
diff --git a/apps/client/src/features/app-settings/panel/general-panel/GeneralPanel.tsx b/apps/client/src/features/app-settings/panel/general-panel/GeneralPanel.tsx
index 0c87d4eaf..2e11e30ae 100644
--- a/apps/client/src/features/app-settings/panel/general-panel/GeneralPanel.tsx
+++ b/apps/client/src/features/app-settings/panel/general-panel/GeneralPanel.tsx
@@ -1,5 +1,5 @@
import useScrollIntoView from '../../../../common/hooks/useScrollIntoView';
-import { PanelBaseProps } from '../../settingsStore';
+import type { PanelBaseProps } from '../../panel-list/PanelList';
import EditorSettingsForm from '../interface-panel/EditorSettingsForm';
import * as Panel from '../PanelUtils';
diff --git a/apps/client/src/features/app-settings/panel/integrations-panel/IntegrationsPanel.tsx b/apps/client/src/features/app-settings/panel/integrations-panel/IntegrationsPanel.tsx
index ea3cfe151..23d3a0b4f 100644
--- a/apps/client/src/features/app-settings/panel/integrations-panel/IntegrationsPanel.tsx
+++ b/apps/client/src/features/app-settings/panel/integrations-panel/IntegrationsPanel.tsx
@@ -2,7 +2,7 @@ import { Alert, AlertDescription, AlertIcon } from '@chakra-ui/react';
import ExternalLink from '../../../../common/components/external-link/ExternalLink';
import useScrollIntoView from '../../../../common/hooks/useScrollIntoView';
-import { PanelBaseProps } from '../../settingsStore';
+import type { PanelBaseProps } from '../../panel-list/PanelList';
import * as Panel from '../PanelUtils';
import HttpIntegrations from './HttpIntegrations';
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 0de76a1cf..e9224ba43 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
@@ -1,5 +1,5 @@
import useScrollIntoView from '../../../../common/hooks/useScrollIntoView';
-import { PanelBaseProps } from '../../settingsStore';
+import type { PanelBaseProps } from '../../panel-list/PanelList';
import ClientControlPanel from '../client-control-panel/ClientControlPanel';
import * as Panel from '../PanelUtils';
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 ffd90aee3..83b25cf61 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,5 +1,5 @@
import useScrollIntoView from '../../../../common/hooks/useScrollIntoView';
-import { PanelBaseProps } from '../../settingsStore';
+import type { PanelBaseProps } from '../../panel-list/PanelList';
import * as Panel from '../PanelUtils';
import ManageProjects from './ManageProjects';
diff --git a/apps/client/src/features/app-settings/settingsStore.ts b/apps/client/src/features/app-settings/useAppSettingsMenu.tsx
similarity index 61%
rename from apps/client/src/features/app-settings/settingsStore.ts
rename to apps/client/src/features/app-settings/useAppSettingsMenu.tsx
index b93f78719..1badd22eb 100644
--- a/apps/client/src/features/app-settings/settingsStore.ts
+++ b/apps/client/src/features/app-settings/useAppSettingsMenu.tsx
@@ -1,13 +1,16 @@
-import { create } from 'zustand';
+import { useMemo } from 'react';
+
+import useAppVersion from '../../common/hooks-query/useAppVersion';
export type SettingsOption = {
id: string;
label: string;
secondary?: Readonly;
split?: boolean;
+ highlight?: string;
};
-export const settingPanels: Readonly = [
+const staticOptions = [
{
id: 'project',
label: 'Project',
@@ -77,30 +80,19 @@ export const settingPanels: Readonly = [
},
] as const;
-export type SettingsOptionId = (typeof settingPanels)[number]['id'];
+export type SettingsOptionId = (typeof staticOptions)[number]['id'];
-export interface PanelBaseProps {
- location?: string;
+export function useAppSettingsMenu() {
+ const { data } = useAppVersion();
+
+ const options: Readonly = useMemo(
+ () =>
+ staticOptions.map((option) => ({
+ ...option,
+ highlight: option.id === 'about' && data.hasUpdates ? 'New version available' : undefined,
+ })),
+ [data],
+ );
+
+ return { options };
}
-
-type SettingsStore = {
- unsavedChanges: Set;
- hasUnsavedChanges: (panelId: SettingsOptionId) => boolean;
- addUnsavedChanges: (panelId: SettingsOptionId) => void;
- removeUnsavedChanges: (panelId: SettingsOptionId) => void;
-};
-
-export const useSettingsStore = create((set, get) => ({
- unsavedChanges: new Set(),
- hasUnsavedChanges: (panelId: SettingsOptionId) => get().unsavedChanges.has(panelId),
- addUnsavedChanges: (panelId: SettingsOptionId) =>
- set((state) => {
- state.unsavedChanges.add(panelId);
- return { unsavedChanges: new Set(state.unsavedChanges) };
- }),
- removeUnsavedChanges: (panelId: SettingsOptionId) =>
- set((state) => {
- state.unsavedChanges.delete(panelId);
- return { unsavedChanges: new Set(state.unsavedChanges) };
- }),
-}));
diff --git a/apps/client/src/features/app-settings/useAppSettingsNavigation.tsx b/apps/client/src/features/app-settings/useAppSettingsNavigation.tsx
index 70867f3fc..abe59a3da 100644
--- a/apps/client/src/features/app-settings/useAppSettingsNavigation.tsx
+++ b/apps/client/src/features/app-settings/useAppSettingsNavigation.tsx
@@ -1,7 +1,7 @@
import { useCallback, useMemo } from 'react';
import { useSearchParams } from 'react-router-dom';
-import { SettingsOptionId } from './settingsStore';
+import { SettingsOptionId } from './useAppSettingsMenu';
const settingsKey = 'settings';