feat: add improved search (#1051)

* feat: add improved search

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

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

* feat: add additional search engines

* feat: add group search details

* refactor: improve users search group type

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

* feat: add integrations and boards for search

* wip: hook issue with react

* fix: hook issue regarding actions and interactions

* chore: address pull request feedback

* fix: format issues

* feat: add additional global actions to search

* chore: remove unused code

* fix: search engine short key

* fix: typecheck issues

* fix: deepsource issues

* fix: eslint issue

* fix: lint issues

* fix: unordered dependencies

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,32 @@
import { Spotlight } from "@mantine/spotlight";
import type { TranslationObject } from "@homarr/translation";
import { translateIfNecessary } from "@homarr/translation";
import { useI18n } from "@homarr/translation/client";
import type { SearchGroup } from "../../../lib/group";
import type { inferSearchInteractionOptions } from "../../../lib/interaction";
import { SpotlightGroupActions } from "../group-actions";
interface SpotlightActionGroupsProps {
groups: SearchGroup[];
query: string;
setMode: (mode: keyof TranslationObject["search"]["mode"]) => void;
setChildrenOptions: (options: inferSearchInteractionOptions<"children">) => void;
}
export const SpotlightActionGroups = ({ groups, query, setMode, setChildrenOptions }: SpotlightActionGroupsProps) => {
const t = useI18n();
return groups.map((group) => (
<Spotlight.ActionsGroup key={translateIfNecessary(t, group.title)} label={translateIfNecessary(t, group.title)}>
{/*eslint-disable-next-line @typescript-eslint/no-explicit-any */}
<SpotlightGroupActions<any>
group={group}
query={query}
setMode={setMode}
setChildrenOptions={setChildrenOptions}
/>
</Spotlight.ActionsGroup>
));
};

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,82 @@
import { Group, Stack, Text } from "@mantine/core";
import type { TablerIcon } from "@tabler/icons-react";
import { IconDownload } from "@tabler/icons-react";
import { useScopedI18n } from "@homarr/translation/client";
import { createGroup } from "../../lib/group";
import { interaction } from "../../lib/interaction";
// This has to be type so it can be interpreted as Record<string, unknown>.
// eslint-disable-next-line @typescript-eslint/consistent-type-definitions
type SearchEngine = {
short: string;
image: string | TablerIcon;
name: string;
description: string;
urlTemplate: string;
};
export const searchEnginesSearchGroups = createGroup<SearchEngine>({
keyPath: "short",
title: (t) => t("search.mode.external.group.searchEngine.title"),
component: ({ image: Image, name, description }) => (
<Group w="100%" wrap="nowrap" justify="space-between" align="center" px="md" py="xs">
<Group wrap="nowrap">
{typeof Image === "string" ? <img height={24} width={24} src={Image} alt={name} /> : <Image size={24} />}
<Stack gap={0} justify="center">
<Text size="sm">{name}</Text>
<Text size="xs" c="gray.6">
{description}
</Text>
</Stack>
</Group>
</Group>
),
filter: () => true,
useInteraction: interaction.link(({ urlTemplate }, query) => ({
href: urlTemplate.replace("%s", query),
newTab: true,
})),
useOptions() {
const tOption = useScopedI18n("search.mode.external.group.searchEngine.option");
return [
{
short: "g",
name: tOption("google.name"),
image: "https://www.google.com/favicon.ico",
description: tOption("google.description"),
urlTemplate: "https://www.google.com/search?q=%s",
},
{
short: "b",
name: tOption("bing.name"),
image: "https://www.bing.com/favicon.ico",
description: tOption("bing.description"),
urlTemplate: "https://www.bing.com/search?q=%s",
},
{
short: "d",
name: tOption("duckduckgo.name"),
image: "https://duckduckgo.com/favicon.ico",
description: tOption("duckduckgo.description"),
urlTemplate: "https://duckduckgo.com/?q=%s",
},
{
short: "t",
name: tOption("torrent.name"),
image: IconDownload,
description: tOption("torrent.description"),
urlTemplate: "https://www.torrentdownloads.pro/search/?search=%s",
},
{
short: "y",
name: tOption("youTube.name"),
image: "https://www.youtube.com/favicon.ico",
description: tOption("youTube.description"),
urlTemplate: "https://www.youtube.com/results?search_query=%s",
},
];
},
});

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,83 @@
import { Group, Stack, Text } from "@mantine/core";
import { IconEye, IconUsersGroup } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
import { useI18n } from "@homarr/translation/client";
import { createChildrenOptions } from "../../lib/children";
import { createGroup } from "../../lib/group";
import { interaction } from "../../lib/interaction";
// This has to be type so it can be interpreted as Record<string, unknown>.
// eslint-disable-next-line @typescript-eslint/consistent-type-definitions
type Group = { id: string; name: string };
const groupChildrenOptions = createChildrenOptions<Group>({
useActions: () => [
{
key: "detail",
component: () => {
const t = useI18n();
return (
<Group mx="md" my="sm">
<IconEye stroke={1.5} />
<Text>{t("search.mode.userGroup.group.group.children.action.detail.label")}</Text>
</Group>
);
},
useInteraction: interaction.link(({ id }) => ({ href: `/manage/users/groups/${id}` })),
},
{
key: "manageMember",
component: () => {
const t = useI18n();
return (
<Group mx="md" my="sm">
<IconUsersGroup stroke={1.5} />
<Text>{t("search.mode.userGroup.group.group.children.action.manageMember.label")}</Text>
</Group>
);
},
useInteraction: interaction.link(({ id }) => ({ href: `/manage/users/groups/${id}/members` })),
},
{
key: "managePermission",
component: () => {
const t = useI18n();
return (
<Group mx="md" my="sm">
<IconEye stroke={1.5} />
<Text>{t("search.mode.userGroup.group.group.children.action.managePermission.label")}</Text>
</Group>
);
},
useInteraction: interaction.link(({ id }) => ({ href: `/manage/users/groups/${id}/permissions` })),
},
],
detailComponent: ({ options }) => {
const t = useI18n();
return (
<Stack mx="md" my="sm">
<Text>{t("search.mode.userGroup.group.group.children.detail.title")}</Text>
<Group>
<Text>{options.name}</Text>
</Group>
</Stack>
);
},
});
export const groupsSearchGroup = createGroup<Group>({
keyPath: "id",
title: "Groups",
component: ({ name }) => (
<Group px="md" py="sm">
<Text>{name}</Text>
</Group>
),
useInteraction: interaction.children(groupChildrenOptions),
useQueryOptions(query) {
return clientApi.group.search.useQuery({ query, limit: 5 });
},
});

View File

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

View File

@@ -0,0 +1,62 @@
import { Group, Stack, Text } from "@mantine/core";
import { IconEye } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
import { useI18n } from "@homarr/translation/client";
import { UserAvatar } from "@homarr/ui";
import { createChildrenOptions } from "../../lib/children";
import { createGroup } from "../../lib/group";
import { interaction } from "../../lib/interaction";
// This has to be type so it can be interpreted as Record<string, unknown>.
// eslint-disable-next-line @typescript-eslint/consistent-type-definitions
type User = { id: string; name: string; image: string | null };
const userChildrenOptions = createChildrenOptions<User>({
useActions: () => [
{
key: "detail",
component: () => {
const t = useI18n();
return (
<Group mx="md" my="sm">
<IconEye stroke={1.5} />
<Text>{t("search.mode.userGroup.group.user.children.action.detail.label")}</Text>
</Group>
);
},
useInteraction: interaction.link(({ id }) => ({ href: `/manage/users/${id}/general` })),
},
],
detailComponent: ({ options }) => {
const t = useI18n();
return (
<Stack mx="md" my="sm">
<Text>{t("search.mode.userGroup.group.user.children.detail.title")}</Text>
<Group>
<UserAvatar user={options} size="sm" />
<Text>{options.name}</Text>
</Group>
</Stack>
);
},
});
export const usersSearchGroup = createGroup<User>({
keyPath: "id",
title: (t) => t("search.mode.userGroup.group.user.title"),
component: (user) => (
<Group px="md" py="sm">
<UserAvatar user={user} size="sm" />
<Text>{user.name}</Text>
</Group>
),
useInteraction: interaction.children(userChildrenOptions),
useQueryOptions(query) {
return clientApi.user.search.useQuery({ query, limit: 5 });
},
});

View File

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

View File

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