feat: app settings (#658)

* feat: app settings

* refactor: cleanup routes

* style: smaller base font

* chore: migrate about modal

* fix: import links
This commit is contained in:
Carlos Valente
2023-12-25 21:25:09 +01:00
committed by GitHub
parent 6ffc314513
commit a463cb491b
24 changed files with 666 additions and 184 deletions
@@ -0,0 +1,13 @@
@use '../../theme/ontimeColours' as *;
@use '../../theme/v2Styles' as *;
.container {
grid-area: main;
width: 100%;
padding: 1rem;
display: flex;
gap: 0.25rem;
overflow: hidden;
}
@@ -0,0 +1,29 @@
import { ErrorBoundary } from '@sentry/react';
import { useKeyDown } from '../../common/hooks/useKeyDown';
import AboutPanel from './panel/about-panel/AboutPanel';
import PanelContent from './panel-content/PanelContent';
import PanelList from './panel-list/PanelList';
import { useSettingsStore } from './settingsStore';
import style from './AppSettings.module.scss';
export default function AppSettings() {
const setShowSettings = useSettingsStore((state) => state.setShowSettings);
const selectedPanel = useSettingsStore((state) => state.showSettings);
const closeSettings = () => {
setShowSettings(null);
};
useKeyDown(closeSettings, 'Escape');
return (
<div className={style.container}>
<ErrorBoundary>
<PanelList />
<PanelContent onClose={closeSettings}>{selectedPanel === 'about' && <AboutPanel />}</PanelContent>
</ErrorBoundary>
</div>
);
}
@@ -0,0 +1,20 @@
.corner {
position: absolute;
top: 1rem;
right: 1rem;
}
.contentWrapper {
display: flex;
flex-direction: column;
height: 100%;
width: 100%;
position: relative;
}
.content {
margin: 1rem;
overflow-y: auto;
flex-grow: 1;
}
@@ -0,0 +1,22 @@
import { PropsWithChildren } from 'react';
import { IconButton } from '@chakra-ui/react';
import { IoClose } from '@react-icons/all-files/io5/IoClose';
import style from './PanelContent.module.scss';
interface PanelContentProps {
onClose: () => void;
}
export default function PanelContent(props: PropsWithChildren<PanelContentProps>) {
const { onClose, children } = props;
return (
<div className={style.contentWrapper}>
<div className={style.corner}>
<IconButton onClick={onClose} aria-label='close' icon={<IoClose />} variant='ontime-ghosted-white' />
</div>
<div className={style.content}>{children}</div>
</div>
);
}
@@ -0,0 +1,64 @@
@use '../../../theme/ontimeColours' as *;
@use '../../../theme/v2Styles' as *;
.tabs,
ul {
list-style: none;
padding: 0;
margin: 0;
}
.tabs {
width: min(30vw, 300px);
display: flex;
flex-direction: column;
overflow-y: auto;
}
.primary,
.secondary {
padding: 0.25rem 1rem;
margin-right: 1rem;
&:focus {
background-color: $gray-1000;
outline: 0;
}
&:hover {
background-color: $gray-1000;
cursor: pointer;
}
}
.primary {
font-size: 1rem;
border-radius: 2px;
display: flex;
align-items: center;
gap: 0.5rem;
&.active {
color: $blue-400;
background-color: $gray-1100;
}
&.unsaved::before {
content: '';
width: 6px;
height: 6px;
border-radius: 3px;
background-color: $blue-400;
}
&.split {
margin-top: 1rem;
}
}
.secondary {
margin-left: 1rem;
color: $secondary-text-gray;
border-left: 1px solid $white-10;
font-size: $inner-section-text-size;
}
@@ -0,0 +1,55 @@
import { KeyboardEvent } from 'react';
import { cx } from '../../../common/utils/styleUtils';
import { settingPanels, SettingsOption, useSettingsStore } from '../settingsStore';
import style from './PanelList.module.scss';
export default function PanelList() {
const { showSettings, setShowSettings, hasUnsavedChanges } = useSettingsStore();
const handleSelect = (panel: SettingsOption) => {
setShowSettings(panel.id);
};
const isKeyEnter = (event: KeyboardEvent<HTMLLIElement>) => event.key === 'Enter';
return (
<ul className={style.tabs}>
{settingPanels.map((panel) => {
const unsaved = hasUnsavedChanges(panel.id);
const classes = cx([
style.primary,
showSettings === panel.id ? style.active : null,
panel.split ? style.split : null,
unsaved ? style.unsaved : null,
]);
return (
<>
<li
key={panel.id}
onClick={() => handleSelect(panel)}
onKeyDown={(event) => {
isKeyEnter(event) && handleSelect(panel);
}}
className={classes}
tabIndex={0}
role='button'
>
{panel.label}
</li>
{panel.secondary?.map((secondary) => {
return (
<li key={secondary.id} onClick={() => handleSelect(panel)} className={style.secondary}>
{secondary.label}
</li>
);
})}
</>
);
})}
</ul>
);
}
@@ -0,0 +1,37 @@
@use '../../../theme/ontimeColours' as *;
@use '../../../theme/v2Styles' as *;
.header {
font-size: 2rem;
padding-bottom: 0.5rem;
border-bottom: 1px solid $white-10;
font-weight: 600;
}
.subheader {
font-size: 1.25rem;
font-weight: 600;
}
.section {
margin: 2rem 0;
font-size: calc(1rem - 1px);
max-width: 800px;
}
.paragraph {
padding: 0.5rem 0;
}
.card {
padding: 1rem;
background-color: $white-1;
border: 1px solid $gray-1100;
border-radius: 0.25rem;
}
.error {
font-size: $inner-section-text-size;
display: block;
color: $error-red;
}
@@ -0,0 +1,23 @@
import { ReactNode } from 'react';
import style from './Panel.module.scss';
export function Header({ children }: { children: ReactNode }) {
return <h2 className={style.header}>{children}</h2>;
}
export function SubHeader({ children }: { children: ReactNode }) {
return <h3 className={style.subheader}>{children}</h3>;
}
export function Section({ children }: { children: ReactNode }) {
return <p className={style.section}>{children}</p>;
}
export function Paragraph({ children }: { children: ReactNode }) {
return <p className={style.paragraph}>{children}</p>;
}
export function Card({ children }: { children: ReactNode }) {
return <div className={style.card}>{children}</div>;
}
@@ -0,0 +1,35 @@
import { version } from '../../../../../package.json';
import ExternalLink from '../../../../common/components/external-link/ExternalLink';
import { gitbookUrl, githubUrl, websiteUrl } from '../../../../externals';
import * as Panel from '../PanelUtils';
import CheckUpdatesButton from './CheckUpdatesButton';
export default function AboutPanel() {
return (
<>
<Panel.Header>About Ontime</Panel.Header>
<Panel.Section>
<Panel.SubHeader>Ontime</Panel.SubHeader>
<Panel.Paragraph>
Free, open-source software for managing rundowns and event timers
<ExternalLink href={websiteUrl}>www.getontime.no</ExternalLink>
</Panel.Paragraph>
</Panel.Section>
<Panel.Section>
<Panel.Card>
<Panel.SubHeader>Links</Panel.SubHeader>
<ExternalLink href={gitbookUrl}>Read the docs over at GitBook</ExternalLink>
<ExternalLink href={githubUrl}>Follow the project on GitHub</ExternalLink>
</Panel.Card>
</Panel.Section>
<Panel.Section>
<Panel.Card>
<Panel.SubHeader>Current version</Panel.SubHeader>
<Panel.Paragraph>{`You are currently using Ontime ${version}`}</Panel.Paragraph>
<CheckUpdatesButton version={version} />
</Panel.Card>
</Panel.Section>
</>
);
}
@@ -0,0 +1,73 @@
import { useState } from 'react';
import { Button } from '@chakra-ui/react';
import { getLatestVersion, HasUpdate } from '../../../../common/api/ontimeApi';
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<CheckRemote | null>(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 (
<>
<Button onClick={versionCheck} variant='ontime-filled' isLoading={isFetching} isDisabled={disableButton}>
Check for updates
</Button>
<ResolveUpdateMessage updateMessage={updateMessage} />
</>
);
}
function ResolveUpdateMessage(props: { updateMessage: CheckRemote | null }) {
const { updateMessage } = props;
if (updateMessage && 'error' in updateMessage) {
return <span className={style.error}>{updateMessage.error}</span>;
}
if (updateMessage && 'url' in updateMessage) {
return <ExternalLink href={updateMessage?.url}>{`New version available: ${updateMessage.version}`}</ExternalLink>;
}
return null;
}
@@ -0,0 +1,76 @@
import { create } from 'zustand';
export type SettingsOption = {
id: string;
label: string;
secondary?: SettingsOption[];
split?: boolean;
};
export const settingPanels: SettingsOption[] = [
{ id: 'project', label: 'Project' },
{ id: 'general', label: 'General' },
{ id: 'interface', label: 'Interface' },
{ id: 'views', label: 'Views' },
{
id: 'sources',
label: 'Data Sources',
secondary: [{ id: 'g-sheet', label: 'Sync with Google Sheet' }],
split: true,
},
{
id: 'integrations',
label: 'Integrations',
secondary: [
{ id: 'osc', label: 'OSC Integration' },
{ id: 'http', label: 'HTTP Integration' },
],
},
{ id: 'log', label: 'Log', split: true },
{
id: 'about',
label: 'About',
split: true,
secondary: [
{ id: 'links', label: 'Links' },
{ id: 'version', label: 'Version' },
],
},
] as const;
export type SettingsOptionId = (typeof settingPanels)[number]['id'];
const firstPanel = settingPanels[0].id;
type SettingsStore = {
showSettings: SettingsOptionId | null;
setShowSettings: (panelId?: SettingsOptionId | null) => void;
unsavedChanges: Set<SettingsOptionId>;
hasUnsavedChanges: (panelId: SettingsOptionId) => boolean;
addUnsavedChanges: (panelId: SettingsOptionId) => void;
removeUnsavedChanges: (panelId: SettingsOptionId) => void;
};
export const useSettingsStore = create<SettingsStore>((set, get) => ({
showSettings: null,
setShowSettings: (panelId?: SettingsOptionId | null) => {
const newPanel = panelId === undefined ? firstPanel : panelId;
set((state) => {
return {
...state,
showSettings: newPanel,
};
});
},
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) };
}),
}));