diff --git a/prisma/schema.prisma b/prisma/schema.prisma index b948183b5..231c6a876 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -78,6 +78,7 @@ model UserSettings { colorScheme String @default("environment") // environment, light, dark language String @default("en") defaultBoard String @default("default") + firstDayOfWeek String @default("monday") // monday, saturnday, sunday searchTemplate String @default("https://google.com/search?q=%s") openSearchInNewTab Boolean @default(true) disablePingPulse Boolean @default(false) diff --git a/public/locales/en/user/preferences.json b/public/locales/en/user/preferences.json index ea559a7cf..4867794f6 100644 --- a/public/locales/en/user/preferences.json +++ b/public/locales/en/user/preferences.json @@ -6,6 +6,7 @@ } }, "accessibility": { + "title": "Accessibility", "disablePulse": { "label": "Disable ping pulse", "description": "By default, ping indicators in Homarr will pulse. This may be irritating. This slider will deactivate the animation" @@ -22,5 +23,16 @@ "firstDayOfWeek": { "label": "First day of the week" } + }, + "searchEngine": { + "title": "Search engine", + "custom": "Custom", + "newTab": { + "label": "Open search results in a new tab" + }, + "template": { + "label": "Query URL", + "description": "Use %s as a placeholder for the query" + } } } \ No newline at end of file diff --git a/src/components/User/Preferences/AccessibilitySettings.tsx b/src/components/User/Preferences/AccessibilitySettings.tsx index 6078322a4..3a30f519f 100644 --- a/src/components/User/Preferences/AccessibilitySettings.tsx +++ b/src/components/User/Preferences/AccessibilitySettings.tsx @@ -1,11 +1,11 @@ import { Stack, Switch } from '@mantine/core'; import { useTranslation } from 'react-i18next'; -import { useFormContext } from '~/pages/user/preferences'; +import { useUserPreferencesFormContext } from '~/pages/user/preferences'; export const AccessibilitySettings = () => { const { t } = useTranslation('user/preferences'); - const form = useFormContext(); + const form = useUserPreferencesFormContext(); return ( diff --git a/src/components/User/Preferences/SearchEngine/SearchEngineSelector.tsx b/src/components/User/Preferences/SearchEngine/SearchEngineSelector.tsx deleted file mode 100644 index eb0fa634d..000000000 --- a/src/components/User/Preferences/SearchEngine/SearchEngineSelector.tsx +++ /dev/null @@ -1,136 +0,0 @@ -import { Alert, Paper, SegmentedControl, Space, Stack, TextInput, Title } from '@mantine/core'; -import { IconInfoCircle } from '@tabler/icons-react'; -import { useTranslation } from 'next-i18next'; -import { ChangeEventHandler, useState } from 'react'; - -import { useConfigContext } from '../../../../config/provider'; -import { useConfigStore } from '../../../../config/store'; -import { - CommonSearchEngineCommonSettingsType, - SearchEngineCommonSettingsType, -} from '../../../../types/settings'; -import { SearchNewTabSwitch } from './SearchNewTabSwitch'; - -interface Props { - searchEngine: SearchEngineCommonSettingsType; -} - -export const SearchEngineSelector = ({ searchEngine }: Props) => { - const { t } = useTranslation(['settings/general/search-engine']); - const { updateSearchEngineConfig } = useUpdateSearchEngineConfig(); - - const [engine, setEngine] = useState(searchEngine.type); - const [searchUrl, setSearchUrl] = useState( - searchEngine.type === 'custom' ? searchEngine.properties.template : searchUrls.google - ); - - const onEngineChange = (value: EngineType) => { - setEngine(value); - updateSearchEngineConfig(value, searchUrl); - }; - - const onSearchUrlChange: ChangeEventHandler = (ev) => { - const url = ev.currentTarget.value; - setSearchUrl(url); - updateSearchEngineConfig(engine, url); - }; - - return ( - - - {t('title')} - - - - - - {t('configurationName')} - - - - - {engine === 'custom' && ( - <> - - - - )} - - } color="blue"> - {t('tips.generalTip')} - - - ); -}; - -const searchEngineOptions: { label: string; value: EngineType }[] = [ - { label: 'Google', value: 'google' }, - { label: 'DuckDuckGo', value: 'duckDuckGo' }, - { label: 'Bing', value: 'bing' }, - { label: 'Custom', value: 'custom' }, -]; - -export const searchUrls: { [key in CommonSearchEngineCommonSettingsType['type']]: string } = { - google: 'https://google.com/search?q=', - duckDuckGo: 'https://duckduckgo.com/?q=', - bing: 'https://bing.com/search?q=', -}; - -type EngineType = SearchEngineCommonSettingsType['type']; - -const useUpdateSearchEngineConfig = () => { - const { name: configName } = useConfigContext(); - const updateConfig = useConfigStore((x) => x.updateConfig); - - if (!configName) { - return { - updateSearchEngineConfig: () => {}, - }; - } - - const updateSearchEngineConfig = (engine: EngineType, searchUrl: string) => { - updateConfig(configName, (prev) => ({ - ...prev, - settings: { - ...prev.settings, - common: { - ...prev.settings.common, - searchEngine: - engine === 'custom' - ? { - type: engine, - properties: { - ...prev.settings.common.searchEngine.properties, - template: searchUrl, - }, - } - : { - type: engine, - properties: { - openInNewTab: prev.settings.common.searchEngine.properties.openInNewTab, - enabled: prev.settings.common.searchEngine.properties.enabled, - }, - }, - }, - }, - })); - }; - - return { - updateSearchEngineConfig, - }; -}; diff --git a/src/components/User/Preferences/SearchEngine/SearchNewTabSwitch.tsx b/src/components/User/Preferences/SearchEngine/SearchNewTabSwitch.tsx deleted file mode 100644 index 301bad508..000000000 --- a/src/components/User/Preferences/SearchEngine/SearchNewTabSwitch.tsx +++ /dev/null @@ -1,45 +0,0 @@ -import { Switch } from '@mantine/core'; -import { useTranslation } from 'next-i18next'; -import { useState } from 'react'; - -import { useConfigContext } from '../../../../config/provider'; -import { useConfigStore } from '../../../../config/store'; -import { SearchEngineCommonSettingsType } from '../../../../types/settings'; - -interface SearchNewTabSwitchProps { - defaultValue: boolean | undefined; -} - -export function SearchNewTabSwitch({ defaultValue }: SearchNewTabSwitchProps) { - const { t } = useTranslation('settings/general/search-engine'); - const { name: configName } = useConfigContext(); - const updateConfig = useConfigStore((x) => x.updateConfig); - - const [openInNewTab, setOpenInNewTab] = useState(defaultValue ?? true); - - if (!configName) return null; - - const toggleOpenInNewTab = () => { - setOpenInNewTab(!openInNewTab); - updateConfig(configName, (prev) => ({ - ...prev, - settings: { - ...prev.settings, - common: { - ...prev.settings.common, - searchEngine: { - ...prev.settings.common.searchEngine, - properties: { - ...prev.settings.common.searchEngine.properties, - openInNewTab: !openInNewTab, - }, - } as SearchEngineCommonSettingsType, - }, - }, - })); - }; - - return ( - - ); -} diff --git a/src/components/User/Preferences/SearchEngineSelector.tsx b/src/components/User/Preferences/SearchEngineSelector.tsx new file mode 100644 index 000000000..99be1522f --- /dev/null +++ b/src/components/User/Preferences/SearchEngineSelector.tsx @@ -0,0 +1,60 @@ +import { Paper, SegmentedControl, Stack, Switch, TextInput } from '@mantine/core'; +import { useTranslation } from 'next-i18next'; +import { useMemo } from 'react'; +import { useUserPreferencesFormContext } from '~/pages/user/preferences'; + +const searchEngineOptions = [ + { label: 'Google', value: 'https://google.com/search?q=%s' }, + { label: 'DuckDuckGo', value: 'https://duckduckgo.com/?q=%s' }, + { label: 'Bing', value: 'https://bing.com/search?q=%s' }, + { value: 'custom' }, +] as const; + +const useSegmentData = () => { + const { t } = useTranslation('user/preferences'); + return searchEngineOptions.map((option) => ({ + label: option.value === 'custom' ? t('searchEngine.custom') : option.label, + value: option.value, + })); +}; + +export const SearchEngineSelector = () => { + const { t } = useTranslation('user/preferences'); + const form = useUserPreferencesFormContext(); + const segmentData = useSegmentData(); + const segmentValue = useMemo( + () => + searchEngineOptions.find((x) => x.value === form.values.searchTemplate)?.value ?? 'custom', + [form.values.searchTemplate] + ); + + return ( + + { + v === 'custom' + ? form.setFieldValue('searchTemplate', '') + : form.setFieldValue('searchTemplate', v); + }} + /> + + + + + + + + + ); +}; diff --git a/src/components/layout/header/Search.tsx b/src/components/layout/header/Search.tsx index c809a9a73..ff55e1e43 100644 --- a/src/components/layout/header/Search.tsx +++ b/src/components/layout/header/Search.tsx @@ -1,5 +1,5 @@ -import { Autocomplete, Group, Kbd, Modal, Text, Tooltip, useMantineTheme } from '@mantine/core'; -import { useDisclosure, useHotkeys, useMediaQuery } from '@mantine/hooks'; +import { Autocomplete, Group, Text, useMantineTheme } from '@mantine/core'; +import { useDisclosure, useHotkeys } from '@mantine/hooks'; import { IconBrandYoutube, IconDownload, @@ -10,7 +10,7 @@ import { } from '@tabler/icons-react'; import { useSession } from 'next-auth/react'; import { useRouter } from 'next/router'; -import { ReactNode, forwardRef, useEffect, useMemo, useRef, useState } from 'react'; +import { ReactNode, forwardRef, useMemo, useRef, useState } from 'react'; import { useConfigContext } from '~/config/provider'; import { api } from '~/utils/api'; diff --git a/src/pages/user/preferences.tsx b/src/pages/user/preferences.tsx index 9a5ab67aa..22826a217 100644 --- a/src/pages/user/preferences.tsx +++ b/src/pages/user/preferences.tsx @@ -1,4 +1,14 @@ -import { Button, Group, LoadingOverlay, Select, Stack, Text, Title } from '@mantine/core'; +import { + Button, + Container, + Group, + LoadingOverlay, + Paper, + Select, + Stack, + Text, + Title, +} from '@mantine/core'; import { createFormContext } from '@mantine/form'; import type { InferGetServerSidePropsType } from 'next'; import { GetServerSidePropsContext } from 'next'; @@ -7,7 +17,8 @@ import { forwardRef } from 'react'; import { useTranslation } from 'react-i18next'; import { z } from 'zod'; import { AccessibilitySettings } from '~/components/User/Preferences/AccessibilitySettings'; -import { ManageLayout } from '~/components/layout/Templates/ManageLayout'; +import { SearchEngineSelector } from '~/components/User/Preferences/SearchEngineSelector'; +import { MainLayout } from '~/components/layout/Templates/MainLayout'; import { sleep } from '~/tools/client/time'; import { languages } from '~/tools/language'; import { getServerSideTranslations } from '~/tools/server/getServerSideTranslations'; @@ -21,18 +32,24 @@ const PreferencesPage = ({ locale }: InferGetServerSidePropsType - - Preferences • Homarr - - Preferences + + + + + Preferences • Homarr + + Preferences - {data && boardsData && } - + {data && boardsData && ( + + )} + + + ); }; -export const [FormProvider, useFormContext, useForm] = +export const [FormProvider, useUserPreferencesFormContext, useForm] = createFormContext>(); const SettingsComponent = ({ @@ -56,10 +73,13 @@ const SettingsComponent = ({ const form = useForm({ initialValues: { + defaultBoard: settings.defaultBoard, + language: settings.language, + firstDayOfWeek: settings.firstDayOfWeek, disablePingPulse: settings.disablePingPulse, replaceDotsWithIcons: settings.replacePingWithIcons, - language: settings.language, - defaultBoard: settings.defaultBoard, + searchTemplate: settings.searchTemplate, + openSearchInNewTab: settings.openSearchInNewTab, }, validate: i18nZodResolver(updateSettingsValidationSchema), validateInputOnBlur: true, @@ -70,13 +90,12 @@ const SettingsComponent = ({ const { mutate, isLoading } = api.user.updateSettings.useMutation({ onSettled: () => { void context.boards.all.invalidate(); - } + void context.user.withSettings.invalidate(); + }, }); - const handleSubmit = () => { - sleep(500).then(() => { - mutate(form.values); - }); + const handleSubmit = (values: z.infer) => { + mutate(values); }; return ( @@ -130,11 +149,17 @@ const SettingsComponent = ({ /> - Accessibility + {t('accessibility.title')} + + {t('searchEngine.title')} + + + + diff --git a/src/server/api/routers/user.ts b/src/server/api/routers/user.ts index 5108766aa..25e38742f 100644 --- a/src/server/api/routers/user.ts +++ b/src/server/api/routers/user.ts @@ -146,7 +146,12 @@ export const userRouter = createTRPCRouter({ return { id: user.id, name: user.name, - settings: user.settings, + settings: { + ...user.settings, + firstDayOfWeek: z + .enum(['monday', 'saturday', 'sunday']) + .parse(user.settings.firstDayOfWeek), + }, }; }), @@ -164,6 +169,9 @@ export const userRouter = createTRPCRouter({ replacePingWithIcons: input.replaceDotsWithIcons, defaultBoard: input.defaultBoard, language: input.language, + firstDayOfWeek: input.firstDayOfWeek, + searchTemplate: input.searchTemplate, + openSearchInNewTab: input.openSearchInNewTab, }, }, }, diff --git a/src/validations/user.ts b/src/validations/user.ts index d67a8337a..3dee871a9 100644 --- a/src/validations/user.ts +++ b/src/validations/user.ts @@ -1,5 +1,4 @@ import { z } from 'zod'; - import { CustomErrorParams } from '~/utils/i18n-zod-resolver'; export const passwordSchema = z @@ -41,8 +40,11 @@ export const colorSchemeParser = z .catch('environment'); export const updateSettingsValidationSchema = z.object({ + defaultBoard: z.string(), + language: z.string(), + firstDayOfWeek: z.enum(['monday', 'saturday', 'sunday']), disablePingPulse: z.boolean(), replaceDotsWithIcons: z.boolean(), - language: z.string(), - defaultBoard: z.string() + searchTemplate: z.string().nonempty().max(256), + openSearchInNewTab: z.boolean(), });