mirror of
https://github.com/ajnart/homarr.git
synced 2026-02-26 16:30:57 +01:00
feat: add improved search (#1051)
* feat: add improved search * wip: add support for sorting, rename use-options to use-query-options, add use-options for local usage, add pages search group * feat: add help links from manage layout to help search mode * feat: add additional search engines * feat: add group search details * refactor: improve users search group type * feat: add apps search group, add disabled search interaction * feat: add integrations and boards for search * wip: hook issue with react * fix: hook issue regarding actions and interactions * chore: address pull request feedback * fix: format issues * feat: add additional global actions to search * chore: remove unused code * fix: search engine short key * fix: typecheck issues * fix: deepsource issues * fix: eslint issue * fix: lint issues * fix: unordered dependencies * chore: address pull request feedback
This commit is contained in:
@@ -1,39 +1,21 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback } from "react";
|
||||
import { Affix, Button, Group, Menu } from "@mantine/core";
|
||||
import { IconCategoryPlus, IconChevronDown, IconFileImport } from "@tabler/icons-react";
|
||||
|
||||
import { revalidatePathActionAsync } from "@homarr/common/client";
|
||||
import { useModalAction } from "@homarr/modals";
|
||||
import { AddBoardModal, ImportBoardModal } from "@homarr/modals-collection";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import { BetaBadge } from "@homarr/ui";
|
||||
|
||||
interface CreateBoardButtonProps {
|
||||
boardNames: string[];
|
||||
}
|
||||
|
||||
export const CreateBoardButton = ({ boardNames }: CreateBoardButtonProps) => {
|
||||
export const CreateBoardButton = () => {
|
||||
const t = useI18n();
|
||||
const { openModal: openAddModal } = useModalAction(AddBoardModal);
|
||||
const { openModal: openImportModal } = useModalAction(ImportBoardModal);
|
||||
|
||||
const onCreateClick = useCallback(() => {
|
||||
openAddModal({
|
||||
onSettled: async () => {
|
||||
await revalidatePathActionAsync("/manage/boards");
|
||||
},
|
||||
});
|
||||
}, [openAddModal]);
|
||||
|
||||
const onImportClick = useCallback(() => {
|
||||
openImportModal({ boardNames });
|
||||
}, [openImportModal, boardNames]);
|
||||
|
||||
const buttonGroupContent = (
|
||||
<>
|
||||
<Button leftSection={<IconCategoryPlus size="1rem" />} onClick={onCreateClick}>
|
||||
<Button leftSection={<IconCategoryPlus size="1rem" />} onClick={openAddModal}>
|
||||
{t("management.page.board.action.new.label")}
|
||||
</Button>
|
||||
<Menu position="bottom-end">
|
||||
@@ -43,7 +25,7 @@ export const CreateBoardButton = ({ boardNames }: CreateBoardButtonProps) => {
|
||||
</Button>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown>
|
||||
<Menu.Item onClick={onImportClick} leftSection={<IconFileImport size="1rem" />}>
|
||||
<Menu.Item onClick={openImportModal} leftSection={<IconFileImport size="1rem" />}>
|
||||
<Group>
|
||||
{t("board.action.oldImport.label")}
|
||||
<BetaBadge size="xs" />
|
||||
|
||||
@@ -39,7 +39,7 @@ export default async function ManageBoardsPage() {
|
||||
<Stack>
|
||||
<Group justify="space-between">
|
||||
<Title mb="md">{t("title")}</Title>
|
||||
<CreateBoardButton boardNames={boards.map((board) => board.name)} />
|
||||
<CreateBoardButton />
|
||||
</Group>
|
||||
|
||||
<Grid mb={{ base: "xl", md: 0 }}>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -1,15 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback } from "react";
|
||||
import { Button, Group, Stack, TextInput } from "@mantine/core";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { revalidatePathActionAsync } from "@homarr/common/client";
|
||||
import { useZodForm } from "@homarr/form";
|
||||
import { createModal, useModalAction } from "@homarr/modals";
|
||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||
import { useModalAction } from "@homarr/modals";
|
||||
import { AddGroupModal } from "@homarr/modals-collection";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import { validation } from "@homarr/validation";
|
||||
|
||||
import { MobileAffixButton } from "~/components/manage/mobile-affix-button";
|
||||
|
||||
@@ -27,50 +22,3 @@ export const AddGroup = () => {
|
||||
</MobileAffixButton>
|
||||
);
|
||||
};
|
||||
|
||||
const AddGroupModal = createModal<void>(({ actions }) => {
|
||||
const t = useI18n();
|
||||
const { mutate, isPending } = clientApi.group.createGroup.useMutation();
|
||||
const form = useZodForm(validation.group.create, {
|
||||
initialValues: {
|
||||
name: "",
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={form.onSubmit((values) => {
|
||||
mutate(values, {
|
||||
onSuccess() {
|
||||
actions.closeModal();
|
||||
void revalidatePathActionAsync("/manage/users/groups");
|
||||
showSuccessNotification({
|
||||
title: t("common.notification.create.success"),
|
||||
message: t("group.action.create.notification.success.message"),
|
||||
});
|
||||
},
|
||||
onError() {
|
||||
showErrorNotification({
|
||||
title: t("common.notification.create.error"),
|
||||
message: t("group.action.create.notification.error.message"),
|
||||
});
|
||||
},
|
||||
});
|
||||
})}
|
||||
>
|
||||
<Stack>
|
||||
<TextInput label={t("group.field.name")} data-autofocus {...form.getInputProps("name")} />
|
||||
<Group justify="right">
|
||||
<Button onClick={actions.closeModal} variant="subtle" color="gray">
|
||||
{t("common.action.cancel")}
|
||||
</Button>
|
||||
<Button loading={isPending} type="submit" color="teal">
|
||||
{t("common.action.create")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</form>
|
||||
);
|
||||
}).withOptions({
|
||||
defaultTitle: (t) => t("group.action.create.label"),
|
||||
});
|
||||
|
||||
@@ -107,7 +107,7 @@ export const BoardItemMenu = ({
|
||||
}}
|
||||
>
|
||||
{tItem("action.moveResize")}
|
||||
</Menu.Item>{" "}
|
||||
</Menu.Item>
|
||||
<Menu.Item leftSection={<IconCopy size={16} />} onClick={() => duplicateItem({ itemId: item.id })}>
|
||||
{tItem("action.duplicate")}
|
||||
</Menu.Item>
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useState } from "react";
|
||||
import { Combobox, Group, Image, InputBase, Skeleton, Text, useCombobox } from "@mantine/core";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
import { useI18n, useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
interface IconPickerProps {
|
||||
initialValue?: string;
|
||||
@@ -18,7 +18,8 @@ export const IconPicker = ({ initialValue, onChange, error, onFocus, onBlur }: I
|
||||
const [search, setSearch] = useState(initialValue ?? "");
|
||||
const [previewUrl, setPreviewUrl] = useState<string | null>(initialValue ?? null);
|
||||
|
||||
const t = useScopedI18n("common");
|
||||
const t = useI18n();
|
||||
const tCommon = useScopedI18n("common");
|
||||
|
||||
const { data, isFetching } = clientApi.icon.findIcons.useQuery({
|
||||
searchText: search,
|
||||
@@ -89,13 +90,13 @@ export const IconPicker = ({ initialValue, onChange, error, onFocus, onBlur }: I
|
||||
rightSectionPointerEvents="none"
|
||||
withAsterisk
|
||||
error={error}
|
||||
label={t("iconPicker.label")}
|
||||
label={tCommon("iconPicker.label")}
|
||||
/>
|
||||
</Combobox.Target>
|
||||
|
||||
<Combobox.Dropdown>
|
||||
<Combobox.Header>
|
||||
<Text c="dimmed">{t("iconPicker.header", { countIcons: data?.countIcons })}</Text>
|
||||
<Text c="dimmed">{tCommon("iconPicker.header", { countIcons: data?.countIcons })}</Text>
|
||||
</Combobox.Header>
|
||||
<Combobox.Options mah={350} style={{ overflowY: "auto" }}>
|
||||
{totalOptions > 0 ? (
|
||||
|
||||
@@ -4,13 +4,13 @@ import { TextInput, UnstyledButton } from "@mantine/core";
|
||||
import { IconSearch } from "@tabler/icons-react";
|
||||
|
||||
import { openSpotlight } from "@homarr/spotlight";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
|
||||
import { HeaderButton } from "./button";
|
||||
import classes from "./search.module.css";
|
||||
|
||||
export const DesktopSearchInput = () => {
|
||||
const t = useScopedI18n("common.search");
|
||||
const t = useI18n();
|
||||
|
||||
return (
|
||||
<TextInput
|
||||
@@ -21,7 +21,10 @@ export const DesktopSearchInput = () => {
|
||||
leftSection={<IconSearch size={20} stroke={1.5} />}
|
||||
onClick={openSpotlight}
|
||||
>
|
||||
{t("placeholder")}
|
||||
{t("common.rtl", {
|
||||
value: t("search.placeholder"),
|
||||
symbol: "...",
|
||||
})}
|
||||
</TextInput>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
};
|
||||
|
||||
@@ -3,18 +3,14 @@ import { useDebouncedValue } from "@mantine/hooks";
|
||||
import { IconAlertTriangle, IconCircleCheck } from "@tabler/icons-react";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import type { MaybePromise } from "@homarr/common/types";
|
||||
import { revalidatePathActionAsync } from "@homarr/common/client";
|
||||
import { useZodForm } from "@homarr/form";
|
||||
import { createModal } from "@homarr/modals";
|
||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import { validation } from "@homarr/validation";
|
||||
|
||||
interface InnerProps {
|
||||
onSettled: () => MaybePromise<void>;
|
||||
}
|
||||
|
||||
export const AddBoardModal = createModal<InnerProps>(({ actions, innerProps }) => {
|
||||
export const AddBoardModal = createModal(({ actions }) => {
|
||||
const t = useI18n();
|
||||
const form = useZodForm(validation.board.create, {
|
||||
mode: "controlled",
|
||||
@@ -25,7 +21,9 @@ export const AddBoardModal = createModal<InnerProps>(({ actions, innerProps }) =
|
||||
},
|
||||
});
|
||||
const { mutate, isPending } = clientApi.board.createBoard.useMutation({
|
||||
onSettled: innerProps.onSettled,
|
||||
onSettled: async () => {
|
||||
await revalidatePathActionAsync("/manage/boards");
|
||||
},
|
||||
});
|
||||
|
||||
const boardNameStatus = useBoardNameStatus(form.values.name);
|
||||
|
||||
56
packages/modals-collection/src/groups/add-group-modal.tsx
Normal file
56
packages/modals-collection/src/groups/add-group-modal.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
import { Button, Group, Stack, TextInput } from "@mantine/core";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { revalidatePathActionAsync } from "@homarr/common/client";
|
||||
import { useZodForm } from "@homarr/form";
|
||||
import { createModal } from "@homarr/modals";
|
||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import { validation } from "@homarr/validation";
|
||||
|
||||
export const AddGroupModal = createModal<void>(({ actions }) => {
|
||||
const t = useI18n();
|
||||
const { mutate, isPending } = clientApi.group.createGroup.useMutation();
|
||||
const form = useZodForm(validation.group.create, {
|
||||
initialValues: {
|
||||
name: "",
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={form.onSubmit((values) => {
|
||||
mutate(values, {
|
||||
onSuccess() {
|
||||
actions.closeModal();
|
||||
void revalidatePathActionAsync("/manage/users/groups");
|
||||
showSuccessNotification({
|
||||
title: t("common.notification.create.success"),
|
||||
message: t("group.action.create.notification.success.message"),
|
||||
});
|
||||
},
|
||||
onError() {
|
||||
showErrorNotification({
|
||||
title: t("common.notification.create.error"),
|
||||
message: t("group.action.create.notification.error.message"),
|
||||
});
|
||||
},
|
||||
});
|
||||
})}
|
||||
>
|
||||
<Stack>
|
||||
<TextInput label={t("group.field.name")} data-autofocus {...form.getInputProps("name")} />
|
||||
<Group justify="right">
|
||||
<Button onClick={actions.closeModal} variant="subtle" color="gray">
|
||||
{t("common.action.cancel")}
|
||||
</Button>
|
||||
<Button loading={isPending} type="submit" color="teal">
|
||||
{t("common.action.create")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</form>
|
||||
);
|
||||
}).withOptions({
|
||||
defaultTitle: (t) => t("group.action.create.label"),
|
||||
});
|
||||
1
packages/modals-collection/src/groups/index.ts
Normal file
1
packages/modals-collection/src/groups/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { AddGroupModal } from "./add-group-modal";
|
||||
@@ -1,2 +1,3 @@
|
||||
export * from "./boards";
|
||||
export * from "./invites";
|
||||
export * from "./groups";
|
||||
|
||||
@@ -1,145 +0,0 @@
|
||||
# Spotlight
|
||||
|
||||
Spotlight is the search functionality of Homarr. It can be opened by pressing `Ctrl + K` or `Cmd + K` on Mac. It is a quick way to search for anything in Homarr.
|
||||
|
||||
## API
|
||||
|
||||
### SpotlightActionData
|
||||
|
||||
The [SpotlightActionData](./src/type.ts) is the data structure that is used to define the actions that are shown in the spotlight.
|
||||
|
||||
#### Common properties
|
||||
|
||||
| Name | Type | Description |
|
||||
| ------------------------------ | ------------------------------------------------------------- | ----------------------------------------------------------------------------------- |
|
||||
| id | `string` | The id of the action. |
|
||||
| title | `string \| (t: TranslationFunction) => string` | The title of the action. Either static or generated with translation function |
|
||||
| description | `string \| (t: TranslationFunction) => string` | The description of the action. Either static or generated with translation function |
|
||||
| icon | `string \| TablerIcon` | The icon of the action. Either a url to an image or a TablerIcon |
|
||||
| group | `string` | The group of the action. By default the groups all, web and action exist. |
|
||||
| ignoreSearchAndOnlyShowInGroup | `boolean` | If true, the action will only be shown in the group and not in the search results. |
|
||||
| type | `'link' \| 'button'` | The type of the action. Either link or button |
|
||||
|
||||
#### Properties for links
|
||||
|
||||
| Name | Type | Description |
|
||||
| ---- | -------- | ---------------------------------------------------------------------------------------------------------- |
|
||||
| href | `string` | The url the link should navigate to. If %s is contained it will be replaced with the current search query. |
|
||||
|
||||
#### Properties for buttons
|
||||
|
||||
| Name | Type | Description |
|
||||
| ------- | -------------------------- | ----------------------------------------------------------------------------------------- |
|
||||
| onClick | `() => MaybePromise<void>` | The function that should be called when the button is clicked. It can be async if needed. |
|
||||
|
||||
### useRegisterSpotlightActions
|
||||
|
||||
The [useRegisterSpotlightActions](./src/data-store.ts) hook is used to register actions to the spotlight. It takes an unique key and the array of [SpotlightActionData](#SpotlightActionData).
|
||||
|
||||
#### Usage
|
||||
|
||||
The following example shows how to use the `useRegisterSpotlightActions` hook to register an action to the spotlight.
|
||||
|
||||
```tsx
|
||||
"use client";
|
||||
|
||||
import { useRegisterSpotlightActions } from "@homarr/spotlight";
|
||||
|
||||
const MyComponent = () => {
|
||||
useRegisterSpotlightActions("my-component", [
|
||||
{
|
||||
id: "my-action",
|
||||
title: "My Action",
|
||||
description: "This is my action",
|
||||
icon: "https://example.com/icon.png",
|
||||
group: "web",
|
||||
type: "link",
|
||||
href: "https://example.com",
|
||||
},
|
||||
]);
|
||||
|
||||
return <div>My Component</div>;
|
||||
};
|
||||
```
|
||||
|
||||
##### Using translation function
|
||||
|
||||
```tsx
|
||||
"use client";
|
||||
|
||||
import { useRegisterSpotlightActions } from "@homarr/spotlight";
|
||||
|
||||
const MyComponent = () => {
|
||||
useRegisterSpotlightActions("my-component", [
|
||||
{
|
||||
id: "my-action",
|
||||
title: (t) => t("some.path.to.translation.key"),
|
||||
description: (t) => t("some.other.path.to.translation.key"),
|
||||
icon: "https://example.com/icon.png",
|
||||
group: "web",
|
||||
type: "link",
|
||||
href: "https://example.com",
|
||||
},
|
||||
]);
|
||||
|
||||
return <div>Component implementation</div>;
|
||||
};
|
||||
```
|
||||
|
||||
##### Using TablerIcon
|
||||
|
||||
```tsx
|
||||
"use client";
|
||||
|
||||
import { IconUserCog } from "tabler-react";
|
||||
|
||||
import { useRegisterSpotlightActions } from "@homarr/spotlight";
|
||||
|
||||
const UserMenu = () => {
|
||||
useRegisterSpotlightActions("header-user-menu", [
|
||||
{
|
||||
id: "user-preferences",
|
||||
title: (t) => t("user.preferences.title"),
|
||||
description: (t) => t("user.preferences.description"),
|
||||
icon: IconUserCog,
|
||||
group: "action",
|
||||
type: "link",
|
||||
href: "/user/preferences",
|
||||
},
|
||||
]);
|
||||
|
||||
return <div>Component implementation</div>;
|
||||
};
|
||||
```
|
||||
|
||||
##### Using dependency array
|
||||
|
||||
```tsx
|
||||
"use client";
|
||||
|
||||
import { IconUserCog } from "tabler-react";
|
||||
|
||||
import { useRegisterSpotlightActions } from "@homarr/spotlight";
|
||||
|
||||
const ColorSchemeButton = () => {
|
||||
const { colorScheme, toggleColorScheme } = useColorScheme();
|
||||
|
||||
useRegisterSpotlightActions(
|
||||
"toggle-color-scheme",
|
||||
[
|
||||
{
|
||||
id: "toggle-color-scheme",
|
||||
title: (t) => t("common.colorScheme.toggle.title"),
|
||||
description: (t) => t(`common.colorScheme.toggle.${colorScheme}.description`),
|
||||
icon: colorScheme === "light" ? IconSun : IconMoon,
|
||||
group: "action",
|
||||
type: "button",
|
||||
onClick: toggleColorScheme,
|
||||
},
|
||||
],
|
||||
[colorScheme],
|
||||
);
|
||||
|
||||
return <div>Component implementation</div>;
|
||||
};
|
||||
```
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
import { Chip } from "@mantine/core";
|
||||
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import { selectNextAction, selectPreviousAction, spotlightStore, triggerSelectedAction } from "./spotlight-store";
|
||||
import type { SpotlightActionGroup } from "./type";
|
||||
|
||||
const disableArrowUpAndDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (event.key === "ArrowDown") {
|
||||
selectNextAction(spotlightStore);
|
||||
event.preventDefault();
|
||||
} else if (event.key === "ArrowUp") {
|
||||
selectPreviousAction(spotlightStore);
|
||||
event.preventDefault();
|
||||
} else if (event.key === "Enter") {
|
||||
triggerSelectedAction(spotlightStore);
|
||||
}
|
||||
};
|
||||
|
||||
const focusActiveByDefault = (event: React.FocusEvent<HTMLInputElement>) => {
|
||||
const relatedTarget = event.relatedTarget;
|
||||
|
||||
const isPreviousTargetRadio = relatedTarget && "type" in relatedTarget && relatedTarget.type === "radio";
|
||||
if (isPreviousTargetRadio) return;
|
||||
|
||||
const group = event.currentTarget.parentElement?.parentElement;
|
||||
if (!group) return;
|
||||
const label = group.querySelector<HTMLLabelElement>("label[data-checked]");
|
||||
if (!label) return;
|
||||
label.focus();
|
||||
};
|
||||
|
||||
interface Props {
|
||||
group: SpotlightActionGroup;
|
||||
}
|
||||
|
||||
export const GroupChip = ({ group }: Props) => {
|
||||
const t = useScopedI18n("common.search.group");
|
||||
return (
|
||||
<Chip key={group} value={group} onFocus={focusActiveByDefault} onKeyDown={disableArrowUpAndDown}>
|
||||
{t(group)}
|
||||
</Chip>
|
||||
);
|
||||
};
|
||||
@@ -1,126 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { Center, Chip, Divider, Flex, Group, Text } from "@mantine/core";
|
||||
import { Spotlight as MantineSpotlight, SpotlightAction } from "@mantine/spotlight";
|
||||
import { IconSearch } from "@tabler/icons-react";
|
||||
import { useAtomValue } from "jotai";
|
||||
|
||||
import type { TranslationFunction } from "@homarr/translation";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
|
||||
import { GroupChip } from "./chip-group";
|
||||
import classes from "./component.module.css";
|
||||
import { actionsAtomRead, groupsAtomRead } from "./data-store";
|
||||
import { setSelectedAction, spotlightStore } from "./spotlight-store";
|
||||
import type { SpotlightActionData } from "./type";
|
||||
import { useWebSearchEngines } from "./web-search-engines";
|
||||
|
||||
export const Spotlight = () => {
|
||||
useWebSearchEngines();
|
||||
const [query, setQuery] = useState("");
|
||||
const [group, setGroup] = useState("all");
|
||||
const groups = useAtomValue(groupsAtomRead);
|
||||
const actions = useAtomValue(actionsAtomRead);
|
||||
const t = useI18n();
|
||||
|
||||
const preparedActions = actions.map((action) => prepareAction(action, t));
|
||||
const items = preparedActions
|
||||
.filter(
|
||||
(item) =>
|
||||
(item.ignoreSearchAndOnlyShowInGroup
|
||||
? item.group === group
|
||||
: item.title.toLowerCase().includes(query.toLowerCase().trim())) &&
|
||||
(group === "all" || item.group === group),
|
||||
)
|
||||
.map((item) => {
|
||||
const renderRoot =
|
||||
item.type === "link"
|
||||
? (props: Record<string, unknown>) => (
|
||||
<Link href={prepareHref(item.href, query)} target={item.openInNewTab ? "_blank" : undefined} {...props} />
|
||||
)
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<SpotlightAction
|
||||
key={item.id}
|
||||
renderRoot={renderRoot}
|
||||
onClick={item.type === "button" ? item.onClick : undefined}
|
||||
className={classes.spotlightAction}
|
||||
>
|
||||
<Group wrap="nowrap" w="100%">
|
||||
{item.icon && (
|
||||
<Center w={50} h={50}>
|
||||
{typeof item.icon !== "string" && <item.icon size={24} />}
|
||||
{typeof item.icon === "string" && <img src={item.icon} alt={item.title} width={24} height={24} />}
|
||||
</Center>
|
||||
)}
|
||||
|
||||
<Flex direction="column">
|
||||
<Text>{item.title}</Text>
|
||||
|
||||
{item.description && (
|
||||
<Text opacity={0.6} size="xs">
|
||||
{item.description}
|
||||
</Text>
|
||||
)}
|
||||
</Flex>
|
||||
</Group>
|
||||
</SpotlightAction>
|
||||
);
|
||||
});
|
||||
|
||||
const onGroupChange = useCallback(
|
||||
(group: string) => {
|
||||
setSelectedAction(-1, spotlightStore);
|
||||
setGroup(group);
|
||||
},
|
||||
[setGroup, setSelectedAction],
|
||||
);
|
||||
|
||||
return (
|
||||
<MantineSpotlight.Root query={query} onQueryChange={setQuery} store={spotlightStore}>
|
||||
<MantineSpotlight.Search
|
||||
placeholder={t("common.rtl", {
|
||||
value: t("common.search.placeholder"),
|
||||
symbol: "...",
|
||||
})}
|
||||
leftSection={<IconSearch stroke={1.5} />}
|
||||
/>
|
||||
|
||||
<Divider />
|
||||
<Group wrap="nowrap" p="sm">
|
||||
<Chip.Group multiple={false} value={group} onChange={onGroupChange}>
|
||||
<Group justify="start">
|
||||
{groups.map((group) => (
|
||||
<GroupChip key={group} group={group} />
|
||||
))}
|
||||
</Group>
|
||||
</Chip.Group>
|
||||
</Group>
|
||||
|
||||
<MantineSpotlight.ActionsList>
|
||||
{items.length > 0 ? items : <MantineSpotlight.Empty>{t("common.search.nothingFound")}</MantineSpotlight.Empty>}
|
||||
</MantineSpotlight.ActionsList>
|
||||
</MantineSpotlight.Root>
|
||||
);
|
||||
};
|
||||
|
||||
const prepareHref = (href: string, query: string) => {
|
||||
return href.replace("%s", query);
|
||||
};
|
||||
|
||||
const translateIfNecessary = (value: string | ((t: TranslationFunction) => string), t: TranslationFunction) => {
|
||||
if (typeof value === "function") {
|
||||
return value(t);
|
||||
}
|
||||
|
||||
return value;
|
||||
};
|
||||
|
||||
const prepareAction = (action: SpotlightActionData, t: TranslationFunction) => ({
|
||||
...action,
|
||||
title: translateIfNecessary(action.title, t),
|
||||
description: translateIfNecessary(action.description, t),
|
||||
});
|
||||
@@ -0,0 +1,17 @@
|
||||
import type { inferSearchInteractionOptions } from "../../lib/interaction";
|
||||
import { ChildrenActionItem } from "./items/children-action-item";
|
||||
|
||||
interface SpotlightChildrenActionsProps {
|
||||
childrenOptions: inferSearchInteractionOptions<"children">;
|
||||
query: string;
|
||||
}
|
||||
|
||||
export const SpotlightChildrenActions = ({ childrenOptions, query }: SpotlightChildrenActionsProps) => {
|
||||
const actions = childrenOptions.useActions(childrenOptions.option, query);
|
||||
|
||||
return actions
|
||||
.filter((action) => (typeof action.hide === "function" ? !action.hide(childrenOptions.option) : !action.hide))
|
||||
.map((action) => (
|
||||
<ChildrenActionItem key={action.key} childrenOptions={childrenOptions} query={query} action={action} />
|
||||
));
|
||||
};
|
||||
87
packages/spotlight/src/components/actions/group-actions.tsx
Normal file
87
packages/spotlight/src/components/actions/group-actions.tsx
Normal file
@@ -0,0 +1,87 @@
|
||||
import { Center, Loader } from "@mantine/core";
|
||||
|
||||
import type { TranslationObject } from "@homarr/translation";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
|
||||
import type { SearchGroup } from "../../lib/group";
|
||||
import type { inferSearchInteractionOptions } from "../../lib/interaction";
|
||||
import { SpotlightNoResults } from "../no-results";
|
||||
import { SpotlightGroupActionItem } from "./items/group-action-item";
|
||||
|
||||
interface GroupActionsProps<TOption extends Record<string, unknown>> {
|
||||
group: SearchGroup<TOption>;
|
||||
query: string;
|
||||
setMode: (mode: keyof TranslationObject["search"]["mode"]) => void;
|
||||
setChildrenOptions: (options: inferSearchInteractionOptions<"children">) => void;
|
||||
}
|
||||
|
||||
export const SpotlightGroupActions = <TOption extends Record<string, unknown>>({
|
||||
group,
|
||||
query,
|
||||
setMode,
|
||||
setChildrenOptions,
|
||||
}: GroupActionsProps<TOption>) => {
|
||||
// This does work as the same amount of hooks is called on every render
|
||||
const useOptions =
|
||||
"options" in group ? () => group.options : "useOptions" in group ? group.useOptions : group.useQueryOptions;
|
||||
const options = useOptions(query);
|
||||
const t = useI18n();
|
||||
|
||||
if (Array.isArray(options)) {
|
||||
const filteredOptions = options
|
||||
.filter((option) => ("filter" in group ? group.filter(query, option) : false))
|
||||
.sort((optionA, optionB) => {
|
||||
if ("sort" in group) {
|
||||
return group.sort?.(query, [optionA, optionB]) ?? 0;
|
||||
}
|
||||
|
||||
return 0;
|
||||
});
|
||||
|
||||
if (filteredOptions.length === 0) {
|
||||
return <SpotlightNoResults />;
|
||||
}
|
||||
|
||||
return filteredOptions.map((option) => (
|
||||
<SpotlightGroupActionItem
|
||||
key={option[group.keyPath] as never}
|
||||
option={option}
|
||||
group={group}
|
||||
query={query}
|
||||
setMode={setMode}
|
||||
setChildrenOptions={setChildrenOptions}
|
||||
/>
|
||||
));
|
||||
}
|
||||
|
||||
if (options.isLoading) {
|
||||
return (
|
||||
<Center w="100%" py="sm">
|
||||
<Loader size="sm" />
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
|
||||
if (options.isError) {
|
||||
return <Center py="sm">{t("search.error.fetch")}</Center>;
|
||||
}
|
||||
|
||||
if (!options.data) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (options.data.length === 0) {
|
||||
return <SpotlightNoResults />;
|
||||
}
|
||||
|
||||
return options.data.map((option) => (
|
||||
<SpotlightGroupActionItem
|
||||
key={option[group.keyPath] as never}
|
||||
option={option}
|
||||
group={group}
|
||||
query={query}
|
||||
setMode={setMode}
|
||||
setChildrenOptions={setChildrenOptions}
|
||||
/>
|
||||
));
|
||||
};
|
||||
@@ -0,0 +1,32 @@
|
||||
import { Spotlight } from "@mantine/spotlight";
|
||||
|
||||
import type { TranslationObject } from "@homarr/translation";
|
||||
import { translateIfNecessary } from "@homarr/translation";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
|
||||
import type { SearchGroup } from "../../../lib/group";
|
||||
import type { inferSearchInteractionOptions } from "../../../lib/interaction";
|
||||
import { SpotlightGroupActions } from "../group-actions";
|
||||
|
||||
interface SpotlightActionGroupsProps {
|
||||
groups: SearchGroup[];
|
||||
query: string;
|
||||
setMode: (mode: keyof TranslationObject["search"]["mode"]) => void;
|
||||
setChildrenOptions: (options: inferSearchInteractionOptions<"children">) => void;
|
||||
}
|
||||
|
||||
export const SpotlightActionGroups = ({ groups, query, setMode, setChildrenOptions }: SpotlightActionGroupsProps) => {
|
||||
const t = useI18n();
|
||||
|
||||
return groups.map((group) => (
|
||||
<Spotlight.ActionsGroup key={translateIfNecessary(t, group.title)} label={translateIfNecessary(t, group.title)}>
|
||||
{/*eslint-disable-next-line @typescript-eslint/no-explicit-any */}
|
||||
<SpotlightGroupActions<any>
|
||||
group={group}
|
||||
query={query}
|
||||
setMode={setMode}
|
||||
setChildrenOptions={setChildrenOptions}
|
||||
/>
|
||||
</Spotlight.ActionsGroup>
|
||||
));
|
||||
};
|
||||
@@ -0,0 +1,30 @@
|
||||
import Link from "next/link";
|
||||
import { Spotlight } from "@mantine/spotlight";
|
||||
|
||||
import type { inferSearchInteractionOptions } from "../../../lib/interaction";
|
||||
import classes from "./action-item.module.css";
|
||||
|
||||
interface ChildrenActionItemProps {
|
||||
childrenOptions: inferSearchInteractionOptions<"children">;
|
||||
query: string;
|
||||
action: ReturnType<inferSearchInteractionOptions<"children">["useActions"]>[number];
|
||||
}
|
||||
|
||||
export const ChildrenActionItem = ({ childrenOptions, action, query }: ChildrenActionItemProps) => {
|
||||
const interaction = action.useInteraction(childrenOptions.option, query);
|
||||
|
||||
const renderRoot =
|
||||
interaction.type === "link"
|
||||
? (props: Record<string, unknown>) => {
|
||||
return <Link href={interaction.href} target={interaction.newTab ? "_blank" : undefined} {...props} />;
|
||||
}
|
||||
: undefined;
|
||||
|
||||
const onClick = interaction.type === "javaScript" ? interaction.onSelect : undefined;
|
||||
|
||||
return (
|
||||
<Spotlight.Action renderRoot={renderRoot} onClick={onClick} className={classes.spotlightAction}>
|
||||
<action.component {...childrenOptions.option} />
|
||||
</Spotlight.Action>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,54 @@
|
||||
import Link from "next/link";
|
||||
import { Spotlight } from "@mantine/spotlight";
|
||||
|
||||
import type { TranslationObject } from "@homarr/translation";
|
||||
|
||||
import type { SearchGroup } from "../../../lib/group";
|
||||
import type { inferSearchInteractionOptions } from "../../../lib/interaction";
|
||||
import classes from "./action-item.module.css";
|
||||
|
||||
interface SpotlightGroupActionItemProps<TOption extends Record<string, unknown>> {
|
||||
option: TOption;
|
||||
query: string;
|
||||
setMode: (mode: keyof TranslationObject["search"]["mode"]) => void;
|
||||
setChildrenOptions: (options: inferSearchInteractionOptions<"children">) => void;
|
||||
group: SearchGroup<TOption>;
|
||||
}
|
||||
|
||||
export const SpotlightGroupActionItem = <TOption extends Record<string, unknown>>({
|
||||
group,
|
||||
query,
|
||||
setMode,
|
||||
setChildrenOptions,
|
||||
option,
|
||||
}: SpotlightGroupActionItemProps<TOption>) => {
|
||||
const interaction = group.useInteraction(option, query);
|
||||
|
||||
const renderRoot =
|
||||
interaction.type === "link"
|
||||
? (props: Record<string, unknown>) => {
|
||||
return <Link href={interaction.href} target={interaction.newTab ? "_blank" : undefined} {...props} />;
|
||||
}
|
||||
: undefined;
|
||||
|
||||
const handleClickAsync = async () => {
|
||||
if (interaction.type === "javaScript") {
|
||||
await interaction.onSelect();
|
||||
} else if (interaction.type === "mode") {
|
||||
setMode(interaction.mode);
|
||||
} else if (interaction.type === "children") {
|
||||
setChildrenOptions(interaction);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Spotlight.Action
|
||||
renderRoot={renderRoot}
|
||||
onClick={handleClickAsync}
|
||||
closeSpotlightOnTrigger={interaction.type !== "mode" && interaction.type !== "children"}
|
||||
className={classes.spotlightAction}
|
||||
>
|
||||
<group.component {...option} />
|
||||
</Spotlight.Action>
|
||||
);
|
||||
};
|
||||
9
packages/spotlight/src/components/no-results.tsx
Normal file
9
packages/spotlight/src/components/no-results.tsx
Normal file
@@ -0,0 +1,9 @@
|
||||
import { Spotlight } from "@mantine/spotlight";
|
||||
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
|
||||
export const SpotlightNoResults = () => {
|
||||
const t = useI18n();
|
||||
|
||||
return <Spotlight.Empty>{t("search.nothingFound")}</Spotlight.Empty>;
|
||||
};
|
||||
118
packages/spotlight/src/components/spotlight.tsx
Normal file
118
packages/spotlight/src/components/spotlight.tsx
Normal file
@@ -0,0 +1,118 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useRef, useState } from "react";
|
||||
import { ActionIcon, Center, Group, Kbd } from "@mantine/core";
|
||||
import { Spotlight as MantineSpotlight } from "@mantine/spotlight";
|
||||
import { IconSearch, IconX } from "@tabler/icons-react";
|
||||
|
||||
import type { TranslationObject } from "@homarr/translation";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
|
||||
import type { inferSearchInteractionOptions } from "../lib/interaction";
|
||||
import { searchModes } from "../modes";
|
||||
import { selectAction, spotlightStore } from "../spotlight-store";
|
||||
import { SpotlightChildrenActions } from "./actions/children-actions";
|
||||
import { SpotlightActionGroups } from "./actions/groups/action-group";
|
||||
|
||||
export const Spotlight = () => {
|
||||
const [query, setQuery] = useState("");
|
||||
const [mode, setMode] = useState<keyof TranslationObject["search"]["mode"]>("help");
|
||||
const [childrenOptions, setChildrenOptions] = useState<inferSearchInteractionOptions<"children"> | null>(null);
|
||||
const t = useI18n();
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const activeMode = useMemo(() => searchModes.find((searchMode) => searchMode.modeKey === mode), [mode]);
|
||||
|
||||
if (!activeMode) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<MantineSpotlight.Root
|
||||
onSpotlightClose={() => {
|
||||
setMode("help");
|
||||
setChildrenOptions(null);
|
||||
}}
|
||||
query={query}
|
||||
onQueryChange={(query) => {
|
||||
if (mode !== "help" || query.length !== 1) {
|
||||
setQuery(query);
|
||||
}
|
||||
|
||||
const modeToActivate = searchModes.find((mode) => mode.character === query);
|
||||
if (!modeToActivate) {
|
||||
return;
|
||||
}
|
||||
|
||||
setMode(modeToActivate.modeKey);
|
||||
setQuery("");
|
||||
setTimeout(() => selectAction(0, spotlightStore));
|
||||
}}
|
||||
store={spotlightStore}
|
||||
>
|
||||
<MantineSpotlight.Search
|
||||
placeholder={t("common.rtl", {
|
||||
value: t("search.placeholder"),
|
||||
symbol: "...",
|
||||
})}
|
||||
ref={inputRef}
|
||||
leftSectionWidth={activeMode.modeKey !== "help" ? 80 : 48}
|
||||
leftSection={
|
||||
<Group align="center" wrap="nowrap" gap="xs" w="100%" h="100%">
|
||||
<Center w={48} h="100%">
|
||||
<IconSearch stroke={1.5} />
|
||||
</Center>
|
||||
{activeMode.modeKey !== "help" ? <Kbd size="sm">{activeMode.character}</Kbd> : null}
|
||||
</Group>
|
||||
}
|
||||
rightSection={
|
||||
mode === "help" ? undefined : (
|
||||
<ActionIcon
|
||||
onClick={() => {
|
||||
setMode("help");
|
||||
setChildrenOptions(null);
|
||||
inputRef.current?.focus();
|
||||
}}
|
||||
variant="subtle"
|
||||
>
|
||||
<IconX stroke={1.5} />
|
||||
</ActionIcon>
|
||||
)
|
||||
}
|
||||
value={query}
|
||||
onKeyDown={(event) => {
|
||||
if (query.length === 0 && mode !== "help" && event.key === "Backspace") {
|
||||
setMode("help");
|
||||
setChildrenOptions(null);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
{childrenOptions ? (
|
||||
<Group>
|
||||
<childrenOptions.detailComponent options={childrenOptions.option as never} />
|
||||
</Group>
|
||||
) : null}
|
||||
|
||||
<MantineSpotlight.ActionsList>
|
||||
{childrenOptions ? (
|
||||
<SpotlightChildrenActions childrenOptions={childrenOptions} query={query} />
|
||||
) : (
|
||||
<SpotlightActionGroups
|
||||
setMode={(mode) => {
|
||||
setMode(mode);
|
||||
setChildrenOptions(null);
|
||||
setTimeout(() => selectAction(0, spotlightStore));
|
||||
}}
|
||||
setChildrenOptions={(options) => {
|
||||
setChildrenOptions(options);
|
||||
setQuery("");
|
||||
setTimeout(() => selectAction(0, spotlightStore));
|
||||
}}
|
||||
query={query}
|
||||
groups={activeMode.groups}
|
||||
/>
|
||||
)}
|
||||
</MantineSpotlight.ActionsList>
|
||||
</MantineSpotlight.Root>
|
||||
);
|
||||
};
|
||||
@@ -1,69 +0,0 @@
|
||||
import { useEffect } from "react";
|
||||
import { atom, useSetAtom } from "jotai";
|
||||
import useDeepCompareEffect from "use-deep-compare-effect";
|
||||
|
||||
import type { SpotlightActionData, SpotlightActionGroup } from "./type";
|
||||
|
||||
const defaultGroups = ["all", "web", "action"] as const;
|
||||
const reversedDefaultGroups = [...defaultGroups].reverse() as string[];
|
||||
const actionsAtom = atom<Record<string, readonly SpotlightActionData[]>>({});
|
||||
export const actionsAtomRead = atom((get) => Object.values(get(actionsAtom)).flatMap((item) => item));
|
||||
|
||||
export const groupsAtomRead = atom((get) =>
|
||||
Array.from(
|
||||
new Set(
|
||||
get(actionsAtomRead)
|
||||
.map((item) => item.group as SpotlightActionGroup) // Allow "all" group to be included in the list of groups
|
||||
.concat(...defaultGroups),
|
||||
),
|
||||
)
|
||||
.sort((groupA, groupB) => {
|
||||
const groupAIndex = reversedDefaultGroups.indexOf(groupA);
|
||||
const groupBIndex = reversedDefaultGroups.indexOf(groupB);
|
||||
|
||||
// if both groups are not in the default groups, sort them by name (here reversed because we reverse the array afterwards)
|
||||
if (groupAIndex === -1 && groupBIndex === -1) {
|
||||
return groupB.localeCompare(groupA);
|
||||
}
|
||||
|
||||
return groupAIndex - groupBIndex;
|
||||
})
|
||||
.reverse(),
|
||||
);
|
||||
|
||||
const registrations = new Map<string, number>();
|
||||
|
||||
export const useRegisterSpotlightActions = (
|
||||
key: string,
|
||||
actions: SpotlightActionData[],
|
||||
dependencies: readonly unknown[] = [],
|
||||
) => {
|
||||
const setActions = useSetAtom(actionsAtom);
|
||||
|
||||
// Use deep compare effect if there are dependencies for the actions, this supports deep compare of the action dependencies
|
||||
const useSpecificEffect = dependencies.length >= 1 ? useDeepCompareEffect : useEffect;
|
||||
|
||||
useSpecificEffect(() => {
|
||||
if (!registrations.has(key) || dependencies.length >= 1) {
|
||||
setActions((prev) => ({
|
||||
...prev,
|
||||
[key]: actions,
|
||||
}));
|
||||
}
|
||||
registrations.set(key, (registrations.get(key) ?? 0) + 1);
|
||||
|
||||
return () => {
|
||||
if (registrations.get(key) === 1) {
|
||||
setActions((prev) => {
|
||||
const { [key]: _, ...rest } = prev;
|
||||
return rest;
|
||||
});
|
||||
}
|
||||
|
||||
registrations.set(key, (registrations.get(key) ?? 0) - 1);
|
||||
if (registrations.get(key) === 0) {
|
||||
registrations.delete(key);
|
||||
}
|
||||
};
|
||||
}, [key, dependencies.length >= 1 ? dependencies : undefined]);
|
||||
};
|
||||
@@ -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;
|
||||
|
||||
24
packages/spotlight/src/lib/children.ts
Normal file
24
packages/spotlight/src/lib/children.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
import type { inferSearchInteractionDefinition } from "./interaction";
|
||||
|
||||
export interface CreateChildrenOptionsProps<TParentOptions extends Record<string, unknown>> {
|
||||
detailComponent: ({ options }: { options: TParentOptions }) => ReactNode;
|
||||
useActions: (options: TParentOptions, query: string) => ChildrenAction<TParentOptions>[];
|
||||
}
|
||||
|
||||
export interface ChildrenAction<TParentOptions extends Record<string, unknown>> {
|
||||
key: string;
|
||||
component: (option: TParentOptions) => JSX.Element;
|
||||
useInteraction: (option: TParentOptions, query: string) => inferSearchInteractionDefinition<"link" | "javaScript">;
|
||||
hide?: boolean | ((option: TParentOptions) => boolean);
|
||||
}
|
||||
|
||||
export const createChildrenOptions = <TParentOptions extends Record<string, unknown>>(
|
||||
props: CreateChildrenOptionsProps<TParentOptions>,
|
||||
) => {
|
||||
return (option: TParentOptions) => ({
|
||||
option,
|
||||
...props,
|
||||
});
|
||||
};
|
||||
28
packages/spotlight/src/lib/group.ts
Normal file
28
packages/spotlight/src/lib/group.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import type { UseTRPCQueryResult } from "@trpc/react-query/shared";
|
||||
|
||||
import type { stringOrTranslation } from "@homarr/translation";
|
||||
|
||||
import type { inferSearchInteractionDefinition, SearchInteraction } from "./interaction";
|
||||
|
||||
type CommonSearchGroup<TOption extends Record<string, unknown>, TOptionProps extends Record<string, unknown>> = {
|
||||
// key path is used to define the path to a unique key in the option object
|
||||
keyPath: keyof TOption;
|
||||
title: stringOrTranslation;
|
||||
component: (option: TOption) => JSX.Element;
|
||||
useInteraction: (option: TOption, query: string) => inferSearchInteractionDefinition<SearchInteraction>;
|
||||
} & TOptionProps;
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export type SearchGroup<TOption extends Record<string, unknown> = any> =
|
||||
| CommonSearchGroup<TOption, { filter: (query: string, option: TOption) => boolean; options: TOption[] }>
|
||||
| CommonSearchGroup<
|
||||
TOption,
|
||||
{
|
||||
filter: (query: string, option: TOption) => boolean;
|
||||
sort?: (query: string, options: [TOption, TOption]) => number;
|
||||
useOptions: () => TOption[];
|
||||
}
|
||||
>
|
||||
| CommonSearchGroup<TOption, { useQueryOptions: (query: string) => UseTRPCQueryResult<TOption[], unknown> }>;
|
||||
|
||||
export const createGroup = <TOption extends Record<string, unknown>>(group: SearchGroup<TOption>) => group;
|
||||
56
packages/spotlight/src/lib/interaction.ts
Normal file
56
packages/spotlight/src/lib/interaction.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import type { MaybePromise } from "@homarr/common/types";
|
||||
import type { TranslationObject } from "@homarr/translation";
|
||||
|
||||
import type { CreateChildrenOptionsProps } from "./children";
|
||||
|
||||
const createSearchInteraction = <TType extends string>(type: TType) => ({
|
||||
optionsType: <TOption extends Record<string, unknown>>() => ({ type, _inferOptions: {} as TOption }),
|
||||
});
|
||||
|
||||
// This is used to define search interactions with their options
|
||||
const searchInteractions = [
|
||||
createSearchInteraction("link").optionsType<{ href: string; newTab?: boolean }>(),
|
||||
createSearchInteraction("javaScript").optionsType<{ onSelect: () => MaybePromise<void> }>(),
|
||||
createSearchInteraction("mode").optionsType<{ mode: keyof TranslationObject["search"]["mode"] }>(),
|
||||
createSearchInteraction("children").optionsType<{
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
useActions: CreateChildrenOptionsProps<any>["useActions"];
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
detailComponent: CreateChildrenOptionsProps<any>["detailComponent"];
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
option: any;
|
||||
}>(),
|
||||
] as const;
|
||||
|
||||
// Union of all search interactions types
|
||||
export type SearchInteraction = (typeof searchInteractions)[number]["type"];
|
||||
|
||||
// Infer the options for the specified search interaction
|
||||
export type inferSearchInteractionOptions<TInteraction extends SearchInteraction> = Extract<
|
||||
(typeof searchInteractions)[number],
|
||||
{ type: TInteraction }
|
||||
>["_inferOptions"];
|
||||
|
||||
// Infer the search interaction definition (type + options) for the specified search interaction
|
||||
export type inferSearchInteractionDefinition<TInteraction extends SearchInteraction> = {
|
||||
[interactionKey in TInteraction]: { type: interactionKey } & inferSearchInteractionOptions<interactionKey>;
|
||||
}[TInteraction];
|
||||
|
||||
// Type used for helper functions to define basic search interactions
|
||||
type SearchInteractions = {
|
||||
[optionKey in SearchInteraction]: <TOption extends Record<string, unknown>>(
|
||||
callback: (option: TOption, query: string) => inferSearchInteractionOptions<optionKey>,
|
||||
) => (option: TOption, query: string) => inferSearchInteractionDefinition<optionKey>;
|
||||
};
|
||||
|
||||
// Helper functions to define basic search interactions
|
||||
export const interaction = searchInteractions.reduce((acc, interaction) => {
|
||||
return {
|
||||
...acc,
|
||||
[interaction.type]: <TOption extends Record<string, unknown>>(
|
||||
callback: (option: TOption, query: string) => inferSearchInteractionOptions<SearchInteraction>,
|
||||
) => {
|
||||
return (option: TOption, query: string) => ({ type: interaction.type, ...callback(option, query) });
|
||||
},
|
||||
};
|
||||
}, {} as SearchInteractions);
|
||||
9
packages/spotlight/src/lib/mode.ts
Normal file
9
packages/spotlight/src/lib/mode.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import type { TranslationObject } from "@homarr/translation";
|
||||
|
||||
import type { SearchGroup } from "./group";
|
||||
|
||||
export interface SearchMode {
|
||||
modeKey: keyof TranslationObject["search"]["mode"];
|
||||
character: string;
|
||||
groups: SearchGroup[];
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import { Avatar, Group, Stack, Text } from "@mantine/core";
|
||||
import { IconExternalLink, IconEye } from "@tabler/icons-react";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
|
||||
import { createChildrenOptions } from "../../lib/children";
|
||||
import { createGroup } from "../../lib/group";
|
||||
import { interaction } from "../../lib/interaction";
|
||||
|
||||
// This has to be type so it can be interpreted as Record<string, unknown>.
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-definitions
|
||||
type App = { id: string; name: string; iconUrl: string; href: string | null };
|
||||
|
||||
const appChildrenOptions = createChildrenOptions<App>({
|
||||
useActions: () => [
|
||||
{
|
||||
key: "open",
|
||||
component: () => {
|
||||
const t = useI18n();
|
||||
|
||||
return (
|
||||
<Group mx="md" my="sm">
|
||||
<IconExternalLink stroke={1.5} />
|
||||
<Text>{t("search.mode.appIntegrationBoard.group.app.children.action.open.label")}</Text>
|
||||
</Group>
|
||||
);
|
||||
},
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
useInteraction: interaction.link((option) => ({ href: option.href! })),
|
||||
hide(option) {
|
||||
return !option.href;
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "edit",
|
||||
component: () => {
|
||||
const t = useI18n();
|
||||
|
||||
return (
|
||||
<Group mx="md" my="sm">
|
||||
<IconEye stroke={1.5} />
|
||||
<Text>{t("search.mode.appIntegrationBoard.group.app.children.action.edit.label")}</Text>
|
||||
</Group>
|
||||
);
|
||||
},
|
||||
useInteraction: interaction.link(({ id }) => ({ href: `/manage/apps/edit/${id}` })),
|
||||
},
|
||||
],
|
||||
detailComponent: ({ options }) => {
|
||||
const t = useI18n();
|
||||
|
||||
return (
|
||||
<Stack mx="md" my="sm">
|
||||
<Text>{t("search.mode.appIntegrationBoard.group.app.children.detail.title")}</Text>
|
||||
|
||||
<Group>
|
||||
<Avatar
|
||||
size="sm"
|
||||
src={options.iconUrl}
|
||||
radius={0}
|
||||
styles={{
|
||||
image: {
|
||||
objectFit: "contain",
|
||||
},
|
||||
}}
|
||||
/>
|
||||
<Text>{options.name}</Text>
|
||||
</Group>
|
||||
</Stack>
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
export const appsSearchGroup = createGroup<App>({
|
||||
keyPath: "id",
|
||||
title: (t) => t("search.mode.appIntegrationBoard.group.app.title"),
|
||||
component: (app) => (
|
||||
<Group px="md" py="sm">
|
||||
<Avatar
|
||||
size="sm"
|
||||
src={app.iconUrl}
|
||||
radius={0}
|
||||
styles={{
|
||||
image: {
|
||||
objectFit: "contain",
|
||||
},
|
||||
}}
|
||||
/>
|
||||
<Text>{app.name}</Text>
|
||||
</Group>
|
||||
),
|
||||
useInteraction: interaction.children(appChildrenOptions),
|
||||
useQueryOptions(query) {
|
||||
return clientApi.app.search.useQuery({ query, limit: 5 });
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,120 @@
|
||||
import { Group, Stack, Text } from "@mantine/core";
|
||||
import { IconHome, IconLayoutDashboard, IconLink, IconSettings } from "@tabler/icons-react";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
|
||||
import type { ChildrenAction } from "../../lib/children";
|
||||
import { createChildrenOptions } from "../../lib/children";
|
||||
import { createGroup } from "../../lib/group";
|
||||
import { interaction } from "../../lib/interaction";
|
||||
|
||||
// This has to be type so it can be interpreted as Record<string, unknown>.
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-definitions
|
||||
type Board = {
|
||||
id: string;
|
||||
name: string;
|
||||
logoImageUrl: string | null;
|
||||
permissions: { hasFullAccess: boolean; hasChangeAccess: boolean; hasViewAccess: boolean };
|
||||
};
|
||||
|
||||
const boardChildrenOptions = createChildrenOptions<Board>({
|
||||
useActions: (options) => {
|
||||
const actions: (ChildrenAction<Board> & { hidden?: boolean })[] = [
|
||||
{
|
||||
key: "open",
|
||||
component: () => {
|
||||
const t = useI18n();
|
||||
|
||||
return (
|
||||
<Group mx="md" my="sm">
|
||||
<IconLink stroke={1.5} />
|
||||
<Text>{t("search.mode.appIntegrationBoard.group.board.children.action.open.label")}</Text>
|
||||
</Group>
|
||||
);
|
||||
},
|
||||
useInteraction: interaction.link(({ name }) => ({ href: `/boards/${name}` })),
|
||||
},
|
||||
{
|
||||
key: "homeBoard",
|
||||
component: () => {
|
||||
const t = useI18n();
|
||||
|
||||
return (
|
||||
<Group mx="md" my="sm">
|
||||
<IconHome stroke={1.5} />
|
||||
<Text>{t("search.mode.appIntegrationBoard.group.board.children.action.homeBoard.label")}</Text>
|
||||
</Group>
|
||||
);
|
||||
},
|
||||
useInteraction(option) {
|
||||
const { mutateAsync } = clientApi.board.setHomeBoard.useMutation();
|
||||
|
||||
return {
|
||||
type: "javaScript",
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
async onSelect() {
|
||||
await mutateAsync({ id: option.id });
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "settings",
|
||||
component: () => {
|
||||
const t = useI18n();
|
||||
|
||||
return (
|
||||
<Group mx="md" my="sm">
|
||||
<IconSettings stroke={1.5} />
|
||||
<Text>{t("search.mode.appIntegrationBoard.group.board.children.action.settings.label")}</Text>
|
||||
</Group>
|
||||
);
|
||||
},
|
||||
useInteraction: interaction.link(({ name }) => ({ href: `/boards/${name}/settings` })),
|
||||
hidden: !options.permissions.hasChangeAccess,
|
||||
},
|
||||
];
|
||||
|
||||
return actions;
|
||||
},
|
||||
detailComponent: ({ options: board }) => {
|
||||
const t = useI18n();
|
||||
|
||||
return (
|
||||
<Stack mx="md" my="sm">
|
||||
<Text>{t("search.mode.appIntegrationBoard.group.board.children.detail.title")}</Text>
|
||||
|
||||
<Group>
|
||||
{board.logoImageUrl ? (
|
||||
<img src={board.logoImageUrl} alt={board.name} width={24} height={24} />
|
||||
) : (
|
||||
<IconLayoutDashboard size={24} />
|
||||
)}
|
||||
|
||||
<Text>{board.name}</Text>
|
||||
</Group>
|
||||
</Stack>
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
export const boardsSearchGroup = createGroup<Board>({
|
||||
keyPath: "id",
|
||||
title: "Boards",
|
||||
component: (board) => (
|
||||
<Group px="md" py="sm">
|
||||
{board.logoImageUrl ? (
|
||||
<img src={board.logoImageUrl} alt={board.name} width={24} height={24} />
|
||||
) : (
|
||||
<IconLayoutDashboard size={24} />
|
||||
)}
|
||||
|
||||
<Text>{board.name}</Text>
|
||||
</Group>
|
||||
),
|
||||
useInteraction: interaction.children(boardChildrenOptions),
|
||||
useQueryOptions(query) {
|
||||
return clientApi.board.search.useQuery({ query, limit: 5 });
|
||||
},
|
||||
});
|
||||
10
packages/spotlight/src/modes/app-integration-board/index.tsx
Normal file
10
packages/spotlight/src/modes/app-integration-board/index.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import type { SearchMode } from "../../lib/mode";
|
||||
import { appsSearchGroup } from "./apps-search-group";
|
||||
import { boardsSearchGroup } from "./boards-search-group";
|
||||
import { integrationsSearchGroup } from "./integrations-search-group";
|
||||
|
||||
export const appIntegrationBoardMode = {
|
||||
modeKey: "appIntegrationBoard",
|
||||
character: "#",
|
||||
groups: [appsSearchGroup, integrationsSearchGroup, boardsSearchGroup],
|
||||
} satisfies SearchMode;
|
||||
@@ -0,0 +1,24 @@
|
||||
import { Group, Text } from "@mantine/core";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import type { IntegrationKind } from "@homarr/definitions";
|
||||
import { IntegrationAvatar } from "@homarr/ui";
|
||||
|
||||
import { createGroup } from "../../lib/group";
|
||||
import { interaction } from "../../lib/interaction";
|
||||
|
||||
export const integrationsSearchGroup = createGroup<{ id: string; kind: IntegrationKind; name: string }>({
|
||||
keyPath: "id",
|
||||
title: (t) => t("search.mode.appIntegrationBoard.group.integration.title"),
|
||||
component: (integration) => (
|
||||
<Group px="md" py="sm">
|
||||
<IntegrationAvatar size="sm" kind={integration.kind} />
|
||||
|
||||
<Text>{integration.name}</Text>
|
||||
</Group>
|
||||
),
|
||||
useInteraction: interaction.link(({ id }) => ({ href: `/manage/integrations/edit/${id}` })),
|
||||
useQueryOptions(query) {
|
||||
return clientApi.integration.search.useQuery({ query, limit: 5 });
|
||||
},
|
||||
});
|
||||
65
packages/spotlight/src/modes/command/children/language.tsx
Normal file
65
packages/spotlight/src/modes/command/children/language.tsx
Normal file
@@ -0,0 +1,65 @@
|
||||
import { Group, Stack, Text } from "@mantine/core";
|
||||
import { IconCheck } from "@tabler/icons-react";
|
||||
|
||||
import { localeAttributes, supportedLanguages } from "@homarr/translation";
|
||||
import { useChangeLocale, useCurrentLocale, useI18n } from "@homarr/translation/client";
|
||||
|
||||
import { createChildrenOptions } from "../../../lib/children";
|
||||
|
||||
export const languageChildrenOptions = createChildrenOptions<Record<string, unknown>>({
|
||||
useActions: (_, query) => {
|
||||
const normalizedQuery = query.trim().toLowerCase();
|
||||
const currentLocale = useCurrentLocale();
|
||||
return supportedLanguages
|
||||
.map((localeKey) => ({ localeKey, attributes: localeAttributes[localeKey] }))
|
||||
.filter(
|
||||
({ attributes }) =>
|
||||
attributes.name.toLowerCase().includes(normalizedQuery) ||
|
||||
attributes.translatedName.toLowerCase().includes(normalizedQuery),
|
||||
)
|
||||
.sort(
|
||||
(languageA, languageB) =>
|
||||
Math.min(
|
||||
languageA.attributes.name.toLowerCase().indexOf(normalizedQuery),
|
||||
languageA.attributes.translatedName.toLowerCase().indexOf(normalizedQuery),
|
||||
) -
|
||||
Math.min(
|
||||
languageB.attributes.name.toLowerCase().indexOf(normalizedQuery),
|
||||
languageB.attributes.translatedName.toLowerCase().indexOf(normalizedQuery),
|
||||
),
|
||||
)
|
||||
.map(({ localeKey, attributes }) => ({
|
||||
key: localeKey,
|
||||
component() {
|
||||
return (
|
||||
<Group mx="md" my="sm" wrap="nowrap" justify="space-between" w="100%">
|
||||
<Group wrap="nowrap">
|
||||
<span className={`fi fi-${attributes.flagIcon}`} style={{ borderRadius: 4 }}></span>
|
||||
<Group wrap="nowrap" gap="xs">
|
||||
<Text>{attributes.name}</Text>
|
||||
<Text size="xs" c="dimmed" inherit>
|
||||
({attributes.translatedName})
|
||||
</Text>
|
||||
</Group>
|
||||
</Group>
|
||||
{localeKey === currentLocale && <IconCheck color="currentColor" size={24} />}
|
||||
</Group>
|
||||
);
|
||||
},
|
||||
useInteraction() {
|
||||
const changeLocale = useChangeLocale();
|
||||
|
||||
return { type: "javaScript", onSelect: () => changeLocale(localeKey) };
|
||||
},
|
||||
}));
|
||||
},
|
||||
detailComponent: () => {
|
||||
const t = useI18n();
|
||||
|
||||
return (
|
||||
<Stack mx="md" my="sm">
|
||||
<Text>{t("search.mode.command.group.globalCommand.option.language.children.detail.title")}</Text>
|
||||
</Stack>
|
||||
);
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,43 @@
|
||||
import { Group, Stack, Text } from "@mantine/core";
|
||||
|
||||
import { objectEntries } from "@homarr/common";
|
||||
import { integrationDefs } from "@homarr/definitions";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import { IntegrationAvatar } from "@homarr/ui";
|
||||
|
||||
import { createChildrenOptions } from "../../../lib/children";
|
||||
import { interaction } from "../../../lib/interaction";
|
||||
|
||||
export const newIntegrationChildrenOptions = createChildrenOptions<Record<string, unknown>>({
|
||||
useActions: (_, query) => {
|
||||
const normalizedQuery = query.trim().toLowerCase();
|
||||
return objectEntries(integrationDefs)
|
||||
.filter(([, integrationDef]) => integrationDef.name.toLowerCase().includes(normalizedQuery))
|
||||
.sort(
|
||||
([, definitionA], [, definitionB]) =>
|
||||
definitionA.name.toLowerCase().indexOf(normalizedQuery) -
|
||||
definitionB.name.toLowerCase().indexOf(normalizedQuery),
|
||||
)
|
||||
.map(([kind, integrationDef]) => ({
|
||||
key: kind,
|
||||
component() {
|
||||
return (
|
||||
<Group mx="md" my="sm" wrap="nowrap" w="100%">
|
||||
<IntegrationAvatar kind={kind} size="sm" />
|
||||
<Text>{integrationDef.name}</Text>
|
||||
</Group>
|
||||
);
|
||||
},
|
||||
useInteraction: interaction.link(() => ({ href: `/manage/integrations/new?kind=${kind}` })),
|
||||
}));
|
||||
},
|
||||
detailComponent() {
|
||||
const t = useI18n();
|
||||
|
||||
return (
|
||||
<Stack mx="md" my="sm">
|
||||
<Text>{t("search.mode.command.group.globalCommand.option.newIntegration.children.detail.title")}</Text>
|
||||
</Stack>
|
||||
);
|
||||
},
|
||||
});
|
||||
164
packages/spotlight/src/modes/command/index.tsx
Normal file
164
packages/spotlight/src/modes/command/index.tsx
Normal file
@@ -0,0 +1,164 @@
|
||||
import { Group, Text, useMantineColorScheme } from "@mantine/core";
|
||||
import {
|
||||
IconCategoryPlus,
|
||||
IconFileImport,
|
||||
IconLanguage,
|
||||
IconMailForward,
|
||||
IconMoon,
|
||||
IconPackage,
|
||||
IconPlug,
|
||||
IconSun,
|
||||
IconUserPlus,
|
||||
IconUsersGroup,
|
||||
} from "@tabler/icons-react";
|
||||
|
||||
import { useModalAction } from "@homarr/modals";
|
||||
import { AddBoardModal, AddGroupModal, ImportBoardModal, InviteCreateModal } from "@homarr/modals-collection";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
import type { TablerIcon } from "@homarr/ui";
|
||||
|
||||
import { createGroup } from "../../lib/group";
|
||||
import type { inferSearchInteractionDefinition, SearchInteraction } from "../../lib/interaction";
|
||||
import { interaction } from "../../lib/interaction";
|
||||
import type { SearchMode } from "../../lib/mode";
|
||||
import { languageChildrenOptions } from "./children/language";
|
||||
import { newIntegrationChildrenOptions } from "./children/new-integration";
|
||||
|
||||
// This has to be type so it can be interpreted as Record<string, unknown>.
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-definitions
|
||||
type Command<TSearchInteraction extends SearchInteraction = SearchInteraction> = {
|
||||
commandKey: string;
|
||||
icon: TablerIcon;
|
||||
name: string;
|
||||
useInteraction: (
|
||||
_c: Command<TSearchInteraction>,
|
||||
query: string,
|
||||
) => inferSearchInteractionDefinition<TSearchInteraction>;
|
||||
};
|
||||
|
||||
export const commandMode = {
|
||||
modeKey: "command",
|
||||
character: ">",
|
||||
groups: [
|
||||
createGroup<Command>({
|
||||
keyPath: "commandKey",
|
||||
title: "Global commands",
|
||||
useInteraction: (option, query) => option.useInteraction(option, query),
|
||||
component: ({ icon: Icon, name }) => (
|
||||
<Group px="md" py="sm">
|
||||
<Icon stroke={1.5} />
|
||||
<Text>{name}</Text>
|
||||
</Group>
|
||||
),
|
||||
filter(query, option) {
|
||||
return option.name.toLowerCase().includes(query.toLowerCase());
|
||||
},
|
||||
useOptions() {
|
||||
const tOption = useScopedI18n("search.mode.command.group.globalCommand.option");
|
||||
const { colorScheme } = useMantineColorScheme();
|
||||
|
||||
const commands: (Command & { hidden?: boolean })[] = [
|
||||
{
|
||||
commandKey: "colorScheme",
|
||||
icon: colorScheme === "dark" ? IconSun : IconMoon,
|
||||
name: tOption(`colorScheme.${colorScheme === "dark" ? "light" : "dark"}`),
|
||||
useInteraction: () => {
|
||||
const { toggleColorScheme } = useMantineColorScheme();
|
||||
|
||||
return {
|
||||
type: "javaScript",
|
||||
onSelect: toggleColorScheme,
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
commandKey: "language",
|
||||
icon: IconLanguage,
|
||||
name: tOption("language.label"),
|
||||
useInteraction: interaction.children(languageChildrenOptions),
|
||||
},
|
||||
{
|
||||
commandKey: "newBoard",
|
||||
icon: IconCategoryPlus,
|
||||
name: tOption("newBoard.label"),
|
||||
useInteraction() {
|
||||
const { openModal } = useModalAction(AddBoardModal);
|
||||
|
||||
return {
|
||||
type: "javaScript",
|
||||
onSelect() {
|
||||
openModal(undefined);
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
commandKey: "importBoard",
|
||||
icon: IconFileImport,
|
||||
name: tOption("importBoard.label"),
|
||||
useInteraction() {
|
||||
const { openModal } = useModalAction(ImportBoardModal);
|
||||
|
||||
return {
|
||||
type: "javaScript",
|
||||
onSelect() {
|
||||
openModal(undefined);
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
commandKey: "newApp",
|
||||
icon: IconPackage,
|
||||
name: tOption("newApp.label"),
|
||||
useInteraction: interaction.link(() => ({ href: "/manage/apps/new" })),
|
||||
},
|
||||
{
|
||||
commandKey: "newIntegration",
|
||||
icon: IconPlug,
|
||||
name: tOption("newIntegration.label"),
|
||||
useInteraction: interaction.children(newIntegrationChildrenOptions),
|
||||
},
|
||||
{
|
||||
commandKey: "newUser",
|
||||
icon: IconUserPlus,
|
||||
name: tOption("newUser.label"),
|
||||
useInteraction: interaction.link(() => ({ href: "/manage/users/new" })),
|
||||
},
|
||||
{
|
||||
commandKey: "newInvite",
|
||||
icon: IconMailForward,
|
||||
name: tOption("newInvite.label"),
|
||||
useInteraction() {
|
||||
const { openModal } = useModalAction(InviteCreateModal);
|
||||
|
||||
return {
|
||||
type: "javaScript",
|
||||
onSelect() {
|
||||
openModal(undefined);
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
commandKey: "newGroup",
|
||||
icon: IconUsersGroup,
|
||||
name: tOption("newGroup.label"),
|
||||
useInteraction() {
|
||||
const { openModal } = useModalAction(AddGroupModal);
|
||||
|
||||
return {
|
||||
type: "javaScript",
|
||||
onSelect() {
|
||||
openModal(undefined);
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
return commands.filter((command) => !command.hidden);
|
||||
},
|
||||
}),
|
||||
],
|
||||
} satisfies SearchMode;
|
||||
8
packages/spotlight/src/modes/external/index.tsx
vendored
Normal file
8
packages/spotlight/src/modes/external/index.tsx
vendored
Normal file
@@ -0,0 +1,8 @@
|
||||
import type { SearchMode } from "../../lib/mode";
|
||||
import { searchEnginesSearchGroups } from "./search-engines-search-group";
|
||||
|
||||
export const externalMode = {
|
||||
modeKey: "external",
|
||||
character: "!",
|
||||
groups: [searchEnginesSearchGroups],
|
||||
} satisfies SearchMode;
|
||||
82
packages/spotlight/src/modes/external/search-engines-search-group.tsx
vendored
Normal file
82
packages/spotlight/src/modes/external/search-engines-search-group.tsx
vendored
Normal file
@@ -0,0 +1,82 @@
|
||||
import { Group, Stack, Text } from "@mantine/core";
|
||||
import type { TablerIcon } from "@tabler/icons-react";
|
||||
import { IconDownload } from "@tabler/icons-react";
|
||||
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import { createGroup } from "../../lib/group";
|
||||
import { interaction } from "../../lib/interaction";
|
||||
|
||||
// This has to be type so it can be interpreted as Record<string, unknown>.
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-definitions
|
||||
type SearchEngine = {
|
||||
short: string;
|
||||
image: string | TablerIcon;
|
||||
name: string;
|
||||
description: string;
|
||||
urlTemplate: string;
|
||||
};
|
||||
|
||||
export const searchEnginesSearchGroups = createGroup<SearchEngine>({
|
||||
keyPath: "short",
|
||||
title: (t) => t("search.mode.external.group.searchEngine.title"),
|
||||
component: ({ image: Image, name, description }) => (
|
||||
<Group w="100%" wrap="nowrap" justify="space-between" align="center" px="md" py="xs">
|
||||
<Group wrap="nowrap">
|
||||
{typeof Image === "string" ? <img height={24} width={24} src={Image} alt={name} /> : <Image size={24} />}
|
||||
<Stack gap={0} justify="center">
|
||||
<Text size="sm">{name}</Text>
|
||||
<Text size="xs" c="gray.6">
|
||||
{description}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Group>
|
||||
</Group>
|
||||
),
|
||||
filter: () => true,
|
||||
useInteraction: interaction.link(({ urlTemplate }, query) => ({
|
||||
href: urlTemplate.replace("%s", query),
|
||||
newTab: true,
|
||||
})),
|
||||
useOptions() {
|
||||
const tOption = useScopedI18n("search.mode.external.group.searchEngine.option");
|
||||
|
||||
return [
|
||||
{
|
||||
short: "g",
|
||||
name: tOption("google.name"),
|
||||
image: "https://www.google.com/favicon.ico",
|
||||
description: tOption("google.description"),
|
||||
urlTemplate: "https://www.google.com/search?q=%s",
|
||||
},
|
||||
{
|
||||
short: "b",
|
||||
name: tOption("bing.name"),
|
||||
image: "https://www.bing.com/favicon.ico",
|
||||
description: tOption("bing.description"),
|
||||
urlTemplate: "https://www.bing.com/search?q=%s",
|
||||
},
|
||||
{
|
||||
short: "d",
|
||||
name: tOption("duckduckgo.name"),
|
||||
image: "https://duckduckgo.com/favicon.ico",
|
||||
description: tOption("duckduckgo.description"),
|
||||
urlTemplate: "https://duckduckgo.com/?q=%s",
|
||||
},
|
||||
{
|
||||
short: "t",
|
||||
name: tOption("torrent.name"),
|
||||
image: IconDownload,
|
||||
description: tOption("torrent.description"),
|
||||
urlTemplate: "https://www.torrentdownloads.pro/search/?search=%s",
|
||||
},
|
||||
{
|
||||
short: "y",
|
||||
name: tOption("youTube.name"),
|
||||
image: "https://www.youtube.com/favicon.ico",
|
||||
description: tOption("youTube.description"),
|
||||
urlTemplate: "https://www.youtube.com/results?search_query=%s",
|
||||
},
|
||||
];
|
||||
},
|
||||
});
|
||||
74
packages/spotlight/src/modes/index.tsx
Normal file
74
packages/spotlight/src/modes/index.tsx
Normal file
@@ -0,0 +1,74 @@
|
||||
import { Group, Kbd, Text } from "@mantine/core";
|
||||
import { IconBook2, IconBrandDiscord, IconBrandGithub } from "@tabler/icons-react";
|
||||
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import { createGroup } from "../lib/group";
|
||||
import { interaction } from "../lib/interaction";
|
||||
import type { SearchMode } from "../lib/mode";
|
||||
import { appIntegrationBoardMode } from "./app-integration-board";
|
||||
import { commandMode } from "./command";
|
||||
import { externalMode } from "./external";
|
||||
import { pageMode } from "./page";
|
||||
import { userGroupMode } from "./user-group";
|
||||
|
||||
const searchModesWithoutHelp = [userGroupMode, appIntegrationBoardMode, externalMode, commandMode, pageMode] as const;
|
||||
|
||||
const helpMode = {
|
||||
modeKey: "help",
|
||||
character: "?",
|
||||
groups: [
|
||||
createGroup({
|
||||
keyPath: "character",
|
||||
title: (t) => t("search.mode.help.group.mode.title"),
|
||||
options: searchModesWithoutHelp.map(({ character, modeKey }) => ({ character, modeKey })),
|
||||
component: ({ modeKey, character }) => {
|
||||
const t = useScopedI18n(`search.mode.${modeKey}`);
|
||||
|
||||
return (
|
||||
<Group px="md" py="xs" w="100%" wrap="nowrap" align="center" justify="space-between">
|
||||
<Text>{t("help")}</Text>
|
||||
<Kbd size="sm">{character}</Kbd>
|
||||
</Group>
|
||||
);
|
||||
},
|
||||
filter: () => true,
|
||||
useInteraction: interaction.mode(({ modeKey }) => ({ mode: modeKey })),
|
||||
}),
|
||||
createGroup({
|
||||
keyPath: "href",
|
||||
title: (t) => t("search.mode.help.group.help.title"),
|
||||
useOptions() {
|
||||
const t = useScopedI18n("search.mode.help.group.help.option");
|
||||
|
||||
return [
|
||||
{
|
||||
label: t("documentation.label"),
|
||||
icon: IconBook2,
|
||||
href: "https://homarr.dev/docs/getting-started/",
|
||||
},
|
||||
{
|
||||
label: t("submitIssue.label"),
|
||||
icon: IconBrandGithub,
|
||||
href: "https://github.com/ajnart/homarr/issues/new/choose",
|
||||
},
|
||||
{
|
||||
label: t("discord.label"),
|
||||
icon: IconBrandDiscord,
|
||||
href: "https://discord.com/invite/aCsmEV5RgA",
|
||||
},
|
||||
];
|
||||
},
|
||||
component: (props) => (
|
||||
<Group px="md" py="xs" w="100%" wrap="nowrap" align="center">
|
||||
<props.icon />
|
||||
<Text>{props.label}</Text>
|
||||
</Group>
|
||||
),
|
||||
filter: () => true,
|
||||
useInteraction: interaction.link(({ href }) => ({ href })),
|
||||
}),
|
||||
],
|
||||
} satisfies SearchMode;
|
||||
|
||||
export const searchModes = [...searchModesWithoutHelp, helpMode] as const;
|
||||
8
packages/spotlight/src/modes/page/index.tsx
Normal file
8
packages/spotlight/src/modes/page/index.tsx
Normal file
@@ -0,0 +1,8 @@
|
||||
import type { SearchMode } from "../../lib/mode";
|
||||
import { pagesSearchGroup } from "./pages-search-group";
|
||||
|
||||
export const pageMode = {
|
||||
modeKey: "page",
|
||||
character: "/",
|
||||
groups: [pagesSearchGroup],
|
||||
} satisfies SearchMode;
|
||||
156
packages/spotlight/src/modes/page/pages-search-group.tsx
Normal file
156
packages/spotlight/src/modes/page/pages-search-group.tsx
Normal file
@@ -0,0 +1,156 @@
|
||||
import { Group, Text } from "@mantine/core";
|
||||
import {
|
||||
IconBox,
|
||||
IconBrandDocker,
|
||||
IconHome,
|
||||
IconInfoSmall,
|
||||
IconLayoutDashboard,
|
||||
IconLogs,
|
||||
IconMailForward,
|
||||
IconPlug,
|
||||
IconReport,
|
||||
IconSettings,
|
||||
IconUsers,
|
||||
IconUsersGroup,
|
||||
} from "@tabler/icons-react";
|
||||
|
||||
import { useSession } from "@homarr/auth/client";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
import type { TablerIcon } from "@homarr/ui";
|
||||
|
||||
import { createGroup } from "../../lib/group";
|
||||
import { interaction } from "../../lib/interaction";
|
||||
|
||||
export const pagesSearchGroup = createGroup<{
|
||||
icon: TablerIcon;
|
||||
name: string;
|
||||
path: string;
|
||||
}>({
|
||||
keyPath: "path",
|
||||
title: (t) => t("search.mode.page.group.page.title"),
|
||||
component: ({ name, icon: Icon }) => (
|
||||
<Group px="md" py="sm">
|
||||
<Icon stroke={1.5} />
|
||||
<Text>{name}</Text>
|
||||
</Group>
|
||||
),
|
||||
useInteraction: interaction.link(({ path }) => ({ href: path })),
|
||||
filter: (query, { name, path }) => {
|
||||
const normalizedQuery = query.trim().toLowerCase();
|
||||
return name.toLowerCase().includes(normalizedQuery) || path.toLowerCase().includes(normalizedQuery);
|
||||
},
|
||||
sort: (query, options) => {
|
||||
const normalizedQuery = query.trim().toLowerCase();
|
||||
|
||||
const nameMatches = options.map((option) => option.name.toLowerCase().includes(normalizedQuery));
|
||||
const pathMatches = options.map((option) => option.path.toLowerCase().includes(normalizedQuery));
|
||||
|
||||
if (nameMatches.every(Boolean) && pathMatches.every(Boolean)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (nameMatches.every(Boolean) && !pathMatches.every(Boolean)) {
|
||||
return pathMatches[0] ? -1 : 1;
|
||||
}
|
||||
|
||||
return nameMatches[0] ? -1 : 1;
|
||||
},
|
||||
useOptions() {
|
||||
const { data: session } = useSession();
|
||||
const t = useScopedI18n("search.mode.page.group.page.option");
|
||||
|
||||
const managePages = [
|
||||
{
|
||||
icon: IconHome,
|
||||
path: "/manage",
|
||||
name: t("manageHome.label"),
|
||||
},
|
||||
{
|
||||
icon: IconLayoutDashboard,
|
||||
path: "/manage/boards",
|
||||
name: t("manageBoard.label"),
|
||||
},
|
||||
{
|
||||
icon: IconBox,
|
||||
path: "/manage/apps",
|
||||
name: t("manageApp.label"),
|
||||
hidden: !session,
|
||||
},
|
||||
{
|
||||
icon: IconPlug,
|
||||
path: "/manage/integrations",
|
||||
name: t("manageIntegration.label"),
|
||||
hidden: !session,
|
||||
},
|
||||
{
|
||||
icon: IconUsers,
|
||||
path: "/manage/users",
|
||||
name: t("manageUser.label"),
|
||||
hidden: !session,
|
||||
},
|
||||
{
|
||||
icon: IconMailForward,
|
||||
path: "/manage/users/invites",
|
||||
name: t("manageInvite.label"),
|
||||
hidden: !session?.user.permissions.includes("admin"),
|
||||
},
|
||||
{
|
||||
icon: IconUsersGroup,
|
||||
path: "/manage/users/groups",
|
||||
name: t("manageGroup.label"),
|
||||
hidden: !session,
|
||||
},
|
||||
{
|
||||
icon: IconBrandDocker,
|
||||
path: "/manage/tools/docker",
|
||||
name: "Manage Docker",
|
||||
hidden: !session?.user.permissions.includes("admin"),
|
||||
},
|
||||
{
|
||||
icon: IconPlug,
|
||||
path: "/manage/tools/api",
|
||||
name: t("manageApi.label"),
|
||||
hidden: !session,
|
||||
},
|
||||
{
|
||||
icon: IconLogs,
|
||||
path: "/manage/tools/logs",
|
||||
name: t("manageLog.label"),
|
||||
hidden: !session?.user.permissions.includes("admin"),
|
||||
},
|
||||
{
|
||||
icon: IconReport,
|
||||
path: "/manage/tools/tasks",
|
||||
name: t("manageTask.label"),
|
||||
hidden: !session?.user.permissions.includes("admin"),
|
||||
},
|
||||
{
|
||||
icon: IconSettings,
|
||||
path: "/manage/settings",
|
||||
name: t("manageSettings.label"),
|
||||
hidden: !session?.user.permissions.includes("admin"),
|
||||
},
|
||||
{
|
||||
icon: IconInfoSmall,
|
||||
path: "/manage/about",
|
||||
name: t("about.label"),
|
||||
},
|
||||
];
|
||||
|
||||
const otherPages = [
|
||||
{
|
||||
icon: IconHome,
|
||||
path: "/boards",
|
||||
name: t("homeBoard.label"),
|
||||
},
|
||||
{
|
||||
icon: IconSettings,
|
||||
path: `/manage/users/${session?.user.id}/general`,
|
||||
name: t("preferences.label"),
|
||||
hidden: !session,
|
||||
},
|
||||
];
|
||||
|
||||
return otherPages.concat(managePages).filter(({ hidden }) => !hidden);
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,83 @@
|
||||
import { Group, Stack, Text } from "@mantine/core";
|
||||
import { IconEye, IconUsersGroup } from "@tabler/icons-react";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
|
||||
import { createChildrenOptions } from "../../lib/children";
|
||||
import { createGroup } from "../../lib/group";
|
||||
import { interaction } from "../../lib/interaction";
|
||||
|
||||
// This has to be type so it can be interpreted as Record<string, unknown>.
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-definitions
|
||||
type Group = { id: string; name: string };
|
||||
|
||||
const groupChildrenOptions = createChildrenOptions<Group>({
|
||||
useActions: () => [
|
||||
{
|
||||
key: "detail",
|
||||
component: () => {
|
||||
const t = useI18n();
|
||||
return (
|
||||
<Group mx="md" my="sm">
|
||||
<IconEye stroke={1.5} />
|
||||
<Text>{t("search.mode.userGroup.group.group.children.action.detail.label")}</Text>
|
||||
</Group>
|
||||
);
|
||||
},
|
||||
useInteraction: interaction.link(({ id }) => ({ href: `/manage/users/groups/${id}` })),
|
||||
},
|
||||
{
|
||||
key: "manageMember",
|
||||
component: () => {
|
||||
const t = useI18n();
|
||||
return (
|
||||
<Group mx="md" my="sm">
|
||||
<IconUsersGroup stroke={1.5} />
|
||||
<Text>{t("search.mode.userGroup.group.group.children.action.manageMember.label")}</Text>
|
||||
</Group>
|
||||
);
|
||||
},
|
||||
useInteraction: interaction.link(({ id }) => ({ href: `/manage/users/groups/${id}/members` })),
|
||||
},
|
||||
{
|
||||
key: "managePermission",
|
||||
component: () => {
|
||||
const t = useI18n();
|
||||
return (
|
||||
<Group mx="md" my="sm">
|
||||
<IconEye stroke={1.5} />
|
||||
<Text>{t("search.mode.userGroup.group.group.children.action.managePermission.label")}</Text>
|
||||
</Group>
|
||||
);
|
||||
},
|
||||
useInteraction: interaction.link(({ id }) => ({ href: `/manage/users/groups/${id}/permissions` })),
|
||||
},
|
||||
],
|
||||
detailComponent: ({ options }) => {
|
||||
const t = useI18n();
|
||||
return (
|
||||
<Stack mx="md" my="sm">
|
||||
<Text>{t("search.mode.userGroup.group.group.children.detail.title")}</Text>
|
||||
|
||||
<Group>
|
||||
<Text>{options.name}</Text>
|
||||
</Group>
|
||||
</Stack>
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
export const groupsSearchGroup = createGroup<Group>({
|
||||
keyPath: "id",
|
||||
title: "Groups",
|
||||
component: ({ name }) => (
|
||||
<Group px="md" py="sm">
|
||||
<Text>{name}</Text>
|
||||
</Group>
|
||||
),
|
||||
useInteraction: interaction.children(groupChildrenOptions),
|
||||
useQueryOptions(query) {
|
||||
return clientApi.group.search.useQuery({ query, limit: 5 });
|
||||
},
|
||||
});
|
||||
9
packages/spotlight/src/modes/user-group/index.tsx
Normal file
9
packages/spotlight/src/modes/user-group/index.tsx
Normal file
@@ -0,0 +1,9 @@
|
||||
import type { SearchMode } from "../../lib/mode";
|
||||
import { groupsSearchGroup } from "./groups-search-group";
|
||||
import { usersSearchGroup } from "./users-search-group";
|
||||
|
||||
export const userGroupMode = {
|
||||
modeKey: "userGroup",
|
||||
character: "@",
|
||||
groups: [usersSearchGroup, groupsSearchGroup],
|
||||
} satisfies SearchMode;
|
||||
@@ -0,0 +1,62 @@
|
||||
import { Group, Stack, Text } from "@mantine/core";
|
||||
import { IconEye } from "@tabler/icons-react";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import { UserAvatar } from "@homarr/ui";
|
||||
|
||||
import { createChildrenOptions } from "../../lib/children";
|
||||
import { createGroup } from "../../lib/group";
|
||||
import { interaction } from "../../lib/interaction";
|
||||
|
||||
// This has to be type so it can be interpreted as Record<string, unknown>.
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-definitions
|
||||
type User = { id: string; name: string; image: string | null };
|
||||
|
||||
const userChildrenOptions = createChildrenOptions<User>({
|
||||
useActions: () => [
|
||||
{
|
||||
key: "detail",
|
||||
component: () => {
|
||||
const t = useI18n();
|
||||
|
||||
return (
|
||||
<Group mx="md" my="sm">
|
||||
<IconEye stroke={1.5} />
|
||||
<Text>{t("search.mode.userGroup.group.user.children.action.detail.label")}</Text>
|
||||
</Group>
|
||||
);
|
||||
},
|
||||
useInteraction: interaction.link(({ id }) => ({ href: `/manage/users/${id}/general` })),
|
||||
},
|
||||
],
|
||||
detailComponent: ({ options }) => {
|
||||
const t = useI18n();
|
||||
|
||||
return (
|
||||
<Stack mx="md" my="sm">
|
||||
<Text>{t("search.mode.userGroup.group.user.children.detail.title")}</Text>
|
||||
|
||||
<Group>
|
||||
<UserAvatar user={options} size="sm" />
|
||||
<Text>{options.name}</Text>
|
||||
</Group>
|
||||
</Stack>
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
export const usersSearchGroup = createGroup<User>({
|
||||
keyPath: "id",
|
||||
title: (t) => t("search.mode.userGroup.group.user.title"),
|
||||
component: (user) => (
|
||||
<Group px="md" py="sm">
|
||||
<UserAvatar user={user} size="sm" />
|
||||
<Text>{user.name}</Text>
|
||||
</Group>
|
||||
),
|
||||
useInteraction: interaction.children(userChildrenOptions),
|
||||
useQueryOptions(query) {
|
||||
return clientApi.user.search.useQuery({ query, limit: 5 });
|
||||
},
|
||||
});
|
||||
@@ -1,28 +0,0 @@
|
||||
import type { TranslationFunction, TranslationObject } from "@homarr/translation";
|
||||
import type { TablerIcon } from "@homarr/ui";
|
||||
|
||||
export type SpotlightActionGroup = keyof TranslationObject["common"]["search"]["group"];
|
||||
|
||||
interface BaseSpotlightAction {
|
||||
id: string;
|
||||
title: string | ((t: TranslationFunction) => string);
|
||||
description: string | ((t: TranslationFunction) => string);
|
||||
group: Exclude<SpotlightActionGroup, "all">; // actions can not be assigned to the "all" group
|
||||
icon: TablerIcon | string;
|
||||
ignoreSearchAndOnlyShowInGroup?: boolean;
|
||||
}
|
||||
|
||||
interface SpotlightActionLink extends BaseSpotlightAction {
|
||||
type: "link";
|
||||
href: string;
|
||||
openInNewTab?: boolean;
|
||||
}
|
||||
|
||||
type MaybePromise<T> = T | Promise<T>;
|
||||
|
||||
interface SpotlightActionButton extends BaseSpotlightAction {
|
||||
type: "button";
|
||||
onClick: () => MaybePromise<void>;
|
||||
}
|
||||
|
||||
export type SpotlightActionData = SpotlightActionLink | SpotlightActionButton;
|
||||
@@ -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,
|
||||
},
|
||||
]);
|
||||
};
|
||||
@@ -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;
|
||||
|
||||
@@ -597,16 +597,6 @@ export default {
|
||||
recommended: "Recommended",
|
||||
},
|
||||
},
|
||||
search: {
|
||||
placeholder: "Search for anything",
|
||||
nothingFound: "Nothing found",
|
||||
group: {
|
||||
all: "All",
|
||||
web: "Web",
|
||||
action: "Actions",
|
||||
app: "Apps",
|
||||
},
|
||||
},
|
||||
userAvatar: {
|
||||
menu: {
|
||||
switchToDarkMode: "Switch to dark mode",
|
||||
@@ -1723,7 +1713,7 @@ export default {
|
||||
copy: {
|
||||
title: "Copy invite",
|
||||
description:
|
||||
"Your invitation has been generated. After this modal closes, <b>you'll not be able to copy this link anymore</b>. If you do no longer wish to invite said person, you can delete this invitation any time.",
|
||||
"Your invitation has been generated. After this modal closes, you'll not be able to copy this link anymore. If you do no longer wish to invite said person, you can delete this invitation any time.",
|
||||
link: "Invitation link",
|
||||
button: "Copy & close",
|
||||
},
|
||||
@@ -2058,4 +2048,247 @@ export default {
|
||||
},
|
||||
},
|
||||
},
|
||||
search: {
|
||||
placeholder: "Search for anything",
|
||||
nothingFound: "Nothing found",
|
||||
error: {
|
||||
fetch: "An error occurred while fetching data",
|
||||
},
|
||||
mode: {
|
||||
appIntegrationBoard: {
|
||||
help: "Search for apps, integrations or boards",
|
||||
group: {
|
||||
app: {
|
||||
title: "Apps",
|
||||
children: {
|
||||
action: {
|
||||
open: {
|
||||
label: "Open app url",
|
||||
},
|
||||
edit: {
|
||||
label: "Edit app",
|
||||
},
|
||||
},
|
||||
detail: {
|
||||
title: "Select an action for the app",
|
||||
},
|
||||
},
|
||||
},
|
||||
board: {
|
||||
title: "Boards",
|
||||
children: {
|
||||
action: {
|
||||
open: {
|
||||
label: "Open board",
|
||||
},
|
||||
homeBoard: {
|
||||
label: "Set as home board",
|
||||
},
|
||||
settings: {
|
||||
label: "Open settings",
|
||||
},
|
||||
},
|
||||
detail: {
|
||||
title: "Select an action for the board",
|
||||
},
|
||||
},
|
||||
},
|
||||
integration: {
|
||||
title: "Integrations",
|
||||
},
|
||||
},
|
||||
},
|
||||
command: {
|
||||
help: "Activate command mode",
|
||||
group: {
|
||||
globalCommand: {
|
||||
title: "Global commands",
|
||||
option: {
|
||||
colorScheme: {
|
||||
light: "Switch to light mode",
|
||||
dark: "Switch to dark mode",
|
||||
},
|
||||
language: {
|
||||
label: "Change language",
|
||||
children: {
|
||||
detail: {
|
||||
title: "Select your prefered language",
|
||||
},
|
||||
},
|
||||
},
|
||||
newBoard: {
|
||||
label: "Create a new board",
|
||||
},
|
||||
importBoard: {
|
||||
label: "Import a board",
|
||||
},
|
||||
newApp: {
|
||||
label: "Create a new app",
|
||||
},
|
||||
newIntegration: {
|
||||
label: "Create a new integration",
|
||||
children: {
|
||||
detail: {
|
||||
title: "Select the integration type you want to create",
|
||||
},
|
||||
},
|
||||
},
|
||||
newUser: {
|
||||
label: "Create a new user",
|
||||
},
|
||||
newInvite: {
|
||||
label: "Create a new invite",
|
||||
},
|
||||
newGroup: {
|
||||
label: "Create a new group",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
external: {
|
||||
help: "Use an external search engine",
|
||||
group: {
|
||||
searchEngine: {
|
||||
title: "Search engines",
|
||||
option: {
|
||||
google: {
|
||||
name: "Google",
|
||||
description: "Search the web with Google",
|
||||
},
|
||||
bing: {
|
||||
name: "Bing",
|
||||
description: "Search the web with Bing",
|
||||
},
|
||||
duckduckgo: {
|
||||
name: "DuckDuckGo",
|
||||
description: "Search the web with DuckDuckGo",
|
||||
},
|
||||
torrent: {
|
||||
name: "Torrents",
|
||||
description: "Search for torrents on torrentdownloads.pro",
|
||||
},
|
||||
youTube: {
|
||||
name: "YouTube",
|
||||
description: "Search for videos on YouTube",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
help: {
|
||||
group: {
|
||||
mode: {
|
||||
title: "Modes",
|
||||
},
|
||||
help: {
|
||||
title: "Help",
|
||||
option: {
|
||||
documentation: {
|
||||
label: "Documentation",
|
||||
},
|
||||
submitIssue: {
|
||||
label: "Submit an issue",
|
||||
},
|
||||
discord: {
|
||||
label: "Community Discord",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
page: {
|
||||
help: "Search for pages",
|
||||
group: {
|
||||
page: {
|
||||
title: "Pages",
|
||||
option: {
|
||||
manageHome: {
|
||||
label: "Manage home page",
|
||||
},
|
||||
manageBoard: {
|
||||
label: "Manage boards",
|
||||
},
|
||||
manageApp: {
|
||||
label: "Manage apps",
|
||||
},
|
||||
manageIntegration: {
|
||||
label: "Manage integrations",
|
||||
},
|
||||
manageUser: {
|
||||
label: "Manage users",
|
||||
},
|
||||
manageInvite: {
|
||||
label: "Manage invites",
|
||||
},
|
||||
manageGroup: {
|
||||
label: "Manage groups",
|
||||
},
|
||||
manageDocker: {
|
||||
label: "Manage docker",
|
||||
},
|
||||
manageApi: {
|
||||
label: "Swagger API",
|
||||
},
|
||||
manageLog: {
|
||||
label: "View logs",
|
||||
},
|
||||
manageTask: {
|
||||
label: "Manage tasks",
|
||||
},
|
||||
manageSettings: {
|
||||
label: "Global settings",
|
||||
},
|
||||
about: {
|
||||
label: "About",
|
||||
},
|
||||
homeBoard: {
|
||||
label: "Home board",
|
||||
},
|
||||
preferences: {
|
||||
label: "Your preferences",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
userGroup: {
|
||||
help: "Search for users or groups",
|
||||
group: {
|
||||
user: {
|
||||
title: "Users",
|
||||
children: {
|
||||
action: {
|
||||
detail: {
|
||||
label: "Show user details",
|
||||
},
|
||||
},
|
||||
detail: {
|
||||
title: "Select an action for the user",
|
||||
},
|
||||
},
|
||||
},
|
||||
group: {
|
||||
title: "Groups",
|
||||
children: {
|
||||
action: {
|
||||
detail: {
|
||||
label: "Show group details",
|
||||
},
|
||||
manageMember: {
|
||||
label: "Manage members",
|
||||
},
|
||||
managePermission: {
|
||||
label: "Manage permissions",
|
||||
},
|
||||
},
|
||||
detail: {
|
||||
title: "Select an action for the group",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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;
|
||||
@@ -7,7 +7,6 @@ import combineClasses from "clsx";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { parseAppHrefWithVariablesClient } from "@homarr/common/client";
|
||||
import { useRegisterSpotlightActions } from "@homarr/spotlight";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
|
||||
import type { WidgetComponentProps } from "../definition";
|
||||
@@ -29,25 +28,6 @@ export default function AppWidget({ options, isEditMode }: WidgetComponentProps<
|
||||
},
|
||||
);
|
||||
|
||||
useRegisterSpotlightActions(
|
||||
`app-${options.appId}`,
|
||||
app.href
|
||||
? [
|
||||
{
|
||||
id: `app-${options.appId}`,
|
||||
title: app.name,
|
||||
description: app.description ?? "",
|
||||
icon: app.iconUrl,
|
||||
group: "app",
|
||||
type: "link",
|
||||
href: parseAppHrefWithVariablesClient(app.href),
|
||||
openInNewTab: options.openInNewTab,
|
||||
},
|
||||
]
|
||||
: [],
|
||||
[app, options.appId, options.openInNewTab],
|
||||
);
|
||||
|
||||
return (
|
||||
<AppLink
|
||||
href={parseAppHrefWithVariablesClient(app.href ?? "")}
|
||||
|
||||
21
pnpm-lock.yaml
generated
21
pnpm-lock.yaml
generated
@@ -1304,6 +1304,24 @@ importers:
|
||||
|
||||
packages/spotlight:
|
||||
dependencies:
|
||||
'@homarr/api':
|
||||
specifier: workspace:^0.1.0
|
||||
version: link:../api
|
||||
'@homarr/auth':
|
||||
specifier: workspace:^0.1.0
|
||||
version: link:../auth
|
||||
'@homarr/common':
|
||||
specifier: workspace:^0.1.0
|
||||
version: link:../common
|
||||
'@homarr/definitions':
|
||||
specifier: workspace:^0.1.0
|
||||
version: link:../definitions
|
||||
'@homarr/modals':
|
||||
specifier: workspace:^0.1.0
|
||||
version: link:../modals
|
||||
'@homarr/modals-collection':
|
||||
specifier: workspace:^0.1.0
|
||||
version: link:../modals-collection
|
||||
'@homarr/translation':
|
||||
specifier: workspace:^0.1.0
|
||||
version: link:../translation
|
||||
@@ -1384,6 +1402,9 @@ importers:
|
||||
'@homarr/common':
|
||||
specifier: workspace:^0.1.0
|
||||
version: link:../common
|
||||
'@homarr/definitions':
|
||||
specifier: workspace:^0.1.0
|
||||
version: link:../definitions
|
||||
'@homarr/log':
|
||||
specifier: workspace:^0.1.0
|
||||
version: link:../log
|
||||
|
||||
1
tooling/eslint/react.js
vendored
1
tooling/eslint/react.js
vendored
@@ -13,6 +13,7 @@ export default [
|
||||
...reactPlugin.configs["jsx-runtime"].rules,
|
||||
...hooksPlugin.configs.recommended.rules,
|
||||
// context.getSource is not a function
|
||||
"react-hooks/rules-of-hooks": "off",
|
||||
"react-hooks/exhaustive-deps": "off",
|
||||
},
|
||||
languageOptions: {
|
||||
|
||||
Reference in New Issue
Block a user