Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
932426550b | ||
|
|
d0ede4d643 | ||
|
|
14da6727e0 | ||
|
|
a88c1ba440 | ||
|
|
5537f95b85 | ||
|
|
d8e7dfa2fa | ||
|
|
06ba0f0e9f | ||
|
|
11d2972713 | ||
|
|
4483dc8bcd | ||
|
|
50367eb2f5 | ||
|
|
fdd91f585d | ||
|
|
add91701de | ||
|
|
8c1308c97f | ||
|
|
b2cc97e316 | ||
|
|
46ff689a7e | ||
|
|
2b40233235 | ||
|
|
d6939e4a6d | ||
|
|
c2d6e2efcf | ||
|
|
0f2fc0505c | ||
|
|
faf7322bcb | ||
|
|
3b830ae098 | ||
|
|
a6f91998c3 | ||
|
|
874414fc4f | ||
|
|
fda3aff170 | ||
|
|
a9a37afd44 | ||
|
|
2f3d297c46 | ||
|
|
8355bb5919 | ||
|
|
46070fa5a3 | ||
|
|
593c50bb8f | ||
|
|
508f9afeb5 | ||
|
|
ef9289d945 | ||
|
|
9c892eacfc | ||
|
|
7761dc29c8 | ||
|
|
2085b5ece2 | ||
|
|
c5107b2b67 | ||
|
|
5dc5d9e04a | ||
|
|
4f09363823 | ||
|
|
c132a07213 | ||
|
|
86e778db1e | ||
|
|
ee1ed0d076 | ||
|
|
675ed5bdc1 | ||
|
|
fb467ac165 | ||
|
|
9d54e938c8 | ||
|
|
f6029fa79d | ||
|
|
039d881418 | ||
|
|
4f220ffca1 | ||
|
|
a54e0783e2 | ||
|
|
1cf36ed4c7 | ||
|
|
afd7bcea84 | ||
|
|
b58f64b05c | ||
|
|
118f19a4f2 | ||
|
|
52549a598d | ||
|
|
311ae80667 | ||
|
|
8b7596642a | ||
|
|
078aacd9fa | ||
|
|
bf600d0ae5 | ||
|
|
de5c34ab07 | ||
|
|
58babbf88f | ||
|
|
3cca0249a4 | ||
|
|
59b1cb48eb | ||
|
|
67d48e11d7 | ||
|
|
6420feee72 | ||
|
|
63e96230e0 | ||
|
|
8b7caf1d7d | ||
|
|
7705bc44ae | ||
|
|
8e71b882db | ||
|
|
e84cc5e6ae | ||
|
|
241037fc2a | ||
|
|
bd0d910137 | ||
|
|
146fe499db | ||
|
|
96a071725e | ||
|
|
91c61f8c06 | ||
|
|
c59fbfc677 | ||
|
|
602faf6221 | ||
|
|
cdc1d38be6 | ||
|
|
4764f41e1b | ||
|
|
f6450cb4b4 | ||
|
|
a4317f9479 | ||
|
|
a62aa09db6 | ||
|
|
f32af66a6a | ||
|
|
4152ca7366 | ||
|
|
ac941362de | ||
|
|
f8fedefde9 | ||
|
|
5154b6d17a | ||
|
|
f6c923017c | ||
|
|
22a341ea4d | ||
|
|
3257f6101b | ||
|
|
9c4e5f9842 | ||
|
|
ffe7259802 | ||
|
|
33ef9f6678 | ||
|
|
91bbc47b00 | ||
|
|
d7822d2b98 | ||
|
|
51bea03099 | ||
|
|
8ee39eb6db | ||
|
|
4074d189e3 | ||
|
|
7a7f40a140 | ||
|
|
1f9442f7f4 | ||
|
|
a38afb05d3 | ||
|
|
fae1aacf20 | ||
|
|
d508fd466d | ||
|
|
4d3cc1d806 | ||
|
|
e1fa92b91e | ||
|
|
10454656a2 | ||
|
|
ee08725d53 | ||
|
|
379675a901 | ||
|
|
8649faca79 | ||
|
|
91260bf0d4 | ||
|
|
51038cc03c | ||
|
|
fc43da59f8 | ||
|
|
2399967868 | ||
|
|
480dae09c4 | ||
|
|
2d2ba2e088 | ||
|
|
0502e47770 | ||
|
|
3c877be44a | ||
|
|
8371f46d34 | ||
|
|
2b9f834d96 | ||
|
|
8ec0080fca | ||
|
|
b7fd2470ee | ||
|
|
749b0c6ee9 | ||
|
|
25b7419d7b | ||
|
|
7edaa48fb9 | ||
|
|
627064b632 | ||
|
|
42a68140b1 | ||
|
|
f6a5c04017 | ||
|
|
2e150d548f | ||
|
|
584c660906 | ||
|
|
7abc4e4e79 | ||
|
|
0e553a5f8f | ||
|
|
dff6cb9d31 | ||
|
|
c04c42dc8a | ||
|
|
60323b2651 | ||
|
|
93235cab6f | ||
|
|
c849109df5 | ||
|
|
0da5a1b0fb | ||
|
|
7a8e671205 | ||
|
|
3cbb656ee9 | ||
|
|
55a493c475 | ||
|
|
7bbbaf343b | ||
|
|
09b6323246 | ||
|
|
3d0b319a88 | ||
|
|
26468a4137 | ||
|
|
62c83b79b4 | ||
|
|
da09826393 | ||
|
|
8058b6207a | ||
|
|
c623a420ca | ||
|
|
d5e36835f6 | ||
|
|
1f12a893ff | ||
|
|
bb67bf5fa2 | ||
|
|
be603badfe | ||
|
|
0d97cc50c2 | ||
|
|
14a4b94911 | ||
|
|
83044d23dd | ||
|
|
12456af101 | ||
|
|
8c656febeb | ||
|
|
2598ade925 | ||
|
|
46cfb2383b | ||
|
|
a385337268 | ||
|
|
bc79c6abab | ||
|
|
5f36d8b125 | ||
|
|
6a9b65f219 | ||
|
|
6ce7bc0ee2 | ||
|
|
36c246cca8 | ||
|
|
3506f875e3 | ||
|
|
e30e4b8cb7 | ||
|
|
9662e1abba | ||
|
|
e44c3a7bc8 | ||
|
|
c287e9965f | ||
|
|
bea0124e3f | ||
|
|
d4154d259f | ||
|
|
77746278a3 | ||
|
|
c75d4f0f58 | ||
|
|
96fd5cc35e | ||
|
|
bd0b7c9518 | ||
|
|
c05c51856b | ||
|
|
dac1646ecc | ||
|
|
c19a4009f3 | ||
|
|
5657384d2a | ||
|
|
4c72a34daf | ||
|
|
de48ef21e9 | ||
|
|
37ccfab89a | ||
|
|
07d60fe13e | ||
|
|
bbd1b0c5d0 | ||
|
|
c130e92661 | ||
|
|
0de853ff37 | ||
|
|
a438a23f4e | ||
|
|
8dd7fe297c | ||
|
|
780efab80c | ||
|
|
a4e9abcda1 | ||
|
|
55b1afbb21 | ||
|
|
b447fc9fe4 | ||
|
|
51ebaba399 | ||
|
|
9d4aea2c41 | ||
|
|
4dcdac6621 | ||
|
|
84bc242834 | ||
|
|
f840f4c600 | ||
|
|
5c8d7fcc15 | ||
|
|
93dc3ae97e | ||
|
|
7cb0aa70f1 | ||
|
|
5c219a8b59 | ||
|
|
50970e431d | ||
|
|
af67bf26d6 | ||
|
|
0d5487366c | ||
|
|
f0daef237c | ||
|
|
54a62c49cf | ||
|
|
92f70f5a03 | ||
|
|
c43a2f0488 | ||
|
|
1146c59a41 | ||
|
|
af1f8eaf76 | ||
|
|
dd2ca13004 | ||
|
|
b38de29a88 | ||
|
|
2bb44e1666 | ||
|
|
57b523fa80 | ||
|
|
ecbd2d14da | ||
|
|
bbac4128eb | ||
|
|
5aaca9261f | ||
|
|
963d77463a | ||
|
|
b28274fa85 | ||
|
|
3e85f4e44d | ||
|
|
614f8077e1 | ||
|
|
b36b5c1eb1 |
No files matched your search
+3
-4
@@ -12,6 +12,8 @@ AUTH_SECRET="supersecret"
|
|||||||
# or starting the project without any (which will show a randomly generated one).
|
# or starting the project without any (which will show a randomly generated one).
|
||||||
SECRET_ENCRYPTION_KEY=0000000000000000000000000000000000000000000000000000000000000000
|
SECRET_ENCRYPTION_KEY=0000000000000000000000000000000000000000000000000000000000000000
|
||||||
|
|
||||||
|
LOG_LEVEL='info'
|
||||||
|
|
||||||
# This is how you can use the sqlite driver:
|
# This is how you can use the sqlite driver:
|
||||||
DB_DRIVER='better-sqlite3'
|
DB_DRIVER='better-sqlite3'
|
||||||
DB_URL='FULL_PATH_TO_YOUR_SQLITE_DB_FILE'
|
DB_URL='FULL_PATH_TO_YOUR_SQLITE_DB_FILE'
|
||||||
@@ -32,7 +34,4 @@ DB_URL='FULL_PATH_TO_YOUR_SQLITE_DB_FILE'
|
|||||||
# If it is used, please use the full path to the directory where the certificates are stored.
|
# If it is used, please use the full path to the directory where the certificates are stored.
|
||||||
# LOCAL_CERTIFICATE_PATH='FULL_PATH_TO_CERTIFICATES'
|
# LOCAL_CERTIFICATE_PATH='FULL_PATH_TO_CERTIFICATES'
|
||||||
|
|
||||||
TURBO_TELEMETRY_DISABLED=1
|
TURBO_TELEMETRY_DISABLED=1
|
||||||
|
|
||||||
# Configure logging to use winston logger
|
|
||||||
NODE_OPTIONS='-r @homarr/log/override'
|
|
||||||
@@ -31,6 +31,12 @@ body:
|
|||||||
label: Version
|
label: Version
|
||||||
description: What version of Homarr are you running?
|
description: What version of Homarr are you running?
|
||||||
options:
|
options:
|
||||||
|
- 1.6.0
|
||||||
|
- 1.5.0
|
||||||
|
- 1.4.0
|
||||||
|
- 1.3.1
|
||||||
|
- 1.3.0
|
||||||
|
- 1.2.0
|
||||||
- 1.1.0
|
- 1.1.0
|
||||||
- 1.0.1
|
- 1.0.1
|
||||||
- 1.0.0
|
- 1.0.0
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
|
|
||||||
**Thank you for your contribution. Please ensure that your pull request meets the following pull request:**
|
**Thank you for your contribution. Please ensure that your pull request meets the following pull request:**
|
||||||
|
|
||||||
- [ ] Builds without warnings or errors (``pnpm buid``, autofix with ``pnpm format:fix``)
|
- [ ] Builds without warnings or errors (``pnpm build``, autofix with ``pnpm format:fix``)
|
||||||
- [ ] Pull request targets ``dev`` branch
|
- [ ] Pull request targets ``dev`` branch
|
||||||
- [ ] Commits follow the [conventional commits guideline](https://www.conventionalcommits.org/en/v1.0.0/)
|
- [ ] 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)
|
- [ ] No shorthand variable names are used (eg. ``x``, ``y``, ``i`` or any abbrevation)
|
||||||
|
|||||||
@@ -6,9 +6,9 @@
|
|||||||
matchPackagePatterns: ["^@homarr/"],
|
matchPackagePatterns: ["^@homarr/"],
|
||||||
enabled: false,
|
enabled: false,
|
||||||
},
|
},
|
||||||
// Disable Dockerode updates see https://github.com/apocas/dockerode/issues/787
|
// 15.2.0 crashes with turbopack error (panic)
|
||||||
{
|
{
|
||||||
matchPackagePatterns: ["^dockerode$"],
|
matchPackagePatterns: ["^next$", "^@next/eslint-plugin-next$"],
|
||||||
enabled: false,
|
enabled: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -15,13 +15,14 @@ on:
|
|||||||
description: Send notifications
|
description: Send notifications
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: write
|
contents: write # Required to update package.json version
|
||||||
packages: write
|
packages: write # Required for pushing to GHCR
|
||||||
|
|
||||||
env:
|
env:
|
||||||
SKIP_ENV_VALIDATION: true
|
SKIP_ENV_VALIDATION: true
|
||||||
REGISTRY: ghcr.io
|
REGISTRY: ghcr.io
|
||||||
IMAGE_NAME: ${{ github.repository }}
|
IMAGE_NAME: ${{ github.repository }}
|
||||||
|
GHCR_REPO: ghcr.io/${{ github.repository }}
|
||||||
TURBO_TELEMETRY_DISABLED: 1
|
TURBO_TELEMETRY_DISABLED: 1
|
||||||
|
|
||||||
concurrency:
|
concurrency:
|
||||||
@@ -96,64 +97,129 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
git config user.name "Releases Homarr"
|
git config user.name "Releases Homarr"
|
||||||
git config user.email "175486441+homarr-releases[bot]@users.noreply.github.com"
|
git config user.email "175486441+homarr-releases[bot]@users.noreply.github.com"
|
||||||
|
git remote set-url origin https://x-access-token:${{ steps.obtainToken.outputs.token }}@github.com/${{ github.repository }}.git
|
||||||
git fetch origin dev
|
git fetch origin dev
|
||||||
git checkout dev
|
git checkout dev
|
||||||
git pull origin dev
|
git pull origin dev
|
||||||
git merge ${{ github.ref_name }}
|
git rebase ${{ github.ref_name }}
|
||||||
git push origin dev
|
git push origin dev
|
||||||
deploy:
|
build-amd64:
|
||||||
name: Deploy docker image
|
name: Build docker image for amd64
|
||||||
needs: release
|
needs: release
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
env:
|
outputs:
|
||||||
NEXT_VERSION: ${{ needs.release.outputs.version }}
|
digest: ${{ steps.build.outputs.digest }}
|
||||||
DEPLOY_LATEST: ${{ github.ref_name == 'main' }}
|
|
||||||
DEPLOY_BETA: ${{ github.ref_name == 'beta' }}
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
ref: ${{ needs.release.outputs.git_ref }}
|
ref: ${{ needs.release.outputs.git_ref }}
|
||||||
- name: Discord notification
|
|
||||||
if: ${{ github.events.inputs.send-notifications != false }}
|
- name: Docker meta
|
||||||
env:
|
id: meta
|
||||||
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
|
uses: docker/metadata-action@v5
|
||||||
uses: Ilshidur/action-discord@master
|
|
||||||
with:
|
with:
|
||||||
args: "Deployment of an image for version '${{env.NEXT_VERSION}}' has been triggered: [run ${{ github.run_number }}](<${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}>)"
|
images: "${{ env.GHCR_REPO }}"
|
||||||
|
|
||||||
- name: Log in to the Container registry
|
- name: Log in to the Container registry
|
||||||
uses: docker/login-action@v3
|
uses: docker/login-action@v3
|
||||||
with:
|
with:
|
||||||
registry: ${{ env.REGISTRY }}
|
registry: ${{ env.REGISTRY }}
|
||||||
username: ${{ github.actor }}
|
username: ${{ github.actor }}
|
||||||
password: ${{ secrets.GITHUB_TOKEN }}
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
- name: Set up QEMU
|
|
||||||
uses: docker/setup-qemu-action@v3
|
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@v3
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
|
- name: Build and push by digest
|
||||||
|
id: build
|
||||||
|
uses: docker/build-push-action@v6
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
network: host
|
||||||
|
platforms: linux/amd64
|
||||||
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
|
outputs: type=image,"name=${{ env.GHCR_REPO }}",push-by-digest=true,name-canonical=true,push=true
|
||||||
|
env:
|
||||||
|
SKIP_ENV_VALIDATION: true
|
||||||
|
|
||||||
|
build-arm64:
|
||||||
|
name: Build docker image for arm64
|
||||||
|
needs: release
|
||||||
|
runs-on: ubuntu-24.04-arm
|
||||||
|
outputs:
|
||||||
|
digest: ${{ steps.build.outputs.digest }}
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
ref: ${{ needs.release.outputs.git_ref }}
|
||||||
|
|
||||||
- name: Docker meta
|
- name: Docker meta
|
||||||
id: meta
|
id: meta
|
||||||
uses: docker/metadata-action@v5
|
uses: docker/metadata-action@v5
|
||||||
with:
|
with:
|
||||||
images: "${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}"
|
images: "${{ env.GHCR_REPO }}"
|
||||||
tags: |
|
|
||||||
${{ env.DEPLOY_LATEST == 'true' && 'type=raw,value=latest' || null }}
|
- name: Log in to the Container registry
|
||||||
${{ env.DEPLOY_BETA == 'true' && 'type=raw,value=beta' || null }}
|
uses: docker/login-action@v3
|
||||||
type=raw,value=${{ env.NEXT_VERSION }}
|
with:
|
||||||
- name: Build and push
|
registry: ${{ env.REGISTRY }}
|
||||||
id: buildPushAction
|
username: ${{ github.actor }}
|
||||||
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
|
- name: Build and push by digest
|
||||||
|
id: build
|
||||||
uses: docker/build-push-action@v6
|
uses: docker/build-push-action@v6
|
||||||
with:
|
with:
|
||||||
platforms: linux/amd64,linux/arm64
|
|
||||||
context: .
|
context: .
|
||||||
push: true
|
|
||||||
tags: ${{ steps.meta.outputs.tags }}
|
|
||||||
labels: ${{ steps.meta.outputs.labels }}
|
|
||||||
network: host
|
network: host
|
||||||
|
platforms: linux/arm64
|
||||||
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
|
outputs: type=image,"name=${{ env.GHCR_REPO }}",push-by-digest=true,name-canonical=true,push=true
|
||||||
env:
|
env:
|
||||||
SKIP_ENV_VALIDATION: true
|
SKIP_ENV_VALIDATION: true
|
||||||
|
|
||||||
|
publish:
|
||||||
|
name: Complete deployment and notify
|
||||||
|
needs: [release, build-amd64, build-arm64]
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
env:
|
||||||
|
NEXT_VERSION: ${{ needs.release.outputs.version }}
|
||||||
|
DEPLOY_LATEST: ${{ github.ref_name == 'main' }}
|
||||||
|
DEPLOY_BETA: ${{ github.ref_name == 'beta' }}
|
||||||
|
steps:
|
||||||
|
- name: Log in to the Container registry
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: ${{ env.REGISTRY }}
|
||||||
|
username: ${{ github.actor }}
|
||||||
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: Publish beta
|
||||||
|
if: env.DEPLOY_BETA == 'true'
|
||||||
|
run: |
|
||||||
|
docker buildx imagetools create -t ${{ env.GHCR_REPO }}:beta \
|
||||||
|
${{ env.GHCR_REPO }}@${{ needs.build-amd64.outputs.digest }} \
|
||||||
|
${{ env.GHCR_REPO }}@${{ needs.build-arm64.outputs.digest }}
|
||||||
|
|
||||||
|
- name: Publish latest
|
||||||
|
if: env.DEPLOY_LATEST == 'true'
|
||||||
|
run: |
|
||||||
|
docker buildx imagetools create -t ${{ env.GHCR_REPO }}:latest \
|
||||||
|
${{ env.GHCR_REPO }}@${{ needs.build-amd64.outputs.digest }} \
|
||||||
|
${{ env.GHCR_REPO }}@${{ needs.build-arm64.outputs.digest }}
|
||||||
|
|
||||||
|
- name: Publish version
|
||||||
|
run: |
|
||||||
|
docker buildx imagetools create -t ${{ env.GHCR_REPO }}:${{ env.NEXT_VERSION }} \
|
||||||
|
${{ env.GHCR_REPO }}@${{ needs.build-amd64.outputs.digest }} \
|
||||||
|
${{ env.GHCR_REPO }}@${{ needs.build-arm64.outputs.digest }}
|
||||||
|
|
||||||
- name: Discord notification
|
- name: Discord notification
|
||||||
env:
|
env:
|
||||||
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
|
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
|
||||||
uses: Ilshidur/action-discord@master
|
uses: Ilshidur/action-discord@master
|
||||||
with:
|
with:
|
||||||
args: "Deployment of image has completed for branch ${{ github.ref_name }}. Image ID is '${{ steps.buildPushAction.outputs.imageid }}'."
|
args: "Successfully deployed images for branch **${{ github.ref_name }}**. Tagged as **${{env.NEXT_VERSION}}**."
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<component name="ProjectRunConfigurationManager">
|
||||||
|
<configuration default="false" name="typecheck" type="js.build_tools.npm">
|
||||||
|
<package-json value="$PROJECT_DIR$/package.json" />
|
||||||
|
<command value="run" />
|
||||||
|
<scripts>
|
||||||
|
<script value="typecheck" />
|
||||||
|
</scripts>
|
||||||
|
<node-interpreter value="project" />
|
||||||
|
<package-manager value="pnpm" />
|
||||||
|
<envs />
|
||||||
|
<method v="2" />
|
||||||
|
</configuration>
|
||||||
|
</component>
|
||||||
+177
@@ -1,3 +1,180 @@
|
|||||||
|
## [1.8.0](https://github.com/homarr-labs/homarr/compare/v1.7.0...v1.8.0) (2025-02-28)
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* add app ping url ([#2380](https://github.com/homarr-labs/homarr/issues/2380)) ([fb467ac](https://github.com/homarr-labs/homarr/commit/fb467ac16553d3c498770a531cf77b9f2c7c0a9e))
|
||||||
|
* **boards:** add responsive layout system ([#2271](https://github.com/homarr-labs/homarr/issues/2271)) ([7761dc2](https://github.com/homarr-labs/homarr/commit/7761dc29c809b28cc8275dcabf77c453cea3621c))
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* await params for board metadata ([#2406](https://github.com/homarr-labs/homarr/issues/2406)) ([2085b5e](https://github.com/homarr-labs/homarr/commit/2085b5ece24f661086c4896a6ecfbff61de1b7d4))
|
||||||
|
* **boards:** add scrollarea to board-select ([#2409](https://github.com/homarr-labs/homarr/issues/2409)) ([508f9af](https://github.com/homarr-labs/homarr/commit/508f9afeb542d09d06fc34f06ac68cf732011620))
|
||||||
|
* chinese traditional locale ([#2440](https://github.com/homarr-labs/homarr/issues/2440)) ([50367eb](https://github.com/homarr-labs/homarr/commit/50367eb2f5ae9cb6b736197692b3f555e07aca59))
|
||||||
|
* **deps:** downgrade to next 15.1.7 cause of turbopack crash with 15.2.0 and above ([#2438](https://github.com/homarr-labs/homarr/issues/2438)) ([fdd91f5](https://github.com/homarr-labs/homarr/commit/fdd91f585d1c0c66a6c28c6d1e374bdd9fdcb787))
|
||||||
|
* **deps:** update dependency @auth/core to ^0.38.0 ([#2434](https://github.com/homarr-labs/homarr/issues/2434)) ([8c1308c](https://github.com/homarr-labs/homarr/commit/8c1308c97f011336c2a813e92e7cefa195277994))
|
||||||
|
* **deps:** update dependency @auth/drizzle-adapter to ^1.8.0 ([#2435](https://github.com/homarr-labs/homarr/issues/2435)) ([b2cc97e](https://github.com/homarr-labs/homarr/commit/b2cc97e316976e74df8136a7da53a3fae78f815b))
|
||||||
|
* **deps:** update dependency drizzle-kit to ^0.30.5 ([#2423](https://github.com/homarr-labs/homarr/issues/2423)) ([3b830ae](https://github.com/homarr-labs/homarr/commit/3b830ae098c290aaa15356cae5ec2fe8cb618d9f))
|
||||||
|
* **deps:** update dependency drizzle-orm to ^0.40.0 ([#2425](https://github.com/homarr-labs/homarr/issues/2425)) ([faf7322](https://github.com/homarr-labs/homarr/commit/faf7322bcb94a61dbb2b3295cc4a4eca52484d74))
|
||||||
|
* **deps:** update dependency eslint-config-prettier to ^10.0.2 ([#2427](https://github.com/homarr-labs/homarr/issues/2427)) ([c2d6e2e](https://github.com/homarr-labs/homarr/commit/c2d6e2efcf06e97c04bddc2d821c77ad034426b8))
|
||||||
|
* **deps:** update dependency eslint-plugin-react-hooks to ^5.2.0 ([#2449](https://github.com/homarr-labs/homarr/issues/2449)) ([a88c1ba](https://github.com/homarr-labs/homarr/commit/a88c1ba4401bbe6317ccde11cff9a057bfa53e26))
|
||||||
|
* **deps:** update dependency prettier to ^3.5.2 ([#2399](https://github.com/homarr-labs/homarr/issues/2399)) ([86e778d](https://github.com/homarr-labs/homarr/commit/86e778db1ee081288b4174f6b52fcc44a0bbc1e0))
|
||||||
|
* **deps:** update dependency sass to ^1.85.1 ([#2418](https://github.com/homarr-labs/homarr/issues/2418)) ([a9a37af](https://github.com/homarr-labs/homarr/commit/a9a37afd441ad148a42432c24a9c7e6acf804c89))
|
||||||
|
* **deps:** update dependency swagger-ui-react to ^5.20.0 ([#2436](https://github.com/homarr-labs/homarr/issues/2436)) ([add9170](https://github.com/homarr-labs/homarr/commit/add91701de30eaeb61efe3295307756e998703a9))
|
||||||
|
* **deps:** update dependency typescript-eslint to ^8.25.0 ([#2413](https://github.com/homarr-labs/homarr/issues/2413)) ([593c50b](https://github.com/homarr-labs/homarr/commit/593c50bb8f803ecbda8e6d0c4a082e019cc56352))
|
||||||
|
* **deps:** update dependency undici to v7.4.0 ([#2445](https://github.com/homarr-labs/homarr/issues/2445)) ([06ba0f0](https://github.com/homarr-labs/homarr/commit/06ba0f0e9f6da9c22ef531e41811a89c8c5039a8))
|
||||||
|
* **deps:** update nextjs monorepo to ^15.2.0 ([#2428](https://github.com/homarr-labs/homarr/issues/2428)) ([d6939e4](https://github.com/homarr-labs/homarr/commit/d6939e4a6daf7fbe0095ae4028d33189c5866fb2))
|
||||||
|
* **deps:** update tanstack-query monorepo to ^5.66.10 ([#2443](https://github.com/homarr-labs/homarr/issues/2443)) ([4483dc8](https://github.com/homarr-labs/homarr/commit/4483dc8bcd062f531badd94ba872cf42a17491df))
|
||||||
|
* **deps:** update tanstack-query monorepo to ^5.66.11 ([#2447](https://github.com/homarr-labs/homarr/issues/2447)) ([5537f95](https://github.com/homarr-labs/homarr/commit/5537f95b85b1ae56a35d7b529907ebe22d0b1ae6))
|
||||||
|
* **deps:** update turbo monorepo to ^2.4.3 ([#2422](https://github.com/homarr-labs/homarr/issues/2422)) ([874414f](https://github.com/homarr-labs/homarr/commit/874414fc4fc1130502abc267116ad109dab11efe))
|
||||||
|
* **deps:** update turbo monorepo to ^2.4.4 ([#2424](https://github.com/homarr-labs/homarr/issues/2424)) ([a6f9199](https://github.com/homarr-labs/homarr/commit/a6f91998c3a8192eaa93b57a74b6dfc8f3fdc13a))
|
||||||
|
* omv invalid temperature when not available ([#2322](https://github.com/homarr-labs/homarr/issues/2322)) ([5dc5d9e](https://github.com/homarr-labs/homarr/commit/5dc5d9e04ab747655276494d70557b45fed87b2c))
|
||||||
|
* update checker logs incorrectly ([#2402](https://github.com/homarr-labs/homarr/issues/2402)) ([c5107b2](https://github.com/homarr-labs/homarr/commit/c5107b2b67cad73808dcbae6bde633ac1a7eb2b5))
|
||||||
|
|
||||||
|
## [1.7.0](https://github.com/homarr-labs/homarr/compare/v1.6.0...v1.7.0) (2025-02-21)
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* add tile border radius ([#2338](https://github.com/homarr-labs/homarr/issues/2338)) ([7705bc4](https://github.com/homarr-labs/homarr/commit/7705bc44ae7c93751e718b019c98c11369c221af))
|
||||||
|
* **board:** allow to set icon color of widgets ([#2228](https://github.com/homarr-labs/homarr/issues/2228)) ([de5c34a](https://github.com/homarr-labs/homarr/commit/de5c34ab079c308c80cc3b1ba8f9c8a265e3a1c3))
|
||||||
|
* **cli:** add fix-username command to make all usernames lowercase ([#2366](https://github.com/homarr-labs/homarr/issues/2366)) ([8b7caf1](https://github.com/homarr-labs/homarr/commit/8b7caf1d7dfebb8121cefd301fa72ef59601d410))
|
||||||
|
* downgrade log level callback succeeded ([#2329](https://github.com/homarr-labs/homarr/issues/2329)) ([22a341e](https://github.com/homarr-labs/homarr/commit/22a341ea4d7365fb51781f96791394315afd8860))
|
||||||
|
* **groups:** add home board settings ([#2321](https://github.com/homarr-labs/homarr/issues/2321)) ([ffe7259](https://github.com/homarr-labs/homarr/commit/ffe72598026e5e27588b18feeaaf7d30065d5536))
|
||||||
|
* **logging:** add log level env variable ([#2299](https://github.com/homarr-labs/homarr/issues/2299)) ([67d48e1](https://github.com/homarr-labs/homarr/commit/67d48e11d7e1165540c66f86b299e55028d76788))
|
||||||
|
* quick add app modal ([#2248](https://github.com/homarr-labs/homarr/issues/2248)) ([6420fee](https://github.com/homarr-labs/homarr/commit/6420feee729aa641c0545fa39308772e4a4b1af8))
|
||||||
|
* redirect to login screen when no global home board ([#2379](https://github.com/homarr-labs/homarr/issues/2379)) ([311ae80](https://github.com/homarr-labs/homarr/commit/311ae80667715fccffbfa73699c0770d483c3434))
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* **deps:** update dependency jotai to ^2.12.1 ([#2354](https://github.com/homarr-labs/homarr/issues/2354)) ([c59fbfc](https://github.com/homarr-labs/homarr/commit/c59fbfc677b10ee20c2e698d9837af1aa098854b))
|
||||||
|
* **deps:** update dependency mantine-react-table to v2.0.0-beta.9 ([#2348](https://github.com/homarr-labs/homarr/issues/2348)) ([4764f41](https://github.com/homarr-labs/homarr/commit/4764f41e1bf77f7934840a84e25b2989fc1e7c7c))
|
||||||
|
* **deps:** update dependency next-intl to v3.26.4 ([#2361](https://github.com/homarr-labs/homarr/issues/2361)) ([241037f](https://github.com/homarr-labs/homarr/commit/241037fc2a699249f317e5e434ac603cee4e460d))
|
||||||
|
* **deps:** update dependency next-intl to v3.26.5 ([#2386](https://github.com/homarr-labs/homarr/issues/2386)) ([afd7bce](https://github.com/homarr-labs/homarr/commit/afd7bcea841e906003ec90516b033214ad412473))
|
||||||
|
* **deps:** update dependency octokit to ^4.1.2 ([#2333](https://github.com/homarr-labs/homarr/issues/2333)) ([33ef9f6](https://github.com/homarr-labs/homarr/commit/33ef9f66782a59047878a3e82330128fdcabfa93))
|
||||||
|
* **deps:** update dependency prettier to ^3.5.1 ([#2284](https://github.com/homarr-labs/homarr/issues/2284)) ([f6c9230](https://github.com/homarr-labs/homarr/commit/f6c923017c6b4ac9670843bba5dee2f52afd1918))
|
||||||
|
* **deps:** update dependency swagger-ui-react to ^5.19.0 ([#2358](https://github.com/homarr-labs/homarr/issues/2358)) ([91c61f8](https://github.com/homarr-labs/homarr/commit/91c61f8c06622893b90f668e30a615437d9bb549))
|
||||||
|
* **deps:** update dependency tsx to v4.19.3 ([#2370](https://github.com/homarr-labs/homarr/issues/2370)) ([58babbf](https://github.com/homarr-labs/homarr/commit/58babbf88fecc401bf4e7baa52636a4a937bead2))
|
||||||
|
* **deps:** update dependency typescript-eslint to ^8.24.1 ([#2360](https://github.com/homarr-labs/homarr/issues/2360)) ([146fe49](https://github.com/homarr-labs/homarr/commit/146fe499db0fe36c8822ad30a762738425fb6f50))
|
||||||
|
* **deps:** update dependency ws to ^8.18.1 ([#2387](https://github.com/homarr-labs/homarr/issues/2387)) ([b58f64b](https://github.com/homarr-labs/homarr/commit/b58f64b05c09070d8c5cdc7f89b23768b9997405))
|
||||||
|
* **deps:** update mantine monorepo to ^7.17.0 ([#2359](https://github.com/homarr-labs/homarr/issues/2359)) ([96a0717](https://github.com/homarr-labs/homarr/commit/96a071725e557e64d8951091063f047cb43a385b))
|
||||||
|
* **deps:** update tanstack-query monorepo to ^5.66.2 ([#2341](https://github.com/homarr-labs/homarr/issues/2341)) ([f8fedef](https://github.com/homarr-labs/homarr/commit/f8fedefde9ae65923369b46876009f9f6cb27683))
|
||||||
|
* **deps:** update tanstack-query monorepo to ^5.66.3 ([#2343](https://github.com/homarr-labs/homarr/issues/2343)) ([f32af66](https://github.com/homarr-labs/homarr/commit/f32af66a6ad4676530265bfd1052b56308d4d40f))
|
||||||
|
* **deps:** update tanstack-query monorepo to ^5.66.4 ([#2355](https://github.com/homarr-labs/homarr/issues/2355)) ([602faf6](https://github.com/homarr-labs/homarr/commit/602faf622140af1cc4ac92f8a54a56924d809f67))
|
||||||
|
* **deps:** update tanstack-query monorepo to ^5.66.6 ([#2357](https://github.com/homarr-labs/homarr/issues/2357)) ([bd0d910](https://github.com/homarr-labs/homarr/commit/bd0d910137194caef989f4dba360bcaf616e9ea4))
|
||||||
|
* **deps:** update tanstack-query monorepo to ^5.66.7 ([#2364](https://github.com/homarr-labs/homarr/issues/2364)) ([e84cc5e](https://github.com/homarr-labs/homarr/commit/e84cc5e6ae32c78d4e2be4d57e2b97ad84094f5e))
|
||||||
|
* **deps:** update tanstack-query monorepo to ^5.66.8 ([#2376](https://github.com/homarr-labs/homarr/issues/2376)) ([8b75966](https://github.com/homarr-labs/homarr/commit/8b7596642a8398af817eedbe2dd89e9bad3fd937))
|
||||||
|
* **deps:** update tanstack-query monorepo to ^5.66.9 ([#2391](https://github.com/homarr-labs/homarr/issues/2391)) ([1cf36ed](https://github.com/homarr-labs/homarr/commit/1cf36ed4c776d6c99e1e1058dde9129475069aaf))
|
||||||
|
* **medias:** svg files don't load in list ([#2381](https://github.com/homarr-labs/homarr/issues/2381)) ([52549a5](https://github.com/homarr-labs/homarr/commit/52549a598d039a9fa2165983698212564eb5233b))
|
||||||
|
* missing cover types media organizers ([#2328](https://github.com/homarr-labs/homarr/issues/2328)) ([3257f61](https://github.com/homarr-labs/homarr/commit/3257f6101bdd8967089655507144dc4368390b8c))
|
||||||
|
* omv integrations is unable to correctly retrieve the file system when using omv-zfs ([#2337](https://github.com/homarr-labs/homarr/issues/2337)) ([63e9623](https://github.com/homarr-labs/homarr/commit/63e96230e0e2831c787d3e520a6b8e9b4bddb2a7))
|
||||||
|
* typo in pr template ([#2344](https://github.com/homarr-labs/homarr/issues/2344)) ([a62aa09](https://github.com/homarr-labs/homarr/commit/a62aa09db6c87573d579bcebc9f5d5c3ac133af2))
|
||||||
|
* **widgets:** app icon not visible ([#2390](https://github.com/homarr-labs/homarr/issues/2390)) ([a54e078](https://github.com/homarr-labs/homarr/commit/a54e0783e2ad452c4db3d7ddb4e4bcd501a8687a))
|
||||||
|
|
||||||
|
## [1.6.0](https://github.com/homarr-labs/homarr/compare/v1.5.0...v1.6.0) (2025-02-14)
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* **lang:** add catalan language ([#2283](https://github.com/homarr-labs/homarr/issues/2283)) ([749b0c6](https://github.com/homarr-labs/homarr/commit/749b0c6ee9dc7802ae10393b90e19bf3c43bf9e0))
|
||||||
|
* **settings:** add simple-ping settings ([#2118](https://github.com/homarr-labs/homarr/issues/2118)) ([dff6cb9](https://github.com/homarr-labs/homarr/commit/dff6cb9d31c3884bf811774a7c90dad27ac19691))
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* add error handling for available updates ([#2289](https://github.com/homarr-labs/homarr/issues/2289)) ([0502e47](https://github.com/homarr-labs/homarr/commit/0502e477707932db96b25372ddab8263572b389c))
|
||||||
|
* add mysql pool with keep-alive to not close the connection ([#2282](https://github.com/homarr-labs/homarr/issues/2282)) ([a38afb0](https://github.com/homarr-labs/homarr/commit/a38afb05d35cf2bdba378be3744f3a8b6c9d38f6))
|
||||||
|
* **calendar:** info popover overlap ([#2268](https://github.com/homarr-labs/homarr/issues/2268)) ([584c660](https://github.com/homarr-labs/homarr/commit/584c660906f5ae7e8ab1c88ce9cfbbbbdcdb5145))
|
||||||
|
* **deps:** update dependency @ctrl/qbittorrent to ^9.3.0 ([#2310](https://github.com/homarr-labs/homarr/issues/2310)) ([8649fac](https://github.com/homarr-labs/homarr/commit/8649faca7929daff426607f104057fce26edd049))
|
||||||
|
* **deps:** update dependency @ctrl/qbittorrent to ^9.4.0 ([#2315](https://github.com/homarr-labs/homarr/issues/2315)) ([4d3cc1d](https://github.com/homarr-labs/homarr/commit/4d3cc1d80698e659a669de7d4bd0d185ad68605e))
|
||||||
|
* **deps:** update dependency @extractus/feed-extractor to v7.1.4 ([#2281](https://github.com/homarr-labs/homarr/issues/2281)) ([7edaa48](https://github.com/homarr-labs/homarr/commit/7edaa48fb9bcf48e8b734beec5a8bad003124233))
|
||||||
|
* **deps:** update dependency drizzle-orm to ^0.39.3 ([#2295](https://github.com/homarr-labs/homarr/issues/2295)) ([8ec0080](https://github.com/homarr-labs/homarr/commit/8ec0080fca17c530cdd0576ba101299a0f769463))
|
||||||
|
* **deps:** update dependency jotai to ^2.12.0 ([#2306](https://github.com/homarr-labs/homarr/issues/2306)) ([fc43da5](https://github.com/homarr-labs/homarr/commit/fc43da59f845934da428bcafd4ec46551ace8a81))
|
||||||
|
* **deps:** update dependency sass to ^1.85.0 ([#2326](https://github.com/homarr-labs/homarr/issues/2326)) ([8ee39eb](https://github.com/homarr-labs/homarr/commit/8ee39eb6db89fdeaec0fae7988ffa97280c2f5ab))
|
||||||
|
* **deps:** update dependency typescript-eslint to ^8.24.0 ([#2297](https://github.com/homarr-labs/homarr/issues/2297)) ([8371f46](https://github.com/homarr-labs/homarr/commit/8371f46d34ad36fd25f23d91804d3f95439d7012))
|
||||||
|
* **deps:** update dependency zod to ^3.24.2 ([#2311](https://github.com/homarr-labs/homarr/issues/2311)) ([379675a](https://github.com/homarr-labs/homarr/commit/379675a901637a1b878595a80d0776d05311218d))
|
||||||
|
* **deps:** update mantine monorepo to ^7.16.3 ([#2275](https://github.com/homarr-labs/homarr/issues/2275)) ([42a6814](https://github.com/homarr-labs/homarr/commit/42a68140b1493cc171977d996eea16d876dce60c))
|
||||||
|
* **deps:** update nextjs monorepo to ^15.1.7 ([#2302](https://github.com/homarr-labs/homarr/issues/2302)) ([2399967](https://github.com/homarr-labs/homarr/commit/2399967868652436ee6827bce8883c1b4a00af0e))
|
||||||
|
* **deps:** update turbo monorepo to ^2.4.1 ([#2296](https://github.com/homarr-labs/homarr/issues/2296)) ([2b9f834](https://github.com/homarr-labs/homarr/commit/2b9f834d9645aaf5f5aa1b8a5f1e48300389ac8b))
|
||||||
|
* **deps:** update turbo monorepo to ^2.4.2 ([#2313](https://github.com/homarr-labs/homarr/issues/2313)) ([1045465](https://github.com/homarr-labs/homarr/commit/10454656a286c320a8d3b5ff12a138025d871b20))
|
||||||
|
* **users:** edit profile username not lowercase ([#2279](https://github.com/homarr-labs/homarr/issues/2279)) ([b7fd247](https://github.com/homarr-labs/homarr/commit/b7fd2470ee0d85821f79a34e839e62fbab6989c6))
|
||||||
|
|
||||||
|
## [1.5.0](https://github.com/homarr-labs/homarr/compare/v1.4.0...v1.5.0) (2025-02-07)
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* add api key delete ([#2210](https://github.com/homarr-labs/homarr/issues/2210)) ([8058b62](https://github.com/homarr-labs/homarr/commit/8058b6207ad4be1d646182b67b810c397097c054))
|
||||||
|
* custom time/date format in clock widget ([#2180](https://github.com/homarr-labs/homarr/issues/2180)) ([be603ba](https://github.com/homarr-labs/homarr/commit/be603badfed63ad7583a5265bef3b9e85f6b6b61))
|
||||||
|
* improve consistency and design ([#1867](https://github.com/homarr-labs/homarr/issues/1867)) ([5f36d8b](https://github.com/homarr-labs/homarr/commit/5f36d8b12528fd2c8c3da783b266d614203ec46a))
|
||||||
|
* open all apps in category ([#2212](https://github.com/homarr-labs/homarr/issues/2212)) ([bb67bf5](https://github.com/homarr-labs/homarr/commit/bb67bf5fa2968d91ad3522ebae960a6880f85987))
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* **deps:** update dependency @tabler/icons-react to ^3.30.0 ([#2266](https://github.com/homarr-labs/homarr/issues/2266)) ([c849109](https://github.com/homarr-labs/homarr/commit/c849109df5b622aca8cf7b392b94685581bf6b32))
|
||||||
|
* **deps:** update dependency drizzle-orm to ^0.39.2 ([#2255](https://github.com/homarr-labs/homarr/issues/2255)) ([3d0b319](https://github.com/homarr-labs/homarr/commit/3d0b319a88bdb8d955d7942553d92988bf5b2c28))
|
||||||
|
* **deps:** update dependency ioredis to v5.5.0 ([#2264](https://github.com/homarr-labs/homarr/issues/2264)) ([0da5a1b](https://github.com/homarr-labs/homarr/commit/0da5a1b0fb86ff2074f12a5e028fcc09ff94a4a5))
|
||||||
|
* **deps:** update dependency jotai to ^2.11.3 ([#2215](https://github.com/homarr-labs/homarr/issues/2215)) ([a385337](https://github.com/homarr-labs/homarr/commit/a3853372687aed543fad2dd89bfb13871af0f2c1))
|
||||||
|
* **deps:** update dependency octokit to ^4.1.1 ([#2262](https://github.com/homarr-labs/homarr/issues/2262)) ([3cbb656](https://github.com/homarr-labs/homarr/commit/3cbb656ee9b540d2dd4c7db37946efacbbfdc522))
|
||||||
|
* **deps:** update dependency sass to ^1.84.0 ([#2257](https://github.com/homarr-labs/homarr/issues/2257)) ([7bbbaf3](https://github.com/homarr-labs/homarr/commit/7bbbaf343befaaa3d03a91e932f683c5aec4b41e))
|
||||||
|
* **deps:** update dependency semver-parser to ^4.1.8 ([#2216](https://github.com/homarr-labs/homarr/issues/2216)) ([46cfb23](https://github.com/homarr-labs/homarr/commit/46cfb2383b6628472d3972da2166884330bf6d24))
|
||||||
|
* **deps:** update dependency trpc-to-openapi to ^2.1.3 ([#2244](https://github.com/homarr-labs/homarr/issues/2244)) ([c623a42](https://github.com/homarr-labs/homarr/commit/c623a420cadce677e39b99fceeb3f3a391f879ea))
|
||||||
|
* **deps:** update dependency typescript-eslint to ^8.23.0 ([#2232](https://github.com/homarr-labs/homarr/issues/2232)) ([83044d2](https://github.com/homarr-labs/homarr/commit/83044d23dd7accb1b3a21cdde1e034969a494ccb))
|
||||||
|
* **deps:** update testcontainers-node monorepo to ^10.18.0 ([#2259](https://github.com/homarr-labs/homarr/issues/2259)) ([7a8e671](https://github.com/homarr-labs/homarr/commit/7a8e67120576b333bfcf73171b54ccfeeda0d44a))
|
||||||
|
* **deps:** update turbo monorepo to ^2.4.0 ([#2207](https://github.com/homarr-labs/homarr/issues/2207)) ([6ce7bc0](https://github.com/homarr-labs/homarr/commit/6ce7bc0ee2dcd5150d72fdbc2070c05ba836ebd5))
|
||||||
|
* foreign key reference default search engine ([#2211](https://github.com/homarr-labs/homarr/issues/2211)) ([26468a4](https://github.com/homarr-labs/homarr/commit/26468a413795afa7cc3444058bfc73435fb7f2a0))
|
||||||
|
* make system health temp optional ([#2247](https://github.com/homarr-labs/homarr/issues/2247)) ([da09826](https://github.com/homarr-labs/homarr/commit/da098263930c9c9611a1505d52d3816bd2a49e7e))
|
||||||
|
|
||||||
|
## [1.4.0](https://github.com/homarr-labs/homarr/compare/v1.3.1...v1.4.0) (2025-01-31)
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* weather widget - add wind speed, option to disable decimals for temperature & redesign dropdown forecast ([#2099](https://github.com/homarr-labs/homarr/issues/2099)) ([c19a400](https://github.com/homarr-labs/homarr/commit/c19a4009f37ec2aba8d4edd2d19819605ecb9575))
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* board crashes with empty string favicon ([#2192](https://github.com/homarr-labs/homarr/issues/2192)) ([bd0b7c9](https://github.com/homarr-labs/homarr/commit/bd0b7c9518af88e0a329ea6286cb5ef97098782a))
|
||||||
|
* **deps:** update dependency drizzle-kit to ^0.30.4 ([#2183](https://github.com/homarr-labs/homarr/issues/2183)) ([37ccfab](https://github.com/homarr-labs/homarr/commit/37ccfab89a7843c65d6f354bef2b0537b20ea757))
|
||||||
|
* **deps:** update dependency drizzle-orm to ^0.39.1 ([#2186](https://github.com/homarr-labs/homarr/issues/2186)) ([4c72a34](https://github.com/homarr-labs/homarr/commit/4c72a34daf06c5e00fc4ece6d990148c1cd6d87f))
|
||||||
|
* **deps:** update dependency jotai to ^2.11.2 ([#2201](https://github.com/homarr-labs/homarr/issues/2201)) ([c287e99](https://github.com/homarr-labs/homarr/commit/c287e9965f008b6ac6f3bac56057b94c412a3779))
|
||||||
|
* **deps:** update tanstack-query monorepo to ^5.66.0 ([#2202](https://github.com/homarr-labs/homarr/issues/2202)) ([e44c3a7](https://github.com/homarr-labs/homarr/commit/e44c3a7bc8be5b67d78096d5daa53ad23ccdea5d))
|
||||||
|
* **deps:** update tiptap monorepo to v2.11.4 ([#2195](https://github.com/homarr-labs/homarr/issues/2195)) ([c05c518](https://github.com/homarr-labs/homarr/commit/c05c51856baff5222b0abda56e1c11d9bc9bd6f9))
|
||||||
|
* **deps:** update tiptap monorepo to v2.11.5 ([#2203](https://github.com/homarr-labs/homarr/issues/2203)) ([9662e1a](https://github.com/homarr-labs/homarr/commit/9662e1abba3e105fd549d25ee0a0efc48cf293a1))
|
||||||
|
* **docker:** missing production env variable ([#2188](https://github.com/homarr-labs/homarr/issues/2188)) ([7774627](https://github.com/homarr-labs/homarr/commit/77746278a36f2b9d39c780eada25195121cc0803))
|
||||||
|
* negative curve radius system health ([#2197](https://github.com/homarr-labs/homarr/issues/2197)) ([c75d4f0](https://github.com/homarr-labs/homarr/commit/c75d4f0f58136a0499a51494eb54c78c745f0b3f))
|
||||||
|
* respect certificates for app ping ([#2198](https://github.com/homarr-labs/homarr/issues/2198)) ([d4154d2](https://github.com/homarr-labs/homarr/commit/d4154d259fa50bfd754e7ea98b872df78864e014))
|
||||||
|
* use hostname for suspense query url ([#2187](https://github.com/homarr-labs/homarr/issues/2187)) ([96fd5cc](https://github.com/homarr-labs/homarr/commit/96fd5cc35e9ca85aba48670860e6bf2a4a2caaa9))
|
||||||
|
|
||||||
|
## [1.3.1](https://github.com/homarr-labs/homarr/compare/v1.3.0...v1.3.1) (2025-01-28)
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* app url line clamp ([#2170](https://github.com/homarr-labs/homarr/issues/2170)) ([780efab](https://github.com/homarr-labs/homarr/commit/780efab80cdc5446a6468d60afe2c1d9ecca0f1e))
|
||||||
|
* automatic search engine creation ([#2172](https://github.com/homarr-labs/homarr/issues/2172)) ([8dd7fe2](https://github.com/homarr-labs/homarr/commit/8dd7fe297c431bbe448bc47b381370ade7c4d3d9))
|
||||||
|
* **deps:** update dependency swagger-ui-react to ^5.18.3 ([#2164](https://github.com/homarr-labs/homarr/issues/2164)) ([a4e9abc](https://github.com/homarr-labs/homarr/commit/a4e9abcda192a96754078eece4343c2c1c9fa032))
|
||||||
|
* **deps:** update dependency typescript-eslint to ^8.22.0 ([#2149](https://github.com/homarr-labs/homarr/issues/2149)) ([9d4aea2](https://github.com/homarr-labs/homarr/commit/9d4aea2c4187ddda6a0484d0f6a0403c04961dde))
|
||||||
|
* **deps:** update tanstack-query monorepo to ^5.65.1 ([#2161](https://github.com/homarr-labs/homarr/issues/2161)) ([55b1afb](https://github.com/homarr-labs/homarr/commit/55b1afbb2167ccb6f5115cbb1291dfe561a5aa05))
|
||||||
|
|
||||||
|
## [1.3.0](https://github.com/homarr-labs/homarr/compare/v1.2.0...v1.3.0) (2025-01-27)
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* add opacity for categories ([#2132](https://github.com/homarr-labs/homarr/issues/2132)) ([1146c59](https://github.com/homarr-labs/homarr/commit/1146c59a41ddd4b4cda14f6601437cd9bceb305a))
|
||||||
|
* **category:** save collapse state for signed in users ([#2134](https://github.com/homarr-labs/homarr/issues/2134)) ([7cb0aa7](https://github.com/homarr-labs/homarr/commit/7cb0aa70f11253aa4016d928f326eb71183626f6))
|
||||||
|
* log body integration test failure ([#2151](https://github.com/homarr-labs/homarr/issues/2151)) ([5c8d7fc](https://github.com/homarr-labs/homarr/commit/5c8d7fcc153ba4b94cd88c6ca8b22962d812b2b4))
|
||||||
|
* **user:** add search in new tab preference ([#2125](https://github.com/homarr-labs/homarr/issues/2125)) ([92f70f5](https://github.com/homarr-labs/homarr/commit/92f70f5a03048c7b269604f1ce33313ecf23a64b))
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* app url not required ([#2130](https://github.com/homarr-labs/homarr/issues/2130)) ([b38de29](https://github.com/homarr-labs/homarr/commit/b38de29a882ce26a9326f7f65403ea3916a58cac))
|
||||||
|
* bookmarks show unauthorized when not logged in ([#2123](https://github.com/homarr-labs/homarr/issues/2123)) ([2bb44e1](https://github.com/homarr-labs/homarr/commit/2bb44e16669f94d3d7500e828b1ab66b98c54bc7))
|
||||||
|
* **deps:** update dependency @t3-oss/env-nextjs to ^0.12.0 ([#2117](https://github.com/homarr-labs/homarr/issues/2117)) ([963d774](https://github.com/homarr-labs/homarr/commit/963d77463a5206ac1cb5d035b52271c6bbbd5e73))
|
||||||
|
* **deps:** update dependency drizzle-kit to ^0.30.3 ([#2142](https://github.com/homarr-labs/homarr/issues/2142)) ([0d54873](https://github.com/homarr-labs/homarr/commit/0d5487366c2616738b2542838f5cbd9c6d628973))
|
||||||
|
* **deps:** update dependency drizzle-orm to ^0.39.0 ([#2144](https://github.com/homarr-labs/homarr/issues/2144)) ([f0daef2](https://github.com/homarr-labs/homarr/commit/f0daef237c08c866f1f2aaefc113e70838b76be4))
|
||||||
|
* **deps:** update dependency drizzle-zod to ^0.7.0 ([#2143](https://github.com/homarr-labs/homarr/issues/2143)) ([af67bf2](https://github.com/homarr-labs/homarr/commit/af67bf26d674539abd7a0fdc54d7764d90f7007c))
|
||||||
|
* **deps:** update mantine monorepo to ^7.16.2 ([#2129](https://github.com/homarr-labs/homarr/issues/2129)) ([bbac412](https://github.com/homarr-labs/homarr/commit/bbac4128eb36d934310a83087c733338ae2b9b9c))
|
||||||
|
* **deps:** update tanstack-query monorepo to ^5.65.0 ([#2146](https://github.com/homarr-labs/homarr/issues/2146)) ([50970e4](https://github.com/homarr-labs/homarr/commit/50970e431d7654d2db43a4b1d55cc4f7d15a4ac2))
|
||||||
|
* **deps:** update testcontainers-node monorepo to ^10.17.2 ([#2148](https://github.com/homarr-labs/homarr/issues/2148)) ([5c219a8](https://github.com/homarr-labs/homarr/commit/5c219a8b595f90c53189569bd63fb85db8eab882))
|
||||||
|
* multi-text-input not saving on blur ([#2128](https://github.com/homarr-labs/homarr/issues/2128)) ([dd2ca13](https://github.com/homarr-labs/homarr/commit/dd2ca1300422a6ad477089efe905901515eebaf6))
|
||||||
|
* split up proxmox secrets are incorrectly migrated ([#2124](https://github.com/homarr-labs/homarr/issues/2124)) ([57b523f](https://github.com/homarr-labs/homarr/commit/57b523fa805de56d8795169a535e5e45d7c22ad7))
|
||||||
|
* unable to update integration when encryption key changed ([#2133](https://github.com/homarr-labs/homarr/issues/2133)) ([93dc3ae](https://github.com/homarr-labs/homarr/commit/93dc3ae97ed97691c304c9b6de9245930d5461ae))
|
||||||
|
* websocket is not keep alive ([#2131](https://github.com/homarr-labs/homarr/issues/2131)) ([af1f8ea](https://github.com/homarr-labs/homarr/commit/af1f8eaf76062a9fde5570097fc6a75742174d97))
|
||||||
|
|
||||||
## [1.2.0](https://github.com/homarr-labs/homarr/compare/v1.1.0...v1.2.0) (2025-01-24)
|
## [1.2.0](https://github.com/homarr-labs/homarr/compare/v1.1.0...v1.2.0) (2025-01-24)
|
||||||
|
|
||||||
### Features
|
### Features
|
||||||
|
|||||||
+7
-2
@@ -1,4 +1,4 @@
|
|||||||
FROM node:22.13.1-alpine AS base
|
FROM node:22.14.0-alpine AS base
|
||||||
|
|
||||||
FROM base AS builder
|
FROM base AS builder
|
||||||
RUN apk add --no-cache libc6-compat
|
RUN apk add --no-cache libc6-compat
|
||||||
@@ -10,6 +10,8 @@ RUN apk add --no-cache libc6-compat curl bash
|
|||||||
RUN apk update
|
RUN apk update
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
|
# Install working version of corepack (See https://github.com/nodejs/corepack/issues/612)
|
||||||
|
RUN npm install -g corepack@0.31.0 && corepack --version
|
||||||
RUN corepack enable pnpm && pnpm install --recursive --frozen-lockfile
|
RUN corepack enable pnpm && pnpm install --recursive --frozen-lockfile
|
||||||
|
|
||||||
# Copy static data as it is not part of the build
|
# Copy static data as it is not part of the build
|
||||||
@@ -17,6 +19,8 @@ COPY static-data ./static-data
|
|||||||
ARG SKIP_ENV_VALIDATION='true'
|
ARG SKIP_ENV_VALIDATION='true'
|
||||||
ARG CI='true'
|
ARG CI='true'
|
||||||
ARG DISABLE_REDIS_LOGS='true'
|
ARG DISABLE_REDIS_LOGS='true'
|
||||||
|
# Install working version of corepack (See https://github.com/nodejs/corepack/issues/612)
|
||||||
|
RUN npm install -g corepack@0.31.0 && corepack --version
|
||||||
RUN corepack enable pnpm && pnpm build
|
RUN corepack enable pnpm && pnpm build
|
||||||
|
|
||||||
FROM base AS runner
|
FROM base AS runner
|
||||||
@@ -63,6 +67,7 @@ ENV DB_URL='/appdata/db/db.sqlite'
|
|||||||
ENV DB_DIALECT='sqlite'
|
ENV DB_DIALECT='sqlite'
|
||||||
ENV DB_DRIVER='better-sqlite3'
|
ENV DB_DRIVER='better-sqlite3'
|
||||||
ENV AUTH_PROVIDERS='credentials'
|
ENV AUTH_PROVIDERS='credentials'
|
||||||
|
ENV NODE_ENV='production'
|
||||||
|
|
||||||
ENTRYPOINT [ "/app/entrypoint.sh" ]
|
ENTRYPOINT [ "/app/entrypoint.sh" ]
|
||||||
CMD ["sh", "run.sh"]
|
CMD ["sh", "run.sh"]
|
||||||
@@ -2,14 +2,13 @@
|
|||||||
import "@homarr/auth/env";
|
import "@homarr/auth/env";
|
||||||
import "@homarr/db/env";
|
import "@homarr/db/env";
|
||||||
import "@homarr/common/env";
|
import "@homarr/common/env";
|
||||||
|
import "@homarr/log/env";
|
||||||
import "@homarr/docker/env";
|
import "@homarr/docker/env";
|
||||||
|
|
||||||
import type { NextConfig } from "next";
|
import type { NextConfig } from "next";
|
||||||
import MillionLint from "@million/lint";
|
import MillionLint from "@million/lint";
|
||||||
import createNextIntlPlugin from "next-intl/plugin";
|
import createNextIntlPlugin from "next-intl/plugin";
|
||||||
|
|
||||||
import "./src/env.ts";
|
|
||||||
|
|
||||||
// Package path does not work... so we need to use relative path
|
// Package path does not work... so we need to use relative path
|
||||||
const withNextIntl = createNextIntlPlugin("../../packages/translation/src/request.ts");
|
const withNextIntl = createNextIntlPlugin("../../packages/translation/src/request.ts");
|
||||||
|
|
||||||
|
|||||||
+32
-25
@@ -15,9 +15,14 @@
|
|||||||
},
|
},
|
||||||
"prettier": "@homarr/prettier-config",
|
"prettier": "@homarr/prettier-config",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@dnd-kit/core": "^6.3.1",
|
||||||
|
"@dnd-kit/modifiers": "^9.0.0",
|
||||||
|
"@dnd-kit/sortable": "^10.0.0",
|
||||||
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
"@homarr/analytics": "workspace:^0.1.0",
|
"@homarr/analytics": "workspace:^0.1.0",
|
||||||
"@homarr/api": "workspace:^0.1.0",
|
"@homarr/api": "workspace:^0.1.0",
|
||||||
"@homarr/auth": "workspace:^0.1.0",
|
"@homarr/auth": "workspace:^0.1.0",
|
||||||
|
"@homarr/boards": "workspace:^0.1.0",
|
||||||
"@homarr/certificates": "workspace:^0.1.0",
|
"@homarr/certificates": "workspace:^0.1.0",
|
||||||
"@homarr/common": "workspace:^0.1.0",
|
"@homarr/common": "workspace:^0.1.0",
|
||||||
"@homarr/cron-job-status": "workspace:^0.1.0",
|
"@homarr/cron-job-status": "workspace:^0.1.0",
|
||||||
@@ -25,6 +30,7 @@
|
|||||||
"@homarr/definitions": "workspace:^0.1.0",
|
"@homarr/definitions": "workspace:^0.1.0",
|
||||||
"@homarr/docker": "workspace:^0.1.0",
|
"@homarr/docker": "workspace:^0.1.0",
|
||||||
"@homarr/form": "workspace:^0.1.0",
|
"@homarr/form": "workspace:^0.1.0",
|
||||||
|
"@homarr/forms-collection": "workspace:^0.1.0",
|
||||||
"@homarr/gridstack": "^1.12.0",
|
"@homarr/gridstack": "^1.12.0",
|
||||||
"@homarr/icons": "workspace:^0.1.0",
|
"@homarr/icons": "workspace:^0.1.0",
|
||||||
"@homarr/integrations": "workspace:^0.1.0",
|
"@homarr/integrations": "workspace:^0.1.0",
|
||||||
@@ -36,23 +42,23 @@
|
|||||||
"@homarr/old-schema": "workspace:^0.1.0",
|
"@homarr/old-schema": "workspace:^0.1.0",
|
||||||
"@homarr/redis": "workspace:^0.1.0",
|
"@homarr/redis": "workspace:^0.1.0",
|
||||||
"@homarr/server-settings": "workspace:^0.1.0",
|
"@homarr/server-settings": "workspace:^0.1.0",
|
||||||
|
"@homarr/settings": "workspace:^0.1.0",
|
||||||
"@homarr/spotlight": "workspace:^0.1.0",
|
"@homarr/spotlight": "workspace:^0.1.0",
|
||||||
"@homarr/translation": "workspace:^0.1.0",
|
"@homarr/translation": "workspace:^0.1.0",
|
||||||
"@homarr/ui": "workspace:^0.1.0",
|
"@homarr/ui": "workspace:^0.1.0",
|
||||||
"@homarr/validation": "workspace:^0.1.0",
|
"@homarr/validation": "workspace:^0.1.0",
|
||||||
"@homarr/widgets": "workspace:^0.1.0",
|
"@homarr/widgets": "workspace:^0.1.0",
|
||||||
"@mantine/colors-generator": "^7.16.1",
|
"@mantine/colors-generator": "^7.17.0",
|
||||||
"@mantine/core": "^7.16.1",
|
"@mantine/core": "^7.17.0",
|
||||||
"@mantine/dropzone": "^7.16.1",
|
"@mantine/dropzone": "^7.17.0",
|
||||||
"@mantine/hooks": "^7.16.1",
|
"@mantine/hooks": "^7.17.0",
|
||||||
"@mantine/modals": "^7.16.1",
|
"@mantine/modals": "^7.17.0",
|
||||||
"@mantine/tiptap": "^7.16.1",
|
"@mantine/tiptap": "^7.17.0",
|
||||||
"@million/lint": "1.0.14",
|
"@million/lint": "1.0.14",
|
||||||
"@t3-oss/env-nextjs": "^0.11.1",
|
"@tabler/icons-react": "^3.30.0",
|
||||||
"@tabler/icons-react": "^3.29.0",
|
"@tanstack/react-query": "^5.66.11",
|
||||||
"@tanstack/react-query": "^5.64.2",
|
"@tanstack/react-query-devtools": "^5.66.11",
|
||||||
"@tanstack/react-query-devtools": "^5.64.2",
|
"@tanstack/react-query-next-experimental": "^5.66.11",
|
||||||
"@tanstack/react-query-next-experimental": "5.64.2",
|
|
||||||
"@trpc/client": "next",
|
"@trpc/client": "next",
|
||||||
"@trpc/next": "next",
|
"@trpc/next": "next",
|
||||||
"@trpc/react-query": "next",
|
"@trpc/react-query": "next",
|
||||||
@@ -66,34 +72,35 @@
|
|||||||
"dotenv": "^16.4.7",
|
"dotenv": "^16.4.7",
|
||||||
"flag-icons": "^7.3.2",
|
"flag-icons": "^7.3.2",
|
||||||
"glob": "^11.0.1",
|
"glob": "^11.0.1",
|
||||||
"jotai": "^2.11.1",
|
"jotai": "^2.12.1",
|
||||||
"mantine-react-table": "2.0.0-beta.8",
|
"mantine-react-table": "2.0.0-beta.9",
|
||||||
"next": "15.1.6",
|
"next": "15.1.7",
|
||||||
"postcss-preset-mantine": "^1.17.0",
|
"postcss-preset-mantine": "^1.17.0",
|
||||||
"prismjs": "^1.29.0",
|
"prismjs": "^1.29.0",
|
||||||
"react": "19.0.0",
|
"react": "19.0.0",
|
||||||
"react-dom": "19.0.0",
|
"react-dom": "19.0.0",
|
||||||
"react-error-boundary": "^5.0.0",
|
"react-error-boundary": "^5.0.0",
|
||||||
"react-simple-code-editor": "^0.14.1",
|
"react-simple-code-editor": "^0.14.1",
|
||||||
"sass": "^1.83.4",
|
"sass": "^1.85.1",
|
||||||
"superjson": "2.2.2",
|
"superjson": "2.2.2",
|
||||||
"swagger-ui-react": "^5.18.2",
|
"swagger-ui-react": "^5.20.0",
|
||||||
"use-deep-compare-effect": "^1.8.1"
|
"use-deep-compare-effect": "^1.8.1",
|
||||||
|
"zod": "^3.24.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@homarr/eslint-config": "workspace:^0.2.0",
|
"@homarr/eslint-config": "workspace:^0.2.0",
|
||||||
"@homarr/prettier-config": "workspace:^0.1.0",
|
"@homarr/prettier-config": "workspace:^0.1.0",
|
||||||
"@homarr/tsconfig": "workspace:^0.1.0",
|
"@homarr/tsconfig": "workspace:^0.1.0",
|
||||||
"@types/chroma-js": "3.1.0",
|
"@types/chroma-js": "3.1.1",
|
||||||
"@types/node": "^22.10.10",
|
"@types/node": "^22.13.5",
|
||||||
"@types/prismjs": "^1.26.5",
|
"@types/prismjs": "^1.26.5",
|
||||||
"@types/react": "19.0.8",
|
"@types/react": "19.0.10",
|
||||||
"@types/react-dom": "19.0.3",
|
"@types/react-dom": "19.0.4",
|
||||||
"@types/swagger-ui-react": "^4.18.3",
|
"@types/swagger-ui-react": "^5.18.0",
|
||||||
"concurrently": "^9.1.2",
|
"concurrently": "^9.1.2",
|
||||||
"eslint": "^9.18.0",
|
"eslint": "^9.21.0",
|
||||||
"node-loader": "^2.1.0",
|
"node-loader": "^2.1.0",
|
||||||
"prettier": "^3.4.2",
|
"prettier": "^3.5.2",
|
||||||
"typescript": "^5.7.3"
|
"typescript": "^5.8.2"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -18,10 +18,9 @@ import superjson from "superjson";
|
|||||||
import type { SuperJSONResult } from "superjson";
|
import type { SuperJSONResult } from "superjson";
|
||||||
|
|
||||||
import type { AppRouter } from "@homarr/api";
|
import type { AppRouter } from "@homarr/api";
|
||||||
import { clientApi, getTrpcUrl } from "@homarr/api/client";
|
import { clientApi } from "@homarr/api/client";
|
||||||
import { createHeadersCallbackForSource } from "@homarr/api/shared";
|
import { createHeadersCallbackForSource, getTrpcUrl } from "@homarr/api/shared";
|
||||||
|
import { env } from "@homarr/common/env";
|
||||||
import { env } from "~/env";
|
|
||||||
|
|
||||||
const getWebSocketProtocol = () => {
|
const getWebSocketProtocol = () => {
|
||||||
// window is not defined on server side
|
// window is not defined on server side
|
||||||
@@ -66,7 +65,7 @@ export function TRPCReactProvider(props: PropsWithChildren) {
|
|||||||
links: [
|
links: [
|
||||||
loggerLink({
|
loggerLink({
|
||||||
enabled: (opts) =>
|
enabled: (opts) =>
|
||||||
process.env.NODE_ENV === "development" || (opts.direction === "down" && opts.result instanceof Error),
|
env.NODE_ENV === "development" || (opts.direction === "down" && opts.result instanceof Error),
|
||||||
}),
|
}),
|
||||||
splitLink({
|
splitLink({
|
||||||
condition: ({ type }) => type === "subscription",
|
condition: ({ type }) => type === "subscription",
|
||||||
|
|||||||
@@ -2,13 +2,13 @@
|
|||||||
|
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { Button, PasswordInput, Stack, TextInput } from "@mantine/core";
|
import { Button, PasswordInput, Stack, TextInput } from "@mantine/core";
|
||||||
|
import type { z } from "zod";
|
||||||
|
|
||||||
import { clientApi } from "@homarr/api/client";
|
import { clientApi } from "@homarr/api/client";
|
||||||
import { useZodForm } from "@homarr/form";
|
import { useZodForm } from "@homarr/form";
|
||||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||||
import { useScopedI18n } from "@homarr/translation/client";
|
import { useScopedI18n } from "@homarr/translation/client";
|
||||||
import { CustomPasswordInput } from "@homarr/ui";
|
import { CustomPasswordInput } from "@homarr/ui";
|
||||||
import type { z } from "@homarr/validation";
|
|
||||||
import { validation } from "@homarr/validation";
|
import { validation } from "@homarr/validation";
|
||||||
|
|
||||||
interface RegistrationFormProps {
|
interface RegistrationFormProps {
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { useCallback, useEffect, useRef, useState } from "react";
|
|||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { Anchor, Button, Card, Code, Collapse, Divider, PasswordInput, Stack, Text, TextInput } from "@mantine/core";
|
import { Anchor, Button, Card, Code, Collapse, Divider, PasswordInput, Stack, Text, TextInput } from "@mantine/core";
|
||||||
import { useDisclosure } from "@mantine/hooks";
|
import { useDisclosure } from "@mantine/hooks";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
import { signIn } from "@homarr/auth/client";
|
import { signIn } from "@homarr/auth/client";
|
||||||
import { revalidatePathActionAsync } from "@homarr/common/client";
|
import { revalidatePathActionAsync } from "@homarr/common/client";
|
||||||
@@ -12,7 +13,7 @@ import type { useForm } from "@homarr/form";
|
|||||||
import { useZodForm } from "@homarr/form";
|
import { useZodForm } from "@homarr/form";
|
||||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||||
import { useScopedI18n } from "@homarr/translation/client";
|
import { useScopedI18n } from "@homarr/translation/client";
|
||||||
import { validation, z } from "@homarr/validation";
|
import { validation } from "@homarr/validation";
|
||||||
|
|
||||||
interface LoginFormProps {
|
interface LoginFormProps {
|
||||||
providers: string[];
|
providers: string[];
|
||||||
|
|||||||
@@ -5,12 +5,13 @@ import { Box, LoadingOverlay, Stack } from "@mantine/core";
|
|||||||
|
|
||||||
import type { RouterOutputs } from "@homarr/api";
|
import type { RouterOutputs } from "@homarr/api";
|
||||||
import { clientApi } from "@homarr/api/client";
|
import { clientApi } from "@homarr/api/client";
|
||||||
|
import { useCurrentLayout, useRequiredBoard } from "@homarr/boards/context";
|
||||||
|
|
||||||
import { BoardCategorySection } from "~/components/board/sections/category-section";
|
import { BoardCategorySection } from "~/components/board/sections/category-section";
|
||||||
import { BoardEmptySection } from "~/components/board/sections/empty-section";
|
import { BoardEmptySection } from "~/components/board/sections/empty-section";
|
||||||
import { BoardBackgroundVideo } from "~/components/layout/background";
|
import { BoardBackgroundVideo } from "~/components/layout/background";
|
||||||
import { fullHeightWithoutHeaderAndFooter } from "~/constants";
|
import { fullHeightWithoutHeaderAndFooter } from "~/constants";
|
||||||
import { useIsBoardReady, useRequiredBoard } from "./_context";
|
import { useIsBoardReady } from "./_ready-context";
|
||||||
|
|
||||||
let boardName: string | null = null;
|
let boardName: string | null = null;
|
||||||
|
|
||||||
@@ -42,6 +43,7 @@ export const useUpdateBoard = () => {
|
|||||||
|
|
||||||
export const ClientBoard = () => {
|
export const ClientBoard = () => {
|
||||||
const board = useRequiredBoard();
|
const board = useRequiredBoard();
|
||||||
|
const currentLayoutId = useCurrentLayout();
|
||||||
const isReady = useIsBoardReady();
|
const isReady = useIsBoardReady();
|
||||||
|
|
||||||
const fullWidthSortedSections = board.sections
|
const fullWidthSortedSections = board.sections
|
||||||
@@ -62,9 +64,10 @@ export const ClientBoard = () => {
|
|||||||
<Stack ref={ref} h="100%" style={{ visibility: isReady ? "visible" : "hidden" }}>
|
<Stack ref={ref} h="100%" style={{ visibility: isReady ? "visible" : "hidden" }}>
|
||||||
{fullWidthSortedSections.map((section) =>
|
{fullWidthSortedSections.map((section) =>
|
||||||
section.kind === "empty" ? (
|
section.kind === "empty" ? (
|
||||||
<BoardEmptySection key={section.id} section={section} />
|
// Unique keys per layout to always reinitialize the gridstack
|
||||||
|
<BoardEmptySection key={`${currentLayoutId}-${section.id}`} section={section} />
|
||||||
) : (
|
) : (
|
||||||
<BoardCategorySection key={section.id} section={section} />
|
<BoardCategorySection key={`${currentLayoutId}-${section.id}`} section={section} />
|
||||||
),
|
),
|
||||||
)}
|
)}
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|||||||
@@ -7,12 +7,13 @@ import "~/styles/gridstack.scss";
|
|||||||
import { IntegrationProvider } from "@homarr/auth/client";
|
import { IntegrationProvider } from "@homarr/auth/client";
|
||||||
import { auth } from "@homarr/auth/next";
|
import { auth } from "@homarr/auth/next";
|
||||||
import { getIntegrationsWithPermissionsAsync } from "@homarr/auth/server";
|
import { getIntegrationsWithPermissionsAsync } from "@homarr/auth/server";
|
||||||
|
import { isNullOrWhitespace } from "@homarr/common";
|
||||||
import { getI18n } from "@homarr/translation/server";
|
import { getI18n } from "@homarr/translation/server";
|
||||||
|
|
||||||
import { createMetaTitle } from "~/metadata";
|
import { createMetaTitle } from "~/metadata";
|
||||||
import { createBoardLayout } from "../_layout-creator";
|
import { createBoardLayout } from "../_layout-creator";
|
||||||
import type { Board } from "../_types";
|
import type { Board } from "../_types";
|
||||||
import { ClientBoard } from "./_client";
|
import { DynamicClientBoard } from "./_dynamic-client";
|
||||||
import { BoardContentHeaderActions } from "./_header-actions";
|
import { BoardContentHeaderActions } from "./_header-actions";
|
||||||
|
|
||||||
export type Params = Record<string, unknown>;
|
export type Params = Record<string, unknown>;
|
||||||
@@ -36,23 +37,25 @@ export const createBoardContentPage = <TParams extends Record<string, unknown>>(
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<IntegrationProvider integrations={integrations}>
|
<IntegrationProvider integrations={integrations}>
|
||||||
<ClientBoard />
|
<DynamicClientBoard />
|
||||||
</IntegrationProvider>
|
</IntegrationProvider>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
generateMetadataAsync: async ({ params }: { params: TParams }): Promise<Metadata> => {
|
generateMetadataAsync: async ({ params }: { params: Promise<TParams> }): Promise<Metadata> => {
|
||||||
try {
|
try {
|
||||||
const board = await getInitialBoard(params);
|
const board = await getInitialBoard(await params);
|
||||||
const t = await getI18n();
|
const t = await getI18n();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
title: board.metaTitle ?? createMetaTitle(t("board.content.metaTitle", { boardName: board.name })),
|
title: board.metaTitle ?? createMetaTitle(t("board.content.metaTitle", { boardName: board.name })),
|
||||||
icons: {
|
icons: {
|
||||||
icon: board.faviconImageUrl ? board.faviconImageUrl : undefined,
|
icon: !isNullOrWhitespace(board.faviconImageUrl) ? board.faviconImageUrl : undefined,
|
||||||
apple: board.faviconImageUrl ? board.faviconImageUrl : undefined,
|
apple: !isNullOrWhitespace(board.faviconImageUrl) ? board.faviconImageUrl : undefined,
|
||||||
},
|
},
|
||||||
appleWebApp: {
|
appleWebApp: {
|
||||||
startupImage: { url: board.faviconImageUrl ? board.faviconImageUrl : "/logo/logo.png" },
|
startupImage: {
|
||||||
|
url: !isNullOrWhitespace(board.faviconImageUrl) ? board.faviconImageUrl : "/logo/logo.png",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useRequiredBoard } from "./_context";
|
import { useRequiredBoard } from "@homarr/boards/context";
|
||||||
|
|
||||||
export const CustomCss = () => {
|
export const CustomCss = () => {
|
||||||
const board = useRequiredBoard();
|
const board = useRequiredBoard();
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import dynamic from "next/dynamic";
|
||||||
|
|
||||||
|
export const DynamicClientBoard = dynamic(() => import("./_client").then((mod) => mod.ClientBoard), {
|
||||||
|
ssr: false,
|
||||||
|
});
|
||||||
@@ -4,7 +4,7 @@ import type { MouseEvent } from "react";
|
|||||||
import { useCallback, useEffect } from "react";
|
import { useCallback, useEffect } from "react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { Group, Menu } from "@mantine/core";
|
import { Group, Menu, ScrollArea } from "@mantine/core";
|
||||||
import { useHotkeys } from "@mantine/hooks";
|
import { useHotkeys } from "@mantine/hooks";
|
||||||
import {
|
import {
|
||||||
IconBox,
|
IconBox,
|
||||||
@@ -20,7 +20,10 @@ import {
|
|||||||
} from "@tabler/icons-react";
|
} from "@tabler/icons-react";
|
||||||
|
|
||||||
import { clientApi } from "@homarr/api/client";
|
import { clientApi } from "@homarr/api/client";
|
||||||
|
import { useRequiredBoard } from "@homarr/boards/context";
|
||||||
|
import { useEditMode } from "@homarr/boards/edit-mode";
|
||||||
import { revalidatePathActionAsync } from "@homarr/common/client";
|
import { revalidatePathActionAsync } from "@homarr/common/client";
|
||||||
|
import { env } from "@homarr/common/env";
|
||||||
import { useConfirmModal, useModalAction } from "@homarr/modals";
|
import { useConfirmModal, useModalAction } from "@homarr/modals";
|
||||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||||
import { useI18n, useScopedI18n } from "@homarr/translation/client";
|
import { useI18n, useScopedI18n } from "@homarr/translation/client";
|
||||||
@@ -31,8 +34,6 @@ import { useCategoryActions } from "~/components/board/sections/category/categor
|
|||||||
import { CategoryEditModal } from "~/components/board/sections/category/category-edit-modal";
|
import { CategoryEditModal } from "~/components/board/sections/category/category-edit-modal";
|
||||||
import { useDynamicSectionActions } from "~/components/board/sections/dynamic/dynamic-actions";
|
import { useDynamicSectionActions } from "~/components/board/sections/dynamic/dynamic-actions";
|
||||||
import { HeaderButton } from "~/components/layout/header/button";
|
import { HeaderButton } from "~/components/layout/header/button";
|
||||||
import { env } from "~/env";
|
|
||||||
import { useEditMode, useRequiredBoard } from "./_context";
|
|
||||||
|
|
||||||
export const BoardContentHeaderActions = () => {
|
export const BoardContentHeaderActions = () => {
|
||||||
const [isEditMode] = useEditMode();
|
const [isEditMode] = useEditMode();
|
||||||
@@ -119,7 +120,7 @@ const AddMenu = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const EditModeMenu = () => {
|
const EditModeMenu = () => {
|
||||||
const [isEditMode, setEditMode] = useEditMode();
|
const [isEditMode, { open, close }] = useEditMode();
|
||||||
const board = useRequiredBoard();
|
const board = useRequiredBoard();
|
||||||
const utils = clientApi.useUtils();
|
const utils = clientApi.useUtils();
|
||||||
const t = useScopedI18n("board.action.edit");
|
const t = useScopedI18n("board.action.edit");
|
||||||
@@ -131,7 +132,7 @@ const EditModeMenu = () => {
|
|||||||
});
|
});
|
||||||
void utils.board.getBoardByName.invalidate({ name: board.name });
|
void utils.board.getBoardByName.invalidate({ name: board.name });
|
||||||
void revalidatePathActionAsync(`/boards/${board.name}`);
|
void revalidatePathActionAsync(`/boards/${board.name}`);
|
||||||
setEditMode(false);
|
close();
|
||||||
},
|
},
|
||||||
onError() {
|
onError() {
|
||||||
showErrorNotification({
|
showErrorNotification({
|
||||||
@@ -143,8 +144,8 @@ const EditModeMenu = () => {
|
|||||||
|
|
||||||
const toggle = useCallback(() => {
|
const toggle = useCallback(() => {
|
||||||
if (isEditMode) return saveBoard(board);
|
if (isEditMode) return saveBoard(board);
|
||||||
setEditMode(true);
|
open();
|
||||||
}, [board, isEditMode, saveBoard, setEditMode]);
|
}, [board, isEditMode, saveBoard, open]);
|
||||||
|
|
||||||
useHotkeys([["mod+e", toggle]]);
|
useHotkeys([["mod+e", toggle]]);
|
||||||
usePreventLeaveWithDirty(isEditMode);
|
usePreventLeaveWithDirty(isEditMode);
|
||||||
@@ -167,16 +168,18 @@ const SelectBoardsMenu = () => {
|
|||||||
</HeaderButton>
|
</HeaderButton>
|
||||||
</Menu.Target>
|
</Menu.Target>
|
||||||
<Menu.Dropdown style={{ transform: "translate(-7px, 0)" }}>
|
<Menu.Dropdown style={{ transform: "translate(-7px, 0)" }}>
|
||||||
{boards.map((board) => (
|
<ScrollArea.Autosize mah={300}>
|
||||||
<Menu.Item
|
{boards.map((board) => (
|
||||||
key={board.id}
|
<Menu.Item
|
||||||
component={Link}
|
key={board.id}
|
||||||
href={`/boards/${board.name}`}
|
component={Link}
|
||||||
leftSection={<IconLayoutBoard size={20} />}
|
href={`/boards/${board.name}`}
|
||||||
>
|
leftSection={<IconLayoutBoard size={20} />}
|
||||||
{board.name}
|
>
|
||||||
</Menu.Item>
|
{board.name}
|
||||||
))}
|
</Menu.Item>
|
||||||
|
))}
|
||||||
|
</ScrollArea.Autosize>
|
||||||
</Menu.Dropdown>
|
</Menu.Dropdown>
|
||||||
</Menu>
|
</Menu>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import type { PropsWithChildren } from "react";
|
||||||
|
import { createContext, useCallback, useContext, useEffect, useState } from "react";
|
||||||
|
import { usePathname } from "next/navigation";
|
||||||
|
|
||||||
|
import { clientApi } from "@homarr/api/client";
|
||||||
|
import { useRequiredBoard } from "@homarr/boards/context";
|
||||||
|
|
||||||
|
const BoardReadyContext = createContext<{
|
||||||
|
isReady: boolean;
|
||||||
|
markAsReady: (id: string) => void;
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
|
export const BoardReadyProvider = ({ children }: PropsWithChildren) => {
|
||||||
|
const pathname = usePathname();
|
||||||
|
const utils = clientApi.useUtils();
|
||||||
|
const board = useRequiredBoard();
|
||||||
|
const [readySections, setReadySections] = useState<string[]>([]);
|
||||||
|
|
||||||
|
// Reset sections required for ready state
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
setReadySections([]);
|
||||||
|
};
|
||||||
|
}, [pathname, utils]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setReadySections((previous) => previous.filter((id) => board.sections.some((section) => section.id === id)));
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [board.sections.length, setReadySections]);
|
||||||
|
|
||||||
|
const markAsReady = useCallback((id: string) => {
|
||||||
|
setReadySections((previous) => (previous.includes(id) ? previous : [...previous, id]));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<BoardReadyContext.Provider
|
||||||
|
value={{
|
||||||
|
isReady: board.sections.length === readySections.length,
|
||||||
|
markAsReady,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</BoardReadyContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useMarkSectionAsReady = () => {
|
||||||
|
const context = useContext(BoardReadyContext);
|
||||||
|
|
||||||
|
if (!context) {
|
||||||
|
throw new Error("BoardReadyProvider is required");
|
||||||
|
}
|
||||||
|
|
||||||
|
return context.markAsReady;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useIsBoardReady = () => {
|
||||||
|
const context = useContext(BoardReadyContext);
|
||||||
|
|
||||||
|
if (!context) {
|
||||||
|
throw new Error("BoardReadyProvider is required");
|
||||||
|
}
|
||||||
|
|
||||||
|
return context.isReady;
|
||||||
|
};
|
||||||
@@ -2,12 +2,12 @@
|
|||||||
|
|
||||||
import type { PropsWithChildren } from "react";
|
import type { PropsWithChildren } from "react";
|
||||||
import type { MantineColorsTuple } from "@mantine/core";
|
import type { MantineColorsTuple } from "@mantine/core";
|
||||||
import { createTheme, darken, lighten, MantineProvider } from "@mantine/core";
|
import { colorsTuple, createTheme, darken, lighten, MantineProvider } from "@mantine/core";
|
||||||
|
|
||||||
|
import { useRequiredBoard } from "@homarr/boards/context";
|
||||||
import type { ColorScheme } from "@homarr/definitions";
|
import type { ColorScheme } from "@homarr/definitions";
|
||||||
|
|
||||||
import { useColorSchemeManager } from "../../_client-providers/mantine";
|
import { useColorSchemeManager } from "../../_client-providers/mantine";
|
||||||
import { useRequiredBoard } from "./_context";
|
|
||||||
|
|
||||||
export const BoardMantineProvider = ({
|
export const BoardMantineProvider = ({
|
||||||
children,
|
children,
|
||||||
@@ -20,6 +20,7 @@ export const BoardMantineProvider = ({
|
|||||||
colors: {
|
colors: {
|
||||||
primaryColor: generateColors(board.primaryColor),
|
primaryColor: generateColors(board.primaryColor),
|
||||||
secondaryColor: generateColors(board.secondaryColor),
|
secondaryColor: generateColors(board.secondaryColor),
|
||||||
|
iconColor: board.iconColor ? generateColors(board.iconColor) : colorsTuple("#000000"),
|
||||||
},
|
},
|
||||||
primaryColor: "primaryColor",
|
primaryColor: "primaryColor",
|
||||||
autoContrast: true,
|
autoContrast: true,
|
||||||
|
|||||||
+23
@@ -10,6 +10,7 @@ import {
|
|||||||
Group,
|
Group,
|
||||||
InputWrapper,
|
InputWrapper,
|
||||||
isLightColor,
|
isLightColor,
|
||||||
|
Select,
|
||||||
Slider,
|
Slider,
|
||||||
Stack,
|
Stack,
|
||||||
Text,
|
Text,
|
||||||
@@ -39,6 +40,8 @@ export const ColorSettingsContent = ({ board }: Props) => {
|
|||||||
primaryColor: board.primaryColor,
|
primaryColor: board.primaryColor,
|
||||||
secondaryColor: board.secondaryColor,
|
secondaryColor: board.secondaryColor,
|
||||||
opacity: board.opacity,
|
opacity: board.opacity,
|
||||||
|
iconColor: board.iconColor ?? "",
|
||||||
|
itemRadius: board.itemRadius,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const [showPreview, { toggle }] = useDisclosure(false);
|
const [showPreview, { toggle }] = useDisclosure(false);
|
||||||
@@ -98,6 +101,26 @@ export const ColorSettingsContent = ({ board }: Props) => {
|
|||||||
/>
|
/>
|
||||||
</InputWrapper>
|
</InputWrapper>
|
||||||
</Grid.Col>
|
</Grid.Col>
|
||||||
|
<Grid.Col span={{ sm: 12, md: 6 }}>
|
||||||
|
<ColorInput
|
||||||
|
label={t("board.field.iconColor.label")}
|
||||||
|
format="hex"
|
||||||
|
swatches={Object.values(theme.colors).map((color) => color[6])}
|
||||||
|
{...form.getInputProps("iconColor")}
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
label={t("board.field.itemRadius.label")}
|
||||||
|
description={t("board.field.itemRadius.description")}
|
||||||
|
data={[
|
||||||
|
{ label: t("board.field.itemRadius.option.xs"), value: "xs" },
|
||||||
|
{ label: t("board.field.itemRadius.option.sm"), value: "sm" },
|
||||||
|
{ label: t("board.field.itemRadius.option.md"), value: "md" },
|
||||||
|
{ label: t("board.field.itemRadius.option.lg"), value: "lg" },
|
||||||
|
{ label: t("board.field.itemRadius.option.xl"), value: "xl" },
|
||||||
|
]}
|
||||||
|
{...form.getInputProps("itemRadius")}
|
||||||
|
/>
|
||||||
|
</Grid.Col>
|
||||||
</Grid>
|
</Grid>
|
||||||
<Group justify="end">
|
<Group justify="end">
|
||||||
<Button type="submit" loading={isPending} color="teal">
|
<Button type="submit" loading={isPending} color="teal">
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Button, Group, Stack, Switch } from "@mantine/core";
|
||||||
|
|
||||||
|
import { useForm } from "@homarr/form";
|
||||||
|
import { useI18n } from "@homarr/translation/client";
|
||||||
|
|
||||||
|
import type { Board } from "../../_types";
|
||||||
|
import { useSavePartialSettingsMutation } from "./_shared";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
board: Board;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const BehaviorSettingsContent = ({ board }: Props) => {
|
||||||
|
const t = useI18n();
|
||||||
|
const { mutate: savePartialSettings, isPending } = useSavePartialSettingsMutation(board);
|
||||||
|
const form = useForm({
|
||||||
|
initialValues: {
|
||||||
|
disableStatus: board.disableStatus,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form
|
||||||
|
onSubmit={form.onSubmit((values) => {
|
||||||
|
savePartialSettings({
|
||||||
|
id: board.id,
|
||||||
|
...values,
|
||||||
|
});
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<Stack>
|
||||||
|
<Switch
|
||||||
|
label={t("board.field.disableStatus.label")}
|
||||||
|
description={t("board.field.disableStatus.description")}
|
||||||
|
{...form.getInputProps("disableStatus", { type: "checkbox" })}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Group justify="end">
|
||||||
|
<Button type="submit" loading={isPending} color="teal">
|
||||||
|
{t("common.action.saveChanges")}
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -5,11 +5,11 @@ import { useRouter } from "next/navigation";
|
|||||||
import { Button, Divider, Group, Stack, Text } from "@mantine/core";
|
import { Button, Divider, Group, Stack, Text } from "@mantine/core";
|
||||||
|
|
||||||
import { clientApi } from "@homarr/api/client";
|
import { clientApi } from "@homarr/api/client";
|
||||||
|
import { useRequiredBoard } from "@homarr/boards/context";
|
||||||
import { useConfirmModal, useModalAction } from "@homarr/modals";
|
import { useConfirmModal, useModalAction } from "@homarr/modals";
|
||||||
import { useScopedI18n } from "@homarr/translation/client";
|
import { useScopedI18n } from "@homarr/translation/client";
|
||||||
|
|
||||||
import { BoardRenameModal } from "~/components/board/modals/board-rename-modal";
|
import { BoardRenameModal } from "~/components/board/modals/board-rename-modal";
|
||||||
import { useRequiredBoard } from "../../(content)/_context";
|
|
||||||
import classes from "./danger.module.css";
|
import classes from "./danger.module.css";
|
||||||
|
|
||||||
export const DangerZoneSettingsContent = ({ hideVisibility }: { hideVisibility: boolean }) => {
|
export const DangerZoneSettingsContent = ({ hideVisibility }: { hideVisibility: boolean }) => {
|
||||||
|
|||||||
@@ -5,13 +5,13 @@ import { Button, Grid, Group, Loader, Stack, TextInput, Tooltip } from "@mantine
|
|||||||
import { useDebouncedValue, useDocumentTitle, useFavicon } from "@mantine/hooks";
|
import { useDebouncedValue, useDocumentTitle, useFavicon } from "@mantine/hooks";
|
||||||
import { IconAlertTriangle } from "@tabler/icons-react";
|
import { IconAlertTriangle } from "@tabler/icons-react";
|
||||||
|
|
||||||
|
import { useUpdateBoard } from "@homarr/boards/updater";
|
||||||
import { useZodForm } from "@homarr/form";
|
import { useZodForm } from "@homarr/form";
|
||||||
import { useI18n } from "@homarr/translation/client";
|
import { useI18n } from "@homarr/translation/client";
|
||||||
import { validation } from "@homarr/validation";
|
import { validation } from "@homarr/validation";
|
||||||
|
|
||||||
import { createMetaTitle } from "~/metadata";
|
import { createMetaTitle } from "~/metadata";
|
||||||
import type { Board } from "../../_types";
|
import type { Board } from "../../_types";
|
||||||
import { useUpdateBoard } from "../../(content)/_client";
|
|
||||||
import { useSavePartialSettingsMutation } from "./_shared";
|
import { useSavePartialSettingsMutation } from "./_shared";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
|
|||||||
@@ -1,43 +1,109 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { Button, Grid, Group, Input, Slider, Stack } from "@mantine/core";
|
import { Button, Fieldset, Grid, Group, Input, NumberInput, Slider, Stack, Text, TextInput } from "@mantine/core";
|
||||||
|
|
||||||
|
import { clientApi } from "@homarr/api/client";
|
||||||
|
import { createId } from "@homarr/db/client";
|
||||||
import { useZodForm } from "@homarr/form";
|
import { useZodForm } from "@homarr/form";
|
||||||
import { useI18n } from "@homarr/translation/client";
|
import { useI18n } from "@homarr/translation/client";
|
||||||
import { validation } from "@homarr/validation";
|
import { validation } from "@homarr/validation";
|
||||||
|
|
||||||
import type { Board } from "../../_types";
|
import type { Board } from "../../_types";
|
||||||
import { useSavePartialSettingsMutation } from "./_shared";
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
board: Board;
|
board: Board;
|
||||||
}
|
}
|
||||||
export const LayoutSettingsContent = ({ board }: Props) => {
|
export const LayoutSettingsContent = ({ board }: Props) => {
|
||||||
const t = useI18n();
|
const t = useI18n();
|
||||||
const { mutate: savePartialSettings, isPending } = useSavePartialSettingsMutation(board);
|
const utils = clientApi.useUtils();
|
||||||
const form = useZodForm(validation.board.savePartialSettings.pick({ columnCount: true }).required(), {
|
const { mutate: saveLayouts, isPending } = clientApi.board.saveLayouts.useMutation({
|
||||||
|
onSettled() {
|
||||||
|
void utils.board.getBoardByName.invalidate({ name: board.name });
|
||||||
|
void utils.board.getHomeBoard.invalidate();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const form = useZodForm(validation.board.saveLayouts.omit({ id: true }).required(), {
|
||||||
initialValues: {
|
initialValues: {
|
||||||
columnCount: board.columnCount,
|
layouts: board.layouts,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form
|
<form
|
||||||
onSubmit={form.onSubmit((values) => {
|
onSubmit={form.onSubmit((values) => {
|
||||||
savePartialSettings({
|
saveLayouts({
|
||||||
id: board.id,
|
id: board.id,
|
||||||
...values,
|
...values,
|
||||||
});
|
});
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
<Stack>
|
<Stack>
|
||||||
<Grid>
|
<Stack gap="sm">
|
||||||
<Grid.Col span={{ sm: 12, md: 6 }}>
|
<Group justify="space-between" align="center">
|
||||||
<Input.Wrapper label={t("board.field.columnCount.label")}>
|
<Text fw={500}>{t("board.setting.section.layout.responsive.title")}</Text>
|
||||||
<Slider mt="xs" min={1} max={24} step={1} {...form.getInputProps("columnCount")} />
|
<Button
|
||||||
</Input.Wrapper>
|
variant="subtle"
|
||||||
</Grid.Col>
|
onClick={() => {
|
||||||
</Grid>
|
form.setValues({
|
||||||
|
layouts: [
|
||||||
|
...form.values.layouts,
|
||||||
|
{
|
||||||
|
id: createId(),
|
||||||
|
name: "",
|
||||||
|
columnCount: 10,
|
||||||
|
breakpoint: 0,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("board.setting.section.layout.responsive.action.add")}
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
{form.values.layouts.map((layout, index) => (
|
||||||
|
<Fieldset key={layout.id} legend={layout.name} bg="transparent">
|
||||||
|
<Grid>
|
||||||
|
<Grid.Col span={{ sm: 12, md: 6 }}>
|
||||||
|
<TextInput {...form.getInputProps(`layouts.${index}.name`)} label={t("layout.field.name.label")} />
|
||||||
|
</Grid.Col>
|
||||||
|
|
||||||
|
<Grid.Col span={{ sm: 12, md: 6 }}>
|
||||||
|
<Input.Wrapper label={t("layout.field.columnCount.label")}>
|
||||||
|
<Slider mt="xs" min={1} max={24} step={1} {...form.getInputProps(`layouts.${index}.columnCount`)} />
|
||||||
|
</Input.Wrapper>
|
||||||
|
</Grid.Col>
|
||||||
|
|
||||||
|
<Grid.Col span={{ sm: 12, md: 6 }}>
|
||||||
|
<NumberInput
|
||||||
|
{...form.getInputProps(`layouts.${index}.breakpoint`)}
|
||||||
|
label={t("layout.field.breakpoint.label")}
|
||||||
|
description={t("layout.field.breakpoint.description")}
|
||||||
|
/>
|
||||||
|
</Grid.Col>
|
||||||
|
</Grid>
|
||||||
|
{form.values.layouts.length >= 2 && (
|
||||||
|
<Group justify="end">
|
||||||
|
<Button
|
||||||
|
variant="subtle"
|
||||||
|
onClick={() => {
|
||||||
|
form.setValues((previous) =>
|
||||||
|
previous.layouts !== undefined && previous.layouts.length >= 2
|
||||||
|
? {
|
||||||
|
layouts: form.values.layouts.filter((filteredLayout) => filteredLayout.id !== layout.id),
|
||||||
|
}
|
||||||
|
: previous,
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("common.action.remove")}
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
)}
|
||||||
|
</Fieldset>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
|
||||||
<Group justify="end">
|
<Group justify="end">
|
||||||
<Button type="submit" loading={isPending} color="teal">
|
<Button type="submit" loading={isPending} color="teal">
|
||||||
{t("common.action.saveChanges")}
|
{t("common.action.saveChanges")}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { AccordionControl, AccordionItem, AccordionPanel, Container, Stack, Text
|
|||||||
import {
|
import {
|
||||||
IconAlertTriangle,
|
IconAlertTriangle,
|
||||||
IconBrush,
|
IconBrush,
|
||||||
|
IconClick,
|
||||||
IconFileTypeCss,
|
IconFileTypeCss,
|
||||||
IconLayout,
|
IconLayout,
|
||||||
IconPhoto,
|
IconPhoto,
|
||||||
@@ -22,9 +23,10 @@ import type { TablerIcon } from "@homarr/ui";
|
|||||||
|
|
||||||
import { getBoardPermissionsAsync } from "~/components/board/permissions/server";
|
import { getBoardPermissionsAsync } from "~/components/board/permissions/server";
|
||||||
import { ActiveTabAccordion } from "../../../../../components/active-tab-accordion";
|
import { ActiveTabAccordion } from "../../../../../components/active-tab-accordion";
|
||||||
|
import { ColorSettingsContent } from "./_appereance";
|
||||||
import { BackgroundSettingsContent } from "./_background";
|
import { BackgroundSettingsContent } from "./_background";
|
||||||
|
import { BehaviorSettingsContent } from "./_behavior";
|
||||||
import { BoardAccessSettings } from "./_board-access";
|
import { BoardAccessSettings } from "./_board-access";
|
||||||
import { ColorSettingsContent } from "./_colors";
|
|
||||||
import { CustomCssSettingsContent } from "./_customCss";
|
import { CustomCssSettingsContent } from "./_customCss";
|
||||||
import { DangerZoneSettingsContent } from "./_danger";
|
import { DangerZoneSettingsContent } from "./_danger";
|
||||||
import { GeneralSettingsContent } from "./_general";
|
import { GeneralSettingsContent } from "./_general";
|
||||||
@@ -89,12 +91,15 @@ export default async function BoardSettingsPage(props: Props) {
|
|||||||
<AccordionItemFor value="background" icon={IconPhoto}>
|
<AccordionItemFor value="background" icon={IconPhoto}>
|
||||||
<BackgroundSettingsContent board={board} />
|
<BackgroundSettingsContent board={board} />
|
||||||
</AccordionItemFor>
|
</AccordionItemFor>
|
||||||
<AccordionItemFor value="color" icon={IconBrush}>
|
<AccordionItemFor value="appearance" icon={IconBrush}>
|
||||||
<ColorSettingsContent board={board} />
|
<ColorSettingsContent board={board} />
|
||||||
</AccordionItemFor>
|
</AccordionItemFor>
|
||||||
<AccordionItemFor value="customCss" icon={IconFileTypeCss}>
|
<AccordionItemFor value="customCss" icon={IconFileTypeCss}>
|
||||||
<CustomCssSettingsContent board={board} />
|
<CustomCssSettingsContent board={board} />
|
||||||
</AccordionItemFor>
|
</AccordionItemFor>
|
||||||
|
<AccordionItemFor value="behavior" icon={IconClick}>
|
||||||
|
<BehaviorSettingsContent board={board} />
|
||||||
|
</AccordionItemFor>
|
||||||
{hasFullAccess && (
|
{hasFullAccess && (
|
||||||
<>
|
<>
|
||||||
<AccordionItemFor value="access" icon={IconUser}>
|
<AccordionItemFor value="access" icon={IconUser}>
|
||||||
|
|||||||
@@ -2,8 +2,9 @@
|
|||||||
|
|
||||||
import { IconLayoutBoard } from "@tabler/icons-react";
|
import { IconLayoutBoard } from "@tabler/icons-react";
|
||||||
|
|
||||||
|
import { useRequiredBoard } from "@homarr/boards/context";
|
||||||
|
|
||||||
import { HeaderButton } from "~/components/layout/header/button";
|
import { HeaderButton } from "~/components/layout/header/button";
|
||||||
import { useRequiredBoard } from "./(content)/_context";
|
|
||||||
|
|
||||||
export const BoardOtherHeaderActions = () => {
|
export const BoardOtherHeaderActions = () => {
|
||||||
const board = useRequiredBoard();
|
const board = useRequiredBoard();
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
import type { JSX, PropsWithChildren } from "react";
|
import type { JSX, PropsWithChildren } from "react";
|
||||||
import { notFound } from "next/navigation";
|
import { notFound, redirect } from "next/navigation";
|
||||||
import { AppShellMain } from "@mantine/core";
|
import { AppShellMain } from "@mantine/core";
|
||||||
import { TRPCError } from "@trpc/server";
|
import { TRPCError } from "@trpc/server";
|
||||||
|
|
||||||
|
import { auth } from "@homarr/auth/next";
|
||||||
|
import { BoardProvider } from "@homarr/boards/context";
|
||||||
|
import { EditModeProvider } from "@homarr/boards/edit-mode";
|
||||||
import { logger } from "@homarr/log";
|
import { logger } from "@homarr/log";
|
||||||
|
|
||||||
import { MainHeader } from "~/components/layout/header";
|
import { MainHeader } from "~/components/layout/header";
|
||||||
@@ -10,9 +13,9 @@ import { BoardLogoWithTitle } from "~/components/layout/logo/board-logo";
|
|||||||
import { ClientShell } from "~/components/layout/shell";
|
import { ClientShell } from "~/components/layout/shell";
|
||||||
import { getCurrentColorSchemeAsync } from "~/theme/color-scheme";
|
import { getCurrentColorSchemeAsync } from "~/theme/color-scheme";
|
||||||
import type { Board } from "./_types";
|
import type { Board } from "./_types";
|
||||||
import { BoardProvider } from "./(content)/_context";
|
|
||||||
import type { Params } from "./(content)/_creator";
|
import type { Params } from "./(content)/_creator";
|
||||||
import { CustomCss } from "./(content)/_custom-css";
|
import { CustomCss } from "./(content)/_custom-css";
|
||||||
|
import { BoardReadyProvider } from "./(content)/_ready-context";
|
||||||
import { BoardMantineProvider } from "./(content)/_theme";
|
import { BoardMantineProvider } from "./(content)/_theme";
|
||||||
|
|
||||||
interface CreateBoardLayoutProps<TParams extends Params> {
|
interface CreateBoardLayoutProps<TParams extends Params> {
|
||||||
@@ -30,8 +33,14 @@ export const createBoardLayout = <TParams extends Params>({
|
|||||||
}: PropsWithChildren<{
|
}: PropsWithChildren<{
|
||||||
params: Promise<TParams>;
|
params: Promise<TParams>;
|
||||||
}>) => {
|
}>) => {
|
||||||
|
const session = await auth();
|
||||||
const initialBoard = await getInitialBoard(await params).catch((error) => {
|
const initialBoard = await getInitialBoard(await params).catch((error) => {
|
||||||
if (error instanceof TRPCError && error.code === "NOT_FOUND") {
|
if (error instanceof TRPCError && error.code === "NOT_FOUND") {
|
||||||
|
if (!session) {
|
||||||
|
logger.debug("No home board found for anonymous user, redirecting to login");
|
||||||
|
redirect("/auth/login");
|
||||||
|
}
|
||||||
|
|
||||||
logger.warn(error);
|
logger.warn(error);
|
||||||
notFound();
|
notFound();
|
||||||
}
|
}
|
||||||
@@ -42,17 +51,21 @@ export const createBoardLayout = <TParams extends Params>({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<BoardProvider initialBoard={initialBoard}>
|
<BoardProvider initialBoard={initialBoard}>
|
||||||
<BoardMantineProvider defaultColorScheme={colorScheme}>
|
<BoardReadyProvider>
|
||||||
<CustomCss />
|
<EditModeProvider>
|
||||||
<ClientShell hasNavigation={false}>
|
<BoardMantineProvider defaultColorScheme={colorScheme}>
|
||||||
<MainHeader
|
<CustomCss />
|
||||||
logo={<BoardLogoWithTitle size="md" hideTitleOnMobile />}
|
<ClientShell hasNavigation={false}>
|
||||||
actions={headerActions}
|
<MainHeader
|
||||||
hasNavigation={false}
|
logo={<BoardLogoWithTitle size="md" hideTitleOnMobile />}
|
||||||
/>
|
actions={headerActions}
|
||||||
<AppShellMain>{children}</AppShellMain>
|
hasNavigation={false}
|
||||||
</ClientShell>
|
/>
|
||||||
</BoardMantineProvider>
|
<AppShellMain>{children}</AppShellMain>
|
||||||
|
</ClientShell>
|
||||||
|
</BoardMantineProvider>
|
||||||
|
</EditModeProvider>
|
||||||
|
</BoardReadyProvider>
|
||||||
</BoardProvider>
|
</BoardProvider>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -3,10 +3,14 @@ import type { WidgetKind } from "@homarr/definitions";
|
|||||||
|
|
||||||
export type Board = RouterOutputs["board"]["getHomeBoard"];
|
export type Board = RouterOutputs["board"]["getHomeBoard"];
|
||||||
export type Section = Board["sections"][number];
|
export type Section = Board["sections"][number];
|
||||||
export type Item = Section["items"][number];
|
export type Item = Board["items"][number];
|
||||||
|
export type ItemLayout = Item["layouts"][number];
|
||||||
|
export type SectionItem = Omit<Item, "layouts"> & ItemLayout & { type: "item" };
|
||||||
|
|
||||||
export type CategorySection = Extract<Section, { kind: "category" }>;
|
export type CategorySection = Extract<Section, { kind: "category" }>;
|
||||||
export type EmptySection = Extract<Section, { kind: "empty" }>;
|
export type EmptySection = Extract<Section, { kind: "empty" }>;
|
||||||
export type DynamicSection = Extract<Section, { kind: "dynamic" }>;
|
export type DynamicSection = Extract<Section, { kind: "dynamic" }>;
|
||||||
|
export type DynamicSectionLayout = DynamicSection["layouts"][number];
|
||||||
|
export type DynamicSectionItem = Omit<DynamicSection, "layouts"> & DynamicSectionLayout & { type: "section" };
|
||||||
|
|
||||||
export type ItemOfKind<TKind extends WidgetKind> = Extract<Item, { kind: TKind }>;
|
export type ItemOfKind<TKind extends WidgetKind> = Extract<Item, { kind: TKind }>;
|
||||||
@@ -2,12 +2,12 @@
|
|||||||
|
|
||||||
import { Button, Card, Stack, TextInput } from "@mantine/core";
|
import { Button, Card, Stack, TextInput } from "@mantine/core";
|
||||||
import { IconArrowRight } from "@tabler/icons-react";
|
import { IconArrowRight } from "@tabler/icons-react";
|
||||||
|
import type { z } from "zod";
|
||||||
|
|
||||||
import { clientApi } from "@homarr/api/client";
|
import { clientApi } from "@homarr/api/client";
|
||||||
import { revalidatePathActionAsync } from "@homarr/common/client";
|
import { revalidatePathActionAsync } from "@homarr/common/client";
|
||||||
import { useZodForm } from "@homarr/form";
|
import { useZodForm } from "@homarr/form";
|
||||||
import { useI18n } from "@homarr/translation/client";
|
import { useI18n } from "@homarr/translation/client";
|
||||||
import type { z } from "@homarr/validation";
|
|
||||||
import { validation } from "@homarr/validation";
|
import { validation } from "@homarr/validation";
|
||||||
|
|
||||||
export const InitGroup = () => {
|
export const InitGroup = () => {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import { startTransition } from "react";
|
import { startTransition } from "react";
|
||||||
import { Button, Card, Group, Stack, Switch, Text } from "@mantine/core";
|
import { Button, Card, Group, Stack, Switch, Text } from "@mantine/core";
|
||||||
import { IconArrowRight } from "@tabler/icons-react";
|
import { IconArrowRight } from "@tabler/icons-react";
|
||||||
|
import type { z } from "zod";
|
||||||
|
|
||||||
import { clientApi } from "@homarr/api/client";
|
import { clientApi } from "@homarr/api/client";
|
||||||
import { revalidatePathActionAsync } from "@homarr/common/client";
|
import { revalidatePathActionAsync } from "@homarr/common/client";
|
||||||
@@ -11,7 +12,6 @@ import type { CheckboxProps } from "@homarr/form/types";
|
|||||||
import { defaultServerSettings } from "@homarr/server-settings";
|
import { defaultServerSettings } from "@homarr/server-settings";
|
||||||
import type { TranslationObject } from "@homarr/translation";
|
import type { TranslationObject } from "@homarr/translation";
|
||||||
import { useI18n, useScopedI18n } from "@homarr/translation/client";
|
import { useI18n, useScopedI18n } from "@homarr/translation/client";
|
||||||
import type { z } from "@homarr/validation";
|
|
||||||
import { validation } from "@homarr/validation";
|
import { validation } from "@homarr/validation";
|
||||||
|
|
||||||
export const InitSettings = () => {
|
export const InitSettings = () => {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { Button, PasswordInput, Stack, TextInput } from "@mantine/core";
|
import { Button, PasswordInput, Stack, TextInput } from "@mantine/core";
|
||||||
|
import type { z } from "zod";
|
||||||
|
|
||||||
import { clientApi } from "@homarr/api/client";
|
import { clientApi } from "@homarr/api/client";
|
||||||
import { revalidatePathActionAsync } from "@homarr/common/client";
|
import { revalidatePathActionAsync } from "@homarr/common/client";
|
||||||
@@ -8,7 +9,6 @@ import { useZodForm } from "@homarr/form";
|
|||||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||||
import { useScopedI18n } from "@homarr/translation/client";
|
import { useScopedI18n } from "@homarr/translation/client";
|
||||||
import { CustomPasswordInput } from "@homarr/ui";
|
import { CustomPasswordInput } from "@homarr/ui";
|
||||||
import type { z } from "@homarr/validation";
|
|
||||||
import { validation } from "@homarr/validation";
|
import { validation } from "@homarr/validation";
|
||||||
|
|
||||||
export const InitUserForm = () => {
|
export const InitUserForm = () => {
|
||||||
|
|||||||
@@ -9,10 +9,14 @@ import "~/styles/scroll-area.scss";
|
|||||||
import { notFound } from "next/navigation";
|
import { notFound } from "next/navigation";
|
||||||
import { NextIntlClientProvider } from "next-intl";
|
import { NextIntlClientProvider } from "next-intl";
|
||||||
|
|
||||||
|
import { api } from "@homarr/api/server";
|
||||||
import { env } from "@homarr/auth/env";
|
import { env } from "@homarr/auth/env";
|
||||||
import { auth } from "@homarr/auth/next";
|
import { auth } from "@homarr/auth/next";
|
||||||
|
import { db } from "@homarr/db";
|
||||||
|
import { getServerSettingsAsync } from "@homarr/db/queries";
|
||||||
import { ModalProvider } from "@homarr/modals";
|
import { ModalProvider } from "@homarr/modals";
|
||||||
import { Notifications } from "@homarr/notifications";
|
import { Notifications } from "@homarr/notifications";
|
||||||
|
import { SettingsProvider } from "@homarr/settings";
|
||||||
import { SpotlightProvider } from "@homarr/spotlight";
|
import { SpotlightProvider } from "@homarr/spotlight";
|
||||||
import type { SupportedLanguage } from "@homarr/translation";
|
import type { SupportedLanguage } from "@homarr/translation";
|
||||||
import { isLocaleRTL, isLocaleSupported } from "@homarr/translation";
|
import { isLocaleRTL, isLocaleSupported } from "@homarr/translation";
|
||||||
@@ -73,6 +77,8 @@ export default async function Layout(props: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const session = await auth();
|
const session = await auth();
|
||||||
|
const user = session ? await api.user.getById({ userId: session.user.id }).catch(() => null) : null;
|
||||||
|
const serverSettings = await getServerSettingsAsync(db);
|
||||||
const colorScheme = await getCurrentColorSchemeAsync();
|
const colorScheme = await getCurrentColorSchemeAsync();
|
||||||
const direction = isLocaleRTL((await props.params).locale) ? "rtl" : "ltr";
|
const direction = isLocaleRTL((await props.params).locale) ? "rtl" : "ltr";
|
||||||
const i18nMessages = await getI18nMessages();
|
const i18nMessages = await getI18nMessages();
|
||||||
@@ -81,6 +87,21 @@ export default async function Layout(props: {
|
|||||||
(innerProps) => {
|
(innerProps) => {
|
||||||
return <AuthProvider session={session} logoutUrl={env.AUTH_LOGOUT_REDIRECT_URL} {...innerProps} />;
|
return <AuthProvider session={session} logoutUrl={env.AUTH_LOGOUT_REDIRECT_URL} {...innerProps} />;
|
||||||
},
|
},
|
||||||
|
(innerProps) => (
|
||||||
|
<SettingsProvider
|
||||||
|
user={user}
|
||||||
|
serverSettings={{
|
||||||
|
board: {
|
||||||
|
homeBoardId: serverSettings.board.homeBoardId,
|
||||||
|
mobileHomeBoardId: serverSettings.board.mobileHomeBoardId,
|
||||||
|
enableStatusByDefault: serverSettings.board.enableStatusByDefault,
|
||||||
|
forceDisableStatus: serverSettings.board.forceDisableStatus,
|
||||||
|
},
|
||||||
|
search: { defaultSearchEngineId: serverSettings.search.defaultSearchEngineId },
|
||||||
|
}}
|
||||||
|
{...innerProps}
|
||||||
|
/>
|
||||||
|
),
|
||||||
(innerProps) => <JotaiProvider {...innerProps} />,
|
(innerProps) => <JotaiProvider {...innerProps} />,
|
||||||
(innerProps) => <TRPCReactProvider {...innerProps} />,
|
(innerProps) => <TRPCReactProvider {...innerProps} />,
|
||||||
(innerProps) => <DayJsLoader {...innerProps} />,
|
(innerProps) => <DayJsLoader {...innerProps} />,
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
.bannerContainer {
|
.bannerContainer {
|
||||||
border-radius: 8px;
|
border-radius: 16px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
@mixin dark {
|
@mixin dark {
|
||||||
background: linear-gradient(
|
background: linear-gradient(
|
||||||
|
|||||||
@@ -1,55 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import Link from "next/link";
|
|
||||||
import { Button, Group, Stack, Textarea, TextInput } from "@mantine/core";
|
|
||||||
|
|
||||||
import { useZodForm } 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";
|
|
||||||
|
|
||||||
import { IconPicker } from "~/components/icons/picker/icon-picker";
|
|
||||||
|
|
||||||
type FormType = z.infer<typeof validation.app.manage>;
|
|
||||||
|
|
||||||
interface AppFormProps {
|
|
||||||
submitButtonTranslation: (t: TranslationFunction) => string;
|
|
||||||
initialValues?: FormType;
|
|
||||||
handleSubmit: (values: FormType) => void;
|
|
||||||
isPending: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const AppForm = (props: AppFormProps) => {
|
|
||||||
const { submitButtonTranslation, handleSubmit, initialValues, isPending } = props;
|
|
||||||
const t = useI18n();
|
|
||||||
|
|
||||||
const form = useZodForm(validation.app.manage, {
|
|
||||||
initialValues: initialValues ?? {
|
|
||||||
name: "",
|
|
||||||
description: "",
|
|
||||||
iconUrl: "",
|
|
||||||
href: "",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<form onSubmit={form.onSubmit(handleSubmit)}>
|
|
||||||
<Stack>
|
|
||||||
<TextInput {...form.getInputProps("name")} withAsterisk label={t("app.field.name.label")} />
|
|
||||||
<IconPicker initialValue={initialValues?.iconUrl} {...form.getInputProps("iconUrl")} />
|
|
||||||
<Textarea {...form.getInputProps("description")} label={t("app.field.description.label")} />
|
|
||||||
<TextInput {...form.getInputProps("href")} label={t("app.field.url.label")} />
|
|
||||||
|
|
||||||
<Group justify="end">
|
|
||||||
<Button variant="default" component={Link} href="/manage/apps">
|
|
||||||
{t("common.action.backToOverview")}
|
|
||||||
</Button>
|
|
||||||
<Button type="submit" loading={isPending}>
|
|
||||||
{submitButtonTranslation(t)}
|
|
||||||
</Button>
|
|
||||||
</Group>
|
|
||||||
</Stack>
|
|
||||||
</form>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -2,30 +2,30 @@
|
|||||||
|
|
||||||
import { useCallback } from "react";
|
import { useCallback } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
|
import type { z } from "zod";
|
||||||
|
|
||||||
import type { RouterOutputs } from "@homarr/api";
|
import type { RouterOutputs } from "@homarr/api";
|
||||||
import { clientApi } from "@homarr/api/client";
|
import { clientApi } from "@homarr/api/client";
|
||||||
import { revalidatePathActionAsync } from "@homarr/common/client";
|
import { revalidatePathActionAsync } from "@homarr/common/client";
|
||||||
|
import { AppForm } from "@homarr/forms-collection";
|
||||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||||
import type { TranslationFunction } from "@homarr/translation";
|
import { useI18n, useScopedI18n } from "@homarr/translation/client";
|
||||||
import { useScopedI18n } from "@homarr/translation/client";
|
import type { validation } from "@homarr/validation";
|
||||||
import type { validation, z } from "@homarr/validation";
|
|
||||||
|
|
||||||
import { AppForm } from "../../_form";
|
|
||||||
|
|
||||||
interface AppEditFormProps {
|
interface AppEditFormProps {
|
||||||
app: RouterOutputs["app"]["byId"];
|
app: RouterOutputs["app"]["byId"];
|
||||||
}
|
}
|
||||||
|
|
||||||
export const AppEditForm = ({ app }: AppEditFormProps) => {
|
export const AppEditForm = ({ app }: AppEditFormProps) => {
|
||||||
const t = useScopedI18n("app.page.edit.notification");
|
const tScoped = useScopedI18n("app.page.edit.notification");
|
||||||
|
const t = useI18n();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
const { mutate, isPending } = clientApi.app.update.useMutation({
|
const { mutate, isPending } = clientApi.app.update.useMutation({
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
showSuccessNotification({
|
showSuccessNotification({
|
||||||
title: t("success.title"),
|
title: tScoped("success.title"),
|
||||||
message: t("success.message"),
|
message: tScoped("success.message"),
|
||||||
});
|
});
|
||||||
void revalidatePathActionAsync("/manage/apps").then(() => {
|
void revalidatePathActionAsync("/manage/apps").then(() => {
|
||||||
router.push("/manage/apps");
|
router.push("/manage/apps");
|
||||||
@@ -33,8 +33,8 @@ export const AppEditForm = ({ app }: AppEditFormProps) => {
|
|||||||
},
|
},
|
||||||
onError: () => {
|
onError: () => {
|
||||||
showErrorNotification({
|
showErrorNotification({
|
||||||
title: t("error.title"),
|
title: tScoped("error.title"),
|
||||||
message: t("error.message"),
|
message: tScoped("error.message"),
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -49,14 +49,15 @@ export const AppEditForm = ({ app }: AppEditFormProps) => {
|
|||||||
[mutate, app.id],
|
[mutate, app.id],
|
||||||
);
|
);
|
||||||
|
|
||||||
const submitButtonTranslation = useCallback((t: TranslationFunction) => t("common.action.save"), []);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AppForm
|
<AppForm
|
||||||
submitButtonTranslation={submitButtonTranslation}
|
buttonLabels={{
|
||||||
|
submit: t("common.action.save"),
|
||||||
|
}}
|
||||||
initialValues={app}
|
initialValues={app}
|
||||||
handleSubmit={handleSubmit}
|
handleSubmit={handleSubmit}
|
||||||
isPending={isPending}
|
isPending={isPending}
|
||||||
|
showBackToOverview
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useCallback } from "react";
|
|
||||||
import { useRouter } from "next/navigation";
|
|
||||||
|
|
||||||
import { clientApi } from "@homarr/api/client";
|
|
||||||
import { revalidatePathActionAsync } from "@homarr/common/client";
|
|
||||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
|
||||||
import type { TranslationFunction } from "@homarr/translation";
|
|
||||||
import { useScopedI18n } from "@homarr/translation/client";
|
|
||||||
import type { validation, z } from "@homarr/validation";
|
|
||||||
|
|
||||||
import { AppForm } from "../_form";
|
|
||||||
|
|
||||||
export const AppNewForm = () => {
|
|
||||||
const t = useScopedI18n("app.page.create.notification");
|
|
||||||
const router = useRouter();
|
|
||||||
|
|
||||||
const { mutate, isPending } = clientApi.app.create.useMutation({
|
|
||||||
onSuccess: () => {
|
|
||||||
showSuccessNotification({
|
|
||||||
title: t("success.title"),
|
|
||||||
message: t("success.message"),
|
|
||||||
});
|
|
||||||
void revalidatePathActionAsync("/manage/apps").then(() => {
|
|
||||||
router.push("/manage/apps");
|
|
||||||
});
|
|
||||||
},
|
|
||||||
onError: () => {
|
|
||||||
showErrorNotification({
|
|
||||||
title: t("error.title"),
|
|
||||||
message: t("error.message"),
|
|
||||||
});
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const handleSubmit = useCallback(
|
|
||||||
(values: z.infer<typeof validation.app.manage>) => {
|
|
||||||
mutate(values);
|
|
||||||
},
|
|
||||||
[mutate],
|
|
||||||
);
|
|
||||||
|
|
||||||
const submitButtonTranslation = useCallback((t: TranslationFunction) => t("common.action.create"), []);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<AppForm submitButtonTranslation={submitButtonTranslation} handleSubmit={handleSubmit} isPending={isPending} />
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -2,10 +2,10 @@ import { notFound } from "next/navigation";
|
|||||||
import { Container, Stack, Title } from "@mantine/core";
|
import { Container, Stack, Title } from "@mantine/core";
|
||||||
|
|
||||||
import { auth } from "@homarr/auth/next";
|
import { auth } from "@homarr/auth/next";
|
||||||
|
import { AppNewForm } from "@homarr/forms-collection";
|
||||||
import { getI18n } from "@homarr/translation/server";
|
import { getI18n } from "@homarr/translation/server";
|
||||||
|
|
||||||
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
|
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
|
||||||
import { AppNewForm } from "./_app-new-form";
|
|
||||||
|
|
||||||
export default async function AppNewPage() {
|
export default async function AppNewPage() {
|
||||||
const session = await auth();
|
const session = await auth();
|
||||||
@@ -22,7 +22,7 @@ export default async function AppNewPage() {
|
|||||||
<Container>
|
<Container>
|
||||||
<Stack>
|
<Stack>
|
||||||
<Title>{t("app.page.create.title")}</Title>
|
<Title>{t("app.page.create.title")}</Title>
|
||||||
<AppNewForm />
|
<AppNewForm showBackToOverview showCreateAnother />
|
||||||
</Stack>
|
</Stack>
|
||||||
</Container>
|
</Container>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import Link from "next/link";
|
|||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { ActionIcon, ActionIconGroup, Anchor, Avatar, Card, Group, Stack, Text, Title } from "@mantine/core";
|
import { ActionIcon, ActionIconGroup, Anchor, Avatar, Card, Group, Stack, Text, Title } from "@mantine/core";
|
||||||
import { IconBox, IconPencil } from "@tabler/icons-react";
|
import { IconBox, IconPencil } from "@tabler/icons-react";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
import type { RouterOutputs } from "@homarr/api";
|
import type { RouterOutputs } from "@homarr/api";
|
||||||
import { api } from "@homarr/api/server";
|
import { api } from "@homarr/api/server";
|
||||||
@@ -9,7 +10,6 @@ import { auth } from "@homarr/auth/next";
|
|||||||
import type { inferSearchParamsFromSchema } from "@homarr/common/types";
|
import type { inferSearchParamsFromSchema } from "@homarr/common/types";
|
||||||
import { getI18n, getScopedI18n } from "@homarr/translation/server";
|
import { getI18n, getScopedI18n } from "@homarr/translation/server";
|
||||||
import { SearchInput, TablePagination } from "@homarr/ui";
|
import { SearchInput, TablePagination } from "@homarr/ui";
|
||||||
import { z } from "@homarr/validation";
|
|
||||||
|
|
||||||
import { ManageContainer } from "~/components/manage/manage-container";
|
import { ManageContainer } from "~/components/manage/manage-container";
|
||||||
import { MobileAffixButton } from "~/components/manage/mobile-affix-button";
|
import { MobileAffixButton } from "~/components/manage/mobile-affix-button";
|
||||||
@@ -45,7 +45,7 @@ export default async function AppsPage(props: AppsPageProps) {
|
|||||||
<Stack>
|
<Stack>
|
||||||
<Title>{t("page.list.title")}</Title>
|
<Title>{t("page.list.title")}</Title>
|
||||||
<Group justify="space-between" align="center">
|
<Group justify="space-between" align="center">
|
||||||
<SearchInput placeholder={`${t("search")}...`} defaultValue={searchParams.search} />
|
<SearchInput placeholder={`${t("search")}...`} defaultValue={searchParams.search} flexExpand />
|
||||||
{session.user.permissions.includes("app-create") && (
|
{session.user.permissions.includes("app-create") && (
|
||||||
<MobileAffixButton component={Link} href="/manage/apps/new">
|
<MobileAffixButton component={Link} href="/manage/apps/new">
|
||||||
{t("page.create.title")}
|
{t("page.create.title")}
|
||||||
@@ -80,7 +80,7 @@ const AppCard = async ({ app }: AppCardProps) => {
|
|||||||
return (
|
return (
|
||||||
<Card withBorder>
|
<Card withBorder>
|
||||||
<Group justify="space-between" wrap="nowrap">
|
<Group justify="space-between" wrap="nowrap">
|
||||||
<Group align="top" justify="start" wrap="nowrap">
|
<Group align="top" justify="start" wrap="nowrap" style={{ flex: "1" }}>
|
||||||
<Avatar
|
<Avatar
|
||||||
size="sm"
|
size="sm"
|
||||||
src={app.iconUrl}
|
src={app.iconUrl}
|
||||||
@@ -91,7 +91,7 @@ const AppCard = async ({ app }: AppCardProps) => {
|
|||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Stack gap={0}>
|
<Stack gap={0} style={{ flex: "1" }}>
|
||||||
<Text fw={500} lineClamp={1}>
|
<Text fw={500} lineClamp={1}>
|
||||||
{app.name}
|
{app.name}
|
||||||
</Text>
|
</Text>
|
||||||
@@ -101,7 +101,7 @@ const AppCard = async ({ app }: AppCardProps) => {
|
|||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
{app.href && (
|
{app.href && (
|
||||||
<Anchor href={app.href} lineClamp={1} size="sm" w="min-content">
|
<Anchor href={app.href} lineClamp={1} size="sm" style={{ wordBreak: "break-all" }}>
|
||||||
{app.href}
|
{app.href}
|
||||||
</Anchor>
|
</Anchor>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ const BoardCard = async ({ board }: BoardCardProps) => {
|
|||||||
const VisibilityIcon = board.isPublic ? IconWorld : IconLock;
|
const VisibilityIcon = board.isPublic ? IconWorld : IconLock;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card withBorder>
|
<Card radius="lg" withBorder>
|
||||||
<CardSection p="sm" withBorder>
|
<CardSection p="sm" withBorder>
|
||||||
<Group justify="space-between" align="center">
|
<Group justify="space-between" align="center">
|
||||||
<Group gap="sm">
|
<Group gap="sm">
|
||||||
@@ -106,15 +106,25 @@ const BoardCard = async ({ board }: BoardCardProps) => {
|
|||||||
</Group>
|
</Group>
|
||||||
</CardSection>
|
</CardSection>
|
||||||
|
|
||||||
<CardSection p="sm">
|
<CardSection>
|
||||||
<Group wrap="nowrap">
|
<Group gap={0} wrap="nowrap">
|
||||||
<Button component={Link} href={`/boards/${board.name}`} variant="default" fullWidth>
|
<Button
|
||||||
|
style={{ border: "none", borderRadius: 0 }}
|
||||||
|
component={Link}
|
||||||
|
href={`/boards/${board.name}`}
|
||||||
|
variant="default"
|
||||||
|
fullWidth
|
||||||
|
>
|
||||||
{t("action.open.label")}
|
{t("action.open.label")}
|
||||||
</Button>
|
</Button>
|
||||||
{isMenuVisible && (
|
{isMenuVisible && (
|
||||||
<Menu position="bottom-end">
|
<Menu position="bottom-end">
|
||||||
<MenuTarget>
|
<MenuTarget>
|
||||||
<ActionIcon variant="default" size="lg">
|
<ActionIcon
|
||||||
|
style={{ borderTop: "none", borderBottom: "none", borderRight: "none", borderRadius: 0 }}
|
||||||
|
variant="default"
|
||||||
|
size="lg"
|
||||||
|
>
|
||||||
<IconDotsVertical size={16} stroke={1.5} />
|
<IconDotsVertical size={16} stroke={1.5} />
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
</MenuTarget>
|
</MenuTarget>
|
||||||
|
|||||||
+4
-4
@@ -3,6 +3,7 @@
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { Button, Fieldset, Group, Stack, TextInput } from "@mantine/core";
|
import { Button, Fieldset, Group, Stack, TextInput } from "@mantine/core";
|
||||||
|
import type { z } from "zod";
|
||||||
|
|
||||||
import type { RouterOutputs } from "@homarr/api";
|
import type { RouterOutputs } from "@homarr/api";
|
||||||
import { clientApi } from "@homarr/api/client";
|
import { clientApi } from "@homarr/api/client";
|
||||||
@@ -13,7 +14,6 @@ import { convertIntegrationTestConnectionError } from "@homarr/integrations/clie
|
|||||||
import { useConfirmModal } from "@homarr/modals";
|
import { useConfirmModal } from "@homarr/modals";
|
||||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||||
import { useI18n } from "@homarr/translation/client";
|
import { useI18n } from "@homarr/translation/client";
|
||||||
import type { z } from "@homarr/validation";
|
|
||||||
import { validation } from "@homarr/validation";
|
import { validation } from "@homarr/validation";
|
||||||
|
|
||||||
import { SecretCard } from "../../_components/secrets/integration-secret-card";
|
import { SecretCard } from "../../_components/secrets/integration-secret-card";
|
||||||
@@ -70,9 +70,9 @@ export const EditIntegrationForm = ({ integration }: EditIntegrationForm) => {
|
|||||||
if (testConnectionError) {
|
if (testConnectionError) {
|
||||||
showErrorNotification({
|
showErrorNotification({
|
||||||
title: t(`integration.testConnection.notification.${testConnectionError.key}.title`),
|
title: t(`integration.testConnection.notification.${testConnectionError.key}.title`),
|
||||||
message: testConnectionError.message
|
message:
|
||||||
? testConnectionError.message
|
testConnectionError.message ??
|
||||||
: t(`integration.testConnection.notification.${testConnectionError.key}.message`),
|
t(`integration.testConnection.notification.${testConnectionError.key}.message`),
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ export const IntegrationCreateDropdownContent = () => {
|
|||||||
value={search}
|
value={search}
|
||||||
data-autofocus
|
data-autofocus
|
||||||
onChange={handleSearch}
|
onChange={handleSearch}
|
||||||
|
variant="filled"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{filteredKinds.length > 0 ? (
|
{filteredKinds.length > 0 ? (
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import Link from "next/link";
|
|||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { Alert, Button, Checkbox, Fieldset, Group, SegmentedControl, Stack, Text, TextInput } from "@mantine/core";
|
import { Alert, Button, Checkbox, Fieldset, Group, SegmentedControl, Stack, Text, TextInput } from "@mantine/core";
|
||||||
import { IconInfoCircle } from "@tabler/icons-react";
|
import { IconInfoCircle } from "@tabler/icons-react";
|
||||||
|
import type { z } from "zod";
|
||||||
|
|
||||||
import { clientApi } from "@homarr/api/client";
|
import { clientApi } from "@homarr/api/client";
|
||||||
import { revalidatePathActionAsync } from "@homarr/common/client";
|
import { revalidatePathActionAsync } from "@homarr/common/client";
|
||||||
@@ -15,7 +16,6 @@ import { useZodForm } from "@homarr/form";
|
|||||||
import { convertIntegrationTestConnectionError } from "@homarr/integrations/client";
|
import { convertIntegrationTestConnectionError } from "@homarr/integrations/client";
|
||||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||||
import { useI18n, useScopedI18n } from "@homarr/translation/client";
|
import { useI18n, useScopedI18n } from "@homarr/translation/client";
|
||||||
import type { z } from "@homarr/validation";
|
|
||||||
import { validation } from "@homarr/validation";
|
import { validation } from "@homarr/validation";
|
||||||
|
|
||||||
import { IntegrationSecretInput } from "../_components/secrets/integration-secret-inputs";
|
import { IntegrationSecretInput } from "../_components/secrets/integration-secret-inputs";
|
||||||
@@ -63,9 +63,9 @@ export const NewIntegrationForm = ({ searchParams }: NewIntegrationFormProps) =>
|
|||||||
if (testConnectionError) {
|
if (testConnectionError) {
|
||||||
showErrorNotification({
|
showErrorNotification({
|
||||||
title: t(`integration.testConnection.notification.${testConnectionError.key}.title`),
|
title: t(`integration.testConnection.notification.${testConnectionError.key}.title`),
|
||||||
message: testConnectionError.message
|
message:
|
||||||
? testConnectionError.message
|
testConnectionError.message ??
|
||||||
: t(`integration.testConnection.notification.${testConnectionError.key}.message`),
|
t(`integration.testConnection.notification.${testConnectionError.key}.message`),
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { notFound } from "next/navigation";
|
import { notFound } from "next/navigation";
|
||||||
import { Container, Group, Stack, Title } from "@mantine/core";
|
import { Container, Group, Stack, Title } from "@mantine/core";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
import { auth } from "@homarr/auth/next";
|
import { auth } from "@homarr/auth/next";
|
||||||
import type { IntegrationKind } from "@homarr/definitions";
|
import type { IntegrationKind } from "@homarr/definitions";
|
||||||
@@ -7,7 +8,6 @@ import { getIntegrationName, integrationKinds } from "@homarr/definitions";
|
|||||||
import { getScopedI18n } from "@homarr/translation/server";
|
import { getScopedI18n } from "@homarr/translation/server";
|
||||||
import { IntegrationAvatar } from "@homarr/ui";
|
import { IntegrationAvatar } from "@homarr/ui";
|
||||||
import type { validation } from "@homarr/validation";
|
import type { validation } from "@homarr/validation";
|
||||||
import { z } from "@homarr/validation";
|
|
||||||
|
|
||||||
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
|
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
|
||||||
import { NewIntegrationForm } from "./_integration-new-form";
|
import { NewIntegrationForm } from "./_integration-new-form";
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
.root {
|
||||||
|
border-radius: var(--mantine-radius-lg);
|
||||||
|
background-color: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-6));
|
||||||
|
}
|
||||||
|
|
||||||
|
.item {
|
||||||
|
background-color: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-6));
|
||||||
|
border: 1px solid transparent;
|
||||||
|
position: relative;
|
||||||
|
z-index: 0;
|
||||||
|
transition: transform 150ms ease;
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
&[data-first="true"] {
|
||||||
|
border-radius: var(--mantine-radius-lg) var(--mantine-radius-lg) 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&[data-last="true"] {
|
||||||
|
border-radius: 0 0 var(--mantine-radius-lg) var(--mantine-radius-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
&[data-active] {
|
||||||
|
transform: scale(1.01);
|
||||||
|
z-index: 1;
|
||||||
|
background-color: var(--mantine-color-body);
|
||||||
|
border-color: light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-4));
|
||||||
|
box-shadow: var(--mantine-shadow-md);
|
||||||
|
border-radius: var(--mantine-radius-lg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.chevron {
|
||||||
|
&[data-rotate] {
|
||||||
|
transform: rotate(180deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -44,6 +44,7 @@ import { NoResults } from "~/components/no-results";
|
|||||||
import { ActiveTabAccordion } from "../../../../components/active-tab-accordion";
|
import { ActiveTabAccordion } from "../../../../components/active-tab-accordion";
|
||||||
import { DeleteIntegrationActionButton } from "./_integration-buttons";
|
import { DeleteIntegrationActionButton } from "./_integration-buttons";
|
||||||
import { IntegrationCreateDropdownContent } from "./new/_integration-new-dropdown";
|
import { IntegrationCreateDropdownContent } from "./new/_integration-new-dropdown";
|
||||||
|
import classes from "./page.module.css";
|
||||||
|
|
||||||
interface IntegrationsPageProps {
|
interface IntegrationsPageProps {
|
||||||
searchParams: Promise<{
|
searchParams: Promise<{
|
||||||
@@ -133,7 +134,7 @@ const IntegrationList = async ({ integrations, activeTab }: IntegrationListProps
|
|||||||
return <NoResults icon={IconPlugX} title={t("page.list.noResults.title")} />;
|
return <NoResults icon={IconPlugX} title={t("page.list.noResults.title")} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
const grouppedIntegrations = integrations.reduce(
|
const groupedIntegrations = integrations.reduce(
|
||||||
(acc, integration) => {
|
(acc, integration) => {
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
||||||
if (!acc[integration.kind]) {
|
if (!acc[integration.kind]) {
|
||||||
@@ -147,11 +148,13 @@ const IntegrationList = async ({ integrations, activeTab }: IntegrationListProps
|
|||||||
{} as Record<IntegrationKind, RouterOutputs["integration"]["all"]>,
|
{} as Record<IntegrationKind, RouterOutputs["integration"]["all"]>,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const entries = objectEntries(groupedIntegrations);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ActiveTabAccordion defaultValue={activeTab} variant="separated">
|
<ActiveTabAccordion defaultValue={activeTab} radius="lg" classNames={classes}>
|
||||||
{objectEntries(grouppedIntegrations).map(([kind, integrations]) => (
|
{entries.map(([kind, integrations], index) => (
|
||||||
<AccordionItem key={kind} value={kind}>
|
<AccordionItem key={kind} value={kind} data-first={index === 0} data-last={index === entries.length - 1}>
|
||||||
<AccordionControl icon={<IntegrationAvatar size="sm" kind={kind} />}>
|
<AccordionControl icon={<IntegrationAvatar size="sm" kind={kind} radius="sm" />}>
|
||||||
<Group>
|
<Group>
|
||||||
<Text>{getIntegrationName(kind)}</Text>
|
<Text>{getIntegrationName(kind)}</Text>
|
||||||
<CountBadge count={integrations.length} />
|
<CountBadge count={integrations.length} />
|
||||||
|
|||||||
@@ -1,26 +1,26 @@
|
|||||||
import type { PropsWithChildren } from "react";
|
import type { PropsWithChildren } from "react";
|
||||||
import { AppShellMain } from "@mantine/core";
|
import { AppShellMain } from "@mantine/core";
|
||||||
import {
|
import {
|
||||||
|
IconAffiliateFilled,
|
||||||
IconBook2,
|
IconBook2,
|
||||||
IconBox,
|
IconBox,
|
||||||
IconBrandDiscord,
|
IconBrandDiscord,
|
||||||
IconBrandDocker,
|
IconBrandDocker,
|
||||||
IconBrandGithub,
|
IconBrandGithub,
|
||||||
|
IconBrandTablerFilled,
|
||||||
IconCertificate,
|
IconCertificate,
|
||||||
|
IconClipboardListFilled,
|
||||||
|
IconDirectionsFilled,
|
||||||
IconGitFork,
|
IconGitFork,
|
||||||
IconHome,
|
IconHelpSquareRoundedFilled,
|
||||||
IconInfoSmall,
|
IconHomeFilled,
|
||||||
IconLayoutDashboard,
|
IconLayoutDashboardFilled,
|
||||||
IconLogs,
|
|
||||||
IconMailForward,
|
IconMailForward,
|
||||||
IconPhoto,
|
IconPhotoFilled,
|
||||||
IconPlug,
|
IconPointerFilled,
|
||||||
IconQuestionMark,
|
|
||||||
IconReport,
|
|
||||||
IconSearch,
|
IconSearch,
|
||||||
IconSettings,
|
IconSettingsFilled,
|
||||||
IconTool,
|
IconUserFilled,
|
||||||
IconUser,
|
|
||||||
IconUsers,
|
IconUsers,
|
||||||
IconUsersGroup,
|
IconUsersGroup,
|
||||||
} from "@tabler/icons-react";
|
} from "@tabler/icons-react";
|
||||||
@@ -31,6 +31,7 @@ import { createDocumentationLink } from "@homarr/definitions";
|
|||||||
import { getScopedI18n } from "@homarr/translation/server";
|
import { getScopedI18n } from "@homarr/translation/server";
|
||||||
|
|
||||||
import { MainHeader } from "~/components/layout/header";
|
import { MainHeader } from "~/components/layout/header";
|
||||||
|
import { homarrLogoPath } from "~/components/layout/logo/homarr-logo";
|
||||||
import type { NavigationLink } from "~/components/layout/navigation";
|
import type { NavigationLink } from "~/components/layout/navigation";
|
||||||
import { MainNavigation } from "~/components/layout/navigation";
|
import { MainNavigation } from "~/components/layout/navigation";
|
||||||
import { ClientShell } from "~/components/layout/shell";
|
import { ClientShell } from "~/components/layout/shell";
|
||||||
@@ -41,11 +42,11 @@ export default async function ManageLayout({ children }: PropsWithChildren) {
|
|||||||
const navigationLinks: NavigationLink[] = [
|
const navigationLinks: NavigationLink[] = [
|
||||||
{
|
{
|
||||||
label: t("items.home"),
|
label: t("items.home"),
|
||||||
icon: IconHome,
|
icon: IconHomeFilled,
|
||||||
href: "/manage",
|
href: "/manage",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: IconLayoutDashboard,
|
icon: IconLayoutDashboardFilled,
|
||||||
href: "/manage/boards",
|
href: "/manage/boards",
|
||||||
label: t("items.boards"),
|
label: t("items.boards"),
|
||||||
},
|
},
|
||||||
@@ -54,9 +55,12 @@ export default async function ManageLayout({ children }: PropsWithChildren) {
|
|||||||
href: "/manage/apps",
|
href: "/manage/apps",
|
||||||
label: t("items.apps"),
|
label: t("items.apps"),
|
||||||
hidden: !session,
|
hidden: !session,
|
||||||
|
iconProps: {
|
||||||
|
strokeWidth: 2.5,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: IconPlug,
|
icon: IconAffiliateFilled,
|
||||||
href: "/manage/integrations",
|
href: "/manage/integrations",
|
||||||
label: t("items.integrations"),
|
label: t("items.integrations"),
|
||||||
hidden: !session,
|
hidden: !session,
|
||||||
@@ -66,15 +70,18 @@ export default async function ManageLayout({ children }: PropsWithChildren) {
|
|||||||
href: "/manage/search-engines",
|
href: "/manage/search-engines",
|
||||||
label: t("items.searchEngies"),
|
label: t("items.searchEngies"),
|
||||||
hidden: !session,
|
hidden: !session,
|
||||||
|
iconProps: {
|
||||||
|
strokeWidth: 2.5,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: IconPhoto,
|
icon: IconPhotoFilled,
|
||||||
href: "/manage/medias",
|
href: "/manage/medias",
|
||||||
label: t("items.medias"),
|
label: t("items.medias"),
|
||||||
hidden: !session,
|
hidden: !session,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: IconUser,
|
icon: IconUserFilled,
|
||||||
label: t("items.users.label"),
|
label: t("items.users.label"),
|
||||||
hidden: !session?.user.permissions.includes("admin"),
|
hidden: !session?.user.permissions.includes("admin"),
|
||||||
items: [
|
items: [
|
||||||
@@ -98,7 +105,7 @@ export default async function ManageLayout({ children }: PropsWithChildren) {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: t("items.tools.label"),
|
label: t("items.tools.label"),
|
||||||
icon: IconTool,
|
icon: IconPointerFilled,
|
||||||
// As permissions always include there children permissions, we can check other-view-logs as admin includes it
|
// As permissions always include there children permissions, we can check other-view-logs as admin includes it
|
||||||
hidden: !session?.user.permissions.includes("other-view-logs"),
|
hidden: !session?.user.permissions.includes("other-view-logs"),
|
||||||
items: [
|
items: [
|
||||||
@@ -110,13 +117,13 @@ export default async function ManageLayout({ children }: PropsWithChildren) {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: t("items.tools.items.api"),
|
label: t("items.tools.items.api"),
|
||||||
icon: IconPlug,
|
icon: IconDirectionsFilled,
|
||||||
href: "/manage/tools/api",
|
href: "/manage/tools/api",
|
||||||
hidden: !session?.user.permissions.includes("admin"),
|
hidden: !session?.user.permissions.includes("admin"),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: t("items.tools.items.logs"),
|
label: t("items.tools.items.logs"),
|
||||||
icon: IconLogs,
|
icon: IconBrandTablerFilled,
|
||||||
href: "/manage/tools/logs",
|
href: "/manage/tools/logs",
|
||||||
hidden: !session?.user.permissions.includes("other-view-logs"),
|
hidden: !session?.user.permissions.includes("other-view-logs"),
|
||||||
},
|
},
|
||||||
@@ -128,7 +135,7 @@ export default async function ManageLayout({ children }: PropsWithChildren) {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: t("items.tools.items.tasks"),
|
label: t("items.tools.items.tasks"),
|
||||||
icon: IconReport,
|
icon: IconClipboardListFilled,
|
||||||
href: "/manage/tools/tasks",
|
href: "/manage/tools/tasks",
|
||||||
hidden: !session?.user.permissions.includes("admin"),
|
hidden: !session?.user.permissions.includes("admin"),
|
||||||
},
|
},
|
||||||
@@ -137,12 +144,12 @@ export default async function ManageLayout({ children }: PropsWithChildren) {
|
|||||||
{
|
{
|
||||||
label: t("items.settings"),
|
label: t("items.settings"),
|
||||||
href: "/manage/settings",
|
href: "/manage/settings",
|
||||||
icon: IconSettings,
|
icon: IconSettingsFilled,
|
||||||
hidden: !session?.user.permissions.includes("admin"),
|
hidden: !session?.user.permissions.includes("admin"),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: t("items.help.label"),
|
label: t("items.help.label"),
|
||||||
icon: IconQuestionMark,
|
icon: IconHelpSquareRoundedFilled,
|
||||||
items: [
|
items: [
|
||||||
{
|
{
|
||||||
label: t("items.help.items.documentation"),
|
label: t("items.help.items.documentation"),
|
||||||
@@ -172,7 +179,7 @@ export default async function ManageLayout({ children }: PropsWithChildren) {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: t("items.about"),
|
label: t("items.about"),
|
||||||
icon: IconInfoSmall,
|
icon: homarrLogoPath,
|
||||||
href: "/manage/about",
|
href: "/manage/about",
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -1,15 +1,11 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import type { JSX } from "react";
|
import { Button } from "@mantine/core";
|
||||||
import { Button, FileButton } from "@mantine/core";
|
|
||||||
import { IconUpload } from "@tabler/icons-react";
|
import { IconUpload } from "@tabler/icons-react";
|
||||||
|
|
||||||
import { clientApi } from "@homarr/api/client";
|
|
||||||
import { revalidatePathActionAsync } from "@homarr/common/client";
|
import { revalidatePathActionAsync } from "@homarr/common/client";
|
||||||
import type { MaybePromise } from "@homarr/common/types";
|
import { UploadMedia } from "@homarr/forms-collection";
|
||||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
|
||||||
import { useI18n } from "@homarr/translation/client";
|
import { useI18n } from "@homarr/translation/client";
|
||||||
import { supportedMediaUploadFormats } from "@homarr/validation";
|
|
||||||
|
|
||||||
export const UploadMediaButton = () => {
|
export const UploadMediaButton = () => {
|
||||||
const t = useI18n();
|
const t = useI18n();
|
||||||
@@ -27,45 +23,3 @@ export const UploadMediaButton = () => {
|
|||||||
</UploadMedia>
|
</UploadMedia>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
interface UploadMediaProps {
|
|
||||||
children: (props: { onClick: () => void; loading: boolean }) => JSX.Element;
|
|
||||||
onSettled?: () => MaybePromise<void>;
|
|
||||||
onSuccess?: (media: { id: string; url: string }) => MaybePromise<void>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const UploadMedia = ({ children, onSettled, onSuccess }: UploadMediaProps) => {
|
|
||||||
const t = useI18n();
|
|
||||||
const { mutateAsync, isPending } = clientApi.media.uploadMedia.useMutation();
|
|
||||||
|
|
||||||
const handleFileUploadAsync = async (file: File | null) => {
|
|
||||||
if (!file) return;
|
|
||||||
const formData = new FormData();
|
|
||||||
formData.append("file", file);
|
|
||||||
await mutateAsync(formData, {
|
|
||||||
async onSuccess(mediaId) {
|
|
||||||
showSuccessNotification({
|
|
||||||
message: t("media.action.upload.notification.success.message"),
|
|
||||||
});
|
|
||||||
await onSuccess?.({
|
|
||||||
id: mediaId,
|
|
||||||
url: `/api/user-medias/${mediaId}`,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
onError() {
|
|
||||||
showErrorNotification({
|
|
||||||
message: t("media.action.upload.notification.error.message"),
|
|
||||||
});
|
|
||||||
},
|
|
||||||
async onSettled() {
|
|
||||||
await onSettled?.();
|
|
||||||
},
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<FileButton onChange={handleFileUploadAsync} accept={supportedMediaUploadFormats.join(",")}>
|
|
||||||
{({ onClick }) => children({ onClick, loading: isPending })}
|
|
||||||
</FileButton>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,10 +1,10 @@
|
|||||||
import Image from "next/image";
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { notFound } from "next/navigation";
|
import { notFound } from "next/navigation";
|
||||||
import {
|
import {
|
||||||
ActionIcon,
|
ActionIcon,
|
||||||
Anchor,
|
Anchor,
|
||||||
Group,
|
Group,
|
||||||
|
Image,
|
||||||
Stack,
|
Stack,
|
||||||
Table,
|
Table,
|
||||||
TableTbody,
|
TableTbody,
|
||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
Tooltip,
|
Tooltip,
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import { IconExternalLink } from "@tabler/icons-react";
|
import { IconExternalLink } from "@tabler/icons-react";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
import type { RouterOutputs } from "@homarr/api";
|
import type { RouterOutputs } from "@homarr/api";
|
||||||
import { api } from "@homarr/api/server";
|
import { api } from "@homarr/api/server";
|
||||||
@@ -25,7 +26,6 @@ import type { inferSearchParamsFromSchema } from "@homarr/common/types";
|
|||||||
import { createLocalImageUrl } from "@homarr/icons/local";
|
import { createLocalImageUrl } from "@homarr/icons/local";
|
||||||
import { getI18n } from "@homarr/translation/server";
|
import { getI18n } from "@homarr/translation/server";
|
||||||
import { SearchInput, TablePagination, UserAvatar } from "@homarr/ui";
|
import { SearchInput, TablePagination, UserAvatar } from "@homarr/ui";
|
||||||
import { z } from "@homarr/validation";
|
|
||||||
|
|
||||||
import { ManageContainer } from "~/components/manage/manage-container";
|
import { ManageContainer } from "~/components/manage/manage-container";
|
||||||
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
|
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
|
||||||
@@ -61,7 +61,7 @@ export default async function GroupsListPage(props: MediaListPageProps) {
|
|||||||
const { items: medias, totalCount } = await api.media.getPaginated(searchParams);
|
const { items: medias, totalCount } = await api.media.getPaginated(searchParams);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ManageContainer size="xl">
|
<ManageContainer>
|
||||||
<DynamicBreadcrumb />
|
<DynamicBreadcrumb />
|
||||||
<Stack>
|
<Stack>
|
||||||
<Title>{t("media.plural")}</Title>
|
<Title>{t("media.plural")}</Title>
|
||||||
@@ -113,11 +113,12 @@ const Row = async ({ media }: RowProps) => {
|
|||||||
<TableTr>
|
<TableTr>
|
||||||
<TableTd w={64}>
|
<TableTd w={64}>
|
||||||
<Image
|
<Image
|
||||||
|
// Switched to mantine image because next/image doesn't support svgs
|
||||||
src={createLocalImageUrl(media.id)}
|
src={createLocalImageUrl(media.id)}
|
||||||
alt={media.name}
|
alt={media.name}
|
||||||
width={64}
|
w={64}
|
||||||
height={64}
|
h={64}
|
||||||
style={{ objectFit: "contain" }}
|
fit="contain"
|
||||||
/>
|
/>
|
||||||
</TableTd>
|
</TableTd>
|
||||||
<TableTd>{media.name}</TableTd>
|
<TableTd>{media.name}</TableTd>
|
||||||
|
|||||||
@@ -83,7 +83,7 @@ export default async function ManagementPage() {
|
|||||||
{links.map(
|
{links.map(
|
||||||
(link) =>
|
(link) =>
|
||||||
!link.hidden && (
|
!link.hidden && (
|
||||||
<Card component={Link} href={link.href} key={link.href} withBorder>
|
<Card component={Link} href={link.href} key={link.href} radius="lg">
|
||||||
<Group justify="space-between" wrap="nowrap">
|
<Group justify="space-between" wrap="nowrap">
|
||||||
<Group wrap="nowrap">
|
<Group wrap="nowrap">
|
||||||
<Text size="2.4rem" fw="bolder">
|
<Text size="2.4rem" fw="bolder">
|
||||||
|
|||||||
@@ -4,17 +4,16 @@ import Link from "next/link";
|
|||||||
import type { SegmentedControlItem } from "@mantine/core";
|
import type { SegmentedControlItem } from "@mantine/core";
|
||||||
import { Button, Fieldset, Grid, Group, SegmentedControl, Stack, Textarea, TextInput } from "@mantine/core";
|
import { Button, Fieldset, Grid, Group, SegmentedControl, Stack, Textarea, TextInput } from "@mantine/core";
|
||||||
import { WidgetIntegrationSelect } from "node_modules/@homarr/widgets/src/widget-integration-select";
|
import { WidgetIntegrationSelect } from "node_modules/@homarr/widgets/src/widget-integration-select";
|
||||||
|
import type { z } from "zod";
|
||||||
|
|
||||||
import { clientApi } from "@homarr/api/client";
|
import { clientApi } from "@homarr/api/client";
|
||||||
import { searchEngineTypes } from "@homarr/definitions";
|
import { searchEngineTypes } from "@homarr/definitions";
|
||||||
import { useZodForm } from "@homarr/form";
|
import { useZodForm } from "@homarr/form";
|
||||||
|
import { IconPicker } from "@homarr/forms-collection";
|
||||||
import type { TranslationFunction } from "@homarr/translation";
|
import type { TranslationFunction } from "@homarr/translation";
|
||||||
import { useI18n } from "@homarr/translation/client";
|
import { useI18n } from "@homarr/translation/client";
|
||||||
import type { z } from "@homarr/validation";
|
|
||||||
import { validation } from "@homarr/validation";
|
import { validation } from "@homarr/validation";
|
||||||
|
|
||||||
import { IconPicker } from "~/components/icons/picker/icon-picker";
|
|
||||||
|
|
||||||
type FormType = z.infer<typeof validation.searchEngine.manage>;
|
type FormType = z.infer<typeof validation.searchEngine.manage>;
|
||||||
|
|
||||||
interface SearchEngineFormProps {
|
interface SearchEngineFormProps {
|
||||||
@@ -58,7 +57,7 @@ export const SearchEngineForm = (props: SearchEngineFormProps) => {
|
|||||||
/>
|
/>
|
||||||
</Grid.Col>
|
</Grid.Col>
|
||||||
</Grid>
|
</Grid>
|
||||||
<IconPicker initialValue={initialValues?.iconUrl} {...form.getInputProps("iconUrl")} />
|
<IconPicker {...form.getInputProps("iconUrl")} />
|
||||||
|
|
||||||
<Fieldset legend={t("search.engine.page.edit.configControl")}>
|
<Fieldset legend={t("search.engine.page.edit.configControl")}>
|
||||||
<SegmentedControl
|
<SegmentedControl
|
||||||
|
|||||||
+2
-1
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { useCallback } from "react";
|
import { useCallback } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
|
import type { z } from "zod";
|
||||||
|
|
||||||
import type { RouterOutputs } from "@homarr/api";
|
import type { RouterOutputs } from "@homarr/api";
|
||||||
import { clientApi } from "@homarr/api/client";
|
import { clientApi } from "@homarr/api/client";
|
||||||
@@ -9,7 +10,7 @@ import { revalidatePathActionAsync } from "@homarr/common/client";
|
|||||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||||
import type { TranslationFunction } from "@homarr/translation";
|
import type { TranslationFunction } from "@homarr/translation";
|
||||||
import { useScopedI18n } from "@homarr/translation/client";
|
import { useScopedI18n } from "@homarr/translation/client";
|
||||||
import type { validation, z } from "@homarr/validation";
|
import type { validation } from "@homarr/validation";
|
||||||
|
|
||||||
import { SearchEngineForm } from "../../_form";
|
import { SearchEngineForm } from "../../_form";
|
||||||
|
|
||||||
|
|||||||
@@ -2,13 +2,14 @@
|
|||||||
|
|
||||||
import { useCallback } from "react";
|
import { useCallback } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
|
import type { z } from "zod";
|
||||||
|
|
||||||
import { clientApi } from "@homarr/api/client";
|
import { clientApi } from "@homarr/api/client";
|
||||||
import { revalidatePathActionAsync } from "@homarr/common/client";
|
import { revalidatePathActionAsync } from "@homarr/common/client";
|
||||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||||
import type { TranslationFunction } from "@homarr/translation";
|
import type { TranslationFunction } from "@homarr/translation";
|
||||||
import { useScopedI18n } from "@homarr/translation/client";
|
import { useScopedI18n } from "@homarr/translation/client";
|
||||||
import type { validation, z } from "@homarr/validation";
|
import type { validation } from "@homarr/validation";
|
||||||
|
|
||||||
import { SearchEngineForm } from "../_form";
|
import { SearchEngineForm } from "../_form";
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import Link from "next/link";
|
|||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { ActionIcon, ActionIconGroup, Anchor, Avatar, Card, Group, Stack, Text, Title } from "@mantine/core";
|
import { ActionIcon, ActionIconGroup, Anchor, Avatar, Card, Group, Stack, Text, Title } from "@mantine/core";
|
||||||
import { IconPencil, IconSearch } from "@tabler/icons-react";
|
import { IconPencil, IconSearch } from "@tabler/icons-react";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
import type { RouterOutputs } from "@homarr/api";
|
import type { RouterOutputs } from "@homarr/api";
|
||||||
import { api } from "@homarr/api/server";
|
import { api } from "@homarr/api/server";
|
||||||
@@ -9,7 +10,6 @@ import { auth } from "@homarr/auth/next";
|
|||||||
import type { inferSearchParamsFromSchema } from "@homarr/common/types";
|
import type { inferSearchParamsFromSchema } from "@homarr/common/types";
|
||||||
import { getI18n, getScopedI18n } from "@homarr/translation/server";
|
import { getI18n, getScopedI18n } from "@homarr/translation/server";
|
||||||
import { SearchInput, TablePagination } from "@homarr/ui";
|
import { SearchInput, TablePagination } from "@homarr/ui";
|
||||||
import { z } from "@homarr/validation";
|
|
||||||
|
|
||||||
import { ManageContainer } from "~/components/manage/manage-container";
|
import { ManageContainer } from "~/components/manage/manage-container";
|
||||||
import { MobileAffixButton } from "~/components/manage/mobile-affix-button";
|
import { MobileAffixButton } from "~/components/manage/mobile-affix-button";
|
||||||
@@ -45,7 +45,7 @@ export default async function SearchEnginesPage(props: SearchEnginesPageProps) {
|
|||||||
<Stack>
|
<Stack>
|
||||||
<Title>{tEngine("page.list.title")}</Title>
|
<Title>{tEngine("page.list.title")}</Title>
|
||||||
<Group justify="space-between" align="center">
|
<Group justify="space-between" align="center">
|
||||||
<SearchInput placeholder={`${tEngine("search")}...`} defaultValue={searchParams.search} />
|
<SearchInput placeholder={`${tEngine("search")}...`} defaultValue={searchParams.search} flexExpand />
|
||||||
{session.user.permissions.includes("search-engine-create") && (
|
{session.user.permissions.includes("search-engine-create") && (
|
||||||
<MobileAffixButton component={Link} href="/manage/search-engines/new">
|
<MobileAffixButton component={Link} href="/manage/search-engines/new">
|
||||||
{tEngine("page.create.title")}
|
{tEngine("page.create.title")}
|
||||||
|
|||||||
@@ -1,13 +1,12 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { Group, Text } from "@mantine/core";
|
import { Switch, Text } from "@mantine/core";
|
||||||
import { IconLayoutDashboard } from "@tabler/icons-react";
|
|
||||||
|
|
||||||
import { clientApi } from "@homarr/api/client";
|
import { clientApi } from "@homarr/api/client";
|
||||||
import type { ServerSettings } from "@homarr/server-settings";
|
import type { ServerSettings } from "@homarr/server-settings";
|
||||||
import { useScopedI18n } from "@homarr/translation/client";
|
import { useScopedI18n } from "@homarr/translation/client";
|
||||||
import { SelectWithCustomItems } from "@homarr/ui";
|
|
||||||
|
|
||||||
|
import { BoardSelect } from "~/components/board/board-select";
|
||||||
import { CommonSettingsForm } from "./common-form";
|
import { CommonSettingsForm } from "./common-form";
|
||||||
|
|
||||||
export const BoardSettingsForm = ({ defaultValues }: { defaultValues: ServerSettings["board"] }) => {
|
export const BoardSettingsForm = ({ defaultValues }: { defaultValues: ServerSettings["board"] }) => {
|
||||||
@@ -18,44 +17,33 @@ export const BoardSettingsForm = ({ defaultValues }: { defaultValues: ServerSett
|
|||||||
<CommonSettingsForm settingKey="board" defaultValues={defaultValues}>
|
<CommonSettingsForm settingKey="board" defaultValues={defaultValues}>
|
||||||
{(form) => (
|
{(form) => (
|
||||||
<>
|
<>
|
||||||
<SelectWithCustomItems
|
<BoardSelect
|
||||||
label={tBoard("homeBoard.label")}
|
label={tBoard("homeBoard.label")}
|
||||||
description={tBoard("homeBoard.description")}
|
description={tBoard("homeBoard.description")}
|
||||||
data={selectableBoards.map((board) => ({
|
clearable
|
||||||
value: board.id,
|
boards={selectableBoards}
|
||||||
label: board.name,
|
|
||||||
image: board.logoImageUrl,
|
|
||||||
}))}
|
|
||||||
SelectOption={({ label, image }: { value: string; label: string; image: string | null }) => (
|
|
||||||
<Group>
|
|
||||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
|
||||||
{image ? <img width={16} height={16} src={image} alt={label} /> : <IconLayoutDashboard size={16} />}
|
|
||||||
<Text fz="sm" fw={500}>
|
|
||||||
{label}
|
|
||||||
</Text>
|
|
||||||
</Group>
|
|
||||||
)}
|
|
||||||
{...form.getInputProps("homeBoardId")}
|
{...form.getInputProps("homeBoardId")}
|
||||||
/>
|
/>
|
||||||
<SelectWithCustomItems
|
|
||||||
|
<BoardSelect
|
||||||
label={tBoard("homeBoard.mobileLabel")}
|
label={tBoard("homeBoard.mobileLabel")}
|
||||||
description={tBoard("homeBoard.description")}
|
description={tBoard("homeBoard.description")}
|
||||||
data={selectableBoards.map((board) => ({
|
clearable
|
||||||
value: board.id,
|
boards={selectableBoards}
|
||||||
label: board.name,
|
|
||||||
image: board.logoImageUrl,
|
|
||||||
}))}
|
|
||||||
SelectOption={({ label, image }: { value: string; label: string; image: string | null }) => (
|
|
||||||
<Group>
|
|
||||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
|
||||||
{image ? <img width={16} height={16} src={image} alt={label} /> : <IconLayoutDashboard size={16} />}
|
|
||||||
<Text fz="sm" fw={500}>
|
|
||||||
{label}
|
|
||||||
</Text>
|
|
||||||
</Group>
|
|
||||||
)}
|
|
||||||
{...form.getInputProps("mobileHomeBoardId")}
|
{...form.getInputProps("mobileHomeBoardId")}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<Text fw={500}>{tBoard("status.title")}</Text>
|
||||||
|
<Switch
|
||||||
|
{...form.getInputProps("enableStatusByDefault", { type: "checkbox" })}
|
||||||
|
label={tBoard("status.enableStatusByDefault.label")}
|
||||||
|
description={tBoard("status.enableStatusByDefault.description")}
|
||||||
|
/>
|
||||||
|
<Switch
|
||||||
|
{...form.getInputProps("forceDisableStatus", { type: "checkbox" })}
|
||||||
|
label={tBoard("status.forceDisableStatus.label")}
|
||||||
|
description={tBoard("status.forceDisableStatus.description")}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</CommonSettingsForm>
|
</CommonSettingsForm>
|
||||||
|
|||||||
@@ -1,14 +1,15 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useMemo } from "react";
|
import { useCallback, useMemo } from "react";
|
||||||
import { Button, Group, Stack, Text, Title } from "@mantine/core";
|
import { ActionIcon, Button, Group, Stack, Text, Title } from "@mantine/core";
|
||||||
|
import { IconTrash } from "@tabler/icons-react";
|
||||||
import type { MRT_ColumnDef } from "mantine-react-table";
|
import type { MRT_ColumnDef } from "mantine-react-table";
|
||||||
import { MantineReactTable, useMantineReactTable } from "mantine-react-table";
|
import { MantineReactTable, useMantineReactTable } from "mantine-react-table";
|
||||||
|
|
||||||
import type { RouterOutputs } from "@homarr/api";
|
import type { RouterOutputs } from "@homarr/api";
|
||||||
import { clientApi } from "@homarr/api/client";
|
import { clientApi } from "@homarr/api/client";
|
||||||
import { revalidatePathActionAsync } from "@homarr/common/client";
|
import { revalidatePathActionAsync } from "@homarr/common/client";
|
||||||
import { useModalAction } from "@homarr/modals";
|
import { useConfirmModal, useModalAction } from "@homarr/modals";
|
||||||
import { useScopedI18n } from "@homarr/translation/client";
|
import { useScopedI18n } from "@homarr/translation/client";
|
||||||
import { UserAvatar } from "@homarr/ui";
|
import { UserAvatar } from "@homarr/ui";
|
||||||
|
|
||||||
@@ -20,7 +21,8 @@ interface ApiKeysManagementProps {
|
|||||||
|
|
||||||
export const ApiKeysManagement = ({ apiKeys }: ApiKeysManagementProps) => {
|
export const ApiKeysManagement = ({ apiKeys }: ApiKeysManagementProps) => {
|
||||||
const { openModal } = useModalAction(CopyApiKeyModal);
|
const { openModal } = useModalAction(CopyApiKeyModal);
|
||||||
const { mutate, isPending } = clientApi.apiKeys.create.useMutation({
|
const { openConfirmModal } = useConfirmModal();
|
||||||
|
const { mutate: mutateCreate, isPending: isPendingCreate } = clientApi.apiKeys.create.useMutation({
|
||||||
async onSuccess(data) {
|
async onSuccess(data) {
|
||||||
openModal({
|
openModal({
|
||||||
apiKey: data.apiKey,
|
apiKey: data.apiKey,
|
||||||
@@ -28,7 +30,26 @@ export const ApiKeysManagement = ({ apiKeys }: ApiKeysManagementProps) => {
|
|||||||
await revalidatePathActionAsync("/manage/tools/api");
|
await revalidatePathActionAsync("/manage/tools/api");
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
const { mutateAsync: mutateDeleteAsync, isPending: isPendingDelete } = clientApi.apiKeys.delete.useMutation({
|
||||||
|
async onSuccess() {
|
||||||
|
await revalidatePathActionAsync("/manage/tools/api");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const t = useScopedI18n("management.page.tool.api.tab.apiKey");
|
const t = useScopedI18n("management.page.tool.api.tab.apiKey");
|
||||||
|
const handleDelete = useCallback(
|
||||||
|
(id: string) => {
|
||||||
|
openConfirmModal({
|
||||||
|
title: t("modal.delete.title"),
|
||||||
|
children: t("modal.delete.text"),
|
||||||
|
// eslint-disable-next-line no-restricted-syntax
|
||||||
|
async onConfirm() {
|
||||||
|
await mutateDeleteAsync({ apiKeyId: id });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[t, openConfirmModal, mutateDeleteAsync],
|
||||||
|
);
|
||||||
|
|
||||||
const columns = useMemo<MRT_ColumnDef<RouterOutputs["apiKeys"]["getAll"][number]>[]>(
|
const columns = useMemo<MRT_ColumnDef<RouterOutputs["apiKeys"]["getAll"][number]>[]>(
|
||||||
() => [
|
() => [
|
||||||
@@ -46,8 +67,18 @@ export const ApiKeysManagement = ({ apiKeys }: ApiKeysManagementProps) => {
|
|||||||
</Group>
|
</Group>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
header: t("table.header.actions"),
|
||||||
|
Cell: ({ row }) => (
|
||||||
|
<Group gap="xs">
|
||||||
|
<ActionIcon onClick={() => handleDelete(row.original.id)} loading={isPendingDelete} c="red">
|
||||||
|
<IconTrash size="1rem" />
|
||||||
|
</ActionIcon>
|
||||||
|
</Group>
|
||||||
|
),
|
||||||
|
},
|
||||||
],
|
],
|
||||||
[t],
|
[t, handleDelete, isPendingDelete],
|
||||||
);
|
);
|
||||||
|
|
||||||
const table = useMantineReactTable({
|
const table = useMantineReactTable({
|
||||||
@@ -56,9 +87,9 @@ export const ApiKeysManagement = ({ apiKeys }: ApiKeysManagementProps) => {
|
|||||||
renderTopToolbarCustomActions: () => (
|
renderTopToolbarCustomActions: () => (
|
||||||
<Button
|
<Button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
mutate();
|
mutateCreate();
|
||||||
}}
|
}}
|
||||||
loading={isPending}
|
loading={isPendingCreate}
|
||||||
>
|
>
|
||||||
{t("button.createApiToken")}
|
{t("button.createApiToken")}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -1434,26 +1434,30 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
::-webkit-scrollbar-button:vertical:start:decrement {
|
::-webkit-scrollbar-button:vertical:start:decrement {
|
||||||
background: linear-gradient(130deg, #696969 40%, rgba(255, 0, 0, 0) 41%),
|
background:
|
||||||
|
linear-gradient(130deg, #696969 40%, rgba(255, 0, 0, 0) 41%),
|
||||||
linear-gradient(230deg, #696969 40%, transparent 41%), linear-gradient(0deg, #696969 40%, transparent 31%);
|
linear-gradient(230deg, #696969 40%, transparent 41%), linear-gradient(0deg, #696969 40%, transparent 31%);
|
||||||
background-color: #b6b6b6;
|
background-color: #b6b6b6;
|
||||||
}
|
}
|
||||||
|
|
||||||
::-webkit-scrollbar-button:vertical:end:increment {
|
::-webkit-scrollbar-button:vertical:end:increment {
|
||||||
background: linear-gradient(310deg, #696969 40%, transparent 41%),
|
background:
|
||||||
linear-gradient(50deg, #696969 40%, transparent 41%), linear-gradient(180deg, #696969 40%, transparent 31%);
|
linear-gradient(310deg, #696969 40%, transparent 41%), linear-gradient(50deg, #696969 40%, transparent 41%),
|
||||||
|
linear-gradient(180deg, #696969 40%, transparent 31%);
|
||||||
background-color: #b6b6b6;
|
background-color: #b6b6b6;
|
||||||
}
|
}
|
||||||
|
|
||||||
::-webkit-scrollbar-button:horizontal:end:increment {
|
::-webkit-scrollbar-button:horizontal:end:increment {
|
||||||
background: linear-gradient(210deg, #696969 40%, transparent 41%),
|
background:
|
||||||
linear-gradient(330deg, #696969 40%, transparent 41%), linear-gradient(90deg, #696969 30%, transparent 31%);
|
linear-gradient(210deg, #696969 40%, transparent 41%), linear-gradient(330deg, #696969 40%, transparent 41%),
|
||||||
|
linear-gradient(90deg, #696969 30%, transparent 31%);
|
||||||
background-color: #b6b6b6;
|
background-color: #b6b6b6;
|
||||||
}
|
}
|
||||||
|
|
||||||
::-webkit-scrollbar-button:horizontal:start:decrement {
|
::-webkit-scrollbar-button:horizontal:start:decrement {
|
||||||
background: linear-gradient(30deg, #696969 40%, transparent 41%),
|
background:
|
||||||
linear-gradient(150deg, #696969 40%, transparent 41%), linear-gradient(270deg, #696969 30%, transparent 31%);
|
linear-gradient(30deg, #696969 40%, transparent 41%), linear-gradient(150deg, #696969 40%, transparent 41%),
|
||||||
|
linear-gradient(270deg, #696969 30%, transparent 31%);
|
||||||
background-color: #b6b6b6;
|
background-color: #b6b6b6;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1681,28 +1685,32 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
::-webkit-scrollbar-button:vertical:start:decrement {
|
::-webkit-scrollbar-button:vertical:start:decrement {
|
||||||
background: linear-gradient(130deg, #696969 40%, rgba(255, 0, 0, 0) 41%),
|
background:
|
||||||
|
linear-gradient(130deg, #696969 40%, rgba(255, 0, 0, 0) 41%),
|
||||||
linear-gradient(230deg, #696969 40%, rgba(0, 0, 0, 0) 41%),
|
linear-gradient(230deg, #696969 40%, rgba(0, 0, 0, 0) 41%),
|
||||||
linear-gradient(0deg, #696969 40%, rgba(0, 0, 0, 0) 31%);
|
linear-gradient(0deg, #696969 40%, rgba(0, 0, 0, 0) 31%);
|
||||||
background-color: #b6b6b6;
|
background-color: #b6b6b6;
|
||||||
}
|
}
|
||||||
|
|
||||||
::-webkit-scrollbar-button:vertical:end:increment {
|
::-webkit-scrollbar-button:vertical:end:increment {
|
||||||
background: linear-gradient(310deg, #696969 40%, rgba(0, 0, 0, 0) 41%),
|
background:
|
||||||
|
linear-gradient(310deg, #696969 40%, rgba(0, 0, 0, 0) 41%),
|
||||||
linear-gradient(50deg, #696969 40%, rgba(0, 0, 0, 0) 41%),
|
linear-gradient(50deg, #696969 40%, rgba(0, 0, 0, 0) 41%),
|
||||||
linear-gradient(180deg, #696969 40%, rgba(0, 0, 0, 0) 31%);
|
linear-gradient(180deg, #696969 40%, rgba(0, 0, 0, 0) 31%);
|
||||||
background-color: #b6b6b6;
|
background-color: #b6b6b6;
|
||||||
}
|
}
|
||||||
|
|
||||||
::-webkit-scrollbar-button:horizontal:end:increment {
|
::-webkit-scrollbar-button:horizontal:end:increment {
|
||||||
background: linear-gradient(210deg, #696969 40%, rgba(0, 0, 0, 0) 41%),
|
background:
|
||||||
|
linear-gradient(210deg, #696969 40%, rgba(0, 0, 0, 0) 41%),
|
||||||
linear-gradient(330deg, #696969 40%, rgba(0, 0, 0, 0) 41%),
|
linear-gradient(330deg, #696969 40%, rgba(0, 0, 0, 0) 41%),
|
||||||
linear-gradient(90deg, #696969 30%, rgba(0, 0, 0, 0) 31%);
|
linear-gradient(90deg, #696969 30%, rgba(0, 0, 0, 0) 31%);
|
||||||
background-color: #b6b6b6;
|
background-color: #b6b6b6;
|
||||||
}
|
}
|
||||||
|
|
||||||
::-webkit-scrollbar-button:horizontal:start:decrement {
|
::-webkit-scrollbar-button:horizontal:start:decrement {
|
||||||
background: linear-gradient(30deg, #696969 40%, rgba(0, 0, 0, 0) 41%),
|
background:
|
||||||
|
linear-gradient(30deg, #696969 40%, rgba(0, 0, 0, 0) 41%),
|
||||||
linear-gradient(150deg, #696969 40%, rgba(0, 0, 0, 0) 41%),
|
linear-gradient(150deg, #696969 40%, rgba(0, 0, 0, 0) 41%),
|
||||||
linear-gradient(270deg, #696969 30%, rgba(0, 0, 0, 0) 31%);
|
linear-gradient(270deg, #696969 30%, rgba(0, 0, 0, 0) 31%);
|
||||||
background-color: #b6b6b6;
|
background-color: #b6b6b6;
|
||||||
|
|||||||
+2
-1
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { Button, FileInput, Group, Stack } from "@mantine/core";
|
import { Button, FileInput, Group, Stack } from "@mantine/core";
|
||||||
import { IconCertificate } from "@tabler/icons-react";
|
import { IconCertificate } from "@tabler/icons-react";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
import { clientApi } from "@homarr/api/client";
|
import { clientApi } from "@homarr/api/client";
|
||||||
import { revalidatePathActionAsync } from "@homarr/common/client";
|
import { revalidatePathActionAsync } from "@homarr/common/client";
|
||||||
@@ -9,7 +10,7 @@ import { useZodForm } from "@homarr/form";
|
|||||||
import { createModal, useModalAction } from "@homarr/modals";
|
import { createModal, useModalAction } from "@homarr/modals";
|
||||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||||
import { useI18n } from "@homarr/translation/client";
|
import { useI18n } from "@homarr/translation/client";
|
||||||
import { superRefineCertificateFile, z } from "@homarr/validation";
|
import { superRefineCertificateFile } from "@homarr/validation";
|
||||||
|
|
||||||
export const AddCertificateButton = () => {
|
export const AddCertificateButton = () => {
|
||||||
const { openModal } = useModalAction(AddCertificateModal);
|
const { openModal } = useModalAction(AddCertificateModal);
|
||||||
|
|||||||
+12
-7
@@ -1,6 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { Button, Group, Select, Stack } from "@mantine/core";
|
import { Button, Group, Stack } from "@mantine/core";
|
||||||
|
import type { z } from "zod";
|
||||||
|
|
||||||
import type { RouterOutputs } from "@homarr/api";
|
import type { RouterOutputs } from "@homarr/api";
|
||||||
import { clientApi } from "@homarr/api/client";
|
import { clientApi } from "@homarr/api/client";
|
||||||
@@ -8,12 +9,14 @@ import { revalidatePathActionAsync } from "@homarr/common/client";
|
|||||||
import { useZodForm } from "@homarr/form";
|
import { useZodForm } from "@homarr/form";
|
||||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||||
import { useI18n } from "@homarr/translation/client";
|
import { useI18n } from "@homarr/translation/client";
|
||||||
import type { z } from "@homarr/validation";
|
|
||||||
import { validation } from "@homarr/validation";
|
import { validation } from "@homarr/validation";
|
||||||
|
|
||||||
|
import type { Board } from "~/app/[locale]/boards/_types";
|
||||||
|
import { BoardSelect } from "~/components/board/board-select";
|
||||||
|
|
||||||
interface ChangeHomeBoardFormProps {
|
interface ChangeHomeBoardFormProps {
|
||||||
user: RouterOutputs["user"]["getById"];
|
user: RouterOutputs["user"]["getById"];
|
||||||
boardsData: { value: string; label: string }[];
|
boardsData: Pick<Board, "id" | "name" | "logoImageUrl">[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ChangeHomeBoardForm = ({ user, boardsData }: ChangeHomeBoardFormProps) => {
|
export const ChangeHomeBoardForm = ({ user, boardsData }: ChangeHomeBoardFormProps) => {
|
||||||
@@ -54,16 +57,18 @@ export const ChangeHomeBoardForm = ({ user, boardsData }: ChangeHomeBoardFormPro
|
|||||||
return (
|
return (
|
||||||
<form onSubmit={form.onSubmit(handleSubmit)}>
|
<form onSubmit={form.onSubmit(handleSubmit)}>
|
||||||
<Stack gap="md">
|
<Stack gap="md">
|
||||||
<Select
|
<BoardSelect
|
||||||
label={t("management.page.user.setting.general.item.board.type.general")}
|
label={t("management.page.user.setting.general.item.board.type.general")}
|
||||||
|
clearable
|
||||||
|
boards={boardsData}
|
||||||
w="100%"
|
w="100%"
|
||||||
data={boardsData}
|
|
||||||
{...form.getInputProps("homeBoardId")}
|
{...form.getInputProps("homeBoardId")}
|
||||||
/>
|
/>
|
||||||
<Select
|
<BoardSelect
|
||||||
label={t("management.page.user.setting.general.item.board.type.mobile")}
|
label={t("management.page.user.setting.general.item.board.type.mobile")}
|
||||||
|
clearable
|
||||||
|
boards={boardsData}
|
||||||
w="100%"
|
w="100%"
|
||||||
data={boardsData}
|
|
||||||
{...form.getInputProps("mobileHomeBoardId")}
|
{...form.getInputProps("mobileHomeBoardId")}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
+22
-11
@@ -1,6 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { Button, Group, Select, Stack } from "@mantine/core";
|
import { Button, Group, Select, Stack, Switch } from "@mantine/core";
|
||||||
|
import type { z } from "zod";
|
||||||
|
|
||||||
import type { RouterOutputs } from "@homarr/api";
|
import type { RouterOutputs } from "@homarr/api";
|
||||||
import { clientApi } from "@homarr/api/client";
|
import { clientApi } from "@homarr/api/client";
|
||||||
@@ -8,37 +9,38 @@ import { revalidatePathActionAsync } from "@homarr/common/client";
|
|||||||
import { useZodForm } from "@homarr/form";
|
import { useZodForm } from "@homarr/form";
|
||||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||||
import { useI18n } from "@homarr/translation/client";
|
import { useI18n } from "@homarr/translation/client";
|
||||||
import type { z } from "@homarr/validation";
|
|
||||||
import { validation } from "@homarr/validation";
|
import { validation } from "@homarr/validation";
|
||||||
|
|
||||||
interface ChangeDefaultSearchEngineFormProps {
|
interface ChangeSearchPreferencesFormProps {
|
||||||
user: RouterOutputs["user"]["getById"];
|
user: RouterOutputs["user"]["getById"];
|
||||||
searchEnginesData: { value: string; label: string }[];
|
searchEnginesData: { value: string; label: string }[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ChangeDefaultSearchEngineForm = ({ user, searchEnginesData }: ChangeDefaultSearchEngineFormProps) => {
|
export const ChangeSearchPreferencesForm = ({ user, searchEnginesData }: ChangeSearchPreferencesFormProps) => {
|
||||||
const t = useI18n();
|
const t = useI18n();
|
||||||
const { mutate, isPending } = clientApi.user.changeDefaultSearchEngine.useMutation({
|
const { mutate, isPending } = clientApi.user.changeSearchPreferences.useMutation({
|
||||||
async onSettled() {
|
async onSettled() {
|
||||||
await revalidatePathActionAsync(`/manage/users/${user.id}`);
|
await revalidatePathActionAsync(`/manage/users/${user.id}`);
|
||||||
},
|
},
|
||||||
onSuccess(_, variables) {
|
onSuccess(_, variables) {
|
||||||
form.setInitialValues({
|
form.setInitialValues({
|
||||||
defaultSearchEngineId: variables.defaultSearchEngineId,
|
defaultSearchEngineId: variables.defaultSearchEngineId,
|
||||||
|
openInNewTab: variables.openInNewTab,
|
||||||
});
|
});
|
||||||
showSuccessNotification({
|
showSuccessNotification({
|
||||||
message: t("user.action.changeDefaultSearchEngine.notification.success.message"),
|
message: t("user.action.changeSearchPreferences.notification.success.message"),
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
onError() {
|
onError() {
|
||||||
showErrorNotification({
|
showErrorNotification({
|
||||||
message: t("user.action.changeDefaultSearchEngine.notification.error.message"),
|
message: t("user.action.changeSearchPreferences.notification.error.message"),
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const form = useZodForm(validation.user.changeDefaultSearchEngine, {
|
const form = useZodForm(validation.user.changeSearchPreferences, {
|
||||||
initialValues: {
|
initialValues: {
|
||||||
defaultSearchEngineId: user.defaultSearchEngineId ?? "",
|
defaultSearchEngineId: user.defaultSearchEngineId,
|
||||||
|
openInNewTab: user.openSearchInNewTab,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -52,7 +54,16 @@ export const ChangeDefaultSearchEngineForm = ({ user, searchEnginesData }: Chang
|
|||||||
return (
|
return (
|
||||||
<form onSubmit={form.onSubmit(handleSubmit)}>
|
<form onSubmit={form.onSubmit(handleSubmit)}>
|
||||||
<Stack gap="md">
|
<Stack gap="md">
|
||||||
<Select w="100%" data={searchEnginesData} {...form.getInputProps("defaultSearchEngineId")} />
|
<Select
|
||||||
|
label={t("user.field.defaultSearchEngine.label")}
|
||||||
|
w="100%"
|
||||||
|
data={searchEnginesData}
|
||||||
|
{...form.getInputProps("defaultSearchEngineId")}
|
||||||
|
/>
|
||||||
|
<Switch
|
||||||
|
label={t("user.field.openSearchInNewTab.label")}
|
||||||
|
{...form.getInputProps("openInNewTab", { type: "checkbox" })}
|
||||||
|
/>
|
||||||
|
|
||||||
<Group justify="end">
|
<Group justify="end">
|
||||||
<Button type="submit" color="teal" loading={isPending}>
|
<Button type="submit" color="teal" loading={isPending}>
|
||||||
@@ -64,4 +75,4 @@ export const ChangeDefaultSearchEngineForm = ({ user, searchEnginesData }: Chang
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
type FormType = z.infer<typeof validation.user.changeDefaultSearchEngine>;
|
type FormType = z.infer<typeof validation.user.changeSearchPreferences>;
|
||||||
+1
-1
@@ -4,6 +4,7 @@ import { Button, Group, Radio, Stack } from "@mantine/core";
|
|||||||
import type { DayOfWeek } from "@mantine/dates";
|
import type { DayOfWeek } from "@mantine/dates";
|
||||||
import dayjs from "dayjs";
|
import dayjs from "dayjs";
|
||||||
import localeData from "dayjs/plugin/localeData";
|
import localeData from "dayjs/plugin/localeData";
|
||||||
|
import type { z } from "zod";
|
||||||
|
|
||||||
import type { RouterOutputs } from "@homarr/api";
|
import type { RouterOutputs } from "@homarr/api";
|
||||||
import { clientApi } from "@homarr/api/client";
|
import { clientApi } from "@homarr/api/client";
|
||||||
@@ -11,7 +12,6 @@ import { revalidatePathActionAsync } from "@homarr/common/client";
|
|||||||
import { useZodForm } from "@homarr/form";
|
import { useZodForm } from "@homarr/form";
|
||||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||||
import { useI18n } from "@homarr/translation/client";
|
import { useI18n } from "@homarr/translation/client";
|
||||||
import type { z } from "@homarr/validation";
|
|
||||||
import { validation } from "@homarr/validation";
|
import { validation } from "@homarr/validation";
|
||||||
|
|
||||||
dayjs.extend(localeData);
|
dayjs.extend(localeData);
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { Button, Group, Stack, Switch } from "@mantine/core";
|
import { Button, Group, Stack, Switch } from "@mantine/core";
|
||||||
|
import type { z } from "zod";
|
||||||
|
|
||||||
import type { RouterOutputs } from "@homarr/api";
|
import type { RouterOutputs } from "@homarr/api";
|
||||||
import { clientApi } from "@homarr/api/client";
|
import { clientApi } from "@homarr/api/client";
|
||||||
@@ -8,7 +9,6 @@ import { revalidatePathActionAsync } from "@homarr/common/client";
|
|||||||
import { useZodForm } from "@homarr/form";
|
import { useZodForm } from "@homarr/form";
|
||||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||||
import { useI18n } from "@homarr/translation/client";
|
import { useI18n } from "@homarr/translation/client";
|
||||||
import type { z } from "@homarr/validation";
|
|
||||||
import { validation } from "@homarr/validation";
|
import { validation } from "@homarr/validation";
|
||||||
|
|
||||||
interface PingIconsEnabledProps {
|
interface PingIconsEnabledProps {
|
||||||
|
|||||||
@@ -11,8 +11,8 @@ import { DangerZoneItem, DangerZoneRoot } from "~/components/manage/danger-zone"
|
|||||||
import { catchTrpcNotFound } from "~/errors/trpc-catch-error";
|
import { catchTrpcNotFound } from "~/errors/trpc-catch-error";
|
||||||
import { createMetaTitle } from "~/metadata";
|
import { createMetaTitle } from "~/metadata";
|
||||||
import { canAccessUserEditPage } from "../access";
|
import { canAccessUserEditPage } from "../access";
|
||||||
import { ChangeDefaultSearchEngineForm } from "./_components/_change-default-search-engine";
|
|
||||||
import { ChangeHomeBoardForm } from "./_components/_change-home-board";
|
import { ChangeHomeBoardForm } from "./_components/_change-home-board";
|
||||||
|
import { ChangeSearchPreferencesForm } from "./_components/_change-search-preferences";
|
||||||
import { DeleteUserButton } from "./_components/_delete-user-button";
|
import { DeleteUserButton } from "./_components/_delete-user-button";
|
||||||
import { FirstDayOfWeek } from "./_components/_first-day-of-week";
|
import { FirstDayOfWeek } from "./_components/_first-day-of-week";
|
||||||
import { PingIconsEnabled } from "./_components/_ping-icons-enabled";
|
import { PingIconsEnabled } from "./_components/_ping-icons-enabled";
|
||||||
@@ -95,15 +95,16 @@ export default async function EditUserPage(props: Props) {
|
|||||||
<ChangeHomeBoardForm
|
<ChangeHomeBoardForm
|
||||||
user={user}
|
user={user}
|
||||||
boardsData={boards.map((board) => ({
|
boardsData={boards.map((board) => ({
|
||||||
value: board.id,
|
id: board.id,
|
||||||
label: board.name,
|
name: board.name,
|
||||||
|
logoImageUrl: board.logoImageUrl,
|
||||||
}))}
|
}))}
|
||||||
/>
|
/>
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|
||||||
<Stack mb="lg">
|
<Stack mb="lg">
|
||||||
<Title order={2}>{tGeneral("item.defaultSearchEngine")}</Title>
|
<Title order={2}>{tGeneral("item.search")}</Title>
|
||||||
<ChangeDefaultSearchEngineForm user={user} searchEnginesData={searchEngines} />
|
<ChangeSearchPreferencesForm user={user} searchEnginesData={searchEngines} />
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|
||||||
<Stack mb="lg">
|
<Stack mb="lg">
|
||||||
|
|||||||
+2
-1
@@ -17,6 +17,7 @@ import {
|
|||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import { useListState } from "@mantine/hooks";
|
import { useListState } from "@mantine/hooks";
|
||||||
import { IconPlus, IconUserCheck } from "@tabler/icons-react";
|
import { IconPlus, IconUserCheck } from "@tabler/icons-react";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
import { clientApi } from "@homarr/api/client";
|
import { clientApi } from "@homarr/api/client";
|
||||||
import { everyoneGroup, groupPermissions } from "@homarr/definitions";
|
import { everyoneGroup, groupPermissions } from "@homarr/definitions";
|
||||||
@@ -26,7 +27,7 @@ import { useModalAction } from "@homarr/modals";
|
|||||||
import { showErrorNotification } from "@homarr/notifications";
|
import { showErrorNotification } from "@homarr/notifications";
|
||||||
import { useI18n, useScopedI18n } from "@homarr/translation/client";
|
import { useI18n, useScopedI18n } from "@homarr/translation/client";
|
||||||
import { CustomPasswordInput, UserAvatar } from "@homarr/ui";
|
import { CustomPasswordInput, UserAvatar } from "@homarr/ui";
|
||||||
import { validation, z } from "@homarr/validation";
|
import { validation } from "@homarr/validation";
|
||||||
import { createCustomErrorParams } from "@homarr/validation/form";
|
import { createCustomErrorParams } from "@homarr/validation/form";
|
||||||
|
|
||||||
import { GroupSelectModal } from "~/components/access/group-select-modal";
|
import { GroupSelectModal } from "~/components/access/group-select-modal";
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { PropsWithChildren } from "react";
|
import type { PropsWithChildren } from "react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { Button, Grid, GridCol, Group, Stack, Text, Title } from "@mantine/core";
|
import { Button, Grid, GridCol, Group, Stack, Text, Title } from "@mantine/core";
|
||||||
import { IconLock, IconSettings, IconUsersGroup } from "@tabler/icons-react";
|
import { IconId, IconLock, IconSettings, IconUsersGroup } from "@tabler/icons-react";
|
||||||
|
|
||||||
import { api } from "@homarr/api/server";
|
import { api } from "@homarr/api/server";
|
||||||
import { getI18n, getScopedI18n } from "@homarr/translation/server";
|
import { getI18n, getScopedI18n } from "@homarr/translation/server";
|
||||||
@@ -42,6 +42,11 @@ export default async function Layout(props: PropsWithChildren<LayoutProps>) {
|
|||||||
<NavigationLink
|
<NavigationLink
|
||||||
href={`/manage/users/groups/${params.id}`}
|
href={`/manage/users/groups/${params.id}`}
|
||||||
label={tGroup("setting.general.title")}
|
label={tGroup("setting.general.title")}
|
||||||
|
icon={<IconId size="1rem" stroke={1.5} />}
|
||||||
|
/>
|
||||||
|
<NavigationLink
|
||||||
|
href={`/manage/users/groups/${params.id}/settings`}
|
||||||
|
label={tGroup("setting.setting.title")}
|
||||||
icon={<IconSettings size="1rem" stroke={1.5} />}
|
icon={<IconSettings size="1rem" stroke={1.5} />}
|
||||||
/>
|
/>
|
||||||
<NavigationLink
|
<NavigationLink
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Button, Group, Stack } from "@mantine/core";
|
||||||
|
|
||||||
|
import { clientApi } from "@homarr/api/client";
|
||||||
|
import { useZodForm } from "@homarr/form";
|
||||||
|
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||||
|
import { useI18n } from "@homarr/translation/client";
|
||||||
|
import { validation } from "@homarr/validation";
|
||||||
|
|
||||||
|
import { BoardSelect } from "~/components/board/board-select";
|
||||||
|
|
||||||
|
interface GroupHomeBoardsProps {
|
||||||
|
homeBoardId: string | null;
|
||||||
|
mobileHomeBoardId: string | null;
|
||||||
|
groupId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const GroupHomeBoards = ({ homeBoardId, mobileHomeBoardId, groupId }: GroupHomeBoardsProps) => {
|
||||||
|
const t = useI18n();
|
||||||
|
const [availableBoards] = clientApi.board.getBoardsForGroup.useSuspenseQuery({ groupId });
|
||||||
|
const form = useZodForm(validation.group.settings.pick({ homeBoardId: true, mobileHomeBoardId: true }), {
|
||||||
|
initialValues: {
|
||||||
|
homeBoardId,
|
||||||
|
mobileHomeBoardId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const { mutateAsync, isPending } = clientApi.group.savePartialSettings.useMutation();
|
||||||
|
|
||||||
|
const handleSubmit = form.onSubmit(async (values) => {
|
||||||
|
await mutateAsync(
|
||||||
|
{
|
||||||
|
id: groupId,
|
||||||
|
settings: values,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onSuccess() {
|
||||||
|
form.setInitialValues(values);
|
||||||
|
showSuccessNotification({
|
||||||
|
title: t("group.action.settings.board.notification.success.title"),
|
||||||
|
message: t("group.action.settings.board.notification.success.message"),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onError() {
|
||||||
|
showErrorNotification({
|
||||||
|
title: t("group.action.settings.board.notification.error.title"),
|
||||||
|
message: t("group.action.settings.board.notification.error.message"),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={handleSubmit}>
|
||||||
|
<Stack gap="md">
|
||||||
|
<BoardSelect
|
||||||
|
label={t("group.field.homeBoard.label")}
|
||||||
|
description={t("group.field.homeBoard.description")}
|
||||||
|
clearable
|
||||||
|
boards={availableBoards}
|
||||||
|
{...form.getInputProps("homeBoardId")}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<BoardSelect
|
||||||
|
label={t("group.field.mobileBoard.label")}
|
||||||
|
description={t("group.field.mobileBoard.description")}
|
||||||
|
clearable
|
||||||
|
boards={availableBoards}
|
||||||
|
{...form.getInputProps("mobileHomeBoardId")}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Group justify="end">
|
||||||
|
<Button type="submit" color="teal" loading={isPending}>
|
||||||
|
{t("common.action.save")}
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { notFound } from "next/navigation";
|
||||||
|
import { Alert, Stack, Title } from "@mantine/core";
|
||||||
|
import { IconExclamationCircle } from "@tabler/icons-react";
|
||||||
|
|
||||||
|
import { api } from "@homarr/api/server";
|
||||||
|
import { auth } from "@homarr/auth/next";
|
||||||
|
import { getI18n } from "@homarr/translation/server";
|
||||||
|
|
||||||
|
import { GroupHomeBoards } from "./_group-home-boards";
|
||||||
|
|
||||||
|
interface GroupSettingsPageProps {
|
||||||
|
params: Promise<{
|
||||||
|
id: string;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function GroupPermissionsPage(props: GroupSettingsPageProps) {
|
||||||
|
const params = await props.params;
|
||||||
|
const session = await auth();
|
||||||
|
|
||||||
|
if (!session?.user.permissions.includes("admin")) {
|
||||||
|
notFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
const group = await api.group.getById({ id: params.id });
|
||||||
|
const t = await getI18n();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack>
|
||||||
|
<Title>{t("management.page.group.setting.setting.title")}</Title>
|
||||||
|
|
||||||
|
<Alert color="cyan" icon={<IconExclamationCircle size="1rem" stroke={1.5} />}>
|
||||||
|
{t("management.page.group.setting.setting.alert")}
|
||||||
|
</Alert>
|
||||||
|
|
||||||
|
<Title order={3}>{t("management.page.group.setting.setting.board.title")}</Title>
|
||||||
|
|
||||||
|
<GroupHomeBoards homeBoardId={group.homeBoardId} mobileHomeBoardId={group.mobileHomeBoardId} groupId={group.id} />
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useCallback } from "react";
|
|
||||||
|
|
||||||
import { useModalAction } from "@homarr/modals";
|
|
||||||
import { AddGroupModal } from "@homarr/modals-collection";
|
|
||||||
import { useI18n } from "@homarr/translation/client";
|
|
||||||
|
|
||||||
import { MobileAffixButton } from "~/components/manage/mobile-affix-button";
|
|
||||||
|
|
||||||
export const AddGroup = () => {
|
|
||||||
const t = useI18n();
|
|
||||||
const { openModal } = useModalAction(AddGroupModal);
|
|
||||||
|
|
||||||
const handleAddGroup = useCallback(() => {
|
|
||||||
openModal();
|
|
||||||
}, [openModal]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<MobileAffixButton onClick={handleAddGroup} color="teal">
|
|
||||||
{t("group.action.create.label")}
|
|
||||||
</MobileAffixButton>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useCallback, useMemo, useState } from "react";
|
||||||
|
import { Group, TextInput } from "@mantine/core";
|
||||||
|
import { IconSearch } from "@tabler/icons-react";
|
||||||
|
|
||||||
|
import type { RouterOutputs } from "@homarr/api";
|
||||||
|
import { useModalAction } from "@homarr/modals";
|
||||||
|
import { AddGroupModal } from "@homarr/modals-collection";
|
||||||
|
import { useI18n } from "@homarr/translation/client";
|
||||||
|
|
||||||
|
import { MobileAffixButton } from "~/components/manage/mobile-affix-button";
|
||||||
|
import { GroupsTable } from "./_groups-table";
|
||||||
|
|
||||||
|
interface GroupsListProps {
|
||||||
|
groups: RouterOutputs["group"]["getAll"];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const GroupsList = ({ groups }: GroupsListProps) => {
|
||||||
|
const [search, setSearch] = useState("");
|
||||||
|
const initialGroupIds = useMemo(
|
||||||
|
() => groups.sort((groupA, groupB) => groupA.position - groupB.position).map((group) => group.id),
|
||||||
|
[groups],
|
||||||
|
);
|
||||||
|
const filteredGroups = useMemo(
|
||||||
|
() =>
|
||||||
|
groups
|
||||||
|
.filter((group) => group.name.toLowerCase().includes(search.toLowerCase()))
|
||||||
|
.sort((groupA, groupB) => groupA.position - groupB.position),
|
||||||
|
[groups, search],
|
||||||
|
);
|
||||||
|
const t = useI18n();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Group justify="space-between">
|
||||||
|
<TextInput
|
||||||
|
leftSection={<IconSearch size={20} stroke={1.5} />}
|
||||||
|
value={search}
|
||||||
|
onChange={(event) => setSearch(event.currentTarget.value)}
|
||||||
|
placeholder={`${t("group.search")}...`}
|
||||||
|
style={{ flex: 1 }}
|
||||||
|
/>
|
||||||
|
<AddGroup />
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
<GroupsTable groups={filteredGroups} initialGroupIds={initialGroupIds} hasFilter={search.length !== 0} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const AddGroup = () => {
|
||||||
|
const t = useI18n();
|
||||||
|
const { openModal } = useModalAction(AddGroupModal);
|
||||||
|
|
||||||
|
const handleAddGroup = useCallback(() => {
|
||||||
|
openModal();
|
||||||
|
}, [openModal]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<MobileAffixButton onClick={handleAddGroup} color="teal">
|
||||||
|
{t("group.action.create.label")}
|
||||||
|
</MobileAffixButton>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,277 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import type { DragEndEvent, DraggableAttributes, DragStartEvent } from "@dnd-kit/core";
|
||||||
|
import {
|
||||||
|
closestCenter,
|
||||||
|
DndContext,
|
||||||
|
DragOverlay,
|
||||||
|
KeyboardSensor,
|
||||||
|
MouseSensor,
|
||||||
|
TouchSensor,
|
||||||
|
useSensor,
|
||||||
|
useSensors,
|
||||||
|
} from "@dnd-kit/core";
|
||||||
|
import type { SyntheticListenerMap } from "@dnd-kit/core/dist/hooks/utilities";
|
||||||
|
import { restrictToVerticalAxis } from "@dnd-kit/modifiers";
|
||||||
|
import { arrayMove, SortableContext, useSortable, verticalListSortingStrategy } from "@dnd-kit/sortable";
|
||||||
|
import { CSS } from "@dnd-kit/utilities";
|
||||||
|
import {
|
||||||
|
Anchor,
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
Flex,
|
||||||
|
Group,
|
||||||
|
Table,
|
||||||
|
TableTbody,
|
||||||
|
TableTd,
|
||||||
|
TableTh,
|
||||||
|
TableThead,
|
||||||
|
TableTr,
|
||||||
|
Text,
|
||||||
|
Transition,
|
||||||
|
} from "@mantine/core";
|
||||||
|
import { IconGripVertical } from "@tabler/icons-react";
|
||||||
|
|
||||||
|
import type { RouterOutputs } from "@homarr/api";
|
||||||
|
import { clientApi } from "@homarr/api/client";
|
||||||
|
import { revalidatePathActionAsync } from "@homarr/common/client";
|
||||||
|
import { showSuccessNotification } from "@homarr/notifications";
|
||||||
|
import { useI18n } from "@homarr/translation/client";
|
||||||
|
import { UserAvatarGroup } from "@homarr/ui";
|
||||||
|
|
||||||
|
interface GroupsTableProps {
|
||||||
|
initialGroupIds: string[];
|
||||||
|
groups: RouterOutputs["group"]["getAll"];
|
||||||
|
hasFilter: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const GroupsTable = ({ groups, initialGroupIds, hasFilter }: GroupsTableProps) => {
|
||||||
|
const t = useI18n();
|
||||||
|
const [activeId, setActiveId] = useState<string | null>(null);
|
||||||
|
const [groupIds, setGroupIds] = useState(groups.map((group) => group.id));
|
||||||
|
const isDirty = useMemo(
|
||||||
|
() => initialGroupIds.some((groupId, index) => groupIds.indexOf(groupId) !== index),
|
||||||
|
[groupIds, initialGroupIds],
|
||||||
|
);
|
||||||
|
const { mutateAsync, isPending } = clientApi.group.savePositions.useMutation();
|
||||||
|
const handleSavePositionsAsync = async () => {
|
||||||
|
await mutateAsync(
|
||||||
|
{ positions: groupIds },
|
||||||
|
{
|
||||||
|
async onSuccess() {
|
||||||
|
showSuccessNotification({
|
||||||
|
message: t("group.action.changePosition.notification.success.message"),
|
||||||
|
});
|
||||||
|
await revalidatePathActionAsync("/manage/users/groups");
|
||||||
|
},
|
||||||
|
onError() {
|
||||||
|
showSuccessNotification({
|
||||||
|
message: t("group.action.changePosition.notification.error.message"),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const sensors = useSensors(useSensor(MouseSensor, {}), useSensor(TouchSensor, {}), useSensor(KeyboardSensor, {}));
|
||||||
|
|
||||||
|
const handleDragStart = (event: DragStartEvent) => {
|
||||||
|
setActiveId(event.active.id as string);
|
||||||
|
};
|
||||||
|
|
||||||
|
function handleDragEnd(event: DragEndEvent) {
|
||||||
|
const { active, over } = event;
|
||||||
|
if (!over || active.id === over.id) {
|
||||||
|
setActiveId(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setGroupIds((groupIds) => {
|
||||||
|
const oldIndex = groupIds.indexOf(active.id as string);
|
||||||
|
const newIndex = groupIds.indexOf(over.id as string);
|
||||||
|
return arrayMove(groupIds, oldIndex, newIndex);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDragCancel() {
|
||||||
|
setActiveId(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectedRow = useMemo(() => {
|
||||||
|
if (!activeId) return null;
|
||||||
|
|
||||||
|
const current = groups.find((group) => group.id === activeId);
|
||||||
|
if (!current) return null;
|
||||||
|
|
||||||
|
return <Row group={current} handle={<DragHandle attributes={undefined} listeners={undefined} active />} />;
|
||||||
|
}, [activeId, groups]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<DndContext
|
||||||
|
sensors={sensors}
|
||||||
|
onDragEnd={handleDragEnd}
|
||||||
|
onDragStart={handleDragStart}
|
||||||
|
onDragCancel={handleDragCancel}
|
||||||
|
collisionDetection={closestCenter}
|
||||||
|
modifiers={[restrictToVerticalAxis]}
|
||||||
|
id="groups-table"
|
||||||
|
>
|
||||||
|
<Table striped highlightOnHover>
|
||||||
|
<TableThead>
|
||||||
|
<TableTr>
|
||||||
|
<TableTh>{t("group.field.name")}</TableTh>
|
||||||
|
<TableTh>{t("group.field.members")}</TableTh>
|
||||||
|
</TableTr>
|
||||||
|
</TableThead>
|
||||||
|
<TableTbody>
|
||||||
|
<SortableContext items={groupIds} strategy={verticalListSortingStrategy}>
|
||||||
|
{groupIds.map((groupId) => {
|
||||||
|
const group = groups.find(({ id }) => id === groupId);
|
||||||
|
if (!group) return null;
|
||||||
|
|
||||||
|
return <DraggableRow key={group.id} group={group} disabled={hasFilter} />;
|
||||||
|
})}
|
||||||
|
</SortableContext>
|
||||||
|
</TableTbody>
|
||||||
|
</Table>
|
||||||
|
|
||||||
|
<DragOverlay>
|
||||||
|
{activeId && (
|
||||||
|
<Table w="100%">
|
||||||
|
<TableTbody>{selectedRow}</TableTbody>
|
||||||
|
</Table>
|
||||||
|
)}
|
||||||
|
</DragOverlay>
|
||||||
|
</DndContext>
|
||||||
|
<SaveAffix
|
||||||
|
visible={isDirty}
|
||||||
|
onDiscard={() => setGroupIds(initialGroupIds)}
|
||||||
|
isPending={isPending}
|
||||||
|
onSave={handleSavePositionsAsync}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
interface DraggableRowProps {
|
||||||
|
group: RouterOutputs["group"]["getAll"][number];
|
||||||
|
disabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DraggableRow = ({ group, disabled }: DraggableRowProps) => {
|
||||||
|
const { attributes, listeners, transform, transition, setNodeRef, isDragging } = useSortable({
|
||||||
|
id: group.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
const style = {
|
||||||
|
transform: CSS.Transform.toString(transform),
|
||||||
|
transition,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isDragging) {
|
||||||
|
return (
|
||||||
|
<TableTr ref={setNodeRef} style={style}>
|
||||||
|
<TableTd colSpan={2}> </TableTd>
|
||||||
|
</TableTr>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Row
|
||||||
|
group={group}
|
||||||
|
setNodeRef={setNodeRef}
|
||||||
|
style={style}
|
||||||
|
handle={<DragHandle attributes={attributes} listeners={listeners} active={false} disabled={disabled} />}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
interface RowProps {
|
||||||
|
group: RouterOutputs["group"]["getAll"][number];
|
||||||
|
handle?: ReactNode;
|
||||||
|
setNodeRef?: (node: HTMLElement | null) => void;
|
||||||
|
style?: React.CSSProperties;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Row = ({ group, handle, setNodeRef, style }: RowProps) => {
|
||||||
|
return (
|
||||||
|
<TableTr ref={setNodeRef} style={style}>
|
||||||
|
<TableTd>
|
||||||
|
<Group>
|
||||||
|
{handle}
|
||||||
|
<Anchor component={Link} href={`/manage/users/groups/${group.id}`}>
|
||||||
|
{group.name}
|
||||||
|
</Anchor>
|
||||||
|
</Group>
|
||||||
|
</TableTd>
|
||||||
|
<TableTd>
|
||||||
|
<UserAvatarGroup users={group.members} size="sm" limit={5} />
|
||||||
|
</TableTd>
|
||||||
|
</TableTr>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
interface DragHandleProps {
|
||||||
|
attributes: DraggableAttributes | undefined;
|
||||||
|
listeners: SyntheticListenerMap | undefined;
|
||||||
|
active: boolean;
|
||||||
|
disabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DragHandle = ({ attributes, listeners, active, disabled }: DragHandleProps) => {
|
||||||
|
if (disabled) {
|
||||||
|
return <Box w={40} h="100%" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Flex
|
||||||
|
align="center"
|
||||||
|
justify="center"
|
||||||
|
h="100%"
|
||||||
|
w={40}
|
||||||
|
style={{ cursor: active ? "grabbing" : "grab" }}
|
||||||
|
{...attributes}
|
||||||
|
{...listeners}
|
||||||
|
>
|
||||||
|
<IconGripVertical size={18} stroke={1.5} />
|
||||||
|
</Flex>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
interface SaveAffixProps {
|
||||||
|
visible: boolean;
|
||||||
|
isPending: boolean;
|
||||||
|
onDiscard: () => void;
|
||||||
|
onSave: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SaveAffix = ({ visible, isPending, onDiscard, onSave }: SaveAffixProps) => {
|
||||||
|
const t = useI18n();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ position: "sticky", bottom: 20 }}>
|
||||||
|
<Transition transition="slide-up" mounted={visible}>
|
||||||
|
{(transitionStyles) => (
|
||||||
|
<Card style={transitionStyles} withBorder>
|
||||||
|
<Group justify="space-between">
|
||||||
|
<Text fw={500}>{t("common.unsavedChanges")}</Text>
|
||||||
|
<Group>
|
||||||
|
<Button disabled={isPending} onClick={onDiscard}>
|
||||||
|
{t("common.action.discard")}
|
||||||
|
</Button>
|
||||||
|
<Button color="teal" loading={isPending} onClick={onSave}>
|
||||||
|
{t("common.action.saveChanges")}
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Group>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</Transition>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
.everyoneGroup {
|
||||||
|
background-color: light-dark(var(--mantine-color-gray-1), var(--mantine-color-dark-6));
|
||||||
|
}
|
||||||
|
|
||||||
|
.everyoneGroup:hover {
|
||||||
|
background-color: light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-5));
|
||||||
|
}
|
||||||
@@ -1,30 +1,19 @@
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { notFound } from "next/navigation";
|
import { notFound } from "next/navigation";
|
||||||
import { Anchor, Group, Stack, Table, TableTbody, TableTd, TableTh, TableThead, TableTr, Title } from "@mantine/core";
|
import { Card, Group, Stack, Text, ThemeIcon, Title, UnstyledButton } from "@mantine/core";
|
||||||
|
import { IconChevronRight, IconUsersGroup } from "@tabler/icons-react";
|
||||||
|
|
||||||
import type { RouterOutputs } from "@homarr/api";
|
|
||||||
import { api } from "@homarr/api/server";
|
import { api } from "@homarr/api/server";
|
||||||
import { auth } from "@homarr/auth/next";
|
import { auth } from "@homarr/auth/next";
|
||||||
import type { inferSearchParamsFromSchema } from "@homarr/common/types";
|
import { everyoneGroup } from "@homarr/definitions";
|
||||||
import { getI18n } from "@homarr/translation/server";
|
import { getI18n } from "@homarr/translation/server";
|
||||||
import { SearchInput, TablePagination, UserAvatarGroup } from "@homarr/ui";
|
|
||||||
import { z } from "@homarr/validation";
|
|
||||||
|
|
||||||
import { ManageContainer } from "~/components/manage/manage-container";
|
import { ManageContainer } from "~/components/manage/manage-container";
|
||||||
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
|
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
|
||||||
import { AddGroup } from "./_add-group";
|
import { GroupsList } from "./_client";
|
||||||
|
import classes from "./groups.module.css";
|
||||||
|
|
||||||
const searchParamsSchema = z.object({
|
export default async function GroupsListPage() {
|
||||||
search: z.string().optional(),
|
|
||||||
pageSize: z.string().regex(/\d+/).transform(Number).catch(10),
|
|
||||||
page: z.string().regex(/\d+/).transform(Number).catch(1),
|
|
||||||
});
|
|
||||||
|
|
||||||
interface GroupsListPageProps {
|
|
||||||
searchParams: Promise<inferSearchParamsFromSchema<typeof searchParamsSchema>>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default async function GroupsListPage(props: GroupsListPageProps) {
|
|
||||||
const session = await auth();
|
const session = await auth();
|
||||||
|
|
||||||
if (!session?.user.permissions.includes("admin")) {
|
if (!session?.user.permissions.includes("admin")) {
|
||||||
@@ -32,55 +21,38 @@ export default async function GroupsListPage(props: GroupsListPageProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const t = await getI18n();
|
const t = await getI18n();
|
||||||
const searchParams = searchParamsSchema.parse(await props.searchParams);
|
const groups = await api.group.getAll();
|
||||||
const { items: groups, totalCount } = await api.group.getPaginated(searchParams);
|
const dbEveryoneGroup = groups.find((group) => group.name === everyoneGroup);
|
||||||
|
const groupsWithoutEveryone = groups.filter((group) => group.name !== everyoneGroup);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ManageContainer size="xl">
|
<ManageContainer size="xl">
|
||||||
<DynamicBreadcrumb />
|
<DynamicBreadcrumb />
|
||||||
<Stack>
|
<Stack>
|
||||||
<Title>{t("group.title")}</Title>
|
<Title>{t("group.title")}</Title>
|
||||||
<Group justify="space-between">
|
|
||||||
<SearchInput placeholder={`${t("group.search")}...`} defaultValue={searchParams.search} />
|
|
||||||
<AddGroup />
|
|
||||||
</Group>
|
|
||||||
<Table striped highlightOnHover>
|
|
||||||
<TableThead>
|
|
||||||
<TableTr>
|
|
||||||
<TableTh>{t("group.field.name")}</TableTh>
|
|
||||||
<TableTh>{t("group.field.members")}</TableTh>
|
|
||||||
</TableTr>
|
|
||||||
</TableThead>
|
|
||||||
<TableTbody>
|
|
||||||
{groups.map((group) => (
|
|
||||||
<Row key={group.id} group={group} />
|
|
||||||
))}
|
|
||||||
</TableTbody>
|
|
||||||
</Table>
|
|
||||||
|
|
||||||
<Group justify="end">
|
{dbEveryoneGroup && (
|
||||||
<TablePagination total={Math.ceil(totalCount / searchParams.pageSize)} />
|
<UnstyledButton component={Link} href={`/manage/users/groups/${dbEveryoneGroup.id}`}>
|
||||||
</Group>
|
<Card className={classes.everyoneGroup}>
|
||||||
|
<Group align="center">
|
||||||
|
<ThemeIcon radius="xl" variant="light">
|
||||||
|
<IconUsersGroup size={16} />
|
||||||
|
</ThemeIcon>
|
||||||
|
|
||||||
|
<Stack gap={0} flex={1}>
|
||||||
|
<Text fw={500}>{t("group.defaultGroup.name")}</Text>
|
||||||
|
<Text size="sm" c="gray.6">
|
||||||
|
{t("group.defaultGroup.description", { name: everyoneGroup })}
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
<IconChevronRight size={20} />
|
||||||
|
</Group>
|
||||||
|
</Card>
|
||||||
|
</UnstyledButton>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<GroupsList groups={groupsWithoutEveryone} />
|
||||||
</Stack>
|
</Stack>
|
||||||
</ManageContainer>
|
</ManageContainer>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
interface RowProps {
|
|
||||||
group: RouterOutputs["group"]["getPaginated"]["items"][number];
|
|
||||||
}
|
|
||||||
|
|
||||||
const Row = ({ group }: RowProps) => {
|
|
||||||
return (
|
|
||||||
<TableTr>
|
|
||||||
<TableTd>
|
|
||||||
<Anchor component={Link} href={`/manage/users/groups/${group.id}`}>
|
|
||||||
{group.name}
|
|
||||||
</Anchor>
|
|
||||||
</TableTd>
|
|
||||||
<TableTd>
|
|
||||||
<UserAvatarGroup users={group.members} size="sm" limit={5} />
|
|
||||||
</TableTd>
|
|
||||||
</TableTr>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -9,6 +9,7 @@ import { ErrorBoundary } from "react-error-boundary";
|
|||||||
import type { IntegrationKind, WidgetKind } from "@homarr/definitions";
|
import type { IntegrationKind, WidgetKind } from "@homarr/definitions";
|
||||||
import { useModalAction } from "@homarr/modals";
|
import { useModalAction } from "@homarr/modals";
|
||||||
import { showSuccessNotification } from "@homarr/notifications";
|
import { showSuccessNotification } from "@homarr/notifications";
|
||||||
|
import { useSettings } from "@homarr/settings";
|
||||||
import { useScopedI18n } from "@homarr/translation/client";
|
import { useScopedI18n } from "@homarr/translation/client";
|
||||||
import type { BoardItemAdvancedOptions } from "@homarr/validation";
|
import type { BoardItemAdvancedOptions } from "@homarr/validation";
|
||||||
import { loadWidgetDynamic, reduceWidgetOptionsWithDefaultValues, widgetImports } from "@homarr/widgets";
|
import { loadWidgetDynamic, reduceWidgetOptionsWithDefaultValues, widgetImports } from "@homarr/widgets";
|
||||||
@@ -29,6 +30,7 @@ interface WidgetPreviewPageContentProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const WidgetPreviewPageContent = ({ kind, integrationData }: WidgetPreviewPageContentProps) => {
|
export const WidgetPreviewPageContent = ({ kind, integrationData }: WidgetPreviewPageContentProps) => {
|
||||||
|
const settings = useSettings();
|
||||||
const t = useScopedI18n("widgetPreview");
|
const t = useScopedI18n("widgetPreview");
|
||||||
const { openModal: openWidgetEditModal } = useModalAction(WidgetEditModal);
|
const { openModal: openWidgetEditModal } = useModalAction(WidgetEditModal);
|
||||||
const { openModal: openPreviewDimensionsModal } = useModalAction(PreviewDimensionsModal);
|
const { openModal: openPreviewDimensionsModal } = useModalAction(PreviewDimensionsModal);
|
||||||
@@ -43,7 +45,7 @@ export const WidgetPreviewPageContent = ({ kind, integrationData }: WidgetPrevie
|
|||||||
integrationIds: string[];
|
integrationIds: string[];
|
||||||
advancedOptions: BoardItemAdvancedOptions;
|
advancedOptions: BoardItemAdvancedOptions;
|
||||||
}>({
|
}>({
|
||||||
options: reduceWidgetOptionsWithDefaultValues(kind, {}),
|
options: reduceWidgetOptionsWithDefaultValues(kind, settings, {}),
|
||||||
integrationIds: [],
|
integrationIds: [],
|
||||||
advancedOptions: {
|
advancedOptions: {
|
||||||
customCssClasses: [],
|
customCssClasses: [],
|
||||||
@@ -63,8 +65,9 @@ export const WidgetPreviewPageContent = ({ kind, integrationData }: WidgetPrevie
|
|||||||
(currentDefinition.supportedIntegrations as string[]).some((kind) => kind === integration.kind),
|
(currentDefinition.supportedIntegrations as string[]).some((kind) => kind === integration.kind),
|
||||||
),
|
),
|
||||||
integrationSupport: "supportedIntegrations" in currentDefinition,
|
integrationSupport: "supportedIntegrations" in currentDefinition,
|
||||||
|
settings,
|
||||||
});
|
});
|
||||||
}, [currentDefinition, integrationData, kind, openWidgetEditModal, state]);
|
}, [currentDefinition, integrationData, kind, openWidgetEditModal, settings, state]);
|
||||||
|
|
||||||
const Comp = loadWidgetDynamic(kind);
|
const Comp = loadWidgetDynamic(kind);
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import { notFound } from "next/navigation";
|
import { notFound } from "next/navigation";
|
||||||
import { Center } from "@mantine/core";
|
import { Center } from "@mantine/core";
|
||||||
|
|
||||||
|
import { env } from "@homarr/common/env";
|
||||||
import { db } from "@homarr/db";
|
import { db } from "@homarr/db";
|
||||||
import type { WidgetKind } from "@homarr/definitions";
|
import type { WidgetKind } from "@homarr/definitions";
|
||||||
import { widgetImports } from "@homarr/widgets";
|
import { widgetImports } from "@homarr/widgets";
|
||||||
|
|
||||||
import { env } from "~/env";
|
|
||||||
import { WidgetPreviewPageContent } from "./_content";
|
import { WidgetPreviewPageContent } from "./_content";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { fetchRequestHandler } from "@trpc/server/adapters/fetch";
|
import { fetchRequestHandler } from "@trpc/server/adapters/fetch";
|
||||||
|
|
||||||
import { appRouter, createTRPCContext } from "@homarr/api";
|
import { appRouter, createTRPCContext } from "@homarr/api";
|
||||||
|
import { trpcPath } from "@homarr/api/shared";
|
||||||
import { auth } from "@homarr/auth/next";
|
import { auth } from "@homarr/auth/next";
|
||||||
import { logger } from "@homarr/log";
|
import { logger } from "@homarr/log";
|
||||||
|
|
||||||
@@ -25,7 +26,7 @@ export function OPTIONS() {
|
|||||||
|
|
||||||
const handler = auth(async (req) => {
|
const handler = auth(async (req) => {
|
||||||
const response = await fetchRequestHandler({
|
const response = await fetchRequestHandler({
|
||||||
endpoint: "/api/trpc",
|
endpoint: trpcPath,
|
||||||
router: appRouter,
|
router: appRouter,
|
||||||
req,
|
req,
|
||||||
createContext: () => createTRPCContext({ session: req.auth, headers: req.headers }),
|
createContext: () => createTRPCContext({ session: req.auth, headers: req.headers }),
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { Group, Text } from "@mantine/core";
|
||||||
|
import { IconLayoutDashboard } from "@tabler/icons-react";
|
||||||
|
|
||||||
|
import type { SelectWithCustomItemsProps } from "@homarr/ui";
|
||||||
|
import { SelectWithCustomItems } from "@homarr/ui";
|
||||||
|
|
||||||
|
import type { Board } from "~/app/[locale]/boards/_types";
|
||||||
|
|
||||||
|
interface BoardSelectProps extends Omit<SelectWithCustomItemsProps<{ value: string; label: string }>, "data"> {
|
||||||
|
boards: Pick<Board, "id" | "name" | "logoImageUrl">[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const BoardSelect = ({ boards, ...props }: BoardSelectProps) => {
|
||||||
|
return (
|
||||||
|
<SelectWithCustomItems
|
||||||
|
{...props}
|
||||||
|
data={boards.map((board) => ({
|
||||||
|
value: board.id,
|
||||||
|
label: board.name,
|
||||||
|
image: board.logoImageUrl,
|
||||||
|
}))}
|
||||||
|
SelectOption={({ label, image }: { value: string; label: string; image: string | null }) => (
|
||||||
|
<Group>
|
||||||
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||||
|
{image ? <img width={16} height={16} src={image} alt={label} /> : <IconLayoutDashboard size={16} />}
|
||||||
|
<Text fz="sm" fw={500}>
|
||||||
|
{label}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,9 +1,11 @@
|
|||||||
|
import { getBoardLayouts } from "@homarr/boards/context";
|
||||||
import type { Modify } from "@homarr/common/types";
|
import type { Modify } from "@homarr/common/types";
|
||||||
import { createId } from "@homarr/db/client";
|
import { createId } from "@homarr/db/client";
|
||||||
import type { WidgetKind } from "@homarr/definitions";
|
import type { WidgetKind } from "@homarr/definitions";
|
||||||
|
|
||||||
import type { Board, DynamicSection, EmptySection, Item } from "~/app/[locale]/boards/_types";
|
import type { Board, EmptySection, Item, ItemLayout } from "~/app/[locale]/boards/_types";
|
||||||
import { getFirstEmptyPosition } from "./empty-position";
|
import { getFirstEmptyPosition } from "./empty-position";
|
||||||
|
import { getSectionElements } from "./section-elements";
|
||||||
|
|
||||||
export interface CreateItemInput {
|
export interface CreateItemInput {
|
||||||
kind: WidgetKind;
|
kind: WidgetKind;
|
||||||
@@ -19,24 +21,11 @@ export const createItemCallback =
|
|||||||
|
|
||||||
if (!firstSection) return previous;
|
if (!firstSection) return previous;
|
||||||
|
|
||||||
const dynamicSectionsOfFirstSection = previous.sections.filter(
|
|
||||||
(section): section is DynamicSection => section.kind === "dynamic" && section.parentSectionId === firstSection.id,
|
|
||||||
);
|
|
||||||
const elements = [...firstSection.items, ...dynamicSectionsOfFirstSection];
|
|
||||||
const emptyPosition = getFirstEmptyPosition(elements, previous.columnCount);
|
|
||||||
|
|
||||||
if (!emptyPosition) {
|
|
||||||
console.error("Your board is full");
|
|
||||||
return previous;
|
|
||||||
}
|
|
||||||
|
|
||||||
const widget = {
|
const widget = {
|
||||||
id: createId(),
|
id: createId(),
|
||||||
kind,
|
kind,
|
||||||
options: {},
|
options: {},
|
||||||
width: 1,
|
layouts: createItemLayouts(previous, firstSection),
|
||||||
height: 1,
|
|
||||||
...emptyPosition,
|
|
||||||
integrationIds: [],
|
integrationIds: [],
|
||||||
advancedOptions: {
|
advancedOptions: {
|
||||||
customCssClasses: [],
|
customCssClasses: [],
|
||||||
@@ -50,13 +39,31 @@ export const createItemCallback =
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
...previous,
|
...previous,
|
||||||
sections: previous.sections.map((section) => {
|
items: previous.items.concat(widget),
|
||||||
// Return same section if item is not in it
|
|
||||||
if (section.id !== firstSection.id) return section;
|
|
||||||
return {
|
|
||||||
...section,
|
|
||||||
items: section.items.concat(widget),
|
|
||||||
};
|
|
||||||
}),
|
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const createItemLayouts = (board: Board, currentSection: EmptySection): ItemLayout[] => {
|
||||||
|
const layouts = getBoardLayouts(board);
|
||||||
|
|
||||||
|
return layouts.map((layoutId) => {
|
||||||
|
const boardLayout = board.layouts.find((layout) => layout.id === layoutId);
|
||||||
|
const elements = getSectionElements(board, { sectionId: currentSection.id, layoutId });
|
||||||
|
|
||||||
|
const emptyPosition = boardLayout
|
||||||
|
? getFirstEmptyPosition(elements, boardLayout.columnCount)
|
||||||
|
: { xOffset: 0, yOffset: 0 };
|
||||||
|
|
||||||
|
if (!emptyPosition) {
|
||||||
|
throw new Error("Your board is full");
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
width: 1,
|
||||||
|
height: 1,
|
||||||
|
...emptyPosition,
|
||||||
|
sectionId: currentSection.id,
|
||||||
|
layoutId,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
import { createId } from "@homarr/db/client";
|
import { createId } from "@homarr/db/client";
|
||||||
|
|
||||||
import type { Board, DynamicSection, EmptySection } from "~/app/[locale]/boards/_types";
|
import type { Board, EmptySection, ItemLayout, Section } from "~/app/[locale]/boards/_types";
|
||||||
import { getFirstEmptyPosition } from "./empty-position";
|
import { getFirstEmptyPosition } from "./empty-position";
|
||||||
|
import { getSectionElements } from "./section-elements";
|
||||||
|
|
||||||
export interface DuplicateItemInput {
|
export interface DuplicateItemInput {
|
||||||
itemId: string;
|
itemId: string;
|
||||||
@@ -10,72 +11,78 @@ export interface DuplicateItemInput {
|
|||||||
export const duplicateItemCallback =
|
export const duplicateItemCallback =
|
||||||
({ itemId }: DuplicateItemInput) =>
|
({ itemId }: DuplicateItemInput) =>
|
||||||
(previous: Board): Board => {
|
(previous: Board): Board => {
|
||||||
const itemToDuplicate = previous.sections
|
const itemToDuplicate = previous.items.find((item) => item.id === itemId);
|
||||||
.flatMap((section) => section.items.map((item) => ({ ...item, sectionId: section.id })))
|
|
||||||
.find((item) => item.id === itemId);
|
|
||||||
if (!itemToDuplicate) return previous;
|
if (!itemToDuplicate) return previous;
|
||||||
|
|
||||||
const currentSection = previous.sections.find((section) => section.id === itemToDuplicate.sectionId);
|
const clonedItem = structuredClone(itemToDuplicate);
|
||||||
if (!currentSection) return previous;
|
|
||||||
|
|
||||||
const dynamicSectionsOfCurrentSection = previous.sections.filter(
|
return {
|
||||||
(section): section is DynamicSection =>
|
|
||||||
section.kind === "dynamic" && section.parentSectionId === currentSection.id,
|
|
||||||
);
|
|
||||||
const elements = [...currentSection.items, ...dynamicSectionsOfCurrentSection];
|
|
||||||
let sectionId = currentSection.id;
|
|
||||||
let emptyPosition = getFirstEmptyPosition(
|
|
||||||
elements,
|
|
||||||
currentSection.kind === "dynamic" ? currentSection.width : previous.columnCount,
|
|
||||||
currentSection.kind === "dynamic" ? currentSection.height : undefined,
|
|
||||||
{
|
|
||||||
width: itemToDuplicate.width,
|
|
||||||
height: itemToDuplicate.height,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!emptyPosition) {
|
|
||||||
const firstSection = previous.sections
|
|
||||||
.filter((section): section is EmptySection => section.kind === "empty")
|
|
||||||
.sort((sectionA, sectionB) => sectionA.yOffset - sectionB.yOffset)
|
|
||||||
.at(0);
|
|
||||||
|
|
||||||
if (!firstSection) return previous;
|
|
||||||
|
|
||||||
const dynamicSectionsOfFirstSection = previous.sections.filter(
|
|
||||||
(section): section is DynamicSection =>
|
|
||||||
section.kind === "dynamic" && section.parentSectionId === firstSection.id,
|
|
||||||
);
|
|
||||||
const elements = [...firstSection.items, ...dynamicSectionsOfFirstSection];
|
|
||||||
emptyPosition = getFirstEmptyPosition(elements, previous.columnCount, undefined, {
|
|
||||||
width: itemToDuplicate.width,
|
|
||||||
height: itemToDuplicate.height,
|
|
||||||
});
|
|
||||||
if (!emptyPosition) {
|
|
||||||
console.error("Your board is full");
|
|
||||||
return previous;
|
|
||||||
}
|
|
||||||
|
|
||||||
sectionId = firstSection.id;
|
|
||||||
}
|
|
||||||
|
|
||||||
const widget = structuredClone(itemToDuplicate);
|
|
||||||
widget.id = createId();
|
|
||||||
widget.xOffset = emptyPosition.xOffset;
|
|
||||||
widget.yOffset = emptyPosition.yOffset;
|
|
||||||
widget.sectionId = sectionId;
|
|
||||||
|
|
||||||
const result = {
|
|
||||||
...previous,
|
...previous,
|
||||||
sections: previous.sections.map((section) => {
|
items: previous.items.concat({
|
||||||
// Return same section if item is not in it
|
...clonedItem,
|
||||||
if (section.id !== sectionId) return section;
|
id: createId(),
|
||||||
return {
|
layouts: clonedItem.layouts.map((layout) => ({
|
||||||
...section,
|
...layout,
|
||||||
items: section.items.concat(widget),
|
...getNextPosition(previous, layout),
|
||||||
};
|
})),
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
|
|
||||||
return result;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const getNextPosition = (board: Board, layout: ItemLayout): { xOffset: number; yOffset: number; sectionId: string } => {
|
||||||
|
const currentSection = board.sections.find((section) => section.id === layout.sectionId);
|
||||||
|
if (currentSection) {
|
||||||
|
const emptySectionPosition = getEmptySectionPosition(board, layout, currentSection);
|
||||||
|
if (emptySectionPosition) {
|
||||||
|
return {
|
||||||
|
...emptySectionPosition,
|
||||||
|
sectionId: currentSection.id,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const firstSection = board.sections
|
||||||
|
.filter((section): section is EmptySection => section.kind === "empty")
|
||||||
|
.sort((sectionA, sectionB) => sectionA.yOffset - sectionB.yOffset)
|
||||||
|
.at(0);
|
||||||
|
|
||||||
|
if (!firstSection) {
|
||||||
|
throw new Error("Your board is full. reason='no first section'");
|
||||||
|
}
|
||||||
|
|
||||||
|
const emptySectionPosition = getEmptySectionPosition(board, layout, firstSection);
|
||||||
|
|
||||||
|
if (!emptySectionPosition) {
|
||||||
|
throw new Error("Your board is full. reason='no empty positions'");
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...emptySectionPosition,
|
||||||
|
sectionId: firstSection.id,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const getEmptySectionPosition = (
|
||||||
|
board: Board,
|
||||||
|
layout: ItemLayout,
|
||||||
|
section: Section,
|
||||||
|
): { xOffset: number; yOffset: number } | undefined => {
|
||||||
|
const boardLayout = board.layouts.find((boardLayout) => boardLayout.id === layout.layoutId);
|
||||||
|
if (!boardLayout) return;
|
||||||
|
|
||||||
|
const sectionElements = getSectionElements(board, { sectionId: layout.sectionId, layoutId: layout.layoutId });
|
||||||
|
if (section.kind !== "dynamic") {
|
||||||
|
return getFirstEmptyPosition(sectionElements, boardLayout.columnCount, undefined, {
|
||||||
|
width: layout.width,
|
||||||
|
height: layout.height,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const sectionLayout = section.layouts.find((sectionLayout) => sectionLayout.layoutId === layout.layoutId);
|
||||||
|
if (!sectionLayout) return;
|
||||||
|
|
||||||
|
return getFirstEmptyPosition(sectionElements, sectionLayout.width, sectionLayout.height, {
|
||||||
|
width: layout.width,
|
||||||
|
height: layout.height,
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { Item } from "~/app/[locale]/boards/_types";
|
import type { SectionItem } from "~/app/[locale]/boards/_types";
|
||||||
|
|
||||||
export const getFirstEmptyPosition = (
|
export const getFirstEmptyPosition = (
|
||||||
elements: Pick<Item, "yOffset" | "xOffset" | "width" | "height">[],
|
elements: Pick<SectionItem, "yOffset" | "xOffset" | "width" | "height">[],
|
||||||
columnCount: number,
|
columnCount: number,
|
||||||
rowCount = 9999,
|
rowCount = 9999,
|
||||||
size: { width: number; height: number } = { width: 1, height: 1 },
|
size: { width: number; height: number } = { width: 1, height: 1 },
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { getCurrentLayout } from "@homarr/boards/context";
|
||||||
|
|
||||||
|
import type { Board } from "~/app/[locale]/boards/_types";
|
||||||
|
|
||||||
|
export interface MoveAndResizeItemInput {
|
||||||
|
itemId: string;
|
||||||
|
xOffset: number;
|
||||||
|
yOffset: number;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const moveAndResizeItemCallback =
|
||||||
|
({ itemId, ...layoutInput }: MoveAndResizeItemInput) =>
|
||||||
|
(previous: Board): Board => {
|
||||||
|
const currentLayout = getCurrentLayout(previous);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...previous,
|
||||||
|
items: previous.items.map((item) =>
|
||||||
|
item.id !== itemId
|
||||||
|
? item
|
||||||
|
: {
|
||||||
|
...item,
|
||||||
|
layouts: item.layouts.map((layout) =>
|
||||||
|
layout.layoutId !== currentLayout
|
||||||
|
? layout
|
||||||
|
: {
|
||||||
|
...layout,
|
||||||
|
...layoutInput,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import { getCurrentLayout } from "@homarr/boards/context";
|
||||||
|
|
||||||
|
import type { Board } from "~/app/[locale]/boards/_types";
|
||||||
|
|
||||||
|
export interface MoveItemToSectionInput {
|
||||||
|
itemId: string;
|
||||||
|
sectionId: string;
|
||||||
|
xOffset: number;
|
||||||
|
yOffset: number;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const moveItemToSectionCallback =
|
||||||
|
({ itemId, ...layoutInput }: MoveItemToSectionInput) =>
|
||||||
|
(board: Board): Board => {
|
||||||
|
const currentLayout = getCurrentLayout(board);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...board,
|
||||||
|
items: board.items.map((item) =>
|
||||||
|
item.id !== itemId
|
||||||
|
? item
|
||||||
|
: {
|
||||||
|
...item,
|
||||||
|
layouts: item.layouts.map((layout) =>
|
||||||
|
layout.layoutId !== currentLayout
|
||||||
|
? layout
|
||||||
|
: {
|
||||||
|
...layout,
|
||||||
|
...layoutInput,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import type { Board } from "~/app/[locale]/boards/_types";
|
||||||
|
|
||||||
|
export interface RemoveItemInput {
|
||||||
|
itemId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const removeItemCallback =
|
||||||
|
({ itemId }: RemoveItemInput) =>
|
||||||
|
(board: Board): Board => ({
|
||||||
|
...board,
|
||||||
|
items: board.items.filter((item) => item.id !== itemId),
|
||||||
|
});
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import type { Board } from "~/app/[locale]/boards/_types";
|
||||||
|
|
||||||
|
export const getSectionElements = (board: Board, { sectionId, layoutId }: { sectionId: string; layoutId: string }) => {
|
||||||
|
const dynamicSectionsOfFirstSection = board.sections
|
||||||
|
.filter((section) => section.kind === "dynamic")
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||||
|
.map(({ layouts, ...section }) => ({ ...section, ...layouts.find((layout) => layout.layoutId === layoutId)! }))
|
||||||
|
.filter((section) => section.parentSectionId === sectionId);
|
||||||
|
const items = board.items
|
||||||
|
.map(({ layouts, ...item }) => ({
|
||||||
|
...item,
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||||
|
...layouts.find((layout) => layout.layoutId === layoutId)!,
|
||||||
|
}))
|
||||||
|
.filter((item) => item.sectionId === sectionId);
|
||||||
|
|
||||||
|
return [...items, ...dynamicSectionsOfFirstSection];
|
||||||
|
};
|
||||||
@@ -1,61 +1,109 @@
|
|||||||
import { describe, expect, test, vi } from "vitest";
|
import { describe, expect, test, vi } from "vitest";
|
||||||
|
|
||||||
import type { Board } from "~/app/[locale]/boards/_types";
|
import * as boardContext from "@homarr/boards/context";
|
||||||
|
|
||||||
import { createItemCallback } from "../create-item";
|
import { createItemCallback } from "../create-item";
|
||||||
import * as emptyPosition from "../empty-position";
|
import * as emptyPositionModule from "../empty-position";
|
||||||
import { createDynamicSection, createEmptySection, createItem } from "./shared";
|
import { BoardMockBuilder } from "./mocks/board-mock";
|
||||||
|
import { DynamicSectionMockBuilder } from "./mocks/dynamic-section-mock";
|
||||||
|
import { ItemMockBuilder } from "./mocks/item-mock";
|
||||||
|
import { LayoutMockBuilder } from "./mocks/layout-mock";
|
||||||
|
|
||||||
describe("item actions create-item", () => {
|
describe("item actions create-item", () => {
|
||||||
test("should add it to first section", () => {
|
test("should add it to first section", () => {
|
||||||
const spy = vi.spyOn(emptyPosition, "getFirstEmptyPosition");
|
// Arrange
|
||||||
spy.mockReturnValue({ xOffset: 5, yOffset: 5 });
|
const itemKind = "clock";
|
||||||
const input = {
|
const emptyPosition = { xOffset: 5, yOffset: 5 };
|
||||||
sections: [createEmptySection("1", 2), createEmptySection("2", 0), createEmptySection("3", 1)],
|
const firstSectionId = "2";
|
||||||
columnCount: 4,
|
const layoutId = "1";
|
||||||
} satisfies Pick<Board, "sections" | "columnCount">;
|
|
||||||
|
|
||||||
|
const layout = new LayoutMockBuilder({ id: layoutId, columnCount: 4 }).build();
|
||||||
|
const board = new BoardMockBuilder()
|
||||||
|
.addLayout(layout)
|
||||||
|
.addLayout()
|
||||||
|
.addEmptySection({ id: "1", yOffset: 2 })
|
||||||
|
.addEmptySection({ id: firstSectionId, yOffset: 0 })
|
||||||
|
.addEmptySection({ id: "3", yOffset: 1 })
|
||||||
|
.build();
|
||||||
|
|
||||||
|
const emptyPositionSpy = vi.spyOn(emptyPositionModule, "getFirstEmptyPosition");
|
||||||
|
emptyPositionSpy.mockReturnValue(emptyPosition);
|
||||||
|
const layoutsSpy = vi.spyOn(boardContext, "getBoardLayouts");
|
||||||
|
layoutsSpy.mockReturnValue([layoutId]);
|
||||||
|
|
||||||
|
// Act
|
||||||
const result = createItemCallback({
|
const result = createItemCallback({
|
||||||
kind: "clock",
|
kind: itemKind,
|
||||||
})(input as unknown as Board);
|
})(board);
|
||||||
|
|
||||||
const firstSection = result.sections.find((section) => section.id === "2");
|
// Assert
|
||||||
const item = firstSection?.items.at(0);
|
const item = result.items.at(0);
|
||||||
expect(item).toEqual(expect.objectContaining({ kind: "clock", xOffset: 5, yOffset: 5 }));
|
expect(item).toEqual(
|
||||||
expect(spy).toHaveBeenCalledWith([], input.columnCount);
|
expect.objectContaining({
|
||||||
|
kind: itemKind,
|
||||||
|
layouts: [
|
||||||
|
{
|
||||||
|
layoutId,
|
||||||
|
height: 1,
|
||||||
|
width: 1,
|
||||||
|
...emptyPosition,
|
||||||
|
sectionId: firstSectionId,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(emptyPositionSpy).toHaveBeenCalledWith([], layout.columnCount);
|
||||||
});
|
});
|
||||||
test("should correctly pass dynamic section and items to getFirstEmptyPosition", () => {
|
test("should correctly pass dynamic section and items to getFirstEmptyPosition", () => {
|
||||||
const spy = vi.spyOn(emptyPosition, "getFirstEmptyPosition");
|
// Arrange
|
||||||
spy.mockReturnValue({ xOffset: 5, yOffset: 5 });
|
const itemKind = "clock";
|
||||||
const firstSection = createEmptySection("2", 0);
|
const emptyPosition = { xOffset: 5, yOffset: 5 };
|
||||||
const expectedItem = createItem({ id: "12", xOffset: 1, yOffset: 2, width: 3, height: 2 });
|
const firstSectionId = "2";
|
||||||
firstSection.items.push(expectedItem);
|
const layoutId = "1";
|
||||||
const dynamicSectionInFirst = createDynamicSection({
|
const itemAndSectionPosition = { height: 2, width: 3, yOffset: 2, xOffset: 1 };
|
||||||
id: "4",
|
|
||||||
parentSectionId: "2",
|
|
||||||
yOffset: 0,
|
|
||||||
xOffset: 0,
|
|
||||||
width: 2,
|
|
||||||
height: 2,
|
|
||||||
});
|
|
||||||
|
|
||||||
const input = {
|
const layout = new LayoutMockBuilder({ id: layoutId, columnCount: 4 }).build();
|
||||||
sections: [
|
const dynamicSectionInFirstSection = new DynamicSectionMockBuilder({ id: "4" })
|
||||||
createEmptySection("1", 2),
|
.addLayout({ ...itemAndSectionPosition, layoutId, parentSectionId: firstSectionId })
|
||||||
firstSection,
|
.build();
|
||||||
createEmptySection("3", 1),
|
const itemInFirstSection = new ItemMockBuilder({ id: "12" })
|
||||||
dynamicSectionInFirst,
|
.addLayout({ ...itemAndSectionPosition, layoutId, sectionId: firstSectionId })
|
||||||
createDynamicSection({ id: "5", parentSectionId: "3", yOffset: 1 }),
|
.build();
|
||||||
],
|
const otherDynamicSection = new DynamicSectionMockBuilder({ id: "5" }).addLayout({ layoutId }).build();
|
||||||
columnCount: 4,
|
const otherItem = new ItemMockBuilder({ id: "13" }).addLayout({ layoutId }).build();
|
||||||
} satisfies Pick<Board, "sections" | "columnCount">;
|
const board = new BoardMockBuilder()
|
||||||
|
.addLayout(layout)
|
||||||
|
.addEmptySection({ id: "1", yOffset: 2 })
|
||||||
|
.addEmptySection({ id: firstSectionId, yOffset: 0 })
|
||||||
|
.addEmptySection({ id: "3", yOffset: 1 })
|
||||||
|
.addSection(dynamicSectionInFirstSection)
|
||||||
|
.addSection(otherDynamicSection)
|
||||||
|
.addItem(itemInFirstSection)
|
||||||
|
.addItem(otherItem)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
const spy = vi.spyOn(emptyPositionModule, "getFirstEmptyPosition");
|
||||||
|
spy.mockReturnValue(emptyPosition);
|
||||||
|
const layoutsSpy = vi.spyOn(boardContext, "getBoardLayouts");
|
||||||
|
layoutsSpy.mockReturnValue([layoutId]);
|
||||||
|
|
||||||
|
// Act
|
||||||
const result = createItemCallback({
|
const result = createItemCallback({
|
||||||
kind: "clock",
|
kind: itemKind,
|
||||||
})(input as unknown as Board);
|
})(board);
|
||||||
|
|
||||||
const firstSectionResult = result.sections.find((section) => section.id === "2");
|
// Assert
|
||||||
const item = firstSectionResult?.items.find((item) => item.id !== "12");
|
expect(result.items.length).toBe(3);
|
||||||
expect(item).toEqual(expect.objectContaining({ kind: "clock", xOffset: 5, yOffset: 5 }));
|
const item = result.items.find((item) => item.id !== itemInFirstSection.id && item.id !== otherItem.id);
|
||||||
expect(spy).toHaveBeenCalledWith([expectedItem, dynamicSectionInFirst], input.columnCount);
|
expect(item).toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
kind: itemKind,
|
||||||
|
layouts: [{ ...emptyPosition, height: 1, width: 1, sectionId: firstSectionId, layoutId }],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(spy).toHaveBeenCalledWith(
|
||||||
|
[expect.objectContaining(itemAndSectionPosition), expect.objectContaining(itemAndSectionPosition)],
|
||||||
|
layout.columnCount,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -1,51 +1,63 @@
|
|||||||
import { describe, expect, test, vi } from "vitest";
|
import { describe, expect, test, vi } from "vitest";
|
||||||
|
|
||||||
import type { Board } from "~/app/[locale]/boards/_types";
|
|
||||||
import { duplicateItemCallback } from "../duplicate-item";
|
import { duplicateItemCallback } from "../duplicate-item";
|
||||||
import * as emptyPosition from "../empty-position";
|
import * as emptyPositionModule from "../empty-position";
|
||||||
import { createEmptySection, createItem } from "./shared";
|
import { BoardMockBuilder } from "./mocks/board-mock";
|
||||||
|
import { ItemMockBuilder } from "./mocks/item-mock";
|
||||||
|
import { LayoutMockBuilder } from "./mocks/layout-mock";
|
||||||
|
|
||||||
describe("item actions duplicate-item", () => {
|
describe("item actions duplicate-item", () => {
|
||||||
test("should copy it in the same section", () => {
|
test("should copy it in the same section", () => {
|
||||||
const spy = vi.spyOn(emptyPosition, "getFirstEmptyPosition");
|
// Arrange
|
||||||
spy.mockReturnValue({ xOffset: 5, yOffset: 5 });
|
const itemKind = "minecraftServerStatus";
|
||||||
const currentSection = createEmptySection("2", 1);
|
const emptyPosition = { xOffset: 5, yOffset: 5 };
|
||||||
const currentItem = createItem({
|
const currentSectionId = "2";
|
||||||
id: "1",
|
const layoutId = "1";
|
||||||
xOffset: 1,
|
const currentItemSize = { height: 2, width: 3 };
|
||||||
yOffset: 3,
|
|
||||||
width: 3,
|
const layout = new LayoutMockBuilder({ id: layoutId, columnCount: 4 }).build();
|
||||||
height: 2,
|
const currentItem = new ItemMockBuilder({
|
||||||
kind: "minecraftServerStatus",
|
kind: itemKind,
|
||||||
integrationIds: ["1"],
|
integrationIds: ["1"],
|
||||||
options: { address: "localhost" },
|
options: { address: "localhost" },
|
||||||
advancedOptions: { customCssClasses: ["test"] },
|
advancedOptions: { customCssClasses: ["test"] },
|
||||||
});
|
})
|
||||||
const otherItem = createItem({
|
.addLayout({ layoutId, sectionId: currentSectionId, ...currentItemSize })
|
||||||
id: "2",
|
.build();
|
||||||
});
|
const otherItem = new ItemMockBuilder({ id: "2" }).addLayout({ layoutId }).build();
|
||||||
currentSection.items.push(currentItem, otherItem);
|
|
||||||
const input = {
|
|
||||||
columnCount: 10,
|
|
||||||
sections: [createEmptySection("1", 0), currentSection, createEmptySection("3", 2)],
|
|
||||||
} satisfies Pick<Board, "sections" | "columnCount">;
|
|
||||||
|
|
||||||
const result = duplicateItemCallback({ itemId: currentItem.id })(input as unknown as Board);
|
const board = new BoardMockBuilder()
|
||||||
|
.addLayout(layout)
|
||||||
|
.addItem(currentItem)
|
||||||
|
.addItem(otherItem)
|
||||||
|
.addEmptySection({ id: "1", yOffset: 2 })
|
||||||
|
.addEmptySection({ id: currentSectionId, yOffset: 0 })
|
||||||
|
.addEmptySection({ id: "3", yOffset: 1 })
|
||||||
|
.build();
|
||||||
|
|
||||||
const section = result.sections.find((section) => section.id === "2");
|
const spy = vi.spyOn(emptyPositionModule, "getFirstEmptyPosition");
|
||||||
expect(section?.items.length).toBe(3);
|
spy.mockReturnValue(emptyPosition);
|
||||||
const duplicatedItem = section?.items.find((item) => item.id !== currentItem.id && item.id !== otherItem.id);
|
|
||||||
|
// Act
|
||||||
|
const result = duplicateItemCallback({ itemId: currentItem.id })(board);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(result.items.length).toBe(3);
|
||||||
|
const duplicatedItem = result.items.find((item) => item.id !== currentItem.id && item.id !== otherItem.id);
|
||||||
|
|
||||||
expect(duplicatedItem).toEqual(
|
expect(duplicatedItem).toEqual(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
kind: "minecraftServerStatus",
|
kind: itemKind,
|
||||||
xOffset: 5,
|
integrationIds: currentItem.integrationIds,
|
||||||
yOffset: 5,
|
options: currentItem.options,
|
||||||
width: 3,
|
advancedOptions: currentItem.advancedOptions,
|
||||||
height: 2,
|
layouts: [
|
||||||
integrationIds: ["1"],
|
expect.objectContaining({
|
||||||
options: { address: "localhost" },
|
...emptyPosition,
|
||||||
advancedOptions: { customCssClasses: ["test"] },
|
...currentItemSize,
|
||||||
|
sectionId: currentSectionId,
|
||||||
|
}),
|
||||||
|
],
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -109,7 +109,7 @@ describe("get first empty position", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const createElementsFromLayout = (layout: string[][]) => {
|
const createElementsFromLayout = (layout: string[][]) => {
|
||||||
const elements: (Pick<Item, "xOffset" | "yOffset" | "width" | "height"> & { char: string })[] = [];
|
const elements: (Pick<Item["layouts"][number], "xOffset" | "yOffset" | "width" | "height"> & { char: string })[] = [];
|
||||||
for (let yOffset = 0; yOffset < layout.length; yOffset++) {
|
for (let yOffset = 0; yOffset < layout.length; yOffset++) {
|
||||||
const row = layout[yOffset];
|
const row = layout[yOffset];
|
||||||
if (!row) continue;
|
if (!row) continue;
|
||||||
|
|||||||
@@ -0,0 +1,90 @@
|
|||||||
|
import { createId } from "@homarr/db";
|
||||||
|
|
||||||
|
import type { Board, DynamicSection, EmptySection, Item, Section } from "~/app/[locale]/boards/_types";
|
||||||
|
import { DynamicSectionMockBuilder } from "./dynamic-section-mock";
|
||||||
|
import { EmptySectionMockBuilder } from "./empty-section-mock";
|
||||||
|
import { ItemMockBuilder } from "./item-mock";
|
||||||
|
import { LayoutMockBuilder } from "./layout-mock";
|
||||||
|
|
||||||
|
export class BoardMockBuilder {
|
||||||
|
private readonly board: Board;
|
||||||
|
|
||||||
|
constructor(board?: Partial<Omit<Board, "groupPermissions" | "userPermissions" | "sections" | "items" | "layouts">>) {
|
||||||
|
this.board = {
|
||||||
|
id: createId(),
|
||||||
|
backgroundImageRepeat: "no-repeat",
|
||||||
|
backgroundImageAttachment: "scroll",
|
||||||
|
backgroundImageSize: "cover",
|
||||||
|
backgroundImageUrl: null,
|
||||||
|
primaryColor: "#ffffff",
|
||||||
|
secondaryColor: "#000000",
|
||||||
|
iconColor: null,
|
||||||
|
itemRadius: "lg",
|
||||||
|
pageTitle: "Board",
|
||||||
|
metaTitle: "Board",
|
||||||
|
logoImageUrl: null,
|
||||||
|
faviconImageUrl: null,
|
||||||
|
name: "board",
|
||||||
|
opacity: 100,
|
||||||
|
isPublic: true,
|
||||||
|
disableStatus: false,
|
||||||
|
customCss: "",
|
||||||
|
creatorId: createId(),
|
||||||
|
creator: {
|
||||||
|
id: createId(),
|
||||||
|
image: null,
|
||||||
|
name: "User",
|
||||||
|
},
|
||||||
|
groupPermissions: [],
|
||||||
|
userPermissions: [],
|
||||||
|
sections: [],
|
||||||
|
items: [],
|
||||||
|
layouts: [
|
||||||
|
{
|
||||||
|
id: createId(),
|
||||||
|
name: "Base",
|
||||||
|
columnCount: 12,
|
||||||
|
breakpoint: 0,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
...board,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public addEmptySection(emptySection?: Partial<EmptySection>): BoardMockBuilder {
|
||||||
|
return this.addSection(new EmptySectionMockBuilder(emptySection).build());
|
||||||
|
}
|
||||||
|
|
||||||
|
public addDynamicSection(dynamicSection?: Partial<DynamicSection>): BoardMockBuilder {
|
||||||
|
return this.addSection(new DynamicSectionMockBuilder(dynamicSection).build());
|
||||||
|
}
|
||||||
|
|
||||||
|
public addSection(section: Section): BoardMockBuilder {
|
||||||
|
this.board.sections.push(section);
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public addSections(sections: Section[]): BoardMockBuilder {
|
||||||
|
this.board.sections.push(...sections);
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public addItem(item?: Partial<Item>): BoardMockBuilder {
|
||||||
|
this.board.items.push(new ItemMockBuilder(item).build());
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public addItems(items: Item[]): BoardMockBuilder {
|
||||||
|
this.board.items.push(...items);
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public addLayout(layout?: Partial<Board["layouts"][number]>): BoardMockBuilder {
|
||||||
|
this.board.layouts.push(new LayoutMockBuilder(layout).build());
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public build(): Board {
|
||||||
|
return this.board;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { createId } from "@homarr/db";
|
||||||
|
|
||||||
|
import type { CategorySection } from "~/app/[locale]/boards/_types";
|
||||||
|
|
||||||
|
export class CategorySectionMockBuilder {
|
||||||
|
private readonly section: CategorySection;
|
||||||
|
|
||||||
|
constructor(section?: Partial<CategorySection>) {
|
||||||
|
this.section = {
|
||||||
|
id: createId(),
|
||||||
|
kind: "category",
|
||||||
|
xOffset: 0,
|
||||||
|
yOffset: 0,
|
||||||
|
name: "Category",
|
||||||
|
collapsed: false,
|
||||||
|
...section,
|
||||||
|
} satisfies CategorySection;
|
||||||
|
}
|
||||||
|
|
||||||
|
public build(): CategorySection {
|
||||||
|
return this.section;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { createId } from "@homarr/db";
|
||||||
|
|
||||||
|
import type { DynamicSection } from "~/app/[locale]/boards/_types";
|
||||||
|
|
||||||
|
export class DynamicSectionMockBuilder {
|
||||||
|
private readonly section: DynamicSection;
|
||||||
|
|
||||||
|
constructor(section?: Partial<DynamicSection>) {
|
||||||
|
this.section = {
|
||||||
|
id: createId(),
|
||||||
|
kind: "dynamic",
|
||||||
|
layouts: [],
|
||||||
|
...section,
|
||||||
|
} satisfies DynamicSection;
|
||||||
|
}
|
||||||
|
|
||||||
|
public addLayout(layout?: Partial<DynamicSection["layouts"][0]>): DynamicSectionMockBuilder {
|
||||||
|
this.section.layouts.push({
|
||||||
|
layoutId: "1",
|
||||||
|
height: 1,
|
||||||
|
width: 1,
|
||||||
|
xOffset: 0,
|
||||||
|
yOffset: 0,
|
||||||
|
parentSectionId: "0",
|
||||||
|
...layout,
|
||||||
|
} satisfies DynamicSection["layouts"][0]);
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public build(): DynamicSection {
|
||||||
|
return this.section;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { createId } from "@homarr/db";
|
||||||
|
|
||||||
|
import type { EmptySection } from "~/app/[locale]/boards/_types";
|
||||||
|
|
||||||
|
export class EmptySectionMockBuilder {
|
||||||
|
private readonly section: EmptySection;
|
||||||
|
|
||||||
|
constructor(section?: Partial<EmptySection>) {
|
||||||
|
this.section = {
|
||||||
|
id: createId(),
|
||||||
|
kind: "empty",
|
||||||
|
xOffset: 0,
|
||||||
|
yOffset: 0,
|
||||||
|
...section,
|
||||||
|
} satisfies EmptySection;
|
||||||
|
}
|
||||||
|
|
||||||
|
public build(): EmptySection {
|
||||||
|
return this.section;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import { createId } from "@homarr/db";
|
||||||
|
|
||||||
|
import type { Item } from "~/app/[locale]/boards/_types";
|
||||||
|
|
||||||
|
export class ItemMockBuilder {
|
||||||
|
private readonly item: Item;
|
||||||
|
|
||||||
|
constructor(item?: Partial<Item>) {
|
||||||
|
this.item = {
|
||||||
|
id: createId(),
|
||||||
|
kind: "app",
|
||||||
|
options: {},
|
||||||
|
layouts: [],
|
||||||
|
integrationIds: [],
|
||||||
|
advancedOptions: {
|
||||||
|
customCssClasses: [],
|
||||||
|
},
|
||||||
|
...item,
|
||||||
|
} satisfies Item;
|
||||||
|
}
|
||||||
|
|
||||||
|
public addLayout(layout?: Partial<Item["layouts"][0]>): ItemMockBuilder {
|
||||||
|
this.item.layouts.push({
|
||||||
|
layoutId: "1",
|
||||||
|
height: 1,
|
||||||
|
width: 1,
|
||||||
|
xOffset: 0,
|
||||||
|
yOffset: 0,
|
||||||
|
sectionId: "0",
|
||||||
|
...layout,
|
||||||
|
} satisfies Item["layouts"][0]);
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public build(): Item {
|
||||||
|
return this.item;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { createId } from "@homarr/db";
|
||||||
|
|
||||||
|
import type { Board } from "~/app/[locale]/boards/_types";
|
||||||
|
|
||||||
|
export class LayoutMockBuilder {
|
||||||
|
private readonly layout: Board["layouts"][number];
|
||||||
|
|
||||||
|
constructor(layout?: Partial<Board["layouts"][number]>) {
|
||||||
|
this.layout = {
|
||||||
|
id: createId(),
|
||||||
|
name: "Base",
|
||||||
|
columnCount: 12,
|
||||||
|
breakpoint: 0,
|
||||||
|
...layout,
|
||||||
|
} satisfies Board["layouts"][0];
|
||||||
|
}
|
||||||
|
|
||||||
|
public build(): Board["layouts"][0] {
|
||||||
|
return this.layout;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import { describe, expect, test, vi } from "vitest";
|
||||||
|
|
||||||
|
import * as boardContext from "@homarr/boards/context";
|
||||||
|
|
||||||
|
import { moveAndResizeItemCallback } from "../move-and-resize-item";
|
||||||
|
import { BoardMockBuilder } from "./mocks/board-mock";
|
||||||
|
import { ItemMockBuilder } from "./mocks/item-mock";
|
||||||
|
|
||||||
|
describe("moveItemToSectionCallback should move item in section", () => {
|
||||||
|
test("should move item in section", () => {
|
||||||
|
// Arrange
|
||||||
|
const itemToMove = "2";
|
||||||
|
const layoutId = "1";
|
||||||
|
const spy = vi.spyOn(boardContext, "getCurrentLayout");
|
||||||
|
spy.mockReturnValue(layoutId);
|
||||||
|
const newPosition = {
|
||||||
|
xOffset: 20,
|
||||||
|
yOffset: 30,
|
||||||
|
width: 15,
|
||||||
|
height: 17,
|
||||||
|
};
|
||||||
|
const itemA = new ItemMockBuilder().addLayout({ layoutId }).build();
|
||||||
|
const itemB = new ItemMockBuilder({ id: itemToMove }).addLayout({ layoutId }).addLayout().build();
|
||||||
|
const itemC = new ItemMockBuilder().addLayout({ layoutId }).build();
|
||||||
|
const board = new BoardMockBuilder().addItem(itemA).addItem(itemB).addItem(itemC).build();
|
||||||
|
|
||||||
|
// Act
|
||||||
|
const updatedBoard = moveAndResizeItemCallback({ itemId: itemToMove, ...newPosition })(board);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(updatedBoard.items).toHaveLength(3);
|
||||||
|
const movedItem = updatedBoard.items.find((item) => item.id === itemToMove);
|
||||||
|
expect(movedItem).not.toBeUndefined();
|
||||||
|
expect(movedItem?.layouts.find((layout) => layout.layoutId === layoutId)).toEqual(
|
||||||
|
expect.objectContaining(newPosition),
|
||||||
|
);
|
||||||
|
const otherItemLayouts = updatedBoard.items
|
||||||
|
.filter((item) => item.id !== itemToMove)
|
||||||
|
.flatMap((item) => item.layouts);
|
||||||
|
expect(otherItemLayouts).not.toContainEqual(expect.objectContaining(newPosition));
|
||||||
|
});
|
||||||
|
test("should not move item if item not found", () => {
|
||||||
|
// Arrange
|
||||||
|
const itemToMove = "2";
|
||||||
|
const layoutId = "1";
|
||||||
|
const spy = vi.spyOn(boardContext, "getCurrentLayout");
|
||||||
|
spy.mockReturnValue(layoutId);
|
||||||
|
const newPosition = {
|
||||||
|
xOffset: 20,
|
||||||
|
yOffset: 30,
|
||||||
|
width: 15,
|
||||||
|
height: 17,
|
||||||
|
};
|
||||||
|
const itemA = new ItemMockBuilder().addLayout({ layoutId }).build();
|
||||||
|
const itemC = new ItemMockBuilder().addLayout({ layoutId }).build();
|
||||||
|
const board = new BoardMockBuilder().addItem(itemA).addItem(itemC).build();
|
||||||
|
|
||||||
|
// Act
|
||||||
|
const updatedBoard = moveAndResizeItemCallback({ itemId: itemToMove, ...newPosition })(board);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(updatedBoard.items).toHaveLength(2);
|
||||||
|
expect(updatedBoard.items.find((item) => item.layouts.at(0)?.yOffset === newPosition.yOffset)).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import { describe, expect, test, vi } from "vitest";
|
||||||
|
|
||||||
|
import * as boardContext from "@homarr/boards/context";
|
||||||
|
|
||||||
|
import { moveItemToSectionCallback } from "../move-item-to-section";
|
||||||
|
import { BoardMockBuilder } from "./mocks/board-mock";
|
||||||
|
import { ItemMockBuilder } from "./mocks/item-mock";
|
||||||
|
|
||||||
|
describe("moveItemToSectionCallback should move item to section", () => {
|
||||||
|
test("should move item to section", () => {
|
||||||
|
// Arrange
|
||||||
|
const itemToMove = "2";
|
||||||
|
const layoutId = "1";
|
||||||
|
const spy = vi.spyOn(boardContext, "getCurrentLayout");
|
||||||
|
spy.mockReturnValue(layoutId);
|
||||||
|
const newPosition = {
|
||||||
|
sectionId: "3",
|
||||||
|
xOffset: 20,
|
||||||
|
yOffset: 30,
|
||||||
|
width: 15,
|
||||||
|
height: 17,
|
||||||
|
};
|
||||||
|
const itemA = new ItemMockBuilder().addLayout({ layoutId }).build();
|
||||||
|
const itemB = new ItemMockBuilder({ id: itemToMove }).addLayout({ layoutId }).addLayout().build();
|
||||||
|
const itemC = new ItemMockBuilder().addLayout({ layoutId }).build();
|
||||||
|
const board = new BoardMockBuilder().addItem(itemA).addItem(itemB).addItem(itemC).build();
|
||||||
|
|
||||||
|
// Act
|
||||||
|
const updatedBoard = moveItemToSectionCallback({ itemId: itemToMove, ...newPosition })(board);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(updatedBoard.items).toHaveLength(3);
|
||||||
|
const movedItem = updatedBoard.items.find((item) => item.id === itemToMove);
|
||||||
|
expect(movedItem).not.toBeUndefined();
|
||||||
|
expect(movedItem?.layouts.find((layout) => layout.layoutId === layoutId)).toEqual(
|
||||||
|
expect.objectContaining(newPosition),
|
||||||
|
);
|
||||||
|
const otherItemLayouts = updatedBoard.items
|
||||||
|
.filter((item) => item.id !== itemToMove)
|
||||||
|
.flatMap((item) => item.layouts);
|
||||||
|
expect(otherItemLayouts).not.toContainEqual(expect.objectContaining(newPosition));
|
||||||
|
});
|
||||||
|
test("should not move item if item not found", () => {
|
||||||
|
// Arrange
|
||||||
|
const itemToMove = "2";
|
||||||
|
const layoutId = "1";
|
||||||
|
const spy = vi.spyOn(boardContext, "getCurrentLayout");
|
||||||
|
spy.mockReturnValue(layoutId);
|
||||||
|
const newPosition = {
|
||||||
|
sectionId: "3",
|
||||||
|
xOffset: 20,
|
||||||
|
yOffset: 30,
|
||||||
|
width: 15,
|
||||||
|
height: 17,
|
||||||
|
};
|
||||||
|
const itemA = new ItemMockBuilder().addLayout({ layoutId }).build();
|
||||||
|
const itemC = new ItemMockBuilder().addLayout({ layoutId }).build();
|
||||||
|
const board = new BoardMockBuilder().addItem(itemA).addItem(itemC).build();
|
||||||
|
|
||||||
|
// Act
|
||||||
|
const updatedBoard = moveItemToSectionCallback({ itemId: itemToMove, ...newPosition })(board);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(updatedBoard.items).toHaveLength(2);
|
||||||
|
expect(updatedBoard.items.find((item) => item.layouts.at(0)?.sectionId === newPosition.sectionId)).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import { describe, expect, test } from "vitest";
|
||||||
|
|
||||||
|
import { removeItemCallback } from "../remove-item";
|
||||||
|
import { BoardMockBuilder } from "./mocks/board-mock";
|
||||||
|
|
||||||
|
describe("removeItemCallback should remove item from board", () => {
|
||||||
|
test("should remove correct item from board", () => {
|
||||||
|
// Arrange
|
||||||
|
const itemIdToRemove = "2";
|
||||||
|
const board = new BoardMockBuilder()
|
||||||
|
.addItem({ id: "1" })
|
||||||
|
.addItem({ id: itemIdToRemove })
|
||||||
|
.addItem({ id: "3" })
|
||||||
|
.build();
|
||||||
|
|
||||||
|
// Act
|
||||||
|
const updatedBoard = removeItemCallback({ itemId: itemIdToRemove })(board);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
const itemIds = updatedBoard.items.map((item) => item.id);
|
||||||
|
expect(itemIds).toHaveLength(2);
|
||||||
|
expect(itemIds).not.toContain(itemIdToRemove);
|
||||||
|
});
|
||||||
|
test("should not remove item if item not found", () => {
|
||||||
|
// Arrange
|
||||||
|
const itemIdToRemove = "2";
|
||||||
|
const board = new BoardMockBuilder().addItem({ id: "1" }).addItem({ id: "3" }).build();
|
||||||
|
|
||||||
|
// Act
|
||||||
|
const updatedBoard = removeItemCallback({ itemId: itemIdToRemove })(board);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
const itemIds = updatedBoard.items.map((item) => item.id);
|
||||||
|
expect(itemIds).toHaveLength(2);
|
||||||
|
expect(itemIds).not.toContain(itemIdToRemove);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
import type { DynamicSection, EmptySection, Item } from "~/app/[locale]/boards/_types";
|
|
||||||
|
|
||||||
export const createEmptySection = (id: string, yOffset: number): EmptySection => ({
|
|
||||||
id,
|
|
||||||
kind: "empty",
|
|
||||||
yOffset,
|
|
||||||
xOffset: 0,
|
|
||||||
items: [],
|
|
||||||
});
|
|
||||||
|
|
||||||
export const createDynamicSection = (section: Omit<Partial<DynamicSection>, "kind">): DynamicSection => ({
|
|
||||||
id: section.id ?? "0",
|
|
||||||
kind: "dynamic",
|
|
||||||
parentSectionId: section.parentSectionId ?? "0",
|
|
||||||
height: section.height ?? 1,
|
|
||||||
width: section.width ?? 1,
|
|
||||||
yOffset: section.yOffset ?? 0,
|
|
||||||
xOffset: section.xOffset ?? 0,
|
|
||||||
items: section.items ?? [],
|
|
||||||
});
|
|
||||||
|
|
||||||
export const createItem = (item: Partial<Item>): Item => ({
|
|
||||||
id: item.id ?? "0",
|
|
||||||
width: item.width ?? 1,
|
|
||||||
height: item.height ?? 1,
|
|
||||||
yOffset: item.yOffset ?? 0,
|
|
||||||
xOffset: item.xOffset ?? 0,
|
|
||||||
kind: item.kind ?? "clock",
|
|
||||||
integrationIds: item.integrationIds ?? [],
|
|
||||||
options: item.options ?? {},
|
|
||||||
advancedOptions: item.advancedOptions ?? { customCssClasses: [] },
|
|
||||||
});
|
|
||||||
@@ -1,32 +1,18 @@
|
|||||||
import { useCallback } from "react";
|
import { useCallback } from "react";
|
||||||
|
|
||||||
|
import { useUpdateBoard } from "@homarr/boards/updater";
|
||||||
import type { BoardItemAdvancedOptions } from "@homarr/validation";
|
import type { BoardItemAdvancedOptions } from "@homarr/validation";
|
||||||
|
|
||||||
import type { Item } from "~/app/[locale]/boards/_types";
|
|
||||||
import { useUpdateBoard } from "~/app/[locale]/boards/(content)/_client";
|
|
||||||
import type { CreateItemInput } from "./actions/create-item";
|
import type { CreateItemInput } from "./actions/create-item";
|
||||||
import { createItemCallback } from "./actions/create-item";
|
import { createItemCallback } from "./actions/create-item";
|
||||||
import type { DuplicateItemInput } from "./actions/duplicate-item";
|
import type { DuplicateItemInput } from "./actions/duplicate-item";
|
||||||
import { duplicateItemCallback } from "./actions/duplicate-item";
|
import { duplicateItemCallback } from "./actions/duplicate-item";
|
||||||
|
import type { MoveAndResizeItemInput } from "./actions/move-and-resize-item";
|
||||||
interface MoveAndResizeItem {
|
import { moveAndResizeItemCallback } from "./actions/move-and-resize-item";
|
||||||
itemId: string;
|
import type { MoveItemToSectionInput } from "./actions/move-item-to-section";
|
||||||
xOffset: number;
|
import { moveItemToSectionCallback } from "./actions/move-item-to-section";
|
||||||
yOffset: number;
|
import type { RemoveItemInput } from "./actions/remove-item";
|
||||||
width: number;
|
import { removeItemCallback } from "./actions/remove-item";
|
||||||
height: number;
|
|
||||||
}
|
|
||||||
interface MoveItemToSection {
|
|
||||||
itemId: string;
|
|
||||||
sectionId: string;
|
|
||||||
xOffset: number;
|
|
||||||
yOffset: number;
|
|
||||||
width: number;
|
|
||||||
height: number;
|
|
||||||
}
|
|
||||||
interface RemoveItem {
|
|
||||||
itemId: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface UpdateItemOptions {
|
interface UpdateItemOptions {
|
||||||
itemId: string;
|
itemId: string;
|
||||||
@@ -62,164 +48,55 @@ export const useItemActions = () => {
|
|||||||
|
|
||||||
const updateItemOptions = useCallback(
|
const updateItemOptions = useCallback(
|
||||||
({ itemId, newOptions }: UpdateItemOptions) => {
|
({ itemId, newOptions }: UpdateItemOptions) => {
|
||||||
updateBoard((previous) => {
|
updateBoard((previous) => ({
|
||||||
return {
|
...previous,
|
||||||
...previous,
|
items: previous.items.map((item) => (item.id !== itemId ? item : { ...item, options: newOptions })),
|
||||||
sections: previous.sections.map((section) => {
|
}));
|
||||||
// Return same section if item is not in it
|
|
||||||
if (!section.items.some((item) => item.id === itemId)) return section;
|
|
||||||
return {
|
|
||||||
...section,
|
|
||||||
items: section.items.map((item) => {
|
|
||||||
// Return same item if item is not the one we're changing
|
|
||||||
if (item.id !== itemId) return item;
|
|
||||||
return {
|
|
||||||
...item,
|
|
||||||
options: newOptions,
|
|
||||||
};
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
},
|
},
|
||||||
[updateBoard],
|
[updateBoard],
|
||||||
);
|
);
|
||||||
|
|
||||||
const updateItemAdvancedOptions = useCallback(
|
const updateItemAdvancedOptions = useCallback(
|
||||||
({ itemId, newAdvancedOptions }: UpdateItemAdvancedOptions) => {
|
({ itemId, newAdvancedOptions }: UpdateItemAdvancedOptions) => {
|
||||||
updateBoard((previous) => {
|
updateBoard((previous) => ({
|
||||||
return {
|
...previous,
|
||||||
...previous,
|
items: previous.items.map((item) =>
|
||||||
sections: previous.sections.map((section) => {
|
item.id !== itemId ? item : { ...item, advancedOptions: newAdvancedOptions },
|
||||||
// Return same section if item is not in it
|
),
|
||||||
if (!section.items.some((item) => item.id === itemId)) return section;
|
}));
|
||||||
return {
|
|
||||||
...section,
|
|
||||||
items: section.items.map((item) => {
|
|
||||||
// Return same item if item is not the one we're changing
|
|
||||||
if (item.id !== itemId) return item;
|
|
||||||
return {
|
|
||||||
...item,
|
|
||||||
advancedOptions: newAdvancedOptions,
|
|
||||||
};
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
},
|
},
|
||||||
[updateBoard],
|
[updateBoard],
|
||||||
);
|
);
|
||||||
|
|
||||||
const updateItemIntegrations = useCallback(
|
const updateItemIntegrations = useCallback(
|
||||||
({ itemId, newIntegrations }: UpdateItemIntegrations) => {
|
({ itemId, newIntegrations }: UpdateItemIntegrations) => {
|
||||||
updateBoard((previous) => {
|
|
||||||
return {
|
|
||||||
...previous,
|
|
||||||
sections: previous.sections.map((section) => {
|
|
||||||
// Return same section if item is not in it
|
|
||||||
if (!section.items.some((item) => item.id === itemId)) return section;
|
|
||||||
return {
|
|
||||||
...section,
|
|
||||||
items: section.items.map((item) => {
|
|
||||||
// Return same item if item is not the one we're moving
|
|
||||||
if (item.id !== itemId) return item;
|
|
||||||
return {
|
|
||||||
...item,
|
|
||||||
...("integrationIds" in item ? { integrationIds: newIntegrations } : {}),
|
|
||||||
};
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
},
|
|
||||||
[updateBoard],
|
|
||||||
);
|
|
||||||
|
|
||||||
const moveAndResizeItem = useCallback(
|
|
||||||
({ itemId, ...positionProps }: MoveAndResizeItem) => {
|
|
||||||
updateBoard((previous) => ({
|
updateBoard((previous) => ({
|
||||||
...previous,
|
...previous,
|
||||||
sections: previous.sections.map((section) => {
|
items: previous.items.map((item) =>
|
||||||
// Return same section if item is not in it
|
item.id !== itemId || !("integrationIds" in item) ? item : { ...item, integrationIds: newIntegrations },
|
||||||
if (!section.items.some((item) => item.id === itemId)) return section;
|
),
|
||||||
return {
|
|
||||||
...section,
|
|
||||||
items: section.items.map((item) => {
|
|
||||||
// Return same item if item is not the one we're moving
|
|
||||||
if (item.id !== itemId) return item;
|
|
||||||
return {
|
|
||||||
...item,
|
|
||||||
...positionProps,
|
|
||||||
} satisfies Item;
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
}),
|
|
||||||
}));
|
}));
|
||||||
},
|
},
|
||||||
[updateBoard],
|
[updateBoard],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const moveAndResizeItem = useCallback(
|
||||||
|
(input: MoveAndResizeItemInput) => {
|
||||||
|
updateBoard(moveAndResizeItemCallback(input));
|
||||||
|
},
|
||||||
|
[updateBoard],
|
||||||
|
);
|
||||||
|
|
||||||
const moveItemToSection = useCallback(
|
const moveItemToSection = useCallback(
|
||||||
({ itemId, sectionId, ...positionProps }: MoveItemToSection) => {
|
(input: MoveItemToSectionInput) => {
|
||||||
updateBoard((previous) => {
|
updateBoard(moveItemToSectionCallback(input));
|
||||||
const currentSection = previous.sections.find((section) => section.items.some((item) => item.id === itemId));
|
|
||||||
|
|
||||||
// If item is in the same section (on initial loading) don't do anything
|
|
||||||
if (!currentSection) {
|
|
||||||
return previous;
|
|
||||||
}
|
|
||||||
|
|
||||||
const currentItem = currentSection.items.find((item) => item.id === itemId);
|
|
||||||
if (!currentItem) {
|
|
||||||
return previous;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (currentSection.id === sectionId && currentItem.xOffset) {
|
|
||||||
return previous;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
...previous,
|
|
||||||
sections: previous.sections.map((section) => {
|
|
||||||
// Return sections without item if not section where it is moved to
|
|
||||||
if (section.id !== sectionId)
|
|
||||||
return {
|
|
||||||
...section,
|
|
||||||
items: section.items.filter((item) => item.id !== itemId),
|
|
||||||
};
|
|
||||||
|
|
||||||
// Return section and add item to it
|
|
||||||
return {
|
|
||||||
...section,
|
|
||||||
items: section.items
|
|
||||||
.filter((item) => item.id !== itemId)
|
|
||||||
.concat({
|
|
||||||
...currentItem,
|
|
||||||
...positionProps,
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
},
|
},
|
||||||
[updateBoard],
|
[updateBoard],
|
||||||
);
|
);
|
||||||
|
|
||||||
const removeItem = useCallback(
|
const removeItem = useCallback(
|
||||||
({ itemId }: RemoveItem) => {
|
({ itemId }: RemoveItemInput) => {
|
||||||
updateBoard((previous) => {
|
updateBoard(removeItemCallback({ itemId }));
|
||||||
return {
|
|
||||||
...previous,
|
|
||||||
// Filter removed item out of items array
|
|
||||||
sections: previous.sections.map((section) => ({
|
|
||||||
...section,
|
|
||||||
items: section.items.filter((item) => item.id !== itemId),
|
|
||||||
})),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
},
|
},
|
||||||
[updateBoard],
|
[updateBoard],
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -5,17 +5,19 @@ import combineClasses from "clsx";
|
|||||||
import { NoIntegrationSelectedError } from "node_modules/@homarr/widgets/src/errors";
|
import { NoIntegrationSelectedError } from "node_modules/@homarr/widgets/src/errors";
|
||||||
import { ErrorBoundary } from "react-error-boundary";
|
import { ErrorBoundary } from "react-error-boundary";
|
||||||
|
|
||||||
|
import { useRequiredBoard } from "@homarr/boards/context";
|
||||||
|
import { useEditMode } from "@homarr/boards/edit-mode";
|
||||||
|
import { useSettings } from "@homarr/settings";
|
||||||
import { loadWidgetDynamic, reduceWidgetOptionsWithDefaultValues, widgetImports } from "@homarr/widgets";
|
import { loadWidgetDynamic, reduceWidgetOptionsWithDefaultValues, widgetImports } from "@homarr/widgets";
|
||||||
import { WidgetError } from "@homarr/widgets/errors";
|
import { WidgetError } from "@homarr/widgets/errors";
|
||||||
|
|
||||||
import type { Item } from "~/app/[locale]/boards/_types";
|
import type { SectionItem } from "~/app/[locale]/boards/_types";
|
||||||
import { useEditMode, useRequiredBoard } from "~/app/[locale]/boards/(content)/_context";
|
|
||||||
import classes from "../sections/item.module.css";
|
import classes from "../sections/item.module.css";
|
||||||
import { useItemActions } from "./item-actions";
|
import { useItemActions } from "./item-actions";
|
||||||
import { BoardItemMenu } from "./item-menu";
|
import { BoardItemMenu } from "./item-menu";
|
||||||
|
|
||||||
interface BoardItemContentProps {
|
interface BoardItemContentProps {
|
||||||
item: Item;
|
item: SectionItem;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const BoardItemContent = ({ item }: BoardItemContentProps) => {
|
export const BoardItemContent = ({ item }: BoardItemContentProps) => {
|
||||||
@@ -31,6 +33,7 @@ export const BoardItemContent = ({ item }: BoardItemContentProps) => {
|
|||||||
"grid-stack-item-content",
|
"grid-stack-item-content",
|
||||||
item.advancedOptions.customCssClasses.join(" "),
|
item.advancedOptions.customCssClasses.join(" "),
|
||||||
)}
|
)}
|
||||||
|
radius={board.itemRadius}
|
||||||
withBorder
|
withBorder
|
||||||
styles={{
|
styles={{
|
||||||
root: {
|
root: {
|
||||||
@@ -47,17 +50,18 @@ export const BoardItemContent = ({ item }: BoardItemContentProps) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
interface InnerContentProps {
|
interface InnerContentProps {
|
||||||
item: Item;
|
item: SectionItem;
|
||||||
width: number;
|
width: number;
|
||||||
height: number;
|
height: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
const InnerContent = ({ item, ...dimensions }: InnerContentProps) => {
|
const InnerContent = ({ item, ...dimensions }: InnerContentProps) => {
|
||||||
|
const settings = useSettings();
|
||||||
const board = useRequiredBoard();
|
const board = useRequiredBoard();
|
||||||
const [isEditMode] = useEditMode();
|
const [isEditMode] = useEditMode();
|
||||||
const Comp = loadWidgetDynamic(item.kind);
|
const Comp = loadWidgetDynamic(item.kind);
|
||||||
const { definition } = widgetImports[item.kind];
|
const { definition } = widgetImports[item.kind];
|
||||||
const options = reduceWidgetOptionsWithDefaultValues(item.kind, item.options);
|
const options = reduceWidgetOptionsWithDefaultValues(item.kind, settings, item.options);
|
||||||
const newItem = { ...item, options };
|
const newItem = { ...item, options };
|
||||||
const { updateItemOptions } = useItemActions();
|
const { updateItemOptions } = useItemActions();
|
||||||
const updateOptions = ({ newOptions }: { newOptions: Record<string, unknown> }) =>
|
const updateOptions = ({ newOptions }: { newOptions: Record<string, unknown> }) =>
|
||||||
|
|||||||
@@ -3,13 +3,14 @@ import { ActionIcon, Menu } from "@mantine/core";
|
|||||||
import { IconCopy, IconDotsVertical, IconLayoutKanban, IconPencil, IconTrash } from "@tabler/icons-react";
|
import { IconCopy, IconDotsVertical, IconLayoutKanban, IconPencil, IconTrash } from "@tabler/icons-react";
|
||||||
|
|
||||||
import { clientApi } from "@homarr/api/client";
|
import { clientApi } from "@homarr/api/client";
|
||||||
|
import { useEditMode } from "@homarr/boards/edit-mode";
|
||||||
import { useConfirmModal, useModalAction } from "@homarr/modals";
|
import { useConfirmModal, useModalAction } from "@homarr/modals";
|
||||||
|
import { useSettings } from "@homarr/settings";
|
||||||
import { useI18n, useScopedI18n } from "@homarr/translation/client";
|
import { useI18n, useScopedI18n } from "@homarr/translation/client";
|
||||||
import { widgetImports } from "@homarr/widgets";
|
import { widgetImports } from "@homarr/widgets";
|
||||||
import { WidgetEditModal } from "@homarr/widgets/modals";
|
import { WidgetEditModal } from "@homarr/widgets/modals";
|
||||||
|
|
||||||
import type { Item } from "~/app/[locale]/boards/_types";
|
import type { SectionItem } from "~/app/[locale]/boards/_types";
|
||||||
import { useEditMode } from "~/app/[locale]/boards/(content)/_context";
|
|
||||||
import { useSectionContext } from "../sections/section-context";
|
import { useSectionContext } from "../sections/section-context";
|
||||||
import { useItemActions } from "./item-actions";
|
import { useItemActions } from "./item-actions";
|
||||||
import { ItemMoveModal } from "./item-move-modal";
|
import { ItemMoveModal } from "./item-move-modal";
|
||||||
@@ -20,7 +21,7 @@ export const BoardItemMenu = ({
|
|||||||
resetErrorBoundary,
|
resetErrorBoundary,
|
||||||
}: {
|
}: {
|
||||||
offset: number;
|
offset: number;
|
||||||
item: Item;
|
item: SectionItem;
|
||||||
resetErrorBoundary?: () => void;
|
resetErrorBoundary?: () => void;
|
||||||
}) => {
|
}) => {
|
||||||
const refResetErrorBoundaryOnNextRender = useRef(false);
|
const refResetErrorBoundaryOnNextRender = useRef(false);
|
||||||
@@ -35,6 +36,7 @@ export const BoardItemMenu = ({
|
|||||||
const { data: integrationData, isPending } = clientApi.integration.all.useQuery();
|
const { data: integrationData, isPending } = clientApi.integration.all.useQuery();
|
||||||
const currentDefinition = useMemo(() => widgetImports[item.kind].definition, [item.kind]);
|
const currentDefinition = useMemo(() => widgetImports[item.kind].definition, [item.kind]);
|
||||||
const { gridstack } = useSectionContext().refs;
|
const { gridstack } = useSectionContext().refs;
|
||||||
|
const settings = useSettings();
|
||||||
|
|
||||||
// Reset error boundary on next render if item has been edited
|
// Reset error boundary on next render if item has been edited
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -75,6 +77,7 @@ export const BoardItemMenu = ({
|
|||||||
(currentDefinition.supportedIntegrations as string[]).some((kind) => kind === integration.kind),
|
(currentDefinition.supportedIntegrations as string[]).some((kind) => kind === integration.kind),
|
||||||
),
|
),
|
||||||
integrationSupport: "supportedIntegrations" in currentDefinition,
|
integrationSupport: "supportedIntegrations" in currentDefinition,
|
||||||
|
settings,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
Loaded 100 of 472 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user