Compare commits
406
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7251938294 | ||
|
|
b888fad1fc | ||
|
|
5abd438a49 | ||
|
|
b1e065f1da | ||
|
|
ca49a01352 | ||
|
|
2139f81a1d | ||
|
|
fb43fc6046 | ||
|
|
3047f3bf2b | ||
|
|
4d313be37e | ||
|
|
11c20ce711 | ||
|
|
90b9d56729 | ||
|
|
e8805dfb5a | ||
|
|
7c98b65401 | ||
|
|
84801d3174 | ||
|
|
fba767bde3 | ||
|
|
48c2473ab3 | ||
|
|
ad1796dc02 | ||
|
|
14d941e57a | ||
|
|
0278e3a854 | ||
|
|
efaf721b5a | ||
|
|
e556bf3e66 | ||
|
|
a138e83f16 | ||
|
|
7bc7bd6bf9 | ||
|
|
0020680ca0 | ||
|
|
876f21a26d | ||
|
|
46bb3ee0c9 | ||
|
|
6dff7c48e2 | ||
|
|
7ab9dc2501 | ||
|
|
56388eb8ef | ||
|
|
c988f3d7f8 | ||
|
|
e5d024c62f | ||
|
|
fc1360d6bb | ||
|
|
584483aa9c | ||
|
|
47accae90d | ||
|
|
03c2993611 | ||
|
|
55822ee7a5 | ||
|
|
7a9e9fb203 | ||
|
|
5f247977fa | ||
|
|
036925bf78 | ||
|
|
621f6c81ae | ||
|
|
16e42d654d | ||
|
|
45ffbadeec | ||
|
|
73a72dc675 | ||
|
|
42a5a1a563 | ||
|
|
0cdf0b73bf | ||
|
|
bfdc8d584b | ||
|
|
6dfdc8971d | ||
|
|
f5002c3265 | ||
|
|
cc93824fe0 | ||
|
|
392a185640 | ||
|
|
92b417a37e | ||
|
|
3b5bd809a4 | ||
|
|
51ea67fc88 | ||
|
|
d88b6def75 | ||
|
|
ea528ea076 | ||
|
|
847adffe7b | ||
|
|
f204f34850 | ||
|
|
b8780d24bc | ||
|
|
a9e593e004 | ||
|
|
75e4e8176e | ||
|
|
84175e6a7e | ||
|
|
b78d32b81c | ||
|
|
04a313186f | ||
|
|
0342a177bb | ||
|
|
e47187512c | ||
|
|
974b4cf115 | ||
|
|
c0594f2419 | ||
|
|
4970c3a1b2 | ||
|
|
9615568ff3 | ||
|
|
f58d0ddd94 | ||
|
|
8c0ce672d0 | ||
|
|
f6c0f3ab78 | ||
|
|
e7689beee8 | ||
|
|
20f44d3494 | ||
|
|
0ba6455982 | ||
|
|
7f9f29bba5 | ||
|
|
cb2b934c83 | ||
|
|
b465b95ca2 | ||
|
|
a6d65fda6b | ||
|
|
3e3299a358 | ||
|
|
859fdf4c44 | ||
|
|
0cc5225e32 | ||
|
|
01f471ccce | ||
|
|
c40533e758 | ||
|
|
0edce9fd0b | ||
|
|
49e45e8122 | ||
|
|
f50550b3e1 | ||
|
|
48a4b8ba7e | ||
|
|
ed5e5d1edc | ||
|
|
2ccb7d1765 | ||
|
|
9ed298d641 | ||
|
|
6b1879cbb1 | ||
|
|
8f4897186c | ||
|
|
82e9887f36 | ||
|
|
80d2d485b8 | ||
|
|
7fb0decd5b | ||
|
|
719d1cd845 | ||
|
|
27c858dc81 | ||
|
|
57c6c7385a | ||
|
|
e0bb40a47e | ||
|
|
6a2ed0a8cf | ||
|
|
cb65d465aa | ||
|
|
8988880439 | ||
|
|
e6e6e4e7d6 | ||
|
|
669c6c8955 | ||
|
|
35ca732744 | ||
|
|
75224e0533 | ||
|
|
a5b8fdb397 | ||
|
|
fffefbb4c8 | ||
|
|
a6729a2b18 | ||
|
|
48f45d7377 | ||
|
|
50c162444c | ||
|
|
6cea1cbd85 | ||
|
|
e68a2e5813 | ||
|
|
7db5adf110 | ||
|
|
362257cf2b | ||
|
|
7bff8e5148 | ||
|
|
fbea026122 | ||
|
|
1fc2df19ec | ||
|
|
1936596c04 | ||
|
|
c82915c6dc | ||
|
|
2fb0535260 | ||
|
|
a4f316f10f | ||
|
|
54a7d1f4ae | ||
|
|
538c79528d | ||
|
|
3a1b4c3738 | ||
|
|
fdf060a3a2 | ||
|
|
fbc69f6078 | ||
|
|
ba16ba1382 | ||
|
|
d8d25d5037 | ||
|
|
d06528f311 | ||
|
|
6ef58beead | ||
|
|
e74926d382 | ||
|
|
085bd94704 | ||
|
|
415c632495 | ||
|
|
e1b62ecd53 | ||
|
|
b0bba1418b | ||
|
|
c9e15805a9 | ||
|
|
dd7f04e395 | ||
|
|
c00d856e1a | ||
|
|
1825e56349 | ||
|
|
c4a4452839 | ||
|
|
36073dbc5e | ||
|
|
724b348106 | ||
|
|
4783aaea20 | ||
|
|
98a8eaac09 | ||
|
|
ba27871765 | ||
|
|
c72f4bb80d | ||
|
|
0f59b10c64 | ||
|
|
058a8c4776 | ||
|
|
87808c1349 | ||
|
|
8d40f54d7d | ||
|
|
78b8cf6b29 | ||
|
|
e4e3463d04 | ||
|
|
d867ab162a | ||
|
|
1dfc6a9f0b | ||
|
|
7214df3e44 | ||
|
|
28f630f58b | ||
|
|
1f359cd87c | ||
|
|
cdc4870925 | ||
|
|
36e509a474 | ||
|
|
aeed1d7583 | ||
|
|
0ffb3f18b9 | ||
|
|
ab55a1357d | ||
|
|
92a8db718c | ||
|
|
361700b239 | ||
|
|
4753bc7162 | ||
|
|
4a52c6e3bc | ||
|
|
c5428efd60 | ||
|
|
0440af3566 | ||
|
|
30278f37d2 | ||
|
|
333aaf34dc | ||
|
|
9ba6f91570 | ||
|
|
75ab823019 | ||
|
|
8b009db4fe | ||
|
|
404f2945dc | ||
|
|
98c353349a | ||
|
|
a4951037e3 | ||
|
|
cf57b33d2c | ||
|
|
64354e4b43 | ||
|
|
4bab91f8de | ||
|
|
603b8a01af | ||
|
|
90465175f9 | ||
|
|
4f375cbe6d | ||
|
|
9ae99ad06c | ||
|
|
9a203e8fac | ||
|
|
72011147d1 | ||
|
|
179ceaebf0 | ||
|
|
fc29db0151 | ||
|
|
b63b2c8e90 | ||
|
|
2ee5027a3c | ||
|
|
084adeb166 | ||
|
|
ea79953353 | ||
|
|
a3c9afddab | ||
|
|
efbc4cf8fe | ||
|
|
1c222ed89c | ||
|
|
24ec13c2ab | ||
|
|
c4ff968cbc | ||
|
|
7d5b999ab8 | ||
|
|
5000c4f008 | ||
|
|
851e3701c2 | ||
|
|
edcba9ceb6 | ||
|
|
dceec34929 | ||
|
|
0ee8c5ad91 | ||
|
|
be9434bf8c | ||
|
|
e34161f33e | ||
|
|
19243b8681 | ||
|
|
ef7bd4cffb | ||
|
|
56d5e709bc | ||
|
|
a1342ea809 | ||
|
|
4f41765f10 | ||
|
|
1728d16177 | ||
|
|
46955830d9 | ||
|
|
beb7defd32 | ||
|
|
c0401702f0 | ||
|
|
82e8a02d8e | ||
|
|
345843b01c | ||
|
|
41b99f191c | ||
|
|
8c9adb31f3 | ||
|
|
e2bc88d9b6 | ||
|
|
8df514dec8 | ||
|
|
8d5984c58a | ||
|
|
70f34efd53 | ||
|
|
50fd4c17ad | ||
|
|
aa19139cf7 | ||
|
|
27a63dbf7a | ||
|
|
b454ed38eb | ||
|
|
a9abc5bfe2 | ||
|
|
acf9d1803f | ||
|
|
8c0534f54c | ||
|
|
4cd897d521 | ||
|
|
fde6d6601d | ||
|
|
f98046d9e2 | ||
|
|
f4b93d1eae | ||
|
|
0da3253d21 | ||
|
|
e1a98f7fc0 | ||
|
|
9ee96a1462 | ||
|
|
ada16317e3 | ||
|
|
27a039d27a | ||
|
|
dc2e13dcb5 | ||
|
|
6ef69475e2 | ||
|
|
9b8255aac7 | ||
|
|
fd5fe529e8 | ||
|
|
9440d04261 | ||
|
|
7f2ab7f192 | ||
|
|
bb02163e25 | ||
|
|
2a83df3485 | ||
|
|
990be660c5 | ||
|
|
258ad94bd2 | ||
|
|
af203e19e7 | ||
|
|
14cf39b996 | ||
|
|
b407f2a9ac | ||
|
|
baa541afa9 | ||
|
|
fc8d9071aa | ||
|
|
c6c8778397 | ||
|
|
212063a51e | ||
|
|
e74a41165d | ||
|
|
47f44128f5 | ||
|
|
f211ffbf78 | ||
|
|
4cf505f0c5 | ||
|
|
c4ffa59ede | ||
|
|
3022e7f0af | ||
|
|
e9b02519a4 | ||
|
|
8e5379225a | ||
|
|
b2cee8f693 | ||
|
|
cfb3daef95 | ||
|
|
f060f50caf | ||
|
|
abc4a9afbc | ||
|
|
60dec6e3be | ||
|
|
5d68300600 | ||
|
|
4d011364bf | ||
|
|
3d50f8165f | ||
|
|
b4749e7091 | ||
|
|
fde634d834 | ||
|
|
859eeed329 | ||
|
|
69b9247550 | ||
|
|
72feb06610 | ||
|
|
64194c51ed | ||
|
|
a4fc6f6444 | ||
|
|
6a02afbfe2 | ||
|
|
e150577c7e | ||
|
|
4565f38bef | ||
|
|
7d648600ee | ||
|
|
e3e38e7f44 | ||
|
|
f1aa422614 | ||
|
|
1e414af57c | ||
|
|
4f911c4f56 | ||
|
|
dbf02c6264 | ||
|
|
a9a3d98248 | ||
|
|
3d3c1b50a3 | ||
|
|
547f75d19c | ||
|
|
d5025da789 | ||
|
|
3577bd6ac3 | ||
|
|
71521c0768 | ||
|
|
3bdd117659 | ||
|
|
1d3fa16484 | ||
|
|
3e44644ef6 | ||
|
|
9a60588a03 | ||
|
|
ba0c3c72ed | ||
|
|
4ea1d14966 | ||
|
|
35edfc74c4 | ||
|
|
5d69ff74f3 | ||
|
|
5997afdd7b | ||
|
|
144e1512f7 | ||
|
|
f91338446d | ||
|
|
6c5e4752ea | ||
|
|
11393525d9 | ||
|
|
336dc38b2d | ||
|
|
b77e652822 | ||
|
|
966425c119 | ||
|
|
c5fb8075ce | ||
|
|
25693b10b1 | ||
|
|
34f06e817d | ||
|
|
08fa338abb | ||
|
|
16442bc379 | ||
|
|
f070a0cb0a | ||
|
|
13aae82790 | ||
|
|
34875dfd15 | ||
|
|
00f4a288a4 | ||
|
|
60d5ebbc11 | ||
|
|
3e732fc613 | ||
|
|
f704bad36e | ||
|
|
5ef79edc1a | ||
|
|
81e61b4d6b | ||
|
|
7f4ad88b0f | ||
|
|
a4f6a7c16a | ||
|
|
11d1568714 | ||
|
|
15f08011a6 | ||
|
|
975f9123dd | ||
|
|
cc52c2ba78 | ||
|
|
55ec60041a | ||
|
|
91812531b4 | ||
|
|
6797cca259 | ||
|
|
5cbd02b142 | ||
|
|
941307b954 | ||
|
|
593bc9ca1c | ||
|
|
8994ac2501 | ||
|
|
544a6d9926 | ||
|
|
ddc6f1fa16 | ||
|
|
e683d61568 | ||
|
|
5d3ca5b190 | ||
|
|
19588d77c8 | ||
|
|
c89d13557d | ||
|
|
ae12f0c9df | ||
|
|
ca04bf692d | ||
|
|
7cd56217eb | ||
|
|
4815230f8b | ||
|
|
59f024184b | ||
|
|
2b5ebda727 | ||
|
|
a1e15c65d2 | ||
|
|
1e01312fcd | ||
|
|
58803bcece | ||
|
|
717e919450 | ||
|
|
33c918603c | ||
|
|
dadfc0c5d7 | ||
|
|
ab7c884fbf | ||
|
|
9aaec5495d | ||
|
|
d73e7a3c3f | ||
|
|
b8fe04144f | ||
|
|
837e3900fa | ||
|
|
00db901833 | ||
|
|
6a892e02c8 | ||
|
|
f8a3aae116 | ||
|
|
191d773aaf | ||
|
|
8bcbc45943 | ||
|
|
d9d169c0ce | ||
|
|
a7d5c36aa7 | ||
|
|
b3e2032b54 | ||
|
|
7da312134c | ||
|
|
8a4fc849a2 | ||
|
|
f3bb990c4d | ||
|
|
8a9656a01e | ||
|
|
2e555fbb35 | ||
|
|
e6a5c83d9c | ||
|
|
2943819f74 | ||
|
|
d3c11f49dc | ||
|
|
74e1ad2f00 | ||
|
|
a3156bcb18 | ||
|
|
ea7cb27cfa | ||
|
|
154fc050cd | ||
|
|
9d520874f4 | ||
|
|
cfd1c14034 | ||
|
|
f0fa0eb321 | ||
|
|
e9c6583d21 | ||
|
|
3e2bc9df0e | ||
|
|
c736f17f14 | ||
|
|
39b4e761de | ||
|
|
89311c44a0 | ||
|
|
b678401020 | ||
|
|
f12192e6f2 | ||
|
|
3724071fcb | ||
|
|
b671f68b13 | ||
|
|
1740450648 | ||
|
|
3a0f280984 | ||
|
|
81d35656ba | ||
|
|
367beb6759 | ||
|
|
2809e01b03 | ||
|
|
782897527f | ||
|
|
fa19966fcc | ||
|
|
a082f70470 | ||
|
|
3cedb7fba5 | ||
|
|
41e54d940b | ||
|
|
f6094c6ae2 | ||
|
|
9ea96899b1 | ||
|
|
d115779c85 | ||
|
|
a2cedf73c0 |
No files matched your search
@@ -0,0 +1,11 @@
|
||||
version = 1
|
||||
|
||||
[[analyzers]]
|
||||
name = "javascript"
|
||||
|
||||
[analyzers.meta]
|
||||
plugins = ["react"]
|
||||
environment = ["nodejs"]
|
||||
|
||||
[[transformers]]
|
||||
name = "prettier"
|
||||
@@ -0,0 +1,8 @@
|
||||
Dockerfile
|
||||
.dockerignore
|
||||
node_modules
|
||||
npm-debug.log
|
||||
README.md
|
||||
.next
|
||||
.git
|
||||
dev
|
||||
@@ -0,0 +1,33 @@
|
||||
# Since .env is gitignored, you can use .env.example to build a new `.env` file when you clone the repo.
|
||||
# Keep this file up-to-date when you add new variables to \`.env\`.
|
||||
|
||||
# This file will be committed to version control, so make sure not to have any secrets in it.
|
||||
# If you are cloning this repo, create a copy of this file named `.env` and populate it with your secrets.
|
||||
|
||||
# This is how you can use the sqlite driver:
|
||||
DB_DRIVER='better-sqlite3'
|
||||
DB_URL='FULL_PATH_TO_YOUR_SQLITE_DB_FILE'
|
||||
|
||||
# Those are the two ways to use the mysql2 driver:
|
||||
# 1. Using the URL format:
|
||||
# DB_DRIVER='mysql2'
|
||||
# DB_URL='mysql://user:password@host:port/database'
|
||||
# 2. Using the connection options format:
|
||||
# DB_DRIVER='mysql2'
|
||||
# DB_HOST='localhost'
|
||||
# DB_PORT='3306'
|
||||
# DB_USER='username'
|
||||
# DB_PASSWORD='password'
|
||||
# DB_NAME='name-of-database'
|
||||
|
||||
# @see https://next-auth.js.org/configuration/options#nextauth_url
|
||||
AUTH_URL='http://localhost:3000'
|
||||
|
||||
# You can generate the secret via 'openssl rand -base64 32' on Unix
|
||||
# @see https://next-auth.js.org/configuration/options#secret
|
||||
AUTH_SECRET='supersecret'
|
||||
|
||||
TURBO_TELEMETRY_DISABLED=1
|
||||
|
||||
# Configure logging to use winston logger
|
||||
NODE_OPTIONS='-r @homarr/log/override'
|
||||
@@ -0,0 +1,3 @@
|
||||
# These are supported funding model platforms
|
||||
|
||||
open_collective: homarr
|
||||
@@ -0,0 +1,37 @@
|
||||
name: 🐞 Bug Report
|
||||
description: Create a bug report to help us improve
|
||||
title: "bug: "
|
||||
labels: ["🐞❔ unconfirmed bug"]
|
||||
body:
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Provide environment information
|
||||
description: |
|
||||
Run this command in your project root and paste the results in a code block:
|
||||
```bash
|
||||
npx envinfo --system --binaries
|
||||
```
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Describe the bug
|
||||
description: A clear and concise description of the bug, as well as what you expected to happen when encountering it.
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
attributes:
|
||||
label: Link to reproduction
|
||||
description: Please provide a link to a reproduction of the bug. Issues without a reproduction repo may be ignored.
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: To reproduce
|
||||
description: Describe how to reproduce your bug. Steps, code snippets, reproduction repos etc.
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Additional information
|
||||
description: Add any other information related to the bug here, screenshots if applicable.
|
||||
@@ -0,0 +1,28 @@
|
||||
# This template is heavily inspired by the Next.js's template:
|
||||
# See here: https://github.com/vercel/next.js/blob/canary/.github/ISSUE_TEMPLATE/3.feature_request.yml
|
||||
|
||||
name: 🛠 Feature Request
|
||||
description: Create a feature request for the core packages
|
||||
title: "feat: "
|
||||
labels: ["✨ enhancement"]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Thank you for taking the time to file a feature request. Please fill out this form as completely as possible.
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Describe the feature you'd like to request
|
||||
description: Please describe the feature as clear and concise as possible. Remember to add context as to why you believe this feature is needed.
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Describe the solution you'd like to see
|
||||
description: Please describe the solution you would like to see. Adding example usage is a good way to provide context.
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Additional information
|
||||
description: Add any other information related to the feature here. If your feature request is related to any issues or discussions, link them here.
|
||||
@@ -0,0 +1,13 @@
|
||||
<br/>
|
||||
<div align="center">
|
||||
<img src="https://homarr.dev/img/logo.png" height="80" alt="" />
|
||||
<h3>Homarr</h3>
|
||||
</div>
|
||||
|
||||
**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``)
|
||||
- [ ] 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)
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
|
||||
"extends": ["config:recommended"],
|
||||
"packageRules": [
|
||||
{
|
||||
"matchPackagePatterns": ["^@homarr/"],
|
||||
"enabled": false
|
||||
}
|
||||
],
|
||||
"updateInternalDeps": true,
|
||||
"rangeStrategy": "bump",
|
||||
"automerge": false,
|
||||
"baseBranches": ["dev"],
|
||||
"dependencyDashboard": false
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
name: Build apps and migration script
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: ["*"]
|
||||
push:
|
||||
branches: ["main"]
|
||||
merge_group:
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: ${{ github.ref != 'refs/heads/master' }}
|
||||
|
||||
env:
|
||||
FORCE_COLOR: 3
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Setup
|
||||
uses: ./tooling/github/setup
|
||||
|
||||
- name: Copy env
|
||||
shell: bash
|
||||
run: cp .env.example .env
|
||||
|
||||
- name: Build
|
||||
run: pnpm build
|
||||
@@ -0,0 +1,68 @@
|
||||
name: Code quality analysis
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: ["*"]
|
||||
push:
|
||||
branches: ["main"]
|
||||
merge_group:
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: ${{ github.ref != 'refs/heads/master' }}
|
||||
|
||||
# You can leverage Vercel Remote Caching with Turbo to speed up your builds
|
||||
# @link https://turborepo.org/docs/core-concepts/remote-caching#remote-caching-on-vercel-builds
|
||||
env:
|
||||
FORCE_COLOR: 3
|
||||
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
|
||||
TURBO_TEAM: ${{ vars.TURBO_TEAM }}
|
||||
|
||||
jobs:
|
||||
lint:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Setup
|
||||
uses: ./tooling/github/setup
|
||||
|
||||
- name: Copy env
|
||||
shell: bash
|
||||
run: cp .env.example .env
|
||||
|
||||
- name: Lint
|
||||
run: pnpm lint && pnpm lint:ws
|
||||
|
||||
format:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Setup
|
||||
uses: ./tooling/github/setup
|
||||
|
||||
- name: Format
|
||||
run: pnpm format
|
||||
|
||||
typecheck:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Setup
|
||||
uses: ./tooling/github/setup
|
||||
|
||||
- name: Typecheck
|
||||
run: turbo typecheck
|
||||
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Setup
|
||||
uses: ./tooling/github/setup
|
||||
|
||||
- name: Test
|
||||
run: pnpm test
|
||||
@@ -0,0 +1,89 @@
|
||||
name: Docker image
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types:
|
||||
- closed
|
||||
branches:
|
||||
- main
|
||||
workflow_dispatch: {}
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
packages: write
|
||||
|
||||
env:
|
||||
TURBO_TELEMETRY_DISABLED: 1
|
||||
|
||||
concurrency: production
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
name: Deploy docker image
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
node-version: [20]
|
||||
steps:
|
||||
- name: Discord notification
|
||||
env:
|
||||
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
|
||||
uses: Ilshidur/action-discord@master
|
||||
with:
|
||||
args: "Deployment of an image has been triggered"
|
||||
- uses: actions/checkout@v4
|
||||
- uses: pnpm/action-setup@v2
|
||||
with:
|
||||
version: 8
|
||||
- name: Use Node.js ${{ matrix.node-version }}
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ matrix.node-version }}
|
||||
cache: "pnpm"
|
||||
- name: Install dependencies
|
||||
run: pnpm install
|
||||
- name: Build artifacts
|
||||
run: pnpm build
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
- name: Bump version and push tag
|
||||
id: githubTagAction
|
||||
uses: anothrNick/github-tag-action@1.67.0
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
WITH_V: false
|
||||
DRY_RUN: true
|
||||
- name: Discord notification
|
||||
env:
|
||||
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
|
||||
uses: Ilshidur/action-discord@master
|
||||
with:
|
||||
args: "Image has been tagged as ${{ steps.githubTagAction.outputs.new_tag }}"
|
||||
- name: Docker meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
tags: |
|
||||
type=raw,value=latest
|
||||
type=raw,value=${{ steps.githubTagAction.outputs.new_tag }}
|
||||
- name: Build and push
|
||||
id: buildPushAction
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
platforms: linux/amd64,linux/arm64,linux/riscv64,linux/arm/v7,linux/arm/v6
|
||||
context: .
|
||||
push: false
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
network: host
|
||||
- name: Discord notification
|
||||
env:
|
||||
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
|
||||
uses: Ilshidur/action-discord@master
|
||||
with:
|
||||
args: "Image built with ID ${{ steps.buildPushAction.outputs.imageid }}"
|
||||
+54
@@ -0,0 +1,54 @@
|
||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# dependencies
|
||||
node_modules
|
||||
.pnp
|
||||
.pnp.js
|
||||
.idea/
|
||||
|
||||
# testing
|
||||
coverage
|
||||
|
||||
# next.js
|
||||
.next/
|
||||
out/
|
||||
next-env.d.ts
|
||||
|
||||
# nest.js
|
||||
apps/nestjs/dist
|
||||
|
||||
# nitro
|
||||
.nitro/
|
||||
.output/
|
||||
|
||||
# production
|
||||
build
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
*.pem
|
||||
|
||||
# debug
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
.pnpm-debug.log*
|
||||
|
||||
# local env files
|
||||
.env
|
||||
.env*.local
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
|
||||
# turbo
|
||||
.turbo
|
||||
|
||||
# database
|
||||
db.sqlite
|
||||
|
||||
# logs
|
||||
*.log
|
||||
@@ -0,0 +1,5 @@
|
||||
# Expo doesn't play nice with pnpm by default.
|
||||
# The symbolic links of pnpm break the rules of Expo monorepos.
|
||||
# @link https://docs.expo.dev/guides/monorepos/#common-issues
|
||||
node-linker=hoisted
|
||||
strict-peer-dependencies=false
|
||||
Vendored
+7
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"recommendations": [
|
||||
"dbaeumer.vscode-eslint",
|
||||
"esbenp.prettier-vscode",
|
||||
"yoavbls.pretty-ts-errors"
|
||||
]
|
||||
}
|
||||
Vendored
+87
@@ -0,0 +1,87 @@
|
||||
{
|
||||
"version": "0.2.0",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "dev",
|
||||
"type": "node-terminal",
|
||||
"request": "launch",
|
||||
"command": "pnpm dev",
|
||||
"cwd": "${workspaceFolder}",
|
||||
"skipFiles": ["<node_internals>/**"]
|
||||
},
|
||||
{
|
||||
"name": "docker dev",
|
||||
"type": "node-terminal",
|
||||
"request": "launch",
|
||||
"command": "pnpm docker:dev",
|
||||
"cwd": "${workspaceFolder}",
|
||||
"skipFiles": [
|
||||
"<node_internals>/**"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "lint fix",
|
||||
"type": "node-terminal",
|
||||
"request": "launch",
|
||||
"command": "pnpm lint:fix",
|
||||
"cwd": "${workspaceFolder}",
|
||||
"skipFiles": [
|
||||
"<node_internals>/**"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "format fix",
|
||||
"type": "node-terminal",
|
||||
"request": "launch",
|
||||
"command": "pnpm format:fix",
|
||||
"cwd": "${workspaceFolder}",
|
||||
"skipFiles": [
|
||||
"<node_internals>/**"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "db push",
|
||||
"type": "node-terminal",
|
||||
"request": "launch",
|
||||
"command": "pnpm db:push",
|
||||
"cwd": "${workspaceFolder}",
|
||||
"skipFiles": ["<node_internals>/**"]
|
||||
},
|
||||
{
|
||||
"name": "db studio",
|
||||
"type": "node-terminal",
|
||||
"request": "launch",
|
||||
"command": "pnpm db:studio",
|
||||
"cwd": "${workspaceFolder}",
|
||||
"skipFiles": ["<node_internals>/**"]
|
||||
},
|
||||
{
|
||||
"name": "db migration run",
|
||||
"type": "node-terminal",
|
||||
"request": "launch",
|
||||
"command": "pnpm db:migration:run",
|
||||
"cwd": "${workspaceFolder}",
|
||||
"skipFiles": ["<node_internals>/**"]
|
||||
},
|
||||
{
|
||||
"name": "test",
|
||||
"type": "node-terminal",
|
||||
"request": "launch",
|
||||
"command": "pnpm test",
|
||||
"cwd": "${workspaceFolder}",
|
||||
"skipFiles": [
|
||||
"<node_internals>/**"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "test ui",
|
||||
"type": "node-terminal",
|
||||
"request": "launch",
|
||||
"command": "pnpm test:ui",
|
||||
"cwd": "${workspaceFolder}",
|
||||
"skipFiles": [
|
||||
"<node_internals>/**"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
Vendored
+14
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"eslint.workingDirectories": [
|
||||
{
|
||||
"mode": "auto"
|
||||
}
|
||||
],
|
||||
"typescript.tsdk": "node_modules\\typescript\\lib",
|
||||
"js/ts.implicitProjectConfig.experimentalDecorators": true,
|
||||
"cSpell.words": [
|
||||
"superjson",
|
||||
"homarr",
|
||||
"trpc"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
* @homarr-labs/maintainers
|
||||
+77
@@ -0,0 +1,77 @@
|
||||
FROM node:20.12.2-alpine AS base
|
||||
|
||||
FROM base AS builder
|
||||
RUN apk add --no-cache libc6-compat
|
||||
RUN apk update
|
||||
# Set working directory
|
||||
WORKDIR /app
|
||||
COPY . .
|
||||
RUN npm i -g turbo
|
||||
RUN turbo prune @homarr/nextjs --docker --out-dir ./next-out
|
||||
RUN turbo prune @homarr/tasks --docker --out-dir ./tasks-out
|
||||
RUN turbo prune @homarr/websocket --docker --out-dir ./websocket-out
|
||||
RUN turbo prune @homarr/db --docker --out-dir ./migration-out
|
||||
|
||||
# Add lockfile and package.json's of isolated subworkspace
|
||||
FROM base AS installer
|
||||
RUN apk add --no-cache libc6-compat curl bash
|
||||
RUN apk update
|
||||
WORKDIR /app
|
||||
|
||||
# First install the dependencies (as they change less often)
|
||||
COPY .gitignore .gitignore
|
||||
|
||||
COPY --from=builder /app/tasks-out/json/ .
|
||||
COPY --from=builder /app/tasks-out/pnpm-lock.yaml ./pnpm-lock.yaml
|
||||
RUN corepack enable pnpm && pnpm install
|
||||
|
||||
COPY --from=builder /app/websocket-out/json/ .
|
||||
COPY --from=builder /app/websocket-out/pnpm-lock.yaml ./pnpm-lock.yaml
|
||||
RUN corepack enable pnpm && pnpm install
|
||||
|
||||
COPY --from=builder /app/migration-out/json/ .
|
||||
COPY --from=builder /app/migration-out/pnpm-lock.yaml ./pnpm-lock.yaml
|
||||
RUN corepack enable pnpm && pnpm install
|
||||
|
||||
COPY --from=builder /app/next-out/json/ .
|
||||
COPY --from=builder /app/next-out/pnpm-lock.yaml ./pnpm-lock.yaml
|
||||
RUN corepack enable pnpm && pnpm install sharp -w
|
||||
|
||||
# Build the project
|
||||
COPY --from=builder /app/tasks-out/full/ .
|
||||
COPY --from=builder /app/websocket-out/full/ .
|
||||
COPY --from=builder /app/next-out/full/ .
|
||||
COPY --from=builder /app/migration-out/full/ .
|
||||
ARG SKIP_ENV_VALIDATION=true
|
||||
RUN corepack enable pnpm && pnpm turbo run build
|
||||
|
||||
FROM base AS runner
|
||||
WORKDIR /app
|
||||
|
||||
RUN apk add --no-cache redis
|
||||
|
||||
# Don't run production as root
|
||||
RUN addgroup --system --gid 1001 nodejs
|
||||
RUN adduser --system --uid 1001 nextjs
|
||||
USER nextjs
|
||||
|
||||
COPY --from=installer /app/apps/nextjs/next.config.mjs .
|
||||
COPY --from=installer /app/apps/nextjs/package.json .
|
||||
|
||||
COPY --from=installer --chown=nextjs:nodejs /app/apps/tasks/tasks.cjs ./apps/tasks/tasks.cjs
|
||||
COPY --from=installer --chown=nextjs:nodejs /app/apps/websocket/wssServer.cjs ./apps/websocket/wssServer.cjs
|
||||
COPY --from=installer --chown=nextjs:nodejs /app/node_modules/better-sqlite3/build/Release/better_sqlite3.node /app/build/better_sqlite3.node
|
||||
|
||||
COPY --from=installer --chown=nextjs:nodejs /app/packages/db/migrations ./db/migrations
|
||||
COPY --from=installer --chown=nextjs:nodejs /app/packages/db/migrate.cjs ./db/migrate.cjs
|
||||
|
||||
# Automatically leverage output traces to reduce image size
|
||||
# https://nextjs.org/docs/advanced-features/output-file-tracing
|
||||
COPY --from=installer --chown=nextjs:nodejs /app/apps/nextjs/.next/standalone ./
|
||||
COPY --from=installer --chown=nextjs:nodejs /app/apps/nextjs/.next/static ./apps/nextjs/.next/static
|
||||
COPY --from=installer --chown=nextjs:nodejs /app/apps/nextjs/public ./apps/nextjs/public
|
||||
COPY --chown=nextjs:nodejs scripts/run.sh ./run.sh
|
||||
|
||||
ENV DB_URL='/app/db/db.sqlite'
|
||||
|
||||
CMD ["sh", "run.sh"]
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2023 Julius Marminge
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,5 @@
|
||||
# THIS PROJECT IS STILL UNSTABLE AND WE DO NOT PROVIDE ANY SUPPORT FOR ISSUES THAT OCCURE.
|
||||
## PLEASE DO NOT OPEN ANY ISSUES OR DISCUSSIONS
|
||||
### EVERYTHING IS SUBJECT TO CHANGE
|
||||
|
||||
Please use [this](https://github.com/ajnart/homarr) version of Homarr when you want to use it
|
||||
@@ -0,0 +1,28 @@
|
||||
# Create T3 App
|
||||
|
||||
This is a [T3 Stack](https://create.t3.gg/) project bootstrapped with `create-t3-app`.
|
||||
|
||||
## What's next? How do I make an app with this?
|
||||
|
||||
We try to keep this project as simple as possible, so you can start with just the scaffolding we set up for you, and add additional things later when they become necessary.
|
||||
|
||||
If you are not familiar with the different technologies used in this project, please refer to the respective docs. If you still are in the wind, please join our [Discord](https://t3.gg/discord) and ask for help.
|
||||
|
||||
- [Next.js](https://nextjs.org)
|
||||
- [NextAuth.js](https://next-auth.js.org)
|
||||
- [Drizzle](https://orm.drizzle.team)
|
||||
- [Tailwind CSS](https://tailwindcss.com)
|
||||
- [tRPC](https://trpc.io)
|
||||
|
||||
## Learn More
|
||||
|
||||
To learn more about the [T3 Stack](https://create.t3.gg/), take a look at the following resources:
|
||||
|
||||
- [Documentation](https://create.t3.gg/)
|
||||
- [Learn the T3 Stack](https://create.t3.gg/en/faq#what-learning-resources-are-currently-available) — Check out these awesome tutorials
|
||||
|
||||
You can check out the [create-t3-app GitHub repository](https://github.com/t3-oss/create-t3-app) — your feedback and contributions are welcome!
|
||||
|
||||
## How do I deploy this?
|
||||
|
||||
Follow our deployment guides for [Vercel](https://create.t3.gg/en/deployment/vercel) and [Docker](https://create.t3.gg/en/deployment/docker) for more information.
|
||||
@@ -0,0 +1,31 @@
|
||||
// Importing env files here to validate on build
|
||||
import "./src/env.mjs";
|
||||
import "@homarr/auth/env.mjs";
|
||||
|
||||
/** @type {import("next").NextConfig} */
|
||||
const config = {
|
||||
output: "standalone",
|
||||
reactStrictMode: true,
|
||||
/** We already do linting and typechecking as separate tasks in CI */
|
||||
eslint: { ignoreDuringBuilds: true },
|
||||
typescript: { ignoreBuildErrors: true },
|
||||
experimental: {
|
||||
optimizePackageImports: [
|
||||
"@mantine/core",
|
||||
"@mantine/hooks",
|
||||
"@tabler/icons-react",
|
||||
],
|
||||
},
|
||||
transpilePackages: [
|
||||
"@homarr/ui",
|
||||
"@homarr/notifications",
|
||||
"@homarr/modals",
|
||||
"@homarr/spotlight",
|
||||
"@homarr/widgets",
|
||||
],
|
||||
images: {
|
||||
domains: ["cdn.jsdelivr.net"],
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
@@ -0,0 +1,83 @@
|
||||
{
|
||||
"name": "@homarr/nextjs",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "pnpm with-env next build",
|
||||
"clean": "git clean -xdf .next .turbo node_modules",
|
||||
"dev": "pnpm with-env next dev",
|
||||
"lint": "dotenv -v SKIP_ENV_VALIDATION=1 next lint",
|
||||
"format": "prettier --check . --ignore-path ../../.gitignore",
|
||||
"start": "pnpm with-env next start",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"with-env": "dotenv -e ../../.env --"
|
||||
},
|
||||
"dependencies": {
|
||||
"@homarr/api": "workspace:^0.1.0",
|
||||
"@homarr/auth": "workspace:^0.1.0",
|
||||
"@homarr/common": "workspace:^0.1.0",
|
||||
"@homarr/db": "workspace:^0.1.0",
|
||||
"@homarr/definitions": "workspace:^0.1.0",
|
||||
"@homarr/form": "workspace:^0.1.0",
|
||||
"@homarr/gridstack": "^1.0.0",
|
||||
"@homarr/log": "workspace:^",
|
||||
"@homarr/modals": "workspace:^0.1.0",
|
||||
"@homarr/notifications": "workspace:^0.1.0",
|
||||
"@homarr/spotlight": "workspace:^0.1.0",
|
||||
"@homarr/translation": "workspace:^0.1.0",
|
||||
"@homarr/ui": "workspace:^0.1.0",
|
||||
"@homarr/validation": "workspace:^0.1.0",
|
||||
"@homarr/widgets": "workspace:^0.1.0",
|
||||
"@mantine/colors-generator": "^7.9.0",
|
||||
"@mantine/hooks": "^7.9.0",
|
||||
"@mantine/modals": "^7.9.0",
|
||||
"@mantine/tiptap": "^7.9.0",
|
||||
"@t3-oss/env-nextjs": "^0.10.1",
|
||||
"@tanstack/react-query": "^5.34.1",
|
||||
"@tanstack/react-query-devtools": "^5.34.1",
|
||||
"@tanstack/react-query-next-experimental": "5.34.1",
|
||||
"@trpc/client": "11.0.0-rc.364",
|
||||
"@trpc/next": "next",
|
||||
"@trpc/react-query": "next",
|
||||
"@trpc/server": "next",
|
||||
"@xterm/addon-canvas": "^0.7.0",
|
||||
"@xterm/addon-fit": "0.10.0",
|
||||
"@xterm/xterm": "^5.5.0",
|
||||
"chroma-js": "^2.4.2",
|
||||
"dayjs": "^1.11.11",
|
||||
"dotenv": "^16.4.5",
|
||||
"glob": "^10.3.12",
|
||||
"jotai": "^2.8.0",
|
||||
"next": "^14.2.3",
|
||||
"postcss-preset-mantine": "^1.15.0",
|
||||
"react": "18.3.1",
|
||||
"react-dom": "18.3.1",
|
||||
"sass": "^1.76.0",
|
||||
"superjson": "2.2.1",
|
||||
"use-deep-compare-effect": "^1.8.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@homarr/eslint-config": "workspace:^0.2.0",
|
||||
"@homarr/prettier-config": "workspace:^0.1.0",
|
||||
"@homarr/tsconfig": "workspace:^0.1.0",
|
||||
"@types/chroma-js": "2.4.4",
|
||||
"@types/node": "^20.12.8",
|
||||
"@types/react": "^18.3.1",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"concurrently": "^8.2.2",
|
||||
"eslint": "^8.57.0",
|
||||
"prettier": "^3.2.5",
|
||||
"tsx": "4.9.0",
|
||||
"typescript": "^5.4.5"
|
||||
},
|
||||
"eslintConfig": {
|
||||
"root": true,
|
||||
"extends": [
|
||||
"@homarr/eslint-config/base",
|
||||
"@homarr/eslint-config/nextjs",
|
||||
"@homarr/eslint-config/react"
|
||||
]
|
||||
},
|
||||
"prettier": "@homarr/prettier-config"
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
"postcss-preset-mantine": {},
|
||||
"postcss-simple-vars": {
|
||||
variables: {
|
||||
"mantine-breakpoint-xs": "36em",
|
||||
"mantine-breakpoint-sm": "48em",
|
||||
"mantine-breakpoint-md": "62em",
|
||||
"mantine-breakpoint-lg": "75em",
|
||||
"mantine-breakpoint-xl": "88em",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 172 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 3.3 KiB |
@@ -0,0 +1,14 @@
|
||||
import type { PropsWithChildren } from "react";
|
||||
import { AppShellMain } from "@mantine/core";
|
||||
|
||||
import { MainHeader } from "~/components/layout/header";
|
||||
import { ClientShell } from "~/components/layout/shell";
|
||||
|
||||
export default function MainLayout({ children }: PropsWithChildren) {
|
||||
return (
|
||||
<ClientShell hasNavigation={false}>
|
||||
<MainHeader />
|
||||
<AppShellMain>{children}</AppShellMain>
|
||||
</ClientShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { Stack, Title } from "@mantine/core";
|
||||
|
||||
export default function HomePage() {
|
||||
return (
|
||||
<Stack>
|
||||
<Title>Home</Title>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import type { PropsWithChildren } from "react";
|
||||
import { Provider } from "jotai";
|
||||
|
||||
export const JotaiProvider = ({ children }: PropsWithChildren) => {
|
||||
return <Provider>{children}</Provider>;
|
||||
};
|
||||
@@ -0,0 +1,15 @@
|
||||
import type { PropsWithChildren } from "react";
|
||||
|
||||
import { defaultLocale } from "@homarr/translation";
|
||||
import { I18nProviderClient } from "@homarr/translation/client";
|
||||
|
||||
export const NextInternationalProvider = ({
|
||||
children,
|
||||
locale,
|
||||
}: PropsWithChildren<{ locale: string }>) => {
|
||||
return (
|
||||
<I18nProviderClient locale={locale} fallback={defaultLocale}>
|
||||
{children}
|
||||
</I18nProviderClient>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,17 @@
|
||||
"use client";
|
||||
|
||||
import type { PropsWithChildren } from "react";
|
||||
|
||||
import type { Session } from "@homarr/auth";
|
||||
import { SessionProvider } from "@homarr/auth/client";
|
||||
|
||||
interface AuthProviderProps {
|
||||
session: Session | null;
|
||||
}
|
||||
|
||||
export const AuthProvider = ({
|
||||
children,
|
||||
session,
|
||||
}: PropsWithChildren<AuthProviderProps>) => {
|
||||
return <SessionProvider session={session}>{children}</SessionProvider>;
|
||||
};
|
||||
@@ -0,0 +1,85 @@
|
||||
"use client";
|
||||
|
||||
import type { PropsWithChildren } from "react";
|
||||
import { useState } from "react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
|
||||
import { ReactQueryStreamedHydration } from "@tanstack/react-query-next-experimental";
|
||||
import {
|
||||
createWSClient,
|
||||
loggerLink,
|
||||
unstable_httpBatchStreamLink,
|
||||
wsLink,
|
||||
} from "@trpc/client";
|
||||
import superjson from "superjson";
|
||||
|
||||
import type { AppRouter } from "@homarr/api";
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
|
||||
const wsClient = createWSClient({
|
||||
url: "ws://localhost:3001",
|
||||
});
|
||||
|
||||
export function TRPCReactProvider(props: PropsWithChildren) {
|
||||
const [queryClient] = useState(
|
||||
() =>
|
||||
new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
staleTime: 5 * 1000,
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const [trpcClient] = useState(() => {
|
||||
return clientApi.createClient({
|
||||
links: [
|
||||
loggerLink({
|
||||
enabled: (opts) =>
|
||||
process.env.NODE_ENV === "development" ||
|
||||
(opts.direction === "down" && opts.result instanceof Error),
|
||||
}),
|
||||
(args) => {
|
||||
return ({ op, next }) => {
|
||||
console.log("op", op.type, op.input, op.path, op.id);
|
||||
if (op.type === "subscription") {
|
||||
const link = wsLink<AppRouter>({
|
||||
client: wsClient,
|
||||
transformer: superjson,
|
||||
});
|
||||
return link(args)({ op, next });
|
||||
}
|
||||
|
||||
return unstable_httpBatchStreamLink({
|
||||
transformer: superjson,
|
||||
url: `${getBaseUrl()}/api/trpc`,
|
||||
headers() {
|
||||
const headers = new Headers();
|
||||
headers.set("x-trpc-source", "nextjs-react");
|
||||
return headers;
|
||||
},
|
||||
})(args)({ op, next });
|
||||
};
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
return (
|
||||
<clientApi.Provider client={trpcClient} queryClient={queryClient}>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ReactQueryStreamedHydration transformer={superjson}>
|
||||
{props.children}
|
||||
</ReactQueryStreamedHydration>
|
||||
<ReactQueryDevtools initialIsOpen={false} />
|
||||
</QueryClientProvider>
|
||||
</clientApi.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
function getBaseUrl() {
|
||||
if (typeof window !== "undefined") return window.location.origin;
|
||||
if (process.env.VERCEL_URL) return `https://${process.env.VERCEL_URL}`;
|
||||
return `http://localhost:${process.env.PORT ?? 3000}`;
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import {
|
||||
Alert,
|
||||
Button,
|
||||
PasswordInput,
|
||||
rem,
|
||||
Stack,
|
||||
TextInput,
|
||||
} from "@mantine/core";
|
||||
import { IconAlertTriangle } from "@tabler/icons-react";
|
||||
|
||||
import { signIn } from "@homarr/auth/client";
|
||||
import { useForm, zodResolver } from "@homarr/form";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
import type { z } from "@homarr/validation";
|
||||
import { validation } from "@homarr/validation";
|
||||
|
||||
export const LoginForm = () => {
|
||||
const t = useScopedI18n("user");
|
||||
const router = useRouter();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string>();
|
||||
const form = useForm<FormType>({
|
||||
validate: zodResolver(validation.user.signIn),
|
||||
initialValues: {
|
||||
name: "",
|
||||
password: "",
|
||||
},
|
||||
});
|
||||
|
||||
const handleSubmit = async (values: FormType) => {
|
||||
setIsLoading(true);
|
||||
setError(undefined);
|
||||
await signIn("credentials", {
|
||||
...values,
|
||||
redirect: false,
|
||||
callbackUrl: "/",
|
||||
})
|
||||
.then((response) => {
|
||||
if (!response?.ok || response.error) {
|
||||
throw response?.error;
|
||||
}
|
||||
|
||||
void router.push("/");
|
||||
})
|
||||
.catch((error: Error | string) => {
|
||||
setIsLoading(false);
|
||||
setError(error.toString());
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack gap="xl">
|
||||
<form onSubmit={form.onSubmit((values) => void handleSubmit(values))}>
|
||||
<Stack gap="lg">
|
||||
<TextInput
|
||||
label={t("field.username.label")}
|
||||
{...form.getInputProps("name")}
|
||||
/>
|
||||
<PasswordInput
|
||||
label={t("field.password.label")}
|
||||
{...form.getInputProps("password")}
|
||||
/>
|
||||
<Button type="submit" fullWidth loading={isLoading}>
|
||||
{t("action.login")}
|
||||
</Button>
|
||||
</Stack>
|
||||
</form>
|
||||
|
||||
{error && (
|
||||
<Alert icon={<IconAlertTriangle size={rem(16)} />} color="red">
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
type FormType = z.infer<typeof validation.user.signIn>;
|
||||
@@ -0,0 +1,29 @@
|
||||
import { Card, Center, Stack, Text, Title } from "@mantine/core";
|
||||
|
||||
import { getScopedI18n } from "@homarr/translation/server";
|
||||
|
||||
import { HomarrLogoWithTitle } from "~/components/layout/logo/homarr-logo";
|
||||
import { LoginForm } from "./_login-form";
|
||||
|
||||
export default async function Login() {
|
||||
const t = await getScopedI18n("user.page.login");
|
||||
|
||||
return (
|
||||
<Center>
|
||||
<Stack align="center" mt="xl">
|
||||
<HomarrLogoWithTitle size="lg" />
|
||||
<Stack gap={6} align="center">
|
||||
<Title order={3} fw={400} ta="center">
|
||||
{t("title")}
|
||||
</Title>
|
||||
<Text size="sm" c="gray.5" ta="center">
|
||||
{t("subtitle")}
|
||||
</Text>
|
||||
</Stack>
|
||||
<Card bg="dark.8" w={64 * 6} maw="90vw">
|
||||
<LoginForm />
|
||||
</Card>
|
||||
</Stack>
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { api } from "@homarr/api/server";
|
||||
|
||||
import { createBoardContentPage } from "../_creator";
|
||||
|
||||
export default createBoardContentPage<{ locale: string }>({
|
||||
async getInitialBoard() {
|
||||
return await api.board.getDefaultBoard();
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,5 @@
|
||||
import definition from "./_definition";
|
||||
|
||||
const { layout } = definition;
|
||||
|
||||
export default layout;
|
||||
@@ -0,0 +1,7 @@
|
||||
import definition from "./_definition";
|
||||
|
||||
const { generateMetadata, page } = definition;
|
||||
|
||||
export default page;
|
||||
|
||||
export { generateMetadata };
|
||||
@@ -0,0 +1,9 @@
|
||||
import { api } from "@homarr/api/server";
|
||||
|
||||
import { createBoardContentPage } from "../_creator";
|
||||
|
||||
export default createBoardContentPage<{ locale: string; name: string }>({
|
||||
async getInitialBoard({ name }) {
|
||||
return await api.board.getBoardByName({ name });
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,5 @@
|
||||
import definition from "./_definition";
|
||||
|
||||
const { layout } = definition;
|
||||
|
||||
export default layout;
|
||||
@@ -0,0 +1,7 @@
|
||||
import definition from "./_definition";
|
||||
|
||||
const { generateMetadata, page } = definition;
|
||||
|
||||
export default page;
|
||||
|
||||
export { generateMetadata };
|
||||
@@ -0,0 +1,87 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useRef } from "react";
|
||||
import { Box, LoadingOverlay, Stack } from "@mantine/core";
|
||||
|
||||
import type { RouterOutputs } from "@homarr/api";
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
|
||||
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";
|
||||
|
||||
let boardName: string | null = null;
|
||||
|
||||
export const updateBoardName = (name: string | null) => {
|
||||
boardName = name;
|
||||
};
|
||||
|
||||
type UpdateCallback = (
|
||||
prev: RouterOutputs["board"]["getDefaultBoard"],
|
||||
) => RouterOutputs["board"]["getDefaultBoard"];
|
||||
|
||||
export const useUpdateBoard = () => {
|
||||
const utils = clientApi.useUtils();
|
||||
|
||||
const updateBoard = useCallback(
|
||||
(updaterWithoutUndefined: UpdateCallback) => {
|
||||
if (!boardName) {
|
||||
throw new Error("Board name is not set");
|
||||
}
|
||||
utils.board.getBoardByName.setData({ name: boardName }, (previous) =>
|
||||
previous ? updaterWithoutUndefined(previous) : previous,
|
||||
);
|
||||
},
|
||||
[utils],
|
||||
);
|
||||
|
||||
return {
|
||||
updateBoard,
|
||||
};
|
||||
};
|
||||
|
||||
export const ClientBoard = () => {
|
||||
const board = useRequiredBoard();
|
||||
const isReady = useIsBoardReady();
|
||||
|
||||
const sortedSections = board.sections.sort(
|
||||
(sectionA, sectionB) => sectionA.position - sectionB.position,
|
||||
);
|
||||
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
return (
|
||||
<Box h="100%" pos="relative">
|
||||
<BoardBackgroundVideo />
|
||||
<LoadingOverlay
|
||||
visible={!isReady}
|
||||
transitionProps={{ duration: 500 }}
|
||||
loaderProps={{ size: "lg" }}
|
||||
h={fullHeightWithoutHeaderAndFooter}
|
||||
/>
|
||||
<Stack
|
||||
ref={ref}
|
||||
h="100%"
|
||||
style={{ visibility: isReady ? "visible" : "hidden" }}
|
||||
>
|
||||
{sortedSections.map((section) =>
|
||||
section.kind === "empty" ? (
|
||||
<BoardEmptySection
|
||||
key={section.id}
|
||||
section={section}
|
||||
mainRef={ref}
|
||||
/>
|
||||
) : (
|
||||
<BoardCategorySection
|
||||
key={section.id}
|
||||
section={section}
|
||||
mainRef={ref}
|
||||
/>
|
||||
),
|
||||
)}
|
||||
</Stack>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,116 @@
|
||||
"use client";
|
||||
|
||||
import type { PropsWithChildren } 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"]["getDefaultBoard"];
|
||||
isReady: boolean;
|
||||
markAsReady: (id: string) => void;
|
||||
} | 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 { data } = clientApi.board.getBoardByName.useQuery(
|
||||
{ name: initialBoard.name },
|
||||
{
|
||||
initialData: initialBoard,
|
||||
refetchOnMount: false,
|
||||
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,
|
||||
}}
|
||||
>
|
||||
{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;
|
||||
};
|
||||
@@ -0,0 +1,58 @@
|
||||
import type { Metadata } from "next";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
|
||||
import { capitalize } from "@homarr/common";
|
||||
|
||||
// Placed here because gridstack styles are used for board content
|
||||
import "~/styles/gridstack.scss";
|
||||
|
||||
import { createBoardLayout } from "../_layout-creator";
|
||||
import type { Board } from "../_types";
|
||||
import { ClientBoard } from "./_client";
|
||||
import { BoardContentHeaderActions } from "./_header-actions";
|
||||
|
||||
export type Params = Record<string, unknown>;
|
||||
|
||||
interface Props<TParams extends Params> {
|
||||
getInitialBoard: (params: TParams) => Promise<Board>;
|
||||
}
|
||||
|
||||
export const createBoardContentPage = <
|
||||
TParams extends Record<string, unknown>,
|
||||
>({
|
||||
getInitialBoard,
|
||||
}: Props<TParams>) => {
|
||||
return {
|
||||
layout: createBoardLayout({
|
||||
headerActions: <BoardContentHeaderActions />,
|
||||
getInitialBoard,
|
||||
isBoardContentPage: true,
|
||||
}),
|
||||
page: () => {
|
||||
return <ClientBoard />;
|
||||
},
|
||||
generateMetadata: async ({
|
||||
params,
|
||||
}: {
|
||||
params: TParams;
|
||||
}): Promise<Metadata> => {
|
||||
try {
|
||||
const board = await getInitialBoard(params);
|
||||
|
||||
return {
|
||||
title: board.metaTitle ?? `${capitalize(board.name)} board | Homarr`,
|
||||
icons: {
|
||||
icon: board.faviconImageUrl ? board.faviconImageUrl : undefined,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
// Ignore not found errors and return empty metadata
|
||||
if (error instanceof TRPCError && error.code === "NOT_FOUND") {
|
||||
return {};
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,159 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback } from "react";
|
||||
import { Group, Menu } from "@mantine/core";
|
||||
import {
|
||||
IconBox,
|
||||
IconBoxAlignTop,
|
||||
IconChevronDown,
|
||||
IconPackageImport,
|
||||
IconPencil,
|
||||
IconPencilOff,
|
||||
IconPlus,
|
||||
IconSettings,
|
||||
} from "@tabler/icons-react";
|
||||
import { useAtom, useAtomValue } from "jotai";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { useModalAction } from "@homarr/modals";
|
||||
import {
|
||||
showErrorNotification,
|
||||
showSuccessNotification,
|
||||
} from "@homarr/notifications";
|
||||
import { useI18n, useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import { revalidatePathAction } from "~/app/revalidatePathAction";
|
||||
import { editModeAtom } from "~/components/board/editMode";
|
||||
import { ItemSelectModal } from "~/components/board/items/item-select-modal";
|
||||
import { useBoardPermissions } from "~/components/board/permissions/client";
|
||||
import { useCategoryActions } from "~/components/board/sections/category/category-actions";
|
||||
import { CategoryEditModal } from "~/components/board/sections/category/category-edit-modal";
|
||||
import { HeaderButton } from "~/components/layout/header/button";
|
||||
import { useRequiredBoard } from "./_context";
|
||||
|
||||
export const BoardContentHeaderActions = () => {
|
||||
const isEditMode = useAtomValue(editModeAtom);
|
||||
const board = useRequiredBoard();
|
||||
const { hasChangeAccess } = useBoardPermissions(board);
|
||||
|
||||
if (!hasChangeAccess) {
|
||||
return null; // Hide actions for user without access
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{isEditMode && <AddMenu />}
|
||||
|
||||
<EditModeMenu />
|
||||
|
||||
<HeaderButton href={`/boards/${board.name}/settings`}>
|
||||
<IconSettings stroke={1.5} />
|
||||
</HeaderButton>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const AddMenu = () => {
|
||||
const { openModal: openCategoryEditModal } =
|
||||
useModalAction(CategoryEditModal);
|
||||
const { openModal: openItemSelectModal } = useModalAction(ItemSelectModal);
|
||||
const { addCategoryToEnd } = useCategoryActions();
|
||||
const t = useI18n();
|
||||
|
||||
const handleAddCategory = useCallback(
|
||||
() =>
|
||||
openCategoryEditModal(
|
||||
{
|
||||
category: {
|
||||
id: "new",
|
||||
name: "",
|
||||
},
|
||||
onSuccess({ name }) {
|
||||
addCategoryToEnd({ name });
|
||||
},
|
||||
submitLabel: t("section.category.create.submit"),
|
||||
},
|
||||
{
|
||||
title: (t) => t("section.category.create.title"),
|
||||
},
|
||||
),
|
||||
[addCategoryToEnd, openCategoryEditModal, t],
|
||||
);
|
||||
|
||||
const handleSelectItem = useCallback(() => {
|
||||
openItemSelectModal();
|
||||
}, [openItemSelectModal]);
|
||||
|
||||
return (
|
||||
<Menu position="bottom-end" withArrow>
|
||||
<Menu.Target>
|
||||
<HeaderButton w="auto" px={4}>
|
||||
<Group gap={4} wrap="nowrap">
|
||||
<IconPlus stroke={1.5} />
|
||||
<IconChevronDown color="gray" size={16} />
|
||||
</Group>
|
||||
</HeaderButton>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown style={{ transform: "translate(-3px, 0)" }}>
|
||||
<Menu.Item
|
||||
leftSection={<IconBox size={20} />}
|
||||
onClick={handleSelectItem}
|
||||
>
|
||||
{t("item.action.create")}
|
||||
</Menu.Item>
|
||||
<Menu.Item leftSection={<IconPackageImport size={20} />}>
|
||||
{t("item.action.import")}
|
||||
</Menu.Item>
|
||||
|
||||
<Menu.Divider />
|
||||
|
||||
<Menu.Item
|
||||
leftSection={<IconBoxAlignTop size={20} />}
|
||||
onClick={handleAddCategory}
|
||||
>
|
||||
{t("section.category.action.create")}
|
||||
</Menu.Item>
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
);
|
||||
};
|
||||
|
||||
const EditModeMenu = () => {
|
||||
const [isEditMode, setEditMode] = useAtom(editModeAtom);
|
||||
const board = useRequiredBoard();
|
||||
const utils = clientApi.useUtils();
|
||||
const t = useScopedI18n("board.action.edit");
|
||||
const { mutate: saveBoard, isPending } =
|
||||
clientApi.board.saveBoard.useMutation({
|
||||
onSuccess() {
|
||||
showSuccessNotification({
|
||||
title: t("notification.success.title"),
|
||||
message: t("notification.success.message"),
|
||||
});
|
||||
void utils.board.getBoardByName.invalidate({ name: board.name });
|
||||
void revalidatePathAction(`/boards/${board.name}`);
|
||||
setEditMode(false);
|
||||
},
|
||||
onError() {
|
||||
showErrorNotification({
|
||||
title: t("notification.error.title"),
|
||||
message: t("notification.error.message"),
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const toggle = useCallback(() => {
|
||||
if (isEditMode) return saveBoard(board);
|
||||
setEditMode(true);
|
||||
}, [board, isEditMode, saveBoard, setEditMode]);
|
||||
|
||||
return (
|
||||
<HeaderButton onClick={toggle} loading={isPending}>
|
||||
{isEditMode ? (
|
||||
<IconPencilOff stroke={1.5} />
|
||||
) : (
|
||||
<IconPencil stroke={1.5} />
|
||||
)}
|
||||
</HeaderButton>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,49 @@
|
||||
"use client";
|
||||
|
||||
import type { PropsWithChildren } from "react";
|
||||
import type { MantineColorsTuple } from "@mantine/core";
|
||||
import { createTheme, darken, lighten, MantineProvider } from "@mantine/core";
|
||||
|
||||
import { useRequiredBoard } from "./_context";
|
||||
|
||||
export const BoardMantineProvider = ({ children }: PropsWithChildren) => {
|
||||
const board = useRequiredBoard();
|
||||
|
||||
const theme = createTheme({
|
||||
colors: {
|
||||
primaryColor: generateColors(board.primaryColor),
|
||||
secondaryColor: generateColors(board.secondaryColor),
|
||||
},
|
||||
primaryColor: "primaryColor",
|
||||
autoContrast: true,
|
||||
});
|
||||
|
||||
return <MantineProvider theme={theme}>{children}</MantineProvider>;
|
||||
};
|
||||
|
||||
export const generateColors = (hex: string) => {
|
||||
const lightnessForColors = [
|
||||
-0.25, -0.2, -0.15, -0.1, -0.05, 0, 0.05, 0.1, 0.15, 0.2,
|
||||
] as const;
|
||||
const rgbaColors = lightnessForColors.map((lightness) => {
|
||||
if (lightness < 0) {
|
||||
return lighten(hex, -lightness);
|
||||
}
|
||||
return darken(hex, lightness);
|
||||
});
|
||||
|
||||
return rgbaColors.map((color) => {
|
||||
return (
|
||||
"#" +
|
||||
color
|
||||
.split("(")[1]!
|
||||
.replaceAll(" ", "")
|
||||
.replace(")", "")
|
||||
.split(",")
|
||||
.map((color) => parseInt(color, 10))
|
||||
.slice(0, 3)
|
||||
.map((color) => color.toString(16).padStart(2, "0"))
|
||||
.join("")
|
||||
);
|
||||
}) as unknown as MantineColorsTuple;
|
||||
};
|
||||
@@ -0,0 +1,12 @@
|
||||
import { api } from "@homarr/api/server";
|
||||
|
||||
import { BoardOtherHeaderActions } from "../_header-actions";
|
||||
import { createBoardLayout } from "../_layout-creator";
|
||||
|
||||
export default createBoardLayout<{ locale: string; name: string }>({
|
||||
headerActions: <BoardOtherHeaderActions />,
|
||||
async getInitialBoard({ name }) {
|
||||
return await api.board.getBoardByName({ name });
|
||||
},
|
||||
isBoardContentPage: false,
|
||||
});
|
||||
@@ -0,0 +1,105 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Group, Stack, Tabs } from "@mantine/core";
|
||||
import { IconUser, IconUserDown, IconUsersGroup } from "@tabler/icons-react";
|
||||
|
||||
import type { RouterOutputs } from "@homarr/api";
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
import type { TablerIcon } from "@homarr/ui";
|
||||
import { CountBadge } from "@homarr/ui";
|
||||
|
||||
import type { Board } from "../../_types";
|
||||
import { GroupsForm } from "./_access/group-access";
|
||||
import { InheritTable } from "./_access/inherit-access";
|
||||
import { UsersForm } from "./_access/user-access";
|
||||
|
||||
interface Props {
|
||||
board: Board;
|
||||
initialPermissions: RouterOutputs["board"]["getBoardPermissions"];
|
||||
}
|
||||
|
||||
export const AccessSettingsContent = ({ board, initialPermissions }: Props) => {
|
||||
const { data: permissions } = clientApi.board.getBoardPermissions.useQuery(
|
||||
{
|
||||
id: board.id,
|
||||
},
|
||||
{
|
||||
initialData: initialPermissions,
|
||||
refetchOnMount: false,
|
||||
refetchOnWindowFocus: false,
|
||||
refetchOnReconnect: false,
|
||||
},
|
||||
);
|
||||
|
||||
const [counts, setCounts] = useState({
|
||||
user: initialPermissions.userPermissions.length + (board.creator ? 1 : 0),
|
||||
group: initialPermissions.groupPermissions.length,
|
||||
});
|
||||
|
||||
return (
|
||||
<Stack>
|
||||
<Tabs color="red" defaultValue="user">
|
||||
<Tabs.List grow>
|
||||
<TabItem value="user" count={counts.user} icon={IconUser} />
|
||||
<TabItem value="group" count={counts.group} icon={IconUsersGroup} />
|
||||
<TabItem
|
||||
value="inherited"
|
||||
count={initialPermissions.inherited.length}
|
||||
icon={IconUserDown}
|
||||
/>
|
||||
</Tabs.List>
|
||||
|
||||
<Tabs.Panel value="user">
|
||||
<UsersForm
|
||||
board={board}
|
||||
initialPermissions={permissions}
|
||||
onCountChange={(callback) =>
|
||||
setCounts(({ user, ...others }) => ({
|
||||
user: callback(user),
|
||||
...others,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</Tabs.Panel>
|
||||
|
||||
<Tabs.Panel value="group">
|
||||
<GroupsForm
|
||||
board={board}
|
||||
initialPermissions={permissions}
|
||||
onCountChange={(callback) =>
|
||||
setCounts(({ group, ...others }) => ({
|
||||
group: callback(group),
|
||||
...others,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</Tabs.Panel>
|
||||
|
||||
<Tabs.Panel value="inherited">
|
||||
<InheritTable initialPermissions={permissions} />
|
||||
</Tabs.Panel>
|
||||
</Tabs>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
interface TabItemProps {
|
||||
value: "user" | "group" | "inherited";
|
||||
count: number;
|
||||
icon: TablerIcon;
|
||||
}
|
||||
|
||||
const TabItem = ({ value, icon: Icon, count }: TabItemProps) => {
|
||||
const t = useScopedI18n("board.setting.section.access.permission");
|
||||
|
||||
return (
|
||||
<Tabs.Tab value={value} leftSection={<Icon stroke={1.5} size={16} />}>
|
||||
<Group gap="sm">
|
||||
{t(`tab.${value}`)}
|
||||
<CountBadge count={count} />
|
||||
</Group>
|
||||
</Tabs.Tab>
|
||||
);
|
||||
};
|
||||
+139
@@ -0,0 +1,139 @@
|
||||
import { useCallback } from "react";
|
||||
import type { ReactNode } from "react";
|
||||
import type { SelectProps } from "@mantine/core";
|
||||
import {
|
||||
Button,
|
||||
Flex,
|
||||
Group,
|
||||
Select,
|
||||
TableTd,
|
||||
TableTr,
|
||||
Text,
|
||||
} from "@mantine/core";
|
||||
import {
|
||||
IconCheck,
|
||||
IconEye,
|
||||
IconPencil,
|
||||
IconSettings,
|
||||
} from "@tabler/icons-react";
|
||||
|
||||
import type { BoardPermission } from "@homarr/definitions";
|
||||
import { boardPermissions } from "@homarr/definitions";
|
||||
import { useI18n, useScopedI18n } from "@homarr/translation/client";
|
||||
import type { TablerIcon } from "@homarr/ui";
|
||||
|
||||
import type { OnCountChange } from "./form";
|
||||
import { useFormContext } from "./form";
|
||||
|
||||
const icons = {
|
||||
"board-change": IconPencil,
|
||||
"board-view": IconEye,
|
||||
"board-full": IconSettings,
|
||||
} satisfies Record<BoardPermission | "board-full", TablerIcon>;
|
||||
|
||||
interface BoardAccessSelectRowProps {
|
||||
itemContent: ReactNode;
|
||||
permission: BoardPermission;
|
||||
index: number;
|
||||
onCountChange: OnCountChange;
|
||||
}
|
||||
|
||||
export const BoardAccessSelectRow = ({
|
||||
itemContent,
|
||||
permission,
|
||||
index,
|
||||
onCountChange,
|
||||
}: BoardAccessSelectRowProps) => {
|
||||
const tRoot = useI18n();
|
||||
const tPermissions = useScopedI18n("board.setting.section.access.permission");
|
||||
const form = useFormContext();
|
||||
const Icon = icons[permission];
|
||||
|
||||
const handleRemove = useCallback(() => {
|
||||
form.setFieldValue(
|
||||
"items",
|
||||
form.values.items.filter((_, i) => i !== index),
|
||||
);
|
||||
onCountChange((prev) => prev - 1);
|
||||
}, [form, index, onCountChange]);
|
||||
|
||||
return (
|
||||
<TableTr>
|
||||
<TableTd w={{ sm: 128, lg: 256 }}>{itemContent}</TableTd>
|
||||
<TableTd>
|
||||
<Flex
|
||||
direction={{ base: "column", xs: "row" }}
|
||||
align={{ base: "end", xs: "center" }}
|
||||
wrap="nowrap"
|
||||
>
|
||||
<Select
|
||||
allowDeselect={false}
|
||||
flex="1"
|
||||
leftSection={<Icon size="1rem" />}
|
||||
renderOption={RenderOption}
|
||||
variant="unstyled"
|
||||
data={boardPermissions.map((permission) => ({
|
||||
value: permission,
|
||||
label: tPermissions(`item.${permission}.label`),
|
||||
}))}
|
||||
{...form.getInputProps(`items.${index}.permission`)}
|
||||
/>
|
||||
|
||||
<Button size="xs" variant="subtle" onClick={handleRemove}>
|
||||
{tRoot("common.action.remove")}
|
||||
</Button>
|
||||
</Flex>
|
||||
</TableTd>
|
||||
</TableTr>
|
||||
);
|
||||
};
|
||||
|
||||
interface BoardAccessDisplayRowProps {
|
||||
itemContent: ReactNode;
|
||||
permission: BoardPermission | "board-full";
|
||||
}
|
||||
|
||||
export const BoardAccessDisplayRow = ({
|
||||
itemContent,
|
||||
permission,
|
||||
}: BoardAccessDisplayRowProps) => {
|
||||
const tPermissions = useScopedI18n("board.setting.section.access.permission");
|
||||
const Icon = icons[permission];
|
||||
|
||||
return (
|
||||
<TableTr>
|
||||
<TableTd w={{ sm: 128, lg: 256 }}>{itemContent}</TableTd>
|
||||
<TableTd>
|
||||
<Group gap={0}>
|
||||
<Flex w={34} h={34} align="center" justify="center">
|
||||
<Icon
|
||||
size="1rem"
|
||||
color="var(--input-section-color, var(--mantine-color-dimmed))"
|
||||
/>
|
||||
</Flex>
|
||||
<Text size="sm">{tPermissions(`item.${permission}.label`)}</Text>
|
||||
</Group>
|
||||
</TableTd>
|
||||
</TableTr>
|
||||
);
|
||||
};
|
||||
|
||||
const iconProps = {
|
||||
stroke: 1.5,
|
||||
color: "currentColor",
|
||||
opacity: 0.6,
|
||||
size: "1rem",
|
||||
};
|
||||
|
||||
const RenderOption: SelectProps["renderOption"] = ({ option, checked }) => {
|
||||
const Icon = icons[option.value as BoardPermission];
|
||||
return (
|
||||
<Group flex="1" gap="xs" wrap="nowrap">
|
||||
<Icon {...iconProps} />
|
||||
{option.label}
|
||||
{checked && (
|
||||
<IconCheck style={{ marginInlineStart: "auto" }} {...iconProps} />
|
||||
)}
|
||||
</Group>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,14 @@
|
||||
import type { BoardPermission } from "@homarr/definitions";
|
||||
import { createFormContext } from "@homarr/form";
|
||||
|
||||
export interface BoardAccessFormType {
|
||||
items: {
|
||||
itemId: string;
|
||||
permission: BoardPermission;
|
||||
}[];
|
||||
}
|
||||
|
||||
export const [FormProvider, useFormContext, useForm] =
|
||||
createFormContext<BoardAccessFormType>();
|
||||
|
||||
export type OnCountChange = (callback: (prev: number) => number) => void;
|
||||
@@ -0,0 +1,148 @@
|
||||
import { useCallback, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import {
|
||||
Anchor,
|
||||
Button,
|
||||
Group,
|
||||
Stack,
|
||||
Table,
|
||||
TableTbody,
|
||||
TableTh,
|
||||
TableThead,
|
||||
TableTr,
|
||||
} from "@mantine/core";
|
||||
import { IconPlus } from "@tabler/icons-react";
|
||||
|
||||
import type { RouterOutputs } from "@homarr/api";
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { useModalAction } from "@homarr/modals";
|
||||
import { useI18n, useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import { BoardAccessSelectRow } from "./board-access-table-rows";
|
||||
import type { BoardAccessFormType } from "./form";
|
||||
import { FormProvider, useForm } from "./form";
|
||||
import { GroupSelectModal } from "./group-select-modal";
|
||||
import type { FormProps } from "./user-access";
|
||||
|
||||
export const GroupsForm = ({
|
||||
board,
|
||||
initialPermissions,
|
||||
onCountChange,
|
||||
}: FormProps) => {
|
||||
const { mutate, isPending } =
|
||||
clientApi.board.saveGroupBoardPermissions.useMutation();
|
||||
const utils = clientApi.useUtils();
|
||||
const [groups, setGroups] = useState<Map<string, Group>>(
|
||||
new Map(
|
||||
initialPermissions.groupPermissions.map(({ group }) => [group.id, group]),
|
||||
),
|
||||
);
|
||||
const { openModal } = useModalAction(GroupSelectModal);
|
||||
const t = useI18n();
|
||||
const tPermissions = useScopedI18n("board.setting.section.access.permission");
|
||||
const form = useForm({
|
||||
initialValues: {
|
||||
items: initialPermissions.groupPermissions.map(
|
||||
({ group, permission }) => ({
|
||||
itemId: group.id,
|
||||
permission,
|
||||
}),
|
||||
),
|
||||
},
|
||||
});
|
||||
|
||||
const handleSubmit = useCallback(
|
||||
(values: BoardAccessFormType) => {
|
||||
mutate(
|
||||
{
|
||||
id: board.id,
|
||||
permissions: values.items,
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
void utils.board.getBoardPermissions.invalidate();
|
||||
},
|
||||
},
|
||||
);
|
||||
},
|
||||
[board.id, mutate, utils.board.getBoardPermissions],
|
||||
);
|
||||
|
||||
const handleAddUser = useCallback(() => {
|
||||
openModal({
|
||||
presentGroupIds: form.values.items.map(({ itemId: id }) => id),
|
||||
onSelect: (group) => {
|
||||
setGroups((prev) => new Map(prev).set(group.id, group));
|
||||
form.setFieldValue("items", [
|
||||
{
|
||||
itemId: group.id,
|
||||
permission: "board-view",
|
||||
},
|
||||
...form.values.items,
|
||||
]);
|
||||
onCountChange((prev) => prev + 1);
|
||||
},
|
||||
});
|
||||
}, [form, openModal, onCountChange]);
|
||||
|
||||
return (
|
||||
<form onSubmit={form.onSubmit(handleSubmit)}>
|
||||
<FormProvider form={form}>
|
||||
<Stack pt="sm">
|
||||
<Table>
|
||||
<TableThead>
|
||||
<TableTr>
|
||||
<TableTh style={{ whiteSpace: "nowrap" }}>
|
||||
{tPermissions("field.group.label")}
|
||||
</TableTh>
|
||||
<TableTh>{tPermissions("field.permission.label")}</TableTh>
|
||||
</TableTr>
|
||||
</TableThead>
|
||||
<TableTbody>
|
||||
{form.values.items.map((row, index) => (
|
||||
<BoardAccessSelectRow
|
||||
key={row.itemId}
|
||||
itemContent={
|
||||
<GroupItemContent group={groups.get(row.itemId)!} />
|
||||
}
|
||||
permission={row.permission}
|
||||
index={index}
|
||||
onCountChange={onCountChange}
|
||||
/>
|
||||
))}
|
||||
</TableTbody>
|
||||
</Table>
|
||||
|
||||
<Group justify="space-between">
|
||||
<Button
|
||||
rightSection={<IconPlus size="1rem" />}
|
||||
variant="light"
|
||||
onClick={handleAddUser}
|
||||
>
|
||||
{t("common.action.add")}
|
||||
</Button>
|
||||
<Button type="submit" loading={isPending} color="teal">
|
||||
{t("common.action.saveChanges")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</FormProvider>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
export const GroupItemContent = ({ group }: { group: Group }) => {
|
||||
return (
|
||||
<Anchor
|
||||
component={Link}
|
||||
href={`/manage/users/groups/${group.id}`}
|
||||
size="sm"
|
||||
style={{ whiteSpace: "nowrap" }}
|
||||
>
|
||||
{group.name}
|
||||
</Anchor>
|
||||
);
|
||||
};
|
||||
|
||||
type Group =
|
||||
RouterOutputs["board"]["getBoardPermissions"]["groupPermissions"][0]["group"];
|
||||
@@ -0,0 +1,72 @@
|
||||
import { useState } from "react";
|
||||
import { Button, Group, Loader, Select, Stack } from "@mantine/core";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { useForm } from "@homarr/form";
|
||||
import { createModal } from "@homarr/modals";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
|
||||
interface InnerProps {
|
||||
presentGroupIds: string[];
|
||||
onSelect: (props: { id: string; name: string }) => void | Promise<void>;
|
||||
confirmLabel?: string;
|
||||
}
|
||||
|
||||
interface GroupSelectFormType {
|
||||
groupId: string;
|
||||
}
|
||||
|
||||
export const GroupSelectModal = createModal<InnerProps>(
|
||||
({ actions, innerProps }) => {
|
||||
const t = useI18n();
|
||||
const { data: groups, isPending } = clientApi.group.selectable.useQuery();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const form = useForm<GroupSelectFormType>();
|
||||
const handleSubmit = async (values: GroupSelectFormType) => {
|
||||
const currentGroup = groups?.find((group) => group.id === values.groupId);
|
||||
if (!currentGroup) return;
|
||||
setLoading(true);
|
||||
await innerProps.onSelect({
|
||||
id: currentGroup.id,
|
||||
name: currentGroup.name,
|
||||
});
|
||||
|
||||
setLoading(false);
|
||||
actions.closeModal();
|
||||
};
|
||||
|
||||
const confirmLabel = innerProps.confirmLabel ?? t("common.action.add");
|
||||
|
||||
return (
|
||||
<form onSubmit={form.onSubmit((values) => void handleSubmit(values))}>
|
||||
<Stack>
|
||||
<Select
|
||||
{...form.getInputProps("groupId")}
|
||||
label={t("group.action.select.label")}
|
||||
clearable
|
||||
searchable
|
||||
leftSection={isPending ? <Loader size="xs" /> : undefined}
|
||||
nothingFoundMessage={t("group.action.select.notFound")}
|
||||
limit={5}
|
||||
data={groups
|
||||
?.filter(
|
||||
(group) => !innerProps.presentGroupIds.includes(group.id),
|
||||
)
|
||||
.map((group) => ({ value: group.id, label: group.name }))}
|
||||
/>
|
||||
<Group justify="end">
|
||||
<Button variant="default" onClick={actions.closeModal}>
|
||||
{t("common.action.cancel")}
|
||||
</Button>
|
||||
<Button type="submit" loading={loading}>
|
||||
{confirmLabel}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</form>
|
||||
);
|
||||
},
|
||||
).withOptions({
|
||||
defaultTitle: (t) =>
|
||||
t("board.setting.section.access.permission.groupSelect.title"),
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
import {
|
||||
Stack,
|
||||
Table,
|
||||
TableTbody,
|
||||
TableTh,
|
||||
TableThead,
|
||||
TableTr,
|
||||
} from "@mantine/core";
|
||||
|
||||
import type { RouterOutputs } from "@homarr/api";
|
||||
import { getPermissionsWithChildren } from "@homarr/definitions";
|
||||
import type { BoardPermission, GroupPermissionKey } from "@homarr/definitions";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import { BoardAccessDisplayRow } from "./board-access-table-rows";
|
||||
import { GroupItemContent } from "./group-access";
|
||||
|
||||
export interface InheritTableProps {
|
||||
initialPermissions: RouterOutputs["board"]["getBoardPermissions"];
|
||||
}
|
||||
|
||||
const mapPermissions = {
|
||||
"board-full-access": "board-full",
|
||||
"board-modify-all": "board-change",
|
||||
"board-view-all": "board-view",
|
||||
} satisfies Partial<Record<GroupPermissionKey, BoardPermission | "board-full">>;
|
||||
|
||||
export const InheritTable = ({ initialPermissions }: InheritTableProps) => {
|
||||
const tPermissions = useScopedI18n("board.setting.section.access.permission");
|
||||
return (
|
||||
<Stack pt="sm">
|
||||
<Table>
|
||||
<TableThead>
|
||||
<TableTr>
|
||||
<TableTh>{tPermissions("field.user.label")}</TableTh>
|
||||
<TableTh>{tPermissions("field.permission.label")}</TableTh>
|
||||
</TableTr>
|
||||
</TableThead>
|
||||
<TableTbody>
|
||||
{initialPermissions.inherited.map(({ group, permission }) => {
|
||||
const boardPermission =
|
||||
permission in mapPermissions
|
||||
? mapPermissions[permission as keyof typeof mapPermissions]
|
||||
: getPermissionsWithChildren([permission]).includes(
|
||||
"board-full-access",
|
||||
)
|
||||
? "board-full"
|
||||
: null;
|
||||
|
||||
if (!boardPermission) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<BoardAccessDisplayRow
|
||||
key={group.id}
|
||||
itemContent={<GroupItemContent group={group} />}
|
||||
permission={boardPermission}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</TableTbody>
|
||||
</Table>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,173 @@
|
||||
import { useCallback, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import {
|
||||
Anchor,
|
||||
Box,
|
||||
Button,
|
||||
Group,
|
||||
Stack,
|
||||
Table,
|
||||
TableTbody,
|
||||
TableTh,
|
||||
TableThead,
|
||||
TableTr,
|
||||
} from "@mantine/core";
|
||||
import { IconPlus } from "@tabler/icons-react";
|
||||
|
||||
import type { RouterOutputs } from "@homarr/api";
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { useModalAction } from "@homarr/modals";
|
||||
import { useI18n, useScopedI18n } from "@homarr/translation/client";
|
||||
import { UserAvatar } from "@homarr/ui";
|
||||
|
||||
import type { Board } from "../../../_types";
|
||||
import {
|
||||
BoardAccessDisplayRow,
|
||||
BoardAccessSelectRow,
|
||||
} from "./board-access-table-rows";
|
||||
import type { BoardAccessFormType, OnCountChange } from "./form";
|
||||
import { FormProvider, useForm } from "./form";
|
||||
import { UserSelectModal } from "./user-select-modal";
|
||||
|
||||
export interface FormProps {
|
||||
board: Pick<Board, "id" | "creatorId" | "creator">;
|
||||
initialPermissions: RouterOutputs["board"]["getBoardPermissions"];
|
||||
onCountChange: OnCountChange;
|
||||
}
|
||||
|
||||
export const UsersForm = ({
|
||||
board,
|
||||
initialPermissions,
|
||||
onCountChange,
|
||||
}: FormProps) => {
|
||||
const { mutate, isPending } =
|
||||
clientApi.board.saveUserBoardPermissions.useMutation();
|
||||
const utils = clientApi.useUtils();
|
||||
const [users, setUsers] = useState<Map<string, User>>(
|
||||
new Map(
|
||||
initialPermissions.userPermissions.map(({ user }) => [user.id, user]),
|
||||
),
|
||||
);
|
||||
const { openModal } = useModalAction(UserSelectModal);
|
||||
const t = useI18n();
|
||||
const tPermissions = useScopedI18n("board.setting.section.access.permission");
|
||||
const form = useForm({
|
||||
initialValues: {
|
||||
items: initialPermissions.userPermissions.map(({ user, permission }) => ({
|
||||
itemId: user.id,
|
||||
permission,
|
||||
})),
|
||||
},
|
||||
});
|
||||
|
||||
const handleSubmit = useCallback(
|
||||
(values: BoardAccessFormType) => {
|
||||
mutate(
|
||||
{
|
||||
id: board.id,
|
||||
permissions: values.items,
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
void utils.board.getBoardPermissions.invalidate();
|
||||
},
|
||||
},
|
||||
);
|
||||
},
|
||||
[board.id, mutate, utils.board.getBoardPermissions],
|
||||
);
|
||||
|
||||
const handleAddUser = useCallback(() => {
|
||||
const presentUserIds = form.values.items.map(({ itemId: id }) => id);
|
||||
|
||||
openModal({
|
||||
presentUserIds: board.creatorId
|
||||
? presentUserIds.concat(board.creatorId)
|
||||
: presentUserIds,
|
||||
onSelect: (user) => {
|
||||
setUsers((prev) => new Map(prev).set(user.id, user));
|
||||
form.setFieldValue("items", [
|
||||
{
|
||||
itemId: user.id,
|
||||
permission: "board-view",
|
||||
},
|
||||
...form.values.items,
|
||||
]);
|
||||
onCountChange((prev) => prev + 1);
|
||||
},
|
||||
});
|
||||
}, [form, openModal, board.creatorId, onCountChange]);
|
||||
|
||||
return (
|
||||
<form onSubmit={form.onSubmit(handleSubmit)}>
|
||||
<FormProvider form={form}>
|
||||
<Stack pt="sm">
|
||||
<Table>
|
||||
<TableThead>
|
||||
<TableTr>
|
||||
<TableTh>{tPermissions("field.user.label")}</TableTh>
|
||||
<TableTh>{tPermissions("field.permission.label")}</TableTh>
|
||||
</TableTr>
|
||||
</TableThead>
|
||||
<TableTbody>
|
||||
{board.creator && (
|
||||
<BoardAccessDisplayRow
|
||||
itemContent={<UserItemContent user={board.creator} />}
|
||||
permission="board-full"
|
||||
/>
|
||||
)}
|
||||
{form.values.items.map((row, index) => (
|
||||
<BoardAccessSelectRow
|
||||
key={row.itemId}
|
||||
itemContent={
|
||||
<UserItemContent user={users.get(row.itemId)!} />
|
||||
}
|
||||
permission={row.permission}
|
||||
index={index}
|
||||
onCountChange={onCountChange}
|
||||
/>
|
||||
))}
|
||||
</TableTbody>
|
||||
</Table>
|
||||
|
||||
<Group justify="space-between">
|
||||
<Button
|
||||
rightSection={<IconPlus size="1rem" />}
|
||||
variant="light"
|
||||
onClick={handleAddUser}
|
||||
>
|
||||
{t("common.action.add")}
|
||||
</Button>
|
||||
<Button type="submit" loading={isPending} color="teal">
|
||||
{t("common.action.saveChanges")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</FormProvider>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
const UserItemContent = ({ user }: { user: User }) => {
|
||||
return (
|
||||
<Group wrap="nowrap">
|
||||
<Box visibleFrom="xs">
|
||||
<UserAvatar user={user} size="sm" />
|
||||
</Box>
|
||||
<Anchor
|
||||
component={Link}
|
||||
href={`/manage/users/${user.id}`}
|
||||
size="sm"
|
||||
style={{ whiteSpace: "nowrap" }}
|
||||
>
|
||||
{user.name}
|
||||
</Anchor>
|
||||
</Group>
|
||||
);
|
||||
};
|
||||
|
||||
interface User {
|
||||
id: string;
|
||||
name: string | null;
|
||||
image: string | null;
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import { useState } from "react";
|
||||
import type { SelectProps } from "@mantine/core";
|
||||
import { Button, Group, Loader, Select, Stack } from "@mantine/core";
|
||||
import { IconCheck } from "@tabler/icons-react";
|
||||
|
||||
import type { RouterOutputs } from "@homarr/api";
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { useForm } from "@homarr/form";
|
||||
import { createModal } from "@homarr/modals";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import { UserAvatar } from "@homarr/ui";
|
||||
|
||||
interface InnerProps {
|
||||
presentUserIds: string[];
|
||||
onSelect: (props: {
|
||||
id: string;
|
||||
name: string;
|
||||
image: string;
|
||||
}) => void | Promise<void>;
|
||||
confirmLabel?: string;
|
||||
}
|
||||
|
||||
interface UserSelectFormType {
|
||||
userId: string;
|
||||
}
|
||||
|
||||
export const UserSelectModal = createModal<InnerProps>(
|
||||
({ actions, innerProps }) => {
|
||||
const t = useI18n();
|
||||
const { data: users, isPending } = clientApi.user.selectable.useQuery();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const form = useForm<UserSelectFormType>();
|
||||
const handleSubmit = async (values: UserSelectFormType) => {
|
||||
const currentUser = users?.find((user) => user.id === values.userId);
|
||||
if (!currentUser) return;
|
||||
setLoading(true);
|
||||
await innerProps.onSelect({
|
||||
id: currentUser.id,
|
||||
name: currentUser.name ?? "",
|
||||
image: currentUser.image ?? "",
|
||||
});
|
||||
|
||||
setLoading(false);
|
||||
actions.closeModal();
|
||||
};
|
||||
|
||||
const confirmLabel = innerProps.confirmLabel ?? t("common.action.add");
|
||||
const currentUser = users?.find((user) => user.id === form.values.userId);
|
||||
|
||||
return (
|
||||
<form onSubmit={form.onSubmit((values) => void handleSubmit(values))}>
|
||||
<Stack>
|
||||
<Select
|
||||
{...form.getInputProps("userId")}
|
||||
label={t("user.action.select.label")}
|
||||
searchable
|
||||
clearable
|
||||
leftSection={
|
||||
isPending ? (
|
||||
<Loader size="xs" />
|
||||
) : currentUser ? (
|
||||
<UserAvatar user={currentUser} size="xs" />
|
||||
) : undefined
|
||||
}
|
||||
nothingFoundMessage={t("user.action.select.notFound")}
|
||||
renderOption={createRenderOption(users ?? [])}
|
||||
limit={5}
|
||||
data={users
|
||||
?.filter((user) => !innerProps.presentUserIds.includes(user.id))
|
||||
.map((user) => ({ value: user.id, label: user.name ?? "" }))}
|
||||
/>
|
||||
<Group justify="end">
|
||||
<Button variant="default" onClick={actions.closeModal}>
|
||||
{t("common.action.cancel")}
|
||||
</Button>
|
||||
<Button type="submit" loading={loading}>
|
||||
{confirmLabel}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</form>
|
||||
);
|
||||
},
|
||||
).withOptions({
|
||||
defaultTitle: (t) =>
|
||||
t("board.setting.section.access.permission.userSelect.title"),
|
||||
});
|
||||
|
||||
const iconProps = {
|
||||
stroke: 1.5,
|
||||
color: "currentColor",
|
||||
opacity: 0.6,
|
||||
size: "1rem",
|
||||
};
|
||||
|
||||
const createRenderOption = (
|
||||
users: RouterOutputs["user"]["selectable"],
|
||||
): SelectProps["renderOption"] =>
|
||||
function InnerRenderRoot({ option, checked }) {
|
||||
const user = users.find((user) => user.id === option.value);
|
||||
if (!user) return null;
|
||||
|
||||
return (
|
||||
<Group flex="1" gap="xs">
|
||||
<UserAvatar user={user} size="xs" />
|
||||
{option.label}
|
||||
{checked && (
|
||||
<IconCheck style={{ marginInlineStart: "auto" }} {...iconProps} />
|
||||
)}
|
||||
</Group>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,135 @@
|
||||
"use client";
|
||||
|
||||
import { Button, Grid, Group, Stack, TextInput } from "@mantine/core";
|
||||
|
||||
import {
|
||||
backgroundImageAttachments,
|
||||
backgroundImageRepeats,
|
||||
backgroundImageSizes,
|
||||
} from "@homarr/definitions";
|
||||
import { useForm } from "@homarr/form";
|
||||
import type { TranslationObject } from "@homarr/translation";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import type { SelectItemWithDescriptionBadge } from "@homarr/ui";
|
||||
import { SelectWithDescriptionBadge } from "@homarr/ui";
|
||||
|
||||
import type { Board } from "../../_types";
|
||||
import { useSavePartialSettingsMutation } from "./_shared";
|
||||
|
||||
interface Props {
|
||||
board: Board;
|
||||
}
|
||||
export const BackgroundSettingsContent = ({ board }: Props) => {
|
||||
const t = useI18n();
|
||||
const { mutate: savePartialSettings, isPending } =
|
||||
useSavePartialSettingsMutation(board);
|
||||
const form = useForm({
|
||||
initialValues: {
|
||||
backgroundImageUrl: board.backgroundImageUrl ?? "",
|
||||
backgroundImageAttachment: board.backgroundImageAttachment,
|
||||
backgroundImageRepeat: board.backgroundImageRepeat,
|
||||
backgroundImageSize: board.backgroundImageSize,
|
||||
},
|
||||
});
|
||||
|
||||
const backgroundImageAttachmentData = useBackgroundOptionData(
|
||||
"backgroundImageAttachment",
|
||||
backgroundImageAttachments,
|
||||
);
|
||||
const backgroundImageSizeData = useBackgroundOptionData(
|
||||
"backgroundImageSize",
|
||||
backgroundImageSizes,
|
||||
);
|
||||
const backgroundImageRepeatData = useBackgroundOptionData(
|
||||
"backgroundImageRepeat",
|
||||
backgroundImageRepeats,
|
||||
);
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={form.onSubmit((values) => {
|
||||
savePartialSettings({
|
||||
id: board.id,
|
||||
...values,
|
||||
});
|
||||
})}
|
||||
>
|
||||
<Stack>
|
||||
<Grid>
|
||||
<Grid.Col span={12}>
|
||||
<TextInput
|
||||
label={t("board.field.backgroundImageUrl.label")}
|
||||
{...form.getInputProps("backgroundImageUrl")}
|
||||
/>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={12}>
|
||||
<SelectWithDescriptionBadge
|
||||
label={t("board.field.backgroundImageAttachment.label")}
|
||||
data={backgroundImageAttachmentData}
|
||||
{...form.getInputProps("backgroundImageAttachment")}
|
||||
/>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={12}>
|
||||
<SelectWithDescriptionBadge
|
||||
label={t("board.field.backgroundImageSize.label")}
|
||||
data={backgroundImageSizeData}
|
||||
{...form.getInputProps("backgroundImageSize")}
|
||||
/>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={12}>
|
||||
<SelectWithDescriptionBadge
|
||||
label={t("board.field.backgroundImageRepeat.label")}
|
||||
data={backgroundImageRepeatData}
|
||||
{...form.getInputProps("backgroundImageRepeat")}
|
||||
/>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
|
||||
<Group justify="end">
|
||||
<Button type="submit" loading={isPending} color="teal">
|
||||
{t("common.action.saveChanges")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
type BackgroundImageKey =
|
||||
| "backgroundImageAttachment"
|
||||
| "backgroundImageSize"
|
||||
| "backgroundImageRepeat";
|
||||
|
||||
type inferOptions<TKey extends BackgroundImageKey> =
|
||||
TranslationObject["board"]["field"][TKey]["option"];
|
||||
|
||||
const useBackgroundOptionData = <
|
||||
TKey extends BackgroundImageKey,
|
||||
TOptions extends inferOptions<TKey> = inferOptions<TKey>,
|
||||
>(
|
||||
key: TKey,
|
||||
data: {
|
||||
values: (keyof TOptions)[];
|
||||
defaultValue: keyof TOptions;
|
||||
},
|
||||
) => {
|
||||
const t = useI18n();
|
||||
|
||||
return data.values.map(
|
||||
(value) =>
|
||||
({
|
||||
label: t(`board.field.${key}.option.${value as string}.label` as never),
|
||||
description: t(
|
||||
`board.field.${key}.option.${value as string}.description` as never,
|
||||
),
|
||||
value: value as string,
|
||||
badge:
|
||||
data.defaultValue === value
|
||||
? {
|
||||
color: "blue",
|
||||
label: t("common.select.badge.recommended"),
|
||||
}
|
||||
: undefined,
|
||||
}) satisfies SelectItemWithDescriptionBadge,
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,158 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Anchor,
|
||||
Button,
|
||||
Collapse,
|
||||
ColorInput,
|
||||
ColorSwatch,
|
||||
Grid,
|
||||
Group,
|
||||
InputWrapper,
|
||||
isLightColor,
|
||||
Slider,
|
||||
Stack,
|
||||
Text,
|
||||
useMantineTheme,
|
||||
} from "@mantine/core";
|
||||
import { useDisclosure } from "@mantine/hooks";
|
||||
|
||||
import { useForm } from "@homarr/form";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
|
||||
import type { Board } from "../../_types";
|
||||
import { generateColors } from "../../(content)/_theme";
|
||||
import { useSavePartialSettingsMutation } from "./_shared";
|
||||
|
||||
interface Props {
|
||||
board: Board;
|
||||
}
|
||||
|
||||
const hexRegex = /^#[0-9a-fA-F]{6}$/;
|
||||
|
||||
const progressPercentageLabel = (value: number) => `${value}%`;
|
||||
|
||||
export const ColorSettingsContent = ({ board }: Props) => {
|
||||
const form = useForm({
|
||||
initialValues: {
|
||||
primaryColor: board.primaryColor,
|
||||
secondaryColor: board.secondaryColor,
|
||||
opacity: board.opacity,
|
||||
},
|
||||
});
|
||||
const [showPreview, { toggle }] = useDisclosure(false);
|
||||
const t = useI18n();
|
||||
const theme = useMantineTheme();
|
||||
const { mutate: savePartialSettings, isPending } =
|
||||
useSavePartialSettingsMutation(board);
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={form.onSubmit((values) => {
|
||||
savePartialSettings({
|
||||
id: board.id,
|
||||
...values,
|
||||
});
|
||||
})}
|
||||
>
|
||||
<Stack>
|
||||
<Grid>
|
||||
<Grid.Col span={{ sm: 12, md: 6 }}>
|
||||
<Stack gap="xs">
|
||||
<ColorInput
|
||||
label={t("board.field.primaryColor.label")}
|
||||
format="hex"
|
||||
swatches={Object.values(theme.colors).map((color) => color[6])}
|
||||
{...form.getInputProps("primaryColor")}
|
||||
/>
|
||||
</Stack>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={{ sm: 12, md: 6 }}>
|
||||
<ColorInput
|
||||
label={t("board.field.secondaryColor.label")}
|
||||
format="hex"
|
||||
swatches={Object.values(theme.colors).map((color) => color[6])}
|
||||
{...form.getInputProps("secondaryColor")}
|
||||
/>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={12}>
|
||||
<Anchor onClick={toggle}>
|
||||
{showPreview
|
||||
? t("common.preview.hide")
|
||||
: t("common.preview.show")}
|
||||
</Anchor>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={12}>
|
||||
<Collapse in={showPreview}>
|
||||
<Stack>
|
||||
<ColorsPreview previewColor={form.values.primaryColor} />
|
||||
<ColorsPreview previewColor={form.values.secondaryColor} />
|
||||
</Stack>
|
||||
</Collapse>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={{ sm: 12, md: 6 }}>
|
||||
<InputWrapper label={t("board.field.opacity.label")}>
|
||||
<Slider
|
||||
my={6}
|
||||
min={0}
|
||||
max={100}
|
||||
step={5}
|
||||
label={progressPercentageLabel}
|
||||
{...form.getInputProps("opacity")}
|
||||
/>
|
||||
</InputWrapper>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
<Group justify="end">
|
||||
<Button type="submit" loading={isPending} color="teal">
|
||||
{t("common.action.saveChanges")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
interface ColorsPreviewProps {
|
||||
previewColor: string;
|
||||
}
|
||||
|
||||
const ColorsPreview = ({ previewColor }: ColorsPreviewProps) => {
|
||||
const theme = useMantineTheme();
|
||||
|
||||
const colors = hexRegex.test(previewColor)
|
||||
? generateColors(previewColor)
|
||||
: generateColors("#000000");
|
||||
|
||||
return (
|
||||
<Group gap={0} wrap="nowrap">
|
||||
{colors.map((color, index) => (
|
||||
<ColorSwatch
|
||||
key={index}
|
||||
color={color}
|
||||
w="10%"
|
||||
pb="10%"
|
||||
c={isLightColor(color) ? "black" : "white"}
|
||||
radius={0}
|
||||
styles={{
|
||||
colorOverlay: {
|
||||
borderTopLeftRadius: index === 0 ? theme.radius.md : 0,
|
||||
borderBottomLeftRadius: index === 0 ? theme.radius.md : 0,
|
||||
borderTopRightRadius: index === 9 ? theme.radius.md : 0,
|
||||
borderBottomRightRadius: index === 9 ? theme.radius.md : 0,
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Stack align="center" gap={4}>
|
||||
<Text visibleFrom="md" fw={500} size="lg">
|
||||
{index}
|
||||
</Text>
|
||||
<Text visibleFrom="md" fw={500} size="xs" tt="uppercase">
|
||||
{color}
|
||||
</Text>
|
||||
</Stack>
|
||||
</ColorSwatch>
|
||||
))}
|
||||
</Group>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,7 @@
|
||||
"use client";
|
||||
|
||||
// TODO: add some sort of store (maybe directory on GitHub)
|
||||
|
||||
export const CustomCssSettingsContent = () => {
|
||||
return null;
|
||||
};
|
||||
@@ -0,0 +1,157 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Button, Divider, Group, Stack, Text } from "@mantine/core";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
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 = () => {
|
||||
const board = useRequiredBoard();
|
||||
const t = useScopedI18n("board.setting");
|
||||
const router = useRouter();
|
||||
const { openConfirmModal } = useConfirmModal();
|
||||
const { openModal } = useModalAction(BoardRenameModal);
|
||||
const { mutate: changeVisibility, isPending: isChangeVisibilityPending } =
|
||||
clientApi.board.changeBoardVisibility.useMutation();
|
||||
const { mutate: deleteBoard, isPending: isDeletePending } =
|
||||
clientApi.board.deleteBoard.useMutation();
|
||||
const utils = clientApi.useUtils();
|
||||
const visibility = board.isPublic ? "public" : "private";
|
||||
|
||||
const onRenameClick = useCallback(
|
||||
() =>
|
||||
openModal({
|
||||
id: board.id,
|
||||
previousName: board.name,
|
||||
onSuccess: (name) => router.push(`/boards/${name}/settings`),
|
||||
}),
|
||||
[board.id, board.name, router, openModal],
|
||||
);
|
||||
|
||||
const onVisibilityClick = useCallback(() => {
|
||||
openConfirmModal({
|
||||
title: t(
|
||||
`section.dangerZone.action.visibility.confirm.${visibility}.title`,
|
||||
),
|
||||
children: t(
|
||||
`section.dangerZone.action.visibility.confirm.${visibility}.description`,
|
||||
),
|
||||
onConfirm: () => {
|
||||
changeVisibility(
|
||||
{
|
||||
id: board.id,
|
||||
visibility: visibility === "public" ? "private" : "public",
|
||||
},
|
||||
{
|
||||
onSettled() {
|
||||
void utils.board.getBoardByName.invalidate({ name: board.name });
|
||||
void utils.board.getDefaultBoard.invalidate();
|
||||
},
|
||||
},
|
||||
);
|
||||
},
|
||||
});
|
||||
}, [
|
||||
board.id,
|
||||
board.name,
|
||||
changeVisibility,
|
||||
t,
|
||||
utils.board.getBoardByName,
|
||||
utils.board.getDefaultBoard,
|
||||
visibility,
|
||||
openConfirmModal,
|
||||
]);
|
||||
|
||||
const onDeleteClick = useCallback(() => {
|
||||
openConfirmModal({
|
||||
title: t("section.dangerZone.action.delete.confirm.title"),
|
||||
children: t("section.dangerZone.action.delete.confirm.description"),
|
||||
onConfirm: () => {
|
||||
deleteBoard(
|
||||
{ id: board.id },
|
||||
{
|
||||
onSettled: () => {
|
||||
router.push("/");
|
||||
},
|
||||
},
|
||||
);
|
||||
},
|
||||
});
|
||||
}, [board.id, deleteBoard, router, t, openConfirmModal]);
|
||||
|
||||
return (
|
||||
<Stack gap="sm">
|
||||
<Divider />
|
||||
<DangerZoneRow
|
||||
label={t("section.dangerZone.action.rename.label")}
|
||||
description={t("section.dangerZone.action.rename.description")}
|
||||
buttonText={t("section.dangerZone.action.rename.button")}
|
||||
onClick={onRenameClick}
|
||||
/>
|
||||
<Divider />
|
||||
<DangerZoneRow
|
||||
label={t("section.dangerZone.action.visibility.label")}
|
||||
description={t(
|
||||
`section.dangerZone.action.visibility.description.${visibility}`,
|
||||
)}
|
||||
buttonText={t(
|
||||
`section.dangerZone.action.visibility.button.${visibility}`,
|
||||
)}
|
||||
onClick={onVisibilityClick}
|
||||
isPending={isChangeVisibilityPending}
|
||||
/>
|
||||
<Divider />
|
||||
<DangerZoneRow
|
||||
label={t("section.dangerZone.action.delete.label")}
|
||||
description={t("section.dangerZone.action.delete.description")}
|
||||
buttonText={t("section.dangerZone.action.delete.button")}
|
||||
onClick={onDeleteClick}
|
||||
isPending={isDeletePending}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
interface DangerZoneRowProps {
|
||||
label: string;
|
||||
description: string;
|
||||
buttonText: string;
|
||||
isPending?: boolean;
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
const DangerZoneRow = ({
|
||||
label,
|
||||
description,
|
||||
buttonText,
|
||||
onClick,
|
||||
isPending,
|
||||
}: DangerZoneRowProps) => {
|
||||
return (
|
||||
<Group justify="space-between" px="md" className={classes.dangerZoneGroup}>
|
||||
<Stack gap={0}>
|
||||
<Text fw="bold" size="sm">
|
||||
{label}
|
||||
</Text>
|
||||
<Text size="sm">{description}</Text>
|
||||
</Stack>
|
||||
<Group justify="end" w={{ base: "100%", xs: "auto" }}>
|
||||
<Button
|
||||
variant="subtle"
|
||||
color="red"
|
||||
loading={isPending}
|
||||
onClick={onClick}
|
||||
>
|
||||
{buttonText}
|
||||
</Button>
|
||||
</Group>
|
||||
</Group>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,197 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
import {
|
||||
Button,
|
||||
Grid,
|
||||
Group,
|
||||
Loader,
|
||||
Stack,
|
||||
TextInput,
|
||||
Tooltip,
|
||||
} from "@mantine/core";
|
||||
import {
|
||||
useDebouncedValue,
|
||||
useDocumentTitle,
|
||||
useFavicon,
|
||||
} from "@mantine/hooks";
|
||||
import { IconAlertTriangle } from "@tabler/icons-react";
|
||||
|
||||
import { useForm } from "@homarr/form";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
|
||||
import type { Board } from "../../_types";
|
||||
import { useUpdateBoard } from "../../(content)/_client";
|
||||
import { useSavePartialSettingsMutation } from "./_shared";
|
||||
|
||||
interface Props {
|
||||
board: Board;
|
||||
}
|
||||
|
||||
export const GeneralSettingsContent = ({ board }: Props) => {
|
||||
const t = useI18n();
|
||||
const ref = useRef({
|
||||
pageTitle: board.pageTitle,
|
||||
logoImageUrl: board.logoImageUrl,
|
||||
});
|
||||
const { updateBoard } = useUpdateBoard();
|
||||
|
||||
const { mutate: savePartialSettings, isPending } =
|
||||
useSavePartialSettingsMutation(board);
|
||||
const form = useForm({
|
||||
initialValues: {
|
||||
pageTitle: board.pageTitle ?? "",
|
||||
logoImageUrl: board.logoImageUrl ?? "",
|
||||
metaTitle: board.metaTitle ?? "",
|
||||
faviconImageUrl: board.faviconImageUrl ?? "",
|
||||
},
|
||||
onValuesChange({ pageTitle }) {
|
||||
updateBoard((previous) => ({
|
||||
...previous,
|
||||
pageTitle,
|
||||
}));
|
||||
},
|
||||
});
|
||||
|
||||
const metaTitleStatus = useMetaTitlePreview(form.values.metaTitle);
|
||||
const faviconStatus = useFaviconPreview(form.values.faviconImageUrl);
|
||||
const logoStatus = useLogoPreview(form.values.logoImageUrl);
|
||||
|
||||
// Cleanup for not applied changes of the page title and logo image URL
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
updateBoard((previous) => ({
|
||||
...previous,
|
||||
pageTitle: ref.current.pageTitle,
|
||||
logoImageUrl: ref.current.logoImageUrl,
|
||||
}));
|
||||
};
|
||||
}, [updateBoard]);
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={form.onSubmit((values) => {
|
||||
// Save the current values to the ref so that it does not reset if the form is submitted
|
||||
ref.current = {
|
||||
pageTitle: values.pageTitle,
|
||||
logoImageUrl: values.logoImageUrl,
|
||||
};
|
||||
savePartialSettings({
|
||||
id: board.id,
|
||||
...values,
|
||||
});
|
||||
})}
|
||||
>
|
||||
<Stack>
|
||||
<Grid>
|
||||
<Grid.Col span={{ xs: 12, md: 6 }}>
|
||||
<TextInput
|
||||
label={t("board.field.pageTitle.label")}
|
||||
placeholder="Homarr"
|
||||
{...form.getInputProps("pageTitle")}
|
||||
/>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={{ xs: 12, md: 6 }}>
|
||||
<TextInput
|
||||
label={t("board.field.metaTitle.label")}
|
||||
placeholder="Default Board | Homarr"
|
||||
rightSection={<PendingOrInvalidIndicator {...metaTitleStatus} />}
|
||||
{...form.getInputProps("metaTitle")}
|
||||
/>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={{ xs: 12, md: 6 }}>
|
||||
<TextInput
|
||||
label={t("board.field.logoImageUrl.label")}
|
||||
placeholder="/logo/logo.png"
|
||||
rightSection={<PendingOrInvalidIndicator {...logoStatus} />}
|
||||
{...form.getInputProps("logoImageUrl")}
|
||||
/>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={{ xs: 12, md: 6 }}>
|
||||
<TextInput
|
||||
label={t("board.field.faviconImageUrl.label")}
|
||||
placeholder="/logo/logo.png"
|
||||
rightSection={<PendingOrInvalidIndicator {...faviconStatus} />}
|
||||
{...form.getInputProps("faviconImageUrl")}
|
||||
/>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
<Group justify="end">
|
||||
<Button type="submit" loading={isPending} color="teal">
|
||||
{t("common.action.saveChanges")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
const PendingOrInvalidIndicator = ({
|
||||
isPending,
|
||||
isInvalid,
|
||||
}: {
|
||||
isPending: boolean;
|
||||
isInvalid?: boolean;
|
||||
}) => {
|
||||
const t = useI18n();
|
||||
|
||||
if (isInvalid) {
|
||||
return (
|
||||
<Tooltip
|
||||
multiline
|
||||
w={220}
|
||||
label={t("board.setting.section.general.unrecognizedLink")}
|
||||
>
|
||||
<IconAlertTriangle size="1rem" color="red" />
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
if (isPending) {
|
||||
return <Loader size="xs" />;
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const useLogoPreview = (url: string | null) => {
|
||||
const { updateBoard } = useUpdateBoard();
|
||||
const [logoDebounced] = useDebouncedValue(url ?? "", 500);
|
||||
|
||||
useEffect(() => {
|
||||
if (!logoDebounced.includes(".") && logoDebounced.length >= 1) return;
|
||||
updateBoard((previous) => ({
|
||||
...previous,
|
||||
logoImageUrl: logoDebounced.length >= 1 ? logoDebounced : null,
|
||||
}));
|
||||
}, [logoDebounced, updateBoard]);
|
||||
|
||||
return {
|
||||
isPending: (url ?? "") !== logoDebounced,
|
||||
isInvalid: logoDebounced.length >= 1 && !logoDebounced.includes("."),
|
||||
};
|
||||
};
|
||||
|
||||
const useMetaTitlePreview = (title: string | null) => {
|
||||
const [metaTitleDebounced] = useDebouncedValue(title ?? "", 200);
|
||||
useDocumentTitle(metaTitleDebounced);
|
||||
|
||||
return {
|
||||
isPending: (title ?? "") !== metaTitleDebounced,
|
||||
};
|
||||
};
|
||||
|
||||
const validFaviconExtensions = ["ico", "png", "svg", "gif"];
|
||||
const isValidUrl = (url: string) =>
|
||||
url.includes("/") &&
|
||||
validFaviconExtensions.some((extension) => url.endsWith(`.${extension}`));
|
||||
|
||||
const useFaviconPreview = (url: string | null) => {
|
||||
const [faviconDebounced] = useDebouncedValue(url ?? "", 500);
|
||||
useFavicon(isValidUrl(faviconDebounced) ? faviconDebounced : "");
|
||||
|
||||
return {
|
||||
isPending: (url ?? "") !== faviconDebounced,
|
||||
isInvalid: faviconDebounced.length >= 1 && !isValidUrl(faviconDebounced),
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,55 @@
|
||||
"use client";
|
||||
|
||||
import { Button, Grid, Group, Input, Slider, Stack } 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 LayoutSettingsContent = ({ board }: Props) => {
|
||||
const t = useI18n();
|
||||
const { mutate: savePartialSettings, isPending } =
|
||||
useSavePartialSettingsMutation(board);
|
||||
const form = useForm({
|
||||
initialValues: {
|
||||
columnCount: board.columnCount,
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={form.onSubmit((values) => {
|
||||
savePartialSettings({
|
||||
id: board.id,
|
||||
...values,
|
||||
});
|
||||
})}
|
||||
>
|
||||
<Stack>
|
||||
<Grid>
|
||||
<Grid.Col span={{ sm: 12, md: 6 }}>
|
||||
<Input.Wrapper label={t("board.field.columnCount.label")}>
|
||||
<Slider
|
||||
mt="xs"
|
||||
min={1}
|
||||
max={24}
|
||||
step={1}
|
||||
{...form.getInputProps("columnCount")}
|
||||
/>
|
||||
</Input.Wrapper>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
<Group justify="end">
|
||||
<Button type="submit" loading={isPending} color="teal">
|
||||
{t("common.action.saveChanges")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,13 @@
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
|
||||
import type { Board } from "../../_types";
|
||||
|
||||
export const useSavePartialSettingsMutation = (board: Board) => {
|
||||
const utils = clientApi.useUtils();
|
||||
return clientApi.board.savePartialBoardSettings.useMutation({
|
||||
onSettled() {
|
||||
void utils.board.getBoardByName.invalidate({ name: board.name });
|
||||
void utils.board.getDefaultBoard.invalidate();
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,5 @@
|
||||
@media (min-width: 36em) {
|
||||
.dangerZoneGroup {
|
||||
--group-wrap: nowrap !important;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
import type { PropsWithChildren } from "react";
|
||||
import { notFound } from "next/navigation";
|
||||
import {
|
||||
AccordionControl,
|
||||
AccordionItem,
|
||||
AccordionPanel,
|
||||
Container,
|
||||
Stack,
|
||||
Text,
|
||||
Title,
|
||||
} from "@mantine/core";
|
||||
import {
|
||||
IconAlertTriangle,
|
||||
IconBrush,
|
||||
IconFileTypeCss,
|
||||
IconLayout,
|
||||
IconPhoto,
|
||||
IconSettings,
|
||||
IconUser,
|
||||
} from "@tabler/icons-react";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
|
||||
import { api } from "@homarr/api/server";
|
||||
import { capitalize } from "@homarr/common";
|
||||
import type { TranslationObject } from "@homarr/translation";
|
||||
import { getScopedI18n } from "@homarr/translation/server";
|
||||
import type { TablerIcon } from "@homarr/ui";
|
||||
|
||||
import { getBoardPermissions } from "~/components/board/permissions/server";
|
||||
import { ActiveTabAccordion } from "../../../../../components/active-tab-accordion";
|
||||
import { AccessSettingsContent } from "./_access";
|
||||
import { BackgroundSettingsContent } from "./_background";
|
||||
import { ColorSettingsContent } from "./_colors";
|
||||
import { CustomCssSettingsContent } from "./_customCss";
|
||||
import { DangerZoneSettingsContent } from "./_danger";
|
||||
import { GeneralSettingsContent } from "./_general";
|
||||
import { LayoutSettingsContent } from "./_layout";
|
||||
|
||||
interface Props {
|
||||
params: {
|
||||
name: string;
|
||||
};
|
||||
searchParams: {
|
||||
tab?: keyof TranslationObject["board"]["setting"]["section"];
|
||||
};
|
||||
}
|
||||
|
||||
const getBoardAndPermissions = async (params: Props["params"]) => {
|
||||
try {
|
||||
const board = await api.board.getBoardByName({ name: params.name });
|
||||
const { hasFullAccess } = await getBoardPermissions(board);
|
||||
const permissions = hasFullAccess
|
||||
? await api.board.getBoardPermissions({ id: board.id })
|
||||
: {
|
||||
userPermissions: [],
|
||||
groupPermissions: [],
|
||||
inherited: [],
|
||||
};
|
||||
|
||||
return { board, permissions };
|
||||
} catch (error) {
|
||||
// Ignore not found errors and redirect to 404
|
||||
// error is already logged in _layout-creator.tsx
|
||||
if (error instanceof TRPCError && error.code === "NOT_FOUND") {
|
||||
notFound();
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export default async function BoardSettingsPage({
|
||||
params,
|
||||
searchParams,
|
||||
}: Props) {
|
||||
const { board, permissions } = await getBoardAndPermissions(params);
|
||||
const { hasFullAccess } = await getBoardPermissions(board);
|
||||
const t = await getScopedI18n("board.setting");
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<Stack>
|
||||
<Title>{t("title", { boardName: capitalize(board.name) })}</Title>
|
||||
<ActiveTabAccordion
|
||||
variant="separated"
|
||||
defaultValue={searchParams.tab ?? "general"}
|
||||
>
|
||||
<AccordionItemFor value="general" icon={IconSettings}>
|
||||
<GeneralSettingsContent board={board} />
|
||||
</AccordionItemFor>
|
||||
<AccordionItemFor value="layout" icon={IconLayout}>
|
||||
<LayoutSettingsContent board={board} />
|
||||
</AccordionItemFor>
|
||||
<AccordionItemFor value="background" icon={IconPhoto}>
|
||||
<BackgroundSettingsContent board={board} />
|
||||
</AccordionItemFor>
|
||||
<AccordionItemFor value="color" icon={IconBrush}>
|
||||
<ColorSettingsContent board={board} />
|
||||
</AccordionItemFor>
|
||||
<AccordionItemFor value="customCss" icon={IconFileTypeCss}>
|
||||
<CustomCssSettingsContent />
|
||||
</AccordionItemFor>
|
||||
{hasFullAccess && (
|
||||
<>
|
||||
<AccordionItemFor value="access" icon={IconUser}>
|
||||
<AccessSettingsContent
|
||||
board={board}
|
||||
initialPermissions={permissions}
|
||||
/>
|
||||
</AccordionItemFor>
|
||||
<AccordionItemFor
|
||||
value="dangerZone"
|
||||
icon={IconAlertTriangle}
|
||||
danger
|
||||
noPadding
|
||||
>
|
||||
<DangerZoneSettingsContent />
|
||||
</AccordionItemFor>
|
||||
</>
|
||||
)}
|
||||
</ActiveTabAccordion>
|
||||
</Stack>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
type AccordionItemForProps = PropsWithChildren<{
|
||||
value: keyof TranslationObject["board"]["setting"]["section"];
|
||||
icon: TablerIcon;
|
||||
danger?: boolean;
|
||||
noPadding?: boolean;
|
||||
}>;
|
||||
|
||||
const AccordionItemFor = async ({
|
||||
value,
|
||||
children,
|
||||
icon: Icon,
|
||||
danger,
|
||||
noPadding,
|
||||
}: AccordionItemForProps) => {
|
||||
const t = await getScopedI18n("board.setting.section");
|
||||
return (
|
||||
<AccordionItem
|
||||
value={value}
|
||||
styles={
|
||||
danger
|
||||
? {
|
||||
item: {
|
||||
"--__item-border-color": "rgba(248,81,73,0.4)",
|
||||
borderWidth: 4,
|
||||
},
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<AccordionControl icon={<Icon />}>
|
||||
<Text fw="bold" size="lg">
|
||||
{t(`${value}.title`)}
|
||||
</Text>
|
||||
</AccordionControl>
|
||||
<AccordionPanel
|
||||
styles={
|
||||
noPadding
|
||||
? { content: { paddingRight: 0, paddingLeft: 0 } }
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{children}
|
||||
</AccordionPanel>
|
||||
</AccordionItem>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,16 @@
|
||||
"use client";
|
||||
|
||||
import { IconLayoutBoard } from "@tabler/icons-react";
|
||||
|
||||
import { HeaderButton } from "~/components/layout/header/button";
|
||||
import { useRequiredBoard } from "./(content)/_context";
|
||||
|
||||
export const BoardOtherHeaderActions = () => {
|
||||
const board = useRequiredBoard();
|
||||
|
||||
return (
|
||||
<HeaderButton href={`/boards/${board.name}`}>
|
||||
<IconLayoutBoard stroke={1.5} />
|
||||
</HeaderButton>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,65 @@
|
||||
import type { PropsWithChildren } from "react";
|
||||
import { notFound } from "next/navigation";
|
||||
import { AppShellMain } from "@mantine/core";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
|
||||
import { logger } from "@homarr/log";
|
||||
import { GlobalItemServerDataRunner } from "@homarr/widgets";
|
||||
|
||||
import { MainHeader } from "~/components/layout/header";
|
||||
import { BoardLogoWithTitle } from "~/components/layout/logo/board-logo";
|
||||
import { ClientShell } from "~/components/layout/shell";
|
||||
import type { Board } from "./_types";
|
||||
import { BoardProvider } from "./(content)/_context";
|
||||
import type { Params } from "./(content)/_creator";
|
||||
import { BoardMantineProvider } from "./(content)/_theme";
|
||||
|
||||
interface CreateBoardLayoutProps<TParams extends Params> {
|
||||
headerActions: JSX.Element;
|
||||
getInitialBoard: (params: TParams) => Promise<Board>;
|
||||
isBoardContentPage: boolean;
|
||||
}
|
||||
|
||||
export const createBoardLayout = <TParams extends Params>({
|
||||
headerActions,
|
||||
getInitialBoard,
|
||||
isBoardContentPage,
|
||||
}: CreateBoardLayoutProps<TParams>) => {
|
||||
const Layout = async ({
|
||||
params,
|
||||
children,
|
||||
}: PropsWithChildren<{
|
||||
params: TParams;
|
||||
}>) => {
|
||||
const initialBoard = await getInitialBoard(params).catch((error) => {
|
||||
if (error instanceof TRPCError && error.code === "NOT_FOUND") {
|
||||
logger.warn(error);
|
||||
notFound();
|
||||
}
|
||||
|
||||
throw error;
|
||||
});
|
||||
|
||||
return (
|
||||
<GlobalItemServerDataRunner
|
||||
board={initialBoard}
|
||||
shouldRun={isBoardContentPage}
|
||||
>
|
||||
<BoardProvider initialBoard={initialBoard}>
|
||||
<BoardMantineProvider>
|
||||
<ClientShell hasNavigation={false}>
|
||||
<MainHeader
|
||||
logo={<BoardLogoWithTitle size="md" hideTitleOnMobile />}
|
||||
actions={headerActions}
|
||||
hasNavigation={false}
|
||||
/>
|
||||
<AppShellMain>{children}</AppShellMain>
|
||||
</ClientShell>
|
||||
</BoardMantineProvider>
|
||||
</BoardProvider>
|
||||
</GlobalItemServerDataRunner>
|
||||
);
|
||||
};
|
||||
|
||||
return Layout;
|
||||
};
|
||||
@@ -0,0 +1,14 @@
|
||||
import type { RouterOutputs } from "@homarr/api";
|
||||
import type { WidgetKind } from "@homarr/definitions";
|
||||
|
||||
export type Board = RouterOutputs["board"]["getDefaultBoard"];
|
||||
export type Section = Board["sections"][number];
|
||||
export type Item = Section["items"][number];
|
||||
|
||||
export type CategorySection = Extract<Section, { kind: "category" }>;
|
||||
export type EmptySection = Extract<Section, { kind: "empty" }>;
|
||||
|
||||
export type ItemOfKind<TKind extends WidgetKind> = Extract<
|
||||
Item,
|
||||
{ kind: TKind }
|
||||
>;
|
||||
@@ -0,0 +1,18 @@
|
||||
import React from "react";
|
||||
|
||||
type PropsWithChildren = Required<React.PropsWithChildren>;
|
||||
|
||||
export const composeWrappers = (
|
||||
wrappers: React.FunctionComponent<PropsWithChildren>[],
|
||||
): React.FunctionComponent<PropsWithChildren> => {
|
||||
return wrappers
|
||||
.reverse()
|
||||
.reduce((Acc, Current): React.FunctionComponent<PropsWithChildren> => {
|
||||
// eslint-disable-next-line react/display-name
|
||||
return (props) => (
|
||||
<Current>
|
||||
<Acc {...props} />
|
||||
</Current>
|
||||
);
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,80 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Button, PasswordInput, Stack, TextInput } from "@mantine/core";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { useForm, zodResolver } from "@homarr/form";
|
||||
import {
|
||||
showErrorNotification,
|
||||
showSuccessNotification,
|
||||
} from "@homarr/notifications";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
import type { z } from "@homarr/validation";
|
||||
import { validation } from "@homarr/validation";
|
||||
|
||||
export const InitUserForm = () => {
|
||||
const router = useRouter();
|
||||
const t = useScopedI18n("user");
|
||||
const { mutateAsync, error, isPending } =
|
||||
clientApi.user.initUser.useMutation();
|
||||
const form = useForm<FormType>({
|
||||
validate: zodResolver(validation.user.init),
|
||||
validateInputOnBlur: true,
|
||||
validateInputOnChange: true,
|
||||
initialValues: {
|
||||
username: "",
|
||||
password: "",
|
||||
confirmPassword: "",
|
||||
},
|
||||
});
|
||||
|
||||
const handleSubmit = async (values: FormType) => {
|
||||
await mutateAsync(values, {
|
||||
onSuccess: () => {
|
||||
showSuccessNotification({
|
||||
title: "User created",
|
||||
message: "You can now log in",
|
||||
});
|
||||
router.push("/auth/login");
|
||||
},
|
||||
onError: () => {
|
||||
showErrorNotification({
|
||||
title: "User creation failed",
|
||||
message: error?.message ?? "Unknown error",
|
||||
});
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack gap="xl">
|
||||
<form
|
||||
onSubmit={form.onSubmit(
|
||||
(values) => void handleSubmit(values),
|
||||
(err) => console.log(err),
|
||||
)}
|
||||
>
|
||||
<Stack gap="lg">
|
||||
<TextInput
|
||||
label={t("field.username.label")}
|
||||
{...form.getInputProps("username")}
|
||||
/>
|
||||
<PasswordInput
|
||||
label={t("field.password.label")}
|
||||
{...form.getInputProps("password")}
|
||||
/>
|
||||
<PasswordInput
|
||||
label={t("field.passwordConfirm.label")}
|
||||
{...form.getInputProps("confirmPassword")}
|
||||
/>
|
||||
<Button type="submit" fullWidth loading={isPending}>
|
||||
{t("action.create")}
|
||||
</Button>
|
||||
</Stack>
|
||||
</form>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
type FormType = z.infer<typeof validation.user.init>;
|
||||
@@ -0,0 +1,41 @@
|
||||
import { notFound } from "next/navigation";
|
||||
import { Card, Center, Stack, Text, Title } from "@mantine/core";
|
||||
|
||||
import { db } from "@homarr/db";
|
||||
import { getScopedI18n } from "@homarr/translation/server";
|
||||
|
||||
import { HomarrLogoWithTitle } from "~/components/layout/logo/homarr-logo";
|
||||
import { InitUserForm } from "./_init-user-form";
|
||||
|
||||
export default async function InitUser() {
|
||||
const firstUser = await db.query.users.findFirst({
|
||||
columns: {
|
||||
id: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (firstUser) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const t = await getScopedI18n("user.page.init");
|
||||
|
||||
return (
|
||||
<Center>
|
||||
<Stack align="center" mt="xl">
|
||||
<HomarrLogoWithTitle size="lg" />
|
||||
<Stack gap={6} align="center">
|
||||
<Title order={3} fw={400} ta="center">
|
||||
{t("title")}
|
||||
</Title>
|
||||
<Text size="sm" c="gray.5" ta="center">
|
||||
{t("subtitle")}
|
||||
</Text>
|
||||
</Stack>
|
||||
<Card bg="dark.8" w={64 * 6} maw="90vw">
|
||||
<InitUserForm />
|
||||
</Card>
|
||||
</Stack>
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import type { Metadata, Viewport } from "next";
|
||||
import { Inter } from "next/font/google";
|
||||
|
||||
import "@homarr/notifications/styles.css";
|
||||
import "@homarr/spotlight/styles.css";
|
||||
import "@mantine/core/styles.css";
|
||||
|
||||
import { ColorSchemeScript, createTheme, MantineProvider } from "@mantine/core";
|
||||
|
||||
import { auth } from "@homarr/auth/next";
|
||||
import { ModalProvider } from "@homarr/modals";
|
||||
import { Notifications } from "@homarr/notifications";
|
||||
|
||||
import { JotaiProvider } from "./_client-providers/jotai";
|
||||
import { NextInternationalProvider } from "./_client-providers/next-international";
|
||||
import { AuthProvider } from "./_client-providers/session";
|
||||
import { TRPCReactProvider } from "./_client-providers/trpc";
|
||||
import { composeWrappers } from "./compose";
|
||||
|
||||
const fontSans = Inter({
|
||||
subsets: ["latin"],
|
||||
variable: "--font-sans",
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
metadataBase: new URL("http://localhost:3000"),
|
||||
title: "Homarr",
|
||||
description:
|
||||
"Simplify the management of your server with Homarr - a sleek, modern dashboard that puts all of your apps and services at your fingertips.",
|
||||
openGraph: {
|
||||
title: "Homarr Dashboard",
|
||||
description:
|
||||
"Simplify the management of your server with Homarr - a sleek, modern dashboard that puts all of your apps and services at your fingertips.",
|
||||
url: "https://homarr.dev",
|
||||
siteName: "Homarr Documentation",
|
||||
},
|
||||
twitter: {
|
||||
card: "summary_large_image",
|
||||
site: "@jullerino",
|
||||
creator: "@jullerino",
|
||||
},
|
||||
};
|
||||
|
||||
export const viewport: Viewport = {
|
||||
themeColor: [
|
||||
{ media: "(prefers-color-scheme: light)", color: "white" },
|
||||
{ media: "(prefers-color-scheme: dark)", color: "black" },
|
||||
],
|
||||
};
|
||||
|
||||
export default function Layout(props: {
|
||||
children: React.ReactNode;
|
||||
params: { locale: string };
|
||||
}) {
|
||||
const colorScheme = "dark";
|
||||
|
||||
const StackedProvider = composeWrappers([
|
||||
async (innerProps) => {
|
||||
const session = await auth();
|
||||
return <AuthProvider session={session} {...innerProps} />;
|
||||
},
|
||||
(innerProps) => <JotaiProvider {...innerProps} />,
|
||||
(innerProps) => <TRPCReactProvider {...innerProps} />,
|
||||
(innerProps) => (
|
||||
<NextInternationalProvider {...innerProps} locale={props.params.locale} />
|
||||
),
|
||||
(innerProps) => (
|
||||
<MantineProvider
|
||||
{...innerProps}
|
||||
defaultColorScheme="dark"
|
||||
theme={createTheme({
|
||||
primaryColor: "red",
|
||||
autoContrast: true,
|
||||
})}
|
||||
/>
|
||||
),
|
||||
(innerProps) => <ModalProvider {...innerProps} />,
|
||||
]);
|
||||
|
||||
return (
|
||||
<html lang="en" suppressHydrationWarning>
|
||||
<head>
|
||||
<ColorSchemeScript defaultColorScheme={colorScheme} />
|
||||
</head>
|
||||
<body className={["font-sans", fontSans.variable].join(" ")}>
|
||||
<StackedProvider>
|
||||
<Notifications />
|
||||
{props.children}
|
||||
</StackedProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { Center, Loader } from "@mantine/core";
|
||||
|
||||
export default function CommonLoading() {
|
||||
return (
|
||||
<Center h="100vh">
|
||||
<Loader />
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { Center, Stack, Text, Title } from "@mantine/core";
|
||||
|
||||
import { getScopedI18n } from "@homarr/translation/server";
|
||||
|
||||
export default async function NotFound() {
|
||||
const t = await getScopedI18n("management.notFound");
|
||||
return (
|
||||
<Center h="100%">
|
||||
<Stack align="center">
|
||||
<Title order={1} tt="uppercase">
|
||||
{t("title")}
|
||||
</Title>
|
||||
<Text>{t("text")}</Text>
|
||||
</Stack>
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
.root {
|
||||
border-radius: var(--mantine-radius-sm);
|
||||
background-color: light-dark(
|
||||
var(--mantine-color-gray-0),
|
||||
var(--mantine-color-dark-6)
|
||||
);
|
||||
}
|
||||
|
||||
.item {
|
||||
background-color: light-dark(
|
||||
var(--mantine-color-gray-0),
|
||||
var(--mantine-color-dark-6)
|
||||
);
|
||||
border: 1px solid transparent;
|
||||
position: relative;
|
||||
z-index: 0;
|
||||
transition: transform 150ms ease;
|
||||
|
||||
&[data-active] {
|
||||
transform: scale(1.015);
|
||||
z-index: 1;
|
||||
background-color: var(--mantine-color-body);
|
||||
border-color: light-dark(
|
||||
var(--mantine-color-gray-2),
|
||||
var(--mantine-color-dark-4)
|
||||
);
|
||||
box-shadow: var(--mantine-shadow-md);
|
||||
border-radius: var(--mantine-radius-md);
|
||||
}
|
||||
}
|
||||
|
||||
.chevron {
|
||||
&[data-rotate] {
|
||||
transform: rotate(-90deg);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
import Image from "next/image";
|
||||
import {
|
||||
Accordion,
|
||||
AccordionControl,
|
||||
AccordionItem,
|
||||
AccordionPanel,
|
||||
Center,
|
||||
Group,
|
||||
List,
|
||||
ListItem,
|
||||
Stack,
|
||||
Text,
|
||||
Title,
|
||||
} from "@mantine/core";
|
||||
import { IconLanguage, IconLibrary, IconUsers } from "@tabler/icons-react";
|
||||
import { setStaticParamsLocale } from "next-international/server";
|
||||
|
||||
import { getScopedI18n, getStaticParams } from "@homarr/translation/server";
|
||||
|
||||
import { getPackageAttributesAsync } from "~/versions/package-reader";
|
||||
import logo from "../../../../../public/logo/logo.png";
|
||||
import classes from "./accordion.module.css";
|
||||
|
||||
export async function generateMetadata() {
|
||||
const t = await getScopedI18n("management");
|
||||
const metaTitle = `${t("metaTitle")} • Homarr`;
|
||||
|
||||
return {
|
||||
title: metaTitle,
|
||||
};
|
||||
}
|
||||
|
||||
interface PageProps {
|
||||
params: {
|
||||
locale: string;
|
||||
};
|
||||
}
|
||||
|
||||
export default async function AboutPage({ params: { locale } }: PageProps) {
|
||||
setStaticParamsLocale(locale);
|
||||
const t = await getScopedI18n("management.page.about");
|
||||
const attributes = await getPackageAttributesAsync();
|
||||
return (
|
||||
<div>
|
||||
<Center w="100%">
|
||||
<Group py="lg">
|
||||
<Image src={logo} width={100} height={100} alt="" />
|
||||
<Stack gap={0}>
|
||||
<Title order={1} tt="uppercase">
|
||||
Homarr
|
||||
</Title>
|
||||
<Title order={2}>
|
||||
{t("version", { version: attributes.version })}
|
||||
</Title>
|
||||
</Stack>
|
||||
</Group>
|
||||
</Center>
|
||||
<Text mb="xl">{t("text")}</Text>
|
||||
|
||||
<Accordion classNames={classes}>
|
||||
<AccordionItem value="contributors">
|
||||
<AccordionControl icon={<IconUsers size="1rem" />}>
|
||||
{t("accordion.contributors.title")}
|
||||
</AccordionControl>
|
||||
<AccordionPanel></AccordionPanel>
|
||||
</AccordionItem>
|
||||
<AccordionItem value="translators">
|
||||
<AccordionControl icon={<IconLanguage size="1rem" />}>
|
||||
{t("accordion.translators.title")}
|
||||
</AccordionControl>
|
||||
<AccordionPanel></AccordionPanel>
|
||||
</AccordionItem>
|
||||
<AccordionItem value="libraries">
|
||||
<AccordionControl icon={<IconLibrary size="1rem" />}>
|
||||
<Stack gap={0}>
|
||||
<Text>{t("accordion.libraries.title")}</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
{t("accordion.libraries.subtitle", {
|
||||
count: Object.keys(attributes.dependencies).length,
|
||||
})}
|
||||
</Text>
|
||||
</Stack>
|
||||
</AccordionControl>
|
||||
<AccordionPanel>
|
||||
<List>
|
||||
{Object.entries(attributes.dependencies)
|
||||
.sort(([key1], [key2]) => key1.localeCompare(key2))
|
||||
.map(([key, value]) => (
|
||||
<ListItem key={key}>
|
||||
{value.includes("workspace:") ? (
|
||||
<Text>{key}</Text>
|
||||
) : (
|
||||
<a href={`https://www.npmjs.com/package/${key}`}>{key}</a>
|
||||
)}
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
</AccordionPanel>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function generateStaticParams() {
|
||||
return getStaticParams();
|
||||
}
|
||||
|
||||
export const dynamic = "force-static";
|
||||
@@ -0,0 +1,65 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback } from "react";
|
||||
import { ActionIcon } from "@mantine/core";
|
||||
import { IconTrash } from "@tabler/icons-react";
|
||||
|
||||
import type { RouterOutputs } from "@homarr/api";
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { useConfirmModal } from "@homarr/modals";
|
||||
import {
|
||||
showErrorNotification,
|
||||
showSuccessNotification,
|
||||
} from "@homarr/notifications";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import { revalidatePathAction } from "../../../revalidatePathAction";
|
||||
|
||||
interface AppDeleteButtonProps {
|
||||
app: RouterOutputs["app"]["all"][number];
|
||||
}
|
||||
|
||||
export const AppDeleteButton = ({ app }: AppDeleteButtonProps) => {
|
||||
const t = useScopedI18n("app.page.delete");
|
||||
const { openConfirmModal } = useConfirmModal();
|
||||
const { mutate, isPending } = clientApi.app.delete.useMutation();
|
||||
|
||||
const onClick = useCallback(() => {
|
||||
openConfirmModal({
|
||||
title: t("title"),
|
||||
children: t("message", app),
|
||||
onConfirm: () => {
|
||||
mutate(
|
||||
{ id: app.id },
|
||||
{
|
||||
onSuccess: () => {
|
||||
showSuccessNotification({
|
||||
title: t("notification.success.title"),
|
||||
message: t("notification.success.message"),
|
||||
});
|
||||
void revalidatePathAction("/manage/apps");
|
||||
},
|
||||
onError: () => {
|
||||
showErrorNotification({
|
||||
title: t("notification.error.title"),
|
||||
message: t("notification.error.message"),
|
||||
});
|
||||
},
|
||||
},
|
||||
);
|
||||
},
|
||||
});
|
||||
}, [app, mutate, t, openConfirmModal]);
|
||||
|
||||
return (
|
||||
<ActionIcon
|
||||
loading={isPending}
|
||||
variant="subtle"
|
||||
color="red"
|
||||
onClick={onClick}
|
||||
aria-label="Delete app"
|
||||
>
|
||||
<IconTrash color="red" size={16} stroke={1.5} />
|
||||
</ActionIcon>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,60 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { Button, Group, Stack, Textarea, TextInput } from "@mantine/core";
|
||||
|
||||
import { useForm, zodResolver } from "@homarr/form";
|
||||
import type { TranslationFunction } from "@homarr/translation";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import type { z } from "@homarr/validation";
|
||||
import { validation } from "@homarr/validation";
|
||||
|
||||
// TODO: add 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 = useForm({
|
||||
initialValues: initialValues ?? {
|
||||
name: "",
|
||||
description: "",
|
||||
iconUrl: "",
|
||||
href: "",
|
||||
},
|
||||
validate: zodResolver(validation.app.manage),
|
||||
});
|
||||
|
||||
return (
|
||||
<form onSubmit={form.onSubmit(handleSubmit)}>
|
||||
<Stack>
|
||||
<TextInput {...form.getInputProps("name")} withAsterisk label="Name" />
|
||||
<TextInput
|
||||
{...form.getInputProps("iconUrl")}
|
||||
withAsterisk
|
||||
label="Icon URL"
|
||||
/>
|
||||
<Textarea {...form.getInputProps("description")} label="Description" />
|
||||
<TextInput {...form.getInputProps("href")} label="URL" />
|
||||
|
||||
<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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,68 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
import type { RouterOutputs } from "@homarr/api";
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import {
|
||||
showErrorNotification,
|
||||
showSuccessNotification,
|
||||
} from "@homarr/notifications";
|
||||
import type { TranslationFunction } from "@homarr/translation";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
import type { validation, z } from "@homarr/validation";
|
||||
|
||||
import { revalidatePathAction } from "~/app/revalidatePathAction";
|
||||
import { AppForm } from "../../_form";
|
||||
|
||||
interface AppEditFormProps {
|
||||
app: RouterOutputs["app"]["byId"];
|
||||
}
|
||||
|
||||
export const AppEditForm = ({ app }: AppEditFormProps) => {
|
||||
const t = useScopedI18n("app.page.edit.notification");
|
||||
const router = useRouter();
|
||||
|
||||
const { mutate, isPending } = clientApi.app.update.useMutation({
|
||||
onSuccess: () => {
|
||||
showSuccessNotification({
|
||||
title: t("success.title"),
|
||||
message: t("success.message"),
|
||||
});
|
||||
void revalidatePathAction("/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({
|
||||
id: app.id,
|
||||
...values,
|
||||
});
|
||||
},
|
||||
[mutate, app.id],
|
||||
);
|
||||
|
||||
const submitButtonTranslation = useCallback(
|
||||
(t: TranslationFunction) => t("common.action.save"),
|
||||
[],
|
||||
);
|
||||
|
||||
return (
|
||||
<AppForm
|
||||
submitButtonTranslation={submitButtonTranslation}
|
||||
initialValues={app}
|
||||
handleSubmit={handleSubmit}
|
||||
isPending={isPending}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,24 @@
|
||||
import { Container, Stack, Title } from "@mantine/core";
|
||||
|
||||
import { api } from "@homarr/api/server";
|
||||
import { getI18n } from "@homarr/translation/server";
|
||||
|
||||
import { AppEditForm } from "./_app-edit-form";
|
||||
|
||||
interface AppEditPageProps {
|
||||
params: { id: string };
|
||||
}
|
||||
|
||||
export default async function AppEditPage({ params }: AppEditPageProps) {
|
||||
const app = await api.app.byId({ id: params.id });
|
||||
const t = await getI18n();
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<Stack>
|
||||
<Title>{t("app.page.edit.title")}</Title>
|
||||
<AppEditForm app={app} />
|
||||
</Stack>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import {
|
||||
showErrorNotification,
|
||||
showSuccessNotification,
|
||||
} from "@homarr/notifications";
|
||||
import type { TranslationFunction } from "@homarr/translation";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
import type { validation, z } from "@homarr/validation";
|
||||
|
||||
import { revalidatePathAction } from "~/app/revalidatePathAction";
|
||||
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 revalidatePathAction("/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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,14 @@
|
||||
import { Container, Stack, Title } from "@mantine/core";
|
||||
|
||||
import { AppNewForm } from "./_app-new-form";
|
||||
|
||||
export default function AppNewPage() {
|
||||
return (
|
||||
<Container>
|
||||
<Stack>
|
||||
<Title>New app</Title>
|
||||
<AppNewForm />
|
||||
</Stack>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
import Link from "next/link";
|
||||
import {
|
||||
ActionIcon,
|
||||
ActionIconGroup,
|
||||
Anchor,
|
||||
Avatar,
|
||||
Button,
|
||||
Card,
|
||||
Container,
|
||||
Group,
|
||||
Stack,
|
||||
Text,
|
||||
Title,
|
||||
} from "@mantine/core";
|
||||
import { IconApps, IconPencil } from "@tabler/icons-react";
|
||||
|
||||
import type { RouterOutputs } from "@homarr/api";
|
||||
import { api } from "@homarr/api/server";
|
||||
import { getI18n } from "@homarr/translation/server";
|
||||
|
||||
import { AppDeleteButton } from "./_app-delete-button";
|
||||
|
||||
export default async function AppsPage() {
|
||||
const apps = await api.app.all();
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<Stack>
|
||||
<Group justify="space-between" align="center">
|
||||
<Title>Apps</Title>
|
||||
<Button component={Link} href="/manage/apps/new">
|
||||
New app
|
||||
</Button>
|
||||
</Group>
|
||||
{apps.length === 0 && <AppNoResults />}
|
||||
{apps.length > 0 && (
|
||||
<Stack gap="sm">
|
||||
{apps.map((app) => (
|
||||
<AppCard key={app.id} app={app} />
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
</Stack>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
interface AppCardProps {
|
||||
app: RouterOutputs["app"]["all"][number];
|
||||
}
|
||||
|
||||
const AppCard = ({ app }: AppCardProps) => {
|
||||
return (
|
||||
<Card>
|
||||
<Group justify="space-between">
|
||||
<Group align="top" justify="start" wrap="nowrap">
|
||||
<Avatar
|
||||
size="sm"
|
||||
src={app.iconUrl}
|
||||
radius={0}
|
||||
styles={{
|
||||
image: {
|
||||
objectFit: "contain",
|
||||
},
|
||||
}}
|
||||
/>
|
||||
<Stack gap={0}>
|
||||
<Text fw={500}>{app.name}</Text>
|
||||
{app.description && (
|
||||
<Text size="sm" c="gray.6">
|
||||
{app.description}
|
||||
</Text>
|
||||
)}
|
||||
{app.href && (
|
||||
<Anchor href={app.href} size="sm" w="min-content">
|
||||
{app.href}
|
||||
</Anchor>
|
||||
)}
|
||||
</Stack>
|
||||
</Group>
|
||||
<Group>
|
||||
<ActionIconGroup>
|
||||
<ActionIcon
|
||||
component={Link}
|
||||
href={`/manage/apps/edit/${app.id}`}
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
aria-label="Edit app"
|
||||
>
|
||||
<IconPencil size={16} stroke={1.5} />
|
||||
</ActionIcon>
|
||||
<AppDeleteButton app={app} />
|
||||
</ActionIconGroup>
|
||||
</Group>
|
||||
</Group>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
const AppNoResults = async () => {
|
||||
const t = await getI18n();
|
||||
|
||||
return (
|
||||
<Card withBorder bg="transparent">
|
||||
<Stack align="center" gap="sm">
|
||||
<IconApps size="2rem" />
|
||||
<Text fw={500} size="lg">
|
||||
{t("app.page.list.noResults.title")}
|
||||
</Text>
|
||||
<Anchor href="/manage/apps/new">
|
||||
{t("app.page.list.noResults.description")}
|
||||
</Anchor>
|
||||
</Stack>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,92 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback } from "react";
|
||||
import Link from "next/link";
|
||||
import { Menu } from "@mantine/core";
|
||||
import { IconSettings, IconTrash } from "@tabler/icons-react";
|
||||
|
||||
import type { RouterOutputs } from "@homarr/api";
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { useConfirmModal } from "@homarr/modals";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import { revalidatePathAction } from "~/app/revalidatePathAction";
|
||||
import { useBoardPermissions } from "~/components/board/permissions/client";
|
||||
|
||||
const iconProps = {
|
||||
size: 16,
|
||||
stroke: 1.5,
|
||||
};
|
||||
|
||||
interface BoardCardMenuDropdownProps {
|
||||
board: Pick<
|
||||
RouterOutputs["board"]["getAllBoards"][number],
|
||||
| "id"
|
||||
| "name"
|
||||
| "creator"
|
||||
| "userPermissions"
|
||||
| "groupPermissions"
|
||||
| "isPublic"
|
||||
>;
|
||||
}
|
||||
|
||||
export const BoardCardMenuDropdown = ({
|
||||
board,
|
||||
}: BoardCardMenuDropdownProps) => {
|
||||
const t = useScopedI18n("management.page.board.action");
|
||||
const tCommon = useScopedI18n("common");
|
||||
|
||||
const { hasFullAccess, hasChangeAccess } = useBoardPermissions(board);
|
||||
|
||||
const { openConfirmModal } = useConfirmModal();
|
||||
|
||||
const { mutateAsync, isPending } = clientApi.board.deleteBoard.useMutation({
|
||||
onSettled: async () => {
|
||||
await revalidatePathAction("/manage/boards");
|
||||
},
|
||||
});
|
||||
|
||||
const handleDeletion = useCallback(() => {
|
||||
openConfirmModal({
|
||||
title: t("delete.confirm.title"),
|
||||
children: t("delete.confirm.description", {
|
||||
name: board.name,
|
||||
}),
|
||||
onConfirm: async () => {
|
||||
await mutateAsync({
|
||||
id: board.id,
|
||||
});
|
||||
},
|
||||
});
|
||||
}, [board.id, board.name, mutateAsync, openConfirmModal, t]);
|
||||
|
||||
return (
|
||||
<Menu.Dropdown>
|
||||
{hasChangeAccess && (
|
||||
<Menu.Item
|
||||
component={Link}
|
||||
href={`/boards/${board.name}/settings`}
|
||||
leftSection={<IconSettings {...iconProps} />}
|
||||
>
|
||||
{t("settings.label")}
|
||||
</Menu.Item>
|
||||
)}
|
||||
{hasFullAccess && (
|
||||
<>
|
||||
<Menu.Divider />
|
||||
<Menu.Label c="red.7">
|
||||
{tCommon("menu.section.dangerZone.title")}
|
||||
</Menu.Label>
|
||||
<Menu.Item
|
||||
c="red.7"
|
||||
leftSection={<IconTrash {...iconProps} />}
|
||||
onClick={handleDeletion}
|
||||
disabled={isPending}
|
||||
>
|
||||
{t("delete.label")}
|
||||
</Menu.Item>
|
||||
</>
|
||||
)}
|
||||
</Menu.Dropdown>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,83 @@
|
||||
"use client";
|
||||
|
||||
import { Box, Card, Center, Flex, Loader, Text } from "@mantine/core";
|
||||
import { useElementSize } from "@mantine/hooks";
|
||||
import dayjs from "dayjs";
|
||||
|
||||
import type { RouterOutputs } from "@homarr/api";
|
||||
|
||||
interface BoardPreviewProps {
|
||||
board: RouterOutputs["board"]["getAllBoards"][number];
|
||||
}
|
||||
|
||||
export const BoardPreview = ({ board }: BoardPreviewProps) => {
|
||||
const { ref, width } = useElementSize();
|
||||
|
||||
return (
|
||||
<Card
|
||||
h="100%"
|
||||
mah={width ? (width / 1920) * 1080 : undefined}
|
||||
ref={ref}
|
||||
styles={{
|
||||
root: {
|
||||
"--gridstack-column-count": board.columnCount,
|
||||
"--gridstack-widget-width": width / board.columnCount,
|
||||
},
|
||||
}}
|
||||
>
|
||||
{width === 0 ? (
|
||||
<Center h="100%">
|
||||
<Loader size="sm" />
|
||||
</Center>
|
||||
) : (
|
||||
<>
|
||||
{board.sections.map((section) => (
|
||||
<Section
|
||||
key={section.id}
|
||||
section={section}
|
||||
itemWidth={width / board.columnCount}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
interface SectionProps {
|
||||
section: RouterOutputs["board"]["getAllBoards"][number]["sections"][number];
|
||||
itemWidth: number;
|
||||
}
|
||||
|
||||
const Section = ({ section, itemWidth }: SectionProps) => {
|
||||
const maxHeight = Math.max(
|
||||
...section.items.map((item) => item.yOffset + item.height),
|
||||
);
|
||||
|
||||
return (
|
||||
<Card
|
||||
withBorder={section.kind === "category"}
|
||||
h={maxHeight * itemWidth}
|
||||
key={section.id}
|
||||
className="grid-stack"
|
||||
>
|
||||
{section.items.map((item) => (
|
||||
<Box
|
||||
className="grid-stack-item"
|
||||
gs-h={item.height}
|
||||
gs-w={item.width}
|
||||
gs-x={item.xOffset}
|
||||
gs-y={item.yOffset}
|
||||
key={item.id}
|
||||
p={4}
|
||||
>
|
||||
<Card withBorder h="100%" p={0}>
|
||||
<Flex justify="center" align="center" h="100%" w="100%">
|
||||
<Text fz={itemWidth / 4}>{dayjs().format("HH:mm")}</Text>
|
||||
</Flex>
|
||||
</Card>
|
||||
</Box>
|
||||
))}
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,48 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback } from "react";
|
||||
import { Button } from "@mantine/core";
|
||||
import { IconCategoryPlus } from "@tabler/icons-react";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { useModalAction } from "@homarr/modals";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
|
||||
import { revalidatePathAction } from "~/app/revalidatePathAction";
|
||||
import { AddBoardModal } from "~/components/manage/boards/add-board-modal";
|
||||
|
||||
interface CreateBoardButtonProps {
|
||||
boardNames: string[];
|
||||
}
|
||||
|
||||
export const CreateBoardButton = ({ boardNames }: CreateBoardButtonProps) => {
|
||||
const t = useI18n();
|
||||
const { openModal } = useModalAction(AddBoardModal);
|
||||
|
||||
const { mutateAsync, isPending } = clientApi.board.createBoard.useMutation({
|
||||
onSettled: async () => {
|
||||
await revalidatePathAction("/manage/boards");
|
||||
},
|
||||
});
|
||||
|
||||
const onClick = useCallback(() => {
|
||||
openModal({
|
||||
onSuccess: async (values) => {
|
||||
await mutateAsync({
|
||||
name: values.name,
|
||||
});
|
||||
},
|
||||
boardNames,
|
||||
});
|
||||
}, [mutateAsync, boardNames, openModal]);
|
||||
|
||||
return (
|
||||
<Button
|
||||
leftSection={<IconCategoryPlus size="1rem" />}
|
||||
onClick={onClick}
|
||||
loading={isPending}
|
||||
>
|
||||
{t("management.page.board.action.new.label")}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,115 @@
|
||||
import Link from "next/link";
|
||||
import {
|
||||
ActionIcon,
|
||||
Button,
|
||||
Card,
|
||||
CardSection,
|
||||
Grid,
|
||||
GridCol,
|
||||
Group,
|
||||
Menu,
|
||||
MenuTarget,
|
||||
Text,
|
||||
Title,
|
||||
Tooltip,
|
||||
} from "@mantine/core";
|
||||
import { IconDotsVertical, IconLock, IconWorld } from "@tabler/icons-react";
|
||||
|
||||
import "~/styles/gridstack.scss";
|
||||
|
||||
import type { RouterOutputs } from "@homarr/api";
|
||||
import { api } from "@homarr/api/server";
|
||||
import { getScopedI18n } from "@homarr/translation/server";
|
||||
import { UserAvatar } from "@homarr/ui";
|
||||
|
||||
import { getBoardPermissions } from "~/components/board/permissions/server";
|
||||
import { BoardCardMenuDropdown } from "./_components/board-card-menu-dropdown";
|
||||
import { BoardPreview } from "./_components/board-preview";
|
||||
import { CreateBoardButton } from "./_components/create-board-button";
|
||||
|
||||
export default async function ManageBoardsPage() {
|
||||
const t = await getScopedI18n("management.page.board");
|
||||
|
||||
const boards = await api.board.getAllBoards();
|
||||
|
||||
return (
|
||||
<>
|
||||
<Group justify="space-between">
|
||||
<Title mb="md">{t("title")}</Title>
|
||||
<CreateBoardButton boardNames={boards.map((board) => board.name)} />
|
||||
</Group>
|
||||
|
||||
<Grid>
|
||||
{boards.map((board) => (
|
||||
<GridCol span={{ base: 12, md: 6, xl: 4 }} key={board.id}>
|
||||
<BoardCard board={board} />
|
||||
</GridCol>
|
||||
))}
|
||||
</Grid>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
interface BoardCardProps {
|
||||
board: RouterOutputs["board"]["getAllBoards"][number];
|
||||
}
|
||||
|
||||
const BoardCard = async ({ board }: BoardCardProps) => {
|
||||
const t = await getScopedI18n("management.page.board");
|
||||
const { hasChangeAccess: isMenuVisible } = await getBoardPermissions(board);
|
||||
const visibility = board.isPublic ? "public" : "private";
|
||||
const VisibilityIcon = board.isPublic ? IconWorld : IconLock;
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardSection p="sm" withBorder>
|
||||
<Group justify="space-between" align="center">
|
||||
<Group gap="sm">
|
||||
<Tooltip label={t(`visibility.${visibility}`)}>
|
||||
<VisibilityIcon size={20} stroke={1.5} />
|
||||
</Tooltip>
|
||||
<Text fw="bolder" tt="uppercase">
|
||||
{board.name}
|
||||
</Text>
|
||||
</Group>
|
||||
|
||||
{board.creator && (
|
||||
<Group>
|
||||
<Group gap="xs">
|
||||
<UserAvatar user={board.creator} size="sm" />
|
||||
<Text>{board.creator?.name}</Text>
|
||||
</Group>
|
||||
</Group>
|
||||
)}
|
||||
</Group>
|
||||
</CardSection>
|
||||
|
||||
<CardSection p="sm" style={{ aspectRatio: 1920 / 1080 }}>
|
||||
<BoardPreview board={board} />
|
||||
</CardSection>
|
||||
|
||||
<CardSection p="sm">
|
||||
<Group wrap="nowrap">
|
||||
<Button
|
||||
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">
|
||||
<IconDotsVertical size={16} stroke={1.5} />
|
||||
</ActionIcon>
|
||||
</MenuTarget>
|
||||
<BoardCardMenuDropdown board={board} />
|
||||
</Menu>
|
||||
)}
|
||||
</Group>
|
||||
</CardSection>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,19 @@
|
||||
import { Avatar } from "@mantine/core";
|
||||
import type { MantineSize } from "@mantine/core";
|
||||
|
||||
import { getIconUrl } from "@homarr/definitions";
|
||||
import type { IntegrationKind } from "@homarr/definitions";
|
||||
|
||||
interface IntegrationAvatarProps {
|
||||
size: MantineSize;
|
||||
kind: IntegrationKind | null;
|
||||
}
|
||||
|
||||
export const IntegrationAvatar = ({ kind, size }: IntegrationAvatarProps) => {
|
||||
const url = kind ? getIconUrl(kind) : null;
|
||||
if (!url) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <Avatar size={size} src={url} />;
|
||||
};
|
||||
@@ -0,0 +1,70 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { ActionIcon } from "@mantine/core";
|
||||
import { IconTrash } from "@tabler/icons-react";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { useConfirmModal } from "@homarr/modals";
|
||||
import {
|
||||
showErrorNotification,
|
||||
showSuccessNotification,
|
||||
} from "@homarr/notifications";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import { revalidatePathAction } from "../../../revalidatePathAction";
|
||||
|
||||
interface DeleteIntegrationActionButtonProps {
|
||||
count: number;
|
||||
integration: { id: string; name: string };
|
||||
}
|
||||
|
||||
export const DeleteIntegrationActionButton = ({
|
||||
count,
|
||||
integration,
|
||||
}: DeleteIntegrationActionButtonProps) => {
|
||||
const t = useScopedI18n("integration.page.delete");
|
||||
const router = useRouter();
|
||||
const { openConfirmModal } = useConfirmModal();
|
||||
const { mutateAsync, isPending } = clientApi.integration.delete.useMutation();
|
||||
|
||||
return (
|
||||
<ActionIcon
|
||||
loading={isPending}
|
||||
variant="subtle"
|
||||
color="red"
|
||||
onClick={() => {
|
||||
openConfirmModal({
|
||||
title: t("title"),
|
||||
children: t("message", integration),
|
||||
onConfirm: () => {
|
||||
void mutateAsync(
|
||||
{ id: integration.id },
|
||||
{
|
||||
onSuccess: () => {
|
||||
showSuccessNotification({
|
||||
title: t("notification.success.title"),
|
||||
message: t("notification.success.message"),
|
||||
});
|
||||
if (count === 1) {
|
||||
router.replace("/integrations");
|
||||
}
|
||||
void revalidatePathAction("/integrations");
|
||||
},
|
||||
onError: () => {
|
||||
showErrorNotification({
|
||||
title: t("notification.error.title"),
|
||||
message: t("notification.error.message"),
|
||||
});
|
||||
},
|
||||
},
|
||||
);
|
||||
},
|
||||
});
|
||||
}}
|
||||
aria-label="Delete integration"
|
||||
>
|
||||
<IconTrash color="red" size={16} stroke={1.5} />
|
||||
</ActionIcon>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,94 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useParams } from "next/navigation";
|
||||
import {
|
||||
ActionIcon,
|
||||
Avatar,
|
||||
Button,
|
||||
Card,
|
||||
Collapse,
|
||||
Group,
|
||||
Kbd,
|
||||
Stack,
|
||||
Text,
|
||||
} from "@mantine/core";
|
||||
import { useDisclosure } from "@mantine/hooks";
|
||||
import { IconEye, IconEyeOff } from "@tabler/icons-react";
|
||||
import dayjs from "dayjs";
|
||||
import relativeTime from "dayjs/plugin/relativeTime";
|
||||
|
||||
import type { RouterOutputs } from "@homarr/api";
|
||||
import { integrationSecretKindObject } from "@homarr/definitions";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
|
||||
import { integrationSecretIcons } from "./_integration-secret-icons";
|
||||
|
||||
dayjs.extend(relativeTime);
|
||||
|
||||
interface SecretCardProps {
|
||||
secret: RouterOutputs["integration"]["byId"]["secrets"][number];
|
||||
children: React.ReactNode;
|
||||
onCancel: () => Promise<boolean>;
|
||||
}
|
||||
|
||||
export const SecretCard = ({ secret, children, onCancel }: SecretCardProps) => {
|
||||
const params = useParams<{ locale: string }>();
|
||||
const t = useI18n();
|
||||
const { isPublic } = integrationSecretKindObject[secret.kind];
|
||||
const [publicSecretDisplayOpened, { toggle: togglePublicSecretDisplay }] =
|
||||
useDisclosure(false);
|
||||
const [editMode, setEditMode] = useState(false);
|
||||
const DisplayIcon = publicSecretDisplayOpened ? IconEye : IconEyeOff;
|
||||
const KindIcon = integrationSecretIcons[secret.kind];
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<Stack>
|
||||
<Group justify="space-between">
|
||||
<Group>
|
||||
<Avatar>
|
||||
<KindIcon size={16} />
|
||||
</Avatar>
|
||||
<Text fw={500}>
|
||||
{t(`integration.secrets.kind.${secret.kind}.label`)}
|
||||
</Text>
|
||||
{publicSecretDisplayOpened ? <Kbd>{secret.value}</Kbd> : null}
|
||||
</Group>
|
||||
<Group>
|
||||
<Text c="gray.6" size="sm">
|
||||
{t("integration.secrets.lastUpdated", {
|
||||
date: dayjs().locale(params.locale).to(dayjs(secret.updatedAt)),
|
||||
})}
|
||||
</Text>
|
||||
{isPublic ? (
|
||||
<ActionIcon
|
||||
color="gray"
|
||||
variant="subtle"
|
||||
onClick={togglePublicSecretDisplay}
|
||||
>
|
||||
<DisplayIcon size={16} stroke={1.5} />
|
||||
</ActionIcon>
|
||||
) : null}
|
||||
<Button
|
||||
variant="default"
|
||||
onClick={async () => {
|
||||
if (!editMode) {
|
||||
setEditMode(true);
|
||||
return;
|
||||
}
|
||||
|
||||
const shouldCancel = await onCancel();
|
||||
if (!shouldCancel) return;
|
||||
setEditMode(false);
|
||||
}}
|
||||
>
|
||||
{editMode ? t("common.action.cancel") : t("common.action.edit")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Group>
|
||||
<Collapse in={editMode}>{children}</Collapse>
|
||||
</Stack>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,10 @@
|
||||
import { IconKey, IconPassword, IconUser } from "@tabler/icons-react";
|
||||
|
||||
import type { IntegrationSecretKind } from "@homarr/definitions";
|
||||
import type { TablerIcon } from "@homarr/ui";
|
||||
|
||||
export const integrationSecretIcons = {
|
||||
username: IconUser,
|
||||
apiKey: IconKey,
|
||||
password: IconPassword,
|
||||
} satisfies Record<IntegrationSecretKind, TablerIcon>;
|
||||
@@ -0,0 +1,60 @@
|
||||
"use client";
|
||||
|
||||
import type { ChangeEventHandler, FocusEventHandler } from "react";
|
||||
import { PasswordInput, TextInput } from "@mantine/core";
|
||||
|
||||
import { integrationSecretKindObject } from "@homarr/definitions";
|
||||
import type { IntegrationSecretKind } from "@homarr/definitions";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
|
||||
import { integrationSecretIcons } from "./_integration-secret-icons";
|
||||
|
||||
interface IntegrationSecretInputProps {
|
||||
label?: string;
|
||||
kind: IntegrationSecretKind;
|
||||
value?: string;
|
||||
onChange: ChangeEventHandler<HTMLInputElement>;
|
||||
onFocus?: FocusEventHandler<HTMLInputElement>;
|
||||
onBlur?: FocusEventHandler<HTMLInputElement>;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export const IntegrationSecretInput = (props: IntegrationSecretInputProps) => {
|
||||
const { isPublic } = integrationSecretKindObject[props.kind];
|
||||
|
||||
if (isPublic) return <PublicSecretInput {...props} />;
|
||||
|
||||
return <PrivateSecretInput {...props} />;
|
||||
};
|
||||
|
||||
const PublicSecretInput = ({ kind, ...props }: IntegrationSecretInputProps) => {
|
||||
const t = useI18n();
|
||||
const Icon = integrationSecretIcons[kind];
|
||||
|
||||
return (
|
||||
<TextInput
|
||||
{...props}
|
||||
label={props.label ?? t(`integration.secrets.kind.${kind}.label`)}
|
||||
w="100%"
|
||||
leftSection={<Icon size={20} stroke={1.5} />}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const PrivateSecretInput = ({
|
||||
kind,
|
||||
...props
|
||||
}: IntegrationSecretInputProps) => {
|
||||
const t = useI18n();
|
||||
const Icon = integrationSecretIcons[kind];
|
||||
|
||||
return (
|
||||
<PasswordInput
|
||||
{...props}
|
||||
label={props.label ?? t(`integration.secrets.kind.${kind}.label`)}
|
||||
description={t(`integration.secrets.secureNotice`)}
|
||||
w="100%"
|
||||
leftSection={<Icon size={20} stroke={1.5} />}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,154 @@
|
||||
"use client";
|
||||
|
||||
import { useRef, useState } from "react";
|
||||
import { Alert, Anchor, Group, Loader } from "@mantine/core";
|
||||
import { IconCheck, IconInfoCircle, IconX } from "@tabler/icons-react";
|
||||
|
||||
import type { RouterInputs } from "@homarr/api";
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import {
|
||||
showErrorNotification,
|
||||
showSuccessNotification,
|
||||
} from "@homarr/notifications";
|
||||
import { useI18n, useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
interface UseTestConnectionDirtyProps {
|
||||
defaultDirty: boolean;
|
||||
initialFormValue: {
|
||||
url: string;
|
||||
secrets: { kind: string; value: string | null }[];
|
||||
};
|
||||
}
|
||||
|
||||
export const useTestConnectionDirty = ({
|
||||
defaultDirty,
|
||||
initialFormValue,
|
||||
}: UseTestConnectionDirtyProps) => {
|
||||
const [isDirty, setIsDirty] = useState(defaultDirty);
|
||||
const prevFormValueRef = useRef(initialFormValue);
|
||||
|
||||
return {
|
||||
onValuesChange: (values: typeof initialFormValue) => {
|
||||
if (isDirty) return;
|
||||
|
||||
// If relevant values changed, set dirty
|
||||
if (
|
||||
prevFormValueRef.current.url !== values.url ||
|
||||
!prevFormValueRef.current.secrets
|
||||
.map((secret) => secret.value)
|
||||
.every(
|
||||
(secretValue, index) =>
|
||||
values.secrets[index]?.value === secretValue,
|
||||
)
|
||||
) {
|
||||
setIsDirty(true);
|
||||
return;
|
||||
}
|
||||
|
||||
// If relevant values changed back to last tested, set not dirty
|
||||
setIsDirty(false);
|
||||
},
|
||||
isDirty,
|
||||
removeDirty: () => {
|
||||
prevFormValueRef.current = initialFormValue;
|
||||
setIsDirty(false);
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
interface TestConnectionProps {
|
||||
isDirty: boolean;
|
||||
removeDirty: () => void;
|
||||
integration: RouterInputs["integration"]["testConnection"] & { name: string };
|
||||
}
|
||||
|
||||
export const TestConnection = ({
|
||||
integration,
|
||||
removeDirty,
|
||||
isDirty,
|
||||
}: TestConnectionProps) => {
|
||||
const t = useScopedI18n("integration.testConnection");
|
||||
const { mutateAsync, ...mutation } =
|
||||
clientApi.integration.testConnection.useMutation();
|
||||
|
||||
return (
|
||||
<Group>
|
||||
<Anchor
|
||||
type="button"
|
||||
component="button"
|
||||
onClick={async () => {
|
||||
await mutateAsync(integration, {
|
||||
onSuccess: () => {
|
||||
removeDirty();
|
||||
showSuccessNotification({
|
||||
title: t("notification.success.title"),
|
||||
message: t("notification.success.message"),
|
||||
});
|
||||
},
|
||||
onError: (error) => {
|
||||
if (error.data?.zodError?.fieldErrors.url) {
|
||||
showErrorNotification({
|
||||
title: t("notification.invalidUrl.title"),
|
||||
message: t("notification.invalidUrl.message"),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (error.message === "SECRETS_NOT_DEFINED") {
|
||||
showErrorNotification({
|
||||
title: t("notification.notAllSecretsProvided.title"),
|
||||
message: t("notification.notAllSecretsProvided.message"),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
showErrorNotification({
|
||||
title: t("notification.commonError.title"),
|
||||
message: t("notification.commonError.message"),
|
||||
});
|
||||
},
|
||||
});
|
||||
}}
|
||||
>
|
||||
{t("action")}
|
||||
</Anchor>
|
||||
<TestConnectionIcon isDirty={isDirty} {...mutation} size={20} />
|
||||
</Group>
|
||||
);
|
||||
};
|
||||
|
||||
interface TestConnectionIconProps {
|
||||
isDirty: boolean;
|
||||
isPending: boolean;
|
||||
isSuccess: boolean;
|
||||
isError: boolean;
|
||||
size: number;
|
||||
}
|
||||
|
||||
const TestConnectionIcon = ({
|
||||
isDirty,
|
||||
isPending,
|
||||
isSuccess,
|
||||
isError,
|
||||
size,
|
||||
}: TestConnectionIconProps) => {
|
||||
if (isPending) return <Loader color="blue" size={size} />;
|
||||
if (isDirty) return null;
|
||||
if (isSuccess) return <IconCheck size={size} stroke={1.5} color="green" />;
|
||||
if (isError) return <IconX size={size} stroke={1.5} color="red" />;
|
||||
return null;
|
||||
};
|
||||
|
||||
export const TestConnectionNoticeAlert = () => {
|
||||
const t = useI18n();
|
||||
return (
|
||||
<Alert
|
||||
variant="light"
|
||||
color="yellow"
|
||||
title="Test Connection"
|
||||
icon={<IconInfoCircle />}
|
||||
>
|
||||
{t("integration.testConnection.alertNotice")}
|
||||
</Alert>
|
||||
);
|
||||
};
|
||||
+180
@@ -0,0 +1,180 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Button, Fieldset, Group, Stack, TextInput } from "@mantine/core";
|
||||
|
||||
import type { RouterOutputs } from "@homarr/api";
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import {
|
||||
getAllSecretKindOptions,
|
||||
getDefaultSecretKinds,
|
||||
} from "@homarr/definitions";
|
||||
import { useForm, zodResolver } from "@homarr/form";
|
||||
import { useConfirmModal } from "@homarr/modals";
|
||||
import {
|
||||
showErrorNotification,
|
||||
showSuccessNotification,
|
||||
} from "@homarr/notifications";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import type { z } from "@homarr/validation";
|
||||
import { validation } from "@homarr/validation";
|
||||
|
||||
import { revalidatePathAction } from "~/app/revalidatePathAction";
|
||||
import { SecretCard } from "../../_integration-secret-card";
|
||||
import { IntegrationSecretInput } from "../../_integration-secret-inputs";
|
||||
import {
|
||||
TestConnection,
|
||||
TestConnectionNoticeAlert,
|
||||
useTestConnectionDirty,
|
||||
} from "../../_integration-test-connection";
|
||||
|
||||
interface EditIntegrationForm {
|
||||
integration: RouterOutputs["integration"]["byId"];
|
||||
}
|
||||
|
||||
export const EditIntegrationForm = ({ integration }: EditIntegrationForm) => {
|
||||
const t = useI18n();
|
||||
const { openConfirmModal } = useConfirmModal();
|
||||
const secretsKinds =
|
||||
getAllSecretKindOptions(integration.kind).find((secretKinds) =>
|
||||
integration.secrets.every((secret) => secretKinds.includes(secret.kind)),
|
||||
) ?? getDefaultSecretKinds(integration.kind);
|
||||
const initialFormValues = {
|
||||
name: integration.name,
|
||||
url: integration.url,
|
||||
secrets: secretsKinds.map((kind) => ({
|
||||
kind,
|
||||
value:
|
||||
integration.secrets.find((secret) => secret.kind === kind)?.value ?? "",
|
||||
})),
|
||||
};
|
||||
const { isDirty, onValuesChange, removeDirty } = useTestConnectionDirty({
|
||||
defaultDirty: true,
|
||||
initialFormValue: initialFormValues,
|
||||
});
|
||||
|
||||
const router = useRouter();
|
||||
const form = useForm<FormType>({
|
||||
initialValues: initialFormValues,
|
||||
validate: zodResolver(validation.integration.update.omit({ id: true })),
|
||||
onValuesChange,
|
||||
});
|
||||
const { mutateAsync, isPending } = clientApi.integration.update.useMutation();
|
||||
|
||||
const secretsMap = new Map(
|
||||
integration.secrets.map((secret) => [secret.kind, secret]),
|
||||
);
|
||||
|
||||
const handleSubmit = async (values: FormType) => {
|
||||
if (isDirty) return;
|
||||
await mutateAsync(
|
||||
{
|
||||
id: integration.id,
|
||||
...values,
|
||||
secrets: values.secrets.map((secret) => ({
|
||||
kind: secret.kind,
|
||||
value: secret.value === "" ? null : secret.value,
|
||||
})),
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
showSuccessNotification({
|
||||
title: t("integration.page.edit.notification.success.title"),
|
||||
message: t("integration.page.edit.notification.success.message"),
|
||||
});
|
||||
void revalidatePathAction("/integrations").then(() =>
|
||||
router.push("/integrations"),
|
||||
);
|
||||
},
|
||||
onError: () => {
|
||||
showErrorNotification({
|
||||
title: t("integration.page.edit.notification.error.title"),
|
||||
message: t("integration.page.edit.notification.error.message"),
|
||||
});
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={form.onSubmit((values) => void handleSubmit(values))}>
|
||||
<Stack>
|
||||
<TestConnectionNoticeAlert />
|
||||
|
||||
<TextInput
|
||||
label={t("integration.field.name.label")}
|
||||
{...form.getInputProps("name")}
|
||||
/>
|
||||
|
||||
<TextInput
|
||||
label={t("integration.field.url.label")}
|
||||
{...form.getInputProps("url")}
|
||||
/>
|
||||
|
||||
<Fieldset legend={t("integration.secrets.title")}>
|
||||
<Stack gap="sm">
|
||||
{secretsKinds.map((kind, index) => (
|
||||
<SecretCard
|
||||
key={kind}
|
||||
secret={secretsMap.get(kind)!}
|
||||
onCancel={() =>
|
||||
new Promise((res) => {
|
||||
// When nothing changed, just close the secret card
|
||||
if (
|
||||
(form.values.secrets[index]?.value ?? "") ===
|
||||
(secretsMap.get(kind)?.value ?? "")
|
||||
) {
|
||||
return res(true);
|
||||
}
|
||||
openConfirmModal({
|
||||
title: t("integration.secrets.reset.title"),
|
||||
children: t("integration.secrets.reset.message"),
|
||||
onCancel: () => res(false),
|
||||
onConfirm: () => {
|
||||
form.setFieldValue(
|
||||
`secrets.${index}.value`,
|
||||
secretsMap.get(kind)!.value ?? "",
|
||||
);
|
||||
res(true);
|
||||
},
|
||||
});
|
||||
})
|
||||
}
|
||||
>
|
||||
<IntegrationSecretInput
|
||||
label={t(`integration.secrets.kind.${kind}.newLabel`)}
|
||||
key={kind}
|
||||
kind={kind}
|
||||
{...form.getInputProps(`secrets.${index}.value`)}
|
||||
/>
|
||||
</SecretCard>
|
||||
))}
|
||||
</Stack>
|
||||
</Fieldset>
|
||||
|
||||
<Group justify="space-between" align="center">
|
||||
<TestConnection
|
||||
isDirty={isDirty}
|
||||
removeDirty={removeDirty}
|
||||
integration={{
|
||||
id: integration.id,
|
||||
kind: integration.kind,
|
||||
...form.values,
|
||||
}}
|
||||
/>
|
||||
<Group>
|
||||
<Button variant="default" component={Link} href="/integrations">
|
||||
{t("common.action.backToOverview")}
|
||||
</Button>
|
||||
<Button type="submit" loading={isPending} disabled={isDirty}>
|
||||
{t("common.action.save")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Group>
|
||||
</Stack>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
type FormType = Omit<z.infer<typeof validation.integration.update>, "id">;
|
||||
@@ -0,0 +1,33 @@
|
||||
import { Container, Group, Stack, Title } from "@mantine/core";
|
||||
|
||||
import { api } from "@homarr/api/server";
|
||||
import { getIntegrationName } from "@homarr/definitions";
|
||||
import { getScopedI18n } from "@homarr/translation/server";
|
||||
|
||||
import { IntegrationAvatar } from "../../_integration-avatar";
|
||||
import { EditIntegrationForm } from "./_integration-edit-form";
|
||||
|
||||
interface EditIntegrationPageProps {
|
||||
params: { id: string };
|
||||
}
|
||||
|
||||
export default async function EditIntegrationPage({
|
||||
params,
|
||||
}: EditIntegrationPageProps) {
|
||||
const t = await getScopedI18n("integration.page.edit");
|
||||
const integration = await api.integration.byId({ id: params.id });
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<Stack>
|
||||
<Group align="center">
|
||||
<IntegrationAvatar kind={integration.kind} size="md" />
|
||||
<Title>
|
||||
{t("title", { name: getIntegrationName(integration.kind) })}
|
||||
</Title>
|
||||
</Group>
|
||||
<EditIntegrationForm integration={integration} />
|
||||
</Stack>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
"use client";
|
||||
|
||||
import React, { ChangeEvent, useMemo, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { Group, Menu, ScrollArea, Stack, Text, TextInput } from "@mantine/core";
|
||||
import { IconSearch } from "@tabler/icons-react";
|
||||
|
||||
import { getIntegrationName, integrationKinds } from "@homarr/definitions";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
|
||||
import { IntegrationAvatar } from "../_integration-avatar";
|
||||
|
||||
export const IntegrationCreateDropdownContent = () => {
|
||||
const t = useI18n();
|
||||
const [search, setSearch] = useState("");
|
||||
|
||||
const filteredKinds = useMemo(() => {
|
||||
return integrationKinds.filter((kind) =>
|
||||
kind.includes(search.toLowerCase()),
|
||||
);
|
||||
}, [search]);
|
||||
|
||||
const handleSearch = React.useCallback(
|
||||
(event: ChangeEvent<HTMLInputElement>) => setSearch(event.target.value),
|
||||
[setSearch],
|
||||
);
|
||||
|
||||
return (
|
||||
<Stack>
|
||||
<TextInput
|
||||
leftSection={<IconSearch stroke={1.5} size={20} />}
|
||||
placeholder={t("integration.page.list.search")}
|
||||
value={search}
|
||||
onChange={handleSearch}
|
||||
/>
|
||||
|
||||
{filteredKinds.length > 0 ? (
|
||||
<ScrollArea.Autosize mah={384}>
|
||||
{filteredKinds.map((kind) => (
|
||||
<Menu.Item
|
||||
component={Link}
|
||||
href={`/integrations/new?kind=${kind}`}
|
||||
key={kind}
|
||||
>
|
||||
<Group>
|
||||
<IntegrationAvatar kind={kind} size="sm" />
|
||||
<Text size="sm">{getIntegrationName(kind)}</Text>
|
||||
</Group>
|
||||
</Menu.Item>
|
||||
))}
|
||||
</ScrollArea.Autosize>
|
||||
) : (
|
||||
<Menu.Item disabled>{t("common.noResults")}</Menu.Item>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,192 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback } from "react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import {
|
||||
Button,
|
||||
Fieldset,
|
||||
Group,
|
||||
SegmentedControl,
|
||||
Stack,
|
||||
TextInput,
|
||||
} from "@mantine/core";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import type {
|
||||
IntegrationKind,
|
||||
IntegrationSecretKind,
|
||||
} from "@homarr/definitions";
|
||||
import { getAllSecretKindOptions } from "@homarr/definitions";
|
||||
import type { UseFormReturnType } from "@homarr/form";
|
||||
import { useForm, zodResolver } from "@homarr/form";
|
||||
import {
|
||||
showErrorNotification,
|
||||
showSuccessNotification,
|
||||
} from "@homarr/notifications";
|
||||
import { useI18n, useScopedI18n } from "@homarr/translation/client";
|
||||
import type { z } from "@homarr/validation";
|
||||
import { validation } from "@homarr/validation";
|
||||
|
||||
import { IntegrationSecretInput } from "../_integration-secret-inputs";
|
||||
import {
|
||||
TestConnection,
|
||||
TestConnectionNoticeAlert,
|
||||
useTestConnectionDirty,
|
||||
} from "../_integration-test-connection";
|
||||
import { revalidatePathAction } from "../../../../revalidatePathAction";
|
||||
|
||||
interface NewIntegrationFormProps {
|
||||
searchParams: Partial<z.infer<typeof validation.integration.create>> & {
|
||||
kind: IntegrationKind;
|
||||
};
|
||||
}
|
||||
|
||||
export const NewIntegrationForm = ({
|
||||
searchParams,
|
||||
}: NewIntegrationFormProps) => {
|
||||
const t = useI18n();
|
||||
const secretKinds = getAllSecretKindOptions(searchParams.kind);
|
||||
const initialFormValues = {
|
||||
name: searchParams.name ?? "",
|
||||
url: searchParams.url ?? "",
|
||||
secrets: secretKinds[0].map((kind) => ({
|
||||
kind,
|
||||
value: "",
|
||||
})),
|
||||
};
|
||||
const { isDirty, onValuesChange, removeDirty } = useTestConnectionDirty({
|
||||
defaultDirty: true,
|
||||
initialFormValue: initialFormValues,
|
||||
});
|
||||
const router = useRouter();
|
||||
const form = useForm<FormType>({
|
||||
initialValues: initialFormValues,
|
||||
validate: zodResolver(validation.integration.create.omit({ kind: true })),
|
||||
onValuesChange,
|
||||
});
|
||||
const { mutateAsync, isPending } = clientApi.integration.create.useMutation();
|
||||
|
||||
const handleSubmit = async (values: FormType) => {
|
||||
if (isDirty) return;
|
||||
await mutateAsync(
|
||||
{
|
||||
kind: searchParams.kind,
|
||||
...values,
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
showSuccessNotification({
|
||||
title: t("integration.page.create.notification.success.title"),
|
||||
message: t("integration.page.create.notification.success.message"),
|
||||
});
|
||||
void revalidatePathAction("/integrations").then(() =>
|
||||
router.push("/integrations"),
|
||||
);
|
||||
},
|
||||
onError: () => {
|
||||
showErrorNotification({
|
||||
title: t("integration.page.create.notification.error.title"),
|
||||
message: t("integration.page.create.notification.error.message"),
|
||||
});
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={form.onSubmit((value) => void handleSubmit(value))}>
|
||||
<Stack>
|
||||
<TestConnectionNoticeAlert />
|
||||
|
||||
<TextInput
|
||||
label={t("integration.field.name.label")}
|
||||
{...form.getInputProps("name")}
|
||||
/>
|
||||
|
||||
<TextInput
|
||||
label={t("integration.field.url.label")}
|
||||
{...form.getInputProps("url")}
|
||||
/>
|
||||
|
||||
<Fieldset legend={t("integration.secrets.title")}>
|
||||
<Stack gap="sm">
|
||||
{secretKinds.length > 1 && (
|
||||
<SecretKindsSegmentedControl
|
||||
secretKinds={secretKinds}
|
||||
form={form}
|
||||
/>
|
||||
)}
|
||||
{form.values.secrets.map(({ kind }, index) => (
|
||||
<IntegrationSecretInput
|
||||
key={kind}
|
||||
kind={kind}
|
||||
{...form.getInputProps(`secrets.${index}.value`)}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
</Fieldset>
|
||||
|
||||
<Group justify="space-between" align="center">
|
||||
<TestConnection
|
||||
isDirty={isDirty}
|
||||
removeDirty={removeDirty}
|
||||
integration={{
|
||||
id: null,
|
||||
kind: searchParams.kind,
|
||||
...form.values,
|
||||
}}
|
||||
/>
|
||||
|
||||
<Group>
|
||||
<Button variant="default" component={Link} href="/integrations">
|
||||
{t("common.action.backToOverview")}
|
||||
</Button>
|
||||
<Button type="submit" loading={isPending} disabled={isDirty}>
|
||||
{t("common.action.create")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Group>
|
||||
</Stack>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
interface SecretKindsSegmentedControlProps {
|
||||
secretKinds: IntegrationSecretKind[][];
|
||||
form: UseFormReturnType<FormType, (values: FormType) => FormType>;
|
||||
}
|
||||
|
||||
const SecretKindsSegmentedControl = ({
|
||||
secretKinds,
|
||||
form,
|
||||
}: SecretKindsSegmentedControlProps) => {
|
||||
const t = useScopedI18n("integration.secrets");
|
||||
|
||||
const secretKindGroups = secretKinds.map((kinds) => ({
|
||||
label: kinds.map((kind) => t(`kind.${kind}.label`)).join(" & "),
|
||||
value: kinds.join("-"),
|
||||
}));
|
||||
|
||||
const onChange = useCallback(
|
||||
(value: string) => {
|
||||
const kinds = value.split("-") as IntegrationSecretKind[];
|
||||
const secrets = kinds.map((kind) => ({
|
||||
kind,
|
||||
value: "",
|
||||
}));
|
||||
form.setFieldValue("secrets", secrets);
|
||||
},
|
||||
[form],
|
||||
);
|
||||
|
||||
return (
|
||||
<SegmentedControl
|
||||
fullWidth
|
||||
data={secretKindGroups}
|
||||
onChange={onChange}
|
||||
></SegmentedControl>
|
||||
);
|
||||
};
|
||||
|
||||
type FormType = Omit<z.infer<typeof validation.integration.create>, "kind">;
|
||||
@@ -0,0 +1,44 @@
|
||||
import { notFound } from "next/navigation";
|
||||
import { Container, Group, Stack, Title } from "@mantine/core";
|
||||
|
||||
import type { IntegrationKind } from "@homarr/definitions";
|
||||
import { getIntegrationName, integrationKinds } from "@homarr/definitions";
|
||||
import { getScopedI18n } from "@homarr/translation/server";
|
||||
import type { validation } from "@homarr/validation";
|
||||
import { z } from "@homarr/validation";
|
||||
|
||||
import { IntegrationAvatar } from "../_integration-avatar";
|
||||
import { NewIntegrationForm } from "./_integration-new-form";
|
||||
|
||||
interface NewIntegrationPageProps {
|
||||
searchParams: Partial<z.infer<typeof validation.integration.create>> & {
|
||||
kind: IntegrationKind;
|
||||
};
|
||||
}
|
||||
|
||||
export default async function IntegrationsNewPage({
|
||||
searchParams,
|
||||
}: NewIntegrationPageProps) {
|
||||
const result = z
|
||||
.enum([integrationKinds[0]!, ...integrationKinds.slice(1)])
|
||||
.safeParse(searchParams.kind);
|
||||
if (!result.success) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const t = await getScopedI18n("integration.page.create");
|
||||
|
||||
const currentKind = result.data;
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<Stack>
|
||||
<Group align="center">
|
||||
<IntegrationAvatar kind={currentKind} size="md" />
|
||||
<Title>{t("title", { name: getIntegrationName(currentKind) })}</Title>
|
||||
</Group>
|
||||
<NewIntegrationForm searchParams={searchParams} />
|
||||
</Stack>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
import Link from "next/link";
|
||||
import {
|
||||
AccordionControl,
|
||||
AccordionItem,
|
||||
AccordionPanel,
|
||||
ActionIcon,
|
||||
ActionIconGroup,
|
||||
Anchor,
|
||||
Button,
|
||||
Container,
|
||||
Group,
|
||||
Menu,
|
||||
MenuDropdown,
|
||||
MenuTarget,
|
||||
Stack,
|
||||
Table,
|
||||
TableTbody,
|
||||
TableTd,
|
||||
TableTh,
|
||||
TableThead,
|
||||
TableTr,
|
||||
Text,
|
||||
Title,
|
||||
} from "@mantine/core";
|
||||
import { IconChevronDown, IconPencil } from "@tabler/icons-react";
|
||||
|
||||
import type { RouterOutputs } from "@homarr/api";
|
||||
import { api } from "@homarr/api/server";
|
||||
import { objectEntries } from "@homarr/common";
|
||||
import type { IntegrationKind } from "@homarr/definitions";
|
||||
import { getIntegrationName } from "@homarr/definitions";
|
||||
import { getScopedI18n } from "@homarr/translation/server";
|
||||
import { CountBadge } from "@homarr/ui";
|
||||
|
||||
import { ActiveTabAccordion } from "../../../../components/active-tab-accordion";
|
||||
import { IntegrationAvatar } from "./_integration-avatar";
|
||||
import { DeleteIntegrationActionButton } from "./_integration-buttons";
|
||||
import { IntegrationCreateDropdownContent } from "./new/_integration-new-dropdown";
|
||||
|
||||
interface IntegrationsPageProps {
|
||||
searchParams: {
|
||||
tab?: IntegrationKind;
|
||||
};
|
||||
}
|
||||
|
||||
export default async function IntegrationsPage({
|
||||
searchParams,
|
||||
}: IntegrationsPageProps) {
|
||||
const integrations = await api.integration.all();
|
||||
const t = await getScopedI18n("integration");
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<Stack>
|
||||
<Group justify="space-between" align="center">
|
||||
<Title>{t("page.list.title")}</Title>
|
||||
<Menu
|
||||
width={256}
|
||||
trapFocus
|
||||
position="bottom-start"
|
||||
withinPortal
|
||||
shadow="md"
|
||||
keepMounted={false}
|
||||
>
|
||||
<MenuTarget>
|
||||
<Button rightSection={<IconChevronDown size={16} stroke={1.5} />}>
|
||||
{t("action.create")}
|
||||
</Button>
|
||||
</MenuTarget>
|
||||
<MenuDropdown>
|
||||
<IntegrationCreateDropdownContent />
|
||||
</MenuDropdown>
|
||||
</Menu>
|
||||
</Group>
|
||||
|
||||
<IntegrationList
|
||||
integrations={integrations}
|
||||
activeTab={searchParams.tab}
|
||||
/>
|
||||
</Stack>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
interface IntegrationListProps {
|
||||
integrations: RouterOutputs["integration"]["all"];
|
||||
activeTab?: IntegrationKind;
|
||||
}
|
||||
|
||||
const IntegrationList = async ({
|
||||
integrations,
|
||||
activeTab,
|
||||
}: IntegrationListProps) => {
|
||||
const t = await getScopedI18n("integration");
|
||||
|
||||
if (integrations.length === 0) {
|
||||
return <div>{t("page.list.empty")}</div>;
|
||||
}
|
||||
|
||||
const grouppedIntegrations = integrations.reduce(
|
||||
(acc, integration) => {
|
||||
if (!acc[integration.kind]) {
|
||||
acc[integration.kind] = [];
|
||||
}
|
||||
|
||||
acc[integration.kind].push(integration);
|
||||
|
||||
return acc;
|
||||
},
|
||||
{} as Record<IntegrationKind, RouterOutputs["integration"]["all"]>,
|
||||
);
|
||||
|
||||
return (
|
||||
<ActiveTabAccordion defaultValue={activeTab} variant="separated">
|
||||
{objectEntries(grouppedIntegrations).map(([kind, integrations]) => (
|
||||
<AccordionItem key={kind} value={kind}>
|
||||
<AccordionControl icon={<IntegrationAvatar size="sm" kind={kind} />}>
|
||||
<Group>
|
||||
<Text>{getIntegrationName(kind)}</Text>
|
||||
<CountBadge count={integrations.length} />
|
||||
</Group>
|
||||
</AccordionControl>
|
||||
<AccordionPanel>
|
||||
<Table>
|
||||
<TableThead>
|
||||
<TableTr>
|
||||
<TableTh>{t("field.name.label")}</TableTh>
|
||||
<TableTh>{t("field.url.label")}</TableTh>
|
||||
<TableTh />
|
||||
</TableTr>
|
||||
</TableThead>
|
||||
<TableTbody>
|
||||
{integrations.map((integration) => (
|
||||
<TableTr key={integration.id}>
|
||||
<TableTd>{integration.name}</TableTd>
|
||||
<TableTd>
|
||||
<Anchor
|
||||
href={integration.url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
size="sm"
|
||||
>
|
||||
{integration.url}
|
||||
</Anchor>
|
||||
</TableTd>
|
||||
<TableTd>
|
||||
<Group justify="end">
|
||||
<ActionIconGroup>
|
||||
<ActionIcon
|
||||
component={Link}
|
||||
href={`/integrations/edit/${integration.id}`}
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
aria-label="Edit integration"
|
||||
>
|
||||
<IconPencil size={16} stroke={1.5} />
|
||||
</ActionIcon>
|
||||
<DeleteIntegrationActionButton
|
||||
integration={integration}
|
||||
count={integrations.length}
|
||||
/>
|
||||
</ActionIconGroup>
|
||||
</Group>
|
||||
</TableTd>
|
||||
</TableTr>
|
||||
))}
|
||||
</TableTbody>
|
||||
</Table>
|
||||
</AccordionPanel>
|
||||
</AccordionItem>
|
||||
))}
|
||||
</ActiveTabAccordion>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,134 @@
|
||||
import type { PropsWithChildren } from "react";
|
||||
import { AppShellMain } from "@mantine/core";
|
||||
import {
|
||||
IconBook2,
|
||||
IconBox,
|
||||
IconBrandDiscord,
|
||||
IconBrandDocker,
|
||||
IconBrandGithub,
|
||||
IconGitFork,
|
||||
IconHome,
|
||||
IconInfoSmall,
|
||||
IconLayoutDashboard,
|
||||
IconLogs,
|
||||
IconMailForward,
|
||||
IconPlug,
|
||||
IconQuestionMark,
|
||||
IconTool,
|
||||
IconUser,
|
||||
IconUsers,
|
||||
IconUsersGroup,
|
||||
} from "@tabler/icons-react";
|
||||
|
||||
import { getScopedI18n } from "@homarr/translation/server";
|
||||
|
||||
import { MainHeader } from "~/components/layout/header";
|
||||
import type { NavigationLink } from "~/components/layout/navigation";
|
||||
import { MainNavigation } from "~/components/layout/navigation";
|
||||
import { ClientShell } from "~/components/layout/shell";
|
||||
|
||||
export default async function ManageLayout({ children }: PropsWithChildren) {
|
||||
const t = await getScopedI18n("management.navbar");
|
||||
const navigationLinks: NavigationLink[] = [
|
||||
{
|
||||
label: t("items.home"),
|
||||
icon: IconHome,
|
||||
href: "/manage",
|
||||
},
|
||||
{
|
||||
icon: IconLayoutDashboard,
|
||||
href: "/manage/boards",
|
||||
label: t("items.boards"),
|
||||
},
|
||||
{
|
||||
icon: IconBox,
|
||||
href: "/manage/apps",
|
||||
label: t("items.apps"),
|
||||
},
|
||||
{
|
||||
icon: IconPlug,
|
||||
href: "/manage/integrations",
|
||||
label: t("items.integrations"),
|
||||
},
|
||||
{
|
||||
icon: IconUser,
|
||||
label: t("items.users.label"),
|
||||
items: [
|
||||
{
|
||||
label: t("items.users.items.manage"),
|
||||
icon: IconUsers,
|
||||
href: "/manage/users",
|
||||
},
|
||||
{
|
||||
label: t("items.users.items.invites"),
|
||||
icon: IconMailForward,
|
||||
href: "/manage/users/invites",
|
||||
},
|
||||
{
|
||||
label: t("items.users.items.groups"),
|
||||
icon: IconUsersGroup,
|
||||
href: "/manage/users/groups",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: t("items.tools.label"),
|
||||
icon: IconTool,
|
||||
items: [
|
||||
{
|
||||
label: t("items.tools.items.docker"),
|
||||
icon: IconBrandDocker,
|
||||
href: "/manage/tools/docker",
|
||||
},
|
||||
{
|
||||
label: t("items.tools.items.logs"),
|
||||
icon: IconLogs,
|
||||
href: "/manage/tools/logs",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: t("items.help.label"),
|
||||
icon: IconQuestionMark,
|
||||
items: [
|
||||
{
|
||||
label: t("items.help.items.documentation"),
|
||||
icon: IconBook2,
|
||||
href: "https://homarr.dev/docs/getting-started/prerequisites",
|
||||
external: true,
|
||||
},
|
||||
{
|
||||
label: t("items.help.items.submitIssue"),
|
||||
icon: IconBrandGithub,
|
||||
href: "https://github.com/ajnart/homarr/issues/new/choose",
|
||||
external: true,
|
||||
},
|
||||
{
|
||||
label: t("items.tools.items.docker"),
|
||||
icon: IconBrandDiscord,
|
||||
href: "https://discord.com/invite/aCsmEV5RgA",
|
||||
external: true,
|
||||
},
|
||||
{
|
||||
label: t("items.help.items.sourceCode"),
|
||||
icon: IconGitFork,
|
||||
href: "https://github.com/ajnart/homarr",
|
||||
external: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: t("items.about"),
|
||||
icon: IconInfoSmall,
|
||||
href: "/manage/about",
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<ClientShell hasNavigation>
|
||||
<MainHeader></MainHeader>
|
||||
<MainNavigation links={navigationLinks}></MainNavigation>
|
||||
<AppShellMain>{children}</AppShellMain>
|
||||
</ClientShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { Title } from "@mantine/core";
|
||||
|
||||
import { getScopedI18n } from "@homarr/translation/server";
|
||||
|
||||
import { Test } from "./test";
|
||||
|
||||
export async function generateMetadata() {
|
||||
const t = await getScopedI18n("management");
|
||||
const metaTitle = `${t("metaTitle")} • Homarr`;
|
||||
|
||||
return {
|
||||
title: metaTitle,
|
||||
};
|
||||
}
|
||||
|
||||
export default async function ManagementPage() {
|
||||
const t = await getScopedI18n("management.title");
|
||||
|
||||
const dateNow = new Date();
|
||||
const timeOfDay =
|
||||
dateNow.getHours() < 10
|
||||
? "morning"
|
||||
: dateNow.getHours() < 17
|
||||
? "afternoon"
|
||||
: "evening";
|
||||
|
||||
return (
|
||||
<>
|
||||
<Title>{t(timeOfDay, { username: "admin" })}</Title>
|
||||
<Test />
|
||||
</>
|
||||
);
|
||||
}
|
||||
Loaded 100 of 449 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user