Compare commits

...
Author SHA1 Message Date
homarr-renovate[bot] ec29d77186 chore(deps): update pnpm to v9.1.4 (#582)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2024-05-30 12:16:50 +00:00
homarr-renovate[bot] b12d109f6d fix(deps): update mantine monorepo to ^7.10.1 (#581)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2024-05-30 10:29:27 +00:00
homarr-renovate[bot] 169fecaba1 fix(deps): update dependency sass to ^1.77.3 (#579)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2024-05-29 22:28:30 +00:00
Thomas CamlongandMeier Lukas dd2937a0d6 feat: add docker container table (#520)
* WIP On docker integration

* WIP on adding docker support

* WIP on adding docker support

* chore: Add cacheTime parameter to createCacheChannel function

* bugfix: Add node-loader npm dependency for webpack configuration

* revert changes

* chore: Add node-loader npm dependency for webpack configuration

* feat: Add Docker container list to DockerPage

* chore: apply pr suggestions

* fix: fix printing issue using a Date objext

* chore: Update npm dependencies

* feat: Create DockerTable component for displaying Docker container list

* feat: Refactor DockerPage to use DockerTable component

* feat: Refactor DockerPage to use DockerTable component

* feat: Add useTimeAgo hook for displaying relative timestamps

* feat: Add hooks module to common package

* refactor: Update DockerTable component

Include container actions and state badges

* feat: add information about instance for docker containers

* feat: Add OverflowBadge component for displaying overflowed data

* feat: Refactor DockerSingleton to use host and instance properties

This commit refactors the DockerSingleton class in the `docker.ts` file to use the `host` and `instance` properties instead of the previous `key` and `remoteApi` properties. This change improves clarity and consistency in the codebase.

* feat: Add OverflowBadge component for displaying overflowed data

* feat: Improve DockerTable component with Avatar and Name column

This commit enhances the DockerTable component in the `DockerTable.tsx` file by adding an Avatar and Name column. The Avatar column displays an icon based on the Docker container's image, while the Name column shows the container's name. This improvement provides better visual representation and identification of the containers in the table.

* feat: Enhance DockerTable component with Avatar and Name columns

* refactor: improve docker table and icon resolution

* chore: address pull request feedback

* fix: format issues

* chore: add missing translations

* refactor: remove black background

---------

Co-authored-by: Meier Lukas <meierschlumpf@gmail.com>
2024-05-29 21:57:02 +02:00
homarr-renovate[bot] e030e06a16 chore(deps): update actions/checkout action to v4 (#571)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2024-05-29 21:52:44 +02:00
homarr-renovate[bot] dc7f455fe7 chore(deps): update dependency @types/node to ^20.12.13 (#577)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2024-05-29 17:27:00 +00:00
homarr-renovate[bot] 612b8d8c6e fix(deps): update dependency mysql2 to v3.9.9 (#576)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2024-05-29 14:48:11 +00:00
homarr-renovate[bot] fe43e74c53 fix(deps): update dependency flag-icons to ^7.2.3 (#575)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2024-05-29 11:21:16 +00:00
homarr-renovate[bot] 2cb3e95593 chore(deps): update node.js to v20.14.0 (#573)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2024-05-29 03:48:21 +00:00
homarr-renovate[bot] 9e499bd30b fix(deps): update dependency eslint-plugin-react to ^7.34.2 (#567)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2024-05-29 03:35:19 +00:00
Meier Lukas c2185e3489 fix: renovate automatic approval typo no file extension (#572) 2024-05-29 05:17:52 +02:00
homarr-renovate[bot] e448eeb0a6 fix(deps): update dependency @trpc/client to v11.0.0-rc.382 (#568)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2024-05-28 20:24:49 +00:00
homarr-renovate[bot] deea531607 chore(deps): update dependency node to v20.14.0 (#569)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2024-05-28 21:40:23 +02:00
Manuel ba46b6f869 ci: add conventional commits workflow (#541) 2024-05-28 21:40:04 +02:00
Meier Lukas bcbc2d1c56 ci: add github action that approves pull requests from renovate automatically (#566) 2024-05-28 21:39:15 +02:00
homarr-renovate[bot] c20f7a64a0 fix(deps): update typescript-eslint monorepo to ^7.11.0 (#565)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2024-05-28 17:48:13 +00:00
homarr-renovate[bot] 6f47adff8a fix(deps): update dependency jotai to ^2.8.2 (#561)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2024-05-28 16:11:10 +00:00
homarr-renovate[bot] e325e1c02c chore(deps): update dependency jsdom to ^24.1.0 (#564)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2024-05-27 21:52:27 +02:00
homarr-renovate[bot] 12e30c392e chore(deps): update dependency @types/react to ^18.3.3 (#555)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2024-05-27 21:51:40 +02:00
homarr-renovate[bot] ad3ef4c313 fix(deps): update mantine monorepo to ^7.10.0 (#538)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2024-05-27 19:48:39 +00:00
homarr-renovate[bot] 116d9dda04 chore(deps): update pnpm to v9.1.3 (#559)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2024-05-27 21:26:32 +02:00
homarr-renovate[bot] 2bc0431e4e fix(deps): update typescript-eslint monorepo to ^7.10.0 (#562)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2024-05-27 21:26:01 +02:00
homarr-renovate[bot] 3f364caced fix(deps): update dependency mysql2 to v3.9.8 (#558)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2024-05-27 21:24:32 +02:00
homarr-renovate[bot] abdf9bc122 fix(deps): update dependency @trpc/client to v11.0.0-rc.377 (#553)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2024-05-27 21:23:48 +02:00
homarr-renovate[bot] a0322bfd99 fix(deps): update dependency glob to ^10.4.1 (#531)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2024-05-27 21:23:27 +02:00
homarr-renovate[bot] 8f50def4c9 fix(deps): update tanstack-query monorepo to ^5.40.0 (#552)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2024-05-27 19:09:15 +00:00
homarr-renovate[bot] 56b6d76f43 fix(deps): update dependency drizzle-kit to ^0.21.4 (#535)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2024-05-27 20:13:01 +02:00
homarr-renovate[bot] 6e655b7ad2 chore(deps): update dependency @vitejs/plugin-react to ^4.3.0 (#536)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2024-05-27 18:12:06 +00:00
Meier Lukas 4d1e4d55bc fix: typo in renovate config name (#557) 2024-05-27 18:05:31 +02:00
homarr-renovate[bot]andMeier Lukas 0ccf258a90 chore(deps): update dependency tsx to v4.11.0 (#537)
* chore(deps): update dependency tsx to v4.11.0

* fix: build not working

---------

Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
Co-authored-by: Meier Lukas <meierschlumpf@gmail.com>
2024-05-26 17:51:12 +02:00
homarr-renovate[bot] 98ebabc9ca chore(deps): update pnpm to v9.1.2 (#530)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2024-05-26 17:22:02 +02:00
homarr-renovate[bot] 30fe9fef84 fix(deps): update typescript-eslint monorepo to v7.10.0 (#529)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2024-05-26 17:20:48 +02:00
ManuelandMeier Lukas 7e64d39693 feat: add analytics (#528)
* feat: add analytics

* feat: send data to umami

* refactor: fix format

* fix: click behavior of switches

* refactor: format

* chore: rerun ci

---------

Co-authored-by: Meier Lukas <meierschlumpf@gmail.com>
2024-05-26 17:18:25 +02:00
Meier Lukas d57b771a17 feat: add pi hole summary integration (#521)
* feat: add pi hole summary integration

* feat: add pi hole summary widget

* fix: type issues with integrations and integrationIds

* feat: add middleware for integrations and improve cache redis channel

* feat: add error boundary for widgets

* fix: broken lock file

* fix: format format issues

* fix: typecheck issue

* fix: deepsource issues

* fix: widget sandbox without error boundary

* chore: address pull request feedback

* chore: remove todo comment and created issue

* fix: format issues

* fix: deepsource issue
2024-05-26 17:13:34 +02:00
homarr-renovate[bot] 96c71aed6e chore(deps): update dependency @vitejs/plugin-react to v4.3.0 (#545)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2024-05-25 19:19:26 +00:00
homarr-renovate[bot] 1569862baf fix(deps): update dependency @homarr/gridstack to v1.0.2 (#542)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2024-05-25 19:08:36 +00:00
homarr-renovate[bot] cd09d54163 chore(deps): update dependency @types/react to v18.3.3 (#546)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2024-05-25 19:57:09 +02:00
homarr-renovate[bot] a029abe6f3 fix(deps): update tanstack-query monorepo to v5.38.0 (#551)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2024-05-25 19:56:55 +02:00
Manuel b76fa9d722 ci: report vitest code coverage (#549) 2024-05-25 19:56:24 +02:00
Manuel b678d4f951 ci: validate pr title (#550) 2024-05-25 19:56:11 +02:00
Manuel 4ef668c5ea ci: use semver workflow action (#548) 2024-05-25 19:56:02 +02:00
homarr-renovate[bot] 7b46d92d4f fix(deps): update dependency glob to v10.4.1 (#544)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2024-05-24 23:44:38 +02:00
Manuel 9e199b7dff config: format json hbs as json (#540) 2024-05-24 21:49:25 +02:00
homarr-renovate[bot] 114176ee17 chore(deps): update anothrnick/github-tag-action action to v1.69.0 (#348)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2024-05-23 16:42:18 +00:00
Manuel 3940d296f2 config; activate automerge (#539) 2024-05-23 18:15:53 +02:00
homarr-renovate[bot] 63f743175e fix(deps): update dependency @tabler/icons-react to ^3.5.0 (#532)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2024-05-23 17:53:51 +02:00
homarr-renovate[bot] f45fa50024 fix(deps): update dependency flag-icons to ^7.2.2 (#533)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2024-05-23 17:53:36 +02:00
homarr-renovate[bot] c668d0a765 fix(deps): update dependency jotai to ^2.8.1 (#534)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2024-05-23 17:53:22 +02:00
Meier Lukas e097cffe59 refactor: improve behaviour of edit mode when navigating away and back (#525) 2024-05-22 21:28:45 +02:00
Thomas Camlong 5a9e8edf70 feat: Add VOLUME instruction to Dockerfile (#527) 2024-05-20 17:34:16 +02:00
Thomas Camlong 048711fe05 chore: Update trpc logger info (#526)
It now includes the username of the person doing the tRPC request (because the user id is not enough)
2024-05-20 17:00:50 +02:00
Thomas Camlong f617c60916 Merge pull request #464 from homarr-labs/ajnart/fix-duplicate-users 2024-05-19 23:44:46 +02:00
Meier Lukas c3b2215c7a Merge branch 'dev' into ajnart/fix-duplicate-users 2024-05-19 23:08:04 +02:00
homarr-renovate[bot] eb0db3ec4e fix(deps): update dependency @trpc/client to v11.0.0-rc.374 (#522)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2024-05-19 23:02:09 +02:00
Meier Lukas 26b1c4a319 feat: add custom css for board and custom classes in advanced options for items (#512)
* feat: add custom css for board and custom classes in advanced options for items

* chore: add mysql migration

* fix: test not working

* fix: format issues

* fix: typecheck issue

* fix: build issue

* chore: add missing translations

* fix: merge issues related to migrations

* fix: format issues

* fix: merge issue with migration

* fix: format issue
2024-05-19 23:01:26 +02:00
Thomas CamlongandMeier Lukas f1b1ec59ec chore: update prettier configuration for print width (#519)
* feat: update prettier configuration for print width

* chore: apply code formatting to entire repository

* fix: remove build files

* fix: format issue

---------

Co-authored-by: Meier Lukas <meierschlumpf@gmail.com>
2024-05-19 22:38:39 +02:00
Manuel 919161798e feat: add server settings (#487)
* feat: add server settings

* feat: remove old migration

* feat: add new migrations

* refactor: format

* fix: build error

* refactor: format

* fix: lint
2024-05-19 22:29:15 +02:00
Meier Lukas d9f5158662 perf: improve typescript performance (#523) 2024-05-19 19:55:44 +02:00
Meier Lukas 7e339c09c8 feat: add home board for users (#505)
* feat: add home board for users

* fix: format issues

* fix: deepsource issue

* chore: address pull request feedback

* fix: typecheck issue
2024-05-18 16:57:00 +02:00
Meier Lukas dfed804f65 feat: add i18n translated form errors (#509) 2024-05-18 16:55:08 +02:00
homarr-renovate[bot] b312032f02 fix(deps): update tanstack-query monorepo to ^5.37.1 (#513)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2024-05-18 16:44:35 +02:00
Thomas Camlong a29cc78acd Merge pull request #517 from homarr-labs/create-cache-channel 2024-05-18 15:48:56 +02:00
homarr-renovate[bot] abffc866c0 fix(deps): update mantine monorepo to ^7.9.2 (#510)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2024-05-18 15:32:25 +02:00
homarr-renovate[bot] dc9a8dd9c6 chore(deps): update dependency tsx to v4.10.5 (#507)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2024-05-18 15:31:34 +02:00
homarr-renovate[bot] 7664b00be0 fix(deps): update dependency sass to ^1.77.2 (#506)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2024-05-18 15:30:22 +02:00
Meier Lukas e05deef6e2 feat: add redis cache channel 2024-05-18 15:28:05 +02:00
Manuel 31c2694185 feat: language selector (#484)
* feat: language selector

* refactor: move user general page

* feat: language selector

* refactor: move user general page

* feat: add language combobox in user general
2024-05-18 13:54:43 +02:00
Meier Lukas 7fa120c474 fix: create user async is called twice 2024-05-18 13:50:25 +02:00
Meier Lukas f30f133bfa Merge branch 'dev' into ajnart/fix-duplicate-users 2024-05-18 13:49:25 +02:00
Meier Lukas f0891dda31 Merge branch 'dev' into ajnart/fix-duplicate-users 2024-05-18 13:47:25 +02:00
Meier Lukas 8e8f9081b0 fix: when no dead icons, all icons are removed (#516) 2024-05-18 13:12:16 +02:00
Manuel dcaff1d91c feat: add async suffix eslint rule (#485) 2024-05-18 12:25:33 +02:00
Meier Lukas 5723295856 fix: mantine react table style issue (#508) 2024-05-17 21:42:45 +02:00
homarr-renovate[bot] 1abf441cdb fix(deps): update dependency @trpc/client to v11.0.0-rc.373 (#493)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2024-05-16 21:55:05 +02:00
homarr-renovate[bot] db48615096 chore(deps): update dependency tsx to v4.10.3 (#490)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2024-05-16 21:46:40 +02:00
homarr-renovate[bot] 8166fdb8e2 fix(deps): update dependency drizzle-kit to ^0.21.2 (#495)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2024-05-16 21:43:29 +02:00
Thomas Camlong ff3244d9ac Update run.sh (#498) 2024-05-16 21:26:07 +02:00
Meier Lukas 9a73b13ae5 chore: fix db studio command not working (#503)
* chore: fix db studio command not working

* fix: format issue
2024-05-16 21:12:59 +02:00
homarr-renovate[bot] 023feafafe fix(deps): update tanstack-query monorepo to ^5.36.2 (#496)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2024-05-16 21:11:36 +02:00
homarr-renovate[bot] 1b6a9c5aee chore(deps): update dependency @types/node to ^20.12.12 (#492)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2024-05-16 21:10:36 +02:00
homarr-renovate[bot] 3fe1a19e37 fix(deps): update dependency @tabler/icons-react to ^3.4.0 (#501)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2024-05-16 21:10:04 +02:00
Meier Lukas 669a79ef08 style: improve styling of items on board (#504)
style: improve styling of items on board by defining margin depending on screen size, removing general padding on card of items and reducing size of no app icon
2024-05-16 18:48:20 +02:00
Meier Lukas 333dc571fa perf: improve performance of icon updater from 10s to 300ms (#502) 2024-05-16 17:06:03 +02:00
Manuel f329e2a393 config: fix db push command (#497) 2024-05-15 21:14:27 +02:00
homarr-renovate[bot] a702f7dc67 fix(deps): update typescript-eslint monorepo to ^7.9.0 (#491)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2024-05-14 21:59:12 +02:00
homarr-renovate[bot] 8b84695079 fix(deps): update dependency mantine-react-table to v2.0.0-beta.3 (#488)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2024-05-14 21:58:45 +02:00
homarr-renovate[bot] 18d1aa6fcd chore(deps): update pnpm to v9.1.1 (#489)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2024-05-14 21:50:28 +02:00
homarr-renovate[bot] b18b364270 fix(deps): update tiptap monorepo to v2.4.0 (#494)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2024-05-14 21:47:18 +02:00
Manuel 87fe03dd00 feat: add hero banner (#463) 2024-05-12 18:19:01 +02:00
homarr-renovate[bot] 1570faa20a fix(deps): update tanstack-query monorepo to ^5.36.0 (#481)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2024-05-12 16:32:21 +02:00
homarr-renovate[bot] 143db86b77 chore(deps): update dependency tsx to v4.10.1 (#480)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2024-05-12 16:31:43 +02:00
Meier Lukas fd44ee48f4 fix: rdb files can not be saved because of permission issues bug: redis can not save RDB files #318 (#472)
* fix: rdb files can not be saved because of permission issues bug: redis can not save RDB files #318

* refactor: move db and redis dumps to /appdata directory
2024-05-12 16:28:44 +02:00
Meier Lukas db01301845 feat: user preferences (#470)
* wip: improve user preferences

* wip: fix translations and add user danger zone

* feat: add user delete button to danger zone

* fix: test not working

* refactor: add access checks for user edit page, improve not found behaviour, change user preference link in avatar menu to correct link

* fix: remove invalid bg for container

* chore: address pull request feedback
2024-05-12 16:27:56 +02:00
homarr-renovate[bot] f0da1d81a6 fix(deps): update dependency better-sqlite3 to v10 (#479)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2024-05-12 10:18:54 +02:00
homarr-renovate[bot] 333b124f67 fix(deps): update dependency glob to ^10.3.15 (#478)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2024-05-12 10:17:41 +02:00
Meier Lukas 94af21abbf feat: add user invite registration (#477) 2024-05-12 10:04:20 +02:00
Thomas Camlong fe7de2cf6d Merge branch 'dev' into ajnart/fix-duplicate-users 2024-05-12 01:02:07 +02:00
homarr-renovate[bot] 82d41abf46 fix(deps): update dependency @auth/drizzle-adapter to ^1.1.0 (#474)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2024-05-11 23:12:22 +02:00
homarr-renovate[bot]andMeier Lukas 95ddd03763 fix(deps): update dependency @auth/core to ^0.31.0 (#473)
* fix(deps): update dependency @auth/core to ^0.31.0

* fix: typecheck issue

* fix: more issues with types

* fix: add newest next-auth beta version as types were changed and depend on each other

* fix: format issues

---------

Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
Co-authored-by: Meier Lukas <meierschlumpf@gmail.com>
2024-05-11 22:44:00 +02:00
homarr-renovate[bot] 99114f0727 chore(deps): update dependency tsx to v4.10.0 (#476)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2024-05-11 22:24:15 +02:00
homarr-renovate[bot] d970cb54f5 chore(deps): update dependency @types/react to ^18.3.2 (#471)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2024-05-11 13:01:01 +02:00
homarr-renovate[bot] cb6f754492 fix(deps): update dependency sass to ^1.77.1 (#469)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2024-05-11 13:00:36 +02:00
homarr-renovate[bot] 8dd5987e5e chore(deps): update dependency tsx to v4.9.4 (#467)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2024-05-11 11:03:04 +02:00
homarr-renovate[bot] 627f8957e5 fix(deps): update dependency mantine-react-table to v2.0.0-beta.2 (#466)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2024-05-11 10:58:45 +02:00
homarr-renovate[bot] 8e6d143edc fix(deps): update tanstack-query monorepo to ^5.35.5 (#465)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2024-05-11 10:50:51 +02:00
homarr-renovate[bot]andMeier Lukas 1ce6fb0d0f fix(deps): update dependency drizzle-kit to ^0.21.1 (#462)
* fix(deps): update dependency drizzle-kit to ^0.21.0

* fix(deps): update dependency drizzle-kit to ^0.21.1

* fix: migrate to drizzle kit 0.21

* fix: format issues

* feat: add default env variables for db dialect and driver

* fix: issue with driver seems to be fixed

---------

Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
Co-authored-by: Meier Lukas <meierschlumpf@gmail.com>
2024-05-10 23:46:01 +02:00
ajnart 0008ceffed Fix prettier 2024-05-10 20:05:18 +02:00
ajnart 543bfc0835 Fix profile accordion error display 2024-05-10 20:02:57 +02:00
Thomas Camlong 6e2bd0ce11 fix: Handle existing user when editing profile 2024-05-10 15:24:40 +02:00
Thomas Camlong f5d71aebb4 Add successful profile edit message 2024-05-10 15:24:20 +02:00
Thomas Camlong 0ae8a75a5e feat: cannot change username to the current one 2024-05-10 15:23:57 +02:00
Thomas Camlong 65025a5dda fix: Update check for user existence 2024-05-10 14:24:35 +02:00
Thomas Camlong c2026d0962 refactor: Update UserCreateStepperComponent
To use ErrorDisplay component
2024-05-10 14:16:45 +02:00
Thomas Camlong 245c2f483c feat: Add ErrorDisplay component 2024-05-10 14:16:24 +02:00
Thomas Camlong 781247de51 feat: add username validation to prevent duplicate 2024-05-10 13:39:21 +02:00
Thomas Camlong 400fa3d480 propose forcing lowercase usernames 2024-05-10 13:36:46 +02:00
homarr-renovate[bot] 2434e2e758 chore(deps): update dependency node to v20.13.1 (#458)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2024-05-10 11:22:07 +02:00
homarr-renovate[bot] 48d7aaaf1d fix(deps): update mantine monorepo to ^7.9.1 (#457)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2024-05-10 11:21:02 +02:00
homarr-renovate[bot] 687556ad96 fix(deps): update dependency zod to ^3.23.8 (#460)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2024-05-09 21:35:33 +02:00
homarr-renovate[bot] 8bd08d39ca fix(deps): update dependency glob to ^10.3.14 (#461)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2024-05-09 21:35:15 +02:00
homarr-renovate[bot] 382be496ff fix(deps): update tiptap monorepo to v2.3.2 (#459)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2024-05-08 19:15:27 +02:00
homarr-renovate[bot] 1172d84223 fix(deps): update dependency @trpc/client to v11.0.0-rc.366 (#454)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2024-05-08 06:26:48 +02:00
homarr-renovate[bot] cd4762377a chore(deps): update dependency node (#455)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2024-05-08 06:07:24 +02:00
homarr-renovate[bot] cab34052f9 fix(deps): update dependency zod to ^3.23.7 (#456)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2024-05-08 06:01:34 +02:00
homarr-renovate[bot] 442d45dd15 chore(deps): update dependency dotenv-cli to ^7.4.2 (#453)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2024-05-07 16:16:07 +02:00
homarr-renovate[bot] 3ae96d94a2 fix(deps): update dependency sass to ^1.77.0 (#451)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2024-05-07 06:26:12 +02:00
homarr-renovate[bot] 5eb1d716de fix(deps): update dependency drizzle-kit to ^0.20.18 (#449)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2024-05-06 21:38:32 +02:00
homarr-renovate[bot] 25ce74a82f chore(deps): update pnpm to v9.1.0 (#447)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2024-05-06 21:37:27 +02:00
homarr-renovate[bot] 5ae7bc957c chore(deps): update dependency @types/node to ^20.12.10 (#448)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2024-05-06 21:35:09 +02:00
homarr-renovate[bot] 35ba58a0d9 chore(deps): update dependency tsx to v4.9.3 (#435)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2024-05-06 21:34:41 +02:00
Meier Lukas c88464498f fix: unable to select integration on board page (#450) 2024-05-06 21:12:55 +02:00
Manuel dc0184af07 feat: log sql queries (#444) 2024-05-06 20:14:51 +02:00
ManuelandMeier Lukas 3cda718474 feat: add contributors to about page (#445)
* feat: add contributors to about page

* fix: remove unnecessary client component for generic-contribution-link-card

---------

Co-authored-by: Meier Lukas <meierschlumpf@gmail.com>
2024-05-06 20:10:40 +02:00
homarr-renovate[bot] 96158bcb5e fix(deps): update tanstack-query monorepo to ^5.35.1 (#446)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2024-05-06 12:03:38 +02:00
Meier Lukas 5d30301b95 feat: show creator on board page (#440) 2024-05-05 22:20:51 +02:00
Meier Lukas 7b31684c84 feat: add job on start option for icons updater (#442) 2024-05-05 22:20:38 +02:00
Meier Lukas 4b80b16b53 fix: integration links in manage section do not work (#441) 2024-05-05 22:20:18 +02:00
homarr-renovate[bot] 08d1b6c638 fix(deps): update tanstack-query monorepo to ^5.34.2 (#439)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2024-05-05 15:00:47 +02:00
Manuel 60a35e2583 feat: #420 reimplement icon picker (#421) 2024-05-04 23:00:15 +02:00
Manuel 51aaab2f23 feat: add login and logout (#436) 2024-05-04 22:57:13 +02:00
390 changed files with 18415 additions and 6703 deletions

No files matched your search

+18 -3
View File
@@ -1,15 +1,30 @@
{ {
"$schema": "https://docs.renovatebot.com/renovate-schema.json", "$schema": "https://docs.renovatebot.com/renovate-schema.json",
"extends": ["config:recommended"], "extends": [
"config:recommended"
],
"packageRules": [ "packageRules": [
{ {
"matchPackagePatterns": ["^@homarr/"], "matchPackagePatterns": [
"^@homarr/"
],
"enabled": false "enabled": false
},
{
"matchUpdateTypes": [
"minor",
"patch",
"pin",
"digest"
],
"automerge": true
} }
], ],
"updateInternalDeps": true, "updateInternalDeps": true,
"rangeStrategy": "bump", "rangeStrategy": "bump",
"automerge": false, "automerge": false,
"baseBranches": ["dev"], "baseBranches": [
"dev"
],
"dependencyDashboard": false "dependencyDashboard": false
} }
+6
View File
@@ -66,3 +66,9 @@ jobs:
- name: Test - name: Test
run: pnpm test run: pnpm test
- name: 'Report Coverage'
# Set if: always() to also generate the report if tests are failing
# Only works if you set `reportOnFailure: true` in your vite config as specified above
if: always()
uses: davelosert/vitest-coverage-report-action@v2
@@ -0,0 +1,16 @@
# https://github.com/webiny/action-conventional-commits?tab=readme-ov-file
name: Conventional Commits
on:
pull_request:
branches: [ dev ]
jobs:
build:
name: Conventional Commits
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: webiny/action-conventional-commits@v1.3.0
+20 -15
View File
@@ -30,8 +30,20 @@ jobs:
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }} DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
uses: Ilshidur/action-discord@master uses: Ilshidur/action-discord@master
with: with:
args: "Deployment of an image has been triggered" args: "Deployment of an image has been triggered: [run ${{ github.run_number }}](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }})"
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: Get Next Version
id: semver
uses: ietf-tools/semver-action@v1
with:
token: ${{ github.token }}
branch: master
- name: Discord notification
env:
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
uses: Ilshidur/action-discord@master
with:
args: "Semver computed next tag to be ${{ steps.semver.outputs.next }}. Current is ${{ steps.semver.outputs.current }}"
- uses: pnpm/action-setup@v2 - uses: pnpm/action-setup@v2
with: with:
version: 8 version: 8
@@ -44,23 +56,16 @@ jobs:
run: pnpm install run: pnpm install
- name: Build artifacts - name: Build artifacts
run: pnpm build run: pnpm build
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Bump version and push tag
id: githubTagAction
uses: anothrNick/github-tag-action@1.67.0
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
WITH_V: false
DRY_RUN: true
- name: Discord notification - name: Discord notification
env: env:
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }} DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
uses: Ilshidur/action-discord@master uses: Ilshidur/action-discord@master
with: with:
args: "Image has been tagged as ${{ steps.githubTagAction.outputs.new_tag }}" args: "Built application artifacts. Building images..."
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Docker meta - name: Docker meta
id: meta id: meta
uses: docker/metadata-action@v5 uses: docker/metadata-action@v5
@@ -68,7 +73,7 @@ jobs:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: | tags: |
type=raw,value=latest type=raw,value=latest
type=raw,value=${{ steps.githubTagAction.outputs.new_tag }} type=raw,value=${{ steps.semver.outputs.next }}
- name: Build and push - name: Build and push
id: buildPushAction id: buildPushAction
uses: docker/build-push-action@v5 uses: docker/build-push-action@v5
@@ -86,4 +91,4 @@ jobs:
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }} DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
uses: Ilshidur/action-discord@master uses: Ilshidur/action-discord@master
with: with:
args: "Image built with ID ${{ steps.buildPushAction.outputs.imageid }}" args: "Deployment of image has completed. Image ID is '${{ steps.buildPushAction.outputs.imageid }}'. This was a dry run."
@@ -0,0 +1,20 @@
name: "Lint PR"
on:
pull_request_target:
types:
- opened
- edited
- synchronize
permissions:
pull-requests: read
jobs:
main:
name: Validate PR title
runs-on: ubuntu-latest
steps:
- uses: amannn/action-semantic-pull-request@v5
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
@@ -0,0 +1,23 @@
name: Approve Renovate PRs
on:
pull_request:
types: [opened, synchronize]
jobs:
approve-renovate-prs:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Install GitHub CLI
run: sudo apt-get install -y gh
- name: Approve Renovate PRs
env:
GITHUB_TOKEN: ${{ secrets.RENOVATE_APPROVE_TOKEN }}
run: |
for pr in $(gh pr list --author homarr-renovate[bot] --json number --jq .[].number); do
gh pr review $pr --approve --body "Automatically approved by GitHub Action"
done
+3
View File
@@ -52,3 +52,6 @@ db.sqlite
# logs # logs
*.log *.log
apps/tasks/tasks.cjs
apps/websocket/wssServer.cjs
+1 -1
View File
@@ -1 +1 @@
20.12.2 20.14.0
+3 -1
View File
@@ -6,9 +6,11 @@
], ],
"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": [
"superjson", "superjson",
"homarr", "homarr",
"trpc" "trpc",
"Umami"
] ]
} }
+13 -3
View File
@@ -1,4 +1,4 @@
FROM node:20.12.2-alpine AS base FROM node:20.14.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
@@ -35,6 +35,7 @@ RUN corepack enable pnpm && pnpm install
COPY --from=builder /app/next-out/json/ . COPY --from=builder /app/next-out/json/ .
COPY --from=builder /app/next-out/pnpm-lock.yaml ./pnpm-lock.yaml COPY --from=builder /app/next-out/pnpm-lock.yaml ./pnpm-lock.yaml
RUN corepack enable pnpm && pnpm install sharp -w RUN corepack enable pnpm && pnpm install sharp -w
# Build the project # Build the project
@@ -42,6 +43,8 @@ COPY --from=builder /app/tasks-out/full/ .
COPY --from=builder /app/websocket-out/full/ . COPY --from=builder /app/websocket-out/full/ .
COPY --from=builder /app/next-out/full/ . COPY --from=builder /app/next-out/full/ .
COPY --from=builder /app/migration-out/full/ . COPY --from=builder /app/migration-out/full/ .
# Copy static data as it is not part of the build
COPY static-data ./static-data
ARG SKIP_ENV_VALIDATION=true ARG SKIP_ENV_VALIDATION=true
RUN corepack enable pnpm && pnpm turbo run build RUN corepack enable pnpm && pnpm turbo run build
@@ -49,10 +52,15 @@ FROM base AS runner
WORKDIR /app WORKDIR /app
RUN apk add --no-cache redis RUN apk add --no-cache redis
RUN mkdir /appdata
RUN mkdir /appdata/db
RUN mkdir /appdata/redis
VOLUME /appdata
# Don't run production as root # Don't run production as root
RUN addgroup --system --gid 1001 nodejs RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs RUN adduser --system --uid 1001 nextjs
RUN chown -R nextjs:nodejs /appdata
USER nextjs USER nextjs
COPY --from=installer /app/apps/nextjs/next.config.mjs . COPY --from=installer /app/apps/nextjs/next.config.mjs .
@@ -63,7 +71,6 @@ COPY --from=installer --chown=nextjs:nodejs /app/apps/websocket/wssServer.cjs ./
COPY --from=installer --chown=nextjs:nodejs /app/node_modules/better-sqlite3/build/Release/better_sqlite3.node /app/build/better_sqlite3.node COPY --from=installer --chown=nextjs:nodejs /app/node_modules/better-sqlite3/build/Release/better_sqlite3.node /app/build/better_sqlite3.node
COPY --from=installer --chown=nextjs:nodejs /app/packages/db/migrations ./db/migrations COPY --from=installer --chown=nextjs:nodejs /app/packages/db/migrations ./db/migrations
COPY --from=installer --chown=nextjs:nodejs /app/packages/db/migrate.cjs ./db/migrate.cjs
# Automatically leverage output traces to reduce image size # Automatically leverage output traces to reduce image size
# https://nextjs.org/docs/advanced-features/output-file-tracing # https://nextjs.org/docs/advanced-features/output-file-tracing
@@ -71,7 +78,10 @@ COPY --from=installer --chown=nextjs:nodejs /app/apps/nextjs/.next/standalone ./
COPY --from=installer --chown=nextjs:nodejs /app/apps/nextjs/.next/static ./apps/nextjs/.next/static COPY --from=installer --chown=nextjs:nodejs /app/apps/nextjs/.next/static ./apps/nextjs/.next/static
COPY --from=installer --chown=nextjs:nodejs /app/apps/nextjs/public ./apps/nextjs/public COPY --from=installer --chown=nextjs:nodejs /app/apps/nextjs/public ./apps/nextjs/public
COPY --chown=nextjs:nodejs scripts/run.sh ./run.sh COPY --chown=nextjs:nodejs scripts/run.sh ./run.sh
COPY --chown=nextjs:nodejs packages/redis/redis.conf /app/redis.conf
ENV DB_URL='/app/db/db.sqlite' ENV DB_URL='/appdata/db/db.sqlite'
ENV DB_DIALECT='sqlite'
ENV DB_DRIVER='better-sqlite3'
CMD ["sh", "run.sh"] CMD ["sh", "run.sh"]
+2
View File
@@ -1,5 +1,7 @@
# THIS PROJECT IS STILL UNSTABLE AND WE DO NOT PROVIDE ANY SUPPORT FOR ISSUES THAT OCCURE. # THIS PROJECT IS STILL UNSTABLE AND WE DO NOT PROVIDE ANY SUPPORT FOR ISSUES THAT OCCURE.
## PLEASE DO NOT OPEN ANY ISSUES OR DISCUSSIONS ## PLEASE DO NOT OPEN ANY ISSUES OR DISCUSSIONS
### EVERYTHING IS SUBJECT TO CHANGE ### EVERYTHING IS SUBJECT TO CHANGE
Please use [this](https://github.com/ajnart/homarr) version of Homarr when you want to use it Please use [this](https://github.com/ajnart/homarr) version of Homarr when you want to use it
+13 -14
View File
@@ -1,6 +1,6 @@
// Importing env files here to validate on build // Importing env files here to validate on build
import "./src/env.mjs";
import "@homarr/auth/env.mjs"; import "@homarr/auth/env.mjs";
import "./src/env.mjs";
/** @type {import("next").NextConfig} */ /** @type {import("next").NextConfig} */
const config = { const config = {
@@ -9,20 +9,19 @@ const config = {
/** We already do linting and typechecking as separate tasks in CI */ /** We already do linting and typechecking as separate tasks in CI */
eslint: { ignoreDuringBuilds: true }, eslint: { ignoreDuringBuilds: true },
typescript: { ignoreBuildErrors: true }, typescript: { ignoreBuildErrors: true },
experimental: { webpack: (config) => {
optimizePackageImports: [ // eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access
"@mantine/core", config.module.rules.push({
"@mantine/hooks", test: /\.node$/,
"@tabler/icons-react", loader: "node-loader",
], });
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
return config;
}, },
transpilePackages: [ experimental: {
"@homarr/ui", optimizePackageImports: ["@mantine/core", "@mantine/hooks", "@tabler/icons-react"],
"@homarr/notifications", },
"@homarr/modals", transpilePackages: ["@homarr/ui", "@homarr/notifications", "@homarr/modals", "@homarr/spotlight", "@homarr/widgets"],
"@homarr/spotlight",
"@homarr/widgets",
],
images: { images: {
domains: ["cdn.jsdelivr.net"], domains: ["cdn.jsdelivr.net"],
}, },
+22 -14
View File
@@ -14,6 +14,7 @@
"with-env": "dotenv -e ../../.env --" "with-env": "dotenv -e ../../.env --"
}, },
"dependencies": { "dependencies": {
"@homarr/analytics": "workspace:^0.1.0",
"@homarr/api": "workspace:^0.1.0", "@homarr/api": "workspace:^0.1.0",
"@homarr/auth": "workspace:^0.1.0", "@homarr/auth": "workspace:^0.1.0",
"@homarr/common": "workspace:^0.1.0", "@homarr/common": "workspace:^0.1.0",
@@ -21,6 +22,7 @@
"@homarr/definitions": "workspace:^0.1.0", "@homarr/definitions": "workspace:^0.1.0",
"@homarr/form": "workspace:^0.1.0", "@homarr/form": "workspace:^0.1.0",
"@homarr/gridstack": "^1.0.0", "@homarr/gridstack": "^1.0.0",
"@homarr/integrations": "workspace:^0.1.0",
"@homarr/log": "workspace:^", "@homarr/log": "workspace:^",
"@homarr/modals": "workspace:^0.1.0", "@homarr/modals": "workspace:^0.1.0",
"@homarr/notifications": "workspace:^0.1.0", "@homarr/notifications": "workspace:^0.1.0",
@@ -29,15 +31,16 @@
"@homarr/ui": "workspace:^0.1.0", "@homarr/ui": "workspace:^0.1.0",
"@homarr/validation": "workspace:^0.1.0", "@homarr/validation": "workspace:^0.1.0",
"@homarr/widgets": "workspace:^0.1.0", "@homarr/widgets": "workspace:^0.1.0",
"@mantine/colors-generator": "^7.9.0", "@mantine/colors-generator": "^7.10.1",
"@mantine/hooks": "^7.9.0", "@mantine/hooks": "^7.10.1",
"@mantine/modals": "^7.9.0", "@mantine/modals": "^7.10.1",
"@mantine/tiptap": "^7.9.0", "@mantine/tiptap": "^7.10.1",
"@homarr/server-settings": "workspace:^0.1.0",
"@t3-oss/env-nextjs": "^0.10.1", "@t3-oss/env-nextjs": "^0.10.1",
"@tanstack/react-query": "^5.34.1", "@tanstack/react-query": "^5.40.0",
"@tanstack/react-query-devtools": "^5.34.1", "@tanstack/react-query-devtools": "^5.40.0",
"@tanstack/react-query-next-experimental": "5.34.1", "@tanstack/react-query-next-experimental": "5.40.0",
"@trpc/client": "11.0.0-rc.364", "@trpc/client": "11.0.0-rc.382",
"@trpc/next": "next", "@trpc/next": "next",
"@trpc/react-query": "next", "@trpc/react-query": "next",
"@trpc/server": "next", "@trpc/server": "next",
@@ -45,15 +48,19 @@
"@xterm/addon-fit": "0.10.0", "@xterm/addon-fit": "0.10.0",
"@xterm/xterm": "^5.5.0", "@xterm/xterm": "^5.5.0",
"chroma-js": "^2.4.2", "chroma-js": "^2.4.2",
"clsx": "^2.1.1",
"dayjs": "^1.11.11", "dayjs": "^1.11.11",
"dotenv": "^16.4.5", "dotenv": "^16.4.5",
"glob": "^10.3.12", "flag-icons": "^7.2.3",
"jotai": "^2.8.0", "glob": "^10.4.1",
"jotai": "^2.8.2",
"mantine-react-table": "2.0.0-beta.3",
"next": "^14.2.3", "next": "^14.2.3",
"postcss-preset-mantine": "^1.15.0", "postcss-preset-mantine": "^1.15.0",
"react": "18.3.1", "react": "18.3.1",
"react-dom": "18.3.1", "react-dom": "18.3.1",
"sass": "^1.76.0", "react-error-boundary": "^4.0.13",
"sass": "^1.77.3",
"superjson": "2.2.1", "superjson": "2.2.1",
"use-deep-compare-effect": "^1.8.1" "use-deep-compare-effect": "^1.8.1"
}, },
@@ -62,13 +69,14 @@
"@homarr/prettier-config": "workspace:^0.1.0", "@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0", "@homarr/tsconfig": "workspace:^0.1.0",
"@types/chroma-js": "2.4.4", "@types/chroma-js": "2.4.4",
"@types/node": "^20.12.8", "@types/node": "^20.12.13",
"@types/react": "^18.3.1", "@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0", "@types/react-dom": "^18.3.0",
"concurrently": "^8.2.2", "concurrently": "^8.2.2",
"eslint": "^8.57.0", "eslint": "^8.57.0",
"node-loader": "^2.0.0",
"prettier": "^3.2.5", "prettier": "^3.2.5",
"tsx": "4.9.0", "tsx": "4.11.0",
"typescript": "^5.4.5" "typescript": "^5.4.5"
}, },
"eslintConfig": { "eslintConfig": {
@@ -3,10 +3,7 @@ import type { PropsWithChildren } from "react";
import { defaultLocale } from "@homarr/translation"; import { defaultLocale } from "@homarr/translation";
import { I18nProviderClient } from "@homarr/translation/client"; import { I18nProviderClient } from "@homarr/translation/client";
export const NextInternationalProvider = ({ export const NextInternationalProvider = ({ children, locale }: PropsWithChildren<{ locale: string }>) => {
children,
locale,
}: PropsWithChildren<{ locale: string }>) => {
return ( return (
<I18nProviderClient locale={locale} fallback={defaultLocale}> <I18nProviderClient locale={locale} fallback={defaultLocale}>
{children} {children}
@@ -9,9 +9,6 @@ interface AuthProviderProps {
session: Session | null; session: Session | null;
} }
export const AuthProvider = ({ export const AuthProvider = ({ children, session }: PropsWithChildren<AuthProviderProps>) => {
children,
session,
}: PropsWithChildren<AuthProviderProps>) => {
return <SessionProvider session={session}>{children}</SessionProvider>; return <SessionProvider session={session}>{children}</SessionProvider>;
}; };
@@ -5,12 +5,7 @@ import { useState } from "react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { ReactQueryDevtools } from "@tanstack/react-query-devtools"; import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
import { ReactQueryStreamedHydration } from "@tanstack/react-query-next-experimental"; import { ReactQueryStreamedHydration } from "@tanstack/react-query-next-experimental";
import { import { createWSClient, loggerLink, unstable_httpBatchStreamLink, wsLink } from "@trpc/client";
createWSClient,
loggerLink,
unstable_httpBatchStreamLink,
wsLink,
} from "@trpc/client";
import superjson from "superjson"; import superjson from "superjson";
import type { AppRouter } from "@homarr/api"; import type { AppRouter } from "@homarr/api";
@@ -37,8 +32,7 @@ export function TRPCReactProvider(props: PropsWithChildren) {
links: [ links: [
loggerLink({ loggerLink({
enabled: (opts) => enabled: (opts) =>
process.env.NODE_ENV === "development" || process.env.NODE_ENV === "development" || (opts.direction === "down" && opts.result instanceof Error),
(opts.direction === "down" && opts.result instanceof Error),
}), }),
(args) => { (args) => {
return ({ op, next }) => { return ({ op, next }) => {
@@ -69,9 +63,7 @@ export function TRPCReactProvider(props: PropsWithChildren) {
return ( return (
<clientApi.Provider client={trpcClient} queryClient={queryClient}> <clientApi.Provider client={trpcClient} queryClient={queryClient}>
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
<ReactQueryStreamedHydration transformer={superjson}> <ReactQueryStreamedHydration transformer={superjson}>{props.children}</ReactQueryStreamedHydration>
{props.children}
</ReactQueryStreamedHydration>
<ReactQueryDevtools initialIsOpen={false} /> <ReactQueryDevtools initialIsOpen={false} />
</QueryClientProvider> </QueryClientProvider>
</clientApi.Provider> </clientApi.Provider>
@@ -0,0 +1,85 @@
"use client";
import { useRouter } from "next/navigation";
import { Button, PasswordInput, Stack, TextInput } from "@mantine/core";
import { clientApi } from "@homarr/api/client";
import { useZodForm } from "@homarr/form";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useScopedI18n } from "@homarr/translation/client";
import type { z } from "@homarr/validation";
import { validation } from "@homarr/validation";
interface RegistrationFormProps {
invite: {
id: string;
token: string;
};
}
export const RegistrationForm = ({ invite }: RegistrationFormProps) => {
const t = useScopedI18n("user");
const router = useRouter();
const { mutate, isPending } = clientApi.user.register.useMutation();
const form = useZodForm(validation.user.registration, {
initialValues: {
username: "",
password: "",
confirmPassword: "",
},
});
const handleSubmit = (values: z.infer<typeof validation.user.registration>) => {
mutate(
{
...values,
inviteId: invite.id,
token: invite.token,
},
{
onSuccess() {
showSuccessNotification({
title: t("action.register.notification.success.title"),
message: t("action.register.notification.success.message"),
});
router.push("/auth/login");
},
onError(error) {
const message =
error.data?.code === "CONFLICT"
? t("error.usernameTaken")
: t("action.register.notification.error.message");
showErrorNotification({
title: t("action.register.notification.error.title"),
message,
});
},
},
);
};
return (
<Stack gap="xl">
<form onSubmit={form.onSubmit(handleSubmit)}>
<Stack gap="lg">
<TextInput label={t("field.username.label")} autoComplete="off" {...form.getInputProps("username")} />
<PasswordInput
label={t("field.password.label")}
autoComplete="new-password"
{...form.getInputProps("password")}
/>
<PasswordInput
label={t("field.passwordConfirm.label")}
autoComplete="new-password"
{...form.getInputProps("confirmPassword")}
/>
<Button type="submit" fullWidth loading={isPending}>
{t("action.register.label")}
</Button>
</Stack>
</form>
</Stack>
);
};
@@ -0,0 +1,66 @@
import { notFound } from "next/navigation";
import { Card, Center, Stack, Text, Title } from "@mantine/core";
import { auth } from "@homarr/auth/next";
import { and, db, eq } from "@homarr/db";
import { invites } from "@homarr/db/schema/sqlite";
import { getScopedI18n } from "@homarr/translation/server";
import { HomarrLogoWithTitle } from "~/components/layout/logo/homarr-logo";
import { RegistrationForm } from "./_registration-form";
interface InviteUsagePageProps {
params: {
id: string;
};
searchParams: {
token: string;
};
}
export default async function InviteUsagePage({ params, searchParams }: InviteUsagePageProps) {
const session = await auth();
if (session) notFound();
const invite = await db.query.invites.findFirst({
where: and(eq(invites.id, params.id), eq(invites.token, searchParams.token)),
columns: {
id: true,
token: true,
expirationDate: true,
},
with: {
creator: {
columns: {
name: true,
},
},
},
});
if (!invite || invite.expirationDate < new Date()) notFound();
const t = await getScopedI18n("user.page.invite");
return (
<Center>
<Stack align="center" mt="xl">
<HomarrLogoWithTitle size="lg" />
<Stack gap={6} align="center">
<Title order={3} fw={400} ta="center">
{t("title")}
</Title>
<Text size="sm" c="gray.5" ta="center">
{t("subtitle")}
</Text>
</Stack>
<Card bg="dark.8" w={64 * 6} maw="90vw">
<RegistrationForm invite={invite} />
</Card>
<Text size="xs" c="gray.5" ta="center">
{t("description", { username: invite.creator.name })}
</Text>
</Stack>
</Center>
);
}
@@ -2,18 +2,12 @@
import { useState } from "react"; import { useState } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { import { Alert, Button, PasswordInput, rem, Stack, TextInput } from "@mantine/core";
Alert,
Button,
PasswordInput,
rem,
Stack,
TextInput,
} from "@mantine/core";
import { IconAlertTriangle } from "@tabler/icons-react"; import { IconAlertTriangle } from "@tabler/icons-react";
import { signIn } from "@homarr/auth/client"; import { signIn } from "@homarr/auth/client";
import { useForm, zodResolver } from "@homarr/form"; import { useZodForm } from "@homarr/form";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useScopedI18n } from "@homarr/translation/client"; import { useScopedI18n } from "@homarr/translation/client";
import type { z } from "@homarr/validation"; import type { z } from "@homarr/validation";
import { validation } from "@homarr/validation"; import { validation } from "@homarr/validation";
@@ -23,15 +17,14 @@ export const LoginForm = () => {
const router = useRouter(); const router = useRouter();
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string>(); const [error, setError] = useState<string>();
const form = useForm<FormType>({ const form = useZodForm(validation.user.signIn, {
validate: zodResolver(validation.user.signIn),
initialValues: { initialValues: {
name: "", name: "",
password: "", password: "",
}, },
}); });
const handleSubmit = async (values: FormType) => { const handleSubmitAsync = async (values: z.infer<typeof validation.user.signIn>) => {
setIsLoading(true); setIsLoading(true);
setError(undefined); setError(undefined);
await signIn("credentials", { await signIn("credentials", {
@@ -44,28 +37,30 @@ export const LoginForm = () => {
throw response?.error; throw response?.error;
} }
void router.push("/"); showSuccessNotification({
title: t("action.login.notification.success.title"),
message: t("action.login.notification.success.message"),
});
router.push("/");
}) })
.catch((error: Error | string) => { .catch((error: Error | string) => {
setIsLoading(false); setIsLoading(false);
setError(error.toString()); setError(error.toString());
showErrorNotification({
title: t("action.login.notification.error.title"),
message: t("action.login.notification.error.message"),
});
}); });
}; };
return ( return (
<Stack gap="xl"> <Stack gap="xl">
<form onSubmit={form.onSubmit((values) => void handleSubmit(values))}> <form onSubmit={form.onSubmit((values) => void handleSubmitAsync(values))}>
<Stack gap="lg"> <Stack gap="lg">
<TextInput <TextInput label={t("field.username.label")} {...form.getInputProps("name")} />
label={t("field.username.label")} <PasswordInput label={t("field.password.label")} {...form.getInputProps("password")} />
{...form.getInputProps("name")}
/>
<PasswordInput
label={t("field.password.label")}
{...form.getInputProps("password")}
/>
<Button type="submit" fullWidth loading={isLoading}> <Button type="submit" fullWidth loading={isLoading}>
{t("action.login")} {t("action.login.label")}
</Button> </Button>
</Stack> </Stack>
</form> </form>
@@ -78,5 +73,3 @@ export const LoginForm = () => {
</Stack> </Stack>
); );
}; };
type FormType = z.infer<typeof validation.user.signIn>;
@@ -3,7 +3,7 @@ import { api } from "@homarr/api/server";
import { createBoardContentPage } from "../_creator"; import { createBoardContentPage } from "../_creator";
export default createBoardContentPage<{ locale: string }>({ export default createBoardContentPage<{ locale: string }>({
async getInitialBoard() { async getInitialBoardAsync() {
return await api.board.getDefaultBoard(); return await api.board.getHomeBoard();
}, },
}); });
@@ -1,6 +1,6 @@
import definition from "./_definition"; import definition from "./_definition";
const { generateMetadata, page } = definition; const { generateMetadataAsync: generateMetadata, page } = definition;
export default page; export default page;
@@ -3,7 +3,7 @@ import { api } from "@homarr/api/server";
import { createBoardContentPage } from "../_creator"; import { createBoardContentPage } from "../_creator";
export default createBoardContentPage<{ locale: string; name: string }>({ export default createBoardContentPage<{ locale: string; name: string }>({
async getInitialBoard({ name }) { async getInitialBoardAsync({ name }) {
return await api.board.getBoardByName({ name }); return await api.board.getBoardByName({ name });
}, },
}); });
@@ -1,6 +1,6 @@
import definition from "./_definition"; import definition from "./_definition";
const { generateMetadata, page } = definition; const { generateMetadataAsync: generateMetadata, page } = definition;
export default page; export default page;
@@ -18,9 +18,7 @@ export const updateBoardName = (name: string | null) => {
boardName = name; boardName = name;
}; };
type UpdateCallback = ( type UpdateCallback = (prev: RouterOutputs["board"]["getHomeBoard"]) => RouterOutputs["board"]["getHomeBoard"];
prev: RouterOutputs["board"]["getDefaultBoard"],
) => RouterOutputs["board"]["getDefaultBoard"];
export const useUpdateBoard = () => { export const useUpdateBoard = () => {
const utils = clientApi.useUtils(); const utils = clientApi.useUtils();
@@ -46,9 +44,7 @@ export const ClientBoard = () => {
const board = useRequiredBoard(); const board = useRequiredBoard();
const isReady = useIsBoardReady(); const isReady = useIsBoardReady();
const sortedSections = board.sections.sort( const sortedSections = board.sections.sort((sectionA, sectionB) => sectionA.position - sectionB.position);
(sectionA, sectionB) => sectionA.position - sectionB.position,
);
const ref = useRef<HTMLDivElement>(null); const ref = useRef<HTMLDivElement>(null);
@@ -61,24 +57,12 @@ export const ClientBoard = () => {
loaderProps={{ size: "lg" }} loaderProps={{ size: "lg" }}
h={fullHeightWithoutHeaderAndFooter} h={fullHeightWithoutHeaderAndFooter}
/> />
<Stack <Stack ref={ref} h="100%" style={{ visibility: isReady ? "visible" : "hidden" }}>
ref={ref}
h="100%"
style={{ visibility: isReady ? "visible" : "hidden" }}
>
{sortedSections.map((section) => {sortedSections.map((section) =>
section.kind === "empty" ? ( section.kind === "empty" ? (
<BoardEmptySection <BoardEmptySection key={section.id} section={section} mainRef={ref} />
key={section.id}
section={section}
mainRef={ref}
/>
) : ( ) : (
<BoardCategorySection <BoardCategorySection key={section.id} section={section} mainRef={ref} />
key={section.id}
section={section}
mainRef={ref}
/>
), ),
)} )}
</Stack> </Stack>
@@ -1,13 +1,7 @@
"use client"; "use client";
import type { PropsWithChildren } from "react"; import type { Dispatch, PropsWithChildren, SetStateAction } from "react";
import { import { createContext, useCallback, useContext, useEffect, useState } from "react";
createContext,
useCallback,
useContext,
useEffect,
useState,
} from "react";
import { usePathname } from "next/navigation"; import { usePathname } from "next/navigation";
import type { RouterOutputs } from "@homarr/api"; import type { RouterOutputs } from "@homarr/api";
@@ -16,9 +10,11 @@ import { clientApi } from "@homarr/api/client";
import { updateBoardName } from "./_client"; import { updateBoardName } from "./_client";
const BoardContext = createContext<{ const BoardContext = createContext<{
board: RouterOutputs["board"]["getDefaultBoard"]; board: RouterOutputs["board"]["getHomeBoard"];
isReady: boolean; isReady: boolean;
markAsReady: (id: string) => void; markAsReady: (id: string) => void;
isEditMode: boolean;
setEditMode: Dispatch<SetStateAction<boolean>>;
} | null>(null); } | null>(null);
export const BoardProvider = ({ export const BoardProvider = ({
@@ -30,11 +26,11 @@ export const BoardProvider = ({
const pathname = usePathname(); const pathname = usePathname();
const utils = clientApi.useUtils(); const utils = clientApi.useUtils();
const [readySections, setReadySections] = useState<string[]>([]); const [readySections, setReadySections] = useState<string[]>([]);
const [isEditMode, setEditMode] = useState(false);
const { data } = clientApi.board.getBoardByName.useQuery( const { data } = clientApi.board.getBoardByName.useQuery(
{ name: initialBoard.name }, { name: initialBoard.name },
{ {
initialData: initialBoard, initialData: initialBoard,
refetchOnMount: false,
refetchOnWindowFocus: false, refetchOnWindowFocus: false,
refetchOnReconnect: false, refetchOnReconnect: false,
}, },
@@ -52,18 +48,12 @@ export const BoardProvider = ({
}, [pathname, utils, initialBoard.name]); }, [pathname, utils, initialBoard.name]);
useEffect(() => { useEffect(() => {
setReadySections((previous) => setReadySections((previous) => previous.filter((id) => data.sections.some((section) => section.id === id)));
previous.filter((id) =>
data.sections.some((section) => section.id === id),
),
);
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [data.sections.length, setReadySections]); }, [data.sections.length, setReadySections]);
const markAsReady = useCallback((id: string) => { const markAsReady = useCallback((id: string) => {
setReadySections((previous) => setReadySections((previous) => (previous.includes(id) ? previous : [...previous, id]));
previous.includes(id) ? previous : [...previous, id],
);
}, []); }, []);
return ( return (
@@ -72,6 +62,8 @@ export const BoardProvider = ({
board: data, board: data,
isReady: data.sections.length === readySections.length, isReady: data.sections.length === readySections.length,
markAsReady, markAsReady,
isEditMode,
setEditMode,
}} }}
> >
{children} {children}
@@ -114,3 +106,13 @@ export const useOptionalBoard = () => {
return context?.board; return context?.board;
}; };
export const useEditMode = () => {
const context = useContext(BoardContext);
if (!context) {
throw new Error("Board is required");
}
return [context.isEditMode, context.setEditMode] as const;
};
@@ -1,11 +1,12 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import { TRPCError } from "@trpc/server"; import { TRPCError } from "@trpc/server";
import { capitalize } from "@homarr/common";
// Placed here because gridstack styles are used for board content // Placed here because gridstack styles are used for board content
import "~/styles/gridstack.scss"; import "~/styles/gridstack.scss";
import { getI18n } from "@homarr/translation/server";
import { createMetaTitle } from "~/metadata";
import { createBoardLayout } from "../_layout-creator"; import { createBoardLayout } from "../_layout-creator";
import type { Board } from "../_types"; import type { Board } from "../_types";
import { ClientBoard } from "./_client"; import { ClientBoard } from "./_client";
@@ -14,33 +15,28 @@ import { BoardContentHeaderActions } from "./_header-actions";
export type Params = Record<string, unknown>; export type Params = Record<string, unknown>;
interface Props<TParams extends Params> { interface Props<TParams extends Params> {
getInitialBoard: (params: TParams) => Promise<Board>; getInitialBoardAsync: (params: TParams) => Promise<Board>;
} }
export const createBoardContentPage = < export const createBoardContentPage = <TParams extends Record<string, unknown>>({
TParams extends Record<string, unknown>, getInitialBoardAsync: getInitialBoard,
>({
getInitialBoard,
}: Props<TParams>) => { }: Props<TParams>) => {
return { return {
layout: createBoardLayout({ layout: createBoardLayout({
headerActions: <BoardContentHeaderActions />, headerActions: <BoardContentHeaderActions />,
getInitialBoard, getInitialBoardAsync: getInitialBoard,
isBoardContentPage: true, isBoardContentPage: true,
}), }),
page: () => { page: () => {
return <ClientBoard />; return <ClientBoard />;
}, },
generateMetadata: async ({ generateMetadataAsync: async ({ params }: { params: TParams }): Promise<Metadata> => {
params,
}: {
params: TParams;
}): Promise<Metadata> => {
try { try {
const board = await getInitialBoard(params); const board = await getInitialBoard(params);
const t = await getI18n();
return { return {
title: board.metaTitle ?? `${capitalize(board.name)} board | Homarr`, title: board.metaTitle ?? createMetaTitle(t("board.content.metaTitle", { boardName: board.name })),
icons: { icons: {
icon: board.faviconImageUrl ? board.faviconImageUrl : undefined, icon: board.faviconImageUrl ? board.faviconImageUrl : undefined,
}, },
@@ -0,0 +1,9 @@
"use client";
import { useRequiredBoard } from "./_context";
export const CustomCss = () => {
const board = useRequiredBoard();
return <style>{board.customCss}</style>;
};
@@ -12,27 +12,22 @@ import {
IconPlus, IconPlus,
IconSettings, IconSettings,
} from "@tabler/icons-react"; } from "@tabler/icons-react";
import { useAtom, useAtomValue } from "jotai";
import { clientApi } from "@homarr/api/client"; import { clientApi } from "@homarr/api/client";
import { useModalAction } from "@homarr/modals"; import { useModalAction } from "@homarr/modals";
import { import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
showErrorNotification,
showSuccessNotification,
} from "@homarr/notifications";
import { useI18n, useScopedI18n } from "@homarr/translation/client"; import { useI18n, useScopedI18n } from "@homarr/translation/client";
import { revalidatePathAction } from "~/app/revalidatePathAction"; import { revalidatePathActionAsync } from "~/app/revalidatePathAction";
import { editModeAtom } from "~/components/board/editMode";
import { ItemSelectModal } from "~/components/board/items/item-select-modal"; import { ItemSelectModal } from "~/components/board/items/item-select-modal";
import { useBoardPermissions } from "~/components/board/permissions/client"; import { useBoardPermissions } from "~/components/board/permissions/client";
import { useCategoryActions } from "~/components/board/sections/category/category-actions"; import { useCategoryActions } from "~/components/board/sections/category/category-actions";
import { CategoryEditModal } from "~/components/board/sections/category/category-edit-modal"; import { CategoryEditModal } from "~/components/board/sections/category/category-edit-modal";
import { HeaderButton } from "~/components/layout/header/button"; import { HeaderButton } from "~/components/layout/header/button";
import { useRequiredBoard } from "./_context"; import { useEditMode, useRequiredBoard } from "./_context";
export const BoardContentHeaderActions = () => { export const BoardContentHeaderActions = () => {
const isEditMode = useAtomValue(editModeAtom); const [isEditMode] = useEditMode();
const board = useRequiredBoard(); const board = useRequiredBoard();
const { hasChangeAccess } = useBoardPermissions(board); const { hasChangeAccess } = useBoardPermissions(board);
@@ -54,8 +49,7 @@ export const BoardContentHeaderActions = () => {
}; };
const AddMenu = () => { const AddMenu = () => {
const { openModal: openCategoryEditModal } = const { openModal: openCategoryEditModal } = useModalAction(CategoryEditModal);
useModalAction(CategoryEditModal);
const { openModal: openItemSelectModal } = useModalAction(ItemSelectModal); const { openModal: openItemSelectModal } = useModalAction(ItemSelectModal);
const { addCategoryToEnd } = useCategoryActions(); const { addCategoryToEnd } = useCategoryActions();
const t = useI18n(); const t = useI18n();
@@ -95,22 +89,14 @@ const AddMenu = () => {
</HeaderButton> </HeaderButton>
</Menu.Target> </Menu.Target>
<Menu.Dropdown style={{ transform: "translate(-3px, 0)" }}> <Menu.Dropdown style={{ transform: "translate(-3px, 0)" }}>
<Menu.Item <Menu.Item leftSection={<IconBox size={20} />} onClick={handleSelectItem}>
leftSection={<IconBox size={20} />}
onClick={handleSelectItem}
>
{t("item.action.create")} {t("item.action.create")}
</Menu.Item> </Menu.Item>
<Menu.Item leftSection={<IconPackageImport size={20} />}> <Menu.Item leftSection={<IconPackageImport size={20} />}>{t("item.action.import")}</Menu.Item>
{t("item.action.import")}
</Menu.Item>
<Menu.Divider /> <Menu.Divider />
<Menu.Item <Menu.Item leftSection={<IconBoxAlignTop size={20} />} onClick={handleAddCategory}>
leftSection={<IconBoxAlignTop size={20} />}
onClick={handleAddCategory}
>
{t("section.category.action.create")} {t("section.category.action.create")}
</Menu.Item> </Menu.Item>
</Menu.Dropdown> </Menu.Dropdown>
@@ -119,28 +105,27 @@ const AddMenu = () => {
}; };
const EditModeMenu = () => { const EditModeMenu = () => {
const [isEditMode, setEditMode] = useAtom(editModeAtom); const [isEditMode, setEditMode] = useEditMode();
const board = useRequiredBoard(); const board = useRequiredBoard();
const utils = clientApi.useUtils(); const utils = clientApi.useUtils();
const t = useScopedI18n("board.action.edit"); const t = useScopedI18n("board.action.edit");
const { mutate: saveBoard, isPending } = const { mutate: saveBoard, isPending } = clientApi.board.saveBoard.useMutation({
clientApi.board.saveBoard.useMutation({ onSuccess() {
onSuccess() { showSuccessNotification({
showSuccessNotification({ title: t("notification.success.title"),
title: t("notification.success.title"), message: t("notification.success.message"),
message: t("notification.success.message"), });
}); void utils.board.getBoardByName.invalidate({ name: board.name });
void utils.board.getBoardByName.invalidate({ name: board.name }); void revalidatePathActionAsync(`/boards/${board.name}`);
void revalidatePathAction(`/boards/${board.name}`); setEditMode(false);
setEditMode(false); },
}, onError() {
onError() { showErrorNotification({
showErrorNotification({ title: t("notification.error.title"),
title: t("notification.error.title"), message: t("notification.error.message"),
message: t("notification.error.message"), });
}); },
}, });
});
const toggle = useCallback(() => { const toggle = useCallback(() => {
if (isEditMode) return saveBoard(board); if (isEditMode) return saveBoard(board);
@@ -149,11 +134,7 @@ const EditModeMenu = () => {
return ( return (
<HeaderButton onClick={toggle} loading={isPending}> <HeaderButton onClick={toggle} loading={isPending}>
{isEditMode ? ( {isEditMode ? <IconPencilOff stroke={1.5} /> : <IconPencil stroke={1.5} />}
<IconPencilOff stroke={1.5} />
) : (
<IconPencil stroke={1.5} />
)}
</HeaderButton> </HeaderButton>
); );
}; };
@@ -22,9 +22,7 @@ export const BoardMantineProvider = ({ children }: PropsWithChildren) => {
}; };
export const generateColors = (hex: string) => { export const generateColors = (hex: string) => {
const lightnessForColors = [ const lightnessForColors = [-0.25, -0.2, -0.15, -0.1, -0.05, 0, 0.05, 0.1, 0.15, 0.2] as const;
-0.25, -0.2, -0.15, -0.1, -0.05, 0, 0.05, 0.1, 0.15, 0.2,
] as const;
const rgbaColors = lightnessForColors.map((lightness) => { const rgbaColors = lightnessForColors.map((lightness) => {
if (lightness < 0) { if (lightness < 0) {
return lighten(hex, -lightness); return lighten(hex, -lightness);
@@ -5,7 +5,7 @@ import { createBoardLayout } from "../_layout-creator";
export default createBoardLayout<{ locale: string; name: string }>({ export default createBoardLayout<{ locale: string; name: string }>({
headerActions: <BoardOtherHeaderActions />, headerActions: <BoardOtherHeaderActions />,
async getInitialBoard({ name }) { async getInitialBoardAsync({ name }) {
return await api.board.getBoardByName({ name }); return await api.board.getBoardByName({ name });
}, },
isBoardContentPage: false, isBoardContentPage: false,
@@ -44,11 +44,7 @@ export const AccessSettingsContent = ({ board, initialPermissions }: Props) => {
<Tabs.List grow> <Tabs.List grow>
<TabItem value="user" count={counts.user} icon={IconUser} /> <TabItem value="user" count={counts.user} icon={IconUser} />
<TabItem value="group" count={counts.group} icon={IconUsersGroup} /> <TabItem value="group" count={counts.group} icon={IconUsersGroup} />
<TabItem <TabItem value="inherited" count={initialPermissions.inherited.length} icon={IconUserDown} />
value="inherited"
count={initialPermissions.inherited.length}
icon={IconUserDown}
/>
</Tabs.List> </Tabs.List>
<Tabs.Panel value="user"> <Tabs.Panel value="user">
@@ -1,21 +1,8 @@
import { useCallback } from "react"; import { useCallback } from "react";
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import type { SelectProps } from "@mantine/core"; import type { SelectProps } from "@mantine/core";
import { import { Button, Flex, Group, Select, TableTd, TableTr, Text } from "@mantine/core";
Button, import { IconCheck, IconEye, IconPencil, IconSettings } from "@tabler/icons-react";
Flex,
Group,
Select,
TableTd,
TableTr,
Text,
} from "@mantine/core";
import {
IconCheck,
IconEye,
IconPencil,
IconSettings,
} from "@tabler/icons-react";
import type { BoardPermission } from "@homarr/definitions"; import type { BoardPermission } from "@homarr/definitions";
import { boardPermissions } from "@homarr/definitions"; import { boardPermissions } from "@homarr/definitions";
@@ -38,12 +25,7 @@ interface BoardAccessSelectRowProps {
onCountChange: OnCountChange; onCountChange: OnCountChange;
} }
export const BoardAccessSelectRow = ({ export const BoardAccessSelectRow = ({ itemContent, permission, index, onCountChange }: BoardAccessSelectRowProps) => {
itemContent,
permission,
index,
onCountChange,
}: BoardAccessSelectRowProps) => {
const tRoot = useI18n(); const tRoot = useI18n();
const tPermissions = useScopedI18n("board.setting.section.access.permission"); const tPermissions = useScopedI18n("board.setting.section.access.permission");
const form = useFormContext(); const form = useFormContext();
@@ -61,11 +43,7 @@ export const BoardAccessSelectRow = ({
<TableTr> <TableTr>
<TableTd w={{ sm: 128, lg: 256 }}>{itemContent}</TableTd> <TableTd w={{ sm: 128, lg: 256 }}>{itemContent}</TableTd>
<TableTd> <TableTd>
<Flex <Flex direction={{ base: "column", xs: "row" }} align={{ base: "end", xs: "center" }} wrap="nowrap">
direction={{ base: "column", xs: "row" }}
align={{ base: "end", xs: "center" }}
wrap="nowrap"
>
<Select <Select
allowDeselect={false} allowDeselect={false}
flex="1" flex="1"
@@ -93,10 +71,7 @@ interface BoardAccessDisplayRowProps {
permission: BoardPermission | "board-full"; permission: BoardPermission | "board-full";
} }
export const BoardAccessDisplayRow = ({ export const BoardAccessDisplayRow = ({ itemContent, permission }: BoardAccessDisplayRowProps) => {
itemContent,
permission,
}: BoardAccessDisplayRowProps) => {
const tPermissions = useScopedI18n("board.setting.section.access.permission"); const tPermissions = useScopedI18n("board.setting.section.access.permission");
const Icon = icons[permission]; const Icon = icons[permission];
@@ -106,10 +81,7 @@ export const BoardAccessDisplayRow = ({
<TableTd> <TableTd>
<Group gap={0}> <Group gap={0}>
<Flex w={34} h={34} align="center" justify="center"> <Flex w={34} h={34} align="center" justify="center">
<Icon <Icon size="1rem" color="var(--input-section-color, var(--mantine-color-dimmed))" />
size="1rem"
color="var(--input-section-color, var(--mantine-color-dimmed))"
/>
</Flex> </Flex>
<Text size="sm">{tPermissions(`item.${permission}.label`)}</Text> <Text size="sm">{tPermissions(`item.${permission}.label`)}</Text>
</Group> </Group>
@@ -131,9 +103,7 @@ const RenderOption: SelectProps["renderOption"] = ({ option, checked }) => {
<Group flex="1" gap="xs" wrap="nowrap"> <Group flex="1" gap="xs" wrap="nowrap">
<Icon {...iconProps} /> <Icon {...iconProps} />
{option.label} {option.label}
{checked && ( {checked && <IconCheck style={{ marginInlineStart: "auto" }} {...iconProps} />}
<IconCheck style={{ marginInlineStart: "auto" }} {...iconProps} />
)}
</Group> </Group>
); );
}; };
@@ -8,7 +8,6 @@ export interface BoardAccessFormType {
}[]; }[];
} }
export const [FormProvider, useFormContext, useForm] = export const [FormProvider, useFormContext, useForm] = createFormContext<BoardAccessFormType>();
createFormContext<BoardAccessFormType>();
export type OnCountChange = (callback: (prev: number) => number) => void; export type OnCountChange = (callback: (prev: number) => number) => void;
@@ -1,16 +1,6 @@
import { useCallback, useState } from "react"; import { useCallback, useState } from "react";
import Link from "next/link"; import Link from "next/link";
import { import { Anchor, Button, Group, Stack, Table, TableTbody, TableTh, TableThead, TableTr } from "@mantine/core";
Anchor,
Button,
Group,
Stack,
Table,
TableTbody,
TableTh,
TableThead,
TableTr,
} from "@mantine/core";
import { IconPlus } from "@tabler/icons-react"; import { IconPlus } from "@tabler/icons-react";
import type { RouterOutputs } from "@homarr/api"; import type { RouterOutputs } from "@homarr/api";
@@ -24,30 +14,21 @@ import { FormProvider, useForm } from "./form";
import { GroupSelectModal } from "./group-select-modal"; import { GroupSelectModal } from "./group-select-modal";
import type { FormProps } from "./user-access"; import type { FormProps } from "./user-access";
export const GroupsForm = ({ export const GroupsForm = ({ board, initialPermissions, onCountChange }: FormProps) => {
board, const { mutate, isPending } = clientApi.board.saveGroupBoardPermissions.useMutation();
initialPermissions,
onCountChange,
}: FormProps) => {
const { mutate, isPending } =
clientApi.board.saveGroupBoardPermissions.useMutation();
const utils = clientApi.useUtils(); const utils = clientApi.useUtils();
const [groups, setGroups] = useState<Map<string, Group>>( const [groups, setGroups] = useState<Map<string, Group>>(
new Map( new Map(initialPermissions.groupPermissions.map(({ group }) => [group.id, group])),
initialPermissions.groupPermissions.map(({ group }) => [group.id, group]),
),
); );
const { openModal } = useModalAction(GroupSelectModal); const { openModal } = useModalAction(GroupSelectModal);
const t = useI18n(); const t = useI18n();
const tPermissions = useScopedI18n("board.setting.section.access.permission"); const tPermissions = useScopedI18n("board.setting.section.access.permission");
const form = useForm({ const form = useForm({
initialValues: { initialValues: {
items: initialPermissions.groupPermissions.map( items: initialPermissions.groupPermissions.map(({ group, permission }) => ({
({ group, permission }) => ({ itemId: group.id,
itemId: group.id, permission,
permission, })),
}),
),
}, },
}); });
@@ -92,9 +73,7 @@ export const GroupsForm = ({
<Table> <Table>
<TableThead> <TableThead>
<TableTr> <TableTr>
<TableTh style={{ whiteSpace: "nowrap" }}> <TableTh style={{ whiteSpace: "nowrap" }}>{tPermissions("field.group.label")}</TableTh>
{tPermissions("field.group.label")}
</TableTh>
<TableTh>{tPermissions("field.permission.label")}</TableTh> <TableTh>{tPermissions("field.permission.label")}</TableTh>
</TableTr> </TableTr>
</TableThead> </TableThead>
@@ -102,9 +81,7 @@ export const GroupsForm = ({
{form.values.items.map((row, index) => ( {form.values.items.map((row, index) => (
<BoardAccessSelectRow <BoardAccessSelectRow
key={row.itemId} key={row.itemId}
itemContent={ itemContent={<GroupItemContent group={groups.get(row.itemId)!} />}
<GroupItemContent group={groups.get(row.itemId)!} />
}
permission={row.permission} permission={row.permission}
index={index} index={index}
onCountChange={onCountChange} onCountChange={onCountChange}
@@ -114,11 +91,7 @@ export const GroupsForm = ({
</Table> </Table>
<Group justify="space-between"> <Group justify="space-between">
<Button <Button rightSection={<IconPlus size="1rem" />} variant="light" onClick={handleAddUser}>
rightSection={<IconPlus size="1rem" />}
variant="light"
onClick={handleAddUser}
>
{t("common.action.add")} {t("common.action.add")}
</Button> </Button>
<Button type="submit" loading={isPending} color="teal"> <Button type="submit" loading={isPending} color="teal">
@@ -133,16 +106,10 @@ export const GroupsForm = ({
export const GroupItemContent = ({ group }: { group: Group }) => { export const GroupItemContent = ({ group }: { group: Group }) => {
return ( return (
<Anchor <Anchor component={Link} href={`/manage/users/groups/${group.id}`} size="sm" style={{ whiteSpace: "nowrap" }}>
component={Link}
href={`/manage/users/groups/${group.id}`}
size="sm"
style={{ whiteSpace: "nowrap" }}
>
{group.name} {group.name}
</Anchor> </Anchor>
); );
}; };
type Group = type Group = RouterOutputs["board"]["getBoardPermissions"]["groupPermissions"][0]["group"];
RouterOutputs["board"]["getBoardPermissions"]["groupPermissions"][0]["group"];
@@ -16,57 +16,52 @@ interface GroupSelectFormType {
groupId: string; groupId: string;
} }
export const GroupSelectModal = createModal<InnerProps>( export const GroupSelectModal = createModal<InnerProps>(({ actions, innerProps }) => {
({ actions, innerProps }) => { const t = useI18n();
const t = useI18n(); const { data: groups, isPending } = clientApi.group.selectable.useQuery();
const { data: groups, isPending } = clientApi.group.selectable.useQuery(); const [loading, setLoading] = useState(false);
const [loading, setLoading] = useState(false); const form = useForm<GroupSelectFormType>();
const form = useForm<GroupSelectFormType>(); const handleSubmitAsync = async (values: GroupSelectFormType) => {
const handleSubmit = async (values: GroupSelectFormType) => { const currentGroup = groups?.find((group) => group.id === values.groupId);
const currentGroup = groups?.find((group) => group.id === values.groupId); if (!currentGroup) return;
if (!currentGroup) return; setLoading(true);
setLoading(true); await innerProps.onSelect({
await innerProps.onSelect({ id: currentGroup.id,
id: currentGroup.id, name: currentGroup.name,
name: currentGroup.name, });
});
setLoading(false); setLoading(false);
actions.closeModal(); actions.closeModal();
}; };
const confirmLabel = innerProps.confirmLabel ?? t("common.action.add"); const confirmLabel = innerProps.confirmLabel ?? t("common.action.add");
return ( return (
<form onSubmit={form.onSubmit((values) => void handleSubmit(values))}> <form onSubmit={form.onSubmit((values) => void handleSubmitAsync(values))}>
<Stack> <Stack>
<Select <Select
{...form.getInputProps("groupId")} {...form.getInputProps("groupId")}
label={t("group.action.select.label")} label={t("group.action.select.label")}
clearable clearable
searchable searchable
leftSection={isPending ? <Loader size="xs" /> : undefined} leftSection={isPending ? <Loader size="xs" /> : undefined}
nothingFoundMessage={t("group.action.select.notFound")} nothingFoundMessage={t("group.action.select.notFound")}
limit={5} limit={5}
data={groups data={groups
?.filter( ?.filter((group) => !innerProps.presentGroupIds.includes(group.id))
(group) => !innerProps.presentGroupIds.includes(group.id), .map((group) => ({ value: group.id, label: group.name }))}
) />
.map((group) => ({ value: group.id, label: group.name }))} <Group justify="end">
/> <Button variant="default" onClick={actions.closeModal}>
<Group justify="end"> {t("common.action.cancel")}
<Button variant="default" onClick={actions.closeModal}> </Button>
{t("common.action.cancel")} <Button type="submit" loading={loading}>
</Button> {confirmLabel}
<Button type="submit" loading={loading}> </Button>
{confirmLabel} </Group>
</Button> </Stack>
</Group> </form>
</Stack> );
</form> }).withOptions({
); defaultTitle: (t) => t("board.setting.section.access.permission.groupSelect.title"),
},
).withOptions({
defaultTitle: (t) =>
t("board.setting.section.access.permission.groupSelect.title"),
}); });
@@ -1,11 +1,4 @@
import { import { Stack, Table, TableTbody, TableTh, TableThead, TableTr } from "@mantine/core";
Stack,
Table,
TableTbody,
TableTh,
TableThead,
TableTr,
} from "@mantine/core";
import type { RouterOutputs } from "@homarr/api"; import type { RouterOutputs } from "@homarr/api";
import { getPermissionsWithChildren } from "@homarr/definitions"; import { getPermissionsWithChildren } from "@homarr/definitions";
@@ -41,9 +34,7 @@ export const InheritTable = ({ initialPermissions }: InheritTableProps) => {
const boardPermission = const boardPermission =
permission in mapPermissions permission in mapPermissions
? mapPermissions[permission as keyof typeof mapPermissions] ? mapPermissions[permission as keyof typeof mapPermissions]
: getPermissionsWithChildren([permission]).includes( : getPermissionsWithChildren([permission]).includes("board-full-access")
"board-full-access",
)
? "board-full" ? "board-full"
: null; : null;
@@ -1,17 +1,6 @@
import { useCallback, useState } from "react"; import { useCallback, useState } from "react";
import Link from "next/link"; import Link from "next/link";
import { import { Anchor, Box, Button, Group, Stack, Table, TableTbody, TableTh, TableThead, TableTr } from "@mantine/core";
Anchor,
Box,
Button,
Group,
Stack,
Table,
TableTbody,
TableTh,
TableThead,
TableTr,
} from "@mantine/core";
import { IconPlus } from "@tabler/icons-react"; import { IconPlus } from "@tabler/icons-react";
import type { RouterOutputs } from "@homarr/api"; import type { RouterOutputs } from "@homarr/api";
@@ -21,10 +10,7 @@ import { useI18n, useScopedI18n } from "@homarr/translation/client";
import { UserAvatar } from "@homarr/ui"; import { UserAvatar } from "@homarr/ui";
import type { Board } from "../../../_types"; import type { Board } from "../../../_types";
import { import { BoardAccessDisplayRow, BoardAccessSelectRow } from "./board-access-table-rows";
BoardAccessDisplayRow,
BoardAccessSelectRow,
} from "./board-access-table-rows";
import type { BoardAccessFormType, OnCountChange } from "./form"; import type { BoardAccessFormType, OnCountChange } from "./form";
import { FormProvider, useForm } from "./form"; import { FormProvider, useForm } from "./form";
import { UserSelectModal } from "./user-select-modal"; import { UserSelectModal } from "./user-select-modal";
@@ -35,18 +21,11 @@ export interface FormProps {
onCountChange: OnCountChange; onCountChange: OnCountChange;
} }
export const UsersForm = ({ export const UsersForm = ({ board, initialPermissions, onCountChange }: FormProps) => {
board, const { mutate, isPending } = clientApi.board.saveUserBoardPermissions.useMutation();
initialPermissions,
onCountChange,
}: FormProps) => {
const { mutate, isPending } =
clientApi.board.saveUserBoardPermissions.useMutation();
const utils = clientApi.useUtils(); const utils = clientApi.useUtils();
const [users, setUsers] = useState<Map<string, User>>( const [users, setUsers] = useState<Map<string, User>>(
new Map( new Map(initialPermissions.userPermissions.map(({ user }) => [user.id, user])),
initialPermissions.userPermissions.map(({ user }) => [user.id, user]),
),
); );
const { openModal } = useModalAction(UserSelectModal); const { openModal } = useModalAction(UserSelectModal);
const t = useI18n(); const t = useI18n();
@@ -81,9 +60,7 @@ export const UsersForm = ({
const presentUserIds = form.values.items.map(({ itemId: id }) => id); const presentUserIds = form.values.items.map(({ itemId: id }) => id);
openModal({ openModal({
presentUserIds: board.creatorId presentUserIds: board.creatorId ? presentUserIds.concat(board.creatorId) : presentUserIds,
? presentUserIds.concat(board.creatorId)
: presentUserIds,
onSelect: (user) => { onSelect: (user) => {
setUsers((prev) => new Map(prev).set(user.id, user)); setUsers((prev) => new Map(prev).set(user.id, user));
form.setFieldValue("items", [ form.setFieldValue("items", [
@@ -111,17 +88,12 @@ export const UsersForm = ({
</TableThead> </TableThead>
<TableTbody> <TableTbody>
{board.creator && ( {board.creator && (
<BoardAccessDisplayRow <BoardAccessDisplayRow itemContent={<UserItemContent user={board.creator} />} permission="board-full" />
itemContent={<UserItemContent user={board.creator} />}
permission="board-full"
/>
)} )}
{form.values.items.map((row, index) => ( {form.values.items.map((row, index) => (
<BoardAccessSelectRow <BoardAccessSelectRow
key={row.itemId} key={row.itemId}
itemContent={ itemContent={<UserItemContent user={users.get(row.itemId)!} />}
<UserItemContent user={users.get(row.itemId)!} />
}
permission={row.permission} permission={row.permission}
index={index} index={index}
onCountChange={onCountChange} onCountChange={onCountChange}
@@ -131,11 +103,7 @@ export const UsersForm = ({
</Table> </Table>
<Group justify="space-between"> <Group justify="space-between">
<Button <Button rightSection={<IconPlus size="1rem" />} variant="light" onClick={handleAddUser}>
rightSection={<IconPlus size="1rem" />}
variant="light"
onClick={handleAddUser}
>
{t("common.action.add")} {t("common.action.add")}
</Button> </Button>
<Button type="submit" loading={isPending} color="teal"> <Button type="submit" loading={isPending} color="teal">
@@ -154,12 +122,7 @@ const UserItemContent = ({ user }: { user: User }) => {
<Box visibleFrom="xs"> <Box visibleFrom="xs">
<UserAvatar user={user} size="sm" /> <UserAvatar user={user} size="sm" />
</Box> </Box>
<Anchor <Anchor component={Link} href={`/manage/users/${user.id}`} size="sm" style={{ whiteSpace: "nowrap" }}>
component={Link}
href={`/manage/users/${user.id}`}
size="sm"
style={{ whiteSpace: "nowrap" }}
>
{user.name} {user.name}
</Anchor> </Anchor>
</Group> </Group>
@@ -12,11 +12,7 @@ import { UserAvatar } from "@homarr/ui";
interface InnerProps { interface InnerProps {
presentUserIds: string[]; presentUserIds: string[];
onSelect: (props: { onSelect: (props: { id: string; name: string; image: string }) => void | Promise<void>;
id: string;
name: string;
image: string;
}) => void | Promise<void>;
confirmLabel?: string; confirmLabel?: string;
} }
@@ -24,66 +20,59 @@ interface UserSelectFormType {
userId: string; userId: string;
} }
export const UserSelectModal = createModal<InnerProps>( export const UserSelectModal = createModal<InnerProps>(({ actions, innerProps }) => {
({ actions, innerProps }) => { const t = useI18n();
const t = useI18n(); const { data: users, isPending } = clientApi.user.selectable.useQuery();
const { data: users, isPending } = clientApi.user.selectable.useQuery(); const [loading, setLoading] = useState(false);
const [loading, setLoading] = useState(false); const form = useForm<UserSelectFormType>();
const form = useForm<UserSelectFormType>(); const handleSubmitAsync = async (values: UserSelectFormType) => {
const handleSubmit = async (values: UserSelectFormType) => { const currentUser = users?.find((user) => user.id === values.userId);
const currentUser = users?.find((user) => user.id === values.userId); if (!currentUser) return;
if (!currentUser) return; setLoading(true);
setLoading(true); await innerProps.onSelect({
await innerProps.onSelect({ id: currentUser.id,
id: currentUser.id, name: currentUser.name ?? "",
name: currentUser.name ?? "", image: currentUser.image ?? "",
image: currentUser.image ?? "", });
});
setLoading(false); setLoading(false);
actions.closeModal(); actions.closeModal();
}; };
const confirmLabel = innerProps.confirmLabel ?? t("common.action.add"); const confirmLabel = innerProps.confirmLabel ?? t("common.action.add");
const currentUser = users?.find((user) => user.id === form.values.userId); const currentUser = users?.find((user) => user.id === form.values.userId);
return ( return (
<form onSubmit={form.onSubmit((values) => void handleSubmit(values))}> <form onSubmit={form.onSubmit((values) => void handleSubmitAsync(values))}>
<Stack> <Stack>
<Select <Select
{...form.getInputProps("userId")} {...form.getInputProps("userId")}
label={t("user.action.select.label")} label={t("user.action.select.label")}
searchable searchable
clearable clearable
leftSection={ leftSection={
isPending ? ( isPending ? <Loader size="xs" /> : currentUser ? <UserAvatar user={currentUser} size="xs" /> : undefined
<Loader size="xs" /> }
) : currentUser ? ( nothingFoundMessage={t("user.action.select.notFound")}
<UserAvatar user={currentUser} size="xs" /> renderOption={createRenderOption(users ?? [])}
) : undefined limit={5}
} data={users
nothingFoundMessage={t("user.action.select.notFound")} ?.filter((user) => !innerProps.presentUserIds.includes(user.id))
renderOption={createRenderOption(users ?? [])} .map((user) => ({ value: user.id, label: user.name ?? "" }))}
limit={5} />
data={users <Group justify="end">
?.filter((user) => !innerProps.presentUserIds.includes(user.id)) <Button variant="default" onClick={actions.closeModal}>
.map((user) => ({ value: user.id, label: user.name ?? "" }))} {t("common.action.cancel")}
/> </Button>
<Group justify="end"> <Button type="submit" loading={loading}>
<Button variant="default" onClick={actions.closeModal}> {confirmLabel}
{t("common.action.cancel")} </Button>
</Button> </Group>
<Button type="submit" loading={loading}> </Stack>
{confirmLabel} </form>
</Button> );
</Group> }).withOptions({
</Stack> defaultTitle: (t) => t("board.setting.section.access.permission.userSelect.title"),
</form>
);
},
).withOptions({
defaultTitle: (t) =>
t("board.setting.section.access.permission.userSelect.title"),
}); });
const iconProps = { const iconProps = {
@@ -93,9 +82,7 @@ const iconProps = {
size: "1rem", size: "1rem",
}; };
const createRenderOption = ( const createRenderOption = (users: RouterOutputs["user"]["selectable"]): SelectProps["renderOption"] =>
users: RouterOutputs["user"]["selectable"],
): SelectProps["renderOption"] =>
function InnerRenderRoot({ option, checked }) { function InnerRenderRoot({ option, checked }) {
const user = users.find((user) => user.id === option.value); const user = users.find((user) => user.id === option.value);
if (!user) return null; if (!user) return null;
@@ -104,9 +91,7 @@ const createRenderOption = (
<Group flex="1" gap="xs"> <Group flex="1" gap="xs">
<UserAvatar user={user} size="xs" /> <UserAvatar user={user} size="xs" />
{option.label} {option.label}
{checked && ( {checked && <IconCheck style={{ marginInlineStart: "auto" }} {...iconProps} />}
<IconCheck style={{ marginInlineStart: "auto" }} {...iconProps} />
)}
</Group> </Group>
); );
}; };
@@ -2,16 +2,13 @@
import { Button, Grid, Group, Stack, TextInput } from "@mantine/core"; import { Button, Grid, Group, Stack, TextInput } from "@mantine/core";
import { import { backgroundImageAttachments, backgroundImageRepeats, backgroundImageSizes } from "@homarr/definitions";
backgroundImageAttachments, import { useZodForm } from "@homarr/form";
backgroundImageRepeats,
backgroundImageSizes,
} from "@homarr/definitions";
import { useForm } from "@homarr/form";
import type { TranslationObject } from "@homarr/translation"; import type { TranslationObject } from "@homarr/translation";
import { useI18n } from "@homarr/translation/client"; import { useI18n } from "@homarr/translation/client";
import type { SelectItemWithDescriptionBadge } from "@homarr/ui"; import type { SelectItemWithDescriptionBadge } from "@homarr/ui";
import { SelectWithDescriptionBadge } from "@homarr/ui"; import { SelectWithDescriptionBadge } from "@homarr/ui";
import { validation } from "@homarr/validation";
import type { Board } from "../../_types"; import type { Board } from "../../_types";
import { useSavePartialSettingsMutation } from "./_shared"; import { useSavePartialSettingsMutation } from "./_shared";
@@ -21,9 +18,8 @@ interface Props {
} }
export const BackgroundSettingsContent = ({ board }: Props) => { export const BackgroundSettingsContent = ({ board }: Props) => {
const t = useI18n(); const t = useI18n();
const { mutate: savePartialSettings, isPending } = const { mutate: savePartialSettings, isPending } = useSavePartialSettingsMutation(board);
useSavePartialSettingsMutation(board); const form = useZodForm(validation.board.savePartialSettings, {
const form = useForm({
initialValues: { initialValues: {
backgroundImageUrl: board.backgroundImageUrl ?? "", backgroundImageUrl: board.backgroundImageUrl ?? "",
backgroundImageAttachment: board.backgroundImageAttachment, backgroundImageAttachment: board.backgroundImageAttachment,
@@ -36,14 +32,8 @@ export const BackgroundSettingsContent = ({ board }: Props) => {
"backgroundImageAttachment", "backgroundImageAttachment",
backgroundImageAttachments, backgroundImageAttachments,
); );
const backgroundImageSizeData = useBackgroundOptionData( const backgroundImageSizeData = useBackgroundOptionData("backgroundImageSize", backgroundImageSizes);
"backgroundImageSize", const backgroundImageRepeatData = useBackgroundOptionData("backgroundImageRepeat", backgroundImageRepeats);
backgroundImageSizes,
);
const backgroundImageRepeatData = useBackgroundOptionData(
"backgroundImageRepeat",
backgroundImageRepeats,
);
return ( return (
<form <form
@@ -95,13 +85,9 @@ export const BackgroundSettingsContent = ({ board }: Props) => {
); );
}; };
type BackgroundImageKey = type BackgroundImageKey = "backgroundImageAttachment" | "backgroundImageSize" | "backgroundImageRepeat";
| "backgroundImageAttachment"
| "backgroundImageSize"
| "backgroundImageRepeat";
type inferOptions<TKey extends BackgroundImageKey> = type inferOptions<TKey extends BackgroundImageKey> = TranslationObject["board"]["field"][TKey]["option"];
TranslationObject["board"]["field"][TKey]["option"];
const useBackgroundOptionData = < const useBackgroundOptionData = <
TKey extends BackgroundImageKey, TKey extends BackgroundImageKey,
@@ -119,9 +105,7 @@ const useBackgroundOptionData = <
(value) => (value) =>
({ ({
label: t(`board.field.${key}.option.${value as string}.label` as never), label: t(`board.field.${key}.option.${value as string}.label` as never),
description: t( description: t(`board.field.${key}.option.${value as string}.description` as never),
`board.field.${key}.option.${value as string}.description` as never,
),
value: value as string, value: value as string,
badge: badge:
data.defaultValue === value data.defaultValue === value
@@ -17,8 +17,9 @@ import {
} from "@mantine/core"; } from "@mantine/core";
import { useDisclosure } from "@mantine/hooks"; import { useDisclosure } from "@mantine/hooks";
import { useForm } from "@homarr/form"; import { useZodForm } from "@homarr/form";
import { useI18n } from "@homarr/translation/client"; import { useI18n } from "@homarr/translation/client";
import { validation } from "@homarr/validation";
import type { Board } from "../../_types"; import type { Board } from "../../_types";
import { generateColors } from "../../(content)/_theme"; import { generateColors } from "../../(content)/_theme";
@@ -33,7 +34,7 @@ const hexRegex = /^#[0-9a-fA-F]{6}$/;
const progressPercentageLabel = (value: number) => `${value}%`; const progressPercentageLabel = (value: number) => `${value}%`;
export const ColorSettingsContent = ({ board }: Props) => { export const ColorSettingsContent = ({ board }: Props) => {
const form = useForm({ const form = useZodForm(validation.board.savePartialSettings, {
initialValues: { initialValues: {
primaryColor: board.primaryColor, primaryColor: board.primaryColor,
secondaryColor: board.secondaryColor, secondaryColor: board.secondaryColor,
@@ -43,8 +44,7 @@ export const ColorSettingsContent = ({ board }: Props) => {
const [showPreview, { toggle }] = useDisclosure(false); const [showPreview, { toggle }] = useDisclosure(false);
const t = useI18n(); const t = useI18n();
const theme = useMantineTheme(); const theme = useMantineTheme();
const { mutate: savePartialSettings, isPending } = const { mutate: savePartialSettings, isPending } = useSavePartialSettingsMutation(board);
useSavePartialSettingsMutation(board);
return ( return (
<form <form
@@ -76,11 +76,7 @@ export const ColorSettingsContent = ({ board }: Props) => {
/> />
</Grid.Col> </Grid.Col>
<Grid.Col span={12}> <Grid.Col span={12}>
<Anchor onClick={toggle}> <Anchor onClick={toggle}>{showPreview ? t("common.preview.hide") : t("common.preview.show")}</Anchor>
{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 in={showPreview}>
@@ -114,15 +110,13 @@ export const ColorSettingsContent = ({ board }: Props) => {
}; };
interface ColorsPreviewProps { interface ColorsPreviewProps {
previewColor: string; previewColor: string | undefined;
} }
const ColorsPreview = ({ previewColor }: ColorsPreviewProps) => { const ColorsPreview = ({ previewColor }: ColorsPreviewProps) => {
const theme = useMantineTheme(); const theme = useMantineTheme();
const colors = hexRegex.test(previewColor) const colors = previewColor && hexRegex.test(previewColor) ? generateColors(previewColor) : generateColors("#000000");
? generateColors(previewColor)
: generateColors("#000000");
return ( return (
<Group gap={0} wrap="nowrap"> <Group gap={0} wrap="nowrap">
@@ -1,7 +1,91 @@
"use client"; "use client";
// TODO: add some sort of store (maybe directory on GitHub) import { Alert, Button, Group, Input, Stack } from "@mantine/core";
import { highlight, languages } from "prismjs";
import Editor from "react-simple-code-editor";
export const CustomCssSettingsContent = () => { import "~/styles/prismjs.scss";
return null;
import { IconInfoCircle } from "@tabler/icons-react";
import { useForm } from "@homarr/form";
import { useI18n, useScopedI18n } from "@homarr/translation/client";
import type { Board } from "../../_types";
import { useSavePartialSettingsMutation } from "./_shared";
import classes from "./customcss.module.css";
interface Props {
board: Board;
}
export const CustomCssSettingsContent = ({ board }: Props) => {
const t = useI18n();
const customCssT = useScopedI18n("board.field.customCss");
const { mutate: savePartialSettings, isPending } = useSavePartialSettingsMutation(board);
const form = useForm({
initialValues: {
customCss: board.customCss ?? "",
},
});
return (
<form
onSubmit={form.onSubmit((values) => {
savePartialSettings({
id: board.id,
...values,
});
})}
>
<Stack>
<CustomCssInput {...form.getInputProps("customCss")} />
<Alert variant="light" color="cyan" title={customCssT("customClassesAlert.title")} icon={<IconInfoCircle />}>
{customCssT("customClassesAlert.description")}
</Alert>
<Group justify="end">
<Button type="submit" loading={isPending} color="teal">
{t("common.action.saveChanges")}
</Button>
</Group>
</Stack>
</form>
);
};
interface CustomCssInputProps {
value?: string;
onChange: (value: string) => void;
}
const CustomCssInput = ({ value, onChange }: CustomCssInputProps) => {
const customCssT = useScopedI18n("board.field.customCss");
return (
<Input.Wrapper
label={customCssT("label")}
labelProps={{
htmlFor: "custom-css",
}}
description={customCssT("description")}
inputWrapperOrder={["label", "description", "input", "error"]}
>
<div className={classes.codeEditorRoot}>
<Editor
textareaId="custom-css"
onValueChange={onChange}
value={value ?? ""}
highlight={(code) => highlight(code, languages.extend("css", {}), "css")}
padding={10}
style={{
fontFamily: '"Fira code", "Fira Mono", monospace',
fontSize: 12,
minHeight: 250,
}}
/>
</div>
</Input.Wrapper>
);
}; };
@@ -20,8 +20,7 @@ export const DangerZoneSettingsContent = () => {
const { openModal } = useModalAction(BoardRenameModal); const { openModal } = useModalAction(BoardRenameModal);
const { mutate: changeVisibility, isPending: isChangeVisibilityPending } = const { mutate: changeVisibility, isPending: isChangeVisibilityPending } =
clientApi.board.changeBoardVisibility.useMutation(); clientApi.board.changeBoardVisibility.useMutation();
const { mutate: deleteBoard, isPending: isDeletePending } = const { mutate: deleteBoard, isPending: isDeletePending } = clientApi.board.deleteBoard.useMutation();
clientApi.board.deleteBoard.useMutation();
const utils = clientApi.useUtils(); const utils = clientApi.useUtils();
const visibility = board.isPublic ? "public" : "private"; const visibility = board.isPublic ? "public" : "private";
@@ -37,12 +36,8 @@ export const DangerZoneSettingsContent = () => {
const onVisibilityClick = useCallback(() => { const onVisibilityClick = useCallback(() => {
openConfirmModal({ openConfirmModal({
title: t( title: t(`section.dangerZone.action.visibility.confirm.${visibility}.title`),
`section.dangerZone.action.visibility.confirm.${visibility}.title`, children: t(`section.dangerZone.action.visibility.confirm.${visibility}.description`),
),
children: t(
`section.dangerZone.action.visibility.confirm.${visibility}.description`,
),
onConfirm: () => { onConfirm: () => {
changeVisibility( changeVisibility(
{ {
@@ -52,7 +47,7 @@ export const DangerZoneSettingsContent = () => {
{ {
onSettled() { onSettled() {
void utils.board.getBoardByName.invalidate({ name: board.name }); void utils.board.getBoardByName.invalidate({ name: board.name });
void utils.board.getDefaultBoard.invalidate(); void utils.board.getHomeBoard.invalidate();
}, },
}, },
); );
@@ -64,7 +59,7 @@ export const DangerZoneSettingsContent = () => {
changeVisibility, changeVisibility,
t, t,
utils.board.getBoardByName, utils.board.getBoardByName,
utils.board.getDefaultBoard, utils.board.getHomeBoard,
visibility, visibility,
openConfirmModal, openConfirmModal,
]); ]);
@@ -98,12 +93,8 @@ export const DangerZoneSettingsContent = () => {
<Divider /> <Divider />
<DangerZoneRow <DangerZoneRow
label={t("section.dangerZone.action.visibility.label")} label={t("section.dangerZone.action.visibility.label")}
description={t( description={t(`section.dangerZone.action.visibility.description.${visibility}`)}
`section.dangerZone.action.visibility.description.${visibility}`, buttonText={t(`section.dangerZone.action.visibility.button.${visibility}`)}
)}
buttonText={t(
`section.dangerZone.action.visibility.button.${visibility}`,
)}
onClick={onVisibilityClick} onClick={onVisibilityClick}
isPending={isChangeVisibilityPending} isPending={isChangeVisibilityPending}
/> />
@@ -127,13 +118,7 @@ interface DangerZoneRowProps {
onClick: () => void; onClick: () => void;
} }
const DangerZoneRow = ({ const DangerZoneRow = ({ label, description, buttonText, onClick, isPending }: DangerZoneRowProps) => {
label,
description,
buttonText,
onClick,
isPending,
}: DangerZoneRowProps) => {
return ( return (
<Group justify="space-between" px="md" className={classes.dangerZoneGroup}> <Group justify="space-between" px="md" className={classes.dangerZoneGroup}>
<Stack gap={0}> <Stack gap={0}>
@@ -143,12 +128,7 @@ const DangerZoneRow = ({
<Text size="sm">{description}</Text> <Text size="sm">{description}</Text>
</Stack> </Stack>
<Group justify="end" w={{ base: "100%", xs: "auto" }}> <Group justify="end" w={{ base: "100%", xs: "auto" }}>
<Button <Button variant="subtle" color="red" loading={isPending} onClick={onClick}>
variant="subtle"
color="red"
loading={isPending}
onClick={onClick}
>
{buttonText} {buttonText}
</Button> </Button>
</Group> </Group>
@@ -1,25 +1,15 @@
"use client"; "use client";
import { useEffect, useRef } from "react"; import { useEffect, useRef } from "react";
import { import { Button, Grid, Group, Loader, Stack, TextInput, Tooltip } from "@mantine/core";
Button, import { useDebouncedValue, useDocumentTitle, useFavicon } from "@mantine/hooks";
Grid,
Group,
Loader,
Stack,
TextInput,
Tooltip,
} from "@mantine/core";
import {
useDebouncedValue,
useDocumentTitle,
useFavicon,
} from "@mantine/hooks";
import { IconAlertTriangle } from "@tabler/icons-react"; import { IconAlertTriangle } from "@tabler/icons-react";
import { useForm } from "@homarr/form"; import { useZodForm } from "@homarr/form";
import { useI18n } from "@homarr/translation/client"; import { useI18n } from "@homarr/translation/client";
import { validation } from "@homarr/validation";
import { createMetaTitle } from "~/metadata";
import type { Board } from "../../_types"; import type { Board } from "../../_types";
import { useUpdateBoard } from "../../(content)/_client"; import { useUpdateBoard } from "../../(content)/_client";
import { useSavePartialSettingsMutation } from "./_shared"; import { useSavePartialSettingsMutation } from "./_shared";
@@ -36,22 +26,31 @@ export const GeneralSettingsContent = ({ board }: Props) => {
}); });
const { updateBoard } = useUpdateBoard(); const { updateBoard } = useUpdateBoard();
const { mutate: savePartialSettings, isPending } = const { mutate: savePartialSettings, isPending } = useSavePartialSettingsMutation(board);
useSavePartialSettingsMutation(board); const form = useZodForm(
const form = useForm({ validation.board.savePartialSettings
initialValues: { .pick({
pageTitle: board.pageTitle ?? "", pageTitle: true,
logoImageUrl: board.logoImageUrl ?? "", logoImageUrl: true,
metaTitle: board.metaTitle ?? "", metaTitle: true,
faviconImageUrl: board.faviconImageUrl ?? "", faviconImageUrl: true,
})
.required(),
{
initialValues: {
pageTitle: board.pageTitle ?? "",
logoImageUrl: board.logoImageUrl ?? "",
metaTitle: board.metaTitle ?? "",
faviconImageUrl: board.faviconImageUrl ?? "",
},
onValuesChange({ pageTitle }) {
updateBoard((previous) => ({
...previous,
pageTitle,
}));
},
}, },
onValuesChange({ pageTitle }) { );
updateBoard((previous) => ({
...previous,
pageTitle,
}));
},
});
const metaTitleStatus = useMetaTitlePreview(form.values.metaTitle); const metaTitleStatus = useMetaTitlePreview(form.values.metaTitle);
const faviconStatus = useFaviconPreview(form.values.faviconImageUrl); const faviconStatus = useFaviconPreview(form.values.faviconImageUrl);
@@ -94,7 +93,7 @@ export const GeneralSettingsContent = ({ board }: Props) => {
<Grid.Col span={{ xs: 12, md: 6 }}> <Grid.Col span={{ xs: 12, md: 6 }}>
<TextInput <TextInput
label={t("board.field.metaTitle.label")} label={t("board.field.metaTitle.label")}
placeholder="Default Board | Homarr" placeholder={createMetaTitle(t("board.content.metaTitle", { boardName: board.name }))}
rightSection={<PendingOrInvalidIndicator {...metaTitleStatus} />} rightSection={<PendingOrInvalidIndicator {...metaTitleStatus} />}
{...form.getInputProps("metaTitle")} {...form.getInputProps("metaTitle")}
/> />
@@ -126,22 +125,12 @@ export const GeneralSettingsContent = ({ board }: Props) => {
); );
}; };
const PendingOrInvalidIndicator = ({ const PendingOrInvalidIndicator = ({ isPending, isInvalid }: { isPending: boolean; isInvalid?: boolean }) => {
isPending,
isInvalid,
}: {
isPending: boolean;
isInvalid?: boolean;
}) => {
const t = useI18n(); const t = useI18n();
if (isInvalid) { if (isInvalid) {
return ( return (
<Tooltip <Tooltip multiline w={220} label={t("board.setting.section.general.unrecognizedLink")}>
multiline
w={220}
label={t("board.setting.section.general.unrecognizedLink")}
>
<IconAlertTriangle size="1rem" color="red" /> <IconAlertTriangle size="1rem" color="red" />
</Tooltip> </Tooltip>
); );
@@ -183,8 +172,7 @@ const useMetaTitlePreview = (title: string | null) => {
const validFaviconExtensions = ["ico", "png", "svg", "gif"]; const validFaviconExtensions = ["ico", "png", "svg", "gif"];
const isValidUrl = (url: string) => const isValidUrl = (url: string) =>
url.includes("/") && url.includes("/") && validFaviconExtensions.some((extension) => url.endsWith(`.${extension}`));
validFaviconExtensions.some((extension) => url.endsWith(`.${extension}`));
const useFaviconPreview = (url: string | null) => { const useFaviconPreview = (url: string | null) => {
const [faviconDebounced] = useDebouncedValue(url ?? "", 500); const [faviconDebounced] = useDebouncedValue(url ?? "", 500);
@@ -2,8 +2,9 @@
import { Button, Grid, Group, Input, Slider, Stack } from "@mantine/core"; import { Button, Grid, Group, Input, Slider, Stack } from "@mantine/core";
import { useForm } from "@homarr/form"; import { useZodForm } from "@homarr/form";
import { useI18n } from "@homarr/translation/client"; import { useI18n } from "@homarr/translation/client";
import { validation } from "@homarr/validation";
import type { Board } from "../../_types"; import type { Board } from "../../_types";
import { useSavePartialSettingsMutation } from "./_shared"; import { useSavePartialSettingsMutation } from "./_shared";
@@ -13,9 +14,8 @@ interface Props {
} }
export const LayoutSettingsContent = ({ board }: Props) => { export const LayoutSettingsContent = ({ board }: Props) => {
const t = useI18n(); const t = useI18n();
const { mutate: savePartialSettings, isPending } = const { mutate: savePartialSettings, isPending } = useSavePartialSettingsMutation(board);
useSavePartialSettingsMutation(board); const form = useZodForm(validation.board.savePartialSettings.pick({ columnCount: true }).required(), {
const form = useForm({
initialValues: { initialValues: {
columnCount: board.columnCount, columnCount: board.columnCount,
}, },
@@ -34,13 +34,7 @@ export const LayoutSettingsContent = ({ board }: Props) => {
<Grid> <Grid>
<Grid.Col span={{ sm: 12, md: 6 }}> <Grid.Col span={{ sm: 12, md: 6 }}>
<Input.Wrapper label={t("board.field.columnCount.label")}> <Input.Wrapper label={t("board.field.columnCount.label")}>
<Slider <Slider mt="xs" min={1} max={24} step={1} {...form.getInputProps("columnCount")} />
mt="xs"
min={1}
max={24}
step={1}
{...form.getInputProps("columnCount")}
/>
</Input.Wrapper> </Input.Wrapper>
</Grid.Col> </Grid.Col>
</Grid> </Grid>
@@ -7,7 +7,7 @@ export const useSavePartialSettingsMutation = (board: Board) => {
return clientApi.board.savePartialBoardSettings.useMutation({ return clientApi.board.savePartialBoardSettings.useMutation({
onSettled() { onSettled() {
void utils.board.getBoardByName.invalidate({ name: board.name }); void utils.board.getBoardByName.invalidate({ name: board.name });
void utils.board.getDefaultBoard.invalidate(); void utils.board.getHomeBoard.invalidate();
}, },
}); });
}; };
@@ -0,0 +1,22 @@
.codeEditorFooter {
border-bottom-left-radius: var(--mantine-radius-sm);
border-bottom-right-radius: var(--mantine-radius-sm);
background-color: light-dark(var(--mantine-color-gray-1), var(--mantine-color-dark-7));
}
.codeEditorRoot {
margin-top: 4px;
border-color: light-dark(var(--mantine-color-gray-4), var(--mantine-color-dark-4));
border-width: 1px;
border-style: solid;
border-radius: var(--mantine-radius-sm);
}
.codeEditor {
background-color: light-dark(white, var(--mantine-color-dark-6));
font-size: var(--mantine-font-size-xs);
}
.codeEditor ::placeholder {
color: light-dark(var(--mantine-color-gray-5), var(--mantine-color-dark-3));
}
@@ -1,14 +1,6 @@
import type { PropsWithChildren } from "react"; import type { PropsWithChildren } from "react";
import { notFound } from "next/navigation"; import { notFound } from "next/navigation";
import { import { AccordionControl, AccordionItem, AccordionPanel, Container, Stack, Text, Title } from "@mantine/core";
AccordionControl,
AccordionItem,
AccordionPanel,
Container,
Stack,
Text,
Title,
} from "@mantine/core";
import { import {
IconAlertTriangle, IconAlertTriangle,
IconBrush, IconBrush,
@@ -26,7 +18,7 @@ import type { TranslationObject } from "@homarr/translation";
import { getScopedI18n } from "@homarr/translation/server"; import { getScopedI18n } from "@homarr/translation/server";
import type { TablerIcon } from "@homarr/ui"; import type { TablerIcon } from "@homarr/ui";
import { getBoardPermissions } from "~/components/board/permissions/server"; import { getBoardPermissionsAsync } from "~/components/board/permissions/server";
import { ActiveTabAccordion } from "../../../../../components/active-tab-accordion"; import { ActiveTabAccordion } from "../../../../../components/active-tab-accordion";
import { AccessSettingsContent } from "./_access"; import { AccessSettingsContent } from "./_access";
import { BackgroundSettingsContent } from "./_background"; import { BackgroundSettingsContent } from "./_background";
@@ -45,10 +37,10 @@ interface Props {
}; };
} }
const getBoardAndPermissions = async (params: Props["params"]) => { const getBoardAndPermissionsAsync = async (params: Props["params"]) => {
try { try {
const board = await api.board.getBoardByName({ name: params.name }); const board = await api.board.getBoardByName({ name: params.name });
const { hasFullAccess } = await getBoardPermissions(board); const { hasFullAccess } = await getBoardPermissionsAsync(board);
const permissions = hasFullAccess const permissions = hasFullAccess
? await api.board.getBoardPermissions({ id: board.id }) ? await api.board.getBoardPermissions({ id: board.id })
: { : {
@@ -69,22 +61,16 @@ const getBoardAndPermissions = async (params: Props["params"]) => {
} }
}; };
export default async function BoardSettingsPage({ export default async function BoardSettingsPage({ params, searchParams }: Props) {
params, const { board, permissions } = await getBoardAndPermissionsAsync(params);
searchParams, const { hasFullAccess } = await getBoardPermissionsAsync(board);
}: Props) {
const { board, permissions } = await getBoardAndPermissions(params);
const { hasFullAccess } = await getBoardPermissions(board);
const t = await getScopedI18n("board.setting"); const t = await getScopedI18n("board.setting");
return ( return (
<Container> <Container>
<Stack> <Stack>
<Title>{t("title", { boardName: capitalize(board.name) })}</Title> <Title>{t("title", { boardName: capitalize(board.name) })}</Title>
<ActiveTabAccordion <ActiveTabAccordion variant="separated" defaultValue={searchParams.tab ?? "general"}>
variant="separated"
defaultValue={searchParams.tab ?? "general"}
>
<AccordionItemFor value="general" icon={IconSettings}> <AccordionItemFor value="general" icon={IconSettings}>
<GeneralSettingsContent board={board} /> <GeneralSettingsContent board={board} />
</AccordionItemFor> </AccordionItemFor>
@@ -98,22 +84,14 @@ export default async function BoardSettingsPage({
<ColorSettingsContent board={board} /> <ColorSettingsContent board={board} />
</AccordionItemFor> </AccordionItemFor>
<AccordionItemFor value="customCss" icon={IconFileTypeCss}> <AccordionItemFor value="customCss" icon={IconFileTypeCss}>
<CustomCssSettingsContent /> <CustomCssSettingsContent board={board} />
</AccordionItemFor> </AccordionItemFor>
{hasFullAccess && ( {hasFullAccess && (
<> <>
<AccordionItemFor value="access" icon={IconUser}> <AccordionItemFor value="access" icon={IconUser}>
<AccessSettingsContent <AccessSettingsContent board={board} initialPermissions={permissions} />
board={board}
initialPermissions={permissions}
/>
</AccordionItemFor> </AccordionItemFor>
<AccordionItemFor <AccordionItemFor value="dangerZone" icon={IconAlertTriangle} danger noPadding>
value="dangerZone"
icon={IconAlertTriangle}
danger
noPadding
>
<DangerZoneSettingsContent /> <DangerZoneSettingsContent />
</AccordionItemFor> </AccordionItemFor>
</> </>
@@ -131,13 +109,7 @@ type AccordionItemForProps = PropsWithChildren<{
noPadding?: boolean; noPadding?: boolean;
}>; }>;
const AccordionItemFor = async ({ const AccordionItemFor = async ({ value, children, icon: Icon, danger, noPadding }: AccordionItemForProps) => {
value,
children,
icon: Icon,
danger,
noPadding,
}: AccordionItemForProps) => {
const t = await getScopedI18n("board.setting.section"); const t = await getScopedI18n("board.setting.section");
return ( return (
<AccordionItem <AccordionItem
@@ -158,13 +130,7 @@ const AccordionItemFor = async ({
{t(`${value}.title`)} {t(`${value}.title`)}
</Text> </Text>
</AccordionControl> </AccordionControl>
<AccordionPanel <AccordionPanel styles={noPadding ? { content: { paddingRight: 0, paddingLeft: 0 } } : undefined}>
styles={
noPadding
? { content: { paddingRight: 0, paddingLeft: 0 } }
: undefined
}
>
{children} {children}
</AccordionPanel> </AccordionPanel>
</AccordionItem> </AccordionItem>
@@ -12,17 +12,18 @@ import { ClientShell } from "~/components/layout/shell";
import type { Board } from "./_types"; import type { Board } from "./_types";
import { BoardProvider } from "./(content)/_context"; import { BoardProvider } from "./(content)/_context";
import type { Params } from "./(content)/_creator"; import type { Params } from "./(content)/_creator";
import { CustomCss } from "./(content)/_custom-css";
import { BoardMantineProvider } from "./(content)/_theme"; import { BoardMantineProvider } from "./(content)/_theme";
interface CreateBoardLayoutProps<TParams extends Params> { interface CreateBoardLayoutProps<TParams extends Params> {
headerActions: JSX.Element; headerActions: JSX.Element;
getInitialBoard: (params: TParams) => Promise<Board>; getInitialBoardAsync: (params: TParams) => Promise<Board>;
isBoardContentPage: boolean; isBoardContentPage: boolean;
} }
export const createBoardLayout = <TParams extends Params>({ export const createBoardLayout = <TParams extends Params>({
headerActions, headerActions,
getInitialBoard, getInitialBoardAsync: getInitialBoard,
isBoardContentPage, isBoardContentPage,
}: CreateBoardLayoutProps<TParams>) => { }: CreateBoardLayoutProps<TParams>) => {
const Layout = async ({ const Layout = async ({
@@ -41,12 +42,10 @@ export const createBoardLayout = <TParams extends Params>({
}); });
return ( return (
<GlobalItemServerDataRunner <GlobalItemServerDataRunner board={initialBoard} shouldRun={isBoardContentPage}>
board={initialBoard}
shouldRun={isBoardContentPage}
>
<BoardProvider initialBoard={initialBoard}> <BoardProvider initialBoard={initialBoard}>
<BoardMantineProvider> <BoardMantineProvider>
<CustomCss />
<ClientShell hasNavigation={false}> <ClientShell hasNavigation={false}>
<MainHeader <MainHeader
logo={<BoardLogoWithTitle size="md" hideTitleOnMobile />} logo={<BoardLogoWithTitle size="md" hideTitleOnMobile />}
@@ -1,14 +1,11 @@
import type { RouterOutputs } from "@homarr/api"; import type { RouterOutputs } from "@homarr/api";
import type { WidgetKind } from "@homarr/definitions"; import type { WidgetKind } from "@homarr/definitions";
export type Board = RouterOutputs["board"]["getDefaultBoard"]; export type Board = RouterOutputs["board"]["getHomeBoard"];
export type Section = Board["sections"][number]; export type Section = Board["sections"][number];
export type Item = Section["items"][number]; export type Item = Section["items"][number];
export type CategorySection = Extract<Section, { kind: "category" }>; export type CategorySection = Extract<Section, { kind: "category" }>;
export type EmptySection = Extract<Section, { kind: "empty" }>; export type EmptySection = Extract<Section, { kind: "empty" }>;
export type ItemOfKind<TKind extends WidgetKind> = Extract< export type ItemOfKind<TKind extends WidgetKind> = Extract<Item, { kind: TKind }>;
Item,
{ kind: TKind }
>;
+8 -10
View File
@@ -5,14 +5,12 @@ type PropsWithChildren = Required<React.PropsWithChildren>;
export const composeWrappers = ( export const composeWrappers = (
wrappers: React.FunctionComponent<PropsWithChildren>[], wrappers: React.FunctionComponent<PropsWithChildren>[],
): React.FunctionComponent<PropsWithChildren> => { ): React.FunctionComponent<PropsWithChildren> => {
return wrappers return wrappers.reverse().reduce((Acc, Current): React.FunctionComponent<PropsWithChildren> => {
.reverse() // eslint-disable-next-line react/display-name
.reduce((Acc, Current): React.FunctionComponent<PropsWithChildren> => { return (props) => (
// eslint-disable-next-line react/display-name <Current>
return (props) => ( <Acc {...props} />
<Current> </Current>
<Acc {...props} /> );
</Current> });
);
});
}; };
@@ -4,11 +4,8 @@ import { useRouter } from "next/navigation";
import { Button, PasswordInput, Stack, TextInput } from "@mantine/core"; import { Button, PasswordInput, Stack, TextInput } from "@mantine/core";
import { clientApi } from "@homarr/api/client"; import { clientApi } from "@homarr/api/client";
import { useForm, zodResolver } from "@homarr/form"; import { useZodForm } from "@homarr/form";
import { import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
showErrorNotification,
showSuccessNotification,
} from "@homarr/notifications";
import { useScopedI18n } from "@homarr/translation/client"; import { useScopedI18n } from "@homarr/translation/client";
import type { z } from "@homarr/validation"; import type { z } from "@homarr/validation";
import { validation } from "@homarr/validation"; import { validation } from "@homarr/validation";
@@ -16,12 +13,8 @@ import { validation } from "@homarr/validation";
export const InitUserForm = () => { export const InitUserForm = () => {
const router = useRouter(); const router = useRouter();
const t = useScopedI18n("user"); const t = useScopedI18n("user");
const { mutateAsync, error, isPending } = const { mutateAsync, error, isPending } = clientApi.user.initUser.useMutation();
clientApi.user.initUser.useMutation(); const form = useZodForm(validation.user.init, {
const form = useForm<FormType>({
validate: zodResolver(validation.user.init),
validateInputOnBlur: true,
validateInputOnChange: true,
initialValues: { initialValues: {
username: "", username: "",
password: "", password: "",
@@ -29,7 +22,7 @@ export const InitUserForm = () => {
}, },
}); });
const handleSubmit = async (values: FormType) => { const handleSubmitAsync = async (values: FormType) => {
await mutateAsync(values, { await mutateAsync(values, {
onSuccess: () => { onSuccess: () => {
showSuccessNotification({ showSuccessNotification({
@@ -51,23 +44,14 @@ export const InitUserForm = () => {
<Stack gap="xl"> <Stack gap="xl">
<form <form
onSubmit={form.onSubmit( onSubmit={form.onSubmit(
(values) => void handleSubmit(values), (values) => void handleSubmitAsync(values),
(err) => console.log(err), (err) => console.log(err),
)} )}
> >
<Stack gap="lg"> <Stack gap="lg">
<TextInput <TextInput label={t("field.username.label")} {...form.getInputProps("username")} />
label={t("field.username.label")} <PasswordInput label={t("field.password.label")} {...form.getInputProps("password")} />
{...form.getInputProps("username")} <PasswordInput label={t("field.passwordConfirm.label")} {...form.getInputProps("confirmPassword")} />
/>
<PasswordInput
label={t("field.password.label")}
{...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>
+5 -8
View File
@@ -1,9 +1,9 @@
import type { Metadata, Viewport } from "next"; import type { Metadata, Viewport } from "next";
import { Inter } from "next/font/google"; import { Inter } from "next/font/google";
import "@homarr/ui/styles.css";
import "@homarr/notifications/styles.css"; import "@homarr/notifications/styles.css";
import "@homarr/spotlight/styles.css"; import "@homarr/spotlight/styles.css";
import "@mantine/core/styles.css";
import { ColorSchemeScript, createTheme, MantineProvider } from "@mantine/core"; import { ColorSchemeScript, createTheme, MantineProvider } from "@mantine/core";
@@ -11,6 +11,7 @@ import { auth } from "@homarr/auth/next";
import { ModalProvider } from "@homarr/modals"; import { ModalProvider } from "@homarr/modals";
import { Notifications } from "@homarr/notifications"; import { Notifications } from "@homarr/notifications";
import { Analytics } from "~/components/layout/analytics";
import { JotaiProvider } from "./_client-providers/jotai"; import { JotaiProvider } from "./_client-providers/jotai";
import { NextInternationalProvider } from "./_client-providers/next-international"; import { NextInternationalProvider } from "./_client-providers/next-international";
import { AuthProvider } from "./_client-providers/session"; import { AuthProvider } from "./_client-providers/session";
@@ -48,10 +49,7 @@ export const viewport: Viewport = {
], ],
}; };
export default function Layout(props: { export default function Layout(props: { children: React.ReactNode; params: { locale: string } }) {
children: React.ReactNode;
params: { locale: string };
}) {
const colorScheme = "dark"; const colorScheme = "dark";
const StackedProvider = composeWrappers([ const StackedProvider = composeWrappers([
@@ -61,9 +59,7 @@ export default function Layout(props: {
}, },
(innerProps) => <JotaiProvider {...innerProps} />, (innerProps) => <JotaiProvider {...innerProps} />,
(innerProps) => <TRPCReactProvider {...innerProps} />, (innerProps) => <TRPCReactProvider {...innerProps} />,
(innerProps) => ( (innerProps) => <NextInternationalProvider {...innerProps} locale={props.params.locale} />,
<NextInternationalProvider {...innerProps} locale={props.params.locale} />
),
(innerProps) => ( (innerProps) => (
<MantineProvider <MantineProvider
{...innerProps} {...innerProps}
@@ -81,6 +77,7 @@ export default function Layout(props: {
<html lang="en" suppressHydrationWarning> <html lang="en" suppressHydrationWarning>
<head> <head>
<ColorSchemeScript defaultColorScheme={colorScheme} /> <ColorSchemeScript defaultColorScheme={colorScheme} />
<Analytics />
</head> </head>
<body className={["font-sans", fontSans.variable].join(" ")}> <body className={["font-sans", fontSans.variable].join(" ")}>
<StackedProvider> <StackedProvider>
@@ -1,17 +1,5 @@
import { Center, Stack, Text, Title } from "@mantine/core"; import { notFound } from "next/navigation";
import { getScopedI18n } from "@homarr/translation/server"; export default function NotFound() {
return notFound();
export default async function NotFound() {
const t = await getScopedI18n("management.notFound");
return (
<Center h="100%">
<Stack align="center">
<Title order={1} tt="uppercase">
{t("title")}
</Title>
<Text>{t("text")}</Text>
</Stack>
</Center>
);
} }
@@ -0,0 +1,37 @@
.bannerContainer {
padding: 3rem;
border-radius: 8px;
overflow: hidden;
background: linear-gradient(
130deg,
#fa52521f 0%,
var(--mantine-color-dark-6) 35%,
var(--mantine-color-dark-6) 100%
) !important;
}
.scrollContainer {
height: 100%;
transform: rotateZ(10deg);
}
@keyframes scrolling {
0% {
transform: translateY(0);
}
100% {
transform: translateY(-50%);
}
}
.scrollAnimationContainer {
animation: scrolling;
animation-timing-function: linear;
animation-iteration-count: infinite;
}
@media (prefers-reduced-motion: reduce) {
.scrollAnimationContainer {
animation: none !important;
}
}
@@ -0,0 +1,78 @@
import { Box, Grid, GridCol, Group, Image, Stack, Title } from "@mantine/core";
import { splitToNChunks } from "@homarr/common";
import classes from "./hero-banner.module.css";
const icons = [
"https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/svg/homarr.svg",
"https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/svg/sabnzbd.svg",
"https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/svg/deluge.svg",
"https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/svg/radarr.svg",
"https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/svg/sonarr.svg",
"https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/svg/lidarr.svg",
"https://cdn.jsdelivr.net/gh/loganmarchione/homelab-svg-assets/assets/pihole.svg",
"https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/png/dashdot.png",
"https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/svg/overseerr.svg",
"https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/svg/plex.svg",
"https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/svg/jellyfin.svg",
"https://cdn.jsdelivr.net/gh/loganmarchione/homelab-svg-assets/assets/homeassistant.svg",
"https://cdn.jsdelivr.net/gh/loganmarchione/homelab-svg-assets/assets/freshrss.svg",
"https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/svg/readarr.svg",
"https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/svg/transmission.svg",
"https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/svg/qbittorrent.svg",
"https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/png/nzbget.png",
"https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/svg/openmediavault.svg",
"https://cdn.jsdelivr.net/gh/loganmarchione/homelab-svg-assets/assets/docker.svg",
"https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/svg/jellyseerr.svg",
"https://cdn.jsdelivr.net/gh/loganmarchione/homelab-svg-assets/assets/adguardhome.svg",
"https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/png/tdarr.png",
"https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/svg/prowlarr.svg",
];
const countIconGroups = 3;
const animationDurationInSeconds = 12;
export const HeroBanner = () => {
const arrayInChunks = splitToNChunks(icons, countIconGroups);
const gridSpan = 12 / countIconGroups;
return (
<Box className={classes.bannerContainer} bg="dark.6" pos="relative">
<Stack gap={0}>
<Title order={2} c="dimmed">
Welcome back to your
</Title>
<Group gap="xs">
<Image src="/logo/logo.png" w={40} h={40} />
<Title>Homarr Dashboard</Title>
</Group>
</Stack>
<Box className={classes.scrollContainer} w={"30%"} top={0} right={0} pos="absolute">
<Grid>
{Array(countIconGroups)
.fill(0)
.map((_, columnIndex) => (
<GridCol key={`grid-column-${columnIndex}`} span={gridSpan}>
<Stack
className={classes.scrollAnimationContainer}
style={{
animationDuration: `${animationDurationInSeconds - columnIndex}s`,
}}
>
{arrayInChunks[columnIndex]?.map((icon, index) => (
<Image key={`grid-column-${columnIndex}-scroll-1-${index}`} src={icon} radius="md" w={50} h={50} />
))}
{/* This is used for making the animation seem seamless */}
{arrayInChunks[columnIndex]?.map((icon, index) => (
<Image key={`grid-column-${columnIndex}-scroll-2-${index}`} src={icon} radius="md" w={50} h={50} />
))}
</Stack>
</GridCol>
))}
</Grid>
</Box>
</Box>
);
};
@@ -0,0 +1,3 @@
.contributorCard {
background-color: light-dark(var(--mantine-color-gray-1), var(--mantine-color-dark-5));
}
@@ -1,36 +0,0 @@
.root {
border-radius: var(--mantine-radius-sm);
background-color: light-dark(
var(--mantine-color-gray-0),
var(--mantine-color-dark-6)
);
}
.item {
background-color: light-dark(
var(--mantine-color-gray-0),
var(--mantine-color-dark-6)
);
border: 1px solid transparent;
position: relative;
z-index: 0;
transition: transform 150ms ease;
&[data-active] {
transform: scale(1.015);
z-index: 1;
background-color: var(--mantine-color-body);
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-md);
}
}
.chevron {
&[data-rotate] {
transform: rotate(-90deg);
}
}
@@ -4,7 +4,11 @@ import {
AccordionControl, AccordionControl,
AccordionItem, AccordionItem,
AccordionPanel, AccordionPanel,
AspectRatio,
Avatar,
Card,
Center, Center,
Flex,
Group, Group,
List, List,
ListItem, ListItem,
@@ -17,16 +21,18 @@ import { setStaticParamsLocale } from "next-international/server";
import { getScopedI18n, getStaticParams } from "@homarr/translation/server"; import { getScopedI18n, getStaticParams } from "@homarr/translation/server";
import { createMetaTitle } from "~/metadata";
import { getPackageAttributesAsync } from "~/versions/package-reader"; import { getPackageAttributesAsync } from "~/versions/package-reader";
import contributorsData from "../../../../../../../static-data/contributors.json";
import translatorsData from "../../../../../../../static-data/translators.json";
import logo from "../../../../../public/logo/logo.png"; import logo from "../../../../../public/logo/logo.png";
import classes from "./accordion.module.css"; import classes from "./about.module.css";
export async function generateMetadata() { export async function generateMetadata() {
const t = await getScopedI18n("management"); const t = await getScopedI18n("management");
const metaTitle = `${t("metaTitle")} • Homarr`;
return { return {
title: metaTitle, title: createMetaTitle(t("metaTitle")),
}; };
} }
@@ -49,26 +55,60 @@ export default async function AboutPage({ params: { locale } }: PageProps) {
<Title order={1} tt="uppercase"> <Title order={1} tt="uppercase">
Homarr Homarr
</Title> </Title>
<Title order={2}> <Title order={2}>{t("version", { version: attributes.version })}</Title>
{t("version", { version: attributes.version })}
</Title>
</Stack> </Stack>
</Group> </Group>
</Center> </Center>
<Text mb="xl">{t("text")}</Text> <Text mb="xl">{t("text")}</Text>
<Accordion classNames={classes}> <Accordion defaultValue="contributors" variant="filled" radius="md">
<AccordionItem value="contributors"> <AccordionItem value="contributors">
<AccordionControl icon={<IconUsers size="1rem" />}> <AccordionControl icon={<IconUsers size="1rem" />}>
{t("accordion.contributors.title")} <Stack gap={0}>
<Text>{t("accordion.contributors.title")}</Text>
<Text size="sm" c="dimmed">
{t("accordion.contributors.subtitle", {
count: contributorsData.length,
})}
</Text>
</Stack>
</AccordionControl> </AccordionControl>
<AccordionPanel></AccordionPanel> <AccordionPanel>
<Flex wrap="wrap" gap="xs">
{contributorsData.map((contributor) => (
<GenericContributorLinkCard
key={contributor.login}
link={`https://github.com/${contributor.login}`}
image={contributor.avatar_url}
name={contributor.login}
/>
))}
</Flex>
</AccordionPanel>
</AccordionItem> </AccordionItem>
<AccordionItem value="translators"> <AccordionItem value="translators">
<AccordionControl icon={<IconLanguage size="1rem" />}> <AccordionControl icon={<IconLanguage size="1rem" />}>
{t("accordion.translators.title")} <Stack gap={0}>
<Text>{t("accordion.translators.title")}</Text>
<Text size="sm" c="dimmed">
{t("accordion.translators.subtitle", {
count: translatorsData.length,
})}
</Text>
</Stack>
</AccordionControl> </AccordionControl>
<AccordionPanel></AccordionPanel> <AccordionPanel>
<Flex wrap="wrap" gap="xs">
{translatorsData.map((translator) => (
<GenericContributorLinkCard
key={translator.username}
link={`https://crowdin.com/profile/${translator.username}`}
image={translator.avatarUrl}
name={translator.username}
/>
))}
</Flex>
</AccordionPanel>
</AccordionItem> </AccordionItem>
<AccordionItem value="libraries"> <AccordionItem value="libraries">
<AccordionControl icon={<IconLibrary size="1rem" />}> <AccordionControl icon={<IconLibrary size="1rem" />}>
@@ -102,6 +142,27 @@ export default async function AboutPage({ params: { locale } }: PageProps) {
); );
} }
interface GenericContributorLinkCardProps {
name: string;
link: string;
image: string;
}
const GenericContributorLinkCard = ({ name, image, link }: GenericContributorLinkCardProps) => {
return (
<AspectRatio ratio={1}>
<Card className={classes.contributorCard} component="a" href={link} target="_blank" w={100}>
<Stack align="center">
<Avatar src={image} alt={name} size={40} display="block" />
<Text lineClamp={1} size="sm">
{name}
</Text>
</Stack>
</Card>
</AspectRatio>
);
};
export function generateStaticParams() { export function generateStaticParams() {
return getStaticParams(); return getStaticParams();
} }
@@ -7,13 +7,10 @@ import { IconTrash } from "@tabler/icons-react";
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 } from "@homarr/modals"; import { useConfirmModal } from "@homarr/modals";
import { import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
showErrorNotification,
showSuccessNotification,
} from "@homarr/notifications";
import { useScopedI18n } from "@homarr/translation/client"; import { useScopedI18n } from "@homarr/translation/client";
import { revalidatePathAction } from "../../../revalidatePathAction"; import { revalidatePathActionAsync } from "../../../revalidatePathAction";
interface AppDeleteButtonProps { interface AppDeleteButtonProps {
app: RouterOutputs["app"]["all"][number]; app: RouterOutputs["app"]["all"][number];
@@ -37,7 +34,7 @@ export const AppDeleteButton = ({ app }: AppDeleteButtonProps) => {
title: t("notification.success.title"), title: t("notification.success.title"),
message: t("notification.success.message"), message: t("notification.success.message"),
}); });
void revalidatePathAction("/manage/apps"); void revalidatePathActionAsync("/manage/apps");
}, },
onError: () => { onError: () => {
showErrorNotification({ showErrorNotification({
@@ -52,13 +49,7 @@ export const AppDeleteButton = ({ app }: AppDeleteButtonProps) => {
}, [app, mutate, t, openConfirmModal]); }, [app, mutate, t, openConfirmModal]);
return ( return (
<ActionIcon <ActionIcon loading={isPending} variant="subtle" color="red" onClick={onClick} aria-label="Delete app">
loading={isPending}
variant="subtle"
color="red"
onClick={onClick}
aria-label="Delete app"
>
<IconTrash color="red" size={16} stroke={1.5} /> <IconTrash color="red" size={16} stroke={1.5} />
</ActionIcon> </ActionIcon>
); );
@@ -3,13 +3,14 @@
import Link from "next/link"; import Link from "next/link";
import { Button, Group, Stack, Textarea, TextInput } from "@mantine/core"; import { Button, Group, Stack, Textarea, TextInput } from "@mantine/core";
import { useForm, zodResolver } from "@homarr/form"; import { useZodForm } from "@homarr/form";
import type { TranslationFunction } from "@homarr/translation"; import type { TranslationFunction } from "@homarr/translation";
import { useI18n } from "@homarr/translation/client"; import { useI18n } from "@homarr/translation/client";
import type { z } from "@homarr/validation"; import type { z } from "@homarr/validation";
import { validation } from "@homarr/validation"; import { validation } from "@homarr/validation";
// TODO: add icon picker import { IconPicker } from "~/components/icons/picker/icon-picker";
type FormType = z.infer<typeof validation.app.manage>; type FormType = z.infer<typeof validation.app.manage>;
interface AppFormProps { interface AppFormProps {
@@ -20,29 +21,23 @@ interface AppFormProps {
} }
export const AppForm = (props: AppFormProps) => { export const AppForm = (props: AppFormProps) => {
const { submitButtonTranslation, handleSubmit, initialValues, isPending } = const { submitButtonTranslation, handleSubmit, initialValues, isPending } = props;
props;
const t = useI18n(); const t = useI18n();
const form = useForm({ const form = useZodForm(validation.app.manage, {
initialValues: initialValues ?? { initialValues: initialValues ?? {
name: "", name: "",
description: "", description: "",
iconUrl: "", iconUrl: "",
href: "", href: "",
}, },
validate: zodResolver(validation.app.manage),
}); });
return ( return (
<form onSubmit={form.onSubmit(handleSubmit)}> <form onSubmit={form.onSubmit(handleSubmit)}>
<Stack> <Stack>
<TextInput {...form.getInputProps("name")} withAsterisk label="Name" /> <TextInput {...form.getInputProps("name")} withAsterisk label="Name" />
<TextInput <IconPicker initialValue={initialValues?.iconUrl} {...form.getInputProps("iconUrl")} />
{...form.getInputProps("iconUrl")}
withAsterisk
label="Icon URL"
/>
<Textarea {...form.getInputProps("description")} label="Description" /> <Textarea {...form.getInputProps("description")} label="Description" />
<TextInput {...form.getInputProps("href")} label="URL" /> <TextInput {...form.getInputProps("href")} label="URL" />
@@ -5,15 +5,12 @@ import { useRouter } from "next/navigation";
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 { import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
showErrorNotification,
showSuccessNotification,
} from "@homarr/notifications";
import type { TranslationFunction } from "@homarr/translation"; import type { TranslationFunction } from "@homarr/translation";
import { useScopedI18n } from "@homarr/translation/client"; import { useScopedI18n } from "@homarr/translation/client";
import type { validation, z } from "@homarr/validation"; import type { validation, z } from "@homarr/validation";
import { revalidatePathAction } from "~/app/revalidatePathAction"; import { revalidatePathActionAsync } from "~/app/revalidatePathAction";
import { AppForm } from "../../_form"; import { AppForm } from "../../_form";
interface AppEditFormProps { interface AppEditFormProps {
@@ -30,7 +27,7 @@ export const AppEditForm = ({ app }: AppEditFormProps) => {
title: t("success.title"), title: t("success.title"),
message: t("success.message"), message: t("success.message"),
}); });
void revalidatePathAction("/manage/apps").then(() => { void revalidatePathActionAsync("/manage/apps").then(() => {
router.push("/manage/apps"); router.push("/manage/apps");
}); });
}, },
@@ -52,10 +49,7 @@ export const AppEditForm = ({ app }: AppEditFormProps) => {
[mutate, app.id], [mutate, app.id],
); );
const submitButtonTranslation = useCallback( const submitButtonTranslation = useCallback((t: TranslationFunction) => t("common.action.save"), []);
(t: TranslationFunction) => t("common.action.save"),
[],
);
return ( return (
<AppForm <AppForm
@@ -4,15 +4,12 @@ import { useCallback } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { clientApi } from "@homarr/api/client"; import { clientApi } from "@homarr/api/client";
import { import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
showErrorNotification,
showSuccessNotification,
} from "@homarr/notifications";
import type { TranslationFunction } from "@homarr/translation"; import type { TranslationFunction } from "@homarr/translation";
import { useScopedI18n } from "@homarr/translation/client"; import { useScopedI18n } from "@homarr/translation/client";
import type { validation, z } from "@homarr/validation"; import type { validation, z } from "@homarr/validation";
import { revalidatePathAction } from "~/app/revalidatePathAction"; import { revalidatePathActionAsync } from "~/app/revalidatePathAction";
import { AppForm } from "../_form"; import { AppForm } from "../_form";
export const AppNewForm = () => { export const AppNewForm = () => {
@@ -25,7 +22,7 @@ export const AppNewForm = () => {
title: t("success.title"), title: t("success.title"),
message: t("success.message"), message: t("success.message"),
}); });
void revalidatePathAction("/manage/apps").then(() => { void revalidatePathActionAsync("/manage/apps").then(() => {
router.push("/manage/apps"); router.push("/manage/apps");
}); });
}, },
@@ -44,16 +41,9 @@ export const AppNewForm = () => {
[mutate], [mutate],
); );
const submitButtonTranslation = useCallback( const submitButtonTranslation = useCallback((t: TranslationFunction) => t("common.action.create"), []);
(t: TranslationFunction) => t("common.action.create"),
[],
);
return ( return (
<AppForm <AppForm submitButtonTranslation={submitButtonTranslation} handleSubmit={handleSubmit} isPending={isPending} />
submitButtonTranslation={submitButtonTranslation}
handleSubmit={handleSubmit}
isPending={isPending}
/>
); );
}; };
@@ -107,9 +107,7 @@ const AppNoResults = async () => {
<Text fw={500} size="lg"> <Text fw={500} size="lg">
{t("app.page.list.noResults.title")} {t("app.page.list.noResults.title")}
</Text> </Text>
<Anchor href="/manage/apps/new"> <Anchor href="/manage/apps/new">{t("app.page.list.noResults.description")}</Anchor>
{t("app.page.list.noResults.description")}
</Anchor>
</Stack> </Stack>
</Card> </Card>
); );
@@ -3,14 +3,14 @@
import { useCallback } from "react"; import { useCallback } from "react";
import Link from "next/link"; import Link from "next/link";
import { Menu } from "@mantine/core"; import { Menu } from "@mantine/core";
import { IconSettings, IconTrash } from "@tabler/icons-react"; import { IconHome, IconSettings, IconTrash } from "@tabler/icons-react";
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 } from "@homarr/modals"; import { useConfirmModal } from "@homarr/modals";
import { useScopedI18n } from "@homarr/translation/client"; import { useScopedI18n } from "@homarr/translation/client";
import { revalidatePathAction } from "~/app/revalidatePathAction"; import { revalidatePathActionAsync } from "~/app/revalidatePathAction";
import { useBoardPermissions } from "~/components/board/permissions/client"; import { useBoardPermissions } from "~/components/board/permissions/client";
const iconProps = { const iconProps = {
@@ -21,18 +21,11 @@ const iconProps = {
interface BoardCardMenuDropdownProps { interface BoardCardMenuDropdownProps {
board: Pick< board: Pick<
RouterOutputs["board"]["getAllBoards"][number], RouterOutputs["board"]["getAllBoards"][number],
| "id" "id" | "name" | "creator" | "userPermissions" | "groupPermissions" | "isPublic"
| "name"
| "creator"
| "userPermissions"
| "groupPermissions"
| "isPublic"
>; >;
} }
export const BoardCardMenuDropdown = ({ export const BoardCardMenuDropdown = ({ board }: BoardCardMenuDropdownProps) => {
board,
}: BoardCardMenuDropdownProps) => {
const t = useScopedI18n("management.page.board.action"); const t = useScopedI18n("management.page.board.action");
const tCommon = useScopedI18n("common"); const tCommon = useScopedI18n("common");
@@ -40,9 +33,15 @@ export const BoardCardMenuDropdown = ({
const { openConfirmModal } = useConfirmModal(); const { openConfirmModal } = useConfirmModal();
const { mutateAsync, isPending } = clientApi.board.deleteBoard.useMutation({ const setHomeBoardMutation = clientApi.board.setHomeBoard.useMutation({
onSettled: async () => { onSettled: async () => {
await revalidatePathAction("/manage/boards"); // Revalidate all as it's part of the user settings, /boards page and board manage page
await revalidatePathActionAsync("/");
},
});
const deleteBoardMutation = clientApi.board.deleteBoard.useMutation({
onSettled: async () => {
await revalidatePathActionAsync("/manage/boards");
}, },
}); });
@@ -52,36 +51,45 @@ export const BoardCardMenuDropdown = ({
children: t("delete.confirm.description", { children: t("delete.confirm.description", {
name: board.name, name: board.name,
}), }),
// eslint-disable-next-line no-restricted-syntax
onConfirm: async () => { onConfirm: async () => {
await mutateAsync({ await deleteBoardMutation.mutateAsync({
id: board.id, id: board.id,
}); });
}, },
}); });
}, [board.id, board.name, mutateAsync, openConfirmModal, t]); }, [board.id, board.name, deleteBoardMutation, openConfirmModal, t]);
const handleSetHomeBoard = useCallback(async () => {
await setHomeBoardMutation.mutateAsync({ id: board.id });
}, [board.id, setHomeBoardMutation]);
return ( return (
<Menu.Dropdown> <Menu.Dropdown>
<Menu.Item onClick={handleSetHomeBoard} leftSection={<IconHome {...iconProps} />}>
{t("setHomeBoard.label")}
</Menu.Item>
{hasChangeAccess && ( {hasChangeAccess && (
<Menu.Item <>
component={Link} <Menu.Divider />
href={`/boards/${board.name}/settings`} <Menu.Item
leftSection={<IconSettings {...iconProps} />} component={Link}
> href={`/boards/${board.name}/settings`}
{t("settings.label")} leftSection={<IconSettings {...iconProps} />}
</Menu.Item> >
{t("settings.label")}
</Menu.Item>
</>
)} )}
{hasFullAccess && ( {hasFullAccess && (
<> <>
<Menu.Divider /> <Menu.Divider />
<Menu.Label c="red.7"> <Menu.Label c="red.7">{tCommon("dangerZone")}</Menu.Label>
{tCommon("menu.section.dangerZone.title")}
</Menu.Label>
<Menu.Item <Menu.Item
c="red.7" c="red.7"
leftSection={<IconTrash {...iconProps} />} leftSection={<IconTrash {...iconProps} />}
onClick={handleDeletion} onClick={handleDeletion}
disabled={isPending} disabled={deleteBoardMutation.isPending}
> >
{t("delete.label")} {t("delete.label")}
</Menu.Item> </Menu.Item>
@@ -8,7 +8,7 @@ import { clientApi } from "@homarr/api/client";
import { useModalAction } from "@homarr/modals"; import { useModalAction } from "@homarr/modals";
import { useI18n } from "@homarr/translation/client"; import { useI18n } from "@homarr/translation/client";
import { revalidatePathAction } from "~/app/revalidatePathAction"; import { revalidatePathActionAsync } from "~/app/revalidatePathAction";
import { AddBoardModal } from "~/components/manage/boards/add-board-modal"; import { AddBoardModal } from "~/components/manage/boards/add-board-modal";
interface CreateBoardButtonProps { interface CreateBoardButtonProps {
@@ -21,7 +21,7 @@ export const CreateBoardButton = ({ boardNames }: CreateBoardButtonProps) => {
const { mutateAsync, isPending } = clientApi.board.createBoard.useMutation({ const { mutateAsync, isPending } = clientApi.board.createBoard.useMutation({
onSettled: async () => { onSettled: async () => {
await revalidatePathAction("/manage/boards"); await revalidatePathActionAsync("/manage/boards");
}, },
}); });
@@ -37,11 +37,7 @@ export const CreateBoardButton = ({ boardNames }: CreateBoardButtonProps) => {
}, [mutateAsync, boardNames, openModal]); }, [mutateAsync, boardNames, openModal]);
return ( return (
<Button <Button leftSection={<IconCategoryPlus size="1rem" />} onClick={onClick} loading={isPending}>
leftSection={<IconCategoryPlus size="1rem" />}
onClick={onClick}
loading={isPending}
>
{t("management.page.board.action.new.label")} {t("management.page.board.action.new.label")}
</Button> </Button>
); );
@@ -1,6 +1,7 @@
import Link from "next/link"; import Link from "next/link";
import { import {
ActionIcon, ActionIcon,
Badge,
Button, Button,
Card, Card,
CardSection, CardSection,
@@ -13,13 +14,14 @@ import {
Title, Title,
Tooltip, Tooltip,
} from "@mantine/core"; } from "@mantine/core";
import { IconDotsVertical, IconLock, IconWorld } from "@tabler/icons-react"; import { IconDotsVertical, IconHomeFilled, 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";
import { getScopedI18n } from "@homarr/translation/server"; import { getScopedI18n } from "@homarr/translation/server";
import { UserAvatar } from "@homarr/ui";
import { getBoardPermissions } from "~/components/board/permissions/server"; import { getBoardPermissionsAsync } from "~/components/board/permissions/server";
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";
@@ -37,7 +39,7 @@ export default async function ManageBoardsPage() {
<Grid> <Grid>
{boards.map((board) => ( {boards.map((board) => (
<GridCol span={{ xs: 12, md: 4 }} key={board.id}> <GridCol span={{ base: 12, md: 6, xl: 4 }} key={board.id}>
<BoardCard board={board} /> <BoardCard board={board} />
</GridCol> </GridCol>
))} ))}
@@ -52,7 +54,7 @@ interface BoardCardProps {
const BoardCard = async ({ board }: BoardCardProps) => { const BoardCard = async ({ board }: BoardCardProps) => {
const t = await getScopedI18n("management.page.board"); const t = await getScopedI18n("management.page.board");
const { hasChangeAccess: isMenuVisible } = await getBoardPermissions(board); const { hasChangeAccess: isMenuVisible } = await getBoardPermissionsAsync(board);
const visibility = board.isPublic ? "public" : "private"; const visibility = board.isPublic ? "public" : "private";
const VisibilityIcon = board.isPublic ? IconWorld : IconLock; const VisibilityIcon = board.isPublic ? IconWorld : IconLock;
@@ -68,17 +70,29 @@ const BoardCard = async ({ board }: BoardCardProps) => {
{board.name} {board.name}
</Text> </Text>
</Group> </Group>
<Group>
{board.isHome && (
<Tooltip label={t("action.setHomeBoard.badge.tooltip")}>
<Badge tt="none" color="yellow" variant="light" leftSection={<IconHomeFilled size=".7rem" />}>
{t("action.setHomeBoard.badge.label")}
</Badge>
</Tooltip>
)}
{board.creator && (
<Group gap="xs">
<UserAvatar user={board.creator} size="sm" />
<Text>{board.creator?.name}</Text>
</Group>
)}
</Group>
</Group> </Group>
</CardSection> </CardSection>
<CardSection p="sm"> <CardSection p="sm">
<Group wrap="nowrap"> <Group wrap="nowrap">
<Button <Button component={Link} href={`/boards/${board.name}`} variant="default" fullWidth>
component={Link}
href={`/boards/${board.name}`}
variant="default"
fullWidth
>
{t("action.open.label")} {t("action.open.label")}
</Button> </Button>
{isMenuVisible && ( {isMenuVisible && (
@@ -6,23 +6,17 @@ import { IconTrash } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client"; import { clientApi } from "@homarr/api/client";
import { useConfirmModal } from "@homarr/modals"; import { useConfirmModal } from "@homarr/modals";
import { import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
showErrorNotification,
showSuccessNotification,
} from "@homarr/notifications";
import { useScopedI18n } from "@homarr/translation/client"; import { useScopedI18n } from "@homarr/translation/client";
import { revalidatePathAction } from "../../../revalidatePathAction"; import { revalidatePathActionAsync } from "../../../revalidatePathAction";
interface DeleteIntegrationActionButtonProps { interface DeleteIntegrationActionButtonProps {
count: number; count: number;
integration: { id: string; name: string }; integration: { id: string; name: string };
} }
export const DeleteIntegrationActionButton = ({ export const DeleteIntegrationActionButton = ({ count, integration }: DeleteIntegrationActionButtonProps) => {
count,
integration,
}: DeleteIntegrationActionButtonProps) => {
const t = useScopedI18n("integration.page.delete"); const t = useScopedI18n("integration.page.delete");
const router = useRouter(); const router = useRouter();
const { openConfirmModal } = useConfirmModal(); const { openConfirmModal } = useConfirmModal();
@@ -47,9 +41,9 @@ export const DeleteIntegrationActionButton = ({
message: t("notification.success.message"), message: t("notification.success.message"),
}); });
if (count === 1) { if (count === 1) {
router.replace("/integrations"); router.replace("/manage/integrations");
} }
void revalidatePathAction("/integrations"); void revalidatePathActionAsync("/manage/integrations");
}, },
onError: () => { onError: () => {
showErrorNotification({ showErrorNotification({
@@ -2,17 +2,7 @@
import { useState } from "react"; import { useState } from "react";
import { useParams } from "next/navigation"; import { useParams } from "next/navigation";
import { import { ActionIcon, Avatar, Button, Card, Collapse, Group, Kbd, Stack, Text } from "@mantine/core";
ActionIcon,
Avatar,
Button,
Card,
Collapse,
Group,
Kbd,
Stack,
Text,
} from "@mantine/core";
import { useDisclosure } from "@mantine/hooks"; import { useDisclosure } from "@mantine/hooks";
import { IconEye, IconEyeOff } from "@tabler/icons-react"; import { IconEye, IconEyeOff } from "@tabler/icons-react";
import dayjs from "dayjs"; import dayjs from "dayjs";
@@ -36,8 +26,7 @@ export const SecretCard = ({ secret, children, onCancel }: SecretCardProps) => {
const params = useParams<{ locale: string }>(); const params = useParams<{ locale: string }>();
const t = useI18n(); const t = useI18n();
const { isPublic } = integrationSecretKindObject[secret.kind]; const { isPublic } = integrationSecretKindObject[secret.kind];
const [publicSecretDisplayOpened, { toggle: togglePublicSecretDisplay }] = const [publicSecretDisplayOpened, { toggle: togglePublicSecretDisplay }] = useDisclosure(false);
useDisclosure(false);
const [editMode, setEditMode] = useState(false); const [editMode, setEditMode] = useState(false);
const DisplayIcon = publicSecretDisplayOpened ? IconEye : IconEyeOff; const DisplayIcon = publicSecretDisplayOpened ? IconEye : IconEyeOff;
const KindIcon = integrationSecretIcons[secret.kind]; const KindIcon = integrationSecretIcons[secret.kind];
@@ -50,9 +39,7 @@ export const SecretCard = ({ secret, children, onCancel }: SecretCardProps) => {
<Avatar> <Avatar>
<KindIcon size={16} /> <KindIcon size={16} />
</Avatar> </Avatar>
<Text fw={500}> <Text fw={500}>{t(`integration.secrets.kind.${secret.kind}.label`)}</Text>
{t(`integration.secrets.kind.${secret.kind}.label`)}
</Text>
{publicSecretDisplayOpened ? <Kbd>{secret.value}</Kbd> : null} {publicSecretDisplayOpened ? <Kbd>{secret.value}</Kbd> : null}
</Group> </Group>
<Group> <Group>
@@ -62,11 +49,7 @@ export const SecretCard = ({ secret, children, onCancel }: SecretCardProps) => {
})} })}
</Text> </Text>
{isPublic ? ( {isPublic ? (
<ActionIcon <ActionIcon color="gray" variant="subtle" onClick={togglePublicSecretDisplay}>
color="gray"
variant="subtle"
onClick={togglePublicSecretDisplay}
>
<DisplayIcon size={16} stroke={1.5} /> <DisplayIcon size={16} stroke={1.5} />
</ActionIcon> </ActionIcon>
) : null} ) : null}
@@ -10,6 +10,7 @@ import { useI18n } from "@homarr/translation/client";
import { integrationSecretIcons } from "./_integration-secret-icons"; import { integrationSecretIcons } from "./_integration-secret-icons";
interface IntegrationSecretInputProps { interface IntegrationSecretInputProps {
withAsterisk?: boolean;
label?: string; label?: string;
kind: IntegrationSecretKind; kind: IntegrationSecretKind;
value?: string; value?: string;
@@ -41,10 +42,7 @@ const PublicSecretInput = ({ kind, ...props }: IntegrationSecretInputProps) => {
); );
}; };
const PrivateSecretInput = ({ const PrivateSecretInput = ({ kind, ...props }: IntegrationSecretInputProps) => {
kind,
...props
}: IntegrationSecretInputProps) => {
const t = useI18n(); const t = useI18n();
const Icon = integrationSecretIcons[kind]; const Icon = integrationSecretIcons[kind];
@@ -6,10 +6,7 @@ import { IconCheck, IconInfoCircle, IconX } from "@tabler/icons-react";
import type { RouterInputs } from "@homarr/api"; import type { RouterInputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client"; import { clientApi } from "@homarr/api/client";
import { import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
showErrorNotification,
showSuccessNotification,
} from "@homarr/notifications";
import { useI18n, useScopedI18n } from "@homarr/translation/client"; import { useI18n, useScopedI18n } from "@homarr/translation/client";
interface UseTestConnectionDirtyProps { interface UseTestConnectionDirtyProps {
@@ -20,10 +17,7 @@ interface UseTestConnectionDirtyProps {
}; };
} }
export const useTestConnectionDirty = ({ export const useTestConnectionDirty = ({ defaultDirty, initialFormValue }: UseTestConnectionDirtyProps) => {
defaultDirty,
initialFormValue,
}: UseTestConnectionDirtyProps) => {
const [isDirty, setIsDirty] = useState(defaultDirty); const [isDirty, setIsDirty] = useState(defaultDirty);
const prevFormValueRef = useRef(initialFormValue); const prevFormValueRef = useRef(initialFormValue);
@@ -36,10 +30,7 @@ export const useTestConnectionDirty = ({
prevFormValueRef.current.url !== values.url || prevFormValueRef.current.url !== values.url ||
!prevFormValueRef.current.secrets !prevFormValueRef.current.secrets
.map((secret) => secret.value) .map((secret) => secret.value)
.every( .every((secretValue, index) => values.secrets[index]?.value === secretValue)
(secretValue, index) =>
values.secrets[index]?.value === secretValue,
)
) { ) {
setIsDirty(true); setIsDirty(true);
return; return;
@@ -62,14 +53,9 @@ interface TestConnectionProps {
integration: RouterInputs["integration"]["testConnection"] & { name: string }; integration: RouterInputs["integration"]["testConnection"] & { name: string };
} }
export const TestConnection = ({ export const TestConnection = ({ integration, removeDirty, isDirty }: TestConnectionProps) => {
integration,
removeDirty,
isDirty,
}: TestConnectionProps) => {
const t = useScopedI18n("integration.testConnection"); const t = useScopedI18n("integration.testConnection");
const { mutateAsync, ...mutation } = const { mutateAsync, ...mutation } = clientApi.integration.testConnection.useMutation();
clientApi.integration.testConnection.useMutation();
return ( return (
<Group> <Group>
@@ -125,13 +111,7 @@ interface TestConnectionIconProps {
size: number; size: number;
} }
const TestConnectionIcon = ({ const TestConnectionIcon = ({ isDirty, isPending, isSuccess, isError, size }: TestConnectionIconProps) => {
isDirty,
isPending,
isSuccess,
isError,
size,
}: TestConnectionIconProps) => {
if (isPending) return <Loader color="blue" size={size} />; if (isPending) return <Loader color="blue" size={size} />;
if (isDirty) return null; if (isDirty) return null;
if (isSuccess) return <IconCheck size={size} stroke={1.5} color="green" />; if (isSuccess) return <IconCheck size={size} stroke={1.5} color="green" />;
@@ -142,12 +122,7 @@ const TestConnectionIcon = ({
export const TestConnectionNoticeAlert = () => { export const TestConnectionNoticeAlert = () => {
const t = useI18n(); const t = useI18n();
return ( return (
<Alert <Alert variant="light" color="yellow" title="Test Connection" icon={<IconInfoCircle />}>
variant="light"
color="yellow"
title="Test Connection"
icon={<IconInfoCircle />}
>
{t("integration.testConnection.alertNotice")} {t("integration.testConnection.alertNotice")}
</Alert> </Alert>
); );
@@ -6,28 +6,18 @@ import { Button, Fieldset, Group, Stack, TextInput } from "@mantine/core";
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 { import { getAllSecretKindOptions, getDefaultSecretKinds } from "@homarr/definitions";
getAllSecretKindOptions, import { useZodForm } from "@homarr/form";
getDefaultSecretKinds,
} from "@homarr/definitions";
import { useForm, zodResolver } from "@homarr/form";
import { useConfirmModal } from "@homarr/modals"; import { useConfirmModal } from "@homarr/modals";
import { import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
showErrorNotification,
showSuccessNotification,
} from "@homarr/notifications";
import { useI18n } from "@homarr/translation/client"; import { useI18n } from "@homarr/translation/client";
import type { z } from "@homarr/validation"; import type { z } from "@homarr/validation";
import { validation } from "@homarr/validation"; import { validation } from "@homarr/validation";
import { revalidatePathAction } from "~/app/revalidatePathAction"; import { revalidatePathActionAsync } from "~/app/revalidatePathAction";
import { SecretCard } from "../../_integration-secret-card"; import { SecretCard } from "../../_integration-secret-card";
import { IntegrationSecretInput } from "../../_integration-secret-inputs"; import { IntegrationSecretInput } from "../../_integration-secret-inputs";
import { import { TestConnection, TestConnectionNoticeAlert, useTestConnectionDirty } from "../../_integration-test-connection";
TestConnection,
TestConnectionNoticeAlert,
useTestConnectionDirty,
} from "../../_integration-test-connection";
interface EditIntegrationForm { interface EditIntegrationForm {
integration: RouterOutputs["integration"]["byId"]; integration: RouterOutputs["integration"]["byId"];
@@ -45,8 +35,7 @@ export const EditIntegrationForm = ({ integration }: EditIntegrationForm) => {
url: integration.url, url: integration.url,
secrets: secretsKinds.map((kind) => ({ secrets: secretsKinds.map((kind) => ({
kind, kind,
value: value: integration.secrets.find((secret) => secret.kind === kind)?.value ?? "",
integration.secrets.find((secret) => secret.kind === kind)?.value ?? "",
})), })),
}; };
const { isDirty, onValuesChange, removeDirty } = useTestConnectionDirty({ const { isDirty, onValuesChange, removeDirty } = useTestConnectionDirty({
@@ -55,18 +44,15 @@ export const EditIntegrationForm = ({ integration }: EditIntegrationForm) => {
}); });
const router = useRouter(); const router = useRouter();
const form = useForm<FormType>({ const form = useZodForm(validation.integration.update.omit({ id: true }), {
initialValues: initialFormValues, initialValues: initialFormValues,
validate: zodResolver(validation.integration.update.omit({ id: true })),
onValuesChange, onValuesChange,
}); });
const { mutateAsync, isPending } = clientApi.integration.update.useMutation(); const { mutateAsync, isPending } = clientApi.integration.update.useMutation();
const secretsMap = new Map( const secretsMap = new Map(integration.secrets.map((secret) => [secret.kind, secret]));
integration.secrets.map((secret) => [secret.kind, secret]),
);
const handleSubmit = async (values: FormType) => { const handleSubmitAsync = async (values: FormType) => {
if (isDirty) return; if (isDirty) return;
await mutateAsync( await mutateAsync(
{ {
@@ -83,9 +69,7 @@ export const EditIntegrationForm = ({ integration }: EditIntegrationForm) => {
title: t("integration.page.edit.notification.success.title"), title: t("integration.page.edit.notification.success.title"),
message: t("integration.page.edit.notification.success.message"), message: t("integration.page.edit.notification.success.message"),
}); });
void revalidatePathAction("/integrations").then(() => void revalidatePathActionAsync("/manage/integrations").then(() => router.push("/manage/integrations"));
router.push("/integrations"),
);
}, },
onError: () => { onError: () => {
showErrorNotification({ showErrorNotification({
@@ -98,19 +82,13 @@ export const EditIntegrationForm = ({ integration }: EditIntegrationForm) => {
}; };
return ( return (
<form onSubmit={form.onSubmit((values) => void handleSubmit(values))}> <form onSubmit={form.onSubmit((values) => void handleSubmitAsync(values))}>
<Stack> <Stack>
<TestConnectionNoticeAlert /> <TestConnectionNoticeAlert />
<TextInput <TextInput withAsterisk label={t("integration.field.name.label")} {...form.getInputProps("name")} />
label={t("integration.field.name.label")}
{...form.getInputProps("name")}
/>
<TextInput <TextInput withAsterisk label={t("integration.field.url.label")} {...form.getInputProps("url")} />
label={t("integration.field.url.label")}
{...form.getInputProps("url")}
/>
<Fieldset legend={t("integration.secrets.title")}> <Fieldset legend={t("integration.secrets.title")}>
<Stack gap="sm"> <Stack gap="sm">
@@ -121,10 +99,7 @@ export const EditIntegrationForm = ({ integration }: EditIntegrationForm) => {
onCancel={() => onCancel={() =>
new Promise((res) => { new Promise((res) => {
// When nothing changed, just close the secret card // When nothing changed, just close the secret card
if ( if ((form.values.secrets[index]?.value ?? "") === (secretsMap.get(kind)?.value ?? "")) {
(form.values.secrets[index]?.value ?? "") ===
(secretsMap.get(kind)?.value ?? "")
) {
return res(true); return res(true);
} }
openConfirmModal({ openConfirmModal({
@@ -132,10 +107,7 @@ export const EditIntegrationForm = ({ integration }: EditIntegrationForm) => {
children: t("integration.secrets.reset.message"), children: t("integration.secrets.reset.message"),
onCancel: () => res(false), onCancel: () => res(false),
onConfirm: () => { onConfirm: () => {
form.setFieldValue( form.setFieldValue(`secrets.${index}.value`, secretsMap.get(kind)!.value ?? "");
`secrets.${index}.value`,
secretsMap.get(kind)!.value ?? "",
);
res(true); res(true);
}, },
}); });
@@ -164,7 +136,7 @@ export const EditIntegrationForm = ({ integration }: EditIntegrationForm) => {
}} }}
/> />
<Group> <Group>
<Button variant="default" component={Link} href="/integrations"> <Button variant="default" component={Link} href="/manage/integrations">
{t("common.action.backToOverview")} {t("common.action.backToOverview")}
</Button> </Button>
<Button type="submit" loading={isPending} disabled={isDirty}> <Button type="submit" loading={isPending} disabled={isDirty}>
@@ -11,9 +11,7 @@ interface EditIntegrationPageProps {
params: { id: string }; params: { id: string };
} }
export default async function EditIntegrationPage({ export default async function EditIntegrationPage({ params }: EditIntegrationPageProps) {
params,
}: EditIntegrationPageProps) {
const t = await getScopedI18n("integration.page.edit"); const t = await getScopedI18n("integration.page.edit");
const integration = await api.integration.byId({ id: params.id }); const integration = await api.integration.byId({ id: params.id });
@@ -22,9 +20,7 @@ export default async function EditIntegrationPage({
<Stack> <Stack>
<Group align="center"> <Group align="center">
<IntegrationAvatar kind={integration.kind} size="md" /> <IntegrationAvatar kind={integration.kind} size="md" />
<Title> <Title>{t("title", { name: getIntegrationName(integration.kind) })}</Title>
{t("title", { name: getIntegrationName(integration.kind) })}
</Title>
</Group> </Group>
<EditIntegrationForm integration={integration} /> <EditIntegrationForm integration={integration} />
</Stack> </Stack>
@@ -1,6 +1,7 @@
"use client"; "use client";
import React, { ChangeEvent, useMemo, useState } from "react"; import type { ChangeEvent } from "react";
import React, { useMemo, useState } from "react";
import Link from "next/link"; import Link from "next/link";
import { Group, Menu, ScrollArea, Stack, Text, TextInput } from "@mantine/core"; import { Group, Menu, ScrollArea, Stack, Text, TextInput } from "@mantine/core";
import { IconSearch } from "@tabler/icons-react"; import { IconSearch } from "@tabler/icons-react";
@@ -15,9 +16,7 @@ export const IntegrationCreateDropdownContent = () => {
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
const filteredKinds = useMemo(() => { const filteredKinds = useMemo(() => {
return integrationKinds.filter((kind) => return integrationKinds.filter((kind) => kind.includes(search.toLowerCase()));
kind.includes(search.toLowerCase()),
);
}, [search]); }, [search]);
const handleSearch = React.useCallback( const handleSearch = React.useCallback(
@@ -37,11 +36,7 @@ export const IntegrationCreateDropdownContent = () => {
{filteredKinds.length > 0 ? ( {filteredKinds.length > 0 ? (
<ScrollArea.Autosize mah={384}> <ScrollArea.Autosize mah={384}>
{filteredKinds.map((kind) => ( {filteredKinds.map((kind) => (
<Menu.Item <Menu.Item component={Link} href={`/manage/integrations/new?kind=${kind}`} key={kind}>
component={Link}
href={`/integrations/new?kind=${kind}`}
key={kind}
>
<Group> <Group>
<IntegrationAvatar kind={kind} size="sm" /> <IntegrationAvatar kind={kind} size="sm" />
<Text size="sm">{getIntegrationName(kind)}</Text> <Text size="sm">{getIntegrationName(kind)}</Text>
@@ -3,38 +3,21 @@
import { useCallback } from "react"; import { useCallback } from "react";
import Link from "next/link"; import Link from "next/link";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { import { Button, Fieldset, Group, SegmentedControl, Stack, TextInput } from "@mantine/core";
Button,
Fieldset,
Group,
SegmentedControl,
Stack,
TextInput,
} from "@mantine/core";
import { clientApi } from "@homarr/api/client"; import { clientApi } from "@homarr/api/client";
import type { import type { IntegrationKind, IntegrationSecretKind } from "@homarr/definitions";
IntegrationKind,
IntegrationSecretKind,
} from "@homarr/definitions";
import { getAllSecretKindOptions } from "@homarr/definitions"; import { getAllSecretKindOptions } from "@homarr/definitions";
import type { UseFormReturnType } from "@homarr/form"; import type { UseFormReturnType } from "@homarr/form";
import { useForm, zodResolver } from "@homarr/form"; import { useZodForm } from "@homarr/form";
import { import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
showErrorNotification,
showSuccessNotification,
} from "@homarr/notifications";
import { useI18n, useScopedI18n } from "@homarr/translation/client"; import { useI18n, useScopedI18n } from "@homarr/translation/client";
import type { z } from "@homarr/validation"; import type { z } from "@homarr/validation";
import { validation } from "@homarr/validation"; import { validation } from "@homarr/validation";
import { IntegrationSecretInput } from "../_integration-secret-inputs"; import { IntegrationSecretInput } from "../_integration-secret-inputs";
import { import { TestConnection, TestConnectionNoticeAlert, useTestConnectionDirty } from "../_integration-test-connection";
TestConnection, import { revalidatePathActionAsync } from "../../../../revalidatePathAction";
TestConnectionNoticeAlert,
useTestConnectionDirty,
} from "../_integration-test-connection";
import { revalidatePathAction } from "../../../../revalidatePathAction";
interface NewIntegrationFormProps { interface NewIntegrationFormProps {
searchParams: Partial<z.infer<typeof validation.integration.create>> & { searchParams: Partial<z.infer<typeof validation.integration.create>> & {
@@ -42,9 +25,7 @@ interface NewIntegrationFormProps {
}; };
} }
export const NewIntegrationForm = ({ export const NewIntegrationForm = ({ searchParams }: NewIntegrationFormProps) => {
searchParams,
}: NewIntegrationFormProps) => {
const t = useI18n(); const t = useI18n();
const secretKinds = getAllSecretKindOptions(searchParams.kind); const secretKinds = getAllSecretKindOptions(searchParams.kind);
const initialFormValues = { const initialFormValues = {
@@ -60,14 +41,13 @@ export const NewIntegrationForm = ({
initialFormValue: initialFormValues, initialFormValue: initialFormValues,
}); });
const router = useRouter(); const router = useRouter();
const form = useForm<FormType>({ const form = useZodForm(validation.integration.create.omit({ kind: true }), {
initialValues: initialFormValues, initialValues: initialFormValues,
validate: zodResolver(validation.integration.create.omit({ kind: true })),
onValuesChange, onValuesChange,
}); });
const { mutateAsync, isPending } = clientApi.integration.create.useMutation(); const { mutateAsync, isPending } = clientApi.integration.create.useMutation();
const handleSubmit = async (values: FormType) => { const handleSubmitAsync = async (values: FormType) => {
if (isDirty) return; if (isDirty) return;
await mutateAsync( await mutateAsync(
{ {
@@ -80,9 +60,7 @@ export const NewIntegrationForm = ({
title: t("integration.page.create.notification.success.title"), title: t("integration.page.create.notification.success.title"),
message: t("integration.page.create.notification.success.message"), message: t("integration.page.create.notification.success.message"),
}); });
void revalidatePathAction("/integrations").then(() => void revalidatePathActionAsync("/manage/integrations").then(() => router.push("/manage/integrations"));
router.push("/integrations"),
);
}, },
onError: () => { onError: () => {
showErrorNotification({ showErrorNotification({
@@ -95,30 +73,20 @@ export const NewIntegrationForm = ({
}; };
return ( return (
<form onSubmit={form.onSubmit((value) => void handleSubmit(value))}> <form onSubmit={form.onSubmit((value) => void handleSubmitAsync(value))}>
<Stack> <Stack>
<TestConnectionNoticeAlert /> <TestConnectionNoticeAlert />
<TextInput <TextInput withAsterisk label={t("integration.field.name.label")} {...form.getInputProps("name")} />
label={t("integration.field.name.label")}
{...form.getInputProps("name")}
/>
<TextInput <TextInput withAsterisk label={t("integration.field.url.label")} {...form.getInputProps("url")} />
label={t("integration.field.url.label")}
{...form.getInputProps("url")}
/>
<Fieldset legend={t("integration.secrets.title")}> <Fieldset legend={t("integration.secrets.title")}>
<Stack gap="sm"> <Stack gap="sm">
{secretKinds.length > 1 && ( {secretKinds.length > 1 && <SecretKindsSegmentedControl secretKinds={secretKinds} form={form} />}
<SecretKindsSegmentedControl
secretKinds={secretKinds}
form={form}
/>
)}
{form.values.secrets.map(({ kind }, index) => ( {form.values.secrets.map(({ kind }, index) => (
<IntegrationSecretInput <IntegrationSecretInput
withAsterisk
key={kind} key={kind}
kind={kind} kind={kind}
{...form.getInputProps(`secrets.${index}.value`)} {...form.getInputProps(`secrets.${index}.value`)}
@@ -139,7 +107,7 @@ export const NewIntegrationForm = ({
/> />
<Group> <Group>
<Button variant="default" component={Link} href="/integrations"> <Button variant="default" component={Link} href="/manage/integrations">
{t("common.action.backToOverview")} {t("common.action.backToOverview")}
</Button> </Button>
<Button type="submit" loading={isPending} disabled={isDirty}> <Button type="submit" loading={isPending} disabled={isDirty}>
@@ -157,10 +125,7 @@ interface SecretKindsSegmentedControlProps {
form: UseFormReturnType<FormType, (values: FormType) => FormType>; form: UseFormReturnType<FormType, (values: FormType) => FormType>;
} }
const SecretKindsSegmentedControl = ({ const SecretKindsSegmentedControl = ({ secretKinds, form }: SecretKindsSegmentedControlProps) => {
secretKinds,
form,
}: SecretKindsSegmentedControlProps) => {
const t = useScopedI18n("integration.secrets"); const t = useScopedI18n("integration.secrets");
const secretKindGroups = secretKinds.map((kinds) => ({ const secretKindGroups = secretKinds.map((kinds) => ({
@@ -180,13 +145,7 @@ const SecretKindsSegmentedControl = ({
[form], [form],
); );
return ( return <SegmentedControl fullWidth data={secretKindGroups} onChange={onChange}></SegmentedControl>;
<SegmentedControl
fullWidth
data={secretKindGroups}
onChange={onChange}
></SegmentedControl>
);
}; };
type FormType = Omit<z.infer<typeof validation.integration.create>, "kind">; type FormType = Omit<z.infer<typeof validation.integration.create>, "kind">;
@@ -16,12 +16,8 @@ interface NewIntegrationPageProps {
}; };
} }
export default async function IntegrationsNewPage({ export default async function IntegrationsNewPage({ searchParams }: NewIntegrationPageProps) {
searchParams, const result = z.enum([integrationKinds[0]!, ...integrationKinds.slice(1)]).safeParse(searchParams.kind);
}: NewIntegrationPageProps) {
const result = z
.enum([integrationKinds[0]!, ...integrationKinds.slice(1)])
.safeParse(searchParams.kind);
if (!result.success) { if (!result.success) {
notFound(); notFound();
} }
@@ -43,9 +43,7 @@ interface IntegrationsPageProps {
}; };
} }
export default async function IntegrationsPage({ export default async function IntegrationsPage({ searchParams }: IntegrationsPageProps) {
searchParams,
}: IntegrationsPageProps) {
const integrations = await api.integration.all(); const integrations = await api.integration.all();
const t = await getScopedI18n("integration"); const t = await getScopedI18n("integration");
@@ -54,18 +52,9 @@ export default async function IntegrationsPage({
<Stack> <Stack>
<Group justify="space-between" align="center"> <Group justify="space-between" align="center">
<Title>{t("page.list.title")}</Title> <Title>{t("page.list.title")}</Title>
<Menu <Menu width={256} trapFocus position="bottom-start" withinPortal shadow="md" keepMounted={false}>
width={256}
trapFocus
position="bottom-start"
withinPortal
shadow="md"
keepMounted={false}
>
<MenuTarget> <MenuTarget>
<Button rightSection={<IconChevronDown size={16} stroke={1.5} />}> <Button rightSection={<IconChevronDown size={16} stroke={1.5} />}>{t("action.create")}</Button>
{t("action.create")}
</Button>
</MenuTarget> </MenuTarget>
<MenuDropdown> <MenuDropdown>
<IntegrationCreateDropdownContent /> <IntegrationCreateDropdownContent />
@@ -73,10 +62,7 @@ export default async function IntegrationsPage({
</Menu> </Menu>
</Group> </Group>
<IntegrationList <IntegrationList integrations={integrations} activeTab={searchParams.tab} />
integrations={integrations}
activeTab={searchParams.tab}
/>
</Stack> </Stack>
</Container> </Container>
); );
@@ -87,10 +73,7 @@ interface IntegrationListProps {
activeTab?: IntegrationKind; activeTab?: IntegrationKind;
} }
const IntegrationList = async ({ const IntegrationList = async ({ integrations, activeTab }: IntegrationListProps) => {
integrations,
activeTab,
}: IntegrationListProps) => {
const t = await getScopedI18n("integration"); const t = await getScopedI18n("integration");
if (integrations.length === 0) { if (integrations.length === 0) {
@@ -134,12 +117,7 @@ const IntegrationList = async ({
<TableTr key={integration.id}> <TableTr key={integration.id}>
<TableTd>{integration.name}</TableTd> <TableTd>{integration.name}</TableTd>
<TableTd> <TableTd>
<Anchor <Anchor href={integration.url} target="_blank" rel="noreferrer" size="sm">
href={integration.url}
target="_blank"
rel="noreferrer"
size="sm"
>
{integration.url} {integration.url}
</Anchor> </Anchor>
</TableTd> </TableTd>
@@ -148,17 +126,14 @@ const IntegrationList = async ({
<ActionIconGroup> <ActionIconGroup>
<ActionIcon <ActionIcon
component={Link} component={Link}
href={`/integrations/edit/${integration.id}`} href={`/manage/integrations/edit/${integration.id}`}
variant="subtle" variant="subtle"
color="gray" color="gray"
aria-label="Edit integration" aria-label="Edit integration"
> >
<IconPencil size={16} stroke={1.5} /> <IconPencil size={16} stroke={1.5} />
</ActionIcon> </ActionIcon>
<DeleteIntegrationActionButton <DeleteIntegrationActionButton integration={integration} count={integrations.length} />
integration={integration}
count={integrations.length}
/>
</ActionIconGroup> </ActionIconGroup>
</Group> </Group>
</TableTd> </TableTd>
@@ -14,6 +14,7 @@ import {
IconMailForward, IconMailForward,
IconPlug, IconPlug,
IconQuestionMark, IconQuestionMark,
IconSettings,
IconTool, IconTool,
IconUser, IconUser,
IconUsers, IconUsers,
@@ -87,6 +88,11 @@ export default async function ManageLayout({ children }: PropsWithChildren) {
}, },
], ],
}, },
{
label: t("items.settings"),
href: "/manage/settings",
icon: IconSettings,
},
{ {
label: t("items.help.label"), label: t("items.help.label"),
icon: IconQuestionMark, icon: IconQuestionMark,
@@ -0,0 +1,17 @@
import { Center, Stack, Text, Title } from "@mantine/core";
import { getScopedI18n } from "@homarr/translation/server";
export default async function NotFound() {
const t = await getScopedI18n("management.notFound");
return (
<Center h="100%">
<Stack align="center">
<Title order={1} tt="uppercase">
{t("title")}
</Title>
<Text>{t("text")}</Text>
</Stack>
</Center>
);
}
+76 -15
View File
@@ -1,33 +1,94 @@
import { Title } from "@mantine/core"; import Link from "next/link";
import { Card, Group, SimpleGrid, Space, Stack, Text } from "@mantine/core";
import { IconArrowRight } from "@tabler/icons-react";
import { api } from "@homarr/api/server";
import { getScopedI18n } from "@homarr/translation/server"; import { getScopedI18n } from "@homarr/translation/server";
import { Test } from "./test"; import { createMetaTitle } from "~/metadata";
import { HeroBanner } from "./_components/hero-banner";
interface LinkProps {
title: string;
subtitle: string;
count: number;
href: string;
}
export async function generateMetadata() { export async function generateMetadata() {
const t = await getScopedI18n("management"); const t = await getScopedI18n("management");
const metaTitle = `${t("metaTitle")} • Homarr`;
return { return {
title: metaTitle, title: createMetaTitle(t("metaTitle")),
}; };
} }
export default async function ManagementPage() { export default async function ManagementPage() {
const t = await getScopedI18n("management.title"); const statistics = await api.home.getStats();
const t = await getScopedI18n("management.page.home");
const dateNow = new Date();
const timeOfDay =
dateNow.getHours() < 10
? "morning"
: dateNow.getHours() < 17
? "afternoon"
: "evening";
const links: LinkProps[] = [
{
count: statistics.countBoards,
href: "/manage/boards",
subtitle: t("statisticLabel.boards"),
title: t("statistic.countBoards"),
},
{
count: statistics.countUsers,
href: "/manage/boards",
subtitle: t("statisticLabel.authentication"),
title: t("statistic.createUser"),
},
{
count: statistics.countInvites,
href: "/manage/boards",
subtitle: t("statisticLabel.authentication"),
title: t("statistic.createInvite"),
},
{
count: statistics.countIntegrations,
href: "/manage/integrations",
subtitle: t("statisticLabel.resources"),
title: t("statistic.addIntegration"),
},
{
count: statistics.countApps,
href: "/manage/apps",
subtitle: t("statisticLabel.resources"),
title: t("statistic.addApp"),
},
{
count: statistics.countGroups,
href: "/manage/users/groups",
subtitle: t("statisticLabel.authorization"),
title: t("statistic.manageRoles"),
},
];
return ( return (
<> <>
<Title>{t(timeOfDay, { username: "admin" })}</Title> <HeroBanner />
<Test /> <Space h="md" />
<SimpleGrid cols={{ xs: 1, sm: 2, md: 3 }}>
{links.map((link, index) => (
<Card component={Link} href={link.href} key={`link-${index}`} withBorder>
<Group justify="space-between">
<Group>
<Text size="2.4rem" fw="bolder">
{link.count}
</Text>
<Stack gap={0}>
<Text c="red" size="xs">
{link.subtitle}
</Text>
<Text fw="bold">{link.title}</Text>
</Stack>
</Group>
<IconArrowRight />
</Group>
</Card>
))}
</SimpleGrid>
</> </>
); );
} }
@@ -0,0 +1,118 @@
"use client";
import type { ReactNode } from "react";
import React from "react";
import type { MantineSpacing } from "@mantine/core";
import { Card, Group, LoadingOverlay, Stack, Switch, Text, Title, UnstyledButton } from "@mantine/core";
import { clientApi } from "@homarr/api/client";
import type { UseFormReturnType } from "@homarr/form";
import { useForm } from "@homarr/form";
import type { defaultServerSettings } from "@homarr/server-settings";
import { useScopedI18n } from "@homarr/translation/client";
import { revalidatePathActionAsync } from "~/app/revalidatePathAction";
interface AnalyticsSettingsProps {
initialData: typeof defaultServerSettings.analytics;
}
export const AnalyticsSettings = ({ initialData }: AnalyticsSettingsProps) => {
const t = useScopedI18n("management.page.settings.section.analytics");
const form = useForm({
initialValues: initialData,
onValuesChange: (updatedValues, _) => {
if (!form.isValid()) {
return;
}
if (
!updatedValues.enableGeneral &&
(updatedValues.enableWidgetData || updatedValues.enableIntegrationData || updatedValues.enableUserData)
) {
updatedValues.enableIntegrationData = false;
updatedValues.enableUserData = false;
updatedValues.enableWidgetData = false;
}
void mutateAsync({
settingsKey: "analytics",
value: updatedValues,
});
},
});
const { mutateAsync, isPending } = clientApi.serverSettings.saveSettings.useMutation({
onSettled: async () => {
await revalidatePathActionAsync("/manage/settings");
},
});
return (
<>
<Title order={2}>{t("title")}</Title>
<Card pos="relative" withBorder>
<LoadingOverlay visible={isPending} zIndex={1000} overlayProps={{ radius: "sm", blur: 2 }} />
<Stack>
<SwitchSetting form={form} formKey="enableGeneral" title={t("general.title")} text={t("general.text")} />
<SwitchSetting
form={form}
formKey="enableIntegrationData"
ms="xl"
title={t("integrationData.title")}
text={t("integrationData.text")}
/>
<SwitchSetting
form={form}
formKey="enableWidgetData"
ms="xl"
title={t("widgetData.title")}
text={t("widgetData.text")}
/>
<SwitchSetting
form={form}
formKey="enableUserData"
ms="xl"
title={t("usersData.title")}
text={t("usersData.text")}
/>
</Stack>
</Card>
</>
);
};
const SwitchSetting = ({
form,
ms,
title,
text,
formKey,
}: {
form: UseFormReturnType<typeof defaultServerSettings.analytics>;
formKey: keyof typeof defaultServerSettings.analytics;
ms?: MantineSpacing;
title: string;
text: ReactNode;
}) => {
const disabled = formKey !== "enableGeneral" && !form.values.enableGeneral;
const handleClick = React.useCallback(() => {
if (disabled) {
return;
}
form.setFieldValue(formKey, !form.values[formKey]);
}, [form, formKey, disabled]);
return (
<Group ms={ms} justify="space-between" gap="lg" align="center" wrap="nowrap">
<UnstyledButton style={{ flexGrow: 1 }} onClick={handleClick}>
<Stack gap={0}>
<Text fw="bold">{title}</Text>
<Text c="gray.5">{text}</Text>
</Stack>
</UnstyledButton>
<Switch disabled={disabled} onClick={handleClick} checked={form.values[formKey] && !disabled} />
</Group>
);
};
@@ -0,0 +1,26 @@
import { Stack, Title } from "@mantine/core";
import { api } from "@homarr/api/server";
import { getScopedI18n } from "@homarr/translation/server";
import { AnalyticsSettings } from "./_components/analytics.settings";
export async function generateMetadata() {
const t = await getScopedI18n("management");
const metaTitle = `${t("metaTitle")} • Homarr`;
return {
title: metaTitle,
};
}
export default async function SettingsPage() {
const serverSettings = await api.serverSettings.getAll();
const t = await getScopedI18n("management.page.settings");
return (
<Stack>
<Title order={1}>{t("title")}</Title>
<AnalyticsSettings initialData={serverSettings.analytics} />
</Stack>
);
}
@@ -1,38 +0,0 @@
"use client";
import { useCallback, useState } from "react";
import type { ChangeEvent } from "react";
import { Button, Stack, Text, TextInput } from "@mantine/core";
import { clientApi } from "@homarr/api/client";
export const Test = () => {
const [value, setValue] = useState("");
const [message, setMessage] = useState<string>("Hello, world!");
const { mutate } = clientApi.user.setMessage.useMutation();
clientApi.user.test.useSubscription(undefined, {
onData({ message }) {
setMessage(message);
},
onError(err) {
alert(err);
},
});
const onChange = useCallback(
(event: ChangeEvent<HTMLInputElement>) => setValue(event.target.value),
[setValue],
);
const onClick = useCallback(() => {
mutate(value);
setValue("");
}, [mutate, value]);
return (
<Stack>
<TextInput label="Update message" value={value} onChange={onChange} />
<Button onClick={onClick}>Update message</Button>
<Text>This message gets through subscription: {message}</Text>
</Stack>
);
};
@@ -0,0 +1,154 @@
"use client";
import type { ButtonProps, MantineColor } from "@mantine/core";
import { Avatar, Badge, Box, Button, Group, Text } from "@mantine/core";
import { IconPlayerPlay, IconPlayerStop, IconRotateClockwise, IconTrash } from "@tabler/icons-react";
import type { MRT_ColumnDef } from "mantine-react-table";
import { MantineReactTable, useMantineReactTable } from "mantine-react-table";
import type { RouterOutputs } from "@homarr/api";
import { useTimeAgo } from "@homarr/common";
import type { DockerContainerState } from "@homarr/definitions";
import type { TranslationFunction } from "@homarr/translation";
import { useI18n, useScopedI18n } from "@homarr/translation/client";
import { OverflowBadge } from "@homarr/ui";
const createColumns = (
t: TranslationFunction,
): MRT_ColumnDef<RouterOutputs["docker"]["getContainers"]["containers"][number]>[] => [
{
accessorKey: "name",
header: t("docker.field.name.label"),
Cell({ renderedCellValue, row }) {
return (
<Group gap="xs">
<Avatar variant="outline" radius="lg" size="md" src={row.original.iconUrl}>
{row.original.name.at(0)?.toUpperCase()}
</Avatar>
<Text>{renderedCellValue}</Text>
</Group>
);
},
},
{
accessorKey: "state",
header: t("docker.field.state.label"),
size: 120,
Cell({ cell }) {
return <ContainerStateBadge state={cell.row.original.state} />;
},
},
{
accessorKey: "image",
header: t("docker.field.containerImage.label"),
maxSize: 200,
Cell({ renderedCellValue }) {
return (
<Box maw={200}>
<Text truncate="end">{renderedCellValue}</Text>
</Box>
);
},
},
{
accessorKey: "ports",
header: t("docker.field.ports.label"),
Cell({ cell }) {
return (
<OverflowBadge overflowCount={1} data={cell.row.original.ports.map((port) => port.PrivatePort.toString())} />
);
},
},
];
export function DockerTable({ containers, timestamp }: RouterOutputs["docker"]["getContainers"]) {
const t = useI18n();
const tDocker = useScopedI18n("docker");
const relativeTime = useTimeAgo(timestamp);
const table = useMantineReactTable({
data: containers,
enableDensityToggle: false,
enableColumnActions: false,
enableColumnFilters: false,
enablePagination: false,
enableRowSelection: true,
positionToolbarAlertBanner: "top",
enableTableFooter: false,
enableBottomToolbar: false,
positionGlobalFilter: "right",
mantineSearchTextInputProps: {
placeholder: tDocker("table.search", { count: containers.length }),
style: { minWidth: 300 },
autoFocus: true,
},
initialState: { density: "xs", showGlobalFilter: true },
renderToolbarAlertBannerContent: ({ groupedAlert, table }) => {
return (
<Group gap={"sm"}>
{groupedAlert}
<Text fw={500}>
{tDocker("table.selected", {
selectCount: table.getSelectedRowModel().rows.length,
totalCount: table.getRowCount(),
})}
</Text>
<ContainerActionBar />
</Group>
);
},
columns: createColumns(t),
});
return (
<>
<Text>{tDocker("table.updated", { when: relativeTime })}</Text>
<MantineReactTable table={table} />
</>
);
}
const ContainerActionBar = () => {
const t = useScopedI18n("docker.action");
const sharedButtonProps = {
variant: "light",
radius: "md",
} satisfies Partial<ButtonProps>;
return (
<Group gap="xs">
<Button leftSection={<IconPlayerPlay />} color="green" {...sharedButtonProps}>
{t("start")}
</Button>
<Button leftSection={<IconPlayerStop />} color="red" {...sharedButtonProps}>
{t("stop")}
</Button>
<Button leftSection={<IconRotateClockwise />} color="orange" {...sharedButtonProps}>
{t("restart")}
</Button>
<Button leftSection={<IconTrash />} color="red" {...sharedButtonProps}>
{t("remove")}
</Button>
</Group>
);
};
const containerStates = {
created: "cyan",
running: "green",
paused: "yellow",
restarting: "orange",
exited: "red",
removing: "pink",
dead: "dark",
} satisfies Record<DockerContainerState, MantineColor>;
const ContainerStateBadge = ({ state }: { state: DockerContainerState }) => {
const t = useScopedI18n("docker.field.state.option");
return (
<Badge size="lg" radius="sm" variant="light" w={120} color={containerStates[state]}>
{t(state)}
</Badge>
);
};
@@ -0,0 +1,18 @@
import { Stack, Title } from "@mantine/core";
import { api } from "@homarr/api/server";
import { getScopedI18n } from "@homarr/translation/server";
import { DockerTable } from "./DockerTable";
export default async function DockerPage() {
const { containers, timestamp } = await api.docker.getContainers();
const tDocker = await getScopedI18n("docker");
return (
<Stack>
<Title order={1}>{tDocker("title")}</Title>
<DockerTable containers={containers} timestamp={timestamp} />
</Stack>
);
}
@@ -7,13 +7,13 @@ import "@xterm/xterm/css/xterm.css";
import dynamic from "next/dynamic"; import dynamic from "next/dynamic";
import { fullHeightWithoutHeaderAndFooter } from "~/constants"; import { fullHeightWithoutHeaderAndFooter } from "~/constants";
import { createMetaTitle } from "~/metadata";
export async function generateMetadata() { export async function generateMetadata() {
const t = await getScopedI18n("management"); const t = await getScopedI18n("management");
const metaTitle = `${t("metaTitle")} • Homarr`;
return { return {
title: metaTitle, title: createMetaTitle(t("metaTitle")),
}; };
} }
@@ -23,12 +23,7 @@ const ClientSideTerminalComponent = dynamic(() => import("./terminal"), {
export default function LogsManagementPage() { export default function LogsManagementPage() {
return ( return (
<Box <Box style={{ borderRadius: 6 }} h={fullHeightWithoutHeaderAndFooter} p="md" bg="black">
style={{ borderRadius: 6 }}
h={fullHeightWithoutHeaderAndFooter}
p="md"
bg="black"
>
<ClientSideTerminalComponent /> <ClientSideTerminalComponent />
</Box> </Box>
); );
@@ -16,9 +16,7 @@ export default function TerminalComponent() {
const terminalRef = useRef<Terminal>(); const terminalRef = useRef<Terminal>();
clientApi.log.subscribe.useSubscription(undefined, { clientApi.log.subscribe.useSubscription(undefined, {
onData(data) { onData(data) {
terminalRef.current?.writeln( terminalRef.current?.writeln(`${data.timestamp} ${data.level} ${data.message}`);
`${data.timestamp} ${data.level} ${data.message}`,
);
terminalRef.current?.refresh(0, terminalRef.current.rows - 1); terminalRef.current?.refresh(0, terminalRef.current.rows - 1);
}, },
onError(err) { onError(err) {
@@ -55,12 +53,5 @@ export default function TerminalComponent() {
canvasAddon.dispose(); canvasAddon.dispose();
}; };
}, []); }, []);
return ( return <Box ref={ref} id="terminal" className={classes.outerTerminal} h="100%"></Box>;
<Box
ref={ref}
id="terminal"
className={classes.outerTerminal}
h="100%"
></Box>
);
} }
@@ -1,46 +0,0 @@
"use client";
import React from "react";
import { useRouter } from "next/navigation";
import { Button, Divider, Group, Stack, Text } from "@mantine/core";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import { useScopedI18n } from "@homarr/translation/client";
interface DangerZoneAccordionProps {
user: NonNullable<RouterOutputs["user"]["getById"]>;
}
export const DangerZoneAccordion = ({ user }: DangerZoneAccordionProps) => {
const t = useScopedI18n("management.page.user.edit.section.dangerZone");
const router = useRouter();
const { mutateAsync: mutateUserDeletionAsync } =
clientApi.user.delete.useMutation({
onSettled: () => {
router.push("/manage/users");
},
});
const handleDelete = React.useCallback(
async () => await mutateUserDeletionAsync(user.id),
[user, mutateUserDeletionAsync],
);
return (
<Stack>
<Divider />
<Group justify="space-between" px="md">
<Stack gap={0}>
<Text fw="bold" size="sm">
{t("action.delete.label")}
</Text>
<Text size="sm">{t("action.delete.description")}</Text>
</Stack>
<Button onClick={handleDelete} variant="subtle" color="red">
{t("action.delete.button")}
</Button>
</Group>
</Stack>
);
};
@@ -1,59 +0,0 @@
"use client";
import { Button, Stack, TextInput } from "@mantine/core";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import { useForm, zodResolver } from "@homarr/form";
import { useI18n } from "@homarr/translation/client";
import { validation } from "@homarr/validation";
import { revalidatePathAction } from "~/app/revalidatePathAction";
interface ProfileAccordionProps {
user: NonNullable<RouterOutputs["user"]["getById"]>;
}
export const ProfileAccordion = ({ user }: ProfileAccordionProps) => {
const t = useI18n();
const { mutate, isPending } = clientApi.user.editProfile.useMutation({
onSettled: async () => {
await revalidatePathAction("/manage/users");
},
});
const form = useForm({
initialValues: {
name: user.name ?? "",
email: user.email ?? "",
},
validate: zodResolver(validation.user.editProfile),
validateInputOnBlur: true,
validateInputOnChange: true,
});
const handleSubmit = () => {
mutate({
userId: user.id,
form: form.values,
});
};
return (
<form onSubmit={form.onSubmit(handleSubmit)}>
<Stack>
<TextInput
label={t("user.field.username.label")}
withAsterisk
{...form.getInputProps("name")}
/>
<TextInput
label={t("user.field.email.label")}
{...form.getInputProps("email")}
/>
<Button type="submit" disabled={!form.isValid()} loading={isPending}>
{t("common.action.save")}
</Button>
</Stack>
</form>
);
};
@@ -1,79 +0,0 @@
"use client";
import { Button, PasswordInput, Stack, Title } from "@mantine/core";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import { useForm, zodResolver } from "@homarr/form";
import { showSuccessNotification } from "@homarr/notifications";
import { useI18n } from "@homarr/translation/client";
import { validation } from "@homarr/validation";
import { revalidatePathAction } from "~/app/revalidatePathAction";
interface SecurityAccordionComponentProps {
user: NonNullable<RouterOutputs["user"]["getById"]>;
}
export const SecurityAccordionComponent = ({
user,
}: SecurityAccordionComponentProps) => {
return (
<Stack>
<ChangePasswordForm user={user} />
</Stack>
);
};
const ChangePasswordForm = ({
user,
}: {
user: NonNullable<RouterOutputs["user"]["getById"]>;
}) => {
const t = useI18n();
const { mutate, isPending } = clientApi.user.changePassword.useMutation({
onSettled: async () => {
await revalidatePathAction(`/manage/users/${user.id}`);
showSuccessNotification({
title: t(
"management.page.user.edit.section.security.changePassword.message.passwordUpdated",
),
message: "",
});
},
});
const form = useForm({
initialValues: {
userId: user.id,
password: "",
},
validate: zodResolver(validation.user.changePassword),
validateInputOnBlur: true,
validateInputOnChange: true,
});
const handleSubmit = () => {
mutate(form.values);
};
return (
<form onSubmit={form.onSubmit(handleSubmit)}>
<Stack>
<Stack gap={0}>
<Title order={5}>
{t(
"management.page.user.edit.section.security.changePassword.title",
)}
</Title>
<PasswordInput
label={t("user.field.password.label")}
{...form.getInputProps("password")}
/>
</Stack>
<Button loading={isPending} type="submit" disabled={!form.isValid()}>
{t("common.action.confirm")}
</Button>
</Stack>
</form>
);
};
@@ -0,0 +1,17 @@
import type { Session } from "@homarr/auth";
export const canAccessUserEditPage = (session: Session | null, userId: string) => {
if (!session) {
return false;
}
if (session.user.id === userId) {
return true;
}
if (session.user.permissions.includes("admin")) {
return true;
}
return false;
};
@@ -0,0 +1,46 @@
"use client";
import { useCallback } from "react";
import { useRouter } from "next/navigation";
import { Button } from "@mantine/core";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import { useConfirmModal } from "@homarr/modals";
import { useI18n } from "@homarr/translation/client";
import { revalidatePathActionAsync } from "~/app/revalidatePathAction";
interface DeleteUserButtonProps {
user: RouterOutputs["user"]["getById"];
}
export const DeleteUserButton = ({ user }: DeleteUserButtonProps) => {
const t = useI18n();
const router = useRouter();
const { mutateAsync: mutateUserDeletionAsync } = clientApi.user.delete.useMutation({
async onSuccess() {
await revalidatePathActionAsync("/manage/users").then(() => router.push("/manage/users"));
},
});
const { openConfirmModal } = useConfirmModal();
const handleDelete = useCallback(
() =>
openConfirmModal({
title: t("user.action.delete.label"),
children: t("user.action.delete.confirm", { username: user.name }),
// eslint-disable-next-line no-restricted-syntax
async onConfirm() {
await mutateUserDeletionAsync(user.id);
},
}),
[user, mutateUserDeletionAsync, openConfirmModal, t],
);
return (
<Button onClick={handleDelete} variant="subtle" color="red">
{t("common.action.delete")}
</Button>
);
};
@@ -0,0 +1,141 @@
"use client";
import { useCallback } from "react";
import { Box, Button, FileButton, Menu, UnstyledButton } from "@mantine/core";
import { useDisclosure } from "@mantine/hooks";
import { IconPencil, IconPhotoEdit, IconPhotoX } from "@tabler/icons-react";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import { useConfirmModal } from "@homarr/modals";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useI18n, useScopedI18n } from "@homarr/translation/client";
import { UserAvatar } from "@homarr/ui";
import { revalidatePathActionAsync } from "~/app/revalidatePathAction";
interface UserProfileAvatarForm {
user: RouterOutputs["user"]["getById"];
}
export const UserProfileAvatarForm = ({ user }: UserProfileAvatarForm) => {
const { mutate } = clientApi.user.setProfileImage.useMutation();
const [opened, { toggle }] = useDisclosure(false);
const { openConfirmModal } = useConfirmModal();
const t = useI18n();
const tManageAvatar = useScopedI18n("user.action.manageAvatar");
const handleAvatarChange = useCallback(
async (file: File | null) => {
if (!file) {
return;
}
const base64Url = await fileToBase64Async(file);
mutate(
{
userId: user.id,
image: base64Url,
},
{
async onSuccess() {
// Revalidate all as the avatar is used in multiple places
await revalidatePathActionAsync("/");
showSuccessNotification({
message: tManageAvatar("changeImage.notification.success.message"),
});
},
onError(error) {
if (error.shape?.data.code === "BAD_REQUEST") {
showErrorNotification({
title: tManageAvatar("changeImage.notification.toLarge.title"),
message: tManageAvatar("changeImage.notification.toLarge.message", { size: "256KB" }),
});
} else {
showErrorNotification({
message: tManageAvatar("changeImage.notification.error.message"),
});
}
},
},
);
},
[mutate, user.id, tManageAvatar],
);
const handleRemoveAvatar = useCallback(() => {
openConfirmModal({
title: tManageAvatar("removeImage.label"),
children: tManageAvatar("removeImage.confirm"),
onConfirm() {
mutate(
{
userId: user.id,
image: null,
},
{
async onSuccess() {
// Revalidate all as the avatar is used in multiple places
await revalidatePathActionAsync("/");
showSuccessNotification({
message: tManageAvatar("removeImage.notification.success.message"),
});
},
onError() {
showErrorNotification({
message: tManageAvatar("removeImage.notification.error.message"),
});
},
},
);
},
});
}, [mutate, user.id, openConfirmModal, tManageAvatar]);
return (
<Box pos="relative">
<Menu opened={opened} keepMounted onChange={toggle} position="bottom-start" withArrow>
<Menu.Target>
<UnstyledButton onClick={toggle}>
<UserAvatar user={user} size={200} />
<Button
component="div"
pos="absolute"
bottom={0}
left={0}
size="compact-md"
fw="normal"
variant="default"
leftSection={<IconPencil size={18} stroke={1.5} />}
>
{t("common.action.edit")}
</Button>
</UnstyledButton>
</Menu.Target>
<Menu.Dropdown>
<FileButton onChange={handleAvatarChange} accept="image/png,image/jpeg,image/webp,image/gif">
{(props) => (
<Menu.Item {...props} leftSection={<IconPhotoEdit size={16} stroke={1.5} />}>
{tManageAvatar("changeImage.label")}
</Menu.Item>
)}
</FileButton>
{user.image && (
<Menu.Item onClick={handleRemoveAvatar} leftSection={<IconPhotoX size={16} stroke={1.5} />}>
{tManageAvatar("removeImage.label")}
</Menu.Item>
)}
</Menu.Dropdown>
</Menu>
</Box>
);
};
const fileToBase64Async = async (file: File): Promise<string> =>
new Promise((resolve, reject) => {
const reader = new FileReader();
reader.readAsDataURL(file);
reader.onload = () => resolve(reader.result?.toString() || "");
reader.onerror = reject;
});
@@ -0,0 +1,80 @@
"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 { useZodForm } from "@homarr/form";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useI18n } from "@homarr/translation/client";
import { validation } from "@homarr/validation";
import { revalidatePathActionAsync } from "~/app/revalidatePathAction";
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(validation.user.editProfile.omit({ id: true }), {
initialValues: {
name: user.name ?? "",
email: user.email ?? "",
},
});
const handleSubmit = useCallback(
(values: FormType) => {
mutate({
...values,
id: user.id,
});
},
[user.id, mutate],
);
return (
<form onSubmit={form.onSubmit(handleSubmit)}>
<Stack>
<TextInput label={t("user.field.username.label")} withAsterisk {...form.getInputProps("name")} />
<TextInput label={t("user.field.email.label")} {...form.getInputProps("email")} />
<Group justify="end">
<Button type="submit" color="teal" disabled={!form.isDirty()} loading={isPending}>
{t("common.action.saveChanges")}
</Button>
</Group>
</Stack>
</form>
);
};
type FormType = Omit<RouterInputs["user"]["editProfile"], "id">;
@@ -0,0 +1,12 @@
import { Stack, Title } from "@mantine/core";
import { LanguageCombobox } from "~/components/language/language-combobox";
export const ProfileLanguageChange = () => {
return (
<Stack mb="lg">
<Title order={2}>Language & Region</Title>
<LanguageCombobox />
</Stack>
);
};
@@ -0,0 +1,79 @@
import { notFound } from "next/navigation";
import { Box, Group, Stack, Title } from "@mantine/core";
import { api } from "@homarr/api/server";
import { auth } from "@homarr/auth/next";
import { getI18n, getScopedI18n } from "@homarr/translation/server";
import { DangerZoneItem, DangerZoneRoot } from "~/components/manage/danger-zone";
import { catchTrpcNotFound } from "~/errors/trpc-not-found";
import { createMetaTitle } from "~/metadata";
import { canAccessUserEditPage } from "../access";
import { DeleteUserButton } from "./_components/_delete-user-button";
import { UserProfileAvatarForm } from "./_components/_profile-avatar-form";
import { UserProfileForm } from "./_components/_profile-form";
import { ProfileLanguageChange } from "./_components/_profile-language-change";
interface Props {
params: {
userId: string;
};
}
export async function generateMetadata({ params }: Props) {
const session = await auth();
const user = await api.user
.getById({
userId: params.userId,
})
.catch(() => null);
if (!user || !canAccessUserEditPage(session, user.id)) {
return {};
}
const t = await getScopedI18n("management.page.user.edit");
return {
title: createMetaTitle(t("metaTitle", { username: user?.name })),
};
}
export default async function EditUserPage({ params }: Props) {
const t = await getI18n();
const tGeneral = await getScopedI18n("management.page.user.setting.general");
const session = await auth();
const user = await api.user
.getById({
userId: params.userId,
})
.catch(catchTrpcNotFound);
if (!canAccessUserEditPage(session, user.id)) {
notFound();
}
return (
<Stack>
<Title>{tGeneral("title")}</Title>
<Group gap="xl">
<Box flex={1}>
<UserProfileForm user={user} />
</Box>
<Box w={{ base: "100%", lg: 200 }}>
<UserProfileAvatarForm user={user} />
</Box>
</Group>
<ProfileLanguageChange />
<DangerZoneRoot>
<DangerZoneItem
label={t("user.action.delete.label")}
description={t("user.action.delete.description")}
action={<DeleteUserButton user={user} />}
/>
</DangerZoneRoot>
</Stack>
);
}
@@ -0,0 +1,69 @@
import type { PropsWithChildren } from "react";
import Link from "next/link";
import { notFound } from "next/navigation";
import { Button, Container, Grid, GridCol, Group, Stack, Text, Title } from "@mantine/core";
import { IconSettings, IconShieldLock } from "@tabler/icons-react";
import { api } from "@homarr/api/server";
import { auth } from "@homarr/auth/next";
import { getI18n, getScopedI18n } from "@homarr/translation/server";
import { UserAvatar } from "@homarr/ui";
import { catchTrpcNotFound } from "~/errors/trpc-not-found";
import { NavigationLink } from "../groups/[id]/_navigation";
import { canAccessUserEditPage } from "./access";
interface LayoutProps {
params: { userId: string };
}
export default async function Layout({ children, params }: PropsWithChildren<LayoutProps>) {
const session = await auth();
const t = await getI18n();
const tUser = await getScopedI18n("management.page.user");
const user = await api.user.getById({ userId: params.userId }).catch(catchTrpcNotFound);
if (!canAccessUserEditPage(session, user.id)) {
notFound();
}
return (
<Container size="xl">
<Grid>
<GridCol span={12}>
<Group justify="space-between" align="center">
<Group>
<UserAvatar user={user} size="lg" />
<Stack gap={0}>
<Title order={3}>{user.name}</Title>
<Text c="gray.5">{t("user.name")}</Text>
</Stack>
</Group>
{session?.user.permissions.includes("admin") && (
<Button component={Link} href="/manage/users" color="gray" variant="light">
{tUser("back")}
</Button>
)}
</Group>
</GridCol>
<GridCol span={{ xs: 12, md: 4, lg: 3, xl: 2 }}>
<Stack>
<Stack gap={0}>
<NavigationLink
href={`/manage/users/${params.userId}/general`}
label={tUser("setting.general.title")}
icon={<IconSettings size="1rem" stroke={1.5} />}
/>
<NavigationLink
href={`/manage/users/${params.userId}/security`}
label={tUser("setting.security.title")}
icon={<IconShieldLock size="1rem" stroke={1.5} />}
/>
</Stack>
</Stack>
</GridCol>
<GridCol span={{ xs: 12, md: 8, lg: 9, xl: 10 }}>{children}</GridCol>
</Grid>
</Container>
);
}
@@ -1,113 +0,0 @@
import { notFound } from "next/navigation";
import {
Accordion,
AccordionControl,
AccordionItem,
AccordionPanel,
Avatar,
Group,
Stack,
Text,
Title,
} from "@mantine/core";
import {
IconAlertTriangleFilled,
IconSettingsFilled,
IconShieldLockFilled,
IconUserFilled,
} from "@tabler/icons-react";
import { api } from "@homarr/api/server";
import { getScopedI18n } from "@homarr/translation/server";
import { DangerZoneAccordion } from "./_components/dangerZone.accordion";
import { ProfileAccordion } from "./_components/profile.accordion";
import { SecurityAccordionComponent } from "./_components/security.accordion";
interface Props {
params: {
userId: string;
};
}
export async function generateMetadata({ params }: Props) {
const user = await api.user.getById({
userId: params.userId,
});
const t = await getScopedI18n("management.page.user.edit");
const metaTitle = `${t("metaTitle", { username: user?.name })} • Homarr`;
return {
title: metaTitle,
};
}
export default async function EditUserPage({ params }: Props) {
const t = await getScopedI18n("management.page.user.edit");
const user = await api.user.getById({
userId: params.userId,
});
if (!user) {
notFound();
}
return (
<Stack>
<Group mb="md">
<Avatar>{user.name?.substring(0, 2)}</Avatar>
<Title>{user.name}</Title>
</Group>
<Accordion variant="separated" defaultValue="general">
<AccordionItem value="general">
<AccordionControl icon={<IconUserFilled />}>
<Text fw="bold" size="lg">
{t("section.profile.title")}
</Text>
</AccordionControl>
<AccordionPanel>
<ProfileAccordion user={user} />
</AccordionPanel>
</AccordionItem>
<AccordionItem value="preferences">
<AccordionControl icon={<IconSettingsFilled />}>
<Text fw="bold" size="lg">
{t("section.preferences.title")}
</Text>
</AccordionControl>
<AccordionPanel></AccordionPanel>
</AccordionItem>
<AccordionItem value="security">
<AccordionControl icon={<IconShieldLockFilled />}>
<Text fw="bold" size="lg">
{t("section.security.title")}
</Text>
</AccordionControl>
<AccordionPanel>
<SecurityAccordionComponent user={user} />
</AccordionPanel>
</AccordionItem>
<AccordionItem
styles={{
item: {
borderColor: "rgba(248,81,73,0.4)",
borderWidth: 4,
},
}}
value="dangerZone"
>
<AccordionControl icon={<IconAlertTriangleFilled />}>
<Text fw="bold" size="lg">
{t("section.dangerZone.title")}
</Text>
</AccordionControl>
<AccordionPanel
styles={{ content: { paddingRight: 0, paddingLeft: 0 } }}
>
<DangerZoneAccordion user={user} />
</AccordionPanel>
</AccordionItem>
</Accordion>
</Stack>
);
}
@@ -0,0 +1,94 @@
"use client";
import { Button, Fieldset, Group, PasswordInput, Stack } from "@mantine/core";
import type { RouterInputs, RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import { useSession } from "@homarr/auth/client";
import { useZodForm } from "@homarr/form";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useI18n } from "@homarr/translation/client";
import { validation } from "@homarr/validation";
import { revalidatePathActionAsync } from "~/app/revalidatePathAction";
interface ChangePasswordFormProps {
user: RouterOutputs["user"]["getById"];
}
export const ChangePasswordForm = ({ user }: ChangePasswordFormProps) => {
const { data: session } = useSession();
const t = useI18n();
const { mutate, isPending } = clientApi.user.changePassword.useMutation({
async onSettled() {
await revalidatePathActionAsync(`/manage/users/${user.id}`);
},
onSuccess() {
showSuccessNotification({
message: t("user.action.changePassword.notification.success.message"),
});
},
onError() {
showErrorNotification({
message: t("user.action.changePassword.notification.error.message"),
});
},
});
const form = useZodForm(validation.user.changePassword, {
initialValues: {
/* Require previous password if the current user want's to change his password */
previousPassword: session?.user.id === user.id ? "" : "_",
password: "",
confirmPassword: "",
},
});
const handleSubmit = (values: FormType) => {
mutate(
{
userId: user.id,
...values,
},
{
onSettled() {
form.reset();
},
},
);
};
return (
<form onSubmit={form.onSubmit(handleSubmit)}>
<Stack>
<Fieldset legend={t("user.action.changePassword.label")}>
<Stack gap="xs">
{/* Require previous password if the current user want's to change his password */}
{session?.user.id === user.id && (
<PasswordInput
withAsterisk
label={t("user.field.previousPassword.label")}
{...form.getInputProps("previousPassword")}
/>
)}
<PasswordInput withAsterisk label={t("user.field.password.label")} {...form.getInputProps("password")} />
<PasswordInput
withAsterisk
label={t("user.field.passwordConfirm.label")}
{...form.getInputProps("confirmPassword")}
/>
<Group justify="end">
<Button type="submit" color="teal" loading={isPending}>
{t("common.action.confirm")}
</Button>
</Group>
</Stack>
</Fieldset>
</Stack>
</form>
);
};
type FormType = Omit<RouterInputs["user"]["changePassword"], "userId">;
@@ -0,0 +1,38 @@
import { notFound } from "next/navigation";
import { Stack, Title } from "@mantine/core";
import { api } from "@homarr/api/server";
import { auth } from "@homarr/auth/next";
import { getScopedI18n } from "@homarr/translation/server";
import { catchTrpcNotFound } from "~/errors/trpc-not-found";
import { canAccessUserEditPage } from "../access";
import { ChangePasswordForm } from "./_components/_change-password-form";
interface Props {
params: {
userId: string;
};
}
export default async function UserSecurityPage({ params }: Props) {
const session = await auth();
const tSecurity = await getScopedI18n("management.page.user.setting.security");
const user = await api.user
.getById({
userId: params.userId,
})
.catch(catchTrpcNotFound);
if (!canAccessUserEditPage(session, user.id)) {
notFound();
}
return (
<Stack>
<Title>{tSecurity("title")}</Title>
<ChangePasswordForm user={user} />
</Stack>
);
}
Loaded 100 of 390 files, more files were not shown because too many files have changed in this diff. Show more