Compare commits
62
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
024ec9235d | ||
|
|
58fdea0245 | ||
|
|
4ead238462 | ||
|
|
6305c74f90 | ||
|
|
0780080681 | ||
|
|
aa15a705d7 | ||
|
|
cc70175125 | ||
|
|
bc5e25e9cd | ||
|
|
d43557e165 | ||
|
|
b6c96f73a5 | ||
|
|
ee8831affe | ||
|
|
6e21863d04 | ||
|
|
ca39ca66f0 | ||
|
|
cc7d53e84e | ||
|
|
99c409cbb7 | ||
|
|
1fcf9d0146 | ||
|
|
a234fc844e | ||
|
|
168b587c18 | ||
|
|
dc12a82805 | ||
|
|
f5076454cd | ||
|
|
aeb681a858 | ||
|
|
56b57ad171 | ||
|
|
91e1cef611 | ||
|
|
e53b06e8c2 | ||
|
|
0ebf4bc55e | ||
|
|
b8a155dd08 | ||
|
|
63786eed06 | ||
|
|
598f0047eb | ||
|
|
eacda4c397 | ||
|
|
5cb726f793 | ||
|
|
e8d29420e0 | ||
|
|
23c7d0bbf0 | ||
|
|
75aefcb0d5 | ||
|
|
b52a8a7935 | ||
|
|
6d90209e00 | ||
|
|
8f0945f51a | ||
|
|
5b04b93f23 | ||
|
|
c8228b920c | ||
|
|
abf7942fb7 | ||
|
|
50746eb3e4 | ||
|
|
76abfca11b | ||
|
|
fcab6a347d | ||
|
|
c91aae53ac | ||
|
|
4943e7b851 | ||
|
|
88c423fca6 | ||
|
|
f238663ec7 | ||
|
|
e9870e7ee7 | ||
|
|
9c0a0ced04 | ||
|
|
4c27635066 | ||
|
|
3d66ff9148 | ||
|
|
ec257e5f60 | ||
|
|
ef24370a8c | ||
|
|
1fa0fc50c5 | ||
|
|
e9a1dae857 | ||
|
|
e37279fe80 | ||
|
|
82c6ce7b77 | ||
|
|
555212e2dc | ||
|
|
c831539772 | ||
|
|
3e2ffbd7bf | ||
|
|
22d7f676eb | ||
|
|
23bf262f4e | ||
|
|
ecae80d741 |
No files matched your search
+2
-1
@@ -6,4 +6,5 @@ README.md
|
||||
.next
|
||||
.git
|
||||
dev
|
||||
.build
|
||||
.build
|
||||
e2e
|
||||
+8
-5
@@ -4,6 +4,14 @@
|
||||
# This file will be committed to version control, so make sure not to have any secrets in it.
|
||||
# If you are cloning this repo, create a copy of this file named `.env` and populate it with your secrets.
|
||||
|
||||
# The below secret is not used anywhere but required for Auth.js (Would encrypt JWTs and Mail hashes, both not used)
|
||||
AUTH_SECRET="supersecret"
|
||||
|
||||
# The below secret is used to encrypt integration secrets in the database.
|
||||
# It should be a 32-byte string, generated by running `openssl rand -hex 32` on Unix
|
||||
# or starting the project without any (which will show a randomly generated one).
|
||||
SECRET_ENCRYPTION_KEY=0000000000000000000000000000000000000000000000000000000000000000
|
||||
|
||||
# This is how you can use the sqlite driver:
|
||||
DB_DRIVER='better-sqlite3'
|
||||
DB_URL='FULL_PATH_TO_YOUR_SQLITE_DB_FILE'
|
||||
@@ -20,11 +28,6 @@ DB_URL='FULL_PATH_TO_YOUR_SQLITE_DB_FILE'
|
||||
# DB_PASSWORD='password'
|
||||
# DB_NAME='name-of-database'
|
||||
|
||||
|
||||
# You can generate the secret via 'openssl rand -base64 32' on Unix
|
||||
# @see https://next-auth.js.org/configuration/options#secret
|
||||
AUTH_SECRET='supersecret'
|
||||
|
||||
TURBO_TELEMETRY_DISABLED=1
|
||||
|
||||
# Configure logging to use winston logger
|
||||
|
||||
@@ -91,6 +91,8 @@ jobs:
|
||||
network: host
|
||||
env:
|
||||
SKIP_ENV_VALIDATION: true
|
||||
- name: Install playwright browsers
|
||||
run: pnpm exec playwright install chromium
|
||||
- name: Run E2E Tests
|
||||
shell: bash
|
||||
run: pnpm test:e2e
|
||||
|
||||
@@ -13,11 +13,6 @@ on:
|
||||
required: false
|
||||
default: true
|
||||
description: Send notifications
|
||||
push-image:
|
||||
type: boolean
|
||||
required: false
|
||||
default: true
|
||||
description: Push Docker Image
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
@@ -112,7 +107,6 @@ jobs:
|
||||
NEXT_VERSION: ${{ needs.release.outputs.version }}
|
||||
DEPLOY_LATEST: ${{ github.ref_name == 'main' }}
|
||||
DEPLOY_BETA: ${{ github.ref_name == 'beta' }}
|
||||
PUSH_IMAGE: ${{ github.event_name != 'workflow_dispatch' || github.events.inputs.push-image == true }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
@@ -143,13 +137,13 @@ jobs:
|
||||
${{ env.DEPLOY_LATEST == 'true' && 'type=raw,value=latest' || null }}
|
||||
${{ env.DEPLOY_BETA == 'true' && 'type=raw,value=beta' || null }}
|
||||
type=raw,value=${{ env.NEXT_VERSION }}
|
||||
- name: Build and maybe push
|
||||
- name: Build and push
|
||||
id: buildPushAction
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
platforms: linux/amd64,linux/arm64
|
||||
context: .
|
||||
push: ${{ env.PUSH_IMAGE }}
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
network: host
|
||||
@@ -160,4 +154,4 @@ jobs:
|
||||
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
|
||||
uses: Ilshidur/action-discord@master
|
||||
with:
|
||||
args: "Deployment of image has completed for branch ${{ github.ref_name }}. Image ID is '${{ steps.buildPushAction.outputs.imageid }}'. ${{ env.PUSH_IMAGE == 'true' && '' || 'This was a dry run' }}"
|
||||
args: "Deployment of image has completed for branch ${{ github.ref_name }}. Image ID is '${{ steps.buildPushAction.outputs.imageid }}'."
|
||||
@@ -58,6 +58,8 @@ apps/websocket/wssServer.cjs
|
||||
apps/nextjs/.million/
|
||||
packages/cli/cli.cjs
|
||||
|
||||
# e2e mounts
|
||||
e2e/shared/tmp
|
||||
|
||||
#personal backgrounds
|
||||
apps/nextjs/public/images/background.png
|
||||
Vendored
+1
-1
@@ -39,5 +39,5 @@
|
||||
"i18n-ally.localesPaths": [
|
||||
"packages/translation/src/lang",
|
||||
],
|
||||
"i18n-ally.keystyle": "auto",
|
||||
"i18n-ally.keystyle": "nested",
|
||||
}
|
||||
@@ -1,3 +1,53 @@
|
||||
## [1.0.0-beta.11](https://github.com/homarr-labs/homarr/compare/v1.0.0-beta.10...v1.0.0-beta.11) (2025-01-03)
|
||||
|
||||
### Features
|
||||
|
||||
* remove import beta badge ([#1829](https://github.com/homarr-labs/homarr/issues/1829)) ([6305c74](https://github.com/homarr-labs/homarr/commit/6305c74f90d943722c915ffbf54b5fd7e68a7dca))
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **deps:** update dependency zod-form-data to ^2.0.5 ([#1822](https://github.com/homarr-labs/homarr/issues/1822)) ([a234fc8](https://github.com/homarr-labs/homarr/commit/a234fc844e5edcef9935efb0725b6ccceadfe69d))
|
||||
* **deps:** update tanstack-query monorepo to ^5.62.12 ([#1843](https://github.com/homarr-labs/homarr/issues/1843)) ([bc5e25e](https://github.com/homarr-labs/homarr/commit/bc5e25e9cd3269a3d8cc8ef521ae75aff50088be))
|
||||
* **icons:** picker does not allow multiple words ([#1827](https://github.com/homarr-labs/homarr/issues/1827)) ([6e21863](https://github.com/homarr-labs/homarr/commit/6e21863d04f7d0598e7e7ca3fb707f9beb09d7ef))
|
||||
* login page in light mode is always black ([#1821](https://github.com/homarr-labs/homarr/issues/1821)) ([cc7d53e](https://github.com/homarr-labs/homarr/commit/cc7d53e84ec8c2dd3698a178ca6f35099194b5d7))
|
||||
* prowlarr integration indexers status changed from id to indexerId ([#1837](https://github.com/homarr-labs/homarr/issues/1837)) ([ee8831a](https://github.com/homarr-labs/homarr/commit/ee8831affe34a33b80a0993aa538c2b16c035f11))
|
||||
|
||||
## [1.0.0-beta.10](https://github.com/homarr-labs/homarr/compare/v1.0.0-beta.9...v1.0.0-beta.10) (2024-12-31)
|
||||
|
||||
### Features
|
||||
|
||||
* add request media ([#1811](https://github.com/homarr-labs/homarr/issues/1811)) ([91e1cef](https://github.com/homarr-labs/homarr/commit/91e1cef611a47d57b42c91c0c6d49fcf2e6a10bb))
|
||||
* **docker:** add support for pgid and puid env variables ([#1759](https://github.com/homarr-labs/homarr/issues/1759)) ([aeb681a](https://github.com/homarr-labs/homarr/commit/aeb681a85839e31284e8f1566adc4191a68ea385))
|
||||
* **integration:** add search engine creation ([#1816](https://github.com/homarr-labs/homarr/issues/1816)) ([f507645](https://github.com/homarr-labs/homarr/commit/f5076454cd644a99439b697f24bb0b1f871b2205))
|
||||
* **widget:** add minecraft server status widget ([#1801](https://github.com/homarr-labs/homarr/issues/1801)) ([0ebf4bc](https://github.com/homarr-labs/homarr/commit/0ebf4bc55e62caf5917340496abb350d564df255))
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **deps:** update dependency octokit to ^4.0.3 ([#1806](https://github.com/homarr-labs/homarr/issues/1806)) ([75aefcb](https://github.com/homarr-labs/homarr/commit/75aefcb0d5210a53e8a00b675dbbce847aa8add4))
|
||||
* **deps:** update dependency trpc-to-openapi to ^2.1.1 ([#1788](https://github.com/homarr-labs/homarr/issues/1788)) ([4943e7b](https://github.com/homarr-labs/homarr/commit/4943e7b851cd29291b5e8f2f9d0e084b4deeffc0))
|
||||
* **deps:** update dependency typescript-eslint to ^8.19.0 ([#1814](https://github.com/homarr-labs/homarr/issues/1814)) ([598f004](https://github.com/homarr-labs/homarr/commit/598f0047eb13d6d0d6b68c9032a7a6648f4b6aa3))
|
||||
* **deps:** update tiptap monorepo to v2.11.0 ([#1813](https://github.com/homarr-labs/homarr/issues/1813)) ([eacda4c](https://github.com/homarr-labs/homarr/commit/eacda4c397cb384a35e9eec0305f9b26f2a22a4a))
|
||||
* **docker:** replace anonymous docker volume with env variable for encrypting secrets ([#1809](https://github.com/homarr-labs/homarr/issues/1809)) ([56b57ad](https://github.com/homarr-labs/homarr/commit/56b57ad171da170c2d8edd6112092cef0b586852))
|
||||
* **health-monitoring:** add months to uptime, fix status update ([#1792](https://github.com/homarr-labs/homarr/issues/1792)) ([c91aae5](https://github.com/homarr-labs/homarr/commit/c91aae53acf27eab96a9150281f98a2156e9e2af))
|
||||
* **media-server:** redesign widget ([#1775](https://github.com/homarr-labs/homarr/issues/1775)) ([23c7d0b](https://github.com/homarr-labs/homarr/commit/23c7d0bbf01215b680f346e7eef8072d8b9db09e))
|
||||
|
||||
## [1.0.0-beta.9](https://github.com/homarr-labs/homarr/compare/v1.0.0-beta.8...v1.0.0-beta.9) (2024-12-27)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **deps:** update nextjs monorepo to ^14.2.22 ([#1787](https://github.com/homarr-labs/homarr/issues/1787)) ([3d66ff9](https://github.com/homarr-labs/homarr/commit/3d66ff914883a665c0329c8fc4fd087f4d220692))
|
||||
* **deps:** update tanstack-query monorepo to ^5.62.11 ([#1791](https://github.com/homarr-labs/homarr/issues/1791)) ([e9870e7](https://github.com/homarr-labs/homarr/commit/e9870e7ee78227d3be773d9d93aa732760cc8e21))
|
||||
|
||||
## [1.0.0-beta.8](https://github.com/homarr-labs/homarr/compare/v1.0.0-beta.7...v1.0.0-beta.8) (2024-12-25)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **deps:** update dependency @ctrl/deluge to ^7.1.0 ([#1776](https://github.com/homarr-labs/homarr/issues/1776)) ([ecae80d](https://github.com/homarr-labs/homarr/commit/ecae80d741213f12ff08f87992cc4d906cbe0085))
|
||||
* **deps:** update dependency @ctrl/transmission to ^7.2.0 ([#1777](https://github.com/homarr-labs/homarr/issues/1777)) ([3e2ffbd](https://github.com/homarr-labs/homarr/commit/3e2ffbd7bf2793ff58136ed75b38e0d96b4a5ca8))
|
||||
* **deps:** update dependency drizzle-orm to ^0.38.3 ([#1783](https://github.com/homarr-labs/homarr/issues/1783)) ([82c6ce7](https://github.com/homarr-labs/homarr/commit/82c6ce7b77023881bd4c74fb43437e777f9b219e))
|
||||
* **deps:** update dependency drizzle-zod to ^0.6.1 ([#1782](https://github.com/homarr-labs/homarr/issues/1782)) ([555212e](https://github.com/homarr-labs/homarr/commit/555212e2dc82c4b71b8c25a5f20964b3441b5ab9))
|
||||
* oldmarr import user casing wrong ([#1784](https://github.com/homarr-labs/homarr/issues/1784)) ([e37279f](https://github.com/homarr-labs/homarr/commit/e37279fe8071773f1a9773e3233bc81cbb336798))
|
||||
|
||||
## [1.0.0-beta.7](https://github.com/homarr-labs/homarr/compare/v1.0.0-beta.6...v1.0.0-beta.7) (2024-12-24)
|
||||
|
||||
### Features
|
||||
|
||||
+20
-30
@@ -25,51 +25,41 @@ RUN corepack enable pnpm && pnpm build
|
||||
FROM base AS runner
|
||||
WORKDIR /app
|
||||
|
||||
# gettext is required for envsubst
|
||||
RUN apk add --no-cache redis nginx bash gettext su-exec
|
||||
# gettext is required for envsubst, openssl for generating AUTH_SECRET, su-exec for running application as non-root
|
||||
RUN apk add --no-cache redis nginx bash gettext su-exec openssl
|
||||
RUN mkdir /appdata
|
||||
VOLUME /appdata
|
||||
RUN mkdir /secrets
|
||||
VOLUME /secrets
|
||||
|
||||
|
||||
|
||||
RUN addgroup --system --gid 1001 nodejs
|
||||
RUN adduser --system --uid 1001 nextjs
|
||||
|
||||
# Enable homarr cli
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/packages/cli/cli.cjs /app/apps/cli/cli.cjs
|
||||
COPY --from=builder /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
|
||||
|
||||
# Don't run production as root
|
||||
RUN chown -R nextjs:nodejs /secrets
|
||||
RUN mkdir -p /var/cache/nginx && chown -R nextjs:nodejs /var/cache/nginx && \
|
||||
mkdir -p /var/log/nginx && chown -R nextjs:nodejs /var/log/nginx && \
|
||||
mkdir -p /var/lib/nginx && chown -R nextjs:nodejs /var/lib/nginx && \
|
||||
touch /run/nginx/nginx.pid && chown -R nextjs:nodejs /run/nginx/nginx.pid && \
|
||||
mkdir -p /etc/nginx/templates /etc/nginx/ssl/certs && chown -R nextjs:nodejs /etc/nginx
|
||||
RUN mkdir -p /var/cache/nginx && \
|
||||
mkdir -p /var/log/nginx && \
|
||||
mkdir -p /var/lib/nginx && \
|
||||
touch /run/nginx/nginx.pid && \
|
||||
mkdir -p /etc/nginx/templates /etc/nginx/ssl/certs
|
||||
|
||||
COPY --from=builder /app/apps/nextjs/next.config.mjs .
|
||||
COPY --from=builder /app/apps/nextjs/package.json .
|
||||
|
||||
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=builder /app/apps/tasks/tasks.cjs ./apps/tasks/tasks.cjs
|
||||
COPY --from=builder /app/apps/websocket/wssServer.cjs ./apps/websocket/wssServer.cjs
|
||||
COPY --from=builder /app/node_modules/better-sqlite3/build/Release/better_sqlite3.node /app/build/better_sqlite3.node
|
||||
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/packages/db/migrations ./db/migrations
|
||||
COPY --from=builder /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=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 scripts/entrypoint.sh ./entrypoint.sh
|
||||
RUN chmod +x ./entrypoint.sh
|
||||
COPY --chown=nextjs:nodejs scripts/generateRandomSecureKey.js ./generateRandomSecureKey.js
|
||||
COPY --chown=nextjs:nodejs packages/redis/redis.conf /app/redis.conf
|
||||
COPY --chown=nextjs:nodejs nginx.conf /etc/nginx/templates/nginx.conf
|
||||
COPY --from=builder /app/apps/nextjs/.next/standalone ./
|
||||
COPY --from=builder /app/apps/nextjs/.next/static ./apps/nextjs/.next/static
|
||||
COPY --from=builder /app/apps/nextjs/public ./apps/nextjs/public
|
||||
COPY scripts/run.sh ./run.sh
|
||||
COPY --chmod=777 scripts/entrypoint.sh ./entrypoint.sh
|
||||
COPY packages/redis/redis.conf /app/redis.conf
|
||||
COPY nginx.conf /etc/nginx/templates/nginx.conf
|
||||
|
||||
|
||||
ENV DB_URL='/appdata/db/db.sqlite'
|
||||
@@ -78,4 +68,4 @@ ENV DB_DRIVER='better-sqlite3'
|
||||
ENV AUTH_PROVIDERS='credentials'
|
||||
|
||||
ENTRYPOINT [ "/app/entrypoint.sh" ]
|
||||
CMD ["sh", "run.sh"]
|
||||
CMD ["sh", "run.sh"]
|
||||
@@ -1,6 +1,7 @@
|
||||
// Importing env files here to validate on build
|
||||
import "@homarr/auth/env.mjs";
|
||||
import "@homarr/db/env.mjs";
|
||||
import "@homarr/common/env.mjs";
|
||||
|
||||
import MillionLint from "@million/lint";
|
||||
import createNextIntlPlugin from "next-intl/plugin";
|
||||
|
||||
@@ -47,9 +47,9 @@
|
||||
"@million/lint": "1.0.14",
|
||||
"@t3-oss/env-nextjs": "^0.11.1",
|
||||
"@tabler/icons-react": "^3.26.0",
|
||||
"@tanstack/react-query": "^5.62.10",
|
||||
"@tanstack/react-query-devtools": "^5.62.10",
|
||||
"@tanstack/react-query-next-experimental": "5.62.10",
|
||||
"@tanstack/react-query": "^5.62.12",
|
||||
"@tanstack/react-query-devtools": "^5.62.12",
|
||||
"@tanstack/react-query-next-experimental": "5.62.12",
|
||||
"@trpc/client": "next",
|
||||
"@trpc/next": "next",
|
||||
"@trpc/react-query": "next",
|
||||
@@ -65,7 +65,7 @@
|
||||
"glob": "^11.0.0",
|
||||
"jotai": "^2.11.0",
|
||||
"mantine-react-table": "2.0.0-beta.7",
|
||||
"next": "^14.2.21",
|
||||
"next": "^14.2.22",
|
||||
"postcss-preset-mantine": "^1.17.0",
|
||||
"prismjs": "^1.29.0",
|
||||
"react": "^19.0.0",
|
||||
@@ -82,12 +82,12 @@
|
||||
"@homarr/prettier-config": "workspace:^0.1.0",
|
||||
"@homarr/tsconfig": "workspace:^0.1.0",
|
||||
"@types/chroma-js": "2.4.5",
|
||||
"@types/node": "^22.10.2",
|
||||
"@types/node": "^22.10.5",
|
||||
"@types/prismjs": "^1.26.5",
|
||||
"@types/react": "^19.0.2",
|
||||
"@types/react-dom": "^19.0.2",
|
||||
"@types/swagger-ui-react": "^4.18.3",
|
||||
"concurrently": "^9.1.0",
|
||||
"concurrently": "^9.1.2",
|
||||
"eslint": "^9.17.0",
|
||||
"node-loader": "^2.1.0",
|
||||
"prettier": "^3.4.2",
|
||||
|
||||
@@ -57,7 +57,7 @@ export default async function InviteUsagePage({ params, searchParams }: InviteUs
|
||||
{t("subtitle")}
|
||||
</Text>
|
||||
</Stack>
|
||||
<Card bg="dark.8" w={64 * 6} maw="90vw">
|
||||
<Card withBorder w={64 * 6} maw="90vw">
|
||||
<RegistrationForm invite={invite} />
|
||||
</Card>
|
||||
<Text size="xs" c="gray.5" ta="center">
|
||||
|
||||
@@ -35,7 +35,7 @@ export default async function Login({ searchParams }: LoginProps) {
|
||||
{t("subtitle")}
|
||||
</Text>
|
||||
</Stack>
|
||||
<Card bg="dark.8" w={64 * 6} maw="90vw">
|
||||
<Card withBorder w={64 * 6} maw="90vw">
|
||||
<LoginForm
|
||||
providers={env.AUTH_PROVIDERS}
|
||||
oidcClientName={env.AUTH_OIDC_CLIENT_NAME}
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import { Affix, Button, Group, Menu } from "@mantine/core";
|
||||
import { Affix, Button, Menu } from "@mantine/core";
|
||||
import { IconCategoryPlus, IconChevronDown, IconFileImport } from "@tabler/icons-react";
|
||||
|
||||
import { useModalAction } from "@homarr/modals";
|
||||
import { AddBoardModal, ImportBoardModal } from "@homarr/modals-collection";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import { BetaBadge } from "@homarr/ui";
|
||||
|
||||
export const CreateBoardButton = () => {
|
||||
const t = useI18n();
|
||||
@@ -26,10 +25,7 @@ export const CreateBoardButton = () => {
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown>
|
||||
<Menu.Item onClick={openImportModal} leftSection={<IconFileImport size="1rem" />}>
|
||||
<Group>
|
||||
{t("board.action.oldImport.label")}
|
||||
<BetaBadge size="xs" />
|
||||
</Group>
|
||||
{t("board.action.oldImport.label")}
|
||||
</Menu.Item>
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
|
||||
@@ -3,13 +3,13 @@
|
||||
import { useCallback } from "react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Alert, Button, Fieldset, Group, SegmentedControl, Stack, Text, TextInput } from "@mantine/core";
|
||||
import { Alert, Button, Checkbox, Fieldset, Group, SegmentedControl, Stack, Text, TextInput } from "@mantine/core";
|
||||
import { IconInfoCircle } from "@tabler/icons-react";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { revalidatePathActionAsync } from "@homarr/common/client";
|
||||
import type { IntegrationKind, IntegrationSecretKind } from "@homarr/definitions";
|
||||
import { getAllSecretKindOptions, getIntegrationName } from "@homarr/definitions";
|
||||
import { getAllSecretKindOptions, getIntegrationName, integrationDefs } from "@homarr/definitions";
|
||||
import type { UseFormReturnType } from "@homarr/form";
|
||||
import { useZodForm } from "@homarr/form";
|
||||
import { convertIntegrationTestConnectionError } from "@homarr/integrations/client";
|
||||
@@ -38,6 +38,7 @@ export const NewIntegrationForm = ({ searchParams }: NewIntegrationFormProps) =>
|
||||
kind,
|
||||
value: "",
|
||||
})),
|
||||
attemptSearchEngineCreation: true,
|
||||
},
|
||||
});
|
||||
const { mutateAsync, isPending } = clientApi.integration.create.useMutation();
|
||||
@@ -78,6 +79,8 @@ export const NewIntegrationForm = ({ searchParams }: NewIntegrationFormProps) =>
|
||||
);
|
||||
};
|
||||
|
||||
const supportsSearchEngine = integrationDefs[searchParams.kind].category.flat().includes("search");
|
||||
|
||||
return (
|
||||
<form onSubmit={form.onSubmit((value) => void handleSubmitAsync(value))}>
|
||||
<Stack>
|
||||
@@ -104,6 +107,16 @@ export const NewIntegrationForm = ({ searchParams }: NewIntegrationFormProps) =>
|
||||
</Stack>
|
||||
</Fieldset>
|
||||
|
||||
{supportsSearchEngine && (
|
||||
<Checkbox
|
||||
label={t("integration.field.attemptSearchEngineCreation.label")}
|
||||
description={t("integration.field.attemptSearchEngineCreation.description", {
|
||||
kind: getIntegrationName(searchParams.kind),
|
||||
})}
|
||||
{...form.getInputProps("attemptSearchEngineCreation", { type: "checkbox" })}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Group justify="end" align="center">
|
||||
<Button variant="default" component={Link} href="/manage/integrations">
|
||||
{t("common.action.backToOverview")}
|
||||
|
||||
@@ -3,10 +3,6 @@ import { z } from "zod";
|
||||
|
||||
export const env = createEnv({
|
||||
shared: {
|
||||
VERCEL_URL: z
|
||||
.string()
|
||||
.optional()
|
||||
.transform((url) => (url ? `https://${url}` : undefined)),
|
||||
PORT: z.coerce.number().default(3000),
|
||||
NODE_ENV: z.enum(["development", "production", "test"]).default("development"),
|
||||
},
|
||||
@@ -30,7 +26,6 @@ export const env = createEnv({
|
||||
* Destructure all variables from `process.env` to make sure they aren't tree-shaken away.
|
||||
*/
|
||||
runtimeEnv: {
|
||||
VERCEL_URL: process.env.VERCEL_URL,
|
||||
PORT: process.env.PORT,
|
||||
NODE_ENV: process.env.NODE_ENV,
|
||||
DOCKER_HOSTNAMES: process.env.DOCKER_HOSTNAMES,
|
||||
|
||||
@@ -44,7 +44,7 @@
|
||||
"@homarr/eslint-config": "workspace:^0.2.0",
|
||||
"@homarr/prettier-config": "workspace:^0.1.0",
|
||||
"@homarr/tsconfig": "workspace:^0.1.0",
|
||||
"@types/node": "^22.10.2",
|
||||
"@types/node": "^22.10.5",
|
||||
"dotenv-cli": "^8.0.0",
|
||||
"eslint": "^9.17.0",
|
||||
"prettier": "^3.4.2",
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
import { chromium } from "playwright";
|
||||
import { GenericContainer } from "testcontainers";
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { OnboardingActions } from "./shared/actions/onboarding-actions";
|
||||
import { createHomarrContainer, withLogs } from "./shared/create-homarr-container";
|
||||
import { createSqliteDbFileAsync } from "./shared/e2e-db";
|
||||
|
||||
const defaultCredentials = {
|
||||
username: "admin",
|
||||
password: "password",
|
||||
email: "admin@homarr.dev",
|
||||
group: "lldap_admin",
|
||||
};
|
||||
|
||||
const ldapBase = "dc=example,dc=com";
|
||||
|
||||
describe("LLDAP authorization", () => {
|
||||
test("Authorize with LLDAP successfully", async () => {
|
||||
// Arrange
|
||||
const lldapContainer = await createLldapContainer().start();
|
||||
const { db, localMountPath } = await createSqliteDbFileAsync();
|
||||
const homarrContainer = await createHomarrContainer({
|
||||
environment: {
|
||||
AUTH_PROVIDERS: "ldap",
|
||||
AUTH_LDAP_URI: `ldap://host.docker.internal:${lldapContainer.getMappedPort(3890)}`,
|
||||
AUTH_LDAP_BASE: ldapBase,
|
||||
AUTH_LDAP_BIND_DN: `uid=${defaultCredentials.username},ou=People,${ldapBase}`,
|
||||
AUTH_LDAP_BIND_PASSWORD: defaultCredentials.password,
|
||||
},
|
||||
mounts: {
|
||||
"/appdata": localMountPath,
|
||||
},
|
||||
}).start();
|
||||
|
||||
const browser = await chromium.launch();
|
||||
const context = await browser.newContext();
|
||||
const page = await context.newPage();
|
||||
|
||||
const onboardingActions = new OnboardingActions(page, db);
|
||||
await onboardingActions.skipOnboardingAsync({
|
||||
group: defaultCredentials.group,
|
||||
});
|
||||
|
||||
// Act
|
||||
await page.goto(`http://${homarrContainer.getHost()}:${homarrContainer.getMappedPort(7575)}/auth/login`);
|
||||
await page.getByLabel("Username").fill(defaultCredentials.username);
|
||||
await page.getByLabel("Password").fill(defaultCredentials.password);
|
||||
await page.locator("css=button[type='submit']").click();
|
||||
|
||||
// Assert
|
||||
await page.waitForURL(`http://${homarrContainer.getHost()}:${homarrContainer.getMappedPort(7575)}`);
|
||||
const users = await db.query.users.findMany({
|
||||
with: {
|
||||
groups: {
|
||||
with: {
|
||||
group: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(users).toHaveLength(1);
|
||||
const user = users[0]!;
|
||||
expect(user).toEqual(
|
||||
expect.objectContaining({
|
||||
name: defaultCredentials.username,
|
||||
email: defaultCredentials.email,
|
||||
provider: "ldap",
|
||||
}),
|
||||
);
|
||||
|
||||
const groups = user.groups.map((g) => g.group.name);
|
||||
expect(groups).toContain(defaultCredentials.group);
|
||||
|
||||
// Cleanup
|
||||
await browser.close();
|
||||
await homarrContainer.stop();
|
||||
await lldapContainer.stop();
|
||||
}, 120_000);
|
||||
});
|
||||
|
||||
const createLldapContainer = () => {
|
||||
return withLogs(
|
||||
new GenericContainer("lldap/lldap:stable").withExposedPorts(3890).withEnvironment({
|
||||
LLDAP_JWT_SECRET: "REPLACE_WITH_RANDOM",
|
||||
LLDAP_KEY_SEED: "REPLACE_WITH_RANDOM",
|
||||
LLDAP_LDAP_BASE_DN: ldapBase,
|
||||
LLDAP_LDAP_USER_PASS: defaultCredentials.password,
|
||||
LLDAP_LDAP_USER_EMAIL: defaultCredentials.email,
|
||||
}),
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,85 @@
|
||||
import { chromium } from "playwright";
|
||||
import { describe, test } from "vitest";
|
||||
|
||||
import { OnboardingActions } from "./shared/actions/onboarding-actions";
|
||||
import { OnboardingAssertions } from "./shared/assertions/onboarding-assertions";
|
||||
import { createHomarrContainer } from "./shared/create-homarr-container";
|
||||
import { createSqliteDbFileAsync } from "./shared/e2e-db";
|
||||
|
||||
describe("Onboarding", () => {
|
||||
test("Credentials onboarding should be successful", async () => {
|
||||
// Arrange
|
||||
const { db, localMountPath } = await createSqliteDbFileAsync();
|
||||
const homarrContainer = await createHomarrContainer({
|
||||
mounts: {
|
||||
"/appdata": localMountPath,
|
||||
},
|
||||
}).start();
|
||||
|
||||
const browser = await chromium.launch();
|
||||
const context = await browser.newContext();
|
||||
const page = await context.newPage();
|
||||
const actions = new OnboardingActions(page, db);
|
||||
const assertions = new OnboardingAssertions(page, db);
|
||||
|
||||
// Act
|
||||
await page.goto(`http://${homarrContainer.getHost()}:${homarrContainer.getMappedPort(7575)}`);
|
||||
await actions.startOnboardingAsync("scratch");
|
||||
await actions.processUserStepAsync({
|
||||
username: "admin",
|
||||
password: "Comp(exP4sswOrd",
|
||||
confirmPassword: "Comp(exP4sswOrd",
|
||||
});
|
||||
await actions.processSettingsStepAsync();
|
||||
|
||||
// Assert
|
||||
await assertions.assertFinishStepVisibleAsync();
|
||||
await assertions.assertUserAndAdminGroupInsertedAsync("admin");
|
||||
await assertions.assertDbOnboardingStepAsync("finish");
|
||||
|
||||
// Cleanup
|
||||
await browser.close();
|
||||
await homarrContainer.stop();
|
||||
}, 60_000);
|
||||
|
||||
test("External provider onboarding setup should be successful", async () => {
|
||||
// Arrange
|
||||
const { db, localMountPath } = await createSqliteDbFileAsync();
|
||||
const homarrContainer = await createHomarrContainer({
|
||||
environment: {
|
||||
AUTH_PROVIDERS: "ldap",
|
||||
AUTH_LDAP_URI: "ldap://host.docker.internal:3890",
|
||||
AUTH_LDAP_BASE: "",
|
||||
AUTH_LDAP_BIND_DN: "",
|
||||
AUTH_LDAP_BIND_PASSWORD: "",
|
||||
},
|
||||
mounts: {
|
||||
"/appdata": localMountPath,
|
||||
},
|
||||
}).start();
|
||||
const externalGroupName = "oidc-admins";
|
||||
|
||||
const browser = await chromium.launch();
|
||||
const context = await browser.newContext();
|
||||
const page = await context.newPage();
|
||||
const actions = new OnboardingActions(page, db);
|
||||
const assertions = new OnboardingAssertions(page, db);
|
||||
|
||||
// Act
|
||||
await page.goto(`http://${homarrContainer.getHost()}:${homarrContainer.getMappedPort(7575)}`);
|
||||
await actions.startOnboardingAsync("scratch");
|
||||
await actions.processExternalGroupStepAsync({
|
||||
name: externalGroupName,
|
||||
});
|
||||
await actions.processSettingsStepAsync();
|
||||
|
||||
// Assert
|
||||
await assertions.assertFinishStepVisibleAsync();
|
||||
await assertions.assertExternalGroupInsertedAsync(externalGroupName);
|
||||
await assertions.assertDbOnboardingStepAsync("finish");
|
||||
|
||||
// Cleanup
|
||||
await browser.close();
|
||||
await homarrContainer.stop();
|
||||
}, 60_000);
|
||||
});
|
||||
@@ -0,0 +1,53 @@
|
||||
import { createId } from "@paralleldrive/cuid2";
|
||||
import type { Page } from "playwright";
|
||||
|
||||
import * as sqliteSchema from "../../../packages/db/schema/sqlite";
|
||||
import type { SqliteDatabase } from "../e2e-db";
|
||||
|
||||
export class OnboardingActions {
|
||||
private readonly page: Page;
|
||||
private readonly db: SqliteDatabase;
|
||||
|
||||
constructor(page: Page, db: SqliteDatabase) {
|
||||
this.page = page;
|
||||
this.db = db;
|
||||
}
|
||||
|
||||
public async skipOnboardingAsync(input?: { group?: string }) {
|
||||
await this.db.update(sqliteSchema.onboarding).set({
|
||||
step: "finish",
|
||||
});
|
||||
|
||||
if (input?.group) {
|
||||
await this.db.insert(sqliteSchema.groups).values({
|
||||
id: createId(),
|
||||
name: input.group,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
public async startOnboardingAsync(type: "scratch" | "before 1.0") {
|
||||
await this.page.locator("button", { hasText: type }).click();
|
||||
}
|
||||
|
||||
public async processUserStepAsync(input: { username: string; password: string; confirmPassword: string }) {
|
||||
await this.page.waitForSelector("text=administrator user");
|
||||
|
||||
await this.page.getByLabel("Username").fill(input.username);
|
||||
await this.page.getByLabel("Password", { exact: true }).fill(input.password);
|
||||
await this.page.getByLabel("Confirm password").fill(input.confirmPassword);
|
||||
|
||||
await this.page.locator("css=button[type='submit']").click();
|
||||
}
|
||||
|
||||
public async processExternalGroupStepAsync(input: { name: string }) {
|
||||
await this.page.waitForSelector("text=external provider");
|
||||
await this.page.locator("input").fill(input.name);
|
||||
await this.page.locator("css=button[type='submit']").click();
|
||||
}
|
||||
|
||||
public async processSettingsStepAsync() {
|
||||
await this.page.waitForSelector("text=Analytics");
|
||||
await this.page.locator("css=button[type='submit']").click();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { eq } from "drizzle-orm";
|
||||
import type { Page } from "playwright";
|
||||
import { expect } from "vitest";
|
||||
|
||||
import * as sqliteSchema from "../../../packages/db/schema/sqlite";
|
||||
import { OnboardingStep } from "../../../packages/definitions/src";
|
||||
import { credentialsAdminGroup } from "../../../packages/definitions/src/group";
|
||||
import type { SqliteDatabase } from "../e2e-db";
|
||||
|
||||
export class OnboardingAssertions {
|
||||
private readonly page: Page;
|
||||
private readonly db: SqliteDatabase;
|
||||
|
||||
constructor(page: Page, db: SqliteDatabase) {
|
||||
this.page = page;
|
||||
this.db = db;
|
||||
}
|
||||
|
||||
public async assertDbOnboardingStepAsync(expectedStep: OnboardingStep) {
|
||||
const onboarding = await this.db.query.onboarding.findFirst();
|
||||
expect(onboarding?.step).toEqual(expectedStep);
|
||||
}
|
||||
|
||||
public async assertUserAndAdminGroupInsertedAsync(expectedUsername: string) {
|
||||
const users = await this.db.query.users.findMany({
|
||||
with: {
|
||||
groups: {
|
||||
with: {
|
||||
group: {
|
||||
with: {
|
||||
permissions: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const user = users.find((u) => u.name === expectedUsername);
|
||||
expect(user).toBeDefined();
|
||||
|
||||
const adminGroup = user!.groups.find((g) => g.group.name === credentialsAdminGroup);
|
||||
expect(adminGroup).toBeDefined();
|
||||
expect(adminGroup!.group.permissions).toEqual([expect.objectContaining({ permission: "admin" })]);
|
||||
}
|
||||
|
||||
public async assertExternalGroupInsertedAsync(expectedGroupName: string) {
|
||||
const group = await this.db.query.groups.findFirst({
|
||||
where: eq(sqliteSchema.groups.name, expectedGroupName),
|
||||
with: {
|
||||
permissions: true,
|
||||
},
|
||||
});
|
||||
|
||||
expect(group).toBeDefined();
|
||||
expect(group!.permissions).toEqual([expect.objectContaining({ permission: "admin" })]);
|
||||
}
|
||||
|
||||
public async assertFinishStepVisibleAsync() {
|
||||
await this.page.waitForSelector("text=completed the setup", { timeout: 5000 });
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,51 @@
|
||||
import { GenericContainer, Wait } from "testcontainers";
|
||||
import { Environment } from "testcontainers/build/types";
|
||||
|
||||
export const createHomarrContainer = () => {
|
||||
export const createHomarrContainer = (
|
||||
options: {
|
||||
environment?: Environment;
|
||||
mounts?: {
|
||||
"/appdata"?: string;
|
||||
"/var/run/docker.sock"?: string;
|
||||
};
|
||||
} = {},
|
||||
) => {
|
||||
if (!process.env.CI) {
|
||||
throw new Error("This test should only be run in CI or with a homarr image named 'homarr-e2e'");
|
||||
}
|
||||
|
||||
return new GenericContainer("homarr-e2e")
|
||||
const container = new GenericContainer("homarr-e2e")
|
||||
.withExposedPorts(7575)
|
||||
.withWaitStrategy(Wait.forHttp("/api/health/ready", 7575));
|
||||
.withEnvironment({
|
||||
...options.environment,
|
||||
SECRET_ENCRYPTION_KEY: "0".repeat(64),
|
||||
})
|
||||
.withBindMounts(
|
||||
Object.entries(options.mounts ?? {})
|
||||
.filter((item) => item?.[0] !== undefined)
|
||||
.map(([container, local]) => ({
|
||||
source: local,
|
||||
target: container,
|
||||
})),
|
||||
)
|
||||
.withWaitStrategy(Wait.forHttp("/api/health/ready", 7575))
|
||||
.withExtraHosts([
|
||||
{
|
||||
// This enabled the usage of host.docker.internal as hostname in the container
|
||||
host: "host.docker.internal",
|
||||
ipAddress: "host-gateway",
|
||||
},
|
||||
]);
|
||||
|
||||
return withLogs(container);
|
||||
};
|
||||
|
||||
export const withLogs = (container: GenericContainer) => {
|
||||
container.withLogConsumer((stream) =>
|
||||
stream
|
||||
.on("data", (line) => console.log(line))
|
||||
.on("err", (line) => console.error(line))
|
||||
.on("end", () => console.log("Stream closed")),
|
||||
);
|
||||
return container;
|
||||
};
|
||||
@@ -0,0 +1,32 @@
|
||||
import { mkdir } from "fs/promises";
|
||||
import path from "path";
|
||||
import { createId } from "@paralleldrive/cuid2";
|
||||
import Database from "better-sqlite3";
|
||||
import { BetterSQLite3Database, drizzle } from "drizzle-orm/better-sqlite3";
|
||||
import { migrate } from "drizzle-orm/better-sqlite3/migrator";
|
||||
|
||||
import * as sqliteSchema from "../../packages/db/schema/sqlite";
|
||||
|
||||
export const createSqliteDbFileAsync = async () => {
|
||||
const localMountPath = path.join(__dirname, "tmp", createId());
|
||||
await mkdir(path.join(localMountPath, "db"), { recursive: true });
|
||||
|
||||
const localDbUrl = path.join(localMountPath, "db", "db.sqlite");
|
||||
|
||||
const connection = new Database(localDbUrl);
|
||||
const db = drizzle(connection, {
|
||||
schema: sqliteSchema,
|
||||
casing: "snake_case",
|
||||
});
|
||||
|
||||
await migrate(db, {
|
||||
migrationsFolder: path.join(__dirname, "..", "..", "packages", "db", "migrations", "sqlite"),
|
||||
});
|
||||
|
||||
return {
|
||||
db,
|
||||
localMountPath,
|
||||
};
|
||||
};
|
||||
|
||||
export type SqliteDatabase = BetterSQLite3Database<typeof sqliteSchema>;
|
||||
+5
-5
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "homarr",
|
||||
"version": "1.0.0-beta.7",
|
||||
"version": "1.0.0-beta.11",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build": "cross-env CI=true turbo build",
|
||||
@@ -32,11 +32,11 @@
|
||||
"devDependencies": {
|
||||
"@homarr/prettier-config": "workspace:^0.1.0",
|
||||
"@semantic-release/changelog": "^6.0.3",
|
||||
"@semantic-release/commit-analyzer": "^13.0.0",
|
||||
"@semantic-release/commit-analyzer": "^13.0.1",
|
||||
"@semantic-release/git": "^10.0.1",
|
||||
"@semantic-release/github": "^11.0.1",
|
||||
"@semantic-release/npm": "^12.0.1",
|
||||
"@semantic-release/release-notes-generator": "^14.0.2",
|
||||
"@semantic-release/release-notes-generator": "^14.0.3",
|
||||
"@turbo/gen": "^2.3.3",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"@vitest/coverage-v8": "^2.1.8",
|
||||
@@ -45,14 +45,14 @@
|
||||
"cross-env": "^7.0.3",
|
||||
"jsdom": "^25.0.1",
|
||||
"prettier": "^3.4.2",
|
||||
"semantic-release": "^24.2.0",
|
||||
"semantic-release": "^24.2.1",
|
||||
"testcontainers": "^10.16.0",
|
||||
"turbo": "^2.3.3",
|
||||
"typescript": "^5.7.2",
|
||||
"vite-tsconfig-paths": "^5.1.4",
|
||||
"vitest": "^2.1.8"
|
||||
},
|
||||
"packageManager": "pnpm@9.15.1",
|
||||
"packageManager": "pnpm@9.15.2",
|
||||
"engines": {
|
||||
"node": ">=22.12.0"
|
||||
},
|
||||
|
||||
@@ -41,16 +41,17 @@
|
||||
"@trpc/react-query": "next",
|
||||
"@trpc/server": "next",
|
||||
"dockerode": "^4.0.2",
|
||||
"next": "^14.2.21",
|
||||
"lodash.clonedeep": "^4.5.0",
|
||||
"next": "^14.2.22",
|
||||
"react": "^19.0.0",
|
||||
"superjson": "2.2.2",
|
||||
"trpc-to-openapi": "^2.1.0"
|
||||
"trpc-to-openapi": "^2.1.1"
|
||||
},
|
||||
"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",
|
||||
"@types/dockerode": "^3.3.33",
|
||||
"eslint": "^9.17.0",
|
||||
"prettier": "^3.4.2",
|
||||
"typescript": "^5.7.2"
|
||||
|
||||
@@ -16,7 +16,6 @@ export const fetchApi = createTRPCClient<AppRouter>({
|
||||
|
||||
function getBaseUrl() {
|
||||
if (typeof window !== "undefined") return window.location.origin;
|
||||
if (process.env.VERCEL_URL) return `https://${process.env.VERCEL_URL}`;
|
||||
return `http://localhost:${process.env.PORT ?? 3000}`;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { count, like } from "@homarr/db";
|
||||
import { and, count, like } from "@homarr/db";
|
||||
import { icons } from "@homarr/db/schema";
|
||||
import { validation } from "@homarr/validation";
|
||||
|
||||
@@ -15,7 +15,11 @@ export const iconsRouter = createTRPCRouter({
|
||||
name: true,
|
||||
url: true,
|
||||
},
|
||||
where: (input.searchText?.length ?? 0) > 0 ? like(icons.name, `%${input.searchText}%`) : undefined,
|
||||
where:
|
||||
(input.searchText?.length ?? 0) > 0
|
||||
? // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
and(...input.searchText!.split(" ").map((keyword) => like(icons.name, `%${keyword}%`)))
|
||||
: undefined,
|
||||
limit: input.limitPerGroup,
|
||||
},
|
||||
},
|
||||
|
||||
@@ -11,9 +11,11 @@ import {
|
||||
integrations,
|
||||
integrationSecrets,
|
||||
integrationUserPermissions,
|
||||
searchEngines,
|
||||
} from "@homarr/db/schema";
|
||||
import type { IntegrationSecretKind } from "@homarr/definitions";
|
||||
import {
|
||||
getIconUrl,
|
||||
getIntegrationKindsByCategory,
|
||||
getPermissionsWithParents,
|
||||
integrationDefs,
|
||||
@@ -192,6 +194,18 @@ export const integrationRouter = createTRPCRouter({
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
if (input.attemptSearchEngineCreation) {
|
||||
const icon = getIconUrl(input.kind);
|
||||
await ctx.db.insert(searchEngines).values({
|
||||
id: createId(),
|
||||
name: input.name,
|
||||
integrationId,
|
||||
type: "fromIntegration",
|
||||
iconUrl: icon,
|
||||
short: await getNextValidShortNameForSearchEngineAsync(ctx.db, input.name),
|
||||
});
|
||||
}
|
||||
}),
|
||||
update: protectedProcedure.input(validation.integration.update).mutation(async ({ ctx, input }) => {
|
||||
await throwIfActionForbiddenAsync(ctx, eq(integrations.id, input.id), "full");
|
||||
@@ -411,6 +425,36 @@ interface AddSecretInput {
|
||||
value: string;
|
||||
kind: IntegrationSecretKind;
|
||||
}
|
||||
|
||||
const getNextValidShortNameForSearchEngineAsync = async (db: Database, integrationName: string) => {
|
||||
const searchEngines = await db.query.searchEngines.findMany({
|
||||
columns: {
|
||||
short: true,
|
||||
},
|
||||
});
|
||||
|
||||
const usedShortNames = searchEngines.flatMap((searchEngine) => searchEngine.short.toLowerCase());
|
||||
const nameByIntegrationName = integrationName.slice(0, 1).toLowerCase();
|
||||
|
||||
if (!usedShortNames.includes(nameByIntegrationName)) {
|
||||
return nameByIntegrationName;
|
||||
}
|
||||
|
||||
// 8 is max length constraint
|
||||
for (let i = 2; i < 9999999; i++) {
|
||||
const generatedName = `${nameByIntegrationName}${i}`;
|
||||
if (usedShortNames.includes(generatedName)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
return generatedName;
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
"Unable to automatically generate a short name. All possible variations were exhausted. Please disable the automatic creation and choose one later yourself.",
|
||||
);
|
||||
};
|
||||
|
||||
const addSecretAsync = async (db: Database, input: AddSecretInput) => {
|
||||
await db.insert(integrationSecrets).values({
|
||||
kind: input.kind,
|
||||
|
||||
@@ -2,8 +2,10 @@ import { TRPCError } from "@trpc/server";
|
||||
|
||||
import { createId, eq, like, sql } from "@homarr/db";
|
||||
import { searchEngines } from "@homarr/db/schema";
|
||||
import { integrationCreator } from "@homarr/integrations";
|
||||
import { validation } from "@homarr/validation";
|
||||
|
||||
import { createOneIntegrationMiddleware } from "../../middlewares/integration";
|
||||
import { createTRPCRouter, permissionRequiredProcedure, protectedProcedure } from "../../trpc";
|
||||
|
||||
export const searchEngineRouter = createTRPCRouter({
|
||||
@@ -56,9 +58,32 @@ export const searchEngineRouter = createTRPCRouter({
|
||||
search: protectedProcedure.input(validation.common.search).query(async ({ ctx, input }) => {
|
||||
return await ctx.db.query.searchEngines.findMany({
|
||||
where: like(searchEngines.short, `${input.query.toLowerCase().trim()}%`),
|
||||
with: {
|
||||
integration: {
|
||||
columns: {
|
||||
kind: true,
|
||||
url: true,
|
||||
id: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
limit: input.limit,
|
||||
});
|
||||
}),
|
||||
getMediaRequestOptions: protectedProcedure
|
||||
.unstable_concat(createOneIntegrationMiddleware("query", "jellyseerr", "overseerr"))
|
||||
.input(validation.common.mediaRequestOptions)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const integration = integrationCreator(ctx.integration);
|
||||
return await integration.getSeriesInformationAsync(input.mediaType, input.mediaId);
|
||||
}),
|
||||
requestMedia: protectedProcedure
|
||||
.unstable_concat(createOneIntegrationMiddleware("interact", "jellyseerr", "overseerr"))
|
||||
.input(validation.common.requestMedia)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const integration = integrationCreator(ctx.integration);
|
||||
return await integration.requestMediaAsync(input.mediaType, input.mediaId, input.seasons);
|
||||
}),
|
||||
create: permissionRequiredProcedure
|
||||
.requiresPermission("search-engine-create")
|
||||
.input(validation.searchEngine.manage)
|
||||
|
||||
@@ -179,6 +179,7 @@ describe("create should create a new integration", () => {
|
||||
kind: "jellyfin" as const,
|
||||
url: "http://jellyfin.local",
|
||||
secrets: [{ kind: "apiKey" as const, value: "1234567890" }],
|
||||
attemptSearchEngineCreation: false,
|
||||
};
|
||||
|
||||
const fakeNow = new Date("2023-07-01T00:00:00Z");
|
||||
@@ -201,6 +202,48 @@ describe("create should create a new integration", () => {
|
||||
expect(dbSecret!.updatedAt).toEqual(fakeNow);
|
||||
});
|
||||
|
||||
test("with create integration access should create a new integration when creating search engine", async () => {
|
||||
const db = createDb();
|
||||
const caller = integrationRouter.createCaller({
|
||||
db,
|
||||
session: defaultSessionWithPermissions(["integration-create"]),
|
||||
});
|
||||
const input = {
|
||||
name: "Jellyseerr",
|
||||
kind: "jellyseerr" as const,
|
||||
url: "http://jellyseerr.local",
|
||||
secrets: [{ kind: "apiKey" as const, value: "1234567890" }],
|
||||
attemptSearchEngineCreation: true,
|
||||
};
|
||||
|
||||
const fakeNow = new Date("2023-07-01T00:00:00Z");
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(fakeNow);
|
||||
await caller.create(input);
|
||||
vi.useRealTimers();
|
||||
|
||||
const dbIntegration = await db.query.integrations.findFirst();
|
||||
const dbSecret = await db.query.integrationSecrets.findFirst();
|
||||
const dbSearchEngine = await db.query.searchEngines.findFirst();
|
||||
expect(dbIntegration).toBeDefined();
|
||||
expect(dbIntegration!.name).toBe(input.name);
|
||||
expect(dbIntegration!.kind).toBe(input.kind);
|
||||
expect(dbIntegration!.url).toBe(input.url);
|
||||
|
||||
expect(dbSecret!.integrationId).toBe(dbIntegration!.id);
|
||||
expect(dbSecret).toBeDefined();
|
||||
expect(dbSecret!.kind).toBe(input.secrets[0]!.kind);
|
||||
expect(dbSecret!.value).toMatch(/^[a-f0-9]+.[a-f0-9]+$/);
|
||||
expect(dbSecret!.updatedAt).toEqual(fakeNow);
|
||||
|
||||
expect(dbSearchEngine!.integrationId).toBe(dbIntegration!.id);
|
||||
expect(dbSearchEngine!.short).toBe("j");
|
||||
expect(dbSearchEngine!.name).toBe(input.name);
|
||||
expect(dbSearchEngine!.iconUrl).toBe(
|
||||
"https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/jellyseerr.png",
|
||||
);
|
||||
});
|
||||
|
||||
test("without create integration access should throw permission error", async () => {
|
||||
// Arrange
|
||||
const db = createDb();
|
||||
@@ -213,6 +256,7 @@ describe("create should create a new integration", () => {
|
||||
kind: "jellyfin" as const,
|
||||
url: "http://jellyfin.local",
|
||||
secrets: [{ kind: "apiKey" as const, value: "1234567890" }],
|
||||
attemptSearchEngineCreation: false,
|
||||
};
|
||||
|
||||
// Act
|
||||
|
||||
@@ -8,6 +8,7 @@ import { indexerManagerRouter } from "./indexer-manager";
|
||||
import { mediaRequestsRouter } from "./media-requests";
|
||||
import { mediaServerRouter } from "./media-server";
|
||||
import { mediaTranscodingRouter } from "./media-transcoding";
|
||||
import { minecraftRouter } from "./minecraft";
|
||||
import { notebookRouter } from "./notebook";
|
||||
import { rssFeedRouter } from "./rssFeed";
|
||||
import { smartHomeRouter } from "./smart-home";
|
||||
@@ -27,4 +28,5 @@ export const widgetRouter = createTRPCRouter({
|
||||
indexerManager: indexerManagerRouter,
|
||||
healthMonitoring: healthMonitoringRouter,
|
||||
mediaTranscoding: mediaTranscodingRouter,
|
||||
minecraft: minecraftRouter,
|
||||
});
|
||||
@@ -21,6 +21,7 @@ export const mediaServerRouter = createTRPCRouter({
|
||||
const { data } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
|
||||
return {
|
||||
integrationId: integration.id,
|
||||
integrationKind: integration.kind,
|
||||
sessions: data,
|
||||
};
|
||||
}),
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { observable } from "@trpc/server/observable";
|
||||
import { z } from "zod";
|
||||
|
||||
import type { MinecraftServerStatus } from "@homarr/request-handler/minecraft-server-status";
|
||||
import { minecraftServerStatusRequestHandler } from "@homarr/request-handler/minecraft-server-status";
|
||||
|
||||
import { createTRPCRouter, publicProcedure } from "../../trpc";
|
||||
|
||||
const serverStatusInputSchema = z.object({
|
||||
domain: z.string().nonempty(),
|
||||
isBedrockServer: z.boolean(),
|
||||
});
|
||||
export const minecraftRouter = createTRPCRouter({
|
||||
getServerStatus: publicProcedure.input(serverStatusInputSchema).query(async ({ input }) => {
|
||||
const innerHandler = minecraftServerStatusRequestHandler.handler({
|
||||
isBedrockServer: input.isBedrockServer,
|
||||
domain: input.domain,
|
||||
});
|
||||
return await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: true });
|
||||
}),
|
||||
subscribeServerStatus: publicProcedure.input(serverStatusInputSchema).subscription(({ input }) => {
|
||||
return observable<MinecraftServerStatus>((emit) => {
|
||||
const innerHandler = minecraftServerStatusRequestHandler.handler({
|
||||
isBedrockServer: input.isBedrockServer,
|
||||
domain: input.domain,
|
||||
});
|
||||
const unsubscribe = innerHandler.subscribe((data) => {
|
||||
emit.next(data);
|
||||
});
|
||||
|
||||
return () => {
|
||||
unsubscribe();
|
||||
};
|
||||
});
|
||||
}),
|
||||
});
|
||||
@@ -64,7 +64,6 @@ export const env = createEnv({
|
||||
server: {
|
||||
AUTH_LOGOUT_REDIRECT_URL: z.string().url().optional(),
|
||||
AUTH_SESSION_EXPIRY_TIME: createDurationSchema("30d"),
|
||||
AUTH_SECRET: process.env.NODE_ENV === "production" ? z.string().min(1) : z.string().min(1).optional(),
|
||||
AUTH_PROVIDERS: authProvidersSchema,
|
||||
...(authProviders.includes("oidc")
|
||||
? {
|
||||
@@ -98,7 +97,6 @@ export const env = createEnv({
|
||||
runtimeEnv: {
|
||||
AUTH_LOGOUT_REDIRECT_URL: process.env.AUTH_LOGOUT_REDIRECT_URL,
|
||||
AUTH_SESSION_EXPIRY_TIME: process.env.AUTH_SESSION_EXPIRY_TIME,
|
||||
AUTH_SECRET: process.env.AUTH_SECRET,
|
||||
AUTH_PROVIDERS: process.env.AUTH_PROVIDERS,
|
||||
AUTH_LDAP_BASE: process.env.AUTH_LDAP_BASE,
|
||||
AUTH_LDAP_BIND_DN: process.env.AUTH_LDAP_BIND_DN,
|
||||
|
||||
@@ -34,7 +34,7 @@
|
||||
"bcrypt": "^5.1.1",
|
||||
"cookies": "^0.9.1",
|
||||
"ldapts": "7.3.0",
|
||||
"next": "^14.2.21",
|
||||
"next": "^14.2.22",
|
||||
"next-auth": "5.0.0-beta.25",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0"
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import { randomBytes } from "crypto";
|
||||
import { createEnv } from "@t3-oss/env-nextjs";
|
||||
import { z } from "zod";
|
||||
|
||||
const errorSuffix = `, please generate a 64 character secret in hex format or use the following: "${randomBytes(32).toString("hex")}"`;
|
||||
|
||||
export const env = createEnv({
|
||||
server: {
|
||||
SECRET_ENCRYPTION_KEY: z
|
||||
.string({
|
||||
required_error: `SECRET_ENCRYPTION_KEY is required${errorSuffix}`,
|
||||
})
|
||||
.min(64, {
|
||||
message: `SECRET_ENCRYPTION_KEY has to be 64 characters${errorSuffix}`,
|
||||
})
|
||||
.max(64, {
|
||||
message: `SECRET_ENCRYPTION_KEY has to be 64 characters${errorSuffix}`,
|
||||
})
|
||||
.regex(/^[0-9a-fA-F]{64}$/, {
|
||||
message: `SECRET_ENCRYPTION_KEY must only contain hex characters${errorSuffix}`,
|
||||
}),
|
||||
},
|
||||
runtimeEnv: {
|
||||
SECRET_ENCRYPTION_KEY: process.env.SECRET_ENCRYPTION_KEY,
|
||||
},
|
||||
skipValidation:
|
||||
Boolean(process.env.CI) || Boolean(process.env.SKIP_ENV_VALIDATION) || process.env.npm_lifecycle_event === "lint",
|
||||
});
|
||||
@@ -8,7 +8,8 @@
|
||||
".": "./index.ts",
|
||||
"./types": "./src/types.ts",
|
||||
"./server": "./src/server.ts",
|
||||
"./client": "./src/client.ts"
|
||||
"./client": "./src/client.ts",
|
||||
"./env.mjs": "./env.mjs"
|
||||
},
|
||||
"typesVersions": {
|
||||
"*": {
|
||||
@@ -27,7 +28,7 @@
|
||||
"dependencies": {
|
||||
"@homarr/log": "workspace:^0.1.0",
|
||||
"dayjs": "^1.11.13",
|
||||
"next": "^14.2.21",
|
||||
"next": "^14.2.22",
|
||||
"react": "^19.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -1,20 +1,12 @@
|
||||
import crypto from "crypto";
|
||||
|
||||
import { logger } from "@homarr/log";
|
||||
import { env } from "../env.mjs";
|
||||
|
||||
const algorithm = "aes-256-cbc"; //Using AES encryption
|
||||
const fallbackKey = "0000000000000000000000000000000000000000000000000000000000000000";
|
||||
const encryptionKey = process.env.ENCRYPTION_KEY ?? fallbackKey; // Fallback to a default key for local development
|
||||
if (encryptionKey === fallbackKey) {
|
||||
logger.warn("Using a fallback encryption key, stored secrets are not secure");
|
||||
|
||||
// We never want to use the fallback key in production
|
||||
if (process.env.NODE_ENV === "production" && process.env.CI !== "true") {
|
||||
throw new Error("Encryption key is not set");
|
||||
}
|
||||
}
|
||||
|
||||
const key = Buffer.from(encryptionKey, "hex");
|
||||
// We fallback to a key of 0s if the key was not provided because env validation was skipped
|
||||
// This should only be the case in CI
|
||||
const key = Buffer.from(env.SECRET_ENCRYPTION_KEY || "0".repeat(64), "hex");
|
||||
|
||||
export function encryptSecret(text: string): `${string}.${string}` {
|
||||
const initializationVector = crypto.randomBytes(16);
|
||||
|
||||
@@ -11,3 +11,8 @@ export type RemoveReadonly<T> = {
|
||||
};
|
||||
|
||||
export type MaybeArray<T> = T | T[];
|
||||
export type Inverse<T extends Invertible> = {
|
||||
[Key in keyof T as T[Key]]: Key;
|
||||
};
|
||||
|
||||
type Invertible = Record<PropertyKey, PropertyKey>;
|
||||
@@ -9,6 +9,7 @@ import { mediaOrganizerJob } from "./jobs/integrations/media-organizer";
|
||||
import { mediaRequestListJob, mediaRequestStatsJob } from "./jobs/integrations/media-requests";
|
||||
import { mediaServerJob } from "./jobs/integrations/media-server";
|
||||
import { mediaTranscodingJob } from "./jobs/integrations/media-transcoding";
|
||||
import { minecraftServerStatusJob } from "./jobs/minecraft-server-status";
|
||||
import { pingJob } from "./jobs/ping";
|
||||
import type { RssFeed } from "./jobs/rss-feeds";
|
||||
import { rssFeedsJob } from "./jobs/rss-feeds";
|
||||
@@ -33,6 +34,7 @@ export const jobGroup = createCronJobGroup({
|
||||
sessionCleanup: sessionCleanupJob,
|
||||
updateChecker: updateCheckerJob,
|
||||
mediaTranscoding: mediaTranscodingJob,
|
||||
minecraftServerStatus: minecraftServerStatusJob,
|
||||
});
|
||||
|
||||
export type JobGroupKeys = ReturnType<(typeof jobGroup)["getKeys"]>[number];
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import SuperJSON from "superjson";
|
||||
|
||||
import { EVERY_5_MINUTES } from "@homarr/cron-jobs-core/expressions";
|
||||
import { db, eq } from "@homarr/db";
|
||||
import { items } from "@homarr/db/schema";
|
||||
import { minecraftServerStatusRequestHandler } from "@homarr/request-handler/minecraft-server-status";
|
||||
|
||||
import type { WidgetComponentProps } from "../../../widgets/src";
|
||||
import { createCronJob } from "../lib";
|
||||
|
||||
export const minecraftServerStatusJob = createCronJob("minecraftServerStatus", EVERY_5_MINUTES).withCallback(
|
||||
async () => {
|
||||
const dbItems = await db.query.items.findMany({
|
||||
where: eq(items.kind, "minecraftServerStatus"),
|
||||
});
|
||||
|
||||
await Promise.allSettled(
|
||||
dbItems.map(async (item) => {
|
||||
const options = SuperJSON.parse<WidgetComponentProps<"minecraftServerStatus">["options"]>(item.options);
|
||||
const innerHandler = minecraftServerStatusRequestHandler.handler(options);
|
||||
await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: true });
|
||||
}),
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE `search_engine` ADD CONSTRAINT `search_engine_short_unique` UNIQUE(`short`);
|
||||
File diff suppressed because it is too large.
Load diff
@@ -127,6 +127,13 @@
|
||||
"when": 1733777544067,
|
||||
"tag": "0017_tired_penance",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 18,
|
||||
"version": "5",
|
||||
"when": 1735593853768,
|
||||
"tag": "0018_mighty_shaman",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
CREATE UNIQUE INDEX `search_engine_short_unique` ON `search_engine` (`short`);
|
||||
File diff suppressed because it is too large.
Load diff
@@ -127,6 +127,13 @@
|
||||
"when": 1733777395703,
|
||||
"tag": "0017_small_rumiko_fujikawa",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 18,
|
||||
"version": "6",
|
||||
"when": 1735593831501,
|
||||
"tag": "0018_cheerful_tattoo",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -48,8 +48,8 @@
|
||||
"better-sqlite3": "^11.7.0",
|
||||
"dotenv": "^16.4.7",
|
||||
"drizzle-kit": "^0.30.1",
|
||||
"drizzle-orm": "^0.38.2",
|
||||
"drizzle-zod": "^0.6.0",
|
||||
"drizzle-orm": "^0.38.3",
|
||||
"drizzle-zod": "^0.6.1",
|
||||
"mysql2": "3.12.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -389,7 +389,7 @@ export const searchEngines = mysqlTable("search_engine", {
|
||||
id: varchar({ length: 64 }).notNull().primaryKey(),
|
||||
iconUrl: text().notNull(),
|
||||
name: varchar({ length: 64 }).notNull(),
|
||||
short: varchar({ length: 8 }).notNull(),
|
||||
short: varchar({ length: 8 }).unique().notNull(),
|
||||
description: text(),
|
||||
urlTemplate: text(),
|
||||
type: varchar({ length: 64 }).$type<SearchEngineType>().notNull().default("generic"),
|
||||
|
||||
@@ -375,7 +375,7 @@ export const searchEngines = sqliteTable("search_engine", {
|
||||
id: text().notNull().primaryKey(),
|
||||
iconUrl: text().notNull(),
|
||||
name: text().notNull(),
|
||||
short: text().notNull(),
|
||||
short: text().unique().notNull(),
|
||||
description: text(),
|
||||
urlTemplate: text(),
|
||||
type: text().$type<SearchEngineType>().notNull().default("generic"),
|
||||
|
||||
@@ -9,6 +9,7 @@ export type HomarrDocumentationPath =
|
||||
| "/blog/2023/12/22/updated-documentation"
|
||||
| "/blog/2024/09/23/version-1.0"
|
||||
| "/blog/2024/12/17/open-beta-1.0"
|
||||
| "/blog/2024/12/31/migrate-secret-enryption-key"
|
||||
| "/blog/archive"
|
||||
| "/blog/authors"
|
||||
| "/blog/authors/ajnart"
|
||||
@@ -90,6 +91,7 @@ export type HomarrDocumentationPath =
|
||||
| "/docs/tags/lists"
|
||||
| "/docs/tags/management"
|
||||
| "/docs/tags/media"
|
||||
| "/docs/tags/minecraft"
|
||||
| "/docs/tags/monitoring"
|
||||
| "/docs/tags/news"
|
||||
| "/docs/tags/notebook"
|
||||
@@ -99,11 +101,13 @@ export type HomarrDocumentationPath =
|
||||
| "/docs/tags/open-media-vault"
|
||||
| "/docs/tags/overseerr"
|
||||
| "/docs/tags/permissions"
|
||||
| "/docs/tags/pgid"
|
||||
| "/docs/tags/pi-hole"
|
||||
| "/docs/tags/ping"
|
||||
| "/docs/tags/programming"
|
||||
| "/docs/tags/proxmox"
|
||||
| "/docs/tags/proxy"
|
||||
| "/docs/tags/puid"
|
||||
| "/docs/tags/roles"
|
||||
| "/docs/tags/rss"
|
||||
| "/docs/tags/search"
|
||||
@@ -134,6 +138,7 @@ export type HomarrDocumentationPath =
|
||||
| "/docs/advanced/icons"
|
||||
| "/docs/advanced/keyboard-shortcuts"
|
||||
| "/docs/advanced/proxy"
|
||||
| "/docs/advanced/running-as-different-user"
|
||||
| "/docs/advanced/single-sign-on"
|
||||
| "/docs/category/advanced"
|
||||
| "/docs/category/community"
|
||||
@@ -190,6 +195,7 @@ export type HomarrDocumentationPath =
|
||||
| "/docs/widgets/indexer-manager"
|
||||
| "/docs/widgets/media-requests"
|
||||
| "/docs/widgets/media-server"
|
||||
| "/docs/widgets/minecraft-server-status"
|
||||
| "/docs/widgets/notebook"
|
||||
| "/docs/widgets/rss"
|
||||
| "/docs/widgets/video"
|
||||
|
||||
@@ -15,6 +15,7 @@ export const widgetKinds = [
|
||||
"mediaRequests-requestList",
|
||||
"mediaRequests-requestStats",
|
||||
"mediaTranscoding",
|
||||
"minecraftServerStatus",
|
||||
"rssFeed",
|
||||
"bookmarks",
|
||||
"indexerManager",
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
export * from "./src/icons-fetcher";
|
||||
export * from "./src/types";
|
||||
export * from "./src/auto-icon-searcher";
|
||||
@@ -0,0 +1,9 @@
|
||||
import type { Database } from "@homarr/db";
|
||||
import { like } from "@homarr/db";
|
||||
import { icons } from "@homarr/db/schema";
|
||||
|
||||
export const getIconForNameAsync = async (db: Database, name: string) => {
|
||||
return await db.query.icons.findFirst({
|
||||
where: like(icons.name, `%${name}%`),
|
||||
});
|
||||
};
|
||||
@@ -24,9 +24,9 @@
|
||||
},
|
||||
"prettier": "@homarr/prettier-config",
|
||||
"dependencies": {
|
||||
"@ctrl/deluge": "^7.0.0",
|
||||
"@ctrl/deluge": "^7.1.0",
|
||||
"@ctrl/qbittorrent": "^9.2.0",
|
||||
"@ctrl/transmission": "^7.1.0",
|
||||
"@ctrl/transmission": "^7.2.0",
|
||||
"@homarr/common": "workspace:^0.1.0",
|
||||
"@homarr/db": "workspace:^0.1.0",
|
||||
"@homarr/definitions": "workspace:^0.1.0",
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
export interface ISearchableIntegration {
|
||||
searchAsync(query: string): Promise<{ image?: string; name: string; link: string }[]>;
|
||||
export interface ISearchableIntegration<TResult extends { image?: string; name: string; link: string }> {
|
||||
searchAsync(query: string): Promise<TResult[]>;
|
||||
}
|
||||
@@ -6,11 +6,20 @@ import type { ISearchableIntegration } from "../base/searchable-integration";
|
||||
import type { MediaRequest, RequestStats, RequestUser } from "../interfaces/media-requests/media-request";
|
||||
import { MediaAvailability, MediaRequestStatus } from "../interfaces/media-requests/media-request";
|
||||
|
||||
interface OverseerrSearchResult {
|
||||
id: number;
|
||||
name: string;
|
||||
link: string;
|
||||
image?: string;
|
||||
text?: string;
|
||||
type: Exclude<z.infer<typeof searchSchema>["results"], undefined>[number]["mediaType"];
|
||||
}
|
||||
|
||||
/**
|
||||
* Overseerr Integration. See https://api-docs.overseerr.dev
|
||||
*/
|
||||
export class OverseerrIntegration extends Integration implements ISearchableIntegration {
|
||||
public async searchAsync(query: string): Promise<{ image?: string; name: string; link: string; text?: string }[]> {
|
||||
export class OverseerrIntegration extends Integration implements ISearchableIntegration<OverseerrSearchResult> {
|
||||
public async searchAsync(query: string) {
|
||||
const response = await fetch(this.url("/api/v1/search", { query }), {
|
||||
headers: {
|
||||
"X-Api-Key": this.getSecretValue("apiKey"),
|
||||
@@ -23,13 +32,53 @@ export class OverseerrIntegration extends Integration implements ISearchableInte
|
||||
}
|
||||
|
||||
return schemaData.results.map((result) => ({
|
||||
id: result.id,
|
||||
name: "name" in result ? result.name : result.title,
|
||||
link: this.url(`/${result.mediaType}/${result.id}`).toString(),
|
||||
image: constructSearchResultImage(result),
|
||||
text: "overview" in result ? result.overview : undefined,
|
||||
type: result.mediaType,
|
||||
inLibrary: result.mediaInfo !== undefined,
|
||||
}));
|
||||
}
|
||||
|
||||
public async getSeriesInformationAsync(mediaType: "movie" | "tv", id: number) {
|
||||
const url = mediaType === "tv" ? this.url(`/api/v1/tv/${id}`) : this.url(`/api/v1/movie/${id}`);
|
||||
const response = await fetch(url, {
|
||||
headers: {
|
||||
"X-Api-Key": this.getSecretValue("apiKey"),
|
||||
},
|
||||
});
|
||||
return await mediaInformationSchema.parseAsync(await response.json());
|
||||
}
|
||||
|
||||
/**
|
||||
* Request a media. See https://api-docs.overseerr.dev/#/request/post_request
|
||||
* @param mediaType The media type to request. Can be "movie" or "tv".
|
||||
* @param id The Overseerr ID of the media to request.
|
||||
* @param seasons A list of the seasons that should be requested.
|
||||
*/
|
||||
public async requestMediaAsync(mediaType: "movie" | "tv", id: number, seasons?: number[]): Promise<void> {
|
||||
const url = this.url("/api/v1/request");
|
||||
const response = await fetch(url, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
mediaType,
|
||||
mediaId: id,
|
||||
seasons,
|
||||
}),
|
||||
headers: {
|
||||
"X-Api-Key": this.getSecretValue("apiKey"),
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
if (response.status !== 201) {
|
||||
throw new Error(
|
||||
`Status code ${response.status} does not match the expected status code. The request was likely not created. Response: ${await response.text()}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public async testConnectionAsync(): Promise<void> {
|
||||
const response = await fetch(this.url("/api/v1/auth/me"), {
|
||||
headers: {
|
||||
@@ -220,6 +269,27 @@ interface MovieInformation {
|
||||
releaseDate: string;
|
||||
}
|
||||
|
||||
const mediaInformationSchema = z.union([
|
||||
z.object({
|
||||
id: z.number(),
|
||||
overview: z.string(),
|
||||
seasons: z.array(
|
||||
z.object({
|
||||
id: z.number(),
|
||||
name: z.string().min(0),
|
||||
episodeCount: z.number().min(0),
|
||||
}),
|
||||
),
|
||||
numberOfSeasons: z.number(),
|
||||
posterPath: z.string().startsWith("/"),
|
||||
}),
|
||||
z.object({
|
||||
id: z.number(),
|
||||
overview: z.string(),
|
||||
posterPath: z.string().startsWith("/"),
|
||||
}),
|
||||
]);
|
||||
|
||||
const searchSchema = z.object({
|
||||
results: z
|
||||
.array(
|
||||
@@ -230,6 +300,7 @@ const searchSchema = z.object({
|
||||
name: z.string(),
|
||||
posterPath: z.string().startsWith("/").endsWith(".jpg").nullable(),
|
||||
overview: z.string(),
|
||||
mediaInfo: z.object({}).optional(),
|
||||
}),
|
||||
z.object({
|
||||
id: z.number(),
|
||||
@@ -237,12 +308,14 @@ const searchSchema = z.object({
|
||||
title: z.string(),
|
||||
posterPath: z.string().startsWith("/").endsWith(".jpg").nullable(),
|
||||
overview: z.string(),
|
||||
mediaInfo: z.object({}).optional(),
|
||||
}),
|
||||
z.object({
|
||||
id: z.number(),
|
||||
mediaType: z.literal("person"),
|
||||
name: z.string(),
|
||||
profilePath: z.string().startsWith("/").endsWith(".jpg").nullable(),
|
||||
mediaInfo: z.object({}).optional(),
|
||||
}),
|
||||
]),
|
||||
)
|
||||
|
||||
@@ -45,7 +45,7 @@ export class ProwlarrIntegration extends Integration {
|
||||
);
|
||||
}
|
||||
|
||||
const inactiveIndexerIds = new Set(statusResult.data.map((status: { id: number }) => status.id));
|
||||
const inactiveIndexerIds = new Set(statusResult.data.map((status: { indexerId: number }) => status.indexerId));
|
||||
|
||||
const indexers: Indexer[] = indexersResult.data.map((indexer) => ({
|
||||
id: indexer.id,
|
||||
|
||||
@@ -9,6 +9,6 @@ export const indexerResponseSchema = z.object({
|
||||
|
||||
export const statusResponseSchema = z.array(
|
||||
z.object({
|
||||
id: z.number(),
|
||||
indexerId: z.number(),
|
||||
}),
|
||||
);
|
||||
@@ -35,7 +35,7 @@
|
||||
"@mantine/core": "^7.15.2",
|
||||
"@tabler/icons-react": "^3.26.0",
|
||||
"dayjs": "^1.11.13",
|
||||
"next": "^14.2.21",
|
||||
"next": "^14.2.22",
|
||||
"react": "^19.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
export * from "./boards";
|
||||
export * from "./invites";
|
||||
export * from "./groups";
|
||||
export * from "./search-engines";
|
||||
@@ -0,0 +1 @@
|
||||
export { RequestMediaModal } from "./request-media-modal";
|
||||
@@ -0,0 +1,119 @@
|
||||
import { useMemo } from "react";
|
||||
import { Button, Group, Image, LoadingOverlay, Stack, Text } from "@mantine/core";
|
||||
import type { MRT_ColumnDef } from "mantine-react-table";
|
||||
import { MRT_Table } from "mantine-react-table";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { createModal } from "@homarr/modals";
|
||||
import { showSuccessNotification } from "@homarr/notifications";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import { useTranslatedMantineReactTable } from "@homarr/ui/hooks";
|
||||
|
||||
interface RequestMediaModalProps {
|
||||
integrationId: string;
|
||||
mediaId: number;
|
||||
mediaType: "movie" | "tv";
|
||||
}
|
||||
|
||||
export const RequestMediaModal = createModal<RequestMediaModalProps>(({ actions, innerProps }) => {
|
||||
const { data, isPending: isPendingQuery } = clientApi.searchEngine.getMediaRequestOptions.useQuery({
|
||||
integrationId: innerProps.integrationId,
|
||||
mediaId: innerProps.mediaId,
|
||||
mediaType: innerProps.mediaType,
|
||||
});
|
||||
|
||||
const { mutate, isPending: isPendingMutation } = clientApi.searchEngine.requestMedia.useMutation({
|
||||
onSuccess() {
|
||||
actions.closeModal();
|
||||
showSuccessNotification({
|
||||
message: t("common.notification.create.success"),
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const isPending = isPendingQuery || isPendingMutation;
|
||||
const t = useI18n();
|
||||
|
||||
const columns = useMemo<MRT_ColumnDef<Season>[]>(
|
||||
() => [
|
||||
{
|
||||
accessorKey: "name",
|
||||
header: t("search.engine.media.request.modal.table.header.season"),
|
||||
},
|
||||
{
|
||||
accessorKey: "episodeCount",
|
||||
header: t("search.engine.media.request.modal.table.header.episodes"),
|
||||
},
|
||||
],
|
||||
[],
|
||||
);
|
||||
|
||||
const table = useTranslatedMantineReactTable({
|
||||
columns,
|
||||
data: data && "seasons" in data ? data.seasons : [],
|
||||
enableColumnActions: false,
|
||||
enableColumnFilters: false,
|
||||
enablePagination: false,
|
||||
enableSorting: false,
|
||||
enableSelectAll: true,
|
||||
enableRowSelection: true,
|
||||
mantineTableProps: {
|
||||
highlightOnHover: false,
|
||||
striped: "odd",
|
||||
withColumnBorders: true,
|
||||
withRowBorders: true,
|
||||
withTableBorder: true,
|
||||
},
|
||||
initialState: {
|
||||
density: "xs",
|
||||
},
|
||||
});
|
||||
|
||||
const anySelected = Object.keys(table.getState().rowSelection).length > 0;
|
||||
|
||||
const handleMutate = () => {
|
||||
const selectedSeasons = table.getSelectedRowModel().rows.flatMap((row) => row.original.id);
|
||||
mutate({
|
||||
integrationId: innerProps.integrationId,
|
||||
mediaId: innerProps.mediaId,
|
||||
mediaType: innerProps.mediaType,
|
||||
seasons: selectedSeasons,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack>
|
||||
<LoadingOverlay visible={isPending} zIndex={1000} overlayProps={{ radius: "sm", blur: 2 }} />
|
||||
{data && (
|
||||
<Group wrap="nowrap" align="start">
|
||||
<Image
|
||||
src={`https://image.tmdb.org/t/p/w600_and_h900_bestv2${data.posterPath}`}
|
||||
alt="poster"
|
||||
w={100}
|
||||
radius="md"
|
||||
/>
|
||||
<Text c="dimmed" style={{ flex: "1" }}>
|
||||
{data.overview}
|
||||
</Text>
|
||||
</Group>
|
||||
)}
|
||||
{innerProps.mediaType === "tv" && <MRT_Table table={table} />}
|
||||
<Group justify="end">
|
||||
<Button onClick={actions.closeModal} variant="light">
|
||||
{t("common.action.cancel")}
|
||||
</Button>
|
||||
<Button onClick={handleMutate} disabled={!anySelected && innerProps.mediaType === "tv"}>
|
||||
{t("search.engine.media.request.modal.button.send")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
);
|
||||
}).withOptions({
|
||||
size: "xl",
|
||||
});
|
||||
|
||||
interface Season {
|
||||
id: number;
|
||||
name: string;
|
||||
episodeCount: number;
|
||||
}
|
||||
@@ -40,11 +40,11 @@
|
||||
"@mantine/core": "^7.15.2",
|
||||
"@mantine/hooks": "^7.15.2",
|
||||
"adm-zip": "0.5.16",
|
||||
"next": "^14.2.21",
|
||||
"next": "^14.2.22",
|
||||
"react": "^19.0.0",
|
||||
"superjson": "2.2.2",
|
||||
"zod": "^3.24.1",
|
||||
"zod-form-data": "^2.0.4"
|
||||
"zod-form-data": "^2.0.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@homarr/eslint-config": "workspace:^0.2.0",
|
||||
|
||||
@@ -55,7 +55,7 @@ export const insertItemsAsync = async (
|
||||
xOffset: screenSizeShape.location.x,
|
||||
yOffset: screenSizeShape.location.y,
|
||||
kind,
|
||||
options: SuperJSON.stringify(mapOptions(kind, widget.properties, appsMap)),
|
||||
options: SuperJSON.stringify(mapOptions(widget.type, widget.properties, appsMap)),
|
||||
});
|
||||
|
||||
logger.debug(`Inserted widget id=${widget.id} sectionId=${sectionId}`);
|
||||
|
||||
@@ -83,7 +83,11 @@ export const mapWidget = (
|
||||
yOffset: shapeForSize.location.y,
|
||||
kind,
|
||||
options: SuperJSON.stringify(
|
||||
mapOptions(kind, widget.properties, new Map([...appsMap.entries()].map(([key, value]) => [key, value.id]))),
|
||||
mapOptions(
|
||||
widget.type,
|
||||
widget.properties,
|
||||
new Map([...appsMap.entries()].map(([key, value]) => [key, value.id])),
|
||||
),
|
||||
),
|
||||
};
|
||||
};
|
||||
@@ -23,6 +23,7 @@ export const mapAndDecryptUsers = (importUsers: OldmarrImportUser[], encryptionT
|
||||
...user,
|
||||
oldId: id,
|
||||
id: createId(),
|
||||
name: user.name.toLowerCase(),
|
||||
colorScheme: settings?.colorScheme === "environment" ? undefined : settings?.colorScheme,
|
||||
firstDayOfWeek: settings?.firstDayOfWeek === "sunday" ? 0 : settings?.firstDayOfWeek === "monday" ? 1 : 6,
|
||||
provider: "credentials",
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { objectEntries } from "@homarr/common";
|
||||
import type { Inverse } from "@homarr/common/types";
|
||||
import type { WidgetKind } from "@homarr/definitions";
|
||||
|
||||
import type { OldmarrBookmarkDefinition } from "./bookmark";
|
||||
@@ -49,32 +50,32 @@ export type OldmarrWidgetDefinitions =
|
||||
| OldmarrMediaTranscodingDefinition;
|
||||
|
||||
export const widgetKindMapping = {
|
||||
app: null, // In oldmarr apps were not widgets
|
||||
clock: "date",
|
||||
date: "clock",
|
||||
calendar: "calendar",
|
||||
downloads: "torrents-status",
|
||||
"torrents-status": "downloads",
|
||||
weather: "weather",
|
||||
rssFeed: "rss",
|
||||
video: "video-stream",
|
||||
rss: "rssFeed",
|
||||
"video-stream": "video",
|
||||
iframe: "iframe",
|
||||
mediaServer: "media-server",
|
||||
dnsHoleSummary: "dns-hole-summary",
|
||||
dnsHoleControls: "dns-hole-controls",
|
||||
"media-server": "mediaServer",
|
||||
"dns-hole-summary": "dnsHoleSummary",
|
||||
"dns-hole-controls": "dnsHoleControls",
|
||||
notebook: "notebook",
|
||||
"smartHome-entityState": "smart-home/entity-state",
|
||||
"smartHome-executeAutomation": "smart-home/trigger-automation",
|
||||
"mediaRequests-requestList": "media-requests-list",
|
||||
"mediaRequests-requestStats": "media-requests-stats",
|
||||
indexerManager: "indexer-manager",
|
||||
bookmarks: "bookmark",
|
||||
healthMonitoring: "health-monitoring",
|
||||
mediaTranscoding: "media-transcoding",
|
||||
} satisfies Record<WidgetKind, OldmarrWidgetDefinitions["id"] | null>;
|
||||
// Use null for widgets that did not exist in oldmarr
|
||||
// TODO: revert assignment so that only old widgets are needed in the object,
|
||||
// this can be done ones all widgets are implemented
|
||||
"smart-home/entity-state": "smartHome-entityState",
|
||||
"smart-home/trigger-automation": "smartHome-executeAutomation",
|
||||
"media-requests-list": "mediaRequests-requestList",
|
||||
"media-requests-stats": "mediaRequests-requestStats",
|
||||
"indexer-manager": "indexerManager",
|
||||
bookmark: "bookmarks",
|
||||
"health-monitoring": "healthMonitoring",
|
||||
dashdot: "healthMonitoring",
|
||||
"media-transcoding": "mediaTranscoding",
|
||||
dlspeed: null,
|
||||
usenet: "downloads",
|
||||
} satisfies Record<OldmarrWidgetDefinitions["id"], WidgetKind | null>;
|
||||
|
||||
export type WidgetMapping = typeof widgetKindMapping;
|
||||
export type InversedWidgetMapping = Inverse<Omit<typeof widgetKindMapping, "dlspeed">>;
|
||||
|
||||
export const mapKind = (kind: OldmarrWidgetDefinitions["id"]): WidgetKind | undefined =>
|
||||
objectEntries(widgetKindMapping).find(([_, value]) => value === kind)?.[0];
|
||||
export const mapKind = (kind: OldmarrWidgetDefinitions["id"]): keyof InversedWidgetMapping | null =>
|
||||
objectEntries(widgetKindMapping).find(([key]) => key === kind)?.[1] ?? null;
|
||||
@@ -1,18 +1,18 @@
|
||||
import { objectEntries } from "@homarr/common";
|
||||
import type { WidgetKind } from "@homarr/definitions";
|
||||
import { logger } from "@homarr/log";
|
||||
|
||||
import type { WidgetComponentProps } from "../../../widgets/src/definition";
|
||||
import type { OldmarrWidgetDefinitions, WidgetMapping } from "./definitions";
|
||||
import { mapKind } from "./definitions";
|
||||
import type { InversedWidgetMapping, OldmarrWidgetDefinitions, WidgetMapping } from "./definitions";
|
||||
|
||||
// This type enforces, that for all widget mappings there is a corresponding option mapping,
|
||||
// each option of newmarr can be mapped from the value of the oldmarr options
|
||||
type OptionMapping = {
|
||||
[WidgetKey in keyof WidgetMapping]: WidgetMapping[WidgetKey] extends null
|
||||
[WidgetKey in keyof InversedWidgetMapping]: InversedWidgetMapping[WidgetKey] extends null
|
||||
? null
|
||||
: {
|
||||
[OptionsKey in keyof WidgetComponentProps<WidgetKey>["options"]]: (
|
||||
oldOptions: Extract<OldmarrWidgetDefinitions, { id: WidgetMapping[WidgetKey] }>["options"],
|
||||
oldOptions: Extract<OldmarrWidgetDefinitions, { id: InversedWidgetMapping[WidgetKey] }>["options"],
|
||||
appsMap: Map<string, string>,
|
||||
) => WidgetComponentProps<WidgetKey>["options"][OptionsKey] | undefined;
|
||||
};
|
||||
@@ -55,12 +55,16 @@ const optionMapping: OptionMapping = {
|
||||
useCustomTimezone: () => true,
|
||||
},
|
||||
downloads: {
|
||||
activeTorrentThreshold: (oldOptions) => oldOptions.speedLimitOfActiveTorrents,
|
||||
applyFilterToRatio: (oldOptions) => oldOptions.displayRatioWithFilter,
|
||||
categoryFilter: (oldOptions) => oldOptions.labelFilter,
|
||||
filterIsWhitelist: (oldOptions) => oldOptions.labelFilterIsWhitelist,
|
||||
enableRowSorting: (oldOptions) => oldOptions.rowSorting,
|
||||
showCompletedTorrent: (oldOptions) => oldOptions.displayCompletedTorrents,
|
||||
activeTorrentThreshold: (oldOptions) =>
|
||||
"speedLimitOfActiveTorrents" in oldOptions ? oldOptions.speedLimitOfActiveTorrents : undefined,
|
||||
applyFilterToRatio: (oldOptions) =>
|
||||
"displayRatioWithFilter" in oldOptions ? oldOptions.displayRatioWithFilter : undefined,
|
||||
categoryFilter: (oldOptions) => ("labelFilter" in oldOptions ? oldOptions.labelFilter : undefined),
|
||||
filterIsWhitelist: (oldOptions) =>
|
||||
"labelFilterIsWhitelist" in oldOptions ? oldOptions.labelFilterIsWhitelist : undefined,
|
||||
enableRowSorting: (oldOptions) => ("rowSorting" in oldOptions ? oldOptions.rowSorting : undefined),
|
||||
showCompletedTorrent: (oldOptions) =>
|
||||
"displayCompletedTorrents" in oldOptions ? oldOptions.displayCompletedTorrents : undefined,
|
||||
columns: () => ["integration", "name", "progress", "time", "actions"],
|
||||
defaultSort: () => "type",
|
||||
descendingDefaultSort: () => false,
|
||||
@@ -124,45 +128,54 @@ const optionMapping: OptionMapping = {
|
||||
openIndexerSiteInNewTab: (oldOptions) => oldOptions.openIndexerSiteInNewTab,
|
||||
},
|
||||
healthMonitoring: {
|
||||
cpu: (oldOptions) => oldOptions.cpu,
|
||||
memory: (oldOptions) => oldOptions.memory,
|
||||
fahrenheit: (oldOptions) => oldOptions.fahrenheit,
|
||||
fileSystem: (oldOptions) => oldOptions.fileSystem,
|
||||
cpu: (oldOptions) =>
|
||||
"cpu" in oldOptions
|
||||
? oldOptions.cpu
|
||||
: oldOptions.graphsOrder.some((graph) => graph.key === "cpu" && graph.subValues.enabled),
|
||||
memory: (oldOptions) =>
|
||||
"memory" in oldOptions
|
||||
? oldOptions.memory
|
||||
: oldOptions.graphsOrder.some((graph) => graph.key === "ram" && graph.subValues.enabled),
|
||||
fahrenheit: (oldOptions) => ("fahrenheit" in oldOptions ? oldOptions.fahrenheit : undefined),
|
||||
fileSystem: (oldOptions) =>
|
||||
"fileSystem" in oldOptions
|
||||
? oldOptions.fileSystem
|
||||
: oldOptions.graphsOrder.some((graph) => graph.key === "storage" && graph.subValues.enabled),
|
||||
},
|
||||
mediaTranscoding: {
|
||||
defaultView: (oldOptions) => oldOptions.defaultView,
|
||||
queuePageSize: (oldOptions) => oldOptions.queuePageSize,
|
||||
},
|
||||
app: null,
|
||||
};
|
||||
|
||||
/**
|
||||
* Maps the oldmarr options to the newmarr options
|
||||
* @param kind item kind to map
|
||||
* @param type old widget type
|
||||
* @param oldOptions oldmarr options for this item
|
||||
* @param appsMap map of old app ids to new app ids
|
||||
* @returns newmarr options for this item or null if the item did not exist in oldmarr
|
||||
*/
|
||||
export const mapOptions = <K extends WidgetKind>(
|
||||
kind: K,
|
||||
oldOptions: Extract<OldmarrWidgetDefinitions, { id: WidgetMapping[K] }>["options"],
|
||||
export const mapOptions = <K extends OldmarrWidgetDefinitions["id"]>(
|
||||
type: K,
|
||||
oldOptions: Extract<OldmarrWidgetDefinitions, { id: K }>["options"],
|
||||
appsMap: Map<string, string>,
|
||||
) => {
|
||||
logger.debug(`Mapping old homarr options for widget kind=${kind} options=${JSON.stringify(oldOptions)}`);
|
||||
if (optionMapping[kind] === null) {
|
||||
logger.debug(`Mapping old homarr options for widget type=${type} options=${JSON.stringify(oldOptions)}`);
|
||||
const kind = mapKind(type);
|
||||
if (!kind) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const mapping = optionMapping[kind];
|
||||
return objectEntries(mapping).reduce(
|
||||
(acc, [key, value]) => {
|
||||
const newValue = value(oldOptions as never, appsMap);
|
||||
logger.debug(`Mapping old homarr option kind=${kind} key=${key as string} newValue=${newValue as string}`);
|
||||
(acc, [key, value]: [string, (oldOptions: Record<string, unknown>, appsMap: Map<string, string>) => unknown]) => {
|
||||
const newValue = value(oldOptions, appsMap);
|
||||
logger.debug(`Mapping old homarr option kind=${kind} key=${key} newValue=${newValue as string}`);
|
||||
if (newValue !== undefined) {
|
||||
acc[key as string] = newValue;
|
||||
acc[key] = newValue;
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, unknown>,
|
||||
) as WidgetComponentProps<K>["options"];
|
||||
) as WidgetComponentProps<Exclude<WidgetMapping[K], null>>["options"];
|
||||
};
|
||||
@@ -5,6 +5,7 @@ export {
|
||||
createItemAndIntegrationChannel,
|
||||
createItemChannel,
|
||||
createIntegrationOptionsChannel,
|
||||
createWidgetOptionsChannel,
|
||||
createChannelWithLatestAndEvents,
|
||||
handshakeAsync,
|
||||
createSubPubChannel,
|
||||
|
||||
@@ -183,6 +183,16 @@ export const createIntegrationOptionsChannel = <TData>(
|
||||
return createChannelWithLatestAndEvents<TData>(channelName);
|
||||
};
|
||||
|
||||
export const createWidgetOptionsChannel = <TData>(
|
||||
widgetKind: WidgetKind,
|
||||
queryKey: string,
|
||||
options: Record<string, unknown>,
|
||||
) => {
|
||||
const optionsKey = hashObjectBase64(options);
|
||||
const channelName = `widget:${widgetKind}:${queryKey}:options:${optionsKey}`;
|
||||
return createChannelWithLatestAndEvents<TData>(channelName);
|
||||
};
|
||||
|
||||
export const createItemChannel = <TData>(itemId: string) => {
|
||||
return createChannelWithLatestAndEvents<TData>(`item:${itemId}`);
|
||||
};
|
||||
|
||||
@@ -29,7 +29,7 @@
|
||||
"@homarr/log": "workspace:^0.1.0",
|
||||
"@homarr/redis": "workspace:^0.1.0",
|
||||
"dayjs": "^1.11.13",
|
||||
"octokit": "^4.0.2",
|
||||
"octokit": "^4.0.3",
|
||||
"pretty-print-error": "^1.1.2",
|
||||
"superjson": "2.2.2"
|
||||
},
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import type { Duration } from "dayjs/plugin/duration";
|
||||
|
||||
import type { WidgetKind } from "@homarr/definitions";
|
||||
import { createWidgetOptionsChannel } from "@homarr/redis";
|
||||
|
||||
import { createCachedRequestHandler } from "./cached-request-handler";
|
||||
|
||||
interface Options<TData, TKind extends WidgetKind, TInput extends Record<string, unknown>> {
|
||||
// Unique key for this request handler
|
||||
queryKey: string;
|
||||
requestAsync: (input: TInput) => Promise<TData>;
|
||||
cacheDuration: Duration;
|
||||
widgetKind: TKind;
|
||||
}
|
||||
|
||||
export const createCachedWidgetRequestHandler = <
|
||||
TData,
|
||||
TKind extends WidgetKind,
|
||||
TInput extends Record<string, unknown>,
|
||||
>(
|
||||
requestHandlerOptions: Options<TData, TKind, TInput>,
|
||||
) => {
|
||||
return {
|
||||
handler: (widgetOptions: TInput) =>
|
||||
createCachedRequestHandler({
|
||||
queryKey: requestHandlerOptions.queryKey,
|
||||
requestAsync: async (input: TInput) => {
|
||||
return await requestHandlerOptions.requestAsync(input);
|
||||
},
|
||||
cacheDuration: requestHandlerOptions.cacheDuration,
|
||||
createRedisChannel(input, options) {
|
||||
return createWidgetOptionsChannel<TData>(requestHandlerOptions.widgetKind, options.queryKey, input);
|
||||
},
|
||||
}).handler(widgetOptions),
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,35 @@
|
||||
import dayjs from "dayjs";
|
||||
import { z } from "zod";
|
||||
|
||||
import { fetchWithTimeout } from "@homarr/common";
|
||||
|
||||
import { createCachedWidgetRequestHandler } from "./lib/cached-widget-request-handler";
|
||||
|
||||
export const minecraftServerStatusRequestHandler = createCachedWidgetRequestHandler({
|
||||
queryKey: "minecraftServerStatusApiResult",
|
||||
widgetKind: "minecraftServerStatus",
|
||||
async requestAsync(input: { domain: string; isBedrockServer: boolean }) {
|
||||
const path = `/3/${input.isBedrockServer ? "bedrock/" : ""}${input.domain}`;
|
||||
|
||||
const response = await fetchWithTimeout(`https://api.mcsrvstat.us${path}`);
|
||||
return responseSchema.parse(await response.json());
|
||||
},
|
||||
cacheDuration: dayjs.duration(5, "minutes"),
|
||||
});
|
||||
|
||||
const responseSchema = z
|
||||
.object({
|
||||
online: z.literal(false),
|
||||
})
|
||||
.or(
|
||||
z.object({
|
||||
online: z.literal(true),
|
||||
players: z.object({
|
||||
online: z.number(),
|
||||
max: z.number(),
|
||||
}),
|
||||
icon: z.string().optional(),
|
||||
}),
|
||||
);
|
||||
|
||||
export type MinecraftServerStatus = z.infer<typeof responseSchema>;
|
||||
@@ -37,7 +37,7 @@
|
||||
"@mantine/spotlight": "^7.15.2",
|
||||
"@tabler/icons-react": "^3.26.0",
|
||||
"jotai": "^2.11.0",
|
||||
"next": "^14.2.21",
|
||||
"next": "^14.2.22",
|
||||
"react": "^19.0.0",
|
||||
"use-deep-compare-effect": "^1.8.1"
|
||||
},
|
||||
|
||||
@@ -4,14 +4,25 @@ import { ChildrenActionItem } from "./items/children-action-item";
|
||||
interface SpotlightChildrenActionsProps {
|
||||
childrenOptions: inferSearchInteractionOptions<"children">;
|
||||
query: string;
|
||||
setChildrenOptions: (options: inferSearchInteractionOptions<"children">) => void;
|
||||
}
|
||||
|
||||
export const SpotlightChildrenActions = ({ childrenOptions, query }: SpotlightChildrenActionsProps) => {
|
||||
export const SpotlightChildrenActions = ({
|
||||
childrenOptions,
|
||||
query,
|
||||
setChildrenOptions,
|
||||
}: SpotlightChildrenActionsProps) => {
|
||||
const actions = childrenOptions.useActions(childrenOptions.option, query);
|
||||
|
||||
return actions
|
||||
.filter((action) => (typeof action.hide === "function" ? !action.hide(childrenOptions.option) : !action.hide))
|
||||
.map((action) => (
|
||||
<ChildrenActionItem key={action.key} childrenOptions={childrenOptions} query={query} action={action} />
|
||||
<ChildrenActionItem
|
||||
key={action.key}
|
||||
childrenOptions={childrenOptions}
|
||||
query={query}
|
||||
action={action}
|
||||
setChildrenOptions={setChildrenOptions}
|
||||
/>
|
||||
));
|
||||
};
|
||||
@@ -8,9 +8,10 @@ interface ChildrenActionItemProps {
|
||||
childrenOptions: inferSearchInteractionOptions<"children">;
|
||||
query: string;
|
||||
action: ReturnType<inferSearchInteractionOptions<"children">["useActions"]>[number];
|
||||
setChildrenOptions: (options: inferSearchInteractionOptions<"children">) => void;
|
||||
}
|
||||
|
||||
export const ChildrenActionItem = ({ childrenOptions, action, query }: ChildrenActionItemProps) => {
|
||||
export const ChildrenActionItem = ({ childrenOptions, action, query, setChildrenOptions }: ChildrenActionItemProps) => {
|
||||
const interaction = action.useInteraction(childrenOptions.option, query);
|
||||
|
||||
const renderRoot =
|
||||
@@ -20,10 +21,20 @@ export const ChildrenActionItem = ({ childrenOptions, action, query }: ChildrenA
|
||||
}
|
||||
: undefined;
|
||||
|
||||
const onClick = interaction.type === "javaScript" ? interaction.onSelect : undefined;
|
||||
const onClick =
|
||||
interaction.type === "javaScript"
|
||||
? interaction.onSelect
|
||||
: interaction.type === "children"
|
||||
? () => setChildrenOptions(interaction)
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<Spotlight.Action renderRoot={renderRoot} onClick={onClick} className={classes.spotlightAction}>
|
||||
<Spotlight.Action
|
||||
renderRoot={renderRoot}
|
||||
onClick={onClick}
|
||||
closeSpotlightOnTrigger={interaction.type !== "children"}
|
||||
className={classes.spotlightAction}
|
||||
>
|
||||
<action.Component {...childrenOptions.option} />
|
||||
</Spotlight.Action>
|
||||
);
|
||||
|
||||
@@ -140,7 +140,11 @@ const SpotlightWithActiveMode = ({ modeState, activeMode, defaultMode }: Spotlig
|
||||
|
||||
<MantineSpotlight.ActionsList>
|
||||
{childrenOptions ? (
|
||||
<SpotlightChildrenActions childrenOptions={childrenOptions} query={query} />
|
||||
<SpotlightChildrenActions
|
||||
childrenOptions={childrenOptions}
|
||||
query={query}
|
||||
setChildrenOptions={setChildrenOptions}
|
||||
/>
|
||||
) : (
|
||||
<SpotlightActionGroups
|
||||
setMode={(mode) => {
|
||||
|
||||
@@ -10,7 +10,10 @@ export interface CreateChildrenOptionsProps<TParentOptions extends Record<string
|
||||
export interface ChildrenAction<TParentOptions extends Record<string, unknown>> {
|
||||
key: string;
|
||||
Component: (option: TParentOptions) => JSX.Element;
|
||||
useInteraction: (option: TParentOptions, query: string) => inferSearchInteractionDefinition<"link" | "javaScript">;
|
||||
useInteraction: (
|
||||
option: TParentOptions,
|
||||
query: string,
|
||||
) => inferSearchInteractionDefinition<"link" | "javaScript" | "children">;
|
||||
hide?: boolean | ((option: TParentOptions) => boolean);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
import { Group, Image, Kbd, Stack, Text } from "@mantine/core";
|
||||
import { IconSearch } from "@tabler/icons-react";
|
||||
import { IconDownload, IconSearch } from "@tabler/icons-react";
|
||||
|
||||
import type { RouterOutputs } from "@homarr/api";
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import type { IntegrationKind } from "@homarr/definitions";
|
||||
import { getIntegrationKindsByCategory, getIntegrationName } from "@homarr/definitions";
|
||||
import { useModalAction } from "@homarr/modals";
|
||||
import { RequestMediaModal } from "@homarr/modals-collection";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import { createChildrenOptions } from "../../lib/children";
|
||||
@@ -11,6 +15,100 @@ import { interaction } from "../../lib/interaction";
|
||||
|
||||
type SearchEngine = RouterOutputs["searchEngine"]["search"][number];
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-definitions
|
||||
type MediaRequestChildrenProps = {
|
||||
result: {
|
||||
id: number;
|
||||
image?: string;
|
||||
name: string;
|
||||
link: string;
|
||||
text?: string;
|
||||
type: "tv" | "movie";
|
||||
inLibrary: boolean;
|
||||
};
|
||||
integration: {
|
||||
kind: IntegrationKind;
|
||||
url: string;
|
||||
id: string;
|
||||
};
|
||||
};
|
||||
|
||||
const mediaRequestsChildrenOptions = createChildrenOptions<MediaRequestChildrenProps>({
|
||||
useActions() {
|
||||
const { openModal } = useModalAction(RequestMediaModal);
|
||||
return [
|
||||
{
|
||||
key: "request",
|
||||
hide: (option) => option.result.inLibrary,
|
||||
Component(option) {
|
||||
const t = useScopedI18n("search.mode.media");
|
||||
return (
|
||||
<Group mx="md" my="sm" wrap="nowrap">
|
||||
<IconDownload stroke={1.5} />
|
||||
{option.result.type === "tv" && <Text>{t("requestSeries")}</Text>}
|
||||
{option.result.type === "movie" && <Text>{t("requestMovie")}</Text>}
|
||||
</Group>
|
||||
);
|
||||
},
|
||||
useInteraction: interaction.javaScript((option) => ({
|
||||
onSelect() {
|
||||
openModal(
|
||||
{
|
||||
integrationId: option.integration.id,
|
||||
mediaId: option.result.id,
|
||||
mediaType: option.result.type,
|
||||
},
|
||||
{
|
||||
title(t) {
|
||||
return t("search.engine.media.request.modal.title", { name: option.result.name });
|
||||
},
|
||||
},
|
||||
);
|
||||
},
|
||||
})),
|
||||
},
|
||||
{
|
||||
key: "open",
|
||||
Component({ integration }) {
|
||||
const tChildren = useScopedI18n("search.mode.media");
|
||||
return (
|
||||
<Group mx="md" my="sm" wrap="nowrap">
|
||||
<IconSearch stroke={1.5} />
|
||||
<Text>{tChildren("openIn", { kind: getIntegrationName(integration.kind) })}</Text>
|
||||
</Group>
|
||||
);
|
||||
},
|
||||
useInteraction({ result }) {
|
||||
return {
|
||||
type: "link",
|
||||
href: result.link,
|
||||
newTab: true,
|
||||
};
|
||||
},
|
||||
},
|
||||
];
|
||||
},
|
||||
DetailComponent({ options }) {
|
||||
return (
|
||||
<Group mx="md" my="sm" wrap="nowrap">
|
||||
{options.result.image ? (
|
||||
<Image src={options.result.image} w={35} h={50} fit="cover" radius={"md"} />
|
||||
) : (
|
||||
<IconSearch stroke={1.5} size={35} />
|
||||
)}
|
||||
<Stack gap={2}>
|
||||
<Text>{options.result.name}</Text>
|
||||
{options.result.text && (
|
||||
<Text c="dimmed" size="sm" lineClamp={2}>
|
||||
{options.result.text}
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
</Group>
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
export const searchEnginesChildrenOptions = createChildrenOptions<SearchEngine>({
|
||||
useActions: (searchEngine, query) => {
|
||||
const { data } = clientApi.integration.searchInIntegration.useQuery(
|
||||
@@ -64,10 +162,48 @@ export const searchEnginesChildrenOptions = createChildrenOptions<SearchEngine>(
|
||||
</Group>
|
||||
);
|
||||
},
|
||||
useInteraction: interaction.link(() => ({
|
||||
href: searchResult.link,
|
||||
newTab: true,
|
||||
})),
|
||||
useInteraction(searchEngine) {
|
||||
if (searchEngine.type !== "fromIntegration") {
|
||||
throw new Error("Invalid search engine type");
|
||||
}
|
||||
|
||||
if (!searchEngine.integration) {
|
||||
throw new Error("Invalid search engine integration");
|
||||
}
|
||||
|
||||
if (
|
||||
getIntegrationKindsByCategory("mediaRequest").some(
|
||||
(categoryKind) => categoryKind === searchEngine.integration?.kind,
|
||||
) &&
|
||||
"type" in searchResult
|
||||
) {
|
||||
const type = searchResult.type;
|
||||
if (type === "person") {
|
||||
return {
|
||||
type: "link",
|
||||
href: searchResult.link,
|
||||
newTab: true,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
type: "children",
|
||||
...mediaRequestsChildrenOptions({
|
||||
result: {
|
||||
...searchResult,
|
||||
type,
|
||||
},
|
||||
integration: searchEngine.integration,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
type: "link",
|
||||
href: searchResult.link,
|
||||
newTab: true,
|
||||
};
|
||||
},
|
||||
}));
|
||||
},
|
||||
DetailComponent({ options }) {
|
||||
|
||||
@@ -32,7 +32,7 @@
|
||||
"dayjs": "^1.11.13",
|
||||
"deepmerge": "4.3.1",
|
||||
"mantine-react-table": "2.0.0-beta.7",
|
||||
"next": "^14.2.21",
|
||||
"next": "^14.2.22",
|
||||
"next-intl": "3.26.3",
|
||||
"react": "^19.0.0"
|
||||
},
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1381,7 +1381,7 @@
|
||||
"memory": "crwdns5734:0{memory}crwdne5734:0",
|
||||
"memoryAvailable": "crwdns5736:0{memoryAvailable}crwdnd5736:0{percent}crwdne5736:0",
|
||||
"version": "crwdns5738:0{version}crwdne5738:0",
|
||||
"uptime": "crwdns5740:0{days}crwdnd5740:0{hours}crwdnd5740:0{minutes}crwdne5740:0",
|
||||
"uptime": "crwdns7008:0{months}crwdnd7008:0{days}crwdnd7008:0{hours}crwdnd7008:0{minutes}crwdne7008:0",
|
||||
"loadAverage": "crwdns5742:0crwdne5742:0",
|
||||
"minute": "crwdns5744:0crwdne5744:0",
|
||||
"minutes": "crwdns5746:0{count}crwdne5746:0",
|
||||
@@ -1457,7 +1457,12 @@
|
||||
"mediaServer": {
|
||||
"name": "crwdns5810:0crwdne5810:0",
|
||||
"description": "crwdns5812:0crwdne5812:0",
|
||||
"option": {}
|
||||
"option": {},
|
||||
"items": {
|
||||
"user": "crwdns7010:0crwdne7010:0",
|
||||
"name": "crwdns7012:0crwdne7012:0",
|
||||
"id": "crwdns7014:0crwdne7014:0"
|
||||
}
|
||||
},
|
||||
"downloads": {
|
||||
"name": "crwdns5814:0crwdne5814:0",
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -2,130 +2,130 @@
|
||||
"init": {
|
||||
"step": {
|
||||
"start": {
|
||||
"title": "",
|
||||
"subtitle": "",
|
||||
"description": "",
|
||||
"title": "Velkommen til Homarr",
|
||||
"subtitle": "Lad os komme i gang med at konfigurere din Homarr instans.",
|
||||
"description": "For at komme i gang, skal du vælge, hvordan du vil konfigurere din Homarr instans.",
|
||||
"action": {
|
||||
"scratch": "",
|
||||
"importOldmarr": ""
|
||||
"scratch": "Start fra bunden",
|
||||
"importOldmarr": "Importer fra Homarr før 1.0"
|
||||
}
|
||||
},
|
||||
"import": {
|
||||
"title": "",
|
||||
"subtitle": "",
|
||||
"title": "Importer data",
|
||||
"subtitle": "Du kan importere data fra en eksisterende Homarr instans.",
|
||||
"dropzone": {
|
||||
"title": "",
|
||||
"description": ""
|
||||
"title": "Træk zip-filen her eller klik for at gennemse",
|
||||
"description": "Den uploadede zip vil blive behandlet, og du vil kunne vælge, hvad du vil importere"
|
||||
},
|
||||
"fileInfo": {
|
||||
"action": {
|
||||
"change": ""
|
||||
"change": "Ændre fil"
|
||||
}
|
||||
},
|
||||
"importSettings": {
|
||||
"title": "",
|
||||
"description": ""
|
||||
"title": "Importer indstillinger",
|
||||
"description": "Konfigurer importadfærden"
|
||||
},
|
||||
"boardSelection": {
|
||||
"title": "",
|
||||
"description": "",
|
||||
"title": "Fundet {count} tavler",
|
||||
"description": "Vælg alle tavler med deres størrelser du vil importere",
|
||||
"action": {
|
||||
"selectAll": "",
|
||||
"unselectAll": ""
|
||||
"selectAll": "Vælg alle",
|
||||
"unselectAll": "Fravælg alle"
|
||||
}
|
||||
},
|
||||
"summary": {
|
||||
"title": "",
|
||||
"description": "",
|
||||
"title": "Importér sammendrag",
|
||||
"description": "I nedenstående oversigt kan du se hvad der vil blive importeret",
|
||||
"action": {
|
||||
"import": ""
|
||||
"import": "Bekræft import og fortsæt"
|
||||
},
|
||||
"entities": {
|
||||
"apps": "",
|
||||
"boards": "",
|
||||
"integrations": "",
|
||||
"credentialUsers": ""
|
||||
"apps": "Apps",
|
||||
"boards": "Tavler",
|
||||
"integrations": "Integrationer",
|
||||
"credentialUsers": "Brugere, der er legitimerede"
|
||||
}
|
||||
},
|
||||
"tokenModal": {
|
||||
"title": "",
|
||||
"title": "Angiv import token",
|
||||
"field": {
|
||||
"token": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
"label": "Token",
|
||||
"description": "Indtast den viste import token fra din tidligere homarr instans"
|
||||
}
|
||||
},
|
||||
"notification": {
|
||||
"error": {
|
||||
"title": "",
|
||||
"message": ""
|
||||
"title": "Ugyldig token",
|
||||
"message": "Den indtastede token er ugyldig"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"user": {
|
||||
"title": "",
|
||||
"subtitle": "",
|
||||
"title": "Admin bruger",
|
||||
"subtitle": "Angiv legitimationsoplysninger for din administrator bruger.",
|
||||
"notification": {
|
||||
"success": {
|
||||
"title": "",
|
||||
"message": ""
|
||||
"title": "Bruger oprettet",
|
||||
"message": "Brugeren blev oprettet"
|
||||
},
|
||||
"error": {
|
||||
"title": ""
|
||||
"title": "Brugeroprettelse mislykkedes"
|
||||
}
|
||||
}
|
||||
},
|
||||
"group": {
|
||||
"title": "",
|
||||
"subtitle": "",
|
||||
"title": "Ekstern gruppe",
|
||||
"subtitle": "Angiv den gruppe, der skal bruges til eksterne brugere.",
|
||||
"form": {
|
||||
"name": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
"label": "Gruppenavn",
|
||||
"description": "Navn skal matche admin gruppe fra ekstern udbyder"
|
||||
}
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"title": "Indstillinger",
|
||||
"subtitle": ""
|
||||
"subtitle": "Konfigurer serverindstillinger."
|
||||
},
|
||||
"finish": {
|
||||
"title": "",
|
||||
"subtitle": "",
|
||||
"description": "",
|
||||
"title": "Afslut opsætningen",
|
||||
"subtitle": "Du er klar!",
|
||||
"description": "Du har fuldført opsætningsprocessen. Du kan nu begynde at bruge Homarr. Vælg din næste handling:",
|
||||
"action": {
|
||||
"goToBoard": "",
|
||||
"createBoard": "",
|
||||
"inviteUser": "",
|
||||
"docs": ""
|
||||
"goToBoard": "Gå til {name} tavle",
|
||||
"createBoard": "Opret din første tavle",
|
||||
"inviteUser": "Inviter andre brugere",
|
||||
"docs": "Læs dokumentationen"
|
||||
}
|
||||
}
|
||||
},
|
||||
"backToStart": ""
|
||||
"backToStart": "Tilbage til start"
|
||||
},
|
||||
"user": {
|
||||
"title": "Brugere",
|
||||
"name": "Bruger",
|
||||
"page": {
|
||||
"login": {
|
||||
"title": "",
|
||||
"subtitle": ""
|
||||
"title": "Log ind på din konto",
|
||||
"subtitle": "Velkommen tilbage! Indtast venligst dine legitimationsoplysninger"
|
||||
},
|
||||
"invite": {
|
||||
"title": "",
|
||||
"subtitle": "",
|
||||
"description": ""
|
||||
"title": "Tilmeld Homarr",
|
||||
"subtitle": "Velkommen til Homarr! Opret venligst din konto",
|
||||
"description": "Du blev inviteret af {username}"
|
||||
},
|
||||
"init": {
|
||||
"title": "",
|
||||
"subtitle": ""
|
||||
"title": "Ny Homarr installation",
|
||||
"subtitle": "Opret venligst den første administrator bruger"
|
||||
}
|
||||
},
|
||||
"field": {
|
||||
"email": {
|
||||
"label": "E-mail",
|
||||
"verified": ""
|
||||
"verified": "Verificeret"
|
||||
},
|
||||
"username": {
|
||||
"label": "Brugernavn"
|
||||
@@ -133,46 +133,46 @@
|
||||
"password": {
|
||||
"label": "Adgangskode",
|
||||
"requirement": {
|
||||
"length": "",
|
||||
"length": "Inkluderer mindst 8 tegn",
|
||||
"lowercase": "Inkluderer små bogstaver",
|
||||
"uppercase": "Inkluderer store bogstaver",
|
||||
"number": "Inkluderer nummer",
|
||||
"special": ""
|
||||
"special": "Inkluderer specielt symbol"
|
||||
}
|
||||
},
|
||||
"passwordConfirm": {
|
||||
"label": "Bekræft kodeord"
|
||||
},
|
||||
"previousPassword": {
|
||||
"label": ""
|
||||
"label": "Tidligere adgangskode"
|
||||
},
|
||||
"homeBoard": {
|
||||
"label": ""
|
||||
"label": "Hjemme tavle"
|
||||
},
|
||||
"pingIconsEnabled": {
|
||||
"label": ""
|
||||
"label": "Brug ikoner til pings"
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"usernameTaken": ""
|
||||
"usernameTaken": "Brugernavn allerede taget"
|
||||
},
|
||||
"action": {
|
||||
"login": {
|
||||
"label": "Log ind",
|
||||
"labelWith": "",
|
||||
"labelWith": "Log ind med {provider}",
|
||||
"notification": {
|
||||
"success": {
|
||||
"title": "",
|
||||
"message": ""
|
||||
"title": "Login lykkedes",
|
||||
"message": "Du er nu logget ind"
|
||||
},
|
||||
"error": {
|
||||
"title": "",
|
||||
"message": ""
|
||||
"title": "Login mislykkedes",
|
||||
"message": "Dit login mislykkedes"
|
||||
}
|
||||
},
|
||||
"forgotPassword": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
"label": "Glemt din adgangskode?",
|
||||
"description": "En administrator kan bruge følgende kommando til at nulstille din adgangskode:"
|
||||
}
|
||||
},
|
||||
"register": {
|
||||
@@ -180,81 +180,81 @@
|
||||
"notification": {
|
||||
"success": {
|
||||
"title": "Konto oprettet",
|
||||
"message": ""
|
||||
"message": "Log venligst på for at fortsætte"
|
||||
},
|
||||
"error": {
|
||||
"title": "",
|
||||
"message": ""
|
||||
"title": "Oprettelse af konto mislykkedes",
|
||||
"message": "Din konto kunne ikke oprettes"
|
||||
}
|
||||
}
|
||||
},
|
||||
"create": "Opret bruger",
|
||||
"changePassword": {
|
||||
"label": "",
|
||||
"label": "Skift adgangskode",
|
||||
"notification": {
|
||||
"success": {
|
||||
"message": ""
|
||||
"message": "Adgangskoden er blevet ændret"
|
||||
},
|
||||
"error": {
|
||||
"message": ""
|
||||
"message": "Adgangskoden kunne ikke ændres"
|
||||
}
|
||||
}
|
||||
},
|
||||
"changeHomeBoard": {
|
||||
"notification": {
|
||||
"success": {
|
||||
"message": ""
|
||||
"message": "Hjemmetavle blev ændret"
|
||||
},
|
||||
"error": {
|
||||
"message": ""
|
||||
"message": "Kan ikke ændre hjemmetavle"
|
||||
}
|
||||
}
|
||||
},
|
||||
"changeFirstDayOfWeek": {
|
||||
"notification": {
|
||||
"success": {
|
||||
"message": ""
|
||||
"message": "Første ugedag ændret med succes"
|
||||
},
|
||||
"error": {
|
||||
"message": ""
|
||||
"message": "Kan ikke ændre den første ugedag"
|
||||
}
|
||||
}
|
||||
},
|
||||
"changePingIconsEnabled": {
|
||||
"notification": {
|
||||
"success": {
|
||||
"message": ""
|
||||
"message": "Ping ikoner skiftet"
|
||||
},
|
||||
"error": {
|
||||
"message": ""
|
||||
"message": "Kan ikke skifte ping-ikoner"
|
||||
}
|
||||
}
|
||||
},
|
||||
"manageAvatar": {
|
||||
"changeImage": {
|
||||
"label": "",
|
||||
"label": "Skift billede",
|
||||
"notification": {
|
||||
"success": {
|
||||
"message": ""
|
||||
"message": "Billedet blev ændret"
|
||||
},
|
||||
"error": {
|
||||
"message": ""
|
||||
"message": "Kan ikke ændre billede"
|
||||
},
|
||||
"toLarge": {
|
||||
"title": "",
|
||||
"message": ""
|
||||
"title": "Billedet er for stort",
|
||||
"message": "Maks billedstørrelse er {size}"
|
||||
}
|
||||
}
|
||||
},
|
||||
"removeImage": {
|
||||
"label": "",
|
||||
"confirm": "",
|
||||
"label": "Fjern billede",
|
||||
"confirm": "Er du sikker på du vil fjerne billedet?",
|
||||
"notification": {
|
||||
"success": {
|
||||
"message": ""
|
||||
"message": "Billedet blev fjernet"
|
||||
},
|
||||
"error": {
|
||||
"message": ""
|
||||
"message": "Kan ikke fjerne billede"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -262,15 +262,15 @@
|
||||
"editProfile": {
|
||||
"notification": {
|
||||
"success": {
|
||||
"message": ""
|
||||
"message": "Profilen blev opdateret"
|
||||
},
|
||||
"error": {
|
||||
"message": ""
|
||||
"message": "Ikke i stand til at opdatere profil"
|
||||
}
|
||||
}
|
||||
},
|
||||
"delete": {
|
||||
"label": "",
|
||||
"label": "Slet brugeren permanent",
|
||||
"description": "",
|
||||
"confirm": ""
|
||||
},
|
||||
@@ -293,23 +293,23 @@
|
||||
},
|
||||
"permission": {
|
||||
"admin": {
|
||||
"title": "",
|
||||
"title": "Admin",
|
||||
"item": {
|
||||
"admin": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
"label": "Administrator",
|
||||
"description": "Medlemmer med denne tilladelse har fuld adgang til alle funktioner og indstillinger"
|
||||
}
|
||||
}
|
||||
},
|
||||
"app": {
|
||||
"title": "",
|
||||
"title": "Apps",
|
||||
"item": {
|
||||
"create": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
"label": "Opret apps",
|
||||
"description": "Tillad medlemmer at oprette apps"
|
||||
},
|
||||
"use-all": {
|
||||
"label": "",
|
||||
"label": "Brug alle apps",
|
||||
"description": ""
|
||||
},
|
||||
"modify-all": {
|
||||
@@ -612,6 +612,10 @@
|
||||
},
|
||||
"url": {
|
||||
"label": ""
|
||||
},
|
||||
"attemptSearchEngineCreation": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
}
|
||||
},
|
||||
"action": {
|
||||
@@ -1151,6 +1155,25 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"minecraftServerStatus": {
|
||||
"name": "",
|
||||
"description": "",
|
||||
"option": {
|
||||
"title": {
|
||||
"label": ""
|
||||
},
|
||||
"domain": {
|
||||
"label": ""
|
||||
},
|
||||
"isBedrockServer": {
|
||||
"label": ""
|
||||
}
|
||||
},
|
||||
"status": {
|
||||
"online": "",
|
||||
"offline": ""
|
||||
}
|
||||
},
|
||||
"notebook": {
|
||||
"name": "Notesbog",
|
||||
"description": "",
|
||||
@@ -1457,7 +1480,12 @@
|
||||
"mediaServer": {
|
||||
"name": "",
|
||||
"description": "",
|
||||
"option": {}
|
||||
"option": {},
|
||||
"items": {
|
||||
"user": "",
|
||||
"name": "",
|
||||
"id": ""
|
||||
}
|
||||
},
|
||||
"downloads": {
|
||||
"name": "",
|
||||
@@ -2319,6 +2347,9 @@
|
||||
"error": "Fejl"
|
||||
},
|
||||
"job": {
|
||||
"minecraftServerStatus": {
|
||||
"label": ""
|
||||
},
|
||||
"iconsUpdater": {
|
||||
"label": ""
|
||||
},
|
||||
@@ -2717,6 +2748,11 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"media": {
|
||||
"requestMovie": "",
|
||||
"requestSeries": "",
|
||||
"openIn": ""
|
||||
},
|
||||
"external": {
|
||||
"help": "",
|
||||
"group": {
|
||||
@@ -2957,6 +2993,22 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"media": {
|
||||
"request": {
|
||||
"modal": {
|
||||
"title": "",
|
||||
"table": {
|
||||
"header": {
|
||||
"season": "",
|
||||
"episodes": ""
|
||||
}
|
||||
},
|
||||
"button": {
|
||||
"send": ""
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -612,6 +612,10 @@
|
||||
},
|
||||
"url": {
|
||||
"label": "URL"
|
||||
},
|
||||
"attemptSearchEngineCreation": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
}
|
||||
},
|
||||
"action": {
|
||||
@@ -1151,6 +1155,25 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"minecraftServerStatus": {
|
||||
"name": "",
|
||||
"description": "",
|
||||
"option": {
|
||||
"title": {
|
||||
"label": ""
|
||||
},
|
||||
"domain": {
|
||||
"label": ""
|
||||
},
|
||||
"isBedrockServer": {
|
||||
"label": ""
|
||||
}
|
||||
},
|
||||
"status": {
|
||||
"online": "",
|
||||
"offline": ""
|
||||
}
|
||||
},
|
||||
"notebook": {
|
||||
"name": "Notizbuch",
|
||||
"description": "Ein einfaches Notizbuch Widget, das Markdown unterstützt",
|
||||
@@ -1381,7 +1404,7 @@
|
||||
"memory": "Speicher: {memory}GiB",
|
||||
"memoryAvailable": "Verfügbar: {memoryAvailable} GiB ({percent}%)",
|
||||
"version": "",
|
||||
"uptime": "Betriebszeit: {days} Tage, {hours} Stunden, {minutes} Minuten",
|
||||
"uptime": "Laufzeit: {months} Monate, {days} Tage, {hours} Stunden, {minutes} Minuten",
|
||||
"loadAverage": "Durchschnittliche Last:",
|
||||
"minute": "1 Minute",
|
||||
"minutes": "{count} Minuten",
|
||||
@@ -1457,7 +1480,12 @@
|
||||
"mediaServer": {
|
||||
"name": "",
|
||||
"description": "",
|
||||
"option": {}
|
||||
"option": {},
|
||||
"items": {
|
||||
"user": "",
|
||||
"name": "",
|
||||
"id": ""
|
||||
}
|
||||
},
|
||||
"downloads": {
|
||||
"name": "",
|
||||
@@ -2319,6 +2347,9 @@
|
||||
"error": "Fehler"
|
||||
},
|
||||
"job": {
|
||||
"minecraftServerStatus": {
|
||||
"label": ""
|
||||
},
|
||||
"iconsUpdater": {
|
||||
"label": ""
|
||||
},
|
||||
@@ -2717,6 +2748,11 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"media": {
|
||||
"requestMovie": "",
|
||||
"requestSeries": "",
|
||||
"openIn": ""
|
||||
},
|
||||
"external": {
|
||||
"help": "",
|
||||
"group": {
|
||||
@@ -2957,6 +2993,22 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"media": {
|
||||
"request": {
|
||||
"modal": {
|
||||
"title": "",
|
||||
"table": {
|
||||
"header": {
|
||||
"season": "",
|
||||
"episodes": ""
|
||||
}
|
||||
},
|
||||
"button": {
|
||||
"send": ""
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -612,6 +612,10 @@
|
||||
},
|
||||
"url": {
|
||||
"label": ""
|
||||
},
|
||||
"attemptSearchEngineCreation": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
}
|
||||
},
|
||||
"action": {
|
||||
@@ -1151,6 +1155,25 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"minecraftServerStatus": {
|
||||
"name": "",
|
||||
"description": "",
|
||||
"option": {
|
||||
"title": {
|
||||
"label": ""
|
||||
},
|
||||
"domain": {
|
||||
"label": ""
|
||||
},
|
||||
"isBedrockServer": {
|
||||
"label": ""
|
||||
}
|
||||
},
|
||||
"status": {
|
||||
"online": "",
|
||||
"offline": ""
|
||||
}
|
||||
},
|
||||
"notebook": {
|
||||
"name": "Σημειωματάριο",
|
||||
"description": "",
|
||||
@@ -1457,7 +1480,12 @@
|
||||
"mediaServer": {
|
||||
"name": "",
|
||||
"description": "",
|
||||
"option": {}
|
||||
"option": {},
|
||||
"items": {
|
||||
"user": "",
|
||||
"name": "",
|
||||
"id": ""
|
||||
}
|
||||
},
|
||||
"downloads": {
|
||||
"name": "",
|
||||
@@ -2319,6 +2347,9 @@
|
||||
"error": "Σφάλμα"
|
||||
},
|
||||
"job": {
|
||||
"minecraftServerStatus": {
|
||||
"label": ""
|
||||
},
|
||||
"iconsUpdater": {
|
||||
"label": ""
|
||||
},
|
||||
@@ -2717,6 +2748,11 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"media": {
|
||||
"requestMovie": "",
|
||||
"requestSeries": "",
|
||||
"openIn": ""
|
||||
},
|
||||
"external": {
|
||||
"help": "",
|
||||
"group": {
|
||||
@@ -2957,6 +2993,22 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"media": {
|
||||
"request": {
|
||||
"modal": {
|
||||
"title": "",
|
||||
"table": {
|
||||
"header": {
|
||||
"season": "",
|
||||
"episodes": ""
|
||||
}
|
||||
},
|
||||
"button": {
|
||||
"send": ""
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -612,6 +612,10 @@
|
||||
},
|
||||
"url": {
|
||||
"label": "Url"
|
||||
},
|
||||
"attemptSearchEngineCreation": {
|
||||
"label": "Create Search Engine",
|
||||
"description": "Integration \"{kind}\" can be used with the search engines. Check this to automatically configure the search engine."
|
||||
}
|
||||
},
|
||||
"action": {
|
||||
@@ -1151,6 +1155,25 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"minecraftServerStatus": {
|
||||
"name": "Minecraft Server Status",
|
||||
"description": "Displays the status of a Minecraft server",
|
||||
"option": {
|
||||
"title": {
|
||||
"label": "Title"
|
||||
},
|
||||
"domain": {
|
||||
"label": "Server address"
|
||||
},
|
||||
"isBedrockServer": {
|
||||
"label": "Bedrock server"
|
||||
}
|
||||
},
|
||||
"status": {
|
||||
"online": "Online",
|
||||
"offline": "Offline"
|
||||
}
|
||||
},
|
||||
"notebook": {
|
||||
"name": "Notebook",
|
||||
"description": "A simple notebook widget that supports markdown",
|
||||
@@ -1381,7 +1404,7 @@
|
||||
"memory": "Memory: {memory}GiB",
|
||||
"memoryAvailable": "Available: {memoryAvailable}GiB ({percent}%)",
|
||||
"version": "Version: {version}",
|
||||
"uptime": "Uptime: {days} Days, {hours} Hours, {minutes} Minutes",
|
||||
"uptime": "Uptime: {months} Months, {days} Days, {hours} Hours, {minutes} Minutes",
|
||||
"loadAverage": "Load average:",
|
||||
"minute": "1 minute",
|
||||
"minutes": "{count} minutes",
|
||||
@@ -1457,7 +1480,12 @@
|
||||
"mediaServer": {
|
||||
"name": "Current media server streams",
|
||||
"description": "Show the current streams on your media servers",
|
||||
"option": {}
|
||||
"option": {},
|
||||
"items": {
|
||||
"user": "User",
|
||||
"name": "Name",
|
||||
"id": "Id"
|
||||
}
|
||||
},
|
||||
"downloads": {
|
||||
"name": "Download Client",
|
||||
@@ -2319,6 +2347,9 @@
|
||||
"error": "Error"
|
||||
},
|
||||
"job": {
|
||||
"minecraftServerStatus": {
|
||||
"label": "Minecraft server status"
|
||||
},
|
||||
"iconsUpdater": {
|
||||
"label": "Icons Updater"
|
||||
},
|
||||
@@ -2717,6 +2748,11 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"media": {
|
||||
"requestMovie": "Request movie",
|
||||
"requestSeries": "Request series",
|
||||
"openIn": "Open in {kind}"
|
||||
},
|
||||
"external": {
|
||||
"help": "Use an external search engine",
|
||||
"group": {
|
||||
@@ -2957,6 +2993,22 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"media": {
|
||||
"request": {
|
||||
"modal": {
|
||||
"title": "Request \"{name}\"",
|
||||
"table": {
|
||||
"header": {
|
||||
"season": "Season",
|
||||
"episodes": "Episodes"
|
||||
}
|
||||
},
|
||||
"button": {
|
||||
"send": "Send request"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,20 +2,20 @@
|
||||
"init": {
|
||||
"step": {
|
||||
"start": {
|
||||
"title": "",
|
||||
"title": "Bienvenido a Homarr",
|
||||
"subtitle": "",
|
||||
"description": "",
|
||||
"action": {
|
||||
"scratch": "",
|
||||
"importOldmarr": ""
|
||||
"scratch": "Empezar de cero",
|
||||
"importOldmarr": "Importar de Homarr antes de 1.0"
|
||||
}
|
||||
},
|
||||
"import": {
|
||||
"title": "",
|
||||
"title": "Importar datos",
|
||||
"subtitle": "",
|
||||
"dropzone": {
|
||||
"title": "",
|
||||
"description": ""
|
||||
"title": "Arrastre el archivo zip aquí o haga clic para navegar",
|
||||
"description": "El zip subido será procesado y podrás seleccionar lo que quieres importar"
|
||||
},
|
||||
"fileInfo": {
|
||||
"action": {
|
||||
@@ -23,7 +23,7 @@
|
||||
}
|
||||
},
|
||||
"importSettings": {
|
||||
"title": "",
|
||||
"title": "Importar ajustes",
|
||||
"description": ""
|
||||
},
|
||||
"boardSelection": {
|
||||
@@ -612,6 +612,10 @@
|
||||
},
|
||||
"url": {
|
||||
"label": ""
|
||||
},
|
||||
"attemptSearchEngineCreation": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
}
|
||||
},
|
||||
"action": {
|
||||
@@ -1151,6 +1155,25 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"minecraftServerStatus": {
|
||||
"name": "",
|
||||
"description": "",
|
||||
"option": {
|
||||
"title": {
|
||||
"label": ""
|
||||
},
|
||||
"domain": {
|
||||
"label": ""
|
||||
},
|
||||
"isBedrockServer": {
|
||||
"label": ""
|
||||
}
|
||||
},
|
||||
"status": {
|
||||
"online": "",
|
||||
"offline": ""
|
||||
}
|
||||
},
|
||||
"notebook": {
|
||||
"name": "Bloc de notas",
|
||||
"description": "",
|
||||
@@ -1457,7 +1480,12 @@
|
||||
"mediaServer": {
|
||||
"name": "",
|
||||
"description": "",
|
||||
"option": {}
|
||||
"option": {},
|
||||
"items": {
|
||||
"user": "",
|
||||
"name": "",
|
||||
"id": ""
|
||||
}
|
||||
},
|
||||
"downloads": {
|
||||
"name": "",
|
||||
@@ -2319,6 +2347,9 @@
|
||||
"error": ""
|
||||
},
|
||||
"job": {
|
||||
"minecraftServerStatus": {
|
||||
"label": ""
|
||||
},
|
||||
"iconsUpdater": {
|
||||
"label": ""
|
||||
},
|
||||
@@ -2717,6 +2748,11 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"media": {
|
||||
"requestMovie": "",
|
||||
"requestSeries": "",
|
||||
"openIn": ""
|
||||
},
|
||||
"external": {
|
||||
"help": "",
|
||||
"group": {
|
||||
@@ -2957,6 +2993,22 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"media": {
|
||||
"request": {
|
||||
"modal": {
|
||||
"title": "",
|
||||
"table": {
|
||||
"header": {
|
||||
"season": "",
|
||||
"episodes": ""
|
||||
}
|
||||
},
|
||||
"button": {
|
||||
"send": ""
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -612,6 +612,10 @@
|
||||
},
|
||||
"url": {
|
||||
"label": ""
|
||||
},
|
||||
"attemptSearchEngineCreation": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
}
|
||||
},
|
||||
"action": {
|
||||
@@ -1151,6 +1155,25 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"minecraftServerStatus": {
|
||||
"name": "",
|
||||
"description": "",
|
||||
"option": {
|
||||
"title": {
|
||||
"label": ""
|
||||
},
|
||||
"domain": {
|
||||
"label": ""
|
||||
},
|
||||
"isBedrockServer": {
|
||||
"label": ""
|
||||
}
|
||||
},
|
||||
"status": {
|
||||
"online": "",
|
||||
"offline": ""
|
||||
}
|
||||
},
|
||||
"notebook": {
|
||||
"name": "",
|
||||
"description": "",
|
||||
@@ -1457,7 +1480,12 @@
|
||||
"mediaServer": {
|
||||
"name": "",
|
||||
"description": "",
|
||||
"option": {}
|
||||
"option": {},
|
||||
"items": {
|
||||
"user": "",
|
||||
"name": "",
|
||||
"id": ""
|
||||
}
|
||||
},
|
||||
"downloads": {
|
||||
"name": "",
|
||||
@@ -2319,6 +2347,9 @@
|
||||
"error": ""
|
||||
},
|
||||
"job": {
|
||||
"minecraftServerStatus": {
|
||||
"label": ""
|
||||
},
|
||||
"iconsUpdater": {
|
||||
"label": ""
|
||||
},
|
||||
@@ -2717,6 +2748,11 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"media": {
|
||||
"requestMovie": "",
|
||||
"requestSeries": "",
|
||||
"openIn": ""
|
||||
},
|
||||
"external": {
|
||||
"help": "",
|
||||
"group": {
|
||||
@@ -2957,6 +2993,22 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"media": {
|
||||
"request": {
|
||||
"modal": {
|
||||
"title": "",
|
||||
"table": {
|
||||
"header": {
|
||||
"season": "",
|
||||
"episodes": ""
|
||||
}
|
||||
},
|
||||
"button": {
|
||||
"send": ""
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -612,6 +612,10 @@
|
||||
},
|
||||
"url": {
|
||||
"label": "Url"
|
||||
},
|
||||
"attemptSearchEngineCreation": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
}
|
||||
},
|
||||
"action": {
|
||||
@@ -1151,6 +1155,25 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"minecraftServerStatus": {
|
||||
"name": "",
|
||||
"description": "",
|
||||
"option": {
|
||||
"title": {
|
||||
"label": ""
|
||||
},
|
||||
"domain": {
|
||||
"label": ""
|
||||
},
|
||||
"isBedrockServer": {
|
||||
"label": ""
|
||||
}
|
||||
},
|
||||
"status": {
|
||||
"online": "",
|
||||
"offline": ""
|
||||
}
|
||||
},
|
||||
"notebook": {
|
||||
"name": "Bloc-notes",
|
||||
"description": "",
|
||||
@@ -1457,7 +1480,12 @@
|
||||
"mediaServer": {
|
||||
"name": "",
|
||||
"description": "",
|
||||
"option": {}
|
||||
"option": {},
|
||||
"items": {
|
||||
"user": "",
|
||||
"name": "",
|
||||
"id": ""
|
||||
}
|
||||
},
|
||||
"downloads": {
|
||||
"name": "",
|
||||
@@ -2319,6 +2347,9 @@
|
||||
"error": "Erreur"
|
||||
},
|
||||
"job": {
|
||||
"minecraftServerStatus": {
|
||||
"label": ""
|
||||
},
|
||||
"iconsUpdater": {
|
||||
"label": ""
|
||||
},
|
||||
@@ -2717,6 +2748,11 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"media": {
|
||||
"requestMovie": "",
|
||||
"requestSeries": "",
|
||||
"openIn": ""
|
||||
},
|
||||
"external": {
|
||||
"help": "",
|
||||
"group": {
|
||||
@@ -2957,6 +2993,22 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"media": {
|
||||
"request": {
|
||||
"modal": {
|
||||
"title": "",
|
||||
"table": {
|
||||
"header": {
|
||||
"season": "",
|
||||
"episodes": ""
|
||||
}
|
||||
},
|
||||
"button": {
|
||||
"send": ""
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -612,6 +612,10 @@
|
||||
},
|
||||
"url": {
|
||||
"label": ""
|
||||
},
|
||||
"attemptSearchEngineCreation": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
}
|
||||
},
|
||||
"action": {
|
||||
@@ -1151,6 +1155,25 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"minecraftServerStatus": {
|
||||
"name": "",
|
||||
"description": "",
|
||||
"option": {
|
||||
"title": {
|
||||
"label": ""
|
||||
},
|
||||
"domain": {
|
||||
"label": ""
|
||||
},
|
||||
"isBedrockServer": {
|
||||
"label": ""
|
||||
}
|
||||
},
|
||||
"status": {
|
||||
"online": "",
|
||||
"offline": ""
|
||||
}
|
||||
},
|
||||
"notebook": {
|
||||
"name": "Bilježnica",
|
||||
"description": "",
|
||||
@@ -1457,7 +1480,12 @@
|
||||
"mediaServer": {
|
||||
"name": "",
|
||||
"description": "",
|
||||
"option": {}
|
||||
"option": {},
|
||||
"items": {
|
||||
"user": "",
|
||||
"name": "",
|
||||
"id": ""
|
||||
}
|
||||
},
|
||||
"downloads": {
|
||||
"name": "",
|
||||
@@ -2319,6 +2347,9 @@
|
||||
"error": "Pogreška"
|
||||
},
|
||||
"job": {
|
||||
"minecraftServerStatus": {
|
||||
"label": ""
|
||||
},
|
||||
"iconsUpdater": {
|
||||
"label": ""
|
||||
},
|
||||
@@ -2717,6 +2748,11 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"media": {
|
||||
"requestMovie": "",
|
||||
"requestSeries": "",
|
||||
"openIn": ""
|
||||
},
|
||||
"external": {
|
||||
"help": "",
|
||||
"group": {
|
||||
@@ -2957,6 +2993,22 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"media": {
|
||||
"request": {
|
||||
"modal": {
|
||||
"title": "",
|
||||
"table": {
|
||||
"header": {
|
||||
"season": "",
|
||||
"episodes": ""
|
||||
}
|
||||
},
|
||||
"button": {
|
||||
"send": ""
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -612,6 +612,10 @@
|
||||
},
|
||||
"url": {
|
||||
"label": ""
|
||||
},
|
||||
"attemptSearchEngineCreation": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
}
|
||||
},
|
||||
"action": {
|
||||
@@ -1151,6 +1155,25 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"minecraftServerStatus": {
|
||||
"name": "",
|
||||
"description": "",
|
||||
"option": {
|
||||
"title": {
|
||||
"label": ""
|
||||
},
|
||||
"domain": {
|
||||
"label": ""
|
||||
},
|
||||
"isBedrockServer": {
|
||||
"label": ""
|
||||
}
|
||||
},
|
||||
"status": {
|
||||
"online": "",
|
||||
"offline": ""
|
||||
}
|
||||
},
|
||||
"notebook": {
|
||||
"name": "Jegyzettömb",
|
||||
"description": "Egy egyszerű notebook widget, amely támogatja a leértékelést",
|
||||
@@ -1457,7 +1480,12 @@
|
||||
"mediaServer": {
|
||||
"name": "",
|
||||
"description": "",
|
||||
"option": {}
|
||||
"option": {},
|
||||
"items": {
|
||||
"user": "",
|
||||
"name": "",
|
||||
"id": ""
|
||||
}
|
||||
},
|
||||
"downloads": {
|
||||
"name": "",
|
||||
@@ -2319,6 +2347,9 @@
|
||||
"error": "Hiba"
|
||||
},
|
||||
"job": {
|
||||
"minecraftServerStatus": {
|
||||
"label": ""
|
||||
},
|
||||
"iconsUpdater": {
|
||||
"label": ""
|
||||
},
|
||||
@@ -2717,6 +2748,11 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"media": {
|
||||
"requestMovie": "",
|
||||
"requestSeries": "",
|
||||
"openIn": ""
|
||||
},
|
||||
"external": {
|
||||
"help": "",
|
||||
"group": {
|
||||
@@ -2957,6 +2993,22 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"media": {
|
||||
"request": {
|
||||
"modal": {
|
||||
"title": "",
|
||||
"table": {
|
||||
"header": {
|
||||
"season": "",
|
||||
"episodes": ""
|
||||
}
|
||||
},
|
||||
"button": {
|
||||
"send": ""
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -323,14 +323,14 @@
|
||||
}
|
||||
},
|
||||
"board": {
|
||||
"title": "",
|
||||
"title": "Board",
|
||||
"item": {
|
||||
"create": {
|
||||
"label": "Creare schede",
|
||||
"description": "Consenti ai membri di creare schede"
|
||||
"label": "Creare board",
|
||||
"description": "Consenti ai membri di creare board"
|
||||
},
|
||||
"view-all": {
|
||||
"label": "Visualizzare tutte le schede",
|
||||
"label": "Visualizzare tutte le board",
|
||||
"description": "Consenti ai membri di visualizzare tutte le schede"
|
||||
},
|
||||
"modify-all": {
|
||||
@@ -402,50 +402,50 @@
|
||||
"description": "Consenti ai membri di modificare tutti i motori di ricerca"
|
||||
},
|
||||
"full-all": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
"label": "Accesso completo al motore di ricerca",
|
||||
"description": "Consenti ai membri di gestire ed eliminare qualsiasi motore di ricerca"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"memberNotice": {
|
||||
"mixed": "",
|
||||
"external": ""
|
||||
"mixed": "Alcuni membri provengono da fornitori esterni e non possono essere gestiti qui",
|
||||
"external": "Tutti i membri provengono da fornitori esterni e non possono essere gestiti qui"
|
||||
},
|
||||
"reservedNotice": {
|
||||
"message": ""
|
||||
"message": "Questo gruppo è riservato all'uso del sistema e limita alcune azioni. <checkoutDocs></checkoutDocs>"
|
||||
},
|
||||
"action": {
|
||||
"create": {
|
||||
"label": "",
|
||||
"label": "Nuovo gruppo",
|
||||
"notification": {
|
||||
"success": {
|
||||
"message": ""
|
||||
"message": "Gruppo creato"
|
||||
},
|
||||
"error": {
|
||||
"message": ""
|
||||
"message": "Impossibile creare il gruppo"
|
||||
}
|
||||
}
|
||||
},
|
||||
"transfer": {
|
||||
"label": "",
|
||||
"description": "",
|
||||
"confirm": "",
|
||||
"label": "Trasferisci ownership",
|
||||
"description": "Trasferisci l'ownership di questo gruppo a un altro utente.",
|
||||
"confirm": "Hai la certezza di voler trasferire l'ownership del gruppo {name} a {username}?",
|
||||
"notification": {
|
||||
"success": {
|
||||
"message": ""
|
||||
"message": "Gruppo {group} trasferito a {user}"
|
||||
},
|
||||
"error": {
|
||||
"message": ""
|
||||
"message": "Impossibile trasferire l'ownership"
|
||||
}
|
||||
}
|
||||
},
|
||||
"addMember": {
|
||||
"label": ""
|
||||
"label": "Aggiungi membro"
|
||||
},
|
||||
"removeMember": {
|
||||
"label": "",
|
||||
"confirm": ""
|
||||
"label": "Rimuovi membro",
|
||||
"confirm": "Hai la certezza di voler rimuovere {user} da questo gruppo?"
|
||||
},
|
||||
"delete": {
|
||||
"label": "Elimina gruppo",
|
||||
@@ -506,34 +506,34 @@
|
||||
},
|
||||
"error": {
|
||||
"title": "Creazione fallita",
|
||||
"message": ""
|
||||
"message": "Impossibile creare l'app"
|
||||
}
|
||||
}
|
||||
},
|
||||
"edit": {
|
||||
"title": "",
|
||||
"title": "Modifica app",
|
||||
"notification": {
|
||||
"success": {
|
||||
"title": "",
|
||||
"message": ""
|
||||
"title": "Modifiche applicate",
|
||||
"message": "App salvata"
|
||||
},
|
||||
"error": {
|
||||
"title": "",
|
||||
"message": ""
|
||||
"title": "Impossibile applicare le modifiche",
|
||||
"message": "Impossibile salvare l'app"
|
||||
}
|
||||
}
|
||||
},
|
||||
"delete": {
|
||||
"title": "",
|
||||
"message": "",
|
||||
"title": "Elimina app",
|
||||
"message": "Hai la certezza di voler eliminare l'app {name}?",
|
||||
"notification": {
|
||||
"success": {
|
||||
"title": "",
|
||||
"message": ""
|
||||
"title": "Eliminato",
|
||||
"message": "App eliminata"
|
||||
},
|
||||
"error": {
|
||||
"title": "",
|
||||
"message": ""
|
||||
"title": "Eliminazione non riuscita",
|
||||
"message": "Impossibile eliminare l'app"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -543,15 +543,15 @@
|
||||
"label": "Nome"
|
||||
},
|
||||
"description": {
|
||||
"label": ""
|
||||
"label": "Descrizione"
|
||||
},
|
||||
"url": {
|
||||
"label": ""
|
||||
"label": "Url"
|
||||
}
|
||||
},
|
||||
"action": {
|
||||
"select": {
|
||||
"label": "",
|
||||
"label": "Seleziona App",
|
||||
"notFound": "Nessuna app trovata"
|
||||
}
|
||||
}
|
||||
@@ -569,11 +569,11 @@
|
||||
"title": "Nuova integrazione di {name}",
|
||||
"notification": {
|
||||
"success": {
|
||||
"title": "",
|
||||
"title": "Creazione riuscita",
|
||||
"message": "L'integrazione è stata creata con successo"
|
||||
},
|
||||
"error": {
|
||||
"title": "",
|
||||
"title": "Creazione fallita",
|
||||
"message": "Non è stato possibile creare l'integrazione"
|
||||
}
|
||||
}
|
||||
@@ -582,11 +582,11 @@
|
||||
"title": "Modifica integrazione {name}",
|
||||
"notification": {
|
||||
"success": {
|
||||
"title": "",
|
||||
"title": "Modifiche applicate",
|
||||
"message": "L'integrazione è stata salvata correttamente"
|
||||
},
|
||||
"error": {
|
||||
"title": "",
|
||||
"title": "Impossibile applicare le modifiche",
|
||||
"message": "Non è stato possibile salvare l'integrazione"
|
||||
}
|
||||
}
|
||||
@@ -596,11 +596,11 @@
|
||||
"message": "Hai la certezza di voler eliminare l'integrazione {name}?",
|
||||
"notification": {
|
||||
"success": {
|
||||
"title": "",
|
||||
"title": "Eliminato",
|
||||
"message": "L'integrazione è stata eliminata correttamente"
|
||||
},
|
||||
"error": {
|
||||
"title": "",
|
||||
"title": "Eliminazione non riuscita",
|
||||
"message": "Impossibile eliminare l'integrazione"
|
||||
}
|
||||
}
|
||||
@@ -611,7 +611,11 @@
|
||||
"label": "Nome"
|
||||
},
|
||||
"url": {
|
||||
"label": ""
|
||||
"label": "Url"
|
||||
},
|
||||
"attemptSearchEngineCreation": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
}
|
||||
},
|
||||
"action": {
|
||||
@@ -645,7 +649,7 @@
|
||||
"message": "La connessione non è stata stabilita"
|
||||
},
|
||||
"badRequest": {
|
||||
"title": "",
|
||||
"title": "Bad request",
|
||||
"message": "La richiesta era malformata"
|
||||
},
|
||||
"unauthorized": {
|
||||
@@ -653,11 +657,11 @@
|
||||
"message": "Probabilmente le credenziali sono sbagliate"
|
||||
},
|
||||
"forbidden": {
|
||||
"title": "",
|
||||
"title": "Forbidden",
|
||||
"message": "Probabilmente mancano i permessi"
|
||||
},
|
||||
"notFound": {
|
||||
"title": "",
|
||||
"title": "Not found",
|
||||
"message": "Probabilmente url o percorso errati"
|
||||
},
|
||||
"internalServerError": {
|
||||
@@ -691,11 +695,11 @@
|
||||
}
|
||||
},
|
||||
"secrets": {
|
||||
"title": "",
|
||||
"title": "Secrets",
|
||||
"lastUpdated": "Ultimo aggiornamento {date}",
|
||||
"notSet": {
|
||||
"label": "",
|
||||
"tooltip": ""
|
||||
"label": "Nessun valore impostato",
|
||||
"tooltip": "Il secret richiesto non è stato ancora impostato"
|
||||
},
|
||||
"secureNotice": "",
|
||||
"reset": {
|
||||
@@ -1151,6 +1155,25 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"minecraftServerStatus": {
|
||||
"name": "",
|
||||
"description": "",
|
||||
"option": {
|
||||
"title": {
|
||||
"label": ""
|
||||
},
|
||||
"domain": {
|
||||
"label": ""
|
||||
},
|
||||
"isBedrockServer": {
|
||||
"label": ""
|
||||
}
|
||||
},
|
||||
"status": {
|
||||
"online": "",
|
||||
"offline": ""
|
||||
}
|
||||
},
|
||||
"notebook": {
|
||||
"name": "Blocco note",
|
||||
"description": "",
|
||||
@@ -1457,7 +1480,12 @@
|
||||
"mediaServer": {
|
||||
"name": "",
|
||||
"description": "",
|
||||
"option": {}
|
||||
"option": {},
|
||||
"items": {
|
||||
"user": "",
|
||||
"name": "",
|
||||
"id": ""
|
||||
}
|
||||
},
|
||||
"downloads": {
|
||||
"name": "",
|
||||
@@ -2319,6 +2347,9 @@
|
||||
"error": "Errore"
|
||||
},
|
||||
"job": {
|
||||
"minecraftServerStatus": {
|
||||
"label": ""
|
||||
},
|
||||
"iconsUpdater": {
|
||||
"label": ""
|
||||
},
|
||||
@@ -2717,6 +2748,11 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"media": {
|
||||
"requestMovie": "",
|
||||
"requestSeries": "",
|
||||
"openIn": ""
|
||||
},
|
||||
"external": {
|
||||
"help": "",
|
||||
"group": {
|
||||
@@ -2957,6 +2993,22 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"media": {
|
||||
"request": {
|
||||
"modal": {
|
||||
"title": "",
|
||||
"table": {
|
||||
"header": {
|
||||
"season": "",
|
||||
"episodes": ""
|
||||
}
|
||||
},
|
||||
"button": {
|
||||
"send": ""
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,16 +2,16 @@
|
||||
"init": {
|
||||
"step": {
|
||||
"start": {
|
||||
"title": "",
|
||||
"title": "Homarrへようこそ",
|
||||
"subtitle": "",
|
||||
"description": "",
|
||||
"action": {
|
||||
"scratch": "",
|
||||
"scratch": "ゼロからスタート",
|
||||
"importOldmarr": ""
|
||||
}
|
||||
},
|
||||
"import": {
|
||||
"title": "",
|
||||
"title": "データのインポート",
|
||||
"subtitle": "",
|
||||
"dropzone": {
|
||||
"title": "",
|
||||
@@ -612,6 +612,10 @@
|
||||
},
|
||||
"url": {
|
||||
"label": ""
|
||||
},
|
||||
"attemptSearchEngineCreation": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
}
|
||||
},
|
||||
"action": {
|
||||
@@ -1151,6 +1155,25 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"minecraftServerStatus": {
|
||||
"name": "",
|
||||
"description": "",
|
||||
"option": {
|
||||
"title": {
|
||||
"label": ""
|
||||
},
|
||||
"domain": {
|
||||
"label": ""
|
||||
},
|
||||
"isBedrockServer": {
|
||||
"label": ""
|
||||
}
|
||||
},
|
||||
"status": {
|
||||
"online": "",
|
||||
"offline": ""
|
||||
}
|
||||
},
|
||||
"notebook": {
|
||||
"name": "メモ帳",
|
||||
"description": "",
|
||||
@@ -1457,7 +1480,12 @@
|
||||
"mediaServer": {
|
||||
"name": "",
|
||||
"description": "",
|
||||
"option": {}
|
||||
"option": {},
|
||||
"items": {
|
||||
"user": "",
|
||||
"name": "",
|
||||
"id": ""
|
||||
}
|
||||
},
|
||||
"downloads": {
|
||||
"name": "",
|
||||
@@ -2319,6 +2347,9 @@
|
||||
"error": "エラー"
|
||||
},
|
||||
"job": {
|
||||
"minecraftServerStatus": {
|
||||
"label": ""
|
||||
},
|
||||
"iconsUpdater": {
|
||||
"label": ""
|
||||
},
|
||||
@@ -2717,6 +2748,11 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"media": {
|
||||
"requestMovie": "",
|
||||
"requestSeries": "",
|
||||
"openIn": ""
|
||||
},
|
||||
"external": {
|
||||
"help": "",
|
||||
"group": {
|
||||
@@ -2957,6 +2993,22 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"media": {
|
||||
"request": {
|
||||
"modal": {
|
||||
"title": "",
|
||||
"table": {
|
||||
"header": {
|
||||
"season": "",
|
||||
"episodes": ""
|
||||
}
|
||||
},
|
||||
"button": {
|
||||
"send": ""
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -612,6 +612,10 @@
|
||||
},
|
||||
"url": {
|
||||
"label": ""
|
||||
},
|
||||
"attemptSearchEngineCreation": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
}
|
||||
},
|
||||
"action": {
|
||||
@@ -1151,6 +1155,25 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"minecraftServerStatus": {
|
||||
"name": "",
|
||||
"description": "",
|
||||
"option": {
|
||||
"title": {
|
||||
"label": ""
|
||||
},
|
||||
"domain": {
|
||||
"label": ""
|
||||
},
|
||||
"isBedrockServer": {
|
||||
"label": ""
|
||||
}
|
||||
},
|
||||
"status": {
|
||||
"online": "",
|
||||
"offline": ""
|
||||
}
|
||||
},
|
||||
"notebook": {
|
||||
"name": "노트북",
|
||||
"description": "",
|
||||
@@ -1457,7 +1480,12 @@
|
||||
"mediaServer": {
|
||||
"name": "",
|
||||
"description": "",
|
||||
"option": {}
|
||||
"option": {},
|
||||
"items": {
|
||||
"user": "",
|
||||
"name": "",
|
||||
"id": ""
|
||||
}
|
||||
},
|
||||
"downloads": {
|
||||
"name": "",
|
||||
@@ -2319,6 +2347,9 @@
|
||||
"error": "오류"
|
||||
},
|
||||
"job": {
|
||||
"minecraftServerStatus": {
|
||||
"label": ""
|
||||
},
|
||||
"iconsUpdater": {
|
||||
"label": ""
|
||||
},
|
||||
@@ -2717,6 +2748,11 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"media": {
|
||||
"requestMovie": "",
|
||||
"requestSeries": "",
|
||||
"openIn": ""
|
||||
},
|
||||
"external": {
|
||||
"help": "",
|
||||
"group": {
|
||||
@@ -2957,6 +2993,22 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"media": {
|
||||
"request": {
|
||||
"modal": {
|
||||
"title": "",
|
||||
"table": {
|
||||
"header": {
|
||||
"season": "",
|
||||
"episodes": ""
|
||||
}
|
||||
},
|
||||
"button": {
|
||||
"send": ""
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -612,6 +612,10 @@
|
||||
},
|
||||
"url": {
|
||||
"label": ""
|
||||
},
|
||||
"attemptSearchEngineCreation": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
}
|
||||
},
|
||||
"action": {
|
||||
@@ -1151,6 +1155,25 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"minecraftServerStatus": {
|
||||
"name": "",
|
||||
"description": "",
|
||||
"option": {
|
||||
"title": {
|
||||
"label": ""
|
||||
},
|
||||
"domain": {
|
||||
"label": ""
|
||||
},
|
||||
"isBedrockServer": {
|
||||
"label": ""
|
||||
}
|
||||
},
|
||||
"status": {
|
||||
"online": "",
|
||||
"offline": ""
|
||||
}
|
||||
},
|
||||
"notebook": {
|
||||
"name": "Užrašai",
|
||||
"description": "",
|
||||
@@ -1457,7 +1480,12 @@
|
||||
"mediaServer": {
|
||||
"name": "",
|
||||
"description": "",
|
||||
"option": {}
|
||||
"option": {},
|
||||
"items": {
|
||||
"user": "",
|
||||
"name": "",
|
||||
"id": ""
|
||||
}
|
||||
},
|
||||
"downloads": {
|
||||
"name": "",
|
||||
@@ -2319,6 +2347,9 @@
|
||||
"error": ""
|
||||
},
|
||||
"job": {
|
||||
"minecraftServerStatus": {
|
||||
"label": ""
|
||||
},
|
||||
"iconsUpdater": {
|
||||
"label": ""
|
||||
},
|
||||
@@ -2717,6 +2748,11 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"media": {
|
||||
"requestMovie": "",
|
||||
"requestSeries": "",
|
||||
"openIn": ""
|
||||
},
|
||||
"external": {
|
||||
"help": "",
|
||||
"group": {
|
||||
@@ -2957,6 +2993,22 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"media": {
|
||||
"request": {
|
||||
"modal": {
|
||||
"title": "",
|
||||
"table": {
|
||||
"header": {
|
||||
"season": "",
|
||||
"episodes": ""
|
||||
}
|
||||
},
|
||||
"button": {
|
||||
"send": ""
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
}
|
||||
},
|
||||
"import": {
|
||||
"title": "",
|
||||
"title": "Importēt datus",
|
||||
"subtitle": "",
|
||||
"dropzone": {
|
||||
"title": "",
|
||||
@@ -612,6 +612,10 @@
|
||||
},
|
||||
"url": {
|
||||
"label": ""
|
||||
},
|
||||
"attemptSearchEngineCreation": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
}
|
||||
},
|
||||
"action": {
|
||||
@@ -1151,6 +1155,25 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"minecraftServerStatus": {
|
||||
"name": "",
|
||||
"description": "",
|
||||
"option": {
|
||||
"title": {
|
||||
"label": ""
|
||||
},
|
||||
"domain": {
|
||||
"label": ""
|
||||
},
|
||||
"isBedrockServer": {
|
||||
"label": ""
|
||||
}
|
||||
},
|
||||
"status": {
|
||||
"online": "",
|
||||
"offline": ""
|
||||
}
|
||||
},
|
||||
"notebook": {
|
||||
"name": "Piezīmes",
|
||||
"description": "",
|
||||
@@ -1457,7 +1480,12 @@
|
||||
"mediaServer": {
|
||||
"name": "",
|
||||
"description": "",
|
||||
"option": {}
|
||||
"option": {},
|
||||
"items": {
|
||||
"user": "",
|
||||
"name": "",
|
||||
"id": ""
|
||||
}
|
||||
},
|
||||
"downloads": {
|
||||
"name": "",
|
||||
@@ -2319,6 +2347,9 @@
|
||||
"error": "Kļūda"
|
||||
},
|
||||
"job": {
|
||||
"minecraftServerStatus": {
|
||||
"label": ""
|
||||
},
|
||||
"iconsUpdater": {
|
||||
"label": ""
|
||||
},
|
||||
@@ -2717,6 +2748,11 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"media": {
|
||||
"requestMovie": "",
|
||||
"requestSeries": "",
|
||||
"openIn": ""
|
||||
},
|
||||
"external": {
|
||||
"help": "",
|
||||
"group": {
|
||||
@@ -2957,6 +2993,22 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"media": {
|
||||
"request": {
|
||||
"modal": {
|
||||
"title": "",
|
||||
"table": {
|
||||
"header": {
|
||||
"season": "",
|
||||
"episodes": ""
|
||||
}
|
||||
},
|
||||
"button": {
|
||||
"send": ""
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+1084
-1032
File diff suppressed because it is too large.
Load diff
@@ -612,6 +612,10 @@
|
||||
},
|
||||
"url": {
|
||||
"label": ""
|
||||
},
|
||||
"attemptSearchEngineCreation": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
}
|
||||
},
|
||||
"action": {
|
||||
@@ -1151,6 +1155,25 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"minecraftServerStatus": {
|
||||
"name": "",
|
||||
"description": "",
|
||||
"option": {
|
||||
"title": {
|
||||
"label": ""
|
||||
},
|
||||
"domain": {
|
||||
"label": ""
|
||||
},
|
||||
"isBedrockServer": {
|
||||
"label": ""
|
||||
}
|
||||
},
|
||||
"status": {
|
||||
"online": "",
|
||||
"offline": ""
|
||||
}
|
||||
},
|
||||
"notebook": {
|
||||
"name": "Notisbok",
|
||||
"description": "",
|
||||
@@ -1457,7 +1480,12 @@
|
||||
"mediaServer": {
|
||||
"name": "",
|
||||
"description": "",
|
||||
"option": {}
|
||||
"option": {},
|
||||
"items": {
|
||||
"user": "",
|
||||
"name": "",
|
||||
"id": ""
|
||||
}
|
||||
},
|
||||
"downloads": {
|
||||
"name": "",
|
||||
@@ -2319,6 +2347,9 @@
|
||||
"error": "Feil"
|
||||
},
|
||||
"job": {
|
||||
"minecraftServerStatus": {
|
||||
"label": ""
|
||||
},
|
||||
"iconsUpdater": {
|
||||
"label": ""
|
||||
},
|
||||
@@ -2717,6 +2748,11 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"media": {
|
||||
"requestMovie": "",
|
||||
"requestSeries": "",
|
||||
"openIn": ""
|
||||
},
|
||||
"external": {
|
||||
"help": "",
|
||||
"group": {
|
||||
@@ -2957,6 +2993,22 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"media": {
|
||||
"request": {
|
||||
"modal": {
|
||||
"title": "",
|
||||
"table": {
|
||||
"header": {
|
||||
"season": "",
|
||||
"episodes": ""
|
||||
}
|
||||
},
|
||||
"button": {
|
||||
"send": ""
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Loaded 100 of 129 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user