feat: add server settings for default board, default color scheme and 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.
This commit is contained in:
@@ -8,14 +8,19 @@ import dayjs from "dayjs";
|
|||||||
import { clientApi } from "@homarr/api/client";
|
import { clientApi } from "@homarr/api/client";
|
||||||
import { useSession } from "@homarr/auth/client";
|
import { useSession } from "@homarr/auth/client";
|
||||||
import { parseCookies, setClientCookie } from "@homarr/common";
|
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();
|
const manager = useColorSchemeManager();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DirectionProvider>
|
<DirectionProvider>
|
||||||
<MantineProvider
|
<MantineProvider
|
||||||
defaultColorScheme="dark"
|
defaultColorScheme={defaultColorScheme}
|
||||||
colorSchemeManager={manager}
|
colorSchemeManager={manager}
|
||||||
theme={createTheme({
|
theme={createTheme({
|
||||||
primaryColor: "red",
|
primaryColor: "red",
|
||||||
@@ -28,12 +33,11 @@ export const CustomMantineProvider = ({ children }: PropsWithChildren) => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
function useColorSchemeManager(): MantineColorSchemeManager {
|
export function useColorSchemeManager(): MantineColorSchemeManager {
|
||||||
const key = "homarr-color-scheme";
|
|
||||||
const { data: session } = useSession();
|
const { data: session } = useSession();
|
||||||
|
|
||||||
const updateCookieValue = (value: Exclude<MantineColorScheme, "auto">) => {
|
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({
|
const { mutate: mutateColorScheme } = clientApi.user.changeColorScheme.useMutation({
|
||||||
@@ -50,7 +54,7 @@ function useColorSchemeManager(): MantineColorSchemeManager {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const cookies = parseCookies(document.cookie);
|
const cookies = parseCookies(document.cookie);
|
||||||
return (cookies[key] as MantineColorScheme | undefined) ?? defaultValue;
|
return (cookies[colorSchemeCookieKey] as MantineColorScheme | undefined) ?? defaultValue;
|
||||||
} catch {
|
} catch {
|
||||||
return defaultValue;
|
return defaultValue;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,10 +4,17 @@ import type { PropsWithChildren } from "react";
|
|||||||
import type { MantineColorsTuple } from "@mantine/core";
|
import type { MantineColorsTuple } from "@mantine/core";
|
||||||
import { createTheme, darken, lighten, MantineProvider } 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";
|
import { useRequiredBoard } from "./_context";
|
||||||
|
|
||||||
export const BoardMantineProvider = ({ children }: PropsWithChildren) => {
|
export const BoardMantineProvider = ({
|
||||||
|
children,
|
||||||
|
defaultColorScheme,
|
||||||
|
}: PropsWithChildren<{ defaultColorScheme: ColorScheme }>) => {
|
||||||
const board = useRequiredBoard();
|
const board = useRequiredBoard();
|
||||||
|
const colorSchemeManager = useColorSchemeManager();
|
||||||
|
|
||||||
const theme = createTheme({
|
const theme = createTheme({
|
||||||
colors: {
|
colors: {
|
||||||
@@ -18,7 +25,11 @@ export const BoardMantineProvider = ({ children }: PropsWithChildren) => {
|
|||||||
autoContrast: true,
|
autoContrast: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
return <MantineProvider theme={theme}>{children}</MantineProvider>;
|
return (
|
||||||
|
<MantineProvider defaultColorScheme={defaultColorScheme} theme={theme} colorSchemeManager={colorSchemeManager}>
|
||||||
|
{children}
|
||||||
|
</MantineProvider>
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const generateColors = (hex: string) => {
|
export const generateColors = (hex: string) => {
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import { BoardRenameModal } from "~/components/board/modals/board-rename-modal";
|
|||||||
import { useRequiredBoard } from "../../(content)/_context";
|
import { useRequiredBoard } from "../../(content)/_context";
|
||||||
import classes from "./danger.module.css";
|
import classes from "./danger.module.css";
|
||||||
|
|
||||||
export const DangerZoneSettingsContent = () => {
|
export const DangerZoneSettingsContent = ({ hideVisibility }: { hideVisibility: boolean }) => {
|
||||||
const board = useRequiredBoard();
|
const board = useRequiredBoard();
|
||||||
const t = useScopedI18n("board.setting");
|
const t = useScopedI18n("board.setting");
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -90,14 +90,18 @@ export const DangerZoneSettingsContent = () => {
|
|||||||
buttonText={t("section.dangerZone.action.rename.button")}
|
buttonText={t("section.dangerZone.action.rename.button")}
|
||||||
onClick={onRenameClick}
|
onClick={onRenameClick}
|
||||||
/>
|
/>
|
||||||
<Divider />
|
{hideVisibility ? null : (
|
||||||
<DangerZoneRow
|
<>
|
||||||
label={t("section.dangerZone.action.visibility.label")}
|
<Divider />
|
||||||
description={t(`section.dangerZone.action.visibility.description.${visibility}`)}
|
<DangerZoneRow
|
||||||
buttonText={t(`section.dangerZone.action.visibility.button.${visibility}`)}
|
label={t("section.dangerZone.action.visibility.label")}
|
||||||
onClick={onVisibilityClick}
|
description={t(`section.dangerZone.action.visibility.description.${visibility}`)}
|
||||||
isPending={isChangeVisibilityPending}
|
buttonText={t(`section.dangerZone.action.visibility.button.${visibility}`)}
|
||||||
/>
|
onClick={onVisibilityClick}
|
||||||
|
isPending={isChangeVisibilityPending}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
<Divider />
|
<Divider />
|
||||||
<DangerZoneRow
|
<DangerZoneRow
|
||||||
label={t("section.dangerZone.action.delete.label")}
|
label={t("section.dangerZone.action.delete.label")}
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ import { TRPCError } from "@trpc/server";
|
|||||||
|
|
||||||
import { api } from "@homarr/api/server";
|
import { api } from "@homarr/api/server";
|
||||||
import { capitalize } from "@homarr/common";
|
import { capitalize } from "@homarr/common";
|
||||||
|
import { db } from "@homarr/db";
|
||||||
|
import { getServerSettingByKeyAsync } from "@homarr/db/queries";
|
||||||
import type { TranslationObject } from "@homarr/translation";
|
import type { TranslationObject } from "@homarr/translation";
|
||||||
import { getScopedI18n } from "@homarr/translation/server";
|
import { getScopedI18n } from "@homarr/translation/server";
|
||||||
import type { TablerIcon } from "@homarr/ui";
|
import type { TablerIcon } from "@homarr/ui";
|
||||||
@@ -63,6 +65,7 @@ const getBoardAndPermissionsAsync = async (params: Props["params"]) => {
|
|||||||
|
|
||||||
export default async function BoardSettingsPage({ params, searchParams }: Props) {
|
export default async function BoardSettingsPage({ params, searchParams }: Props) {
|
||||||
const { board, permissions } = await getBoardAndPermissionsAsync(params);
|
const { board, permissions } = await getBoardAndPermissionsAsync(params);
|
||||||
|
const boardSettings = await getServerSettingByKeyAsync(db, "board");
|
||||||
const { hasFullAccess } = await getBoardPermissionsAsync(board);
|
const { hasFullAccess } = await getBoardPermissionsAsync(board);
|
||||||
const t = await getScopedI18n("board.setting");
|
const t = await getScopedI18n("board.setting");
|
||||||
|
|
||||||
@@ -92,7 +95,7 @@ export default async function BoardSettingsPage({ params, searchParams }: Props)
|
|||||||
<BoardAccessSettings board={board} initialPermissions={permissions} />
|
<BoardAccessSettings board={board} initialPermissions={permissions} />
|
||||||
</AccordionItemFor>
|
</AccordionItemFor>
|
||||||
<AccordionItemFor value="dangerZone" icon={IconAlertTriangle} danger noPadding>
|
<AccordionItemFor value="dangerZone" icon={IconAlertTriangle} danger noPadding>
|
||||||
<DangerZoneSettingsContent />
|
<DangerZoneSettingsContent hideVisibility={boardSettings.defaultBoardId === board.id} />
|
||||||
</AccordionItemFor>
|
</AccordionItemFor>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { logger } from "@homarr/log";
|
|||||||
import { MainHeader } from "~/components/layout/header";
|
import { MainHeader } from "~/components/layout/header";
|
||||||
import { BoardLogoWithTitle } from "~/components/layout/logo/board-logo";
|
import { BoardLogoWithTitle } from "~/components/layout/logo/board-logo";
|
||||||
import { ClientShell } from "~/components/layout/shell";
|
import { ClientShell } from "~/components/layout/shell";
|
||||||
|
import { getCurrentColorSchemeAsync } from "~/theme/color-scheme";
|
||||||
import type { Board } from "./_types";
|
import type { Board } from "./_types";
|
||||||
import { BoardProvider } from "./(content)/_context";
|
import { BoardProvider } from "./(content)/_context";
|
||||||
import type { Params } from "./(content)/_creator";
|
import type { Params } from "./(content)/_creator";
|
||||||
@@ -37,10 +38,11 @@ export const createBoardLayout = <TParams extends Params>({
|
|||||||
|
|
||||||
throw error;
|
throw error;
|
||||||
});
|
});
|
||||||
|
const colorScheme = await getCurrentColorSchemeAsync();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<BoardProvider initialBoard={initialBoard}>
|
<BoardProvider initialBoard={initialBoard}>
|
||||||
<BoardMantineProvider>
|
<BoardMantineProvider defaultColorScheme={colorScheme}>
|
||||||
<CustomCss />
|
<CustomCss />
|
||||||
<ClientShell hasNavigation={false}>
|
<ClientShell hasNavigation={false}>
|
||||||
<MainHeader
|
<MainHeader
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import "@homarr/spotlight/styles.css";
|
|||||||
import "@homarr/ui/styles.css";
|
import "@homarr/ui/styles.css";
|
||||||
import "~/styles/scroll-area.scss";
|
import "~/styles/scroll-area.scss";
|
||||||
|
|
||||||
import { cookies } from "next/headers";
|
|
||||||
import { notFound } from "next/navigation";
|
import { notFound } from "next/navigation";
|
||||||
import { NextIntlClientProvider } from "next-intl";
|
import { NextIntlClientProvider } from "next-intl";
|
||||||
|
|
||||||
@@ -19,6 +18,7 @@ import { getI18nMessages, getScopedI18n } from "@homarr/translation/server";
|
|||||||
|
|
||||||
import { Analytics } from "~/components/layout/analytics";
|
import { Analytics } from "~/components/layout/analytics";
|
||||||
import { SearchEngineOptimization } from "~/components/layout/search-engine-optimization";
|
import { SearchEngineOptimization } from "~/components/layout/search-engine-optimization";
|
||||||
|
import { getCurrentColorSchemeAsync } from "~/theme/color-scheme";
|
||||||
import { JotaiProvider } from "./_client-providers/jotai";
|
import { JotaiProvider } from "./_client-providers/jotai";
|
||||||
import { CustomMantineProvider } from "./_client-providers/mantine";
|
import { CustomMantineProvider } from "./_client-providers/mantine";
|
||||||
import { AuthProvider } from "./_client-providers/session";
|
import { AuthProvider } from "./_client-providers/session";
|
||||||
@@ -30,7 +30,8 @@ const fontSans = Inter({
|
|||||||
variable: "--font-sans",
|
variable: "--font-sans",
|
||||||
});
|
});
|
||||||
|
|
||||||
export const generateMetadata = (): Metadata => ({
|
// eslint-disable-next-line no-restricted-syntax
|
||||||
|
export const generateMetadata = async (): Promise<Metadata> => ({
|
||||||
title: "Homarr",
|
title: "Homarr",
|
||||||
description:
|
description:
|
||||||
"Simplify the management of your server with Homarr - a sleek, modern dashboard that puts all of your apps and services at your fingertips.",
|
"Simplify the management of your server with Homarr - a sleek, modern dashboard that puts all of your apps and services at your fingertips.",
|
||||||
@@ -49,7 +50,7 @@ export const generateMetadata = (): Metadata => ({
|
|||||||
title: "Homarr",
|
title: "Homarr",
|
||||||
capable: true,
|
capable: true,
|
||||||
startupImage: { url: "/logo/logo.png" },
|
startupImage: { url: "/logo/logo.png" },
|
||||||
statusBarStyle: getColorScheme() === "dark" ? "black-translucent" : "default",
|
statusBarStyle: (await getCurrentColorSchemeAsync()) === "dark" ? "black-translucent" : "default",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -66,7 +67,7 @@ export default async function Layout(props: { children: React.ReactNode; params:
|
|||||||
}
|
}
|
||||||
|
|
||||||
const session = await auth();
|
const session = await auth();
|
||||||
const colorScheme = getColorScheme();
|
const colorScheme = await getCurrentColorSchemeAsync();
|
||||||
const tCommon = await getScopedI18n("common");
|
const tCommon = await getScopedI18n("common");
|
||||||
const direction = tCommon("direction");
|
const direction = tCommon("direction");
|
||||||
const i18nMessages = await getI18nMessages();
|
const i18nMessages = await getI18nMessages();
|
||||||
@@ -78,7 +79,7 @@ export default async function Layout(props: { children: React.ReactNode; params:
|
|||||||
(innerProps) => <JotaiProvider {...innerProps} />,
|
(innerProps) => <JotaiProvider {...innerProps} />,
|
||||||
(innerProps) => <TRPCReactProvider {...innerProps} />,
|
(innerProps) => <TRPCReactProvider {...innerProps} />,
|
||||||
(innerProps) => <NextIntlClientProvider {...innerProps} messages={i18nMessages} />,
|
(innerProps) => <NextIntlClientProvider {...innerProps} messages={i18nMessages} />,
|
||||||
(innerProps) => <CustomMantineProvider {...innerProps} />,
|
(innerProps) => <CustomMantineProvider {...innerProps} defaultColorScheme={colorScheme} />,
|
||||||
(innerProps) => <ModalProvider {...innerProps} />,
|
(innerProps) => <ModalProvider {...innerProps} />,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -106,7 +107,3 @@ export default async function Layout(props: { children: React.ReactNode; params:
|
|||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const getColorScheme = () => {
|
|
||||||
return cookies().get("homarr-color-scheme")?.value ?? "dark";
|
|
||||||
};
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import type { ServerSettings } from "@homarr/server-settings";
|
||||||
|
import type { SupportedLanguage } from "@homarr/translation";
|
||||||
|
import { useScopedI18n } from "@homarr/translation/client";
|
||||||
|
|
||||||
|
import { LanguageCombobox } from "~/components/language/language-combobox";
|
||||||
|
import { CommonSettingsForm } from "./common-form";
|
||||||
|
|
||||||
|
export const CultureSettingsForm = ({ defaultValues }: { defaultValues: ServerSettings["culture"] }) => {
|
||||||
|
const tCulture = useScopedI18n("management.page.settings.section.culture");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CommonSettingsForm settingKey="culture" defaultValues={defaultValues}>
|
||||||
|
{(form) => (
|
||||||
|
<>
|
||||||
|
<LanguageCombobox
|
||||||
|
label={tCulture("defaultLocale.label")}
|
||||||
|
value={form.getInputProps("defaultLocale").value as SupportedLanguage}
|
||||||
|
{...form.getInputProps("defaultLocale")}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</CommonSettingsForm>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -6,6 +6,9 @@ import { getScopedI18n } from "@homarr/translation/server";
|
|||||||
import { CrawlingAndIndexingSettings } from "~/app/[locale]/manage/settings/_components/crawling-and-indexing.settings";
|
import { CrawlingAndIndexingSettings } from "~/app/[locale]/manage/settings/_components/crawling-and-indexing.settings";
|
||||||
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
|
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
|
||||||
import { AnalyticsSettings } from "./_components/analytics.settings";
|
import { AnalyticsSettings } from "./_components/analytics.settings";
|
||||||
|
import { AppearanceSettingsForm } from "./_components/appearance-settings-form";
|
||||||
|
import { BoardSettingsForm } from "./_components/board-settings-form";
|
||||||
|
import { CultureSettingsForm } from "./_components/culture-settings-form";
|
||||||
|
|
||||||
export async function generateMetadata() {
|
export async function generateMetadata() {
|
||||||
const t = await getScopedI18n("management");
|
const t = await getScopedI18n("management");
|
||||||
@@ -18,14 +21,26 @@ export async function generateMetadata() {
|
|||||||
|
|
||||||
export default async function SettingsPage() {
|
export default async function SettingsPage() {
|
||||||
const serverSettings = await api.serverSettings.getAll();
|
const serverSettings = await api.serverSettings.getAll();
|
||||||
const t = await getScopedI18n("management.page.settings");
|
const tSettings = await getScopedI18n("management.page.settings");
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<DynamicBreadcrumb />
|
<DynamicBreadcrumb />
|
||||||
<Stack>
|
<Stack>
|
||||||
<Title order={1}>{t("title")}</Title>
|
<Title order={1}>{tSettings("title")}</Title>
|
||||||
<AnalyticsSettings initialData={serverSettings.analytics} />
|
<AnalyticsSettings initialData={serverSettings.analytics} />
|
||||||
<CrawlingAndIndexingSettings initialData={serverSettings.crawlingAndIndexing} />
|
<CrawlingAndIndexingSettings initialData={serverSettings.crawlingAndIndexing} />
|
||||||
|
<Stack>
|
||||||
|
<Title order={2}>{tSettings("section.board.title")}</Title>
|
||||||
|
<BoardSettingsForm defaultValues={serverSettings.board} />
|
||||||
|
</Stack>
|
||||||
|
<Stack>
|
||||||
|
<Title order={2}>{tSettings("section.appearance.title")}</Title>
|
||||||
|
<AppearanceSettingsForm defaultValues={serverSettings.appearance} />
|
||||||
|
</Stack>
|
||||||
|
<Stack>
|
||||||
|
<Title order={2}>{tSettings("section.culture.title")}</Title>
|
||||||
|
<CultureSettingsForm defaultValues={serverSettings.culture} />
|
||||||
|
</Stack>
|
||||||
</Stack>
|
</Stack>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { api } from "@homarr/api/server";
|
|||||||
import { auth } from "@homarr/auth/next";
|
import { auth } from "@homarr/auth/next";
|
||||||
import { getI18n, getScopedI18n } from "@homarr/translation/server";
|
import { getI18n, getScopedI18n } from "@homarr/translation/server";
|
||||||
|
|
||||||
import { LanguageCombobox } from "~/components/language/language-combobox";
|
import { CurrentLanguageCombobox } from "~/components/language/current-language-combobox";
|
||||||
import { DangerZoneItem, DangerZoneRoot } from "~/components/manage/danger-zone";
|
import { DangerZoneItem, DangerZoneRoot } from "~/components/manage/danger-zone";
|
||||||
import { catchTrpcNotFound } from "~/errors/trpc-not-found";
|
import { catchTrpcNotFound } from "~/errors/trpc-not-found";
|
||||||
import { createMetaTitle } from "~/metadata";
|
import { createMetaTitle } from "~/metadata";
|
||||||
@@ -81,7 +81,7 @@ export default async function EditUserPage({ params }: Props) {
|
|||||||
|
|
||||||
<Stack mb="lg">
|
<Stack mb="lg">
|
||||||
<Title order={2}>{tGeneral("item.language")}</Title>
|
<Title order={2}>{tGeneral("item.language")}</Title>
|
||||||
<LanguageCombobox />
|
<CurrentLanguageCombobox />
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|
||||||
<Stack mb="lg">
|
<Stack mb="lg">
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useChangeLocale, useCurrentLocale } from "@homarr/translation/client";
|
||||||
|
|
||||||
|
import { LanguageCombobox } from "./language-combobox";
|
||||||
|
|
||||||
|
export const CurrentLanguageCombobox = () => {
|
||||||
|
const currentLocale = useCurrentLocale();
|
||||||
|
const { changeLocale, isPending } = useChangeLocale();
|
||||||
|
|
||||||
|
return <LanguageCombobox value={currentLocale} onChange={changeLocale} isPending={isPending} />;
|
||||||
|
};
|
||||||
@@ -6,26 +6,30 @@ import { IconCheck } from "@tabler/icons-react";
|
|||||||
|
|
||||||
import type { SupportedLanguage } from "@homarr/translation";
|
import type { SupportedLanguage } from "@homarr/translation";
|
||||||
import { localeConfigurations, supportedLanguages } from "@homarr/translation";
|
import { localeConfigurations, supportedLanguages } from "@homarr/translation";
|
||||||
import { useChangeLocale, useCurrentLocale } from "@homarr/translation/client";
|
|
||||||
|
|
||||||
import classes from "./language-combobox.module.css";
|
import classes from "./language-combobox.module.css";
|
||||||
|
|
||||||
export const LanguageCombobox = () => {
|
interface LanguageComboboxProps {
|
||||||
|
label?: string;
|
||||||
|
value: SupportedLanguage;
|
||||||
|
onChange: (value: SupportedLanguage) => void;
|
||||||
|
isPending?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const LanguageCombobox = ({ label, value, onChange, isPending }: LanguageComboboxProps) => {
|
||||||
const combobox = useCombobox({
|
const combobox = useCombobox({
|
||||||
onDropdownClose: () => combobox.resetSelectedOption(),
|
onDropdownClose: () => combobox.resetSelectedOption(),
|
||||||
});
|
});
|
||||||
const currentLocale = useCurrentLocale();
|
|
||||||
const { changeLocale, isPending } = useChangeLocale();
|
|
||||||
|
|
||||||
const handleOnOptionSubmit = React.useCallback(
|
const handleOnOptionSubmit = React.useCallback(
|
||||||
(value: string) => {
|
(value: string) => {
|
||||||
if (!value) {
|
if (!value) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
changeLocale(value as SupportedLanguage);
|
onChange(value as SupportedLanguage);
|
||||||
combobox.closeDropdown();
|
combobox.closeDropdown();
|
||||||
},
|
},
|
||||||
[changeLocale, combobox],
|
[onChange, combobox],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleOnClick = React.useCallback(() => {
|
const handleOnClick = React.useCallback(() => {
|
||||||
@@ -39,20 +43,21 @@ export const LanguageCombobox = () => {
|
|||||||
component="button"
|
component="button"
|
||||||
type="button"
|
type="button"
|
||||||
pointer
|
pointer
|
||||||
|
label={label}
|
||||||
leftSection={isPending ? <Loader size={16} /> : null}
|
leftSection={isPending ? <Loader size={16} /> : null}
|
||||||
rightSection={<Combobox.Chevron />}
|
rightSection={<Combobox.Chevron />}
|
||||||
rightSectionPointerEvents="none"
|
rightSectionPointerEvents="none"
|
||||||
onClick={handleOnClick}
|
onClick={handleOnClick}
|
||||||
variant="filled"
|
variant="filled"
|
||||||
>
|
>
|
||||||
<OptionItem currentLocale={currentLocale} localeKey={currentLocale} />
|
<OptionItem currentLocale={value} localeKey={value} />
|
||||||
</InputBase>
|
</InputBase>
|
||||||
</Combobox.Target>
|
</Combobox.Target>
|
||||||
<Combobox.Dropdown>
|
<Combobox.Dropdown>
|
||||||
<Combobox.Options>
|
<Combobox.Options>
|
||||||
{supportedLanguages.map((languageKey) => (
|
{supportedLanguages.map((languageKey) => (
|
||||||
<Combobox.Option value={languageKey} key={languageKey}>
|
<Combobox.Option value={languageKey} key={languageKey}>
|
||||||
<OptionItem currentLocale={currentLocale} localeKey={languageKey} showCheck />
|
<OptionItem currentLocale={value} localeKey={languageKey} showCheck />
|
||||||
</Combobox.Option>
|
</Combobox.Option>
|
||||||
))}
|
))}
|
||||||
</Combobox.Options>
|
</Combobox.Options>
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
import Script from "next/script";
|
import Script from "next/script";
|
||||||
|
|
||||||
import { UMAMI_WEBSITE_ID } from "@homarr/analytics";
|
import { UMAMI_WEBSITE_ID } from "@homarr/analytics";
|
||||||
import { api } from "@homarr/api/server";
|
import { db } from "@homarr/db";
|
||||||
|
import { getServerSettingByKeyAsync } from "@homarr/db/queries";
|
||||||
|
|
||||||
export const Analytics = async () => {
|
export const Analytics = async () => {
|
||||||
// For static pages it will not find any analytics data so we do not include the script on them
|
// For static pages it will not find any analytics data so we do not include the script on them
|
||||||
const analytics = await api.serverSettings.getAnalytics().catch(() => null);
|
const analytics = await getServerSettingByKeyAsync(db, "analytics").catch(() => null);
|
||||||
|
|
||||||
if (analytics?.enableGeneral) {
|
if (analytics?.enableGeneral) {
|
||||||
return <Script src="https://umami.homarr.dev/script.js" data-website-id={UMAMI_WEBSITE_ID} defer />;
|
return <Script src="https://umami.homarr.dev/script.js" data-website-id={UMAMI_WEBSITE_ID} defer />;
|
||||||
|
|||||||
@@ -1,28 +1,28 @@
|
|||||||
import SuperJSON from "superjson";
|
import { db } from "@homarr/db";
|
||||||
|
import { getServerSettingByKeyAsync } from "@homarr/db/queries";
|
||||||
import { db, eq } from "@homarr/db";
|
|
||||||
import { serverSettings } from "@homarr/db/schema/sqlite";
|
|
||||||
import type { defaultServerSettings } from "@homarr/server-settings";
|
|
||||||
|
|
||||||
export const SearchEngineOptimization = async () => {
|
export const SearchEngineOptimization = async () => {
|
||||||
const crawlingAndIndexingSetting = await db.query.serverSettings.findFirst({
|
const crawlingAndIndexingSetting = await getServerSettingByKeyAsync(db, "crawlingAndIndexing");
|
||||||
where: eq(serverSettings.settingKey, "crawlingAndIndexing"),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!crawlingAndIndexingSetting) {
|
const robotsAttributes: string[] = [];
|
||||||
return null;
|
|
||||||
|
if (crawlingAndIndexingSetting.noIndex) {
|
||||||
|
robotsAttributes.push("noindex");
|
||||||
}
|
}
|
||||||
|
|
||||||
const value = SuperJSON.parse<(typeof defaultServerSettings)["crawlingAndIndexing"]>(
|
if (crawlingAndIndexingSetting.noFollow) {
|
||||||
crawlingAndIndexingSetting.value,
|
robotsAttributes.push("nofollow");
|
||||||
);
|
}
|
||||||
|
|
||||||
const robotsAttributes = [...(value.noIndex ? ["noindex"] : []), ...(value.noIndex ? ["nofollow"] : [])];
|
const googleAttributes: string[] = [];
|
||||||
|
|
||||||
const googleAttributes = [
|
if (crawlingAndIndexingSetting.noSiteLinksSearchBox) {
|
||||||
...(value.noSiteLinksSearchBox ? ["nositelinkssearchbox"] : []),
|
googleAttributes.push("nositelinkssearchbox");
|
||||||
...(value.noTranslate ? ["notranslate"] : []),
|
}
|
||||||
];
|
|
||||||
|
if (crawlingAndIndexingSetting.noTranslate) {
|
||||||
|
googleAttributes.push("notranslate");
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ import { useScopedI18n } from "@homarr/translation/client";
|
|||||||
import "flag-icons/css/flag-icons.min.css";
|
import "flag-icons/css/flag-icons.min.css";
|
||||||
|
|
||||||
import { useAuthContext } from "~/app/[locale]/_client-providers/session";
|
import { useAuthContext } from "~/app/[locale]/_client-providers/session";
|
||||||
import { LanguageCombobox } from "./language/language-combobox";
|
import { CurrentLanguageCombobox } from "./language/current-language-combobox";
|
||||||
|
|
||||||
interface UserAvatarMenuProps {
|
interface UserAvatarMenuProps {
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
@@ -72,7 +72,7 @@ export const UserAvatarMenu = ({ children }: UserAvatarMenuProps) => {
|
|||||||
<Menu.Divider />
|
<Menu.Divider />
|
||||||
|
|
||||||
<Menu.Item p={0} closeMenuOnClick={false}>
|
<Menu.Item p={0} closeMenuOnClick={false}>
|
||||||
<LanguageCombobox />
|
<CurrentLanguageCombobox />
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
<Menu.Divider />
|
<Menu.Divider />
|
||||||
{Boolean(session.data) && (
|
{Boolean(session.data) && (
|
||||||
|
|||||||
@@ -1,9 +1,15 @@
|
|||||||
import type { NextRequest } from "next/server";
|
import type { NextRequest } from "next/server";
|
||||||
|
|
||||||
import { I18nMiddleware } from "@homarr/translation/middleware";
|
import { fetchApi } from "@homarr/api/client";
|
||||||
|
import { createI18nMiddleware } from "@homarr/translation/middleware";
|
||||||
|
|
||||||
export function middleware(request: NextRequest) {
|
export async function middleware(request: NextRequest) {
|
||||||
return I18nMiddleware(request);
|
const culture = await fetchApi.serverSettings.getCulture.query();
|
||||||
|
|
||||||
|
// We don't want to fallback to accept-language header so we clear it
|
||||||
|
request.headers.set("accept-language", "");
|
||||||
|
const next = createI18nMiddleware(culture.defaultLocale);
|
||||||
|
return next(request);
|
||||||
}
|
}
|
||||||
|
|
||||||
export const config = {
|
export const config = {
|
||||||
|
|||||||
18
apps/nextjs/src/theme/color-scheme.ts
Normal file
18
apps/nextjs/src/theme/color-scheme.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import { cache } from "react";
|
||||||
|
import { cookies } from "next/headers";
|
||||||
|
|
||||||
|
import { db } from "@homarr/db";
|
||||||
|
import { getServerSettingByKeyAsync } from "@homarr/db/queries";
|
||||||
|
import type { ColorScheme } from "@homarr/definitions";
|
||||||
|
import { colorSchemeCookieKey } from "@homarr/definitions";
|
||||||
|
|
||||||
|
export const getCurrentColorSchemeAsync = cache(async () => {
|
||||||
|
const cookieValue = cookies().get(colorSchemeCookieKey)?.value;
|
||||||
|
|
||||||
|
if (cookieValue) {
|
||||||
|
return cookieValue as ColorScheme;
|
||||||
|
}
|
||||||
|
|
||||||
|
const appearanceSettings = await getServerSettingByKeyAsync(db, "appearance");
|
||||||
|
return appearanceSettings.defaultColorScheme;
|
||||||
|
});
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
import type { UmamiEventData } from "@umami/node";
|
import type { UmamiEventData } from "@umami/node";
|
||||||
import { Umami } from "@umami/node";
|
import { Umami } from "@umami/node";
|
||||||
import SuperJSON from "superjson";
|
|
||||||
|
|
||||||
import { count, db, eq } from "@homarr/db";
|
import { count, db } from "@homarr/db";
|
||||||
import { integrations, items, serverSettings, users } from "@homarr/db/schema/sqlite";
|
import { getServerSettingByKeyAsync } from "@homarr/db/queries";
|
||||||
|
import { integrations, items, users } from "@homarr/db/schema/sqlite";
|
||||||
import { logger } from "@homarr/log";
|
import { logger } from "@homarr/log";
|
||||||
import type { defaultServerSettings } from "@homarr/server-settings";
|
import type { defaultServerSettings } from "@homarr/server-settings";
|
||||||
|
|
||||||
@@ -12,18 +12,7 @@ import { UMAMI_HOST_URL, UMAMI_WEBSITE_ID } from "./constants";
|
|||||||
|
|
||||||
export const sendServerAnalyticsAsync = async () => {
|
export const sendServerAnalyticsAsync = async () => {
|
||||||
const stopWatch = new Stopwatch();
|
const stopWatch = new Stopwatch();
|
||||||
const setting = await db.query.serverSettings.findFirst({
|
const analyticsSettings = await getServerSettingByKeyAsync(db, "analytics");
|
||||||
where: eq(serverSettings.settingKey, "analytics"),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!setting) {
|
|
||||||
logger.info(
|
|
||||||
"Server does not know the configured state of analytics. No data will be sent. Enable analytics in the settings",
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const analyticsSettings = SuperJSON.parse<typeof defaultServerSettings.analytics>(setting.value);
|
|
||||||
|
|
||||||
if (!analyticsSettings.enableGeneral) {
|
if (!analyticsSettings.enableGeneral) {
|
||||||
logger.info("Analytics are disabled. No data will be sent. Enable analytics in the settings");
|
logger.info("Analytics are disabled. No data will be sent. Enable analytics in the settings");
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import superjson from "superjson";
|
|||||||
import { constructBoardPermissions } from "@homarr/auth/shared";
|
import { constructBoardPermissions } from "@homarr/auth/shared";
|
||||||
import type { Database, SQL } from "@homarr/db";
|
import type { Database, SQL } from "@homarr/db";
|
||||||
import { and, createId, eq, inArray, like, or } from "@homarr/db";
|
import { and, createId, eq, inArray, like, or } from "@homarr/db";
|
||||||
|
import { getServerSettingByKeyAsync } from "@homarr/db/queries";
|
||||||
import {
|
import {
|
||||||
boardGroupPermissions,
|
boardGroupPermissions,
|
||||||
boards,
|
boards,
|
||||||
@@ -41,6 +42,16 @@ export const boardRouter = createTRPCRouter({
|
|||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
|
getPublicBoards: publicProcedure.query(async ({ ctx }) => {
|
||||||
|
return await ctx.db.query.boards.findMany({
|
||||||
|
columns: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
logoImageUrl: true,
|
||||||
|
},
|
||||||
|
where: eq(boards.isPublic, true),
|
||||||
|
});
|
||||||
|
}),
|
||||||
getAllBoards: publicProcedure.query(async ({ ctx }) => {
|
getAllBoards: publicProcedure.query(async ({ ctx }) => {
|
||||||
const userId = ctx.session?.user.id;
|
const userId = ctx.session?.user.id;
|
||||||
const permissionsOfCurrentUserWhenPresent = await ctx.db.query.boardUserPermissions.findMany({
|
const permissionsOfCurrentUserWhenPresent = await ctx.db.query.boardUserPermissions.findMany({
|
||||||
@@ -216,6 +227,14 @@ export const boardRouter = createTRPCRouter({
|
|||||||
.input(validation.board.changeVisibility)
|
.input(validation.board.changeVisibility)
|
||||||
.mutation(async ({ ctx, input }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
await throwIfActionForbiddenAsync(ctx, eq(boards.id, input.id), "full");
|
await throwIfActionForbiddenAsync(ctx, eq(boards.id, input.id), "full");
|
||||||
|
const boardSettings = await getServerSettingByKeyAsync(ctx.db, "board");
|
||||||
|
|
||||||
|
if (input.visibility !== "public" && boardSettings.defaultBoardId === input.id) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "BAD_REQUEST",
|
||||||
|
message: "Cannot make default board private",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
await ctx.db
|
await ctx.db
|
||||||
.update(boards)
|
.update(boards)
|
||||||
@@ -240,7 +259,22 @@ export const boardRouter = createTRPCRouter({
|
|||||||
})
|
})
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
const boardWhere = user?.homeBoardId ? eq(boards.id, user.homeBoardId) : eq(boards.name, "home");
|
// 1. user home board, 2. default board, 3. not found
|
||||||
|
let boardWhere: SQL<unknown> | null = null;
|
||||||
|
if (user?.homeBoardId) {
|
||||||
|
boardWhere = eq(boards.id, user.homeBoardId);
|
||||||
|
} else {
|
||||||
|
const boardSettings = await getServerSettingByKeyAsync(ctx.db, "board");
|
||||||
|
boardWhere = boardSettings.defaultBoardId ? eq(boards.id, boardSettings.defaultBoardId) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!boardWhere) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "NOT_FOUND",
|
||||||
|
message: "No home board found",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
await throwIfActionForbiddenAsync(ctx, boardWhere, "view");
|
await throwIfActionForbiddenAsync(ctx, boardWhere, "view");
|
||||||
|
|
||||||
return await getFullBoardWithWhereAsync(ctx.db, boardWhere, ctx.session?.user.id ?? null);
|
return await getFullBoardWithWhereAsync(ctx.db, boardWhere, ctx.session?.user.id ?? null);
|
||||||
|
|||||||
@@ -1,47 +1,16 @@
|
|||||||
import SuperJSON from "superjson";
|
import { getServerSettingByKeyAsync, getServerSettingsAsync, updateServerSettingByKeyAsync } from "@homarr/db/queries";
|
||||||
|
import type { ServerSettings } from "@homarr/server-settings";
|
||||||
import { eq } from "@homarr/db";
|
|
||||||
import { serverSettings } from "@homarr/db/schema/sqlite";
|
|
||||||
import { logger } from "@homarr/log";
|
|
||||||
import type { defaultServerSettings, ServerSettings } from "@homarr/server-settings";
|
|
||||||
import { defaultServerSettingsKeys } from "@homarr/server-settings";
|
import { defaultServerSettingsKeys } from "@homarr/server-settings";
|
||||||
import { z } from "@homarr/validation";
|
import { z } from "@homarr/validation";
|
||||||
|
|
||||||
import { createTRPCRouter, protectedProcedure, publicProcedure } from "../trpc";
|
import { createTRPCRouter, protectedProcedure, publicProcedure } from "../trpc";
|
||||||
|
|
||||||
export const serverSettingsRouter = createTRPCRouter({
|
export const serverSettingsRouter = createTRPCRouter({
|
||||||
// this must be public so anonymous users also get analytics
|
getCulture: publicProcedure.query(async ({ ctx }) => {
|
||||||
getAnalytics: publicProcedure.query(async ({ ctx }) => {
|
return await getServerSettingByKeyAsync(ctx.db, "culture");
|
||||||
const setting = await ctx.db.query.serverSettings.findFirst({
|
|
||||||
where: eq(serverSettings.settingKey, "analytics"),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!setting) {
|
|
||||||
logger.info(
|
|
||||||
"Server settings for analytics is currently undefined. Using default values instead. If this persists, there may be an issue with the server settings",
|
|
||||||
);
|
|
||||||
return {
|
|
||||||
enableGeneral: true,
|
|
||||||
enableIntegrationData: false,
|
|
||||||
enableUserData: false,
|
|
||||||
enableWidgetData: false,
|
|
||||||
} as (typeof defaultServerSettings)["analytics"];
|
|
||||||
}
|
|
||||||
|
|
||||||
return SuperJSON.parse<(typeof defaultServerSettings)["analytics"]>(setting.value);
|
|
||||||
}),
|
}),
|
||||||
getAll: protectedProcedure.query(async ({ ctx }) => {
|
getAll: protectedProcedure.query(async ({ ctx }) => {
|
||||||
const settings = await ctx.db.query.serverSettings.findMany();
|
return await getServerSettingsAsync(ctx.db);
|
||||||
|
|
||||||
const data = {} as ServerSettings;
|
|
||||||
defaultServerSettingsKeys.forEach((key) => {
|
|
||||||
const settingValue = settings.find((setting) => setting.settingKey === key)?.value;
|
|
||||||
if (!settingValue) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
data[key] = SuperJSON.parse(settingValue);
|
|
||||||
});
|
|
||||||
return data;
|
|
||||||
}),
|
}),
|
||||||
saveSettings: protectedProcedure
|
saveSettings: protectedProcedure
|
||||||
.input(
|
.input(
|
||||||
@@ -51,12 +20,10 @@ export const serverSettingsRouter = createTRPCRouter({
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.mutation(async ({ ctx, input }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
const databaseRunResult = await ctx.db
|
await updateServerSettingByKeyAsync(
|
||||||
.update(serverSettings)
|
ctx.db,
|
||||||
.set({
|
input.settingsKey,
|
||||||
value: SuperJSON.stringify(input.value),
|
input.value as ServerSettings[keyof ServerSettings],
|
||||||
})
|
);
|
||||||
.where(eq(serverSettings.settingKey, input.settingsKey));
|
|
||||||
return databaseRunResult.changes === 1;
|
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
integrations,
|
integrations,
|
||||||
items,
|
items,
|
||||||
sections,
|
sections,
|
||||||
|
serverSettings,
|
||||||
users,
|
users,
|
||||||
} from "@homarr/db/schema/sqlite";
|
} from "@homarr/db/schema/sqlite";
|
||||||
import { createDb } from "@homarr/db/test";
|
import { createDb } from "@homarr/db/test";
|
||||||
@@ -473,13 +474,19 @@ describe("deleteBoard should delete board", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("getHomeBoard should return home board", () => {
|
describe("getHomeBoard should return home board", () => {
|
||||||
it("should return home board", async () => {
|
test("should return user home board when user has one", async () => {
|
||||||
// Arrange
|
// Arrange
|
||||||
const spy = vi.spyOn(boardAccess, "throwIfActionForbiddenAsync");
|
const spy = vi.spyOn(boardAccess, "throwIfActionForbiddenAsync");
|
||||||
const db = createDb();
|
const db = createDb();
|
||||||
const caller = boardRouter.createCaller({ db, session: defaultSession });
|
const caller = boardRouter.createCaller({ db, session: defaultSession });
|
||||||
|
|
||||||
const fullBoardProps = await createFullBoardAsync(db, "home");
|
const fullBoardProps = await createFullBoardAsync(db, "home");
|
||||||
|
await db
|
||||||
|
.update(users)
|
||||||
|
.set({
|
||||||
|
homeBoardId: fullBoardProps.boardId,
|
||||||
|
})
|
||||||
|
.where(eq(users.id, defaultCreatorId));
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
const result = await caller.getHomeBoard();
|
const result = await caller.getHomeBoard();
|
||||||
@@ -491,6 +498,40 @@ describe("getHomeBoard should return home board", () => {
|
|||||||
});
|
});
|
||||||
expect(spy).toHaveBeenCalledWith(expect.anything(), expect.anything(), "view");
|
expect(spy).toHaveBeenCalledWith(expect.anything(), expect.anything(), "view");
|
||||||
});
|
});
|
||||||
|
test("should return global home board when user doesn't have one", async () => {
|
||||||
|
// Arrange
|
||||||
|
const spy = vi.spyOn(boardAccess, "throwIfActionForbiddenAsync");
|
||||||
|
const db = createDb();
|
||||||
|
const caller = boardRouter.createCaller({ db, session: defaultSession });
|
||||||
|
|
||||||
|
const fullBoardProps = await createFullBoardAsync(db, "home");
|
||||||
|
await db.insert(serverSettings).values({
|
||||||
|
settingKey: "board",
|
||||||
|
value: SuperJSON.stringify({ defaultBoardId: fullBoardProps.boardId }),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Act
|
||||||
|
const result = await caller.getHomeBoard();
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expectInputToBeFullBoardWithName(result, {
|
||||||
|
name: "home",
|
||||||
|
...fullBoardProps,
|
||||||
|
});
|
||||||
|
expect(spy).toHaveBeenCalledWith(expect.anything(), expect.anything(), "view");
|
||||||
|
});
|
||||||
|
test("should throw error when home board not configured in serverSettings", async () => {
|
||||||
|
// Arrange
|
||||||
|
const db = createDb();
|
||||||
|
const caller = boardRouter.createCaller({ db, session: defaultSession });
|
||||||
|
await createFullBoardAsync(db, "home");
|
||||||
|
|
||||||
|
// Act
|
||||||
|
const actAsync = async () => await caller.getHomeBoard();
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
await expect(actAsync()).rejects.toThrowError("No home board found");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("getBoardByName should return board by name", () => {
|
describe("getBoardByName should return board by name", () => {
|
||||||
|
|||||||
@@ -40,56 +40,20 @@ describe("getAll server settings", () => {
|
|||||||
|
|
||||||
await expect(actAsync()).rejects.toThrow();
|
await expect(actAsync()).rejects.toThrow();
|
||||||
});
|
});
|
||||||
test("getAll should return server", async () => {
|
test("getAll should return default server settings when nothing in database", async () => {
|
||||||
const db = createDb();
|
const db = createDb();
|
||||||
const caller = serverSettingsRouter.createCaller({
|
const caller = serverSettingsRouter.createCaller({
|
||||||
db,
|
db,
|
||||||
session: defaultSession,
|
session: defaultSession,
|
||||||
});
|
});
|
||||||
|
|
||||||
await db.insert(serverSettings).values([
|
|
||||||
{
|
|
||||||
settingKey: defaultServerSettingsKeys[0],
|
|
||||||
value: SuperJSON.stringify(defaultServerSettings.analytics),
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
|
|
||||||
const result = await caller.getAll();
|
const result = await caller.getAll();
|
||||||
|
|
||||||
expect(result).toStrictEqual({
|
expect(result).toStrictEqual(defaultServerSettings);
|
||||||
analytics: {
|
|
||||||
enableGeneral: true,
|
|
||||||
enableWidgetData: false,
|
|
||||||
enableIntegrationData: false,
|
|
||||||
enableUserData: false,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("saveSettings", () => {
|
describe("saveSettings", () => {
|
||||||
test("saveSettings should return false when it did not update one", async () => {
|
|
||||||
const db = createDb();
|
|
||||||
const caller = serverSettingsRouter.createCaller({
|
|
||||||
db,
|
|
||||||
session: defaultSession,
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = await caller.saveSettings({
|
|
||||||
settingsKey: "analytics",
|
|
||||||
value: {
|
|
||||||
enableGeneral: true,
|
|
||||||
enableWidgetData: true,
|
|
||||||
enableIntegrationData: true,
|
|
||||||
enableUserData: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result).toBe(false);
|
|
||||||
|
|
||||||
const dbSettings = await db.select().from(serverSettings);
|
|
||||||
expect(dbSettings.length).toBe(0);
|
|
||||||
});
|
|
||||||
test("saveSettings should update settings and return true when it updated only one", async () => {
|
test("saveSettings should update settings and return true when it updated only one", async () => {
|
||||||
const db = createDb();
|
const db = createDb();
|
||||||
const caller = serverSettingsRouter.createCaller({
|
const caller = serverSettingsRouter.createCaller({
|
||||||
@@ -104,7 +68,7 @@ describe("saveSettings", () => {
|
|||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const result = await caller.saveSettings({
|
await caller.saveSettings({
|
||||||
settingsKey: "analytics",
|
settingsKey: "analytics",
|
||||||
value: {
|
value: {
|
||||||
enableGeneral: true,
|
enableGeneral: true,
|
||||||
@@ -114,8 +78,6 @@ describe("saveSettings", () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(result).toBe(true);
|
|
||||||
|
|
||||||
const dbSettings = await db.select().from(serverSettings);
|
const dbSettings = await db.select().from(serverSettings);
|
||||||
expect(dbSettings).toStrictEqual([
|
expect(dbSettings).toStrictEqual([
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import type { NextAuthConfig } from "next-auth";
|
|||||||
import { and, eq, inArray } from "@homarr/db";
|
import { and, eq, inArray } from "@homarr/db";
|
||||||
import type { Database } from "@homarr/db";
|
import type { Database } from "@homarr/db";
|
||||||
import { groupMembers, groups, users } from "@homarr/db/schema/sqlite";
|
import { groupMembers, groups, users } from "@homarr/db/schema/sqlite";
|
||||||
import { everyoneGroup } from "@homarr/definitions";
|
import { colorSchemeCookieKey, everyoneGroup } from "@homarr/definitions";
|
||||||
import { logger } from "@homarr/log";
|
import { logger } from "@homarr/log";
|
||||||
|
|
||||||
import { env } from "./env.mjs";
|
import { env } from "./env.mjs";
|
||||||
@@ -52,7 +52,7 @@ export const createSignInEventHandler = (db: Database): Exclude<NextAuthConfig["
|
|||||||
}
|
}
|
||||||
|
|
||||||
// We use a cookie as localStorage is not shared with server (otherwise flickering would occur)
|
// We use a cookie as localStorage is not shared with server (otherwise flickering would occur)
|
||||||
cookies().set("homarr-color-scheme", dbUser.colorScheme, {
|
cookies().set(colorSchemeCookieKey, dbUser.colorScheme, {
|
||||||
path: "/",
|
path: "/",
|
||||||
expires: dayjs().add(1, "year").toDate(),
|
expires: dayjs().add(1, "year").toDate(),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { eq } from "@homarr/db";
|
|||||||
import type { Database } from "@homarr/db";
|
import type { Database } from "@homarr/db";
|
||||||
import { groupMembers, groups, users } from "@homarr/db/schema/sqlite";
|
import { groupMembers, groups, users } from "@homarr/db/schema/sqlite";
|
||||||
import { createDb } from "@homarr/db/test";
|
import { createDb } from "@homarr/db/test";
|
||||||
import { everyoneGroup } from "@homarr/definitions";
|
import { colorSchemeCookieKey, everyoneGroup } from "@homarr/definitions";
|
||||||
|
|
||||||
import { createSignInEventHandler } from "../events";
|
import { createSignInEventHandler } from "../events";
|
||||||
|
|
||||||
@@ -224,7 +224,7 @@ describe("createSignInEventHandler should create signInEventHandler", () => {
|
|||||||
});
|
});
|
||||||
expect(dbUser?.name).toBe("test-new");
|
expect(dbUser?.name).toBe("test-new");
|
||||||
});
|
});
|
||||||
test("signInEventHandler should set homarr-color-scheme cookie", async () => {
|
test("signInEventHandler should set color-scheme cookie", async () => {
|
||||||
// Arrange
|
// Arrange
|
||||||
const db = createDb();
|
const db = createDb();
|
||||||
await createUserAsync(db);
|
await createUserAsync(db);
|
||||||
@@ -239,7 +239,7 @@ describe("createSignInEventHandler should create signInEventHandler", () => {
|
|||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
expect(cookies().set).toHaveBeenCalledWith(
|
expect(cookies().set).toHaveBeenCalledWith(
|
||||||
"homarr-color-scheme",
|
colorSchemeCookieKey,
|
||||||
"dark",
|
"dark",
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
path: "/",
|
path: "/",
|
||||||
|
|||||||
@@ -1,27 +1,16 @@
|
|||||||
import SuperJSON from "superjson";
|
|
||||||
|
|
||||||
import { sendServerAnalyticsAsync } from "@homarr/analytics";
|
import { sendServerAnalyticsAsync } from "@homarr/analytics";
|
||||||
import { EVERY_WEEK } from "@homarr/cron-jobs-core/expressions";
|
import { EVERY_WEEK } from "@homarr/cron-jobs-core/expressions";
|
||||||
import { db, eq } from "@homarr/db";
|
import { db } from "@homarr/db";
|
||||||
import { serverSettings } from "@homarr/db/schema/sqlite";
|
import { getServerSettingByKeyAsync } from "@homarr/db/queries";
|
||||||
import type { defaultServerSettings } from "@homarr/server-settings";
|
|
||||||
|
|
||||||
import { createCronJob } from "../lib";
|
import { createCronJob } from "../lib";
|
||||||
|
|
||||||
export const analyticsJob = createCronJob("analytics", EVERY_WEEK, {
|
export const analyticsJob = createCronJob("analytics", EVERY_WEEK, {
|
||||||
runOnStart: true,
|
runOnStart: true,
|
||||||
}).withCallback(async () => {
|
}).withCallback(async () => {
|
||||||
const analyticSetting = await db.query.serverSettings.findFirst({
|
const analyticSetting = await getServerSettingByKeyAsync(db, "analytics");
|
||||||
where: eq(serverSettings.settingKey, "analytics"),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!analyticSetting) {
|
if (!analyticSetting.enableGeneral) {
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const value = SuperJSON.parse<(typeof defaultServerSettings)["analytics"]>(analyticSetting.value);
|
|
||||||
|
|
||||||
if (!value.enableGeneral) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import SuperJSON from "superjson";
|
import SuperJSON from "superjson";
|
||||||
|
|
||||||
|
import { objectKeys } from "@homarr/common";
|
||||||
import { everyoneGroup } from "@homarr/definitions";
|
import { everyoneGroup } from "@homarr/definitions";
|
||||||
import { defaultServerSettings, defaultServerSettingsKeys } from "@homarr/server-settings";
|
import { defaultServerSettings, defaultServerSettingsKeys } from "@homarr/server-settings";
|
||||||
|
|
||||||
@@ -32,21 +33,33 @@ const seedEveryoneGroupAsync = async (db: Database) => {
|
|||||||
|
|
||||||
const seedServerSettingsAsync = async (db: Database) => {
|
const seedServerSettingsAsync = async (db: Database) => {
|
||||||
const serverSettingsData = await db.query.serverSettings.findMany();
|
const serverSettingsData = await db.query.serverSettings.findMany();
|
||||||
let insertedSettingsCount = 0;
|
|
||||||
|
|
||||||
for (const settingsKey of defaultServerSettingsKeys) {
|
for (const settingsKey of defaultServerSettingsKeys) {
|
||||||
if (serverSettingsData.some((setting) => setting.settingKey === settingsKey)) {
|
const currentDbEntry = serverSettingsData.find((setting) => setting.settingKey === settingsKey);
|
||||||
return;
|
if (!currentDbEntry) {
|
||||||
|
await db.insert(serverSettings).values({
|
||||||
|
settingKey: settingsKey,
|
||||||
|
value: SuperJSON.stringify(defaultServerSettings[settingsKey]),
|
||||||
|
});
|
||||||
|
console.log(`Created serverSetting through seed key=${settingsKey}`);
|
||||||
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
await db.insert(serverSettings).values({
|
const currentSettings = SuperJSON.parse<Record<string, unknown>>(currentDbEntry.value);
|
||||||
settingKey: settingsKey,
|
const defaultSettings = defaultServerSettings[settingsKey];
|
||||||
value: SuperJSON.stringify(defaultServerSettings[settingsKey]),
|
const missingKeys = objectKeys(defaultSettings).filter((key) => !(key in currentSettings));
|
||||||
});
|
|
||||||
insertedSettingsCount++;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (insertedSettingsCount > 0) {
|
if (missingKeys.length === 0) {
|
||||||
console.info(`Inserted ${insertedSettingsCount} missing settings`);
|
console.info(`Skipping seeding for serverSetting as it already exists key=${settingsKey}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
await db
|
||||||
|
.update(serverSettings)
|
||||||
|
.set({
|
||||||
|
value: SuperJSON.stringify({ ...defaultSettings, ...currentSettings }), // Add missing keys
|
||||||
|
})
|
||||||
|
.where(eq(serverSettings.settingKey, settingsKey));
|
||||||
|
console.log(`Updated serverSetting through seed key=${settingsKey}`);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1 +1,2 @@
|
|||||||
export * from "./item";
|
export * from "./item";
|
||||||
|
export * from "./server-setting";
|
||||||
|
|||||||
52
packages/db/queries/server-setting.ts
Normal file
52
packages/db/queries/server-setting.ts
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
import SuperJSON from "superjson";
|
||||||
|
|
||||||
|
import type { ServerSettings } from "@homarr/server-settings";
|
||||||
|
import { defaultServerSettings, defaultServerSettingsKeys } from "@homarr/server-settings";
|
||||||
|
|
||||||
|
import type { Database } from "..";
|
||||||
|
import { eq } from "..";
|
||||||
|
import { serverSettings } from "../schema/sqlite";
|
||||||
|
|
||||||
|
export const getServerSettingsAsync = async (db: Database) => {
|
||||||
|
const settings = await db.query.serverSettings.findMany();
|
||||||
|
|
||||||
|
return defaultServerSettingsKeys.reduce((acc, settingKey) => {
|
||||||
|
const setting = settings.find((setting) => setting.settingKey === settingKey);
|
||||||
|
if (!setting) {
|
||||||
|
// Typescript is not happy because the key is a union and it does not know that they are the same
|
||||||
|
acc[settingKey] = defaultServerSettings[settingKey] as never;
|
||||||
|
return acc;
|
||||||
|
}
|
||||||
|
|
||||||
|
acc[settingKey] = {
|
||||||
|
...defaultServerSettings[settingKey],
|
||||||
|
...SuperJSON.parse(setting.value),
|
||||||
|
};
|
||||||
|
return acc;
|
||||||
|
}, {} as ServerSettings);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getServerSettingByKeyAsync = async <TKey extends keyof ServerSettings>(db: Database, key: TKey) => {
|
||||||
|
const dbSettings = await db.query.serverSettings.findFirst({
|
||||||
|
where: eq(serverSettings.settingKey, key),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!dbSettings) {
|
||||||
|
return defaultServerSettings[key];
|
||||||
|
}
|
||||||
|
|
||||||
|
return SuperJSON.parse<ServerSettings[TKey]>(dbSettings.value);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const updateServerSettingByKeyAsync = async <TKey extends keyof ServerSettings>(
|
||||||
|
db: Database,
|
||||||
|
key: TKey,
|
||||||
|
value: ServerSettings[TKey],
|
||||||
|
) => {
|
||||||
|
await db
|
||||||
|
.update(serverSettings)
|
||||||
|
.set({
|
||||||
|
value: SuperJSON.stringify(value),
|
||||||
|
})
|
||||||
|
.where(eq(serverSettings.settingKey, key));
|
||||||
|
};
|
||||||
2
packages/definitions/src/cookie.ts
Normal file
2
packages/definitions/src/cookie.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export const colorSchemeCookieKey = "homarr.color-scheme";
|
||||||
|
export const localeCookieKey = "homarr.locale";
|
||||||
@@ -8,3 +8,4 @@ export * from "./auth";
|
|||||||
export * from "./user";
|
export * from "./user";
|
||||||
export * from "./group";
|
export * from "./group";
|
||||||
export * from "./docs";
|
export * from "./docs";
|
||||||
|
export * from "./cookie";
|
||||||
|
|||||||
@@ -21,6 +21,10 @@
|
|||||||
"typecheck": "tsc --noEmit"
|
"typecheck": "tsc --noEmit"
|
||||||
},
|
},
|
||||||
"prettier": "@homarr/prettier-config",
|
"prettier": "@homarr/prettier-config",
|
||||||
|
"dependencies": {
|
||||||
|
"@homarr/definitions": "workspace:^0.1.0",
|
||||||
|
"@homarr/translation": "workspace:^0.1.0"
|
||||||
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@homarr/eslint-config": "workspace:^0.2.0",
|
"@homarr/eslint-config": "workspace:^0.2.0",
|
||||||
"@homarr/prettier-config": "workspace:^0.1.0",
|
"@homarr/prettier-config": "workspace:^0.1.0",
|
||||||
|
|||||||
@@ -1,4 +1,13 @@
|
|||||||
export const defaultServerSettingsKeys = ["analytics", "crawlingAndIndexing"] as const;
|
import type { ColorScheme } from "@homarr/definitions";
|
||||||
|
import type { SupportedLanguage } from "@homarr/translation";
|
||||||
|
|
||||||
|
export const defaultServerSettingsKeys = [
|
||||||
|
"analytics",
|
||||||
|
"crawlingAndIndexing",
|
||||||
|
"board",
|
||||||
|
"appearance",
|
||||||
|
"culture",
|
||||||
|
] as const;
|
||||||
|
|
||||||
export type ServerSettingsRecord = Record<(typeof defaultServerSettingsKeys)[number], Record<string, unknown>>;
|
export type ServerSettingsRecord = Record<(typeof defaultServerSettingsKeys)[number], Record<string, unknown>>;
|
||||||
|
|
||||||
@@ -15,6 +24,15 @@ export const defaultServerSettings = {
|
|||||||
noTranslate: true,
|
noTranslate: true,
|
||||||
noSiteLinksSearchBox: false,
|
noSiteLinksSearchBox: false,
|
||||||
},
|
},
|
||||||
|
board: {
|
||||||
|
defaultBoardId: null as string | null,
|
||||||
|
},
|
||||||
|
appearance: {
|
||||||
|
defaultColorScheme: "light" as ColorScheme,
|
||||||
|
},
|
||||||
|
culture: {
|
||||||
|
defaultLocale: "en" as SupportedLanguage,
|
||||||
|
},
|
||||||
} satisfies ServerSettingsRecord;
|
} satisfies ServerSettingsRecord;
|
||||||
|
|
||||||
export type ServerSettings = typeof defaultServerSettings;
|
export type ServerSettings = typeof defaultServerSettings;
|
||||||
|
|||||||
@@ -27,7 +27,9 @@
|
|||||||
"prettier": "@homarr/prettier-config",
|
"prettier": "@homarr/prettier-config",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@homarr/common": "workspace:^0.1.0",
|
"@homarr/common": "workspace:^0.1.0",
|
||||||
|
"@homarr/definitions": "workspace:^0.1.0",
|
||||||
"dayjs": "^1.11.13",
|
"dayjs": "^1.11.13",
|
||||||
|
"deepmerge": "4.3.1",
|
||||||
"mantine-react-table": "2.0.0-beta.7",
|
"mantine-react-table": "2.0.0-beta.7",
|
||||||
"next": "^14.2.16",
|
"next": "^14.2.16",
|
||||||
"next-intl": "3.24.0",
|
"next-intl": "3.24.0",
|
||||||
|
|||||||
@@ -23,4 +23,4 @@ export const localeConfigurations = {
|
|||||||
export const supportedLanguages = objectKeys(localeConfigurations);
|
export const supportedLanguages = objectKeys(localeConfigurations);
|
||||||
export type SupportedLanguage = (typeof supportedLanguages)[number];
|
export type SupportedLanguage = (typeof supportedLanguages)[number];
|
||||||
|
|
||||||
export const defaultLocale = "en" satisfies SupportedLanguage;
|
export const fallbackLocale = "en" satisfies SupportedLanguage;
|
||||||
|
|||||||
@@ -1922,6 +1922,14 @@ export default {
|
|||||||
},
|
},
|
||||||
settings: {
|
settings: {
|
||||||
title: "Settings",
|
title: "Settings",
|
||||||
|
notification: {
|
||||||
|
success: {
|
||||||
|
message: "Settings saved successfully",
|
||||||
|
},
|
||||||
|
error: {
|
||||||
|
message: "Failed to save settings",
|
||||||
|
},
|
||||||
|
},
|
||||||
section: {
|
section: {
|
||||||
analytics: {
|
analytics: {
|
||||||
title: "Analytics",
|
title: "Analytics",
|
||||||
@@ -1963,6 +1971,29 @@ export default {
|
|||||||
text: "Google will build a search box with the crawled links along with other direct links. Enabling this will ask Google to disable that box.",
|
text: "Google will build a search box with the crawled links along with other direct links. Enabling this will ask Google to disable that box.",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
board: {
|
||||||
|
title: "Boards",
|
||||||
|
defaultBoard: {
|
||||||
|
label: "Global default board",
|
||||||
|
description: "Only public boards are available for selection",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
appearance: {
|
||||||
|
title: "Appearance",
|
||||||
|
defaultColorScheme: {
|
||||||
|
label: "Default color scheme",
|
||||||
|
options: {
|
||||||
|
light: "Light",
|
||||||
|
dark: "Dark",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
culture: {
|
||||||
|
title: "Culture",
|
||||||
|
defaultLocale: {
|
||||||
|
label: "Default language",
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
tool: {
|
tool: {
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import createMiddleware from "next-intl/middleware";
|
import createMiddleware from "next-intl/middleware";
|
||||||
|
|
||||||
import { routing } from "./routing";
|
import type { SupportedLanguage } from ".";
|
||||||
|
import { createRouting } from "./routing";
|
||||||
|
|
||||||
export const I18nMiddleware = createMiddleware(routing);
|
export const createI18nMiddleware = (defaultLocale: SupportedLanguage) =>
|
||||||
|
createMiddleware(createRouting(defaultLocale));
|
||||||
|
|
||||||
export const config = {
|
export const config = {
|
||||||
// Match only internationalized pathnames
|
// Match only internationalized pathnames
|
||||||
|
|||||||
@@ -1,17 +1,16 @@
|
|||||||
import deepmerge from "deepmerge";
|
import deepmerge from "deepmerge";
|
||||||
import { getRequestConfig } from "next-intl/server";
|
import { getRequestConfig } from "next-intl/server";
|
||||||
|
|
||||||
import { isLocaleSupported } from ".";
|
import { fallbackLocale, isLocaleSupported } from ".";
|
||||||
import type { SupportedLanguage } from "./config";
|
import type { SupportedLanguage } from "./config";
|
||||||
import { createLanguageMapping } from "./mapping";
|
import { createLanguageMapping } from "./mapping";
|
||||||
import { routing } from "./routing";
|
|
||||||
|
|
||||||
// This file is referenced in the `next.config.js` file. See https://next-intl-docs.vercel.app/docs/usage/configuration
|
// This file is referenced in the `next.config.js` file. See https://next-intl-docs.vercel.app/docs/usage/configuration
|
||||||
export default getRequestConfig(async ({ requestLocale }) => {
|
export default getRequestConfig(async ({ requestLocale }) => {
|
||||||
let currentLocale = await requestLocale;
|
let currentLocale = await requestLocale;
|
||||||
|
|
||||||
if (!currentLocale || !isLocaleSupported(currentLocale)) {
|
if (!currentLocale || !isLocaleSupported(currentLocale)) {
|
||||||
currentLocale = routing.defaultLocale;
|
currentLocale = fallbackLocale;
|
||||||
}
|
}
|
||||||
const typedLocale = currentLocale as SupportedLanguage;
|
const typedLocale = currentLocale as SupportedLanguage;
|
||||||
|
|
||||||
@@ -19,8 +18,8 @@ export default getRequestConfig(async ({ requestLocale }) => {
|
|||||||
const currentMessages = (await languageMap[typedLocale]()).default;
|
const currentMessages = (await languageMap[typedLocale]()).default;
|
||||||
|
|
||||||
// Fallback to default locale if the current locales messages if not all messages are present
|
// Fallback to default locale if the current locales messages if not all messages are present
|
||||||
if (currentLocale !== routing.defaultLocale) {
|
if (currentLocale !== fallbackLocale) {
|
||||||
const fallbackMessages = (await languageMap[routing.defaultLocale]()).default;
|
const fallbackMessages = (await languageMap[fallbackLocale]()).default;
|
||||||
return {
|
return {
|
||||||
locale: currentLocale,
|
locale: currentLocale,
|
||||||
messages: deepmerge(fallbackMessages, currentMessages),
|
messages: deepmerge(fallbackMessages, currentMessages),
|
||||||
|
|||||||
@@ -1,11 +1,18 @@
|
|||||||
import { defineRouting } from "next-intl/routing";
|
import { defineRouting } from "next-intl/routing";
|
||||||
|
|
||||||
import { defaultLocale, supportedLanguages } from "./config";
|
import { localeCookieKey } from "@homarr/definitions";
|
||||||
|
|
||||||
export const routing = defineRouting({
|
import type { SupportedLanguage } from "./config";
|
||||||
locales: supportedLanguages,
|
import { supportedLanguages } from "./config";
|
||||||
defaultLocale,
|
|
||||||
localePrefix: {
|
export const createRouting = (defaultLocale: SupportedLanguage) =>
|
||||||
mode: "never", // Rewrite the URL with locale parameter but without shown in url
|
defineRouting({
|
||||||
},
|
locales: supportedLanguages,
|
||||||
});
|
defaultLocale,
|
||||||
|
localeCookie: {
|
||||||
|
name: localeCookieKey,
|
||||||
|
},
|
||||||
|
localePrefix: {
|
||||||
|
mode: "never", // Rewrite the URL with locale parameter but without shown in url
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ export { OverflowBadge } from "./overflow-badge";
|
|||||||
export { SearchInput } from "./search-input";
|
export { SearchInput } from "./search-input";
|
||||||
export * from "./select-with-description";
|
export * from "./select-with-description";
|
||||||
export * from "./select-with-description-and-badge";
|
export * from "./select-with-description-and-badge";
|
||||||
|
export { SelectWithCustomItems } from "./select-with-custom-items";
|
||||||
export { TablePagination } from "./table-pagination";
|
export { TablePagination } from "./table-pagination";
|
||||||
export { TextMultiSelect } from "./text-multi-select";
|
export { TextMultiSelect } from "./text-multi-select";
|
||||||
export { UserAvatar } from "./user-avatar";
|
export { UserAvatar } from "./user-avatar";
|
||||||
|
|||||||
13
pnpm-lock.yaml
generated
13
pnpm-lock.yaml
generated
@@ -1303,6 +1303,13 @@ importers:
|
|||||||
version: 5.6.3
|
version: 5.6.3
|
||||||
|
|
||||||
packages/server-settings:
|
packages/server-settings:
|
||||||
|
dependencies:
|
||||||
|
'@homarr/definitions':
|
||||||
|
specifier: workspace:^0.1.0
|
||||||
|
version: link:../definitions
|
||||||
|
'@homarr/translation':
|
||||||
|
specifier: workspace:^0.1.0
|
||||||
|
version: link:../translation
|
||||||
devDependencies:
|
devDependencies:
|
||||||
'@homarr/eslint-config':
|
'@homarr/eslint-config':
|
||||||
specifier: workspace:^0.2.0
|
specifier: workspace:^0.2.0
|
||||||
@@ -1392,9 +1399,15 @@ importers:
|
|||||||
'@homarr/common':
|
'@homarr/common':
|
||||||
specifier: workspace:^0.1.0
|
specifier: workspace:^0.1.0
|
||||||
version: link:../common
|
version: link:../common
|
||||||
|
'@homarr/definitions':
|
||||||
|
specifier: workspace:^0.1.0
|
||||||
|
version: link:../definitions
|
||||||
dayjs:
|
dayjs:
|
||||||
specifier: ^1.11.13
|
specifier: ^1.11.13
|
||||||
version: 1.11.13
|
version: 1.11.13
|
||||||
|
deepmerge:
|
||||||
|
specifier: 4.3.1
|
||||||
|
version: 4.3.1
|
||||||
mantine-react-table:
|
mantine-react-table:
|
||||||
specifier: 2.0.0-beta.7
|
specifier: 2.0.0-beta.7
|
||||||
version: 2.0.0-beta.7(@mantine/core@7.13.4(@mantine/hooks@7.13.4(react@18.3.1))(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@mantine/dates@7.13.4(@mantine/core@7.13.4(@mantine/hooks@7.13.4(react@18.3.1))(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@mantine/hooks@7.13.4(react@18.3.1))(dayjs@1.11.13)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@mantine/hooks@7.13.4(react@18.3.1))(@tabler/icons-react@3.21.0(react@18.3.1))(clsx@2.1.1)(dayjs@1.11.13)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
version: 2.0.0-beta.7(@mantine/core@7.13.4(@mantine/hooks@7.13.4(react@18.3.1))(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@mantine/dates@7.13.4(@mantine/core@7.13.4(@mantine/hooks@7.13.4(react@18.3.1))(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@mantine/hooks@7.13.4(react@18.3.1))(dayjs@1.11.13)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@mantine/hooks@7.13.4(react@18.3.1))(@tabler/icons-react@3.21.0(react@18.3.1))(clsx@2.1.1)(dayjs@1.11.13)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||||
|
|||||||
Reference in New Issue
Block a user