Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
27b04e620e | ||
|
|
95101e34ed | ||
|
|
998615fc11 | ||
|
|
61cbb74d14 | ||
|
|
228fc48e33 | ||
|
|
a67050e77b | ||
|
|
9375738e05 | ||
|
|
75e5a6c42f | ||
|
|
41dba7b516 | ||
|
|
f557fbbae1 | ||
|
|
1dd509a40f | ||
|
|
bb8640b162 | ||
|
|
1cf119c768 | ||
|
|
15f3493b5b | ||
|
|
d32075d410 | ||
|
|
e12531cccc | ||
|
|
6aecd1a713 | ||
|
|
60c0cfa911 | ||
|
|
dba97a3bd6 | ||
|
|
83ee03b192 | ||
|
|
0ac123b59f | ||
|
|
0990e4b552 | ||
|
|
08d571ad74 | ||
|
|
663eb0bf5b | ||
|
|
8ee0d67a36 | ||
|
|
4e1bbf2ae6 | ||
|
|
be100b610e | ||
|
|
531cd0de25 | ||
|
|
8fc28844bb | ||
|
|
4cf10b97c5 | ||
|
|
e057affddf | ||
|
|
e46bfd9146 | ||
|
|
c8d4461ab8 | ||
|
|
52e936f979 | ||
|
|
7a14c255be | ||
|
|
c4b54ed36c | ||
|
|
988ec6bebb | ||
|
|
6a3a8465e7 | ||
|
|
a76e941378 | ||
|
|
75748dfbc3 | ||
|
|
196ac340a1 | ||
|
|
fab69c1c07 | ||
|
|
a2140d0870 | ||
|
|
6029a5b592 | ||
|
|
f92aeba403 | ||
|
|
92afd82d22 | ||
|
|
ea12da991a | ||
|
|
af0b986b3e | ||
|
|
324a643fd1 | ||
|
|
05702bede8 | ||
|
|
9087f4bfdf | ||
|
|
8d7a7bc9b1 | ||
|
|
7b3143357e | ||
|
|
d5f4bd0aab | ||
|
|
21a2db6019 | ||
|
|
d78b581104 | ||
|
|
4291bde94f | ||
|
|
171fe40fd5 | ||
|
|
541d745cea | ||
|
|
508369cb0b | ||
|
|
bf607349e0 | ||
|
|
0bfc0c5d25 | ||
|
|
fdcf3a89c9 | ||
|
|
86b49414f0 | ||
|
|
64fe061fb0 | ||
|
|
f96e25c0a2 | ||
|
|
4526fa6fc4 | ||
|
|
05690cc5e3 | ||
|
|
e9d612051d | ||
|
|
727f97226c | ||
|
|
33e8cc7412 | ||
|
|
f358c33bd3 | ||
|
|
e957c6b601 | ||
|
|
0d14bad10b | ||
|
|
25f8564e97 | ||
|
|
8b4cc5b994 | ||
|
|
856cb2d005 | ||
|
|
10acb2bc76 | ||
|
|
e4c6864a94 | ||
|
|
4e9593b859 | ||
|
|
37ebcfe318 | ||
|
|
5547e52001 | ||
|
|
f5f6388361 | ||
|
|
476fa59079 | ||
|
|
8b3bc03b9b | ||
|
|
d69cda0313 | ||
|
|
9cab001451 | ||
|
|
c627384c4b | ||
|
|
8e4ba37f94 | ||
|
|
2e782ae442 | ||
|
|
19498854fc | ||
|
|
d985fbe69a | ||
|
|
6fc27dfce4 | ||
|
|
83b4631cde | ||
|
|
84851db333 | ||
|
|
0710425e78 | ||
|
|
28927cfa28 | ||
|
|
3a3a8251bd | ||
|
|
bac09d8be5 | ||
|
|
2c1b52aff9 | ||
|
|
e46ea71bc2 | ||
|
|
fb92c3e95c | ||
|
|
38c32cbece | ||
|
|
1bae7352dc | ||
|
|
d7ecdf5567 | ||
|
|
3dca787fa7 | ||
|
|
1f730ab786 | ||
|
|
a3b861e13c | ||
|
|
66ddb45975 | ||
|
|
4de5ab5d41 | ||
|
|
475f472cb0 | ||
|
|
efd958d6cb | ||
|
|
246272669e | ||
|
|
493c22f9da | ||
|
|
1d6745b142 | ||
|
|
309111c600 | ||
|
|
98078556c3 | ||
|
|
5bf1ddde4b | ||
|
|
afa96349c5 | ||
|
|
1aabc4dfb2 | ||
|
|
8f551aa485 | ||
|
|
dda34e7764 | ||
|
|
899060c234 | ||
|
|
f1dbc4420f | ||
|
|
251cedf111 | ||
|
|
cfd8b328b4 | ||
|
|
2623708c6d | ||
|
|
946ea8753f | ||
|
|
f5aaf89a3d | ||
|
|
710197b3a0 | ||
|
|
d993e8d811 | ||
|
|
381832685f | ||
|
|
812b8d8306 | ||
|
|
2b5f873c00 | ||
|
|
20874cb96d | ||
|
|
dc3b00736e | ||
|
|
869efbc638 | ||
|
|
2e044ac871 | ||
|
|
04c02d886d | ||
|
|
2be1ae8011 | ||
|
|
67d4899ed3 | ||
|
|
a6a6734097 | ||
|
|
4ff39b26e9 | ||
|
|
4b304c74bc | ||
|
|
d08bdf3e2d | ||
|
|
7cc2736d68 | ||
|
|
452308336a | ||
|
|
26146f4037 | ||
|
|
b1b839fcd8 | ||
|
|
fef070c42c | ||
|
|
e832bfbbdb | ||
|
|
777473ee36 | ||
|
|
96150f802d | ||
|
|
51fb7cb402 | ||
|
|
3b39e87212 | ||
|
|
d16281bea7 | ||
|
|
79d977c008 | ||
|
|
ff84c2bdb7 | ||
|
|
6d46e91a7c |
No files matched your search
@@ -6,7 +6,8 @@
|
||||
"packageRules": [
|
||||
{
|
||||
"matchPackagePatterns": [
|
||||
"^@homarr/"
|
||||
"^@homarr/",
|
||||
"tsx" // Disabled for now as version 0.14.4 did not work with the current version of homarr. It resulted in a ERR_MODULE_NOT_FOUND error
|
||||
],
|
||||
"enabled": false
|
||||
},
|
||||
@@ -1,31 +0,0 @@
|
||||
name: Build apps and migration script
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: ["*"]
|
||||
push:
|
||||
branches: ["main"]
|
||||
merge_group:
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: ${{ github.ref != 'refs/heads/master' }}
|
||||
|
||||
env:
|
||||
FORCE_COLOR: 3
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Setup
|
||||
uses: ./tooling/github/setup
|
||||
|
||||
- name: Copy env
|
||||
shell: bash
|
||||
run: cp .env.example .env
|
||||
|
||||
- name: Build
|
||||
run: pnpm build
|
||||
@@ -1,4 +1,4 @@
|
||||
name: Code quality analysis
|
||||
name: "[Quality] Code Analysis"
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
@@ -8,7 +8,7 @@ on:
|
||||
merge_group:
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
group: "${{ github.workflow }}-${{ github.ref }}"
|
||||
cancel-in-progress: ${{ github.ref != 'refs/heads/master' }}
|
||||
|
||||
# You can leverage Vercel Remote Caching with Turbo to speed up your builds
|
||||
@@ -72,3 +72,15 @@ jobs:
|
||||
# Only works if you set `reportOnFailure: true` in your vite config as specified above
|
||||
if: always()
|
||||
uses: davelosert/vitest-coverage-report-action@v2
|
||||
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Setup
|
||||
uses: ./tooling/github/setup
|
||||
- name: Copy env
|
||||
shell: bash
|
||||
run: cp .env.example .env
|
||||
- name: Build
|
||||
run: pnpm build
|
||||
@@ -1,7 +1,5 @@
|
||||
|
||||
# https://github.com/webiny/action-conventional-commits?tab=readme-ov-file
|
||||
|
||||
name: Conventional Commits
|
||||
name: "[Conventions] Semantic Commits"
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
@@ -1,4 +1,4 @@
|
||||
name: "Lint PR"
|
||||
name: "[Conventions] Semantic PRs"
|
||||
|
||||
on:
|
||||
pull_request_target:
|
||||
@@ -11,8 +11,7 @@ permissions:
|
||||
pull-requests: read
|
||||
|
||||
jobs:
|
||||
main:
|
||||
name: Validate PR title
|
||||
validate-pull-request-title:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: amannn/action-semantic-pull-request@v5
|
||||
@@ -1,4 +1,4 @@
|
||||
name: Docker image
|
||||
name: "[Deployment] Release"
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
@@ -70,13 +70,13 @@ jobs:
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
images: "${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}"
|
||||
tags: |
|
||||
type=raw,value=latest
|
||||
type=raw,value=${{ steps.semver.outputs.next }}
|
||||
- name: Build and push
|
||||
id: buildPushAction
|
||||
uses: docker/build-push-action@v5
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
platforms: linux/amd64,linux/arm64,linux/riscv64,linux/arm/v7,linux/arm/v6
|
||||
context: .
|
||||
@@ -0,0 +1,82 @@
|
||||
name: "[Deployment] Automatic Weekly Release"
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: "0 19 * * 5" # https://crontab.guru/#0_19_*_*_5
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
create-and-merge-pr:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Discord notification
|
||||
env:
|
||||
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
|
||||
uses: Ilshidur/action-discord@master
|
||||
with:
|
||||
args: "Automatic release has been triggered: [run ${{ github.run_number }}](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }})"
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Get Next Version
|
||||
id: semver
|
||||
uses: ietf-tools/semver-action@v1
|
||||
with:
|
||||
token: ${{ github.token }}
|
||||
branch: dev
|
||||
- name: Create pull request
|
||||
uses: devops-infra/action-pull-request@v0.5.5
|
||||
id: create-pull-request
|
||||
with:
|
||||
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
title: "chore(release): automatic release ${{ steps.semver.outputs.next }}"
|
||||
new_string: "**This is an automatic release**.<br/>Manual action may be required for major bumps.<br/>Detected change to be ``${{ steps.semver.outputs.bump }}``<br/>Bump version from ``${{ steps.semver.outputs.current }}`` to ``${{ steps.semver.outputs.next }}``"
|
||||
source_branch: dev
|
||||
target_branch: main
|
||||
label: automerge
|
||||
- name: Discord notification
|
||||
env:
|
||||
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
|
||||
uses: Ilshidur/action-discord@master
|
||||
with:
|
||||
args: "Created a release PR ${{ steps.create-pull-request.outputs.url }} for version ${{ steps.semver.outputs.next }} (new behaviour: ${{ steps.semver.outputs.bump }})"
|
||||
- id: automerge
|
||||
if: ${{ steps.semver.outputs.bump != 'major' }}
|
||||
name: automerge
|
||||
uses: "pascalgn/automerge-action@v0.16.3"
|
||||
env:
|
||||
GITHUB_TOKEN: "${{ secrets.GITHUB_TOKEN }}"
|
||||
MERGE_METHOD: merge # we prefer merge commits for merging to master
|
||||
MERGE_COMMIT_MESSAGE: "chore(release): automatic release ${{ steps.semver.outputs.next }}"
|
||||
MERGE_DELETE_BRANCH: false # never set to true!
|
||||
PULL_REQUEST: "${{ steps.create-pull-request.outputs.pr_number }}"
|
||||
MERGE_RETRIES: 20 # 20 retries * MERGE_RETRY_SLEEP until step fails
|
||||
MERGE_RETRY_SLEEP: 10000 # 10 seconds * MERGE_RETRIES until step fails
|
||||
MERGE_REQUIRED_APPROVALS: 0 # do not require approvals
|
||||
|
||||
- name: Merged Discord notification
|
||||
if: ${{ steps.automerge.outputs.mergeResult == 'merged' }}
|
||||
env:
|
||||
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
|
||||
uses: Ilshidur/action-discord@master
|
||||
with:
|
||||
args: "Merged PR ${{ steps.create-pull-request.outputs.url }} for release ${{ steps.semver.outputs.next }}"
|
||||
- name: Major Bump Discord notification
|
||||
if: ${{ steps.semver.outputs.bump == 'major' }}
|
||||
env:
|
||||
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
|
||||
uses: Ilshidur/action-discord@master
|
||||
with:
|
||||
args: "The release PR must be manually merged because the next version is a major version: ${{ steps.create-pull-request.outputs.url }} for release ${{ steps.semver.outputs.next }}"
|
||||
- name: Discord Fail Notification
|
||||
if: failure()
|
||||
env:
|
||||
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
|
||||
uses: Ilshidur/action-discord@master
|
||||
with:
|
||||
args: "The automatic release workflow [run ${{ github.run_number }}](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}) has failed"
|
||||
@@ -1,4 +1,4 @@
|
||||
name: Approve Renovate PRs
|
||||
name: "[Dependency Updates] Auto Approve"
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, synchronize]
|
||||
@@ -6,17 +6,20 @@ on:
|
||||
jobs:
|
||||
approve-renovate-prs:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Obtain token
|
||||
id: obtainToken
|
||||
uses: tibdex/github-app-token@v2
|
||||
with:
|
||||
private_key: ${{ secrets.RENOVATE_APPROVE_PRIVATE_KEY }}
|
||||
app_id: ${{ secrets.RENOVATE_APPROVE_APP_ID }}
|
||||
- name: Install GitHub CLI
|
||||
run: sudo apt-get install -y gh
|
||||
|
||||
- name: Approve Renovate PRs
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.RENOVATE_APPROVE_TOKEN }}
|
||||
GITHUB_TOKEN: ${{ steps.obtainToken.outputs.token }}
|
||||
run: |
|
||||
for pr in $(gh pr list --author homarr-renovate[bot] --json number --jq .[].number); do
|
||||
gh pr review $pr --approve --body "Automatically approved by GitHub Action"
|
||||
|
||||
@@ -14,8 +14,8 @@ coverage
|
||||
out/
|
||||
next-env.d.ts
|
||||
|
||||
# nest.js
|
||||
apps/nestjs/dist
|
||||
# artifacts
|
||||
packages/db/migrations/*/migrate.cjs
|
||||
|
||||
# nitro
|
||||
.nitro/
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
<component name="ProjectRunConfigurationManager">
|
||||
<configuration default="false" name="db:migration:mysql:generate" type="js.build_tools.npm">
|
||||
<package-json value="$PROJECT_DIR$/package.json" />
|
||||
<command value="run" />
|
||||
<scripts>
|
||||
<script value="db:migration:mysql:generate" />
|
||||
</scripts>
|
||||
<node-interpreter value="project" />
|
||||
<package-manager value="pnpm" />
|
||||
<envs />
|
||||
<method v="2" />
|
||||
</configuration>
|
||||
</component>
|
||||
@@ -0,0 +1,13 @@
|
||||
<component name="ProjectRunConfigurationManager">
|
||||
<configuration default="false" name="db:migration:sqlite:generate" type="js.build_tools.npm">
|
||||
<package-json value="$PROJECT_DIR$/package.json" />
|
||||
<command value="run" />
|
||||
<scripts>
|
||||
<script value="db:migration:sqlite:generate" />
|
||||
</scripts>
|
||||
<node-interpreter value="project" />
|
||||
<package-manager value="pnpm" />
|
||||
<envs />
|
||||
<method v="2" />
|
||||
</configuration>
|
||||
</component>
|
||||
@@ -0,0 +1,13 @@
|
||||
<component name="ProjectRunConfigurationManager">
|
||||
<configuration default="false" name="db:push" type="js.build_tools.npm">
|
||||
<package-json value="$PROJECT_DIR$/package.json" />
|
||||
<command value="run" />
|
||||
<scripts>
|
||||
<script value="db:push" />
|
||||
</scripts>
|
||||
<node-interpreter value="project" />
|
||||
<package-manager value="pnpm" />
|
||||
<envs />
|
||||
<method v="2" />
|
||||
</configuration>
|
||||
</component>
|
||||
@@ -0,0 +1,13 @@
|
||||
<component name="ProjectRunConfigurationManager">
|
||||
<configuration default="false" name="db:studio" type="js.build_tools.npm">
|
||||
<package-json value="$PROJECT_DIR$/package.json" />
|
||||
<command value="run" />
|
||||
<scripts>
|
||||
<script value="db:studio" />
|
||||
</scripts>
|
||||
<node-interpreter value="project" />
|
||||
<package-manager value="pnpm" />
|
||||
<envs />
|
||||
<method v="2" />
|
||||
</configuration>
|
||||
</component>
|
||||
@@ -0,0 +1,13 @@
|
||||
<component name="ProjectRunConfigurationManager">
|
||||
<configuration default="false" name="dev" type="js.build_tools.npm" nameIsGenerated="true">
|
||||
<package-json value="$PROJECT_DIR$/package.json" />
|
||||
<command value="run" />
|
||||
<scripts>
|
||||
<script value="dev" />
|
||||
</scripts>
|
||||
<node-interpreter value="project" />
|
||||
<package-manager value="pnpm" />
|
||||
<envs />
|
||||
<method v="2" />
|
||||
</configuration>
|
||||
</component>
|
||||
@@ -0,0 +1,13 @@
|
||||
<component name="ProjectRunConfigurationManager">
|
||||
<configuration default="false" name="docker:dev" type="js.build_tools.npm">
|
||||
<package-json value="$PROJECT_DIR$/package.json" />
|
||||
<command value="run" />
|
||||
<scripts>
|
||||
<script value="docker:dev" />
|
||||
</scripts>
|
||||
<node-interpreter value="project" />
|
||||
<package-manager value="pnpm" />
|
||||
<envs />
|
||||
<method v="2" />
|
||||
</configuration>
|
||||
</component>
|
||||
@@ -0,0 +1,13 @@
|
||||
<component name="ProjectRunConfigurationManager">
|
||||
<configuration default="false" name="format" type="js.build_tools.npm">
|
||||
<package-json value="$PROJECT_DIR$/package.json" />
|
||||
<command value="run" />
|
||||
<scripts>
|
||||
<script value="format" />
|
||||
</scripts>
|
||||
<node-interpreter value="project" />
|
||||
<package-manager value="pnpm" />
|
||||
<envs />
|
||||
<method v="2" />
|
||||
</configuration>
|
||||
</component>
|
||||
@@ -0,0 +1,13 @@
|
||||
<component name="ProjectRunConfigurationManager">
|
||||
<configuration default="false" name="format:fix" type="js.build_tools.npm">
|
||||
<package-json value="$PROJECT_DIR$/package.json" />
|
||||
<command value="run" />
|
||||
<scripts>
|
||||
<script value="format:fix" />
|
||||
</scripts>
|
||||
<node-interpreter value="project" />
|
||||
<package-manager value="pnpm" />
|
||||
<envs />
|
||||
<method v="2" />
|
||||
</configuration>
|
||||
</component>
|
||||
@@ -0,0 +1,13 @@
|
||||
<component name="ProjectRunConfigurationManager">
|
||||
<configuration default="false" name="test" type="js.build_tools.npm">
|
||||
<package-json value="$PROJECT_DIR$/package.json" />
|
||||
<command value="run" />
|
||||
<scripts>
|
||||
<script value="test" />
|
||||
</scripts>
|
||||
<node-interpreter value="project" />
|
||||
<package-manager value="pnpm" />
|
||||
<envs />
|
||||
<method v="2" />
|
||||
</configuration>
|
||||
</component>
|
||||
@@ -0,0 +1,13 @@
|
||||
<component name="ProjectRunConfigurationManager">
|
||||
<configuration default="false" name="test:ui" type="js.build_tools.npm">
|
||||
<package-json value="$PROJECT_DIR$/package.json" />
|
||||
<command value="run" />
|
||||
<scripts>
|
||||
<script value="test:ui" />
|
||||
</scripts>
|
||||
<node-interpreter value="project" />
|
||||
<package-manager value="pnpm" />
|
||||
<envs />
|
||||
<method v="2" />
|
||||
</configuration>
|
||||
</component>
|
||||
@@ -4,13 +4,23 @@
|
||||
"mode": "auto"
|
||||
}
|
||||
],
|
||||
"eslint.experimental.useFlatConfig": true,
|
||||
"typescript.tsdk": "node_modules\\typescript\\lib",
|
||||
"js/ts.implicitProjectConfig.experimentalDecorators": true,
|
||||
"prettier.configPath": "./tooling/prettier/index.mjs",
|
||||
"cSpell.words": [
|
||||
"superjson",
|
||||
"cqmin",
|
||||
"homarr",
|
||||
"jellyfin",
|
||||
"superjson",
|
||||
"trpc",
|
||||
"Umami"
|
||||
]
|
||||
}
|
||||
"Umami",
|
||||
"Sonarr"
|
||||
],
|
||||
"i18n-ally.dirStructure": "auto",
|
||||
"i18n-ally.enabledFrameworks": ["next-international"],
|
||||
"i18n-ally.localesPaths": ["./packages/translation/src/lang/"],
|
||||
"i18n-ally.enabledParsers": ["ts"],
|
||||
"i18n-ally.extract.keyMaxLength": 0,
|
||||
"i18n-ally.keystyle": "flat"
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
FROM node:20.14.0-alpine AS base
|
||||
FROM node:20.15.0-alpine AS base
|
||||
|
||||
FROM base AS builder
|
||||
RUN apk add --no-cache libc6-compat
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import baseConfig from "@homarr/eslint-config/base";
|
||||
import nextjsConfig from "@homarr/eslint-config/nextjs";
|
||||
import reactConfig from "@homarr/eslint-config/react";
|
||||
|
||||
/** @type {import('typescript-eslint').Config} */
|
||||
export default [
|
||||
{
|
||||
ignores: [".next/**"],
|
||||
},
|
||||
...baseConfig,
|
||||
...reactConfig,
|
||||
...nextjsConfig,
|
||||
];
|
||||
@@ -10,12 +10,10 @@ const config = {
|
||||
eslint: { ignoreDuringBuilds: true },
|
||||
typescript: { ignoreBuildErrors: true },
|
||||
webpack: (config) => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access
|
||||
config.module.rules.push({
|
||||
test: /\.node$/,
|
||||
loader: "node-loader",
|
||||
});
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
|
||||
return config;
|
||||
},
|
||||
experimental: {
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
"build": "pnpm with-env next build",
|
||||
"clean": "git clean -xdf .next .turbo node_modules",
|
||||
"dev": "pnpm with-env next dev",
|
||||
"lint": "dotenv -v SKIP_ENV_VALIDATION=1 next lint",
|
||||
"lint": "eslint",
|
||||
"format": "prettier --check . --ignore-path ../../.gitignore",
|
||||
"start": "pnpm with-env next start",
|
||||
"typecheck": "tsc --noEmit",
|
||||
@@ -18,6 +18,7 @@
|
||||
"@homarr/api": "workspace:^0.1.0",
|
||||
"@homarr/auth": "workspace:^0.1.0",
|
||||
"@homarr/common": "workspace:^0.1.0",
|
||||
"@homarr/cron-job-status": "workspace:^0.1.0",
|
||||
"@homarr/db": "workspace:^0.1.0",
|
||||
"@homarr/definitions": "workspace:^0.1.0",
|
||||
"@homarr/form": "workspace:^0.1.0",
|
||||
@@ -31,16 +32,18 @@
|
||||
"@homarr/ui": "workspace:^0.1.0",
|
||||
"@homarr/validation": "workspace:^0.1.0",
|
||||
"@homarr/widgets": "workspace:^0.1.0",
|
||||
"@mantine/colors-generator": "^7.10.1",
|
||||
"@mantine/hooks": "^7.10.1",
|
||||
"@mantine/modals": "^7.10.1",
|
||||
"@mantine/tiptap": "^7.10.1",
|
||||
"@mantine/colors-generator": "^7.11.1",
|
||||
"@mantine/core": "^7.11.1",
|
||||
"@mantine/hooks": "^7.11.1",
|
||||
"@mantine/modals": "^7.11.1",
|
||||
"@mantine/tiptap": "^7.11.1",
|
||||
"@homarr/server-settings": "workspace:^0.1.0",
|
||||
"@t3-oss/env-nextjs": "^0.10.1",
|
||||
"@tanstack/react-query": "^5.40.0",
|
||||
"@tanstack/react-query-devtools": "^5.40.0",
|
||||
"@tanstack/react-query-next-experimental": "5.40.0",
|
||||
"@trpc/client": "11.0.0-rc.382",
|
||||
"@tanstack/react-query": "^5.50.1",
|
||||
"@tanstack/react-query-devtools": "^5.50.1",
|
||||
"@tanstack/react-query-next-experimental": "5.50.1",
|
||||
"@tabler/icons-react": "^3.9.0",
|
||||
"@trpc/client": "next",
|
||||
"@trpc/next": "next",
|
||||
"@trpc/react-query": "next",
|
||||
"@trpc/server": "next",
|
||||
@@ -52,15 +55,17 @@
|
||||
"dayjs": "^1.11.11",
|
||||
"dotenv": "^16.4.5",
|
||||
"flag-icons": "^7.2.3",
|
||||
"glob": "^10.4.1",
|
||||
"jotai": "^2.8.2",
|
||||
"mantine-react-table": "2.0.0-beta.3",
|
||||
"next": "^14.2.3",
|
||||
"glob": "^10.4.3",
|
||||
"jotai": "^2.8.4",
|
||||
"mantine-react-table": "2.0.0-beta.5",
|
||||
"next": "^14.2.4",
|
||||
"postcss-preset-mantine": "^1.15.0",
|
||||
"react": "18.3.1",
|
||||
"react-dom": "18.3.1",
|
||||
"prismjs": "^1.29.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-error-boundary": "^4.0.13",
|
||||
"sass": "^1.77.3",
|
||||
"react-simple-code-editor": "^0.14.1",
|
||||
"sass": "^1.77.6",
|
||||
"superjson": "2.2.1",
|
||||
"use-deep-compare-effect": "^1.8.1"
|
||||
},
|
||||
@@ -69,23 +74,15 @@
|
||||
"@homarr/prettier-config": "workspace:^0.1.0",
|
||||
"@homarr/tsconfig": "workspace:^0.1.0",
|
||||
"@types/chroma-js": "2.4.4",
|
||||
"@types/node": "^20.12.13",
|
||||
"@types/node": "^20.14.10",
|
||||
"@types/prismjs": "^1.26.4",
|
||||
"@types/react": "^18.3.3",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"concurrently": "^8.2.2",
|
||||
"eslint": "^8.57.0",
|
||||
"eslint": "^9.6.0",
|
||||
"node-loader": "^2.0.0",
|
||||
"prettier": "^3.2.5",
|
||||
"tsx": "4.11.0",
|
||||
"typescript": "^5.4.5"
|
||||
},
|
||||
"eslintConfig": {
|
||||
"root": true,
|
||||
"extends": [
|
||||
"@homarr/eslint-config/base",
|
||||
"@homarr/eslint-config/nextjs",
|
||||
"@homarr/eslint-config/react"
|
||||
]
|
||||
"prettier": "^3.3.2",
|
||||
"typescript": "^5.5.3"
|
||||
},
|
||||
"prettier": "@homarr/prettier-config"
|
||||
}
|
||||
|
After Width: | Height: | Size: 497 B |
@@ -0,0 +1,25 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<svg width="1024" height="1024" version="1.1" viewBox="0 0 1024 1024" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<clipPath id="a">
|
||||
<circle cx="512" cy="512" r="410" fill="#989898" stroke-width="3.5906"/>
|
||||
</clipPath>
|
||||
<clipPath id="b">
|
||||
<path d="m512 636l0-565 -330 565 330 0z" fill="#000"/>
|
||||
<path d="m512 388l0 565 330-565 -330 0z" fill="#000"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
<g id="lidarr-group-logo" transform="translate(5e-6 .26738)">
|
||||
<circle id="lidarr-black-circle" cx="512" cy="512" r="410" stroke-width="1.8"/>
|
||||
<path id="lidarr-honeycomb-a" d="m852.12 33.49l23.46 14.44 23.22-14.44 0-28.87 -23.22-14.44 -23.46 14.44 0 28.87zm-46.44-28.87l-23.22-14.44 -23.2 14.44 0 28.87 23.2 14.44 23.22-14.44 0-28.87 23.22-14.44 23.22 14.44m-46.44 28.87l23.22 14.44 23.22-14.44m-46.44 57.49l0 28.87 23.22 14.44 23.22-14.44 0-28.87 -23.22-14.18 -23.22 14.18 -23.22-14.18 -23.2 14.18m92.86 28.87l23.46 14.44 23.22-14.44 0-28.87 -23.22-14.18 -23.46 14.18m23.46-43.05l0 28.87m-46.68-28.87l0 28.87m69.9-72.18l23.22-14.44 23.22 14.44 23.22-14.44 23.22 14.44 23.2-14.44 23.22 14.44 0 28.87 23.22 14.44 0 28.87 -23.22 14.18 0 28.87 23.22 14.44 0 28.87 -23.22 14.44 0 28.87 23.22 14.44 0 28.87 -23.22 14.16 0 28.87 23.22 14.44 0 28.87 -23.22 14.44 0 28.87 23.22 14.44 0 28.87 -23.22 14.16 0 28.87 23.22 14.44 0 28.87 -23.22 14.45 0 28.87 23.22 14.44 0 28.87 -23.22 14.16 0 28.87 23.22 14.44 0 28.87 -23.22 14.44 0 28.87 23.22 14.44 0 28.87 -23.22 14.16 0 28.87 23.22 14.44 0 28.87 -23.22 14.44 0 28.87 23.22 14.44 0 28.87 -23.22 14.18 0 28.87 23.22 14.44 0 28.87 -23.22 14.44 -23.22-14.44 -23.2 14.44 -23.22-14.44 -23.22 14.44 -23.22-14.44 -23.22 14.44 -23.22-14.44 -23.46 14.44 -23.22-14.44 -23.22 14.44 -23.22-14.44 -23.2 14.44 -23.22-14.44 -23.22 14.44 -23.22-14.44 -23.22 14.44 -23.22-14.44 -23.46 14.44 -23.22-14.44 -23.22 14.44 -23.22-14.44 -23.2 14.44 -23.22-14.44 -23.22 14.44 -23.22-14.44 -23.22 14.44 -23.22-14.44 -23.2 14.44 -23.22-14.44 -23.22 14.44 -23.22-14.44 -23.46 14.44 -23.22-14.44 -23.22 14.44 -23.22-14.44 -23.22 14.44 -23.22-14.44 -23.22 14.44 -23.2-14.44 -23.22 14.44 -23.22-14.44 -23.46 14.44 -23.22-14.44 -23.22 14.44 -23.22-14.44 -23.22 14.44 -23.22-14.44 0-28.87 -23.22-14.44 0-28.87 23.22-14.18 0-28.87 -23.22-14.44 0-28.87 23.22-14.44 0-28.87 -23.22-14.44 0-28.87 23.22-14.16 0-28.87 -23.22-14.44 0-28.87 23.22-14.44 0-28.87 -23.22-14.44 0-28.87 23.22-14.16 0-28.87 -23.22-14.44 0-28.87 23.22-14.45 0-28.87 -23.22-14.44 0-28.87 23.22-14.16 0-28.87 -23.22-14.44 0-28.87 23.22-14.44 0-28.87 -23.22-14.44 0-28.87 23.22-14.16 0-28.87 -23.22-14.44 0-28.87 23.22-14.44 0-28.87 -23.22-14.44 0-28.87 23.22-14.18 0-28.87 -23.22-14.44 0-28.87 23.22-14.44 23.22 14.44 23.22-14.44 23.22 14.44 23.22-14.44 23.22 14.44 23.46-14.44 23.22 14.44 23.22-14.44 23.2 14.44 23.22-14.44 23.22 14.44 23.22-14.44 23.22 14.44 23.22-14.44 23.22 14.44 23.46-14.44 23.22 14.44 23.22-14.44 23.22 14.44 23.2-14.44 23.22 14.44 23.22-14.44 23.22 14.44 23.22-14.44 23.22 14.44 23.2-14.44 23.22 14.44 23.22-14.44 23.22 14.44 23.46-14.44 23.22 14.44 23.22-14.44 23.22 14.44 23.22-14.44 23.22 14.44m-46.44 0l0 28.87 23.22 14.44 23.22-14.44m-46.44 57.49l0 28.87 23.22 14.44 23.22-14.44 0-28.87 -23.22-14.18 -23.22 14.18 -23.22-14.18 -23.22 14.18m116.08-43.05l0 28.87m-46.42-28.87l0 28.87m23.22 100.79l0 28.87 23.2 14.44 23.22-14.44 0-28.87 -23.22-14.44 -23.2 14.44 -23.22-14.44 -23.22 14.44 0 28.87 23.22 14.44 23.22-14.44m46.42-86.61l-23.22 14.44 0 28.87m-23.2-43.31l23.2 14.44m-46.42 0l0 28.87m0 57.74l0 28.87 23.22 14.16 23.2-14.16 0-28.87m46.44-86.61l0 28.87 23.22 14.44 23.46-14.44 0-28.87m-46.68 28.87l-23.22 14.44m-23.22 72.18l23.22 14.16 23.22-14.16 0-28.87 -23.22-14.44m46.44-28.87l0 28.87 23.46 14.44 23.22-14.44 0-28.87 -23.22-14.44m0 57.74l0 28.87 23.22 14.16 23.22-14.16 0-28.87 -23.22-14.44m-69.9 14.44l23.22-14.44m-185.74-201.84l0 28.87 23.22 14.44 23.22-14.44m-139.56-28.87l0 28.87 23.22 14.44 23.22-14.44 0-28.87m0 28.87l23.46 14.44 23.22-14.44m-46.68 57.49l0 28.87 23.46 14.44 23.22-14.44 0-28.87 -23.22-14.18 -23.46 14.18 -23.22-14.18 -23.22 14.18 0 28.87 23.22 14.44 23.22-14.44m-23.22-71.92l0 28.87m93.12-28.87l0 28.87m-46.44-28.87l0 28.87m-116.32-72.18l0 28.87 23.2 14.44 23.22-14.44m-92.86-28.87l0 28.87 23.22 14.44 23.22-14.44m-23.22 43.31l-23.22 14.18 0 28.87 23.22 14.44 23.22-14.44 0-28.87 -23.22-14.18zm69.64 14.18l-23.22-14.18 -23.2 14.18m-23.22-43.05l0 28.87m46.42-28.87l0 28.87m23.22 43.05l-23.22 14.44 0 28.87 23.22 14.44 23.22-14.44 0-28.87m-92.86 0l0 28.87 23.22 14.44 23.2-14.44m-46.42 86.61l23.22 14.16 23.2-14.16 0-28.87 -23.2-14.44 -23.22 14.44 0 28.87 -23.22 14.16 0 28.87 23.22 14.44 23.22-14.44 0-28.87m23.2-43.03l23.22-14.44 0-28.87m-46.42-57.74l23.2 14.44m-23.2 72.18l0-28.87m116.32-43.31l0 28.87 23.22 14.44 23.22-14.44 0-28.87 -23.22-14.44m-93.12 86.61l23.22 14.44 23.22-14.44 0-28.87 -23.22-14.44m46.68 0l-23.46 14.44m23.46 72.18l23.22 14.16 23.22-14.16 0-28.87 -23.22-14.44 -23.22 14.44 0 28.87 -23.46 14.16 0 28.87 -23.22 14.44 0 28.87 23.22 14.44 23.46-14.44 0-28.87 -23.46-14.44m-69.66-43.03l23.22 14.16 23.22-14.16 0-28.87m23.22-14.44l23.46 14.44m23.22-14.44l0-28.87m0 86.34l0 28.87 23.22 14.44 23.22-14.44 0-28.87 -23.22-14.16m-69.9 100.77l0 28.87 23.46 14.44 23.22-14.44 0-28.87 -23.22-14.44m23.22 43.31l23.22 14.44 23.22-14.44 0-28.87 -23.22-14.44 -23.22 14.44m-23.22-43.31l23.22-14.44m23.22 14.44l0 28.87m-93.12-28.87l-23.22-14.44 -23.22 14.44 0 28.87 23.22 14.44 23.22-14.44m-23.22-43.31l0-28.87m-69.64 72.18l-23.22 14.44 0 28.87 23.22 14.44 23.22-14.44 0-28.87 -23.22-14.44zm23.22 4Line truncated
|
||||
<ellipse cx="512" cy="512" rx="460" ry="460" style="fill:none;stroke-width:99;stroke:#e5e5e5"/>
|
||||
<ellipse cx="512" cy="512" rx="270" ry="270" style="fill:#e5e5e5;stroke-width:76;stroke:#e5e5e5"/>
|
||||
<ellipse ry="410" rx="410" cy="512" cx="512" style="fill-opacity:null;fill:none;stroke-width:12;stroke:#009252"/>
|
||||
<path d="m512 636l0-565 -330 565 330 0z" fill="#000"/>
|
||||
<path d="m512 388l0 565 330-565 -330 0z" fill="#000"/>
|
||||
<path id="lidarr-honeycomb-b" d="m852.12 33.49l23.46 14.44 23.22-14.44 0-28.87 -23.22-14.44 -23.46 14.44 0 28.87zm-46.44-28.87l-23.22-14.44 -23.2 14.44 0 28.87 23.2 14.44 23.22-14.44 0-28.87 23.22-14.44 23.22 14.44m-46.44 28.87l23.22 14.44 23.22-14.44m-46.44 57.49l0 28.87 23.22 14.44 23.22-14.44 0-28.87 -23.22-14.18 -23.22 14.18 -23.22-14.18 -23.2 14.18m92.86 28.87l23.46 14.44 23.22-14.44 0-28.87 -23.22-14.18 -23.46 14.18m23.46-43.05l0 28.87m-46.68-28.87l0 28.87m69.9-72.18l23.22-14.44 23.22 14.44 23.22-14.44 23.22 14.44 23.2-14.44 23.22 14.44 0 28.87 23.22 14.44 0 28.87 -23.22 14.18 0 28.87 23.22 14.44 0 28.87 -23.22 14.44 0 28.87 23.22 14.44 0 28.87 -23.22 14.16 0 28.87 23.22 14.44 0 28.87 -23.22 14.44 0 28.87 23.22 14.44 0 28.87 -23.22 14.16 0 28.87 23.22 14.44 0 28.87 -23.22 14.45 0 28.87 23.22 14.44 0 28.87 -23.22 14.16 0 28.87 23.22 14.44 0 28.87 -23.22 14.44 0 28.87 23.22 14.44 0 28.87 -23.22 14.16 0 28.87 23.22 14.44 0 28.87 -23.22 14.44 0 28.87 23.22 14.44 0 28.87 -23.22 14.18 0 28.87 23.22 14.44 0 28.87 -23.22 14.44 -23.22-14.44 -23.2 14.44 -23.22-14.44 -23.22 14.44 -23.22-14.44 -23.22 14.44 -23.22-14.44 -23.46 14.44 -23.22-14.44 -23.22 14.44 -23.22-14.44 -23.2 14.44 -23.22-14.44 -23.22 14.44 -23.22-14.44 -23.22 14.44 -23.22-14.44 -23.46 14.44 -23.22-14.44 -23.22 14.44 -23.22-14.44 -23.2 14.44 -23.22-14.44 -23.22 14.44 -23.22-14.44 -23.22 14.44 -23.22-14.44 -23.2 14.44 -23.22-14.44 -23.22 14.44 -23.22-14.44 -23.46 14.44 -23.22-14.44 -23.22 14.44 -23.22-14.44 -23.22 14.44 -23.22-14.44 -23.22 14.44 -23.2-14.44 -23.22 14.44 -23.22-14.44 -23.46 14.44 -23.22-14.44 -23.22 14.44 -23.22-14.44 -23.22 14.44 -23.22-14.44 0-28.87 -23.22-14.44 0-28.87 23.22-14.18 0-28.87 -23.22-14.44 0-28.87 23.22-14.44 0-28.87 -23.22-14.44 0-28.87 23.22-14.16 0-28.87 -23.22-14.44 0-28.87 23.22-14.44 0-28.87 -23.22-14.44 0-28.87 23.22-14.16 0-28.87 -23.22-14.44 0-28.87 23.22-14.45 0-28.87 -23.22-14.44 0-28.87 23.22-14.16 0-28.87 -23.22-14.44 0-28.87 23.22-14.44 0-28.87 -23.22-14.44 0-28.87 23.22-14.16 0-28.87 -23.22-14.44 0-28.87 23.22-14.44 0-28.87 -23.22-14.44 0-28.87 23.22-14.18 0-28.87 -23.22-14.44 0-28.87 23.22-14.44 23.22 14.44 23.22-14.44 23.22 14.44 23.22-14.44 23.22 14.44 23.46-14.44 23.22 14.44 23.22-14.44 23.2 14.44 23.22-14.44 23.22 14.44 23.22-14.44 23.22 14.44 23.22-14.44 23.22 14.44 23.46-14.44 23.22 14.44 23.22-14.44 23.22 14.44 23.2-14.44 23.22 14.44 23.22-14.44 23.22 14.44 23.22-14.44 23.22 14.44 23.2-14.44 23.22 14.44 23.22-14.44 23.22 14.44 23.46-14.44 23.22 14.44 23.22-14.44 23.22 14.44 23.22-14.44 23.22 14.44m-46.44 0l0 28.87 23.22 14.44 23.22-14.44m-46.44 57.49l0 28.87 23.22 14.44 23.22-14.44 0-28.87 -23.22-14.18 -23.22 14.18 -23.22-14.18 -23.22 14.18m116.08-43.05l0 28.87m-46.42-28.87l0 28.87m23.22 100.79l0 28.87 23.2 14.44 23.22-14.44 0-28.87 -23.22-14.44 -23.2 14.44 -23.22-14.44 -23.22 14.44 0 28.87 23.22 14.44 23.22-14.44m46.42-86.61l-23.22 14.44 0 28.87m-23.2-43.31l23.2 14.44m-46.42 0l0 28.87m0 57.74l0 28.87 23.22 14.16 23.2-14.16 0-28.87m46.44-86.61l0 28.87 23.22 14.44 23.46-14.44 0-28.87m-46.68 28.87l-23.22 14.44m-23.22 72.18l23.22 14.16 23.22-14.16 0-28.87 -23.22-14.44m46.44-28.87l0 28.87 23.46 14.44 23.22-14.44 0-28.87 -23.22-14.44m0 57.74l0 28.87 23.22 14.16 23.22-14.16 0-28.87 -23.22-14.44m-69.9 14.44l23.22-14.44m-185.74-201.84l0 28.87 23.22 14.44 23.22-14.44m-139.56-28.87l0 28.87 23.22 14.44 23.22-14.44 0-28.87m0 28.87l23.46 14.44 23.22-14.44m-46.68 57.49l0 28.87 23.46 14.44 23.22-14.44 0-28.87 -23.22-14.18 -23.46 14.18 -23.22-14.18 -23.22 14.18 0 28.87 23.22 14.44 23.22-14.44m-23.22-71.92l0 28.87m93.12-28.87l0 28.87m-46.44-28.87l0 28.87m-116.32-72.18l0 28.87 23.2 14.44 23.22-14.44m-92.86-28.87l0 28.87 23.22 14.44 23.22-14.44m-23.22 43.31l-23.22 14.18 0 28.87 23.22 14.44 23.22-14.44 0-28.87 -23.22-14.18zm69.64 14.18l-23.22-14.18 -23.2 14.18m-23.22-43.05l0 28.87m46.42-28.87l0 28.87m23.22 43.05l-23.22 14.44 0 28.87 23.22 14.44 23.22-14.44 0-28.87m-92.86 0l0 28.87 23.22 14.44 23.2-14.44m-46.42 86.61l23.22 14.16 23.2-14.16 0-28.87 -23.2-14.44 -23.22 14.44 0 28.87 -23.22 14.16 0 28.87 23.22 14.44 23.22-14.44 0-28.87m23.2-43.03l23.22-14.44 0-28.87m-46.42-57.74l23.2 14.44m-23.2 72.18l0-28.87m116.32-43.31l0 28.87 23.22 14.44 23.22-14.44 0-28.87 -23.22-14.44m-93.12 86.61l23.22 14.44 23.22-14.44 0-28.87 -23.22-14.44m46.68 0l-23.46 14.44m23.46 72.18l23.22 14.16 23.22-14.16 0-28.87 -23.22-14.44 -23.22 14.44 0 28.87 -23.46 14.16 0 28.87 -23.22 14.44 0 28.87 23.22 14.44 23.46-14.44 0-28.87 -23.46-14.44m-69.66-43.03l23.22 14.16 23.22-14.16 0-28.87m23.22-14.44l23.46 14.44m23.22-14.44l0-28.87m0 86.34l0 28.87 23.22 14.44 23.22-14.44 0-28.87 -23.22-14.16m-69.9 100.77l0 28.87 23.46 14.44 23.22-14.44 0-28.87 -23.22-14.44m23.22 43.31l23.22 14.44 23.22-14.44 0-28.87 -23.22-14.44 -23.22 14.44m-23.22-43.31l23.22-14.44m23.22 14.44l0 28.87m-93.12-28.87l-23.22-14.44 -23.22 14.44 0 28.87 23.22 14.44 23.22-14.44m-23.22-43.31l0-28.87m-69.64 72.18l-23.22 14.44 0 28.87 23.22 14.44 23.22-14.44 0-28.87 -23.22-14.44zm23.22 4Line truncated
|
||||
<path d="m512 636l0-565 -330 565 330 0z" style="fill:none;stroke-width:16;stroke:#009252;"/>
|
||||
<path d="m512 388l0 565 330-565 -330 0z" style="fill:none;stroke-width:16;stroke:#009252;"/>
|
||||
<ellipse cx="512" cy="512" rx="150" ry="150" style="fill-opacity:null;fill:#009252;stroke-opacity:null;stroke-width:null;stroke:null"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 54 KiB |
@@ -0,0 +1 @@
|
||||
<svg height="1024" viewBox="0 0 1024 1024" width="1024" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"><defs><path id="a" d="m0 0h1024v1024h-1024z"/><clipPath id="b"><use clip-rule="evenodd" xlink:href="#a"/></clipPath></defs><g clip-path="url(#b)"><use fill="none" xlink:href="#a"/><g transform="translate(70 21.00012)"><path d="m105.302 154.943 7.522 714.549c-60.173 7.522-105.30242-22.565-105.30242-82.737l-7.52158-594.205c0-188.03894 172.996-233.1684 278.298-157.9526l534.032 308.3846c75.216 52.651 90.259 150.431 52.651 218.125-7.521-52.651-30.086-82.737-75.216-112.823l-601.726-338.471c-45.129-30.0862-82.737-22.5646-82.737 45.13z" fill="#24292e"/><path d="m0 376.079c45.1295 15.043 90.259 7.521 127.867-15.043l616.769-361.036c37.608 52.651 30.087 105.302-15.043 135.388l-518.989 300.863c-75.216 37.608-172.9961 0-210.604-60.172z" fill="#24292e" transform="translate(60.17249 531.0214)"/><path d="m0 413.687 368.557-210.604-361.03543-203.083z" fill="#ffc230" transform="translate(240.6902 282.8092)"/></g></g></svg>
|
||||
|
After Width: | Height: | Size: 1.0 KiB |
@@ -0,0 +1 @@
|
||||
<svg version="1.1" viewBox="0 0 1000 1000" xmlns="http://www.w3.org/2000/svg"><g transform="matrix(.97895 0 0 .97895 -2.2026 -2.2026)"><g stroke="#443c3c" stroke-width="1.5"><circle cx="513" cy="513" r="510" fill="#eee"/><circle cx="513" cy="513" r="440" fill="#443c3c"/><circle cx="513" cy="513" r="387" fill="#8e2222"/></g><g stroke-width="1.5"><circle cx="513" cy="513" r="378" fill="#eee" stroke="#8e2222"/><circle cx="511.67" cy="514.33" r="265" fill="#443c3c" stroke="#443c3c"/></g><g stroke="#8e2222"><path d="m176.71 682.24-5.71-356.67c0.634-53.106 17.5-47.829 30.454-49.405 198.58 10.83 270.91 71.252 275.35 73.499 13.323 5.018 20.937 31.782 20.302 31.123 0.634 0.658 4.441 420.6 3.807 419.94 3.172 22.455-13.323 21.002-13.958 20.343-124.99-98.152-297.56-122.85-298.19-123.51-12.055-0.795-12.055-15.326-12.055-15.326zm670.08 0.82 5.711-357.54c-0.635-53.236-17.501-47.946-30.456-49.526-198.6 10.857-270.93 71.426-275.38 73.679-13.325 5.03-20.939 31.859-20.304 31.199-0.635 0.66-4.442 421.63-3.807 420.97-3.173 22.51 13.325 21.053 13.959 20.393 125-98.392 297.58-123.15 298.22-123.81 12.056-0.797 12.056-15.363 12.056-15.363z" fill="#eee" stroke-width="10"/><path d="m174.14 739.57-5.802-356.67c0.645-53.106 17.782-47.829 30.945-49.405 201.79 10.83 275.28 71.252 279.8 73.499 13.539 5.018 21.275 31.782 20.63 31.123 0.645 0.658 4.513 420.6 3.868 419.94 3.224 22.455-13.539 21.002-14.183 20.343-127-98.152-302.36-122.85-303.01-123.51-12.249-0.795-12.249-15.326-12.249-15.326zm675.22 0.49 5.803-357.54c-0.645-53.236-17.784-47.946-30.948-49.526-201.81 10.857-275.31 71.426-279.82 73.679-13.54 5.03-21.277 31.859-20.632 31.199-0.645 0.66-4.513 421.63-3.869 420.97-3.224 22.51 13.54 21.053 14.184 20.393 127.02-98.392 302.39-123.15 303.03-123.81 12.25-0.797 12.25-15.363 12.25-15.363z" fill="#8e2222" stroke-width="5"/></g></g></svg>
|
||||
|
After Width: | Height: | Size: 1.8 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48"><g clip-rule="evenodd"><path fill="#eee" fill-rule="evenodd" d="M47.978 24c0 6.602-2.331 12.26-6.993 16.974a3.773 3.773 0 0 1-.52.509 20.53 20.53 0 0 1-2.435 2.047C33.988 46.51 29.318 48 24.022 48c-5.304 0-9.966-1.49-13.986-4.47a21.726 21.726 0 0 1-2.988-2.556c-3.622-3.6-5.846-7.783-6.672-12.548-.162-.93-.27-1.874-.32-2.833a38.27 38.27 0 0 1 0-3.197c0-.052.014-.104.044-.155.346-5.887 2.662-10.973 6.948-15.259C11.762 2.327 17.42 0 24.022 0c6.624 0 12.279 2.327 16.963 6.982 4.662 4.743 6.993 10.416 6.993 17.018z"/><path fill="#3a3f51" fill-rule="evenodd" d="m43.098 9.405-4.957 4.957c-2.899 2.899-3.153 5.422-3.153 9.87 0 3.97.63 7.602 3.585 10.556 2.156 2.157 4.204 4.194 4.204 4.194a27.962 27.962 0 0 1-1.792 1.992 3.773 3.773 0 0 1-.52.509 20.05 20.05 0 0 1-1.749 1.538l-3.883-3.884c-3.452-3.452-6.196-3.784-10.756-3.784-4.375 0-7.352.403-10.556 3.607a2715.831 2715.831 0 0 0-4.105 4.116 21.196 21.196 0 0 1-2.368-2.102 27.739 27.739 0 0 1-1.737-1.903s2.168-2.18 4.238-4.25c3.066-3.065 3.563-6.62 3.563-10.589 0-3.872-.636-7.485-3.452-10.301C7.705 11.975 5 9.284 5 9.284a25.954 25.954 0 0 1 2.047-2.302A29.761 29.761 0 0 1 9.04 5.201l4.504 4.503c2.877 2.877 6.565 3.618 10.533 3.618 4.087 0 7.763-.791 10.756-3.784 1.84-1.841 4.27-4.26 4.27-4.26a25.168 25.168 0 0 1 1.882 1.704c.767.782 1.471 1.59 2.113 2.423z"/><path fill="#0cf" fill-rule="evenodd" d="M17.438 25.228a6.986 6.986 0 0 1-.1-1.228c0-.155.005-.303.012-.443 0-.014.004-.029.011-.044.096-1.63.738-3.039 1.925-4.227 1.306-1.29 2.874-1.936 4.703-1.936 1.837 0 3.404.645 4.703 1.936 1.29 1.313 1.936 2.884 1.936 4.714s-.645 3.397-1.936 4.703c-.045.051-.093.1-.144.143a6.056 6.056 0 0 1-.675.565c-1.121.826-2.416 1.239-3.884 1.239s-2.759-.413-3.873-1.24a5.818 5.818 0 0 1-.83-.707c-1.003-.996-1.619-2.155-1.848-3.475z"/><path fill="none" stroke="#0cf" stroke-miterlimit="1" stroke-width=".4426" d="m34.943 13.223-3.32 3.242M6.834 7.198l9.044 9.012M34.6 34.855l6.154 6.369m.41-34.056-6.22 6.056M7.18 41.107l6.053-6.063"/><path fill="none" stroke="#0cf" stroke-miterlimit="1" stroke-width="1.5491" d="m34.943 13.223-3.75 3.806m-18.12-3.617 3.806 3.795m-3.662 17.854 3.706-3.851m13.705-.309 3.99 3.971"/></g></svg>
|
||||
|
After Width: | Height: | Size: 2.2 KiB |
@@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg width="100px" height="54px" viewBox="0 0 100 54" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
||||
<title>Logo tvdb</title>
|
||||
<g id="Logo-tvdb" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
|
||||
<path d="M0,5.09590006 C0,1.81024006 2.9636,-0.441498938 6.46228,0.0733078623 L6.46228,0.0733078623 L52.10124,6.03470006 C54.15254,6.33652006 55.78724,8.54666006 55.78724,10.9536001 L55.78724,10.9536001 L55.78654,17.1835001 C51.94104,19.7605001 49.42044,24.0737001 49.42044,28.9596001 C49.42044,33.8924001 51.87974,38.1680001 55.78724,40.7361001 L55.78724,40.7361001 L55.78724,43.4756001 C55.78724,45.8825001 54.15254,48.0927001 52.10124,48.3945001 L52.10124,48.3945001 L11.60314,53.9266001 C8.10444,54.4417001 5.14084,52.1897001 5.14084,48.9040001 L5.14084,48.9040001 Z M19.68044,10.8218001 L13.66114,10.8218001 L13.66114,18.7064001 L9.84244,18.7064001 L9.84244,23.2621001 L13.66114,23.2621001 L13.66114,32.0227001 C13.4846091,37.5274601 15.6467584,39.9923503 20.6149401,40.0386142 L25.25134,40.0387001 L25.25134,35.4830001 L22.87064,35.4830001 C20.17484,35.3516001 19.59134,34.5631001 19.68074,31.0149001 L19.68074,23.2617001 L27.08014,23.2617001 L33.93424,40.0384001 L40.40294,40.0384001 L49.83694,18.7061001 L43.45734,18.7061001 L37.34794,33.3806001 L31.77694,18.7064001 L19.68044,18.7064001 L19.68044,10.8218001 Z" id="Combined-Shape" fill="#6CD591" fill-rule="nonzero"></path>
|
||||
<path d="M88.60974,18.2771001 C92.51784,18.2771001 95.12314,19.2407001 97.09994,21.4310001 C98.71734,23.1831001 99.57074,25.7677001 99.57074,28.6584001 C99.57074,32.8634001 97.86394,36.1487001 94.76414,38.0323001 C92.74234,39.2590001 90.99054,39.6094001 87.03734,39.6094001 L77.24404,39.6094001 L77.24404,10.3925001 L83.26404,10.3925001 L83.26404,18.2771001 L88.60974,18.2771001 Z M83.26404,35.0537001 L87.71094,35.0537001 C91.26004,35.0537001 93.41634,32.6884001 93.41634,28.8334001 C93.41634,24.8035001 91.52964,22.8324001 87.71094,22.8324001 L83.26404,22.8324001 L83.26404,35.0537001 Z" id="Shape" fill="#FFFFFF" fill-rule="nonzero"></path>
|
||||
<path d="M68.01354,10.3925001 L74.03354,10.3925001 L74.03354,39.6094001 L63.65594,39.6094001 C59.43354,39.6094001 57.41174,38.9962001 55.25524,37.1126001 C53.05394,35.1416001 51.93124,32.3384001 51.93124,28.7898001 C51.93124,25.1102001 53.14404,22.3070001 55.70494,20.2481001 C57.32204,18.9342001 59.52364,18.2771001 62.35354,18.2771001 L68.01384,18.2771001 L68.01384,10.3925001 L68.01354,10.3925001 Z M68.01354,22.8327001 L63.65594,22.8327001 C60.15224,22.8327001 58.04064,25.0667001 58.04064,28.7898001 C58.04064,32.6884001 60.19654,35.0537001 63.65594,35.0537001 L68.01354,35.0537001 L68.01354,22.8327001 Z" id="Shape" fill="#FFFFFF" fill-rule="nonzero"></path>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.8 KiB |
|
After Width: | Height: | Size: 6.4 KiB |
@@ -0,0 +1 @@
|
||||
<svg viewBox="0 0 90 90" xmlns="http://www.w3.org/2000/svg"><g fill="none"><path fill="#31BEEC" d="M90 38.197v19.137L48.942 80.999V61.864z"/><path d="M41.086 61.863V81L0 57.333V38.197l18.566 10.687c.02.016.043.03.067.04l22.453 12.94Z" fill="#0095D5"/><path fill="#AEADAE" d="m61.621 45.506-16.607 9.576-16.622-9.576 16.622-9.575z"/><path fill="#0095D5" d="M86.086 31.416 69.464 40.99 48.942 29.15V10z"/><path fill="#31BEEC" d="M41.086 10v19.15l-20.55 11.827-16.621-9.561z"/></g></svg>
|
||||
|
After Width: | Height: | Size: 484 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><defs><linearGradient id="a" x1="100%" x2="0" y1="0" y2="100%" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#ff8d30"/><stop offset="1" stop-color="#e32929"/></linearGradient></defs><circle cx="50%" cy="50%" r="50%" fill="url(#a)"/><path fill="#fff" d="M246.6 200.8h18.7v110.6h-18.7zm-182.3 0H83v110.7H64.3zm91.1 123.9h18.7V367h-18.7zm-45.7-47.5h18.7v68.5h-18.7zm91.2 0h18.6v68.4h-18.6zm228.2-76.5h18.7v110.7h-18.7zM338 145.5h18.7v42.3H338zm45.7 21.2h18.7v68.2h-18.7zm-91.5 0h18.7v68.1h-18.7z"/></svg>
|
||||
|
After Width: | Height: | Size: 577 B |
@@ -12,6 +12,8 @@ import { useScopedI18n } from "@homarr/translation/client";
|
||||
import type { z } from "@homarr/validation";
|
||||
import { validation } from "@homarr/validation";
|
||||
|
||||
import { revalidatePathActionAsync } from "~/app/revalidatePathAction";
|
||||
|
||||
export const LoginForm = () => {
|
||||
const t = useScopedI18n("user");
|
||||
const router = useRouter();
|
||||
@@ -32,7 +34,7 @@ export const LoginForm = () => {
|
||||
redirect: false,
|
||||
callbackUrl: "/",
|
||||
})
|
||||
.then((response) => {
|
||||
.then(async (response) => {
|
||||
if (!response?.ok || response.error) {
|
||||
throw response?.error;
|
||||
}
|
||||
@@ -41,6 +43,7 @@ export const LoginForm = () => {
|
||||
title: t("action.login.notification.success.title"),
|
||||
message: t("action.login.notification.success.message"),
|
||||
});
|
||||
await revalidatePathActionAsync("/");
|
||||
router.push("/");
|
||||
})
|
||||
.catch((error: Error | string) => {
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useRef } from "react";
|
||||
import { useCallback, useEffect, useRef } from "react";
|
||||
import { Box, LoadingOverlay, Stack } from "@mantine/core";
|
||||
|
||||
import type { RouterOutputs } from "@homarr/api";
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { DDGridStack } from "@homarr/gridstack";
|
||||
|
||||
import { BoardCategorySection } from "~/components/board/sections/category-section";
|
||||
import { BoardEmptySection } from "~/components/board/sections/empty-section";
|
||||
@@ -40,13 +41,47 @@ export const useUpdateBoard = () => {
|
||||
};
|
||||
};
|
||||
|
||||
const dd = new DDGridStack();
|
||||
|
||||
export const ClientBoard = () => {
|
||||
const board = useRequiredBoard();
|
||||
const isReady = useIsBoardReady();
|
||||
const { updateBoard } = useUpdateBoard();
|
||||
|
||||
const sortedSections = board.sections.sort((sectionA, sectionB) => sectionA.position - sectionB.position);
|
||||
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const boxRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!boxRef.current) return;
|
||||
dd.droppable(boxRef.current, {
|
||||
accept: () => true,
|
||||
})
|
||||
.on(boxRef.current, "dropover", (event, el, helper) => {
|
||||
console.log("dropover", event, el, helper);
|
||||
})
|
||||
.on(boxRef.current, "dropout", (event, el, helper) => {
|
||||
console.log("dropout", event, el, helper);
|
||||
})
|
||||
.on(boxRef.current, "drop", (event, el, helper) => {
|
||||
console.log("drop", event, el, helper);
|
||||
const itemId = el.getAttribute("data-id");
|
||||
if (!itemId) return;
|
||||
|
||||
// Same as removeItem
|
||||
updateBoard((previous) => {
|
||||
return {
|
||||
...previous,
|
||||
// Filter removed item out of items array
|
||||
sections: previous.sections.map((section) => ({
|
||||
...section,
|
||||
items: section.items.filter((item) => item.id !== itemId),
|
||||
})),
|
||||
};
|
||||
});
|
||||
});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Box h="100%" pos="relative">
|
||||
@@ -58,6 +93,7 @@ export const ClientBoard = () => {
|
||||
h={fullHeightWithoutHeaderAndFooter}
|
||||
/>
|
||||
<Stack ref={ref} h="100%" style={{ visibility: isReady ? "visible" : "hidden" }}>
|
||||
<Box ref={boxRef} w="100%" h={80} bg="red"></Box>
|
||||
{sortedSections.map((section) =>
|
||||
section.kind === "empty" ? (
|
||||
<BoardEmptySection key={section.id} section={section} mainRef={ref} />
|
||||
|
||||
@@ -49,7 +49,6 @@ export const BoardProvider = ({
|
||||
|
||||
useEffect(() => {
|
||||
setReadySections((previous) => previous.filter((id) => data.sections.some((section) => section.id === id)));
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [data.sections.length, setReadySections]);
|
||||
|
||||
const markAsReady = useCallback((id: string) => {
|
||||
|
||||
@@ -33,6 +33,7 @@ export const generateColors = (hex: string) => {
|
||||
return rgbaColors.map((color) => {
|
||||
return (
|
||||
"#" +
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
color
|
||||
.split("(")[1]!
|
||||
.replaceAll(" ", "")
|
||||
|
||||
@@ -81,6 +81,7 @@ export const GroupsForm = ({ board, initialPermissions, onCountChange }: FormPro
|
||||
{form.values.items.map((row, index) => (
|
||||
<BoardAccessSelectRow
|
||||
key={row.itemId}
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
itemContent={<GroupItemContent group={groups.get(row.itemId)!} />}
|
||||
permission={row.permission}
|
||||
index={index}
|
||||
|
||||
@@ -93,6 +93,7 @@ export const UsersForm = ({ board, initialPermissions, onCountChange }: FormProp
|
||||
{form.values.items.map((row, index) => (
|
||||
<BoardAccessSelectRow
|
||||
key={row.itemId}
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
itemContent={<UserItemContent user={users.get(row.itemId)!} />}
|
||||
permission={row.permission}
|
||||
index={index}
|
||||
|
||||
@@ -6,7 +6,6 @@ export const composeWrappers = (
|
||||
wrappers: React.FunctionComponent<PropsWithChildren>[],
|
||||
): React.FunctionComponent<PropsWithChildren> => {
|
||||
return wrappers.reverse().reduce((Acc, Current): React.FunctionComponent<PropsWithChildren> => {
|
||||
// eslint-disable-next-line react/display-name
|
||||
return (props) => (
|
||||
<Current>
|
||||
<Acc {...props} />
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
.bannerContainer {
|
||||
padding: 3rem;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
background: linear-gradient(
|
||||
|
||||
@@ -38,17 +38,17 @@ export const HeroBanner = () => {
|
||||
const gridSpan = 12 / countIconGroups;
|
||||
|
||||
return (
|
||||
<Box className={classes.bannerContainer} bg="dark.6" pos="relative">
|
||||
<Box className={classes.bannerContainer} p={{ base: "lg", md: "3rem" }} bg="dark.6" pos="relative">
|
||||
<Stack gap={0}>
|
||||
<Title order={2} c="dimmed">
|
||||
<Title fz={{ base: "h4", md: "h2" }} c="dimmed">
|
||||
Welcome back to your
|
||||
</Title>
|
||||
<Group gap="xs">
|
||||
<Image src="/logo/logo.png" w={40} h={40} />
|
||||
<Title>Homarr Dashboard</Title>
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
<Image src="/logo/logo.png" w={{ base: 32, md: 40 }} h={{ base: 32, md: 40 }} />
|
||||
<Title fz={{ base: "h3", md: "h1" }}>Homarr Board</Title>
|
||||
</Group>
|
||||
</Stack>
|
||||
<Box className={classes.scrollContainer} w={"30%"} top={0} right={0} pos="absolute">
|
||||
<Box visibleFrom="md" className={classes.scrollContainer} w={"30%"} top={0} right={0} pos="absolute">
|
||||
<Grid>
|
||||
{Array(countIconGroups)
|
||||
.fill(0)
|
||||
|
||||
@@ -21,11 +21,12 @@ import { setStaticParamsLocale } from "next-international/server";
|
||||
|
||||
import { getScopedI18n, getStaticParams } from "@homarr/translation/server";
|
||||
|
||||
import { homarrLogoPath } from "~/components/layout/logo/homarr-logo";
|
||||
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
|
||||
import { createMetaTitle } from "~/metadata";
|
||||
import { getPackageAttributesAsync } from "~/versions/package-reader";
|
||||
import contributorsData from "../../../../../../../static-data/contributors.json";
|
||||
import translatorsData from "../../../../../../../static-data/translators.json";
|
||||
import logo from "../../../../../public/logo/logo.png";
|
||||
import classes from "./about.module.css";
|
||||
|
||||
export async function generateMetadata() {
|
||||
@@ -48,9 +49,10 @@ export default async function AboutPage({ params: { locale } }: PageProps) {
|
||||
const attributes = await getPackageAttributesAsync();
|
||||
return (
|
||||
<div>
|
||||
<DynamicBreadcrumb />
|
||||
<Center w="100%">
|
||||
<Group py="lg">
|
||||
<Image src={logo} width={100} height={100} alt="" />
|
||||
<Image src={homarrLogoPath} width={100} height={100} alt="" />
|
||||
<Stack gap={0}>
|
||||
<Title order={1} tt="uppercase">
|
||||
Homarr
|
||||
|
||||
@@ -49,7 +49,7 @@ export const AppDeleteButton = ({ app }: AppDeleteButtonProps) => {
|
||||
}, [app, mutate, t, openConfirmModal]);
|
||||
|
||||
return (
|
||||
<ActionIcon loading={isPending} variant="subtle" color="red" onClick={onClick} aria-label="Delete app">
|
||||
<ActionIcon loading={isPending} variant="subtle" color="red" onClick={onClick} aria-label={t("title")}>
|
||||
<IconTrash color="red" size={16} stroke={1.5} />
|
||||
</ActionIcon>
|
||||
);
|
||||
|
||||
@@ -36,10 +36,10 @@ export const AppForm = (props: AppFormProps) => {
|
||||
return (
|
||||
<form onSubmit={form.onSubmit(handleSubmit)}>
|
||||
<Stack>
|
||||
<TextInput {...form.getInputProps("name")} withAsterisk label="Name" />
|
||||
<TextInput {...form.getInputProps("name")} withAsterisk label={t("app.field.name.label")} />
|
||||
<IconPicker initialValue={initialValues?.iconUrl} {...form.getInputProps("iconUrl")} />
|
||||
<Textarea {...form.getInputProps("description")} label="Description" />
|
||||
<TextInput {...form.getInputProps("href")} label="URL" />
|
||||
<Textarea {...form.getInputProps("description")} label={t("app.field.description.label")} />
|
||||
<TextInput {...form.getInputProps("href")} label={t("app.field.url.label")} />
|
||||
|
||||
<Group justify="end">
|
||||
<Button variant="default" component={Link} href="/manage/apps">
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Container, Stack, Title } from "@mantine/core";
|
||||
import { api } from "@homarr/api/server";
|
||||
import { getI18n } from "@homarr/translation/server";
|
||||
|
||||
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
|
||||
import { AppEditForm } from "./_app-edit-form";
|
||||
|
||||
interface AppEditPageProps {
|
||||
@@ -14,11 +15,14 @@ export default async function AppEditPage({ params }: AppEditPageProps) {
|
||||
const t = await getI18n();
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<Stack>
|
||||
<Title>{t("app.page.edit.title")}</Title>
|
||||
<AppEditForm app={app} />
|
||||
</Stack>
|
||||
</Container>
|
||||
<>
|
||||
<DynamicBreadcrumb dynamicMappings={new Map([[params.id, app.name]])} nonInteractable={["edit"]} />
|
||||
<Container>
|
||||
<Stack>
|
||||
<Title>{t("app.page.edit.title")}</Title>
|
||||
<AppEditForm app={app} />
|
||||
</Stack>
|
||||
</Container>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,14 +1,22 @@
|
||||
import { Container, Stack, Title } from "@mantine/core";
|
||||
|
||||
import { getI18n } from "@homarr/translation/server";
|
||||
|
||||
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
|
||||
import { AppNewForm } from "./_app-new-form";
|
||||
|
||||
export default function AppNewPage() {
|
||||
export default async function AppNewPage() {
|
||||
const t = await getI18n();
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<Stack>
|
||||
<Title>New app</Title>
|
||||
<AppNewForm />
|
||||
</Stack>
|
||||
</Container>
|
||||
<>
|
||||
<DynamicBreadcrumb />
|
||||
<Container>
|
||||
<Stack>
|
||||
<Title>{t("app.page.create.title")}</Title>
|
||||
<AppNewForm />
|
||||
</Stack>
|
||||
</Container>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,36 +1,29 @@
|
||||
import Link from "next/link";
|
||||
import {
|
||||
ActionIcon,
|
||||
ActionIconGroup,
|
||||
Anchor,
|
||||
Avatar,
|
||||
Button,
|
||||
Card,
|
||||
Container,
|
||||
Group,
|
||||
Stack,
|
||||
Text,
|
||||
Title,
|
||||
} from "@mantine/core";
|
||||
import { ActionIcon, ActionIconGroup, Anchor, Avatar, Card, Group, Stack, Text, Title } from "@mantine/core";
|
||||
import { IconApps, IconPencil } from "@tabler/icons-react";
|
||||
|
||||
import type { RouterOutputs } from "@homarr/api";
|
||||
import { api } from "@homarr/api/server";
|
||||
import { getI18n } from "@homarr/translation/server";
|
||||
import { getI18n, getScopedI18n } from "@homarr/translation/server";
|
||||
|
||||
import { ManageContainer } from "~/components/manage/manage-container";
|
||||
import { MobileAffixButton } from "~/components/manage/mobile-affix-button";
|
||||
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
|
||||
import { AppDeleteButton } from "./_app-delete-button";
|
||||
|
||||
export default async function AppsPage() {
|
||||
const apps = await api.app.all();
|
||||
const t = await getScopedI18n("app");
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<ManageContainer>
|
||||
<DynamicBreadcrumb />
|
||||
<Stack>
|
||||
<Group justify="space-between" align="center">
|
||||
<Title>Apps</Title>
|
||||
<Button component={Link} href="/manage/apps/new">
|
||||
New app
|
||||
</Button>
|
||||
<Title>{t("page.list.title")}</Title>
|
||||
<MobileAffixButton component={Link} href="/manage/apps/new">
|
||||
{t("page.create.title")}
|
||||
</MobileAffixButton>
|
||||
</Group>
|
||||
{apps.length === 0 && <AppNoResults />}
|
||||
{apps.length > 0 && (
|
||||
@@ -41,7 +34,7 @@ export default async function AppsPage() {
|
||||
</Stack>
|
||||
)}
|
||||
</Stack>
|
||||
</Container>
|
||||
</ManageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -49,10 +42,12 @@ interface AppCardProps {
|
||||
app: RouterOutputs["app"]["all"][number];
|
||||
}
|
||||
|
||||
const AppCard = ({ app }: AppCardProps) => {
|
||||
const AppCard = async ({ app }: AppCardProps) => {
|
||||
const t = await getScopedI18n("app");
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<Group justify="space-between">
|
||||
<Group justify="space-between" wrap="nowrap">
|
||||
<Group align="top" justify="start" wrap="nowrap">
|
||||
<Avatar
|
||||
size="sm"
|
||||
@@ -65,14 +60,16 @@ const AppCard = ({ app }: AppCardProps) => {
|
||||
}}
|
||||
/>
|
||||
<Stack gap={0}>
|
||||
<Text fw={500}>{app.name}</Text>
|
||||
<Text fw={500} lineClamp={1}>
|
||||
{app.name}
|
||||
</Text>
|
||||
{app.description && (
|
||||
<Text size="sm" c="gray.6">
|
||||
<Text size="sm" c="gray.6" lineClamp={4}>
|
||||
{app.description}
|
||||
</Text>
|
||||
)}
|
||||
{app.href && (
|
||||
<Anchor href={app.href} size="sm" w="min-content">
|
||||
<Anchor href={app.href} lineClamp={1} size="sm" w="min-content">
|
||||
{app.href}
|
||||
</Anchor>
|
||||
)}
|
||||
@@ -85,7 +82,7 @@ const AppCard = ({ app }: AppCardProps) => {
|
||||
href={`/manage/apps/edit/${app.id}`}
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
aria-label="Edit app"
|
||||
aria-label={t("page.edit.title")}
|
||||
>
|
||||
<IconPencil size={16} stroke={1.5} />
|
||||
</ActionIcon>
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback } from "react";
|
||||
import { Button } from "@mantine/core";
|
||||
import { IconCategoryPlus } from "@tabler/icons-react";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
@@ -10,6 +9,7 @@ import { useI18n } from "@homarr/translation/client";
|
||||
|
||||
import { revalidatePathActionAsync } from "~/app/revalidatePathAction";
|
||||
import { AddBoardModal } from "~/components/manage/boards/add-board-modal";
|
||||
import { MobileAffixButton } from "~/components/manage/mobile-affix-button";
|
||||
|
||||
interface CreateBoardButtonProps {
|
||||
boardNames: string[];
|
||||
@@ -37,8 +37,8 @@ export const CreateBoardButton = ({ boardNames }: CreateBoardButtonProps) => {
|
||||
}, [mutateAsync, boardNames, openModal]);
|
||||
|
||||
return (
|
||||
<Button leftSection={<IconCategoryPlus size="1rem" />} onClick={onClick} loading={isPending}>
|
||||
<MobileAffixButton leftSection={<IconCategoryPlus size="1rem" />} onClick={onClick} loading={isPending}>
|
||||
{t("management.page.board.action.new.label")}
|
||||
</Button>
|
||||
</MobileAffixButton>
|
||||
);
|
||||
};
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
Group,
|
||||
Menu,
|
||||
MenuTarget,
|
||||
Stack,
|
||||
Text,
|
||||
Title,
|
||||
Tooltip,
|
||||
@@ -22,6 +23,8 @@ import { getScopedI18n } from "@homarr/translation/server";
|
||||
import { UserAvatar } from "@homarr/ui";
|
||||
|
||||
import { getBoardPermissionsAsync } from "~/components/board/permissions/server";
|
||||
import { ManageContainer } from "~/components/manage/manage-container";
|
||||
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
|
||||
import { BoardCardMenuDropdown } from "./_components/board-card-menu-dropdown";
|
||||
import { CreateBoardButton } from "./_components/create-board-button";
|
||||
|
||||
@@ -31,20 +34,23 @@ export default async function ManageBoardsPage() {
|
||||
const boards = await api.board.getAllBoards();
|
||||
|
||||
return (
|
||||
<>
|
||||
<Group justify="space-between">
|
||||
<Title mb="md">{t("title")}</Title>
|
||||
<CreateBoardButton boardNames={boards.map((board) => board.name)} />
|
||||
</Group>
|
||||
<ManageContainer>
|
||||
<DynamicBreadcrumb />
|
||||
<Stack>
|
||||
<Group justify="space-between">
|
||||
<Title mb="md">{t("title")}</Title>
|
||||
<CreateBoardButton boardNames={boards.map((board) => board.name)} />
|
||||
</Group>
|
||||
|
||||
<Grid>
|
||||
{boards.map((board) => (
|
||||
<GridCol span={{ base: 12, md: 6, xl: 4 }} key={board.id}>
|
||||
<BoardCard board={board} />
|
||||
</GridCol>
|
||||
))}
|
||||
</Grid>
|
||||
</>
|
||||
<Grid mb={{ base: "xl", md: 0 }}>
|
||||
{boards.map((board) => (
|
||||
<GridCol span={{ base: 12, md: 6 }} key={board.id}>
|
||||
<BoardCard board={board} />
|
||||
</GridCol>
|
||||
))}
|
||||
</Grid>
|
||||
</Stack>
|
||||
</ManageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -83,7 +89,7 @@ const BoardCard = async ({ board }: BoardCardProps) => {
|
||||
{board.creator && (
|
||||
<Group gap="xs">
|
||||
<UserAvatar user={board.creator} size="sm" />
|
||||
<Text>{board.creator?.name}</Text>
|
||||
<Text>{board.creator.name}</Text>
|
||||
</Group>
|
||||
)}
|
||||
</Group>
|
||||
|
||||
@@ -12,7 +12,7 @@ import type { RouterOutputs } from "@homarr/api";
|
||||
import { integrationSecretKindObject } from "@homarr/definitions";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
|
||||
import { integrationSecretIcons } from "./_integration-secret-icons";
|
||||
import { integrationSecretIcons } from "./integration-secret-icons";
|
||||
|
||||
dayjs.extend(relativeTime);
|
||||
|
||||
@@ -7,7 +7,7 @@ import { integrationSecretKindObject } from "@homarr/definitions";
|
||||
import type { IntegrationSecretKind } from "@homarr/definitions";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
|
||||
import { integrationSecretIcons } from "./_integration-secret-icons";
|
||||
import { integrationSecretIcons } from "./integration-secret-icons";
|
||||
|
||||
interface IntegrationSecretInputProps {
|
||||
withAsterisk?: boolean;
|
||||
@@ -50,7 +50,7 @@ const PrivateSecretInput = ({ kind, ...props }: IntegrationSecretInputProps) =>
|
||||
<PasswordInput
|
||||
{...props}
|
||||
label={props.label ?? t(`integration.secrets.kind.${kind}.label`)}
|
||||
description={t(`integration.secrets.secureNotice`)}
|
||||
description={t("integration.secrets.secureNotice")}
|
||||
w="100%"
|
||||
leftSection={<Icon size={20} stroke={1.5} />}
|
||||
/>
|
||||
@@ -56,7 +56,7 @@ export const DeleteIntegrationActionButton = ({ count, integration }: DeleteInte
|
||||
},
|
||||
});
|
||||
}}
|
||||
aria-label="Delete integration"
|
||||
aria-label={t("title")}
|
||||
>
|
||||
<IconTrash color="red" size={16} stroke={1.5} />
|
||||
</ActionIcon>
|
||||
|
||||
@@ -1,129 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useRef, useState } from "react";
|
||||
import { Alert, Anchor, Group, Loader } from "@mantine/core";
|
||||
import { IconCheck, IconInfoCircle, IconX } from "@tabler/icons-react";
|
||||
|
||||
import type { RouterInputs } from "@homarr/api";
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||
import { useI18n, useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
interface UseTestConnectionDirtyProps {
|
||||
defaultDirty: boolean;
|
||||
initialFormValue: {
|
||||
url: string;
|
||||
secrets: { kind: string; value: string | null }[];
|
||||
};
|
||||
}
|
||||
|
||||
export const useTestConnectionDirty = ({ defaultDirty, initialFormValue }: UseTestConnectionDirtyProps) => {
|
||||
const [isDirty, setIsDirty] = useState(defaultDirty);
|
||||
const prevFormValueRef = useRef(initialFormValue);
|
||||
|
||||
return {
|
||||
onValuesChange: (values: typeof initialFormValue) => {
|
||||
if (isDirty) return;
|
||||
|
||||
// If relevant values changed, set dirty
|
||||
if (
|
||||
prevFormValueRef.current.url !== values.url ||
|
||||
!prevFormValueRef.current.secrets
|
||||
.map((secret) => secret.value)
|
||||
.every((secretValue, index) => values.secrets[index]?.value === secretValue)
|
||||
) {
|
||||
setIsDirty(true);
|
||||
return;
|
||||
}
|
||||
|
||||
// If relevant values changed back to last tested, set not dirty
|
||||
setIsDirty(false);
|
||||
},
|
||||
isDirty,
|
||||
removeDirty: () => {
|
||||
prevFormValueRef.current = initialFormValue;
|
||||
setIsDirty(false);
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
interface TestConnectionProps {
|
||||
isDirty: boolean;
|
||||
removeDirty: () => void;
|
||||
integration: RouterInputs["integration"]["testConnection"] & { name: string };
|
||||
}
|
||||
|
||||
export const TestConnection = ({ integration, removeDirty, isDirty }: TestConnectionProps) => {
|
||||
const t = useScopedI18n("integration.testConnection");
|
||||
const { mutateAsync, ...mutation } = clientApi.integration.testConnection.useMutation();
|
||||
|
||||
return (
|
||||
<Group>
|
||||
<Anchor
|
||||
type="button"
|
||||
component="button"
|
||||
onClick={async () => {
|
||||
await mutateAsync(integration, {
|
||||
onSuccess: () => {
|
||||
removeDirty();
|
||||
showSuccessNotification({
|
||||
title: t("notification.success.title"),
|
||||
message: t("notification.success.message"),
|
||||
});
|
||||
},
|
||||
onError: (error) => {
|
||||
if (error.data?.zodError?.fieldErrors.url) {
|
||||
showErrorNotification({
|
||||
title: t("notification.invalidUrl.title"),
|
||||
message: t("notification.invalidUrl.message"),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (error.message === "SECRETS_NOT_DEFINED") {
|
||||
showErrorNotification({
|
||||
title: t("notification.notAllSecretsProvided.title"),
|
||||
message: t("notification.notAllSecretsProvided.message"),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
showErrorNotification({
|
||||
title: t("notification.commonError.title"),
|
||||
message: t("notification.commonError.message"),
|
||||
});
|
||||
},
|
||||
});
|
||||
}}
|
||||
>
|
||||
{t("action")}
|
||||
</Anchor>
|
||||
<TestConnectionIcon isDirty={isDirty} {...mutation} size={20} />
|
||||
</Group>
|
||||
);
|
||||
};
|
||||
|
||||
interface TestConnectionIconProps {
|
||||
isDirty: boolean;
|
||||
isPending: boolean;
|
||||
isSuccess: boolean;
|
||||
isError: boolean;
|
||||
size: number;
|
||||
}
|
||||
|
||||
const TestConnectionIcon = ({ isDirty, isPending, isSuccess, isError, size }: TestConnectionIconProps) => {
|
||||
if (isPending) return <Loader color="blue" size={size} />;
|
||||
if (isDirty) return null;
|
||||
if (isSuccess) return <IconCheck size={size} stroke={1.5} color="green" />;
|
||||
if (isError) return <IconX size={size} stroke={1.5} color="red" />;
|
||||
return null;
|
||||
};
|
||||
|
||||
export const TestConnectionNoticeAlert = () => {
|
||||
const t = useI18n();
|
||||
return (
|
||||
<Alert variant="light" color="yellow" title="Test Connection" icon={<IconInfoCircle />}>
|
||||
{t("integration.testConnection.alertNotice")}
|
||||
</Alert>
|
||||
);
|
||||
};
|
||||
@@ -8,6 +8,7 @@ import type { RouterOutputs } from "@homarr/api";
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { getAllSecretKindOptions, getDefaultSecretKinds } from "@homarr/definitions";
|
||||
import { useZodForm } from "@homarr/form";
|
||||
import { convertIntegrationTestConnectionError } from "@homarr/integrations/client";
|
||||
import { useConfirmModal } from "@homarr/modals";
|
||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
@@ -15,9 +16,8 @@ import type { z } from "@homarr/validation";
|
||||
import { validation } from "@homarr/validation";
|
||||
|
||||
import { revalidatePathActionAsync } from "~/app/revalidatePathAction";
|
||||
import { SecretCard } from "../../_integration-secret-card";
|
||||
import { IntegrationSecretInput } from "../../_integration-secret-inputs";
|
||||
import { TestConnection, TestConnectionNoticeAlert, useTestConnectionDirty } from "../../_integration-test-connection";
|
||||
import { SecretCard } from "../../_components/secrets/integration-secret-card";
|
||||
import { IntegrationSecretInput } from "../../_components/secrets/integration-secret-inputs";
|
||||
|
||||
interface EditIntegrationForm {
|
||||
integration: RouterOutputs["integration"]["byId"];
|
||||
@@ -30,30 +30,23 @@ export const EditIntegrationForm = ({ integration }: EditIntegrationForm) => {
|
||||
getAllSecretKindOptions(integration.kind).find((secretKinds) =>
|
||||
integration.secrets.every((secret) => secretKinds.includes(secret.kind)),
|
||||
) ?? getDefaultSecretKinds(integration.kind);
|
||||
const initialFormValues = {
|
||||
name: integration.name,
|
||||
url: integration.url,
|
||||
secrets: secretsKinds.map((kind) => ({
|
||||
kind,
|
||||
value: integration.secrets.find((secret) => secret.kind === kind)?.value ?? "",
|
||||
})),
|
||||
};
|
||||
const { isDirty, onValuesChange, removeDirty } = useTestConnectionDirty({
|
||||
defaultDirty: true,
|
||||
initialFormValue: initialFormValues,
|
||||
});
|
||||
|
||||
const router = useRouter();
|
||||
const form = useZodForm(validation.integration.update.omit({ id: true }), {
|
||||
initialValues: initialFormValues,
|
||||
onValuesChange,
|
||||
initialValues: {
|
||||
name: integration.name,
|
||||
url: integration.url,
|
||||
secrets: secretsKinds.map((kind) => ({
|
||||
kind,
|
||||
value: integration.secrets.find((secret) => secret.kind === kind)?.value ?? "",
|
||||
})),
|
||||
},
|
||||
});
|
||||
const { mutateAsync, isPending } = clientApi.integration.update.useMutation();
|
||||
|
||||
const secretsMap = new Map(integration.secrets.map((secret) => [secret.kind, secret]));
|
||||
|
||||
const handleSubmitAsync = async (values: FormType) => {
|
||||
if (isDirty) return;
|
||||
await mutateAsync(
|
||||
{
|
||||
id: integration.id,
|
||||
@@ -71,7 +64,19 @@ export const EditIntegrationForm = ({ integration }: EditIntegrationForm) => {
|
||||
});
|
||||
void revalidatePathActionAsync("/manage/integrations").then(() => router.push("/manage/integrations"));
|
||||
},
|
||||
onError: () => {
|
||||
onError: (error) => {
|
||||
const testConnectionError = convertIntegrationTestConnectionError(error.data?.error);
|
||||
|
||||
if (testConnectionError) {
|
||||
showErrorNotification({
|
||||
title: t(`integration.testConnection.notification.${testConnectionError.key}.title`),
|
||||
message: testConnectionError.message
|
||||
? testConnectionError.message
|
||||
: t(`integration.testConnection.notification.${testConnectionError.key}.message`),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
showErrorNotification({
|
||||
title: t("integration.page.edit.notification.error.title"),
|
||||
message: t("integration.page.edit.notification.error.message"),
|
||||
@@ -84,8 +89,6 @@ export const EditIntegrationForm = ({ integration }: EditIntegrationForm) => {
|
||||
return (
|
||||
<form onSubmit={form.onSubmit((values) => void handleSubmitAsync(values))}>
|
||||
<Stack>
|
||||
<TestConnectionNoticeAlert />
|
||||
|
||||
<TextInput withAsterisk label={t("integration.field.name.label")} {...form.getInputProps("name")} />
|
||||
|
||||
<TextInput withAsterisk label={t("integration.field.url.label")} {...form.getInputProps("url")} />
|
||||
@@ -95,20 +98,21 @@ export const EditIntegrationForm = ({ integration }: EditIntegrationForm) => {
|
||||
{secretsKinds.map((kind, index) => (
|
||||
<SecretCard
|
||||
key={kind}
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
secret={secretsMap.get(kind)!}
|
||||
onCancel={() =>
|
||||
new Promise((res) => {
|
||||
new Promise((resolve) => {
|
||||
// When nothing changed, just close the secret card
|
||||
if ((form.values.secrets[index]?.value ?? "") === (secretsMap.get(kind)?.value ?? "")) {
|
||||
return res(true);
|
||||
return resolve(true);
|
||||
}
|
||||
openConfirmModal({
|
||||
title: t("integration.secrets.reset.title"),
|
||||
children: t("integration.secrets.reset.message"),
|
||||
onCancel: () => res(false),
|
||||
onCancel: () => resolve(false),
|
||||
onConfirm: () => {
|
||||
form.setFieldValue(`secrets.${index}.value`, secretsMap.get(kind)!.value ?? "");
|
||||
res(true);
|
||||
form.setFieldValue(`secrets.${index}.value`, secretsMap.get(kind)?.value ?? "");
|
||||
resolve(true);
|
||||
},
|
||||
});
|
||||
})
|
||||
@@ -125,24 +129,13 @@ export const EditIntegrationForm = ({ integration }: EditIntegrationForm) => {
|
||||
</Stack>
|
||||
</Fieldset>
|
||||
|
||||
<Group justify="space-between" align="center">
|
||||
<TestConnection
|
||||
isDirty={isDirty}
|
||||
removeDirty={removeDirty}
|
||||
integration={{
|
||||
id: integration.id,
|
||||
kind: integration.kind,
|
||||
...form.values,
|
||||
}}
|
||||
/>
|
||||
<Group>
|
||||
<Button variant="default" component={Link} href="/manage/integrations">
|
||||
{t("common.action.backToOverview")}
|
||||
</Button>
|
||||
<Button type="submit" loading={isPending} disabled={isDirty}>
|
||||
{t("common.action.save")}
|
||||
</Button>
|
||||
</Group>
|
||||
<Group justify="end" align="center">
|
||||
<Button variant="default" component={Link} href="/manage/integrations">
|
||||
{t("common.action.backToOverview")}
|
||||
</Button>
|
||||
<Button type="submit" loading={isPending}>
|
||||
{t("integration.testConnection.action.edit")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</form>
|
||||
|
||||
@@ -4,6 +4,7 @@ import { api } from "@homarr/api/server";
|
||||
import { getIntegrationName } from "@homarr/definitions";
|
||||
import { getScopedI18n } from "@homarr/translation/server";
|
||||
|
||||
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
|
||||
import { IntegrationAvatar } from "../../_integration-avatar";
|
||||
import { EditIntegrationForm } from "./_integration-edit-form";
|
||||
|
||||
@@ -16,14 +17,17 @@ export default async function EditIntegrationPage({ params }: EditIntegrationPag
|
||||
const integration = await api.integration.byId({ id: params.id });
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<Stack>
|
||||
<Group align="center">
|
||||
<IntegrationAvatar kind={integration.kind} size="md" />
|
||||
<Title>{t("title", { name: getIntegrationName(integration.kind) })}</Title>
|
||||
</Group>
|
||||
<EditIntegrationForm integration={integration} />
|
||||
</Stack>
|
||||
</Container>
|
||||
<>
|
||||
<DynamicBreadcrumb dynamicMappings={new Map([[params.id, integration.name]])} nonInteractable={["edit"]} />
|
||||
<Container>
|
||||
<Stack>
|
||||
<Group align="center">
|
||||
<IntegrationAvatar kind={integration.kind} size="md" />
|
||||
<Title>{t("title", { name: getIntegrationName(integration.kind) })}</Title>
|
||||
</Group>
|
||||
<EditIntegrationForm integration={integration} />
|
||||
</Stack>
|
||||
</Container>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -3,7 +3,7 @@
|
||||
import type { ChangeEvent } from "react";
|
||||
import React, { useMemo, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { Group, Menu, ScrollArea, Stack, Text, TextInput } from "@mantine/core";
|
||||
import { Flex, Group, Menu, ScrollArea, Text, TextInput } from "@mantine/core";
|
||||
import { IconSearch } from "@tabler/icons-react";
|
||||
|
||||
import { getIntegrationName, integrationKinds } from "@homarr/definitions";
|
||||
@@ -25,7 +25,7 @@ export const IntegrationCreateDropdownContent = () => {
|
||||
);
|
||||
|
||||
return (
|
||||
<Stack>
|
||||
<Flex direction={{ base: "column-reverse", md: "column" }} gap="sm">
|
||||
<TextInput
|
||||
leftSection={<IconSearch stroke={1.5} size={20} />}
|
||||
placeholder={t("integration.page.list.search")}
|
||||
@@ -47,6 +47,6 @@ export const IntegrationCreateDropdownContent = () => {
|
||||
) : (
|
||||
<Menu.Item disabled>{t("common.noResults")}</Menu.Item>
|
||||
)}
|
||||
</Stack>
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
@@ -10,13 +10,13 @@ import type { IntegrationKind, IntegrationSecretKind } from "@homarr/definitions
|
||||
import { getAllSecretKindOptions } from "@homarr/definitions";
|
||||
import type { UseFormReturnType } from "@homarr/form";
|
||||
import { useZodForm } from "@homarr/form";
|
||||
import { convertIntegrationTestConnectionError } from "@homarr/integrations/client";
|
||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||
import { useI18n, useScopedI18n } from "@homarr/translation/client";
|
||||
import type { z } from "@homarr/validation";
|
||||
import { validation } from "@homarr/validation";
|
||||
|
||||
import { IntegrationSecretInput } from "../_integration-secret-inputs";
|
||||
import { TestConnection, TestConnectionNoticeAlert, useTestConnectionDirty } from "../_integration-test-connection";
|
||||
import { IntegrationSecretInput } from "../_components/secrets/integration-secret-inputs";
|
||||
import { revalidatePathActionAsync } from "../../../../revalidatePathAction";
|
||||
|
||||
interface NewIntegrationFormProps {
|
||||
@@ -28,27 +28,20 @@ interface NewIntegrationFormProps {
|
||||
export const NewIntegrationForm = ({ searchParams }: NewIntegrationFormProps) => {
|
||||
const t = useI18n();
|
||||
const secretKinds = getAllSecretKindOptions(searchParams.kind);
|
||||
const initialFormValues = {
|
||||
name: searchParams.name ?? "",
|
||||
url: searchParams.url ?? "",
|
||||
secrets: secretKinds[0].map((kind) => ({
|
||||
kind,
|
||||
value: "",
|
||||
})),
|
||||
};
|
||||
const { isDirty, onValuesChange, removeDirty } = useTestConnectionDirty({
|
||||
defaultDirty: true,
|
||||
initialFormValue: initialFormValues,
|
||||
});
|
||||
const router = useRouter();
|
||||
const form = useZodForm(validation.integration.create.omit({ kind: true }), {
|
||||
initialValues: initialFormValues,
|
||||
onValuesChange,
|
||||
initialValues: {
|
||||
name: searchParams.name ?? "",
|
||||
url: searchParams.url ?? "",
|
||||
secrets: secretKinds[0].map((kind) => ({
|
||||
kind,
|
||||
value: "",
|
||||
})),
|
||||
},
|
||||
});
|
||||
const { mutateAsync, isPending } = clientApi.integration.create.useMutation();
|
||||
|
||||
const handleSubmitAsync = async (values: FormType) => {
|
||||
if (isDirty) return;
|
||||
await mutateAsync(
|
||||
{
|
||||
kind: searchParams.kind,
|
||||
@@ -62,7 +55,19 @@ export const NewIntegrationForm = ({ searchParams }: NewIntegrationFormProps) =>
|
||||
});
|
||||
void revalidatePathActionAsync("/manage/integrations").then(() => router.push("/manage/integrations"));
|
||||
},
|
||||
onError: () => {
|
||||
onError: (error) => {
|
||||
const testConnectionError = convertIntegrationTestConnectionError(error.data?.error);
|
||||
|
||||
if (testConnectionError) {
|
||||
showErrorNotification({
|
||||
title: t(`integration.testConnection.notification.${testConnectionError.key}.title`),
|
||||
message: testConnectionError.message
|
||||
? testConnectionError.message
|
||||
: t(`integration.testConnection.notification.${testConnectionError.key}.message`),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
showErrorNotification({
|
||||
title: t("integration.page.create.notification.error.title"),
|
||||
message: t("integration.page.create.notification.error.message"),
|
||||
@@ -75,8 +80,6 @@ export const NewIntegrationForm = ({ searchParams }: NewIntegrationFormProps) =>
|
||||
return (
|
||||
<form onSubmit={form.onSubmit((value) => void handleSubmitAsync(value))}>
|
||||
<Stack>
|
||||
<TestConnectionNoticeAlert />
|
||||
|
||||
<TextInput withAsterisk label={t("integration.field.name.label")} {...form.getInputProps("name")} />
|
||||
|
||||
<TextInput withAsterisk label={t("integration.field.url.label")} {...form.getInputProps("url")} />
|
||||
@@ -95,25 +98,13 @@ export const NewIntegrationForm = ({ searchParams }: NewIntegrationFormProps) =>
|
||||
</Stack>
|
||||
</Fieldset>
|
||||
|
||||
<Group justify="space-between" align="center">
|
||||
<TestConnection
|
||||
isDirty={isDirty}
|
||||
removeDirty={removeDirty}
|
||||
integration={{
|
||||
id: null,
|
||||
kind: searchParams.kind,
|
||||
...form.values,
|
||||
}}
|
||||
/>
|
||||
|
||||
<Group>
|
||||
<Button variant="default" component={Link} href="/manage/integrations">
|
||||
{t("common.action.backToOverview")}
|
||||
</Button>
|
||||
<Button type="submit" loading={isPending} disabled={isDirty}>
|
||||
{t("common.action.create")}
|
||||
</Button>
|
||||
</Group>
|
||||
<Group justify="end" align="center">
|
||||
<Button variant="default" component={Link} href="/manage/integrations">
|
||||
{t("common.action.backToOverview")}
|
||||
</Button>
|
||||
<Button type="submit" loading={isPending}>
|
||||
{t("integration.testConnection.action.create")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</form>
|
||||
|
||||
@@ -7,6 +7,7 @@ import { getScopedI18n } from "@homarr/translation/server";
|
||||
import type { validation } from "@homarr/validation";
|
||||
import { z } from "@homarr/validation";
|
||||
|
||||
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
|
||||
import { IntegrationAvatar } from "../_integration-avatar";
|
||||
import { NewIntegrationForm } from "./_integration-new-form";
|
||||
|
||||
@@ -17,6 +18,7 @@ interface NewIntegrationPageProps {
|
||||
}
|
||||
|
||||
export default async function IntegrationsNewPage({ searchParams }: NewIntegrationPageProps) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
const result = z.enum([integrationKinds[0]!, ...integrationKinds.slice(1)]).safeParse(searchParams.kind);
|
||||
if (!result.success) {
|
||||
notFound();
|
||||
@@ -27,14 +29,17 @@ export default async function IntegrationsNewPage({ searchParams }: NewIntegrati
|
||||
const currentKind = result.data;
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<Stack>
|
||||
<Group align="center">
|
||||
<IntegrationAvatar kind={currentKind} size="md" />
|
||||
<Title>{t("title", { name: getIntegrationName(currentKind) })}</Title>
|
||||
</Group>
|
||||
<NewIntegrationForm searchParams={searchParams} />
|
||||
</Stack>
|
||||
</Container>
|
||||
<>
|
||||
<DynamicBreadcrumb />
|
||||
<Container>
|
||||
<Stack>
|
||||
<Group align="center">
|
||||
<IntegrationAvatar kind={currentKind} size="md" />
|
||||
<Title>{t("title", { name: getIntegrationName(currentKind) })}</Title>
|
||||
</Group>
|
||||
<NewIntegrationForm searchParams={searchParams} />
|
||||
</Stack>
|
||||
</Container>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
import { Fragment } from "react";
|
||||
import type { PropsWithChildren } from "react";
|
||||
import Link from "next/link";
|
||||
import {
|
||||
AccordionControl,
|
||||
@@ -5,9 +7,11 @@ import {
|
||||
AccordionPanel,
|
||||
ActionIcon,
|
||||
ActionIconGroup,
|
||||
Affix,
|
||||
Anchor,
|
||||
Box,
|
||||
Button,
|
||||
Container,
|
||||
Divider,
|
||||
Group,
|
||||
Menu,
|
||||
MenuDropdown,
|
||||
@@ -22,7 +26,7 @@ import {
|
||||
Text,
|
||||
Title,
|
||||
} from "@mantine/core";
|
||||
import { IconChevronDown, IconPencil } from "@tabler/icons-react";
|
||||
import { IconChevronDown, IconChevronUp, IconPencil } from "@tabler/icons-react";
|
||||
|
||||
import type { RouterOutputs } from "@homarr/api";
|
||||
import { api } from "@homarr/api/server";
|
||||
@@ -32,6 +36,8 @@ import { getIntegrationName } from "@homarr/definitions";
|
||||
import { getScopedI18n } from "@homarr/translation/server";
|
||||
import { CountBadge } from "@homarr/ui";
|
||||
|
||||
import { ManageContainer } from "~/components/manage/manage-container";
|
||||
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
|
||||
import { ActiveTabAccordion } from "../../../../components/active-tab-accordion";
|
||||
import { IntegrationAvatar } from "./_integration-avatar";
|
||||
import { DeleteIntegrationActionButton } from "./_integration-buttons";
|
||||
@@ -48,26 +54,48 @@ export default async function IntegrationsPage({ searchParams }: IntegrationsPag
|
||||
const t = await getScopedI18n("integration");
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<ManageContainer>
|
||||
<DynamicBreadcrumb />
|
||||
<Stack>
|
||||
<Group justify="space-between" align="center">
|
||||
<Title>{t("page.list.title")}</Title>
|
||||
<Menu width={256} trapFocus position="bottom-start" withinPortal shadow="md" keepMounted={false}>
|
||||
<MenuTarget>
|
||||
<Button rightSection={<IconChevronDown size={16} stroke={1.5} />}>{t("action.create")}</Button>
|
||||
</MenuTarget>
|
||||
<MenuDropdown>
|
||||
<IntegrationCreateDropdownContent />
|
||||
</MenuDropdown>
|
||||
</Menu>
|
||||
|
||||
<Box>
|
||||
<IntegrationSelectMenu>
|
||||
<Affix hiddenFrom="md" position={{ bottom: 20, right: 20 }}>
|
||||
<MenuTarget>
|
||||
<Button rightSection={<IconChevronUp size={16} stroke={1.5} />}>{t("action.create")}</Button>
|
||||
</MenuTarget>
|
||||
</Affix>
|
||||
</IntegrationSelectMenu>
|
||||
</Box>
|
||||
|
||||
<Box visibleFrom="md">
|
||||
<IntegrationSelectMenu>
|
||||
<MenuTarget>
|
||||
<Button rightSection={<IconChevronDown size={16} stroke={1.5} />}>{t("action.create")}</Button>
|
||||
</MenuTarget>
|
||||
</IntegrationSelectMenu>
|
||||
</Box>
|
||||
</Group>
|
||||
|
||||
<IntegrationList integrations={integrations} activeTab={searchParams.tab} />
|
||||
</Stack>
|
||||
</Container>
|
||||
</ManageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
const IntegrationSelectMenu = ({ children }: PropsWithChildren) => {
|
||||
return (
|
||||
<Menu width={256} trapFocus position="bottom-end" withinPortal shadow="md" keepMounted={false}>
|
||||
{children}
|
||||
<MenuDropdown>
|
||||
<IntegrationCreateDropdownContent />
|
||||
</MenuDropdown>
|
||||
</Menu>
|
||||
);
|
||||
};
|
||||
|
||||
interface IntegrationListProps {
|
||||
integrations: RouterOutputs["integration"]["all"];
|
||||
activeTab?: IntegrationKind;
|
||||
@@ -82,6 +110,7 @@ const IntegrationList = async ({ integrations, activeTab }: IntegrationListProps
|
||||
|
||||
const grouppedIntegrations = integrations.reduce(
|
||||
(acc, integration) => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
||||
if (!acc[integration.kind]) {
|
||||
acc[integration.kind] = [];
|
||||
}
|
||||
@@ -104,7 +133,7 @@ const IntegrationList = async ({ integrations, activeTab }: IntegrationListProps
|
||||
</Group>
|
||||
</AccordionControl>
|
||||
<AccordionPanel>
|
||||
<Table>
|
||||
<Table visibleFrom="md">
|
||||
<TableThead>
|
||||
<TableTr>
|
||||
<TableTh>{t("field.name.label")}</TableTh>
|
||||
@@ -129,7 +158,7 @@ const IntegrationList = async ({ integrations, activeTab }: IntegrationListProps
|
||||
href={`/manage/integrations/edit/${integration.id}`}
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
aria-label="Edit integration"
|
||||
aria-label={t("page.edit.title", { name: getIntegrationName(integration.kind) })}
|
||||
>
|
||||
<IconPencil size={16} stroke={1.5} />
|
||||
</ActionIcon>
|
||||
@@ -141,6 +170,34 @@ const IntegrationList = async ({ integrations, activeTab }: IntegrationListProps
|
||||
))}
|
||||
</TableTbody>
|
||||
</Table>
|
||||
|
||||
<Stack gap="xs" hiddenFrom="md">
|
||||
{integrations.map((integration, index) => (
|
||||
<Fragment key={integration.id}>
|
||||
{index !== 0 && <Divider />}
|
||||
<Stack gap={0}>
|
||||
<Group justify="space-between" align="center" wrap="nowrap">
|
||||
<Text>{integration.name}</Text>
|
||||
<ActionIconGroup>
|
||||
<ActionIcon
|
||||
component={Link}
|
||||
href={`/manage/integrations/edit/${integration.id}`}
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
aria-label={t("page.edit.title", { name: getIntegrationName(integration.kind) })}
|
||||
>
|
||||
<IconPencil size={16} stroke={1.5} />
|
||||
</ActionIcon>
|
||||
<DeleteIntegrationActionButton integration={integration} count={integrations.length} />
|
||||
</ActionIconGroup>
|
||||
</Group>
|
||||
<Anchor href={integration.url} target="_blank" rel="noreferrer" size="sm">
|
||||
{integration.url}
|
||||
</Anchor>
|
||||
</Stack>
|
||||
</Fragment>
|
||||
))}
|
||||
</Stack>
|
||||
</AccordionPanel>
|
||||
</AccordionItem>
|
||||
))}
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
IconMailForward,
|
||||
IconPlug,
|
||||
IconQuestionMark,
|
||||
IconReport,
|
||||
IconSettings,
|
||||
IconTool,
|
||||
IconUser,
|
||||
@@ -86,6 +87,11 @@ export default async function ManageLayout({ children }: PropsWithChildren) {
|
||||
icon: IconLogs,
|
||||
href: "/manage/tools/logs",
|
||||
},
|
||||
{
|
||||
label: t("items.tools.items.tasks"),
|
||||
icon: IconReport,
|
||||
href: "/manage/tools/tasks",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -5,6 +5,7 @@ import { IconArrowRight } from "@tabler/icons-react";
|
||||
import { api } from "@homarr/api/server";
|
||||
import { getScopedI18n } from "@homarr/translation/server";
|
||||
|
||||
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
|
||||
import { createMetaTitle } from "~/metadata";
|
||||
import { HeroBanner } from "./_components/hero-banner";
|
||||
|
||||
@@ -67,13 +68,14 @@ export default async function ManagementPage() {
|
||||
];
|
||||
return (
|
||||
<>
|
||||
<DynamicBreadcrumb />
|
||||
<HeroBanner />
|
||||
<Space h="md" />
|
||||
<SimpleGrid cols={{ xs: 1, sm: 2, md: 3 }}>
|
||||
{links.map((link, index) => (
|
||||
<Card component={Link} href={link.href} key={`link-${index}`} withBorder>
|
||||
<Group justify="space-between">
|
||||
<Group>
|
||||
<Group justify="space-between" wrap="nowrap">
|
||||
<Group wrap="nowrap">
|
||||
<Text size="2.4rem" fw="bolder">
|
||||
{link.count}
|
||||
</Text>
|
||||
|
||||
@@ -109,7 +109,9 @@ const SwitchSetting = ({
|
||||
<UnstyledButton style={{ flexGrow: 1 }} onClick={handleClick}>
|
||||
<Stack gap={0}>
|
||||
<Text fw="bold">{title}</Text>
|
||||
<Text c="gray.5">{text}</Text>
|
||||
<Text c="gray.5" fz={{ base: "xs", md: "sm" }}>
|
||||
{text}
|
||||
</Text>
|
||||
</Stack>
|
||||
</UnstyledButton>
|
||||
<Switch disabled={disabled} onClick={handleClick} checked={form.values[formKey] && !disabled} />
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Stack, Title } from "@mantine/core";
|
||||
import { api } from "@homarr/api/server";
|
||||
import { getScopedI18n } from "@homarr/translation/server";
|
||||
|
||||
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
|
||||
import { AnalyticsSettings } from "./_components/analytics.settings";
|
||||
|
||||
export async function generateMetadata() {
|
||||
@@ -18,9 +19,12 @@ export default async function SettingsPage() {
|
||||
const serverSettings = await api.serverSettings.getAll();
|
||||
const t = await getScopedI18n("management.page.settings");
|
||||
return (
|
||||
<Stack>
|
||||
<Title order={1}>{t("title")}</Title>
|
||||
<AnalyticsSettings initialData={serverSettings.analytics} />
|
||||
</Stack>
|
||||
<>
|
||||
<DynamicBreadcrumb />
|
||||
<Stack>
|
||||
<Title order={1}>{t("title")}</Title>
|
||||
<AnalyticsSettings initialData={serverSettings.analytics} />
|
||||
</Stack>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,16 +1,19 @@
|
||||
"use client";
|
||||
|
||||
import type { ButtonProps, MantineColor } from "@mantine/core";
|
||||
import type { MantineColor } from "@mantine/core";
|
||||
import { Avatar, Badge, Box, Button, Group, Text } from "@mantine/core";
|
||||
import { IconPlayerPlay, IconPlayerStop, IconRotateClockwise, IconTrash } from "@tabler/icons-react";
|
||||
import type { MRT_ColumnDef } from "mantine-react-table";
|
||||
import { MantineReactTable, useMantineReactTable } from "mantine-react-table";
|
||||
|
||||
import type { RouterOutputs } from "@homarr/api";
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { useTimeAgo } from "@homarr/common";
|
||||
import type { DockerContainerState } from "@homarr/definitions";
|
||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||
import type { TranslationFunction } from "@homarr/translation";
|
||||
import { useI18n, useScopedI18n } from "@homarr/translation/client";
|
||||
import type { TablerIcon } from "@homarr/ui";
|
||||
import { OverflowBadge } from "@homarr/ui";
|
||||
|
||||
const createColumns = (
|
||||
@@ -61,12 +64,18 @@ const createColumns = (
|
||||
},
|
||||
];
|
||||
|
||||
export function DockerTable({ containers, timestamp }: RouterOutputs["docker"]["getContainers"]) {
|
||||
export function DockerTable(initialData: RouterOutputs["docker"]["getContainers"]) {
|
||||
const t = useI18n();
|
||||
const tDocker = useScopedI18n("docker");
|
||||
const relativeTime = useTimeAgo(timestamp);
|
||||
const { data } = clientApi.docker.getContainers.useQuery(undefined, {
|
||||
initialData,
|
||||
refetchOnMount: false,
|
||||
refetchOnWindowFocus: false,
|
||||
refetchOnReconnect: false,
|
||||
});
|
||||
const relativeTime = useTimeAgo(data.timestamp);
|
||||
const table = useMantineReactTable({
|
||||
data: containers,
|
||||
data: data.containers,
|
||||
enableDensityToggle: false,
|
||||
enableColumnActions: false,
|
||||
enableColumnFilters: false,
|
||||
@@ -77,7 +86,7 @@ export function DockerTable({ containers, timestamp }: RouterOutputs["docker"]["
|
||||
enableBottomToolbar: false,
|
||||
positionGlobalFilter: "right",
|
||||
mantineSearchTextInputProps: {
|
||||
placeholder: tDocker("table.search", { count: containers.length }),
|
||||
placeholder: tDocker("table.search", { count: data.containers.length }),
|
||||
style: { minWidth: 300 },
|
||||
autoFocus: true,
|
||||
},
|
||||
@@ -93,13 +102,14 @@ export function DockerTable({ containers, timestamp }: RouterOutputs["docker"]["
|
||||
totalCount: table.getRowCount(),
|
||||
})}
|
||||
</Text>
|
||||
<ContainerActionBar />
|
||||
<ContainerActionBar selectedIds={table.getSelectedRowModel().rows.map((row) => row.original.id)} />
|
||||
</Group>
|
||||
);
|
||||
},
|
||||
|
||||
columns: createColumns(t),
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<Text>{tDocker("table.updated", { when: relativeTime })}</Text>
|
||||
@@ -108,31 +118,70 @@ export function DockerTable({ containers, timestamp }: RouterOutputs["docker"]["
|
||||
);
|
||||
}
|
||||
|
||||
const ContainerActionBar = () => {
|
||||
const t = useScopedI18n("docker.action");
|
||||
const sharedButtonProps = {
|
||||
variant: "light",
|
||||
radius: "md",
|
||||
} satisfies Partial<ButtonProps>;
|
||||
interface ContainerActionBarProps {
|
||||
selectedIds: string[];
|
||||
}
|
||||
|
||||
const ContainerActionBar = ({ selectedIds }: ContainerActionBarProps) => {
|
||||
return (
|
||||
<Group gap="xs">
|
||||
<Button leftSection={<IconPlayerPlay />} color="green" {...sharedButtonProps}>
|
||||
{t("start")}
|
||||
</Button>
|
||||
<Button leftSection={<IconPlayerStop />} color="red" {...sharedButtonProps}>
|
||||
{t("stop")}
|
||||
</Button>
|
||||
<Button leftSection={<IconRotateClockwise />} color="orange" {...sharedButtonProps}>
|
||||
{t("restart")}
|
||||
</Button>
|
||||
<Button leftSection={<IconTrash />} color="red" {...sharedButtonProps}>
|
||||
{t("remove")}
|
||||
</Button>
|
||||
<ContainerActionBarButton icon={IconPlayerPlay} color="green" action="start" selectedIds={selectedIds} />
|
||||
<ContainerActionBarButton icon={IconPlayerStop} color="red" action="stop" selectedIds={selectedIds} />
|
||||
<ContainerActionBarButton icon={IconRotateClockwise} color="orange" action="restart" selectedIds={selectedIds} />
|
||||
<ContainerActionBarButton icon={IconTrash} color="red" action="remove" selectedIds={selectedIds} />
|
||||
</Group>
|
||||
);
|
||||
};
|
||||
|
||||
interface ContainerActionBarButtonProps {
|
||||
icon: TablerIcon;
|
||||
color: MantineColor;
|
||||
action: "start" | "stop" | "restart" | "remove";
|
||||
selectedIds: string[];
|
||||
}
|
||||
|
||||
const ContainerActionBarButton = (props: ContainerActionBarButtonProps) => {
|
||||
const t = useScopedI18n("docker.action");
|
||||
const { mutateAsync, isPending } = clientApi.docker[`${props.action}All`].useMutation();
|
||||
const utils = clientApi.useUtils();
|
||||
|
||||
const handleClickAsync = async () => {
|
||||
await mutateAsync(
|
||||
{ ids: props.selectedIds },
|
||||
{
|
||||
async onSettled() {
|
||||
await utils.docker.getContainers.invalidate();
|
||||
},
|
||||
onSuccess() {
|
||||
showSuccessNotification({
|
||||
title: t(`${props.action}.notification.success.title`),
|
||||
message: t(`${props.action}.notification.success.message`),
|
||||
});
|
||||
},
|
||||
onError() {
|
||||
showErrorNotification({
|
||||
title: t(`${props.action}.notification.error.title`),
|
||||
message: t(`${props.action}.notification.error.message`),
|
||||
});
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Button
|
||||
leftSection={<props.icon />}
|
||||
color={props.color}
|
||||
onClick={handleClickAsync}
|
||||
loading={isPending}
|
||||
variant="light"
|
||||
radius="md"
|
||||
>
|
||||
{t(`${props.action}.label`)}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
const containerStates = {
|
||||
created: "cyan",
|
||||
running: "green",
|
||||
@@ -3,16 +3,20 @@ import { Stack, Title } from "@mantine/core";
|
||||
import { api } from "@homarr/api/server";
|
||||
import { getScopedI18n } from "@homarr/translation/server";
|
||||
|
||||
import { DockerTable } from "./DockerTable";
|
||||
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
|
||||
import { DockerTable } from "./docker-table";
|
||||
|
||||
export default async function DockerPage() {
|
||||
const { containers, timestamp } = await api.docker.getContainers();
|
||||
const tDocker = await getScopedI18n("docker");
|
||||
|
||||
return (
|
||||
<Stack>
|
||||
<Title order={1}>{tDocker("title")}</Title>
|
||||
<DockerTable containers={containers} timestamp={timestamp} />
|
||||
</Stack>
|
||||
<>
|
||||
<DynamicBreadcrumb />
|
||||
<Stack>
|
||||
<Title order={1}>{tDocker("title")}</Title>
|
||||
<DockerTable containers={containers} timestamp={timestamp} />
|
||||
</Stack>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import "@xterm/xterm/css/xterm.css";
|
||||
|
||||
import dynamic from "next/dynamic";
|
||||
|
||||
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
|
||||
import { fullHeightWithoutHeaderAndFooter } from "~/constants";
|
||||
import { createMetaTitle } from "~/metadata";
|
||||
|
||||
@@ -23,8 +24,11 @@ const ClientSideTerminalComponent = dynamic(() => import("./terminal"), {
|
||||
|
||||
export default function LogsManagementPage() {
|
||||
return (
|
||||
<Box style={{ borderRadius: 6 }} h={fullHeightWithoutHeaderAndFooter} p="md" bg="black">
|
||||
<ClientSideTerminalComponent />
|
||||
</Box>
|
||||
<>
|
||||
<DynamicBreadcrumb />
|
||||
<Box style={{ borderRadius: 6 }} h={fullHeightWithoutHeaderAndFooter} p="md" bg="black">
|
||||
<ClientSideTerminalComponent />
|
||||
</Box>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { ActionIcon, Badge, Card, Group, Stack, Text } from "@mantine/core";
|
||||
import { useListState } from "@mantine/hooks";
|
||||
import { IconPlayerPlay } from "@tabler/icons-react";
|
||||
|
||||
import type { RouterOutputs } from "@homarr/api";
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { useTimeAgo } from "@homarr/common";
|
||||
import type { TaskStatus } from "@homarr/cron-job-status";
|
||||
import type { TranslationKeys } from "@homarr/translation";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
interface JobsListProps {
|
||||
initialJobs: RouterOutputs["cronJobs"]["getJobs"];
|
||||
}
|
||||
|
||||
interface JobState {
|
||||
job: JobsListProps["initialJobs"][number];
|
||||
status: TaskStatus | null;
|
||||
}
|
||||
|
||||
export const JobsList = ({ initialJobs }: JobsListProps) => {
|
||||
const t = useScopedI18n("management.page.tool.tasks");
|
||||
const [jobs, handlers] = useListState<JobState>(
|
||||
initialJobs.map((job) => ({
|
||||
job,
|
||||
status: null,
|
||||
})),
|
||||
);
|
||||
clientApi.cronJobs.subscribeToStatusUpdates.useSubscription(undefined, {
|
||||
onData: (data) => {
|
||||
const jobByName = jobs.find((job) => job.job.name === data.name);
|
||||
if (!jobByName) {
|
||||
return;
|
||||
}
|
||||
handlers.applyWhere(
|
||||
(job) => job.job.name === data.name,
|
||||
(job) => ({ ...job, status: data }),
|
||||
);
|
||||
},
|
||||
});
|
||||
const { mutateAsync } = clientApi.cronJobs.triggerJob.useMutation();
|
||||
const handleJobTrigger = React.useCallback(
|
||||
async (job: JobState) => {
|
||||
if (job.status?.status === "running") {
|
||||
return;
|
||||
}
|
||||
await mutateAsync(job.job.name);
|
||||
},
|
||||
[mutateAsync],
|
||||
);
|
||||
return (
|
||||
<Stack>
|
||||
{jobs.map((job) => (
|
||||
<Card key={job.job.name}>
|
||||
<Group justify={"space-between"} gap={"md"}>
|
||||
<Stack gap={0}>
|
||||
<Group>
|
||||
<Text>{t(`${job.job.name}.label` as TranslationKeys)}</Text>
|
||||
{job.status?.status === "idle" && <Badge variant="default">{t("status.idle")}</Badge>}
|
||||
{job.status?.status === "running" && <Badge color="green">{t("status.running")}</Badge>}
|
||||
{job.status?.lastExecutionStatus === "error" && <Badge color="red">{t("status.error")}</Badge>}
|
||||
</Group>
|
||||
{job.status && <TimeAgo timestamp={job.status.lastExecutionTimestamp} />}
|
||||
</Stack>
|
||||
|
||||
<ActionIcon
|
||||
onClick={() => handleJobTrigger(job)}
|
||||
disabled={job.status?.status === "running"}
|
||||
variant={"default"}
|
||||
size={"xl"}
|
||||
radius={"xl"}
|
||||
>
|
||||
<IconPlayerPlay stroke={1.5} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
</Card>
|
||||
))}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
const TimeAgo = ({ timestamp }: { timestamp: string }) => {
|
||||
const timeAgo = useTimeAgo(new Date(timestamp));
|
||||
|
||||
return (
|
||||
<Text size={"sm"} c={"dimmed"}>
|
||||
{timeAgo}
|
||||
</Text>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,25 @@
|
||||
import { Box, Title } from "@mantine/core";
|
||||
|
||||
import { api } from "@homarr/api/server";
|
||||
import { getScopedI18n } from "@homarr/translation/server";
|
||||
|
||||
import { createMetaTitle } from "~/metadata";
|
||||
import { JobsList } from "./_components/jobs-list";
|
||||
|
||||
export async function generateMetadata() {
|
||||
const t = await getScopedI18n("management");
|
||||
|
||||
return {
|
||||
title: createMetaTitle(t("metaTitle")),
|
||||
};
|
||||
}
|
||||
|
||||
export default async function TasksPage() {
|
||||
const jobs = await api.cronJobs.getJobs();
|
||||
return (
|
||||
<Box>
|
||||
<Title mb={"md"}>Tasks</Title>
|
||||
<JobsList initialJobs={jobs} />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -136,6 +136,6 @@ const fileToBase64Async = async (file: File): Promise<string> =>
|
||||
new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.readAsDataURL(file);
|
||||
reader.onload = () => resolve(reader.result?.toString() || "");
|
||||
reader.onload = () => resolve(reader.result?.toString() ?? "");
|
||||
reader.onerror = reject;
|
||||
});
|
||||
@@ -35,7 +35,7 @@ export async function generateMetadata({ params }: Props) {
|
||||
const t = await getScopedI18n("management.page.user.edit");
|
||||
|
||||
return {
|
||||
title: createMetaTitle(t("metaTitle", { username: user?.name })),
|
||||
title: createMetaTitle(t("metaTitle", { username: user.name })),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import type { PropsWithChildren } from "react";
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import { Button, Container, Grid, GridCol, Group, Stack, Text, Title } from "@mantine/core";
|
||||
import { Grid, GridCol, Group, Stack, Text, Title } from "@mantine/core";
|
||||
import { IconSettings, IconShieldLock } from "@tabler/icons-react";
|
||||
|
||||
import { api } from "@homarr/api/server";
|
||||
@@ -9,6 +8,8 @@ import { auth } from "@homarr/auth/next";
|
||||
import { getI18n, getScopedI18n } from "@homarr/translation/server";
|
||||
import { UserAvatar } from "@homarr/ui";
|
||||
|
||||
import { ManageContainer } from "~/components/manage/manage-container";
|
||||
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
|
||||
import { catchTrpcNotFound } from "~/errors/trpc-not-found";
|
||||
import { NavigationLink } from "../groups/[id]/_navigation";
|
||||
import { canAccessUserEditPage } from "./access";
|
||||
@@ -28,22 +29,24 @@ export default async function Layout({ children, params }: PropsWithChildren<Lay
|
||||
}
|
||||
|
||||
return (
|
||||
<Container size="xl">
|
||||
<ManageContainer size="xl">
|
||||
<DynamicBreadcrumb
|
||||
dynamicMappings={
|
||||
new Map([
|
||||
[params.userId, user.name ?? ""],
|
||||
["general", t("navigationStructure.manage.users.general")],
|
||||
["security", t("navigationStructure.manage.users.security")],
|
||||
])
|
||||
}
|
||||
/>
|
||||
<Grid>
|
||||
<GridCol span={12}>
|
||||
<Group justify="space-between" align="center">
|
||||
<Group>
|
||||
<UserAvatar user={user} size="lg" />
|
||||
<Stack gap={0}>
|
||||
<Title order={3}>{user.name}</Title>
|
||||
<Text c="gray.5">{t("user.name")}</Text>
|
||||
</Stack>
|
||||
</Group>
|
||||
{session?.user.permissions.includes("admin") && (
|
||||
<Button component={Link} href="/manage/users" color="gray" variant="light">
|
||||
{tUser("back")}
|
||||
</Button>
|
||||
)}
|
||||
<UserAvatar user={user} size="lg" />
|
||||
<Stack gap={0}>
|
||||
<Title order={3}>{user.name}</Title>
|
||||
<Text c="gray.5">{t("user.name")}</Text>
|
||||
</Stack>
|
||||
</Group>
|
||||
</GridCol>
|
||||
<GridCol span={{ xs: 12, md: 4, lg: 3, xl: 2 }}>
|
||||
@@ -64,6 +67,6 @@ export default async function Layout({ children, params }: PropsWithChildren<Lay
|
||||
</GridCol>
|
||||
<GridCol span={{ xs: 12, md: 8, lg: 9, xl: 10 }}>{children}</GridCol>
|
||||
</Grid>
|
||||
</Container>
|
||||
</ManageContainer>
|
||||
);
|
||||
}
|
||||
@@ -71,7 +71,8 @@ export const UserCreateStepperComponent = () => {
|
||||
|
||||
const allForms = useMemo(() => [generalForm, securityForm], [generalForm, securityForm]);
|
||||
|
||||
const isCurrentFormValid = allForms[active] ? (allForms[active]!.isValid satisfies () => boolean) : () => true;
|
||||
const activeForm = allForms[active];
|
||||
const isCurrentFormValid = activeForm ? activeForm.isValid : () => true;
|
||||
const canNavigateToNextStep = isCurrentFormValid();
|
||||
|
||||
const controlledGoToNextStep = useCallback(async () => {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { getScopedI18n } from "@homarr/translation/server";
|
||||
|
||||
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
|
||||
import { createMetaTitle } from "~/metadata";
|
||||
import { UserCreateStepperComponent } from "./_components/create-user-stepper";
|
||||
|
||||
@@ -12,5 +13,10 @@ export async function generateMetadata() {
|
||||
}
|
||||
|
||||
export default function CreateUserPage() {
|
||||
return <UserCreateStepperComponent />;
|
||||
return (
|
||||
<>
|
||||
<DynamicBreadcrumb />
|
||||
<UserCreateStepperComponent />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,11 +1,12 @@
|
||||
import type { PropsWithChildren } from "react";
|
||||
import Link from "next/link";
|
||||
import { Button, Container, Grid, GridCol, Group, Stack, Text, Title } from "@mantine/core";
|
||||
import { Button, Grid, GridCol, Group, Stack, Text, Title } from "@mantine/core";
|
||||
import { IconLock, IconSettings, IconUsersGroup } from "@tabler/icons-react";
|
||||
|
||||
import { api } from "@homarr/api/server";
|
||||
import { getI18n, getScopedI18n } from "@homarr/translation/server";
|
||||
|
||||
import { ManageContainer } from "~/components/manage/manage-container";
|
||||
import { NavigationLink } from "./_navigation";
|
||||
|
||||
interface LayoutProps {
|
||||
@@ -18,7 +19,7 @@ export default async function Layout({ children, params }: PropsWithChildren<Lay
|
||||
const group = await api.group.getById({ id: params.id });
|
||||
|
||||
return (
|
||||
<Container size="xl">
|
||||
<ManageContainer size="xl">
|
||||
<Grid>
|
||||
<GridCol span={12}>
|
||||
<Group justify="space-between" align="center">
|
||||
@@ -54,6 +55,6 @@ export default async function Layout({ children, params }: PropsWithChildren<Lay
|
||||
</GridCol>
|
||||
<GridCol span={{ xs: 12, md: 8, lg: 9, xl: 10 }}>{children}</GridCol>
|
||||
</Grid>
|
||||
</Container>
|
||||
</ManageContainer>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback } from "react";
|
||||
import { Button } from "@mantine/core";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { useModalAction } from "@homarr/modals";
|
||||
@@ -9,6 +8,7 @@ import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import { UserSelectModal } from "~/app/[locale]/boards/[name]/settings/_access/user-select-modal";
|
||||
import { revalidatePathActionAsync } from "~/app/revalidatePathAction";
|
||||
import { MobileAffixButton } from "~/components/manage/mobile-affix-button";
|
||||
|
||||
interface AddGroupMemberProps {
|
||||
groupId: string;
|
||||
@@ -40,8 +40,8 @@ export const AddGroupMember = ({ groupId, presentUserIds }: AddGroupMemberProps)
|
||||
}, [openModal, presentUserIds, groupId, mutateAsync, tMembersAdd]);
|
||||
|
||||
return (
|
||||
<Button color="teal" onClick={handleAddMember}>
|
||||
<MobileAffixButton color="teal" onClick={handleAddMember}>
|
||||
{tMembersAdd("label")}
|
||||
</Button>
|
||||
</MobileAffixButton>
|
||||
);
|
||||
};
|
||||
@@ -24,7 +24,8 @@ export default async function GroupsDetailPage({ params, searchParams }: GroupsD
|
||||
const group = await api.group.getById({ id: params.id });
|
||||
|
||||
const filteredMembers = searchParams.search
|
||||
? group.members.filter((member) => member.name?.toLowerCase().includes(searchParams.search!.trim().toLowerCase()))
|
||||
? // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
group.members.filter((member) => member.name?.toLowerCase().includes(searchParams.search!.trim().toLowerCase()))
|
||||
: group.members;
|
||||
|
||||
return (
|
||||
|
||||
@@ -101,7 +101,7 @@ interface PermissionRowProps {
|
||||
|
||||
const PermissionRow = ({ name, label, description }: PermissionRowProps) => {
|
||||
return (
|
||||
<Group justify="space-between" align="center">
|
||||
<Group justify="space-between" align="center" wrap="nowrap">
|
||||
<Stack gap={0}>
|
||||
<Text fw={500}>{label}</Text>
|
||||
<Text c="gray.5">{description}</Text>
|
||||
|
||||
@@ -11,6 +11,7 @@ import { useI18n } from "@homarr/translation/client";
|
||||
import { validation } from "@homarr/validation";
|
||||
|
||||
import { revalidatePathActionAsync } from "~/app/revalidatePathAction";
|
||||
import { MobileAffixButton } from "~/components/manage/mobile-affix-button";
|
||||
|
||||
export const AddGroup = () => {
|
||||
const t = useI18n();
|
||||
@@ -21,9 +22,9 @@ export const AddGroup = () => {
|
||||
}, [openModal]);
|
||||
|
||||
return (
|
||||
<Button onClick={handleAddGroup} color="teal">
|
||||
<MobileAffixButton onClick={handleAddGroup} color="teal">
|
||||
{t("group.action.create.label")}
|
||||
</Button>
|
||||
</MobileAffixButton>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,17 +1,5 @@
|
||||
import Link from "next/link";
|
||||
import {
|
||||
Anchor,
|
||||
Container,
|
||||
Group,
|
||||
Stack,
|
||||
Table,
|
||||
TableTbody,
|
||||
TableTd,
|
||||
TableTh,
|
||||
TableThead,
|
||||
TableTr,
|
||||
Title,
|
||||
} from "@mantine/core";
|
||||
import { Anchor, Group, Stack, Table, TableTbody, TableTd, TableTh, TableThead, TableTr, Title } from "@mantine/core";
|
||||
|
||||
import type { RouterOutputs } from "@homarr/api";
|
||||
import { api } from "@homarr/api/server";
|
||||
@@ -19,6 +7,8 @@ import { getI18n } from "@homarr/translation/server";
|
||||
import { SearchInput, TablePagination, UserAvatarGroup } from "@homarr/ui";
|
||||
import { z } from "@homarr/validation";
|
||||
|
||||
import { ManageContainer } from "~/components/manage/manage-container";
|
||||
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
|
||||
import { AddGroup } from "./_add-group";
|
||||
|
||||
const searchParamsSchema = z.object({
|
||||
@@ -41,7 +31,8 @@ export default async function GroupsListPage(props: GroupsListPageProps) {
|
||||
const { items: groups, totalCount } = await api.group.getPaginated(searchParams);
|
||||
|
||||
return (
|
||||
<Container size="xl">
|
||||
<ManageContainer size="xl">
|
||||
<DynamicBreadcrumb />
|
||||
<Stack>
|
||||
<Title>{t("group.title")}</Title>
|
||||
<Group justify="space-between">
|
||||
@@ -72,7 +63,7 @@ export default async function GroupsListPage(props: GroupsListPageProps) {
|
||||
<TablePagination total={Math.ceil(totalCount / searchParams.pageSize)} />
|
||||
</Group>
|
||||
</Stack>
|
||||
</Container>
|
||||
</ManageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
import { api } from "@homarr/api/server";
|
||||
|
||||
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
|
||||
import { InviteListComponent } from "./_components/invite-list";
|
||||
|
||||
export default async function InvitesOverviewPage() {
|
||||
const initialInvites = await api.invite.getAll();
|
||||
return <InviteListComponent initialInvites={initialInvites} />;
|
||||
return (
|
||||
<>
|
||||
<DynamicBreadcrumb />
|
||||
<InviteListComponent initialInvites={initialInvites} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { api } from "@homarr/api/server";
|
||||
import { getScopedI18n } from "@homarr/translation/server";
|
||||
|
||||
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
|
||||
import { createMetaTitle } from "~/metadata";
|
||||
import { UserListComponent } from "./_components/user-list.component";
|
||||
|
||||
@@ -14,5 +15,10 @@ export async function generateMetadata() {
|
||||
|
||||
export default async function UsersPage() {
|
||||
const userList = await api.user.getAll();
|
||||
return <UserListComponent initialUserList={userList} />;
|
||||
return (
|
||||
<>
|
||||
<DynamicBreadcrumb />
|
||||
<UserListComponent initialUserList={userList} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -90,7 +90,6 @@ export const useItemActions = () => {
|
||||
const updateItemOptions = useCallback(
|
||||
({ itemId, newOptions }: UpdateItemOptions) => {
|
||||
updateBoard((previous) => {
|
||||
if (!previous) return previous;
|
||||
return {
|
||||
...previous,
|
||||
sections: previous.sections.map((section) => {
|
||||
@@ -117,7 +116,6 @@ export const useItemActions = () => {
|
||||
const updateItemAdvancedOptions = useCallback(
|
||||
({ itemId, newAdvancedOptions }: UpdateItemAdvancedOptions) => {
|
||||
updateBoard((previous) => {
|
||||
if (!previous) return previous;
|
||||
return {
|
||||
...previous,
|
||||
sections: previous.sections.map((section) => {
|
||||
@@ -144,7 +142,6 @@ export const useItemActions = () => {
|
||||
const updateItemIntegrations = useCallback(
|
||||
({ itemId, newIntegrations }: UpdateItemIntegrations) => {
|
||||
updateBoard((previous) => {
|
||||
if (!previous) return previous;
|
||||
return {
|
||||
...previous,
|
||||
sections: previous.sections.map((section) => {
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { Button, Card, Center, Grid, Stack, Text } from "@mantine/core";
|
||||
|
||||
import { objectEntries } from "@homarr/common";
|
||||
import type { WidgetKind } from "@homarr/definitions";
|
||||
import { createModal } from "@homarr/modals";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import { widgetImports } from "@homarr/widgets";
|
||||
import type { WidgetDefinition } from "@homarr/widgets";
|
||||
|
||||
import { objectEntries } from "../../../../../../packages/common/src";
|
||||
import { widgetImports } from "../../../../../../packages/widgets/src";
|
||||
import type { WidgetDefinition } from "../../../../../../packages/widgets/src/definition";
|
||||
import { useItemActions } from "./item-actions";
|
||||
|
||||
export const ItemSelectModal = createModal<void>(({ actions }) => {
|
||||
|
||||
@@ -37,6 +37,7 @@ export const useCategoryActions = () => {
|
||||
sections: [
|
||||
// Place sections before the new category
|
||||
...previous.sections.filter(
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
||||
(section) => (section.kind === "category" || section.kind === "empty") && section.position < position,
|
||||
),
|
||||
{
|
||||
@@ -56,6 +57,7 @@ export const useCategoryActions = () => {
|
||||
...previous.sections
|
||||
.filter(
|
||||
(section): section is CategorySection | EmptySection =>
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
||||
(section.kind === "category" || section.kind === "empty") && section.position >= position,
|
||||
)
|
||||
.map((section) => ({
|
||||
@@ -74,6 +76,7 @@ export const useCategoryActions = () => {
|
||||
const lastSection = previous.sections
|
||||
.filter(
|
||||
(section): section is CategorySection | EmptySection =>
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
||||
section.kind === "empty" || section.kind === "category",
|
||||
)
|
||||
.sort((sectionA, sectionB) => sectionB.position - sectionA.position)
|
||||
@@ -130,12 +133,13 @@ export const useCategoryActions = () => {
|
||||
(section): section is CategorySection => section.kind === "category" && section.id === id,
|
||||
);
|
||||
if (!currentCategory) return previous;
|
||||
if (currentCategory?.position === 1 && direction === "up") return previous;
|
||||
if (currentCategory?.position === previous.sections.length - 2 && direction === "down") return previous;
|
||||
if (currentCategory.position === 1 && direction === "up") return previous;
|
||||
if (currentCategory.position === previous.sections.length - 2 && direction === "down") return previous;
|
||||
|
||||
return {
|
||||
...previous,
|
||||
sections: previous.sections.map((section) => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
||||
if (section.kind !== "category" && section.kind !== "empty") return section;
|
||||
const offset = direction === "up" ? -2 : 2;
|
||||
// Move category and empty section
|
||||
|
||||
@@ -1,6 +1,3 @@
|
||||
/* eslint-disable react/no-unknown-property */
|
||||
// Ignored because of gridstack attributes
|
||||
|
||||
import type { RefObject } from "react";
|
||||
import { useEffect, useMemo, useRef } from "react";
|
||||
import { ActionIcon, Card, Menu } from "@mantine/core";
|
||||
@@ -65,14 +62,13 @@ const BoardItem = ({ refs, item, opacity }: ItemProps) => {
|
||||
gs-h={item.height}
|
||||
gs-min-w={1}
|
||||
gs-min-h={1}
|
||||
gs-max-w={4}
|
||||
gs-max-h={4}
|
||||
ref={refs.items.current[item.id] as RefObject<HTMLDivElement>}
|
||||
>
|
||||
<Card
|
||||
ref={ref}
|
||||
className={combineClasses(
|
||||
classes.itemCard,
|
||||
`${item.kind}-wrapper`,
|
||||
"grid-stack-item-content",
|
||||
item.advancedOptions.customCssClasses.join(" "),
|
||||
)}
|
||||
@@ -80,6 +76,7 @@ const BoardItem = ({ refs, item, opacity }: ItemProps) => {
|
||||
styles={{
|
||||
root: {
|
||||
"--opacity": opacity / 100,
|
||||
containerType: "size",
|
||||
},
|
||||
}}
|
||||
p={0}
|
||||
@@ -122,6 +119,7 @@ const BoardItemContent = ({ item, ...dimensions }: ItemContentProps) => {
|
||||
<Comp
|
||||
options={options as never}
|
||||
integrationIds={item.integrationIds}
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
||||
serverData={serverData?.data as never}
|
||||
isEditMode={isEditMode}
|
||||
boardId={board.id}
|
||||
@@ -208,8 +206,8 @@ const ItemMenu = ({
|
||||
return (
|
||||
<Menu withinPortal withArrow position="right-start" arrowPosition="center">
|
||||
<Menu.Target>
|
||||
<ActionIcon variant="transparent" pos="absolute" top={offset} right={offset} style={{ zIndex: 1 }}>
|
||||
<IconDotsVertical />
|
||||
<ActionIcon variant="default" radius={"xl"} pos="absolute" top={offset} right={offset} style={{ zIndex: 10 }}>
|
||||
<IconDotsVertical size={"1rem"} />
|
||||
</ActionIcon>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown miw={128}>
|
||||
|
||||
@@ -37,7 +37,7 @@ export const initializeGridstack = ({ section, refs, sectionColumnCount }: Initi
|
||||
`.grid-stack-${section.kind}[data-section-id='${section.id}']`,
|
||||
);
|
||||
const grid = newGrid.current;
|
||||
if (!grid) return false;
|
||||
|
||||
// Must be used to update the column count after the initialization
|
||||
grid.column(sectionColumnCount, "none");
|
||||
|
||||
|
||||
@@ -59,9 +59,13 @@ export const useGridstack = ({ section, mainRef }: UseGridstackProps): UseGrista
|
||||
// Updates the react-query state
|
||||
moveAndResizeItem({
|
||||
itemId,
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
xOffset: changedNode.x!,
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
yOffset: changedNode.y!,
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
width: changedNode.w!,
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
height: changedNode.h!,
|
||||
});
|
||||
},
|
||||
@@ -76,9 +80,13 @@ export const useGridstack = ({ section, mainRef }: UseGridstackProps): UseGrista
|
||||
moveItemToSection({
|
||||
itemId,
|
||||
sectionId: section.id,
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
xOffset: addedNode.x!,
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
yOffset: addedNode.y!,
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
width: addedNode.w!,
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
height: addedNode.h!,
|
||||
});
|
||||
},
|
||||
@@ -121,7 +129,6 @@ export const useGridstack = ({ section, mainRef }: UseGridstackProps): UseGrista
|
||||
}
|
||||
|
||||
// Only run this effect when the section items change
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [items.length, section.items.length, board.columnCount]);
|
||||
|
||||
return {
|
||||
|
||||
@@ -67,6 +67,8 @@ export const IconPicker = ({ initialValue, onChange, error, onFocus, onBlur }: I
|
||||
combobox.openDropdown();
|
||||
combobox.updateSelectedOptionIndex();
|
||||
setSearch(event.currentTarget.value);
|
||||
setValue(event.currentTarget.value);
|
||||
onChange(event.currentTarget.value);
|
||||
}}
|
||||
onClick={() => combobox.openDropdown()}
|
||||
onFocus={(event) => {
|
||||
@@ -81,7 +83,7 @@ export const IconPicker = ({ initialValue, onChange, error, onFocus, onBlur }: I
|
||||
rightSectionPointerEvents="none"
|
||||
withAsterisk
|
||||
error={error}
|
||||
label="Icon URL"
|
||||
label={t("iconPicker.label")}
|
||||
/>
|
||||
</Combobox.Target>
|
||||
|
||||
|
||||
@@ -20,11 +20,11 @@ export const useOptionalBackgroundProps = (): Partial<AppShellProps> => {
|
||||
}
|
||||
|
||||
return {
|
||||
bg: `url(${board?.backgroundImageUrl})`,
|
||||
bg: `url(${board.backgroundImageUrl})`,
|
||||
bgp: "center center",
|
||||
bgsz: board?.backgroundImageSize ?? "cover",
|
||||
bgr: board?.backgroundImageRepeat ?? "no-repeat",
|
||||
bga: board?.backgroundImageAttachment ?? "fixed",
|
||||
bgsz: board.backgroundImageSize,
|
||||
bgr: board.backgroundImageRepeat,
|
||||
bga: board.backgroundImageAttachment,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -49,7 +49,7 @@ export const BoardBackgroundVideo = () => {
|
||||
height: "100vh",
|
||||
top: 0,
|
||||
left: 0,
|
||||
objectFit: board.backgroundImageSize ?? "cover",
|
||||
objectFit: board.backgroundImageSize,
|
||||
}}
|
||||
>
|
||||
<source src={board.backgroundImageUrl} type={`video/${videoFormat}`} />
|
||||
|
||||
@@ -22,7 +22,6 @@ const headerButtonActionIconProps: ActionIconProps = {
|
||||
size: "lg",
|
||||
};
|
||||
|
||||
// eslint-disable-next-line react/display-name
|
||||
export const HeaderButton = forwardRef<HTMLButtonElement, HeaderButtonProps>((props, ref) => {
|
||||
if ("href" in props) {
|
||||
return (
|
||||
|
||||
@@ -35,19 +35,24 @@ const NavLinkHref = (props: NavigationLinkHref) => {
|
||||
);
|
||||
};
|
||||
|
||||
const NavLinkWithItems = (props: NavigationLinkWithItems) => (
|
||||
<NavLink
|
||||
style={{
|
||||
borderRadius: 5,
|
||||
}}
|
||||
label={props.label}
|
||||
leftSection={props.icon}
|
||||
>
|
||||
{props.items.map((item) => (
|
||||
<NavLinkHref key={item.label} {...item} />
|
||||
))}
|
||||
</NavLink>
|
||||
);
|
||||
const NavLinkWithItems = (props: NavigationLinkWithItems) => {
|
||||
const pathname = usePathname();
|
||||
const isActive = props.items.some((item) => item.href === pathname);
|
||||
return (
|
||||
<NavLink
|
||||
style={{
|
||||
borderRadius: 5,
|
||||
}}
|
||||
label={props.label}
|
||||
leftSection={props.icon}
|
||||
defaultOpened={isActive}
|
||||
>
|
||||
{props.items.map((item) => (
|
||||
<NavLinkHref key={item.label} {...item} />
|
||||
))}
|
||||
</NavLink>
|
||||
);
|
||||
};
|
||||
|
||||
interface CommonNavigationLinkProps {
|
||||
label: string;
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import { Button, Group, Stack, TextInput } from "@mantine/core";
|
||||
import { boardSchemas } from "node_modules/@homarr/validation/src/board";
|
||||
|
||||
import { useZodForm } from "@homarr/form";
|
||||
import { createModal } from "@homarr/modals";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import { z } from "@homarr/validation";
|
||||
import { validation, z } from "@homarr/validation";
|
||||
import { createCustomErrorParams } from "@homarr/validation/form";
|
||||
|
||||
interface InnerProps {
|
||||
@@ -16,7 +15,7 @@ export const AddBoardModal = createModal<InnerProps>(({ actions, innerProps }) =
|
||||
const t = useI18n();
|
||||
const form = useZodForm(
|
||||
z.object({
|
||||
name: boardSchemas.byName.shape.name.refine((value) => !innerProps.boardNames.includes(value), {
|
||||
name: validation.board.byName.shape.name.refine((value) => !innerProps.boardNames.includes(value), {
|
||||
params: createCustomErrorParams("boardAlreadyExists"),
|
||||
}),
|
||||
}),
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
import type { PropsWithChildren } from "react";
|
||||
import type { MantineSize } from "@mantine/core";
|
||||
import { Container } from "@mantine/core";
|
||||
|
||||
export const ManageContainer = ({ children, size }: PropsWithChildren<{ size?: MantineSize }>) => {
|
||||
return (
|
||||
<Container size={size} px={{ base: "0 !important", md: "var(--mantine-spacing-md) !important" }}>
|
||||
{children}
|
||||
</Container>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,16 @@
|
||||
import { forwardRef } from "react";
|
||||
import type { ButtonProps } from "@mantine/core";
|
||||
import { Affix, Button, createPolymorphicComponent } from "@mantine/core";
|
||||
|
||||
type MobileAffixButtonProps = Omit<ButtonProps, "visibleFrom" | "hiddenFrom">;
|
||||
|
||||
export const MobileAffixButton = createPolymorphicComponent<"button", MobileAffixButtonProps>(
|
||||
forwardRef<HTMLButtonElement, MobileAffixButtonProps>((props, ref) => (
|
||||
<>
|
||||
<Button ref={ref} visibleFrom="md" {...props} />
|
||||
<Affix hiddenFrom="md" position={{ bottom: 20, right: 20 }}>
|
||||
<Button ref={ref} {...props} />
|
||||
</Affix>
|
||||
</>
|
||||
)),
|
||||
);
|
||||
@@ -0,0 +1,82 @@
|
||||
"use client";
|
||||
|
||||
import { usePathname } from "next/navigation";
|
||||
import { Anchor, Badge, Breadcrumbs, Text } from "@mantine/core";
|
||||
import { IconHomeFilled } from "@tabler/icons-react";
|
||||
|
||||
import type { TranslationKeys } from "@homarr/translation";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
interface DynamicBreadcrumbProps {
|
||||
customHome?: string | null;
|
||||
customHomeLink?: string;
|
||||
dynamicMappings?: Map<string, string>;
|
||||
nonInteractable?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Breadcrumb is client side rendered. Elements are automatically
|
||||
* calculated and translated using dynamic keys.
|
||||
* For dynamic routes (e.g. UIDs, names , ...),
|
||||
* you can pass dynamic mappings to define their values
|
||||
* in your parent component.
|
||||
* @constructor
|
||||
*/
|
||||
export const DynamicBreadcrumb = ({
|
||||
dynamicMappings,
|
||||
customHome = "manage",
|
||||
customHomeLink = "/manage",
|
||||
nonInteractable,
|
||||
}: DynamicBreadcrumbProps) => {
|
||||
const pathname = usePathname();
|
||||
const pathnameParts = pathname.split("/").filter((part) => part.length > 0);
|
||||
const t = useScopedI18n("navigationStructure");
|
||||
const tNavbar = useScopedI18n("management.navbar.items");
|
||||
|
||||
const length = pathnameParts.filter((part) => part !== customHome).length;
|
||||
|
||||
if (length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Breadcrumbs w="100%" mb="md">
|
||||
<Badge
|
||||
styles={{ root: { cursor: "pointer" } }}
|
||||
component={"a"}
|
||||
href={customHomeLink}
|
||||
leftSection={<IconHomeFilled size="1rem" />}
|
||||
variant="default"
|
||||
tt="initial"
|
||||
h="auto"
|
||||
>
|
||||
<Text fw="bold">{tNavbar("home")}</Text>
|
||||
</Badge>
|
||||
{pathnameParts.map((pathnamePart, index) => {
|
||||
if (pathnamePart === customHome) {
|
||||
return null;
|
||||
}
|
||||
const href = `/${pathnameParts.slice(0, index + 1).join("/")}`;
|
||||
const translationKey = `${pathnameParts.slice(0, index + 1).join(".")}`;
|
||||
|
||||
if (nonInteractable?.includes(pathnamePart)) {
|
||||
return <Text key={href}>{t(`${translationKey}.label` as TranslationKeys)}</Text>;
|
||||
}
|
||||
|
||||
if (dynamicMappings?.has(pathnamePart)) {
|
||||
return (
|
||||
<Anchor key={href} href={href}>
|
||||
{dynamicMappings.get(pathnamePart)}
|
||||
</Anchor>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Anchor key={href} href={href}>
|
||||
{t(`${translationKey}.label` as TranslationKeys)}
|
||||
</Anchor>
|
||||
);
|
||||
})}
|
||||
</Breadcrumbs>
|
||||
);
|
||||
};
|
||||
@@ -72,7 +72,7 @@ export const UserAvatarMenu = ({ children }: UserAvatarMenuProps) => {
|
||||
<>
|
||||
<Menu.Item
|
||||
component={Link}
|
||||
href={`/manage/users/${session.data?.user.id}`}
|
||||
href={`/manage/users/${session.data?.user.id}/general`}
|
||||
leftSection={<IconSettings size="1rem" />}
|
||||
>
|
||||
{t("preferences")}
|
||||
|
||||
@@ -17,7 +17,8 @@ export const UserAvatar = async ({ size }: UserAvatarProps) => {
|
||||
|
||||
if (!currentSession?.user) return <Avatar {...commonProps} />;
|
||||
if (currentSession.user.image)
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
return <Avatar {...commonProps} src={currentSession.user.image} alt={currentSession.user.name!} />;
|
||||
|
||||
return <Avatar {...commonProps}>{currentSession.user.name!.substring(0, 2).toUpperCase()}</Avatar>;
|
||||
return <Avatar {...commonProps}>{currentSession.user.name?.substring(0, 2).toUpperCase()}</Avatar>;
|
||||
};
|
||||