feat: board settings (#137)

* refactor: improve user feedback for general board settings section

* wip: add board settings for background and colors, move danger zone to own file, refactor code

* feat: add shade selector

* feat: add slider for opacity

* fix: issue with invalid hex values for color preview

* refactor: add shared mutation hook for saving partial board settings with invalidate query

* fix: add cleanup for not applied changes to logo and page title

* feat: add layout settings

* feat: add empty custom css section to board settings

* refactor: improve layout of board logo on mobile

* feat: add theme provider for board colors

* refactor: add auto contrast for better contrast of buttons with low primary shade

* feat: add background for boards

* feat: add opacity for boards

* feat: add rename board

* feat: add visibility and delete of board settings

* fix: issue that wrong data is updated with update board method

* refactor: improve danger zone button placement for mobile

* fix: board not revalidated when already in boards layout

* refactor: improve board color preview

* refactor: change save button color to teal, add placeholders for general board settings

* chore: update initial migration

* refactor: remove unnecessary div

* chore: address pull request feedback

* fix: ci issues

* fix: deepsource issues

* chore: address pull request feedback

* fix: formatting issue

* chore: address pull request feedback
This commit is contained in:
Meier Lukas
2024-03-03 16:01:32 +01:00
committed by GitHub
parent 2a83df3485
commit bb02163e25
49 changed files with 1620 additions and 406 deletions

View File

@@ -27,6 +27,7 @@
"@homarr/ui": "workspace:^0.1.0", "@homarr/ui": "workspace:^0.1.0",
"@homarr/validation": "workspace:^0.1.0", "@homarr/validation": "workspace:^0.1.0",
"@homarr/widgets": "workspace:^0.1.0", "@homarr/widgets": "workspace:^0.1.0",
"@mantine/colors-generator": "^7.5.3",
"@mantine/hooks": "^7.5.3", "@mantine/hooks": "^7.5.3",
"@mantine/modals": "^7.5.3", "@mantine/modals": "^7.5.3",
"@mantine/tiptap": "^7.5.3", "@mantine/tiptap": "^7.5.3",
@@ -41,6 +42,7 @@
"@trpc/next": "next", "@trpc/next": "next",
"@trpc/react-query": "next", "@trpc/react-query": "next",
"@trpc/server": "next", "@trpc/server": "next",
"chroma-js": "^2.4.2",
"dayjs": "^1.11.10", "dayjs": "^1.11.10",
"jotai": "^2.7.0", "jotai": "^2.7.0",
"mantine-modal-manager": "^7.5.3", "mantine-modal-manager": "^7.5.3",
@@ -59,6 +61,7 @@
"@types/node": "^20.11.24", "@types/node": "^20.11.24",
"@types/react": "^18.2.61", "@types/react": "^18.2.61",
"@types/react-dom": "^18.2.19", "@types/react-dom": "^18.2.19",
"@types/chroma-js": "2.4.4",
"dotenv-cli": "^7.3.0", "dotenv-cli": "^7.3.0",
"eslint": "^8.57.0", "eslint": "^8.57.0",
"prettier": "^3.2.5", "prettier": "^3.2.5",

View File

@@ -1,32 +0,0 @@
"use client";
import type { PropsWithChildren } from "react";
import { useRouter } from "next/navigation";
import type { IntegrationKind } from "@homarr/definitions";
import { Accordion } from "@homarr/ui";
type IntegrationGroupAccordionControlProps = PropsWithChildren<{
activeTab: IntegrationKind | undefined;
}>;
export const IntegrationGroupAccordion = ({
children,
activeTab,
}: IntegrationGroupAccordionControlProps) => {
const router = useRouter();
return (
<Accordion
variant="separated"
defaultValue={activeTab}
onChange={(tab) =>
tab
? router.replace(`?tab=${tab}`, {})
: router.replace("/integrations")
}
>
{children}
</Accordion>
);
};

View File

@@ -2,8 +2,8 @@ import Link from "next/link";
import type { RouterOutputs } from "@homarr/api"; import type { RouterOutputs } from "@homarr/api";
import { objectEntries } from "@homarr/common"; import { objectEntries } from "@homarr/common";
import { getIntegrationName } from "@homarr/definitions";
import type { IntegrationKind } from "@homarr/definitions"; import type { IntegrationKind } from "@homarr/definitions";
import { getIntegrationName } from "@homarr/definitions";
import { getScopedI18n } from "@homarr/translation/server"; import { getScopedI18n } from "@homarr/translation/server";
import { import {
AccordionControl, AccordionControl,
@@ -33,7 +33,7 @@ import {
} from "@homarr/ui"; } from "@homarr/ui";
import { api } from "~/trpc/server"; import { api } from "~/trpc/server";
import { IntegrationGroupAccordion } from "./_integration-accordion"; import { ActiveTabAccordion } from "../../../../components/active-tab-accordion";
import { IntegrationAvatar } from "./_integration-avatar"; import { IntegrationAvatar } from "./_integration-avatar";
import { DeleteIntegrationActionButton } from "./_integration-buttons"; import { DeleteIntegrationActionButton } from "./_integration-buttons";
import { IntegrationCreateDropdownContent } from "./new/_integration-new-dropdown"; import { IntegrationCreateDropdownContent } from "./new/_integration-new-dropdown";
@@ -112,7 +112,7 @@ const IntegrationList = async ({
); );
return ( return (
<IntegrationGroupAccordion activeTab={activeTab}> <ActiveTabAccordion defaultValue={activeTab} variant="separated">
{objectEntries(grouppedIntegrations).map(([kind, integrations]) => ( {objectEntries(grouppedIntegrations).map(([kind, integrations]) => (
<AccordionItem key={kind} value={kind}> <AccordionItem key={kind} value={kind}>
<AccordionControl icon={<IntegrationAvatar size="sm" kind={kind} />}> <AccordionControl icon={<IntegrationAvatar size="sm" kind={kind} />}>
@@ -170,6 +170,6 @@ const IntegrationList = async ({
</AccordionPanel> </AccordionPanel>
</AccordionItem> </AccordionItem>
))} ))}
</IntegrationGroupAccordion> </ActiveTabAccordion>
); );
}; };

View File

@@ -22,6 +22,7 @@ import {
} from "@homarr/ui"; } from "@homarr/ui";
import { modalEvents } from "~/app/[locale]/modals"; import { modalEvents } from "~/app/[locale]/modals";
import { revalidatePathAction } from "~/app/revalidatePathAction";
import { editModeAtom } from "~/components/board/editMode"; import { editModeAtom } from "~/components/board/editMode";
import { useCategoryActions } from "~/components/board/sections/category/category-actions"; import { useCategoryActions } from "~/components/board/sections/category/category-actions";
import { HeaderButton } from "~/components/layout/header/button"; import { HeaderButton } from "~/components/layout/header/button";
@@ -107,6 +108,7 @@ const AddMenu = () => {
const EditModeMenu = () => { const EditModeMenu = () => {
const [isEditMode, setEditMode] = useAtom(editModeAtom); const [isEditMode, setEditMode] = useAtom(editModeAtom);
const board = useRequiredBoard(); const board = useRequiredBoard();
const utils = clientApi.useUtils();
const t = useScopedI18n("board.action.edit"); const t = useScopedI18n("board.action.edit");
const { mutate: saveBoard, isPending } = clientApi.board.save.useMutation({ const { mutate: saveBoard, isPending } = clientApi.board.save.useMutation({
onSuccess() { onSuccess() {
@@ -114,6 +116,8 @@ const EditModeMenu = () => {
title: t("notification.success.title"), title: t("notification.success.title"),
message: t("notification.success.message"), message: t("notification.success.message"),
}); });
void utils.board.byName.invalidate({ name: board.name });
void revalidatePathAction(`/boards/${board.name}`);
setEditMode(false); setEditMode(false);
}, },
onError() { onError() {
@@ -125,11 +129,7 @@ const EditModeMenu = () => {
}); });
const toggle = () => { const toggle = () => {
if (isEditMode) if (isEditMode) return saveBoard(board);
return saveBoard({
boardId: board.id,
...board,
});
setEditMode(true); setEditMode(true);
}; };

View File

@@ -0,0 +1,140 @@
"use client";
import {
backgroundImageAttachments,
backgroundImageRepeats,
backgroundImageSizes,
} from "@homarr/definitions";
import { useForm } from "@homarr/form";
import type { TranslationObject } from "@homarr/translation";
import { useI18n } from "@homarr/translation/client";
import type { SelectItemWithDescriptionBadge } from "@homarr/ui";
import {
Button,
Grid,
Group,
SelectWithDescriptionBadge,
Stack,
TextInput,
} from "@homarr/ui";
import type { Board } from "../../_types";
import { useSavePartialSettingsMutation } from "./_shared";
interface Props {
board: Board;
}
export const BackgroundSettingsContent = ({ board }: Props) => {
const t = useI18n();
const { mutate: savePartialSettings, isPending } =
useSavePartialSettingsMutation(board);
const form = useForm({
initialValues: {
backgroundImageUrl: board.backgroundImageUrl ?? "",
backgroundImageAttachment: board.backgroundImageAttachment,
backgroundImageRepeat: board.backgroundImageRepeat,
backgroundImageSize: board.backgroundImageSize,
},
});
const backgroundImageAttachmentData = useBackgroundOptionData(
"backgroundImageAttachment",
backgroundImageAttachments,
);
const backgroundImageSizeData = useBackgroundOptionData(
"backgroundImageSize",
backgroundImageSizes,
);
const backgroundImageRepeatData = useBackgroundOptionData(
"backgroundImageRepeat",
backgroundImageRepeats,
);
return (
<form
onSubmit={form.onSubmit((values) => {
savePartialSettings({
id: board.id,
...values,
});
})}
>
<Stack>
<Grid>
<Grid.Col span={12}>
<TextInput
label={t("board.field.backgroundImageUrl.label")}
{...form.getInputProps("backgroundImageUrl")}
/>
</Grid.Col>
<Grid.Col span={12}>
<SelectWithDescriptionBadge
label={t("board.field.backgroundImageAttachment.label")}
data={backgroundImageAttachmentData}
{...form.getInputProps("backgroundImageAttachment")}
/>
</Grid.Col>
<Grid.Col span={12}>
<SelectWithDescriptionBadge
label={t("board.field.backgroundImageSize.label")}
data={backgroundImageSizeData}
{...form.getInputProps("backgroundImageSize")}
/>
</Grid.Col>
<Grid.Col span={12}>
<SelectWithDescriptionBadge
label={t("board.field.backgroundImageRepeat.label")}
data={backgroundImageRepeatData}
{...form.getInputProps("backgroundImageRepeat")}
/>
</Grid.Col>
</Grid>
<Group justify="end">
<Button type="submit" loading={isPending} color="teal">
{t("common.action.saveChanges")}
</Button>
</Group>
</Stack>
</form>
);
};
type BackgroundImageKey =
| "backgroundImageAttachment"
| "backgroundImageSize"
| "backgroundImageRepeat";
type inferOptions<TKey extends BackgroundImageKey> =
TranslationObject["board"]["field"][TKey]["option"];
const useBackgroundOptionData = <
TKey extends BackgroundImageKey,
TOptions extends inferOptions<TKey> = inferOptions<TKey>,
>(
key: TKey,
data: {
values: (keyof TOptions)[];
defaultValue: keyof TOptions;
},
) => {
const t = useI18n();
return data.values.map(
(value) =>
({
label: t(`board.field.${key}.option.${value as string}.label` as never),
description: t(
`board.field.${key}.option.${value as string}.description` as never,
),
value: value as string,
badge:
data.defaultValue === value
? {
color: "blue",
label: t("common.select.badge.recommended"),
}
: undefined,
}) satisfies SelectItemWithDescriptionBadge,
);
};

View File

@@ -0,0 +1,157 @@
"use client";
import { useDisclosure } from "@mantine/hooks";
import { useForm } from "@homarr/form";
import { useI18n } from "@homarr/translation/client";
import {
Anchor,
Button,
Collapse,
ColorInput,
ColorSwatch,
Grid,
Group,
InputWrapper,
isLightColor,
Slider,
Stack,
Text,
useMantineTheme,
} from "@homarr/ui";
import { generateColors } from "../../_theme";
import type { Board } from "../../_types";
import { useSavePartialSettingsMutation } from "./_shared";
interface Props {
board: Board;
}
const hexRegex = /^#[0-9a-fA-F]{6}$/;
const progressPercentageLabel = (value: number) => `${value}%`;
export const ColorSettingsContent = ({ board }: Props) => {
const form = useForm({
initialValues: {
primaryColor: board.primaryColor,
secondaryColor: board.secondaryColor,
opacity: board.opacity,
},
});
const [showPreview, { toggle }] = useDisclosure(false);
const t = useI18n();
const theme = useMantineTheme();
const { mutate: savePartialSettings, isPending } =
useSavePartialSettingsMutation(board);
return (
<form
onSubmit={form.onSubmit((values) => {
savePartialSettings({
id: board.id,
...values,
});
})}
>
<Stack>
<Grid>
<Grid.Col span={{ sm: 12, md: 6 }}>
<Stack gap="xs">
<ColorInput
label={t("board.field.primaryColor.label")}
format="hex"
swatches={Object.values(theme.colors).map((color) => color[6])}
{...form.getInputProps("primaryColor")}
/>
</Stack>
</Grid.Col>
<Grid.Col span={{ sm: 12, md: 6 }}>
<ColorInput
label={t("board.field.secondaryColor.label")}
format="hex"
swatches={Object.values(theme.colors).map((color) => color[6])}
{...form.getInputProps("secondaryColor")}
/>
</Grid.Col>
<Grid.Col span={12}>
<Anchor onClick={toggle}>
{showPreview
? t("common.preview.hide")
: t("common.preview.show")}
</Anchor>
</Grid.Col>
<Grid.Col span={12}>
<Collapse in={showPreview}>
<Stack>
<ColorsPreview previewColor={form.values.primaryColor} />
<ColorsPreview previewColor={form.values.secondaryColor} />
</Stack>
</Collapse>
</Grid.Col>
<Grid.Col span={{ sm: 12, md: 6 }}>
<InputWrapper label={t("board.field.opacity.label")}>
<Slider
my={6}
min={0}
max={100}
step={5}
label={progressPercentageLabel}
{...form.getInputProps("opacity")}
/>
</InputWrapper>
</Grid.Col>
</Grid>
<Group justify="end">
<Button type="submit" loading={isPending} color="teal">
{t("common.action.saveChanges")}
</Button>
</Group>
</Stack>
</form>
);
};
interface ColorsPreviewProps {
previewColor: string;
}
const ColorsPreview = ({ previewColor }: ColorsPreviewProps) => {
const theme = useMantineTheme();
const colors = hexRegex.test(previewColor)
? generateColors(previewColor)
: generateColors("#000000");
return (
<Group gap={0} wrap="nowrap">
{colors.map((color, index) => (
<ColorSwatch
key={index}
color={color}
w="10%"
pb="10%"
c={isLightColor(color) ? "black" : "white"}
radius={0}
styles={{
colorOverlay: {
borderTopLeftRadius: index === 0 ? theme.radius.md : 0,
borderBottomLeftRadius: index === 0 ? theme.radius.md : 0,
borderTopRightRadius: index === 9 ? theme.radius.md : 0,
borderBottomRightRadius: index === 9 ? theme.radius.md : 0,
},
}}
>
<Stack align="center" gap={4}>
<Text visibleFrom="md" fw={500} size="lg">
{index}
</Text>
<Text visibleFrom="md" fw={500} size="xs" tt="uppercase">
{color}
</Text>
</Stack>
</ColorSwatch>
))}
</Group>
);
};

View File

@@ -0,0 +1,7 @@
"use client";
// TODO: add some sort of store (maybe directory on GitHub)
export const CustomCssSettingsContent = () => {
return null;
};

View File

@@ -0,0 +1,165 @@
"use client";
import { useCallback } from "react";
import { useRouter } from "next/navigation";
import { clientApi } from "@homarr/api/client";
import { useScopedI18n } from "@homarr/translation/client";
import { Button, Divider, Group, Stack, Text } from "@homarr/ui";
import { modalEvents } from "~/app/[locale]/modals";
import { useRequiredBoard } from "../../_context";
import classes from "./danger.module.css";
export const DangerZoneSettingsContent = () => {
const board = useRequiredBoard();
const t = useScopedI18n("board.setting");
const router = useRouter();
const { mutate: changeVisibility, isPending: isChangeVisibilityPending } =
clientApi.board.changeVisibility.useMutation();
const { mutate: deleteBoard, isPending: isDeletePending } =
clientApi.board.delete.useMutation();
const utils = clientApi.useUtils();
const visibility = board.isPublic ? "public" : "private";
const onRenameClick = useCallback(
() =>
modalEvents.openManagedModal({
modal: "boardRenameModal",
title: t("section.dangerZone.action.rename.modal.title"),
innerProps: {
id: board.id,
previousName: board.name,
onSuccess: (name) => {
router.push(`/boards/${name}/settings`);
},
},
}),
[board.id, board.name, router, t],
);
const onVisibilityClick = useCallback(() => {
modalEvents.openConfirmModal({
title: t(
`section.dangerZone.action.visibility.confirm.${visibility}.title`,
),
children: t(
`section.dangerZone.action.visibility.confirm.${visibility}.description`,
),
confirmProps: {
color: "red.9",
},
onConfirm: () => {
changeVisibility(
{
id: board.id,
visibility: visibility === "public" ? "private" : "public",
},
{
onSettled() {
void utils.board.byName.invalidate({ name: board.name });
void utils.board.default.invalidate();
},
},
);
},
});
}, [
board.id,
board.name,
changeVisibility,
t,
utils.board.byName,
utils.board.default,
visibility,
]);
const onDeleteClick = useCallback(() => {
modalEvents.openConfirmModal({
title: t("section.dangerZone.action.delete.confirm.title"),
children: t("section.dangerZone.action.delete.confirm.description"),
confirmProps: {
color: "red.9",
},
onConfirm: () => {
deleteBoard(
{ id: board.id },
{
onSettled: () => {
router.push("/");
},
},
);
},
});
}, [board.id, deleteBoard, router, t]);
return (
<Stack gap="sm">
<Divider />
<DangerZoneRow
label={t("section.dangerZone.action.rename.label")}
description={t("section.dangerZone.action.rename.description")}
buttonText={t("section.dangerZone.action.rename.button")}
onClick={onRenameClick}
/>
<Divider />
<DangerZoneRow
label={t("section.dangerZone.action.visibility.label")}
description={t(
`section.dangerZone.action.visibility.description.${visibility}`,
)}
buttonText={t(
`section.dangerZone.action.visibility.button.${visibility}`,
)}
onClick={onVisibilityClick}
isPending={isChangeVisibilityPending}
/>
<Divider />
<DangerZoneRow
label={t("section.dangerZone.action.delete.label")}
description={t("section.dangerZone.action.delete.description")}
buttonText={t("section.dangerZone.action.delete.button")}
onClick={onDeleteClick}
isPending={isDeletePending}
/>
</Stack>
);
};
interface DangerZoneRowProps {
label: string;
description: string;
buttonText: string;
isPending?: boolean;
onClick: () => void;
}
const DangerZoneRow = ({
label,
description,
buttonText,
onClick,
isPending,
}: DangerZoneRowProps) => {
return (
<Group justify="space-between" px="md" className={classes.dangerZoneGroup}>
<Stack gap={0}>
<Text fw="bold" size="sm">
{label}
</Text>
<Text size="sm">{description}</Text>
</Stack>
<Group justify="end" w={{ base: "100%", xs: "auto" }}>
<Button
variant="subtle"
color="red"
loading={isPending}
onClick={onClick}
>
{buttonText}
</Button>
</Group>
</Group>
);
};

View File

@@ -1,19 +1,28 @@
"use client"; "use client";
import { useEffect } from "react"; import { useEffect, useRef } from "react";
import { import {
useDebouncedValue, useDebouncedValue,
useDocumentTitle, useDocumentTitle,
useFavicon, useFavicon,
} from "@mantine/hooks"; } from "@mantine/hooks";
import { clientApi } from "@homarr/api/client";
import { useForm } from "@homarr/form"; import { useForm } from "@homarr/form";
import { useI18n } from "@homarr/translation/client"; import { useI18n } from "@homarr/translation/client";
import { Button, Grid, Group, Stack, TextInput } from "@homarr/ui"; import {
Button,
Grid,
Group,
IconAlertTriangle,
Loader,
Stack,
TextInput,
Tooltip,
} from "@homarr/ui";
import { useUpdateBoard } from "../../_client"; import { useUpdateBoard } from "../../_client";
import type { Board } from "../../_types"; import type { Board } from "../../_types";
import { useSavePartialSettingsMutation } from "./_shared";
interface Props { interface Props {
board: Board; board: Board;
@@ -21,15 +30,20 @@ interface Props {
export const GeneralSettingsContent = ({ board }: Props) => { export const GeneralSettingsContent = ({ board }: Props) => {
const t = useI18n(); const t = useI18n();
const ref = useRef({
pageTitle: board.pageTitle,
logoImageUrl: board.logoImageUrl,
});
const { updateBoard } = useUpdateBoard(); const { updateBoard } = useUpdateBoard();
const { mutate: saveGeneralSettings, isPending } =
clientApi.board.saveGeneralSettings.useMutation(); const { mutate: savePartialSettings, isPending } =
useSavePartialSettingsMutation(board);
const form = useForm({ const form = useForm({
initialValues: { initialValues: {
pageTitle: board.pageTitle, pageTitle: board.pageTitle ?? "",
logoImageUrl: board.logoImageUrl, logoImageUrl: board.logoImageUrl ?? "",
metaTitle: board.metaTitle, metaTitle: board.metaTitle ?? "",
faviconImageUrl: board.faviconImageUrl, faviconImageUrl: board.faviconImageUrl ?? "",
}, },
onValuesChange({ pageTitle }) { onValuesChange({ pageTitle }) {
updateBoard((previous) => ({ updateBoard((previous) => ({
@@ -39,15 +53,31 @@ export const GeneralSettingsContent = ({ board }: Props) => {
}, },
}); });
useMetaTitlePreview(form.values.metaTitle); const metaTitleStatus = useMetaTitlePreview(form.values.metaTitle);
useFaviconPreview(form.values.faviconImageUrl); const faviconStatus = useFaviconPreview(form.values.faviconImageUrl);
useLogoPreview(form.values.logoImageUrl); const logoStatus = useLogoPreview(form.values.logoImageUrl);
// Cleanup for not applied changes of the page title and logo image URL
useEffect(() => {
return () => {
updateBoard((previous) => ({
...previous,
pageTitle: ref.current.pageTitle,
logoImageUrl: ref.current.logoImageUrl,
}));
};
}, [updateBoard]);
return ( return (
<form <form
onSubmit={form.onSubmit((values) => { onSubmit={form.onSubmit((values) => {
saveGeneralSettings({ // Save the current values to the ref so that it does not reset if the form is submitted
boardId: board.id, ref.current = {
pageTitle: values.pageTitle,
logoImageUrl: values.logoImageUrl,
};
savePartialSettings({
id: board.id,
...values, ...values,
}); });
})} })}
@@ -57,30 +87,37 @@ export const GeneralSettingsContent = ({ board }: Props) => {
<Grid.Col span={{ xs: 12, md: 6 }}> <Grid.Col span={{ xs: 12, md: 6 }}>
<TextInput <TextInput
label={t("board.field.pageTitle.label")} label={t("board.field.pageTitle.label")}
placeholder="Homarr"
{...form.getInputProps("pageTitle")} {...form.getInputProps("pageTitle")}
/> />
</Grid.Col> </Grid.Col>
<Grid.Col span={{ xs: 12, md: 6 }}> <Grid.Col span={{ xs: 12, md: 6 }}>
<TextInput <TextInput
label={t("board.field.metaTitle.label")} label={t("board.field.metaTitle.label")}
placeholder="Default Board | Homarr"
rightSection={<PendingOrInvalidIndicator {...metaTitleStatus} />}
{...form.getInputProps("metaTitle")} {...form.getInputProps("metaTitle")}
/> />
</Grid.Col> </Grid.Col>
<Grid.Col span={{ xs: 12, md: 6 }}> <Grid.Col span={{ xs: 12, md: 6 }}>
<TextInput <TextInput
label={t("board.field.logoImageUrl.label")} label={t("board.field.logoImageUrl.label")}
placeholder="/logo/logo.png"
rightSection={<PendingOrInvalidIndicator {...logoStatus} />}
{...form.getInputProps("logoImageUrl")} {...form.getInputProps("logoImageUrl")}
/> />
</Grid.Col> </Grid.Col>
<Grid.Col span={{ xs: 12, md: 6 }}> <Grid.Col span={{ xs: 12, md: 6 }}>
<TextInput <TextInput
label={t("board.field.faviconImageUrl.label")} label={t("board.field.faviconImageUrl.label")}
placeholder="/logo/logo.png"
rightSection={<PendingOrInvalidIndicator {...faviconStatus} />}
{...form.getInputProps("faviconImageUrl")} {...form.getInputProps("faviconImageUrl")}
/> />
</Grid.Col> </Grid.Col>
</Grid> </Grid>
<Group justify="end"> <Group justify="end">
<Button type="submit" loading={isPending}> <Button type="submit" loading={isPending} color="teal">
{t("common.action.saveChanges")} {t("common.action.saveChanges")}
</Button> </Button>
</Group> </Group>
@@ -89,22 +126,59 @@ export const GeneralSettingsContent = ({ board }: Props) => {
); );
}; };
const PendingOrInvalidIndicator = ({
isPending,
isInvalid,
}: {
isPending: boolean;
isInvalid?: boolean;
}) => {
const t = useI18n();
if (isInvalid) {
return (
<Tooltip
multiline
w={220}
label={t("board.setting.section.general.unrecognizedLink")}
>
<IconAlertTriangle size="1rem" color="red" />
</Tooltip>
);
}
if (isPending) {
return <Loader size="xs" />;
}
return null;
};
const useLogoPreview = (url: string | null) => { const useLogoPreview = (url: string | null) => {
const { updateBoard } = useUpdateBoard(); const { updateBoard } = useUpdateBoard();
const [logoDebounced] = useDebouncedValue(url ?? "", 500); const [logoDebounced] = useDebouncedValue(url ?? "", 500);
useEffect(() => { useEffect(() => {
if (!logoDebounced.includes(".")) return; if (!logoDebounced.includes(".") && logoDebounced.length >= 1) return;
updateBoard((previous) => ({ updateBoard((previous) => ({
...previous, ...previous,
logoImageUrl: logoDebounced, logoImageUrl: logoDebounced.length >= 1 ? logoDebounced : null,
})); }));
}, [logoDebounced, updateBoard]); }, [logoDebounced, updateBoard]);
return {
isPending: (url ?? "") !== logoDebounced,
isInvalid: logoDebounced.length >= 1 && !logoDebounced.includes("."),
};
}; };
const useMetaTitlePreview = (title: string | null) => { const useMetaTitlePreview = (title: string | null) => {
const [metaTitleDebounced] = useDebouncedValue(title ?? "", 200); const [metaTitleDebounced] = useDebouncedValue(title ?? "", 200);
useDocumentTitle(metaTitleDebounced); useDocumentTitle(metaTitleDebounced);
return {
isPending: (title ?? "") !== metaTitleDebounced,
};
}; };
const validFaviconExtensions = ["ico", "png", "svg", "gif"]; const validFaviconExtensions = ["ico", "png", "svg", "gif"];
@@ -115,4 +189,9 @@ const isValidUrl = (url: string) =>
const useFaviconPreview = (url: string | null) => { const useFaviconPreview = (url: string | null) => {
const [faviconDebounced] = useDebouncedValue(url ?? "", 500); const [faviconDebounced] = useDebouncedValue(url ?? "", 500);
useFavicon(isValidUrl(faviconDebounced) ? faviconDebounced : ""); useFavicon(isValidUrl(faviconDebounced) ? faviconDebounced : "");
return {
isPending: (url ?? "") !== faviconDebounced,
isInvalid: faviconDebounced.length >= 1 && !isValidUrl(faviconDebounced),
};
}; };

View File

@@ -0,0 +1,54 @@
"use client";
import { useForm } from "@homarr/form";
import { useI18n } from "@homarr/translation/client";
import { Button, Grid, Group, Input, Slider, Stack } from "@homarr/ui";
import type { Board } from "../../_types";
import { useSavePartialSettingsMutation } from "./_shared";
interface Props {
board: Board;
}
export const LayoutSettingsContent = ({ board }: Props) => {
const t = useI18n();
const { mutate: savePartialSettings, isPending } =
useSavePartialSettingsMutation(board);
const form = useForm({
initialValues: {
columnCount: board.columnCount,
},
});
return (
<form
onSubmit={form.onSubmit((values) => {
savePartialSettings({
id: board.id,
...values,
});
})}
>
<Stack>
<Grid>
<Grid.Col span={{ sm: 12, md: 6 }}>
<Input.Wrapper label={t("board.field.columnCount.label")}>
<Slider
mt="xs"
min={1}
max={24}
step={1}
{...form.getInputProps("columnCount")}
/>
</Input.Wrapper>
</Grid.Col>
</Grid>
<Group justify="end">
<Button type="submit" loading={isPending} color="teal">
{t("common.action.saveChanges")}
</Button>
</Group>
</Stack>
</form>
);
};

View File

@@ -0,0 +1,13 @@
import { clientApi } from "@homarr/api/client";
import type { Board } from "../../_types";
export const useSavePartialSettingsMutation = (board: Board) => {
const utils = clientApi.useUtils();
return clientApi.board.savePartialSettings.useMutation({
onSettled() {
void utils.board.byName.invalidate({ name: board.name });
void utils.board.default.invalidate();
},
});
};

View File

@@ -0,0 +1,5 @@
@media (min-width: 36em) {
.dangerZoneGroup {
--group-wrap: nowrap !important;
}
}

View File

@@ -1,17 +1,19 @@
import type { PropsWithChildren } from "react";
import { capitalize } from "@homarr/common"; import { capitalize } from "@homarr/common";
import type { TranslationObject } from "@homarr/translation";
import { getScopedI18n } from "@homarr/translation/server"; import { getScopedI18n } from "@homarr/translation/server";
import type { TablerIconsProps } from "@homarr/ui";
import { import {
Accordion,
AccordionControl, AccordionControl,
AccordionItem, AccordionItem,
AccordionPanel, AccordionPanel,
Button,
Container, Container,
Divider,
Group,
IconAlertTriangle, IconAlertTriangle,
IconBrush, IconBrush,
IconFileTypeCss,
IconLayout, IconLayout,
IconPhoto,
IconSettings, IconSettings,
Stack, Stack,
Text, Text,
@@ -19,15 +21,27 @@ import {
} from "@homarr/ui"; } from "@homarr/ui";
import { api } from "~/trpc/server"; import { api } from "~/trpc/server";
import { ActiveTabAccordion } from "../../../../../components/active-tab-accordion";
import { BackgroundSettingsContent } from "./_background";
import { ColorSettingsContent } from "./_colors";
import { CustomCssSettingsContent } from "./_customCss";
import { DangerZoneSettingsContent } from "./_danger";
import { GeneralSettingsContent } from "./_general"; import { GeneralSettingsContent } from "./_general";
import { LayoutSettingsContent } from "./_layout";
interface Props { interface Props {
params: { params: {
name: string; name: string;
}; };
searchParams: {
tab?: keyof TranslationObject["board"]["setting"]["section"];
};
} }
export default async function BoardSettingsPage({ params }: Props) { export default async function BoardSettingsPage({
params,
searchParams,
}: Props) {
const board = await api.board.byName({ name: params.name }); const board = await api.board.byName({ name: params.name });
const t = await getScopedI18n("board.setting"); const t = await getScopedI18n("board.setting");
@@ -35,99 +49,82 @@ export default async function BoardSettingsPage({ params }: Props) {
<Container> <Container>
<Stack> <Stack>
<Title>{t("title", { boardName: capitalize(board.name) })}</Title> <Title>{t("title", { boardName: capitalize(board.name) })}</Title>
<Accordion variant="separated" defaultValue="general"> <ActiveTabAccordion
<AccordionItem value="general"> variant="separated"
<AccordionControl icon={<IconSettings />}> defaultValue={searchParams.tab ?? "general"}
<Text fw="bold" size="lg"> >
{t("section.general.title")} <AccordionItemFor value="general" icon={IconSettings}>
</Text> <GeneralSettingsContent board={board} />
</AccordionControl> </AccordionItemFor>
<AccordionPanel> <AccordionItemFor value="layout" icon={IconLayout}>
<GeneralSettingsContent board={board} /> <LayoutSettingsContent board={board} />
</AccordionPanel> </AccordionItemFor>
</AccordionItem> <AccordionItemFor value="background" icon={IconPhoto}>
<AccordionItem value="layout"> <BackgroundSettingsContent board={board} />
<AccordionControl icon={<IconLayout />}> </AccordionItemFor>
<Text fw="bold" size="lg"> <AccordionItemFor value="color" icon={IconBrush}>
{t("section.layout.title")} <ColorSettingsContent board={board} />
</Text> </AccordionItemFor>
</AccordionControl> <AccordionItemFor value="customCss" icon={IconFileTypeCss}>
<AccordionPanel></AccordionPanel> <CustomCssSettingsContent />
</AccordionItem> </AccordionItemFor>
<AccordionItem value="appearance"> <AccordionItemFor
<AccordionControl icon={<IconBrush />}> value="dangerZone"
<Text fw="bold" size="lg"> icon={IconAlertTriangle}
{t("section.appearance.title")} danger
</Text> noPadding
</AccordionControl>
<AccordionPanel></AccordionPanel>
</AccordionItem>
<AccordionItem
value="danger"
styles={{
item: {
"--__item-border-color": "rgba(248,81,73,0.4)",
},
}}
> >
<AccordionControl icon={<IconAlertTriangle />}> <DangerZoneSettingsContent />
<Text fw="bold" size="lg"> </AccordionItemFor>
{t("section.dangerZone.title")} </ActiveTabAccordion>
</Text>
</AccordionControl>
<AccordionPanel
styles={{ content: { paddingRight: 0, paddingLeft: 0 } }}
>
<Stack gap="sm">
<Divider />
<Group justify="space-between" px="md">
<Stack gap={0}>
<Text fw="bold" size="sm">
{t("section.dangerZone.action.rename.label")}
</Text>
<Text size="sm">
{t("section.dangerZone.action.rename.description")}
</Text>
</Stack>
<Button variant="subtle" color="red">
{t("section.dangerZone.action.rename.button")}
</Button>
</Group>
<Divider />
<Group justify="space-between" px="md">
<Stack gap={0}>
<Text fw="bold" size="sm">
{t("section.dangerZone.action.visibility.label")}
</Text>
<Text size="sm">
{t(
"section.dangerZone.action.visibility.description.private",
)}
</Text>
</Stack>
<Button variant="subtle" color="red">
{t("section.dangerZone.action.visibility.button.private")}
</Button>
</Group>
<Divider />
<Group justify="space-between" px="md">
<Stack gap={0}>
<Text fw="bold" size="sm">
{t("section.dangerZone.action.delete.label")}
</Text>
<Text size="sm">
{t("section.dangerZone.action.delete.description")}
</Text>
</Stack>
<Button variant="subtle" color="red">
{t("section.dangerZone.action.delete.button")}
</Button>
</Group>
</Stack>
</AccordionPanel>
</AccordionItem>
</Accordion>
</Stack> </Stack>
</Container> </Container>
); );
} }
type AccordionItemForProps = PropsWithChildren<{
value: keyof TranslationObject["board"]["setting"]["section"];
icon: (props: TablerIconsProps) => JSX.Element;
danger?: boolean;
noPadding?: boolean;
}>;
const AccordionItemFor = async ({
value,
children,
icon: Icon,
danger,
noPadding,
}: AccordionItemForProps) => {
const t = await getScopedI18n("board.setting.section");
return (
<AccordionItem
value={value}
styles={
danger
? {
item: {
"--__item-border-color": "rgba(248,81,73,0.4)",
borderWidth: 4,
},
}
: undefined
}
>
<AccordionControl icon={<Icon />}>
<Text fw="bold" size="lg">
{t(`${value}.title`)}
</Text>
</AccordionControl>
<AccordionPanel
styles={
noPadding
? { content: { paddingRight: 0, paddingLeft: 0 } }
: undefined
}
>
{children}
</AccordionPanel>
</AccordionItem>
);
};

View File

@@ -8,8 +8,14 @@ import { Box, LoadingOverlay, Stack } from "@homarr/ui";
import { BoardCategorySection } from "~/components/board/sections/category-section"; import { BoardCategorySection } from "~/components/board/sections/category-section";
import { BoardEmptySection } from "~/components/board/sections/empty-section"; import { BoardEmptySection } from "~/components/board/sections/empty-section";
import { BoardBackgroundVideo } from "~/components/layout/background";
import { useIsBoardReady, useRequiredBoard } from "./_context"; import { useIsBoardReady, useRequiredBoard } from "./_context";
import type { CategorySection, EmptySection } from "./_types";
let boardName: string | null = null;
export const updateBoardName = (name: string | null) => {
boardName = name;
};
type UpdateCallback = ( type UpdateCallback = (
prev: RouterOutputs["board"]["default"], prev: RouterOutputs["board"]["default"],
@@ -20,7 +26,10 @@ export const useUpdateBoard = () => {
const updateBoard = useCallback( const updateBoard = useCallback(
(updaterWithoutUndefined: UpdateCallback) => { (updaterWithoutUndefined: UpdateCallback) => {
utils.board.default.setData(undefined, (previous) => if (!boardName) {
throw new Error("Board name is not set");
}
utils.board.byName.setData({ name: boardName }, (previous) =>
previous ? updaterWithoutUndefined(previous) : previous, previous ? updaterWithoutUndefined(previous) : previous,
); );
}, },
@@ -36,21 +45,17 @@ export const ClientBoard = () => {
const board = useRequiredBoard(); const board = useRequiredBoard();
const isReady = useIsBoardReady(); const isReady = useIsBoardReady();
const sectionsWithoutSidebars = board.sections const sortedSections = board.sections.sort((a, b) => a.position - b.position);
.filter(
(section): section is CategorySection | EmptySection =>
section.kind !== "sidebar",
)
.sort((a, b) => a.position - b.position);
const ref = useRef<HTMLDivElement>(null); const ref = useRef<HTMLDivElement>(null);
return ( return (
<Box h="100%" pos="relative"> <Box h="100%" pos="relative">
<BoardBackgroundVideo />
<LoadingOverlay <LoadingOverlay
visible={!isReady} visible={!isReady}
transitionProps={{ duration: 500 }} transitionProps={{ duration: 500 }}
loaderProps={{ size: "lg", variant: "bars" }} loaderProps={{ size: "lg" }}
h="calc(100dvh - var(--app-shell-header-offset, 0px) - var(--app-shell-padding) - var(--app-shell-footer-offset, 0px) - var(--app-shell-padding))" h="calc(100dvh - var(--app-shell-header-offset, 0px) - var(--app-shell-padding) - var(--app-shell-footer-offset, 0px) - var(--app-shell-padding))"
/> />
<Stack <Stack
@@ -58,7 +63,7 @@ export const ClientBoard = () => {
h="100%" h="100%"
style={{ visibility: isReady ? "visible" : "hidden" }} style={{ visibility: isReady ? "visible" : "hidden" }}
> >
{sectionsWithoutSidebars.map((section) => {sortedSections.map((section) =>
section.kind === "empty" ? ( section.kind === "empty" ? (
<BoardEmptySection <BoardEmptySection
key={section.id} key={section.id}

View File

@@ -8,10 +8,13 @@ import {
useEffect, useEffect,
useState, useState,
} from "react"; } from "react";
import { usePathname } from "next/navigation";
import type { RouterOutputs } from "@homarr/api"; import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client"; import { clientApi } from "@homarr/api/client";
import { updateBoardName } from "./_client";
const BoardContext = createContext<{ const BoardContext = createContext<{
board: RouterOutputs["board"]["default"]; board: RouterOutputs["board"]["default"];
isReady: boolean; isReady: boolean;
@@ -21,14 +24,30 @@ const BoardContext = createContext<{
export const BoardProvider = ({ export const BoardProvider = ({
children, children,
initialBoard, initialBoard,
}: PropsWithChildren<{ initialBoard: RouterOutputs["board"]["default"] }>) => { }: PropsWithChildren<{ initialBoard: RouterOutputs["board"]["byName"] }>) => {
const pathname = usePathname();
const utils = clientApi.useUtils();
const [readySections, setReadySections] = useState<string[]>([]); const [readySections, setReadySections] = useState<string[]>([]);
const { data } = clientApi.board.default.useQuery(undefined, { const { data } = clientApi.board.byName.useQuery(
initialData: initialBoard, { name: initialBoard.name },
refetchOnMount: false, {
refetchOnWindowFocus: false, initialData: initialBoard,
refetchOnReconnect: false, refetchOnMount: false,
}); refetchOnWindowFocus: false,
refetchOnReconnect: false,
},
);
// Update the board name so it can be used within updateBoard method
updateBoardName(initialBoard.name);
// Invalidate the board when the pathname changes
// This allows to refetch the board when it might have changed - e.g. if someone else added an item
useEffect(() => {
return () => {
setReadySections([]);
void utils.board.byName.invalidate({ name: initialBoard.name });
};
}, [pathname, utils, initialBoard.name]);
useEffect(() => { useEffect(() => {
setReadySections((previous) => setReadySections((previous) =>

View File

@@ -15,6 +15,8 @@ import "../../../styles/gridstack.scss";
import { GlobalItemServerDataRunner } from "@homarr/widgets"; import { GlobalItemServerDataRunner } from "@homarr/widgets";
import { BoardMantineProvider } from "./_theme";
type Params = Record<string, unknown>; type Params = Record<string, unknown>;
interface Props<TParams extends Params> { interface Props<TParams extends Params> {
@@ -35,14 +37,16 @@ export const createBoardPage = <TParams extends Record<string, unknown>>({
return ( return (
<GlobalItemServerDataRunner board={initialBoard}> <GlobalItemServerDataRunner board={initialBoard}>
<BoardProvider initialBoard={initialBoard}> <BoardProvider initialBoard={initialBoard}>
<ClientShell hasNavigation={false}> <BoardMantineProvider>
<MainHeader <ClientShell hasNavigation={false}>
logo={<BoardLogoWithTitle size="md" />} <MainHeader
actions={headeractions} logo={<BoardLogoWithTitle size="md" hideTitleOnMobile />}
hasNavigation={false} actions={headeractions}
/> hasNavigation={false}
<AppShellMain>{children}</AppShellMain> />
</ClientShell> <AppShellMain>{children}</AppShellMain>
</ClientShell>
</BoardMantineProvider>
</BoardProvider> </BoardProvider>
</GlobalItemServerDataRunner> </GlobalItemServerDataRunner>
); );

View File

@@ -0,0 +1,50 @@
"use client";
import type { PropsWithChildren } from "react";
import type { MantineColorsTuple } from "@homarr/ui";
import { createTheme, darken, lighten, MantineProvider } from "@homarr/ui";
import { useRequiredBoard } from "./_context";
export const BoardMantineProvider = ({ children }: PropsWithChildren) => {
const board = useRequiredBoard();
const theme = createTheme({
colors: {
primaryColor: generateColors(board.primaryColor),
secondaryColor: generateColors(board.secondaryColor),
},
primaryColor: "primaryColor",
autoContrast: true,
});
return <MantineProvider theme={theme}>{children}</MantineProvider>;
};
export const generateColors = (hex: string) => {
const lightnessForColors = [
-0.25, -0.2, -0.15, -0.1, -0.05, 0, 0.05, 0.1, 0.15, 0.2,
] as const;
const rgbaColors = lightnessForColors.map((lightness) => {
if (lightness < 0) {
return lighten(hex, -lightness);
}
return darken(hex, lightness);
});
return rgbaColors.map((color) => {
return (
"#" +
color
.split("(")[1]!
.replaceAll(" ", "")
.replace(")", "")
.split(",")
.map((color) => parseInt(color, 10))
.slice(0, 3)
.map((color) => color.toString(16).padStart(2, "0"))
.join("")
);
}) as unknown as MantineColorsTuple;
};

View File

@@ -7,7 +7,6 @@ export type Item = Section["items"][number];
export type CategorySection = Extract<Section, { kind: "category" }>; export type CategorySection = Extract<Section, { kind: "category" }>;
export type EmptySection = Extract<Section, { kind: "empty" }>; export type EmptySection = Extract<Section, { kind: "empty" }>;
export type SidebarSection = Extract<Section, { kind: "sidebar" }>;
export type ItemOfKind<TKind extends WidgetKind> = Extract< export type ItemOfKind<TKind extends WidgetKind> = Extract<
Item, Item,

View File

@@ -6,11 +6,7 @@ import "@homarr/spotlight/styles.css";
import "@homarr/ui/styles.css"; import "@homarr/ui/styles.css";
import { Notifications } from "@homarr/notifications"; import { Notifications } from "@homarr/notifications";
import { import { ColorSchemeScript, createTheme, MantineProvider } from "@homarr/ui";
ColorSchemeScript,
MantineProvider,
uiConfiguration,
} from "@homarr/ui";
import { JotaiProvider } from "./_client-providers/jotai"; import { JotaiProvider } from "./_client-providers/jotai";
import { ModalsProvider } from "./_client-providers/modals"; import { ModalsProvider } from "./_client-providers/modals";
@@ -64,8 +60,11 @@ export default function Layout(props: {
(innerProps) => ( (innerProps) => (
<MantineProvider <MantineProvider
{...innerProps} {...innerProps}
defaultColorScheme={colorScheme} defaultColorScheme="dark"
{...uiConfiguration} theme={createTheme({
primaryColor: "red",
autoContrast: true,
})}
/> />
), ),
(innerProps) => <ModalsProvider {...innerProps} />, (innerProps) => <ModalsProvider {...innerProps} />,

View File

@@ -5,6 +5,7 @@ import { createModalManager } from "mantine-modal-manager";
import { WidgetEditModal } from "@homarr/widgets"; import { WidgetEditModal } from "@homarr/widgets";
import { ItemSelectModal } from "~/components/board/items/item-select-modal"; import { ItemSelectModal } from "~/components/board/items/item-select-modal";
import { BoardRenameModal } from "~/components/board/modals/board-rename-modal";
import { CategoryEditModal } from "~/components/board/sections/category/category-edit-modal"; import { CategoryEditModal } from "~/components/board/sections/category/category-edit-modal";
import { AddBoardModal } from "~/components/manage/boards/add-board-modal"; import { AddBoardModal } from "~/components/manage/boards/add-board-modal";
@@ -13,4 +14,5 @@ export const [ModalsManager, modalEvents] = createModalManager({
widgetEditModal: WidgetEditModal, widgetEditModal: WidgetEditModal,
itemSelectModal: ItemSelectModal, itemSelectModal: ItemSelectModal,
addBoardModal: AddBoardModal, addBoardModal: AddBoardModal,
boardRenameModal: BoardRenameModal,
}); });

View File

@@ -0,0 +1,45 @@
"use client";
import type { PropsWithChildren } from "react";
import { useCallback } from "react";
import { usePathname } from "next/navigation";
import { useShallowEffect } from "@mantine/hooks";
import type { AccordionProps } from "@homarr/ui";
import { Accordion } from "@homarr/ui";
type ActiveTabAccordionProps = PropsWithChildren<
Omit<AccordionProps<false>, "onChange">
>;
// Replace state without fetchign new data
const replace = (newUrl: string) => {
window.history.replaceState(
{ ...window.history.state, as: newUrl, url: newUrl },
"",
newUrl,
);
};
export const ActiveTabAccordion = ({
children,
...props
}: ActiveTabAccordionProps) => {
const pathname = usePathname();
const onChange = useCallback(
(tab: string | null) => (tab ? replace(`?tab=${tab}`) : replace(pathname)),
[pathname],
);
useShallowEffect(() => {
if (props.defaultValue) {
replace(`?tab=${props.defaultValue}`);
}
}, [props.defaultValue]);
return (
<Accordion {...props} onChange={onChange}>
{children}
</Accordion>
);
};

View File

@@ -0,0 +1,71 @@
"use client";
import type { ManagedModal } from "mantine-modal-manager";
import { clientApi } from "@homarr/api/client";
import { useForm } from "@homarr/form";
import { useI18n } from "@homarr/translation/client";
import { Button, Group, Stack, TextInput } from "@homarr/ui";
import type { validation, z } from "@homarr/validation";
interface InnerProps {
id: string;
previousName: string;
onSuccess?: (name: string) => void;
}
export const BoardRenameModal: ManagedModal<InnerProps> = ({
actions,
innerProps,
}) => {
const utils = clientApi.useUtils();
const t = useI18n();
const { mutate, isPending } = clientApi.board.rename.useMutation({
onSettled() {
void utils.board.byName.invalidate({ name: innerProps.previousName });
void utils.board.default.invalidate();
},
});
const form = useForm<FormType>({
initialValues: {
name: innerProps.previousName,
},
});
const handleSubmit = (values: FormType) => {
mutate(
{
id: innerProps.id,
name: values.name,
},
{
onSuccess: () => {
actions.closeModal();
innerProps.onSuccess?.(values.name);
},
},
);
};
return (
<form onSubmit={form.onSubmit(handleSubmit)}>
<Stack>
<TextInput
label={t("board.field.name.label")}
{...form.getInputProps("name")}
data-autofocus
/>
<Group justify="end">
<Button variant="subtle" color="gray" onClick={actions.closeModal}>
{t("common.action.cancel")}
</Button>
<Button type="submit" loading={isPending}>
{t("common.action.confirm")}
</Button>
</Group>
</Stack>
</form>
);
};
type FormType = Omit<z.infer<(typeof validation)["board"]["rename"]>, "id">;

View File

@@ -39,8 +39,6 @@ export const useCategoryActions = () => {
updateBoard((previous) => ({ updateBoard((previous) => ({
...previous, ...previous,
sections: [ sections: [
// Ignore sidebar sections
...previous.sections.filter((section) => section.kind === "sidebar"),
// Place sections before the new category // Place sections before the new category
...previous.sections.filter( ...previous.sections.filter(
(section) => (section) =>
@@ -235,12 +233,7 @@ export const useCategoryActions = () => {
...previous, ...previous,
sections: [ sections: [
...previous.sections.filter( ...previous.sections.filter(
(section) => section.kind === "sidebar", (section) => section.position < currentCategory.position - 1,
),
...previous.sections.filter(
(section) =>
(section.kind === "category" || section.kind === "empty") &&
section.position < currentCategory.position - 1,
), ),
{ {
...aboveWrapper, ...aboveWrapper,
@@ -253,7 +246,6 @@ export const useCategoryActions = () => {
...previous.sections ...previous.sections
.filter( .filter(
(section): section is CategorySection | EmptySection => (section): section is CategorySection | EmptySection =>
(section.kind === "category" || section.kind === "empty") &&
section.position >= currentCategory.position + 2, section.position >= currentCategory.position + 2,
) )
.map((section) => ({ .map((section) => ({

View File

@@ -2,6 +2,7 @@
// Ignored because of gridstack attributes // Ignored because of gridstack attributes
import type { RefObject } from "react"; import type { RefObject } from "react";
import cx from "clsx";
import { useAtomValue } from "jotai"; import { useAtomValue } from "jotai";
import { useScopedI18n } from "@homarr/translation/client"; import { useScopedI18n } from "@homarr/translation/client";
@@ -20,11 +21,13 @@ import {
useServerDataFor, useServerDataFor,
} from "@homarr/widgets"; } from "@homarr/widgets";
import { useRequiredBoard } from "~/app/[locale]/boards/_context";
import type { Item } from "~/app/[locale]/boards/_types"; import type { Item } from "~/app/[locale]/boards/_types";
import { modalEvents } from "~/app/[locale]/modals"; import { modalEvents } from "~/app/[locale]/modals";
import { editModeAtom } from "../editMode"; import { editModeAtom } from "../editMode";
import { useItemActions } from "../items/item-actions"; import { useItemActions } from "../items/item-actions";
import type { UseGridstackRefs } from "./gridstack/use-gridstack"; import type { UseGridstackRefs } from "./gridstack/use-gridstack";
import classes from "./item.module.css";
interface Props { interface Props {
items: Item[]; items: Item[];
@@ -32,6 +35,8 @@ interface Props {
} }
export const SectionContent = ({ items, refs }: Props) => { export const SectionContent = ({ items, refs }: Props) => {
const board = useRequiredBoard();
return ( return (
<> <>
{items.map((item) => { {items.map((item) => {
@@ -50,7 +55,15 @@ export const SectionContent = ({ items, refs }: Props) => {
gs-max-h={4} gs-max-h={4}
ref={refs.items.current[item.id] as RefObject<HTMLDivElement>} ref={refs.items.current[item.id] as RefObject<HTMLDivElement>}
> >
<Card className="grid-stack-item-content" withBorder> <Card
className={cx(classes.itemCard, "grid-stack-item-content")}
withBorder
styles={{
root: {
"--opacity": board.opacity / 100,
},
}}
>
<BoardItem item={item} /> <BoardItem item={item} />
</Card> </Card>
</div> </div>

View File

@@ -21,24 +21,18 @@ export const initializeGridstack = ({
sectionColumnCount, sectionColumnCount,
}: InitializeGridstackProps) => { }: InitializeGridstackProps) => {
if (!refs.wrapper.current) return false; if (!refs.wrapper.current) return false;
// calculates the currently available count of columns
const columnCount = section.kind === "sidebar" ? 2 : sectionColumnCount;
const minRow =
section.kind !== "sidebar"
? 1
: Math.floor(refs.wrapper.current.offsetHeight / 128);
// initialize gridstack // initialize gridstack
const newGrid = refs.gridstack; const newGrid = refs.gridstack;
newGrid.current = GridStack.init( newGrid.current = GridStack.init(
{ {
column: columnCount, column: sectionColumnCount,
margin: section.kind === "sidebar" ? 5 : 10, margin: 10,
cellHeight: 128, cellHeight: 128,
float: true, float: true,
alwaysShowResizeHandle: true, alwaysShowResizeHandle: true,
acceptWidgets: true, acceptWidgets: true,
staticGrid: true, staticGrid: true,
minRow, minRow: 1,
animate: false, animate: false,
styleInHead: true, styleInHead: true,
disableRemoveNodeOnDrop: true, disableRemoveNodeOnDrop: true,
@@ -49,7 +43,7 @@ export const initializeGridstack = ({
const grid = newGrid.current; const grid = newGrid.current;
if (!grid) return false; if (!grid) return false;
// Must be used to update the column count after the initialization // Must be used to update the column count after the initialization
grid.column(columnCount, "none"); grid.column(sectionColumnCount, "none");
grid.batchUpdate(); grid.batchUpdate();
grid.removeAll(false); grid.removeAll(false);

View File

@@ -48,7 +48,7 @@ export const useGridstack = ({
useCssVariableConfiguration({ section, mainRef, gridRef }); useCssVariableConfiguration({ section, mainRef, gridRef });
const sectionColumnCount = useSectionColumnCount(section.kind); const board = useRequiredBoard();
const items = useMemo(() => section.items, [section.items]); const items = useMemo(() => section.items, [section.items]);
@@ -125,7 +125,7 @@ export const useGridstack = ({
wrapper: wrapperRef, wrapper: wrapperRef,
gridstack: gridRef, gridstack: gridRef,
}, },
sectionColumnCount, sectionColumnCount: board.columnCount,
}); });
if (isReady) { if (isReady) {
@@ -134,7 +134,7 @@ export const useGridstack = ({
// Only run this effect when the section items change // Only run this effect when the section items change
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [items.length, section.items.length]); }, [items.length, section.items.length, board.columnCount]);
return { return {
refs: { refs: {
@@ -145,19 +145,6 @@ export const useGridstack = ({
}; };
}; };
/**
* Get the column count for the section
* For the sidebar it's always 2 otherwise it's the column count of the board
* @param sectionKind kind of the section
* @returns count of columns
*/
const useSectionColumnCount = (sectionKind: Section["kind"]) => {
const board = useRequiredBoard();
if (sectionKind === "sidebar") return 2;
return board.columnCount;
};
interface UseCssVariableConfiguration { interface UseCssVariableConfiguration {
section: Section; section: Section;
mainRef?: RefObject<HTMLDivElement>; mainRef?: RefObject<HTMLDivElement>;
@@ -177,7 +164,7 @@ const useCssVariableConfiguration = ({
mainRef, mainRef,
gridRef, gridRef,
}: UseCssVariableConfiguration) => { }: UseCssVariableConfiguration) => {
const sectionColumnCount = useSectionColumnCount(section.kind); const board = useRequiredBoard();
// Get reference to the :root element // Get reference to the :root element
const typeofDocument = typeof document; const typeofDocument = typeof document;
@@ -188,20 +175,20 @@ const useCssVariableConfiguration = ({
// Define widget-width by calculating the width of one column with mainRef width and column count // Define widget-width by calculating the width of one column with mainRef width and column count
useEffect(() => { useEffect(() => {
if (section.kind === "sidebar" || !mainRef?.current) return; if (!mainRef?.current) return;
const widgetWidth = mainRef.current.clientWidth / sectionColumnCount; const widgetWidth = mainRef.current.clientWidth / board.columnCount;
// widget width is used to define sizes of gridstack items within global.scss // widget width is used to define sizes of gridstack items within global.scss
root?.style.setProperty("--gridstack-widget-width", widgetWidth.toString()); root?.style.setProperty("--gridstack-widget-width", widgetWidth.toString());
gridRef.current?.cellHeight(widgetWidth); gridRef.current?.cellHeight(widgetWidth);
// gridRef.current is required otherwise the cellheight is run on production as undefined // gridRef.current is required otherwise the cellheight is run on production as undefined
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [sectionColumnCount, root, section.kind, mainRef, gridRef.current]); }, [board.columnCount, root, section.kind, mainRef, gridRef.current]);
// Define column count by using the sectionColumnCount // Define column count by using the sectionColumnCount
useEffect(() => { useEffect(() => {
root?.style.setProperty( root?.style.setProperty(
"--gridstack-column-count", "--gridstack-column-count",
sectionColumnCount.toString(), board.columnCount.toString(),
); );
}, [sectionColumnCount, root]); }, [board.columnCount, root]);
}; };

View File

@@ -0,0 +1,10 @@
.itemCard {
@mixin dark {
background-color: rgba(46, 46, 46, var(--opacity));
border-color: rgba(66, 66, 66, var(--opacity));
}
@mixin light {
background-color: rgba(255, 255, 255, var(--opacity));
border-color: rgba(222, 226, 230, var(--opacity));
}
}

View File

@@ -0,0 +1,62 @@
import { usePathname } from "next/navigation";
import type { AppShellProps } from "@homarr/ui";
import { useOptionalBoard } from "~/app/[locale]/boards/_context";
const supportedVideoFormats = ["mp4", "webm", "ogg"];
const isVideo = (url: string) =>
supportedVideoFormats.some((format) =>
url.toLowerCase().endsWith(`.${format}`),
);
export const useOptionalBackgroundProps = (): Partial<AppShellProps> => {
const board = useOptionalBoard();
const pathname = usePathname();
if (!board?.backgroundImageUrl) return {};
// Check if we are on a client board page
if (pathname.split("/").length > 3) return {};
if (isVideo(board.backgroundImageUrl)) {
return {};
}
return {
bg: `url(${board?.backgroundImageUrl})`,
bgp: "center center",
bgsz: board?.backgroundImageSize ?? "cover",
bgr: board?.backgroundImageRepeat ?? "no-repeat",
bga: board?.backgroundImageAttachment ?? "fixed",
};
};
export const BoardBackgroundVideo = () => {
const board = useOptionalBoard();
if (!board?.backgroundImageUrl) return null;
if (!isVideo(board.backgroundImageUrl)) return null;
const videoFormat = board.backgroundImageUrl.split(".").pop()?.toLowerCase();
if (!videoFormat) return null;
return (
<video
autoPlay
muted
loop
style={{
position: "fixed",
width: "100vw",
height: "100vh",
top: 0,
left: 0,
objectFit: board.backgroundImageSize ?? "cover",
}}
>
<source src={board.backgroundImageUrl} type={`video/${videoFormat}`} />
</video>
);
};

View File

@@ -25,14 +25,19 @@ export const BoardLogo = ({ size }: LogoProps) => {
interface CommonLogoWithTitleProps { interface CommonLogoWithTitleProps {
size: LogoWithTitleProps["size"]; size: LogoWithTitleProps["size"];
hideTitleOnMobile?: boolean;
} }
export const BoardLogoWithTitle = ({ size }: CommonLogoWithTitleProps) => { export const BoardLogoWithTitle = ({
size,
hideTitleOnMobile,
}: CommonLogoWithTitleProps) => {
const board = useRequiredBoard(); const board = useRequiredBoard();
const imageOptions = useImageOptions(); const imageOptions = useImageOptions();
return ( return (
<LogoWithTitle <LogoWithTitle
size={size} size={size}
hideTitleOnMobile={hideTitleOnMobile}
title={board.pageTitle ?? homarrPageTitle} title={board.pageTitle ?? homarrPageTitle}
image={imageOptions} image={imageOptions}
/> />

View File

@@ -34,15 +34,27 @@ export interface LogoWithTitleProps {
size: keyof typeof logoWithTitleSizes; size: keyof typeof logoWithTitleSizes;
title: string; title: string;
image: Omit<LogoProps, "size">; image: Omit<LogoProps, "size">;
hideTitleOnMobile?: boolean;
} }
export const LogoWithTitle = ({ size, title, image }: LogoWithTitleProps) => { export const LogoWithTitle = ({
size,
title,
image,
hideTitleOnMobile,
}: LogoWithTitleProps) => {
const { logoSize, titleOrder } = logoWithTitleSizes[size]; const { logoSize, titleOrder } = logoWithTitleSizes[size];
return ( return (
<Group gap="xs" wrap="nowrap"> <Group gap="xs" wrap="nowrap">
<Logo {...image} size={logoSize} /> <Logo {...image} size={logoSize} />
<Title order={titleOrder}>{title}</Title> <Title
order={titleOrder}
visibleFrom={hideTitleOnMobile ? "sm" : undefined}
textWrap="nowrap"
>
{title}
</Title>
</Group> </Group>
); );
}; };

View File

@@ -5,6 +5,7 @@ import { useAtomValue } from "jotai";
import { AppShell } from "@homarr/ui"; import { AppShell } from "@homarr/ui";
import { useOptionalBackgroundProps } from "./background";
import { navigationCollapsedAtom } from "./header/burger"; import { navigationCollapsedAtom } from "./header/burger";
interface ClientShellProps { interface ClientShellProps {
@@ -18,9 +19,11 @@ export const ClientShell = ({
children, children,
}: PropsWithChildren<ClientShellProps>) => { }: PropsWithChildren<ClientShellProps>) => {
const collapsed = useAtomValue(navigationCollapsedAtom); const collapsed = useAtomValue(navigationCollapsedAtom);
const backgroundProps = useOptionalBackgroundProps();
return ( return (
<AppShell <AppShell
{...backgroundProps}
header={hasHeader ? { height: 60 } : undefined} header={hasHeader ? { height: 60 } : undefined}
navbar={ navbar={
hasNavigation hasNavigation

View File

@@ -60,47 +60,6 @@
} }
} }
// Styling for sidebar grid-stack elements
@for $i from 1 to 96 {
.grid-stack.grid-stack-sidebar > .grid-stack-item[gs-w="#{$i}"] {
width: 128px * $i;
}
.grid-stack.grid-stack-sidebar > .grid-stack-item[gs-min-w="#{$i}"] {
min-width: 128px * $i;
}
.grid-stack.grid-stack-sidebar > .grid-stack-item[gs-max-w="#{$i}"] {
max-width: 128px * $i;
}
}
@for $i from 1 to 96 {
.grid-stack.grid-stack-sidebar > .grid-stack-item[gs-h="#{$i}"] {
height: 128px * $i;
}
.grid-stack.grid-stack-sidebar > .grid-stack-item[gs-min-h="#{$i}"] {
min-height: 128px * $i;
}
.grid-stack.grid-stack-sidebar > .grid-stack-item[gs-max-h="#{$i}"] {
max-height: 128px * $i;
}
}
@for $i from 1 to 3 {
.grid-stack.grid-stack-sidebar > .grid-stack-item[gs-x="#{$i}"] {
left: 128px * $i;
}
}
@for $i from 1 to 96 {
.grid-stack.grid-stack-sidebar > .grid-stack-item[gs-y="#{$i}"] {
top: 128px * $i;
}
}
.grid-stack.grid-stack-sidebar > .grid-stack-item {
min-width: 128px;
}
// General gridstack styling // General gridstack styling
.grid-stack > .grid-stack-item > .grid-stack-item-content, .grid-stack > .grid-stack-item > .grid-stack-item-content,
.grid-stack > .grid-stack-item > .placeholder-content { .grid-stack > .grid-stack-item > .placeholder-content {

View File

@@ -79,6 +79,24 @@ export const boardRouter = createTRPCRouter({
}); });
}); });
}), }),
rename: publicProcedure
.input(validation.board.rename)
.mutation(async ({ ctx, input }) => {
await noBoardWithSimilarName(ctx.db, input.name, [input.id]);
await ctx.db
.update(boards)
.set({ name: input.name })
.where(eq(boards.id, input.id));
}),
changeVisibility: publicProcedure
.input(validation.board.changeVisibility)
.mutation(async ({ ctx, input }) => {
await ctx.db
.update(boards)
.set({ isPublic: input.visibility === "public" })
.where(eq(boards.id, input.id));
}),
delete: publicProcedure delete: publicProcedure
.input(z.object({ id: z.string() })) .input(z.object({ id: z.string() }))
.mutation(async ({ ctx, input }) => { .mutation(async ({ ctx, input }) => {
@@ -92,11 +110,11 @@ export const boardRouter = createTRPCRouter({
.query(async ({ input, ctx }) => { .query(async ({ input, ctx }) => {
return await getFullBoardWithWhere(ctx.db, eq(boards.name, input.name)); return await getFullBoardWithWhere(ctx.db, eq(boards.name, input.name));
}), }),
saveGeneralSettings: publicProcedure savePartialSettings: publicProcedure
.input(validation.board.saveGeneralSettings) .input(validation.board.savePartialSettings)
.mutation(async ({ ctx, input }) => { .mutation(async ({ ctx, input }) => {
const board = await ctx.db.query.boards.findFirst({ const board = await ctx.db.query.boards.findFirst({
where: eq(boards.id, input.boardId), where: eq(boards.id, input.id),
}); });
if (!board) { if (!board) {
@@ -109,12 +127,30 @@ export const boardRouter = createTRPCRouter({
await ctx.db await ctx.db
.update(boards) .update(boards)
.set({ .set({
// general settings
pageTitle: input.pageTitle, pageTitle: input.pageTitle,
metaTitle: input.metaTitle, metaTitle: input.metaTitle,
logoImageUrl: input.logoImageUrl, logoImageUrl: input.logoImageUrl,
faviconImageUrl: input.faviconImageUrl, faviconImageUrl: input.faviconImageUrl,
// background settings
backgroundImageUrl: input.backgroundImageUrl,
backgroundImageAttachment: input.backgroundImageAttachment,
backgroundImageRepeat: input.backgroundImageRepeat,
backgroundImageSize: input.backgroundImageSize,
// color settings
primaryColor: input.primaryColor,
secondaryColor: input.secondaryColor,
opacity: input.opacity,
// custom css
customCss: input.customCss,
// layout settings
columnCount: input.columnCount,
}) })
.where(eq(boards.id, input.boardId)); .where(eq(boards.id, input.id));
}), }),
save: publicProcedure save: publicProcedure
.input(validation.board.save) .input(validation.board.save)
@@ -122,7 +158,7 @@ export const boardRouter = createTRPCRouter({
await ctx.db.transaction(async (tx) => { await ctx.db.transaction(async (tx) => {
const dbBoard = await getFullBoardWithWhere( const dbBoard = await getFullBoardWithWhere(
tx, tx,
eq(boards.id, input.boardId), eq(boards.id, input.id),
); );
const addedSections = filterAddedItems( const addedSections = filterAddedItems(
@@ -276,6 +312,32 @@ export const boardRouter = createTRPCRouter({
}), }),
}); });
const noBoardWithSimilarName = async (
db: Database,
name: string,
ignoredIds: string[] = [],
) => {
const boards = await db.query.boards.findMany({
columns: {
id: true,
name: true,
},
});
const board = boards.find(
(board) =>
board.name.toLowerCase() === name.toLowerCase() &&
!ignoredIds.includes(board.id),
);
if (board) {
throw new TRPCError({
code: "CONFLICT",
message: "Board with similar name already exists",
});
}
};
const getFullBoardWithWhere = async (db: Database, where: SQL<unknown>) => { const getFullBoardWithWhere = async (db: Database, where: SQL<unknown>) => {
const board = await db.query.boards.findFirst({ const board = await db.query.boards.findFirst({
where, where,

View File

@@ -66,7 +66,7 @@ describe("byName should return board by name", () => {
it("should throw error when not present"); it("should throw error when not present");
}); });
describe("saveGeneralSettings should save general settings", () => { describe("savePartialSettings should save general settings", () => {
it("should save general settings", async () => { it("should save general settings", async () => {
const db = createDb(); const db = createDb();
const caller = boardRouter.createCaller({ db, session: null }); const caller = boardRouter.createCaller({ db, session: null });
@@ -78,12 +78,12 @@ describe("saveGeneralSettings should save general settings", () => {
const { boardId } = await createFullBoardAsync(db, "default"); const { boardId } = await createFullBoardAsync(db, "default");
await caller.saveGeneralSettings({ await caller.savePartialSettings({
pageTitle: newPageTitle, pageTitle: newPageTitle,
metaTitle: newMetaTitle, metaTitle: newMetaTitle,
logoImageUrl: newLogoImageUrl, logoImageUrl: newLogoImageUrl,
faviconImageUrl: newFaviconImageUrl, faviconImageUrl: newFaviconImageUrl,
boardId, id: boardId,
}); });
}); });
@@ -92,12 +92,12 @@ describe("saveGeneralSettings should save general settings", () => {
const caller = boardRouter.createCaller({ db, session: null }); const caller = boardRouter.createCaller({ db, session: null });
const act = async () => const act = async () =>
await caller.saveGeneralSettings({ await caller.savePartialSettings({
pageTitle: "newPageTitle", pageTitle: "newPageTitle",
metaTitle: "newMetaTitle", metaTitle: "newMetaTitle",
logoImageUrl: "http://logo.image/url.png", logoImageUrl: "http://logo.image/url.png",
faviconImageUrl: "http://favicon.image/url.png", faviconImageUrl: "http://favicon.image/url.png",
boardId: "nonExistentBoardId", id: "nonExistentBoardId",
}); });
await expect(act()).rejects.toThrowError("Board not found"); await expect(act()).rejects.toThrowError("Board not found");
@@ -112,7 +112,7 @@ describe("save should save full board", () => {
const { boardId, sectionId } = await createFullBoardAsync(db, "default"); const { boardId, sectionId } = await createFullBoardAsync(db, "default");
await caller.save({ await caller.save({
boardId, id: boardId,
sections: [ sections: [
{ {
id: createId(), id: createId(),
@@ -149,7 +149,7 @@ describe("save should save full board", () => {
); );
await caller.save({ await caller.save({
boardId, id: boardId,
sections: [ sections: [
{ {
id: sectionId, id: sectionId,
@@ -208,7 +208,7 @@ describe("save should save full board", () => {
await db.insert(integrations).values(anotherIntegration); await db.insert(integrations).values(anotherIntegration);
await caller.save({ await caller.save({
boardId, id: boardId,
sections: [ sections: [
{ {
id: sectionId, id: sectionId,
@@ -269,7 +269,7 @@ describe("save should save full board", () => {
const newSectionId = createId(); const newSectionId = createId();
await caller.save({ await caller.save({
boardId, id: boardId,
sections: [ sections: [
{ {
id: newSectionId, id: newSectionId,
@@ -319,7 +319,7 @@ describe("save should save full board", () => {
const newItemId = createId(); const newItemId = createId();
await caller.save({ await caller.save({
boardId, id: boardId,
sections: [ sections: [
{ {
id: sectionId, id: sectionId,
@@ -392,7 +392,7 @@ describe("save should save full board", () => {
await db.insert(integrations).values(integration); await db.insert(integrations).values(integration);
await caller.save({ await caller.save({
boardId, id: boardId,
sections: [ sections: [
{ {
id: sectionId, id: sectionId,
@@ -459,7 +459,7 @@ describe("save should save full board", () => {
}); });
await caller.save({ await caller.save({
boardId, id: boardId,
sections: [ sections: [
{ {
id: sectionId, id: sectionId,
@@ -512,7 +512,7 @@ describe("save should save full board", () => {
); );
await caller.save({ await caller.save({
boardId, id: boardId,
sections: [ sections: [
{ {
id: sectionId, id: sectionId,
@@ -569,7 +569,7 @@ describe("save should save full board", () => {
const act = async () => const act = async () =>
await caller.save({ await caller.save({
boardId: "nonExistentBoardId", id: "nonExistentBoardId",
sections: [], sections: [],
}); });

View File

@@ -26,13 +26,10 @@ CREATE TABLE `board` (
`background_image_attachment` text DEFAULT 'fixed' NOT NULL, `background_image_attachment` text DEFAULT 'fixed' NOT NULL,
`background_image_repeat` text DEFAULT 'no-repeat' NOT NULL, `background_image_repeat` text DEFAULT 'no-repeat' NOT NULL,
`background_image_size` text DEFAULT 'cover' NOT NULL, `background_image_size` text DEFAULT 'cover' NOT NULL,
`primary_color` text DEFAULT 'red' NOT NULL, `primary_color` text DEFAULT '#fa5252' NOT NULL,
`secondary_color` text DEFAULT 'orange' NOT NULL, `secondary_color` text DEFAULT '#fd7e14' NOT NULL,
`primary_shade` integer DEFAULT 6 NOT NULL, `opacity` integer DEFAULT 100 NOT NULL,
`app_opacity` integer DEFAULT 100 NOT NULL,
`custom_css` text, `custom_css` text,
`show_right_sidebar` integer DEFAULT false NOT NULL,
`show_left_sidebar` integer DEFAULT false NOT NULL,
`column_count` integer DEFAULT 10 NOT NULL `column_count` integer DEFAULT 10 NOT NULL
); );
--> statement-breakpoint --> statement-breakpoint
@@ -106,6 +103,7 @@ CREATE TABLE `verificationToken` (
); );
--> statement-breakpoint --> statement-breakpoint
CREATE INDEX `userId_idx` ON `account` (`userId`);--> statement-breakpoint CREATE INDEX `userId_idx` ON `account` (`userId`);--> statement-breakpoint
CREATE UNIQUE INDEX `board_name_unique` ON `board` (`name`);--> statement-breakpoint
CREATE INDEX `integration_secret__kind_idx` ON `integrationSecret` (`kind`);--> statement-breakpoint CREATE INDEX `integration_secret__kind_idx` ON `integrationSecret` (`kind`);--> statement-breakpoint
CREATE INDEX `integration_secret__updated_at_idx` ON `integrationSecret` (`updated_at`);--> statement-breakpoint CREATE INDEX `integration_secret__updated_at_idx` ON `integrationSecret` (`updated_at`);--> statement-breakpoint
CREATE INDEX `integration__kind_idx` ON `integration` (`kind`);--> statement-breakpoint CREATE INDEX `integration__kind_idx` ON `integration` (`kind`);--> statement-breakpoint

View File

@@ -1,7 +1,7 @@
{ {
"version": "5", "version": "5",
"dialect": "sqlite", "dialect": "sqlite",
"id": "c9ea435a-5bbf-4439-84a1-55d3e2581b13", "id": "7ba12cbf-d8eb-4469-b7c5-7f31acb7717d",
"prevId": "00000000-0000-0000-0000-000000000000", "prevId": "00000000-0000-0000-0000-000000000000",
"tables": { "tables": {
"account": { "account": {
@@ -201,7 +201,7 @@
"primaryKey": false, "primaryKey": false,
"notNull": true, "notNull": true,
"autoincrement": false, "autoincrement": false,
"default": "'red'" "default": "'#fa5252'"
}, },
"secondary_color": { "secondary_color": {
"name": "secondary_color", "name": "secondary_color",
@@ -209,18 +209,10 @@
"primaryKey": false, "primaryKey": false,
"notNull": true, "notNull": true,
"autoincrement": false, "autoincrement": false,
"default": "'orange'" "default": "'#fd7e14'"
}, },
"primary_shade": { "opacity": {
"name": "primary_shade", "name": "opacity",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 6
},
"app_opacity": {
"name": "app_opacity",
"type": "integer", "type": "integer",
"primaryKey": false, "primaryKey": false,
"notNull": true, "notNull": true,
@@ -234,22 +226,6 @@
"notNull": false, "notNull": false,
"autoincrement": false "autoincrement": false
}, },
"show_right_sidebar": {
"name": "show_right_sidebar",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"show_left_sidebar": {
"name": "show_left_sidebar",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"column_count": { "column_count": {
"name": "column_count", "name": "column_count",
"type": "integer", "type": "integer",
@@ -259,7 +235,13 @@
"default": 10 "default": 10
} }
}, },
"indexes": {}, "indexes": {
"board_name_unique": {
"name": "board_name_unique",
"columns": ["name"],
"isUnique": true
}
},
"foreignKeys": {}, "foreignKeys": {},
"compositePrimaryKeys": {}, "compositePrimaryKeys": {},
"uniqueConstraints": {} "uniqueConstraints": {}

View File

@@ -5,8 +5,8 @@
{ {
"idx": 0, "idx": 0,
"version": "5", "version": "5",
"when": 1707511343363, "when": 1709409142712,
"tag": "0000_true_red_wolf", "tag": "0000_sloppy_bloodstorm",
"breakpoints": true "breakpoints": true
} }
] ]

View File

@@ -1,5 +1,4 @@
import type { AdapterAccount } from "@auth/core/adapters"; import type { AdapterAccount } from "@auth/core/adapters";
import type { MantineColor } from "@mantine/core";
import type { InferSelectModel } from "drizzle-orm"; import type { InferSelectModel } from "drizzle-orm";
import { relations } from "drizzle-orm"; import { relations } from "drizzle-orm";
import { import {
@@ -11,6 +10,11 @@ import {
text, text,
} from "drizzle-orm/sqlite-core"; } from "drizzle-orm/sqlite-core";
import {
backgroundImageAttachments,
backgroundImageRepeats,
backgroundImageSizes,
} from "@homarr/definitions";
import type { import type {
BackgroundImageAttachment, BackgroundImageAttachment,
BackgroundImageRepeat, BackgroundImageRepeat,
@@ -125,37 +129,20 @@ export const boards = sqliteTable("board", {
backgroundImageUrl: text("background_image_url"), backgroundImageUrl: text("background_image_url"),
backgroundImageAttachment: text("background_image_attachment") backgroundImageAttachment: text("background_image_attachment")
.$type<BackgroundImageAttachment>() .$type<BackgroundImageAttachment>()
.default("fixed") .default(backgroundImageAttachments.defaultValue)
.notNull(), .notNull(),
backgroundImageRepeat: text("background_image_repeat") backgroundImageRepeat: text("background_image_repeat")
.$type<BackgroundImageRepeat>() .$type<BackgroundImageRepeat>()
.default("no-repeat") .default(backgroundImageRepeats.defaultValue)
.notNull(), .notNull(),
backgroundImageSize: text("background_image_size") backgroundImageSize: text("background_image_size")
.$type<BackgroundImageSize>() .$type<BackgroundImageSize>()
.default("cover") .default(backgroundImageSizes.defaultValue)
.notNull(), .notNull(),
primaryColor: text("primary_color") primaryColor: text("primary_color").default("#fa5252").notNull(),
.$type<MantineColor>() secondaryColor: text("secondary_color").default("#fd7e14").notNull(),
.default("red") opacity: int("opacity").default(100).notNull(),
.notNull(),
secondaryColor: text("secondary_color")
.$type<MantineColor>()
.default("orange")
.notNull(),
primaryShade: int("primary_shade").default(6).notNull(),
appOpacity: int("app_opacity").default(100).notNull(),
customCss: text("custom_css"), customCss: text("custom_css"),
showRightSidebar: int("show_right_sidebar", {
mode: "boolean",
})
.default(false)
.notNull(),
showLeftSidebar: int("show_left_sidebar", {
mode: "boolean",
})
.default(false)
.notNull(),
columnCount: int("column_count").default(10).notNull(), columnCount: int("column_count").default(10).notNull(),
}); });

View File

@@ -0,0 +1,20 @@
export const createDefinition = <
const TKeys extends string[],
TOptions extends { defaultValue: TKeys[number] } | void,
>(
values: TKeys,
options: TOptions,
) => ({
values,
defaultValue: options?.defaultValue as TOptions extends {
defaultValue: infer T;
}
? T
: undefined,
});
export type inferDefinitionType<TDefinition> = TDefinition extends {
values: readonly (infer T)[];
}
? T
: never;

View File

@@ -1,13 +1,24 @@
export const backgroundImageAttachments = ["fixed", "scroll"] as const; import type { inferDefinitionType } from "./_definition";
export const backgroundImageRepeats = [ import { createDefinition } from "./_definition";
"repeat",
"repeat-x",
"repeat-y",
"no-repeat",
] as const;
export const backgroundImageSizes = ["cover", "contain"] as const;
export type BackgroundImageAttachment = export const backgroundImageAttachments = createDefinition(
(typeof backgroundImageAttachments)[number]; ["fixed", "scroll"],
export type BackgroundImageRepeat = (typeof backgroundImageRepeats)[number]; { defaultValue: "fixed" },
export type BackgroundImageSize = (typeof backgroundImageSizes)[number]; );
export const backgroundImageRepeats = createDefinition(
["repeat", "repeat-x", "repeat-y", "no-repeat"],
{ defaultValue: "no-repeat" },
);
export const backgroundImageSizes = createDefinition(["cover", "contain"], {
defaultValue: "cover",
});
export type BackgroundImageAttachment = inferDefinitionType<
typeof backgroundImageAttachments
>;
export type BackgroundImageRepeat = inferDefinitionType<
typeof backgroundImageRepeats
>;
export type BackgroundImageSize = inferDefinitionType<
typeof backgroundImageSizes
>;

View File

@@ -1,2 +1,2 @@
export const sectionKinds = ["category", "empty", "sidebar"] as const; export const sectionKinds = ["category", "empty"] as const;
export type SectionKind = (typeof sectionKinds)[number]; export type SectionKind = (typeof sectionKinds)[number];

View File

@@ -153,6 +153,12 @@ export default {
multiSelect: { multiSelect: {
placeholder: "Pick one or more values", placeholder: "Pick one or more values",
}, },
select: {
placeholder: "Pick value",
badge: {
recommended: "Recommended",
},
},
search: { search: {
placeholder: "Search for anything...", placeholder: "Search for anything...",
nothingFound: "Nothing found", nothingFound: "Nothing found",
@@ -172,6 +178,10 @@ export default {
}, },
}, },
noResults: "No results found", noResults: "No results found",
preview: {
show: "Show preview",
hide: "Hide preview",
},
}, },
section: { section: {
category: { category: {
@@ -299,18 +309,98 @@ export default {
faviconImageUrl: { faviconImageUrl: {
label: "Favicon image URL", label: "Favicon image URL",
}, },
backgroundImageUrl: {
label: "Background image URL",
},
backgroundImageAttachment: {
label: "Background image attachment",
option: {
fixed: {
label: "Fixed",
description: "Background stays in the same position.",
},
scroll: {
label: "Scroll",
description: "Background scrolls with your mouse.",
},
},
},
backgroundImageRepeat: {
label: "Background image repeat",
option: {
repeat: {
label: "Repeat",
description:
"The image is repeated as much as needed to cover the whole background image painting area.",
},
"no-repeat": {
label: "No repeat",
description:
"The image is not repeated and may not fill the entire space.",
},
"repeat-x": {
label: "Repeat X",
description: "Same as 'Repeat' but only on horizontal axis.",
},
"repeat-y": {
label: "Repeat Y",
description: "Same as 'Repeat' but only on vertical axis.",
},
},
},
backgroundImageSize: {
label: "Background image size",
option: {
cover: {
label: "Cover",
description:
"Scales the image as small as possible to cover the entire window by cropping excessive space.",
},
contain: {
label: "Contain",
description:
"Scales the image as large as possible within its container without cropping or stretching the image.",
},
},
},
primaryColor: {
label: "Primary color",
},
secondaryColor: {
label: "Secondary color",
},
opacity: {
label: "Opacity",
},
customCss: {
label: "Custom CSS",
},
columnCount: {
label: "Column count",
},
name: {
label: "Name",
},
}, },
setting: { setting: {
title: "Settings for {boardName} board", title: "Settings for {boardName} board",
section: { section: {
general: { general: {
title: "General", title: "General",
unrecognizedLink:
"The provided link is not recognized and won't preview, it might still work.",
}, },
layout: { layout: {
title: "Layout", title: "Layout",
}, },
appearance: { background: {
title: "Appearance", title: "Background",
},
color: {
title: "Colors",
},
customCss: {
title: "Custom css",
}, },
dangerZone: { dangerZone: {
title: "Danger Zone", title: "Danger Zone",
@@ -320,6 +410,9 @@ export default {
description: description:
"Changing the name will break any links to this board.", "Changing the name will break any links to this board.",
button: "Change name", button: "Change name",
modal: {
title: "Rename board",
},
}, },
visibility: { visibility: {
label: "Change board visibility", label: "Change board visibility",
@@ -331,12 +424,29 @@ export default {
public: "Make private", public: "Make private",
private: "Make public", private: "Make public",
}, },
confirm: {
public: {
title: "Make board private",
description:
"Are you sure you want to make this board private? This will hide the board from the public. Links for guest users will break.",
},
private: {
title: "Make board public",
description:
"Are you sure you want to make this board public? This will make the board accessible to everyone.",
},
},
}, },
delete: { delete: {
label: "Delete this board", label: "Delete this board",
description: description:
"Once you delete a board, there is no going back. Please be certain.", "Once you delete a board, there is no going back. Please be certain.",
button: "Delete this board", button: "Delete this board",
confirm: {
title: "Delete board",
description:
"Are you sure you want to delete this board? This will permanently delete the board and all its content.",
},
}, },
}, },
}, },

View File

@@ -1 +1,3 @@
export * from "./count-badge"; export * from "./count-badge";
export * from "./select-with-description";
export * from "./select-with-description-and-badge";

View File

@@ -0,0 +1,101 @@
"use client";
import { useCallback, useMemo } from "react";
import type { SelectProps } from "@mantine/core";
import { Combobox, Input, InputBase, useCombobox } from "@mantine/core";
import { useUncontrolled } from "@mantine/hooks";
interface BaseSelectItem {
value: string;
label: string;
}
export interface SelectWithCustomItemsProps<TSelectItem extends BaseSelectItem>
extends Pick<
SelectProps,
"label" | "error" | "defaultValue" | "value" | "onChange" | "placeholder"
> {
data: TSelectItem[];
onBlur?: (event: React.FocusEvent<HTMLButtonElement>) => void;
onFocus?: (event: React.FocusEvent<HTMLButtonElement>) => void;
}
type Props<TSelectItem extends BaseSelectItem> =
SelectWithCustomItemsProps<TSelectItem> & {
SelectOption: React.ComponentType<TSelectItem>;
};
export const SelectWithCustomItems = <TSelectItem extends BaseSelectItem>({
data,
onChange,
value,
defaultValue,
placeholder,
SelectOption,
...props
}: Props<TSelectItem>) => {
const combobox = useCombobox({
onDropdownClose: () => combobox.resetSelectedOption(),
});
const [_value, setValue] = useUncontrolled({
value,
defaultValue,
finalValue: null,
onChange,
});
const selectedOption = useMemo(
() => data.find((item) => item.value === _value),
[data, _value],
);
const options = data.map((item) => (
<Combobox.Option value={item.value} key={item.value}>
<SelectOption {...item} />
</Combobox.Option>
));
const toggle = useCallback(() => combobox.toggleDropdown(), [combobox]);
const onOptionSubmit = useCallback(
(value: string) => {
setValue(
value,
data.find((item) => item.value === value),
);
combobox.closeDropdown();
},
[setValue, data, combobox],
);
return (
<Combobox
store={combobox}
withinPortal={false}
onOptionSubmit={onOptionSubmit}
>
<Combobox.Target>
<InputBase
{...props}
component="button"
type="button"
pointer
rightSection={<Combobox.Chevron />}
onClick={toggle}
rightSectionPointerEvents="none"
multiline
>
{selectedOption ? (
<SelectOption {...selectedOption} />
) : (
<Input.Placeholder>{placeholder}</Input.Placeholder>
)}
</InputBase>
</Combobox.Target>
<Combobox.Dropdown>
<Combobox.Options>{options}</Combobox.Options>
</Combobox.Dropdown>
</Combobox>
);
};

View File

@@ -0,0 +1,49 @@
"use client";
import type { MantineColor } from "@mantine/core";
import { Badge, Group, Text } from "@mantine/core";
import type { SelectWithCustomItemsProps } from "./select-with-custom-items";
import { SelectWithCustomItems } from "./select-with-custom-items";
export interface SelectItemWithDescriptionBadge {
value: string;
label: string;
badge?: { label: string; color: MantineColor };
description: string;
}
type Props = SelectWithCustomItemsProps<SelectItemWithDescriptionBadge>;
export const SelectWithDescriptionBadge = (props: Props) => {
return (
<SelectWithCustomItems<SelectItemWithDescriptionBadge>
{...props}
SelectOption={SelectOption}
/>
);
};
const SelectOption = ({
label,
description,
badge,
}: SelectItemWithDescriptionBadge) => {
return (
<Group justify="space-between">
<div>
<Text fz="sm" fw={500}>
{label}
</Text>
<Text fz="xs" opacity={0.6}>
{description}
</Text>
</div>
{badge && (
<Badge color={badge.color} variant="outline" size="sm">
{badge.label}
</Badge>
)}
</Group>
);
};

View File

@@ -0,0 +1,35 @@
"use client";
import { Text } from "@mantine/core";
import type { SelectWithCustomItemsProps } from "./select-with-custom-items";
import { SelectWithCustomItems } from "./select-with-custom-items";
export interface SelectItemWithDescription {
value: string;
label: string;
description: string;
}
type Props = SelectWithCustomItemsProps<SelectItemWithDescription>;
export const SelectWithDescription = (props: Props) => {
return (
<SelectWithCustomItems<SelectItemWithDescription>
{...props}
SelectOption={SelectOption}
/>
);
};
const SelectOption = ({ label, description }: SelectItemWithDescription) => {
return (
<div>
<Text fz="sm" fw={500}>
{label}
</Text>
<Text fz="xs" opacity={0.6}>
{description}
</Text>
</div>
);
};

View File

@@ -1,7 +1,15 @@
import { z } from "zod"; import { z } from "zod";
import {
backgroundImageAttachments,
backgroundImageRepeats,
backgroundImageSizes,
} from "@homarr/definitions";
import { commonItemSchema, createSectionSchema } from "./shared"; import { commonItemSchema, createSectionSchema } from "./shared";
const hexColorSchema = z.string().regex(/^#[0-9A-Fa-f]{6}$/);
const boardNameSchema = z const boardNameSchema = z
.string() .string()
.min(1) .min(1)
@@ -12,36 +20,56 @@ const byNameSchema = z.object({
name: boardNameSchema, name: boardNameSchema,
}); });
const saveGeneralSettingsSchema = z.object({ const renameSchema = z.object({
pageTitle: z id: z.string(),
.string() name: boardNameSchema,
.nullable()
.transform((value) => (value?.trim().length === 0 ? null : value)),
metaTitle: z
.string()
.nullable()
.transform((value) => (value?.trim().length === 0 ? null : value)),
logoImageUrl: z
.string()
.nullable()
.transform((value) => (value?.trim().length === 0 ? null : value)),
faviconImageUrl: z
.string()
.nullable()
.transform((value) => (value?.trim().length === 0 ? null : value)),
boardId: z.string(),
}); });
const changeVisibilitySchema = z.object({
id: z.string(),
visibility: z.enum(["public", "private"]),
});
const trimmedNullableString = z
.string()
.nullable()
.transform((value) => (value?.trim().length === 0 ? null : value));
const savePartialSettingsSchema = z
.object({
pageTitle: trimmedNullableString,
metaTitle: trimmedNullableString,
logoImageUrl: trimmedNullableString,
faviconImageUrl: trimmedNullableString,
backgroundImageUrl: trimmedNullableString,
backgroundImageAttachment: z.enum(backgroundImageAttachments.values),
backgroundImageRepeat: z.enum(backgroundImageRepeats.values),
backgroundImageSize: z.enum(backgroundImageSizes.values),
primaryColor: hexColorSchema,
secondaryColor: hexColorSchema,
opacity: z.number().min(0).max(100),
customCss: z.string().max(16384),
columnCount: z.number().min(1).max(24),
})
.partial()
.and(
z.object({
id: z.string(),
}),
);
const saveSchema = z.object({ const saveSchema = z.object({
boardId: z.string(), id: z.string(),
sections: z.array(createSectionSchema(commonItemSchema)), sections: z.array(createSectionSchema(commonItemSchema)),
}); });
const createSchema = z.object({ name: z.string() }); const createSchema = z.object({ name: boardNameSchema });
export const boardSchemas = { export const boardSchemas = {
byName: byNameSchema, byName: byNameSchema,
saveGeneralSettings: saveGeneralSettingsSchema, savePartialSettings: savePartialSettingsSchema,
save: saveSchema, save: saveSchema,
create: createSchema, create: createSchema,
rename: renameSchema,
changeVisibility: changeVisibilitySchema,
}; };

View File

@@ -48,21 +48,6 @@ const createEmptySchema = <TItemSchema extends z.ZodTypeAny>(
items: z.array(itemSchema), items: z.array(itemSchema),
}); });
const createSidebarSchema = <TItemSchema extends z.ZodTypeAny>(
itemSchema: TItemSchema,
) =>
z.object({
id: z.string(),
kind: z.literal("sidebar"),
position: z.union([z.literal(0), z.literal(1)]),
items: z.array(itemSchema),
});
export const createSectionSchema = <TItemSchema extends z.ZodTypeAny>( export const createSectionSchema = <TItemSchema extends z.ZodTypeAny>(
itemSchema: TItemSchema, itemSchema: TItemSchema,
) => ) => z.union([createCategorySchema(itemSchema), createEmptySchema(itemSchema)]);
z.union([
createCategorySchema(itemSchema),
createEmptySchema(itemSchema),
createSidebarSchema(itemSchema),
]);

25
pnpm-lock.yaml generated
View File

@@ -92,6 +92,9 @@ importers:
'@homarr/widgets': '@homarr/widgets':
specifier: workspace:^0.1.0 specifier: workspace:^0.1.0
version: link:../../packages/widgets version: link:../../packages/widgets
'@mantine/colors-generator':
specifier: ^7.5.3
version: 7.5.3(chroma-js@2.4.2)
'@mantine/hooks': '@mantine/hooks':
specifier: ^7.5.3 specifier: ^7.5.3
version: 7.5.3(react@18.2.0) version: 7.5.3(react@18.2.0)
@@ -134,6 +137,9 @@ importers:
'@trpc/server': '@trpc/server':
specifier: next specifier: next
version: 11.0.0-next-beta.289 version: 11.0.0-next-beta.289
chroma-js:
specifier: ^2.4.2
version: 2.4.2
dayjs: dayjs:
specifier: ^1.11.10 specifier: ^1.11.10
version: 1.11.10 version: 1.11.10
@@ -174,6 +180,9 @@ importers:
'@homarr/tsconfig': '@homarr/tsconfig':
specifier: workspace:^0.1.0 specifier: workspace:^0.1.0
version: link:../../tooling/typescript version: link:../../tooling/typescript
'@types/chroma-js':
specifier: 2.4.4
version: 2.4.4
'@types/node': '@types/node':
specifier: ^20.11.24 specifier: ^20.11.24
version: 20.11.24 version: 20.11.24
@@ -1550,6 +1559,14 @@ packages:
'@jridgewell/sourcemap-codec': 1.4.15 '@jridgewell/sourcemap-codec': 1.4.15
dev: true dev: true
/@mantine/colors-generator@7.5.3(chroma-js@2.4.2):
resolution: {integrity: sha512-jWG9G53jq2htcNgR7b0KS3bL5yygJnhOQH6b/qcUw61I8cShwBg6xzNNnp4RHMmlRbzVRKCWXqttPwtmksMzSw==}
peerDependencies:
chroma-js: ^2.4.2
dependencies:
chroma-js: 2.4.2
dev: false
/@mantine/core@7.5.3(@mantine/hooks@7.5.3)(@types/react@18.2.61)(react-dom@18.2.0)(react@18.2.0): /@mantine/core@7.5.3(@mantine/hooks@7.5.3)(@types/react@18.2.61)(react-dom@18.2.0)(react@18.2.0):
resolution: {integrity: sha512-Wvv6DJXI+GX9mmKG5HITTh/24sCZ0RoYQHdTHh0tOfGnEy+RleyhA82UjnMsp0n2NjfCISBwbiKgfya6b2iaFw==} resolution: {integrity: sha512-Wvv6DJXI+GX9mmKG5HITTh/24sCZ0RoYQHdTHh0tOfGnEy+RleyhA82UjnMsp0n2NjfCISBwbiKgfya6b2iaFw==}
peerDependencies: peerDependencies:
@@ -2537,6 +2554,10 @@ packages:
'@types/node': 20.11.24 '@types/node': 20.11.24
dev: true dev: true
/@types/chroma-js@2.4.4:
resolution: {integrity: sha512-/DTccpHTaKomqussrn+ciEvfW4k6NAHzNzs/sts1TCqg333qNxOhy8TNIoQCmbGG3Tl8KdEhkGAssb1n3mTXiQ==}
dev: true
/@types/connect@3.4.38: /@types/connect@3.4.38:
resolution: {integrity: sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==} resolution: {integrity: sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==}
dependencies: dependencies:
@@ -3448,6 +3469,10 @@ packages:
engines: {node: '>=10'} engines: {node: '>=10'}
dev: false dev: false
/chroma-js@2.4.2:
resolution: {integrity: sha512-U9eDw6+wt7V8z5NncY2jJfZa+hUH8XEj8FQHgFJTrUFnJfXYf4Ml4adI2vXZOjqRDpFWtYVWypDfZwnJ+HIR4A==}
dev: false
/clean-stack@2.2.0: /clean-stack@2.2.0:
resolution: {integrity: sha512-4diC9HaTE+KRAMWhDhrGOECgWZxoevMc5TlkObMqNSsVU62PYzXZ/SMTjzyGAFF1YusgxGcSWTEXBhp0CPwQ1A==} resolution: {integrity: sha512-4diC9HaTE+KRAMWhDhrGOECgWZxoevMc5TlkObMqNSsVU62PYzXZ/SMTjzyGAFF1YusgxGcSWTEXBhp0CPwQ1A==}
engines: {node: '>=6'} engines: {node: '>=6'}