diff --git a/apps/nextjs/src/app/[locale]/manage/boards/_components/create-board-button.tsx b/apps/nextjs/src/app/[locale]/manage/boards/_components/create-board-button.tsx index cbe15468d..0aa295481 100644 --- a/apps/nextjs/src/app/[locale]/manage/boards/_components/create-board-button.tsx +++ b/apps/nextjs/src/app/[locale]/manage/boards/_components/create-board-button.tsx @@ -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 = ( <> - @@ -43,7 +25,7 @@ export const CreateBoardButton = ({ boardNames }: CreateBoardButtonProps) => { - }> + }> {t("board.action.oldImport.label")} diff --git a/apps/nextjs/src/app/[locale]/manage/boards/page.tsx b/apps/nextjs/src/app/[locale]/manage/boards/page.tsx index 506af6a99..482701639 100644 --- a/apps/nextjs/src/app/[locale]/manage/boards/page.tsx +++ b/apps/nextjs/src/app/[locale]/manage/boards/page.tsx @@ -39,7 +39,7 @@ export default async function ManageBoardsPage() { {t("title")} - board.name)} /> + diff --git a/apps/nextjs/src/app/[locale]/manage/integrations/edit/[id]/page.tsx b/apps/nextjs/src/app/[locale]/manage/integrations/edit/[id]/page.tsx index a2b557522..7fa82f019 100644 --- a/apps/nextjs/src/app/[locale]/manage/integrations/edit/[id]/page.tsx +++ b/apps/nextjs/src/app/[locale]/manage/integrations/edit/[id]/page.tsx @@ -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 { diff --git a/apps/nextjs/src/app/[locale]/manage/integrations/new/_integration-new-dropdown.tsx b/apps/nextjs/src/app/[locale]/manage/integrations/new/_integration-new-dropdown.tsx index b2700ff70..188916e9e 100644 --- a/apps/nextjs/src/app/[locale]/manage/integrations/new/_integration-new-dropdown.tsx +++ b/apps/nextjs/src/app/[locale]/manage/integrations/new/_integration-new-dropdown.tsx @@ -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(); diff --git a/apps/nextjs/src/app/[locale]/manage/integrations/new/page.tsx b/apps/nextjs/src/app/[locale]/manage/integrations/new/page.tsx index 3dcc78ab2..6665a2417 100644 --- a/apps/nextjs/src/app/[locale]/manage/integrations/new/page.tsx +++ b/apps/nextjs/src/app/[locale]/manage/integrations/new/page.tsx @@ -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 { diff --git a/apps/nextjs/src/app/[locale]/manage/integrations/page.tsx b/apps/nextjs/src/app/[locale]/manage/integrations/page.tsx index f3d1cc0af..7df483661 100644 --- a/apps/nextjs/src/app/[locale]/manage/integrations/page.tsx +++ b/apps/nextjs/src/app/[locale]/manage/integrations/page.tsx @@ -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"; diff --git a/apps/nextjs/src/app/[locale]/manage/layout.tsx b/apps/nextjs/src/app/[locale]/manage/layout.tsx index 613383fd1..58314d65a 100644 --- a/apps/nextjs/src/app/[locale]/manage/layout.tsx +++ b/apps/nextjs/src/app/[locale]/manage/layout.tsx @@ -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, diff --git a/apps/nextjs/src/app/[locale]/manage/users/groups/_add-group.tsx b/apps/nextjs/src/app/[locale]/manage/users/groups/_add-group.tsx index 97d135589..f3febd49c 100644 --- a/apps/nextjs/src/app/[locale]/manage/users/groups/_add-group.tsx +++ b/apps/nextjs/src/app/[locale]/manage/users/groups/_add-group.tsx @@ -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 = () => { ); }; - -const AddGroupModal = createModal(({ actions }) => { - const t = useI18n(); - const { mutate, isPending } = clientApi.group.createGroup.useMutation(); - const form = useZodForm(validation.group.create, { - initialValues: { - name: "", - }, - }); - - return ( -
{ - 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"), - }); - }, - }); - })} - > - - - - - - - -
- ); -}).withOptions({ - defaultTitle: (t) => t("group.action.create.label"), -}); diff --git a/apps/nextjs/src/components/board/items/item-menu.tsx b/apps/nextjs/src/components/board/items/item-menu.tsx index c02f915cd..4c4d9642f 100644 --- a/apps/nextjs/src/components/board/items/item-menu.tsx +++ b/apps/nextjs/src/components/board/items/item-menu.tsx @@ -107,7 +107,7 @@ export const BoardItemMenu = ({ }} > {tItem("action.moveResize")} -
{" "} +
} onClick={() => duplicateItem({ itemId: item.id })}> {tItem("action.duplicate")} diff --git a/apps/nextjs/src/components/icons/picker/icon-picker.tsx b/apps/nextjs/src/components/icons/picker/icon-picker.tsx index 51143cd01..91983ff7c 100644 --- a/apps/nextjs/src/components/icons/picker/icon-picker.tsx +++ b/apps/nextjs/src/components/icons/picker/icon-picker.tsx @@ -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(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")} /> - {t("iconPicker.header", { countIcons: data?.countIcons })} + {tCommon("iconPicker.header", { countIcons: data?.countIcons })} {totalOptions > 0 ? ( diff --git a/apps/nextjs/src/components/layout/header/search.tsx b/apps/nextjs/src/components/layout/header/search.tsx index 4dad0404f..9b2edd78f 100644 --- a/apps/nextjs/src/components/layout/header/search.tsx +++ b/apps/nextjs/src/components/layout/header/search.tsx @@ -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 ( { leftSection={} onClick={openSpotlight} > - {t("placeholder")} + {t("common.rtl", { + value: t("search.placeholder"), + symbol: "...", + })} ); }; diff --git a/packages/api/src/router/app.ts b/packages/api/src/router/app.ts index 38171a946..714828838 100644 --- a/packages/api/src/router/app.ts +++ b/packages/api/src/router/app.ts @@ -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), diff --git a/packages/api/src/router/board.ts b/packages/api/src/router/board.ts index 97931de95..bc1fba67a 100644 --- a/packages/api/src/router/board.ts +++ b/packages/api/src/router/board.ts @@ -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) diff --git a/packages/api/src/router/group.ts b/packages/api/src/router/group.ts index 7a47e7d3e..639a5bf15 100644 --- a/packages/api/src/router/group.ts +++ b/packages/api/src/router/group.ts @@ -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); diff --git a/packages/api/src/router/integration/integration-router.ts b/packages/api/src/router/integration/integration-router.ts index 5ac6c4350..196c30761 100644 --- a/packages/api/src/router/integration/integration-router.ts +++ b/packages/api/src/router/integration/integration-router.ts @@ -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({ diff --git a/packages/api/src/router/user.ts b/packages/api/src/router/user.ts index 362b3b7d7..4d10a4652 100644 --- a/packages/api/src/router/user.ts +++ b/packages/api/src/router/user.ts @@ -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: { diff --git a/packages/auth/permissions/board-permissions.ts b/packages/auth/permissions/board-permissions.ts index 817cced6f..c59d56a3e 100644 --- a/packages/auth/permissions/board-permissions.ts +++ b/packages/auth/permissions/board-permissions.ts @@ -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), }; }; diff --git a/packages/modals-collection/src/boards/add-board-modal.tsx b/packages/modals-collection/src/boards/add-board-modal.tsx index 2573ffe2c..45990aad0 100644 --- a/packages/modals-collection/src/boards/add-board-modal.tsx +++ b/packages/modals-collection/src/boards/add-board-modal.tsx @@ -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; -} - -export const AddBoardModal = createModal(({ 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(({ actions, innerProps }) = }, }); const { mutate, isPending } = clientApi.board.createBoard.useMutation({ - onSettled: innerProps.onSettled, + onSettled: async () => { + await revalidatePathActionAsync("/manage/boards"); + }, }); const boardNameStatus = useBoardNameStatus(form.values.name); diff --git a/packages/modals-collection/src/groups/add-group-modal.tsx b/packages/modals-collection/src/groups/add-group-modal.tsx new file mode 100644 index 000000000..2ba0af1a3 --- /dev/null +++ b/packages/modals-collection/src/groups/add-group-modal.tsx @@ -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(({ actions }) => { + const t = useI18n(); + const { mutate, isPending } = clientApi.group.createGroup.useMutation(); + const form = useZodForm(validation.group.create, { + initialValues: { + name: "", + }, + }); + + return ( +
{ + 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"), + }); + }, + }); + })} + > + + + + + + + +
+ ); +}).withOptions({ + defaultTitle: (t) => t("group.action.create.label"), +}); diff --git a/packages/modals-collection/src/groups/index.ts b/packages/modals-collection/src/groups/index.ts new file mode 100644 index 000000000..428bea2ec --- /dev/null +++ b/packages/modals-collection/src/groups/index.ts @@ -0,0 +1 @@ +export { AddGroupModal } from "./add-group-modal"; diff --git a/packages/modals-collection/src/index.ts b/packages/modals-collection/src/index.ts index 410b744a3..45caf0411 100644 --- a/packages/modals-collection/src/index.ts +++ b/packages/modals-collection/src/index.ts @@ -1,2 +1,3 @@ export * from "./boards"; export * from "./invites"; +export * from "./groups"; diff --git a/packages/spotlight/ReadMe.md b/packages/spotlight/ReadMe.md deleted file mode 100644 index ac4122282..000000000 --- a/packages/spotlight/ReadMe.md +++ /dev/null @@ -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` | 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
My Component
; -}; -``` - -##### 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
Component implementation
; -}; -``` - -##### 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
Component implementation
; -}; -``` - -##### 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
Component implementation
; -}; -``` diff --git a/packages/spotlight/package.json b/packages/spotlight/package.json index ad0c1ba70..029f0f7e5 100644 --- a/packages/spotlight/package.json +++ b/packages/spotlight/package.json @@ -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" } diff --git a/packages/spotlight/src/chip-group.tsx b/packages/spotlight/src/chip-group.tsx deleted file mode 100644 index d2cc08cc9..000000000 --- a/packages/spotlight/src/chip-group.tsx +++ /dev/null @@ -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) => { - 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) => { - 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("label[data-checked]"); - if (!label) return; - label.focus(); -}; - -interface Props { - group: SpotlightActionGroup; -} - -export const GroupChip = ({ group }: Props) => { - const t = useScopedI18n("common.search.group"); - return ( - - {t(group)} - - ); -}; diff --git a/packages/spotlight/src/component.tsx b/packages/spotlight/src/component.tsx deleted file mode 100644 index 59f67ef20..000000000 --- a/packages/spotlight/src/component.tsx +++ /dev/null @@ -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) => ( - - ) - : undefined; - - return ( - - - {item.icon && ( -
- {typeof item.icon !== "string" && } - {typeof item.icon === "string" && {item.title}} -
- )} - - - {item.title} - - {item.description && ( - - {item.description} - - )} - -
-
- ); - }); - - const onGroupChange = useCallback( - (group: string) => { - setSelectedAction(-1, spotlightStore); - setGroup(group); - }, - [setGroup, setSelectedAction], - ); - - return ( - - } - /> - - - - - - {groups.map((group) => ( - - ))} - - - - - - {items.length > 0 ? items : {t("common.search.nothingFound")}} - - - ); -}; - -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), -}); diff --git a/packages/spotlight/src/components/actions/children-actions.tsx b/packages/spotlight/src/components/actions/children-actions.tsx new file mode 100644 index 000000000..b7f36909d --- /dev/null +++ b/packages/spotlight/src/components/actions/children-actions.tsx @@ -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) => ( + + )); +}; diff --git a/packages/spotlight/src/components/actions/group-actions.tsx b/packages/spotlight/src/components/actions/group-actions.tsx new file mode 100644 index 000000000..ce2d8f370 --- /dev/null +++ b/packages/spotlight/src/components/actions/group-actions.tsx @@ -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> { + group: SearchGroup; + query: string; + setMode: (mode: keyof TranslationObject["search"]["mode"]) => void; + setChildrenOptions: (options: inferSearchInteractionOptions<"children">) => void; +} + +export const SpotlightGroupActions = >({ + group, + query, + setMode, + setChildrenOptions, +}: GroupActionsProps) => { + // 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 ; + } + + return filteredOptions.map((option) => ( + + )); + } + + if (options.isLoading) { + return ( +
+ +
+ ); + } + + if (options.isError) { + return
{t("search.error.fetch")}
; + } + + if (!options.data) { + return null; + } + + if (options.data.length === 0) { + return ; + } + + return options.data.map((option) => ( + + )); +}; diff --git a/packages/spotlight/src/components/actions/groups/action-group.tsx b/packages/spotlight/src/components/actions/groups/action-group.tsx new file mode 100644 index 000000000..c35fa2d35 --- /dev/null +++ b/packages/spotlight/src/components/actions/groups/action-group.tsx @@ -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) => ( + + {/*eslint-disable-next-line @typescript-eslint/no-explicit-any */} + + group={group} + query={query} + setMode={setMode} + setChildrenOptions={setChildrenOptions} + /> + + )); +}; diff --git a/packages/spotlight/src/component.module.css b/packages/spotlight/src/components/actions/items/action-item.module.css similarity index 100% rename from packages/spotlight/src/component.module.css rename to packages/spotlight/src/components/actions/items/action-item.module.css diff --git a/packages/spotlight/src/components/actions/items/children-action-item.tsx b/packages/spotlight/src/components/actions/items/children-action-item.tsx new file mode 100644 index 000000000..c36fb3c65 --- /dev/null +++ b/packages/spotlight/src/components/actions/items/children-action-item.tsx @@ -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["useActions"]>[number]; +} + +export const ChildrenActionItem = ({ childrenOptions, action, query }: ChildrenActionItemProps) => { + const interaction = action.useInteraction(childrenOptions.option, query); + + const renderRoot = + interaction.type === "link" + ? (props: Record) => { + return ; + } + : undefined; + + const onClick = interaction.type === "javaScript" ? interaction.onSelect : undefined; + + return ( + + + + ); +}; diff --git a/packages/spotlight/src/components/actions/items/group-action-item.tsx b/packages/spotlight/src/components/actions/items/group-action-item.tsx new file mode 100644 index 000000000..1b2ebe1c5 --- /dev/null +++ b/packages/spotlight/src/components/actions/items/group-action-item.tsx @@ -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> { + option: TOption; + query: string; + setMode: (mode: keyof TranslationObject["search"]["mode"]) => void; + setChildrenOptions: (options: inferSearchInteractionOptions<"children">) => void; + group: SearchGroup; +} + +export const SpotlightGroupActionItem = >({ + group, + query, + setMode, + setChildrenOptions, + option, +}: SpotlightGroupActionItemProps) => { + const interaction = group.useInteraction(option, query); + + const renderRoot = + interaction.type === "link" + ? (props: Record) => { + return ; + } + : 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 ( + + + + ); +}; diff --git a/packages/spotlight/src/components/no-results.tsx b/packages/spotlight/src/components/no-results.tsx new file mode 100644 index 000000000..e5fc52f99 --- /dev/null +++ b/packages/spotlight/src/components/no-results.tsx @@ -0,0 +1,9 @@ +import { Spotlight } from "@mantine/spotlight"; + +import { useI18n } from "@homarr/translation/client"; + +export const SpotlightNoResults = () => { + const t = useI18n(); + + return {t("search.nothingFound")}; +}; diff --git a/packages/spotlight/src/components/spotlight.tsx b/packages/spotlight/src/components/spotlight.tsx new file mode 100644 index 000000000..c9c9c2904 --- /dev/null +++ b/packages/spotlight/src/components/spotlight.tsx @@ -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("help"); + const [childrenOptions, setChildrenOptions] = useState | null>(null); + const t = useI18n(); + const inputRef = useRef(null); + const activeMode = useMemo(() => searchModes.find((searchMode) => searchMode.modeKey === mode), [mode]); + + if (!activeMode) { + return null; + } + + return ( + { + 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} + > + +
+ +
+ {activeMode.modeKey !== "help" ? {activeMode.character} : null} + + } + rightSection={ + mode === "help" ? undefined : ( + { + setMode("help"); + setChildrenOptions(null); + inputRef.current?.focus(); + }} + variant="subtle" + > + + + ) + } + value={query} + onKeyDown={(event) => { + if (query.length === 0 && mode !== "help" && event.key === "Backspace") { + setMode("help"); + setChildrenOptions(null); + } + }} + /> + + {childrenOptions ? ( + + + + ) : null} + + + {childrenOptions ? ( + + ) : ( + { + setMode(mode); + setChildrenOptions(null); + setTimeout(() => selectAction(0, spotlightStore)); + }} + setChildrenOptions={(options) => { + setChildrenOptions(options); + setQuery(""); + setTimeout(() => selectAction(0, spotlightStore)); + }} + query={query} + groups={activeMode.groups} + /> + )} + +
+ ); +}; diff --git a/packages/spotlight/src/data-store.ts b/packages/spotlight/src/data-store.ts deleted file mode 100644 index 0a2dbd52d..000000000 --- a/packages/spotlight/src/data-store.ts +++ /dev/null @@ -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>({}); -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(); - -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]); -}; diff --git a/packages/spotlight/src/index.ts b/packages/spotlight/src/index.ts index d0e3aa43a..2d4207075 100644 --- a/packages/spotlight/src/index.ts +++ b/packages/spotlight/src/index.ts @@ -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; diff --git a/packages/spotlight/src/lib/children.ts b/packages/spotlight/src/lib/children.ts new file mode 100644 index 000000000..1a792a2b6 --- /dev/null +++ b/packages/spotlight/src/lib/children.ts @@ -0,0 +1,24 @@ +import type { ReactNode } from "react"; + +import type { inferSearchInteractionDefinition } from "./interaction"; + +export interface CreateChildrenOptionsProps> { + detailComponent: ({ options }: { options: TParentOptions }) => ReactNode; + useActions: (options: TParentOptions, query: string) => ChildrenAction[]; +} + +export interface ChildrenAction> { + key: string; + component: (option: TParentOptions) => JSX.Element; + useInteraction: (option: TParentOptions, query: string) => inferSearchInteractionDefinition<"link" | "javaScript">; + hide?: boolean | ((option: TParentOptions) => boolean); +} + +export const createChildrenOptions = >( + props: CreateChildrenOptionsProps, +) => { + return (option: TParentOptions) => ({ + option, + ...props, + }); +}; diff --git a/packages/spotlight/src/lib/group.ts b/packages/spotlight/src/lib/group.ts new file mode 100644 index 000000000..d1cfc4a44 --- /dev/null +++ b/packages/spotlight/src/lib/group.ts @@ -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, TOptionProps extends Record> = { + // 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; +} & TOptionProps; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export type SearchGroup = any> = + | CommonSearchGroup boolean; options: TOption[] }> + | CommonSearchGroup< + TOption, + { + filter: (query: string, option: TOption) => boolean; + sort?: (query: string, options: [TOption, TOption]) => number; + useOptions: () => TOption[]; + } + > + | CommonSearchGroup UseTRPCQueryResult }>; + +export const createGroup = >(group: SearchGroup) => group; diff --git a/packages/spotlight/src/lib/interaction.ts b/packages/spotlight/src/lib/interaction.ts new file mode 100644 index 000000000..1528a39ca --- /dev/null +++ b/packages/spotlight/src/lib/interaction.ts @@ -0,0 +1,56 @@ +import type { MaybePromise } from "@homarr/common/types"; +import type { TranslationObject } from "@homarr/translation"; + +import type { CreateChildrenOptionsProps } from "./children"; + +const createSearchInteraction = (type: TType) => ({ + optionsType: >() => ({ 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 }>(), + createSearchInteraction("mode").optionsType<{ mode: keyof TranslationObject["search"]["mode"] }>(), + createSearchInteraction("children").optionsType<{ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + useActions: CreateChildrenOptionsProps["useActions"]; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + detailComponent: CreateChildrenOptionsProps["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 = Extract< + (typeof searchInteractions)[number], + { type: TInteraction } +>["_inferOptions"]; + +// Infer the search interaction definition (type + options) for the specified search interaction +export type inferSearchInteractionDefinition = { + [interactionKey in TInteraction]: { type: interactionKey } & inferSearchInteractionOptions; +}[TInteraction]; + +// Type used for helper functions to define basic search interactions +type SearchInteractions = { + [optionKey in SearchInteraction]: >( + callback: (option: TOption, query: string) => inferSearchInteractionOptions, + ) => (option: TOption, query: string) => inferSearchInteractionDefinition; +}; + +// Helper functions to define basic search interactions +export const interaction = searchInteractions.reduce((acc, interaction) => { + return { + ...acc, + [interaction.type]: >( + callback: (option: TOption, query: string) => inferSearchInteractionOptions, + ) => { + return (option: TOption, query: string) => ({ type: interaction.type, ...callback(option, query) }); + }, + }; +}, {} as SearchInteractions); diff --git a/packages/spotlight/src/lib/mode.ts b/packages/spotlight/src/lib/mode.ts new file mode 100644 index 000000000..a0b480f3c --- /dev/null +++ b/packages/spotlight/src/lib/mode.ts @@ -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[]; +} diff --git a/packages/spotlight/src/modes/app-integration-board/apps-search-group.tsx b/packages/spotlight/src/modes/app-integration-board/apps-search-group.tsx new file mode 100644 index 000000000..5d2dc98a6 --- /dev/null +++ b/packages/spotlight/src/modes/app-integration-board/apps-search-group.tsx @@ -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. +// eslint-disable-next-line @typescript-eslint/consistent-type-definitions +type App = { id: string; name: string; iconUrl: string; href: string | null }; + +const appChildrenOptions = createChildrenOptions({ + useActions: () => [ + { + key: "open", + component: () => { + const t = useI18n(); + + return ( + + + {t("search.mode.appIntegrationBoard.group.app.children.action.open.label")} + + ); + }, + // 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 ( + + + {t("search.mode.appIntegrationBoard.group.app.children.action.edit.label")} + + ); + }, + useInteraction: interaction.link(({ id }) => ({ href: `/manage/apps/edit/${id}` })), + }, + ], + detailComponent: ({ options }) => { + const t = useI18n(); + + return ( + + {t("search.mode.appIntegrationBoard.group.app.children.detail.title")} + + + + {options.name} + + + ); + }, +}); + +export const appsSearchGroup = createGroup({ + keyPath: "id", + title: (t) => t("search.mode.appIntegrationBoard.group.app.title"), + component: (app) => ( + + + {app.name} + + ), + useInteraction: interaction.children(appChildrenOptions), + useQueryOptions(query) { + return clientApi.app.search.useQuery({ query, limit: 5 }); + }, +}); diff --git a/packages/spotlight/src/modes/app-integration-board/boards-search-group.tsx b/packages/spotlight/src/modes/app-integration-board/boards-search-group.tsx new file mode 100644 index 000000000..089fb732c --- /dev/null +++ b/packages/spotlight/src/modes/app-integration-board/boards-search-group.tsx @@ -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. +// 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({ + useActions: (options) => { + const actions: (ChildrenAction & { hidden?: boolean })[] = [ + { + key: "open", + component: () => { + const t = useI18n(); + + return ( + + + {t("search.mode.appIntegrationBoard.group.board.children.action.open.label")} + + ); + }, + useInteraction: interaction.link(({ name }) => ({ href: `/boards/${name}` })), + }, + { + key: "homeBoard", + component: () => { + const t = useI18n(); + + return ( + + + {t("search.mode.appIntegrationBoard.group.board.children.action.homeBoard.label")} + + ); + }, + 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 ( + + + {t("search.mode.appIntegrationBoard.group.board.children.action.settings.label")} + + ); + }, + useInteraction: interaction.link(({ name }) => ({ href: `/boards/${name}/settings` })), + hidden: !options.permissions.hasChangeAccess, + }, + ]; + + return actions; + }, + detailComponent: ({ options: board }) => { + const t = useI18n(); + + return ( + + {t("search.mode.appIntegrationBoard.group.board.children.detail.title")} + + + {board.logoImageUrl ? ( + {board.name} + ) : ( + + )} + + {board.name} + + + ); + }, +}); + +export const boardsSearchGroup = createGroup({ + keyPath: "id", + title: "Boards", + component: (board) => ( + + {board.logoImageUrl ? ( + {board.name} + ) : ( + + )} + + {board.name} + + ), + useInteraction: interaction.children(boardChildrenOptions), + useQueryOptions(query) { + return clientApi.board.search.useQuery({ query, limit: 5 }); + }, +}); diff --git a/packages/spotlight/src/modes/app-integration-board/index.tsx b/packages/spotlight/src/modes/app-integration-board/index.tsx new file mode 100644 index 000000000..417fe967c --- /dev/null +++ b/packages/spotlight/src/modes/app-integration-board/index.tsx @@ -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; diff --git a/packages/spotlight/src/modes/app-integration-board/integrations-search-group.tsx b/packages/spotlight/src/modes/app-integration-board/integrations-search-group.tsx new file mode 100644 index 000000000..55fd14eca --- /dev/null +++ b/packages/spotlight/src/modes/app-integration-board/integrations-search-group.tsx @@ -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) => ( + + + + {integration.name} + + ), + useInteraction: interaction.link(({ id }) => ({ href: `/manage/integrations/edit/${id}` })), + useQueryOptions(query) { + return clientApi.integration.search.useQuery({ query, limit: 5 }); + }, +}); diff --git a/packages/spotlight/src/modes/command/children/language.tsx b/packages/spotlight/src/modes/command/children/language.tsx new file mode 100644 index 000000000..7a248275a --- /dev/null +++ b/packages/spotlight/src/modes/command/children/language.tsx @@ -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>({ + 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 ( + + + + + {attributes.name} + + ({attributes.translatedName}) + + + + {localeKey === currentLocale && } + + ); + }, + useInteraction() { + const changeLocale = useChangeLocale(); + + return { type: "javaScript", onSelect: () => changeLocale(localeKey) }; + }, + })); + }, + detailComponent: () => { + const t = useI18n(); + + return ( + + {t("search.mode.command.group.globalCommand.option.language.children.detail.title")} + + ); + }, +}); diff --git a/packages/spotlight/src/modes/command/children/new-integration.tsx b/packages/spotlight/src/modes/command/children/new-integration.tsx new file mode 100644 index 000000000..d70a719f3 --- /dev/null +++ b/packages/spotlight/src/modes/command/children/new-integration.tsx @@ -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>({ + 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 ( + + + {integrationDef.name} + + ); + }, + useInteraction: interaction.link(() => ({ href: `/manage/integrations/new?kind=${kind}` })), + })); + }, + detailComponent() { + const t = useI18n(); + + return ( + + {t("search.mode.command.group.globalCommand.option.newIntegration.children.detail.title")} + + ); + }, +}); diff --git a/packages/spotlight/src/modes/command/index.tsx b/packages/spotlight/src/modes/command/index.tsx new file mode 100644 index 000000000..240fd13b5 --- /dev/null +++ b/packages/spotlight/src/modes/command/index.tsx @@ -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. +// eslint-disable-next-line @typescript-eslint/consistent-type-definitions +type Command = { + commandKey: string; + icon: TablerIcon; + name: string; + useInteraction: ( + _c: Command, + query: string, + ) => inferSearchInteractionDefinition; +}; + +export const commandMode = { + modeKey: "command", + character: ">", + groups: [ + createGroup({ + keyPath: "commandKey", + title: "Global commands", + useInteraction: (option, query) => option.useInteraction(option, query), + component: ({ icon: Icon, name }) => ( + + + {name} + + ), + 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; diff --git a/packages/spotlight/src/modes/external/index.tsx b/packages/spotlight/src/modes/external/index.tsx new file mode 100644 index 000000000..204d01e1d --- /dev/null +++ b/packages/spotlight/src/modes/external/index.tsx @@ -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; diff --git a/packages/spotlight/src/modes/external/search-engines-search-group.tsx b/packages/spotlight/src/modes/external/search-engines-search-group.tsx new file mode 100644 index 000000000..45669ba52 --- /dev/null +++ b/packages/spotlight/src/modes/external/search-engines-search-group.tsx @@ -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. +// 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({ + keyPath: "short", + title: (t) => t("search.mode.external.group.searchEngine.title"), + component: ({ image: Image, name, description }) => ( + + + {typeof Image === "string" ? {name} : } + + {name} + + {description} + + + + + ), + 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", + }, + ]; + }, +}); diff --git a/packages/spotlight/src/modes/index.tsx b/packages/spotlight/src/modes/index.tsx new file mode 100644 index 000000000..29b0f220f --- /dev/null +++ b/packages/spotlight/src/modes/index.tsx @@ -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 ( + + {t("help")} + {character} + + ); + }, + 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) => ( + + + {props.label} + + ), + filter: () => true, + useInteraction: interaction.link(({ href }) => ({ href })), + }), + ], +} satisfies SearchMode; + +export const searchModes = [...searchModesWithoutHelp, helpMode] as const; diff --git a/packages/spotlight/src/modes/page/index.tsx b/packages/spotlight/src/modes/page/index.tsx new file mode 100644 index 000000000..39be2cb19 --- /dev/null +++ b/packages/spotlight/src/modes/page/index.tsx @@ -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; diff --git a/packages/spotlight/src/modes/page/pages-search-group.tsx b/packages/spotlight/src/modes/page/pages-search-group.tsx new file mode 100644 index 000000000..d4ad2eb52 --- /dev/null +++ b/packages/spotlight/src/modes/page/pages-search-group.tsx @@ -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 }) => ( + + + {name} + + ), + 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); + }, +}); diff --git a/packages/spotlight/src/modes/user-group/groups-search-group.tsx b/packages/spotlight/src/modes/user-group/groups-search-group.tsx new file mode 100644 index 000000000..507c7cb80 --- /dev/null +++ b/packages/spotlight/src/modes/user-group/groups-search-group.tsx @@ -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. +// eslint-disable-next-line @typescript-eslint/consistent-type-definitions +type Group = { id: string; name: string }; + +const groupChildrenOptions = createChildrenOptions({ + useActions: () => [ + { + key: "detail", + component: () => { + const t = useI18n(); + return ( + + + {t("search.mode.userGroup.group.group.children.action.detail.label")} + + ); + }, + useInteraction: interaction.link(({ id }) => ({ href: `/manage/users/groups/${id}` })), + }, + { + key: "manageMember", + component: () => { + const t = useI18n(); + return ( + + + {t("search.mode.userGroup.group.group.children.action.manageMember.label")} + + ); + }, + useInteraction: interaction.link(({ id }) => ({ href: `/manage/users/groups/${id}/members` })), + }, + { + key: "managePermission", + component: () => { + const t = useI18n(); + return ( + + + {t("search.mode.userGroup.group.group.children.action.managePermission.label")} + + ); + }, + useInteraction: interaction.link(({ id }) => ({ href: `/manage/users/groups/${id}/permissions` })), + }, + ], + detailComponent: ({ options }) => { + const t = useI18n(); + return ( + + {t("search.mode.userGroup.group.group.children.detail.title")} + + + {options.name} + + + ); + }, +}); + +export const groupsSearchGroup = createGroup({ + keyPath: "id", + title: "Groups", + component: ({ name }) => ( + + {name} + + ), + useInteraction: interaction.children(groupChildrenOptions), + useQueryOptions(query) { + return clientApi.group.search.useQuery({ query, limit: 5 }); + }, +}); diff --git a/packages/spotlight/src/modes/user-group/index.tsx b/packages/spotlight/src/modes/user-group/index.tsx new file mode 100644 index 000000000..e9faa6ab5 --- /dev/null +++ b/packages/spotlight/src/modes/user-group/index.tsx @@ -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; diff --git a/packages/spotlight/src/modes/user-group/users-search-group.tsx b/packages/spotlight/src/modes/user-group/users-search-group.tsx new file mode 100644 index 000000000..ec750f182 --- /dev/null +++ b/packages/spotlight/src/modes/user-group/users-search-group.tsx @@ -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. +// eslint-disable-next-line @typescript-eslint/consistent-type-definitions +type User = { id: string; name: string; image: string | null }; + +const userChildrenOptions = createChildrenOptions({ + useActions: () => [ + { + key: "detail", + component: () => { + const t = useI18n(); + + return ( + + + {t("search.mode.userGroup.group.user.children.action.detail.label")} + + ); + }, + useInteraction: interaction.link(({ id }) => ({ href: `/manage/users/${id}/general` })), + }, + ], + detailComponent: ({ options }) => { + const t = useI18n(); + + return ( + + {t("search.mode.userGroup.group.user.children.detail.title")} + + + + {options.name} + + + ); + }, +}); + +export const usersSearchGroup = createGroup({ + keyPath: "id", + title: (t) => t("search.mode.userGroup.group.user.title"), + component: (user) => ( + + + {user.name} + + ), + useInteraction: interaction.children(userChildrenOptions), + useQueryOptions(query) { + return clientApi.user.search.useQuery({ query, limit: 5 }); + }, +}); diff --git a/packages/spotlight/src/type.ts b/packages/spotlight/src/type.ts deleted file mode 100644 index 25f96c2c7..000000000 --- a/packages/spotlight/src/type.ts +++ /dev/null @@ -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; // 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 | Promise; - -interface SpotlightActionButton extends BaseSpotlightAction { - type: "button"; - onClick: () => MaybePromise; -} - -export type SpotlightActionData = SpotlightActionLink | SpotlightActionButton; diff --git a/packages/spotlight/src/web-search-engines.ts b/packages/spotlight/src/web-search-engines.ts deleted file mode 100644 index 870e4a96f..000000000 --- a/packages/spotlight/src/web-search-engines.ts +++ /dev/null @@ -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, - }, - ]); -}; diff --git a/packages/translation/src/lang/de.ts b/packages/translation/src/lang/de.ts index c556cef68..4134d7109 100644 --- a/packages/translation/src/lang/de.ts +++ b/packages/translation/src/lang/de.ts @@ -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; diff --git a/packages/translation/src/lang/en.ts b/packages/translation/src/lang/en.ts index f16ece370..027732955 100644 --- a/packages/translation/src/lang/en.ts +++ b/packages/translation/src/lang/en.ts @@ -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, 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.", + "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; diff --git a/packages/ui/package.json b/packages/ui/package.json index dc1d20f2b..6bef09590 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -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" } diff --git a/packages/ui/src/components/index.tsx b/packages/ui/src/components/index.tsx index ff01de8b2..ecd4632ba 100644 --- a/packages/ui/src/components/index.tsx +++ b/packages/ui/src/components/index.tsx @@ -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"; diff --git a/apps/nextjs/src/app/[locale]/manage/integrations/_integration-avatar.tsx b/packages/ui/src/components/integration-avatar.tsx similarity index 100% rename from apps/nextjs/src/app/[locale]/manage/integrations/_integration-avatar.tsx rename to packages/ui/src/components/integration-avatar.tsx index f443569da..59f962eef 100644 --- a/apps/nextjs/src/app/[locale]/manage/integrations/_integration-avatar.tsx +++ b/packages/ui/src/components/integration-avatar.tsx @@ -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; diff --git a/packages/widgets/src/app/component.tsx b/packages/widgets/src/app/component.tsx index 36472e715..c8628030a 100644 --- a/packages/widgets/src/app/component.tsx +++ b/packages/widgets/src/app/component.tsx @@ -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 (