mirror of
https://github.com/ajnart/homarr.git
synced 2026-02-27 08:50:56 +01:00
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 { useSession } from "@homarr/auth/client";
|
||||
import { parseCookies, setClientCookie } from "@homarr/common";
|
||||
import type { ColorScheme } from "@homarr/definitions";
|
||||
import { colorSchemeCookieKey } from "@homarr/definitions";
|
||||
|
||||
export const CustomMantineProvider = ({ children }: PropsWithChildren) => {
|
||||
export const CustomMantineProvider = ({
|
||||
children,
|
||||
defaultColorScheme,
|
||||
}: PropsWithChildren<{ defaultColorScheme: ColorScheme }>) => {
|
||||
const manager = useColorSchemeManager();
|
||||
|
||||
return (
|
||||
<DirectionProvider>
|
||||
<MantineProvider
|
||||
defaultColorScheme="dark"
|
||||
defaultColorScheme={defaultColorScheme}
|
||||
colorSchemeManager={manager}
|
||||
theme={createTheme({
|
||||
primaryColor: "red",
|
||||
@@ -28,12 +33,11 @@ export const CustomMantineProvider = ({ children }: PropsWithChildren) => {
|
||||
);
|
||||
};
|
||||
|
||||
function useColorSchemeManager(): MantineColorSchemeManager {
|
||||
const key = "homarr-color-scheme";
|
||||
export function useColorSchemeManager(): MantineColorSchemeManager {
|
||||
const { data: session } = useSession();
|
||||
|
||||
const updateCookieValue = (value: Exclude<MantineColorScheme, "auto">) => {
|
||||
setClientCookie(key, value, { expires: dayjs().add(1, "year").toDate(), path: "/" });
|
||||
setClientCookie(colorSchemeCookieKey, value, { expires: dayjs().add(1, "year").toDate(), path: "/" });
|
||||
};
|
||||
|
||||
const { mutate: mutateColorScheme } = clientApi.user.changeColorScheme.useMutation({
|
||||
@@ -50,7 +54,7 @@ function useColorSchemeManager(): MantineColorSchemeManager {
|
||||
|
||||
try {
|
||||
const cookies = parseCookies(document.cookie);
|
||||
return (cookies[key] as MantineColorScheme | undefined) ?? defaultValue;
|
||||
return (cookies[colorSchemeCookieKey] as MantineColorScheme | undefined) ?? defaultValue;
|
||||
} catch {
|
||||
return defaultValue;
|
||||
}
|
||||
|
||||
@@ -4,10 +4,17 @@ import type { PropsWithChildren } from "react";
|
||||
import type { MantineColorsTuple } from "@mantine/core";
|
||||
import { createTheme, darken, lighten, MantineProvider } from "@mantine/core";
|
||||
|
||||
import type { ColorScheme } from "@homarr/definitions";
|
||||
|
||||
import { useColorSchemeManager } from "../../_client-providers/mantine";
|
||||
import { useRequiredBoard } from "./_context";
|
||||
|
||||
export const BoardMantineProvider = ({ children }: PropsWithChildren) => {
|
||||
export const BoardMantineProvider = ({
|
||||
children,
|
||||
defaultColorScheme,
|
||||
}: PropsWithChildren<{ defaultColorScheme: ColorScheme }>) => {
|
||||
const board = useRequiredBoard();
|
||||
const colorSchemeManager = useColorSchemeManager();
|
||||
|
||||
const theme = createTheme({
|
||||
colors: {
|
||||
@@ -18,7 +25,11 @@ export const BoardMantineProvider = ({ children }: PropsWithChildren) => {
|
||||
autoContrast: true,
|
||||
});
|
||||
|
||||
return <MantineProvider theme={theme}>{children}</MantineProvider>;
|
||||
return (
|
||||
<MantineProvider defaultColorScheme={defaultColorScheme} theme={theme} colorSchemeManager={colorSchemeManager}>
|
||||
{children}
|
||||
</MantineProvider>
|
||||
);
|
||||
};
|
||||
|
||||
export const generateColors = (hex: string) => {
|
||||
|
||||
@@ -12,7 +12,7 @@ import { BoardRenameModal } from "~/components/board/modals/board-rename-modal";
|
||||
import { useRequiredBoard } from "../../(content)/_context";
|
||||
import classes from "./danger.module.css";
|
||||
|
||||
export const DangerZoneSettingsContent = () => {
|
||||
export const DangerZoneSettingsContent = ({ hideVisibility }: { hideVisibility: boolean }) => {
|
||||
const board = useRequiredBoard();
|
||||
const t = useScopedI18n("board.setting");
|
||||
const router = useRouter();
|
||||
@@ -90,14 +90,18 @@ export const DangerZoneSettingsContent = () => {
|
||||
buttonText={t("section.dangerZone.action.rename.button")}
|
||||
onClick={onRenameClick}
|
||||
/>
|
||||
<Divider />
|
||||
<DangerZoneRow
|
||||
label={t("section.dangerZone.action.visibility.label")}
|
||||
description={t(`section.dangerZone.action.visibility.description.${visibility}`)}
|
||||
buttonText={t(`section.dangerZone.action.visibility.button.${visibility}`)}
|
||||
onClick={onVisibilityClick}
|
||||
isPending={isChangeVisibilityPending}
|
||||
/>
|
||||
{hideVisibility ? null : (
|
||||
<>
|
||||
<Divider />
|
||||
<DangerZoneRow
|
||||
label={t("section.dangerZone.action.visibility.label")}
|
||||
description={t(`section.dangerZone.action.visibility.description.${visibility}`)}
|
||||
buttonText={t(`section.dangerZone.action.visibility.button.${visibility}`)}
|
||||
onClick={onVisibilityClick}
|
||||
isPending={isChangeVisibilityPending}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<Divider />
|
||||
<DangerZoneRow
|
||||
label={t("section.dangerZone.action.delete.label")}
|
||||
|
||||
@@ -14,6 +14,8 @@ import { TRPCError } from "@trpc/server";
|
||||
|
||||
import { api } from "@homarr/api/server";
|
||||
import { capitalize } from "@homarr/common";
|
||||
import { db } from "@homarr/db";
|
||||
import { getServerSettingByKeyAsync } from "@homarr/db/queries";
|
||||
import type { TranslationObject } from "@homarr/translation";
|
||||
import { getScopedI18n } from "@homarr/translation/server";
|
||||
import type { TablerIcon } from "@homarr/ui";
|
||||
@@ -63,6 +65,7 @@ const getBoardAndPermissionsAsync = async (params: Props["params"]) => {
|
||||
|
||||
export default async function BoardSettingsPage({ params, searchParams }: Props) {
|
||||
const { board, permissions } = await getBoardAndPermissionsAsync(params);
|
||||
const boardSettings = await getServerSettingByKeyAsync(db, "board");
|
||||
const { hasFullAccess } = await getBoardPermissionsAsync(board);
|
||||
const t = await getScopedI18n("board.setting");
|
||||
|
||||
@@ -92,7 +95,7 @@ export default async function BoardSettingsPage({ params, searchParams }: Props)
|
||||
<BoardAccessSettings board={board} initialPermissions={permissions} />
|
||||
</AccordionItemFor>
|
||||
<AccordionItemFor value="dangerZone" icon={IconAlertTriangle} danger noPadding>
|
||||
<DangerZoneSettingsContent />
|
||||
<DangerZoneSettingsContent hideVisibility={boardSettings.defaultBoardId === board.id} />
|
||||
</AccordionItemFor>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -8,6 +8,7 @@ import { logger } from "@homarr/log";
|
||||
import { MainHeader } from "~/components/layout/header";
|
||||
import { BoardLogoWithTitle } from "~/components/layout/logo/board-logo";
|
||||
import { ClientShell } from "~/components/layout/shell";
|
||||
import { getCurrentColorSchemeAsync } from "~/theme/color-scheme";
|
||||
import type { Board } from "./_types";
|
||||
import { BoardProvider } from "./(content)/_context";
|
||||
import type { Params } from "./(content)/_creator";
|
||||
@@ -37,10 +38,11 @@ export const createBoardLayout = <TParams extends Params>({
|
||||
|
||||
throw error;
|
||||
});
|
||||
const colorScheme = await getCurrentColorSchemeAsync();
|
||||
|
||||
return (
|
||||
<BoardProvider initialBoard={initialBoard}>
|
||||
<BoardMantineProvider>
|
||||
<BoardMantineProvider defaultColorScheme={colorScheme}>
|
||||
<CustomCss />
|
||||
<ClientShell hasNavigation={false}>
|
||||
<MainHeader
|
||||
|
||||
@@ -6,7 +6,6 @@ import "@homarr/spotlight/styles.css";
|
||||
import "@homarr/ui/styles.css";
|
||||
import "~/styles/scroll-area.scss";
|
||||
|
||||
import { cookies } from "next/headers";
|
||||
import { notFound } from "next/navigation";
|
||||
import { NextIntlClientProvider } from "next-intl";
|
||||
|
||||
@@ -19,6 +18,7 @@ import { getI18nMessages, getScopedI18n } from "@homarr/translation/server";
|
||||
|
||||
import { Analytics } from "~/components/layout/analytics";
|
||||
import { SearchEngineOptimization } from "~/components/layout/search-engine-optimization";
|
||||
import { getCurrentColorSchemeAsync } from "~/theme/color-scheme";
|
||||
import { JotaiProvider } from "./_client-providers/jotai";
|
||||
import { CustomMantineProvider } from "./_client-providers/mantine";
|
||||
import { AuthProvider } from "./_client-providers/session";
|
||||
@@ -30,7 +30,8 @@ const fontSans = Inter({
|
||||
variable: "--font-sans",
|
||||
});
|
||||
|
||||
export const generateMetadata = (): Metadata => ({
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
export const generateMetadata = async (): Promise<Metadata> => ({
|
||||
title: "Homarr",
|
||||
description:
|
||||
"Simplify the management of your server with Homarr - a sleek, modern dashboard that puts all of your apps and services at your fingertips.",
|
||||
@@ -49,7 +50,7 @@ export const generateMetadata = (): Metadata => ({
|
||||
title: "Homarr",
|
||||
capable: true,
|
||||
startupImage: { url: "/logo/logo.png" },
|
||||
statusBarStyle: getColorScheme() === "dark" ? "black-translucent" : "default",
|
||||
statusBarStyle: (await getCurrentColorSchemeAsync()) === "dark" ? "black-translucent" : "default",
|
||||
},
|
||||
});
|
||||
|
||||
@@ -66,7 +67,7 @@ export default async function Layout(props: { children: React.ReactNode; params:
|
||||
}
|
||||
|
||||
const session = await auth();
|
||||
const colorScheme = getColorScheme();
|
||||
const colorScheme = await getCurrentColorSchemeAsync();
|
||||
const tCommon = await getScopedI18n("common");
|
||||
const direction = tCommon("direction");
|
||||
const i18nMessages = await getI18nMessages();
|
||||
@@ -78,7 +79,7 @@ export default async function Layout(props: { children: React.ReactNode; params:
|
||||
(innerProps) => <JotaiProvider {...innerProps} />,
|
||||
(innerProps) => <TRPCReactProvider {...innerProps} />,
|
||||
(innerProps) => <NextIntlClientProvider {...innerProps} messages={i18nMessages} />,
|
||||
(innerProps) => <CustomMantineProvider {...innerProps} />,
|
||||
(innerProps) => <CustomMantineProvider {...innerProps} defaultColorScheme={colorScheme} />,
|
||||
(innerProps) => <ModalProvider {...innerProps} />,
|
||||
]);
|
||||
|
||||
@@ -106,7 +107,3 @@ export default async function Layout(props: { children: React.ReactNode; params:
|
||||
</html>
|
||||
);
|
||||
}
|
||||
|
||||
const getColorScheme = () => {
|
||||
return cookies().get("homarr-color-scheme")?.value ?? "dark";
|
||||
};
|
||||
|
||||
@@ -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 { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
|
||||
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() {
|
||||
const t = await getScopedI18n("management");
|
||||
@@ -18,14 +21,26 @@ export async function generateMetadata() {
|
||||
|
||||
export default async function SettingsPage() {
|
||||
const serverSettings = await api.serverSettings.getAll();
|
||||
const t = await getScopedI18n("management.page.settings");
|
||||
const tSettings = await getScopedI18n("management.page.settings");
|
||||
return (
|
||||
<>
|
||||
<DynamicBreadcrumb />
|
||||
<Stack>
|
||||
<Title order={1}>{t("title")}</Title>
|
||||
<Title order={1}>{tSettings("title")}</Title>
|
||||
<AnalyticsSettings initialData={serverSettings.analytics} />
|
||||
<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>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -6,7 +6,7 @@ import { api } from "@homarr/api/server";
|
||||
import { auth } from "@homarr/auth/next";
|
||||
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 { catchTrpcNotFound } from "~/errors/trpc-not-found";
|
||||
import { createMetaTitle } from "~/metadata";
|
||||
@@ -81,7 +81,7 @@ export default async function EditUserPage({ params }: Props) {
|
||||
|
||||
<Stack mb="lg">
|
||||
<Title order={2}>{tGeneral("item.language")}</Title>
|
||||
<LanguageCombobox />
|
||||
<CurrentLanguageCombobox />
|
||||
</Stack>
|
||||
|
||||
<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 { localeConfigurations, supportedLanguages } from "@homarr/translation";
|
||||
import { useChangeLocale, useCurrentLocale } from "@homarr/translation/client";
|
||||
|
||||
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({
|
||||
onDropdownClose: () => combobox.resetSelectedOption(),
|
||||
});
|
||||
const currentLocale = useCurrentLocale();
|
||||
const { changeLocale, isPending } = useChangeLocale();
|
||||
|
||||
const handleOnOptionSubmit = React.useCallback(
|
||||
(value: string) => {
|
||||
if (!value) {
|
||||
return;
|
||||
}
|
||||
changeLocale(value as SupportedLanguage);
|
||||
onChange(value as SupportedLanguage);
|
||||
combobox.closeDropdown();
|
||||
},
|
||||
[changeLocale, combobox],
|
||||
[onChange, combobox],
|
||||
);
|
||||
|
||||
const handleOnClick = React.useCallback(() => {
|
||||
@@ -39,20 +43,21 @@ export const LanguageCombobox = () => {
|
||||
component="button"
|
||||
type="button"
|
||||
pointer
|
||||
label={label}
|
||||
leftSection={isPending ? <Loader size={16} /> : null}
|
||||
rightSection={<Combobox.Chevron />}
|
||||
rightSectionPointerEvents="none"
|
||||
onClick={handleOnClick}
|
||||
variant="filled"
|
||||
>
|
||||
<OptionItem currentLocale={currentLocale} localeKey={currentLocale} />
|
||||
<OptionItem currentLocale={value} localeKey={value} />
|
||||
</InputBase>
|
||||
</Combobox.Target>
|
||||
<Combobox.Dropdown>
|
||||
<Combobox.Options>
|
||||
{supportedLanguages.map((languageKey) => (
|
||||
<Combobox.Option value={languageKey} key={languageKey}>
|
||||
<OptionItem currentLocale={currentLocale} localeKey={languageKey} showCheck />
|
||||
<OptionItem currentLocale={value} localeKey={languageKey} showCheck />
|
||||
</Combobox.Option>
|
||||
))}
|
||||
</Combobox.Options>
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import Script from "next/script";
|
||||
|
||||
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 () => {
|
||||
// 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) {
|
||||
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, eq } from "@homarr/db";
|
||||
import { serverSettings } from "@homarr/db/schema/sqlite";
|
||||
import type { defaultServerSettings } from "@homarr/server-settings";
|
||||
import { db } from "@homarr/db";
|
||||
import { getServerSettingByKeyAsync } from "@homarr/db/queries";
|
||||
|
||||
export const SearchEngineOptimization = async () => {
|
||||
const crawlingAndIndexingSetting = await db.query.serverSettings.findFirst({
|
||||
where: eq(serverSettings.settingKey, "crawlingAndIndexing"),
|
||||
});
|
||||
const crawlingAndIndexingSetting = await getServerSettingByKeyAsync(db, "crawlingAndIndexing");
|
||||
|
||||
if (!crawlingAndIndexingSetting) {
|
||||
return null;
|
||||
const robotsAttributes: string[] = [];
|
||||
|
||||
if (crawlingAndIndexingSetting.noIndex) {
|
||||
robotsAttributes.push("noindex");
|
||||
}
|
||||
|
||||
const value = SuperJSON.parse<(typeof defaultServerSettings)["crawlingAndIndexing"]>(
|
||||
crawlingAndIndexingSetting.value,
|
||||
);
|
||||
if (crawlingAndIndexingSetting.noFollow) {
|
||||
robotsAttributes.push("nofollow");
|
||||
}
|
||||
|
||||
const robotsAttributes = [...(value.noIndex ? ["noindex"] : []), ...(value.noIndex ? ["nofollow"] : [])];
|
||||
const googleAttributes: string[] = [];
|
||||
|
||||
const googleAttributes = [
|
||||
...(value.noSiteLinksSearchBox ? ["nositelinkssearchbox"] : []),
|
||||
...(value.noTranslate ? ["notranslate"] : []),
|
||||
];
|
||||
if (crawlingAndIndexingSetting.noSiteLinksSearchBox) {
|
||||
googleAttributes.push("nositelinkssearchbox");
|
||||
}
|
||||
|
||||
if (crawlingAndIndexingSetting.noTranslate) {
|
||||
googleAttributes.push("notranslate");
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
|
||||
@@ -24,7 +24,7 @@ import { useScopedI18n } from "@homarr/translation/client";
|
||||
import "flag-icons/css/flag-icons.min.css";
|
||||
|
||||
import { useAuthContext } from "~/app/[locale]/_client-providers/session";
|
||||
import { LanguageCombobox } from "./language/language-combobox";
|
||||
import { CurrentLanguageCombobox } from "./language/current-language-combobox";
|
||||
|
||||
interface UserAvatarMenuProps {
|
||||
children: ReactNode;
|
||||
@@ -72,7 +72,7 @@ export const UserAvatarMenu = ({ children }: UserAvatarMenuProps) => {
|
||||
<Menu.Divider />
|
||||
|
||||
<Menu.Item p={0} closeMenuOnClick={false}>
|
||||
<LanguageCombobox />
|
||||
<CurrentLanguageCombobox />
|
||||
</Menu.Item>
|
||||
<Menu.Divider />
|
||||
{Boolean(session.data) && (
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
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) {
|
||||
return I18nMiddleware(request);
|
||||
export async function middleware(request: NextRequest) {
|
||||
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 = {
|
||||
|
||||
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 { Umami } from "@umami/node";
|
||||
import SuperJSON from "superjson";
|
||||
|
||||
import { count, db, eq } from "@homarr/db";
|
||||
import { integrations, items, serverSettings, users } from "@homarr/db/schema/sqlite";
|
||||
import { count, db } from "@homarr/db";
|
||||
import { getServerSettingByKeyAsync } from "@homarr/db/queries";
|
||||
import { integrations, items, users } from "@homarr/db/schema/sqlite";
|
||||
import { logger } from "@homarr/log";
|
||||
import type { defaultServerSettings } from "@homarr/server-settings";
|
||||
|
||||
@@ -12,18 +12,7 @@ import { UMAMI_HOST_URL, UMAMI_WEBSITE_ID } from "./constants";
|
||||
|
||||
export const sendServerAnalyticsAsync = async () => {
|
||||
const stopWatch = new Stopwatch();
|
||||
const setting = await db.query.serverSettings.findFirst({
|
||||
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);
|
||||
const analyticsSettings = await getServerSettingByKeyAsync(db, "analytics");
|
||||
|
||||
if (!analyticsSettings.enableGeneral) {
|
||||
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 type { Database, SQL } from "@homarr/db";
|
||||
import { and, createId, eq, inArray, like, or } from "@homarr/db";
|
||||
import { getServerSettingByKeyAsync } from "@homarr/db/queries";
|
||||
import {
|
||||
boardGroupPermissions,
|
||||
boards,
|
||||
@@ -41,6 +42,16 @@ export const boardRouter = createTRPCRouter({
|
||||
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 }) => {
|
||||
const userId = ctx.session?.user.id;
|
||||
const permissionsOfCurrentUserWhenPresent = await ctx.db.query.boardUserPermissions.findMany({
|
||||
@@ -216,6 +227,14 @@ export const boardRouter = createTRPCRouter({
|
||||
.input(validation.board.changeVisibility)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
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
|
||||
.update(boards)
|
||||
@@ -240,7 +259,22 @@ export const boardRouter = createTRPCRouter({
|
||||
})
|
||||
: 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");
|
||||
|
||||
return await getFullBoardWithWhereAsync(ctx.db, boardWhere, ctx.session?.user.id ?? null);
|
||||
|
||||
@@ -1,47 +1,16 @@
|
||||
import SuperJSON from "superjson";
|
||||
|
||||
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 { getServerSettingByKeyAsync, getServerSettingsAsync, updateServerSettingByKeyAsync } from "@homarr/db/queries";
|
||||
import type { ServerSettings } from "@homarr/server-settings";
|
||||
import { defaultServerSettingsKeys } from "@homarr/server-settings";
|
||||
import { z } from "@homarr/validation";
|
||||
|
||||
import { createTRPCRouter, protectedProcedure, publicProcedure } from "../trpc";
|
||||
|
||||
export const serverSettingsRouter = createTRPCRouter({
|
||||
// this must be public so anonymous users also get analytics
|
||||
getAnalytics: publicProcedure.query(async ({ ctx }) => {
|
||||
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);
|
||||
getCulture: publicProcedure.query(async ({ ctx }) => {
|
||||
return await getServerSettingByKeyAsync(ctx.db, "culture");
|
||||
}),
|
||||
getAll: protectedProcedure.query(async ({ ctx }) => {
|
||||
const settings = await ctx.db.query.serverSettings.findMany();
|
||||
|
||||
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;
|
||||
return await getServerSettingsAsync(ctx.db);
|
||||
}),
|
||||
saveSettings: protectedProcedure
|
||||
.input(
|
||||
@@ -51,12 +20,10 @@ export const serverSettingsRouter = createTRPCRouter({
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const databaseRunResult = await ctx.db
|
||||
.update(serverSettings)
|
||||
.set({
|
||||
value: SuperJSON.stringify(input.value),
|
||||
})
|
||||
.where(eq(serverSettings.settingKey, input.settingsKey));
|
||||
return databaseRunResult.changes === 1;
|
||||
await updateServerSettingByKeyAsync(
|
||||
ctx.db,
|
||||
input.settingsKey,
|
||||
input.value as ServerSettings[keyof ServerSettings],
|
||||
);
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
integrations,
|
||||
items,
|
||||
sections,
|
||||
serverSettings,
|
||||
users,
|
||||
} from "@homarr/db/schema/sqlite";
|
||||
import { createDb } from "@homarr/db/test";
|
||||
@@ -473,13 +474,19 @@ describe("deleteBoard should delete 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
|
||||
const spy = vi.spyOn(boardAccess, "throwIfActionForbiddenAsync");
|
||||
const db = createDb();
|
||||
const caller = boardRouter.createCaller({ db, session: defaultSession });
|
||||
|
||||
const fullBoardProps = await createFullBoardAsync(db, "home");
|
||||
await db
|
||||
.update(users)
|
||||
.set({
|
||||
homeBoardId: fullBoardProps.boardId,
|
||||
})
|
||||
.where(eq(users.id, defaultCreatorId));
|
||||
|
||||
// Act
|
||||
const result = await caller.getHomeBoard();
|
||||
@@ -491,6 +498,40 @@ describe("getHomeBoard should return home board", () => {
|
||||
});
|
||||
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", () => {
|
||||
|
||||
@@ -40,56 +40,20 @@ describe("getAll server settings", () => {
|
||||
|
||||
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 caller = serverSettingsRouter.createCaller({
|
||||
db,
|
||||
session: defaultSession,
|
||||
});
|
||||
|
||||
await db.insert(serverSettings).values([
|
||||
{
|
||||
settingKey: defaultServerSettingsKeys[0],
|
||||
value: SuperJSON.stringify(defaultServerSettings.analytics),
|
||||
},
|
||||
]);
|
||||
|
||||
const result = await caller.getAll();
|
||||
|
||||
expect(result).toStrictEqual({
|
||||
analytics: {
|
||||
enableGeneral: true,
|
||||
enableWidgetData: false,
|
||||
enableIntegrationData: false,
|
||||
enableUserData: false,
|
||||
},
|
||||
});
|
||||
expect(result).toStrictEqual(defaultServerSettings);
|
||||
});
|
||||
});
|
||||
|
||||
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 () => {
|
||||
const db = createDb();
|
||||
const caller = serverSettingsRouter.createCaller({
|
||||
@@ -104,7 +68,7 @@ describe("saveSettings", () => {
|
||||
},
|
||||
]);
|
||||
|
||||
const result = await caller.saveSettings({
|
||||
await caller.saveSettings({
|
||||
settingsKey: "analytics",
|
||||
value: {
|
||||
enableGeneral: true,
|
||||
@@ -114,8 +78,6 @@ describe("saveSettings", () => {
|
||||
},
|
||||
});
|
||||
|
||||
expect(result).toBe(true);
|
||||
|
||||
const dbSettings = await db.select().from(serverSettings);
|
||||
expect(dbSettings).toStrictEqual([
|
||||
{
|
||||
|
||||
@@ -5,7 +5,7 @@ import type { NextAuthConfig } from "next-auth";
|
||||
import { and, eq, inArray } from "@homarr/db";
|
||||
import type { Database } from "@homarr/db";
|
||||
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 { 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)
|
||||
cookies().set("homarr-color-scheme", dbUser.colorScheme, {
|
||||
cookies().set(colorSchemeCookieKey, dbUser.colorScheme, {
|
||||
path: "/",
|
||||
expires: dayjs().add(1, "year").toDate(),
|
||||
});
|
||||
|
||||
@@ -7,7 +7,7 @@ import { eq } from "@homarr/db";
|
||||
import type { Database } from "@homarr/db";
|
||||
import { groupMembers, groups, users } from "@homarr/db/schema/sqlite";
|
||||
import { createDb } from "@homarr/db/test";
|
||||
import { everyoneGroup } from "@homarr/definitions";
|
||||
import { colorSchemeCookieKey, everyoneGroup } from "@homarr/definitions";
|
||||
|
||||
import { createSignInEventHandler } from "../events";
|
||||
|
||||
@@ -224,7 +224,7 @@ describe("createSignInEventHandler should create signInEventHandler", () => {
|
||||
});
|
||||
expect(dbUser?.name).toBe("test-new");
|
||||
});
|
||||
test("signInEventHandler should set homarr-color-scheme cookie", async () => {
|
||||
test("signInEventHandler should set color-scheme cookie", async () => {
|
||||
// Arrange
|
||||
const db = createDb();
|
||||
await createUserAsync(db);
|
||||
@@ -239,7 +239,7 @@ describe("createSignInEventHandler should create signInEventHandler", () => {
|
||||
|
||||
// Assert
|
||||
expect(cookies().set).toHaveBeenCalledWith(
|
||||
"homarr-color-scheme",
|
||||
colorSchemeCookieKey,
|
||||
"dark",
|
||||
expect.objectContaining({
|
||||
path: "/",
|
||||
|
||||
@@ -1,27 +1,16 @@
|
||||
import SuperJSON from "superjson";
|
||||
|
||||
import { sendServerAnalyticsAsync } from "@homarr/analytics";
|
||||
import { EVERY_WEEK } from "@homarr/cron-jobs-core/expressions";
|
||||
import { db, eq } from "@homarr/db";
|
||||
import { serverSettings } from "@homarr/db/schema/sqlite";
|
||||
import type { defaultServerSettings } from "@homarr/server-settings";
|
||||
import { db } from "@homarr/db";
|
||||
import { getServerSettingByKeyAsync } from "@homarr/db/queries";
|
||||
|
||||
import { createCronJob } from "../lib";
|
||||
|
||||
export const analyticsJob = createCronJob("analytics", EVERY_WEEK, {
|
||||
runOnStart: true,
|
||||
}).withCallback(async () => {
|
||||
const analyticSetting = await db.query.serverSettings.findFirst({
|
||||
where: eq(serverSettings.settingKey, "analytics"),
|
||||
});
|
||||
const analyticSetting = await getServerSettingByKeyAsync(db, "analytics");
|
||||
|
||||
if (!analyticSetting) {
|
||||
return;
|
||||
}
|
||||
|
||||
const value = SuperJSON.parse<(typeof defaultServerSettings)["analytics"]>(analyticSetting.value);
|
||||
|
||||
if (!value.enableGeneral) {
|
||||
if (!analyticSetting.enableGeneral) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import SuperJSON from "superjson";
|
||||
|
||||
import { objectKeys } from "@homarr/common";
|
||||
import { everyoneGroup } from "@homarr/definitions";
|
||||
import { defaultServerSettings, defaultServerSettingsKeys } from "@homarr/server-settings";
|
||||
|
||||
@@ -32,21 +33,33 @@ const seedEveryoneGroupAsync = async (db: Database) => {
|
||||
|
||||
const seedServerSettingsAsync = async (db: Database) => {
|
||||
const serverSettingsData = await db.query.serverSettings.findMany();
|
||||
let insertedSettingsCount = 0;
|
||||
|
||||
for (const settingsKey of defaultServerSettingsKeys) {
|
||||
if (serverSettingsData.some((setting) => setting.settingKey === settingsKey)) {
|
||||
return;
|
||||
const currentDbEntry = serverSettingsData.find((setting) => setting.settingKey === settingsKey);
|
||||
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({
|
||||
settingKey: settingsKey,
|
||||
value: SuperJSON.stringify(defaultServerSettings[settingsKey]),
|
||||
});
|
||||
insertedSettingsCount++;
|
||||
}
|
||||
const currentSettings = SuperJSON.parse<Record<string, unknown>>(currentDbEntry.value);
|
||||
const defaultSettings = defaultServerSettings[settingsKey];
|
||||
const missingKeys = objectKeys(defaultSettings).filter((key) => !(key in currentSettings));
|
||||
|
||||
if (insertedSettingsCount > 0) {
|
||||
console.info(`Inserted ${insertedSettingsCount} missing settings`);
|
||||
if (missingKeys.length === 0) {
|
||||
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 "./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 "./group";
|
||||
export * from "./docs";
|
||||
export * from "./cookie";
|
||||
|
||||
@@ -21,6 +21,10 @@
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"prettier": "@homarr/prettier-config",
|
||||
"dependencies": {
|
||||
"@homarr/definitions": "workspace:^0.1.0",
|
||||
"@homarr/translation": "workspace:^0.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@homarr/eslint-config": "workspace:^0.2.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>>;
|
||||
|
||||
@@ -15,6 +24,15 @@ export const defaultServerSettings = {
|
||||
noTranslate: true,
|
||||
noSiteLinksSearchBox: false,
|
||||
},
|
||||
board: {
|
||||
defaultBoardId: null as string | null,
|
||||
},
|
||||
appearance: {
|
||||
defaultColorScheme: "light" as ColorScheme,
|
||||
},
|
||||
culture: {
|
||||
defaultLocale: "en" as SupportedLanguage,
|
||||
},
|
||||
} satisfies ServerSettingsRecord;
|
||||
|
||||
export type ServerSettings = typeof defaultServerSettings;
|
||||
|
||||
@@ -27,7 +27,9 @@
|
||||
"prettier": "@homarr/prettier-config",
|
||||
"dependencies": {
|
||||
"@homarr/common": "workspace:^0.1.0",
|
||||
"@homarr/definitions": "workspace:^0.1.0",
|
||||
"dayjs": "^1.11.13",
|
||||
"deepmerge": "4.3.1",
|
||||
"mantine-react-table": "2.0.0-beta.7",
|
||||
"next": "^14.2.16",
|
||||
"next-intl": "3.24.0",
|
||||
|
||||
@@ -23,4 +23,4 @@ export const localeConfigurations = {
|
||||
export const supportedLanguages = objectKeys(localeConfigurations);
|
||||
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: {
|
||||
title: "Settings",
|
||||
notification: {
|
||||
success: {
|
||||
message: "Settings saved successfully",
|
||||
},
|
||||
error: {
|
||||
message: "Failed to save settings",
|
||||
},
|
||||
},
|
||||
section: {
|
||||
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.",
|
||||
},
|
||||
},
|
||||
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: {
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
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 = {
|
||||
// Match only internationalized pathnames
|
||||
|
||||
@@ -1,17 +1,16 @@
|
||||
import deepmerge from "deepmerge";
|
||||
import { getRequestConfig } from "next-intl/server";
|
||||
|
||||
import { isLocaleSupported } from ".";
|
||||
import { fallbackLocale, isLocaleSupported } from ".";
|
||||
import type { SupportedLanguage } from "./config";
|
||||
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
|
||||
export default getRequestConfig(async ({ requestLocale }) => {
|
||||
let currentLocale = await requestLocale;
|
||||
|
||||
if (!currentLocale || !isLocaleSupported(currentLocale)) {
|
||||
currentLocale = routing.defaultLocale;
|
||||
currentLocale = fallbackLocale;
|
||||
}
|
||||
const typedLocale = currentLocale as SupportedLanguage;
|
||||
|
||||
@@ -19,8 +18,8 @@ export default getRequestConfig(async ({ requestLocale }) => {
|
||||
const currentMessages = (await languageMap[typedLocale]()).default;
|
||||
|
||||
// Fallback to default locale if the current locales messages if not all messages are present
|
||||
if (currentLocale !== routing.defaultLocale) {
|
||||
const fallbackMessages = (await languageMap[routing.defaultLocale]()).default;
|
||||
if (currentLocale !== fallbackLocale) {
|
||||
const fallbackMessages = (await languageMap[fallbackLocale]()).default;
|
||||
return {
|
||||
locale: currentLocale,
|
||||
messages: deepmerge(fallbackMessages, currentMessages),
|
||||
|
||||
@@ -1,11 +1,18 @@
|
||||
import { defineRouting } from "next-intl/routing";
|
||||
|
||||
import { defaultLocale, supportedLanguages } from "./config";
|
||||
import { localeCookieKey } from "@homarr/definitions";
|
||||
|
||||
export const routing = defineRouting({
|
||||
locales: supportedLanguages,
|
||||
defaultLocale,
|
||||
localePrefix: {
|
||||
mode: "never", // Rewrite the URL with locale parameter but without shown in url
|
||||
},
|
||||
});
|
||||
import type { SupportedLanguage } from "./config";
|
||||
import { supportedLanguages } from "./config";
|
||||
|
||||
export const createRouting = (defaultLocale: SupportedLanguage) =>
|
||||
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 * from "./select-with-description";
|
||||
export * from "./select-with-description-and-badge";
|
||||
export { SelectWithCustomItems } from "./select-with-custom-items";
|
||||
export { TablePagination } from "./table-pagination";
|
||||
export { TextMultiSelect } from "./text-multi-select";
|
||||
export { UserAvatar } from "./user-avatar";
|
||||
|
||||
13
pnpm-lock.yaml
generated
13
pnpm-lock.yaml
generated
@@ -1303,6 +1303,13 @@ importers:
|
||||
version: 5.6.3
|
||||
|
||||
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:
|
||||
'@homarr/eslint-config':
|
||||
specifier: workspace:^0.2.0
|
||||
@@ -1392,9 +1399,15 @@ importers:
|
||||
'@homarr/common':
|
||||
specifier: workspace:^0.1.0
|
||||
version: link:../common
|
||||
'@homarr/definitions':
|
||||
specifier: workspace:^0.1.0
|
||||
version: link:../definitions
|
||||
dayjs:
|
||||
specifier: ^1.11.13
|
||||
version: 1.11.13
|
||||
deepmerge:
|
||||
specifier: 4.3.1
|
||||
version: 4.3.1
|
||||
mantine-react-table:
|
||||
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)
|
||||
|
||||
Reference in New Issue
Block a user