Skip to content

Commit

Permalink
feat: add server settings for default board, default color scheme and…
Browse files Browse the repository at this point in the history
… default locale (#1373)

* feat: add server settings for default board, default color scheme and default locale

* chore: address pull request feedback

* test: adjust unit tests to match requirements

* fix: deepsource issue

* chore: add deepsource as dependency to translation library

* refactor: restructure language-combobox, adjust default locale for next-intl

* chore: change cookie keys prefix from homarr- to homarr.
  • Loading branch information
Meierschlumpf authored Nov 2, 2024
1 parent 49c0ebe commit 326b769
Show file tree
Hide file tree
Showing 42 changed files with 599 additions and 214 deletions.
16 changes: 10 additions & 6 deletions apps/nextjs/src/app/[locale]/_client-providers/mantine.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,14 +8,19 @@ import dayjs from "dayjs";
import { clientApi } from "@homarr/api/client";
import { useSession } from "@homarr/auth/client";
import { parseCookies, setClientCookie } from "@homarr/common";
import type { ColorScheme } from "@homarr/definitions";
import { colorSchemeCookieKey } from "@homarr/definitions";

export const CustomMantineProvider = ({ children }: PropsWithChildren) => {
export const CustomMantineProvider = ({
children,
defaultColorScheme,
}: PropsWithChildren<{ defaultColorScheme: ColorScheme }>) => {
const manager = useColorSchemeManager();

return (
<DirectionProvider>
<MantineProvider
defaultColorScheme="dark"
defaultColorScheme={defaultColorScheme}
colorSchemeManager={manager}
theme={createTheme({
primaryColor: "red",
Expand All @@ -28,12 +33,11 @@ export const CustomMantineProvider = ({ children }: PropsWithChildren) => {
);
};

function useColorSchemeManager(): MantineColorSchemeManager {
const key = "homarr-color-scheme";
export function useColorSchemeManager(): MantineColorSchemeManager {
const { data: session } = useSession();

const updateCookieValue = (value: Exclude<MantineColorScheme, "auto">) => {
setClientCookie(key, value, { expires: dayjs().add(1, "year").toDate(), path: "/" });
setClientCookie(colorSchemeCookieKey, value, { expires: dayjs().add(1, "year").toDate(), path: "/" });
};

const { mutate: mutateColorScheme } = clientApi.user.changeColorScheme.useMutation({
Expand All @@ -50,7 +54,7 @@ function useColorSchemeManager(): MantineColorSchemeManager {

try {
const cookies = parseCookies(document.cookie);
return (cookies[key] as MantineColorScheme | undefined) ?? defaultValue;
return (cookies[colorSchemeCookieKey] as MantineColorScheme | undefined) ?? defaultValue;
} catch {
return defaultValue;
}
Expand Down
15 changes: 13 additions & 2 deletions apps/nextjs/src/app/[locale]/boards/(content)/_theme.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,17 @@ import type { PropsWithChildren } from "react";
import type { MantineColorsTuple } from "@mantine/core";
import { createTheme, darken, lighten, MantineProvider } from "@mantine/core";

import type { ColorScheme } from "@homarr/definitions";

import { useColorSchemeManager } from "../../_client-providers/mantine";
import { useRequiredBoard } from "./_context";

export const BoardMantineProvider = ({ children }: PropsWithChildren) => {
export const BoardMantineProvider = ({
children,
defaultColorScheme,
}: PropsWithChildren<{ defaultColorScheme: ColorScheme }>) => {
const board = useRequiredBoard();
const colorSchemeManager = useColorSchemeManager();

const theme = createTheme({
colors: {
Expand All @@ -18,7 +25,11 @@ export const BoardMantineProvider = ({ children }: PropsWithChildren) => {
autoContrast: true,
});

return <MantineProvider theme={theme}>{children}</MantineProvider>;
return (
<MantineProvider defaultColorScheme={defaultColorScheme} theme={theme} colorSchemeManager={colorSchemeManager}>
{children}
</MantineProvider>
);
};

export const generateColors = (hex: string) => {
Expand Down
22 changes: 13 additions & 9 deletions apps/nextjs/src/app/[locale]/boards/[name]/settings/_danger.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import { BoardRenameModal } from "~/components/board/modals/board-rename-modal";
import { useRequiredBoard } from "../../(content)/_context";
import classes from "./danger.module.css";

export const DangerZoneSettingsContent = () => {
export const DangerZoneSettingsContent = ({ hideVisibility }: { hideVisibility: boolean }) => {
const board = useRequiredBoard();
const t = useScopedI18n("board.setting");
const router = useRouter();
Expand Down Expand Up @@ -90,14 +90,18 @@ export const DangerZoneSettingsContent = () => {
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}
/>
{hideVisibility ? null : (
<>
<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")}
Expand Down
5 changes: 4 additions & 1 deletion apps/nextjs/src/app/[locale]/boards/[name]/settings/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ import { TRPCError } from "@trpc/server";

import { api } from "@homarr/api/server";
import { capitalize } from "@homarr/common";
import { db } from "@homarr/db";
import { getServerSettingByKeyAsync } from "@homarr/db/queries";
import type { TranslationObject } from "@homarr/translation";
import { getScopedI18n } from "@homarr/translation/server";
import type { TablerIcon } from "@homarr/ui";
Expand Down Expand Up @@ -63,6 +65,7 @@ const getBoardAndPermissionsAsync = async (params: Props["params"]) => {

export default async function BoardSettingsPage({ params, searchParams }: Props) {
const { board, permissions } = await getBoardAndPermissionsAsync(params);
const boardSettings = await getServerSettingByKeyAsync(db, "board");
const { hasFullAccess } = await getBoardPermissionsAsync(board);
const t = await getScopedI18n("board.setting");

Expand Down Expand Up @@ -92,7 +95,7 @@ export default async function BoardSettingsPage({ params, searchParams }: Props)
<BoardAccessSettings board={board} initialPermissions={permissions} />
</AccordionItemFor>
<AccordionItemFor value="dangerZone" icon={IconAlertTriangle} danger noPadding>
<DangerZoneSettingsContent />
<DangerZoneSettingsContent hideVisibility={boardSettings.defaultBoardId === board.id} />
</AccordionItemFor>
</>
)}
Expand Down
4 changes: 3 additions & 1 deletion apps/nextjs/src/app/[locale]/boards/_layout-creator.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { logger } from "@homarr/log";
import { MainHeader } from "~/components/layout/header";
import { BoardLogoWithTitle } from "~/components/layout/logo/board-logo";
import { ClientShell } from "~/components/layout/shell";
import { getCurrentColorSchemeAsync } from "~/theme/color-scheme";
import type { Board } from "./_types";
import { BoardProvider } from "./(content)/_context";
import type { Params } from "./(content)/_creator";
Expand Down Expand Up @@ -37,10 +38,11 @@ export const createBoardLayout = <TParams extends Params>({

throw error;
});
const colorScheme = await getCurrentColorSchemeAsync();

return (
<BoardProvider initialBoard={initialBoard}>
<BoardMantineProvider>
<BoardMantineProvider defaultColorScheme={colorScheme}>
<CustomCss />
<ClientShell hasNavigation={false}>
<MainHeader
Expand Down
15 changes: 6 additions & 9 deletions apps/nextjs/src/app/[locale]/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ import "@homarr/spotlight/styles.css";
import "@homarr/ui/styles.css";
import "~/styles/scroll-area.scss";

import { cookies } from "next/headers";
import { notFound } from "next/navigation";
import { NextIntlClientProvider } from "next-intl";

Expand All @@ -19,6 +18,7 @@ import { getI18nMessages, getScopedI18n } from "@homarr/translation/server";

import { Analytics } from "~/components/layout/analytics";
import { SearchEngineOptimization } from "~/components/layout/search-engine-optimization";
import { getCurrentColorSchemeAsync } from "~/theme/color-scheme";
import { JotaiProvider } from "./_client-providers/jotai";
import { CustomMantineProvider } from "./_client-providers/mantine";
import { AuthProvider } from "./_client-providers/session";
Expand All @@ -30,7 +30,8 @@ const fontSans = Inter({
variable: "--font-sans",
});

export const generateMetadata = (): Metadata => ({
// eslint-disable-next-line no-restricted-syntax
export const generateMetadata = async (): Promise<Metadata> => ({
title: "Homarr",
description:
"Simplify the management of your server with Homarr - a sleek, modern dashboard that puts all of your apps and services at your fingertips.",
Expand All @@ -49,7 +50,7 @@ export const generateMetadata = (): Metadata => ({
title: "Homarr",
capable: true,
startupImage: { url: "/logo/logo.png" },
statusBarStyle: getColorScheme() === "dark" ? "black-translucent" : "default",
statusBarStyle: (await getCurrentColorSchemeAsync()) === "dark" ? "black-translucent" : "default",
},
});

Expand All @@ -66,7 +67,7 @@ export default async function Layout(props: { children: React.ReactNode; params:
}

const session = await auth();
const colorScheme = getColorScheme();
const colorScheme = await getCurrentColorSchemeAsync();
const tCommon = await getScopedI18n("common");
const direction = tCommon("direction");
const i18nMessages = await getI18nMessages();
Expand All @@ -78,7 +79,7 @@ export default async function Layout(props: { children: React.ReactNode; params:
(innerProps) => <JotaiProvider {...innerProps} />,
(innerProps) => <TRPCReactProvider {...innerProps} />,
(innerProps) => <NextIntlClientProvider {...innerProps} messages={i18nMessages} />,
(innerProps) => <CustomMantineProvider {...innerProps} />,
(innerProps) => <CustomMantineProvider {...innerProps} defaultColorScheme={colorScheme} />,
(innerProps) => <ModalProvider {...innerProps} />,
]);

Expand Down Expand Up @@ -106,7 +107,3 @@ export default async function Layout(props: { children: React.ReactNode; params:
</html>
);
}

const getColorScheme = () => {
return cookies().get("homarr-color-scheme")?.value ?? "dark";
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
"use client";

import { Group, Text } from "@mantine/core";
import { IconMoon, IconSun } from "@tabler/icons-react";

import type { ColorScheme } from "@homarr/definitions";
import { colorSchemes } from "@homarr/definitions";
import type { ServerSettings } from "@homarr/server-settings";
import { useScopedI18n } from "@homarr/translation/client";
import { SelectWithCustomItems } from "@homarr/ui";

import { CommonSettingsForm } from "./common-form";

export const AppearanceSettingsForm = ({ defaultValues }: { defaultValues: ServerSettings["appearance"] }) => {
const tApperance = useScopedI18n("management.page.settings.section.appearance");

return (
<CommonSettingsForm settingKey="appearance" defaultValues={defaultValues}>
{(form) => (
<>
<SelectWithCustomItems
label={tApperance("defaultColorScheme.label")}
data={colorSchemes.map((scheme) => ({
value: scheme,
label: tApperance(`defaultColorScheme.options.${scheme}`),
}))}
{...form.getInputProps("defaultColorScheme")}
SelectOption={ApperanceCustomOption}
/>
</>
)}
</CommonSettingsForm>
);
};

const appearanceIcons = {
light: IconSun,
dark: IconMoon,
};

const ApperanceCustomOption = ({ value, label }: { value: ColorScheme; label: string }) => {
const Icon = appearanceIcons[value];

return (
<Group>
<Icon size={16} stroke={1.5} />
<Text fz="sm" fw={500}>
{label}
</Text>
</Group>
);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
"use client";

import { Group, Text } from "@mantine/core";
import { IconLayoutDashboard } from "@tabler/icons-react";

import { clientApi } from "@homarr/api/client";
import type { ServerSettings } from "@homarr/server-settings";
import { useScopedI18n } from "@homarr/translation/client";
import { SelectWithCustomItems } from "@homarr/ui";

import { CommonSettingsForm } from "./common-form";

export const BoardSettingsForm = ({ defaultValues }: { defaultValues: ServerSettings["board"] }) => {
const tBoard = useScopedI18n("management.page.settings.section.board");
const [selectableBoards] = clientApi.board.getPublicBoards.useSuspenseQuery();

return (
<CommonSettingsForm settingKey="board" defaultValues={defaultValues}>
{(form) => (
<>
<SelectWithCustomItems
label={tBoard("defaultBoard.label")}
description={tBoard("defaultBoard.description")}
data={selectableBoards.map((board) => ({
value: board.id,
label: board.name,
image: board.logoImageUrl,
}))}
SelectOption={({ label, image }: { value: string; label: string; image: string | null }) => (
<Group>
{/* eslint-disable-next-line @next/next/no-img-element */}
{image ? <img width={16} height={16} src={image} alt={label} /> : <IconLayoutDashboard size={16} />}
<Text fz="sm" fw={500}>
{label}
</Text>
</Group>
)}
{...form.getInputProps("defaultBoardId")}
/>
</>
)}
</CommonSettingsForm>
);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
"use client";

import { Button, Group, Stack } from "@mantine/core";

import { clientApi } from "@homarr/api/client";
import { useForm } from "@homarr/form";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import type { ServerSettings } from "@homarr/server-settings";
import { useI18n, useScopedI18n } from "@homarr/translation/client";

export const CommonSettingsForm = <TKey extends keyof ServerSettings>({
settingKey,
defaultValues,
children,
}: {
settingKey: TKey;
defaultValues: ServerSettings[TKey];
children: (form: ReturnType<typeof useForm<ServerSettings[TKey]>>) => React.ReactNode;
}) => {
const t = useI18n();
const tSettings = useScopedI18n("management.page.settings");
const { mutateAsync, isPending } = clientApi.serverSettings.saveSettings.useMutation({
onSuccess() {
showSuccessNotification({
message: tSettings("notification.success.message"),
});
},
onError() {
showErrorNotification({
message: tSettings("notification.error.message"),
});
},
});
const form = useForm({
initialValues: defaultValues,
});

const handleSubmitAsync = async (values: ServerSettings[TKey]) => {
await mutateAsync({
settingsKey: settingKey,
value: values,
});
};

return (
<form onSubmit={form.onSubmit((values) => void handleSubmitAsync(values))}>
<Stack gap="sm">
{children(form)}
<Group justify="end">
<Button type="submit" loading={isPending}>
{t("common.action.save")}
</Button>
</Group>
</Stack>
</form>
);
};
Loading

0 comments on commit 326b769

Please sign in to comment.