From f57a660088d6777c86aca22977354fa8b810e2be Mon Sep 17 00:00:00 2001 From: Thomas Date: Mon, 31 Aug 2026 14:26:58 +0200 Subject: [PATCH] feat: add global server custom CSS (#6753) * feat: add global server custom CSS * refactor: reuse CodeMirror for CSS editors --- apps/docs/docs/advanced/styling/index.mdx | 7 +- apps/docs/docs/management/settings/index.mdx | 4 + apps/nextjs/package.json | 3 - .../boards/[name]/settings/_customCss.tsx | 53 +---- .../[name]/settings/customcss.module.css | 22 -- apps/nextjs/src/app/[locale]/layout.tsx | 1 + .../manage/custom-widgets/_code-editor.tsx | 51 +--- .../_components/branding-settings-form.tsx | 17 ++ .../settings/_components/settings-form.tsx | 3 + .../custom-css/custom-css-editor.tsx | 38 +++ .../components/custom-widgets/code-editor.tsx | 50 ++++ .../workshop/workshop-css-import-button.tsx | 19 +- apps/nextjs/src/styles/prismjs.scss | 225 ------------------ .../src/workbench/code-editor-toolbar.tsx | 1 + .../src/workbench/code-editor-types.ts | 1 + packages/server-settings/src/index.ts | 2 + packages/translation/src/lang/en.json | 5 + pnpm-lock.yaml | 9 - 18 files changed, 152 insertions(+), 359 deletions(-) delete mode 100644 apps/nextjs/src/app/[locale]/boards/[name]/settings/customcss.module.css create mode 100644 apps/nextjs/src/components/custom-css/custom-css-editor.tsx create mode 100644 apps/nextjs/src/components/custom-widgets/code-editor.tsx delete mode 100644 apps/nextjs/src/styles/prismjs.scss diff --git a/apps/docs/docs/advanced/styling/index.mdx b/apps/docs/docs/advanced/styling/index.mdx index ed2faac99..705a3eccd 100644 --- a/apps/docs/docs/advanced/styling/index.mdx +++ b/apps/docs/docs/advanced/styling/index.mdx @@ -85,8 +85,11 @@ To do this, enter the board settings: ![](./img/enter-board-settings.png) -Scroll to the **Custom CSS** section on the board settings page. It is part of the unified settings form; enter your CSS -there and save the board settings to apply it to that board. +Administrators can add instance-wide CSS under **Management → Settings → Branding → Custom CSS**. Enter CSS directly +or import a community theme from the Workshop, then save the server settings to apply it to every page. + +For board-specific styling, scroll to the **Custom CSS** section on the board settings page. Enter your CSS there and +save the board settings to apply it to that board. Board CSS is loaded after the global stylesheet and can override it. ![](./img/custom-css-setting.png) diff --git a/apps/docs/docs/management/settings/index.mdx b/apps/docs/docs/management/settings/index.mdx index f45fabdc5..e531d04f2 100644 --- a/apps/docs/docs/management/settings/index.mdx +++ b/apps/docs/docs/management/settings/index.mdx @@ -142,6 +142,10 @@ Branding settings let administrators adapt Homarr to the identity and visual sty Choose the primary and secondary colors used by the theme. **Lock primary color** prevents users from overriding the configured primary color. **Default corner style** controls the global radius used by cards, controls, and other interface elements. +### Global custom CSS + +The branding settings include a CSS editor for styles that should apply across the entire instance. You can write CSS directly or import a community theme from the Workshop. CSS configured on an individual board takes precedence over the global stylesheet. + ### Sign-in page You can set a greeting and a background image for authentication pages. The overlay slider adjusts the contrast between the background and the sign-in content. The visibility controls independently show or hide the application name, logo, and greeting. diff --git a/apps/nextjs/package.json b/apps/nextjs/package.json index f5c4794b7..3a8feec1d 100644 --- a/apps/nextjs/package.json +++ b/apps/nextjs/package.json @@ -106,12 +106,10 @@ "next": "catalog:", "postcss-preset-mantine": "catalog:", "posthog-js": "catalog:", - "prismjs": "catalog:", "react": "catalog:", "react-dom": "catalog:", "react-error-boundary": "catalog:", "react-shiki": "catalog:", - "react-simple-code-editor": "catalog:", "remark-breaks": "catalog:", "remark-directive": "catalog:", "remark-gfm": "catalog:", @@ -128,7 +126,6 @@ "@types/better-sqlite3": "catalog:", "@types/chroma-js": "catalog:", "@types/node": "catalog:", - "@types/prismjs": "catalog:", "@types/react": "catalog:", "@types/react-dom": "catalog:", "concurrently": "catalog:", diff --git a/apps/nextjs/src/app/[locale]/boards/[name]/settings/_customCss.tsx b/apps/nextjs/src/app/[locale]/boards/[name]/settings/_customCss.tsx index 0b5ab693f..26ada0288 100644 --- a/apps/nextjs/src/app/[locale]/boards/[name]/settings/_customCss.tsx +++ b/apps/nextjs/src/app/[locale]/boards/[name]/settings/_customCss.tsx @@ -1,21 +1,16 @@ "use client"; -import { Alert, Input } from "@mantine/core"; -import { highlight, languages } from "prismjs"; -import Editor from "react-simple-code-editor"; - -import "~/styles/prismjs.scss"; - +import { Alert } from "@mantine/core"; import { IconInfoCircle } from "@tabler/icons-react"; import { useSession } from "@homarr/auth/client"; import type { UseFormReturnType } from "@homarr/form"; import { useI18n } from "@homarr/translation/client"; +import { CustomCssEditor } from "~/components/custom-css/custom-css-editor"; import { SectionCard } from "~/components/manage/section-card"; import { WorkshopCssImportButton } from "~/components/workshop/workshop-css-import-button"; import type { FormValues } from "./_settings-form"; -import classes from "./customcss.module.css"; interface Props { form: UseFormReturnType; @@ -29,7 +24,12 @@ export const CustomCssSettingsContent = ({ form }: Props) => { return ( - + }> {customCssT("customClassesAlert.description")} @@ -38,40 +38,3 @@ export const CustomCssSettingsContent = ({ form }: Props) => { ); }; - -interface CustomCssInputProps { - value?: string; - onChange: (value: string) => void; - error?: React.ReactNode; -} - -const CustomCssInput = ({ value, onChange, error }: CustomCssInputProps) => { - const customCssT = useI18n("board.field.customCss"); - - return ( - -
- highlight(code, languages.extend("css", {}), "css")} - padding={10} - style={{ - fontFamily: '"Fira code", "Fira Mono", monospace', - fontSize: 12, - minHeight: 250, - }} - /> -
-
- ); -}; diff --git a/apps/nextjs/src/app/[locale]/boards/[name]/settings/customcss.module.css b/apps/nextjs/src/app/[locale]/boards/[name]/settings/customcss.module.css deleted file mode 100644 index b01c21383..000000000 --- a/apps/nextjs/src/app/[locale]/boards/[name]/settings/customcss.module.css +++ /dev/null @@ -1,22 +0,0 @@ -.codeEditorFooter { - border-bottom-left-radius: var(--mantine-radius-sm); - border-bottom-right-radius: var(--mantine-radius-sm); - background-color: light-dark(var(--mantine-color-gray-1), var(--mantine-color-dark-7)); -} - -.codeEditorRoot { - margin-top: 4px; - border-color: light-dark(var(--mantine-color-gray-4), var(--mantine-color-dark-4)); - border-width: 1px; - border-style: solid; - border-radius: var(--mantine-radius-sm); -} - -.codeEditor { - background-color: light-dark(white, var(--mantine-color-dark-6)); - font-size: var(--mantine-font-size-xs); -} - -.codeEditor ::placeholder { - color: light-dark(var(--mantine-color-gray-5), var(--mantine-color-dark-3)); -} diff --git a/apps/nextjs/src/app/[locale]/layout.tsx b/apps/nextjs/src/app/[locale]/layout.tsx index 884e56989..cea080987 100644 --- a/apps/nextjs/src/app/[locale]/layout.tsx +++ b/apps/nextjs/src/app/[locale]/layout.tsx @@ -199,6 +199,7 @@ export default async function Layout(props: { ) : null} + diff --git a/apps/nextjs/src/app/[locale]/manage/custom-widgets/_code-editor.tsx b/apps/nextjs/src/app/[locale]/manage/custom-widgets/_code-editor.tsx index b8c6630f3..bc8c28a0b 100644 --- a/apps/nextjs/src/app/[locale]/manage/custom-widgets/_code-editor.tsx +++ b/apps/nextjs/src/app/[locale]/manage/custom-widgets/_code-editor.tsx @@ -1,50 +1 @@ -"use client"; - -import { useMemo } from "react"; -import type { ComponentProps } from "react"; - -import { CustomWidgetCodeEditor } from "@homarr/custom-widgets/workbench"; -import { invariantTechnicalLabels } from "@homarr/definitions"; -import { useI18n } from "@homarr/translation/client"; - -type SharedEditorProps = ComponentProps; -type CodeEditorProps = Omit; - -export function CodeEditor(props: CodeEditorProps) { - const t = useI18n("customWidget.editor"); - const tCommon = useI18n("common"); - const messages = useMemo( - () => ({ - languageJsx: invariantTechnicalLabels.jsx, - languageJson: invariantTechnicalLabels.json, - undo: tCommon("action.undo"), - redo: t("action.redo"), - components: t("action.components"), - componentSearch: t("componentReference.search"), - componentEmpty: t("componentReference.empty"), - componentCount: (count) => t("componentReference.count", { count }), - insertStarter: t("action.insertStarter"), - format: t("action.format"), - copy: tCommon("action.copy"), - copied: t("action.copied"), - schema: t("action.schema"), - schemaTab: invariantTechnicalLabels.jsonSchema, - minimalTab: t("reference.minimal"), - fullTab: t("reference.full"), - errors: (count) => t("status.errors", { count }), - warnings: (count) => t("status.warnings", { count }), - ready: t("status.ready"), - position: (cursor) => t("status.position", cursor), - characters: (count, limit) => - limit ? t("status.charactersWithLimit", { count, limit }) : t("status.characters", { count }), - diagnosticsTitle: t("diagnostics.title"), - diagnostic: (diagnostic) => - `${diagnostic.line ? `${t("diagnostics.line", { line: diagnostic.line })}: ` : ""}${t( - `diagnostics.${diagnostic.code}` as never, - { value: diagnostic.value ?? "" } as never, - )}`, - }), - [t, tCommon], - ); - return ; -} +export { CodeEditor } from "~/components/custom-widgets/code-editor"; diff --git a/apps/nextjs/src/app/[locale]/manage/settings/_components/branding-settings-form.tsx b/apps/nextjs/src/app/[locale]/manage/settings/_components/branding-settings-form.tsx index ae580a324..e1b196f5b 100644 --- a/apps/nextjs/src/app/[locale]/manage/settings/_components/branding-settings-form.tsx +++ b/apps/nextjs/src/app/[locale]/manage/settings/_components/branding-settings-form.tsx @@ -20,7 +20,9 @@ import type { UseFormReturnType } from "@homarr/form"; import { useI18n } from "@homarr/translation/client"; import { BoardColorInput, CornerStylePicker, cornerStyleValues } from "@homarr/ui"; +import { CustomCssEditor } from "~/components/custom-css/custom-css-editor"; import { SectionCard } from "~/components/manage/section-card"; +import { WorkshopCssImportButton } from "~/components/workshop/workshop-css-import-button"; import type { FormValues } from "./settings-form"; interface BrandingSettingsFormProps { @@ -101,6 +103,21 @@ export const BrandingSettingsForm = ({ form }: BrandingSettingsFormProps) => { +
+ form.setFieldValue("branding.customCss", css)} + /> + } + {...form.getInputProps("branding.customCss")} + /> +
+
diff --git a/apps/nextjs/src/app/[locale]/manage/settings/_components/settings-form.tsx b/apps/nextjs/src/app/[locale]/manage/settings/_components/settings-form.tsx index ba8661966..4642482d1 100644 --- a/apps/nextjs/src/app/[locale]/manage/settings/_components/settings-form.tsx +++ b/apps/nextjs/src/app/[locale]/manage/settings/_components/settings-form.tsx @@ -1,6 +1,7 @@ "use client"; import { useEffect, useRef, useState } from "react"; +import { useRouter } from "next/navigation"; import { Button, Stack } from "@mantine/core"; import { z } from "zod/v4"; @@ -70,6 +71,7 @@ interface SettingsFormProps { export const SettingsForm = ({ initialData, selectableBoards, selectableSearchEngines }: SettingsFormProps) => { const tCommon = useI18n("common"); const tSettings = useI18n("management.page.settings"); + const router = useRouter(); const initialValues = buildInitialValues(initialData); const initialValuesRef = useRef(initialValues); @@ -164,6 +166,7 @@ export const SettingsForm = ({ initialData, selectableBoards, selectableSearchEn form.setInitialValues(values); form.resetDirty(); await revalidatePathActionAsync("/manage/settings"); + router.refresh(); showSuccessNotification({ title: tCommon("notification.update.success"), message: tSettings("notification.success.message"), diff --git a/apps/nextjs/src/components/custom-css/custom-css-editor.tsx b/apps/nextjs/src/components/custom-css/custom-css-editor.tsx new file mode 100644 index 000000000..d46db8d74 --- /dev/null +++ b/apps/nextjs/src/components/custom-css/custom-css-editor.tsx @@ -0,0 +1,38 @@ +"use client"; + +import { CodeEditor } from "~/components/custom-widgets/code-editor"; + +interface CustomCssEditorProps { + id: string; + label: string; + description: string; + value?: string; + onChange: (value: string) => void; + error?: React.ReactNode; + actions?: React.ReactNode; +} + +export const CustomCssEditor = ({ + id, + label, + description, + value = "", + onChange, + error, + actions, +}: CustomCssEditorProps) => { + return ( + + ); +}; diff --git a/apps/nextjs/src/components/custom-widgets/code-editor.tsx b/apps/nextjs/src/components/custom-widgets/code-editor.tsx new file mode 100644 index 000000000..b8c6630f3 --- /dev/null +++ b/apps/nextjs/src/components/custom-widgets/code-editor.tsx @@ -0,0 +1,50 @@ +"use client"; + +import { useMemo } from "react"; +import type { ComponentProps } from "react"; + +import { CustomWidgetCodeEditor } from "@homarr/custom-widgets/workbench"; +import { invariantTechnicalLabels } from "@homarr/definitions"; +import { useI18n } from "@homarr/translation/client"; + +type SharedEditorProps = ComponentProps; +type CodeEditorProps = Omit; + +export function CodeEditor(props: CodeEditorProps) { + const t = useI18n("customWidget.editor"); + const tCommon = useI18n("common"); + const messages = useMemo( + () => ({ + languageJsx: invariantTechnicalLabels.jsx, + languageJson: invariantTechnicalLabels.json, + undo: tCommon("action.undo"), + redo: t("action.redo"), + components: t("action.components"), + componentSearch: t("componentReference.search"), + componentEmpty: t("componentReference.empty"), + componentCount: (count) => t("componentReference.count", { count }), + insertStarter: t("action.insertStarter"), + format: t("action.format"), + copy: tCommon("action.copy"), + copied: t("action.copied"), + schema: t("action.schema"), + schemaTab: invariantTechnicalLabels.jsonSchema, + minimalTab: t("reference.minimal"), + fullTab: t("reference.full"), + errors: (count) => t("status.errors", { count }), + warnings: (count) => t("status.warnings", { count }), + ready: t("status.ready"), + position: (cursor) => t("status.position", cursor), + characters: (count, limit) => + limit ? t("status.charactersWithLimit", { count, limit }) : t("status.characters", { count }), + diagnosticsTitle: t("diagnostics.title"), + diagnostic: (diagnostic) => + `${diagnostic.line ? `${t("diagnostics.line", { line: diagnostic.line })}: ` : ""}${t( + `diagnostics.${diagnostic.code}` as never, + { value: diagnostic.value ?? "" } as never, + )}`, + }), + [t, tCommon], + ); + return ; +} diff --git a/apps/nextjs/src/components/workshop/workshop-css-import-button.tsx b/apps/nextjs/src/components/workshop/workshop-css-import-button.tsx index 4de28b2de..efce929e6 100644 --- a/apps/nextjs/src/components/workshop/workshop-css-import-button.tsx +++ b/apps/nextjs/src/components/workshop/workshop-css-import-button.tsx @@ -1,6 +1,7 @@ "use client"; import { useState } from "react"; +import type { ButtonProps } from "@mantine/core"; import { ActionIcon, Button, Group, Loader, Modal, Pagination, Stack, Text, TextInput, Tooltip } from "@mantine/core"; import { useDebouncedValue, useDisclosure } from "@mantine/hooks"; import { IconDownload, IconExternalLink, IconSearch } from "@tabler/icons-react"; @@ -21,15 +22,27 @@ import { WorkshopVoteControl } from "./workshop-vote-control"; /** * Picks a community CSS theme in a single surface: browse, confirm, done. * The imported rules land in the editor below and are only persisted when the - * board settings themselves are saved, so there is nothing to preview twice. + * settings themselves are saved, so there is nothing to preview twice. */ -export function WorkshopCssImportButton({ onImport }: { onImport(css: string): void }) { +export function WorkshopCssImportButton({ + onImport, + size, +}: { + onImport(css: string): void; + size?: ButtonProps["size"]; +}) { const t = useI18n("workshop"); const [opened, controls] = useDisclosure(false); return ( <> - diff --git a/apps/nextjs/src/styles/prismjs.scss b/apps/nextjs/src/styles/prismjs.scss deleted file mode 100644 index d9f1d6327..000000000 --- a/apps/nextjs/src/styles/prismjs.scss +++ /dev/null @@ -1,225 +0,0 @@ -[data-mantine-color-scheme="light"] { - code[class*="language-"], - pre[class*="language-"] { - color: #000; - background: 0 0; - text-shadow: 0 1px #fff; - font-family: Consolas, Monaco, "Andale Mono", "Ubuntu Mono", monospace; - font-size: 1em; - text-align: left; - white-space: pre; - word-spacing: normal; - word-break: normal; - word-wrap: normal; - line-height: 1.5; - -moz-tab-size: 4; - -o-tab-size: 4; - tab-size: 4; - -webkit-hyphens: none; - -moz-hyphens: none; - -ms-hyphens: none; - hyphens: none; - } - code[class*="language-"] ::-moz-selection, - code[class*="language-"]::-moz-selection, - pre[class*="language-"] ::-moz-selection, - pre[class*="language-"]::-moz-selection { - text-shadow: none; - background: #b3d4fc; - } - code[class*="language-"] ::selection, - code[class*="language-"]::selection, - pre[class*="language-"] ::selection, - pre[class*="language-"]::selection { - text-shadow: none; - background: #b3d4fc; - } - @media print { - code[class*="language-"], - pre[class*="language-"] { - text-shadow: none; - } - } - pre[class*="language-"] { - padding: 1em; - margin: 0.5em 0; - overflow: auto; - } - :not(pre) > code[class*="language-"], - pre[class*="language-"] { - background: #f5f2f0; - } - :not(pre) > code[class*="language-"] { - padding: 0.1em; - border-radius: 0.3em; - white-space: normal; - } - .token.cdata, - .token.comment, - .token.doctype, - .token.prolog { - color: #708090; - } - .token.punctuation { - color: #999; - } - .token.namespace { - opacity: 0.7; - } - .token.boolean, - .token.constant, - .token.deleted, - .token.number, - .token.property, - .token.symbol, - .token.tag { - color: #905; - } - .token.attr-name, - .token.builtin, - .token.char, - .token.inserted, - .token.selector, - .token.string { - color: #690; - } - .language-css .token.string, - .style .token.string, - .token.entity, - .token.operator, - .token.url { - color: #9a6e3a; - background: hsla(0, 0%, 100%, 0.5); - } - .token.atrule, - .token.attr-value, - .token.keyword { - color: #07a; - } - .token.class-name, - .token.function { - color: #dd4a68; - } - .token.important, - .token.regex, - .token.variable { - color: #e90; - } - .token.bold, - .token.important { - font-weight: 700; - } - .token.italic { - font-style: italic; - } - .token.entity { - cursor: help; - } -} - -[data-mantine-color-scheme="dark"] { - code[class*="language-"], - pre[class*="language-"] { - color: #fff; - background: 0 0; - text-shadow: 0 -0.1em 0.2em #000; - font-family: Consolas, Monaco, "Andale Mono", "Ubuntu Mono", monospace; - font-size: 1em; - text-align: left; - white-space: pre; - word-spacing: normal; - word-break: normal; - word-wrap: normal; - line-height: 1.5; - -moz-tab-size: 4; - -o-tab-size: 4; - tab-size: 4; - -webkit-hyphens: none; - -moz-hyphens: none; - -ms-hyphens: none; - hyphens: none; - } - @media print { - code[class*="language-"], - pre[class*="language-"] { - text-shadow: none; - } - } - :not(pre) > code[class*="language-"], - pre[class*="language-"] { - background: #4c3f33; - } - pre[class*="language-"] { - padding: 1em; - margin: 0.5em 0; - overflow: auto; - border: 0.3em solid #7a6651; - border-radius: 0.5em; - box-shadow: 1px 1px 0.5em #000 inset; - } - :not(pre) > code[class*="language-"] { - padding: 0.15em 0.2em 0.05em; - border-radius: 0.3em; - border: 0.13em solid #7a6651; - box-shadow: 1px 1px 0.3em -0.1em #000 inset; - white-space: normal; - } - .token.cdata, - .token.comment, - .token.doctype, - .token.prolog { - color: #997f66; - } - .token.punctuation { - opacity: 0.7; - } - .token.namespace { - opacity: 0.7; - } - .token.boolean, - .token.constant, - .token.number, - .token.property, - .token.symbol, - .token.tag { - color: #d1939e; - } - .token.attr-name, - .token.builtin, - .token.char, - .token.inserted, - .token.selector, - .token.string { - color: #bce051; - } - .language-css .token.string, - .style .token.string, - .token.entity, - .token.operator, - .token.url, - .token.variable { - color: #f4b73d; - } - .token.atrule, - .token.attr-value, - .token.keyword { - color: #d1939e; - } - .token.important, - .token.regex { - color: #e90; - } - .token.bold, - .token.important { - font-weight: 700; - } - .token.italic { - font-style: italic; - } - .token.entity { - cursor: help; - } - .token.deleted { - color: red; - } -} diff --git a/packages/custom-widgets/src/workbench/code-editor-toolbar.tsx b/packages/custom-widgets/src/workbench/code-editor-toolbar.tsx index 3b3f246c0..010bf5eee 100644 --- a/packages/custom-widgets/src/workbench/code-editor-toolbar.tsx +++ b/packages/custom-widgets/src/workbench/code-editor-toolbar.tsx @@ -54,6 +54,7 @@ export function CodeEditorToolbar({ + {props.actions} {!props.readOnly && ( <>