refactor: reuse CodeMirror for CSS editors

This commit is contained in:
ajnart committed 2026-08-31 14:01:43 +02:00
1 parent e617a47395
commit dc7a8579dc
12 files changed
+76 -331

No files matched your search

+5 -2
View File
@@ -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)
@@ -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.
-3
View File
@@ -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:",
@@ -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<typeof CustomWidgetCodeEditor>;
type CodeEditorProps = Omit<SharedEditorProps, "messages">;
export function CodeEditor(props: CodeEditorProps) {
const t = useI18n("customWidget.editor");
const tCommon = useI18n("common");
const messages = useMemo<SharedEditorProps["messages"]>(
() => ({
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 <CustomWidgetCodeEditor {...props} messages={messages} />;
}
export { CodeEditor } from "~/components/custom-widgets/code-editor";
@@ -110,7 +110,7 @@ export const BrandingSettingsForm = ({ form }: BrandingSettingsFormProps) => {
description={t("customCss.description")}
actions={
<WorkshopCssImportButton
size="compact-sm"
size="compact-xs"
onImport={(css) => form.setFieldValue("branding.customCss", css)}
/>
}
@@ -1,13 +0,0 @@
.root {
overflow: auto;
margin-top: 4px;
border: 1px solid light-dark(var(--mantine-color-gray-4), var(--mantine-color-dark-4));
border-radius: var(--mantine-radius-sm);
}
.editor {
min-height: 15.625rem;
background-color: light-dark(var(--mantine-color-white), var(--mantine-color-dark-6));
font-family: "Fira code", "Fira Mono", monospace;
font-size: var(--mantine-font-size-xs);
}
@@ -1,12 +1,6 @@
"use client";
import { Group, Input, Stack } from "@mantine/core";
import { highlight, languages } from "prismjs";
import Editor from "react-simple-code-editor";
import "~/styles/prismjs.scss";
import classes from "./custom-css-editor.module.css";
import { CodeEditor } from "~/components/custom-widgets/code-editor";
interface CustomCssEditorProps {
id: string;
@@ -28,26 +22,17 @@ export const CustomCssEditor = ({
actions,
}: CustomCssEditorProps) => {
return (
<Stack gap="xs">
<Group justify="space-between" align="end">
<div>
<Input.Label htmlFor={id}>{label}</Input.Label>
<Input.Description>{description}</Input.Description>
</div>
{actions}
</Group>
<Input.Wrapper error={error} inputWrapperOrder={["input", "error"]}>
<div className={classes.root}>
<Editor
textareaId={id}
onValueChange={onChange}
value={value}
highlight={(code) => highlight(code, languages.extend("css", {}), "css")}
padding={10}
className={classes.editor}
/>
</div>
</Input.Wrapper>
</Stack>
<CodeEditor
id={id}
label={label}
description={description}
value={value}
onChange={onChange}
error={error}
language="css"
height="250px"
maxLength={16_384}
actions={actions}
/>
);
};
@@ -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<typeof CustomWidgetCodeEditor>;
type CodeEditorProps = Omit<SharedEditorProps, "messages">;
export function CodeEditor(props: CodeEditorProps) {
const t = useI18n("customWidget.editor");
const tCommon = useI18n("common");
const messages = useMemo<SharedEditorProps["messages"]>(
() => ({
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 <CustomWidgetCodeEditor {...props} messages={messages} />;
}
-225
View File
@@ -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;
}
}
@@ -54,6 +54,7 @@ export function CodeEditorToolbar({
</Text>
</Group>
<Group gap={6}>
{props.actions}
{!props.readOnly && (
<>
<Button
@@ -57,4 +57,5 @@ export interface CustomWidgetCodeEditorProps {
readOnly?: boolean;
reference?: CustomWidgetSchemaReferenceData;
height?: string;
actions?: ReactNode;
}
-9
View File
@@ -1238,9 +1238,6 @@ importers:
posthog-js:
specifier: 'catalog:'
version: 1.399.2(preact-render-to-string@6.5.11)
prismjs:
specifier: 'catalog:'
version: 1.30.0
react:
specifier: 'catalog:'
version: 19.2.7
@@ -1253,9 +1250,6 @@ importers:
react-shiki:
specifier: 'catalog:'
version: 0.11.0(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(supports-color@10.2.2)
react-simple-code-editor:
specifier: 'catalog:'
version: 0.14.1(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
remark-breaks:
specifier: 'catalog:'
version: 4.0.0
@@ -1299,9 +1293,6 @@ importers:
'@types/node':
specifier: 'catalog:'
version: 24.13.3
'@types/prismjs':
specifier: 'catalog:'
version: 1.26.6
'@types/react':
specifier: 'catalog:'
version: 19.2.17