diff --git a/apps/nextjs/src/app/[locale]/_client-providers/mantine.tsx b/apps/nextjs/src/app/[locale]/_client-providers/mantine.tsx index 867ecfd94..3b5ad50f6 100644 --- a/apps/nextjs/src/app/[locale]/_client-providers/mantine.tsx +++ b/apps/nextjs/src/app/[locale]/_client-providers/mantine.tsx @@ -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 ( { ); }; -function useColorSchemeManager(): MantineColorSchemeManager { - const key = "homarr-color-scheme"; +export function useColorSchemeManager(): MantineColorSchemeManager { const { data: session } = useSession(); const updateCookieValue = (value: Exclude) => { - 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; } diff --git a/apps/nextjs/src/app/[locale]/boards/(content)/_theme.tsx b/apps/nextjs/src/app/[locale]/boards/(content)/_theme.tsx index e3c87aa05..755c0a42b 100644 --- a/apps/nextjs/src/app/[locale]/boards/(content)/_theme.tsx +++ b/apps/nextjs/src/app/[locale]/boards/(content)/_theme.tsx @@ -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 {children}; + return ( + + {children} + + ); }; export const generateColors = (hex: string) => { diff --git a/apps/nextjs/src/app/[locale]/boards/[name]/settings/_danger.tsx b/apps/nextjs/src/app/[locale]/boards/[name]/settings/_danger.tsx index fa1c05bd3..115605279 100644 --- a/apps/nextjs/src/app/[locale]/boards/[name]/settings/_danger.tsx +++ b/apps/nextjs/src/app/[locale]/boards/[name]/settings/_danger.tsx @@ -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} /> - - + {hideVisibility ? null : ( + <> + + + + )} { 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) - + )} diff --git a/apps/nextjs/src/app/[locale]/boards/_layout-creator.tsx b/apps/nextjs/src/app/[locale]/boards/_layout-creator.tsx index 4e8edd149..e6737e14e 100644 --- a/apps/nextjs/src/app/[locale]/boards/_layout-creator.tsx +++ b/apps/nextjs/src/app/[locale]/boards/_layout-creator.tsx @@ -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 = ({ throw error; }); + const colorScheme = await getCurrentColorSchemeAsync(); return ( - + ({ +// eslint-disable-next-line no-restricted-syntax +export const generateMetadata = async (): Promise => ({ 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) => , (innerProps) => , (innerProps) => , - (innerProps) => , + (innerProps) => , (innerProps) => , ]); @@ -106,7 +107,3 @@ export default async function Layout(props: { children: React.ReactNode; params: ); } - -const getColorScheme = () => { - return cookies().get("homarr-color-scheme")?.value ?? "dark"; -}; diff --git a/apps/nextjs/src/app/[locale]/manage/settings/_components/appearance-settings-form.tsx b/apps/nextjs/src/app/[locale]/manage/settings/_components/appearance-settings-form.tsx new file mode 100644 index 000000000..2ca25ac4d --- /dev/null +++ b/apps/nextjs/src/app/[locale]/manage/settings/_components/appearance-settings-form.tsx @@ -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 ( + + {(form) => ( + <> + ({ + value: scheme, + label: tApperance(`defaultColorScheme.options.${scheme}`), + }))} + {...form.getInputProps("defaultColorScheme")} + SelectOption={ApperanceCustomOption} + /> + + )} + + ); +}; + +const appearanceIcons = { + light: IconSun, + dark: IconMoon, +}; + +const ApperanceCustomOption = ({ value, label }: { value: ColorScheme; label: string }) => { + const Icon = appearanceIcons[value]; + + return ( + + + + {label} + + + ); +}; diff --git a/apps/nextjs/src/app/[locale]/manage/settings/_components/board-settings-form.tsx b/apps/nextjs/src/app/[locale]/manage/settings/_components/board-settings-form.tsx new file mode 100644 index 000000000..d91d94196 --- /dev/null +++ b/apps/nextjs/src/app/[locale]/manage/settings/_components/board-settings-form.tsx @@ -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 ( + + {(form) => ( + <> + ({ + value: board.id, + label: board.name, + image: board.logoImageUrl, + }))} + SelectOption={({ label, image }: { value: string; label: string; image: string | null }) => ( + + {/* eslint-disable-next-line @next/next/no-img-element */} + {image ? {label} : } + + {label} + + + )} + {...form.getInputProps("defaultBoardId")} + /> + + )} + + ); +}; diff --git a/apps/nextjs/src/app/[locale]/manage/settings/_components/common-form.tsx b/apps/nextjs/src/app/[locale]/manage/settings/_components/common-form.tsx new file mode 100644 index 000000000..8a932dee1 --- /dev/null +++ b/apps/nextjs/src/app/[locale]/manage/settings/_components/common-form.tsx @@ -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 = ({ + settingKey, + defaultValues, + children, +}: { + settingKey: TKey; + defaultValues: ServerSettings[TKey]; + children: (form: ReturnType>) => 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 ( +
void handleSubmitAsync(values))}> + + {children(form)} + + + + +
+ ); +}; diff --git a/apps/nextjs/src/app/[locale]/manage/settings/_components/culture-settings-form.tsx b/apps/nextjs/src/app/[locale]/manage/settings/_components/culture-settings-form.tsx new file mode 100644 index 000000000..7d9dff99e --- /dev/null +++ b/apps/nextjs/src/app/[locale]/manage/settings/_components/culture-settings-form.tsx @@ -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 ( + + {(form) => ( + <> + + + )} + + ); +}; diff --git a/apps/nextjs/src/app/[locale]/manage/settings/page.tsx b/apps/nextjs/src/app/[locale]/manage/settings/page.tsx index 26ef0b3c1..a651682e9 100644 --- a/apps/nextjs/src/app/[locale]/manage/settings/page.tsx +++ b/apps/nextjs/src/app/[locale]/manage/settings/page.tsx @@ -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 ( <> - {t("title")} + {tSettings("title")} + + {tSettings("section.board.title")} + + + + {tSettings("section.appearance.title")} + + + + {tSettings("section.culture.title")} + + ); diff --git a/apps/nextjs/src/app/[locale]/manage/users/[userId]/general/page.tsx b/apps/nextjs/src/app/[locale]/manage/users/[userId]/general/page.tsx index fa26d7bd1..ede4d8df9 100644 --- a/apps/nextjs/src/app/[locale]/manage/users/[userId]/general/page.tsx +++ b/apps/nextjs/src/app/[locale]/manage/users/[userId]/general/page.tsx @@ -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) { {tGeneral("item.language")} - + diff --git a/apps/nextjs/src/components/language/current-language-combobox.tsx b/apps/nextjs/src/components/language/current-language-combobox.tsx new file mode 100644 index 000000000..cdb4709d6 --- /dev/null +++ b/apps/nextjs/src/components/language/current-language-combobox.tsx @@ -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 ; +}; diff --git a/apps/nextjs/src/components/language/language-combobox.tsx b/apps/nextjs/src/components/language/language-combobox.tsx index b60e3a4fe..5723e3fde 100644 --- a/apps/nextjs/src/components/language/language-combobox.tsx +++ b/apps/nextjs/src/components/language/language-combobox.tsx @@ -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 ? : null} rightSection={} rightSectionPointerEvents="none" onClick={handleOnClick} variant="filled" > - + {supportedLanguages.map((languageKey) => ( - + ))} diff --git a/apps/nextjs/src/components/layout/analytics.tsx b/apps/nextjs/src/components/layout/analytics.tsx index 322d7a0a6..8d81fa13c 100644 --- a/apps/nextjs/src/components/layout/analytics.tsx +++ b/apps/nextjs/src/components/layout/analytics.tsx @@ -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