mirror of
https://github.com/ajnart/homarr.git
synced 2026-02-26 08:20:56 +01:00
Add search spotlight with registration hook (#82)
* wip: add spotlight * feat: add spotlight with registration hook and group chips * chore: address pull request feedback * docs: add documentation for usage of spotlight actions * fix: deepsource issue JS-0415 * feat: add support for dependencies of spotlight actions * fix: lockfile broken * feat: add hover effect for spotlight action * docs: Add documentation about dependency array * refactor: remove test spotlight actions, disallow all as group for actions * fix: type issues * chore: address pull request feedback
This commit is contained in:
@@ -19,6 +19,7 @@
|
||||
"@homarr/db": "workspace:^0.1.0",
|
||||
"@homarr/definitions": "workspace:^0.1.0",
|
||||
"@homarr/form": "workspace:^0.1.0",
|
||||
"@homarr/gridstack": "^1.0.0",
|
||||
"@homarr/notifications": "workspace:^0.1.0",
|
||||
"@homarr/spotlight": "workspace:^0.1.0",
|
||||
"@homarr/translation": "workspace:^0.1.0",
|
||||
@@ -40,15 +41,15 @@
|
||||
"@trpc/react-query": "next",
|
||||
"@trpc/server": "next",
|
||||
"dayjs": "^1.11.10",
|
||||
"@homarr/gridstack": "^1.0.0",
|
||||
"jotai": "^2.6.4",
|
||||
"mantine-modal-manager": "^7.5.2",
|
||||
"next": "^14.1.1-canary.58",
|
||||
"postcss-preset-mantine": "^1.13.0",
|
||||
"react": "18.2.0",
|
||||
"react-dom": "18.2.0",
|
||||
"sass": "^1.71.0",
|
||||
"superjson": "2.2.1"
|
||||
"superjson": "2.2.1",
|
||||
"use-deep-compare-effect": "^1.8.1",
|
||||
"sass": "^1.71.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@homarr/eslint-config": "workspace:^0.2.0",
|
||||
|
||||
8
apps/nextjs/src/app/[locale]/_client-providers/jotai.tsx
Normal file
8
apps/nextjs/src/app/[locale]/_client-providers/jotai.tsx
Normal file
@@ -0,0 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import type { PropsWithChildren } from "react";
|
||||
import { Provider } from "jotai";
|
||||
|
||||
export const JotaiProvider = ({ children }: PropsWithChildren) => {
|
||||
return <Provider>{children}</Provider>;
|
||||
};
|
||||
18
apps/nextjs/src/app/[locale]/compose.tsx
Normal file
18
apps/nextjs/src/app/[locale]/compose.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import React from "react";
|
||||
|
||||
type PropsWithChildren = Required<React.PropsWithChildren>;
|
||||
|
||||
export const composeWrappers = (
|
||||
wrappers: React.FunctionComponent<PropsWithChildren>[],
|
||||
): React.FunctionComponent<PropsWithChildren> => {
|
||||
return wrappers
|
||||
.reverse()
|
||||
.reduce((Acc, Current): React.FunctionComponent<PropsWithChildren> => {
|
||||
// eslint-disable-next-line react/display-name
|
||||
return (props) => (
|
||||
<Current>
|
||||
<Acc {...props} />
|
||||
</Current>
|
||||
);
|
||||
});
|
||||
};
|
||||
@@ -30,7 +30,6 @@ export const InitUserForm = () => {
|
||||
});
|
||||
|
||||
const handleSubmit = async (values: FormType) => {
|
||||
console.log(values);
|
||||
await mutateAsync(values, {
|
||||
onSuccess: () => {
|
||||
showSuccessNotification({
|
||||
|
||||
@@ -12,9 +12,11 @@ import {
|
||||
uiConfiguration,
|
||||
} from "@homarr/ui";
|
||||
|
||||
import { JotaiProvider } from "./_client-providers/jotai";
|
||||
import { ModalsProvider } from "./_client-providers/modals";
|
||||
import { NextInternationalProvider } from "./_client-providers/next-international";
|
||||
import { TRPCReactProvider } from "./_client-providers/trpc";
|
||||
import { composeWrappers } from "./compose";
|
||||
|
||||
const fontSans = Inter({
|
||||
subsets: ["latin"],
|
||||
@@ -51,25 +53,32 @@ export default function Layout(props: {
|
||||
}) {
|
||||
const colorScheme = "dark";
|
||||
|
||||
const StackedProvider = composeWrappers([
|
||||
(innerProps) => <JotaiProvider {...innerProps} />,
|
||||
(innerProps) => <TRPCReactProvider {...innerProps} />,
|
||||
(innerProps) => (
|
||||
<NextInternationalProvider {...innerProps} locale={props.params.locale} />
|
||||
),
|
||||
(innerProps) => (
|
||||
<MantineProvider
|
||||
{...innerProps}
|
||||
defaultColorScheme={colorScheme}
|
||||
{...uiConfiguration}
|
||||
/>
|
||||
),
|
||||
(innerProps) => <ModalsProvider {...innerProps} />,
|
||||
]);
|
||||
|
||||
return (
|
||||
<html lang="en" suppressHydrationWarning>
|
||||
<head>
|
||||
<ColorSchemeScript defaultColorScheme={colorScheme} />
|
||||
</head>
|
||||
<body className={["font-sans", fontSans.variable].join(" ")}>
|
||||
<TRPCReactProvider>
|
||||
<NextInternationalProvider locale={props.params.locale}>
|
||||
<MantineProvider
|
||||
defaultColorScheme={colorScheme}
|
||||
{...uiConfiguration}
|
||||
>
|
||||
<ModalsProvider>
|
||||
<Notifications />
|
||||
{props.children}
|
||||
</ModalsProvider>
|
||||
</MantineProvider>
|
||||
</NextInternationalProvider>
|
||||
</TRPCReactProvider>
|
||||
<StackedProvider>
|
||||
<Notifications />
|
||||
{props.children}
|
||||
</StackedProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
|
||||
@@ -34,26 +34,28 @@ interface Props {
|
||||
export const SectionContent = ({ items, refs }: Props) => {
|
||||
return (
|
||||
<>
|
||||
{items.map((item) => (
|
||||
<div
|
||||
key={item.id}
|
||||
className="grid-stack-item"
|
||||
data-id={item.id}
|
||||
gs-x={item.xOffset}
|
||||
gs-y={item.yOffset}
|
||||
gs-w={item.width}
|
||||
gs-h={item.height}
|
||||
gs-min-w={1}
|
||||
gs-min-h={1}
|
||||
gs-max-w={4}
|
||||
gs-max-h={4}
|
||||
ref={refs.items.current[item.id] as RefObject<HTMLDivElement>}
|
||||
>
|
||||
<Card className="grid-stack-item-content" withBorder>
|
||||
<BoardItem item={item} />
|
||||
</Card>
|
||||
</div>
|
||||
))}
|
||||
{items.map((item) => {
|
||||
return (
|
||||
<div
|
||||
key={item.id}
|
||||
className="grid-stack-item"
|
||||
data-id={item.id}
|
||||
gs-x={item.xOffset}
|
||||
gs-y={item.yOffset}
|
||||
gs-w={item.width}
|
||||
gs-h={item.height}
|
||||
gs-min-w={1}
|
||||
gs-min-h={1}
|
||||
gs-max-w={4}
|
||||
gs-max-h={4}
|
||||
ref={refs.items.current[item.id] as RefObject<HTMLDivElement>}
|
||||
>
|
||||
<Card className="grid-stack-item-content" withBorder>
|
||||
<BoardItem item={item} />
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -107,7 +107,7 @@ export const useGridstack = ({
|
||||
|
||||
// Add listener for moving items in config from one wrapper to another
|
||||
currentGrid?.on("added", (_, nodes) => {
|
||||
nodes.forEach((node) => onAdd(node));
|
||||
nodes.forEach(onAdd);
|
||||
});
|
||||
|
||||
return () => {
|
||||
@@ -192,8 +192,6 @@ const useCssVariableConfiguration = ({
|
||||
const widgetWidth = mainRef.current.clientWidth / sectionColumnCount;
|
||||
// widget width is used to define sizes of gridstack items within global.scss
|
||||
root?.style.setProperty("--gridstack-widget-width", widgetWidth.toString());
|
||||
console.log("widgetWidth", widgetWidth);
|
||||
console.log(gridRef.current);
|
||||
gridRef.current?.cellHeight(widgetWidth);
|
||||
// gridRef.current is required otherwise the cellheight is run on production as undefined
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import type { ReactNode } from "react";
|
||||
import Link from "next/link";
|
||||
|
||||
import { Spotlight } from "@homarr/spotlight";
|
||||
import { AppShellHeader, Group, UnstyledButton } from "@homarr/ui";
|
||||
|
||||
import { ClientBurger } from "./header/burger";
|
||||
import { DesktopSearchInput, MobileSearchButton } from "./header/search";
|
||||
import { ClientSpotlight } from "./header/spotlight";
|
||||
import { UserButton } from "./header/user";
|
||||
import { HomarrLogoWithTitle } from "./logo/homarr-logo";
|
||||
|
||||
@@ -38,7 +38,7 @@ export const MainHeader = ({ logo, actions, hasNavigation = true }: Props) => {
|
||||
<UserButton />
|
||||
</Group>
|
||||
</Group>
|
||||
<ClientSpotlight />
|
||||
<Spotlight />
|
||||
</AppShellHeader>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { spotlight } from "@homarr/spotlight";
|
||||
import { openSpotlight } from "@homarr/spotlight";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
import { IconSearch, TextInput, UnstyledButton } from "@homarr/ui";
|
||||
|
||||
@@ -17,7 +17,7 @@ export const DesktopSearchInput = () => {
|
||||
w={400}
|
||||
size="sm"
|
||||
leftSection={<IconSearch size={20} stroke={1.5} />}
|
||||
onClick={spotlight.open}
|
||||
onClick={openSpotlight}
|
||||
>
|
||||
{t("placeholder")}
|
||||
</TextInput>
|
||||
@@ -26,7 +26,7 @@ export const DesktopSearchInput = () => {
|
||||
|
||||
export const MobileSearchButton = () => {
|
||||
return (
|
||||
<HeaderButton onClick={spotlight.open} className={classes.mobileSearch}>
|
||||
<HeaderButton onClick={openSpotlight} className={classes.mobileSearch}>
|
||||
<IconSearch size={20} stroke={1.5} />
|
||||
</HeaderButton>
|
||||
);
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { Spotlight } from "@homarr/spotlight";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
import { IconSearch } from "@homarr/ui";
|
||||
|
||||
export const ClientSpotlight = () => {
|
||||
const t = useScopedI18n("common.search");
|
||||
|
||||
return (
|
||||
<Spotlight
|
||||
actions={[]}
|
||||
nothingFound={t("nothingFound")}
|
||||
highlightQuery
|
||||
searchProps={{
|
||||
leftSection: <IconSearch size={20} stroke={1.5} />,
|
||||
placeholder: `${t("placeholder")}`,
|
||||
}}
|
||||
yOffset={12}
|
||||
/>
|
||||
);
|
||||
};
|
||||
146
packages/spotlight/ReadMe.md
Normal file
146
packages/spotlight/ReadMe.md
Normal file
@@ -0,0 +1,146 @@
|
||||
# Spotlight
|
||||
|
||||
Spotlight is the search functionality of Homarr. It can be opened by pressing `Ctrl + K` or `Cmd + K` on Mac. It is a quick way to search for anything in Homarr.
|
||||
|
||||
## API
|
||||
|
||||
### SpotlightActionData
|
||||
|
||||
The [SpotlightActionData](./src/type.ts) is the data structure that is used to define the actions that are shown in the spotlight.
|
||||
|
||||
#### Common properties
|
||||
|
||||
| Name | Type | Description |
|
||||
| ------------------------------ | ------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------- |
|
||||
| id | `string` | The id of the action. |
|
||||
| title | `string \| (t: TranslationFunction) => string` | The title of the action. Either static or generated with translation function |
|
||||
| description | `string \| (t: TranslationFunction) => string` | The description of the action. Either static or generated with translation function |
|
||||
| icon | `string \| (props: TablerIconProps) => JSX.Element` | The icon of the action. Either a url to an image or a TablerIcon |
|
||||
| group | `string` | The group of the action. By default the groups all, web and action exist. |
|
||||
| ignoreSearchAndOnlyShowInGroup | `boolean` | If true, the action will only be shown in the group and not in the search results. |
|
||||
| type | `'link' \| 'button'` | The type of the action. Either link or button |
|
||||
|
||||
#### Properties for links
|
||||
|
||||
| Name | Type | Description |
|
||||
| ---- | -------- | ---------------------------------------------------------------------------------------------------------- |
|
||||
| href | `string` | The url the link should navigate to. If %s is contained it will be replaced with the current search query. |
|
||||
|
||||
#### Properties for buttons
|
||||
|
||||
| Name | Type | Description |
|
||||
| ------- | -------------------------- | ----------------------------------------------------------------------------------------- |
|
||||
| onClick | `() => MaybePromise<void>` | The function that should be called when the button is clicked. It can be async if needed. |
|
||||
|
||||
### useRegisterSpotlightActions
|
||||
|
||||
The [useRegisterSpotlightActions](./src/data-store.ts) hook is used to register actions to the spotlight. It takes an unique key and the array of [SpotlightActionData](#SpotlightActionData).
|
||||
|
||||
#### Usage
|
||||
|
||||
The following example shows how to use the `useRegisterSpotlightActions` hook to register an action to the spotlight.
|
||||
|
||||
```tsx
|
||||
"use client";
|
||||
|
||||
import { useRegisterSpotlightActions } from "@homarr/spotlight";
|
||||
|
||||
const MyComponent = () => {
|
||||
useRegisterSpotlightActions("my-component", [
|
||||
{
|
||||
id: "my-action",
|
||||
title: "My Action",
|
||||
description: "This is my action",
|
||||
icon: "https://example.com/icon.png",
|
||||
group: "web",
|
||||
type: "link",
|
||||
href: "https://example.com",
|
||||
},
|
||||
]);
|
||||
|
||||
return <div>My Component</div>;
|
||||
};
|
||||
```
|
||||
|
||||
##### Using translation function
|
||||
|
||||
```tsx
|
||||
"use client";
|
||||
|
||||
import { useRegisterSpotlightActions } from "@homarr/spotlight";
|
||||
|
||||
const MyComponent = () => {
|
||||
useRegisterSpotlightActions("my-component", [
|
||||
{
|
||||
id: "my-action",
|
||||
title: (t) => t("some.path.to.translation.key"),
|
||||
description: (t) => t("some.other.path.to.translation.key"),
|
||||
icon: "https://example.com/icon.png",
|
||||
group: "web",
|
||||
type: "link",
|
||||
href: "https://example.com",
|
||||
},
|
||||
]);
|
||||
|
||||
return <div>Component implementation</div>;
|
||||
};
|
||||
```
|
||||
|
||||
##### Using TablerIcon
|
||||
|
||||
```tsx
|
||||
"use client";
|
||||
|
||||
import { IconUserCog } from "tabler-react";
|
||||
|
||||
import { useRegisterSpotlightActions } from "@homarr/spotlight";
|
||||
|
||||
const UserMenu = () => {
|
||||
useRegisterSpotlightActions("header-user-menu", [
|
||||
{
|
||||
id: "user-preferences",
|
||||
title: (t) => t("user.preferences.title"),
|
||||
description: (t) => t("user.preferences.description"),
|
||||
icon: IconUserCog,
|
||||
group: "action",
|
||||
type: "link",
|
||||
href: "/user/preferences",
|
||||
},
|
||||
]);
|
||||
|
||||
return <div>Component implementation</div>;
|
||||
};
|
||||
```
|
||||
|
||||
##### Using dependency array
|
||||
|
||||
```tsx
|
||||
"use client";
|
||||
|
||||
import { IconUserCog } from "tabler-react";
|
||||
|
||||
import { useRegisterSpotlightActions } from "@homarr/spotlight";
|
||||
|
||||
const ColorSchemeButton = () => {
|
||||
const { colorScheme, toggleColorScheme } = useColorScheme();
|
||||
|
||||
useRegisterSpotlightActions(
|
||||
"toggle-color-scheme",
|
||||
[
|
||||
{
|
||||
id: "toggle-color-scheme",
|
||||
title: (t) => t("common.colorScheme.toggle.title"),
|
||||
description: (t) =>
|
||||
t(`common.colorScheme.toggle.${colorScheme}.description`),
|
||||
icon: colorScheme === "light" ? IconSun : IconMoon,
|
||||
group: "action",
|
||||
type: "button",
|
||||
onClick: toggleColorScheme,
|
||||
},
|
||||
],
|
||||
[colorScheme],
|
||||
);
|
||||
|
||||
return <div>Component implementation</div>;
|
||||
};
|
||||
```
|
||||
@@ -1,2 +1 @@
|
||||
export * from "./src";
|
||||
export { spotlight, Spotlight } from "@mantine/spotlight";
|
||||
|
||||
@@ -34,6 +34,8 @@
|
||||
},
|
||||
"prettier": "@homarr/prettier-config",
|
||||
"dependencies": {
|
||||
"@mantine/spotlight": "^7.5.3"
|
||||
"@mantine/spotlight": "^7.5.3",
|
||||
"@homarr/ui": "workspace:^0.1.0",
|
||||
"@homarr/translation": "workspace:^0.1.0"
|
||||
}
|
||||
}
|
||||
|
||||
54
packages/spotlight/src/chip-group.tsx
Normal file
54
packages/spotlight/src/chip-group.tsx
Normal file
@@ -0,0 +1,54 @@
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
import { Chip } from "@homarr/ui";
|
||||
|
||||
import {
|
||||
selectNextAction,
|
||||
selectPreviousAction,
|
||||
spotlightStore,
|
||||
triggerSelectedAction,
|
||||
} from "./spotlight-store";
|
||||
import type { SpotlightActionGroup } from "./type";
|
||||
|
||||
const disableArrowUpAndDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === "ArrowDown") {
|
||||
selectNextAction(spotlightStore);
|
||||
e.preventDefault();
|
||||
} else if (e.key === "ArrowUp") {
|
||||
selectPreviousAction(spotlightStore);
|
||||
e.preventDefault();
|
||||
} else if (e.key === "Enter") {
|
||||
triggerSelectedAction(spotlightStore);
|
||||
}
|
||||
};
|
||||
|
||||
const focusActiveByDefault = (e: React.FocusEvent<HTMLInputElement>) => {
|
||||
const relatedTarget = e.relatedTarget;
|
||||
|
||||
const isPreviousTargetRadio =
|
||||
relatedTarget && "type" in relatedTarget && relatedTarget.type === "radio";
|
||||
if (isPreviousTargetRadio) return;
|
||||
|
||||
const group = e.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>
|
||||
);
|
||||
};
|
||||
7
packages/spotlight/src/component.module.css
Normal file
7
packages/spotlight/src/component.module.css
Normal file
@@ -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);
|
||||
}
|
||||
154
packages/spotlight/src/component.tsx
Normal file
154
packages/spotlight/src/component.tsx
Normal file
@@ -0,0 +1,154 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import {
|
||||
Spotlight as MantineSpotlight,
|
||||
SpotlightAction,
|
||||
} from "@mantine/spotlight";
|
||||
import { useAtomValue } from "jotai";
|
||||
|
||||
import type { TranslationFunction } from "@homarr/translation";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import {
|
||||
Center,
|
||||
Chip,
|
||||
Divider,
|
||||
Flex,
|
||||
Group,
|
||||
IconSearch,
|
||||
Text,
|
||||
} from "@homarr/ui";
|
||||
|
||||
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";
|
||||
|
||||
export const Spotlight = () => {
|
||||
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)} {...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.search.placeholder")}
|
||||
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),
|
||||
});
|
||||
72
packages/spotlight/src/data-store.ts
Normal file
72
packages/spotlight/src/data-store.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
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();
|
||||
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]);
|
||||
};
|
||||
@@ -1 +1,8 @@
|
||||
export const name = "spotlight";
|
||||
"use client";
|
||||
|
||||
import { spotlightActions } from "./spotlight-store";
|
||||
|
||||
export { Spotlight } from "./component";
|
||||
|
||||
const openSpotlight = spotlightActions.open;
|
||||
export { openSpotlight };
|
||||
|
||||
45
packages/spotlight/src/spotlight-store.ts
Normal file
45
packages/spotlight/src/spotlight-store.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
"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();
|
||||
};
|
||||
31
packages/spotlight/src/type.ts
Normal file
31
packages/spotlight/src/type.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import type {
|
||||
TranslationFunction,
|
||||
TranslationObject,
|
||||
} from "@homarr/translation";
|
||||
import type { TablerIconsProps } 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: ((props: TablerIconsProps) => JSX.Element) | string;
|
||||
ignoreSearchAndOnlyShowInGroup?: boolean;
|
||||
}
|
||||
|
||||
interface SpotlightActionLink extends BaseSpotlightAction {
|
||||
type: "link";
|
||||
href: string;
|
||||
}
|
||||
|
||||
type MaybePromise<T> = T | Promise<T>;
|
||||
|
||||
interface SpotlightActionButton extends BaseSpotlightAction {
|
||||
type: "button";
|
||||
onClick: () => MaybePromise<void>;
|
||||
}
|
||||
|
||||
export type SpotlightActionData = SpotlightActionLink | SpotlightActionButton;
|
||||
@@ -1,3 +1,5 @@
|
||||
export * from "./type";
|
||||
|
||||
export const supportedLanguages = ["en", "de"] as const;
|
||||
export type SupportedLanguage = (typeof supportedLanguages)[number];
|
||||
|
||||
|
||||
@@ -154,6 +154,11 @@ export default {
|
||||
search: {
|
||||
placeholder: "Search for anything...",
|
||||
nothingFound: "Nothing found",
|
||||
group: {
|
||||
all: "All",
|
||||
web: "Web",
|
||||
action: "Actions",
|
||||
},
|
||||
},
|
||||
userAvatar: {
|
||||
menu: {
|
||||
|
||||
5
packages/translation/src/type.ts
Normal file
5
packages/translation/src/type.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import type { useI18n } from "./client";
|
||||
import type enTranslation from "./lang/en";
|
||||
|
||||
export type TranslationFunction = ReturnType<typeof useI18n>;
|
||||
export type TranslationObject = typeof enTranslation;
|
||||
@@ -55,7 +55,6 @@ export const WidgetEditModal: ManagedModal<ModalProps<WidgetKind>> = ({
|
||||
)}
|
||||
{Object.entries(definition.options).map(
|
||||
([key, value]: [string, OptionsBuilderResult[string]]) => {
|
||||
console.log(value);
|
||||
const Input = getInputForType(value.type);
|
||||
|
||||
if (!Input || value.shouldHide?.(form.values.options as never)) {
|
||||
|
||||
7393
pnpm-lock.yaml
generated
7393
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user