chore(release): automatic release v0.1.0

This commit is contained in:
homarr-releases[bot]
2024-12-06 19:14:14 +00:00
committed by GitHub
112 changed files with 55607 additions and 3202 deletions

2
.gitattributes vendored
View File

@@ -1 +1 @@
* text eol=lf * text=auto eol=lf

View File

@@ -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 }}

28
.github/workflows/crowdin-upload.yml vendored Normal file
View File

@@ -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 }}

2
.nvmrc
View File

@@ -1 +1 @@
22.11.0 22.12.0

View File

@@ -1,4 +1,4 @@
FROM node:22.11.0-alpine AS base FROM node:22.12.0-alpine AS base
FROM base AS builder FROM base AS builder
RUN apk add --no-cache libc6-compat RUN apk add --no-cache libc6-compat
@@ -6,46 +6,15 @@ RUN apk update
# Set working directory # Set working directory
WORKDIR /app 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 add --no-cache libc6-compat curl bash
RUN apk update 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 RUN corepack enable pnpm && pnpm install --recursive --frozen-lockfile
# Install sharp for image optimization # Install sharp for image optimization
RUN corepack enable pnpm && pnpm install sharp -w 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 as it is not part of the build
COPY static-data ./static-data COPY static-data ./static-data
ARG SKIP_ENV_VALIDATION='true' ARG SKIP_ENV_VALIDATION='true'
@@ -69,7 +38,7 @@ RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs RUN adduser --system --uid 1001 nextjs
# Enable homarr cli # 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 echo $'#!/bin/bash\ncd /app/apps/cli && node ./cli.cjs "$@"' > /usr/bin/homarr
RUN chmod +x /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 mkdir -p /etc/nginx/templates /etc/nginx/ssl/certs && chown -R nextjs:nodejs /etc/nginx
USER nextjs USER nextjs
COPY --from=installer /app/apps/nextjs/next.config.mjs . COPY --from=builder /app/apps/nextjs/next.config.mjs .
COPY --from=installer /app/apps/nextjs/package.json . 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=builder --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=builder --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/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 # Automatically leverage output traces to reduce image size
# https://nextjs.org/docs/advanced-features/output-file-tracing # https://nextjs.org/docs/advanced-features/output-file-tracing
COPY --from=installer --chown=nextjs:nodejs /app/apps/nextjs/.next/standalone ./ COPY --from=builder --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=builder --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/public ./apps/nextjs/public
COPY --chown=nextjs:nodejs scripts/run.sh ./run.sh COPY --chown=nextjs:nodejs scripts/run.sh ./run.sh
COPY --chown=nextjs:nodejs scripts/generateEncryptionKey.js ./generateEncryptionKey.js COPY --chown=nextjs:nodejs scripts/generateEncryptionKey.js ./generateEncryptionKey.js
COPY --chown=nextjs:nodejs packages/redis/redis.conf /app/redis.conf COPY --chown=nextjs:nodejs packages/redis/redis.conf /app/redis.conf

View File

@@ -23,7 +23,7 @@
"@homarr/db": "workspace:^0.1.0", "@homarr/db": "workspace:^0.1.0",
"@homarr/definitions": "workspace:^0.1.0", "@homarr/definitions": "workspace:^0.1.0",
"@homarr/form": "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/integrations": "workspace:^0.1.0",
"@homarr/log": "workspace:^", "@homarr/log": "workspace:^",
"@homarr/modals": "workspace:^0.1.0", "@homarr/modals": "workspace:^0.1.0",
@@ -44,10 +44,10 @@
"@mantine/tiptap": "^7.14.3", "@mantine/tiptap": "^7.14.3",
"@million/lint": "1.0.13", "@million/lint": "1.0.13",
"@t3-oss/env-nextjs": "^0.11.1", "@t3-oss/env-nextjs": "^0.11.1",
"@tabler/icons-react": "^3.23.0", "@tabler/icons-react": "^3.24.0",
"@tanstack/react-query": "^5.62.0", "@tanstack/react-query": "^5.62.3",
"@tanstack/react-query-devtools": "^5.62.0", "@tanstack/react-query-devtools": "^5.62.3",
"@tanstack/react-query-next-experimental": "5.62.0", "@tanstack/react-query-next-experimental": "5.62.3",
"@trpc/client": "next", "@trpc/client": "next",
"@trpc/next": "next", "@trpc/next": "next",
"@trpc/react-query": "next", "@trpc/react-query": "next",
@@ -58,20 +58,20 @@
"chroma-js": "^3.1.2", "chroma-js": "^3.1.2",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"dayjs": "^1.11.13", "dayjs": "^1.11.13",
"dotenv": "^16.4.5", "dotenv": "^16.4.7",
"flag-icons": "^7.2.3", "flag-icons": "^7.2.3",
"glob": "^11.0.0", "glob": "^11.0.0",
"jotai": "^2.10.3", "jotai": "^2.10.3",
"mantine-react-table": "2.0.0-beta.7", "mantine-react-table": "2.0.0-beta.7",
"next": "^14.2.18", "next": "^14.2.20",
"postcss-preset-mantine": "^1.17.0", "postcss-preset-mantine": "^1.17.0",
"prismjs": "^1.29.0", "prismjs": "^1.29.0",
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
"react-error-boundary": "^4.1.2", "react-error-boundary": "^4.1.2",
"react-simple-code-editor": "^0.14.1", "react-simple-code-editor": "^0.14.1",
"sass": "^1.81.0", "sass": "^1.82.0",
"superjson": "2.2.1", "superjson": "2.2.2",
"swagger-ui-react": "^5.18.2", "swagger-ui-react": "^5.18.2",
"use-deep-compare-effect": "^1.8.1" "use-deep-compare-effect": "^1.8.1"
}, },
@@ -82,13 +82,13 @@
"@types/chroma-js": "2.4.4", "@types/chroma-js": "2.4.4",
"@types/node": "^22.10.1", "@types/node": "^22.10.1",
"@types/prismjs": "^1.26.5", "@types/prismjs": "^1.26.5",
"@types/react": "^18.3.12", "@types/react": "^18.3.13",
"@types/react-dom": "^18.3.1", "@types/react-dom": "^18.3.1",
"@types/swagger-ui-react": "^4.18.3", "@types/swagger-ui-react": "^4.18.3",
"concurrently": "^9.1.0", "concurrently": "^9.1.0",
"eslint": "^9.15.0", "eslint": "^9.16.0",
"node-loader": "^2.1.0", "node-loader": "^2.1.0",
"prettier": "^3.4.1", "prettier": "^3.4.2",
"typescript": "^5.7.2" "typescript": "^5.7.2"
} }
} }

View File

@@ -13,6 +13,7 @@ import { env } from "@homarr/auth/env.mjs";
import { auth } from "@homarr/auth/next"; import { auth } from "@homarr/auth/next";
import { ModalProvider } from "@homarr/modals"; import { ModalProvider } from "@homarr/modals";
import { Notifications } from "@homarr/notifications"; import { Notifications } from "@homarr/notifications";
import { SpotlightProvider } from "@homarr/spotlight";
import { isLocaleRTL, isLocaleSupported } from "@homarr/translation"; import { isLocaleRTL, isLocaleSupported } from "@homarr/translation";
import { getI18nMessages } from "@homarr/translation/server"; import { getI18nMessages } from "@homarr/translation/server";
@@ -82,6 +83,7 @@ export default async function Layout(props: { children: React.ReactNode; params:
(innerProps) => <NextIntlClientProvider {...innerProps} messages={i18nMessages} />, (innerProps) => <NextIntlClientProvider {...innerProps} messages={i18nMessages} />,
(innerProps) => <CustomMantineProvider {...innerProps} defaultColorScheme={colorScheme} />, (innerProps) => <CustomMantineProvider {...innerProps} defaultColorScheme={colorScheme} />,
(innerProps) => <ModalProvider {...innerProps} />, (innerProps) => <ModalProvider {...innerProps} />,
(innerProps) => <SpotlightProvider {...innerProps} />,
]); ]);
return ( return (

View File

@@ -36,9 +36,9 @@
"@homarr/validation": "workspace:^0.1.0", "@homarr/validation": "workspace:^0.1.0",
"@homarr/widgets": "workspace:^0.1.0", "@homarr/widgets": "workspace:^0.1.0",
"dayjs": "^1.11.13", "dayjs": "^1.11.13",
"dotenv": "^16.4.5", "dotenv": "^16.4.7",
"superjson": "2.2.1", "superjson": "2.2.2",
"undici": "7.0.0" "undici": "7.1.0"
}, },
"devDependencies": { "devDependencies": {
"@homarr/eslint-config": "workspace:^0.2.0", "@homarr/eslint-config": "workspace:^0.2.0",
@@ -46,8 +46,8 @@
"@homarr/tsconfig": "workspace:^0.1.0", "@homarr/tsconfig": "workspace:^0.1.0",
"@types/node": "^22.10.1", "@types/node": "^22.10.1",
"dotenv-cli": "^7.4.4", "dotenv-cli": "^7.4.4",
"eslint": "^9.15.0", "eslint": "^9.16.0",
"prettier": "^3.4.1", "prettier": "^3.4.2",
"tsx": "4.19.2", "tsx": "4.19.2",
"typescript": "^5.7.2" "typescript": "^5.7.2"
} }

View File

@@ -25,7 +25,7 @@
"@homarr/log": "workspace:^", "@homarr/log": "workspace:^",
"@homarr/redis": "workspace:^0.1.0", "@homarr/redis": "workspace:^0.1.0",
"@homarr/validation": "workspace:^0.1.0", "@homarr/validation": "workspace:^0.1.0",
"dotenv": "^16.4.5", "dotenv": "^16.4.7",
"tsx": "4.19.2", "tsx": "4.19.2",
"ws": "^8.18.0" "ws": "^8.18.0"
}, },
@@ -34,8 +34,8 @@
"@homarr/prettier-config": "workspace:^0.1.0", "@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0", "@homarr/tsconfig": "workspace:^0.1.0",
"@types/ws": "^8.5.13", "@types/ws": "^8.5.13",
"eslint": "^9.15.0", "eslint": "^9.16.0",
"prettier": "^3.4.1", "prettier": "^3.4.2",
"typescript": "^5.7.2" "typescript": "^5.7.2"
} }
} }

10
crowdin.yml Normal file
View File

@@ -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

View File

@@ -31,19 +31,24 @@
"@homarr/prettier-config": "workspace:^0.1.0", "@homarr/prettier-config": "workspace:^0.1.0",
"@turbo/gen": "^2.3.3", "@turbo/gen": "^2.3.3",
"@vitejs/plugin-react": "^4.3.4", "@vitejs/plugin-react": "^4.3.4",
"@vitest/coverage-v8": "^2.1.6", "@vitest/coverage-v8": "^2.1.8",
"@vitest/ui": "^2.1.6", "@vitest/ui": "^2.1.8",
"cross-env": "^7.0.3", "cross-env": "^7.0.3",
"jsdom": "^25.0.1", "jsdom": "^25.0.1",
"prettier": "^3.4.1", "prettier": "^3.4.2",
"testcontainers": "^10.15.0", "testcontainers": "^10.16.0",
"turbo": "^2.3.3", "turbo": "^2.3.3",
"typescript": "^5.7.2", "typescript": "^5.7.2",
"vite-tsconfig-paths": "^5.1.3", "vite-tsconfig-paths": "^5.1.4",
"vitest": "^2.1.6" "vitest": "^2.1.8"
}, },
"packageManager": "pnpm@9.14.4", "packageManager": "pnpm@9.15.0",
"engines": { "engines": {
"node": ">=22.11.0" "node": ">=22.12.0"
},
"pnpm": {
"patchedDependencies": {
"pretty-print-error": "patches/pretty-print-error.patch"
}
} }
} }

View File

@@ -26,13 +26,13 @@
"@homarr/log": "workspace:^0.1.0", "@homarr/log": "workspace:^0.1.0",
"@homarr/server-settings": "workspace:^0.1.0", "@homarr/server-settings": "workspace:^0.1.0",
"@umami/node": "^0.4.0", "@umami/node": "^0.4.0",
"superjson": "2.2.1" "superjson": "2.2.2"
}, },
"devDependencies": { "devDependencies": {
"@homarr/eslint-config": "workspace:^0.2.0", "@homarr/eslint-config": "workspace:^0.2.0",
"@homarr/prettier-config": "workspace:^0.1.0", "@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0", "@homarr/tsconfig": "workspace:^0.1.0",
"eslint": "^9.15.0", "eslint": "^9.16.0",
"typescript": "^5.7.2" "typescript": "^5.7.2"
} }
} }

View File

@@ -40,18 +40,18 @@
"@trpc/react-query": "next", "@trpc/react-query": "next",
"@trpc/server": "next", "@trpc/server": "next",
"dockerode": "^4.0.2", "dockerode": "^4.0.2",
"next": "^14.2.18", "next": "^14.2.20",
"react": "^18.3.1", "react": "^18.3.1",
"superjson": "2.2.1", "superjson": "2.2.2",
"trpc-to-openapi": "^2.0.2" "trpc-to-openapi": "^2.1.0"
}, },
"devDependencies": { "devDependencies": {
"@homarr/eslint-config": "workspace:^0.2.0", "@homarr/eslint-config": "workspace:^0.2.0",
"@homarr/prettier-config": "workspace:^0.1.0", "@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0", "@homarr/tsconfig": "workspace:^0.1.0",
"@types/dockerode": "^3.3.32", "@types/dockerode": "^3.3.32",
"eslint": "^9.15.0", "eslint": "^9.16.0",
"prettier": "^3.4.1", "prettier": "^3.4.2",
"typescript": "^5.7.2" "typescript": "^5.7.2"
} }
} }

View File

@@ -33,8 +33,8 @@
"@t3-oss/env-nextjs": "^0.11.1", "@t3-oss/env-nextjs": "^0.11.1",
"bcrypt": "^5.1.1", "bcrypt": "^5.1.1",
"cookies": "^0.9.1", "cookies": "^0.9.1",
"ldapts": "7.2.1", "ldapts": "7.2.2",
"next": "^14.2.18", "next": "^14.2.20",
"next-auth": "5.0.0-beta.25", "next-auth": "5.0.0-beta.25",
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1" "react-dom": "^18.3.1"
@@ -45,8 +45,8 @@
"@homarr/tsconfig": "workspace:^0.1.0", "@homarr/tsconfig": "workspace:^0.1.0",
"@types/bcrypt": "5.0.2", "@types/bcrypt": "5.0.2",
"@types/cookies": "0.9.0", "@types/cookies": "0.9.0",
"eslint": "^9.15.0", "eslint": "^9.16.0",
"prettier": "^3.4.1", "prettier": "^3.4.2",
"typescript": "^5.7.2" "typescript": "^5.7.2"
} }
} }

View File

@@ -11,7 +11,7 @@ export const authorizeWithBasicCredentialsAsync = async (
credentials: z.infer<typeof validation.user.signIn>, credentials: z.infer<typeof validation.user.signIn>,
) => { ) => {
const user = await db.query.users.findFirst({ 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) { if (!user?.password) {

View File

@@ -27,13 +27,13 @@
"@homarr/auth": "workspace:^0.1.0", "@homarr/auth": "workspace:^0.1.0",
"@homarr/common": "workspace:^0.1.0", "@homarr/common": "workspace:^0.1.0",
"@homarr/db": "workspace:^0.1.0", "@homarr/db": "workspace:^0.1.0",
"dotenv": "^16.4.5" "dotenv": "^16.4.7"
}, },
"devDependencies": { "devDependencies": {
"@homarr/eslint-config": "workspace:^0.2.0", "@homarr/eslint-config": "workspace:^0.2.0",
"@homarr/prettier-config": "workspace:^0.1.0", "@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0", "@homarr/tsconfig": "workspace:^0.1.0",
"eslint": "^9.15.0", "eslint": "^9.16.0",
"typescript": "^5.7.2" "typescript": "^5.7.2"
} }
} }

View File

@@ -27,15 +27,15 @@
"dependencies": { "dependencies": {
"@homarr/log": "workspace:^0.1.0", "@homarr/log": "workspace:^0.1.0",
"dayjs": "^1.11.13", "dayjs": "^1.11.13",
"next": "^14.2.18", "next": "^14.2.20",
"react": "^18.3.1", "react": "^18.3.1",
"tldts": "^6.1.64" "tldts": "^6.1.65"
}, },
"devDependencies": { "devDependencies": {
"@homarr/eslint-config": "workspace:^0.2.0", "@homarr/eslint-config": "workspace:^0.2.0",
"@homarr/prettier-config": "workspace:^0.1.0", "@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0", "@homarr/tsconfig": "workspace:^0.1.0",
"eslint": "^9.15.0", "eslint": "^9.16.0",
"typescript": "^5.7.2" "typescript": "^5.7.2"
} }
} }

View File

@@ -1,5 +1,6 @@
import { parseAppHrefWithVariables } from "./base"; import { parseAppHrefWithVariables } from "./base";
export const parseAppHrefWithVariablesClient = <TInput extends string | null>(url: TInput): TInput => { export const parseAppHrefWithVariablesClient = <TInput extends string | null>(url: TInput): TInput => {
if (typeof window === "undefined") return url;
return parseAppHrefWithVariables(url, window.location.href); return parseAppHrefWithVariables(url, window.location.href);
}; };

View File

@@ -1,12 +1,6 @@
import type { ReadonlyHeaders } from "next/dist/server/web/spec-extension/adapters/headers"; import type { ReadonlyHeaders } from "next/dist/server/web/spec-extension/adapters/headers";
export const appendPath = (url: URL | string, path: string) => { export const removeTrailingSlash = (path: string) => {
const newUrl = new URL(url);
newUrl.pathname = removeTrailingSlash(newUrl.pathname) + path;
return newUrl;
};
const removeTrailingSlash = (path: string) => {
return path.at(-1) === "/" ? path.substring(0, path.length - 1) : path; return path.at(-1) === "/" ? path.substring(0, path.length - 1) : path;
}; };

View File

@@ -30,7 +30,7 @@
"@homarr/eslint-config": "workspace:^0.2.0", "@homarr/eslint-config": "workspace:^0.2.0",
"@homarr/prettier-config": "workspace:^0.1.0", "@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0", "@homarr/tsconfig": "workspace:^0.1.0",
"eslint": "^9.15.0", "eslint": "^9.16.0",
"typescript": "^5.7.2" "typescript": "^5.7.2"
} }
} }

View File

@@ -29,7 +29,7 @@
"@homarr/eslint-config": "workspace:^0.2.0", "@homarr/eslint-config": "workspace:^0.2.0",
"@homarr/prettier-config": "workspace:^0.1.0", "@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0", "@homarr/tsconfig": "workspace:^0.1.0",
"eslint": "^9.15.0", "eslint": "^9.16.0",
"typescript": "^5.7.2" "typescript": "^5.7.2"
} }
} }

View File

@@ -32,7 +32,7 @@
"@homarr/prettier-config": "workspace:^0.1.0", "@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0", "@homarr/tsconfig": "workspace:^0.1.0",
"@types/node-cron": "^3.0.11", "@types/node-cron": "^3.0.11",
"eslint": "^9.15.0", "eslint": "^9.16.0",
"typescript": "^5.7.2" "typescript": "^5.7.2"
} }
} }

View File

@@ -44,7 +44,7 @@
"@homarr/eslint-config": "workspace:^0.2.0", "@homarr/eslint-config": "workspace:^0.2.0",
"@homarr/prettier-config": "workspace:^0.1.0", "@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0", "@homarr/tsconfig": "workspace:^0.1.0",
"eslint": "^9.15.0", "eslint": "^9.16.0",
"typescript": "^5.7.2" "typescript": "^5.7.2"
} }
} }

View File

@@ -21,12 +21,12 @@
"clean": "rm -rf .turbo node_modules", "clean": "rm -rf .turbo node_modules",
"format": "prettier --check . --ignore-path ../../.gitignore", "format": "prettier --check . --ignore-path ../../.gitignore",
"lint": "eslint", "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: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: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: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: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:mysql": "drizzle-kit push --config ./configs/mysql.config.ts",
"push:sqlite": "drizzle-kit push --config ./configs/sqlite.config.ts", "push:sqlite": "drizzle-kit push --config ./configs/sqlite.config.ts",
"seed": "pnpm with-env tsx ./migrations/run-seed.ts", "seed": "pnpm with-env tsx ./migrations/run-seed.ts",
@@ -42,11 +42,11 @@
"@homarr/log": "workspace:^0.1.0", "@homarr/log": "workspace:^0.1.0",
"@homarr/server-settings": "workspace:^0.1.0", "@homarr/server-settings": "workspace:^0.1.0",
"@paralleldrive/cuid2": "^2.2.2", "@paralleldrive/cuid2": "^2.2.2",
"@testcontainers/mysql": "^10.15.0", "@testcontainers/mysql": "^10.16.0",
"better-sqlite3": "^11.6.0", "better-sqlite3": "^11.6.0",
"dotenv": "^16.4.5", "dotenv": "^16.4.7",
"drizzle-kit": "^0.28.1", "drizzle-kit": "^0.29.1",
"drizzle-orm": "^0.36.4", "drizzle-orm": "^0.37.0",
"drizzle-zod": "^0.5.1", "drizzle-zod": "^0.5.1",
"mysql2": "3.11.5" "mysql2": "3.11.5"
}, },
@@ -56,8 +56,8 @@
"@homarr/tsconfig": "workspace:^0.1.0", "@homarr/tsconfig": "workspace:^0.1.0",
"@types/better-sqlite3": "7.6.12", "@types/better-sqlite3": "7.6.12",
"dotenv-cli": "^7.4.4", "dotenv-cli": "^7.4.4",
"eslint": "^9.15.0", "eslint": "^9.16.0",
"prettier": "^3.4.1", "prettier": "^3.4.2",
"tsx": "4.19.2", "tsx": "4.19.2",
"typescript": "^5.7.2" "typescript": "^5.7.2"
} }

View File

@@ -17,9 +17,9 @@
"scripts": { "scripts": {
"clean": "rm -rf .turbo node_modules", "clean": "rm -rf .turbo node_modules",
"format": "prettier --check . --ignore-path ../../.gitignore", "format": "prettier --check . --ignore-path ../../.gitignore",
"postinstall": "tsx ./src/docs/codegen.ts",
"lint": "eslint", "lint": "eslint",
"typecheck": "tsc --noEmit", "typecheck": "tsc --noEmit"
"postinstall": "tsx ./src/docs/codegen.ts"
}, },
"prettier": "@homarr/prettier-config", "prettier": "@homarr/prettier-config",
"dependencies": { "dependencies": {
@@ -29,7 +29,7 @@
"@homarr/eslint-config": "workspace:^0.2.0", "@homarr/eslint-config": "workspace:^0.2.0",
"@homarr/prettier-config": "workspace:^0.1.0", "@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0", "@homarr/tsconfig": "workspace:^0.1.0",
"eslint": "^9.15.0", "eslint": "^9.16.0",
"typescript": "^5.7.2" "typescript": "^5.7.2"
} }
} }

View File

@@ -30,7 +30,7 @@
"@homarr/eslint-config": "workspace:^0.2.0", "@homarr/eslint-config": "workspace:^0.2.0",
"@homarr/prettier-config": "workspace:^0.1.0", "@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0", "@homarr/tsconfig": "workspace:^0.1.0",
"eslint": "^9.15.0", "eslint": "^9.16.0",
"typescript": "^5.7.2" "typescript": "^5.7.2"
} }
} }

View File

@@ -30,7 +30,7 @@
"@homarr/eslint-config": "workspace:^0.2.0", "@homarr/eslint-config": "workspace:^0.2.0",
"@homarr/prettier-config": "workspace:^0.1.0", "@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0", "@homarr/tsconfig": "workspace:^0.1.0",
"eslint": "^9.15.0", "eslint": "^9.16.0",
"typescript": "^5.7.2" "typescript": "^5.7.2"
} }
} }

View File

@@ -31,6 +31,7 @@
"@homarr/db": "workspace:^0.1.0", "@homarr/db": "workspace:^0.1.0",
"@homarr/definitions": "workspace:^0.1.0", "@homarr/definitions": "workspace:^0.1.0",
"@homarr/log": "workspace:^0.1.0", "@homarr/log": "workspace:^0.1.0",
"@homarr/redis": "workspace:^0.1.0",
"@homarr/translation": "workspace:^0.1.0", "@homarr/translation": "workspace:^0.1.0",
"@homarr/validation": "workspace:^0.1.0", "@homarr/validation": "workspace:^0.1.0",
"@jellyfin/sdk": "^0.11.0", "@jellyfin/sdk": "^0.11.0",
@@ -41,7 +42,7 @@
"@homarr/prettier-config": "workspace:^0.1.0", "@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0", "@homarr/tsconfig": "workspace:^0.1.0",
"@types/xml2js": "^0.4.14", "@types/xml2js": "^0.4.14",
"eslint": "^9.15.0", "eslint": "^9.16.0",
"typescript": "^5.7.2" "typescript": "^5.7.2"
} }
} }

View File

@@ -6,7 +6,7 @@ import { filteringStatusSchema, statsResponseSchema, statusResponseSchema } from
export class AdGuardHomeIntegration extends Integration implements DnsHoleSummaryIntegration { export class AdGuardHomeIntegration extends Integration implements DnsHoleSummaryIntegration {
public async getSummaryAsync(): Promise<DnsHoleSummary> { public async getSummaryAsync(): Promise<DnsHoleSummary> {
const statsResponse = await fetch(`${this.integration.url}/control/stats`, { const statsResponse = await fetch(this.url("/control/stats"), {
headers: { headers: {
Authorization: `Basic ${this.getAuthorizationHeaderValue()}`, 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: { headers: {
Authorization: `Basic ${this.getAuthorizationHeaderValue()}`, 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: { headers: {
Authorization: `Basic ${this.getAuthorizationHeaderValue()}`, Authorization: `Basic ${this.getAuthorizationHeaderValue()}`,
}, },
@@ -86,7 +86,7 @@ export class AdGuardHomeIntegration extends Integration implements DnsHoleSummar
public async testConnectionAsync(): Promise<void> { public async testConnectionAsync(): Promise<void> {
await super.handleTestConnectionResponseAsync({ await super.handleTestConnectionResponseAsync({
queryFunctionAsync: async () => { queryFunctionAsync: async () => {
return await fetch(`${this.integration.url}/control/status`, { return await fetch(this.url("/control/status"), {
headers: { headers: {
Authorization: `Basic ${this.getAuthorizationHeaderValue()}`, Authorization: `Basic ${this.getAuthorizationHeaderValue()}`,
}, },
@@ -106,7 +106,7 @@ export class AdGuardHomeIntegration extends Integration implements DnsHoleSummar
} }
public async enableAsync(): Promise<void> { public async enableAsync(): Promise<void> {
const response = await fetch(`${this.integration.url}/control/protection`, { const response = await fetch(this.url("/control/protection"), {
method: "POST", method: "POST",
headers: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json",
@@ -124,7 +124,7 @@ export class AdGuardHomeIntegration extends Integration implements DnsHoleSummar
} }
public async disableAsync(duration = 0): Promise<void> { public async disableAsync(duration = 0): Promise<void> {
const response = await fetch(`${this.integration.url}/control/protection`, { const response = await fetch(this.url("/control/protection"), {
method: "POST", method: "POST",
headers: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json",

View File

@@ -1,4 +1,4 @@
import { extractErrorMessage } from "@homarr/common"; import { extractErrorMessage, removeTrailingSlash } from "@homarr/common";
import type { IntegrationSecretKind } from "@homarr/definitions"; import type { IntegrationSecretKind } from "@homarr/definitions";
import { logger } from "@homarr/log"; import { logger } from "@homarr/log";
import type { TranslationObject } from "@homarr/translation"; import type { TranslationObject } from "@homarr/translation";
@@ -29,6 +29,19 @@ export abstract class Integration {
return secret.value; return secret.value;
} }
protected url(path: `/${string}`, queryParams?: Record<string, string | Date | number | boolean>) {
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 * Test the connection to the integration
* @throws {IntegrationTestConnectionError} if the connection fails * @throws {IntegrationTestConnectionError} if the connection fails

View File

@@ -89,9 +89,8 @@ export class DelugeIntegration extends DownloadClientIntegration {
} }
private getClient() { private getClient() {
const baseUrl = new URL(this.integration.url).href;
return new Deluge({ return new Deluge({
baseUrl, baseUrl: this.url("/").toString(),
password: this.getSecretValue("password"), password: this.getSecretValue("password"),
}); });
} }

View File

@@ -92,9 +92,9 @@ export class NzbGetIntegration extends DownloadClientIntegration {
method: CallType, method: CallType,
...params: Parameters<NzbGetClient[CallType]> ...params: Parameters<NzbGetClient[CallType]>
): Promise<ReturnType<NzbGetClient[CallType]>> { ): Promise<ReturnType<NzbGetClient[CallType]>> {
const url = new URL(this.integration.url); const username = this.getSecretValue("username");
url.pathname += `${this.getSecretValue("username")}:${this.getSecretValue("password")}`; const password = this.getSecretValue("password");
url.pathname += url.pathname.endsWith("/") ? "jsonrpc" : "/jsonrpc"; const url = this.url(`/${username}:${password}/jsonrpc`);
const body = JSON.stringify({ method, params }); const body = JSON.stringify({ method, params });
return await fetch(url, { method: "POST", body }) return await fetch(url, { method: "POST", body })
.then(async (response) => { .then(async (response) => {

View File

@@ -70,9 +70,8 @@ export class QBitTorrentIntegration extends DownloadClientIntegration {
} }
private getClient() { private getClient() {
const baseUrl = new URL(this.integration.url).href;
return new QBittorrent({ return new QBittorrent({
baseUrl, baseUrl: this.url("/").toString(),
username: this.getSecretValue("username"), username: this.getSecretValue("username"),
password: this.getSecretValue("password"), password: this.getSecretValue("password"),
}); });

View File

@@ -12,7 +12,7 @@ dayjs.extend(duration);
export class SabnzbdIntegration extends DownloadClientIntegration { export class SabnzbdIntegration extends DownloadClientIntegration {
public async testConnectionAsync(): Promise<void> { public async testConnectionAsync(): Promise<void> {
//This is the one call that uses the least amount of data while requiring the api key //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<DownloadClientJobsAndStatus> { public async getClientJobsAndStatusAsync(): Promise<DownloadClientJobsAndStatus> {
@@ -75,7 +75,7 @@ export class SabnzbdIntegration extends DownloadClientIntegration {
} }
public async pauseItemAsync({ id }: DownloadClientItem) { 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() { public async resumeQueueAsync() {
@@ -83,32 +83,29 @@ export class SabnzbdIntegration extends DownloadClientIntegration {
} }
public async resumeItemAsync({ id }: DownloadClientItem): Promise<void> { public async resumeItemAsync({ id }: DownloadClientItem): Promise<void> {
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 //Delete files prevented on completed files. https://github.com/sabnzbd/sabnzbd/issues/2754
//Works on all other in downloading and post-processing. //Works on all other in downloading and post-processing.
//Will stop working as soon as the finished files is moved to completed folder. //Will stop working as soon as the finished files is moved to completed folder.
public async deleteItemAsync({ id, progress }: DownloadClientItem, fromDisk: boolean): Promise<void> { public async deleteItemAsync({ id, progress }: DownloadClientItem, fromDisk: boolean): Promise<void> {
await this.sabNzbApiCallAsync( await this.sabNzbApiCallAsync(progress !== 1 ? "queue" : "history", {
progress !== 1 ? "queue" : "history", name: "delete",
new URLSearchParams({ archive: fromDisk ? "0" : "1",
name: "delete", value: id,
archive: fromDisk ? "0" : "1", del_files: fromDisk ? "1" : "0",
value: id, });
del_files: fromDisk ? "1" : "0",
}),
);
} }
private async sabNzbApiCallAsync(mode: string, searchParams?: URLSearchParams): Promise<unknown> { private async sabNzbApiCallAsync(mode: string, searchParams?: Record<string, string>): Promise<unknown> {
const url = new URL("api", this.integration.url); const url = this.url("/api", {
url.searchParams.append("output", "json"); ...searchParams,
url.searchParams.append("mode", mode); output: "json",
searchParams?.forEach((value, key) => { mode,
url.searchParams.append(key, value); apikey: this.getSecretValue("apiKey"),
}); });
url.searchParams.append("apikey", this.getSecretValue("apiKey"));
return await fetch(url) return await fetch(url)
.then((response) => { .then((response) => {
if (!response.ok) { if (!response.ok) {

View File

@@ -71,9 +71,8 @@ export class TransmissionIntegration extends DownloadClientIntegration {
} }
private getClient() { private getClient() {
const baseUrl = new URL(this.integration.url).href;
return new Transmission({ return new Transmission({
baseUrl, baseUrl: this.url("/").toString(),
username: this.getSecretValue("username"), username: this.getSecretValue("username"),
password: this.getSecretValue("password"), password: this.getSecretValue("password"),
}); });

View File

@@ -1,4 +1,3 @@
import { appendPath } from "@homarr/common";
import { logger } from "@homarr/log"; import { logger } from "@homarr/log";
import { Integration } from "../base/integration"; import { Integration } from "../base/integration";
@@ -17,7 +16,7 @@ export class HomeAssistantIntegration extends Integration {
} }
return entityStateSchema.safeParseAsync(body); return entityStateSchema.safeParseAsync(body);
} catch (err) { } 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 { return {
success: false as const, success: false as const,
error: err, error: err,
@@ -33,7 +32,7 @@ export class HomeAssistantIntegration extends Integration {
return response.ok; return response.ok;
} catch (err) { } 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; return false;
} }
} }
@@ -52,7 +51,7 @@ export class HomeAssistantIntegration extends Integration {
return response.ok; return response.ok;
} catch (err) { } 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; return false;
} }
} }
@@ -72,7 +71,7 @@ export class HomeAssistantIntegration extends Integration {
* @returns the response from the API * @returns the response from the API
*/ */
private async getAsync(path: `/api/${string}`) { private async getAsync(path: `/api/${string}`) {
return await fetch(appendPath(this.integration.url, path), { return await fetch(this.url(path), {
headers: this.getAuthHeaders(), headers: this.getAuthHeaders(),
}); });
} }
@@ -85,7 +84,7 @@ export class HomeAssistantIntegration extends Integration {
* @returns the response from the API * @returns the response from the API
*/ */
private async postAsync(path: `/api/${string}`, body: Record<string, string>) { private async postAsync(path: `/api/${string}`, body: Record<string, string>) {
return await fetch(appendPath(this.integration.url, path), { return await fetch(this.url(path), {
headers: this.getAuthHeaders(), headers: this.getAuthHeaders(),
body: JSON.stringify(body), body: JSON.stringify(body),
method: "POST", method: "POST",

View File

@@ -29,9 +29,7 @@ export class JellyfinIntegration extends Integration {
const sessions = await sessionApi.getSessions(); const sessions = await sessionApi.getSessions();
if (sessions.status !== 200) { if (sessions.status !== 200) {
throw new Error( throw new Error(`Jellyfin server ${this.url("/")} returned a non successful status code: ${sessions.status}`);
`Jellyfin server ${this.integration.url} returned a non successful status code: ${sessions.status}`,
);
} }
return sessions.data.map((sessionInfo): StreamSession => { return sessions.data.map((sessionInfo): StreamSession => {
@@ -52,7 +50,7 @@ export class JellyfinIntegration extends Integration {
sessionId: `${sessionInfo.Id}`, sessionId: `${sessionInfo.Id}`,
sessionName: `${sessionInfo.Client} (${sessionInfo.DeviceName})`, sessionName: `${sessionInfo.Client} (${sessionInfo.DeviceName})`,
user: { user: {
profilePictureUrl: `${this.integration.url}/Users/${sessionInfo.UserId}/Images/Primary`, profilePictureUrl: this.url(`/Users/${sessionInfo.UserId}/Images/Primary`).toString(),
userId: sessionInfo.UserId ?? "", userId: sessionInfo.UserId ?? "",
username: sessionInfo.UserName ?? "", username: sessionInfo.UserName ?? "",
}, },
@@ -63,6 +61,6 @@ export class JellyfinIntegration extends Integration {
private getApi() { private getApi() {
const apiKey = this.getSecretValue("apiKey"); const apiKey = this.getSecretValue("apiKey");
return this.jellyfin.createApi(this.integration.url, apiKey); return this.jellyfin.createApi(this.url("/").toString(), apiKey);
} }
} }

View File

@@ -8,7 +8,7 @@ export class LidarrIntegration extends MediaOrganizerIntegration {
public async testConnectionAsync(): Promise<void> { public async testConnectionAsync(): Promise<void> {
await super.handleTestConnectionResponseAsync({ await super.handleTestConnectionResponseAsync({
queryFunctionAsync: async () => { queryFunctionAsync: async () => {
return await fetch(`${this.integration.url}/api`, { return await fetch(this.url("/api"), {
headers: { "X-Api-Key": super.getSecretValue("apiKey") }, 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. * @param includeUnmonitored When true results will include unmonitored items of the Tadarr library.
*/ */
async getCalendarEventsAsync(start: Date, end: Date, includeUnmonitored = true): Promise<CalendarEvent[]> { async getCalendarEventsAsync(start: Date, end: Date, includeUnmonitored = true): Promise<CalendarEvent[]> {
const url = new URL(this.integration.url); const url = this.url("/api/v1/calendar", {
url.pathname = "/api/v1/calendar"; start,
url.searchParams.append("start", start.toISOString()); end,
url.searchParams.append("end", end.toISOString()); unmonitored: includeUnmonitored,
url.searchParams.append("unmonitored", includeUnmonitored ? "true" : "false"); });
const response = await fetch(url, { const response = await fetch(url, {
headers: { headers: {
"X-Api-Key": super.getSecretValue("apiKey"), "X-Api-Key": super.getSecretValue("apiKey"),

View File

@@ -14,11 +14,12 @@ export class RadarrIntegration extends MediaOrganizerIntegration {
* @param includeUnmonitored When true results will include unmonitored items of the Tadarr library. * @param includeUnmonitored When true results will include unmonitored items of the Tadarr library.
*/ */
async getCalendarEventsAsync(start: Date, end: Date, includeUnmonitored = true): Promise<CalendarEvent[]> { async getCalendarEventsAsync(start: Date, end: Date, includeUnmonitored = true): Promise<CalendarEvent[]> {
const url = new URL(this.integration.url); const url = this.url("/api/v3/calendar", {
url.pathname = "/api/v3/calendar"; start,
url.searchParams.append("start", start.toISOString()); end,
url.searchParams.append("end", end.toISOString()); unmonitored: includeUnmonitored,
url.searchParams.append("unmonitored", includeUnmonitored ? "true" : "false"); });
const response = await fetch(url, { const response = await fetch(url, {
headers: { headers: {
"X-Api-Key": super.getSecretValue("apiKey"), "X-Api-Key": super.getSecretValue("apiKey"),
@@ -48,7 +49,7 @@ export class RadarrIntegration extends MediaOrganizerIntegration {
private getLinksForRadarrCalendarEvent = (event: z.infer<typeof radarrCalendarEventSchema>) => { private getLinksForRadarrCalendarEvent = (event: z.infer<typeof radarrCalendarEventSchema>) => {
const links: CalendarEvent["links"] = [ const links: CalendarEvent["links"] = [
{ {
href: `${this.integration.url}/movie/${event.titleSlug}`, href: this.url(`/movie/${event.titleSlug}`).toString(),
name: "Radarr", name: "Radarr",
logo: "/images/apps/radarr.svg", logo: "/images/apps/radarr.svg",
color: undefined, color: undefined,
@@ -93,7 +94,7 @@ export class RadarrIntegration extends MediaOrganizerIntegration {
public async testConnectionAsync(): Promise<void> { public async testConnectionAsync(): Promise<void> {
await super.handleTestConnectionResponseAsync({ await super.handleTestConnectionResponseAsync({
queryFunctionAsync: async () => { queryFunctionAsync: async () => {
return await fetch(`${this.integration.url}/api`, { return await fetch(this.url("/api"), {
headers: { "X-Api-Key": super.getSecretValue("apiKey") }, headers: { "X-Api-Key": super.getSecretValue("apiKey") },
}); });
}, },

View File

@@ -8,7 +8,7 @@ export class ReadarrIntegration extends MediaOrganizerIntegration {
public async testConnectionAsync(): Promise<void> { public async testConnectionAsync(): Promise<void> {
await super.handleTestConnectionResponseAsync({ await super.handleTestConnectionResponseAsync({
queryFunctionAsync: async () => { queryFunctionAsync: async () => {
return await fetch(`${this.integration.url}/api`, { return await fetch(this.url("/api"), {
headers: { "X-Api-Key": super.getSecretValue("apiKey") }, headers: { "X-Api-Key": super.getSecretValue("apiKey") },
}); });
}, },
@@ -27,12 +27,13 @@ export class ReadarrIntegration extends MediaOrganizerIntegration {
includeUnmonitored = true, includeUnmonitored = true,
includeAuthor = true, includeAuthor = true,
): Promise<CalendarEvent[]> { ): Promise<CalendarEvent[]> {
const url = new URL(this.integration.url); const url = this.url("/api/v1/calendar", {
url.pathname = "/api/v1/calendar"; start,
url.searchParams.append("start", start.toISOString()); end,
url.searchParams.append("end", end.toISOString()); unmonitored: includeUnmonitored,
url.searchParams.append("unmonitored", includeUnmonitored.toString()); includeAuthor,
url.searchParams.append("includeAuthor", includeAuthor.toString()); });
const response = await fetch(url, { const response = await fetch(url, {
headers: { headers: {
"X-Api-Key": super.getSecretValue("apiKey"), "X-Api-Key": super.getSecretValue("apiKey"),
@@ -58,7 +59,7 @@ export class ReadarrIntegration extends MediaOrganizerIntegration {
private getLinksForReadarrCalendarEvent = (event: z.infer<typeof readarrCalendarEventSchema>) => { private getLinksForReadarrCalendarEvent = (event: z.infer<typeof readarrCalendarEventSchema>) => {
return [ return [
{ {
href: `${this.integration.url}/author/${event.author.foreignAuthorId}`, href: this.url(`/author/${event.author.foreignAuthorId}`).toString(),
color: "#f5c518", color: "#f5c518",
isDark: false, isDark: false,
logo: "/images/apps/readarr.svg", logo: "/images/apps/readarr.svg",
@@ -85,7 +86,7 @@ export class ReadarrIntegration extends MediaOrganizerIntegration {
if (!bestImage) { if (!bestImage) {
return undefined; return undefined;
} }
return `${this.integration.url}${bestImage.url}`; return this.url(bestImage.url as `/${string}`).toString();
}; };
} }

View File

@@ -12,14 +12,15 @@ export class SonarrIntegration extends MediaOrganizerIntegration {
* @param includeUnmonitored When true results will include unmonitored items of the Sonarr library. * @param includeUnmonitored When true results will include unmonitored items of the Sonarr library.
*/ */
async getCalendarEventsAsync(start: Date, end: Date, includeUnmonitored = true): Promise<CalendarEvent[]> { async getCalendarEventsAsync(start: Date, end: Date, includeUnmonitored = true): Promise<CalendarEvent[]> {
const url = new URL(this.integration.url); const url = this.url("/api/v3/calendar", {
url.pathname = "/api/v3/calendar"; start,
url.searchParams.append("start", start.toISOString()); end,
url.searchParams.append("end", end.toISOString()); unmonitored: includeUnmonitored,
url.searchParams.append("includeSeries", "true"); includeSeries: true,
url.searchParams.append("includeEpisodeFile", "true"); includeEpisodeFile: true,
url.searchParams.append("includeEpisodeImages", "true"); includeEpisodeImages: true,
url.searchParams.append("unmonitored", includeUnmonitored ? "true" : "false"); });
const response = await fetch(url, { const response = await fetch(url, {
headers: { headers: {
"X-Api-Key": super.getSecretValue("apiKey"), "X-Api-Key": super.getSecretValue("apiKey"),
@@ -47,7 +48,7 @@ export class SonarrIntegration extends MediaOrganizerIntegration {
private getLinksForSonarCalendarEvent = (event: z.infer<typeof sonarrCalendarEventSchema>) => { private getLinksForSonarCalendarEvent = (event: z.infer<typeof sonarrCalendarEventSchema>) => {
const links: CalendarEvent["links"] = [ const links: CalendarEvent["links"] = [
{ {
href: `${this.integration.url}/series/${event.series.titleSlug}`, href: this.url(`/series/${event.series.titleSlug}`).toString(),
name: "Sonarr", name: "Sonarr",
logo: "/images/apps/sonarr.svg", logo: "/images/apps/sonarr.svg",
color: undefined, color: undefined,
@@ -92,7 +93,7 @@ export class SonarrIntegration extends MediaOrganizerIntegration {
public async testConnectionAsync(): Promise<void> { public async testConnectionAsync(): Promise<void> {
await super.handleTestConnectionResponseAsync({ await super.handleTestConnectionResponseAsync({
queryFunctionAsync: async () => { queryFunctionAsync: async () => {
return await fetch(`${this.integration.url}/api`, { return await fetch(this.url("/api"), {
headers: { "X-Api-Key": super.getSecretValue("apiKey") }, headers: { "X-Api-Key": super.getSecretValue("apiKey") },
}); });
}, },

View File

@@ -117,7 +117,7 @@ export class OpenMediaVaultIntegration extends Integration {
params: Record<string, unknown>, params: Record<string, unknown>,
headers: Record<string, string> = {}, headers: Record<string, string> = {},
): Promise<Response> { ): Promise<Response> {
return await fetch(`${this.integration.url}/rpc.php`, { return await fetch(this.url("/rpc.php"), {
method: "POST", method: "POST",
headers: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json",

View File

@@ -11,7 +11,7 @@ import { MediaAvailability, MediaRequestStatus } from "../interfaces/media-reque
*/ */
export class OverseerrIntegration extends Integration implements ISearchableIntegration { export class OverseerrIntegration extends Integration implements ISearchableIntegration {
public async searchAsync(query: string): Promise<{ image?: string; name: string; link: string; text?: string }[]> { 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: { headers: {
"X-Api-Key": this.getSecretValue("apiKey"), "X-Api-Key": this.getSecretValue("apiKey"),
}, },
@@ -24,13 +24,14 @@ export class OverseerrIntegration extends Integration implements ISearchableInte
return schemaData.results.map((result) => ({ return schemaData.results.map((result) => ({
name: "name" in result ? result.name : result.title, name: "name" in result ? result.name : result.title,
link: `${this.integration.url}/${result.mediaType}/${result.id}`, link: this.url(`/${result.mediaType}/${result.id}`).toString(),
image: constructSearchResultImage(this.integration.url, result), image: constructSearchResultImage(result),
text: "overview" in result ? result.overview : undefined, text: "overview" in result ? result.overview : undefined,
})); }));
} }
public async testConnectionAsync(): Promise<void> { public async testConnectionAsync(): Promise<void> {
const response = await fetch(`${this.integration.url}/api/v1/auth/me`, { const response = await fetch(this.url("/api/v1/auth/me"), {
headers: { headers: {
"X-Api-Key": this.getSecretValue("apiKey"), "X-Api-Key": this.getSecretValue("apiKey"),
}, },
@@ -46,14 +47,14 @@ export class OverseerrIntegration extends Integration implements ISearchableInte
public async getRequestsAsync(): Promise<MediaRequest[]> { public async getRequestsAsync(): Promise<MediaRequest[]> {
//Ensure to get all pending request first //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: { headers: {
"X-Api-Key": this.getSecretValue("apiKey"), "X-Api-Key": this.getSecretValue("apiKey"),
}, },
}); });
//Change 20 to integration setting (set to -1 for all) //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: { headers: {
"X-Api-Key": this.getSecretValue("apiKey"), "X-Api-Key": this.getSecretValue("apiKey"),
}, },
@@ -83,7 +84,7 @@ export class OverseerrIntegration extends Integration implements ISearchableInte
availability: request.media.status, availability: request.media.status,
backdropImageUrl: `https://image.tmdb.org/t/p/original/${information.backdropPath}`, backdropImageUrl: `https://image.tmdb.org/t/p/original/${information.backdropPath}`,
posterImagePath: `https://image.tmdb.org/t/p/w600_and_h900_bestv2/${information.posterPath}`, 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, type: request.type,
createdAt: request.createdAt, createdAt: request.createdAt,
airDate: new Date(information.airDate), airDate: new Date(information.airDate),
@@ -91,8 +92,8 @@ export class OverseerrIntegration extends Integration implements ISearchableInte
? ({ ? ({
...request.requestedBy, ...request.requestedBy,
displayName: request.requestedBy.displayName, displayName: request.requestedBy.displayName,
link: `${this.integration.url}/users/${request.requestedBy.id}`, link: this.url(`/users/${request.requestedBy.id}`).toString(),
avatar: constructAvatarUrl(this.integration.url, request.requestedBy.avatar), avatar: this.constructAvatarUrl(request.requestedBy.avatar).toString(),
} satisfies Omit<RequestUser, "requestCount">) } satisfies Omit<RequestUser, "requestCount">)
: undefined, : undefined,
}; };
@@ -101,7 +102,7 @@ export class OverseerrIntegration extends Integration implements ISearchableInte
} }
public async getStatsAsync(): Promise<RequestStats> { public async getStatsAsync(): Promise<RequestStats> {
const response = await fetch(`${this.integration.url}/api/v1/request/count`, { const response = await fetch(this.url("/api/v1/request/count"), {
headers: { headers: {
"X-Api-Key": this.getSecretValue("apiKey"), "X-Api-Key": this.getSecretValue("apiKey"),
}, },
@@ -110,7 +111,7 @@ export class OverseerrIntegration extends Integration implements ISearchableInte
} }
public async getUsersAsync(): Promise<RequestUser[]> { public async getUsersAsync(): Promise<RequestUser[]> {
const response = await fetch(`${this.integration.url}/api/v1/user?take=-1`, { const response = await fetch(this.url("/api/v1/user", { take: -1 }), {
headers: { headers: {
"X-Api-Key": this.getSecretValue("apiKey"), "X-Api-Key": this.getSecretValue("apiKey"),
}, },
@@ -119,15 +120,15 @@ export class OverseerrIntegration extends Integration implements ISearchableInte
return users.map((user): RequestUser => { return users.map((user): RequestUser => {
return { return {
...user, ...user,
link: `${this.integration.url}/users/${user.id}`, link: this.url(`/users/${user.id}`).toString(),
avatar: constructAvatarUrl(this.integration.url, user.avatar), avatar: this.constructAvatarUrl(user.avatar).toString(),
}; };
}); });
} }
public async approveRequestAsync(requestId: number): Promise<void> { public async approveRequestAsync(requestId: number): Promise<void> {
logger.info(`Approving media request id='${requestId}' integration='${this.integration.name}'`); 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", method: "POST",
headers: { headers: {
"X-Api-Key": this.getSecretValue("apiKey"), "X-Api-Key": this.getSecretValue("apiKey"),
@@ -145,7 +146,7 @@ export class OverseerrIntegration extends Integration implements ISearchableInte
public async declineRequestAsync(requestId: number): Promise<void> { public async declineRequestAsync(requestId: number): Promise<void> {
logger.info(`Declining media request id='${requestId}' integration='${this.integration.name}'`); 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", method: "POST",
headers: { headers: {
"X-Api-Key": this.getSecretValue("apiKey"), "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<MediaInformation> { private async getItemInformationAsync(id: number, type: MediaRequest["type"]): Promise<MediaInformation> {
const response = await fetch(`${this.integration.url}/api/v1/${type}/${id}`, { const response = await fetch(this.url(`/api/v1/${type}/${id}`), {
headers: { headers: {
"X-Api-Key": this.getSecretValue("apiKey"), "X-Api-Key": this.getSecretValue("apiKey"),
}, },
@@ -186,17 +187,17 @@ export class OverseerrIntegration extends Integration implements ISearchableInte
airDate: movie.releaseDate, airDate: movie.releaseDate,
} satisfies MediaInformation; } satisfies MediaInformation;
} }
}
const constructAvatarUrl = (appUrl: string, avatar: string) => { private constructAvatarUrl(avatar: string) {
const isAbsolute = avatar.startsWith("http://") || avatar.startsWith("https://"); const isAbsolute = avatar.startsWith("http://") || avatar.startsWith("https://");
if (isAbsolute) { if (isAbsolute) {
return avatar; return avatar;
}
return this.url(`/${avatar}`);
} }
}
return `${appUrl}/${avatar}`;
};
interface MediaInformation { interface MediaInformation {
name: string; name: string;
@@ -308,11 +309,8 @@ const getUsersSchema = z.object({
}), }),
}); });
const constructSearchResultImage = ( const constructSearchResultImage = (result: Exclude<z.infer<typeof searchSchema>["results"], undefined>[number]) => {
appUrl: string, const path = getResultImagePath(result);
result: Exclude<z.infer<typeof searchSchema>["results"], undefined>[number],
) => {
const path = getResultImagePath(appUrl, result);
if (!path) { if (!path) {
return undefined; return undefined;
} }
@@ -320,10 +318,7 @@ const constructSearchResultImage = (
return `https://image.tmdb.org/t/p/w600_and_h900_bestv2${path}`; return `https://image.tmdb.org/t/p/w600_and_h900_bestv2${path}`;
}; };
const getResultImagePath = ( const getResultImagePath = (result: Exclude<z.infer<typeof searchSchema>["results"], undefined>[number]) => {
appUrl: string,
result: Exclude<z.infer<typeof searchSchema>["results"], undefined>[number],
) => {
switch (result.mediaType) { switch (result.mediaType) {
case "person": case "person":
return result.profilePath; return result.profilePath;

View File

@@ -7,7 +7,7 @@ import { summaryResponseSchema } from "./pi-hole-types";
export class PiHoleIntegration extends Integration implements DnsHoleSummaryIntegration { export class PiHoleIntegration extends Integration implements DnsHoleSummaryIntegration {
public async getSummaryAsync(): Promise<DnsHoleSummary> { public async getSummaryAsync(): Promise<DnsHoleSummary> {
const apiKey = super.getSecretValue("apiKey"); 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) { if (!response.ok) {
throw new Error( throw new Error(
`Failed to fetch summary for ${this.integration.name} (${this.integration.id}): ${response.statusText}`, `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({ await super.handleTestConnectionResponseAsync({
queryFunctionAsync: async () => { 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) => { handleResponseAsync: async (response) => {
try { try {
@@ -53,7 +53,7 @@ export class PiHoleIntegration extends Integration implements DnsHoleSummaryInte
public async enableAsync(): Promise<void> { public async enableAsync(): Promise<void> {
const apiKey = super.getSecretValue("apiKey"); 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) { if (!response.ok) {
throw new Error( throw new Error(
`Failed to enable PiHole for ${this.integration.name} (${this.integration.id}): ${response.statusText}`, `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<void> { public async disableAsync(duration?: number): Promise<void> {
const apiKey = super.getSecretValue("apiKey"); 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); const response = await fetch(url);
if (!response.ok) { if (!response.ok) {
throw new Error( throw new Error(

View File

@@ -11,7 +11,7 @@ export class PlexIntegration extends Integration {
public async getCurrentSessionsAsync(): Promise<StreamSession[]> { public async getCurrentSessionsAsync(): Promise<StreamSession[]> {
const token = super.getSecretValue("apiKey"); const token = super.getSecretValue("apiKey");
const response = await fetch(`${this.integration.url}/status/sessions`, { const response = await fetch(this.url("/status/sessions"), {
headers: { headers: {
"X-Plex-Token": token, "X-Plex-Token": token,
}, },
@@ -66,7 +66,7 @@ export class PlexIntegration extends Integration {
await super.handleTestConnectionResponseAsync({ await super.handleTestConnectionResponseAsync({
queryFunctionAsync: async () => { queryFunctionAsync: async () => {
return await fetch(this.integration.url, { return await fetch(this.url("/"), {
headers: { headers: {
"X-Plex-Token": token, "X-Plex-Token": token,
}, },

View File

@@ -7,7 +7,7 @@ export class ProwlarrIntegration extends Integration {
public async getIndexersAsync(): Promise<Indexer[]> { public async getIndexersAsync(): Promise<Indexer[]> {
const apiKey = super.getSecretValue("apiKey"); 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: { headers: {
"X-Api-Key": apiKey, "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: { headers: {
"X-Api-Key": apiKey, "X-Api-Key": apiKey,
}, },
@@ -60,7 +60,7 @@ export class ProwlarrIntegration extends Integration {
public async testAllAsync(): Promise<void> { public async testAllAsync(): Promise<void> {
const apiKey = super.getSecretValue("apiKey"); 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: { headers: {
"X-Api-Key": apiKey, "X-Api-Key": apiKey,
}, },
@@ -78,7 +78,7 @@ export class ProwlarrIntegration extends Integration {
await super.handleTestConnectionResponseAsync({ await super.handleTestConnectionResponseAsync({
queryFunctionAsync: async () => { queryFunctionAsync: async () => {
return await fetch(`${this.integration.url}/api`, { return await fetch(this.url("/api"), {
headers: { headers: {
"X-Api-Key": apiKey, "X-Api-Key": apiKey,
}, },

View File

@@ -27,14 +27,14 @@
"prettier": "@homarr/prettier-config", "prettier": "@homarr/prettier-config",
"dependencies": { "dependencies": {
"ioredis": "5.4.1", "ioredis": "5.4.1",
"superjson": "2.2.1", "superjson": "2.2.2",
"winston": "3.17.0" "winston": "3.17.0"
}, },
"devDependencies": { "devDependencies": {
"@homarr/eslint-config": "workspace:^0.2.0", "@homarr/eslint-config": "workspace:^0.2.0",
"@homarr/prettier-config": "workspace:^0.1.0", "@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0", "@homarr/tsconfig": "workspace:^0.1.0",
"eslint": "^9.15.0", "eslint": "^9.16.0",
"typescript": "^5.7.2" "typescript": "^5.7.2"
} }
} }

View File

@@ -1,7 +1,8 @@
{ {
"name": "@homarr/modals-collection", "name": "@homarr/modals-collection",
"private": true,
"version": "0.1.0", "version": "0.1.0",
"private": true,
"license": "MIT",
"type": "module", "type": "module",
"exports": { "exports": {
".": "./index.ts" ".": "./index.ts"
@@ -13,13 +14,13 @@
] ]
} }
}, },
"license": "MIT",
"scripts": { "scripts": {
"clean": "rm -rf .turbo node_modules", "clean": "rm -rf .turbo node_modules",
"lint": "eslint",
"format": "prettier --check . --ignore-path ../../.gitignore", "format": "prettier --check . --ignore-path ../../.gitignore",
"lint": "eslint",
"typecheck": "tsc --noEmit" "typecheck": "tsc --noEmit"
}, },
"prettier": "@homarr/prettier-config",
"dependencies": { "dependencies": {
"@homarr/api": "workspace:^0.1.0", "@homarr/api": "workspace:^0.1.0",
"@homarr/common": "workspace:^0.1.0", "@homarr/common": "workspace:^0.1.0",
@@ -31,17 +32,16 @@
"@homarr/ui": "workspace:^0.1.0", "@homarr/ui": "workspace:^0.1.0",
"@homarr/validation": "workspace:^0.1.0", "@homarr/validation": "workspace:^0.1.0",
"@mantine/core": "^7.14.3", "@mantine/core": "^7.14.3",
"@tabler/icons-react": "^3.23.0", "@tabler/icons-react": "^3.24.0",
"dayjs": "^1.11.13", "dayjs": "^1.11.13",
"next": "^14.2.18", "next": "^14.2.20",
"react": "^18.3.1" "react": "^18.3.1"
}, },
"devDependencies": { "devDependencies": {
"@homarr/eslint-config": "workspace:^0.2.0", "@homarr/eslint-config": "workspace:^0.2.0",
"@homarr/prettier-config": "workspace:^0.1.0", "@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0", "@homarr/tsconfig": "workspace:^0.1.0",
"eslint": "^9.15.0", "eslint": "^9.16.0",
"typescript": "^5.7.2" "typescript": "^5.7.2"
}, }
"prettier": "@homarr/prettier-config"
} }

View File

@@ -32,7 +32,7 @@
"@homarr/eslint-config": "workspace:^0.2.0", "@homarr/eslint-config": "workspace:^0.2.0",
"@homarr/prettier-config": "workspace:^0.1.0", "@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0", "@homarr/tsconfig": "workspace:^0.1.0",
"eslint": "^9.15.0", "eslint": "^9.16.0",
"typescript": "^5.7.2" "typescript": "^5.7.2"
} }
} }

View File

@@ -25,13 +25,13 @@
"dependencies": { "dependencies": {
"@homarr/ui": "workspace:^0.1.0", "@homarr/ui": "workspace:^0.1.0",
"@mantine/notifications": "^7.14.3", "@mantine/notifications": "^7.14.3",
"@tabler/icons-react": "^3.23.0" "@tabler/icons-react": "^3.24.0"
}, },
"devDependencies": { "devDependencies": {
"@homarr/eslint-config": "workspace:^0.2.0", "@homarr/eslint-config": "workspace:^0.2.0",
"@homarr/prettier-config": "workspace:^0.1.0", "@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0", "@homarr/tsconfig": "workspace:^0.1.0",
"eslint": "^9.15.0", "eslint": "^9.16.0",
"typescript": "^5.7.2" "typescript": "^5.7.2"
} }
} }

View File

@@ -1,7 +1,8 @@
{ {
"name": "@homarr/old-import", "name": "@homarr/old-import",
"private": true,
"version": "0.1.0", "version": "0.1.0",
"private": true,
"license": "MIT",
"type": "module", "type": "module",
"exports": { "exports": {
".": "./index.ts" ".": "./index.ts"
@@ -13,13 +14,13 @@
] ]
} }
}, },
"license": "MIT",
"scripts": { "scripts": {
"clean": "rm -rf .turbo node_modules", "clean": "rm -rf .turbo node_modules",
"lint": "eslint",
"format": "prettier --check . --ignore-path ../../.gitignore", "format": "prettier --check . --ignore-path ../../.gitignore",
"lint": "eslint",
"typecheck": "tsc --noEmit" "typecheck": "tsc --noEmit"
}, },
"prettier": "@homarr/prettier-config",
"dependencies": { "dependencies": {
"@homarr/common": "workspace:^0.1.0", "@homarr/common": "workspace:^0.1.0",
"@homarr/db": "workspace:^0.1.0", "@homarr/db": "workspace:^0.1.0",
@@ -27,14 +28,13 @@
"@homarr/log": "workspace:^0.1.0", "@homarr/log": "workspace:^0.1.0",
"@homarr/old-schema": "workspace:^0.1.0", "@homarr/old-schema": "workspace:^0.1.0",
"@homarr/validation": "workspace:^0.1.0", "@homarr/validation": "workspace:^0.1.0",
"superjson": "2.2.1" "superjson": "2.2.2"
}, },
"devDependencies": { "devDependencies": {
"@homarr/eslint-config": "workspace:^0.2.0", "@homarr/eslint-config": "workspace:^0.2.0",
"@homarr/prettier-config": "workspace:^0.1.0", "@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0", "@homarr/tsconfig": "workspace:^0.1.0",
"eslint": "^9.15.0", "eslint": "^9.16.0",
"typescript": "^5.7.2" "typescript": "^5.7.2"
}, }
"prettier": "@homarr/prettier-config"
} }

View File

@@ -1,7 +1,8 @@
{ {
"name": "@homarr/old-schema", "name": "@homarr/old-schema",
"private": true,
"version": "0.1.0", "version": "0.1.0",
"private": true,
"license": "MIT",
"type": "module", "type": "module",
"exports": { "exports": {
".": "./index.ts" ".": "./index.ts"
@@ -13,13 +14,13 @@
] ]
} }
}, },
"license": "MIT",
"scripts": { "scripts": {
"clean": "rm -rf .turbo node_modules", "clean": "rm -rf .turbo node_modules",
"lint": "eslint",
"format": "prettier --check . --ignore-path ../../.gitignore", "format": "prettier --check . --ignore-path ../../.gitignore",
"lint": "eslint",
"typecheck": "tsc --noEmit" "typecheck": "tsc --noEmit"
}, },
"prettier": "@homarr/prettier-config",
"dependencies": { "dependencies": {
"zod": "^3.23.8" "zod": "^3.23.8"
}, },
@@ -27,8 +28,7 @@
"@homarr/eslint-config": "workspace:^0.2.0", "@homarr/eslint-config": "workspace:^0.2.0",
"@homarr/prettier-config": "workspace:^0.1.0", "@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0", "@homarr/tsconfig": "workspace:^0.1.0",
"eslint": "^9.15.0", "eslint": "^9.16.0",
"typescript": "^5.7.2" "typescript": "^5.7.2"
}, }
"prettier": "@homarr/prettier-config"
} }

View File

@@ -29,7 +29,7 @@
"@homarr/eslint-config": "workspace:^0.2.0", "@homarr/eslint-config": "workspace:^0.2.0",
"@homarr/prettier-config": "workspace:^0.1.0", "@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0", "@homarr/tsconfig": "workspace:^0.1.0",
"eslint": "^9.15.0", "eslint": "^9.16.0",
"typescript": "^5.7.2" "typescript": "^5.7.2"
} }
} }

View File

@@ -27,13 +27,13 @@
"@homarr/definitions": "workspace:^", "@homarr/definitions": "workspace:^",
"@homarr/log": "workspace:^", "@homarr/log": "workspace:^",
"ioredis": "5.4.1", "ioredis": "5.4.1",
"superjson": "2.2.1" "superjson": "2.2.2"
}, },
"devDependencies": { "devDependencies": {
"@homarr/eslint-config": "workspace:^0.2.0", "@homarr/eslint-config": "workspace:^0.2.0",
"@homarr/prettier-config": "workspace:^0.1.0", "@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0", "@homarr/tsconfig": "workspace:^0.1.0",
"eslint": "^9.15.0", "eslint": "^9.16.0",
"typescript": "^5.7.2" "typescript": "^5.7.2"
} }
} }

View File

@@ -1,7 +1,8 @@
{ {
"name": "@homarr/request-handler", "name": "@homarr/request-handler",
"private": true,
"version": "0.1.0", "version": "0.1.0",
"private": true,
"license": "MIT",
"type": "module", "type": "module",
"exports": { "exports": {
"./*": "./src/*.ts" "./*": "./src/*.ts"
@@ -13,13 +14,13 @@
] ]
} }
}, },
"license": "MIT",
"scripts": { "scripts": {
"clean": "rm -rf .turbo node_modules", "clean": "rm -rf .turbo node_modules",
"lint": "eslint",
"format": "prettier --check . --ignore-path ../../.gitignore", "format": "prettier --check . --ignore-path ../../.gitignore",
"lint": "eslint",
"typecheck": "tsc --noEmit" "typecheck": "tsc --noEmit"
}, },
"prettier": "@homarr/prettier-config",
"dependencies": { "dependencies": {
"@homarr/common": "workspace:^0.1.0", "@homarr/common": "workspace:^0.1.0",
"@homarr/db": "workspace:^0.1.0", "@homarr/db": "workspace:^0.1.0",
@@ -28,14 +29,14 @@
"@homarr/log": "workspace:^0.1.0", "@homarr/log": "workspace:^0.1.0",
"@homarr/redis": "workspace:^0.1.0", "@homarr/redis": "workspace:^0.1.0",
"dayjs": "^1.11.13", "dayjs": "^1.11.13",
"superjson": "2.2.1" "pretty-print-error": "^1.1.2",
"superjson": "2.2.2"
}, },
"devDependencies": { "devDependencies": {
"@homarr/eslint-config": "workspace:^0.2.0", "@homarr/eslint-config": "workspace:^0.2.0",
"@homarr/prettier-config": "workspace:^0.1.0", "@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0", "@homarr/tsconfig": "workspace:^0.1.0",
"eslint": "^9.15.0", "eslint": "^9.16.0",
"typescript": "^5.7.2" "typescript": "^5.7.2"
}, }
"prettier": "@homarr/prettier-config"
} }

View File

@@ -1,3 +1,4 @@
import { formatError } from "pretty-print-error";
import SuperJSON from "superjson"; import SuperJSON from "superjson";
import { hashObjectBase64, Stopwatch } from "@homarr/common"; import { hashObjectBase64, Stopwatch } from "@homarr/common";
@@ -95,7 +96,7 @@ export const createRequestIntegrationJobHandler = <
); );
} catch (error) { } catch (error) {
logger.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)}`,
); );
} }
} }

View File

@@ -29,7 +29,7 @@
"@homarr/eslint-config": "workspace:^0.2.0", "@homarr/eslint-config": "workspace:^0.2.0",
"@homarr/prettier-config": "workspace:^0.1.0", "@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0", "@homarr/tsconfig": "workspace:^0.1.0",
"eslint": "^9.15.0", "eslint": "^9.16.0",
"typescript": "^5.7.2" "typescript": "^5.7.2"
} }
} }

View File

@@ -21,6 +21,7 @@
"lint": "eslint", "lint": "eslint",
"typecheck": "tsc --noEmit" "typecheck": "tsc --noEmit"
}, },
"prettier": "@homarr/prettier-config",
"dependencies": { "dependencies": {
"@homarr/api": "workspace:^0.1.0", "@homarr/api": "workspace:^0.1.0",
"@homarr/auth": "workspace:^0.1.0", "@homarr/auth": "workspace:^0.1.0",
@@ -34,9 +35,9 @@
"@mantine/core": "^7.14.3", "@mantine/core": "^7.14.3",
"@mantine/hooks": "^7.14.3", "@mantine/hooks": "^7.14.3",
"@mantine/spotlight": "^7.14.3", "@mantine/spotlight": "^7.14.3",
"@tabler/icons-react": "^3.23.0", "@tabler/icons-react": "^3.24.0",
"jotai": "^2.10.3", "jotai": "^2.10.3",
"next": "^14.2.18", "next": "^14.2.20",
"react": "^18.3.1", "react": "^18.3.1",
"use-deep-compare-effect": "^1.8.1" "use-deep-compare-effect": "^1.8.1"
}, },
@@ -44,8 +45,7 @@
"@homarr/eslint-config": "workspace:^0.2.0", "@homarr/eslint-config": "workspace:^0.2.0",
"@homarr/prettier-config": "workspace:^0.1.0", "@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0", "@homarr/tsconfig": "workspace:^0.1.0",
"eslint": "^9.15.0", "eslint": "^9.16.0",
"typescript": "^5.7.2" "typescript": "^5.7.2"
}, }
"prettier": "@homarr/prettier-config"
} }

View File

@@ -34,6 +34,10 @@ export const SpotlightGroupActions = <TOption extends Record<string, unknown>>({
}); });
if (Array.isArray(options)) { if (Array.isArray(options)) {
if (options.length === 0) {
return null;
}
const filteredOptions = options const filteredOptions = options
.filter((option) => ("filter" in group ? group.filter(query, option) : false)) .filter((option) => ("filter" in group ? group.filter(query, option) : false))
.sort((optionA, optionB) => { .sort((optionA, optionB) => {

View File

@@ -1,7 +1,7 @@
"use client"; "use client";
import type { Dispatch, SetStateAction } from "react"; 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 { ActionIcon, Center, Group, Kbd } from "@mantine/core";
import { Spotlight as MantineSpotlight } from "@mantine/spotlight"; import { Spotlight as MantineSpotlight } from "@mantine/spotlight";
import { IconSearch, IconX } from "@tabler/icons-react"; 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 { inferSearchInteractionOptions } from "../lib/interaction";
import type { SearchMode } from "../lib/mode"; import type { SearchMode } from "../lib/mode";
import { searchModes } from "../modes"; import { searchModes } from "../modes";
import { useSpotlightContextResults } from "../modes/home/context";
import { selectAction, spotlightStore } from "../spotlight-store"; import { selectAction, spotlightStore } from "../spotlight-store";
import { SpotlightChildrenActions } from "./actions/children-actions"; import { SpotlightChildrenActions } from "./actions/children-actions";
import { SpotlightActionGroups } from "./actions/groups/action-group"; import { SpotlightActionGroups } from "./actions/groups/action-group";
@@ -19,24 +20,45 @@ import { SpotlightActionGroups } from "./actions/groups/action-group";
type SearchModeKey = keyof TranslationObject["search"]["mode"]; type SearchModeKey = keyof TranslationObject["search"]["mode"];
export const Spotlight = () => { export const Spotlight = () => {
const searchModeState = useState<SearchModeKey>("help"); const items = useSpotlightContextResults();
// We fallback to help if no context results are available
const defaultMode = items.length >= 1 ? "home" : "help";
const searchModeState = useState<SearchModeKey>(defaultMode);
const mode = searchModeState[0]; const mode = searchModeState[0];
const activeMode = useMemo(() => searchModes.find((searchMode) => searchMode.modeKey === mode), [mode]); 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) { if (!activeMode) {
return null; return null;
} }
// We use the "key" below to prevent the 'Different amounts of hooks' error // We use the "key" below to prevent the 'Different amounts of hooks' error
return <SpotlightWithActiveMode key={mode} modeState={searchModeState} activeMode={activeMode} />; return (
<SpotlightWithActiveMode key={mode} modeState={searchModeState} activeMode={activeMode} defaultMode={defaultMode} />
);
}; };
interface SpotlightWithActiveModeProps { interface SpotlightWithActiveModeProps {
modeState: [SearchModeKey, Dispatch<SetStateAction<SearchModeKey>>]; modeState: [SearchModeKey, Dispatch<SetStateAction<SearchModeKey>>];
activeMode: SearchMode; activeMode: SearchMode;
defaultMode: SearchModeKey;
} }
const SpotlightWithActiveMode = ({ modeState, activeMode }: SpotlightWithActiveModeProps) => { const SpotlightWithActiveMode = ({ modeState, activeMode, defaultMode }: SpotlightWithActiveModeProps) => {
const [query, setQuery] = useState(""); const [query, setQuery] = useState("");
const [mode, setMode] = modeState; const [mode, setMode] = modeState;
const [childrenOptions, setChildrenOptions] = useState<inferSearchInteractionOptions<"children"> | null>(null); const [childrenOptions, setChildrenOptions] = useState<inferSearchInteractionOptions<"children"> | null>(null);
@@ -50,12 +72,12 @@ const SpotlightWithActiveMode = ({ modeState, activeMode }: SpotlightWithActiveM
<MantineSpotlight.Root <MantineSpotlight.Root
yOffset={8} yOffset={8}
onSpotlightClose={() => { onSpotlightClose={() => {
setMode("help"); setMode(defaultMode);
setChildrenOptions(null); setChildrenOptions(null);
}} }}
query={query} query={query}
onQueryChange={(query) => { onQueryChange={(query) => {
if (mode !== "help" || query.length !== 1) { if ((mode !== "help" && mode !== "home") || query.length !== 1) {
setQuery(query); setQuery(query);
} }
@@ -73,13 +95,13 @@ const SpotlightWithActiveMode = ({ modeState, activeMode }: SpotlightWithActiveM
<MantineSpotlight.Search <MantineSpotlight.Search
placeholder={`${t("search.placeholder")}...`} placeholder={`${t("search.placeholder")}...`}
ref={inputRef} ref={inputRef}
leftSectionWidth={activeMode.modeKey !== "help" ? 80 : 48} leftSectionWidth={activeMode.modeKey !== defaultMode ? 80 : 48}
leftSection={ leftSection={
<Group align="center" wrap="nowrap" gap="xs" w="100%" h="100%"> <Group align="center" wrap="nowrap" gap="xs" w="100%" h="100%">
<Center w={48} h="100%"> <Center w={48} h="100%">
<IconSearch stroke={1.5} /> <IconSearch stroke={1.5} />
</Center> </Center>
{activeMode.modeKey !== "help" ? <Kbd size="sm">{activeMode.character}</Kbd> : null} {activeMode.modeKey !== defaultMode ? <Kbd size="sm">{activeMode.character}</Kbd> : null}
</Group> </Group>
} }
styles={{ styles={{
@@ -88,10 +110,10 @@ const SpotlightWithActiveMode = ({ modeState, activeMode }: SpotlightWithActiveM
}, },
}} }}
rightSection={ rightSection={
mode === "help" ? undefined : ( mode === defaultMode ? undefined : (
<ActionIcon <ActionIcon
onClick={() => { onClick={() => {
setMode("help"); setMode(defaultMode);
setChildrenOptions(null); setChildrenOptions(null);
inputRef.current?.focus(); inputRef.current?.focus();
}} }}
@@ -103,8 +125,8 @@ const SpotlightWithActiveMode = ({ modeState, activeMode }: SpotlightWithActiveM
} }
value={query} value={query}
onKeyDown={(event) => { onKeyDown={(event) => {
if (query.length === 0 && mode !== "help" && event.key === "Backspace") { if (query.length === 0 && mode !== defaultMode && event.key === "Backspace") {
setMode("help"); setMode(defaultMode);
setChildrenOptions(null); setChildrenOptions(null);
} }
}} }}

View File

@@ -4,5 +4,10 @@ import { spotlightActions } from "./spotlight-store";
export { Spotlight } from "./components/spotlight"; export { Spotlight } from "./components/spotlight";
export { openSpotlight }; export { openSpotlight };
export {
SpotlightProvider,
useRegisterSpotlightContextResults,
useRegisterSpotlightContextActions,
} from "./modes/home/context";
const openSpotlight = spotlightActions.open; const openSpotlight = spotlightActions.open;

View File

@@ -4,7 +4,7 @@ import type { SearchGroup } from "./group";
export type SearchMode = { export type SearchMode = {
modeKey: keyof TranslationObject["search"]["mode"]; modeKey: keyof TranslationObject["search"]["mode"];
character: string; character: string | undefined;
} & ( } & (
| { | {
groups: SearchGroup[]; groups: SearchGroup[];

View File

@@ -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<ContextSpecificItem>({
title: (t) => t("search.mode.command.group.localCommand.title"),
keyPath: "id",
Component(option) {
const icon =
typeof option.icon !== "string" ? (
<option.icon size={24} />
) : (
<img width={24} height={24} src={option.icon} alt={option.name} />
);
return (
<Group w="100%" wrap="nowrap" align="center" px="md" py="xs">
{icon}
<Text>{option.name}</Text>
</Group>
);
},
useInteraction(option) {
return option.interaction();
},
filter(query, option) {
return option.name.toLowerCase().includes(query.toLowerCase());
},
useOptions() {
return useSpotlightContextActions();
},
});

View File

@@ -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<string, unknown>.
// eslint-disable-next-line @typescript-eslint/consistent-type-definitions
type Command<TSearchInteraction extends SearchInteraction = SearchInteraction> = {
commandKey: string;
icon: TablerIcon;
name: string;
useInteraction: (
_c: Command<TSearchInteraction>,
query: string,
) => inferSearchInteractionDefinition<TSearchInteraction>;
};
export const globalCommandGroup = createGroup<Command>({
keyPath: "commandKey",
title: "Global commands",
useInteraction: (option, query) => option.useInteraction(option, query),
Component: ({ icon: Icon, name }) => (
<Group px="md" py="sm">
<Icon stroke={1.5} />
<Text>{name}</Text>
</Group>
),
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);
},
});

View File

@@ -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 type { SearchMode } from "../../lib/mode";
import { languageChildrenOptions } from "./children/language"; import { contextSpecificActionsSearchGroups } from "./context-specific-group";
import { newIntegrationChildrenOptions } from "./children/new-integration"; import { globalCommandGroup } from "./global-group";
// This has to be type so it can be interpreted as Record<string, unknown>.
// eslint-disable-next-line @typescript-eslint/consistent-type-definitions
type Command<TSearchInteraction extends SearchInteraction = SearchInteraction> = {
commandKey: string;
icon: TablerIcon;
name: string;
useInteraction: (
_c: Command<TSearchInteraction>,
query: string,
) => inferSearchInteractionDefinition<TSearchInteraction>;
};
export const commandMode = { export const commandMode = {
modeKey: "command", modeKey: "command",
character: ">", character: ">",
groups: [ groups: [contextSpecificActionsSearchGroups, globalCommandGroup],
createGroup<Command>({
keyPath: "commandKey",
title: "Global commands",
useInteraction: (option, query) => option.useInteraction(option, query),
Component: ({ icon: Icon, name }) => (
<Group px="md" py="sm">
<Icon stroke={1.5} />
<Text>{name}</Text>
</Group>
),
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);
},
}),
],
} satisfies SearchMode; } satisfies SearchMode;

View File

@@ -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<ContextSpecificItem>({
title: (t) => t("search.mode.home.group.local.title"),
keyPath: "id",
Component(option) {
const icon =
typeof option.icon !== "string" ? (
<option.icon size={24} />
) : (
<img width={24} height={24} src={option.icon} alt={option.name} />
);
return (
<Group w="100%" wrap="nowrap" align="center" px="md" py="xs">
{icon}
<Text>{option.name}</Text>
</Group>
);
},
useInteraction(option) {
return option.interaction();
},
filter(query, option) {
return option.name.toLowerCase().includes(query.toLowerCase());
},
useOptions() {
return useSpotlightContextResults();
},
});

View File

@@ -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<SearchInteraction>;
disabled?: boolean;
};
interface SpotlightContextProps {
items: ContextSpecificItem[];
registerItems: (key: string, results: ContextSpecificItem[]) => void;
unregisterItems: (key: string) => void;
}
const createSpotlightContext = (displayName: string) => {
const SpotlightContext = createContext<SpotlightContextProps | null>(null);
SpotlightContext.displayName = displayName;
const Provider = ({ children }: PropsWithChildren) => {
const [itemsMap, setItemsMap] = useState<Map<string, { items: ContextSpecificItem[]; count: number }>>(new Map());
const registerItems = useCallback((key: string, newItems: ContextSpecificItem[]) => {
setItemsMap((prevItems) => {
const newItemsMap = new Map(prevItems);
newItemsMap.set(key, { items: newItems, count: (newItemsMap.get(key)?.count ?? 0) + 1 });
return newItemsMap;
});
}, []);
const unregisterItems = useCallback((key: string) => {
setItemsMap((prevItems) => {
const registrationCount = prevItems.get(key)?.count ?? 0;
if (registrationCount <= 1) {
const newItemsMap = new Map(prevItems);
newItemsMap.delete(key);
return newItemsMap;
}
const newItemsMap = new Map(prevItems);
newItemsMap.set(key, { items: newItemsMap.get(key)?.items ?? [], count: registrationCount - 1 });
return prevItems;
});
}, []);
const items = useMemo(() => Array.from(itemsMap.values()).flatMap(({ items }) => items), [itemsMap]);
return (
<SpotlightContext.Provider value={{ items, registerItems, unregisterItems }}>
{children}
</SpotlightContext.Provider>
);
};
const useSpotlightContextItems = () => {
const context = useContext(SpotlightContext);
if (!context) {
throw new Error(`useSpotlightContextItems must be used within SpotlightContext[displayName=${displayName}]`);
}
return context.items;
};
const useRegisterSpotlightContextItems = (
key: string,
items: ContextSpecificItem[],
dependencyArray: DependencyList,
) => {
const context = useContext(SpotlightContext);
if (!context) {
throw new Error(
`useRegisterSpotlightContextItems must be used within SpotlightContext[displayName=${displayName}]`,
);
}
useEffect(() => {
context.registerItems(
key,
items.filter((item) => !item.disabled),
);
return () => {
context.unregisterItems(key);
};
// We ignore the results
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [...dependencyArray, key]);
};
return [SpotlightContext, Provider, useSpotlightContextItems, useRegisterSpotlightContextItems] as const;
};
const [_ResultContext, ResultProvider, useSpotlightContextResults, useRegisterSpotlightContextResults] =
createSpotlightContext("SpotlightContextSpecificResults");
const [_ActionContext, ActionProvider, useSpotlightContextActions, useRegisterSpotlightContextActions] =
createSpotlightContext("SpotlightContextSpecificActions");
export {
useRegisterSpotlightContextActions,
useRegisterSpotlightContextResults,
useSpotlightContextActions,
useSpotlightContextResults,
};
export const SpotlightProvider = ({ children }: PropsWithChildren) => {
return (
<ResultProvider>
<ActionProvider>{children}</ActionProvider>
</ResultProvider>
);
};

View File

@@ -0,0 +1,8 @@
import type { SearchMode } from "../../lib/mode";
import { contextSpecificSearchGroups } from "./context-specific-group";
export const homeMode = {
character: undefined,
modeKey: "home",
groups: [contextSpecificSearchGroups],
} satisfies SearchMode;

View File

@@ -11,10 +11,11 @@ import type { SearchMode } from "../lib/mode";
import { appIntegrationBoardMode } from "./app-integration-board"; import { appIntegrationBoardMode } from "./app-integration-board";
import { commandMode } from "./command"; import { commandMode } from "./command";
import { externalMode } from "./external"; import { externalMode } from "./external";
import { homeMode } from "./home";
import { pageMode } from "./page"; import { pageMode } from "./page";
import { userGroupMode } from "./user-group"; import { userGroupMode } from "./user-group";
const searchModesWithoutHelp = [userGroupMode, appIntegrationBoardMode, externalMode, commandMode, pageMode] as const; const searchModesForHelp = [userGroupMode, appIntegrationBoardMode, externalMode, commandMode, pageMode] as const;
const helpMode = { const helpMode = {
modeKey: "help", modeKey: "help",
@@ -82,4 +83,4 @@ const helpMode = {
}, },
} satisfies SearchMode; } satisfies SearchMode;
export const searchModes = [...searchModesWithoutHelp, helpMode] as const; export const searchModes = [...searchModesForHelp, helpMode, homeMode] as const;

View File

@@ -32,15 +32,15 @@
"dayjs": "^1.11.13", "dayjs": "^1.11.13",
"deepmerge": "4.3.1", "deepmerge": "4.3.1",
"mantine-react-table": "2.0.0-beta.7", "mantine-react-table": "2.0.0-beta.7",
"next": "^14.2.18", "next": "^14.2.20",
"next-intl": "3.25.3", "next-intl": "3.26.0",
"react": "^18.3.1" "react": "^18.3.1"
}, },
"devDependencies": { "devDependencies": {
"@homarr/eslint-config": "workspace:^0.2.0", "@homarr/eslint-config": "workspace:^0.2.0",
"@homarr/prettier-config": "workspace:^0.1.0", "@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0", "@homarr/tsconfig": "workspace:^0.1.0",
"eslint": "^9.15.0", "eslint": "^9.16.0",
"typescript": "^5.7.2" "typescript": "^5.7.2"
} }
} }

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1159,6 +1159,9 @@
"automationId": { "automationId": {
"label": "Automation ID" "label": "Automation ID"
} }
},
"spotlightAction": {
"run": "Run {name}"
} }
}, },
"calendar": { "calendar": {
@@ -2450,6 +2453,9 @@
"command": { "command": {
"help": "Activate command mode", "help": "Activate command mode",
"group": { "group": {
"localCommand": {
"title": "Local commands"
},
"globalCommand": { "globalCommand": {
"title": "Global commands", "title": "Global commands",
"option": { "option": {
@@ -2559,6 +2565,13 @@
} }
} }
}, },
"home": {
"group": {
"local": {
"title": "Local results"
}
}
},
"page": { "page": {
"help": "Search for pages", "help": "Search for pages",
"group": { "group": {

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,7 @@
import deepmerge from "deepmerge"; import deepmerge from "deepmerge";
import { getRequestConfig } from "next-intl/server"; import { getRequestConfig } from "next-intl/server";
import type { TranslationObject } from ".";
import { fallbackLocale, isLocaleSupported } from "."; import { fallbackLocale, isLocaleSupported } from ".";
import type { SupportedLanguage } from "./config"; import type { SupportedLanguage } from "./config";
import { createLanguageMapping } from "./mapping"; import { createLanguageMapping } from "./mapping";
@@ -15,7 +16,7 @@ export default getRequestConfig(async ({ requestLocale }) => {
const typedLocale = currentLocale as SupportedLanguage; const typedLocale = currentLocale as SupportedLanguage;
const languageMap = createLanguageMapping(); const languageMap = createLanguageMapping();
const currentMessages = (await languageMap[typedLocale]()).default; const currentMessages = removeEmptyTranslations((await languageMap[typedLocale]()).default) as TranslationObject;
// Fallback to default locale if the current locales messages if not all messages are present // Fallback to default locale if the current locales messages if not all messages are present
if (currentLocale !== fallbackLocale) { if (currentLocale !== fallbackLocale) {
@@ -31,3 +32,26 @@ export default getRequestConfig(async ({ requestLocale }) => {
messages: currentMessages, messages: currentMessages,
}; };
}); });
const removeEmptyTranslations = (translations: Record<string, unknown>): Record<string, unknown> => {
return Object.entries(translations).reduce(
(acc, [key, value]) => {
if (typeof value !== "string") {
return {
...acc,
[key]: removeEmptyTranslations(value as Record<string, unknown>),
};
}
if (value.trim() === "") {
return acc;
}
return {
...acc,
[key]: value,
};
},
{} as Record<string, unknown>,
);
};

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