🚧 Add search engine to user preferences

This commit is contained in:
Meier Lukas
2023-08-05 09:57:48 +02:00
parent c0b836f2a4
commit e9904ababf
10 changed files with 135 additions and 208 deletions

View File

@@ -78,6 +78,7 @@ model UserSettings {
colorScheme String @default("environment") // environment, light, dark colorScheme String @default("environment") // environment, light, dark
language String @default("en") language String @default("en")
defaultBoard String @default("default") defaultBoard String @default("default")
firstDayOfWeek String @default("monday") // monday, saturnday, sunday
searchTemplate String @default("https://google.com/search?q=%s") searchTemplate String @default("https://google.com/search?q=%s")
openSearchInNewTab Boolean @default(true) openSearchInNewTab Boolean @default(true)
disablePingPulse Boolean @default(false) disablePingPulse Boolean @default(false)

View File

@@ -6,6 +6,7 @@
} }
}, },
"accessibility": { "accessibility": {
"title": "Accessibility",
"disablePulse": { "disablePulse": {
"label": "Disable ping pulse", "label": "Disable ping pulse",
"description": "By default, ping indicators in Homarr will pulse. This may be irritating. This slider will deactivate the animation" "description": "By default, ping indicators in Homarr will pulse. This may be irritating. This slider will deactivate the animation"
@@ -22,5 +23,16 @@
"firstDayOfWeek": { "firstDayOfWeek": {
"label": "First day of the week" "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"
}
} }
} }

View File

@@ -1,11 +1,11 @@
import { Stack, Switch } from '@mantine/core'; import { Stack, Switch } from '@mantine/core';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useFormContext } from '~/pages/user/preferences'; import { useUserPreferencesFormContext } from '~/pages/user/preferences';
export const AccessibilitySettings = () => { export const AccessibilitySettings = () => {
const { t } = useTranslation('user/preferences'); const { t } = useTranslation('user/preferences');
const form = useFormContext(); const form = useUserPreferencesFormContext();
return ( return (
<Stack> <Stack>

View File

@@ -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<HTMLInputElement> = (ev) => {
const url = ev.currentTarget.value;
setSearchUrl(url);
updateSearchEngineConfig(engine, url);
};
return (
<Stack spacing={0} mt="xs">
<Title order={5} mb="xs">
{t('title')}
</Title>
<SegmentedControl
fullWidth
mb="sm"
title={t('title') ?? undefined}
value={engine}
onChange={onEngineChange}
data={searchEngineOptions}
/>
<Paper p="md" py="sm" mb="md" withBorder>
<Title order={6} mb={0}>
{t('configurationName')}
</Title>
<SearchNewTabSwitch defaultValue={searchEngine.properties.openInNewTab} />
{engine === 'custom' && (
<>
<Space mb="md" />
<TextInput
label={t('customEngine.label')}
name={t('configurationName') ?? undefined}
description={t('tips.placeholderTip')}
placeholder={t('customEngine.placeholder') ?? undefined}
value={searchUrl}
onChange={onSearchUrlChange}
/>
</>
)}
</Paper>
<Alert icon={<IconInfoCircle />} color="blue">
{t('tips.generalTip')}
</Alert>
</Stack>
);
};
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,
};
};

View File

@@ -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<boolean>(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 (
<Switch checked={openInNewTab} onChange={toggleOpenInNewTab} label={t('searchNewTab.label')} />
);
}

View File

@@ -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 (
<Stack>
<SegmentedControl
fullWidth
data={segmentData}
value={segmentValue}
onChange={(v: typeof segmentValue) => {
v === 'custom'
? form.setFieldValue('searchTemplate', '')
: form.setFieldValue('searchTemplate', v);
}}
/>
<Paper p="md" py="sm" mb="md" withBorder>
<Stack spacing="sm">
<Switch
label={t('searchEngine.newTab.label')}
{...form.getInputProps('openSearchInNewTab', { type: 'checkbox' })}
/>
<TextInput
label={t('searchEngine.template.label')}
description={t('searchEngine.template.description')}
inputWrapperOrder={['label', 'input', 'description', 'error']}
{...form.getInputProps('searchTemplate')}
/>
</Stack>
</Paper>
</Stack>
);
};

View File

@@ -1,5 +1,5 @@
import { Autocomplete, Group, Kbd, Modal, Text, Tooltip, useMantineTheme } from '@mantine/core'; import { Autocomplete, Group, Text, useMantineTheme } from '@mantine/core';
import { useDisclosure, useHotkeys, useMediaQuery } from '@mantine/hooks'; import { useDisclosure, useHotkeys } from '@mantine/hooks';
import { import {
IconBrandYoutube, IconBrandYoutube,
IconDownload, IconDownload,
@@ -10,7 +10,7 @@ import {
} from '@tabler/icons-react'; } from '@tabler/icons-react';
import { useSession } from 'next-auth/react'; import { useSession } from 'next-auth/react';
import { useRouter } from 'next/router'; 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 { useConfigContext } from '~/config/provider';
import { api } from '~/utils/api'; import { api } from '~/utils/api';

View File

@@ -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 { createFormContext } from '@mantine/form';
import type { InferGetServerSidePropsType } from 'next'; import type { InferGetServerSidePropsType } from 'next';
import { GetServerSidePropsContext } from 'next'; import { GetServerSidePropsContext } from 'next';
@@ -7,7 +17,8 @@ import { forwardRef } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { z } from 'zod'; import { z } from 'zod';
import { AccessibilitySettings } from '~/components/User/Preferences/AccessibilitySettings'; 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 { sleep } from '~/tools/client/time';
import { languages } from '~/tools/language'; import { languages } from '~/tools/language';
import { getServerSideTranslations } from '~/tools/server/getServerSideTranslations'; import { getServerSideTranslations } from '~/tools/server/getServerSideTranslations';
@@ -21,18 +32,24 @@ const PreferencesPage = ({ locale }: InferGetServerSidePropsType<typeof getServe
const { data: boardsData } = api.boards.all.useQuery(); const { data: boardsData } = api.boards.all.useQuery();
return ( return (
<ManageLayout> <MainLayout>
<Head> <Container>
<title>Preferences Homarr</title> <Paper p="xl" mih="100%" withBorder>
</Head> <Head>
<Title mb="xl">Preferences</Title> <title>Preferences Homarr</title>
</Head>
<Title mb="xl">Preferences</Title>
{data && boardsData && <SettingsComponent settings={data.settings} boardsData={boardsData} />} {data && boardsData && (
</ManageLayout> <SettingsComponent settings={data.settings} boardsData={boardsData} />
)}
</Paper>
</Container>
</MainLayout>
); );
}; };
export const [FormProvider, useFormContext, useForm] = export const [FormProvider, useUserPreferencesFormContext, useForm] =
createFormContext<z.infer<typeof updateSettingsValidationSchema>>(); createFormContext<z.infer<typeof updateSettingsValidationSchema>>();
const SettingsComponent = ({ const SettingsComponent = ({
@@ -56,10 +73,13 @@ const SettingsComponent = ({
const form = useForm({ const form = useForm({
initialValues: { initialValues: {
defaultBoard: settings.defaultBoard,
language: settings.language,
firstDayOfWeek: settings.firstDayOfWeek,
disablePingPulse: settings.disablePingPulse, disablePingPulse: settings.disablePingPulse,
replaceDotsWithIcons: settings.replacePingWithIcons, replaceDotsWithIcons: settings.replacePingWithIcons,
language: settings.language, searchTemplate: settings.searchTemplate,
defaultBoard: settings.defaultBoard, openSearchInNewTab: settings.openSearchInNewTab,
}, },
validate: i18nZodResolver(updateSettingsValidationSchema), validate: i18nZodResolver(updateSettingsValidationSchema),
validateInputOnBlur: true, validateInputOnBlur: true,
@@ -70,13 +90,12 @@ const SettingsComponent = ({
const { mutate, isLoading } = api.user.updateSettings.useMutation({ const { mutate, isLoading } = api.user.updateSettings.useMutation({
onSettled: () => { onSettled: () => {
void context.boards.all.invalidate(); void context.boards.all.invalidate();
} void context.user.withSettings.invalidate();
},
}); });
const handleSubmit = () => { const handleSubmit = (values: z.infer<typeof updateSettingsValidationSchema>) => {
sleep(500).then(() => { mutate(values);
mutate(form.values);
});
}; };
return ( return (
@@ -130,11 +149,17 @@ const SettingsComponent = ({
/> />
<Title order={2} size="lg" mt="lg" mb="md"> <Title order={2} size="lg" mt="lg" mb="md">
Accessibility {t('accessibility.title')}
</Title> </Title>
<AccessibilitySettings /> <AccessibilitySettings />
<Title order={2} size="lg" mt="lg" mb="md">
{t('searchEngine.title')}
</Title>
<SearchEngineSelector />
<Button type="submit" fullWidth mt="md"> <Button type="submit" fullWidth mt="md">
Save Save
</Button> </Button>

View File

@@ -146,7 +146,12 @@ export const userRouter = createTRPCRouter({
return { return {
id: user.id, id: user.id,
name: user.name, 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, replacePingWithIcons: input.replaceDotsWithIcons,
defaultBoard: input.defaultBoard, defaultBoard: input.defaultBoard,
language: input.language, language: input.language,
firstDayOfWeek: input.firstDayOfWeek,
searchTemplate: input.searchTemplate,
openSearchInNewTab: input.openSearchInNewTab,
}, },
}, },
}, },

View File

@@ -1,5 +1,4 @@
import { z } from 'zod'; import { z } from 'zod';
import { CustomErrorParams } from '~/utils/i18n-zod-resolver'; import { CustomErrorParams } from '~/utils/i18n-zod-resolver';
export const passwordSchema = z export const passwordSchema = z
@@ -41,8 +40,11 @@ export const colorSchemeParser = z
.catch('environment'); .catch('environment');
export const updateSettingsValidationSchema = z.object({ export const updateSettingsValidationSchema = z.object({
defaultBoard: z.string(),
language: z.string(),
firstDayOfWeek: z.enum(['monday', 'saturday', 'sunday']),
disablePingPulse: z.boolean(), disablePingPulse: z.boolean(),
replaceDotsWithIcons: z.boolean(), replaceDotsWithIcons: z.boolean(),
language: z.string(), searchTemplate: z.string().nonempty().max(256),
defaultBoard: z.string() openSearchInNewTab: z.boolean(),
}); });