Compare commits
25
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f612e2f917 | ||
|
|
c7e4b30c2d | ||
|
|
366ec6bdbc | ||
|
|
9f4b147f0e | ||
|
|
08af704c8a | ||
|
|
3f9fb215c6 | ||
|
|
de801f3f00 | ||
|
|
d90e762e4b | ||
|
|
81a13d3b52 | ||
|
|
388a74086e | ||
|
|
cd6c83a7b0 | ||
|
|
9963ba0274 | ||
|
|
5d9f05e083 | ||
|
|
d48f299585 | ||
|
|
c80e9878de | ||
|
|
84f87ce24e | ||
|
|
fbb57c8f40 | ||
|
|
0b9c4d6375 | ||
|
|
7266e15356 | ||
|
|
c22c733463 | ||
|
|
440626ba00 | ||
|
|
f8d8a09c56 | ||
|
|
add6ee56ba | ||
|
|
6b74078766 | ||
|
|
4ca0b747bb |
No files matched your search
@@ -33,7 +33,6 @@ body:
|
||||
options:
|
||||
# The below comment is used to insert a new version with on-release.yml
|
||||
#NEXT_VERSION#
|
||||
- 1.74.0
|
||||
- 1.73.0
|
||||
- 1.72.0
|
||||
- 1.71.0
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
name: "[Dependency Updates] Auto Approve"
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, synchronize]
|
||||
|
||||
permissions: {}
|
||||
|
||||
jobs:
|
||||
approve-automatic-prs:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 2
|
||||
# 158783068: homarr-renovate[bot]
|
||||
# 190541745: homarr-crowdin[bot]
|
||||
# 210161987: homarr-update-contributors[bot]
|
||||
# 175486441: homarr-releases[bot]
|
||||
# Extracted from https://api.github.com/users/<username>
|
||||
if: github.actor_id == 158783068 || github.actor_id == 190541745 || github.actor_id == 210161987 || github.actor_id == 175486441
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v7
|
||||
- name: Obtain token
|
||||
id: obtainToken
|
||||
uses: actions/create-github-app-token@v3
|
||||
with:
|
||||
private-key: ${{ secrets.RENOVATE_APPROVE_PRIVATE_KEY }}
|
||||
client-id: ${{ secrets.RENOVATE_APPROVE_APP_ID }}
|
||||
permission-pull-requests: write # required to approve pull request
|
||||
|
||||
- name: Install GitHub CLI
|
||||
run: sudo apt-get install -y gh
|
||||
- name: Approve automatic PRs
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ steps.obtainToken.outputs.token }}
|
||||
run: |
|
||||
gh pr review ${{github.event.pull_request.number}} --approve --body "Automatically approved by GitHub Action"
|
||||
@@ -1,43 +0,0 @@
|
||||
name: "[Bots] Reconcile Auto-merge"
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: "*/30 * * * *"
|
||||
workflow_dispatch:
|
||||
|
||||
permissions: {}
|
||||
|
||||
jobs:
|
||||
reconcile:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Obtain token
|
||||
id: obtainToken
|
||||
uses: actions/create-github-app-token@v3
|
||||
with:
|
||||
private-key: ${{ secrets.RENOVATE_APPROVE_PRIVATE_KEY }}
|
||||
client-id: ${{ secrets.RENOVATE_APPROVE_APP_ID }}
|
||||
permission-pull-requests: write # required to enable auto-merge
|
||||
|
||||
- name: Install GitHub CLI
|
||||
run: sudo apt-get install -y gh
|
||||
|
||||
- name: Re-enable auto-merge for prepared bot PRs
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ steps.obtainToken.outputs.token }}
|
||||
run: |
|
||||
# 158783068: homarr-renovate[bot]
|
||||
# 190541745: homarr-crowdin[bot]
|
||||
# 210161987: homarr-update-contributors[bot]
|
||||
# 175441: homarr-releases[bot]
|
||||
gh pr list --repo "${{ github.repository }}" --base dev --state open --limit 100 \
|
||||
--json number,author,reviewDecision,mergeStateStatus,autoMergeRequest \
|
||||
--jq '.[] | select(.author.login as $login | ["app/homarr-renovate", "app/homarr-crowdin", "app/homarr-update-contributors", "app/homarr-releases"] | index($login)) | select(.reviewDecision == "APPROVED") | select(.mergeStateStatus == "CLEAN") | select(.autoMergeRequest == null) | .number' \
|
||||
| while read -r pr; do
|
||||
echo "Re-enabling auto-merge on PR #$pr"
|
||||
gh pr merge "$pr" --auto --squash
|
||||
done
|
||||
@@ -1,47 +0,0 @@
|
||||
name: "[Bots] Approve and Auto-merge"
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, synchronize, reopened, ready_for_review]
|
||||
|
||||
permissions: {}
|
||||
|
||||
jobs:
|
||||
approve-and-automerge:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
# 158783068: homarr-renovate[bot]
|
||||
# 190541745: homarr-crowdin[bot]
|
||||
# 210161987: homarr-update-contributors[bot]
|
||||
# 175441: homarr-releases[bot]
|
||||
# Extracted from https://api.github.com/users/<username>
|
||||
if: |
|
||||
(github.actor_id == 158783068 || github.actor_id == 190541745 || github.actor_id == 210161987 || github.actor_id == 175441) &&
|
||||
github.event.pull_request.head.repo.full_name == github.repository &&
|
||||
github.event.pull_request.base.ref == 'dev'
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Obtain token
|
||||
id: obtainToken
|
||||
uses: actions/create-github-app-token@v3
|
||||
with:
|
||||
private-key: ${{ secrets.RENOVATE_APPROVE_PRIVATE_KEY }}
|
||||
client-id: ${{ secrets.RENOVATE_APPROVE_APP_ID }}
|
||||
permission-pull-requests: write # required to approve a pull request and enable auto-merge
|
||||
|
||||
- name: Install GitHub CLI
|
||||
run: sudo apt-get install -y gh
|
||||
|
||||
- name: Approve and enable auto-merge
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ steps.obtainToken.outputs.token }}
|
||||
run: |
|
||||
PR_NUMBER="${{ github.event.pull_request.number }}"
|
||||
if [ "$(gh pr view "$PR_NUMBER" --json reviewDecision --jq .reviewDecision)" != "APPROVED" ]; then
|
||||
gh pr review "$PR_NUMBER" --approve --body "Automatically approved by Homarr bot policy"
|
||||
fi
|
||||
if [ "$(gh pr view "$PR_NUMBER" --json autoMergeRequest --jq '.autoMergeRequest != null')" != "true" ]; then
|
||||
gh pr merge "$PR_NUMBER" --auto --squash
|
||||
fi
|
||||
@@ -1,5 +1,3 @@
|
||||
## [1.74.0](https://github.com/homarr-labs/homarr/compare/v1.73.0...v1.74.0) (2026-08-07)
|
||||
|
||||
## [1.73.0](https://github.com/homarr-labs/homarr/compare/v1.72.0...v1.73.0) (2026-07-31)
|
||||
|
||||
## [1.72.0](https://github.com/homarr-labs/homarr/compare/v1.71.0...v1.72.0) (2026-07-24)
|
||||
|
||||
@@ -206,14 +206,6 @@ packages/integrations/src/unraid @manuel-rw
|
||||
apps/docs/docs/integrations/uptime-kuma @ajnart
|
||||
packages/integrations/src/uptime-kuma @ajnart
|
||||
|
||||
# What's Up Docker (WUD) - introduced in https://github.com/homarr-labs/homarr/pull/6523 by @tiimonn
|
||||
apps/docs/docs/integrations/whats-up-docker @tiimonn
|
||||
apps/docs/docs/widgets/whats-up-docker @tiimonn
|
||||
packages/api/src/router/widgets/wud.ts @tiimonn
|
||||
packages/integrations/src/wud @tiimonn
|
||||
packages/request-handler/src/wud.ts @tiimonn
|
||||
packages/widgets/src/wud @tiimonn
|
||||
|
||||
# Traefik - by @Nerdy-Technician
|
||||
apps/docs/docs/integrations/traefik @Nerdy-Technician
|
||||
apps/docs/docs/widgets/traefik @Nerdy-Technician
|
||||
|
||||
@@ -95,6 +95,5 @@ ENV AUTH_PROVIDERS='credentials'
|
||||
ENV REDIS_IS_EXTERNAL='false'
|
||||
ENV NODE_ENV='production'
|
||||
|
||||
EXPOSE 7575
|
||||
ENTRYPOINT [ "/app/entrypoint.sh" ]
|
||||
CMD ["sh", "run.sh"]
|
||||
+2
-2
@@ -55,8 +55,8 @@ pnpm typecheck
|
||||
docs/
|
||||
├── getting-started/ # Installation guides, glossary, prerequisites
|
||||
├── management/ # Boards, apps, users, integrations, settings, tasks, API
|
||||
├── integrations/ # 56 integration guides (one folder each)
|
||||
├── widgets/ # 43 widget guides (one folder each)
|
||||
├── integrations/ # 55 integration guides (one folder each)
|
||||
├── widgets/ # 42 widget guides (one folder each)
|
||||
├── advanced/ # SSO, env vars, proxy, styling, CLI, development
|
||||
└── community/ # FAQ, donate, license, translations, get-in-touch
|
||||
```
|
||||
|
||||
@@ -25,14 +25,13 @@ This is especially useful for [docker secrets](https://docs.docker.com/engine/sw
|
||||
Using the `PUID` and `PGID` will require you to set the correct permissions on the mounted volumes and if used the docker socket. See more in the [Running as a different user](/docs/advanced/running-as-different-user) documentation.
|
||||
:::
|
||||
|
||||
| Environment Variable | Description | Possible values | Default |
|
||||
| ------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------- | ------- |
|
||||
| `PUID` | User ID to run the container as | Any valid user ID | `0` |
|
||||
| `PGID` | Group ID to run the container as | Any valid group ID | `0` |
|
||||
| `LOG_LEVEL` | Log level to use | `debug` / `info` / `warn` / `error` | `info` |
|
||||
| `NO_EXTERNAL_CONNECTION` | Disables Homarr's internet-backed requests, including update checks, weather, and RSS feeds | `true` or `false` | `false` |
|
||||
| `ENABLE_DNS_CACHING` | Enables dns caching. Enabled by default, but if you experience issues with IPv6 or static IPs, see [#4006](https://github.com/homarr-labs/homarr/issues/4006) or disable this flag | `true` or `false` | `true` |
|
||||
| `HOMARR_DISABLE_IPV6` | Disables the IPv6 listener of the internal nginx proxy, even when the host supports IPv6. IPv6 is normally disabled automatically when the host has no IPv6 configured. See [#4596](https://github.com/homarr-labs/homarr/issues/4596) | `true` or `false` | `false` |
|
||||
| Environment Variable | Description | Possible values | Default |
|
||||
| ------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------- | ------- |
|
||||
| `PUID` | User ID to run the container as | Any valid user ID | `0` |
|
||||
| `PGID` | Group ID to run the container as | Any valid group ID | `0` |
|
||||
| `LOG_LEVEL` | Log level to use | `debug` / `info` / `warn` / `error` | `info` |
|
||||
| `NO_EXTERNAL_CONNECTION` | Disables Homarr's internet-backed requests, including update checks, weather, and RSS feeds | `true` or `false` | `false` |
|
||||
| `ENABLE_DNS_CACHING` | Enables dns caching. Enabled by default, but if you experience issues with IPv6 or static IPs, see [#4006](https://github.com/homarr-labs/homarr/issues/4006) or disable this flag | `true` or `false` | `true` |
|
||||
|
||||
## Authentication
|
||||
|
||||
|
||||
@@ -47,6 +47,7 @@ services:
|
||||
traefik.http.routers.homarr.rule: Host(`your.internal.dns.address.here.com`)
|
||||
traefik.http.routers.homarr.entrypoints: websecure
|
||||
traefik.http.routers.homarr-secure.app: homarr
|
||||
traefik.http.apps.homarr.loadbalancer.server.port: 7575
|
||||
|
||||
networks:
|
||||
proxy:
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
|
||||
<img src="https://raw.githubusercontent.com/homarr-labs/charts/refs/heads/main/charts/homarr/icon.svg" align="right" width="92" alt="homarr logo">
|
||||
|
||||

|
||||

|
||||

|
||||

|
||||

|
||||
|
||||
A Helm chart to deploy homarr for Kubernetes
|
||||
|
||||
@@ -429,7 +429,7 @@ All available values are listed on the [artifacthub](https://artifacthub.io/pack
|
||||
| httproute.rules[0].matches[0].path.value | string | `"/"` | Path value to match |
|
||||
| image.pullPolicy | string | `"IfNotPresent"` | Image pull policy |
|
||||
| image.repository | string | `"ghcr.io/homarr-labs/homarr"` | Image repository |
|
||||
| image.tag | string | `"v1.74.0"` | Overrides the image tag whose default is the chart appVersion |
|
||||
| image.tag | string | `"v1.73.0"` | Overrides the image tag whose default is the chart appVersion |
|
||||
| imagePullSecrets | list | `[]` | Secrets for Docker registry |
|
||||
| ingress.annotations | object | `{}` | Ingress annotations |
|
||||
| ingress.enabled | bool | `false` | Enable ingress |
|
||||
|
||||
@@ -1,48 +0,0 @@
|
||||
---
|
||||
title: "What's Up Docker"
|
||||
description: "What's Up Docker (WUD) watches your Docker containers and reports which ones have newer image versions available."
|
||||
hide_title: true
|
||||
---
|
||||
|
||||
import { IntegrationHeader } from "@site/src/components/integrations/header";
|
||||
import { IntegrationCapabilites } from "@site/src/components/integrations/widgets";
|
||||
import { AddingIntegration } from "@site/src/components/integrations/adding";
|
||||
import { IntegrationSecrets } from "@site/src/components/integrations/secrets";
|
||||
import { wudIntegration } from ".";
|
||||
import { wudWidget } from "@site/docs/widgets/whats-up-docker";
|
||||
|
||||
<IntegrationHeader integration={wudIntegration} categories={["Health Monitoring"]} />
|
||||
|
||||
The What's Up Docker integration lets Homarr connect to your [WUD](https://github.com/getwud/wud) instance so you can see how many of your watched containers have image updates available directly from your dashboard.
|
||||
|
||||
### Widgets & Capabilities
|
||||
|
||||
<IntegrationCapabilites items={[{ widget: wudWidget }]} />
|
||||
|
||||
### Adding the integration
|
||||
|
||||
<AddingIntegration />
|
||||
|
||||
:::info URL configuration
|
||||
Use the root URL of your WUD instance, for example `https://wud.example.com`. Homarr calls the `/api/app` and `/api/containers` endpoints under that URL automatically.
|
||||
:::
|
||||
|
||||
### Secrets
|
||||
|
||||
WUD does not require authentication by default. If your instance is protected with [Basic Authentication](https://github.com/getwud/wud/blob/main/docs/configuration/authentications/basic/README.md), you can provide a username and password.
|
||||
|
||||
<IntegrationSecrets
|
||||
secrets={[
|
||||
{
|
||||
credentials: [],
|
||||
steps: [],
|
||||
},
|
||||
{
|
||||
credentials: ["username", "password"],
|
||||
steps: [
|
||||
"Open your WUD instance's configuration and configure a WUD_AUTH_BASIC_* user as described in the WUD documentation",
|
||||
"Enter that username and password in Homarr — Basic authentication is handled automatically",
|
||||
],
|
||||
},
|
||||
]}
|
||||
/>
|
||||
@@ -1,9 +0,0 @@
|
||||
import { IntegrationDefinition } from "@site/src/types";
|
||||
|
||||
export const wudIntegration: IntegrationDefinition = {
|
||||
name: "What's Up Docker",
|
||||
description:
|
||||
"What's Up Docker (WUD) watches your Docker containers and reports which ones have newer image versions available.",
|
||||
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/whats-up-docker.svg",
|
||||
path: "../../integrations/whats-up-docker",
|
||||
};
|
||||
@@ -7,8 +7,8 @@ tags:
|
||||
- Ping
|
||||
---
|
||||
|
||||
Apps are the building blocks of your Homarr board.
|
||||
Most of the time they are a simple link to a website, but used on your board they also support a simple ping to check if your service is up.
|
||||
Apps are the building blocks of your Homarr board.
|
||||
Most of the time they are a simple link to a website, but used on your board they also support a simple ping to check if your service is up.
|
||||
Apps are used in the bookmark and app widget.
|
||||
|
||||
## Create an app
|
||||
@@ -27,7 +27,7 @@ The name must be at least one character long and can be chosen freely.
|
||||
|
||||
### Icon URL
|
||||
|
||||
This is the URL to the icon that will be displayed on the app widget.
|
||||
This is the URL to the icon that will be displayed on the app widget.
|
||||
You can paste a direct HTTP(S) URL to an image, search the included icon repositories by name, or upload a local file through the upload button. Direct image URLs are used as entered and are not searched in the icon repositories.
|
||||
|
||||

|
||||
@@ -40,14 +40,11 @@ An optional description that can be shown as tooltip on the app widget.
|
||||
|
||||
The URL to the website you want to link to. Clicking on the app widget or bookmark will open this URL.
|
||||
|
||||
You can also use custom URI schemes such as `vscode://file/path`, `microsoft-edge:https://example.com`, or `myapp://open` to link to local applications or third-party programs. We trust that you know what you are doing — only `javascript:` URIs are blocked for security reasons.
|
||||
|
||||
### Ping URL
|
||||
|
||||
When you want to use another url for the simple status check you can set it here.
|
||||
When you want to use another url for the simple status check you can set it here.
|
||||
This will allow you to set a different URL for the ping than the URL that is opened when clicking on the app widget.
|
||||
It's useful when you want to ping the service internally for example with a custom ip but open the website externally.
|
||||
Note that the ping URL only accepts `http://` and `https://` URLs — custom URI schemes are not supported for pings.
|
||||
|
||||
## List of apps
|
||||
|
||||
|
||||
@@ -9,11 +9,11 @@ tags:
|
||||
- Layout
|
||||
---
|
||||
|
||||
import iconColorImage from "./img/icon-color.png";
|
||||
import itemRadiusImage from "./img/item-radius.png";
|
||||
import layoutLargeImage from "./img/layout-large.png";
|
||||
import layoutMediumImage from "./img/layout-medium.png";
|
||||
import layoutSmallImage from "./img/layout-small.png";
|
||||
import iconColorImage from './img/icon-color.png'
|
||||
import itemRadiusImage from './img/item-radius.png'
|
||||
import layoutLargeImage from './img/layout-large.png'
|
||||
import layoutMediumImage from './img/layout-medium.png'
|
||||
import layoutSmallImage from './img/layout-small.png'
|
||||
|
||||
Each dashboard is called a "board".
|
||||
You can create as many boards as you want.
|
||||
@@ -24,29 +24,24 @@ Other boards can be viewed by direct links (e.g. my-homarr.com/board/smart-home)
|
||||
Names of boards must be unique because this will be used in the URL to load the desired board.
|
||||
|
||||
## Create a new board
|
||||
|
||||
When creating a board, the availability of the board name will be checked for you.
|
||||
|
||||

|
||||
|
||||
### Name
|
||||
|
||||
The name must not include any special characters and must be at least one character long.
|
||||
Underscores and hyphen-minus are allowed.
|
||||
|
||||
### Column count
|
||||
|
||||
Homarr has a complex drag-and-drop system that can be used from the browser.
|
||||
Homarr has a complex drag and drop system that can be used from the browser.
|
||||
The column count defines how many horizontal columns are available to drag and drop elements to.
|
||||
|
||||
### Public
|
||||
|
||||
This will enable public access for everyone - regardless of whether they are logged in or not.
|
||||
This can be really useful if you want to have public boards in your company or for family and friends.
|
||||
This can be really useful if you want to have public boards in your company or for family and fiends.
|
||||
Modifications for public boards must be allowed explicitly using user groups and their permissions.
|
||||
|
||||
## Board settings
|
||||
|
||||
You navigate to the board settings by clicking on these three dots:
|
||||
|
||||

|
||||
@@ -55,12 +50,8 @@ Alternatively, you can also navigate from the board page itself using the cog ic
|
||||
|
||||

|
||||
|
||||
All board settings are edited on a single page. Click the **Save changes** button in the save bar at the bottom to apply them, or **Discard** to restore the last saved values. The save bar only appears when you have unsaved changes. Access control and the Danger zone are independent sections and are applied immediately.
|
||||
|
||||
### General
|
||||
|
||||
#### Page title
|
||||
|
||||
This is the title that will be visible at the top left in the navigation bar:
|
||||
|
||||

|
||||
@@ -68,7 +59,6 @@ This is the title that will be visible at the top left in the navigation bar:
|
||||
This field is optional and Homarr will fall back to "Homarr" if nothing is set.
|
||||
|
||||
#### Meta title
|
||||
|
||||
This is the secondary title of the page that is not directly visible in the page itself.
|
||||
Browser will use it to set the title of tabs:
|
||||
|
||||
@@ -77,12 +67,10 @@ Browser will use it to set the title of tabs:
|
||||
Homarr will fall back to the board name and a suffix if nothing is set.
|
||||
|
||||
#### Logo image URL
|
||||
|
||||
This is the URL that is used for the image at the top left.
|
||||
Homarr will fall back to the Homarr logo if nothing is set.
|
||||
|
||||
#### Favicon image URL
|
||||
|
||||
This is the URL that is used for the favicon in the tab title.
|
||||
Homarr will fall back to the Homarr logo if nothing is set.
|
||||
|
||||
@@ -106,65 +94,51 @@ Amount of columns for this layout. Defines how many items can be placed in a row
|
||||
|
||||
#### Breakpoint
|
||||
|
||||
Minimum width of the screen where the layout will be used.
|
||||
Once the minimum of the next layout is reached the layout will change.
|
||||
Minimum width of the screen where the layout will be used.
|
||||
Once the minmum of the next layout is reached the layout will change.
|
||||
|
||||
#### Preview
|
||||
|
||||
<img src={layoutSmallImage} height={256} />
|
||||
<img src={layoutMediumImage} height={256} />
|
||||
<img src={layoutLargeImage} height={256} />
|
||||
<img src={layoutSmallImage} height={256} />
|
||||
<img src={layoutMediumImage} height={256} />
|
||||
<img src={layoutLargeImage} height={256} />
|
||||
|
||||
### Background
|
||||
|
||||
#### Background image URL
|
||||
|
||||
Homarr can display a background image behind apps and widgets.
|
||||
This URL can be used to define what image should be shown.
|
||||
|
||||
#### Background image attachment
|
||||
|
||||
This defines whether the background should move when the user scrolls.
|
||||
|
||||
#### Background image size
|
||||
|
||||
This defines whether the background should cover the entire page (and possibly be cropped) or never be cropped but may not cover the entire page.
|
||||
|
||||
#### Background image repeat
|
||||
|
||||
This defines whether the background should be repeated if it cannot cover the entire page.
|
||||
This option can be useful if you have patterns that can be repeated easily (e.g. patterns):
|
||||
|
||||

|
||||
|
||||
### Appearance
|
||||
|
||||
#### Primary color
|
||||
|
||||
Color that can be chosen freely to give accents to active elements.
|
||||
|
||||
#### Secondary color
|
||||
|
||||
Color that can be chosen freely to give accents.
|
||||
|
||||
#### Opacity
|
||||
|
||||
Opacity of the colors.
|
||||
|
||||
#### Icon color
|
||||
|
||||
Change the color of your bookmark / app icons.
|
||||
|
||||
<img src={iconColorImage} height={100} />
|
||||
<img src={iconColorImage} height={100} />
|
||||
|
||||
#### Item radius
|
||||
|
||||
Change the radius of your items / categories.
|
||||
|
||||
<img src={itemRadiusImage} height={100} />
|
||||
<img src={itemRadiusImage} height={100} />
|
||||
|
||||
|
||||
### Custom CSS
|
||||
|
||||
This is the field where you can enter custom CSS.
|
||||
The styling will only be applied on the respective board page.
|
||||
|
||||
@@ -173,16 +147,14 @@ The styling will only be applied on the respective board page.
|
||||
### Behaviour
|
||||
|
||||
#### Disable app status
|
||||
|
||||
This will disable the status check for apps.
|
||||
|
||||
### Access control
|
||||
|
||||
With the access control, you can permit a single user or a group to your board.
|
||||
The users and groups can have one of these three levels of access:
|
||||
|
||||
| **Permission Level** | View board | Edit mode | Settings | Danger zone (eg. delete) |
|
||||
| -------------------- | ---------- | --------- | -------- | ------------------------ |
|
||||
|----------------------|------------|-----------|----------|--------------------------|
|
||||
| View board | X | | | |
|
||||
| Modify board | X | X | X | |
|
||||
| Full access | X | X | X | X |
|
||||
@@ -201,27 +173,22 @@ After editing permissions, the save button below the table must be pressed.
|
||||
Otherwise, all changes will be discarded.
|
||||
|
||||
### Danger zone
|
||||
|
||||
#### Rename
|
||||
|
||||
Renaming a board will break all existing links to it permanently with no redirect.
|
||||
|
||||
#### Board visibility
|
||||
|
||||
By default, boards will be private. This means only authenticated users that are authorized have access.
|
||||
Making a board public will expose it to everyone - including unauthenticated users.
|
||||
|
||||
#### Delete
|
||||
|
||||
This will permanently delete the board.
|
||||
This action is irreversible and cannot be undone.
|
||||
|
||||
## Delete boards
|
||||
|
||||
The deletion of boards is permanent and cannot be undone.
|
||||
There are two ways to delete a board:
|
||||
|
||||
- Navigate to the settings of your board, open the "Danger zone" section and click on the "delete this board" button.
|
||||
- Navigate to the settings of your board, open the "Danger zone" dropdown and click on the "delete this board" button.
|
||||
- Navigate to the list of boards in the management pages, click the three dots and click on "delete permanently".
|
||||
|
||||
Both actions will execute the same deletion process.
|
||||
@@ -229,7 +196,7 @@ Both actions will execute the same deletion process.
|
||||
## Duplicate boards
|
||||
|
||||
:::note
|
||||
Duplicating a board will not copy the users and groups that have access to the board
|
||||
Duplicating a board will not copy the users and groups that have access to the board
|
||||
nor the integrations the user does not have access to.
|
||||
:::
|
||||
|
||||
@@ -239,51 +206,47 @@ You can duplicate a board by clicking on the three dots in the list of boards an
|
||||

|
||||
|
||||
## Setting boards as your home board
|
||||
|
||||
The home board is the dashboard, that will be shown at the root path of Homarr or if no board name was passed in the URL.
|
||||
All the following paths will lead to the home board:
|
||||
|
||||
- `my-homarr.com`
|
||||
- `my-homarr.com/boards`
|
||||
- ``my-homarr.com``
|
||||
- ``my-homarr.com/boards``
|
||||
|
||||
Your home board will have a special small badge at the top right in the list of boards:
|
||||
|
||||

|
||||
|
||||
### For yourself
|
||||
|
||||
To set the home board yourself, the easiest option is to set it directly in the list of boards:
|
||||
|
||||

|
||||
|
||||
Alternatively, you can also define it via your user profile:
|
||||
|
||||
1. Click on your user profile at the top right
|
||||
2. Click on `Your preferences`
|
||||
3. Change the home board in the `home board` dropdown and click on `Save`.
|
||||
2. Click on ``Your preferences``
|
||||
3. Change the home board in the ``home board`` dropdown and click on ``Save``.
|
||||
|
||||
### For other users
|
||||
|
||||
1. Navigate to the users: `Management` > `Users` > `Manage`
|
||||
1. Navigate to the users: ``Management`` > ``Users`` > ``Manage``
|
||||
2. Click on the username of the user that you want to adjust
|
||||
3. Select the desired home board via the dropdown and click on `Save` \
|
||||

|
||||
3. Select the desired home board via the dropdown and click on ``Save`` \
|
||||

|
||||
|
||||
### For user groups
|
||||
|
||||
1. Navigate to the user groups: `Management` > `Users` > `Groups` > `Manage`
|
||||
2. Go to tab `Settings`
|
||||
3. Select the desired home board via the dropdown and click on `Save` \
|
||||

|
||||
1. Navigate to the user groups: ``Management`` > ``Users`` > ``Groups`` > ``Manage``
|
||||
2. Go to tab ``Settings``
|
||||
3. Select the desired home board via the dropdown and click on ``Save`` \
|
||||

|
||||
|
||||
### For whole server
|
||||
|
||||
You can also set a global home board that will be used for all users that have not set a home board themselves.
|
||||
It will also be used for anonymous users.
|
||||
|
||||
1. Navigate to the settings: `Management` > `Settings` > `Boards`
|
||||
2. Select the desired home board via the dropdown and click on `Save` \
|
||||

|
||||
1. Navigate to the settings: ``Management`` > ``Settings`` > ``Boards``
|
||||
2. Select the desired home board via the dropdown and click on ``Save`` \
|
||||

|
||||
|
||||
:::note
|
||||
For the global home board you can only select boards that are public.
|
||||
@@ -292,4 +255,4 @@ For the global home board you can only select boards that are public.
|
||||
### Mobile home board
|
||||
|
||||
Same as the home board, you can also set a mobile home board.
|
||||
It will be used for devices that are detected as mobile devices. (Using User-Agent header)
|
||||
It will be used for devices that are detected as mobile devices. (Using User-Agent header)
|
||||
@@ -9,127 +9,97 @@ tags:
|
||||
---
|
||||
|
||||
# Settings
|
||||
|
||||
The settings page applies options to the whole Homarr application.
|
||||
Some changes need a full page reload before they take effect.
|
||||
|
||||
All changes are collected in a single form. Click the **Save changes** button in the save bar at the bottom of the page to apply them. The save bar only appears when you have unsaved changes, and the **Discard** button reverts your changes.
|
||||
The settings page has a few options that will be applied to the entire Homarr application.
|
||||
They may require a full page reload for changes to apply.
|
||||
|
||||
## Analytics
|
||||
By default, Homarr is collecting anonymous analytics to identify issues and prioritize bugfixes and improvements.
|
||||
The data is never shared with any third-parties and is fully GDPR & CCPA compliant.
|
||||
The software being used is [Umami Analytics](https://umami.is/) which is fully open source.
|
||||
The main server software can be found [here](https://github.com/umami-software/umami) and [this node library](https://github.com/umami-software/node) to perform server side calls to the API.
|
||||
|
||||
Homarr uses [PostHog](https://posthog.com/) to collect anonymous usage data.
|
||||
We collect this data to see what features people ACTUALLY use.
|
||||
We use this data to decide where we spend our development time.
|
||||
A full list of the collected data by default can be found here: https://umami.is/docs/metric-definitions.
|
||||
|
||||
We do not sell the data. We do not share the data with third parties.
|
||||
The data does not contain personal information and does not identify you or your installation.
|
||||
We understand that you might not want Homarr to communicate with our services.
|
||||
By unchecking the ``Send anonymous analytics`` checkbox and reloading the full page, all communication by Umami will be disabled and no data will be collected.
|
||||
If you are paranoid, you can permanently block ``umami.homarr.dev`` from your DNS / firewall.
|
||||
|
||||
Homarr sends the data to our own PostHog server at `https://hog.homarr.dev`.
|
||||
The server sends the data once a week and when the server starts.
|
||||
Additionally, you opt in to more data collection to help us provide more useful data.
|
||||
|
||||
### What we collect
|
||||
### Integration data
|
||||
Enabling this optional data will send the types of integrations (e.g. SABnzbd, Sonarr, ...) and how many you have configured of them.
|
||||
This will not include any URLs, names, secrets, errors and others. This is disabled by default.
|
||||
|
||||
Homarr collects technical data about your installation:
|
||||
### Widget data
|
||||
Enabling this optional data will send the types of widgets (e.g. downloads, media streams, iframe, ...) and how many you have configured of them.
|
||||
This will not include any URLs, names, secrets, errors and others. This is disabled by default.
|
||||
|
||||
- Your Homarr version
|
||||
- Your database type (SQLite, MySQL, or PostgreSQL)
|
||||
- Whether Docker or Kubernetes is enabled
|
||||
- Your enabled authentication providers
|
||||
- Your operating system and CPU architecture
|
||||
- Your default language
|
||||
- The number of boards, apps, sections, layouts, users, groups, search engines, icon repositories, cron jobs, API keys, invites, sessions, and accounts
|
||||
- The integration types you use and how many you have of each (for example, Sonarr or SABnzbd)
|
||||
- The widget types you use and how many you have of each
|
||||
|
||||
Homarr does NOT collect:
|
||||
|
||||
- URLs
|
||||
- Names
|
||||
- Usernames, emails, or passwords
|
||||
- Integration secrets or API keys
|
||||
- Error messages or logs
|
||||
- Any data that can identify you
|
||||
|
||||
Each installation gets a random ID. We use this ID only to count unique installations. We cannot use this ID to identify you.
|
||||
|
||||
Homarr does not use cookies for analytics. Homarr does not track page views automatically. Homarr only sends the data listed above.
|
||||
|
||||
### Disable analytics
|
||||
|
||||
Analytics are enabled by default. To disable analytics:
|
||||
|
||||
1. Open the Settings page.
|
||||
2. Uncheck the "Help us improve Homarr" checkbox.
|
||||
3. Reload the full page.
|
||||
|
||||
When analytics are disabled, Homarr does not send any data to PostHog.
|
||||
|
||||
You can also set the `NO_EXTERNAL_CONNECTION` environment variable to `true`. This stops all external requests, including analytics. See the [environment variables](/docs/advanced/environment-variables) page for more information.
|
||||
|
||||
You can block `hog.homarr.dev` in your DNS or firewall. Homarr cannot send data when this address is blocked.
|
||||
### Users data
|
||||
Enabling this optional data will send the count of users and whether you activated SSO.
|
||||
This will not include any usernames, passwords, emails, URLs, authentication flows or client IDs.
|
||||
This is disabled by default.
|
||||
|
||||
## Search Engine Optimization (SEO)
|
||||
|
||||
Search engines use web crawlers to index websites. This can cause unwanted traffic to your dashboard.
|
||||
Homarr asks crawlers to not index your instance by default. Use these settings to change this request.
|
||||
|
||||
Homarr cannot control what the crawlers do. A crawler can ignore the request. Your website can still appear in an index.
|
||||
Big companies [like Google, Microsoft, Baidu and Yandex](https://de.wikipedia.org/wiki/Web-Index) will use [web crawlers](https://de.wikipedia.org/wiki/Webcrawler) to index the Internet for new websites & pages.
|
||||
This is desirable in many cases (for example when selling products in an online shop) but can lead to unwanted traffic and attention to your personal dashboard.
|
||||
Homarr is requesting the crawlers to not index Homarr instances by default for this reason.
|
||||
If you use Homarr as a public website and want it to be searchable on these search engines, you can disable these settings.
|
||||
Please note that Homarr doesn't have control over what the crawlers do; it is a mere request to them to not perform these actions but will not guarantee that your website doesn't land on some index.
|
||||
|
||||
### No index
|
||||
|
||||
This setting asks crawlers to not index your instance.
|
||||
This setting is enabled by default. Disable this setting to allow indexing.
|
||||
This will ask crawlers to not index your Homarr instance on any indexes.
|
||||
It is up to the crawler to properly respect this request.
|
||||
By default, this setting is enabled. Disabling it will allow indexing.
|
||||
|
||||
### No follow
|
||||
|
||||
Crawlers follow links to discover new pages.
|
||||
This setting asks crawlers to not follow links. This setting is enabled by default.
|
||||
Crawler behavior can differ between search engines.
|
||||
If indexing is allowed, crawlers will also attempt to follow any links on your page to further "discover" new parts of the Internet and on your website.
|
||||
By default, this setting is enabled and will ask crawlers to not follow any links.
|
||||
Crawler of different companies may behave differently when indexing is prohibited and following is allowed.
|
||||
|
||||
### No translate
|
||||
|
||||
Some browsers offer to translate pages.
|
||||
This setting asks crawlers to not offer translation. Some browsers ignore this setting.
|
||||
Google and Chrome on Android will prompt users to translate pages that are not in their device language.
|
||||
This can lead to some shifting and can be undesired in some cases. By enabling this,
|
||||
you ask crawlers to never translate and never prompt users for translation.
|
||||
It should be noted that some browsers will ignore this and translate regardless.
|
||||
|
||||
### No site links search box
|
||||
|
||||
This setting applies to Google only.
|
||||
Google can show a search box below your domain.
|
||||
This setting asks Google to not show this box. This setting is disabled by default.
|
||||
This setting is exclusive to Google.
|
||||
[Google will create a box below your domain](https://developers.google.com/search/docs/appearance/structured-data/sitelinks-searchbox) that lists the paths that were discovered by the "folowing".
|
||||
The user can search them directly in Google.
|
||||
You can ask the crawlers to not allow searches by enabling this setting.
|
||||
This setting is disabled by default.
|
||||
|
||||
## Boards
|
||||
|
||||
### Global home board
|
||||
|
||||
You can set a global home board.
|
||||
This board is the fallback for users without their own home board. It is also the fallback for anonymous users.
|
||||
The board must be public.
|
||||
You can set a global home board that will be used for all users that have not set a home board themselves.
|
||||
It will also be used for anonymous users.
|
||||
Note that the board must be public to be used as a global home board.
|
||||
|
||||
### Global mobile board
|
||||
|
||||
You can set a global mobile board.
|
||||
Homarr shows this board on mobile devices instead of the global home board.
|
||||
Homarr detects mobile devices with the User-Agent header.
|
||||
The board must be public.
|
||||
You can set a global mobile board. It will be shown instead of the global home board on mobile devices that can be detected by Homarr. (Using the User-Agent header).
|
||||
It will similar to the global home board be as fallback for normal users and for any anonymous users.
|
||||
It also has to be public.
|
||||
|
||||
### App status
|
||||
|
||||
#### Enable status by default
|
||||
|
||||
You can decide if app status is enabled when you add a new app.
|
||||
You can decide if the app status should be enabled by default when adding a new app to the board.
|
||||
|
||||
#### Force disable status
|
||||
|
||||
You can decide if app status is disabled on all boards.
|
||||
You can decide if the app status should be disabled for all boards.
|
||||
|
||||
###
|
||||
|
||||
## Appearance
|
||||
|
||||
### Default color scheme
|
||||
|
||||
You can set the default color scheme for users without their own color scheme.
|
||||
You can set the default color scheme for all users that have not set a color scheme themselves.
|
||||
|
||||
## Culture
|
||||
|
||||
### Default language
|
||||
|
||||
You can set the default language for users without their own language.
|
||||
You can set the default language for all users that have not set a language themselves.
|
||||
@@ -1,35 +0,0 @@
|
||||
---
|
||||
title: "What's Up Docker"
|
||||
description: "Displays how many watched containers have image updates available."
|
||||
hide_title: true
|
||||
---
|
||||
|
||||
import { WidgetHeader } from "@site/src/components/widgets/header";
|
||||
import { AddingWidget } from "@site/src/components/widgets/adding";
|
||||
import { WidgetConfig } from "@site/src/components/widgets/configuration";
|
||||
import { WidgetIntegrations } from "@site/src/components/widgets/integrations";
|
||||
import { wudWidget } from ".";
|
||||
import { wudIntegration } from "@site/docs/integrations/whats-up-docker";
|
||||
|
||||
<WidgetHeader widget={wudWidget} categories={["Health Monitoring"]} />
|
||||
|
||||
This widget shows two stats from your WUD instance: the total number of monitored containers, and how many of them currently have an image update available. Optionally, it can also list each outdated container with its current and new version, linking to the release notes when WUD provides one.
|
||||
|
||||
### Supported Integrations
|
||||
|
||||
<WidgetIntegrations
|
||||
items={[
|
||||
{
|
||||
integration: wudIntegration,
|
||||
note: "Displays monitored container and available-update counts",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
### Adding the widget
|
||||
|
||||
<AddingWidget />
|
||||
|
||||
### Configuration
|
||||
|
||||
<WidgetConfig configuration={wudWidget.configuration} />
|
||||
@@ -1,37 +0,0 @@
|
||||
import { WidgetDefinition } from "@site/src/types";
|
||||
import { IconBrandDocker } from "@tabler/icons-react";
|
||||
|
||||
export const wudWidget: WidgetDefinition = {
|
||||
icon: IconBrandDocker,
|
||||
name: "What's Up Docker",
|
||||
description: "Displays how many watched containers have image updates available.",
|
||||
path: "../../widgets/whats-up-docker",
|
||||
configuration: {
|
||||
items: [
|
||||
{
|
||||
name: "Show title",
|
||||
description: "Displays the icon and \"What's Up Docker\" label above the stats",
|
||||
values: { type: "boolean" },
|
||||
defaultValue: "yes",
|
||||
},
|
||||
{
|
||||
name: "Show update ring",
|
||||
description: "Displays a ring showing the percentage of monitored containers with a pending update",
|
||||
values: { type: "boolean" },
|
||||
defaultValue: "yes",
|
||||
},
|
||||
{
|
||||
name: "Layout",
|
||||
description: "Arrange the two stats side by side (Horizontal) or stacked (Vertical)",
|
||||
values: { type: "select", options: ["Horizontal", "Vertical"] },
|
||||
defaultValue: "Horizontal",
|
||||
},
|
||||
{
|
||||
name: "Show container update list",
|
||||
description: "Lists each monitored container that has an image update available, linking to its release notes when known",
|
||||
values: { type: "boolean" },
|
||||
defaultValue: "no",
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
@@ -19,106 +19,130 @@ import {
|
||||
import { useDisclosure } from "@mantine/hooks";
|
||||
import { IconX } from "@tabler/icons-react";
|
||||
|
||||
import type { UseFormReturnType } from "@homarr/form";
|
||||
import { useZodForm } from "@homarr/form";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import { boardSavePartialSettingsSchema } from "@homarr/validation/board";
|
||||
|
||||
import { SectionCard } from "~/components/manage/section-card";
|
||||
import type { Board } from "../../_types";
|
||||
import { generateColors } from "../../(content)/_theme";
|
||||
import type { FormValues } from "./_settings-form";
|
||||
import { useSavePartialSettingsMutation } from "./_shared";
|
||||
|
||||
interface Props {
|
||||
form: UseFormReturnType<FormValues>;
|
||||
board: Board;
|
||||
}
|
||||
|
||||
const hexRegex = /^#[0-9a-fA-F]{6}$/;
|
||||
|
||||
const progressPercentageLabel = (value: number) => `${value}%`;
|
||||
|
||||
export const ColorSettingsContent = ({ form }: Props) => {
|
||||
export const ColorSettingsContent = ({ board }: Props) => {
|
||||
const form = useZodForm(boardSavePartialSettingsSchema, {
|
||||
initialValues: {
|
||||
primaryColor: board.primaryColor,
|
||||
secondaryColor: board.secondaryColor,
|
||||
opacity: board.opacity,
|
||||
iconColor: board.iconColor ?? "",
|
||||
itemRadius: board.itemRadius,
|
||||
},
|
||||
});
|
||||
const [showPreview, { toggle }] = useDisclosure(false);
|
||||
const t = useI18n();
|
||||
const theme = useMantineTheme();
|
||||
const { mutate: savePartialSettings, isPending } = useSavePartialSettingsMutation(board);
|
||||
|
||||
return (
|
||||
<SectionCard title={t("board.setting.section.appearance.title")}>
|
||||
<Grid>
|
||||
<Grid.Col span={{ sm: 12, md: 6 }}>
|
||||
<Stack gap="xs">
|
||||
<ColorInput
|
||||
label={t("board.field.primaryColor.label")}
|
||||
format="hex"
|
||||
swatches={Object.values(theme.colors).map((color) => color[6])}
|
||||
{...form.getInputProps("primaryColor")}
|
||||
/>
|
||||
</Stack>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={{ sm: 12, md: 6 }}>
|
||||
<ColorInput
|
||||
label={t("board.field.secondaryColor.label")}
|
||||
format="hex"
|
||||
swatches={Object.values(theme.colors).map((color) => color[6])}
|
||||
{...form.getInputProps("secondaryColor")}
|
||||
/>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={12}>
|
||||
<Anchor onClick={toggle}>{showPreview ? t("common.preview.hide") : t("common.preview.show")}</Anchor>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={12}>
|
||||
<Collapse expanded={showPreview}>
|
||||
<Stack>
|
||||
<ColorsPreview previewColor={form.values.primaryColor} />
|
||||
<ColorsPreview previewColor={form.values.secondaryColor} />
|
||||
<form
|
||||
onSubmit={form.onSubmit((values) => {
|
||||
savePartialSettings({
|
||||
id: board.id,
|
||||
...values,
|
||||
});
|
||||
})}
|
||||
>
|
||||
<Stack>
|
||||
<Grid>
|
||||
<Grid.Col span={{ sm: 12, md: 6 }}>
|
||||
<Stack gap="xs">
|
||||
<ColorInput
|
||||
label={t("board.field.primaryColor.label")}
|
||||
format="hex"
|
||||
swatches={Object.values(theme.colors).map((color) => color[6])}
|
||||
{...form.getInputProps("primaryColor")}
|
||||
/>
|
||||
</Stack>
|
||||
</Collapse>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={{ sm: 12, md: 6 }}>
|
||||
<InputWrapper label={t("board.field.opacity.label")}>
|
||||
<Slider
|
||||
my={6}
|
||||
min={0}
|
||||
max={100}
|
||||
step={5}
|
||||
label={progressPercentageLabel}
|
||||
{...form.getInputProps("opacity")}
|
||||
/>
|
||||
</InputWrapper>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={{ sm: 12, md: 6 }}>
|
||||
<Group align="end">
|
||||
</Grid.Col>
|
||||
<Grid.Col span={{ sm: 12, md: 6 }}>
|
||||
<ColorInput
|
||||
label={t("board.field.iconColor.label")}
|
||||
label={t("board.field.secondaryColor.label")}
|
||||
format="hex"
|
||||
swatches={Object.values(theme.colors).map((color) => color[6])}
|
||||
flex={1}
|
||||
{...form.getInputProps("iconColor")}
|
||||
{...form.getInputProps("secondaryColor")}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="subtle"
|
||||
leftSection={<IconX />}
|
||||
onClick={() => form.setFieldValue("iconColor", "")}
|
||||
disabled={!form.values.iconColor}
|
||||
>
|
||||
{t("board.field.clearColor.label")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={{ sm: 12, md: 6 }}>
|
||||
<Select
|
||||
label={t("board.field.itemRadius.label")}
|
||||
description={t("board.field.itemRadius.description")}
|
||||
data={[
|
||||
{ label: t("board.field.itemRadius.option.xs"), value: "xs" },
|
||||
{ label: t("board.field.itemRadius.option.sm"), value: "sm" },
|
||||
{ label: t("board.field.itemRadius.option.md"), value: "md" },
|
||||
{ label: t("board.field.itemRadius.option.lg"), value: "lg" },
|
||||
{ label: t("board.field.itemRadius.option.xl"), value: "xl" },
|
||||
]}
|
||||
{...form.getInputProps("itemRadius")}
|
||||
/>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
</SectionCard>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={12}>
|
||||
<Anchor onClick={toggle}>{showPreview ? t("common.preview.hide") : t("common.preview.show")}</Anchor>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={12}>
|
||||
<Collapse expanded={showPreview}>
|
||||
<Stack>
|
||||
<ColorsPreview previewColor={form.values.primaryColor} />
|
||||
<ColorsPreview previewColor={form.values.secondaryColor} />
|
||||
</Stack>
|
||||
</Collapse>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={{ sm: 12, md: 6 }}>
|
||||
<InputWrapper label={t("board.field.opacity.label")}>
|
||||
<Slider
|
||||
my={6}
|
||||
min={0}
|
||||
max={100}
|
||||
step={5}
|
||||
label={progressPercentageLabel}
|
||||
{...form.getInputProps("opacity")}
|
||||
/>
|
||||
</InputWrapper>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={{ sm: 12, md: 6 }}>
|
||||
<Group align="end">
|
||||
<ColorInput
|
||||
label={t("board.field.iconColor.label")}
|
||||
format="hex"
|
||||
swatches={Object.values(theme.colors).map((color) => color[6])}
|
||||
flex={1}
|
||||
{...form.getInputProps("iconColor")}
|
||||
/>
|
||||
<Button
|
||||
variant="subtle"
|
||||
leftSection={<IconX />}
|
||||
onClick={() => form.setFieldValue("iconColor", "")}
|
||||
disabled={!form.values.iconColor}
|
||||
>
|
||||
{t("board.field.clearColor.label")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={{ sm: 12, md: 6 }}>
|
||||
<Select
|
||||
label={t("board.field.itemRadius.label")}
|
||||
description={t("board.field.itemRadius.description")}
|
||||
data={[
|
||||
{ label: t("board.field.itemRadius.option.xs"), value: "xs" },
|
||||
{ label: t("board.field.itemRadius.option.sm"), value: "sm" },
|
||||
{ label: t("board.field.itemRadius.option.md"), value: "md" },
|
||||
{ label: t("board.field.itemRadius.option.lg"), value: "lg" },
|
||||
{ label: t("board.field.itemRadius.option.xl"), value: "xl" },
|
||||
]}
|
||||
{...form.getInputProps("itemRadius")}
|
||||
/>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
<Group justify="end">
|
||||
<Button type="submit" loading={isPending}>
|
||||
{t("common.action.saveChanges")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,32 +1,41 @@
|
||||
"use client";
|
||||
|
||||
import { startTransition } from "react";
|
||||
import { ActionIcon, Autocomplete, Center, Grid, Group, Popover, Stack, Text } from "@mantine/core";
|
||||
import { ActionIcon, Autocomplete, Button, Center, Grid, Group, Popover, Stack, Text } from "@mantine/core";
|
||||
import { useDebouncedValue } from "@mantine/hooks";
|
||||
import { IconPhotoOff, IconUpload } from "@tabler/icons-react";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { useSession } from "@homarr/auth/client";
|
||||
import { backgroundImageAttachments, backgroundImageRepeats, backgroundImageSizes } from "@homarr/definitions";
|
||||
import type { UseFormReturnType } from "@homarr/form";
|
||||
import { useZodForm } from "@homarr/form";
|
||||
import { UploadMedia } from "@homarr/forms-collection";
|
||||
import type { TranslationObject } from "@homarr/translation";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import type { SelectItemWithDescriptionBadge } from "@homarr/ui";
|
||||
import { SelectWithDescriptionBadge } from "@homarr/ui";
|
||||
import { boardSavePartialSettingsSchema } from "@homarr/validation/board";
|
||||
|
||||
import { SectionCard } from "~/components/manage/section-card";
|
||||
import type { FormValues } from "./_settings-form";
|
||||
import type { Board } from "../../_types";
|
||||
import { useSavePartialSettingsMutation } from "./_shared";
|
||||
|
||||
interface Props {
|
||||
form: UseFormReturnType<FormValues>;
|
||||
board: Board;
|
||||
}
|
||||
|
||||
export const BackgroundSettingsContent = ({ form }: Props) => {
|
||||
export const BackgroundSettingsContent = ({ board }: Props) => {
|
||||
const t = useI18n();
|
||||
const { data: session } = useSession();
|
||||
const { mutate: savePartialSettings, isPending } = useSavePartialSettingsMutation(board);
|
||||
const form = useZodForm(boardSavePartialSettingsSchema, {
|
||||
initialValues: {
|
||||
backgroundImageUrl: board.backgroundImageUrl ?? "",
|
||||
backgroundImageAttachment: board.backgroundImageAttachment,
|
||||
backgroundImageRepeat: board.backgroundImageRepeat,
|
||||
backgroundImageSize: board.backgroundImageSize,
|
||||
},
|
||||
});
|
||||
|
||||
const [debouncedSearch] = useDebouncedValue(form.values.backgroundImageUrl ?? "", 200);
|
||||
const [debouncedSearch] = useDebouncedValue(form.values.backgroundImageUrl, 200);
|
||||
const medias = clientApi.media.getPaginated.useQuery({
|
||||
page: 1,
|
||||
pageSize: 10,
|
||||
@@ -44,106 +53,121 @@ export const BackgroundSettingsContent = ({ form }: Props) => {
|
||||
const backgroundImageRepeatData = useBackgroundOptionData("backgroundImageRepeat", backgroundImageRepeats);
|
||||
|
||||
return (
|
||||
<SectionCard title={t("board.setting.section.background.title")}>
|
||||
<Grid>
|
||||
<Grid.Col span={12}>
|
||||
<Group wrap="nowrap" gap="xs" w="100%" align="start">
|
||||
<Autocomplete
|
||||
flex={1}
|
||||
leftSection={
|
||||
form.values.backgroundImageUrl &&
|
||||
form.values.backgroundImageUrl.trim().length >= 2 && (
|
||||
<Popover width={300} withArrow>
|
||||
<Popover.Target>
|
||||
<Center h="100%">
|
||||
<ImagePreview src={form.values.backgroundImageUrl} w={20} h={20} />
|
||||
</Center>
|
||||
</Popover.Target>
|
||||
<Popover.Dropdown>
|
||||
<ImagePreview src={form.values.backgroundImageUrl} w="100%" />
|
||||
</Popover.Dropdown>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
// We filter it on the server
|
||||
filter={({ options }) => options}
|
||||
label={t("board.field.backgroundImageUrl.label")}
|
||||
placeholder={`${t("board.field.backgroundImageUrl.placeholder")}...`}
|
||||
renderOption={({ option }) => {
|
||||
const current = imageMap.get(option.value);
|
||||
if (!current) return null;
|
||||
<form
|
||||
onSubmit={form.onSubmit((values) => {
|
||||
savePartialSettings({
|
||||
id: board.id,
|
||||
...values,
|
||||
});
|
||||
})}
|
||||
>
|
||||
<Stack>
|
||||
<Grid>
|
||||
<Grid.Col span={12}>
|
||||
<Group wrap="nowrap" gap="xs" w="100%" align="start">
|
||||
<Autocomplete
|
||||
flex={1}
|
||||
leftSection={
|
||||
form.values.backgroundImageUrl &&
|
||||
form.values.backgroundImageUrl.trim().length >= 2 && (
|
||||
<Popover width={300} withArrow>
|
||||
<Popover.Target>
|
||||
<Center h="100%">
|
||||
<ImagePreview src={form.values.backgroundImageUrl} w={20} h={20} />
|
||||
</Center>
|
||||
</Popover.Target>
|
||||
<Popover.Dropdown>
|
||||
<ImagePreview src={form.values.backgroundImageUrl} w="100%" />
|
||||
</Popover.Dropdown>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
// We filter it on the server
|
||||
filter={({ options }) => options}
|
||||
label={t("board.field.backgroundImageUrl.label")}
|
||||
placeholder={`${t("board.field.backgroundImageUrl.placeholder")}...`}
|
||||
renderOption={({ option }) => {
|
||||
const current = imageMap.get(option.value);
|
||||
if (!current) return null;
|
||||
|
||||
return (
|
||||
<Group gap="sm">
|
||||
<ImagePreview src={option.value} w={20} h={20} />
|
||||
<Stack gap={0}>
|
||||
<Text size="sm">{current.name}</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{option.value}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Group>
|
||||
);
|
||||
}}
|
||||
data={[
|
||||
{
|
||||
group: t("board.field.backgroundImageUrl.group.your"),
|
||||
items: images
|
||||
.filter((media) => media.creatorId === session?.user.id)
|
||||
.map((media) => `/api/user-medias/${media.id}`),
|
||||
},
|
||||
{
|
||||
group: t("board.field.backgroundImageUrl.group.other"),
|
||||
items: images
|
||||
.filter((media) => media.creatorId !== session?.user.id)
|
||||
.map((media) => `/api/user-medias/${media.id}`),
|
||||
},
|
||||
]}
|
||||
{...form.getInputProps("backgroundImageUrl")}
|
||||
/>
|
||||
{session?.user.permissions.includes("media-upload") && (
|
||||
<UploadMedia
|
||||
onSuccess={(uploadedMedias) => {
|
||||
const first = uploadedMedias.at(0);
|
||||
if (!first) return;
|
||||
|
||||
startTransition(() => {
|
||||
form.setFieldValue("backgroundImageUrl", first.url);
|
||||
});
|
||||
return (
|
||||
<Group gap="sm">
|
||||
<ImagePreview src={option.value} w={20} h={20} />
|
||||
<Stack gap={0}>
|
||||
<Text size="sm">{current.name}</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{option.value}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Group>
|
||||
);
|
||||
}}
|
||||
>
|
||||
{({ onClick, loading }) => (
|
||||
<ActionIcon onClick={onClick} loading={loading} mt={24} size={36} variant="default">
|
||||
<IconUpload size={16} stroke={1.5} />
|
||||
</ActionIcon>
|
||||
)}
|
||||
</UploadMedia>
|
||||
)}
|
||||
</Group>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={12}>
|
||||
<SelectWithDescriptionBadge
|
||||
label={t("board.field.backgroundImageAttachment.label")}
|
||||
data={backgroundImageAttachmentData}
|
||||
{...form.getInputProps("backgroundImageAttachment")}
|
||||
/>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={12}>
|
||||
<SelectWithDescriptionBadge
|
||||
label={t("board.field.backgroundImageSize.label")}
|
||||
data={backgroundImageSizeData}
|
||||
{...form.getInputProps("backgroundImageSize")}
|
||||
/>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={12}>
|
||||
<SelectWithDescriptionBadge
|
||||
label={t("board.field.backgroundImageRepeat.label")}
|
||||
data={backgroundImageRepeatData}
|
||||
{...form.getInputProps("backgroundImageRepeat")}
|
||||
/>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
</SectionCard>
|
||||
data={[
|
||||
{
|
||||
group: t("board.field.backgroundImageUrl.group.your"),
|
||||
items: images
|
||||
.filter((media) => media.creatorId === session?.user.id)
|
||||
.map((media) => `/api/user-medias/${media.id}`),
|
||||
},
|
||||
{
|
||||
group: t("board.field.backgroundImageUrl.group.other"),
|
||||
items: images
|
||||
.filter((media) => media.creatorId !== session?.user.id)
|
||||
.map((media) => `/api/user-medias/${media.id}`),
|
||||
},
|
||||
]}
|
||||
{...form.getInputProps("backgroundImageUrl")}
|
||||
/>
|
||||
{session?.user.permissions.includes("media-upload") && (
|
||||
<UploadMedia
|
||||
onSuccess={(medias) => {
|
||||
const first = medias.at(0);
|
||||
if (!first) return;
|
||||
|
||||
startTransition(() => {
|
||||
form.setFieldValue("backgroundImageUrl", first.url);
|
||||
});
|
||||
}}
|
||||
>
|
||||
{({ onClick, loading }) => (
|
||||
<ActionIcon onClick={onClick} loading={loading} mt={24} size={36} variant="default">
|
||||
<IconUpload size={16} stroke={1.5} />
|
||||
</ActionIcon>
|
||||
)}
|
||||
</UploadMedia>
|
||||
)}
|
||||
</Group>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={12}>
|
||||
<SelectWithDescriptionBadge
|
||||
label={t("board.field.backgroundImageAttachment.label")}
|
||||
data={backgroundImageAttachmentData}
|
||||
{...form.getInputProps("backgroundImageAttachment")}
|
||||
/>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={12}>
|
||||
<SelectWithDescriptionBadge
|
||||
label={t("board.field.backgroundImageSize.label")}
|
||||
data={backgroundImageSizeData}
|
||||
{...form.getInputProps("backgroundImageSize")}
|
||||
/>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={12}>
|
||||
<SelectWithDescriptionBadge
|
||||
label={t("board.field.backgroundImageRepeat.label")}
|
||||
data={backgroundImageRepeatData}
|
||||
{...form.getInputProps("backgroundImageRepeat")}
|
||||
/>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
|
||||
<Group justify="end">
|
||||
<Button type="submit" loading={isPending}>
|
||||
{t("common.action.saveChanges")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,27 +1,48 @@
|
||||
"use client";
|
||||
|
||||
import { Switch } from "@mantine/core";
|
||||
import { Button, Group, Stack, Switch } from "@mantine/core";
|
||||
|
||||
import type { UseFormReturnType } from "@homarr/form";
|
||||
import { useForm } from "@homarr/form";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
|
||||
import { SectionCard } from "~/components/manage/section-card";
|
||||
import type { FormValues } from "./_settings-form";
|
||||
import type { Board } from "../../_types";
|
||||
import { useSavePartialSettingsMutation } from "./_shared";
|
||||
|
||||
interface Props {
|
||||
form: UseFormReturnType<FormValues>;
|
||||
board: Board;
|
||||
}
|
||||
|
||||
export const BehaviorSettingsContent = ({ form }: Props) => {
|
||||
export const BehaviorSettingsContent = ({ board }: Props) => {
|
||||
const t = useI18n();
|
||||
const { mutate: savePartialSettings, isPending } = useSavePartialSettingsMutation(board);
|
||||
const form = useForm({
|
||||
initialValues: {
|
||||
disableStatus: board.disableStatus,
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<SectionCard title={t("board.setting.section.behavior.title")}>
|
||||
<Switch
|
||||
label={t("board.field.disableStatus.label")}
|
||||
description={t("board.field.disableStatus.description")}
|
||||
{...form.getInputProps("disableStatus", { type: "checkbox" })}
|
||||
/>
|
||||
</SectionCard>
|
||||
<form
|
||||
onSubmit={form.onSubmit((values) => {
|
||||
savePartialSettings({
|
||||
id: board.id,
|
||||
...values,
|
||||
});
|
||||
})}
|
||||
>
|
||||
<Stack>
|
||||
<Switch
|
||||
label={t("board.field.disableStatus.label")}
|
||||
description={t("board.field.disableStatus.description")}
|
||||
{...form.getInputProps("disableStatus", { type: "checkbox" })}
|
||||
/>
|
||||
|
||||
<Group justify="end">
|
||||
<Button type="submit" loading={isPending}>
|
||||
{t("common.action.saveChanges")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { Alert, Input } from "@mantine/core";
|
||||
import { Alert, Button, Group, Input, Stack } from "@mantine/core";
|
||||
import { highlight, languages } from "prismjs";
|
||||
import Editor from "react-simple-code-editor";
|
||||
|
||||
@@ -8,39 +8,59 @@ import "~/styles/prismjs.scss";
|
||||
|
||||
import { IconInfoCircle } from "@tabler/icons-react";
|
||||
|
||||
import type { UseFormReturnType } from "@homarr/form";
|
||||
import { useForm } from "@homarr/form";
|
||||
import { useI18n, useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import { SectionCard } from "~/components/manage/section-card";
|
||||
import type { FormValues } from "./_settings-form";
|
||||
import type { Board } from "../../_types";
|
||||
import { useSavePartialSettingsMutation } from "./_shared";
|
||||
import classes from "./customcss.module.css";
|
||||
|
||||
interface Props {
|
||||
form: UseFormReturnType<FormValues>;
|
||||
board: Board;
|
||||
}
|
||||
|
||||
export const CustomCssSettingsContent = ({ form }: Props) => {
|
||||
export const CustomCssSettingsContent = ({ board }: Props) => {
|
||||
const t = useI18n();
|
||||
const customCssT = useScopedI18n("board.field.customCss");
|
||||
const { mutate: savePartialSettings, isPending } = useSavePartialSettingsMutation(board);
|
||||
const form = useForm({
|
||||
initialValues: {
|
||||
customCss: board.customCss ?? "",
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<SectionCard title={t("board.setting.section.customCss.title")}>
|
||||
<CustomCssInput {...form.getInputProps("customCss")} />
|
||||
<form
|
||||
onSubmit={form.onSubmit((values) => {
|
||||
savePartialSettings({
|
||||
id: board.id,
|
||||
...values,
|
||||
});
|
||||
})}
|
||||
>
|
||||
<Stack>
|
||||
<CustomCssInput {...form.getInputProps("customCss")} />
|
||||
|
||||
<Alert variant="light" color="cyan" title={customCssT("customClassesAlert.title")} icon={<IconInfoCircle />}>
|
||||
{customCssT("customClassesAlert.description")}
|
||||
</Alert>
|
||||
</SectionCard>
|
||||
<Alert variant="light" color="cyan" title={customCssT("customClassesAlert.title")} icon={<IconInfoCircle />}>
|
||||
{customCssT("customClassesAlert.description")}
|
||||
</Alert>
|
||||
|
||||
<Group justify="end">
|
||||
<Button type="submit" loading={isPending}>
|
||||
{t("common.action.saveChanges")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
interface CustomCssInputProps {
|
||||
value?: string;
|
||||
onChange: (value: string) => void;
|
||||
error?: React.ReactNode;
|
||||
}
|
||||
|
||||
const CustomCssInput = ({ value, onChange, error }: CustomCssInputProps) => {
|
||||
const CustomCssInput = ({ value, onChange }: CustomCssInputProps) => {
|
||||
const customCssT = useScopedI18n("board.field.customCss");
|
||||
|
||||
return (
|
||||
@@ -50,7 +70,6 @@ const CustomCssInput = ({ value, onChange, error }: CustomCssInputProps) => {
|
||||
htmlFor: "custom-css",
|
||||
}}
|
||||
description={customCssT("description")}
|
||||
error={error}
|
||||
inputWrapperOrder={["label", "description", "input", "error"]}
|
||||
>
|
||||
<div className={classes.codeEditorRoot}>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useCallback } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Button } from "@mantine/core";
|
||||
import { Button, Divider, Group, Stack, Text } from "@mantine/core";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { useRequiredBoard } from "@homarr/boards/context";
|
||||
@@ -10,7 +10,7 @@ import { useConfirmModal, useModalAction } from "@homarr/modals";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import { BoardRenameModal } from "~/components/board/modals/board-rename-modal";
|
||||
import { DangerZoneItem, DangerZoneRoot } from "~/components/manage/danger-zone";
|
||||
import classes from "./danger.module.css";
|
||||
|
||||
export const DangerZoneSettingsContent = ({ hideVisibility }: { hideVisibility: boolean }) => {
|
||||
const board = useRequiredBoard();
|
||||
@@ -82,36 +82,60 @@ export const DangerZoneSettingsContent = ({ hideVisibility }: { hideVisibility:
|
||||
}, [board.id, deleteBoard, router, t, openConfirmModal]);
|
||||
|
||||
return (
|
||||
<DangerZoneRoot>
|
||||
<DangerZoneItem
|
||||
<Stack gap="sm">
|
||||
<Divider />
|
||||
<DangerZoneRow
|
||||
label={t("section.dangerZone.action.rename.label")}
|
||||
description={t("section.dangerZone.action.rename.description")}
|
||||
action={
|
||||
<Button variant="subtle" color="red" onClick={onRenameClick}>
|
||||
{t("section.dangerZone.action.rename.button")}
|
||||
</Button>
|
||||
}
|
||||
buttonText={t("section.dangerZone.action.rename.button")}
|
||||
onClick={onRenameClick}
|
||||
/>
|
||||
{!hideVisibility && (
|
||||
<DangerZoneItem
|
||||
label={t("section.dangerZone.action.visibility.label")}
|
||||
description={t(`section.dangerZone.action.visibility.description.${visibility}`)}
|
||||
action={
|
||||
<Button variant="subtle" color="red" loading={isChangeVisibilityPending} onClick={onVisibilityClick}>
|
||||
{t(`section.dangerZone.action.visibility.button.${visibility}`)}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
{hideVisibility ? null : (
|
||||
<>
|
||||
<Divider />
|
||||
<DangerZoneRow
|
||||
label={t("section.dangerZone.action.visibility.label")}
|
||||
description={t(`section.dangerZone.action.visibility.description.${visibility}`)}
|
||||
buttonText={t(`section.dangerZone.action.visibility.button.${visibility}`)}
|
||||
onClick={onVisibilityClick}
|
||||
isPending={isChangeVisibilityPending}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<DangerZoneItem
|
||||
<Divider />
|
||||
<DangerZoneRow
|
||||
label={t("section.dangerZone.action.delete.label")}
|
||||
description={t("section.dangerZone.action.delete.description")}
|
||||
action={
|
||||
<Button variant="subtle" color="red" loading={isDeletePending} onClick={onDeleteClick}>
|
||||
{t("section.dangerZone.action.delete.button")}
|
||||
</Button>
|
||||
}
|
||||
buttonText={t("section.dangerZone.action.delete.button")}
|
||||
onClick={onDeleteClick}
|
||||
isPending={isDeletePending}
|
||||
/>
|
||||
</DangerZoneRoot>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
interface DangerZoneRowProps {
|
||||
label: string;
|
||||
description: string;
|
||||
buttonText: string;
|
||||
isPending?: boolean;
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
const DangerZoneRow = ({ label, description, buttonText, onClick, isPending }: DangerZoneRowProps) => {
|
||||
return (
|
||||
<Group justify="space-between" px="md" className={classes.dangerZoneGroup}>
|
||||
<Stack gap={0}>
|
||||
<Text fw="bold" size="sm">
|
||||
{label}
|
||||
</Text>
|
||||
<Text size="sm">{description}</Text>
|
||||
</Stack>
|
||||
<Group justify="end" w={{ base: "100%", xs: "auto" }}>
|
||||
<Button variant="subtle" color="red" loading={isPending} onClick={onClick}>
|
||||
{buttonText}
|
||||
</Button>
|
||||
</Group>
|
||||
</Group>
|
||||
);
|
||||
};
|
||||
@@ -1,66 +1,128 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { Grid, Loader, TextInput } from "@mantine/core";
|
||||
import { useEffect, useRef } from "react";
|
||||
import { Button, Grid, Group, Loader, Stack, TextInput } from "@mantine/core";
|
||||
import { useDebouncedValue, useDocumentTitle } from "@mantine/hooks";
|
||||
|
||||
import { useUpdateBoard } from "@homarr/boards/updater";
|
||||
import type { UseFormReturnType } from "@homarr/form";
|
||||
import { useZodForm } from "@homarr/form";
|
||||
import { IconPicker } from "@homarr/forms-collection";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import { boardSavePartialSettingsSchema } from "@homarr/validation/board";
|
||||
|
||||
import { SectionCard } from "~/components/manage/section-card";
|
||||
import { homarrLogoPath } from "~/components/layout/logo/homarr-logo";
|
||||
import { createMetaTitle } from "~/metadata";
|
||||
import type { Board } from "../../_types";
|
||||
import type { FormValues } from "./_settings-form";
|
||||
import { useSavePartialSettingsMutation } from "./_shared";
|
||||
|
||||
interface Props {
|
||||
board: Board;
|
||||
form: UseFormReturnType<FormValues>;
|
||||
}
|
||||
|
||||
export const GeneralSettingsContent = ({ board, form }: Props) => {
|
||||
export const GeneralSettingsContent = ({ board }: Props) => {
|
||||
const t = useI18n();
|
||||
const ref = useRef({
|
||||
pageTitle: board.pageTitle,
|
||||
logoImageUrl: board.logoImageUrl,
|
||||
});
|
||||
const { updateBoard } = useUpdateBoard();
|
||||
|
||||
const { mutate: savePartialSettings, isPending } = useSavePartialSettingsMutation(board);
|
||||
const form = useZodForm(
|
||||
boardSavePartialSettingsSchema
|
||||
.pick({
|
||||
pageTitle: true,
|
||||
logoImageUrl: true,
|
||||
metaTitle: true,
|
||||
faviconImageUrl: true,
|
||||
})
|
||||
.required(),
|
||||
{
|
||||
initialValues: {
|
||||
pageTitle: board.pageTitle ?? "",
|
||||
logoImageUrl: board.logoImageUrl ?? "",
|
||||
metaTitle: board.metaTitle ?? "",
|
||||
faviconImageUrl: board.faviconImageUrl ?? "",
|
||||
},
|
||||
onValuesChange({ pageTitle }) {
|
||||
updateBoard((previous) => ({
|
||||
...previous,
|
||||
pageTitle,
|
||||
}));
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
useLogoPreview(form.values.logoImageUrl);
|
||||
const metaTitleStatus = useMetaTitlePreview(form.values.metaTitle);
|
||||
|
||||
// Cleanup for not applied changes of the page title and logo image URL
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
updateBoard((previous) => ({
|
||||
...previous,
|
||||
pageTitle: ref.current.pageTitle,
|
||||
logoImageUrl: ref.current.logoImageUrl,
|
||||
}));
|
||||
};
|
||||
}, [updateBoard]);
|
||||
|
||||
return (
|
||||
<SectionCard title={t("board.setting.section.general.title")}>
|
||||
<Grid>
|
||||
<Grid.Col span={{ xs: 12, md: 6 }}>
|
||||
<TextInput
|
||||
label={t("board.field.pageTitle.label")}
|
||||
placeholder="Homarr"
|
||||
{...form.getInputProps("pageTitle")}
|
||||
/>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={{ xs: 12, md: 6 }}>
|
||||
<TextInput
|
||||
label={t("board.field.metaTitle.label")}
|
||||
placeholder={createMetaTitle(t("board.content.metaTitle", { boardName: board.name }))}
|
||||
rightSection={metaTitleStatus.isPending && <Loader size="xs" />}
|
||||
{...form.getInputProps("metaTitle")}
|
||||
/>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={{ xs: 12, md: 6 }}>
|
||||
<IconPicker
|
||||
{...form.getInputProps("logoImageUrl")}
|
||||
label={t("board.field.logoImageUrl.label")}
|
||||
placeholder="/logo/logo.png"
|
||||
withAsterisk={false}
|
||||
/>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={{ xs: 12, md: 6 }}>
|
||||
<IconPicker
|
||||
{...form.getInputProps("faviconImageUrl")}
|
||||
label={t("board.field.faviconImageUrl.label")}
|
||||
placeholder="/logo/logo.png"
|
||||
withAsterisk={false}
|
||||
/>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
</SectionCard>
|
||||
<form
|
||||
onSubmit={form.onSubmit((values) => {
|
||||
// Save the current values to the ref so that it does not reset if the form is submitted
|
||||
ref.current = {
|
||||
pageTitle: values.pageTitle,
|
||||
logoImageUrl: values.logoImageUrl,
|
||||
};
|
||||
updateFavicon(values.faviconImageUrl ?? homarrLogoPath);
|
||||
savePartialSettings({
|
||||
id: board.id,
|
||||
...values,
|
||||
});
|
||||
})}
|
||||
>
|
||||
<Stack>
|
||||
<Grid>
|
||||
<Grid.Col span={{ xs: 12, md: 6 }}>
|
||||
<TextInput
|
||||
label={t("board.field.pageTitle.label")}
|
||||
placeholder="Homarr"
|
||||
{...form.getInputProps("pageTitle")}
|
||||
/>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={{ xs: 12, md: 6 }}>
|
||||
<TextInput
|
||||
label={t("board.field.metaTitle.label")}
|
||||
placeholder={createMetaTitle(t("board.content.metaTitle", { boardName: board.name }))}
|
||||
rightSection={metaTitleStatus.isPending && <Loader size="xs" />}
|
||||
{...form.getInputProps("metaTitle")}
|
||||
/>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={{ xs: 12, md: 6 }}>
|
||||
<IconPicker
|
||||
{...form.getInputProps("logoImageUrl")}
|
||||
label={t("board.field.logoImageUrl.label")}
|
||||
placeholder="/logo/logo.png"
|
||||
withAsterisk={false}
|
||||
/>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={{ xs: 12, md: 6 }}>
|
||||
<IconPicker
|
||||
{...form.getInputProps("faviconImageUrl")}
|
||||
label={t("board.field.faviconImageUrl.label")}
|
||||
placeholder="/logo/logo.png"
|
||||
withAsterisk={false}
|
||||
/>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
<Group justify="end">
|
||||
<Button type="submit" loading={isPending}>
|
||||
{t("common.action.saveChanges")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -84,3 +146,14 @@ const useMetaTitlePreview = (title: string | null) => {
|
||||
isPending: (title ?? "") !== metaTitleDebounced,
|
||||
};
|
||||
};
|
||||
|
||||
// Previously we used the useFavicon hook from Mantine
|
||||
// However if the user tried to click on a link to a different page
|
||||
// it caused an error "Cannot read properties of null (reading 'removeChild')"
|
||||
// Probably because the head element was null (https://github.com/mantinedev/mantine/blob/b90d9b81031f45f5d15e75f25138ed6477f65bce/packages/%40mantine/hooks/src/use-favicon/use-favicon.ts#L21)
|
||||
// See https://github.com/homarr-labs/homarr/issues/4905
|
||||
const updateFavicon = (url: string) => {
|
||||
const link: HTMLLinkElement | null = document.querySelector('link[rel="icon"]');
|
||||
if (!link) return;
|
||||
link.href = url;
|
||||
};
|
||||
@@ -2,79 +2,114 @@
|
||||
|
||||
import { Button, Fieldset, Grid, Group, Input, NumberInput, Slider, Stack, Text, TextInput } from "@mantine/core";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { createId } from "@homarr/common";
|
||||
import type { UseFormReturnType } from "@homarr/form";
|
||||
import { useZodForm } from "@homarr/form";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import { boardSaveLayoutsSchema } from "@homarr/validation/board";
|
||||
|
||||
import { SectionCard } from "~/components/manage/section-card";
|
||||
import type { FormValues } from "./_settings-form";
|
||||
import type { Board } from "../../_types";
|
||||
|
||||
interface Props {
|
||||
form: UseFormReturnType<FormValues>;
|
||||
board: Board;
|
||||
}
|
||||
|
||||
export const LayoutSettingsContent = ({ form }: Props) => {
|
||||
export const LayoutSettingsContent = ({ board }: Props) => {
|
||||
const t = useI18n();
|
||||
const utils = clientApi.useUtils();
|
||||
const { mutate: saveLayouts, isPending } = clientApi.board.saveLayouts.useMutation({
|
||||
onSettled() {
|
||||
void utils.board.getBoardByName.invalidate({ name: board.name });
|
||||
void utils.board.getHomeBoard.invalidate();
|
||||
},
|
||||
});
|
||||
const form = useZodForm(boardSaveLayoutsSchema.omit({ id: true }).required(), {
|
||||
initialValues: {
|
||||
layouts: board.layouts,
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<SectionCard title={t("board.setting.section.layout.title")}>
|
||||
<Stack gap="sm">
|
||||
<Group justify="space-between" align="center">
|
||||
<Text fw={500}>{t("board.setting.section.layout.responsive.title")}</Text>
|
||||
<Button
|
||||
type="button"
|
||||
variant="subtle"
|
||||
onClick={() => {
|
||||
form.setValues((previous) => ({
|
||||
...previous,
|
||||
layouts: [...(previous.layouts ?? []), { id: createId(), name: "", columnCount: 10, breakpoint: 0 }],
|
||||
}));
|
||||
}}
|
||||
>
|
||||
{t("board.setting.section.layout.responsive.action.add")}
|
||||
<form
|
||||
onSubmit={form.onSubmit((values) => {
|
||||
saveLayouts({
|
||||
id: board.id,
|
||||
...values,
|
||||
});
|
||||
})}
|
||||
>
|
||||
<Stack>
|
||||
<Stack gap="sm">
|
||||
<Group justify="space-between" align="center">
|
||||
<Text fw={500}>{t("board.setting.section.layout.responsive.title")}</Text>
|
||||
<Button
|
||||
variant="subtle"
|
||||
onClick={() => {
|
||||
form.setValues({
|
||||
layouts: [
|
||||
...form.values.layouts,
|
||||
{
|
||||
id: createId(),
|
||||
name: "",
|
||||
columnCount: 10,
|
||||
breakpoint: 0,
|
||||
},
|
||||
],
|
||||
});
|
||||
}}
|
||||
>
|
||||
{t("board.setting.section.layout.responsive.action.add")}
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
{form.values.layouts.map((layout, index) => (
|
||||
<Fieldset key={layout.id} legend={layout.name} bg="transparent">
|
||||
<Grid>
|
||||
<Grid.Col span={{ sm: 12, md: 6 }}>
|
||||
<TextInput {...form.getInputProps(`layouts.${index}.name`)} label={t("layout.field.name.label")} />
|
||||
</Grid.Col>
|
||||
|
||||
<Grid.Col span={{ sm: 12, md: 6 }}>
|
||||
<Input.Wrapper label={t("layout.field.columnCount.label")}>
|
||||
<Slider mt="xs" min={1} max={24} step={1} {...form.getInputProps(`layouts.${index}.columnCount`)} />
|
||||
</Input.Wrapper>
|
||||
</Grid.Col>
|
||||
|
||||
<Grid.Col span={{ sm: 12, md: 6 }}>
|
||||
<NumberInput
|
||||
{...form.getInputProps(`layouts.${index}.breakpoint`)}
|
||||
label={t("layout.field.breakpoint.label")}
|
||||
description={t("layout.field.breakpoint.description")}
|
||||
/>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
{form.values.layouts.length >= 2 && (
|
||||
<Group justify="end">
|
||||
<Button
|
||||
variant="subtle"
|
||||
onClick={() => {
|
||||
form.setValues((previous) =>
|
||||
previous.layouts !== undefined && previous.layouts.length >= 2
|
||||
? {
|
||||
layouts: form.values.layouts.filter((filteredLayout) => filteredLayout.id !== layout.id),
|
||||
}
|
||||
: previous,
|
||||
);
|
||||
}}
|
||||
>
|
||||
{t("common.action.remove")}
|
||||
</Button>
|
||||
</Group>
|
||||
)}
|
||||
</Fieldset>
|
||||
))}
|
||||
</Stack>
|
||||
|
||||
<Group justify="end">
|
||||
<Button type="submit" loading={isPending}>
|
||||
{t("common.action.saveChanges")}
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
{form.values.layouts.map((layout, index) => (
|
||||
<Fieldset key={layout.id} legend={layout.name} bg="transparent">
|
||||
<Grid>
|
||||
<Grid.Col span={{ sm: 12, md: 6 }}>
|
||||
<TextInput {...form.getInputProps(`layouts.${index}.name`)} label={t("layout.field.name.label")} />
|
||||
</Grid.Col>
|
||||
|
||||
<Grid.Col span={{ sm: 12, md: 6 }}>
|
||||
<Input.Wrapper label={t("layout.field.columnCount.label")}>
|
||||
<Slider mt="xs" min={1} max={24} step={1} {...form.getInputProps(`layouts.${index}.columnCount`)} />
|
||||
</Input.Wrapper>
|
||||
</Grid.Col>
|
||||
|
||||
<Grid.Col span={{ sm: 12, md: 6 }}>
|
||||
<NumberInput
|
||||
{...form.getInputProps(`layouts.${index}.breakpoint`)}
|
||||
label={t("layout.field.breakpoint.label")}
|
||||
description={t("layout.field.breakpoint.description")}
|
||||
/>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
{form.values.layouts.length >= 2 && (
|
||||
<Group justify="end">
|
||||
<Button
|
||||
type="button"
|
||||
variant="subtle"
|
||||
onClick={() => {
|
||||
form.setValues((previous) => ({
|
||||
...previous,
|
||||
layouts: form.values.layouts.filter((filteredLayout) => filteredLayout.id !== layout.id),
|
||||
}));
|
||||
}}
|
||||
>
|
||||
{t("common.action.remove")}
|
||||
</Button>
|
||||
</Group>
|
||||
)}
|
||||
</Fieldset>
|
||||
))}
|
||||
</Stack>
|
||||
</SectionCard>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
@@ -1,219 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
import { Button, Stack } from "@mantine/core";
|
||||
import type { z } from "zod/v4";
|
||||
|
||||
import type { RouterOutputs } from "@homarr/api";
|
||||
import { useUpdateBoard } from "@homarr/boards/updater";
|
||||
import { revalidatePathActionAsync } from "@homarr/common/client";
|
||||
import { env } from "@homarr/common/env";
|
||||
import { useZodForm } from "@homarr/form";
|
||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||
import { useI18n, useScopedI18n } from "@homarr/translation/client";
|
||||
import { boardSaveLayoutsSchema, boardSavePartialSettingsSchema } from "@homarr/validation/board";
|
||||
|
||||
import { homarrLogoPath } from "~/components/layout/logo/homarr-logo";
|
||||
import { SectionCard } from "~/components/manage/section-card";
|
||||
import { UnsavedChangesBar } from "~/components/manage/unsaved-changes-bar";
|
||||
|
||||
import type { Board } from "../../_types";
|
||||
import { ColorSettingsContent } from "./_appereance";
|
||||
import { BackgroundSettingsContent } from "./_background";
|
||||
import { BehaviorSettingsContent } from "./_behavior";
|
||||
import { BoardAccessSettings } from "./_board-access";
|
||||
import { CustomCssSettingsContent } from "./_customCss";
|
||||
import { DangerZoneSettingsContent } from "./_danger";
|
||||
import { GeneralSettingsContent } from "./_general";
|
||||
import { LayoutSettingsContent } from "./_layout";
|
||||
import { useSaveLayoutsMutation, useSavePartialSettingsMutation } from "./_shared";
|
||||
|
||||
const boardSettingsFormSchema = boardSavePartialSettingsSchema
|
||||
.extend({
|
||||
layouts: boardSaveLayoutsSchema.shape.layouts,
|
||||
})
|
||||
.required();
|
||||
|
||||
export type FormValues = z.infer<typeof boardSettingsFormSchema>;
|
||||
|
||||
const PARTIAL_FORM_KEYS = [
|
||||
"pageTitle",
|
||||
"metaTitle",
|
||||
"logoImageUrl",
|
||||
"faviconImageUrl",
|
||||
"backgroundImageUrl",
|
||||
"backgroundImageAttachment",
|
||||
"backgroundImageRepeat",
|
||||
"backgroundImageSize",
|
||||
"primaryColor",
|
||||
"secondaryColor",
|
||||
"opacity",
|
||||
"iconColor",
|
||||
"itemRadius",
|
||||
"customCss",
|
||||
"disableStatus",
|
||||
] as const;
|
||||
|
||||
const buildInitialValues = (board: Board): FormValues => ({
|
||||
pageTitle: board.pageTitle ?? "",
|
||||
metaTitle: board.metaTitle ?? "",
|
||||
logoImageUrl: board.logoImageUrl ?? "",
|
||||
faviconImageUrl: board.faviconImageUrl ?? "",
|
||||
backgroundImageUrl: board.backgroundImageUrl ?? "",
|
||||
backgroundImageAttachment: board.backgroundImageAttachment,
|
||||
backgroundImageRepeat: board.backgroundImageRepeat,
|
||||
backgroundImageSize: board.backgroundImageSize,
|
||||
primaryColor: board.primaryColor,
|
||||
secondaryColor: board.secondaryColor,
|
||||
opacity: board.opacity,
|
||||
iconColor: board.iconColor ?? "",
|
||||
itemRadius: board.itemRadius,
|
||||
customCss: board.customCss ?? "",
|
||||
disableStatus: board.disableStatus,
|
||||
layouts: board.layouts,
|
||||
});
|
||||
|
||||
interface BoardSettingsFormProps {
|
||||
board: Board;
|
||||
permissions: RouterOutputs["board"]["getBoardPermissions"];
|
||||
hasFullAccess: boolean;
|
||||
hideVisibility: boolean;
|
||||
}
|
||||
|
||||
export const BoardSettingsForm = ({ board, permissions, hasFullAccess, hideVisibility }: BoardSettingsFormProps) => {
|
||||
const t = useI18n();
|
||||
const tSection = useScopedI18n("board.setting.section");
|
||||
const { updateBoard } = useUpdateBoard();
|
||||
const savePartialSettings = useSavePartialSettingsMutation(board);
|
||||
const saveLayouts = useSaveLayoutsMutation(board);
|
||||
|
||||
const form = useZodForm(boardSettingsFormSchema, {
|
||||
initialValues: buildInitialValues(board),
|
||||
onValuesChange({ pageTitle }) {
|
||||
updateBoard((previous) => ({
|
||||
...previous,
|
||||
pageTitle,
|
||||
}));
|
||||
},
|
||||
});
|
||||
|
||||
const initialValuesRef = useRef(buildInitialValues(board));
|
||||
const lastSavedRef = useRef({ pageTitle: board.pageTitle, logoImageUrl: board.logoImageUrl });
|
||||
|
||||
const isDirtyRef = useRef(false);
|
||||
isDirtyRef.current = form.isDirty();
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
updateBoard((previous) => ({
|
||||
...previous,
|
||||
pageTitle: lastSavedRef.current.pageTitle,
|
||||
logoImageUrl: lastSavedRef.current.logoImageUrl,
|
||||
}));
|
||||
};
|
||||
}, [updateBoard]);
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (event: BeforeUnloadEvent) => {
|
||||
if (env.NODE_ENV === "development") return;
|
||||
if (isDirtyRef.current) event.preventDefault();
|
||||
};
|
||||
window.addEventListener("beforeunload", handler);
|
||||
return () => window.removeEventListener("beforeunload", handler);
|
||||
}, []);
|
||||
|
||||
const handleSubmitAsync = async (values: FormValues) => {
|
||||
const defaults = initialValuesRef.current;
|
||||
const changed = <TKey extends keyof FormValues>(...fields: TKey[]) =>
|
||||
fields.some((field) => values[field] !== defaults[field]);
|
||||
|
||||
const { layouts, ...partialSettings } = values;
|
||||
|
||||
const saveActions: { when: boolean; action: () => Promise<unknown> }[] = [
|
||||
{
|
||||
when: changed(...PARTIAL_FORM_KEYS),
|
||||
action: () =>
|
||||
savePartialSettings.mutateAsync({ id: board.id, ...partialSettings }).then(() => {
|
||||
updateFavicon(values.faviconImageUrl ?? homarrLogoPath);
|
||||
}),
|
||||
},
|
||||
{
|
||||
when: changed("layouts"),
|
||||
action: () => saveLayouts.mutateAsync({ id: board.id, layouts }),
|
||||
},
|
||||
];
|
||||
|
||||
const promises = saveActions.filter((saveAction) => saveAction.when).map((saveAction) => saveAction.action());
|
||||
if (promises.length === 0) return;
|
||||
|
||||
try {
|
||||
await Promise.all(promises);
|
||||
lastSavedRef.current = { pageTitle: values.pageTitle, logoImageUrl: values.logoImageUrl };
|
||||
initialValuesRef.current = values;
|
||||
form.setInitialValues(values);
|
||||
form.resetDirty();
|
||||
await revalidatePathActionAsync(`/boards/${board.name}/settings`);
|
||||
showSuccessNotification({
|
||||
title: t("common.notification.update.success"),
|
||||
message: t("common.notification.update.success"),
|
||||
});
|
||||
} catch {
|
||||
showErrorNotification({
|
||||
title: t("common.notification.update.error"),
|
||||
message: t("common.notification.update.error"),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleDiscard = () => {
|
||||
form.setValues(initialValuesRef.current);
|
||||
form.resetDirty();
|
||||
updateBoard((previous) => ({
|
||||
...previous,
|
||||
pageTitle: lastSavedRef.current.pageTitle,
|
||||
logoImageUrl: lastSavedRef.current.logoImageUrl,
|
||||
}));
|
||||
};
|
||||
|
||||
const isPending = savePartialSettings.isPending || saveLayouts.isPending;
|
||||
|
||||
return (
|
||||
<form onSubmit={form.onSubmit((values) => void handleSubmitAsync(values))}>
|
||||
<Stack gap="xl">
|
||||
<GeneralSettingsContent board={board} form={form} />
|
||||
<LayoutSettingsContent form={form} />
|
||||
<BackgroundSettingsContent form={form} />
|
||||
<ColorSettingsContent form={form} />
|
||||
<CustomCssSettingsContent form={form} />
|
||||
<BehaviorSettingsContent form={form} />
|
||||
|
||||
{hasFullAccess && (
|
||||
<SectionCard title={tSection("access.title")}>
|
||||
<BoardAccessSettings board={board} initialPermissions={permissions} />
|
||||
</SectionCard>
|
||||
)}
|
||||
|
||||
{hasFullAccess && <DangerZoneSettingsContent hideVisibility={hideVisibility} />}
|
||||
|
||||
{form.isDirty() && (
|
||||
<UnsavedChangesBar>
|
||||
<Button type="button" disabled={isPending} variant="default" onClick={handleDiscard}>
|
||||
{t("common.action.discard")}
|
||||
</Button>
|
||||
<Button loading={isPending} type="submit" disabled={!form.isValid()}>
|
||||
{t("common.action.saveChanges")}
|
||||
</Button>
|
||||
</UnsavedChangesBar>
|
||||
)}
|
||||
</Stack>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
// Previously part of the general settings section; applied on the unified save
|
||||
// https://github.com/homarr-labs/homarr/issues/4905
|
||||
const updateFavicon = (url: string) => {
|
||||
const link = document.querySelector<HTMLLinkElement>('link[rel="icon"]');
|
||||
if (!link) return;
|
||||
link.href = url;
|
||||
};
|
||||
@@ -11,13 +11,3 @@ export const useSavePartialSettingsMutation = (board: Board) => {
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const useSaveLayoutsMutation = (board: Board) => {
|
||||
const utils = clientApi.useUtils();
|
||||
return clientApi.board.saveLayouts.useMutation({
|
||||
onSettled() {
|
||||
void utils.board.getBoardByName.invalidate({ name: board.name });
|
||||
void utils.board.getHomeBoard.invalidate();
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,5 @@
|
||||
@media (min-width: 36em) {
|
||||
.dangerZoneGroup {
|
||||
--group-wrap: nowrap !important;
|
||||
}
|
||||
}
|
||||
@@ -1,20 +1,44 @@
|
||||
import type { PropsWithChildren } from "react";
|
||||
import { notFound } from "next/navigation";
|
||||
import { Container, Stack, Title } from "@mantine/core";
|
||||
import { AccordionControl, AccordionItem, AccordionPanel, Container, Stack, Text, Title } from "@mantine/core";
|
||||
import {
|
||||
IconAlertTriangle,
|
||||
IconBrush,
|
||||
IconClick,
|
||||
IconFileTypeCss,
|
||||
IconLayout,
|
||||
IconPhoto,
|
||||
IconSettings,
|
||||
IconUser,
|
||||
} from "@tabler/icons-react";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
|
||||
import { api } from "@homarr/api/server";
|
||||
import { capitalize } from "@homarr/common";
|
||||
import { db } from "@homarr/db";
|
||||
import { getServerSettingByKeyAsync } from "@homarr/db/queries";
|
||||
import type { TranslationObject } from "@homarr/translation";
|
||||
import { getScopedI18n } from "@homarr/translation/server";
|
||||
import type { TablerIcon } from "@homarr/ui";
|
||||
|
||||
import { getBoardPermissionsAsync } from "~/components/board/permissions/server";
|
||||
import { BoardSettingsForm } from "./_settings-form";
|
||||
import { ActiveTabAccordion } from "../../../../../components/active-tab-accordion";
|
||||
import { ColorSettingsContent } from "./_appereance";
|
||||
import { BackgroundSettingsContent } from "./_background";
|
||||
import { BehaviorSettingsContent } from "./_behavior";
|
||||
import { BoardAccessSettings } from "./_board-access";
|
||||
import { CustomCssSettingsContent } from "./_customCss";
|
||||
import { DangerZoneSettingsContent } from "./_danger";
|
||||
import { GeneralSettingsContent } from "./_general";
|
||||
import { LayoutSettingsContent } from "./_layout";
|
||||
|
||||
interface Props {
|
||||
params: Promise<{
|
||||
name: string;
|
||||
}>;
|
||||
searchParams: Promise<{
|
||||
tab?: keyof TranslationObject["board"]["setting"]["section"];
|
||||
}>;
|
||||
}
|
||||
|
||||
const getBoardAndPermissionsAsync = async (params: Awaited<Props["params"]>) => {
|
||||
@@ -33,7 +57,11 @@ const getBoardAndPermissionsAsync = async (params: Awaited<Props["params"]>) =>
|
||||
} catch (error) {
|
||||
// Ignore not found errors and redirect to 404
|
||||
// error is already logged in _layout-creator.tsx
|
||||
if (error instanceof TRPCError && (error.code === "NOT_FOUND" || error.code === "BAD_REQUEST")) {
|
||||
if (error instanceof TRPCError && error.code === "NOT_FOUND") {
|
||||
notFound();
|
||||
}
|
||||
|
||||
if (error instanceof TRPCError && error.code === "BAD_REQUEST") {
|
||||
notFound();
|
||||
}
|
||||
|
||||
@@ -42,6 +70,7 @@ const getBoardAndPermissionsAsync = async (params: Awaited<Props["params"]>) =>
|
||||
};
|
||||
|
||||
export default async function BoardSettingsPage(props: Props) {
|
||||
const searchParams = await props.searchParams;
|
||||
const params = await props.params;
|
||||
const { board, permissions } = await getBoardAndPermissionsAsync(params);
|
||||
const boardSettings = await getServerSettingByKeyAsync(db, "board");
|
||||
@@ -56,13 +85,76 @@ export default async function BoardSettingsPage(props: Props) {
|
||||
<Container>
|
||||
<Stack>
|
||||
<Title>{t("title", { boardName: capitalize(board.name) })}</Title>
|
||||
<BoardSettingsForm
|
||||
board={board}
|
||||
permissions={permissions}
|
||||
hasFullAccess={hasFullAccess}
|
||||
hideVisibility={boardSettings.homeBoardId === board.id || boardSettings.mobileHomeBoardId === board.id}
|
||||
/>
|
||||
<ActiveTabAccordion variant="separated" defaultValue={searchParams.tab ?? "general"}>
|
||||
<AccordionItemFor value="general" icon={IconSettings}>
|
||||
<GeneralSettingsContent board={board} />
|
||||
</AccordionItemFor>
|
||||
<AccordionItemFor value="layout" icon={IconLayout}>
|
||||
<LayoutSettingsContent board={board} />
|
||||
</AccordionItemFor>
|
||||
<AccordionItemFor value="background" icon={IconPhoto}>
|
||||
<BackgroundSettingsContent board={board} />
|
||||
</AccordionItemFor>
|
||||
<AccordionItemFor value="appearance" icon={IconBrush}>
|
||||
<ColorSettingsContent board={board} />
|
||||
</AccordionItemFor>
|
||||
<AccordionItemFor value="customCss" icon={IconFileTypeCss}>
|
||||
<CustomCssSettingsContent board={board} />
|
||||
</AccordionItemFor>
|
||||
<AccordionItemFor value="behavior" icon={IconClick}>
|
||||
<BehaviorSettingsContent board={board} />
|
||||
</AccordionItemFor>
|
||||
{hasFullAccess && (
|
||||
<>
|
||||
<AccordionItemFor value="access" icon={IconUser}>
|
||||
<BoardAccessSettings board={board} initialPermissions={permissions} />
|
||||
</AccordionItemFor>
|
||||
<AccordionItemFor value="dangerZone" icon={IconAlertTriangle} danger noPadding>
|
||||
<DangerZoneSettingsContent
|
||||
hideVisibility={
|
||||
boardSettings.homeBoardId === board.id || boardSettings.mobileHomeBoardId === board.id
|
||||
}
|
||||
/>
|
||||
</AccordionItemFor>
|
||||
</>
|
||||
)}
|
||||
</ActiveTabAccordion>
|
||||
</Stack>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
type AccordionItemForProps = PropsWithChildren<{
|
||||
value: keyof TranslationObject["board"]["setting"]["section"];
|
||||
icon: TablerIcon;
|
||||
danger?: boolean;
|
||||
noPadding?: boolean;
|
||||
}>;
|
||||
|
||||
const AccordionItemFor = async ({ value, children, icon: Icon, danger, noPadding }: AccordionItemForProps) => {
|
||||
const t = await getScopedI18n("board.setting.section");
|
||||
return (
|
||||
<AccordionItem
|
||||
value={value}
|
||||
styles={
|
||||
danger
|
||||
? {
|
||||
item: {
|
||||
"--__item-border-color": "rgba(248,81,73,0.4)",
|
||||
borderWidth: 4,
|
||||
},
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<AccordionControl icon={<Icon />}>
|
||||
<Text fw="bold" size="lg">
|
||||
{t(`${value}.title`)}
|
||||
</Text>
|
||||
</AccordionControl>
|
||||
<AccordionPanel styles={noPadding ? { content: { paddingRight: 0, paddingLeft: 0 } } : undefined}>
|
||||
{children}
|
||||
</AccordionPanel>
|
||||
</AccordionItem>
|
||||
);
|
||||
};
|
||||
@@ -1,22 +1,53 @@
|
||||
"use client";
|
||||
|
||||
import type { UseFormReturnType } from "@homarr/form";
|
||||
import React from "react";
|
||||
import { Card, LoadingOverlay, Stack, Title } from "@mantine/core";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { revalidatePathActionAsync } from "@homarr/common/client";
|
||||
import { useForm } from "@homarr/form";
|
||||
import type { defaultServerSettings } from "@homarr/server-settings";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import { SectionCard } from "~/components/manage/section-card";
|
||||
import type { FormValues } from "./settings-form";
|
||||
import { SwitchSetting } from "./setting-switch";
|
||||
import { SwitchSetting } from "~/app/[locale]/manage/settings/_components/setting-switch";
|
||||
|
||||
interface AnalyticsSettingsProps {
|
||||
form: UseFormReturnType<FormValues>;
|
||||
initialData: typeof defaultServerSettings.analytics;
|
||||
}
|
||||
|
||||
export const AnalyticsSettings = ({ form }: AnalyticsSettingsProps) => {
|
||||
export const AnalyticsSettings = ({ initialData }: AnalyticsSettingsProps) => {
|
||||
const t = useScopedI18n("management.page.settings.section.analytics");
|
||||
const { instanceId: _, ...formDefaults } = initialData;
|
||||
const form = useForm({
|
||||
initialValues: formDefaults,
|
||||
onValuesChange: (updatedValues, _prev) => {
|
||||
if (!form.isValid()) {
|
||||
return;
|
||||
}
|
||||
|
||||
void mutateAsync({
|
||||
settingsKey: "analytics",
|
||||
value: updatedValues,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const { mutateAsync, isPending } = clientApi.serverSettings.saveSettings.useMutation({
|
||||
onSettled: async () => {
|
||||
await revalidatePathActionAsync("/manage/settings");
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<SectionCard title={t("title")}>
|
||||
<SwitchSetting form={form} formKey="enableGeneral" title={t("general.title")} text={t("general.text")} />
|
||||
</SectionCard>
|
||||
<>
|
||||
<Title order={2}>{t("title")}</Title>
|
||||
|
||||
<Card pos="relative">
|
||||
<LoadingOverlay visible={isPending} />
|
||||
<Stack>
|
||||
<SwitchSetting form={form} formKey="enableGeneral" title={t("general.title")} text={t("general.text")} />
|
||||
</Stack>
|
||||
</Card>
|
||||
</>
|
||||
);
|
||||
};
|
||||
+20
-21
@@ -5,32 +5,31 @@ import { IconDeviceDesktop, IconMoon, IconSun } from "@tabler/icons-react";
|
||||
|
||||
import type { ColorScheme } from "@homarr/definitions";
|
||||
import { colorSchemes } from "@homarr/definitions";
|
||||
import type { UseFormReturnType } from "@homarr/form";
|
||||
import type { ServerSettings } from "@homarr/server-settings";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
import { SelectWithCustomItems } from "@homarr/ui";
|
||||
|
||||
import { SectionCard } from "~/components/manage/section-card";
|
||||
import type { FormValues } from "./settings-form";
|
||||
import { CommonSettingsForm } from "./common-form";
|
||||
|
||||
interface AppearanceSettingsFormProps {
|
||||
form: UseFormReturnType<FormValues>;
|
||||
}
|
||||
|
||||
export const AppearanceSettingsForm = ({ form }: AppearanceSettingsFormProps) => {
|
||||
const tAppearance = useScopedI18n("management.page.settings.section.appearance");
|
||||
export const AppearanceSettingsForm = ({ defaultValues }: { defaultValues: ServerSettings["appearance"] }) => {
|
||||
const tApperance = useScopedI18n("management.page.settings.section.appearance");
|
||||
|
||||
return (
|
||||
<SectionCard title={tAppearance("title")}>
|
||||
<SelectWithCustomItems
|
||||
label={tAppearance("defaultColorScheme.label")}
|
||||
data={colorSchemes.map((scheme) => ({
|
||||
value: scheme,
|
||||
label: tAppearance(`defaultColorScheme.options.${scheme}`),
|
||||
}))}
|
||||
{...form.getInputProps("defaultColorScheme")}
|
||||
SelectOption={AppearanceCustomOption}
|
||||
/>
|
||||
</SectionCard>
|
||||
<CommonSettingsForm settingKey="appearance" defaultValues={defaultValues}>
|
||||
{(form) => (
|
||||
<>
|
||||
<SelectWithCustomItems
|
||||
label={tApperance("defaultColorScheme.label")}
|
||||
data={colorSchemes.map((scheme) => ({
|
||||
value: scheme,
|
||||
label: tApperance(`defaultColorScheme.options.${scheme}`),
|
||||
}))}
|
||||
{...form.getInputProps("defaultColorScheme")}
|
||||
SelectOption={ApperanceCustomOption}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</CommonSettingsForm>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -40,7 +39,7 @@ const appearanceIcons = {
|
||||
dark: IconMoon,
|
||||
};
|
||||
|
||||
const AppearanceCustomOption = ({ value, label }: { value: ColorScheme; label: string }) => {
|
||||
const ApperanceCustomOption = ({ value, label }: { value: ColorScheme; label: string }) => {
|
||||
const Icon = appearanceIcons[value];
|
||||
|
||||
return (
|
||||
|
||||
@@ -3,50 +3,49 @@
|
||||
import { Switch, Text } from "@mantine/core";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import type { UseFormReturnType } from "@homarr/form";
|
||||
import type { ServerSettings } from "@homarr/server-settings";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import { BoardSelect } from "~/components/board/board-select";
|
||||
import { SectionCard } from "~/components/manage/section-card";
|
||||
import type { FormValues } from "./settings-form";
|
||||
import { CommonSettingsForm } from "./common-form";
|
||||
|
||||
interface BoardSettingsFormProps {
|
||||
form: UseFormReturnType<FormValues>;
|
||||
}
|
||||
|
||||
export const BoardSettingsForm = ({ form }: BoardSettingsFormProps) => {
|
||||
export const BoardSettingsForm = ({ defaultValues }: { defaultValues: ServerSettings["board"] }) => {
|
||||
const tBoard = useScopedI18n("management.page.settings.section.board");
|
||||
const [selectableBoards] = clientApi.board.getPublicBoards.useSuspenseQuery();
|
||||
|
||||
return (
|
||||
<SectionCard title={tBoard("title")}>
|
||||
<BoardSelect
|
||||
label={tBoard("homeBoard.label")}
|
||||
description={tBoard("homeBoard.description")}
|
||||
clearable
|
||||
boards={selectableBoards}
|
||||
{...form.getInputProps("homeBoardId")}
|
||||
/>
|
||||
<CommonSettingsForm settingKey="board" defaultValues={defaultValues}>
|
||||
{(form) => (
|
||||
<>
|
||||
<BoardSelect
|
||||
label={tBoard("homeBoard.label")}
|
||||
description={tBoard("homeBoard.description")}
|
||||
clearable
|
||||
boards={selectableBoards}
|
||||
{...form.getInputProps("homeBoardId")}
|
||||
/>
|
||||
|
||||
<BoardSelect
|
||||
label={tBoard("homeBoard.mobileLabel")}
|
||||
description={tBoard("homeBoard.description")}
|
||||
clearable
|
||||
boards={selectableBoards}
|
||||
{...form.getInputProps("mobileHomeBoardId")}
|
||||
/>
|
||||
<BoardSelect
|
||||
label={tBoard("homeBoard.mobileLabel")}
|
||||
description={tBoard("homeBoard.description")}
|
||||
clearable
|
||||
boards={selectableBoards}
|
||||
{...form.getInputProps("mobileHomeBoardId")}
|
||||
/>
|
||||
|
||||
<Text fw={500}>{tBoard("status.title")}</Text>
|
||||
<Switch
|
||||
{...form.getInputProps("enableStatusByDefault", { type: "checkbox" })}
|
||||
label={tBoard("status.enableStatusByDefault.label")}
|
||||
description={tBoard("status.enableStatusByDefault.description")}
|
||||
/>
|
||||
<Switch
|
||||
{...form.getInputProps("forceDisableStatus", { type: "checkbox" })}
|
||||
label={tBoard("status.forceDisableStatus.label")}
|
||||
description={tBoard("status.forceDisableStatus.description")}
|
||||
/>
|
||||
</SectionCard>
|
||||
<Text fw={500}>{tBoard("status.title")}</Text>
|
||||
<Switch
|
||||
{...form.getInputProps("enableStatusByDefault", { type: "checkbox" })}
|
||||
label={tBoard("status.enableStatusByDefault.label")}
|
||||
description={tBoard("status.enableStatusByDefault.description")}
|
||||
/>
|
||||
<Switch
|
||||
{...form.getInputProps("forceDisableStatus", { type: "checkbox" })}
|
||||
label={tBoard("status.forceDisableStatus.label")}
|
||||
description={tBoard("status.forceDisableStatus.description")}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</CommonSettingsForm>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,57 @@
|
||||
"use client";
|
||||
|
||||
import { Button, Group, Stack } from "@mantine/core";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { useForm } from "@homarr/form";
|
||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||
import type { ServerSettings } from "@homarr/server-settings";
|
||||
import { useI18n, useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
export const CommonSettingsForm = <TKey extends keyof ServerSettings>({
|
||||
settingKey,
|
||||
defaultValues,
|
||||
children,
|
||||
}: {
|
||||
settingKey: TKey;
|
||||
defaultValues: ServerSettings[TKey];
|
||||
children: (form: ReturnType<typeof useForm<ServerSettings[TKey]>>) => React.ReactNode;
|
||||
}) => {
|
||||
const t = useI18n();
|
||||
const tSettings = useScopedI18n("management.page.settings");
|
||||
const { mutateAsync, isPending } = clientApi.serverSettings.saveSettings.useMutation({
|
||||
onSuccess() {
|
||||
showSuccessNotification({
|
||||
message: tSettings("notification.success.message"),
|
||||
});
|
||||
},
|
||||
onError() {
|
||||
showErrorNotification({
|
||||
message: tSettings("notification.error.message"),
|
||||
});
|
||||
},
|
||||
});
|
||||
const form = useForm({
|
||||
initialValues: defaultValues,
|
||||
});
|
||||
|
||||
const handleSubmitAsync = async (values: ServerSettings[TKey]) => {
|
||||
await mutateAsync({
|
||||
settingsKey: settingKey,
|
||||
value: values,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={form.onSubmit((values) => void handleSubmitAsync(values))}>
|
||||
<Stack gap="sm">
|
||||
{children(form)}
|
||||
<Group justify="end">
|
||||
<Button type="submit" loading={isPending}>
|
||||
{t("common.action.save")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
+54
-21
@@ -1,35 +1,68 @@
|
||||
"use client";
|
||||
|
||||
import { Text } from "@mantine/core";
|
||||
import React from "react";
|
||||
import { Card, LoadingOverlay, Stack, Text, Title } from "@mantine/core";
|
||||
|
||||
import type { UseFormReturnType } from "@homarr/form";
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { revalidatePathActionAsync } from "@homarr/common/client";
|
||||
import { useForm } from "@homarr/form";
|
||||
import type { defaultServerSettings } from "@homarr/server-settings";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import { SectionCard } from "~/components/manage/section-card";
|
||||
import type { FormValues } from "./settings-form";
|
||||
import { SwitchSetting } from "./setting-switch";
|
||||
import { SwitchSetting } from "~/app/[locale]/manage/settings/_components/setting-switch";
|
||||
|
||||
interface CrawlingAndIndexingSettingsProps {
|
||||
form: UseFormReturnType<FormValues>;
|
||||
initialData: typeof defaultServerSettings.crawlingAndIndexing;
|
||||
}
|
||||
|
||||
export const CrawlingAndIndexingSettings = ({ form }: CrawlingAndIndexingSettingsProps) => {
|
||||
export const CrawlingAndIndexingSettings = ({ initialData }: CrawlingAndIndexingSettingsProps) => {
|
||||
const t = useScopedI18n("management.page.settings.section.crawlingAndIndexing");
|
||||
const form = useForm({
|
||||
initialValues: initialData,
|
||||
onValuesChange: (updatedValues, _) => {
|
||||
if (!form.isValid()) {
|
||||
return;
|
||||
}
|
||||
|
||||
void mutateAsync({
|
||||
settingsKey: "crawlingAndIndexing",
|
||||
value: updatedValues,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const { mutateAsync, isPending } = clientApi.serverSettings.saveSettings.useMutation({
|
||||
onSettled: async () => {
|
||||
await revalidatePathActionAsync("/manage/settings");
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<SectionCard title={t("title")}>
|
||||
<Text c="dimmed" size="sm">
|
||||
{t("warning")}
|
||||
</Text>
|
||||
<SwitchSetting form={form} formKey="noIndex" title={t("noIndex.title")} text={t("noIndex.text")} />
|
||||
<SwitchSetting form={form} formKey="noFollow" title={t("noFollow.title")} text={t("noFollow.text")} />
|
||||
<SwitchSetting form={form} formKey="noTranslate" title={t("noTranslate.title")} text={t("noTranslate.text")} />
|
||||
<SwitchSetting
|
||||
form={form}
|
||||
formKey="noSiteLinksSearchBox"
|
||||
title={t("noSiteLinksSearchBox.title")}
|
||||
text={t("noSiteLinksSearchBox.text")}
|
||||
/>
|
||||
</SectionCard>
|
||||
<>
|
||||
<Title order={2}>{t("title")}</Title>
|
||||
|
||||
<Card pos="relative">
|
||||
<Text c={"dimmed"} mb={"lg"}>
|
||||
{t("warning")}
|
||||
</Text>
|
||||
<LoadingOverlay visible={isPending} />
|
||||
<Stack>
|
||||
<SwitchSetting form={form} formKey="noIndex" title={t("noIndex.title")} text={t("noIndex.text")} />
|
||||
<SwitchSetting form={form} formKey="noFollow" title={t("noFollow.title")} text={t("noFollow.text")} />
|
||||
<SwitchSetting
|
||||
form={form}
|
||||
formKey="noTranslate"
|
||||
title={t("noTranslate.title")}
|
||||
text={t("noTranslate.text")}
|
||||
/>
|
||||
<SwitchSetting
|
||||
form={form}
|
||||
formKey="noSiteLinksSearchBox"
|
||||
title={t("noSiteLinksSearchBox.title")}
|
||||
text={t("noSiteLinksSearchBox.text")}
|
||||
/>
|
||||
</Stack>
|
||||
</Card>
|
||||
</>
|
||||
);
|
||||
};
|
||||
+14
-16
@@ -1,28 +1,26 @@
|
||||
"use client";
|
||||
|
||||
import type { UseFormReturnType } from "@homarr/form";
|
||||
import type { ServerSettings } from "@homarr/server-settings";
|
||||
import type { SupportedLanguage } from "@homarr/translation";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import { LanguageCombobox } from "~/components/language/language-combobox";
|
||||
import { SectionCard } from "~/components/manage/section-card";
|
||||
import type { FormValues } from "./settings-form";
|
||||
import { CommonSettingsForm } from "./common-form";
|
||||
|
||||
interface CultureSettingsFormProps {
|
||||
form: UseFormReturnType<FormValues>;
|
||||
}
|
||||
|
||||
export const CultureSettingsForm = ({ form }: CultureSettingsFormProps) => {
|
||||
export const CultureSettingsForm = ({ defaultValues }: { defaultValues: ServerSettings["culture"] }) => {
|
||||
const tCulture = useScopedI18n("management.page.settings.section.culture");
|
||||
const localeInputProps = form.getInputProps("defaultLocale");
|
||||
|
||||
return (
|
||||
<SectionCard title={tCulture("title")}>
|
||||
<LanguageCombobox
|
||||
label={tCulture("defaultLocale.label")}
|
||||
{...localeInputProps}
|
||||
value={localeInputProps.value as SupportedLanguage}
|
||||
/>
|
||||
</SectionCard>
|
||||
<CommonSettingsForm settingKey="culture" defaultValues={defaultValues}>
|
||||
{(form) => (
|
||||
<>
|
||||
<LanguageCombobox
|
||||
label={tCulture("defaultLocale.label")}
|
||||
value={form.getInputProps("defaultLocale").value as SupportedLanguage}
|
||||
{...form.getInputProps("defaultLocale")}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</CommonSettingsForm>
|
||||
);
|
||||
};
|
||||
@@ -3,28 +3,27 @@
|
||||
import { Select } from "@mantine/core";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import type { UseFormReturnType } from "@homarr/form";
|
||||
import type { ServerSettings } from "@homarr/server-settings";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import { SectionCard } from "~/components/manage/section-card";
|
||||
import type { FormValues } from "./settings-form";
|
||||
import { CommonSettingsForm } from "./common-form";
|
||||
|
||||
interface SearchSettingsFormProps {
|
||||
form: UseFormReturnType<FormValues>;
|
||||
}
|
||||
|
||||
export const SearchSettingsForm = ({ form }: SearchSettingsFormProps) => {
|
||||
export const SearchSettingsForm = ({ defaultValues }: { defaultValues: ServerSettings["search"] }) => {
|
||||
const tSearch = useScopedI18n("management.page.settings.section.search");
|
||||
const [selectableSearchEngines] = clientApi.searchEngine.getSelectable.useSuspenseQuery({ withIntegrations: false });
|
||||
|
||||
return (
|
||||
<SectionCard title={tSearch("title")}>
|
||||
<Select
|
||||
label={tSearch("defaultSearchEngine.label")}
|
||||
description={tSearch("defaultSearchEngine.description")}
|
||||
data={selectableSearchEngines}
|
||||
{...form.getInputProps("defaultSearchEngineId")}
|
||||
/>
|
||||
</SectionCard>
|
||||
<CommonSettingsForm settingKey="search" defaultValues={defaultValues}>
|
||||
{(form) => (
|
||||
<>
|
||||
<Select
|
||||
label={tSearch("defaultSearchEngine.label")}
|
||||
description={tSearch("defaultSearchEngine.description")}
|
||||
data={selectableSearchEngines}
|
||||
{...form.getInputProps("defaultSearchEngineId")}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</CommonSettingsForm>
|
||||
);
|
||||
};
|
||||
@@ -3,13 +3,10 @@ import React from "react";
|
||||
import type { MantineSpacing } from "@mantine/core";
|
||||
import { Group, Stack, Switch, Text, UnstyledButton } from "@mantine/core";
|
||||
|
||||
import type { Modify } from "@homarr/common/types";
|
||||
import type { UseFormReturnType } from "@homarr/form";
|
||||
|
||||
type BooleanKeys<TFormValue> = {
|
||||
[TKey in keyof TFormValue]: TFormValue[TKey] extends boolean ? TKey : never;
|
||||
}[keyof TFormValue];
|
||||
|
||||
export const SwitchSetting = <TFormValue extends Record<string, unknown>>({
|
||||
export const SwitchSetting = <TFormValue extends Record<string, boolean>>({
|
||||
form,
|
||||
ms,
|
||||
title,
|
||||
@@ -17,8 +14,13 @@ export const SwitchSetting = <TFormValue extends Record<string, unknown>>({
|
||||
formKey,
|
||||
disabled,
|
||||
}: {
|
||||
form: UseFormReturnType<TFormValue, TFormValue>;
|
||||
formKey: string & BooleanKeys<TFormValue>;
|
||||
form: Modify<
|
||||
UseFormReturnType<TFormValue, TFormValue>,
|
||||
{
|
||||
setFieldValue: (key: keyof TFormValue, value: (previous: boolean) => boolean) => void;
|
||||
}
|
||||
>;
|
||||
formKey: keyof TFormValue;
|
||||
ms?: MantineSpacing;
|
||||
title: string;
|
||||
text: ReactNode;
|
||||
@@ -29,7 +31,7 @@ export const SwitchSetting = <TFormValue extends Record<string, unknown>>({
|
||||
return;
|
||||
}
|
||||
|
||||
form.setFieldValue(formKey, (previous) => !previous as never);
|
||||
form.setFieldValue(formKey, (previous) => !previous);
|
||||
}, [form, formKey, disabled]);
|
||||
|
||||
return (
|
||||
@@ -42,7 +44,7 @@ export const SwitchSetting = <TFormValue extends Record<string, unknown>>({
|
||||
</Text>
|
||||
</Stack>
|
||||
</UnstyledButton>
|
||||
<Switch disabled={disabled} onClick={handleClick} checked={Boolean(form.values[formKey])} />
|
||||
<Switch disabled={disabled} onClick={handleClick} checked={form.values[formKey] && !disabled} />
|
||||
</Group>
|
||||
);
|
||||
};
|
||||
@@ -1,193 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Button, Stack } from "@mantine/core";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { revalidatePathActionAsync } from "@homarr/common/client";
|
||||
import { env } from "@homarr/common/env";
|
||||
import { colorSchemes } from "@homarr/definitions";
|
||||
import { useZodForm } from "@homarr/form";
|
||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||
import type { ServerSettings, defaultServerSettingsKeys } from "@homarr/server-settings";
|
||||
import { useI18n, useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import { UnsavedChangesBar } from "~/components/manage/unsaved-changes-bar";
|
||||
import { AnalyticsSettings } from "./analytics.settings";
|
||||
import { AppearanceSettingsForm } from "./appearance-settings-form";
|
||||
import { BoardSettingsForm } from "./board-settings-form";
|
||||
import { CrawlingAndIndexingSettings } from "./crawling-and-indexing.settings";
|
||||
import { CultureSettingsForm } from "./culture-settings-form";
|
||||
import { SearchSettingsForm } from "./search-settings-form";
|
||||
import { UserSettingsForm } from "./user-settings-form";
|
||||
|
||||
const settingsFormSchema = z.object({
|
||||
enableGeneral: z.boolean(),
|
||||
noIndex: z.boolean(),
|
||||
noFollow: z.boolean(),
|
||||
noTranslate: z.boolean(),
|
||||
noSiteLinksSearchBox: z.boolean(),
|
||||
homeBoardId: z.string().nullable(),
|
||||
mobileHomeBoardId: z.string().nullable(),
|
||||
enableStatusByDefault: z.boolean(),
|
||||
forceDisableStatus: z.boolean(),
|
||||
enableGravatar: z.boolean(),
|
||||
defaultSearchEngineId: z.string().nullable(),
|
||||
defaultColorScheme: z.enum(colorSchemes),
|
||||
defaultLocale: z.string(),
|
||||
});
|
||||
|
||||
export type FormValues = z.infer<typeof settingsFormSchema>;
|
||||
|
||||
const buildInitialValues = (initialData: ServerSettings): FormValues => ({
|
||||
enableGeneral: initialData.analytics.enableGeneral,
|
||||
noIndex: initialData.crawlingAndIndexing.noIndex,
|
||||
noFollow: initialData.crawlingAndIndexing.noFollow,
|
||||
noTranslate: initialData.crawlingAndIndexing.noTranslate,
|
||||
noSiteLinksSearchBox: initialData.crawlingAndIndexing.noSiteLinksSearchBox,
|
||||
homeBoardId: initialData.board.homeBoardId,
|
||||
mobileHomeBoardId: initialData.board.mobileHomeBoardId,
|
||||
enableStatusByDefault: initialData.board.enableStatusByDefault,
|
||||
forceDisableStatus: initialData.board.forceDisableStatus,
|
||||
enableGravatar: initialData.user.enableGravatar,
|
||||
defaultSearchEngineId: initialData.search.defaultSearchEngineId,
|
||||
defaultColorScheme: initialData.appearance.defaultColorScheme,
|
||||
defaultLocale: initialData.culture.defaultLocale,
|
||||
});
|
||||
|
||||
interface SettingsFormProps {
|
||||
initialData: ServerSettings;
|
||||
}
|
||||
|
||||
export const SettingsForm = ({ initialData }: SettingsFormProps) => {
|
||||
const t = useI18n();
|
||||
const tSettings = useScopedI18n("management.page.settings");
|
||||
|
||||
const initialValues = buildInitialValues(initialData);
|
||||
const initialValuesRef = useRef(initialValues);
|
||||
|
||||
const form = useZodForm(settingsFormSchema, {
|
||||
initialValues,
|
||||
});
|
||||
|
||||
const isDirtyRef = useRef(false);
|
||||
isDirtyRef.current = form.isDirty();
|
||||
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
const saveSettingsMutation = clientApi.serverSettings.saveSettings.useMutation({
|
||||
onError(error) {
|
||||
showErrorNotification({ title: t("common.notification.update.error"), message: error.message });
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (event: BeforeUnloadEvent) => {
|
||||
if (env.NODE_ENV === "development") return;
|
||||
if (isDirtyRef.current) event.preventDefault();
|
||||
};
|
||||
window.addEventListener("beforeunload", handler);
|
||||
return () => window.removeEventListener("beforeunload", handler);
|
||||
}, []);
|
||||
|
||||
const handleSubmitAsync = async (values: FormValues) => {
|
||||
const defaults = initialValuesRef.current;
|
||||
const changed = <TKey extends keyof FormValues>(...fields: TKey[]) =>
|
||||
fields.some((field) => values[field] !== defaults[field]);
|
||||
|
||||
const groups: {
|
||||
settingsKey: (typeof defaultServerSettingsKeys)[number];
|
||||
when: boolean;
|
||||
value: Record<string, unknown>;
|
||||
}[] = [
|
||||
{ settingsKey: "analytics", when: changed("enableGeneral"), value: { enableGeneral: values.enableGeneral } },
|
||||
{
|
||||
settingsKey: "crawlingAndIndexing",
|
||||
when: changed("noIndex", "noFollow", "noTranslate", "noSiteLinksSearchBox"),
|
||||
value: {
|
||||
noIndex: values.noIndex,
|
||||
noFollow: values.noFollow,
|
||||
noTranslate: values.noTranslate,
|
||||
noSiteLinksSearchBox: values.noSiteLinksSearchBox,
|
||||
},
|
||||
},
|
||||
{
|
||||
settingsKey: "board",
|
||||
when: changed("homeBoardId", "mobileHomeBoardId", "enableStatusByDefault", "forceDisableStatus"),
|
||||
value: {
|
||||
homeBoardId: values.homeBoardId,
|
||||
mobileHomeBoardId: values.mobileHomeBoardId,
|
||||
enableStatusByDefault: values.enableStatusByDefault,
|
||||
forceDisableStatus: values.forceDisableStatus,
|
||||
},
|
||||
},
|
||||
{ settingsKey: "user", when: changed("enableGravatar"), value: { enableGravatar: values.enableGravatar } },
|
||||
{
|
||||
settingsKey: "search",
|
||||
when: changed("defaultSearchEngineId"),
|
||||
value: { defaultSearchEngineId: values.defaultSearchEngineId },
|
||||
},
|
||||
{
|
||||
settingsKey: "appearance",
|
||||
when: changed("defaultColorScheme"),
|
||||
value: { defaultColorScheme: values.defaultColorScheme },
|
||||
},
|
||||
{ settingsKey: "culture", when: changed("defaultLocale"), value: { defaultLocale: values.defaultLocale } },
|
||||
];
|
||||
|
||||
const promises = groups
|
||||
.filter((group) => group.when)
|
||||
.map((group) => saveSettingsMutation.mutateAsync({ settingsKey: group.settingsKey, value: group.value }));
|
||||
if (promises.length === 0) return;
|
||||
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
const results = await Promise.allSettled(promises);
|
||||
if (!results.every((result) => result.status === "fulfilled")) {
|
||||
return;
|
||||
}
|
||||
|
||||
initialValuesRef.current = values;
|
||||
form.setInitialValues(values);
|
||||
form.resetDirty();
|
||||
await revalidatePathActionAsync("/manage/settings");
|
||||
showSuccessNotification({
|
||||
title: t("common.notification.update.success"),
|
||||
message: tSettings("notification.success.message"),
|
||||
});
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDiscard = () => {
|
||||
form.setValues(initialValuesRef.current);
|
||||
form.resetDirty();
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={form.onSubmit((values) => void handleSubmitAsync(values))}>
|
||||
<Stack gap="xl">
|
||||
<AnalyticsSettings form={form} />
|
||||
<CrawlingAndIndexingSettings form={form} />
|
||||
<BoardSettingsForm form={form} />
|
||||
<UserSettingsForm form={form} />
|
||||
<SearchSettingsForm form={form} />
|
||||
<AppearanceSettingsForm form={form} />
|
||||
<CultureSettingsForm form={form} />
|
||||
|
||||
{form.isDirty() && (
|
||||
<UnsavedChangesBar>
|
||||
<Button type="button" disabled={isSubmitting} variant="default" onClick={handleDiscard}>
|
||||
{t("common.action.discard")}
|
||||
</Button>
|
||||
<Button loading={isSubmitting} type="submit" disabled={!form.isValid()}>
|
||||
{t("common.action.saveChanges")}
|
||||
</Button>
|
||||
</UnsavedChangesBar>
|
||||
)}
|
||||
</Stack>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
@@ -2,26 +2,25 @@
|
||||
|
||||
import { Switch } from "@mantine/core";
|
||||
|
||||
import type { UseFormReturnType } from "@homarr/form";
|
||||
import type { ServerSettings } from "@homarr/server-settings";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import { SectionCard } from "~/components/manage/section-card";
|
||||
import type { FormValues } from "./settings-form";
|
||||
import { CommonSettingsForm } from "./common-form";
|
||||
|
||||
interface UserSettingsFormProps {
|
||||
form: UseFormReturnType<FormValues>;
|
||||
}
|
||||
|
||||
export const UserSettingsForm = ({ form }: UserSettingsFormProps) => {
|
||||
export const UserSettingsForm = ({ defaultValues }: { defaultValues: ServerSettings["user"] }) => {
|
||||
const tUser = useScopedI18n("management.page.settings.section.user");
|
||||
|
||||
return (
|
||||
<SectionCard title={tUser("title")}>
|
||||
<Switch
|
||||
{...form.getInputProps("enableGravatar", { type: "checkbox" })}
|
||||
label={tUser("enableGravatar.label")}
|
||||
description={tUser("enableGravatar.description")}
|
||||
/>
|
||||
</SectionCard>
|
||||
<CommonSettingsForm settingKey="user" defaultValues={defaultValues}>
|
||||
{(form) => (
|
||||
<>
|
||||
<Switch
|
||||
{...form.getInputProps("enableGravatar", { type: "checkbox" })}
|
||||
label={tUser("enableGravatar.label")}
|
||||
description={tUser("enableGravatar.description")}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</CommonSettingsForm>
|
||||
);
|
||||
};
|
||||
@@ -1,9 +0,0 @@
|
||||
import { Center, Loader } from "@mantine/core";
|
||||
|
||||
export default function SettingsLoading() {
|
||||
return (
|
||||
<Center h="40vh">
|
||||
<Loader />
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
@@ -5,8 +5,14 @@ import { api } from "@homarr/api/server";
|
||||
import { auth } from "@homarr/auth/next";
|
||||
import { getScopedI18n } from "@homarr/translation/server";
|
||||
|
||||
import { CrawlingAndIndexingSettings } from "~/app/[locale]/manage/settings/_components/crawling-and-indexing.settings";
|
||||
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
|
||||
import { SettingsForm } from "./_components/settings-form";
|
||||
import { AnalyticsSettings } from "./_components/analytics.settings";
|
||||
import { AppearanceSettingsForm } from "./_components/appearance-settings-form";
|
||||
import { BoardSettingsForm } from "./_components/board-settings-form";
|
||||
import { CultureSettingsForm } from "./_components/culture-settings-form";
|
||||
import { SearchSettingsForm } from "./_components/search-settings-form";
|
||||
import { UserSettingsForm } from "./_components/user-settings-form";
|
||||
|
||||
export async function generateMetadata() {
|
||||
const t = await getScopedI18n("management");
|
||||
@@ -26,13 +32,33 @@ export default async function SettingsPage() {
|
||||
|
||||
const serverSettings = await api.serverSettings.getAll();
|
||||
const tSettings = await getScopedI18n("management.page.settings");
|
||||
|
||||
return (
|
||||
<>
|
||||
<DynamicBreadcrumb />
|
||||
<Stack>
|
||||
<Title order={1}>{tSettings("title")}</Title>
|
||||
<SettingsForm initialData={serverSettings} />
|
||||
<AnalyticsSettings initialData={serverSettings.analytics} />
|
||||
<CrawlingAndIndexingSettings initialData={serverSettings.crawlingAndIndexing} />
|
||||
<Stack>
|
||||
<Title order={2}>{tSettings("section.board.title")}</Title>
|
||||
<BoardSettingsForm defaultValues={serverSettings.board} />
|
||||
</Stack>
|
||||
<Stack>
|
||||
<Title order={2}>{tSettings("section.user.title")}</Title>
|
||||
<UserSettingsForm defaultValues={serverSettings.user} />
|
||||
</Stack>
|
||||
<Stack>
|
||||
<Title order={2}>{tSettings("section.search.title")}</Title>
|
||||
<SearchSettingsForm defaultValues={serverSettings.search} />
|
||||
</Stack>
|
||||
<Stack>
|
||||
<Title order={2}>{tSettings("section.appearance.title")}</Title>
|
||||
<AppearanceSettingsForm defaultValues={serverSettings.appearance} />
|
||||
</Stack>
|
||||
<Stack>
|
||||
<Title order={2}>{tSettings("section.culture.title")}</Title>
|
||||
<CultureSettingsForm defaultValues={serverSettings.culture} />
|
||||
</Stack>
|
||||
</Stack>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -1,21 +1,19 @@
|
||||
"use client";
|
||||
|
||||
import { Fragment } from "react";
|
||||
import { Card, CardSection, Divider, Group, Stack, Text, Title } from "@mantine/core";
|
||||
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
import { getI18n } from "@homarr/translation/server";
|
||||
|
||||
interface DangerZoneRootProps {
|
||||
children: React.ReactNode[] | React.ReactNode;
|
||||
}
|
||||
|
||||
export const DangerZoneRoot = ({ children }: DangerZoneRootProps) => {
|
||||
const t = useScopedI18n("common");
|
||||
export const DangerZoneRoot = async ({ children }: DangerZoneRootProps) => {
|
||||
const t = await getI18n();
|
||||
|
||||
return (
|
||||
<Stack gap="sm">
|
||||
<Title c="red.8" order={2}>
|
||||
{t("dangerZone")}
|
||||
{t("common.dangerZone")}
|
||||
</Title>
|
||||
<Card style={{ borderColor: "var(--mantine-color-red-8)", borderWidth: 3 }}>
|
||||
<Stack gap="sm">
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { Card, Divider, Stack, Title } from "@mantine/core";
|
||||
|
||||
interface SectionCardProps {
|
||||
title: ReactNode;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export const SectionCard = ({ title, children }: SectionCardProps) => {
|
||||
return (
|
||||
<Card withBorder bg="transparent">
|
||||
<Stack gap="md">
|
||||
<Title order={3}>{title}</Title>
|
||||
<Divider />
|
||||
{children}
|
||||
</Stack>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
@@ -1,25 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import { Card, Group, Text } from "@mantine/core";
|
||||
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
|
||||
interface UnsavedChangesBarProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export const UnsavedChangesBar = ({ children }: UnsavedChangesBarProps) => {
|
||||
const t = useI18n();
|
||||
|
||||
return (
|
||||
<div style={{ position: "sticky", bottom: 20 }}>
|
||||
<Card withBorder>
|
||||
<Group justify="space-between" wrap="wrap">
|
||||
<Text fw={500}>{t("common.unsavedChanges")}</Text>
|
||||
<Group>{children}</Group>
|
||||
</Group>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -429,13 +429,6 @@
|
||||
<br/>
|
||||
<p align="center">Uptime<br/>Kuma</p>
|
||||
</a>
|
||||
</td>
|
||||
<td align="center">
|
||||
<a href="https://homarr.dev/docs/integrations/whats-up-docker" target="_blank" rel="noreferrer noopener">
|
||||
<img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/whats-up-docker.svg" alt="What's Up Docker" width="90" height="90" />
|
||||
<br/>
|
||||
<p align="center">What's<br/>Up<br/>Docker</p>
|
||||
</a>
|
||||
</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
+1
-1
@@ -5,7 +5,7 @@ events {
|
||||
http {
|
||||
server {
|
||||
listen 7575;
|
||||
${NGINX_LISTEN_IPV6}
|
||||
listen [::]:7575;
|
||||
|
||||
# Route websockets traffic to port 3001
|
||||
location /websockets {
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "homarr",
|
||||
"version": "1.74.0",
|
||||
"version": "1.73.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build": "cross-env CI=true turbo build",
|
||||
|
||||
@@ -173,7 +173,7 @@ export const appRouter = createTRPCRouter({
|
||||
mcp: {
|
||||
enabled: true,
|
||||
description:
|
||||
"Create a new app (bookmark/shortcut to a service). REQUIRED: name (string), iconUrl (icon URL string), href (app URL or custom URI scheme such as vscode://, or blank). OPTIONAL: description (string or null), pingUrl (URL to check reachability, or empty string)",
|
||||
"Create a new app (bookmark/shortcut to a service). REQUIRED: name (string), iconUrl (icon URL string), href (app URL, http/https or blank). OPTIONAL: description (string or null), pingUrl (URL to check reachability, or empty string)",
|
||||
},
|
||||
})
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
|
||||
@@ -44,5 +44,4 @@ export const widgetRouter = createTRPCRouter({
|
||||
traefik: lazy(() => import("./traefik").then((mod) => mod.traefikRouter)),
|
||||
customApi: lazy(() => import("./custom-api").then((mod) => mod.customApiRouter)),
|
||||
secrets: lazy(() => import("./widget-secrets").then((mod) => mod.widgetSecretsRouter)),
|
||||
wud: lazy(() => import("./wud").then((mod) => mod.wudRouter)),
|
||||
});
|
||||
@@ -1,25 +0,0 @@
|
||||
import { wudStatsRequestHandler } from "@homarr/request-handler/wud";
|
||||
|
||||
import { createOneIntegrationMiddleware } from "../../middlewares/integration";
|
||||
import { createTRPCRouter, publicProcedure } from "../../trpc";
|
||||
|
||||
export const wudRouter = createTRPCRouter({
|
||||
getStats: publicProcedure
|
||||
.meta({
|
||||
mcp: {
|
||||
enabled: true,
|
||||
description:
|
||||
"Returns monitored-container counts and available updates from a What's Up Docker integration. REQUIRED: integrationId from integration_all. The caller needs query permission for that integration.",
|
||||
},
|
||||
})
|
||||
.concat(createOneIntegrationMiddleware("query", "wud"))
|
||||
.query(async ({ ctx }) => {
|
||||
const handler = wudStatsRequestHandler.handler(ctx.integration, {});
|
||||
const { data, timestamp } = await handler.getDataAsync();
|
||||
|
||||
return {
|
||||
stats: data,
|
||||
updatedAt: timestamp,
|
||||
};
|
||||
}),
|
||||
});
|
||||
@@ -210,7 +210,6 @@ export type HomarrDocumentationPath =
|
||||
| "/docs/integrations/unifi-controller"
|
||||
| "/docs/integrations/unraid"
|
||||
| "/docs/integrations/uptime-kuma"
|
||||
| "/docs/integrations/whats-up-docker"
|
||||
| "/docs/management/api"
|
||||
| "/docs/management/apps"
|
||||
| "/docs/management/backup"
|
||||
@@ -277,7 +276,6 @@ export type HomarrDocumentationPath =
|
||||
| "/docs/widgets/video"
|
||||
| "/docs/widgets/vpn"
|
||||
| "/docs/widgets/weather"
|
||||
| "/docs/widgets/whats-up-docker"
|
||||
| ""
|
||||
| "/sitemap.xml"
|
||||
| "/docs/integrations/sabnzbd"
|
||||
@@ -332,7 +330,6 @@ export type HomarrDocumentationPath =
|
||||
| "/docs/integrations/peanut"
|
||||
| "/docs/integrations/beszel"
|
||||
| "/docs/integrations/traefik"
|
||||
| "/docs/integrations/whats-up-docker"
|
||||
| "/docs/widgets/clock"
|
||||
| "/docs/widgets/weather"
|
||||
| "/docs/widgets/app"
|
||||
@@ -385,5 +382,4 @@ export type HomarrDocumentationPath =
|
||||
| "/docs/widgets/beszel-alerts"
|
||||
| "/docs/widgets/beszel-system-stats"
|
||||
| "/docs/widgets/traefik"
|
||||
| "/docs/widgets/custom-api"
|
||||
| "/docs/widgets/whats-up-docker";
|
||||
| "/docs/widgets/custom-api";
|
||||
@@ -54,6 +54,5 @@ export const integrationDocSlugs: Record<IntegrationKind, string | null> = {
|
||||
beszel: "beszel",
|
||||
traefik: "traefik",
|
||||
archiveTeamWarrior: null,
|
||||
wud: "whats-up-docker",
|
||||
mock: null,
|
||||
};
|
||||
@@ -55,5 +55,4 @@ export const widgetDocSlugs: Record<WidgetKind, string | null> = {
|
||||
beszelSystemStats: "beszel-system-stats",
|
||||
traefik: "traefik",
|
||||
customApi: "custom-api",
|
||||
wud: "whats-up-docker",
|
||||
};
|
||||
@@ -468,14 +468,6 @@ export const integrationDefs = {
|
||||
category: ["archiving"],
|
||||
documentationUrl: null,
|
||||
},
|
||||
wud: {
|
||||
name: "What's Up Docker",
|
||||
secretKinds: [[], ["username", "password"]],
|
||||
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/whats-up-docker.svg",
|
||||
category: ["healthMonitoring"],
|
||||
documentationUrl: createDocumentationLink("/docs/integrations/whats-up-docker"),
|
||||
defaultPort: 3000,
|
||||
},
|
||||
// This integration only returns mock data, it is used during development (but can also be used in production by directly going to the create page)
|
||||
mock: {
|
||||
name: "Mock",
|
||||
|
||||
@@ -24,9 +24,7 @@ export const widgetIntegrationSupport: Partial<Record<WidgetKind, readonly Integ
|
||||
networkControllerSummary: getIntegrationKindsByCategory("networkController"),
|
||||
networkControllerStatus: getIntegrationKindsByCategory("networkController"),
|
||||
indexerManager: getIntegrationKindsByCategory("indexerManager"),
|
||||
healthMonitoring: getIntegrationKindsByCategory("healthMonitoring").filter(
|
||||
(kind) => kind !== "patchmon" && kind !== "wud",
|
||||
),
|
||||
healthMonitoring: getIntegrationKindsByCategory("healthMonitoring").filter((kind) => kind !== "patchmon"),
|
||||
firewall: getIntegrationKindsByCategory("firewall"),
|
||||
notifications: getIntegrationKindsByCategory("notifications"),
|
||||
mediaReleases: ["mock", "emby", "jellyfin", "plex"],
|
||||
@@ -52,7 +50,6 @@ export const widgetIntegrationSupport: Partial<Record<WidgetKind, readonly Integ
|
||||
archiveTeamWarrior: ["archiveTeamWarrior"],
|
||||
anchorNote: ["anchor"],
|
||||
traefik: ["traefik"],
|
||||
wud: ["wud"],
|
||||
};
|
||||
|
||||
export const getWidgetKindsForIntegration = (integrationKind: IntegrationKind): WidgetKind[] => {
|
||||
|
||||
@@ -53,7 +53,6 @@ export const widgetKinds = [
|
||||
"beszelSystemStats",
|
||||
"traefik",
|
||||
"customApi",
|
||||
"wud",
|
||||
] as const;
|
||||
export type WidgetKind = (typeof widgetKinds)[number];
|
||||
|
||||
|
||||
@@ -54,7 +54,6 @@ import { UmamiIntegration } from "../umami/umami-integration";
|
||||
import { UptimeKumaIntegration } from "../uptime-kuma/uptime-kuma-integration";
|
||||
import { UnifiControllerIntegration } from "../unifi-controller/unifi-controller-integration";
|
||||
import { UnraidIntegration } from "../unraid/unraid-integration";
|
||||
import { WudIntegration } from "../wud/wud-integration";
|
||||
import type { Integration, IntegrationInput } from "./integration";
|
||||
|
||||
export const createIntegrationAsync = async <TKind extends keyof typeof integrationCreators>(
|
||||
@@ -134,7 +133,6 @@ export const integrationCreators = {
|
||||
beszel: BeszelIntegration,
|
||||
bazarr: BazarrIntegration,
|
||||
traefik: TraefikIntegration,
|
||||
wud: WudIntegration,
|
||||
} satisfies Record<IntegrationKind, IntegrationInstance | [(input: IntegrationInput) => Promise<Integration>]>;
|
||||
|
||||
type IntegrationInstanceOfKind<TKind extends keyof typeof integrationCreators> = {
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
// General integrations
|
||||
export { ArchiveTeamWarriorIntegration } from "./archive-team-warrior/archive-team-warrior-integration";
|
||||
export { WudIntegration } from "./wud/wud-integration";
|
||||
export { AdGuardHomeIntegration } from "./adguard-home/adguard-home-integration";
|
||||
export { TechnitiumDnsIntegration } from "./technitium/technitium-integration";
|
||||
export { AnchorIntegration } from "./anchor/anchor-integration";
|
||||
@@ -92,7 +91,6 @@ export type {
|
||||
ArchiveTeamWarriorItem,
|
||||
ArchiveTeamWarriorStatus,
|
||||
} from "./archive-team-warrior/archive-team-warrior-types";
|
||||
export type { WudStats, WudContainerUpdate } from "./wud/wud-types";
|
||||
|
||||
// Schemas
|
||||
export { anchorNotesListInputSchema } from "./anchor/anchor-types";
|
||||
|
||||
@@ -185,15 +185,9 @@ export class OverseerrIntegration
|
||||
}),
|
||||
);
|
||||
|
||||
const fulfilled = settled
|
||||
return settled
|
||||
.filter((result): result is PromiseFulfilledResult<MediaRequest> => result.status === "fulfilled")
|
||||
.map((result) => result.value);
|
||||
|
||||
if (fulfilled.length === 0) {
|
||||
throw new Error("Failed to resolve any media request information");
|
||||
}
|
||||
|
||||
return fulfilled;
|
||||
}
|
||||
|
||||
protected mapRequestStatus(status: UpstreamMediaRequestStatus): MediaRequestStatus {
|
||||
|
||||
@@ -1,269 +0,0 @@
|
||||
// @vitest-environment node
|
||||
import { ParseError } from "@homarr/common/server";
|
||||
import { Response } from "undici";
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
vi.hoisted(() => {
|
||||
process.env.SKIP_ENV_VALIDATION = "true";
|
||||
process.env.SECRET_ENCRYPTION_KEY = "ff3f4f7ce30e870c9630de9e5d244ffa81101a24ed0dfe5f064beb53a7e684f1";
|
||||
});
|
||||
|
||||
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
|
||||
|
||||
import type { IntegrationTestingInput } from "../../base/integration";
|
||||
import { IntegrationParseError } from "../../base/errors/parse/integration-parse-error";
|
||||
import type { IntegrationSecret } from "../../base/types";
|
||||
import { WudIntegration } from "../wud-integration";
|
||||
|
||||
vi.mock("@homarr/core/infrastructure/http", () => ({
|
||||
fetchWithTrustedCertificatesAsync: vi.fn(),
|
||||
}));
|
||||
|
||||
const TEST_URL = "https://wud.example.com";
|
||||
const TEST_USERNAME = "user";
|
||||
const TEST_PASSWORD = "p@ss";
|
||||
|
||||
const mockFetch = vi.mocked(fetchWithTrustedCertificatesAsync);
|
||||
|
||||
const sampleContainersResponse = [
|
||||
{
|
||||
id: "1",
|
||||
name: "homeassistant",
|
||||
displayName: "Home Assistant",
|
||||
updateAvailable: true,
|
||||
link: "https://github.com/home-assistant/core/releases/tag/2024.1.0",
|
||||
image: { tag: { value: "2023.12.0" } },
|
||||
updateKind: { remoteValue: "2024.1.0" },
|
||||
},
|
||||
{ id: "2", name: "traefik", updateAvailable: false },
|
||||
{
|
||||
id: "3",
|
||||
name: "grafana",
|
||||
updateAvailable: true,
|
||||
image: { tag: { value: "10.0.0" } },
|
||||
result: { tag: "10.1.0" },
|
||||
},
|
||||
];
|
||||
|
||||
const sampleUpdates = [
|
||||
{
|
||||
id: "1",
|
||||
name: "Home Assistant",
|
||||
currentVersion: "2023.12.0",
|
||||
newVersion: "2024.1.0",
|
||||
link: "https://github.com/home-assistant/core/releases/tag/2024.1.0",
|
||||
},
|
||||
{
|
||||
id: "3",
|
||||
name: "grafana",
|
||||
currentVersion: "10.0.0",
|
||||
newVersion: "10.1.0",
|
||||
link: null,
|
||||
},
|
||||
];
|
||||
|
||||
const createIntegration = (decryptedSecrets: IntegrationSecret[] = []) =>
|
||||
new WudIntegration({
|
||||
id: "test-wud",
|
||||
name: "Test WUD",
|
||||
url: TEST_URL,
|
||||
externalUrl: null,
|
||||
decryptedSecrets,
|
||||
});
|
||||
|
||||
const createIntegrationWithBasicAuth = () =>
|
||||
createIntegration([
|
||||
{ kind: "username", value: TEST_USERNAME },
|
||||
{ kind: "password", value: TEST_PASSWORD },
|
||||
]);
|
||||
|
||||
beforeEach(() => {
|
||||
mockFetch.mockReset();
|
||||
});
|
||||
|
||||
describe("WudIntegration getStatsAsync", () => {
|
||||
test("computes total and available-update counts from the containers list", async () => {
|
||||
mockFetch.mockResolvedValue(
|
||||
new Response(JSON.stringify(sampleContainersResponse), {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
}) as unknown as Awaited<ReturnType<typeof fetchWithTrustedCertificatesAsync>>,
|
||||
);
|
||||
|
||||
const stats = await createIntegration().getStatsAsync();
|
||||
|
||||
expect(stats).toStrictEqual({ totalContainers: 3, updatesAvailable: 2, updates: sampleUpdates });
|
||||
});
|
||||
|
||||
test("returns zero counts for an empty containers list", async () => {
|
||||
mockFetch.mockResolvedValue(
|
||||
new Response(JSON.stringify([]), {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
}) as unknown as Awaited<ReturnType<typeof fetchWithTrustedCertificatesAsync>>,
|
||||
);
|
||||
|
||||
const stats = await createIntegration().getStatsAsync();
|
||||
|
||||
expect(stats).toStrictEqual({ totalContainers: 0, updatesAvailable: 0, updates: [] });
|
||||
});
|
||||
|
||||
test("maps container updates using displayName, falling back to result.tag and null link", async () => {
|
||||
mockFetch.mockResolvedValue(
|
||||
new Response(JSON.stringify(sampleContainersResponse), {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
}) as unknown as Awaited<ReturnType<typeof fetchWithTrustedCertificatesAsync>>,
|
||||
);
|
||||
|
||||
const stats = await createIntegration().getStatsAsync();
|
||||
|
||||
expect(stats.updates).toStrictEqual(sampleUpdates);
|
||||
});
|
||||
|
||||
test("throws ParseError when API response is not valid JSON", async () => {
|
||||
mockFetch.mockResolvedValue(
|
||||
new Response("not-json", {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
}) as unknown as Awaited<ReturnType<typeof fetchWithTrustedCertificatesAsync>>,
|
||||
);
|
||||
|
||||
await expect(createIntegration().getStatsAsync()).rejects.toSatisfy((error) => {
|
||||
if (!(error instanceof IntegrationParseError)) return false;
|
||||
|
||||
const cause = error.cause;
|
||||
return cause instanceof ParseError && cause.message.includes("Invalid WUD containers response");
|
||||
});
|
||||
});
|
||||
|
||||
test("throws ParseError when API response does not match schema", async () => {
|
||||
mockFetch.mockResolvedValue(
|
||||
new Response(JSON.stringify({ invalid: true }), {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
}) as unknown as Awaited<ReturnType<typeof fetchWithTrustedCertificatesAsync>>,
|
||||
);
|
||||
|
||||
await expect(createIntegration().getStatsAsync()).rejects.toSatisfy((error) => {
|
||||
if (!(error instanceof IntegrationParseError)) return false;
|
||||
|
||||
const cause = error.cause;
|
||||
return cause instanceof ParseError && cause.message.includes("Invalid WUD containers response");
|
||||
});
|
||||
});
|
||||
|
||||
test("throws when API returns an error", async () => {
|
||||
mockFetch.mockResolvedValue(
|
||||
new Response("Unauthorized", {
|
||||
status: 401,
|
||||
headers: { "content-type": "text/plain" },
|
||||
}) as unknown as Awaited<ReturnType<typeof fetchWithTrustedCertificatesAsync>>,
|
||||
);
|
||||
|
||||
await expect(createIntegration().getStatsAsync()).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("WudIntegration authentication", () => {
|
||||
test("sends no Authorization header when no credentials are configured", async () => {
|
||||
mockFetch.mockResolvedValue(
|
||||
new Response(JSON.stringify(sampleContainersResponse), {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
}) as unknown as Awaited<ReturnType<typeof fetchWithTrustedCertificatesAsync>>,
|
||||
);
|
||||
|
||||
await createIntegration().getStatsAsync();
|
||||
|
||||
const [, requestInit] = mockFetch.mock.calls[0] ?? [];
|
||||
const headers = (requestInit?.headers ?? {}) as Record<string, string>;
|
||||
expect(headers.Authorization).toBeUndefined();
|
||||
});
|
||||
|
||||
test("sends a Basic auth header when username and password are configured", async () => {
|
||||
mockFetch.mockResolvedValue(
|
||||
new Response(JSON.stringify(sampleContainersResponse), {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
}) as unknown as Awaited<ReturnType<typeof fetchWithTrustedCertificatesAsync>>,
|
||||
);
|
||||
|
||||
await createIntegrationWithBasicAuth().getStatsAsync();
|
||||
|
||||
const expected = `Basic ${Buffer.from(`${TEST_USERNAME}:${TEST_PASSWORD}`).toString("base64")}`;
|
||||
const [url, requestInit] = mockFetch.mock.calls[0] ?? [];
|
||||
expect(String(url)).toContain("/api/containers");
|
||||
expect(requestInit?.headers).toMatchObject({ Authorization: expected });
|
||||
expect(requestInit?.timeout).toBe(10_000);
|
||||
});
|
||||
|
||||
test("does not send credentials over a non-HTTPS URL", async () => {
|
||||
mockFetch.mockResolvedValue(
|
||||
new Response(JSON.stringify(sampleContainersResponse), {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
}) as unknown as Awaited<ReturnType<typeof fetchWithTrustedCertificatesAsync>>,
|
||||
);
|
||||
|
||||
const integration = new WudIntegration({
|
||||
id: "test-wud-http",
|
||||
name: "Test WUD HTTP",
|
||||
url: "http://wud.example.com",
|
||||
externalUrl: null,
|
||||
decryptedSecrets: [
|
||||
{ kind: "username", value: TEST_USERNAME },
|
||||
{ kind: "password", value: TEST_PASSWORD },
|
||||
],
|
||||
});
|
||||
|
||||
await integration.getStatsAsync();
|
||||
|
||||
const [, requestInit] = mockFetch.mock.calls[0] ?? [];
|
||||
const headers = (requestInit?.headers ?? {}) as Record<string, string>;
|
||||
expect(headers.Authorization).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("WudIntegration testing endpoint", () => {
|
||||
type TestingFn = (input: IntegrationTestingInput) => Promise<{ success: boolean }>;
|
||||
|
||||
const invokeTesting = (integration: WudIntegration, input: IntegrationTestingInput) => {
|
||||
const testing = (integration as unknown as { testingAsync: TestingFn }).testingAsync.bind(integration);
|
||||
return testing(input);
|
||||
};
|
||||
|
||||
const makeInput = (response: Response): IntegrationTestingInput =>
|
||||
({
|
||||
fetchAsync: vi.fn().mockResolvedValue(response),
|
||||
}) as unknown as IntegrationTestingInput;
|
||||
|
||||
test("returns success when /api/app responds 200", async () => {
|
||||
const input = makeInput(new Response(JSON.stringify({ name: "wud", version: "5.0.0" }), { status: 200 }));
|
||||
|
||||
const result = await invokeTesting(createIntegration(), input);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
test("returns a status-code failure when /api/app responds non-OK", async () => {
|
||||
const input = makeInput(new Response("unauthorized", { status: 401 }));
|
||||
|
||||
const result = await invokeTesting(createIntegration(), input);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
test("passes the Basic auth header through to fetchAsync when configured", async () => {
|
||||
const response = new Response(JSON.stringify({ name: "wud", version: "5.0.0" }), { status: 200 });
|
||||
const input = makeInput(response);
|
||||
|
||||
await invokeTesting(createIntegrationWithBasicAuth(), input);
|
||||
|
||||
const fetchAsyncMock = vi.mocked(input.fetchAsync);
|
||||
const init = fetchAsyncMock.mock.calls[0]?.[1];
|
||||
const headers = (init?.headers ?? {}) as Record<string, string>;
|
||||
const expected = `Basic ${Buffer.from(`${TEST_USERNAME}:${TEST_PASSWORD}`).toString("base64")}`;
|
||||
expect(headers.Authorization).toBe(expected);
|
||||
});
|
||||
});
|
||||
@@ -1,68 +0,0 @@
|
||||
import { Buffer } from "node:buffer";
|
||||
|
||||
import { ParseError, ResponseError } from "@homarr/common/server";
|
||||
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
|
||||
|
||||
import type { IntegrationTestingInput } from "../base/integration";
|
||||
import { Integration } from "../base/integration";
|
||||
import { TestConnectionError } from "../base/test-connection/test-connection-error";
|
||||
import type { TestingResult } from "../base/test-connection/test-connection-service";
|
||||
import type { WudStats } from "./wud-types";
|
||||
import { mapWudStats, parseWudContainersResponseAsync } from "./wud-types";
|
||||
|
||||
const CONTAINERS_REQUEST_TIMEOUT_MS = 10_000;
|
||||
|
||||
export class WudIntegration extends Integration {
|
||||
protected async testingAsync(input: IntegrationTestingInput): Promise<TestingResult> {
|
||||
const response = await input.fetchAsync(this.url("/api/app"), {
|
||||
headers: this.getAuthHeaders(),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
return TestConnectionError.StatusResult(response);
|
||||
}
|
||||
|
||||
try {
|
||||
await response.json();
|
||||
} catch (error) {
|
||||
return TestConnectionError.ParseResult(
|
||||
new ParseError("Invalid WUD app response", {
|
||||
cause: error instanceof Error ? error : new Error(String(error)),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
public async getStatsAsync(): Promise<WudStats> {
|
||||
const response = await fetchWithTrustedCertificatesAsync(this.url("/api/containers"), {
|
||||
headers: this.getAuthHeaders(),
|
||||
timeout: CONTAINERS_REQUEST_TIMEOUT_MS,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new ResponseError(response);
|
||||
}
|
||||
|
||||
const containers = await parseWudContainersResponseAsync(response);
|
||||
|
||||
return mapWudStats(containers);
|
||||
}
|
||||
|
||||
private getAuthHeaders(): Record<string, string> | undefined {
|
||||
if (!this.hasSecretValue("username") || !this.hasSecretValue("password")) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (this.url("/api/app").protocol !== "https:") {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const credentials = Buffer.from(`${this.getSecretValue("username")}:${this.getSecretValue("password")}`).toString(
|
||||
"base64",
|
||||
);
|
||||
|
||||
return { Authorization: `Basic ${credentials}` };
|
||||
}
|
||||
}
|
||||
@@ -1,81 +0,0 @@
|
||||
import { ParseError } from "@homarr/common/server";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
const WUD_CONTAINERS_PARSE_ERROR_MESSAGE = "Invalid WUD containers response";
|
||||
|
||||
export const wudContainerSchema = z.object({
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
displayName: z.string().nullable().optional(),
|
||||
updateAvailable: z.boolean(),
|
||||
link: z.string().nullable().optional(),
|
||||
image: z
|
||||
.object({
|
||||
tag: z
|
||||
.object({
|
||||
value: z.string().nullable().optional(),
|
||||
})
|
||||
.optional(),
|
||||
})
|
||||
.optional(),
|
||||
updateKind: z
|
||||
.object({
|
||||
remoteValue: z.string().nullable().optional(),
|
||||
})
|
||||
.optional(),
|
||||
result: z
|
||||
.object({
|
||||
tag: z.string().nullable().optional(),
|
||||
})
|
||||
.optional(),
|
||||
});
|
||||
|
||||
export const wudContainersResponseSchema = z.array(wudContainerSchema);
|
||||
|
||||
export interface WudContainerUpdate {
|
||||
id: string;
|
||||
name: string;
|
||||
currentVersion: string | null;
|
||||
newVersion: string | null;
|
||||
link: string | null;
|
||||
}
|
||||
|
||||
export interface WudStats {
|
||||
totalContainers: number;
|
||||
updatesAvailable: number;
|
||||
updates: WudContainerUpdate[];
|
||||
}
|
||||
|
||||
export const parseWudContainersResponseAsync = async (response: {
|
||||
json: () => Promise<unknown>;
|
||||
}): Promise<z.infer<typeof wudContainersResponseSchema>> => {
|
||||
let json: unknown;
|
||||
try {
|
||||
json = await response.json();
|
||||
} catch (error) {
|
||||
throw new ParseError(WUD_CONTAINERS_PARSE_ERROR_MESSAGE, {
|
||||
cause: error instanceof Error ? error : new Error(String(error)),
|
||||
});
|
||||
}
|
||||
|
||||
const parseResult = await wudContainersResponseSchema.safeParseAsync(json);
|
||||
if (!parseResult.success) {
|
||||
throw new ParseError(WUD_CONTAINERS_PARSE_ERROR_MESSAGE, { cause: parseResult.error });
|
||||
}
|
||||
|
||||
return parseResult.data;
|
||||
};
|
||||
|
||||
const mapContainerUpdate = (container: z.infer<typeof wudContainerSchema>): WudContainerUpdate => ({
|
||||
id: container.id,
|
||||
name: container.displayName ?? container.name,
|
||||
currentVersion: container.image?.tag?.value ?? null,
|
||||
newVersion: container.updateKind?.remoteValue ?? container.result?.tag ?? null,
|
||||
link: container.link ?? null,
|
||||
});
|
||||
|
||||
export const mapWudStats = (containers: z.infer<typeof wudContainersResponseSchema>): WudStats => ({
|
||||
totalContainers: containers.length,
|
||||
updatesAvailable: containers.filter((container) => container.updateAvailable).length,
|
||||
updates: containers.filter((container) => container.updateAvailable).map(mapContainerUpdate),
|
||||
});
|
||||
@@ -6,7 +6,7 @@ import { createIntegrationRequestHandler } from "./lib/integration-request-handl
|
||||
|
||||
export const systemInfoRequestHandler = createIntegrationRequestHandler<
|
||||
SystemHealthMonitoring,
|
||||
Exclude<IntegrationKindByCategory<"healthMonitoring">, "proxmox" | "coolify" | "beszel" | "patchmon" | "wud">,
|
||||
Exclude<IntegrationKindByCategory<"healthMonitoring">, "proxmox" | "coolify" | "beszel" | "patchmon">,
|
||||
Record<string, never>
|
||||
>({
|
||||
async requestAsync(integration, _input) {
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
import { createIntegrationAsync } from "@homarr/integrations";
|
||||
import type { WudStats } from "@homarr/integrations";
|
||||
|
||||
import { createIntegrationRequestHandler } from "./lib/integration-request-handler";
|
||||
|
||||
export const wudStatsRequestHandler = createIntegrationRequestHandler<WudStats, "wud", Record<string, never>>({
|
||||
async requestAsync(integration) {
|
||||
const integrationInstance = await createIntegrationAsync(integration);
|
||||
return await integrationInstance.getStatsAsync();
|
||||
},
|
||||
});
|
||||
@@ -2428,21 +2428,10 @@
|
||||
},
|
||||
"descendingDefaultSort": {
|
||||
"label": ""
|
||||
},
|
||||
"columnOrder": {
|
||||
"label": ""
|
||||
},
|
||||
"columnWidths": {
|
||||
"label": ""
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"internalServerError": "",
|
||||
"layoutSaveTitle": "",
|
||||
"layoutSaveMessage": ""
|
||||
},
|
||||
"empty": {
|
||||
"noContainers": ""
|
||||
"internalServerError": ""
|
||||
}
|
||||
},
|
||||
"coolify": {
|
||||
@@ -2736,20 +2725,8 @@
|
||||
"showOnlyPlaying": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
},
|
||||
"showBitrate": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
},
|
||||
"showLocation": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
}
|
||||
},
|
||||
"footer": {
|
||||
"streams": "",
|
||||
"totalBitrate": ""
|
||||
},
|
||||
"items": {
|
||||
"currentlyPlaying": "S'està reproduint",
|
||||
"user": "Usuari",
|
||||
@@ -2757,9 +2734,6 @@
|
||||
"id": "ID",
|
||||
"status": "",
|
||||
"directPlay": "",
|
||||
"directStream": "",
|
||||
"transcodeVideo": "",
|
||||
"transcodeAudio": "",
|
||||
"transcoding": "",
|
||||
"paused": "",
|
||||
"remaining": "",
|
||||
@@ -3794,6 +3768,18 @@
|
||||
"label": "",
|
||||
"description": ""
|
||||
},
|
||||
"showNowPlaying": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
},
|
||||
"showNowPlayingList": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
},
|
||||
"maxNowPlayingItems": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
},
|
||||
"showLibraryCount": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
@@ -3827,6 +3813,8 @@
|
||||
"podcasts": "",
|
||||
"listeningTime": "",
|
||||
"activeSessions": "",
|
||||
"nowPlaying": "",
|
||||
"nowPlayingCount": "",
|
||||
"error": {
|
||||
"internalServerError": ""
|
||||
}
|
||||
@@ -3966,19 +3954,11 @@
|
||||
},
|
||||
"showAgent": {
|
||||
"label": ""
|
||||
},
|
||||
"columnOrder": {
|
||||
"label": ""
|
||||
},
|
||||
"columnWidths": {
|
||||
"label": ""
|
||||
}
|
||||
},
|
||||
"noRecords": "",
|
||||
"error": {
|
||||
"internalServerError": "",
|
||||
"layoutSaveTitle": "",
|
||||
"layoutSaveMessage": ""
|
||||
"internalServerError": ""
|
||||
},
|
||||
"column": {
|
||||
"system": "",
|
||||
@@ -5376,7 +5356,6 @@
|
||||
},
|
||||
"remove": {
|
||||
"label": "Elimina",
|
||||
"confirm": "",
|
||||
"notification": {
|
||||
"success": {
|
||||
"title": "Contenidors eliminats",
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -2428,21 +2428,10 @@
|
||||
},
|
||||
"descendingDefaultSort": {
|
||||
"label": "crwdns3820:0crwdne3820:0"
|
||||
},
|
||||
"columnOrder": {
|
||||
"label": "crwdns6155:0crwdne6155:0"
|
||||
},
|
||||
"columnWidths": {
|
||||
"label": "crwdns6157:0crwdne6157:0"
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"internalServerError": "crwdns3428:0crwdne3428:0",
|
||||
"layoutSaveTitle": "crwdns6159:0crwdne6159:0",
|
||||
"layoutSaveMessage": "crwdns6161:0crwdne6161:0"
|
||||
},
|
||||
"empty": {
|
||||
"noContainers": "crwdns6163:0crwdne6163:0"
|
||||
"internalServerError": "crwdns3428:0crwdne3428:0"
|
||||
}
|
||||
},
|
||||
"coolify": {
|
||||
@@ -2736,20 +2725,8 @@
|
||||
"showOnlyPlaying": {
|
||||
"label": "crwdns3176:0crwdne3176:0",
|
||||
"description": "crwdns3178:0crwdne3178:0"
|
||||
},
|
||||
"showBitrate": {
|
||||
"label": "crwdns6175:0crwdne6175:0",
|
||||
"description": "crwdns6177:0crwdne6177:0"
|
||||
},
|
||||
"showLocation": {
|
||||
"label": "crwdns6179:0crwdne6179:0",
|
||||
"description": "crwdns6181:0crwdne6181:0"
|
||||
}
|
||||
},
|
||||
"footer": {
|
||||
"streams": "crwdns6183:0count={count}crwdne6183:0",
|
||||
"totalBitrate": "crwdns6185:0{bitrate}crwdne6185:0"
|
||||
},
|
||||
"items": {
|
||||
"currentlyPlaying": "crwdns2847:0crwdne2847:0",
|
||||
"user": "crwdns2476:0crwdne2476:0",
|
||||
@@ -2757,9 +2734,6 @@
|
||||
"id": "crwdns2480:0crwdne2480:0",
|
||||
"status": "crwdns6085:0crwdne6085:0",
|
||||
"directPlay": "crwdns6087:0crwdne6087:0",
|
||||
"directStream": "crwdns6187:0crwdne6187:0",
|
||||
"transcodeVideo": "crwdns6189:0crwdne6189:0",
|
||||
"transcodeAudio": "crwdns6191:0crwdne6191:0",
|
||||
"transcoding": "crwdns6089:0crwdne6089:0",
|
||||
"paused": "crwdns6091:0crwdne6091:0",
|
||||
"remaining": "crwdns6093:0{minutes}crwdne6093:0",
|
||||
@@ -3794,6 +3768,18 @@
|
||||
"label": "crwdns5109:0crwdne5109:0",
|
||||
"description": "crwdns5111:0crwdne5111:0"
|
||||
},
|
||||
"showNowPlaying": {
|
||||
"label": "crwdns5113:0crwdne5113:0",
|
||||
"description": "crwdns5115:0crwdne5115:0"
|
||||
},
|
||||
"showNowPlayingList": {
|
||||
"label": "crwdns5117:0crwdne5117:0",
|
||||
"description": "crwdns5119:0crwdne5119:0"
|
||||
},
|
||||
"maxNowPlayingItems": {
|
||||
"label": "crwdns5121:0crwdne5121:0",
|
||||
"description": "crwdns5123:0crwdne5123:0"
|
||||
},
|
||||
"showLibraryCount": {
|
||||
"label": "crwdns5125:0crwdne5125:0",
|
||||
"description": "crwdns5127:0crwdne5127:0"
|
||||
@@ -3827,6 +3813,8 @@
|
||||
"podcasts": "crwdns5159:0crwdne5159:0",
|
||||
"listeningTime": "crwdns5161:0crwdne5161:0",
|
||||
"activeSessions": "crwdns5163:0crwdne5163:0",
|
||||
"nowPlaying": "crwdns5165:0crwdne5165:0",
|
||||
"nowPlayingCount": "crwdns5167:0count={count}crwdne5167:0",
|
||||
"error": {
|
||||
"internalServerError": "crwdns5169:0crwdne5169:0"
|
||||
}
|
||||
@@ -3966,19 +3954,11 @@
|
||||
},
|
||||
"showAgent": {
|
||||
"label": "crwdns4809:0crwdne4809:0"
|
||||
},
|
||||
"columnOrder": {
|
||||
"label": "crwdns6165:0crwdne6165:0"
|
||||
},
|
||||
"columnWidths": {
|
||||
"label": "crwdns6167:0crwdne6167:0"
|
||||
}
|
||||
},
|
||||
"noRecords": "crwdns4811:0crwdne4811:0",
|
||||
"error": {
|
||||
"internalServerError": "crwdns5651:0crwdne5651:0",
|
||||
"layoutSaveTitle": "crwdns6169:0crwdne6169:0",
|
||||
"layoutSaveMessage": "crwdns6171:0crwdne6171:0"
|
||||
"internalServerError": "crwdns5651:0crwdne5651:0"
|
||||
},
|
||||
"column": {
|
||||
"system": "crwdns4813:0crwdne4813:0",
|
||||
@@ -5376,7 +5356,6 @@
|
||||
},
|
||||
"remove": {
|
||||
"label": "crwdns1952:0crwdne1952:0",
|
||||
"confirm": "crwdns6173:0crwdne6173:0",
|
||||
"notification": {
|
||||
"success": {
|
||||
"title": "crwdns1954:0crwdne1954:0",
|
||||
|
||||
@@ -2428,21 +2428,10 @@
|
||||
},
|
||||
"descendingDefaultSort": {
|
||||
"label": ""
|
||||
},
|
||||
"columnOrder": {
|
||||
"label": ""
|
||||
},
|
||||
"columnWidths": {
|
||||
"label": ""
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"internalServerError": "",
|
||||
"layoutSaveTitle": "",
|
||||
"layoutSaveMessage": ""
|
||||
},
|
||||
"empty": {
|
||||
"noContainers": ""
|
||||
"internalServerError": ""
|
||||
}
|
||||
},
|
||||
"coolify": {
|
||||
@@ -2736,20 +2725,8 @@
|
||||
"showOnlyPlaying": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
},
|
||||
"showBitrate": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
},
|
||||
"showLocation": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
}
|
||||
},
|
||||
"footer": {
|
||||
"streams": "",
|
||||
"totalBitrate": ""
|
||||
},
|
||||
"items": {
|
||||
"currentlyPlaying": "",
|
||||
"user": "Uživatel",
|
||||
@@ -2757,9 +2734,6 @@
|
||||
"id": "Id",
|
||||
"status": "",
|
||||
"directPlay": "",
|
||||
"directStream": "",
|
||||
"transcodeVideo": "",
|
||||
"transcodeAudio": "",
|
||||
"transcoding": "",
|
||||
"paused": "",
|
||||
"remaining": "",
|
||||
@@ -3794,6 +3768,18 @@
|
||||
"label": "",
|
||||
"description": ""
|
||||
},
|
||||
"showNowPlaying": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
},
|
||||
"showNowPlayingList": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
},
|
||||
"maxNowPlayingItems": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
},
|
||||
"showLibraryCount": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
@@ -3827,6 +3813,8 @@
|
||||
"podcasts": "",
|
||||
"listeningTime": "",
|
||||
"activeSessions": "",
|
||||
"nowPlaying": "",
|
||||
"nowPlayingCount": "",
|
||||
"error": {
|
||||
"internalServerError": ""
|
||||
}
|
||||
@@ -3966,19 +3954,11 @@
|
||||
},
|
||||
"showAgent": {
|
||||
"label": "Zobrazit verzi agenta"
|
||||
},
|
||||
"columnOrder": {
|
||||
"label": ""
|
||||
},
|
||||
"columnWidths": {
|
||||
"label": ""
|
||||
}
|
||||
},
|
||||
"noRecords": "Nenalezeny žádné systémy",
|
||||
"error": {
|
||||
"internalServerError": "",
|
||||
"layoutSaveTitle": "",
|
||||
"layoutSaveMessage": ""
|
||||
"internalServerError": ""
|
||||
},
|
||||
"column": {
|
||||
"system": "Systém",
|
||||
@@ -5376,7 +5356,6 @@
|
||||
},
|
||||
"remove": {
|
||||
"label": "Odstranit",
|
||||
"confirm": "",
|
||||
"notification": {
|
||||
"success": {
|
||||
"title": "",
|
||||
|
||||
@@ -2428,21 +2428,10 @@
|
||||
},
|
||||
"descendingDefaultSort": {
|
||||
"label": "Inverter sortering"
|
||||
},
|
||||
"columnOrder": {
|
||||
"label": ""
|
||||
},
|
||||
"columnWidths": {
|
||||
"label": ""
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"internalServerError": "Kunne ikke hente containerstatistik",
|
||||
"layoutSaveTitle": "",
|
||||
"layoutSaveMessage": ""
|
||||
},
|
||||
"empty": {
|
||||
"noContainers": ""
|
||||
"internalServerError": "Kunne ikke hente containerstatistik"
|
||||
}
|
||||
},
|
||||
"coolify": {
|
||||
@@ -2736,20 +2725,8 @@
|
||||
"showOnlyPlaying": {
|
||||
"label": "Vis kun hvad der aktuelt afspilles",
|
||||
"description": "Deaktivering af dette vil ikke virke for plex"
|
||||
},
|
||||
"showBitrate": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
},
|
||||
"showLocation": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
}
|
||||
},
|
||||
"footer": {
|
||||
"streams": "",
|
||||
"totalBitrate": ""
|
||||
},
|
||||
"items": {
|
||||
"currentlyPlaying": "Afspiller lige nu",
|
||||
"user": "Bruger",
|
||||
@@ -2757,9 +2734,6 @@
|
||||
"id": "Id",
|
||||
"status": "",
|
||||
"directPlay": "",
|
||||
"directStream": "",
|
||||
"transcodeVideo": "",
|
||||
"transcodeAudio": "",
|
||||
"transcoding": "",
|
||||
"paused": "",
|
||||
"remaining": "",
|
||||
@@ -3794,6 +3768,18 @@
|
||||
"label": "",
|
||||
"description": ""
|
||||
},
|
||||
"showNowPlaying": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
},
|
||||
"showNowPlayingList": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
},
|
||||
"maxNowPlayingItems": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
},
|
||||
"showLibraryCount": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
@@ -3827,6 +3813,8 @@
|
||||
"podcasts": "",
|
||||
"listeningTime": "",
|
||||
"activeSessions": "",
|
||||
"nowPlaying": "",
|
||||
"nowPlayingCount": "",
|
||||
"error": {
|
||||
"internalServerError": ""
|
||||
}
|
||||
@@ -3966,19 +3954,11 @@
|
||||
},
|
||||
"showAgent": {
|
||||
"label": "Vis agentversion"
|
||||
},
|
||||
"columnOrder": {
|
||||
"label": ""
|
||||
},
|
||||
"columnWidths": {
|
||||
"label": ""
|
||||
}
|
||||
},
|
||||
"noRecords": "Ingen systemer fundet",
|
||||
"error": {
|
||||
"internalServerError": "",
|
||||
"layoutSaveTitle": "",
|
||||
"layoutSaveMessage": ""
|
||||
"internalServerError": ""
|
||||
},
|
||||
"column": {
|
||||
"system": "System",
|
||||
@@ -5376,7 +5356,6 @@
|
||||
},
|
||||
"remove": {
|
||||
"label": "Fjern",
|
||||
"confirm": "",
|
||||
"notification": {
|
||||
"success": {
|
||||
"title": "Containere fjernet",
|
||||
|
||||
@@ -2428,21 +2428,10 @@
|
||||
},
|
||||
"descendingDefaultSort": {
|
||||
"label": ""
|
||||
},
|
||||
"columnOrder": {
|
||||
"label": ""
|
||||
},
|
||||
"columnWidths": {
|
||||
"label": ""
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"internalServerError": "",
|
||||
"layoutSaveTitle": "",
|
||||
"layoutSaveMessage": ""
|
||||
},
|
||||
"empty": {
|
||||
"noContainers": ""
|
||||
"internalServerError": ""
|
||||
}
|
||||
},
|
||||
"coolify": {
|
||||
@@ -2736,20 +2725,8 @@
|
||||
"showOnlyPlaying": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
},
|
||||
"showBitrate": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
},
|
||||
"showLocation": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
}
|
||||
},
|
||||
"footer": {
|
||||
"streams": "",
|
||||
"totalBitrate": ""
|
||||
},
|
||||
"items": {
|
||||
"currentlyPlaying": "Aktuelle Wiedergabe",
|
||||
"user": "Benutzer",
|
||||
@@ -2757,9 +2734,6 @@
|
||||
"id": "",
|
||||
"status": "",
|
||||
"directPlay": "",
|
||||
"directStream": "",
|
||||
"transcodeVideo": "",
|
||||
"transcodeAudio": "",
|
||||
"transcoding": "",
|
||||
"paused": "",
|
||||
"remaining": "",
|
||||
@@ -3794,6 +3768,18 @@
|
||||
"label": "",
|
||||
"description": ""
|
||||
},
|
||||
"showNowPlaying": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
},
|
||||
"showNowPlayingList": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
},
|
||||
"maxNowPlayingItems": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
},
|
||||
"showLibraryCount": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
@@ -3827,6 +3813,8 @@
|
||||
"podcasts": "",
|
||||
"listeningTime": "",
|
||||
"activeSessions": "",
|
||||
"nowPlaying": "",
|
||||
"nowPlayingCount": "",
|
||||
"error": {
|
||||
"internalServerError": ""
|
||||
}
|
||||
@@ -3966,19 +3954,11 @@
|
||||
},
|
||||
"showAgent": {
|
||||
"label": ""
|
||||
},
|
||||
"columnOrder": {
|
||||
"label": ""
|
||||
},
|
||||
"columnWidths": {
|
||||
"label": ""
|
||||
}
|
||||
},
|
||||
"noRecords": "",
|
||||
"error": {
|
||||
"internalServerError": "",
|
||||
"layoutSaveTitle": "",
|
||||
"layoutSaveMessage": ""
|
||||
"internalServerError": ""
|
||||
},
|
||||
"column": {
|
||||
"system": "",
|
||||
@@ -5376,7 +5356,6 @@
|
||||
},
|
||||
"remove": {
|
||||
"label": "Entfernen",
|
||||
"confirm": "",
|
||||
"notification": {
|
||||
"success": {
|
||||
"title": "Container entfernt",
|
||||
|
||||
@@ -1342,19 +1342,19 @@
|
||||
},
|
||||
"traefik": {
|
||||
"name": "Traefik",
|
||||
"description": "Cloud-nativer Reverse-Proxy und Lastverteiler"
|
||||
"description": ""
|
||||
},
|
||||
"paperlessNgx": {
|
||||
"name": "Paperless-ngx",
|
||||
"description": "Dokumentenverwaltungssystem zum Archivieren und Organisieren gescannter Dokumente"
|
||||
},
|
||||
"patchmon": {
|
||||
"name": "PatchMon",
|
||||
"description": "Patch-Verwaltung und Systemüberwachung für Linux-Hosts"
|
||||
"name": "",
|
||||
"description": ""
|
||||
},
|
||||
"bazarr": {
|
||||
"name": "Bazarr",
|
||||
"description": "Schwesteranwendung zu Sonarr und Radarr, welche Untertitel und Downloads verwaltet"
|
||||
"description": ""
|
||||
}
|
||||
},
|
||||
"media": {
|
||||
@@ -2328,8 +2328,8 @@
|
||||
"label": "Dateisystem Info anzeigen"
|
||||
},
|
||||
"visibleStorageVolumes": {
|
||||
"label": "Sichtbare Speichervolumes",
|
||||
"description": "Nur verfügbar, wenn alle Integrationen Synology betreffen. Nicht ausfüllen, um alle erkannten Speichervolumes zu zeigen. ",
|
||||
"label": "",
|
||||
"description": "",
|
||||
"placeholder": "Speichervolumen auswählen"
|
||||
},
|
||||
"defaultTab": {
|
||||
@@ -2428,21 +2428,10 @@
|
||||
},
|
||||
"descendingDefaultSort": {
|
||||
"label": "Sortierung umkehren"
|
||||
},
|
||||
"columnOrder": {
|
||||
"label": "Spaltenordnung"
|
||||
},
|
||||
"columnWidths": {
|
||||
"label": "Spaltenbreite"
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"internalServerError": "Fehler beim Abrufen der Container Statistiken",
|
||||
"layoutSaveTitle": "Tabellenlayout nicht gespeichert",
|
||||
"layoutSaveMessage": "Die Docker-Tabelle konnte nicht gespeichert werden. Versuche die Spalte zu verschieben oder eine andere Spaltengröße zu verwenden. "
|
||||
},
|
||||
"empty": {
|
||||
"noContainers": "Keine Docker-Container gefunden"
|
||||
"internalServerError": "Fehler beim Abrufen der Container Statistiken"
|
||||
}
|
||||
},
|
||||
"coolify": {
|
||||
@@ -2736,20 +2725,8 @@
|
||||
"showOnlyPlaying": {
|
||||
"label": "Nur aktuelle Wiedergabe anzeigen",
|
||||
"description": "Diese Einstellung zu deaktivieren wird für Plex nicht funktionieren"
|
||||
},
|
||||
"showBitrate": {
|
||||
"label": "Zeigt die Bitrate an",
|
||||
"description": ""
|
||||
},
|
||||
"showLocation": {
|
||||
"label": "",
|
||||
"description": "Zeigt an, ob der Stream über das LAN oder WAN läuft"
|
||||
}
|
||||
},
|
||||
"footer": {
|
||||
"streams": "",
|
||||
"totalBitrate": "Gesamtbitrate: {bitrate}"
|
||||
},
|
||||
"items": {
|
||||
"currentlyPlaying": "Aktuelle Wiedergabe",
|
||||
"user": "Benutzer",
|
||||
@@ -2757,9 +2734,6 @@
|
||||
"id": "Id",
|
||||
"status": "Status",
|
||||
"directPlay": "Direkte Wiedergabe",
|
||||
"directStream": "Direkter Stream",
|
||||
"transcodeVideo": "",
|
||||
"transcodeAudio": "",
|
||||
"transcoding": "Transkodierung",
|
||||
"paused": "Pausiert",
|
||||
"remaining": "{minutes} Min. verbleibend",
|
||||
@@ -2829,21 +2803,21 @@
|
||||
}
|
||||
},
|
||||
"patchmon": {
|
||||
"name": "PatchMon",
|
||||
"name": "",
|
||||
"description": "",
|
||||
"title": "PatchMon-Statistiken",
|
||||
"title": "",
|
||||
"totalHosts": "Hosts gesamt",
|
||||
"hostsNeedingUpdates": "Update erforderlich",
|
||||
"securityUpdates": "Sicherheits-Updates",
|
||||
"upToDateHosts": "Aktualisierte Hosts",
|
||||
"upToDateHosts": "",
|
||||
"hostsWithSecurityUpdates": "Hosts mit Sicherheits-Updates",
|
||||
"recentUpdates24h": "Updates (24h)",
|
||||
"totalOutdatedPackages": "Veraltete Pakete",
|
||||
"totalRepos": "Repositories",
|
||||
"osDistribution": "Betriebssystem-Distribution",
|
||||
"compliance": "Patch-Kompatibilität",
|
||||
"complianceRatio": "{upToDate} / {total}",
|
||||
"lastUpdated": "Zuletzt aktualisiert: {time}",
|
||||
"osDistribution": "",
|
||||
"compliance": "",
|
||||
"complianceRatio": "",
|
||||
"lastUpdated": "",
|
||||
"option": {
|
||||
"showTotalHosts": {
|
||||
"label": "Alle Hosts anzeigen",
|
||||
@@ -2859,30 +2833,30 @@
|
||||
},
|
||||
"showUpToDateHosts": {
|
||||
"label": "Zeige aktuelle Hosts",
|
||||
"description": "Zeigt Hosts mit keinen veralteten Paketen an"
|
||||
"description": ""
|
||||
},
|
||||
"showHostsWithSecurityUpdates": {
|
||||
"label": "Zeigt Hosts mit Sicherheitsupdates an",
|
||||
"description": "Zeigt Hosts an, welche Sicherheitsupdates benötigen"
|
||||
"label": "",
|
||||
"description": ""
|
||||
},
|
||||
"showRecentUpdates24h": {
|
||||
"label": "Zeigt vergangene Updates in den letzten 24 Stunden an",
|
||||
"description": "Zeigt die Anzahl der angewendeten Updates in den letzten 24 Stunden an"
|
||||
"label": "",
|
||||
"description": ""
|
||||
},
|
||||
"showTotalOutdatedPackages": {
|
||||
"label": "Zeigt veraltete Pakete an",
|
||||
"description": "Zeigt die Gesamtanzahl der veralteten Pakete über mehrere Hosts verteilt an"
|
||||
"label": "",
|
||||
"description": ""
|
||||
},
|
||||
"showTotalRepos": {
|
||||
"label": "Zeigt Repositorys an",
|
||||
"description": "Zeigt die Anzahl der zu überwachenden aktiven Repositorys an"
|
||||
"label": "",
|
||||
"description": ""
|
||||
},
|
||||
"enableThresholdColors": {
|
||||
"label": "Aktiviert die Schwellwert-Farben",
|
||||
"label": "",
|
||||
"description": ""
|
||||
},
|
||||
"useCustomThresholds": {
|
||||
"label": "Erweiterte Schwellwerte",
|
||||
"label": "",
|
||||
"description": ""
|
||||
},
|
||||
"thresholdMode": {
|
||||
@@ -3794,6 +3768,18 @@
|
||||
"label": "Songs anzeigen",
|
||||
"description": "Zeigt die Gesamtzahl der Titel in deiner Musikbibliothek an (Navidrome)"
|
||||
},
|
||||
"showNowPlaying": {
|
||||
"label": "\"Sie hören\" anzeigen",
|
||||
"description": "Zeige aktuell gespielte Titel an (Navidrome)"
|
||||
},
|
||||
"showNowPlayingList": {
|
||||
"label": "\"Sie hören jetzt\" Wiedergabeliste anzeigen",
|
||||
"description": "Einzelne Tracks anstelle der aktiven Streamanzahl anzeigen (Navidrome)"
|
||||
},
|
||||
"maxNowPlayingItems": {
|
||||
"label": "Maximale jetzt abgespielte Elemente",
|
||||
"description": "Maximale Anzahl an Titeln, die in der \"Sie hören jetzt\" Wiedergabeliste angezeigt werden (Navidrome)"
|
||||
},
|
||||
"showLibraryCount": {
|
||||
"label": "Anzahl der Bibliotheken anzeigen",
|
||||
"description": "Zeige die Anzahl der Bibliotheken in deiner Instanz (Hörbücherregal)"
|
||||
@@ -3827,6 +3813,8 @@
|
||||
"podcasts": "Podcasts",
|
||||
"listeningTime": "Hördauer",
|
||||
"activeSessions": "Aktive Sitzungen",
|
||||
"nowPlaying": "Aktuelle Wiedergabe",
|
||||
"nowPlayingCount": "{count, plural, one {# stream} other {# streams}}",
|
||||
"error": {
|
||||
"internalServerError": "Fehler beim Abrufen der Audio-Statistikdaten"
|
||||
}
|
||||
@@ -3966,19 +3954,11 @@
|
||||
},
|
||||
"showAgent": {
|
||||
"label": "Agent-Version anzeigen"
|
||||
},
|
||||
"columnOrder": {
|
||||
"label": ""
|
||||
},
|
||||
"columnWidths": {
|
||||
"label": ""
|
||||
}
|
||||
},
|
||||
"noRecords": "Keine Systeme gefunden",
|
||||
"error": {
|
||||
"internalServerError": "Keine Verbindung zu Beszel. Überprüfen Sie Ihre Integrationseinstellungen.",
|
||||
"layoutSaveTitle": "",
|
||||
"layoutSaveMessage": ""
|
||||
"internalServerError": "Keine Verbindung zu Beszel. Überprüfen Sie Ihre Integrationseinstellungen."
|
||||
},
|
||||
"column": {
|
||||
"system": "System",
|
||||
@@ -5376,7 +5356,6 @@
|
||||
},
|
||||
"remove": {
|
||||
"label": "Entfernen",
|
||||
"confirm": "",
|
||||
"notification": {
|
||||
"success": {
|
||||
"title": "Container entfernt",
|
||||
|
||||
@@ -2428,21 +2428,10 @@
|
||||
},
|
||||
"descendingDefaultSort": {
|
||||
"label": ""
|
||||
},
|
||||
"columnOrder": {
|
||||
"label": ""
|
||||
},
|
||||
"columnWidths": {
|
||||
"label": ""
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"internalServerError": "",
|
||||
"layoutSaveTitle": "",
|
||||
"layoutSaveMessage": ""
|
||||
},
|
||||
"empty": {
|
||||
"noContainers": ""
|
||||
"internalServerError": ""
|
||||
}
|
||||
},
|
||||
"coolify": {
|
||||
@@ -2736,20 +2725,8 @@
|
||||
"showOnlyPlaying": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
},
|
||||
"showBitrate": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
},
|
||||
"showLocation": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
}
|
||||
},
|
||||
"footer": {
|
||||
"streams": "",
|
||||
"totalBitrate": ""
|
||||
},
|
||||
"items": {
|
||||
"currentlyPlaying": "",
|
||||
"user": "",
|
||||
@@ -2757,9 +2734,6 @@
|
||||
"id": "",
|
||||
"status": "",
|
||||
"directPlay": "",
|
||||
"directStream": "",
|
||||
"transcodeVideo": "",
|
||||
"transcodeAudio": "",
|
||||
"transcoding": "",
|
||||
"paused": "",
|
||||
"remaining": "",
|
||||
@@ -3794,6 +3768,18 @@
|
||||
"label": "Εμφάνιση τραγουδιών",
|
||||
"description": "Εμφάνιση του συνολικού αριθμού τραγουδιών στη βιβλιοθήκη μουσικής σας (Navidrome)"
|
||||
},
|
||||
"showNowPlaying": {
|
||||
"label": "Εμφάνιση τρέχουσας αναπαραγωγής",
|
||||
"description": "Εμφάνιση κομματιών που αναπαράγονται αυτήν τη στιγμή (Navidrome)"
|
||||
},
|
||||
"showNowPlayingList": {
|
||||
"label": "Εμφάνιση λίστας τρέχουσας αναπαραγωγής",
|
||||
"description": "Εμφάνιση μεμονωμένων κομματιών αντί μόνο του αριθμού ενεργών ροών (Navidrome)"
|
||||
},
|
||||
"maxNowPlayingItems": {
|
||||
"label": "Μέγιστα στοιχεία τρέχουσας αναπαραγωγής",
|
||||
"description": "Μέγιστος αριθμός κομματιών προς εμφάνιση στη λίστα τρέχουσας αναπαραγωγής (Navidrome)"
|
||||
},
|
||||
"showLibraryCount": {
|
||||
"label": "Εμφάνιση αριθμού βιβλιοθηκών",
|
||||
"description": "Εμφάνιση του αριθμού βιβλιοθηκών στην εγκατάστασή σας (Audiobookshelf)"
|
||||
@@ -3827,6 +3813,8 @@
|
||||
"podcasts": "Ποντκαστ",
|
||||
"listeningTime": "Χρόνος Ακρόασης",
|
||||
"activeSessions": "Ενεργές Συνεδρίες",
|
||||
"nowPlaying": "Τρέχουσα Αναπαραγωγή",
|
||||
"nowPlayingCount": "{count, plural, one {# ροή} other {# ροές}}",
|
||||
"error": {
|
||||
"internalServerError": "Αποτυχία λήψης δεδομένων στατιστικών ήχου"
|
||||
}
|
||||
@@ -3966,19 +3954,11 @@
|
||||
},
|
||||
"showAgent": {
|
||||
"label": "Εμφάνιση έκδοσης agent"
|
||||
},
|
||||
"columnOrder": {
|
||||
"label": ""
|
||||
},
|
||||
"columnWidths": {
|
||||
"label": ""
|
||||
}
|
||||
},
|
||||
"noRecords": "Δεν βρέθηκαν συστήματα",
|
||||
"error": {
|
||||
"internalServerError": "",
|
||||
"layoutSaveTitle": "",
|
||||
"layoutSaveMessage": ""
|
||||
"internalServerError": ""
|
||||
},
|
||||
"column": {
|
||||
"system": "Σύστημα",
|
||||
@@ -5376,7 +5356,6 @@
|
||||
},
|
||||
"remove": {
|
||||
"label": "Αφαίρεση",
|
||||
"confirm": "",
|
||||
"notification": {
|
||||
"success": {
|
||||
"title": "",
|
||||
|
||||
@@ -2428,21 +2428,10 @@
|
||||
},
|
||||
"descendingDefaultSort": {
|
||||
"label": "Invert sorting"
|
||||
},
|
||||
"columnOrder": {
|
||||
"label": ""
|
||||
},
|
||||
"columnWidths": {
|
||||
"label": ""
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"internalServerError": "Failed to fetch containers stats",
|
||||
"layoutSaveTitle": "",
|
||||
"layoutSaveMessage": ""
|
||||
},
|
||||
"empty": {
|
||||
"noContainers": ""
|
||||
"internalServerError": "Failed to fetch containers stats"
|
||||
}
|
||||
},
|
||||
"coolify": {
|
||||
@@ -2575,26 +2564,26 @@
|
||||
"description": "Display visitor stats from your Umami analytics instance",
|
||||
"visitors": "visitors",
|
||||
"active": "active",
|
||||
"pageviews": "pageviews",
|
||||
"visits": "visits",
|
||||
"bounceRate": "bounce",
|
||||
"avgDuration": "avg",
|
||||
"pageviews": "",
|
||||
"visits": "",
|
||||
"bounceRate": "",
|
||||
"avgDuration": "",
|
||||
"option": {
|
||||
"websiteId": {
|
||||
"label": "Website",
|
||||
"description": "Select a website tracked by your Umami instance",
|
||||
"placeholder": "Select a website",
|
||||
"loading": "Loading websites...",
|
||||
"noWebsites": "No websites found",
|
||||
"configureIntegrationFirst": "Connect a Umami integration first"
|
||||
"label": "",
|
||||
"description": "",
|
||||
"placeholder": "",
|
||||
"loading": "",
|
||||
"noWebsites": "",
|
||||
"configureIntegrationFirst": ""
|
||||
},
|
||||
"timeFrame": {
|
||||
"label": "Time frame",
|
||||
"description": "The time period to display visitor data for",
|
||||
"label": "",
|
||||
"description": "",
|
||||
"option": {
|
||||
"today": "Today",
|
||||
"24h": "Last 24 hours",
|
||||
"7d": "Last 7 days",
|
||||
"today": "",
|
||||
"24h": "",
|
||||
"7d": "",
|
||||
"30d": "Last 30 days",
|
||||
"month": "This month",
|
||||
"lastMonth": "Last month"
|
||||
@@ -2736,20 +2725,8 @@
|
||||
"showOnlyPlaying": {
|
||||
"label": "Show only currently playing",
|
||||
"description": "Disabling this will not work for Plex"
|
||||
},
|
||||
"showBitrate": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
},
|
||||
"showLocation": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
}
|
||||
},
|
||||
"footer": {
|
||||
"streams": "",
|
||||
"totalBitrate": ""
|
||||
},
|
||||
"items": {
|
||||
"currentlyPlaying": "Currently playing",
|
||||
"user": "User",
|
||||
@@ -2757,9 +2734,6 @@
|
||||
"id": "Id",
|
||||
"status": "Status",
|
||||
"directPlay": "Direct play",
|
||||
"directStream": "",
|
||||
"transcodeVideo": "",
|
||||
"transcodeAudio": "",
|
||||
"transcoding": "Transcoding",
|
||||
"paused": "Paused",
|
||||
"remaining": "{minutes} min left",
|
||||
@@ -3794,6 +3768,18 @@
|
||||
"label": "Show songs",
|
||||
"description": "Show the total number of songs in your music library (Navidrome)"
|
||||
},
|
||||
"showNowPlaying": {
|
||||
"label": "Show now playing",
|
||||
"description": "Show tracks currently being played (Navidrome)"
|
||||
},
|
||||
"showNowPlayingList": {
|
||||
"label": "Show now playing list",
|
||||
"description": "Show individual tracks instead of only the active stream count (Navidrome)"
|
||||
},
|
||||
"maxNowPlayingItems": {
|
||||
"label": "Max now playing items",
|
||||
"description": "Maximum number of tracks to show in the now playing list (Navidrome)"
|
||||
},
|
||||
"showLibraryCount": {
|
||||
"label": "Show library count",
|
||||
"description": "Show the number of libraries on your instance (Audiobookshelf)"
|
||||
@@ -3827,6 +3813,8 @@
|
||||
"podcasts": "Podcasts",
|
||||
"listeningTime": "Listening Time",
|
||||
"activeSessions": "Active Sessions",
|
||||
"nowPlaying": "Now Playing",
|
||||
"nowPlayingCount": "{count, plural, one {# stream} other {# streams}}",
|
||||
"error": {
|
||||
"internalServerError": "Failed to fetch audio stats data"
|
||||
}
|
||||
@@ -3966,19 +3954,11 @@
|
||||
},
|
||||
"showAgent": {
|
||||
"label": "Show agent version"
|
||||
},
|
||||
"columnOrder": {
|
||||
"label": ""
|
||||
},
|
||||
"columnWidths": {
|
||||
"label": ""
|
||||
}
|
||||
},
|
||||
"noRecords": "No systems found",
|
||||
"error": {
|
||||
"internalServerError": "Unable to connect to Beszel. Check your integration settings.",
|
||||
"layoutSaveTitle": "",
|
||||
"layoutSaveMessage": ""
|
||||
"internalServerError": "Unable to connect to Beszel. Check your integration settings."
|
||||
},
|
||||
"column": {
|
||||
"system": "System",
|
||||
@@ -5376,7 +5356,6 @@
|
||||
},
|
||||
"remove": {
|
||||
"label": "",
|
||||
"confirm": "",
|
||||
"notification": {
|
||||
"success": {
|
||||
"title": "",
|
||||
|
||||
@@ -1344,10 +1344,6 @@
|
||||
"name": "Traefik",
|
||||
"description": "Cloud-native reverse proxy and load balancer"
|
||||
},
|
||||
"wud": {
|
||||
"name": "What's Up Docker",
|
||||
"description": "Monitor which of your Docker containers have image updates available"
|
||||
},
|
||||
"paperlessNgx": {
|
||||
"name": "Paperless-ngx",
|
||||
"description": "Document management system for archiving and organizing scanned documents"
|
||||
@@ -1797,38 +1793,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"wud": {
|
||||
"name": "What's Up Docker",
|
||||
"description": "Show how many watched containers have updates available.",
|
||||
"title": "What's Up Docker",
|
||||
"monitored": "Monitored",
|
||||
"updatesAvailable": "{count, plural, one {# update available} other {# updates available}}",
|
||||
"updateAvailable": "Update available",
|
||||
"openReleaseNotes": "Open release notes",
|
||||
"option": {
|
||||
"showTitle": {
|
||||
"label": "Show title"
|
||||
},
|
||||
"showRing": {
|
||||
"label": "Show update ring"
|
||||
},
|
||||
"layout": {
|
||||
"label": "Layout",
|
||||
"option": {
|
||||
"horizontal": {
|
||||
"label": "Horizontal"
|
||||
},
|
||||
"vertical": {
|
||||
"label": "Vertical"
|
||||
}
|
||||
}
|
||||
},
|
||||
"showUpdateList": {
|
||||
"label": "Show container update list",
|
||||
"description": "Lists each monitored container that has an image update available"
|
||||
}
|
||||
}
|
||||
},
|
||||
"dnsHoleSummary": {
|
||||
"name": "DNS Hole Summary",
|
||||
"description": "Displays the summary of your DNS Hole",
|
||||
|
||||
@@ -2428,21 +2428,10 @@
|
||||
},
|
||||
"descendingDefaultSort": {
|
||||
"label": "Invertir orden"
|
||||
},
|
||||
"columnOrder": {
|
||||
"label": ""
|
||||
},
|
||||
"columnWidths": {
|
||||
"label": ""
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"internalServerError": "No se pudo obtener las estadísticas de los contenedores",
|
||||
"layoutSaveTitle": "",
|
||||
"layoutSaveMessage": ""
|
||||
},
|
||||
"empty": {
|
||||
"noContainers": ""
|
||||
"internalServerError": "No se pudo obtener las estadísticas de los contenedores"
|
||||
}
|
||||
},
|
||||
"coolify": {
|
||||
@@ -2736,20 +2725,8 @@
|
||||
"showOnlyPlaying": {
|
||||
"label": "Mostrar sólo reproducción actual",
|
||||
"description": "Desactivar esta opción no funcionará para plex"
|
||||
},
|
||||
"showBitrate": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
},
|
||||
"showLocation": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
}
|
||||
},
|
||||
"footer": {
|
||||
"streams": "",
|
||||
"totalBitrate": ""
|
||||
},
|
||||
"items": {
|
||||
"currentlyPlaying": "Reproducción en curso",
|
||||
"user": "Usuario",
|
||||
@@ -2757,9 +2734,6 @@
|
||||
"id": "ID",
|
||||
"status": "",
|
||||
"directPlay": "",
|
||||
"directStream": "",
|
||||
"transcodeVideo": "",
|
||||
"transcodeAudio": "",
|
||||
"transcoding": "",
|
||||
"paused": "",
|
||||
"remaining": "",
|
||||
@@ -3794,6 +3768,18 @@
|
||||
"label": "",
|
||||
"description": ""
|
||||
},
|
||||
"showNowPlaying": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
},
|
||||
"showNowPlayingList": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
},
|
||||
"maxNowPlayingItems": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
},
|
||||
"showLibraryCount": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
@@ -3827,6 +3813,8 @@
|
||||
"podcasts": "",
|
||||
"listeningTime": "",
|
||||
"activeSessions": "",
|
||||
"nowPlaying": "",
|
||||
"nowPlayingCount": "",
|
||||
"error": {
|
||||
"internalServerError": ""
|
||||
}
|
||||
@@ -3966,19 +3954,11 @@
|
||||
},
|
||||
"showAgent": {
|
||||
"label": ""
|
||||
},
|
||||
"columnOrder": {
|
||||
"label": ""
|
||||
},
|
||||
"columnWidths": {
|
||||
"label": ""
|
||||
}
|
||||
},
|
||||
"noRecords": "",
|
||||
"error": {
|
||||
"internalServerError": "",
|
||||
"layoutSaveTitle": "",
|
||||
"layoutSaveMessage": ""
|
||||
"internalServerError": ""
|
||||
},
|
||||
"column": {
|
||||
"system": "",
|
||||
@@ -5376,7 +5356,6 @@
|
||||
},
|
||||
"remove": {
|
||||
"label": "Eliminar",
|
||||
"confirm": "",
|
||||
"notification": {
|
||||
"success": {
|
||||
"title": "Contenedores eliminados",
|
||||
|
||||
@@ -2428,21 +2428,10 @@
|
||||
},
|
||||
"descendingDefaultSort": {
|
||||
"label": ""
|
||||
},
|
||||
"columnOrder": {
|
||||
"label": ""
|
||||
},
|
||||
"columnWidths": {
|
||||
"label": ""
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"internalServerError": "",
|
||||
"layoutSaveTitle": "",
|
||||
"layoutSaveMessage": ""
|
||||
},
|
||||
"empty": {
|
||||
"noContainers": ""
|
||||
"internalServerError": ""
|
||||
}
|
||||
},
|
||||
"coolify": {
|
||||
@@ -2736,20 +2725,8 @@
|
||||
"showOnlyPlaying": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
},
|
||||
"showBitrate": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
},
|
||||
"showLocation": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
}
|
||||
},
|
||||
"footer": {
|
||||
"streams": "",
|
||||
"totalBitrate": ""
|
||||
},
|
||||
"items": {
|
||||
"currentlyPlaying": "",
|
||||
"user": "",
|
||||
@@ -2757,9 +2734,6 @@
|
||||
"id": "",
|
||||
"status": "",
|
||||
"directPlay": "",
|
||||
"directStream": "",
|
||||
"transcodeVideo": "",
|
||||
"transcodeAudio": "",
|
||||
"transcoding": "",
|
||||
"paused": "",
|
||||
"remaining": "",
|
||||
@@ -3794,6 +3768,18 @@
|
||||
"label": "",
|
||||
"description": ""
|
||||
},
|
||||
"showNowPlaying": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
},
|
||||
"showNowPlayingList": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
},
|
||||
"maxNowPlayingItems": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
},
|
||||
"showLibraryCount": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
@@ -3827,6 +3813,8 @@
|
||||
"podcasts": "",
|
||||
"listeningTime": "",
|
||||
"activeSessions": "",
|
||||
"nowPlaying": "",
|
||||
"nowPlayingCount": "",
|
||||
"error": {
|
||||
"internalServerError": ""
|
||||
}
|
||||
@@ -3966,19 +3954,11 @@
|
||||
},
|
||||
"showAgent": {
|
||||
"label": ""
|
||||
},
|
||||
"columnOrder": {
|
||||
"label": ""
|
||||
},
|
||||
"columnWidths": {
|
||||
"label": ""
|
||||
}
|
||||
},
|
||||
"noRecords": "",
|
||||
"error": {
|
||||
"internalServerError": "",
|
||||
"layoutSaveTitle": "",
|
||||
"layoutSaveMessage": ""
|
||||
"internalServerError": ""
|
||||
},
|
||||
"column": {
|
||||
"system": "",
|
||||
@@ -5376,7 +5356,6 @@
|
||||
},
|
||||
"remove": {
|
||||
"label": "",
|
||||
"confirm": "",
|
||||
"notification": {
|
||||
"success": {
|
||||
"title": "",
|
||||
|
||||
@@ -2428,21 +2428,10 @@
|
||||
},
|
||||
"descendingDefaultSort": {
|
||||
"label": "Käänteinen järjestys"
|
||||
},
|
||||
"columnOrder": {
|
||||
"label": ""
|
||||
},
|
||||
"columnWidths": {
|
||||
"label": ""
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"internalServerError": "",
|
||||
"layoutSaveTitle": "",
|
||||
"layoutSaveMessage": ""
|
||||
},
|
||||
"empty": {
|
||||
"noContainers": ""
|
||||
"internalServerError": ""
|
||||
}
|
||||
},
|
||||
"coolify": {
|
||||
@@ -2736,20 +2725,8 @@
|
||||
"showOnlyPlaying": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
},
|
||||
"showBitrate": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
},
|
||||
"showLocation": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
}
|
||||
},
|
||||
"footer": {
|
||||
"streams": "",
|
||||
"totalBitrate": ""
|
||||
},
|
||||
"items": {
|
||||
"currentlyPlaying": "",
|
||||
"user": "",
|
||||
@@ -2757,9 +2734,6 @@
|
||||
"id": "",
|
||||
"status": "Tila",
|
||||
"directPlay": "Muuntamaton toisto",
|
||||
"directStream": "",
|
||||
"transcodeVideo": "",
|
||||
"transcodeAudio": "",
|
||||
"transcoding": "Transkoodaus",
|
||||
"paused": "Keskeytetty",
|
||||
"remaining": "{minutes} min jäljellä",
|
||||
@@ -3794,6 +3768,18 @@
|
||||
"label": "Näytä kappaleet",
|
||||
"description": "Näytä musiikkikirjastosi kappaleiden kokonaismäärä (Navidrome)"
|
||||
},
|
||||
"showNowPlaying": {
|
||||
"label": "Näytä nyt soi",
|
||||
"description": "Näytä tällä hetkellä toistettavat kappaleet (Navidrome)"
|
||||
},
|
||||
"showNowPlayingList": {
|
||||
"label": "Näytä nyt soiva lista",
|
||||
"description": "Näytä yksittäiset kappaleet pelkän aktiivisten striimien määrän sijaan (Navidrome)"
|
||||
},
|
||||
"maxNowPlayingItems": {
|
||||
"label": "Nyt soivien kohteiden enimmäismäärä",
|
||||
"description": "Nyt soivassa listassa näytettävien kappaleiden enimmäismäärä (Navidrome)"
|
||||
},
|
||||
"showLibraryCount": {
|
||||
"label": "Näytä kirjastojen määrä",
|
||||
"description": "Näytä instanssisi kirjastojen lukumäärä (Audiobookshelf)"
|
||||
@@ -3827,6 +3813,8 @@
|
||||
"podcasts": "Podcastit",
|
||||
"listeningTime": "Kuunteluaika",
|
||||
"activeSessions": "Aktiiviset istunnot",
|
||||
"nowPlaying": "Nyt soitetaan",
|
||||
"nowPlayingCount": "{count, plural, one {# striimi} other {# striimiä}}",
|
||||
"error": {
|
||||
"internalServerError": "Audiotilastojen nouto epäonnistui"
|
||||
}
|
||||
@@ -3966,19 +3954,11 @@
|
||||
},
|
||||
"showAgent": {
|
||||
"label": "Näytä agentin versio"
|
||||
},
|
||||
"columnOrder": {
|
||||
"label": ""
|
||||
},
|
||||
"columnWidths": {
|
||||
"label": ""
|
||||
}
|
||||
},
|
||||
"noRecords": "Järjestelmiä ei löytynyt",
|
||||
"error": {
|
||||
"internalServerError": "Beszel-yhteyden muodostaminen epäonnistui. Tarkista integraatioasetuksesi.",
|
||||
"layoutSaveTitle": "",
|
||||
"layoutSaveMessage": ""
|
||||
"internalServerError": "Beszel-yhteyden muodostaminen epäonnistui. Tarkista integraatioasetuksesi."
|
||||
},
|
||||
"column": {
|
||||
"system": "Järjestelmä",
|
||||
@@ -5376,7 +5356,6 @@
|
||||
},
|
||||
"remove": {
|
||||
"label": "Poista",
|
||||
"confirm": "",
|
||||
"notification": {
|
||||
"success": {
|
||||
"title": "",
|
||||
|
||||
@@ -2428,21 +2428,10 @@
|
||||
},
|
||||
"descendingDefaultSort": {
|
||||
"label": "Inverser le tri"
|
||||
},
|
||||
"columnOrder": {
|
||||
"label": ""
|
||||
},
|
||||
"columnWidths": {
|
||||
"label": ""
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"internalServerError": "Impossible de récupérer les statistiques des conteneurs",
|
||||
"layoutSaveTitle": "",
|
||||
"layoutSaveMessage": ""
|
||||
},
|
||||
"empty": {
|
||||
"noContainers": ""
|
||||
"internalServerError": "Impossible de récupérer les statistiques des conteneurs"
|
||||
}
|
||||
},
|
||||
"coolify": {
|
||||
@@ -2736,20 +2725,8 @@
|
||||
"showOnlyPlaying": {
|
||||
"label": "Afficher seulement les médias en cours de lecture",
|
||||
"description": "Désactiver cette option ne fonctionnera pas pour Plex"
|
||||
},
|
||||
"showBitrate": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
},
|
||||
"showLocation": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
}
|
||||
},
|
||||
"footer": {
|
||||
"streams": "",
|
||||
"totalBitrate": ""
|
||||
},
|
||||
"items": {
|
||||
"currentlyPlaying": "En cours de lecture",
|
||||
"user": "Utilisateur",
|
||||
@@ -2757,9 +2734,6 @@
|
||||
"id": "Id",
|
||||
"status": "",
|
||||
"directPlay": "",
|
||||
"directStream": "",
|
||||
"transcodeVideo": "",
|
||||
"transcodeAudio": "",
|
||||
"transcoding": "",
|
||||
"paused": "",
|
||||
"remaining": "",
|
||||
@@ -3794,6 +3768,18 @@
|
||||
"label": "Afficher les titres",
|
||||
"description": ""
|
||||
},
|
||||
"showNowPlaying": {
|
||||
"label": "Afficher la lecture en cours",
|
||||
"description": ""
|
||||
},
|
||||
"showNowPlayingList": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
},
|
||||
"maxNowPlayingItems": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
},
|
||||
"showLibraryCount": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
@@ -3827,6 +3813,8 @@
|
||||
"podcasts": "",
|
||||
"listeningTime": "",
|
||||
"activeSessions": "",
|
||||
"nowPlaying": "",
|
||||
"nowPlayingCount": "",
|
||||
"error": {
|
||||
"internalServerError": ""
|
||||
}
|
||||
@@ -3966,19 +3954,11 @@
|
||||
},
|
||||
"showAgent": {
|
||||
"label": ""
|
||||
},
|
||||
"columnOrder": {
|
||||
"label": ""
|
||||
},
|
||||
"columnWidths": {
|
||||
"label": ""
|
||||
}
|
||||
},
|
||||
"noRecords": "",
|
||||
"error": {
|
||||
"internalServerError": "",
|
||||
"layoutSaveTitle": "",
|
||||
"layoutSaveMessage": ""
|
||||
"internalServerError": ""
|
||||
},
|
||||
"column": {
|
||||
"system": "",
|
||||
@@ -5376,7 +5356,6 @@
|
||||
},
|
||||
"remove": {
|
||||
"label": "Supprimer",
|
||||
"confirm": "",
|
||||
"notification": {
|
||||
"success": {
|
||||
"title": "Conteneurs supprimés",
|
||||
|
||||
@@ -2428,21 +2428,10 @@
|
||||
},
|
||||
"descendingDefaultSort": {
|
||||
"label": "מיון הפוך"
|
||||
},
|
||||
"columnOrder": {
|
||||
"label": ""
|
||||
},
|
||||
"columnWidths": {
|
||||
"label": ""
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"internalServerError": "נכשל באחזור סטטיסטיקות המכולות",
|
||||
"layoutSaveTitle": "",
|
||||
"layoutSaveMessage": ""
|
||||
},
|
||||
"empty": {
|
||||
"noContainers": ""
|
||||
"internalServerError": "נכשל באחזור סטטיסטיקות המכולות"
|
||||
}
|
||||
},
|
||||
"coolify": {
|
||||
@@ -2736,20 +2725,8 @@
|
||||
"showOnlyPlaying": {
|
||||
"label": "הצג רק את ההפעלה הנוכחית",
|
||||
"description": "השבתת אפשרות זו לא תעבוד עבור פלקס"
|
||||
},
|
||||
"showBitrate": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
},
|
||||
"showLocation": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
}
|
||||
},
|
||||
"footer": {
|
||||
"streams": "",
|
||||
"totalBitrate": ""
|
||||
},
|
||||
"items": {
|
||||
"currentlyPlaying": "מתנגן כרגע",
|
||||
"user": "משתמש",
|
||||
@@ -2757,9 +2734,6 @@
|
||||
"id": "מזהה",
|
||||
"status": "",
|
||||
"directPlay": "",
|
||||
"directStream": "",
|
||||
"transcodeVideo": "",
|
||||
"transcodeAudio": "",
|
||||
"transcoding": "",
|
||||
"paused": "",
|
||||
"remaining": "",
|
||||
@@ -3794,6 +3768,18 @@
|
||||
"label": "",
|
||||
"description": ""
|
||||
},
|
||||
"showNowPlaying": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
},
|
||||
"showNowPlayingList": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
},
|
||||
"maxNowPlayingItems": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
},
|
||||
"showLibraryCount": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
@@ -3827,6 +3813,8 @@
|
||||
"podcasts": "",
|
||||
"listeningTime": "",
|
||||
"activeSessions": "",
|
||||
"nowPlaying": "",
|
||||
"nowPlayingCount": "",
|
||||
"error": {
|
||||
"internalServerError": ""
|
||||
}
|
||||
@@ -3966,19 +3954,11 @@
|
||||
},
|
||||
"showAgent": {
|
||||
"label": ""
|
||||
},
|
||||
"columnOrder": {
|
||||
"label": ""
|
||||
},
|
||||
"columnWidths": {
|
||||
"label": ""
|
||||
}
|
||||
},
|
||||
"noRecords": "",
|
||||
"error": {
|
||||
"internalServerError": "",
|
||||
"layoutSaveTitle": "",
|
||||
"layoutSaveMessage": ""
|
||||
"internalServerError": ""
|
||||
},
|
||||
"column": {
|
||||
"system": "",
|
||||
@@ -5376,7 +5356,6 @@
|
||||
},
|
||||
"remove": {
|
||||
"label": "הסר",
|
||||
"confirm": "",
|
||||
"notification": {
|
||||
"success": {
|
||||
"title": "הקונטיינרים הוסרו",
|
||||
|
||||
@@ -2428,21 +2428,10 @@
|
||||
},
|
||||
"descendingDefaultSort": {
|
||||
"label": ""
|
||||
},
|
||||
"columnOrder": {
|
||||
"label": ""
|
||||
},
|
||||
"columnWidths": {
|
||||
"label": ""
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"internalServerError": "",
|
||||
"layoutSaveTitle": "",
|
||||
"layoutSaveMessage": ""
|
||||
},
|
||||
"empty": {
|
||||
"noContainers": ""
|
||||
"internalServerError": ""
|
||||
}
|
||||
},
|
||||
"coolify": {
|
||||
@@ -2736,20 +2725,8 @@
|
||||
"showOnlyPlaying": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
},
|
||||
"showBitrate": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
},
|
||||
"showLocation": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
}
|
||||
},
|
||||
"footer": {
|
||||
"streams": "",
|
||||
"totalBitrate": ""
|
||||
},
|
||||
"items": {
|
||||
"currentlyPlaying": "",
|
||||
"user": "",
|
||||
@@ -2757,9 +2734,6 @@
|
||||
"id": "",
|
||||
"status": "",
|
||||
"directPlay": "",
|
||||
"directStream": "",
|
||||
"transcodeVideo": "",
|
||||
"transcodeAudio": "",
|
||||
"transcoding": "",
|
||||
"paused": "",
|
||||
"remaining": "",
|
||||
@@ -3794,6 +3768,18 @@
|
||||
"label": "",
|
||||
"description": ""
|
||||
},
|
||||
"showNowPlaying": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
},
|
||||
"showNowPlayingList": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
},
|
||||
"maxNowPlayingItems": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
},
|
||||
"showLibraryCount": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
@@ -3827,6 +3813,8 @@
|
||||
"podcasts": "",
|
||||
"listeningTime": "",
|
||||
"activeSessions": "",
|
||||
"nowPlaying": "",
|
||||
"nowPlayingCount": "",
|
||||
"error": {
|
||||
"internalServerError": ""
|
||||
}
|
||||
@@ -3966,19 +3954,11 @@
|
||||
},
|
||||
"showAgent": {
|
||||
"label": "Prikaži verziju agenta"
|
||||
},
|
||||
"columnOrder": {
|
||||
"label": ""
|
||||
},
|
||||
"columnWidths": {
|
||||
"label": ""
|
||||
}
|
||||
},
|
||||
"noRecords": "Nisu pronađeni sustavi",
|
||||
"error": {
|
||||
"internalServerError": "",
|
||||
"layoutSaveTitle": "",
|
||||
"layoutSaveMessage": ""
|
||||
"internalServerError": ""
|
||||
},
|
||||
"column": {
|
||||
"system": "Sustav",
|
||||
@@ -5376,7 +5356,6 @@
|
||||
},
|
||||
"remove": {
|
||||
"label": "Ukloni",
|
||||
"confirm": "",
|
||||
"notification": {
|
||||
"success": {
|
||||
"title": "Kontejneri su uklonjeni",
|
||||
|
||||
@@ -2428,21 +2428,10 @@
|
||||
},
|
||||
"descendingDefaultSort": {
|
||||
"label": "Fordított rendezés"
|
||||
},
|
||||
"columnOrder": {
|
||||
"label": ""
|
||||
},
|
||||
"columnWidths": {
|
||||
"label": ""
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"internalServerError": "A konténerek statisztikáinak lekérése sikertelen",
|
||||
"layoutSaveTitle": "",
|
||||
"layoutSaveMessage": ""
|
||||
},
|
||||
"empty": {
|
||||
"noContainers": ""
|
||||
"internalServerError": "A konténerek statisztikáinak lekérése sikertelen"
|
||||
}
|
||||
},
|
||||
"coolify": {
|
||||
@@ -2736,20 +2725,8 @@
|
||||
"showOnlyPlaying": {
|
||||
"label": "Csak a jelenleg lejátszottakat mutasd",
|
||||
"description": "Ennek letiltása a Plex esetében nem fog működni"
|
||||
},
|
||||
"showBitrate": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
},
|
||||
"showLocation": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
}
|
||||
},
|
||||
"footer": {
|
||||
"streams": "",
|
||||
"totalBitrate": ""
|
||||
},
|
||||
"items": {
|
||||
"currentlyPlaying": "Jelenleg lejátszás alatt",
|
||||
"user": "Felhasználó",
|
||||
@@ -2757,9 +2734,6 @@
|
||||
"id": "Azonosító",
|
||||
"status": "",
|
||||
"directPlay": "",
|
||||
"directStream": "",
|
||||
"transcodeVideo": "",
|
||||
"transcodeAudio": "",
|
||||
"transcoding": "",
|
||||
"paused": "",
|
||||
"remaining": "",
|
||||
@@ -3794,6 +3768,18 @@
|
||||
"label": "Zeneszámok megjelenítése",
|
||||
"description": "A zeneszámok teljes számának megjelenítése a zenekönyvtárban (Navidrome)"
|
||||
},
|
||||
"showNowPlaying": {
|
||||
"label": "Most játszott megjelenítése",
|
||||
"description": "A jelenleg játszott sávok megjelenítése (Navidrome)"
|
||||
},
|
||||
"showNowPlayingList": {
|
||||
"label": "Most játszott lista megjelenítése",
|
||||
"description": "Egyes sávok megjelenítése a csak aktív streamszám helyett (Navidrome)"
|
||||
},
|
||||
"maxNowPlayingItems": {
|
||||
"label": "Maximális most játszott elemek",
|
||||
"description": "A most játszott listában megjelenítendő sávok maximális száma (Navidrome)"
|
||||
},
|
||||
"showLibraryCount": {
|
||||
"label": "Könyvtárak számának megjelenítése",
|
||||
"description": "A példányodon lévő könyvtárak számának megjelenítése (Audiobookshelf)"
|
||||
@@ -3827,6 +3813,8 @@
|
||||
"podcasts": "Podcastek",
|
||||
"listeningTime": "Hallgatási idő",
|
||||
"activeSessions": "Aktív munkamenetek",
|
||||
"nowPlaying": "Most játszott",
|
||||
"nowPlayingCount": "{count, plural, one {# stream} other {# stream}}",
|
||||
"error": {
|
||||
"internalServerError": "Az audió statisztikai adatok letöltése nem sikerült"
|
||||
}
|
||||
@@ -3966,19 +3954,11 @@
|
||||
},
|
||||
"showAgent": {
|
||||
"label": "Agent verzió mutatása"
|
||||
},
|
||||
"columnOrder": {
|
||||
"label": ""
|
||||
},
|
||||
"columnWidths": {
|
||||
"label": ""
|
||||
}
|
||||
},
|
||||
"noRecords": "Nincsenek rendszerek",
|
||||
"error": {
|
||||
"internalServerError": "",
|
||||
"layoutSaveTitle": "",
|
||||
"layoutSaveMessage": ""
|
||||
"internalServerError": ""
|
||||
},
|
||||
"column": {
|
||||
"system": "Rendszer",
|
||||
@@ -5376,7 +5356,6 @@
|
||||
},
|
||||
"remove": {
|
||||
"label": "Eltávolítás",
|
||||
"confirm": "",
|
||||
"notification": {
|
||||
"success": {
|
||||
"title": "Konténerek eltávolítva",
|
||||
|
||||
@@ -2428,21 +2428,10 @@
|
||||
},
|
||||
"descendingDefaultSort": {
|
||||
"label": "Inverti ordinamento"
|
||||
},
|
||||
"columnOrder": {
|
||||
"label": "Ordine delle colonne"
|
||||
},
|
||||
"columnWidths": {
|
||||
"label": "Larghezza delle colonne"
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"internalServerError": "Recupero statistiche container non riuscito",
|
||||
"layoutSaveTitle": "Layout della tabella non salvato",
|
||||
"layoutSaveMessage": "Impossibile salvare il layout della tabella Docker. Prova a spostare o ridimensionare nuovamente la colonna."
|
||||
},
|
||||
"empty": {
|
||||
"noContainers": "Nessun container trovato"
|
||||
"internalServerError": "Recupero statistiche container non riuscito"
|
||||
}
|
||||
},
|
||||
"coolify": {
|
||||
@@ -2736,20 +2725,8 @@
|
||||
"showOnlyPlaying": {
|
||||
"label": "Mostra solo attualmente in riproduzione",
|
||||
"description": "La disabilitazione non funzionerà per Plex"
|
||||
},
|
||||
"showBitrate": {
|
||||
"label": "Mostra bitrate",
|
||||
"description": "Mostra il bitrate di riproduzione corrente accanto alla posizione"
|
||||
},
|
||||
"showLocation": {
|
||||
"label": "Mostra posizione",
|
||||
"description": "Mostra se lo stream si trova sulla tua LAN o WAN"
|
||||
}
|
||||
},
|
||||
"footer": {
|
||||
"streams": "{count, plural, one {# stream attivo} other {# stream attivi}}",
|
||||
"totalBitrate": "Totale: {bitrate}"
|
||||
},
|
||||
"items": {
|
||||
"currentlyPlaying": "Attualmente in riproduzione",
|
||||
"user": "Utente",
|
||||
@@ -2757,9 +2734,6 @@
|
||||
"id": "Id",
|
||||
"status": "Stato",
|
||||
"directPlay": "Riproduzione diretta",
|
||||
"directStream": "Streaming diretto",
|
||||
"transcodeVideo": "Transcodifica video",
|
||||
"transcodeAudio": "Transcodifica audio",
|
||||
"transcoding": "Transcodifica",
|
||||
"paused": "In pausa",
|
||||
"remaining": "{minutes} min rimanenti",
|
||||
@@ -3794,6 +3768,18 @@
|
||||
"label": "Mostra brani",
|
||||
"description": "Mostra il numero totale di brani nella tua libreria musicale (Navidrome)"
|
||||
},
|
||||
"showNowPlaying": {
|
||||
"label": "Mostra in riproduzione",
|
||||
"description": "Mostra le tracce attualmente in riproduzione (Navidrome)"
|
||||
},
|
||||
"showNowPlayingList": {
|
||||
"label": "Mostra elenco in riproduzione",
|
||||
"description": "Mostra le singole tracce invece del solo conteggio degli stream attivi (Navidrome)"
|
||||
},
|
||||
"maxNowPlayingItems": {
|
||||
"label": "Numero massimo elementi in riproduzione",
|
||||
"description": "Numero massimo di tracce da mostrare nell'elenco in riproduzione (Navidrome)"
|
||||
},
|
||||
"showLibraryCount": {
|
||||
"label": "Mostra conteggio librerie",
|
||||
"description": "Mostra il numero di librerie sulla tua istanza (Audiobookshelf)"
|
||||
@@ -3827,6 +3813,8 @@
|
||||
"podcasts": "Podcast",
|
||||
"listeningTime": "Tempo di Ascolto",
|
||||
"activeSessions": "Sessioni Attive",
|
||||
"nowPlaying": "In Riproduzione",
|
||||
"nowPlayingCount": "{count, plural, one {# stream} other {# streams}}",
|
||||
"error": {
|
||||
"internalServerError": "Impossibile recuperare i dati delle statistiche audio"
|
||||
}
|
||||
@@ -3966,19 +3954,11 @@
|
||||
},
|
||||
"showAgent": {
|
||||
"label": "Mostra versione agente"
|
||||
},
|
||||
"columnOrder": {
|
||||
"label": "Ordine delle colonne"
|
||||
},
|
||||
"columnWidths": {
|
||||
"label": "Larghezza delle colonne"
|
||||
}
|
||||
},
|
||||
"noRecords": "Nessun sistema trovato",
|
||||
"error": {
|
||||
"internalServerError": "Impossibile connettersi a Beszel. Controlla le tue impostazioni di integrazione.",
|
||||
"layoutSaveTitle": "Impossibile salvare il layout della tabella",
|
||||
"layoutSaveMessage": "Impossibile salvare l'ordine o la larghezza delle colonne. Riprova."
|
||||
"internalServerError": "Impossibile connettersi a Beszel. Controlla le tue impostazioni di integrazione."
|
||||
},
|
||||
"column": {
|
||||
"system": "Sistema",
|
||||
@@ -5376,7 +5356,6 @@
|
||||
},
|
||||
"remove": {
|
||||
"label": "Rimuovi",
|
||||
"confirm": "Fare clic di nuovo per rimuovere definitivamente",
|
||||
"notification": {
|
||||
"success": {
|
||||
"title": "Containers rimossi",
|
||||
|
||||
@@ -2428,21 +2428,10 @@
|
||||
},
|
||||
"descendingDefaultSort": {
|
||||
"label": "並び替えを反転"
|
||||
},
|
||||
"columnOrder": {
|
||||
"label": ""
|
||||
},
|
||||
"columnWidths": {
|
||||
"label": ""
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"internalServerError": "コンテナステータスの取得に失敗しました",
|
||||
"layoutSaveTitle": "",
|
||||
"layoutSaveMessage": ""
|
||||
},
|
||||
"empty": {
|
||||
"noContainers": ""
|
||||
"internalServerError": "コンテナステータスの取得に失敗しました"
|
||||
}
|
||||
},
|
||||
"coolify": {
|
||||
@@ -2736,20 +2725,8 @@
|
||||
"showOnlyPlaying": {
|
||||
"label": "現在再生中のみ表示",
|
||||
"description": "これを無効にすると plex では動作しません"
|
||||
},
|
||||
"showBitrate": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
},
|
||||
"showLocation": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
}
|
||||
},
|
||||
"footer": {
|
||||
"streams": "",
|
||||
"totalBitrate": ""
|
||||
},
|
||||
"items": {
|
||||
"currentlyPlaying": "再生中の曲",
|
||||
"user": "ユーザー",
|
||||
@@ -2757,9 +2734,6 @@
|
||||
"id": "Id",
|
||||
"status": "",
|
||||
"directPlay": "",
|
||||
"directStream": "",
|
||||
"transcodeVideo": "",
|
||||
"transcodeAudio": "",
|
||||
"transcoding": "",
|
||||
"paused": "",
|
||||
"remaining": "",
|
||||
@@ -3794,6 +3768,18 @@
|
||||
"label": "",
|
||||
"description": ""
|
||||
},
|
||||
"showNowPlaying": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
},
|
||||
"showNowPlayingList": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
},
|
||||
"maxNowPlayingItems": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
},
|
||||
"showLibraryCount": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
@@ -3827,6 +3813,8 @@
|
||||
"podcasts": "",
|
||||
"listeningTime": "",
|
||||
"activeSessions": "",
|
||||
"nowPlaying": "",
|
||||
"nowPlayingCount": "",
|
||||
"error": {
|
||||
"internalServerError": ""
|
||||
}
|
||||
@@ -3966,19 +3954,11 @@
|
||||
},
|
||||
"showAgent": {
|
||||
"label": ""
|
||||
},
|
||||
"columnOrder": {
|
||||
"label": ""
|
||||
},
|
||||
"columnWidths": {
|
||||
"label": ""
|
||||
}
|
||||
},
|
||||
"noRecords": "",
|
||||
"error": {
|
||||
"internalServerError": "",
|
||||
"layoutSaveTitle": "",
|
||||
"layoutSaveMessage": ""
|
||||
"internalServerError": ""
|
||||
},
|
||||
"column": {
|
||||
"system": "",
|
||||
@@ -5376,7 +5356,6 @@
|
||||
},
|
||||
"remove": {
|
||||
"label": "削除",
|
||||
"confirm": "",
|
||||
"notification": {
|
||||
"success": {
|
||||
"title": "コンテナー削除",
|
||||
|
||||
@@ -2428,21 +2428,10 @@
|
||||
},
|
||||
"descendingDefaultSort": {
|
||||
"label": "역순 정렬"
|
||||
},
|
||||
"columnOrder": {
|
||||
"label": ""
|
||||
},
|
||||
"columnWidths": {
|
||||
"label": ""
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"internalServerError": "컨테이너 상태 불러오기 실패",
|
||||
"layoutSaveTitle": "",
|
||||
"layoutSaveMessage": ""
|
||||
},
|
||||
"empty": {
|
||||
"noContainers": ""
|
||||
"internalServerError": "컨테이너 상태 불러오기 실패"
|
||||
}
|
||||
},
|
||||
"coolify": {
|
||||
@@ -2736,20 +2725,8 @@
|
||||
"showOnlyPlaying": {
|
||||
"label": "현재 재생 중 항목만 표시",
|
||||
"description": "비활성화 옵션은 plex에서는 지원되지 않음"
|
||||
},
|
||||
"showBitrate": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
},
|
||||
"showLocation": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
}
|
||||
},
|
||||
"footer": {
|
||||
"streams": "",
|
||||
"totalBitrate": ""
|
||||
},
|
||||
"items": {
|
||||
"currentlyPlaying": "현재 재생 중",
|
||||
"user": "사용자",
|
||||
@@ -2757,9 +2734,6 @@
|
||||
"id": "아이디",
|
||||
"status": "상태",
|
||||
"directPlay": "직접 재생",
|
||||
"directStream": "",
|
||||
"transcodeVideo": "",
|
||||
"transcodeAudio": "",
|
||||
"transcoding": "트랜스코딩",
|
||||
"paused": "일시 중지됨",
|
||||
"remaining": "남은 시간 {minutes}분",
|
||||
@@ -3794,6 +3768,18 @@
|
||||
"label": "음원 표시",
|
||||
"description": "음악 라이브러리에 있는 음원 총 수를 표시 (Navidrome)"
|
||||
},
|
||||
"showNowPlaying": {
|
||||
"label": "현재 재생 중 표시",
|
||||
"description": "현재 재생 중인 트랙을 표시 (Navidrome)"
|
||||
},
|
||||
"showNowPlayingList": {
|
||||
"label": "현재 재생 목록 표시",
|
||||
"description": "활성 스트림 수 대신 개별 트랙 표시 (Navidrome)"
|
||||
},
|
||||
"maxNowPlayingItems": {
|
||||
"label": "최대 현재 재생 항목 수",
|
||||
"description": "현재 재생 목록에 표시할 최대 트랙 수 (Navidrome)"
|
||||
},
|
||||
"showLibraryCount": {
|
||||
"label": "라이브러리 수 표시",
|
||||
"description": "인스턴스에 있는 라이브러리 수 표시 (Audiobookshelf)"
|
||||
@@ -3827,6 +3813,8 @@
|
||||
"podcasts": "팟캐스트",
|
||||
"listeningTime": "청취 시간",
|
||||
"activeSessions": "활성 세션",
|
||||
"nowPlaying": "현재 재생 중",
|
||||
"nowPlayingCount": "{count, plural, one {# 스트림} other {# 스트림}}",
|
||||
"error": {
|
||||
"internalServerError": "오디오 정보 가져오기 실패"
|
||||
}
|
||||
@@ -3966,19 +3954,11 @@
|
||||
},
|
||||
"showAgent": {
|
||||
"label": "에이전트 버전 표시"
|
||||
},
|
||||
"columnOrder": {
|
||||
"label": ""
|
||||
},
|
||||
"columnWidths": {
|
||||
"label": ""
|
||||
}
|
||||
},
|
||||
"noRecords": "시스템을 찾을 수 없음",
|
||||
"error": {
|
||||
"internalServerError": "Beszel에 연결할 수 없습니다. 통합 설정을 확인하십시오.",
|
||||
"layoutSaveTitle": "",
|
||||
"layoutSaveMessage": ""
|
||||
"internalServerError": "Beszel에 연결할 수 없습니다. 통합 설정을 확인하십시오."
|
||||
},
|
||||
"column": {
|
||||
"system": "시스템",
|
||||
@@ -5376,7 +5356,6 @@
|
||||
},
|
||||
"remove": {
|
||||
"label": "제거",
|
||||
"confirm": "",
|
||||
"notification": {
|
||||
"success": {
|
||||
"title": "컨테이너 제거됨",
|
||||
|
||||
@@ -2428,21 +2428,10 @@
|
||||
},
|
||||
"descendingDefaultSort": {
|
||||
"label": ""
|
||||
},
|
||||
"columnOrder": {
|
||||
"label": ""
|
||||
},
|
||||
"columnWidths": {
|
||||
"label": ""
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"internalServerError": "",
|
||||
"layoutSaveTitle": "",
|
||||
"layoutSaveMessage": ""
|
||||
},
|
||||
"empty": {
|
||||
"noContainers": ""
|
||||
"internalServerError": ""
|
||||
}
|
||||
},
|
||||
"coolify": {
|
||||
@@ -2736,20 +2725,8 @@
|
||||
"showOnlyPlaying": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
},
|
||||
"showBitrate": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
},
|
||||
"showLocation": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
}
|
||||
},
|
||||
"footer": {
|
||||
"streams": "",
|
||||
"totalBitrate": ""
|
||||
},
|
||||
"items": {
|
||||
"currentlyPlaying": "",
|
||||
"user": "",
|
||||
@@ -2757,9 +2734,6 @@
|
||||
"id": "",
|
||||
"status": "",
|
||||
"directPlay": "",
|
||||
"directStream": "",
|
||||
"transcodeVideo": "",
|
||||
"transcodeAudio": "",
|
||||
"transcoding": "",
|
||||
"paused": "",
|
||||
"remaining": "",
|
||||
@@ -3794,6 +3768,18 @@
|
||||
"label": "Rodyti dainas",
|
||||
"description": "Rodyti bendrą dainų skaičių savo muzikos bibliotekoje (Navidrome)"
|
||||
},
|
||||
"showNowPlaying": {
|
||||
"label": "Rodyti dabar grojamą",
|
||||
"description": "Rodyti šiuo metu grojamas dainas (Navidrome)"
|
||||
},
|
||||
"showNowPlayingList": {
|
||||
"label": "Rodyti dabar grojamų sąrašą",
|
||||
"description": "Rodyti atskiras dainas vietoj tik aktyvių srautų skaičiaus (Navidrome)"
|
||||
},
|
||||
"maxNowPlayingItems": {
|
||||
"label": "Maks. dabar grojamų elementų",
|
||||
"description": "Didžiausias rodomų dainų skaičius dabar grojamų sąraše (Navidrome)"
|
||||
},
|
||||
"showLibraryCount": {
|
||||
"label": "Rodyti bibliotekų skaičių",
|
||||
"description": "Rodyti bibliotekų skaičių savo egzemplioriuje (Audiobookshelf)"
|
||||
@@ -3827,6 +3813,8 @@
|
||||
"podcasts": "",
|
||||
"listeningTime": "",
|
||||
"activeSessions": "",
|
||||
"nowPlaying": "",
|
||||
"nowPlayingCount": "",
|
||||
"error": {
|
||||
"internalServerError": ""
|
||||
}
|
||||
@@ -3966,19 +3954,11 @@
|
||||
},
|
||||
"showAgent": {
|
||||
"label": "Rodyti agento versiją"
|
||||
},
|
||||
"columnOrder": {
|
||||
"label": ""
|
||||
},
|
||||
"columnWidths": {
|
||||
"label": ""
|
||||
}
|
||||
},
|
||||
"noRecords": "Sistemų nerasta",
|
||||
"error": {
|
||||
"internalServerError": "",
|
||||
"layoutSaveTitle": "",
|
||||
"layoutSaveMessage": ""
|
||||
"internalServerError": ""
|
||||
},
|
||||
"column": {
|
||||
"system": "Sistema",
|
||||
@@ -5376,7 +5356,6 @@
|
||||
},
|
||||
"remove": {
|
||||
"label": "Pašalinti",
|
||||
"confirm": "",
|
||||
"notification": {
|
||||
"success": {
|
||||
"title": "",
|
||||
|
||||
@@ -2428,21 +2428,10 @@
|
||||
},
|
||||
"descendingDefaultSort": {
|
||||
"label": ""
|
||||
},
|
||||
"columnOrder": {
|
||||
"label": ""
|
||||
},
|
||||
"columnWidths": {
|
||||
"label": ""
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"internalServerError": "",
|
||||
"layoutSaveTitle": "",
|
||||
"layoutSaveMessage": ""
|
||||
},
|
||||
"empty": {
|
||||
"noContainers": ""
|
||||
"internalServerError": ""
|
||||
}
|
||||
},
|
||||
"coolify": {
|
||||
@@ -2736,20 +2725,8 @@
|
||||
"showOnlyPlaying": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
},
|
||||
"showBitrate": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
},
|
||||
"showLocation": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
}
|
||||
},
|
||||
"footer": {
|
||||
"streams": "",
|
||||
"totalBitrate": ""
|
||||
},
|
||||
"items": {
|
||||
"currentlyPlaying": "",
|
||||
"user": "",
|
||||
@@ -2757,9 +2734,6 @@
|
||||
"id": "",
|
||||
"status": "",
|
||||
"directPlay": "",
|
||||
"directStream": "",
|
||||
"transcodeVideo": "",
|
||||
"transcodeAudio": "",
|
||||
"transcoding": "",
|
||||
"paused": "",
|
||||
"remaining": "",
|
||||
@@ -3794,6 +3768,18 @@
|
||||
"label": "Rādīt dziesmas",
|
||||
"description": "Parādīt kopējo dziesmu skaitu mūzikas bibliotēkā (Navidrome)"
|
||||
},
|
||||
"showNowPlaying": {
|
||||
"label": "Rādīt pašlaik atskaņoto",
|
||||
"description": "Parādīt šobrīd atskaņotās dziesmas (Navidrome)"
|
||||
},
|
||||
"showNowPlayingList": {
|
||||
"label": "Rādīt pašlaik atskaņoto sarakstu",
|
||||
"description": "Rādīt atsevišķas dziesmas, nevis tikai aktīvo straumju skaitu (Navidrome)"
|
||||
},
|
||||
"maxNowPlayingItems": {
|
||||
"label": "Maks. pašlaik atskaņoto vienumu skaits",
|
||||
"description": "Maksimālais dziesmu skaits, ko rādīt pašlaik atskaņotajā sarakstā (Navidrome)"
|
||||
},
|
||||
"showLibraryCount": {
|
||||
"label": "Rādīt bibliotēku skaitu",
|
||||
"description": "Parādīt bibliotēku skaitu jūsu instancē (Audiobookshelf)"
|
||||
@@ -3827,6 +3813,8 @@
|
||||
"podcasts": "Podkāsti",
|
||||
"listeningTime": "Klausīšanās laiks",
|
||||
"activeSessions": "Aktīvās sesijas",
|
||||
"nowPlaying": "Atskaņo pašlaik",
|
||||
"nowPlayingCount": "{count, plural, zero {# straumju} one {# straume} other {# straumes}}",
|
||||
"error": {
|
||||
"internalServerError": "Neizdevās iegūt audio statistikas datus"
|
||||
}
|
||||
@@ -3966,19 +3954,11 @@
|
||||
},
|
||||
"showAgent": {
|
||||
"label": "Rādīt aģenta versiju"
|
||||
},
|
||||
"columnOrder": {
|
||||
"label": ""
|
||||
},
|
||||
"columnWidths": {
|
||||
"label": ""
|
||||
}
|
||||
},
|
||||
"noRecords": "Nav atrasta neviena sistēma",
|
||||
"error": {
|
||||
"internalServerError": "",
|
||||
"layoutSaveTitle": "",
|
||||
"layoutSaveMessage": ""
|
||||
"internalServerError": ""
|
||||
},
|
||||
"column": {
|
||||
"system": "Sistēma",
|
||||
@@ -5376,7 +5356,6 @@
|
||||
},
|
||||
"remove": {
|
||||
"label": "Noņemt",
|
||||
"confirm": "",
|
||||
"notification": {
|
||||
"success": {
|
||||
"title": "",
|
||||
|
||||
@@ -2428,21 +2428,10 @@
|
||||
},
|
||||
"descendingDefaultSort": {
|
||||
"label": "Sorteren omkeren"
|
||||
},
|
||||
"columnOrder": {
|
||||
"label": ""
|
||||
},
|
||||
"columnWidths": {
|
||||
"label": ""
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"internalServerError": "Ophalen van containerstatistieken mislukt",
|
||||
"layoutSaveTitle": "",
|
||||
"layoutSaveMessage": ""
|
||||
},
|
||||
"empty": {
|
||||
"noContainers": ""
|
||||
"internalServerError": "Ophalen van containerstatistieken mislukt"
|
||||
}
|
||||
},
|
||||
"coolify": {
|
||||
@@ -2736,20 +2725,8 @@
|
||||
"showOnlyPlaying": {
|
||||
"label": "Alleen huidig spelende weergeven",
|
||||
"description": "Dit uitschakelen werkt niet voor Plex"
|
||||
},
|
||||
"showBitrate": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
},
|
||||
"showLocation": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
}
|
||||
},
|
||||
"footer": {
|
||||
"streams": "",
|
||||
"totalBitrate": ""
|
||||
},
|
||||
"items": {
|
||||
"currentlyPlaying": "Momenteel aan het afspelen",
|
||||
"user": "Gebruiker",
|
||||
@@ -2757,9 +2734,6 @@
|
||||
"id": "Id",
|
||||
"status": "",
|
||||
"directPlay": "",
|
||||
"directStream": "",
|
||||
"transcodeVideo": "",
|
||||
"transcodeAudio": "",
|
||||
"transcoding": "",
|
||||
"paused": "",
|
||||
"remaining": "",
|
||||
@@ -3794,6 +3768,18 @@
|
||||
"label": "",
|
||||
"description": ""
|
||||
},
|
||||
"showNowPlaying": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
},
|
||||
"showNowPlayingList": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
},
|
||||
"maxNowPlayingItems": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
},
|
||||
"showLibraryCount": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
@@ -3827,6 +3813,8 @@
|
||||
"podcasts": "",
|
||||
"listeningTime": "",
|
||||
"activeSessions": "",
|
||||
"nowPlaying": "",
|
||||
"nowPlayingCount": "",
|
||||
"error": {
|
||||
"internalServerError": ""
|
||||
}
|
||||
@@ -3966,19 +3954,11 @@
|
||||
},
|
||||
"showAgent": {
|
||||
"label": "Toon agentversie"
|
||||
},
|
||||
"columnOrder": {
|
||||
"label": ""
|
||||
},
|
||||
"columnWidths": {
|
||||
"label": ""
|
||||
}
|
||||
},
|
||||
"noRecords": "Geen systemen gevonden",
|
||||
"error": {
|
||||
"internalServerError": "",
|
||||
"layoutSaveTitle": "",
|
||||
"layoutSaveMessage": ""
|
||||
"internalServerError": ""
|
||||
},
|
||||
"column": {
|
||||
"system": "Systeem",
|
||||
@@ -5376,7 +5356,6 @@
|
||||
},
|
||||
"remove": {
|
||||
"label": "Verwijderen",
|
||||
"confirm": "",
|
||||
"notification": {
|
||||
"success": {
|
||||
"title": "Containers verwijderd",
|
||||
|
||||
@@ -2428,21 +2428,10 @@
|
||||
},
|
||||
"descendingDefaultSort": {
|
||||
"label": "Reverser sortering"
|
||||
},
|
||||
"columnOrder": {
|
||||
"label": ""
|
||||
},
|
||||
"columnWidths": {
|
||||
"label": ""
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"internalServerError": "Kunne ikke hente containerstatistikk",
|
||||
"layoutSaveTitle": "",
|
||||
"layoutSaveMessage": ""
|
||||
},
|
||||
"empty": {
|
||||
"noContainers": ""
|
||||
"internalServerError": "Kunne ikke hente containerstatistikk"
|
||||
}
|
||||
},
|
||||
"coolify": {
|
||||
@@ -2736,20 +2725,8 @@
|
||||
"showOnlyPlaying": {
|
||||
"label": "Vis kun det som spiller nå",
|
||||
"description": "Å deaktivere dette fungerer ikke for Plex"
|
||||
},
|
||||
"showBitrate": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
},
|
||||
"showLocation": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
}
|
||||
},
|
||||
"footer": {
|
||||
"streams": "",
|
||||
"totalBitrate": ""
|
||||
},
|
||||
"items": {
|
||||
"currentlyPlaying": "Spilles nå",
|
||||
"user": "Bruker",
|
||||
@@ -2757,9 +2734,6 @@
|
||||
"id": "Id",
|
||||
"status": "",
|
||||
"directPlay": "",
|
||||
"directStream": "",
|
||||
"transcodeVideo": "",
|
||||
"transcodeAudio": "",
|
||||
"transcoding": "",
|
||||
"paused": "",
|
||||
"remaining": "",
|
||||
@@ -3794,6 +3768,18 @@
|
||||
"label": "",
|
||||
"description": ""
|
||||
},
|
||||
"showNowPlaying": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
},
|
||||
"showNowPlayingList": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
},
|
||||
"maxNowPlayingItems": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
},
|
||||
"showLibraryCount": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
@@ -3827,6 +3813,8 @@
|
||||
"podcasts": "",
|
||||
"listeningTime": "",
|
||||
"activeSessions": "",
|
||||
"nowPlaying": "",
|
||||
"nowPlayingCount": "",
|
||||
"error": {
|
||||
"internalServerError": ""
|
||||
}
|
||||
@@ -3966,19 +3954,11 @@
|
||||
},
|
||||
"showAgent": {
|
||||
"label": ""
|
||||
},
|
||||
"columnOrder": {
|
||||
"label": ""
|
||||
},
|
||||
"columnWidths": {
|
||||
"label": ""
|
||||
}
|
||||
},
|
||||
"noRecords": "",
|
||||
"error": {
|
||||
"internalServerError": "",
|
||||
"layoutSaveTitle": "",
|
||||
"layoutSaveMessage": ""
|
||||
"internalServerError": ""
|
||||
},
|
||||
"column": {
|
||||
"system": "",
|
||||
@@ -5376,7 +5356,6 @@
|
||||
},
|
||||
"remove": {
|
||||
"label": "Fjern",
|
||||
"confirm": "",
|
||||
"notification": {
|
||||
"success": {
|
||||
"title": "Containere fjernet",
|
||||
|
||||
@@ -2428,21 +2428,10 @@
|
||||
},
|
||||
"descendingDefaultSort": {
|
||||
"label": "Odwróć sortowanie"
|
||||
},
|
||||
"columnOrder": {
|
||||
"label": "Kolejność kolumn"
|
||||
},
|
||||
"columnWidths": {
|
||||
"label": "Szerokości kolumn"
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"internalServerError": "Nie udało się pobrać statystyk kontenerów",
|
||||
"layoutSaveTitle": "Nie zapisano układu tabeli",
|
||||
"layoutSaveMessage": "Nie można zapisać układu tabeli Dockera. Spróbuj ponownie przesunąć lub zmienić rozmiar kolumny."
|
||||
},
|
||||
"empty": {
|
||||
"noContainers": "Nie znaleziono kontenerów"
|
||||
"internalServerError": "Nie udało się pobrać statystyk kontenerów"
|
||||
}
|
||||
},
|
||||
"coolify": {
|
||||
@@ -2736,20 +2725,8 @@
|
||||
"showOnlyPlaying": {
|
||||
"label": "Pokaż tylko aktualnie odtwarzane",
|
||||
"description": "Wyłączenie tej opcji nie zadziała w przypadku Plex"
|
||||
},
|
||||
"showBitrate": {
|
||||
"label": "Pokaż bitrate",
|
||||
"description": "Pokaż bieżącą przepływność odtwarzania obok lokalizacji"
|
||||
},
|
||||
"showLocation": {
|
||||
"label": "Pokaż lokalizację",
|
||||
"description": "Pokaż, czy strumień jest w sieci LAN, czy WAN"
|
||||
}
|
||||
},
|
||||
"footer": {
|
||||
"streams": "{count, plural, one {# aktywny strumień} few {# aktywne strumienie} many {# aktywnych strumieni} other {# aktywnych strumieni}}",
|
||||
"totalBitrate": "Łącznie: {bitrate}"
|
||||
},
|
||||
"items": {
|
||||
"currentlyPlaying": "Obecnie odtwarzane",
|
||||
"user": "Użytkownik",
|
||||
@@ -2757,9 +2734,6 @@
|
||||
"id": "ID",
|
||||
"status": "Status",
|
||||
"directPlay": "Odtwarzanie bezpośrednie",
|
||||
"directStream": "Bezpośredni strumień",
|
||||
"transcodeVideo": "Transkodowanie wideo",
|
||||
"transcodeAudio": "Transkodowanie audio",
|
||||
"transcoding": "Transkodowanie",
|
||||
"paused": "Zatrzymane",
|
||||
"remaining": "{minutes} min pozostało",
|
||||
@@ -3107,18 +3081,18 @@
|
||||
"description": "To ograniczy liczbę wyświetlanych elementów dla każdej integracji, a nie globalnie."
|
||||
},
|
||||
"columnOrder": {
|
||||
"label": "Kolejność kolumn"
|
||||
"label": ""
|
||||
},
|
||||
"columnWidths": {
|
||||
"label": "Szerokości kolumn"
|
||||
"label": ""
|
||||
}
|
||||
},
|
||||
"errors": {
|
||||
"noColumns": "Wybierz kolumny w elementach",
|
||||
"noCommunications": "Nie można załadować danych z integracji",
|
||||
"noItems": "Nie znaleziono pobrań",
|
||||
"actionFailed": "Akcja nie powiodła się",
|
||||
"actionFailedMessage": "Nie można ukończyć działania. Spróbuj ponownie."
|
||||
"noItems": "",
|
||||
"actionFailed": "",
|
||||
"actionFailedMessage": ""
|
||||
},
|
||||
"items": {
|
||||
"actions": {
|
||||
@@ -3142,7 +3116,7 @@
|
||||
},
|
||||
"id": {
|
||||
"columnTitle": "ID",
|
||||
"detailsTitle": "Identyfikator elementu"
|
||||
"detailsTitle": ""
|
||||
},
|
||||
"integration": {
|
||||
"columnTitle": "Integracja"
|
||||
@@ -3177,7 +3151,7 @@
|
||||
"time": {
|
||||
"columnTitle": "Czas zakończenia",
|
||||
"detailsTitle": "Czas od/do zakończenia",
|
||||
"eta": "Przewidywany czas"
|
||||
"eta": ""
|
||||
},
|
||||
"type": {
|
||||
"columnTitle": "Typ",
|
||||
@@ -3223,21 +3197,21 @@
|
||||
},
|
||||
"globalRatio": "Wskaźnik globalny",
|
||||
"contextMenu": {
|
||||
"copyName": "Kopiuj nazwę",
|
||||
"copyId": "Kopiuj ID",
|
||||
"clearFilters": "Wyczyść filtry"
|
||||
"copyName": "",
|
||||
"copyId": "",
|
||||
"clearFilters": ""
|
||||
},
|
||||
"footer": {
|
||||
"filter": "Filtruj pobierania"
|
||||
"filter": ""
|
||||
},
|
||||
"stats": {
|
||||
"show": "Pokaż globalne statystyki",
|
||||
"hide": "Ukryj globalne statystyki",
|
||||
"overallProgress": "Ogólny postęp kolejki",
|
||||
"totalSize": "Całkowity rozmiar kolejki",
|
||||
"clients": "{count, plural, one {# klient} few {# klientów} many {# klientów} other {# klientów}}"
|
||||
"show": "",
|
||||
"hide": "",
|
||||
"overallProgress": "",
|
||||
"totalSize": "",
|
||||
"clients": ""
|
||||
},
|
||||
"disconnected": "(rozłączono)"
|
||||
"disconnected": ""
|
||||
},
|
||||
"mediaReleases": {
|
||||
"name": "Wydania mediów",
|
||||
@@ -3794,6 +3768,18 @@
|
||||
"label": "Pokaż utwory",
|
||||
"description": "Pokaż całkowitą liczbę utworów w twojej bibliotece muzycznej (Navidrome)"
|
||||
},
|
||||
"showNowPlaying": {
|
||||
"label": "Pokaż teraz odtwarzane",
|
||||
"description": "Pokaż aktualnie odtwarzane utwory (Navidrome)"
|
||||
},
|
||||
"showNowPlayingList": {
|
||||
"label": "Pokaż listę teraz odtwarzanych",
|
||||
"description": "Pokaż poszczególne utwory zamiast tylko aktywnej liczby strumieni (Navidrome)"
|
||||
},
|
||||
"maxNowPlayingItems": {
|
||||
"label": "Maksymalna ilość teraz odtwarzanych elementów",
|
||||
"description": "Maksymalna liczba elementów wyświetlanych na liście teraz odtwarzanych (Navidrome)"
|
||||
},
|
||||
"showLibraryCount": {
|
||||
"label": "Pokaż ilość bibliotek",
|
||||
"description": "Pokaż liczbę bibliotek na twojej instancji (Audiobookshelf)"
|
||||
@@ -3827,6 +3813,8 @@
|
||||
"podcasts": "Podcasty",
|
||||
"listeningTime": "Czas Słuchania",
|
||||
"activeSessions": "Aktywne Sesje",
|
||||
"nowPlaying": "Teraz Odtwarzane",
|
||||
"nowPlayingCount": "{count, plural, one {# stream} other {# streams}}",
|
||||
"error": {
|
||||
"internalServerError": "Nie udało się pobrać danych audio stats"
|
||||
}
|
||||
@@ -3966,19 +3954,11 @@
|
||||
},
|
||||
"showAgent": {
|
||||
"label": "Pokaż wersję agenta"
|
||||
},
|
||||
"columnOrder": {
|
||||
"label": "Kolejność kolumn"
|
||||
},
|
||||
"columnWidths": {
|
||||
"label": "Szerokości kolumn"
|
||||
}
|
||||
},
|
||||
"noRecords": "Nie znaleziono systemów",
|
||||
"error": {
|
||||
"internalServerError": "Nie można połączyć się z Beszel. Sprawdź ustawienia integracji.",
|
||||
"layoutSaveTitle": "Nie można zapisać układu tabeli",
|
||||
"layoutSaveMessage": "Nie można zapisać kolejności kolumn ani ich szerokości. Spróbuj ponownie."
|
||||
"internalServerError": "Nie można połączyć się z Beszel. Sprawdź ustawienia integracji."
|
||||
},
|
||||
"column": {
|
||||
"system": "System",
|
||||
@@ -5376,7 +5356,6 @@
|
||||
},
|
||||
"remove": {
|
||||
"label": "Usuń",
|
||||
"confirm": "Kliknij ponownie, aby trwale usunąć",
|
||||
"notification": {
|
||||
"success": {
|
||||
"title": "Usunięto kontenery",
|
||||
|
||||
@@ -2428,21 +2428,10 @@
|
||||
},
|
||||
"descendingDefaultSort": {
|
||||
"label": "Inverter ordenação"
|
||||
},
|
||||
"columnOrder": {
|
||||
"label": ""
|
||||
},
|
||||
"columnWidths": {
|
||||
"label": ""
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"internalServerError": "Erro ao obter as estatísticas dos contêineres",
|
||||
"layoutSaveTitle": "",
|
||||
"layoutSaveMessage": ""
|
||||
},
|
||||
"empty": {
|
||||
"noContainers": ""
|
||||
"internalServerError": "Erro ao obter as estatísticas dos contêineres"
|
||||
}
|
||||
},
|
||||
"coolify": {
|
||||
@@ -2736,20 +2725,8 @@
|
||||
"showOnlyPlaying": {
|
||||
"label": "Exibir apenas o que está sendo reproduzido",
|
||||
"description": "Desabilitar isso não funcionará para o Plex"
|
||||
},
|
||||
"showBitrate": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
},
|
||||
"showLocation": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
}
|
||||
},
|
||||
"footer": {
|
||||
"streams": "",
|
||||
"totalBitrate": ""
|
||||
},
|
||||
"items": {
|
||||
"currentlyPlaying": "Reproduzindo agora",
|
||||
"user": "Usuário",
|
||||
@@ -2757,9 +2734,6 @@
|
||||
"id": "Id",
|
||||
"status": "",
|
||||
"directPlay": "",
|
||||
"directStream": "",
|
||||
"transcodeVideo": "",
|
||||
"transcodeAudio": "",
|
||||
"transcoding": "",
|
||||
"paused": "",
|
||||
"remaining": "",
|
||||
@@ -3794,6 +3768,18 @@
|
||||
"label": "Mostrar músicas",
|
||||
"description": "Mostrar o número total de músicas na sua biblioteca de música (Navidrome)"
|
||||
},
|
||||
"showNowPlaying": {
|
||||
"label": "Mostrar tocando agora",
|
||||
"description": "Mostrar as faixas que estão sendo reproduzidas no momento (Navidrome)"
|
||||
},
|
||||
"showNowPlayingList": {
|
||||
"label": "Mostrar lista de reprodução atual",
|
||||
"description": "Mostrar faixas individuais em vez de apenas a contagem de streams ativos (Navidrome)"
|
||||
},
|
||||
"maxNowPlayingItems": {
|
||||
"label": "Máximo de itens em reprodução",
|
||||
"description": "Número máximo de faixas para mostrar na lista de reprodução atual (Navidrome)"
|
||||
},
|
||||
"showLibraryCount": {
|
||||
"label": "Mostrar contagem de bibliotecas",
|
||||
"description": "Mostrar o número de bibliotecas na sua instância (Audiobookshelf)"
|
||||
@@ -3827,6 +3813,8 @@
|
||||
"podcasts": "Podcasts",
|
||||
"listeningTime": "Tempo de audição",
|
||||
"activeSessions": "Sessões ativas",
|
||||
"nowPlaying": "Tocando agora",
|
||||
"nowPlayingCount": "{count, plural, one {# transmissão} other {# transmissões}}",
|
||||
"error": {
|
||||
"internalServerError": "Falha ao buscar dados de estatísticas de áudio"
|
||||
}
|
||||
@@ -3966,19 +3954,11 @@
|
||||
},
|
||||
"showAgent": {
|
||||
"label": "Mostrar versão do agente"
|
||||
},
|
||||
"columnOrder": {
|
||||
"label": ""
|
||||
},
|
||||
"columnWidths": {
|
||||
"label": ""
|
||||
}
|
||||
},
|
||||
"noRecords": "Nenhum sistema encontrado",
|
||||
"error": {
|
||||
"internalServerError": "Não foi possível conectar ao Beszel. Verifique as configurações de integração.",
|
||||
"layoutSaveTitle": "",
|
||||
"layoutSaveMessage": ""
|
||||
"internalServerError": "Não foi possível conectar ao Beszel. Verifique as configurações de integração."
|
||||
},
|
||||
"column": {
|
||||
"system": "Sistema",
|
||||
@@ -5376,7 +5356,6 @@
|
||||
},
|
||||
"remove": {
|
||||
"label": "Excluir",
|
||||
"confirm": "",
|
||||
"notification": {
|
||||
"success": {
|
||||
"title": "Contêineres removidos",
|
||||
|
||||
@@ -2428,21 +2428,10 @@
|
||||
},
|
||||
"descendingDefaultSort": {
|
||||
"label": ""
|
||||
},
|
||||
"columnOrder": {
|
||||
"label": ""
|
||||
},
|
||||
"columnWidths": {
|
||||
"label": ""
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"internalServerError": "",
|
||||
"layoutSaveTitle": "",
|
||||
"layoutSaveMessage": ""
|
||||
},
|
||||
"empty": {
|
||||
"noContainers": ""
|
||||
"internalServerError": ""
|
||||
}
|
||||
},
|
||||
"coolify": {
|
||||
@@ -2736,20 +2725,8 @@
|
||||
"showOnlyPlaying": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
},
|
||||
"showBitrate": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
},
|
||||
"showLocation": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
}
|
||||
},
|
||||
"footer": {
|
||||
"streams": "",
|
||||
"totalBitrate": ""
|
||||
},
|
||||
"items": {
|
||||
"currentlyPlaying": "",
|
||||
"user": "",
|
||||
@@ -2757,9 +2734,6 @@
|
||||
"id": "",
|
||||
"status": "",
|
||||
"directPlay": "",
|
||||
"directStream": "",
|
||||
"transcodeVideo": "",
|
||||
"transcodeAudio": "",
|
||||
"transcoding": "",
|
||||
"paused": "",
|
||||
"remaining": "",
|
||||
@@ -3794,6 +3768,18 @@
|
||||
"label": "",
|
||||
"description": ""
|
||||
},
|
||||
"showNowPlaying": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
},
|
||||
"showNowPlayingList": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
},
|
||||
"maxNowPlayingItems": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
},
|
||||
"showLibraryCount": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
@@ -3827,6 +3813,8 @@
|
||||
"podcasts": "",
|
||||
"listeningTime": "",
|
||||
"activeSessions": "",
|
||||
"nowPlaying": "",
|
||||
"nowPlayingCount": "",
|
||||
"error": {
|
||||
"internalServerError": ""
|
||||
}
|
||||
@@ -3966,19 +3954,11 @@
|
||||
},
|
||||
"showAgent": {
|
||||
"label": "Exibir versão do agente"
|
||||
},
|
||||
"columnOrder": {
|
||||
"label": ""
|
||||
},
|
||||
"columnWidths": {
|
||||
"label": ""
|
||||
}
|
||||
},
|
||||
"noRecords": "Nenhum sistema encontrado",
|
||||
"error": {
|
||||
"internalServerError": "",
|
||||
"layoutSaveTitle": "",
|
||||
"layoutSaveMessage": ""
|
||||
"internalServerError": ""
|
||||
},
|
||||
"column": {
|
||||
"system": "Sistema",
|
||||
@@ -5376,7 +5356,6 @@
|
||||
},
|
||||
"remove": {
|
||||
"label": "Remover",
|
||||
"confirm": "",
|
||||
"notification": {
|
||||
"success": {
|
||||
"title": "Contêineres removidos",
|
||||
|
||||
@@ -2428,21 +2428,10 @@
|
||||
},
|
||||
"descendingDefaultSort": {
|
||||
"label": ""
|
||||
},
|
||||
"columnOrder": {
|
||||
"label": ""
|
||||
},
|
||||
"columnWidths": {
|
||||
"label": ""
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"internalServerError": "",
|
||||
"layoutSaveTitle": "",
|
||||
"layoutSaveMessage": ""
|
||||
},
|
||||
"empty": {
|
||||
"noContainers": ""
|
||||
"internalServerError": ""
|
||||
}
|
||||
},
|
||||
"coolify": {
|
||||
@@ -2736,20 +2725,8 @@
|
||||
"showOnlyPlaying": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
},
|
||||
"showBitrate": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
},
|
||||
"showLocation": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
}
|
||||
},
|
||||
"footer": {
|
||||
"streams": "",
|
||||
"totalBitrate": ""
|
||||
},
|
||||
"items": {
|
||||
"currentlyPlaying": "",
|
||||
"user": "",
|
||||
@@ -2757,9 +2734,6 @@
|
||||
"id": "",
|
||||
"status": "",
|
||||
"directPlay": "",
|
||||
"directStream": "",
|
||||
"transcodeVideo": "",
|
||||
"transcodeAudio": "",
|
||||
"transcoding": "",
|
||||
"paused": "",
|
||||
"remaining": "",
|
||||
@@ -3794,6 +3768,18 @@
|
||||
"label": "",
|
||||
"description": ""
|
||||
},
|
||||
"showNowPlaying": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
},
|
||||
"showNowPlayingList": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
},
|
||||
"maxNowPlayingItems": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
},
|
||||
"showLibraryCount": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
@@ -3827,6 +3813,8 @@
|
||||
"podcasts": "",
|
||||
"listeningTime": "",
|
||||
"activeSessions": "",
|
||||
"nowPlaying": "",
|
||||
"nowPlayingCount": "",
|
||||
"error": {
|
||||
"internalServerError": ""
|
||||
}
|
||||
@@ -3966,19 +3954,11 @@
|
||||
},
|
||||
"showAgent": {
|
||||
"label": "Arată versiunea agentului"
|
||||
},
|
||||
"columnOrder": {
|
||||
"label": ""
|
||||
},
|
||||
"columnWidths": {
|
||||
"label": ""
|
||||
}
|
||||
},
|
||||
"noRecords": "Niciun sistem găsit",
|
||||
"error": {
|
||||
"internalServerError": "",
|
||||
"layoutSaveTitle": "",
|
||||
"layoutSaveMessage": ""
|
||||
"internalServerError": ""
|
||||
},
|
||||
"column": {
|
||||
"system": "Sistem",
|
||||
@@ -5376,7 +5356,6 @@
|
||||
},
|
||||
"remove": {
|
||||
"label": "Elimină",
|
||||
"confirm": "",
|
||||
"notification": {
|
||||
"success": {
|
||||
"title": "",
|
||||
|
||||
@@ -2428,21 +2428,10 @@
|
||||
},
|
||||
"descendingDefaultSort": {
|
||||
"label": "Обратная сортировка"
|
||||
},
|
||||
"columnOrder": {
|
||||
"label": ""
|
||||
},
|
||||
"columnWidths": {
|
||||
"label": ""
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"internalServerError": "Не удалось получить статистику контейнеров",
|
||||
"layoutSaveTitle": "",
|
||||
"layoutSaveMessage": ""
|
||||
},
|
||||
"empty": {
|
||||
"noContainers": ""
|
||||
"internalServerError": "Не удалось получить статистику контейнеров"
|
||||
}
|
||||
},
|
||||
"coolify": {
|
||||
@@ -2736,20 +2725,8 @@
|
||||
"showOnlyPlaying": {
|
||||
"label": "Показывать только воспроизводимое",
|
||||
"description": "Отключение этой опции не работает для Plex"
|
||||
},
|
||||
"showBitrate": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
},
|
||||
"showLocation": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
}
|
||||
},
|
||||
"footer": {
|
||||
"streams": "",
|
||||
"totalBitrate": ""
|
||||
},
|
||||
"items": {
|
||||
"currentlyPlaying": "Сейчас воспроизводится",
|
||||
"user": "Пользователь",
|
||||
@@ -2757,9 +2734,6 @@
|
||||
"id": "Id",
|
||||
"status": "",
|
||||
"directPlay": "",
|
||||
"directStream": "",
|
||||
"transcodeVideo": "",
|
||||
"transcodeAudio": "",
|
||||
"transcoding": "",
|
||||
"paused": "",
|
||||
"remaining": "",
|
||||
@@ -3794,6 +3768,18 @@
|
||||
"label": "",
|
||||
"description": ""
|
||||
},
|
||||
"showNowPlaying": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
},
|
||||
"showNowPlayingList": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
},
|
||||
"maxNowPlayingItems": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
},
|
||||
"showLibraryCount": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
@@ -3827,6 +3813,8 @@
|
||||
"podcasts": "",
|
||||
"listeningTime": "",
|
||||
"activeSessions": "",
|
||||
"nowPlaying": "",
|
||||
"nowPlayingCount": "",
|
||||
"error": {
|
||||
"internalServerError": ""
|
||||
}
|
||||
@@ -3966,19 +3954,11 @@
|
||||
},
|
||||
"showAgent": {
|
||||
"label": "Показать версию агента"
|
||||
},
|
||||
"columnOrder": {
|
||||
"label": ""
|
||||
},
|
||||
"columnWidths": {
|
||||
"label": ""
|
||||
}
|
||||
},
|
||||
"noRecords": "Системы не найдены",
|
||||
"error": {
|
||||
"internalServerError": "",
|
||||
"layoutSaveTitle": "",
|
||||
"layoutSaveMessage": ""
|
||||
"internalServerError": ""
|
||||
},
|
||||
"column": {
|
||||
"system": "Система",
|
||||
@@ -5376,7 +5356,6 @@
|
||||
},
|
||||
"remove": {
|
||||
"label": "Удалить",
|
||||
"confirm": "",
|
||||
"notification": {
|
||||
"success": {
|
||||
"title": "Контейнеры удалены",
|
||||
|
||||
@@ -2428,21 +2428,10 @@
|
||||
},
|
||||
"descendingDefaultSort": {
|
||||
"label": ""
|
||||
},
|
||||
"columnOrder": {
|
||||
"label": ""
|
||||
},
|
||||
"columnWidths": {
|
||||
"label": ""
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"internalServerError": "Nepodarilo sa načítať štatistiky kontajnerov",
|
||||
"layoutSaveTitle": "",
|
||||
"layoutSaveMessage": ""
|
||||
},
|
||||
"empty": {
|
||||
"noContainers": ""
|
||||
"internalServerError": "Nepodarilo sa načítať štatistiky kontajnerov"
|
||||
}
|
||||
},
|
||||
"coolify": {
|
||||
@@ -2736,20 +2725,8 @@
|
||||
"showOnlyPlaying": {
|
||||
"label": "Zobraziť len aktuálne prehrávanú",
|
||||
"description": "Zakázanie tohto nebude fungovať pre plex"
|
||||
},
|
||||
"showBitrate": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
},
|
||||
"showLocation": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
}
|
||||
},
|
||||
"footer": {
|
||||
"streams": "",
|
||||
"totalBitrate": ""
|
||||
},
|
||||
"items": {
|
||||
"currentlyPlaying": "Momentálne sa prehráva",
|
||||
"user": "Používateľ",
|
||||
@@ -2757,9 +2734,6 @@
|
||||
"id": "Id",
|
||||
"status": "",
|
||||
"directPlay": "",
|
||||
"directStream": "",
|
||||
"transcodeVideo": "",
|
||||
"transcodeAudio": "",
|
||||
"transcoding": "",
|
||||
"paused": "",
|
||||
"remaining": "",
|
||||
@@ -3794,6 +3768,18 @@
|
||||
"label": "Zobraziť skladby",
|
||||
"description": "Zobraziť celkový počet skladieb vo vašej hudobnej knižnici (Navidrome)"
|
||||
},
|
||||
"showNowPlaying": {
|
||||
"label": "Zobraziť práve hrané",
|
||||
"description": "Zobraziť skladby, ktoré sa práve prehrávajú (Navidrome)"
|
||||
},
|
||||
"showNowPlayingList": {
|
||||
"label": "Zobraziť zoznam práve hraných",
|
||||
"description": "Zobraziť jednotlivé skladby namiesto len počtu aktívnych streamov (Navidrome)"
|
||||
},
|
||||
"maxNowPlayingItems": {
|
||||
"label": "Max. počet položiek práve hraných",
|
||||
"description": "Maximálny počet skladieb zobrazených v zozname práve hraných (Navidrome)"
|
||||
},
|
||||
"showLibraryCount": {
|
||||
"label": "Zobraziť počet knižníc",
|
||||
"description": "Zobraziť počet knižníc na vašej inštancii (Audiobookshelf)"
|
||||
@@ -3827,6 +3813,8 @@
|
||||
"podcasts": "Podcasty",
|
||||
"listeningTime": "Čas počúvania",
|
||||
"activeSessions": "Aktívne relácie",
|
||||
"nowPlaying": "Práve hrané",
|
||||
"nowPlayingCount": "{count, plural, one {# stream} few {# streamy} many {# streamov} other {# streamov}}",
|
||||
"error": {
|
||||
"internalServerError": "Nepodarilo sa načítať údaje audio štatistík"
|
||||
}
|
||||
@@ -3966,19 +3954,11 @@
|
||||
},
|
||||
"showAgent": {
|
||||
"label": "Zobraziť verziu agenta"
|
||||
},
|
||||
"columnOrder": {
|
||||
"label": ""
|
||||
},
|
||||
"columnWidths": {
|
||||
"label": ""
|
||||
}
|
||||
},
|
||||
"noRecords": "Nenašli sa žiadne systémy",
|
||||
"error": {
|
||||
"internalServerError": "",
|
||||
"layoutSaveTitle": "",
|
||||
"layoutSaveMessage": ""
|
||||
"internalServerError": ""
|
||||
},
|
||||
"column": {
|
||||
"system": "Systém",
|
||||
@@ -5376,7 +5356,6 @@
|
||||
},
|
||||
"remove": {
|
||||
"label": "Odstrániť",
|
||||
"confirm": "",
|
||||
"notification": {
|
||||
"success": {
|
||||
"title": "Kontajnery boli odstránené",
|
||||
|
||||
@@ -2428,21 +2428,10 @@
|
||||
},
|
||||
"descendingDefaultSort": {
|
||||
"label": ""
|
||||
},
|
||||
"columnOrder": {
|
||||
"label": ""
|
||||
},
|
||||
"columnWidths": {
|
||||
"label": ""
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"internalServerError": "",
|
||||
"layoutSaveTitle": "",
|
||||
"layoutSaveMessage": ""
|
||||
},
|
||||
"empty": {
|
||||
"noContainers": ""
|
||||
"internalServerError": ""
|
||||
}
|
||||
},
|
||||
"coolify": {
|
||||
@@ -2736,20 +2725,8 @@
|
||||
"showOnlyPlaying": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
},
|
||||
"showBitrate": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
},
|
||||
"showLocation": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
}
|
||||
},
|
||||
"footer": {
|
||||
"streams": "",
|
||||
"totalBitrate": ""
|
||||
},
|
||||
"items": {
|
||||
"currentlyPlaying": "",
|
||||
"user": "",
|
||||
@@ -2757,9 +2734,6 @@
|
||||
"id": "",
|
||||
"status": "",
|
||||
"directPlay": "",
|
||||
"directStream": "",
|
||||
"transcodeVideo": "",
|
||||
"transcodeAudio": "",
|
||||
"transcoding": "",
|
||||
"paused": "",
|
||||
"remaining": "",
|
||||
@@ -3794,6 +3768,18 @@
|
||||
"label": "",
|
||||
"description": ""
|
||||
},
|
||||
"showNowPlaying": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
},
|
||||
"showNowPlayingList": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
},
|
||||
"maxNowPlayingItems": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
},
|
||||
"showLibraryCount": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
@@ -3827,6 +3813,8 @@
|
||||
"podcasts": "",
|
||||
"listeningTime": "",
|
||||
"activeSessions": "",
|
||||
"nowPlaying": "",
|
||||
"nowPlayingCount": "",
|
||||
"error": {
|
||||
"internalServerError": ""
|
||||
}
|
||||
@@ -3966,19 +3954,11 @@
|
||||
},
|
||||
"showAgent": {
|
||||
"label": "Pokaži različico agenta"
|
||||
},
|
||||
"columnOrder": {
|
||||
"label": ""
|
||||
},
|
||||
"columnWidths": {
|
||||
"label": ""
|
||||
}
|
||||
},
|
||||
"noRecords": "Ni najdenih sistemov",
|
||||
"error": {
|
||||
"internalServerError": "",
|
||||
"layoutSaveTitle": "",
|
||||
"layoutSaveMessage": ""
|
||||
"internalServerError": ""
|
||||
},
|
||||
"column": {
|
||||
"system": "Sistem",
|
||||
@@ -5376,7 +5356,6 @@
|
||||
},
|
||||
"remove": {
|
||||
"label": "Odstrani",
|
||||
"confirm": "",
|
||||
"notification": {
|
||||
"success": {
|
||||
"title": "",
|
||||
|
||||
@@ -2428,21 +2428,10 @@
|
||||
},
|
||||
"descendingDefaultSort": {
|
||||
"label": ""
|
||||
},
|
||||
"columnOrder": {
|
||||
"label": ""
|
||||
},
|
||||
"columnWidths": {
|
||||
"label": ""
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"internalServerError": "",
|
||||
"layoutSaveTitle": "",
|
||||
"layoutSaveMessage": ""
|
||||
},
|
||||
"empty": {
|
||||
"noContainers": ""
|
||||
"internalServerError": ""
|
||||
}
|
||||
},
|
||||
"coolify": {
|
||||
@@ -2736,20 +2725,8 @@
|
||||
"showOnlyPlaying": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
},
|
||||
"showBitrate": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
},
|
||||
"showLocation": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
}
|
||||
},
|
||||
"footer": {
|
||||
"streams": "",
|
||||
"totalBitrate": ""
|
||||
},
|
||||
"items": {
|
||||
"currentlyPlaying": "",
|
||||
"user": "",
|
||||
@@ -2757,9 +2734,6 @@
|
||||
"id": "",
|
||||
"status": "",
|
||||
"directPlay": "",
|
||||
"directStream": "",
|
||||
"transcodeVideo": "",
|
||||
"transcodeAudio": "",
|
||||
"transcoding": "",
|
||||
"paused": "",
|
||||
"remaining": "",
|
||||
@@ -3794,6 +3768,18 @@
|
||||
"label": "",
|
||||
"description": ""
|
||||
},
|
||||
"showNowPlaying": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
},
|
||||
"showNowPlayingList": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
},
|
||||
"maxNowPlayingItems": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
},
|
||||
"showLibraryCount": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
@@ -3827,6 +3813,8 @@
|
||||
"podcasts": "",
|
||||
"listeningTime": "",
|
||||
"activeSessions": "",
|
||||
"nowPlaying": "",
|
||||
"nowPlayingCount": "",
|
||||
"error": {
|
||||
"internalServerError": ""
|
||||
}
|
||||
@@ -3966,19 +3954,11 @@
|
||||
},
|
||||
"showAgent": {
|
||||
"label": "Visa agentversion"
|
||||
},
|
||||
"columnOrder": {
|
||||
"label": ""
|
||||
},
|
||||
"columnWidths": {
|
||||
"label": ""
|
||||
}
|
||||
},
|
||||
"noRecords": "Inga system hittades",
|
||||
"error": {
|
||||
"internalServerError": "",
|
||||
"layoutSaveTitle": "",
|
||||
"layoutSaveMessage": ""
|
||||
"internalServerError": ""
|
||||
},
|
||||
"column": {
|
||||
"system": "System",
|
||||
@@ -5376,7 +5356,6 @@
|
||||
},
|
||||
"remove": {
|
||||
"label": "Ta bort",
|
||||
"confirm": "",
|
||||
"notification": {
|
||||
"success": {
|
||||
"title": "",
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -2428,21 +2428,10 @@
|
||||
},
|
||||
"descendingDefaultSort": {
|
||||
"label": "Інверсія сортування"
|
||||
},
|
||||
"columnOrder": {
|
||||
"label": ""
|
||||
},
|
||||
"columnWidths": {
|
||||
"label": ""
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"internalServerError": "Не вдалося отримати статистику контейнерів",
|
||||
"layoutSaveTitle": "",
|
||||
"layoutSaveMessage": ""
|
||||
},
|
||||
"empty": {
|
||||
"noContainers": ""
|
||||
"internalServerError": "Не вдалося отримати статистику контейнерів"
|
||||
}
|
||||
},
|
||||
"coolify": {
|
||||
@@ -2736,20 +2725,8 @@
|
||||
"showOnlyPlaying": {
|
||||
"label": "Показувати тільки поточне відтворення",
|
||||
"description": "Вимкнення цієї опції не працюватиме для Plex."
|
||||
},
|
||||
"showBitrate": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
},
|
||||
"showLocation": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
}
|
||||
},
|
||||
"footer": {
|
||||
"streams": "",
|
||||
"totalBitrate": ""
|
||||
},
|
||||
"items": {
|
||||
"currentlyPlaying": "Зараз відтворюється",
|
||||
"user": "Користувач",
|
||||
@@ -2757,9 +2734,6 @@
|
||||
"id": "Id",
|
||||
"status": "Статус",
|
||||
"directPlay": "Пряме відтворення",
|
||||
"directStream": "",
|
||||
"transcodeVideo": "",
|
||||
"transcodeAudio": "",
|
||||
"transcoding": "Транскодування",
|
||||
"paused": "Призупинено",
|
||||
"remaining": "Залишилося {minutes} хв",
|
||||
@@ -3794,6 +3768,18 @@
|
||||
"label": "Показувати пісні",
|
||||
"description": "Показати загальну кількість пісень у вашій музичній бібліотеці (Navidrome)"
|
||||
},
|
||||
"showNowPlaying": {
|
||||
"label": "Показати, що зараз відтворюються",
|
||||
"description": "Показувати пісні, що зараз відтворюються (Navidrome)"
|
||||
},
|
||||
"showNowPlayingList": {
|
||||
"label": "Показати список відтворення зараз",
|
||||
"description": "Відображати окремі треки замість лише кількості активних потоків (Navidrome)"
|
||||
},
|
||||
"maxNowPlayingItems": {
|
||||
"label": "Максимум елементів для відтворення",
|
||||
"description": "Максимальна кількість треків для відображення у списку відтворення (Navidrome)"
|
||||
},
|
||||
"showLibraryCount": {
|
||||
"label": "Показувати кількість бібліотек",
|
||||
"description": "Показати кількість бібліотек на вашому екземплярі (Audiobookshelf)"
|
||||
@@ -3827,6 +3813,8 @@
|
||||
"podcasts": "Подкасти",
|
||||
"listeningTime": "Час прослуховування",
|
||||
"activeSessions": "Активні Сесії",
|
||||
"nowPlaying": "Зараз програється",
|
||||
"nowPlayingCount": "{count, plural, one {# трансляція} few {# трансляції} many {# трансляцій} other {# трансляцій}}",
|
||||
"error": {
|
||||
"internalServerError": "Не вдалося отримати статистику аудіо"
|
||||
}
|
||||
@@ -3966,19 +3954,11 @@
|
||||
},
|
||||
"showAgent": {
|
||||
"label": "Показати версію агента"
|
||||
},
|
||||
"columnOrder": {
|
||||
"label": ""
|
||||
},
|
||||
"columnWidths": {
|
||||
"label": ""
|
||||
}
|
||||
},
|
||||
"noRecords": "Системи не знайдені",
|
||||
"error": {
|
||||
"internalServerError": "Не вдається підключитися до Beszel. Перевірте параметри інтеграції.",
|
||||
"layoutSaveTitle": "",
|
||||
"layoutSaveMessage": ""
|
||||
"internalServerError": "Не вдається підключитися до Beszel. Перевірте параметри інтеграції."
|
||||
},
|
||||
"column": {
|
||||
"system": "Система",
|
||||
@@ -5376,7 +5356,6 @@
|
||||
},
|
||||
"remove": {
|
||||
"label": "Видалити",
|
||||
"confirm": "",
|
||||
"notification": {
|
||||
"success": {
|
||||
"title": "Контейнери видалено",
|
||||
|
||||
@@ -2428,21 +2428,10 @@
|
||||
},
|
||||
"descendingDefaultSort": {
|
||||
"label": ""
|
||||
},
|
||||
"columnOrder": {
|
||||
"label": ""
|
||||
},
|
||||
"columnWidths": {
|
||||
"label": ""
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"internalServerError": "",
|
||||
"layoutSaveTitle": "",
|
||||
"layoutSaveMessage": ""
|
||||
},
|
||||
"empty": {
|
||||
"noContainers": ""
|
||||
"internalServerError": ""
|
||||
}
|
||||
},
|
||||
"coolify": {
|
||||
@@ -2736,20 +2725,8 @@
|
||||
"showOnlyPlaying": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
},
|
||||
"showBitrate": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
},
|
||||
"showLocation": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
}
|
||||
},
|
||||
"footer": {
|
||||
"streams": "",
|
||||
"totalBitrate": ""
|
||||
},
|
||||
"items": {
|
||||
"currentlyPlaying": "",
|
||||
"user": "",
|
||||
@@ -2757,9 +2734,6 @@
|
||||
"id": "",
|
||||
"status": "",
|
||||
"directPlay": "",
|
||||
"directStream": "",
|
||||
"transcodeVideo": "",
|
||||
"transcodeAudio": "",
|
||||
"transcoding": "",
|
||||
"paused": "",
|
||||
"remaining": "",
|
||||
@@ -3794,6 +3768,18 @@
|
||||
"label": "",
|
||||
"description": ""
|
||||
},
|
||||
"showNowPlaying": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
},
|
||||
"showNowPlayingList": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
},
|
||||
"maxNowPlayingItems": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
},
|
||||
"showLibraryCount": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
@@ -3827,6 +3813,8 @@
|
||||
"podcasts": "",
|
||||
"listeningTime": "",
|
||||
"activeSessions": "",
|
||||
"nowPlaying": "",
|
||||
"nowPlayingCount": "",
|
||||
"error": {
|
||||
"internalServerError": ""
|
||||
}
|
||||
@@ -3966,19 +3954,11 @@
|
||||
},
|
||||
"showAgent": {
|
||||
"label": "Hiển thị phiên bản tác tử"
|
||||
},
|
||||
"columnOrder": {
|
||||
"label": ""
|
||||
},
|
||||
"columnWidths": {
|
||||
"label": ""
|
||||
}
|
||||
},
|
||||
"noRecords": "Không tìm thấy hệ thống",
|
||||
"error": {
|
||||
"internalServerError": "",
|
||||
"layoutSaveTitle": "",
|
||||
"layoutSaveMessage": ""
|
||||
"internalServerError": ""
|
||||
},
|
||||
"column": {
|
||||
"system": "Hệ thống",
|
||||
@@ -5376,7 +5356,6 @@
|
||||
},
|
||||
"remove": {
|
||||
"label": "Xóa",
|
||||
"confirm": "",
|
||||
"notification": {
|
||||
"success": {
|
||||
"title": "",
|
||||
|
||||
@@ -2428,21 +2428,10 @@
|
||||
},
|
||||
"descendingDefaultSort": {
|
||||
"label": ""
|
||||
},
|
||||
"columnOrder": {
|
||||
"label": ""
|
||||
},
|
||||
"columnWidths": {
|
||||
"label": ""
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"internalServerError": "",
|
||||
"layoutSaveTitle": "",
|
||||
"layoutSaveMessage": ""
|
||||
},
|
||||
"empty": {
|
||||
"noContainers": ""
|
||||
"internalServerError": ""
|
||||
}
|
||||
},
|
||||
"coolify": {
|
||||
@@ -2736,20 +2725,8 @@
|
||||
"showOnlyPlaying": {
|
||||
"label": "只顯示目前播放中的項目",
|
||||
"description": "停用此功能將無法在 Plex 上運作"
|
||||
},
|
||||
"showBitrate": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
},
|
||||
"showLocation": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
}
|
||||
},
|
||||
"footer": {
|
||||
"streams": "",
|
||||
"totalBitrate": ""
|
||||
},
|
||||
"items": {
|
||||
"currentlyPlaying": "目前播放中",
|
||||
"user": "使用者",
|
||||
@@ -2757,9 +2734,6 @@
|
||||
"id": "ID",
|
||||
"status": "狀態",
|
||||
"directPlay": "直接播放",
|
||||
"directStream": "",
|
||||
"transcodeVideo": "",
|
||||
"transcodeAudio": "",
|
||||
"transcoding": "轉碼中",
|
||||
"paused": "已暫停",
|
||||
"remaining": "剩餘 {minutes} 分鐘",
|
||||
@@ -3794,6 +3768,18 @@
|
||||
"label": "顯示歌曲",
|
||||
"description": "顯示音樂庫中的歌曲總數 (Navidrome)"
|
||||
},
|
||||
"showNowPlaying": {
|
||||
"label": "顯示正在播放",
|
||||
"description": "顯示目前播放中的曲目 (Navidrome)"
|
||||
},
|
||||
"showNowPlayingList": {
|
||||
"label": "顯示正在播放清單",
|
||||
"description": "顯示個別曲目,而非僅顯示活動串流計數 (Navidrome)"
|
||||
},
|
||||
"maxNowPlayingItems": {
|
||||
"label": "最大正在播放項目數",
|
||||
"description": "在正在播放清單中顯示的最大曲目數量 (Navidrome)"
|
||||
},
|
||||
"showLibraryCount": {
|
||||
"label": "顯示庫數量",
|
||||
"description": "顯示你的執行個體上的庫數量 (Audiobookshelf)"
|
||||
@@ -3827,6 +3813,8 @@
|
||||
"podcasts": "播客",
|
||||
"listeningTime": "聆聽時間",
|
||||
"activeSessions": "活躍會話",
|
||||
"nowPlaying": "正在播放",
|
||||
"nowPlayingCount": "",
|
||||
"error": {
|
||||
"internalServerError": "無法擷取音訊統計資料"
|
||||
}
|
||||
@@ -3966,19 +3954,11 @@
|
||||
},
|
||||
"showAgent": {
|
||||
"label": "顯示代理版本"
|
||||
},
|
||||
"columnOrder": {
|
||||
"label": ""
|
||||
},
|
||||
"columnWidths": {
|
||||
"label": ""
|
||||
}
|
||||
},
|
||||
"noRecords": "找不到系統",
|
||||
"error": {
|
||||
"internalServerError": "無法連線到 Beszel。請檢查整合設定。",
|
||||
"layoutSaveTitle": "",
|
||||
"layoutSaveMessage": ""
|
||||
"internalServerError": "無法連線到 Beszel。請檢查整合設定。"
|
||||
},
|
||||
"column": {
|
||||
"system": "系統",
|
||||
@@ -5376,7 +5356,6 @@
|
||||
},
|
||||
"remove": {
|
||||
"label": "移除",
|
||||
"confirm": "",
|
||||
"notification": {
|
||||
"success": {
|
||||
"title": "容器已移除",
|
||||
|
||||
Loaded 100 of 112 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user