chore(release): automatic release v0.1.0

This commit is contained in:
homarr-releases[bot]
2024-09-20 19:13:13 +00:00
committed by GitHub
148 changed files with 4644 additions and 2984 deletions

View File

@@ -38,12 +38,12 @@ jobs:
node-version: [20]
steps:
- name: Discord notification
if: ${{ github.events.inputs.send-notifications }}
if: ${{ github.events.inputs.send-notifications || true }}
env:
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
uses: Ilshidur/action-discord@master
with:
args: "Deployment of an image has been triggered: [run ${{ github.run_number }}](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }})"
args: "Deployment of an image has been triggered: [run ${{ github.run_number }}](<${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}>)"
- uses: actions/checkout@v4
- name: Get Next Version
id: semver
@@ -52,7 +52,7 @@ jobs:
token: ${{ github.token }}
branch: dev
- name: Discord notification
if: ${{ github.events.inputs.send-notifications }}
if: ${{ github.events.inputs.send-notifications || true }}
env:
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
uses: Ilshidur/action-discord@master
@@ -65,9 +65,8 @@ jobs:
uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
cache: "pnpm"
- name: Discord notification
if: ${{ github.events.inputs.send-notifications }}
if: ${{ github.events.inputs.send-notifications || true }}
env:
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
uses: Ilshidur/action-discord@master
@@ -97,19 +96,40 @@ jobs:
- name: Build and push
id: buildPushAction
uses: docker/build-push-action@v6
if: ${{ github.events.inputs.push-image == 'true' || github.events.inputs.push-image == null }}
with:
platforms: linux/amd64,linux/arm64
context: .
push: ${{ github.events.inputs.push-image && 'true' || 'false' }}
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
network: host
env:
SKIP_ENV_VALIDATION: true
- name: Build
id: buildPushDryAction
uses: docker/build-push-action@v6
if: ${{ github.events.inputs.push-image == 'false' }}
with:
platforms: linux/amd64,linux/arm64
context: .
push: false
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
network: host
env:
SKIP_ENV_VALIDATION: true
- name: Discord notification
if: ${{ github.events.inputs.send-notifications }}
if: ${{ github.events.inputs.send-notifications || true && (github.events.inputs.push-image == 'true' || github.events.inputs.push-image == null) }}
env:
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
uses: Ilshidur/action-discord@master
with:
args: "Deployment of image has completed. Image ID is '${{ steps.buildPushAction.outputs.imageid }}'. This was a dry run."
args: "Deployment of image has completed. Image ID is '${{ steps.buildPushAction.outputs.imageid }}'."
- name: Discord notification
if: ${{ github.events.inputs.send-notifications || true && !(github.events.inputs.push-image == 'true' || github.events.inputs.push-image == null) }}
env:
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
uses: Ilshidur/action-discord@master
with:
args: "Deployment of image has completed. Image ID is '${{ steps.buildPushDryAction.outputs.imageid }}'. This was a dry run."

View File

@@ -61,7 +61,8 @@ RUN corepack enable pnpm && pnpm build
FROM base AS runner
WORKDIR /app
RUN apk add --no-cache redis bash
# gettext is required for envsubst
RUN apk add --no-cache redis nginx bash gettext
RUN mkdir /appdata
RUN mkdir /appdata/db
RUN mkdir /appdata/redis
@@ -79,6 +80,11 @@ RUN chmod +x /usr/bin/homarr
# Don't run production as root
RUN chown -R nextjs:nodejs /appdata
RUN mkdir -p /var/cache/nginx && chown -R nextjs:nodejs /var/cache/nginx && \
mkdir -p /var/log/nginx && chown -R nextjs:nodejs /var/log/nginx && \
mkdir -p /var/lib/nginx && chown -R nextjs:nodejs /var/lib/nginx && \
touch /run/nginx/nginx.pid && chown -R nextjs:nodejs /run/nginx/nginx.pid && \
mkdir -p /etc/nginx/templates /etc/nginx/ssl/certs && chown -R nextjs:nodejs /etc/nginx
USER nextjs
COPY --from=installer /app/apps/nextjs/next.config.mjs .
@@ -97,6 +103,8 @@ COPY --from=installer --chown=nextjs:nodejs /app/apps/nextjs/.next/static ./apps
COPY --from=installer --chown=nextjs:nodejs /app/apps/nextjs/public ./apps/nextjs/public
COPY --chown=nextjs:nodejs scripts/run.sh ./run.sh
COPY --chown=nextjs:nodejs packages/redis/redis.conf /app/redis.conf
COPY --chown=nextjs:nodejs nginx.conf /etc/nginx/templates/nginx.conf
ENV DB_URL='/appdata/db/db.sqlite'
ENV DB_DIALECT='sqlite'

View File

@@ -27,6 +27,7 @@
"@homarr/integrations": "workspace:^0.1.0",
"@homarr/log": "workspace:^",
"@homarr/modals": "workspace:^0.1.0",
"@homarr/modals-collection": "workspace:^0.1.0",
"@homarr/notifications": "workspace:^0.1.0",
"@homarr/old-schema": "workspace:^0.1.0",
"@homarr/server-settings": "workspace:^0.1.0",
@@ -42,7 +43,7 @@
"@mantine/tiptap": "^7.12.2",
"@million/lint": "1.0.0-rc.84",
"@t3-oss/env-nextjs": "^0.11.1",
"@tabler/icons-react": "^3.16.0",
"@tabler/icons-react": "^3.17.0",
"@tanstack/react-query": "^5.56.2",
"@tanstack/react-query-devtools": "^5.56.2",
"@tanstack/react-query-next-experimental": "5.56.2",
@@ -59,16 +60,16 @@
"dotenv": "^16.4.5",
"flag-icons": "^7.2.3",
"glob": "^11.0.0",
"jotai": "^2.9.3",
"jotai": "^2.10.0",
"mantine-react-table": "2.0.0-beta.6",
"next": "^14.2.11",
"next": "^14.2.13",
"postcss-preset-mantine": "^1.17.0",
"prismjs": "^1.29.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-error-boundary": "^4.0.13",
"react-simple-code-editor": "^0.14.1",
"sass": "^1.78.0",
"sass": "^1.79.2",
"superjson": "2.2.1",
"swagger-ui-react": "^5.17.14",
"use-deep-compare-effect": "^1.8.1"
@@ -80,7 +81,7 @@
"@types/chroma-js": "2.4.4",
"@types/node": "^20.16.5",
"@types/prismjs": "^1.26.4",
"@types/react": "^18.3.5",
"@types/react": "^18.3.8",
"@types/react-dom": "^18.3.0",
"@types/swagger-ui-react": "^4.18.3",
"concurrently": "^9.0.1",

View File

@@ -20,7 +20,10 @@ import type { AppRouter } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
const wsClient = createWSClient({
url: typeof window === "undefined" ? "ws://localhost:3001" : `ws://${window.location.hostname}:3001`,
url:
typeof window === "undefined"
? "ws://localhost:3001/websockets"
: `ws://${window.location.hostname}:${window.location.port}/websockets`,
});
export function TRPCReactProvider(props: PropsWithChildren) {

View File

@@ -7,6 +7,7 @@ import { Anchor, Button, Card, Code, Collapse, Divider, PasswordInput, Stack, Te
import { useDisclosure } from "@mantine/hooks";
import { signIn } from "@homarr/auth/client";
import { revalidatePathActionAsync } from "@homarr/common/client";
import type { useForm } from "@homarr/form";
import { useZodForm } from "@homarr/form";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
@@ -14,8 +15,6 @@ import { useScopedI18n } from "@homarr/translation/client";
import type { z } from "@homarr/validation";
import { validation } from "@homarr/validation";
import { revalidatePathActionAsync } from "~/app/revalidatePathAction";
interface LoginFormProps {
providers: string[];
oidcClientName: string;

View File

@@ -15,11 +15,11 @@ import {
} from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
import { revalidatePathActionAsync } from "@homarr/common/client";
import { useModalAction } from "@homarr/modals";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useI18n, useScopedI18n } from "@homarr/translation/client";
import { revalidatePathActionAsync } from "~/app/revalidatePathAction";
import { ItemSelectModal } from "~/components/board/items/item-select-modal";
import { useBoardPermissions } from "~/components/board/permissions/client";
import { useCategoryActions } from "~/components/board/sections/category/category-actions";

View File

@@ -6,12 +6,11 @@ import { IconTrash } from "@tabler/icons-react";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import { revalidatePathActionAsync } from "@homarr/common/client";
import { useConfirmModal } from "@homarr/modals";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useScopedI18n } from "@homarr/translation/client";
import { revalidatePathActionAsync } from "../../../revalidatePathAction";
interface AppDeleteButtonProps {
app: RouterOutputs["app"]["all"][number];
}

View File

@@ -5,12 +5,12 @@ import { useRouter } from "next/navigation";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import { revalidatePathActionAsync } from "@homarr/common/client";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import type { TranslationFunction } from "@homarr/translation";
import { useScopedI18n } from "@homarr/translation/client";
import type { validation, z } from "@homarr/validation";
import { revalidatePathActionAsync } from "~/app/revalidatePathAction";
import { AppForm } from "../../_form";
interface AppEditFormProps {

View File

@@ -4,12 +4,12 @@ import { useCallback } from "react";
import { useRouter } from "next/navigation";
import { clientApi } from "@homarr/api/client";
import { revalidatePathActionAsync } from "@homarr/common/client";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import type { TranslationFunction } from "@homarr/translation";
import { useScopedI18n } from "@homarr/translation/client";
import type { validation, z } from "@homarr/validation";
import { revalidatePathActionAsync } from "~/app/revalidatePathAction";
import { AppForm } from "../_form";
export const AppNewForm = () => {

View File

@@ -7,10 +7,10 @@ import { IconHome, IconSettings, IconTrash } from "@tabler/icons-react";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import { revalidatePathActionAsync } from "@homarr/common/client";
import { useConfirmModal } from "@homarr/modals";
import { useScopedI18n } from "@homarr/translation/client";
import { revalidatePathActionAsync } from "~/app/revalidatePathAction";
import { useBoardPermissions } from "~/components/board/permissions/client";
const iconProps = {

View File

@@ -1,53 +1,21 @@
"use client";
import { useCallback } from "react";
import { Affix, Button, Group, Menu } from "@mantine/core";
import { IconCategoryPlus, IconChevronDown, IconFileImport } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
import { useModalAction } from "@homarr/modals";
import { AddBoardModal, ImportBoardModal } from "@homarr/modals-collection";
import { useI18n } from "@homarr/translation/client";
import { BetaBadge } from "@homarr/ui";
import { revalidatePathActionAsync } from "~/app/revalidatePathAction";
import { AddBoardModal } from "~/components/manage/boards/add-board-modal";
import { ImportBoardModal } from "~/components/manage/boards/import-board-modal";
interface CreateBoardButtonProps {
boardNames: string[];
}
export const CreateBoardButton = ({ boardNames }: CreateBoardButtonProps) => {
export const CreateBoardButton = () => {
const t = useI18n();
const { openModal: openAddModal } = useModalAction(AddBoardModal);
const { openModal: openImportModal } = useModalAction(ImportBoardModal);
const { mutateAsync, isPending } = clientApi.board.createBoard.useMutation({
onSettled: async () => {
await revalidatePathActionAsync("/manage/boards");
},
});
const onCreateClick = useCallback(() => {
openAddModal({
onSuccess: async (values) => {
await mutateAsync({
name: values.name,
columnCount: values.columnCount,
isPublic: values.isPublic,
});
},
boardNames,
});
}, [mutateAsync, boardNames, openAddModal]);
const onImportClick = useCallback(() => {
openImportModal({ boardNames });
}, [openImportModal, boardNames]);
const buttonGroupContent = (
<>
<Button leftSection={<IconCategoryPlus size="1rem" />} onClick={onCreateClick} loading={isPending}>
<Button leftSection={<IconCategoryPlus size="1rem" />} onClick={openAddModal}>
{t("management.page.board.action.new.label")}
</Button>
<Menu position="bottom-end">
@@ -57,7 +25,7 @@ export const CreateBoardButton = ({ boardNames }: CreateBoardButtonProps) => {
</Button>
</Menu.Target>
<Menu.Dropdown>
<Menu.Item onClick={onImportClick} leftSection={<IconFileImport size="1rem" />}>
<Menu.Item onClick={openImportModal} leftSection={<IconFileImport size="1rem" />}>
<Group>
{t("board.action.oldImport.label")}
<BetaBadge size="xs" />

View File

@@ -39,7 +39,7 @@ export default async function ManageBoardsPage() {
<Stack>
<Group justify="space-between">
<Title mb="md">{t("title")}</Title>
<CreateBoardButton boardNames={boards.map((board) => board.name)} />
<CreateBoardButton />
</Group>
<Grid mb={{ base: "xl", md: 0 }}>

View File

@@ -5,12 +5,11 @@ import { ActionIcon } from "@mantine/core";
import { IconTrash } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
import { revalidatePathActionAsync } from "@homarr/common/client";
import { useConfirmModal } from "@homarr/modals";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useScopedI18n } from "@homarr/translation/client";
import { revalidatePathActionAsync } from "../../../revalidatePathAction";
interface DeleteIntegrationActionButtonProps {
count: number;
integration: { id: string; name: string };

View File

@@ -6,6 +6,7 @@ import { Button, Fieldset, Group, Stack, TextInput } from "@mantine/core";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import { revalidatePathActionAsync } from "@homarr/common/client";
import { getAllSecretKindOptions, getDefaultSecretKinds } from "@homarr/definitions";
import { useZodForm } from "@homarr/form";
import { convertIntegrationTestConnectionError } from "@homarr/integrations/client";
@@ -15,7 +16,6 @@ import { useI18n } from "@homarr/translation/client";
import type { z } from "@homarr/validation";
import { validation } from "@homarr/validation";
import { revalidatePathActionAsync } from "~/app/revalidatePathAction";
import { SecretCard } from "../../_components/secrets/integration-secret-card";
import { IntegrationSecretInput } from "../../_components/secrets/integration-secret-inputs";

View File

@@ -3,10 +3,10 @@ import { Container, Fieldset, Group, Stack, Title } from "@mantine/core";
import { api } from "@homarr/api/server";
import { getIntegrationName } from "@homarr/definitions";
import { getI18n, getScopedI18n } from "@homarr/translation/server";
import { IntegrationAvatar } from "@homarr/ui";
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
import { IntegrationAccessSettings } from "../../_components/integration-access-settings";
import { IntegrationAvatar } from "../../_integration-avatar";
import { EditIntegrationForm } from "./_integration-edit-form";
interface EditIntegrationPageProps {

View File

@@ -8,8 +8,7 @@ import { IconSearch } from "@tabler/icons-react";
import { getIntegrationName, integrationKinds } from "@homarr/definitions";
import { useI18n } from "@homarr/translation/client";
import { IntegrationAvatar } from "../_integration-avatar";
import { IntegrationAvatar } from "@homarr/ui";
export const IntegrationCreateDropdownContent = () => {
const t = useI18n();

View File

@@ -7,6 +7,7 @@ import { Alert, Button, Fieldset, Group, SegmentedControl, Stack, Text, TextInpu
import { IconInfoCircle } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
import { revalidatePathActionAsync } from "@homarr/common/client";
import type { IntegrationKind, IntegrationSecretKind } from "@homarr/definitions";
import { getAllSecretKindOptions } from "@homarr/definitions";
import type { UseFormReturnType } from "@homarr/form";
@@ -18,7 +19,6 @@ import type { z } from "@homarr/validation";
import { validation } from "@homarr/validation";
import { IntegrationSecretInput } from "../_components/secrets/integration-secret-inputs";
import { revalidatePathActionAsync } from "../../../../revalidatePathAction";
interface NewIntegrationFormProps {
searchParams: Partial<z.infer<typeof validation.integration.create>> & {

View File

@@ -4,11 +4,11 @@ import { Container, Group, Stack, Title } from "@mantine/core";
import type { IntegrationKind } from "@homarr/definitions";
import { getIntegrationName, integrationKinds } from "@homarr/definitions";
import { getScopedI18n } from "@homarr/translation/server";
import { IntegrationAvatar } from "@homarr/ui";
import type { validation } from "@homarr/validation";
import { z } from "@homarr/validation";
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
import { IntegrationAvatar } from "../_integration-avatar";
import { NewIntegrationForm } from "./_integration-new-form";
interface NewIntegrationPageProps {

View File

@@ -34,12 +34,11 @@ import { objectEntries } from "@homarr/common";
import type { IntegrationKind } from "@homarr/definitions";
import { getIntegrationName } from "@homarr/definitions";
import { getScopedI18n } from "@homarr/translation/server";
import { CountBadge } from "@homarr/ui";
import { CountBadge, IntegrationAvatar } from "@homarr/ui";
import { ManageContainer } from "~/components/manage/manage-container";
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
import { ActiveTabAccordion } from "../../../../components/active-tab-accordion";
import { IntegrationAvatar } from "./_integration-avatar";
import { DeleteIntegrationActionButton } from "./_integration-buttons";
import { IntegrationCreateDropdownContent } from "./new/_integration-new-dropdown";

View File

@@ -113,7 +113,7 @@ export default async function ManageLayout({ children }: PropsWithChildren) {
{
label: t("items.help.items.documentation"),
icon: IconBook2,
href: "https://homarr.dev/docs/getting-started/prerequisites",
href: "https://homarr.dev/docs/getting-started/",
external: true,
},
{
@@ -123,7 +123,7 @@ export default async function ManageLayout({ children }: PropsWithChildren) {
external: true,
},
{
label: t("items.tools.items.docker"),
label: t("items.help.items.discord"),
icon: IconBrandDiscord,
href: "https://discord.com/invite/aCsmEV5RgA",
external: true,

View File

@@ -4,12 +4,12 @@ import React from "react";
import { Card, LoadingOverlay, Stack, Title } from "@mantine/core";
import { clientApi } from "@homarr/api/client";
import { revalidatePathActionAsync } from "@homarr/common/client";
import { useForm } from "@homarr/form";
import type { defaultServerSettings } from "@homarr/server-settings";
import { useScopedI18n } from "@homarr/translation/client";
import { SwitchSetting } from "~/app/[locale]/manage/settings/_components/setting-switch";
import { revalidatePathActionAsync } from "~/app/revalidatePathAction";
interface AnalyticsSettingsProps {
initialData: typeof defaultServerSettings.analytics;

View File

@@ -4,12 +4,12 @@ import React from "react";
import { Card, LoadingOverlay, Stack, Text, Title } from "@mantine/core";
import { clientApi } from "@homarr/api/client";
import { revalidatePathActionAsync } from "@homarr/common/client";
import { useForm } from "@homarr/form";
import type { defaultServerSettings } from "@homarr/server-settings";
import { useScopedI18n } from "@homarr/translation/client";
import { SwitchSetting } from "~/app/[locale]/manage/settings/_components/setting-switch";
import { revalidatePathActionAsync } from "~/app/revalidatePathAction";
interface CrawlingAndIndexingSettingsProps {
initialData: typeof defaultServerSettings.crawlingAndIndexing;

View File

@@ -4,14 +4,13 @@ import { Button, Group, Select, Stack } from "@mantine/core";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import { revalidatePathActionAsync } from "@homarr/common/client";
import { useZodForm } from "@homarr/form";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useI18n } from "@homarr/translation/client";
import type { z } from "@homarr/validation";
import { validation } from "@homarr/validation";
import { revalidatePathActionAsync } from "~/app/revalidatePathAction";
interface ChangeHomeBoardFormProps {
user: RouterOutputs["user"]["getById"];
boardsData: { value: string; label: string }[];

View File

@@ -6,11 +6,10 @@ import { Button } from "@mantine/core";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import { revalidatePathActionAsync } from "@homarr/common/client";
import { useConfirmModal } from "@homarr/modals";
import { useI18n } from "@homarr/translation/client";
import { revalidatePathActionAsync } from "~/app/revalidatePathAction";
interface DeleteUserButtonProps {
user: RouterOutputs["user"]["getById"];
}

View File

@@ -7,13 +7,12 @@ import { IconPencil, IconPhotoEdit, IconPhotoX } from "@tabler/icons-react";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import { revalidatePathActionAsync } from "@homarr/common/client";
import { useConfirmModal } from "@homarr/modals";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useI18n, useScopedI18n } from "@homarr/translation/client";
import { UserAvatar } from "@homarr/ui";
import { revalidatePathActionAsync } from "~/app/revalidatePathAction";
interface UserProfileAvatarForm {
user: RouterOutputs["user"]["getById"];
}

View File

@@ -5,13 +5,12 @@ import { Button, Group, Stack, TextInput } from "@mantine/core";
import type { RouterInputs, RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import { revalidatePathActionAsync } from "@homarr/common/client";
import { useZodForm } from "@homarr/form";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useI18n } from "@homarr/translation/client";
import { validation } from "@homarr/validation";
import { revalidatePathActionAsync } from "~/app/revalidatePathAction";
interface UserProfileFormProps {
user: RouterOutputs["user"]["getById"];
}

View File

@@ -5,14 +5,13 @@ import { Button, Fieldset, Group, PasswordInput, Stack } from "@mantine/core";
import type { RouterInputs, RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import { useSession } from "@homarr/auth/client";
import { revalidatePathActionAsync } from "@homarr/common/client";
import { useZodForm } from "@homarr/form";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useI18n } from "@homarr/translation/client";
import { CustomPasswordInput } from "@homarr/ui";
import { validation } from "@homarr/validation";
import { revalidatePathActionAsync } from "~/app/revalidatePathAction";
interface ChangePasswordFormProps {
user: RouterOutputs["user"]["getById"];
}

View File

@@ -5,12 +5,11 @@ import { useRouter } from "next/navigation";
import { Button } from "@mantine/core";
import { clientApi } from "@homarr/api/client";
import { revalidatePathActionAsync } from "@homarr/common/client";
import { useConfirmModal } from "@homarr/modals";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useI18n, useScopedI18n } from "@homarr/translation/client";
import { revalidatePathActionAsync } from "~/app/revalidatePathAction";
interface DeleteGroupProps {
group: {
id: string;

View File

@@ -4,13 +4,12 @@ import { useCallback } from "react";
import { Button, Group, Stack, TextInput } from "@mantine/core";
import { clientApi } from "@homarr/api/client";
import { revalidatePathActionAsync } from "@homarr/common/client";
import { useZodForm } from "@homarr/form";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useI18n } from "@homarr/translation/client";
import { validation } from "@homarr/validation";
import { revalidatePathActionAsync } from "~/app/revalidatePathAction";
interface RenameGroupFormProps {
group: {
id: string;

View File

@@ -3,10 +3,10 @@
import { useCallback } from "react";
import { clientApi } from "@homarr/api/client";
import { revalidatePathActionAsync } from "@homarr/common/client";
import { useModalAction } from "@homarr/modals";
import { useScopedI18n } from "@homarr/translation/client";
import { revalidatePathActionAsync } from "~/app/revalidatePathAction";
import { UserSelectModal } from "~/components/access/user-select-modal";
import { MobileAffixButton } from "~/components/manage/mobile-affix-button";

View File

@@ -4,11 +4,10 @@ import { useCallback } from "react";
import { Button } from "@mantine/core";
import { clientApi } from "@homarr/api/client";
import { revalidatePathActionAsync } from "@homarr/common/client";
import { useConfirmModal } from "@homarr/modals";
import { useI18n, useScopedI18n } from "@homarr/translation/client";
import { revalidatePathActionAsync } from "~/app/revalidatePathAction";
interface RemoveGroupMemberProps {
groupId: string;
user: { id: string; name: string | null };

View File

@@ -1,16 +1,11 @@
"use client";
import { useCallback } from "react";
import { Button, Group, Stack, TextInput } from "@mantine/core";
import { clientApi } from "@homarr/api/client";
import { useZodForm } from "@homarr/form";
import { createModal, useModalAction } from "@homarr/modals";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useModalAction } from "@homarr/modals";
import { AddGroupModal } from "@homarr/modals-collection";
import { useI18n } from "@homarr/translation/client";
import { validation } from "@homarr/validation";
import { revalidatePathActionAsync } from "~/app/revalidatePathAction";
import { MobileAffixButton } from "~/components/manage/mobile-affix-button";
export const AddGroup = () => {
@@ -27,50 +22,3 @@ export const AddGroup = () => {
</MobileAffixButton>
);
};
const AddGroupModal = createModal<void>(({ actions }) => {
const t = useI18n();
const { mutate, isPending } = clientApi.group.createGroup.useMutation();
const form = useZodForm(validation.group.create, {
initialValues: {
name: "",
},
});
return (
<form
onSubmit={form.onSubmit((values) => {
mutate(values, {
onSuccess() {
actions.closeModal();
void revalidatePathActionAsync("/manage/users/groups");
showSuccessNotification({
title: t("common.notification.create.success"),
message: t("group.action.create.notification.success.message"),
});
},
onError() {
showErrorNotification({
title: t("common.notification.create.error"),
message: t("group.action.create.notification.error.message"),
});
},
});
})}
>
<Stack>
<TextInput label={t("group.field.name")} data-autofocus {...form.getInputProps("name")} />
<Group justify="right">
<Button onClick={actions.closeModal} variant="subtle" color="gray">
{t("common.action.cancel")}
</Button>
<Button loading={isPending} type="submit" color="teal">
{t("common.action.create")}
</Button>
</Group>
</Stack>
</form>
);
}).withOptions({
defaultTitle: (t) => t("group.action.create.label"),
});

View File

@@ -11,11 +11,10 @@ import { MantineReactTable } from "mantine-react-table";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import { useConfirmModal, useModalAction } from "@homarr/modals";
import { InviteCreateModal } from "@homarr/modals-collection";
import { useScopedI18n } from "@homarr/translation/client";
import { useTranslatedMantineReactTable } from "@homarr/ui/hooks";
import { InviteCreateModal } from "./invite-create-modal";
dayjs.extend(relativeTime);
interface InviteListComponentProps {

View File

@@ -107,7 +107,7 @@ export const BoardItemMenu = ({
}}
>
{tItem("action.moveResize")}
</Menu.Item>{" "}
</Menu.Item>
<Menu.Item leftSection={<IconCopy size={16} />} onClick={() => duplicateItem({ itemId: item.id })}>
{tItem("action.duplicate")}
</Menu.Item>

View File

@@ -3,7 +3,7 @@ import { useState } from "react";
import { Combobox, Group, Image, InputBase, Skeleton, Text, useCombobox } from "@mantine/core";
import { clientApi } from "@homarr/api/client";
import { useScopedI18n } from "@homarr/translation/client";
import { useI18n, useScopedI18n } from "@homarr/translation/client";
interface IconPickerProps {
initialValue?: string;
@@ -18,7 +18,8 @@ export const IconPicker = ({ initialValue, onChange, error, onFocus, onBlur }: I
const [search, setSearch] = useState(initialValue ?? "");
const [previewUrl, setPreviewUrl] = useState<string | null>(initialValue ?? null);
const t = useScopedI18n("common");
const t = useI18n();
const tCommon = useScopedI18n("common");
const { data, isFetching } = clientApi.icon.findIcons.useQuery({
searchText: search,
@@ -89,13 +90,13 @@ export const IconPicker = ({ initialValue, onChange, error, onFocus, onBlur }: I
rightSectionPointerEvents="none"
withAsterisk
error={error}
label={t("iconPicker.label")}
label={tCommon("iconPicker.label")}
/>
</Combobox.Target>
<Combobox.Dropdown>
<Combobox.Header>
<Text c="dimmed">{t("iconPicker.header", { countIcons: data?.countIcons })}</Text>
<Text c="dimmed">{tCommon("iconPicker.header", { countIcons: data?.countIcons })}</Text>
</Combobox.Header>
<Combobox.Options mah={350} style={{ overflowY: "auto" }}>
{totalOptions > 0 ? (

View File

@@ -4,13 +4,13 @@ import { TextInput, UnstyledButton } from "@mantine/core";
import { IconSearch } from "@tabler/icons-react";
import { openSpotlight } from "@homarr/spotlight";
import { useScopedI18n } from "@homarr/translation/client";
import { useI18n } from "@homarr/translation/client";
import { HeaderButton } from "./button";
import classes from "./search.module.css";
export const DesktopSearchInput = () => {
const t = useScopedI18n("common.search");
const t = useI18n();
return (
<TextInput
@@ -21,7 +21,10 @@ export const DesktopSearchInput = () => {
leftSection={<IconSearch size={20} stroke={1.5} />}
onClick={openSpotlight}
>
{t("placeholder")}
{t("common.rtl", {
value: t("search.placeholder"),
symbol: "...",
})}
</TextInput>
);
};

View File

@@ -1,66 +0,0 @@
import { Button, Group, InputWrapper, Slider, Stack, Switch, TextInput } from "@mantine/core";
import { useZodForm } from "@homarr/form";
import { createModal } from "@homarr/modals";
import { useI18n } from "@homarr/translation/client";
import { validation } from "@homarr/validation";
import { createCustomErrorParams } from "@homarr/validation/form";
interface InnerProps {
boardNames: string[];
onSuccess: (props: { name: string; columnCount: number; isPublic: boolean }) => Promise<void>;
}
export const AddBoardModal = createModal<InnerProps>(({ actions, innerProps }) => {
const t = useI18n();
const form = useZodForm(
validation.board.create.refine((value) => !innerProps.boardNames.includes(value.name), {
params: createCustomErrorParams("boardAlreadyExists"),
path: ["name"],
}),
{
initialValues: {
name: "",
columnCount: 10,
isPublic: false,
},
},
);
const columnCountChecks = validation.board.create.shape.columnCount._def.checks;
const minColumnCount = columnCountChecks.find((check) => check.kind === "min")?.value;
const maxColumnCount = columnCountChecks.find((check) => check.kind === "max")?.value;
return (
<form
onSubmit={form.onSubmit((values) => {
void innerProps.onSuccess(values);
actions.closeModal();
})}
>
<Stack>
<TextInput label={t("board.field.name.label")} data-autofocus {...form.getInputProps("name")} />
<InputWrapper label={t("board.field.columnCount.label")} {...form.getInputProps("columnCount")}>
<Slider min={minColumnCount} max={maxColumnCount} step={1} {...form.getInputProps("columnCount")} />
</InputWrapper>
<Switch
label={t("board.field.isPublic.label")}
description={t("board.field.isPublic.description")}
{...form.getInputProps("isPublic")}
/>
<Group justify="right">
<Button onClick={actions.closeModal} variant="subtle" color="gray">
{t("common.action.cancel")}
</Button>
<Button type="submit" color="teal">
{t("common.action.create")}
</Button>
</Group>
</Stack>
</form>
);
}).withOptions({
defaultTitle: (t) => t("management.page.board.action.new.label"),
});

View File

@@ -23,7 +23,14 @@ export const env = createEnv({
// If the DB_HOST is set, the DB_URL is optional
DB_URL: isUsingDbHost ? z.string().optional() : z.string(),
DB_HOST: isUsingDbUrl ? z.string().optional() : z.string(),
DB_PORT: isUsingDbUrl ? z.number().optional() : z.number().min(1).default(3306),
DB_PORT: isUsingDbUrl
? z.string().regex(/\d+/).transform(Number).optional()
: z
.string()
.regex(/\d+/)
.transform(Number)
.refine((number) => number >= 1)
.default("3306"),
DB_USER: isUsingDbCredentials ? z.string() : z.string().optional(),
DB_PASSWORD: isUsingDbCredentials ? z.string() : z.string().optional(),
DB_NAME: isUsingDbUrl ? z.string().optional() : z.string(),

27
nginx.conf Normal file
View File

@@ -0,0 +1,27 @@
events {
worker_connections 1024;
}
http {
server {
listen 7575;
# Route websockets traffic to port 3001
location /websockets {
proxy_pass http://${HOSTNAME}:3001;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "Upgrade";
proxy_set_header Host $http_host;
}
# Route all other traffic to port 3000
location / {
proxy_pass http://${HOSTNAME}:3000;
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
}

View File

@@ -40,7 +40,7 @@
"vite-tsconfig-paths": "^5.0.1",
"vitest": "^2.1.1"
},
"packageManager": "pnpm@9.10.0",
"packageManager": "pnpm@9.11.0",
"engines": {
"node": ">=20.17.0"
},

View File

@@ -39,7 +39,7 @@
"@trpc/react-query": "next",
"@trpc/server": "next",
"dockerode": "^4.0.2",
"next": "^14.2.11",
"next": "^14.2.13",
"react": "^18.3.1",
"superjson": "2.2.1",
"trpc-swagger": "^1.2.6"

View File

@@ -1,5 +1,25 @@
import { createTRPCReact } from "@trpc/react-query";
import { createTRPCClient, createTRPCReact, httpLink } from "@trpc/react-query";
import SuperJSON from "superjson";
import type { AppRouter } from ".";
export const clientApi = createTRPCReact<AppRouter>();
export const fetchApi = createTRPCClient<AppRouter>({
links: [
httpLink({
url: `${getBaseUrl()}/api/trpc`,
transformer: SuperJSON,
headers() {
const headers = new Headers();
headers.set("x-trpc-source", "fetch");
return headers;
},
}),
],
});
function getBaseUrl() {
if (typeof window !== "undefined") return window.location.origin;
if (process.env.VERCEL_URL) return `https://${process.env.VERCEL_URL}`;
return `http://localhost:${process.env.PORT ?? 3000}`;
}

View File

@@ -1,8 +1,8 @@
import { TRPCError } from "@trpc/server";
import { asc, createId, eq } from "@homarr/db";
import { asc, createId, eq, like } from "@homarr/db";
import { apps } from "@homarr/db/schema/sqlite";
import { validation } from "@homarr/validation";
import { validation, z } from "@homarr/validation";
import { createTRPCRouter, publicProcedure } from "../trpc";
@@ -22,6 +22,15 @@ export const appRouter = createTRPCRouter({
orderBy: asc(apps.name),
});
}),
search: publicProcedure
.input(z.object({ query: z.string(), limit: z.number().min(1).max(100).default(10) }))
.query(async ({ ctx, input }) => {
return await ctx.db.query.apps.findMany({
where: like(apps.name, `%${input.query}%`),
orderBy: asc(apps.name),
limit: input.limit,
});
}),
byId: publicProcedure.input(validation.app.byId).query(async ({ ctx, input }) => {
const app = await ctx.db.query.apps.findFirst({
where: eq(apps.id, input.id),

View File

@@ -1,8 +1,9 @@
import { TRPCError } from "@trpc/server";
import superjson from "superjson";
import { constructBoardPermissions } from "@homarr/auth/shared";
import type { Database, SQL } from "@homarr/db";
import { and, createId, eq, inArray, or } from "@homarr/db";
import { and, createId, eq, inArray, like, or } from "@homarr/db";
import {
boardGroupPermissions,
boards,
@@ -26,6 +27,20 @@ import { createTRPCRouter, permissionRequiredProcedure, protectedProcedure, publ
import { throwIfActionForbiddenAsync } from "./board/board-access";
export const boardRouter = createTRPCRouter({
exists: permissionRequiredProcedure
.requiresPermission("board-create")
.input(z.string())
.query(async ({ ctx, input: name }) => {
try {
await noBoardWithSimilarNameAsync(ctx.db, name);
return false;
} catch (error) {
if (error instanceof TRPCError && error.code === "CONFLICT") {
return true;
}
throw error;
}
}),
getAllBoards: publicProcedure.query(async ({ ctx }) => {
const userId = ctx.session?.user.id;
const permissionsOfCurrentUserWhenPresent = await ctx.db.query.boardUserPermissions.findMany({
@@ -95,6 +110,79 @@ export const boardRouter = createTRPCRouter({
isHome: currentUserWhenPresent?.homeBoardId === board.id,
}));
}),
search: publicProcedure
.input(z.object({ query: z.string(), limit: z.number().min(1).max(100).default(10) }))
.query(async ({ ctx, input }) => {
const userId = ctx.session?.user.id;
const permissionsOfCurrentUserWhenPresent = await ctx.db.query.boardUserPermissions.findMany({
where: eq(boardUserPermissions.userId, userId ?? ""),
});
const permissionsOfCurrentUserGroupsWhenPresent = await ctx.db.query.groupMembers.findMany({
where: eq(groupMembers.userId, userId ?? ""),
with: {
group: {
with: {
boardPermissions: {},
},
},
},
});
const boardIds = permissionsOfCurrentUserWhenPresent
.map((permission) => permission.boardId)
.concat(
permissionsOfCurrentUserGroupsWhenPresent
.map((groupMember) => groupMember.group.boardPermissions.map((permission) => permission.boardId))
.flat(),
);
const currentUserWhenPresent = await ctx.db.query.users.findFirst({
where: eq(users.id, userId ?? ""),
});
const foundBoards = await ctx.db.query.boards.findMany({
where: and(
like(boards.name, `%${input.query}%`),
ctx.session?.user.permissions.includes("board-view-all")
? undefined
: or(
eq(boards.isPublic, true),
eq(boards.creatorId, ctx.session?.user.id ?? ""),
inArray(boards.id, boardIds),
),
),
limit: input.limit,
columns: {
id: true,
name: true,
creatorId: true,
isPublic: true,
logoImageUrl: true,
},
with: {
userPermissions: {
where: eq(boardUserPermissions.userId, ctx.session?.user.id ?? ""),
},
groupPermissions: {
where:
permissionsOfCurrentUserGroupsWhenPresent.length >= 1
? inArray(
boardGroupPermissions.groupId,
permissionsOfCurrentUserGroupsWhenPresent.map((groupMember) => groupMember.groupId),
)
: undefined,
},
},
});
return foundBoards.map((board) => ({
id: board.id,
name: board.name,
logoImageUrl: board.logoImageUrl,
permissions: constructBoardPermissions(board, ctx.session),
isHome: currentUserWhenPresent?.homeBoardId === board.id,
}));
}),
createBoard: permissionRequiredProcedure
.requiresPermission("board-create")
.input(validation.board.create)

View File

@@ -3,9 +3,9 @@ import { TRPCError } from "@trpc/server";
import type { Database } from "@homarr/db";
import { and, createId, eq, like, not, sql } from "@homarr/db";
import { groupMembers, groupPermissions, groups } from "@homarr/db/schema/sqlite";
import { validation } from "@homarr/validation";
import { validation, z } from "@homarr/validation";
import { createTRPCRouter, protectedProcedure } from "../trpc";
import { createTRPCRouter, protectedProcedure, publicProcedure } from "../trpc";
export const groupRouter = createTRPCRouter({
getPaginated: protectedProcedure.input(validation.group.paginated).query(async ({ input, ctx }) => {
@@ -91,6 +91,23 @@ export const groupRouter = createTRPCRouter({
},
});
}),
search: publicProcedure
.input(
z.object({
query: z.string(),
limit: z.number().min(1).max(100).default(10),
}),
)
.query(async ({ input, ctx }) => {
return await ctx.db.query.groups.findMany({
where: like(groups.name, `%${input.query}%`),
columns: {
id: true,
name: true,
},
limit: input.limit,
});
}),
createGroup: protectedProcedure.input(validation.group.create).mutation(async ({ input, ctx }) => {
const normalizedName = normalizeName(input.name);
await checkSimilarNameAndThrowAsync(ctx.db, normalizedName);

View File

@@ -2,7 +2,7 @@ import { TRPCError } from "@trpc/server";
import { decryptSecret, encryptSecret } from "@homarr/common/server";
import type { Database } from "@homarr/db";
import { and, createId, eq, inArray } from "@homarr/db";
import { and, asc, createId, eq, inArray, like } from "@homarr/db";
import {
groupPermissions,
integrationGroupPermissions,
@@ -12,7 +12,7 @@ import {
} from "@homarr/db/schema/sqlite";
import type { IntegrationSecretKind } from "@homarr/definitions";
import { getPermissionsWithParents, integrationKinds, integrationSecretKindObject } from "@homarr/definitions";
import { validation } from "@homarr/validation";
import { validation, z } from "@homarr/validation";
import { createTRPCRouter, permissionRequiredProcedure, protectedProcedure } from "../../trpc";
import { throwIfActionForbiddenAsync } from "./integration-access";
@@ -33,6 +33,15 @@ export const integrationRouter = createTRPCRouter({
integrationKinds.indexOf(integrationA.kind) - integrationKinds.indexOf(integrationB.kind),
);
}),
search: protectedProcedure
.input(z.object({ query: z.string(), limit: z.number().min(1).max(100).default(10) }))
.query(async ({ ctx, input }) => {
return await ctx.db.query.integrations.findMany({
where: like(integrations.name, `%${input.query}%`),
orderBy: asc(integrations.name),
limit: input.limit,
});
}),
byId: protectedProcedure.input(validation.integration.byId).query(async ({ ctx, input }) => {
await throwIfActionForbiddenAsync(ctx, eq(integrations.id, input.id), "full");
const integration = await ctx.db.query.integrations.findFirst({

View File

@@ -2,7 +2,7 @@ import { TRPCError } from "@trpc/server";
import { createSaltAsync, hashPasswordAsync } from "@homarr/auth";
import type { Database } from "@homarr/db";
import { and, createId, eq, schema } from "@homarr/db";
import { and, createId, eq, like, schema } from "@homarr/db";
import { groupMembers, groupPermissions, groups, invites, users } from "@homarr/db/schema/sqlite";
import type { SupportedAuthProvider } from "@homarr/definitions";
import { logger } from "@homarr/log";
@@ -164,6 +164,29 @@ export const userRouter = createTRPCRouter({
},
});
}),
search: publicProcedure
.input(
z.object({
query: z.string(),
limit: z.number().min(1).max(100).default(10),
}),
)
.query(async ({ input, ctx }) => {
const dbUsers = await ctx.db.query.users.findMany({
columns: {
id: true,
name: true,
image: true,
},
where: like(users.name, `%${input.query}%`),
limit: input.limit,
});
return dbUsers.map((user) => ({
id: user.id,
name: user.name ?? "",
image: user.image,
}));
}),
getById: publicProcedure.input(z.object({ userId: z.string() })).query(async ({ input, ctx }) => {
const user = await ctx.db.query.users.findFirst({
columns: {

View File

@@ -23,8 +23,8 @@
},
"prettier": "@homarr/prettier-config",
"dependencies": {
"@auth/core": "^0.34.2",
"@auth/drizzle-adapter": "^1.4.2",
"@auth/core": "^0.35.0",
"@auth/drizzle-adapter": "^1.5.0",
"@homarr/common": "workspace:^0.1.0",
"@homarr/db": "workspace:^0.1.0",
"@homarr/definitions": "workspace:^0.1.0",
@@ -34,8 +34,8 @@
"bcrypt": "^5.1.1",
"cookies": "^0.9.1",
"ldapts": "7.2.0",
"next": "^14.2.11",
"next-auth": "5.0.0-beta.20",
"next": "^14.2.13",
"next-auth": "5.0.0-beta.21",
"react": "^18.3.1",
"react-dom": "^18.3.1"
},

View File

@@ -25,17 +25,18 @@ export const constructBoardPermissions = (board: BoardPermissionsProps, session:
const creatorId = "creator" in board ? board.creator?.id : board.creatorId;
return {
hasFullAccess: session?.user.id === creatorId || session?.user.permissions.includes("board-full-all"),
hasFullAccess: session?.user.id === creatorId || (session?.user.permissions.includes("board-full-all") ?? false),
hasChangeAccess:
session?.user.id === creatorId ||
board.userPermissions.some(({ permission }) => permission === "modify") ||
board.groupPermissions.some(({ permission }) => permission === "modify") ||
session?.user.permissions.includes("board-modify-all"),
(session?.user.permissions.includes("board-modify-all") ?? false) ||
(session?.user.permissions.includes("board-full-all") ?? false),
hasViewAccess:
session?.user.id === creatorId ||
board.userPermissions.length >= 1 ||
board.groupPermissions.length >= 1 ||
board.isPublic ||
session?.user.permissions.includes("board-view-all"),
(session?.user.permissions.includes("board-view-all") ?? false),
};
};

View File

@@ -1,4 +1,4 @@
import type { Session } from "@auth/core/types";
import type { Session } from "next-auth";
import { db, eq, inArray } from "@homarr/db";
import { groupMembers, integrationGroupPermissions, integrationUserPermissions } from "@homarr/db/schema/sqlite";

View File

@@ -26,9 +26,9 @@
"prettier": "@homarr/prettier-config",
"dependencies": {
"dayjs": "^1.11.13",
"next": "^14.2.11",
"next": "^14.2.13",
"react": "^18.3.1",
"tldts": "^6.1.44"
"tldts": "^6.1.47"
},
"devDependencies": {
"@homarr/eslint-config": "workspace:^0.2.0",

View File

@@ -1 +1,2 @@
export * from "./app-url/client";
export * from "./revalidate-path-action";

View File

@@ -1,14 +1,14 @@
CREATE TABLE `integrationGroupPermissions` (
`integration_id` varchar(64) NOT NULL,
`group_id` varchar(64) NOT NULL,
`permission` text NOT NULL,
CONSTRAINT `integrationGroupPermissions_integration_id_group_id_permission_pk` PRIMARY KEY(`integration_id`,`group_id`,`permission`)
`permission` varchar(128) NOT NULL,
CONSTRAINT `integration_group_permission__pk` PRIMARY KEY(`integration_id`,`group_id`,`permission`)
);
--> statement-breakpoint
CREATE TABLE `integrationUserPermission` (
`integration_id` varchar(64) NOT NULL,
`user_id` varchar(64) NOT NULL,
`permission` text NOT NULL,
`permission` varchar(128) NOT NULL,
CONSTRAINT `integrationUserPermission_integration_id_user_id_permission_pk` PRIMARY KEY(`integration_id`,`user_id`,`permission`)
);
--> statement-breakpoint

View File

@@ -2,5 +2,5 @@ ALTER TABLE `section` RENAME COLUMN `position` TO `y_offset`;--> statement-break
ALTER TABLE `section` ADD `x_offset` int NOT NULL;--> statement-breakpoint
ALTER TABLE `section` ADD `width` int;--> statement-breakpoint
ALTER TABLE `section` ADD `height` int;--> statement-breakpoint
ALTER TABLE `section` ADD `parent_section_id` text;--> statement-breakpoint
ALTER TABLE `section` ADD `parent_section_id` varchar(64);--> statement-breakpoint
ALTER TABLE `section` ADD CONSTRAINT `section_parent_section_id_section_id_fk` FOREIGN KEY (`parent_section_id`) REFERENCES `section`(`id`) ON DELETE cascade ON UPDATE no action;

View File

@@ -655,7 +655,7 @@
},
"permission": {
"name": "permission",
"type": "text",
"type": "varchar(128)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
@@ -683,8 +683,8 @@
}
},
"compositePrimaryKeys": {
"integrationGroupPermissions_integration_id_group_id_permission_pk": {
"name": "integrationGroupPermissions_integration_id_group_id_permission_pk",
"integration_group_permission__pk": {
"name": "integration_group_permission__pk",
"columns": ["integration_id", "group_id", "permission"]
}
},
@@ -819,7 +819,7 @@
},
"permission": {
"name": "permission",
"type": "text",
"type": "varchar(128)",
"primaryKey": false,
"notNull": true,
"autoincrement": false

View File

@@ -655,7 +655,7 @@
},
"permission": {
"name": "permission",
"type": "text",
"type": "varchar(128)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
@@ -683,8 +683,8 @@
}
},
"compositePrimaryKeys": {
"integrationGroupPermissions_integration_id_group_id_permission_pk": {
"name": "integrationGroupPermissions_integration_id_group_id_permission_pk",
"integration_group_permission__pk": {
"name": "integration_group_permission__pk",
"columns": ["integration_id", "group_id", "permission"]
}
},
@@ -819,7 +819,7 @@
},
"permission": {
"name": "permission",
"type": "text",
"type": "varchar(128)",
"primaryKey": false,
"notNull": true,
"autoincrement": false

View File

@@ -655,7 +655,7 @@
},
"permission": {
"name": "permission",
"type": "text",
"type": "varchar(128)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
@@ -683,8 +683,8 @@
}
},
"compositePrimaryKeys": {
"integrationGroupPermissions_integration_id_group_id_permission_pk": {
"name": "integrationGroupPermissions_integration_id_group_id_permission_pk",
"integration_group_permission__pk": {
"name": "integration_group_permission__pk",
"columns": ["integration_id", "group_id", "permission"]
}
},
@@ -819,7 +819,7 @@
},
"permission": {
"name": "permission",
"type": "text",
"type": "varchar(128)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
@@ -1109,7 +1109,7 @@
},
"parent_section_id": {
"name": "parent_section_id",
"type": "text",
"type": "varchar(64)",
"primaryKey": false,
"notNull": false,
"autoincrement": false

View File

@@ -655,7 +655,7 @@
},
"permission": {
"name": "permission",
"type": "text",
"type": "varchar(128)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
@@ -683,8 +683,8 @@
}
},
"compositePrimaryKeys": {
"integrationGroupPermissions_integration_id_group_id_permission_pk": {
"name": "integrationGroupPermissions_integration_id_group_id_permission_pk",
"integration_group_permission__pk": {
"name": "integration_group_permission__pk",
"columns": ["integration_id", "group_id", "permission"]
}
},
@@ -819,7 +819,7 @@
},
"permission": {
"name": "permission",
"type": "text",
"type": "varchar(128)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
@@ -1109,7 +1109,7 @@
},
"parent_section_id": {
"name": "parent_section_id",
"type": "text",
"type": "varchar(64)",
"primaryKey": false,
"notNull": false,
"autoincrement": false

View File

@@ -659,9 +659,9 @@
}
},
"compositePrimaryKeys": {
"integrationGroupPermissions_integration_id_group_id_permission_pk": {
"integration_group_permission__pk": {
"columns": ["group_id", "integration_id", "permission"],
"name": "integrationGroupPermissions_integration_id_group_id_permission_pk"
"name": "integration_group_permission__pk"
}
},
"uniqueConstraints": {}

View File

@@ -659,9 +659,9 @@
}
},
"compositePrimaryKeys": {
"integrationGroupPermissions_integration_id_group_id_permission_pk": {
"integration_group_permission__pk": {
"columns": ["group_id", "integration_id", "permission"],
"name": "integrationGroupPermissions_integration_id_group_id_permission_pk"
"name": "integration_group_permission__pk"
}
},
"uniqueConstraints": {}

View File

@@ -659,9 +659,9 @@
}
},
"compositePrimaryKeys": {
"integrationGroupPermissions_integration_id_group_id_permission_pk": {
"integration_group_permission__pk": {
"columns": ["group_id", "integration_id", "permission"],
"name": "integrationGroupPermissions_integration_id_group_id_permission_pk"
"name": "integration_group_permission__pk"
}
},
"uniqueConstraints": {}

View File

@@ -659,9 +659,9 @@
}
},
"compositePrimaryKeys": {
"integrationGroupPermissions_integration_id_group_id_permission_pk": {
"integration_group_permission__pk": {
"columns": ["integration_id", "group_id", "permission"],
"name": "integrationGroupPermissions_integration_id_group_id_permission_pk"
"name": "integration_group_permission__pk"
}
},
"uniqueConstraints": {}

View File

@@ -31,16 +31,17 @@
},
"prettier": "@homarr/prettier-config",
"dependencies": {
"@auth/core": "^0.34.2",
"@auth/core": "^0.35.0",
"@homarr/common": "workspace:^0.1.0",
"@homarr/definitions": "workspace:^0.1.0",
"@homarr/log": "workspace:^0.1.0",
"@paralleldrive/cuid2": "^2.2.2",
"@testcontainers/mysql": "^10.13.1",
"better-sqlite3": "^11.3.0",
"dotenv": "^16.4.5",
"drizzle-kit": "^0.24.2",
"drizzle-orm": "^0.33.0",
"mysql2": "3.11.2"
"mysql2": "3.11.3"
},
"devDependencies": {
"@homarr/eslint-config": "workspace:^0.2.0",

View File

@@ -171,7 +171,7 @@ export const integrationUserPermissions = mysqlTable(
userId: varchar("user_id", { length: 64 })
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
permission: text("permission").$type<IntegrationPermission>().notNull(),
permission: varchar("permission", { length: 128 }).$type<IntegrationPermission>().notNull(),
},
(table) => ({
compoundKey: primaryKey({
@@ -189,11 +189,12 @@ export const integrationGroupPermissions = mysqlTable(
groupId: varchar("group_id", { length: 64 })
.notNull()
.references(() => groups.id, { onDelete: "cascade" }),
permission: text("permission").$type<IntegrationPermission>().notNull(),
permission: varchar("permission", { length: 128 }).$type<IntegrationPermission>().notNull(),
},
(table) => ({
compoundKey: primaryKey({
columns: [table.integrationId, table.groupId, table.permission],
name: "integration_group_permission__pk",
}),
}),
);
@@ -276,7 +277,7 @@ export const sections = mysqlTable("section", {
width: int("width"),
height: int("height"),
name: text("name"),
parentSectionId: text("parent_section_id").references((): AnyMySqlColumn => sections.id, {
parentSectionId: varchar("parent_section_id", { length: 64 }).references((): AnyMySqlColumn => sections.id, {
onDelete: "cascade",
}),
});

View File

@@ -0,0 +1,38 @@
import path from "path";
import { MySqlContainer } from "@testcontainers/mysql";
import { drizzle } from "drizzle-orm/mysql2";
import { migrate } from "drizzle-orm/mysql2/migrator";
import mysql from "mysql2";
import { describe, test } from "vitest";
import * as mysqlSchema from "../schema/mysql";
describe("Mysql Migration", () => {
test("should add all tables and keys specified in migration files", async () => {
const mysqlContainer = await new MySqlContainer().start();
const connection = mysql.createConnection({
host: mysqlContainer.getHost(),
database: mysqlContainer.getDatabase(),
port: mysqlContainer.getPort(),
user: mysqlContainer.getUsername(),
password: mysqlContainer.getUserPassword(),
});
const database = drizzle(connection, {
schema: mysqlSchema,
mode: "default",
});
// Run migrations and check if it works
await migrate(database, {
migrationsFolder: path.join(__dirname, "..", "migrations", "mysql"),
});
// Check if users table exists
await database.query.users.findMany();
connection.end();
await mysqlContainer.stop();
}, 40_000);
});

View File

@@ -11,6 +11,22 @@ const repositories = [
new URL("https://api.github.com/repos/walkxcode/dashboard-icons/git/trees/main?recursive=true"),
"https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/{0}",
),
new GitHubIconRepository(
"selfh.st",
"selfhst/icons",
"CC0-1.0",
new URL("https://github.com/selfhst/icons"),
new URL("https://api.github.com/repos/selfhst/icons/git/trees/main?recursive=true"),
"https://cdn.jsdelivr.net/gh/selfhst/icons/{0}",
),
new GitHubIconRepository(
"SimpleIcons",
"simple-icons/simple-icons",
"CC0-1.0",
new URL("https://github.com/simple-icons/simple-icons"),
new URL("https://api.github.com/repos/simple-icons/simple-icons/git/trees/master?recursive=true"),
"https://cdn.simpleicons.org/{1}",
),
new JsdelivrIconRepository(
"Papirus",
"PapirusDevelopmentTeam/papirus-icon-theme",

View File

@@ -1,3 +1,5 @@
import { parse } from "path";
import { fetchWithTimeout } from "@homarr/common";
import type { IconRepositoryLicense } from "../types/icon-repository-license";
@@ -27,19 +29,23 @@ export class GitHubIconRepository extends IconRepository {
return {
success: true,
icons: listOfFiles.tree
.filter((treeItem) =>
this.allowedImageFileTypes.some((allowedExtension) => treeItem.path.includes(allowedExtension)),
.filter(({ path }) =>
this.allowedImageFileTypes.some((allowedImageFileType) => parse(path).ext === allowedImageFileType),
)
.map((treeItem) => {
const fileNameWithExtension = this.getFileNameWithoutExtensionFromPath(treeItem.path);
.map(({ path, size: sizeInBytes, sha: checksum }) => {
const file = parse(path);
const fileNameWithExtension = file.base;
const imageUrl = new URL(
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
this.repositoryBlobUrlTemplate!.replace("{0}", path).replace("{1}", file.name),
);
return {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
imageUrl: new URL(this.repositoryBlobUrlTemplate!.replace("{0}", treeItem.path)),
fileNameWithExtension: fileNameWithExtension,
imageUrl,
fileNameWithExtension,
local: false,
sizeInBytes: treeItem.size,
checksum: treeItem.sha,
sizeInBytes,
checksum,
};
}),
slug: this.slug,

View File

@@ -29,8 +29,4 @@ export abstract class IconRepository {
}
protected abstract getAllIconsInternalAsync(): Promise<RepositoryIconGroup>;
protected getFileNameWithoutExtensionFromPath(path: string) {
return path.replace(/^.*[\\/]/, "");
}
}

View File

@@ -1,3 +1,5 @@
import { parse } from "path";
import { fetchWithTimeout } from "@homarr/common";
import type { IconRepositoryLicense } from "../types/icon-repository-license";
@@ -23,18 +25,19 @@ export class JsdelivrIconRepository extends IconRepository {
return {
success: true,
icons: listOfFiles.files
.filter((file) =>
this.allowedImageFileTypes.some((allowedImageFileType) => file.name.includes(allowedImageFileType)),
.filter(({ name: path }) =>
this.allowedImageFileTypes.some((allowedImageFileType) => parse(path).ext === allowedImageFileType),
)
.map((file) => {
const fileNameWithExtension = this.getFileNameWithoutExtensionFromPath(file.name);
.map(({ name: path, size: sizeInBytes, hash: checksum }) => {
const file = parse(path);
const fileNameWithExtension = file.base;
return {
imageUrl: new URL(this.repositoryBlobUrlTemplate.replace("{0}", file.name)),
fileNameWithExtension: fileNameWithExtension,
imageUrl: new URL(this.repositoryBlobUrlTemplate.replace("{0}", path).replace("{1}", file.name)),
fileNameWithExtension,
local: false,
sizeInBytes: file.size,
checksum: file.hash,
sizeInBytes,
checksum,
};
}),
slug: this.slug,

View File

@@ -1 +1 @@
export type IconRepositoryLicense = "MIT" | "GPL-3.0" | undefined;
export type IconRepositoryLicense = "MIT" | "GPL-3.0" | "CC0-1.0" | undefined;

View File

@@ -26,15 +26,14 @@
"dependencies": {
"@ctrl/deluge": "^6.1.0",
"@ctrl/qbittorrent": "^9.0.1",
"@ctrl/transmission": "^6.1.0",
"@ctrl/transmission": "^7.0.0",
"@homarr/common": "workspace:^0.1.0",
"@homarr/db": "workspace:^0.1.0",
"@homarr/definitions": "workspace:^0.1.0",
"@homarr/log": "workspace:^0.1.0",
"@homarr/translation": "workspace:^0.1.0",
"@homarr/validation": "workspace:^0.1.0",
"@jellyfin/sdk": "^0.10.0",
"typed-rpc": "^5.1.0"
"@jellyfin/sdk": "^0.10.0"
},
"devDependencies": {
"@homarr/eslint-config": "workspace:^0.2.0",

View File

@@ -1,5 +1,4 @@
import dayjs from "dayjs";
import { rpcClient } from "typed-rpc";
import type { DownloadClientJobsAndStatus } from "../../interfaces/downloads/download-client-data";
import { DownloadClientIntegration } from "../../interfaces/downloads/download-client-integration";
@@ -9,16 +8,14 @@ import type { NzbGetClient } from "./nzbget-types";
export class NzbGetIntegration extends DownloadClientIntegration {
public async testConnectionAsync(): Promise<void> {
const client = this.getClient();
await client.version();
await this.nzbGetApiCallAsync("version");
}
public async getClientJobsAndStatusAsync(): Promise<DownloadClientJobsAndStatus> {
const type = "usenet";
const nzbGetClient = this.getClient();
const queue = await nzbGetClient.listgroups();
const history = await nzbGetClient.history();
const nzbGetStatus = await nzbGetClient.status();
const queue = await this.nzbGetApiCallAsync("listgroups");
const history = await this.nzbGetApiCallAsync("history");
const nzbGetStatus = await this.nzbGetApiCallAsync("status");
const status: DownloadClientStatus = {
paused: nzbGetStatus.DownloadPaused,
rates: { down: nzbGetStatus.DownloadRate },
@@ -64,39 +61,55 @@ export class NzbGetIntegration extends DownloadClientIntegration {
}
public async pauseQueueAsync() {
await this.getClient().pausedownload();
await this.nzbGetApiCallAsync("pausedownload");
}
public async pauseItemAsync({ id }: DownloadClientItem): Promise<void> {
await this.getClient().editqueue("GroupPause", "", [Number(id)]);
await this.nzbGetApiCallAsync("editqueue", "GroupPause", "", [Number(id)]);
}
public async resumeQueueAsync() {
await this.getClient().resumedownload();
await this.nzbGetApiCallAsync("resumedownload");
}
public async resumeItemAsync({ id }: DownloadClientItem): Promise<void> {
await this.getClient().editqueue("GroupResume", "", [Number(id)]);
await this.nzbGetApiCallAsync("editqueue", "GroupResume", "", [Number(id)]);
}
public async deleteItemAsync({ id, progress }: DownloadClientItem, fromDisk: boolean): Promise<void> {
const client = this.getClient();
if (fromDisk) {
const filesIds = (await client.listfiles(0, 0, Number(id))).map((value) => value.ID);
await this.getClient().editqueue("FileDelete", "", filesIds);
const filesIds = (await this.nzbGetApiCallAsync("listfiles", 0, 0, Number(id))).map((file) => file.ID);
await this.nzbGetApiCallAsync("editqueue", "FileDelete", "", filesIds);
}
if (progress !== 1) {
await client.editqueue("GroupFinalDelete", "", [Number(id)]);
if (progress === 1) {
await this.nzbGetApiCallAsync("editqueue", "GroupFinalDelete", "", [Number(id)]);
} else {
await client.editqueue("HistoryFinalDelete", "", [Number(id)]);
await this.nzbGetApiCallAsync("editqueue", "HistoryFinalDelete", "", [Number(id)]);
}
}
private getClient() {
private async nzbGetApiCallAsync<CallType extends keyof NzbGetClient>(
method: CallType,
...params: Parameters<NzbGetClient[CallType]>
): Promise<ReturnType<NzbGetClient[CallType]>> {
const url = new URL(this.integration.url);
url.pathname += `${this.getSecretValue("username")}:${this.getSecretValue("password")}`;
url.pathname += url.pathname.endsWith("/") ? "jsonrpc" : "/jsonrpc";
return rpcClient<NzbGetClient>(url.toString());
const body = JSON.stringify({ method, params });
return await fetch(url, { method: "POST", body })
.then(async (response) => {
if (!response.ok) {
throw new Error(response.statusText);
}
return ((await response.json()) as { result: ReturnType<NzbGetClient[CallType]> }).result;
})
.catch((error) => {
if (error instanceof Error) {
throw new Error(error.message);
} else {
throw new Error("Error communicating with NzbGet");
}
});
}
private static getUsenetQueueState(status: string): DownloadClientItem["state"] {

View File

@@ -125,7 +125,7 @@ describe("Nzbget integration", () => {
// Act
const getAsync = async () => await nzbGetIntegration.getClientJobsAndStatusAsync();
const actAsync = async () => await nzbGetIntegration.deleteItemAsync(item, false);
const actAsync = async () => await nzbGetIntegration.deleteItemAsync(item, true);
// Assert
await expect(actAsync()).resolves.not.toThrow();

View File

@@ -0,0 +1,9 @@
import baseConfig from "@homarr/eslint-config/base";
/** @type {import('typescript-eslint').Config} */
export default [
{
ignores: [],
},
...baseConfig,
];

View File

@@ -0,0 +1 @@
export * from "./src";

View File

@@ -0,0 +1,47 @@
{
"name": "@homarr/modals-collection",
"private": true,
"version": "0.1.0",
"type": "module",
"exports": {
".": "./index.ts"
},
"typesVersions": {
"*": {
"*": [
"src/*"
]
}
},
"license": "MIT",
"scripts": {
"clean": "rm -rf .turbo node_modules",
"lint": "eslint",
"format": "prettier --check . --ignore-path ../../.gitignore",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@homarr/api": "workspace:^0.1.0",
"@homarr/common": "workspace:^0.1.0",
"@homarr/form": "workspace:^0.1.0",
"@homarr/modals": "workspace:^0.1.0",
"@homarr/notifications": "workspace:^0.1.0",
"@homarr/old-schema": "workspace:^0.1.0",
"@homarr/translation": "workspace:^0.1.0",
"@homarr/ui": "workspace:^0.1.0",
"@homarr/validation": "workspace:^0.1.0",
"@mantine/core": "^7.12.2",
"@tabler/icons-react": "^3.17.0",
"dayjs": "^1.11.13",
"next": "^14.2.13",
"react": "^18.3.1"
},
"devDependencies": {
"@homarr/eslint-config": "workspace:^0.2.0",
"@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0",
"eslint": "^9.10.0",
"typescript": "^5.6.2"
},
"prettier": "@homarr/prettier-config"
}

View File

@@ -0,0 +1,126 @@
import { Button, Group, InputWrapper, Slider, Stack, Switch, TextInput } from "@mantine/core";
import { useDebouncedValue } from "@mantine/hooks";
import { IconAlertTriangle, IconCircleCheck } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
import { revalidatePathActionAsync } from "@homarr/common/client";
import { useZodForm } from "@homarr/form";
import { createModal } from "@homarr/modals";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useI18n } from "@homarr/translation/client";
import { validation } from "@homarr/validation";
export const AddBoardModal = createModal(({ actions }) => {
const t = useI18n();
const form = useZodForm(validation.board.create, {
mode: "controlled",
initialValues: {
name: "",
columnCount: 10,
isPublic: false,
},
});
const { mutate, isPending } = clientApi.board.createBoard.useMutation({
onSettled: async () => {
await revalidatePathActionAsync("/manage/boards");
},
});
const boardNameStatus = useBoardNameStatus(form.values.name);
const columnCountChecks = validation.board.create.shape.columnCount._def.checks;
const minColumnCount = columnCountChecks.find((check) => check.kind === "min")?.value;
const maxColumnCount = columnCountChecks.find((check) => check.kind === "max")?.value;
return (
<form
onSubmit={form.onSubmit((values) => {
// Prevent submit before name availability check
if (!boardNameStatus.canSubmit) return;
mutate(values, {
onSuccess: () => {
actions.closeModal();
showSuccessNotification({
title: "Board created",
message: `Board ${values.name} has been created`,
});
},
onError() {
showErrorNotification({
title: "Failed to create board",
message: `Board ${values.name} could not be created`,
});
},
});
})}
>
<Stack>
<TextInput
label={t("board.field.name.label")}
data-autofocus
{...form.getInputProps("name")}
description={
boardNameStatus.description ? (
<Group c={boardNameStatus.description.color} gap="xs" align="center">
{boardNameStatus.description.icon ? <boardNameStatus.description.icon size={16} /> : null}
<span>{boardNameStatus.description.label}</span>
</Group>
) : null
}
/>
<InputWrapper label={t("board.field.columnCount.label")} {...form.getInputProps("columnCount")}>
<Slider min={minColumnCount} max={maxColumnCount} step={1} {...form.getInputProps("columnCount")} />
</InputWrapper>
<Switch
label={t("board.field.isPublic.label")}
description={t("board.field.isPublic.description")}
{...form.getInputProps("isPublic")}
/>
<Group justify="right">
<Button onClick={actions.closeModal} variant="subtle" color="gray">
{t("common.action.cancel")}
</Button>
<Button type="submit" color="teal" loading={isPending}>
{t("common.action.create")}
</Button>
</Group>
</Stack>
</form>
);
}).withOptions({
defaultTitle: (t) => t("management.page.board.action.new.label"),
});
export const useBoardNameStatus = (name: string) => {
const t = useI18n();
const [debouncedName] = useDebouncedValue(name, 250);
const { data: boardExists, isLoading } = clientApi.board.exists.useQuery(debouncedName, {
enabled: validation.board.create.shape.name.safeParse(debouncedName).success,
});
return {
canSubmit: !boardExists && !isLoading,
description:
debouncedName.trim() === ""
? undefined
: isLoading
? {
label: "Checking availability...",
}
: boardExists === undefined
? undefined
: boardExists
? {
icon: IconAlertTriangle,
label: t("common.zod.errors.custom.boardAlreadyExists"), // The board ${debouncedName} already exists
color: "red",
}
: {
icon: IconCircleCheck,
label: `${debouncedName} is available`,
color: "green",
},
};
};

View File

@@ -3,6 +3,7 @@ import { Button, Fieldset, FileInput, Grid, Group, Radio, Stack, Switch, TextInp
import { IconFileUpload } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
import { revalidatePathActionAsync } from "@homarr/common/client";
import { useZodForm } from "@homarr/form";
import { createModal } from "@homarr/modals";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
@@ -10,24 +11,21 @@ import { oldmarrConfigSchema } from "@homarr/old-schema";
import { useScopedI18n } from "@homarr/translation/client";
import { SelectWithDescription } from "@homarr/ui";
import type { OldmarrImportConfiguration } from "@homarr/validation";
import { createOldmarrImportConfigurationSchema, superRefineJsonImportFile, z } from "@homarr/validation";
import { oldmarrImportConfigurationSchema, superRefineJsonImportFile, z } from "@homarr/validation";
import { revalidatePathActionAsync } from "~/app/revalidatePathAction";
import { useBoardNameStatus } from "./add-board-modal";
interface InnerProps {
boardNames: string[];
}
export const ImportBoardModal = createModal<InnerProps>(({ actions, innerProps }) => {
export const ImportBoardModal = createModal(({ actions }) => {
const tOldImport = useScopedI18n("board.action.oldImport");
const tCommon = useScopedI18n("common");
const [fileValid, setFileValid] = useState(true);
const form = useZodForm(
z.object({
file: z.instanceof(File).nullable().superRefine(superRefineJsonImportFile),
configuration: createOldmarrImportConfigurationSchema(innerProps.boardNames),
configuration: oldmarrImportConfigurationSchema,
}),
{
mode: "controlled",
initialValues: {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
file: null!,
@@ -67,6 +65,7 @@ export const ImportBoardModal = createModal<InnerProps>(({ actions, innerProps }
);
const { mutateAsync, isPending } = clientApi.board.importOldmarrConfig.useMutation();
const boardNameStatus = useBoardNameStatus(form.values.configuration.name);
const handleSubmitAsync = async (values: { file: File; configuration: OldmarrImportConfiguration }) => {
const formData = new FormData();
@@ -94,7 +93,7 @@ export const ImportBoardModal = createModal<InnerProps>(({ actions, innerProps }
return (
<form
onSubmit={form.onSubmit((values) => {
if (!fileValid) {
if (!fileValid || !boardNameStatus.canSubmit) {
return;
}
@@ -139,7 +138,19 @@ export const ImportBoardModal = createModal<InnerProps>(({ actions, innerProps }
</Grid>
</Fieldset>
<TextInput withAsterisk label={tOldImport("form.name.label")} {...form.getInputProps("configuration.name")} />
<TextInput
withAsterisk
label={tOldImport("form.name.label")}
description={
boardNameStatus.description ? (
<Group c={boardNameStatus.description.color} gap="xs" align="center">
{boardNameStatus.description.icon ? <boardNameStatus.description.icon size={16} /> : null}
<span>{boardNameStatus.description.label}</span>
</Group>
) : null
}
{...form.getInputProps("configuration.name")}
/>
<Radio.Group
withAsterisk

View File

@@ -0,0 +1,2 @@
export { AddBoardModal } from "./add-board-modal";
export { ImportBoardModal } from "./import-board-modal";

View File

@@ -0,0 +1,56 @@
import { Button, Group, Stack, TextInput } from "@mantine/core";
import { clientApi } from "@homarr/api/client";
import { revalidatePathActionAsync } from "@homarr/common/client";
import { useZodForm } from "@homarr/form";
import { createModal } from "@homarr/modals";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useI18n } from "@homarr/translation/client";
import { validation } from "@homarr/validation";
export const AddGroupModal = createModal<void>(({ actions }) => {
const t = useI18n();
const { mutate, isPending } = clientApi.group.createGroup.useMutation();
const form = useZodForm(validation.group.create, {
initialValues: {
name: "",
},
});
return (
<form
onSubmit={form.onSubmit((values) => {
mutate(values, {
onSuccess() {
actions.closeModal();
void revalidatePathActionAsync("/manage/users/groups");
showSuccessNotification({
title: t("common.notification.create.success"),
message: t("group.action.create.notification.success.message"),
});
},
onError() {
showErrorNotification({
title: t("common.notification.create.error"),
message: t("group.action.create.notification.error.message"),
});
},
});
})}
>
<Stack>
<TextInput label={t("group.field.name")} data-autofocus {...form.getInputProps("name")} />
<Group justify="right">
<Button onClick={actions.closeModal} variant="subtle" color="gray">
{t("common.action.cancel")}
</Button>
<Button loading={isPending} type="submit" color="teal">
{t("common.action.create")}
</Button>
</Group>
</Stack>
</form>
);
}).withOptions({
defaultTitle: (t) => t("group.action.create.label"),
});

View File

@@ -0,0 +1 @@
export { AddGroupModal } from "./add-group-modal";

View File

@@ -0,0 +1,3 @@
export * from "./boards";
export * from "./invites";
export * from "./groups";

View File

@@ -0,0 +1,2 @@
export { InviteCopyModal } from "./invite-copy-modal";
export { InviteCreateModal } from "./invite-create-modal";

View File

@@ -0,0 +1,8 @@
{
"extends": "@homarr/tsconfig/base.json",
"compilerOptions": {
"tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json"
},
"include": ["*.ts", "src"],
"exclude": ["node_modules"]
}

View File

@@ -25,7 +25,7 @@
"dependencies": {
"@homarr/ui": "workspace:^0.1.0",
"@mantine/notifications": "^7.12.2",
"@tabler/icons-react": "^3.16.0"
"@tabler/icons-react": "^3.17.0"
},
"devDependencies": {
"@homarr/eslint-config": "workspace:^0.2.0",

View File

@@ -1,145 +0,0 @@
# Spotlight
Spotlight is the search functionality of Homarr. It can be opened by pressing `Ctrl + K` or `Cmd + K` on Mac. It is a quick way to search for anything in Homarr.
## API
### SpotlightActionData
The [SpotlightActionData](./src/type.ts) is the data structure that is used to define the actions that are shown in the spotlight.
#### Common properties
| Name | Type | Description |
| ------------------------------ | ------------------------------------------------------------- | ----------------------------------------------------------------------------------- |
| id | `string` | The id of the action. |
| title | `string \| (t: TranslationFunction) => string` | The title of the action. Either static or generated with translation function |
| description | `string \| (t: TranslationFunction) => string` | The description of the action. Either static or generated with translation function |
| icon | `string \| TablerIcon` | The icon of the action. Either a url to an image or a TablerIcon |
| group | `string` | The group of the action. By default the groups all, web and action exist. |
| ignoreSearchAndOnlyShowInGroup | `boolean` | If true, the action will only be shown in the group and not in the search results. |
| type | `'link' \| 'button'` | The type of the action. Either link or button |
#### Properties for links
| Name | Type | Description |
| ---- | -------- | ---------------------------------------------------------------------------------------------------------- |
| href | `string` | The url the link should navigate to. If %s is contained it will be replaced with the current search query. |
#### Properties for buttons
| Name | Type | Description |
| ------- | -------------------------- | ----------------------------------------------------------------------------------------- |
| onClick | `() => MaybePromise<void>` | The function that should be called when the button is clicked. It can be async if needed. |
### useRegisterSpotlightActions
The [useRegisterSpotlightActions](./src/data-store.ts) hook is used to register actions to the spotlight. It takes an unique key and the array of [SpotlightActionData](#SpotlightActionData).
#### Usage
The following example shows how to use the `useRegisterSpotlightActions` hook to register an action to the spotlight.
```tsx
"use client";
import { useRegisterSpotlightActions } from "@homarr/spotlight";
const MyComponent = () => {
useRegisterSpotlightActions("my-component", [
{
id: "my-action",
title: "My Action",
description: "This is my action",
icon: "https://example.com/icon.png",
group: "web",
type: "link",
href: "https://example.com",
},
]);
return <div>My Component</div>;
};
```
##### Using translation function
```tsx
"use client";
import { useRegisterSpotlightActions } from "@homarr/spotlight";
const MyComponent = () => {
useRegisterSpotlightActions("my-component", [
{
id: "my-action",
title: (t) => t("some.path.to.translation.key"),
description: (t) => t("some.other.path.to.translation.key"),
icon: "https://example.com/icon.png",
group: "web",
type: "link",
href: "https://example.com",
},
]);
return <div>Component implementation</div>;
};
```
##### Using TablerIcon
```tsx
"use client";
import { IconUserCog } from "tabler-react";
import { useRegisterSpotlightActions } from "@homarr/spotlight";
const UserMenu = () => {
useRegisterSpotlightActions("header-user-menu", [
{
id: "user-preferences",
title: (t) => t("user.preferences.title"),
description: (t) => t("user.preferences.description"),
icon: IconUserCog,
group: "action",
type: "link",
href: "/user/preferences",
},
]);
return <div>Component implementation</div>;
};
```
##### Using dependency array
```tsx
"use client";
import { IconUserCog } from "tabler-react";
import { useRegisterSpotlightActions } from "@homarr/spotlight";
const ColorSchemeButton = () => {
const { colorScheme, toggleColorScheme } = useColorScheme();
useRegisterSpotlightActions(
"toggle-color-scheme",
[
{
id: "toggle-color-scheme",
title: (t) => t("common.colorScheme.toggle.title"),
description: (t) => t(`common.colorScheme.toggle.${colorScheme}.description`),
icon: colorScheme === "light" ? IconSun : IconMoon,
group: "action",
type: "button",
onClick: toggleColorScheme,
},
],
[colorScheme],
);
return <div>Component implementation</div>;
};
```

View File

@@ -21,16 +21,21 @@
"lint": "eslint",
"typecheck": "tsc --noEmit"
},
"prettier": "@homarr/prettier-config",
"dependencies": {
"@homarr/api": "workspace:^0.1.0",
"@homarr/auth": "workspace:^0.1.0",
"@homarr/common": "workspace:^0.1.0",
"@homarr/definitions": "workspace:^0.1.0",
"@homarr/modals": "workspace:^0.1.0",
"@homarr/modals-collection": "workspace:^0.1.0",
"@homarr/translation": "workspace:^0.1.0",
"@homarr/ui": "workspace:^0.1.0",
"@mantine/core": "^7.12.2",
"@mantine/hooks": "^7.12.2",
"@mantine/spotlight": "^7.12.2",
"@tabler/icons-react": "^3.16.0",
"jotai": "^2.9.3",
"next": "^14.2.11",
"@tabler/icons-react": "^3.17.0",
"jotai": "^2.10.0",
"next": "^14.2.13",
"react": "^18.3.1",
"use-deep-compare-effect": "^1.8.1"
},
@@ -40,5 +45,6 @@
"@homarr/tsconfig": "workspace:^0.1.0",
"eslint": "^9.10.0",
"typescript": "^5.6.2"
}
},
"prettier": "@homarr/prettier-config"
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

Some files were not shown because too many files have changed in this diff Show More