diff --git a/.github/workflows/deployment-docker-image.yml b/.github/workflows/deployment-docker-image.yml index 3611ae31e..a3bfd26df 100644 --- a/.github/workflows/deployment-docker-image.yml +++ b/.github/workflows/deployment-docker-image.yml @@ -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." diff --git a/Dockerfile b/Dockerfile index a0e2c5949..635a03172 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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' diff --git a/apps/nextjs/package.json b/apps/nextjs/package.json index 7b96c572f..fea56abdf 100644 --- a/apps/nextjs/package.json +++ b/apps/nextjs/package.json @@ -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", diff --git a/apps/nextjs/src/app/[locale]/_client-providers/trpc.tsx b/apps/nextjs/src/app/[locale]/_client-providers/trpc.tsx index 8583ac24f..c08542590 100644 --- a/apps/nextjs/src/app/[locale]/_client-providers/trpc.tsx +++ b/apps/nextjs/src/app/[locale]/_client-providers/trpc.tsx @@ -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) { diff --git a/apps/nextjs/src/app/[locale]/auth/login/_login-form.tsx b/apps/nextjs/src/app/[locale]/auth/login/_login-form.tsx index 6b144d35a..0c745c656 100644 --- a/apps/nextjs/src/app/[locale]/auth/login/_login-form.tsx +++ b/apps/nextjs/src/app/[locale]/auth/login/_login-form.tsx @@ -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; diff --git a/apps/nextjs/src/app/[locale]/boards/(content)/_header-actions.tsx b/apps/nextjs/src/app/[locale]/boards/(content)/_header-actions.tsx index 27980758b..0782c1f94 100644 --- a/apps/nextjs/src/app/[locale]/boards/(content)/_header-actions.tsx +++ b/apps/nextjs/src/app/[locale]/boards/(content)/_header-actions.tsx @@ -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"; diff --git a/apps/nextjs/src/app/[locale]/manage/apps/_app-delete-button.tsx b/apps/nextjs/src/app/[locale]/manage/apps/_app-delete-button.tsx index dd20241d3..bdc520579 100644 --- a/apps/nextjs/src/app/[locale]/manage/apps/_app-delete-button.tsx +++ b/apps/nextjs/src/app/[locale]/manage/apps/_app-delete-button.tsx @@ -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]; } diff --git a/apps/nextjs/src/app/[locale]/manage/apps/edit/[id]/_app-edit-form.tsx b/apps/nextjs/src/app/[locale]/manage/apps/edit/[id]/_app-edit-form.tsx index 5fda90040..9c34c160a 100644 --- a/apps/nextjs/src/app/[locale]/manage/apps/edit/[id]/_app-edit-form.tsx +++ b/apps/nextjs/src/app/[locale]/manage/apps/edit/[id]/_app-edit-form.tsx @@ -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 { diff --git a/apps/nextjs/src/app/[locale]/manage/apps/new/_app-new-form.tsx b/apps/nextjs/src/app/[locale]/manage/apps/new/_app-new-form.tsx index 171dec027..42a9ea40f 100644 --- a/apps/nextjs/src/app/[locale]/manage/apps/new/_app-new-form.tsx +++ b/apps/nextjs/src/app/[locale]/manage/apps/new/_app-new-form.tsx @@ -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 = () => { diff --git a/apps/nextjs/src/app/[locale]/manage/boards/_components/board-card-menu-dropdown.tsx b/apps/nextjs/src/app/[locale]/manage/boards/_components/board-card-menu-dropdown.tsx index 0da1a442d..dd7794db8 100644 --- a/apps/nextjs/src/app/[locale]/manage/boards/_components/board-card-menu-dropdown.tsx +++ b/apps/nextjs/src/app/[locale]/manage/boards/_components/board-card-menu-dropdown.tsx @@ -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 = { diff --git a/apps/nextjs/src/app/[locale]/manage/boards/_components/create-board-button.tsx b/apps/nextjs/src/app/[locale]/manage/boards/_components/create-board-button.tsx index 864c5ea83..0aa295481 100644 --- a/apps/nextjs/src/app/[locale]/manage/boards/_components/create-board-button.tsx +++ b/apps/nextjs/src/app/[locale]/manage/boards/_components/create-board-button.tsx @@ -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 = ( <> - @@ -57,7 +25,7 @@ export const CreateBoardButton = ({ boardNames }: CreateBoardButtonProps) => { - }> + }> {t("board.action.oldImport.label")} diff --git a/apps/nextjs/src/app/[locale]/manage/boards/page.tsx b/apps/nextjs/src/app/[locale]/manage/boards/page.tsx index 506af6a99..482701639 100644 --- a/apps/nextjs/src/app/[locale]/manage/boards/page.tsx +++ b/apps/nextjs/src/app/[locale]/manage/boards/page.tsx @@ -39,7 +39,7 @@ export default async function ManageBoardsPage() { {t("title")} - board.name)} /> + diff --git a/apps/nextjs/src/app/[locale]/manage/integrations/_integration-buttons.tsx b/apps/nextjs/src/app/[locale]/manage/integrations/_integration-buttons.tsx index 3640dc790..7ccbde09f 100644 --- a/apps/nextjs/src/app/[locale]/manage/integrations/_integration-buttons.tsx +++ b/apps/nextjs/src/app/[locale]/manage/integrations/_integration-buttons.tsx @@ -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 }; diff --git a/apps/nextjs/src/app/[locale]/manage/integrations/edit/[id]/_integration-edit-form.tsx b/apps/nextjs/src/app/[locale]/manage/integrations/edit/[id]/_integration-edit-form.tsx index 847479ea3..60707e35a 100644 --- a/apps/nextjs/src/app/[locale]/manage/integrations/edit/[id]/_integration-edit-form.tsx +++ b/apps/nextjs/src/app/[locale]/manage/integrations/edit/[id]/_integration-edit-form.tsx @@ -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"; diff --git a/apps/nextjs/src/app/[locale]/manage/integrations/edit/[id]/page.tsx b/apps/nextjs/src/app/[locale]/manage/integrations/edit/[id]/page.tsx index a2b557522..7fa82f019 100644 --- a/apps/nextjs/src/app/[locale]/manage/integrations/edit/[id]/page.tsx +++ b/apps/nextjs/src/app/[locale]/manage/integrations/edit/[id]/page.tsx @@ -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 { diff --git a/apps/nextjs/src/app/[locale]/manage/integrations/new/_integration-new-dropdown.tsx b/apps/nextjs/src/app/[locale]/manage/integrations/new/_integration-new-dropdown.tsx index b2700ff70..188916e9e 100644 --- a/apps/nextjs/src/app/[locale]/manage/integrations/new/_integration-new-dropdown.tsx +++ b/apps/nextjs/src/app/[locale]/manage/integrations/new/_integration-new-dropdown.tsx @@ -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(); diff --git a/apps/nextjs/src/app/[locale]/manage/integrations/new/_integration-new-form.tsx b/apps/nextjs/src/app/[locale]/manage/integrations/new/_integration-new-form.tsx index 6d4ed9727..25eacf9b9 100644 --- a/apps/nextjs/src/app/[locale]/manage/integrations/new/_integration-new-form.tsx +++ b/apps/nextjs/src/app/[locale]/manage/integrations/new/_integration-new-form.tsx @@ -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> & { diff --git a/apps/nextjs/src/app/[locale]/manage/integrations/new/page.tsx b/apps/nextjs/src/app/[locale]/manage/integrations/new/page.tsx index 3dcc78ab2..6665a2417 100644 --- a/apps/nextjs/src/app/[locale]/manage/integrations/new/page.tsx +++ b/apps/nextjs/src/app/[locale]/manage/integrations/new/page.tsx @@ -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 { diff --git a/apps/nextjs/src/app/[locale]/manage/integrations/page.tsx b/apps/nextjs/src/app/[locale]/manage/integrations/page.tsx index f3d1cc0af..7df483661 100644 --- a/apps/nextjs/src/app/[locale]/manage/integrations/page.tsx +++ b/apps/nextjs/src/app/[locale]/manage/integrations/page.tsx @@ -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"; diff --git a/apps/nextjs/src/app/[locale]/manage/layout.tsx b/apps/nextjs/src/app/[locale]/manage/layout.tsx index 613383fd1..58314d65a 100644 --- a/apps/nextjs/src/app/[locale]/manage/layout.tsx +++ b/apps/nextjs/src/app/[locale]/manage/layout.tsx @@ -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, diff --git a/apps/nextjs/src/app/[locale]/manage/settings/_components/analytics.settings.tsx b/apps/nextjs/src/app/[locale]/manage/settings/_components/analytics.settings.tsx index f56b4a1b9..21215e6d0 100644 --- a/apps/nextjs/src/app/[locale]/manage/settings/_components/analytics.settings.tsx +++ b/apps/nextjs/src/app/[locale]/manage/settings/_components/analytics.settings.tsx @@ -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; diff --git a/apps/nextjs/src/app/[locale]/manage/settings/_components/crawling-and-indexing.settings.tsx b/apps/nextjs/src/app/[locale]/manage/settings/_components/crawling-and-indexing.settings.tsx index a74c4cf47..b99f298ee 100644 --- a/apps/nextjs/src/app/[locale]/manage/settings/_components/crawling-and-indexing.settings.tsx +++ b/apps/nextjs/src/app/[locale]/manage/settings/_components/crawling-and-indexing.settings.tsx @@ -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; diff --git a/apps/nextjs/src/app/[locale]/manage/users/[userId]/general/_components/_change-home-board.tsx b/apps/nextjs/src/app/[locale]/manage/users/[userId]/general/_components/_change-home-board.tsx index bbc8f308c..b6fe54770 100644 --- a/apps/nextjs/src/app/[locale]/manage/users/[userId]/general/_components/_change-home-board.tsx +++ b/apps/nextjs/src/app/[locale]/manage/users/[userId]/general/_components/_change-home-board.tsx @@ -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 }[]; diff --git a/apps/nextjs/src/app/[locale]/manage/users/[userId]/general/_components/_delete-user-button.tsx b/apps/nextjs/src/app/[locale]/manage/users/[userId]/general/_components/_delete-user-button.tsx index c23202e79..7ca9f64d6 100644 --- a/apps/nextjs/src/app/[locale]/manage/users/[userId]/general/_components/_delete-user-button.tsx +++ b/apps/nextjs/src/app/[locale]/manage/users/[userId]/general/_components/_delete-user-button.tsx @@ -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"]; } diff --git a/apps/nextjs/src/app/[locale]/manage/users/[userId]/general/_components/_profile-avatar-form.tsx b/apps/nextjs/src/app/[locale]/manage/users/[userId]/general/_components/_profile-avatar-form.tsx index 7a61bc3de..00b7c0deb 100644 --- a/apps/nextjs/src/app/[locale]/manage/users/[userId]/general/_components/_profile-avatar-form.tsx +++ b/apps/nextjs/src/app/[locale]/manage/users/[userId]/general/_components/_profile-avatar-form.tsx @@ -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"]; } diff --git a/apps/nextjs/src/app/[locale]/manage/users/[userId]/general/_components/_profile-form.tsx b/apps/nextjs/src/app/[locale]/manage/users/[userId]/general/_components/_profile-form.tsx index 0921ccd61..0099dcbd8 100644 --- a/apps/nextjs/src/app/[locale]/manage/users/[userId]/general/_components/_profile-form.tsx +++ b/apps/nextjs/src/app/[locale]/manage/users/[userId]/general/_components/_profile-form.tsx @@ -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"]; } diff --git a/apps/nextjs/src/app/[locale]/manage/users/[userId]/security/_components/_change-password-form.tsx b/apps/nextjs/src/app/[locale]/manage/users/[userId]/security/_components/_change-password-form.tsx index 961ed23f3..e8ea4e96c 100644 --- a/apps/nextjs/src/app/[locale]/manage/users/[userId]/security/_components/_change-password-form.tsx +++ b/apps/nextjs/src/app/[locale]/manage/users/[userId]/security/_components/_change-password-form.tsx @@ -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"]; } diff --git a/apps/nextjs/src/app/[locale]/manage/users/groups/[id]/_delete-group.tsx b/apps/nextjs/src/app/[locale]/manage/users/groups/[id]/_delete-group.tsx index 5941a31a2..2ee946944 100644 --- a/apps/nextjs/src/app/[locale]/manage/users/groups/[id]/_delete-group.tsx +++ b/apps/nextjs/src/app/[locale]/manage/users/groups/[id]/_delete-group.tsx @@ -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; diff --git a/apps/nextjs/src/app/[locale]/manage/users/groups/[id]/_rename-group-form.tsx b/apps/nextjs/src/app/[locale]/manage/users/groups/[id]/_rename-group-form.tsx index fea242030..f5241161f 100644 --- a/apps/nextjs/src/app/[locale]/manage/users/groups/[id]/_rename-group-form.tsx +++ b/apps/nextjs/src/app/[locale]/manage/users/groups/[id]/_rename-group-form.tsx @@ -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; diff --git a/apps/nextjs/src/app/[locale]/manage/users/groups/[id]/members/_add-group-member.tsx b/apps/nextjs/src/app/[locale]/manage/users/groups/[id]/members/_add-group-member.tsx index adc364f99..745af9e3e 100644 --- a/apps/nextjs/src/app/[locale]/manage/users/groups/[id]/members/_add-group-member.tsx +++ b/apps/nextjs/src/app/[locale]/manage/users/groups/[id]/members/_add-group-member.tsx @@ -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"; diff --git a/apps/nextjs/src/app/[locale]/manage/users/groups/[id]/members/_remove-group-member.tsx b/apps/nextjs/src/app/[locale]/manage/users/groups/[id]/members/_remove-group-member.tsx index be9b9ff78..1fba4ccaa 100644 --- a/apps/nextjs/src/app/[locale]/manage/users/groups/[id]/members/_remove-group-member.tsx +++ b/apps/nextjs/src/app/[locale]/manage/users/groups/[id]/members/_remove-group-member.tsx @@ -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 }; diff --git a/apps/nextjs/src/app/[locale]/manage/users/groups/_add-group.tsx b/apps/nextjs/src/app/[locale]/manage/users/groups/_add-group.tsx index ed6e6dd3d..f3febd49c 100644 --- a/apps/nextjs/src/app/[locale]/manage/users/groups/_add-group.tsx +++ b/apps/nextjs/src/app/[locale]/manage/users/groups/_add-group.tsx @@ -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 = () => { ); }; - -const AddGroupModal = createModal(({ actions }) => { - const t = useI18n(); - const { mutate, isPending } = clientApi.group.createGroup.useMutation(); - const form = useZodForm(validation.group.create, { - initialValues: { - name: "", - }, - }); - - return ( -
{ - 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"), - }); - }, - }); - })} - > - - - - - - - -
- ); -}).withOptions({ - defaultTitle: (t) => t("group.action.create.label"), -}); diff --git a/apps/nextjs/src/app/[locale]/manage/users/invites/_components/invite-list.tsx b/apps/nextjs/src/app/[locale]/manage/users/invites/_components/invite-list.tsx index d5aea273e..8e6d59e62 100644 --- a/apps/nextjs/src/app/[locale]/manage/users/invites/_components/invite-list.tsx +++ b/apps/nextjs/src/app/[locale]/manage/users/invites/_components/invite-list.tsx @@ -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 { diff --git a/apps/nextjs/src/components/board/items/item-menu.tsx b/apps/nextjs/src/components/board/items/item-menu.tsx index c02f915cd..4c4d9642f 100644 --- a/apps/nextjs/src/components/board/items/item-menu.tsx +++ b/apps/nextjs/src/components/board/items/item-menu.tsx @@ -107,7 +107,7 @@ export const BoardItemMenu = ({ }} > {tItem("action.moveResize")} -
{" "} +
} onClick={() => duplicateItem({ itemId: item.id })}> {tItem("action.duplicate")} diff --git a/apps/nextjs/src/components/icons/picker/icon-picker.tsx b/apps/nextjs/src/components/icons/picker/icon-picker.tsx index 51143cd01..91983ff7c 100644 --- a/apps/nextjs/src/components/icons/picker/icon-picker.tsx +++ b/apps/nextjs/src/components/icons/picker/icon-picker.tsx @@ -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(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")} /> - {t("iconPicker.header", { countIcons: data?.countIcons })} + {tCommon("iconPicker.header", { countIcons: data?.countIcons })} {totalOptions > 0 ? ( diff --git a/apps/nextjs/src/components/layout/header/search.tsx b/apps/nextjs/src/components/layout/header/search.tsx index 4dad0404f..9b2edd78f 100644 --- a/apps/nextjs/src/components/layout/header/search.tsx +++ b/apps/nextjs/src/components/layout/header/search.tsx @@ -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 ( { leftSection={} onClick={openSpotlight} > - {t("placeholder")} + {t("common.rtl", { + value: t("search.placeholder"), + symbol: "...", + })} ); }; diff --git a/apps/nextjs/src/components/manage/boards/add-board-modal.tsx b/apps/nextjs/src/components/manage/boards/add-board-modal.tsx deleted file mode 100644 index e3bf35f8a..000000000 --- a/apps/nextjs/src/components/manage/boards/add-board-modal.tsx +++ /dev/null @@ -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; -} - -export const AddBoardModal = createModal(({ 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 ( -
{ - void innerProps.onSuccess(values); - actions.closeModal(); - })} - > - - - - - - - - - - - - - -
- ); -}).withOptions({ - defaultTitle: (t) => t("management.page.board.action.new.label"), -}); diff --git a/apps/nextjs/src/env.mjs b/apps/nextjs/src/env.mjs index d5b00da89..bdef43eb0 100644 --- a/apps/nextjs/src/env.mjs +++ b/apps/nextjs/src/env.mjs @@ -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(), diff --git a/nginx.conf b/nginx.conf new file mode 100644 index 000000000..25e571394 --- /dev/null +++ b/nginx.conf @@ -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; + } + } +} \ No newline at end of file diff --git a/package.json b/package.json index 48194bc67..f14ca30ad 100644 --- a/package.json +++ b/package.json @@ -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" }, diff --git a/packages/api/package.json b/packages/api/package.json index 782e0a281..e833971dc 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -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" diff --git a/packages/api/src/client.ts b/packages/api/src/client.ts index 48f89771d..0d7c95f38 100644 --- a/packages/api/src/client.ts +++ b/packages/api/src/client.ts @@ -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(); +export const fetchApi = createTRPCClient({ + 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}`; +} diff --git a/packages/api/src/router/app.ts b/packages/api/src/router/app.ts index 38171a946..714828838 100644 --- a/packages/api/src/router/app.ts +++ b/packages/api/src/router/app.ts @@ -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), diff --git a/packages/api/src/router/board.ts b/packages/api/src/router/board.ts index 0a937f374..bc1fba67a 100644 --- a/packages/api/src/router/board.ts +++ b/packages/api/src/router/board.ts @@ -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) diff --git a/packages/api/src/router/group.ts b/packages/api/src/router/group.ts index 7a47e7d3e..639a5bf15 100644 --- a/packages/api/src/router/group.ts +++ b/packages/api/src/router/group.ts @@ -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); diff --git a/packages/api/src/router/integration/integration-router.ts b/packages/api/src/router/integration/integration-router.ts index 5ac6c4350..196c30761 100644 --- a/packages/api/src/router/integration/integration-router.ts +++ b/packages/api/src/router/integration/integration-router.ts @@ -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({ diff --git a/packages/api/src/router/user.ts b/packages/api/src/router/user.ts index 362b3b7d7..4d10a4652 100644 --- a/packages/api/src/router/user.ts +++ b/packages/api/src/router/user.ts @@ -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: { diff --git a/packages/auth/package.json b/packages/auth/package.json index eaca572c6..38a029fb0 100644 --- a/packages/auth/package.json +++ b/packages/auth/package.json @@ -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" }, diff --git a/packages/auth/permissions/board-permissions.ts b/packages/auth/permissions/board-permissions.ts index 817cced6f..c59d56a3e 100644 --- a/packages/auth/permissions/board-permissions.ts +++ b/packages/auth/permissions/board-permissions.ts @@ -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), }; }; diff --git a/packages/auth/permissions/integrations-with-permissions.ts b/packages/auth/permissions/integrations-with-permissions.ts index 98a25be66..64fe09087 100644 --- a/packages/auth/permissions/integrations-with-permissions.ts +++ b/packages/auth/permissions/integrations-with-permissions.ts @@ -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"; diff --git a/packages/common/package.json b/packages/common/package.json index 57eefabcd..91fb431e1 100644 --- a/packages/common/package.json +++ b/packages/common/package.json @@ -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", diff --git a/packages/common/src/client.ts b/packages/common/src/client.ts index ce5720136..1a9fcaadf 100644 --- a/packages/common/src/client.ts +++ b/packages/common/src/client.ts @@ -1 +1,2 @@ export * from "./app-url/client"; +export * from "./revalidate-path-action"; diff --git a/apps/nextjs/src/app/revalidatePathAction.ts b/packages/common/src/revalidate-path-action.ts similarity index 100% rename from apps/nextjs/src/app/revalidatePathAction.ts rename to packages/common/src/revalidate-path-action.ts diff --git a/packages/db/migrations/mysql/0004_noisy_giant_girl.sql b/packages/db/migrations/mysql/0004_noisy_giant_girl.sql index dd1732222..d6c3d4d36 100644 --- a/packages/db/migrations/mysql/0004_noisy_giant_girl.sql +++ b/packages/db/migrations/mysql/0004_noisy_giant_girl.sql @@ -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 diff --git a/packages/db/migrations/mysql/0006_young_micromax.sql b/packages/db/migrations/mysql/0006_young_micromax.sql index 9cf760f08..46550bf1a 100644 --- a/packages/db/migrations/mysql/0006_young_micromax.sql +++ b/packages/db/migrations/mysql/0006_young_micromax.sql @@ -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; \ No newline at end of file diff --git a/packages/db/migrations/mysql/meta/0004_snapshot.json b/packages/db/migrations/mysql/meta/0004_snapshot.json index 5f8bfaecf..24a1de0cc 100644 --- a/packages/db/migrations/mysql/meta/0004_snapshot.json +++ b/packages/db/migrations/mysql/meta/0004_snapshot.json @@ -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 diff --git a/packages/db/migrations/mysql/meta/0005_snapshot.json b/packages/db/migrations/mysql/meta/0005_snapshot.json index 123e68089..59a34b677 100644 --- a/packages/db/migrations/mysql/meta/0005_snapshot.json +++ b/packages/db/migrations/mysql/meta/0005_snapshot.json @@ -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 diff --git a/packages/db/migrations/mysql/meta/0006_snapshot.json b/packages/db/migrations/mysql/meta/0006_snapshot.json index 784cb4ef2..2e1dd71a4 100644 --- a/packages/db/migrations/mysql/meta/0006_snapshot.json +++ b/packages/db/migrations/mysql/meta/0006_snapshot.json @@ -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 diff --git a/packages/db/migrations/mysql/meta/0007_snapshot.json b/packages/db/migrations/mysql/meta/0007_snapshot.json index b1b162698..bba63722f 100644 --- a/packages/db/migrations/mysql/meta/0007_snapshot.json +++ b/packages/db/migrations/mysql/meta/0007_snapshot.json @@ -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 diff --git a/packages/db/migrations/sqlite/meta/0004_snapshot.json b/packages/db/migrations/sqlite/meta/0004_snapshot.json index 2d0f12df4..b5cc11ab0 100644 --- a/packages/db/migrations/sqlite/meta/0004_snapshot.json +++ b/packages/db/migrations/sqlite/meta/0004_snapshot.json @@ -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": {} diff --git a/packages/db/migrations/sqlite/meta/0005_snapshot.json b/packages/db/migrations/sqlite/meta/0005_snapshot.json index 90a6c7d32..99770a1c7 100644 --- a/packages/db/migrations/sqlite/meta/0005_snapshot.json +++ b/packages/db/migrations/sqlite/meta/0005_snapshot.json @@ -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": {} diff --git a/packages/db/migrations/sqlite/meta/0006_snapshot.json b/packages/db/migrations/sqlite/meta/0006_snapshot.json index 899500eb8..aa727f8dc 100644 --- a/packages/db/migrations/sqlite/meta/0006_snapshot.json +++ b/packages/db/migrations/sqlite/meta/0006_snapshot.json @@ -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": {} diff --git a/packages/db/migrations/sqlite/meta/0007_snapshot.json b/packages/db/migrations/sqlite/meta/0007_snapshot.json index 85bccfaea..9d256da25 100644 --- a/packages/db/migrations/sqlite/meta/0007_snapshot.json +++ b/packages/db/migrations/sqlite/meta/0007_snapshot.json @@ -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": {} diff --git a/packages/db/package.json b/packages/db/package.json index 663150cb6..29589d74b 100644 --- a/packages/db/package.json +++ b/packages/db/package.json @@ -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", diff --git a/packages/db/schema/mysql.ts b/packages/db/schema/mysql.ts index 32cabdbe5..e89942d43 100644 --- a/packages/db/schema/mysql.ts +++ b/packages/db/schema/mysql.ts @@ -171,7 +171,7 @@ export const integrationUserPermissions = mysqlTable( userId: varchar("user_id", { length: 64 }) .notNull() .references(() => users.id, { onDelete: "cascade" }), - permission: text("permission").$type().notNull(), + permission: varchar("permission", { length: 128 }).$type().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().notNull(), + permission: varchar("permission", { length: 128 }).$type().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", }), }); diff --git a/packages/db/test/mysql-migration.spec.ts b/packages/db/test/mysql-migration.spec.ts new file mode 100644 index 000000000..d7dad22d0 --- /dev/null +++ b/packages/db/test/mysql-migration.spec.ts @@ -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); +}); diff --git a/packages/icons/src/icons-fetcher.ts b/packages/icons/src/icons-fetcher.ts index 32ba31af8..ba60296a7 100644 --- a/packages/icons/src/icons-fetcher.ts +++ b/packages/icons/src/icons-fetcher.ts @@ -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", diff --git a/packages/icons/src/repositories/github.icon-repository.ts b/packages/icons/src/repositories/github.icon-repository.ts index 695ade2a0..eb5e92b90 100644 --- a/packages/icons/src/repositories/github.icon-repository.ts +++ b/packages/icons/src/repositories/github.icon-repository.ts @@ -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, diff --git a/packages/icons/src/repositories/icon-repository.ts b/packages/icons/src/repositories/icon-repository.ts index 358e48dfa..525ca1422 100644 --- a/packages/icons/src/repositories/icon-repository.ts +++ b/packages/icons/src/repositories/icon-repository.ts @@ -29,8 +29,4 @@ export abstract class IconRepository { } protected abstract getAllIconsInternalAsync(): Promise; - - protected getFileNameWithoutExtensionFromPath(path: string) { - return path.replace(/^.*[\\/]/, ""); - } } diff --git a/packages/icons/src/repositories/jsdelivr.icon-repository.ts b/packages/icons/src/repositories/jsdelivr.icon-repository.ts index 1c153d5b2..c611fcd7e 100644 --- a/packages/icons/src/repositories/jsdelivr.icon-repository.ts +++ b/packages/icons/src/repositories/jsdelivr.icon-repository.ts @@ -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, diff --git a/packages/icons/src/types/icon-repository-license.ts b/packages/icons/src/types/icon-repository-license.ts index a3ebe8728..1a01ca02d 100644 --- a/packages/icons/src/types/icon-repository-license.ts +++ b/packages/icons/src/types/icon-repository-license.ts @@ -1 +1 @@ -export type IconRepositoryLicense = "MIT" | "GPL-3.0" | undefined; +export type IconRepositoryLicense = "MIT" | "GPL-3.0" | "CC0-1.0" | undefined; diff --git a/packages/integrations/package.json b/packages/integrations/package.json index 206b71a33..c60ca8133 100644 --- a/packages/integrations/package.json +++ b/packages/integrations/package.json @@ -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", diff --git a/packages/integrations/src/download-client/nzbget/nzbget-integration.ts b/packages/integrations/src/download-client/nzbget/nzbget-integration.ts index 824a47a9c..5e91f393b 100644 --- a/packages/integrations/src/download-client/nzbget/nzbget-integration.ts +++ b/packages/integrations/src/download-client/nzbget/nzbget-integration.ts @@ -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 { - const client = this.getClient(); - await client.version(); + await this.nzbGetApiCallAsync("version"); } public async getClientJobsAndStatusAsync(): Promise { 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 { - 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 { - await this.getClient().editqueue("GroupResume", "", [Number(id)]); + await this.nzbGetApiCallAsync("editqueue", "GroupResume", "", [Number(id)]); } public async deleteItemAsync({ id, progress }: DownloadClientItem, fromDisk: boolean): Promise { - 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( + method: CallType, + ...params: Parameters + ): Promise> { const url = new URL(this.integration.url); url.pathname += `${this.getSecretValue("username")}:${this.getSecretValue("password")}`; url.pathname += url.pathname.endsWith("/") ? "jsonrpc" : "/jsonrpc"; - return rpcClient(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 }).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"] { diff --git a/packages/integrations/test/nzbget.spec.ts b/packages/integrations/test/nzbget.spec.ts index a23f081c7..b19b39d71 100644 --- a/packages/integrations/test/nzbget.spec.ts +++ b/packages/integrations/test/nzbget.spec.ts @@ -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(); diff --git a/packages/modals-collection/eslint.config.js b/packages/modals-collection/eslint.config.js new file mode 100644 index 000000000..5b19b6f8a --- /dev/null +++ b/packages/modals-collection/eslint.config.js @@ -0,0 +1,9 @@ +import baseConfig from "@homarr/eslint-config/base"; + +/** @type {import('typescript-eslint').Config} */ +export default [ + { + ignores: [], + }, + ...baseConfig, +]; diff --git a/packages/modals-collection/index.ts b/packages/modals-collection/index.ts new file mode 100644 index 000000000..3bd16e178 --- /dev/null +++ b/packages/modals-collection/index.ts @@ -0,0 +1 @@ +export * from "./src"; diff --git a/packages/modals-collection/package.json b/packages/modals-collection/package.json new file mode 100644 index 000000000..dbfadb433 --- /dev/null +++ b/packages/modals-collection/package.json @@ -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" +} diff --git a/packages/modals-collection/src/boards/add-board-modal.tsx b/packages/modals-collection/src/boards/add-board-modal.tsx new file mode 100644 index 000000000..45990aad0 --- /dev/null +++ b/packages/modals-collection/src/boards/add-board-modal.tsx @@ -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 ( +
{ + // 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`, + }); + }, + }); + })} + > + + + {boardNameStatus.description.icon ? : null} + {boardNameStatus.description.label} + + ) : null + } + /> + + + + + + + + + + + +
+ ); +}).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", + }, + }; +}; diff --git a/apps/nextjs/src/components/manage/boards/import-board-modal.tsx b/packages/modals-collection/src/boards/import-board-modal.tsx similarity index 85% rename from apps/nextjs/src/components/manage/boards/import-board-modal.tsx rename to packages/modals-collection/src/boards/import-board-modal.tsx index bf201477f..82caa0b75 100644 --- a/apps/nextjs/src/components/manage/boards/import-board-modal.tsx +++ b/packages/modals-collection/src/boards/import-board-modal.tsx @@ -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(({ 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(({ 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(({ actions, innerProps } return (
{ - if (!fileValid) { + if (!fileValid || !boardNameStatus.canSubmit) { return; } @@ -139,7 +138,19 @@ export const ImportBoardModal = createModal(({ actions, innerProps } - + + {boardNameStatus.description.icon ? : null} + {boardNameStatus.description.label} + + ) : null + } + {...form.getInputProps("configuration.name")} + /> (({ actions }) => { + const t = useI18n(); + const { mutate, isPending } = clientApi.group.createGroup.useMutation(); + const form = useZodForm(validation.group.create, { + initialValues: { + name: "", + }, + }); + + return ( + { + 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"), + }); + }, + }); + })} + > + + + + + + + + + ); +}).withOptions({ + defaultTitle: (t) => t("group.action.create.label"), +}); diff --git a/packages/modals-collection/src/groups/index.ts b/packages/modals-collection/src/groups/index.ts new file mode 100644 index 000000000..428bea2ec --- /dev/null +++ b/packages/modals-collection/src/groups/index.ts @@ -0,0 +1 @@ +export { AddGroupModal } from "./add-group-modal"; diff --git a/packages/modals-collection/src/index.ts b/packages/modals-collection/src/index.ts new file mode 100644 index 000000000..45caf0411 --- /dev/null +++ b/packages/modals-collection/src/index.ts @@ -0,0 +1,3 @@ +export * from "./boards"; +export * from "./invites"; +export * from "./groups"; diff --git a/packages/modals-collection/src/invites/index.ts b/packages/modals-collection/src/invites/index.ts new file mode 100644 index 000000000..f55df7d2b --- /dev/null +++ b/packages/modals-collection/src/invites/index.ts @@ -0,0 +1,2 @@ +export { InviteCopyModal } from "./invite-copy-modal"; +export { InviteCreateModal } from "./invite-create-modal"; diff --git a/apps/nextjs/src/app/[locale]/manage/users/invites/_components/invite-copy-modal.tsx b/packages/modals-collection/src/invites/invite-copy-modal.tsx similarity index 100% rename from apps/nextjs/src/app/[locale]/manage/users/invites/_components/invite-copy-modal.tsx rename to packages/modals-collection/src/invites/invite-copy-modal.tsx diff --git a/apps/nextjs/src/app/[locale]/manage/users/invites/_components/invite-create-modal.tsx b/packages/modals-collection/src/invites/invite-create-modal.tsx similarity index 100% rename from apps/nextjs/src/app/[locale]/manage/users/invites/_components/invite-create-modal.tsx rename to packages/modals-collection/src/invites/invite-create-modal.tsx diff --git a/packages/modals-collection/tsconfig.json b/packages/modals-collection/tsconfig.json new file mode 100644 index 000000000..cbe8483d9 --- /dev/null +++ b/packages/modals-collection/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "@homarr/tsconfig/base.json", + "compilerOptions": { + "tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json" + }, + "include": ["*.ts", "src"], + "exclude": ["node_modules"] +} diff --git a/packages/notifications/package.json b/packages/notifications/package.json index 74cb86d91..6843ee867 100644 --- a/packages/notifications/package.json +++ b/packages/notifications/package.json @@ -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", diff --git a/packages/spotlight/ReadMe.md b/packages/spotlight/ReadMe.md deleted file mode 100644 index ac4122282..000000000 --- a/packages/spotlight/ReadMe.md +++ /dev/null @@ -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` | 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
My Component
; -}; -``` - -##### 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
Component implementation
; -}; -``` - -##### 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
Component implementation
; -}; -``` - -##### 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
Component implementation
; -}; -``` diff --git a/packages/spotlight/package.json b/packages/spotlight/package.json index baea3f973..029f0f7e5 100644 --- a/packages/spotlight/package.json +++ b/packages/spotlight/package.json @@ -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" } diff --git a/packages/spotlight/src/chip-group.tsx b/packages/spotlight/src/chip-group.tsx deleted file mode 100644 index d2cc08cc9..000000000 --- a/packages/spotlight/src/chip-group.tsx +++ /dev/null @@ -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) => { - 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) => { - 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("label[data-checked]"); - if (!label) return; - label.focus(); -}; - -interface Props { - group: SpotlightActionGroup; -} - -export const GroupChip = ({ group }: Props) => { - const t = useScopedI18n("common.search.group"); - return ( - - {t(group)} - - ); -}; diff --git a/packages/spotlight/src/component.tsx b/packages/spotlight/src/component.tsx deleted file mode 100644 index 59f67ef20..000000000 --- a/packages/spotlight/src/component.tsx +++ /dev/null @@ -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) => ( - - ) - : undefined; - - return ( - - - {item.icon && ( -
- {typeof item.icon !== "string" && } - {typeof item.icon === "string" && {item.title}} -
- )} - - - {item.title} - - {item.description && ( - - {item.description} - - )} - -
-
- ); - }); - - const onGroupChange = useCallback( - (group: string) => { - setSelectedAction(-1, spotlightStore); - setGroup(group); - }, - [setGroup, setSelectedAction], - ); - - return ( - - } - /> - - - - - - {groups.map((group) => ( - - ))} - - - - - - {items.length > 0 ? items : {t("common.search.nothingFound")}} - - - ); -}; - -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), -}); diff --git a/packages/spotlight/src/components/actions/children-actions.tsx b/packages/spotlight/src/components/actions/children-actions.tsx new file mode 100644 index 000000000..b7f36909d --- /dev/null +++ b/packages/spotlight/src/components/actions/children-actions.tsx @@ -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) => ( + + )); +}; diff --git a/packages/spotlight/src/components/actions/group-actions.tsx b/packages/spotlight/src/components/actions/group-actions.tsx new file mode 100644 index 000000000..ce2d8f370 --- /dev/null +++ b/packages/spotlight/src/components/actions/group-actions.tsx @@ -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> { + group: SearchGroup; + query: string; + setMode: (mode: keyof TranslationObject["search"]["mode"]) => void; + setChildrenOptions: (options: inferSearchInteractionOptions<"children">) => void; +} + +export const SpotlightGroupActions = >({ + group, + query, + setMode, + setChildrenOptions, +}: GroupActionsProps) => { + // 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 ; + } + + return filteredOptions.map((option) => ( + + )); + } + + if (options.isLoading) { + return ( +
+ +
+ ); + } + + if (options.isError) { + return
{t("search.error.fetch")}
; + } + + if (!options.data) { + return null; + } + + if (options.data.length === 0) { + return ; + } + + return options.data.map((option) => ( + + )); +}; diff --git a/packages/spotlight/src/components/actions/groups/action-group.tsx b/packages/spotlight/src/components/actions/groups/action-group.tsx new file mode 100644 index 000000000..c35fa2d35 --- /dev/null +++ b/packages/spotlight/src/components/actions/groups/action-group.tsx @@ -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) => ( + + {/*eslint-disable-next-line @typescript-eslint/no-explicit-any */} + + group={group} + query={query} + setMode={setMode} + setChildrenOptions={setChildrenOptions} + /> + + )); +}; diff --git a/packages/spotlight/src/component.module.css b/packages/spotlight/src/components/actions/items/action-item.module.css similarity index 100% rename from packages/spotlight/src/component.module.css rename to packages/spotlight/src/components/actions/items/action-item.module.css diff --git a/packages/spotlight/src/components/actions/items/children-action-item.tsx b/packages/spotlight/src/components/actions/items/children-action-item.tsx new file mode 100644 index 000000000..c36fb3c65 --- /dev/null +++ b/packages/spotlight/src/components/actions/items/children-action-item.tsx @@ -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["useActions"]>[number]; +} + +export const ChildrenActionItem = ({ childrenOptions, action, query }: ChildrenActionItemProps) => { + const interaction = action.useInteraction(childrenOptions.option, query); + + const renderRoot = + interaction.type === "link" + ? (props: Record) => { + return ; + } + : undefined; + + const onClick = interaction.type === "javaScript" ? interaction.onSelect : undefined; + + return ( + + + + ); +}; diff --git a/packages/spotlight/src/components/actions/items/group-action-item.tsx b/packages/spotlight/src/components/actions/items/group-action-item.tsx new file mode 100644 index 000000000..1b2ebe1c5 --- /dev/null +++ b/packages/spotlight/src/components/actions/items/group-action-item.tsx @@ -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> { + option: TOption; + query: string; + setMode: (mode: keyof TranslationObject["search"]["mode"]) => void; + setChildrenOptions: (options: inferSearchInteractionOptions<"children">) => void; + group: SearchGroup; +} + +export const SpotlightGroupActionItem = >({ + group, + query, + setMode, + setChildrenOptions, + option, +}: SpotlightGroupActionItemProps) => { + const interaction = group.useInteraction(option, query); + + const renderRoot = + interaction.type === "link" + ? (props: Record) => { + return ; + } + : 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 ( + + + + ); +}; diff --git a/packages/spotlight/src/components/no-results.tsx b/packages/spotlight/src/components/no-results.tsx new file mode 100644 index 000000000..e5fc52f99 --- /dev/null +++ b/packages/spotlight/src/components/no-results.tsx @@ -0,0 +1,9 @@ +import { Spotlight } from "@mantine/spotlight"; + +import { useI18n } from "@homarr/translation/client"; + +export const SpotlightNoResults = () => { + const t = useI18n(); + + return {t("search.nothingFound")}; +}; diff --git a/packages/spotlight/src/components/spotlight.tsx b/packages/spotlight/src/components/spotlight.tsx new file mode 100644 index 000000000..c9c9c2904 --- /dev/null +++ b/packages/spotlight/src/components/spotlight.tsx @@ -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("help"); + const [childrenOptions, setChildrenOptions] = useState | null>(null); + const t = useI18n(); + const inputRef = useRef(null); + const activeMode = useMemo(() => searchModes.find((searchMode) => searchMode.modeKey === mode), [mode]); + + if (!activeMode) { + return null; + } + + return ( + { + 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} + > + +
+ +
+ {activeMode.modeKey !== "help" ? {activeMode.character} : null} + + } + rightSection={ + mode === "help" ? undefined : ( + { + setMode("help"); + setChildrenOptions(null); + inputRef.current?.focus(); + }} + variant="subtle" + > + + + ) + } + value={query} + onKeyDown={(event) => { + if (query.length === 0 && mode !== "help" && event.key === "Backspace") { + setMode("help"); + setChildrenOptions(null); + } + }} + /> + + {childrenOptions ? ( + + + + ) : null} + + + {childrenOptions ? ( + + ) : ( + { + setMode(mode); + setChildrenOptions(null); + setTimeout(() => selectAction(0, spotlightStore)); + }} + setChildrenOptions={(options) => { + setChildrenOptions(options); + setQuery(""); + setTimeout(() => selectAction(0, spotlightStore)); + }} + query={query} + groups={activeMode.groups} + /> + )} + +
+ ); +}; diff --git a/packages/spotlight/src/data-store.ts b/packages/spotlight/src/data-store.ts deleted file mode 100644 index 0a2dbd52d..000000000 --- a/packages/spotlight/src/data-store.ts +++ /dev/null @@ -1,69 +0,0 @@ -import { useEffect } from "react"; -import { atom, useSetAtom } from "jotai"; -import useDeepCompareEffect from "use-deep-compare-effect"; - -import type { SpotlightActionData, SpotlightActionGroup } from "./type"; - -const defaultGroups = ["all", "web", "action"] as const; -const reversedDefaultGroups = [...defaultGroups].reverse() as string[]; -const actionsAtom = atom>({}); -export const actionsAtomRead = atom((get) => Object.values(get(actionsAtom)).flatMap((item) => item)); - -export const groupsAtomRead = atom((get) => - Array.from( - new Set( - get(actionsAtomRead) - .map((item) => item.group as SpotlightActionGroup) // Allow "all" group to be included in the list of groups - .concat(...defaultGroups), - ), - ) - .sort((groupA, groupB) => { - const groupAIndex = reversedDefaultGroups.indexOf(groupA); - const groupBIndex = reversedDefaultGroups.indexOf(groupB); - - // if both groups are not in the default groups, sort them by name (here reversed because we reverse the array afterwards) - if (groupAIndex === -1 && groupBIndex === -1) { - return groupB.localeCompare(groupA); - } - - return groupAIndex - groupBIndex; - }) - .reverse(), -); - -const registrations = new Map(); - -export const useRegisterSpotlightActions = ( - key: string, - actions: SpotlightActionData[], - dependencies: readonly unknown[] = [], -) => { - const setActions = useSetAtom(actionsAtom); - - // Use deep compare effect if there are dependencies for the actions, this supports deep compare of the action dependencies - const useSpecificEffect = dependencies.length >= 1 ? useDeepCompareEffect : useEffect; - - useSpecificEffect(() => { - if (!registrations.has(key) || dependencies.length >= 1) { - setActions((prev) => ({ - ...prev, - [key]: actions, - })); - } - registrations.set(key, (registrations.get(key) ?? 0) + 1); - - return () => { - if (registrations.get(key) === 1) { - setActions((prev) => { - const { [key]: _, ...rest } = prev; - return rest; - }); - } - - registrations.set(key, (registrations.get(key) ?? 0) - 1); - if (registrations.get(key) === 0) { - registrations.delete(key); - } - }; - }, [key, dependencies.length >= 1 ? dependencies : undefined]); -}; diff --git a/packages/spotlight/src/index.ts b/packages/spotlight/src/index.ts index d0e3aa43a..2d4207075 100644 --- a/packages/spotlight/src/index.ts +++ b/packages/spotlight/src/index.ts @@ -2,8 +2,7 @@ import { spotlightActions } from "./spotlight-store"; -export { Spotlight } from "./component"; -export { useRegisterSpotlightActions } from "./data-store"; +export { Spotlight } from "./components/spotlight"; export { openSpotlight }; const openSpotlight = spotlightActions.open; diff --git a/packages/spotlight/src/lib/children.ts b/packages/spotlight/src/lib/children.ts new file mode 100644 index 000000000..1a792a2b6 --- /dev/null +++ b/packages/spotlight/src/lib/children.ts @@ -0,0 +1,24 @@ +import type { ReactNode } from "react"; + +import type { inferSearchInteractionDefinition } from "./interaction"; + +export interface CreateChildrenOptionsProps> { + detailComponent: ({ options }: { options: TParentOptions }) => ReactNode; + useActions: (options: TParentOptions, query: string) => ChildrenAction[]; +} + +export interface ChildrenAction> { + key: string; + component: (option: TParentOptions) => JSX.Element; + useInteraction: (option: TParentOptions, query: string) => inferSearchInteractionDefinition<"link" | "javaScript">; + hide?: boolean | ((option: TParentOptions) => boolean); +} + +export const createChildrenOptions = >( + props: CreateChildrenOptionsProps, +) => { + return (option: TParentOptions) => ({ + option, + ...props, + }); +}; diff --git a/packages/spotlight/src/lib/group.ts b/packages/spotlight/src/lib/group.ts new file mode 100644 index 000000000..d1cfc4a44 --- /dev/null +++ b/packages/spotlight/src/lib/group.ts @@ -0,0 +1,28 @@ +import type { UseTRPCQueryResult } from "@trpc/react-query/shared"; + +import type { stringOrTranslation } from "@homarr/translation"; + +import type { inferSearchInteractionDefinition, SearchInteraction } from "./interaction"; + +type CommonSearchGroup, TOptionProps extends Record> = { + // key path is used to define the path to a unique key in the option object + keyPath: keyof TOption; + title: stringOrTranslation; + component: (option: TOption) => JSX.Element; + useInteraction: (option: TOption, query: string) => inferSearchInteractionDefinition; +} & TOptionProps; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export type SearchGroup = any> = + | CommonSearchGroup boolean; options: TOption[] }> + | CommonSearchGroup< + TOption, + { + filter: (query: string, option: TOption) => boolean; + sort?: (query: string, options: [TOption, TOption]) => number; + useOptions: () => TOption[]; + } + > + | CommonSearchGroup UseTRPCQueryResult }>; + +export const createGroup = >(group: SearchGroup) => group; diff --git a/packages/spotlight/src/lib/interaction.ts b/packages/spotlight/src/lib/interaction.ts new file mode 100644 index 000000000..1528a39ca --- /dev/null +++ b/packages/spotlight/src/lib/interaction.ts @@ -0,0 +1,56 @@ +import type { MaybePromise } from "@homarr/common/types"; +import type { TranslationObject } from "@homarr/translation"; + +import type { CreateChildrenOptionsProps } from "./children"; + +const createSearchInteraction = (type: TType) => ({ + optionsType: >() => ({ type, _inferOptions: {} as TOption }), +}); + +// This is used to define search interactions with their options +const searchInteractions = [ + createSearchInteraction("link").optionsType<{ href: string; newTab?: boolean }>(), + createSearchInteraction("javaScript").optionsType<{ onSelect: () => MaybePromise }>(), + createSearchInteraction("mode").optionsType<{ mode: keyof TranslationObject["search"]["mode"] }>(), + createSearchInteraction("children").optionsType<{ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + useActions: CreateChildrenOptionsProps["useActions"]; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + detailComponent: CreateChildrenOptionsProps["detailComponent"]; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + option: any; + }>(), +] as const; + +// Union of all search interactions types +export type SearchInteraction = (typeof searchInteractions)[number]["type"]; + +// Infer the options for the specified search interaction +export type inferSearchInteractionOptions = Extract< + (typeof searchInteractions)[number], + { type: TInteraction } +>["_inferOptions"]; + +// Infer the search interaction definition (type + options) for the specified search interaction +export type inferSearchInteractionDefinition = { + [interactionKey in TInteraction]: { type: interactionKey } & inferSearchInteractionOptions; +}[TInteraction]; + +// Type used for helper functions to define basic search interactions +type SearchInteractions = { + [optionKey in SearchInteraction]: >( + callback: (option: TOption, query: string) => inferSearchInteractionOptions, + ) => (option: TOption, query: string) => inferSearchInteractionDefinition; +}; + +// Helper functions to define basic search interactions +export const interaction = searchInteractions.reduce((acc, interaction) => { + return { + ...acc, + [interaction.type]: >( + callback: (option: TOption, query: string) => inferSearchInteractionOptions, + ) => { + return (option: TOption, query: string) => ({ type: interaction.type, ...callback(option, query) }); + }, + }; +}, {} as SearchInteractions); diff --git a/packages/spotlight/src/lib/mode.ts b/packages/spotlight/src/lib/mode.ts new file mode 100644 index 000000000..a0b480f3c --- /dev/null +++ b/packages/spotlight/src/lib/mode.ts @@ -0,0 +1,9 @@ +import type { TranslationObject } from "@homarr/translation"; + +import type { SearchGroup } from "./group"; + +export interface SearchMode { + modeKey: keyof TranslationObject["search"]["mode"]; + character: string; + groups: SearchGroup[]; +} diff --git a/packages/spotlight/src/modes/app-integration-board/apps-search-group.tsx b/packages/spotlight/src/modes/app-integration-board/apps-search-group.tsx new file mode 100644 index 000000000..5d2dc98a6 --- /dev/null +++ b/packages/spotlight/src/modes/app-integration-board/apps-search-group.tsx @@ -0,0 +1,97 @@ +import { Avatar, Group, Stack, Text } from "@mantine/core"; +import { IconExternalLink, IconEye } from "@tabler/icons-react"; + +import { clientApi } from "@homarr/api/client"; +import { useI18n } from "@homarr/translation/client"; + +import { createChildrenOptions } from "../../lib/children"; +import { createGroup } from "../../lib/group"; +import { interaction } from "../../lib/interaction"; + +// This has to be type so it can be interpreted as Record. +// eslint-disable-next-line @typescript-eslint/consistent-type-definitions +type App = { id: string; name: string; iconUrl: string; href: string | null }; + +const appChildrenOptions = createChildrenOptions({ + useActions: () => [ + { + key: "open", + component: () => { + const t = useI18n(); + + return ( + + + {t("search.mode.appIntegrationBoard.group.app.children.action.open.label")} + + ); + }, + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + useInteraction: interaction.link((option) => ({ href: option.href! })), + hide(option) { + return !option.href; + }, + }, + { + key: "edit", + component: () => { + const t = useI18n(); + + return ( + + + {t("search.mode.appIntegrationBoard.group.app.children.action.edit.label")} + + ); + }, + useInteraction: interaction.link(({ id }) => ({ href: `/manage/apps/edit/${id}` })), + }, + ], + detailComponent: ({ options }) => { + const t = useI18n(); + + return ( + + {t("search.mode.appIntegrationBoard.group.app.children.detail.title")} + + + + {options.name} + + + ); + }, +}); + +export const appsSearchGroup = createGroup({ + keyPath: "id", + title: (t) => t("search.mode.appIntegrationBoard.group.app.title"), + component: (app) => ( + + + {app.name} + + ), + useInteraction: interaction.children(appChildrenOptions), + useQueryOptions(query) { + return clientApi.app.search.useQuery({ query, limit: 5 }); + }, +}); diff --git a/packages/spotlight/src/modes/app-integration-board/boards-search-group.tsx b/packages/spotlight/src/modes/app-integration-board/boards-search-group.tsx new file mode 100644 index 000000000..089fb732c --- /dev/null +++ b/packages/spotlight/src/modes/app-integration-board/boards-search-group.tsx @@ -0,0 +1,120 @@ +import { Group, Stack, Text } from "@mantine/core"; +import { IconHome, IconLayoutDashboard, IconLink, IconSettings } from "@tabler/icons-react"; + +import { clientApi } from "@homarr/api/client"; +import { useI18n } from "@homarr/translation/client"; + +import type { ChildrenAction } from "../../lib/children"; +import { createChildrenOptions } from "../../lib/children"; +import { createGroup } from "../../lib/group"; +import { interaction } from "../../lib/interaction"; + +// This has to be type so it can be interpreted as Record. +// eslint-disable-next-line @typescript-eslint/consistent-type-definitions +type Board = { + id: string; + name: string; + logoImageUrl: string | null; + permissions: { hasFullAccess: boolean; hasChangeAccess: boolean; hasViewAccess: boolean }; +}; + +const boardChildrenOptions = createChildrenOptions({ + useActions: (options) => { + const actions: (ChildrenAction & { hidden?: boolean })[] = [ + { + key: "open", + component: () => { + const t = useI18n(); + + return ( + + + {t("search.mode.appIntegrationBoard.group.board.children.action.open.label")} + + ); + }, + useInteraction: interaction.link(({ name }) => ({ href: `/boards/${name}` })), + }, + { + key: "homeBoard", + component: () => { + const t = useI18n(); + + return ( + + + {t("search.mode.appIntegrationBoard.group.board.children.action.homeBoard.label")} + + ); + }, + useInteraction(option) { + const { mutateAsync } = clientApi.board.setHomeBoard.useMutation(); + + return { + type: "javaScript", + // eslint-disable-next-line no-restricted-syntax + async onSelect() { + await mutateAsync({ id: option.id }); + }, + }; + }, + }, + { + key: "settings", + component: () => { + const t = useI18n(); + + return ( + + + {t("search.mode.appIntegrationBoard.group.board.children.action.settings.label")} + + ); + }, + useInteraction: interaction.link(({ name }) => ({ href: `/boards/${name}/settings` })), + hidden: !options.permissions.hasChangeAccess, + }, + ]; + + return actions; + }, + detailComponent: ({ options: board }) => { + const t = useI18n(); + + return ( + + {t("search.mode.appIntegrationBoard.group.board.children.detail.title")} + + + {board.logoImageUrl ? ( + {board.name} + ) : ( + + )} + + {board.name} + + + ); + }, +}); + +export const boardsSearchGroup = createGroup({ + keyPath: "id", + title: "Boards", + component: (board) => ( + + {board.logoImageUrl ? ( + {board.name} + ) : ( + + )} + + {board.name} + + ), + useInteraction: interaction.children(boardChildrenOptions), + useQueryOptions(query) { + return clientApi.board.search.useQuery({ query, limit: 5 }); + }, +}); diff --git a/packages/spotlight/src/modes/app-integration-board/index.tsx b/packages/spotlight/src/modes/app-integration-board/index.tsx new file mode 100644 index 000000000..417fe967c --- /dev/null +++ b/packages/spotlight/src/modes/app-integration-board/index.tsx @@ -0,0 +1,10 @@ +import type { SearchMode } from "../../lib/mode"; +import { appsSearchGroup } from "./apps-search-group"; +import { boardsSearchGroup } from "./boards-search-group"; +import { integrationsSearchGroup } from "./integrations-search-group"; + +export const appIntegrationBoardMode = { + modeKey: "appIntegrationBoard", + character: "#", + groups: [appsSearchGroup, integrationsSearchGroup, boardsSearchGroup], +} satisfies SearchMode; diff --git a/packages/spotlight/src/modes/app-integration-board/integrations-search-group.tsx b/packages/spotlight/src/modes/app-integration-board/integrations-search-group.tsx new file mode 100644 index 000000000..55fd14eca --- /dev/null +++ b/packages/spotlight/src/modes/app-integration-board/integrations-search-group.tsx @@ -0,0 +1,24 @@ +import { Group, Text } from "@mantine/core"; + +import { clientApi } from "@homarr/api/client"; +import type { IntegrationKind } from "@homarr/definitions"; +import { IntegrationAvatar } from "@homarr/ui"; + +import { createGroup } from "../../lib/group"; +import { interaction } from "../../lib/interaction"; + +export const integrationsSearchGroup = createGroup<{ id: string; kind: IntegrationKind; name: string }>({ + keyPath: "id", + title: (t) => t("search.mode.appIntegrationBoard.group.integration.title"), + component: (integration) => ( + + + + {integration.name} + + ), + useInteraction: interaction.link(({ id }) => ({ href: `/manage/integrations/edit/${id}` })), + useQueryOptions(query) { + return clientApi.integration.search.useQuery({ query, limit: 5 }); + }, +}); diff --git a/packages/spotlight/src/modes/command/children/language.tsx b/packages/spotlight/src/modes/command/children/language.tsx new file mode 100644 index 000000000..7a248275a --- /dev/null +++ b/packages/spotlight/src/modes/command/children/language.tsx @@ -0,0 +1,65 @@ +import { Group, Stack, Text } from "@mantine/core"; +import { IconCheck } from "@tabler/icons-react"; + +import { localeAttributes, supportedLanguages } from "@homarr/translation"; +import { useChangeLocale, useCurrentLocale, useI18n } from "@homarr/translation/client"; + +import { createChildrenOptions } from "../../../lib/children"; + +export const languageChildrenOptions = createChildrenOptions>({ + useActions: (_, query) => { + const normalizedQuery = query.trim().toLowerCase(); + const currentLocale = useCurrentLocale(); + return supportedLanguages + .map((localeKey) => ({ localeKey, attributes: localeAttributes[localeKey] })) + .filter( + ({ attributes }) => + attributes.name.toLowerCase().includes(normalizedQuery) || + attributes.translatedName.toLowerCase().includes(normalizedQuery), + ) + .sort( + (languageA, languageB) => + Math.min( + languageA.attributes.name.toLowerCase().indexOf(normalizedQuery), + languageA.attributes.translatedName.toLowerCase().indexOf(normalizedQuery), + ) - + Math.min( + languageB.attributes.name.toLowerCase().indexOf(normalizedQuery), + languageB.attributes.translatedName.toLowerCase().indexOf(normalizedQuery), + ), + ) + .map(({ localeKey, attributes }) => ({ + key: localeKey, + component() { + return ( + + + + + {attributes.name} + + ({attributes.translatedName}) + + + + {localeKey === currentLocale && } + + ); + }, + useInteraction() { + const changeLocale = useChangeLocale(); + + return { type: "javaScript", onSelect: () => changeLocale(localeKey) }; + }, + })); + }, + detailComponent: () => { + const t = useI18n(); + + return ( + + {t("search.mode.command.group.globalCommand.option.language.children.detail.title")} + + ); + }, +}); diff --git a/packages/spotlight/src/modes/command/children/new-integration.tsx b/packages/spotlight/src/modes/command/children/new-integration.tsx new file mode 100644 index 000000000..d70a719f3 --- /dev/null +++ b/packages/spotlight/src/modes/command/children/new-integration.tsx @@ -0,0 +1,43 @@ +import { Group, Stack, Text } from "@mantine/core"; + +import { objectEntries } from "@homarr/common"; +import { integrationDefs } from "@homarr/definitions"; +import { useI18n } from "@homarr/translation/client"; +import { IntegrationAvatar } from "@homarr/ui"; + +import { createChildrenOptions } from "../../../lib/children"; +import { interaction } from "../../../lib/interaction"; + +export const newIntegrationChildrenOptions = createChildrenOptions>({ + useActions: (_, query) => { + const normalizedQuery = query.trim().toLowerCase(); + return objectEntries(integrationDefs) + .filter(([, integrationDef]) => integrationDef.name.toLowerCase().includes(normalizedQuery)) + .sort( + ([, definitionA], [, definitionB]) => + definitionA.name.toLowerCase().indexOf(normalizedQuery) - + definitionB.name.toLowerCase().indexOf(normalizedQuery), + ) + .map(([kind, integrationDef]) => ({ + key: kind, + component() { + return ( + + + {integrationDef.name} + + ); + }, + useInteraction: interaction.link(() => ({ href: `/manage/integrations/new?kind=${kind}` })), + })); + }, + detailComponent() { + const t = useI18n(); + + return ( + + {t("search.mode.command.group.globalCommand.option.newIntegration.children.detail.title")} + + ); + }, +}); diff --git a/packages/spotlight/src/modes/command/index.tsx b/packages/spotlight/src/modes/command/index.tsx new file mode 100644 index 000000000..240fd13b5 --- /dev/null +++ b/packages/spotlight/src/modes/command/index.tsx @@ -0,0 +1,164 @@ +import { Group, Text, useMantineColorScheme } from "@mantine/core"; +import { + IconCategoryPlus, + IconFileImport, + IconLanguage, + IconMailForward, + IconMoon, + IconPackage, + IconPlug, + IconSun, + IconUserPlus, + IconUsersGroup, +} from "@tabler/icons-react"; + +import { useModalAction } from "@homarr/modals"; +import { AddBoardModal, AddGroupModal, ImportBoardModal, InviteCreateModal } from "@homarr/modals-collection"; +import { useScopedI18n } from "@homarr/translation/client"; +import type { TablerIcon } from "@homarr/ui"; + +import { createGroup } from "../../lib/group"; +import type { inferSearchInteractionDefinition, SearchInteraction } from "../../lib/interaction"; +import { interaction } from "../../lib/interaction"; +import type { SearchMode } from "../../lib/mode"; +import { languageChildrenOptions } from "./children/language"; +import { newIntegrationChildrenOptions } from "./children/new-integration"; + +// This has to be type so it can be interpreted as Record. +// eslint-disable-next-line @typescript-eslint/consistent-type-definitions +type Command = { + commandKey: string; + icon: TablerIcon; + name: string; + useInteraction: ( + _c: Command, + query: string, + ) => inferSearchInteractionDefinition; +}; + +export const commandMode = { + modeKey: "command", + character: ">", + groups: [ + createGroup({ + keyPath: "commandKey", + title: "Global commands", + useInteraction: (option, query) => option.useInteraction(option, query), + component: ({ icon: Icon, name }) => ( + + + {name} + + ), + filter(query, option) { + return option.name.toLowerCase().includes(query.toLowerCase()); + }, + useOptions() { + const tOption = useScopedI18n("search.mode.command.group.globalCommand.option"); + const { colorScheme } = useMantineColorScheme(); + + const commands: (Command & { hidden?: boolean })[] = [ + { + commandKey: "colorScheme", + icon: colorScheme === "dark" ? IconSun : IconMoon, + name: tOption(`colorScheme.${colorScheme === "dark" ? "light" : "dark"}`), + useInteraction: () => { + const { toggleColorScheme } = useMantineColorScheme(); + + return { + type: "javaScript", + onSelect: toggleColorScheme, + }; + }, + }, + { + commandKey: "language", + icon: IconLanguage, + name: tOption("language.label"), + useInteraction: interaction.children(languageChildrenOptions), + }, + { + commandKey: "newBoard", + icon: IconCategoryPlus, + name: tOption("newBoard.label"), + useInteraction() { + const { openModal } = useModalAction(AddBoardModal); + + return { + type: "javaScript", + onSelect() { + openModal(undefined); + }, + }; + }, + }, + { + commandKey: "importBoard", + icon: IconFileImport, + name: tOption("importBoard.label"), + useInteraction() { + const { openModal } = useModalAction(ImportBoardModal); + + return { + type: "javaScript", + onSelect() { + openModal(undefined); + }, + }; + }, + }, + { + commandKey: "newApp", + icon: IconPackage, + name: tOption("newApp.label"), + useInteraction: interaction.link(() => ({ href: "/manage/apps/new" })), + }, + { + commandKey: "newIntegration", + icon: IconPlug, + name: tOption("newIntegration.label"), + useInteraction: interaction.children(newIntegrationChildrenOptions), + }, + { + commandKey: "newUser", + icon: IconUserPlus, + name: tOption("newUser.label"), + useInteraction: interaction.link(() => ({ href: "/manage/users/new" })), + }, + { + commandKey: "newInvite", + icon: IconMailForward, + name: tOption("newInvite.label"), + useInteraction() { + const { openModal } = useModalAction(InviteCreateModal); + + return { + type: "javaScript", + onSelect() { + openModal(undefined); + }, + }; + }, + }, + { + commandKey: "newGroup", + icon: IconUsersGroup, + name: tOption("newGroup.label"), + useInteraction() { + const { openModal } = useModalAction(AddGroupModal); + + return { + type: "javaScript", + onSelect() { + openModal(undefined); + }, + }; + }, + }, + ]; + + return commands.filter((command) => !command.hidden); + }, + }), + ], +} satisfies SearchMode; diff --git a/packages/spotlight/src/modes/external/index.tsx b/packages/spotlight/src/modes/external/index.tsx new file mode 100644 index 000000000..204d01e1d --- /dev/null +++ b/packages/spotlight/src/modes/external/index.tsx @@ -0,0 +1,8 @@ +import type { SearchMode } from "../../lib/mode"; +import { searchEnginesSearchGroups } from "./search-engines-search-group"; + +export const externalMode = { + modeKey: "external", + character: "!", + groups: [searchEnginesSearchGroups], +} satisfies SearchMode; diff --git a/packages/spotlight/src/modes/external/search-engines-search-group.tsx b/packages/spotlight/src/modes/external/search-engines-search-group.tsx new file mode 100644 index 000000000..45669ba52 --- /dev/null +++ b/packages/spotlight/src/modes/external/search-engines-search-group.tsx @@ -0,0 +1,82 @@ +import { Group, Stack, Text } from "@mantine/core"; +import type { TablerIcon } from "@tabler/icons-react"; +import { IconDownload } from "@tabler/icons-react"; + +import { useScopedI18n } from "@homarr/translation/client"; + +import { createGroup } from "../../lib/group"; +import { interaction } from "../../lib/interaction"; + +// This has to be type so it can be interpreted as Record. +// eslint-disable-next-line @typescript-eslint/consistent-type-definitions +type SearchEngine = { + short: string; + image: string | TablerIcon; + name: string; + description: string; + urlTemplate: string; +}; + +export const searchEnginesSearchGroups = createGroup({ + keyPath: "short", + title: (t) => t("search.mode.external.group.searchEngine.title"), + component: ({ image: Image, name, description }) => ( + + + {typeof Image === "string" ? {name} : } + + {name} + + {description} + + + + + ), + filter: () => true, + useInteraction: interaction.link(({ urlTemplate }, query) => ({ + href: urlTemplate.replace("%s", query), + newTab: true, + })), + useOptions() { + const tOption = useScopedI18n("search.mode.external.group.searchEngine.option"); + + return [ + { + short: "g", + name: tOption("google.name"), + image: "https://www.google.com/favicon.ico", + description: tOption("google.description"), + urlTemplate: "https://www.google.com/search?q=%s", + }, + { + short: "b", + name: tOption("bing.name"), + image: "https://www.bing.com/favicon.ico", + description: tOption("bing.description"), + urlTemplate: "https://www.bing.com/search?q=%s", + }, + { + short: "d", + name: tOption("duckduckgo.name"), + image: "https://duckduckgo.com/favicon.ico", + description: tOption("duckduckgo.description"), + urlTemplate: "https://duckduckgo.com/?q=%s", + }, + { + short: "t", + name: tOption("torrent.name"), + image: IconDownload, + description: tOption("torrent.description"), + urlTemplate: "https://www.torrentdownloads.pro/search/?search=%s", + }, + { + short: "y", + name: tOption("youTube.name"), + image: "https://www.youtube.com/favicon.ico", + description: tOption("youTube.description"), + urlTemplate: "https://www.youtube.com/results?search_query=%s", + }, + ]; + }, +}); diff --git a/packages/spotlight/src/modes/index.tsx b/packages/spotlight/src/modes/index.tsx new file mode 100644 index 000000000..29b0f220f --- /dev/null +++ b/packages/spotlight/src/modes/index.tsx @@ -0,0 +1,74 @@ +import { Group, Kbd, Text } from "@mantine/core"; +import { IconBook2, IconBrandDiscord, IconBrandGithub } from "@tabler/icons-react"; + +import { useScopedI18n } from "@homarr/translation/client"; + +import { createGroup } from "../lib/group"; +import { interaction } from "../lib/interaction"; +import type { SearchMode } from "../lib/mode"; +import { appIntegrationBoardMode } from "./app-integration-board"; +import { commandMode } from "./command"; +import { externalMode } from "./external"; +import { pageMode } from "./page"; +import { userGroupMode } from "./user-group"; + +const searchModesWithoutHelp = [userGroupMode, appIntegrationBoardMode, externalMode, commandMode, pageMode] as const; + +const helpMode = { + modeKey: "help", + character: "?", + groups: [ + createGroup({ + keyPath: "character", + title: (t) => t("search.mode.help.group.mode.title"), + options: searchModesWithoutHelp.map(({ character, modeKey }) => ({ character, modeKey })), + component: ({ modeKey, character }) => { + const t = useScopedI18n(`search.mode.${modeKey}`); + + return ( + + {t("help")} + {character} + + ); + }, + filter: () => true, + useInteraction: interaction.mode(({ modeKey }) => ({ mode: modeKey })), + }), + createGroup({ + keyPath: "href", + title: (t) => t("search.mode.help.group.help.title"), + useOptions() { + const t = useScopedI18n("search.mode.help.group.help.option"); + + return [ + { + label: t("documentation.label"), + icon: IconBook2, + href: "https://homarr.dev/docs/getting-started/", + }, + { + label: t("submitIssue.label"), + icon: IconBrandGithub, + href: "https://github.com/ajnart/homarr/issues/new/choose", + }, + { + label: t("discord.label"), + icon: IconBrandDiscord, + href: "https://discord.com/invite/aCsmEV5RgA", + }, + ]; + }, + component: (props) => ( + + + {props.label} + + ), + filter: () => true, + useInteraction: interaction.link(({ href }) => ({ href })), + }), + ], +} satisfies SearchMode; + +export const searchModes = [...searchModesWithoutHelp, helpMode] as const; diff --git a/packages/spotlight/src/modes/page/index.tsx b/packages/spotlight/src/modes/page/index.tsx new file mode 100644 index 000000000..39be2cb19 --- /dev/null +++ b/packages/spotlight/src/modes/page/index.tsx @@ -0,0 +1,8 @@ +import type { SearchMode } from "../../lib/mode"; +import { pagesSearchGroup } from "./pages-search-group"; + +export const pageMode = { + modeKey: "page", + character: "/", + groups: [pagesSearchGroup], +} satisfies SearchMode; diff --git a/packages/spotlight/src/modes/page/pages-search-group.tsx b/packages/spotlight/src/modes/page/pages-search-group.tsx new file mode 100644 index 000000000..d4ad2eb52 --- /dev/null +++ b/packages/spotlight/src/modes/page/pages-search-group.tsx @@ -0,0 +1,156 @@ +import { Group, Text } from "@mantine/core"; +import { + IconBox, + IconBrandDocker, + IconHome, + IconInfoSmall, + IconLayoutDashboard, + IconLogs, + IconMailForward, + IconPlug, + IconReport, + IconSettings, + IconUsers, + IconUsersGroup, +} from "@tabler/icons-react"; + +import { useSession } from "@homarr/auth/client"; +import { useScopedI18n } from "@homarr/translation/client"; +import type { TablerIcon } from "@homarr/ui"; + +import { createGroup } from "../../lib/group"; +import { interaction } from "../../lib/interaction"; + +export const pagesSearchGroup = createGroup<{ + icon: TablerIcon; + name: string; + path: string; +}>({ + keyPath: "path", + title: (t) => t("search.mode.page.group.page.title"), + component: ({ name, icon: Icon }) => ( + + + {name} + + ), + useInteraction: interaction.link(({ path }) => ({ href: path })), + filter: (query, { name, path }) => { + const normalizedQuery = query.trim().toLowerCase(); + return name.toLowerCase().includes(normalizedQuery) || path.toLowerCase().includes(normalizedQuery); + }, + sort: (query, options) => { + const normalizedQuery = query.trim().toLowerCase(); + + const nameMatches = options.map((option) => option.name.toLowerCase().includes(normalizedQuery)); + const pathMatches = options.map((option) => option.path.toLowerCase().includes(normalizedQuery)); + + if (nameMatches.every(Boolean) && pathMatches.every(Boolean)) { + return 0; + } + + if (nameMatches.every(Boolean) && !pathMatches.every(Boolean)) { + return pathMatches[0] ? -1 : 1; + } + + return nameMatches[0] ? -1 : 1; + }, + useOptions() { + const { data: session } = useSession(); + const t = useScopedI18n("search.mode.page.group.page.option"); + + const managePages = [ + { + icon: IconHome, + path: "/manage", + name: t("manageHome.label"), + }, + { + icon: IconLayoutDashboard, + path: "/manage/boards", + name: t("manageBoard.label"), + }, + { + icon: IconBox, + path: "/manage/apps", + name: t("manageApp.label"), + hidden: !session, + }, + { + icon: IconPlug, + path: "/manage/integrations", + name: t("manageIntegration.label"), + hidden: !session, + }, + { + icon: IconUsers, + path: "/manage/users", + name: t("manageUser.label"), + hidden: !session, + }, + { + icon: IconMailForward, + path: "/manage/users/invites", + name: t("manageInvite.label"), + hidden: !session?.user.permissions.includes("admin"), + }, + { + icon: IconUsersGroup, + path: "/manage/users/groups", + name: t("manageGroup.label"), + hidden: !session, + }, + { + icon: IconBrandDocker, + path: "/manage/tools/docker", + name: "Manage Docker", + hidden: !session?.user.permissions.includes("admin"), + }, + { + icon: IconPlug, + path: "/manage/tools/api", + name: t("manageApi.label"), + hidden: !session, + }, + { + icon: IconLogs, + path: "/manage/tools/logs", + name: t("manageLog.label"), + hidden: !session?.user.permissions.includes("admin"), + }, + { + icon: IconReport, + path: "/manage/tools/tasks", + name: t("manageTask.label"), + hidden: !session?.user.permissions.includes("admin"), + }, + { + icon: IconSettings, + path: "/manage/settings", + name: t("manageSettings.label"), + hidden: !session?.user.permissions.includes("admin"), + }, + { + icon: IconInfoSmall, + path: "/manage/about", + name: t("about.label"), + }, + ]; + + const otherPages = [ + { + icon: IconHome, + path: "/boards", + name: t("homeBoard.label"), + }, + { + icon: IconSettings, + path: `/manage/users/${session?.user.id}/general`, + name: t("preferences.label"), + hidden: !session, + }, + ]; + + return otherPages.concat(managePages).filter(({ hidden }) => !hidden); + }, +}); diff --git a/packages/spotlight/src/modes/user-group/groups-search-group.tsx b/packages/spotlight/src/modes/user-group/groups-search-group.tsx new file mode 100644 index 000000000..507c7cb80 --- /dev/null +++ b/packages/spotlight/src/modes/user-group/groups-search-group.tsx @@ -0,0 +1,83 @@ +import { Group, Stack, Text } from "@mantine/core"; +import { IconEye, IconUsersGroup } from "@tabler/icons-react"; + +import { clientApi } from "@homarr/api/client"; +import { useI18n } from "@homarr/translation/client"; + +import { createChildrenOptions } from "../../lib/children"; +import { createGroup } from "../../lib/group"; +import { interaction } from "../../lib/interaction"; + +// This has to be type so it can be interpreted as Record. +// eslint-disable-next-line @typescript-eslint/consistent-type-definitions +type Group = { id: string; name: string }; + +const groupChildrenOptions = createChildrenOptions({ + useActions: () => [ + { + key: "detail", + component: () => { + const t = useI18n(); + return ( + + + {t("search.mode.userGroup.group.group.children.action.detail.label")} + + ); + }, + useInteraction: interaction.link(({ id }) => ({ href: `/manage/users/groups/${id}` })), + }, + { + key: "manageMember", + component: () => { + const t = useI18n(); + return ( + + + {t("search.mode.userGroup.group.group.children.action.manageMember.label")} + + ); + }, + useInteraction: interaction.link(({ id }) => ({ href: `/manage/users/groups/${id}/members` })), + }, + { + key: "managePermission", + component: () => { + const t = useI18n(); + return ( + + + {t("search.mode.userGroup.group.group.children.action.managePermission.label")} + + ); + }, + useInteraction: interaction.link(({ id }) => ({ href: `/manage/users/groups/${id}/permissions` })), + }, + ], + detailComponent: ({ options }) => { + const t = useI18n(); + return ( + + {t("search.mode.userGroup.group.group.children.detail.title")} + + + {options.name} + + + ); + }, +}); + +export const groupsSearchGroup = createGroup({ + keyPath: "id", + title: "Groups", + component: ({ name }) => ( + + {name} + + ), + useInteraction: interaction.children(groupChildrenOptions), + useQueryOptions(query) { + return clientApi.group.search.useQuery({ query, limit: 5 }); + }, +}); diff --git a/packages/spotlight/src/modes/user-group/index.tsx b/packages/spotlight/src/modes/user-group/index.tsx new file mode 100644 index 000000000..e9faa6ab5 --- /dev/null +++ b/packages/spotlight/src/modes/user-group/index.tsx @@ -0,0 +1,9 @@ +import type { SearchMode } from "../../lib/mode"; +import { groupsSearchGroup } from "./groups-search-group"; +import { usersSearchGroup } from "./users-search-group"; + +export const userGroupMode = { + modeKey: "userGroup", + character: "@", + groups: [usersSearchGroup, groupsSearchGroup], +} satisfies SearchMode; diff --git a/packages/spotlight/src/modes/user-group/users-search-group.tsx b/packages/spotlight/src/modes/user-group/users-search-group.tsx new file mode 100644 index 000000000..ec750f182 --- /dev/null +++ b/packages/spotlight/src/modes/user-group/users-search-group.tsx @@ -0,0 +1,62 @@ +import { Group, Stack, Text } from "@mantine/core"; +import { IconEye } from "@tabler/icons-react"; + +import { clientApi } from "@homarr/api/client"; +import { useI18n } from "@homarr/translation/client"; +import { UserAvatar } from "@homarr/ui"; + +import { createChildrenOptions } from "../../lib/children"; +import { createGroup } from "../../lib/group"; +import { interaction } from "../../lib/interaction"; + +// This has to be type so it can be interpreted as Record. +// eslint-disable-next-line @typescript-eslint/consistent-type-definitions +type User = { id: string; name: string; image: string | null }; + +const userChildrenOptions = createChildrenOptions({ + useActions: () => [ + { + key: "detail", + component: () => { + const t = useI18n(); + + return ( + + + {t("search.mode.userGroup.group.user.children.action.detail.label")} + + ); + }, + useInteraction: interaction.link(({ id }) => ({ href: `/manage/users/${id}/general` })), + }, + ], + detailComponent: ({ options }) => { + const t = useI18n(); + + return ( + + {t("search.mode.userGroup.group.user.children.detail.title")} + + + + {options.name} + + + ); + }, +}); + +export const usersSearchGroup = createGroup({ + keyPath: "id", + title: (t) => t("search.mode.userGroup.group.user.title"), + component: (user) => ( + + + {user.name} + + ), + useInteraction: interaction.children(userChildrenOptions), + useQueryOptions(query) { + return clientApi.user.search.useQuery({ query, limit: 5 }); + }, +}); diff --git a/packages/spotlight/src/type.ts b/packages/spotlight/src/type.ts deleted file mode 100644 index 25f96c2c7..000000000 --- a/packages/spotlight/src/type.ts +++ /dev/null @@ -1,28 +0,0 @@ -import type { TranslationFunction, TranslationObject } from "@homarr/translation"; -import type { TablerIcon } from "@homarr/ui"; - -export type SpotlightActionGroup = keyof TranslationObject["common"]["search"]["group"]; - -interface BaseSpotlightAction { - id: string; - title: string | ((t: TranslationFunction) => string); - description: string | ((t: TranslationFunction) => string); - group: Exclude; // actions can not be assigned to the "all" group - icon: TablerIcon | string; - ignoreSearchAndOnlyShowInGroup?: boolean; -} - -interface SpotlightActionLink extends BaseSpotlightAction { - type: "link"; - href: string; - openInNewTab?: boolean; -} - -type MaybePromise = T | Promise; - -interface SpotlightActionButton extends BaseSpotlightAction { - type: "button"; - onClick: () => MaybePromise; -} - -export type SpotlightActionData = SpotlightActionLink | SpotlightActionButton; diff --git a/packages/spotlight/src/web-search-engines.ts b/packages/spotlight/src/web-search-engines.ts deleted file mode 100644 index 870e4a96f..000000000 --- a/packages/spotlight/src/web-search-engines.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { IconDownload } from "@tabler/icons-react"; - -import { useRegisterSpotlightActions } from "./data-store"; - -export const useWebSearchEngines = () => { - useRegisterSpotlightActions("web-search-engines", [ - { - id: "google", - title: "Google", - description: "Search the web with Google", - icon: "https://www.google.com/favicon.ico", - href: "https://www.google.com/search?q=%s", - group: "web", - type: "link", - ignoreSearchAndOnlyShowInGroup: true, - openInNewTab: true, - }, - { - id: "bing", - title: "Bing", - description: "Search the web with Bing", - icon: "https://www.bing.com/favicon.ico", - href: "https://www.bing.com/search?q=%s", - group: "web", - type: "link", - ignoreSearchAndOnlyShowInGroup: true, - openInNewTab: true, - }, - { - id: "duckduckgo", - title: "DuckDuckGo", - description: "Search the web with DuckDuckGo", - icon: "https://duckduckgo.com/favicon.ico", - href: "https://duckduckgo.com/?q=%s", - group: "web", - type: "link", - ignoreSearchAndOnlyShowInGroup: true, - openInNewTab: true, - }, - { - id: "torrent", - title: "Torrents", - description: "Search for torrents on torrentdownloads.pro", - icon: IconDownload, - href: "https://www.torrentdownloads.pro/search/?search=%s", - group: "web", - type: "link", - ignoreSearchAndOnlyShowInGroup: true, - openInNewTab: true, - }, - { - id: "youtube", - title: "YouTube", - description: "Search for videos on YouTube", - icon: "https://www.youtube.com/favicon.ico", - href: "https://www.youtube.com/results?search_query=%s", - group: "web", - type: "link", - ignoreSearchAndOnlyShowInGroup: true, - openInNewTab: true, - }, - ]); -}; diff --git a/packages/translation/src/lang/de.ts b/packages/translation/src/lang/de.ts index c556cef68..4134d7109 100644 --- a/packages/translation/src/lang/de.ts +++ b/packages/translation/src/lang/de.ts @@ -156,10 +156,6 @@ export default { placeholder: "Wähle eine oder mehrere Optionen aus", }, noResults: "Keine Ergebnisse gefunden", - search: { - placeholder: "Suche nach etwas", - nothingFound: "Nichts gefunden", - }, mantineReactTable: MRT_Localization_DE, }, widget: { @@ -193,4 +189,8 @@ export default { }, }, }, + search: { + placeholder: "Suche nach etwas", + nothingFound: "Nichts gefunden", + }, } as const; diff --git a/packages/translation/src/lang/en.ts b/packages/translation/src/lang/en.ts index b22979d42..027732955 100644 --- a/packages/translation/src/lang/en.ts +++ b/packages/translation/src/lang/en.ts @@ -556,6 +556,7 @@ export default { next: "Next", checkoutDocs: "Check out the documentation", tryAgain: "Try again", + loading: "Loading", }, iconPicker: { label: "Icon URL", @@ -596,16 +597,6 @@ export default { recommended: "Recommended", }, }, - search: { - placeholder: "Search for anything", - nothingFound: "Nothing found", - group: { - all: "All", - web: "Web", - action: "Actions", - app: "Apps", - }, - }, userAvatar: { menu: { switchToDarkMode: "Switch to dark mode", @@ -1722,7 +1713,7 @@ export default { copy: { title: "Copy invite", description: - "Your invitation has been generated. After this modal closes, you'll not be able to copy this link anymore. If you do no longer wish to invite said person, you can delete this invitation any time.", + "Your invitation has been generated. After this modal closes, you'll not be able to copy this link anymore. If you do no longer wish to invite said person, you can delete this invitation any time.", link: "Invitation link", button: "Copy & close", }, @@ -2057,4 +2048,247 @@ export default { }, }, }, + search: { + placeholder: "Search for anything", + nothingFound: "Nothing found", + error: { + fetch: "An error occurred while fetching data", + }, + mode: { + appIntegrationBoard: { + help: "Search for apps, integrations or boards", + group: { + app: { + title: "Apps", + children: { + action: { + open: { + label: "Open app url", + }, + edit: { + label: "Edit app", + }, + }, + detail: { + title: "Select an action for the app", + }, + }, + }, + board: { + title: "Boards", + children: { + action: { + open: { + label: "Open board", + }, + homeBoard: { + label: "Set as home board", + }, + settings: { + label: "Open settings", + }, + }, + detail: { + title: "Select an action for the board", + }, + }, + }, + integration: { + title: "Integrations", + }, + }, + }, + command: { + help: "Activate command mode", + group: { + globalCommand: { + title: "Global commands", + option: { + colorScheme: { + light: "Switch to light mode", + dark: "Switch to dark mode", + }, + language: { + label: "Change language", + children: { + detail: { + title: "Select your prefered language", + }, + }, + }, + newBoard: { + label: "Create a new board", + }, + importBoard: { + label: "Import a board", + }, + newApp: { + label: "Create a new app", + }, + newIntegration: { + label: "Create a new integration", + children: { + detail: { + title: "Select the integration type you want to create", + }, + }, + }, + newUser: { + label: "Create a new user", + }, + newInvite: { + label: "Create a new invite", + }, + newGroup: { + label: "Create a new group", + }, + }, + }, + }, + }, + external: { + help: "Use an external search engine", + group: { + searchEngine: { + title: "Search engines", + option: { + google: { + name: "Google", + description: "Search the web with Google", + }, + bing: { + name: "Bing", + description: "Search the web with Bing", + }, + duckduckgo: { + name: "DuckDuckGo", + description: "Search the web with DuckDuckGo", + }, + torrent: { + name: "Torrents", + description: "Search for torrents on torrentdownloads.pro", + }, + youTube: { + name: "YouTube", + description: "Search for videos on YouTube", + }, + }, + }, + }, + }, + help: { + group: { + mode: { + title: "Modes", + }, + help: { + title: "Help", + option: { + documentation: { + label: "Documentation", + }, + submitIssue: { + label: "Submit an issue", + }, + discord: { + label: "Community Discord", + }, + }, + }, + }, + }, + page: { + help: "Search for pages", + group: { + page: { + title: "Pages", + option: { + manageHome: { + label: "Manage home page", + }, + manageBoard: { + label: "Manage boards", + }, + manageApp: { + label: "Manage apps", + }, + manageIntegration: { + label: "Manage integrations", + }, + manageUser: { + label: "Manage users", + }, + manageInvite: { + label: "Manage invites", + }, + manageGroup: { + label: "Manage groups", + }, + manageDocker: { + label: "Manage docker", + }, + manageApi: { + label: "Swagger API", + }, + manageLog: { + label: "View logs", + }, + manageTask: { + label: "Manage tasks", + }, + manageSettings: { + label: "Global settings", + }, + about: { + label: "About", + }, + homeBoard: { + label: "Home board", + }, + preferences: { + label: "Your preferences", + }, + }, + }, + }, + }, + userGroup: { + help: "Search for users or groups", + group: { + user: { + title: "Users", + children: { + action: { + detail: { + label: "Show user details", + }, + }, + detail: { + title: "Select an action for the user", + }, + }, + }, + group: { + title: "Groups", + children: { + action: { + detail: { + label: "Show group details", + }, + manageMember: { + label: "Manage members", + }, + managePermission: { + label: "Manage permissions", + }, + }, + detail: { + title: "Select an action for the group", + }, + }, + }, + }, + }, + }, + }, } as const; diff --git a/packages/ui/package.json b/packages/ui/package.json index 9e65b6efd..6bef09590 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -22,18 +22,18 @@ "lint": "eslint", "typecheck": "tsc --noEmit" }, - "prettier": "@homarr/prettier-config", "dependencies": { "@homarr/common": "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", "@mantine/core": "^7.12.2", "@mantine/dates": "^7.12.2", "@mantine/hooks": "^7.12.2", - "@tabler/icons-react": "^3.16.0", + "@tabler/icons-react": "^3.17.0", "mantine-react-table": "2.0.0-beta.6", - "next": "^14.2.11", + "next": "^14.2.13", "react": "^18.3.1" }, "devDependencies": { @@ -43,5 +43,6 @@ "@types/css-modules": "^1.0.5", "eslint": "^9.10.0", "typescript": "^5.6.2" - } + }, + "prettier": "@homarr/prettier-config" } diff --git a/packages/ui/src/components/index.tsx b/packages/ui/src/components/index.tsx index ff01de8b2..ecd4632ba 100644 --- a/packages/ui/src/components/index.tsx +++ b/packages/ui/src/components/index.tsx @@ -8,4 +8,5 @@ export { TextMultiSelect } from "./text-multi-select"; export { UserAvatar } from "./user-avatar"; export { UserAvatarGroup } from "./user-avatar-group"; export { CustomPasswordInput } from "./password-input/password-input"; +export { IntegrationAvatar } from "./integration-avatar"; export { BetaBadge } from "./beta-badge"; diff --git a/apps/nextjs/src/app/[locale]/manage/integrations/_integration-avatar.tsx b/packages/ui/src/components/integration-avatar.tsx similarity index 100% rename from apps/nextjs/src/app/[locale]/manage/integrations/_integration-avatar.tsx rename to packages/ui/src/components/integration-avatar.tsx index f443569da..59f962eef 100644 --- a/apps/nextjs/src/app/[locale]/manage/integrations/_integration-avatar.tsx +++ b/packages/ui/src/components/integration-avatar.tsx @@ -1,8 +1,8 @@ -import { Avatar } from "@mantine/core"; import type { MantineSize } from "@mantine/core"; +import { Avatar } from "@mantine/core"; -import { getIconUrl } from "@homarr/definitions"; import type { IntegrationKind } from "@homarr/definitions"; +import { getIconUrl } from "@homarr/definitions"; interface IntegrationAvatarProps { size: MantineSize; diff --git a/packages/validation/src/board.ts b/packages/validation/src/board.ts index 8e5302aa7..33930063c 100644 --- a/packages/validation/src/board.ts +++ b/packages/validation/src/board.ts @@ -69,23 +69,15 @@ const permissionsSchema = z.object({ id: z.string(), }); -export const createOldmarrImportConfigurationSchema = (existingBoardNames: string[]) => - z.object({ - name: boardNameSchema.refine( - (value) => { - return existingBoardNames.every((name) => name.toLowerCase().trim() !== value.toLowerCase().trim()); - }, - { - params: createCustomErrorParams("boardAlreadyExists"), - }, - ), - onlyImportApps: z.boolean().default(false), - distinctAppsByHref: z.boolean().default(true), - screenSize: z.enum(["lg", "md", "sm"]).default("lg"), - sidebarBehaviour: z.enum(["remove-items", "last-section"]).default("last-section"), - }); +export const oldmarrImportConfigurationSchema = z.object({ + name: boardNameSchema, + onlyImportApps: z.boolean().default(false), + distinctAppsByHref: z.boolean().default(true), + screenSize: z.enum(["lg", "md", "sm"]).default("lg"), + sidebarBehaviour: z.enum(["remove-items", "last-section"]).default("last-section"), +}); -export type OldmarrImportConfiguration = z.infer>; +export type OldmarrImportConfiguration = z.infer; export const superRefineJsonImportFile = (value: File | null, context: z.RefinementCtx) => { if (!value) { @@ -121,7 +113,7 @@ export const superRefineJsonImportFile = (value: File | null, context: z.Refinem const importJsonFileSchema = zfd.formData({ file: zfd.file().superRefine(superRefineJsonImportFile), - configuration: zfd.json(createOldmarrImportConfigurationSchema([])), + configuration: zfd.json(oldmarrImportConfigurationSchema), }); const savePermissionsSchema = createSavePermissionsSchema(zodEnumFromArray(boardPermissions)); diff --git a/packages/validation/src/index.ts b/packages/validation/src/index.ts index 6361c31a7..b03232328 100644 --- a/packages/validation/src/index.ts +++ b/packages/validation/src/index.ts @@ -26,5 +26,5 @@ export { type BoardItemAdvancedOptions, } from "./shared"; export { passwordRequirements } from "./user"; -export { createOldmarrImportConfigurationSchema, superRefineJsonImportFile } from "./board"; +export { oldmarrImportConfigurationSchema, superRefineJsonImportFile } from "./board"; export type { OldmarrImportConfiguration } from "./board"; diff --git a/packages/widgets/package.json b/packages/widgets/package.json index 965497781..b5e6a5e7b 100644 --- a/packages/widgets/package.json +++ b/packages/widgets/package.json @@ -40,26 +40,26 @@ "@homarr/validation": "workspace:^0.1.0", "@mantine/core": "^7.12.2", "@mantine/hooks": "^7.12.2", - "@tabler/icons-react": "^3.16.0", - "@tiptap/extension-color": "2.6.6", - "@tiptap/extension-highlight": "2.6.6", - "@tiptap/extension-image": "2.6.6", - "@tiptap/extension-link": "^2.6.6", - "@tiptap/extension-table": "2.6.6", - "@tiptap/extension-table-cell": "2.6.6", - "@tiptap/extension-table-header": "2.6.6", - "@tiptap/extension-table-row": "2.6.6", - "@tiptap/extension-task-item": "2.6.6", - "@tiptap/extension-task-list": "2.6.6", - "@tiptap/extension-text-align": "2.6.6", - "@tiptap/extension-text-style": "2.6.6", - "@tiptap/extension-underline": "2.6.6", - "@tiptap/react": "^2.6.6", - "@tiptap/starter-kit": "^2.6.6", + "@tabler/icons-react": "^3.17.0", + "@tiptap/extension-color": "2.7.2", + "@tiptap/extension-highlight": "2.7.2", + "@tiptap/extension-image": "2.7.2", + "@tiptap/extension-link": "^2.7.2", + "@tiptap/extension-table": "2.7.2", + "@tiptap/extension-table-cell": "2.7.2", + "@tiptap/extension-table-header": "2.7.2", + "@tiptap/extension-table-row": "2.7.2", + "@tiptap/extension-task-item": "2.7.2", + "@tiptap/extension-task-list": "2.7.2", + "@tiptap/extension-text-align": "2.7.2", + "@tiptap/extension-text-style": "2.7.2", + "@tiptap/extension-underline": "2.7.2", + "@tiptap/react": "^2.7.2", + "@tiptap/starter-kit": "^2.7.2", "clsx": "^2.1.1", "dayjs": "^1.11.13", "mantine-react-table": "2.0.0-beta.6", - "next": "^14.2.11", + "next": "^14.2.13", "react": "^18.3.1", "video.js": "^8.17.4" }, diff --git a/packages/widgets/src/app/component.tsx b/packages/widgets/src/app/component.tsx index cf9422832..c8628030a 100644 --- a/packages/widgets/src/app/component.tsx +++ b/packages/widgets/src/app/component.tsx @@ -1,112 +1,48 @@ "use client"; import type { PropsWithChildren } from "react"; -import { useState } from "react"; -import { Box, Center, Flex, Loader, Stack, Text, Tooltip, UnstyledButton } from "@mantine/core"; -import { IconDeviceDesktopX } from "@tabler/icons-react"; +import { Suspense } from "react"; +import { Flex, Text, Tooltip, UnstyledButton } from "@mantine/core"; import combineClasses from "clsx"; -import type { RouterOutputs } from "@homarr/api"; import { clientApi } from "@homarr/api/client"; import { parseAppHrefWithVariablesClient } from "@homarr/common/client"; -import { useRegisterSpotlightActions } from "@homarr/spotlight"; -import { useScopedI18n } from "@homarr/translation/client"; +import { useI18n } from "@homarr/translation/client"; import type { WidgetComponentProps } from "../definition"; import classes from "./app.module.css"; +import { PingDot } from "./ping/ping-dot"; +import { PingIndicator } from "./ping/ping-indicator"; -export default function AppWidget({ options, serverData, isEditMode, width }: WidgetComponentProps<"app">) { - const t = useScopedI18n("widget.app"); - const isQueryEnabled = Boolean(options.appId); - const { - data: app, - isPending, - isError, - } = clientApi.app.byId.useQuery( +export default function AppWidget({ options, isEditMode }: WidgetComponentProps<"app">) { + const t = useI18n(); + const [app] = clientApi.app.byId.useSuspenseQuery( { id: options.appId, }, { - initialData: - // We need to check if the id's match because otherwise the same initialData for a changed id will be used - serverData?.app?.id === options.appId ? serverData.app : undefined, refetchOnMount: false, refetchOnWindowFocus: false, refetchOnReconnect: false, - enabled: isQueryEnabled, + retry: false, }, ); - const [pingResult, setPingResult] = useState(null); - - const shouldRunPing = Boolean(app?.href) && options.pingEnabled; - clientApi.widget.app.updatedPing.useSubscription( - { url: parseAppHrefWithVariablesClient(app?.href ?? "") }, - { - enabled: shouldRunPing, - onData(data) { - setPingResult(data); - }, - }, - ); - - useRegisterSpotlightActions( - `app-${options.appId}`, - app?.href - ? [ - { - id: `app-${options.appId}`, - title: app.name, - description: app.description ?? "", - icon: app.iconUrl, - group: "app", - type: "link", - href: parseAppHrefWithVariablesClient(app.href), - openInNewTab: options.openInNewTab, - }, - ] - : [], - [app, options.appId, options.openInNewTab], - ); - - if (isPending && isQueryEnabled) { - return ( -
- -
- ); - } - - if (isError || !isQueryEnabled) { - return ( - - - = 96 ? "2rem" : "1rem"} /> - {width >= 96 && ( - - {t("error.notFound.label")} - - )} - - - ); - } - return ( {options.showTitle && ( - - {app?.name} + + {app.name} )} - {app?.name} + {app.name} - {shouldRunPing && } + {options.pingEnabled && app.href ? ( + + } + > + + + ) : null} ); } @@ -141,31 +85,3 @@ const AppLink = ({ href, openInNewTab, enabled, children }: PropsWithChildren { - return ( - - - = 500 - ? "red" - : "green", - }} - w={16} - h={16} - > - - - ); -}; diff --git a/packages/widgets/src/app/index.ts b/packages/widgets/src/app/index.ts index cfa202e5c..506f654e5 100644 --- a/packages/widgets/src/app/index.ts +++ b/packages/widgets/src/app/index.ts @@ -1,4 +1,4 @@ -import { IconApps } from "@tabler/icons-react"; +import { IconApps, IconDeviceDesktopX } from "@tabler/icons-react"; import { createWidgetDefinition } from "../definition"; import { optionsBuilder } from "../options"; @@ -12,6 +12,11 @@ export const { definition, componentLoader, serverDataLoader } = createWidgetDef showDescriptionTooltip: factory.switch({ defaultValue: false }), pingEnabled: factory.switch({ defaultValue: false }), })), -}) - .withServerData(() => import("./serverData")) - .withDynamicImport(() => import("./component")); + errors: { + NOT_FOUND: { + icon: IconDeviceDesktopX, + message: (t) => t("widget.app.error.notFound.label"), + hideLogsLink: true, + }, + }, +}).withDynamicImport(() => import("./component")); diff --git a/packages/widgets/src/app/ping/ping-dot.tsx b/packages/widgets/src/app/ping/ping-dot.tsx new file mode 100644 index 000000000..b54262f28 --- /dev/null +++ b/packages/widgets/src/app/ping/ping-dot.tsx @@ -0,0 +1,24 @@ +import type { MantineColor } from "@mantine/core"; +import { Box, Tooltip } from "@mantine/core"; + +interface PingDotProps { + color: MantineColor; + tooltip: string; +} + +export const PingDot = ({ color, tooltip }: PingDotProps) => { + return ( + + + + + + ); +}; diff --git a/packages/widgets/src/app/ping/ping-indicator.tsx b/packages/widgets/src/app/ping/ping-indicator.tsx new file mode 100644 index 000000000..058600257 --- /dev/null +++ b/packages/widgets/src/app/ping/ping-indicator.tsx @@ -0,0 +1,41 @@ +import { useState } from "react"; + +import type { RouterOutputs } from "@homarr/api"; +import { clientApi } from "@homarr/api/client"; +import { parseAppHrefWithVariablesClient } from "@homarr/common/client"; + +import { PingDot } from "./ping-dot"; + +interface PingIndicatorProps { + href: string; +} + +export const PingIndicator = ({ href }: PingIndicatorProps) => { + const [ping] = clientApi.widget.app.ping.useSuspenseQuery( + { + url: parseAppHrefWithVariablesClient(href), + }, + { + refetchOnMount: false, + refetchOnWindowFocus: false, + }, + ); + + const [pingResult, setPingResult] = useState(ping); + + clientApi.widget.app.updatedPing.useSubscription( + { url: parseAppHrefWithVariablesClient(href) }, + { + onData(data) { + setPingResult(data); + }, + }, + ); + + return ( + = 500 ? "red" : "green"} + tooltip={"statusCode" in pingResult ? pingResult.statusCode.toString() : pingResult.error} + /> + ); +}; diff --git a/packages/widgets/src/app/serverData.ts b/packages/widgets/src/app/serverData.ts deleted file mode 100644 index 4b8d7204a..000000000 --- a/packages/widgets/src/app/serverData.ts +++ /dev/null @@ -1,28 +0,0 @@ -"use server"; - -import type { RouterOutputs } from "@homarr/api"; -import { api } from "@homarr/api/server"; -import { parseAppHrefWithVariablesServer } from "@homarr/common/server"; - -import type { WidgetProps } from "../definition"; - -export default async function getServerDataAsync({ options }: WidgetProps<"app">) { - if (!options.appId) { - return { app: null, pingResult: null }; - } - - try { - const app = await api.app.byId({ id: options.appId }); - let pingResult: RouterOutputs["widget"]["app"]["ping"] | null = null; - - if (app.href && options.pingEnabled) { - pingResult = await api.widget.app.ping({ - url: parseAppHrefWithVariablesServer(app.href), - }); - } - - return { app, pingResult }; - } catch { - return { app: null, pingResult: null }; - } -} diff --git a/packages/widgets/src/app/test/serverData.spec.ts b/packages/widgets/src/app/test/serverData.spec.ts deleted file mode 100644 index 999d7d1e8..000000000 --- a/packages/widgets/src/app/test/serverData.spec.ts +++ /dev/null @@ -1,132 +0,0 @@ -import { TRPCError } from "@trpc/server"; -import { describe, expect, test, vi } from "vitest"; - -import type { RouterOutputs } from "@homarr/api"; -import { api } from "@homarr/api/server"; -import { objectKeys } from "@homarr/common"; - -import type { WidgetProps } from "../../definition"; -import getServerDataAsync from "../serverData"; - -const mockApp = (override: Partial) => - ({ - id: "1", - name: "Mock app", - iconUrl: "https://some.com/icon.png", - description: null, - href: "https://google.ch", - ...override, - }) satisfies RouterOutputs["app"]["byId"]; - -vi.mock("@homarr/api/server", () => ({ - api: { - app: { - byId: () => null, - }, - widget: { - app: { - ping: () => null, - }, - }, - }, -})); -vi.mock("@homarr/common/server", () => ({ - parseAppHrefWithVariablesServer: () => "http://localhost", -})); - -describe("getServerDataAsync should load app and ping result", () => { - test("when appId is empty it should return null for app and pingResult", async () => { - // Arrange - const options = { - appId: "", - pingEnabled: true, - }; - - // Act - const result = await getServerDataAsync({ options } as unknown as WidgetProps<"app">); - - // Assert - expect(result.app).toBeNull(); - expect(result.pingResult).toBeNull(); - }); - - test("when app exists and ping is disabled it should return existing app and pingResult null", async () => { - // Arrange - const spy = vi.spyOn(api.app, "byId"); - const options = { - appId: "1", - pingEnabled: false, - }; - const mockedApp = mockApp({}); - spy.mockImplementation(() => Promise.resolve(mockedApp)); - - // Act - const result = await getServerDataAsync({ options } as unknown as WidgetProps<"app">); - - // Assert - expect(result.pingResult).toBeNull(); - objectKeys(mockedApp).forEach((key) => expect(result.app?.[key]).toBe(mockedApp[key])); - }); - - test("when app exists without href and ping enabled it should return existing app and pingResult null", async () => { - // Arrange - const spy = vi.spyOn(api.app, "byId"); - const options = { - appId: "1", - pingEnabled: true, - }; - const mockedApp = mockApp({ href: null }); - spy.mockImplementation(() => Promise.resolve(mockedApp)); - - // Act - const result = await getServerDataAsync({ options } as unknown as WidgetProps<"app">); - - // Assert - expect(result.pingResult).toBeNull(); - objectKeys(mockedApp).forEach((key) => expect(result.app?.[key]).toBe(mockedApp[key])); - }); - - test("when app does not exist it should return for both null", async () => { - // Arrange - const spy = vi.spyOn(api.app, "byId"); - const options = { - appId: "1", - pingEnabled: true, - }; - spy.mockImplementation(() => - Promise.reject( - new TRPCError({ - code: "NOT_FOUND", - }), - ), - ); - - // Act - const result = await getServerDataAsync({ options } as unknown as WidgetProps<"app">); - - // Assert - expect(result.pingResult).toBeNull(); - expect(result.app).toBeNull(); - }); - - test("when app found and ping enabled it should return existing app and pingResult", async () => { - // Arrange - const spyById = vi.spyOn(api.app, "byId"); - const spyPing = vi.spyOn(api.widget.app, "ping"); - const options = { - appId: "1", - pingEnabled: true, - }; - const mockedApp = mockApp({}); - const pingResult = { statusCode: 200, url: "http://localhost" }; - spyById.mockImplementation(() => Promise.resolve(mockedApp)); - spyPing.mockImplementation(() => Promise.resolve(pingResult)); - - // Act - const result = await getServerDataAsync({ options } as unknown as WidgetProps<"app">); - - // Assert - expect(result.pingResult).toBe(pingResult); - expect(result.app).toBe(mockedApp); - }); -}); diff --git a/packages/widgets/src/definition.ts b/packages/widgets/src/definition.ts index 9bb36b275..46346bddb 100644 --- a/packages/widgets/src/definition.ts +++ b/packages/widgets/src/definition.ts @@ -71,6 +71,7 @@ export interface WidgetDefinition { { icon: TablerIcon; message: stringOrTranslation; + hideLogsLink?: boolean; } > >; diff --git a/packages/widgets/src/downloads/component.tsx b/packages/widgets/src/downloads/component.tsx index 5fae5bce5..afd8be9cc 100644 --- a/packages/widgets/src/downloads/component.tsx +++ b/packages/widgets/src/downloads/component.tsx @@ -831,6 +831,7 @@ const ClientsControl = ({ clients, style }: ClientsControlProps) => { px="calc(var(--space-size)*2)" fw="500" onClick={open} + styles={{ label: { height: "fit-content", paddingBottom: "calc(var(--space-size)*0.75)" } }} > {totalSpeed} diff --git a/packages/widgets/src/errors/component.tsx b/packages/widgets/src/errors/component.tsx index b27b52875..5638ef321 100644 --- a/packages/widgets/src/errors/component.tsx +++ b/packages/widgets/src/errors/component.tsx @@ -5,6 +5,7 @@ import type { DefaultErrorData } from "@trpc/server/unstable-core-do-not-import" import type { WidgetKind } from "@homarr/definitions"; +import type { WidgetDefinition } from ".."; import { widgetImports } from ".."; import { ErrorBoundaryError } from "./base"; import { BaseWidgetError } from "./base-component"; @@ -22,21 +23,28 @@ export const WidgetError = ({ error, resetErrorBoundary, kind }: WidgetErrorProp return ; } - if (error instanceof TRPCClientError && "code" in error.data) { - const errorData = error.data as DefaultErrorData; - - if (!("errors" in currentDefinition && errorData.code in currentDefinition.errors)) return null; - - const errorDefinition = currentDefinition.errors[errorData.code as keyof typeof currentDefinition.errors]; - - return ; - } - - return ( + const commonFallbackError = ( string }).toString()} onRetry={resetErrorBoundary} /> ); + + if (error instanceof TRPCClientError && "code" in error.data) { + const errorData = error.data as DefaultErrorData; + + if (!("errors" in currentDefinition)) return commonFallbackError; + + const errors: Exclude = currentDefinition.errors; + const errorDefinition = errors[errorData.code]; + + if (!errorDefinition) return commonFallbackError; + + return ( + + ); + } + + return commonFallbackError; }; diff --git a/packages/widgets/src/index.tsx b/packages/widgets/src/index.tsx index 4c1b04b8c..842f201ba 100644 --- a/packages/widgets/src/index.tsx +++ b/packages/widgets/src/index.tsx @@ -1,7 +1,7 @@ import type { ComponentType } from "react"; import type { Loader } from "next/dynamic"; import dynamic from "next/dynamic"; -import { Loader as UiLoader } from "@mantine/core"; +import { Center, Loader as UiLoader } from "@mantine/core"; import type { WidgetKind } from "@homarr/definitions"; @@ -65,7 +65,11 @@ export const loadWidgetDynamic = (kind: TKind) => { const newlyLoadedComponent = dynamic>( widgetImports[kind].componentLoader as Loader>, { - loading: () => , + loading: () => ( +
+ +
+ ), }, ); diff --git a/packages/widgets/src/indexer-manager/component.tsx b/packages/widgets/src/indexer-manager/component.tsx index d9d79020f..6e487af5a 100644 --- a/packages/widgets/src/indexer-manager/component.tsx +++ b/packages/widgets/src/indexer-manager/component.tsx @@ -16,9 +16,6 @@ export default function IndexerManagerWidget({ integrationIds, serverData, }: WidgetComponentProps<"indexerManager">) { - if (integrationIds.length === 0) { - throw new NoIntegrationSelectedError(); - } const t = useI18n(); const [indexersData, setIndexersData] = useState<{ integrationId: string; indexers: Indexer[] }[]>( serverData?.initialData ?? [], @@ -39,26 +36,49 @@ export default function IndexerManagerWidget({ }, ); + const iconStyle = { height: "7.5cqmin", width: "7.5cqmin" }; + + if (integrationIds.length === 0) { + throw new NoIntegrationSelectedError(); + } + return ( - - - {t("widget.indexerManager.title")} + + + {t("widget.indexerManager.title")} - - + + {indexersData.map(({ integrationId, indexers }) => ( - + {indexers.map((indexer) => ( - - - + + + {indexer.name} {indexer.status === false || indexer.enabled === false ? ( - + ) : ( - + )} ))} @@ -67,11 +87,13 @@ export default function IndexerManagerWidget({