Compare commits
105
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
510c9b945d | ||
|
|
2405e1b931 | ||
|
|
41fe5928f1 | ||
|
|
6eb59d6f53 | ||
|
|
2c6b7a818e | ||
|
|
fd10788838 | ||
|
|
fe4598f722 | ||
|
|
a755692d81 | ||
|
|
3ff0d72ba1 | ||
|
|
a63a191203 | ||
|
|
1be906a0cd | ||
|
|
63d8fa8c26 | ||
|
|
b5dd6ae8ef | ||
|
|
d540ad374a | ||
|
|
6a971c1986 | ||
|
|
d86fe3ddf0 | ||
|
|
06db8960a7 | ||
|
|
a5719acf34 | ||
|
|
d1d597c8e1 | ||
|
|
657e42e4bb | ||
|
|
256ebcb864 | ||
|
|
72e86dee01 | ||
|
|
b7910b16e0 | ||
|
|
5a83bb6e39 | ||
|
|
433b66582d | ||
|
|
acbcb6818b | ||
|
|
07d991a0a1 | ||
|
|
92364e9574 | ||
|
|
2991777b0a | ||
|
|
6cc2ac56a9 | ||
|
|
92086e060f | ||
|
|
2aa5ab95e6 | ||
|
|
650f761325 | ||
|
|
be61602576 | ||
|
|
d01834d975 | ||
|
|
4a67bf0ef3 | ||
|
|
4454e3fb7a | ||
|
|
c252b4f9ac | ||
|
|
4f2b3cf3ee | ||
|
|
b19549b601 | ||
|
|
a0cc68798d | ||
|
|
9a538941ee | ||
|
|
fc12500b3e | ||
|
|
64dccec362 | ||
|
|
f7ea1b33a0 | ||
|
|
fe3cebb902 | ||
|
|
ba1d213870 | ||
|
|
8f03a38de5 | ||
|
|
605af19cd8 | ||
|
|
b4921af60d | ||
|
|
32385206ad | ||
|
|
7e59ba3d5e | ||
|
|
81caaa9d6c | ||
|
|
09243148da | ||
|
|
c7c57887cf | ||
|
|
990a924631 | ||
|
|
83caf3ef4b | ||
|
|
991f07ae51 | ||
|
|
bc05128cd4 | ||
|
|
cf0fb8a879 | ||
|
|
1f31735ee0 | ||
|
|
ff974bf343 | ||
|
|
d42bbfce17 | ||
|
|
0130303e74 | ||
|
|
66212b4e47 | ||
|
|
f10bb3ae23 | ||
|
|
af1991a4a3 | ||
|
|
9a9551130c | ||
|
|
a93f03eae4 | ||
|
|
df4b57bd41 | ||
|
|
dade477b32 | ||
|
|
79f88dc3b8 | ||
|
|
4a12f8be61 | ||
|
|
044ad9edf9 | ||
|
|
57d2c6a7e1 | ||
|
|
46f6301fdd | ||
|
|
c3f300d87d | ||
|
|
1de558dd51 | ||
|
|
2711f3db13 | ||
|
|
ff3933b335 | ||
|
|
fa737638a8 | ||
|
|
97b7c417ab | ||
|
|
58d8005d38 | ||
|
|
507ac5181f | ||
|
|
b0cdb81533 | ||
|
|
fc0367a32f | ||
|
|
48ef8eb6c0 | ||
|
|
7cd30b640c | ||
|
|
e9142c0dbc | ||
|
|
a9855fe8d3 | ||
|
|
7a1272146c | ||
|
|
db61b6f390 | ||
|
|
887c294191 | ||
|
|
2f2a6291a2 | ||
|
|
1b3353118d | ||
|
|
e03f56ac31 | ||
|
|
d571a7f75e | ||
|
|
e3b765b9bd | ||
|
|
4da313826d | ||
|
|
948ed7a7fa | ||
|
|
f7fc64ebc4 | ||
|
|
b7f51d6f5c | ||
|
|
e185eeb6d7 | ||
|
|
90d703b870 | ||
|
|
75640f21e0 |
No files matched your search
@@ -0,0 +1,2 @@
|
|||||||
|
-P ubuntu-latest=catthehacker/ubuntu:act-latest
|
||||||
|
--container-architecture linux/amd64
|
||||||
+3
-6
@@ -3,9 +3,6 @@ version = 1
|
|||||||
[[analyzers]]
|
[[analyzers]]
|
||||||
name = "javascript"
|
name = "javascript"
|
||||||
|
|
||||||
[analyzers.meta]
|
[analyzers.meta]
|
||||||
plugins = ["react"]
|
plugins = ["react"]
|
||||||
environment = ["nodejs"]
|
environment = ["nodejs"]
|
||||||
|
|
||||||
[[transformers]]
|
|
||||||
name = "prettier"
|
|
||||||
+5
-1
@@ -9,4 +9,8 @@ README.md
|
|||||||
.git
|
.git
|
||||||
dev
|
dev
|
||||||
.build
|
.build
|
||||||
e2e
|
e2e
|
||||||
|
apps/nextjs/.next
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
@@ -41,6 +41,14 @@ DB_URL='FULL_PATH_TO_YOUR_SQLITE_DB_FILE'
|
|||||||
|
|
||||||
TURBO_TELEMETRY_DISABLED=1
|
TURBO_TELEMETRY_DISABLED=1
|
||||||
|
|
||||||
|
# Turborepo remote cache (optional, for faster CI/local builds)
|
||||||
|
# Uncomment and fill in to enable remote caching locally.
|
||||||
|
# CI uses GitHub secrets for these values automatically.
|
||||||
|
# TURBO_API=https://your-cache-worker.workers.dev
|
||||||
|
# TURBO_TEAM=team_your_team
|
||||||
|
# TURBO_TOKEN=your-turbo-token
|
||||||
|
# TURBO_REMOTE_CACHE_SIGNATURE_KEY=your-signature-key
|
||||||
|
|
||||||
# Comma-separated paths to Docker-compatible Unix sockets.
|
# Comma-separated paths to Docker-compatible Unix sockets.
|
||||||
# Useful for connecting to multiple runtimes (Docker + Podman) simultaneously.
|
# Useful for connecting to multiple runtimes (Docker + Podman) simultaneously.
|
||||||
# When unset and no TCP hosts configured, defaults to /var/run/docker.sock.
|
# When unset and no TCP hosts configured, defaults to /var/run/docker.sock.
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ body:
|
|||||||
options:
|
options:
|
||||||
# The below comment is used to insert a new version with on-release.yml
|
# The below comment is used to insert a new version with on-release.yml
|
||||||
#NEXT_VERSION#
|
#NEXT_VERSION#
|
||||||
|
- 1.62.0
|
||||||
- 1.61.0
|
- 1.61.0
|
||||||
- 1.60.1-beta.1
|
- 1.60.1-beta.1
|
||||||
- 1.60.0
|
- 1.60.0
|
||||||
|
|||||||
@@ -19,7 +19,10 @@ body:
|
|||||||
- type: textarea
|
- type: textarea
|
||||||
attributes:
|
attributes:
|
||||||
label: Additional information
|
label: Additional information
|
||||||
description: Add any other information related to the integration.
|
description: |
|
||||||
|
Add any other information related to the integration.
|
||||||
|
|
||||||
|
After your integration is merged to `dev`, a bot PR updates [CODEOWNERS](https://github.com/homarr-labs/homarr/blob/dev/CODEOWNERS) so you are requested on future PRs touching your folders. Run `pnpm scripts:generate-codeowners` locally for faster updates. Add email mappings in `scripts/codeowners-overrides.json` if the generator cannot resolve your GitHub user.
|
||||||
- type: dropdown
|
- type: dropdown
|
||||||
attributes:
|
attributes:
|
||||||
label: Public API available?
|
label: Public API available?
|
||||||
|
|||||||
@@ -19,7 +19,10 @@ body:
|
|||||||
- type: textarea
|
- type: textarea
|
||||||
attributes:
|
attributes:
|
||||||
label: Additional information
|
label: Additional information
|
||||||
description: Add any other information related to the widget.
|
description: |
|
||||||
|
Add any other information related to the widget.
|
||||||
|
|
||||||
|
After your widget is merged to `dev`, a bot PR updates [CODEOWNERS](https://github.com/homarr-labs/homarr/blob/dev/CODEOWNERS) so you are requested on future PRs touching your folders. Run `pnpm scripts:generate-codeowners` locally for faster updates. Add email mappings in `scripts/codeowners-overrides.json` if the generator cannot resolve your GitHub user.
|
||||||
- type: dropdown
|
- type: dropdown
|
||||||
attributes:
|
attributes:
|
||||||
label: Are you willing to contribute this yourself?
|
label: Are you willing to contribute this yourself?
|
||||||
|
|||||||
@@ -15,13 +15,54 @@ concurrency:
|
|||||||
|
|
||||||
env:
|
env:
|
||||||
FORCE_COLOR: 3
|
FORCE_COLOR: 3
|
||||||
|
SKIP_ENV_VALIDATION: true
|
||||||
|
TURBO_API: ${{ secrets.TURBO_API }}
|
||||||
|
TURBO_TEAM: ${{ secrets.TURBO_TEAM }}
|
||||||
|
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
|
||||||
|
TURBO_REMOTE_CACHE_SIGNATURE_KEY: ${{ secrets.TURBO_REMOTE_CACHE_SIGNATURE_KEY }}
|
||||||
|
TURBO_SCM_BASE: ${{ github.event.pull_request.base.sha || github.event.before || 'HEAD~1' }}
|
||||||
|
TURBO_SCM_HEAD: ${{ github.sha }}
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
|
lint-format-autofix:
|
||||||
|
if: github.event_name == 'pull_request'
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 5
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v6
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
ref: ${{ github.head_ref }}
|
||||||
|
|
||||||
|
- name: Setup
|
||||||
|
uses: ./tooling/github/setup
|
||||||
|
|
||||||
|
- name: Get changed files
|
||||||
|
id: changed
|
||||||
|
run: |
|
||||||
|
git diff --name-only --diff-filter=ACMR origin/${{ github.base_ref }}...HEAD -- '*.ts' '*.tsx' '*.js' '*.jsx' > /tmp/changed-files
|
||||||
|
echo "count=$(wc -l < /tmp/changed-files | tr -d '[:space:]')" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
|
- name: Auto-fix lint + format
|
||||||
|
if: steps.changed.outputs.count != '0'
|
||||||
|
run: |
|
||||||
|
xargs -r -a /tmp/changed-files pnpm exec oxlint --fix || true
|
||||||
|
xargs -r -a /tmp/changed-files pnpm exec oxfmt --write
|
||||||
|
|
||||||
|
- name: Commit fixes
|
||||||
|
uses: stefanzweifel/git-auto-commit-action@v5
|
||||||
|
with:
|
||||||
|
commit_message: "style: auto-fix lint and format"
|
||||||
|
|
||||||
lint:
|
lint:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 10
|
timeout-minutes: 10
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v6
|
- uses: actions/checkout@v6
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
- name: Setup
|
- name: Setup
|
||||||
uses: ./tooling/github/setup
|
uses: ./tooling/github/setup
|
||||||
@@ -31,31 +72,35 @@ jobs:
|
|||||||
run: cp .env.example .env
|
run: cp .env.example .env
|
||||||
|
|
||||||
- name: Lint
|
- name: Lint
|
||||||
run: pnpm lint && pnpm lint:ws
|
run: pnpm turbo lint --affected && pnpm lint:ws
|
||||||
|
|
||||||
format:
|
format:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 10
|
timeout-minutes: 10
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v6
|
- uses: actions/checkout@v6
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
- name: Setup
|
- name: Setup
|
||||||
uses: ./tooling/github/setup
|
uses: ./tooling/github/setup
|
||||||
|
|
||||||
- name: Format
|
- name: Format
|
||||||
run: pnpm format
|
run: pnpm turbo format --affected --continue
|
||||||
|
|
||||||
typecheck:
|
typecheck:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 10
|
timeout-minutes: 10
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v6
|
- uses: actions/checkout@v6
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
- name: Setup
|
- name: Setup
|
||||||
uses: ./tooling/github/setup
|
uses: ./tooling/github/setup
|
||||||
|
|
||||||
- name: Typecheck
|
- name: Typecheck
|
||||||
run: pnpm typecheck
|
run: pnpm turbo typecheck --affected
|
||||||
|
|
||||||
test:
|
test:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
@@ -78,11 +123,12 @@ jobs:
|
|||||||
e2e:
|
e2e:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 15
|
timeout-minutes: 15
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v6
|
- uses: actions/checkout@v6
|
||||||
- name: Setup
|
- name: Setup
|
||||||
uses: ./tooling/github/setup
|
uses: ./tooling/github/setup
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v4
|
||||||
- name: Build docker image
|
- name: Build docker image
|
||||||
id: build-docker-image
|
id: build-docker-image
|
||||||
uses: docker/build-push-action@v7
|
uses: docker/build-push-action@v7
|
||||||
@@ -90,8 +136,11 @@ jobs:
|
|||||||
platforms: linux/amd64
|
platforms: linux/amd64
|
||||||
context: .
|
context: .
|
||||||
push: false
|
push: false
|
||||||
|
load: true
|
||||||
tags: homarr-e2e
|
tags: homarr-e2e
|
||||||
network: host
|
network: host
|
||||||
|
cache-from: type=gha
|
||||||
|
cache-to: type=gha,mode=max
|
||||||
env:
|
env:
|
||||||
SKIP_ENV_VALIDATION: true
|
SKIP_ENV_VALIDATION: true
|
||||||
- name: Install playwright browsers
|
- name: Install playwright browsers
|
||||||
@@ -105,10 +154,12 @@ jobs:
|
|||||||
timeout-minutes: 10
|
timeout-minutes: 10
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v6
|
- uses: actions/checkout@v6
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
- name: Setup
|
- name: Setup
|
||||||
uses: ./tooling/github/setup
|
uses: ./tooling/github/setup
|
||||||
- name: Copy env
|
- name: Copy env
|
||||||
shell: bash
|
shell: bash
|
||||||
run: cp .env.example .env
|
run: cp .env.example .env
|
||||||
- name: Build
|
- name: Build
|
||||||
run: pnpm build
|
run: pnpm turbo build --affected
|
||||||
@@ -66,7 +66,7 @@ jobs:
|
|||||||
- uses: actions/setup-node@v6
|
- uses: actions/setup-node@v6
|
||||||
if: env.SKIP_RELEASE == 'false'
|
if: env.SKIP_RELEASE == 'false'
|
||||||
with:
|
with:
|
||||||
node-version: 24.15.0
|
node-version: 24.16.0
|
||||||
package-manager-cache: false
|
package-manager-cache: false
|
||||||
- run: npm i -g pnpm
|
- run: npm i -g pnpm
|
||||||
if: env.SKIP_RELEASE == 'false'
|
if: env.SKIP_RELEASE == 'false'
|
||||||
|
|||||||
@@ -0,0 +1,131 @@
|
|||||||
|
name: Update CODEOWNERS
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch: {}
|
||||||
|
pull_request:
|
||||||
|
paths:
|
||||||
|
- CODEOWNERS
|
||||||
|
- packages/widgets/src/**
|
||||||
|
- packages/integrations/src/**
|
||||||
|
- packages/api/src/router/widgets/**
|
||||||
|
- packages/cron-jobs/src/jobs/**
|
||||||
|
- scripts/generate-codeowners.mts
|
||||||
|
- scripts/validate-codeowners.mts
|
||||||
|
- scripts/codeowners-overrides.json
|
||||||
|
push:
|
||||||
|
paths:
|
||||||
|
- packages/widgets/src/**
|
||||||
|
- packages/integrations/src/**
|
||||||
|
- packages/api/src/router/widgets/**
|
||||||
|
- packages/cron-jobs/src/jobs/**
|
||||||
|
- scripts/generate-codeowners.mts
|
||||||
|
- scripts/validate-codeowners.mts
|
||||||
|
- scripts/codeowners-overrides.json
|
||||||
|
branches:
|
||||||
|
- dev
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
pull-requests: write
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
validate:
|
||||||
|
name: Validate CODEOWNERS
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 10
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v6
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Use Node.js
|
||||||
|
uses: actions/setup-node@v6
|
||||||
|
with:
|
||||||
|
node-version-file: .nvmrc
|
||||||
|
|
||||||
|
- uses: pnpm/action-setup@v6
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
- name: Check CODEOWNERS is up to date
|
||||||
|
run: pnpm exec tsx ./scripts/generate-codeowners.mts --check
|
||||||
|
|
||||||
|
- name: Validate CODEOWNERS
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ github.token }}
|
||||||
|
GITHUB_REPOSITORY: ${{ github.repository }}
|
||||||
|
run: pnpm exec tsx ./scripts/validate-codeowners.mts
|
||||||
|
|
||||||
|
update:
|
||||||
|
name: Open CODEOWNERS update PR
|
||||||
|
needs: validate
|
||||||
|
if: github.event_name == 'push' && github.ref == 'refs/heads/dev'
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 10
|
||||||
|
concurrency:
|
||||||
|
group: update-codeowners
|
||||||
|
cancel-in-progress: false
|
||||||
|
steps:
|
||||||
|
- name: Obtain token
|
||||||
|
id: obtainToken
|
||||||
|
uses: actions/create-github-app-token@v3
|
||||||
|
with:
|
||||||
|
private-key: ${{ secrets.HOMARR_UPDATE_CONTRIBUTORS_PRIVATE_KEY }}
|
||||||
|
client-id: ${{ vars.HOMARR_UPDATE_CONTRIBUTORS_APP_ID }}
|
||||||
|
permission-contents: write
|
||||||
|
permission-pull-requests: write
|
||||||
|
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v6
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ steps.obtainToken.outputs.token }}
|
||||||
|
|
||||||
|
- name: Use Node.js
|
||||||
|
uses: actions/setup-node@v6
|
||||||
|
with:
|
||||||
|
node-version-file: .nvmrc
|
||||||
|
|
||||||
|
- uses: pnpm/action-setup@v6
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
- name: Generate CODEOWNERS
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ steps.obtainToken.outputs.token }}
|
||||||
|
run: pnpm scripts:generate-codeowners
|
||||||
|
|
||||||
|
- name: Validate CODEOWNERS
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ steps.obtainToken.outputs.token }}
|
||||||
|
GITHUB_REPOSITORY: ${{ github.repository }}
|
||||||
|
run: pnpm exec tsx ./scripts/validate-codeowners.mts
|
||||||
|
|
||||||
|
- name: Create Pull Request
|
||||||
|
id: create-pull-request
|
||||||
|
uses: peter-evans/create-pull-request@v8
|
||||||
|
with:
|
||||||
|
token: ${{ steps.obtainToken.outputs.token }}
|
||||||
|
branch: update-codeowners
|
||||||
|
base: dev
|
||||||
|
title: "chore: update widget/integration CODEOWNERS"
|
||||||
|
delete-branch: true
|
||||||
|
commit-message: "chore: update widget/integration CODEOWNERS"
|
||||||
|
body: |
|
||||||
|
This PR updates per-widget and per-integration CODEOWNERS entries from git history.
|
||||||
|
|
||||||
|
Unmapped authors can be added in `scripts/codeowners-overrides.json`.
|
||||||
|
|
||||||
|
- name: Install GitHub CLI
|
||||||
|
run: sudo apt-get install -y gh
|
||||||
|
|
||||||
|
- name: Enable auto-merge
|
||||||
|
if: steps.create-pull-request.outputs.pull-request-number != ''
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ steps.obtainToken.outputs.token }}
|
||||||
|
run: |
|
||||||
|
gh pr merge ${{ steps.create-pull-request.outputs.pull-request-number }} --auto --squash
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"$schema": "./node_modules/oxfmt/configuration_schema.json",
|
||||||
|
"printWidth": 120,
|
||||||
|
"endOfLine": "lf",
|
||||||
|
"sortPackageJson": true,
|
||||||
|
"ignorePatterns": ["**/*.cjs", "**/lang/**", "static-data/**", "CHANGELOG.md"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
{
|
||||||
|
"$schema": "./node_modules/oxlint/configuration_schema.json",
|
||||||
|
"plugins": ["eslint", "typescript", "unicorn", "oxc", "react", "import", "jsx-a11y"],
|
||||||
|
"categories": {
|
||||||
|
"correctness": "error",
|
||||||
|
"suspicious": "warn",
|
||||||
|
"pedantic": "off",
|
||||||
|
"perf": "off",
|
||||||
|
"style": "off",
|
||||||
|
"restriction": "off",
|
||||||
|
"nursery": "off"
|
||||||
|
},
|
||||||
|
"rules": {
|
||||||
|
"eslint/no-unused-vars": ["error", { "argsIgnorePattern": "^_", "varsIgnorePattern": "^_" }],
|
||||||
|
"typescript/consistent-type-imports": ["warn", { "prefer": "type-imports", "fixStyle": "separate-type-imports" }],
|
||||||
|
"typescript/no-non-null-assertion": "error",
|
||||||
|
"import/consistent-type-specifier-style": ["error", "prefer-top-level"],
|
||||||
|
"unicorn/no-new-array": "warn",
|
||||||
|
"unicorn/no-useless-spread": "warn",
|
||||||
|
"unicorn/no-useless-length-check": "warn",
|
||||||
|
"unicorn/no-useless-fallback-in-spread": "warn",
|
||||||
|
"jsx-a11y/prefer-tag-over-role": "warn",
|
||||||
|
"oxc/const-comparisons": "warn",
|
||||||
|
"react-hooks/exhaustive-deps": "warn",
|
||||||
|
"react/no-children-prop": "warn",
|
||||||
|
"jsx-a11y/no-autofocus": "warn",
|
||||||
|
"jsx-a11y/img-redundant-alt": "warn",
|
||||||
|
"react/react-in-jsx-scope": "off"
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"reportUnusedDisableDirectives": "off"
|
||||||
|
},
|
||||||
|
"ignorePatterns": ["**/*.config.js", "**/*.config.mjs", "**/*.cjs", "**/lang/*.d.json.ts", ".next/**"],
|
||||||
|
"settings": {
|
||||||
|
"react": { "version": "19" }
|
||||||
|
}
|
||||||
|
}
|
||||||
+1
-2
@@ -31,6 +31,5 @@ scopeRangeRegex: "(?:const|let|var)\\s+t\\s*=\\s*(?:await\\s+)?(?:getScopedI18n|
|
|||||||
# refactorTemplates:
|
# refactorTemplates:
|
||||||
# - i18n.get("$1")
|
# - i18n.get("$1")
|
||||||
|
|
||||||
|
|
||||||
# If set to true, only enables this custom framework (will disable all built-in frameworks)
|
# If set to true, only enables this custom framework (will disable all built-in frameworks)
|
||||||
monopoly: true
|
monopoly: true
|
||||||
Vendored
+5
-15
@@ -15,9 +15,7 @@
|
|||||||
"request": "launch",
|
"request": "launch",
|
||||||
"command": "pnpm docker:dev",
|
"command": "pnpm docker:dev",
|
||||||
"cwd": "${workspaceFolder}",
|
"cwd": "${workspaceFolder}",
|
||||||
"skipFiles": [
|
"skipFiles": ["<node_internals>/**"]
|
||||||
"<node_internals>/**"
|
|
||||||
]
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "lint fix",
|
"name": "lint fix",
|
||||||
@@ -25,9 +23,7 @@
|
|||||||
"request": "launch",
|
"request": "launch",
|
||||||
"command": "pnpm lint:fix",
|
"command": "pnpm lint:fix",
|
||||||
"cwd": "${workspaceFolder}",
|
"cwd": "${workspaceFolder}",
|
||||||
"skipFiles": [
|
"skipFiles": ["<node_internals>/**"]
|
||||||
"<node_internals>/**"
|
|
||||||
]
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "format fix",
|
"name": "format fix",
|
||||||
@@ -35,9 +31,7 @@
|
|||||||
"request": "launch",
|
"request": "launch",
|
||||||
"command": "pnpm format:fix",
|
"command": "pnpm format:fix",
|
||||||
"cwd": "${workspaceFolder}",
|
"cwd": "${workspaceFolder}",
|
||||||
"skipFiles": [
|
"skipFiles": ["<node_internals>/**"]
|
||||||
"<node_internals>/**"
|
|
||||||
]
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "db push",
|
"name": "db push",
|
||||||
@@ -69,9 +63,7 @@
|
|||||||
"request": "launch",
|
"request": "launch",
|
||||||
"command": "pnpm test",
|
"command": "pnpm test",
|
||||||
"cwd": "${workspaceFolder}",
|
"cwd": "${workspaceFolder}",
|
||||||
"skipFiles": [
|
"skipFiles": ["<node_internals>/**"]
|
||||||
"<node_internals>/**"
|
|
||||||
]
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "test ui",
|
"name": "test ui",
|
||||||
@@ -79,9 +71,7 @@
|
|||||||
"request": "launch",
|
"request": "launch",
|
||||||
"command": "pnpm test:ui",
|
"command": "pnpm test:ui",
|
||||||
"cwd": "${workspaceFolder}",
|
"cwd": "${workspaceFolder}",
|
||||||
"skipFiles": [
|
"skipFiles": ["<node_internals>/**"]
|
||||||
"<node_internals>/**"
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
Vendored
+3
-14
@@ -1,13 +1,6 @@
|
|||||||
{
|
{
|
||||||
"eslint.workingDirectories": [
|
|
||||||
{
|
|
||||||
"mode": "auto"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"eslint.experimental.useFlatConfig": true,
|
|
||||||
"typescript.tsdk": "node_modules\\typescript\\lib",
|
"typescript.tsdk": "node_modules\\typescript\\lib",
|
||||||
"js/ts.implicitProjectConfig.experimentalDecorators": true,
|
"js/ts.implicitProjectConfig.experimentalDecorators": true,
|
||||||
"prettier.configPath": "./tooling/prettier/index.mjs",
|
|
||||||
"cSpell.words": [
|
"cSpell.words": [
|
||||||
"ajnart",
|
"ajnart",
|
||||||
"cqmin",
|
"cqmin",
|
||||||
@@ -34,11 +27,7 @@
|
|||||||
],
|
],
|
||||||
"i18n-ally.dirStructure": "auto",
|
"i18n-ally.dirStructure": "auto",
|
||||||
"i18n-ally.displayLanguage": "en",
|
"i18n-ally.displayLanguage": "en",
|
||||||
"i18n-ally.enabledFrameworks": [
|
"i18n-ally.enabledFrameworks": ["custom"],
|
||||||
"custom"
|
"i18n-ally.localesPaths": ["packages/translation/src/lang"],
|
||||||
],
|
"i18n-ally.keystyle": "nested"
|
||||||
"i18n-ally.localesPaths": [
|
|
||||||
"packages/translation/src/lang",
|
|
||||||
],
|
|
||||||
"i18n-ally.keystyle": "nested",
|
|
||||||
}
|
}
|
||||||
@@ -1,3 +1,9 @@
|
|||||||
|
## [1.61.1](https://github.com/homarr-labs/homarr/compare/v1.61.0...v1.61.1) (2026-05-18)
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* **downloads-widget:** increase name column default width ratio ([e9142c0](https://github.com/homarr-labs/homarr/commit/e9142c0dbcaca6cd9215b5292a2d139d96919b51))
|
||||||
|
|
||||||
## [1.61.1-beta.1](https://github.com/homarr-labs/homarr/compare/v1.61.0...v1.61.1-beta.1) (2026-05-08)
|
## [1.61.1-beta.1](https://github.com/homarr-labs/homarr/compare/v1.61.0...v1.61.1-beta.1) (2026-05-08)
|
||||||
|
|
||||||
### Performance Improvements
|
### Performance Improvements
|
||||||
|
|||||||
+83
@@ -11,3 +11,86 @@ pnpm-lock.yaml
|
|||||||
pnpm-workspace.yaml
|
pnpm-workspace.yaml
|
||||||
Dockerfile
|
Dockerfile
|
||||||
docker-compose.yml
|
docker-compose.yml
|
||||||
|
|
||||||
|
# BEGIN GENERATED widget/integration owners
|
||||||
|
/packages/api/src/router/widgets/anchor-notes.ts /packages/integrations/src/anchor/ /packages/widgets/src/anchor-note/ @omariqbal
|
||||||
|
/packages/api/src/router/widgets/app.ts /packages/widgets/src/app/ @manuel-rw
|
||||||
|
/packages/api/src/router/widgets/calendar.ts /packages/widgets/src/calendar/ @manuel-rw
|
||||||
|
/packages/api/src/router/widgets/coolify.ts /packages/integrations/src/coolify/ /packages/widgets/src/coolify/ @Rhiz3K
|
||||||
|
/packages/api/src/router/widgets/dns-hole.ts /packages/cron-jobs/src/jobs/integrations/dns-hole.ts /packages/widgets/src/dns-hole/controls/ @manuel-rw
|
||||||
|
/packages/api/src/router/widgets/dns-hole.ts /packages/cron-jobs/src/jobs/integrations/dns-hole.ts /packages/widgets/src/dns-hole/summary/ @manuel-rw
|
||||||
|
/packages/api/src/router/widgets/downloads.ts /packages/cron-jobs/src/jobs/integrations/downloads.ts /packages/widgets/src/downloads/ @manuel-rw
|
||||||
|
/packages/api/src/router/widgets/firewall.ts /packages/cron-jobs/src/jobs/integrations/firewall.ts /packages/widgets/src/firewall/ @Oupsman
|
||||||
|
/packages/api/src/router/widgets/health-monitoring.ts /packages/cron-jobs/src/jobs/integrations/health-monitoring.ts /packages/widgets/src/health-monitoring/ @dgshue
|
||||||
|
/packages/api/src/router/widgets/immich.ts /packages/integrations/src/immich/ /packages/widgets/src/immich/album-carousel/ @manuel-rw
|
||||||
|
/packages/api/src/router/widgets/immich.ts /packages/integrations/src/immich/ /packages/widgets/src/immich/server-stats/ @manuel-rw
|
||||||
|
/packages/api/src/router/widgets/indexer-manager.ts /packages/cron-jobs/src/jobs/integrations/indexer-manager.ts /packages/widgets/src/indexer-manager/ @manuel-rw
|
||||||
|
/packages/api/src/router/widgets/media-release.ts /packages/widgets/src/media-releases/ @manuel-rw
|
||||||
|
/packages/api/src/router/widgets/media-requests.ts /packages/cron-jobs/src/jobs/integrations/media-requests.ts /packages/widgets/src/media-requests/list/ @manuel-rw
|
||||||
|
/packages/api/src/router/widgets/media-requests.ts /packages/cron-jobs/src/jobs/integrations/media-requests.ts /packages/widgets/src/media-requests/stats/ @manuel-rw
|
||||||
|
/packages/api/src/router/widgets/media-server.ts /packages/cron-jobs/src/jobs/integrations/media-server.ts /packages/widgets/src/media-server/ @manuel-rw
|
||||||
|
/packages/api/src/router/widgets/media-transcoding.ts /packages/cron-jobs/src/jobs/integrations/media-transcoding.ts /packages/integrations/src/media-transcoding/ /packages/widgets/src/media-transcoding/ @manuel-rw
|
||||||
|
/packages/api/src/router/widgets/minecraft.ts /packages/widgets/src/minecraft/server-status/ @manuel-rw
|
||||||
|
/packages/api/src/router/widgets/network-controller.ts /packages/cron-jobs/src/jobs/integrations/network-controller.ts /packages/widgets/src/network-controller/network-status/ @pitschi
|
||||||
|
/packages/api/src/router/widgets/network-controller.ts /packages/cron-jobs/src/jobs/integrations/network-controller.ts /packages/widgets/src/network-controller/summary/ @pitschi
|
||||||
|
/packages/api/src/router/widgets/notebook.ts /packages/widgets/src/notebook/ @manuel-rw
|
||||||
|
/packages/api/src/router/widgets/notifications.ts /packages/cron-jobs/src/jobs/integrations/notifications.ts /packages/widgets/src/notifications/ @itzTheMeow
|
||||||
|
/packages/api/src/router/widgets/releases.ts /packages/widgets/src/releases/ @Aandree5
|
||||||
|
/packages/api/src/router/widgets/rssFeed.ts /packages/widgets/src/rssFeed/ @manuel-rw
|
||||||
|
/packages/api/src/router/widgets/smart-home.ts /packages/cron-jobs/src/jobs/integrations/home-assistant.ts /packages/widgets/src/smart-home/entity-state/ @manuel-rw
|
||||||
|
/packages/api/src/router/widgets/smart-home.ts /packages/cron-jobs/src/jobs/integrations/home-assistant.ts /packages/widgets/src/smart-home/execute-automation/ @manuel-rw
|
||||||
|
/packages/api/src/router/widgets/speedtest-tracker.ts /packages/integrations/src/speedtest-tracker/ /packages/widgets/src/speedtest-tracker/ @kylegrieder
|
||||||
|
/packages/api/src/router/widgets/stocks.ts /packages/widgets/src/stocks/ @jackwellerreal
|
||||||
|
/packages/api/src/router/widgets/timetable.ts /packages/cron-jobs/src/jobs/integrations/timetable.ts /packages/widgets/src/timetable/ @manuel-rw
|
||||||
|
/packages/api/src/router/widgets/tracearr.ts /packages/cron-jobs/src/jobs/integrations/tracearr.ts /packages/integrations/src/tracearr/ /packages/widgets/src/tracearr/ @StrandedTurtle
|
||||||
|
/packages/api/src/router/widgets/umami.ts /packages/cron-jobs/src/jobs/integrations/umami.ts /packages/integrations/src/umami/ /packages/widgets/src/umami/ @febrile42
|
||||||
|
/packages/api/src/router/widgets/weather.ts /packages/cron-jobs/src/jobs/weather.ts /packages/widgets/src/weather/ @manuel-rw
|
||||||
|
/packages/cron-jobs/src/jobs/docker.ts /packages/widgets/src/docker/ @ashtonbatty
|
||||||
|
/packages/cron-jobs/src/jobs/integrations/dns-hole.ts /packages/integrations/src/pi-hole/ @manuel-rw
|
||||||
|
/packages/cron-jobs/src/jobs/integrations/downloads.ts /packages/integrations/src/download-client/aria2/ @manuel-rw
|
||||||
|
/packages/cron-jobs/src/jobs/integrations/downloads.ts /packages/integrations/src/download-client/deluge/ @manuel-rw
|
||||||
|
/packages/cron-jobs/src/jobs/integrations/downloads.ts /packages/integrations/src/download-client/nzbget/ @manuel-rw
|
||||||
|
/packages/cron-jobs/src/jobs/integrations/downloads.ts /packages/integrations/src/download-client/qbittorrent/ @manuel-rw
|
||||||
|
/packages/cron-jobs/src/jobs/integrations/downloads.ts /packages/integrations/src/download-client/sabnzbd/ @manuel-rw
|
||||||
|
/packages/cron-jobs/src/jobs/integrations/downloads.ts /packages/integrations/src/download-client/slskd/ @manuel-rw
|
||||||
|
/packages/cron-jobs/src/jobs/integrations/downloads.ts /packages/integrations/src/download-client/transmission/ @manuel-rw
|
||||||
|
/packages/cron-jobs/src/jobs/integrations/home-assistant.ts /packages/integrations/src/homeassistant/ @manuel-rw
|
||||||
|
/packages/cron-jobs/src/jobs/integrations/media-organizer.ts /packages/integrations/src/media-organizer/lidarr/ @NicolasNewman
|
||||||
|
/packages/cron-jobs/src/jobs/integrations/media-organizer.ts /packages/integrations/src/media-organizer/radarr/ @manuel-rw
|
||||||
|
/packages/cron-jobs/src/jobs/integrations/media-organizer.ts /packages/integrations/src/media-organizer/readarr/ @manuel-rw
|
||||||
|
/packages/cron-jobs/src/jobs/integrations/media-organizer.ts /packages/integrations/src/media-organizer/sonarr/ @manuel-rw
|
||||||
|
/packages/integrations/src/adguard-home/ @manuel-rw
|
||||||
|
/packages/integrations/src/codeberg/ @Aandree5
|
||||||
|
/packages/integrations/src/dashdot/ @dgshue
|
||||||
|
/packages/integrations/src/docker-hub/ @Aandree5
|
||||||
|
/packages/integrations/src/emby/ @manuel-rw
|
||||||
|
/packages/integrations/src/github-container-registry/ @manuel-rw
|
||||||
|
/packages/integrations/src/github/ @manuel-rw
|
||||||
|
/packages/integrations/src/gitlab/ @Aandree5
|
||||||
|
/packages/integrations/src/glances/ @dgshue
|
||||||
|
/packages/integrations/src/ical/ @NicolasNewman
|
||||||
|
/packages/integrations/src/jellyfin/ @manuel-rw
|
||||||
|
/packages/integrations/src/jellyseerr/ @manuel-rw
|
||||||
|
/packages/integrations/src/linuxserverio/ @Aandree5
|
||||||
|
/packages/integrations/src/nextcloud/ @manuel-rw
|
||||||
|
/packages/integrations/src/npm/ @Aandree5
|
||||||
|
/packages/integrations/src/ntfy/ @manuel-rw
|
||||||
|
/packages/integrations/src/openmediavault/ @dgshue
|
||||||
|
/packages/integrations/src/opnsense/ @manuel-rw
|
||||||
|
/packages/integrations/src/overseerr/ @manuel-rw
|
||||||
|
/packages/integrations/src/plex/ @manuel-rw
|
||||||
|
/packages/integrations/src/prowlarr/ @manuel-rw
|
||||||
|
/packages/integrations/src/proxmox/ @manuel-rw
|
||||||
|
/packages/integrations/src/quay/ @Aandree5
|
||||||
|
/packages/integrations/src/search-ch/ @manuel-rw
|
||||||
|
/packages/integrations/src/seerr/ @manuel-rw
|
||||||
|
/packages/integrations/src/truenas/ @dgshue
|
||||||
|
/packages/integrations/src/unifi-controller/ @manuel-rw
|
||||||
|
/packages/integrations/src/unraid/ @dgshue
|
||||||
|
/packages/widgets/src/bookmarks/ @manuel-rw
|
||||||
|
/packages/widgets/src/clock/ @manuel-rw
|
||||||
|
/packages/widgets/src/iframe/ @manuel-rw
|
||||||
|
/packages/widgets/src/system-disks/ @manuel-rw
|
||||||
|
/packages/widgets/src/system-resources/ @kylegrieder
|
||||||
|
/packages/widgets/src/video/ @manuel-rw
|
||||||
|
# END GENERATED widget/integration owners
|
||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
FROM node:24.15.0-alpine AS base
|
FROM node:24.16.0-alpine AS base
|
||||||
|
|
||||||
FROM base AS builder
|
FROM base AS builder
|
||||||
RUN apk add --no-cache libc6-compat
|
RUN apk add --no-cache libc6-compat
|
||||||
|
|||||||
@@ -1,30 +0,0 @@
|
|||||||
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,
|
|
||||||
{
|
|
||||||
files: ["public/sw.js"],
|
|
||||||
languageOptions: {
|
|
||||||
globals: {
|
|
||||||
self: "readonly",
|
|
||||||
clients: "readonly",
|
|
||||||
skipWaiting: "readonly",
|
|
||||||
importScripts: "readonly",
|
|
||||||
caches: "readonly",
|
|
||||||
fetch: "readonly",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
rules: {
|
|
||||||
"@typescript-eslint/no-unsafe-call": "off",
|
|
||||||
"@typescript-eslint/no-unsafe-member-access": "off",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
];
|
|
||||||
@@ -6,7 +6,6 @@ import "@homarr/core/infrastructure/logs/env";
|
|||||||
import "@homarr/docker/env";
|
import "@homarr/docker/env";
|
||||||
|
|
||||||
import type { NextConfig } from "next";
|
import type { NextConfig } from "next";
|
||||||
import MillionLint from "@million/lint";
|
|
||||||
import createNextIntlPlugin from "next-intl/plugin";
|
import createNextIntlPlugin from "next-intl/plugin";
|
||||||
|
|
||||||
// Package path does not work... so we need to use relative path
|
// Package path does not work... so we need to use relative path
|
||||||
@@ -85,8 +84,4 @@ const nextConfig: NextConfig = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
// Skip transform is used because of webpack loader, without it for example 'Tooltip.Floating' will not work and show an error
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
||||||
const withMillionLint = MillionLint.next({ rsc: true, skipTransform: true, telemetry: false });
|
|
||||||
|
|
||||||
export default withNextIntl(nextConfig);
|
export default withNextIntl(nextConfig);
|
||||||
@@ -7,13 +7,12 @@
|
|||||||
"build": "pnpm with-env next build",
|
"build": "pnpm with-env next build",
|
||||||
"clean": "git clean -xdf .next .turbo node_modules",
|
"clean": "git clean -xdf .next .turbo node_modules",
|
||||||
"dev": "pnpm with-env next dev",
|
"dev": "pnpm with-env next dev",
|
||||||
"format": "prettier --check . --ignore-path ../../.gitignore",
|
"format": "oxfmt .",
|
||||||
"lint": "eslint",
|
"lint": "oxlint",
|
||||||
"start": "pnpm with-env next start",
|
"start": "pnpm with-env next start",
|
||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
"with-env": "dotenv -e ../../.env --"
|
"with-env": "dotenv -e ../../.env --"
|
||||||
},
|
},
|
||||||
"prettier": "@homarr/prettier-config",
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@dnd-kit/core": "catalog:",
|
"@dnd-kit/core": "catalog:",
|
||||||
"@dnd-kit/modifiers": "catalog:",
|
"@dnd-kit/modifiers": "catalog:",
|
||||||
@@ -56,7 +55,6 @@
|
|||||||
"@mantine/hooks": "catalog:",
|
"@mantine/hooks": "catalog:",
|
||||||
"@mantine/modals": "catalog:",
|
"@mantine/modals": "catalog:",
|
||||||
"@mantine/tiptap": "catalog:",
|
"@mantine/tiptap": "catalog:",
|
||||||
"@million/lint": "catalog:",
|
|
||||||
"@tabler/icons-react": "catalog:",
|
"@tabler/icons-react": "catalog:",
|
||||||
"@tanstack/react-query": "catalog:",
|
"@tanstack/react-query": "catalog:",
|
||||||
"@tanstack/react-query-devtools": "catalog:",
|
"@tanstack/react-query-devtools": "catalog:",
|
||||||
@@ -93,8 +91,6 @@
|
|||||||
"zod": "catalog:"
|
"zod": "catalog:"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@homarr/eslint-config": "workspace:^0.2.0",
|
|
||||||
"@homarr/prettier-config": "workspace:^0.1.0",
|
|
||||||
"@homarr/tsconfig": "workspace:^0.1.0",
|
"@homarr/tsconfig": "workspace:^0.1.0",
|
||||||
"@types/chroma-js": "catalog:",
|
"@types/chroma-js": "catalog:",
|
||||||
"@types/node": "catalog:",
|
"@types/node": "catalog:",
|
||||||
@@ -103,9 +99,7 @@
|
|||||||
"@types/react-dom": "catalog:",
|
"@types/react-dom": "catalog:",
|
||||||
"@types/swagger-ui-react": "catalog:",
|
"@types/swagger-ui-react": "catalog:",
|
||||||
"concurrently": "catalog:",
|
"concurrently": "catalog:",
|
||||||
"eslint": "catalog:",
|
|
||||||
"node-loader": "catalog:",
|
"node-loader": "catalog:",
|
||||||
"prettier": "catalog:",
|
|
||||||
"typescript": "catalog:"
|
"typescript": "catalog:"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2,13 +2,14 @@
|
|||||||
|
|
||||||
import type { PropsWithChildren } from "react";
|
import type { PropsWithChildren } from "react";
|
||||||
import type { MantineColorScheme, MantineColorSchemeManager } from "@mantine/core";
|
import type { MantineColorScheme, MantineColorSchemeManager } from "@mantine/core";
|
||||||
import { createTheme, DirectionProvider, MantineProvider } from "@mantine/core";
|
import { DirectionProvider, MantineProvider, v8CssVariablesResolver } from "@mantine/core";
|
||||||
import dayjs from "dayjs";
|
import dayjs from "dayjs";
|
||||||
|
|
||||||
import { clientApi } from "@homarr/api/client";
|
import { clientApi } from "@homarr/api/client";
|
||||||
import { useSession } from "@homarr/auth/client";
|
import { useSession } from "@homarr/auth/client";
|
||||||
import { parseCookies, setClientCookie } from "@homarr/common";
|
import { parseCookies, setClientCookie } from "@homarr/common";
|
||||||
import { colorSchemeCookieKey } from "@homarr/definitions";
|
import { colorSchemeCookieKey } from "@homarr/definitions";
|
||||||
|
import { theme } from "@homarr/ui";
|
||||||
|
|
||||||
export const CustomMantineProvider = ({
|
export const CustomMantineProvider = ({
|
||||||
children,
|
children,
|
||||||
@@ -20,10 +21,8 @@ export const CustomMantineProvider = ({
|
|||||||
<MantineProvider
|
<MantineProvider
|
||||||
defaultColorScheme={defaultColorScheme}
|
defaultColorScheme={defaultColorScheme}
|
||||||
colorSchemeManager={manager}
|
colorSchemeManager={manager}
|
||||||
theme={createTheme({
|
theme={theme}
|
||||||
primaryColor: "red",
|
cssVariablesResolver={v8CssVariablesResolver}
|
||||||
autoContrast: true,
|
|
||||||
})}
|
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</MantineProvider>
|
</MantineProvider>
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ export default async function InviteUsagePage(props: InviteUsagePageProps) {
|
|||||||
{t("subtitle")}
|
{t("subtitle")}
|
||||||
</Text>
|
</Text>
|
||||||
</Stack>
|
</Stack>
|
||||||
<Card withBorder w={64 * 6} maw="90vw">
|
<Card w={64 * 6} maw="90vw">
|
||||||
<RegistrationForm invite={invite} />
|
<RegistrationForm invite={invite} />
|
||||||
</Card>
|
</Card>
|
||||||
<Text size="xs" c="gray.5" ta="center">
|
<Text size="xs" c="gray.5" ta="center">
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import { z } from "zod/v4";
|
|||||||
|
|
||||||
import { signIn } from "@homarr/auth/client";
|
import { signIn } from "@homarr/auth/client";
|
||||||
import { revalidatePathActionAsync } from "@homarr/common/client";
|
import { revalidatePathActionAsync } from "@homarr/common/client";
|
||||||
import type { useForm } from "@homarr/form";
|
import type { UseFormReturnType } from "@homarr/form";
|
||||||
import { useZodForm } from "@homarr/form";
|
import { useZodForm } from "@homarr/form";
|
||||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||||
import { useScopedI18n } from "@homarr/translation/client";
|
import { useScopedI18n } from "@homarr/translation/client";
|
||||||
@@ -162,7 +162,7 @@ export const LoginForm = ({ providers, oidcClientName, isOidcAutoLoginEnabled, c
|
|||||||
|
|
||||||
interface SubmitButtonProps {
|
interface SubmitButtonProps {
|
||||||
isPending: boolean;
|
isPending: boolean;
|
||||||
form: ReturnType<typeof useForm<FormType, (values: FormType) => FormType>>;
|
form: UseFormReturnType<FormType>;
|
||||||
provider: "credentials" | "ldap";
|
provider: "credentials" | "ldap";
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -198,7 +198,7 @@ const PasswordForgottenCollapse = ({ username }: PasswordForgottenCollapseProps)
|
|||||||
{tForgotPassword("label")}
|
{tForgotPassword("label")}
|
||||||
</Anchor>
|
</Anchor>
|
||||||
|
|
||||||
<Collapse in={visible}>
|
<Collapse expanded={visible}>
|
||||||
<Card>
|
<Card>
|
||||||
<Stack gap="xs">
|
<Stack gap="xs">
|
||||||
<Text size="sm">{tForgotPassword("description")}</Text>
|
<Text size="sm">{tForgotPassword("description")}</Text>
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ export default async function Login(props: LoginProps) {
|
|||||||
{t("subtitle")}
|
{t("subtitle")}
|
||||||
</Text>
|
</Text>
|
||||||
</Stack>
|
</Stack>
|
||||||
<Card withBorder w={64 * 6} maw="90vw">
|
<Card w={64 * 6} maw="90vw">
|
||||||
<LoginForm
|
<LoginForm
|
||||||
providers={env.AUTH_PROVIDERS}
|
providers={env.AUTH_PROVIDERS}
|
||||||
oidcClientName={env.AUTH_OIDC_CLIENT_NAME}
|
oidcClientName={env.AUTH_OIDC_CLIENT_NAME}
|
||||||
|
|||||||
@@ -1,70 +1,27 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useCallback, useRef } from "react";
|
import { Box, Stack } from "@mantine/core";
|
||||||
import { Box, LoadingOverlay, Stack } from "@mantine/core";
|
|
||||||
|
|
||||||
import type { RouterOutputs } from "@homarr/api";
|
|
||||||
import { clientApi } from "@homarr/api/client";
|
|
||||||
import { useCurrentLayout, useRequiredBoard } from "@homarr/boards/context";
|
import { useCurrentLayout, useRequiredBoard } from "@homarr/boards/context";
|
||||||
|
|
||||||
import { BoardCategorySection } from "~/components/board/sections/category-section";
|
import { BoardCategorySection } from "~/components/board/sections/category-section";
|
||||||
import { BoardEmptySection } from "~/components/board/sections/empty-section";
|
import { BoardEmptySection } from "~/components/board/sections/empty-section";
|
||||||
import { BoardBackgroundVideo } from "~/components/layout/background";
|
import { BoardBackgroundVideo } from "~/components/layout/background";
|
||||||
import { fullHeightWithoutHeaderAndFooter } from "~/constants";
|
|
||||||
import { useIsBoardReady } from "./_ready-context";
|
|
||||||
|
|
||||||
let boardName: string | null = null;
|
|
||||||
|
|
||||||
export const updateBoardName = (name: string | null) => {
|
|
||||||
boardName = name;
|
|
||||||
};
|
|
||||||
|
|
||||||
type UpdateCallback = (prev: RouterOutputs["board"]["getHomeBoard"]) => RouterOutputs["board"]["getHomeBoard"];
|
|
||||||
|
|
||||||
export const useUpdateBoard = () => {
|
|
||||||
const utils = clientApi.useUtils();
|
|
||||||
|
|
||||||
const updateBoard = useCallback(
|
|
||||||
(updaterWithoutUndefined: UpdateCallback) => {
|
|
||||||
if (!boardName) {
|
|
||||||
throw new Error("Board name is not set");
|
|
||||||
}
|
|
||||||
utils.board.getBoardByName.setData({ name: boardName }, (previous) =>
|
|
||||||
previous ? updaterWithoutUndefined(previous) : previous,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
[utils],
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
|
||||||
updateBoard,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
export const ClientBoard = () => {
|
export const ClientBoard = () => {
|
||||||
const board = useRequiredBoard();
|
const board = useRequiredBoard();
|
||||||
const currentLayoutId = useCurrentLayout();
|
const currentLayoutId = useCurrentLayout();
|
||||||
const isReady = useIsBoardReady();
|
|
||||||
|
|
||||||
const fullWidthSortedSections = board.sections
|
const fullWidthSortedSections = board.sections
|
||||||
.filter((section) => section.kind === "empty" || section.kind === "category")
|
.filter((section) => section.kind === "empty" || section.kind === "category")
|
||||||
.sort((sectionA, sectionB) => sectionA.yOffset - sectionB.yOffset);
|
.sort((sectionA, sectionB) => sectionA.yOffset - sectionB.yOffset);
|
||||||
|
|
||||||
const ref = useRef<HTMLDivElement>(null);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box h="100%" pos="relative">
|
<Box h="100%" pos="relative">
|
||||||
<BoardBackgroundVideo />
|
<BoardBackgroundVideo />
|
||||||
<LoadingOverlay
|
<Stack h="100%">
|
||||||
visible={!isReady}
|
|
||||||
transitionProps={{ duration: 500 }}
|
|
||||||
loaderProps={{ size: "lg" }}
|
|
||||||
h={fullHeightWithoutHeaderAndFooter}
|
|
||||||
/>
|
|
||||||
<Stack ref={ref} h="100%" style={{ visibility: isReady ? "visible" : "hidden" }}>
|
|
||||||
{fullWidthSortedSections.map((section) =>
|
{fullWidthSortedSections.map((section) =>
|
||||||
section.kind === "empty" ? (
|
section.kind === "empty" ? (
|
||||||
// Unique keys per layout to always reinitialize the gridstack
|
|
||||||
<BoardEmptySection key={`${currentLayoutId}-${section.id}`} section={section} />
|
<BoardEmptySection key={`${currentLayoutId}-${section.id}`} section={section} />
|
||||||
) : (
|
) : (
|
||||||
<BoardCategorySection key={`${currentLayoutId}-${section.id}`} section={section} />
|
<BoardCategorySection key={`${currentLayoutId}-${section.id}`} section={section} />
|
||||||
|
|||||||
@@ -41,13 +41,11 @@ export const createBoardContentPage = <TParams extends Record<string, unknown>>(
|
|||||||
}),
|
}),
|
||||||
// eslint-disable-next-line no-restricted-syntax
|
// eslint-disable-next-line no-restricted-syntax
|
||||||
page: async ({ params }: { params: Promise<TParams> }) => {
|
page: async ({ params }: { params: Promise<TParams> }) => {
|
||||||
const session = await auth();
|
const resolvedParams = await params;
|
||||||
const integrations = await getIntegrationsWithPermissionsAsync(session);
|
|
||||||
|
|
||||||
const board = await getInitialBoard(await params);
|
|
||||||
const queryClient = getQueryClient();
|
const queryClient = getQueryClient();
|
||||||
|
|
||||||
// Prefetch item data
|
const [board, session] = await Promise.all([getInitialBoard(resolvedParams), auth()]);
|
||||||
|
|
||||||
const itemsMap = board.items.reduce((acc, item) => {
|
const itemsMap = board.items.reduce((acc, item) => {
|
||||||
const existing = acc.get(item.kind);
|
const existing = acc.get(item.kind);
|
||||||
if (existing) {
|
if (existing) {
|
||||||
@@ -58,17 +56,20 @@ export const createBoardContentPage = <TParams extends Record<string, unknown>>(
|
|||||||
return acc;
|
return acc;
|
||||||
}, new Map<WidgetKind, Item[]>());
|
}, new Map<WidgetKind, Item[]>());
|
||||||
|
|
||||||
for (const [kind, items] of itemsMap) {
|
const [integrations] = await Promise.all([
|
||||||
await prefetchForKindAsync(kind, queryClient, items).catch((error) => {
|
getIntegrationsWithPermissionsAsync(session),
|
||||||
logger.error(
|
...Array.from(itemsMap).map(([kind, items]) =>
|
||||||
new ErrorWithMetadata(
|
prefetchForKindAsync(kind, queryClient, items).catch((error) => {
|
||||||
"Failed to prefetch widget",
|
logger.error(
|
||||||
{ widgetKind: kind, itemCount: items.length },
|
new ErrorWithMetadata(
|
||||||
{ cause: error },
|
"Failed to prefetch widget",
|
||||||
),
|
{ widgetKind: kind, itemCount: items.length },
|
||||||
);
|
{ cause: error },
|
||||||
});
|
),
|
||||||
}
|
);
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<HydrationBoundary state={dehydrate(queryClient)}>
|
<HydrationBoundary state={dehydrate(queryClient)}>
|
||||||
|
|||||||
@@ -109,7 +109,7 @@ const AddMenu = () => {
|
|||||||
}, [openAppSelectModal, createItem]);
|
}, [openAppSelectModal, createItem]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Menu position="bottom-end" withArrow>
|
<Menu position="bottom-end">
|
||||||
<Menu.Target>
|
<Menu.Target>
|
||||||
<HeaderButton w="auto" px={4}>
|
<HeaderButton w="auto" px={4}>
|
||||||
<Group gap={4} wrap="nowrap">
|
<Group gap={4} wrap="nowrap">
|
||||||
@@ -183,7 +183,7 @@ const SelectBoardsMenu = () => {
|
|||||||
const { data: boards = [] } = clientApi.board.getAllBoards.useQuery();
|
const { data: boards = [] } = clientApi.board.getAllBoards.useQuery();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Menu position="bottom-end" withArrow>
|
<Menu position="bottom-end">
|
||||||
<Menu.Target>
|
<Menu.Target>
|
||||||
<HeaderButton w="auto" px={4}>
|
<HeaderButton w="auto" px={4}>
|
||||||
<IconReplace stroke={1.5} />
|
<IconReplace stroke={1.5} />
|
||||||
|
|||||||
@@ -1,42 +1,19 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import type { PropsWithChildren } from "react";
|
import type { PropsWithChildren } from "react";
|
||||||
import { createContext, useCallback, useContext, useEffect, useState } from "react";
|
import { createContext, useCallback, useContext } from "react";
|
||||||
import { usePathname } from "next/navigation";
|
|
||||||
|
|
||||||
import { clientApi } from "@homarr/api/client";
|
|
||||||
import { useRequiredBoard } from "@homarr/boards/context";
|
|
||||||
|
|
||||||
const BoardReadyContext = createContext<{
|
const BoardReadyContext = createContext<{
|
||||||
isReady: boolean;
|
|
||||||
markAsReady: (id: string) => void;
|
markAsReady: (id: string) => void;
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
|
|
||||||
export const BoardReadyProvider = ({ children }: PropsWithChildren) => {
|
export const BoardReadyProvider = ({ children }: PropsWithChildren) => {
|
||||||
const pathname = usePathname();
|
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
||||||
const utils = clientApi.useUtils();
|
const markAsReady = useCallback((_id: string) => {}, []);
|
||||||
const board = useRequiredBoard();
|
|
||||||
const [readySections, setReadySections] = useState<string[]>([]);
|
|
||||||
|
|
||||||
// Reset sections required for ready state
|
|
||||||
useEffect(() => {
|
|
||||||
return () => {
|
|
||||||
setReadySections([]);
|
|
||||||
};
|
|
||||||
}, [pathname, utils]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setReadySections((previous) => previous.filter((id) => board.sections.some((section) => section.id === id)));
|
|
||||||
}, [board.sections.length, setReadySections]);
|
|
||||||
|
|
||||||
const markAsReady = useCallback((id: string) => {
|
|
||||||
setReadySections((previous) => (previous.includes(id) ? previous : [...previous, id]));
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<BoardReadyContext.Provider
|
<BoardReadyContext.Provider
|
||||||
value={{
|
value={{
|
||||||
isReady: board.sections.length === readySections.length,
|
|
||||||
markAsReady,
|
markAsReady,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -54,13 +31,3 @@ export const useMarkSectionAsReady = () => {
|
|||||||
|
|
||||||
return context.markAsReady;
|
return context.markAsReady;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const useIsBoardReady = () => {
|
|
||||||
const context = useContext(BoardReadyContext);
|
|
||||||
|
|
||||||
if (!context) {
|
|
||||||
throw new Error("BoardReadyProvider is required");
|
|
||||||
}
|
|
||||||
|
|
||||||
return context.isReady;
|
|
||||||
};
|
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import type { PropsWithChildren } from "react";
|
import type { PropsWithChildren } from "react";
|
||||||
import type { MantineColorsTuple } from "@mantine/core";
|
import type { MantineColorsTuple } from "@mantine/core";
|
||||||
import { colorsTuple, createTheme, darken, lighten, MantineProvider, rem } from "@mantine/core";
|
import { colorsTuple, createTheme, darken, lighten, MantineProvider, rem, v8CssVariablesResolver } from "@mantine/core";
|
||||||
|
|
||||||
import { useRequiredBoard } from "@homarr/boards/context";
|
import { useRequiredBoard } from "@homarr/boards/context";
|
||||||
import type { ColorScheme } from "@homarr/definitions";
|
import type { ColorScheme } from "@homarr/definitions";
|
||||||
@@ -24,6 +24,7 @@ export const BoardMantineProvider = ({
|
|||||||
},
|
},
|
||||||
primaryColor: "primaryColor",
|
primaryColor: "primaryColor",
|
||||||
autoContrast: true,
|
autoContrast: true,
|
||||||
|
defaultRadius: "sm",
|
||||||
fontSizes: {
|
fontSizes: {
|
||||||
"2xl": rem(24),
|
"2xl": rem(24),
|
||||||
"3xl": rem(28),
|
"3xl": rem(28),
|
||||||
@@ -32,7 +33,12 @@ export const BoardMantineProvider = ({
|
|||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<MantineProvider defaultColorScheme={defaultColorScheme} theme={theme} colorSchemeManager={colorSchemeManager}>
|
<MantineProvider
|
||||||
|
defaultColorScheme={defaultColorScheme}
|
||||||
|
theme={theme}
|
||||||
|
colorSchemeManager={colorSchemeManager}
|
||||||
|
cssVariablesResolver={v8CssVariablesResolver}
|
||||||
|
>
|
||||||
{children}
|
{children}
|
||||||
</MantineProvider>
|
</MantineProvider>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -83,7 +83,7 @@ export const ColorSettingsContent = ({ board }: Props) => {
|
|||||||
<Anchor onClick={toggle}>{showPreview ? t("common.preview.hide") : t("common.preview.show")}</Anchor>
|
<Anchor onClick={toggle}>{showPreview ? t("common.preview.hide") : t("common.preview.show")}</Anchor>
|
||||||
</Grid.Col>
|
</Grid.Col>
|
||||||
<Grid.Col span={12}>
|
<Grid.Col span={12}>
|
||||||
<Collapse in={showPreview}>
|
<Collapse expanded={showPreview}>
|
||||||
<Stack>
|
<Stack>
|
||||||
<ColorsPreview previewColor={form.values.primaryColor} />
|
<ColorsPreview previewColor={form.values.primaryColor} />
|
||||||
<ColorsPreview previewColor={form.values.secondaryColor} />
|
<ColorsPreview previewColor={form.values.secondaryColor} />
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ export const InitFinish = async () => {
|
|||||||
const tFinish = await getScopedI18n("init.step.finish");
|
const tFinish = await getScopedI18n("init.step.finish");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card w={64 * 6} maw="90vw" withBorder>
|
<Card w={64 * 6} maw="90vw">
|
||||||
<Stack>
|
<Stack>
|
||||||
<Text>{tFinish("description")}</Text>
|
<Text>{tFinish("description")}</Text>
|
||||||
|
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ export const InitGroup = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card w={64 * 6} maw="90vw" withBorder>
|
<Card w={64 * 6} maw="90vw">
|
||||||
<form onSubmit={form.onSubmit(handleSubmitAsync)}>
|
<form onSubmit={form.onSubmit(handleSubmitAsync)}>
|
||||||
<Stack>
|
<Stack>
|
||||||
<TextInput
|
<TextInput
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ interface FileInfoCardProps {
|
|||||||
export const FileInfoCard = ({ file, onRemove }: FileInfoCardProps) => {
|
export const FileInfoCard = ({ file, onRemove }: FileInfoCardProps) => {
|
||||||
const tFileInfo = useScopedI18n("init.step.import.fileInfo");
|
const tFileInfo = useScopedI18n("init.step.import.fileInfo");
|
||||||
return (
|
return (
|
||||||
<Card w={64 * 12 + 8} maw="90vw">
|
<Card w={64 * 12 + 8} maw="90vw" withBorder={false}>
|
||||||
<Group justify="space-between" align="center" wrap="nowrap">
|
<Group justify="space-between" align="center" wrap="nowrap">
|
||||||
<Group>
|
<Group>
|
||||||
<Text fw={500} lineClamp={1} style={{ wordBreak: "break-all" }}>
|
<Text fw={500} lineClamp={1} style={{ wordBreak: "break-all" }}>
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ export const InitImport = () => {
|
|||||||
|
|
||||||
if (!file) {
|
if (!file) {
|
||||||
return (
|
return (
|
||||||
<Card w={64 * 12 + 8} maw="90vw" withBorder>
|
<Card w={64 * 12 + 8} maw="90vw">
|
||||||
<ImportDropZone
|
<ImportDropZone
|
||||||
loading={isPending}
|
loading={isPending}
|
||||||
updateFile={(file) => {
|
updateFile={(file) => {
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ export const InitSettings = () => {
|
|||||||
return (
|
return (
|
||||||
<form onSubmit={form.onSubmit(handleSubmitAsync)}>
|
<form onSubmit={form.onSubmit(handleSubmitAsync)}>
|
||||||
<Stack>
|
<Stack>
|
||||||
<Card w={64 * 12 + 8} maw="90vw" withBorder>
|
<Card w={64 * 12 + 8} maw="90vw">
|
||||||
<Stack gap="sm">
|
<Stack gap="sm">
|
||||||
<Text fw={500}>{tSection("analytics.title")}</Text>
|
<Text fw={500}>{tSection("analytics.title")}</Text>
|
||||||
|
|
||||||
@@ -39,7 +39,7 @@ export const InitSettings = () => {
|
|||||||
</Stack>
|
</Stack>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Card>
|
</Card>
|
||||||
<Card w={64 * 12 + 8} maw="90vw" withBorder>
|
<Card w={64 * 12 + 8} maw="90vw">
|
||||||
<Stack gap="sm">
|
<Stack gap="sm">
|
||||||
<Text fw={500}>{tSection("crawlingAndIndexing.title")}</Text>
|
<Text fw={500}>{tSection("crawlingAndIndexing.title")}</Text>
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ export const InitStart = async () => {
|
|||||||
const tStart = await getScopedI18n("init.step.start");
|
const tStart = await getScopedI18n("init.step.start");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card w={64 * 6} maw="90vw" withBorder>
|
<Card w={64 * 6} maw="90vw">
|
||||||
<Stack>
|
<Stack>
|
||||||
<Text>{tStart("description")}</Text>
|
<Text>{tStart("description")}</Text>
|
||||||
|
|
||||||
|
|||||||
@@ -1,15 +1,15 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { Button, PasswordInput, Stack, TextInput } from "@mantine/core";
|
import { Button, Stack, TextInput } from "@mantine/core";
|
||||||
import type { z } from "zod/v4";
|
import type { z } from "zod/v4";
|
||||||
|
|
||||||
import { clientApi } from "@homarr/api/client";
|
import { clientApi } from "@homarr/api/client";
|
||||||
import { signIn } from "@homarr/auth/client";
|
import { signIn } from "@homarr/auth/client";
|
||||||
import { revalidatePathActionAsync } from "@homarr/common/client";
|
import { revalidatePathActionAsync } from "@homarr/common/client";
|
||||||
import { useZodForm } from "@homarr/form";
|
import { useZodForm } from "@homarr/form";
|
||||||
|
import { UserCreatePasswordFields } from "@homarr/forms-collection";
|
||||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||||
import { useScopedI18n } from "@homarr/translation/client";
|
import { useScopedI18n } from "@homarr/translation/client";
|
||||||
import { CustomPasswordInput } from "@homarr/ui";
|
|
||||||
import { userInitSchema } from "@homarr/validation/user";
|
import { userInitSchema } from "@homarr/validation/user";
|
||||||
|
|
||||||
export const InitUserForm = () => {
|
export const InitUserForm = () => {
|
||||||
@@ -59,12 +59,10 @@ export const InitUserForm = () => {
|
|||||||
>
|
>
|
||||||
<Stack gap="lg">
|
<Stack gap="lg">
|
||||||
<TextInput label={t("field.username.label")} {...form.getInputProps("username")} />
|
<TextInput label={t("field.username.label")} {...form.getInputProps("username")} />
|
||||||
<CustomPasswordInput
|
<UserCreatePasswordFields
|
||||||
withPasswordRequirements
|
passwordInputProps={form.getInputProps("password")}
|
||||||
label={t("field.password.label")}
|
confirmPasswordInputProps={form.getInputProps("confirmPassword")}
|
||||||
{...form.getInputProps("password")}
|
|
||||||
/>
|
/>
|
||||||
<PasswordInput label={t("field.passwordConfirm.label")} {...form.getInputProps("confirmPassword")} />
|
|
||||||
<Button type="submit" fullWidth loading={isPending}>
|
<Button type="submit" fullWidth loading={isPending}>
|
||||||
{t("action.create")}
|
{t("action.create")}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { InitUserForm } from "./init-user-form";
|
|||||||
|
|
||||||
export const InitUser = () => {
|
export const InitUser = () => {
|
||||||
return (
|
return (
|
||||||
<Card w={64 * 6} maw="90vw" withBorder>
|
<Card w={64 * 6} maw="90vw">
|
||||||
<InitUserForm />
|
<InitUserForm />
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -44,13 +44,13 @@ const fontSans = Inter({
|
|||||||
export const generateMetadata = async (): Promise<Metadata> => ({
|
export const generateMetadata = async (): Promise<Metadata> => ({
|
||||||
title: "Homarr",
|
title: "Homarr",
|
||||||
description:
|
description:
|
||||||
"Simplify the management of your server with Homarr - a sleek, modern dashboard that puts all of your apps and services at your fingertips.",
|
"A self-hosted dashboard for the *arr stack and your entire homelab. Integrates with 50+ services, real-time widgets, no config files.",
|
||||||
openGraph: {
|
openGraph: {
|
||||||
title: "Homarr Dashboard",
|
title: "Homarr Dashboard",
|
||||||
description:
|
description:
|
||||||
"Simplify the management of your server with Homarr - a sleek, modern dashboard that puts all of your apps and services at your fingertips.",
|
"A self-hosted dashboard for the *arr stack and your entire homelab. Integrates with 50+ services, real-time widgets, no config files.",
|
||||||
url: "https://homarr.dev",
|
url: "https://homarr.dev",
|
||||||
siteName: "Homarr Documentation",
|
siteName: "Homarr",
|
||||||
},
|
},
|
||||||
icons: {
|
icons: {
|
||||||
icon: "/logo/logo.png",
|
icon: "/logo/logo.png",
|
||||||
@@ -142,7 +142,7 @@ export default async function Layout(props: {
|
|||||||
</head>
|
</head>
|
||||||
<body className={["font-sans", fontSans.variable].join(" ")}>
|
<body className={["font-sans", fontSans.variable].join(" ")}>
|
||||||
<StackedProvider>
|
<StackedProvider>
|
||||||
<Notifications />
|
<Notifications pauseResetOnHover="notification" />
|
||||||
<ServiceWorkerRegistration />
|
<ServiceWorkerRegistration />
|
||||||
{props.children}
|
{props.children}
|
||||||
</StackedProvider>
|
</StackedProvider>
|
||||||
|
|||||||
@@ -45,7 +45,6 @@ export const HeroBanner = async () => {
|
|||||||
<Image
|
<Image
|
||||||
key={`grid-column-${columnIndex}-scroll-1-${index}`}
|
key={`grid-column-${columnIndex}-scroll-1-${index}`}
|
||||||
src={icon}
|
src={icon}
|
||||||
radius="md"
|
|
||||||
fit={"contain"}
|
fit={"contain"}
|
||||||
w={50}
|
w={50}
|
||||||
h={50}
|
h={50}
|
||||||
@@ -57,7 +56,6 @@ export const HeroBanner = async () => {
|
|||||||
<Image
|
<Image
|
||||||
key={`grid-column-${columnIndex}-scroll-2-${index}`}
|
key={`grid-column-${columnIndex}-scroll-2-${index}`}
|
||||||
src={icon}
|
src={icon}
|
||||||
radius="md"
|
|
||||||
fit={"contain"}
|
fit={"contain"}
|
||||||
w={50}
|
w={50}
|
||||||
h={50}
|
h={50}
|
||||||
|
|||||||
@@ -90,9 +90,7 @@ export default async function AboutPage() {
|
|||||||
<Group py="lg">
|
<Group py="lg">
|
||||||
<Image src={homarrLogoPath} width={100} height={100} alt="" />
|
<Image src={homarrLogoPath} width={100} height={100} alt="" />
|
||||||
<Stack gap={0}>
|
<Stack gap={0}>
|
||||||
<Title order={1} tt="uppercase">
|
<Title order={1}>Homarr</Title>
|
||||||
Homarr
|
|
||||||
</Title>
|
|
||||||
<Title order={2}>{t("version", { version })}</Title>
|
<Title order={2}>{t("version", { version })}</Title>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Group>
|
</Group>
|
||||||
@@ -195,9 +193,9 @@ export default async function AboutPage() {
|
|||||||
href={`https://www.npmjs.com/package/${name}`}
|
href={`https://www.npmjs.com/package/${name}`}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
>
|
>
|
||||||
<Card withBorder radius="md" p="xs" className={classes.dependencyCard}>
|
<Card radius="md" p="xs" className={classes.dependencyCard}>
|
||||||
<Group gap="sm" wrap="nowrap">
|
<Group gap="sm" wrap="nowrap">
|
||||||
<ThemeIcon variant="light" size="lg" radius="md">
|
<ThemeIcon variant="light" size="lg">
|
||||||
<IconPackage size="1.5rem" stroke={1.5} />
|
<IconPackage size="1.5rem" stroke={1.5} />
|
||||||
</ThemeIcon>
|
</ThemeIcon>
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Fragment } from "react";
|
import { Fragment } from "react";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { ActionIcon, ActionIconGroup, Anchor, Avatar, Card, Group, Stack, Text, Title } from "@mantine/core";
|
import { ActionIcon, ActionIconGroup, Anchor, Avatar, Card, Group, Stack, Text } from "@mantine/core";
|
||||||
import { IconBox, IconPencil } from "@tabler/icons-react";
|
import { IconBox, IconPencil } from "@tabler/icons-react";
|
||||||
import { z } from "zod/v4";
|
import { z } from "zod/v4";
|
||||||
|
|
||||||
@@ -11,9 +11,8 @@ import type { inferSearchParamsFromSchema } from "@homarr/common/types";
|
|||||||
import { getI18n, getScopedI18n } from "@homarr/translation/server";
|
import { getI18n, getScopedI18n } from "@homarr/translation/server";
|
||||||
import { Link, SearchInput, TablePagination } from "@homarr/ui";
|
import { Link, SearchInput, TablePagination } from "@homarr/ui";
|
||||||
|
|
||||||
import { ManageContainer } from "~/components/manage/manage-container";
|
import { ManagePageLayout } from "~/components/manage/manage-page-layout";
|
||||||
import { MobileAffixButton } from "~/components/manage/mobile-affix-button";
|
import { MobileAffixButton } from "~/components/manage/mobile-affix-button";
|
||||||
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
|
|
||||||
import { NoResults } from "~/components/no-results";
|
import { NoResults } from "~/components/no-results";
|
||||||
import { AppDeleteButton } from "./_app-delete-button";
|
import { AppDeleteButton } from "./_app-delete-button";
|
||||||
|
|
||||||
@@ -39,34 +38,31 @@ export default async function AppsPage(props: AppsPageProps) {
|
|||||||
const { items: apps, totalCount } = await api.app.getPaginated(searchParams);
|
const { items: apps, totalCount } = await api.app.getPaginated(searchParams);
|
||||||
const t = await getScopedI18n("app");
|
const t = await getScopedI18n("app");
|
||||||
|
|
||||||
return (
|
const canCreate = session.user.permissions.includes("app-create");
|
||||||
<ManageContainer>
|
|
||||||
<DynamicBreadcrumb />
|
|
||||||
<Stack>
|
|
||||||
<Title>{t("page.list.title")}</Title>
|
|
||||||
<Group justify="space-between" align="center">
|
|
||||||
<SearchInput placeholder={`${t("search")}...`} defaultValue={searchParams.search} flexExpand />
|
|
||||||
{session.user.permissions.includes("app-create") && (
|
|
||||||
<MobileAffixButton component={Link} href="/manage/apps/new">
|
|
||||||
{t("page.create.title")}
|
|
||||||
</MobileAffixButton>
|
|
||||||
)}
|
|
||||||
</Group>
|
|
||||||
{apps.length === 0 && <AppNoResults />}
|
|
||||||
{apps.length > 0 && (
|
|
||||||
<Stack gap="sm">
|
|
||||||
{apps.map((app) => (
|
|
||||||
<AppCard key={app.id} app={app} />
|
|
||||||
))}
|
|
||||||
</Stack>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Added margin to not hide pagination behind affix-button */}
|
return (
|
||||||
<Group justify="end" mb={48}>
|
<ManagePageLayout
|
||||||
<TablePagination total={Math.ceil(totalCount / searchParams.pageSize)} />
|
title={t("page.list.title")}
|
||||||
</Group>
|
primaryAction={
|
||||||
</Stack>
|
canCreate ? (
|
||||||
</ManageContainer>
|
<MobileAffixButton component={Link} href="/manage/apps/new">
|
||||||
|
{t("page.create.title")}
|
||||||
|
</MobileAffixButton>
|
||||||
|
) : undefined
|
||||||
|
}
|
||||||
|
toolbar={<SearchInput placeholder={`${t("search")}...`} defaultValue={searchParams.search} flexExpand />}
|
||||||
|
footer={<TablePagination total={Math.ceil(totalCount / searchParams.pageSize)} />}
|
||||||
|
floatingPrimaryAction={canCreate}
|
||||||
|
>
|
||||||
|
{apps.length === 0 && <AppNoResults />}
|
||||||
|
{apps.length > 0 && (
|
||||||
|
<Stack gap="sm">
|
||||||
|
{apps.map((app) => (
|
||||||
|
<AppCard key={app.id} app={app} />
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
</ManagePageLayout>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -79,7 +75,7 @@ const AppCard = async ({ app }: AppCardProps) => {
|
|||||||
const session = await auth();
|
const session = await auth();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card withBorder>
|
<Card>
|
||||||
<Group justify="space-between" wrap="nowrap">
|
<Group justify="space-between" wrap="nowrap">
|
||||||
<Group align="top" justify="start" wrap="nowrap" style={{ flex: "1" }}>
|
<Group align="top" justify="start" wrap="nowrap" style={{ flex: "1" }}>
|
||||||
<Avatar
|
<Avatar
|
||||||
|
|||||||
@@ -1,43 +1,38 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { Affix, Button, Menu } from "@mantine/core";
|
import { Button, Menu } from "@mantine/core";
|
||||||
import { IconCategoryPlus, IconChevronDown, IconFileImport } from "@tabler/icons-react";
|
import { IconCategoryPlus, IconChevronDown, IconFileImport } from "@tabler/icons-react";
|
||||||
|
|
||||||
import { useModalAction } from "@homarr/modals";
|
import { useModalAction } from "@homarr/modals";
|
||||||
import { AddBoardModal, ImportBoardModal } from "@homarr/modals-collection";
|
import { AddBoardModal, ImportBoardModal } from "@homarr/modals-collection";
|
||||||
import { useI18n } from "@homarr/translation/client";
|
import { useI18n } from "@homarr/translation/client";
|
||||||
|
|
||||||
|
import { ManageMobilePrimaryAction } from "~/components/manage/manage-mobile-primary-action";
|
||||||
|
|
||||||
export const CreateBoardButton = () => {
|
export const CreateBoardButton = () => {
|
||||||
const t = useI18n();
|
const t = useI18n();
|
||||||
const { openModal: openAddModal } = useModalAction(AddBoardModal);
|
const { openModal: openAddModal } = useModalAction(AddBoardModal);
|
||||||
const { openModal: openImportModal } = useModalAction(ImportBoardModal);
|
const { openModal: openImportModal } = useModalAction(ImportBoardModal);
|
||||||
|
|
||||||
const buttonGroupContent = (
|
|
||||||
<>
|
|
||||||
<Button leftSection={<IconCategoryPlus size="1rem" />} onClick={openAddModal}>
|
|
||||||
{t("management.page.board.action.new.label")}
|
|
||||||
</Button>
|
|
||||||
<Menu position="bottom-end">
|
|
||||||
<Menu.Target>
|
|
||||||
<Button px="xs" ms={1}>
|
|
||||||
<IconChevronDown size="1rem" />
|
|
||||||
</Button>
|
|
||||||
</Menu.Target>
|
|
||||||
<Menu.Dropdown>
|
|
||||||
<Menu.Item onClick={openImportModal} leftSection={<IconFileImport size="1rem" />}>
|
|
||||||
{t("board.action.oldImport.label")}
|
|
||||||
</Menu.Item>
|
|
||||||
</Menu.Dropdown>
|
|
||||||
</Menu>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<ManageMobilePrimaryAction>
|
||||||
<Button.Group visibleFrom="md">{buttonGroupContent}</Button.Group>
|
<Button.Group>
|
||||||
<Affix hiddenFrom="md" position={{ bottom: 20, right: 20 }}>
|
<Button leftSection={<IconCategoryPlus size="1rem" />} onClick={openAddModal}>
|
||||||
<Button.Group>{buttonGroupContent}</Button.Group>
|
{t("management.page.board.action.new.label")}
|
||||||
</Affix>
|
</Button>
|
||||||
</>
|
<Menu position="bottom-end">
|
||||||
|
<Menu.Target>
|
||||||
|
<Button px="xs" ms={1}>
|
||||||
|
<IconChevronDown size="1rem" />
|
||||||
|
</Button>
|
||||||
|
</Menu.Target>
|
||||||
|
<Menu.Dropdown>
|
||||||
|
<Menu.Item onClick={openImportModal} leftSection={<IconFileImport size="1rem" />}>
|
||||||
|
{t("board.action.oldImport.label")}
|
||||||
|
</Menu.Item>
|
||||||
|
</Menu.Dropdown>
|
||||||
|
</Menu>
|
||||||
|
</Button.Group>
|
||||||
|
</ManageMobilePrimaryAction>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -9,12 +9,17 @@ import {
|
|||||||
Group,
|
Group,
|
||||||
Menu,
|
Menu,
|
||||||
MenuTarget,
|
MenuTarget,
|
||||||
Stack,
|
|
||||||
Text,
|
Text,
|
||||||
Title,
|
|
||||||
Tooltip,
|
Tooltip,
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import { IconDeviceMobile, IconDotsVertical, IconHomeFilled, IconLock, IconWorld } from "@tabler/icons-react";
|
import {
|
||||||
|
IconDeviceMobile,
|
||||||
|
IconDotsVertical,
|
||||||
|
IconHomeFilled,
|
||||||
|
IconLayoutDashboard,
|
||||||
|
IconLock,
|
||||||
|
IconWorld,
|
||||||
|
} from "@tabler/icons-react";
|
||||||
|
|
||||||
import type { RouterOutputs } from "@homarr/api";
|
import type { RouterOutputs } from "@homarr/api";
|
||||||
import { api } from "@homarr/api/server";
|
import { api } from "@homarr/api/server";
|
||||||
@@ -23,8 +28,8 @@ import { getScopedI18n } from "@homarr/translation/server";
|
|||||||
import { Link, UserAvatar } from "@homarr/ui";
|
import { Link, UserAvatar } from "@homarr/ui";
|
||||||
|
|
||||||
import { getBoardPermissionsAsync } from "~/components/board/permissions/server";
|
import { getBoardPermissionsAsync } from "~/components/board/permissions/server";
|
||||||
import { ManageContainer } from "~/components/manage/manage-container";
|
import { ManagePageLayout } from "~/components/manage/manage-page-layout";
|
||||||
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
|
import { NoResults } from "~/components/no-results";
|
||||||
import { BoardCardMenuDropdown } from "./_components/board-card-menu-dropdown";
|
import { BoardCardMenuDropdown } from "./_components/board-card-menu-dropdown";
|
||||||
import { CreateBoardButton } from "./_components/create-board-button";
|
import { CreateBoardButton } from "./_components/create-board-button";
|
||||||
|
|
||||||
@@ -35,23 +40,22 @@ export default async function ManageBoardsPage() {
|
|||||||
const canCreateBoards = session?.user.permissions.includes("board-create");
|
const canCreateBoards = session?.user.permissions.includes("board-create");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ManageContainer>
|
<ManagePageLayout
|
||||||
<DynamicBreadcrumb />
|
title={t("title")}
|
||||||
<Stack>
|
primaryAction={canCreateBoards ? <CreateBoardButton /> : undefined}
|
||||||
<Group justify="space-between">
|
floatingPrimaryAction={canCreateBoards}
|
||||||
<Title mb="md">{t("title")}</Title>
|
>
|
||||||
{canCreateBoards && <CreateBoardButton />}
|
{boards.length === 0 && <NoResults icon={IconLayoutDashboard} title={t("noResults.title")} />}
|
||||||
</Group>
|
{boards.length > 0 && (
|
||||||
|
<Grid>
|
||||||
<Grid mb={{ base: "xl", md: 0 }}>
|
|
||||||
{boards.map((board) => (
|
{boards.map((board) => (
|
||||||
<GridCol span={{ base: 12, md: 6 }} key={board.id}>
|
<GridCol span={{ base: 12, md: 6 }} key={board.id}>
|
||||||
<BoardCard board={board} />
|
<BoardCard board={board} />
|
||||||
</GridCol>
|
</GridCol>
|
||||||
))}
|
))}
|
||||||
</Grid>
|
</Grid>
|
||||||
</Stack>
|
)}
|
||||||
</ManageContainer>
|
</ManagePageLayout>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -66,16 +70,14 @@ const BoardCard = async ({ board }: BoardCardProps) => {
|
|||||||
const VisibilityIcon = board.isPublic ? IconWorld : IconLock;
|
const VisibilityIcon = board.isPublic ? IconWorld : IconLock;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card radius="lg" withBorder>
|
<Card>
|
||||||
<CardSection p="sm" withBorder>
|
<CardSection p="sm" withBorder>
|
||||||
<Group justify="space-between" align="center">
|
<Group justify="space-between" align="center">
|
||||||
<Group gap="sm">
|
<Group gap="sm">
|
||||||
<Tooltip label={t(`visibility.${visibility}`)}>
|
<Tooltip label={t(`visibility.${visibility}`)}>
|
||||||
<VisibilityIcon size={20} stroke={1.5} />
|
<VisibilityIcon size={20} stroke={1.5} />
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
<Text fw="bolder" tt="uppercase">
|
<Text fw={700}>{board.name}</Text>
|
||||||
{board.name}
|
|
||||||
</Text>
|
|
||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
<Group>
|
<Group>
|
||||||
@@ -123,6 +125,7 @@ const BoardCard = async ({ board }: BoardCardProps) => {
|
|||||||
style={{ borderTop: "none", borderBottom: "none", borderRight: "none", borderRadius: 0 }}
|
style={{ borderTop: "none", borderBottom: "none", borderRight: "none", borderRadius: 0 }}
|
||||||
variant="default"
|
variant="default"
|
||||||
size="lg"
|
size="lg"
|
||||||
|
aria-label={t("action.settings.label")}
|
||||||
>
|
>
|
||||||
<IconDotsVertical size={16} stroke={1.5} />
|
<IconDotsVertical size={16} stroke={1.5} />
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
|
|||||||
+8
-3
@@ -33,7 +33,7 @@ export const SecretCard = ({ secret, children, onCancel }: SecretCardProps) => {
|
|||||||
const KindIcon = integrationSecretIcons[secret.kind];
|
const KindIcon = integrationSecretIcons[secret.kind];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card withBorder>
|
<Card>
|
||||||
<Stack>
|
<Stack>
|
||||||
<Group justify="space-between">
|
<Group justify="space-between">
|
||||||
<Group>
|
<Group>
|
||||||
@@ -58,7 +58,12 @@ export const SecretCard = ({ secret, children, onCancel }: SecretCardProps) => {
|
|||||||
</Tooltip>
|
</Tooltip>
|
||||||
)}
|
)}
|
||||||
{isPublic ? (
|
{isPublic ? (
|
||||||
<ActionIcon color="gray" variant="subtle" onClick={togglePublicSecretDisplay}>
|
<ActionIcon
|
||||||
|
color="gray"
|
||||||
|
variant="subtle"
|
||||||
|
onClick={togglePublicSecretDisplay}
|
||||||
|
aria-label={t(publicSecretDisplayOpened ? "common.action.hide" : "common.action.show")}
|
||||||
|
>
|
||||||
<DisplayIcon size={16} stroke={1.5} />
|
<DisplayIcon size={16} stroke={1.5} />
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -79,7 +84,7 @@ export const SecretCard = ({ secret, children, onCancel }: SecretCardProps) => {
|
|||||||
</Button>
|
</Button>
|
||||||
</Group>
|
</Group>
|
||||||
</Group>
|
</Group>
|
||||||
<Collapse in={editMode}>{children}</Collapse>
|
<Collapse expanded={editMode}>{children}</Collapse>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
|
|||||||
+1
-1
@@ -12,7 +12,7 @@ interface FormType {
|
|||||||
interface SecretKindsSegmentedControlProps<TFormType extends FormType> {
|
interface SecretKindsSegmentedControlProps<TFormType extends FormType> {
|
||||||
defaultKinds?: IntegrationSecretKind[];
|
defaultKinds?: IntegrationSecretKind[];
|
||||||
secretKinds: IntegrationSecretKind[][];
|
secretKinds: IntegrationSecretKind[][];
|
||||||
form: UseFormReturnType<TFormType, (values: TFormType) => TFormType>;
|
form: UseFormReturnType<TFormType, TFormType>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const SecretKindsSegmentedControl = <TFormType extends FormType>({
|
export const SecretKindsSegmentedControl = <TFormType extends FormType>({
|
||||||
|
|||||||
+1
-1
@@ -18,7 +18,7 @@ export const IntegrationTestConnectionError = ({ error, url }: IntegrationTestCo
|
|||||||
const causeArray = useMemo(() => toCauseArray(error.cause), [error.cause]);
|
const causeArray = useMemo(() => toCauseArray(error.cause), [error.cause]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card withBorder style={{ borderColor: getMantineColor("red", 8) }}>
|
<Card style={{ borderColor: getMantineColor("red", 8) }}>
|
||||||
<Stack>
|
<Stack>
|
||||||
<Stack gap="sm">
|
<Stack gap="sm">
|
||||||
<Text size="lg" fw={500} c="red.8">
|
<Text size="lg" fw={500} c="red.8">
|
||||||
|
|||||||
+1
-1
@@ -216,7 +216,7 @@ export const CertificateDetailsCard = ({ certificate }: CertificateDetailsProps)
|
|||||||
const tCertificateField = useScopedI18n("certificate.field");
|
const tCertificateField = useScopedI18n("certificate.field");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card withBorder>
|
<Card>
|
||||||
<Text fw={500}>{tDetails("title")}</Text>
|
<Text fw={500}>{tDetails("title")}</Text>
|
||||||
<Group justify="space-between">
|
<Group justify="space-between">
|
||||||
<Text size="sm" c="dimmed">
|
<Text size="sm" c="dimmed">
|
||||||
|
|||||||
@@ -226,7 +226,7 @@ const AppForm = ({ form, canCreateApps }: { form: UseFormReturnType<FormType>; c
|
|||||||
description={t("integration.field.createApp.description")}
|
description={t("integration.field.createApp.description")}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Collapse in={form.values.hasApp}>
|
<Collapse expanded={form.values.hasApp}>
|
||||||
<Fieldset legend={t("integration.field.app.sectionTitle")}>
|
<Fieldset legend={t("integration.field.app.sectionTitle")}>
|
||||||
<Stack gap="sm">
|
<Stack gap="sm">
|
||||||
{canCreateApps && (
|
{canCreateApps && (
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
border: 1px solid transparent;
|
border: 1px solid transparent;
|
||||||
position: relative;
|
position: relative;
|
||||||
z-index: 0;
|
z-index: 0;
|
||||||
transition: transform 150ms ease;
|
transition: background-color 150ms ease;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
|
||||||
&[data-first="true"] {
|
&[data-first="true"] {
|
||||||
@@ -20,11 +20,9 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
&[data-active] {
|
&[data-active] {
|
||||||
transform: scale(1.01);
|
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
background-color: var(--mantine-color-body);
|
background-color: var(--mantine-color-body);
|
||||||
border-color: light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-4));
|
border-color: light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-4));
|
||||||
box-shadow: var(--mantine-shadow-md);
|
|
||||||
border-radius: var(--mantine-radius-lg);
|
border-radius: var(--mantine-radius-lg);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,9 +7,7 @@ import {
|
|||||||
AccordionPanel,
|
AccordionPanel,
|
||||||
ActionIcon,
|
ActionIcon,
|
||||||
ActionIconGroup,
|
ActionIconGroup,
|
||||||
Affix,
|
|
||||||
Anchor,
|
Anchor,
|
||||||
Box,
|
|
||||||
Button,
|
Button,
|
||||||
Divider,
|
Divider,
|
||||||
Group,
|
Group,
|
||||||
@@ -24,9 +22,8 @@ import {
|
|||||||
TableThead,
|
TableThead,
|
||||||
TableTr,
|
TableTr,
|
||||||
Text,
|
Text,
|
||||||
Title,
|
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import { IconChevronDown, IconChevronUp, IconPencil, IconPlugX } from "@tabler/icons-react";
|
import { IconChevronDown, IconPencil, IconPlugX } from "@tabler/icons-react";
|
||||||
|
|
||||||
import type { RouterOutputs } from "@homarr/api";
|
import type { RouterOutputs } from "@homarr/api";
|
||||||
import { api } from "@homarr/api/server";
|
import { api } from "@homarr/api/server";
|
||||||
@@ -37,8 +34,8 @@ import { getIntegrationName } from "@homarr/definitions";
|
|||||||
import { getScopedI18n } from "@homarr/translation/server";
|
import { getScopedI18n } from "@homarr/translation/server";
|
||||||
import { CountBadge, IntegrationAvatar, Link } from "@homarr/ui";
|
import { CountBadge, IntegrationAvatar, Link } from "@homarr/ui";
|
||||||
|
|
||||||
import { ManageContainer } from "~/components/manage/manage-container";
|
import { ManageMobilePrimaryAction } from "~/components/manage/manage-mobile-primary-action";
|
||||||
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
|
import { ManagePageLayout } from "~/components/manage/manage-page-layout";
|
||||||
import { NoResults } from "~/components/no-results";
|
import { NoResults } from "~/components/no-results";
|
||||||
import { env } from "~/env";
|
import { env } from "~/env";
|
||||||
import { ActiveTabAccordion } from "../../../../components/active-tab-accordion";
|
import { ActiveTabAccordion } from "../../../../components/active-tab-accordion";
|
||||||
@@ -66,38 +63,23 @@ export default async function IntegrationsPage(props: IntegrationsPageProps) {
|
|||||||
const canCreateIntegrations = session.user.permissions.includes("integration-create");
|
const canCreateIntegrations = session.user.permissions.includes("integration-create");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ManageContainer>
|
<ManagePageLayout
|
||||||
<DynamicBreadcrumb />
|
title={t("page.list.title")}
|
||||||
<Stack>
|
primaryAction={
|
||||||
<Group justify="space-between" align="center">
|
canCreateIntegrations ? (
|
||||||
<Title>{t("page.list.title")}</Title>
|
<ManageMobilePrimaryAction>
|
||||||
|
<IntegrationSelectMenu>
|
||||||
{canCreateIntegrations && (
|
<MenuTarget>
|
||||||
<>
|
<Button rightSection={<IconChevronDown size={16} stroke={1.5} />}>{t("action.create")}</Button>
|
||||||
<Box>
|
</MenuTarget>
|
||||||
<IntegrationSelectMenu>
|
</IntegrationSelectMenu>
|
||||||
<Affix hiddenFrom="md" position={{ bottom: 20, right: 20 }}>
|
</ManageMobilePrimaryAction>
|
||||||
<MenuTarget>
|
) : undefined
|
||||||
<Button rightSection={<IconChevronUp size={16} stroke={1.5} />}>{t("action.create")}</Button>
|
}
|
||||||
</MenuTarget>
|
floatingPrimaryAction={canCreateIntegrations}
|
||||||
</Affix>
|
>
|
||||||
</IntegrationSelectMenu>
|
<IntegrationList integrations={integrations} activeTab={searchParams.tab} />
|
||||||
</Box>
|
</ManagePageLayout>
|
||||||
|
|
||||||
<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>
|
|
||||||
</ManageContainer>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,17 +4,15 @@ import {
|
|||||||
Anchor,
|
Anchor,
|
||||||
Group,
|
Group,
|
||||||
Image,
|
Image,
|
||||||
Stack,
|
|
||||||
Table,
|
Table,
|
||||||
TableTbody,
|
TableTbody,
|
||||||
TableTd,
|
TableTd,
|
||||||
TableTh,
|
TableTh,
|
||||||
TableThead,
|
TableThead,
|
||||||
TableTr,
|
TableTr,
|
||||||
Title,
|
|
||||||
Tooltip,
|
Tooltip,
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import { IconExternalLink } from "@tabler/icons-react";
|
import { IconExternalLink, IconPhoto } from "@tabler/icons-react";
|
||||||
import { z } from "zod/v4";
|
import { z } from "zod/v4";
|
||||||
|
|
||||||
import type { RouterOutputs } from "@homarr/api";
|
import type { RouterOutputs } from "@homarr/api";
|
||||||
@@ -26,8 +24,9 @@ import { createLocalImageUrl } from "@homarr/icons/local";
|
|||||||
import { getI18n } from "@homarr/translation/server";
|
import { getI18n } from "@homarr/translation/server";
|
||||||
import { Link, SearchInput, TablePagination, UserAvatar } from "@homarr/ui";
|
import { Link, SearchInput, TablePagination, UserAvatar } from "@homarr/ui";
|
||||||
|
|
||||||
import { ManageContainer } from "~/components/manage/manage-container";
|
import { ManageMobilePrimaryAction } from "~/components/manage/manage-mobile-primary-action";
|
||||||
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
|
import { ManagePageLayout } from "~/components/manage/manage-page-layout";
|
||||||
|
import { NoResults } from "~/components/no-results";
|
||||||
import { CopyMedia } from "./_actions/copy-media";
|
import { CopyMedia } from "./_actions/copy-media";
|
||||||
import { DeleteMedia } from "./_actions/delete-media";
|
import { DeleteMedia } from "./_actions/delete-media";
|
||||||
import { IncludeFromAllUsersSwitch } from "./_actions/show-all";
|
import { IncludeFromAllUsersSwitch } from "./_actions/show-all";
|
||||||
@@ -48,7 +47,7 @@ interface MediaListPageProps {
|
|||||||
searchParams: Promise<inferSearchParamsFromSchema<typeof searchParamsSchema>>;
|
searchParams: Promise<inferSearchParamsFromSchema<typeof searchParamsSchema>>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default async function GroupsListPage(props: MediaListPageProps) {
|
export default async function MediaListPage(props: MediaListPageProps) {
|
||||||
const session = await auth();
|
const session = await auth();
|
||||||
|
|
||||||
if (!session) {
|
if (!session) {
|
||||||
@@ -58,22 +57,31 @@ export default async function GroupsListPage(props: MediaListPageProps) {
|
|||||||
const t = await getI18n();
|
const t = await getI18n();
|
||||||
const searchParams = searchParamsSchema.parse(await props.searchParams);
|
const searchParams = searchParamsSchema.parse(await props.searchParams);
|
||||||
const { items: medias, totalCount } = await api.media.getPaginated(searchParams);
|
const { items: medias, totalCount } = await api.media.getPaginated(searchParams);
|
||||||
|
const canUpload = session.user.permissions.includes("media-upload");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ManageContainer>
|
<ManagePageLayout
|
||||||
<DynamicBreadcrumb />
|
title={t("media.plural")}
|
||||||
<Stack>
|
primaryAction={
|
||||||
<Title>{t("media.plural")}</Title>
|
canUpload ? (
|
||||||
<Group justify="space-between">
|
<ManageMobilePrimaryAction>
|
||||||
<Group>
|
<UploadMediaButton />
|
||||||
<SearchInput placeholder={`${t("media.search")}...`} defaultValue={searchParams.search} />
|
</ManageMobilePrimaryAction>
|
||||||
{session.user.permissions.includes("media-view-all") && (
|
) : undefined
|
||||||
<IncludeFromAllUsersSwitch defaultChecked={searchParams.includeFromAllUsers} />
|
}
|
||||||
)}
|
toolbar={
|
||||||
</Group>
|
<Group>
|
||||||
|
<SearchInput placeholder={`${t("media.search")}...`} defaultValue={searchParams.search} />
|
||||||
{session.user.permissions.includes("media-upload") && <UploadMediaButton />}
|
{session.user.permissions.includes("media-view-all") && (
|
||||||
|
<IncludeFromAllUsersSwitch defaultChecked={searchParams.includeFromAllUsers} />
|
||||||
|
)}
|
||||||
</Group>
|
</Group>
|
||||||
|
}
|
||||||
|
footer={<TablePagination total={Math.ceil(totalCount / searchParams.pageSize)} />}
|
||||||
|
floatingPrimaryAction={canUpload}
|
||||||
|
>
|
||||||
|
{medias.length === 0 && <NoResults icon={IconPhoto} title={t("media.noResults.title")} />}
|
||||||
|
{medias.length > 0 && (
|
||||||
<Table striped highlightOnHover>
|
<Table striped highlightOnHover>
|
||||||
<TableThead>
|
<TableThead>
|
||||||
<TableTr>
|
<TableTr>
|
||||||
@@ -90,13 +98,8 @@ export default async function GroupsListPage(props: MediaListPageProps) {
|
|||||||
))}
|
))}
|
||||||
</TableTbody>
|
</TableTbody>
|
||||||
</Table>
|
</Table>
|
||||||
|
)}
|
||||||
{/* Added margin to not hide pagination behind affix-button */}
|
</ManagePageLayout>
|
||||||
<Group justify="end" mb={48}>
|
|
||||||
<TablePagination total={Math.ceil(totalCount / searchParams.pageSize)} />
|
|
||||||
</Group>
|
|
||||||
</Stack>
|
|
||||||
</ManageContainer>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,9 +7,7 @@ export default async function NotFound() {
|
|||||||
return (
|
return (
|
||||||
<Center h="100%">
|
<Center h="100%">
|
||||||
<Stack align="center">
|
<Stack align="center">
|
||||||
<Title order={1} tt="uppercase">
|
<Title order={1}>{t("title")}</Title>
|
||||||
{t("title")}
|
|
||||||
</Title>
|
|
||||||
<Text>{t("text")}</Text>
|
<Text>{t("text")}</Text>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Center>
|
</Center>
|
||||||
|
|||||||
@@ -29,20 +29,20 @@ export default async function ManagementPage() {
|
|||||||
<Space h="md" />
|
<Space h="md" />
|
||||||
<SimpleGrid cols={{ xs: 1, sm: 2, md: 3 }}>
|
<SimpleGrid cols={{ xs: 1, sm: 2, md: 3 }}>
|
||||||
{statistics.map((statistic) => (
|
{statistics.map((statistic) => (
|
||||||
<Card component={Link} href={statistic.path} key={statistic.path} radius="lg">
|
<Card component={Link} href={statistic.path} key={statistic.path}>
|
||||||
<Group justify="space-between" wrap="nowrap">
|
<Group justify="space-between" wrap="nowrap">
|
||||||
<Group wrap="nowrap">
|
<Group wrap="nowrap">
|
||||||
<Text size="2.4rem" fw="bolder">
|
<Text size="xl" fw={700}>
|
||||||
{statistic.count}
|
{statistic.count}
|
||||||
</Text>
|
</Text>
|
||||||
<Stack gap={0}>
|
<Stack gap={0}>
|
||||||
<Text c="red" size="xs">
|
<Text c="dimmed" size="xs">
|
||||||
{t(`statisticLabel.${statistic.subtitleKey}`)}
|
{t(`statisticLabel.${statistic.subtitleKey}`)}
|
||||||
</Text>
|
</Text>
|
||||||
<Text fw="bold">{t(`statistic.${statistic.titleKey}`)}</Text>
|
<Text fw={600}>{t(`statistic.${statistic.titleKey}`)}</Text>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Group>
|
</Group>
|
||||||
<IconArrowRight />
|
<IconArrowRight size={16} stroke={1.5} color="var(--mantine-color-dimmed)" />
|
||||||
</Group>
|
</Group>
|
||||||
</Card>
|
</Card>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { ActionIcon, ActionIconGroup, Anchor, Avatar, Card, Group, Stack, Text, Title } from "@mantine/core";
|
import { ActionIcon, ActionIconGroup, Anchor, Avatar, Card, Group, Stack, Text } from "@mantine/core";
|
||||||
import { IconPencil, IconSearch } from "@tabler/icons-react";
|
import { IconPencil, IconSearch } from "@tabler/icons-react";
|
||||||
import { z } from "zod/v4";
|
import { z } from "zod/v4";
|
||||||
|
|
||||||
@@ -10,9 +10,8 @@ import type { inferSearchParamsFromSchema } from "@homarr/common/types";
|
|||||||
import { getI18n, getScopedI18n } from "@homarr/translation/server";
|
import { getI18n, getScopedI18n } from "@homarr/translation/server";
|
||||||
import { Link, SearchInput, TablePagination } from "@homarr/ui";
|
import { Link, SearchInput, TablePagination } from "@homarr/ui";
|
||||||
|
|
||||||
import { ManageContainer } from "~/components/manage/manage-container";
|
import { ManagePageLayout } from "~/components/manage/manage-page-layout";
|
||||||
import { MobileAffixButton } from "~/components/manage/mobile-affix-button";
|
import { MobileAffixButton } from "~/components/manage/mobile-affix-button";
|
||||||
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
|
|
||||||
import { NoResults } from "~/components/no-results";
|
import { NoResults } from "~/components/no-results";
|
||||||
import { SearchEngineDeleteButton } from "./_search-engine-delete-button";
|
import { SearchEngineDeleteButton } from "./_search-engine-delete-button";
|
||||||
|
|
||||||
@@ -38,34 +37,31 @@ export default async function SearchEnginesPage(props: SearchEnginesPageProps) {
|
|||||||
|
|
||||||
const tEngine = await getScopedI18n("search.engine");
|
const tEngine = await getScopedI18n("search.engine");
|
||||||
|
|
||||||
return (
|
const canCreate = session.user.permissions.includes("search-engine-create");
|
||||||
<ManageContainer>
|
|
||||||
<DynamicBreadcrumb />
|
|
||||||
<Stack>
|
|
||||||
<Title>{tEngine("page.list.title")}</Title>
|
|
||||||
<Group justify="space-between" align="center">
|
|
||||||
<SearchInput placeholder={`${tEngine("search")}...`} defaultValue={searchParams.search} flexExpand />
|
|
||||||
{session.user.permissions.includes("search-engine-create") && (
|
|
||||||
<MobileAffixButton component={Link} href="/manage/search-engines/new">
|
|
||||||
{tEngine("page.create.title")}
|
|
||||||
</MobileAffixButton>
|
|
||||||
)}
|
|
||||||
</Group>
|
|
||||||
{searchEngines.length === 0 && <SearchEngineNoResults />}
|
|
||||||
{searchEngines.length > 0 && (
|
|
||||||
<Stack gap="sm">
|
|
||||||
{searchEngines.map((searchEngine) => (
|
|
||||||
<SearchEngineCard key={searchEngine.id} searchEngine={searchEngine} />
|
|
||||||
))}
|
|
||||||
</Stack>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Added margin to not hide pagination behind affix-button */}
|
return (
|
||||||
<Group justify="end" mb={48}>
|
<ManagePageLayout
|
||||||
<TablePagination total={Math.ceil(totalCount / searchParams.pageSize)} />
|
title={tEngine("page.list.title")}
|
||||||
</Group>
|
primaryAction={
|
||||||
</Stack>
|
canCreate ? (
|
||||||
</ManageContainer>
|
<MobileAffixButton component={Link} href="/manage/search-engines/new">
|
||||||
|
{tEngine("page.create.title")}
|
||||||
|
</MobileAffixButton>
|
||||||
|
) : undefined
|
||||||
|
}
|
||||||
|
toolbar={<SearchInput placeholder={`${tEngine("search")}...`} defaultValue={searchParams.search} flexExpand />}
|
||||||
|
footer={<TablePagination total={Math.ceil(totalCount / searchParams.pageSize)} />}
|
||||||
|
floatingPrimaryAction={canCreate}
|
||||||
|
>
|
||||||
|
{searchEngines.length === 0 && <SearchEngineNoResults />}
|
||||||
|
{searchEngines.length > 0 && (
|
||||||
|
<Stack gap="sm">
|
||||||
|
{searchEngines.map((searchEngine) => (
|
||||||
|
<SearchEngineCard key={searchEngine.id} searchEngine={searchEngine} />
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
</ManagePageLayout>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -78,7 +74,7 @@ const SearchEngineCard = async ({ searchEngine }: SearchEngineCardProps) => {
|
|||||||
const session = await auth();
|
const session = await auth();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card withBorder>
|
<Card>
|
||||||
<Group justify="space-between" wrap="nowrap">
|
<Group justify="space-between" wrap="nowrap">
|
||||||
<Group align="top" justify="start" wrap="nowrap" style={{ flex: 1 }}>
|
<Group align="top" justify="start" wrap="nowrap" style={{ flex: 1 }}>
|
||||||
<Avatar
|
<Avatar
|
||||||
|
|||||||
@@ -41,8 +41,8 @@ export const AnalyticsSettings = ({ initialData }: AnalyticsSettingsProps) => {
|
|||||||
<>
|
<>
|
||||||
<Title order={2}>{t("title")}</Title>
|
<Title order={2}>{t("title")}</Title>
|
||||||
|
|
||||||
<Card pos="relative" withBorder>
|
<Card pos="relative">
|
||||||
<LoadingOverlay visible={isPending} zIndex={1000} overlayProps={{ radius: "sm", blur: 2 }} />
|
<LoadingOverlay visible={isPending} />
|
||||||
<Stack>
|
<Stack>
|
||||||
<SwitchSetting form={form} formKey="enableGeneral" title={t("general.title")} text={t("general.text")} />
|
<SwitchSetting form={form} formKey="enableGeneral" title={t("general.title")} text={t("general.text")} />
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|||||||
+2
-2
@@ -41,11 +41,11 @@ export const CrawlingAndIndexingSettings = ({ initialData }: CrawlingAndIndexing
|
|||||||
<>
|
<>
|
||||||
<Title order={2}>{t("title")}</Title>
|
<Title order={2}>{t("title")}</Title>
|
||||||
|
|
||||||
<Card pos="relative" withBorder>
|
<Card pos="relative">
|
||||||
<Text c={"dimmed"} mb={"lg"}>
|
<Text c={"dimmed"} mb={"lg"}>
|
||||||
{t("warning")}
|
{t("warning")}
|
||||||
</Text>
|
</Text>
|
||||||
<LoadingOverlay visible={isPending} zIndex={1000} overlayProps={{ radius: "sm", blur: 2 }} />
|
<LoadingOverlay visible={isPending} />
|
||||||
<Stack>
|
<Stack>
|
||||||
<SwitchSetting form={form} formKey="noIndex" title={t("noIndex.title")} text={t("noIndex.text")} />
|
<SwitchSetting form={form} formKey="noIndex" title={t("noIndex.title")} text={t("noIndex.text")} />
|
||||||
<SwitchSetting form={form} formKey="noFollow" title={t("noFollow.title")} text={t("noFollow.text")} />
|
<SwitchSetting form={form} formKey="noFollow" title={t("noFollow.title")} text={t("noFollow.text")} />
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ export const SwitchSetting = <TFormValue extends Record<string, boolean>>({
|
|||||||
disabled,
|
disabled,
|
||||||
}: {
|
}: {
|
||||||
form: Modify<
|
form: Modify<
|
||||||
UseFormReturnType<TFormValue, () => TFormValue>,
|
UseFormReturnType<TFormValue, TFormValue>,
|
||||||
{
|
{
|
||||||
setFieldValue: (key: keyof TFormValue, value: (previous: boolean) => boolean) => void;
|
setFieldValue: (key: keyof TFormValue, value: (previous: boolean) => boolean) => void;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -79,7 +79,7 @@ export default async function CertificatesPage({ params }: CertificatesPageProps
|
|||||||
|
|
||||||
<SimpleGrid cols={{ sm: 1, lg: 2, xl: 3 }} spacing="lg">
|
<SimpleGrid cols={{ sm: 1, lg: 2, xl: 3 }} spacing="lg">
|
||||||
{x509Certificates.map((cert) => (
|
{x509Certificates.map((cert) => (
|
||||||
<Card key={cert.fileName} withBorder>
|
<Card key={cert.fileName}>
|
||||||
<Group wrap="nowrap">
|
<Group wrap="nowrap">
|
||||||
{cert.isError ? (
|
{cert.isError ? (
|
||||||
<IconAlertTriangle
|
<IconAlertTriangle
|
||||||
|
|||||||
@@ -107,6 +107,23 @@ export function DockerTable(initialData: RouterOutputs["docker"]["getContainers"
|
|||||||
refetchOnReconnect: false,
|
refetchOnReconnect: false,
|
||||||
});
|
});
|
||||||
const relativeTime = useTimeAgo(data.timestamp);
|
const relativeTime = useTimeAgo(data.timestamp);
|
||||||
|
const utils = clientApi.useUtils();
|
||||||
|
const { mutate, isPending } = clientApi.docker.invalidate.useMutation({
|
||||||
|
async onSuccess() {
|
||||||
|
await utils.docker.getContainers.invalidate();
|
||||||
|
showSuccessNotification({
|
||||||
|
title: tDocker("action.refresh.notification.success.title"),
|
||||||
|
message: tDocker("action.refresh.notification.success.message"),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onError() {
|
||||||
|
showErrorNotification({
|
||||||
|
title: tDocker("action.refresh.notification.error.title"),
|
||||||
|
message: tDocker("action.refresh.notification.error.message"),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const table = useTranslatedMantineReactTable({
|
const table = useTranslatedMantineReactTable({
|
||||||
data: data.containers,
|
data: data.containers,
|
||||||
enableDensityToggle: false,
|
enableDensityToggle: false,
|
||||||
@@ -125,35 +142,11 @@ export function DockerTable(initialData: RouterOutputs["docker"]["getContainers"
|
|||||||
},
|
},
|
||||||
|
|
||||||
initialState: { density: "xs", showGlobalFilter: true },
|
initialState: { density: "xs", showGlobalFilter: true },
|
||||||
renderTopToolbarCustomActions: () => {
|
renderTopToolbarCustomActions: () => (
|
||||||
const utils = clientApi.useUtils();
|
<Button variant="default" rightSection={<IconRefresh size="1rem" />} onClick={() => mutate()} loading={isPending}>
|
||||||
const { mutate, isPending } = clientApi.docker.invalidate.useMutation({
|
{tDocker("action.refresh.label")}
|
||||||
async onSuccess() {
|
</Button>
|
||||||
await utils.docker.getContainers.invalidate();
|
),
|
||||||
showSuccessNotification({
|
|
||||||
title: tDocker("action.refresh.notification.success.title"),
|
|
||||||
message: tDocker("action.refresh.notification.success.message"),
|
|
||||||
});
|
|
||||||
},
|
|
||||||
onError() {
|
|
||||||
showErrorNotification({
|
|
||||||
title: tDocker("action.refresh.notification.error.title"),
|
|
||||||
message: tDocker("action.refresh.notification.error.message"),
|
|
||||||
});
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Button
|
|
||||||
variant="default"
|
|
||||||
rightSection={<IconRefresh size="1rem" />}
|
|
||||||
onClick={() => mutate()}
|
|
||||||
loading={isPending}
|
|
||||||
>
|
|
||||||
{tDocker("action.refresh.label")}
|
|
||||||
</Button>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
renderToolbarAlertBannerContent: ({ groupedAlert, table }) => {
|
renderToolbarAlertBannerContent: ({ groupedAlert, table }) => {
|
||||||
const dockerContainers = table.getSelectedRowModel().rows.map((row) => row.original);
|
const dockerContainers = table.getSelectedRowModel().rows.map((row) => row.original);
|
||||||
return (
|
return (
|
||||||
@@ -202,7 +195,7 @@ const ContainerActionBar = ({ selectedContainers }: ContainerActionBarProps) =>
|
|||||||
<ContainerActionBarButton icon={IconPlayerStop} color="red" action="stop" selectedIds={selectedIds} />
|
<ContainerActionBarButton icon={IconPlayerStop} color="red" action="stop" selectedIds={selectedIds} />
|
||||||
<ContainerActionBarButton icon={IconRotateClockwise} color="orange" action="restart" selectedIds={selectedIds} />
|
<ContainerActionBarButton icon={IconRotateClockwise} color="orange" action="restart" selectedIds={selectedIds} />
|
||||||
<ContainerActionBarButton icon={IconTrash} color="red" action="remove" selectedIds={selectedIds} />
|
<ContainerActionBarButton icon={IconTrash} color="red" action="remove" selectedIds={selectedIds} />
|
||||||
<Button leftSection={<IconCategoryPlus />} color={"red"} onClick={handleClick} variant="light" radius="md">
|
<Button leftSection={<IconCategoryPlus />} color={"red"} onClick={handleClick} variant="light">
|
||||||
{t("addToHomarr.label")}
|
{t("addToHomarr.label")}
|
||||||
</Button>
|
</Button>
|
||||||
</Group>
|
</Group>
|
||||||
@@ -253,7 +246,6 @@ const ContainerActionBarButton = (props: ContainerActionBarButtonProps) => {
|
|||||||
onClick={handleClickAsync}
|
onClick={handleClickAsync}
|
||||||
loading={isPending}
|
loading={isPending}
|
||||||
variant="light"
|
variant="light"
|
||||||
radius="md"
|
|
||||||
>
|
>
|
||||||
{t(`${props.action}.label`)}
|
{t(`${props.action}.label`)}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
+7
-19
@@ -4,28 +4,16 @@
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
padding: var(--mantine-spacing-xs);
|
padding: var(--mantine-spacing-xs);
|
||||||
padding-left: calc(var(--mantine-spacing-xs) * 2);
|
padding-left: calc(var(--mantine-spacing-xs) * 2);
|
||||||
|
border-left: 1px solid var(--mantine-color-blue-6);
|
||||||
@mixin hover {
|
background-color: light-dark(
|
||||||
box-shadow: var(--mantine-shadow-md);
|
var(--mantine-color-blue-0),
|
||||||
}
|
color-mix(in oklch, var(--mantine-color-blue-9), transparent 85%)
|
||||||
|
);
|
||||||
&::before {
|
|
||||||
content: "";
|
|
||||||
position: absolute;
|
|
||||||
top: 0;
|
|
||||||
bottom: 0;
|
|
||||||
left: 0;
|
|
||||||
width: 6px;
|
|
||||||
background-image: linear-gradient(0, var(--mantine-color-blue-4), var(--mantine-color-blue-9));
|
|
||||||
}
|
|
||||||
|
|
||||||
@mixin rtl {
|
@mixin rtl {
|
||||||
padding-left: var(--mantine-spacing-xs);
|
padding-left: var(--mantine-spacing-xs);
|
||||||
padding-right: calc(var(--mantine-spacing-xs) * 2);
|
padding-right: calc(var(--mantine-spacing-xs) * 2);
|
||||||
|
border-left: none;
|
||||||
&::before {
|
border-right: 1px solid var(--mantine-color-blue-6);
|
||||||
left: unset;
|
|
||||||
right: 0;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
-1
@@ -23,7 +23,6 @@ export function HeaderCard(props: HeaderCardProps) {
|
|||||||
<Flex align="center" justify={isRTL ? "space-between" : "flex-start"} gap="md" direction={"row"}>
|
<Flex align="center" justify={isRTL ? "space-between" : "flex-start"} gap="md" direction={"row"}>
|
||||||
<ThemeIcon
|
<ThemeIcon
|
||||||
size="xl"
|
size="xl"
|
||||||
radius="md"
|
|
||||||
variant="gradient"
|
variant="gradient"
|
||||||
gradient={{
|
gradient={{
|
||||||
deg: 0,
|
deg: 0,
|
||||||
|
|||||||
+4
-2
@@ -8,6 +8,8 @@ import { useCurrentLocale, useI18n } from "@homarr/translation/client";
|
|||||||
import { ResourceIcon } from "~/app/[locale]/manage/tools/kubernetes/cluster-dashboard/resource-gauge/resource-icon";
|
import { ResourceIcon } from "~/app/[locale]/manage/tools/kubernetes/cluster-dashboard/resource-gauge/resource-icon";
|
||||||
import classes from "./resource-gauge.module.css";
|
import classes from "./resource-gauge.module.css";
|
||||||
|
|
||||||
|
const KUBERNETES_BRAND_COLOR = "#326ce5";
|
||||||
|
|
||||||
interface KubernetesResourceGaugeProps {
|
interface KubernetesResourceGaugeProps {
|
||||||
kubernetesCapacity: KubernetesCapacity;
|
kubernetesCapacity: KubernetesCapacity;
|
||||||
}
|
}
|
||||||
@@ -18,8 +20,8 @@ export function ResourceGauge(props: KubernetesResourceGaugeProps) {
|
|||||||
const isRTL = Boolean(isLocaleRTL(currentLocale));
|
const isRTL = Boolean(isLocaleRTL(currentLocale));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Paper radius="md" withBorder className={classes.paper} mt={20}>
|
<Paper className={classes.paper} mt={20}>
|
||||||
<ThemeIcon className={classes.icon} size={60} radius={60} bg={"#326ce5"}>
|
<ThemeIcon className={classes.icon} size={60} radius={60} bg={KUBERNETES_BRAND_COLOR}>
|
||||||
<ResourceIcon type={props.kubernetesCapacity.type} />
|
<ResourceIcon type={props.kubernetesCapacity.type} />
|
||||||
</ThemeIcon>
|
</ThemeIcon>
|
||||||
|
|
||||||
|
|||||||
+1
-6
@@ -17,12 +17,7 @@ interface ResourceTileProps {
|
|||||||
export function ResourceTile(props: ResourceTileProps) {
|
export function ResourceTile(props: ResourceTileProps) {
|
||||||
const t = useI18n();
|
const t = useI18n();
|
||||||
return (
|
return (
|
||||||
<Card
|
<Card component={Link} href={`/manage/tools/kubernetes/${props.label}`} className={classes.cardContainer}>
|
||||||
withBorder
|
|
||||||
component={Link}
|
|
||||||
href={`/manage/tools/kubernetes/${props.label}`}
|
|
||||||
className={classes.cardContainer}
|
|
||||||
>
|
|
||||||
<Group justify="space-between" wrap="nowrap">
|
<Group justify="space-between" wrap="nowrap">
|
||||||
<Image src={`/images/kubernetes/${props.label}.svg`} alt={props.label} width={64} height={64} />
|
<Image src={`/images/kubernetes/${props.label}.svg`} alt={props.label} width={64} height={64} />
|
||||||
<Group gap="xs">
|
<Group gap="xs">
|
||||||
|
|||||||
@@ -2,12 +2,14 @@
|
|||||||
|
|
||||||
import { Select } from "@mantine/core";
|
import { Select } from "@mantine/core";
|
||||||
|
|
||||||
import type { LogLevel } from "@homarr/core/infrastructure/logs/constants";
|
|
||||||
import { logLevelConfiguration, logLevels } from "@homarr/core/infrastructure/logs/constants";
|
import { logLevelConfiguration, logLevels } from "@homarr/core/infrastructure/logs/constants";
|
||||||
|
import type { LogLevel } from "@homarr/core/infrastructure/logs/constants";
|
||||||
import { useI18n } from "@homarr/translation/client";
|
import { useI18n } from "@homarr/translation/client";
|
||||||
|
|
||||||
import { useLogContext } from "./log-context";
|
import { useLogContext } from "./log-context";
|
||||||
|
|
||||||
|
const levelByValue = Object.fromEntries(logLevels.map((logLevel) => [logLevel, logLevel])) as Record<string, LogLevel>;
|
||||||
|
|
||||||
export const LogLevelSelection = () => {
|
export const LogLevelSelection = () => {
|
||||||
const { level, setLevel } = useLogContext();
|
const { level, setLevel } = useLogContext();
|
||||||
const t = useI18n();
|
const t = useI18n();
|
||||||
@@ -19,7 +21,12 @@ export const LogLevelSelection = () => {
|
|||||||
label: `${logLevelConfiguration[level].prefix} ${t(`log.level.option.${level}`)}`,
|
label: `${logLevelConfiguration[level].prefix} ${t(`log.level.option.${level}`)}`,
|
||||||
}))}
|
}))}
|
||||||
value={level}
|
value={level}
|
||||||
onChange={(value) => setLevel(value as LogLevel)}
|
onChange={(value) => {
|
||||||
|
const nextLevel = value && levelByValue[value];
|
||||||
|
if (nextLevel) {
|
||||||
|
setLevel(nextLevel);
|
||||||
|
}
|
||||||
|
}}
|
||||||
checkIconPosition="right"
|
checkIconPosition="right"
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
-85
@@ -1,85 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { Button, Group, Stack } from "@mantine/core";
|
|
||||||
import type { z } from "zod/v4";
|
|
||||||
|
|
||||||
import type { RouterOutputs } from "@homarr/api";
|
|
||||||
import { clientApi } from "@homarr/api/client";
|
|
||||||
import { revalidatePathActionAsync } from "@homarr/common/client";
|
|
||||||
import { useZodForm } from "@homarr/form";
|
|
||||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
|
||||||
import { useI18n } from "@homarr/translation/client";
|
|
||||||
import { userChangeHomeBoardsSchema } from "@homarr/validation/user";
|
|
||||||
|
|
||||||
import type { Board } from "~/app/[locale]/boards/_types";
|
|
||||||
import { BoardSelect } from "~/components/board/board-select";
|
|
||||||
|
|
||||||
interface ChangeHomeBoardFormProps {
|
|
||||||
user: RouterOutputs["user"]["getById"];
|
|
||||||
boardsData: Pick<Board, "id" | "name" | "logoImageUrl">[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export const ChangeHomeBoardForm = ({ user, boardsData }: ChangeHomeBoardFormProps) => {
|
|
||||||
const t = useI18n();
|
|
||||||
const { mutate, isPending } = clientApi.user.changeHomeBoards.useMutation({
|
|
||||||
async onSettled() {
|
|
||||||
await revalidatePathActionAsync(`/manage/users/${user.id}`);
|
|
||||||
},
|
|
||||||
onSuccess(_, variables) {
|
|
||||||
form.setInitialValues({
|
|
||||||
homeBoardId: variables.homeBoardId,
|
|
||||||
mobileHomeBoardId: variables.mobileHomeBoardId,
|
|
||||||
});
|
|
||||||
showSuccessNotification({
|
|
||||||
message: t("user.action.changeHomeBoard.notification.success.message"),
|
|
||||||
});
|
|
||||||
},
|
|
||||||
onError() {
|
|
||||||
showErrorNotification({
|
|
||||||
message: t("user.action.changeHomeBoard.notification.error.message"),
|
|
||||||
});
|
|
||||||
},
|
|
||||||
});
|
|
||||||
const form = useZodForm(userChangeHomeBoardsSchema, {
|
|
||||||
initialValues: {
|
|
||||||
homeBoardId: user.homeBoardId,
|
|
||||||
mobileHomeBoardId: user.mobileHomeBoardId,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const handleSubmit = (values: FormType) => {
|
|
||||||
mutate({
|
|
||||||
userId: user.id,
|
|
||||||
...values,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<form onSubmit={form.onSubmit(handleSubmit)}>
|
|
||||||
<Stack gap="md">
|
|
||||||
<BoardSelect
|
|
||||||
label={t("management.page.user.setting.general.item.board.type.general")}
|
|
||||||
clearable
|
|
||||||
boards={boardsData}
|
|
||||||
w="100%"
|
|
||||||
{...form.getInputProps("homeBoardId")}
|
|
||||||
/>
|
|
||||||
<BoardSelect
|
|
||||||
label={t("management.page.user.setting.general.item.board.type.mobile")}
|
|
||||||
clearable
|
|
||||||
boards={boardsData}
|
|
||||||
w="100%"
|
|
||||||
{...form.getInputProps("mobileHomeBoardId")}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Group justify="end">
|
|
||||||
<Button type="submit" loading={isPending}>
|
|
||||||
{t("common.action.save")}
|
|
||||||
</Button>
|
|
||||||
</Group>
|
|
||||||
</Stack>
|
|
||||||
</form>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
type FormType = z.infer<typeof userChangeHomeBoardsSchema>;
|
|
||||||
-84
@@ -1,84 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { Button, Group, Select, Stack, Switch } from "@mantine/core";
|
|
||||||
import type { z } from "zod/v4";
|
|
||||||
|
|
||||||
import type { RouterOutputs } from "@homarr/api";
|
|
||||||
import { clientApi } from "@homarr/api/client";
|
|
||||||
import { revalidatePathActionAsync } from "@homarr/common/client";
|
|
||||||
import { useZodForm } from "@homarr/form";
|
|
||||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
|
||||||
import { useI18n } from "@homarr/translation/client";
|
|
||||||
import { userChangeSearchPreferencesSchema } from "@homarr/validation/user";
|
|
||||||
|
|
||||||
interface ChangeSearchPreferencesFormProps {
|
|
||||||
user: RouterOutputs["user"]["getById"];
|
|
||||||
searchEnginesData: { value: string; label: string }[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export const ChangeSearchPreferencesForm = ({ user, searchEnginesData }: ChangeSearchPreferencesFormProps) => {
|
|
||||||
const t = useI18n();
|
|
||||||
const { mutate, isPending } = clientApi.user.changeSearchPreferences.useMutation({
|
|
||||||
async onSettled() {
|
|
||||||
await revalidatePathActionAsync(`/manage/users/${user.id}`);
|
|
||||||
},
|
|
||||||
onSuccess(_, variables) {
|
|
||||||
form.setInitialValues({
|
|
||||||
defaultSearchEngineId: variables.defaultSearchEngineId,
|
|
||||||
openInNewTab: variables.openInNewTab,
|
|
||||||
ddgBangsEnabled: variables.ddgBangsEnabled,
|
|
||||||
});
|
|
||||||
showSuccessNotification({
|
|
||||||
message: t("user.action.changeSearchPreferences.notification.success.message"),
|
|
||||||
});
|
|
||||||
},
|
|
||||||
onError() {
|
|
||||||
showErrorNotification({
|
|
||||||
message: t("user.action.changeSearchPreferences.notification.error.message"),
|
|
||||||
});
|
|
||||||
},
|
|
||||||
});
|
|
||||||
const form = useZodForm(userChangeSearchPreferencesSchema, {
|
|
||||||
initialValues: {
|
|
||||||
defaultSearchEngineId: user.defaultSearchEngineId,
|
|
||||||
openInNewTab: user.openSearchInNewTab,
|
|
||||||
ddgBangsEnabled: user.ddgBangs,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const handleSubmit = (values: FormType) => {
|
|
||||||
mutate({
|
|
||||||
userId: user.id,
|
|
||||||
...values,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<form onSubmit={form.onSubmit(handleSubmit)}>
|
|
||||||
<Stack gap="md">
|
|
||||||
<Select
|
|
||||||
label={t("user.field.defaultSearchEngine.label")}
|
|
||||||
w="100%"
|
|
||||||
data={searchEnginesData}
|
|
||||||
{...form.getInputProps("defaultSearchEngineId")}
|
|
||||||
/>
|
|
||||||
<Switch
|
|
||||||
label={t("user.field.openSearchInNewTab.label")}
|
|
||||||
{...form.getInputProps("openInNewTab", { type: "checkbox" })}
|
|
||||||
/>
|
|
||||||
<Switch
|
|
||||||
label={t("user.field.ddgBangs.label")}
|
|
||||||
{...form.getInputProps("ddgBangsEnabled", { type: "checkbox" })}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Group justify="end">
|
|
||||||
<Button type="submit" loading={isPending}>
|
|
||||||
{t("common.action.save")}
|
|
||||||
</Button>
|
|
||||||
</Group>
|
|
||||||
</Stack>
|
|
||||||
</form>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
type FormType = z.infer<typeof userChangeSearchPreferencesSchema>;
|
|
||||||
-83
@@ -1,83 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { Button, Group, Radio, Stack } from "@mantine/core";
|
|
||||||
import type { DayOfWeek } from "@mantine/dates";
|
|
||||||
import dayjs from "dayjs";
|
|
||||||
import localeData from "dayjs/plugin/localeData";
|
|
||||||
import type { z } from "zod/v4";
|
|
||||||
|
|
||||||
import type { RouterOutputs } from "@homarr/api";
|
|
||||||
import { clientApi } from "@homarr/api/client";
|
|
||||||
import { revalidatePathActionAsync } from "@homarr/common/client";
|
|
||||||
import { useZodForm } from "@homarr/form";
|
|
||||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
|
||||||
import { useI18n } from "@homarr/translation/client";
|
|
||||||
import { userFirstDayOfWeekSchema } from "@homarr/validation/user";
|
|
||||||
|
|
||||||
dayjs.extend(localeData);
|
|
||||||
|
|
||||||
interface FirstDayOfWeekProps {
|
|
||||||
user: RouterOutputs["user"]["getById"];
|
|
||||||
}
|
|
||||||
|
|
||||||
const weekDays = dayjs.weekdays(false);
|
|
||||||
|
|
||||||
export const FirstDayOfWeek = ({ user }: FirstDayOfWeekProps) => {
|
|
||||||
const t = useI18n();
|
|
||||||
const { mutate, isPending } = clientApi.user.changeFirstDayOfWeek.useMutation({
|
|
||||||
async onSettled() {
|
|
||||||
await revalidatePathActionAsync(`/manage/users/${user.id}`);
|
|
||||||
},
|
|
||||||
onSuccess(_, variables) {
|
|
||||||
form.setInitialValues({
|
|
||||||
firstDayOfWeek: variables.firstDayOfWeek as DayOfWeek,
|
|
||||||
});
|
|
||||||
showSuccessNotification({
|
|
||||||
message: t("user.action.changeFirstDayOfWeek.notification.success.message"),
|
|
||||||
});
|
|
||||||
},
|
|
||||||
onError() {
|
|
||||||
showErrorNotification({
|
|
||||||
message: t("user.action.changeFirstDayOfWeek.notification.error.message"),
|
|
||||||
});
|
|
||||||
},
|
|
||||||
});
|
|
||||||
const form = useZodForm(userFirstDayOfWeekSchema, {
|
|
||||||
initialValues: {
|
|
||||||
firstDayOfWeek: user.firstDayOfWeek as DayOfWeek,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const handleSubmit = (values: FormType) => {
|
|
||||||
mutate({
|
|
||||||
id: user.id,
|
|
||||||
...values,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const inputProps = form.getInputProps("firstDayOfWeek");
|
|
||||||
const onChange = inputProps.onChange as (value: number) => void;
|
|
||||||
const value = (inputProps.value as number).toString();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<form onSubmit={form.onSubmit(handleSubmit)}>
|
|
||||||
<Stack gap="md">
|
|
||||||
<Radio.Group {...inputProps} value={value} onChange={(value) => onChange(parseInt(value))}>
|
|
||||||
<Group mt="xs">
|
|
||||||
<Radio value="1" label={weekDays[1]} />
|
|
||||||
<Radio value="6" label={weekDays[6]} />
|
|
||||||
<Radio value="0" label={weekDays[0]} />
|
|
||||||
</Group>
|
|
||||||
</Radio.Group>
|
|
||||||
|
|
||||||
<Group justify="end">
|
|
||||||
<Button type="submit" loading={isPending}>
|
|
||||||
{t("common.action.save")}
|
|
||||||
</Button>
|
|
||||||
</Group>
|
|
||||||
</Stack>
|
|
||||||
</form>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
type FormType = z.infer<typeof userFirstDayOfWeekSchema>;
|
|
||||||
+332
@@ -0,0 +1,332 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useMemo, useRef } from "react";
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
Divider,
|
||||||
|
Group,
|
||||||
|
Radio,
|
||||||
|
Select,
|
||||||
|
SimpleGrid,
|
||||||
|
Stack,
|
||||||
|
Switch,
|
||||||
|
Text,
|
||||||
|
TextInput,
|
||||||
|
Title,
|
||||||
|
} from "@mantine/core";
|
||||||
|
import type { DayOfWeek } from "@mantine/dates";
|
||||||
|
import dayjs from "dayjs";
|
||||||
|
import localeData from "dayjs/plugin/localeData";
|
||||||
|
import { z } from "zod/v4";
|
||||||
|
|
||||||
|
import type { RouterOutputs } from "@homarr/api";
|
||||||
|
import { clientApi } from "@homarr/api/client";
|
||||||
|
import { revalidatePathActionAsync } from "@homarr/common/client";
|
||||||
|
import { env } from "@homarr/common/env";
|
||||||
|
import { useZodForm } from "@homarr/form";
|
||||||
|
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||||
|
import { useI18n, useScopedI18n } from "@homarr/translation/client";
|
||||||
|
import {
|
||||||
|
userChangeHomeBoardsSchema,
|
||||||
|
userChangeSearchPreferencesSchema,
|
||||||
|
userEditProfileSchema,
|
||||||
|
userFirstDayOfWeekSchema,
|
||||||
|
userPingIconsEnabledSchema,
|
||||||
|
} from "@homarr/validation/user";
|
||||||
|
|
||||||
|
import type { Board } from "~/app/[locale]/boards/_types";
|
||||||
|
import { BoardSelect } from "~/components/board/board-select";
|
||||||
|
import { CurrentLanguageCombobox } from "~/components/language/current-language-combobox";
|
||||||
|
|
||||||
|
dayjs.extend(localeData);
|
||||||
|
|
||||||
|
const userGeneralSettingsSchema = z.object({
|
||||||
|
name: userEditProfileSchema.shape.name,
|
||||||
|
email: userEditProfileSchema.shape.email,
|
||||||
|
homeBoardId: userChangeHomeBoardsSchema.shape.homeBoardId,
|
||||||
|
mobileHomeBoardId: userChangeHomeBoardsSchema.shape.mobileHomeBoardId,
|
||||||
|
defaultSearchEngineId: userChangeSearchPreferencesSchema.shape.defaultSearchEngineId,
|
||||||
|
openInNewTab: userChangeSearchPreferencesSchema.shape.openInNewTab,
|
||||||
|
ddgBangsEnabled: userChangeSearchPreferencesSchema.shape.ddgBangsEnabled,
|
||||||
|
firstDayOfWeek: userFirstDayOfWeekSchema.shape.firstDayOfWeek,
|
||||||
|
pingIconsEnabled: userPingIconsEnabledSchema.shape.pingIconsEnabled,
|
||||||
|
});
|
||||||
|
|
||||||
|
type FormValues = z.infer<typeof userGeneralSettingsSchema>;
|
||||||
|
|
||||||
|
const FIRST_DAY_OPTIONS: { value: DayOfWeek; labelKey: number }[] = [
|
||||||
|
{ value: 1, labelKey: 1 },
|
||||||
|
{ value: 6, labelKey: 6 },
|
||||||
|
{ value: 0, labelKey: 0 },
|
||||||
|
];
|
||||||
|
|
||||||
|
interface UserGeneralSettingsFormProps {
|
||||||
|
user: RouterOutputs["user"]["getById"];
|
||||||
|
boardsData: Pick<Board, "id" | "name" | "logoImageUrl">[];
|
||||||
|
searchEnginesData: { value: string; label: string }[];
|
||||||
|
showLanguageSelector?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const buildInitialValues = (user: RouterOutputs["user"]["getById"]): FormValues => ({
|
||||||
|
name: user.name ?? "",
|
||||||
|
email: user.email ?? "",
|
||||||
|
homeBoardId: user.homeBoardId,
|
||||||
|
mobileHomeBoardId: user.mobileHomeBoardId,
|
||||||
|
defaultSearchEngineId: user.defaultSearchEngineId,
|
||||||
|
openInNewTab: user.openSearchInNewTab,
|
||||||
|
ddgBangsEnabled: user.ddgBangs,
|
||||||
|
firstDayOfWeek: user.firstDayOfWeek as DayOfWeek,
|
||||||
|
pingIconsEnabled: user.pingIconsEnabled,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const UserGeneralSettingsForm = ({
|
||||||
|
user,
|
||||||
|
boardsData,
|
||||||
|
searchEnginesData,
|
||||||
|
showLanguageSelector = false,
|
||||||
|
}: UserGeneralSettingsFormProps) => {
|
||||||
|
const t = useI18n();
|
||||||
|
const tGeneral = useScopedI18n("management.page.user.setting.general");
|
||||||
|
const isCredentialsUser = user.provider === "credentials";
|
||||||
|
|
||||||
|
const editProfileMutation = clientApi.user.editProfile.useMutation();
|
||||||
|
const changeHomeBoardsMutation = clientApi.user.changeHomeBoards.useMutation();
|
||||||
|
const changeSearchPreferencesMutation = clientApi.user.changeSearchPreferences.useMutation();
|
||||||
|
const changeFirstDayOfWeekMutation = clientApi.user.changeFirstDayOfWeek.useMutation();
|
||||||
|
const changePingIconsEnabledMutation = clientApi.user.changePingIconsEnabled.useMutation();
|
||||||
|
|
||||||
|
const initialValues = buildInitialValues(user);
|
||||||
|
const initialValuesRef = useRef(initialValues);
|
||||||
|
|
||||||
|
const form = useZodForm(userGeneralSettingsSchema, {
|
||||||
|
initialValues,
|
||||||
|
});
|
||||||
|
|
||||||
|
const isDirtyRef = useRef(false);
|
||||||
|
isDirtyRef.current = form.isDirty();
|
||||||
|
|
||||||
|
const weekDays = useMemo(() => dayjs.weekdays(false), []);
|
||||||
|
|
||||||
|
const mutations = [
|
||||||
|
editProfileMutation,
|
||||||
|
changeHomeBoardsMutation,
|
||||||
|
changeSearchPreferencesMutation,
|
||||||
|
changeFirstDayOfWeekMutation,
|
||||||
|
changePingIconsEnabledMutation,
|
||||||
|
];
|
||||||
|
const isPending = mutations.some((m) => m.isPending);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handler = (e: BeforeUnloadEvent) => {
|
||||||
|
if (env.NODE_ENV === "development") return;
|
||||||
|
if (isDirtyRef.current) e.preventDefault();
|
||||||
|
};
|
||||||
|
window.addEventListener("beforeunload", handler);
|
||||||
|
return () => window.removeEventListener("beforeunload", handler);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleSubmitAsync = async (values: FormValues) => {
|
||||||
|
const parsed = userGeneralSettingsSchema.safeParse(values);
|
||||||
|
if (!parsed.success) return;
|
||||||
|
|
||||||
|
const defaults = initialValuesRef.current;
|
||||||
|
const changed = <K extends keyof FormValues>(...fields: K[]) =>
|
||||||
|
fields.some((f) => (f === "email" ? (values[f] || null) !== (defaults[f] || null) : values[f] !== defaults[f]));
|
||||||
|
|
||||||
|
const saveActions: { when: boolean; action: () => Promise<unknown> }[] = [
|
||||||
|
{
|
||||||
|
when: isCredentialsUser && changed("name", "email"),
|
||||||
|
action: () =>
|
||||||
|
editProfileMutation.mutateAsync({ id: user.id, name: parsed.data.name, email: parsed.data.email ?? "" }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
when: changed("homeBoardId", "mobileHomeBoardId"),
|
||||||
|
action: () =>
|
||||||
|
changeHomeBoardsMutation.mutateAsync({
|
||||||
|
userId: user.id,
|
||||||
|
homeBoardId: parsed.data.homeBoardId,
|
||||||
|
mobileHomeBoardId: parsed.data.mobileHomeBoardId,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
when: changed("defaultSearchEngineId", "openInNewTab", "ddgBangsEnabled"),
|
||||||
|
action: () =>
|
||||||
|
changeSearchPreferencesMutation.mutateAsync({
|
||||||
|
userId: user.id,
|
||||||
|
defaultSearchEngineId: parsed.data.defaultSearchEngineId,
|
||||||
|
openInNewTab: parsed.data.openInNewTab,
|
||||||
|
ddgBangsEnabled: parsed.data.ddgBangsEnabled,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
when: changed("firstDayOfWeek"),
|
||||||
|
action: () =>
|
||||||
|
changeFirstDayOfWeekMutation.mutateAsync({ id: user.id, firstDayOfWeek: parsed.data.firstDayOfWeek }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
when: changed("pingIconsEnabled"),
|
||||||
|
action: () =>
|
||||||
|
changePingIconsEnabledMutation.mutateAsync({ id: user.id, pingIconsEnabled: parsed.data.pingIconsEnabled }),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const promises = saveActions.filter((s) => s.when).map((s) => s.action());
|
||||||
|
if (promises.length === 0) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await Promise.all(promises);
|
||||||
|
const newValues = { ...parsed.data, email: parsed.data.email ?? "" };
|
||||||
|
initialValuesRef.current = newValues;
|
||||||
|
form.setInitialValues(newValues);
|
||||||
|
form.resetDirty();
|
||||||
|
await revalidatePathActionAsync(`/manage/users/${user.id}`);
|
||||||
|
showSuccessNotification({
|
||||||
|
title: t("common.notification.update.success"),
|
||||||
|
message: t("common.notification.update.success"),
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
showErrorNotification({
|
||||||
|
title: t("common.notification.update.error"),
|
||||||
|
message: t("common.notification.update.error"),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDiscard = () => {
|
||||||
|
form.setValues(initialValuesRef.current);
|
||||||
|
form.resetDirty();
|
||||||
|
};
|
||||||
|
|
||||||
|
const firstDayOfWeekInputProps = form.getInputProps("firstDayOfWeek");
|
||||||
|
const firstDayOfWeekOnChange = firstDayOfWeekInputProps.onChange as (value: number) => void;
|
||||||
|
const firstDayOfWeekValue = (firstDayOfWeekInputProps.value as number).toString();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={form.onSubmit(handleSubmitAsync)}>
|
||||||
|
<Stack gap="lg">
|
||||||
|
<SimpleGrid cols={{ base: 1, md: 2 }} spacing="lg" verticalSpacing="lg">
|
||||||
|
<Card withBorder bg="transparent">
|
||||||
|
<Stack gap="md">
|
||||||
|
<Stack gap={2}>
|
||||||
|
<Title order={3}>{t("user.name")}</Title>
|
||||||
|
{!isCredentialsUser && (
|
||||||
|
<Text c="dimmed" size="sm">
|
||||||
|
{t("management.page.user.fieldsDisabledExternalProvider")}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
<Divider />
|
||||||
|
<TextInput
|
||||||
|
disabled={!isCredentialsUser}
|
||||||
|
label={t("user.field.username.label")}
|
||||||
|
withAsterisk
|
||||||
|
{...form.getInputProps("name")}
|
||||||
|
/>
|
||||||
|
<TextInput
|
||||||
|
disabled={!isCredentialsUser}
|
||||||
|
label={t("user.field.email.label")}
|
||||||
|
{...form.getInputProps("email")}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card withBorder bg="transparent">
|
||||||
|
<Stack gap="md">
|
||||||
|
<Stack gap={2}>
|
||||||
|
<Title order={3}>{tGeneral("item.board.title")}</Title>
|
||||||
|
</Stack>
|
||||||
|
<Divider />
|
||||||
|
<BoardSelect
|
||||||
|
label={tGeneral("item.board.type.general")}
|
||||||
|
clearable
|
||||||
|
boards={boardsData}
|
||||||
|
w="100%"
|
||||||
|
withinPortal
|
||||||
|
{...form.getInputProps("homeBoardId")}
|
||||||
|
/>
|
||||||
|
<BoardSelect
|
||||||
|
label={tGeneral("item.board.type.mobile")}
|
||||||
|
clearable
|
||||||
|
boards={boardsData}
|
||||||
|
w="100%"
|
||||||
|
withinPortal
|
||||||
|
{...form.getInputProps("mobileHomeBoardId")}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card withBorder bg="transparent">
|
||||||
|
<Stack gap="md">
|
||||||
|
<Stack gap={2}>
|
||||||
|
<Title order={3}>{tGeneral("item.search")}</Title>
|
||||||
|
</Stack>
|
||||||
|
<Divider />
|
||||||
|
<Select
|
||||||
|
label={t("user.field.defaultSearchEngine.label")}
|
||||||
|
w="100%"
|
||||||
|
data={searchEnginesData}
|
||||||
|
comboboxProps={{ withinPortal: true }}
|
||||||
|
{...form.getInputProps("defaultSearchEngineId")}
|
||||||
|
/>
|
||||||
|
<Switch
|
||||||
|
label={t("user.field.openSearchInNewTab.label")}
|
||||||
|
{...form.getInputProps("openInNewTab", { type: "checkbox" })}
|
||||||
|
/>
|
||||||
|
<Switch
|
||||||
|
label={t("user.field.ddgBangs.label")}
|
||||||
|
{...form.getInputProps("ddgBangsEnabled", { type: "checkbox" })}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card withBorder bg="transparent">
|
||||||
|
<Stack gap="md">
|
||||||
|
<Stack gap={2}>
|
||||||
|
<Title order={3}>{tGeneral("item.language")}</Title>
|
||||||
|
</Stack>
|
||||||
|
<Divider />
|
||||||
|
{showLanguageSelector && <CurrentLanguageCombobox withinPortal />}
|
||||||
|
<Title order={4}>{tGeneral("item.firstDayOfWeek")}</Title>
|
||||||
|
<Radio.Group
|
||||||
|
{...firstDayOfWeekInputProps}
|
||||||
|
value={firstDayOfWeekValue}
|
||||||
|
onChange={(nextValue: string) => firstDayOfWeekOnChange(parseInt(nextValue, 10))}
|
||||||
|
>
|
||||||
|
<Group mt="xs" wrap="wrap">
|
||||||
|
{FIRST_DAY_OPTIONS.map(({ value: dayValue, labelKey }) => (
|
||||||
|
<Radio key={dayValue} value={dayValue.toString()} label={weekDays[labelKey]} />
|
||||||
|
))}
|
||||||
|
</Group>
|
||||||
|
</Radio.Group>
|
||||||
|
<Divider my="xs" />
|
||||||
|
<Title order={4}>{tGeneral("item.accessibility")}</Title>
|
||||||
|
<Switch
|
||||||
|
label={t("user.field.pingIconsEnabled.label")}
|
||||||
|
{...form.getInputProps("pingIconsEnabled", { type: "checkbox" })}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
</Card>
|
||||||
|
</SimpleGrid>
|
||||||
|
|
||||||
|
<div style={{ position: "sticky", bottom: 20 }}>
|
||||||
|
{form.isDirty() && (
|
||||||
|
<Card withBorder>
|
||||||
|
<Group justify="space-between" wrap="wrap">
|
||||||
|
<Text fw={500}>{t("common.unsavedChanges")}</Text>
|
||||||
|
<Group>
|
||||||
|
<Button disabled={isPending} variant="default" onClick={handleDiscard}>
|
||||||
|
{t("common.action.discard")}
|
||||||
|
</Button>
|
||||||
|
<Button loading={isPending} type="submit" disabled={!form.isValid()}>
|
||||||
|
{t("common.action.saveChanges")}
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Group>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Stack>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
};
|
||||||
-69
@@ -1,69 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { Button, Group, Stack, Switch } from "@mantine/core";
|
|
||||||
import type { z } from "zod/v4";
|
|
||||||
|
|
||||||
import type { RouterOutputs } from "@homarr/api";
|
|
||||||
import { clientApi } from "@homarr/api/client";
|
|
||||||
import { revalidatePathActionAsync } from "@homarr/common/client";
|
|
||||||
import { useZodForm } from "@homarr/form";
|
|
||||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
|
||||||
import { useI18n } from "@homarr/translation/client";
|
|
||||||
import { userPingIconsEnabledSchema } from "@homarr/validation/user";
|
|
||||||
|
|
||||||
interface PingIconsEnabledProps {
|
|
||||||
user: RouterOutputs["user"]["getById"];
|
|
||||||
}
|
|
||||||
|
|
||||||
export const PingIconsEnabled = ({ user }: PingIconsEnabledProps) => {
|
|
||||||
const t = useI18n();
|
|
||||||
const { mutate, isPending } = clientApi.user.changePingIconsEnabled.useMutation({
|
|
||||||
async onSettled() {
|
|
||||||
await revalidatePathActionAsync(`/manage/users/${user.id}`);
|
|
||||||
},
|
|
||||||
onSuccess(_, variables) {
|
|
||||||
form.setInitialValues({
|
|
||||||
pingIconsEnabled: variables.pingIconsEnabled,
|
|
||||||
});
|
|
||||||
showSuccessNotification({
|
|
||||||
message: t("user.action.changePingIconsEnabled.notification.success.message"),
|
|
||||||
});
|
|
||||||
},
|
|
||||||
onError() {
|
|
||||||
showErrorNotification({
|
|
||||||
message: t("user.action.changePingIconsEnabled.notification.error.message"),
|
|
||||||
});
|
|
||||||
},
|
|
||||||
});
|
|
||||||
const form = useZodForm(userPingIconsEnabledSchema, {
|
|
||||||
initialValues: {
|
|
||||||
pingIconsEnabled: user.pingIconsEnabled,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const handleSubmit = (values: FormType) => {
|
|
||||||
mutate({
|
|
||||||
id: user.id,
|
|
||||||
...values,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<form onSubmit={form.onSubmit(handleSubmit)}>
|
|
||||||
<Stack gap="md">
|
|
||||||
<Switch
|
|
||||||
{...form.getInputProps("pingIconsEnabled", { type: "checkbox" })}
|
|
||||||
label={t("user.field.pingIconsEnabled.label")}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Group justify="end">
|
|
||||||
<Button type="submit" loading={isPending}>
|
|
||||||
{t("common.action.save")}
|
|
||||||
</Button>
|
|
||||||
</Group>
|
|
||||||
</Stack>
|
|
||||||
</form>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
type FormType = z.infer<typeof userPingIconsEnabledSchema>;
|
|
||||||
+5
-4
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { useCallback } from "react";
|
import { useCallback } from "react";
|
||||||
import { Box, Button, FileButton, Menu, UnstyledButton } from "@mantine/core";
|
import { Box, Button, FileButton, Menu, UnstyledButton } from "@mantine/core";
|
||||||
import { useDisclosure } from "@mantine/hooks";
|
import { useDisclosure, useMediaQuery } from "@mantine/hooks";
|
||||||
import { IconPencil, IconPhotoEdit, IconPhotoX } from "@tabler/icons-react";
|
import { IconPencil, IconPhotoEdit, IconPhotoX } from "@tabler/icons-react";
|
||||||
|
|
||||||
import type { RouterOutputs } from "@homarr/api";
|
import type { RouterOutputs } from "@homarr/api";
|
||||||
@@ -25,6 +25,7 @@ export const UserProfileAvatarForm = ({ user }: UserProfileAvatarForm) => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
const [opened, { toggle }] = useDisclosure(false);
|
const [opened, { toggle }] = useDisclosure(false);
|
||||||
|
const isMobile = useMediaQuery("(max-width: 48em)");
|
||||||
const { openConfirmModal } = useConfirmModal();
|
const { openConfirmModal } = useConfirmModal();
|
||||||
const t = useI18n();
|
const t = useI18n();
|
||||||
const tManageAvatar = useScopedI18n("user.action.manageAvatar");
|
const tManageAvatar = useScopedI18n("user.action.manageAvatar");
|
||||||
@@ -94,11 +95,11 @@ export const UserProfileAvatarForm = ({ user }: UserProfileAvatarForm) => {
|
|||||||
}, [mutate, user.id, openConfirmModal, tManageAvatar]);
|
}, [mutate, user.id, openConfirmModal, tManageAvatar]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box pos="relative">
|
<Box pos="relative" display="flex" style={{ justifyContent: isMobile ? "center" : undefined }}>
|
||||||
<Menu opened={opened} keepMounted onChange={toggle} position="bottom-start" withArrow>
|
<Menu opened={opened} keepMounted onChange={toggle} position="bottom-start" withArrow withinPortal>
|
||||||
<Menu.Target>
|
<Menu.Target>
|
||||||
<UnstyledButton onClick={toggle}>
|
<UnstyledButton onClick={toggle}>
|
||||||
<UserAvatar user={user} size={200} />
|
<UserAvatar user={user} size={isMobile ? 120 : 200} />
|
||||||
<Button
|
<Button
|
||||||
component="div"
|
component="div"
|
||||||
pos="absolute"
|
pos="absolute"
|
||||||
|
|||||||
-94
@@ -1,94 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useCallback } from "react";
|
|
||||||
import { Button, Group, Stack, TextInput } from "@mantine/core";
|
|
||||||
|
|
||||||
import type { RouterInputs, RouterOutputs } from "@homarr/api";
|
|
||||||
import { clientApi } from "@homarr/api/client";
|
|
||||||
import { revalidatePathActionAsync } from "@homarr/common/client";
|
|
||||||
import { useZodForm } from "@homarr/form";
|
|
||||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
|
||||||
import { useI18n } from "@homarr/translation/client";
|
|
||||||
import { userEditProfileSchema } from "@homarr/validation/user";
|
|
||||||
|
|
||||||
interface UserProfileFormProps {
|
|
||||||
user: RouterOutputs["user"]["getById"];
|
|
||||||
}
|
|
||||||
|
|
||||||
export const UserProfileForm = ({ user }: UserProfileFormProps) => {
|
|
||||||
const t = useI18n();
|
|
||||||
const { mutate, isPending } = clientApi.user.editProfile.useMutation({
|
|
||||||
async onSettled() {
|
|
||||||
await revalidatePathActionAsync("/manage/users");
|
|
||||||
},
|
|
||||||
onSuccess(_, variables) {
|
|
||||||
// Reset form initial values to reset dirty state
|
|
||||||
form.setInitialValues({
|
|
||||||
name: variables.name,
|
|
||||||
email: variables.email ?? "",
|
|
||||||
});
|
|
||||||
showSuccessNotification({
|
|
||||||
title: t("common.notification.update.success"),
|
|
||||||
message: t("user.action.editProfile.notification.success.message"),
|
|
||||||
});
|
|
||||||
},
|
|
||||||
onError(error) {
|
|
||||||
const message =
|
|
||||||
error.data?.code === "CONFLICT"
|
|
||||||
? t("user.error.usernameTaken")
|
|
||||||
: t("user.action.editProfile.notification.error.message");
|
|
||||||
showErrorNotification({
|
|
||||||
title: t("common.notification.update.error"),
|
|
||||||
message,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
});
|
|
||||||
const form = useZodForm(userEditProfileSchema.omit({ id: true }), {
|
|
||||||
initialValues: {
|
|
||||||
name: user.name ?? "",
|
|
||||||
email: user.email ?? "",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// Only credentials users can edit their profile
|
|
||||||
const isProviderCredentials = user.provider === "credentials";
|
|
||||||
|
|
||||||
const handleSubmit = useCallback(
|
|
||||||
(values: FormType) => {
|
|
||||||
if (!isProviderCredentials) return;
|
|
||||||
mutate({
|
|
||||||
...values,
|
|
||||||
id: user.id,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
[isProviderCredentials, mutate, user.id],
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<form onSubmit={form.onSubmit(handleSubmit)}>
|
|
||||||
<Stack>
|
|
||||||
<TextInput
|
|
||||||
disabled={!isProviderCredentials}
|
|
||||||
label={t("user.field.username.label")}
|
|
||||||
withAsterisk
|
|
||||||
{...form.getInputProps("name")}
|
|
||||||
/>
|
|
||||||
<TextInput
|
|
||||||
disabled={!isProviderCredentials}
|
|
||||||
label={t("user.field.email.label")}
|
|
||||||
{...form.getInputProps("email")}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{isProviderCredentials && (
|
|
||||||
<Group justify="end">
|
|
||||||
<Button type="submit" disabled={!form.isDirty()} loading={isPending}>
|
|
||||||
{t("common.action.saveChanges")}
|
|
||||||
</Button>
|
|
||||||
</Group>
|
|
||||||
)}
|
|
||||||
</Stack>
|
|
||||||
</form>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
type FormType = Omit<RouterInputs["user"]["editProfile"], "id">;
|
|
||||||
@@ -6,18 +6,13 @@ import { api } from "@homarr/api/server";
|
|||||||
import { auth } from "@homarr/auth/next";
|
import { auth } from "@homarr/auth/next";
|
||||||
import { getI18n, getScopedI18n } from "@homarr/translation/server";
|
import { getI18n, getScopedI18n } from "@homarr/translation/server";
|
||||||
|
|
||||||
import { CurrentLanguageCombobox } from "~/components/language/current-language-combobox";
|
|
||||||
import { DangerZoneItem, DangerZoneRoot } from "~/components/manage/danger-zone";
|
import { DangerZoneItem, DangerZoneRoot } from "~/components/manage/danger-zone";
|
||||||
import { catchTrpcNotFound } from "~/errors/trpc-catch-error";
|
import { catchTrpcNotFound } from "~/errors/trpc-catch-error";
|
||||||
import { createMetaTitle } from "~/metadata";
|
import { createMetaTitle } from "~/metadata";
|
||||||
import { canAccessUserEditPage } from "../access";
|
import { canAccessUserEditPage } from "../access";
|
||||||
import { ChangeHomeBoardForm } from "./_components/_change-home-board";
|
|
||||||
import { ChangeSearchPreferencesForm } from "./_components/_change-search-preferences";
|
|
||||||
import { DeleteUserButton } from "./_components/_delete-user-button";
|
import { DeleteUserButton } from "./_components/_delete-user-button";
|
||||||
import { FirstDayOfWeek } from "./_components/_first-day-of-week";
|
import { UserGeneralSettingsForm } from "./_components/_general-settings-form";
|
||||||
import { PingIconsEnabled } from "./_components/_ping-icons-enabled";
|
|
||||||
import { UserProfileAvatarForm } from "./_components/_profile-avatar-form";
|
import { UserProfileAvatarForm } from "./_components/_profile-avatar-form";
|
||||||
import { UserProfileForm } from "./_components/_profile-form";
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
params: Promise<{
|
params: Promise<{
|
||||||
@@ -63,7 +58,7 @@ export default async function EditUserPage(props: Props) {
|
|||||||
|
|
||||||
const boards = await api.board.getAllBoards();
|
const boards = await api.board.getAllBoards();
|
||||||
const searchEngines = await api.searchEngine.getSelectable();
|
const searchEngines = await api.searchEngine.getSelectable();
|
||||||
|
const isSelf = session?.user.id === user.id;
|
||||||
const isCredentialsUser = user.provider === "credentials";
|
const isCredentialsUser = user.provider === "credentials";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -73,51 +68,25 @@ export default async function EditUserPage(props: Props) {
|
|||||||
{t("management.page.user.fieldsDisabledExternalProvider")}
|
{t("management.page.user.fieldsDisabledExternalProvider")}
|
||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Title>{tGeneral("title")}</Title>
|
<Title>{tGeneral("title")}</Title>
|
||||||
<Group gap="xl">
|
<Group gap="xl" align="flex-start" wrap="wrap">
|
||||||
<Box flex={1}>
|
<Box flex={1} miw={{ base: "100%", md: 540 }}>
|
||||||
<UserProfileForm user={user} />
|
<UserGeneralSettingsForm
|
||||||
|
user={user}
|
||||||
|
boardsData={boards.map((board) => ({
|
||||||
|
id: board.id,
|
||||||
|
name: board.name,
|
||||||
|
logoImageUrl: board.logoImageUrl,
|
||||||
|
}))}
|
||||||
|
searchEnginesData={searchEngines}
|
||||||
|
showLanguageSelector={isSelf}
|
||||||
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
<Box w={{ base: "100%", lg: 200 }}>
|
<Box w={{ base: "100%", lg: 260 }}>
|
||||||
<UserProfileAvatarForm user={user} />
|
<UserProfileAvatarForm user={user} />
|
||||||
</Box>
|
</Box>
|
||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
{session?.user.id === user.id && (
|
|
||||||
<Stack mb="lg">
|
|
||||||
<Title order={2}>{tGeneral("item.language")}</Title>
|
|
||||||
<CurrentLanguageCombobox />
|
|
||||||
</Stack>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Stack mb="lg">
|
|
||||||
<Title order={2}>{tGeneral("item.board.title")}</Title>
|
|
||||||
<ChangeHomeBoardForm
|
|
||||||
user={user}
|
|
||||||
boardsData={boards.map((board) => ({
|
|
||||||
id: board.id,
|
|
||||||
name: board.name,
|
|
||||||
logoImageUrl: board.logoImageUrl,
|
|
||||||
}))}
|
|
||||||
/>
|
|
||||||
</Stack>
|
|
||||||
|
|
||||||
<Stack mb="lg">
|
|
||||||
<Title order={2}>{tGeneral("item.search")}</Title>
|
|
||||||
<ChangeSearchPreferencesForm user={user} searchEnginesData={searchEngines} />
|
|
||||||
</Stack>
|
|
||||||
|
|
||||||
<Stack mb="lg">
|
|
||||||
<Title order={2}>{tGeneral("item.firstDayOfWeek")}</Title>
|
|
||||||
<FirstDayOfWeek user={user} />
|
|
||||||
</Stack>
|
|
||||||
|
|
||||||
<Stack mb="lg">
|
|
||||||
<Title order={2}>{tGeneral("item.accessibility")}</Title>
|
|
||||||
<PingIconsEnabled user={user} />
|
|
||||||
</Stack>
|
|
||||||
|
|
||||||
<DangerZoneRoot>
|
<DangerZoneRoot>
|
||||||
<DangerZoneItem
|
<DangerZoneItem
|
||||||
label={t("user.action.delete.label")}
|
label={t("user.action.delete.label")}
|
||||||
|
|||||||
@@ -1,24 +1,22 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import { Anchor, Button, Group, Text, Title, Tooltip } from "@mantine/core";
|
import { Anchor, Group, Text, Tooltip } from "@mantine/core";
|
||||||
import { IconCheck } from "@tabler/icons-react";
|
import { IconCheck } from "@tabler/icons-react";
|
||||||
import type { MRT_ColumnDef } from "mantine-react-table";
|
import type { MRT_ColumnDef } from "mantine-react-table";
|
||||||
import { MantineReactTable } from "mantine-react-table";
|
import { MantineReactTable } from "mantine-react-table";
|
||||||
|
|
||||||
import type { RouterOutputs } from "@homarr/api";
|
import type { RouterOutputs } from "@homarr/api";
|
||||||
import { clientApi } from "@homarr/api/client";
|
import { clientApi } from "@homarr/api/client";
|
||||||
import { useI18n, useScopedI18n } from "@homarr/translation/client";
|
import { useI18n } from "@homarr/translation/client";
|
||||||
import { Link, UserAvatar } from "@homarr/ui";
|
import { Link, UserAvatar } from "@homarr/ui";
|
||||||
import { useTranslatedMantineReactTable } from "@homarr/ui/hooks";
|
import { useTranslatedMantineReactTable } from "@homarr/ui/hooks";
|
||||||
|
|
||||||
interface UserListComponentProps {
|
interface UserListComponentProps {
|
||||||
initialUserList: RouterOutputs["user"]["getAll"];
|
initialUserList: RouterOutputs["user"]["getAll"];
|
||||||
credentialsProviderEnabled: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const UserListComponent = ({ initialUserList, credentialsProviderEnabled }: UserListComponentProps) => {
|
export const UserListComponent = ({ initialUserList }: UserListComponentProps) => {
|
||||||
const tUserList = useScopedI18n("management.page.user.list");
|
|
||||||
const t = useI18n();
|
const t = useI18n();
|
||||||
const { data, isLoading } = clientApi.user.getAll.useQuery(undefined, {
|
const { data, isLoading } = clientApi.user.getAll.useQuery(undefined, {
|
||||||
initialData: initialUserList,
|
initialData: initialUserList,
|
||||||
@@ -69,21 +67,10 @@ export const UserListComponent = ({ initialUserList, credentialsProviderEnabled
|
|||||||
enableFullScreenToggle: false,
|
enableFullScreenToggle: false,
|
||||||
layoutMode: "grid-no-grow",
|
layoutMode: "grid-no-grow",
|
||||||
getRowId: (row) => row.id,
|
getRowId: (row) => row.id,
|
||||||
renderTopToolbarCustomActions: () =>
|
|
||||||
credentialsProviderEnabled ? (
|
|
||||||
<Button component={Link} href="/manage/users/create">
|
|
||||||
{t("management.page.user.create.title")}
|
|
||||||
</Button>
|
|
||||||
) : null,
|
|
||||||
state: {
|
state: {
|
||||||
isLoading,
|
isLoading,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return <MantineReactTable table={table} />;
|
||||||
<>
|
|
||||||
<Title mb="md">{tUserList("title")}</Title>
|
|
||||||
<MantineReactTable table={table} />
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
+12
-32
@@ -1,20 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { startTransition, useCallback, useState } from "react";
|
import { startTransition, useCallback, useState } from "react";
|
||||||
import {
|
import { Badge, Button, Card, Group, Stack, Stepper, Table, Text, TextInput, Title, Tooltip } from "@mantine/core";
|
||||||
Badge,
|
|
||||||
Button,
|
|
||||||
Card,
|
|
||||||
Group,
|
|
||||||
PasswordInput,
|
|
||||||
Stack,
|
|
||||||
Stepper,
|
|
||||||
Table,
|
|
||||||
Text,
|
|
||||||
TextInput,
|
|
||||||
Title,
|
|
||||||
Tooltip,
|
|
||||||
} from "@mantine/core";
|
|
||||||
import { useListState } from "@mantine/hooks";
|
import { useListState } from "@mantine/hooks";
|
||||||
import { IconPlus, IconUserCheck } from "@tabler/icons-react";
|
import { IconPlus, IconUserCheck } from "@tabler/icons-react";
|
||||||
import { z } from "zod/v4";
|
import { z } from "zod/v4";
|
||||||
@@ -24,10 +11,11 @@ import type { GroupPermissionKey } from "@homarr/definitions";
|
|||||||
import { everyoneGroup, groupPermissions } from "@homarr/definitions";
|
import { everyoneGroup, groupPermissions } from "@homarr/definitions";
|
||||||
import type { IsValid } from "@homarr/form";
|
import type { IsValid } from "@homarr/form";
|
||||||
import { useZodForm } from "@homarr/form";
|
import { useZodForm } from "@homarr/form";
|
||||||
|
import { UserCreatePasswordFields } from "@homarr/forms-collection";
|
||||||
import { useModalAction } from "@homarr/modals";
|
import { useModalAction } from "@homarr/modals";
|
||||||
import { showErrorNotification } from "@homarr/notifications";
|
import { showErrorNotification } from "@homarr/notifications";
|
||||||
import { useI18n, useScopedI18n } from "@homarr/translation/client";
|
import { useI18n, useScopedI18n } from "@homarr/translation/client";
|
||||||
import { CustomPasswordInput, UserAvatar } from "@homarr/ui";
|
import { UserAvatar } from "@homarr/ui";
|
||||||
import { createCustomErrorParams } from "@homarr/validation/form/i18n";
|
import { createCustomErrorParams } from "@homarr/validation/form/i18n";
|
||||||
import { userPasswordSchema } from "@homarr/validation/user";
|
import { userPasswordSchema } from "@homarr/validation/user";
|
||||||
|
|
||||||
@@ -149,7 +137,7 @@ export const UserCreateStepperComponent = ({ initialGroups }: UserCreateStepperC
|
|||||||
color={!generalForm.isValid() ? "red" : undefined}
|
color={!generalForm.isValid() ? "red" : undefined}
|
||||||
>
|
>
|
||||||
<form>
|
<form>
|
||||||
<Card p="xl" shadow="md" withBorder>
|
<Card p="xl">
|
||||||
<Stack gap="md">
|
<Stack gap="md">
|
||||||
<TextInput
|
<TextInput
|
||||||
label={tUserField("username.label")}
|
label={tUserField("username.label")}
|
||||||
@@ -165,27 +153,19 @@ export const UserCreateStepperComponent = ({ initialGroups }: UserCreateStepperC
|
|||||||
</Stepper.Step>
|
</Stepper.Step>
|
||||||
<Stepper.Step label={t("step.security.label")} allowStepSelect={false} allowStepClick={false}>
|
<Stepper.Step label={t("step.security.label")} allowStepSelect={false} allowStepClick={false}>
|
||||||
<form>
|
<form>
|
||||||
<Card p="xl" shadow="md" withBorder>
|
<Card p="xl">
|
||||||
<Stack gap="md">
|
<Stack gap="md">
|
||||||
<CustomPasswordInput
|
<UserCreatePasswordFields
|
||||||
withPasswordRequirements
|
|
||||||
label={tUserField("password.label")}
|
|
||||||
variant="filled"
|
variant="filled"
|
||||||
withAsterisk
|
passwordInputProps={securityForm.getInputProps("password")}
|
||||||
{...securityForm.getInputProps("password")}
|
confirmPasswordInputProps={securityForm.getInputProps("confirmPassword")}
|
||||||
/>
|
|
||||||
<PasswordInput
|
|
||||||
label={tUserField("passwordConfirm.label")}
|
|
||||||
variant="filled"
|
|
||||||
withAsterisk
|
|
||||||
{...securityForm.getInputProps("confirmPassword")}
|
|
||||||
/>
|
/>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Card>
|
</Card>
|
||||||
</form>
|
</form>
|
||||||
</Stepper.Step>
|
</Stepper.Step>
|
||||||
<Stepper.Step label={t("step.groups.label")} allowStepSelect={false} allowStepClick={false}>
|
<Stepper.Step label={t("step.groups.label")} allowStepSelect={false} allowStepClick={false}>
|
||||||
<Card p="xl" shadow="md" withBorder>
|
<Card p="xl">
|
||||||
<GroupsForm
|
<GroupsForm
|
||||||
initialGroups={initialGroups}
|
initialGroups={initialGroups}
|
||||||
addGroup={(groupId) =>
|
addGroup={(groupId) =>
|
||||||
@@ -198,20 +178,20 @@ export const UserCreateStepperComponent = ({ initialGroups }: UserCreateStepperC
|
|||||||
</Card>
|
</Card>
|
||||||
</Stepper.Step>
|
</Stepper.Step>
|
||||||
<Stepper.Step label={t("step.review.label")} allowStepSelect={false} allowStepClick={false}>
|
<Stepper.Step label={t("step.review.label")} allowStepSelect={false} allowStepClick={false}>
|
||||||
<Card p="xl" shadow="md" withBorder>
|
<Card p="xl">
|
||||||
<Stack maw={300} align="center" mx="auto">
|
<Stack maw={300} align="center" mx="auto">
|
||||||
<UserAvatar
|
<UserAvatar
|
||||||
size="xl"
|
size="xl"
|
||||||
user={{ name: generalForm.values.username, email: generalForm.values.email ?? null, image: null }}
|
user={{ name: generalForm.values.username, email: generalForm.values.email ?? null, image: null }}
|
||||||
/>
|
/>
|
||||||
<Text tt="uppercase" fw="bolder" size="xl">
|
<Text fw={700} size="xl">
|
||||||
{generalForm.values.username}
|
{generalForm.values.username}
|
||||||
</Text>
|
</Text>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Card>
|
</Card>
|
||||||
</Stepper.Step>
|
</Stepper.Step>
|
||||||
<Stepper.Completed>
|
<Stepper.Completed>
|
||||||
<Card p="xl" shadow="md" withBorder>
|
<Card p="xl">
|
||||||
<Stack align="center" maw={300} mx="auto">
|
<Stack align="center" maw={300} mx="auto">
|
||||||
<IconUserCheck size="3rem" />
|
<IconUserCheck size="3rem" />
|
||||||
<Title order={2}>{t("step.completed.title")}</Title>
|
<Title order={2}>{t("step.completed.title")}</Title>
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ export const StepperNavigationComponent = ({
|
|||||||
}: StepperNavigationComponentProps) => {
|
}: StepperNavigationComponentProps) => {
|
||||||
const t = useI18n();
|
const t = useI18n();
|
||||||
return (
|
return (
|
||||||
<Card shadow="md" withBorder>
|
<Card>
|
||||||
{!isComplete ? (
|
{!isComplete ? (
|
||||||
<Group justify="space-between" wrap="nowrap">
|
<Group justify="space-between" wrap="nowrap">
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
+1
-1
@@ -101,7 +101,7 @@ export const SaveAffix = ({ groupId }: SaveAffixProps) => {
|
|||||||
<div style={{ position: "sticky", bottom: 20 }}>
|
<div style={{ position: "sticky", bottom: 20 }}>
|
||||||
<Transition transition="slide-up" mounted={form.isDirty()}>
|
<Transition transition="slide-up" mounted={form.isDirty()}>
|
||||||
{(transitionStyles) => (
|
{(transitionStyles) => (
|
||||||
<Card style={transitionStyles} withBorder>
|
<Card style={transitionStyles}>
|
||||||
<Group justify="space-between">
|
<Group justify="space-between">
|
||||||
<Text fw={500}>{tForm("unsavedChanges")}</Text>
|
<Text fw={500}>{tForm("unsavedChanges")}</Text>
|
||||||
<Group>
|
<Group>
|
||||||
|
|||||||
@@ -68,7 +68,6 @@ const PermissionCard = async ({ group, isDanger }: PermissionCardProps) => {
|
|||||||
return (
|
return (
|
||||||
<Card
|
<Card
|
||||||
p="md"
|
p="md"
|
||||||
withBorder
|
|
||||||
style={{
|
style={{
|
||||||
borderColor: isDanger ? "var(--mantine-color-red-8)" : undefined,
|
borderColor: isDanger ? "var(--mantine-color-red-8)" : undefined,
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useCallback, useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { Group, TextInput } from "@mantine/core";
|
import { TextInput } from "@mantine/core";
|
||||||
import { IconSearch } from "@tabler/icons-react";
|
import { IconSearch } from "@tabler/icons-react";
|
||||||
|
|
||||||
import type { RouterOutputs } from "@homarr/api";
|
import type { RouterOutputs } from "@homarr/api";
|
||||||
@@ -33,29 +33,20 @@ export const GroupsList = ({ groups }: GroupsListProps) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Group justify="space-between">
|
<TextInput
|
||||||
<TextInput
|
leftSection={<IconSearch size={20} stroke={1.5} />}
|
||||||
leftSection={<IconSearch size={20} stroke={1.5} />}
|
value={search}
|
||||||
value={search}
|
onChange={(event) => setSearch(event.currentTarget.value)}
|
||||||
onChange={(event) => setSearch(event.currentTarget.value)}
|
placeholder={`${t("group.search")}...`}
|
||||||
placeholder={`${t("group.search")}...`}
|
/>
|
||||||
style={{ flex: 1 }}
|
|
||||||
/>
|
|
||||||
<AddGroup />
|
|
||||||
</Group>
|
|
||||||
|
|
||||||
<GroupsTable groups={filteredGroups} initialGroupIds={initialGroupIds} hasFilter={search.length !== 0} />
|
<GroupsTable groups={filteredGroups} initialGroupIds={initialGroupIds} hasFilter={search.length !== 0} />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const AddGroup = () => {
|
export const AddGroupButton = () => {
|
||||||
const t = useI18n();
|
const t = useI18n();
|
||||||
const { openModal } = useModalAction(AddGroupModal);
|
const { openModal } = useModalAction(AddGroupModal);
|
||||||
|
|
||||||
const handleAddGroup = useCallback(() => {
|
return <MobileAffixButton onClick={() => openModal()}>{t("group.action.create.label")}</MobileAffixButton>;
|
||||||
openModal();
|
|
||||||
}, [openModal]);
|
|
||||||
|
|
||||||
return <MobileAffixButton onClick={handleAddGroup}>{t("group.action.create.label")}</MobileAffixButton>;
|
|
||||||
};
|
};
|
||||||
@@ -259,7 +259,7 @@ const SaveAffix = ({ visible, isPending, onDiscard, onSave }: SaveAffixProps) =>
|
|||||||
<div style={{ position: "sticky", bottom: 20 }}>
|
<div style={{ position: "sticky", bottom: 20 }}>
|
||||||
<Transition transition="slide-up" mounted={visible}>
|
<Transition transition="slide-up" mounted={visible}>
|
||||||
{(transitionStyles) => (
|
{(transitionStyles) => (
|
||||||
<Card style={transitionStyles} withBorder>
|
<Card style={transitionStyles}>
|
||||||
<Group justify="space-between">
|
<Group justify="space-between">
|
||||||
<Text fw={500}>{t("common.unsavedChanges")}</Text>
|
<Text fw={500}>{t("common.unsavedChanges")}</Text>
|
||||||
<Group>
|
<Group>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { notFound } from "next/navigation";
|
import { notFound } from "next/navigation";
|
||||||
import { Card, Group, Stack, Text, ThemeIcon, Title, UnstyledButton } from "@mantine/core";
|
import { Card, Group, Stack, Text, ThemeIcon, UnstyledButton } from "@mantine/core";
|
||||||
import { IconChevronRight, IconUsersGroup } from "@tabler/icons-react";
|
import { IconChevronRight, IconUsersGroup } from "@tabler/icons-react";
|
||||||
|
|
||||||
import { api } from "@homarr/api/server";
|
import { api } from "@homarr/api/server";
|
||||||
@@ -8,9 +8,8 @@ import { everyoneGroup } from "@homarr/definitions";
|
|||||||
import { getI18n } from "@homarr/translation/server";
|
import { getI18n } from "@homarr/translation/server";
|
||||||
import { Link } from "@homarr/ui";
|
import { Link } from "@homarr/ui";
|
||||||
|
|
||||||
import { ManageContainer } from "~/components/manage/manage-container";
|
import { ManagePageLayout } from "~/components/manage/manage-page-layout";
|
||||||
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
|
import { AddGroupButton, GroupsList } from "./_client";
|
||||||
import { GroupsList } from "./_client";
|
|
||||||
import classes from "./groups.module.css";
|
import classes from "./groups.module.css";
|
||||||
|
|
||||||
export default async function GroupsListPage() {
|
export default async function GroupsListPage() {
|
||||||
@@ -26,11 +25,8 @@ export default async function GroupsListPage() {
|
|||||||
const groupsWithoutEveryone = groups.filter((group) => group.name !== everyoneGroup);
|
const groupsWithoutEveryone = groups.filter((group) => group.name !== everyoneGroup);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ManageContainer size="xl">
|
<ManagePageLayout title={t("group.title")} primaryAction={<AddGroupButton />} floatingPrimaryAction>
|
||||||
<DynamicBreadcrumb />
|
|
||||||
<Stack>
|
<Stack>
|
||||||
<Title>{t("group.title")}</Title>
|
|
||||||
|
|
||||||
{dbEveryoneGroup && (
|
{dbEveryoneGroup && (
|
||||||
<UnstyledButton component={Link} href={`/manage/users/groups/${dbEveryoneGroup.id}`}>
|
<UnstyledButton component={Link} href={`/manage/users/groups/${dbEveryoneGroup.id}`}>
|
||||||
<Card className={classes.everyoneGroup}>
|
<Card className={classes.everyoneGroup}>
|
||||||
@@ -53,6 +49,6 @@ export default async function GroupsListPage() {
|
|||||||
|
|
||||||
<GroupsList groups={groupsWithoutEveryone} />
|
<GroupsList groups={groupsWithoutEveryone} />
|
||||||
</Stack>
|
</Stack>
|
||||||
</ManageContainer>
|
</ManagePageLayout>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
+20
@@ -0,0 +1,20 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Button } from "@mantine/core";
|
||||||
|
|
||||||
|
import { useModalAction } from "@homarr/modals";
|
||||||
|
import { InviteCreateModal } from "@homarr/modals-collection";
|
||||||
|
import { useScopedI18n } from "@homarr/translation/client";
|
||||||
|
|
||||||
|
import { MANAGE_ACTION_BUTTON_MIN_WIDTH } from "~/components/manage/manage-page.constants";
|
||||||
|
|
||||||
|
export const InviteCreateButton = () => {
|
||||||
|
const t = useScopedI18n("management.page.user.invite");
|
||||||
|
const { openModal } = useModalAction(InviteCreateModal);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button miw={MANAGE_ACTION_BUTTON_MIN_WIDTH} onClick={() => openModal()}>
|
||||||
|
{t("action.new.title")}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useCallback, useMemo } from "react";
|
import { useCallback, useMemo } from "react";
|
||||||
import { ActionIcon, Button, Title } from "@mantine/core";
|
import { ActionIcon } from "@mantine/core";
|
||||||
import { IconTrash } from "@tabler/icons-react";
|
import { IconTrash } from "@tabler/icons-react";
|
||||||
import dayjs from "dayjs";
|
import dayjs from "dayjs";
|
||||||
import relativeTime from "dayjs/plugin/relativeTime";
|
import relativeTime from "dayjs/plugin/relativeTime";
|
||||||
@@ -10,8 +10,7 @@ import { MantineReactTable } from "mantine-react-table";
|
|||||||
|
|
||||||
import type { RouterOutputs } from "@homarr/api";
|
import type { RouterOutputs } from "@homarr/api";
|
||||||
import { clientApi } from "@homarr/api/client";
|
import { clientApi } from "@homarr/api/client";
|
||||||
import { useConfirmModal, useModalAction } from "@homarr/modals";
|
import { useConfirmModal } from "@homarr/modals";
|
||||||
import { InviteCreateModal } from "@homarr/modals-collection";
|
|
||||||
import { useScopedI18n } from "@homarr/translation/client";
|
import { useScopedI18n } from "@homarr/translation/client";
|
||||||
import { useTranslatedMantineReactTable } from "@homarr/ui/hooks";
|
import { useTranslatedMantineReactTable } from "@homarr/ui/hooks";
|
||||||
|
|
||||||
@@ -65,7 +64,6 @@ export const InviteListComponent = ({ initialInvites }: InviteListComponentProps
|
|||||||
enableFullScreenToggle: false,
|
enableFullScreenToggle: false,
|
||||||
layoutMode: "grid-no-grow",
|
layoutMode: "grid-no-grow",
|
||||||
getRowId: (row) => row.id,
|
getRowId: (row) => row.id,
|
||||||
renderTopToolbarCustomActions: RenderTopToolbarCustomActions,
|
|
||||||
state: {
|
state: {
|
||||||
isLoading,
|
isLoading,
|
||||||
},
|
},
|
||||||
@@ -74,22 +72,7 @@ export const InviteListComponent = ({ initialInvites }: InviteListComponentProps
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return <MantineReactTable table={table} />;
|
||||||
<>
|
|
||||||
<Title mb="md">{t("title")}</Title>
|
|
||||||
<MantineReactTable table={table} />
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const RenderTopToolbarCustomActions = () => {
|
|
||||||
const t = useScopedI18n("management.page.user.invite");
|
|
||||||
const { openModal } = useModalAction(InviteCreateModal);
|
|
||||||
const handleNewInvite = useCallback(() => {
|
|
||||||
openModal();
|
|
||||||
}, [openModal]);
|
|
||||||
|
|
||||||
return <Button onClick={handleNewInvite}>{t("action.new.title")}</Button>;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const RenderRowActions = ({ row }: { row: MRT_Row<RouterOutputs["invite"]["getAll"][number]> }) => {
|
const RenderRowActions = ({ row }: { row: MRT_Row<RouterOutputs["invite"]["getAll"][number]> }) => {
|
||||||
|
|||||||
@@ -3,8 +3,11 @@ import { notFound } from "next/navigation";
|
|||||||
import { api } from "@homarr/api/server";
|
import { api } from "@homarr/api/server";
|
||||||
import { auth } from "@homarr/auth/next";
|
import { auth } from "@homarr/auth/next";
|
||||||
import { isProviderEnabled } from "@homarr/auth/server";
|
import { isProviderEnabled } from "@homarr/auth/server";
|
||||||
|
import { getScopedI18n } from "@homarr/translation/server";
|
||||||
|
|
||||||
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
|
import { ManageMobilePrimaryAction } from "~/components/manage/manage-mobile-primary-action";
|
||||||
|
import { ManagePageLayout } from "~/components/manage/manage-page-layout";
|
||||||
|
import { InviteCreateButton } from "./_components/invite-create-button";
|
||||||
import { InviteListComponent } from "./_components/invite-list";
|
import { InviteListComponent } from "./_components/invite-list";
|
||||||
|
|
||||||
export default async function InvitesOverviewPage() {
|
export default async function InvitesOverviewPage() {
|
||||||
@@ -17,11 +20,20 @@ export default async function InvitesOverviewPage() {
|
|||||||
return notFound();
|
return notFound();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const t = await getScopedI18n("management.page.user.invite");
|
||||||
const initialInvites = await api.invite.getAll();
|
const initialInvites = await api.invite.getAll();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<ManagePageLayout
|
||||||
<DynamicBreadcrumb />
|
title={t("title")}
|
||||||
|
primaryAction={
|
||||||
|
<ManageMobilePrimaryAction>
|
||||||
|
<InviteCreateButton />
|
||||||
|
</ManageMobilePrimaryAction>
|
||||||
|
}
|
||||||
|
floatingPrimaryAction
|
||||||
|
>
|
||||||
<InviteListComponent initialInvites={initialInvites} />
|
<InviteListComponent initialInvites={initialInvites} />
|
||||||
</>
|
</ManagePageLayout>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -4,8 +4,10 @@ import { api } from "@homarr/api/server";
|
|||||||
import { auth } from "@homarr/auth/next";
|
import { auth } from "@homarr/auth/next";
|
||||||
import { isProviderEnabled } from "@homarr/auth/server";
|
import { isProviderEnabled } from "@homarr/auth/server";
|
||||||
import { getScopedI18n } from "@homarr/translation/server";
|
import { getScopedI18n } from "@homarr/translation/server";
|
||||||
|
import { Link } from "@homarr/ui";
|
||||||
|
|
||||||
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
|
import { ManagePageLayout } from "~/components/manage/manage-page-layout";
|
||||||
|
import { MobileAffixButton } from "~/components/manage/mobile-affix-button";
|
||||||
import { createMetaTitle } from "~/metadata";
|
import { createMetaTitle } from "~/metadata";
|
||||||
import { UserListComponent } from "./_components/user-list";
|
import { UserListComponent } from "./_components/user-list";
|
||||||
|
|
||||||
@@ -27,13 +29,23 @@ export default async function UsersPage() {
|
|||||||
return notFound();
|
return notFound();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const t = await getScopedI18n("management.page.user");
|
||||||
const userList = await api.user.getAll();
|
const userList = await api.user.getAll();
|
||||||
const credentialsProviderEnabled = isProviderEnabled("credentials");
|
const credentialsProviderEnabled = isProviderEnabled("credentials");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<ManagePageLayout
|
||||||
<DynamicBreadcrumb />
|
title={t("list.title")}
|
||||||
<UserListComponent initialUserList={userList} credentialsProviderEnabled={credentialsProviderEnabled} />
|
primaryAction={
|
||||||
</>
|
credentialsProviderEnabled ? (
|
||||||
|
<MobileAffixButton component={Link} href="/manage/users/create">
|
||||||
|
{t("create.title")}
|
||||||
|
</MobileAffixButton>
|
||||||
|
) : undefined
|
||||||
|
}
|
||||||
|
floatingPrimaryAction={credentialsProviderEnabled}
|
||||||
|
>
|
||||||
|
<UserListComponent initialUserList={userList} />
|
||||||
|
</ManagePageLayout>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -99,7 +99,7 @@ export const WidgetPreviewPageContent = ({ kind, integrationData }: WidgetPrevie
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Card withBorder w={dimensions.width} h={dimensions.height} p={dimensions.height >= 96 ? undefined : 4}>
|
<Card w={dimensions.width} h={dimensions.height} p={dimensions.height >= 96 ? undefined : 4}>
|
||||||
<QueryErrorResetBoundary>
|
<QueryErrorResetBoundary>
|
||||||
{({ reset }) => (
|
{({ reset }) => (
|
||||||
<ErrorBoundary
|
<ErrorBoundary
|
||||||
@@ -123,17 +123,35 @@ export const WidgetPreviewPageContent = ({ kind, integrationData }: WidgetPrevie
|
|||||||
</QueryErrorResetBoundary>
|
</QueryErrorResetBoundary>
|
||||||
</Card>
|
</Card>
|
||||||
<Affix bottom={12} right={72}>
|
<Affix bottom={12} right={72}>
|
||||||
<ActionIcon size={48} variant="default" radius="xl" onClick={handleOpenEditWidgetModal}>
|
<ActionIcon
|
||||||
|
size={48}
|
||||||
|
variant="default"
|
||||||
|
radius="xl"
|
||||||
|
onClick={handleOpenEditWidgetModal}
|
||||||
|
aria-label={t("common.action.edit")}
|
||||||
|
>
|
||||||
<IconPencil size={24} />
|
<IconPencil size={24} />
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
</Affix>
|
</Affix>
|
||||||
<Affix bottom={12} right={72 + 60}>
|
<Affix bottom={12} right={72 + 60}>
|
||||||
<ActionIcon size={48} variant="default" radius="xl" onClick={toggleEditMode}>
|
<ActionIcon
|
||||||
|
size={48}
|
||||||
|
variant="default"
|
||||||
|
radius="xl"
|
||||||
|
onClick={toggleEditMode}
|
||||||
|
aria-label={editMode ? t("widgetPreview.toggle.disabled") : t("widgetPreview.toggle.enabled")}
|
||||||
|
>
|
||||||
{editMode ? <IconToggleLeft size={24} /> : <IconToggleRight size={24} />}
|
{editMode ? <IconToggleLeft size={24} /> : <IconToggleRight size={24} />}
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
</Affix>
|
</Affix>
|
||||||
<Affix bottom={12} right={72 + 120}>
|
<Affix bottom={12} right={72 + 120}>
|
||||||
<ActionIcon size={48} variant="default" radius="xl" onClick={openDimensionsModal}>
|
<ActionIcon
|
||||||
|
size={48}
|
||||||
|
variant="default"
|
||||||
|
radius="xl"
|
||||||
|
onClick={openDimensionsModal}
|
||||||
|
aria-label={t("widgetPreview.dimensions.title")}
|
||||||
|
>
|
||||||
<IconDimensions size={24} />
|
<IconDimensions size={24} />
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
</Affix>
|
</Affix>
|
||||||
|
|||||||
@@ -8,12 +8,14 @@ import { getFirstEmptyPosition } from "./empty-position";
|
|||||||
import { getSectionElements } from "./section-elements";
|
import { getSectionElements } from "./section-elements";
|
||||||
|
|
||||||
export interface CreateItemInput {
|
export interface CreateItemInput {
|
||||||
|
id?: string;
|
||||||
kind: WidgetKind;
|
kind: WidgetKind;
|
||||||
options?: Record<string, unknown>;
|
options?: Record<string, unknown>;
|
||||||
|
integrationIds?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export const createItemCallback =
|
export const createItemCallback =
|
||||||
({ kind, options = {} }: CreateItemInput) =>
|
({ id, kind, options = {}, integrationIds = [] }: CreateItemInput) =>
|
||||||
(previous: Board): Board => {
|
(previous: Board): Board => {
|
||||||
const firstSection = previous.sections
|
const firstSection = previous.sections
|
||||||
.filter((section): section is EmptySection => section.kind === "empty")
|
.filter((section): section is EmptySection => section.kind === "empty")
|
||||||
@@ -23,11 +25,11 @@ export const createItemCallback =
|
|||||||
if (!firstSection) return previous;
|
if (!firstSection) return previous;
|
||||||
|
|
||||||
const widget = {
|
const widget = {
|
||||||
id: createId(),
|
id: id ?? createId(),
|
||||||
kind,
|
kind,
|
||||||
options,
|
options,
|
||||||
layouts: createItemLayouts(previous, firstSection),
|
layouts: createItemLayouts(previous, firstSection),
|
||||||
integrationIds: [],
|
integrationIds,
|
||||||
advancedOptions: {
|
advancedOptions: {
|
||||||
title: null,
|
title: null,
|
||||||
customCssClasses: [],
|
customCssClasses: [],
|
||||||
|
|||||||
@@ -42,7 +42,6 @@ export const BoardItemContent = ({ item }: BoardItemContentProps) => {
|
|||||||
item.advancedOptions.customCssClasses.join(" "),
|
item.advancedOptions.customCssClasses.join(" "),
|
||||||
)}
|
)}
|
||||||
radius={board.itemRadius}
|
radius={board.itemRadius}
|
||||||
withBorder
|
|
||||||
styles={{
|
styles={{
|
||||||
root: {
|
root: {
|
||||||
"--opacity": board.opacity / 100,
|
"--opacity": board.opacity / 100,
|
||||||
|
|||||||
@@ -105,9 +105,17 @@ const BoardItemMenuInner = ({ offset, item, resetErrorBoundary }: BoardItemMenuP
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Menu withinPortal withArrow position="right-start" arrowPosition="center">
|
<Menu withinPortal position="right-start" arrowPosition="center">
|
||||||
<Menu.Target>
|
<Menu.Target>
|
||||||
<ActionIcon variant="default" radius={"xl"} pos="absolute" top={offset} right={offset} style={{ zIndex: 10 }}>
|
<ActionIcon
|
||||||
|
variant="default"
|
||||||
|
radius={"xl"}
|
||||||
|
pos="absolute"
|
||||||
|
top={offset}
|
||||||
|
right={offset}
|
||||||
|
style={{ zIndex: 10 }}
|
||||||
|
aria-label={tItem("menu.label.settings")}
|
||||||
|
>
|
||||||
<IconDotsVertical size={"1rem"} />
|
<IconDotsVertical size={"1rem"} />
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
</Menu.Target>
|
</Menu.Target>
|
||||||
|
|||||||
@@ -2,20 +2,26 @@ import { useMemo, useState } from "react";
|
|||||||
import { Avatar, Button, Card, Center, Grid, Group, Input, Stack, Text, Tooltip } from "@mantine/core";
|
import { Avatar, Button, Card, Center, Grid, Group, Input, Stack, Text, Tooltip } from "@mantine/core";
|
||||||
import { IconSearch } from "@tabler/icons-react";
|
import { IconSearch } from "@tabler/icons-react";
|
||||||
|
|
||||||
import { objectEntries } from "@homarr/common";
|
import { clientApi } from "@homarr/api/client";
|
||||||
|
import { createId, objectEntries } from "@homarr/common";
|
||||||
import { getIconUrl, getIntegrationName } from "@homarr/definitions";
|
import { getIconUrl, getIntegrationName } from "@homarr/definitions";
|
||||||
import type { IntegrationKind, WidgetKind } from "@homarr/definitions";
|
import type { IntegrationKind, WidgetKind } from "@homarr/definitions";
|
||||||
import { createModal } from "@homarr/modals";
|
import { createModal, useModalAction } from "@homarr/modals";
|
||||||
|
import { useSettings } from "@homarr/settings";
|
||||||
import { useI18n } from "@homarr/translation/client";
|
import { useI18n } from "@homarr/translation/client";
|
||||||
import type { TablerIcon } from "@homarr/ui";
|
import type { TablerIcon } from "@homarr/ui";
|
||||||
import { widgetImports } from "@homarr/widgets";
|
import { reduceWidgetOptionsWithDefaultValues, widgetImports } from "@homarr/widgets";
|
||||||
|
import { WidgetEditModal } from "@homarr/widgets/modals";
|
||||||
|
|
||||||
import { useItemActions } from "./item-actions";
|
import { useItemActions } from "./item-actions";
|
||||||
|
|
||||||
export const ItemSelectModal = createModal<void>(({ actions }) => {
|
export const ItemSelectModal = createModal<void>(({ actions }) => {
|
||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState("");
|
||||||
const t = useI18n();
|
const t = useI18n();
|
||||||
const { createItem } = useItemActions();
|
const { createItem, updateItemOptions, updateItemAdvancedOptions, updateItemIntegrations } = useItemActions();
|
||||||
|
const { openModal: openEditModal } = useModalAction(WidgetEditModal);
|
||||||
|
const { data: integrationData } = clientApi.integration.all.useQuery();
|
||||||
|
const settings = useSettings();
|
||||||
|
|
||||||
const items = useMemo(
|
const items = useMemo(
|
||||||
() =>
|
() =>
|
||||||
@@ -40,8 +46,43 @@ export const ItemSelectModal = createModal<void>(({ actions }) => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const handleAdd = (kind: WidgetKind) => {
|
const handleAdd = (kind: WidgetKind) => {
|
||||||
createItem({ kind });
|
const definition = widgetImports[kind].definition;
|
||||||
|
const hasIntegrationSupport = "supportedIntegrations" in definition;
|
||||||
|
|
||||||
|
const matchingIntegrations = hasIntegrationSupport
|
||||||
|
? (integrationData ?? []).filter((integration) =>
|
||||||
|
(definition.supportedIntegrations as string[]).includes(integration.kind),
|
||||||
|
)
|
||||||
|
: [];
|
||||||
|
|
||||||
|
const integrationIds = matchingIntegrations.map((i) => i.id);
|
||||||
|
const itemId = createId();
|
||||||
|
const defaultOptions = reduceWidgetOptionsWithDefaultValues(kind, settings);
|
||||||
|
|
||||||
|
createItem({ id: itemId, kind, integrationIds });
|
||||||
actions.closeModal();
|
actions.closeModal();
|
||||||
|
|
||||||
|
openEditModal(
|
||||||
|
{
|
||||||
|
kind,
|
||||||
|
value: {
|
||||||
|
advancedOptions: { title: null, customCssClasses: [], borderColor: "" },
|
||||||
|
options: defaultOptions,
|
||||||
|
integrationIds,
|
||||||
|
},
|
||||||
|
onSuccessfulEdit: ({ options, integrationIds: newIntegrationIds, advancedOptions }) => {
|
||||||
|
updateItemOptions({ itemId, newOptions: options });
|
||||||
|
updateItemAdvancedOptions({ itemId, newAdvancedOptions: advancedOptions });
|
||||||
|
updateItemIntegrations({ itemId, newIntegrations: newIntegrationIds });
|
||||||
|
},
|
||||||
|
integrationData: matchingIntegrations,
|
||||||
|
integrationSupport: hasIntegrationSupport,
|
||||||
|
settings,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: (titleT) => `${titleT("item.edit.title")} - ${titleT(`widget.${kind}.name`)}`,
|
||||||
|
},
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -104,7 +145,7 @@ const WidgetItem = ({
|
|||||||
</Text>
|
</Text>
|
||||||
</Stack>
|
</Stack>
|
||||||
<SupportedIntegrations mt="auto" integrations={item.supportedIntegrations} />
|
<SupportedIntegrations mt="auto" integrations={item.supportedIntegrations} />
|
||||||
<Button onClick={onSelect} variant="light" size="xs" radius="md" fullWidth>
|
<Button onClick={onSelect} variant="light" size="xs" fullWidth>
|
||||||
{t(`item.create.addToBoard`)}
|
{t(`item.create.addToBoard`)}
|
||||||
</Button>
|
</Button>
|
||||||
</Stack>
|
</Stack>
|
||||||
@@ -129,7 +170,7 @@ const SupportedIntegrations = ({ integrations, mt }: SupportedIntegrationsProps)
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Center mt={mt}>
|
<Center mt={mt}>
|
||||||
<Tooltip.Group openDelay={300} closeDelay={100}>
|
<Tooltip.Group closeDelay={100}>
|
||||||
<Group gap={2}>
|
<Group gap={2}>
|
||||||
{integrations.slice(0, countToShow).map((integration) => (
|
{integrations.slice(0, countToShow).map((integration) => (
|
||||||
<Tooltip key={integration} label={getIntegrationName(integration)} withArrow>
|
<Tooltip key={integration} label={getIntegrationName(integration)} withArrow>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { useDisclosure } from "@mantine/hooks";
|
|||||||
import { IconChevronDown, IconChevronUp } from "@tabler/icons-react";
|
import { IconChevronDown, IconChevronUp } from "@tabler/icons-react";
|
||||||
|
|
||||||
import { clientApi } from "@homarr/api/client";
|
import { clientApi } from "@homarr/api/client";
|
||||||
|
import { useSession } from "@homarr/auth/client";
|
||||||
import { useRequiredBoard } from "@homarr/boards/context";
|
import { useRequiredBoard } from "@homarr/boards/context";
|
||||||
|
|
||||||
import type { CategorySection } from "~/app/[locale]/boards/_types";
|
import type { CategorySection } from "~/app/[locale]/boards/_types";
|
||||||
@@ -10,30 +11,41 @@ import { CategoryMenu } from "./category/category-menu";
|
|||||||
import { GridStack } from "./gridstack/gridstack";
|
import { GridStack } from "./gridstack/gridstack";
|
||||||
import classes from "./item.module.css";
|
import classes from "./item.module.css";
|
||||||
|
|
||||||
|
const localStorageKey = (sectionId: string) => `homarr-section-collapsed-${sectionId}`;
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
section: CategorySection;
|
section: CategorySection;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const BoardCategorySection = ({ section }: Props) => {
|
export const BoardCategorySection = ({ section }: Props) => {
|
||||||
|
const { status } = useSession();
|
||||||
const { mutate } = clientApi.section.changeCollapsed.useMutation();
|
const { mutate } = clientApi.section.changeCollapsed.useMutation();
|
||||||
const board = useRequiredBoard();
|
const board = useRequiredBoard();
|
||||||
const [opened, { toggle }] = useDisclosure(section.collapsed, {
|
|
||||||
|
const initialCollapsed =
|
||||||
|
typeof window !== "undefined"
|
||||||
|
? (localStorage.getItem(localStorageKey(section.id)) ?? String(section.collapsed)) === "true"
|
||||||
|
: section.collapsed;
|
||||||
|
|
||||||
|
const [opened, { toggle }] = useDisclosure(initialCollapsed, {
|
||||||
onOpen() {
|
onOpen() {
|
||||||
mutate({ sectionId: section.id, collapsed: true });
|
if (status === "authenticated") {
|
||||||
|
mutate({ sectionId: section.id, collapsed: true });
|
||||||
|
} else if (status === "unauthenticated") {
|
||||||
|
localStorage.setItem(localStorageKey(section.id), "true");
|
||||||
|
}
|
||||||
},
|
},
|
||||||
onClose() {
|
onClose() {
|
||||||
mutate({ sectionId: section.id, collapsed: false });
|
if (status === "authenticated") {
|
||||||
|
mutate({ sectionId: section.id, collapsed: false });
|
||||||
|
} else if (status === "unauthenticated") {
|
||||||
|
localStorage.setItem(localStorageKey(section.id), "false");
|
||||||
|
}
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card
|
<Card style={{ "--opacity": board.opacity / 100 }} radius={board.itemRadius} p={0} className={classes.itemCard}>
|
||||||
style={{ "--opacity": board.opacity / 100 }}
|
|
||||||
radius={board.itemRadius}
|
|
||||||
withBorder
|
|
||||||
p={0}
|
|
||||||
className={classes.itemCard}
|
|
||||||
>
|
|
||||||
<Stack>
|
<Stack>
|
||||||
<Group wrap="nowrap" gap="sm">
|
<Group wrap="nowrap" gap="sm">
|
||||||
<UnstyledButton w="100%" p="sm" onClick={toggle}>
|
<UnstyledButton w="100%" p="sm" onClick={toggle}>
|
||||||
@@ -44,7 +56,7 @@ export const BoardCategorySection = ({ section }: Props) => {
|
|||||||
</UnstyledButton>
|
</UnstyledButton>
|
||||||
<CategoryMenu category={section} />
|
<CategoryMenu category={section} />
|
||||||
</Group>
|
</Group>
|
||||||
<Collapse in={opened} p="sm" pt={0}>
|
<Collapse expanded={opened} p="sm" pt={0}>
|
||||||
<GridStack section={section} />
|
<GridStack section={section} />
|
||||||
</Collapse>
|
</Collapse>
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|||||||
@@ -30,9 +30,9 @@ export const CategoryMenu = ({ category }: Props) => {
|
|||||||
const t = useScopedI18n("section.category");
|
const t = useScopedI18n("section.category");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Menu withArrow>
|
<Menu>
|
||||||
<Menu.Target>
|
<Menu.Target>
|
||||||
<ActionIcon mr="sm" variant="transparent">
|
<ActionIcon mr="sm" variant="transparent" aria-label={t("menu.label.settings")}>
|
||||||
<IconDotsVertical size={20} />
|
<IconDotsVertical size={20} />
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
</Menu.Target>
|
</Menu.Target>
|
||||||
|
|||||||
@@ -28,7 +28,6 @@ export const BoardDynamicSection = ({ section }: Props) => {
|
|||||||
className={combineClasses(classes.itemCard, section.options.customCssClasses.join(" "))}
|
className={combineClasses(classes.itemCard, section.options.customCssClasses.join(" "))}
|
||||||
w="100%"
|
w="100%"
|
||||||
h="100%"
|
h="100%"
|
||||||
withBorder
|
|
||||||
styles={{
|
styles={{
|
||||||
root: {
|
root: {
|
||||||
overflow: "visible",
|
overflow: "visible",
|
||||||
|
|||||||
@@ -43,9 +43,17 @@ export const BoardDynamicSectionMenu = ({ section }: { section: DynamicSectionIt
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Menu withinPortal withArrow position="right-start" arrowPosition="center">
|
<Menu withinPortal position="right-start" arrowPosition="center">
|
||||||
<Menu.Target>
|
<Menu.Target>
|
||||||
<ActionIcon variant="default" radius={"xl"} pos="absolute" top={4} right={4} style={{ zIndex: 10 }}>
|
<ActionIcon
|
||||||
|
variant="default"
|
||||||
|
radius={"xl"}
|
||||||
|
pos="absolute"
|
||||||
|
top={4}
|
||||||
|
right={4}
|
||||||
|
style={{ zIndex: 10 }}
|
||||||
|
aria-label={tItem("menu.label.settings")}
|
||||||
|
>
|
||||||
<IconDotsVertical size={"1rem"} />
|
<IconDotsVertical size={"1rem"} />
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
</Menu.Target>
|
</Menu.Target>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { RefObject } from "react";
|
import type { Ref, RefObject } from "react";
|
||||||
import { createRef, useCallback, useEffect, useRef } from "react";
|
import { createRef, useCallback, useEffect, useRef } from "react";
|
||||||
import { useElementSize } from "@mantine/hooks";
|
import { useElementSize, useMergedRef } from "@mantine/hooks";
|
||||||
|
|
||||||
import { useCurrentLayout, useRequiredBoard } from "@homarr/boards/context";
|
import { useCurrentLayout, useRequiredBoard } from "@homarr/boards/context";
|
||||||
import { useEditMode } from "@homarr/boards/edit-mode";
|
import { useEditMode } from "@homarr/boards/edit-mode";
|
||||||
@@ -13,7 +13,7 @@ import { useSectionActions } from "../section-actions";
|
|||||||
import { initializeGridstack } from "./init-gridstack";
|
import { initializeGridstack } from "./init-gridstack";
|
||||||
|
|
||||||
export interface UseGridstackRefs {
|
export interface UseGridstackRefs {
|
||||||
wrapper: RefObject<HTMLDivElement | null>;
|
wrapper: Ref<HTMLDivElement | null>;
|
||||||
items: RefObject<Record<string, RefObject<GridItemHTMLElement | null>>>;
|
items: RefObject<Record<string, RefObject<GridItemHTMLElement | null>>>;
|
||||||
gridstack: RefObject<GridStack | null>;
|
gridstack: RefObject<GridStack | null>;
|
||||||
}
|
}
|
||||||
@@ -59,8 +59,9 @@ export const useGridstack = (section: Omit<Section, "items">, itemIds: string[])
|
|||||||
const { moveAndResizeItem, moveItemToSection } = useItemActions();
|
const { moveAndResizeItem, moveItemToSection } = useItemActions();
|
||||||
const { moveAndResizeInnerSection, moveInnerSectionToSection } = useSectionActions();
|
const { moveAndResizeInnerSection, moveInnerSectionToSection } = useSectionActions();
|
||||||
|
|
||||||
// define reference for wrapper - is used to calculate the width of the wrapper
|
const wrapperElementRef = useRef<HTMLDivElement | null>(null);
|
||||||
const { ref: wrapperRef, width, height } = useElementSize<HTMLDivElement>();
|
const { ref: measureWrapperRef, width, height } = useElementSize<HTMLDivElement>();
|
||||||
|
const wrapperRef = useMergedRef(measureWrapperRef, wrapperElementRef);
|
||||||
// references to the diffrent items contained in the gridstack
|
// references to the diffrent items contained in the gridstack
|
||||||
const itemRefs = useRef<Record<string, RefObject<GridItemHTMLElement | null>>>({});
|
const itemRefs = useRef<Record<string, RefObject<GridItemHTMLElement | null>>>({});
|
||||||
// reference of the gridstack object for modifications after initialization
|
// reference of the gridstack object for modifications after initialization
|
||||||
@@ -193,7 +194,7 @@ export const useGridstack = (section: Omit<Section, "items">, itemIds: string[])
|
|||||||
itemIds,
|
itemIds,
|
||||||
refs: {
|
refs: {
|
||||||
items: itemRefs,
|
items: itemRefs,
|
||||||
wrapper: wrapperRef,
|
wrapper: wrapperElementRef,
|
||||||
gridstack: gridRef,
|
gridstack: gridRef,
|
||||||
},
|
},
|
||||||
sectionColumnCount: columnCount,
|
sectionColumnCount: columnCount,
|
||||||
@@ -276,7 +277,7 @@ export const useGridstack = (section: Omit<Section, "items">, itemIds: string[])
|
|||||||
useCssVariableConfiguration({
|
useCssVariableConfiguration({
|
||||||
columnCount,
|
columnCount,
|
||||||
gridRef,
|
gridRef,
|
||||||
wrapperRef,
|
wrapperRef: wrapperElementRef,
|
||||||
width,
|
width,
|
||||||
height,
|
height,
|
||||||
isDynamic: section.kind === "dynamic",
|
isDynamic: section.kind === "dynamic",
|
||||||
@@ -293,7 +294,7 @@ export const useGridstack = (section: Omit<Section, "items">, itemIds: string[])
|
|||||||
|
|
||||||
interface UseCssVariableConfiguration {
|
interface UseCssVariableConfiguration {
|
||||||
gridRef: UseGridstackRefs["gridstack"];
|
gridRef: UseGridstackRefs["gridstack"];
|
||||||
wrapperRef: UseGridstackRefs["wrapper"];
|
wrapperRef: RefObject<HTMLDivElement | null>;
|
||||||
width: number;
|
width: number;
|
||||||
height: number;
|
height: number;
|
||||||
columnCount: number;
|
columnCount: number;
|
||||||
|
|||||||
@@ -6,11 +6,20 @@ import { LanguageCombobox } from "./language-combobox";
|
|||||||
|
|
||||||
interface CurrentLanguageComboboxProps {
|
interface CurrentLanguageComboboxProps {
|
||||||
width?: string;
|
width?: string;
|
||||||
|
withinPortal?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const CurrentLanguageCombobox = ({ width }: CurrentLanguageComboboxProps) => {
|
export const CurrentLanguageCombobox = ({ width, withinPortal }: CurrentLanguageComboboxProps) => {
|
||||||
const currentLocale = useCurrentLocale();
|
const currentLocale = useCurrentLocale();
|
||||||
const { changeLocale, isPending } = useChangeLocale();
|
const { changeLocale, isPending } = useChangeLocale();
|
||||||
|
|
||||||
return <LanguageCombobox value={currentLocale} onChange={changeLocale} isPending={isPending} width={width} />;
|
return (
|
||||||
|
<LanguageCombobox
|
||||||
|
value={currentLocale}
|
||||||
|
onChange={changeLocale}
|
||||||
|
isPending={isPending}
|
||||||
|
width={width}
|
||||||
|
withinPortal={withinPortal}
|
||||||
|
/>
|
||||||
|
);
|
||||||
};
|
};
|
||||||
@@ -16,9 +16,17 @@ interface LanguageComboboxProps {
|
|||||||
onChange: (value: SupportedLanguage) => void;
|
onChange: (value: SupportedLanguage) => void;
|
||||||
isPending?: boolean;
|
isPending?: boolean;
|
||||||
width?: string;
|
width?: string;
|
||||||
|
withinPortal?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const LanguageCombobox = ({ label, value, onChange, isPending, width }: LanguageComboboxProps) => {
|
export const LanguageCombobox = ({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
isPending,
|
||||||
|
width,
|
||||||
|
withinPortal = false,
|
||||||
|
}: LanguageComboboxProps) => {
|
||||||
const combobox = useCombobox({
|
const combobox = useCombobox({
|
||||||
onDropdownClose: () => combobox.resetSelectedOption(),
|
onDropdownClose: () => combobox.resetSelectedOption(),
|
||||||
});
|
});
|
||||||
@@ -39,7 +47,7 @@ export const LanguageCombobox = ({ label, value, onChange, isPending, width }: L
|
|||||||
}, [combobox]);
|
}, [combobox]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Combobox store={combobox} onOptionSubmit={handleOnOptionSubmit}>
|
<Combobox store={combobox} withinPortal={withinPortal} onOptionSubmit={handleOnOptionSubmit}>
|
||||||
<Combobox.Target>
|
<Combobox.Target>
|
||||||
<InputBase
|
<InputBase
|
||||||
component="button"
|
component="button"
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ export const BoardBackgroundVideo = () => {
|
|||||||
muted
|
muted
|
||||||
loop
|
loop
|
||||||
playsInline
|
playsInline
|
||||||
|
aria-hidden="true"
|
||||||
style={{
|
style={{
|
||||||
position: "fixed",
|
position: "fixed",
|
||||||
width: "100vw",
|
width: "100vw",
|
||||||
|
|||||||
Loaded 100 of 323 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user