feat(spotlight): add support for custom search-engines (#1200)

* feat(spotlight): add search settings link

* feat(search-engine): add to manage pages

* feat(spotlight): add children option for external search engines

* chore: revert search settings

* fix: deepsource issue

* fix: inconsistent breadcrum placement

* chore: address pull request feedback
This commit is contained in:
Meier Lukas
2024-10-04 15:59:08 +02:00
committed by GitHub
parent 8ea8b2ded5
commit 4c9471e608
34 changed files with 3620 additions and 109 deletions

View File

@@ -105,7 +105,7 @@ const AppNoResults = async () => {
<Text fw={500} size="lg">
{t("app.page.list.noResults.title")}
</Text>
<Anchor href="/manage/apps/new">{t("app.page.list.noResults.description")}</Anchor>
<Anchor href="/manage/apps/new">{t("app.page.list.noResults.action")}</Anchor>
</Stack>
</Card>
);

View File

@@ -15,6 +15,7 @@ import {
IconPlug,
IconQuestionMark,
IconReport,
IconSearch,
IconSettings,
IconTool,
IconUser,
@@ -53,6 +54,11 @@ export default async function ManageLayout({ children }: PropsWithChildren) {
href: "/manage/integrations",
label: t("items.integrations"),
},
{
icon: IconSearch,
href: "/manage/search-engines",
label: t("items.searchEngies"),
},
{
icon: IconUser,
label: t("items.users.label"),

View File

@@ -0,0 +1,73 @@
"use client";
import Link from "next/link";
import { Button, Grid, Group, Stack, Textarea, TextInput } from "@mantine/core";
import { useZodForm } from "@homarr/form";
import type { TranslationFunction } from "@homarr/translation";
import { useI18n } from "@homarr/translation/client";
import type { z } from "@homarr/validation";
import { validation } from "@homarr/validation";
import { IconPicker } from "~/components/icons/picker/icon-picker";
type FormType = z.infer<typeof validation.searchEngine.manage>;
interface SearchEngineFormProps {
submitButtonTranslation: (t: TranslationFunction) => string;
initialValues?: FormType;
handleSubmit: (values: FormType) => void;
isPending: boolean;
disableShort?: boolean;
}
export const SearchEngineForm = (props: SearchEngineFormProps) => {
const { submitButtonTranslation, handleSubmit, initialValues, isPending, disableShort } = props;
const t = useI18n();
const form = useZodForm(validation.searchEngine.manage, {
initialValues: initialValues ?? {
name: "",
short: "",
iconUrl: "",
urlTemplate: "",
description: "",
},
});
return (
<form onSubmit={form.onSubmit(handleSubmit)}>
<Stack>
<Grid>
<Grid.Col span={{ base: 12, md: 8, lg: 9, xl: 10 }}>
<TextInput {...form.getInputProps("name")} withAsterisk label={t("search.engine.field.name.label")} />
</Grid.Col>
<Grid.Col span={{ base: 12, md: 4, lg: 3, xl: 2 }}>
<TextInput
{...form.getInputProps("short")}
disabled={disableShort}
withAsterisk
label={t("search.engine.field.short.label")}
/>
</Grid.Col>
</Grid>
<IconPicker initialValue={initialValues?.iconUrl} {...form.getInputProps("iconUrl")} />
<TextInput
{...form.getInputProps("urlTemplate")}
withAsterisk
label={t("search.engine.field.urlTemplate.label")}
/>
<Textarea {...form.getInputProps("description")} label={t("search.engine.field.description.label")} />
<Group justify="end">
<Button variant="default" component={Link} href="/manage/search-engines">
{t("common.action.backToOverview")}
</Button>
<Button type="submit" loading={isPending}>
{submitButtonTranslation(t)}
</Button>
</Group>
</Stack>
</form>
);
};

View File

@@ -0,0 +1,55 @@
"use client";
import { useCallback } from "react";
import { ActionIcon } from "@mantine/core";
import { IconTrash } from "@tabler/icons-react";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import { revalidatePathActionAsync } from "@homarr/common/client";
import { useConfirmModal } from "@homarr/modals";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useScopedI18n } from "@homarr/translation/client";
interface SearchEngineDeleteButtonProps {
searchEngine: RouterOutputs["searchEngine"]["getPaginated"]["items"][number];
}
export const SearchEngineDeleteButton = ({ searchEngine }: SearchEngineDeleteButtonProps) => {
const t = useScopedI18n("search.engine.page.delete");
const { openConfirmModal } = useConfirmModal();
const { mutate, isPending } = clientApi.searchEngine.delete.useMutation();
const onClick = useCallback(() => {
openConfirmModal({
title: t("title"),
children: t("message", searchEngine),
onConfirm: () => {
mutate(
{ id: searchEngine.id },
{
onSuccess: () => {
showSuccessNotification({
title: t("notification.success.title"),
message: t("notification.success.message"),
});
void revalidatePathActionAsync("/manage/search-engines");
},
onError: () => {
showErrorNotification({
title: t("notification.error.title"),
message: t("notification.error.message"),
});
},
},
);
},
});
}, [searchEngine, mutate, t, openConfirmModal]);
return (
<ActionIcon loading={isPending} variant="subtle" color="red" onClick={onClick} aria-label={t("title")}>
<IconTrash color="red" size={16} stroke={1.5} />
</ActionIcon>
);
};

View File

@@ -0,0 +1,63 @@
"use client";
import { useCallback } from "react";
import { useRouter } from "next/navigation";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import { revalidatePathActionAsync } from "@homarr/common/client";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import type { TranslationFunction } from "@homarr/translation";
import { useScopedI18n } from "@homarr/translation/client";
import type { validation, z } from "@homarr/validation";
import { SearchEngineForm } from "../../_form";
interface SearchEngineEditFormProps {
searchEngine: RouterOutputs["searchEngine"]["byId"];
}
export const SearchEngineEditForm = ({ searchEngine }: SearchEngineEditFormProps) => {
const t = useScopedI18n("search.engine.page.edit.notification");
const router = useRouter();
const { mutate, isPending } = clientApi.searchEngine.update.useMutation({
onSuccess: () => {
showSuccessNotification({
title: t("success.title"),
message: t("success.message"),
});
void revalidatePathActionAsync("/manage/search-engines").then(() => {
router.push("/manage/search-engines");
});
},
onError: () => {
showErrorNotification({
title: t("error.title"),
message: t("error.message"),
});
},
});
const handleSubmit = useCallback(
(values: z.infer<typeof validation.searchEngine.manage>) => {
mutate({
id: searchEngine.id,
...values,
});
},
[mutate, searchEngine.id],
);
const submitButtonTranslation = useCallback((t: TranslationFunction) => t("common.action.save"), []);
return (
<SearchEngineForm
submitButtonTranslation={submitButtonTranslation}
initialValues={searchEngine}
handleSubmit={handleSubmit}
isPending={isPending}
disableShort
/>
);
};

View File

@@ -0,0 +1,27 @@
import { Stack, Title } from "@mantine/core";
import { api } from "@homarr/api/server";
import { getI18n } from "@homarr/translation/server";
import { ManageContainer } from "~/components/manage/manage-container";
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
import { SearchEngineEditForm } from "./_search-engine-edit-form";
interface SearchEngineEditPageProps {
params: { id: string };
}
export default async function SearchEngineEditPage({ params }: SearchEngineEditPageProps) {
const searchEngine = await api.searchEngine.byId({ id: params.id });
const t = await getI18n();
return (
<ManageContainer>
<DynamicBreadcrumb dynamicMappings={new Map([[params.id, searchEngine.name]])} nonInteractable={["edit"]} />
<Stack>
<Title>{t("search.engine.page.edit.title")}</Title>
<SearchEngineEditForm searchEngine={searchEngine} />
</Stack>
</ManageContainer>
);
}

View File

@@ -0,0 +1,52 @@
"use client";
import { useCallback } from "react";
import { useRouter } from "next/navigation";
import { clientApi } from "@homarr/api/client";
import { revalidatePathActionAsync } from "@homarr/common/client";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import type { TranslationFunction } from "@homarr/translation";
import { useScopedI18n } from "@homarr/translation/client";
import type { validation, z } from "@homarr/validation";
import { SearchEngineForm } from "../_form";
export const SearchEngineNewForm = () => {
const t = useScopedI18n("search.engine.page.create.notification");
const router = useRouter();
const { mutate, isPending } = clientApi.searchEngine.create.useMutation({
onSuccess: async () => {
showSuccessNotification({
title: t("success.title"),
message: t("success.message"),
});
await revalidatePathActionAsync("/manage/search-engines");
router.push("/manage/search-engines");
},
onError: () => {
showErrorNotification({
title: t("error.title"),
message: t("error.message"),
});
},
});
const handleSubmit = useCallback(
(values: z.infer<typeof validation.searchEngine.manage>) => {
mutate(values);
},
[mutate],
);
const submitButtonTranslation = useCallback((t: TranslationFunction) => t("common.action.create"), []);
return (
<SearchEngineForm
submitButtonTranslation={submitButtonTranslation}
handleSubmit={handleSubmit}
isPending={isPending}
/>
);
};

View File

@@ -0,0 +1,21 @@
import { Stack, Title } from "@mantine/core";
import { getI18n } from "@homarr/translation/server";
import { ManageContainer } from "~/components/manage/manage-container";
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
import { SearchEngineNewForm } from "./_search-engine-new-form";
export default async function SearchEngineNewPage() {
const t = await getI18n();
return (
<ManageContainer>
<DynamicBreadcrumb />
<Stack>
<Title>{t("search.engine.page.create.title")}</Title>
<SearchEngineNewForm />
</Stack>
</ManageContainer>
);
}

View File

@@ -0,0 +1,139 @@
import Link from "next/link";
import { ActionIcon, ActionIconGroup, Anchor, Avatar, Card, Group, Stack, Text, Title } from "@mantine/core";
import { IconPencil, IconSearch } from "@tabler/icons-react";
import type { RouterOutputs } from "@homarr/api";
import { api } from "@homarr/api/server";
import { getI18n, getScopedI18n } from "@homarr/translation/server";
import { SearchInput, TablePagination } from "@homarr/ui";
import { z } from "@homarr/validation";
import { ManageContainer } from "~/components/manage/manage-container";
import { MobileAffixButton } from "~/components/manage/mobile-affix-button";
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
import { SearchEngineDeleteButton } from "./_search-engine-delete-button";
const searchParamsSchema = z.object({
search: z.string().optional(),
pageSize: z.string().regex(/\d+/).transform(Number).catch(10),
page: z.string().regex(/\d+/).transform(Number).catch(1),
});
type SearchParamsSchemaInputFromSchema<TSchema extends Record<string, unknown>> = Partial<{
[K in keyof TSchema]: Exclude<TSchema[K], undefined> extends unknown[] ? string[] : string;
}>;
interface SearchEnginesPageProps {
searchParams: SearchParamsSchemaInputFromSchema<z.infer<typeof searchParamsSchema>>;
}
export default async function SearchEnginesPage(props: SearchEnginesPageProps) {
const searchParams = searchParamsSchema.parse(props.searchParams);
const { items: searchEngines, totalCount } = await api.searchEngine.getPaginated(searchParams);
const t = await getI18n();
const tEngine = await getScopedI18n("search.engine");
return (
<ManageContainer>
<DynamicBreadcrumb />
<Stack>
<Title>{tEngine("page.list.title")}</Title>
<Group justify="space-between" align="center">
<SearchInput
placeholder={t("common.rtl", {
value: tEngine("search"),
symbol: "...",
})}
defaultValue={searchParams.search}
/>
<MobileAffixButton component={Link} href="/manage/search-engines/new">
{tEngine("page.create.title")}
</MobileAffixButton>
</Group>
{searchEngines.length === 0 && <SearchEngineNoResults />}
{searchEngines.length > 0 && (
<Stack gap="sm">
{searchEngines.map((searchEngine) => (
<SearchEngineCard key={searchEngine.id} searchEngine={searchEngine} />
))}
</Stack>
)}
<Group justify="end">
<TablePagination total={Math.ceil(totalCount / searchParams.pageSize)} />
</Group>
</Stack>
</ManageContainer>
);
}
interface SearchEngineCardProps {
searchEngine: RouterOutputs["searchEngine"]["getPaginated"]["items"][number];
}
const SearchEngineCard = async ({ searchEngine }: SearchEngineCardProps) => {
const t = await getScopedI18n("search.engine");
return (
<Card>
<Group justify="space-between" wrap="nowrap">
<Group align="top" justify="start" wrap="nowrap" style={{ flex: 1 }}>
<Avatar
size="sm"
src={searchEngine.iconUrl}
radius={0}
styles={{
image: {
objectFit: "contain",
},
}}
/>
<Stack gap={0}>
<Text fw={500} lineClamp={1}>
{searchEngine.name}
</Text>
{searchEngine.description && (
<Text size="sm" c="gray.6" lineClamp={4}>
{searchEngine.description}
</Text>
)}
<Anchor href={searchEngine.urlTemplate.replace("%s", "test")} lineClamp={1} size="sm">
{searchEngine.urlTemplate}
</Anchor>
</Stack>
</Group>
<Group>
<ActionIconGroup>
<ActionIcon
component={Link}
href={`/manage/search-engines/edit/${searchEngine.id}`}
variant="subtle"
color="gray"
aria-label={t("page.edit.title")}
>
<IconPencil size={16} stroke={1.5} />
</ActionIcon>
<SearchEngineDeleteButton searchEngine={searchEngine} />
</ActionIconGroup>
</Group>
</Group>
</Card>
);
};
const SearchEngineNoResults = async () => {
const t = await getI18n();
return (
<Card withBorder bg="transparent">
<Stack align="center" gap="sm">
<IconSearch size="2rem" />
<Text fw={500} size="lg">
{t("search.engine.page.list.noResults.title")}
</Text>
<Anchor href="/manage/search-engines/new">{t("search.engine.page.list.noResults.action")}</Anchor>
</Stack>
</Card>
);
};