Compare commits
128
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3498fdaebb | ||
|
|
5dc5d9e04a | ||
|
|
4f09363823 | ||
|
|
c132a07213 | ||
|
|
86e778db1e | ||
|
|
ee1ed0d076 | ||
|
|
675ed5bdc1 | ||
|
|
fb467ac165 | ||
|
|
9d54e938c8 | ||
|
|
f6029fa79d | ||
|
|
039d881418 | ||
|
|
4f220ffca1 | ||
|
|
a54e0783e2 | ||
|
|
1cf36ed4c7 | ||
|
|
afd7bcea84 | ||
|
|
b58f64b05c | ||
|
|
118f19a4f2 | ||
|
|
52549a598d | ||
|
|
311ae80667 | ||
|
|
8b7596642a | ||
|
|
078aacd9fa | ||
|
|
bf600d0ae5 | ||
|
|
de5c34ab07 | ||
|
|
58babbf88f | ||
|
|
3cca0249a4 | ||
|
|
59b1cb48eb | ||
|
|
67d48e11d7 | ||
|
|
6420feee72 | ||
|
|
63e96230e0 | ||
|
|
8b7caf1d7d | ||
|
|
7705bc44ae | ||
|
|
8e71b882db | ||
|
|
e84cc5e6ae | ||
|
|
241037fc2a | ||
|
|
bd0d910137 | ||
|
|
146fe499db | ||
|
|
96a071725e | ||
|
|
91c61f8c06 | ||
|
|
c59fbfc677 | ||
|
|
602faf6221 | ||
|
|
cdc1d38be6 | ||
|
|
4764f41e1b | ||
|
|
f6450cb4b4 | ||
|
|
a4317f9479 | ||
|
|
a62aa09db6 | ||
|
|
f32af66a6a | ||
|
|
4152ca7366 | ||
|
|
ac941362de | ||
|
|
f8fedefde9 | ||
|
|
5154b6d17a | ||
|
|
f6c923017c | ||
|
|
22a341ea4d | ||
|
|
3257f6101b | ||
|
|
9c4e5f9842 | ||
|
|
ffe7259802 | ||
|
|
33ef9f6678 | ||
|
|
91bbc47b00 | ||
|
|
d7822d2b98 | ||
|
|
51bea03099 | ||
|
|
8ee39eb6db | ||
|
|
4074d189e3 | ||
|
|
7a7f40a140 | ||
|
|
1f9442f7f4 | ||
|
|
a38afb05d3 | ||
|
|
fae1aacf20 | ||
|
|
d508fd466d | ||
|
|
4d3cc1d806 | ||
|
|
e1fa92b91e | ||
|
|
10454656a2 | ||
|
|
ee08725d53 | ||
|
|
379675a901 | ||
|
|
8649faca79 | ||
|
|
91260bf0d4 | ||
|
|
51038cc03c | ||
|
|
fc43da59f8 | ||
|
|
2399967868 | ||
|
|
480dae09c4 | ||
|
|
2d2ba2e088 | ||
|
|
0502e47770 | ||
|
|
3c877be44a | ||
|
|
8371f46d34 | ||
|
|
2b9f834d96 | ||
|
|
8ec0080fca | ||
|
|
b7fd2470ee | ||
|
|
749b0c6ee9 | ||
|
|
25b7419d7b | ||
|
|
7edaa48fb9 | ||
|
|
627064b632 | ||
|
|
42a68140b1 | ||
|
|
f6a5c04017 | ||
|
|
2e150d548f | ||
|
|
584c660906 | ||
|
|
7abc4e4e79 | ||
|
|
0e553a5f8f | ||
|
|
dff6cb9d31 | ||
|
|
c04c42dc8a | ||
|
|
60323b2651 | ||
|
|
93235cab6f | ||
|
|
c849109df5 | ||
|
|
0da5a1b0fb | ||
|
|
7a8e671205 | ||
|
|
3cbb656ee9 | ||
|
|
55a493c475 | ||
|
|
7bbbaf343b | ||
|
|
09b6323246 | ||
|
|
3d0b319a88 | ||
|
|
26468a4137 | ||
|
|
62c83b79b4 | ||
|
|
da09826393 | ||
|
|
8058b6207a | ||
|
|
c623a420ca | ||
|
|
d5e36835f6 | ||
|
|
1f12a893ff | ||
|
|
bb67bf5fa2 | ||
|
|
be603badfe | ||
|
|
0d97cc50c2 | ||
|
|
14a4b94911 | ||
|
|
83044d23dd | ||
|
|
12456af101 | ||
|
|
8c656febeb | ||
|
|
2598ade925 | ||
|
|
46cfb2383b | ||
|
|
a385337268 | ||
|
|
bc79c6abab | ||
|
|
5f36d8b125 | ||
|
|
6a9b65f219 | ||
|
|
6ce7bc0ee2 | ||
|
|
36c246cca8 |
No files matched your search
@@ -12,6 +12,8 @@ AUTH_SECRET="supersecret"
|
||||
# or starting the project without any (which will show a randomly generated one).
|
||||
SECRET_ENCRYPTION_KEY=0000000000000000000000000000000000000000000000000000000000000000
|
||||
|
||||
LOG_LEVEL='info'
|
||||
|
||||
# This is how you can use the sqlite driver:
|
||||
DB_DRIVER='better-sqlite3'
|
||||
DB_URL='FULL_PATH_TO_YOUR_SQLITE_DB_FILE'
|
||||
|
||||
@@ -31,6 +31,9 @@ body:
|
||||
label: Version
|
||||
description: What version of Homarr are you running?
|
||||
options:
|
||||
- 1.6.0
|
||||
- 1.5.0
|
||||
- 1.4.0
|
||||
- 1.3.1
|
||||
- 1.3.0
|
||||
- 1.2.0
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
**Thank you for your contribution. Please ensure that your pull request meets the following pull request:**
|
||||
|
||||
- [ ] Builds without warnings or errors (``pnpm buid``, autofix with ``pnpm format:fix``)
|
||||
- [ ] Builds without warnings or errors (``pnpm build``, autofix with ``pnpm format:fix``)
|
||||
- [ ] Pull request targets ``dev`` branch
|
||||
- [ ] Commits follow the [conventional commits guideline](https://www.conventionalcommits.org/en/v1.0.0/)
|
||||
- [ ] No shorthand variable names are used (eg. ``x``, ``y``, ``i`` or any abbrevation)
|
||||
|
||||
@@ -96,10 +96,11 @@ jobs:
|
||||
run: |
|
||||
git config user.name "Releases Homarr"
|
||||
git config user.email "175486441+homarr-releases[bot]@users.noreply.github.com"
|
||||
git remote set-url origin https://x-access-token:${{ steps.obtainToken.outputs.token }}@github.com/${{ github.repository }}.git
|
||||
git fetch origin dev
|
||||
git checkout dev
|
||||
git pull origin dev
|
||||
git merge ${{ github.ref_name }}
|
||||
git rebase ${{ github.ref_name }}
|
||||
git push origin dev
|
||||
deploy:
|
||||
name: Deploy docker image
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
<component name="ProjectRunConfigurationManager">
|
||||
<configuration default="false" name="typecheck" type="js.build_tools.npm">
|
||||
<package-json value="$PROJECT_DIR$/package.json" />
|
||||
<command value="run" />
|
||||
<scripts>
|
||||
<script value="typecheck" />
|
||||
</scripts>
|
||||
<node-interpreter value="project" />
|
||||
<package-manager value="pnpm" />
|
||||
<envs />
|
||||
<method v="2" />
|
||||
</configuration>
|
||||
</component>
|
||||
@@ -1,3 +1,93 @@
|
||||
## [1.7.0](https://github.com/homarr-labs/homarr/compare/v1.6.0...v1.7.0) (2025-02-21)
|
||||
|
||||
### Features
|
||||
|
||||
* add tile border radius ([#2338](https://github.com/homarr-labs/homarr/issues/2338)) ([7705bc4](https://github.com/homarr-labs/homarr/commit/7705bc44ae7c93751e718b019c98c11369c221af))
|
||||
* **board:** allow to set icon color of widgets ([#2228](https://github.com/homarr-labs/homarr/issues/2228)) ([de5c34a](https://github.com/homarr-labs/homarr/commit/de5c34ab079c308c80cc3b1ba8f9c8a265e3a1c3))
|
||||
* **cli:** add fix-username command to make all usernames lowercase ([#2366](https://github.com/homarr-labs/homarr/issues/2366)) ([8b7caf1](https://github.com/homarr-labs/homarr/commit/8b7caf1d7dfebb8121cefd301fa72ef59601d410))
|
||||
* downgrade log level callback succeeded ([#2329](https://github.com/homarr-labs/homarr/issues/2329)) ([22a341e](https://github.com/homarr-labs/homarr/commit/22a341ea4d7365fb51781f96791394315afd8860))
|
||||
* **groups:** add home board settings ([#2321](https://github.com/homarr-labs/homarr/issues/2321)) ([ffe7259](https://github.com/homarr-labs/homarr/commit/ffe72598026e5e27588b18feeaaf7d30065d5536))
|
||||
* **logging:** add log level env variable ([#2299](https://github.com/homarr-labs/homarr/issues/2299)) ([67d48e1](https://github.com/homarr-labs/homarr/commit/67d48e11d7e1165540c66f86b299e55028d76788))
|
||||
* quick add app modal ([#2248](https://github.com/homarr-labs/homarr/issues/2248)) ([6420fee](https://github.com/homarr-labs/homarr/commit/6420feee729aa641c0545fa39308772e4a4b1af8))
|
||||
* redirect to login screen when no global home board ([#2379](https://github.com/homarr-labs/homarr/issues/2379)) ([311ae80](https://github.com/homarr-labs/homarr/commit/311ae80667715fccffbfa73699c0770d483c3434))
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **deps:** update dependency jotai to ^2.12.1 ([#2354](https://github.com/homarr-labs/homarr/issues/2354)) ([c59fbfc](https://github.com/homarr-labs/homarr/commit/c59fbfc677b10ee20c2e698d9837af1aa098854b))
|
||||
* **deps:** update dependency mantine-react-table to v2.0.0-beta.9 ([#2348](https://github.com/homarr-labs/homarr/issues/2348)) ([4764f41](https://github.com/homarr-labs/homarr/commit/4764f41e1bf77f7934840a84e25b2989fc1e7c7c))
|
||||
* **deps:** update dependency next-intl to v3.26.4 ([#2361](https://github.com/homarr-labs/homarr/issues/2361)) ([241037f](https://github.com/homarr-labs/homarr/commit/241037fc2a699249f317e5e434ac603cee4e460d))
|
||||
* **deps:** update dependency next-intl to v3.26.5 ([#2386](https://github.com/homarr-labs/homarr/issues/2386)) ([afd7bce](https://github.com/homarr-labs/homarr/commit/afd7bcea841e906003ec90516b033214ad412473))
|
||||
* **deps:** update dependency octokit to ^4.1.2 ([#2333](https://github.com/homarr-labs/homarr/issues/2333)) ([33ef9f6](https://github.com/homarr-labs/homarr/commit/33ef9f66782a59047878a3e82330128fdcabfa93))
|
||||
* **deps:** update dependency prettier to ^3.5.1 ([#2284](https://github.com/homarr-labs/homarr/issues/2284)) ([f6c9230](https://github.com/homarr-labs/homarr/commit/f6c923017c6b4ac9670843bba5dee2f52afd1918))
|
||||
* **deps:** update dependency swagger-ui-react to ^5.19.0 ([#2358](https://github.com/homarr-labs/homarr/issues/2358)) ([91c61f8](https://github.com/homarr-labs/homarr/commit/91c61f8c06622893b90f668e30a615437d9bb549))
|
||||
* **deps:** update dependency tsx to v4.19.3 ([#2370](https://github.com/homarr-labs/homarr/issues/2370)) ([58babbf](https://github.com/homarr-labs/homarr/commit/58babbf88fecc401bf4e7baa52636a4a937bead2))
|
||||
* **deps:** update dependency typescript-eslint to ^8.24.1 ([#2360](https://github.com/homarr-labs/homarr/issues/2360)) ([146fe49](https://github.com/homarr-labs/homarr/commit/146fe499db0fe36c8822ad30a762738425fb6f50))
|
||||
* **deps:** update dependency ws to ^8.18.1 ([#2387](https://github.com/homarr-labs/homarr/issues/2387)) ([b58f64b](https://github.com/homarr-labs/homarr/commit/b58f64b05c09070d8c5cdc7f89b23768b9997405))
|
||||
* **deps:** update mantine monorepo to ^7.17.0 ([#2359](https://github.com/homarr-labs/homarr/issues/2359)) ([96a0717](https://github.com/homarr-labs/homarr/commit/96a071725e557e64d8951091063f047cb43a385b))
|
||||
* **deps:** update tanstack-query monorepo to ^5.66.2 ([#2341](https://github.com/homarr-labs/homarr/issues/2341)) ([f8fedef](https://github.com/homarr-labs/homarr/commit/f8fedefde9ae65923369b46876009f9f6cb27683))
|
||||
* **deps:** update tanstack-query monorepo to ^5.66.3 ([#2343](https://github.com/homarr-labs/homarr/issues/2343)) ([f32af66](https://github.com/homarr-labs/homarr/commit/f32af66a6ad4676530265bfd1052b56308d4d40f))
|
||||
* **deps:** update tanstack-query monorepo to ^5.66.4 ([#2355](https://github.com/homarr-labs/homarr/issues/2355)) ([602faf6](https://github.com/homarr-labs/homarr/commit/602faf622140af1cc4ac92f8a54a56924d809f67))
|
||||
* **deps:** update tanstack-query monorepo to ^5.66.6 ([#2357](https://github.com/homarr-labs/homarr/issues/2357)) ([bd0d910](https://github.com/homarr-labs/homarr/commit/bd0d910137194caef989f4dba360bcaf616e9ea4))
|
||||
* **deps:** update tanstack-query monorepo to ^5.66.7 ([#2364](https://github.com/homarr-labs/homarr/issues/2364)) ([e84cc5e](https://github.com/homarr-labs/homarr/commit/e84cc5e6ae32c78d4e2be4d57e2b97ad84094f5e))
|
||||
* **deps:** update tanstack-query monorepo to ^5.66.8 ([#2376](https://github.com/homarr-labs/homarr/issues/2376)) ([8b75966](https://github.com/homarr-labs/homarr/commit/8b7596642a8398af817eedbe2dd89e9bad3fd937))
|
||||
* **deps:** update tanstack-query monorepo to ^5.66.9 ([#2391](https://github.com/homarr-labs/homarr/issues/2391)) ([1cf36ed](https://github.com/homarr-labs/homarr/commit/1cf36ed4c776d6c99e1e1058dde9129475069aaf))
|
||||
* **medias:** svg files don't load in list ([#2381](https://github.com/homarr-labs/homarr/issues/2381)) ([52549a5](https://github.com/homarr-labs/homarr/commit/52549a598d039a9fa2165983698212564eb5233b))
|
||||
* missing cover types media organizers ([#2328](https://github.com/homarr-labs/homarr/issues/2328)) ([3257f61](https://github.com/homarr-labs/homarr/commit/3257f6101bdd8967089655507144dc4368390b8c))
|
||||
* omv integrations is unable to correctly retrieve the file system when using omv-zfs ([#2337](https://github.com/homarr-labs/homarr/issues/2337)) ([63e9623](https://github.com/homarr-labs/homarr/commit/63e96230e0e2831c787d3e520a6b8e9b4bddb2a7))
|
||||
* typo in pr template ([#2344](https://github.com/homarr-labs/homarr/issues/2344)) ([a62aa09](https://github.com/homarr-labs/homarr/commit/a62aa09db6c87573d579bcebc9f5d5c3ac133af2))
|
||||
* **widgets:** app icon not visible ([#2390](https://github.com/homarr-labs/homarr/issues/2390)) ([a54e078](https://github.com/homarr-labs/homarr/commit/a54e0783e2ad452c4db3d7ddb4e4bcd501a8687a))
|
||||
|
||||
## [1.6.0](https://github.com/homarr-labs/homarr/compare/v1.5.0...v1.6.0) (2025-02-14)
|
||||
|
||||
### Features
|
||||
|
||||
* **lang:** add catalan language ([#2283](https://github.com/homarr-labs/homarr/issues/2283)) ([749b0c6](https://github.com/homarr-labs/homarr/commit/749b0c6ee9dc7802ae10393b90e19bf3c43bf9e0))
|
||||
* **settings:** add simple-ping settings ([#2118](https://github.com/homarr-labs/homarr/issues/2118)) ([dff6cb9](https://github.com/homarr-labs/homarr/commit/dff6cb9d31c3884bf811774a7c90dad27ac19691))
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* add error handling for available updates ([#2289](https://github.com/homarr-labs/homarr/issues/2289)) ([0502e47](https://github.com/homarr-labs/homarr/commit/0502e477707932db96b25372ddab8263572b389c))
|
||||
* add mysql pool with keep-alive to not close the connection ([#2282](https://github.com/homarr-labs/homarr/issues/2282)) ([a38afb0](https://github.com/homarr-labs/homarr/commit/a38afb05d35cf2bdba378be3744f3a8b6c9d38f6))
|
||||
* **calendar:** info popover overlap ([#2268](https://github.com/homarr-labs/homarr/issues/2268)) ([584c660](https://github.com/homarr-labs/homarr/commit/584c660906f5ae7e8ab1c88ce9cfbbbbdcdb5145))
|
||||
* **deps:** update dependency @ctrl/qbittorrent to ^9.3.0 ([#2310](https://github.com/homarr-labs/homarr/issues/2310)) ([8649fac](https://github.com/homarr-labs/homarr/commit/8649faca7929daff426607f104057fce26edd049))
|
||||
* **deps:** update dependency @ctrl/qbittorrent to ^9.4.0 ([#2315](https://github.com/homarr-labs/homarr/issues/2315)) ([4d3cc1d](https://github.com/homarr-labs/homarr/commit/4d3cc1d80698e659a669de7d4bd0d185ad68605e))
|
||||
* **deps:** update dependency @extractus/feed-extractor to v7.1.4 ([#2281](https://github.com/homarr-labs/homarr/issues/2281)) ([7edaa48](https://github.com/homarr-labs/homarr/commit/7edaa48fb9bcf48e8b734beec5a8bad003124233))
|
||||
* **deps:** update dependency drizzle-orm to ^0.39.3 ([#2295](https://github.com/homarr-labs/homarr/issues/2295)) ([8ec0080](https://github.com/homarr-labs/homarr/commit/8ec0080fca17c530cdd0576ba101299a0f769463))
|
||||
* **deps:** update dependency jotai to ^2.12.0 ([#2306](https://github.com/homarr-labs/homarr/issues/2306)) ([fc43da5](https://github.com/homarr-labs/homarr/commit/fc43da59f845934da428bcafd4ec46551ace8a81))
|
||||
* **deps:** update dependency sass to ^1.85.0 ([#2326](https://github.com/homarr-labs/homarr/issues/2326)) ([8ee39eb](https://github.com/homarr-labs/homarr/commit/8ee39eb6db89fdeaec0fae7988ffa97280c2f5ab))
|
||||
* **deps:** update dependency typescript-eslint to ^8.24.0 ([#2297](https://github.com/homarr-labs/homarr/issues/2297)) ([8371f46](https://github.com/homarr-labs/homarr/commit/8371f46d34ad36fd25f23d91804d3f95439d7012))
|
||||
* **deps:** update dependency zod to ^3.24.2 ([#2311](https://github.com/homarr-labs/homarr/issues/2311)) ([379675a](https://github.com/homarr-labs/homarr/commit/379675a901637a1b878595a80d0776d05311218d))
|
||||
* **deps:** update mantine monorepo to ^7.16.3 ([#2275](https://github.com/homarr-labs/homarr/issues/2275)) ([42a6814](https://github.com/homarr-labs/homarr/commit/42a68140b1493cc171977d996eea16d876dce60c))
|
||||
* **deps:** update nextjs monorepo to ^15.1.7 ([#2302](https://github.com/homarr-labs/homarr/issues/2302)) ([2399967](https://github.com/homarr-labs/homarr/commit/2399967868652436ee6827bce8883c1b4a00af0e))
|
||||
* **deps:** update turbo monorepo to ^2.4.1 ([#2296](https://github.com/homarr-labs/homarr/issues/2296)) ([2b9f834](https://github.com/homarr-labs/homarr/commit/2b9f834d9645aaf5f5aa1b8a5f1e48300389ac8b))
|
||||
* **deps:** update turbo monorepo to ^2.4.2 ([#2313](https://github.com/homarr-labs/homarr/issues/2313)) ([1045465](https://github.com/homarr-labs/homarr/commit/10454656a286c320a8d3b5ff12a138025d871b20))
|
||||
* **users:** edit profile username not lowercase ([#2279](https://github.com/homarr-labs/homarr/issues/2279)) ([b7fd247](https://github.com/homarr-labs/homarr/commit/b7fd2470ee0d85821f79a34e839e62fbab6989c6))
|
||||
|
||||
## [1.5.0](https://github.com/homarr-labs/homarr/compare/v1.4.0...v1.5.0) (2025-02-07)
|
||||
|
||||
### Features
|
||||
|
||||
* add api key delete ([#2210](https://github.com/homarr-labs/homarr/issues/2210)) ([8058b62](https://github.com/homarr-labs/homarr/commit/8058b6207ad4be1d646182b67b810c397097c054))
|
||||
* custom time/date format in clock widget ([#2180](https://github.com/homarr-labs/homarr/issues/2180)) ([be603ba](https://github.com/homarr-labs/homarr/commit/be603badfed63ad7583a5265bef3b9e85f6b6b61))
|
||||
* improve consistency and design ([#1867](https://github.com/homarr-labs/homarr/issues/1867)) ([5f36d8b](https://github.com/homarr-labs/homarr/commit/5f36d8b12528fd2c8c3da783b266d614203ec46a))
|
||||
* open all apps in category ([#2212](https://github.com/homarr-labs/homarr/issues/2212)) ([bb67bf5](https://github.com/homarr-labs/homarr/commit/bb67bf5fa2968d91ad3522ebae960a6880f85987))
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **deps:** update dependency @tabler/icons-react to ^3.30.0 ([#2266](https://github.com/homarr-labs/homarr/issues/2266)) ([c849109](https://github.com/homarr-labs/homarr/commit/c849109df5b622aca8cf7b392b94685581bf6b32))
|
||||
* **deps:** update dependency drizzle-orm to ^0.39.2 ([#2255](https://github.com/homarr-labs/homarr/issues/2255)) ([3d0b319](https://github.com/homarr-labs/homarr/commit/3d0b319a88bdb8d955d7942553d92988bf5b2c28))
|
||||
* **deps:** update dependency ioredis to v5.5.0 ([#2264](https://github.com/homarr-labs/homarr/issues/2264)) ([0da5a1b](https://github.com/homarr-labs/homarr/commit/0da5a1b0fb86ff2074f12a5e028fcc09ff94a4a5))
|
||||
* **deps:** update dependency jotai to ^2.11.3 ([#2215](https://github.com/homarr-labs/homarr/issues/2215)) ([a385337](https://github.com/homarr-labs/homarr/commit/a3853372687aed543fad2dd89bfb13871af0f2c1))
|
||||
* **deps:** update dependency octokit to ^4.1.1 ([#2262](https://github.com/homarr-labs/homarr/issues/2262)) ([3cbb656](https://github.com/homarr-labs/homarr/commit/3cbb656ee9b540d2dd4c7db37946efacbbfdc522))
|
||||
* **deps:** update dependency sass to ^1.84.0 ([#2257](https://github.com/homarr-labs/homarr/issues/2257)) ([7bbbaf3](https://github.com/homarr-labs/homarr/commit/7bbbaf343befaaa3d03a91e932f683c5aec4b41e))
|
||||
* **deps:** update dependency semver-parser to ^4.1.8 ([#2216](https://github.com/homarr-labs/homarr/issues/2216)) ([46cfb23](https://github.com/homarr-labs/homarr/commit/46cfb2383b6628472d3972da2166884330bf6d24))
|
||||
* **deps:** update dependency trpc-to-openapi to ^2.1.3 ([#2244](https://github.com/homarr-labs/homarr/issues/2244)) ([c623a42](https://github.com/homarr-labs/homarr/commit/c623a420cadce677e39b99fceeb3f3a391f879ea))
|
||||
* **deps:** update dependency typescript-eslint to ^8.23.0 ([#2232](https://github.com/homarr-labs/homarr/issues/2232)) ([83044d2](https://github.com/homarr-labs/homarr/commit/83044d23dd7accb1b3a21cdde1e034969a494ccb))
|
||||
* **deps:** update testcontainers-node monorepo to ^10.18.0 ([#2259](https://github.com/homarr-labs/homarr/issues/2259)) ([7a8e671](https://github.com/homarr-labs/homarr/commit/7a8e67120576b333bfcf73171b54ccfeeda0d44a))
|
||||
* **deps:** update turbo monorepo to ^2.4.0 ([#2207](https://github.com/homarr-labs/homarr/issues/2207)) ([6ce7bc0](https://github.com/homarr-labs/homarr/commit/6ce7bc0ee2dcd5150d72fdbc2070c05ba836ebd5))
|
||||
* foreign key reference default search engine ([#2211](https://github.com/homarr-labs/homarr/issues/2211)) ([26468a4](https://github.com/homarr-labs/homarr/commit/26468a413795afa7cc3444058bfc73435fb7f2a0))
|
||||
* make system health temp optional ([#2247](https://github.com/homarr-labs/homarr/issues/2247)) ([da09826](https://github.com/homarr-labs/homarr/commit/da098263930c9c9611a1505d52d3816bd2a49e7e))
|
||||
|
||||
## [1.4.0](https://github.com/homarr-labs/homarr/compare/v1.3.1...v1.4.0) (2025-01-31)
|
||||
|
||||
### Features
|
||||
|
||||
+6
-2
@@ -1,4 +1,4 @@
|
||||
FROM node:22.13.1-alpine AS base
|
||||
FROM node:22.14.0-alpine AS base
|
||||
|
||||
FROM base AS builder
|
||||
RUN apk add --no-cache libc6-compat
|
||||
@@ -10,6 +10,8 @@ RUN apk add --no-cache libc6-compat curl bash
|
||||
RUN apk update
|
||||
COPY . .
|
||||
|
||||
# Install working version of corepack (See https://github.com/nodejs/corepack/issues/612)
|
||||
RUN npm install -g corepack@0.31.0 && corepack --version
|
||||
RUN corepack enable pnpm && pnpm install --recursive --frozen-lockfile
|
||||
|
||||
# Copy static data as it is not part of the build
|
||||
@@ -17,6 +19,8 @@ COPY static-data ./static-data
|
||||
ARG SKIP_ENV_VALIDATION='true'
|
||||
ARG CI='true'
|
||||
ARG DISABLE_REDIS_LOGS='true'
|
||||
# Install working version of corepack (See https://github.com/nodejs/corepack/issues/612)
|
||||
RUN npm install -g corepack@0.31.0 && corepack --version
|
||||
RUN corepack enable pnpm && pnpm build
|
||||
|
||||
FROM base AS runner
|
||||
@@ -66,4 +70,4 @@ ENV AUTH_PROVIDERS='credentials'
|
||||
ENV NODE_ENV='production'
|
||||
|
||||
ENTRYPOINT [ "/app/entrypoint.sh" ]
|
||||
CMD ["sh", "run.sh"]
|
||||
CMD ["sh", "run.sh"]
|
||||
@@ -2,14 +2,13 @@
|
||||
import "@homarr/auth/env";
|
||||
import "@homarr/db/env";
|
||||
import "@homarr/common/env";
|
||||
import "@homarr/log/env";
|
||||
import "@homarr/docker/env";
|
||||
|
||||
import type { NextConfig } from "next";
|
||||
import MillionLint from "@million/lint";
|
||||
import createNextIntlPlugin from "next-intl/plugin";
|
||||
|
||||
import "./src/env.ts";
|
||||
|
||||
// Package path does not work... so we need to use relative path
|
||||
const withNextIntl = createNextIntlPlugin("../../packages/translation/src/request.ts");
|
||||
|
||||
|
||||
+27
-22
@@ -15,9 +15,14 @@
|
||||
},
|
||||
"prettier": "@homarr/prettier-config",
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/modifiers": "^9.0.0",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@homarr/analytics": "workspace:^0.1.0",
|
||||
"@homarr/api": "workspace:^0.1.0",
|
||||
"@homarr/auth": "workspace:^0.1.0",
|
||||
"@homarr/boards": "workspace:^0.1.0",
|
||||
"@homarr/certificates": "workspace:^0.1.0",
|
||||
"@homarr/common": "workspace:^0.1.0",
|
||||
"@homarr/cron-job-status": "workspace:^0.1.0",
|
||||
@@ -25,6 +30,7 @@
|
||||
"@homarr/definitions": "workspace:^0.1.0",
|
||||
"@homarr/docker": "workspace:^0.1.0",
|
||||
"@homarr/form": "workspace:^0.1.0",
|
||||
"@homarr/forms-collection": "workspace:^0.1.0",
|
||||
"@homarr/gridstack": "^1.12.0",
|
||||
"@homarr/icons": "workspace:^0.1.0",
|
||||
"@homarr/integrations": "workspace:^0.1.0",
|
||||
@@ -42,18 +48,17 @@
|
||||
"@homarr/ui": "workspace:^0.1.0",
|
||||
"@homarr/validation": "workspace:^0.1.0",
|
||||
"@homarr/widgets": "workspace:^0.1.0",
|
||||
"@mantine/colors-generator": "^7.16.2",
|
||||
"@mantine/core": "^7.16.2",
|
||||
"@mantine/dropzone": "^7.16.2",
|
||||
"@mantine/hooks": "^7.16.2",
|
||||
"@mantine/modals": "^7.16.2",
|
||||
"@mantine/tiptap": "^7.16.2",
|
||||
"@mantine/colors-generator": "^7.17.0",
|
||||
"@mantine/core": "^7.17.0",
|
||||
"@mantine/dropzone": "^7.17.0",
|
||||
"@mantine/hooks": "^7.17.0",
|
||||
"@mantine/modals": "^7.17.0",
|
||||
"@mantine/tiptap": "^7.17.0",
|
||||
"@million/lint": "1.0.14",
|
||||
"@t3-oss/env-nextjs": "^0.12.0",
|
||||
"@tabler/icons-react": "^3.29.0",
|
||||
"@tanstack/react-query": "^5.66.0",
|
||||
"@tanstack/react-query-devtools": "^5.66.0",
|
||||
"@tanstack/react-query-next-experimental": "^5.66.0",
|
||||
"@tabler/icons-react": "^3.30.0",
|
||||
"@tanstack/react-query": "^5.66.9",
|
||||
"@tanstack/react-query-devtools": "^5.66.9",
|
||||
"@tanstack/react-query-next-experimental": "^5.66.9",
|
||||
"@trpc/client": "next",
|
||||
"@trpc/next": "next",
|
||||
"@trpc/react-query": "next",
|
||||
@@ -67,35 +72,35 @@
|
||||
"dotenv": "^16.4.7",
|
||||
"flag-icons": "^7.3.2",
|
||||
"glob": "^11.0.1",
|
||||
"jotai": "^2.11.2",
|
||||
"mantine-react-table": "2.0.0-beta.8",
|
||||
"next": "15.1.6",
|
||||
"jotai": "^2.12.1",
|
||||
"mantine-react-table": "2.0.0-beta.9",
|
||||
"next": "15.1.7",
|
||||
"postcss-preset-mantine": "^1.17.0",
|
||||
"prismjs": "^1.29.0",
|
||||
"react": "19.0.0",
|
||||
"react-dom": "19.0.0",
|
||||
"react-error-boundary": "^5.0.0",
|
||||
"react-simple-code-editor": "^0.14.1",
|
||||
"sass": "^1.83.4",
|
||||
"sass": "^1.85.0",
|
||||
"superjson": "2.2.2",
|
||||
"swagger-ui-react": "^5.18.3",
|
||||
"swagger-ui-react": "^5.19.0",
|
||||
"use-deep-compare-effect": "^1.8.1",
|
||||
"zod": "^3.24.1"
|
||||
"zod": "^3.24.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@homarr/eslint-config": "workspace:^0.2.0",
|
||||
"@homarr/prettier-config": "workspace:^0.1.0",
|
||||
"@homarr/tsconfig": "workspace:^0.1.0",
|
||||
"@types/chroma-js": "3.1.1",
|
||||
"@types/node": "^22.12.0",
|
||||
"@types/node": "^22.13.5",
|
||||
"@types/prismjs": "^1.26.5",
|
||||
"@types/react": "19.0.8",
|
||||
"@types/react-dom": "19.0.3",
|
||||
"@types/react": "19.0.10",
|
||||
"@types/react-dom": "19.0.4",
|
||||
"@types/swagger-ui-react": "^5.18.0",
|
||||
"concurrently": "^9.1.2",
|
||||
"eslint": "^9.19.0",
|
||||
"eslint": "^9.21.0",
|
||||
"node-loader": "^2.1.0",
|
||||
"prettier": "^3.4.2",
|
||||
"prettier": "^3.5.2",
|
||||
"typescript": "^5.7.3"
|
||||
}
|
||||
}
|
||||
@@ -20,8 +20,7 @@ import type { SuperJSONResult } from "superjson";
|
||||
import type { AppRouter } from "@homarr/api";
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { createHeadersCallbackForSource, getTrpcUrl } from "@homarr/api/shared";
|
||||
|
||||
import { env } from "~/env";
|
||||
import { env } from "@homarr/common/env";
|
||||
|
||||
const getWebSocketProtocol = () => {
|
||||
// window is not defined on server side
|
||||
@@ -66,7 +65,7 @@ export function TRPCReactProvider(props: PropsWithChildren) {
|
||||
links: [
|
||||
loggerLink({
|
||||
enabled: (opts) =>
|
||||
process.env.NODE_ENV === "development" || (opts.direction === "down" && opts.result instanceof Error),
|
||||
env.NODE_ENV === "development" || (opts.direction === "down" && opts.result instanceof Error),
|
||||
}),
|
||||
splitLink({
|
||||
condition: ({ type }) => type === "subscription",
|
||||
|
||||
@@ -5,12 +5,13 @@ import { Box, LoadingOverlay, Stack } from "@mantine/core";
|
||||
|
||||
import type { RouterOutputs } from "@homarr/api";
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { useRequiredBoard } from "@homarr/boards/context";
|
||||
|
||||
import { BoardCategorySection } from "~/components/board/sections/category-section";
|
||||
import { BoardEmptySection } from "~/components/board/sections/empty-section";
|
||||
import { BoardBackgroundVideo } from "~/components/layout/background";
|
||||
import { fullHeightWithoutHeaderAndFooter } from "~/constants";
|
||||
import { useIsBoardReady, useRequiredBoard } from "./_context";
|
||||
import { useIsBoardReady } from "./_ready-context";
|
||||
|
||||
let boardName: string | null = null;
|
||||
|
||||
|
||||
@@ -1,118 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import type { Dispatch, PropsWithChildren, SetStateAction } from "react";
|
||||
import { createContext, useCallback, useContext, useEffect, useState } from "react";
|
||||
import { usePathname } from "next/navigation";
|
||||
|
||||
import type { RouterOutputs } from "@homarr/api";
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
|
||||
import { updateBoardName } from "./_client";
|
||||
|
||||
const BoardContext = createContext<{
|
||||
board: RouterOutputs["board"]["getHomeBoard"];
|
||||
isReady: boolean;
|
||||
markAsReady: (id: string) => void;
|
||||
isEditMode: boolean;
|
||||
setEditMode: Dispatch<SetStateAction<boolean>>;
|
||||
} | null>(null);
|
||||
|
||||
export const BoardProvider = ({
|
||||
children,
|
||||
initialBoard,
|
||||
}: PropsWithChildren<{
|
||||
initialBoard: RouterOutputs["board"]["getBoardByName"];
|
||||
}>) => {
|
||||
const pathname = usePathname();
|
||||
const utils = clientApi.useUtils();
|
||||
const [readySections, setReadySections] = useState<string[]>([]);
|
||||
const [isEditMode, setEditMode] = useState(false);
|
||||
const { data } = clientApi.board.getBoardByName.useQuery(
|
||||
{ name: initialBoard.name },
|
||||
{
|
||||
initialData: initialBoard,
|
||||
refetchOnWindowFocus: false,
|
||||
refetchOnReconnect: false,
|
||||
},
|
||||
);
|
||||
// Update the board name so it can be used within updateBoard method
|
||||
updateBoardName(initialBoard.name);
|
||||
|
||||
// Invalidate the board when the pathname changes
|
||||
// This allows to refetch the board when it might have changed - e.g. if someone else added an item
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
setReadySections([]);
|
||||
void utils.board.getBoardByName.invalidate({ name: initialBoard.name });
|
||||
};
|
||||
}, [pathname, utils, initialBoard.name]);
|
||||
|
||||
useEffect(() => {
|
||||
setReadySections((previous) => previous.filter((id) => data.sections.some((section) => section.id === id)));
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [data.sections.length, setReadySections]);
|
||||
|
||||
const markAsReady = useCallback((id: string) => {
|
||||
setReadySections((previous) => (previous.includes(id) ? previous : [...previous, id]));
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<BoardContext.Provider
|
||||
value={{
|
||||
board: data,
|
||||
isReady: data.sections.length === readySections.length,
|
||||
markAsReady,
|
||||
isEditMode,
|
||||
setEditMode,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</BoardContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export const useMarkSectionAsReady = () => {
|
||||
const context = useContext(BoardContext);
|
||||
|
||||
if (!context) {
|
||||
throw new Error("Board is required");
|
||||
}
|
||||
|
||||
return context.markAsReady;
|
||||
};
|
||||
|
||||
export const useIsBoardReady = () => {
|
||||
const context = useContext(BoardContext);
|
||||
|
||||
if (!context) {
|
||||
throw new Error("Board is required");
|
||||
}
|
||||
|
||||
return context.isReady;
|
||||
};
|
||||
|
||||
export const useRequiredBoard = () => {
|
||||
const optionalBoard = useOptionalBoard();
|
||||
|
||||
if (!optionalBoard) {
|
||||
throw new Error("Board is required");
|
||||
}
|
||||
|
||||
return optionalBoard;
|
||||
};
|
||||
|
||||
export const useOptionalBoard = () => {
|
||||
const context = useContext(BoardContext);
|
||||
|
||||
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,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useRequiredBoard } from "./_context";
|
||||
import { useRequiredBoard } from "@homarr/boards/context";
|
||||
|
||||
export const CustomCss = () => {
|
||||
const board = useRequiredBoard();
|
||||
|
||||
@@ -20,7 +20,10 @@ import {
|
||||
} from "@tabler/icons-react";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { useRequiredBoard } from "@homarr/boards/context";
|
||||
import { useEditMode } from "@homarr/boards/edit-mode";
|
||||
import { revalidatePathActionAsync } from "@homarr/common/client";
|
||||
import { env } from "@homarr/common/env";
|
||||
import { useConfirmModal, useModalAction } from "@homarr/modals";
|
||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||
import { useI18n, useScopedI18n } from "@homarr/translation/client";
|
||||
@@ -31,8 +34,6 @@ import { useCategoryActions } from "~/components/board/sections/category/categor
|
||||
import { CategoryEditModal } from "~/components/board/sections/category/category-edit-modal";
|
||||
import { useDynamicSectionActions } from "~/components/board/sections/dynamic/dynamic-actions";
|
||||
import { HeaderButton } from "~/components/layout/header/button";
|
||||
import { env } from "~/env";
|
||||
import { useEditMode, useRequiredBoard } from "./_context";
|
||||
|
||||
export const BoardContentHeaderActions = () => {
|
||||
const [isEditMode] = useEditMode();
|
||||
@@ -119,7 +120,7 @@ const AddMenu = () => {
|
||||
};
|
||||
|
||||
const EditModeMenu = () => {
|
||||
const [isEditMode, setEditMode] = useEditMode();
|
||||
const [isEditMode, { open, close }] = useEditMode();
|
||||
const board = useRequiredBoard();
|
||||
const utils = clientApi.useUtils();
|
||||
const t = useScopedI18n("board.action.edit");
|
||||
@@ -131,7 +132,7 @@ const EditModeMenu = () => {
|
||||
});
|
||||
void utils.board.getBoardByName.invalidate({ name: board.name });
|
||||
void revalidatePathActionAsync(`/boards/${board.name}`);
|
||||
setEditMode(false);
|
||||
close();
|
||||
},
|
||||
onError() {
|
||||
showErrorNotification({
|
||||
@@ -143,8 +144,8 @@ const EditModeMenu = () => {
|
||||
|
||||
const toggle = useCallback(() => {
|
||||
if (isEditMode) return saveBoard(board);
|
||||
setEditMode(true);
|
||||
}, [board, isEditMode, saveBoard, setEditMode]);
|
||||
open();
|
||||
}, [board, isEditMode, saveBoard, open]);
|
||||
|
||||
useHotkeys([["mod+e", toggle]]);
|
||||
usePreventLeaveWithDirty(isEditMode);
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
"use client";
|
||||
|
||||
import type { PropsWithChildren } from "react";
|
||||
import { createContext, useCallback, useContext, useEffect, useState } from "react";
|
||||
import { usePathname } from "next/navigation";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { useRequiredBoard } from "@homarr/boards/context";
|
||||
|
||||
const BoardReadyContext = createContext<{
|
||||
isReady: boolean;
|
||||
markAsReady: (id: string) => void;
|
||||
} | null>(null);
|
||||
|
||||
export const BoardReadyProvider = ({ children }: PropsWithChildren) => {
|
||||
const pathname = usePathname();
|
||||
const utils = clientApi.useUtils();
|
||||
const board = useRequiredBoard();
|
||||
const [readySections, setReadySections] = useState<string[]>([]);
|
||||
|
||||
// Reset sections required for ready state
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
setReadySections([]);
|
||||
};
|
||||
}, [pathname, utils]);
|
||||
|
||||
useEffect(() => {
|
||||
setReadySections((previous) => previous.filter((id) => board.sections.some((section) => section.id === id)));
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [board.sections.length, setReadySections]);
|
||||
|
||||
const markAsReady = useCallback((id: string) => {
|
||||
setReadySections((previous) => (previous.includes(id) ? previous : [...previous, id]));
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<BoardReadyContext.Provider
|
||||
value={{
|
||||
isReady: board.sections.length === readySections.length,
|
||||
markAsReady,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</BoardReadyContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export const useMarkSectionAsReady = () => {
|
||||
const context = useContext(BoardReadyContext);
|
||||
|
||||
if (!context) {
|
||||
throw new Error("BoardReadyProvider is required");
|
||||
}
|
||||
|
||||
return context.markAsReady;
|
||||
};
|
||||
|
||||
export const useIsBoardReady = () => {
|
||||
const context = useContext(BoardReadyContext);
|
||||
|
||||
if (!context) {
|
||||
throw new Error("BoardReadyProvider is required");
|
||||
}
|
||||
|
||||
return context.isReady;
|
||||
};
|
||||
@@ -2,12 +2,12 @@
|
||||
|
||||
import type { PropsWithChildren } from "react";
|
||||
import type { MantineColorsTuple } from "@mantine/core";
|
||||
import { createTheme, darken, lighten, MantineProvider } from "@mantine/core";
|
||||
import { colorsTuple, createTheme, darken, lighten, MantineProvider } from "@mantine/core";
|
||||
|
||||
import { useRequiredBoard } from "@homarr/boards/context";
|
||||
import type { ColorScheme } from "@homarr/definitions";
|
||||
|
||||
import { useColorSchemeManager } from "../../_client-providers/mantine";
|
||||
import { useRequiredBoard } from "./_context";
|
||||
|
||||
export const BoardMantineProvider = ({
|
||||
children,
|
||||
@@ -20,6 +20,7 @@ export const BoardMantineProvider = ({
|
||||
colors: {
|
||||
primaryColor: generateColors(board.primaryColor),
|
||||
secondaryColor: generateColors(board.secondaryColor),
|
||||
iconColor: board.iconColor ? generateColors(board.iconColor) : colorsTuple("#000000"),
|
||||
},
|
||||
primaryColor: "primaryColor",
|
||||
autoContrast: true,
|
||||
|
||||
+23
@@ -10,6 +10,7 @@ import {
|
||||
Group,
|
||||
InputWrapper,
|
||||
isLightColor,
|
||||
Select,
|
||||
Slider,
|
||||
Stack,
|
||||
Text,
|
||||
@@ -39,6 +40,8 @@ export const ColorSettingsContent = ({ board }: Props) => {
|
||||
primaryColor: board.primaryColor,
|
||||
secondaryColor: board.secondaryColor,
|
||||
opacity: board.opacity,
|
||||
iconColor: board.iconColor ?? "",
|
||||
itemRadius: board.itemRadius,
|
||||
},
|
||||
});
|
||||
const [showPreview, { toggle }] = useDisclosure(false);
|
||||
@@ -98,6 +101,26 @@ export const ColorSettingsContent = ({ board }: Props) => {
|
||||
/>
|
||||
</InputWrapper>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={{ sm: 12, md: 6 }}>
|
||||
<ColorInput
|
||||
label={t("board.field.iconColor.label")}
|
||||
format="hex"
|
||||
swatches={Object.values(theme.colors).map((color) => color[6])}
|
||||
{...form.getInputProps("iconColor")}
|
||||
/>
|
||||
<Select
|
||||
label={t("board.field.itemRadius.label")}
|
||||
description={t("board.field.itemRadius.description")}
|
||||
data={[
|
||||
{ label: t("board.field.itemRadius.option.xs"), value: "xs" },
|
||||
{ label: t("board.field.itemRadius.option.sm"), value: "sm" },
|
||||
{ label: t("board.field.itemRadius.option.md"), value: "md" },
|
||||
{ label: t("board.field.itemRadius.option.lg"), value: "lg" },
|
||||
{ label: t("board.field.itemRadius.option.xl"), value: "xl" },
|
||||
]}
|
||||
{...form.getInputProps("itemRadius")}
|
||||
/>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
<Group justify="end">
|
||||
<Button type="submit" loading={isPending} color="teal">
|
||||
@@ -0,0 +1,48 @@
|
||||
"use client";
|
||||
|
||||
import { Button, Group, Stack, Switch } from "@mantine/core";
|
||||
|
||||
import { useForm } from "@homarr/form";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
|
||||
import type { Board } from "../../_types";
|
||||
import { useSavePartialSettingsMutation } from "./_shared";
|
||||
|
||||
interface Props {
|
||||
board: Board;
|
||||
}
|
||||
|
||||
export const BehaviorSettingsContent = ({ board }: Props) => {
|
||||
const t = useI18n();
|
||||
const { mutate: savePartialSettings, isPending } = useSavePartialSettingsMutation(board);
|
||||
const form = useForm({
|
||||
initialValues: {
|
||||
disableStatus: board.disableStatus,
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={form.onSubmit((values) => {
|
||||
savePartialSettings({
|
||||
id: board.id,
|
||||
...values,
|
||||
});
|
||||
})}
|
||||
>
|
||||
<Stack>
|
||||
<Switch
|
||||
label={t("board.field.disableStatus.label")}
|
||||
description={t("board.field.disableStatus.description")}
|
||||
{...form.getInputProps("disableStatus", { type: "checkbox" })}
|
||||
/>
|
||||
|
||||
<Group justify="end">
|
||||
<Button type="submit" loading={isPending} color="teal">
|
||||
{t("common.action.saveChanges")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
@@ -5,11 +5,11 @@ import { useRouter } from "next/navigation";
|
||||
import { Button, Divider, Group, Stack, Text } from "@mantine/core";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { useRequiredBoard } from "@homarr/boards/context";
|
||||
import { useConfirmModal, useModalAction } from "@homarr/modals";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import { BoardRenameModal } from "~/components/board/modals/board-rename-modal";
|
||||
import { useRequiredBoard } from "../../(content)/_context";
|
||||
import classes from "./danger.module.css";
|
||||
|
||||
export const DangerZoneSettingsContent = ({ hideVisibility }: { hideVisibility: boolean }) => {
|
||||
|
||||
@@ -5,13 +5,13 @@ import { Button, Grid, Group, Loader, Stack, TextInput, Tooltip } from "@mantine
|
||||
import { useDebouncedValue, useDocumentTitle, useFavicon } from "@mantine/hooks";
|
||||
import { IconAlertTriangle } from "@tabler/icons-react";
|
||||
|
||||
import { useUpdateBoard } from "@homarr/boards/updater";
|
||||
import { useZodForm } from "@homarr/form";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import { validation } from "@homarr/validation";
|
||||
|
||||
import { createMetaTitle } from "~/metadata";
|
||||
import type { Board } from "../../_types";
|
||||
import { useUpdateBoard } from "../../(content)/_client";
|
||||
import { useSavePartialSettingsMutation } from "./_shared";
|
||||
|
||||
interface Props {
|
||||
|
||||
@@ -4,6 +4,7 @@ import { AccordionControl, AccordionItem, AccordionPanel, Container, Stack, Text
|
||||
import {
|
||||
IconAlertTriangle,
|
||||
IconBrush,
|
||||
IconClick,
|
||||
IconFileTypeCss,
|
||||
IconLayout,
|
||||
IconPhoto,
|
||||
@@ -22,9 +23,10 @@ import type { TablerIcon } from "@homarr/ui";
|
||||
|
||||
import { getBoardPermissionsAsync } from "~/components/board/permissions/server";
|
||||
import { ActiveTabAccordion } from "../../../../../components/active-tab-accordion";
|
||||
import { ColorSettingsContent } from "./_appereance";
|
||||
import { BackgroundSettingsContent } from "./_background";
|
||||
import { BehaviorSettingsContent } from "./_behavior";
|
||||
import { BoardAccessSettings } from "./_board-access";
|
||||
import { ColorSettingsContent } from "./_colors";
|
||||
import { CustomCssSettingsContent } from "./_customCss";
|
||||
import { DangerZoneSettingsContent } from "./_danger";
|
||||
import { GeneralSettingsContent } from "./_general";
|
||||
@@ -89,12 +91,15 @@ export default async function BoardSettingsPage(props: Props) {
|
||||
<AccordionItemFor value="background" icon={IconPhoto}>
|
||||
<BackgroundSettingsContent board={board} />
|
||||
</AccordionItemFor>
|
||||
<AccordionItemFor value="color" icon={IconBrush}>
|
||||
<AccordionItemFor value="appearance" icon={IconBrush}>
|
||||
<ColorSettingsContent board={board} />
|
||||
</AccordionItemFor>
|
||||
<AccordionItemFor value="customCss" icon={IconFileTypeCss}>
|
||||
<CustomCssSettingsContent board={board} />
|
||||
</AccordionItemFor>
|
||||
<AccordionItemFor value="behavior" icon={IconClick}>
|
||||
<BehaviorSettingsContent board={board} />
|
||||
</AccordionItemFor>
|
||||
{hasFullAccess && (
|
||||
<>
|
||||
<AccordionItemFor value="access" icon={IconUser}>
|
||||
|
||||
@@ -2,8 +2,9 @@
|
||||
|
||||
import { IconLayoutBoard } from "@tabler/icons-react";
|
||||
|
||||
import { useRequiredBoard } from "@homarr/boards/context";
|
||||
|
||||
import { HeaderButton } from "~/components/layout/header/button";
|
||||
import { useRequiredBoard } from "./(content)/_context";
|
||||
|
||||
export const BoardOtherHeaderActions = () => {
|
||||
const board = useRequiredBoard();
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import type { JSX, PropsWithChildren } from "react";
|
||||
import { notFound } from "next/navigation";
|
||||
import { notFound, redirect } from "next/navigation";
|
||||
import { AppShellMain } from "@mantine/core";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
|
||||
import { auth } from "@homarr/auth/next";
|
||||
import { BoardProvider } from "@homarr/boards/context";
|
||||
import { EditModeProvider } from "@homarr/boards/edit-mode";
|
||||
import { logger } from "@homarr/log";
|
||||
|
||||
import { MainHeader } from "~/components/layout/header";
|
||||
@@ -10,9 +13,9 @@ import { BoardLogoWithTitle } from "~/components/layout/logo/board-logo";
|
||||
import { ClientShell } from "~/components/layout/shell";
|
||||
import { getCurrentColorSchemeAsync } from "~/theme/color-scheme";
|
||||
import type { Board } from "./_types";
|
||||
import { BoardProvider } from "./(content)/_context";
|
||||
import type { Params } from "./(content)/_creator";
|
||||
import { CustomCss } from "./(content)/_custom-css";
|
||||
import { BoardReadyProvider } from "./(content)/_ready-context";
|
||||
import { BoardMantineProvider } from "./(content)/_theme";
|
||||
|
||||
interface CreateBoardLayoutProps<TParams extends Params> {
|
||||
@@ -30,8 +33,14 @@ export const createBoardLayout = <TParams extends Params>({
|
||||
}: PropsWithChildren<{
|
||||
params: Promise<TParams>;
|
||||
}>) => {
|
||||
const session = await auth();
|
||||
const initialBoard = await getInitialBoard(await params).catch((error) => {
|
||||
if (error instanceof TRPCError && error.code === "NOT_FOUND") {
|
||||
if (!session) {
|
||||
logger.debug("No home board found for anonymous user, redirecting to login");
|
||||
redirect("/auth/login");
|
||||
}
|
||||
|
||||
logger.warn(error);
|
||||
notFound();
|
||||
}
|
||||
@@ -42,17 +51,21 @@ export const createBoardLayout = <TParams extends Params>({
|
||||
|
||||
return (
|
||||
<BoardProvider initialBoard={initialBoard}>
|
||||
<BoardMantineProvider defaultColorScheme={colorScheme}>
|
||||
<CustomCss />
|
||||
<ClientShell hasNavigation={false}>
|
||||
<MainHeader
|
||||
logo={<BoardLogoWithTitle size="md" hideTitleOnMobile />}
|
||||
actions={headerActions}
|
||||
hasNavigation={false}
|
||||
/>
|
||||
<AppShellMain>{children}</AppShellMain>
|
||||
</ClientShell>
|
||||
</BoardMantineProvider>
|
||||
<BoardReadyProvider>
|
||||
<EditModeProvider>
|
||||
<BoardMantineProvider defaultColorScheme={colorScheme}>
|
||||
<CustomCss />
|
||||
<ClientShell hasNavigation={false}>
|
||||
<MainHeader
|
||||
logo={<BoardLogoWithTitle size="md" hideTitleOnMobile />}
|
||||
actions={headerActions}
|
||||
hasNavigation={false}
|
||||
/>
|
||||
<AppShellMain>{children}</AppShellMain>
|
||||
</ClientShell>
|
||||
</BoardMantineProvider>
|
||||
</EditModeProvider>
|
||||
</BoardReadyProvider>
|
||||
</BoardProvider>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -94,6 +94,8 @@ export default async function Layout(props: {
|
||||
board: {
|
||||
homeBoardId: serverSettings.board.homeBoardId,
|
||||
mobileHomeBoardId: serverSettings.board.mobileHomeBoardId,
|
||||
enableStatusByDefault: serverSettings.board.enableStatusByDefault,
|
||||
forceDisableStatus: serverSettings.board.forceDisableStatus,
|
||||
},
|
||||
search: { defaultSearchEngineId: serverSettings.search.defaultSearchEngineId },
|
||||
}}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
.bannerContainer {
|
||||
border-radius: 8px;
|
||||
border-radius: 16px;
|
||||
overflow: hidden;
|
||||
@mixin dark {
|
||||
background: linear-gradient(
|
||||
|
||||
@@ -1,55 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { Button, Group, Stack, Textarea, TextInput } from "@mantine/core";
|
||||
import type { z } from "zod";
|
||||
|
||||
import { useZodForm } from "@homarr/form";
|
||||
import type { TranslationFunction } from "@homarr/translation";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import { validation } from "@homarr/validation";
|
||||
|
||||
import { IconPicker } from "~/components/icons/picker/icon-picker";
|
||||
|
||||
type FormType = z.infer<typeof validation.app.manage>;
|
||||
|
||||
interface AppFormProps {
|
||||
submitButtonTranslation: (t: TranslationFunction) => string;
|
||||
initialValues?: FormType;
|
||||
handleSubmit: (values: FormType) => void;
|
||||
isPending: boolean;
|
||||
}
|
||||
|
||||
export const AppForm = (props: AppFormProps) => {
|
||||
const { submitButtonTranslation, handleSubmit, initialValues, isPending } = props;
|
||||
const t = useI18n();
|
||||
|
||||
const form = useZodForm(validation.app.manage, {
|
||||
initialValues: {
|
||||
name: initialValues?.name ?? "",
|
||||
description: initialValues?.description ?? "",
|
||||
iconUrl: initialValues?.iconUrl ?? "",
|
||||
href: initialValues?.href ?? "",
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<form onSubmit={form.onSubmit(handleSubmit)}>
|
||||
<Stack>
|
||||
<TextInput {...form.getInputProps("name")} withAsterisk label={t("app.field.name.label")} />
|
||||
<IconPicker initialValue={initialValues?.iconUrl} {...form.getInputProps("iconUrl")} />
|
||||
<Textarea {...form.getInputProps("description")} label={t("app.field.description.label")} />
|
||||
<TextInput {...form.getInputProps("href")} label={t("app.field.url.label")} />
|
||||
|
||||
<Group justify="end">
|
||||
<Button variant="default" component={Link} href="/manage/apps">
|
||||
{t("common.action.backToOverview")}
|
||||
</Button>
|
||||
<Button type="submit" loading={isPending}>
|
||||
{submitButtonTranslation(t)}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
@@ -7,26 +7,25 @@ import type { z } from "zod";
|
||||
import type { RouterOutputs } from "@homarr/api";
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { revalidatePathActionAsync } from "@homarr/common/client";
|
||||
import { AppForm } from "@homarr/forms-collection";
|
||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||
import type { TranslationFunction } from "@homarr/translation";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
import { useI18n, useScopedI18n } from "@homarr/translation/client";
|
||||
import type { validation } from "@homarr/validation";
|
||||
|
||||
import { AppForm } from "../../_form";
|
||||
|
||||
interface AppEditFormProps {
|
||||
app: RouterOutputs["app"]["byId"];
|
||||
}
|
||||
|
||||
export const AppEditForm = ({ app }: AppEditFormProps) => {
|
||||
const t = useScopedI18n("app.page.edit.notification");
|
||||
const tScoped = useScopedI18n("app.page.edit.notification");
|
||||
const t = useI18n();
|
||||
const router = useRouter();
|
||||
|
||||
const { mutate, isPending } = clientApi.app.update.useMutation({
|
||||
onSuccess: () => {
|
||||
showSuccessNotification({
|
||||
title: t("success.title"),
|
||||
message: t("success.message"),
|
||||
title: tScoped("success.title"),
|
||||
message: tScoped("success.message"),
|
||||
});
|
||||
void revalidatePathActionAsync("/manage/apps").then(() => {
|
||||
router.push("/manage/apps");
|
||||
@@ -34,8 +33,8 @@ export const AppEditForm = ({ app }: AppEditFormProps) => {
|
||||
},
|
||||
onError: () => {
|
||||
showErrorNotification({
|
||||
title: t("error.title"),
|
||||
message: t("error.message"),
|
||||
title: tScoped("error.title"),
|
||||
message: tScoped("error.message"),
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -50,14 +49,15 @@ export const AppEditForm = ({ app }: AppEditFormProps) => {
|
||||
[mutate, app.id],
|
||||
);
|
||||
|
||||
const submitButtonTranslation = useCallback((t: TranslationFunction) => t("common.action.save"), []);
|
||||
|
||||
return (
|
||||
<AppForm
|
||||
submitButtonTranslation={submitButtonTranslation}
|
||||
buttonLabels={{
|
||||
submit: t("common.action.save"),
|
||||
}}
|
||||
initialValues={app}
|
||||
handleSubmit={handleSubmit}
|
||||
isPending={isPending}
|
||||
showBackToOverview
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -1,50 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import type { z } from "zod";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { revalidatePathActionAsync } from "@homarr/common/client";
|
||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||
import type { TranslationFunction } from "@homarr/translation";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
import type { validation } from "@homarr/validation";
|
||||
|
||||
import { AppForm } from "../_form";
|
||||
|
||||
export const AppNewForm = () => {
|
||||
const t = useScopedI18n("app.page.create.notification");
|
||||
const router = useRouter();
|
||||
|
||||
const { mutate, isPending } = clientApi.app.create.useMutation({
|
||||
onSuccess: () => {
|
||||
showSuccessNotification({
|
||||
title: t("success.title"),
|
||||
message: t("success.message"),
|
||||
});
|
||||
void revalidatePathActionAsync("/manage/apps").then(() => {
|
||||
router.push("/manage/apps");
|
||||
});
|
||||
},
|
||||
onError: () => {
|
||||
showErrorNotification({
|
||||
title: t("error.title"),
|
||||
message: t("error.message"),
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const handleSubmit = useCallback(
|
||||
(values: z.infer<typeof validation.app.manage>) => {
|
||||
mutate(values);
|
||||
},
|
||||
[mutate],
|
||||
);
|
||||
|
||||
const submitButtonTranslation = useCallback((t: TranslationFunction) => t("common.action.create"), []);
|
||||
|
||||
return (
|
||||
<AppForm submitButtonTranslation={submitButtonTranslation} handleSubmit={handleSubmit} isPending={isPending} />
|
||||
);
|
||||
};
|
||||
@@ -2,10 +2,10 @@ import { notFound } from "next/navigation";
|
||||
import { Container, Stack, Title } from "@mantine/core";
|
||||
|
||||
import { auth } from "@homarr/auth/next";
|
||||
import { AppNewForm } from "@homarr/forms-collection";
|
||||
import { getI18n } from "@homarr/translation/server";
|
||||
|
||||
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
|
||||
import { AppNewForm } from "./_app-new-form";
|
||||
|
||||
export default async function AppNewPage() {
|
||||
const session = await auth();
|
||||
@@ -22,7 +22,7 @@ export default async function AppNewPage() {
|
||||
<Container>
|
||||
<Stack>
|
||||
<Title>{t("app.page.create.title")}</Title>
|
||||
<AppNewForm />
|
||||
<AppNewForm showBackToOverview showCreateAnother />
|
||||
</Stack>
|
||||
</Container>
|
||||
</>
|
||||
|
||||
@@ -45,7 +45,7 @@ export default async function AppsPage(props: AppsPageProps) {
|
||||
<Stack>
|
||||
<Title>{t("page.list.title")}</Title>
|
||||
<Group justify="space-between" align="center">
|
||||
<SearchInput placeholder={`${t("search")}...`} defaultValue={searchParams.search} />
|
||||
<SearchInput placeholder={`${t("search")}...`} defaultValue={searchParams.search} flexExpand />
|
||||
{session.user.permissions.includes("app-create") && (
|
||||
<MobileAffixButton component={Link} href="/manage/apps/new">
|
||||
{t("page.create.title")}
|
||||
|
||||
@@ -67,7 +67,7 @@ const BoardCard = async ({ board }: BoardCardProps) => {
|
||||
const VisibilityIcon = board.isPublic ? IconWorld : IconLock;
|
||||
|
||||
return (
|
||||
<Card withBorder>
|
||||
<Card radius="lg" withBorder>
|
||||
<CardSection p="sm" withBorder>
|
||||
<Group justify="space-between" align="center">
|
||||
<Group gap="sm">
|
||||
@@ -106,15 +106,25 @@ const BoardCard = async ({ board }: BoardCardProps) => {
|
||||
</Group>
|
||||
</CardSection>
|
||||
|
||||
<CardSection p="sm">
|
||||
<Group wrap="nowrap">
|
||||
<Button component={Link} href={`/boards/${board.name}`} variant="default" fullWidth>
|
||||
<CardSection>
|
||||
<Group gap={0} wrap="nowrap">
|
||||
<Button
|
||||
style={{ border: "none", borderRadius: 0 }}
|
||||
component={Link}
|
||||
href={`/boards/${board.name}`}
|
||||
variant="default"
|
||||
fullWidth
|
||||
>
|
||||
{t("action.open.label")}
|
||||
</Button>
|
||||
{isMenuVisible && (
|
||||
<Menu position="bottom-end">
|
||||
<MenuTarget>
|
||||
<ActionIcon variant="default" size="lg">
|
||||
<ActionIcon
|
||||
style={{ borderTop: "none", borderBottom: "none", borderRight: "none", borderRadius: 0 }}
|
||||
variant="default"
|
||||
size="lg"
|
||||
>
|
||||
<IconDotsVertical size={16} stroke={1.5} />
|
||||
</ActionIcon>
|
||||
</MenuTarget>
|
||||
|
||||
@@ -33,6 +33,7 @@ export const IntegrationCreateDropdownContent = () => {
|
||||
value={search}
|
||||
data-autofocus
|
||||
onChange={handleSearch}
|
||||
variant="filled"
|
||||
/>
|
||||
|
||||
{filteredKinds.length > 0 ? (
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
.root {
|
||||
border-radius: var(--mantine-radius-lg);
|
||||
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;
|
||||
overflow: hidden;
|
||||
|
||||
&[data-first="true"] {
|
||||
border-radius: var(--mantine-radius-lg) var(--mantine-radius-lg) 0 0;
|
||||
}
|
||||
|
||||
&[data-last="true"] {
|
||||
border-radius: 0 0 var(--mantine-radius-lg) var(--mantine-radius-lg);
|
||||
}
|
||||
|
||||
&[data-active] {
|
||||
transform: scale(1.01);
|
||||
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-lg);
|
||||
}
|
||||
}
|
||||
|
||||
.chevron {
|
||||
&[data-rotate] {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
}
|
||||
@@ -44,6 +44,7 @@ import { NoResults } from "~/components/no-results";
|
||||
import { ActiveTabAccordion } from "../../../../components/active-tab-accordion";
|
||||
import { DeleteIntegrationActionButton } from "./_integration-buttons";
|
||||
import { IntegrationCreateDropdownContent } from "./new/_integration-new-dropdown";
|
||||
import classes from "./page.module.css";
|
||||
|
||||
interface IntegrationsPageProps {
|
||||
searchParams: Promise<{
|
||||
@@ -133,7 +134,7 @@ const IntegrationList = async ({ integrations, activeTab }: IntegrationListProps
|
||||
return <NoResults icon={IconPlugX} title={t("page.list.noResults.title")} />;
|
||||
}
|
||||
|
||||
const grouppedIntegrations = integrations.reduce(
|
||||
const groupedIntegrations = integrations.reduce(
|
||||
(acc, integration) => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
||||
if (!acc[integration.kind]) {
|
||||
@@ -147,11 +148,13 @@ const IntegrationList = async ({ integrations, activeTab }: IntegrationListProps
|
||||
{} as Record<IntegrationKind, RouterOutputs["integration"]["all"]>,
|
||||
);
|
||||
|
||||
const entries = objectEntries(groupedIntegrations);
|
||||
|
||||
return (
|
||||
<ActiveTabAccordion defaultValue={activeTab} variant="separated">
|
||||
{objectEntries(grouppedIntegrations).map(([kind, integrations]) => (
|
||||
<AccordionItem key={kind} value={kind}>
|
||||
<AccordionControl icon={<IntegrationAvatar size="sm" kind={kind} />}>
|
||||
<ActiveTabAccordion defaultValue={activeTab} radius="lg" classNames={classes}>
|
||||
{entries.map(([kind, integrations], index) => (
|
||||
<AccordionItem key={kind} value={kind} data-first={index === 0} data-last={index === entries.length - 1}>
|
||||
<AccordionControl icon={<IntegrationAvatar size="sm" kind={kind} radius="sm" />}>
|
||||
<Group>
|
||||
<Text>{getIntegrationName(kind)}</Text>
|
||||
<CountBadge count={integrations.length} />
|
||||
|
||||
@@ -1,26 +1,26 @@
|
||||
import type { PropsWithChildren } from "react";
|
||||
import { AppShellMain } from "@mantine/core";
|
||||
import {
|
||||
IconAffiliateFilled,
|
||||
IconBook2,
|
||||
IconBox,
|
||||
IconBrandDiscord,
|
||||
IconBrandDocker,
|
||||
IconBrandGithub,
|
||||
IconBrandTablerFilled,
|
||||
IconCertificate,
|
||||
IconClipboardListFilled,
|
||||
IconDirectionsFilled,
|
||||
IconGitFork,
|
||||
IconHome,
|
||||
IconInfoSmall,
|
||||
IconLayoutDashboard,
|
||||
IconLogs,
|
||||
IconHelpSquareRoundedFilled,
|
||||
IconHomeFilled,
|
||||
IconLayoutDashboardFilled,
|
||||
IconMailForward,
|
||||
IconPhoto,
|
||||
IconPlug,
|
||||
IconQuestionMark,
|
||||
IconReport,
|
||||
IconPhotoFilled,
|
||||
IconPointerFilled,
|
||||
IconSearch,
|
||||
IconSettings,
|
||||
IconTool,
|
||||
IconUser,
|
||||
IconSettingsFilled,
|
||||
IconUserFilled,
|
||||
IconUsers,
|
||||
IconUsersGroup,
|
||||
} from "@tabler/icons-react";
|
||||
@@ -31,6 +31,7 @@ import { createDocumentationLink } from "@homarr/definitions";
|
||||
import { getScopedI18n } from "@homarr/translation/server";
|
||||
|
||||
import { MainHeader } from "~/components/layout/header";
|
||||
import { homarrLogoPath } from "~/components/layout/logo/homarr-logo";
|
||||
import type { NavigationLink } from "~/components/layout/navigation";
|
||||
import { MainNavigation } from "~/components/layout/navigation";
|
||||
import { ClientShell } from "~/components/layout/shell";
|
||||
@@ -41,11 +42,11 @@ export default async function ManageLayout({ children }: PropsWithChildren) {
|
||||
const navigationLinks: NavigationLink[] = [
|
||||
{
|
||||
label: t("items.home"),
|
||||
icon: IconHome,
|
||||
icon: IconHomeFilled,
|
||||
href: "/manage",
|
||||
},
|
||||
{
|
||||
icon: IconLayoutDashboard,
|
||||
icon: IconLayoutDashboardFilled,
|
||||
href: "/manage/boards",
|
||||
label: t("items.boards"),
|
||||
},
|
||||
@@ -54,9 +55,12 @@ export default async function ManageLayout({ children }: PropsWithChildren) {
|
||||
href: "/manage/apps",
|
||||
label: t("items.apps"),
|
||||
hidden: !session,
|
||||
iconProps: {
|
||||
strokeWidth: 2.5,
|
||||
},
|
||||
},
|
||||
{
|
||||
icon: IconPlug,
|
||||
icon: IconAffiliateFilled,
|
||||
href: "/manage/integrations",
|
||||
label: t("items.integrations"),
|
||||
hidden: !session,
|
||||
@@ -66,15 +70,18 @@ export default async function ManageLayout({ children }: PropsWithChildren) {
|
||||
href: "/manage/search-engines",
|
||||
label: t("items.searchEngies"),
|
||||
hidden: !session,
|
||||
iconProps: {
|
||||
strokeWidth: 2.5,
|
||||
},
|
||||
},
|
||||
{
|
||||
icon: IconPhoto,
|
||||
icon: IconPhotoFilled,
|
||||
href: "/manage/medias",
|
||||
label: t("items.medias"),
|
||||
hidden: !session,
|
||||
},
|
||||
{
|
||||
icon: IconUser,
|
||||
icon: IconUserFilled,
|
||||
label: t("items.users.label"),
|
||||
hidden: !session?.user.permissions.includes("admin"),
|
||||
items: [
|
||||
@@ -98,7 +105,7 @@ export default async function ManageLayout({ children }: PropsWithChildren) {
|
||||
},
|
||||
{
|
||||
label: t("items.tools.label"),
|
||||
icon: IconTool,
|
||||
icon: IconPointerFilled,
|
||||
// As permissions always include there children permissions, we can check other-view-logs as admin includes it
|
||||
hidden: !session?.user.permissions.includes("other-view-logs"),
|
||||
items: [
|
||||
@@ -110,13 +117,13 @@ export default async function ManageLayout({ children }: PropsWithChildren) {
|
||||
},
|
||||
{
|
||||
label: t("items.tools.items.api"),
|
||||
icon: IconPlug,
|
||||
icon: IconDirectionsFilled,
|
||||
href: "/manage/tools/api",
|
||||
hidden: !session?.user.permissions.includes("admin"),
|
||||
},
|
||||
{
|
||||
label: t("items.tools.items.logs"),
|
||||
icon: IconLogs,
|
||||
icon: IconBrandTablerFilled,
|
||||
href: "/manage/tools/logs",
|
||||
hidden: !session?.user.permissions.includes("other-view-logs"),
|
||||
},
|
||||
@@ -128,7 +135,7 @@ export default async function ManageLayout({ children }: PropsWithChildren) {
|
||||
},
|
||||
{
|
||||
label: t("items.tools.items.tasks"),
|
||||
icon: IconReport,
|
||||
icon: IconClipboardListFilled,
|
||||
href: "/manage/tools/tasks",
|
||||
hidden: !session?.user.permissions.includes("admin"),
|
||||
},
|
||||
@@ -137,12 +144,12 @@ export default async function ManageLayout({ children }: PropsWithChildren) {
|
||||
{
|
||||
label: t("items.settings"),
|
||||
href: "/manage/settings",
|
||||
icon: IconSettings,
|
||||
icon: IconSettingsFilled,
|
||||
hidden: !session?.user.permissions.includes("admin"),
|
||||
},
|
||||
{
|
||||
label: t("items.help.label"),
|
||||
icon: IconQuestionMark,
|
||||
icon: IconHelpSquareRoundedFilled,
|
||||
items: [
|
||||
{
|
||||
label: t("items.help.items.documentation"),
|
||||
@@ -172,7 +179,7 @@ export default async function ManageLayout({ children }: PropsWithChildren) {
|
||||
},
|
||||
{
|
||||
label: t("items.about"),
|
||||
icon: IconInfoSmall,
|
||||
icon: homarrLogoPath,
|
||||
href: "/manage/about",
|
||||
},
|
||||
];
|
||||
|
||||
@@ -1,15 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import type { JSX } from "react";
|
||||
import { Button, FileButton } from "@mantine/core";
|
||||
import { Button } from "@mantine/core";
|
||||
import { IconUpload } from "@tabler/icons-react";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { revalidatePathActionAsync } from "@homarr/common/client";
|
||||
import type { MaybePromise } from "@homarr/common/types";
|
||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||
import { UploadMedia } from "@homarr/forms-collection";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import { supportedMediaUploadFormats } from "@homarr/validation";
|
||||
|
||||
export const UploadMediaButton = () => {
|
||||
const t = useI18n();
|
||||
@@ -27,45 +23,3 @@ export const UploadMediaButton = () => {
|
||||
</UploadMedia>
|
||||
);
|
||||
};
|
||||
|
||||
interface UploadMediaProps {
|
||||
children: (props: { onClick: () => void; loading: boolean }) => JSX.Element;
|
||||
onSettled?: () => MaybePromise<void>;
|
||||
onSuccess?: (media: { id: string; url: string }) => MaybePromise<void>;
|
||||
}
|
||||
|
||||
export const UploadMedia = ({ children, onSettled, onSuccess }: UploadMediaProps) => {
|
||||
const t = useI18n();
|
||||
const { mutateAsync, isPending } = clientApi.media.uploadMedia.useMutation();
|
||||
|
||||
const handleFileUploadAsync = async (file: File | null) => {
|
||||
if (!file) return;
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
await mutateAsync(formData, {
|
||||
async onSuccess(mediaId) {
|
||||
showSuccessNotification({
|
||||
message: t("media.action.upload.notification.success.message"),
|
||||
});
|
||||
await onSuccess?.({
|
||||
id: mediaId,
|
||||
url: `/api/user-medias/${mediaId}`,
|
||||
});
|
||||
},
|
||||
onError() {
|
||||
showErrorNotification({
|
||||
message: t("media.action.upload.notification.error.message"),
|
||||
});
|
||||
},
|
||||
async onSettled() {
|
||||
await onSettled?.();
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<FileButton onChange={handleFileUploadAsync} accept={supportedMediaUploadFormats.join(",")}>
|
||||
{({ onClick }) => children({ onClick, loading: isPending })}
|
||||
</FileButton>
|
||||
);
|
||||
};
|
||||
@@ -1,10 +1,10 @@
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import {
|
||||
ActionIcon,
|
||||
Anchor,
|
||||
Group,
|
||||
Image,
|
||||
Stack,
|
||||
Table,
|
||||
TableTbody,
|
||||
@@ -61,7 +61,7 @@ export default async function GroupsListPage(props: MediaListPageProps) {
|
||||
const { items: medias, totalCount } = await api.media.getPaginated(searchParams);
|
||||
|
||||
return (
|
||||
<ManageContainer size="xl">
|
||||
<ManageContainer>
|
||||
<DynamicBreadcrumb />
|
||||
<Stack>
|
||||
<Title>{t("media.plural")}</Title>
|
||||
@@ -113,11 +113,12 @@ const Row = async ({ media }: RowProps) => {
|
||||
<TableTr>
|
||||
<TableTd w={64}>
|
||||
<Image
|
||||
// Switched to mantine image because next/image doesn't support svgs
|
||||
src={createLocalImageUrl(media.id)}
|
||||
alt={media.name}
|
||||
width={64}
|
||||
height={64}
|
||||
style={{ objectFit: "contain" }}
|
||||
w={64}
|
||||
h={64}
|
||||
fit="contain"
|
||||
/>
|
||||
</TableTd>
|
||||
<TableTd>{media.name}</TableTd>
|
||||
|
||||
@@ -83,7 +83,7 @@ export default async function ManagementPage() {
|
||||
{links.map(
|
||||
(link) =>
|
||||
!link.hidden && (
|
||||
<Card component={Link} href={link.href} key={link.href} withBorder>
|
||||
<Card component={Link} href={link.href} key={link.href} radius="lg">
|
||||
<Group justify="space-between" wrap="nowrap">
|
||||
<Group wrap="nowrap">
|
||||
<Text size="2.4rem" fw="bolder">
|
||||
|
||||
@@ -9,12 +9,11 @@ import type { z } from "zod";
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { searchEngineTypes } from "@homarr/definitions";
|
||||
import { useZodForm } from "@homarr/form";
|
||||
import { IconPicker } from "@homarr/forms-collection";
|
||||
import type { TranslationFunction } from "@homarr/translation";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import { validation } from "@homarr/validation";
|
||||
|
||||
import { IconPicker } from "~/components/icons/picker/icon-picker";
|
||||
|
||||
type FormType = z.infer<typeof validation.searchEngine.manage>;
|
||||
|
||||
interface SearchEngineFormProps {
|
||||
@@ -58,7 +57,7 @@ export const SearchEngineForm = (props: SearchEngineFormProps) => {
|
||||
/>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
<IconPicker initialValue={initialValues?.iconUrl} {...form.getInputProps("iconUrl")} />
|
||||
<IconPicker {...form.getInputProps("iconUrl")} />
|
||||
|
||||
<Fieldset legend={t("search.engine.page.edit.configControl")}>
|
||||
<SegmentedControl
|
||||
|
||||
@@ -45,7 +45,7 @@ export default async function SearchEnginesPage(props: SearchEnginesPageProps) {
|
||||
<Stack>
|
||||
<Title>{tEngine("page.list.title")}</Title>
|
||||
<Group justify="space-between" align="center">
|
||||
<SearchInput placeholder={`${tEngine("search")}...`} defaultValue={searchParams.search} />
|
||||
<SearchInput placeholder={`${tEngine("search")}...`} defaultValue={searchParams.search} flexExpand />
|
||||
{session.user.permissions.includes("search-engine-create") && (
|
||||
<MobileAffixButton component={Link} href="/manage/search-engines/new">
|
||||
{tEngine("page.create.title")}
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import { Group, Text } from "@mantine/core";
|
||||
import { IconLayoutDashboard } from "@tabler/icons-react";
|
||||
import { Switch, Text } from "@mantine/core";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import type { ServerSettings } from "@homarr/server-settings";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
import { SelectWithCustomItems } from "@homarr/ui";
|
||||
|
||||
import { BoardSelect } from "~/components/board/board-select";
|
||||
import { CommonSettingsForm } from "./common-form";
|
||||
|
||||
export const BoardSettingsForm = ({ defaultValues }: { defaultValues: ServerSettings["board"] }) => {
|
||||
@@ -18,44 +17,33 @@ export const BoardSettingsForm = ({ defaultValues }: { defaultValues: ServerSett
|
||||
<CommonSettingsForm settingKey="board" defaultValues={defaultValues}>
|
||||
{(form) => (
|
||||
<>
|
||||
<SelectWithCustomItems
|
||||
<BoardSelect
|
||||
label={tBoard("homeBoard.label")}
|
||||
description={tBoard("homeBoard.description")}
|
||||
data={selectableBoards.map((board) => ({
|
||||
value: board.id,
|
||||
label: board.name,
|
||||
image: board.logoImageUrl,
|
||||
}))}
|
||||
SelectOption={({ label, image }: { value: string; label: string; image: string | null }) => (
|
||||
<Group>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
{image ? <img width={16} height={16} src={image} alt={label} /> : <IconLayoutDashboard size={16} />}
|
||||
<Text fz="sm" fw={500}>
|
||||
{label}
|
||||
</Text>
|
||||
</Group>
|
||||
)}
|
||||
clearable
|
||||
boards={selectableBoards}
|
||||
{...form.getInputProps("homeBoardId")}
|
||||
/>
|
||||
<SelectWithCustomItems
|
||||
|
||||
<BoardSelect
|
||||
label={tBoard("homeBoard.mobileLabel")}
|
||||
description={tBoard("homeBoard.description")}
|
||||
data={selectableBoards.map((board) => ({
|
||||
value: board.id,
|
||||
label: board.name,
|
||||
image: board.logoImageUrl,
|
||||
}))}
|
||||
SelectOption={({ label, image }: { value: string; label: string; image: string | null }) => (
|
||||
<Group>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
{image ? <img width={16} height={16} src={image} alt={label} /> : <IconLayoutDashboard size={16} />}
|
||||
<Text fz="sm" fw={500}>
|
||||
{label}
|
||||
</Text>
|
||||
</Group>
|
||||
)}
|
||||
clearable
|
||||
boards={selectableBoards}
|
||||
{...form.getInputProps("mobileHomeBoardId")}
|
||||
/>
|
||||
|
||||
<Text fw={500}>{tBoard("status.title")}</Text>
|
||||
<Switch
|
||||
{...form.getInputProps("enableStatusByDefault", { type: "checkbox" })}
|
||||
label={tBoard("status.enableStatusByDefault.label")}
|
||||
description={tBoard("status.enableStatusByDefault.description")}
|
||||
/>
|
||||
<Switch
|
||||
{...form.getInputProps("forceDisableStatus", { type: "checkbox" })}
|
||||
label={tBoard("status.forceDisableStatus.label")}
|
||||
description={tBoard("status.forceDisableStatus.description")}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</CommonSettingsForm>
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
import { Button, Group, Stack, Text, Title } from "@mantine/core";
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { ActionIcon, Button, Group, Stack, Text, Title } from "@mantine/core";
|
||||
import { IconTrash } from "@tabler/icons-react";
|
||||
import type { MRT_ColumnDef } from "mantine-react-table";
|
||||
import { MantineReactTable, useMantineReactTable } from "mantine-react-table";
|
||||
|
||||
import type { RouterOutputs } from "@homarr/api";
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { revalidatePathActionAsync } from "@homarr/common/client";
|
||||
import { useModalAction } from "@homarr/modals";
|
||||
import { useConfirmModal, useModalAction } from "@homarr/modals";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
import { UserAvatar } from "@homarr/ui";
|
||||
|
||||
@@ -20,7 +21,8 @@ interface ApiKeysManagementProps {
|
||||
|
||||
export const ApiKeysManagement = ({ apiKeys }: ApiKeysManagementProps) => {
|
||||
const { openModal } = useModalAction(CopyApiKeyModal);
|
||||
const { mutate, isPending } = clientApi.apiKeys.create.useMutation({
|
||||
const { openConfirmModal } = useConfirmModal();
|
||||
const { mutate: mutateCreate, isPending: isPendingCreate } = clientApi.apiKeys.create.useMutation({
|
||||
async onSuccess(data) {
|
||||
openModal({
|
||||
apiKey: data.apiKey,
|
||||
@@ -28,7 +30,26 @@ export const ApiKeysManagement = ({ apiKeys }: ApiKeysManagementProps) => {
|
||||
await revalidatePathActionAsync("/manage/tools/api");
|
||||
},
|
||||
});
|
||||
const { mutateAsync: mutateDeleteAsync, isPending: isPendingDelete } = clientApi.apiKeys.delete.useMutation({
|
||||
async onSuccess() {
|
||||
await revalidatePathActionAsync("/manage/tools/api");
|
||||
},
|
||||
});
|
||||
|
||||
const t = useScopedI18n("management.page.tool.api.tab.apiKey");
|
||||
const handleDelete = useCallback(
|
||||
(id: string) => {
|
||||
openConfirmModal({
|
||||
title: t("modal.delete.title"),
|
||||
children: t("modal.delete.text"),
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
async onConfirm() {
|
||||
await mutateDeleteAsync({ apiKeyId: id });
|
||||
},
|
||||
});
|
||||
},
|
||||
[t, openConfirmModal, mutateDeleteAsync],
|
||||
);
|
||||
|
||||
const columns = useMemo<MRT_ColumnDef<RouterOutputs["apiKeys"]["getAll"][number]>[]>(
|
||||
() => [
|
||||
@@ -46,8 +67,18 @@ export const ApiKeysManagement = ({ apiKeys }: ApiKeysManagementProps) => {
|
||||
</Group>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: t("table.header.actions"),
|
||||
Cell: ({ row }) => (
|
||||
<Group gap="xs">
|
||||
<ActionIcon onClick={() => handleDelete(row.original.id)} loading={isPendingDelete} c="red">
|
||||
<IconTrash size="1rem" />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
),
|
||||
},
|
||||
],
|
||||
[t],
|
||||
[t, handleDelete, isPendingDelete],
|
||||
);
|
||||
|
||||
const table = useMantineReactTable({
|
||||
@@ -56,9 +87,9 @@ export const ApiKeysManagement = ({ apiKeys }: ApiKeysManagementProps) => {
|
||||
renderTopToolbarCustomActions: () => (
|
||||
<Button
|
||||
onClick={() => {
|
||||
mutate();
|
||||
mutateCreate();
|
||||
}}
|
||||
loading={isPending}
|
||||
loading={isPendingCreate}
|
||||
>
|
||||
{t("button.createApiToken")}
|
||||
</Button>
|
||||
|
||||
@@ -1434,26 +1434,30 @@
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-button:vertical:start:decrement {
|
||||
background: linear-gradient(130deg, #696969 40%, rgba(255, 0, 0, 0) 41%),
|
||||
background:
|
||||
linear-gradient(130deg, #696969 40%, rgba(255, 0, 0, 0) 41%),
|
||||
linear-gradient(230deg, #696969 40%, transparent 41%), linear-gradient(0deg, #696969 40%, transparent 31%);
|
||||
background-color: #b6b6b6;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-button:vertical:end:increment {
|
||||
background: linear-gradient(310deg, #696969 40%, transparent 41%),
|
||||
linear-gradient(50deg, #696969 40%, transparent 41%), linear-gradient(180deg, #696969 40%, transparent 31%);
|
||||
background:
|
||||
linear-gradient(310deg, #696969 40%, transparent 41%), linear-gradient(50deg, #696969 40%, transparent 41%),
|
||||
linear-gradient(180deg, #696969 40%, transparent 31%);
|
||||
background-color: #b6b6b6;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-button:horizontal:end:increment {
|
||||
background: linear-gradient(210deg, #696969 40%, transparent 41%),
|
||||
linear-gradient(330deg, #696969 40%, transparent 41%), linear-gradient(90deg, #696969 30%, transparent 31%);
|
||||
background:
|
||||
linear-gradient(210deg, #696969 40%, transparent 41%), linear-gradient(330deg, #696969 40%, transparent 41%),
|
||||
linear-gradient(90deg, #696969 30%, transparent 31%);
|
||||
background-color: #b6b6b6;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-button:horizontal:start:decrement {
|
||||
background: linear-gradient(30deg, #696969 40%, transparent 41%),
|
||||
linear-gradient(150deg, #696969 40%, transparent 41%), linear-gradient(270deg, #696969 30%, transparent 31%);
|
||||
background:
|
||||
linear-gradient(30deg, #696969 40%, transparent 41%), linear-gradient(150deg, #696969 40%, transparent 41%),
|
||||
linear-gradient(270deg, #696969 30%, transparent 31%);
|
||||
background-color: #b6b6b6;
|
||||
}
|
||||
|
||||
@@ -1681,28 +1685,32 @@
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-button:vertical:start:decrement {
|
||||
background: linear-gradient(130deg, #696969 40%, rgba(255, 0, 0, 0) 41%),
|
||||
background:
|
||||
linear-gradient(130deg, #696969 40%, rgba(255, 0, 0, 0) 41%),
|
||||
linear-gradient(230deg, #696969 40%, rgba(0, 0, 0, 0) 41%),
|
||||
linear-gradient(0deg, #696969 40%, rgba(0, 0, 0, 0) 31%);
|
||||
background-color: #b6b6b6;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-button:vertical:end:increment {
|
||||
background: linear-gradient(310deg, #696969 40%, rgba(0, 0, 0, 0) 41%),
|
||||
background:
|
||||
linear-gradient(310deg, #696969 40%, rgba(0, 0, 0, 0) 41%),
|
||||
linear-gradient(50deg, #696969 40%, rgba(0, 0, 0, 0) 41%),
|
||||
linear-gradient(180deg, #696969 40%, rgba(0, 0, 0, 0) 31%);
|
||||
background-color: #b6b6b6;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-button:horizontal:end:increment {
|
||||
background: linear-gradient(210deg, #696969 40%, rgba(0, 0, 0, 0) 41%),
|
||||
background:
|
||||
linear-gradient(210deg, #696969 40%, rgba(0, 0, 0, 0) 41%),
|
||||
linear-gradient(330deg, #696969 40%, rgba(0, 0, 0, 0) 41%),
|
||||
linear-gradient(90deg, #696969 30%, rgba(0, 0, 0, 0) 31%);
|
||||
background-color: #b6b6b6;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-button:horizontal:start:decrement {
|
||||
background: linear-gradient(30deg, #696969 40%, rgba(0, 0, 0, 0) 41%),
|
||||
background:
|
||||
linear-gradient(30deg, #696969 40%, rgba(0, 0, 0, 0) 41%),
|
||||
linear-gradient(150deg, #696969 40%, rgba(0, 0, 0, 0) 41%),
|
||||
linear-gradient(270deg, #696969 30%, rgba(0, 0, 0, 0) 31%);
|
||||
background-color: #b6b6b6;
|
||||
|
||||
+11
-6
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { Button, Group, Select, Stack } from "@mantine/core";
|
||||
import { Button, Group, Stack } from "@mantine/core";
|
||||
import type { z } from "zod";
|
||||
|
||||
import type { RouterOutputs } from "@homarr/api";
|
||||
@@ -11,9 +11,12 @@ import { showErrorNotification, showSuccessNotification } from "@homarr/notifica
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import { validation } from "@homarr/validation";
|
||||
|
||||
import type { Board } from "~/app/[locale]/boards/_types";
|
||||
import { BoardSelect } from "~/components/board/board-select";
|
||||
|
||||
interface ChangeHomeBoardFormProps {
|
||||
user: RouterOutputs["user"]["getById"];
|
||||
boardsData: { value: string; label: string }[];
|
||||
boardsData: Pick<Board, "id" | "name" | "logoImageUrl">[];
|
||||
}
|
||||
|
||||
export const ChangeHomeBoardForm = ({ user, boardsData }: ChangeHomeBoardFormProps) => {
|
||||
@@ -54,16 +57,18 @@ export const ChangeHomeBoardForm = ({ user, boardsData }: ChangeHomeBoardFormPro
|
||||
return (
|
||||
<form onSubmit={form.onSubmit(handleSubmit)}>
|
||||
<Stack gap="md">
|
||||
<Select
|
||||
<BoardSelect
|
||||
label={t("management.page.user.setting.general.item.board.type.general")}
|
||||
clearable
|
||||
boards={boardsData}
|
||||
w="100%"
|
||||
data={boardsData}
|
||||
{...form.getInputProps("homeBoardId")}
|
||||
/>
|
||||
<Select
|
||||
<BoardSelect
|
||||
label={t("management.page.user.setting.general.item.board.type.mobile")}
|
||||
clearable
|
||||
boards={boardsData}
|
||||
w="100%"
|
||||
data={boardsData}
|
||||
{...form.getInputProps("mobileHomeBoardId")}
|
||||
/>
|
||||
|
||||
|
||||
@@ -95,8 +95,9 @@ export default async function EditUserPage(props: Props) {
|
||||
<ChangeHomeBoardForm
|
||||
user={user}
|
||||
boardsData={boards.map((board) => ({
|
||||
value: board.id,
|
||||
label: board.name,
|
||||
id: board.id,
|
||||
name: board.name,
|
||||
logoImageUrl: board.logoImageUrl,
|
||||
}))}
|
||||
/>
|
||||
</Stack>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { PropsWithChildren } from "react";
|
||||
import Link from "next/link";
|
||||
import { Button, Grid, GridCol, Group, Stack, Text, Title } from "@mantine/core";
|
||||
import { IconLock, IconSettings, IconUsersGroup } from "@tabler/icons-react";
|
||||
import { IconId, IconLock, IconSettings, IconUsersGroup } from "@tabler/icons-react";
|
||||
|
||||
import { api } from "@homarr/api/server";
|
||||
import { getI18n, getScopedI18n } from "@homarr/translation/server";
|
||||
@@ -42,6 +42,11 @@ export default async function Layout(props: PropsWithChildren<LayoutProps>) {
|
||||
<NavigationLink
|
||||
href={`/manage/users/groups/${params.id}`}
|
||||
label={tGroup("setting.general.title")}
|
||||
icon={<IconId size="1rem" stroke={1.5} />}
|
||||
/>
|
||||
<NavigationLink
|
||||
href={`/manage/users/groups/${params.id}/settings`}
|
||||
label={tGroup("setting.setting.title")}
|
||||
icon={<IconSettings size="1rem" stroke={1.5} />}
|
||||
/>
|
||||
<NavigationLink
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
"use client";
|
||||
|
||||
import { Button, Group, Stack } from "@mantine/core";
|
||||
|
||||
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 { BoardSelect } from "~/components/board/board-select";
|
||||
|
||||
interface GroupHomeBoardsProps {
|
||||
homeBoardId: string | null;
|
||||
mobileHomeBoardId: string | null;
|
||||
groupId: string;
|
||||
}
|
||||
|
||||
export const GroupHomeBoards = ({ homeBoardId, mobileHomeBoardId, groupId }: GroupHomeBoardsProps) => {
|
||||
const t = useI18n();
|
||||
const [availableBoards] = clientApi.board.getBoardsForGroup.useSuspenseQuery({ groupId });
|
||||
const form = useZodForm(validation.group.settings.pick({ homeBoardId: true, mobileHomeBoardId: true }), {
|
||||
initialValues: {
|
||||
homeBoardId,
|
||||
mobileHomeBoardId,
|
||||
},
|
||||
});
|
||||
const { mutateAsync, isPending } = clientApi.group.savePartialSettings.useMutation();
|
||||
|
||||
const handleSubmit = form.onSubmit(async (values) => {
|
||||
await mutateAsync(
|
||||
{
|
||||
id: groupId,
|
||||
settings: values,
|
||||
},
|
||||
{
|
||||
onSuccess() {
|
||||
form.setInitialValues(values);
|
||||
showSuccessNotification({
|
||||
title: t("group.action.settings.board.notification.success.title"),
|
||||
message: t("group.action.settings.board.notification.success.message"),
|
||||
});
|
||||
},
|
||||
onError() {
|
||||
showErrorNotification({
|
||||
title: t("group.action.settings.board.notification.error.title"),
|
||||
message: t("group.action.settings.board.notification.error.message"),
|
||||
});
|
||||
},
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit}>
|
||||
<Stack gap="md">
|
||||
<BoardSelect
|
||||
label={t("group.field.homeBoard.label")}
|
||||
description={t("group.field.homeBoard.description")}
|
||||
clearable
|
||||
boards={availableBoards}
|
||||
{...form.getInputProps("homeBoardId")}
|
||||
/>
|
||||
|
||||
<BoardSelect
|
||||
label={t("group.field.mobileBoard.label")}
|
||||
description={t("group.field.mobileBoard.description")}
|
||||
clearable
|
||||
boards={availableBoards}
|
||||
{...form.getInputProps("mobileHomeBoardId")}
|
||||
/>
|
||||
|
||||
<Group justify="end">
|
||||
<Button type="submit" color="teal" loading={isPending}>
|
||||
{t("common.action.save")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,41 @@
|
||||
import { notFound } from "next/navigation";
|
||||
import { Alert, Stack, Title } from "@mantine/core";
|
||||
import { IconExclamationCircle } from "@tabler/icons-react";
|
||||
|
||||
import { api } from "@homarr/api/server";
|
||||
import { auth } from "@homarr/auth/next";
|
||||
import { getI18n } from "@homarr/translation/server";
|
||||
|
||||
import { GroupHomeBoards } from "./_group-home-boards";
|
||||
|
||||
interface GroupSettingsPageProps {
|
||||
params: Promise<{
|
||||
id: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
export default async function GroupPermissionsPage(props: GroupSettingsPageProps) {
|
||||
const params = await props.params;
|
||||
const session = await auth();
|
||||
|
||||
if (!session?.user.permissions.includes("admin")) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const group = await api.group.getById({ id: params.id });
|
||||
const t = await getI18n();
|
||||
|
||||
return (
|
||||
<Stack>
|
||||
<Title>{t("management.page.group.setting.setting.title")}</Title>
|
||||
|
||||
<Alert color="cyan" icon={<IconExclamationCircle size="1rem" stroke={1.5} />}>
|
||||
{t("management.page.group.setting.setting.alert")}
|
||||
</Alert>
|
||||
|
||||
<Title order={3}>{t("management.page.group.setting.setting.board.title")}</Title>
|
||||
|
||||
<GroupHomeBoards homeBoardId={group.homeBoardId} mobileHomeBoardId={group.mobileHomeBoardId} groupId={group.id} />
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback } from "react";
|
||||
|
||||
import { useModalAction } from "@homarr/modals";
|
||||
import { AddGroupModal } from "@homarr/modals-collection";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
|
||||
import { MobileAffixButton } from "~/components/manage/mobile-affix-button";
|
||||
|
||||
export const AddGroup = () => {
|
||||
const t = useI18n();
|
||||
const { openModal } = useModalAction(AddGroupModal);
|
||||
|
||||
const handleAddGroup = useCallback(() => {
|
||||
openModal();
|
||||
}, [openModal]);
|
||||
|
||||
return (
|
||||
<MobileAffixButton onClick={handleAddGroup} color="teal">
|
||||
{t("group.action.create.label")}
|
||||
</MobileAffixButton>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,65 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { Group, TextInput } from "@mantine/core";
|
||||
import { IconSearch } from "@tabler/icons-react";
|
||||
|
||||
import type { RouterOutputs } from "@homarr/api";
|
||||
import { useModalAction } from "@homarr/modals";
|
||||
import { AddGroupModal } from "@homarr/modals-collection";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
|
||||
import { MobileAffixButton } from "~/components/manage/mobile-affix-button";
|
||||
import { GroupsTable } from "./_groups-table";
|
||||
|
||||
interface GroupsListProps {
|
||||
groups: RouterOutputs["group"]["getAll"];
|
||||
}
|
||||
|
||||
export const GroupsList = ({ groups }: GroupsListProps) => {
|
||||
const [search, setSearch] = useState("");
|
||||
const initialGroupIds = useMemo(
|
||||
() => groups.sort((groupA, groupB) => groupA.position - groupB.position).map((group) => group.id),
|
||||
[groups],
|
||||
);
|
||||
const filteredGroups = useMemo(
|
||||
() =>
|
||||
groups
|
||||
.filter((group) => group.name.toLowerCase().includes(search.toLowerCase()))
|
||||
.sort((groupA, groupB) => groupA.position - groupB.position),
|
||||
[groups, search],
|
||||
);
|
||||
const t = useI18n();
|
||||
|
||||
return (
|
||||
<>
|
||||
<Group justify="space-between">
|
||||
<TextInput
|
||||
leftSection={<IconSearch size={20} stroke={1.5} />}
|
||||
value={search}
|
||||
onChange={(event) => setSearch(event.currentTarget.value)}
|
||||
placeholder={`${t("group.search")}...`}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<AddGroup />
|
||||
</Group>
|
||||
|
||||
<GroupsTable groups={filteredGroups} initialGroupIds={initialGroupIds} hasFilter={search.length !== 0} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const AddGroup = () => {
|
||||
const t = useI18n();
|
||||
const { openModal } = useModalAction(AddGroupModal);
|
||||
|
||||
const handleAddGroup = useCallback(() => {
|
||||
openModal();
|
||||
}, [openModal]);
|
||||
|
||||
return (
|
||||
<MobileAffixButton onClick={handleAddGroup} color="teal">
|
||||
{t("group.action.create.label")}
|
||||
</MobileAffixButton>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,277 @@
|
||||
"use client";
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import { useMemo, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import type { DragEndEvent, DraggableAttributes, DragStartEvent } from "@dnd-kit/core";
|
||||
import {
|
||||
closestCenter,
|
||||
DndContext,
|
||||
DragOverlay,
|
||||
KeyboardSensor,
|
||||
MouseSensor,
|
||||
TouchSensor,
|
||||
useSensor,
|
||||
useSensors,
|
||||
} from "@dnd-kit/core";
|
||||
import type { SyntheticListenerMap } from "@dnd-kit/core/dist/hooks/utilities";
|
||||
import { restrictToVerticalAxis } from "@dnd-kit/modifiers";
|
||||
import { arrayMove, SortableContext, useSortable, verticalListSortingStrategy } from "@dnd-kit/sortable";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
import {
|
||||
Anchor,
|
||||
Box,
|
||||
Button,
|
||||
Card,
|
||||
Flex,
|
||||
Group,
|
||||
Table,
|
||||
TableTbody,
|
||||
TableTd,
|
||||
TableTh,
|
||||
TableThead,
|
||||
TableTr,
|
||||
Text,
|
||||
Transition,
|
||||
} from "@mantine/core";
|
||||
import { IconGripVertical } from "@tabler/icons-react";
|
||||
|
||||
import type { RouterOutputs } from "@homarr/api";
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { revalidatePathActionAsync } from "@homarr/common/client";
|
||||
import { showSuccessNotification } from "@homarr/notifications";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import { UserAvatarGroup } from "@homarr/ui";
|
||||
|
||||
interface GroupsTableProps {
|
||||
initialGroupIds: string[];
|
||||
groups: RouterOutputs["group"]["getAll"];
|
||||
hasFilter: boolean;
|
||||
}
|
||||
|
||||
export const GroupsTable = ({ groups, initialGroupIds, hasFilter }: GroupsTableProps) => {
|
||||
const t = useI18n();
|
||||
const [activeId, setActiveId] = useState<string | null>(null);
|
||||
const [groupIds, setGroupIds] = useState(groups.map((group) => group.id));
|
||||
const isDirty = useMemo(
|
||||
() => initialGroupIds.some((groupId, index) => groupIds.indexOf(groupId) !== index),
|
||||
[groupIds, initialGroupIds],
|
||||
);
|
||||
const { mutateAsync, isPending } = clientApi.group.savePositions.useMutation();
|
||||
const handleSavePositionsAsync = async () => {
|
||||
await mutateAsync(
|
||||
{ positions: groupIds },
|
||||
{
|
||||
async onSuccess() {
|
||||
showSuccessNotification({
|
||||
message: t("group.action.changePosition.notification.success.message"),
|
||||
});
|
||||
await revalidatePathActionAsync("/manage/users/groups");
|
||||
},
|
||||
onError() {
|
||||
showSuccessNotification({
|
||||
message: t("group.action.changePosition.notification.error.message"),
|
||||
});
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const sensors = useSensors(useSensor(MouseSensor, {}), useSensor(TouchSensor, {}), useSensor(KeyboardSensor, {}));
|
||||
|
||||
const handleDragStart = (event: DragStartEvent) => {
|
||||
setActiveId(event.active.id as string);
|
||||
};
|
||||
|
||||
function handleDragEnd(event: DragEndEvent) {
|
||||
const { active, over } = event;
|
||||
if (!over || active.id === over.id) {
|
||||
setActiveId(null);
|
||||
return;
|
||||
}
|
||||
|
||||
setGroupIds((groupIds) => {
|
||||
const oldIndex = groupIds.indexOf(active.id as string);
|
||||
const newIndex = groupIds.indexOf(over.id as string);
|
||||
return arrayMove(groupIds, oldIndex, newIndex);
|
||||
});
|
||||
}
|
||||
|
||||
function handleDragCancel() {
|
||||
setActiveId(null);
|
||||
}
|
||||
|
||||
const selectedRow = useMemo(() => {
|
||||
if (!activeId) return null;
|
||||
|
||||
const current = groups.find((group) => group.id === activeId);
|
||||
if (!current) return null;
|
||||
|
||||
return <Row group={current} handle={<DragHandle attributes={undefined} listeners={undefined} active />} />;
|
||||
}, [activeId, groups]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
onDragEnd={handleDragEnd}
|
||||
onDragStart={handleDragStart}
|
||||
onDragCancel={handleDragCancel}
|
||||
collisionDetection={closestCenter}
|
||||
modifiers={[restrictToVerticalAxis]}
|
||||
id="groups-table"
|
||||
>
|
||||
<Table striped highlightOnHover>
|
||||
<TableThead>
|
||||
<TableTr>
|
||||
<TableTh>{t("group.field.name")}</TableTh>
|
||||
<TableTh>{t("group.field.members")}</TableTh>
|
||||
</TableTr>
|
||||
</TableThead>
|
||||
<TableTbody>
|
||||
<SortableContext items={groupIds} strategy={verticalListSortingStrategy}>
|
||||
{groupIds.map((groupId) => {
|
||||
const group = groups.find(({ id }) => id === groupId);
|
||||
if (!group) return null;
|
||||
|
||||
return <DraggableRow key={group.id} group={group} disabled={hasFilter} />;
|
||||
})}
|
||||
</SortableContext>
|
||||
</TableTbody>
|
||||
</Table>
|
||||
|
||||
<DragOverlay>
|
||||
{activeId && (
|
||||
<Table w="100%">
|
||||
<TableTbody>{selectedRow}</TableTbody>
|
||||
</Table>
|
||||
)}
|
||||
</DragOverlay>
|
||||
</DndContext>
|
||||
<SaveAffix
|
||||
visible={isDirty}
|
||||
onDiscard={() => setGroupIds(initialGroupIds)}
|
||||
isPending={isPending}
|
||||
onSave={handleSavePositionsAsync}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
interface DraggableRowProps {
|
||||
group: RouterOutputs["group"]["getAll"][number];
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
const DraggableRow = ({ group, disabled }: DraggableRowProps) => {
|
||||
const { attributes, listeners, transform, transition, setNodeRef, isDragging } = useSortable({
|
||||
id: group.id,
|
||||
});
|
||||
|
||||
const style = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
};
|
||||
|
||||
if (isDragging) {
|
||||
return (
|
||||
<TableTr ref={setNodeRef} style={style}>
|
||||
<TableTd colSpan={2}> </TableTd>
|
||||
</TableTr>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Row
|
||||
group={group}
|
||||
setNodeRef={setNodeRef}
|
||||
style={style}
|
||||
handle={<DragHandle attributes={attributes} listeners={listeners} active={false} disabled={disabled} />}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
interface RowProps {
|
||||
group: RouterOutputs["group"]["getAll"][number];
|
||||
handle?: ReactNode;
|
||||
setNodeRef?: (node: HTMLElement | null) => void;
|
||||
style?: React.CSSProperties;
|
||||
}
|
||||
|
||||
const Row = ({ group, handle, setNodeRef, style }: RowProps) => {
|
||||
return (
|
||||
<TableTr ref={setNodeRef} style={style}>
|
||||
<TableTd>
|
||||
<Group>
|
||||
{handle}
|
||||
<Anchor component={Link} href={`/manage/users/groups/${group.id}`}>
|
||||
{group.name}
|
||||
</Anchor>
|
||||
</Group>
|
||||
</TableTd>
|
||||
<TableTd>
|
||||
<UserAvatarGroup users={group.members} size="sm" limit={5} />
|
||||
</TableTd>
|
||||
</TableTr>
|
||||
);
|
||||
};
|
||||
|
||||
interface DragHandleProps {
|
||||
attributes: DraggableAttributes | undefined;
|
||||
listeners: SyntheticListenerMap | undefined;
|
||||
active: boolean;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
const DragHandle = ({ attributes, listeners, active, disabled }: DragHandleProps) => {
|
||||
if (disabled) {
|
||||
return <Box w={40} h="100%" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Flex
|
||||
align="center"
|
||||
justify="center"
|
||||
h="100%"
|
||||
w={40}
|
||||
style={{ cursor: active ? "grabbing" : "grab" }}
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
>
|
||||
<IconGripVertical size={18} stroke={1.5} />
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
|
||||
interface SaveAffixProps {
|
||||
visible: boolean;
|
||||
isPending: boolean;
|
||||
onDiscard: () => void;
|
||||
onSave: () => void;
|
||||
}
|
||||
|
||||
const SaveAffix = ({ visible, isPending, onDiscard, onSave }: SaveAffixProps) => {
|
||||
const t = useI18n();
|
||||
|
||||
return (
|
||||
<div style={{ position: "sticky", bottom: 20 }}>
|
||||
<Transition transition="slide-up" mounted={visible}>
|
||||
{(transitionStyles) => (
|
||||
<Card style={transitionStyles} withBorder>
|
||||
<Group justify="space-between">
|
||||
<Text fw={500}>{t("common.unsavedChanges")}</Text>
|
||||
<Group>
|
||||
<Button disabled={isPending} onClick={onDiscard}>
|
||||
{t("common.action.discard")}
|
||||
</Button>
|
||||
<Button color="teal" loading={isPending} onClick={onSave}>
|
||||
{t("common.action.saveChanges")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Group>
|
||||
</Card>
|
||||
)}
|
||||
</Transition>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,7 @@
|
||||
.everyoneGroup {
|
||||
background-color: light-dark(var(--mantine-color-gray-1), var(--mantine-color-dark-6));
|
||||
}
|
||||
|
||||
.everyoneGroup:hover {
|
||||
background-color: light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-5));
|
||||
}
|
||||
@@ -1,30 +1,19 @@
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import { Anchor, Group, Stack, Table, TableTbody, TableTd, TableTh, TableThead, TableTr, Title } from "@mantine/core";
|
||||
import { z } from "zod";
|
||||
import { Card, Group, Stack, Text, ThemeIcon, Title, UnstyledButton } from "@mantine/core";
|
||||
import { IconChevronRight, IconUsersGroup } from "@tabler/icons-react";
|
||||
|
||||
import type { RouterOutputs } from "@homarr/api";
|
||||
import { api } from "@homarr/api/server";
|
||||
import { auth } from "@homarr/auth/next";
|
||||
import type { inferSearchParamsFromSchema } from "@homarr/common/types";
|
||||
import { everyoneGroup } from "@homarr/definitions";
|
||||
import { getI18n } from "@homarr/translation/server";
|
||||
import { SearchInput, TablePagination, UserAvatarGroup } from "@homarr/ui";
|
||||
|
||||
import { ManageContainer } from "~/components/manage/manage-container";
|
||||
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
|
||||
import { AddGroup } from "./_add-group";
|
||||
import { GroupsList } from "./_client";
|
||||
import classes from "./groups.module.css";
|
||||
|
||||
const searchParamsSchema = z.object({
|
||||
search: z.string().optional(),
|
||||
pageSize: z.string().regex(/\d+/).transform(Number).catch(10),
|
||||
page: z.string().regex(/\d+/).transform(Number).catch(1),
|
||||
});
|
||||
|
||||
interface GroupsListPageProps {
|
||||
searchParams: Promise<inferSearchParamsFromSchema<typeof searchParamsSchema>>;
|
||||
}
|
||||
|
||||
export default async function GroupsListPage(props: GroupsListPageProps) {
|
||||
export default async function GroupsListPage() {
|
||||
const session = await auth();
|
||||
|
||||
if (!session?.user.permissions.includes("admin")) {
|
||||
@@ -32,55 +21,38 @@ export default async function GroupsListPage(props: GroupsListPageProps) {
|
||||
}
|
||||
|
||||
const t = await getI18n();
|
||||
const searchParams = searchParamsSchema.parse(await props.searchParams);
|
||||
const { items: groups, totalCount } = await api.group.getPaginated(searchParams);
|
||||
const groups = await api.group.getAll();
|
||||
const dbEveryoneGroup = groups.find((group) => group.name === everyoneGroup);
|
||||
const groupsWithoutEveryone = groups.filter((group) => group.name !== everyoneGroup);
|
||||
|
||||
return (
|
||||
<ManageContainer size="xl">
|
||||
<DynamicBreadcrumb />
|
||||
<Stack>
|
||||
<Title>{t("group.title")}</Title>
|
||||
<Group justify="space-between">
|
||||
<SearchInput placeholder={`${t("group.search")}...`} defaultValue={searchParams.search} />
|
||||
<AddGroup />
|
||||
</Group>
|
||||
<Table striped highlightOnHover>
|
||||
<TableThead>
|
||||
<TableTr>
|
||||
<TableTh>{t("group.field.name")}</TableTh>
|
||||
<TableTh>{t("group.field.members")}</TableTh>
|
||||
</TableTr>
|
||||
</TableThead>
|
||||
<TableTbody>
|
||||
{groups.map((group) => (
|
||||
<Row key={group.id} group={group} />
|
||||
))}
|
||||
</TableTbody>
|
||||
</Table>
|
||||
|
||||
<Group justify="end">
|
||||
<TablePagination total={Math.ceil(totalCount / searchParams.pageSize)} />
|
||||
</Group>
|
||||
{dbEveryoneGroup && (
|
||||
<UnstyledButton component={Link} href={`/manage/users/groups/${dbEveryoneGroup.id}`}>
|
||||
<Card className={classes.everyoneGroup}>
|
||||
<Group align="center">
|
||||
<ThemeIcon radius="xl" variant="light">
|
||||
<IconUsersGroup size={16} />
|
||||
</ThemeIcon>
|
||||
|
||||
<Stack gap={0} flex={1}>
|
||||
<Text fw={500}>{t("group.defaultGroup.name")}</Text>
|
||||
<Text size="sm" c="gray.6">
|
||||
{t("group.defaultGroup.description", { name: everyoneGroup })}
|
||||
</Text>
|
||||
</Stack>
|
||||
<IconChevronRight size={20} />
|
||||
</Group>
|
||||
</Card>
|
||||
</UnstyledButton>
|
||||
)}
|
||||
|
||||
<GroupsList groups={groupsWithoutEveryone} />
|
||||
</Stack>
|
||||
</ManageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
interface RowProps {
|
||||
group: RouterOutputs["group"]["getPaginated"]["items"][number];
|
||||
}
|
||||
|
||||
const Row = ({ group }: RowProps) => {
|
||||
return (
|
||||
<TableTr>
|
||||
<TableTd>
|
||||
<Anchor component={Link} href={`/manage/users/groups/${group.id}`}>
|
||||
{group.name}
|
||||
</Anchor>
|
||||
</TableTd>
|
||||
<TableTd>
|
||||
<UserAvatarGroup users={group.members} size="sm" limit={5} />
|
||||
</TableTd>
|
||||
</TableTr>
|
||||
);
|
||||
};
|
||||
@@ -9,6 +9,7 @@ import { ErrorBoundary } from "react-error-boundary";
|
||||
import type { IntegrationKind, WidgetKind } from "@homarr/definitions";
|
||||
import { useModalAction } from "@homarr/modals";
|
||||
import { showSuccessNotification } from "@homarr/notifications";
|
||||
import { useSettings } from "@homarr/settings";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
import type { BoardItemAdvancedOptions } from "@homarr/validation";
|
||||
import { loadWidgetDynamic, reduceWidgetOptionsWithDefaultValues, widgetImports } from "@homarr/widgets";
|
||||
@@ -29,6 +30,7 @@ interface WidgetPreviewPageContentProps {
|
||||
}
|
||||
|
||||
export const WidgetPreviewPageContent = ({ kind, integrationData }: WidgetPreviewPageContentProps) => {
|
||||
const settings = useSettings();
|
||||
const t = useScopedI18n("widgetPreview");
|
||||
const { openModal: openWidgetEditModal } = useModalAction(WidgetEditModal);
|
||||
const { openModal: openPreviewDimensionsModal } = useModalAction(PreviewDimensionsModal);
|
||||
@@ -43,7 +45,7 @@ export const WidgetPreviewPageContent = ({ kind, integrationData }: WidgetPrevie
|
||||
integrationIds: string[];
|
||||
advancedOptions: BoardItemAdvancedOptions;
|
||||
}>({
|
||||
options: reduceWidgetOptionsWithDefaultValues(kind, {}),
|
||||
options: reduceWidgetOptionsWithDefaultValues(kind, settings, {}),
|
||||
integrationIds: [],
|
||||
advancedOptions: {
|
||||
customCssClasses: [],
|
||||
@@ -63,8 +65,9 @@ export const WidgetPreviewPageContent = ({ kind, integrationData }: WidgetPrevie
|
||||
(currentDefinition.supportedIntegrations as string[]).some((kind) => kind === integration.kind),
|
||||
),
|
||||
integrationSupport: "supportedIntegrations" in currentDefinition,
|
||||
settings,
|
||||
});
|
||||
}, [currentDefinition, integrationData, kind, openWidgetEditModal, state]);
|
||||
}, [currentDefinition, integrationData, kind, openWidgetEditModal, settings, state]);
|
||||
|
||||
const Comp = loadWidgetDynamic(kind);
|
||||
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { notFound } from "next/navigation";
|
||||
import { Center } from "@mantine/core";
|
||||
|
||||
import { env } from "@homarr/common/env";
|
||||
import { db } from "@homarr/db";
|
||||
import type { WidgetKind } from "@homarr/definitions";
|
||||
import { widgetImports } from "@homarr/widgets";
|
||||
|
||||
import { env } from "~/env";
|
||||
import { WidgetPreviewPageContent } from "./_content";
|
||||
|
||||
interface Props {
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { Group, Text } from "@mantine/core";
|
||||
import { IconLayoutDashboard } from "@tabler/icons-react";
|
||||
|
||||
import type { SelectWithCustomItemsProps } from "@homarr/ui";
|
||||
import { SelectWithCustomItems } from "@homarr/ui";
|
||||
|
||||
import type { Board } from "~/app/[locale]/boards/_types";
|
||||
|
||||
interface BoardSelectProps extends Omit<SelectWithCustomItemsProps<{ value: string; label: string }>, "data"> {
|
||||
boards: Pick<Board, "id" | "name" | "logoImageUrl">[];
|
||||
}
|
||||
|
||||
export const BoardSelect = ({ boards, ...props }: BoardSelectProps) => {
|
||||
return (
|
||||
<SelectWithCustomItems
|
||||
{...props}
|
||||
data={boards.map((board) => ({
|
||||
value: board.id,
|
||||
label: board.name,
|
||||
image: board.logoImageUrl,
|
||||
}))}
|
||||
SelectOption={({ label, image }: { value: string; label: string; image: string | null }) => (
|
||||
<Group>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
{image ? <img width={16} height={16} src={image} alt={label} /> : <IconLayoutDashboard size={16} />}
|
||||
<Text fz="sm" fw={500}>
|
||||
{label}
|
||||
</Text>
|
||||
</Group>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -1,9 +1,9 @@
|
||||
import { useCallback } from "react";
|
||||
|
||||
import { useUpdateBoard } from "@homarr/boards/updater";
|
||||
import type { BoardItemAdvancedOptions } from "@homarr/validation";
|
||||
|
||||
import type { Item } from "~/app/[locale]/boards/_types";
|
||||
import { useUpdateBoard } from "~/app/[locale]/boards/(content)/_client";
|
||||
import type { CreateItemInput } from "./actions/create-item";
|
||||
import { createItemCallback } from "./actions/create-item";
|
||||
import type { DuplicateItemInput } from "./actions/duplicate-item";
|
||||
|
||||
@@ -5,11 +5,13 @@ import combineClasses from "clsx";
|
||||
import { NoIntegrationSelectedError } from "node_modules/@homarr/widgets/src/errors";
|
||||
import { ErrorBoundary } from "react-error-boundary";
|
||||
|
||||
import { useRequiredBoard } from "@homarr/boards/context";
|
||||
import { useEditMode } from "@homarr/boards/edit-mode";
|
||||
import { useSettings } from "@homarr/settings";
|
||||
import { loadWidgetDynamic, reduceWidgetOptionsWithDefaultValues, widgetImports } from "@homarr/widgets";
|
||||
import { WidgetError } from "@homarr/widgets/errors";
|
||||
|
||||
import type { Item } from "~/app/[locale]/boards/_types";
|
||||
import { useEditMode, useRequiredBoard } from "~/app/[locale]/boards/(content)/_context";
|
||||
import classes from "../sections/item.module.css";
|
||||
import { useItemActions } from "./item-actions";
|
||||
import { BoardItemMenu } from "./item-menu";
|
||||
@@ -31,6 +33,7 @@ export const BoardItemContent = ({ item }: BoardItemContentProps) => {
|
||||
"grid-stack-item-content",
|
||||
item.advancedOptions.customCssClasses.join(" "),
|
||||
)}
|
||||
radius={board.itemRadius}
|
||||
withBorder
|
||||
styles={{
|
||||
root: {
|
||||
@@ -53,11 +56,12 @@ interface InnerContentProps {
|
||||
}
|
||||
|
||||
const InnerContent = ({ item, ...dimensions }: InnerContentProps) => {
|
||||
const settings = useSettings();
|
||||
const board = useRequiredBoard();
|
||||
const [isEditMode] = useEditMode();
|
||||
const Comp = loadWidgetDynamic(item.kind);
|
||||
const { definition } = widgetImports[item.kind];
|
||||
const options = reduceWidgetOptionsWithDefaultValues(item.kind, item.options);
|
||||
const options = reduceWidgetOptionsWithDefaultValues(item.kind, settings, item.options);
|
||||
const newItem = { ...item, options };
|
||||
const { updateItemOptions } = useItemActions();
|
||||
const updateOptions = ({ newOptions }: { newOptions: Record<string, unknown> }) =>
|
||||
|
||||
@@ -3,13 +3,14 @@ import { ActionIcon, Menu } from "@mantine/core";
|
||||
import { IconCopy, IconDotsVertical, IconLayoutKanban, IconPencil, IconTrash } from "@tabler/icons-react";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { useEditMode } from "@homarr/boards/edit-mode";
|
||||
import { useConfirmModal, useModalAction } from "@homarr/modals";
|
||||
import { useSettings } from "@homarr/settings";
|
||||
import { useI18n, useScopedI18n } from "@homarr/translation/client";
|
||||
import { widgetImports } from "@homarr/widgets";
|
||||
import { WidgetEditModal } from "@homarr/widgets/modals";
|
||||
|
||||
import type { Item } from "~/app/[locale]/boards/_types";
|
||||
import { useEditMode } from "~/app/[locale]/boards/(content)/_context";
|
||||
import { useSectionContext } from "../sections/section-context";
|
||||
import { useItemActions } from "./item-actions";
|
||||
import { ItemMoveModal } from "./item-move-modal";
|
||||
@@ -35,6 +36,7 @@ export const BoardItemMenu = ({
|
||||
const { data: integrationData, isPending } = clientApi.integration.all.useQuery();
|
||||
const currentDefinition = useMemo(() => widgetImports[item.kind].definition, [item.kind]);
|
||||
const { gridstack } = useSectionContext().refs;
|
||||
const settings = useSettings();
|
||||
|
||||
// Reset error boundary on next render if item has been edited
|
||||
useEffect(() => {
|
||||
@@ -75,6 +77,7 @@ export const BoardItemMenu = ({
|
||||
(currentDefinition.supportedIntegrations as string[]).some((kind) => kind === integration.kind),
|
||||
),
|
||||
integrationSupport: "supportedIntegrations" in currentDefinition,
|
||||
settings,
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -3,9 +3,9 @@ import { useDisclosure } from "@mantine/hooks";
|
||||
import { IconChevronDown, IconChevronUp } from "@tabler/icons-react";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { useRequiredBoard } from "@homarr/boards/context";
|
||||
|
||||
import type { CategorySection } from "~/app/[locale]/boards/_types";
|
||||
import { useRequiredBoard } from "~/app/[locale]/boards/(content)/_context";
|
||||
import { CategoryMenu } from "./category/category-menu";
|
||||
import { GridStack } from "./gridstack/gridstack";
|
||||
import classes from "./item.module.css";
|
||||
@@ -27,7 +27,13 @@ export const BoardCategorySection = ({ section }: Props) => {
|
||||
});
|
||||
|
||||
return (
|
||||
<Card style={{ "--opacity": board.opacity / 100 }} withBorder p={0} className={classes.itemCard}>
|
||||
<Card
|
||||
style={{ "--opacity": board.opacity / 100 }}
|
||||
radius={board.itemRadius}
|
||||
withBorder
|
||||
p={0}
|
||||
className={classes.itemCard}
|
||||
>
|
||||
<Stack>
|
||||
<Group wrap="nowrap" gap="sm">
|
||||
<UnstyledButton w="100%" p="sm" onClick={toggle}>
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { useCallback } from "react";
|
||||
|
||||
import { useUpdateBoard } from "@homarr/boards/updater";
|
||||
import { createId } from "@homarr/db/client";
|
||||
|
||||
import type { CategorySection, EmptySection } from "~/app/[locale]/boards/_types";
|
||||
import { useUpdateBoard } from "~/app/[locale]/boards/(content)/_client";
|
||||
import type { MoveCategoryInput } from "./actions/move-category";
|
||||
import { moveCategoryCallback } from "./actions/move-category";
|
||||
import type { RemoveCategoryInput } from "./actions/remove-category";
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
import { useCallback } from "react";
|
||||
|
||||
import { fetchApi } from "@homarr/api/client";
|
||||
import { createId } from "@homarr/db/client";
|
||||
import { useConfirmModal, useModalAction } from "@homarr/modals";
|
||||
import { useSettings } from "@homarr/settings";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
|
||||
import type { CategorySection } from "~/app/[locale]/boards/_types";
|
||||
import { useCategoryActions } from "./category-actions";
|
||||
import { CategoryEditModal } from "./category-edit-modal";
|
||||
import { filterByItemKind } from "./filter";
|
||||
|
||||
export const useCategoryMenuActions = (category: CategorySection) => {
|
||||
const { openModal } = useModalAction(CategoryEditModal);
|
||||
@@ -97,6 +100,29 @@ export const useCategoryMenuActions = (category: CategorySection) => {
|
||||
);
|
||||
}, [category, openModal, renameCategory, t]);
|
||||
|
||||
const settings = useSettings();
|
||||
const openAllInNewTabs = useCallback(async () => {
|
||||
const appIds = filterByItemKind(category.items, settings, "app").map((item) => {
|
||||
return item.options.appId;
|
||||
});
|
||||
|
||||
const apps = await fetchApi.app.byIds.query(appIds);
|
||||
const appsWithUrls = apps.filter((app) => app.href && app.href.length > 0);
|
||||
|
||||
for (const app of appsWithUrls) {
|
||||
const openedWindow = window.open(app.href ?? undefined);
|
||||
if (openedWindow) {
|
||||
continue;
|
||||
}
|
||||
|
||||
openConfirmModal({
|
||||
title: t("section.category.openAllInNewTabs.title"),
|
||||
children: t("section.category.openAllInNewTabs.text"),
|
||||
});
|
||||
break;
|
||||
}
|
||||
}, [category, t, openConfirmModal, settings]);
|
||||
|
||||
return {
|
||||
addCategoryAbove,
|
||||
addCategoryBelow,
|
||||
@@ -104,5 +130,6 @@ export const useCategoryMenuActions = (category: CategorySection) => {
|
||||
moveCategoryDown,
|
||||
remove,
|
||||
edit,
|
||||
openAllInNewTabs,
|
||||
};
|
||||
};
|
||||
@@ -5,6 +5,7 @@ import { ActionIcon, Menu } from "@mantine/core";
|
||||
import {
|
||||
IconDotsVertical,
|
||||
IconEdit,
|
||||
IconExternalLink,
|
||||
IconRowInsertBottom,
|
||||
IconRowInsertTop,
|
||||
IconTransitionBottom,
|
||||
@@ -12,11 +13,12 @@ import {
|
||||
IconTrash,
|
||||
} from "@tabler/icons-react";
|
||||
|
||||
import { useEditMode } from "@homarr/boards/edit-mode";
|
||||
import type { MaybePromise } from "@homarr/common/types";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
import type { TablerIcon } from "@homarr/ui";
|
||||
|
||||
import type { CategorySection } from "~/app/[locale]/boards/_types";
|
||||
import { useEditMode } from "~/app/[locale]/boards/(content)/_context";
|
||||
import { useCategoryMenuActions } from "./category-menu-actions";
|
||||
|
||||
interface Props {
|
||||
@@ -27,8 +29,6 @@ export const CategoryMenu = ({ category }: Props) => {
|
||||
const actions = useActions(category);
|
||||
const t = useScopedI18n("section.category");
|
||||
|
||||
if (actions.length === 0) return null;
|
||||
|
||||
return (
|
||||
<Menu withArrow>
|
||||
<Menu.Target>
|
||||
@@ -37,18 +37,20 @@ export const CategoryMenu = ({ category }: Props) => {
|
||||
</ActionIcon>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown>
|
||||
{actions.map((action) => (
|
||||
<React.Fragment key={action.label}>
|
||||
{"group" in action && <Menu.Label>{t(action.group)}</Menu.Label>}
|
||||
<Menu.Item
|
||||
leftSection={<action.icon size="1rem" />}
|
||||
onClick={action.onClick}
|
||||
color={"color" in action ? action.color : undefined}
|
||||
>
|
||||
{t(action.label)}
|
||||
</Menu.Item>
|
||||
</React.Fragment>
|
||||
))}
|
||||
{actions.map((action) => {
|
||||
return (
|
||||
<React.Fragment key={action.label}>
|
||||
{"group" in action && <Menu.Label>{t(action.group)}</Menu.Label>}
|
||||
<Menu.Item
|
||||
leftSection={<action.icon size="1rem" />}
|
||||
onClick={action.onClick}
|
||||
color={"color" in action ? action.color : undefined}
|
||||
>
|
||||
{t(action.label)}
|
||||
</Menu.Item>
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
);
|
||||
@@ -106,15 +108,21 @@ const useEditModeActions = (category: CategorySection) => {
|
||||
] as const satisfies ActionDefinition[];
|
||||
};
|
||||
|
||||
// TODO: once apps are added we can use this for the open many apps action
|
||||
const useNonEditModeActions = (_category: CategorySection) => {
|
||||
return [] as const satisfies ActionDefinition[];
|
||||
const useNonEditModeActions = (category: CategorySection) => {
|
||||
const { openAllInNewTabs } = useCategoryMenuActions(category);
|
||||
return [
|
||||
{
|
||||
icon: IconExternalLink,
|
||||
label: "action.openAllInNewTabs",
|
||||
onClick: openAllInNewTabs,
|
||||
},
|
||||
] as const satisfies ActionDefinition[];
|
||||
};
|
||||
|
||||
interface ActionDefinition {
|
||||
icon: TablerIcon;
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
onClick: () => MaybePromise<void>;
|
||||
color?: string;
|
||||
group?: string;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import type { WidgetKind } from "@homarr/definitions";
|
||||
import type { SettingsContextProps } from "@homarr/settings";
|
||||
import type { WidgetComponentProps } from "@homarr/widgets";
|
||||
import { reduceWidgetOptionsWithDefaultValues } from "@homarr/widgets";
|
||||
|
||||
import type { Item } from "~/app/[locale]/boards/_types";
|
||||
|
||||
export const filterByItemKind = <TKind extends WidgetKind>(
|
||||
items: Item[],
|
||||
settings: SettingsContextProps,
|
||||
kind: TKind,
|
||||
) => {
|
||||
return items
|
||||
.filter((item) => item.kind === kind)
|
||||
.map((item) => ({
|
||||
...item,
|
||||
options: reduceWidgetOptionsWithDefaultValues(
|
||||
kind,
|
||||
settings,
|
||||
item.options,
|
||||
) as WidgetComponentProps<TKind>["options"],
|
||||
}));
|
||||
};
|
||||
@@ -1,7 +1,8 @@
|
||||
import { useMemo } from "react";
|
||||
|
||||
import { useRequiredBoard } from "@homarr/boards/context";
|
||||
|
||||
import type { DynamicSection, Item, Section } from "~/app/[locale]/boards/_types";
|
||||
import { useRequiredBoard } from "~/app/[locale]/boards/(content)/_context";
|
||||
import { BoardItemContent } from "../items/item-content";
|
||||
import { BoardDynamicSection } from "./dynamic-section";
|
||||
import { GridStackItem } from "./gridstack/gridstack-item";
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { Box, Card } from "@mantine/core";
|
||||
|
||||
import { useRequiredBoard } from "@homarr/boards/context";
|
||||
|
||||
import type { DynamicSection } from "~/app/[locale]/boards/_types";
|
||||
import { useRequiredBoard } from "~/app/[locale]/boards/(content)/_context";
|
||||
import { BoardDynamicSectionMenu } from "./dynamic/dynamic-menu";
|
||||
import { GridStack } from "./gridstack/gridstack";
|
||||
import classes from "./item.module.css";
|
||||
@@ -25,6 +26,7 @@ export const BoardDynamicSection = ({ section }: Props) => {
|
||||
overflow: "hidden",
|
||||
},
|
||||
}}
|
||||
radius={board.itemRadius}
|
||||
p={0}
|
||||
>
|
||||
<GridStack section={section} className="min-row" />
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { useCallback } from "react";
|
||||
|
||||
import { useUpdateBoard } from "@homarr/boards/updater";
|
||||
import { createId } from "@homarr/db/client";
|
||||
|
||||
import type { DynamicSection, EmptySection } from "~/app/[locale]/boards/_types";
|
||||
import { useUpdateBoard } from "~/app/[locale]/boards/(content)/_client";
|
||||
|
||||
interface RemoveDynamicSection {
|
||||
id: string;
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { ActionIcon, Menu } from "@mantine/core";
|
||||
import { IconDotsVertical, IconTrash } from "@tabler/icons-react";
|
||||
|
||||
import { useEditMode } from "@homarr/boards/edit-mode";
|
||||
import { useConfirmModal } from "@homarr/modals";
|
||||
import { useI18n, useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import type { DynamicSection } from "~/app/[locale]/boards/_types";
|
||||
import { useEditMode } from "~/app/[locale]/boards/(content)/_context";
|
||||
import { useDynamicSectionActions } from "./dynamic-actions";
|
||||
|
||||
export const BoardDynamicSectionMenu = ({ section }: { section: DynamicSection }) => {
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import combineClasses from "clsx";
|
||||
|
||||
import { useEditMode } from "@homarr/boards/edit-mode";
|
||||
|
||||
import type { EmptySection } from "~/app/[locale]/boards/_types";
|
||||
import { useEditMode } from "~/app/[locale]/boards/(content)/_context";
|
||||
import { GridStack } from "./gridstack/gridstack";
|
||||
import { useSectionItems } from "./use-section-items";
|
||||
|
||||
|
||||
@@ -2,10 +2,12 @@ import type { RefObject } from "react";
|
||||
import { createRef, useCallback, useEffect, useRef } from "react";
|
||||
import { useElementSize } from "@mantine/hooks";
|
||||
|
||||
import { useRequiredBoard } from "@homarr/boards/context";
|
||||
import { useEditMode } from "@homarr/boards/edit-mode";
|
||||
import type { GridHTMLElement, GridItemHTMLElement, GridStack, GridStackNode } from "@homarr/gridstack";
|
||||
|
||||
import type { Section } from "~/app/[locale]/boards/_types";
|
||||
import { useEditMode, useMarkSectionAsReady, useRequiredBoard } from "~/app/[locale]/boards/(content)/_context";
|
||||
import { useMarkSectionAsReady } from "~/app/[locale]/boards/(content)/_ready-context";
|
||||
import { useItemActions } from "../../items/item-actions";
|
||||
import { useSectionActions } from "../section-actions";
|
||||
import { initializeGridstack } from "./init-gridstack";
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useCallback } from "react";
|
||||
|
||||
import { useUpdateBoard } from "~/app/[locale]/boards/(content)/_client";
|
||||
import { useUpdateBoard } from "@homarr/boards/updater";
|
||||
|
||||
interface MoveAndResizeInnerSection {
|
||||
innerSectionId: string;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useRequiredBoard } from "@homarr/boards/context";
|
||||
|
||||
import type { Section } from "~/app/[locale]/boards/_types";
|
||||
import { useRequiredBoard } from "~/app/[locale]/boards/(content)/_context";
|
||||
|
||||
export const useSectionItems = (section: Section) => {
|
||||
const board = useRequiredBoard();
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { usePathname } from "next/navigation";
|
||||
import type { AppShellProps } from "@mantine/core";
|
||||
|
||||
import { useOptionalBoard } from "~/app/[locale]/boards/(content)/_context";
|
||||
import { useOptionalBoard } from "@homarr/boards/context";
|
||||
|
||||
const supportedVideoFormats = ["mp4", "webm", "ogg"];
|
||||
const isVideo = (url: string) => supportedVideoFormats.some((format) => url.toLowerCase().endsWith(`.${format}`));
|
||||
|
||||
@@ -20,6 +20,7 @@ export const DesktopSearchInput = () => {
|
||||
size="sm"
|
||||
leftSection={<IconSearch size={20} stroke={1.5} />}
|
||||
onClick={openSpotlight}
|
||||
radius="xl"
|
||||
>
|
||||
{`${t("search.placeholder")}...`}
|
||||
</TextInput>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useRequiredBoard } from "~/app/[locale]/boards/(content)/_context";
|
||||
import { useRequiredBoard } from "@homarr/boards/context";
|
||||
|
||||
import { homarrLogoPath, homarrPageTitle } from "./homarr-logo";
|
||||
import type { LogoWithTitleProps } from "./logo";
|
||||
import { Logo, LogoWithTitle } from "./logo";
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { JSX } from "react";
|
||||
import { AppShellNavbar, AppShellSection, ScrollArea } from "@mantine/core";
|
||||
import { AppShellNavbar, AppShellSection, Image, ScrollArea } from "@mantine/core";
|
||||
|
||||
import type { TablerIcon } from "@homarr/ui";
|
||||
import type { TablerIcon, TablerIconProps } from "@homarr/ui";
|
||||
|
||||
import type { ClientNavigationLink } from "./navigation-link";
|
||||
import { CommonNavLink } from "./navigation-link";
|
||||
@@ -27,8 +27,13 @@ export const MainNavigation = ({ headerSection, footerSection, links }: MainNavi
|
||||
return null;
|
||||
}
|
||||
|
||||
const { icon: TablerIcon, ...props } = link;
|
||||
const Icon = <TablerIcon size={20} stroke={1.5} />;
|
||||
const { icon: TablerIcon, iconProps, ...props } = link;
|
||||
const Icon =
|
||||
typeof TablerIcon === "string" ? (
|
||||
<Image src={TablerIcon} w={20} h={20} />
|
||||
) : (
|
||||
<TablerIcon size={20} stroke={1.5} {...iconProps} />
|
||||
);
|
||||
let clientLink: ClientNavigationLink;
|
||||
if ("items" in props) {
|
||||
clientLink = {
|
||||
@@ -38,7 +43,7 @@ export const MainNavigation = ({ headerSection, footerSection, links }: MainNavi
|
||||
.map((item) => {
|
||||
return {
|
||||
...item,
|
||||
icon: <item.icon size={20} stroke={1.5} />,
|
||||
icon: <item.icon size={20} stroke={1.5} {...iconProps} />,
|
||||
};
|
||||
}),
|
||||
} as ClientNavigationLink;
|
||||
@@ -55,7 +60,8 @@ export const MainNavigation = ({ headerSection, footerSection, links }: MainNavi
|
||||
|
||||
interface CommonNavigationLinkProps {
|
||||
label: string;
|
||||
icon: TablerIcon;
|
||||
icon: TablerIcon | string;
|
||||
iconProps?: TablerIconProps;
|
||||
hidden?: boolean;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
import { createEnv } from "@t3-oss/env-nextjs";
|
||||
import { z } from "zod";
|
||||
|
||||
import { shouldSkipEnvValidation } from "@homarr/common/env-validation";
|
||||
|
||||
export const env = createEnv({
|
||||
shared: {
|
||||
PORT: z.coerce.number().default(3000),
|
||||
NODE_ENV: z.enum(["development", "production", "test"]).default("development"),
|
||||
},
|
||||
/**
|
||||
* Specify your server-side environment variables schema here. This way you can ensure the app isn't
|
||||
* built with invalid env vars.
|
||||
*/
|
||||
server: {},
|
||||
/**
|
||||
* Specify your client-side environment variables schema here.
|
||||
* For them to be exposed to the client, prefix them with `NEXT_PUBLIC_`.
|
||||
*/
|
||||
client: {
|
||||
// NEXT_PUBLIC_CLIENTVAR: z.string(),
|
||||
},
|
||||
/**
|
||||
* Destructure all variables from `process.env` to make sure they aren't tree-shaken away.
|
||||
*/
|
||||
runtimeEnv: {
|
||||
PORT: process.env.PORT,
|
||||
NODE_ENV: process.env.NODE_ENV,
|
||||
// NEXT_PUBLIC_CLIENTVAR: process.env.NEXT_PUBLIC_CLIENTVAR,
|
||||
},
|
||||
skipValidation: shouldSkipEnvValidation(),
|
||||
emptyStringAsUndefined: true,
|
||||
});
|
||||
@@ -44,11 +44,11 @@
|
||||
"@homarr/eslint-config": "workspace:^0.2.0",
|
||||
"@homarr/prettier-config": "workspace:^0.1.0",
|
||||
"@homarr/tsconfig": "workspace:^0.1.0",
|
||||
"@types/node": "^22.12.0",
|
||||
"@types/node": "^22.13.5",
|
||||
"dotenv-cli": "^8.0.0",
|
||||
"eslint": "^9.19.0",
|
||||
"prettier": "^3.4.2",
|
||||
"tsx": "4.19.2",
|
||||
"eslint": "^9.21.0",
|
||||
"prettier": "^3.5.2",
|
||||
"tsx": "4.19.3",
|
||||
"typescript": "^5.7.3"
|
||||
}
|
||||
}
|
||||
@@ -26,16 +26,16 @@
|
||||
"@homarr/redis": "workspace:^0.1.0",
|
||||
"@homarr/validation": "workspace:^0.1.0",
|
||||
"dotenv": "^16.4.7",
|
||||
"tsx": "4.19.2",
|
||||
"ws": "^8.18.0"
|
||||
"tsx": "4.19.3",
|
||||
"ws": "^8.18.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@homarr/eslint-config": "workspace:^0.2.0",
|
||||
"@homarr/prettier-config": "workspace:^0.1.0",
|
||||
"@homarr/tsconfig": "workspace:^0.1.0",
|
||||
"@types/ws": "^8.5.14",
|
||||
"eslint": "^9.19.0",
|
||||
"prettier": "^3.4.2",
|
||||
"eslint": "^9.21.0",
|
||||
"prettier": "^3.5.2",
|
||||
"typescript": "^5.7.3"
|
||||
}
|
||||
}
|
||||
+5
-5
@@ -1,5 +1,5 @@
|
||||
<!-- Project Title -->
|
||||

|
||||
[](https://homarr.dev/)
|
||||
|
||||
<!-- Badges -->
|
||||
<p align="center">
|
||||
@@ -36,7 +36,7 @@
|
||||
</p>
|
||||
|
||||
|
||||

|
||||
[](https://homarr.dev/)
|
||||
|
||||
- 🖌️ Highly customizable with an extensive drag and drop grid system
|
||||
- ✨ Integrates seamlessly with your favorite self-hosted applications
|
||||
@@ -53,7 +53,7 @@
|
||||
<br/>
|
||||
<br/>
|
||||
|
||||

|
||||
[](https://homarr.dev/docs/category/widgets)
|
||||
|
||||
Homarr has a [built-in collection of widgets and integrations](https://homarr.dev/docs/category/integrations), that connect to your applications and enable you to control them directly from the dashboard.
|
||||
|
||||
@@ -88,7 +88,7 @@ Homarr has a [built-in collection of widgets and integrations](https://homarr.de
|
||||
<br/>
|
||||
<br/>
|
||||
|
||||

|
||||
[](https://homarr.dev/docs/category/installation-1)
|
||||
|
||||
Since we are updating Homarr very frequently, we recommend reading our official installation guides:
|
||||
|
||||
@@ -101,7 +101,7 @@ Since we are updating Homarr very frequently, we recommend reading our official
|
||||
<br/>
|
||||
<br/>
|
||||
|
||||

|
||||
[](https://opencollective.com/homarr)
|
||||
|
||||
<br/>
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@ export class OnboardingActions {
|
||||
await this.db.insert(sqliteSchema.groups).values({
|
||||
id: createId(),
|
||||
name: input.group,
|
||||
position: 1,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
+28
-12
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "homarr",
|
||||
"version": "1.4.0",
|
||||
"version": "1.7.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build": "cross-env CI=true turbo build",
|
||||
@@ -38,32 +38,48 @@
|
||||
"@semantic-release/github": "^11.0.1",
|
||||
"@semantic-release/npm": "^12.0.1",
|
||||
"@semantic-release/release-notes-generator": "^14.0.3",
|
||||
"@turbo/gen": "^2.3.4",
|
||||
"@turbo/gen": "^2.4.2",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"@vitest/coverage-v8": "^3.0.4",
|
||||
"@vitest/ui": "^3.0.4",
|
||||
"@vitest/coverage-v8": "^3.0.6",
|
||||
"@vitest/ui": "^3.0.6",
|
||||
"conventional-changelog-conventionalcommits": "^8.0.0",
|
||||
"cross-env": "^7.0.3",
|
||||
"jsdom": "^26.0.0",
|
||||
"prettier": "^3.4.2",
|
||||
"semantic-release": "^24.2.1",
|
||||
"testcontainers": "^10.17.2",
|
||||
"turbo": "^2.3.4",
|
||||
"prettier": "^3.5.2",
|
||||
"semantic-release": "^24.2.3",
|
||||
"testcontainers": "^10.18.0",
|
||||
"turbo": "^2.4.2",
|
||||
"typescript": "^5.7.3",
|
||||
"vite-tsconfig-paths": "^5.1.4",
|
||||
"vitest": "^3.0.4"
|
||||
"vitest": "^3.0.6"
|
||||
},
|
||||
"packageManager": "pnpm@9.15.4",
|
||||
"packageManager": "pnpm@10.4.1",
|
||||
"engines": {
|
||||
"node": ">=22.13.1"
|
||||
"node": ">=22.14.0"
|
||||
},
|
||||
"pnpm": {
|
||||
"onlyBuiltDependencies": [
|
||||
"@tree-sitter-grammars/tree-sitter-yaml",
|
||||
"bcrypt",
|
||||
"better-sqlite3",
|
||||
"cpu-features",
|
||||
"esbuild",
|
||||
"sharp",
|
||||
"ssh2",
|
||||
"tree-sitter",
|
||||
"tree-sitter-json"
|
||||
],
|
||||
"allowNonAppliedPatches": true,
|
||||
"overrides": {
|
||||
"proxmox-api>undici": "7.3.0"
|
||||
},
|
||||
"patchedDependencies": {
|
||||
"pretty-print-error": "patches/pretty-print-error.patch"
|
||||
}
|
||||
},
|
||||
"ignoredBuiltDependencies": [
|
||||
"@scarf/scarf",
|
||||
"core-js-pure",
|
||||
"protobufjs"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -32,7 +32,7 @@
|
||||
"@homarr/eslint-config": "workspace:^0.2.0",
|
||||
"@homarr/prettier-config": "workspace:^0.1.0",
|
||||
"@homarr/tsconfig": "workspace:^0.1.0",
|
||||
"eslint": "^9.19.0",
|
||||
"eslint": "^9.21.0",
|
||||
"typescript": "^5.7.3"
|
||||
}
|
||||
}
|
||||
@@ -44,20 +44,20 @@
|
||||
"@trpc/react-query": "next",
|
||||
"@trpc/server": "next",
|
||||
"lodash.clonedeep": "^4.5.0",
|
||||
"next": "15.1.6",
|
||||
"next": "15.1.7",
|
||||
"pretty-print-error": "^1.1.2",
|
||||
"react": "19.0.0",
|
||||
"react-dom": "19.0.0",
|
||||
"superjson": "2.2.2",
|
||||
"trpc-to-openapi": "^2.1.2",
|
||||
"zod": "^3.24.1"
|
||||
"trpc-to-openapi": "^2.1.3",
|
||||
"zod": "^3.24.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@homarr/eslint-config": "workspace:^0.2.0",
|
||||
"@homarr/prettier-config": "workspace:^0.1.0",
|
||||
"@homarr/tsconfig": "workspace:^0.1.0",
|
||||
"eslint": "^9.19.0",
|
||||
"prettier": "^3.4.2",
|
||||
"eslint": "^9.21.0",
|
||||
"prettier": "^3.5.2",
|
||||
"typescript": "^5.7.3"
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import { createSaltAsync, hashPasswordAsync } from "@homarr/auth";
|
||||
import { generateSecureRandomToken } from "@homarr/common/server";
|
||||
import { createId, db } from "@homarr/db";
|
||||
import { createId, db, eq } from "@homarr/db";
|
||||
import { apiKeys } from "@homarr/db/schema";
|
||||
|
||||
import { createTRPCRouter, permissionRequiredProcedure } from "../trpc";
|
||||
@@ -39,4 +41,10 @@ export const apiKeysRouter = createTRPCRouter({
|
||||
apiKey: `${id}.${randomToken}`,
|
||||
};
|
||||
}),
|
||||
delete: permissionRequiredProcedure
|
||||
.requiresPermission("admin")
|
||||
.input(z.object({ apiKeyId: z.string() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
await ctx.db.delete(apiKeys).where(eq(apiKeys.id, input.apiKeyId)).limit(1);
|
||||
}),
|
||||
});
|
||||
@@ -56,7 +56,11 @@ export const appRouter = createTRPCRouter({
|
||||
}),
|
||||
selectable: protectedProcedure
|
||||
.input(z.void())
|
||||
.output(z.array(selectAppSchema.pick({ id: true, name: true, iconUrl: true, href: true, description: true })))
|
||||
.output(
|
||||
z.array(
|
||||
selectAppSchema.pick({ id: true, name: true, iconUrl: true, href: true, pingUrl: true, description: true }),
|
||||
),
|
||||
)
|
||||
.meta({
|
||||
openapi: {
|
||||
method: "GET",
|
||||
@@ -73,6 +77,7 @@ export const appRouter = createTRPCRouter({
|
||||
iconUrl: true,
|
||||
description: true,
|
||||
href: true,
|
||||
pingUrl: true,
|
||||
},
|
||||
orderBy: asc(apps.name),
|
||||
});
|
||||
@@ -111,16 +116,20 @@ export const appRouter = createTRPCRouter({
|
||||
create: permissionRequiredProcedure
|
||||
.requiresPermission("app-create")
|
||||
.input(validation.app.manage)
|
||||
.output(z.void())
|
||||
.output(z.object({ appId: z.string() }))
|
||||
.meta({ openapi: { method: "POST", path: "/api/apps", tags: ["apps"], protect: true } })
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const id = createId();
|
||||
await ctx.db.insert(apps).values({
|
||||
id: createId(),
|
||||
id,
|
||||
name: input.name,
|
||||
description: input.description,
|
||||
iconUrl: input.iconUrl,
|
||||
href: input.href,
|
||||
pingUrl: input.pingUrl === "" ? null : input.pingUrl,
|
||||
});
|
||||
|
||||
return { appId: id };
|
||||
}),
|
||||
createMany: permissionRequiredProcedure
|
||||
.requiresPermission("app-create")
|
||||
@@ -161,6 +170,7 @@ export const appRouter = createTRPCRouter({
|
||||
description: input.description,
|
||||
iconUrl: input.iconUrl,
|
||||
href: input.href,
|
||||
pingUrl: input.pingUrl === "" ? null : input.pingUrl,
|
||||
})
|
||||
.where(eq(apps.id, input.id));
|
||||
}),
|
||||
|
||||
@@ -5,7 +5,7 @@ import { z } from "zod";
|
||||
import { constructBoardPermissions } from "@homarr/auth/shared";
|
||||
import type { DeviceType } from "@homarr/common/server";
|
||||
import type { Database, InferInsertModel, InferSelectModel, SQL } from "@homarr/db";
|
||||
import { and, createId, eq, handleTransactionsAsync, inArray, like, or } from "@homarr/db";
|
||||
import { and, asc, createId, eq, handleTransactionsAsync, inArray, isNull, like, not, or } from "@homarr/db";
|
||||
import { getServerSettingByKeyAsync } from "@homarr/db/queries";
|
||||
import {
|
||||
boardGroupPermissions,
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
boardUserPermissions,
|
||||
groupMembers,
|
||||
groupPermissions,
|
||||
groups,
|
||||
integrationGroupPermissions,
|
||||
integrationItems,
|
||||
integrationUserPermissions,
|
||||
@@ -22,7 +23,7 @@ import {
|
||||
users,
|
||||
} from "@homarr/db/schema";
|
||||
import type { WidgetKind } from "@homarr/definitions";
|
||||
import { getPermissionsWithParents, widgetKinds } from "@homarr/definitions";
|
||||
import { everyoneGroup, getPermissionsWithChildren, getPermissionsWithParents, widgetKinds } from "@homarr/definitions";
|
||||
import { importOldmarrAsync } from "@homarr/old-import";
|
||||
import { importJsonFileSchema } from "@homarr/old-import/shared";
|
||||
import { oldmarrConfigSchema } from "@homarr/old-schema";
|
||||
@@ -57,6 +58,37 @@ export const boardRouter = createTRPCRouter({
|
||||
where: eq(boards.isPublic, true),
|
||||
});
|
||||
}),
|
||||
getBoardsForGroup: permissionRequiredProcedure
|
||||
.requiresPermission("admin")
|
||||
.input(z.object({ groupId: z.string() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
const dbEveryoneAndCurrentGroup = await ctx.db.query.groups.findMany({
|
||||
where: or(eq(groups.name, everyoneGroup), eq(groups.id, input.groupId)),
|
||||
with: {
|
||||
boardPermissions: true,
|
||||
permissions: true,
|
||||
},
|
||||
});
|
||||
|
||||
const distinctPermissions = new Set(
|
||||
dbEveryoneAndCurrentGroup.flatMap((group) => group.permissions.map(({ permission }) => permission)),
|
||||
);
|
||||
const canViewAllBoards = getPermissionsWithChildren([...distinctPermissions]).includes("board-view-all");
|
||||
|
||||
const boardIds = dbEveryoneAndCurrentGroup.flatMap((group) =>
|
||||
group.boardPermissions.map(({ boardId }) => boardId),
|
||||
);
|
||||
const boardWhere = canViewAllBoards ? undefined : or(eq(boards.isPublic, true), inArray(boards.id, boardIds));
|
||||
|
||||
return await ctx.db.query.boards.findMany({
|
||||
columns: {
|
||||
id: true,
|
||||
name: true,
|
||||
logoImageUrl: true,
|
||||
},
|
||||
where: boardWhere,
|
||||
});
|
||||
}),
|
||||
getAllBoards: publicProcedure.query(async ({ ctx }) => {
|
||||
const userId = ctx.session?.user.id;
|
||||
const permissionsOfCurrentUserWhenPresent = await ctx.db.query.boardUserPermissions.findMany({
|
||||
@@ -89,6 +121,7 @@ export const boardRouter = createTRPCRouter({
|
||||
columns: {
|
||||
id: true,
|
||||
name: true,
|
||||
logoImageUrl: true,
|
||||
isPublic: true,
|
||||
},
|
||||
with: {
|
||||
@@ -478,12 +511,17 @@ export const boardRouter = createTRPCRouter({
|
||||
primaryColor: input.primaryColor,
|
||||
secondaryColor: input.secondaryColor,
|
||||
opacity: input.opacity,
|
||||
iconColor: input.iconColor,
|
||||
|
||||
// custom css
|
||||
customCss: input.customCss,
|
||||
|
||||
// layout settings
|
||||
columnCount: input.columnCount,
|
||||
itemRadius: input.itemRadius,
|
||||
|
||||
// Behavior settings
|
||||
disableStatus: input.disableStatus,
|
||||
})
|
||||
.where(eq(boards.id, input.id));
|
||||
}),
|
||||
@@ -972,9 +1010,13 @@ export const boardRouter = createTRPCRouter({
|
||||
* For an example of a user with deviceType = 'mobile' it would go through the following order:
|
||||
* 1. user.mobileHomeBoardId
|
||||
* 2. user.homeBoardId
|
||||
* 3. serverSettings.mobileHomeBoardId
|
||||
* 4. serverSettings.homeBoardId
|
||||
* 5. show NOT_FOUND error
|
||||
* 3. group.mobileHomeBoardId of the lowest positions group
|
||||
* 4. group.homeBoardId of the lowest positions group
|
||||
* 5. everyoneGroup.mobileHomeBoardId
|
||||
* 6. everyoneGroup.homeBoardId
|
||||
* 7. serverSettings.mobileHomeBoardId
|
||||
* 8. serverSettings.homeBoardId
|
||||
* 9. show NOT_FOUND error
|
||||
*/
|
||||
const getHomeIdBoardAsync = async (
|
||||
db: Database,
|
||||
@@ -982,12 +1024,46 @@ const getHomeIdBoardAsync = async (
|
||||
deviceType: DeviceType,
|
||||
) => {
|
||||
const settingKey = deviceType === "mobile" ? "mobileHomeBoardId" : "homeBoardId";
|
||||
if (user?.[settingKey] || user?.homeBoardId) {
|
||||
return user[settingKey] ?? user.homeBoardId;
|
||||
} else {
|
||||
|
||||
if (!user) {
|
||||
const boardSettings = await getServerSettingByKeyAsync(db, "board");
|
||||
return boardSettings[settingKey] ?? boardSettings.homeBoardId;
|
||||
}
|
||||
|
||||
if (user[settingKey]) return user[settingKey];
|
||||
if (user.homeBoardId) return user.homeBoardId;
|
||||
|
||||
const lowestGroupExceptEveryone = await db
|
||||
.select({
|
||||
homeBoardId: groups.homeBoardId,
|
||||
mobileHomeBoardId: groups.mobileHomeBoardId,
|
||||
})
|
||||
.from(groups)
|
||||
.leftJoin(groupMembers, eq(groups.id, groupMembers.groupId))
|
||||
.where(
|
||||
and(
|
||||
eq(groupMembers.userId, user.id),
|
||||
not(eq(groups.name, everyoneGroup)),
|
||||
not(isNull(groups[settingKey])),
|
||||
not(isNull(groups.homeBoardId)),
|
||||
),
|
||||
)
|
||||
.orderBy(asc(groups.position))
|
||||
.limit(1)
|
||||
.then((result) => result[0]);
|
||||
|
||||
if (lowestGroupExceptEveryone?.[settingKey]) return lowestGroupExceptEveryone[settingKey];
|
||||
if (lowestGroupExceptEveryone?.homeBoardId) return lowestGroupExceptEveryone.homeBoardId;
|
||||
|
||||
const dbEveryoneGroup = await db.query.groups.findFirst({
|
||||
where: eq(groups.name, everyoneGroup),
|
||||
});
|
||||
|
||||
if (dbEveryoneGroup?.[settingKey]) return dbEveryoneGroup[settingKey];
|
||||
if (dbEveryoneGroup?.homeBoardId) return dbEveryoneGroup.homeBoardId;
|
||||
|
||||
const boardSettings = await getServerSettingByKeyAsync(db, "board");
|
||||
return boardSettings[settingKey] ?? boardSettings.homeBoardId;
|
||||
};
|
||||
|
||||
const noBoardWithSimilarNameAsync = async (db: Database, name: string, ignoredIds: string[] = []) => {
|
||||
|
||||
@@ -2,7 +2,8 @@ import { TRPCError } from "@trpc/server";
|
||||
import { z } from "zod";
|
||||
|
||||
import type { Database } from "@homarr/db";
|
||||
import { and, createId, eq, like, not, sql } from "@homarr/db";
|
||||
import { and, createId, eq, handleTransactionsAsync, like, not, sql } from "@homarr/db";
|
||||
import { getMaxGroupPositionAsync } from "@homarr/db/queries";
|
||||
import { groupMembers, groupPermissions, groups } from "@homarr/db/schema";
|
||||
import { everyoneGroup } from "@homarr/definitions";
|
||||
import { validation } from "@homarr/validation";
|
||||
@@ -12,6 +13,30 @@ import { throwIfCredentialsDisabled } from "./invite/checks";
|
||||
import { nextOnboardingStepAsync } from "./onboard/onboard-queries";
|
||||
|
||||
export const groupRouter = createTRPCRouter({
|
||||
getAll: permissionRequiredProcedure.requiresPermission("admin").query(async ({ ctx }) => {
|
||||
const dbGroups = await ctx.db.query.groups.findMany({
|
||||
with: {
|
||||
members: {
|
||||
with: {
|
||||
user: {
|
||||
columns: {
|
||||
id: true,
|
||||
name: true,
|
||||
email: true,
|
||||
image: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return dbGroups.map((group) => ({
|
||||
...group,
|
||||
members: group.members.map((member) => member.user),
|
||||
}));
|
||||
}),
|
||||
|
||||
getPaginated: permissionRequiredProcedure
|
||||
.requiresPermission("admin")
|
||||
.input(validation.common.paginated)
|
||||
@@ -153,10 +178,13 @@ export const groupRouter = createTRPCRouter({
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
await checkSimilarNameAndThrowAsync(ctx.db, input.name);
|
||||
|
||||
const maxPosition = await getMaxGroupPositionAsync(ctx.db);
|
||||
|
||||
const groupId = createId();
|
||||
await ctx.db.insert(groups).values({
|
||||
id: groupId,
|
||||
name: input.name,
|
||||
position: maxPosition + 1,
|
||||
});
|
||||
|
||||
await ctx.db.insert(groupPermissions).values({
|
||||
@@ -172,10 +200,13 @@ export const groupRouter = createTRPCRouter({
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
await checkSimilarNameAndThrowAsync(ctx.db, input.name);
|
||||
|
||||
const maxPosition = await getMaxGroupPositionAsync(ctx.db);
|
||||
|
||||
const id = createId();
|
||||
await ctx.db.insert(groups).values({
|
||||
id,
|
||||
name: input.name,
|
||||
position: maxPosition + 1,
|
||||
ownerId: ctx.session.user.id,
|
||||
});
|
||||
|
||||
@@ -197,6 +228,43 @@ export const groupRouter = createTRPCRouter({
|
||||
})
|
||||
.where(eq(groups.id, input.id));
|
||||
}),
|
||||
savePartialSettings: permissionRequiredProcedure
|
||||
.requiresPermission("admin")
|
||||
.input(validation.group.savePartialSettings)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
await throwIfGroupNotFoundAsync(ctx.db, input.id);
|
||||
|
||||
await ctx.db
|
||||
.update(groups)
|
||||
.set({
|
||||
homeBoardId: input.settings.homeBoardId,
|
||||
mobileHomeBoardId: input.settings.mobileHomeBoardId,
|
||||
})
|
||||
.where(eq(groups.id, input.id));
|
||||
}),
|
||||
savePositions: permissionRequiredProcedure
|
||||
.requiresPermission("admin")
|
||||
.input(validation.group.savePositions)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
const positions = input.positions.map((id, index) => ({ id, position: index + 1 }));
|
||||
|
||||
await handleTransactionsAsync(ctx.db, {
|
||||
handleAsync: async (db, schema) => {
|
||||
await db.transaction(async (trx) => {
|
||||
for (const { id, position } of positions) {
|
||||
await trx.update(schema.groups).set({ position }).where(eq(groups.id, id));
|
||||
}
|
||||
});
|
||||
},
|
||||
handleSync: (db) => {
|
||||
db.transaction((trx) => {
|
||||
for (const { id, position } of positions) {
|
||||
trx.update(groups).set({ position }).where(eq(groups.id, id)).run();
|
||||
}
|
||||
});
|
||||
},
|
||||
});
|
||||
}),
|
||||
savePermissions: permissionRequiredProcedure
|
||||
.requiresPermission("admin")
|
||||
.input(validation.group.savePermissions)
|
||||
|
||||
@@ -195,6 +195,12 @@ export const searchEngineRouter = createTRPCRouter({
|
||||
.requiresPermission("search-engine-full-all")
|
||||
.input(validation.common.byId)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
await ctx.db
|
||||
.update(users)
|
||||
.set({
|
||||
defaultSearchEngineId: null,
|
||||
})
|
||||
.where(eq(users.defaultSearchEngineId, input.id));
|
||||
await ctx.db.delete(searchEngines).where(eq(searchEngines.id, input.id));
|
||||
}),
|
||||
});
|
||||
@@ -158,6 +158,7 @@ describe("create should create a new app with all arguments", () => {
|
||||
description: "React components and hooks library",
|
||||
iconUrl: "https://mantine.dev/favicon.svg",
|
||||
href: "https://mantine.dev",
|
||||
pingUrl: "https://mantine.dev/a",
|
||||
};
|
||||
|
||||
// Act
|
||||
@@ -170,6 +171,7 @@ describe("create should create a new app with all arguments", () => {
|
||||
expect(dbApp!.description).toBe(input.description);
|
||||
expect(dbApp!.iconUrl).toBe(input.iconUrl);
|
||||
expect(dbApp!.href).toBe(input.href);
|
||||
expect(dbApp!.pingUrl).toBe(input.pingUrl);
|
||||
});
|
||||
|
||||
test("should create a new app only with required arguments", async () => {
|
||||
@@ -185,6 +187,7 @@ describe("create should create a new app with all arguments", () => {
|
||||
description: null,
|
||||
iconUrl: "https://mantine.dev/favicon.svg",
|
||||
href: null,
|
||||
pingUrl: "",
|
||||
};
|
||||
|
||||
// Act
|
||||
@@ -197,6 +200,7 @@ describe("create should create a new app with all arguments", () => {
|
||||
expect(dbApp!.description).toBe(input.description);
|
||||
expect(dbApp!.iconUrl).toBe(input.iconUrl);
|
||||
expect(dbApp!.href).toBe(input.href);
|
||||
expect(dbApp!.pingUrl).toBe(null);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -225,6 +229,7 @@ describe("update should update an app", () => {
|
||||
description: "React components and hooks library",
|
||||
iconUrl: "https://mantine.dev/favicon.svg2",
|
||||
href: "https://mantine.dev",
|
||||
pingUrl: "https://mantine.dev/a",
|
||||
};
|
||||
|
||||
// Act
|
||||
@@ -257,6 +262,7 @@ describe("update should update an app", () => {
|
||||
iconUrl: "https://mantine.dev/favicon.svg",
|
||||
description: null,
|
||||
href: null,
|
||||
pingUrl: "",
|
||||
});
|
||||
|
||||
// Assert
|
||||
|
||||
@@ -205,6 +205,7 @@ describe("getAllBoards should return all boards accessable to the current user",
|
||||
await db.insert(groups).values({
|
||||
id: groupId,
|
||||
name: "group1",
|
||||
position: 1,
|
||||
});
|
||||
|
||||
await db.insert(groupMembers).values({
|
||||
@@ -1166,6 +1167,7 @@ describe("getBoardPermissions should return board permissions", () => {
|
||||
await db.insert(groups).values({
|
||||
id: groupId,
|
||||
name: "group1",
|
||||
position: 1,
|
||||
});
|
||||
|
||||
await db.insert(boardGroupPermissions).values({
|
||||
@@ -1260,6 +1262,7 @@ describe("saveGroupBoardPermissions should save group board permissions", () =>
|
||||
await db.insert(groups).values({
|
||||
id: groupId,
|
||||
name: "group1",
|
||||
position: 1,
|
||||
});
|
||||
|
||||
const boardId = createId();
|
||||
|
||||
@@ -43,6 +43,7 @@ describe("paginated should return a list of groups with pagination", () => {
|
||||
[1, 2, 3, 4, 5].map((number) => ({
|
||||
id: number.toString(),
|
||||
name: `Group ${number}`,
|
||||
position: number,
|
||||
})),
|
||||
);
|
||||
|
||||
@@ -66,6 +67,7 @@ describe("paginated should return a list of groups with pagination", () => {
|
||||
[1, 2, 3, 4, 5].map((number) => ({
|
||||
id: number.toString(),
|
||||
name: `Group ${number}`,
|
||||
position: number,
|
||||
})),
|
||||
);
|
||||
|
||||
@@ -89,6 +91,7 @@ describe("paginated should return a list of groups with pagination", () => {
|
||||
await db.insert(groups).values({
|
||||
id: groupId,
|
||||
name: "Group",
|
||||
position: 1,
|
||||
});
|
||||
await db.insert(groupMembers).values({
|
||||
groupId,
|
||||
@@ -123,6 +126,7 @@ describe("paginated should return a list of groups with pagination", () => {
|
||||
["first", "second", "third", "forth", "fifth"].map((key, index) => ({
|
||||
id: index.toString(),
|
||||
name: key,
|
||||
position: index + 1,
|
||||
})),
|
||||
);
|
||||
|
||||
@@ -163,10 +167,12 @@ describe("byId should return group by id including members and permissions", ()
|
||||
{
|
||||
id: groupId,
|
||||
name: "Group",
|
||||
position: 1,
|
||||
},
|
||||
{
|
||||
id: createId(),
|
||||
name: "Another group",
|
||||
position: 2,
|
||||
},
|
||||
]);
|
||||
await db.insert(groupMembers).values({
|
||||
@@ -202,6 +208,7 @@ describe("byId should return group by id including members and permissions", ()
|
||||
await db.insert(groups).values({
|
||||
id: "2",
|
||||
name: "Group",
|
||||
position: 1,
|
||||
});
|
||||
|
||||
// Act
|
||||
@@ -278,6 +285,7 @@ describe("create should create group in database", () => {
|
||||
await db.insert(groups).values({
|
||||
id: createId(),
|
||||
name: similarName,
|
||||
position: 1,
|
||||
});
|
||||
|
||||
// Act
|
||||
@@ -314,10 +322,12 @@ describe("update should update name with value that is no duplicate", () => {
|
||||
{
|
||||
id: groupId,
|
||||
name: initialValue,
|
||||
position: 1,
|
||||
},
|
||||
{
|
||||
id: createId(),
|
||||
name: "Third",
|
||||
position: 2,
|
||||
},
|
||||
]);
|
||||
|
||||
@@ -347,10 +357,12 @@ describe("update should update name with value that is no duplicate", () => {
|
||||
{
|
||||
id: groupId,
|
||||
name: "Something",
|
||||
position: 1,
|
||||
},
|
||||
{
|
||||
id: createId(),
|
||||
name: initialDuplicate,
|
||||
position: 2,
|
||||
},
|
||||
]);
|
||||
|
||||
@@ -373,6 +385,7 @@ describe("update should update name with value that is no duplicate", () => {
|
||||
await db.insert(groups).values({
|
||||
id: createId(),
|
||||
name: "something",
|
||||
position: 1,
|
||||
});
|
||||
|
||||
// Act
|
||||
@@ -413,6 +426,7 @@ describe("savePermissions should save permissions for group", () => {
|
||||
await db.insert(groups).values({
|
||||
id: groupId,
|
||||
name: "Group",
|
||||
position: 1,
|
||||
});
|
||||
await db.insert(groupPermissions).values({
|
||||
groupId,
|
||||
@@ -442,6 +456,7 @@ describe("savePermissions should save permissions for group", () => {
|
||||
await db.insert(groups).values({
|
||||
id: createId(),
|
||||
name: "Group",
|
||||
position: 1,
|
||||
});
|
||||
|
||||
// Act
|
||||
@@ -494,6 +509,7 @@ describe("transferOwnership should transfer ownership of group", () => {
|
||||
id: groupId,
|
||||
name: "Group",
|
||||
ownerId: defaultOwnerId,
|
||||
position: 1,
|
||||
});
|
||||
|
||||
// Act
|
||||
@@ -518,6 +534,7 @@ describe("transferOwnership should transfer ownership of group", () => {
|
||||
await db.insert(groups).values({
|
||||
id: createId(),
|
||||
name: "Group",
|
||||
position: 1,
|
||||
});
|
||||
|
||||
// Act
|
||||
@@ -559,10 +576,12 @@ describe("deleteGroup should delete group", () => {
|
||||
{
|
||||
id: groupId,
|
||||
name: "Group",
|
||||
position: 1,
|
||||
},
|
||||
{
|
||||
id: createId(),
|
||||
name: "Another group",
|
||||
position: 2,
|
||||
},
|
||||
]);
|
||||
|
||||
@@ -586,6 +605,7 @@ describe("deleteGroup should delete group", () => {
|
||||
await db.insert(groups).values({
|
||||
id: createId(),
|
||||
name: "Group",
|
||||
position: 1,
|
||||
});
|
||||
|
||||
// Act
|
||||
@@ -638,6 +658,7 @@ describe("addMember should add member to group", () => {
|
||||
id: groupId,
|
||||
name: "Group",
|
||||
ownerId: defaultOwnerId,
|
||||
position: 1,
|
||||
});
|
||||
|
||||
// Act
|
||||
@@ -715,6 +736,7 @@ describe("addMember should add member to group", () => {
|
||||
id: groupId,
|
||||
name: "Group",
|
||||
ownerId: defaultOwnerId,
|
||||
position: 1,
|
||||
});
|
||||
|
||||
// Act
|
||||
@@ -753,6 +775,7 @@ describe("removeMember should remove member from group", () => {
|
||||
id: groupId,
|
||||
name: "Group",
|
||||
ownerId: defaultOwnerId,
|
||||
position: 1,
|
||||
});
|
||||
await db.insert(groupMembers).values({
|
||||
groupId,
|
||||
@@ -833,6 +856,7 @@ describe("removeMember should remove member from group", () => {
|
||||
id: groupId,
|
||||
name: "Group",
|
||||
ownerId: defaultOwnerId,
|
||||
position: 1,
|
||||
});
|
||||
await db.insert(groupMembers).values({
|
||||
groupId,
|
||||
|
||||
@@ -247,7 +247,7 @@ describe("create should create a new integration", () => {
|
||||
expect(dbSearchEngine!.short).toBe("j");
|
||||
expect(dbSearchEngine!.name).toBe(input.name);
|
||||
expect(dbSearchEngine!.iconUrl).toBe(
|
||||
"https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/png/jellyseerr.png",
|
||||
"https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/jellyseerr.svg",
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -237,7 +237,7 @@ describe("editProfile shoud update user", () => {
|
||||
expect(user).toHaveLength(1);
|
||||
expect(user[0]).containSubset({
|
||||
id: defaultOwnerId,
|
||||
name: "ABC",
|
||||
name: "abc",
|
||||
email: "abc@gmail.com",
|
||||
emailVerified,
|
||||
});
|
||||
@@ -272,7 +272,7 @@ describe("editProfile shoud update user", () => {
|
||||
expect(user).toHaveLength(1);
|
||||
expect(user[0]).containSubset({
|
||||
id: defaultOwnerId,
|
||||
name: "ABC",
|
||||
name: "abc",
|
||||
email: "myNewEmail@gmail.com",
|
||||
emailVerified: null,
|
||||
});
|
||||
|
||||
@@ -1,11 +1,19 @@
|
||||
import { formatError } from "pretty-print-error";
|
||||
|
||||
import { logger } from "@homarr/log";
|
||||
import { updateCheckerRequestHandler } from "@homarr/request-handler/update-checker";
|
||||
|
||||
import { createTRPCRouter, permissionRequiredProcedure } from "../trpc";
|
||||
|
||||
export const updateCheckerRouter = createTRPCRouter({
|
||||
getAvailableUpdates: permissionRequiredProcedure.requiresPermission("admin").query(async () => {
|
||||
const handler = updateCheckerRequestHandler.handler({});
|
||||
const data = await handler.getCachedOrUpdatedDataAsync({});
|
||||
return data.data.availableUpdates;
|
||||
try {
|
||||
const handler = updateCheckerRequestHandler.handler({});
|
||||
const data = await handler.getCachedOrUpdatedDataAsync({});
|
||||
return data.data.availableUpdates;
|
||||
} catch (error) {
|
||||
logger.error(`Failed to get available updates\n${formatError(error)}`);
|
||||
return undefined; // We return undefined to not show the indicator in the UI
|
||||
}
|
||||
}),
|
||||
});
|
||||
@@ -4,6 +4,7 @@ import { z } from "zod";
|
||||
import { createSaltAsync, hashPasswordAsync } from "@homarr/auth";
|
||||
import type { Database } from "@homarr/db";
|
||||
import { and, createId, eq, like } from "@homarr/db";
|
||||
import { getMaxGroupPositionAsync } from "@homarr/db/queries";
|
||||
import { boards, groupMembers, groupPermissions, groups, invites, users } from "@homarr/db/schema";
|
||||
import { selectUserSchema } from "@homarr/db/validationSchemas";
|
||||
import { credentialsAdminGroup } from "@homarr/definitions";
|
||||
@@ -31,12 +32,14 @@ export const userRouter = createTRPCRouter({
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
throwIfCredentialsDisabled();
|
||||
|
||||
const maxPosition = await getMaxGroupPositionAsync(ctx.db);
|
||||
const userId = await createUserAsync(ctx.db, input);
|
||||
const groupId = createId();
|
||||
await ctx.db.insert(groups).values({
|
||||
id: groupId,
|
||||
name: credentialsAdminGroup,
|
||||
ownerId: userId,
|
||||
position: maxPosition + 1,
|
||||
});
|
||||
await ctx.db.insert(groupPermissions).values({
|
||||
groupId,
|
||||
@@ -523,12 +526,10 @@ const createUserAsync = async (db: Database, input: Omit<z.infer<typeof validati
|
||||
const salt = await createSaltAsync();
|
||||
const hashedPassword = await hashPasswordAsync(input.password, salt);
|
||||
|
||||
const username = input.username.toLowerCase();
|
||||
|
||||
const userId = createId();
|
||||
await db.insert(users).values({
|
||||
id: userId,
|
||||
name: username,
|
||||
name: input.username,
|
||||
email: input.email,
|
||||
password: hashedPassword,
|
||||
salt,
|
||||
@@ -543,7 +544,7 @@ const checkUsernameAlreadyTakenAndThrowAsync = async (
|
||||
ignoreId?: string,
|
||||
) => {
|
||||
const user = await db.query.users.findFirst({
|
||||
where: and(eq(users.name, username.toLowerCase()), eq(users.provider, provider)),
|
||||
where: and(eq(users.name, username), eq(users.provider, provider)),
|
||||
});
|
||||
|
||||
if (!user) return;
|
||||
|
||||
@@ -10,6 +10,7 @@ import { mediaServerRouter } from "./media-server";
|
||||
import { mediaTranscodingRouter } from "./media-transcoding";
|
||||
import { minecraftRouter } from "./minecraft";
|
||||
import { notebookRouter } from "./notebook";
|
||||
import { optionsRouter } from "./options";
|
||||
import { rssFeedRouter } from "./rssFeed";
|
||||
import { smartHomeRouter } from "./smart-home";
|
||||
import { weatherRouter } from "./weather";
|
||||
@@ -29,4 +30,5 @@ export const widgetRouter = createTRPCRouter({
|
||||
healthMonitoring: healthMonitoringRouter,
|
||||
mediaTranscoding: mediaTranscodingRouter,
|
||||
minecraft: minecraftRouter,
|
||||
options: optionsRouter,
|
||||
});
|
||||
@@ -0,0 +1,19 @@
|
||||
import { getServerSettingsAsync } from "@homarr/db/queries";
|
||||
|
||||
import type { WidgetOptionsSettings } from "../../../../widgets/src";
|
||||
import { createTRPCRouter, publicProcedure } from "../../trpc";
|
||||
|
||||
export const optionsRouter = createTRPCRouter({
|
||||
getWidgetOptionSettings: publicProcedure.query(async ({ ctx }): Promise<WidgetOptionsSettings> => {
|
||||
const serverSettings = await getServerSettingsAsync(ctx.db);
|
||||
|
||||
return {
|
||||
server: {
|
||||
board: {
|
||||
enableStatusByDefault: serverSettings.board.enableStatusByDefault,
|
||||
forceDisableStatus: serverSettings.board.forceDisableStatus,
|
||||
},
|
||||
},
|
||||
};
|
||||
}),
|
||||
});
|
||||
+4
-32
@@ -1,8 +1,8 @@
|
||||
import { createEnv } from "@t3-oss/env-nextjs";
|
||||
import { z } from "zod";
|
||||
|
||||
import { createBooleanSchema, createDurationSchema, shouldSkipEnvValidation } from "@homarr/common/env-validation";
|
||||
import { supportedAuthProviders } from "@homarr/definitions";
|
||||
import { createEnv } from "@homarr/env";
|
||||
import { createBooleanSchema, createDurationSchema } from "@homarr/env/schemas";
|
||||
|
||||
const authProvidersSchema = z
|
||||
.string()
|
||||
@@ -22,8 +22,7 @@ const authProvidersSchema = z
|
||||
)
|
||||
.default("credentials");
|
||||
|
||||
const skipValidation = shouldSkipEnvValidation();
|
||||
const authProviders = skipValidation ? [] : authProvidersSchema.parse(process.env.AUTH_PROVIDERS);
|
||||
const authProviders = authProvidersSchema.safeParse(process.env.AUTH_PROVIDERS).data ?? [];
|
||||
|
||||
export const env = createEnv({
|
||||
server: {
|
||||
@@ -59,32 +58,5 @@ export const env = createEnv({
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
client: {},
|
||||
runtimeEnv: {
|
||||
AUTH_LOGOUT_REDIRECT_URL: process.env.AUTH_LOGOUT_REDIRECT_URL,
|
||||
AUTH_SESSION_EXPIRY_TIME: process.env.AUTH_SESSION_EXPIRY_TIME,
|
||||
AUTH_PROVIDERS: process.env.AUTH_PROVIDERS,
|
||||
AUTH_LDAP_BASE: process.env.AUTH_LDAP_BASE,
|
||||
AUTH_LDAP_BIND_DN: process.env.AUTH_LDAP_BIND_DN,
|
||||
AUTH_LDAP_BIND_PASSWORD: process.env.AUTH_LDAP_BIND_PASSWORD,
|
||||
AUTH_LDAP_GROUP_CLASS: process.env.AUTH_LDAP_GROUP_CLASS,
|
||||
AUTH_LDAP_GROUP_FILTER_EXTRA_ARG: process.env.AUTH_LDAP_GROUP_FILTER_EXTRA_ARG,
|
||||
AUTH_LDAP_GROUP_MEMBER_ATTRIBUTE: process.env.AUTH_LDAP_GROUP_MEMBER_ATTRIBUTE,
|
||||
AUTH_LDAP_GROUP_MEMBER_USER_ATTRIBUTE: process.env.AUTH_LDAP_GROUP_MEMBER_USER_ATTRIBUTE,
|
||||
AUTH_LDAP_SEARCH_SCOPE: process.env.AUTH_LDAP_SEARCH_SCOPE,
|
||||
AUTH_LDAP_URI: process.env.AUTH_LDAP_URI,
|
||||
AUTH_OIDC_CLIENT_ID: process.env.AUTH_OIDC_CLIENT_ID,
|
||||
AUTH_OIDC_CLIENT_NAME: process.env.AUTH_OIDC_CLIENT_NAME,
|
||||
AUTH_OIDC_CLIENT_SECRET: process.env.AUTH_OIDC_CLIENT_SECRET,
|
||||
AUTH_OIDC_ISSUER: process.env.AUTH_OIDC_ISSUER,
|
||||
AUTH_OIDC_SCOPE_OVERWRITE: process.env.AUTH_OIDC_SCOPE_OVERWRITE,
|
||||
AUTH_OIDC_GROUPS_ATTRIBUTE: process.env.AUTH_OIDC_GROUPS_ATTRIBUTE,
|
||||
AUTH_LDAP_USERNAME_ATTRIBUTE: process.env.AUTH_LDAP_USERNAME_ATTRIBUTE,
|
||||
AUTH_LDAP_USER_MAIL_ATTRIBUTE: process.env.AUTH_LDAP_USER_MAIL_ATTRIBUTE,
|
||||
AUTH_LDAP_USERNAME_FILTER_EXTRA_ARG: process.env.AUTH_LDAP_USERNAME_FILTER_EXTRA_ARG,
|
||||
AUTH_OIDC_AUTO_LOGIN: process.env.AUTH_OIDC_AUTO_LOGIN,
|
||||
AUTH_OIDC_NAME_ATTRIBUTE_OVERWRITE: process.env.AUTH_OIDC_NAME_ATTRIBUTE_OVERWRITE,
|
||||
},
|
||||
skipValidation,
|
||||
emptyStringAsUndefined: true,
|
||||
experimental__runtimeEnv: process.env,
|
||||
});
|
||||
Loaded 100 of 315 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user