diff --git a/.dockerignore b/.dockerignore index 521c55109..c0f2129a3 100644 --- a/.dockerignore +++ b/.dockerignore @@ -17,7 +17,8 @@ coverage dev .build e2e -apps/docs +apps/docs/* +!apps/docs/package.json apps/nextjs/.next .env .env.* diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index dcbb352c3..dbb3d2a2d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -82,7 +82,7 @@ jobs: run: cp .env.example .env - name: Lint, typecheck, and build affected workspaces - run: pnpm turbo run lint typecheck build --affected --continue=dependencies-successful + run: pnpm turbo run lint typecheck build --affected --continue=dependencies-successful --concurrency=2 - name: Validate workspace configuration run: pnpm lint:ws diff --git a/.gitignore b/.gitignore index c22262f6d..71a27829a 100644 --- a/.gitignore +++ b/.gitignore @@ -78,3 +78,4 @@ skills-lock.json apps/docs/.docusaurus apps/docs/build .pnpm-store +benchmark-results diff --git a/Dockerfile b/Dockerfile index 8f4c5e49a..afc7b5ded 100644 --- a/Dockerfile +++ b/Dockerfile @@ -3,12 +3,18 @@ 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 pnpm-lock.yaml pnpm-workspace.yaml package.json ./ +COPY .npmrc pnpm-lock.yaml pnpm-workspace.yaml package.json ./ COPY patches ./patches +COPY --parents ./apps/*/package.json ./packages/*/package.json ./tooling/*/package.json ./ +# @homarr/definitions generates documentation types during install. +COPY --parents ./packages/definitions/src ./ # Workaround for pnpm/pnpm#5268: pnpm fetch crashes when patchedDependencies # are configured with nodeLinker: hoisted. The applyPatchToDir function tries # to chdir into node_modules/ which doesn't exist during fetch (only the @@ -19,14 +25,16 @@ COPY patches ./patches 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 -COPY . . -# 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 . . ARG SKIP_ENV_VALIDATION='true' ARG CI='true' @@ -37,7 +45,9 @@ 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 \ - TURBO_PLATFORM="${TARGETPLATFORM:-linux/amd64}" pnpm build + --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 FROM base AS runner WORKDIR /app diff --git a/apps/docs/docs/advanced/development/getting-started.mdx b/apps/docs/docs/advanced/development/getting-started.mdx index e8f68ea6d..1bad7be05 100644 --- a/apps/docs/docs/advanced/development/getting-started.mdx +++ b/apps/docs/docs/advanced/development/getting-started.mdx @@ -14,8 +14,8 @@ This guide will help you set up your development environment to start contributi ### Prerequisites -- **Node.js (lts)**: You can download it from the [official website](https://nodejs.org/). -- **pnpm (latest)**: You can install it by running `npm install -g pnpm`. +- **Node.js 24.18 or newer**: You can download it from the [official website](https://nodejs.org/). +- **Corepack**: Run `corepack enable` so the repository selects its pinned pnpm version. - **Git**: You can download it from the [official website](https://git-scm.com/). - **Docker or Docker Desktop**: Required for the Redis service and the developer Docker CLI. - **Go 1.25+**: Required only for the developer Docker CLI. You can download it from the [official website](https://go.dev/). @@ -49,7 +49,10 @@ This guide will help you set up your development environment to start contributi #### During development -- `pnpm dev`: Start the development server. +- `pnpm dev`: Start the Next.js development server. +- `pnpm dev:benchmark`: Run the local authenticated-board benchmark. Set `DEV_BENCHMARK_BOARD_URL` and + `DEV_BENCHMARK_STORAGE_STATE`; readiness-only measurements require the explicit `--smoke` flag and are not + suitable for performance claims. - `pnpm dev:cli -- dev`: Browse and run local images and remote pull-request images. - `pnpm dev:cli -- build `: Build the current checkout as `homarr:`. - `pnpm dev:cli -- build --pr `: Build a pull request locally from a temporary checkout. @@ -74,4 +77,5 @@ This guide will help you set up your development environment to start contributi - `pnpm build`: Build the project for production. - `pnpm dev:cli -- build `: Build a `homarr:` image and record its checkout for later rebuilds. +- `docker build -t homarr .`: Build a docker image for the project. - `docker run -p 7575:7575 -e SECRET_ENCRYPTION_KEY='your_64_character_hex_string' homarr:`: Run the created docker image. 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 4ff31b9aa..2105259be 100644 --- a/apps/nextjs/next.config.ts +++ b/apps/nextjs/next.config.ts @@ -1,4 +1,6 @@ // Importing env files here to validate on build +import path from "node:path"; + import "@homarr/auth/env"; import "@homarr/core/infrastructure/db/env"; import "@homarr/common/env"; @@ -13,7 +15,21 @@ const withNextIntl = createNextIntlPlugin({ requestConfig: "../../packages/translation/src/request.ts", }); +const getDevelopmentServiceAliases = () => { + if (process.env.NODE_ENV !== "development") { + return undefined; + } + + return { + "@homarr/tasks": "./src/instrumentation-noop.ts", + "@homarr/websocket": "./src/instrumentation-noop.ts", + }; +}; + const nextConfig: NextConfig = { + // Next previews otherwise create agent instruction files in the application + // directory during development. + agentRules: false, env: { HOMARR_VERSION: process.env.HOMARR_VERSION ?? "unknown", }, @@ -30,19 +46,14 @@ const nextConfig: NextConfig = { serverExternalPackages: ["dockerode", "isomorphic-dompurify", "jsdom", "better-sqlite3"], experimental: { optimizePackageImports: ["@mantine/core", "@mantine/hooks", "@tabler/icons-react"], - turbopackFileSystemCacheForDev: true, - webpackMemoryOptimizations: true, + turbopackFileSystemCacheForBuild: true, + useTypeScriptCli: true, }, turbopack: { - // ponytail: known Turbopack NFT warning from path.join(process.cwd(), …) in - // src/app/api/backup/{route,shared}.ts. No working placement in 16.2.x - // (see vercel/next.js#95125). Suppress until upstream fix lands. - ignoreIssue: [ - { - path: "**/*", - title: "Encountered unexpected file in NFT list", - }, - ], + root: path.resolve(import.meta.dirname, "../.."), + // Development runs tasks and WebSocket as separate processes. These aliases + // keep their production-only instrumentation imports out of the dev graph. + resolveAlias: getDevelopmentServiceAliases(), }, transpilePackages: ["@homarr/ui", "@homarr/notifications", "@homarr/modals", "@homarr/spotlight", "@homarr/widgets"], images: { 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 3e167dd8c..535d88e1c 100644 --- a/apps/nextjs/src/app/[locale]/_client-providers/trpc.tsx +++ b/apps/nextjs/src/app/[locale]/_client-providers/trpc.tsx @@ -1,12 +1,10 @@ "use client"; import type { PropsWithChildren } from "react"; -import { useState } from "react"; -import type { QueryKey } from "@tanstack/react-query"; -import { QueryClient } from "@tanstack/react-query"; -import { ReactQueryDevtools } from "@tanstack/react-query-devtools"; +import { useEffect, useMemo, useState } from "react"; +import dynamic from "next/dynamic"; +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, @@ -25,18 +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 { widgetImports } from "@homarr/widgets"; +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 DevelopmentTools = + process.env.NODE_ENV === "development" + ? dynamic(() => import("./development-tools").then(({ DevelopmentTools: Tools }) => Tools), { ssr: false }) + : () => null; const getWebSocketProtocol = () => { if (typeof window === "undefined") { @@ -59,21 +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, - refetchOnWindowFocus: false, - refetchOnReconnect: false, - retry: 3, + retry: createQueryRetry(env.NODE_ENV === "development" ? 1 : 3), }, mutations: { onError(error) { @@ -91,23 +120,29 @@ export function TRPCReactProvider(props: PropsWithChildren) { }, }, }); - client.setQueryDefaults([["widget"]], { refetchInterval: queryCacheDefaultRefetchIntervalMs }); - for (const { definition } of Object.values(widgetImports)) { - const def = definition as { refetchInterval?: number | null; queryKey?: QueryKey; kind: string }; - if (def.refetchInterval === undefined) continue; - const key = def.queryKey ?? [["widget", def.kind]]; - const interval = def.refetchInterval === null ? false : def.refetchInterval * 1000; - client.setQueryDefaults(key, { refetchInterval: interval }); + 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} - - + + {children} + {process.env.NODE_ENV === "development" && } + ); -} +}; diff --git a/apps/nextjs/src/app/[locale]/boards/(content)/(home)/_definition.ts b/apps/nextjs/src/app/[locale]/boards/(content)/(home)/_definition.ts index 2357d7424..d64fdb56e 100644 --- a/apps/nextjs/src/app/[locale]/boards/(content)/(home)/_definition.ts +++ b/apps/nextjs/src/app/[locale]/boards/(content)/(home)/_definition.ts @@ -1,9 +1,9 @@ -import { api } from "@homarr/api/server"; +import { getHomeBoardAsync } from "@homarr/api/board-server"; import { createBoardContentPage } from "../_creator"; export default createBoardContentPage<{ locale: string }>({ async getInitialBoardAsync() { - return await api.board.getHomeBoard(); + return await getHomeBoardAsync(); }, }); diff --git a/apps/nextjs/src/app/[locale]/boards/(content)/[name]/(board)/_definition.tsx b/apps/nextjs/src/app/[locale]/boards/(content)/[name]/(board)/_definition.tsx index d3b65a5e9..f0924bb6e 100644 --- a/apps/nextjs/src/app/[locale]/boards/(content)/[name]/(board)/_definition.tsx +++ b/apps/nextjs/src/app/[locale]/boards/(content)/[name]/(board)/_definition.tsx @@ -1,9 +1,9 @@ -import { api } from "@homarr/api/server"; +import { getBoardByNameAsync } from "@homarr/api/board-server"; import { createBoardContentPage } from "../../_creator"; export default createBoardContentPage<{ locale: string; name: string }>({ async getInitialBoardAsync({ name }) { - return await api.board.getBoardByName({ name }); + return await getBoardByNameAsync(name); }, }); 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)/_client.tsx b/apps/nextjs/src/app/[locale]/boards/(content)/_client.tsx index 0ba502a9a..1b4308d38 100644 --- a/apps/nextjs/src/app/[locale]/boards/(content)/_client.tsx +++ b/apps/nextjs/src/app/[locale]/boards/(content)/_client.tsx @@ -14,10 +14,10 @@ export const ClientBoard = () => { const fullWidthSortedSections = board.sections .filter((section) => section.kind === "empty" || section.kind === "category") - .sort((sectionA, sectionB) => sectionA.yOffset - sectionB.yOffset); + .toSorted((sectionA, sectionB) => sectionA.yOffset - sectionB.yOffset); return ( - + {fullWidthSortedSections.map((section) => diff --git a/apps/nextjs/src/app/[locale]/boards/(content)/_creator.tsx b/apps/nextjs/src/app/[locale]/boards/(content)/_creator.tsx index 16be5c478..2bb868f7d 100644 --- a/apps/nextjs/src/app/[locale]/boards/(content)/_creator.tsx +++ b/apps/nextjs/src/app/[locale]/boards/(content)/_creator.tsx @@ -1,15 +1,12 @@ import type { Metadata } from "next"; -import { 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 { getQueryClient } from "@homarr/api/server"; +import { makeQueryClient } from "@homarr/api/shared"; import { IntegrationProvider } from "@homarr/auth/client"; import { auth } from "@homarr/auth/next"; import { getIntegrationsWithPermissionsAsync } from "@homarr/auth/server"; @@ -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"; @@ -29,6 +25,7 @@ import { DynamicClientBoard } from "./_dynamic-client"; import { BoardContentHeaderActions } from "./_header-actions"; const logger = createLogger({ module: "createBoardContentPage" }); +const getQueryClient = cache(makeQueryClient); export type Params = Record; @@ -61,7 +58,6 @@ export const createBoardContentPage = >( } return acc; }, new Map()); - const [integrations] = await Promise.all([ getIntegrationsWithPermissionsAsync(session), ...Array.from(itemsMap).map(([kind, items]) => @@ -77,19 +73,12 @@ export const createBoardContentPage = >( ), ]); - const userId = session?.user.id ?? "anonymous"; - return ( - <> - - - - - - - - - + + + + + ); }, generateMetadataAsync: async ({ params }: { params: Promise }): Promise => { @@ -120,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/[name]/layout.tsx b/apps/nextjs/src/app/[locale]/boards/[name]/layout.tsx index 1f129e4c1..0c5fd10e0 100644 --- a/apps/nextjs/src/app/[locale]/boards/[name]/layout.tsx +++ b/apps/nextjs/src/app/[locale]/boards/[name]/layout.tsx @@ -1,4 +1,4 @@ -import { api } from "@homarr/api/server"; +import { getBoardByNameAsync } from "@homarr/api/board-server"; import { BoardOtherHeaderActions } from "../_header-actions"; import { createBoardLayout } from "../_layout-creator"; @@ -6,6 +6,6 @@ import { createBoardLayout } from "../_layout-creator"; export default createBoardLayout<{ locale: string; name: string }>({ headerActions: , async getInitialBoardAsync({ name }) { - return await api.board.getBoardByName({ name }); + return await getBoardByNameAsync(name); }, }); 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]/init/page.tsx b/apps/nextjs/src/app/[locale]/init/page.tsx index 212c74e73..0013c3170 100644 --- a/apps/nextjs/src/app/[locale]/init/page.tsx +++ b/apps/nextjs/src/app/[locale]/init/page.tsx @@ -1,8 +1,8 @@ import type { JSX } from "react"; import { Box, Center, Stack, Text, Title } from "@mantine/core"; -import { api } from "@homarr/api/server"; import type { MaybePromise } from "@homarr/common/types"; +import { db } from "@homarr/db"; import type { OnboardingStep } from "@homarr/definitions"; import { getScopedI18n } from "@homarr/translation/server"; @@ -29,9 +29,16 @@ const stepComponents: Record MaybePromise { + const value = await db.query.onboarding.findFirst(); + if (!value) return { current: "start" as const, previous: null }; + + return { current: value.step, previous: value.previousStep }; +}; + export default async function InitPage() { const t = await getScopedI18n("init.step"); - const currentStep = await api.onboard.currentStep(); + const currentStep = await getCurrentOnboardingStepAsync(); const CurrentComponent = stepComponents[currentStep.current]; diff --git a/apps/nextjs/src/app/[locale]/layout.tsx b/apps/nextjs/src/app/[locale]/layout.tsx index 2388e2812..ea62beadf 100644 --- a/apps/nextjs/src/app/[locale]/layout.tsx +++ b/apps/nextjs/src/app/[locale]/layout.tsx @@ -13,11 +13,11 @@ import { notFound } from "next/navigation"; import type { DayOfWeek } from "@mantine/dates"; import { NextIntlClientProvider } from "next-intl"; -import { api } from "@homarr/api/server"; +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 { db } from "@homarr/db"; -import { getServerSettingsAsync } from "@homarr/db/queries"; +import { createLogger } from "@homarr/core/infrastructure/logs"; import { ModalProvider } from "@homarr/modals"; import { Notifications } from "@homarr/notifications"; import { SettingsProvider } from "@homarr/settings"; @@ -43,6 +43,8 @@ const fontSans = Inter({ variable: "--font-sans", }); +const logger = createLogger({ module: "rootLayout" }); + // eslint-disable-next-line no-restricted-syntax export const generateMetadata = async (): Promise => ({ title: "Homarr", @@ -78,15 +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 api.user.getById({ userId: session.user.id }).catch(() => null) : null; - const serverSettings = await getServerSettingsAsync(db); - const colorScheme = await getCurrentColorSchemeAsync(); - const direction = isLocaleRTL((await props.params).locale) ? "rtl" : "ltr"; + 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 [session, user, serverSettings, colorScheme] = await Promise.all([ + sessionPromise, + userPromise, + getRscServerSettingsAsync(), + getCurrentColorSchemeAsync(), + ]); + const direction = isLocaleRTL(locale) ? "rtl" : "ltr"; const StackedProvider = composeWrappers([ (innerProps) => { @@ -125,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 9bbc0ac93..882842ee7 100644 --- a/apps/nextjs/src/app/[locale]/widgets/[kind]/_content.tsx +++ b/apps/nextjs/src/app/[locale]/widgets/[kind]/_content.tsx @@ -1,41 +1,38 @@ "use client"; -import { useCallback, useMemo, useState } from "react"; +import { use, useCallback, useState } from "react"; import { ActionIcon, Affix, Card } from "@mantine/core"; import { IconDimensions, IconPencil, IconToggleLeft, IconToggleRight } from "@tabler/icons-react"; 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 { loadWidgetDynamic, reduceWidgetOptionsWithDefaultValues, widgetImports } from "@homarr/widgets"; import { WidgetError } from "@homarr/widgets/errors"; -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 = useMemo(() => widgetImports[kind].definition, [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, @@ -45,7 +42,7 @@ export const WidgetPreviewPageContent = ({ kind, integrationData }: WidgetPrevie integrationIds: string[]; advancedOptions: BoardItemAdvancedOptions; }>({ - options: reduceWidgetOptionsWithDefaultValues(kind, settings, {}), + options: reduceWidgetOptionsWithDefinition(currentDefinition, settings, {}), integrationIds: [], advancedOptions: { title: null, @@ -54,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 = loadWidgetDynamic(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); @@ -94,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 ( <> @@ -105,10 +115,10 @@ 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 cfd213f9d..9cdf91241 100644 --- a/apps/nextjs/src/app/[locale]/widgets/[kind]/layout.tsx +++ b/apps/nextjs/src/app/[locale]/widgets/[kind]/layout.tsx @@ -1,23 +1,18 @@ import type { PropsWithChildren } from "react"; -import { widgetImports } from "@homarr/widgets"; +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 = () => { - return Object.entries(widgetImports).map(([key, value]) => { - return { - href: `/widgets/${key}`, - icon: value.definition.icon, - label: value.definition.kind, - }; - }); -}; +const links = widgetKinds.map((kind) => ({ + href: `/widgets/${kind}`, + icon: widgetCatalogIcons[kind], + label: kind, +})); export default function WidgetPreviewLayout({ children }: PropsWithChildren) { - const links = getLinks(); - return ( diff --git a/apps/nextjs/src/app/[locale]/widgets/[kind]/page.tsx b/apps/nextjs/src/app/[locale]/widgets/[kind]/page.tsx index fbcd741c2..454e7a805 100644 --- a/apps/nextjs/src/app/[locale]/widgets/[kind]/page.tsx +++ b/apps/nextjs/src/app/[locale]/widgets/[kind]/page.tsx @@ -1,10 +1,9 @@ +import { Suspense } from "react"; import { notFound } from "next/navigation"; -import { Center } from "@mantine/core"; +import { Center, Loader } from "@mantine/core"; -import { env } from "@homarr/common/env"; -import { db } from "@homarr/db"; import type { WidgetKind } from "@homarr/definitions"; -import { widgetImports } from "@homarr/widgets"; +import { widgetKinds } from "@homarr/widgets/manifest"; import { WidgetPreviewPageContent } from "./_content"; @@ -13,24 +12,16 @@ interface Props { } export default async function WidgetPreview(props: Props) { - if (!((await props.params).kind in widgetImports || env.NODE_ENV !== "development")) { + const { kind } = await props.params; + if (!widgetKinds.includes(kind as WidgetKind)) { notFound(); } - const integrationData = await db.query.integrations.findMany({ - columns: { - id: true, - name: true, - url: true, - kind: true, - }, - }); - - const sort = (await props.params).kind as WidgetKind; - return (
- + }> + +
); } diff --git a/apps/nextjs/src/components/board/items/item-content.tsx b/apps/nextjs/src/components/board/items/item-content.tsx index ad0f3a305..bd6ef5fa3 100644 --- a/apps/nextjs/src/components/board/items/item-content.tsx +++ b/apps/nextjs/src/components/board/items/item-content.tsx @@ -1,17 +1,21 @@ -import type { MutableRefObject } from "react"; -import { useRef } from "react"; -import { Badge, Card } from "@mantine/core"; +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"; import combineClasses from "clsx"; import { NoIntegrationSelectedError } from "@homarr/widgets/errors/classes"; +import type { FallbackProps } from "react-error-boundary"; 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 { loadWidgetDynamic, reduceWidgetOptionsWithDefaultValues, widgetImports } from "@homarr/widgets"; import { WidgetError } from "@homarr/widgets/errors"; +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"; @@ -19,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; @@ -31,6 +35,53 @@ const getOverflowFromKind = (kind: SectionItem["kind"]) => { return undefined; }; +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) => { const { ref, width, height } = useElementSize(); const board = useRequiredBoard(); @@ -38,29 +89,28 @@ export const BoardItemContent = ({ item }: BoardItemContentProps) => { return ( <> - - } + > + +
+ +
+ + } > - -
-
+ + +
{item.advancedOptions.title?.trim() && ( { ); }; +interface LoadedBoardItemContentProps extends Omit { + innerRef: (element: HTMLDivElement | null) => void; +} + +const LoadedBoardItemContent = ({ item, innerRef, ...innerContentProps }: LoadedBoardItemContentProps) => { + const { definition, Component } = use(loadWidgetResources(item.kind)); + + return ( + + + ( + <> + + + + )} + > + + + + + ); +}; + interface InnerContentProps { item: SectionItem; width: number; height: number; widgetStateRef: MutableRefObject | null>; + definition: WidgetDefinition; + Component: ComponentType>; } -const InnerContent = ({ item, ...dimensions }: InnerContentProps) => { +const InnerContent = ({ item, definition, Component, ...dimensions }: InnerContentProps) => { const settings = useSettings(); const board = useRequiredBoard(); const [isEditMode] = useEditMode(); - const Comp = loadWidgetDynamic(item.kind); - const { definition } = widgetImports[item.kind]; - const options = reduceWidgetOptionsWithDefaultValues(item.kind, settings, item.options); + const options = reduceWidgetOptionsWithDefinition(definition, settings, item.options); const newItem = { ...item, options }; const { updateItemOptions } = useItemActions(); const updateOptions = ({ newOptions }: { newOptions: Record }) => updateItemOptions({ itemId: item.id, newOptions }); const widgetSupportsIntegrations = - "supportedIntegrations" in definition && definition.supportedIntegrations.length >= 1; + "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 }) => ( <> - - + + )} > @@ -131,23 +213,25 @@ const InnerContent = ({ item, ...dimensions }: InnerContentProps) => { (!("integrationsRequired" in definition) || definition.integrationsRequired !== false) } /> - - - 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 b8a136bbb..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, useMemo, 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"; @@ -8,39 +8,44 @@ import { useEditMode } from "@homarr/boards/edit-mode"; import { useConfirmModal, useModalAction } from "@homarr/modals"; import { useSettings } from "@homarr/settings"; import { useI18n, useScopedI18n } from "@homarr/translation/client"; -import { widgetImports } from "@homarr/widgets"; -import { WidgetEditModal } from "@homarr/widgets/modals"; +import type { WidgetDefinition } from "@homarr/widgets/definition"; 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; item: SectionItem; + definition: WidgetDefinition; resetErrorBoundary?: () => void; } export const BoardItemMenu = (props: BoardItemMenuProps) => { const { data: session } = useSession(); - if (!session) return null; + const [isEditMode] = useEditMode(); + if (!session || !isEditMode) return null; return ; }; -const BoardItemMenuInner = ({ offset, item, resetErrorBoundary }: BoardItemMenuProps) => { +const BoardItemMenuInner = ({ offset, item, definition, resetErrorBoundary }: BoardItemMenuProps) => { 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 currentDefinition = useMemo(() => widgetImports[item.kind].definition, [item.kind]); + 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, resetErrorBoundary }: BoardItemMenuP } }, [item, resetErrorBoundary]); - if (!isEditMode || isPending) return null; - const openEditModal = () => { openModal( { kind: item.kind, + definition, value: { advancedOptions: item.advancedOptions, options: item.options, @@ -80,10 +84,10 @@ const BoardItemMenuInner = ({ offset, item, resetErrorBoundary }: BoardItemMenuP }, integrationData: (integrationData ?? []).filter( (integration) => - "supportedIntegrations" in currentDefinition && - (currentDefinition.supportedIntegrations as string[]).some((kind) => kind === integration.kind), + "supportedIntegrations" in definition && + (definition.supportedIntegrations as string[]).some((kind) => kind === integration.kind), ), - integrationSupport: "supportedIntegrations" in currentDefinition, + integrationSupport: "supportedIntegrations" in definition, settings, itemId: item.id, appId: item.kind === "app" ? (item.options.appId as string | undefined) : undefined, @@ -107,7 +111,7 @@ const BoardItemMenuInner = ({ offset, item, resetErrorBoundary }: BoardItemMenuP }; 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 2ac349c6d..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,46 +1,65 @@ -import { 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, objectEntries } from "@homarr/common"; -import { getIconUrl, getIntegrationName } from "@homarr/definitions"; +import { createId } from "@homarr/common"; +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 { reduceWidgetOptionsWithDefaultValues, widgetImports } from "@homarr/widgets"; -import { WidgetEditModal } from "@homarr/widgets/modals"; +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 }) => { +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]); const items = useMemo( () => - objectEntries(widgetImports) - .filter(([kind]) => kind !== "customApi") - .map(([kind, value]) => ({ - kind, - supportedIntegrations: - "supportedIntegrations" in value.definition - ? value.definition.supportedIntegrations.filter((integration) => integration !== "mock") - : [], - icon: value.definition.icon, - name: t(`widget.${kind}.name`), - description: t(`widget.${kind}.description`), - })) + widgetKinds + .filter((kind) => kind !== "customApi") + .map((kind) => { + return { + kind, + supportedIntegrations: (widgetIntegrationSupport[kind] ?? []).filter( + (integration) => integration !== "mock", + ), + icon: widgetCatalogIcons[kind], + name: t(`widget.${kind}.name`), + description: t(`widget.${kind}.description`), + }; + }) .sort((itemA, itemB) => { if (itemA.kind === "app") return -1; if (itemB.kind === "app") return 1; @@ -66,52 +85,81 @@ export const ItemSelectModal = createModal(({ actions }) => { [customWidgetDefs, search], ); - const handleAddCustomWidget = (definitionId: string) => { - const itemId = createId(); - const defaultOptions = reduceWidgetOptionsWithDefaultValues("customApi", 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 = widgetImports[kind].definition; - 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 as string[]).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 = reduceWidgetOptionsWithDefaultValues(kind, 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 ( @@ -120,9 +168,9 @@ export const ItemSelectModal = createModal(({ actions }) => { onSearchChange={setSearch} placeholder={`${t("item.create.search")}...`} onSearchKeyDown={(event) => { - if (event.key === "Enter" && filteredItems.length === 1) { - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - handleAdd(filteredItems[0]!.kind); + if (event.key === "Enter" && loadingSelection === null && filteredItems.length === 1) { + const [item] = filteredItems; + if (item) void handleAdd(item.kind); } }} > @@ -130,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))} /> ))} @@ -146,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)} > @@ -169,18 +223,24 @@ export const ItemSelectModal = createModal(({ actions }) => { - @@ -196,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, }); @@ -204,6 +281,9 @@ export const ItemSelectModal = createModal(({ actions }) => { const WidgetItem = ({ item, onSelect, + onIntent, + disabled, + loading, hasMatchingIntegration, }: { item: { @@ -214,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} > @@ -247,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 8520d6b89..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"; @@ -25,63 +25,116 @@ import { useSettings } from "@homarr/settings"; import { translateIfNecessary } from "@homarr/translation"; import { useI18n, useScopedI18n } from "@homarr/translation/client"; import type { TranslationFunction } from "@homarr/translation"; -import type { WidgetContextMenuAction } from "@homarr/widgets"; -import { reduceWidgetOptionsWithDefaultValues, widgetImports } from "@homarr/widgets"; -import { WidgetEditModal } from "@homarr/widgets/modals"; +import type { WidgetContextMenuAction, WidgetDefinition } from "@homarr/widgets/definition"; +import { getWidgetQueryKeys } from "@homarr/widgets/definition"; +import { reduceWidgetOptionsWithDefinition } from "@homarr/widgets/manifest"; 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; + definition: WidgetDefinition; widgetStateRef: MutableRefObject | null>; children: ReactNode; } -export const WidgetContextMenu = ({ item, widgetStateRef, children }: 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 currentDefinition = useMemo(() => widgetImports[item.kind].definition, [item.kind]); const { gridstack } = useSectionContext().refs; const queryClient = useQueryClient(); - const widgetQueryKey = useMemo( - () => ("queryKey" in currentDefinition ? (currentDefinition.queryKey as QueryKey) : [["widget", item.kind]]), - [currentDefinition, 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( - () => reduceWidgetOptionsWithDefaultValues(item.kind, settings, item.options) as Record, - [item.kind, settings, item.options], + () => reduceWidgetOptionsWithDefinition(definition, settings, item.options), + [definition, settings, item.options], ); type OptionDef = { type: string; skipContextMenu?: boolean }; const toggleOptions = useMemo(() => { - const rawOptions = currentDefinition.createOptions(settings) as unknown as Record; + const rawOptions = definition.createOptions(settings) as unknown as Record; return Object.entries(rawOptions).filter(([, def]) => def.type === "switch" && !def.skipContextMenu); - }, [currentDefinition, settings]); + }, [definition, settings]); const widgetContextActions = useMemo(() => { - const def = currentDefinition as Record; + const def = definition as unknown as Record; if (typeof def.contextActions !== "function") return []; // eslint-disable-next-line @typescript-eslint/no-explicit-any const actions = (def.contextActions as any)({ @@ -94,7 +147,7 @@ export const WidgetContextMenu = ({ item, widgetStateRef, children }: WidgetCont widgetStateRef, }); return (Array.isArray(actions) ? actions : []) as WidgetContextMenuAction[]; - }, [currentDefinition, options, item, updateItemOptions, isEditMode, board.id, widgetStateRef]); + }, [definition, options, item, updateItemOptions, isEditMode, board.id, widgetStateRef]); const persistBoard = useCallback( (updatedItems: typeof board.items) => { @@ -107,6 +160,7 @@ export const WidgetContextMenu = ({ item, widgetStateRef, children }: WidgetCont openModal( { kind: item.kind, + definition, value: { advancedOptions: item.advancedOptions, options: item.options, @@ -133,10 +187,10 @@ export const WidgetContextMenu = ({ item, widgetStateRef, children }: WidgetCont }, integrationData: (integrationData ?? []).filter( (integration) => - "supportedIntegrations" in currentDefinition && - (currentDefinition.supportedIntegrations as string[]).some((kind) => kind === integration.kind), + "supportedIntegrations" in definition && + (definition.supportedIntegrations as string[]).some((kind) => kind === integration.kind), ), - integrationSupport: "supportedIntegrations" in currentDefinition, + integrationSupport: "supportedIntegrations" in definition, settings, appId: item.kind === "app" ? (item.options.appId as string | undefined) : undefined, }, @@ -150,7 +204,7 @@ export const WidgetContextMenu = ({ item, widgetStateRef, children }: WidgetCont updateItemAdvancedOptions, updateItemIntegrations, integrationData, - currentDefinition, + definition, settings, isEditMode, persistBoard, @@ -168,135 +222,133 @@ export const WidgetContextMenu = ({ item, widgetStateRef, children }: WidgetCont [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-hover-overlay.tsx b/apps/nextjs/src/components/board/items/widget-hover-overlay.tsx index 6fcdc4a93..6529eed12 100644 --- a/apps/nextjs/src/components/board/items/widget-hover-overlay.tsx +++ b/apps/nextjs/src/components/board/items/widget-hover-overlay.tsx @@ -1,12 +1,13 @@ "use client"; +import { Suspense, use } from "react"; import { Avatar, Text, Tooltip } from "@mantine/core"; import type { RouterOutputs } from "@homarr/api"; import { useEditMode } from "@homarr/boards/edit-mode"; import { getIconUrl } from "@homarr/definitions"; import { useI18n } from "@homarr/translation/client"; -import { widgetImports } from "@homarr/widgets"; +import { loadWidgetDefinition } from "@homarr/widgets/manifest"; import type { SectionItem } from "~/app/[locale]/boards/_types"; import classes from "./widget-hover-overlay.module.css"; @@ -18,8 +19,19 @@ interface WidgetHoverOverlayProps { export const WidgetHoverOverlay = ({ item, integrations }: WidgetHoverOverlayProps) => { const [isEditMode] = useEditMode(); + + if (!isEditMode) return null; + + return ( + + + + ); +}; + +const LoadedWidgetHoverOverlay = ({ item, integrations }: WidgetHoverOverlayProps) => { const t = useI18n(); - const { definition } = widgetImports[item.kind]; + const definition = use(loadWidgetDefinition(item.kind)); const WidgetIcon = definition.icon; const displayName = item.advancedOptions.title?.trim() || t(`widget.${item.kind}.name`); @@ -28,10 +40,6 @@ export const WidgetHoverOverlay = ({ item, integrations }: WidgetHoverOverlayPro item.integrationIds.includes(integration.id), ); - if (!isEditMode) { - return null; - } - return (
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/category/category-menu-actions.tsx b/apps/nextjs/src/components/board/sections/category/category-menu-actions.tsx index 906abdea6..894bcbad6 100644 --- a/apps/nextjs/src/components/board/sections/category/category-menu-actions.tsx +++ b/apps/nextjs/src/components/board/sections/category/category-menu-actions.tsx @@ -105,12 +105,14 @@ export const useCategoryMenuActions = (category: CategorySection) => { const settings = useSettings(); const openAllInNewTabs = useCallback(async () => { const currentLayoutId = getCurrentLayout(board); - const appIds = filterByItemKind( - board.items.filter( - (item) => item.layouts.find((layout) => layout.layoutId === currentLayoutId)?.sectionId === category.id, - ), - settings, - "app", + const appIds = ( + await filterByItemKind( + board.items.filter( + (item) => item.layouts.find((layout) => layout.layoutId === currentLayoutId)?.sectionId === category.id, + ), + settings, + "app", + ) ).map((item) => { return item.options.appId; }); diff --git a/apps/nextjs/src/components/board/sections/category/filter.ts b/apps/nextjs/src/components/board/sections/category/filter.ts index 72bb4443f..3866a0075 100644 --- a/apps/nextjs/src/components/board/sections/category/filter.ts +++ b/apps/nextjs/src/components/board/sections/category/filter.ts @@ -1,21 +1,23 @@ import type { WidgetKind } from "@homarr/definitions"; import type { SettingsContextProps } from "@homarr/settings/creator"; -import type { WidgetComponentProps } from "@homarr/widgets"; -import { reduceWidgetOptionsWithDefaultValues } from "@homarr/widgets"; +import type { WidgetComponentProps } from "@homarr/widgets/definition"; +import { loadWidgetDefinition, reduceWidgetOptionsWithDefinition } from "@homarr/widgets/manifest"; import type { Item } from "~/app/[locale]/boards/_types"; -export const filterByItemKind = ( +export const filterByItemKind = async ( items: Item[], settings: SettingsContextProps, kind: TKind, ) => { + const definition = await loadWidgetDefinition(kind); + return items .filter((item) => item.kind === kind) .map((item) => ({ ...item, - options: reduceWidgetOptionsWithDefaultValues( - kind, + options: reduceWidgetOptionsWithDefinition( + definition, settings, item.options, ) as WidgetComponentProps["options"], 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 7451af1e7..03056c130 100644 --- a/apps/nextjs/src/components/integration/integration-grid-shared.ts +++ b/apps/nextjs/src/components/integration/integration-grid-shared.ts @@ -1,4 +1,3 @@ -import { objectEntries } from "@homarr/common"; import type { IntegrationKind, WidgetKind } from "@homarr/definitions"; import { featuredIntegrations, @@ -6,30 +5,8 @@ import { hiddenFromOnboarding, integrationDefs, integrationKinds, + getWidgetKindsForIntegration, } from "@homarr/definitions"; -import { widgetImports } from "@homarr/widgets"; - -const buildWidgetsByIntegration = () => { - const map = Object.fromEntries(integrationKinds.map((kind) => [kind, [] as WidgetKind[]])) as Record< - IntegrationKind, - WidgetKind[] - >; - - for (const [widgetKind, widget] of objectEntries(widgetImports)) { - const supported = - "supportedIntegrations" in widget.definition - ? (widget.definition.supportedIntegrations as IntegrationKind[]) - : []; - for (const kind of supported) { - if (kind in map) { - map[kind].push(widgetKind); - } - } - } - return map; -}; - -export const widgetsByIntegration = buildWidgetsByIntegration(); export const categoryTranslationKeys: Record = { dnsHole: "integration.category.dnsHole", @@ -70,11 +47,11 @@ export interface IntegrationGridItem { export const buildSortedIntegrations = ( options: { enableMockIntegration?: boolean; onboarding?: boolean } = {}, -): IntegrationGridItem[] => - integrationKinds +): 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; }) @@ -82,9 +59,9 @@ export const buildSortedIntegrations = ( kind, name: getIntegrationName(kind), categories: [...new Set(integrationDefs[kind].category.flat())] as string[], - widgets: widgetsByIntegration[kind], + widgets: getWidgetKindsForIntegration(kind), })) - .sort((left, right) => { + .toSorted((left, right) => { const leftIdx = featuredIntegrations.indexOf(left.kind); const rightIdx = featuredIntegrations.indexOf(right.kind); if (leftIdx !== -1 && rightIdx !== -1) return leftIdx - rightIdx; @@ -92,6 +69,7 @@ export const buildSortedIntegrations = ( if (rightIdx !== -1) return 1; return right.widgets.length - left.widgets.length || left.name.localeCompare(right.name); }); +}; export const filterIntegrations = (items: IntegrationGridItem[], search: string) => items.filter((i) => i.name.toLowerCase().includes(search.toLowerCase().trim())); 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 &&