diff --git a/apps/client/package.json b/apps/client/package.json index eb274d496..a0a15b443 100644 --- a/apps/client/package.json +++ b/apps/client/package.json @@ -23,6 +23,7 @@ "color": "^4.2.3", "csv-stringify": "^6.4.5", "framer-motion": "^10.10.0", + "prismjs": "^1.29.0", "react": "^18.3.1", "react-colorful": "^5.6.1", "react-dom": "^18.3.1", @@ -31,6 +32,7 @@ "react-icons": "5.4.0", "react-qr-code": "^2.0.12", "react-router-dom": "^6.3.0", + "react-simple-code-editor": "^0.14.1", "web-vitals": "^3.1.1", "zustand": "^5.0.3" }, @@ -65,6 +67,7 @@ "@sentry/vite-plugin": "^2.16.1", "@tanstack/eslint-plugin-query": "^5.8.4", "@types/color": "^3.0.3", + "@types/prismjs": "^1.26.5", "@types/react": "^18.0.26", "@types/react-dom": "^18.0.10", "@typescript-eslint/eslint-plugin": "catalog:", diff --git a/apps/client/src/common/api/assets.ts b/apps/client/src/common/api/assets.ts new file mode 100644 index 000000000..32203e66c --- /dev/null +++ b/apps/client/src/common/api/assets.ts @@ -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 { + const res = await axios.get(`${assetsPath}/css`); + return res.data; +} + +/** + * HTTP request to post css contents + */ +export async function postCSSContents(css: string): Promise { + await axios.post(`${assetsPath}/css`, { + css, + }); +} + +/** + * HTTP request to restore default css + */ +export async function restoreCSSContents(): Promise { + const res = await axios.post(`${assetsPath}/css/restore`); + return res.data; +} diff --git a/apps/client/src/features/app-settings/panel-utils/PanelUtils.module.scss b/apps/client/src/features/app-settings/panel-utils/PanelUtils.module.scss index 837433e14..f483657c6 100644 --- a/apps/client/src/features/app-settings/panel-utils/PanelUtils.module.scss +++ b/apps/client/src/features/app-settings/panel-utils/PanelUtils.module.scss @@ -207,6 +207,9 @@ $inner-padding: 1rem; &.end { justify-content: flex-end; } + &.apart { + justify-content: space-between; + } } @keyframes animloader { diff --git a/apps/client/src/features/app-settings/panel-utils/PanelUtils.tsx b/apps/client/src/features/app-settings/panel-utils/PanelUtils.tsx index b3201e58f..f69e6ca26 100644 --- a/apps/client/src/features/app-settings/panel-utils/PanelUtils.tsx +++ b/apps/client/src/features/app-settings/panel-utils/PanelUtils.tsx @@ -113,8 +113,8 @@ export function BlockQuote({ children }: { children: ReactNode }) { return
{children}
; } -export function Error({ children }: { children: ReactNode }) { - return
{children}
; +export function Error({ children, className }: { children: ReactNode } & JSX.IntrinsicElements['div']) { + return
{children}
; } export function Divider() { @@ -136,7 +136,7 @@ type AllowedInlineTags = 'div' | 'td'; type InlineProps = { as?: C; relation?: 'inner' | 'component' | 'section'; - align?: 'start' | 'end'; + align?: 'start' | 'end' | 'apart'; className?: string; }; diff --git a/apps/client/src/features/app-settings/panel/general-panel/StyleEditor.module.scss b/apps/client/src/features/app-settings/panel/general-panel/StyleEditor.module.scss new file mode 100644 index 000000000..891dda43b --- /dev/null +++ b/apps/client/src/features/app-settings/panel/general-panel/StyleEditor.module.scss @@ -0,0 +1,4 @@ +.wrapper { + max-height: 500px; + overflow-y: auto; +} diff --git a/apps/client/src/features/app-settings/panel/general-panel/StyleEditor.tsx b/apps/client/src/features/app-settings/panel/general-panel/StyleEditor.tsx new file mode 100644 index 000000000..ad14ae471 --- /dev/null +++ b/apps/client/src/features/app-settings/panel/general-panel/StyleEditor.tsx @@ -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 ( +
+ +
+ ); +}); + +CodeEditor.displayName = 'StyleEditor'; + +export default memo(CodeEditor); diff --git a/apps/client/src/features/app-settings/panel/general-panel/StyleEditorModal.module.scss b/apps/client/src/features/app-settings/panel/general-panel/StyleEditorModal.module.scss new file mode 100644 index 000000000..8eb0cb467 --- /dev/null +++ b/apps/client/src/features/app-settings/panel/general-panel/StyleEditorModal.module.scss @@ -0,0 +1,14 @@ +.editorActions { + width: 100%; +} + +.right { + align-self: end; + text-align: right +} + +.column { + align-items: start; + display: flex; + flex-direction: column; +} diff --git a/apps/client/src/features/app-settings/panel/general-panel/StyleEditorModal.tsx b/apps/client/src/features/app-settings/panel/general-panel/StyleEditorModal.tsx new file mode 100644 index 000000000..fbed556b3 --- /dev/null +++ b/apps/client/src/features/app-settings/panel/general-panel/StyleEditorModal.tsx @@ -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(null); + + const cssRef = useRef(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 ( + + + + Edit CSS override + + + + + + + Invalid CSS will be refused by the browser + {error && {`Error: ${error}`}} + + + + + + + + + + + + ); +} diff --git a/apps/client/src/features/app-settings/panel/general-panel/ViewSettingsForm.tsx b/apps/client/src/features/app-settings/panel/general-panel/ViewSettingsForm.tsx index 5709807b0..de277363e 100644 --- a/apps/client/src/features/app-settings/panel/general-panel/ViewSettingsForm.tsx +++ b/apps/client/src/features/app-settings/panel/general-panel/ViewSettingsForm.tsx @@ -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() { + )} /> + diff --git a/apps/client/src/features/app-settings/panel/general-panel/prismjs.d.ts b/apps/client/src/features/app-settings/panel/general-panel/prismjs.d.ts new file mode 100644 index 000000000..062aaf537 --- /dev/null +++ b/apps/client/src/features/app-settings/panel/general-panel/prismjs.d.ts @@ -0,0 +1,5 @@ +declare module 'prismjs/components/prism-core' { + export * from 'prismjs'; +} + +declare module 'prismjs/components/prism-css'; diff --git a/apps/server/package.json b/apps/server/package.json index 00fe7a337..f48bdebc0 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -52,6 +52,7 @@ "dev": "cross-env NODE_ENV=development tsx watch ./src/index.ts", "dev:inspect": "cross-env NODE_ENV=development tsx watch --inspect ./src/index.ts", "dev:test": "cross-env IS_TEST=true tsx ./src/index.ts", + "prebuild": "tsx ./scripts/bundleCss.ts", "build": "node esbuild.electron.js", "build:electron": "node esbuild.electron.js", "build:local": "node esbuild.dev.js", diff --git a/apps/server/scripts/bundleCss.ts b/apps/server/scripts/bundleCss.ts new file mode 100644 index 000000000..6b227e306 --- /dev/null +++ b/apps/server/scripts/bundleCss.ts @@ -0,0 +1,24 @@ +import { existsSync } from 'fs'; +import { writeFile } from 'node:fs/promises'; +import { defaultCss } from '../src/user/styles/bundledCss'; +import path from 'path'; + +/** + * Script to write contents of bundledCss to override.css + */ +async function bundleCss() { + try { + const stylesDir = path.resolve(process.cwd(), 'src', 'user', 'styles'); + const cssFile = path.resolve(stylesDir, 'override.css'); + + if (!existsSync(cssFile)) { + throw new Error('File does not exist'); + } + + await writeFile(cssFile, defaultCss, { encoding: 'utf8' }); + } catch (error) { + console.error('Failed writing to CSS file: ', error); + } +} + +bundleCss(); diff --git a/apps/server/src/api-data/assets/assets.controller.ts b/apps/server/src/api-data/assets/assets.controller.ts new file mode 100644 index 000000000..90b46244a --- /dev/null +++ b/apps/server/src/api-data/assets/assets.controller.ts @@ -0,0 +1,41 @@ +import { defaultCss } from '../../user/styles/bundledCss.js'; +import type { Request, Response } from 'express'; +import { readCssFile, writeCssFile } from './assets.service.js'; + +/** + * Exposes the contents of the cssOverride.css file + */ +export async function getCssOverride(_req: Request, res: Response) { + try { + const data = await readCssFile(); + res.status(200).send(data); + } catch (error) { + res.status(500).send({ message: error }); + } +} + +/** + * Allows modifying the cssOverride.css file + */ +export async function postCssOverride(req: Request, res: Response) { + const { css } = req.body; + + try { + await writeCssFile(css); + res.status(204).send(); + } catch (error) { + res.status(500).send({ message: error }); + } +} + +/** + * Restores the default cssOverride.css file + */ +export async function restoreCss(_req: Request, res: Response) { + try { + await writeCssFile(defaultCss); + res.status(200).send(defaultCss); + } catch (error) { + res.status(500).send({ message: error }); + } +} diff --git a/apps/server/src/api-data/assets/assets.router.ts b/apps/server/src/api-data/assets/assets.router.ts new file mode 100644 index 000000000..933972b38 --- /dev/null +++ b/apps/server/src/api-data/assets/assets.router.ts @@ -0,0 +1,10 @@ +import express from 'express'; + +import { getCssOverride, postCssOverride, restoreCss } from './assets.controller.js'; +import { validatePostCss } from './assets.validation.js'; + +export const router = express.Router(); + +router.get('/css', getCssOverride); +router.post('/css', validatePostCss, postCssOverride); +router.post('/css/restore', restoreCss); diff --git a/apps/server/src/api-data/assets/assets.service.ts b/apps/server/src/api-data/assets/assets.service.ts new file mode 100644 index 000000000..2e5cee6c8 --- /dev/null +++ b/apps/server/src/api-data/assets/assets.service.ts @@ -0,0 +1,33 @@ +import { publicFiles } from '../../setup/index.js'; +import { existsSync } from 'node:fs'; +import { readFile, writeFile } from 'node:fs/promises'; +import { defaultCss } from '../../user/styles/bundledCss.js'; + +/** + * Reads the user's css file + * @returns css contents in the file + */ +export async function readCssFile(): Promise { + const path = publicFiles.cssOverride; + if (!existsSync(path)) { + await writeFile(path, defaultCss, { encoding: 'utf8' }); + } + + const css = await readFile(path, { encoding: 'utf8' }); + + return css; +} + +/** + * Writes the user's css file + * @param css the updated css to write to file + */ +export async function writeCssFile(css: string) { + const path = publicFiles.cssOverride; + if (!existsSync(path)) { + await writeFile(path, css, { encoding: 'utf8' }); + return; + } + + await writeFile(path, css, { encoding: 'utf8' }); +} diff --git a/apps/server/src/api-data/assets/assets.validation.ts b/apps/server/src/api-data/assets/assets.validation.ts new file mode 100644 index 000000000..7005a4331 --- /dev/null +++ b/apps/server/src/api-data/assets/assets.validation.ts @@ -0,0 +1,12 @@ +import { Request, Response, NextFunction } from 'express'; +import { body, validationResult } from 'express-validator'; + +export const validatePostCss = [ + body('css').exists().isString().trim(), + + (req: Request, res: Response, next: NextFunction) => { + const errors = validationResult(req); + if (!errors.isEmpty()) return res.status(422).json({ errors: errors.array() }); + next(); + }, +]; diff --git a/apps/server/src/api-data/index.ts b/apps/server/src/api-data/index.ts index da5c369fa..7f79c497d 100644 --- a/apps/server/src/api-data/index.ts +++ b/apps/server/src/api-data/index.ts @@ -12,6 +12,7 @@ import { router as excelRouter } from './excel/excel.router.js'; import { router as sessionRouter } from './session/session.router.js'; import { router as viewSettingsRouter } from './view-settings/viewSettings.router.js'; import { router as reportRouter } from './report/report.router.js'; +import { router as assetsRouter } from './assets/assets.router.js'; export const appRouter = express.Router(); @@ -27,6 +28,7 @@ appRouter.use('/url-presets', urlPresetsRouter); appRouter.use('/session', sessionRouter); appRouter.use('/view-settings', viewSettingsRouter); appRouter.use('/report', reportRouter); +appRouter.use('/assets', assetsRouter); //we don't want to redirect to react index when using api routes appRouter.all('/*', (_req, res) => { diff --git a/apps/server/src/user/styles/bundledCss.ts b/apps/server/src/user/styles/bundledCss.ts new file mode 100644 index 000000000..e3c9f9702 --- /dev/null +++ b/apps/server/src/user/styles/bundledCss.ts @@ -0,0 +1,79 @@ +export const defaultCss = ` +/** + * This CSS file allows user customisation of the UI + * We expose some CSS properties to facilitate this (see below in :root) + * In the cases where this is missing, you can add your selectors here + */ + +:root { + /** Background colour for the views */ + --background-color-override: #ececec; + + /** Main text colour for the views */ + --color-override: #101010; + + /** Text colour for the views */ + --secondary-color-override: #404040; + + /** Accent text colour, used on active elements */ + --accent-color-override: #fa5656; + + /** Label text colour, used on active elements */ + --label-color-override: #6c6c6c; + + /** Timer text colour */ + --timer-color-override: #202020; + --timer-warning-color-override: #ffbc56; + --timer-danger-color-override: #e69000; + --timer-overtime-color-override: #fa5656; + --timer-pending-color-override: #578AF4; + + /** Background for card elements on background */ + --card-background-color-override: #fff; + + /** Font used for all text in views */ + --font-family-override: 'Open Sans'; + + /** Font used for clock in /minimal and /clock views */ + --font-family-bold-override: 'Arial Black'; + + /** Colour used for external message and aux timer in /timer */ + --external-color-override: #161616; + + /** View specific features: /backstage */ + /** ---- Background highlight for blink behaviour */ + --card-background-color-blink-override: #339e4e; + /** ---- Colour used for progress bar background */ + --timer-progress-bg-override: #fff; + /** ---- Colour used for progress bar progress */ + --timer-progress-override: #202020; + + /** View specific features: /op */ + --operator-customfield-font-size-override: 1.25rem; + --operator-running-bg-override: #339e4e; + + /** View specific features: /studio */ + --studio-active: #101010; + --studio-idle: #cfcfcf; + --studio-active-label: #101010; + --studio-idle-label: #595959; + --studio-overtime: #101010; + + /** View specific features: /lower */ + --lowerThird-font-family-override: 'Courier New'; + --lowerThird-top-font-weight-override: bold; + --lowerThird-bottom-font-weight-override: bold; + --lowerThird-top-font-style-override: normal; + --lowerThird-bottom-font-style-override: italic; + --lowerThird-line-height-override: 1vh; + --lowerThird-text-align-override: end; +} + +/** + * You can inspect the page in your browser and add the selectors here. + * In the below example, we change the colour of the overlay message in the stage-timer view. + */ +.stage-timer > .message-overlay--active > div { + color: red; +} +`; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1a64844cd..69ae35618 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -6,9 +6,6 @@ settings: catalogs: default: - '@types/node': - specifier: 20.17.16 - version: 20.17.16 '@typescript-eslint/eslint-plugin': specifier: 7.16.1 version: 7.16.1 @@ -143,6 +140,9 @@ importers: framer-motion: specifier: ^10.10.0 version: 10.11.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + prismjs: + specifier: ^1.29.0 + version: 1.29.0 react: specifier: ^18.3.1 version: 18.3.1 @@ -167,6 +167,9 @@ importers: react-router-dom: specifier: ^6.3.0 version: 6.6.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + react-simple-code-editor: + specifier: ^0.14.1 + version: 0.14.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) web-vitals: specifier: ^3.1.1 version: 3.1.1 @@ -183,6 +186,9 @@ importers: '@types/color': specifier: ^3.0.3 version: 3.0.3 + '@types/prismjs': + specifier: ^1.26.5 + version: 1.26.5 '@types/react': specifier: ^18.0.26 version: 18.0.26 @@ -2076,6 +2082,9 @@ packages: '@types/plist@3.0.5': resolution: {integrity: sha512-E6OCaRmAe4WDmWNsL/9RMqdkkzDCY1etutkflWk4c+AcjDU07Pcz1fQwTX0TQz+Pxqn9i4L1TU3UFpjnrcDgxA==} + '@types/prismjs@1.26.5': + resolution: {integrity: sha512-AUZTa7hQ2KY5L7AmtSiqxlhWxb4ina0yd8hNbl4TWuqnv/pFP0nDMb3YrfSBf4hJVGLh2YEIBfKaBW/9UEl6IQ==} + '@types/prop-types@15.7.5': resolution: {integrity: sha512-JCB8C6SnDoQf0cNycqd/35A7MjcnK+ZTqE7judS6o7utxUCg6imJg3QK2qzHKszlTjcj2cn+NwMB2i96ubpj7w==} @@ -4123,6 +4132,10 @@ packages: engines: {node: '>=14'} hasBin: true + prismjs@1.29.0: + resolution: {integrity: sha512-Kx/1w86q/epKcmte75LNrEoT+lX8pBpavuAbvJWRXar7Hz8jrtF+e3vY751p0R8H9HdArwaCTNDDzHg/ScJK1Q==} + engines: {node: '>=6'} + process-nextick-args@2.0.1: resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==} @@ -4278,6 +4291,12 @@ packages: peerDependencies: react: '>=16.8' + react-simple-code-editor@0.14.1: + resolution: {integrity: sha512-BR5DtNRy+AswWJECyA17qhUDvrrCZ6zXOCfkQY5zSmb96BVUbpVAv03WpcjcwtCwiLbIANx3gebHOcXYn1EHow==} + peerDependencies: + react: '>=16.8.0' + react-dom: '>=16.8.0' + react-style-singleton@2.2.1: resolution: {integrity: sha512-ZWj0fHEMyWkHzKYUr2Bs/4zU6XLmq9HsgBURm7g5pAVfyn49DgUiNgY2d4lXRlYSiCif9YBGpQleewkcqddc7g==} engines: {node: '>=10'} @@ -6850,6 +6869,8 @@ snapshots: xmlbuilder: 15.1.1 optional: true + '@types/prismjs@1.26.5': {} + '@types/prop-types@15.7.5': {} '@types/qs@6.9.7': {} @@ -9287,6 +9308,8 @@ snapshots: prettier@3.3.1: {} + prismjs@1.29.0: {} + process-nextick-args@2.0.1: {} progress@2.0.3: {} @@ -9431,6 +9454,11 @@ snapshots: '@remix-run/router': 1.2.1 react: 18.3.1 + react-simple-code-editor@0.14.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + dependencies: + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + react-style-singleton@2.2.1(@types/react@18.0.26)(react@18.3.1): dependencies: get-nonce: 1.0.1