Compare commits

..
Author SHA1 Message Date
Thomas Camlong 510c9b945d chore: validate CODEOWNERS in CI and act
Split workflow into validate (PR + act) and update (dev bot PR). Add
structure checks per GitHub docs, API error validation, and act config.
2026-06-04 15:37:31 +02:00
Thomas Camlong 2405e1b931 chore: add per-widget/integration CODEOWNERS generator
Derive owners from git history per feature folder and keep entries in sync
via a bot workflow on dev, following common monorepo automation patterns.
2026-06-04 15:24:07 +02:00
Thomas 41fe5928f1 fix: add bookmark widget option to toggle card borders (#5854) 2026-06-04 11:07:31 +02:00
homarr-renovate[bot] 6eb59d6f53 chore(deps): update tiptap monorepo to v3.24.0 (#5858)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-06-04 08:58:12 +00:00
Afonso Jorge Ramos 2c6b7a818e fix(immich): respect app color scheme in server-stats widget (#5859) 2026-06-04 09:50:02 +02:00
Thomas fd10788838 fix(auth): relax password complexity requirements to suggestions-only (#5764) 2026-06-04 07:56:34 +02:00
Thomas fe4598f722 fix(docker): restore container selection toolbar broken by Mantine v9 (#5855) 2026-06-03 10:03:37 +02:00
Thomas a755692d81 fix(ui): restore pre-v9 light variant colors (#5856) 2026-06-03 06:49:38 +00:00
homarr-renovate[bot] 3ff0d72ba1 chore(deps): update dependency next-intl to v4.13.0 (#5853)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-06-03 04:21:57 +00:00
homarr-renovate[bot] a63a191203 chore(deps): update turbo monorepo to ^2.9.16 (#5851)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-06-02 23:54:09 +00:00
homarr-renovate[bot] 1be906a0cd chore(deps): update dependency isomorphic-dompurify to ^3.15.0 (#5852)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-06-02 21:55:53 +00:00
homarr-renovate[bot] 63d8fa8c26 chore(deps): update dependency @ctrl/qbittorrent to ^9.12.2 (#5834)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-06-02 18:47:29 +00:00
homarr-renovate[bot] b5dd6ae8ef chore(deps): update babel monorepo to >=7.29.7 (#5830)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-06-02 14:11:26 +00:00
homarr-renovate[bot] d540ad374a chore(deps): update pnpm to v10.34.0 (#5846)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-06-02 09:17:24 +00:00
homarr-renovate[bot] 6a971c1986 chore(deps): update mantine monorepo to ^9.2.2 (#5844)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-06-02 04:13:28 +00:00
homarr-crowdin[bot] d86fe3ddf0 chore(lang): updated translations from crowdin
Co-authored-by: Crowdin Homarr <190541745+homarr-crowdin[bot]@users.noreply.github.com>
2026-06-02 02:23:34 +00:00
homarr-renovate[bot] 06db8960a7 chore(deps): update turbo monorepo to ^2.9.15 (#5841)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-06-01 23:12:09 +00:00
homarr-renovate[bot] a5719acf34 chore(deps): update dependency tmp@<=0.2.3 to >=0.2.7 (#5848)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-06-01 21:06:21 +00:00
homarr-renovate[bot] d1d597c8e1 chore(deps): update testcontainers-node monorepo to ^12.0.1 (#5845)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-06-01 16:48:41 +00:00
homarr-renovate[bot] 657e42e4bb chore(deps): update dependency mysql2 to v3.22.4 (#5839)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-05-31 18:12:08 +00:00
homarr-renovate[bot] 256ebcb864 chore(deps): update dependency oxlint to ^1.67.0 (#5837)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-05-31 16:18:04 +00:00
homarr-renovate[bot] 72e86dee01 chore(deps): update dependency ioredis to v5.11.0 (#5838)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-05-31 14:53:18 +00:00
homarr-renovate[bot] b7910b16e0 chore(deps): update dependency oxfmt to ^0.52.0 (#5836)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-05-31 11:08:29 +00:00
homarr-renovate[bot] 5a83bb6e39 chore(deps): update dependency dayjs to ^1.11.21 (#5835)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-05-31 08:54:20 +00:00
homarr-renovate[bot] 433b66582d chore(deps): update dependency hono@<4.6.5 to >=4.12.23 (#5828)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-05-30 09:25:22 +00:00
homarr-renovate[bot] acbcb6818b chore(deps): update dependency react-error-boundary to ^6.1.2 (#5829)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-05-30 06:54:05 +00:00
homarr-crowdin[bot] 07d991a0a1 chore(lang): updated translations from crowdin
Co-authored-by: Crowdin Homarr <190541745+homarr-crowdin[bot]@users.noreply.github.com>
2026-05-30 05:27:45 +00:00
homarr-renovate[bot] 92364e9574 chore(deps): update tanstack-query monorepo to ^5.100.14 (#5813)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-05-29 21:15:51 +00:00
ThomasandMeier Lukas 2991777b0a feat(users): rework general settings (#4915)
Co-authored-by: Meier Lukas <meierschlumpf@gmail.com>
2026-05-29 21:22:55 +02:00
ThomasandMeier Lukas 6cc2ac56a9 fix: prevent GitHub releases check from blocking app on API failure (#5807)
Co-authored-by: Meier Lukas <meierschlumpf@gmail.com>
2026-05-29 21:14:51 +02:00
homarr-update-contributors[bot] 92086e060f chore: update contributors (#5818) 2026-05-29 20:52:54 +02:00
homarr-renovate[bot] 2aa5ab95e6 chore(deps): update dependency trpc-to-openapi to ^3.3.0 (#5802)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-05-29 11:44:26 +00:00
Meier Lukas 650f761325 ci(format): fails to apply format due to conflict between --write and --check (#5815) 2026-05-29 11:07:48 +02:00
David be61602576 feat(auth): support custom token endpoint auth methods (#5806) 2026-05-29 10:51:28 +02:00
homarr-crowdin[bot] d01834d975 chore(lang): updated translations from crowdin (#5814) 2026-05-29 10:07:48 +02:00
Thomas Camlong 4a67bf0ef3 fix(ui): use imported theme instead of undefined createTheme
createTheme was called without being imported, causing a runtime
ReferenceError on every page load. Use the already-imported theme
object from @homarr/ui which contains the full Mantine configuration.
2026-05-28 15:54:37 +02:00
Thomas 4454e3fb7a fix: matine ui fixes for concistency (#5733) 2026-05-28 10:59:03 +02:00
Thomas c252b4f9ac feat(manage): unified management page layout (#5758) 2026-05-28 10:56:46 +02:00
homarr-renovate[bot] 4f2b3cf3ee chore(deps): update node.js to v24.16.0 (#5790)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-05-28 08:09:15 +00:00
Vivaan Dhawan b19549b601 fix: parse Speedtest Tracker created_at as local time (#5751) 2026-05-28 07:48:00 +00:00
homarr-renovate[bot] a0cc68798d chore(deps): update mantine monorepo to ^9.2.1 (#5801)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-05-28 03:50:55 +00:00
homarr-renovate[bot] 9a538941ee chore(deps): update dependency ws to ^8.21.0 (#5804)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-05-27 23:20:59 +00:00
homarr-renovate[bot] fc12500b3e chore(deps): update tanstack-query monorepo to ^5.100.13 (#5803)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-05-27 21:45:28 +00:00
homarr-renovate[bot] 64dccec362 chore(deps): update dependency oxfmt to ^0.51.0 (#5799)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-05-27 16:46:40 +00:00
homarr-renovate[bot]andThomas Camlong f7ea1b33a0 chore(deps): update mantine monorepo to v9 (#5411)
Co-authored-by: Thomas Camlong <thomas@ajnart.fr>
2026-05-27 17:29:52 +02:00
homarr-renovate[bot] fe3cebb902 chore(deps): update dependency hono@<4.6.5 to >=4.12.22 (#5797)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-05-27 13:06:33 +00:00
homarr-renovate[bot] ba1d213870 chore(deps): update dependency oxlint to ^1.66.0 (#5798)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-05-27 10:00:11 +00:00
Thomas 8f03a38de5 perf(boards): parallelize board page data fetching (#5736) 2026-05-27 07:44:37 +00:00
605af19cd8 refactor: replace ESLint + Prettier with OXC (oxlint + oxfmt) (#5741)
Co-authored-by: Meier Lukas <meierschlumpf@gmail.com>
Co-authored-by: Meierschlumpf <63781622+Meierschlumpf@users.noreply.github.com>
2026-05-27 09:37:44 +02:00
homarr-renovate[bot] b4921af60d chore(deps): update dependency sass to ^1.100.0 (#5795)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-05-27 05:24:00 +00:00
homarr-renovate[bot] 32385206ad chore(deps): update tiptap monorepo to v3.23.6 (#5793)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-05-26 21:03:11 +00:00
homarr-renovate[bot] 7e59ba3d5e chore(deps): update dependency vite@>=5.0.0 <=5.4.18 to >=8.0.14 (#5791)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-05-26 07:57:31 +00:00
homarr-renovate[bot] 81caaa9d6c chore(deps): update vitest monorepo to ^4.1.7 (#5789)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-05-25 08:30:36 +00:00
homarr-renovate[bot] 09243148da chore(deps): update dependency ldapts to v8.1.8 (#5788)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-05-25 04:03:12 +00:00
homarr-update-contributors[bot] c7c57887cf chore: update contributors (#5771) 2026-05-24 22:44:36 +02:00
homarr-renovate[bot] 990a924631 chore(deps): update testcontainers-node monorepo to v12 (#5785) 2026-05-24 22:09:13 +02:00
homarr-renovate[bot] 83caf3ef4b chore(deps): update dependency @types/react to v19.2.15 (#5787)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-05-24 19:26:04 +00:00
homarr-renovate[bot] 991f07ae51 chore(deps): update tiptap monorepo to v3.23.5 (#5783)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-05-24 15:12:39 +00:00
homarr-renovate[bot] bc05128cd4 chore(deps): update dependency hono@<4.6.5 to >=4.12.21 (#5782)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-05-24 13:34:35 +00:00
homarr-renovate[bot] cf0fb8a879 chore(deps): update dependency hono@<4.6.5 to >=4.12.20 (#5780)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-05-24 11:25:35 +00:00
homarr-renovate[bot] 1f31735ee0 chore(deps): update dependency isomorphic-dompurify to ^3.14.0 (#5779)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-05-24 10:02:26 +00:00
homarr-crowdin[bot] ff974bf343 chore(lang): updated translations from crowdin
Co-authored-by: Crowdin Homarr <190541745+homarr-crowdin[bot]@users.noreply.github.com>
2026-05-24 02:04:49 +00:00
homarr-renovate[bot] d42bbfce17 chore(deps): update tanstack-query monorepo to ^5.100.11 (#5777)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-05-23 20:13:50 +00:00
homarr-renovate[bot] 0130303e74 chore(deps): update dependency semver-parser to ^4.2.0 (#5766)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-05-23 15:08:34 +00:00
AustinandClaude Sonnet 4.6 66212b4e47 feat(board): persist category collapse state in localStorage for guests (#5714)
* feat(board): persist category collapse state in localStorage for guests

Authenticated users already had their collapse state saved server-side.
This extends the same behaviour to unauthenticated (guest) users by
reading/writing localStorage keyed by section ID.

On mount, if the user is a guest, the stored value is applied to the
disclosure. On toggle, guests write to localStorage instead of calling
the protected API mutation.

Closes #3355

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* refactor: set localStorage value as useDisclosure default instead of useEffect

Replaces the useEffect that read localStorage after mount with a direct
initial value computation passed to useDisclosure. Removes the effect,
the open/close destructuring, and the useEffect import.

Note: initialCollapsed is computed during render rather than post-mount,
which may cause a hydration mismatch on SSR if localStorage differs from
the server-provided section.collapsed. The typeof window guard handles
the SSR case.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-23 16:25:10 +02:00
Thomas f10bb3ae23 feat: auto-open widget settings on add with all integrations pre-selected (#5767)
When adding a widget from the "Choose item to add" modal, the widget
edit modal now opens immediately after placement, allowing the user to
configure options and integrations before the widget renders.

All available integrations for the widget are selected by default.
2026-05-23 16:19:36 +02:00
homarr-renovate[bot] af1991a4a3 chore(deps): update dependency pg to ^8.21.0 (#5776)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-05-23 13:35:11 +00:00
homarr-releases[bot] 9a9551130c chore: update bug report template (#5775)
Co-authored-by: Releases Homarr <175486441+homarr-releases[bot]@users.noreply.github.com>
2026-05-22 21:01:54 +00:00
Meier Lukas a93f03eae4 Merge branch 'main' into dev 2026-05-22 22:46:15 +02:00
Thomas df4b57bd41 perf(boards): remove loading overlay and show board immediately (#5735) 2026-05-22 07:06:48 +00:00
Releases Homarr 48ef8eb6c0 chore(release): 1.61.1 [skip ci] 2026-05-18 19:45:44 +00:00
Austin e9142c0dbc fix(downloads-widget): increase name column default width ratio 2026-05-18 21:44:47 +02:00
315 changed files with 4971 additions and 6619 deletions

No files matched your search

+2
View File
@@ -0,0 +1,2 @@
-P ubuntu-latest=catthehacker/ubuntu:act-latest
--container-architecture linux/amd64
+3 -6
View File
@@ -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"
+1
View File
@@ -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
+4 -1
View File
@@ -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?
+4 -1
View File
@@ -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?
+33 -1
View File
@@ -24,6 +24,38 @@ env:
TURBO_SCM_HEAD: ${{ github.sha }} 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
@@ -54,7 +86,7 @@ jobs:
uses: ./tooling/github/setup uses: ./tooling/github/setup
- name: Format - name: Format
run: pnpm turbo format --affected --continue -- --cache --cache-location node_modules/.cache/.prettiercache run: pnpm turbo format --affected --continue
typecheck: typecheck:
runs-on: ubuntu-latest runs-on: ubuntu-latest
@@ -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'
+131
View File
@@ -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
+1 -1
View File
@@ -1 +1 @@
24.15.0 24.16.0
+7
View File
@@ -0,0 +1,7 @@
{
"$schema": "./node_modules/oxfmt/configuration_schema.json",
"printWidth": 120,
"endOfLine": "lf",
"sortPackageJson": true,
"ignorePatterns": ["**/*.cjs", "**/lang/**", "static-data/**", "CHANGELOG.md"]
}
+37
View File
@@ -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
View File
@@ -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
+5 -15
View File
@@ -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>/**"
]
} }
] ]
} }
+3 -14
View File
@@ -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",
} }
+6
View File
@@ -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
View File
@@ -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
View File
@@ -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
-30
View File
@@ -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",
},
},
];
+2 -7
View File
@@ -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:",
@@ -92,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:",
@@ -102,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>
); );
+4 -4
View File
@@ -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>
@@ -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>
); );
@@ -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>({
@@ -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">
@@ -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>
+5 -5
View File
@@ -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>
@@ -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>
@@ -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;
}
} }
} }
@@ -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,
@@ -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>
@@ -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"
/> />
); );
@@ -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>;
@@ -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>;
@@ -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>;
@@ -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>
);
};
@@ -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>;
@@ -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"
@@ -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} />
</>
);
}; };
@@ -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
@@ -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>
); );
} }
@@ -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",
@@ -12,16 +12,7 @@ export const CommonNavLink = (props: ClientNavigationLink) =>
const NavLinkHref = (props: NavigationLinkHref) => { const NavLinkHref = (props: NavigationLinkHref) => {
const pathname = usePathname(); const pathname = usePathname();
return props.external ? ( return props.external ? (
<NavLink <NavLink component="a" label={props.label} leftSection={props.icon} href={props.href} target="_blank" />
component="a"
label={props.label}
leftSection={props.icon}
href={props.href}
style={{
borderRadius: 5,
}}
target="_blank"
/>
) : ( ) : (
<NavLink <NavLink
component={Link} component={Link}
@@ -29,9 +20,6 @@ const NavLinkHref = (props: NavigationLinkHref) => {
leftSection={props.icon} leftSection={props.icon}
href={props.href} href={props.href}
active={pathname === props.href} active={pathname === props.href}
style={{
borderRadius: 5,
}}
/> />
); );
}; };
@@ -40,14 +28,7 @@ const NavLinkWithItems = (props: NavigationLinkWithItems) => {
const pathname = usePathname(); const pathname = usePathname();
const isActive = props.items.some((item) => item.href === pathname); const isActive = props.items.some((item) => item.href === pathname);
return ( return (
<NavLink <NavLink label={props.label} leftSection={props.icon} defaultOpened={isActive}>
style={{
borderRadius: 5,
}}
label={props.label}
leftSection={props.icon}
defaultOpened={isActive}
>
{props.items.map((item) => ( {props.items.map((item) => (
<NavLinkHref key={item.label} {...item} /> <NavLinkHref key={item.label} {...item} />
))} ))}
@@ -15,7 +15,7 @@ export const DangerZoneRoot = async ({ children }: DangerZoneRootProps) => {
<Title c="red.8" order={2}> <Title c="red.8" order={2}>
{t("common.dangerZone")} {t("common.dangerZone")}
</Title> </Title>
<Card withBorder style={{ borderColor: "var(--mantine-color-red-8)", borderWidth: 3 }}> <Card style={{ borderColor: "var(--mantine-color-red-8)", borderWidth: 3 }}>
<Stack gap="sm"> <Stack gap="sm">
{Array.isArray(children) {Array.isArray(children)
? children.map((child, index) => ( ? children.map((child, index) => (
@@ -0,0 +1,15 @@
import type { PropsWithChildren } from "react";
import { Affix, Box } from "@mantine/core";
import { MANAGE_ACTION_BUTTON_MIN_WIDTH, MANAGE_AFFIX_POSITION } from "./manage-page.constants";
export const ManageMobilePrimaryAction = ({ children }: PropsWithChildren) => (
<>
<Box visibleFrom="md" miw={MANAGE_ACTION_BUTTON_MIN_WIDTH}>
{children}
</Box>
<Affix hiddenFrom="md" position={MANAGE_AFFIX_POSITION}>
{children}
</Affix>
</>
);
Loaded 100 of 315 files, more files were not shown because too many files have changed in this diff. Show more