feat: add global server custom CSS

This commit is contained in:
ajnart committed 2026-08-31 13:47:37 +02:00
1 parent ee30aa13cf
commit e617a47395
10 files changed
+118 -70

No files matched your search

@@ -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<FormValues>;
@@ -29,7 +24,12 @@ export const CustomCssSettingsContent = ({ form }: Props) => {
return (
<SectionCard title={t("setting.section.customCss.title")}>
<CustomCssInput {...form.getInputProps("customCss")} />
<CustomCssEditor
id="board-custom-css"
label={customCssT("label")}
description={customCssT("description")}
{...form.getInputProps("customCss")}
/>
<Alert variant="light" color="cyan" title={customCssT("customClassesAlert.title")} icon={<IconInfoCircle />}>
{customCssT("customClassesAlert.description")}
@@ -38,40 +38,3 @@ export const CustomCssSettingsContent = ({ form }: Props) => {
</SectionCard>
);
};
interface CustomCssInputProps {
value?: string;
onChange: (value: string) => void;
error?: React.ReactNode;
}
const CustomCssInput = ({ value, onChange, error }: CustomCssInputProps) => {
const customCssT = useI18n("board.field.customCss");
return (
<Input.Wrapper
label={customCssT("label")}
labelProps={{
htmlFor: "custom-css",
}}
description={customCssT("description")}
error={error}
inputWrapperOrder={["label", "description", "input", "error"]}
>
<div className={classes.codeEditorRoot}>
<Editor
textareaId="custom-css"
onValueChange={onChange}
value={value ?? ""}
highlight={(code) => highlight(code, languages.extend("css", {}), "css")}
padding={10}
style={{
fontFamily: '"Fira code", "Fira Mono", monospace',
fontSize: 12,
minHeight: 250,
}}
/>
</div>
</Input.Wrapper>
);
};
@@ -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));
}
+1
View File
@@ -199,6 +199,7 @@ export default async function Layout(props: {
) : null}
<SearchEngineOptimization />
<CrowdinLiveTranslation locale={locale} />
<style data-homarr-global-custom-css>{serverSettings.branding.customCss}</style>
</head>
<body className={[fontSans.className, fontSans.variable].join(" ")} suppressHydrationWarning>
<Analytics enabled={serverSettings.analytics.enableGeneral} />
@@ -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) => {
</SimpleGrid>
</Fieldset>
<Fieldset legend={t("groups.customCss")} p="sm">
<CustomCssEditor
id="global-custom-css"
label={t("customCss.label")}
description={t("customCss.description")}
actions={
<WorkshopCssImportButton
size="compact-sm"
onImport={(css) => form.setFieldValue("branding.customCss", css)}
/>
}
{...form.getInputProps("branding.customCss")}
/>
</Fieldset>
<Fieldset legend={t("groups.authentication")} p="sm">
<Stack gap="sm">
<SimpleGrid cols={{ base: 1, md: 2 }} spacing="sm" verticalSpacing="sm">
@@ -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"),
@@ -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);
}
@@ -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 (
<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>
);
};
@@ -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 (
<>
<Button type="button" variant="light" leftSection={<IconDownload size={16} />} onClick={controls.open}>
<Button
type="button"
size={size}
variant="light"
leftSection={<IconDownload size={16} />}
onClick={controls.open}
>
{t("importCss")}
</Button>
<Modal opened={opened} onClose={controls.close} title={t("importCss")} size={modalSizeSelect} radius="md">
+2
View File
@@ -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<typeof brandingServerSettingsSchema>;
@@ -71,6 +72,7 @@ export const defaultBrandingSettings: BrandingSettings = {
showGreeting: true,
},
defaultRadius: "md",
customCss: "",
};
const hasDefaultBrandingColors = (branding: Pick<BrandingSettings, "primaryColor" | "secondaryColor">) =>
+5
View File
@@ -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",