mirror of
https://github.com/cpvalente/ontime.git
synced 2026-08-19 14:14:17 +00:00
feat: css editor (#1542)
* feat: allow editing CSS from settings --------- Co-authored-by: Carlos Valente <carlosvalente@pm.me> refactor: style tweaks
This commit is contained in:
committed by
Carlos Valente
parent
ddd629d7db
commit
6817263123
@@ -0,0 +1,30 @@
|
||||
import axios from 'axios';
|
||||
|
||||
import { apiEntryUrl } from './constants';
|
||||
|
||||
const assetsPath = `${apiEntryUrl}/assets`;
|
||||
|
||||
/**
|
||||
* HTTP request to get css contents
|
||||
*/
|
||||
export async function getCSSContents(): Promise<string> {
|
||||
const res = await axios.get(`${assetsPath}/css`);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* HTTP request to post css contents
|
||||
*/
|
||||
export async function postCSSContents(css: string): Promise<void> {
|
||||
await axios.post(`${assetsPath}/css`, {
|
||||
css,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* HTTP request to restore default css
|
||||
*/
|
||||
export async function restoreCSSContents(): Promise<string> {
|
||||
const res = await axios.post(`${assetsPath}/css/restore`);
|
||||
return res.data;
|
||||
}
|
||||
@@ -207,6 +207,9 @@ $inner-padding: 1rem;
|
||||
&.end {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
&.apart {
|
||||
justify-content: space-between;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes animloader {
|
||||
|
||||
@@ -113,8 +113,8 @@ export function BlockQuote({ children }: { children: ReactNode }) {
|
||||
return <blockquote className={style.blockquote}>{children}</blockquote>;
|
||||
}
|
||||
|
||||
export function Error({ children }: { children: ReactNode }) {
|
||||
return <div className={style.fieldError}>{children}</div>;
|
||||
export function Error({ children, className }: { children: ReactNode } & JSX.IntrinsicElements['div']) {
|
||||
return <div className={cx([style.fieldError, className])}>{children}</div>;
|
||||
}
|
||||
|
||||
export function Divider() {
|
||||
@@ -136,7 +136,7 @@ type AllowedInlineTags = 'div' | 'td';
|
||||
type InlineProps<C extends AllowedInlineTags> = {
|
||||
as?: C;
|
||||
relation?: 'inner' | 'component' | 'section';
|
||||
align?: 'start' | 'end';
|
||||
align?: 'start' | 'end' | 'apart';
|
||||
className?: string;
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
.wrapper {
|
||||
max-height: 500px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import { forwardRef, memo, useEffect, useImperativeHandle, useState } from 'react';
|
||||
import Editor from 'react-simple-code-editor';
|
||||
import Prism from 'prismjs/components/prism-core';
|
||||
|
||||
import 'prismjs/components/prism-css';
|
||||
import 'prismjs/themes/prism-tomorrow.min.css';
|
||||
import style from './StyleEditor.module.scss';
|
||||
|
||||
interface CodeEditorProps {
|
||||
language: string;
|
||||
initialValue: string;
|
||||
isDirty: boolean;
|
||||
setIsDirty: (value: boolean) => void;
|
||||
}
|
||||
|
||||
const CodeEditor = forwardRef((props: CodeEditorProps, cssRef) => {
|
||||
const { language, initialValue, isDirty, setIsDirty } = props;
|
||||
|
||||
const [code, setCode] = useState(initialValue);
|
||||
|
||||
const highlight = (code: string) => {
|
||||
const grammar = Prism.languages[language];
|
||||
return grammar ? Prism.highlight(code, grammar, language) : code;
|
||||
};
|
||||
|
||||
const handleChange = (newCode: string) => {
|
||||
setCode(newCode);
|
||||
};
|
||||
|
||||
useImperativeHandle(cssRef, () => {
|
||||
return {
|
||||
getCss: () => code,
|
||||
};
|
||||
});
|
||||
|
||||
// add contents to editor on mount and any change in initialValue
|
||||
useEffect(() => {
|
||||
setCode(initialValue);
|
||||
}, [initialValue]);
|
||||
|
||||
// handle dirty state on change
|
||||
useEffect(() => {
|
||||
if (initialValue.trim() !== code.trim() && !isDirty && code.length !== 0) {
|
||||
setIsDirty(true);
|
||||
}
|
||||
|
||||
if (initialValue.trim() === code.trim() && isDirty) {
|
||||
setIsDirty(false);
|
||||
}
|
||||
}, [initialValue, code, isDirty, setIsDirty]);
|
||||
|
||||
return (
|
||||
<div className={style.wrapper}>
|
||||
<Editor
|
||||
value={code}
|
||||
padding={15}
|
||||
onValueChange={handleChange}
|
||||
highlight={highlight}
|
||||
style={{
|
||||
fontFamily: 'monospace',
|
||||
fontSize: 12,
|
||||
minHeight: 500,
|
||||
background: '#2d2d2d', // Background of tomorrow theme
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
CodeEditor.displayName = 'StyleEditor';
|
||||
|
||||
export default memo(CodeEditor);
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
.editorActions {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.right {
|
||||
align-self: end;
|
||||
text-align: right
|
||||
}
|
||||
|
||||
.column {
|
||||
align-items: start;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
import { lazy, useEffect, useRef, useState } from 'react';
|
||||
import {
|
||||
Button,
|
||||
Modal,
|
||||
ModalBody,
|
||||
ModalCloseButton,
|
||||
ModalContent,
|
||||
ModalFooter,
|
||||
ModalHeader,
|
||||
ModalOverlay,
|
||||
} from '@chakra-ui/react';
|
||||
|
||||
import { getCSSContents, postCSSContents, restoreCSSContents } from '../../../../common/api/assets';
|
||||
import Info from '../../../../common/components/info/Info';
|
||||
import * as Panel from '../../panel-utils/PanelUtils';
|
||||
|
||||
import style from './StyleEditorModal.module.scss';
|
||||
|
||||
const CodeEditor = lazy(() => import('./StyleEditor'));
|
||||
|
||||
interface CodeEditorModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
interface CSSRef {
|
||||
getCss: () => string;
|
||||
}
|
||||
|
||||
export default function CodeEditorModal(props: CodeEditorModalProps) {
|
||||
const { isOpen, onClose } = props;
|
||||
|
||||
const [css, setCSS] = useState('');
|
||||
const [isDirty, setIsDirty] = useState(false);
|
||||
const [saveLoading, setSaveLoading] = useState(false);
|
||||
const [resetLoading, setResetLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const cssRef = useRef<CSSRef>(null);
|
||||
|
||||
const handleRestore = async () => {
|
||||
try {
|
||||
setResetLoading(true);
|
||||
const defaultCss = await restoreCSSContents();
|
||||
setCSS(defaultCss);
|
||||
} catch (_error) {
|
||||
/** no error handling for now */
|
||||
} finally {
|
||||
setResetLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
try {
|
||||
setSaveLoading(true);
|
||||
if (cssRef.current) {
|
||||
await postCSSContents(cssRef.current.getCss());
|
||||
setCSS(cssRef.current.getCss());
|
||||
setIsDirty(false);
|
||||
}
|
||||
} catch (_error) {
|
||||
/** no error handling for now */
|
||||
} finally {
|
||||
setSaveLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const clear = () => setCSS('');
|
||||
|
||||
useEffect(() => {
|
||||
async function fetchServerCSS() {
|
||||
// check for isOpen to fetch recent css
|
||||
if (isOpen) {
|
||||
try {
|
||||
const css = await getCSSContents();
|
||||
setCSS(css);
|
||||
} catch (_error) {
|
||||
setError('Failed to load CSS from server');
|
||||
/** no error handling for now */
|
||||
}
|
||||
}
|
||||
}
|
||||
fetchServerCSS();
|
||||
}, [isOpen]);
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} onClose={onClose} variant='ontime' isCentered>
|
||||
<ModalOverlay />
|
||||
<ModalContent maxWidth='max(800px, 40vw)'>
|
||||
<ModalHeader>Edit CSS override</ModalHeader>
|
||||
<ModalCloseButton />
|
||||
<ModalBody>
|
||||
<CodeEditor ref={cssRef} initialValue={css} language='css' isDirty={isDirty} setIsDirty={setIsDirty} />
|
||||
</ModalBody>
|
||||
|
||||
<ModalFooter className={style.column}>
|
||||
<Info>Invalid CSS will be refused by the browser</Info>
|
||||
{error && <Panel.Error className={style.right}>{`Error: ${error}`}</Panel.Error>}
|
||||
<Panel.InlineElements align='apart' className={style.editorActions}>
|
||||
<Button
|
||||
variant='ontime-ghosted'
|
||||
onClick={handleRestore}
|
||||
isDisabled={saveLoading || resetLoading}
|
||||
isLoading={resetLoading}
|
||||
>
|
||||
Reset to example
|
||||
</Button>
|
||||
<Panel.InlineElements>
|
||||
<Button variant='ontime-ghosted' onClick={clear}>
|
||||
Clear
|
||||
</Button>
|
||||
<Button variant='ontime-subtle' onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant='ontime-filled'
|
||||
onClick={handleSave}
|
||||
isDisabled={saveLoading || resetLoading || !isDirty}
|
||||
isLoading={saveLoading}
|
||||
>
|
||||
Save changes
|
||||
</Button>
|
||||
</Panel.InlineElements>
|
||||
</Panel.InlineElements>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect } from 'react';
|
||||
import { Controller, useForm } from 'react-hook-form';
|
||||
import { Button, Input, Switch } from '@chakra-ui/react';
|
||||
import { Button, Input, Switch, useDisclosure } from '@chakra-ui/react';
|
||||
import { ViewSettings } from 'ontime-types';
|
||||
|
||||
import { maybeAxiosError } from '../../../../common/api/utils';
|
||||
@@ -14,11 +14,14 @@ import { preventEscape } from '../../../../common/utils/keyEvent';
|
||||
import { isOntimeCloud } from '../../../../externals';
|
||||
import * as Panel from '../../panel-utils/PanelUtils';
|
||||
|
||||
import CodeEditorModal from './StyleEditorModal';
|
||||
|
||||
const cssOverrideDocsUrl = 'https://docs.getontime.no/features/custom-styling/';
|
||||
|
||||
export default function ViewSettingsForm() {
|
||||
const { data, status, refetch } = useViewSettings();
|
||||
const { data: info, status: infoStatus } = useInfo();
|
||||
const { isOpen: isCodeEditorOpen, onOpen: onCodeEditorOpen, onClose: onCodeEditorClose } = useDisclosure();
|
||||
|
||||
const {
|
||||
control,
|
||||
@@ -103,6 +106,7 @@ export default function ViewSettingsForm() {
|
||||
<Panel.Section>
|
||||
<Panel.Loader isLoading={isLoading} />
|
||||
<Panel.ListGroup>
|
||||
<CodeEditorModal isOpen={isCodeEditorOpen} onClose={onCodeEditorClose} />
|
||||
<Panel.ListItem>
|
||||
<Panel.Field
|
||||
title='Override CSS styles'
|
||||
@@ -115,6 +119,15 @@ export default function ViewSettingsForm() {
|
||||
<Switch variant='ontime' size='lg' isChecked={value} onChange={onChange} ref={ref} />
|
||||
)}
|
||||
/>
|
||||
<Button
|
||||
onClick={onCodeEditorOpen}
|
||||
variant='ontime-subtle'
|
||||
size='sm'
|
||||
isDisabled={!data.overrideStyles}
|
||||
width='fit-content'
|
||||
>
|
||||
Edit CSS override
|
||||
</Button>
|
||||
</Panel.ListItem>
|
||||
</Panel.ListGroup>
|
||||
<Panel.ListGroup>
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
declare module 'prismjs/components/prism-core' {
|
||||
export * from 'prismjs';
|
||||
}
|
||||
|
||||
declare module 'prismjs/components/prism-css';
|
||||
Reference in New Issue
Block a user