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:
@@ -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