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>
);
};

View File

@@ -9,6 +9,7 @@ import { integrationRouter } from "./router/integration/integration-router";
import { inviteRouter } from "./router/invite";
import { locationRouter } from "./router/location";
import { logRouter } from "./router/log";
import { searchEngineRouter } from "./router/search-engine/search-engine-router";
import { serverSettingsRouter } from "./router/serverSettings";
import { userRouter } from "./router/user";
import { widgetRouter } from "./router/widgets";
@@ -21,6 +22,7 @@ export const appRouter = createTRPCRouter({
integration: integrationRouter,
board: boardRouter,
app: innerAppRouter,
searchEngine: searchEngineRouter,
widget: widgetRouter,
location: locationRouter,
log: logRouter,

View File

@@ -31,7 +31,7 @@ export const appRouter = createTRPCRouter({
limit: input.limit,
});
}),
byId: publicProcedure.input(validation.app.byId).query(async ({ ctx, input }) => {
byId: publicProcedure.input(validation.common.byId).query(async ({ ctx, input }) => {
const app = await ctx.db.query.apps.findFirst({
where: eq(apps.id, input.id),
});
@@ -76,7 +76,7 @@ export const appRouter = createTRPCRouter({
})
.where(eq(apps.id, input.id));
}),
delete: publicProcedure.input(validation.app.byId).mutation(async ({ ctx, input }) => {
delete: publicProcedure.input(validation.common.byId).mutation(async ({ ctx, input }) => {
await ctx.db.delete(apps).where(eq(apps.id, input.id));
}),
});

View File

@@ -8,7 +8,7 @@ import { validation, z } from "@homarr/validation";
import { createTRPCRouter, protectedProcedure, publicProcedure } from "../trpc";
export const groupRouter = createTRPCRouter({
getPaginated: protectedProcedure.input(validation.group.paginated).query(async ({ input, ctx }) => {
getPaginated: protectedProcedure.input(validation.common.paginated).query(async ({ input, ctx }) => {
const whereQuery = input.search ? like(groups.name, `%${input.search.trim()}%`) : undefined;
const groupCount = await ctx.db
.select({
@@ -45,7 +45,7 @@ export const groupRouter = createTRPCRouter({
totalCount: groupCount[0]?.count ?? 0,
};
}),
getById: protectedProcedure.input(validation.group.byId).query(async ({ input, ctx }) => {
getById: protectedProcedure.input(validation.common.byId).query(async ({ input, ctx }) => {
const group = await ctx.db.query.groups.findFirst({
where: eq(groups.id, input.id),
with: {
@@ -156,7 +156,7 @@ export const groupRouter = createTRPCRouter({
})
.where(eq(groups.id, input.groupId));
}),
deleteGroup: protectedProcedure.input(validation.group.byId).mutation(async ({ input, ctx }) => {
deleteGroup: protectedProcedure.input(validation.common.byId).mutation(async ({ input, ctx }) => {
await throwIfGroupNotFoundAsync(ctx.db, input.id);
await ctx.db.delete(groups).where(eq(groups.id, input.id));

View File

@@ -0,0 +1,85 @@
import { TRPCError } from "@trpc/server";
import { createId, eq, like, sql } from "@homarr/db";
import { searchEngines } from "@homarr/db/schema/sqlite";
import { validation } from "@homarr/validation";
import { createTRPCRouter, protectedProcedure } from "../../trpc";
export const searchEngineRouter = createTRPCRouter({
getPaginated: protectedProcedure.input(validation.common.paginated).query(async ({ input, ctx }) => {
const whereQuery = input.search ? like(searchEngines.name, `%${input.search.trim()}%`) : undefined;
const searchEngineCount = await ctx.db
.select({
count: sql<number>`count(*)`,
})
.from(searchEngines)
.where(whereQuery);
const dbSearachEngines = await ctx.db.query.searchEngines.findMany({
limit: input.pageSize,
offset: (input.page - 1) * input.pageSize,
where: whereQuery,
});
return {
items: dbSearachEngines,
totalCount: searchEngineCount[0]?.count ?? 0,
};
}),
byId: protectedProcedure.input(validation.common.byId).query(async ({ ctx, input }) => {
const searchEngine = await ctx.db.query.searchEngines.findFirst({
where: eq(searchEngines.id, input.id),
});
if (!searchEngine) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Search engine not found",
});
}
return searchEngine;
}),
search: protectedProcedure.input(validation.common.search).query(async ({ ctx, input }) => {
return await ctx.db.query.searchEngines.findMany({
where: like(searchEngines.short, `${input.query.toLowerCase().trim()}%`),
limit: input.limit,
});
}),
create: protectedProcedure.input(validation.searchEngine.manage).mutation(async ({ ctx, input }) => {
await ctx.db.insert(searchEngines).values({
id: createId(),
name: input.name,
short: input.short.toLowerCase(),
iconUrl: input.iconUrl,
urlTemplate: input.urlTemplate,
description: input.description,
});
}),
update: protectedProcedure.input(validation.searchEngine.edit).mutation(async ({ ctx, input }) => {
const searchEngine = await ctx.db.query.searchEngines.findFirst({
where: eq(searchEngines.id, input.id),
});
if (!searchEngine) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Search engine not found",
});
}
await ctx.db
.update(searchEngines)
.set({
name: input.name,
iconUrl: input.iconUrl,
urlTemplate: input.urlTemplate,
description: input.description,
})
.where(eq(searchEngines.id, input.id));
}),
delete: protectedProcedure.input(validation.common.byId).mutation(async ({ ctx, input }) => {
await ctx.db.delete(searchEngines).where(eq(searchEngines.id, input.id));
}),
});

View File

@@ -0,0 +1,9 @@
CREATE TABLE `search_engine` (
`id` varchar(64) NOT NULL,
`icon_url` text NOT NULL,
`name` varchar(64) NOT NULL,
`short` varchar(8) NOT NULL,
`description` text,
`url_template` text NOT NULL,
CONSTRAINT `search_engine_id` PRIMARY KEY(`id`)
);

File diff suppressed because it is too large Load Diff

View File

@@ -57,6 +57,13 @@
"when": 1723749320706,
"tag": "0007_boring_nocturne",
"breakpoints": true
},
{
"idx": 8,
"version": "5",
"when": 1727532165317,
"tag": "0008_far_lifeguard",
"breakpoints": true
}
]
}

View File

@@ -0,0 +1,8 @@
CREATE TABLE `search_engine` (
`id` text PRIMARY KEY NOT NULL,
`icon_url` text NOT NULL,
`name` text NOT NULL,
`short` text NOT NULL,
`description` text,
`url_template` text NOT NULL
);

File diff suppressed because it is too large Load Diff

View File

@@ -57,6 +57,13 @@
"when": 1723746828385,
"tag": "0007_known_ultragirl",
"breakpoints": true
},
{
"idx": 8,
"version": "6",
"when": 1727526190343,
"tag": "0008_third_thor",
"breakpoints": true
}
]
}

View File

@@ -341,6 +341,15 @@ export const serverSettings = mysqlTable("serverSetting", {
value: text("value").default('{"json": {}}').notNull(), // empty superjson object
});
export const searchEngines = mysqlTable("search_engine", {
id: varchar("id", { length: 64 }).notNull().primaryKey(),
iconUrl: text("icon_url").notNull(),
name: varchar("name", { length: 64 }).notNull(),
short: varchar("short", { length: 8 }).notNull(),
description: text("description"),
urlTemplate: text("url_template").notNull(),
});
export const accountRelations = relations(accounts, ({ one }) => ({
user: one(users, {
fields: [accounts.userId],

View File

@@ -343,6 +343,15 @@ export const serverSettings = sqliteTable("serverSetting", {
value: text("value").default('{"json": {}}').notNull(), // empty superjson object
});
export const searchEngines = sqliteTable("search_engine", {
id: text("id").notNull().primaryKey(),
iconUrl: text("icon_url").notNull(),
name: text("name").notNull(),
short: text("short").notNull(),
description: text("description"),
urlTemplate: text("url_template").notNull(),
});
export const accountRelations = relations(accounts, ({ one }) => ({
user: one(users, {
fields: [accounts.userId],

View File

@@ -1,4 +1,5 @@
import { Center, Loader } from "@mantine/core";
import { useWindowEvent } from "@mantine/hooks";
import type { TranslationObject } from "@homarr/translation";
import { useI18n } from "@homarr/translation/client";
@@ -27,6 +28,11 @@ export const SpotlightGroupActions = <TOption extends Record<string, unknown>>({
const options = useOptions(query);
const t = useI18n();
useWindowEvent("keydown", (event) => {
const optionsArray = Array.isArray(options) ? options : (options.data ?? []);
group.onKeyDown?.(event, optionsArray, query, { setChildrenOptions });
});
if (Array.isArray(options)) {
const filteredOptions = options
.filter((option) => ("filter" in group ? group.filter(query, option) : false))

View File

@@ -15,18 +15,13 @@ interface SpotlightActionGroupsProps {
setChildrenOptions: (options: inferSearchInteractionOptions<"children">) => void;
}
export const SpotlightActionGroups = ({ groups, query, setMode, setChildrenOptions }: SpotlightActionGroupsProps) => {
export const SpotlightActionGroups = ({ groups, ...others }: SpotlightActionGroupsProps) => {
const t = useI18n();
return groups.map((group) => (
<Spotlight.ActionsGroup key={translateIfNecessary(t, group.title)} label={translateIfNecessary(t, group.title)}>
{/*eslint-disable-next-line @typescript-eslint/no-explicit-any */}
<SpotlightGroupActions<any>
group={group}
query={query}
setMode={setMode}
setChildrenOptions={setChildrenOptions}
/>
<SpotlightGroupActions<any> group={group} {...others} />
</Spotlight.ActionsGroup>
));
};

View File

@@ -111,8 +111,11 @@ export const Spotlight = () => {
}}
setChildrenOptions={(options) => {
setChildrenOptions(options);
setQuery("");
setTimeout(() => selectAction(0, spotlightStore));
setTimeout(() => {
setQuery("");
selectAction(0, spotlightStore);
});
}}
query={query}
groups={activeMode.groups}

View File

@@ -2,7 +2,7 @@ import type { UseTRPCQueryResult } from "@trpc/react-query/shared";
import type { stringOrTranslation } from "@homarr/translation";
import type { inferSearchInteractionDefinition, SearchInteraction } from "./interaction";
import type { inferSearchInteractionDefinition, inferSearchInteractionOptions, SearchInteraction } from "./interaction";
type CommonSearchGroup<TOption extends Record<string, unknown>, TOptionProps extends Record<string, unknown>> = {
// key path is used to define the path to a unique key in the option object
@@ -10,6 +10,14 @@ type CommonSearchGroup<TOption extends Record<string, unknown>, TOptionProps ext
title: stringOrTranslation;
component: (option: TOption) => JSX.Element;
useInteraction: (option: TOption, query: string) => inferSearchInteractionDefinition<SearchInteraction>;
onKeyDown?: (
event: KeyboardEvent,
options: TOption[],
query: string,
actions: {
setChildrenOptions: (options: inferSearchInteractionOptions<"children">) => void;
},
) => void;
} & TOptionProps;
// eslint-disable-next-line @typescript-eslint/no-explicit-any

View File

@@ -1,82 +1,85 @@
import { Group, Stack, Text } from "@mantine/core";
import type { TablerIcon } from "@tabler/icons-react";
import { IconDownload } from "@tabler/icons-react";
import { Group, Kbd, Stack, Text } from "@mantine/core";
import { IconSearch } from "@tabler/icons-react";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import { useScopedI18n } from "@homarr/translation/client";
import { createChildrenOptions } from "../../lib/children";
import { createGroup } from "../../lib/group";
import { interaction } from "../../lib/interaction";
// This has to be type so it can be interpreted as Record<string, unknown>.
// eslint-disable-next-line @typescript-eslint/consistent-type-definitions
type SearchEngine = {
short: string;
image: string | TablerIcon;
name: string;
description: string;
urlTemplate: string;
};
type SearchEngine = RouterOutputs["searchEngine"]["search"][number];
export const searchEnginesChildrenOptions = createChildrenOptions<SearchEngine>({
useActions: () => [
{
key: "search",
component: ({ name }) => {
const tChildren = useScopedI18n("search.mode.external.group.searchEngine.children");
return (
<Group mx="md" my="sm">
<IconSearch stroke={1.5} />
<Text>{tChildren("action.search.label", { name })}</Text>
</Group>
);
},
useInteraction: interaction.link(({ urlTemplate }, query) => ({
href: urlTemplate.replace("%s", query),
})),
},
],
detailComponent({ options }) {
const tChildren = useScopedI18n("search.mode.external.group.searchEngine.children");
return (
<Stack mx="md" my="sm">
<Text>{tChildren("detail.title")}</Text>
<Group>
<img height={24} width={24} src={options.iconUrl} alt={options.name} />
<Text>{options.name}</Text>
</Group>
</Stack>
);
},
});
export const searchEnginesSearchGroups = createGroup<SearchEngine>({
keyPath: "short",
title: (t) => t("search.mode.external.group.searchEngine.title"),
component: ({ image: Image, name, description }) => (
<Group w="100%" wrap="nowrap" justify="space-between" align="center" px="md" py="xs">
<Group wrap="nowrap">
{typeof Image === "string" ? <img height={24} width={24} src={Image} alt={name} /> : <Image size={24} />}
<Stack gap={0} justify="center">
<Text size="sm">{name}</Text>
<Text size="xs" c="gray.6">
{description}
</Text>
</Stack>
component: ({ iconUrl, name, short, description }) => {
return (
<Group w="100%" wrap="nowrap" justify="space-between" align="center" px="md" py="xs">
<Group wrap="nowrap">
<img height={24} width={24} src={iconUrl} alt={name} />
<Stack gap={0} justify="center">
<Text size="sm">{name}</Text>
<Text size="xs" c="gray.6">
{description}
</Text>
</Stack>
</Group>
<Kbd size="sm">{short}</Kbd>
</Group>
</Group>
),
filter: () => true,
);
},
onKeyDown(event, options, query, { setChildrenOptions }) {
if (event.code !== "Space") return;
const engine = options.find((option) => option.short === query);
if (!engine) return;
setChildrenOptions(searchEnginesChildrenOptions(engine));
},
useInteraction: interaction.link(({ urlTemplate }, query) => ({
href: urlTemplate.replace("%s", query),
newTab: true,
})),
useOptions() {
const tOption = useScopedI18n("search.mode.external.group.searchEngine.option");
return [
{
short: "g",
name: tOption("google.name"),
image: "https://www.google.com/favicon.ico",
description: tOption("google.description"),
urlTemplate: "https://www.google.com/search?q=%s",
},
{
short: "b",
name: tOption("bing.name"),
image: "https://www.bing.com/favicon.ico",
description: tOption("bing.description"),
urlTemplate: "https://www.bing.com/search?q=%s",
},
{
short: "d",
name: tOption("duckduckgo.name"),
image: "https://duckduckgo.com/favicon.ico",
description: tOption("duckduckgo.description"),
urlTemplate: "https://duckduckgo.com/?q=%s",
},
{
short: "t",
name: tOption("torrent.name"),
image: IconDownload,
description: tOption("torrent.description"),
urlTemplate: "https://www.torrentdownloads.pro/search/?search=%s",
},
{
short: "y",
name: tOption("youTube.name"),
image: "https://www.youtube.com/favicon.ico",
description: tOption("youTube.description"),
urlTemplate: "https://www.youtube.com/results?search_query=%s",
},
];
useQueryOptions(query) {
return clientApi.searchEngine.search.useQuery({
query: query.trim(),
limit: 5,
});
},
});

View File

@@ -9,6 +9,7 @@ import {
IconMailForward,
IconPlug,
IconReport,
IconSearch,
IconSettings,
IconUsers,
IconUsersGroup,
@@ -82,6 +83,12 @@ export const pagesSearchGroup = createGroup<{
name: t("manageIntegration.label"),
hidden: !session,
},
{
icon: IconSearch,
path: "/manage/search-engines",
name: t("manageSearchEngine.label"),
hidden: !session,
},
{
icon: IconUsers,
path: "/manage/users",

View File

@@ -304,8 +304,8 @@ export default {
list: {
title: "Apps",
noResults: {
title: "There aren't any apps.",
description: "Create your first app",
title: "There aren't any apps",
action: "Create your first app",
},
},
create: {
@@ -1595,6 +1595,7 @@ export default {
boards: "Boards",
apps: "Apps",
integrations: "Integrations",
searchEngies: "Search engines",
users: {
label: "Users",
items: {
@@ -2049,13 +2050,22 @@ export default {
label: "New",
},
},
"search-engines": {
label: "Search engines",
new: {
label: "New",
},
edit: {
label: "Edit",
},
},
apps: {
label: "Apps",
new: {
label: "New App",
label: "New",
},
edit: {
label: "Edit App",
label: "Edit",
},
},
users: {
@@ -2193,6 +2203,16 @@ export default {
group: {
searchEngine: {
title: "Search engines",
children: {
action: {
search: {
label: "Search with {name}",
},
},
detail: {
title: "Select an action for the search engine",
},
},
option: {
google: {
name: "Google",
@@ -2257,6 +2277,9 @@ export default {
manageIntegration: {
label: "Manage integrations",
},
manageSearchEngine: {
label: "Manage search engines",
},
manageUser: {
label: "Manage users",
},
@@ -2332,5 +2355,71 @@ export default {
},
},
},
engine: {
search: "Find a search engine",
field: {
name: {
label: "Name",
},
short: {
label: "Short",
},
urlTemplate: {
label: "URL search template",
},
description: {
label: "Description",
},
},
page: {
list: {
title: "Search engines",
noResults: {
title: "There aren't any search engines",
action: "Create your first search engine",
},
},
create: {
title: "New search engine",
notification: {
success: {
title: "Search engine created",
message: "The search engine was created successfully",
},
error: {
title: "Search engine not created",
message: "The search engine could not be created",
},
},
},
edit: {
title: "Edit search engine",
notification: {
success: {
title: "Changes applied successfully",
message: "The search engine was saved successfully",
},
error: {
title: "Unable to apply changes",
message: "The search engine could not be saved",
},
},
},
delete: {
title: "Delete search engine",
message: "Are you sure you want to delete the search engine '{name}'?",
notification: {
success: {
title: "Search engine deleted",
message: "The search engine was deleted successfully",
},
error: {
title: "Search engine not deleted",
message: "The search engine could not be deleted",
},
},
},
},
},
},
} as const;

View File

@@ -9,10 +9,7 @@ const manageAppSchema = z.object({
const editAppSchema = manageAppSchema.and(z.object({ id: z.string() }));
const byIdSchema = z.object({ id: z.string() });
export const appSchemas = {
manage: manageAppSchema,
edit: editAppSchema,
byId: byIdSchema,
};

View File

@@ -0,0 +1,22 @@
import { z } from "zod";
const paginatedSchema = z.object({
search: z.string().optional(),
pageSize: z.number().int().positive().default(10),
page: z.number().int().positive().default(1),
});
export const byIdSchema = z.object({
id: z.string(),
});
const searchSchema = z.object({
query: z.string(),
limit: z.number().int().positive().default(10),
});
export const commonSchemas = {
paginated: paginatedSchema,
byId: byIdSchema,
search: searchSchema,
};

View File

@@ -48,7 +48,7 @@ const handleStringError = (issue: z.ZodInvalidStringIssue) => {
}
return {
key: "errors.invalid_string.includes",
key: "errors.string.includes",
params: {
includes: issue.validation.includes,
},

View File

@@ -2,18 +2,9 @@ import { z } from "zod";
import { groupPermissionKeys } from "@homarr/definitions";
import { byIdSchema } from "./common";
import { zodEnumFromArray } from "./enums";
const paginatedSchema = z.object({
search: z.string().optional(),
pageSize: z.number().int().positive().default(10),
page: z.number().int().positive().default(1),
});
const byIdSchema = z.object({
id: z.string(),
});
const createSchema = z.object({
name: z.string().max(64),
});
@@ -28,8 +19,6 @@ const savePermissionsSchema = z.object({
const groupUserSchema = z.object({ groupId: z.string(), userId: z.string() });
export const groupSchemas = {
paginated: paginatedSchema,
byId: byIdSchema,
create: createSchema,
update: updateSchema,
savePermissions: savePermissionsSchema,

View File

@@ -1,9 +1,11 @@
import { appSchemas } from "./app";
import { boardSchemas } from "./board";
import { commonSchemas } from "./common";
import { groupSchemas } from "./group";
import { iconsSchemas } from "./icons";
import { integrationSchemas } from "./integration";
import { locationSchemas } from "./location";
import { searchEngineSchemas } from "./search-engine";
import { userSchemas } from "./user";
import { widgetSchemas } from "./widgets";
@@ -16,15 +18,17 @@ export const validation = {
widget: widgetSchemas,
location: locationSchemas,
icons: iconsSchemas,
searchEngine: searchEngineSchemas,
common: commonSchemas,
};
export {
createSectionSchema,
sharedItemSchema,
itemAdvancedOptionsSchema,
type BoardItemIntegration,
type BoardItemAdvancedOptions,
} from "./shared";
export { passwordRequirements } from "./user";
export { oldmarrImportConfigurationSchema, superRefineJsonImportFile } from "./board";
export type { OldmarrImportConfiguration } from "./board";
export {
createSectionSchema,
itemAdvancedOptionsSchema,
sharedItemSchema,
type BoardItemAdvancedOptions,
type BoardItemIntegration,
} from "./shared";
export { passwordRequirements } from "./user";

View File

@@ -0,0 +1,20 @@
import { z } from "zod";
const manageSearchEngineSchema = z.object({
name: z.string().min(1).max(64),
short: z.string().min(1).max(8),
iconUrl: z.string().min(1),
urlTemplate: z.string().min(1).startsWith("http").includes("%s"),
description: z.string().max(512).nullable(),
});
const editSearchEngineSchema = manageSearchEngineSchema
.extend({
id: z.string(),
})
.omit({ short: true });
export const searchEngineSchemas = {
manage: manageSearchEngineSchema,
edit: editSearchEngineSchema,
};