mirror of
https://github.com/cpvalente/ontime.git
synced 2026-08-17 21:24:11 +00:00
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:
@@ -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) };
|
||||
}),
|
||||
}));
|
||||
Reference in New Issue
Block a user