mirror of
https://github.com/emsesp/EMS-ESP32.git
synced 2026-05-02 20:16:59 +00:00
show badge if there is an update available, which is cached
This commit is contained in:
@@ -1,10 +1,13 @@
|
||||
import { useState } from 'react';
|
||||
import { useContext, useState } from 'react';
|
||||
import { toast } from 'react-toastify';
|
||||
|
||||
import AccessTimeIcon from '@mui/icons-material/AccessTime';
|
||||
import BuildIcon from '@mui/icons-material/Build';
|
||||
import CancelIcon from '@mui/icons-material/Cancel';
|
||||
import DeviceHubIcon from '@mui/icons-material/DeviceHub';
|
||||
import ImportExportIcon from '@mui/icons-material/ImportExport';
|
||||
import LockIcon from '@mui/icons-material/Lock';
|
||||
import PowerSettingsNewIcon from '@mui/icons-material/PowerSettingsNew';
|
||||
import SettingsBackupRestoreIcon from '@mui/icons-material/SettingsBackupRestore';
|
||||
import SettingsEthernetIcon from '@mui/icons-material/SettingsEthernet';
|
||||
import SettingsInputAntennaIcon from '@mui/icons-material/SettingsInputAntenna';
|
||||
@@ -28,15 +31,23 @@ import { useRequest } from 'alova/client';
|
||||
import type { APIcall } from 'app/main/types';
|
||||
import { SectionContent, useLayoutTitle } from 'components';
|
||||
import ListMenuItem from 'components/layout/ListMenuItem';
|
||||
import { AuthenticatedContext } from 'contexts/authentication';
|
||||
import { useI18nContext } from 'i18n/i18n-react';
|
||||
|
||||
import SystemMonitor from '../status/SystemMonitor';
|
||||
|
||||
const Settings = () => {
|
||||
const { LL } = useI18nContext();
|
||||
const { versions } = useContext(AuthenticatedContext);
|
||||
useLayoutTitle(LL.SETTINGS(0));
|
||||
|
||||
const firmwareText = versions?.current?.version
|
||||
? `v${versions.current.version}`
|
||||
: '';
|
||||
const upgradeAvailable = versions?.current?.upgradeable ?? false;
|
||||
|
||||
const [confirmFactoryReset, setConfirmFactoryReset] = useState(false);
|
||||
const [confirmRestart, setConfirmRestart] = useState(false);
|
||||
const [restarting, setRestarting] = useState<boolean>();
|
||||
|
||||
const { send: sendAPI } = useRequest((data: APIcall) => API(data), {
|
||||
@@ -50,6 +61,16 @@ const Settings = () => {
|
||||
});
|
||||
};
|
||||
|
||||
const doRestart = async () => {
|
||||
setConfirmRestart(false);
|
||||
setRestarting(true);
|
||||
await sendAPI({ device: 'system', cmd: 'restart', id: 0 }).catch(
|
||||
(error: Error) => {
|
||||
toast.error(error.message);
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
const handleFactoryResetClose = () => {
|
||||
setConfirmFactoryReset(false);
|
||||
};
|
||||
@@ -58,6 +79,14 @@ const Settings = () => {
|
||||
setConfirmFactoryReset(true);
|
||||
};
|
||||
|
||||
const handleRestartClose = () => {
|
||||
setConfirmRestart(false);
|
||||
};
|
||||
|
||||
const handleRestartClick = () => {
|
||||
setConfirmRestart(true);
|
||||
};
|
||||
|
||||
if (restarting) {
|
||||
return <SystemMonitor />;
|
||||
}
|
||||
@@ -65,6 +94,15 @@ const Settings = () => {
|
||||
return (
|
||||
<SectionContent>
|
||||
<List>
|
||||
<ListMenuItem
|
||||
icon={BuildIcon}
|
||||
bgcolor="#72caf9"
|
||||
label="EMS-ESP Firmware"
|
||||
text={firmwareText}
|
||||
to="/status/version"
|
||||
badge={upgradeAvailable}
|
||||
/>
|
||||
|
||||
<ListMenuItem
|
||||
icon={TuneIcon}
|
||||
bgcolor="#134ba2"
|
||||
@@ -156,6 +194,29 @@ const Settings = () => {
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
|
||||
<Dialog sx={dialogStyle} open={confirmRestart} onClose={handleRestartClose}>
|
||||
<DialogTitle>{LL.RESTART()}</DialogTitle>
|
||||
<DialogContent dividers>{LL.RESTART_CONFIRM()}</DialogContent>
|
||||
<DialogActions>
|
||||
<Button
|
||||
startIcon={<CancelIcon />}
|
||||
variant="outlined"
|
||||
onClick={handleRestartClose}
|
||||
color="secondary"
|
||||
>
|
||||
{LL.CANCEL()}
|
||||
</Button>
|
||||
<Button
|
||||
startIcon={<PowerSettingsNewIcon />}
|
||||
variant="outlined"
|
||||
onClick={doRestart}
|
||||
color="error"
|
||||
>
|
||||
{LL.RESTART()}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
|
||||
<Divider />
|
||||
|
||||
<Box
|
||||
@@ -164,9 +225,18 @@ const Settings = () => {
|
||||
display: 'flex',
|
||||
justifyContent: 'flex-end',
|
||||
flexWrap: 'nowrap',
|
||||
whiteSpace: 'nowrap'
|
||||
whiteSpace: 'nowrap',
|
||||
gap: 1
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
startIcon={<PowerSettingsNewIcon />}
|
||||
variant="outlined"
|
||||
onClick={handleRestartClick}
|
||||
color="error"
|
||||
>
|
||||
{LL.RESTART()}
|
||||
</Button>
|
||||
<Button
|
||||
startIcon={<SettingsBackupRestoreIcon />}
|
||||
variant="outlined"
|
||||
|
||||
@@ -1,25 +1,16 @@
|
||||
import { useContext, useState } from 'react';
|
||||
import { toast } from 'react-toastify';
|
||||
import { useContext } from 'react';
|
||||
|
||||
import AccessTimeIcon from '@mui/icons-material/AccessTime';
|
||||
import BuildIcon from '@mui/icons-material/Build';
|
||||
import CancelIcon from '@mui/icons-material/Cancel';
|
||||
import DeviceHubIcon from '@mui/icons-material/DeviceHub';
|
||||
import DirectionsBusIcon from '@mui/icons-material/DirectionsBus';
|
||||
import LogoDevIcon from '@mui/icons-material/LogoDev';
|
||||
import MemoryIcon from '@mui/icons-material/Memory';
|
||||
import MonitorHeartIcon from '@mui/icons-material/MonitorHeart';
|
||||
import PowerSettingsNewIcon from '@mui/icons-material/PowerSettingsNew';
|
||||
import RouterIcon from '@mui/icons-material/Router';
|
||||
import SettingsInputAntennaIcon from '@mui/icons-material/SettingsInputAntenna';
|
||||
import WifiIcon from '@mui/icons-material/Wifi';
|
||||
import {
|
||||
Avatar,
|
||||
Button,
|
||||
Dialog,
|
||||
DialogActions,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
List,
|
||||
ListItem,
|
||||
ListItemAvatar,
|
||||
@@ -27,12 +18,10 @@ import {
|
||||
useTheme
|
||||
} from '@mui/material';
|
||||
|
||||
import { API } from 'api/app';
|
||||
import { readSystemStatus } from 'api/system';
|
||||
|
||||
import { dialogStyle } from 'CustomTheme';
|
||||
import { useRequest } from 'alova/client';
|
||||
import { type APIcall, busConnectionStatus } from 'app/main/types';
|
||||
import { busConnectionStatus } from 'app/main/types';
|
||||
import { FormLoader, SectionContent, useLayoutTitle } from 'components';
|
||||
import ListMenuItem from 'components/layout/ListMenuItem';
|
||||
import { AuthenticatedContext } from 'contexts/authentication';
|
||||
@@ -41,8 +30,6 @@ import { NTPSyncStatus, NetworkConnectionStatus, SystemStatusCodes } from 'types
|
||||
import { useInterval } from 'utils';
|
||||
import { formatDateTime } from 'utils/time';
|
||||
|
||||
import SystemMonitor from './SystemMonitor';
|
||||
|
||||
const formatNumber = (num: number) => new Intl.NumberFormat().format(num);
|
||||
|
||||
const formatDurationSec = (
|
||||
@@ -71,24 +58,7 @@ const SystemStatus = () => {
|
||||
|
||||
const { me } = useContext(AuthenticatedContext);
|
||||
|
||||
const [confirmRestart, setConfirmRestart] = useState<boolean>(false);
|
||||
const [restarting, setRestarting] = useState<boolean>();
|
||||
|
||||
const { send: sendAPI } = useRequest((data: APIcall) => API(data), {
|
||||
immediate: false
|
||||
});
|
||||
|
||||
const {
|
||||
data,
|
||||
send: loadData,
|
||||
error
|
||||
} = useRequest(readSystemStatus, {
|
||||
async middleware(_, next) {
|
||||
if (!restarting) {
|
||||
await next();
|
||||
}
|
||||
}
|
||||
});
|
||||
const { data, send: loadData, error } = useRequest(readSystemStatus);
|
||||
|
||||
useInterval(() => {
|
||||
void loadData();
|
||||
@@ -217,22 +187,6 @@ const SystemStatus = () => {
|
||||
const activeHighlight = (value: boolean) =>
|
||||
value ? theme.palette.success.main : theme.palette.info.main;
|
||||
|
||||
const doRestart = async () => {
|
||||
setConfirmRestart(false);
|
||||
setRestarting(true);
|
||||
await sendAPI({ device: 'system', cmd: 'restart', id: 0 }).catch(
|
||||
(error: Error) => {
|
||||
toast.error(error.message);
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
const handleCloseRestartDialog = () => setConfirmRestart(false);
|
||||
|
||||
if (restarting) {
|
||||
return <SystemMonitor />;
|
||||
}
|
||||
|
||||
if (!data || !LL) {
|
||||
return (
|
||||
<SectionContent>
|
||||
@@ -244,14 +198,6 @@ const SystemStatus = () => {
|
||||
return (
|
||||
<SectionContent>
|
||||
<List>
|
||||
<ListMenuItem
|
||||
icon={BuildIcon}
|
||||
bgcolor="#72caf9"
|
||||
label="EMS-ESP Firmware"
|
||||
text={`v${data.emsesp_version || ''}`}
|
||||
to="version"
|
||||
/>
|
||||
|
||||
<ListItem>
|
||||
<ListItemAvatar>
|
||||
<Avatar sx={{ bgcolor: '#c5572c', color: 'white' }}>
|
||||
@@ -262,16 +208,6 @@ const SystemStatus = () => {
|
||||
primary={LL.STATUS_OF(LL.SYSTEM(0))}
|
||||
secondary={`${systemStatus} (${LL.UPTIME()}: ${formatDurationSec(data.uptime, LL)})`}
|
||||
/>
|
||||
{me.admin && (
|
||||
<Button
|
||||
startIcon={<PowerSettingsNewIcon />}
|
||||
variant="outlined"
|
||||
color="error"
|
||||
onClick={() => setConfirmRestart(true)}
|
||||
>
|
||||
{LL.RESTART()}
|
||||
</Button>
|
||||
)}
|
||||
</ListItem>
|
||||
|
||||
<ListMenuItem
|
||||
@@ -341,33 +277,6 @@ const SystemStatus = () => {
|
||||
to="/status/log"
|
||||
/>
|
||||
</List>
|
||||
|
||||
<Dialog
|
||||
sx={dialogStyle}
|
||||
open={confirmRestart}
|
||||
onClose={handleCloseRestartDialog}
|
||||
>
|
||||
<DialogTitle>{LL.RESTART()}</DialogTitle>
|
||||
<DialogContent dividers>{LL.RESTART_CONFIRM()}</DialogContent>
|
||||
<DialogActions>
|
||||
<Button
|
||||
startIcon={<CancelIcon />}
|
||||
variant="outlined"
|
||||
onClick={handleCloseRestartDialog}
|
||||
color="secondary"
|
||||
>
|
||||
{LL.CANCEL()}
|
||||
</Button>
|
||||
<Button
|
||||
startIcon={<PowerSettingsNewIcon />}
|
||||
variant="outlined"
|
||||
onClick={doRestart}
|
||||
color="error"
|
||||
>
|
||||
{LL.RESTART()}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</SectionContent>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { memo, useContext, useState } from 'react';
|
||||
import { memo, useContext, useMemo, useState } from 'react';
|
||||
import { Link } from 'react-router';
|
||||
import { toast } from 'react-toastify';
|
||||
|
||||
@@ -40,6 +40,7 @@ import {
|
||||
import { AuthenticatedContext } from 'contexts/authentication';
|
||||
import { useI18nContext } from 'i18n/i18n-react';
|
||||
import type { TranslationFunctions } from 'i18n/i18n-types';
|
||||
import type { VersionInfo } from 'types';
|
||||
import { prettyDateTime } from 'utils/time';
|
||||
|
||||
// Constants moved outside component to avoid recreation
|
||||
@@ -70,26 +71,6 @@ interface VersionData {
|
||||
developer_mode: boolean;
|
||||
}
|
||||
|
||||
interface VersionInfo {
|
||||
version: string;
|
||||
date: string;
|
||||
}
|
||||
|
||||
interface RemoteVersionInfo extends VersionInfo {
|
||||
upgradeable: boolean;
|
||||
}
|
||||
|
||||
interface CurrentVersionInfo extends VersionInfo {
|
||||
type: 'stable' | 'dev';
|
||||
}
|
||||
|
||||
// Response payload from the `getVersions` action
|
||||
interface VersionsResponse {
|
||||
current: CurrentVersionInfo;
|
||||
stable?: RemoteVersionInfo;
|
||||
dev?: RemoteVersionInfo;
|
||||
}
|
||||
|
||||
// Memoized components for better performance
|
||||
const VersionInfoDialog = memo(
|
||||
({
|
||||
@@ -432,10 +413,7 @@ const getPlatform = (data: VersionData): string => {
|
||||
|
||||
const Version = () => {
|
||||
const { LL, locale } = useI18nContext();
|
||||
const { me } = useContext(AuthenticatedContext);
|
||||
|
||||
const [latestVersion, setLatestVersion] = useState<VersionInfo>();
|
||||
const [latestDevVersion, setLatestDevVersion] = useState<VersionInfo>();
|
||||
const { me, versions } = useContext(AuthenticatedContext);
|
||||
|
||||
const [restarting, setRestarting] = useState<boolean>(false);
|
||||
const [openInstallDialog, setOpenInstallDialog] = useState<boolean>(false);
|
||||
@@ -447,16 +425,30 @@ const Version = () => {
|
||||
const [openInstallPartitionDialog, setOpenInstallPartitionDialog] =
|
||||
useState<boolean>(false);
|
||||
|
||||
const [usingDevVersion, setUsingDevVersion] = useState<boolean>(false);
|
||||
const [fetchDevVersion, setFetchDevVersion] = useState<boolean>(false);
|
||||
const [devUpgradeAvailable, setDevUpgradeAvailable] = useState<boolean>(false);
|
||||
const [stableUpgradeAvailable, setStableUpgradeAvailable] =
|
||||
useState<boolean>(false);
|
||||
const [internetLive, setInternetLive] = useState<boolean>(false);
|
||||
const [downloadOnly, setDownloadOnly] = useState<boolean>(false);
|
||||
const [showVersionInfo, setShowVersionInfo] = useState<number>(0); // 1 = stable, 2 = dev, 3 = partition
|
||||
const [firmwareSize, setFirmwareSize] = useState<number>(0);
|
||||
|
||||
const latestVersion = useMemo<VersionInfo | undefined>(
|
||||
() =>
|
||||
versions?.stable
|
||||
? { version: versions.stable.version, date: versions.stable.date }
|
||||
: undefined,
|
||||
[versions?.stable]
|
||||
);
|
||||
const latestDevVersion = useMemo<VersionInfo | undefined>(
|
||||
() =>
|
||||
versions?.dev
|
||||
? { version: versions.dev.version, date: versions.dev.date }
|
||||
: undefined,
|
||||
[versions?.dev]
|
||||
);
|
||||
const usingDevVersion = versions?.current?.type === 'dev';
|
||||
const stableUpgradeAvailable = versions?.stable?.upgradeable ?? false;
|
||||
const devUpgradeAvailable = versions?.dev?.upgradeable ?? false;
|
||||
const internetLive = Boolean(versions?.stable || versions?.dev);
|
||||
|
||||
const { send: sendSetPartition } = useRequest(
|
||||
(partition: string) => callAction({ action: 'setPartition', param: partition }),
|
||||
{ immediate: false }
|
||||
@@ -480,32 +472,6 @@ const Version = () => {
|
||||
{ immediate: false }
|
||||
);
|
||||
|
||||
// fetch latest stable/dev versions via the device. The C++ code makes a call to emsesp.org/versions.json itself
|
||||
// if the device has no internet, stable/dev are omitted and the internetLive flag is set to false
|
||||
useRequest(() => callAction({ action: 'getVersions' }))
|
||||
.onSuccess((event) => {
|
||||
const versions = event.data as VersionsResponse;
|
||||
setUsingDevVersion(versions.current?.type === 'dev');
|
||||
if (versions.stable) {
|
||||
setLatestVersion({
|
||||
version: versions.stable.version,
|
||||
date: versions.stable.date
|
||||
});
|
||||
setStableUpgradeAvailable(versions.stable.upgradeable);
|
||||
}
|
||||
if (versions.dev) {
|
||||
setLatestDevVersion({
|
||||
version: versions.dev.version,
|
||||
date: versions.dev.date
|
||||
});
|
||||
setDevUpgradeAvailable(versions.dev.upgradeable);
|
||||
}
|
||||
setInternetLive(Boolean(versions.stable || versions.dev));
|
||||
})
|
||||
.onError(() => {
|
||||
setInternetLive(false);
|
||||
});
|
||||
|
||||
const { send: sendAPI } = useRequest((data: APIcall) => API(data), {
|
||||
immediate: false
|
||||
});
|
||||
@@ -640,15 +606,33 @@ const Version = () => {
|
||||
|
||||
if (!me.admin) return null;
|
||||
|
||||
const isUpdateAvailable = choice === LL.UPDATE_AVAILABLE();
|
||||
|
||||
return (
|
||||
<Button
|
||||
sx={{ ml: 1 }}
|
||||
variant="outlined"
|
||||
color={choice === LL.UPDATE_AVAILABLE() ? 'success' : 'warning'}
|
||||
color={isUpdateAvailable ? 'success' : 'warning'}
|
||||
size="small"
|
||||
onClick={() => showFirmwareDialog(showingDev)}
|
||||
>
|
||||
{choice}
|
||||
{isUpdateAvailable && (
|
||||
<Box
|
||||
component="span"
|
||||
aria-label="update available"
|
||||
sx={{
|
||||
display: 'inline-block',
|
||||
width: 8,
|
||||
height: 8,
|
||||
ml: 1,
|
||||
verticalAlign: 'middle',
|
||||
borderRadius: '50%',
|
||||
backgroundColor: '#ffeb3b',
|
||||
boxShadow: '0 0 6px rgba(255, 235, 59, 0.8)'
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -18,10 +18,12 @@ import { AuthenticatedContext } from 'contexts/authentication';
|
||||
import { useI18nContext } from 'i18n/i18n-react';
|
||||
|
||||
const LayoutMenuComponent = () => {
|
||||
const { me } = useContext(AuthenticatedContext);
|
||||
const { me, versions } = useContext(AuthenticatedContext);
|
||||
const { LL } = useI18nContext();
|
||||
const [menuOpen, setMenuOpen] = useState(true);
|
||||
|
||||
const upgradeAvailable = versions?.current?.upgradeable ?? false;
|
||||
|
||||
const handleMenuToggle = () => {
|
||||
setMenuOpen((prev) => !prev);
|
||||
};
|
||||
@@ -105,6 +107,7 @@ const LayoutMenuComponent = () => {
|
||||
label={LL.SETTINGS(0)}
|
||||
disabled={!me.admin}
|
||||
to="/settings"
|
||||
badge={upgradeAvailable}
|
||||
/>
|
||||
<LayoutMenuItem icon={LiveHelpIcon} label={LL.HELP()} to={`/help`} />
|
||||
<Divider />
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { memo } from 'react';
|
||||
import { Link, useLocation } from 'react-router';
|
||||
|
||||
import { ListItemButton, ListItemIcon, ListItemText } from '@mui/material';
|
||||
import { Box, ListItemButton, ListItemIcon, ListItemText } from '@mui/material';
|
||||
import type { SvgIconProps, SxProps, Theme } from '@mui/material';
|
||||
|
||||
import { routeMatches } from 'utils';
|
||||
@@ -11,13 +11,15 @@ interface LayoutMenuItemProps {
|
||||
label: string;
|
||||
to: string;
|
||||
disabled?: boolean;
|
||||
badge?: boolean;
|
||||
}
|
||||
|
||||
const LayoutMenuItemComponent = ({
|
||||
icon: Icon,
|
||||
label,
|
||||
to,
|
||||
disabled
|
||||
disabled,
|
||||
badge
|
||||
}: LayoutMenuItemProps) => {
|
||||
const { pathname } = useLocation();
|
||||
|
||||
@@ -68,6 +70,20 @@ const LayoutMenuItemComponent = ({
|
||||
<Icon />
|
||||
</ListItemIcon>
|
||||
<ListItemText sx={textStyles}>{label}</ListItemText>
|
||||
{badge && (
|
||||
<Box
|
||||
aria-label="update available"
|
||||
sx={{
|
||||
width: 8,
|
||||
height: 8,
|
||||
ml: 1,
|
||||
borderRadius: '50%',
|
||||
backgroundColor: '#ffeb3b',
|
||||
boxShadow: '0 0 6px rgba(255, 235, 59, 0.8)',
|
||||
flexShrink: 0
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</ListItemButton>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -5,6 +5,7 @@ import { Link } from 'react-router';
|
||||
import NavigateNextIcon from '@mui/icons-material/NavigateNext';
|
||||
import {
|
||||
Avatar,
|
||||
Box,
|
||||
ListItem,
|
||||
ListItemAvatar,
|
||||
ListItemButton,
|
||||
@@ -20,6 +21,7 @@ interface ListMenuItemProps {
|
||||
text: string;
|
||||
to?: string;
|
||||
disabled?: boolean;
|
||||
badge?: boolean;
|
||||
}
|
||||
|
||||
const iconStyles: CSSProperties = {
|
||||
@@ -28,15 +30,40 @@ const iconStyles: CSSProperties = {
|
||||
verticalAlign: 'middle'
|
||||
};
|
||||
|
||||
const Badge = () => (
|
||||
<Box
|
||||
component="span"
|
||||
aria-label="update available"
|
||||
sx={{
|
||||
display: 'inline-block',
|
||||
width: 8,
|
||||
height: 8,
|
||||
ml: 1,
|
||||
verticalAlign: 'middle',
|
||||
borderRadius: '50%',
|
||||
backgroundColor: '#ffeb3b',
|
||||
boxShadow: '0 0 6px rgba(255, 235, 59, 0.8)'
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
const RenderIcon = memo(
|
||||
({ icon: Icon, bgcolor, label, text }: ListMenuItemProps) => (
|
||||
({ icon: Icon, bgcolor, label, text, badge }: ListMenuItemProps) => (
|
||||
<>
|
||||
<ListItemAvatar>
|
||||
<Avatar sx={{ bgcolor, color: 'white' }}>
|
||||
<Icon />
|
||||
</Avatar>
|
||||
</ListItemAvatar>
|
||||
<ListItemText primary={label} secondary={text} />
|
||||
<ListItemText
|
||||
primary={
|
||||
<>
|
||||
{label}
|
||||
{badge && <Badge />}
|
||||
</>
|
||||
}
|
||||
secondary={text}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
);
|
||||
@@ -47,7 +74,8 @@ const LayoutMenuItem = ({
|
||||
label,
|
||||
text,
|
||||
to,
|
||||
disabled
|
||||
disabled,
|
||||
badge
|
||||
}: ListMenuItemProps) => (
|
||||
<>
|
||||
{to && !disabled ? (
|
||||
@@ -65,6 +93,7 @@ const LayoutMenuItem = ({
|
||||
{...(bgcolor && { bgcolor })}
|
||||
label={label}
|
||||
text={text}
|
||||
{...(badge && { badge })}
|
||||
/>
|
||||
</ListItemButton>
|
||||
</ListItem>
|
||||
@@ -75,6 +104,7 @@ const LayoutMenuItem = ({
|
||||
{...(bgcolor && { bgcolor })}
|
||||
label={label}
|
||||
text={text}
|
||||
{...(badge && { badge })}
|
||||
/>
|
||||
</ListItem>
|
||||
)}
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { FC } from 'react';
|
||||
import { redirect } from 'react-router';
|
||||
import { toast } from 'react-toastify';
|
||||
|
||||
import { callAction } from 'api/app';
|
||||
import { ACCESS_TOKEN } from 'api/endpoints';
|
||||
|
||||
import * as AuthenticationApi from 'components/routing/authentication';
|
||||
@@ -10,7 +11,7 @@ import { useRequest } from 'alova/client';
|
||||
import { LoadingSpinner } from 'components';
|
||||
import { verifyAuthorization } from 'components/routing/authentication';
|
||||
import { useI18nContext } from 'i18n/i18n-react';
|
||||
import type { Me } from 'types';
|
||||
import type { Me, VersionsResponse } from 'types';
|
||||
import type { RequiredChildrenProps } from 'utils';
|
||||
|
||||
import { AuthenticationContext } from './context';
|
||||
@@ -20,17 +21,34 @@ const Authentication: FC<RequiredChildrenProps> = ({ children }) => {
|
||||
|
||||
const [initialized, setInitialized] = useState<boolean>(false);
|
||||
const [me, setMe] = useState<Me>();
|
||||
const [versions, setVersions] = useState<VersionsResponse>();
|
||||
|
||||
const { send: sendVerifyAuthorization } = useRequest(verifyAuthorization(), {
|
||||
immediate: false
|
||||
});
|
||||
|
||||
const { send: sendGetVersions } = useRequest(
|
||||
() => callAction({ action: 'getVersions' }),
|
||||
{ immediate: false }
|
||||
)
|
||||
.onSuccess((event) => {
|
||||
setVersions(event.data as VersionsResponse);
|
||||
})
|
||||
.onError(() => {
|
||||
setVersions(undefined);
|
||||
});
|
||||
|
||||
const refreshVersions = useCallback(async () => {
|
||||
await sendGetVersions().catch(() => undefined);
|
||||
}, []);
|
||||
|
||||
const signIn = (accessToken: string) => {
|
||||
try {
|
||||
AuthenticationApi.getStorage().setItem(ACCESS_TOKEN, accessToken);
|
||||
const decodedMe = AuthenticationApi.decodeMeJWT(accessToken);
|
||||
setMe(decodedMe);
|
||||
toast.success(LL.LOGGED_IN({ name: decodedMe.username }));
|
||||
void refreshVersions();
|
||||
} catch {
|
||||
setMe(undefined);
|
||||
throw new Error('Failed to parse JWT');
|
||||
@@ -40,6 +58,7 @@ const Authentication: FC<RequiredChildrenProps> = ({ children }) => {
|
||||
const signOut = (doRedirect: boolean) => {
|
||||
AuthenticationApi.clearAccessToken();
|
||||
setMe(undefined);
|
||||
setVersions(undefined);
|
||||
if (doRedirect) {
|
||||
redirect('/');
|
||||
}
|
||||
@@ -49,8 +68,9 @@ const Authentication: FC<RequiredChildrenProps> = ({ children }) => {
|
||||
const accessToken = AuthenticationApi.getStorage().getItem(ACCESS_TOKEN);
|
||||
if (accessToken) {
|
||||
await sendVerifyAuthorization()
|
||||
.then(() => {
|
||||
.then(async () => {
|
||||
setMe(AuthenticationApi.decodeMeJWT(accessToken));
|
||||
await refreshVersions();
|
||||
setInitialized(true);
|
||||
})
|
||||
.catch(() => {
|
||||
@@ -61,6 +81,8 @@ const Authentication: FC<RequiredChildrenProps> = ({ children }) => {
|
||||
setMe(undefined);
|
||||
setInitialized(true);
|
||||
}
|
||||
// refreshVersions and sendVerifyAuthorization are stable
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -72,9 +94,11 @@ const Authentication: FC<RequiredChildrenProps> = ({ children }) => {
|
||||
signIn,
|
||||
signOut,
|
||||
refresh,
|
||||
...(me && { me })
|
||||
refreshVersions,
|
||||
...(me && { me }),
|
||||
...(versions && { versions })
|
||||
}),
|
||||
[signIn, signOut, me, refresh]
|
||||
[signIn, signOut, me, refresh, refreshVersions, versions]
|
||||
);
|
||||
|
||||
if (initialized) {
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import { createContext } from 'react';
|
||||
|
||||
import type { Me } from 'types';
|
||||
import type { Me, VersionsResponse } from 'types';
|
||||
|
||||
export interface AuthenticationContextValue {
|
||||
refresh: () => Promise<void>;
|
||||
signIn: (accessToken: string) => void;
|
||||
signOut: (redirect: boolean) => void;
|
||||
me?: Me;
|
||||
versions?: VersionsResponse;
|
||||
refreshVersions: () => Promise<void>;
|
||||
}
|
||||
|
||||
const AuthenticationContextDefaultValue = {} as AuthenticationContextValue;
|
||||
|
||||
@@ -7,3 +7,4 @@ export * from './ntp';
|
||||
export * from './security';
|
||||
export * from './signin';
|
||||
export * from './system';
|
||||
export * from './versions';
|
||||
|
||||
23
interface/src/types/versions.ts
Normal file
23
interface/src/types/versions.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
// Types for the `getVersions` action response coming from the device.
|
||||
// The device proxies the request to emsesp.org/versions.json. If the device
|
||||
// is offline the `stable` and `dev` fields are omitted.
|
||||
|
||||
export interface VersionInfo {
|
||||
version: string;
|
||||
date: string;
|
||||
}
|
||||
|
||||
export interface RemoteVersionInfo extends VersionInfo {
|
||||
upgradeable: boolean;
|
||||
}
|
||||
|
||||
export interface CurrentVersionInfo extends VersionInfo {
|
||||
type: 'stable' | 'dev';
|
||||
upgradeable: boolean;
|
||||
}
|
||||
|
||||
export interface VersionsResponse {
|
||||
current: CurrentVersionInfo;
|
||||
stable?: RemoteVersionInfo;
|
||||
dev?: RemoteVersionInfo;
|
||||
}
|
||||
@@ -144,10 +144,10 @@ let LATEST_STABLE_VERSION = '3.8.2';
|
||||
let LATEST_DEV_VERSION = '3.8.3-dev.2';
|
||||
|
||||
// scenarios for testing versioning
|
||||
let version_test = 0; // on latest stable, or switch to dev
|
||||
// let version_test = 0; // on latest stable, or switch to dev
|
||||
// let version_test = 1; // on latest dev, or switch back to stable
|
||||
// let version_test = 2; // upgrade an older stable to latest stable or switch to latest dev
|
||||
// let version_test = 3; // upgrade dev to latest, or switch to stable
|
||||
let version_test = 3; // upgrade dev to latest, or switch to stable
|
||||
// let version_test = 4; // downgrade to an older dev, or switch back to stable
|
||||
|
||||
switch (version_test as number) {
|
||||
@@ -419,15 +419,25 @@ function upgradeImportantMessages(version: string) {
|
||||
const MOCK_OFFLINE = false;
|
||||
function get_versions() {
|
||||
const isDev = THIS_VERSION.includes('dev');
|
||||
const currentUpgradeable =
|
||||
!MOCK_OFFLINE &&
|
||||
(isDev ? DEV_VERSION_IS_UPGRADEABLE : STABLE_VERSION_IS_UPGRADEABLE);
|
||||
|
||||
const data: {
|
||||
current: { version: string; type: 'stable' | 'dev'; date: string };
|
||||
current: {
|
||||
version: string;
|
||||
type: 'stable' | 'dev';
|
||||
date: string;
|
||||
upgradeable: boolean;
|
||||
};
|
||||
stable?: { version: string; date: string; upgradeable: boolean };
|
||||
dev?: { version: string; date: string; upgradeable: boolean };
|
||||
} = {
|
||||
current: {
|
||||
version: THIS_VERSION,
|
||||
type: isDev ? 'dev' : 'stable',
|
||||
date: '2026-04-25T12:00:00'
|
||||
date: '2026-04-25T12:00:00',
|
||||
upgradeable: currentUpgradeable
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user