feat: add improved search (#1051)

* feat: add improved search

* wip: add support for sorting, rename use-options to use-query-options, add use-options for local usage, add pages search group

* feat: add help links from manage layout to help search mode

* feat: add additional search engines

* feat: add group search details

* refactor: improve users search group type

* feat: add apps search group, add disabled search interaction

* feat: add integrations and boards for search

* wip: hook issue with react

* fix: hook issue regarding actions and interactions

* chore: address pull request feedback

* fix: format issues

* feat: add additional global actions to search

* chore: remove unused code

* fix: search engine short key

* fix: typecheck issues

* fix: deepsource issues

* fix: eslint issue

* fix: lint issues

* fix: unordered dependencies

* chore: address pull request feedback
This commit is contained in:
Meier Lukas
2024-09-20 16:51:42 +02:00
committed by GitHub
parent 0c44af2f67
commit ce1ef3cbe7
64 changed files with 1985 additions and 628 deletions

View File

@@ -1,39 +1,21 @@
"use client";
import { useCallback } from "react";
import { Affix, Button, Group, Menu } from "@mantine/core";
import { IconCategoryPlus, IconChevronDown, IconFileImport } from "@tabler/icons-react";
import { revalidatePathActionAsync } from "@homarr/common/client";
import { useModalAction } from "@homarr/modals";
import { AddBoardModal, ImportBoardModal } from "@homarr/modals-collection";
import { useI18n } from "@homarr/translation/client";
import { BetaBadge } from "@homarr/ui";
interface CreateBoardButtonProps {
boardNames: string[];
}
export const CreateBoardButton = ({ boardNames }: CreateBoardButtonProps) => {
export const CreateBoardButton = () => {
const t = useI18n();
const { openModal: openAddModal } = useModalAction(AddBoardModal);
const { openModal: openImportModal } = useModalAction(ImportBoardModal);
const onCreateClick = useCallback(() => {
openAddModal({
onSettled: async () => {
await revalidatePathActionAsync("/manage/boards");
},
});
}, [openAddModal]);
const onImportClick = useCallback(() => {
openImportModal({ boardNames });
}, [openImportModal, boardNames]);
const buttonGroupContent = (
<>
<Button leftSection={<IconCategoryPlus size="1rem" />} onClick={onCreateClick}>
<Button leftSection={<IconCategoryPlus size="1rem" />} onClick={openAddModal}>
{t("management.page.board.action.new.label")}
</Button>
<Menu position="bottom-end">
@@ -43,7 +25,7 @@ export const CreateBoardButton = ({ boardNames }: CreateBoardButtonProps) => {
</Button>
</Menu.Target>
<Menu.Dropdown>
<Menu.Item onClick={onImportClick} leftSection={<IconFileImport size="1rem" />}>
<Menu.Item onClick={openImportModal} leftSection={<IconFileImport size="1rem" />}>
<Group>
{t("board.action.oldImport.label")}
<BetaBadge size="xs" />

View File

@@ -39,7 +39,7 @@ export default async function ManageBoardsPage() {
<Stack>
<Group justify="space-between">
<Title mb="md">{t("title")}</Title>
<CreateBoardButton boardNames={boards.map((board) => board.name)} />
<CreateBoardButton />
</Group>
<Grid mb={{ base: "xl", md: 0 }}>

View File

@@ -3,10 +3,10 @@ import { Container, Fieldset, Group, Stack, Title } from "@mantine/core";
import { api } from "@homarr/api/server";
import { getIntegrationName } from "@homarr/definitions";
import { getI18n, getScopedI18n } from "@homarr/translation/server";
import { IntegrationAvatar } from "@homarr/ui";
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
import { IntegrationAccessSettings } from "../../_components/integration-access-settings";
import { IntegrationAvatar } from "../../_integration-avatar";
import { EditIntegrationForm } from "./_integration-edit-form";
interface EditIntegrationPageProps {

View File

@@ -8,8 +8,7 @@ import { IconSearch } from "@tabler/icons-react";
import { getIntegrationName, integrationKinds } from "@homarr/definitions";
import { useI18n } from "@homarr/translation/client";
import { IntegrationAvatar } from "../_integration-avatar";
import { IntegrationAvatar } from "@homarr/ui";
export const IntegrationCreateDropdownContent = () => {
const t = useI18n();

View File

@@ -4,11 +4,11 @@ import { Container, Group, Stack, Title } from "@mantine/core";
import type { IntegrationKind } from "@homarr/definitions";
import { getIntegrationName, integrationKinds } from "@homarr/definitions";
import { getScopedI18n } from "@homarr/translation/server";
import { IntegrationAvatar } from "@homarr/ui";
import type { validation } from "@homarr/validation";
import { z } from "@homarr/validation";
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
import { IntegrationAvatar } from "../_integration-avatar";
import { NewIntegrationForm } from "./_integration-new-form";
interface NewIntegrationPageProps {

View File

@@ -34,12 +34,11 @@ import { objectEntries } from "@homarr/common";
import type { IntegrationKind } from "@homarr/definitions";
import { getIntegrationName } from "@homarr/definitions";
import { getScopedI18n } from "@homarr/translation/server";
import { CountBadge } from "@homarr/ui";
import { CountBadge, IntegrationAvatar } from "@homarr/ui";
import { ManageContainer } from "~/components/manage/manage-container";
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
import { ActiveTabAccordion } from "../../../../components/active-tab-accordion";
import { IntegrationAvatar } from "./_integration-avatar";
import { DeleteIntegrationActionButton } from "./_integration-buttons";
import { IntegrationCreateDropdownContent } from "./new/_integration-new-dropdown";

View File

@@ -113,7 +113,7 @@ export default async function ManageLayout({ children }: PropsWithChildren) {
{
label: t("items.help.items.documentation"),
icon: IconBook2,
href: "https://homarr.dev/docs/getting-started/prerequisites",
href: "https://homarr.dev/docs/getting-started/",
external: true,
},
{
@@ -123,7 +123,7 @@ export default async function ManageLayout({ children }: PropsWithChildren) {
external: true,
},
{
label: t("items.tools.items.docker"),
label: t("items.help.items.discord"),
icon: IconBrandDiscord,
href: "https://discord.com/invite/aCsmEV5RgA",
external: true,

View File

@@ -1,15 +1,10 @@
"use client";
import { useCallback } from "react";
import { Button, Group, Stack, TextInput } from "@mantine/core";
import { clientApi } from "@homarr/api/client";
import { revalidatePathActionAsync } from "@homarr/common/client";
import { useZodForm } from "@homarr/form";
import { createModal, useModalAction } from "@homarr/modals";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useModalAction } from "@homarr/modals";
import { AddGroupModal } from "@homarr/modals-collection";
import { useI18n } from "@homarr/translation/client";
import { validation } from "@homarr/validation";
import { MobileAffixButton } from "~/components/manage/mobile-affix-button";
@@ -27,50 +22,3 @@ export const AddGroup = () => {
</MobileAffixButton>
);
};
const AddGroupModal = createModal<void>(({ actions }) => {
const t = useI18n();
const { mutate, isPending } = clientApi.group.createGroup.useMutation();
const form = useZodForm(validation.group.create, {
initialValues: {
name: "",
},
});
return (
<form
onSubmit={form.onSubmit((values) => {
mutate(values, {
onSuccess() {
actions.closeModal();
void revalidatePathActionAsync("/manage/users/groups");
showSuccessNotification({
title: t("common.notification.create.success"),
message: t("group.action.create.notification.success.message"),
});
},
onError() {
showErrorNotification({
title: t("common.notification.create.error"),
message: t("group.action.create.notification.error.message"),
});
},
});
})}
>
<Stack>
<TextInput label={t("group.field.name")} data-autofocus {...form.getInputProps("name")} />
<Group justify="right">
<Button onClick={actions.closeModal} variant="subtle" color="gray">
{t("common.action.cancel")}
</Button>
<Button loading={isPending} type="submit" color="teal">
{t("common.action.create")}
</Button>
</Group>
</Stack>
</form>
);
}).withOptions({
defaultTitle: (t) => t("group.action.create.label"),
});

View File

@@ -107,7 +107,7 @@ export const BoardItemMenu = ({
}}
>
{tItem("action.moveResize")}
</Menu.Item>{" "}
</Menu.Item>
<Menu.Item leftSection={<IconCopy size={16} />} onClick={() => duplicateItem({ itemId: item.id })}>
{tItem("action.duplicate")}
</Menu.Item>

View File

@@ -3,7 +3,7 @@ import { useState } from "react";
import { Combobox, Group, Image, InputBase, Skeleton, Text, useCombobox } from "@mantine/core";
import { clientApi } from "@homarr/api/client";
import { useScopedI18n } from "@homarr/translation/client";
import { useI18n, useScopedI18n } from "@homarr/translation/client";
interface IconPickerProps {
initialValue?: string;
@@ -18,7 +18,8 @@ export const IconPicker = ({ initialValue, onChange, error, onFocus, onBlur }: I
const [search, setSearch] = useState(initialValue ?? "");
const [previewUrl, setPreviewUrl] = useState<string | null>(initialValue ?? null);
const t = useScopedI18n("common");
const t = useI18n();
const tCommon = useScopedI18n("common");
const { data, isFetching } = clientApi.icon.findIcons.useQuery({
searchText: search,
@@ -89,13 +90,13 @@ export const IconPicker = ({ initialValue, onChange, error, onFocus, onBlur }: I
rightSectionPointerEvents="none"
withAsterisk
error={error}
label={t("iconPicker.label")}
label={tCommon("iconPicker.label")}
/>
</Combobox.Target>
<Combobox.Dropdown>
<Combobox.Header>
<Text c="dimmed">{t("iconPicker.header", { countIcons: data?.countIcons })}</Text>
<Text c="dimmed">{tCommon("iconPicker.header", { countIcons: data?.countIcons })}</Text>
</Combobox.Header>
<Combobox.Options mah={350} style={{ overflowY: "auto" }}>
{totalOptions > 0 ? (

View File

@@ -4,13 +4,13 @@ import { TextInput, UnstyledButton } from "@mantine/core";
import { IconSearch } from "@tabler/icons-react";
import { openSpotlight } from "@homarr/spotlight";
import { useScopedI18n } from "@homarr/translation/client";
import { useI18n } from "@homarr/translation/client";
import { HeaderButton } from "./button";
import classes from "./search.module.css";
export const DesktopSearchInput = () => {
const t = useScopedI18n("common.search");
const t = useI18n();
return (
<TextInput
@@ -21,7 +21,10 @@ export const DesktopSearchInput = () => {
leftSection={<IconSearch size={20} stroke={1.5} />}
onClick={openSpotlight}
>
{t("placeholder")}
{t("common.rtl", {
value: t("search.placeholder"),
symbol: "...",
})}
</TextInput>
);
};

View File

@@ -1,8 +1,8 @@
import { TRPCError } from "@trpc/server";
import { asc, createId, eq } from "@homarr/db";
import { asc, createId, eq, like } from "@homarr/db";
import { apps } from "@homarr/db/schema/sqlite";
import { validation } from "@homarr/validation";
import { validation, z } from "@homarr/validation";
import { createTRPCRouter, publicProcedure } from "../trpc";
@@ -22,6 +22,15 @@ export const appRouter = createTRPCRouter({
orderBy: asc(apps.name),
});
}),
search: publicProcedure
.input(z.object({ query: z.string(), limit: z.number().min(1).max(100).default(10) }))
.query(async ({ ctx, input }) => {
return await ctx.db.query.apps.findMany({
where: like(apps.name, `%${input.query}%`),
orderBy: asc(apps.name),
limit: input.limit,
});
}),
byId: publicProcedure.input(validation.app.byId).query(async ({ ctx, input }) => {
const app = await ctx.db.query.apps.findFirst({
where: eq(apps.id, input.id),

View File

@@ -1,8 +1,9 @@
import { TRPCError } from "@trpc/server";
import superjson from "superjson";
import { constructBoardPermissions } from "@homarr/auth/shared";
import type { Database, SQL } from "@homarr/db";
import { and, createId, eq, inArray, or } from "@homarr/db";
import { and, createId, eq, inArray, like, or } from "@homarr/db";
import {
boardGroupPermissions,
boards,
@@ -109,6 +110,79 @@ export const boardRouter = createTRPCRouter({
isHome: currentUserWhenPresent?.homeBoardId === board.id,
}));
}),
search: publicProcedure
.input(z.object({ query: z.string(), limit: z.number().min(1).max(100).default(10) }))
.query(async ({ ctx, input }) => {
const userId = ctx.session?.user.id;
const permissionsOfCurrentUserWhenPresent = await ctx.db.query.boardUserPermissions.findMany({
where: eq(boardUserPermissions.userId, userId ?? ""),
});
const permissionsOfCurrentUserGroupsWhenPresent = await ctx.db.query.groupMembers.findMany({
where: eq(groupMembers.userId, userId ?? ""),
with: {
group: {
with: {
boardPermissions: {},
},
},
},
});
const boardIds = permissionsOfCurrentUserWhenPresent
.map((permission) => permission.boardId)
.concat(
permissionsOfCurrentUserGroupsWhenPresent
.map((groupMember) => groupMember.group.boardPermissions.map((permission) => permission.boardId))
.flat(),
);
const currentUserWhenPresent = await ctx.db.query.users.findFirst({
where: eq(users.id, userId ?? ""),
});
const foundBoards = await ctx.db.query.boards.findMany({
where: and(
like(boards.name, `%${input.query}%`),
ctx.session?.user.permissions.includes("board-view-all")
? undefined
: or(
eq(boards.isPublic, true),
eq(boards.creatorId, ctx.session?.user.id ?? ""),
inArray(boards.id, boardIds),
),
),
limit: input.limit,
columns: {
id: true,
name: true,
creatorId: true,
isPublic: true,
logoImageUrl: true,
},
with: {
userPermissions: {
where: eq(boardUserPermissions.userId, ctx.session?.user.id ?? ""),
},
groupPermissions: {
where:
permissionsOfCurrentUserGroupsWhenPresent.length >= 1
? inArray(
boardGroupPermissions.groupId,
permissionsOfCurrentUserGroupsWhenPresent.map((groupMember) => groupMember.groupId),
)
: undefined,
},
},
});
return foundBoards.map((board) => ({
id: board.id,
name: board.name,
logoImageUrl: board.logoImageUrl,
permissions: constructBoardPermissions(board, ctx.session),
isHome: currentUserWhenPresent?.homeBoardId === board.id,
}));
}),
createBoard: permissionRequiredProcedure
.requiresPermission("board-create")
.input(validation.board.create)

View File

@@ -3,9 +3,9 @@ import { TRPCError } from "@trpc/server";
import type { Database } from "@homarr/db";
import { and, createId, eq, like, not, sql } from "@homarr/db";
import { groupMembers, groupPermissions, groups } from "@homarr/db/schema/sqlite";
import { validation } from "@homarr/validation";
import { validation, z } from "@homarr/validation";
import { createTRPCRouter, protectedProcedure } from "../trpc";
import { createTRPCRouter, protectedProcedure, publicProcedure } from "../trpc";
export const groupRouter = createTRPCRouter({
getPaginated: protectedProcedure.input(validation.group.paginated).query(async ({ input, ctx }) => {
@@ -91,6 +91,23 @@ export const groupRouter = createTRPCRouter({
},
});
}),
search: publicProcedure
.input(
z.object({
query: z.string(),
limit: z.number().min(1).max(100).default(10),
}),
)
.query(async ({ input, ctx }) => {
return await ctx.db.query.groups.findMany({
where: like(groups.name, `%${input.query}%`),
columns: {
id: true,
name: true,
},
limit: input.limit,
});
}),
createGroup: protectedProcedure.input(validation.group.create).mutation(async ({ input, ctx }) => {
const normalizedName = normalizeName(input.name);
await checkSimilarNameAndThrowAsync(ctx.db, normalizedName);

View File

@@ -2,7 +2,7 @@ import { TRPCError } from "@trpc/server";
import { decryptSecret, encryptSecret } from "@homarr/common/server";
import type { Database } from "@homarr/db";
import { and, createId, eq, inArray } from "@homarr/db";
import { and, asc, createId, eq, inArray, like } from "@homarr/db";
import {
groupPermissions,
integrationGroupPermissions,
@@ -12,7 +12,7 @@ import {
} from "@homarr/db/schema/sqlite";
import type { IntegrationSecretKind } from "@homarr/definitions";
import { getPermissionsWithParents, integrationKinds, integrationSecretKindObject } from "@homarr/definitions";
import { validation } from "@homarr/validation";
import { validation, z } from "@homarr/validation";
import { createTRPCRouter, permissionRequiredProcedure, protectedProcedure } from "../../trpc";
import { throwIfActionForbiddenAsync } from "./integration-access";
@@ -33,6 +33,15 @@ export const integrationRouter = createTRPCRouter({
integrationKinds.indexOf(integrationA.kind) - integrationKinds.indexOf(integrationB.kind),
);
}),
search: protectedProcedure
.input(z.object({ query: z.string(), limit: z.number().min(1).max(100).default(10) }))
.query(async ({ ctx, input }) => {
return await ctx.db.query.integrations.findMany({
where: like(integrations.name, `%${input.query}%`),
orderBy: asc(integrations.name),
limit: input.limit,
});
}),
byId: protectedProcedure.input(validation.integration.byId).query(async ({ ctx, input }) => {
await throwIfActionForbiddenAsync(ctx, eq(integrations.id, input.id), "full");
const integration = await ctx.db.query.integrations.findFirst({

View File

@@ -2,7 +2,7 @@ import { TRPCError } from "@trpc/server";
import { createSaltAsync, hashPasswordAsync } from "@homarr/auth";
import type { Database } from "@homarr/db";
import { and, createId, eq, schema } from "@homarr/db";
import { and, createId, eq, like, schema } from "@homarr/db";
import { groupMembers, groupPermissions, groups, invites, users } from "@homarr/db/schema/sqlite";
import type { SupportedAuthProvider } from "@homarr/definitions";
import { logger } from "@homarr/log";
@@ -164,6 +164,29 @@ export const userRouter = createTRPCRouter({
},
});
}),
search: publicProcedure
.input(
z.object({
query: z.string(),
limit: z.number().min(1).max(100).default(10),
}),
)
.query(async ({ input, ctx }) => {
const dbUsers = await ctx.db.query.users.findMany({
columns: {
id: true,
name: true,
image: true,
},
where: like(users.name, `%${input.query}%`),
limit: input.limit,
});
return dbUsers.map((user) => ({
id: user.id,
name: user.name ?? "",
image: user.image,
}));
}),
getById: publicProcedure.input(z.object({ userId: z.string() })).query(async ({ input, ctx }) => {
const user = await ctx.db.query.users.findFirst({
columns: {

View File

@@ -25,17 +25,18 @@ export const constructBoardPermissions = (board: BoardPermissionsProps, session:
const creatorId = "creator" in board ? board.creator?.id : board.creatorId;
return {
hasFullAccess: session?.user.id === creatorId || session?.user.permissions.includes("board-full-all"),
hasFullAccess: session?.user.id === creatorId || (session?.user.permissions.includes("board-full-all") ?? false),
hasChangeAccess:
session?.user.id === creatorId ||
board.userPermissions.some(({ permission }) => permission === "modify") ||
board.groupPermissions.some(({ permission }) => permission === "modify") ||
session?.user.permissions.includes("board-modify-all"),
(session?.user.permissions.includes("board-modify-all") ?? false) ||
(session?.user.permissions.includes("board-full-all") ?? false),
hasViewAccess:
session?.user.id === creatorId ||
board.userPermissions.length >= 1 ||
board.groupPermissions.length >= 1 ||
board.isPublic ||
session?.user.permissions.includes("board-view-all"),
(session?.user.permissions.includes("board-view-all") ?? false),
};
};

View File

@@ -3,18 +3,14 @@ import { useDebouncedValue } from "@mantine/hooks";
import { IconAlertTriangle, IconCircleCheck } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
import type { MaybePromise } from "@homarr/common/types";
import { revalidatePathActionAsync } from "@homarr/common/client";
import { useZodForm } from "@homarr/form";
import { createModal } from "@homarr/modals";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useI18n } from "@homarr/translation/client";
import { validation } from "@homarr/validation";
interface InnerProps {
onSettled: () => MaybePromise<void>;
}
export const AddBoardModal = createModal<InnerProps>(({ actions, innerProps }) => {
export const AddBoardModal = createModal(({ actions }) => {
const t = useI18n();
const form = useZodForm(validation.board.create, {
mode: "controlled",
@@ -25,7 +21,9 @@ export const AddBoardModal = createModal<InnerProps>(({ actions, innerProps }) =
},
});
const { mutate, isPending } = clientApi.board.createBoard.useMutation({
onSettled: innerProps.onSettled,
onSettled: async () => {
await revalidatePathActionAsync("/manage/boards");
},
});
const boardNameStatus = useBoardNameStatus(form.values.name);

View File

@@ -0,0 +1,56 @@
import { Button, Group, Stack, TextInput } from "@mantine/core";
import { clientApi } from "@homarr/api/client";
import { revalidatePathActionAsync } from "@homarr/common/client";
import { useZodForm } from "@homarr/form";
import { createModal } from "@homarr/modals";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useI18n } from "@homarr/translation/client";
import { validation } from "@homarr/validation";
export const AddGroupModal = createModal<void>(({ actions }) => {
const t = useI18n();
const { mutate, isPending } = clientApi.group.createGroup.useMutation();
const form = useZodForm(validation.group.create, {
initialValues: {
name: "",
},
});
return (
<form
onSubmit={form.onSubmit((values) => {
mutate(values, {
onSuccess() {
actions.closeModal();
void revalidatePathActionAsync("/manage/users/groups");
showSuccessNotification({
title: t("common.notification.create.success"),
message: t("group.action.create.notification.success.message"),
});
},
onError() {
showErrorNotification({
title: t("common.notification.create.error"),
message: t("group.action.create.notification.error.message"),
});
},
});
})}
>
<Stack>
<TextInput label={t("group.field.name")} data-autofocus {...form.getInputProps("name")} />
<Group justify="right">
<Button onClick={actions.closeModal} variant="subtle" color="gray">
{t("common.action.cancel")}
</Button>
<Button loading={isPending} type="submit" color="teal">
{t("common.action.create")}
</Button>
</Group>
</Stack>
</form>
);
}).withOptions({
defaultTitle: (t) => t("group.action.create.label"),
});

View File

@@ -0,0 +1 @@
export { AddGroupModal } from "./add-group-modal";

View File

@@ -1,2 +1,3 @@
export * from "./boards";
export * from "./invites";
export * from "./groups";

View File

@@ -1,145 +0,0 @@
# Spotlight
Spotlight is the search functionality of Homarr. It can be opened by pressing `Ctrl + K` or `Cmd + K` on Mac. It is a quick way to search for anything in Homarr.
## API
### SpotlightActionData
The [SpotlightActionData](./src/type.ts) is the data structure that is used to define the actions that are shown in the spotlight.
#### Common properties
| Name | Type | Description |
| ------------------------------ | ------------------------------------------------------------- | ----------------------------------------------------------------------------------- |
| id | `string` | The id of the action. |
| title | `string \| (t: TranslationFunction) => string` | The title of the action. Either static or generated with translation function |
| description | `string \| (t: TranslationFunction) => string` | The description of the action. Either static or generated with translation function |
| icon | `string \| TablerIcon` | The icon of the action. Either a url to an image or a TablerIcon |
| group | `string` | The group of the action. By default the groups all, web and action exist. |
| ignoreSearchAndOnlyShowInGroup | `boolean` | If true, the action will only be shown in the group and not in the search results. |
| type | `'link' \| 'button'` | The type of the action. Either link or button |
#### Properties for links
| Name | Type | Description |
| ---- | -------- | ---------------------------------------------------------------------------------------------------------- |
| href | `string` | The url the link should navigate to. If %s is contained it will be replaced with the current search query. |
#### Properties for buttons
| Name | Type | Description |
| ------- | -------------------------- | ----------------------------------------------------------------------------------------- |
| onClick | `() => MaybePromise<void>` | The function that should be called when the button is clicked. It can be async if needed. |
### useRegisterSpotlightActions
The [useRegisterSpotlightActions](./src/data-store.ts) hook is used to register actions to the spotlight. It takes an unique key and the array of [SpotlightActionData](#SpotlightActionData).
#### Usage
The following example shows how to use the `useRegisterSpotlightActions` hook to register an action to the spotlight.
```tsx
"use client";
import { useRegisterSpotlightActions } from "@homarr/spotlight";
const MyComponent = () => {
useRegisterSpotlightActions("my-component", [
{
id: "my-action",
title: "My Action",
description: "This is my action",
icon: "https://example.com/icon.png",
group: "web",
type: "link",
href: "https://example.com",
},
]);
return <div>My Component</div>;
};
```
##### Using translation function
```tsx
"use client";
import { useRegisterSpotlightActions } from "@homarr/spotlight";
const MyComponent = () => {
useRegisterSpotlightActions("my-component", [
{
id: "my-action",
title: (t) => t("some.path.to.translation.key"),
description: (t) => t("some.other.path.to.translation.key"),
icon: "https://example.com/icon.png",
group: "web",
type: "link",
href: "https://example.com",
},
]);
return <div>Component implementation</div>;
};
```
##### Using TablerIcon
```tsx
"use client";
import { IconUserCog } from "tabler-react";
import { useRegisterSpotlightActions } from "@homarr/spotlight";
const UserMenu = () => {
useRegisterSpotlightActions("header-user-menu", [
{
id: "user-preferences",
title: (t) => t("user.preferences.title"),
description: (t) => t("user.preferences.description"),
icon: IconUserCog,
group: "action",
type: "link",
href: "/user/preferences",
},
]);
return <div>Component implementation</div>;
};
```
##### Using dependency array
```tsx
"use client";
import { IconUserCog } from "tabler-react";
import { useRegisterSpotlightActions } from "@homarr/spotlight";
const ColorSchemeButton = () => {
const { colorScheme, toggleColorScheme } = useColorScheme();
useRegisterSpotlightActions(
"toggle-color-scheme",
[
{
id: "toggle-color-scheme",
title: (t) => t("common.colorScheme.toggle.title"),
description: (t) => t(`common.colorScheme.toggle.${colorScheme}.description`),
icon: colorScheme === "light" ? IconSun : IconMoon,
group: "action",
type: "button",
onClick: toggleColorScheme,
},
],
[colorScheme],
);
return <div>Component implementation</div>;
};
```

View File

@@ -21,8 +21,13 @@
"lint": "eslint",
"typecheck": "tsc --noEmit"
},
"prettier": "@homarr/prettier-config",
"dependencies": {
"@homarr/api": "workspace:^0.1.0",
"@homarr/auth": "workspace:^0.1.0",
"@homarr/common": "workspace:^0.1.0",
"@homarr/definitions": "workspace:^0.1.0",
"@homarr/modals": "workspace:^0.1.0",
"@homarr/modals-collection": "workspace:^0.1.0",
"@homarr/translation": "workspace:^0.1.0",
"@homarr/ui": "workspace:^0.1.0",
"@mantine/core": "^7.12.2",
@@ -40,5 +45,6 @@
"@homarr/tsconfig": "workspace:^0.1.0",
"eslint": "^9.10.0",
"typescript": "^5.6.2"
}
},
"prettier": "@homarr/prettier-config"
}

View File

@@ -1,44 +0,0 @@
import { Chip } from "@mantine/core";
import { useScopedI18n } from "@homarr/translation/client";
import { selectNextAction, selectPreviousAction, spotlightStore, triggerSelectedAction } from "./spotlight-store";
import type { SpotlightActionGroup } from "./type";
const disableArrowUpAndDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
if (event.key === "ArrowDown") {
selectNextAction(spotlightStore);
event.preventDefault();
} else if (event.key === "ArrowUp") {
selectPreviousAction(spotlightStore);
event.preventDefault();
} else if (event.key === "Enter") {
triggerSelectedAction(spotlightStore);
}
};
const focusActiveByDefault = (event: React.FocusEvent<HTMLInputElement>) => {
const relatedTarget = event.relatedTarget;
const isPreviousTargetRadio = relatedTarget && "type" in relatedTarget && relatedTarget.type === "radio";
if (isPreviousTargetRadio) return;
const group = event.currentTarget.parentElement?.parentElement;
if (!group) return;
const label = group.querySelector<HTMLLabelElement>("label[data-checked]");
if (!label) return;
label.focus();
};
interface Props {
group: SpotlightActionGroup;
}
export const GroupChip = ({ group }: Props) => {
const t = useScopedI18n("common.search.group");
return (
<Chip key={group} value={group} onFocus={focusActiveByDefault} onKeyDown={disableArrowUpAndDown}>
{t(group)}
</Chip>
);
};

View File

@@ -1,126 +0,0 @@
"use client";
import { useCallback, useState } from "react";
import Link from "next/link";
import { Center, Chip, Divider, Flex, Group, Text } from "@mantine/core";
import { Spotlight as MantineSpotlight, SpotlightAction } from "@mantine/spotlight";
import { IconSearch } from "@tabler/icons-react";
import { useAtomValue } from "jotai";
import type { TranslationFunction } from "@homarr/translation";
import { useI18n } from "@homarr/translation/client";
import { GroupChip } from "./chip-group";
import classes from "./component.module.css";
import { actionsAtomRead, groupsAtomRead } from "./data-store";
import { setSelectedAction, spotlightStore } from "./spotlight-store";
import type { SpotlightActionData } from "./type";
import { useWebSearchEngines } from "./web-search-engines";
export const Spotlight = () => {
useWebSearchEngines();
const [query, setQuery] = useState("");
const [group, setGroup] = useState("all");
const groups = useAtomValue(groupsAtomRead);
const actions = useAtomValue(actionsAtomRead);
const t = useI18n();
const preparedActions = actions.map((action) => prepareAction(action, t));
const items = preparedActions
.filter(
(item) =>
(item.ignoreSearchAndOnlyShowInGroup
? item.group === group
: item.title.toLowerCase().includes(query.toLowerCase().trim())) &&
(group === "all" || item.group === group),
)
.map((item) => {
const renderRoot =
item.type === "link"
? (props: Record<string, unknown>) => (
<Link href={prepareHref(item.href, query)} target={item.openInNewTab ? "_blank" : undefined} {...props} />
)
: undefined;
return (
<SpotlightAction
key={item.id}
renderRoot={renderRoot}
onClick={item.type === "button" ? item.onClick : undefined}
className={classes.spotlightAction}
>
<Group wrap="nowrap" w="100%">
{item.icon && (
<Center w={50} h={50}>
{typeof item.icon !== "string" && <item.icon size={24} />}
{typeof item.icon === "string" && <img src={item.icon} alt={item.title} width={24} height={24} />}
</Center>
)}
<Flex direction="column">
<Text>{item.title}</Text>
{item.description && (
<Text opacity={0.6} size="xs">
{item.description}
</Text>
)}
</Flex>
</Group>
</SpotlightAction>
);
});
const onGroupChange = useCallback(
(group: string) => {
setSelectedAction(-1, spotlightStore);
setGroup(group);
},
[setGroup, setSelectedAction],
);
return (
<MantineSpotlight.Root query={query} onQueryChange={setQuery} store={spotlightStore}>
<MantineSpotlight.Search
placeholder={t("common.rtl", {
value: t("common.search.placeholder"),
symbol: "...",
})}
leftSection={<IconSearch stroke={1.5} />}
/>
<Divider />
<Group wrap="nowrap" p="sm">
<Chip.Group multiple={false} value={group} onChange={onGroupChange}>
<Group justify="start">
{groups.map((group) => (
<GroupChip key={group} group={group} />
))}
</Group>
</Chip.Group>
</Group>
<MantineSpotlight.ActionsList>
{items.length > 0 ? items : <MantineSpotlight.Empty>{t("common.search.nothingFound")}</MantineSpotlight.Empty>}
</MantineSpotlight.ActionsList>
</MantineSpotlight.Root>
);
};
const prepareHref = (href: string, query: string) => {
return href.replace("%s", query);
};
const translateIfNecessary = (value: string | ((t: TranslationFunction) => string), t: TranslationFunction) => {
if (typeof value === "function") {
return value(t);
}
return value;
};
const prepareAction = (action: SpotlightActionData, t: TranslationFunction) => ({
...action,
title: translateIfNecessary(action.title, t),
description: translateIfNecessary(action.description, t),
});

View File

@@ -0,0 +1,17 @@
import type { inferSearchInteractionOptions } from "../../lib/interaction";
import { ChildrenActionItem } from "./items/children-action-item";
interface SpotlightChildrenActionsProps {
childrenOptions: inferSearchInteractionOptions<"children">;
query: string;
}
export const SpotlightChildrenActions = ({ childrenOptions, query }: SpotlightChildrenActionsProps) => {
const actions = childrenOptions.useActions(childrenOptions.option, query);
return actions
.filter((action) => (typeof action.hide === "function" ? !action.hide(childrenOptions.option) : !action.hide))
.map((action) => (
<ChildrenActionItem key={action.key} childrenOptions={childrenOptions} query={query} action={action} />
));
};

View File

@@ -0,0 +1,87 @@
import { Center, Loader } from "@mantine/core";
import type { TranslationObject } from "@homarr/translation";
import { useI18n } from "@homarr/translation/client";
import type { SearchGroup } from "../../lib/group";
import type { inferSearchInteractionOptions } from "../../lib/interaction";
import { SpotlightNoResults } from "../no-results";
import { SpotlightGroupActionItem } from "./items/group-action-item";
interface GroupActionsProps<TOption extends Record<string, unknown>> {
group: SearchGroup<TOption>;
query: string;
setMode: (mode: keyof TranslationObject["search"]["mode"]) => void;
setChildrenOptions: (options: inferSearchInteractionOptions<"children">) => void;
}
export const SpotlightGroupActions = <TOption extends Record<string, unknown>>({
group,
query,
setMode,
setChildrenOptions,
}: GroupActionsProps<TOption>) => {
// This does work as the same amount of hooks is called on every render
const useOptions =
"options" in group ? () => group.options : "useOptions" in group ? group.useOptions : group.useQueryOptions;
const options = useOptions(query);
const t = useI18n();
if (Array.isArray(options)) {
const filteredOptions = options
.filter((option) => ("filter" in group ? group.filter(query, option) : false))
.sort((optionA, optionB) => {
if ("sort" in group) {
return group.sort?.(query, [optionA, optionB]) ?? 0;
}
return 0;
});
if (filteredOptions.length === 0) {
return <SpotlightNoResults />;
}
return filteredOptions.map((option) => (
<SpotlightGroupActionItem
key={option[group.keyPath] as never}
option={option}
group={group}
query={query}
setMode={setMode}
setChildrenOptions={setChildrenOptions}
/>
));
}
if (options.isLoading) {
return (
<Center w="100%" py="sm">
<Loader size="sm" />
</Center>
);
}
if (options.isError) {
return <Center py="sm">{t("search.error.fetch")}</Center>;
}
if (!options.data) {
return null;
}
if (options.data.length === 0) {
return <SpotlightNoResults />;
}
return options.data.map((option) => (
<SpotlightGroupActionItem
key={option[group.keyPath] as never}
option={option}
group={group}
query={query}
setMode={setMode}
setChildrenOptions={setChildrenOptions}
/>
));
};

View File

@@ -0,0 +1,32 @@
import { Spotlight } from "@mantine/spotlight";
import type { TranslationObject } from "@homarr/translation";
import { translateIfNecessary } from "@homarr/translation";
import { useI18n } from "@homarr/translation/client";
import type { SearchGroup } from "../../../lib/group";
import type { inferSearchInteractionOptions } from "../../../lib/interaction";
import { SpotlightGroupActions } from "../group-actions";
interface SpotlightActionGroupsProps {
groups: SearchGroup[];
query: string;
setMode: (mode: keyof TranslationObject["search"]["mode"]) => void;
setChildrenOptions: (options: inferSearchInteractionOptions<"children">) => void;
}
export const SpotlightActionGroups = ({ groups, query, setMode, setChildrenOptions }: 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}
/>
</Spotlight.ActionsGroup>
));
};

View File

@@ -0,0 +1,30 @@
import Link from "next/link";
import { Spotlight } from "@mantine/spotlight";
import type { inferSearchInteractionOptions } from "../../../lib/interaction";
import classes from "./action-item.module.css";
interface ChildrenActionItemProps {
childrenOptions: inferSearchInteractionOptions<"children">;
query: string;
action: ReturnType<inferSearchInteractionOptions<"children">["useActions"]>[number];
}
export const ChildrenActionItem = ({ childrenOptions, action, query }: ChildrenActionItemProps) => {
const interaction = action.useInteraction(childrenOptions.option, query);
const renderRoot =
interaction.type === "link"
? (props: Record<string, unknown>) => {
return <Link href={interaction.href} target={interaction.newTab ? "_blank" : undefined} {...props} />;
}
: undefined;
const onClick = interaction.type === "javaScript" ? interaction.onSelect : undefined;
return (
<Spotlight.Action renderRoot={renderRoot} onClick={onClick} className={classes.spotlightAction}>
<action.component {...childrenOptions.option} />
</Spotlight.Action>
);
};

View File

@@ -0,0 +1,54 @@
import Link from "next/link";
import { Spotlight } from "@mantine/spotlight";
import type { TranslationObject } from "@homarr/translation";
import type { SearchGroup } from "../../../lib/group";
import type { inferSearchInteractionOptions } from "../../../lib/interaction";
import classes from "./action-item.module.css";
interface SpotlightGroupActionItemProps<TOption extends Record<string, unknown>> {
option: TOption;
query: string;
setMode: (mode: keyof TranslationObject["search"]["mode"]) => void;
setChildrenOptions: (options: inferSearchInteractionOptions<"children">) => void;
group: SearchGroup<TOption>;
}
export const SpotlightGroupActionItem = <TOption extends Record<string, unknown>>({
group,
query,
setMode,
setChildrenOptions,
option,
}: SpotlightGroupActionItemProps<TOption>) => {
const interaction = group.useInteraction(option, query);
const renderRoot =
interaction.type === "link"
? (props: Record<string, unknown>) => {
return <Link href={interaction.href} target={interaction.newTab ? "_blank" : undefined} {...props} />;
}
: undefined;
const handleClickAsync = async () => {
if (interaction.type === "javaScript") {
await interaction.onSelect();
} else if (interaction.type === "mode") {
setMode(interaction.mode);
} else if (interaction.type === "children") {
setChildrenOptions(interaction);
}
};
return (
<Spotlight.Action
renderRoot={renderRoot}
onClick={handleClickAsync}
closeSpotlightOnTrigger={interaction.type !== "mode" && interaction.type !== "children"}
className={classes.spotlightAction}
>
<group.component {...option} />
</Spotlight.Action>
);
};

View File

@@ -0,0 +1,9 @@
import { Spotlight } from "@mantine/spotlight";
import { useI18n } from "@homarr/translation/client";
export const SpotlightNoResults = () => {
const t = useI18n();
return <Spotlight.Empty>{t("search.nothingFound")}</Spotlight.Empty>;
};

View File

@@ -0,0 +1,118 @@
"use client";
import { useMemo, useRef, useState } from "react";
import { ActionIcon, Center, Group, Kbd } from "@mantine/core";
import { Spotlight as MantineSpotlight } from "@mantine/spotlight";
import { IconSearch, IconX } from "@tabler/icons-react";
import type { TranslationObject } from "@homarr/translation";
import { useI18n } from "@homarr/translation/client";
import type { inferSearchInteractionOptions } from "../lib/interaction";
import { searchModes } from "../modes";
import { selectAction, spotlightStore } from "../spotlight-store";
import { SpotlightChildrenActions } from "./actions/children-actions";
import { SpotlightActionGroups } from "./actions/groups/action-group";
export const Spotlight = () => {
const [query, setQuery] = useState("");
const [mode, setMode] = useState<keyof TranslationObject["search"]["mode"]>("help");
const [childrenOptions, setChildrenOptions] = useState<inferSearchInteractionOptions<"children"> | null>(null);
const t = useI18n();
const inputRef = useRef<HTMLInputElement>(null);
const activeMode = useMemo(() => searchModes.find((searchMode) => searchMode.modeKey === mode), [mode]);
if (!activeMode) {
return null;
}
return (
<MantineSpotlight.Root
onSpotlightClose={() => {
setMode("help");
setChildrenOptions(null);
}}
query={query}
onQueryChange={(query) => {
if (mode !== "help" || query.length !== 1) {
setQuery(query);
}
const modeToActivate = searchModes.find((mode) => mode.character === query);
if (!modeToActivate) {
return;
}
setMode(modeToActivate.modeKey);
setQuery("");
setTimeout(() => selectAction(0, spotlightStore));
}}
store={spotlightStore}
>
<MantineSpotlight.Search
placeholder={t("common.rtl", {
value: t("search.placeholder"),
symbol: "...",
})}
ref={inputRef}
leftSectionWidth={activeMode.modeKey !== "help" ? 80 : 48}
leftSection={
<Group align="center" wrap="nowrap" gap="xs" w="100%" h="100%">
<Center w={48} h="100%">
<IconSearch stroke={1.5} />
</Center>
{activeMode.modeKey !== "help" ? <Kbd size="sm">{activeMode.character}</Kbd> : null}
</Group>
}
rightSection={
mode === "help" ? undefined : (
<ActionIcon
onClick={() => {
setMode("help");
setChildrenOptions(null);
inputRef.current?.focus();
}}
variant="subtle"
>
<IconX stroke={1.5} />
</ActionIcon>
)
}
value={query}
onKeyDown={(event) => {
if (query.length === 0 && mode !== "help" && event.key === "Backspace") {
setMode("help");
setChildrenOptions(null);
}
}}
/>
{childrenOptions ? (
<Group>
<childrenOptions.detailComponent options={childrenOptions.option as never} />
</Group>
) : null}
<MantineSpotlight.ActionsList>
{childrenOptions ? (
<SpotlightChildrenActions childrenOptions={childrenOptions} query={query} />
) : (
<SpotlightActionGroups
setMode={(mode) => {
setMode(mode);
setChildrenOptions(null);
setTimeout(() => selectAction(0, spotlightStore));
}}
setChildrenOptions={(options) => {
setChildrenOptions(options);
setQuery("");
setTimeout(() => selectAction(0, spotlightStore));
}}
query={query}
groups={activeMode.groups}
/>
)}
</MantineSpotlight.ActionsList>
</MantineSpotlight.Root>
);
};

View File

@@ -1,69 +0,0 @@
import { useEffect } from "react";
import { atom, useSetAtom } from "jotai";
import useDeepCompareEffect from "use-deep-compare-effect";
import type { SpotlightActionData, SpotlightActionGroup } from "./type";
const defaultGroups = ["all", "web", "action"] as const;
const reversedDefaultGroups = [...defaultGroups].reverse() as string[];
const actionsAtom = atom<Record<string, readonly SpotlightActionData[]>>({});
export const actionsAtomRead = atom((get) => Object.values(get(actionsAtom)).flatMap((item) => item));
export const groupsAtomRead = atom((get) =>
Array.from(
new Set(
get(actionsAtomRead)
.map((item) => item.group as SpotlightActionGroup) // Allow "all" group to be included in the list of groups
.concat(...defaultGroups),
),
)
.sort((groupA, groupB) => {
const groupAIndex = reversedDefaultGroups.indexOf(groupA);
const groupBIndex = reversedDefaultGroups.indexOf(groupB);
// if both groups are not in the default groups, sort them by name (here reversed because we reverse the array afterwards)
if (groupAIndex === -1 && groupBIndex === -1) {
return groupB.localeCompare(groupA);
}
return groupAIndex - groupBIndex;
})
.reverse(),
);
const registrations = new Map<string, number>();
export const useRegisterSpotlightActions = (
key: string,
actions: SpotlightActionData[],
dependencies: readonly unknown[] = [],
) => {
const setActions = useSetAtom(actionsAtom);
// Use deep compare effect if there are dependencies for the actions, this supports deep compare of the action dependencies
const useSpecificEffect = dependencies.length >= 1 ? useDeepCompareEffect : useEffect;
useSpecificEffect(() => {
if (!registrations.has(key) || dependencies.length >= 1) {
setActions((prev) => ({
...prev,
[key]: actions,
}));
}
registrations.set(key, (registrations.get(key) ?? 0) + 1);
return () => {
if (registrations.get(key) === 1) {
setActions((prev) => {
const { [key]: _, ...rest } = prev;
return rest;
});
}
registrations.set(key, (registrations.get(key) ?? 0) - 1);
if (registrations.get(key) === 0) {
registrations.delete(key);
}
};
}, [key, dependencies.length >= 1 ? dependencies : undefined]);
};

View File

@@ -2,8 +2,7 @@
import { spotlightActions } from "./spotlight-store";
export { Spotlight } from "./component";
export { useRegisterSpotlightActions } from "./data-store";
export { Spotlight } from "./components/spotlight";
export { openSpotlight };
const openSpotlight = spotlightActions.open;

View File

@@ -0,0 +1,24 @@
import type { ReactNode } from "react";
import type { inferSearchInteractionDefinition } from "./interaction";
export interface CreateChildrenOptionsProps<TParentOptions extends Record<string, unknown>> {
detailComponent: ({ options }: { options: TParentOptions }) => ReactNode;
useActions: (options: TParentOptions, query: string) => ChildrenAction<TParentOptions>[];
}
export interface ChildrenAction<TParentOptions extends Record<string, unknown>> {
key: string;
component: (option: TParentOptions) => JSX.Element;
useInteraction: (option: TParentOptions, query: string) => inferSearchInteractionDefinition<"link" | "javaScript">;
hide?: boolean | ((option: TParentOptions) => boolean);
}
export const createChildrenOptions = <TParentOptions extends Record<string, unknown>>(
props: CreateChildrenOptionsProps<TParentOptions>,
) => {
return (option: TParentOptions) => ({
option,
...props,
});
};

View File

@@ -0,0 +1,28 @@
import type { UseTRPCQueryResult } from "@trpc/react-query/shared";
import type { stringOrTranslation } from "@homarr/translation";
import type { inferSearchInteractionDefinition, 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
keyPath: keyof TOption;
title: stringOrTranslation;
component: (option: TOption) => JSX.Element;
useInteraction: (option: TOption, query: string) => inferSearchInteractionDefinition<SearchInteraction>;
} & TOptionProps;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export type SearchGroup<TOption extends Record<string, unknown> = any> =
| CommonSearchGroup<TOption, { filter: (query: string, option: TOption) => boolean; options: TOption[] }>
| CommonSearchGroup<
TOption,
{
filter: (query: string, option: TOption) => boolean;
sort?: (query: string, options: [TOption, TOption]) => number;
useOptions: () => TOption[];
}
>
| CommonSearchGroup<TOption, { useQueryOptions: (query: string) => UseTRPCQueryResult<TOption[], unknown> }>;
export const createGroup = <TOption extends Record<string, unknown>>(group: SearchGroup<TOption>) => group;

View File

@@ -0,0 +1,56 @@
import type { MaybePromise } from "@homarr/common/types";
import type { TranslationObject } from "@homarr/translation";
import type { CreateChildrenOptionsProps } from "./children";
const createSearchInteraction = <TType extends string>(type: TType) => ({
optionsType: <TOption extends Record<string, unknown>>() => ({ type, _inferOptions: {} as TOption }),
});
// This is used to define search interactions with their options
const searchInteractions = [
createSearchInteraction("link").optionsType<{ href: string; newTab?: boolean }>(),
createSearchInteraction("javaScript").optionsType<{ onSelect: () => MaybePromise<void> }>(),
createSearchInteraction("mode").optionsType<{ mode: keyof TranslationObject["search"]["mode"] }>(),
createSearchInteraction("children").optionsType<{
// eslint-disable-next-line @typescript-eslint/no-explicit-any
useActions: CreateChildrenOptionsProps<any>["useActions"];
// eslint-disable-next-line @typescript-eslint/no-explicit-any
detailComponent: CreateChildrenOptionsProps<any>["detailComponent"];
// eslint-disable-next-line @typescript-eslint/no-explicit-any
option: any;
}>(),
] as const;
// Union of all search interactions types
export type SearchInteraction = (typeof searchInteractions)[number]["type"];
// Infer the options for the specified search interaction
export type inferSearchInteractionOptions<TInteraction extends SearchInteraction> = Extract<
(typeof searchInteractions)[number],
{ type: TInteraction }
>["_inferOptions"];
// Infer the search interaction definition (type + options) for the specified search interaction
export type inferSearchInteractionDefinition<TInteraction extends SearchInteraction> = {
[interactionKey in TInteraction]: { type: interactionKey } & inferSearchInteractionOptions<interactionKey>;
}[TInteraction];
// Type used for helper functions to define basic search interactions
type SearchInteractions = {
[optionKey in SearchInteraction]: <TOption extends Record<string, unknown>>(
callback: (option: TOption, query: string) => inferSearchInteractionOptions<optionKey>,
) => (option: TOption, query: string) => inferSearchInteractionDefinition<optionKey>;
};
// Helper functions to define basic search interactions
export const interaction = searchInteractions.reduce((acc, interaction) => {
return {
...acc,
[interaction.type]: <TOption extends Record<string, unknown>>(
callback: (option: TOption, query: string) => inferSearchInteractionOptions<SearchInteraction>,
) => {
return (option: TOption, query: string) => ({ type: interaction.type, ...callback(option, query) });
},
};
}, {} as SearchInteractions);

View File

@@ -0,0 +1,9 @@
import type { TranslationObject } from "@homarr/translation";
import type { SearchGroup } from "./group";
export interface SearchMode {
modeKey: keyof TranslationObject["search"]["mode"];
character: string;
groups: SearchGroup[];
}

View File

@@ -0,0 +1,97 @@
import { Avatar, Group, Stack, Text } from "@mantine/core";
import { IconExternalLink, IconEye } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
import { useI18n } 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 App = { id: string; name: string; iconUrl: string; href: string | null };
const appChildrenOptions = createChildrenOptions<App>({
useActions: () => [
{
key: "open",
component: () => {
const t = useI18n();
return (
<Group mx="md" my="sm">
<IconExternalLink stroke={1.5} />
<Text>{t("search.mode.appIntegrationBoard.group.app.children.action.open.label")}</Text>
</Group>
);
},
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
useInteraction: interaction.link((option) => ({ href: option.href! })),
hide(option) {
return !option.href;
},
},
{
key: "edit",
component: () => {
const t = useI18n();
return (
<Group mx="md" my="sm">
<IconEye stroke={1.5} />
<Text>{t("search.mode.appIntegrationBoard.group.app.children.action.edit.label")}</Text>
</Group>
);
},
useInteraction: interaction.link(({ id }) => ({ href: `/manage/apps/edit/${id}` })),
},
],
detailComponent: ({ options }) => {
const t = useI18n();
return (
<Stack mx="md" my="sm">
<Text>{t("search.mode.appIntegrationBoard.group.app.children.detail.title")}</Text>
<Group>
<Avatar
size="sm"
src={options.iconUrl}
radius={0}
styles={{
image: {
objectFit: "contain",
},
}}
/>
<Text>{options.name}</Text>
</Group>
</Stack>
);
},
});
export const appsSearchGroup = createGroup<App>({
keyPath: "id",
title: (t) => t("search.mode.appIntegrationBoard.group.app.title"),
component: (app) => (
<Group px="md" py="sm">
<Avatar
size="sm"
src={app.iconUrl}
radius={0}
styles={{
image: {
objectFit: "contain",
},
}}
/>
<Text>{app.name}</Text>
</Group>
),
useInteraction: interaction.children(appChildrenOptions),
useQueryOptions(query) {
return clientApi.app.search.useQuery({ query, limit: 5 });
},
});

View File

@@ -0,0 +1,120 @@
import { Group, Stack, Text } from "@mantine/core";
import { IconHome, IconLayoutDashboard, IconLink, IconSettings } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
import { useI18n } from "@homarr/translation/client";
import type { ChildrenAction } from "../../lib/children";
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 Board = {
id: string;
name: string;
logoImageUrl: string | null;
permissions: { hasFullAccess: boolean; hasChangeAccess: boolean; hasViewAccess: boolean };
};
const boardChildrenOptions = createChildrenOptions<Board>({
useActions: (options) => {
const actions: (ChildrenAction<Board> & { hidden?: boolean })[] = [
{
key: "open",
component: () => {
const t = useI18n();
return (
<Group mx="md" my="sm">
<IconLink stroke={1.5} />
<Text>{t("search.mode.appIntegrationBoard.group.board.children.action.open.label")}</Text>
</Group>
);
},
useInteraction: interaction.link(({ name }) => ({ href: `/boards/${name}` })),
},
{
key: "homeBoard",
component: () => {
const t = useI18n();
return (
<Group mx="md" my="sm">
<IconHome stroke={1.5} />
<Text>{t("search.mode.appIntegrationBoard.group.board.children.action.homeBoard.label")}</Text>
</Group>
);
},
useInteraction(option) {
const { mutateAsync } = clientApi.board.setHomeBoard.useMutation();
return {
type: "javaScript",
// eslint-disable-next-line no-restricted-syntax
async onSelect() {
await mutateAsync({ id: option.id });
},
};
},
},
{
key: "settings",
component: () => {
const t = useI18n();
return (
<Group mx="md" my="sm">
<IconSettings stroke={1.5} />
<Text>{t("search.mode.appIntegrationBoard.group.board.children.action.settings.label")}</Text>
</Group>
);
},
useInteraction: interaction.link(({ name }) => ({ href: `/boards/${name}/settings` })),
hidden: !options.permissions.hasChangeAccess,
},
];
return actions;
},
detailComponent: ({ options: board }) => {
const t = useI18n();
return (
<Stack mx="md" my="sm">
<Text>{t("search.mode.appIntegrationBoard.group.board.children.detail.title")}</Text>
<Group>
{board.logoImageUrl ? (
<img src={board.logoImageUrl} alt={board.name} width={24} height={24} />
) : (
<IconLayoutDashboard size={24} />
)}
<Text>{board.name}</Text>
</Group>
</Stack>
);
},
});
export const boardsSearchGroup = createGroup<Board>({
keyPath: "id",
title: "Boards",
component: (board) => (
<Group px="md" py="sm">
{board.logoImageUrl ? (
<img src={board.logoImageUrl} alt={board.name} width={24} height={24} />
) : (
<IconLayoutDashboard size={24} />
)}
<Text>{board.name}</Text>
</Group>
),
useInteraction: interaction.children(boardChildrenOptions),
useQueryOptions(query) {
return clientApi.board.search.useQuery({ query, limit: 5 });
},
});

View File

@@ -0,0 +1,10 @@
import type { SearchMode } from "../../lib/mode";
import { appsSearchGroup } from "./apps-search-group";
import { boardsSearchGroup } from "./boards-search-group";
import { integrationsSearchGroup } from "./integrations-search-group";
export const appIntegrationBoardMode = {
modeKey: "appIntegrationBoard",
character: "#",
groups: [appsSearchGroup, integrationsSearchGroup, boardsSearchGroup],
} satisfies SearchMode;

View File

@@ -0,0 +1,24 @@
import { Group, Text } from "@mantine/core";
import { clientApi } from "@homarr/api/client";
import type { IntegrationKind } from "@homarr/definitions";
import { IntegrationAvatar } from "@homarr/ui";
import { createGroup } from "../../lib/group";
import { interaction } from "../../lib/interaction";
export const integrationsSearchGroup = createGroup<{ id: string; kind: IntegrationKind; name: string }>({
keyPath: "id",
title: (t) => t("search.mode.appIntegrationBoard.group.integration.title"),
component: (integration) => (
<Group px="md" py="sm">
<IntegrationAvatar size="sm" kind={integration.kind} />
<Text>{integration.name}</Text>
</Group>
),
useInteraction: interaction.link(({ id }) => ({ href: `/manage/integrations/edit/${id}` })),
useQueryOptions(query) {
return clientApi.integration.search.useQuery({ query, limit: 5 });
},
});

View File

@@ -0,0 +1,65 @@
import { Group, Stack, Text } from "@mantine/core";
import { IconCheck } from "@tabler/icons-react";
import { localeAttributes, supportedLanguages } from "@homarr/translation";
import { useChangeLocale, useCurrentLocale, useI18n } from "@homarr/translation/client";
import { createChildrenOptions } from "../../../lib/children";
export const languageChildrenOptions = createChildrenOptions<Record<string, unknown>>({
useActions: (_, query) => {
const normalizedQuery = query.trim().toLowerCase();
const currentLocale = useCurrentLocale();
return supportedLanguages
.map((localeKey) => ({ localeKey, attributes: localeAttributes[localeKey] }))
.filter(
({ attributes }) =>
attributes.name.toLowerCase().includes(normalizedQuery) ||
attributes.translatedName.toLowerCase().includes(normalizedQuery),
)
.sort(
(languageA, languageB) =>
Math.min(
languageA.attributes.name.toLowerCase().indexOf(normalizedQuery),
languageA.attributes.translatedName.toLowerCase().indexOf(normalizedQuery),
) -
Math.min(
languageB.attributes.name.toLowerCase().indexOf(normalizedQuery),
languageB.attributes.translatedName.toLowerCase().indexOf(normalizedQuery),
),
)
.map(({ localeKey, attributes }) => ({
key: localeKey,
component() {
return (
<Group mx="md" my="sm" wrap="nowrap" justify="space-between" w="100%">
<Group wrap="nowrap">
<span className={`fi fi-${attributes.flagIcon}`} style={{ borderRadius: 4 }}></span>
<Group wrap="nowrap" gap="xs">
<Text>{attributes.name}</Text>
<Text size="xs" c="dimmed" inherit>
({attributes.translatedName})
</Text>
</Group>
</Group>
{localeKey === currentLocale && <IconCheck color="currentColor" size={24} />}
</Group>
);
},
useInteraction() {
const changeLocale = useChangeLocale();
return { type: "javaScript", onSelect: () => changeLocale(localeKey) };
},
}));
},
detailComponent: () => {
const t = useI18n();
return (
<Stack mx="md" my="sm">
<Text>{t("search.mode.command.group.globalCommand.option.language.children.detail.title")}</Text>
</Stack>
);
},
});

View File

@@ -0,0 +1,43 @@
import { Group, Stack, Text } from "@mantine/core";
import { objectEntries } from "@homarr/common";
import { integrationDefs } from "@homarr/definitions";
import { useI18n } from "@homarr/translation/client";
import { IntegrationAvatar } from "@homarr/ui";
import { createChildrenOptions } from "../../../lib/children";
import { interaction } from "../../../lib/interaction";
export const newIntegrationChildrenOptions = createChildrenOptions<Record<string, unknown>>({
useActions: (_, query) => {
const normalizedQuery = query.trim().toLowerCase();
return objectEntries(integrationDefs)
.filter(([, integrationDef]) => integrationDef.name.toLowerCase().includes(normalizedQuery))
.sort(
([, definitionA], [, definitionB]) =>
definitionA.name.toLowerCase().indexOf(normalizedQuery) -
definitionB.name.toLowerCase().indexOf(normalizedQuery),
)
.map(([kind, integrationDef]) => ({
key: kind,
component() {
return (
<Group mx="md" my="sm" wrap="nowrap" w="100%">
<IntegrationAvatar kind={kind} size="sm" />
<Text>{integrationDef.name}</Text>
</Group>
);
},
useInteraction: interaction.link(() => ({ href: `/manage/integrations/new?kind=${kind}` })),
}));
},
detailComponent() {
const t = useI18n();
return (
<Stack mx="md" my="sm">
<Text>{t("search.mode.command.group.globalCommand.option.newIntegration.children.detail.title")}</Text>
</Stack>
);
},
});

View File

@@ -0,0 +1,164 @@
import { Group, Text, useMantineColorScheme } from "@mantine/core";
import {
IconCategoryPlus,
IconFileImport,
IconLanguage,
IconMailForward,
IconMoon,
IconPackage,
IconPlug,
IconSun,
IconUserPlus,
IconUsersGroup,
} from "@tabler/icons-react";
import { useModalAction } from "@homarr/modals";
import { AddBoardModal, AddGroupModal, ImportBoardModal, InviteCreateModal } from "@homarr/modals-collection";
import { useScopedI18n } from "@homarr/translation/client";
import type { TablerIcon } from "@homarr/ui";
import { createGroup } from "../../lib/group";
import type { inferSearchInteractionDefinition, SearchInteraction } from "../../lib/interaction";
import { interaction } from "../../lib/interaction";
import type { SearchMode } from "../../lib/mode";
import { languageChildrenOptions } from "./children/language";
import { newIntegrationChildrenOptions } from "./children/new-integration";
// This has to be type so it can be interpreted as Record<string, unknown>.
// eslint-disable-next-line @typescript-eslint/consistent-type-definitions
type Command<TSearchInteraction extends SearchInteraction = SearchInteraction> = {
commandKey: string;
icon: TablerIcon;
name: string;
useInteraction: (
_c: Command<TSearchInteraction>,
query: string,
) => inferSearchInteractionDefinition<TSearchInteraction>;
};
export const commandMode = {
modeKey: "command",
character: ">",
groups: [
createGroup<Command>({
keyPath: "commandKey",
title: "Global commands",
useInteraction: (option, query) => option.useInteraction(option, query),
component: ({ icon: Icon, name }) => (
<Group px="md" py="sm">
<Icon stroke={1.5} />
<Text>{name}</Text>
</Group>
),
filter(query, option) {
return option.name.toLowerCase().includes(query.toLowerCase());
},
useOptions() {
const tOption = useScopedI18n("search.mode.command.group.globalCommand.option");
const { colorScheme } = useMantineColorScheme();
const commands: (Command & { hidden?: boolean })[] = [
{
commandKey: "colorScheme",
icon: colorScheme === "dark" ? IconSun : IconMoon,
name: tOption(`colorScheme.${colorScheme === "dark" ? "light" : "dark"}`),
useInteraction: () => {
const { toggleColorScheme } = useMantineColorScheme();
return {
type: "javaScript",
onSelect: toggleColorScheme,
};
},
},
{
commandKey: "language",
icon: IconLanguage,
name: tOption("language.label"),
useInteraction: interaction.children(languageChildrenOptions),
},
{
commandKey: "newBoard",
icon: IconCategoryPlus,
name: tOption("newBoard.label"),
useInteraction() {
const { openModal } = useModalAction(AddBoardModal);
return {
type: "javaScript",
onSelect() {
openModal(undefined);
},
};
},
},
{
commandKey: "importBoard",
icon: IconFileImport,
name: tOption("importBoard.label"),
useInteraction() {
const { openModal } = useModalAction(ImportBoardModal);
return {
type: "javaScript",
onSelect() {
openModal(undefined);
},
};
},
},
{
commandKey: "newApp",
icon: IconPackage,
name: tOption("newApp.label"),
useInteraction: interaction.link(() => ({ href: "/manage/apps/new" })),
},
{
commandKey: "newIntegration",
icon: IconPlug,
name: tOption("newIntegration.label"),
useInteraction: interaction.children(newIntegrationChildrenOptions),
},
{
commandKey: "newUser",
icon: IconUserPlus,
name: tOption("newUser.label"),
useInteraction: interaction.link(() => ({ href: "/manage/users/new" })),
},
{
commandKey: "newInvite",
icon: IconMailForward,
name: tOption("newInvite.label"),
useInteraction() {
const { openModal } = useModalAction(InviteCreateModal);
return {
type: "javaScript",
onSelect() {
openModal(undefined);
},
};
},
},
{
commandKey: "newGroup",
icon: IconUsersGroup,
name: tOption("newGroup.label"),
useInteraction() {
const { openModal } = useModalAction(AddGroupModal);
return {
type: "javaScript",
onSelect() {
openModal(undefined);
},
};
},
},
];
return commands.filter((command) => !command.hidden);
},
}),
],
} satisfies SearchMode;

View File

@@ -0,0 +1,8 @@
import type { SearchMode } from "../../lib/mode";
import { searchEnginesSearchGroups } from "./search-engines-search-group";
export const externalMode = {
modeKey: "external",
character: "!",
groups: [searchEnginesSearchGroups],
} satisfies SearchMode;

View File

@@ -0,0 +1,82 @@
import { Group, Stack, Text } from "@mantine/core";
import type { TablerIcon } from "@tabler/icons-react";
import { IconDownload } from "@tabler/icons-react";
import { useScopedI18n } from "@homarr/translation/client";
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;
};
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>
</Group>
</Group>
),
filter: () => true,
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",
},
];
},
});

View File

@@ -0,0 +1,74 @@
import { Group, Kbd, Text } from "@mantine/core";
import { IconBook2, IconBrandDiscord, IconBrandGithub } from "@tabler/icons-react";
import { useScopedI18n } from "@homarr/translation/client";
import { createGroup } from "../lib/group";
import { interaction } from "../lib/interaction";
import type { SearchMode } from "../lib/mode";
import { appIntegrationBoardMode } from "./app-integration-board";
import { commandMode } from "./command";
import { externalMode } from "./external";
import { pageMode } from "./page";
import { userGroupMode } from "./user-group";
const searchModesWithoutHelp = [userGroupMode, appIntegrationBoardMode, externalMode, commandMode, pageMode] as const;
const helpMode = {
modeKey: "help",
character: "?",
groups: [
createGroup({
keyPath: "character",
title: (t) => t("search.mode.help.group.mode.title"),
options: searchModesWithoutHelp.map(({ character, modeKey }) => ({ character, modeKey })),
component: ({ modeKey, character }) => {
const t = useScopedI18n(`search.mode.${modeKey}`);
return (
<Group px="md" py="xs" w="100%" wrap="nowrap" align="center" justify="space-between">
<Text>{t("help")}</Text>
<Kbd size="sm">{character}</Kbd>
</Group>
);
},
filter: () => true,
useInteraction: interaction.mode(({ modeKey }) => ({ mode: modeKey })),
}),
createGroup({
keyPath: "href",
title: (t) => t("search.mode.help.group.help.title"),
useOptions() {
const t = useScopedI18n("search.mode.help.group.help.option");
return [
{
label: t("documentation.label"),
icon: IconBook2,
href: "https://homarr.dev/docs/getting-started/",
},
{
label: t("submitIssue.label"),
icon: IconBrandGithub,
href: "https://github.com/ajnart/homarr/issues/new/choose",
},
{
label: t("discord.label"),
icon: IconBrandDiscord,
href: "https://discord.com/invite/aCsmEV5RgA",
},
];
},
component: (props) => (
<Group px="md" py="xs" w="100%" wrap="nowrap" align="center">
<props.icon />
<Text>{props.label}</Text>
</Group>
),
filter: () => true,
useInteraction: interaction.link(({ href }) => ({ href })),
}),
],
} satisfies SearchMode;
export const searchModes = [...searchModesWithoutHelp, helpMode] as const;

View File

@@ -0,0 +1,8 @@
import type { SearchMode } from "../../lib/mode";
import { pagesSearchGroup } from "./pages-search-group";
export const pageMode = {
modeKey: "page",
character: "/",
groups: [pagesSearchGroup],
} satisfies SearchMode;

View File

@@ -0,0 +1,156 @@
import { Group, Text } from "@mantine/core";
import {
IconBox,
IconBrandDocker,
IconHome,
IconInfoSmall,
IconLayoutDashboard,
IconLogs,
IconMailForward,
IconPlug,
IconReport,
IconSettings,
IconUsers,
IconUsersGroup,
} from "@tabler/icons-react";
import { useSession } from "@homarr/auth/client";
import { useScopedI18n } from "@homarr/translation/client";
import type { TablerIcon } from "@homarr/ui";
import { createGroup } from "../../lib/group";
import { interaction } from "../../lib/interaction";
export const pagesSearchGroup = createGroup<{
icon: TablerIcon;
name: string;
path: string;
}>({
keyPath: "path",
title: (t) => t("search.mode.page.group.page.title"),
component: ({ name, icon: Icon }) => (
<Group px="md" py="sm">
<Icon stroke={1.5} />
<Text>{name}</Text>
</Group>
),
useInteraction: interaction.link(({ path }) => ({ href: path })),
filter: (query, { name, path }) => {
const normalizedQuery = query.trim().toLowerCase();
return name.toLowerCase().includes(normalizedQuery) || path.toLowerCase().includes(normalizedQuery);
},
sort: (query, options) => {
const normalizedQuery = query.trim().toLowerCase();
const nameMatches = options.map((option) => option.name.toLowerCase().includes(normalizedQuery));
const pathMatches = options.map((option) => option.path.toLowerCase().includes(normalizedQuery));
if (nameMatches.every(Boolean) && pathMatches.every(Boolean)) {
return 0;
}
if (nameMatches.every(Boolean) && !pathMatches.every(Boolean)) {
return pathMatches[0] ? -1 : 1;
}
return nameMatches[0] ? -1 : 1;
},
useOptions() {
const { data: session } = useSession();
const t = useScopedI18n("search.mode.page.group.page.option");
const managePages = [
{
icon: IconHome,
path: "/manage",
name: t("manageHome.label"),
},
{
icon: IconLayoutDashboard,
path: "/manage/boards",
name: t("manageBoard.label"),
},
{
icon: IconBox,
path: "/manage/apps",
name: t("manageApp.label"),
hidden: !session,
},
{
icon: IconPlug,
path: "/manage/integrations",
name: t("manageIntegration.label"),
hidden: !session,
},
{
icon: IconUsers,
path: "/manage/users",
name: t("manageUser.label"),
hidden: !session,
},
{
icon: IconMailForward,
path: "/manage/users/invites",
name: t("manageInvite.label"),
hidden: !session?.user.permissions.includes("admin"),
},
{
icon: IconUsersGroup,
path: "/manage/users/groups",
name: t("manageGroup.label"),
hidden: !session,
},
{
icon: IconBrandDocker,
path: "/manage/tools/docker",
name: "Manage Docker",
hidden: !session?.user.permissions.includes("admin"),
},
{
icon: IconPlug,
path: "/manage/tools/api",
name: t("manageApi.label"),
hidden: !session,
},
{
icon: IconLogs,
path: "/manage/tools/logs",
name: t("manageLog.label"),
hidden: !session?.user.permissions.includes("admin"),
},
{
icon: IconReport,
path: "/manage/tools/tasks",
name: t("manageTask.label"),
hidden: !session?.user.permissions.includes("admin"),
},
{
icon: IconSettings,
path: "/manage/settings",
name: t("manageSettings.label"),
hidden: !session?.user.permissions.includes("admin"),
},
{
icon: IconInfoSmall,
path: "/manage/about",
name: t("about.label"),
},
];
const otherPages = [
{
icon: IconHome,
path: "/boards",
name: t("homeBoard.label"),
},
{
icon: IconSettings,
path: `/manage/users/${session?.user.id}/general`,
name: t("preferences.label"),
hidden: !session,
},
];
return otherPages.concat(managePages).filter(({ hidden }) => !hidden);
},
});

View File

@@ -0,0 +1,83 @@
import { Group, Stack, Text } from "@mantine/core";
import { IconEye, IconUsersGroup } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
import { useI18n } 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 Group = { id: string; name: string };
const groupChildrenOptions = createChildrenOptions<Group>({
useActions: () => [
{
key: "detail",
component: () => {
const t = useI18n();
return (
<Group mx="md" my="sm">
<IconEye stroke={1.5} />
<Text>{t("search.mode.userGroup.group.group.children.action.detail.label")}</Text>
</Group>
);
},
useInteraction: interaction.link(({ id }) => ({ href: `/manage/users/groups/${id}` })),
},
{
key: "manageMember",
component: () => {
const t = useI18n();
return (
<Group mx="md" my="sm">
<IconUsersGroup stroke={1.5} />
<Text>{t("search.mode.userGroup.group.group.children.action.manageMember.label")}</Text>
</Group>
);
},
useInteraction: interaction.link(({ id }) => ({ href: `/manage/users/groups/${id}/members` })),
},
{
key: "managePermission",
component: () => {
const t = useI18n();
return (
<Group mx="md" my="sm">
<IconEye stroke={1.5} />
<Text>{t("search.mode.userGroup.group.group.children.action.managePermission.label")}</Text>
</Group>
);
},
useInteraction: interaction.link(({ id }) => ({ href: `/manage/users/groups/${id}/permissions` })),
},
],
detailComponent: ({ options }) => {
const t = useI18n();
return (
<Stack mx="md" my="sm">
<Text>{t("search.mode.userGroup.group.group.children.detail.title")}</Text>
<Group>
<Text>{options.name}</Text>
</Group>
</Stack>
);
},
});
export const groupsSearchGroup = createGroup<Group>({
keyPath: "id",
title: "Groups",
component: ({ name }) => (
<Group px="md" py="sm">
<Text>{name}</Text>
</Group>
),
useInteraction: interaction.children(groupChildrenOptions),
useQueryOptions(query) {
return clientApi.group.search.useQuery({ query, limit: 5 });
},
});

View File

@@ -0,0 +1,9 @@
import type { SearchMode } from "../../lib/mode";
import { groupsSearchGroup } from "./groups-search-group";
import { usersSearchGroup } from "./users-search-group";
export const userGroupMode = {
modeKey: "userGroup",
character: "@",
groups: [usersSearchGroup, groupsSearchGroup],
} satisfies SearchMode;

View File

@@ -0,0 +1,62 @@
import { Group, Stack, Text } from "@mantine/core";
import { IconEye } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
import { useI18n } from "@homarr/translation/client";
import { UserAvatar } from "@homarr/ui";
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 User = { id: string; name: string; image: string | null };
const userChildrenOptions = createChildrenOptions<User>({
useActions: () => [
{
key: "detail",
component: () => {
const t = useI18n();
return (
<Group mx="md" my="sm">
<IconEye stroke={1.5} />
<Text>{t("search.mode.userGroup.group.user.children.action.detail.label")}</Text>
</Group>
);
},
useInteraction: interaction.link(({ id }) => ({ href: `/manage/users/${id}/general` })),
},
],
detailComponent: ({ options }) => {
const t = useI18n();
return (
<Stack mx="md" my="sm">
<Text>{t("search.mode.userGroup.group.user.children.detail.title")}</Text>
<Group>
<UserAvatar user={options} size="sm" />
<Text>{options.name}</Text>
</Group>
</Stack>
);
},
});
export const usersSearchGroup = createGroup<User>({
keyPath: "id",
title: (t) => t("search.mode.userGroup.group.user.title"),
component: (user) => (
<Group px="md" py="sm">
<UserAvatar user={user} size="sm" />
<Text>{user.name}</Text>
</Group>
),
useInteraction: interaction.children(userChildrenOptions),
useQueryOptions(query) {
return clientApi.user.search.useQuery({ query, limit: 5 });
},
});

View File

@@ -1,28 +0,0 @@
import type { TranslationFunction, TranslationObject } from "@homarr/translation";
import type { TablerIcon } from "@homarr/ui";
export type SpotlightActionGroup = keyof TranslationObject["common"]["search"]["group"];
interface BaseSpotlightAction {
id: string;
title: string | ((t: TranslationFunction) => string);
description: string | ((t: TranslationFunction) => string);
group: Exclude<SpotlightActionGroup, "all">; // actions can not be assigned to the "all" group
icon: TablerIcon | string;
ignoreSearchAndOnlyShowInGroup?: boolean;
}
interface SpotlightActionLink extends BaseSpotlightAction {
type: "link";
href: string;
openInNewTab?: boolean;
}
type MaybePromise<T> = T | Promise<T>;
interface SpotlightActionButton extends BaseSpotlightAction {
type: "button";
onClick: () => MaybePromise<void>;
}
export type SpotlightActionData = SpotlightActionLink | SpotlightActionButton;

View File

@@ -1,63 +0,0 @@
import { IconDownload } from "@tabler/icons-react";
import { useRegisterSpotlightActions } from "./data-store";
export const useWebSearchEngines = () => {
useRegisterSpotlightActions("web-search-engines", [
{
id: "google",
title: "Google",
description: "Search the web with Google",
icon: "https://www.google.com/favicon.ico",
href: "https://www.google.com/search?q=%s",
group: "web",
type: "link",
ignoreSearchAndOnlyShowInGroup: true,
openInNewTab: true,
},
{
id: "bing",
title: "Bing",
description: "Search the web with Bing",
icon: "https://www.bing.com/favicon.ico",
href: "https://www.bing.com/search?q=%s",
group: "web",
type: "link",
ignoreSearchAndOnlyShowInGroup: true,
openInNewTab: true,
},
{
id: "duckduckgo",
title: "DuckDuckGo",
description: "Search the web with DuckDuckGo",
icon: "https://duckduckgo.com/favicon.ico",
href: "https://duckduckgo.com/?q=%s",
group: "web",
type: "link",
ignoreSearchAndOnlyShowInGroup: true,
openInNewTab: true,
},
{
id: "torrent",
title: "Torrents",
description: "Search for torrents on torrentdownloads.pro",
icon: IconDownload,
href: "https://www.torrentdownloads.pro/search/?search=%s",
group: "web",
type: "link",
ignoreSearchAndOnlyShowInGroup: true,
openInNewTab: true,
},
{
id: "youtube",
title: "YouTube",
description: "Search for videos on YouTube",
icon: "https://www.youtube.com/favicon.ico",
href: "https://www.youtube.com/results?search_query=%s",
group: "web",
type: "link",
ignoreSearchAndOnlyShowInGroup: true,
openInNewTab: true,
},
]);
};

View File

@@ -156,10 +156,6 @@ export default {
placeholder: "Wähle eine oder mehrere Optionen aus",
},
noResults: "Keine Ergebnisse gefunden",
search: {
placeholder: "Suche nach etwas",
nothingFound: "Nichts gefunden",
},
mantineReactTable: MRT_Localization_DE,
},
widget: {
@@ -193,4 +189,8 @@ export default {
},
},
},
search: {
placeholder: "Suche nach etwas",
nothingFound: "Nichts gefunden",
},
} as const;

View File

@@ -597,16 +597,6 @@ export default {
recommended: "Recommended",
},
},
search: {
placeholder: "Search for anything",
nothingFound: "Nothing found",
group: {
all: "All",
web: "Web",
action: "Actions",
app: "Apps",
},
},
userAvatar: {
menu: {
switchToDarkMode: "Switch to dark mode",
@@ -1723,7 +1713,7 @@ export default {
copy: {
title: "Copy invite",
description:
"Your invitation has been generated. After this modal closes, <b>you'll not be able to copy this link anymore</b>. If you do no longer wish to invite said person, you can delete this invitation any time.",
"Your invitation has been generated. After this modal closes, you'll not be able to copy this link anymore. If you do no longer wish to invite said person, you can delete this invitation any time.",
link: "Invitation link",
button: "Copy & close",
},
@@ -2058,4 +2048,247 @@ export default {
},
},
},
search: {
placeholder: "Search for anything",
nothingFound: "Nothing found",
error: {
fetch: "An error occurred while fetching data",
},
mode: {
appIntegrationBoard: {
help: "Search for apps, integrations or boards",
group: {
app: {
title: "Apps",
children: {
action: {
open: {
label: "Open app url",
},
edit: {
label: "Edit app",
},
},
detail: {
title: "Select an action for the app",
},
},
},
board: {
title: "Boards",
children: {
action: {
open: {
label: "Open board",
},
homeBoard: {
label: "Set as home board",
},
settings: {
label: "Open settings",
},
},
detail: {
title: "Select an action for the board",
},
},
},
integration: {
title: "Integrations",
},
},
},
command: {
help: "Activate command mode",
group: {
globalCommand: {
title: "Global commands",
option: {
colorScheme: {
light: "Switch to light mode",
dark: "Switch to dark mode",
},
language: {
label: "Change language",
children: {
detail: {
title: "Select your prefered language",
},
},
},
newBoard: {
label: "Create a new board",
},
importBoard: {
label: "Import a board",
},
newApp: {
label: "Create a new app",
},
newIntegration: {
label: "Create a new integration",
children: {
detail: {
title: "Select the integration type you want to create",
},
},
},
newUser: {
label: "Create a new user",
},
newInvite: {
label: "Create a new invite",
},
newGroup: {
label: "Create a new group",
},
},
},
},
},
external: {
help: "Use an external search engine",
group: {
searchEngine: {
title: "Search engines",
option: {
google: {
name: "Google",
description: "Search the web with Google",
},
bing: {
name: "Bing",
description: "Search the web with Bing",
},
duckduckgo: {
name: "DuckDuckGo",
description: "Search the web with DuckDuckGo",
},
torrent: {
name: "Torrents",
description: "Search for torrents on torrentdownloads.pro",
},
youTube: {
name: "YouTube",
description: "Search for videos on YouTube",
},
},
},
},
},
help: {
group: {
mode: {
title: "Modes",
},
help: {
title: "Help",
option: {
documentation: {
label: "Documentation",
},
submitIssue: {
label: "Submit an issue",
},
discord: {
label: "Community Discord",
},
},
},
},
},
page: {
help: "Search for pages",
group: {
page: {
title: "Pages",
option: {
manageHome: {
label: "Manage home page",
},
manageBoard: {
label: "Manage boards",
},
manageApp: {
label: "Manage apps",
},
manageIntegration: {
label: "Manage integrations",
},
manageUser: {
label: "Manage users",
},
manageInvite: {
label: "Manage invites",
},
manageGroup: {
label: "Manage groups",
},
manageDocker: {
label: "Manage docker",
},
manageApi: {
label: "Swagger API",
},
manageLog: {
label: "View logs",
},
manageTask: {
label: "Manage tasks",
},
manageSettings: {
label: "Global settings",
},
about: {
label: "About",
},
homeBoard: {
label: "Home board",
},
preferences: {
label: "Your preferences",
},
},
},
},
},
userGroup: {
help: "Search for users or groups",
group: {
user: {
title: "Users",
children: {
action: {
detail: {
label: "Show user details",
},
},
detail: {
title: "Select an action for the user",
},
},
},
group: {
title: "Groups",
children: {
action: {
detail: {
label: "Show group details",
},
manageMember: {
label: "Manage members",
},
managePermission: {
label: "Manage permissions",
},
},
detail: {
title: "Select an action for the group",
},
},
},
},
},
},
},
} as const;

View File

@@ -22,9 +22,9 @@
"lint": "eslint",
"typecheck": "tsc --noEmit"
},
"prettier": "@homarr/prettier-config",
"dependencies": {
"@homarr/common": "workspace:^0.1.0",
"@homarr/definitions": "workspace:^0.1.0",
"@homarr/log": "workspace:^0.1.0",
"@homarr/translation": "workspace:^0.1.0",
"@homarr/validation": "workspace:^0.1.0",
@@ -43,5 +43,6 @@
"@types/css-modules": "^1.0.5",
"eslint": "^9.10.0",
"typescript": "^5.6.2"
}
},
"prettier": "@homarr/prettier-config"
}

View File

@@ -8,4 +8,5 @@ export { TextMultiSelect } from "./text-multi-select";
export { UserAvatar } from "./user-avatar";
export { UserAvatarGroup } from "./user-avatar-group";
export { CustomPasswordInput } from "./password-input/password-input";
export { IntegrationAvatar } from "./integration-avatar";
export { BetaBadge } from "./beta-badge";

View File

@@ -1,8 +1,8 @@
import { Avatar } from "@mantine/core";
import type { MantineSize } from "@mantine/core";
import { Avatar } from "@mantine/core";
import { getIconUrl } from "@homarr/definitions";
import type { IntegrationKind } from "@homarr/definitions";
import { getIconUrl } from "@homarr/definitions";
interface IntegrationAvatarProps {
size: MantineSize;

View File

@@ -7,7 +7,6 @@ import combineClasses from "clsx";
import { clientApi } from "@homarr/api/client";
import { parseAppHrefWithVariablesClient } from "@homarr/common/client";
import { useRegisterSpotlightActions } from "@homarr/spotlight";
import { useI18n } from "@homarr/translation/client";
import type { WidgetComponentProps } from "../definition";
@@ -29,25 +28,6 @@ export default function AppWidget({ options, isEditMode }: WidgetComponentProps<
},
);
useRegisterSpotlightActions(
`app-${options.appId}`,
app.href
? [
{
id: `app-${options.appId}`,
title: app.name,
description: app.description ?? "",
icon: app.iconUrl,
group: "app",
type: "link",
href: parseAppHrefWithVariablesClient(app.href),
openInNewTab: options.openInNewTab,
},
]
: [],
[app, options.appId, options.openInNewTab],
);
return (
<AppLink
href={parseAppHrefWithVariablesClient(app.href ?? "")}

21
pnpm-lock.yaml generated
View File

@@ -1304,6 +1304,24 @@ importers:
packages/spotlight:
dependencies:
'@homarr/api':
specifier: workspace:^0.1.0
version: link:../api
'@homarr/auth':
specifier: workspace:^0.1.0
version: link:../auth
'@homarr/common':
specifier: workspace:^0.1.0
version: link:../common
'@homarr/definitions':
specifier: workspace:^0.1.0
version: link:../definitions
'@homarr/modals':
specifier: workspace:^0.1.0
version: link:../modals
'@homarr/modals-collection':
specifier: workspace:^0.1.0
version: link:../modals-collection
'@homarr/translation':
specifier: workspace:^0.1.0
version: link:../translation
@@ -1384,6 +1402,9 @@ importers:
'@homarr/common':
specifier: workspace:^0.1.0
version: link:../common
'@homarr/definitions':
specifier: workspace:^0.1.0
version: link:../definitions
'@homarr/log':
specifier: workspace:^0.1.0
version: link:../log

View File

@@ -13,6 +13,7 @@ export default [
...reactPlugin.configs["jsx-runtime"].rules,
...hooksPlugin.configs.recommended.rules,
// context.getSource is not a function
"react-hooks/rules-of-hooks": "off",
"react-hooks/exhaustive-deps": "off",
},
languageOptions: {