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

@@ -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,
});