Compare commits

...
Author SHA1 Message Date
Releases Homarr 024ec9235d chore(release): 1.0.0-beta.11 [skip ci] 2025-01-03 19:13:36 +00:00
homarr-releases[bot] 58fdea0245 chore(release): automatic release v1.0.0 2025-01-03 19:12:38 +00:00
Meier Lukas 4ead238462 test(e2e): add for onboarding and lldap authorization (#1834)
* test(e2e): add for onboarding and lldap authorization

* ci: add playwright chrome installation to e2e test

* fix(e2e): timeout between lldap login redirect to short

* test(e2e): add oidc azure test

* fix(e2e): lldap test fails

* wip: add temporary error log for failed ldap server connection

* fix(e2e): github actions don't support host.docker.internal

* chore: address pull request feedback

* refactor(e2e): move onboarding steps to onboarding actions and assertions

* fix(e2e): increase timeout for navigating back from azure login

* fix: wait for url network changed error

* fix: revert to wait for url

* fix(e2e): remove oidc test

* refactor(e2e): remove env validation

* ci: remove azure oidc env variables
2025-01-03 16:49:30 +01:00
Meier Lukas 6305c74f90 feat: remove import beta badge (#1829) 2025-01-03 16:35:43 +01:00
homarr-renovate[bot] 0780080681 chore(deps): update dependency @types/node to ^22.10.5 (#1842)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2025-01-03 15:33:47 +00:00
homarr-crowdin[bot] aa15a705d7 chore(lang): updated translations from crowdin
Co-authored-by: Crowdin Homarr <190541745+homarr-crowdin[bot]@users.noreply.github.com>
2025-01-03 15:33:38 +00:00
homarr-renovate[bot] cc70175125 chore(deps): update semantic-release monorepo (#1841)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2025-01-03 15:28:36 +00:00
homarr-renovate[bot] bc5e25e9cd fix(deps): update tanstack-query monorepo to ^5.62.12 (#1843)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2025-01-03 15:16:35 +00:00
homarr-renovate[bot] d43557e165 chore(deps): update dependency @types/dockerode to ^3.3.33 (#1839)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2025-01-02 21:39:38 +00:00
homarr-renovate[bot] b6c96f73a5 chore(deps): update dependency @types/node to ^22.10.4 (#1838)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2025-01-02 18:55:31 +00:00
Yossi Hillali ee8831affe fix: prowlarr integration indexers status changed from id to indexerId (#1837) 2025-01-02 20:53:27 +02:00
Meier Lukas 6e21863d04 fix(icons): picker does not allow multiple words (#1827) 2025-01-02 14:52:38 +01:00
homarr-crowdin[bot] ca39ca66f0 chore(lang): updated translations from crowdin
Co-authored-by: Crowdin Homarr <190541745+homarr-crowdin[bot]@users.noreply.github.com>
2025-01-02 00:43:54 +00:00
Yossi Hillali cc7d53e84e fix: login page in light mode is always black (#1821) 2025-01-01 12:24:53 +02:00
homarr-renovate[bot] 99c409cbb7 chore(deps): update dependency @types/node to ^22.10.3 (#1824)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2025-01-01 02:26:11 +00:00
homarr-crowdin[bot] 1fcf9d0146 chore(lang): updated translations from crowdin
Co-authored-by: Crowdin Homarr <190541745+homarr-crowdin[bot]@users.noreply.github.com>
2025-01-01 00:48:53 +00:00
homarr-renovate[bot] a234fc844e fix(deps): update dependency zod-form-data to ^2.0.5 (#1822)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2024-12-31 17:14:34 +00:00
Releases Homarr 168b587c18 chore(release): 1.0.0-beta.10 [skip ci] 2024-12-31 10:42:11 +00:00
homarr-releases[bot] dc12a82805 chore(release): automatic release v1.0.0 2024-12-31 10:41:19 +00:00
ManuelandMeier Lukas f5076454cd feat(integration): add search engine creation (#1816)
Co-authored-by: Meier Lukas <meierschlumpf@gmail.com>
2024-12-31 11:40:37 +01:00
Meier Lukas aeb681a858 feat(docker): add support for pgid and puid env variables (#1759) 2024-12-31 11:36:28 +01:00
Meier Lukas 56b57ad171 fix(docker): replace anonymous docker volume with env variable for encrypting secrets (#1809) 2024-12-31 11:30:29 +01:00
Manuel 91e1cef611 feat: add request media (#1811) 2024-12-31 11:05:30 +01:00
homarr-renovate[bot] e53b06e8c2 chore(deps): update dependency concurrently to ^9.1.2 (#1818)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2024-12-31 08:46:41 +00:00
Meier Lukas 0ebf4bc55e feat(widget): add minecraft server status widget (#1801) 2024-12-31 09:45:48 +01:00
homarr-crowdin[bot] b8a155dd08 chore(lang): updated translations from crowdin
Co-authored-by: Crowdin Homarr <190541745+homarr-crowdin[bot]@users.noreply.github.com>
2024-12-31 00:43:59 +00:00
Thomas CamlongandMeier Lukas 63786eed06 chore(lang): updated translations from crowdin (#1803)
* New translations en.json (Chinese Simplified)

* New translations en.json (Turkish)

* New translations en.json (Turkish)

* New translations en.json (Turkish)

* New translations en.json (Romanian)

* New translations en.json (French)

* New translations en.json (Spanish)

* New translations en.json (Czech)

* New translations en.json (Danish)

* New translations en.json (German)

* New translations en.json (Greek)

* New translations en.json (Hebrew)

* New translations en.json (Hungarian)

* New translations en.json (Italian)

* New translations en.json (Japanese)

* New translations en.json (Korean)

* New translations en.json (Lithuanian)

* New translations en.json (Dutch)

* New translations en.json (Norwegian)

* New translations en.json (Polish)

* New translations en.json (Russian)

* New translations en.json (Slovak)

* New translations en.json (Slovenian)

* New translations en.json (Swedish)

* New translations en.json (Turkish)

* New translations en.json (Ukrainian)

* New translations en.json (Chinese Simplified)

* New translations en.json (Chinese Traditional)

* New translations en.json (Vietnamese)

* New translations en.json (Portuguese, Brazilian)

* New translations en.json (Croatian)

* New translations en.json (Estonian)

* New translations en.json (Latvian)

* New translations en.json (Cree)

* New translations en.json (Hebrew)

* New translations en.json (Hebrew)

---------

Co-authored-by: Meier Lukas <meierschlumpf@gmail.com>
2024-12-30 21:44:23 +01:00
homarr-renovate[bot] 598f0047eb fix(deps): update dependency typescript-eslint to ^8.19.0 (#1814)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2024-12-30 17:33:38 +00:00
homarr-renovate[bot] eacda4c397 fix(deps): update tiptap monorepo to v2.11.0 (#1813)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2024-12-30 16:54:00 +00:00
Meier Lukas 5cb726f793 ci(docker): always push image (#1812) 2024-12-30 17:38:27 +01:00
homarr-crowdin[bot] e8d29420e0 chore(lang): updated translations from crowdin
Co-authored-by: Crowdin Homarr <190541745+homarr-crowdin[bot]@users.noreply.github.com>
2024-12-30 16:28:16 +00:00
Yossi Hillali 23c7d0bbf0 fix(media-server): redesign widget (#1775)
* fix: redisign media server widget

* fix: reviewed changes

* fix: reviewed chenges

* fix: add icon title

* fix: text resize
2024-12-29 19:24:38 +02:00
homarr-renovate[bot] 75aefcb0d5 fix(deps): update dependency octokit to ^4.0.3 (#1806)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2024-12-29 16:16:34 +00:00
homarr-crowdin[bot] b52a8a7935 chore(lang): updated translations from crowdin
Co-authored-by: Crowdin Homarr <190541745+homarr-crowdin[bot]@users.noreply.github.com>
2024-12-29 08:27:17 +00:00
homarr-renovate[bot] 6d90209e00 chore(deps): update pnpm to v9.15.2 (#1804)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2024-12-29 08:26:48 +00:00
Thomas CamlongandMeier Lukas 8f0945f51a chore(lang): updated translations from crowdin (#1800)
* New translations en.json (German)

* New translations en.json (Hebrew)

* New translations en.json (Hebrew)

* New translations en.json (Turkish)

* New translations en.json (Chinese Simplified)

---------

Co-authored-by: Meier Lukas <meierschlumpf@gmail.com>
2024-12-28 18:50:25 +01:00
homarr-crowdin[bot] 5b04b93f23 chore(lang): updated translations from crowdin (#1802)
Co-authored-by: Crowdin Homarr <190541745+homarr-crowdin[bot]@users.noreply.github.com>
2024-12-28 18:49:22 +01:00
Thomas CamlongandMeier Lukas c8228b920c chore(lang): updated translations from crowdin (#1797)
* New translations en.json (Hebrew)

* New translations en.json (Turkish)

* New translations en.json (Turkish)

---------

Co-authored-by: Meier Lukas <meierschlumpf@gmail.com>
2024-12-28 10:25:14 +01:00
homarr-crowdin[bot] abf7942fb7 chore(lang): updated translations from crowdin
Co-authored-by: Crowdin Homarr <190541745+homarr-crowdin[bot]@users.noreply.github.com>
2024-12-28 09:14:42 +00:00
homarr-crowdin[bot] 50746eb3e4 chore(lang): updated translations from crowdin
Co-authored-by: Crowdin Homarr <190541745+homarr-crowdin[bot]@users.noreply.github.com>
2024-12-28 09:06:24 +00:00
Thomas CamlongandMeier Lukas 76abfca11b chore(lang): updated translations from crowdin (#1786)
* New translations en.json (Hebrew)

* New translations en.json (Dutch)

* New translations en.json (Hebrew)

* New translations en.json (Hebrew)

* New translations en.json (Romanian)

* New translations en.json (French)

* New translations en.json (Spanish)

* New translations en.json (Czech)

* New translations en.json (Danish)

* New translations en.json (German)

* New translations en.json (Greek)

* New translations en.json (Hungarian)

* New translations en.json (Italian)

* New translations en.json (Japanese)

* New translations en.json (Korean)

* New translations en.json (Lithuanian)

* New translations en.json (Dutch)

* New translations en.json (Norwegian)

* New translations en.json (Polish)

* New translations en.json (Russian)

* New translations en.json (Slovak)

* New translations en.json (Slovenian)

* New translations en.json (Swedish)

* New translations en.json (Turkish)

* New translations en.json (Ukrainian)

* New translations en.json (Chinese Simplified)

* New translations en.json (Vietnamese)

* New translations en.json (Portuguese, Brazilian)

* New translations en.json (Croatian)

* New translations en.json (Latvian)

* New translations en.json (Cree)

---------

Co-authored-by: Meier Lukas <meierschlumpf@gmail.com>
2024-12-27 20:47:14 +01:00
homarr-crowdin[bot] fcab6a347d chore(lang): updated translations from crowdin
Co-authored-by: Crowdin Homarr <190541745+homarr-crowdin[bot]@users.noreply.github.com>
2024-12-27 19:43:44 +00:00
Yossi Hillali c91aae53ac fix(health-monitoring): add months to uptime, fix status update (#1792) 2024-12-27 21:32:04 +02:00
homarr-renovate[bot]andMeier Lukas 4943e7b851 fix(deps): update dependency trpc-to-openapi to ^2.1.1 (#1788)
* fix(deps): update dependency trpc-to-openapi to ^2.1.1

* fix(deps): missing dependency lodash.clonedeep in api package

---------

Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
Co-authored-by: Meier Lukas <meierschlumpf@gmail.com>
2024-12-27 20:31:15 +01:00
Releases Homarr 88c423fca6 chore(release): 1.0.0-beta.9 [skip ci] 2024-12-27 19:13:01 +00:00
homarr-releases[bot] f238663ec7 chore(release): automatic release v1.0.0 2024-12-27 19:12:07 +00:00
homarr-renovate[bot] e9870e7ee7 fix(deps): update tanstack-query monorepo to ^5.62.11 (#1791)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2024-12-27 13:22:52 +00:00
homarr-renovate[bot] 9c0a0ced04 chore(deps): update dependency concurrently to ^9.1.1 (#1790)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2024-12-27 11:53:46 +00:00
homarr-crowdin[bot] 4c27635066 chore(lang): updated translations from crowdin
Co-authored-by: Crowdin Homarr <190541745+homarr-crowdin[bot]@users.noreply.github.com>
2024-12-27 00:44:11 +00:00
homarr-renovate[bot] 3d66ff9148 fix(deps): update nextjs monorepo to ^14.2.22 (#1787)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2024-12-26 18:20:53 +00:00
Meier Lukas ec257e5f60 chore(env): remove unused vercel-url env variable (#1779)
* chore(env): remove unused vercel-url env variable

* chore: remove vercel-json file
2024-12-26 08:58:27 +01:00
Meier Lukas ef24370a8c refactor: revert assignment of oldmarr widget mapping (#1780) 2024-12-26 08:58:06 +01:00
Releases Homarr 1fa0fc50c5 chore(release): 1.0.0-beta.8 [skip ci] 2024-12-25 14:19:38 +00:00
homarr-releases[bot] e9a1dae857 chore(release): automatic release v1.0.0 2024-12-25 14:18:45 +00:00
Meier Lukas e37279fe80 fix: oldmarr import user casing wrong (#1784) 2024-12-25 15:17:51 +01:00
homarr-renovate[bot] 82c6ce7b77 fix(deps): update dependency drizzle-orm to ^0.38.3 (#1783)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2024-12-25 12:25:14 +00:00
homarr-renovate[bot] 555212e2dc fix(deps): update dependency drizzle-zod to ^0.6.1 (#1782)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2024-12-25 10:53:06 +00:00
homarr-crowdin[bot] c831539772 chore(lang): updated translations from crowdin
Co-authored-by: Crowdin Homarr <190541745+homarr-crowdin[bot]@users.noreply.github.com>
2024-12-25 00:43:00 +00:00
homarr-renovate[bot] 3e2ffbd7bf fix(deps): update dependency @ctrl/transmission to ^7.2.0 (#1777)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2024-12-24 20:33:01 +00:00
Thomas Camlong 22d7f676eb chore(lang): updated translations from crowdin (#1774)
* New translations en.json (Hebrew)

* New translations en.json (Hebrew)
2024-12-24 21:26:29 +01:00
homarr-crowdin[bot] 23bf262f4e chore(lang): updated translations from crowdin
Co-authored-by: Crowdin Homarr <190541745+homarr-crowdin[bot]@users.noreply.github.com>
2024-12-24 20:26:03 +00:00
homarr-renovate[bot] ecae80d741 fix(deps): update dependency @ctrl/deluge to ^7.1.0 (#1776)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2024-12-24 20:16:58 +00:00
129 changed files with 9988 additions and 3844 deletions

No files matched your search

+2 -1
View File
@@ -6,4 +6,5 @@ README.md
.next
.git
dev
.build
.build
e2e
+8 -5
View File
@@ -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
+2
View File
@@ -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 }}'."
+2
View File
@@ -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
+1 -1
View File
@@ -39,5 +39,5 @@
"i18n-ally.localesPaths": [
"packages/translation/src/lang",
],
"i18n-ally.keystyle": "auto",
"i18n-ally.keystyle": "nested",
}
+50
View File
@@ -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
View File
@@ -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
View File
@@ -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";
+6 -6
View File
@@ -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")}
-5
View File
@@ -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,
+1 -1
View File
@@ -44,7 +44,7 @@
"@homarr/eslint-config": "workspace:^0.2.0",
"@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0",
"@types/node": "^22.10.2",
"@types/node": "^22.10.5",
"dotenv-cli": "^8.0.0",
"eslint": "^9.17.0",
"prettier": "^3.4.2",
+92
View File
@@ -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,
}),
);
};
+85
View File
@@ -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);
});
+53
View File
@@ -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 });
}
}
+43 -3
View File
@@ -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;
};
+32
View File
@@ -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
View File
@@ -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"
},
+4 -3
View File
@@ -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"
-1
View File
@@ -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}`;
}
+6 -2
View File
@@ -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
+2
View File
@@ -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();
};
});
}),
});
-2
View File
@@ -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,
+1 -1
View File
@@ -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"
+28
View File
@@ -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",
});
+3 -2
View File
@@ -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": {
+4 -12
View File
@@ -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);
+5
View File
@@ -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>;
+2
View File
@@ -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
}
]
}
+2 -2
View File
@@ -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": {
+1 -1
View File
@@ -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"),
+1 -1
View File
@@ -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"
+1
View File
@@ -15,6 +15,7 @@ export const widgetKinds = [
"mediaRequests-requestList",
"mediaRequests-requestStats",
"mediaTranscoding",
"minecraftServerStatus",
"rssFeed",
"bookmarks",
"indexerManager",
+1
View File
@@ -1,2 +1,3 @@
export * from "./src/icons-fetcher";
export * from "./src/types";
export * from "./src/auto-icon-searcher";
+9
View File
@@ -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}%`),
});
};
+2 -2
View File
@@ -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(),
}),
);
+1 -1
View File
@@ -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
View File
@@ -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;
}
+2 -2
View File
@@ -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",
+1 -1
View File
@@ -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}`);
+5 -1
View File
@@ -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;
+39 -26
View File
@@ -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"];
};
+1
View File
@@ -5,6 +5,7 @@ export {
createItemAndIntegrationChannel,
createItemChannel,
createIntegrationOptionsChannel,
createWidgetOptionsChannel,
createChannelWithLatestAndEvents,
handshakeAsync,
createSubPubChannel,
+10
View File
@@ -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}`);
};
+1 -1
View File
@@ -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>;
+1 -1
View File
@@ -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) => {
+4 -1
View File
@@ -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 }) {
+1 -1
View File
@@ -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
+7 -2
View File
@@ -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
+151 -99
View File
@@ -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": ""
}
}
}
}
}
}
+54 -2
View File
@@ -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": ""
}
}
}
}
}
}
+53 -1
View File
@@ -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": ""
}
}
}
}
}
}
+54 -2
View File
@@ -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"
}
}
}
}
}
}
+60 -8
View File
@@ -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": ""
}
}
}
}
}
}
+53 -1
View File
@@ -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": ""
}
}
}
}
}
}
+53 -1
View File
@@ -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
+53 -1
View File
@@ -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": ""
}
}
}
}
}
}
+53 -1
View File
@@ -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": ""
}
}
}
}
}
}
+101 -49
View File
@@ -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": ""
}
}
}
}
}
}
+56 -4
View File
@@ -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": ""
}
}
}
}
}
}
+53 -1
View File
@@ -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": ""
}
}
}
}
}
}
+53 -1
View File
@@ -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": ""
}
}
}
}
}
}
+54 -2
View File
@@ -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": ""
}
}
}
}
}
}
File diff suppressed because it is too large. Load diff
+53 -1
View File
@@ -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