feat: add custom css for board and custom classes in advanced options for items (#512)
* feat: add custom css for board and custom classes in advanced options for items * chore: add mysql migration * fix: test not working * fix: format issues * fix: typecheck issue * fix: build issue * chore: add missing translations * fix: merge issues related to migrations * fix: format issues * fix: merge issue with migration * fix: format issue
This commit is contained in:
@@ -0,0 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import { useRequiredBoard } from "./_context";
|
||||
|
||||
export const CustomCss = () => {
|
||||
const board = useRequiredBoard();
|
||||
|
||||
return <style>{board.customCss}</style>;
|
||||
};
|
||||
@@ -1,7 +1,91 @@
|
||||
"use client";
|
||||
|
||||
// TODO: add some sort of store (maybe directory on GitHub)
|
||||
import { Alert, Button, Group, Input, Stack } from "@mantine/core";
|
||||
import { highlight, languages } from "prismjs";
|
||||
import Editor from "react-simple-code-editor";
|
||||
|
||||
export const CustomCssSettingsContent = () => {
|
||||
return null;
|
||||
import "~/styles/prismjs.scss";
|
||||
|
||||
import { IconInfoCircle } from "@tabler/icons-react";
|
||||
|
||||
import { useForm } from "@homarr/form";
|
||||
import { useI18n, useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import type { Board } from "../../_types";
|
||||
import { useSavePartialSettingsMutation } from "./_shared";
|
||||
import classes from "./customcss.module.css";
|
||||
|
||||
interface Props {
|
||||
board: Board;
|
||||
}
|
||||
|
||||
export const CustomCssSettingsContent = ({ board }: Props) => {
|
||||
const t = useI18n();
|
||||
const customCssT = useScopedI18n("board.field.customCss");
|
||||
const { mutate: savePartialSettings, isPending } = useSavePartialSettingsMutation(board);
|
||||
const form = useForm({
|
||||
initialValues: {
|
||||
customCss: board.customCss ?? "",
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={form.onSubmit((values) => {
|
||||
savePartialSettings({
|
||||
id: board.id,
|
||||
...values,
|
||||
});
|
||||
})}
|
||||
>
|
||||
<Stack>
|
||||
<CustomCssInput {...form.getInputProps("customCss")} />
|
||||
|
||||
<Alert variant="light" color="cyan" title={customCssT("customClassesAlert.title")} icon={<IconInfoCircle />}>
|
||||
{customCssT("customClassesAlert.description")}
|
||||
</Alert>
|
||||
|
||||
<Group justify="end">
|
||||
<Button type="submit" loading={isPending} color="teal">
|
||||
{t("common.action.saveChanges")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
interface CustomCssInputProps {
|
||||
value?: string;
|
||||
onChange: (value: string) => void;
|
||||
}
|
||||
|
||||
const CustomCssInput = ({ value, onChange }: CustomCssInputProps) => {
|
||||
const customCssT = useScopedI18n("board.field.customCss");
|
||||
|
||||
return (
|
||||
<Input.Wrapper
|
||||
label={customCssT("label")}
|
||||
labelProps={{
|
||||
htmlFor: "custom-css",
|
||||
}}
|
||||
description={customCssT("description")}
|
||||
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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
.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));
|
||||
}
|
||||
@@ -84,7 +84,7 @@ export default async function BoardSettingsPage({ params, searchParams }: Props)
|
||||
<ColorSettingsContent board={board} />
|
||||
</AccordionItemFor>
|
||||
<AccordionItemFor value="customCss" icon={IconFileTypeCss}>
|
||||
<CustomCssSettingsContent />
|
||||
<CustomCssSettingsContent board={board} />
|
||||
</AccordionItemFor>
|
||||
{hasFullAccess && (
|
||||
<>
|
||||
|
||||
@@ -12,6 +12,7 @@ import { ClientShell } from "~/components/layout/shell";
|
||||
import type { Board } from "./_types";
|
||||
import { BoardProvider } from "./(content)/_context";
|
||||
import type { Params } from "./(content)/_creator";
|
||||
import { CustomCss } from "./(content)/_custom-css";
|
||||
import { BoardMantineProvider } from "./(content)/_theme";
|
||||
|
||||
interface CreateBoardLayoutProps<TParams extends Params> {
|
||||
@@ -44,6 +45,7 @@ export const createBoardLayout = <TParams extends Params>({
|
||||
<GlobalItemServerDataRunner board={initialBoard} shouldRun={isBoardContentPage}>
|
||||
<BoardProvider initialBoard={initialBoard}>
|
||||
<BoardMantineProvider>
|
||||
<CustomCss />
|
||||
<ClientShell hasNavigation={false}>
|
||||
<MainHeader
|
||||
logo={<BoardLogoWithTitle size="md" hideTitleOnMobile />}
|
||||
|
||||
@@ -8,7 +8,7 @@ import type { IntegrationKind, WidgetKind } from "@homarr/definitions";
|
||||
import { useModalAction } from "@homarr/modals";
|
||||
import { showSuccessNotification } from "@homarr/notifications";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
import type { BoardItemIntegration } from "@homarr/validation";
|
||||
import type { BoardItemAdvancedOptions, BoardItemIntegration } from "@homarr/validation";
|
||||
import {
|
||||
loadWidgetDynamic,
|
||||
reduceWidgetOptionsWithDefaultValues,
|
||||
@@ -42,9 +42,13 @@ export const WidgetPreviewPageContent = ({ kind, integrationData }: WidgetPrevie
|
||||
const [state, setState] = useState<{
|
||||
options: Record<string, unknown>;
|
||||
integrations: BoardItemIntegration[];
|
||||
advancedOptions: BoardItemAdvancedOptions;
|
||||
}>({
|
||||
options: reduceWidgetOptionsWithDefaultValues(kind, {}),
|
||||
integrations: [],
|
||||
advancedOptions: {
|
||||
customCssClasses: [],
|
||||
},
|
||||
});
|
||||
|
||||
const handleOpenEditWidgetModal = useCallback(() => {
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useCallback } from "react";
|
||||
|
||||
import { createId } from "@homarr/db/client";
|
||||
import type { WidgetKind } from "@homarr/definitions";
|
||||
import type { BoardItemIntegration } from "@homarr/validation";
|
||||
import type { BoardItemAdvancedOptions, BoardItemIntegration } from "@homarr/validation";
|
||||
|
||||
import type { EmptySection, Item } from "~/app/[locale]/boards/_types";
|
||||
import { useUpdateBoard } from "~/app/[locale]/boards/(content)/_client";
|
||||
@@ -31,6 +31,11 @@ interface UpdateItemOptions {
|
||||
newOptions: Record<string, unknown>;
|
||||
}
|
||||
|
||||
interface UpdateItemAdvancedOptions {
|
||||
itemId: string;
|
||||
newAdvancedOptions: BoardItemAdvancedOptions;
|
||||
}
|
||||
|
||||
interface UpdateItemIntegrations {
|
||||
itemId: string;
|
||||
newIntegrations: BoardItemIntegration[];
|
||||
@@ -59,6 +64,9 @@ export const useItemActions = () => {
|
||||
width: 1,
|
||||
height: 1,
|
||||
integrations: [],
|
||||
advancedOptions: {
|
||||
customCssClasses: [],
|
||||
},
|
||||
} satisfies Omit<Item, "kind" | "yOffset" | "xOffset"> & {
|
||||
kind: WidgetKind;
|
||||
};
|
||||
@@ -91,7 +99,7 @@ export const useItemActions = () => {
|
||||
return {
|
||||
...section,
|
||||
items: section.items.map((item) => {
|
||||
// Return same item if item is not the one we're moving
|
||||
// Return same item if item is not the one we're changing
|
||||
if (item.id !== itemId) return item;
|
||||
return {
|
||||
...item,
|
||||
@@ -106,6 +114,33 @@ export const useItemActions = () => {
|
||||
[updateBoard],
|
||||
);
|
||||
|
||||
const updateItemAdvancedOptions = useCallback(
|
||||
({ itemId, newAdvancedOptions }: UpdateItemAdvancedOptions) => {
|
||||
updateBoard((previous) => {
|
||||
if (!previous) return previous;
|
||||
return {
|
||||
...previous,
|
||||
sections: previous.sections.map((section) => {
|
||||
// Return same section if item is not in it
|
||||
if (!section.items.some((item) => item.id === itemId)) return section;
|
||||
return {
|
||||
...section,
|
||||
items: section.items.map((item) => {
|
||||
// Return same item if item is not the one we're changing
|
||||
if (item.id !== itemId) return item;
|
||||
return {
|
||||
...item,
|
||||
advancedOptions: newAdvancedOptions,
|
||||
};
|
||||
}),
|
||||
};
|
||||
}),
|
||||
};
|
||||
});
|
||||
},
|
||||
[updateBoard],
|
||||
);
|
||||
|
||||
const updateItemIntegrations = useCallback(
|
||||
({ itemId, newIntegrations }: UpdateItemIntegrations) => {
|
||||
updateBoard((previous) => {
|
||||
@@ -224,6 +259,7 @@ export const useItemActions = () => {
|
||||
moveItemToSection,
|
||||
removeItem,
|
||||
updateItemOptions,
|
||||
updateItemAdvancedOptions,
|
||||
updateItemIntegrations,
|
||||
createItem,
|
||||
};
|
||||
|
||||
@@ -70,7 +70,11 @@ const BoardItem = ({ refs, item, opacity }: ItemProps) => {
|
||||
>
|
||||
<Card
|
||||
ref={ref}
|
||||
className={combineClasses(classes.itemCard, "grid-stack-item-content")}
|
||||
className={combineClasses(
|
||||
classes.itemCard,
|
||||
"grid-stack-item-content",
|
||||
item.advancedOptions.customCssClasses.join(" "),
|
||||
)}
|
||||
withBorder
|
||||
styles={{
|
||||
root: {
|
||||
@@ -123,7 +127,7 @@ const ItemMenu = ({ offset, item }: { offset: number; item: Item }) => {
|
||||
const { openModal } = useModalAction(WidgetEditModal);
|
||||
const { openConfirmModal } = useConfirmModal();
|
||||
const isEditMode = useAtomValue(editModeAtom);
|
||||
const { updateItemOptions, updateItemIntegrations, removeItem } = useItemActions();
|
||||
const { updateItemOptions, updateItemAdvancedOptions, updateItemIntegrations, removeItem } = useItemActions();
|
||||
const { data: integrationData, isPending } = clientApi.integration.all.useQuery();
|
||||
const currentDefinition = useMemo(() => widgetImports[item.kind].definition, [item.kind]);
|
||||
|
||||
@@ -133,14 +137,19 @@ const ItemMenu = ({ offset, item }: { offset: number; item: Item }) => {
|
||||
openModal({
|
||||
kind: item.kind,
|
||||
value: {
|
||||
advancedOptions: item.advancedOptions,
|
||||
options: item.options,
|
||||
integrations: item.integrations,
|
||||
},
|
||||
onSuccessfulEdit: ({ options, integrations }) => {
|
||||
onSuccessfulEdit: ({ options, integrations, advancedOptions }) => {
|
||||
updateItemOptions({
|
||||
itemId: item.id,
|
||||
newOptions: options,
|
||||
});
|
||||
updateItemAdvancedOptions({
|
||||
itemId: item.id,
|
||||
newAdvancedOptions: advancedOptions,
|
||||
});
|
||||
updateItemIntegrations({
|
||||
itemId: item.id,
|
||||
newIntegrations: integrations,
|
||||
|
||||
225
apps/nextjs/src/styles/prismjs.scss
Normal file
225
apps/nextjs/src/styles/prismjs.scss
Normal file
@@ -0,0 +1,225 @@
|
||||
[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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user