From de801f3f00fc72c232b100fe2083636d044e6894 Mon Sep 17 00:00:00 2001 From: Thomas Camlong Date: Sun, 2 Aug 2026 15:58:16 +0200 Subject: [PATCH] perf: optimize dashboard loading and builds --- Dockerfile | 15 +- .../advanced/environment-variables/index.mdx | 90 +- apps/nextjs/next.config.ts | 1 + apps/nextjs/package.json | 3 +- .../_client-providers/development-tools.tsx | 120 ++ .../query-cache-persister.ts | 32 - .../_client-providers/query-retry.spec.ts | 27 + .../[locale]/_client-providers/query-retry.ts | 20 + .../session-query-scope.spec.ts | 52 + .../_client-providers/session-query-scope.ts | 39 + .../app/[locale]/_client-providers/trpc.tsx | 98 +- .../boards/(content)/_board-add-menu.tsx | 84 + .../[locale]/boards/(content)/_creator.tsx | 40 +- .../boards/(content)/_dynamic-client.tsx | 17 + .../boards/(content)/_header-actions.tsx | 187 +- .../app/[locale]/boards/_layout-creator.tsx | 45 +- apps/nextjs/src/app/[locale]/layout.tsx | 53 +- .../nextjs/src/app/[locale]/manage/layout.tsx | 34 +- .../general/_components/_reset-tours.tsx | 3 + .../app/[locale]/widgets/[kind]/_content.tsx | 94 +- .../app/[locale]/widgets/[kind]/layout.tsx | 22 +- .../src/app/[locale]/widgets/[kind]/page.tsx | 12 +- .../components/board/items/item-content.tsx | 143 +- .../src/components/board/items/item-menu.tsx | 36 +- .../board/items/item-move-modal.tsx | 4 +- .../board/items/item-select-data.spec.ts | 48 + .../board/items/item-select-data.ts | 28 + .../board/items/item-select-modal.module.css | 21 + .../board/items/item-select-modal.tsx | 221 ++- .../board/items/lazy-item-move-modal.tsx | 67 + .../board/items/lazy-widget-edit-modal.tsx | 68 + .../board/items/widget-context-menu.tsx | 304 +-- .../board/items/widget-hover-metadata.ts | 155 -- .../board/items/widget-query-recovery.spec.ts | 27 +- .../board/items/widget-query-recovery.ts | 8 +- .../src/components/board/sections/content.tsx | 8 +- .../integration/integration-grid-shared.ts | 30 +- .../integration-multi-select-grid.tsx | 29 +- .../integration/integration-select-grid.tsx | 26 +- apps/nextjs/src/components/layout/header.tsx | 4 +- .../src/components/layout/header/button.tsx | 3 + .../layout/header/lazy-spotlight.tsx | 89 + .../src/components/layout/header/search.tsx | 9 +- .../layout/header/tour-target.spec.ts | 71 + .../components/layout/header/tour-target.tsx | 42 +- .../src/components/layout/header/update.tsx | 52 +- .../components/layout/header/user-client.tsx | 125 ++ .../src/components/layout/header/user.tsx | 20 +- .../src/components/layout/navigation-link.tsx | 15 +- .../components/onboarding/board-tour-gate.tsx | 11 + .../src/components/onboarding/board-tour.tsx | 27 +- .../onboarding/manage-tour-gate.tsx | 12 + .../src/components/onboarding/manage-tour.tsx | 51 +- .../src/components/user-avatar-menu.tsx | 85 +- apps/nextjs/src/instrumentation-noop.ts | 1 + apps/nextjs/src/instrumentation.spec.ts | 59 +- apps/nextjs/src/instrumentation.ts | 40 +- apps/nextjs/src/proxy.spec.ts | 38 + apps/nextjs/src/proxy.ts | 34 +- apps/nextjs/src/theme/color-scheme.ts | 7 +- apps/tasks/src/main.ts | 2 +- apps/websocket/src/main.ts | 20 +- package.json | 7 + packages/api/package.json | 3 +- packages/api/src/board-server.ts | 10 +- packages/api/src/query-cache.ts | 62 +- packages/api/src/root.ts | 1 - packages/api/src/router/board.ts | 17 +- packages/api/src/router/board/board-access.ts | 11 +- packages/api/src/router/query-cache.ts | 42 - packages/api/src/router/widgets/rssFeed.ts | 3 + packages/api/src/router/widgets/weather.ts | 3 + packages/api/src/server-settings-server.ts | 8 + packages/api/src/test/query-cache.spec.ts | 173 +- packages/api/src/user-server.ts | 24 + packages/api/src/widget-prefetch-server.ts | 11 - packages/auth/callbacks.ts | 29 +- .../auth/permissions/integration-provider.tsx | 5 - .../integrations-with-permissions.ts | 23 +- packages/boards/src/context.tsx | 11 - .../definitions/src/widget-integration-map.ts | 8 +- packages/redis/src/index.ts | 3 - packages/redis/src/lib/channel.ts | 31 - .../src/lib/integration-request-handler.ts | 21 +- .../src/lib/request-handler.spec.ts | 224 +++ .../src/lib/request-handler.ts | 87 +- .../src/lib/widget-request-handler.ts | 10 +- .../src/test/update-checker-cache.spec.ts | 31 +- .../request-handler/src/update-checker.ts | 13 +- packages/spotlight/package.json | 2 + packages/spotlight/src/component.ts | 3 + .../spotlight/src/components/spotlight.tsx | 40 +- packages/spotlight/src/index.ts | 22 +- packages/spotlight/src/open.spec.ts | 54 + packages/spotlight/src/open.ts | 44 + packages/spotlight/src/spotlight-store.ts | 1 - packages/widgets/package.json | 2 +- packages/widgets/src/anchor-note/index.ts | 1 + packages/widgets/src/app/index.ts | 1 + packages/widgets/src/beszel-alerts/index.ts | 1 + packages/widgets/src/bookmarks/index.tsx | 1 + packages/widgets/src/catalog.ts | 110 ++ packages/widgets/src/client.tsx | 37 - packages/widgets/src/clock/index.ts | 1 + packages/widgets/src/definition.ts | 4 + packages/widgets/src/downloads/prefetch.ts | 30 - .../widgets/src/errors/base-component.tsx | 2 +- .../widgets/src/health-monitoring/index.ts | 1 + packages/widgets/src/manifest.spec.ts | 53 +- packages/widgets/src/manifest.ts | 89 + packages/widgets/src/media-missing/index.ts | 1 + .../widgets/src/media-requests/list/index.ts | 1 + .../widgets/src/media-requests/stats/index.ts | 1 + .../widgets/src/modals/widget-edit-modal.tsx | 14 +- packages/widgets/src/prefetch.ts | 11 +- .../src/smart-home/entity-state/index.ts | 1 + packages/widgets/src/test/translation.spec.ts | 24 +- packages/widgets/src/weather/component.tsx | 2 +- pnpm-lock.yaml | 332 +++- pnpm-workspace.yaml | 4 +- scripts/benchmarks/benchmark-compare.mts | 40 + scripts/benchmarks/benchmark-pair-lib.mts | 713 +++++++ scripts/benchmarks/benchmark-pair-lib.spec.ts | 360 ++++ scripts/benchmarks/board-static-graph.mts | 235 +++ scripts/benchmarks/board-static-graph.spec.ts | 37 + scripts/benchmarks/dev-benchmark-lib.mts | 38 +- scripts/benchmarks/dev-benchmark-lib.spec.ts | 29 +- scripts/benchmarks/dev-board.mts | 225 ++- scripts/benchmarks/docker-build-lib.mts | 259 +++ scripts/benchmarks/docker-build-lib.spec.ts | 224 +++ scripts/benchmarks/docker-build.mts | 351 ++++ scripts/benchmarks/docker-runtime-lib.mts | 649 +++++++ scripts/benchmarks/docker-runtime-lib.spec.ts | 427 +++++ scripts/benchmarks/docker-runtime.mts | 1656 +++++++++++++++++ scripts/benchmarks/request-handler-memory.mts | 107 ++ 135 files changed, 8521 insertions(+), 1718 deletions(-) create mode 100644 apps/nextjs/src/app/[locale]/_client-providers/development-tools.tsx delete mode 100644 apps/nextjs/src/app/[locale]/_client-providers/query-cache-persister.ts create mode 100644 apps/nextjs/src/app/[locale]/_client-providers/query-retry.spec.ts create mode 100644 apps/nextjs/src/app/[locale]/_client-providers/query-retry.ts create mode 100644 apps/nextjs/src/app/[locale]/_client-providers/session-query-scope.spec.ts create mode 100644 apps/nextjs/src/app/[locale]/_client-providers/session-query-scope.ts create mode 100644 apps/nextjs/src/app/[locale]/boards/(content)/_board-add-menu.tsx create mode 100644 apps/nextjs/src/components/board/items/item-select-data.spec.ts create mode 100644 apps/nextjs/src/components/board/items/item-select-data.ts create mode 100644 apps/nextjs/src/components/board/items/item-select-modal.module.css create mode 100644 apps/nextjs/src/components/board/items/lazy-item-move-modal.tsx create mode 100644 apps/nextjs/src/components/board/items/lazy-widget-edit-modal.tsx delete mode 100644 apps/nextjs/src/components/board/items/widget-hover-metadata.ts create mode 100644 apps/nextjs/src/components/layout/header/lazy-spotlight.tsx create mode 100644 apps/nextjs/src/components/layout/header/tour-target.spec.ts create mode 100644 apps/nextjs/src/components/layout/header/user-client.tsx create mode 100644 apps/nextjs/src/components/onboarding/board-tour-gate.tsx create mode 100644 apps/nextjs/src/components/onboarding/manage-tour-gate.tsx delete mode 100644 packages/api/src/router/query-cache.ts create mode 100644 packages/api/src/server-settings-server.ts create mode 100644 packages/api/src/user-server.ts delete mode 100644 packages/api/src/widget-prefetch-server.ts create mode 100644 packages/request-handler/src/lib/request-handler.spec.ts create mode 100644 packages/spotlight/src/component.ts create mode 100644 packages/spotlight/src/open.spec.ts create mode 100644 packages/spotlight/src/open.ts create mode 100644 packages/widgets/src/catalog.ts delete mode 100644 packages/widgets/src/client.tsx delete mode 100644 packages/widgets/src/downloads/prefetch.ts create mode 100644 scripts/benchmarks/benchmark-compare.mts create mode 100644 scripts/benchmarks/benchmark-pair-lib.mts create mode 100644 scripts/benchmarks/benchmark-pair-lib.spec.ts create mode 100644 scripts/benchmarks/board-static-graph.mts create mode 100644 scripts/benchmarks/board-static-graph.spec.ts create mode 100644 scripts/benchmarks/docker-build-lib.mts create mode 100644 scripts/benchmarks/docker-build-lib.spec.ts create mode 100644 scripts/benchmarks/docker-build.mts create mode 100644 scripts/benchmarks/docker-runtime-lib.mts create mode 100644 scripts/benchmarks/docker-runtime-lib.spec.ts create mode 100644 scripts/benchmarks/docker-runtime.mts create mode 100644 scripts/benchmarks/request-handler-memory.mts diff --git a/Dockerfile b/Dockerfile index 21c66b57e..afc7b5ded 100644 --- a/Dockerfile +++ b/Dockerfile @@ -3,8 +3,11 @@ FROM node:24.18.0-alpine AS base FROM base AS builder +ARG TARGETPLATFORM WORKDIR /app -RUN apk add --no-cache libc6-compat curl bash && apk update +# Native dependencies normally use prebuilds, but the build must remain +# reproducible when a prebuild download times out and compilation is required. +RUN apk add --no-cache libc6-compat curl bash python3 make g++ RUN corepack enable pnpm COPY .npmrc pnpm-lock.yaml pnpm-workspace.yaml package.json ./ @@ -22,13 +25,14 @@ COPY --parents ./packages/definitions/src ./ RUN --mount=type=cache,id=pnpm-store,target=/pnpm/store \ pnpm config set store-dir /pnpm/store && \ sed -i 's/nodeLinker: hoisted/nodeLinker: isolated/' pnpm-workspace.yaml && \ - pnpm fetch && \ + pnpm fetch --ignore-scripts && \ sed -i 's/nodeLinker: isolated/nodeLinker: hoisted/' pnpm-workspace.yaml -# Follow the pnpm fetch pattern from https://pnpm.io/cli/fetch -# --frozen-lockfile is omitted as recommended by the pnpm fetch docs +# Install only from the fetched, committed lockfile so local and Docker builds +# resolve the same dependency graph. Serial lifecycle builds avoid esbuild's +# atomic binary replacement racing across the hoisted workspace (pnpm/pnpm#8200). RUN --mount=type=cache,id=pnpm-store,target=/pnpm/store \ - pnpm install --recursive + npm_config_nodedir=/usr/local pnpm install --recursive --offline --frozen-lockfile --child-concurrency=1 COPY . . @@ -41,6 +45,7 @@ RUN --mount=type=secret,id=TURBO_API,env=TURBO_API \ --mount=type=secret,id=TURBO_TEAM,env=TURBO_TEAM \ --mount=type=secret,id=TURBO_TOKEN,env=TURBO_TOKEN \ --mount=type=secret,id=TURBO_REMOTE_CACHE_SIGNATURE_KEY,env=TURBO_REMOTE_CACHE_SIGNATURE_KEY \ + --mount=type=cache,id=homarr-next-build-${TARGETPLATFORM},target=/app/apps/nextjs/.next/cache,sharing=locked \ TURBO_PLATFORM="${TARGETPLATFORM:-linux/amd64}" \ pnpm turbo build --filter=@homarr/nextjs... --filter=@homarr/cli diff --git a/apps/docs/docs/advanced/environment-variables/index.mdx b/apps/docs/docs/advanced/environment-variables/index.mdx index ad249e734..e7dc6960d 100644 --- a/apps/docs/docs/advanced/environment-variables/index.mdx +++ b/apps/docs/docs/advanced/environment-variables/index.mdx @@ -25,15 +25,13 @@ This is especially useful for [docker secrets](https://docs.docker.com/engine/sw Using the `PUID` and `PGID` will require you to set the correct permissions on the mounted volumes and if used the docker socket. See more in the [Running as a different user](/docs/advanced/running-as-different-user) documentation. ::: -| Environment Variable | Description | Possible values | Default | -| ------------------------ | ----------- | --------------- | ------- | -| ``PUID`` | User ID to run the container as | Any valid user ID | ``0`` | -| ``PGID`` | Group ID to run the container as | Any valid group ID | ``0`` | -| ``LOG_LEVEL`` | Log level to use | ``debug`` / ``info`` / ``warn`` / ``error`` | ``info`` | -| ``NO_EXTERNAL_CONNECTION`` | Disables some requests that need internet connection | `true` or `false` | `false` | -| ``ENABLE_DNS_CACHING`` | Enables dns caching. Enabled by default, but if you experience issues with IPv6 or static IPs, see [#4006](https://github.com/homarr-labs/homarr/issues/4006) or disable this flag | `true` or `false` | `true` | - - +| Environment Variable | Description | Possible values | Default | +| ------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------- | ------- | +| `PUID` | User ID to run the container as | Any valid user ID | `0` | +| `PGID` | Group ID to run the container as | Any valid group ID | `0` | +| `LOG_LEVEL` | Log level to use | `debug` / `info` / `warn` / `error` | `info` | +| `NO_EXTERNAL_CONNECTION` | Disables Homarr's internet-backed requests, including update checks, weather, and RSS feeds | `true` or `false` | `false` | +| `ENABLE_DNS_CACHING` | Enables dns caching. Enabled by default, but if you experience issues with IPv6 or static IPs, see [#4006](https://github.com/homarr-labs/homarr/issues/4006) or disable this flag | `true` or `false` | `true` | ## Authentication @@ -43,9 +41,9 @@ See [Single Sign-On](/docs/advanced/single-sign-on) for more informations. The `SECRET_ENCRYPTION_KEY` is required. If none is specified before starting the container, a random key will be shown in the error message and the container will exit. -| Environment Variable | Description | Possible values | Default | -| ------------------------ | ----------- | --------------- | ------- | -| ``SECRET_ENCRYPTION_KEY`` | Secret used to encrypt secrets in database. | 64 character hex string | - | +| Environment Variable | Description | Possible values | Default | +| ----------------------- | ------------------------------------------- | ----------------------- | ------- | +| `SECRET_ENCRYPTION_KEY` | Secret used to encrypt secrets in database. | 64 character hex string | - | :::info A random secret can be generated by using the following command: `openssl rand -hex 32` @@ -53,27 +51,27 @@ A random secret can be generated by using the following command: `openssl rand - ## Docker -| Environment Variable | Description | Possible values | Default | -| ------------------------ | ----------- | --------------- | ------- | -| ``DOCKER_HOSTNAMES`` | Comma-separated list of hostnames to connect to | For example `localhost,docker.example.com` | - | -| ``DOCKER_PORTS`` | Comma-separated list of ports to connect to | For example `2375,2376` | - | -| ``DOCKER_SOCKET_PATHS`` | Comma-separated list of mounted Docker-compatible socket paths. See the [Docker and Podman integration guide](/docs/integrations/docker) | For example `/var/run/docker.sock,/var/run/podman.sock` | - | +| Environment Variable | Description | Possible values | Default | +| --------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------- | ------- | +| `DOCKER_HOSTNAMES` | Comma-separated list of hostnames to connect to | For example `localhost,docker.example.com` | - | +| `DOCKER_PORTS` | Comma-separated list of ports to connect to | For example `2375,2376` | - | +| `DOCKER_SOCKET_PATHS` | Comma-separated list of mounted Docker-compatible socket paths. See the [Docker and Podman integration guide](/docs/integrations/docker) | For example `/var/run/docker.sock,/var/run/podman.sock` | - | ## Database -| Environment Variable | Description | Possible values | Default | -| ------------------------ | ----------- | --------------- | ------- | -| ``DB_DRIVER`` | Database driver to use. Currently `better-sqlite3` is used for sqlite, `mysql2` for mysql and `node-postgres` for postgresql | ``better-sqlite3`` / ``mysql2`` / ``node-postgres`` | ``better-sqlite3`` | -| ``DB_DIALECT`` | Database dialect to use. | ``sqlite`` / ``mysql`` / ``postgresql`` | ``sqlite`` | -| ``DB_URL`` | Database URL to connect to. | Any valid database URL | ``/appdata/db/db.sqlite`` | -| ``DB_HOST`` | Database host to connect to. | Any valid database host | - | -| ``DB_PORT`` | Database port to connect to. | Any valid database port | - | -| ``DB_NAME`` | Database name to connect to. | Any valid database name | - | -| ``DB_USER`` | Database user to connect with. | Any valid database user | - | -| ``DB_PASSWORD`` | Database password to connect with. | Any valid database password | - | + +| Environment Variable | Description | Possible values | Default | +| -------------------- | ---------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------- | ----------------------- | +| `DB_DRIVER` | Database driver to use. Currently `better-sqlite3` is used for sqlite, `mysql2` for mysql and `node-postgres` for postgresql | `better-sqlite3` / `mysql2` / `node-postgres` | `better-sqlite3` | +| `DB_DIALECT` | Database dialect to use. | `sqlite` / `mysql` / `postgresql` | `sqlite` | +| `DB_URL` | Database URL to connect to. | Any valid database URL | `/appdata/db/db.sqlite` | +| `DB_HOST` | Database host to connect to. | Any valid database host | - | +| `DB_PORT` | Database port to connect to. | Any valid database port | - | +| `DB_NAME` | Database name to connect to. | Any valid database name | - | +| `DB_USER` | Database user to connect with. | Any valid database user | - | +| `DB_PASSWORD` | Database password to connect with. | Any valid database password | - | You can either use the url or host, port, name and credentials combined. The URL will be prioritized over the other values. - ## Redis By default Redis is running within the installation. (for example in Docker). @@ -83,25 +81,23 @@ However for example for K8s it can be useful to use an external Redis instance. External redis is currently not supported with Proxmox Community Scripts ::: -| Environment Variable | Description | Possible values | Default | -| ------------------------ | ----------- | --------------- | ------- | -| `REDIS_IS_EXTERNAL` | Whether Redis is running externally. | `true` / `false` | `false` | -| `REDIS_HOST` | Hostname of the Redis instance. | Any valid hostname | - | -| `REDIS_PORT` | Port of the Redis instance. | Any valid port | `6379` | -| `REDIS_USERNAME` | Username to connect to the Redis instance. | Any valid username | - | -| `REDIS_PASSWORD` | Password to connect to the Redis instance. | Any valid password | - | -| `REDIS_DATABASE_INDEX` | Select which database of your redis instance should be used. | Any numeric index | - | -| `REDIS_TLS_CA` | CA certificate for Redis TLS connections. If a certificate is specified the connection will use TLS. | Any valid CA certificate | - | - +| Environment Variable | Description | Possible values | Default | +| ---------------------- | ---------------------------------------------------------------------------------------------------- | ------------------------ | ------- | +| `REDIS_IS_EXTERNAL` | Whether Redis is running externally. | `true` / `false` | `false` | +| `REDIS_HOST` | Hostname of the Redis instance. | Any valid hostname | - | +| `REDIS_PORT` | Port of the Redis instance. | Any valid port | `6379` | +| `REDIS_USERNAME` | Username to connect to the Redis instance. | Any valid username | - | +| `REDIS_PASSWORD` | Password to connect to the Redis instance. | Any valid password | - | +| `REDIS_DATABASE_INDEX` | Select which database of your redis instance should be used. | Any numeric index | - | +| `REDIS_TLS_CA` | CA certificate for Redis TLS connections. If a certificate is specified the connection will use TLS. | Any valid CA certificate | - | ## Proxy -| Environment Variable | Description | Possible values | Default | -| ------------------------ | ----------- | --------------- | ------- | -| ``HTTP_PROXY`` | HTTP proxy to use | Any valid HTTP proxy URL | - | -| ``HTTPS_PROXY`` | HTTPS proxy to use | Any valid HTTPS proxy URL | - | -| ``NO_PROXY`` | Comma separated list of hosts that should be excluded from proxying | Any valid hostnames | - | - +| Environment Variable | Description | Possible values | Default | +| -------------------- | ------------------------------------------------------------------- | ------------------------- | ------- | +| `HTTP_PROXY` | HTTP proxy to use | Any valid HTTP proxy URL | - | +| `HTTPS_PROXY` | HTTPS proxy to use | Any valid HTTPS proxy URL | - | +| `NO_PROXY` | Comma separated list of hosts that should be excluded from proxying | Any valid hostnames | - | ## Advanced deployments @@ -109,6 +105,6 @@ External redis is currently not supported with Proxmox Community Scripts The advanced deployments environment variables should only be used if you know what you are doing. ::: -| Environment Variable | Description | -| ------------------------ | ----------- | -| ``DB_MIGRATIONS_DISABLED`` | Disable db migrations. For example for helm charts | +| Environment Variable | Description | +| ------------------------ | -------------------------------------------------- | +| `DB_MIGRATIONS_DISABLED` | Disable db migrations. For example for helm charts | diff --git a/apps/nextjs/next.config.ts b/apps/nextjs/next.config.ts index 329cf89f1..2105259be 100644 --- a/apps/nextjs/next.config.ts +++ b/apps/nextjs/next.config.ts @@ -46,6 +46,7 @@ const nextConfig: NextConfig = { serverExternalPackages: ["dockerode", "isomorphic-dompurify", "jsdom", "better-sqlite3"], experimental: { optimizePackageImports: ["@mantine/core", "@mantine/hooks", "@tabler/icons-react"], + turbopackFileSystemCacheForBuild: true, useTypeScriptCli: true, }, turbopack: { diff --git a/apps/nextjs/package.json b/apps/nextjs/package.json index 4a3e0dd3e..3c33a1545 100644 --- a/apps/nextjs/package.json +++ b/apps/nextjs/package.json @@ -59,11 +59,10 @@ "@modelcontextprotocol/sdk": "catalog:", "@scalar/api-reference-react": "catalog:", "@tabler/icons-react": "catalog:", - "@tanstack/query-async-storage-persister": "catalog:", + "@tanstack/react-devtools": "catalog:", "@tanstack/react-query": "catalog:", "@tanstack/react-query-devtools": "catalog:", "@tanstack/react-query-next-experimental": "catalog:", - "@tanstack/react-query-persist-client": "catalog:", "@trpc/client": "catalog:", "@trpc/next": "catalog:", "@trpc/react-query": "catalog:", diff --git a/apps/nextjs/src/app/[locale]/_client-providers/development-tools.tsx b/apps/nextjs/src/app/[locale]/_client-providers/development-tools.tsx new file mode 100644 index 000000000..a23164547 --- /dev/null +++ b/apps/nextjs/src/app/[locale]/_client-providers/development-tools.tsx @@ -0,0 +1,120 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { usePathname } from "next/navigation"; +import { TanStackDevtools } from "@tanstack/react-devtools"; +import type { TanStackDevtoolsReactPlugin } from "@tanstack/react-devtools"; +import type { QueryClient } from "@tanstack/react-query"; +import { useQueryClient } from "@tanstack/react-query"; +import { ReactQueryDevtoolsPanel } from "@tanstack/react-query-devtools"; + +const valueStyle = { fontFamily: "monospace", overflowWrap: "anywhere" } as const; +const metricStyle = { + background: "color-mix(in srgb, currentColor 4%, transparent)", + border: "1px solid color-mix(in srgb, currentColor 18%, transparent)", + borderRadius: 8, + padding: 12, +} as const; + +const readDiagnostics = (queryClient: QueryClient) => { + const queries = queryClient.getQueryCache().getAll(); + const mutations = queryClient.getMutationCache().getAll(); + const board = document.querySelector("[data-homarr-dev-benchmark-board]"); + const widgetItems = board?.querySelectorAll('[data-type="item"]').length ?? 0; + const readyWidgets = board?.querySelectorAll("[data-homarr-widget-ready]").length ?? 0; + const resources = performance.getEntriesByType("resource") as PerformanceResourceTiming[]; + + return { + active: queries.filter((query) => query.getObserversCount() > 0).length, + errors: queries.filter((query) => query.state.status === "error").length, + fetching: queries.filter((query) => query.state.fetchStatus === "fetching").length, + pendingMutations: mutations.filter((mutation) => mutation.state.status === "pending").length, + stale: queries.filter((query) => query.isStale()).length, + total: queries.length, + readyWidgets, + widgetItems, + scriptKiB: Math.round( + resources + .filter((resource) => resource.initiatorType === "script") + .reduce((total, resource) => total + resource.decodedBodySize, 0) / 1024, + ), + }; +}; + +const Metric = ({ label, value }: { label: string; value: number | string }) => ( +
+
{label}
+ {value} +
+); + +const HomarrDevtoolsPanel = ({ active }: { active: boolean }) => { + const pathname = usePathname(); + const queryClient = useQueryClient(); + const [diagnostics, setDiagnostics] = useState(() => readDiagnostics(queryClient)); + + useEffect(() => { + if (!active) return; + + const update = () => setDiagnostics(readDiagnostics(queryClient)); + let frame: number | undefined; + const scheduleUpdate = () => { + if (frame !== undefined) return; + frame = window.requestAnimationFrame(() => { + frame = undefined; + update(); + }); + }; + const unsubscribeQueries = queryClient.getQueryCache().subscribe(scheduleUpdate); + const unsubscribeMutations = queryClient.getMutationCache().subscribe(scheduleUpdate); + const observer = new MutationObserver(scheduleUpdate); + observer.observe(document.body, { childList: true, subtree: true }); + update(); + + return () => { + unsubscribeQueries(); + unsubscribeMutations(); + observer.disconnect(); + if (frame !== undefined) window.cancelAnimationFrame(frame); + }; + }, [active, pathname, queryClient]); + + if (!active) return null; + + return ( +
+
+

Homarr performance

+

Live state for {pathname}

+
+
+ + + + + + + + +
+
+ ); +}; + +const developmentToolsPlugins = [ + { + id: "tanstack-query", + name: "TanStack Query", + render: () => , + }, + { + id: "homarr-performance", + name: "Homarr", + defaultOpen: true, + render: (_element, { devtoolsOpen }) => , + }, +] satisfies TanStackDevtoolsReactPlugin[]; + +export const DevelopmentTools = () => ( + +); diff --git a/apps/nextjs/src/app/[locale]/_client-providers/query-cache-persister.ts b/apps/nextjs/src/app/[locale]/_client-providers/query-cache-persister.ts deleted file mode 100644 index 3af24c508..000000000 --- a/apps/nextjs/src/app/[locale]/_client-providers/query-cache-persister.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { createAsyncStoragePersister } from "@tanstack/query-async-storage-persister"; -import superjson from "superjson"; - -import { fetchApi } from "@homarr/api/client"; -import { getActiveQueryCacheBoardId, queryCacheStoragePrefix } from "@homarr/api/query-cache"; - -const queryCacheStorage = { - getItem: (_key: string) => null as string | null, - async setItem(_key: string, value: string) { - const boardId = getActiveQueryCacheBoardId(); - if (!boardId) return; - await fetchApi.queryCache.setItem.mutate({ boardId, value }).catch((error) => { - console.warn("[query-cache] persist failed", error); - }); - }, - async removeItem(_key: string) { - const boardId = getActiveQueryCacheBoardId(); - if (!boardId) return; - await fetchApi.queryCache.removeItem.mutate({ boardId }).catch((error) => { - console.warn("[query-cache] remove failed", error); - }); - }, -}; - -export const createWidgetQueryPersister = () => - createAsyncStoragePersister({ - storage: typeof window === "undefined" ? undefined : queryCacheStorage, - key: queryCacheStoragePrefix, - throttleTime: 2000, - serialize: (data) => superjson.stringify(data), - deserialize: (data) => superjson.parse(data), - }); diff --git a/apps/nextjs/src/app/[locale]/_client-providers/query-retry.spec.ts b/apps/nextjs/src/app/[locale]/_client-providers/query-retry.spec.ts new file mode 100644 index 000000000..77aa7c918 --- /dev/null +++ b/apps/nextjs/src/app/[locale]/_client-providers/query-retry.spec.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from "vitest"; +import { TRPCClientError } from "@trpc/client"; + +import { createQueryRetry } from "./query-retry"; + +const createTrpcError = (code: string) => { + const error = Object.assign(new Error(code), { data: { code } }); + Object.setPrototypeOf(error, TRPCClientError.prototype); + return error; +}; + +describe("createQueryRetry", () => { + it.each(["BAD_REQUEST", "UNAUTHORIZED", "FORBIDDEN", "NOT_FOUND", "UNPROCESSABLE_CONTENT"])( + "does not retry the permanent %s response", + (code) => { + expect(createQueryRetry(3)(0, createTrpcError(code))).toBe(false); + }, + ); + + it("retries transient and network failures only up to the configured limit", () => { + const retry = createQueryRetry(3); + + expect(retry(0, createTrpcError("INTERNAL_SERVER_ERROR"))).toBe(true); + expect(retry(2, new Error("connection reset"))).toBe(true); + expect(retry(3, new Error("connection reset"))).toBe(false); + }); +}); diff --git a/apps/nextjs/src/app/[locale]/_client-providers/query-retry.ts b/apps/nextjs/src/app/[locale]/_client-providers/query-retry.ts new file mode 100644 index 000000000..09b5f19c3 --- /dev/null +++ b/apps/nextjs/src/app/[locale]/_client-providers/query-retry.ts @@ -0,0 +1,20 @@ +import { TRPCClientError } from "@trpc/client"; + +const nonRetryableTrpcCodes = new Set([ + "PARSE_ERROR", + "BAD_REQUEST", + "UNAUTHORIZED", + "FORBIDDEN", + "NOT_FOUND", + "METHOD_NOT_SUPPORTED", + "CONFLICT", + "PRECONDITION_FAILED", + "PAYLOAD_TOO_LARGE", + "UNPROCESSABLE_CONTENT", +]); + +export const createQueryRetry = (maxRetries: number) => (failureCount: number, error: unknown) => { + if (failureCount >= maxRetries) return false; + if (error instanceof TRPCClientError && error.data?.code && nonRetryableTrpcCodes.has(error.data.code)) return false; + return true; +}; diff --git a/apps/nextjs/src/app/[locale]/_client-providers/session-query-scope.spec.ts b/apps/nextjs/src/app/[locale]/_client-providers/session-query-scope.spec.ts new file mode 100644 index 000000000..f82001a5e --- /dev/null +++ b/apps/nextjs/src/app/[locale]/_client-providers/session-query-scope.spec.ts @@ -0,0 +1,52 @@ +// @vitest-environment jsdom + +import { act, createElement } from "react"; +import { createRoot } from "react-dom/client"; +import { describe, expect, it, vi } from "vitest"; + +import { getSessionQueryScope, SessionQueryScopeGuard } from "./session-query-scope"; + +describe("getSessionQueryScope", () => { + it("is stable when permission order changes", () => { + expect(getSessionQueryScope({ user: { id: "user-a", permissions: ["board-view", "integration-use"] } })).toBe( + getSessionQueryScope({ user: { id: "user-a", permissions: ["integration-use", "board-view"] } }), + ); + }); + + it("changes for a different user or permission set", () => { + const original = getSessionQueryScope({ user: { id: "user-a", permissions: ["board-view"] } }); + + expect(getSessionQueryScope({ user: { id: "user-b", permissions: ["board-view"] } })).not.toBe(original); + expect(getSessionQueryScope({ user: { id: "user-a", permissions: [] } })).not.toBe(original); + expect(getSessionQueryScope(null)).toBeNull(); + }); + + it("removes the previous session subtree before requesting a document reload", async () => { + const host = document.createElement("div"); + const root = createRoot(host); + const onScopeChange = vi.fn(); + const renderScope = async (currentScope: string) => { + await act(async () => { + root.render( + createElement( + SessionQueryScopeGuard, + { currentScope, initialScope: "a", onScopeChange }, + createElement("span", null, "session-a-secret"), + ), + ); + }); + }; + + try { + await renderScope("a"); + expect(host.textContent).toBe("session-a-secret"); + + await renderScope("b"); + expect(host.textContent).not.toContain("session-a-secret"); + expect(host.querySelector('[role="status"]')?.getAttribute("aria-busy")).toBe("true"); + expect(onScopeChange).toHaveBeenCalledOnce(); + } finally { + await act(async () => root.unmount()); + } + }); +}); diff --git a/apps/nextjs/src/app/[locale]/_client-providers/session-query-scope.ts b/apps/nextjs/src/app/[locale]/_client-providers/session-query-scope.ts new file mode 100644 index 000000000..d2fa1e732 --- /dev/null +++ b/apps/nextjs/src/app/[locale]/_client-providers/session-query-scope.ts @@ -0,0 +1,39 @@ +import type { PropsWithChildren } from "react"; +import { createElement, useEffect } from "react"; + +interface QuerySession { + user: { + id: string; + permissions: string[]; + }; +} + +export const getSessionQueryScope = (session: QuerySession | null | undefined) => + session ? JSON.stringify([session.user.id, session.user.permissions.toSorted()]) : null; + +interface SessionQueryScopeGuardProps { + initialScope: string | null; + currentScope: string | null; + onScopeChange: () => void; +} + +export const SessionQueryScopeGuard = ({ + initialScope, + currentScope, + onScopeChange, + children, +}: PropsWithChildren) => { + const hasScopeChanged = initialScope !== currentScope; + useEffect(() => { + if (hasScopeChanged) onScopeChange(); + }, [hasScopeChanged, onScopeChange]); + + if (!hasScopeChanged) return children; + + return createElement("div", { + "aria-busy": true, + "aria-label": "Refreshing session", + role: "status", + style: { minHeight: "100dvh" }, + }); +}; diff --git a/apps/nextjs/src/app/[locale]/_client-providers/trpc.tsx b/apps/nextjs/src/app/[locale]/_client-providers/trpc.tsx index a51dd7fad..535d88e1c 100644 --- a/apps/nextjs/src/app/[locale]/_client-providers/trpc.tsx +++ b/apps/nextjs/src/app/[locale]/_client-providers/trpc.tsx @@ -1,11 +1,10 @@ "use client"; import type { PropsWithChildren } from "react"; -import { useState } from "react"; +import { useEffect, useMemo, useState } from "react"; import dynamic from "next/dynamic"; -import { QueryClient } from "@tanstack/react-query"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { ReactQueryStreamedHydration } from "@tanstack/react-query-next-experimental"; -import { PersistQueryClientProvider } from "@tanstack/react-query-persist-client"; import { createWSClient, httpBatchStreamLink, @@ -24,23 +23,24 @@ import { TRPCClientError } from "@trpc/client"; import type { AppRouter } from "@homarr/api"; import { clientApi } from "@homarr/api/client"; import { - isPersistableWidgetQueryKey, - queryCacheBuster, + dashboardSupportingQueryPolicies, queryCacheDefaultGcTimeMs, queryCacheDefaultRefetchIntervalMs, queryCacheDefaultStaleTimeMs, } from "@homarr/api/query-cache"; import { createHeadersCallbackForSource, getTrpcUrl } from "@homarr/api/shared"; +import { useSession } from "@homarr/auth/client"; import { env } from "@homarr/common/env"; import { showWarningNotification } from "@homarr/notifications"; import { widgetQueryRefetchIntervals } from "@homarr/widgets/refetch-intervals"; -import { createWidgetQueryPersister } from "./query-cache-persister"; +import { getSessionQueryScope, SessionQueryScopeGuard } from "./session-query-scope"; +import { createQueryRetry } from "./query-retry"; -const ReactQueryDevtools = dynamic( - () => import("@tanstack/react-query-devtools").then(({ ReactQueryDevtools: DevelopmentTools }) => DevelopmentTools), - { ssr: false }, -); +const DevelopmentTools = + process.env.NODE_ENV === "development" + ? dynamic(() => import("./development-tools").then(({ DevelopmentTools: Tools }) => Tools), { ssr: false }) + : () => null; const getWebSocketProtocol = () => { if (typeof window === "undefined") { @@ -63,19 +63,46 @@ const constructWebsocketUrl = () => { return `${getWebSocketProtocol()}://${window.location.hostname}:${window.location.port}/websockets`; }; -const wsClient = createWSClient({ - url: constructWebsocketUrl(), -}); +export function TRPCReactProvider({ children }: PropsWithChildren) { + const { data: session } = useSession(); + const sessionQueryScope = getSessionQueryScope(session); + const [initialSessionQueryScope] = useState(() => sessionQueryScope); -export function TRPCReactProvider(props: PropsWithChildren) { - const [persister] = useState(() => createWidgetQueryPersister()); + return ( + + {children} + + ); +} + +const reloadPage = () => window.location.reload(); + +const ScopedTRPCReactProvider = ({ children }: PropsWithChildren) => { + const wsClient = useMemo( + () => + createWSClient({ + url: constructWebsocketUrl(), + lazy: { enabled: true, closeMs: 30_000 }, + }), + [], + ); + useEffect( + () => () => { + void wsClient.close(); + }, + [wsClient], + ); const [queryClient] = useState(() => { const client = new QueryClient({ defaultOptions: { queries: { staleTime: queryCacheDefaultStaleTimeMs, gcTime: queryCacheDefaultGcTimeMs, - retry: 3, + retry: createQueryRetry(env.NODE_ENV === "development" ? 1 : 3), }, mutations: { onError(error) { @@ -93,21 +120,29 @@ export function TRPCReactProvider(props: PropsWithChildren) { }, }, }); - client.setQueryDefaults([["widget"]], { refetchInterval: queryCacheDefaultRefetchIntervalMs }); + client.setQueryDefaults([["widget"]], { + refetchInterval: queryCacheDefaultRefetchIntervalMs, + refetchOnWindowFocus: false, + refetchOnReconnect: false, + }); for (const { queryKey, intervalSeconds } of widgetQueryRefetchIntervals) { client.setQueryDefaults(queryKey, { refetchInterval: intervalSeconds === null ? false : intervalSeconds * 1000, }); } + for (const { queryKey, ...policy } of dashboardSupportingQueryPolicies) { + client.setQueryDefaults(queryKey, policy); + } return client; }); - const [trpcClient] = useState(() => { + useEffect(() => () => queryClient.clear(), [queryClient]); + + const trpcClient = useMemo(() => { return clientApi.createClient({ links: [ loggerLink({ - enabled: (opts) => - env.NODE_ENV === "development" || (opts.direction === "down" && opts.result instanceof Error), + enabled: (opts) => opts.direction === "down" && opts.result instanceof Error, }), splitLink({ condition: ({ type }) => type === "subscription", @@ -147,25 +182,14 @@ export function TRPCReactProvider(props: PropsWithChildren) { }), ], }); - }); + }, [wsClient]); return ( - - query.state.status === "success" && isPersistableWidgetQueryKey(query.queryKey), - }, - }} - > - {props.children} - {process.env.NODE_ENV === "development" && } - + + {children} + {process.env.NODE_ENV === "development" && } + ); -} +}; diff --git a/apps/nextjs/src/app/[locale]/boards/(content)/_board-add-menu.tsx b/apps/nextjs/src/app/[locale]/boards/(content)/_board-add-menu.tsx new file mode 100644 index 000000000..e49fefe5e --- /dev/null +++ b/apps/nextjs/src/app/[locale]/boards/(content)/_board-add-menu.tsx @@ -0,0 +1,84 @@ +"use client"; + +import { useCallback } from "react"; +import { Group, Menu } from "@mantine/core"; +import { IconBox, IconBoxAlignTop, IconChevronDown, IconPlug, IconPlus, IconResize } from "@tabler/icons-react"; + +import { useSession } from "@homarr/auth/client"; +import { useModalAction } from "@homarr/modals"; +import { AppSelectModal } from "@homarr/modals-collection"; +import { useI18n } from "@homarr/translation/client"; + +import { useItemActions } from "~/components/board/items/item-actions"; +import { ItemSelectModal } from "~/components/board/items/item-select-modal"; +import { useCategoryActions } from "~/components/board/sections/category/category-actions"; +import { CategoryEditModal } from "~/components/board/sections/category/category-edit-modal"; +import { useDynamicSectionActions } from "~/components/board/sections/dynamic/dynamic-actions"; +import { IntegrationSelectModal } from "~/components/integration/integration-select-modal"; +import { HeaderButton } from "~/components/layout/header/button"; + +export const BoardAddMenu = () => { + const { data: session } = useSession(); + const { openModal: openCategoryEditModal } = useModalAction(CategoryEditModal); + const { openModal: openItemSelectModal } = useModalAction(ItemSelectModal); + const { openModal: openAppSelectModal } = useModalAction(AppSelectModal); + const { openModal: openIntegrationSelectModal } = useModalAction(IntegrationSelectModal); + const { addCategoryToEnd } = useCategoryActions(); + const { addDynamicSection } = useDynamicSectionActions(); + const { createItem } = useItemActions(); + const t = useI18n(); + + const handleAddCategory = useCallback( + () => + openCategoryEditModal( + { + category: { id: "new", name: "" }, + onSuccess({ name }) { + addCategoryToEnd({ name }); + }, + submitLabel: t("section.category.create.submit"), + }, + { title: (translate) => translate("section.category.create.title") }, + ), + [addCategoryToEnd, openCategoryEditModal, t], + ); + + return ( + + + + + + + + + + + } onClick={() => openItemSelectModal()}> + {t("item.action.create")} + + } + onClick={() => + openAppSelectModal({ + onSelect: (app) => createItem({ kind: "app", options: { appId: app.id } }), + withCreate: session?.user.permissions.includes("app-create") ?? false, + }) + } + > + {t("app.action.add")} + + } onClick={() => openIntegrationSelectModal({})}> + {t("integration.action.create")} + + + } onClick={handleAddCategory}> + {t("section.category.action.create")} + + } onClick={addDynamicSection}> + {t("section.dynamic.action.create")} + + + + ); +}; diff --git a/apps/nextjs/src/app/[locale]/boards/(content)/_creator.tsx b/apps/nextjs/src/app/[locale]/boards/(content)/_creator.tsx index 7cc39e4fb..2bb868f7d 100644 --- a/apps/nextjs/src/app/[locale]/boards/(content)/_creator.tsx +++ b/apps/nextjs/src/app/[locale]/boards/(content)/_creator.tsx @@ -1,14 +1,11 @@ import type { Metadata } from "next"; -import { cache, Suspense } from "react"; +import { cache } from "react"; import { TRPCError } from "@trpc/server"; // Placed here because gridstack styles are used for board content import "~/styles/gridstack.scss"; import { dehydrate, HydrationBoundary } from "@tanstack/react-query"; -import type { PersistedClient } from "@tanstack/react-query-persist-client"; -import superjson from "superjson"; -import { queryCacheBuster } from "@homarr/api/query-cache"; import { makeQueryClient } from "@homarr/api/shared"; import { IntegrationProvider } from "@homarr/auth/client"; import { auth } from "@homarr/auth/next"; @@ -17,7 +14,6 @@ import { isNullOrWhitespace } from "@homarr/common"; import { createLogger } from "@homarr/core/infrastructure/logs"; import { ErrorWithMetadata } from "@homarr/core/infrastructure/logs/error"; import type { WidgetKind } from "@homarr/definitions"; -import { getQueryCacheAsync } from "@homarr/redis"; import { getI18n } from "@homarr/translation/server"; import { prefetchForKindAsync } from "@homarr/widgets/prefetch"; @@ -62,7 +58,6 @@ export const createBoardContentPage = >( } return acc; }, new Map()); - const [integrations] = await Promise.all([ getIntegrationsWithPermissionsAsync(session), ...Array.from(itemsMap).map(([kind, items]) => @@ -78,19 +73,12 @@ export const createBoardContentPage = >( ), ]); - const userId = session?.user.id ?? "anonymous"; - return ( - <> - - - - - - - - - + + + + + ); }, generateMetadataAsync: async ({ params }: { params: Promise }): Promise => { @@ -121,19 +109,3 @@ export const createBoardContentPage = >( }, }; }; - -async function QueryCacheHydration({ userId, boardId }: { userId: string; boardId: string }) { - try { - const serialized = await getQueryCacheAsync(userId, boardId); - if (!serialized) return null; - - const persisted = superjson.parse(serialized); - if (!persisted || persisted.buster !== queryCacheBuster) return null; - if (!persisted.clientState?.queries?.length) return null; - - return ; - } catch (error) { - logger.warn(new ErrorWithMetadata("Failed to hydrate query cache", { userId, boardId }, { cause: error })); - return null; - } -} diff --git a/apps/nextjs/src/app/[locale]/boards/(content)/_dynamic-client.tsx b/apps/nextjs/src/app/[locale]/boards/(content)/_dynamic-client.tsx index 50b0aff66..44d0a77c6 100644 --- a/apps/nextjs/src/app/[locale]/boards/(content)/_dynamic-client.tsx +++ b/apps/nextjs/src/app/[locale]/boards/(content)/_dynamic-client.tsx @@ -1,7 +1,24 @@ "use client"; import dynamic from "next/dynamic"; +import { SimpleGrid, Skeleton, Stack } from "@mantine/core"; + +const BoardLoadingShell = () => ( + + + {[2, 1, 2, 2, 1, 2].map((rows, index) => ( + + ))} + + +); export const DynamicClientBoard = dynamic(() => import("./_client").then((mod) => mod.ClientBoard), { ssr: false, + loading: BoardLoadingShell, }); 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 30c91de40..6036a4905 100644 --- a/apps/nextjs/src/app/[locale]/boards/(content)/_header-actions.tsx +++ b/apps/nextjs/src/app/[locale]/boards/(content)/_header-actions.tsx @@ -1,45 +1,36 @@ "use client"; -import { useCallback, useEffect } from "react"; +import { useCallback, useEffect, useState } from "react"; +import dynamic from "next/dynamic"; import { useRouter } from "next/navigation"; -import { OnboardingTour } from "@gfazioli/mantine-onboarding-tour"; -import { Box, Group, Menu, ScrollArea } from "@mantine/core"; +import { Box, Center, Loader, Menu, ScrollArea } from "@mantine/core"; import { useHotkeys } from "@mantine/hooks"; -import { - IconBox, - IconBoxAlignTop, - IconChevronDown, - IconLayoutBoard, - IconPencil, - IconPencilOff, - IconPlug, - IconPlus, - IconReplace, - IconResize, - IconSettings, -} from "@tabler/icons-react"; +import { IconLayoutBoard, IconPencil, IconPencilOff, IconPlus, IconReplace, IconSettings } from "@tabler/icons-react"; import { clientApi } from "@homarr/api/client"; -import { useSession } from "@homarr/auth/client"; import { useRequiredBoard } from "@homarr/boards/context"; import { useEditMode } from "@homarr/boards/edit-mode"; import { revalidatePathActionAsync } from "@homarr/common/client"; import { env } from "@homarr/common/env"; import { hotkeys } from "@homarr/definitions"; -import { useConfirmModal, useModalAction } from "@homarr/modals"; -import { AppSelectModal } from "@homarr/modals-collection"; +import { useConfirmModal } from "@homarr/modals"; import { showErrorNotification, showSuccessNotification } from "@homarr/notifications"; import { useI18n, useScopedI18n } from "@homarr/translation/client"; import { Link } from "@homarr/ui"; -import { useItemActions } from "~/components/board/items/item-actions"; -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"; -import { CategoryEditModal } from "~/components/board/sections/category/category-edit-modal"; -import { useDynamicSectionActions } from "~/components/board/sections/dynamic/dynamic-actions"; -import { IntegrationSelectModal } from "~/components/integration/integration-select-modal"; import { HeaderButton } from "~/components/layout/header/button"; +import { TourTarget } from "~/components/layout/header/tour-target"; + +const loadBoardAddMenu = () => import("./_board-add-menu"); +const preloadBoardAddMenu = () => void loadBoardAddMenu().catch(() => undefined); +const BoardAddMenu = dynamic(() => loadBoardAddMenu().then(({ BoardAddMenu: AddMenu }) => AddMenu), { + loading: () => ( + + + + ), +}); export const BoardContentHeaderActions = ({ demoReadOnly }: { demoReadOnly: boolean }) => { const [isEditMode] = useEditMode(); @@ -52,16 +43,16 @@ export const BoardContentHeaderActions = ({ demoReadOnly }: { demoReadOnly: bool return ( <> - {isEditMode && } + {isEditMode && } {!demoReadOnly && ( - + - + )} @@ -69,94 +60,6 @@ export const BoardContentHeaderActions = ({ demoReadOnly }: { demoReadOnly: bool ); }; -const AddMenu = () => { - const { data: session } = useSession(); - const { openModal: openCategoryEditModal } = useModalAction(CategoryEditModal); - const { openModal: openItemSelectModal } = useModalAction(ItemSelectModal); - const { openModal: openAppSelectModal } = useModalAction(AppSelectModal); - const { openModal: openIntegrationSelectModal } = useModalAction(IntegrationSelectModal); - const { addCategoryToEnd } = useCategoryActions(); - const { addDynamicSection } = useDynamicSectionActions(); - const { createItem } = useItemActions(); - const t = useI18n(); - - const handleAddCategory = useCallback( - () => - openCategoryEditModal( - { - category: { - id: "new", - name: "", - }, - onSuccess({ name }) { - addCategoryToEnd({ name }); - }, - submitLabel: t("section.category.create.submit"), - }, - { - title: (t) => t("section.category.create.title"), - }, - ), - [addCategoryToEnd, openCategoryEditModal, t], - ); - - const handleSelectItem = useCallback(() => { - openItemSelectModal(); - }, [openItemSelectModal]); - - const handleSelectApp = useCallback(() => { - openAppSelectModal({ - onSelect: (app) => { - createItem({ - kind: "app", - options: { appId: app.id }, - }); - }, - withCreate: session?.user.permissions.includes("app-create") ?? false, - }); - }, [openAppSelectModal, createItem]); - - const handleAddIntegration = useCallback(() => { - openIntegrationSelectModal({}); - }, [openIntegrationSelectModal]); - - return ( - - - - - - - - - - - } onClick={handleSelectItem}> - {t("item.action.create")} - - - } onClick={handleSelectApp}> - {t("app.action.add")} - - - } onClick={handleAddIntegration}> - {t("integration.action.create")} - - - - - } onClick={handleAddCategory}> - {t("section.category.action.create")} - - - } onClick={addDynamicSection}> - {t("section.dynamic.action.create")} - - - - ); -}; - const EditModeMenu = ({ demoReadOnly }: { demoReadOnly: boolean }) => { const [isEditMode, { open, close }] = useEditMode(); const board = useRequiredBoard(); @@ -197,21 +100,29 @@ const EditModeMenu = ({ demoReadOnly }: { demoReadOnly: boolean }) => { usePreventLeaveWithDirty(isEditMode); return ( - - + + {isEditMode ? : } - + ); }; const SelectBoardsMenu = () => { - const { data: boards = [] } = clientApi.board.getAllBoards.useQuery(); + const [isOpen, setIsOpen] = useState(false); + const utils = clientApi.useUtils(); + const { data: boards = [], isPending } = clientApi.board.getAllBoards.useQuery(undefined, { enabled: isOpen }); + const preloadBoards = () => void utils.board.getAllBoards.prefetch(); return ( - - - + + + @@ -219,6 +130,11 @@ const SelectBoardsMenu = () => { + {isPending && ( +
+ +
+ )} {boards.map((board) => ( {
- +
); }; @@ -246,15 +162,13 @@ const usePreventLeaveWithDirty = (isDirty: boolean) => { useEffect(() => { if (!isDirty) return; - const handleClick = (event: Event) => { - const target = (event.target as HTMLElement).closest("a"); - - if (!target) { - console.warn("No anchor element found for click event", event); - return; - } + const handleClick = (event: MouseEvent) => { + if (event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return; + const target = event.target instanceof Element ? event.target.closest(anchorSelector) : null; + if (!target) return; event.preventDefault(); + event.stopPropagation(); openConfirmModal({ title: t("board.action.edit.confirmLeave.title"), @@ -280,19 +194,14 @@ const usePreventLeaveWithDirty = (isDirty: boolean) => { event.returnValue = true; }; - const anchors = document.querySelectorAll(anchorSelector); - anchors.forEach((link) => { - link.addEventListener("click", handleClick); - }); + document.addEventListener("click", handleClick, true); window.addEventListener("popstate", handlePopState); window.addEventListener("beforeunload", handleBeforeUnload); return () => { - anchors.forEach((link) => { - link.removeEventListener("click", handleClick); - }); + document.removeEventListener("click", handleClick, true); window.removeEventListener("popstate", handlePopState); window.removeEventListener("beforeunload", handleBeforeUnload); }; - }, [isDirty]); + }, [isDirty, openConfirmModal, router, t]); }; diff --git a/apps/nextjs/src/app/[locale]/boards/_layout-creator.tsx b/apps/nextjs/src/app/[locale]/boards/_layout-creator.tsx index 3cddc6685..24ebe3d03 100644 --- a/apps/nextjs/src/app/[locale]/boards/_layout-creator.tsx +++ b/apps/nextjs/src/app/[locale]/boards/_layout-creator.tsx @@ -3,6 +3,7 @@ import { notFound, redirect } from "next/navigation"; import { AppShellMain } from "@mantine/core"; import { TRPCError } from "@trpc/server"; +import { getRscUserSettingsAsync } from "@homarr/api/user-server"; import { auth } from "@homarr/auth/next"; import { BoardProvider } from "@homarr/boards/context"; import { EditModeProvider } from "@homarr/boards/edit-mode"; @@ -11,7 +12,8 @@ import { createLogger } from "@homarr/core/infrastructure/logs"; import { MainHeader } from "~/components/layout/header"; import { BoardLogoWithTitle } from "~/components/layout/logo/board-logo"; import { ClientShell } from "~/components/layout/shell"; -import { BoardTourProvider } from "~/components/onboarding/board-tour"; +import { BoardTourGate } from "~/components/onboarding/board-tour-gate"; +import { env } from "~/env"; import { getCurrentColorSchemeAsync } from "~/theme/color-scheme"; import type { Board } from "./_types"; import type { Params } from "./(content)/_creator"; @@ -21,11 +23,6 @@ import { BoardMantineProvider } from "./(content)/_theme"; const logger = createLogger({ module: "createBoardLayout" }); -const BoardTourWrapper = ({ hasSession, children }: PropsWithChildren<{ hasSession: boolean }>) => { - if (!hasSession) return <>{children}; - return {children}; -}; - interface CreateBoardLayoutProps { headerActions: JSX.Element; getInitialBoardAsync: (params: TParams) => Promise; @@ -43,8 +40,32 @@ export const createBoardLayout = ({ }: PropsWithChildren<{ params: Promise; }>) => { - const session = await auth(); - const initialBoard = await getInitialBoard(await params).catch((error) => { + const resolvedParams = await params; + const sessionPromise = auth(); + const initialBoardPromise = getInitialBoard(resolvedParams).then( + (board) => ({ status: "fulfilled", board }) as const, + (error: unknown) => ({ status: "rejected", error }) as const, + ); + const colorSchemePromise = getCurrentColorSchemeAsync(); + const shouldRunBoardTourPromise = sessionPromise.then(async (session) => { + if (!withTour || !session || env.DEMO_MODE) return false; + + try { + const user = await getRscUserSettingsAsync(session.user.id); + return user !== undefined && !user.completedBoardTour; + } catch (error) { + logger.error(new Error("Failed to load the board tour status", { cause: error })); + return false; + } + }); + const [session, initialBoardResult, colorScheme, shouldRunBoardTour] = await Promise.all([ + sessionPromise, + initialBoardPromise, + colorSchemePromise, + shouldRunBoardTourPromise, + ]); + if (initialBoardResult.status === "rejected") { + const { error } = initialBoardResult; if (error instanceof TRPCError && error.code === "NOT_FOUND") { if (!session) { logger.debug("No home board found for anonymous user, redirecting to login"); @@ -60,8 +81,8 @@ export const createBoardLayout = ({ } throw error; - }); - const colorScheme = await getCurrentColorSchemeAsync(); + } + const initialBoard = initialBoardResult.board; return ( @@ -69,7 +90,7 @@ export const createBoardLayout = ({ - + } @@ -78,7 +99,7 @@ export const createBoardLayout = ({ /> {children} - + diff --git a/apps/nextjs/src/app/[locale]/layout.tsx b/apps/nextjs/src/app/[locale]/layout.tsx index 598a0a258..ea62beadf 100644 --- a/apps/nextjs/src/app/[locale]/layout.tsx +++ b/apps/nextjs/src/app/[locale]/layout.tsx @@ -13,13 +13,11 @@ import { notFound } from "next/navigation"; import type { DayOfWeek } from "@mantine/dates"; import { NextIntlClientProvider } from "next-intl"; +import { getRscServerSettingsAsync } from "@homarr/api/server-settings-server"; +import { getRscUserSettingsAsync } from "@homarr/api/user-server"; import { env } from "@homarr/auth/env"; import { auth } from "@homarr/auth/next"; import { createLogger } from "@homarr/core/infrastructure/logs"; -import { db } from "@homarr/db"; -import { eq } from "@homarr/db"; -import { getServerSettingsAsync } from "@homarr/db/queries"; -import { users } from "@homarr/db/schema"; import { ModalProvider } from "@homarr/modals"; import { Notifications } from "@homarr/notifications"; import { SettingsProvider } from "@homarr/settings"; @@ -82,42 +80,27 @@ export default async function Layout(props: { children: React.ReactNode; params: Promise<{ locale: SupportedLanguage }>; }) { - if (!isLocaleSupported((await props.params).locale)) { + const { locale } = await props.params; + if (!isLocaleSupported(locale)) { notFound(); } - const session = await auth(); - const user = session - ? await db.query.users - .findFirst({ - columns: { - id: true, - name: true, - email: true, - emailVerified: true, - image: true, - provider: true, - homeBoardId: true, - mobileHomeBoardId: true, - firstDayOfWeek: true, - pingIconsEnabled: true, - enableRightClickOnWidgets: true, - defaultSearchEngineId: true, - openSearchInNewTab: true, - ddgBangs: true, - completedManageTour: true, - completedBoardTour: true, - }, - where: eq(users.id, session.user.id), - }) - .catch((error: unknown) => { + const sessionPromise = auth(); + const userPromise = sessionPromise.then((session) => + session + ? getRscUserSettingsAsync(session.user.id).catch((error: unknown) => { logger.error(new Error("Failed to load the authenticated user in the root layout", { cause: error })); return null; }) - : null; - const serverSettings = await getServerSettingsAsync(db); - const colorScheme = await getCurrentColorSchemeAsync(); - const direction = isLocaleRTL((await props.params).locale) ? "rtl" : "ltr"; + : null, + ); + const [session, user, serverSettings, colorScheme] = await Promise.all([ + sessionPromise, + userPromise, + getRscServerSettingsAsync(), + getCurrentColorSchemeAsync(), + ]); + const direction = isLocaleRTL(locale) ? "rtl" : "ltr"; const StackedProvider = composeWrappers([ (innerProps) => { @@ -156,8 +139,6 @@ export default async function Layout(props: { (innerProps) => , ]); - const { locale } = await props.params; - return ( // Instead of ColorSchemScript we use data-mantine-color-scheme to prevent flickering { + if (!session || nextEnv.DEMO_MODE) return false; + + try { + const user = await getRscUserSettingsAsync(session.user.id); + return user !== undefined && !user.completedManageTour; + } catch (error) { + logger.error(new Error("Failed to load the management tour status", { cause: error })); + return false; + } + }); + const [t, session, shouldRunManageTour] = await Promise.all([ + getScopedI18n("management.navbar"), + sessionPromise, + shouldRunManageTourPromise, + ]); const navigationLinks: NavigationLink[] = [ { label: t("items.home"), @@ -225,7 +245,9 @@ export default async function ManageLayout({ children }: PropsWithChildren) { ); - if (!session) return shell; - - return {shell}; + return ( + + {shell} + + ); } diff --git a/apps/nextjs/src/app/[locale]/manage/users/[userId]/general/_components/_reset-tours.tsx b/apps/nextjs/src/app/[locale]/manage/users/[userId]/general/_components/_reset-tours.tsx index 76f33a8db..82e50d218 100644 --- a/apps/nextjs/src/app/[locale]/manage/users/[userId]/general/_components/_reset-tours.tsx +++ b/apps/nextjs/src/app/[locale]/manage/users/[userId]/general/_components/_reset-tours.tsx @@ -1,5 +1,6 @@ "use client"; +import { useRouter } from "next/navigation"; import { Badge, Button, Group, Stack, Text } from "@mantine/core"; import { IconRefresh } from "@tabler/icons-react"; @@ -9,6 +10,7 @@ import { useScopedI18n } from "@homarr/translation/client"; export const ResetTours = () => { const t = useScopedI18n("management.page.user.setting.general.item.onboardingTours"); + const router = useRouter(); const utils = clientApi.useUtils(); const { data: tourStatus } = clientApi.user.getTourStatus.useQuery(); const { mutate: resetTours, isPending } = clientApi.user.resetTours.useMutation({ @@ -18,6 +20,7 @@ export const ResetTours = () => { message: t("resetSuccess.message"), }); void utils.user.getTourStatus.invalidate(); + router.refresh(); }, }); diff --git a/apps/nextjs/src/app/[locale]/widgets/[kind]/_content.tsx b/apps/nextjs/src/app/[locale]/widgets/[kind]/_content.tsx index 3e604ec7f..882842ee7 100644 --- a/apps/nextjs/src/app/[locale]/widgets/[kind]/_content.tsx +++ b/apps/nextjs/src/app/[locale]/widgets/[kind]/_content.tsx @@ -6,37 +6,33 @@ import { IconDimensions, IconPencil, IconToggleLeft, IconToggleRight } from "@ta import { QueryErrorResetBoundary } from "@tanstack/react-query"; import { ErrorBoundary } from "react-error-boundary"; -import type { IntegrationKind, WidgetKind } from "@homarr/definitions"; +import { clientApi } from "@homarr/api/client"; +import type { WidgetKind } from "@homarr/definitions"; import { useModalAction } from "@homarr/modals"; -import { showSuccessNotification } from "@homarr/notifications"; +import { showErrorNotification, showSuccessNotification } from "@homarr/notifications"; import { useSettings } from "@homarr/settings"; import { useI18n } from "@homarr/translation/client"; import type { BoardItemAdvancedOptions } from "@homarr/validation/shared"; -import { getWidgetComponent } from "@homarr/widgets/client"; import { WidgetError } from "@homarr/widgets/errors"; -import { loadWidgetDefinition, reduceWidgetOptionsWithDefinition } from "@homarr/widgets/manifest"; -import { WidgetEditModal } from "@homarr/widgets/modals"; +import { loadWidgetResources, reduceWidgetOptionsWithDefinition } from "@homarr/widgets/manifest"; +import { LazyWidgetEditModal, preloadWidgetEditModal } from "~/components/board/items/lazy-widget-edit-modal"; import type { Dimensions } from "./_dimension-modal"; import { PreviewDimensionsModal } from "./_dimension-modal"; interface WidgetPreviewPageContentProps { kind: WidgetKind; - integrationData: { - id: string; - name: string; - url: string; - kind: IntegrationKind; - }[]; } -export const WidgetPreviewPageContent = ({ kind, integrationData }: WidgetPreviewPageContentProps) => { +export const WidgetPreviewPageContent = ({ kind }: WidgetPreviewPageContentProps) => { const settings = useSettings(); const t = useI18n(); - const { openModal: openWidgetEditModal } = useModalAction(WidgetEditModal); + const utils = clientApi.useUtils(); + const { openModal: openWidgetEditModal } = useModalAction(LazyWidgetEditModal); const { openModal: openPreviewDimensionsModal } = useModalAction(PreviewDimensionsModal); - const currentDefinition = use(loadWidgetDefinition(kind)); + const { definition: currentDefinition, Component } = use(loadWidgetResources(kind)); const [editMode, setEditMode] = useState(false); + const [isEditorLoading, setIsEditorLoading] = useState(false); const [dimensions, setDimensions] = useState({ width: 128, height: 128, @@ -55,31 +51,41 @@ export const WidgetPreviewPageContent = ({ kind, integrationData }: WidgetPrevie }, }); - const handleOpenEditWidgetModal = useCallback(() => { - openWidgetEditModal( - { - kind, - value: state, - onSuccessfulEdit: (value) => { - setState(value); + const handleOpenEditWidgetModal = useCallback(async () => { + const hasIntegrationSupport = "supportedIntegrations" in currentDefinition; + setIsEditorLoading(true); + preloadWidgetEditModal(); + try { + const integrationData = hasIntegrationSupport ? await utils.integration.all.ensureData() : []; + openWidgetEditModal( + { + kind, + definition: currentDefinition, + value: state, + onSuccessfulEdit: (value) => { + setState(value); + }, + integrationData: integrationData.filter((integration) => + (currentDefinition.supportedIntegrations ?? []).includes(integration.kind), + ), + integrationSupport: hasIntegrationSupport, + settings, }, - integrationData: integrationData.filter( - (integration) => - "supportedIntegrations" in currentDefinition && - (currentDefinition.supportedIntegrations as string[]).some((kind) => kind === integration.kind), - ), - integrationSupport: "supportedIntegrations" in currentDefinition, - settings, - }, - { - title(t) { - return `${t("item.edit.title")} - ${t(`widget.${kind}.name`)}`; + { + title(translate) { + return `${translate("item.edit.title")} - ${translate(`widget.${kind}.name`)}`; + }, }, - }, - ); - }, [currentDefinition, integrationData, kind, openWidgetEditModal, settings, state]); - - const Comp = getWidgetComponent(kind); + ); + } catch (error) { + showErrorNotification({ + title: t("common.error"), + message: error instanceof Error ? error.message : String(error), + }); + } finally { + setIsEditorLoading(false); + } + }, [currentDefinition, kind, openWidgetEditModal, settings, state, t, utils]); const toggleEditMode = useCallback(() => { setEditMode((editMode) => !editMode); @@ -95,8 +101,11 @@ export const WidgetPreviewPageContent = ({ kind, integrationData }: WidgetPrevie }); }, [dimensions, openPreviewDimensionsModal]); - const updateOptions = ({ newOptions }: { newOptions: Record }) => - setState({ ...state, options: { ...state.options, newOptions } }); + const updateOptions = useCallback( + ({ newOptions }: { newOptions: Record }) => + setState((current) => ({ ...current, options: { ...current.options, ...newOptions } })), + [], + ); return ( <> @@ -109,7 +118,7 @@ export const WidgetPreviewPageContent = ({ kind, integrationData }: WidgetPrevie )} > - void handleOpenEditWidgetModal()} + onFocus={preloadWidgetEditModal} + onPointerEnter={preloadWidgetEditModal} + loading={isEditorLoading} aria-label={t("common.action.edit")} > diff --git a/apps/nextjs/src/app/[locale]/widgets/[kind]/layout.tsx b/apps/nextjs/src/app/[locale]/widgets/[kind]/layout.tsx index a11bf8e9f..9cdf91241 100644 --- a/apps/nextjs/src/app/[locale]/widgets/[kind]/layout.tsx +++ b/apps/nextjs/src/app/[locale]/widgets/[kind]/layout.tsx @@ -1,24 +1,18 @@ import type { PropsWithChildren } from "react"; -import { loadAllWidgetDefinitions } from "@homarr/widgets/manifest"; +import { widgetKinds } from "@homarr/definitions"; +import { widgetCatalogIcons } from "@homarr/widgets/catalog"; import { MainNavigation } from "~/components/layout/navigation"; import { ClientShell } from "~/components/layout/shell"; -const getLinks = async () => { - const definitions = await loadAllWidgetDefinitions(); - return [...definitions].map(([key, definition]) => { - return { - href: `/widgets/${key}`, - icon: definition.icon, - label: key, - }; - }); -}; - -export default async function WidgetPreviewLayout({ children }: PropsWithChildren) { - const links = await getLinks(); +const links = widgetKinds.map((kind) => ({ + href: `/widgets/${kind}`, + icon: widgetCatalogIcons[kind], + label: kind, +})); +export default function WidgetPreviewLayout({ children }: PropsWithChildren) { return ( diff --git a/apps/nextjs/src/app/[locale]/widgets/[kind]/page.tsx b/apps/nextjs/src/app/[locale]/widgets/[kind]/page.tsx index 00d54fc1a..454e7a805 100644 --- a/apps/nextjs/src/app/[locale]/widgets/[kind]/page.tsx +++ b/apps/nextjs/src/app/[locale]/widgets/[kind]/page.tsx @@ -2,7 +2,6 @@ import { Suspense } from "react"; import { notFound } from "next/navigation"; import { Center, Loader } from "@mantine/core"; -import { db } from "@homarr/db"; import type { WidgetKind } from "@homarr/definitions"; import { widgetKinds } from "@homarr/widgets/manifest"; @@ -18,19 +17,10 @@ export default async function WidgetPreview(props: Props) { notFound(); } - const integrationData = await db.query.integrations.findMany({ - columns: { - id: true, - name: true, - url: true, - kind: true, - }, - }); - return (
}> - +
); diff --git a/apps/nextjs/src/components/board/items/item-content.tsx b/apps/nextjs/src/components/board/items/item-content.tsx index c40b8a57a..bd6ef5fa3 100644 --- a/apps/nextjs/src/components/board/items/item-content.tsx +++ b/apps/nextjs/src/components/board/items/item-content.tsx @@ -1,5 +1,6 @@ -import type { MutableRefObject } from "react"; +import type { ComponentType, MutableRefObject } from "react"; import { Suspense, use, useRef } from "react"; +import type { CardProps } from "@mantine/core"; import { Badge, Card, Center, Loader } from "@mantine/core"; import { useElementSize } from "@mantine/hooks"; import { QueryErrorResetBoundary, useQueryClient } from "@tanstack/react-query"; @@ -11,10 +12,10 @@ import { ErrorBoundary } from "react-error-boundary"; import { useRequiredBoard } from "@homarr/boards/context"; import { useEditMode } from "@homarr/boards/edit-mode"; import { useSettings } from "@homarr/settings"; -import { getWidgetComponent } from "@homarr/widgets/client"; import { WidgetError } from "@homarr/widgets/errors"; -import { loadWidgetDefinition, reduceWidgetOptionsWithDefinition } from "@homarr/widgets/manifest"; -import type { WidgetDefinition } from "@homarr/widgets/definition"; +import { getWidgetQueryKeys } from "@homarr/widgets/definition"; +import type { WidgetComponentProps, WidgetDefinition } from "@homarr/widgets/definition"; +import { loadWidgetResources, reduceWidgetOptionsWithDefinition } from "@homarr/widgets/manifest"; import type { SectionItem } from "~/app/[locale]/boards/_types"; import classes from "../sections/item.module.css"; @@ -22,7 +23,7 @@ import { useItemActions } from "./item-actions"; import itemContentClasses from "./item-content.module.css"; import { BoardItemMenu } from "./item-menu"; import { WidgetContextMenu } from "./widget-context-menu"; -import { removePersistedWidgetQueries } from "./widget-query-recovery"; +import { removeWidgetDataQueries } from "./widget-query-recovery"; interface BoardItemContentProps { item: SectionItem; @@ -34,10 +35,51 @@ const getOverflowFromKind = (kind: SectionItem["kind"]) => { return undefined; }; -const WidgetDefinitionLoadError = ({ error, resetErrorBoundary }: FallbackProps) => ( -
- -
+type BoardItemCardProps = CardProps & { + item: SectionItem; + innerRef: (element: HTMLDivElement | null) => void; +}; + +const BoardItemCard = ({ item, innerRef, children, ...cardProps }: BoardItemCardProps) => { + const board = useRequiredBoard(); + + return ( + + {children} + + ); +}; + +const WidgetDefinitionLoadError = ({ + error, + resetErrorBoundary, + item, + innerRef, +}: FallbackProps & Pick) => ( + +
+ +
+
); export const BoardItemContent = ({ item }: BoardItemContentProps) => { @@ -47,12 +89,17 @@ export const BoardItemContent = ({ item }: BoardItemContentProps) => { return ( <> - + } + > - - + +
+ +
+
} > { ); }; -interface LoadedBoardItemContentProps extends Omit { +interface LoadedBoardItemContentProps extends Omit { innerRef: (element: HTMLDivElement | null) => void; } const LoadedBoardItemContent = ({ item, innerRef, ...innerContentProps }: LoadedBoardItemContentProps) => { - const board = useRequiredBoard(); - const definition = use(loadWidgetDefinition(item.kind)); + const { definition, Component } = use(loadWidgetResources(item.kind)); return ( - + ( @@ -127,9 +155,9 @@ const LoadedBoardItemContent = ({ item, innerRef, ...innerContentProps }: Loaded )} > - + - + ); }; @@ -140,13 +168,13 @@ interface InnerContentProps { height: number; widgetStateRef: MutableRefObject | null>; definition: WidgetDefinition; + Component: ComponentType>; } -const InnerContent = ({ item, definition, ...dimensions }: InnerContentProps) => { +const InnerContent = ({ item, definition, Component, ...dimensions }: InnerContentProps) => { const settings = useSettings(); const board = useRequiredBoard(); const [isEditMode] = useEditMode(); - const Comp = getWidgetComponent(item.kind); const options = reduceWidgetOptionsWithDefinition(definition, settings, item.options); const newItem = { ...item, options }; const { updateItemOptions } = useItemActions(); @@ -155,13 +183,14 @@ const InnerContent = ({ item, definition, ...dimensions }: InnerContentProps) => const widgetSupportsIntegrations = "supportedIntegrations" in definition && (definition.supportedIntegrations?.length ?? 0) >= 1; const queryClient = useQueryClient(); + const widgetQueryKeys = getWidgetQueryKeys(definition, item.kind); return ( {({ reset }) => ( { - removePersistedWidgetQueries(queryClient); + removeWidgetDataQueries(queryClient, widgetQueryKeys); reset(); }} fallbackRender={({ resetErrorBoundary, error }) => ( @@ -185,22 +214,24 @@ const InnerContent = ({ item, definition, ...dimensions }: InnerContentProps) => } /> - - updateOptions({ - newOptions: { - ...options, - ...partialNewOptions.newOptions, - }, - }) - } - {...dimensions} - /> +
+ + updateOptions({ + newOptions: { + ...options, + ...partialNewOptions.newOptions, + }, + }) + } + {...dimensions} + /> +
)}
diff --git a/apps/nextjs/src/components/board/items/item-menu.tsx b/apps/nextjs/src/components/board/items/item-menu.tsx index 813aa85ac..13fa8a16e 100644 --- a/apps/nextjs/src/components/board/items/item-menu.tsx +++ b/apps/nextjs/src/components/board/items/item-menu.tsx @@ -1,4 +1,4 @@ -import { useEffect, useRef } from "react"; +import { useEffect, useRef, useState } from "react"; import { ActionIcon, Menu } from "@mantine/core"; import { IconCopy, IconDotsVertical, IconLayoutKanban, IconPencil, IconTrash } from "@tabler/icons-react"; @@ -9,12 +9,12 @@ import { useConfirmModal, useModalAction } from "@homarr/modals"; import { useSettings } from "@homarr/settings"; import { useI18n, useScopedI18n } from "@homarr/translation/client"; import type { WidgetDefinition } from "@homarr/widgets/definition"; -import { WidgetEditModal } from "@homarr/widgets/modals"; import type { SectionItem } from "~/app/[locale]/boards/_types"; import { useSectionContext } from "../sections/section-context"; import { useItemActions } from "./item-actions"; -import { ItemMoveModal } from "./item-move-modal"; +import { LazyItemMoveModal, preloadItemMoveModal } from "./lazy-item-move-modal"; +import { LazyWidgetEditModal, preloadWidgetEditModal } from "./lazy-widget-edit-modal"; interface BoardItemMenuProps { offset: number; @@ -25,7 +25,8 @@ interface BoardItemMenuProps { export const BoardItemMenu = (props: BoardItemMenuProps) => { const { data: session } = useSession(); - if (!session) return null; + const [isEditMode] = useEditMode(); + if (!session || !isEditMode) return null; return ; }; @@ -34,13 +35,17 @@ const BoardItemMenuInner = ({ offset, item, definition, resetErrorBoundary }: Bo const refResetErrorBoundaryOnNextRender = useRef(false); const tItem = useScopedI18n("item"); const t = useI18n(); - const { openModal } = useModalAction(WidgetEditModal); - const { openModal: openMoveModal } = useModalAction(ItemMoveModal); + const { openModal } = useModalAction(LazyWidgetEditModal); + const { openModal: openMoveModal } = useModalAction(LazyItemMoveModal); const { openConfirmModal } = useConfirmModal(); - const [isEditMode] = useEditMode(); + const [isMenuOpen, setIsMenuOpen] = useState(false); + const hasSupportedIntegrations = + "supportedIntegrations" in definition && (definition.supportedIntegrations?.length ?? 0) > 0; const { updateItemOptions, updateItemAdvancedOptions, updateItemIntegrations, duplicateItem, removeItem } = useItemActions(); - const { data: integrationData, isPending } = clientApi.integration.all.useQuery(); + const { data: integrationData, isPending } = clientApi.integration.all.useQuery(undefined, { + enabled: isMenuOpen && hasSupportedIntegrations, + }); const { gridstack } = useSectionContext().refs; const settings = useSettings(); @@ -52,12 +57,11 @@ const BoardItemMenuInner = ({ offset, item, definition, resetErrorBoundary }: Bo } }, [item, resetErrorBoundary]); - if (!isEditMode || isPending) return null; - const openEditModal = () => { openModal( { kind: item.kind, + definition, value: { advancedOptions: item.advancedOptions, options: item.options, @@ -107,7 +111,7 @@ const BoardItemMenuInner = ({ offset, item, definition, resetErrorBoundary }: Bo }; return ( - + {tItem("menu.label.settings")} - } onClick={openEditModal}> + } + onClick={openEditModal} + onFocus={preloadWidgetEditModal} + onPointerEnter={preloadWidgetEditModal} + disabled={hasSupportedIntegrations && isPending} + > {tItem("action.edit")} } + onFocus={preloadItemMoveModal} + onPointerEnter={preloadItemMoveModal} onClick={() => { if (!gridstack.current) return; openMoveModal({ item, columnCount: gridstack.current.getColumn(), gridStack: gridstack.current }); diff --git a/apps/nextjs/src/components/board/items/item-move-modal.tsx b/apps/nextjs/src/components/board/items/item-move-modal.tsx index 38e362ede..276e1124e 100644 --- a/apps/nextjs/src/components/board/items/item-move-modal.tsx +++ b/apps/nextjs/src/components/board/items/item-move-modal.tsx @@ -9,13 +9,13 @@ import { useI18n } from "@homarr/translation/client"; import type { Item, SectionItem } from "~/app/[locale]/boards/_types"; -interface InnerProps { +export interface ItemMoveModalProps { gridStack: GridStack; item: Pick; columnCount: number; } -export const ItemMoveModal = createModal(({ actions, innerProps }) => { +export const ItemMoveModal = createModal(({ actions, innerProps }) => { const t = useI18n(); const maxWidthRef = useRef(innerProps.columnCount - innerProps.item.xOffset); const form = useZodForm( diff --git a/apps/nextjs/src/components/board/items/item-select-data.spec.ts b/apps/nextjs/src/components/board/items/item-select-data.spec.ts new file mode 100644 index 000000000..4d0e77860 --- /dev/null +++ b/apps/nextjs/src/components/board/items/item-select-data.spec.ts @@ -0,0 +1,48 @@ +import { describe, expect, it, vi } from "vitest"; + +import { resolveMatchingIntegrationsAsync, tryLockSelection, unlockSelection } from "./item-select-data"; + +describe("widget selection lock", () => { + it("rejects repeated selection synchronously until the active selection finishes", () => { + const lock = { current: false }; + + expect(tryLockSelection(lock)).toBe(true); + expect(tryLockSelection(lock)).toBe(false); + + unlockSelection(lock); + expect(tryLockSelection(lock)).toBe(true); + }); +}); + +describe("resolveMatchingIntegrationsAsync", () => { + it("waits for the shared integration query before a cold integration-backed selection", async () => { + const ensureDataAsync = vi.fn(async () => [ + { id: "matching", kind: "sonarr" as const }, + { id: "other", kind: "radarr" as const }, + ]); + + await expect( + resolveMatchingIntegrationsAsync({ + hasIntegrationSupport: true, + supportedIntegrations: ["sonarr"], + currentData: undefined, + ensureDataAsync, + }), + ).resolves.toEqual([{ id: "matching", kind: "sonarr" }]); + expect(ensureDataAsync).toHaveBeenCalledOnce(); + }); + + it("does not wait for integrations when the widget has no integration support", async () => { + const ensureDataAsync = vi.fn(async () => [{ id: "unused", kind: "sonarr" as const }]); + + await expect( + resolveMatchingIntegrationsAsync({ + hasIntegrationSupport: false, + supportedIntegrations: [], + currentData: undefined, + ensureDataAsync, + }), + ).resolves.toEqual([]); + expect(ensureDataAsync).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/nextjs/src/components/board/items/item-select-data.ts b/apps/nextjs/src/components/board/items/item-select-data.ts new file mode 100644 index 000000000..f2b37f700 --- /dev/null +++ b/apps/nextjs/src/components/board/items/item-select-data.ts @@ -0,0 +1,28 @@ +import type { IntegrationKind } from "@homarr/definitions"; + +export const tryLockSelection = (lock: { current: boolean }) => { + if (lock.current) return false; + lock.current = true; + return true; +}; + +export const unlockSelection = (lock: { current: boolean }) => { + lock.current = false; +}; + +export const resolveMatchingIntegrationsAsync = async ({ + hasIntegrationSupport, + supportedIntegrations, + currentData, + ensureDataAsync, +}: { + hasIntegrationSupport: boolean; + supportedIntegrations: readonly IntegrationKind[]; + currentData: TIntegration[] | undefined; + ensureDataAsync: () => Promise; +}) => { + if (!hasIntegrationSupport) return []; + + const integrations = currentData ?? (await ensureDataAsync()); + return integrations.filter((integration) => supportedIntegrations.includes(integration.kind)); +}; diff --git a/apps/nextjs/src/components/board/items/item-select-modal.module.css b/apps/nextjs/src/components/board/items/item-select-modal.module.css new file mode 100644 index 000000000..ef8c56d8d --- /dev/null +++ b/apps/nextjs/src/components/board/items/item-select-modal.module.css @@ -0,0 +1,21 @@ +.action { + opacity: 0; + transition: opacity 150ms ease; +} + +.card:hover .action, +.card:focus-within .action { + opacity: 1; +} + +@media (hover: none), (pointer: coarse) { + .action { + opacity: 1; + } +} + +@media (prefers-reduced-motion: reduce) { + .action { + transition: none; + } +} diff --git a/apps/nextjs/src/components/board/items/item-select-modal.tsx b/apps/nextjs/src/components/board/items/item-select-modal.tsx index 7ceaa20ae..497a63e49 100644 --- a/apps/nextjs/src/components/board/items/item-select-modal.tsx +++ b/apps/nextjs/src/components/board/items/item-select-modal.tsx @@ -1,29 +1,46 @@ -import { use, useMemo, useState } from "react"; +import { useMemo, useRef, useState } from "react"; import { Avatar, Box, Button, Card, Center, Divider, Group, Image, Stack, Text, Tooltip } from "@mantine/core"; import { IconApi } from "@tabler/icons-react"; +import type { RouterOutputs } from "@homarr/api"; import { clientApi } from "@homarr/api/client"; import { createId } from "@homarr/common"; -import { getIconUrl, getIntegrationName } from "@homarr/definitions"; +import { getIconUrl, getIntegrationName, widgetIntegrationSupport, widgetKinds } from "@homarr/definitions"; import type { IntegrationKind, WidgetKind } from "@homarr/definitions"; import { createModal, modalSizeSelect, useModalAction } from "@homarr/modals"; +import { showErrorNotification } from "@homarr/notifications"; import { useSettings } from "@homarr/settings"; import { useI18n } from "@homarr/translation/client"; import { SelectGridLayout, selectGridCardHeight } from "@homarr/ui"; import type { TablerIcon } from "@homarr/ui"; -import { WidgetEditModal } from "@homarr/widgets/modals"; -import { loadAllWidgetDefinitions, reduceWidgetOptionsWithDefinition, widgetKinds } from "@homarr/widgets/manifest"; +import { widgetCatalogIcons } from "@homarr/widgets/catalog"; +import { loadWidgetDefinition, reduceWidgetOptionsWithDefinition } from "@homarr/widgets/manifest"; import { useItemActions } from "./item-actions"; +import { resolveMatchingIntegrationsAsync, tryLockSelection, unlockSelection } from "./item-select-data"; +import { LazyWidgetEditModal, preloadWidgetEditModal } from "./lazy-widget-edit-modal"; +import classes from "./item-select-modal.module.css"; -export const ItemSelectModal = createModal(({ actions }) => { - const widgetDefinitions = use(loadAllWidgetDefinitions()); +interface ItemSelectModalContentProps { + actions: { closeModal: () => void }; + innerProps: void; + integrationData: RouterOutputs["integration"]["all"] | undefined; + customWidgetDefs: RouterOutputs["customWidget"]["all"] | undefined; + ensureIntegrationDataAsync: () => Promise; +} + +const ItemSelectModalContent = ({ + actions, + integrationData, + customWidgetDefs, + ensureIntegrationDataAsync, +}: ItemSelectModalContentProps) => { const [search, setSearch] = useState(""); + const [loadingSelection, setLoadingSelection] = useState(null); + const selectionLock = useRef(false); const t = useI18n(); const { createItem, updateItemOptions, updateItemAdvancedOptions, updateItemIntegrations } = useItemActions(); - const { openModal: openEditModal } = useModalAction(WidgetEditModal); - const { data: integrationData } = clientApi.integration.all.useQuery(); - const { data: customWidgetDefs } = clientApi.customWidget.all.useQuery(); + const { openModal: openEditModal } = useModalAction(LazyWidgetEditModal); const settings = useSettings(); const availableKinds = useMemo(() => new Set((integrationData ?? []).map((i) => i.kind)), [integrationData]); @@ -33,27 +50,23 @@ export const ItemSelectModal = createModal(({ actions }) => { widgetKinds .filter((kind) => kind !== "customApi") .map((kind) => { - const definition = widgetDefinitions.get(kind); - if (!definition) return null; - return { kind, - supportedIntegrations: (definition.supportedIntegrations ?? []).filter( + supportedIntegrations: (widgetIntegrationSupport[kind] ?? []).filter( (integration) => integration !== "mock", ), - icon: definition.icon, + icon: widgetCatalogIcons[kind], name: t(`widget.${kind}.name`), description: t(`widget.${kind}.description`), }; }) - .filter((item): item is NonNullable => item !== null) .sort((itemA, itemB) => { if (itemA.kind === "app") return -1; if (itemB.kind === "app") return 1; return itemA.name.localeCompare(itemB.name); }), - [t, widgetDefinitions], + [t], ); const filteredItems = useMemo(() => { @@ -72,56 +85,81 @@ export const ItemSelectModal = createModal(({ actions }) => { [customWidgetDefs, search], ); - const handleAddCustomWidget = (definitionId: string) => { - const itemId = createId(); - const definition = widgetDefinitions.get("customApi"); - if (!definition) return; - - const defaultOptions = reduceWidgetOptionsWithDefinition(definition, settings); - createItem({ id: itemId, kind: "customApi", integrationIds: [] }); - updateItemOptions({ itemId, newOptions: { ...defaultOptions, definitionId } }); - actions.closeModal(); + const notifyDefinitionLoadError = (error: unknown) => { + showErrorNotification({ + title: t("common.error"), + message: error instanceof Error ? error.message : String(error), + }); }; - const handleAdd = (kind: WidgetKind) => { - const definition = widgetDefinitions.get(kind); - if (!definition) return; - const hasIntegrationSupport = "supportedIntegrations" in definition; + const handleAddCustomWidget = async (definitionId: string) => { + if (!tryLockSelection(selectionLock)) return; + setLoadingSelection(`custom:${definitionId}`); + try { + const definition = await loadWidgetDefinition("customApi"); + const itemId = createId(); + const defaultOptions = reduceWidgetOptionsWithDefinition(definition, settings); + createItem({ id: itemId, kind: "customApi", integrationIds: [] }); + updateItemOptions({ itemId, newOptions: { ...defaultOptions, definitionId } }); + actions.closeModal(); + } catch (error) { + notifyDefinitionLoadError(error); + } finally { + unlockSelection(selectionLock); + setLoadingSelection(null); + } + }; - const matchingIntegrations = hasIntegrationSupport - ? (integrationData ?? []).filter((integration) => - (definition.supportedIntegrations ?? []).includes(integration.kind), - ) - : []; + const handleAdd = async (kind: WidgetKind) => { + if (!tryLockSelection(selectionLock)) return; + setLoadingSelection(kind); + preloadWidgetEditModal(); + try { + const definition = await loadWidgetDefinition(kind); + const hasIntegrationSupport = "supportedIntegrations" in definition; - const integrationIds = matchingIntegrations.map((i) => i.id); - const itemId = createId(); - const defaultOptions = reduceWidgetOptionsWithDefinition(definition, settings); + const matchingIntegrations = await resolveMatchingIntegrationsAsync({ + hasIntegrationSupport, + supportedIntegrations: definition.supportedIntegrations ?? [], + currentData: integrationData, + ensureDataAsync: ensureIntegrationDataAsync, + }); - createItem({ id: itemId, kind, integrationIds }); - actions.closeModal(); + const integrationIds = matchingIntegrations.map((i) => i.id); + const itemId = createId(); + const defaultOptions = reduceWidgetOptionsWithDefinition(definition, settings); - openEditModal( - { - kind, - value: { - advancedOptions: { title: null, customCssClasses: [], borderColor: "" }, - options: defaultOptions, - integrationIds, + createItem({ id: itemId, kind, integrationIds }); + actions.closeModal(); + + openEditModal( + { + kind, + definition, + value: { + advancedOptions: { title: null, customCssClasses: [], borderColor: "" }, + options: defaultOptions, + integrationIds, + }, + onSuccessfulEdit: ({ options, integrationIds: newIntegrationIds, advancedOptions }) => { + updateItemOptions({ itemId, newOptions: options }); + updateItemAdvancedOptions({ itemId, newAdvancedOptions: advancedOptions }); + updateItemIntegrations({ itemId, newIntegrations: newIntegrationIds }); + }, + integrationData: matchingIntegrations, + integrationSupport: hasIntegrationSupport, + settings, }, - onSuccessfulEdit: ({ options, integrationIds: newIntegrationIds, advancedOptions }) => { - updateItemOptions({ itemId, newOptions: options }); - updateItemAdvancedOptions({ itemId, newAdvancedOptions: advancedOptions }); - updateItemIntegrations({ itemId, newIntegrations: newIntegrationIds }); + { + title: (titleT) => `${titleT("item.edit.title")} - ${titleT(`widget.${kind}.name`)}`, }, - integrationData: matchingIntegrations, - integrationSupport: hasIntegrationSupport, - settings, - }, - { - title: (titleT) => `${titleT("item.edit.title")} - ${titleT(`widget.${kind}.name`)}`, - }, - ); + ); + } catch (error) { + notifyDefinitionLoadError(error); + } finally { + unlockSelection(selectionLock); + setLoadingSelection(null); + } }; return ( @@ -130,9 +168,9 @@ export const ItemSelectModal = createModal(({ actions }) => { onSearchChange={setSearch} placeholder={`${t("item.create.search")}...`} onSearchKeyDown={(event) => { - if (event.key === "Enter" && filteredItems.length === 1) { + if (event.key === "Enter" && loadingSelection === null && filteredItems.length === 1) { const [item] = filteredItems; - if (item) handleAdd(item.kind); + if (item) void handleAdd(item.kind); } }} > @@ -140,7 +178,13 @@ export const ItemSelectModal = createModal(({ actions }) => { handleAdd(item.kind)} + onSelect={() => void handleAdd(item.kind)} + onIntent={() => { + void loadWidgetDefinition(item.kind).catch(() => undefined); + preloadWidgetEditModal(); + }} + disabled={loadingSelection !== null} + loading={loadingSelection === item.kind} hasMatchingIntegration={item.supportedIntegrations.some((kind) => availableKinds.has(kind))} /> ))} @@ -156,12 +200,12 @@ export const ItemSelectModal = createModal(({ actions }) => { {filteredCustomWidgets.map((def) => ( e.currentTarget.style.setProperty("--_hover-opacity", "1")} - onMouseLeave={(e) => e.currentTarget.style.setProperty("--_hover-opacity", "0")} + style={{ overflow: "hidden" }} + onPointerEnter={() => void loadWidgetDefinition("customApi").catch(() => undefined)} > @@ -179,18 +223,24 @@ export const ItemSelectModal = createModal(({ actions }) => { - @@ -206,7 +256,24 @@ export const ItemSelectModal = createModal(({ actions }) => { )} ); -}).withOptions({ +}; + +const ItemSelectModalFrame = (props: Pick) => { + const utils = clientApi.useUtils(); + const { data: integrationData } = clientApi.integration.all.useQuery(); + const { data: customWidgetDefs } = clientApi.customWidget.all.useQuery(); + + return ( + utils.integration.all.ensureData()} + /> + ); +}; + +export const ItemSelectModal = createModal((props) => ).withOptions({ defaultTitle: (t) => t("item.create.title"), size: modalSizeSelect, }); @@ -214,6 +281,9 @@ export const ItemSelectModal = createModal(({ actions }) => { const WidgetItem = ({ item, onSelect, + onIntent, + disabled, + loading, hasMatchingIntegration, }: { item: { @@ -224,23 +294,27 @@ const WidgetItem = ({ icon: TablerIcon; }; onSelect: () => void; + onIntent: () => void; + disabled: boolean; + loading: boolean; hasMatchingIntegration: boolean; }) => { const t = useI18n(); return ( e.currentTarget.style.setProperty("--_hover-opacity", "1")} - onMouseLeave={(e) => e.currentTarget.style.setProperty("--_hover-opacity", "0")} + onFocus={onIntent} + onPointerEnter={onIntent} + aria-busy={loading || undefined} > @@ -257,18 +331,17 @@ const WidgetItem = ({ - diff --git a/apps/nextjs/src/components/board/items/lazy-item-move-modal.tsx b/apps/nextjs/src/components/board/items/lazy-item-move-modal.tsx new file mode 100644 index 000000000..836017bc1 --- /dev/null +++ b/apps/nextjs/src/components/board/items/lazy-item-move-modal.tsx @@ -0,0 +1,67 @@ +"use client"; + +import { Suspense, use } from "react"; +import { Center, Loader } from "@mantine/core"; +import { ErrorBoundary } from "react-error-boundary"; + +import { createModal, modalSizeForm } from "@homarr/modals"; +import { WidgetError } from "@homarr/widgets/errors"; + +import type { ItemMoveModalProps } from "./item-move-modal"; + +let itemMoveModalPromise: Promise | undefined; + +const loadItemMoveModal = () => { + if (itemMoveModalPromise) return itemMoveModalPromise; + + const currentPromise = import("./item-move-modal"); + itemMoveModalPromise = currentPromise; + void currentPromise.catch(() => { + if (itemMoveModalPromise === currentPromise) itemMoveModalPromise = undefined; + }); + return currentPromise; +}; + +export const preloadItemMoveModal = () => void loadItemMoveModal().catch(() => undefined); + +interface LazyItemMoveModalContentProps { + actions: { closeModal: () => void }; + innerProps: ItemMoveModalProps; +} + +const LazyItemMoveModalContent = (props: LazyItemMoveModalContentProps) => { + const { ItemMoveModal } = use(loadItemMoveModal()); + const Component = ItemMoveModal.component; + return ; +}; + +export const LazyItemMoveModal = createModal((props) => ( + ( +
+ { + itemMoveModalPromise = undefined; + resetErrorBoundary(); + }} + /> +
+ )} + > + + + + } + > + + +
+)).withOptions({ + defaultTitle(t) { + return t("item.moveResize.title"); + }, + size: modalSizeForm, +}); diff --git a/apps/nextjs/src/components/board/items/lazy-widget-edit-modal.tsx b/apps/nextjs/src/components/board/items/lazy-widget-edit-modal.tsx new file mode 100644 index 000000000..fb7a26bcd --- /dev/null +++ b/apps/nextjs/src/components/board/items/lazy-widget-edit-modal.tsx @@ -0,0 +1,68 @@ +"use client"; + +import { Suspense, use } from "react"; +import { Center, Loader } from "@mantine/core"; +import { ErrorBoundary } from "react-error-boundary"; + +import type { WidgetKind } from "@homarr/definitions"; +import { createModal, modalSizeForm } from "@homarr/modals"; +import { WidgetError } from "@homarr/widgets/errors"; +import type { WidgetEditModalProps } from "@homarr/widgets/modals"; + +let widgetEditModalPromise: Promise | undefined; + +export const loadWidgetEditModal = () => { + if (widgetEditModalPromise) return widgetEditModalPromise; + + const currentPromise = import("@homarr/widgets/modals"); + widgetEditModalPromise = currentPromise; + void currentPromise.catch(() => { + if (widgetEditModalPromise === currentPromise) widgetEditModalPromise = undefined; + }); + return currentPromise; +}; + +export const preloadWidgetEditModal = () => void loadWidgetEditModal().catch(() => undefined); + +interface LazyWidgetEditModalContentProps { + actions: { closeModal: () => void }; + innerProps: WidgetEditModalProps; +} + +const LazyWidgetEditModalContent = (props: LazyWidgetEditModalContentProps) => { + const { WidgetEditModal } = use(loadWidgetEditModal()); + const Component = WidgetEditModal.component; + return ; +}; + +export const LazyWidgetEditModal = createModal>((props) => ( + ( +
+ { + widgetEditModalPromise = undefined; + resetErrorBoundary(); + }} + /> +
+ )} + > + + + + } + > + + +
+)).withOptions({ + keepMounted: true, + defaultTitle(t) { + return t("item.edit.title"); + }, + size: modalSizeForm, +}); diff --git a/apps/nextjs/src/components/board/items/widget-context-menu.tsx b/apps/nextjs/src/components/board/items/widget-context-menu.tsx index c1ecaa5a0..3b844874f 100644 --- a/apps/nextjs/src/components/board/items/widget-context-menu.tsx +++ b/apps/nextjs/src/components/board/items/widget-context-menu.tsx @@ -1,7 +1,7 @@ "use client"; import type { MutableRefObject, ReactNode } from "react"; -import { useCallback, useMemo } from "react"; +import { useCallback, useMemo, useState } from "react"; import { Group, Loader, Menu, Switch, Text, Tooltip } from "@mantine/core"; import { IconAlertTriangle, @@ -13,7 +13,7 @@ import { IconTrash, } from "@tabler/icons-react"; import type { QueryClient, QueryKey } from "@tanstack/react-query"; -import { useIsFetching, useQueryClient } from "@tanstack/react-query"; +import { partialMatchKey, useIsFetching, useQueryClient } from "@tanstack/react-query"; import { clientApi } from "@homarr/api/client"; import { useSession } from "@homarr/auth/client"; @@ -26,13 +26,14 @@ import { translateIfNecessary } from "@homarr/translation"; import { useI18n, useScopedI18n } from "@homarr/translation/client"; import type { TranslationFunction } from "@homarr/translation"; import type { WidgetContextMenuAction, WidgetDefinition } from "@homarr/widgets/definition"; +import { getWidgetQueryKeys } from "@homarr/widgets/definition"; import { reduceWidgetOptionsWithDefinition } from "@homarr/widgets/manifest"; -import { WidgetEditModal } from "@homarr/widgets/modals"; import type { SectionItem } from "~/app/[locale]/boards/_types"; import { useSectionContext } from "../sections/section-context"; import { useItemActions } from "./item-actions"; -import { ItemMoveModal } from "./item-move-modal"; +import { LazyItemMoveModal, preloadItemMoveModal } from "./lazy-item-move-modal"; +import { LazyWidgetEditModal, preloadWidgetEditModal } from "./lazy-widget-edit-modal"; interface WidgetContextMenuProps { item: SectionItem; @@ -43,31 +44,83 @@ interface WidgetContextMenuProps { export const WidgetContextMenu = ({ item, definition, widgetStateRef, children }: WidgetContextMenuProps) => { const { data: session } = useSession(); + const settings = useSettings(); + + if (!session || !settings.enableRightClickOnWidgets) return <>{children}; + + return ( + + {children} + + ); +}; + +const WidgetContextMenuInner = ({ + item, + definition, + widgetStateRef, + children, + settings, +}: WidgetContextMenuProps & { settings: ReturnType }) => { + const [isMenuOpen, setIsMenuOpen] = useState(false); + + return ( + + {children} + + {isMenuOpen && ( + + )} + + + ); +}; + +type WidgetContextMenuDropdownProps = Omit & { + settings: ReturnType; +}; + +const WidgetContextMenuDropdown = ({ item, definition, widgetStateRef, settings }: WidgetContextMenuDropdownProps) => { const [isEditMode] = useEditMode(); const board = useRequiredBoard(); const tItem = useScopedI18n("item"); const tMenu = useScopedI18n("item.menu.label"); const t = useI18n(); - const settings = useSettings(); - const isRightClickEnabled = settings.enableRightClickOnWidgets; - const { openModal } = useModalAction(WidgetEditModal); - const { openModal: openMoveModal } = useModalAction(ItemMoveModal); + const hasSupportedIntegrations = + "supportedIntegrations" in definition && (definition.supportedIntegrations?.length ?? 0) > 0; + const { openModal } = useModalAction(LazyWidgetEditModal); + const { openModal: openMoveModal } = useModalAction(LazyItemMoveModal); const { openConfirmModal } = useConfirmModal(); const { updateItemOptions, updateItemAdvancedOptions, updateItemIntegrations, duplicateItem, removeItem } = useItemActions(); - const { data: integrationData, isPending } = clientApi.integration.all.useQuery(); + const { data: integrationData, isPending } = clientApi.integration.all.useQuery(undefined, { + enabled: hasSupportedIntegrations, + }); const { mutate: saveBoard } = clientApi.board.saveBoard.useMutation(); const { gridstack } = useSectionContext().refs; const queryClient = useQueryClient(); - const widgetQueryKey = useMemo( - () => ("queryKey" in definition ? (definition.queryKey as QueryKey) : [["widget", item.kind]]), - [definition, item.kind], - ); - const isWidgetFetching = useIsFetching({ queryKey: widgetQueryKey }) > 0; + const widgetQueryKeys = useMemo(() => getWidgetQueryKeys(definition, item.kind), [definition, item.kind]); + const isWidgetFetching = + useIsFetching({ + predicate: (query) => widgetQueryKeys.some((queryKey) => partialMatchKey(query.queryKey, queryKey)), + }) > 0; const handleRefetch = useCallback(() => { - void queryClient.refetchQueries({ queryKey: widgetQueryKey, type: "all" }); - }, [queryClient, widgetQueryKey]); + void Promise.all(widgetQueryKeys.map((queryKey) => queryClient.refetchQueries({ queryKey, type: "all" }))); + }, [queryClient, widgetQueryKeys]); const options = useMemo( () => reduceWidgetOptionsWithDefinition(definition, settings, item.options), @@ -107,6 +160,7 @@ export const WidgetContextMenu = ({ item, definition, widgetStateRef, children } openModal( { kind: item.kind, + definition, value: { advancedOptions: item.advancedOptions, options: item.options, @@ -168,135 +222,133 @@ export const WidgetContextMenu = ({ item, definition, widgetStateRef, children } [options, item.id, updateItemOptions, persistBoard, board], ); - if (!session) return <>{children}; - if (!isRightClickEnabled) return <>{children}; - const visibleWidgetActions = widgetContextActions.filter((a) => !a.hidden); return ( - - {children} - - {toggleOptions.length > 0 && ( - <> - {tMenu("options")} - {toggleOptions.map(([key]) => ( - handleToggle(key)(!options[key])}> - {/* eslint-disable-next-line @typescript-eslint/no-explicit-any */} - - {String(translateIfNecessary(t, ((fn: any) => fn(`widget.${item.kind}.option.${key}.label`)) as any))} - - - - ))} - - )} - - {visibleWidgetActions.length > 0 && ( - <> - {toggleOptions.length > 0 && } - {tMenu("actions")} - {visibleWidgetActions.map((action) => { - const Icon = action.icon; - return ( - : undefined} - onClick={action.onClick} - disabled={action.disabled} - color={action.color} - > - {String(translateIfNecessary(t, action.label))} - - ); - })} - - )} - - {isEditMode && ( - <> - {(toggleOptions.length > 0 || visibleWidgetActions.length > 0) && } - } - onClick={() => { - if (!gridstack.current) return; - openMoveModal({ item, columnCount: gridstack.current.getColumn(), gridStack: gridstack.current }); - }} - > - {tItem("action.moveResize")} - - } - onClick={() => duplicateItem({ itemId: item.id })} - > - {tItem("action.duplicate")} - - - )} - + <> + {toggleOptions.length > 0 && ( <> - {(toggleOptions.length > 0 || visibleWidgetActions.length > 0 || isEditMode) && } + {tMenu("options")} + {toggleOptions.map(([key]) => ( + handleToggle(key)(!options[key])}> + {/* eslint-disable-next-line @typescript-eslint/no-explicit-any */} + + {String(translateIfNecessary(t, ((fn: any) => fn(`widget.${item.kind}.option.${key}.label`)) as any))} + + + + ))} + + )} + + {visibleWidgetActions.length > 0 && ( + <> + {toggleOptions.length > 0 && } + {tMenu("actions")} + {visibleWidgetActions.map((action) => { + const Icon = action.icon; + return ( + : undefined} + onClick={action.onClick} + disabled={action.disabled} + color={action.color} + > + {String(translateIfNecessary(t, action.label))} + + ); + })} + + )} + + {isEditMode && ( + <> + {(toggleOptions.length > 0 || visibleWidgetActions.length > 0) && } : } - onClick={handleRefetch} - disabled={isWidgetFetching} + closeMenuOnClick + leftSection={} + onFocus={preloadItemMoveModal} + onPointerEnter={preloadItemMoveModal} + onClick={() => { + if (!gridstack.current) return; + openMoveModal({ item, columnCount: gridstack.current.getColumn(), gridStack: gridstack.current }); + }} > - - {tMenu("refresh")} - - + {tItem("action.moveResize")} } - onClick={openEditModal} - disabled={isPending} + leftSection={} + onClick={() => duplicateItem({ itemId: item.id })} > - {tMenu("settings")} + {tItem("action.duplicate")} + )} - {isEditMode && ( - <> - - } - onClick={() => { - openConfirmModal({ - title: tItem("remove.title"), - children: tItem("remove.message"), - onConfirm: () => removeItem({ itemId: item.id }), - }); - }} - > - {tItem("action.remove")} - - - )} - - + <> + {(toggleOptions.length > 0 || visibleWidgetActions.length > 0 || isEditMode) && } + : } + onClick={handleRefetch} + disabled={isWidgetFetching} + > + + {tMenu("refresh")} + + + + } + onClick={openEditModal} + onFocus={preloadWidgetEditModal} + onPointerEnter={preloadWidgetEditModal} + disabled={hasSupportedIntegrations && isPending} + > + {tMenu("settings")} + + + + {isEditMode && ( + <> + + } + onClick={() => { + openConfirmModal({ + title: tItem("remove.title"), + children: tItem("remove.message"), + onConfirm: () => removeItem({ itemId: item.id }), + }); + }} + > + {tItem("action.remove")} + + + )} + ); }; interface WidgetQueryStatusProps { queryClient: QueryClient; - queryKey: QueryKey; + queryKeys: QueryKey[]; isFetching: boolean; t: TranslationFunction; } -const WidgetQueryStatus = ({ queryClient, queryKey, isFetching, t }: WidgetQueryStatusProps) => { - const queries = queryClient.getQueryCache().findAll({ queryKey }); +const WidgetQueryStatus = ({ queryClient, queryKeys, isFetching, t }: WidgetQueryStatusProps) => { + const queries = [...new Set(queryKeys.flatMap((queryKey) => queryClient.getQueryCache().findAll({ queryKey })))]; const timestamps = queries.map((query) => query.state.dataUpdatedAt).filter(Boolean); const latest = timestamps.length > 0 ? Math.max(...timestamps) : 0; const ageLabel = useTimeAgo(new Date(latest)); @@ -334,7 +386,8 @@ const WidgetQueryStatus = ({ queryClient, queryKey, isFetching, t }: WidgetQuery - {t("item.menu.status.error")} · {ageLabel} + {t("item.menu.status.error")} + {latest > 0 && ` · ${ageLabel}`} @@ -345,7 +398,8 @@ const WidgetQueryStatus = ({ queryClient, queryKey, isFetching, t }: WidgetQuery - {t("item.menu.status.success")} · {ageLabel} + {t("item.menu.status.success")} + {latest > 0 && ` · ${ageLabel}`} ); diff --git a/apps/nextjs/src/components/board/items/widget-hover-metadata.ts b/apps/nextjs/src/components/board/items/widget-hover-metadata.ts deleted file mode 100644 index 22a75c9b3..000000000 --- a/apps/nextjs/src/components/board/items/widget-hover-metadata.ts +++ /dev/null @@ -1,155 +0,0 @@ -import { objectEntries } from "@homarr/common"; -import type { IntegrationKind, WidgetKind } from "@homarr/definitions"; -import type { SettingsContextProps } from "@homarr/settings/creator"; -import type { WidgetOptionDefinition, WidgetOptionType } from "@homarr/widgets"; -import { reduceWidgetOptionsWithDefaultValues, widgetImports } from "@homarr/widgets"; - -import type { SectionItem } from "~/app/[locale]/boards/_types"; - -export type Translator = { - (key: string): string; -}; - -export interface HoverMetadataItem { - label: string; - value: string; -} - -const maxMetadataItems = 3; - -const skippedOptionTypes: Partial> = { - app: true, - anchorNote: true, - dynamicSelect: true, - sortableItemList: true, - multiReleasesRepositories: true, - umamiEventName: true, - umamiEventNames: true, - umamiWebsite: true, -}; - -const isEqualValue = (left: unknown, right: unknown) => JSON.stringify(left) === JSON.stringify(right); - -type OptionDefinitionWithRules = WidgetOptionDefinition & { - shouldHide?: (options: never, integrationKinds: IntegrationKind[]) => boolean; - options?: readonly { value: unknown; label: unknown }[]; -}; - -type OptionFormatter = (value: unknown, definition: OptionDefinitionWithRules, t: Translator) => string | null; - -const resolveSelectLabel = (value: unknown, definition: OptionDefinitionWithRules, t: Translator) => { - const match = definition.options?.find((option) => option.value === value); - const label = match?.label; - const fallbackLabel = () => String(value); - const labelResolvers: Record string> = { - function: () => (label as (translator: Translator) => string)(t), - string: () => label as string, - missing: fallbackLabel, - }; - const labelType = typeof label; - const resolveLabel: () => string = labelResolvers[labelType] ?? fallbackLabel; - return resolveLabel(); -}; - -const optionTypeFormatters: Partial> = { - switch: (value, _, t) => { - const switchLabels: Record = { - true: t("common.action.show"), - false: t("common.action.hide"), - }; - return switchLabels[String(Boolean(value))] ?? null; - }, - text: (value) => { - const text = String(value).trim(); - return text.length > 0 ? text : null; - }, - number: (value) => String(value), - slider: (value) => String(value), - select: (value, definition, t) => resolveSelectLabel(value, definition, t), - multiSelect: (value) => { - const items = value as unknown[]; - return items.length > 0 ? String(items.length) : null; - }, - multiText: (value) => { - const items = (value as string[]).filter((entry) => entry.trim().length > 0); - return items.length > 0 ? items.join(", ") : null; - }, - location: (value) => { - const location = value as { name?: string }; - const name = location.name?.trim() ?? ""; - return name.length > 0 ? name : null; - }, -}; - -const formatOptionValue = ( - type: WidgetOptionType, - value: unknown, - definition: OptionDefinitionWithRules, - t: Translator, -) => { - const formatter = optionTypeFormatters[type]; - return formatter?.(value, definition, t) ?? null; -}; - -const getOptionLabel = (kind: WidgetKind, key: string, t: Translator) => { - const labelKey = `widget.${kind}.option.${key}.label`; - const translated = t(labelKey); - return translated === labelKey ? key : translated; -}; - -export const collectWidgetHoverMetadata = ( - item: SectionItem, - settings: Pick, - integrationKinds: IntegrationKind[], - t: Translator, -): HoverMetadataItem[] => { - const { definition } = widgetImports[item.kind]; - const optionDefinitions = definition.createOptions(settings) as Record; - const resolvedOptions = reduceWidgetOptionsWithDefaultValues(item.kind, settings, item.options); - - return objectEntries(optionDefinitions) - .filter(([key, optionDefinition]) => { - const skip = skippedOptionTypes[optionDefinition.type]; - const hidden = optionDefinition.shouldHide?.(resolvedOptions as never, integrationKinds); - const unchanged = isEqualValue(resolvedOptions[key], optionDefinition.defaultValue); - return !skip && !hidden && !unchanged; - }) - .map(([key, optionDefinition]) => { - const formattedValue = formatOptionValue(optionDefinition.type, resolvedOptions[key], optionDefinition, t); - return { - label: getOptionLabel(item.kind, key, t), - value: formattedValue ?? "", - }; - }) - .filter((entry) => entry.value.length > 0) - .slice(0, maxMetadataItems); -}; - -interface DisplayNameSource { - matches: (item: SectionItem, options: Record) => boolean; - resolve: (item: SectionItem, options: Record) => string; -} - -const displayNameSources: DisplayNameSource[] = [ - { - matches: (item) => Boolean(item.advancedOptions.title?.trim()), - resolve: (item) => item.advancedOptions.title?.trim() ?? "", - }, - { - matches: (item, options) => - item.kind === "clock" && - Boolean(options.customTitleToggle) && - String(options.customTitle ?? "").trim().length > 0, - resolve: (_, options) => String(options.customTitle).trim(), - }, -]; - -export const resolveWidgetDisplayName = ( - item: SectionItem, - settings: Pick, - t: Translator, -) => { - const resolvedOptions = reduceWidgetOptionsWithDefaultValues(item.kind, settings, item.options); - const source = displayNameSources.find((entry) => entry.matches(item, resolvedOptions)); - return source?.resolve(item, resolvedOptions) ?? t(`widget.${item.kind}.name`); -}; diff --git a/apps/nextjs/src/components/board/items/widget-query-recovery.spec.ts b/apps/nextjs/src/components/board/items/widget-query-recovery.spec.ts index b666df4e9..dabb464f3 100644 --- a/apps/nextjs/src/components/board/items/widget-query-recovery.spec.ts +++ b/apps/nextjs/src/components/board/items/widget-query-recovery.spec.ts @@ -1,27 +1,36 @@ import { QueryClient } from "@tanstack/react-query"; import { describe, expect, test } from "vitest"; -import { removePersistedWidgetQueries } from "./widget-query-recovery"; +import { removeWidgetDataQueries } from "./widget-query-recovery"; -describe("removePersistedWidgetQueries", () => { - test("removes stale widget queries and allows fresh data to be fetched", async () => { +describe("removeWidgetDataQueries", () => { + test("removes only the failed widget prefix and allows fresh data to be fetched", async () => { const queryClient = new QueryClient(); const crashedWidgetQueryKey = [["widget", "mediaServer", "getCurrentStreams"], { input: {} }]; + const sameWidgetQueryKey = [["widget", "mediaServer", "getLibraries"], { input: {} }]; + const unrelatedWidgetQueryKey = [["widget", "calendar", "getEvents"], { input: {} }]; + const appQueryKey = [["app", "byId"], { input: {} }]; const unrelatedQueryKey = [["user", "me"], { input: {} }]; queryClient.setQueryData(crashedWidgetQueryKey, "stale"); + queryClient.setQueryData(sameWidgetQueryKey, "related"); + queryClient.setQueryData(unrelatedWidgetQueryKey, "other-widget"); + queryClient.setQueryData(appQueryKey, "app"); queryClient.setQueryData(unrelatedQueryKey, "unrelated"); - removePersistedWidgetQueries(queryClient); + removeWidgetDataQueries(queryClient, [[["widget", "mediaServer"]]]); expect(queryClient.getQueryData(crashedWidgetQueryKey)).toBeUndefined(); + expect(queryClient.getQueryData(sameWidgetQueryKey)).toBeUndefined(); + expect(queryClient.getQueryData(unrelatedWidgetQueryKey)).toBe("other-widget"); + expect(queryClient.getQueryData(appQueryKey)).toBe("app"); expect(queryClient.getQueryData(unrelatedQueryKey)).toBe("unrelated"); await expect( queryClient.fetchQuery({ queryKey: crashedWidgetQueryKey, queryFn: () => Promise.resolve("fresh") }), ).resolves.toBe("fresh"); }); - test("removes persisted queries outside the widget router", () => { + test("uses an explicit supporting query prefix without purging other supporting data", () => { const queryClient = new QueryClient(); const dockerQueryKey = [["docker", "getContainers"], { input: {} }]; const appQueryKey = [["app", "byId"], { input: {} }]; @@ -31,14 +40,14 @@ describe("removePersistedWidgetQueries", () => { queryClient.setQueryData(appQueryKey, "stale"); queryClient.setQueryData(unrelatedQueryKey, "unrelated"); - removePersistedWidgetQueries(queryClient); + removeWidgetDataQueries(queryClient, [[["docker", "getContainers"]]]); expect(queryClient.getQueryData(dockerQueryKey)).toBeUndefined(); - expect(queryClient.getQueryData(appQueryKey)).toBeUndefined(); + expect(queryClient.getQueryData(appQueryKey)).toBe("stale"); expect(queryClient.getQueryData(unrelatedQueryKey)).toBe("unrelated"); }); - test("removes persisted widget queries when the widget kind and query path differ", () => { + test("uses the definition query prefix when the widget kind and query path differ", () => { const queryClient = new QueryClient(); const crashedWidgetQueryKey = [["widget", "anchorNotes", "getNote"], { input: {} }]; const unrelatedQueryKey = [["user", "me"], { input: {} }]; @@ -46,7 +55,7 @@ describe("removePersistedWidgetQueries", () => { queryClient.setQueryData(crashedWidgetQueryKey, "stale"); queryClient.setQueryData(unrelatedQueryKey, "unrelated"); - removePersistedWidgetQueries(queryClient); + removeWidgetDataQueries(queryClient, [[["widget", "anchorNotes"]]]); expect(queryClient.getQueryData(crashedWidgetQueryKey)).toBeUndefined(); expect(queryClient.getQueryData(unrelatedQueryKey)).toBe("unrelated"); diff --git a/apps/nextjs/src/components/board/items/widget-query-recovery.ts b/apps/nextjs/src/components/board/items/widget-query-recovery.ts index 800614bf4..9bb0b81c5 100644 --- a/apps/nextjs/src/components/board/items/widget-query-recovery.ts +++ b/apps/nextjs/src/components/board/items/widget-query-recovery.ts @@ -1,7 +1,5 @@ -import type { QueryClient } from "@tanstack/react-query"; +import type { QueryClient, QueryKey } from "@tanstack/react-query"; -import { isPersistableWidgetQueryKey } from "@homarr/api/query-cache"; - -export const removePersistedWidgetQueries = (queryClient: QueryClient) => { - queryClient.removeQueries({ predicate: (query) => isPersistableWidgetQueryKey(query.queryKey) }); +export const removeWidgetDataQueries = (queryClient: QueryClient, queryKeys: QueryKey[]) => { + queryKeys.forEach((queryKey) => queryClient.removeQueries({ queryKey })); }; diff --git a/apps/nextjs/src/components/board/sections/content.tsx b/apps/nextjs/src/components/board/sections/content.tsx index ced30c796..aa3b5adc2 100644 --- a/apps/nextjs/src/components/board/sections/content.tsx +++ b/apps/nextjs/src/components/board/sections/content.tsx @@ -4,6 +4,8 @@ import type { GridItemHTMLElement } from "@homarr/gridstack"; import type { RouterOutputs } from "@homarr/api"; import { clientApi } from "@homarr/api/client"; +import { useSession } from "@homarr/auth/client"; +import { useEditMode } from "@homarr/boards/edit-mode"; import type { DynamicSectionItem, SectionItem } from "~/app/[locale]/boards/_types"; import { BoardItemContent } from "../items/item-content"; @@ -15,7 +17,11 @@ import { useSectionItems } from "./use-section-items"; export const SectionContent = () => { const { innerSections, items, refs } = useSectionContext(); - const { data: integrations } = clientApi.integration.all.useQuery(); + const { data: session } = useSession(); + const [isEditMode] = useEditMode(); + const { data: integrations } = clientApi.integration.all.useQuery(undefined, { + enabled: Boolean(session) && isEditMode, + }); /** * IMPORTANT: THE ORDER OF THE BELOW ITEMS HAS TO MATCH THE ORDER OF diff --git a/apps/nextjs/src/components/integration/integration-grid-shared.ts b/apps/nextjs/src/components/integration/integration-grid-shared.ts index a6802b9a6..03056c130 100644 --- a/apps/nextjs/src/components/integration/integration-grid-shared.ts +++ b/apps/nextjs/src/components/integration/integration-grid-shared.ts @@ -5,27 +5,8 @@ import { hiddenFromOnboarding, integrationDefs, integrationKinds, + getWidgetKindsForIntegration, } from "@homarr/definitions"; -import { loadAllWidgetDefinitions } from "@homarr/widgets/manifest"; - -const buildWidgetsByIntegration = async () => { - const map = Object.fromEntries(integrationKinds.map((kind) => [kind, [] as WidgetKind[]])) as Record< - IntegrationKind, - WidgetKind[] - >; - const definitions = await loadAllWidgetDefinitions(); - - for (const [widgetKind, definition] of definitions) { - const supported = - "supportedIntegrations" in definition ? (definition.supportedIntegrations as IntegrationKind[]) : []; - for (const kind of supported) { - if (kind in map) { - map[kind].push(widgetKind); - } - } - } - return map; -}; export const categoryTranslationKeys: Record = { dnsHole: "integration.category.dnsHole", @@ -64,14 +45,13 @@ export interface IntegrationGridItem { widgets: WidgetKind[]; } -export const buildSortedIntegrations = async ( +export const buildSortedIntegrations = ( options: { enableMockIntegration?: boolean; onboarding?: boolean } = {}, -): Promise => { - const widgetsByIntegration = await buildWidgetsByIntegration(); +): IntegrationGridItem[] => { return integrationKinds .filter((kind) => { if (options.onboarding && hiddenFromOnboarding.has(kind)) return false; - if (options.onboarding && widgetsByIntegration[kind].length === 0) return false; + if (options.onboarding && getWidgetKindsForIntegration(kind).length === 0) return false; if (!options.enableMockIntegration && kind === "mock") return false; return true; }) @@ -79,7 +59,7 @@ export const buildSortedIntegrations = async ( kind, name: getIntegrationName(kind), categories: [...new Set(integrationDefs[kind].category.flat())] as string[], - widgets: widgetsByIntegration[kind], + widgets: getWidgetKindsForIntegration(kind), })) .toSorted((left, right) => { const leftIdx = featuredIntegrations.indexOf(left.kind); diff --git a/apps/nextjs/src/components/integration/integration-multi-select-grid.tsx b/apps/nextjs/src/components/integration/integration-multi-select-grid.tsx index bece67741..cbd347ba1 100644 --- a/apps/nextjs/src/components/integration/integration-multi-select-grid.tsx +++ b/apps/nextjs/src/components/integration/integration-multi-select-grid.tsx @@ -1,9 +1,8 @@ "use client"; import { useCallback, useMemo, useState } from "react"; -import { Badge, Card, Center, Group, Loader, Stack, Text, Tooltip } from "@mantine/core"; +import { Badge, Card, Center, Group, Stack, Text, Tooltip } from "@mantine/core"; import { IconBrandDocker, IconCheck, IconPuzzle } from "@tabler/icons-react"; -import { useQuery } from "@tanstack/react-query"; import type { IntegrationKind } from "@homarr/definitions"; import { useI18n } from "@homarr/translation/client"; @@ -43,14 +42,10 @@ export const IntegrationMultiSelectGrid = ({ [selectedKindsArray, onSelectionChange], ); - const { - data: integrations = [], - isPending, - isError, - } = useQuery({ - queryKey: ["widget-integrations", { enableMockIntegration, onboarding }], - queryFn: () => buildSortedIntegrations({ enableMockIntegration, onboarding }), - }); + const integrations = useMemo( + () => buildSortedIntegrations({ enableMockIntegration, onboarding }), + [enableMockIntegration, onboarding], + ); const sorted = useMemo(() => { if (!detectedKinds || detectedKinds.size === 0) return integrations; @@ -69,11 +64,6 @@ export const IntegrationMultiSelectGrid = ({ disableScroll={onboarding} disableAutoFocus={onboarding} > - {isPending && ( -
- -
- )} {filtered.map((integration) => { const isSelected = selectedKinds.has(integration.kind); const isDetected = detectedKinds?.has(integration.kind) ?? false; @@ -140,14 +130,7 @@ export const IntegrationMultiSelectGrid = ({ ); })} - {isError && ( -
- - {t("common.error")} - -
- )} - {!isPending && !isError && filtered.length === 0 && ( + {filtered.length === 0 && (
{t("common.noResults")}
diff --git a/apps/nextjs/src/components/integration/integration-select-grid.tsx b/apps/nextjs/src/components/integration/integration-select-grid.tsx index e6458df6e..f4905dd2a 100644 --- a/apps/nextjs/src/components/integration/integration-select-grid.tsx +++ b/apps/nextjs/src/components/integration/integration-select-grid.tsx @@ -1,9 +1,8 @@ "use client"; import { useMemo, useState } from "react"; -import { Badge, Card, Center, Group, Loader, Stack, Text, Tooltip } from "@mantine/core"; +import { Badge, Card, Center, Group, Stack, Text, Tooltip } from "@mantine/core"; import { IconPuzzle } from "@tabler/icons-react"; -import { useQuery } from "@tanstack/react-query"; import type { IntegrationKind } from "@homarr/definitions"; import { useI18n } from "@homarr/translation/client"; @@ -20,14 +19,7 @@ export const IntegrationSelectGrid = ({ onSelect, enableMockIntegration = false const [search, setSearch] = useState(""); const t = useI18n(); - const { - data: integrations = [], - isPending, - isError, - } = useQuery({ - queryKey: ["widget-integrations", { enableMockIntegration }], - queryFn: () => buildSortedIntegrations({ enableMockIntegration }), - }); + const integrations = useMemo(() => buildSortedIntegrations({ enableMockIntegration }), [enableMockIntegration]); const filtered = useMemo(() => filterIntegrations(integrations, search), [integrations, search]); return ( @@ -36,11 +28,6 @@ export const IntegrationSelectGrid = ({ onSelect, enableMockIntegration = false onSearchChange={setSearch} placeholder={`${t("integration.page.list.search")}...`} > - {isPending && ( -
- -
- )} {filtered.map((integration) => ( ))} - {isError && ( -
- - {t("common.error")} - -
- )} - {!isPending && !isError && filtered.length === 0 && ( + {filtered.length === 0 && (
{t("common.noResults")}
diff --git a/apps/nextjs/src/components/layout/header.tsx b/apps/nextjs/src/components/layout/header.tsx index e9d93f7fc..2c46608c0 100644 --- a/apps/nextjs/src/components/layout/header.tsx +++ b/apps/nextjs/src/components/layout/header.tsx @@ -1,7 +1,6 @@ import type { ReactNode } from "react"; import { AppShellHeader, Group, UnstyledButton } from "@mantine/core"; -import { Spotlight } from "@homarr/spotlight"; import { Link } from "@homarr/ui"; import { ClientBurger } from "./header/burger"; @@ -9,6 +8,7 @@ import { DesktopSearchInput, MobileSearchButton } from "./header/search"; import { TourTarget } from "./header/tour-target"; import { UserButton } from "./header/user"; import { HomarrLogoWithTitle } from "./logo/homarr-logo"; +import { LazySpotlight } from "./header/lazy-spotlight"; interface Props { logo?: ReactNode; @@ -37,7 +37,7 @@ export const MainHeader = ({ logo, actions, hasNavigation = true }: Props) => { - + ); }; diff --git a/apps/nextjs/src/components/layout/header/button.tsx b/apps/nextjs/src/components/layout/header/button.tsx index e8d858b6a..27d48445d 100644 --- a/apps/nextjs/src/components/layout/header/button.tsx +++ b/apps/nextjs/src/components/layout/header/button.tsx @@ -14,6 +14,9 @@ type HeaderButtonProps = ( } ) & { children: ReactNode; + onFocus?: () => void; + onPointerDown?: () => void; + onPointerEnter?: () => void; } & Partial; const headerButtonActionIconProps: ActionIconProps = { diff --git a/apps/nextjs/src/components/layout/header/lazy-spotlight.tsx b/apps/nextjs/src/components/layout/header/lazy-spotlight.tsx new file mode 100644 index 000000000..958c0d537 --- /dev/null +++ b/apps/nextjs/src/components/layout/header/lazy-spotlight.tsx @@ -0,0 +1,89 @@ +"use client"; + +import { useEffect, useState } from "react"; +import dynamic from "next/dynamic"; +import { Center, Loader, Paper } from "@mantine/core"; +import { useHotkeys } from "@mantine/hooks"; + +import { hotkeys } from "@homarr/definitions"; +import { hasPendingSpotlightOpen, openSpotlight, spotlightOpenEvent } from "@homarr/spotlight/open"; + +const loadSpotlight = () => import("@homarr/spotlight/component"); +const SpotlightLoading = () => ( +
+ + + +
+); +const Spotlight = dynamic(() => loadSpotlight().then(({ Spotlight: Component }) => Component), { + ssr: false, + loading: SpotlightLoading, +}); + +export const preloadSpotlight = () => void loadSpotlight().catch(() => undefined); + +export const LazySpotlight = () => { + const [isMounted, setIsMounted] = useState(false); + const [isPreloaded, setIsPreloaded] = useState(false); + + useHotkeys( + isMounted + ? [] + : [ + [ + hotkeys.openSpotlight, + () => { + setIsMounted(true); + openSpotlight(); + }, + ], + ], + ); + + useEffect(() => { + let isActive = true; + const mount = () => setIsMounted(true); + window.addEventListener(spotlightOpenEvent, mount); + if (hasPendingSpotlightOpen()) mount(); + + const preload = () => { + void loadSpotlight().then( + () => { + if (isActive) setIsPreloaded(true); + }, + () => undefined, + ); + }; + let idleId: number | undefined; + const delayId = window.setTimeout(() => { + idleId = window.requestIdleCallback?.(preload, { timeout: 1_000 }); + if (idleId === undefined) preload(); + }, 1_000); + return () => { + isActive = false; + window.removeEventListener(spotlightOpenEvent, mount); + window.clearTimeout(delayId); + if (idleId !== undefined) window.cancelIdleCallback?.(idleId); + }; + }, []); + + return isMounted || isPreloaded ? ( + <> + {isPreloaded &&