diff --git a/.gitattributes b/.gitattributes
index 07764a78d..94f480de9 100644
--- a/.gitattributes
+++ b/.gitattributes
@@ -1 +1 @@
-* text eol=lf
\ No newline at end of file
+* text=auto eol=lf
\ No newline at end of file
diff --git a/.github/workflows/crowdin-schedule-download.yml b/.github/workflows/crowdin-schedule-download.yml
new file mode 100644
index 000000000..4e07f5a10
--- /dev/null
+++ b/.github/workflows/crowdin-schedule-download.yml
@@ -0,0 +1,40 @@
+name: "[Crowdin] Download translations"
+
+on:
+ workflow_dispatch:
+ schedule:
+ - cron: "0 0 * * *" # every day at midnight
+
+jobs:
+ download-crowdin-translations:
+ runs-on: ubuntu-latest
+
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Obtain token
+ id: obtainToken
+ uses: tibdex/github-app-token@v2
+ with:
+ private_key: ${{ secrets.CROWDIN_APP_PRIVATE_KEY }}
+ app_id: ${{ secrets.CROWDIN_APP_ID }}
+
+ - name: Download Crowdin translations
+ uses: crowdin/github-action@v2
+ with:
+ upload_sources: false
+ upload_translations: false
+ download_translations: true
+ localization_branch_name: crowdin
+ create_pull_request: true
+ pull_request_title: "chore(lang): updated translations from crowdin"
+ pull_request_body: "New Crowdin translations by [Crowdin GH Action](https://github.com/crowdin/github-action)"
+ pull_request_base_branch_name: "dev"
+ github_user_name: "Crowdin Homarr"
+ github_user_email: "190541745+homarr-crowdin[bot]@users.noreply.github.com"
+ skip_untranslated_strings: true
+ env:
+ GITHUB_TOKEN: ${{ steps.obtainToken.outputs.token }}
+ CROWDIN_PROJECT_ID: ${{ secrets.CROWDIN_PROJECT_ID }}
+ CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }}
diff --git a/.github/workflows/crowdin-upload.yml b/.github/workflows/crowdin-upload.yml
new file mode 100644
index 000000000..9a9972095
--- /dev/null
+++ b/.github/workflows/crowdin-upload.yml
@@ -0,0 +1,28 @@
+name: "[Crowdin] Upload translations"
+
+on:
+ workflow_dispatch:
+ push:
+ paths:
+ - "packages/translation/src/lang/**"
+ branches: [dev]
+
+jobs:
+ upload-crowdin-translations:
+ # Don't run this action if the downloaded translations are being pushed
+ if: "!contains(github.event.head_commit.message, 'chore(lang)')"
+ runs-on: ubuntu-latest
+
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Upload Crowdin translations
+ uses: crowdin/github-action@v2
+ with:
+ upload_sources: true
+ upload_translations: true
+ download_translations: false
+ env:
+ CROWDIN_PROJECT_ID: ${{ secrets.CROWDIN_PROJECT_ID }}
+ CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }}
diff --git a/.nvmrc b/.nvmrc
index 7af24b7dd..1d9b7831b 100644
--- a/.nvmrc
+++ b/.nvmrc
@@ -1 +1 @@
-22.11.0
+22.12.0
diff --git a/Dockerfile b/Dockerfile
index 5e682f89b..443a7ce08 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -1,4 +1,4 @@
-FROM node:22.11.0-alpine AS base
+FROM node:22.12.0-alpine AS base
FROM base AS builder
RUN apk add --no-cache libc6-compat
@@ -6,46 +6,15 @@ RUN apk update
# Set working directory
WORKDIR /app
-COPY . .
-RUN npm i -g turbo
-RUN turbo prune @homarr/nextjs --docker --out-dir ./next-out
-RUN turbo prune @homarr/tasks --docker --out-dir ./tasks-out
-RUN turbo prune @homarr/websocket --docker --out-dir ./websocket-out
-RUN turbo prune @homarr/db --docker --out-dir ./migration-out
-RUN turbo prune @homarr/cli --docker --out-dir ./cli-out
-
-# Add lockfile and package.json's of isolated subworkspace
-FROM base AS installer
RUN apk add --no-cache libc6-compat curl bash
RUN apk update
-WORKDIR /app
+COPY . .
-# First install the dependencies (as they change less often)
-COPY .gitignore .gitignore
-
-COPY --from=builder /app/tasks-out/json/ .
-COPY --from=builder /app/websocket-out/json/ .
-COPY --from=builder /app/migration-out/json/ .
-COPY --from=builder /app/cli-out/json/ .
-COPY --from=builder /app/next-out/json/ .
-COPY --from=builder /app/pnpm-lock.yaml ./pnpm-lock.yaml
-
-# Is used for postinstall of docs definitions
-COPY --from=builder /app/packages/definitions/src/docs ./packages/definitions/src/docs
-
-# Uses the lockfile to install the dependencies
RUN corepack enable pnpm && pnpm install --recursive --frozen-lockfile
# Install sharp for image optimization
RUN corepack enable pnpm && pnpm install sharp -w
-# Build the project
-COPY --from=builder /app/tasks-out/full/ .
-COPY --from=builder /app/websocket-out/full/ .
-COPY --from=builder /app/next-out/full/ .
-COPY --from=builder /app/migration-out/full/ .
-COPY --from=builder /app/cli-out/full/ .
-
# Copy static data as it is not part of the build
COPY static-data ./static-data
ARG SKIP_ENV_VALIDATION='true'
@@ -69,7 +38,7 @@ RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
# Enable homarr cli
-COPY --from=installer --chown=nextjs:nodejs /app/packages/cli/cli.cjs /app/apps/cli/cli.cjs
+COPY --from=builder --chown=nextjs:nodejs /app/packages/cli/cli.cjs /app/apps/cli/cli.cjs
RUN echo $'#!/bin/bash\ncd /app/apps/cli && node ./cli.cjs "$@"' > /usr/bin/homarr
RUN chmod +x /usr/bin/homarr
@@ -83,20 +52,20 @@ RUN mkdir -p /var/cache/nginx && chown -R nextjs:nodejs /var/cache/nginx && \
mkdir -p /etc/nginx/templates /etc/nginx/ssl/certs && chown -R nextjs:nodejs /etc/nginx
USER nextjs
-COPY --from=installer /app/apps/nextjs/next.config.mjs .
-COPY --from=installer /app/apps/nextjs/package.json .
+COPY --from=builder /app/apps/nextjs/next.config.mjs .
+COPY --from=builder /app/apps/nextjs/package.json .
-COPY --from=installer --chown=nextjs:nodejs /app/apps/tasks/tasks.cjs ./apps/tasks/tasks.cjs
-COPY --from=installer --chown=nextjs:nodejs /app/apps/websocket/wssServer.cjs ./apps/websocket/wssServer.cjs
-COPY --from=installer --chown=nextjs:nodejs /app/node_modules/better-sqlite3/build/Release/better_sqlite3.node /app/build/better_sqlite3.node
+COPY --from=builder --chown=nextjs:nodejs /app/apps/tasks/tasks.cjs ./apps/tasks/tasks.cjs
+COPY --from=builder --chown=nextjs:nodejs /app/apps/websocket/wssServer.cjs ./apps/websocket/wssServer.cjs
+COPY --from=builder --chown=nextjs:nodejs /app/node_modules/better-sqlite3/build/Release/better_sqlite3.node /app/build/better_sqlite3.node
-COPY --from=installer --chown=nextjs:nodejs /app/packages/db/migrations ./db/migrations
+COPY --from=builder --chown=nextjs:nodejs /app/packages/db/migrations ./db/migrations
# Automatically leverage output traces to reduce image size
# https://nextjs.org/docs/advanced-features/output-file-tracing
-COPY --from=installer --chown=nextjs:nodejs /app/apps/nextjs/.next/standalone ./
-COPY --from=installer --chown=nextjs:nodejs /app/apps/nextjs/.next/static ./apps/nextjs/.next/static
-COPY --from=installer --chown=nextjs:nodejs /app/apps/nextjs/public ./apps/nextjs/public
+COPY --from=builder --chown=nextjs:nodejs /app/apps/nextjs/.next/standalone ./
+COPY --from=builder --chown=nextjs:nodejs /app/apps/nextjs/.next/static ./apps/nextjs/.next/static
+COPY --from=builder --chown=nextjs:nodejs /app/apps/nextjs/public ./apps/nextjs/public
COPY --chown=nextjs:nodejs scripts/run.sh ./run.sh
COPY --chown=nextjs:nodejs scripts/generateEncryptionKey.js ./generateEncryptionKey.js
COPY --chown=nextjs:nodejs packages/redis/redis.conf /app/redis.conf
diff --git a/apps/nextjs/package.json b/apps/nextjs/package.json
index a30956ecf..9f8a88337 100644
--- a/apps/nextjs/package.json
+++ b/apps/nextjs/package.json
@@ -23,7 +23,7 @@
"@homarr/db": "workspace:^0.1.0",
"@homarr/definitions": "workspace:^0.1.0",
"@homarr/form": "workspace:^0.1.0",
- "@homarr/gridstack": "^1.0.3",
+ "@homarr/gridstack": "^1.11.2",
"@homarr/integrations": "workspace:^0.1.0",
"@homarr/log": "workspace:^",
"@homarr/modals": "workspace:^0.1.0",
@@ -44,10 +44,10 @@
"@mantine/tiptap": "^7.14.3",
"@million/lint": "1.0.13",
"@t3-oss/env-nextjs": "^0.11.1",
- "@tabler/icons-react": "^3.23.0",
- "@tanstack/react-query": "^5.62.0",
- "@tanstack/react-query-devtools": "^5.62.0",
- "@tanstack/react-query-next-experimental": "5.62.0",
+ "@tabler/icons-react": "^3.24.0",
+ "@tanstack/react-query": "^5.62.3",
+ "@tanstack/react-query-devtools": "^5.62.3",
+ "@tanstack/react-query-next-experimental": "5.62.3",
"@trpc/client": "next",
"@trpc/next": "next",
"@trpc/react-query": "next",
@@ -58,20 +58,20 @@
"chroma-js": "^3.1.2",
"clsx": "^2.1.1",
"dayjs": "^1.11.13",
- "dotenv": "^16.4.5",
+ "dotenv": "^16.4.7",
"flag-icons": "^7.2.3",
"glob": "^11.0.0",
"jotai": "^2.10.3",
"mantine-react-table": "2.0.0-beta.7",
- "next": "^14.2.18",
+ "next": "^14.2.20",
"postcss-preset-mantine": "^1.17.0",
"prismjs": "^1.29.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-error-boundary": "^4.1.2",
"react-simple-code-editor": "^0.14.1",
- "sass": "^1.81.0",
- "superjson": "2.2.1",
+ "sass": "^1.82.0",
+ "superjson": "2.2.2",
"swagger-ui-react": "^5.18.2",
"use-deep-compare-effect": "^1.8.1"
},
@@ -82,13 +82,13 @@
"@types/chroma-js": "2.4.4",
"@types/node": "^22.10.1",
"@types/prismjs": "^1.26.5",
- "@types/react": "^18.3.12",
+ "@types/react": "^18.3.13",
"@types/react-dom": "^18.3.1",
"@types/swagger-ui-react": "^4.18.3",
"concurrently": "^9.1.0",
- "eslint": "^9.15.0",
+ "eslint": "^9.16.0",
"node-loader": "^2.1.0",
- "prettier": "^3.4.1",
+ "prettier": "^3.4.2",
"typescript": "^5.7.2"
}
}
diff --git a/apps/nextjs/src/app/[locale]/layout.tsx b/apps/nextjs/src/app/[locale]/layout.tsx
index fe4ad52d3..afa2e413e 100644
--- a/apps/nextjs/src/app/[locale]/layout.tsx
+++ b/apps/nextjs/src/app/[locale]/layout.tsx
@@ -13,6 +13,7 @@ import { env } from "@homarr/auth/env.mjs";
import { auth } from "@homarr/auth/next";
import { ModalProvider } from "@homarr/modals";
import { Notifications } from "@homarr/notifications";
+import { SpotlightProvider } from "@homarr/spotlight";
import { isLocaleRTL, isLocaleSupported } from "@homarr/translation";
import { getI18nMessages } from "@homarr/translation/server";
@@ -82,6 +83,7 @@ export default async function Layout(props: { children: React.ReactNode; params:
(innerProps) => ,
(innerProps) => ,
(innerProps) => ,
+ (innerProps) => ,
]);
return (
diff --git a/apps/tasks/package.json b/apps/tasks/package.json
index 9712424f1..d502f50f8 100644
--- a/apps/tasks/package.json
+++ b/apps/tasks/package.json
@@ -36,9 +36,9 @@
"@homarr/validation": "workspace:^0.1.0",
"@homarr/widgets": "workspace:^0.1.0",
"dayjs": "^1.11.13",
- "dotenv": "^16.4.5",
- "superjson": "2.2.1",
- "undici": "7.0.0"
+ "dotenv": "^16.4.7",
+ "superjson": "2.2.2",
+ "undici": "7.1.0"
},
"devDependencies": {
"@homarr/eslint-config": "workspace:^0.2.0",
@@ -46,8 +46,8 @@
"@homarr/tsconfig": "workspace:^0.1.0",
"@types/node": "^22.10.1",
"dotenv-cli": "^7.4.4",
- "eslint": "^9.15.0",
- "prettier": "^3.4.1",
+ "eslint": "^9.16.0",
+ "prettier": "^3.4.2",
"tsx": "4.19.2",
"typescript": "^5.7.2"
}
diff --git a/apps/websocket/package.json b/apps/websocket/package.json
index 89b3ca38c..4ec49dc76 100644
--- a/apps/websocket/package.json
+++ b/apps/websocket/package.json
@@ -25,7 +25,7 @@
"@homarr/log": "workspace:^",
"@homarr/redis": "workspace:^0.1.0",
"@homarr/validation": "workspace:^0.1.0",
- "dotenv": "^16.4.5",
+ "dotenv": "^16.4.7",
"tsx": "4.19.2",
"ws": "^8.18.0"
},
@@ -34,8 +34,8 @@
"@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0",
"@types/ws": "^8.5.13",
- "eslint": "^9.15.0",
- "prettier": "^3.4.1",
+ "eslint": "^9.16.0",
+ "prettier": "^3.4.2",
"typescript": "^5.7.2"
}
}
diff --git a/crowdin.yml b/crowdin.yml
new file mode 100644
index 000000000..19fed39f6
--- /dev/null
+++ b/crowdin.yml
@@ -0,0 +1,10 @@
+files:
+ - source: /packages/translation/src/lang/en.json
+ translation: /packages/translation/src/lang/%two_letters_code%.json
+
+# Title of pull request and so the commit that will be used for squashed merge commit
+pull_request_title: "chore(lang): updated translations from crowdin"
+
+# Custom commit message that is not only appended
+commit_message: "chore(lang): update translations %original_file_name% from crowdin [skip ci]"
+append_commit_message: false
\ No newline at end of file
diff --git a/package.json b/package.json
index 197919000..e68d1e811 100644
--- a/package.json
+++ b/package.json
@@ -31,19 +31,24 @@
"@homarr/prettier-config": "workspace:^0.1.0",
"@turbo/gen": "^2.3.3",
"@vitejs/plugin-react": "^4.3.4",
- "@vitest/coverage-v8": "^2.1.6",
- "@vitest/ui": "^2.1.6",
+ "@vitest/coverage-v8": "^2.1.8",
+ "@vitest/ui": "^2.1.8",
"cross-env": "^7.0.3",
"jsdom": "^25.0.1",
- "prettier": "^3.4.1",
- "testcontainers": "^10.15.0",
+ "prettier": "^3.4.2",
+ "testcontainers": "^10.16.0",
"turbo": "^2.3.3",
"typescript": "^5.7.2",
- "vite-tsconfig-paths": "^5.1.3",
- "vitest": "^2.1.6"
+ "vite-tsconfig-paths": "^5.1.4",
+ "vitest": "^2.1.8"
},
- "packageManager": "pnpm@9.14.4",
+ "packageManager": "pnpm@9.15.0",
"engines": {
- "node": ">=22.11.0"
+ "node": ">=22.12.0"
+ },
+ "pnpm": {
+ "patchedDependencies": {
+ "pretty-print-error": "patches/pretty-print-error.patch"
+ }
}
}
diff --git a/packages/analytics/package.json b/packages/analytics/package.json
index ce37922d3..e11c94611 100644
--- a/packages/analytics/package.json
+++ b/packages/analytics/package.json
@@ -26,13 +26,13 @@
"@homarr/log": "workspace:^0.1.0",
"@homarr/server-settings": "workspace:^0.1.0",
"@umami/node": "^0.4.0",
- "superjson": "2.2.1"
+ "superjson": "2.2.2"
},
"devDependencies": {
"@homarr/eslint-config": "workspace:^0.2.0",
"@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0",
- "eslint": "^9.15.0",
+ "eslint": "^9.16.0",
"typescript": "^5.7.2"
}
}
diff --git a/packages/api/package.json b/packages/api/package.json
index c1ed99462..7eb02b636 100644
--- a/packages/api/package.json
+++ b/packages/api/package.json
@@ -40,18 +40,18 @@
"@trpc/react-query": "next",
"@trpc/server": "next",
"dockerode": "^4.0.2",
- "next": "^14.2.18",
+ "next": "^14.2.20",
"react": "^18.3.1",
- "superjson": "2.2.1",
- "trpc-to-openapi": "^2.0.2"
+ "superjson": "2.2.2",
+ "trpc-to-openapi": "^2.1.0"
},
"devDependencies": {
"@homarr/eslint-config": "workspace:^0.2.0",
"@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0",
"@types/dockerode": "^3.3.32",
- "eslint": "^9.15.0",
- "prettier": "^3.4.1",
+ "eslint": "^9.16.0",
+ "prettier": "^3.4.2",
"typescript": "^5.7.2"
}
}
diff --git a/packages/auth/package.json b/packages/auth/package.json
index df8f3050e..3b57782bd 100644
--- a/packages/auth/package.json
+++ b/packages/auth/package.json
@@ -33,8 +33,8 @@
"@t3-oss/env-nextjs": "^0.11.1",
"bcrypt": "^5.1.1",
"cookies": "^0.9.1",
- "ldapts": "7.2.1",
- "next": "^14.2.18",
+ "ldapts": "7.2.2",
+ "next": "^14.2.20",
"next-auth": "5.0.0-beta.25",
"react": "^18.3.1",
"react-dom": "^18.3.1"
@@ -45,8 +45,8 @@
"@homarr/tsconfig": "workspace:^0.1.0",
"@types/bcrypt": "5.0.2",
"@types/cookies": "0.9.0",
- "eslint": "^9.15.0",
- "prettier": "^3.4.1",
+ "eslint": "^9.16.0",
+ "prettier": "^3.4.2",
"typescript": "^5.7.2"
}
}
diff --git a/packages/auth/providers/credentials/authorization/basic-authorization.ts b/packages/auth/providers/credentials/authorization/basic-authorization.ts
index a4debe69c..6357df7c6 100644
--- a/packages/auth/providers/credentials/authorization/basic-authorization.ts
+++ b/packages/auth/providers/credentials/authorization/basic-authorization.ts
@@ -11,7 +11,7 @@ export const authorizeWithBasicCredentialsAsync = async (
credentials: z.infer,
) => {
const user = await db.query.users.findFirst({
- where: and(eq(users.name, credentials.name), eq(users.provider, "credentials")),
+ where: and(eq(users.name, credentials.name.toLowerCase()), eq(users.provider, "credentials")),
});
if (!user?.password) {
diff --git a/packages/cli/package.json b/packages/cli/package.json
index d0cf7c0d7..dda211f4a 100644
--- a/packages/cli/package.json
+++ b/packages/cli/package.json
@@ -27,13 +27,13 @@
"@homarr/auth": "workspace:^0.1.0",
"@homarr/common": "workspace:^0.1.0",
"@homarr/db": "workspace:^0.1.0",
- "dotenv": "^16.4.5"
+ "dotenv": "^16.4.7"
},
"devDependencies": {
"@homarr/eslint-config": "workspace:^0.2.0",
"@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0",
- "eslint": "^9.15.0",
+ "eslint": "^9.16.0",
"typescript": "^5.7.2"
}
}
diff --git a/packages/common/package.json b/packages/common/package.json
index f58a804ad..95d2b4759 100644
--- a/packages/common/package.json
+++ b/packages/common/package.json
@@ -27,15 +27,15 @@
"dependencies": {
"@homarr/log": "workspace:^0.1.0",
"dayjs": "^1.11.13",
- "next": "^14.2.18",
+ "next": "^14.2.20",
"react": "^18.3.1",
- "tldts": "^6.1.64"
+ "tldts": "^6.1.65"
},
"devDependencies": {
"@homarr/eslint-config": "workspace:^0.2.0",
"@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0",
- "eslint": "^9.15.0",
+ "eslint": "^9.16.0",
"typescript": "^5.7.2"
}
}
diff --git a/packages/common/src/app-url/client.ts b/packages/common/src/app-url/client.ts
index e3c08268b..92d16a028 100644
--- a/packages/common/src/app-url/client.ts
+++ b/packages/common/src/app-url/client.ts
@@ -1,5 +1,6 @@
import { parseAppHrefWithVariables } from "./base";
export const parseAppHrefWithVariablesClient = (url: TInput): TInput => {
+ if (typeof window === "undefined") return url;
return parseAppHrefWithVariables(url, window.location.href);
};
diff --git a/packages/common/src/url.ts b/packages/common/src/url.ts
index e734ae6e8..aa15066f6 100644
--- a/packages/common/src/url.ts
+++ b/packages/common/src/url.ts
@@ -1,12 +1,6 @@
import type { ReadonlyHeaders } from "next/dist/server/web/spec-extension/adapters/headers";
-export const appendPath = (url: URL | string, path: string) => {
- const newUrl = new URL(url);
- newUrl.pathname = removeTrailingSlash(newUrl.pathname) + path;
- return newUrl;
-};
-
-const removeTrailingSlash = (path: string) => {
+export const removeTrailingSlash = (path: string) => {
return path.at(-1) === "/" ? path.substring(0, path.length - 1) : path;
};
diff --git a/packages/cron-job-runner/package.json b/packages/cron-job-runner/package.json
index 395398ed4..e3b418702 100644
--- a/packages/cron-job-runner/package.json
+++ b/packages/cron-job-runner/package.json
@@ -30,7 +30,7 @@
"@homarr/eslint-config": "workspace:^0.2.0",
"@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0",
- "eslint": "^9.15.0",
+ "eslint": "^9.16.0",
"typescript": "^5.7.2"
}
}
diff --git a/packages/cron-job-status/package.json b/packages/cron-job-status/package.json
index 4ab48da5f..f495ce6ef 100644
--- a/packages/cron-job-status/package.json
+++ b/packages/cron-job-status/package.json
@@ -29,7 +29,7 @@
"@homarr/eslint-config": "workspace:^0.2.0",
"@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0",
- "eslint": "^9.15.0",
+ "eslint": "^9.16.0",
"typescript": "^5.7.2"
}
}
diff --git a/packages/cron-jobs-core/package.json b/packages/cron-jobs-core/package.json
index 4c9a5181b..497521fe7 100644
--- a/packages/cron-jobs-core/package.json
+++ b/packages/cron-jobs-core/package.json
@@ -32,7 +32,7 @@
"@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0",
"@types/node-cron": "^3.0.11",
- "eslint": "^9.15.0",
+ "eslint": "^9.16.0",
"typescript": "^5.7.2"
}
}
diff --git a/packages/cron-jobs/package.json b/packages/cron-jobs/package.json
index a271b4770..1abaf0c1b 100644
--- a/packages/cron-jobs/package.json
+++ b/packages/cron-jobs/package.json
@@ -44,7 +44,7 @@
"@homarr/eslint-config": "workspace:^0.2.0",
"@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0",
- "eslint": "^9.15.0",
+ "eslint": "^9.16.0",
"typescript": "^5.7.2"
}
}
diff --git a/packages/db/package.json b/packages/db/package.json
index 71d72b4c0..b714e0a77 100644
--- a/packages/db/package.json
+++ b/packages/db/package.json
@@ -21,12 +21,12 @@
"clean": "rm -rf .turbo node_modules",
"format": "prettier --check . --ignore-path ../../.gitignore",
"lint": "eslint",
+ "migration:mysql:drop": "drizzle-kit drop --config ./configs/mysql.config.ts",
"migration:mysql:generate": "drizzle-kit generate --config ./configs/mysql.config.ts",
"migration:mysql:run": "drizzle-kit migrate --config ./configs/mysql.config.ts && pnpm run seed",
- "migration:mysql:drop": "drizzle-kit drop --config ./configs/mysql.config.ts",
+ "migration:sqlite:drop": "drizzle-kit drop --config ./configs/sqlite.config.ts",
"migration:sqlite:generate": "drizzle-kit generate --config ./configs/sqlite.config.ts",
"migration:sqlite:run": "drizzle-kit migrate --config ./configs/sqlite.config.ts && pnpm run seed",
- "migration:sqlite:drop": "drizzle-kit drop --config ./configs/sqlite.config.ts",
"push:mysql": "drizzle-kit push --config ./configs/mysql.config.ts",
"push:sqlite": "drizzle-kit push --config ./configs/sqlite.config.ts",
"seed": "pnpm with-env tsx ./migrations/run-seed.ts",
@@ -42,11 +42,11 @@
"@homarr/log": "workspace:^0.1.0",
"@homarr/server-settings": "workspace:^0.1.0",
"@paralleldrive/cuid2": "^2.2.2",
- "@testcontainers/mysql": "^10.15.0",
+ "@testcontainers/mysql": "^10.16.0",
"better-sqlite3": "^11.6.0",
- "dotenv": "^16.4.5",
- "drizzle-kit": "^0.28.1",
- "drizzle-orm": "^0.36.4",
+ "dotenv": "^16.4.7",
+ "drizzle-kit": "^0.29.1",
+ "drizzle-orm": "^0.37.0",
"drizzle-zod": "^0.5.1",
"mysql2": "3.11.5"
},
@@ -56,8 +56,8 @@
"@homarr/tsconfig": "workspace:^0.1.0",
"@types/better-sqlite3": "7.6.12",
"dotenv-cli": "^7.4.4",
- "eslint": "^9.15.0",
- "prettier": "^3.4.1",
+ "eslint": "^9.16.0",
+ "prettier": "^3.4.2",
"tsx": "4.19.2",
"typescript": "^5.7.2"
}
diff --git a/packages/definitions/package.json b/packages/definitions/package.json
index 1eca1df44..55d970fea 100644
--- a/packages/definitions/package.json
+++ b/packages/definitions/package.json
@@ -17,9 +17,9 @@
"scripts": {
"clean": "rm -rf .turbo node_modules",
"format": "prettier --check . --ignore-path ../../.gitignore",
+ "postinstall": "tsx ./src/docs/codegen.ts",
"lint": "eslint",
- "typecheck": "tsc --noEmit",
- "postinstall": "tsx ./src/docs/codegen.ts"
+ "typecheck": "tsc --noEmit"
},
"prettier": "@homarr/prettier-config",
"dependencies": {
@@ -29,7 +29,7 @@
"@homarr/eslint-config": "workspace:^0.2.0",
"@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0",
- "eslint": "^9.15.0",
+ "eslint": "^9.16.0",
"typescript": "^5.7.2"
}
}
diff --git a/packages/form/package.json b/packages/form/package.json
index 3cd87ab83..c7aea91e8 100644
--- a/packages/form/package.json
+++ b/packages/form/package.json
@@ -30,7 +30,7 @@
"@homarr/eslint-config": "workspace:^0.2.0",
"@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0",
- "eslint": "^9.15.0",
+ "eslint": "^9.16.0",
"typescript": "^5.7.2"
}
}
diff --git a/packages/icons/package.json b/packages/icons/package.json
index f0af2fabf..c82b0bcda 100644
--- a/packages/icons/package.json
+++ b/packages/icons/package.json
@@ -30,7 +30,7 @@
"@homarr/eslint-config": "workspace:^0.2.0",
"@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0",
- "eslint": "^9.15.0",
+ "eslint": "^9.16.0",
"typescript": "^5.7.2"
}
}
diff --git a/packages/integrations/package.json b/packages/integrations/package.json
index 1d94d5db6..b5e125176 100644
--- a/packages/integrations/package.json
+++ b/packages/integrations/package.json
@@ -31,6 +31,7 @@
"@homarr/db": "workspace:^0.1.0",
"@homarr/definitions": "workspace:^0.1.0",
"@homarr/log": "workspace:^0.1.0",
+ "@homarr/redis": "workspace:^0.1.0",
"@homarr/translation": "workspace:^0.1.0",
"@homarr/validation": "workspace:^0.1.0",
"@jellyfin/sdk": "^0.11.0",
@@ -41,7 +42,7 @@
"@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0",
"@types/xml2js": "^0.4.14",
- "eslint": "^9.15.0",
+ "eslint": "^9.16.0",
"typescript": "^5.7.2"
}
}
diff --git a/packages/integrations/src/adguard-home/adguard-home-integration.ts b/packages/integrations/src/adguard-home/adguard-home-integration.ts
index e8b6c5e1a..d7aca6add 100644
--- a/packages/integrations/src/adguard-home/adguard-home-integration.ts
+++ b/packages/integrations/src/adguard-home/adguard-home-integration.ts
@@ -6,7 +6,7 @@ import { filteringStatusSchema, statsResponseSchema, statusResponseSchema } from
export class AdGuardHomeIntegration extends Integration implements DnsHoleSummaryIntegration {
public async getSummaryAsync(): Promise {
- const statsResponse = await fetch(`${this.integration.url}/control/stats`, {
+ const statsResponse = await fetch(this.url("/control/stats"), {
headers: {
Authorization: `Basic ${this.getAuthorizationHeaderValue()}`,
},
@@ -18,7 +18,7 @@ export class AdGuardHomeIntegration extends Integration implements DnsHoleSummar
);
}
- const statusResponse = await fetch(`${this.integration.url}/control/status`, {
+ const statusResponse = await fetch(this.url("/control/status"), {
headers: {
Authorization: `Basic ${this.getAuthorizationHeaderValue()}`,
},
@@ -30,7 +30,7 @@ export class AdGuardHomeIntegration extends Integration implements DnsHoleSummar
);
}
- const filteringStatusResponse = await fetch(`${this.integration.url}/control/filtering/status`, {
+ const filteringStatusResponse = await fetch(this.url("/control/filtering/status"), {
headers: {
Authorization: `Basic ${this.getAuthorizationHeaderValue()}`,
},
@@ -86,7 +86,7 @@ export class AdGuardHomeIntegration extends Integration implements DnsHoleSummar
public async testConnectionAsync(): Promise {
await super.handleTestConnectionResponseAsync({
queryFunctionAsync: async () => {
- return await fetch(`${this.integration.url}/control/status`, {
+ return await fetch(this.url("/control/status"), {
headers: {
Authorization: `Basic ${this.getAuthorizationHeaderValue()}`,
},
@@ -106,7 +106,7 @@ export class AdGuardHomeIntegration extends Integration implements DnsHoleSummar
}
public async enableAsync(): Promise {
- const response = await fetch(`${this.integration.url}/control/protection`, {
+ const response = await fetch(this.url("/control/protection"), {
method: "POST",
headers: {
"Content-Type": "application/json",
@@ -124,7 +124,7 @@ export class AdGuardHomeIntegration extends Integration implements DnsHoleSummar
}
public async disableAsync(duration = 0): Promise {
- const response = await fetch(`${this.integration.url}/control/protection`, {
+ const response = await fetch(this.url("/control/protection"), {
method: "POST",
headers: {
"Content-Type": "application/json",
diff --git a/packages/integrations/src/base/integration.ts b/packages/integrations/src/base/integration.ts
index 69b346e23..c9c953443 100644
--- a/packages/integrations/src/base/integration.ts
+++ b/packages/integrations/src/base/integration.ts
@@ -1,4 +1,4 @@
-import { extractErrorMessage } from "@homarr/common";
+import { extractErrorMessage, removeTrailingSlash } from "@homarr/common";
import type { IntegrationSecretKind } from "@homarr/definitions";
import { logger } from "@homarr/log";
import type { TranslationObject } from "@homarr/translation";
@@ -29,6 +29,19 @@ export abstract class Integration {
return secret.value;
}
+ protected url(path: `/${string}`, queryParams?: Record) {
+ const baseUrl = removeTrailingSlash(this.integration.url);
+ const url = new URL(`${baseUrl}${path}`);
+
+ if (queryParams) {
+ for (const [key, value] of Object.entries(queryParams)) {
+ url.searchParams.set(key, value instanceof Date ? value.toISOString() : value.toString());
+ }
+ }
+
+ return url;
+ }
+
/**
* Test the connection to the integration
* @throws {IntegrationTestConnectionError} if the connection fails
diff --git a/packages/integrations/src/download-client/deluge/deluge-integration.ts b/packages/integrations/src/download-client/deluge/deluge-integration.ts
index ae7d18b36..29067f2c0 100644
--- a/packages/integrations/src/download-client/deluge/deluge-integration.ts
+++ b/packages/integrations/src/download-client/deluge/deluge-integration.ts
@@ -89,9 +89,8 @@ export class DelugeIntegration extends DownloadClientIntegration {
}
private getClient() {
- const baseUrl = new URL(this.integration.url).href;
return new Deluge({
- baseUrl,
+ baseUrl: this.url("/").toString(),
password: this.getSecretValue("password"),
});
}
diff --git a/packages/integrations/src/download-client/nzbget/nzbget-integration.ts b/packages/integrations/src/download-client/nzbget/nzbget-integration.ts
index 5e91f393b..b17af0a87 100644
--- a/packages/integrations/src/download-client/nzbget/nzbget-integration.ts
+++ b/packages/integrations/src/download-client/nzbget/nzbget-integration.ts
@@ -92,9 +92,9 @@ export class NzbGetIntegration extends DownloadClientIntegration {
method: CallType,
...params: Parameters
): Promise> {
- const url = new URL(this.integration.url);
- url.pathname += `${this.getSecretValue("username")}:${this.getSecretValue("password")}`;
- url.pathname += url.pathname.endsWith("/") ? "jsonrpc" : "/jsonrpc";
+ const username = this.getSecretValue("username");
+ const password = this.getSecretValue("password");
+ const url = this.url(`/${username}:${password}/jsonrpc`);
const body = JSON.stringify({ method, params });
return await fetch(url, { method: "POST", body })
.then(async (response) => {
diff --git a/packages/integrations/src/download-client/qbittorrent/qbittorrent-integration.ts b/packages/integrations/src/download-client/qbittorrent/qbittorrent-integration.ts
index 407790fbf..2932e47c4 100644
--- a/packages/integrations/src/download-client/qbittorrent/qbittorrent-integration.ts
+++ b/packages/integrations/src/download-client/qbittorrent/qbittorrent-integration.ts
@@ -70,9 +70,8 @@ export class QBitTorrentIntegration extends DownloadClientIntegration {
}
private getClient() {
- const baseUrl = new URL(this.integration.url).href;
return new QBittorrent({
- baseUrl,
+ baseUrl: this.url("/").toString(),
username: this.getSecretValue("username"),
password: this.getSecretValue("password"),
});
diff --git a/packages/integrations/src/download-client/sabnzbd/sabnzbd-integration.ts b/packages/integrations/src/download-client/sabnzbd/sabnzbd-integration.ts
index 22b65d1b3..644420644 100644
--- a/packages/integrations/src/download-client/sabnzbd/sabnzbd-integration.ts
+++ b/packages/integrations/src/download-client/sabnzbd/sabnzbd-integration.ts
@@ -12,7 +12,7 @@ dayjs.extend(duration);
export class SabnzbdIntegration extends DownloadClientIntegration {
public async testConnectionAsync(): Promise {
//This is the one call that uses the least amount of data while requiring the api key
- await this.sabNzbApiCallAsync("translate", new URLSearchParams({ value: "ping" }));
+ await this.sabNzbApiCallAsync("translate", { value: "ping" });
}
public async getClientJobsAndStatusAsync(): Promise {
@@ -75,7 +75,7 @@ export class SabnzbdIntegration extends DownloadClientIntegration {
}
public async pauseItemAsync({ id }: DownloadClientItem) {
- await this.sabNzbApiCallAsync("queue", new URLSearchParams({ name: "pause", value: id }));
+ await this.sabNzbApiCallAsync("queue", { name: "pause", value: id });
}
public async resumeQueueAsync() {
@@ -83,32 +83,29 @@ export class SabnzbdIntegration extends DownloadClientIntegration {
}
public async resumeItemAsync({ id }: DownloadClientItem): Promise {
- await this.sabNzbApiCallAsync("queue", new URLSearchParams({ name: "resume", value: id }));
+ await this.sabNzbApiCallAsync("queue", { name: "resume", value: id });
}
//Delete files prevented on completed files. https://github.com/sabnzbd/sabnzbd/issues/2754
//Works on all other in downloading and post-processing.
//Will stop working as soon as the finished files is moved to completed folder.
public async deleteItemAsync({ id, progress }: DownloadClientItem, fromDisk: boolean): Promise {
- await this.sabNzbApiCallAsync(
- progress !== 1 ? "queue" : "history",
- new URLSearchParams({
- name: "delete",
- archive: fromDisk ? "0" : "1",
- value: id,
- del_files: fromDisk ? "1" : "0",
- }),
- );
+ await this.sabNzbApiCallAsync(progress !== 1 ? "queue" : "history", {
+ name: "delete",
+ archive: fromDisk ? "0" : "1",
+ value: id,
+ del_files: fromDisk ? "1" : "0",
+ });
}
- private async sabNzbApiCallAsync(mode: string, searchParams?: URLSearchParams): Promise {
- const url = new URL("api", this.integration.url);
- url.searchParams.append("output", "json");
- url.searchParams.append("mode", mode);
- searchParams?.forEach((value, key) => {
- url.searchParams.append(key, value);
+ private async sabNzbApiCallAsync(mode: string, searchParams?: Record): Promise {
+ const url = this.url("/api", {
+ ...searchParams,
+ output: "json",
+ mode,
+ apikey: this.getSecretValue("apiKey"),
});
- url.searchParams.append("apikey", this.getSecretValue("apiKey"));
+
return await fetch(url)
.then((response) => {
if (!response.ok) {
diff --git a/packages/integrations/src/download-client/transmission/transmission-integration.ts b/packages/integrations/src/download-client/transmission/transmission-integration.ts
index 2258546b8..919da6815 100644
--- a/packages/integrations/src/download-client/transmission/transmission-integration.ts
+++ b/packages/integrations/src/download-client/transmission/transmission-integration.ts
@@ -71,9 +71,8 @@ export class TransmissionIntegration extends DownloadClientIntegration {
}
private getClient() {
- const baseUrl = new URL(this.integration.url).href;
return new Transmission({
- baseUrl,
+ baseUrl: this.url("/").toString(),
username: this.getSecretValue("username"),
password: this.getSecretValue("password"),
});
diff --git a/packages/integrations/src/homeassistant/homeassistant-integration.ts b/packages/integrations/src/homeassistant/homeassistant-integration.ts
index 47f10ce3e..7d69d40b1 100644
--- a/packages/integrations/src/homeassistant/homeassistant-integration.ts
+++ b/packages/integrations/src/homeassistant/homeassistant-integration.ts
@@ -1,4 +1,3 @@
-import { appendPath } from "@homarr/common";
import { logger } from "@homarr/log";
import { Integration } from "../base/integration";
@@ -17,7 +16,7 @@ export class HomeAssistantIntegration extends Integration {
}
return entityStateSchema.safeParseAsync(body);
} catch (err) {
- logger.error(`Failed to fetch from ${this.integration.url}: ${err as string}`);
+ logger.error(`Failed to fetch from ${this.url("/")}: ${err as string}`);
return {
success: false as const,
error: err,
@@ -33,7 +32,7 @@ export class HomeAssistantIntegration extends Integration {
return response.ok;
} catch (err) {
- logger.error(`Failed to fetch from '${this.integration.url}': ${err as string}`);
+ logger.error(`Failed to fetch from '${this.url("/")}': ${err as string}`);
return false;
}
}
@@ -52,7 +51,7 @@ export class HomeAssistantIntegration extends Integration {
return response.ok;
} catch (err) {
- logger.error(`Failed to fetch from '${this.integration.url}': ${err as string}`);
+ logger.error(`Failed to fetch from '${this.url("/")}': ${err as string}`);
return false;
}
}
@@ -72,7 +71,7 @@ export class HomeAssistantIntegration extends Integration {
* @returns the response from the API
*/
private async getAsync(path: `/api/${string}`) {
- return await fetch(appendPath(this.integration.url, path), {
+ return await fetch(this.url(path), {
headers: this.getAuthHeaders(),
});
}
@@ -85,7 +84,7 @@ export class HomeAssistantIntegration extends Integration {
* @returns the response from the API
*/
private async postAsync(path: `/api/${string}`, body: Record) {
- return await fetch(appendPath(this.integration.url, path), {
+ return await fetch(this.url(path), {
headers: this.getAuthHeaders(),
body: JSON.stringify(body),
method: "POST",
diff --git a/packages/integrations/src/jellyfin/jellyfin-integration.ts b/packages/integrations/src/jellyfin/jellyfin-integration.ts
index 2cdd79f78..4a603d6c2 100644
--- a/packages/integrations/src/jellyfin/jellyfin-integration.ts
+++ b/packages/integrations/src/jellyfin/jellyfin-integration.ts
@@ -29,9 +29,7 @@ export class JellyfinIntegration extends Integration {
const sessions = await sessionApi.getSessions();
if (sessions.status !== 200) {
- throw new Error(
- `Jellyfin server ${this.integration.url} returned a non successful status code: ${sessions.status}`,
- );
+ throw new Error(`Jellyfin server ${this.url("/")} returned a non successful status code: ${sessions.status}`);
}
return sessions.data.map((sessionInfo): StreamSession => {
@@ -52,7 +50,7 @@ export class JellyfinIntegration extends Integration {
sessionId: `${sessionInfo.Id}`,
sessionName: `${sessionInfo.Client} (${sessionInfo.DeviceName})`,
user: {
- profilePictureUrl: `${this.integration.url}/Users/${sessionInfo.UserId}/Images/Primary`,
+ profilePictureUrl: this.url(`/Users/${sessionInfo.UserId}/Images/Primary`).toString(),
userId: sessionInfo.UserId ?? "",
username: sessionInfo.UserName ?? "",
},
@@ -63,6 +61,6 @@ export class JellyfinIntegration extends Integration {
private getApi() {
const apiKey = this.getSecretValue("apiKey");
- return this.jellyfin.createApi(this.integration.url, apiKey);
+ return this.jellyfin.createApi(this.url("/").toString(), apiKey);
}
}
diff --git a/packages/integrations/src/media-organizer/lidarr/lidarr-integration.ts b/packages/integrations/src/media-organizer/lidarr/lidarr-integration.ts
index dcf6f5913..b89d552ea 100644
--- a/packages/integrations/src/media-organizer/lidarr/lidarr-integration.ts
+++ b/packages/integrations/src/media-organizer/lidarr/lidarr-integration.ts
@@ -8,7 +8,7 @@ export class LidarrIntegration extends MediaOrganizerIntegration {
public async testConnectionAsync(): Promise {
await super.handleTestConnectionResponseAsync({
queryFunctionAsync: async () => {
- return await fetch(`${this.integration.url}/api`, {
+ return await fetch(this.url("/api"), {
headers: { "X-Api-Key": super.getSecretValue("apiKey") },
});
},
@@ -22,11 +22,12 @@ export class LidarrIntegration extends MediaOrganizerIntegration {
* @param includeUnmonitored When true results will include unmonitored items of the Tadarr library.
*/
async getCalendarEventsAsync(start: Date, end: Date, includeUnmonitored = true): Promise {
- const url = new URL(this.integration.url);
- url.pathname = "/api/v1/calendar";
- url.searchParams.append("start", start.toISOString());
- url.searchParams.append("end", end.toISOString());
- url.searchParams.append("unmonitored", includeUnmonitored ? "true" : "false");
+ const url = this.url("/api/v1/calendar", {
+ start,
+ end,
+ unmonitored: includeUnmonitored,
+ });
+
const response = await fetch(url, {
headers: {
"X-Api-Key": super.getSecretValue("apiKey"),
diff --git a/packages/integrations/src/media-organizer/radarr/radarr-integration.ts b/packages/integrations/src/media-organizer/radarr/radarr-integration.ts
index e1387408c..e11400b1e 100644
--- a/packages/integrations/src/media-organizer/radarr/radarr-integration.ts
+++ b/packages/integrations/src/media-organizer/radarr/radarr-integration.ts
@@ -14,11 +14,12 @@ export class RadarrIntegration extends MediaOrganizerIntegration {
* @param includeUnmonitored When true results will include unmonitored items of the Tadarr library.
*/
async getCalendarEventsAsync(start: Date, end: Date, includeUnmonitored = true): Promise {
- const url = new URL(this.integration.url);
- url.pathname = "/api/v3/calendar";
- url.searchParams.append("start", start.toISOString());
- url.searchParams.append("end", end.toISOString());
- url.searchParams.append("unmonitored", includeUnmonitored ? "true" : "false");
+ const url = this.url("/api/v3/calendar", {
+ start,
+ end,
+ unmonitored: includeUnmonitored,
+ });
+
const response = await fetch(url, {
headers: {
"X-Api-Key": super.getSecretValue("apiKey"),
@@ -48,7 +49,7 @@ export class RadarrIntegration extends MediaOrganizerIntegration {
private getLinksForRadarrCalendarEvent = (event: z.infer) => {
const links: CalendarEvent["links"] = [
{
- href: `${this.integration.url}/movie/${event.titleSlug}`,
+ href: this.url(`/movie/${event.titleSlug}`).toString(),
name: "Radarr",
logo: "/images/apps/radarr.svg",
color: undefined,
@@ -93,7 +94,7 @@ export class RadarrIntegration extends MediaOrganizerIntegration {
public async testConnectionAsync(): Promise {
await super.handleTestConnectionResponseAsync({
queryFunctionAsync: async () => {
- return await fetch(`${this.integration.url}/api`, {
+ return await fetch(this.url("/api"), {
headers: { "X-Api-Key": super.getSecretValue("apiKey") },
});
},
diff --git a/packages/integrations/src/media-organizer/readarr/readarr-integration.ts b/packages/integrations/src/media-organizer/readarr/readarr-integration.ts
index 4283c39fa..13d9eaccc 100644
--- a/packages/integrations/src/media-organizer/readarr/readarr-integration.ts
+++ b/packages/integrations/src/media-organizer/readarr/readarr-integration.ts
@@ -8,7 +8,7 @@ export class ReadarrIntegration extends MediaOrganizerIntegration {
public async testConnectionAsync(): Promise {
await super.handleTestConnectionResponseAsync({
queryFunctionAsync: async () => {
- return await fetch(`${this.integration.url}/api`, {
+ return await fetch(this.url("/api"), {
headers: { "X-Api-Key": super.getSecretValue("apiKey") },
});
},
@@ -27,12 +27,13 @@ export class ReadarrIntegration extends MediaOrganizerIntegration {
includeUnmonitored = true,
includeAuthor = true,
): Promise {
- const url = new URL(this.integration.url);
- url.pathname = "/api/v1/calendar";
- url.searchParams.append("start", start.toISOString());
- url.searchParams.append("end", end.toISOString());
- url.searchParams.append("unmonitored", includeUnmonitored.toString());
- url.searchParams.append("includeAuthor", includeAuthor.toString());
+ const url = this.url("/api/v1/calendar", {
+ start,
+ end,
+ unmonitored: includeUnmonitored,
+ includeAuthor,
+ });
+
const response = await fetch(url, {
headers: {
"X-Api-Key": super.getSecretValue("apiKey"),
@@ -58,7 +59,7 @@ export class ReadarrIntegration extends MediaOrganizerIntegration {
private getLinksForReadarrCalendarEvent = (event: z.infer) => {
return [
{
- href: `${this.integration.url}/author/${event.author.foreignAuthorId}`,
+ href: this.url(`/author/${event.author.foreignAuthorId}`).toString(),
color: "#f5c518",
isDark: false,
logo: "/images/apps/readarr.svg",
@@ -85,7 +86,7 @@ export class ReadarrIntegration extends MediaOrganizerIntegration {
if (!bestImage) {
return undefined;
}
- return `${this.integration.url}${bestImage.url}`;
+ return this.url(bestImage.url as `/${string}`).toString();
};
}
diff --git a/packages/integrations/src/media-organizer/sonarr/sonarr-integration.ts b/packages/integrations/src/media-organizer/sonarr/sonarr-integration.ts
index aeb54fe4d..c13c65620 100644
--- a/packages/integrations/src/media-organizer/sonarr/sonarr-integration.ts
+++ b/packages/integrations/src/media-organizer/sonarr/sonarr-integration.ts
@@ -12,14 +12,15 @@ export class SonarrIntegration extends MediaOrganizerIntegration {
* @param includeUnmonitored When true results will include unmonitored items of the Sonarr library.
*/
async getCalendarEventsAsync(start: Date, end: Date, includeUnmonitored = true): Promise {
- const url = new URL(this.integration.url);
- url.pathname = "/api/v3/calendar";
- url.searchParams.append("start", start.toISOString());
- url.searchParams.append("end", end.toISOString());
- url.searchParams.append("includeSeries", "true");
- url.searchParams.append("includeEpisodeFile", "true");
- url.searchParams.append("includeEpisodeImages", "true");
- url.searchParams.append("unmonitored", includeUnmonitored ? "true" : "false");
+ const url = this.url("/api/v3/calendar", {
+ start,
+ end,
+ unmonitored: includeUnmonitored,
+ includeSeries: true,
+ includeEpisodeFile: true,
+ includeEpisodeImages: true,
+ });
+
const response = await fetch(url, {
headers: {
"X-Api-Key": super.getSecretValue("apiKey"),
@@ -47,7 +48,7 @@ export class SonarrIntegration extends MediaOrganizerIntegration {
private getLinksForSonarCalendarEvent = (event: z.infer) => {
const links: CalendarEvent["links"] = [
{
- href: `${this.integration.url}/series/${event.series.titleSlug}`,
+ href: this.url(`/series/${event.series.titleSlug}`).toString(),
name: "Sonarr",
logo: "/images/apps/sonarr.svg",
color: undefined,
@@ -92,7 +93,7 @@ export class SonarrIntegration extends MediaOrganizerIntegration {
public async testConnectionAsync(): Promise {
await super.handleTestConnectionResponseAsync({
queryFunctionAsync: async () => {
- return await fetch(`${this.integration.url}/api`, {
+ return await fetch(this.url("/api"), {
headers: { "X-Api-Key": super.getSecretValue("apiKey") },
});
},
diff --git a/packages/integrations/src/openmediavault/openmediavault-integration.ts b/packages/integrations/src/openmediavault/openmediavault-integration.ts
index fc73a2e31..2296ac124 100644
--- a/packages/integrations/src/openmediavault/openmediavault-integration.ts
+++ b/packages/integrations/src/openmediavault/openmediavault-integration.ts
@@ -117,7 +117,7 @@ export class OpenMediaVaultIntegration extends Integration {
params: Record,
headers: Record = {},
): Promise {
- return await fetch(`${this.integration.url}/rpc.php`, {
+ return await fetch(this.url("/rpc.php"), {
method: "POST",
headers: {
"Content-Type": "application/json",
diff --git a/packages/integrations/src/overseerr/overseerr-integration.ts b/packages/integrations/src/overseerr/overseerr-integration.ts
index db5ab2b56..18101e778 100644
--- a/packages/integrations/src/overseerr/overseerr-integration.ts
+++ b/packages/integrations/src/overseerr/overseerr-integration.ts
@@ -11,7 +11,7 @@ import { MediaAvailability, MediaRequestStatus } from "../interfaces/media-reque
*/
export class OverseerrIntegration extends Integration implements ISearchableIntegration {
public async searchAsync(query: string): Promise<{ image?: string; name: string; link: string; text?: string }[]> {
- const response = await fetch(`${this.integration.url}/api/v1/search?query=${query}`, {
+ const response = await fetch(this.url("/api/v1/search", { query }), {
headers: {
"X-Api-Key": this.getSecretValue("apiKey"),
},
@@ -24,13 +24,14 @@ export class OverseerrIntegration extends Integration implements ISearchableInte
return schemaData.results.map((result) => ({
name: "name" in result ? result.name : result.title,
- link: `${this.integration.url}/${result.mediaType}/${result.id}`,
- image: constructSearchResultImage(this.integration.url, result),
+ link: this.url(`/${result.mediaType}/${result.id}`).toString(),
+ image: constructSearchResultImage(result),
text: "overview" in result ? result.overview : undefined,
}));
}
+
public async testConnectionAsync(): Promise {
- const response = await fetch(`${this.integration.url}/api/v1/auth/me`, {
+ const response = await fetch(this.url("/api/v1/auth/me"), {
headers: {
"X-Api-Key": this.getSecretValue("apiKey"),
},
@@ -46,14 +47,14 @@ export class OverseerrIntegration extends Integration implements ISearchableInte
public async getRequestsAsync(): Promise {
//Ensure to get all pending request first
- const pendingRequests = await fetch(`${this.integration.url}/api/v1/request?take=-1&filter=pending`, {
+ const pendingRequests = await fetch(this.url("/api/v1/request", { take: -1, filter: "pending" }), {
headers: {
"X-Api-Key": this.getSecretValue("apiKey"),
},
});
//Change 20 to integration setting (set to -1 for all)
- const allRequests = await fetch(`${this.integration.url}/api/v1/request?take=20`, {
+ const allRequests = await fetch(this.url("/api/v1/request", { take: 20 }), {
headers: {
"X-Api-Key": this.getSecretValue("apiKey"),
},
@@ -83,7 +84,7 @@ export class OverseerrIntegration extends Integration implements ISearchableInte
availability: request.media.status,
backdropImageUrl: `https://image.tmdb.org/t/p/original/${information.backdropPath}`,
posterImagePath: `https://image.tmdb.org/t/p/w600_and_h900_bestv2/${information.posterPath}`,
- href: `${this.integration.url}/${request.type}/${request.media.tmdbId}`,
+ href: this.url(`/${request.type}/${request.media.tmdbId}`).toString(),
type: request.type,
createdAt: request.createdAt,
airDate: new Date(information.airDate),
@@ -91,8 +92,8 @@ export class OverseerrIntegration extends Integration implements ISearchableInte
? ({
...request.requestedBy,
displayName: request.requestedBy.displayName,
- link: `${this.integration.url}/users/${request.requestedBy.id}`,
- avatar: constructAvatarUrl(this.integration.url, request.requestedBy.avatar),
+ link: this.url(`/users/${request.requestedBy.id}`).toString(),
+ avatar: this.constructAvatarUrl(request.requestedBy.avatar).toString(),
} satisfies Omit)
: undefined,
};
@@ -101,7 +102,7 @@ export class OverseerrIntegration extends Integration implements ISearchableInte
}
public async getStatsAsync(): Promise {
- const response = await fetch(`${this.integration.url}/api/v1/request/count`, {
+ const response = await fetch(this.url("/api/v1/request/count"), {
headers: {
"X-Api-Key": this.getSecretValue("apiKey"),
},
@@ -110,7 +111,7 @@ export class OverseerrIntegration extends Integration implements ISearchableInte
}
public async getUsersAsync(): Promise {
- const response = await fetch(`${this.integration.url}/api/v1/user?take=-1`, {
+ const response = await fetch(this.url("/api/v1/user", { take: -1 }), {
headers: {
"X-Api-Key": this.getSecretValue("apiKey"),
},
@@ -119,15 +120,15 @@ export class OverseerrIntegration extends Integration implements ISearchableInte
return users.map((user): RequestUser => {
return {
...user,
- link: `${this.integration.url}/users/${user.id}`,
- avatar: constructAvatarUrl(this.integration.url, user.avatar),
+ link: this.url(`/users/${user.id}`).toString(),
+ avatar: this.constructAvatarUrl(user.avatar).toString(),
};
});
}
public async approveRequestAsync(requestId: number): Promise {
logger.info(`Approving media request id='${requestId}' integration='${this.integration.name}'`);
- await fetch(`${this.integration.url}/api/v1/request/${requestId}/approve`, {
+ await fetch(this.url(`/api/v1/request/${requestId}/approve`), {
method: "POST",
headers: {
"X-Api-Key": this.getSecretValue("apiKey"),
@@ -145,7 +146,7 @@ export class OverseerrIntegration extends Integration implements ISearchableInte
public async declineRequestAsync(requestId: number): Promise {
logger.info(`Declining media request id='${requestId}' integration='${this.integration.name}'`);
- await fetch(`${this.integration.url}/api/v1/request/${requestId}/decline`, {
+ await fetch(this.url(`/api/v1/request/${requestId}/decline`), {
method: "POST",
headers: {
"X-Api-Key": this.getSecretValue("apiKey"),
@@ -162,7 +163,7 @@ export class OverseerrIntegration extends Integration implements ISearchableInte
}
private async getItemInformationAsync(id: number, type: MediaRequest["type"]): Promise {
- const response = await fetch(`${this.integration.url}/api/v1/${type}/${id}`, {
+ const response = await fetch(this.url(`/api/v1/${type}/${id}`), {
headers: {
"X-Api-Key": this.getSecretValue("apiKey"),
},
@@ -186,17 +187,17 @@ export class OverseerrIntegration extends Integration implements ISearchableInte
airDate: movie.releaseDate,
} satisfies MediaInformation;
}
-}
-const constructAvatarUrl = (appUrl: string, avatar: string) => {
- const isAbsolute = avatar.startsWith("http://") || avatar.startsWith("https://");
+ private constructAvatarUrl(avatar: string) {
+ const isAbsolute = avatar.startsWith("http://") || avatar.startsWith("https://");
- if (isAbsolute) {
- return avatar;
+ if (isAbsolute) {
+ return avatar;
+ }
+
+ return this.url(`/${avatar}`);
}
-
- return `${appUrl}/${avatar}`;
-};
+}
interface MediaInformation {
name: string;
@@ -308,11 +309,8 @@ const getUsersSchema = z.object({
}),
});
-const constructSearchResultImage = (
- appUrl: string,
- result: Exclude["results"], undefined>[number],
-) => {
- const path = getResultImagePath(appUrl, result);
+const constructSearchResultImage = (result: Exclude["results"], undefined>[number]) => {
+ const path = getResultImagePath(result);
if (!path) {
return undefined;
}
@@ -320,10 +318,7 @@ const constructSearchResultImage = (
return `https://image.tmdb.org/t/p/w600_and_h900_bestv2${path}`;
};
-const getResultImagePath = (
- appUrl: string,
- result: Exclude["results"], undefined>[number],
-) => {
+const getResultImagePath = (result: Exclude["results"], undefined>[number]) => {
switch (result.mediaType) {
case "person":
return result.profilePath;
diff --git a/packages/integrations/src/pi-hole/pi-hole-integration.ts b/packages/integrations/src/pi-hole/pi-hole-integration.ts
index b1f25aa6b..5549ab91a 100644
--- a/packages/integrations/src/pi-hole/pi-hole-integration.ts
+++ b/packages/integrations/src/pi-hole/pi-hole-integration.ts
@@ -7,7 +7,7 @@ import { summaryResponseSchema } from "./pi-hole-types";
export class PiHoleIntegration extends Integration implements DnsHoleSummaryIntegration {
public async getSummaryAsync(): Promise {
const apiKey = super.getSecretValue("apiKey");
- const response = await fetch(`${this.integration.url}/admin/api.php?summaryRaw&auth=${apiKey}`);
+ const response = await fetch(this.url("/admin/api.php?summaryRaw", { auth: apiKey }));
if (!response.ok) {
throw new Error(
`Failed to fetch summary for ${this.integration.name} (${this.integration.id}): ${response.statusText}`,
@@ -36,7 +36,7 @@ export class PiHoleIntegration extends Integration implements DnsHoleSummaryInte
await super.handleTestConnectionResponseAsync({
queryFunctionAsync: async () => {
- return await fetch(`${this.integration.url}/admin/api.php?status&auth=${apiKey}`);
+ return await fetch(this.url("/admin/api.php?status", { auth: apiKey }));
},
handleResponseAsync: async (response) => {
try {
@@ -53,7 +53,7 @@ export class PiHoleIntegration extends Integration implements DnsHoleSummaryInte
public async enableAsync(): Promise {
const apiKey = super.getSecretValue("apiKey");
- const response = await fetch(`${this.integration.url}/admin/api.php?enable&auth=${apiKey}`);
+ const response = await fetch(this.url("/admin/api.php?enable", { auth: apiKey }));
if (!response.ok) {
throw new Error(
`Failed to enable PiHole for ${this.integration.name} (${this.integration.id}): ${response.statusText}`,
@@ -63,7 +63,7 @@ export class PiHoleIntegration extends Integration implements DnsHoleSummaryInte
public async disableAsync(duration?: number): Promise {
const apiKey = super.getSecretValue("apiKey");
- const url = `${this.integration.url}/admin/api.php?disable${duration ? `=${duration}` : ""}&auth=${apiKey}`;
+ const url = this.url(`/admin/api.php?disable${duration ? `=${duration}` : ""}`, { auth: apiKey });
const response = await fetch(url);
if (!response.ok) {
throw new Error(
diff --git a/packages/integrations/src/plex/plex-integration.ts b/packages/integrations/src/plex/plex-integration.ts
index cdbad1d41..d33e9457b 100644
--- a/packages/integrations/src/plex/plex-integration.ts
+++ b/packages/integrations/src/plex/plex-integration.ts
@@ -11,7 +11,7 @@ export class PlexIntegration extends Integration {
public async getCurrentSessionsAsync(): Promise {
const token = super.getSecretValue("apiKey");
- const response = await fetch(`${this.integration.url}/status/sessions`, {
+ const response = await fetch(this.url("/status/sessions"), {
headers: {
"X-Plex-Token": token,
},
@@ -66,7 +66,7 @@ export class PlexIntegration extends Integration {
await super.handleTestConnectionResponseAsync({
queryFunctionAsync: async () => {
- return await fetch(this.integration.url, {
+ return await fetch(this.url("/"), {
headers: {
"X-Plex-Token": token,
},
diff --git a/packages/integrations/src/prowlarr/prowlarr-integration.ts b/packages/integrations/src/prowlarr/prowlarr-integration.ts
index 8768073c4..ee76a8ed1 100644
--- a/packages/integrations/src/prowlarr/prowlarr-integration.ts
+++ b/packages/integrations/src/prowlarr/prowlarr-integration.ts
@@ -7,7 +7,7 @@ export class ProwlarrIntegration extends Integration {
public async getIndexersAsync(): Promise {
const apiKey = super.getSecretValue("apiKey");
- const indexerResponse = await fetch(`${this.integration.url}/api/v1/indexer`, {
+ const indexerResponse = await fetch(this.url("/api/v1/indexer"), {
headers: {
"X-Api-Key": apiKey,
},
@@ -18,7 +18,7 @@ export class ProwlarrIntegration extends Integration {
);
}
- const statusResponse = await fetch(`${this.integration.url}/api/v1/indexerstatus`, {
+ const statusResponse = await fetch(this.url("/api/v1/indexerstatus"), {
headers: {
"X-Api-Key": apiKey,
},
@@ -60,7 +60,7 @@ export class ProwlarrIntegration extends Integration {
public async testAllAsync(): Promise {
const apiKey = super.getSecretValue("apiKey");
- const response = await fetch(`${this.integration.url}/api/v1/indexer/testall`, {
+ const response = await fetch(this.url("/api/v1/indexer/testall"), {
headers: {
"X-Api-Key": apiKey,
},
@@ -78,7 +78,7 @@ export class ProwlarrIntegration extends Integration {
await super.handleTestConnectionResponseAsync({
queryFunctionAsync: async () => {
- return await fetch(`${this.integration.url}/api`, {
+ return await fetch(this.url("/api"), {
headers: {
"X-Api-Key": apiKey,
},
diff --git a/packages/log/package.json b/packages/log/package.json
index 75d3db155..5c46e4381 100644
--- a/packages/log/package.json
+++ b/packages/log/package.json
@@ -27,14 +27,14 @@
"prettier": "@homarr/prettier-config",
"dependencies": {
"ioredis": "5.4.1",
- "superjson": "2.2.1",
+ "superjson": "2.2.2",
"winston": "3.17.0"
},
"devDependencies": {
"@homarr/eslint-config": "workspace:^0.2.0",
"@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0",
- "eslint": "^9.15.0",
+ "eslint": "^9.16.0",
"typescript": "^5.7.2"
}
}
diff --git a/packages/modals-collection/package.json b/packages/modals-collection/package.json
index 346a3090e..eac792b94 100644
--- a/packages/modals-collection/package.json
+++ b/packages/modals-collection/package.json
@@ -1,7 +1,8 @@
{
"name": "@homarr/modals-collection",
- "private": true,
"version": "0.1.0",
+ "private": true,
+ "license": "MIT",
"type": "module",
"exports": {
".": "./index.ts"
@@ -13,13 +14,13 @@
]
}
},
- "license": "MIT",
"scripts": {
"clean": "rm -rf .turbo node_modules",
- "lint": "eslint",
"format": "prettier --check . --ignore-path ../../.gitignore",
+ "lint": "eslint",
"typecheck": "tsc --noEmit"
},
+ "prettier": "@homarr/prettier-config",
"dependencies": {
"@homarr/api": "workspace:^0.1.0",
"@homarr/common": "workspace:^0.1.0",
@@ -31,17 +32,16 @@
"@homarr/ui": "workspace:^0.1.0",
"@homarr/validation": "workspace:^0.1.0",
"@mantine/core": "^7.14.3",
- "@tabler/icons-react": "^3.23.0",
+ "@tabler/icons-react": "^3.24.0",
"dayjs": "^1.11.13",
- "next": "^14.2.18",
+ "next": "^14.2.20",
"react": "^18.3.1"
},
"devDependencies": {
"@homarr/eslint-config": "workspace:^0.2.0",
"@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0",
- "eslint": "^9.15.0",
+ "eslint": "^9.16.0",
"typescript": "^5.7.2"
- },
- "prettier": "@homarr/prettier-config"
+ }
}
diff --git a/packages/modals/package.json b/packages/modals/package.json
index fb3c72554..25d18870d 100644
--- a/packages/modals/package.json
+++ b/packages/modals/package.json
@@ -32,7 +32,7 @@
"@homarr/eslint-config": "workspace:^0.2.0",
"@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0",
- "eslint": "^9.15.0",
+ "eslint": "^9.16.0",
"typescript": "^5.7.2"
}
}
diff --git a/packages/notifications/package.json b/packages/notifications/package.json
index 24133008b..51fb23c7f 100644
--- a/packages/notifications/package.json
+++ b/packages/notifications/package.json
@@ -25,13 +25,13 @@
"dependencies": {
"@homarr/ui": "workspace:^0.1.0",
"@mantine/notifications": "^7.14.3",
- "@tabler/icons-react": "^3.23.0"
+ "@tabler/icons-react": "^3.24.0"
},
"devDependencies": {
"@homarr/eslint-config": "workspace:^0.2.0",
"@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0",
- "eslint": "^9.15.0",
+ "eslint": "^9.16.0",
"typescript": "^5.7.2"
}
}
diff --git a/packages/old-import/package.json b/packages/old-import/package.json
index f59a6f761..39f69c524 100644
--- a/packages/old-import/package.json
+++ b/packages/old-import/package.json
@@ -1,7 +1,8 @@
{
"name": "@homarr/old-import",
- "private": true,
"version": "0.1.0",
+ "private": true,
+ "license": "MIT",
"type": "module",
"exports": {
".": "./index.ts"
@@ -13,13 +14,13 @@
]
}
},
- "license": "MIT",
"scripts": {
"clean": "rm -rf .turbo node_modules",
- "lint": "eslint",
"format": "prettier --check . --ignore-path ../../.gitignore",
+ "lint": "eslint",
"typecheck": "tsc --noEmit"
},
+ "prettier": "@homarr/prettier-config",
"dependencies": {
"@homarr/common": "workspace:^0.1.0",
"@homarr/db": "workspace:^0.1.0",
@@ -27,14 +28,13 @@
"@homarr/log": "workspace:^0.1.0",
"@homarr/old-schema": "workspace:^0.1.0",
"@homarr/validation": "workspace:^0.1.0",
- "superjson": "2.2.1"
+ "superjson": "2.2.2"
},
"devDependencies": {
"@homarr/eslint-config": "workspace:^0.2.0",
"@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0",
- "eslint": "^9.15.0",
+ "eslint": "^9.16.0",
"typescript": "^5.7.2"
- },
- "prettier": "@homarr/prettier-config"
+ }
}
diff --git a/packages/old-schema/package.json b/packages/old-schema/package.json
index d499627bd..cdca0e169 100644
--- a/packages/old-schema/package.json
+++ b/packages/old-schema/package.json
@@ -1,7 +1,8 @@
{
"name": "@homarr/old-schema",
- "private": true,
"version": "0.1.0",
+ "private": true,
+ "license": "MIT",
"type": "module",
"exports": {
".": "./index.ts"
@@ -13,13 +14,13 @@
]
}
},
- "license": "MIT",
"scripts": {
"clean": "rm -rf .turbo node_modules",
- "lint": "eslint",
"format": "prettier --check . --ignore-path ../../.gitignore",
+ "lint": "eslint",
"typecheck": "tsc --noEmit"
},
+ "prettier": "@homarr/prettier-config",
"dependencies": {
"zod": "^3.23.8"
},
@@ -27,8 +28,7 @@
"@homarr/eslint-config": "workspace:^0.2.0",
"@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0",
- "eslint": "^9.15.0",
+ "eslint": "^9.16.0",
"typescript": "^5.7.2"
- },
- "prettier": "@homarr/prettier-config"
+ }
}
diff --git a/packages/ping/package.json b/packages/ping/package.json
index 3ba8326b5..42d81f663 100644
--- a/packages/ping/package.json
+++ b/packages/ping/package.json
@@ -29,7 +29,7 @@
"@homarr/eslint-config": "workspace:^0.2.0",
"@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0",
- "eslint": "^9.15.0",
+ "eslint": "^9.16.0",
"typescript": "^5.7.2"
}
}
diff --git a/packages/redis/package.json b/packages/redis/package.json
index c371068cb..59b7797c6 100644
--- a/packages/redis/package.json
+++ b/packages/redis/package.json
@@ -27,13 +27,13 @@
"@homarr/definitions": "workspace:^",
"@homarr/log": "workspace:^",
"ioredis": "5.4.1",
- "superjson": "2.2.1"
+ "superjson": "2.2.2"
},
"devDependencies": {
"@homarr/eslint-config": "workspace:^0.2.0",
"@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0",
- "eslint": "^9.15.0",
+ "eslint": "^9.16.0",
"typescript": "^5.7.2"
}
}
diff --git a/packages/request-handler/package.json b/packages/request-handler/package.json
index b905f4a74..0d9a95680 100644
--- a/packages/request-handler/package.json
+++ b/packages/request-handler/package.json
@@ -1,7 +1,8 @@
{
"name": "@homarr/request-handler",
- "private": true,
"version": "0.1.0",
+ "private": true,
+ "license": "MIT",
"type": "module",
"exports": {
"./*": "./src/*.ts"
@@ -13,13 +14,13 @@
]
}
},
- "license": "MIT",
"scripts": {
"clean": "rm -rf .turbo node_modules",
- "lint": "eslint",
"format": "prettier --check . --ignore-path ../../.gitignore",
+ "lint": "eslint",
"typecheck": "tsc --noEmit"
},
+ "prettier": "@homarr/prettier-config",
"dependencies": {
"@homarr/common": "workspace:^0.1.0",
"@homarr/db": "workspace:^0.1.0",
@@ -28,14 +29,14 @@
"@homarr/log": "workspace:^0.1.0",
"@homarr/redis": "workspace:^0.1.0",
"dayjs": "^1.11.13",
- "superjson": "2.2.1"
+ "pretty-print-error": "^1.1.2",
+ "superjson": "2.2.2"
},
"devDependencies": {
"@homarr/eslint-config": "workspace:^0.2.0",
"@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0",
- "eslint": "^9.15.0",
+ "eslint": "^9.16.0",
"typescript": "^5.7.2"
- },
- "prettier": "@homarr/prettier-config"
+ }
}
diff --git a/packages/request-handler/src/lib/cached-request-integration-job-handler.ts b/packages/request-handler/src/lib/cached-request-integration-job-handler.ts
index 85bbd78f8..c9e2efe9a 100644
--- a/packages/request-handler/src/lib/cached-request-integration-job-handler.ts
+++ b/packages/request-handler/src/lib/cached-request-integration-job-handler.ts
@@ -1,3 +1,4 @@
+import { formatError } from "pretty-print-error";
import SuperJSON from "superjson";
import { hashObjectBase64, Stopwatch } from "@homarr/common";
@@ -95,7 +96,7 @@ export const createRequestIntegrationJobHandler = <
);
} catch (error) {
logger.error(
- `Failed to run integration job integration=${integrationId} inputHash='${inputHash}' error=${error as string}`,
+ `Failed to run integration job integration=${integrationId} inputHash='${inputHash}' error=${formatError(error)}`,
);
}
}
diff --git a/packages/server-settings/package.json b/packages/server-settings/package.json
index 11e80589b..ecb5f334e 100644
--- a/packages/server-settings/package.json
+++ b/packages/server-settings/package.json
@@ -29,7 +29,7 @@
"@homarr/eslint-config": "workspace:^0.2.0",
"@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0",
- "eslint": "^9.15.0",
+ "eslint": "^9.16.0",
"typescript": "^5.7.2"
}
}
diff --git a/packages/spotlight/package.json b/packages/spotlight/package.json
index f8223cc8f..28b919d05 100644
--- a/packages/spotlight/package.json
+++ b/packages/spotlight/package.json
@@ -21,6 +21,7 @@
"lint": "eslint",
"typecheck": "tsc --noEmit"
},
+ "prettier": "@homarr/prettier-config",
"dependencies": {
"@homarr/api": "workspace:^0.1.0",
"@homarr/auth": "workspace:^0.1.0",
@@ -34,9 +35,9 @@
"@mantine/core": "^7.14.3",
"@mantine/hooks": "^7.14.3",
"@mantine/spotlight": "^7.14.3",
- "@tabler/icons-react": "^3.23.0",
+ "@tabler/icons-react": "^3.24.0",
"jotai": "^2.10.3",
- "next": "^14.2.18",
+ "next": "^14.2.20",
"react": "^18.3.1",
"use-deep-compare-effect": "^1.8.1"
},
@@ -44,8 +45,7 @@
"@homarr/eslint-config": "workspace:^0.2.0",
"@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0",
- "eslint": "^9.15.0",
+ "eslint": "^9.16.0",
"typescript": "^5.7.2"
- },
- "prettier": "@homarr/prettier-config"
+ }
}
diff --git a/packages/spotlight/src/components/actions/group-actions.tsx b/packages/spotlight/src/components/actions/group-actions.tsx
index 1c208c491..0477e0ce2 100644
--- a/packages/spotlight/src/components/actions/group-actions.tsx
+++ b/packages/spotlight/src/components/actions/group-actions.tsx
@@ -34,6 +34,10 @@ export const SpotlightGroupActions = >({
});
if (Array.isArray(options)) {
+ if (options.length === 0) {
+ return null;
+ }
+
const filteredOptions = options
.filter((option) => ("filter" in group ? group.filter(query, option) : false))
.sort((optionA, optionB) => {
diff --git a/packages/spotlight/src/components/spotlight.tsx b/packages/spotlight/src/components/spotlight.tsx
index 02251fe32..1ab051bb7 100644
--- a/packages/spotlight/src/components/spotlight.tsx
+++ b/packages/spotlight/src/components/spotlight.tsx
@@ -1,7 +1,7 @@
"use client";
import type { Dispatch, SetStateAction } from "react";
-import { useMemo, useRef, useState } from "react";
+import { useEffect, useMemo, useRef, useState } from "react";
import { ActionIcon, Center, Group, Kbd } from "@mantine/core";
import { Spotlight as MantineSpotlight } from "@mantine/spotlight";
import { IconSearch, IconX } from "@tabler/icons-react";
@@ -12,6 +12,7 @@ import { useI18n } from "@homarr/translation/client";
import type { inferSearchInteractionOptions } from "../lib/interaction";
import type { SearchMode } from "../lib/mode";
import { searchModes } from "../modes";
+import { useSpotlightContextResults } from "../modes/home/context";
import { selectAction, spotlightStore } from "../spotlight-store";
import { SpotlightChildrenActions } from "./actions/children-actions";
import { SpotlightActionGroups } from "./actions/groups/action-group";
@@ -19,24 +20,45 @@ import { SpotlightActionGroups } from "./actions/groups/action-group";
type SearchModeKey = keyof TranslationObject["search"]["mode"];
export const Spotlight = () => {
- const searchModeState = useState("help");
+ const items = useSpotlightContextResults();
+ // We fallback to help if no context results are available
+ const defaultMode = items.length >= 1 ? "home" : "help";
+ const searchModeState = useState(defaultMode);
const mode = searchModeState[0];
const activeMode = useMemo(() => searchModes.find((searchMode) => searchMode.modeKey === mode), [mode]);
+ /**
+ * The below logic is used to switch to home page if any context results are registered
+ * or to help page if context results are unregistered
+ */
+ const previousLengthRef = useRef(items.length);
+ useEffect(() => {
+ if (items.length >= 1 && previousLengthRef.current === 0) {
+ searchModeState[1]("home");
+ } else if (items.length === 0 && previousLengthRef.current >= 1) {
+ searchModeState[1]("help");
+ }
+
+ previousLengthRef.current = items.length;
+ }, [items.length, searchModeState]);
+
if (!activeMode) {
return null;
}
// We use the "key" below to prevent the 'Different amounts of hooks' error
- return ;
+ return (
+
+ );
};
interface SpotlightWithActiveModeProps {
modeState: [SearchModeKey, Dispatch>];
activeMode: SearchMode;
+ defaultMode: SearchModeKey;
}
-const SpotlightWithActiveMode = ({ modeState, activeMode }: SpotlightWithActiveModeProps) => {
+const SpotlightWithActiveMode = ({ modeState, activeMode, defaultMode }: SpotlightWithActiveModeProps) => {
const [query, setQuery] = useState("");
const [mode, setMode] = modeState;
const [childrenOptions, setChildrenOptions] = useState | null>(null);
@@ -50,12 +72,12 @@ const SpotlightWithActiveMode = ({ modeState, activeMode }: SpotlightWithActiveM
{
- setMode("help");
+ setMode(defaultMode);
setChildrenOptions(null);
}}
query={query}
onQueryChange={(query) => {
- if (mode !== "help" || query.length !== 1) {
+ if ((mode !== "help" && mode !== "home") || query.length !== 1) {
setQuery(query);
}
@@ -73,13 +95,13 @@ const SpotlightWithActiveMode = ({ modeState, activeMode }: SpotlightWithActiveM
- {activeMode.modeKey !== "help" ? {activeMode.character} : null}
+ {activeMode.modeKey !== defaultMode ? {activeMode.character} : null}
}
styles={{
@@ -88,10 +110,10 @@ const SpotlightWithActiveMode = ({ modeState, activeMode }: SpotlightWithActiveM
},
}}
rightSection={
- mode === "help" ? undefined : (
+ mode === defaultMode ? undefined : (
{
- setMode("help");
+ setMode(defaultMode);
setChildrenOptions(null);
inputRef.current?.focus();
}}
@@ -103,8 +125,8 @@ const SpotlightWithActiveMode = ({ modeState, activeMode }: SpotlightWithActiveM
}
value={query}
onKeyDown={(event) => {
- if (query.length === 0 && mode !== "help" && event.key === "Backspace") {
- setMode("help");
+ if (query.length === 0 && mode !== defaultMode && event.key === "Backspace") {
+ setMode(defaultMode);
setChildrenOptions(null);
}
}}
diff --git a/packages/spotlight/src/index.ts b/packages/spotlight/src/index.ts
index 2d4207075..18298a57b 100644
--- a/packages/spotlight/src/index.ts
+++ b/packages/spotlight/src/index.ts
@@ -4,5 +4,10 @@ import { spotlightActions } from "./spotlight-store";
export { Spotlight } from "./components/spotlight";
export { openSpotlight };
+export {
+ SpotlightProvider,
+ useRegisterSpotlightContextResults,
+ useRegisterSpotlightContextActions,
+} from "./modes/home/context";
const openSpotlight = spotlightActions.open;
diff --git a/packages/spotlight/src/lib/mode.ts b/packages/spotlight/src/lib/mode.ts
index 358432107..54b7e5ffc 100644
--- a/packages/spotlight/src/lib/mode.ts
+++ b/packages/spotlight/src/lib/mode.ts
@@ -4,7 +4,7 @@ import type { SearchGroup } from "./group";
export type SearchMode = {
modeKey: keyof TranslationObject["search"]["mode"];
- character: string;
+ character: string | undefined;
} & (
| {
groups: SearchGroup[];
diff --git a/packages/spotlight/src/modes/command/context-specific-group.tsx b/packages/spotlight/src/modes/command/context-specific-group.tsx
new file mode 100644
index 000000000..845c70b59
--- /dev/null
+++ b/packages/spotlight/src/modes/command/context-specific-group.tsx
@@ -0,0 +1,34 @@
+import { Group, Text } from "@mantine/core";
+
+import { createGroup } from "../../lib/group";
+import type { ContextSpecificItem } from "../home/context";
+import { useSpotlightContextActions } from "../home/context";
+
+export const contextSpecificActionsSearchGroups = createGroup({
+ title: (t) => t("search.mode.command.group.localCommand.title"),
+ keyPath: "id",
+ Component(option) {
+ const icon =
+ typeof option.icon !== "string" ? (
+
+ ) : (
+
+ );
+
+ return (
+
+ {icon}
+ {option.name}
+
+ );
+ },
+ useInteraction(option) {
+ return option.interaction();
+ },
+ filter(query, option) {
+ return option.name.toLowerCase().includes(query.toLowerCase());
+ },
+ useOptions() {
+ return useSpotlightContextActions();
+ },
+});
diff --git a/packages/spotlight/src/modes/command/global-group.tsx b/packages/spotlight/src/modes/command/global-group.tsx
new file mode 100644
index 000000000..4b13c6b0f
--- /dev/null
+++ b/packages/spotlight/src/modes/command/global-group.tsx
@@ -0,0 +1,166 @@
+import { Group, Text, useMantineColorScheme } from "@mantine/core";
+import type { TablerIcon } from "@tabler/icons-react";
+import {
+ IconBox,
+ IconCategoryPlus,
+ IconFileImport,
+ IconLanguage,
+ IconMailForward,
+ IconMoon,
+ IconPlug,
+ IconSun,
+ IconUserPlus,
+ IconUsersGroup,
+} from "@tabler/icons-react";
+
+import { useSession } from "@homarr/auth/client";
+import { useModalAction } from "@homarr/modals";
+import { AddBoardModal, AddGroupModal, ImportBoardModal, InviteCreateModal } from "@homarr/modals-collection";
+import { useScopedI18n } from "@homarr/translation/client";
+
+import { createGroup } from "../../lib/group";
+import type { inferSearchInteractionDefinition, SearchInteraction } from "../../lib/interaction";
+import { interaction } from "../../lib/interaction";
+import { languageChildrenOptions } from "./children/language";
+import { newIntegrationChildrenOptions } from "./children/new-integration";
+
+// This has to be type so it can be interpreted as Record.
+// eslint-disable-next-line @typescript-eslint/consistent-type-definitions
+type Command = {
+ commandKey: string;
+ icon: TablerIcon;
+ name: string;
+ useInteraction: (
+ _c: Command,
+ query: string,
+ ) => inferSearchInteractionDefinition;
+};
+
+export const globalCommandGroup = createGroup({
+ keyPath: "commandKey",
+ title: "Global commands",
+ useInteraction: (option, query) => option.useInteraction(option, query),
+ Component: ({ icon: Icon, name }) => (
+
+
+ {name}
+
+ ),
+ filter(query, option) {
+ return option.name.toLowerCase().includes(query.toLowerCase());
+ },
+ useOptions() {
+ const tOption = useScopedI18n("search.mode.command.group.globalCommand.option");
+ const { colorScheme } = useMantineColorScheme();
+ const { data: session } = useSession();
+
+ const commands: (Command & { hidden?: boolean })[] = [
+ {
+ commandKey: "colorScheme",
+ icon: colorScheme === "dark" ? IconSun : IconMoon,
+ name: tOption(`colorScheme.${colorScheme === "dark" ? "light" : "dark"}`),
+ useInteraction: () => {
+ const { toggleColorScheme } = useMantineColorScheme();
+
+ return {
+ type: "javaScript",
+ onSelect: toggleColorScheme,
+ };
+ },
+ },
+ {
+ commandKey: "language",
+ icon: IconLanguage,
+ name: tOption("language.label"),
+ useInteraction: interaction.children(languageChildrenOptions),
+ },
+ {
+ commandKey: "newBoard",
+ icon: IconCategoryPlus,
+ name: tOption("newBoard.label"),
+ useInteraction() {
+ const { openModal } = useModalAction(AddBoardModal);
+
+ return {
+ type: "javaScript",
+ onSelect() {
+ openModal(undefined);
+ },
+ };
+ },
+ hidden: !session?.user.permissions.includes("board-create"),
+ },
+ {
+ commandKey: "importBoard",
+ icon: IconFileImport,
+ name: tOption("importBoard.label"),
+ useInteraction() {
+ const { openModal } = useModalAction(ImportBoardModal);
+
+ return {
+ type: "javaScript",
+ onSelect() {
+ openModal(undefined);
+ },
+ };
+ },
+ hidden: !session?.user.permissions.includes("board-create"),
+ },
+ {
+ commandKey: "newApp",
+ icon: IconBox,
+ name: tOption("newApp.label"),
+ useInteraction: interaction.link(() => ({ href: "/manage/apps/new" })),
+ hidden: !session?.user.permissions.includes("app-create"),
+ },
+ {
+ commandKey: "newIntegration",
+ icon: IconPlug,
+ name: tOption("newIntegration.label"),
+ useInteraction: interaction.children(newIntegrationChildrenOptions),
+ hidden: !session?.user.permissions.includes("integration-create"),
+ },
+ {
+ commandKey: "newUser",
+ icon: IconUserPlus,
+ name: tOption("newUser.label"),
+ useInteraction: interaction.link(() => ({ href: "/manage/users/new" })),
+ hidden: !session?.user.permissions.includes("admin"),
+ },
+ {
+ commandKey: "newInvite",
+ icon: IconMailForward,
+ name: tOption("newInvite.label"),
+ useInteraction() {
+ const { openModal } = useModalAction(InviteCreateModal);
+
+ return {
+ type: "javaScript",
+ onSelect() {
+ openModal(undefined);
+ },
+ };
+ },
+ hidden: !session?.user.permissions.includes("admin"),
+ },
+ {
+ commandKey: "newGroup",
+ icon: IconUsersGroup,
+ name: tOption("newGroup.label"),
+ useInteraction() {
+ const { openModal } = useModalAction(AddGroupModal);
+
+ return {
+ type: "javaScript",
+ onSelect() {
+ openModal(undefined);
+ },
+ };
+ },
+ hidden: !session?.user.permissions.includes("admin"),
+ },
+ ];
+
+ return commands.filter((command) => !command.hidden);
+ },
+});
diff --git a/packages/spotlight/src/modes/command/index.tsx b/packages/spotlight/src/modes/command/index.tsx
index 4290c7c1e..eee549dd8 100644
--- a/packages/spotlight/src/modes/command/index.tsx
+++ b/packages/spotlight/src/modes/command/index.tsx
@@ -1,173 +1,9 @@
-import { Group, Text, useMantineColorScheme } from "@mantine/core";
-import {
- IconBox,
- IconCategoryPlus,
- IconFileImport,
- IconLanguage,
- IconMailForward,
- IconMoon,
- IconPlug,
- IconSun,
- IconUserPlus,
- IconUsersGroup,
-} from "@tabler/icons-react";
-
-import { useSession } from "@homarr/auth/client";
-import { useModalAction } from "@homarr/modals";
-import { AddBoardModal, AddGroupModal, ImportBoardModal, InviteCreateModal } from "@homarr/modals-collection";
-import { useScopedI18n } from "@homarr/translation/client";
-import type { TablerIcon } from "@homarr/ui";
-
-import { createGroup } from "../../lib/group";
-import type { inferSearchInteractionDefinition, SearchInteraction } from "../../lib/interaction";
-import { interaction } from "../../lib/interaction";
import type { SearchMode } from "../../lib/mode";
-import { languageChildrenOptions } from "./children/language";
-import { newIntegrationChildrenOptions } from "./children/new-integration";
-
-// This has to be type so it can be interpreted as Record.
-// eslint-disable-next-line @typescript-eslint/consistent-type-definitions
-type Command = {
- commandKey: string;
- icon: TablerIcon;
- name: string;
- useInteraction: (
- _c: Command,
- query: string,
- ) => inferSearchInteractionDefinition;
-};
+import { contextSpecificActionsSearchGroups } from "./context-specific-group";
+import { globalCommandGroup } from "./global-group";
export const commandMode = {
modeKey: "command",
character: ">",
- groups: [
- createGroup({
- keyPath: "commandKey",
- title: "Global commands",
- useInteraction: (option, query) => option.useInteraction(option, query),
- Component: ({ icon: Icon, name }) => (
-
-
- {name}
-
- ),
- filter(query, option) {
- return option.name.toLowerCase().includes(query.toLowerCase());
- },
- useOptions() {
- const tOption = useScopedI18n("search.mode.command.group.globalCommand.option");
- const { colorScheme } = useMantineColorScheme();
- const { data: session } = useSession();
-
- const commands: (Command & { hidden?: boolean })[] = [
- {
- commandKey: "colorScheme",
- icon: colorScheme === "dark" ? IconSun : IconMoon,
- name: tOption(`colorScheme.${colorScheme === "dark" ? "light" : "dark"}`),
- useInteraction: () => {
- const { toggleColorScheme } = useMantineColorScheme();
-
- return {
- type: "javaScript",
- onSelect: toggleColorScheme,
- };
- },
- },
- {
- commandKey: "language",
- icon: IconLanguage,
- name: tOption("language.label"),
- useInteraction: interaction.children(languageChildrenOptions),
- },
- {
- commandKey: "newBoard",
- icon: IconCategoryPlus,
- name: tOption("newBoard.label"),
- useInteraction() {
- const { openModal } = useModalAction(AddBoardModal);
-
- return {
- type: "javaScript",
- onSelect() {
- openModal(undefined);
- },
- };
- },
- hidden: !session?.user.permissions.includes("board-create"),
- },
- {
- commandKey: "importBoard",
- icon: IconFileImport,
- name: tOption("importBoard.label"),
- useInteraction() {
- const { openModal } = useModalAction(ImportBoardModal);
-
- return {
- type: "javaScript",
- onSelect() {
- openModal(undefined);
- },
- };
- },
- hidden: !session?.user.permissions.includes("board-create"),
- },
- {
- commandKey: "newApp",
- icon: IconBox,
- name: tOption("newApp.label"),
- useInteraction: interaction.link(() => ({ href: "/manage/apps/new" })),
- hidden: !session?.user.permissions.includes("app-create"),
- },
- {
- commandKey: "newIntegration",
- icon: IconPlug,
- name: tOption("newIntegration.label"),
- useInteraction: interaction.children(newIntegrationChildrenOptions),
- hidden: !session?.user.permissions.includes("integration-create"),
- },
- {
- commandKey: "newUser",
- icon: IconUserPlus,
- name: tOption("newUser.label"),
- useInteraction: interaction.link(() => ({ href: "/manage/users/new" })),
- hidden: !session?.user.permissions.includes("admin"),
- },
- {
- commandKey: "newInvite",
- icon: IconMailForward,
- name: tOption("newInvite.label"),
- useInteraction() {
- const { openModal } = useModalAction(InviteCreateModal);
-
- return {
- type: "javaScript",
- onSelect() {
- openModal(undefined);
- },
- };
- },
- hidden: !session?.user.permissions.includes("admin"),
- },
- {
- commandKey: "newGroup",
- icon: IconUsersGroup,
- name: tOption("newGroup.label"),
- useInteraction() {
- const { openModal } = useModalAction(AddGroupModal);
-
- return {
- type: "javaScript",
- onSelect() {
- openModal(undefined);
- },
- };
- },
- hidden: !session?.user.permissions.includes("admin"),
- },
- ];
-
- return commands.filter((command) => !command.hidden);
- },
- }),
- ],
+ groups: [contextSpecificActionsSearchGroups, globalCommandGroup],
} satisfies SearchMode;
diff --git a/packages/spotlight/src/modes/home/context-specific-group.tsx b/packages/spotlight/src/modes/home/context-specific-group.tsx
new file mode 100644
index 000000000..336e944dd
--- /dev/null
+++ b/packages/spotlight/src/modes/home/context-specific-group.tsx
@@ -0,0 +1,34 @@
+import { Group, Text } from "@mantine/core";
+
+import { createGroup } from "../../lib/group";
+import type { ContextSpecificItem } from "./context";
+import { useSpotlightContextResults } from "./context";
+
+export const contextSpecificSearchGroups = createGroup({
+ title: (t) => t("search.mode.home.group.local.title"),
+ keyPath: "id",
+ Component(option) {
+ const icon =
+ typeof option.icon !== "string" ? (
+
+ ) : (
+
+ );
+
+ return (
+
+ {icon}
+ {option.name}
+
+ );
+ },
+ useInteraction(option) {
+ return option.interaction();
+ },
+ filter(query, option) {
+ return option.name.toLowerCase().includes(query.toLowerCase());
+ },
+ useOptions() {
+ return useSpotlightContextResults();
+ },
+});
diff --git a/packages/spotlight/src/modes/home/context.tsx b/packages/spotlight/src/modes/home/context.tsx
new file mode 100644
index 000000000..3a681499b
--- /dev/null
+++ b/packages/spotlight/src/modes/home/context.tsx
@@ -0,0 +1,122 @@
+import type { DependencyList, PropsWithChildren } from "react";
+import { createContext, useCallback, useContext, useEffect, useMemo, useState } from "react";
+
+import type { TablerIcon } from "@homarr/ui";
+
+import type { inferSearchInteractionDefinition, SearchInteraction } from "../../lib/interaction";
+
+// eslint-disable-next-line @typescript-eslint/consistent-type-definitions
+export type ContextSpecificItem = {
+ id: string;
+ name: string;
+ icon: TablerIcon | string;
+ interaction: () => inferSearchInteractionDefinition;
+ disabled?: boolean;
+};
+
+interface SpotlightContextProps {
+ items: ContextSpecificItem[];
+ registerItems: (key: string, results: ContextSpecificItem[]) => void;
+ unregisterItems: (key: string) => void;
+}
+
+const createSpotlightContext = (displayName: string) => {
+ const SpotlightContext = createContext(null);
+ SpotlightContext.displayName = displayName;
+
+ const Provider = ({ children }: PropsWithChildren) => {
+ const [itemsMap, setItemsMap] = useState