Replace entire codebase with homarr-labs/homarr
This commit is contained in:
@@ -0,0 +1,28 @@
|
||||
import type { inferSearchInteractionOptions } from "../../lib/interaction";
|
||||
import { ChildrenActionItem } from "./items/children-action-item";
|
||||
|
||||
interface SpotlightChildrenActionsProps {
|
||||
childrenOptions: inferSearchInteractionOptions<"children">;
|
||||
query: string;
|
||||
setChildrenOptions: (options: inferSearchInteractionOptions<"children">) => void;
|
||||
}
|
||||
|
||||
export const SpotlightChildrenActions = ({
|
||||
childrenOptions,
|
||||
query,
|
||||
setChildrenOptions,
|
||||
}: 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}
|
||||
setChildrenOptions={setChildrenOptions}
|
||||
/>
|
||||
));
|
||||
};
|
||||
97
packages/spotlight/src/components/actions/group-actions.tsx
Normal file
97
packages/spotlight/src/components/actions/group-actions.tsx
Normal file
@@ -0,0 +1,97 @@
|
||||
import { Center, Loader } from "@mantine/core";
|
||||
import { useWindowEvent } from "@mantine/hooks";
|
||||
|
||||
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();
|
||||
|
||||
useWindowEvent("keydown", (event) => {
|
||||
const optionsArray = Array.isArray(options) ? options : (options.data ?? []);
|
||||
group.onKeyDown?.(event, optionsArray, query, { setChildrenOptions });
|
||||
});
|
||||
|
||||
if (Array.isArray(options)) {
|
||||
if (options.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
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,27 @@
|
||||
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, ...others }: 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} {...others} />
|
||||
</Spotlight.ActionsGroup>
|
||||
));
|
||||
};
|
||||
@@ -0,0 +1,7 @@
|
||||
.spotlightAction:hover {
|
||||
background-color: alpha(var(--mantine-primary-color-filled), 0.1);
|
||||
}
|
||||
|
||||
.spotlightAction[data-selected="true"] {
|
||||
background-color: alpha(var(--mantine-primary-color-filled), 0.3);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { Spotlight } from "@mantine/spotlight";
|
||||
|
||||
import { Link } from "@homarr/ui";
|
||||
|
||||
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];
|
||||
setChildrenOptions: (options: inferSearchInteractionOptions<"children">) => void;
|
||||
}
|
||||
|
||||
export const ChildrenActionItem = ({ childrenOptions, action, query, setChildrenOptions }: 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
|
||||
: interaction.type === "children"
|
||||
? () => setChildrenOptions(interaction)
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<Spotlight.Action
|
||||
renderRoot={renderRoot}
|
||||
onClick={onClick}
|
||||
closeSpotlightOnTrigger={interaction.type !== "children"}
|
||||
className={classes.spotlightAction}
|
||||
>
|
||||
<action.Component {...childrenOptions.option} />
|
||||
</Spotlight.Action>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,56 @@
|
||||
import { Spotlight } from "@mantine/spotlight";
|
||||
|
||||
import type { TranslationObject } from "@homarr/translation";
|
||||
import { Link } from "@homarr/ui";
|
||||
|
||||
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" && interaction.type !== "none"
|
||||
}
|
||||
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>;
|
||||
};
|
||||
161
packages/spotlight/src/components/spotlight.tsx
Normal file
161
packages/spotlight/src/components/spotlight.tsx
Normal file
@@ -0,0 +1,161 @@
|
||||
"use client";
|
||||
|
||||
import type { Dispatch, SetStateAction } from "react";
|
||||
import { useMemo, useRef, useState } from "react";
|
||||
import { ActionIcon, Center, Group, Kbd } from "@mantine/core";
|
||||
import { Spotlight as MantineSpotlight } from "@mantine/spotlight";
|
||||
import { IconQuestionMark, IconSearch, IconX } from "@tabler/icons-react";
|
||||
|
||||
import { hotkeys } from "@homarr/definitions";
|
||||
import type { TranslationObject } from "@homarr/translation";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
|
||||
import type { inferSearchInteractionOptions } from "../lib/interaction";
|
||||
import type { SearchMode } from "../lib/mode";
|
||||
import { searchModes } from "../modes";
|
||||
import { selectAction, spotlightStore } from "../spotlight-store";
|
||||
import { SpotlightChildrenActions } from "./actions/children-actions";
|
||||
import { SpotlightActionGroups } from "./actions/groups/action-group";
|
||||
|
||||
type SearchModeKey = keyof TranslationObject["search"]["mode"];
|
||||
|
||||
const defaultMode = "home";
|
||||
export const Spotlight = () => {
|
||||
const searchModeState = useState<SearchModeKey>(defaultMode);
|
||||
const mode = searchModeState[0];
|
||||
const activeMode = useMemo(() => searchModes.find((searchMode) => searchMode.modeKey === mode), [mode]);
|
||||
|
||||
if (!activeMode) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// We use the "key" below to prevent the 'Different amounts of hooks' error
|
||||
return <SpotlightWithActiveMode key={mode} modeState={searchModeState} activeMode={activeMode} />;
|
||||
};
|
||||
|
||||
interface SpotlightWithActiveModeProps {
|
||||
modeState: [SearchModeKey, Dispatch<SetStateAction<SearchModeKey>>];
|
||||
activeMode: SearchMode;
|
||||
}
|
||||
|
||||
const SpotlightWithActiveMode = ({ modeState, activeMode }: SpotlightWithActiveModeProps) => {
|
||||
const [query, setQuery] = useState("");
|
||||
const [mode, setMode] = modeState;
|
||||
const [childrenOptions, setChildrenOptions] = useState<inferSearchInteractionOptions<"children"> | null>(null);
|
||||
const t = useI18n();
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
// Works as always the same amount of hooks are executed
|
||||
const useGroups = "groups" in activeMode ? () => activeMode.groups : activeMode.useGroups;
|
||||
const groups = useGroups();
|
||||
|
||||
return (
|
||||
<MantineSpotlight.Root
|
||||
shortcut={hotkeys.openSpotlight}
|
||||
yOffset={8}
|
||||
onSpotlightClose={() => {
|
||||
setMode(defaultMode);
|
||||
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("search.placeholder")}...`}
|
||||
ref={inputRef}
|
||||
leftSectionWidth={activeMode.modeKey !== defaultMode ? 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 !== defaultMode ? <Kbd size="sm">{activeMode.character}</Kbd> : null}
|
||||
</Group>
|
||||
}
|
||||
styles={{
|
||||
section: {
|
||||
pointerEvents: "all",
|
||||
},
|
||||
}}
|
||||
rightSection={
|
||||
mode === defaultMode ? (
|
||||
<ActionIcon
|
||||
onClick={() => {
|
||||
setMode("help");
|
||||
inputRef.current?.focus();
|
||||
}}
|
||||
variant="subtle"
|
||||
>
|
||||
<IconQuestionMark stroke={1.5} />
|
||||
</ActionIcon>
|
||||
) : (
|
||||
<ActionIcon
|
||||
onClick={() => {
|
||||
setMode(defaultMode);
|
||||
setChildrenOptions(null);
|
||||
inputRef.current?.focus();
|
||||
}}
|
||||
variant="subtle"
|
||||
>
|
||||
<IconX stroke={1.5} />
|
||||
</ActionIcon>
|
||||
)
|
||||
}
|
||||
value={query}
|
||||
onKeyDown={(event) => {
|
||||
if (query.length === 0 && mode !== defaultMode && event.key === "Backspace") {
|
||||
setMode(defaultMode);
|
||||
setChildrenOptions(null);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
{childrenOptions ? (
|
||||
<Group>
|
||||
<childrenOptions.DetailComponent options={childrenOptions.option as never} />
|
||||
</Group>
|
||||
) : null}
|
||||
|
||||
<MantineSpotlight.ActionsList>
|
||||
{childrenOptions ? (
|
||||
<SpotlightChildrenActions
|
||||
childrenOptions={childrenOptions}
|
||||
query={query}
|
||||
setChildrenOptions={setChildrenOptions}
|
||||
/>
|
||||
) : (
|
||||
<SpotlightActionGroups
|
||||
setMode={(mode) => {
|
||||
setMode(mode);
|
||||
setChildrenOptions(null);
|
||||
setTimeout(() => selectAction(0, spotlightStore));
|
||||
}}
|
||||
setChildrenOptions={(options) => {
|
||||
setChildrenOptions(options);
|
||||
|
||||
setTimeout(() => {
|
||||
setQuery("");
|
||||
selectAction(0, spotlightStore);
|
||||
});
|
||||
}}
|
||||
query={query}
|
||||
groups={groups}
|
||||
/>
|
||||
)}
|
||||
</MantineSpotlight.ActionsList>
|
||||
</MantineSpotlight.Root>
|
||||
);
|
||||
};
|
||||
13
packages/spotlight/src/index.ts
Normal file
13
packages/spotlight/src/index.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import { spotlightActions } from "./spotlight-store";
|
||||
|
||||
export { Spotlight } from "./components/spotlight";
|
||||
export { openSpotlight };
|
||||
export {
|
||||
SpotlightProvider,
|
||||
useRegisterSpotlightContextResults,
|
||||
useRegisterSpotlightContextActions,
|
||||
} from "./modes/home/context";
|
||||
|
||||
const openSpotlight = spotlightActions.open;
|
||||
27
packages/spotlight/src/lib/children.ts
Normal file
27
packages/spotlight/src/lib/children.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import type { JSX, 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" | "children">;
|
||||
hide?: boolean | ((option: TParentOptions) => boolean);
|
||||
}
|
||||
|
||||
export const createChildrenOptions = <TParentOptions extends Record<string, unknown>>(
|
||||
props: CreateChildrenOptionsProps<TParentOptions>,
|
||||
) => {
|
||||
return (option: TParentOptions) => ({
|
||||
option,
|
||||
...props,
|
||||
});
|
||||
};
|
||||
39
packages/spotlight/src/lib/group.ts
Normal file
39
packages/spotlight/src/lib/group.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import type { JSX } from "react";
|
||||
|
||||
import type { stringOrTranslation } from "@homarr/translation";
|
||||
|
||||
import type { inferSearchInteractionDefinition, inferSearchInteractionOptions, 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>;
|
||||
onKeyDown?: (
|
||||
event: KeyboardEvent,
|
||||
options: TOption[],
|
||||
query: string,
|
||||
actions: {
|
||||
setChildrenOptions: (options: inferSearchInteractionOptions<"children">) => void;
|
||||
},
|
||||
) => void;
|
||||
} & 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: (query: string) => TOption[];
|
||||
}
|
||||
>
|
||||
| CommonSearchGroup<
|
||||
TOption,
|
||||
{ useQueryOptions: (query: string) => { data: TOption[] | undefined; isLoading: boolean; isError: boolean } }
|
||||
>;
|
||||
|
||||
export const createGroup = <TOption extends Record<string, unknown>>(group: SearchGroup<TOption>) => group;
|
||||
59
packages/spotlight/src/lib/interaction.ts
Normal file
59
packages/spotlight/src/lib/interaction.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
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> | undefined>() => ({ 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;
|
||||
}>(),
|
||||
createSearchInteraction("none").optionsType<never>(),
|
||||
] 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> = Exclude<
|
||||
Extract<(typeof searchInteractions)[number], { type: TInteraction }>["_inferOptions"],
|
||||
undefined
|
||||
>;
|
||||
|
||||
// Infer the search interaction definition (type + options) for the specified search interaction
|
||||
export type inferSearchInteractionDefinition<TInteraction extends SearchInteraction> = {
|
||||
[interactionKey in TInteraction]: inferSearchInteractionOptions<interactionKey> extends never
|
||||
? { type: interactionKey }
|
||||
: { 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);
|
||||
15
packages/spotlight/src/lib/mode.ts
Normal file
15
packages/spotlight/src/lib/mode.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import type { TranslationObject } from "@homarr/translation";
|
||||
|
||||
import type { SearchGroup } from "./group";
|
||||
|
||||
export type SearchMode = {
|
||||
modeKey: keyof TranslationObject["search"]["mode"];
|
||||
character: string | undefined;
|
||||
} & (
|
||||
| {
|
||||
groups: SearchGroup[];
|
||||
}
|
||||
| {
|
||||
useGroups: () => 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,144 @@
|
||||
import { Group, Stack, Text } from "@mantine/core";
|
||||
import { IconDeviceMobile, 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: "mobileBoard",
|
||||
Component: () => {
|
||||
const t = useI18n();
|
||||
|
||||
return (
|
||||
<Group mx="md" my="sm">
|
||||
<IconDeviceMobile stroke={1.5} />
|
||||
<Text>{t("search.mode.appIntegrationBoard.group.board.children.action.mobileBoard.label")}</Text>
|
||||
</Group>
|
||||
);
|
||||
},
|
||||
useInteraction(option) {
|
||||
const { mutateAsync } = clientApi.board.setMobileHomeBoard.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 });
|
||||
},
|
||||
});
|
||||
22
packages/spotlight/src/modes/app-integration-board/index.tsx
Normal file
22
packages/spotlight/src/modes/app-integration-board/index.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import { useSession } from "@homarr/auth/client";
|
||||
|
||||
import type { SearchGroup } from "../../lib/group";
|
||||
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: "#",
|
||||
useGroups() {
|
||||
const { data: session } = useSession();
|
||||
const groups: SearchGroup[] = [boardsSearchGroup];
|
||||
|
||||
if (!session?.user) {
|
||||
return groups;
|
||||
}
|
||||
|
||||
return groups.concat([appsSearchGroup, integrationsSearchGroup]);
|
||||
},
|
||||
} 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 });
|
||||
},
|
||||
});
|
||||
66
packages/spotlight/src/modes/command/children/language.tsx
Normal file
66
packages/spotlight/src/modes/command/children/language.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
import { Group, Stack, Text } from "@mantine/core";
|
||||
import { IconCheck } from "@tabler/icons-react";
|
||||
|
||||
import { localeConfigurations, supportedLanguages } from "@homarr/translation";
|
||||
import { useChangeLocale, useCurrentLocale, useI18n } from "@homarr/translation/client";
|
||||
import { LanguageIcon } from "@homarr/ui";
|
||||
|
||||
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, configuration: localeConfigurations[localeKey] }))
|
||||
.filter(
|
||||
({ configuration }) =>
|
||||
configuration.name.toLowerCase().includes(normalizedQuery) ||
|
||||
configuration.translatedName.toLowerCase().includes(normalizedQuery),
|
||||
)
|
||||
.sort(
|
||||
(languageA, languageB) =>
|
||||
Math.min(
|
||||
languageA.configuration.name.toLowerCase().indexOf(normalizedQuery),
|
||||
languageA.configuration.translatedName.toLowerCase().indexOf(normalizedQuery),
|
||||
) -
|
||||
Math.min(
|
||||
languageB.configuration.name.toLowerCase().indexOf(normalizedQuery),
|
||||
languageB.configuration.translatedName.toLowerCase().indexOf(normalizedQuery),
|
||||
),
|
||||
)
|
||||
.map(({ localeKey, configuration }) => ({
|
||||
key: localeKey,
|
||||
Component() {
|
||||
return (
|
||||
<Group mx="md" my="sm" wrap="nowrap" justify="space-between" w="100%">
|
||||
<Group wrap="nowrap">
|
||||
<LanguageIcon icon={localeConfigurations[localeKey].icon} />
|
||||
<Group wrap="nowrap" gap="xs">
|
||||
<Text>{configuration.name}</Text>
|
||||
<Text size="xs" c="dimmed" inherit>
|
||||
({configuration.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>
|
||||
);
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
import { Group, Text } from "@mantine/core";
|
||||
|
||||
import { createGroup } from "../../lib/group";
|
||||
import type { ContextSpecificItem } from "../home/context";
|
||||
import { useSpotlightContextActions } from "../home/context";
|
||||
|
||||
export const contextSpecificActionsSearchGroups = createGroup<ContextSpecificItem>({
|
||||
title: (t) => t("search.mode.command.group.localCommand.title"),
|
||||
keyPath: "id",
|
||||
Component(option) {
|
||||
const icon =
|
||||
typeof option.icon !== "string" ? (
|
||||
<option.icon size={24} />
|
||||
) : (
|
||||
<img width={24} height={24} src={option.icon} alt={option.name} />
|
||||
);
|
||||
|
||||
return (
|
||||
<Group w="100%" wrap="nowrap" align="center" px="md" py="xs">
|
||||
{icon}
|
||||
<Text>{option.name}</Text>
|
||||
</Group>
|
||||
);
|
||||
},
|
||||
useInteraction(option) {
|
||||
return option.interaction();
|
||||
},
|
||||
filter(query, option) {
|
||||
return option.name.toLowerCase().includes(query.toLowerCase());
|
||||
},
|
||||
useOptions() {
|
||||
return useSpotlightContextActions();
|
||||
},
|
||||
});
|
||||
166
packages/spotlight/src/modes/command/global-group.tsx
Normal file
166
packages/spotlight/src/modes/command/global-group.tsx
Normal file
@@ -0,0 +1,166 @@
|
||||
import { Group, Text, useMantineColorScheme } from "@mantine/core";
|
||||
import {
|
||||
IconBox,
|
||||
IconCategoryPlus,
|
||||
IconFileImport,
|
||||
IconLanguage,
|
||||
IconMailForward,
|
||||
IconMoon,
|
||||
IconPlug,
|
||||
IconSun,
|
||||
IconUserPlus,
|
||||
IconUsersGroup,
|
||||
} from "@tabler/icons-react";
|
||||
|
||||
import { useSession } from "@homarr/auth/client";
|
||||
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 { 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 globalCommandGroup = 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 { data: session } = useSession();
|
||||
|
||||
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);
|
||||
},
|
||||
};
|
||||
},
|
||||
hidden: !session?.user.permissions.includes("board-create"),
|
||||
},
|
||||
{
|
||||
commandKey: "importBoard",
|
||||
icon: IconFileImport,
|
||||
name: tOption("importBoard.label"),
|
||||
useInteraction() {
|
||||
const { openModal } = useModalAction(ImportBoardModal);
|
||||
|
||||
return {
|
||||
type: "javaScript",
|
||||
onSelect() {
|
||||
openModal(undefined);
|
||||
},
|
||||
};
|
||||
},
|
||||
hidden: !session?.user.permissions.includes("board-create"),
|
||||
},
|
||||
{
|
||||
commandKey: "newApp",
|
||||
icon: IconBox,
|
||||
name: tOption("newApp.label"),
|
||||
useInteraction: interaction.link(() => ({ href: "/manage/apps/new" })),
|
||||
hidden: !session?.user.permissions.includes("app-create"),
|
||||
},
|
||||
{
|
||||
commandKey: "newIntegration",
|
||||
icon: IconPlug,
|
||||
name: tOption("newIntegration.label"),
|
||||
useInteraction: interaction.children(newIntegrationChildrenOptions),
|
||||
hidden: !session?.user.permissions.includes("integration-create"),
|
||||
},
|
||||
{
|
||||
commandKey: "newUser",
|
||||
icon: IconUserPlus,
|
||||
name: tOption("newUser.label"),
|
||||
useInteraction: interaction.link(() => ({ href: "/manage/users/new" })),
|
||||
hidden: !session?.user.permissions.includes("admin"),
|
||||
},
|
||||
{
|
||||
commandKey: "newInvite",
|
||||
icon: IconMailForward,
|
||||
name: tOption("newInvite.label"),
|
||||
useInteraction() {
|
||||
const { openModal } = useModalAction(InviteCreateModal);
|
||||
|
||||
return {
|
||||
type: "javaScript",
|
||||
onSelect() {
|
||||
openModal(undefined);
|
||||
},
|
||||
};
|
||||
},
|
||||
hidden: !session?.user.permissions.includes("admin"),
|
||||
},
|
||||
{
|
||||
commandKey: "newGroup",
|
||||
icon: IconUsersGroup,
|
||||
name: tOption("newGroup.label"),
|
||||
useInteraction() {
|
||||
const { openModal } = useModalAction(AddGroupModal);
|
||||
|
||||
return {
|
||||
type: "javaScript",
|
||||
onSelect() {
|
||||
openModal(undefined);
|
||||
},
|
||||
};
|
||||
},
|
||||
hidden: !session?.user.permissions.includes("admin"),
|
||||
},
|
||||
];
|
||||
|
||||
return commands.filter((command) => !command.hidden);
|
||||
},
|
||||
});
|
||||
9
packages/spotlight/src/modes/command/index.tsx
Normal file
9
packages/spotlight/src/modes/command/index.tsx
Normal file
@@ -0,0 +1,9 @@
|
||||
import type { SearchMode } from "../../lib/mode";
|
||||
import { contextSpecificActionsSearchGroups } from "./context-specific-group";
|
||||
import { globalCommandGroup } from "./global-group";
|
||||
|
||||
export const commandMode = {
|
||||
modeKey: "command",
|
||||
character: ">",
|
||||
groups: [contextSpecificActionsSearchGroups, globalCommandGroup],
|
||||
} 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;
|
||||
291
packages/spotlight/src/modes/external/search-engines-search-group.tsx
vendored
Normal file
291
packages/spotlight/src/modes/external/search-engines-search-group.tsx
vendored
Normal file
@@ -0,0 +1,291 @@
|
||||
import { Group, Image, Kbd, Stack, Text } from "@mantine/core";
|
||||
import { IconDownload, IconSearch } from "@tabler/icons-react";
|
||||
|
||||
import type { RouterOutputs } from "@homarr/api";
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import type { IntegrationKind } from "@homarr/definitions";
|
||||
import { getIntegrationKindsByCategory, getIntegrationName } from "@homarr/definitions";
|
||||
import { useModalAction } from "@homarr/modals";
|
||||
import { RequestMediaModal } from "@homarr/modals-collection";
|
||||
import { useSettings } from "@homarr/settings";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import { createChildrenOptions } from "../../lib/children";
|
||||
import { createGroup } from "../../lib/group";
|
||||
import type { inferSearchInteractionDefinition } from "../../lib/interaction";
|
||||
import { interaction } from "../../lib/interaction";
|
||||
|
||||
type SearchEngine = RouterOutputs["searchEngine"]["search"][number];
|
||||
type FromIntegrationSearchResult = RouterOutputs["integration"]["searchInIntegration"][number];
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-definitions
|
||||
type MediaRequestChildrenProps = {
|
||||
result: {
|
||||
id: number;
|
||||
image?: string;
|
||||
name: string;
|
||||
link: string;
|
||||
text?: string;
|
||||
type: "tv" | "movie";
|
||||
inLibrary: boolean;
|
||||
};
|
||||
integration: {
|
||||
kind: IntegrationKind;
|
||||
url: string;
|
||||
id: string;
|
||||
};
|
||||
};
|
||||
|
||||
export const useFromIntegrationSearchInteraction = (
|
||||
searchEngine: SearchEngine,
|
||||
searchResult: FromIntegrationSearchResult,
|
||||
): inferSearchInteractionDefinition<"link" | "javaScript" | "children"> => {
|
||||
const { openSearchInNewTab } = useSettings();
|
||||
|
||||
if (searchEngine.type !== "fromIntegration") {
|
||||
throw new Error("Invalid search engine type");
|
||||
}
|
||||
|
||||
if (!searchEngine.integration) {
|
||||
throw new Error("Invalid search engine integration");
|
||||
}
|
||||
|
||||
if (
|
||||
getIntegrationKindsByCategory("mediaRequest").some(
|
||||
(categoryKind) => categoryKind === searchEngine.integration?.kind,
|
||||
) &&
|
||||
"type" in searchResult
|
||||
) {
|
||||
const type = searchResult.type;
|
||||
if (type === "person") {
|
||||
return {
|
||||
type: "link",
|
||||
href: searchResult.link,
|
||||
newTab: openSearchInNewTab,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
type: "children",
|
||||
...mediaRequestsChildrenOptions({
|
||||
result: {
|
||||
...searchResult,
|
||||
type,
|
||||
},
|
||||
integration: searchEngine.integration,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
type: "link",
|
||||
href: searchResult.link,
|
||||
newTab: true,
|
||||
};
|
||||
};
|
||||
|
||||
const mediaRequestsChildrenOptions = createChildrenOptions<MediaRequestChildrenProps>({
|
||||
useActions() {
|
||||
const { openModal } = useModalAction(RequestMediaModal);
|
||||
return [
|
||||
{
|
||||
key: "request",
|
||||
hide: (option) => option.result.inLibrary,
|
||||
Component(option) {
|
||||
const t = useScopedI18n("search.mode.media");
|
||||
return (
|
||||
<Group mx="md" my="sm" wrap="nowrap">
|
||||
<IconDownload stroke={1.5} />
|
||||
{option.result.type === "tv" && <Text>{t("requestSeries")}</Text>}
|
||||
{option.result.type === "movie" && <Text>{t("requestMovie")}</Text>}
|
||||
</Group>
|
||||
);
|
||||
},
|
||||
useInteraction: interaction.javaScript((option) => ({
|
||||
onSelect() {
|
||||
openModal(
|
||||
{
|
||||
integrationId: option.integration.id,
|
||||
mediaId: option.result.id,
|
||||
mediaType: option.result.type,
|
||||
},
|
||||
{
|
||||
title(t) {
|
||||
return t("search.engine.media.request.modal.title", { name: option.result.name });
|
||||
},
|
||||
},
|
||||
);
|
||||
},
|
||||
})),
|
||||
},
|
||||
{
|
||||
key: "open",
|
||||
Component({ integration }) {
|
||||
const tChildren = useScopedI18n("search.mode.media");
|
||||
return (
|
||||
<Group mx="md" my="sm" wrap="nowrap">
|
||||
<IconSearch stroke={1.5} />
|
||||
<Text>{tChildren("openIn", { kind: getIntegrationName(integration.kind) })}</Text>
|
||||
</Group>
|
||||
);
|
||||
},
|
||||
useInteraction({ result }) {
|
||||
const { openSearchInNewTab } = useSettings();
|
||||
return {
|
||||
type: "link",
|
||||
href: result.link,
|
||||
newTab: openSearchInNewTab,
|
||||
};
|
||||
},
|
||||
},
|
||||
];
|
||||
},
|
||||
DetailComponent({ options }) {
|
||||
return (
|
||||
<Group mx="md" my="sm" wrap="nowrap">
|
||||
{options.result.image ? (
|
||||
<Image src={options.result.image} w={35} h={50} fit="cover" radius={"md"} />
|
||||
) : (
|
||||
<IconSearch stroke={1.5} size={35} />
|
||||
)}
|
||||
<Stack gap={2}>
|
||||
<Text>{options.result.name}</Text>
|
||||
{options.result.text && (
|
||||
<Text c="dimmed" size="sm" lineClamp={2}>
|
||||
{options.result.text}
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
</Group>
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
export const searchEnginesChildrenOptions = createChildrenOptions<SearchEngine>({
|
||||
useActions: (searchEngine, query) => {
|
||||
const { data } = clientApi.integration.searchInIntegration.useQuery(
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
{ integrationId: searchEngine.integrationId!, query },
|
||||
{
|
||||
enabled: searchEngine.type === "fromIntegration" && searchEngine.integrationId !== null && query.length > 0,
|
||||
},
|
||||
);
|
||||
const { openSearchInNewTab } = useSettings();
|
||||
|
||||
if (searchEngine.type === "generic") {
|
||||
return [
|
||||
{
|
||||
key: "search",
|
||||
Component: ({ name }) => {
|
||||
const tChildren = useScopedI18n("search.mode.external.group.searchEngine.children");
|
||||
|
||||
return (
|
||||
<Group mx="md" my="sm">
|
||||
<IconSearch stroke={1.5} />
|
||||
<Text>{tChildren("action.search.label", { name })}</Text>
|
||||
</Group>
|
||||
);
|
||||
},
|
||||
useInteraction: interaction.link(({ urlTemplate }, query) => ({
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
href: urlTemplate!.replace("%s", query),
|
||||
newTab: openSearchInNewTab,
|
||||
})),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
return (data ?? []).map((searchResult, index) => ({
|
||||
key: `search-result-${index}`,
|
||||
Component: () => {
|
||||
return (
|
||||
<Group mx="md" my="sm" wrap="nowrap">
|
||||
{searchResult.image ? (
|
||||
<Image src={searchResult.image} w={35} h={50} fit="cover" radius={"md"} />
|
||||
) : (
|
||||
<IconSearch stroke={1.5} size={35} />
|
||||
)}
|
||||
<Stack gap={2}>
|
||||
<Text>{searchResult.name}</Text>
|
||||
{searchResult.text && (
|
||||
<Text c="dimmed" size="sm" lineClamp={2}>
|
||||
{searchResult.text}
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
</Group>
|
||||
);
|
||||
},
|
||||
useInteraction() {
|
||||
return useFromIntegrationSearchInteraction(searchEngine, searchResult);
|
||||
},
|
||||
}));
|
||||
},
|
||||
DetailComponent({ options }) {
|
||||
const tChildren = useScopedI18n("search.mode.external.group.searchEngine.children");
|
||||
return (
|
||||
<Stack mx="md" my="sm">
|
||||
<Text>{options.type === "generic" ? tChildren("detail.title") : tChildren("searchResults.title")}</Text>
|
||||
<Group>
|
||||
<img height={24} width={24} src={options.iconUrl} alt={options.name} />
|
||||
<Text>{options.name}</Text>
|
||||
</Group>
|
||||
</Stack>
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
export const searchEnginesSearchGroups = createGroup<SearchEngine>({
|
||||
keyPath: "short",
|
||||
title: (t) => t("search.mode.external.group.searchEngine.title"),
|
||||
Component: ({ iconUrl, name, short, description }) => {
|
||||
return (
|
||||
<Group w="100%" wrap="nowrap" justify="space-between" align="center" px="md" py="xs">
|
||||
<Group wrap="nowrap">
|
||||
<img height={24} width={24} src={iconUrl} alt={name} />
|
||||
<Stack gap={0} justify="center">
|
||||
<Text size="sm">{name}</Text>
|
||||
<Text size="xs" c="gray.6">
|
||||
{description}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Group>
|
||||
|
||||
<Kbd size="sm">{short}</Kbd>
|
||||
</Group>
|
||||
);
|
||||
},
|
||||
onKeyDown(event, options, query, { setChildrenOptions }) {
|
||||
if (event.code !== "Space") return;
|
||||
|
||||
const engine = options.find((option) => option.short === query);
|
||||
if (!engine) return;
|
||||
|
||||
setChildrenOptions(searchEnginesChildrenOptions(engine));
|
||||
},
|
||||
useInteraction: (searchEngine, query) => {
|
||||
const { openSearchInNewTab } = useSettings();
|
||||
if (searchEngine.type === "generic" && searchEngine.urlTemplate) {
|
||||
return {
|
||||
type: "link" as const,
|
||||
href: searchEngine.urlTemplate.replace("%s", query),
|
||||
newTab: openSearchInNewTab,
|
||||
};
|
||||
}
|
||||
|
||||
if (searchEngine.type === "fromIntegration" && searchEngine.integrationId !== null) {
|
||||
return {
|
||||
type: "children",
|
||||
...searchEnginesChildrenOptions(searchEngine),
|
||||
};
|
||||
}
|
||||
|
||||
throw new Error(`Unable to process search engine with type ${searchEngine.type}`);
|
||||
},
|
||||
useQueryOptions(query) {
|
||||
return clientApi.searchEngine.search.useQuery({
|
||||
query: query.trim(),
|
||||
limit: 5,
|
||||
});
|
||||
},
|
||||
});
|
||||
34
packages/spotlight/src/modes/home/context-specific-group.tsx
Normal file
34
packages/spotlight/src/modes/home/context-specific-group.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
import { Group, Text } from "@mantine/core";
|
||||
|
||||
import { createGroup } from "../../lib/group";
|
||||
import type { ContextSpecificItem } from "./context";
|
||||
import { useSpotlightContextResults } from "./context";
|
||||
|
||||
export const contextSpecificSearchGroups = createGroup<ContextSpecificItem>({
|
||||
title: (t) => t("search.mode.home.group.local.title"),
|
||||
keyPath: "id",
|
||||
Component(option) {
|
||||
const icon =
|
||||
typeof option.icon !== "string" ? (
|
||||
<option.icon size={24} />
|
||||
) : (
|
||||
<img width={24} height={24} src={option.icon} alt={option.name} />
|
||||
);
|
||||
|
||||
return (
|
||||
<Group w="100%" wrap="nowrap" align="center" px="md" py="xs">
|
||||
{icon}
|
||||
<Text>{option.name}</Text>
|
||||
</Group>
|
||||
);
|
||||
},
|
||||
useInteraction(option) {
|
||||
return option.interaction();
|
||||
},
|
||||
filter(query, option) {
|
||||
return option.name.toLowerCase().includes(query.toLowerCase());
|
||||
},
|
||||
useOptions() {
|
||||
return useSpotlightContextResults();
|
||||
},
|
||||
});
|
||||
122
packages/spotlight/src/modes/home/context.tsx
Normal file
122
packages/spotlight/src/modes/home/context.tsx
Normal file
@@ -0,0 +1,122 @@
|
||||
import type { DependencyList, PropsWithChildren } from "react";
|
||||
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from "react";
|
||||
|
||||
import type { TablerIcon } from "@homarr/ui";
|
||||
|
||||
import type { inferSearchInteractionDefinition, SearchInteraction } from "../../lib/interaction";
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-definitions
|
||||
export type ContextSpecificItem = {
|
||||
id: string;
|
||||
name: string;
|
||||
icon: TablerIcon | string;
|
||||
interaction: () => inferSearchInteractionDefinition<SearchInteraction>;
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
interface SpotlightContextProps {
|
||||
items: ContextSpecificItem[];
|
||||
registerItems: (key: string, results: ContextSpecificItem[]) => void;
|
||||
unregisterItems: (key: string) => void;
|
||||
}
|
||||
|
||||
const createSpotlightContext = (displayName: string) => {
|
||||
const SpotlightContext = createContext<SpotlightContextProps | null>(null);
|
||||
SpotlightContext.displayName = displayName;
|
||||
|
||||
const Provider = ({ children }: PropsWithChildren) => {
|
||||
const [itemsMap, setItemsMap] = useState<Map<string, { items: ContextSpecificItem[]; count: number }>>(new Map());
|
||||
|
||||
const registerItems = useCallback((key: string, newItems: ContextSpecificItem[]) => {
|
||||
setItemsMap((prevItems) => {
|
||||
const newItemsMap = new Map(prevItems);
|
||||
newItemsMap.set(key, { items: newItems, count: (newItemsMap.get(key)?.count ?? 0) + 1 });
|
||||
return newItemsMap;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const unregisterItems = useCallback((key: string) => {
|
||||
setItemsMap((prevItems) => {
|
||||
const registrationCount = prevItems.get(key)?.count ?? 0;
|
||||
|
||||
if (registrationCount <= 1) {
|
||||
const newItemsMap = new Map(prevItems);
|
||||
newItemsMap.delete(key);
|
||||
return newItemsMap;
|
||||
}
|
||||
|
||||
const newItemsMap = new Map(prevItems);
|
||||
newItemsMap.set(key, { items: newItemsMap.get(key)?.items ?? [], count: registrationCount - 1 });
|
||||
|
||||
return prevItems;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const items = useMemo(() => Array.from(itemsMap.values()).flatMap(({ items }) => items), [itemsMap]);
|
||||
|
||||
return (
|
||||
<SpotlightContext.Provider value={{ items, registerItems, unregisterItems }}>
|
||||
{children}
|
||||
</SpotlightContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
const useSpotlightContextItems = () => {
|
||||
const context = useContext(SpotlightContext);
|
||||
|
||||
if (!context) {
|
||||
throw new Error(`useSpotlightContextItems must be used within SpotlightContext[displayName=${displayName}]`);
|
||||
}
|
||||
|
||||
return context.items;
|
||||
};
|
||||
|
||||
const useRegisterSpotlightContextItems = (
|
||||
key: string,
|
||||
items: ContextSpecificItem[],
|
||||
dependencyArray: DependencyList,
|
||||
) => {
|
||||
const context = useContext(SpotlightContext);
|
||||
|
||||
if (!context) {
|
||||
throw new Error(
|
||||
`useRegisterSpotlightContextItems must be used within SpotlightContext[displayName=${displayName}]`,
|
||||
);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
context.registerItems(
|
||||
key,
|
||||
items.filter((item) => !item.disabled),
|
||||
);
|
||||
|
||||
return () => {
|
||||
context.unregisterItems(key);
|
||||
};
|
||||
// We ignore the results
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [...dependencyArray, key]);
|
||||
};
|
||||
|
||||
return [SpotlightContext, Provider, useSpotlightContextItems, useRegisterSpotlightContextItems] as const;
|
||||
};
|
||||
|
||||
const [_ResultContext, ResultProvider, useSpotlightContextResults, useRegisterSpotlightContextResults] =
|
||||
createSpotlightContext("SpotlightContextSpecificResults");
|
||||
const [_ActionContext, ActionProvider, useSpotlightContextActions, useRegisterSpotlightContextActions] =
|
||||
createSpotlightContext("SpotlightContextSpecificActions");
|
||||
|
||||
export {
|
||||
useRegisterSpotlightContextActions,
|
||||
useRegisterSpotlightContextResults,
|
||||
useSpotlightContextActions,
|
||||
useSpotlightContextResults,
|
||||
};
|
||||
|
||||
export const SpotlightProvider = ({ children }: PropsWithChildren) => {
|
||||
return (
|
||||
<ResultProvider>
|
||||
<ActionProvider>{children}</ActionProvider>
|
||||
</ResultProvider>
|
||||
);
|
||||
};
|
||||
176
packages/spotlight/src/modes/home/home-search-engine-group.tsx
Normal file
176
packages/spotlight/src/modes/home/home-search-engine-group.tsx
Normal file
@@ -0,0 +1,176 @@
|
||||
import { Box, Group, Stack, Text } from "@mantine/core";
|
||||
import { IconCaretUpDown, IconSearch, IconSearchOff } from "@tabler/icons-react";
|
||||
|
||||
import type { RouterOutputs } from "@homarr/api";
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import type { Session } from "@homarr/auth";
|
||||
import { useSession } from "@homarr/auth/client";
|
||||
import { useSettings } from "@homarr/settings";
|
||||
import type { TranslationFunction } from "@homarr/translation";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import type { TablerIcon } from "@homarr/ui";
|
||||
|
||||
import { createGroup } from "../../lib/group";
|
||||
import type { inferSearchInteractionDefinition, SearchInteraction } from "../../lib/interaction";
|
||||
import { useFromIntegrationSearchInteraction } from "../external/search-engines-search-group";
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-definitions
|
||||
type GroupItem = {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
icon: TablerIcon | string;
|
||||
useInteraction: (query: string) => inferSearchInteractionDefinition<SearchInteraction>;
|
||||
};
|
||||
|
||||
export const homeSearchEngineGroup = createGroup<GroupItem>({
|
||||
title: (t) => t("search.mode.home.group.search.title"),
|
||||
keyPath: "id",
|
||||
Component(item) {
|
||||
const icon =
|
||||
typeof item.icon !== "string" ? (
|
||||
<item.icon size={24} />
|
||||
) : (
|
||||
<Box w={24} h={24}>
|
||||
<img src={item.icon} alt={item.name} style={{ maxWidth: 24 }} />
|
||||
</Box>
|
||||
);
|
||||
|
||||
return (
|
||||
<Group w="100%" wrap="nowrap" align="center" px="md" py="xs">
|
||||
{icon}
|
||||
<Stack gap={0}>
|
||||
<Text>{item.name}</Text>
|
||||
{item.description && (
|
||||
<Text c="gray.6" size="sm">
|
||||
{item.description}
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
</Group>
|
||||
);
|
||||
},
|
||||
useInteraction(item, query) {
|
||||
return item.useInteraction(query);
|
||||
},
|
||||
filter() {
|
||||
return true;
|
||||
},
|
||||
useQueryOptions(query) {
|
||||
const t = useI18n();
|
||||
const { data: session, status } = useSession();
|
||||
const { data: defaultSearchEngine, ...defaultSearchEngineQuery } =
|
||||
clientApi.searchEngine.getDefaultSearchEngine.useQuery(undefined, {
|
||||
enabled: status !== "loading",
|
||||
});
|
||||
const fromIntegrationEnabled = defaultSearchEngine?.type === "fromIntegration" && query.length > 0;
|
||||
const { data: results, ...resultQuery } = clientApi.integration.searchInIntegration.useQuery(
|
||||
{
|
||||
query,
|
||||
integrationId: defaultSearchEngine?.integrationId ?? "",
|
||||
},
|
||||
{
|
||||
enabled: fromIntegrationEnabled,
|
||||
select: (data) => data.slice(0, 5),
|
||||
},
|
||||
);
|
||||
|
||||
return {
|
||||
isLoading:
|
||||
defaultSearchEngineQuery.isLoading || (resultQuery.isLoading && fromIntegrationEnabled) || status === "loading",
|
||||
isError: defaultSearchEngineQuery.isError || (resultQuery.isError && fromIntegrationEnabled),
|
||||
data: [
|
||||
...createDefaultSearchEntries(defaultSearchEngine, results, session, query, t),
|
||||
{
|
||||
id: "other",
|
||||
name: t("search.mode.home.group.search.option.other.label"),
|
||||
icon: IconCaretUpDown,
|
||||
useInteraction() {
|
||||
return {
|
||||
type: "mode",
|
||||
mode: "external",
|
||||
};
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
const createDefaultSearchEntries = (
|
||||
defaultSearchEngine: RouterOutputs["searchEngine"]["getDefaultSearchEngine"] | null,
|
||||
results: RouterOutputs["integration"]["searchInIntegration"] | undefined,
|
||||
session: Session | null,
|
||||
query: string,
|
||||
t: TranslationFunction,
|
||||
): GroupItem[] => {
|
||||
if (!session?.user && !defaultSearchEngine) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (!defaultSearchEngine) {
|
||||
return [
|
||||
{
|
||||
id: "no-default",
|
||||
name: t("search.mode.home.group.search.option.no-default.label"),
|
||||
description: t("search.mode.home.group.search.option.no-default.description"),
|
||||
icon: IconSearchOff,
|
||||
useInteraction() {
|
||||
return {
|
||||
type: "link",
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
href: `/manage/users/${session!.user.id}/general`,
|
||||
};
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
if (defaultSearchEngine.type === "generic") {
|
||||
return [
|
||||
{
|
||||
id: "search",
|
||||
name: t("search.mode.home.group.search.option.search.label", {
|
||||
query,
|
||||
name: defaultSearchEngine.name,
|
||||
}),
|
||||
icon: defaultSearchEngine.iconUrl,
|
||||
useInteraction(query) {
|
||||
const { openSearchInNewTab } = useSettings();
|
||||
return {
|
||||
type: "link",
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
href: defaultSearchEngine.urlTemplate!.replace("%s", query),
|
||||
newTab: openSearchInNewTab,
|
||||
};
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
if (!results) {
|
||||
return [
|
||||
{
|
||||
id: "from-integration",
|
||||
name: defaultSearchEngine.name,
|
||||
icon: defaultSearchEngine.iconUrl,
|
||||
description: t("search.mode.home.group.search.option.from-integration.description"),
|
||||
useInteraction() {
|
||||
return {
|
||||
type: "none",
|
||||
};
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
return results.map((result) => ({
|
||||
id: `search-${result.id}`,
|
||||
name: result.name,
|
||||
description: result.text,
|
||||
icon: result.image ?? IconSearch,
|
||||
useInteraction() {
|
||||
return useFromIntegrationSearchInteraction(defaultSearchEngine, result);
|
||||
},
|
||||
}));
|
||||
};
|
||||
9
packages/spotlight/src/modes/home/index.tsx
Normal file
9
packages/spotlight/src/modes/home/index.tsx
Normal file
@@ -0,0 +1,9 @@
|
||||
import type { SearchMode } from "../../lib/mode";
|
||||
import { contextSpecificSearchGroups } from "./context-specific-group";
|
||||
import { homeSearchEngineGroup } from "./home-search-engine-group";
|
||||
|
||||
export const homeMode = {
|
||||
character: undefined,
|
||||
modeKey: "home",
|
||||
groups: [homeSearchEngineGroup, contextSpecificSearchGroups],
|
||||
} satisfies SearchMode;
|
||||
86
packages/spotlight/src/modes/index.tsx
Normal file
86
packages/spotlight/src/modes/index.tsx
Normal file
@@ -0,0 +1,86 @@
|
||||
import { Group, Kbd, Text } from "@mantine/core";
|
||||
import { IconBook2, IconBrandDiscord, IconBrandGithub } from "@tabler/icons-react";
|
||||
|
||||
import { useSession } from "@homarr/auth/client";
|
||||
import { createDocumentationLink } from "@homarr/definitions";
|
||||
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 { homeMode } from "./home";
|
||||
import { pageMode } from "./page";
|
||||
import { userGroupMode } from "./user-group";
|
||||
|
||||
const searchModesForHelp = [userGroupMode, appIntegrationBoardMode, externalMode, commandMode, pageMode] as const;
|
||||
|
||||
const helpMode = {
|
||||
modeKey: "help",
|
||||
character: "?",
|
||||
useGroups() {
|
||||
const { data: session } = useSession();
|
||||
const visibleSearchModes: SearchMode[] = [appIntegrationBoardMode, externalMode, commandMode, pageMode];
|
||||
|
||||
if (session?.user.permissions.includes("admin")) {
|
||||
visibleSearchModes.unshift(userGroupMode);
|
||||
}
|
||||
|
||||
return [
|
||||
createGroup({
|
||||
keyPath: "character",
|
||||
title: (t) => t("search.mode.help.group.mode.title"),
|
||||
options: visibleSearchModes.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: createDocumentationLink("/docs/getting-started"),
|
||||
},
|
||||
{
|
||||
label: t("submitIssue.label"),
|
||||
icon: IconBrandGithub,
|
||||
href: "https://github.com/homarr-labs/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, newTab: true })),
|
||||
}),
|
||||
];
|
||||
},
|
||||
} satisfies SearchMode;
|
||||
|
||||
export const searchModes = [...searchModesForHelp, helpMode, homeMode] 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;
|
||||
170
packages/spotlight/src/modes/page/pages-search-group.tsx
Normal file
170
packages/spotlight/src/modes/page/pages-search-group.tsx
Normal file
@@ -0,0 +1,170 @@
|
||||
import { Group, Text } from "@mantine/core";
|
||||
import {
|
||||
IconBox,
|
||||
IconBrandDocker,
|
||||
IconHome,
|
||||
IconInfoSmall,
|
||||
IconLayoutDashboard,
|
||||
IconLogs,
|
||||
IconMailForward,
|
||||
IconPhoto,
|
||||
IconPlug,
|
||||
IconReport,
|
||||
IconSearch,
|
||||
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: IconSearch,
|
||||
path: "/manage/search-engines",
|
||||
name: t("manageSearchEngine.label"),
|
||||
hidden: !session,
|
||||
},
|
||||
{
|
||||
icon: IconPhoto,
|
||||
path: "/manage/medias",
|
||||
name: t("manageMedia.label"),
|
||||
hidden: !session,
|
||||
},
|
||||
{
|
||||
icon: IconUsers,
|
||||
path: "/manage/users",
|
||||
name: t("manageUser.label"),
|
||||
hidden: !session?.user.permissions.includes("admin"),
|
||||
},
|
||||
{
|
||||
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?.user.permissions.includes("admin"),
|
||||
},
|
||||
{
|
||||
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?.user.permissions.includes("admin"),
|
||||
},
|
||||
{
|
||||
icon: IconLogs,
|
||||
path: "/manage/tools/logs",
|
||||
name: t("manageLog.label"),
|
||||
hidden: !session?.user.permissions.includes("other-view-logs"),
|
||||
},
|
||||
{
|
||||
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; email: 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 });
|
||||
},
|
||||
});
|
||||
40
packages/spotlight/src/spotlight-store.ts
Normal file
40
packages/spotlight/src/spotlight-store.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
"use client";
|
||||
|
||||
import { clamp } from "@mantine/hooks";
|
||||
import type { SpotlightStore } from "@mantine/spotlight";
|
||||
import { createSpotlight } from "@mantine/spotlight";
|
||||
|
||||
export const [spotlightStore, spotlightActions] = createSpotlight();
|
||||
|
||||
export const setSelectedAction = (index: number, store: SpotlightStore) => {
|
||||
store.updateState((state) => ({ ...state, selected: index }));
|
||||
};
|
||||
|
||||
export const selectAction = (index: number, store: SpotlightStore): number => {
|
||||
const state = store.getState();
|
||||
const actionsList = document.getElementById(state.listId);
|
||||
const selected = actionsList?.querySelector<HTMLButtonElement>("[data-selected]");
|
||||
const actions = actionsList?.querySelectorAll<HTMLButtonElement>("[data-action]") ?? [];
|
||||
const nextIndex = index === -1 ? actions.length - 1 : index === actions.length ? 0 : index;
|
||||
|
||||
const selectedIndex = clamp(nextIndex, 0, actions.length - 1);
|
||||
selected?.removeAttribute("data-selected");
|
||||
actions[selectedIndex]?.scrollIntoView({ block: "nearest" });
|
||||
actions[selectedIndex]?.setAttribute("data-selected", "true");
|
||||
setSelectedAction(selectedIndex, store);
|
||||
|
||||
return selectedIndex;
|
||||
};
|
||||
|
||||
export const selectNextAction = (store: SpotlightStore) => {
|
||||
return selectAction(store.getState().selected + 1, store);
|
||||
};
|
||||
|
||||
export const selectPreviousAction = (store: SpotlightStore) => {
|
||||
return selectAction(store.getState().selected - 1, store);
|
||||
};
|
||||
export const triggerSelectedAction = (store: SpotlightStore) => {
|
||||
const state = store.getState();
|
||||
const selected = document.querySelector<HTMLButtonElement>(`#${state.listId} [data-selected]`);
|
||||
selected?.click();
|
||||
};
|
||||
1
packages/spotlight/src/styles.css
Normal file
1
packages/spotlight/src/styles.css
Normal file
@@ -0,0 +1 @@
|
||||
@import "@mantine/spotlight/styles.css";
|
||||
Reference in New Issue
Block a user