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:
Meier Lukas
2024-11-02 21:15:46 +01:00
committed by GitHub
parent 49c0ebea6d
commit 326b769c23
42 changed files with 599 additions and 214 deletions

View File

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

View File

@@ -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) => {

View File

@@ -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")}

View File

@@ -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>
</> </>
)} )}

View File

@@ -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

View File

@@ -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";
};

View File

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

View File

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

View File

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

View File

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

View File

@@ -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>
</> </>
); );

View File

@@ -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">

View File

@@ -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} />;
};

View File

@@ -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>

View File

@@ -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 />;

View File

@@ -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 (
<> <>

View File

@@ -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) && (

View File

@@ -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 = {

View 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;
});

View File

@@ -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");

View File

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

View File

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

View File

@@ -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", () => {

View File

@@ -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([
{ {

View File

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

View File

@@ -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: "/",

View File

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

View File

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

View File

@@ -1 +1,2 @@
export * from "./item"; export * from "./item";
export * from "./server-setting";

View 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));
};

View File

@@ -0,0 +1,2 @@
export const colorSchemeCookieKey = "homarr.color-scheme";
export const localeCookieKey = "homarr.locale";

View File

@@ -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";

View File

@@ -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",

View File

@@ -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;

View File

@@ -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",

View File

@@ -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;

View File

@@ -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: {

View File

@@ -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

View File

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

View File

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

View File

@@ -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
View File

@@ -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)