From 0d2675e1bb698e198a385cc641b70b784697d73f Mon Sep 17 00:00:00 2001 From: Kyle Grieder Date: Sun, 12 Apr 2026 13:30:21 -0600 Subject: [PATCH] feat: add auto/system color scheme option - Extend ColorScheme type to include 'auto' alongside 'light' and 'dark' - Allow 'auto' to persist to DB and cookie (remove the explicit guard) - Server default and new-user default both changed to 'auto' - layout.tsx: omit inline backgroundColor when scheme is 'auto' - Avatar menu: replace toggle item with a Combobox (Auto/Light/Dark) mirroring the existing language selector pattern - Appearance settings form and color scheme comboboxes updated with IconDeviceDesktop for the new auto option - Spotlight command handles 'auto' state via useComputedColorScheme - Old import: map legacy 'environment' value to 'auto' - Add translation keys for 'auto' in all three relevant scopes --- .../[locale]/_client-providers/mantine.tsx | 6 +- apps/nextjs/src/app/[locale]/layout.tsx | 2 +- .../_components/appearance-settings-form.tsx | 3 +- .../color-scheme/color-scheme-combobox.tsx | 94 +++++++++++++++++++ .../current-color-scheme-combobox.tsx | 40 +------- .../src/components/user-avatar-menu.tsx | 24 ++--- packages/auth/callbacks.ts | 2 +- packages/definitions/src/user.ts | 2 +- packages/old-import/src/mappers/map-user.ts | 2 +- packages/server-settings/src/index.ts | 2 +- .../src/modes/command/global-group.tsx | 11 ++- packages/translation/src/lang/en.json | 3 + 12 files changed, 124 insertions(+), 67 deletions(-) create mode 100644 apps/nextjs/src/components/color-scheme/color-scheme-combobox.tsx diff --git a/apps/nextjs/src/app/[locale]/_client-providers/mantine.tsx b/apps/nextjs/src/app/[locale]/_client-providers/mantine.tsx index e24c08b98..d81b828f3 100644 --- a/apps/nextjs/src/app/[locale]/_client-providers/mantine.tsx +++ b/apps/nextjs/src/app/[locale]/_client-providers/mantine.tsx @@ -8,13 +8,12 @@ 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, defaultColorScheme, -}: PropsWithChildren<{ defaultColorScheme: ColorScheme }>) => { +}: PropsWithChildren<{ defaultColorScheme: MantineColorScheme }>) => { const manager = useColorSchemeManager(); return ( @@ -35,7 +34,7 @@ export const CustomMantineProvider = ({ export function useColorSchemeManager(): MantineColorSchemeManager { const { data: session } = useSession(); - const updateCookieValue = (value: Exclude) => { + const updateCookieValue = (value: MantineColorScheme) => { setClientCookie(colorSchemeCookieKey, value, { expires: dayjs().add(1, "year").toDate(), path: "/" }); }; @@ -60,7 +59,6 @@ export function useColorSchemeManager(): MantineColorSchemeManager { }, set: (value) => { - if (value === "auto") return; try { if (session) { mutateColorScheme({ colorScheme: value }); diff --git a/apps/nextjs/src/app/[locale]/layout.tsx b/apps/nextjs/src/app/[locale]/layout.tsx index 708699bf8..03f159cd6 100644 --- a/apps/nextjs/src/app/[locale]/layout.tsx +++ b/apps/nextjs/src/app/[locale]/layout.tsx @@ -129,7 +129,7 @@ export default async function Layout(props: { dir={direction} data-mantine-color-scheme={colorScheme} style={{ - backgroundColor: colorScheme === "dark" ? "#242424" : "#fff", + backgroundColor: colorScheme === "dark" ? "#242424" : colorScheme === "auto" ? undefined : "#fff", }} suppressHydrationWarning > 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 index 2ca25ac4d..900caf0e4 100644 --- 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 @@ -1,7 +1,7 @@ "use client"; import { Group, Text } from "@mantine/core"; -import { IconMoon, IconSun } from "@tabler/icons-react"; +import { IconDeviceDesktop, IconMoon, IconSun } from "@tabler/icons-react"; import type { ColorScheme } from "@homarr/definitions"; import { colorSchemes } from "@homarr/definitions"; @@ -34,6 +34,7 @@ export const AppearanceSettingsForm = ({ defaultValues }: { defaultValues: Serve }; const appearanceIcons = { + auto: IconDeviceDesktop, light: IconSun, dark: IconMoon, }; diff --git a/apps/nextjs/src/components/color-scheme/color-scheme-combobox.tsx b/apps/nextjs/src/components/color-scheme/color-scheme-combobox.tsx new file mode 100644 index 000000000..207dcb913 --- /dev/null +++ b/apps/nextjs/src/components/color-scheme/color-scheme-combobox.tsx @@ -0,0 +1,94 @@ +"use client"; + +import React from "react"; +import { Combobox, Group, InputBase, Text, useCombobox } from "@mantine/core"; +import { IconCheck, IconDeviceDesktop, IconMoon, IconSun } from "@tabler/icons-react"; + +import type { ColorScheme } from "@homarr/definitions"; +import { colorSchemes } from "@homarr/definitions"; +import { useScopedI18n } from "@homarr/translation/client"; + +const schemeIcons: Record = { + auto: IconDeviceDesktop, + light: IconSun, + dark: IconMoon, +}; + +interface ColorSchemeComboboxProps { + value: ColorScheme; + onChange: (value: ColorScheme) => void; + width?: string; +} + +export const ColorSchemeCombobox = ({ value, onChange, width }: ColorSchemeComboboxProps) => { + const tOptions = useScopedI18n("common.colorScheme.options"); + + const combobox = useCombobox({ + onDropdownClose: () => combobox.resetSelectedOption(), + }); + + const handleOnOptionSubmit = React.useCallback( + (val: string) => { + if (!val) return; + onChange(val as ColorScheme); + combobox.closeDropdown(); + }, + [onChange, combobox], + ); + + const handleOnClick = React.useCallback(() => { + combobox.toggleDropdown(); + }, [combobox]); + + return ( + + + } + rightSectionPointerEvents="none" + onClick={handleOnClick} + variant="filled" + w={width} + > + + + + + + {colorSchemes.map((scheme) => ( + + + + ))} + + + + ); +}; + +const OptionItem = ({ + scheme, + currentScheme, + tOptions, + showCheck, +}: { + scheme: ColorScheme; + currentScheme: ColorScheme; + tOptions: (key: ColorScheme) => string; + showCheck?: boolean; +}) => { + const Icon = schemeIcons[scheme]; + + return ( + + + + {tOptions(scheme)} + + {showCheck && scheme === currentScheme && } + + ); +}; diff --git a/apps/nextjs/src/components/color-scheme/current-color-scheme-combobox.tsx b/apps/nextjs/src/components/color-scheme/current-color-scheme-combobox.tsx index b3534c3aa..232df979d 100644 --- a/apps/nextjs/src/components/color-scheme/current-color-scheme-combobox.tsx +++ b/apps/nextjs/src/components/color-scheme/current-color-scheme-combobox.tsx @@ -1,49 +1,17 @@ "use client"; -import { Group, Text, useMantineColorScheme } from "@mantine/core"; -import { IconMoon, IconSun } from "@tabler/icons-react"; +import { useMantineColorScheme } from "@mantine/core"; import type { ColorScheme } from "@homarr/definitions"; -import { colorSchemes } from "@homarr/definitions"; -import { useScopedI18n } from "@homarr/translation/client"; -import { SelectWithCustomItems } from "@homarr/ui"; + +import { ColorSchemeCombobox } from "./color-scheme-combobox"; interface CurrentColorSchemeComboboxProps { w?: string; } export const CurrentColorSchemeCombobox = ({ w }: CurrentColorSchemeComboboxProps) => { - const tOptions = useScopedI18n("common.colorScheme.options"); const { colorScheme, setColorScheme } = useMantineColorScheme(); - return ( - setColorScheme((value as ColorScheme | null) ?? "light")} - data={colorSchemes.map((scheme) => ({ - value: scheme, - label: tOptions(scheme), - }))} - SelectOption={ColorSchemeCustomOption} - w={w} - /> - ); -}; - -const appearanceIcons = { - light: IconSun, - dark: IconMoon, -}; - -const ColorSchemeCustomOption = ({ value, label }: { value: ColorScheme; label: string }) => { - const Icon = appearanceIcons[value]; - - return ( - - - - {label} - - - ); + return ; }; diff --git a/apps/nextjs/src/components/user-avatar-menu.tsx b/apps/nextjs/src/components/user-avatar-menu.tsx index 4a45a14d7..454ecca78 100644 --- a/apps/nextjs/src/components/user-avatar-menu.tsx +++ b/apps/nextjs/src/components/user-avatar-menu.tsx @@ -5,16 +5,7 @@ import { useCallback, useEffect } from "react"; import { useRouter } from "next/navigation"; import { Center, Menu, Stack, Text, useMantineColorScheme } from "@mantine/core"; import { useHotkeys, useTimeout } from "@mantine/hooks"; -import { - IconCheck, - IconHome, - IconLogin, - IconLogout, - IconMoon, - IconSettings, - IconSun, - IconTool, -} from "@tabler/icons-react"; +import { IconCheck, IconHome, IconLogin, IconLogout, IconSettings, IconTool } from "@tabler/icons-react"; import type { RouterOutputs } from "@homarr/api"; import { signOut, useSession } from "@homarr/auth/client"; @@ -24,6 +15,7 @@ import { useScopedI18n } from "@homarr/translation/client"; import { Link } from "@homarr/ui"; import { useAuthContext } from "~/app/[locale]/_client-providers/session"; +import { CurrentColorSchemeCombobox } from "./color-scheme/current-color-scheme-combobox"; import { CurrentLanguageCombobox } from "./language/current-language-combobox"; import { AvailableUpdatesMenuItem } from "./layout/header/update"; @@ -34,13 +26,9 @@ interface UserAvatarMenuProps { export const UserAvatarMenu = ({ children, availableUpdatesPromise }: UserAvatarMenuProps) => { const t = useScopedI18n("common.userAvatar.menu"); - const { colorScheme, toggleColorScheme } = useMantineColorScheme(); + const { toggleColorScheme } = useMantineColorScheme(); useHotkeys([[hotkeys.toggleColorScheme, toggleColorScheme]]); - const ColorSchemeIcon = colorScheme === "dark" ? IconSun : IconMoon; - - const colorSchemeText = colorScheme === "dark" ? t("switchToLightMode") : t("switchToDarkMode"); - const session = useSession(); const router = useRouter(); @@ -67,14 +55,14 @@ export const UserAvatarMenu = ({ children, availableUpdatesPromise }: UserAvatar - }> - {colorSchemeText} - }> {t("homeBoard")} + + + diff --git a/packages/auth/callbacks.ts b/packages/auth/callbacks.ts index 8f1168ad8..51c9c83e5 100644 --- a/packages/auth/callbacks.ts +++ b/packages/auth/callbacks.ts @@ -36,7 +36,7 @@ export const createSessionAsync = async ( ...user, email: user.email ?? "", permissions: await getCurrentUserPermissionsAsync(db, user.id), - colorScheme: "dark", + colorScheme: "auto", }, } as Session; }; diff --git a/packages/definitions/src/user.ts b/packages/definitions/src/user.ts index 77d8d4138..a25d44811 100644 --- a/packages/definitions/src/user.ts +++ b/packages/definitions/src/user.ts @@ -1,2 +1,2 @@ -export const colorSchemes = ["light", "dark"] as const; +export const colorSchemes = ["auto", "light", "dark"] as const; export type ColorScheme = (typeof colorSchemes)[number]; diff --git a/packages/old-import/src/mappers/map-user.ts b/packages/old-import/src/mappers/map-user.ts index 48ca701ee..240961b3d 100644 --- a/packages/old-import/src/mappers/map-user.ts +++ b/packages/old-import/src/mappers/map-user.ts @@ -24,7 +24,7 @@ export const mapAndDecryptUsers = (importUsers: OldmarrImportUser[], encryptionT oldId: id, id: createId(), name: user.name.toLowerCase(), - colorScheme: settings?.colorScheme === "environment" ? undefined : settings?.colorScheme, + colorScheme: settings?.colorScheme === "environment" ? "auto" : settings?.colorScheme, firstDayOfWeek: settings?.firstDayOfWeek === "sunday" ? 0 : settings?.firstDayOfWeek === "monday" ? 1 : 6, provider: "credentials", pingIconsEnabled: settings?.replacePingWithIcons, diff --git a/packages/server-settings/src/index.ts b/packages/server-settings/src/index.ts index 5c4470f6a..65b0cd354 100644 --- a/packages/server-settings/src/index.ts +++ b/packages/server-settings/src/index.ts @@ -36,7 +36,7 @@ export const defaultServerSettings = { enableGravatar: true, }, appearance: { - defaultColorScheme: "light" as ColorScheme, + defaultColorScheme: "auto" as ColorScheme, }, culture: { defaultLocale: "en" as SupportedLanguage, diff --git a/packages/spotlight/src/modes/command/global-group.tsx b/packages/spotlight/src/modes/command/global-group.tsx index 7aaaa6a2e..4d2f60d37 100644 --- a/packages/spotlight/src/modes/command/global-group.tsx +++ b/packages/spotlight/src/modes/command/global-group.tsx @@ -1,7 +1,8 @@ -import { Group, Text, useMantineColorScheme } from "@mantine/core"; +import { Group, Text, useComputedColorScheme, useMantineColorScheme } from "@mantine/core"; import { IconBox, IconCategoryPlus, + IconDeviceDesktop, IconFileImport, IconLanguage, IconMailForward, @@ -52,13 +53,17 @@ export const globalCommandGroup = createGroup({ useOptions() { const tOption = useScopedI18n("search.mode.command.group.globalCommand.option"); const { colorScheme } = useMantineColorScheme(); + const computedColorScheme = useComputedColorScheme("light"); const { data: session } = useSession(); const commands: (Command & { hidden?: boolean })[] = [ { commandKey: "colorScheme", - icon: colorScheme === "dark" ? IconSun : IconMoon, - name: tOption(`colorScheme.${colorScheme === "dark" ? "light" : "dark"}`), + icon: colorScheme === "auto" ? IconDeviceDesktop : computedColorScheme === "dark" ? IconSun : IconMoon, + name: + colorScheme === "auto" + ? tOption("colorScheme.auto") + : tOption(`colorScheme.${computedColorScheme === "dark" ? "light" : "dark"}`), useInteraction: () => { const { toggleColorScheme } = useMantineColorScheme(); diff --git a/packages/translation/src/lang/en.json b/packages/translation/src/lang/en.json index aadcb0d96..ce2130eae 100644 --- a/packages/translation/src/lang/en.json +++ b/packages/translation/src/lang/en.json @@ -1080,6 +1080,7 @@ }, "colorScheme": { "options": { + "auto": "Auto", "light": "Light", "dark": "Dark" } @@ -3568,6 +3569,7 @@ "defaultColorScheme": { "label": "Default color scheme", "options": { + "auto": "Auto", "light": "Light", "dark": "Dark" } @@ -4376,6 +4378,7 @@ "title": "Global commands", "option": { "colorScheme": { + "auto": "Toggle appearance mode", "light": "Switch to light mode", "dark": "Switch to dark mode" },