🚧 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
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)

View File

@@ -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"
}
}
}

View File

@@ -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 (
<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 { 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';

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 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<typeof getServe
const { data: boardsData } = api.boards.all.useQuery();
return (
<ManageLayout>
<Head>
<title>Preferences Homarr</title>
</Head>
<Title mb="xl">Preferences</Title>
<MainLayout>
<Container>
<Paper p="xl" mih="100%" withBorder>
<Head>
<title>Preferences Homarr</title>
</Head>
<Title mb="xl">Preferences</Title>
{data && boardsData && <SettingsComponent settings={data.settings} boardsData={boardsData} />}
</ManageLayout>
{data && boardsData && (
<SettingsComponent settings={data.settings} boardsData={boardsData} />
)}
</Paper>
</Container>
</MainLayout>
);
};
export const [FormProvider, useFormContext, useForm] =
export const [FormProvider, useUserPreferencesFormContext, useForm] =
createFormContext<z.infer<typeof updateSettingsValidationSchema>>();
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<typeof updateSettingsValidationSchema>) => {
mutate(values);
};
return (
@@ -130,11 +149,17 @@ const SettingsComponent = ({
/>
<Title order={2} size="lg" mt="lg" mb="md">
Accessibility
{t('accessibility.title')}
</Title>
<AccessibilitySettings />
<Title order={2} size="lg" mt="lg" mb="md">
{t('searchEngine.title')}
</Title>
<SearchEngineSelector />
<Button type="submit" fullWidth mt="md">
Save
</Button>

View File

@@ -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,
},
},
},

View File

@@ -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(),
});