From e617a473952414eed87bb11c56e27ed4db3e7323 Mon Sep 17 00:00:00 2001 From: ajnart Date: Mon, 31 Aug 2026 13:47:37 +0200 Subject: [PATCH] feat: add global server custom CSS --- .../boards/[name]/settings/_customCss.tsx | 53 +++---------------- .../[name]/settings/customcss.module.css | 22 -------- apps/nextjs/src/app/[locale]/layout.tsx | 1 + .../_components/branding-settings-form.tsx | 17 ++++++ .../settings/_components/settings-form.tsx | 3 ++ .../custom-css/custom-css-editor.module.css | 13 +++++ .../custom-css/custom-css-editor.tsx | 53 +++++++++++++++++++ .../workshop/workshop-css-import-button.tsx | 19 +++++-- packages/server-settings/src/index.ts | 2 + packages/translation/src/lang/en.json | 5 ++ 10 files changed, 118 insertions(+), 70 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.module.css create mode 100644 apps/nextjs/src/components/custom-css/custom-css-editor.tsx 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/settings/_components/branding-settings-form.tsx b/apps/nextjs/src/app/[locale]/manage/settings/_components/branding-settings-form.tsx index ae580a324..f9b75c9d1 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.module.css b/apps/nextjs/src/components/custom-css/custom-css-editor.module.css new file mode 100644 index 000000000..f098b0fa1 --- /dev/null +++ b/apps/nextjs/src/components/custom-css/custom-css-editor.module.css @@ -0,0 +1,13 @@ +.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); +} 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..9f986f480 --- /dev/null +++ b/apps/nextjs/src/components/custom-css/custom-css-editor.tsx @@ -0,0 +1,53 @@ +"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"; + +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 ( + + +
+ {label} + {description} +
+ {actions} +
+ +
+ highlight(code, languages.extend("css", {}), "css")} + padding={10} + className={classes.editor} + /> +
+
+
+ ); +}; 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/packages/server-settings/src/index.ts b/packages/server-settings/src/index.ts index 1921e37d4..0231b18cc 100644 --- a/packages/server-settings/src/index.ts +++ b/packages/server-settings/src/index.ts @@ -51,6 +51,7 @@ export const brandingServerSettingsSchema = z.object({ signInBackgroundOverlay: z.number().min(0).max(0.9), authBranding: authBrandingSchema, defaultRadius: z.enum(brandingRadiusOptions), + customCss: z.string().max(16_384), }); export type BrandingSettings = z.infer; @@ -71,6 +72,7 @@ export const defaultBrandingSettings: BrandingSettings = { showGreeting: true, }, defaultRadius: "md", + customCss: "", }; const hasDefaultBrandingColors = (branding: Pick) => diff --git a/packages/translation/src/lang/en.json b/packages/translation/src/lang/en.json index 8a37b40d7..d47475816 100644 --- a/packages/translation/src/lang/en.json +++ b/packages/translation/src/lang/en.json @@ -5972,6 +5972,7 @@ "groups": { "identity": "Identity", "appearance": "Appearance", + "customCss": "Custom CSS", "authentication": "Authentication" }, "identity": { @@ -6018,6 +6019,10 @@ "xl": "Soft" } }, + "customCss": { + "label": "Global custom CSS", + "description": "Applied to every page. Board custom CSS can override it." + }, "authVisibility": { "description": "Choose what appears on every authentication page.", "appName": "Application name",