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:
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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"),
|
||||
|
||||
73
apps/nextjs/src/app/[locale]/manage/search-engines/_form.tsx
Normal file
73
apps/nextjs/src/app/[locale]/manage/search-engines/_form.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
139
apps/nextjs/src/app/[locale]/manage/search-engines/page.tsx
Normal file
139
apps/nextjs/src/app/[locale]/manage/search-engines/page.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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,
|
||||
|
||||
@@ -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));
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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));
|
||||
}),
|
||||
});
|
||||
9
packages/db/migrations/mysql/0008_far_lifeguard.sql
Normal file
9
packages/db/migrations/mysql/0008_far_lifeguard.sql
Normal 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`)
|
||||
);
|
||||
1429
packages/db/migrations/mysql/meta/0008_snapshot.json
Normal file
1429
packages/db/migrations/mysql/meta/0008_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -57,6 +57,13 @@
|
||||
"when": 1723749320706,
|
||||
"tag": "0007_boring_nocturne",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 8,
|
||||
"version": "5",
|
||||
"when": 1727532165317,
|
||||
"tag": "0008_far_lifeguard",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
8
packages/db/migrations/sqlite/0008_third_thor.sql
Normal file
8
packages/db/migrations/sqlite/0008_third_thor.sql
Normal 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
|
||||
);
|
||||
1367
packages/db/migrations/sqlite/meta/0008_snapshot.json
Normal file
1367
packages/db/migrations/sqlite/meta/0008_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -57,6 +57,13 @@
|
||||
"when": 1723746828385,
|
||||
"tag": "0007_known_ultragirl",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 8,
|
||||
"version": "6",
|
||||
"when": 1727526190343,
|
||||
"tag": "0008_third_thor",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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>
|
||||
));
|
||||
};
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
22
packages/validation/src/common.ts
Normal file
22
packages/validation/src/common.ts
Normal 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,
|
||||
};
|
||||
@@ -48,7 +48,7 @@ const handleStringError = (issue: z.ZodInvalidStringIssue) => {
|
||||
}
|
||||
|
||||
return {
|
||||
key: "errors.invalid_string.includes",
|
||||
key: "errors.string.includes",
|
||||
params: {
|
||||
includes: issue.validation.includes,
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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";
|
||||
|
||||
20
packages/validation/src/search-engine.ts
Normal file
20
packages/validation/src/search-engine.ts
Normal 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,
|
||||
};
|
||||
Reference in New Issue
Block a user