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:
@@ -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 });
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user