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
RUN apk add --no-cache libc6-compat
@@ -6,46 +6,15 @@ RUN apk update
# Set working directory
WORKDIR /app
COPY . .
RUN npm i -g turbo
RUN turbo prune @homarr/nextjs --docker --out-dir ./next-out
RUN turbo prune @homarr/tasks --docker --out-dir ./tasks-out
RUN turbo prune @homarr/websocket --docker --out-dir ./websocket-out
RUN turbo prune @homarr/db --docker --out-dir ./migration-out
RUN turbo prune @homarr/cli --docker --out-dir ./cli-out
# Add lockfile and package.json's of isolated subworkspace
FROM base AS installer
RUN apk add --no-cache libc6-compat curl bash
RUN apk update
WORKDIR /app
COPY . .
# First install the dependencies (as they change less often)
COPY .gitignore .gitignore
COPY --from=builder /app/tasks-out/json/ .
COPY --from=builder /app/websocket-out/json/ .
COPY --from=builder /app/migration-out/json/ .
COPY --from=builder /app/cli-out/json/ .
COPY --from=builder /app/next-out/json/ .
COPY --from=builder /app/pnpm-lock.yaml ./pnpm-lock.yaml
# Is used for postinstall of docs definitions
COPY --from=builder /app/packages/definitions/src/docs ./packages/definitions/src/docs
# Uses the lockfile to install the dependencies
RUN corepack enable pnpm && pnpm install --recursive --frozen-lockfile
# Install sharp for image optimization
RUN corepack enable pnpm && pnpm install sharp -w
# Build the project
COPY --from=builder /app/tasks-out/full/ .
COPY --from=builder /app/websocket-out/full/ .
COPY --from=builder /app/next-out/full/ .
COPY --from=builder /app/migration-out/full/ .
COPY --from=builder /app/cli-out/full/ .
# Copy static data as it is not part of the build
COPY static-data ./static-data
ARG SKIP_ENV_VALIDATION='true'
@@ -69,7 +38,7 @@ RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
# Enable homarr cli
COPY --from=installer --chown=nextjs:nodejs /app/packages/cli/cli.cjs /app/apps/cli/cli.cjs
COPY --from=builder --chown=nextjs:nodejs /app/packages/cli/cli.cjs /app/apps/cli/cli.cjs
RUN echo $'#!/bin/bash\ncd /app/apps/cli && node ./cli.cjs "$@"' > /usr/bin/homarr
RUN chmod +x /usr/bin/homarr
@@ -83,20 +52,20 @@ RUN mkdir -p /var/cache/nginx && chown -R nextjs:nodejs /var/cache/nginx && \
mkdir -p /etc/nginx/templates /etc/nginx/ssl/certs && chown -R nextjs:nodejs /etc/nginx
USER nextjs
COPY --from=installer /app/apps/nextjs/next.config.mjs .
COPY --from=installer /app/apps/nextjs/package.json .
COPY --from=builder /app/apps/nextjs/next.config.mjs .
COPY --from=builder /app/apps/nextjs/package.json .
COPY --from=installer --chown=nextjs:nodejs /app/apps/tasks/tasks.cjs ./apps/tasks/tasks.cjs
COPY --from=installer --chown=nextjs:nodejs /app/apps/websocket/wssServer.cjs ./apps/websocket/wssServer.cjs
COPY --from=installer --chown=nextjs:nodejs /app/node_modules/better-sqlite3/build/Release/better_sqlite3.node /app/build/better_sqlite3.node
COPY --from=builder --chown=nextjs:nodejs /app/apps/tasks/tasks.cjs ./apps/tasks/tasks.cjs
COPY --from=builder --chown=nextjs:nodejs /app/apps/websocket/wssServer.cjs ./apps/websocket/wssServer.cjs
COPY --from=builder --chown=nextjs:nodejs /app/node_modules/better-sqlite3/build/Release/better_sqlite3.node /app/build/better_sqlite3.node
COPY --from=installer --chown=nextjs:nodejs /app/packages/db/migrations ./db/migrations
COPY --from=builder --chown=nextjs:nodejs /app/packages/db/migrations ./db/migrations
# Automatically leverage output traces to reduce image size
# https://nextjs.org/docs/advanced-features/output-file-tracing
COPY --from=installer --chown=nextjs:nodejs /app/apps/nextjs/.next/standalone ./
COPY --from=installer --chown=nextjs:nodejs /app/apps/nextjs/.next/static ./apps/nextjs/.next/static
COPY --from=installer --chown=nextjs:nodejs /app/apps/nextjs/public ./apps/nextjs/public
COPY --from=builder --chown=nextjs:nodejs /app/apps/nextjs/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/apps/nextjs/.next/static ./apps/nextjs/.next/static
COPY --from=builder --chown=nextjs:nodejs /app/apps/nextjs/public ./apps/nextjs/public
COPY --chown=nextjs:nodejs scripts/run.sh ./run.sh
COPY --chown=nextjs:nodejs scripts/generateEncryptionKey.js ./generateEncryptionKey.js
COPY --chown=nextjs:nodejs packages/redis/redis.conf /app/redis.conf

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

@@ -11,7 +11,7 @@ export const authorizeWithBasicCredentialsAsync = async (
credentials: z.infer<typeof validation.user.signIn>,
) => {
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) {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -6,7 +6,7 @@ import { filteringStatusSchema, statsResponseSchema, statusResponseSchema } from
export class AdGuardHomeIntegration extends Integration implements DnsHoleSummaryIntegration {
public async getSummaryAsync(): Promise<DnsHoleSummary> {
const statsResponse = await fetch(`${this.integration.url}/control/stats`, {
const statsResponse = await fetch(this.url("/control/stats"), {
headers: {
Authorization: `Basic ${this.getAuthorizationHeaderValue()}`,
},
@@ -18,7 +18,7 @@ export class AdGuardHomeIntegration extends Integration implements DnsHoleSummar
);
}
const statusResponse = await fetch(`${this.integration.url}/control/status`, {
const statusResponse = await fetch(this.url("/control/status"), {
headers: {
Authorization: `Basic ${this.getAuthorizationHeaderValue()}`,
},
@@ -30,7 +30,7 @@ export class AdGuardHomeIntegration extends Integration implements DnsHoleSummar
);
}
const filteringStatusResponse = await fetch(`${this.integration.url}/control/filtering/status`, {
const filteringStatusResponse = await fetch(this.url("/control/filtering/status"), {
headers: {
Authorization: `Basic ${this.getAuthorizationHeaderValue()}`,
},
@@ -86,7 +86,7 @@ export class AdGuardHomeIntegration extends Integration implements DnsHoleSummar
public async testConnectionAsync(): Promise<void> {
await super.handleTestConnectionResponseAsync({
queryFunctionAsync: async () => {
return await fetch(`${this.integration.url}/control/status`, {
return await fetch(this.url("/control/status"), {
headers: {
Authorization: `Basic ${this.getAuthorizationHeaderValue()}`,
},
@@ -106,7 +106,7 @@ export class AdGuardHomeIntegration extends Integration implements DnsHoleSummar
}
public async enableAsync(): Promise<void> {
const response = await fetch(`${this.integration.url}/control/protection`, {
const response = await fetch(this.url("/control/protection"), {
method: "POST",
headers: {
"Content-Type": "application/json",
@@ -124,7 +124,7 @@ export class AdGuardHomeIntegration extends Integration implements DnsHoleSummar
}
public async disableAsync(duration = 0): Promise<void> {
const response = await fetch(`${this.integration.url}/control/protection`, {
const response = await fetch(this.url("/control/protection"), {
method: "POST",
headers: {
"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 { logger } from "@homarr/log";
import type { TranslationObject } from "@homarr/translation";
@@ -29,6 +29,19 @@ export abstract class Integration {
return secret.value;
}
protected url(path: `/${string}`, queryParams?: Record<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
* @throws {IntegrationTestConnectionError} if the connection fails

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,3 +1,4 @@
import { formatError } from "pretty-print-error";
import SuperJSON from "superjson";
import { hashObjectBase64, Stopwatch } from "@homarr/common";
@@ -95,7 +96,7 @@ export const createRequestIntegrationJobHandler = <
);
} catch (error) {
logger.error(
`Failed to run integration job integration=${integrationId} inputHash='${inputHash}' error=${error as string}`,
`Failed to run integration job integration=${integrationId} inputHash='${inputHash}' error=${formatError(error)}`,
);
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -4,7 +4,7 @@ import type { SearchGroup } from "./group";
export type SearchMode = {
modeKey: keyof TranslationObject["search"]["mode"];
character: string;
character: string | undefined;
} & (
| {
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 { 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>;
};
import { contextSpecificActionsSearchGroups } from "./context-specific-group";
import { globalCommandGroup } from "./global-group";
export const commandMode = {
modeKey: "command",
character: ">",
groups: [
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);
},
}),
],
groups: [contextSpecificActionsSearchGroups, globalCommandGroup],
} 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 { commandMode } from "./command";
import { externalMode } from "./external";
import { homeMode } from "./home";
import { pageMode } from "./page";
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 = {
modeKey: "help",
@@ -82,4 +83,4 @@ const helpMode = {
},
} 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",
"deepmerge": "4.3.1",
"mantine-react-table": "2.0.0-beta.7",
"next": "^14.2.18",
"next-intl": "3.25.3",
"next": "^14.2.20",
"next-intl": "3.26.0",
"react": "^18.3.1"
},
"devDependencies": {
"@homarr/eslint-config": "workspace:^0.2.0",
"@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0",
"eslint": "^9.15.0",
"eslint": "^9.16.0",
"typescript": "^5.7.2"
}
}

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": {
"label": "Automation ID"
}
},
"spotlightAction": {
"run": "Run {name}"
}
},
"calendar": {
@@ -2450,6 +2453,9 @@
"command": {
"help": "Activate command mode",
"group": {
"localCommand": {
"title": "Local commands"
},
"globalCommand": {
"title": "Global commands",
"option": {
@@ -2559,6 +2565,13 @@
}
}
},
"home": {
"group": {
"local": {
"title": "Local results"
}
}
},
"page": {
"help": "Search for pages",
"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 { getRequestConfig } from "next-intl/server";
import type { TranslationObject } from ".";
import { fallbackLocale, isLocaleSupported } from ".";
import type { SupportedLanguage } from "./config";
import { createLanguageMapping } from "./mapping";
@@ -15,7 +16,7 @@ export default getRequestConfig(async ({ requestLocale }) => {
const typedLocale = currentLocale as SupportedLanguage;
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
if (currentLocale !== fallbackLocale) {
@@ -31,3 +32,26 @@ export default getRequestConfig(async ({ requestLocale }) => {
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