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/validation": "workspace:^0.1.0",
"@homarr/widgets": "workspace:^0.1.0",
"@mantine/colors-generator": "^7.5.3",
"@mantine/hooks": "^7.5.3",
"@mantine/modals": "^7.5.3",
"@mantine/tiptap": "^7.5.3",
@@ -41,6 +42,7 @@
"@trpc/next": "next",
"@trpc/react-query": "next",
"@trpc/server": "next",
"chroma-js": "^2.4.2",
"dayjs": "^1.11.10",
"jotai": "^2.7.0",
"mantine-modal-manager": "^7.5.3",
@@ -59,6 +61,7 @@
"@types/node": "^20.11.24",
"@types/react": "^18.2.61",
"@types/react-dom": "^18.2.19",
"@types/chroma-js": "2.4.4",
"dotenv-cli": "^7.3.0",
"eslint": "^8.57.0",
"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 { objectEntries } from "@homarr/common";
import { getIntegrationName } from "@homarr/definitions";
import type { IntegrationKind } from "@homarr/definitions";
import { getIntegrationName } from "@homarr/definitions";
import { getScopedI18n } from "@homarr/translation/server";
import {
AccordionControl,
@@ -33,7 +33,7 @@ import {
} from "@homarr/ui";
import { api } from "~/trpc/server";
import { IntegrationGroupAccordion } from "./_integration-accordion";
import { ActiveTabAccordion } from "../../../../components/active-tab-accordion";
import { IntegrationAvatar } from "./_integration-avatar";
import { DeleteIntegrationActionButton } from "./_integration-buttons";
import { IntegrationCreateDropdownContent } from "./new/_integration-new-dropdown";
@@ -112,7 +112,7 @@ const IntegrationList = async ({
);
return (
<IntegrationGroupAccordion activeTab={activeTab}>
<ActiveTabAccordion defaultValue={activeTab} variant="separated">
{objectEntries(grouppedIntegrations).map(([kind, integrations]) => (
<AccordionItem key={kind} value={kind}>
<AccordionControl icon={<IntegrationAvatar size="sm" kind={kind} />}>
@@ -170,6 +170,6 @@ const IntegrationList = async ({
</AccordionPanel>
</AccordionItem>
))}
</IntegrationGroupAccordion>
</ActiveTabAccordion>
);
};

View File

@@ -22,6 +22,7 @@ import {
} from "@homarr/ui";
import { modalEvents } from "~/app/[locale]/modals";
import { revalidatePathAction } from "~/app/revalidatePathAction";
import { editModeAtom } from "~/components/board/editMode";
import { useCategoryActions } from "~/components/board/sections/category/category-actions";
import { HeaderButton } from "~/components/layout/header/button";
@@ -107,6 +108,7 @@ const AddMenu = () => {
const EditModeMenu = () => {
const [isEditMode, setEditMode] = useAtom(editModeAtom);
const board = useRequiredBoard();
const utils = clientApi.useUtils();
const t = useScopedI18n("board.action.edit");
const { mutate: saveBoard, isPending } = clientApi.board.save.useMutation({
onSuccess() {
@@ -114,6 +116,8 @@ const EditModeMenu = () => {
title: t("notification.success.title"),
message: t("notification.success.message"),
});
void utils.board.byName.invalidate({ name: board.name });
void revalidatePathAction(`/boards/${board.name}`);
setEditMode(false);
},
onError() {
@@ -125,11 +129,7 @@ const EditModeMenu = () => {
});
const toggle = () => {
if (isEditMode)
return saveBoard({
boardId: board.id,
...board,
});
if (isEditMode) return saveBoard(board);
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";
import { useEffect } from "react";
import { useEffect, useRef } from "react";
import {
useDebouncedValue,
useDocumentTitle,
useFavicon,
} from "@mantine/hooks";
import { clientApi } from "@homarr/api/client";
import { useForm } from "@homarr/form";
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 type { Board } from "../../_types";
import { useSavePartialSettingsMutation } from "./_shared";
interface Props {
board: Board;
@@ -21,15 +30,20 @@ interface Props {
export const GeneralSettingsContent = ({ board }: Props) => {
const t = useI18n();
const ref = useRef({
pageTitle: board.pageTitle,
logoImageUrl: board.logoImageUrl,
});
const { updateBoard } = useUpdateBoard();
const { mutate: saveGeneralSettings, isPending } =
clientApi.board.saveGeneralSettings.useMutation();
const { mutate: savePartialSettings, isPending } =
useSavePartialSettingsMutation(board);
const form = useForm({
initialValues: {
pageTitle: board.pageTitle,
logoImageUrl: board.logoImageUrl,
metaTitle: board.metaTitle,
faviconImageUrl: board.faviconImageUrl,
pageTitle: board.pageTitle ?? "",
logoImageUrl: board.logoImageUrl ?? "",
metaTitle: board.metaTitle ?? "",
faviconImageUrl: board.faviconImageUrl ?? "",
},
onValuesChange({ pageTitle }) {
updateBoard((previous) => ({
@@ -39,15 +53,31 @@ export const GeneralSettingsContent = ({ board }: Props) => {
},
});
useMetaTitlePreview(form.values.metaTitle);
useFaviconPreview(form.values.faviconImageUrl);
useLogoPreview(form.values.logoImageUrl);
const metaTitleStatus = useMetaTitlePreview(form.values.metaTitle);
const faviconStatus = useFaviconPreview(form.values.faviconImageUrl);
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 (
<form
onSubmit={form.onSubmit((values) => {
saveGeneralSettings({
boardId: board.id,
// Save the current values to the ref so that it does not reset if the form is submitted
ref.current = {
pageTitle: values.pageTitle,
logoImageUrl: values.logoImageUrl,
};
savePartialSettings({
id: board.id,
...values,
});
})}
@@ -57,30 +87,37 @@ export const GeneralSettingsContent = ({ board }: Props) => {
<Grid.Col span={{ xs: 12, md: 6 }}>
<TextInput
label={t("board.field.pageTitle.label")}
placeholder="Homarr"
{...form.getInputProps("pageTitle")}
/>
</Grid.Col>
<Grid.Col span={{ xs: 12, md: 6 }}>
<TextInput
label={t("board.field.metaTitle.label")}
placeholder="Default Board | Homarr"
rightSection={<PendingOrInvalidIndicator {...metaTitleStatus} />}
{...form.getInputProps("metaTitle")}
/>
</Grid.Col>
<Grid.Col span={{ xs: 12, md: 6 }}>
<TextInput
label={t("board.field.logoImageUrl.label")}
placeholder="/logo/logo.png"
rightSection={<PendingOrInvalidIndicator {...logoStatus} />}
{...form.getInputProps("logoImageUrl")}
/>
</Grid.Col>
<Grid.Col span={{ xs: 12, md: 6 }}>
<TextInput
label={t("board.field.faviconImageUrl.label")}
placeholder="/logo/logo.png"
rightSection={<PendingOrInvalidIndicator {...faviconStatus} />}
{...form.getInputProps("faviconImageUrl")}
/>
</Grid.Col>
</Grid>
<Group justify="end">
<Button type="submit" loading={isPending}>
<Button type="submit" loading={isPending} color="teal">
{t("common.action.saveChanges")}
</Button>
</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 { updateBoard } = useUpdateBoard();
const [logoDebounced] = useDebouncedValue(url ?? "", 500);
useEffect(() => {
if (!logoDebounced.includes(".")) return;
if (!logoDebounced.includes(".") && logoDebounced.length >= 1) return;
updateBoard((previous) => ({
...previous,
logoImageUrl: logoDebounced,
logoImageUrl: logoDebounced.length >= 1 ? logoDebounced : null,
}));
}, [logoDebounced, updateBoard]);
return {
isPending: (url ?? "") !== logoDebounced,
isInvalid: logoDebounced.length >= 1 && !logoDebounced.includes("."),
};
};
const useMetaTitlePreview = (title: string | null) => {
const [metaTitleDebounced] = useDebouncedValue(title ?? "", 200);
useDocumentTitle(metaTitleDebounced);
return {
isPending: (title ?? "") !== metaTitleDebounced,
};
};
const validFaviconExtensions = ["ico", "png", "svg", "gif"];
@@ -115,4 +189,9 @@ const isValidUrl = (url: string) =>
const useFaviconPreview = (url: string | null) => {
const [faviconDebounced] = useDebouncedValue(url ?? "", 500);
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 type { TranslationObject } from "@homarr/translation";
import { getScopedI18n } from "@homarr/translation/server";
import type { TablerIconsProps } from "@homarr/ui";
import {
Accordion,
AccordionControl,
AccordionItem,
AccordionPanel,
Button,
Container,
Divider,
Group,
IconAlertTriangle,
IconBrush,
IconFileTypeCss,
IconLayout,
IconPhoto,
IconSettings,
Stack,
Text,
@@ -19,15 +21,27 @@ import {
} from "@homarr/ui";
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 { LayoutSettingsContent } from "./_layout";
interface Props {
params: {
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 t = await getScopedI18n("board.setting");
@@ -35,99 +49,82 @@ export default async function BoardSettingsPage({ params }: Props) {
<Container>
<Stack>
<Title>{t("title", { boardName: capitalize(board.name) })}</Title>
<Accordion variant="separated" defaultValue="general">
<AccordionItem value="general">
<AccordionControl icon={<IconSettings />}>
<Text fw="bold" size="lg">
{t("section.general.title")}
</Text>
</AccordionControl>
<AccordionPanel>
<GeneralSettingsContent board={board} />
</AccordionPanel>
</AccordionItem>
<AccordionItem value="layout">
<AccordionControl icon={<IconLayout />}>
<Text fw="bold" size="lg">
{t("section.layout.title")}
</Text>
</AccordionControl>
<AccordionPanel></AccordionPanel>
</AccordionItem>
<AccordionItem value="appearance">
<AccordionControl icon={<IconBrush />}>
<Text fw="bold" size="lg">
{t("section.appearance.title")}
</Text>
</AccordionControl>
<AccordionPanel></AccordionPanel>
</AccordionItem>
<AccordionItem
value="danger"
styles={{
item: {
"--__item-border-color": "rgba(248,81,73,0.4)",
},
}}
<ActiveTabAccordion
variant="separated"
defaultValue={searchParams.tab ?? "general"}
>
<AccordionItemFor value="general" icon={IconSettings}>
<GeneralSettingsContent board={board} />
</AccordionItemFor>
<AccordionItemFor value="layout" icon={IconLayout}>
<LayoutSettingsContent board={board} />
</AccordionItemFor>
<AccordionItemFor value="background" icon={IconPhoto}>
<BackgroundSettingsContent board={board} />
</AccordionItemFor>
<AccordionItemFor value="color" icon={IconBrush}>
<ColorSettingsContent board={board} />
</AccordionItemFor>
<AccordionItemFor value="customCss" icon={IconFileTypeCss}>
<CustomCssSettingsContent />
</AccordionItemFor>
<AccordionItemFor
value="dangerZone"
icon={IconAlertTriangle}
danger
noPadding
>
<AccordionControl icon={<IconAlertTriangle />}>
<Text fw="bold" size="lg">
{t("section.dangerZone.title")}
</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>
<DangerZoneSettingsContent />
</AccordionItemFor>
</ActiveTabAccordion>
</Stack>
</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 { BoardEmptySection } from "~/components/board/sections/empty-section";
import { BoardBackgroundVideo } from "~/components/layout/background";
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 = (
prev: RouterOutputs["board"]["default"],
@@ -20,7 +26,10 @@ export const useUpdateBoard = () => {
const updateBoard = useCallback(
(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,
);
},
@@ -36,21 +45,17 @@ export const ClientBoard = () => {
const board = useRequiredBoard();
const isReady = useIsBoardReady();
const sectionsWithoutSidebars = board.sections
.filter(
(section): section is CategorySection | EmptySection =>
section.kind !== "sidebar",
)
.sort((a, b) => a.position - b.position);
const sortedSections = board.sections.sort((a, b) => a.position - b.position);
const ref = useRef<HTMLDivElement>(null);
return (
<Box h="100%" pos="relative">
<BoardBackgroundVideo />
<LoadingOverlay
visible={!isReady}
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))"
/>
<Stack
@@ -58,7 +63,7 @@ export const ClientBoard = () => {
h="100%"
style={{ visibility: isReady ? "visible" : "hidden" }}
>
{sectionsWithoutSidebars.map((section) =>
{sortedSections.map((section) =>
section.kind === "empty" ? (
<BoardEmptySection
key={section.id}

View File

@@ -8,10 +8,13 @@ import {
useEffect,
useState,
} from "react";
import { usePathname } from "next/navigation";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import { updateBoardName } from "./_client";
const BoardContext = createContext<{
board: RouterOutputs["board"]["default"];
isReady: boolean;
@@ -21,14 +24,30 @@ const BoardContext = createContext<{
export const BoardProvider = ({
children,
initialBoard,
}: PropsWithChildren<{ initialBoard: RouterOutputs["board"]["default"] }>) => {
}: PropsWithChildren<{ initialBoard: RouterOutputs["board"]["byName"] }>) => {
const pathname = usePathname();
const utils = clientApi.useUtils();
const [readySections, setReadySections] = useState<string[]>([]);
const { data } = clientApi.board.default.useQuery(undefined, {
initialData: initialBoard,
refetchOnMount: false,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
});
const { data } = clientApi.board.byName.useQuery(
{ name: initialBoard.name },
{
initialData: initialBoard,
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(() => {
setReadySections((previous) =>

View File

@@ -15,6 +15,8 @@ import "../../../styles/gridstack.scss";
import { GlobalItemServerDataRunner } from "@homarr/widgets";
import { BoardMantineProvider } from "./_theme";
type Params = Record<string, unknown>;
interface Props<TParams extends Params> {
@@ -35,14 +37,16 @@ export const createBoardPage = <TParams extends Record<string, unknown>>({
return (
<GlobalItemServerDataRunner board={initialBoard}>
<BoardProvider initialBoard={initialBoard}>
<ClientShell hasNavigation={false}>
<MainHeader
logo={<BoardLogoWithTitle size="md" />}
actions={headeractions}
hasNavigation={false}
/>
<AppShellMain>{children}</AppShellMain>
</ClientShell>
<BoardMantineProvider>
<ClientShell hasNavigation={false}>
<MainHeader
logo={<BoardLogoWithTitle size="md" hideTitleOnMobile />}
actions={headeractions}
hasNavigation={false}
/>
<AppShellMain>{children}</AppShellMain>
</ClientShell>
</BoardMantineProvider>
</BoardProvider>
</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 EmptySection = Extract<Section, { kind: "empty" }>;
export type SidebarSection = Extract<Section, { kind: "sidebar" }>;
export type ItemOfKind<TKind extends WidgetKind> = Extract<
Item,

View File

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

View File

@@ -5,6 +5,7 @@ import { createModalManager } from "mantine-modal-manager";
import { WidgetEditModal } from "@homarr/widgets";
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 { AddBoardModal } from "~/components/manage/boards/add-board-modal";
@@ -13,4 +14,5 @@ export const [ModalsManager, modalEvents] = createModalManager({
widgetEditModal: WidgetEditModal,
itemSelectModal: ItemSelectModal,
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) => ({
...previous,
sections: [
// Ignore sidebar sections
...previous.sections.filter((section) => section.kind === "sidebar"),
// Place sections before the new category
...previous.sections.filter(
(section) =>
@@ -235,12 +233,7 @@ export const useCategoryActions = () => {
...previous,
sections: [
...previous.sections.filter(
(section) => section.kind === "sidebar",
),
...previous.sections.filter(
(section) =>
(section.kind === "category" || section.kind === "empty") &&
section.position < currentCategory.position - 1,
(section) => section.position < currentCategory.position - 1,
),
{
...aboveWrapper,
@@ -253,7 +246,6 @@ export const useCategoryActions = () => {
...previous.sections
.filter(
(section): section is CategorySection | EmptySection =>
(section.kind === "category" || section.kind === "empty") &&
section.position >= currentCategory.position + 2,
)
.map((section) => ({

View File

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

View File

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

View File

@@ -48,7 +48,7 @@ export const useGridstack = ({
useCssVariableConfiguration({ section, mainRef, gridRef });
const sectionColumnCount = useSectionColumnCount(section.kind);
const board = useRequiredBoard();
const items = useMemo(() => section.items, [section.items]);
@@ -125,7 +125,7 @@ export const useGridstack = ({
wrapper: wrapperRef,
gridstack: gridRef,
},
sectionColumnCount,
sectionColumnCount: board.columnCount,
});
if (isReady) {
@@ -134,7 +134,7 @@ export const useGridstack = ({
// Only run this effect when the section items change
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [items.length, section.items.length]);
}, [items.length, section.items.length, board.columnCount]);
return {
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 {
section: Section;
mainRef?: RefObject<HTMLDivElement>;
@@ -177,7 +164,7 @@ const useCssVariableConfiguration = ({
mainRef,
gridRef,
}: UseCssVariableConfiguration) => {
const sectionColumnCount = useSectionColumnCount(section.kind);
const board = useRequiredBoard();
// Get reference to the :root element
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
useEffect(() => {
if (section.kind === "sidebar" || !mainRef?.current) return;
const widgetWidth = mainRef.current.clientWidth / sectionColumnCount;
if (!mainRef?.current) return;
const widgetWidth = mainRef.current.clientWidth / board.columnCount;
// widget width is used to define sizes of gridstack items within global.scss
root?.style.setProperty("--gridstack-widget-width", widgetWidth.toString());
gridRef.current?.cellHeight(widgetWidth);
// gridRef.current is required otherwise the cellheight is run on production as undefined
// 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
useEffect(() => {
root?.style.setProperty(
"--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 {
size: LogoWithTitleProps["size"];
hideTitleOnMobile?: boolean;
}
export const BoardLogoWithTitle = ({ size }: CommonLogoWithTitleProps) => {
export const BoardLogoWithTitle = ({
size,
hideTitleOnMobile,
}: CommonLogoWithTitleProps) => {
const board = useRequiredBoard();
const imageOptions = useImageOptions();
return (
<LogoWithTitle
size={size}
hideTitleOnMobile={hideTitleOnMobile}
title={board.pageTitle ?? homarrPageTitle}
image={imageOptions}
/>

View File

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

View File

@@ -5,6 +5,7 @@ import { useAtomValue } from "jotai";
import { AppShell } from "@homarr/ui";
import { useOptionalBackgroundProps } from "./background";
import { navigationCollapsedAtom } from "./header/burger";
interface ClientShellProps {
@@ -18,9 +19,11 @@ export const ClientShell = ({
children,
}: PropsWithChildren<ClientShellProps>) => {
const collapsed = useAtomValue(navigationCollapsedAtom);
const backgroundProps = useOptionalBackgroundProps();
return (
<AppShell
{...backgroundProps}
header={hasHeader ? { height: 60 } : undefined}
navbar={
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
.grid-stack > .grid-stack-item > .grid-stack-item-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
.input(z.object({ id: z.string() }))
.mutation(async ({ ctx, input }) => {
@@ -92,11 +110,11 @@ export const boardRouter = createTRPCRouter({
.query(async ({ input, ctx }) => {
return await getFullBoardWithWhere(ctx.db, eq(boards.name, input.name));
}),
saveGeneralSettings: publicProcedure
.input(validation.board.saveGeneralSettings)
savePartialSettings: publicProcedure
.input(validation.board.savePartialSettings)
.mutation(async ({ ctx, input }) => {
const board = await ctx.db.query.boards.findFirst({
where: eq(boards.id, input.boardId),
where: eq(boards.id, input.id),
});
if (!board) {
@@ -109,12 +127,30 @@ export const boardRouter = createTRPCRouter({
await ctx.db
.update(boards)
.set({
// general settings
pageTitle: input.pageTitle,
metaTitle: input.metaTitle,
logoImageUrl: input.logoImageUrl,
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
.input(validation.board.save)
@@ -122,7 +158,7 @@ export const boardRouter = createTRPCRouter({
await ctx.db.transaction(async (tx) => {
const dbBoard = await getFullBoardWithWhere(
tx,
eq(boards.id, input.boardId),
eq(boards.id, input.id),
);
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 board = await db.query.boards.findFirst({
where,

View File

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

View File

@@ -26,13 +26,10 @@ CREATE TABLE `board` (
`background_image_attachment` text DEFAULT 'fixed' NOT NULL,
`background_image_repeat` text DEFAULT 'no-repeat' NOT NULL,
`background_image_size` text DEFAULT 'cover' NOT NULL,
`primary_color` text DEFAULT 'red' NOT NULL,
`secondary_color` text DEFAULT 'orange' NOT NULL,
`primary_shade` integer DEFAULT 6 NOT NULL,
`app_opacity` integer DEFAULT 100 NOT NULL,
`primary_color` text DEFAULT '#fa5252' NOT NULL,
`secondary_color` text DEFAULT '#fd7e14' NOT NULL,
`opacity` integer DEFAULT 100 NOT NULL,
`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
);
--> statement-breakpoint
@@ -106,6 +103,7 @@ CREATE TABLE `verificationToken` (
);
--> 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__updated_at_idx` ON `integrationSecret` (`updated_at`);--> statement-breakpoint
CREATE INDEX `integration__kind_idx` ON `integration` (`kind`);--> statement-breakpoint

View File

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

View File

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

View File

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

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

View File

@@ -153,6 +153,12 @@ export default {
multiSelect: {
placeholder: "Pick one or more values",
},
select: {
placeholder: "Pick value",
badge: {
recommended: "Recommended",
},
},
search: {
placeholder: "Search for anything...",
nothingFound: "Nothing found",
@@ -172,6 +178,10 @@ export default {
},
},
noResults: "No results found",
preview: {
show: "Show preview",
hide: "Hide preview",
},
},
section: {
category: {
@@ -299,18 +309,98 @@ export default {
faviconImageUrl: {
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: {
title: "Settings for {boardName} board",
section: {
general: {
title: "General",
unrecognizedLink:
"The provided link is not recognized and won't preview, it might still work.",
},
layout: {
title: "Layout",
},
appearance: {
title: "Appearance",
background: {
title: "Background",
},
color: {
title: "Colors",
},
customCss: {
title: "Custom css",
},
dangerZone: {
title: "Danger Zone",
@@ -320,6 +410,9 @@ export default {
description:
"Changing the name will break any links to this board.",
button: "Change name",
modal: {
title: "Rename board",
},
},
visibility: {
label: "Change board visibility",
@@ -331,12 +424,29 @@ export default {
public: "Make private",
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: {
label: "Delete this board",
description:
"Once you delete a board, there is no going back. Please be certain.",
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 "./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 {
backgroundImageAttachments,
backgroundImageRepeats,
backgroundImageSizes,
} from "@homarr/definitions";
import { commonItemSchema, createSectionSchema } from "./shared";
const hexColorSchema = z.string().regex(/^#[0-9A-Fa-f]{6}$/);
const boardNameSchema = z
.string()
.min(1)
@@ -12,36 +20,56 @@ const byNameSchema = z.object({
name: boardNameSchema,
});
const saveGeneralSettingsSchema = z.object({
pageTitle: z
.string()
.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 renameSchema = z.object({
id: z.string(),
name: boardNameSchema,
});
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({
boardId: z.string(),
id: z.string(),
sections: z.array(createSectionSchema(commonItemSchema)),
});
const createSchema = z.object({ name: z.string() });
const createSchema = z.object({ name: boardNameSchema });
export const boardSchemas = {
byName: byNameSchema,
saveGeneralSettings: saveGeneralSettingsSchema,
savePartialSettings: savePartialSettingsSchema,
save: saveSchema,
create: createSchema,
rename: renameSchema,
changeVisibility: changeVisibilitySchema,
};

View File

@@ -48,21 +48,6 @@ const createEmptySchema = <TItemSchema extends z.ZodTypeAny>(
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>(
itemSchema: TItemSchema,
) =>
z.union([
createCategorySchema(itemSchema),
createEmptySchema(itemSchema),
createSidebarSchema(itemSchema),
]);
) => z.union([createCategorySchema(itemSchema), createEmptySchema(itemSchema)]);

25
pnpm-lock.yaml generated
View File

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