Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f25837f9f8 | ||
|
|
44007b2b4b | ||
|
|
37d471457a | ||
|
|
9881577f47 | ||
|
|
a572aae887 | ||
|
|
4721a97094 | ||
|
|
15026afa46 | ||
|
|
3403579925 | ||
|
|
da4aaba9f0 | ||
|
|
98dd96f37c | ||
|
|
67c739c35b | ||
|
|
6f8b3de903 | ||
|
|
e4a39223a3 | ||
|
|
72860a877b | ||
|
|
0f3089a1bd | ||
|
|
84519bf734 | ||
|
|
478aae6cd4 | ||
|
|
604a05f9d5 | ||
|
|
b3e4f30312 | ||
|
|
a3999bcf52 | ||
|
|
3804d530ec | ||
|
|
e88b29dc9d | ||
|
|
d470ea4ede | ||
|
|
21f3a5a408 | ||
|
|
14f3fbfc82 | ||
|
|
6135b59b7d | ||
|
|
bad07cbf25 | ||
|
|
49b0f962e0 | ||
|
|
c94fa4e999 | ||
|
|
be771e5630 | ||
|
|
a5e0e8c129 | ||
|
|
7dfb108a56 | ||
|
|
7dfc3646b7 | ||
|
|
d66610b324 | ||
|
|
9eb76634d0 | ||
|
|
e1d69f91e7 | ||
|
|
80c2040b13 | ||
|
|
43619e0cc0 | ||
|
|
6fad48f65a | ||
|
|
788304adfb | ||
|
|
c0e354428a | ||
|
|
286cc0fcac | ||
|
|
d9ef1b0470 | ||
|
|
163e3b007f | ||
|
|
f4b302bbf8 | ||
|
|
3c1cdb5e6e | ||
|
|
f0b932432b | ||
|
|
df13d67b42 | ||
|
|
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 |
No files matched your search
@@ -12,6 +12,8 @@ AUTH_SECRET="supersecret"
|
||||
# or starting the project without any (which will show a randomly generated one).
|
||||
SECRET_ENCRYPTION_KEY=0000000000000000000000000000000000000000000000000000000000000000
|
||||
|
||||
LOG_LEVEL='info'
|
||||
|
||||
# This is how you can use the sqlite driver:
|
||||
DB_DRIVER='better-sqlite3'
|
||||
DB_URL='FULL_PATH_TO_YOUR_SQLITE_DB_FILE'
|
||||
|
||||
@@ -31,6 +31,10 @@ body:
|
||||
label: Version
|
||||
description: What version of Homarr are you running?
|
||||
options:
|
||||
- 1.9.0
|
||||
- 1.8.0
|
||||
- 1.7.0
|
||||
- 1.6.0
|
||||
- 1.5.0
|
||||
- 1.4.0
|
||||
- 1.3.1
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
**Thank you for your contribution. Please ensure that your pull request meets the following pull request:**
|
||||
|
||||
- [ ] Builds without warnings or errors (``pnpm buid``, autofix with ``pnpm format:fix``)
|
||||
- [ ] Builds without warnings or errors (``pnpm build``, autofix with ``pnpm format:fix``)
|
||||
- [ ] Pull request targets ``dev`` branch
|
||||
- [ ] Commits follow the [conventional commits guideline](https://www.conventionalcommits.org/en/v1.0.0/)
|
||||
- [ ] No shorthand variable names are used (eg. ``x``, ``y``, ``i`` or any abbrevation)
|
||||
|
||||
@@ -6,9 +6,9 @@
|
||||
matchPackagePatterns: ["^@homarr/"],
|
||||
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,
|
||||
},
|
||||
{
|
||||
|
||||
@@ -15,13 +15,14 @@ on:
|
||||
description: Send notifications
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
packages: write
|
||||
contents: write # Required to update package.json version
|
||||
packages: write # Required for pushing to GHCR
|
||||
|
||||
env:
|
||||
SKIP_ENV_VALIDATION: true
|
||||
REGISTRY: ghcr.io
|
||||
IMAGE_NAME: ${{ github.repository }}
|
||||
GHCR_REPO: ghcr.io/${{ github.repository }}
|
||||
TURBO_TELEMETRY_DISABLED: 1
|
||||
|
||||
concurrency:
|
||||
@@ -96,64 +97,129 @@ jobs:
|
||||
run: |
|
||||
git config user.name "Releases Homarr"
|
||||
git config user.email "175486441+homarr-releases[bot]@users.noreply.github.com"
|
||||
git remote set-url origin https://x-access-token:${{ steps.obtainToken.outputs.token }}@github.com/${{ github.repository }}.git
|
||||
git fetch origin dev
|
||||
git checkout dev
|
||||
git pull origin dev
|
||||
git merge ${{ github.ref_name }}
|
||||
git rebase ${{ github.ref_name }}
|
||||
git push origin dev
|
||||
deploy:
|
||||
name: Deploy docker image
|
||||
build-amd64:
|
||||
name: Build docker image for amd64
|
||||
needs: release
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
NEXT_VERSION: ${{ needs.release.outputs.version }}
|
||||
DEPLOY_LATEST: ${{ github.ref_name == 'main' }}
|
||||
DEPLOY_BETA: ${{ github.ref_name == 'beta' }}
|
||||
outputs:
|
||||
digest: ${{ steps.build.outputs.digest }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ needs.release.outputs.git_ref }}
|
||||
- name: Discord notification
|
||||
if: ${{ github.events.inputs.send-notifications != false }}
|
||||
env:
|
||||
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
|
||||
uses: Ilshidur/action-discord@master
|
||||
|
||||
- name: Docker meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
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
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
- 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
|
||||
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
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: "${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}"
|
||||
tags: |
|
||||
${{ env.DEPLOY_LATEST == 'true' && 'type=raw,value=latest' || null }}
|
||||
${{ env.DEPLOY_BETA == 'true' && 'type=raw,value=beta' || null }}
|
||||
type=raw,value=${{ env.NEXT_VERSION }}
|
||||
- name: Build and push
|
||||
id: buildPushAction
|
||||
images: "${{ env.GHCR_REPO }}"
|
||||
|
||||
- name: Log in to the Container registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
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
|
||||
with:
|
||||
platforms: linux/amd64,linux/arm64
|
||||
context: .
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
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:
|
||||
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
|
||||
env:
|
||||
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
|
||||
uses: Ilshidur/action-discord@master
|
||||
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>
|
||||
+112
@@ -1,3 +1,115 @@
|
||||
## [1.10.0](https://github.com/homarr-labs/homarr/compare/v1.9.0...v1.10.0) (2025-03-07)
|
||||
|
||||
### Features
|
||||
|
||||
* remove cqmin system ([#2407](https://github.com/homarr-labs/homarr/issues/2407)) ([37d4714](https://github.com/homarr-labs/homarr/commit/37d471457a0a2b10112b67c02923f63de479e617))
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **deps:** update dependency eslint-config-prettier to ^10.1.1 ([#2524](https://github.com/homarr-labs/homarr/issues/2524)) ([4721a97](https://github.com/homarr-labs/homarr/commit/4721a97094ae1181216923c7fef7a29c343f882b))
|
||||
* **deps:** update dependency ioredis to v5.6.0 ([#2509](https://github.com/homarr-labs/homarr/issues/2509)) ([e4a3922](https://github.com/homarr-labs/homarr/commit/e4a39223a33d4bfccb1e9b4e2c30de1376879a1d))
|
||||
* **deps:** update dependency mysql2 to v3.13.0 ([#2516](https://github.com/homarr-labs/homarr/issues/2516)) ([67c739c](https://github.com/homarr-labs/homarr/commit/67c739c35bcb2d537a349f0ebff080eac67e93b5))
|
||||
* **deps:** update tanstack-query monorepo to ^5.67.2 ([#2525](https://github.com/homarr-labs/homarr/issues/2525)) ([a572aae](https://github.com/homarr-labs/homarr/commit/a572aae887142a7e601f70dcc601bf6335e37c17))
|
||||
* **pi-hole:** wrong fetch used in factory ([#2527](https://github.com/homarr-labs/homarr/issues/2527)) ([9881577](https://github.com/homarr-labs/homarr/commit/9881577f47fbeacf861dd564d57fc6d7c469d27b))
|
||||
|
||||
## [1.9.0](https://github.com/homarr-labs/homarr/compare/v1.8.0...v1.9.0) (2025-03-05)
|
||||
|
||||
### Features
|
||||
|
||||
* add sigterm support ([#2472](https://github.com/homarr-labs/homarr/issues/2472)) ([a5e0e8c](https://github.com/homarr-labs/homarr/commit/a5e0e8c1296ebfbe46c88fbb152b5d0049db349d))
|
||||
* added bordercolor option for dynamic section ([#2334](https://github.com/homarr-labs/homarr/issues/2334)) ([7dfb108](https://github.com/homarr-labs/homarr/commit/7dfb108a56e38c32e712b82d1e1bd65a5c15722b))
|
||||
* **integration:** add emby integration ([#2466](https://github.com/homarr-labs/homarr/issues/2466)) ([7dfc364](https://github.com/homarr-labs/homarr/commit/7dfc3646b709e6bb1fc7ec11336024c7eff1f342))
|
||||
* **media-request:** show request status ([#2465](https://github.com/homarr-labs/homarr/issues/2465)) ([80c2040](https://github.com/homarr-labs/homarr/commit/80c2040b1345b0a60030aa8879442b937863216f))
|
||||
* **pihole:** add support for v6 ([#2448](https://github.com/homarr-labs/homarr/issues/2448)) ([3804d53](https://github.com/homarr-labs/homarr/commit/3804d530ec28c90b6ab1fa0f709468e2075afa28))
|
||||
* use fetch with trusted certificates for oidc ([#2412](https://github.com/homarr-labs/homarr/issues/2412)) ([6fad48f](https://github.com/homarr-labs/homarr/commit/6fad48f65ad7cbb927fd80de49930a8256110331))
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* affix hides pagination on mobile ([#2491](https://github.com/homarr-labs/homarr/issues/2491)) ([604a05f](https://github.com/homarr-labs/homarr/commit/604a05f9d528b7d89b09f4f7982921963ba0fb90))
|
||||
* **boards:** case-sensitive board names in url ([#2454](https://github.com/homarr-labs/homarr/issues/2454)) ([c0e3544](https://github.com/homarr-labs/homarr/commit/c0e354428af2c69bf7638911320888f049ec0659))
|
||||
* **deps:** update dependency @tabler/icons-react to ^3.31.0 ([#2502](https://github.com/homarr-labs/homarr/issues/2502)) ([478aae6](https://github.com/homarr-labs/homarr/commit/478aae6cd496ef81e3036d8f2a5b6959b7fea12e))
|
||||
* **deps:** update dependency prettier to ^3.5.3 ([#2485](https://github.com/homarr-labs/homarr/issues/2485)) ([49b0f96](https://github.com/homarr-labs/homarr/commit/49b0f962e0b316bbed599d77d97be927aac252da))
|
||||
* **deps:** update dependency typescript-eslint to ^8.26.0 ([#2488](https://github.com/homarr-labs/homarr/issues/2488)) ([21f3a5a](https://github.com/homarr-labs/homarr/commit/21f3a5a408ab45dbf7d92162703e74b9e60aff10))
|
||||
* **deps:** update mantine monorepo to ^7.17.1 ([#2462](https://github.com/homarr-labs/homarr/issues/2462)) ([d9ef1b0](https://github.com/homarr-labs/homarr/commit/d9ef1b04706a3435d4500db2f2966a28db900d9d))
|
||||
* **deps:** update tanstack-query monorepo to ^5.67.0 ([#2486](https://github.com/homarr-labs/homarr/issues/2486)) ([bad07cb](https://github.com/homarr-labs/homarr/commit/bad07cbf251ab64afe99390bcb687d2f58e8fdc7))
|
||||
* **deps:** update tanstack-query monorepo to ^5.67.1 ([#2487](https://github.com/homarr-labs/homarr/issues/2487)) ([6135b59](https://github.com/homarr-labs/homarr/commit/6135b59b7d24029eaf24ae2a2b94b5cbc822242a))
|
||||
* docker add to homarr layout overflow ([#2441](https://github.com/homarr-labs/homarr/issues/2441)) ([a3999bc](https://github.com/homarr-labs/homarr/commit/a3999bcf521d4d339282701b4734b6cd352c9582))
|
||||
* **integration:** store omv session in redis ([#2467](https://github.com/homarr-labs/homarr/issues/2467)) ([b3e4f30](https://github.com/homarr-labs/homarr/commit/b3e4f3031296ffc69d914f542f95895a449ce2c7))
|
||||
* **logs:** add special case for axios error to prevent huge error messages ([#2468](https://github.com/homarr-labs/homarr/issues/2468)) ([9eb7663](https://github.com/homarr-labs/homarr/commit/9eb76634d07b7bf5ff28062714c44e150591d75d))
|
||||
* **media-request:** inconsistent sorting and reordering ([#2464](https://github.com/homarr-labs/homarr/issues/2464)) ([43619e0](https://github.com/homarr-labs/homarr/commit/43619e0cc0311ca8f26a4fdbc0ccb7fae79c3514))
|
||||
* **old-import:** default logo and favicon not replaced ([#2456](https://github.com/homarr-labs/homarr/issues/2456)) ([788304a](https://github.com/homarr-labs/homarr/commit/788304adfbe3c6c9696758e9ad42bca9d9b86290))
|
||||
|
||||
## [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
|
||||
|
||||
@@ -2,14 +2,13 @@
|
||||
import "@homarr/auth/env";
|
||||
import "@homarr/db/env";
|
||||
import "@homarr/common/env";
|
||||
import "@homarr/log/env";
|
||||
import "@homarr/docker/env";
|
||||
|
||||
import type { NextConfig } from "next";
|
||||
import MillionLint from "@million/lint";
|
||||
import createNextIntlPlugin from "next-intl/plugin";
|
||||
|
||||
import "./src/env.ts";
|
||||
|
||||
// Package path does not work... so we need to use relative path
|
||||
const withNextIntl = createNextIntlPlugin("../../packages/translation/src/request.ts");
|
||||
|
||||
|
||||
+25
-21
@@ -15,6 +15,10 @@
|
||||
},
|
||||
"prettier": "@homarr/prettier-config",
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/modifiers": "^9.0.0",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@homarr/analytics": "workspace:^0.1.0",
|
||||
"@homarr/api": "workspace:^0.1.0",
|
||||
"@homarr/auth": "workspace:^0.1.0",
|
||||
@@ -26,6 +30,7 @@
|
||||
"@homarr/definitions": "workspace:^0.1.0",
|
||||
"@homarr/docker": "workspace:^0.1.0",
|
||||
"@homarr/form": "workspace:^0.1.0",
|
||||
"@homarr/forms-collection": "workspace:^0.1.0",
|
||||
"@homarr/gridstack": "^1.12.0",
|
||||
"@homarr/icons": "workspace:^0.1.0",
|
||||
"@homarr/integrations": "workspace:^0.1.0",
|
||||
@@ -43,18 +48,17 @@
|
||||
"@homarr/ui": "workspace:^0.1.0",
|
||||
"@homarr/validation": "workspace:^0.1.0",
|
||||
"@homarr/widgets": "workspace:^0.1.0",
|
||||
"@mantine/colors-generator": "^7.16.3",
|
||||
"@mantine/core": "^7.16.3",
|
||||
"@mantine/dropzone": "^7.16.3",
|
||||
"@mantine/hooks": "^7.16.3",
|
||||
"@mantine/modals": "^7.16.3",
|
||||
"@mantine/tiptap": "^7.16.3",
|
||||
"@mantine/colors-generator": "^7.17.1",
|
||||
"@mantine/core": "^7.17.1",
|
||||
"@mantine/dropzone": "^7.17.1",
|
||||
"@mantine/hooks": "^7.17.1",
|
||||
"@mantine/modals": "^7.17.1",
|
||||
"@mantine/tiptap": "^7.17.1",
|
||||
"@million/lint": "1.0.14",
|
||||
"@t3-oss/env-nextjs": "^0.12.0",
|
||||
"@tabler/icons-react": "^3.30.0",
|
||||
"@tanstack/react-query": "^5.66.0",
|
||||
"@tanstack/react-query-devtools": "^5.66.0",
|
||||
"@tanstack/react-query-next-experimental": "^5.66.0",
|
||||
"@tabler/icons-react": "^3.31.0",
|
||||
"@tanstack/react-query": "^5.67.2",
|
||||
"@tanstack/react-query-devtools": "^5.67.2",
|
||||
"@tanstack/react-query-next-experimental": "^5.67.2",
|
||||
"@trpc/client": "next",
|
||||
"@trpc/next": "next",
|
||||
"@trpc/react-query": "next",
|
||||
@@ -68,8 +72,8 @@
|
||||
"dotenv": "^16.4.7",
|
||||
"flag-icons": "^7.3.2",
|
||||
"glob": "^11.0.1",
|
||||
"jotai": "^2.12.0",
|
||||
"mantine-react-table": "2.0.0-beta.8",
|
||||
"jotai": "^2.12.1",
|
||||
"mantine-react-table": "2.0.0-beta.9",
|
||||
"next": "15.1.7",
|
||||
"postcss-preset-mantine": "^1.17.0",
|
||||
"prismjs": "^1.29.0",
|
||||
@@ -77,9 +81,9 @@
|
||||
"react-dom": "19.0.0",
|
||||
"react-error-boundary": "^5.0.0",
|
||||
"react-simple-code-editor": "^0.14.1",
|
||||
"sass": "^1.85.0",
|
||||
"sass": "^1.85.1",
|
||||
"superjson": "2.2.2",
|
||||
"swagger-ui-react": "^5.18.3",
|
||||
"swagger-ui-react": "^5.20.0",
|
||||
"use-deep-compare-effect": "^1.8.1",
|
||||
"zod": "^3.24.2"
|
||||
},
|
||||
@@ -88,15 +92,15 @@
|
||||
"@homarr/prettier-config": "workspace:^0.1.0",
|
||||
"@homarr/tsconfig": "workspace:^0.1.0",
|
||||
"@types/chroma-js": "3.1.1",
|
||||
"@types/node": "^22.13.4",
|
||||
"@types/node": "^22.13.9",
|
||||
"@types/prismjs": "^1.26.5",
|
||||
"@types/react": "19.0.8",
|
||||
"@types/react-dom": "19.0.3",
|
||||
"@types/react": "19.0.10",
|
||||
"@types/react-dom": "19.0.4",
|
||||
"@types/swagger-ui-react": "^5.18.0",
|
||||
"concurrently": "^9.1.2",
|
||||
"eslint": "^9.20.1",
|
||||
"eslint": "^9.21.0",
|
||||
"node-loader": "^2.1.0",
|
||||
"prettier": "^3.4.2",
|
||||
"typescript": "^5.7.3"
|
||||
"prettier": "^3.5.3",
|
||||
"typescript": "^5.8.2"
|
||||
}
|
||||
}
|
||||
@@ -20,8 +20,7 @@ import type { SuperJSONResult } from "superjson";
|
||||
import type { AppRouter } from "@homarr/api";
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { createHeadersCallbackForSource, getTrpcUrl } from "@homarr/api/shared";
|
||||
|
||||
import { env } from "~/env";
|
||||
import { env } from "@homarr/common/env";
|
||||
|
||||
const getWebSocketProtocol = () => {
|
||||
// window is not defined on server side
|
||||
@@ -66,7 +65,7 @@ export function TRPCReactProvider(props: PropsWithChildren) {
|
||||
links: [
|
||||
loggerLink({
|
||||
enabled: (opts) =>
|
||||
process.env.NODE_ENV === "development" || (opts.direction === "down" && opts.result instanceof Error),
|
||||
env.NODE_ENV === "development" || (opts.direction === "down" && opts.result instanceof Error),
|
||||
}),
|
||||
splitLink({
|
||||
condition: ({ type }) => type === "subscription",
|
||||
|
||||
@@ -5,7 +5,7 @@ import { Box, LoadingOverlay, Stack } from "@mantine/core";
|
||||
|
||||
import type { RouterOutputs } from "@homarr/api";
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { useRequiredBoard } from "@homarr/boards/context";
|
||||
import { useCurrentLayout, useRequiredBoard } from "@homarr/boards/context";
|
||||
|
||||
import { BoardCategorySection } from "~/components/board/sections/category-section";
|
||||
import { BoardEmptySection } from "~/components/board/sections/empty-section";
|
||||
@@ -43,6 +43,7 @@ export const useUpdateBoard = () => {
|
||||
|
||||
export const ClientBoard = () => {
|
||||
const board = useRequiredBoard();
|
||||
const currentLayoutId = useCurrentLayout();
|
||||
const isReady = useIsBoardReady();
|
||||
|
||||
const fullWidthSortedSections = board.sections
|
||||
@@ -63,9 +64,10 @@ export const ClientBoard = () => {
|
||||
<Stack ref={ref} h="100%" style={{ visibility: isReady ? "visible" : "hidden" }}>
|
||||
{fullWidthSortedSections.map((section) =>
|
||||
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>
|
||||
|
||||
@@ -13,7 +13,7 @@ import { getI18n } from "@homarr/translation/server";
|
||||
import { createMetaTitle } from "~/metadata";
|
||||
import { createBoardLayout } from "../_layout-creator";
|
||||
import type { Board } from "../_types";
|
||||
import { ClientBoard } from "./_client";
|
||||
import { DynamicClientBoard } from "./_dynamic-client";
|
||||
import { BoardContentHeaderActions } from "./_header-actions";
|
||||
|
||||
export type Params = Record<string, unknown>;
|
||||
@@ -37,13 +37,13 @@ export const createBoardContentPage = <TParams extends Record<string, unknown>>(
|
||||
|
||||
return (
|
||||
<IntegrationProvider integrations={integrations}>
|
||||
<ClientBoard />
|
||||
<DynamicClientBoard />
|
||||
</IntegrationProvider>
|
||||
);
|
||||
},
|
||||
generateMetadataAsync: async ({ params }: { params: TParams }): Promise<Metadata> => {
|
||||
generateMetadataAsync: async ({ params }: { params: Promise<TParams> }): Promise<Metadata> => {
|
||||
try {
|
||||
const board = await getInitialBoard(params);
|
||||
const board = await getInitialBoard(await params);
|
||||
const t = await getI18n();
|
||||
|
||||
return {
|
||||
|
||||
@@ -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 Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Group, Menu } from "@mantine/core";
|
||||
import { Group, Menu, ScrollArea } from "@mantine/core";
|
||||
import { useHotkeys } from "@mantine/hooks";
|
||||
import {
|
||||
IconBox,
|
||||
@@ -23,6 +23,7 @@ import { clientApi } from "@homarr/api/client";
|
||||
import { useRequiredBoard } from "@homarr/boards/context";
|
||||
import { useEditMode } from "@homarr/boards/edit-mode";
|
||||
import { revalidatePathActionAsync } from "@homarr/common/client";
|
||||
import { env } from "@homarr/common/env";
|
||||
import { useConfirmModal, useModalAction } from "@homarr/modals";
|
||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||
import { useI18n, useScopedI18n } from "@homarr/translation/client";
|
||||
@@ -33,7 +34,6 @@ import { useCategoryActions } from "~/components/board/sections/category/categor
|
||||
import { CategoryEditModal } from "~/components/board/sections/category/category-edit-modal";
|
||||
import { useDynamicSectionActions } from "~/components/board/sections/dynamic/dynamic-actions";
|
||||
import { HeaderButton } from "~/components/layout/header/button";
|
||||
import { env } from "~/env";
|
||||
|
||||
export const BoardContentHeaderActions = () => {
|
||||
const [isEditMode] = useEditMode();
|
||||
@@ -168,16 +168,18 @@ const SelectBoardsMenu = () => {
|
||||
</HeaderButton>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown style={{ transform: "translate(-7px, 0)" }}>
|
||||
{boards.map((board) => (
|
||||
<Menu.Item
|
||||
key={board.id}
|
||||
component={Link}
|
||||
href={`/boards/${board.name}`}
|
||||
leftSection={<IconLayoutBoard size={20} />}
|
||||
>
|
||||
{board.name}
|
||||
</Menu.Item>
|
||||
))}
|
||||
<ScrollArea.Autosize mah={300}>
|
||||
{boards.map((board) => (
|
||||
<Menu.Item
|
||||
key={board.id}
|
||||
component={Link}
|
||||
href={`/boards/${board.name}`}
|
||||
leftSection={<IconLayoutBoard size={20} />}
|
||||
>
|
||||
{board.name}
|
||||
</Menu.Item>
|
||||
))}
|
||||
</ScrollArea.Autosize>
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
);
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import type { PropsWithChildren } from "react";
|
||||
import type { MantineColorsTuple } from "@mantine/core";
|
||||
import { createTheme, darken, lighten, MantineProvider } from "@mantine/core";
|
||||
import { colorsTuple, createTheme, darken, lighten, MantineProvider } from "@mantine/core";
|
||||
|
||||
import { useRequiredBoard } from "@homarr/boards/context";
|
||||
import type { ColorScheme } from "@homarr/definitions";
|
||||
@@ -20,6 +20,7 @@ export const BoardMantineProvider = ({
|
||||
colors: {
|
||||
primaryColor: generateColors(board.primaryColor),
|
||||
secondaryColor: generateColors(board.secondaryColor),
|
||||
iconColor: board.iconColor ? generateColors(board.iconColor) : colorsTuple("#000000"),
|
||||
},
|
||||
primaryColor: "primaryColor",
|
||||
autoContrast: true,
|
||||
|
||||
+23
@@ -10,6 +10,7 @@ import {
|
||||
Group,
|
||||
InputWrapper,
|
||||
isLightColor,
|
||||
Select,
|
||||
Slider,
|
||||
Stack,
|
||||
Text,
|
||||
@@ -39,6 +40,8 @@ export const ColorSettingsContent = ({ board }: Props) => {
|
||||
primaryColor: board.primaryColor,
|
||||
secondaryColor: board.secondaryColor,
|
||||
opacity: board.opacity,
|
||||
iconColor: board.iconColor ?? "",
|
||||
itemRadius: board.itemRadius,
|
||||
},
|
||||
});
|
||||
const [showPreview, { toggle }] = useDisclosure(false);
|
||||
@@ -98,6 +101,26 @@ export const ColorSettingsContent = ({ board }: Props) => {
|
||||
/>
|
||||
</InputWrapper>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={{ sm: 12, md: 6 }}>
|
||||
<ColorInput
|
||||
label={t("board.field.iconColor.label")}
|
||||
format="hex"
|
||||
swatches={Object.values(theme.colors).map((color) => color[6])}
|
||||
{...form.getInputProps("iconColor")}
|
||||
/>
|
||||
<Select
|
||||
label={t("board.field.itemRadius.label")}
|
||||
description={t("board.field.itemRadius.description")}
|
||||
data={[
|
||||
{ label: t("board.field.itemRadius.option.xs"), value: "xs" },
|
||||
{ label: t("board.field.itemRadius.option.sm"), value: "sm" },
|
||||
{ label: t("board.field.itemRadius.option.md"), value: "md" },
|
||||
{ label: t("board.field.itemRadius.option.lg"), value: "lg" },
|
||||
{ label: t("board.field.itemRadius.option.xl"), value: "xl" },
|
||||
]}
|
||||
{...form.getInputProps("itemRadius")}
|
||||
/>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
<Group justify="end">
|
||||
<Button type="submit" loading={isPending} color="teal">
|
||||
@@ -1,43 +1,109 @@
|
||||
"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 { useI18n } from "@homarr/translation/client";
|
||||
import { validation } from "@homarr/validation";
|
||||
|
||||
import type { Board } from "../../_types";
|
||||
import { useSavePartialSettingsMutation } from "./_shared";
|
||||
|
||||
interface Props {
|
||||
board: Board;
|
||||
}
|
||||
export const LayoutSettingsContent = ({ board }: Props) => {
|
||||
const t = useI18n();
|
||||
const { mutate: savePartialSettings, isPending } = useSavePartialSettingsMutation(board);
|
||||
const form = useZodForm(validation.board.savePartialSettings.pick({ columnCount: true }).required(), {
|
||||
const utils = clientApi.useUtils();
|
||||
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: {
|
||||
columnCount: board.columnCount,
|
||||
layouts: board.layouts,
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={form.onSubmit((values) => {
|
||||
savePartialSettings({
|
||||
saveLayouts({
|
||||
id: board.id,
|
||||
...values,
|
||||
});
|
||||
})}
|
||||
>
|
||||
<Stack>
|
||||
<Grid>
|
||||
<Grid.Col span={{ sm: 12, md: 6 }}>
|
||||
<Input.Wrapper label={t("board.field.columnCount.label")}>
|
||||
<Slider mt="xs" min={1} max={24} step={1} {...form.getInputProps("columnCount")} />
|
||||
</Input.Wrapper>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
<Stack gap="sm">
|
||||
<Group justify="space-between" align="center">
|
||||
<Text fw={500}>{t("board.setting.section.layout.responsive.title")}</Text>
|
||||
<Button
|
||||
variant="subtle"
|
||||
onClick={() => {
|
||||
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">
|
||||
<Button type="submit" loading={isPending} color="teal">
|
||||
{t("common.action.saveChanges")}
|
||||
|
||||
@@ -23,10 +23,10 @@ import type { TablerIcon } from "@homarr/ui";
|
||||
|
||||
import { getBoardPermissionsAsync } from "~/components/board/permissions/server";
|
||||
import { ActiveTabAccordion } from "../../../../../components/active-tab-accordion";
|
||||
import { ColorSettingsContent } from "./_appereance";
|
||||
import { BackgroundSettingsContent } from "./_background";
|
||||
import { BehaviorSettingsContent } from "./_behavior";
|
||||
import { BoardAccessSettings } from "./_board-access";
|
||||
import { ColorSettingsContent } from "./_colors";
|
||||
import { CustomCssSettingsContent } from "./_customCss";
|
||||
import { DangerZoneSettingsContent } from "./_danger";
|
||||
import { GeneralSettingsContent } from "./_general";
|
||||
@@ -91,7 +91,7 @@ export default async function BoardSettingsPage(props: Props) {
|
||||
<AccordionItemFor value="background" icon={IconPhoto}>
|
||||
<BackgroundSettingsContent board={board} />
|
||||
</AccordionItemFor>
|
||||
<AccordionItemFor value="color" icon={IconBrush}>
|
||||
<AccordionItemFor value="appearance" icon={IconBrush}>
|
||||
<ColorSettingsContent board={board} />
|
||||
</AccordionItemFor>
|
||||
<AccordionItemFor value="customCss" icon={IconFileTypeCss}>
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import type { JSX, PropsWithChildren } from "react";
|
||||
import { notFound } from "next/navigation";
|
||||
import { notFound, redirect } from "next/navigation";
|
||||
import { AppShellMain } from "@mantine/core";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
|
||||
import { auth } from "@homarr/auth/next";
|
||||
import { BoardProvider } from "@homarr/boards/context";
|
||||
import { EditModeProvider } from "@homarr/boards/edit-mode";
|
||||
import { logger } from "@homarr/log";
|
||||
@@ -32,8 +33,14 @@ export const createBoardLayout = <TParams extends Params>({
|
||||
}: PropsWithChildren<{
|
||||
params: Promise<TParams>;
|
||||
}>) => {
|
||||
const session = await auth();
|
||||
const initialBoard = await getInitialBoard(await params).catch((error) => {
|
||||
if (error instanceof TRPCError && error.code === "NOT_FOUND") {
|
||||
if (!session) {
|
||||
logger.debug("No home board found for anonymous user, redirecting to login");
|
||||
redirect("/auth/login");
|
||||
}
|
||||
|
||||
logger.warn(error);
|
||||
notFound();
|
||||
}
|
||||
|
||||
@@ -3,10 +3,14 @@ import type { WidgetKind } from "@homarr/definitions";
|
||||
|
||||
export type Board = RouterOutputs["board"]["getHomeBoard"];
|
||||
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 EmptySection = Extract<Section, { kind: "empty" }>;
|
||||
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 }>;
|
||||
@@ -7,12 +7,11 @@ import type { z } from "zod";
|
||||
import type { RouterOutputs } from "@homarr/api";
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { revalidatePathActionAsync } from "@homarr/common/client";
|
||||
import { AppForm } from "@homarr/forms-collection";
|
||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||
import { useI18n, useScopedI18n } from "@homarr/translation/client";
|
||||
import type { validation } from "@homarr/validation";
|
||||
|
||||
import { AppForm } from "../../_form";
|
||||
|
||||
interface AppEditFormProps {
|
||||
app: RouterOutputs["app"]["byId"];
|
||||
}
|
||||
@@ -58,6 +57,7 @@ export const AppEditForm = ({ app }: AppEditFormProps) => {
|
||||
initialValues={app}
|
||||
handleSubmit={handleSubmit}
|
||||
isPending={isPending}
|
||||
showBackToOverview
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -2,10 +2,10 @@ import { notFound } from "next/navigation";
|
||||
import { Container, Stack, Title } from "@mantine/core";
|
||||
|
||||
import { auth } from "@homarr/auth/next";
|
||||
import { AppNewForm } from "@homarr/forms-collection";
|
||||
import { getI18n } from "@homarr/translation/server";
|
||||
|
||||
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
|
||||
import { AppNewForm } from "./_app-new-form";
|
||||
|
||||
export default async function AppNewPage() {
|
||||
const session = await auth();
|
||||
@@ -22,7 +22,7 @@ export default async function AppNewPage() {
|
||||
<Container>
|
||||
<Stack>
|
||||
<Title>{t("app.page.create.title")}</Title>
|
||||
<AppNewForm />
|
||||
<AppNewForm showBackToOverview showCreateAnother />
|
||||
</Stack>
|
||||
</Container>
|
||||
</>
|
||||
|
||||
@@ -61,7 +61,8 @@ export default async function AppsPage(props: AppsPageProps) {
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
<Group justify="end">
|
||||
{/* Added margin to not hide pagination behind affix-button */}
|
||||
<Group justify="end" mb={48}>
|
||||
<TablePagination total={Math.ceil(totalCount / searchParams.pageSize)} />
|
||||
</Group>
|
||||
</Stack>
|
||||
|
||||
@@ -1,15 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import type { JSX } from "react";
|
||||
import { Button, FileButton } from "@mantine/core";
|
||||
import { Button } from "@mantine/core";
|
||||
import { IconUpload } from "@tabler/icons-react";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { revalidatePathActionAsync } from "@homarr/common/client";
|
||||
import type { MaybePromise } from "@homarr/common/types";
|
||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||
import { UploadMedia } from "@homarr/forms-collection";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import { supportedMediaUploadFormats } from "@homarr/validation";
|
||||
|
||||
export const UploadMediaButton = () => {
|
||||
const t = useI18n();
|
||||
@@ -27,45 +23,3 @@ export const UploadMediaButton = () => {
|
||||
</UploadMedia>
|
||||
);
|
||||
};
|
||||
|
||||
interface UploadMediaProps {
|
||||
children: (props: { onClick: () => void; loading: boolean }) => JSX.Element;
|
||||
onSettled?: () => MaybePromise<void>;
|
||||
onSuccess?: (media: { id: string; url: string }) => MaybePromise<void>;
|
||||
}
|
||||
|
||||
export const UploadMedia = ({ children, onSettled, onSuccess }: UploadMediaProps) => {
|
||||
const t = useI18n();
|
||||
const { mutateAsync, isPending } = clientApi.media.uploadMedia.useMutation();
|
||||
|
||||
const handleFileUploadAsync = async (file: File | null) => {
|
||||
if (!file) return;
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
await mutateAsync(formData, {
|
||||
async onSuccess(mediaId) {
|
||||
showSuccessNotification({
|
||||
message: t("media.action.upload.notification.success.message"),
|
||||
});
|
||||
await onSuccess?.({
|
||||
id: mediaId,
|
||||
url: `/api/user-medias/${mediaId}`,
|
||||
});
|
||||
},
|
||||
onError() {
|
||||
showErrorNotification({
|
||||
message: t("media.action.upload.notification.error.message"),
|
||||
});
|
||||
},
|
||||
async onSettled() {
|
||||
await onSettled?.();
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<FileButton onChange={handleFileUploadAsync} accept={supportedMediaUploadFormats.join(",")}>
|
||||
{({ onClick }) => children({ onClick, loading: isPending })}
|
||||
</FileButton>
|
||||
);
|
||||
};
|
||||
@@ -1,10 +1,10 @@
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import {
|
||||
ActionIcon,
|
||||
Anchor,
|
||||
Group,
|
||||
Image,
|
||||
Stack,
|
||||
Table,
|
||||
TableTbody,
|
||||
@@ -92,7 +92,8 @@ export default async function GroupsListPage(props: MediaListPageProps) {
|
||||
</TableTbody>
|
||||
</Table>
|
||||
|
||||
<Group justify="end">
|
||||
{/* Added margin to not hide pagination behind affix-button */}
|
||||
<Group justify="end" mb={48}>
|
||||
<TablePagination total={Math.ceil(totalCount / searchParams.pageSize)} />
|
||||
</Group>
|
||||
</Stack>
|
||||
@@ -113,11 +114,12 @@ const Row = async ({ media }: RowProps) => {
|
||||
<TableTr>
|
||||
<TableTd w={64}>
|
||||
<Image
|
||||
// Switched to mantine image because next/image doesn't support svgs
|
||||
src={createLocalImageUrl(media.id)}
|
||||
alt={media.name}
|
||||
width={64}
|
||||
height={64}
|
||||
style={{ objectFit: "contain" }}
|
||||
w={64}
|
||||
h={64}
|
||||
fit="contain"
|
||||
/>
|
||||
</TableTd>
|
||||
<TableTd>{media.name}</TableTd>
|
||||
|
||||
@@ -9,12 +9,11 @@ import type { z } from "zod";
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { searchEngineTypes } from "@homarr/definitions";
|
||||
import { useZodForm } from "@homarr/form";
|
||||
import { IconPicker } from "@homarr/forms-collection";
|
||||
import type { TranslationFunction } from "@homarr/translation";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import { validation } from "@homarr/validation";
|
||||
|
||||
import { IconPicker } from "~/components/icons/picker/icon-picker";
|
||||
|
||||
type FormType = z.infer<typeof validation.searchEngine.manage>;
|
||||
|
||||
interface SearchEngineFormProps {
|
||||
|
||||
@@ -61,7 +61,8 @@ export default async function SearchEnginesPage(props: SearchEnginesPageProps) {
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
<Group justify="end">
|
||||
{/* Added margin to not hide pagination behind affix-button */}
|
||||
<Group justify="end" mb={48}>
|
||||
<TablePagination total={Math.ceil(totalCount / searchParams.pageSize)} />
|
||||
</Group>
|
||||
</Stack>
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import { Group, Switch, Text } from "@mantine/core";
|
||||
import { IconLayoutDashboard } from "@tabler/icons-react";
|
||||
import { Switch, Text } from "@mantine/core";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import type { ServerSettings } from "@homarr/server-settings";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
import { SelectWithCustomItems } from "@homarr/ui";
|
||||
|
||||
import { BoardSelect } from "~/components/board/board-select";
|
||||
import { CommonSettingsForm } from "./common-form";
|
||||
|
||||
export const BoardSettingsForm = ({ defaultValues }: { defaultValues: ServerSettings["board"] }) => {
|
||||
@@ -18,42 +17,19 @@ export const BoardSettingsForm = ({ defaultValues }: { defaultValues: ServerSett
|
||||
<CommonSettingsForm settingKey="board" defaultValues={defaultValues}>
|
||||
{(form) => (
|
||||
<>
|
||||
<SelectWithCustomItems
|
||||
<BoardSelect
|
||||
label={tBoard("homeBoard.label")}
|
||||
description={tBoard("homeBoard.description")}
|
||||
data={selectableBoards.map((board) => ({
|
||||
value: board.id,
|
||||
label: board.name,
|
||||
image: board.logoImageUrl,
|
||||
}))}
|
||||
SelectOption={({ label, image }: { value: string; label: string; image: string | null }) => (
|
||||
<Group>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
{image ? <img width={16} height={16} src={image} alt={label} /> : <IconLayoutDashboard size={16} />}
|
||||
<Text fz="sm" fw={500}>
|
||||
{label}
|
||||
</Text>
|
||||
</Group>
|
||||
)}
|
||||
clearable
|
||||
boards={selectableBoards}
|
||||
{...form.getInputProps("homeBoardId")}
|
||||
/>
|
||||
<SelectWithCustomItems
|
||||
|
||||
<BoardSelect
|
||||
label={tBoard("homeBoard.mobileLabel")}
|
||||
description={tBoard("homeBoard.description")}
|
||||
data={selectableBoards.map((board) => ({
|
||||
value: board.id,
|
||||
label: board.name,
|
||||
image: board.logoImageUrl,
|
||||
}))}
|
||||
SelectOption={({ label, image }: { value: string; label: string; image: string | null }) => (
|
||||
<Group>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
{image ? <img width={16} height={16} src={image} alt={label} /> : <IconLayoutDashboard size={16} />}
|
||||
<Text fz="sm" fw={500}>
|
||||
{label}
|
||||
</Text>
|
||||
</Group>
|
||||
)}
|
||||
clearable
|
||||
boards={selectableBoards}
|
||||
{...form.getInputProps("mobileHomeBoardId")}
|
||||
/>
|
||||
|
||||
|
||||
@@ -1434,26 +1434,30 @@
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-button:vertical:start:decrement {
|
||||
background: linear-gradient(130deg, #696969 40%, rgba(255, 0, 0, 0) 41%),
|
||||
background:
|
||||
linear-gradient(130deg, #696969 40%, rgba(255, 0, 0, 0) 41%),
|
||||
linear-gradient(230deg, #696969 40%, transparent 41%), linear-gradient(0deg, #696969 40%, transparent 31%);
|
||||
background-color: #b6b6b6;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-button:vertical:end:increment {
|
||||
background: linear-gradient(310deg, #696969 40%, transparent 41%),
|
||||
linear-gradient(50deg, #696969 40%, transparent 41%), linear-gradient(180deg, #696969 40%, transparent 31%);
|
||||
background:
|
||||
linear-gradient(310deg, #696969 40%, transparent 41%), linear-gradient(50deg, #696969 40%, transparent 41%),
|
||||
linear-gradient(180deg, #696969 40%, transparent 31%);
|
||||
background-color: #b6b6b6;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-button:horizontal:end:increment {
|
||||
background: linear-gradient(210deg, #696969 40%, transparent 41%),
|
||||
linear-gradient(330deg, #696969 40%, transparent 41%), linear-gradient(90deg, #696969 30%, transparent 31%);
|
||||
background:
|
||||
linear-gradient(210deg, #696969 40%, transparent 41%), linear-gradient(330deg, #696969 40%, transparent 41%),
|
||||
linear-gradient(90deg, #696969 30%, transparent 31%);
|
||||
background-color: #b6b6b6;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-button:horizontal:start:decrement {
|
||||
background: linear-gradient(30deg, #696969 40%, transparent 41%),
|
||||
linear-gradient(150deg, #696969 40%, transparent 41%), linear-gradient(270deg, #696969 30%, transparent 31%);
|
||||
background:
|
||||
linear-gradient(30deg, #696969 40%, transparent 41%), linear-gradient(150deg, #696969 40%, transparent 41%),
|
||||
linear-gradient(270deg, #696969 30%, transparent 31%);
|
||||
background-color: #b6b6b6;
|
||||
}
|
||||
|
||||
@@ -1681,28 +1685,32 @@
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-button:vertical:start:decrement {
|
||||
background: linear-gradient(130deg, #696969 40%, rgba(255, 0, 0, 0) 41%),
|
||||
background:
|
||||
linear-gradient(130deg, #696969 40%, rgba(255, 0, 0, 0) 41%),
|
||||
linear-gradient(230deg, #696969 40%, rgba(0, 0, 0, 0) 41%),
|
||||
linear-gradient(0deg, #696969 40%, rgba(0, 0, 0, 0) 31%);
|
||||
background-color: #b6b6b6;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-button:vertical:end:increment {
|
||||
background: linear-gradient(310deg, #696969 40%, rgba(0, 0, 0, 0) 41%),
|
||||
background:
|
||||
linear-gradient(310deg, #696969 40%, rgba(0, 0, 0, 0) 41%),
|
||||
linear-gradient(50deg, #696969 40%, rgba(0, 0, 0, 0) 41%),
|
||||
linear-gradient(180deg, #696969 40%, rgba(0, 0, 0, 0) 31%);
|
||||
background-color: #b6b6b6;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-button:horizontal:end:increment {
|
||||
background: linear-gradient(210deg, #696969 40%, rgba(0, 0, 0, 0) 41%),
|
||||
background:
|
||||
linear-gradient(210deg, #696969 40%, rgba(0, 0, 0, 0) 41%),
|
||||
linear-gradient(330deg, #696969 40%, rgba(0, 0, 0, 0) 41%),
|
||||
linear-gradient(90deg, #696969 30%, rgba(0, 0, 0, 0) 31%);
|
||||
background-color: #b6b6b6;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-button:horizontal:start:decrement {
|
||||
background: linear-gradient(30deg, #696969 40%, rgba(0, 0, 0, 0) 41%),
|
||||
background:
|
||||
linear-gradient(30deg, #696969 40%, rgba(0, 0, 0, 0) 41%),
|
||||
linear-gradient(150deg, #696969 40%, rgba(0, 0, 0, 0) 41%),
|
||||
linear-gradient(270deg, #696969 30%, rgba(0, 0, 0, 0) 31%);
|
||||
background-color: #b6b6b6;
|
||||
|
||||
@@ -35,7 +35,13 @@ const createColumns = (
|
||||
Cell({ renderedCellValue, row }) {
|
||||
return (
|
||||
<Group gap="xs">
|
||||
<Avatar variant="outline" radius="lg" size="md" src={row.original.iconUrl}>
|
||||
<Avatar
|
||||
variant="outline"
|
||||
radius="lg"
|
||||
size="md"
|
||||
styles={{ image: { objectFit: "contain" } }}
|
||||
src={row.original.iconUrl}
|
||||
>
|
||||
{row.original.name.at(0)?.toUpperCase()}
|
||||
</Avatar>
|
||||
<Text>{renderedCellValue}</Text>
|
||||
|
||||
+11
-6
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { Button, Group, Select, Stack } from "@mantine/core";
|
||||
import { Button, Group, Stack } from "@mantine/core";
|
||||
import type { z } from "zod";
|
||||
|
||||
import type { RouterOutputs } from "@homarr/api";
|
||||
@@ -11,9 +11,12 @@ import { showErrorNotification, showSuccessNotification } from "@homarr/notifica
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import { validation } from "@homarr/validation";
|
||||
|
||||
import type { Board } from "~/app/[locale]/boards/_types";
|
||||
import { BoardSelect } from "~/components/board/board-select";
|
||||
|
||||
interface ChangeHomeBoardFormProps {
|
||||
user: RouterOutputs["user"]["getById"];
|
||||
boardsData: { value: string; label: string }[];
|
||||
boardsData: Pick<Board, "id" | "name" | "logoImageUrl">[];
|
||||
}
|
||||
|
||||
export const ChangeHomeBoardForm = ({ user, boardsData }: ChangeHomeBoardFormProps) => {
|
||||
@@ -54,16 +57,18 @@ export const ChangeHomeBoardForm = ({ user, boardsData }: ChangeHomeBoardFormPro
|
||||
return (
|
||||
<form onSubmit={form.onSubmit(handleSubmit)}>
|
||||
<Stack gap="md">
|
||||
<Select
|
||||
<BoardSelect
|
||||
label={t("management.page.user.setting.general.item.board.type.general")}
|
||||
clearable
|
||||
boards={boardsData}
|
||||
w="100%"
|
||||
data={boardsData}
|
||||
{...form.getInputProps("homeBoardId")}
|
||||
/>
|
||||
<Select
|
||||
<BoardSelect
|
||||
label={t("management.page.user.setting.general.item.board.type.mobile")}
|
||||
clearable
|
||||
boards={boardsData}
|
||||
w="100%"
|
||||
data={boardsData}
|
||||
{...form.getInputProps("mobileHomeBoardId")}
|
||||
/>
|
||||
|
||||
|
||||
@@ -95,8 +95,9 @@ export default async function EditUserPage(props: Props) {
|
||||
<ChangeHomeBoardForm
|
||||
user={user}
|
||||
boardsData={boards.map((board) => ({
|
||||
value: board.id,
|
||||
label: board.name,
|
||||
id: board.id,
|
||||
name: board.name,
|
||||
logoImageUrl: board.logoImageUrl,
|
||||
}))}
|
||||
/>
|
||||
</Stack>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { PropsWithChildren } from "react";
|
||||
import Link from "next/link";
|
||||
import { Button, Grid, GridCol, Group, Stack, Text, Title } from "@mantine/core";
|
||||
import { IconLock, IconSettings, IconUsersGroup } from "@tabler/icons-react";
|
||||
import { IconId, IconLock, IconSettings, IconUsersGroup } from "@tabler/icons-react";
|
||||
|
||||
import { api } from "@homarr/api/server";
|
||||
import { getI18n, getScopedI18n } from "@homarr/translation/server";
|
||||
@@ -42,6 +42,11 @@ export default async function Layout(props: PropsWithChildren<LayoutProps>) {
|
||||
<NavigationLink
|
||||
href={`/manage/users/groups/${params.id}`}
|
||||
label={tGroup("setting.general.title")}
|
||||
icon={<IconId size="1rem" stroke={1.5} />}
|
||||
/>
|
||||
<NavigationLink
|
||||
href={`/manage/users/groups/${params.id}/settings`}
|
||||
label={tGroup("setting.setting.title")}
|
||||
icon={<IconSettings size="1rem" stroke={1.5} />}
|
||||
/>
|
||||
<NavigationLink
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
"use client";
|
||||
|
||||
import { Button, Group, Stack } from "@mantine/core";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { useZodForm } from "@homarr/form";
|
||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import { validation } from "@homarr/validation";
|
||||
|
||||
import { BoardSelect } from "~/components/board/board-select";
|
||||
|
||||
interface GroupHomeBoardsProps {
|
||||
homeBoardId: string | null;
|
||||
mobileHomeBoardId: string | null;
|
||||
groupId: string;
|
||||
}
|
||||
|
||||
export const GroupHomeBoards = ({ homeBoardId, mobileHomeBoardId, groupId }: GroupHomeBoardsProps) => {
|
||||
const t = useI18n();
|
||||
const [availableBoards] = clientApi.board.getBoardsForGroup.useSuspenseQuery({ groupId });
|
||||
const form = useZodForm(validation.group.settings.pick({ homeBoardId: true, mobileHomeBoardId: true }), {
|
||||
initialValues: {
|
||||
homeBoardId,
|
||||
mobileHomeBoardId,
|
||||
},
|
||||
});
|
||||
const { mutateAsync, isPending } = clientApi.group.savePartialSettings.useMutation();
|
||||
|
||||
const handleSubmit = form.onSubmit(async (values) => {
|
||||
await mutateAsync(
|
||||
{
|
||||
id: groupId,
|
||||
settings: values,
|
||||
},
|
||||
{
|
||||
onSuccess() {
|
||||
form.setInitialValues(values);
|
||||
showSuccessNotification({
|
||||
title: t("group.action.settings.board.notification.success.title"),
|
||||
message: t("group.action.settings.board.notification.success.message"),
|
||||
});
|
||||
},
|
||||
onError() {
|
||||
showErrorNotification({
|
||||
title: t("group.action.settings.board.notification.error.title"),
|
||||
message: t("group.action.settings.board.notification.error.message"),
|
||||
});
|
||||
},
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit}>
|
||||
<Stack gap="md">
|
||||
<BoardSelect
|
||||
label={t("group.field.homeBoard.label")}
|
||||
description={t("group.field.homeBoard.description")}
|
||||
clearable
|
||||
boards={availableBoards}
|
||||
{...form.getInputProps("homeBoardId")}
|
||||
/>
|
||||
|
||||
<BoardSelect
|
||||
label={t("group.field.mobileBoard.label")}
|
||||
description={t("group.field.mobileBoard.description")}
|
||||
clearable
|
||||
boards={availableBoards}
|
||||
{...form.getInputProps("mobileHomeBoardId")}
|
||||
/>
|
||||
|
||||
<Group justify="end">
|
||||
<Button type="submit" color="teal" loading={isPending}>
|
||||
{t("common.action.save")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,41 @@
|
||||
import { notFound } from "next/navigation";
|
||||
import { Alert, Stack, Title } from "@mantine/core";
|
||||
import { IconExclamationCircle } from "@tabler/icons-react";
|
||||
|
||||
import { api } from "@homarr/api/server";
|
||||
import { auth } from "@homarr/auth/next";
|
||||
import { getI18n } from "@homarr/translation/server";
|
||||
|
||||
import { GroupHomeBoards } from "./_group-home-boards";
|
||||
|
||||
interface GroupSettingsPageProps {
|
||||
params: Promise<{
|
||||
id: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
export default async function GroupPermissionsPage(props: GroupSettingsPageProps) {
|
||||
const params = await props.params;
|
||||
const session = await auth();
|
||||
|
||||
if (!session?.user.permissions.includes("admin")) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const group = await api.group.getById({ id: params.id });
|
||||
const t = await getI18n();
|
||||
|
||||
return (
|
||||
<Stack>
|
||||
<Title>{t("management.page.group.setting.setting.title")}</Title>
|
||||
|
||||
<Alert color="cyan" icon={<IconExclamationCircle size="1rem" stroke={1.5} />}>
|
||||
{t("management.page.group.setting.setting.alert")}
|
||||
</Alert>
|
||||
|
||||
<Title order={3}>{t("management.page.group.setting.setting.board.title")}</Title>
|
||||
|
||||
<GroupHomeBoards homeBoardId={group.homeBoardId} mobileHomeBoardId={group.mobileHomeBoardId} groupId={group.id} />
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback } from "react";
|
||||
|
||||
import { useModalAction } from "@homarr/modals";
|
||||
import { AddGroupModal } from "@homarr/modals-collection";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
|
||||
import { MobileAffixButton } from "~/components/manage/mobile-affix-button";
|
||||
|
||||
export const AddGroup = () => {
|
||||
const t = useI18n();
|
||||
const { openModal } = useModalAction(AddGroupModal);
|
||||
|
||||
const handleAddGroup = useCallback(() => {
|
||||
openModal();
|
||||
}, [openModal]);
|
||||
|
||||
return (
|
||||
<MobileAffixButton onClick={handleAddGroup} color="teal">
|
||||
{t("group.action.create.label")}
|
||||
</MobileAffixButton>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,65 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { Group, TextInput } from "@mantine/core";
|
||||
import { IconSearch } from "@tabler/icons-react";
|
||||
|
||||
import type { RouterOutputs } from "@homarr/api";
|
||||
import { useModalAction } from "@homarr/modals";
|
||||
import { AddGroupModal } from "@homarr/modals-collection";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
|
||||
import { MobileAffixButton } from "~/components/manage/mobile-affix-button";
|
||||
import { GroupsTable } from "./_groups-table";
|
||||
|
||||
interface GroupsListProps {
|
||||
groups: RouterOutputs["group"]["getAll"];
|
||||
}
|
||||
|
||||
export const GroupsList = ({ groups }: GroupsListProps) => {
|
||||
const [search, setSearch] = useState("");
|
||||
const initialGroupIds = useMemo(
|
||||
() => groups.sort((groupA, groupB) => groupA.position - groupB.position).map((group) => group.id),
|
||||
[groups],
|
||||
);
|
||||
const filteredGroups = useMemo(
|
||||
() =>
|
||||
groups
|
||||
.filter((group) => group.name.toLowerCase().includes(search.toLowerCase()))
|
||||
.sort((groupA, groupB) => groupA.position - groupB.position),
|
||||
[groups, search],
|
||||
);
|
||||
const t = useI18n();
|
||||
|
||||
return (
|
||||
<>
|
||||
<Group justify="space-between">
|
||||
<TextInput
|
||||
leftSection={<IconSearch size={20} stroke={1.5} />}
|
||||
value={search}
|
||||
onChange={(event) => setSearch(event.currentTarget.value)}
|
||||
placeholder={`${t("group.search")}...`}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<AddGroup />
|
||||
</Group>
|
||||
|
||||
<GroupsTable groups={filteredGroups} initialGroupIds={initialGroupIds} hasFilter={search.length !== 0} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const AddGroup = () => {
|
||||
const t = useI18n();
|
||||
const { openModal } = useModalAction(AddGroupModal);
|
||||
|
||||
const handleAddGroup = useCallback(() => {
|
||||
openModal();
|
||||
}, [openModal]);
|
||||
|
||||
return (
|
||||
<MobileAffixButton onClick={handleAddGroup} color="teal">
|
||||
{t("group.action.create.label")}
|
||||
</MobileAffixButton>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,277 @@
|
||||
"use client";
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import { useMemo, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import type { DragEndEvent, DraggableAttributes, DragStartEvent } from "@dnd-kit/core";
|
||||
import {
|
||||
closestCenter,
|
||||
DndContext,
|
||||
DragOverlay,
|
||||
KeyboardSensor,
|
||||
MouseSensor,
|
||||
TouchSensor,
|
||||
useSensor,
|
||||
useSensors,
|
||||
} from "@dnd-kit/core";
|
||||
import type { SyntheticListenerMap } from "@dnd-kit/core/dist/hooks/utilities";
|
||||
import { restrictToVerticalAxis } from "@dnd-kit/modifiers";
|
||||
import { arrayMove, SortableContext, useSortable, verticalListSortingStrategy } from "@dnd-kit/sortable";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
import {
|
||||
Anchor,
|
||||
Box,
|
||||
Button,
|
||||
Card,
|
||||
Flex,
|
||||
Group,
|
||||
Table,
|
||||
TableTbody,
|
||||
TableTd,
|
||||
TableTh,
|
||||
TableThead,
|
||||
TableTr,
|
||||
Text,
|
||||
Transition,
|
||||
} from "@mantine/core";
|
||||
import { IconGripVertical } from "@tabler/icons-react";
|
||||
|
||||
import type { RouterOutputs } from "@homarr/api";
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { revalidatePathActionAsync } from "@homarr/common/client";
|
||||
import { showSuccessNotification } from "@homarr/notifications";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import { UserAvatarGroup } from "@homarr/ui";
|
||||
|
||||
interface GroupsTableProps {
|
||||
initialGroupIds: string[];
|
||||
groups: RouterOutputs["group"]["getAll"];
|
||||
hasFilter: boolean;
|
||||
}
|
||||
|
||||
export const GroupsTable = ({ groups, initialGroupIds, hasFilter }: GroupsTableProps) => {
|
||||
const t = useI18n();
|
||||
const [activeId, setActiveId] = useState<string | null>(null);
|
||||
const [groupIds, setGroupIds] = useState(groups.map((group) => group.id));
|
||||
const isDirty = useMemo(
|
||||
() => initialGroupIds.some((groupId, index) => groupIds.indexOf(groupId) !== index),
|
||||
[groupIds, initialGroupIds],
|
||||
);
|
||||
const { mutateAsync, isPending } = clientApi.group.savePositions.useMutation();
|
||||
const handleSavePositionsAsync = async () => {
|
||||
await mutateAsync(
|
||||
{ positions: groupIds },
|
||||
{
|
||||
async onSuccess() {
|
||||
showSuccessNotification({
|
||||
message: t("group.action.changePosition.notification.success.message"),
|
||||
});
|
||||
await revalidatePathActionAsync("/manage/users/groups");
|
||||
},
|
||||
onError() {
|
||||
showSuccessNotification({
|
||||
message: t("group.action.changePosition.notification.error.message"),
|
||||
});
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const sensors = useSensors(useSensor(MouseSensor, {}), useSensor(TouchSensor, {}), useSensor(KeyboardSensor, {}));
|
||||
|
||||
const handleDragStart = (event: DragStartEvent) => {
|
||||
setActiveId(event.active.id as string);
|
||||
};
|
||||
|
||||
function handleDragEnd(event: DragEndEvent) {
|
||||
const { active, over } = event;
|
||||
if (!over || active.id === over.id) {
|
||||
setActiveId(null);
|
||||
return;
|
||||
}
|
||||
|
||||
setGroupIds((groupIds) => {
|
||||
const oldIndex = groupIds.indexOf(active.id as string);
|
||||
const newIndex = groupIds.indexOf(over.id as string);
|
||||
return arrayMove(groupIds, oldIndex, newIndex);
|
||||
});
|
||||
}
|
||||
|
||||
function handleDragCancel() {
|
||||
setActiveId(null);
|
||||
}
|
||||
|
||||
const selectedRow = useMemo(() => {
|
||||
if (!activeId) return null;
|
||||
|
||||
const current = groups.find((group) => group.id === activeId);
|
||||
if (!current) return null;
|
||||
|
||||
return <Row group={current} handle={<DragHandle attributes={undefined} listeners={undefined} active />} />;
|
||||
}, [activeId, groups]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
onDragEnd={handleDragEnd}
|
||||
onDragStart={handleDragStart}
|
||||
onDragCancel={handleDragCancel}
|
||||
collisionDetection={closestCenter}
|
||||
modifiers={[restrictToVerticalAxis]}
|
||||
id="groups-table"
|
||||
>
|
||||
<Table striped highlightOnHover>
|
||||
<TableThead>
|
||||
<TableTr>
|
||||
<TableTh>{t("group.field.name")}</TableTh>
|
||||
<TableTh>{t("group.field.members")}</TableTh>
|
||||
</TableTr>
|
||||
</TableThead>
|
||||
<TableTbody>
|
||||
<SortableContext items={groupIds} strategy={verticalListSortingStrategy}>
|
||||
{groupIds.map((groupId) => {
|
||||
const group = groups.find(({ id }) => id === groupId);
|
||||
if (!group) return null;
|
||||
|
||||
return <DraggableRow key={group.id} group={group} disabled={hasFilter} />;
|
||||
})}
|
||||
</SortableContext>
|
||||
</TableTbody>
|
||||
</Table>
|
||||
|
||||
<DragOverlay>
|
||||
{activeId && (
|
||||
<Table w="100%">
|
||||
<TableTbody>{selectedRow}</TableTbody>
|
||||
</Table>
|
||||
)}
|
||||
</DragOverlay>
|
||||
</DndContext>
|
||||
<SaveAffix
|
||||
visible={isDirty}
|
||||
onDiscard={() => setGroupIds(initialGroupIds)}
|
||||
isPending={isPending}
|
||||
onSave={handleSavePositionsAsync}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
interface DraggableRowProps {
|
||||
group: RouterOutputs["group"]["getAll"][number];
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
const DraggableRow = ({ group, disabled }: DraggableRowProps) => {
|
||||
const { attributes, listeners, transform, transition, setNodeRef, isDragging } = useSortable({
|
||||
id: group.id,
|
||||
});
|
||||
|
||||
const style = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
};
|
||||
|
||||
if (isDragging) {
|
||||
return (
|
||||
<TableTr ref={setNodeRef} style={style}>
|
||||
<TableTd colSpan={2}> </TableTd>
|
||||
</TableTr>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Row
|
||||
group={group}
|
||||
setNodeRef={setNodeRef}
|
||||
style={style}
|
||||
handle={<DragHandle attributes={attributes} listeners={listeners} active={false} disabled={disabled} />}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
interface RowProps {
|
||||
group: RouterOutputs["group"]["getAll"][number];
|
||||
handle?: ReactNode;
|
||||
setNodeRef?: (node: HTMLElement | null) => void;
|
||||
style?: React.CSSProperties;
|
||||
}
|
||||
|
||||
const Row = ({ group, handle, setNodeRef, style }: RowProps) => {
|
||||
return (
|
||||
<TableTr ref={setNodeRef} style={style}>
|
||||
<TableTd>
|
||||
<Group>
|
||||
{handle}
|
||||
<Anchor component={Link} href={`/manage/users/groups/${group.id}`}>
|
||||
{group.name}
|
||||
</Anchor>
|
||||
</Group>
|
||||
</TableTd>
|
||||
<TableTd>
|
||||
<UserAvatarGroup users={group.members} size="sm" limit={5} />
|
||||
</TableTd>
|
||||
</TableTr>
|
||||
);
|
||||
};
|
||||
|
||||
interface DragHandleProps {
|
||||
attributes: DraggableAttributes | undefined;
|
||||
listeners: SyntheticListenerMap | undefined;
|
||||
active: boolean;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
const DragHandle = ({ attributes, listeners, active, disabled }: DragHandleProps) => {
|
||||
if (disabled) {
|
||||
return <Box w={40} h="100%" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Flex
|
||||
align="center"
|
||||
justify="center"
|
||||
h="100%"
|
||||
w={40}
|
||||
style={{ cursor: active ? "grabbing" : "grab" }}
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
>
|
||||
<IconGripVertical size={18} stroke={1.5} />
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
|
||||
interface SaveAffixProps {
|
||||
visible: boolean;
|
||||
isPending: boolean;
|
||||
onDiscard: () => void;
|
||||
onSave: () => void;
|
||||
}
|
||||
|
||||
const SaveAffix = ({ visible, isPending, onDiscard, onSave }: SaveAffixProps) => {
|
||||
const t = useI18n();
|
||||
|
||||
return (
|
||||
<div style={{ position: "sticky", bottom: 20 }}>
|
||||
<Transition transition="slide-up" mounted={visible}>
|
||||
{(transitionStyles) => (
|
||||
<Card style={transitionStyles} withBorder>
|
||||
<Group justify="space-between">
|
||||
<Text fw={500}>{t("common.unsavedChanges")}</Text>
|
||||
<Group>
|
||||
<Button disabled={isPending} onClick={onDiscard}>
|
||||
{t("common.action.discard")}
|
||||
</Button>
|
||||
<Button color="teal" loading={isPending} onClick={onSave}>
|
||||
{t("common.action.saveChanges")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Group>
|
||||
</Card>
|
||||
)}
|
||||
</Transition>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,7 @@
|
||||
.everyoneGroup {
|
||||
background-color: light-dark(var(--mantine-color-gray-1), var(--mantine-color-dark-6));
|
||||
}
|
||||
|
||||
.everyoneGroup:hover {
|
||||
background-color: light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-5));
|
||||
}
|
||||
@@ -1,30 +1,19 @@
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import { Anchor, Group, Stack, Table, TableTbody, TableTd, TableTh, TableThead, TableTr, Title } from "@mantine/core";
|
||||
import { z } from "zod";
|
||||
import { Card, Group, Stack, Text, ThemeIcon, Title, UnstyledButton } from "@mantine/core";
|
||||
import { IconChevronRight, IconUsersGroup } from "@tabler/icons-react";
|
||||
|
||||
import type { RouterOutputs } from "@homarr/api";
|
||||
import { api } from "@homarr/api/server";
|
||||
import { auth } from "@homarr/auth/next";
|
||||
import type { inferSearchParamsFromSchema } from "@homarr/common/types";
|
||||
import { everyoneGroup } from "@homarr/definitions";
|
||||
import { getI18n } from "@homarr/translation/server";
|
||||
import { SearchInput, TablePagination, UserAvatarGroup } from "@homarr/ui";
|
||||
|
||||
import { ManageContainer } from "~/components/manage/manage-container";
|
||||
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
|
||||
import { AddGroup } from "./_add-group";
|
||||
import { GroupsList } from "./_client";
|
||||
import classes from "./groups.module.css";
|
||||
|
||||
const searchParamsSchema = z.object({
|
||||
search: z.string().optional(),
|
||||
pageSize: z.string().regex(/\d+/).transform(Number).catch(10),
|
||||
page: z.string().regex(/\d+/).transform(Number).catch(1),
|
||||
});
|
||||
|
||||
interface GroupsListPageProps {
|
||||
searchParams: Promise<inferSearchParamsFromSchema<typeof searchParamsSchema>>;
|
||||
}
|
||||
|
||||
export default async function GroupsListPage(props: GroupsListPageProps) {
|
||||
export default async function GroupsListPage() {
|
||||
const session = await auth();
|
||||
|
||||
if (!session?.user.permissions.includes("admin")) {
|
||||
@@ -32,55 +21,38 @@ export default async function GroupsListPage(props: GroupsListPageProps) {
|
||||
}
|
||||
|
||||
const t = await getI18n();
|
||||
const searchParams = searchParamsSchema.parse(await props.searchParams);
|
||||
const { items: groups, totalCount } = await api.group.getPaginated(searchParams);
|
||||
const groups = await api.group.getAll();
|
||||
const dbEveryoneGroup = groups.find((group) => group.name === everyoneGroup);
|
||||
const groupsWithoutEveryone = groups.filter((group) => group.name !== everyoneGroup);
|
||||
|
||||
return (
|
||||
<ManageContainer size="xl">
|
||||
<DynamicBreadcrumb />
|
||||
<Stack>
|
||||
<Title>{t("group.title")}</Title>
|
||||
<Group justify="space-between">
|
||||
<SearchInput placeholder={`${t("group.search")}...`} defaultValue={searchParams.search} />
|
||||
<AddGroup />
|
||||
</Group>
|
||||
<Table striped highlightOnHover>
|
||||
<TableThead>
|
||||
<TableTr>
|
||||
<TableTh>{t("group.field.name")}</TableTh>
|
||||
<TableTh>{t("group.field.members")}</TableTh>
|
||||
</TableTr>
|
||||
</TableThead>
|
||||
<TableTbody>
|
||||
{groups.map((group) => (
|
||||
<Row key={group.id} group={group} />
|
||||
))}
|
||||
</TableTbody>
|
||||
</Table>
|
||||
|
||||
<Group justify="end">
|
||||
<TablePagination total={Math.ceil(totalCount / searchParams.pageSize)} />
|
||||
</Group>
|
||||
{dbEveryoneGroup && (
|
||||
<UnstyledButton component={Link} href={`/manage/users/groups/${dbEveryoneGroup.id}`}>
|
||||
<Card className={classes.everyoneGroup}>
|
||||
<Group align="center">
|
||||
<ThemeIcon radius="xl" variant="light">
|
||||
<IconUsersGroup size={16} />
|
||||
</ThemeIcon>
|
||||
|
||||
<Stack gap={0} flex={1}>
|
||||
<Text fw={500}>{t("group.defaultGroup.name")}</Text>
|
||||
<Text size="sm" c="gray.6">
|
||||
{t("group.defaultGroup.description", { name: everyoneGroup })}
|
||||
</Text>
|
||||
</Stack>
|
||||
<IconChevronRight size={20} />
|
||||
</Group>
|
||||
</Card>
|
||||
</UnstyledButton>
|
||||
)}
|
||||
|
||||
<GroupsList groups={groupsWithoutEveryone} />
|
||||
</Stack>
|
||||
</ManageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
interface RowProps {
|
||||
group: RouterOutputs["group"]["getPaginated"]["items"][number];
|
||||
}
|
||||
|
||||
const Row = ({ group }: RowProps) => {
|
||||
return (
|
||||
<TableTr>
|
||||
<TableTd>
|
||||
<Anchor component={Link} href={`/manage/users/groups/${group.id}`}>
|
||||
{group.name}
|
||||
</Anchor>
|
||||
</TableTd>
|
||||
<TableTd>
|
||||
<UserAvatarGroup users={group.members} size="sm" limit={5} />
|
||||
</TableTd>
|
||||
</TableTr>
|
||||
);
|
||||
};
|
||||
@@ -1,11 +1,11 @@
|
||||
import { notFound } from "next/navigation";
|
||||
import { Center } from "@mantine/core";
|
||||
|
||||
import { env } from "@homarr/common/env";
|
||||
import { db } from "@homarr/db";
|
||||
import type { WidgetKind } from "@homarr/definitions";
|
||||
import { widgetImports } from "@homarr/widgets";
|
||||
|
||||
import { env } from "~/env";
|
||||
import { WidgetPreviewPageContent } from "./_content";
|
||||
|
||||
interface Props {
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { Group, Text } from "@mantine/core";
|
||||
import { IconLayoutDashboard } from "@tabler/icons-react";
|
||||
|
||||
import type { SelectWithCustomItemsProps } from "@homarr/ui";
|
||||
import { SelectWithCustomItems } from "@homarr/ui";
|
||||
|
||||
import type { Board } from "~/app/[locale]/boards/_types";
|
||||
|
||||
interface BoardSelectProps extends Omit<SelectWithCustomItemsProps<{ value: string; label: string }>, "data"> {
|
||||
boards: Pick<Board, "id" | "name" | "logoImageUrl">[];
|
||||
}
|
||||
|
||||
export const BoardSelect = ({ boards, ...props }: BoardSelectProps) => {
|
||||
return (
|
||||
<SelectWithCustomItems
|
||||
{...props}
|
||||
data={boards.map((board) => ({
|
||||
value: board.id,
|
||||
label: board.name,
|
||||
image: board.logoImageUrl,
|
||||
}))}
|
||||
SelectOption={({ label, image }: { value: string; label: string; image: string | null }) => (
|
||||
<Group>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
{image ? <img width={16} height={16} src={image} alt={label} /> : <IconLayoutDashboard size={16} />}
|
||||
<Text fz="sm" fw={500}>
|
||||
{label}
|
||||
</Text>
|
||||
</Group>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -1,9 +1,11 @@
|
||||
import { getBoardLayouts } from "@homarr/boards/context";
|
||||
import type { Modify } from "@homarr/common/types";
|
||||
import { createId } from "@homarr/db/client";
|
||||
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 { getSectionElements } from "./section-elements";
|
||||
|
||||
export interface CreateItemInput {
|
||||
kind: WidgetKind;
|
||||
@@ -19,24 +21,11 @@ export const createItemCallback =
|
||||
|
||||
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 = {
|
||||
id: createId(),
|
||||
kind,
|
||||
options: {},
|
||||
width: 1,
|
||||
height: 1,
|
||||
...emptyPosition,
|
||||
layouts: createItemLayouts(previous, firstSection),
|
||||
integrationIds: [],
|
||||
advancedOptions: {
|
||||
customCssClasses: [],
|
||||
@@ -50,13 +39,31 @@ export const createItemCallback =
|
||||
|
||||
return {
|
||||
...previous,
|
||||
sections: previous.sections.map((section) => {
|
||||
// Return same section if item is not in it
|
||||
if (section.id !== firstSection.id) return section;
|
||||
return {
|
||||
...section,
|
||||
items: section.items.concat(widget),
|
||||
};
|
||||
}),
|
||||
items: previous.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 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 { getSectionElements } from "./section-elements";
|
||||
|
||||
export interface DuplicateItemInput {
|
||||
itemId: string;
|
||||
@@ -10,72 +11,78 @@ export interface DuplicateItemInput {
|
||||
export const duplicateItemCallback =
|
||||
({ itemId }: DuplicateItemInput) =>
|
||||
(previous: Board): Board => {
|
||||
const itemToDuplicate = previous.sections
|
||||
.flatMap((section) => section.items.map((item) => ({ ...item, sectionId: section.id })))
|
||||
.find((item) => item.id === itemId);
|
||||
const itemToDuplicate = previous.items.find((item) => item.id === itemId);
|
||||
if (!itemToDuplicate) return previous;
|
||||
|
||||
const currentSection = previous.sections.find((section) => section.id === itemToDuplicate.sectionId);
|
||||
if (!currentSection) return previous;
|
||||
const clonedItem = structuredClone(itemToDuplicate);
|
||||
|
||||
const dynamicSectionsOfCurrentSection = previous.sections.filter(
|
||||
(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 = {
|
||||
return {
|
||||
...previous,
|
||||
sections: previous.sections.map((section) => {
|
||||
// Return same section if item is not in it
|
||||
if (section.id !== sectionId) return section;
|
||||
return {
|
||||
...section,
|
||||
items: section.items.concat(widget),
|
||||
};
|
||||
items: previous.items.concat({
|
||||
...clonedItem,
|
||||
id: createId(),
|
||||
layouts: clonedItem.layouts.map((layout) => ({
|
||||
...layout,
|
||||
...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 = (
|
||||
elements: Pick<Item, "yOffset" | "xOffset" | "width" | "height">[],
|
||||
elements: Pick<SectionItem, "yOffset" | "xOffset" | "width" | "height">[],
|
||||
columnCount: number,
|
||||
rowCount = 9999,
|
||||
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 type { Board } from "~/app/[locale]/boards/_types";
|
||||
import * as boardContext from "@homarr/boards/context";
|
||||
|
||||
import { createItemCallback } from "../create-item";
|
||||
import * as emptyPosition from "../empty-position";
|
||||
import { createDynamicSection, createEmptySection, createItem } from "./shared";
|
||||
import * as emptyPositionModule from "../empty-position";
|
||||
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", () => {
|
||||
test("should add it to first section", () => {
|
||||
const spy = vi.spyOn(emptyPosition, "getFirstEmptyPosition");
|
||||
spy.mockReturnValue({ xOffset: 5, yOffset: 5 });
|
||||
const input = {
|
||||
sections: [createEmptySection("1", 2), createEmptySection("2", 0), createEmptySection("3", 1)],
|
||||
columnCount: 4,
|
||||
} satisfies Pick<Board, "sections" | "columnCount">;
|
||||
// Arrange
|
||||
const itemKind = "clock";
|
||||
const emptyPosition = { xOffset: 5, yOffset: 5 };
|
||||
const firstSectionId = "2";
|
||||
const layoutId = "1";
|
||||
|
||||
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({
|
||||
kind: "clock",
|
||||
})(input as unknown as Board);
|
||||
kind: itemKind,
|
||||
})(board);
|
||||
|
||||
const firstSection = result.sections.find((section) => section.id === "2");
|
||||
const item = firstSection?.items.at(0);
|
||||
expect(item).toEqual(expect.objectContaining({ kind: "clock", xOffset: 5, yOffset: 5 }));
|
||||
expect(spy).toHaveBeenCalledWith([], input.columnCount);
|
||||
// Assert
|
||||
const item = result.items.at(0);
|
||||
expect(item).toEqual(
|
||||
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", () => {
|
||||
const spy = vi.spyOn(emptyPosition, "getFirstEmptyPosition");
|
||||
spy.mockReturnValue({ xOffset: 5, yOffset: 5 });
|
||||
const firstSection = createEmptySection("2", 0);
|
||||
const expectedItem = createItem({ id: "12", xOffset: 1, yOffset: 2, width: 3, height: 2 });
|
||||
firstSection.items.push(expectedItem);
|
||||
const dynamicSectionInFirst = createDynamicSection({
|
||||
id: "4",
|
||||
parentSectionId: "2",
|
||||
yOffset: 0,
|
||||
xOffset: 0,
|
||||
width: 2,
|
||||
height: 2,
|
||||
});
|
||||
// Arrange
|
||||
const itemKind = "clock";
|
||||
const emptyPosition = { xOffset: 5, yOffset: 5 };
|
||||
const firstSectionId = "2";
|
||||
const layoutId = "1";
|
||||
const itemAndSectionPosition = { height: 2, width: 3, yOffset: 2, xOffset: 1 };
|
||||
|
||||
const input = {
|
||||
sections: [
|
||||
createEmptySection("1", 2),
|
||||
firstSection,
|
||||
createEmptySection("3", 1),
|
||||
dynamicSectionInFirst,
|
||||
createDynamicSection({ id: "5", parentSectionId: "3", yOffset: 1 }),
|
||||
],
|
||||
columnCount: 4,
|
||||
} satisfies Pick<Board, "sections" | "columnCount">;
|
||||
const layout = new LayoutMockBuilder({ id: layoutId, columnCount: 4 }).build();
|
||||
const dynamicSectionInFirstSection = new DynamicSectionMockBuilder({ id: "4" })
|
||||
.addLayout({ ...itemAndSectionPosition, layoutId, parentSectionId: firstSectionId })
|
||||
.build();
|
||||
const itemInFirstSection = new ItemMockBuilder({ id: "12" })
|
||||
.addLayout({ ...itemAndSectionPosition, layoutId, sectionId: firstSectionId })
|
||||
.build();
|
||||
const otherDynamicSection = new DynamicSectionMockBuilder({ id: "5" }).addLayout({ layoutId }).build();
|
||||
const otherItem = new ItemMockBuilder({ id: "13" }).addLayout({ layoutId }).build();
|
||||
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({
|
||||
kind: "clock",
|
||||
})(input as unknown as Board);
|
||||
kind: itemKind,
|
||||
})(board);
|
||||
|
||||
const firstSectionResult = result.sections.find((section) => section.id === "2");
|
||||
const item = firstSectionResult?.items.find((item) => item.id !== "12");
|
||||
expect(item).toEqual(expect.objectContaining({ kind: "clock", xOffset: 5, yOffset: 5 }));
|
||||
expect(spy).toHaveBeenCalledWith([expectedItem, dynamicSectionInFirst], input.columnCount);
|
||||
// Assert
|
||||
expect(result.items.length).toBe(3);
|
||||
const item = result.items.find((item) => item.id !== itemInFirstSection.id && item.id !== otherItem.id);
|
||||
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 type { Board } from "~/app/[locale]/boards/_types";
|
||||
import { duplicateItemCallback } from "../duplicate-item";
|
||||
import * as emptyPosition from "../empty-position";
|
||||
import { createEmptySection, createItem } from "./shared";
|
||||
import * as emptyPositionModule from "../empty-position";
|
||||
import { BoardMockBuilder } from "./mocks/board-mock";
|
||||
import { ItemMockBuilder } from "./mocks/item-mock";
|
||||
import { LayoutMockBuilder } from "./mocks/layout-mock";
|
||||
|
||||
describe("item actions duplicate-item", () => {
|
||||
test("should copy it in the same section", () => {
|
||||
const spy = vi.spyOn(emptyPosition, "getFirstEmptyPosition");
|
||||
spy.mockReturnValue({ xOffset: 5, yOffset: 5 });
|
||||
const currentSection = createEmptySection("2", 1);
|
||||
const currentItem = createItem({
|
||||
id: "1",
|
||||
xOffset: 1,
|
||||
yOffset: 3,
|
||||
width: 3,
|
||||
height: 2,
|
||||
kind: "minecraftServerStatus",
|
||||
// Arrange
|
||||
const itemKind = "minecraftServerStatus";
|
||||
const emptyPosition = { xOffset: 5, yOffset: 5 };
|
||||
const currentSectionId = "2";
|
||||
const layoutId = "1";
|
||||
const currentItemSize = { height: 2, width: 3 };
|
||||
|
||||
const layout = new LayoutMockBuilder({ id: layoutId, columnCount: 4 }).build();
|
||||
const currentItem = new ItemMockBuilder({
|
||||
kind: itemKind,
|
||||
integrationIds: ["1"],
|
||||
options: { address: "localhost" },
|
||||
advancedOptions: { customCssClasses: ["test"] },
|
||||
});
|
||||
const otherItem = createItem({
|
||||
id: "2",
|
||||
});
|
||||
currentSection.items.push(currentItem, otherItem);
|
||||
const input = {
|
||||
columnCount: 10,
|
||||
sections: [createEmptySection("1", 0), currentSection, createEmptySection("3", 2)],
|
||||
} satisfies Pick<Board, "sections" | "columnCount">;
|
||||
})
|
||||
.addLayout({ layoutId, sectionId: currentSectionId, ...currentItemSize })
|
||||
.build();
|
||||
const otherItem = new ItemMockBuilder({ id: "2" }).addLayout({ layoutId }).build();
|
||||
|
||||
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");
|
||||
expect(section?.items.length).toBe(3);
|
||||
const duplicatedItem = section?.items.find((item) => item.id !== currentItem.id && item.id !== otherItem.id);
|
||||
const spy = vi.spyOn(emptyPositionModule, "getFirstEmptyPosition");
|
||||
spy.mockReturnValue(emptyPosition);
|
||||
|
||||
// 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.objectContaining({
|
||||
kind: "minecraftServerStatus",
|
||||
xOffset: 5,
|
||||
yOffset: 5,
|
||||
width: 3,
|
||||
height: 2,
|
||||
integrationIds: ["1"],
|
||||
options: { address: "localhost" },
|
||||
advancedOptions: { customCssClasses: ["test"] },
|
||||
kind: itemKind,
|
||||
integrationIds: currentItem.integrationIds,
|
||||
options: currentItem.options,
|
||||
advancedOptions: currentItem.advancedOptions,
|
||||
layouts: [
|
||||
expect.objectContaining({
|
||||
...emptyPosition,
|
||||
...currentItemSize,
|
||||
sectionId: currentSectionId,
|
||||
}),
|
||||
],
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -109,7 +109,7 @@ describe("get first empty position", () => {
|
||||
});
|
||||
|
||||
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++) {
|
||||
const row = layout[yOffset];
|
||||
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,36 @@
|
||||
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",
|
||||
options: {
|
||||
borderColor: "",
|
||||
},
|
||||
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: [] },
|
||||
});
|
||||
@@ -3,30 +3,16 @@ import { useCallback } from "react";
|
||||
import { useUpdateBoard } from "@homarr/boards/updater";
|
||||
import type { BoardItemAdvancedOptions } from "@homarr/validation";
|
||||
|
||||
import type { Item } from "~/app/[locale]/boards/_types";
|
||||
import type { CreateItemInput } from "./actions/create-item";
|
||||
import { createItemCallback } from "./actions/create-item";
|
||||
import type { DuplicateItemInput } from "./actions/duplicate-item";
|
||||
import { duplicateItemCallback } from "./actions/duplicate-item";
|
||||
|
||||
interface MoveAndResizeItem {
|
||||
itemId: string;
|
||||
xOffset: number;
|
||||
yOffset: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
interface MoveItemToSection {
|
||||
itemId: string;
|
||||
sectionId: string;
|
||||
xOffset: number;
|
||||
yOffset: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
interface RemoveItem {
|
||||
itemId: string;
|
||||
}
|
||||
import type { MoveAndResizeItemInput } from "./actions/move-and-resize-item";
|
||||
import { moveAndResizeItemCallback } from "./actions/move-and-resize-item";
|
||||
import type { MoveItemToSectionInput } from "./actions/move-item-to-section";
|
||||
import { moveItemToSectionCallback } from "./actions/move-item-to-section";
|
||||
import type { RemoveItemInput } from "./actions/remove-item";
|
||||
import { removeItemCallback } from "./actions/remove-item";
|
||||
|
||||
interface UpdateItemOptions {
|
||||
itemId: string;
|
||||
@@ -62,164 +48,55 @@ export const useItemActions = () => {
|
||||
|
||||
const updateItemOptions = useCallback(
|
||||
({ itemId, newOptions }: UpdateItemOptions) => {
|
||||
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 changing
|
||||
if (item.id !== itemId) return item;
|
||||
return {
|
||||
...item,
|
||||
options: newOptions,
|
||||
};
|
||||
}),
|
||||
};
|
||||
}),
|
||||
};
|
||||
});
|
||||
updateBoard((previous) => ({
|
||||
...previous,
|
||||
items: previous.items.map((item) => (item.id !== itemId ? item : { ...item, options: newOptions })),
|
||||
}));
|
||||
},
|
||||
[updateBoard],
|
||||
);
|
||||
|
||||
const updateItemAdvancedOptions = useCallback(
|
||||
({ itemId, newAdvancedOptions }: UpdateItemAdvancedOptions) => {
|
||||
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 changing
|
||||
if (item.id !== itemId) return item;
|
||||
return {
|
||||
...item,
|
||||
advancedOptions: newAdvancedOptions,
|
||||
};
|
||||
}),
|
||||
};
|
||||
}),
|
||||
};
|
||||
});
|
||||
updateBoard((previous) => ({
|
||||
...previous,
|
||||
items: previous.items.map((item) =>
|
||||
item.id !== itemId ? item : { ...item, advancedOptions: newAdvancedOptions },
|
||||
),
|
||||
}));
|
||||
},
|
||||
[updateBoard],
|
||||
);
|
||||
|
||||
const updateItemIntegrations = useCallback(
|
||||
({ 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) => ({
|
||||
...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,
|
||||
...positionProps,
|
||||
} satisfies Item;
|
||||
}),
|
||||
};
|
||||
}),
|
||||
items: previous.items.map((item) =>
|
||||
item.id !== itemId || !("integrationIds" in item) ? item : { ...item, integrationIds: newIntegrations },
|
||||
),
|
||||
}));
|
||||
},
|
||||
[updateBoard],
|
||||
);
|
||||
|
||||
const moveAndResizeItem = useCallback(
|
||||
(input: MoveAndResizeItemInput) => {
|
||||
updateBoard(moveAndResizeItemCallback(input));
|
||||
},
|
||||
[updateBoard],
|
||||
);
|
||||
|
||||
const moveItemToSection = useCallback(
|
||||
({ itemId, sectionId, ...positionProps }: MoveItemToSection) => {
|
||||
updateBoard((previous) => {
|
||||
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,
|
||||
}),
|
||||
};
|
||||
}),
|
||||
};
|
||||
});
|
||||
(input: MoveItemToSectionInput) => {
|
||||
updateBoard(moveItemToSectionCallback(input));
|
||||
},
|
||||
[updateBoard],
|
||||
);
|
||||
|
||||
const removeItem = useCallback(
|
||||
({ itemId }: RemoveItem) => {
|
||||
updateBoard((previous) => {
|
||||
return {
|
||||
...previous,
|
||||
// Filter removed item out of items array
|
||||
sections: previous.sections.map((section) => ({
|
||||
...section,
|
||||
items: section.items.filter((item) => item.id !== itemId),
|
||||
})),
|
||||
};
|
||||
});
|
||||
({ itemId }: RemoveItemInput) => {
|
||||
updateBoard(removeItemCallback({ itemId }));
|
||||
},
|
||||
[updateBoard],
|
||||
);
|
||||
|
||||
@@ -11,13 +11,13 @@ import { useSettings } from "@homarr/settings";
|
||||
import { loadWidgetDynamic, reduceWidgetOptionsWithDefaultValues, widgetImports } from "@homarr/widgets";
|
||||
import { WidgetError } from "@homarr/widgets/errors";
|
||||
|
||||
import type { Item } from "~/app/[locale]/boards/_types";
|
||||
import type { SectionItem } from "~/app/[locale]/boards/_types";
|
||||
import classes from "../sections/item.module.css";
|
||||
import { useItemActions } from "./item-actions";
|
||||
import { BoardItemMenu } from "./item-menu";
|
||||
|
||||
interface BoardItemContentProps {
|
||||
item: Item;
|
||||
item: SectionItem;
|
||||
}
|
||||
|
||||
export const BoardItemContent = ({ item }: BoardItemContentProps) => {
|
||||
@@ -33,6 +33,7 @@ export const BoardItemContent = ({ item }: BoardItemContentProps) => {
|
||||
"grid-stack-item-content",
|
||||
item.advancedOptions.customCssClasses.join(" "),
|
||||
)}
|
||||
radius={board.itemRadius}
|
||||
withBorder
|
||||
styles={{
|
||||
root: {
|
||||
@@ -49,7 +50,7 @@ export const BoardItemContent = ({ item }: BoardItemContentProps) => {
|
||||
};
|
||||
|
||||
interface InnerContentProps {
|
||||
item: Item;
|
||||
item: SectionItem;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ import { useI18n, useScopedI18n } from "@homarr/translation/client";
|
||||
import { widgetImports } from "@homarr/widgets";
|
||||
import { WidgetEditModal } from "@homarr/widgets/modals";
|
||||
|
||||
import type { Item } from "~/app/[locale]/boards/_types";
|
||||
import type { SectionItem } from "~/app/[locale]/boards/_types";
|
||||
import { useSectionContext } from "../sections/section-context";
|
||||
import { useItemActions } from "./item-actions";
|
||||
import { ItemMoveModal } from "./item-move-modal";
|
||||
@@ -21,7 +21,7 @@ export const BoardItemMenu = ({
|
||||
resetErrorBoundary,
|
||||
}: {
|
||||
offset: number;
|
||||
item: Item;
|
||||
item: SectionItem;
|
||||
resetErrorBoundary?: () => void;
|
||||
}) => {
|
||||
const refResetErrorBoundaryOnNextRender = useRef(false);
|
||||
|
||||
@@ -7,11 +7,11 @@ import type { GridStack } from "@homarr/gridstack";
|
||||
import { createModal } from "@homarr/modals";
|
||||
import { useI18n, useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import type { Item } from "~/app/[locale]/boards/_types";
|
||||
import type { Item, SectionItem } from "~/app/[locale]/boards/_types";
|
||||
|
||||
interface InnerProps {
|
||||
gridStack: GridStack;
|
||||
item: Pick<Item, "id" | "xOffset" | "yOffset" | "width" | "height">;
|
||||
item: Pick<SectionItem, "id" | "width" | "height" | "xOffset" | "yOffset">;
|
||||
columnCount: number;
|
||||
}
|
||||
|
||||
@@ -47,7 +47,7 @@ export const ItemMoveModal = createModal<InnerProps>(({ actions, innerProps }) =
|
||||
);
|
||||
|
||||
const handleSubmit = useCallback(
|
||||
(values: Omit<InnerProps["item"], "id">) => {
|
||||
(values: Pick<Item["layouts"][number], "height" | "width" | "xOffset" | "yOffset">) => {
|
||||
const gridItem = innerProps.gridStack
|
||||
.getGridItems()
|
||||
.find((item) => item.getAttribute("data-id") === innerProps.item.id);
|
||||
|
||||
@@ -27,7 +27,13 @@ export const BoardCategorySection = ({ section }: Props) => {
|
||||
});
|
||||
|
||||
return (
|
||||
<Card style={{ "--opacity": board.opacity / 100 }} withBorder p={0} className={classes.itemCard}>
|
||||
<Card
|
||||
style={{ "--opacity": board.opacity / 100 }}
|
||||
radius={board.itemRadius}
|
||||
withBorder
|
||||
p={0}
|
||||
className={classes.itemCard}
|
||||
>
|
||||
<Stack>
|
||||
<Group wrap="nowrap" gap="sm">
|
||||
<UnstyledButton w="100%" p="sm" onClick={toggle}>
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import type { Board, CategorySection, DynamicSection, EmptySection, Section } from "~/app/[locale]/boards/_types";
|
||||
import { getBoardLayouts } from "@homarr/boards/context";
|
||||
|
||||
import type { Board, CategorySection, EmptySection, Section } from "~/app/[locale]/boards/_types";
|
||||
|
||||
export interface RemoveCategoryInput {
|
||||
id: string;
|
||||
@@ -28,84 +30,121 @@ export const removeCategoryCallback =
|
||||
return previous;
|
||||
}
|
||||
|
||||
// Calculate the yOffset for the items in the currentCategory and removedWrapper to add them with the same offset to the aboveWrapper
|
||||
const aboveYOffset = Math.max(
|
||||
calculateYHeightWithOffsetForItems(aboveSection),
|
||||
calculateYHeightWithOffsetForDynamicSections(previous.sections, aboveSection.id),
|
||||
);
|
||||
const categoryYOffset = Math.max(
|
||||
calculateYHeightWithOffsetForItems(currentCategory),
|
||||
calculateYHeightWithOffsetForDynamicSections(previous.sections, currentCategory.id),
|
||||
);
|
||||
const aboveYOffsets = getBoardLayouts(previous).map((layoutId) => {
|
||||
return {
|
||||
layoutId,
|
||||
yOffset: Math.max(
|
||||
calculateYHeightWithOffsetForItemLayouts(previous, { sectionId: aboveSection.id, layoutId }),
|
||||
calculateYHeightWithOffsetForDynamicSectionLayouts(previous.sections, {
|
||||
sectionId: aboveSection.id,
|
||||
layoutId,
|
||||
}),
|
||||
),
|
||||
};
|
||||
});
|
||||
|
||||
const previousCategoryItems = currentCategory.items.map((item) => ({
|
||||
...item,
|
||||
yOffset: item.yOffset + aboveYOffset,
|
||||
}));
|
||||
const previousBelowWrapperItems = removedSection.items.map((item) => ({
|
||||
...item,
|
||||
yOffset: item.yOffset + aboveYOffset + categoryYOffset,
|
||||
}));
|
||||
const categoryYOffsets = getBoardLayouts(previous).map((layoutId) => {
|
||||
return {
|
||||
layoutId,
|
||||
yOffset: Math.max(
|
||||
calculateYHeightWithOffsetForItemLayouts(previous, { sectionId: currentCategory.id, layoutId }),
|
||||
calculateYHeightWithOffsetForDynamicSectionLayouts(previous.sections, {
|
||||
sectionId: currentCategory.id,
|
||||
layoutId,
|
||||
}),
|
||||
),
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
...previous,
|
||||
sections: [
|
||||
...previous.sections.filter((section) => section.yOffset < aboveSection.yOffset && section.kind !== "dynamic"),
|
||||
{
|
||||
...aboveSection,
|
||||
items: [...aboveSection.items, ...previousCategoryItems, ...previousBelowWrapperItems],
|
||||
},
|
||||
...previous.sections
|
||||
.filter(
|
||||
(section): section is CategorySection | EmptySection =>
|
||||
section.yOffset > removedSection.yOffset && section.kind !== "dynamic",
|
||||
)
|
||||
.map((section) => ({
|
||||
...section,
|
||||
position: section.yOffset - 2,
|
||||
})),
|
||||
...previous.sections
|
||||
.filter((section): section is DynamicSection => section.kind === "dynamic")
|
||||
.map((dynamicSection) => {
|
||||
// Move dynamic sections from removed section to above section with required yOffset
|
||||
if (dynamicSection.parentSectionId === removedSection.id) {
|
||||
return {
|
||||
...dynamicSection,
|
||||
yOffset: dynamicSection.yOffset + aboveYOffset + categoryYOffset,
|
||||
parentSectionId: aboveSection.id,
|
||||
};
|
||||
}
|
||||
sections: previous.sections
|
||||
.filter((section) => section.id !== currentCategory.id && section.id !== removedSection.id)
|
||||
.map((section) =>
|
||||
section.kind === "dynamic"
|
||||
? {
|
||||
...section,
|
||||
layouts: section.layouts.map((layout) => {
|
||||
const aboveYOffset = aboveYOffsets.find(({ layoutId }) => layout.layoutId === layoutId)?.yOffset ?? 0;
|
||||
const categoryYOffset =
|
||||
categoryYOffsets.find(({ layoutId }) => layout.layoutId === layoutId)?.yOffset ?? 0;
|
||||
|
||||
// Move dynamic sections from category to above section with required yOffset
|
||||
if (dynamicSection.parentSectionId === currentCategory.id) {
|
||||
return {
|
||||
...dynamicSection,
|
||||
yOffset: dynamicSection.yOffset + aboveYOffset,
|
||||
parentSectionId: aboveSection.id,
|
||||
};
|
||||
}
|
||||
if (layout.parentSectionId === currentCategory.id) {
|
||||
return {
|
||||
...layout,
|
||||
yOffset: layout.yOffset + aboveYOffset,
|
||||
parentSectionId: aboveSection.id,
|
||||
};
|
||||
}
|
||||
|
||||
return dynamicSection;
|
||||
}),
|
||||
],
|
||||
if (layout.parentSectionId === removedSection.id) {
|
||||
return {
|
||||
...layout,
|
||||
yOffset: layout.yOffset + aboveYOffset + categoryYOffset,
|
||||
parentSectionId: aboveSection.id,
|
||||
};
|
||||
}
|
||||
|
||||
return layout;
|
||||
}),
|
||||
}
|
||||
: section,
|
||||
),
|
||||
|
||||
items: previous.items.map((item) => ({
|
||||
...item,
|
||||
layouts: item.layouts.map((layout) => {
|
||||
const aboveYOffset = aboveYOffsets.find(({ layoutId }) => layout.layoutId === layoutId)?.yOffset ?? 0;
|
||||
const categoryYOffset = categoryYOffsets.find(({ layoutId }) => layout.layoutId === layoutId)?.yOffset ?? 0;
|
||||
|
||||
if (layout.sectionId === currentCategory.id) {
|
||||
return {
|
||||
...layout,
|
||||
yOffset: layout.yOffset + aboveYOffset,
|
||||
sectionId: aboveSection.id,
|
||||
};
|
||||
}
|
||||
|
||||
if (layout.sectionId === removedSection.id) {
|
||||
return {
|
||||
...layout,
|
||||
yOffset: layout.yOffset + aboveYOffset + categoryYOffset,
|
||||
sectionId: aboveSection.id,
|
||||
};
|
||||
}
|
||||
|
||||
return layout;
|
||||
}),
|
||||
})),
|
||||
};
|
||||
};
|
||||
|
||||
const calculateYHeightWithOffsetForDynamicSections = (sections: Section[], sectionId: string) => {
|
||||
return sections.reduce((acc, section) => {
|
||||
if (section.kind !== "dynamic" || section.parentSectionId !== sectionId) {
|
||||
const calculateYHeightWithOffsetForDynamicSectionLayouts = (
|
||||
sections: Section[],
|
||||
{ sectionId, layoutId }: { sectionId: string; layoutId: string },
|
||||
) => {
|
||||
return sections
|
||||
.filter((section) => section.kind === "dynamic")
|
||||
.map((section) => section.layouts.find((layout) => layout.layoutId === layoutId))
|
||||
.filter((layout) => layout !== undefined)
|
||||
.filter((layout) => layout.parentSectionId === sectionId)
|
||||
.reduce((acc, layout) => {
|
||||
const yHeightWithOffset = layout.yOffset + layout.height;
|
||||
if (yHeightWithOffset > acc) return yHeightWithOffset;
|
||||
return acc;
|
||||
}
|
||||
|
||||
const yHeightWithOffset = section.yOffset + section.height;
|
||||
if (yHeightWithOffset > acc) return yHeightWithOffset;
|
||||
return acc;
|
||||
}, 0);
|
||||
}, 0);
|
||||
};
|
||||
|
||||
const calculateYHeightWithOffsetForItems = (section: Section) =>
|
||||
section.items.reduce((acc, item) => {
|
||||
const yHeightWithOffset = item.yOffset + item.height;
|
||||
if (yHeightWithOffset > acc) return yHeightWithOffset;
|
||||
return acc;
|
||||
}, 0);
|
||||
const calculateYHeightWithOffsetForItemLayouts = (
|
||||
board: Board,
|
||||
{ sectionId, layoutId }: { sectionId: string; layoutId: string },
|
||||
) =>
|
||||
board.items
|
||||
.map((item) => item.layouts.find((layout) => layout.layoutId === layoutId))
|
||||
.filter((layout) => layout !== undefined)
|
||||
.filter((layout) => layout.sectionId === sectionId)
|
||||
.reduce((acc, layout) => {
|
||||
const yHeightWithOffset = layout.yOffset + layout.height;
|
||||
if (yHeightWithOffset > acc) return yHeightWithOffset;
|
||||
return acc;
|
||||
}, 0);
|
||||
+3
-1
@@ -73,5 +73,7 @@ const createSections = (categoryCount: number) => {
|
||||
};
|
||||
|
||||
const sortSections = (sections: Section[]) => {
|
||||
return sections.sort((sectionA, sectionB) => sectionA.yOffset - sectionB.yOffset);
|
||||
return sections
|
||||
.filter((section) => section.kind !== "dynamic")
|
||||
.sort((sectionA, sectionB) => sectionA.yOffset - sectionB.yOffset);
|
||||
};
|
||||
+109
-90
@@ -1,7 +1,14 @@
|
||||
/* eslint-disable @typescript-eslint/no-non-null-assertion */
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
|
||||
import type { DynamicSection, Item, Section } from "~/app/[locale]/boards/_types";
|
||||
import * as boardContext from "@homarr/boards/context";
|
||||
|
||||
import type { DynamicSection, Section } from "~/app/[locale]/boards/_types";
|
||||
import { BoardMockBuilder } from "~/components/board/items/actions/test/mocks/board-mock";
|
||||
import { CategorySectionMockBuilder } from "~/components/board/items/actions/test/mocks/category-section-mock";
|
||||
import { DynamicSectionMockBuilder } from "~/components/board/items/actions/test/mocks/dynamic-section-mock";
|
||||
import { EmptySectionMockBuilder } from "~/components/board/items/actions/test/mocks/empty-section-mock";
|
||||
import { ItemMockBuilder } from "~/components/board/items/actions/test/mocks/item-mock";
|
||||
import { removeCategoryCallback } from "../remove-category";
|
||||
|
||||
describe("Remove Category", () => {
|
||||
@@ -13,114 +20,126 @@ describe("Remove Category", () => {
|
||||
])(
|
||||
"should remove category",
|
||||
(removeId, initialYOffsets, expectedYOffsets, expectedRemovals, expectedLocationOfItems) => {
|
||||
const sections = createSections(initialYOffsets);
|
||||
|
||||
// Arrange
|
||||
const layoutId = "1";
|
||||
const input = removeId.toString();
|
||||
|
||||
const result = removeCategoryCallback({ id: input })({ sections } as never);
|
||||
const board = new BoardMockBuilder()
|
||||
.addLayout({ id: layoutId })
|
||||
.addSections(createSections(initialYOffsets))
|
||||
.addItems(createSectionItems(initialYOffsets, layoutId))
|
||||
.build();
|
||||
|
||||
vi.spyOn(boardContext, "getBoardLayouts").mockReturnValue([layoutId]);
|
||||
|
||||
// Act
|
||||
const result = removeCategoryCallback({ id: input })(board);
|
||||
|
||||
// Assert
|
||||
expect(result.sections.map((section) => parseInt(section.id, 10))).toEqual(expectedYOffsets);
|
||||
expectedRemovals.forEach((expectedRemoval) => {
|
||||
expect(result.sections.find((section) => section.id === expectedRemoval.toString())).toBeUndefined();
|
||||
});
|
||||
const aboveSection = result.sections.find((section) => section.id === expectedLocationOfItems.toString());
|
||||
expect(aboveSection?.items.map((item) => parseInt(item.id, 10))).toEqual(
|
||||
expect.arrayContaining(expectedRemovals),
|
||||
const aboveSectionItems = result.items.filter(
|
||||
(item) => item.layouts[0]?.sectionId === expectedLocationOfItems.toString(),
|
||||
);
|
||||
expect(aboveSectionItems.map((item) => parseInt(item.id, 10))).toEqual(expect.arrayContaining(expectedRemovals));
|
||||
},
|
||||
);
|
||||
|
||||
test("should correctly move items to above empty section", () => {
|
||||
// Arrange
|
||||
const layoutId = "1";
|
||||
const sectionIds = {
|
||||
above: "2",
|
||||
category: "3",
|
||||
below: "4",
|
||||
dynamic: "7",
|
||||
};
|
||||
const initialYOffsets = [0, 1, 2, 3, 4, 5, 6];
|
||||
const sections: Section[] = createSections(initialYOffsets);
|
||||
const aboveSection = sections.find((section) => section.yOffset === 2)!;
|
||||
aboveSection.items = [
|
||||
createItem({ id: "above-1" }),
|
||||
createItem({ id: "above-2", yOffset: 3, xOffset: 2, height: 2 }),
|
||||
];
|
||||
const removedCategory = sections.find((section) => section.yOffset === 3)!;
|
||||
removedCategory.items = [
|
||||
createItem({ id: "category-1" }),
|
||||
createItem({ id: "category-2", yOffset: 2, xOffset: 4, width: 4 }),
|
||||
];
|
||||
const removedEmptySection = sections.find((section) => section.yOffset === 4)!;
|
||||
removedEmptySection.items = [
|
||||
createItem({ id: "below-1", xOffset: 5 }),
|
||||
createItem({ id: "below-2", yOffset: 1, xOffset: 1, height: 2 }),
|
||||
];
|
||||
sections.push(
|
||||
createDynamicSection({
|
||||
id: "7",
|
||||
parentSectionId: "3",
|
||||
yOffset: 7,
|
||||
height: 3,
|
||||
items: [createItem({ id: "dynamic-1" })],
|
||||
}),
|
||||
);
|
||||
|
||||
const input = "3";
|
||||
const board = new BoardMockBuilder()
|
||||
.addLayout({ id: layoutId })
|
||||
.addSections(createSections(initialYOffsets))
|
||||
.addItems(createSectionItems([0, 1, 5, 6], layoutId)) // Only add items to other sections
|
||||
.addDynamicSection(
|
||||
new DynamicSectionMockBuilder({ id: sectionIds.dynamic })
|
||||
.addLayout({ layoutId, parentSectionId: sectionIds.category, yOffset: 7, height: 3 })
|
||||
.build(),
|
||||
)
|
||||
.addItem(new ItemMockBuilder({ id: "above-1" }).addLayout({ layoutId, sectionId: sectionIds.above }).build())
|
||||
.addItem(
|
||||
new ItemMockBuilder({ id: "above-2" })
|
||||
.addLayout({ layoutId, sectionId: sectionIds.above, yOffset: 3, xOffset: 2, height: 2 })
|
||||
.build(),
|
||||
)
|
||||
.addItem(
|
||||
new ItemMockBuilder({ id: "category-1" }).addLayout({ layoutId, sectionId: sectionIds.category }).build(),
|
||||
)
|
||||
.addItem(
|
||||
new ItemMockBuilder({ id: "category-2" })
|
||||
.addLayout({ layoutId, sectionId: sectionIds.category, yOffset: 2, xOffset: 4, width: 4 })
|
||||
.build(),
|
||||
)
|
||||
.addItem(
|
||||
new ItemMockBuilder({ id: "below-1" }).addLayout({ layoutId, sectionId: sectionIds.below, xOffset: 5 }).build(),
|
||||
)
|
||||
.addItem(
|
||||
new ItemMockBuilder({ id: "below-2" })
|
||||
.addLayout({ layoutId, sectionId: sectionIds.below, yOffset: 1, xOffset: 1, height: 2 })
|
||||
.build(),
|
||||
)
|
||||
.addItem(new ItemMockBuilder({ id: "dynamic-1" }).addLayout({ layoutId, sectionId: sectionIds.dynamic }).build())
|
||||
.build();
|
||||
|
||||
const result = removeCategoryCallback({ id: input })({ sections } as never);
|
||||
vi.spyOn(boardContext, "getBoardLayouts").mockReturnValue([layoutId]);
|
||||
|
||||
// Act
|
||||
const result = removeCategoryCallback({ id: sectionIds.category })(board);
|
||||
|
||||
// Assert
|
||||
expect(result.sections.map((section) => parseInt(section.id, 10))).toEqual([0, 1, 2, 5, 6, 7]);
|
||||
const aboveSectionResult = result.sections.find((section) => section.id === "2")!;
|
||||
expect(aboveSectionResult.items).toEqual(
|
||||
expect.arrayContaining([
|
||||
createItem({ id: "above-1" }),
|
||||
createItem({ id: "above-2", yOffset: 3, xOffset: 2, height: 2 }),
|
||||
createItem({ id: "category-1", yOffset: 5 }),
|
||||
createItem({ id: "category-2", yOffset: 7, xOffset: 4, width: 4 }),
|
||||
createItem({ id: "below-1", yOffset: 15, xOffset: 5 }),
|
||||
createItem({ id: "below-2", yOffset: 16, xOffset: 1, height: 2 }),
|
||||
]),
|
||||
);
|
||||
const aboveSectionItems = result.items.filter((item) => item.layouts[0]?.sectionId === sectionIds.above);
|
||||
expect(aboveSectionItems.length).toBe(6);
|
||||
|
||||
expect(
|
||||
aboveSectionItems
|
||||
.map((item) => ({
|
||||
...item,
|
||||
...item.layouts[0]!,
|
||||
}))
|
||||
.sort((itemA, itemB) => itemA.yOffset - itemB.yOffset),
|
||||
).toEqual([
|
||||
expect.objectContaining({ id: "above-1", yOffset: 0, xOffset: 0 }),
|
||||
expect.objectContaining({ id: "above-2", yOffset: 3, xOffset: 2, height: 2 }),
|
||||
expect.objectContaining({ id: "category-1", yOffset: 5, xOffset: 0 }),
|
||||
expect.objectContaining({ id: "category-2", yOffset: 7, xOffset: 4, width: 4 }),
|
||||
expect.objectContaining({ id: "below-1", yOffset: 15, xOffset: 5 }),
|
||||
expect.objectContaining({ id: "below-2", yOffset: 16, xOffset: 1, height: 2 }),
|
||||
]);
|
||||
|
||||
const dynamicSection = result.sections.find((section): section is DynamicSection => section.id === "7")!;
|
||||
expect(dynamicSection.yOffset).toBe(12);
|
||||
expect(dynamicSection.parentSectionId).toBe("2");
|
||||
expect(dynamicSection.layouts.at(0)?.yOffset).toBe(12);
|
||||
expect(dynamicSection.layouts[0]?.parentSectionId).toBe("2");
|
||||
});
|
||||
});
|
||||
|
||||
const createItem = (item: Partial<{ id: string; width: number; height: number; yOffset: number; xOffset: number }>) => {
|
||||
return {
|
||||
id: item.id ?? "0",
|
||||
kind: "app",
|
||||
options: {},
|
||||
advancedOptions: {
|
||||
customCssClasses: [],
|
||||
},
|
||||
height: item.height ?? 1,
|
||||
width: item.width ?? 1,
|
||||
yOffset: item.yOffset ?? 0,
|
||||
xOffset: item.xOffset ?? 0,
|
||||
integrationIds: [],
|
||||
} satisfies Item;
|
||||
};
|
||||
|
||||
const createDynamicSection = (
|
||||
section: Partial<
|
||||
Pick<DynamicSection, "id" | "height" | "width" | "yOffset" | "xOffset" | "parentSectionId" | "items">
|
||||
>,
|
||||
) => {
|
||||
return {
|
||||
id: section.id ?? "0",
|
||||
kind: "dynamic",
|
||||
height: section.height ?? 1,
|
||||
width: section.width ?? 1,
|
||||
yOffset: section.yOffset ?? 0,
|
||||
xOffset: section.xOffset ?? 0,
|
||||
parentSectionId: section.parentSectionId ?? "0",
|
||||
items: section.items ?? [],
|
||||
} satisfies DynamicSection;
|
||||
};
|
||||
|
||||
const createSections = (initialYOffsets: number[]) => {
|
||||
return initialYOffsets.map((yOffset, index) => ({
|
||||
id: yOffset.toString(),
|
||||
kind: index % 2 === 0 ? "empty" : "category",
|
||||
name: "Category",
|
||||
collapsed: false,
|
||||
yOffset,
|
||||
xOffset: 0,
|
||||
items: [createItem({ id: yOffset.toString() })],
|
||||
})) satisfies Section[];
|
||||
return initialYOffsets.map((yOffset, index) =>
|
||||
index % 2 === 0
|
||||
? new EmptySectionMockBuilder({
|
||||
id: yOffset.toString(),
|
||||
yOffset,
|
||||
}).build()
|
||||
: new CategorySectionMockBuilder({
|
||||
id: yOffset.toString(),
|
||||
yOffset,
|
||||
}).build(),
|
||||
) satisfies Section[];
|
||||
};
|
||||
|
||||
const createSectionItems = (initialYOffsets: number[], layoutId: string) => {
|
||||
return initialYOffsets.map((yOffset) =>
|
||||
new ItemMockBuilder({ id: yOffset.toString() }).addLayout({ layoutId, sectionId: yOffset.toString() }).build(),
|
||||
);
|
||||
};
|
||||
@@ -3,7 +3,7 @@ import { useCallback } from "react";
|
||||
import { useUpdateBoard } from "@homarr/boards/updater";
|
||||
import { createId } from "@homarr/db/client";
|
||||
|
||||
import type { CategorySection, EmptySection } from "~/app/[locale]/boards/_types";
|
||||
import type { CategorySection, EmptySection, Section } from "~/app/[locale]/boards/_types";
|
||||
import type { MoveCategoryInput } from "./actions/move-category";
|
||||
import { moveCategoryCallback } from "./actions/move-category";
|
||||
import type { RemoveCategoryInput } from "./actions/remove-category";
|
||||
@@ -41,14 +41,12 @@ export const useCategoryActions = () => {
|
||||
yOffset,
|
||||
xOffset: 0,
|
||||
collapsed: false,
|
||||
items: [],
|
||||
},
|
||||
{
|
||||
id: createId(),
|
||||
kind: "empty",
|
||||
yOffset: yOffset + 1,
|
||||
xOffset: 0,
|
||||
items: [],
|
||||
},
|
||||
// Place sections after the new category
|
||||
...previous.sections
|
||||
@@ -60,7 +58,7 @@ export const useCategoryActions = () => {
|
||||
...section,
|
||||
yOffset: section.yOffset + 2,
|
||||
})),
|
||||
],
|
||||
] satisfies Section[],
|
||||
}));
|
||||
},
|
||||
[updateBoard],
|
||||
@@ -91,16 +89,14 @@ export const useCategoryActions = () => {
|
||||
yOffset: lastYOffset + 1,
|
||||
xOffset: 0,
|
||||
collapsed: false,
|
||||
items: [],
|
||||
},
|
||||
{
|
||||
id: createId(),
|
||||
kind: "empty",
|
||||
yOffset: lastYOffset + 2,
|
||||
xOffset: 0,
|
||||
items: [],
|
||||
},
|
||||
],
|
||||
] satisfies Section[],
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useCallback } from "react";
|
||||
|
||||
import { fetchApi } from "@homarr/api/client";
|
||||
import { getCurrentLayout, useRequiredBoard } from "@homarr/boards/context";
|
||||
import { createId } from "@homarr/db/client";
|
||||
import { useConfirmModal, useModalAction } from "@homarr/modals";
|
||||
import { useSettings } from "@homarr/settings";
|
||||
@@ -16,6 +17,7 @@ export const useCategoryMenuActions = (category: CategorySection) => {
|
||||
const { openConfirmModal } = useConfirmModal();
|
||||
const { addCategory, moveCategory, removeCategory, renameCategory } = useCategoryActions();
|
||||
const t = useI18n();
|
||||
const board = useRequiredBoard();
|
||||
|
||||
const createCategoryAtYOffset = useCallback(
|
||||
(position: number) => {
|
||||
@@ -102,7 +104,14 @@ export const useCategoryMenuActions = (category: CategorySection) => {
|
||||
|
||||
const settings = useSettings();
|
||||
const openAllInNewTabs = useCallback(async () => {
|
||||
const appIds = filterByItemKind(category.items, settings, "app").map((item) => {
|
||||
const currentLayoutId = getCurrentLayout(board);
|
||||
const appIds = filterByItemKind(
|
||||
board.items.filter(
|
||||
(item) => item.layouts.find((layout) => layout.layoutId === currentLayoutId)?.sectionId === category.id,
|
||||
),
|
||||
settings,
|
||||
"app",
|
||||
).map((item) => {
|
||||
return item.options.appId;
|
||||
});
|
||||
|
||||
@@ -121,7 +130,7 @@ export const useCategoryMenuActions = (category: CategorySection) => {
|
||||
});
|
||||
break;
|
||||
}
|
||||
}, [category, t, openConfirmModal, settings]);
|
||||
}, [category, board, t, openConfirmModal, settings]);
|
||||
|
||||
return {
|
||||
addCategoryAbove,
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
import { useMemo } from "react";
|
||||
|
||||
import { useRequiredBoard } from "@homarr/boards/context";
|
||||
import type { GridItemHTMLElement } from "@homarr/gridstack";
|
||||
|
||||
import type { DynamicSection, Item, Section } from "~/app/[locale]/boards/_types";
|
||||
import type { DynamicSectionItem, SectionItem } from "~/app/[locale]/boards/_types";
|
||||
import { BoardItemContent } from "../items/item-content";
|
||||
import { BoardDynamicSection } from "./dynamic-section";
|
||||
import { GridStackItem } from "./gridstack/gridstack-item";
|
||||
import { useSectionContext } from "./section-context";
|
||||
import { useSectionItems } from "./use-section-items";
|
||||
|
||||
export const SectionContent = () => {
|
||||
const { section, innerSections, refs } = useSectionContext();
|
||||
const board = useRequiredBoard();
|
||||
const { innerSections, items, refs } = useSectionContext();
|
||||
|
||||
/**
|
||||
* IMPORTANT: THE ORDER OF THE BELOW ITEMS HAS TO MATCH THE ORDER OF
|
||||
@@ -18,41 +18,52 @@ export const SectionContent = () => {
|
||||
* @see https://github.com/homarr-labs/homarr/pull/1770
|
||||
*/
|
||||
const sortedItems = useMemo(() => {
|
||||
return [
|
||||
...section.items.map((item) => ({ ...item, type: "item" as const })),
|
||||
...innerSections.map((section) => ({ ...section, type: "section" as const })),
|
||||
].sort((itemA, itemB) => {
|
||||
return [...items, ...innerSections].sort((itemA, itemB) => {
|
||||
if (itemA.yOffset === itemB.yOffset) {
|
||||
return itemA.xOffset - itemB.xOffset;
|
||||
}
|
||||
|
||||
return itemA.yOffset - itemB.yOffset;
|
||||
});
|
||||
}, [section.items, innerSections]);
|
||||
}, [items, innerSections]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{sortedItems.map((item) => (
|
||||
<GridStackItem
|
||||
key={item.id}
|
||||
innerRef={refs.items.current[item.id]}
|
||||
width={item.width}
|
||||
height={item.height}
|
||||
xOffset={item.xOffset}
|
||||
yOffset={item.yOffset}
|
||||
kind={item.kind}
|
||||
id={item.id}
|
||||
type={item.type}
|
||||
minWidth={item.type === "section" ? getMinSize("x", item.items, board.sections, item.id) : undefined}
|
||||
minHeight={item.type === "section" ? getMinSize("y", item.items, board.sections, item.id) : undefined}
|
||||
>
|
||||
{item.type === "item" ? <BoardItemContent item={item} /> : <BoardDynamicSection section={item} />}
|
||||
</GridStackItem>
|
||||
<Item key={item.id} item={item} innerRef={refs.items.current[item.id]} />
|
||||
))}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
interface ItemProps {
|
||||
item: DynamicSectionItem | SectionItem;
|
||||
innerRef: React.RefObject<GridItemHTMLElement | null> | undefined;
|
||||
}
|
||||
|
||||
const Item = ({ item, innerRef }: ItemProps) => {
|
||||
const minWidth = useMinSize(item, "x");
|
||||
const minHeight = useMinSize(item, "y");
|
||||
|
||||
return (
|
||||
<GridStackItem
|
||||
key={item.id}
|
||||
innerRef={innerRef}
|
||||
width={item.width}
|
||||
height={item.height}
|
||||
xOffset={item.xOffset}
|
||||
yOffset={item.yOffset}
|
||||
kind={item.kind}
|
||||
id={item.id}
|
||||
type={item.type}
|
||||
minWidth={minWidth}
|
||||
minHeight={minHeight}
|
||||
>
|
||||
{item.type === "item" ? <BoardItemContent item={item} /> : <BoardDynamicSection section={item} />}
|
||||
</GridStackItem>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Calculates the min width / height of a section by taking the maximum of
|
||||
* the sum of the offset and size of all items and dynamic sections inside.
|
||||
@@ -62,16 +73,13 @@ export const SectionContent = () => {
|
||||
* @param parentSectionId the id of the section we want to calculate the min size for
|
||||
* @returns the min size
|
||||
*/
|
||||
const getMinSize = (direction: "x" | "y", items: Item[], sections: Section[], parentSectionId: string) => {
|
||||
const useMinSize = (item: DynamicSectionItem | SectionItem, direction: "x" | "y") => {
|
||||
const { items, innerSections } = useSectionItems(item.id);
|
||||
if (item.type === "item") return undefined;
|
||||
|
||||
const size = direction === "x" ? "width" : "height";
|
||||
return Math.max(
|
||||
...items.map((item) => item[`${direction}Offset`] + item[size]),
|
||||
...sections
|
||||
.filter(
|
||||
(section): section is DynamicSection =>
|
||||
section.kind === "dynamic" && section.parentSectionId === parentSectionId,
|
||||
)
|
||||
.map((item) => item[`${direction}Offset`] + item[size]),
|
||||
1, // Minimum size
|
||||
...innerSections.map((item) => item[`${direction}Offset`] + item[size]),
|
||||
);
|
||||
};
|
||||
@@ -1,18 +1,21 @@
|
||||
import { Box, Card } from "@mantine/core";
|
||||
|
||||
import { useRequiredBoard } from "@homarr/boards/context";
|
||||
import { useCurrentLayout, useRequiredBoard } from "@homarr/boards/context";
|
||||
|
||||
import type { DynamicSection } from "~/app/[locale]/boards/_types";
|
||||
import type { DynamicSectionItem } from "~/app/[locale]/boards/_types";
|
||||
import { BoardDynamicSectionMenu } from "./dynamic/dynamic-menu";
|
||||
import { GridStack } from "./gridstack/gridstack";
|
||||
import classes from "./item.module.css";
|
||||
|
||||
interface Props {
|
||||
section: DynamicSection;
|
||||
section: DynamicSectionItem;
|
||||
}
|
||||
|
||||
export const BoardDynamicSection = ({ section }: Props) => {
|
||||
const board = useRequiredBoard();
|
||||
const currentLayoutId = useCurrentLayout();
|
||||
const options = section.options;
|
||||
|
||||
return (
|
||||
<Box className="grid-stack-item-content">
|
||||
<Card
|
||||
@@ -24,11 +27,14 @@ export const BoardDynamicSection = ({ section }: Props) => {
|
||||
root: {
|
||||
"--opacity": board.opacity / 100,
|
||||
overflow: "hidden",
|
||||
"--border-color": options.borderColor !== "" ? options.borderColor : undefined,
|
||||
},
|
||||
}}
|
||||
radius={board.itemRadius}
|
||||
p={0}
|
||||
>
|
||||
<GridStack section={section} className="min-row" />
|
||||
{/* Use unique key by layout to reinitialize gridstack */}
|
||||
<GridStack key={`${currentLayoutId}-${section.id}`} section={section} className="min-row" />
|
||||
</Card>
|
||||
<BoardDynamicSectionMenu section={section} />
|
||||
</Box>
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import { getBoardLayouts } from "@homarr/boards/context";
|
||||
import { createId } from "@homarr/db/client";
|
||||
|
||||
import type { Board, DynamicSection, DynamicSectionLayout, EmptySection } from "~/app/[locale]/boards/_types";
|
||||
import { getFirstEmptyPosition } from "~/components/board/items/actions/empty-position";
|
||||
import { getSectionElements } from "~/components/board/items/actions/section-elements";
|
||||
|
||||
export const addDynamicSectionCallback = () => (board: Board) => {
|
||||
const firstSection = board.sections
|
||||
.filter((section) => section.kind === "empty")
|
||||
.sort((sectionA, sectionB) => sectionA.yOffset - sectionB.yOffset)
|
||||
.at(0);
|
||||
|
||||
if (!firstSection) return board;
|
||||
|
||||
const newSection = {
|
||||
id: createId(),
|
||||
kind: "dynamic",
|
||||
options: {
|
||||
borderColor: "",
|
||||
},
|
||||
layouts: createDynamicSectionLayouts(board, firstSection),
|
||||
} satisfies DynamicSection;
|
||||
|
||||
return {
|
||||
...board,
|
||||
sections: board.sections.concat(newSection as unknown as DynamicSection),
|
||||
};
|
||||
};
|
||||
|
||||
const createDynamicSectionLayouts = (board: Board, currentSection: EmptySection): DynamicSectionLayout[] => {
|
||||
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,
|
||||
parentSectionId: currentSection.id,
|
||||
layoutId,
|
||||
};
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,62 @@
|
||||
import type { Board, DynamicSection } from "~/app/[locale]/boards/_types";
|
||||
|
||||
export interface RemoveDynamicSectionInput {
|
||||
id: string;
|
||||
}
|
||||
|
||||
export const removeDynamicSectionCallback =
|
||||
({ id }: RemoveDynamicSectionInput) =>
|
||||
(board: Board): Board => {
|
||||
const sectionToRemove = board.sections.find(
|
||||
(section): section is DynamicSection => section.id === id && section.kind === "dynamic",
|
||||
);
|
||||
if (!sectionToRemove) return board;
|
||||
|
||||
return {
|
||||
...board,
|
||||
sections: board.sections
|
||||
.filter((section) => section.id !== id)
|
||||
.map((section) => {
|
||||
if (section.kind !== "dynamic") return section;
|
||||
|
||||
// Change xOffset and yOffset of sections that were below the removed section and set parentSectionId to the parent of the removed section
|
||||
return {
|
||||
...section,
|
||||
layouts: section.layouts.map((layout) => {
|
||||
if (layout.parentSectionId !== sectionToRemove.id) return layout;
|
||||
|
||||
const removedSectionLayout = sectionToRemove.layouts.find(
|
||||
(layoutToRemove) => layoutToRemove.layoutId === layout.layoutId,
|
||||
);
|
||||
if (!removedSectionLayout) throw new Error("Layout not found");
|
||||
|
||||
return {
|
||||
...layout,
|
||||
xOffset: layout.xOffset + removedSectionLayout.xOffset,
|
||||
yOffset: layout.yOffset + removedSectionLayout.yOffset,
|
||||
parentSectionId: removedSectionLayout.parentSectionId,
|
||||
};
|
||||
}),
|
||||
};
|
||||
}),
|
||||
// Move all items in dynamic section to parent of the removed section
|
||||
items: board.items.map((item) => ({
|
||||
...item,
|
||||
layouts: item.layouts.map((layout) => {
|
||||
if (layout.sectionId !== sectionToRemove.id) return layout;
|
||||
|
||||
const removedSectionLayout = sectionToRemove.layouts.find(
|
||||
(layoutToRemove) => layoutToRemove.layoutId === layout.layoutId,
|
||||
);
|
||||
if (!removedSectionLayout) throw new Error("Layout not found");
|
||||
|
||||
return {
|
||||
...layout,
|
||||
xOffset: layout.xOffset + removedSectionLayout.xOffset,
|
||||
yOffset: layout.yOffset + removedSectionLayout.yOffset,
|
||||
sectionId: removedSectionLayout.parentSectionId,
|
||||
};
|
||||
}),
|
||||
})),
|
||||
};
|
||||
};
|
||||
@@ -1,89 +1,45 @@
|
||||
import { useCallback } from "react";
|
||||
import type { z } from "zod";
|
||||
|
||||
import { useUpdateBoard } from "@homarr/boards/updater";
|
||||
import { createId } from "@homarr/db/client";
|
||||
import type { dynamicSectionOptionsSchema } from "@homarr/validation";
|
||||
|
||||
import type { DynamicSection, EmptySection } from "~/app/[locale]/boards/_types";
|
||||
import { addDynamicSectionCallback } from "./actions/add-dynamic-section";
|
||||
import type { RemoveDynamicSectionInput } from "./actions/remove-dynamic-section";
|
||||
import { removeDynamicSectionCallback } from "./actions/remove-dynamic-section";
|
||||
|
||||
interface RemoveDynamicSection {
|
||||
id: string;
|
||||
interface UpdateDynamicOptions {
|
||||
itemId: string;
|
||||
newOptions: z.infer<typeof dynamicSectionOptionsSchema>;
|
||||
}
|
||||
|
||||
export const useDynamicSectionActions = () => {
|
||||
const { updateBoard } = useUpdateBoard();
|
||||
|
||||
const addDynamicSection = useCallback(() => {
|
||||
updateBoard((previous) => {
|
||||
const lastSection = previous.sections
|
||||
.filter((section): section is EmptySection => section.kind === "empty")
|
||||
.sort((sectionA, sectionB) => sectionB.yOffset - sectionA.yOffset)[0];
|
||||
|
||||
if (!lastSection) return previous;
|
||||
|
||||
const newSection = {
|
||||
id: createId(),
|
||||
kind: "dynamic",
|
||||
height: 1,
|
||||
width: 1,
|
||||
items: [],
|
||||
parentSectionId: lastSection.id,
|
||||
// We omit xOffset and yOffset because gridstack will use the first available position
|
||||
} satisfies Omit<DynamicSection, "xOffset" | "yOffset">;
|
||||
|
||||
return {
|
||||
...previous,
|
||||
sections: previous.sections.concat(newSection as unknown as DynamicSection),
|
||||
};
|
||||
});
|
||||
updateBoard(addDynamicSectionCallback());
|
||||
}, [updateBoard]);
|
||||
|
||||
const updateDynamicSection = useCallback(
|
||||
({ itemId, newOptions }: UpdateDynamicOptions) => {
|
||||
updateBoard((previous) => ({
|
||||
...previous,
|
||||
sections: previous.sections.map((item) => (item.id !== itemId ? item : { ...item, options: newOptions })),
|
||||
}));
|
||||
},
|
||||
[updateBoard],
|
||||
);
|
||||
|
||||
const removeDynamicSection = useCallback(
|
||||
({ id }: RemoveDynamicSection) => {
|
||||
updateBoard((previous) => {
|
||||
const sectionToRemove = previous.sections.find(
|
||||
(section): section is DynamicSection => section.id === id && section.kind === "dynamic",
|
||||
);
|
||||
if (!sectionToRemove) return previous;
|
||||
|
||||
return {
|
||||
...previous,
|
||||
sections: previous.sections
|
||||
.filter((section) => section.id !== id)
|
||||
.map((section) => {
|
||||
if (section.id === sectionToRemove.parentSectionId) {
|
||||
return {
|
||||
...section,
|
||||
// Add items from the removed section to the parent section
|
||||
items: section.items.concat(
|
||||
sectionToRemove.items.map((item) => ({
|
||||
...item,
|
||||
xOffset: sectionToRemove.xOffset + item.xOffset,
|
||||
yOffset: sectionToRemove.yOffset + item.yOffset,
|
||||
})),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
if (section.kind === "dynamic" && section.parentSectionId === sectionToRemove.id) {
|
||||
// Change xOffset and yOffset of sections that were below the removed section and set parentSectionId to the parent of the removed section
|
||||
return {
|
||||
...section,
|
||||
parentSectionId: sectionToRemove.parentSectionId,
|
||||
yOffset: section.yOffset + sectionToRemove.yOffset,
|
||||
xOffset: section.xOffset + sectionToRemove.xOffset,
|
||||
};
|
||||
}
|
||||
|
||||
return section;
|
||||
}),
|
||||
};
|
||||
});
|
||||
(input: RemoveDynamicSectionInput) => {
|
||||
updateBoard(removeDynamicSectionCallback(input));
|
||||
},
|
||||
[updateBoard],
|
||||
);
|
||||
|
||||
return {
|
||||
addDynamicSection,
|
||||
updateDynamicSection,
|
||||
removeDynamicSection,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,63 @@
|
||||
"use client";
|
||||
|
||||
import { Button, CloseButton, ColorInput, Group, Stack, useMantineTheme } from "@mantine/core";
|
||||
import type { z } from "zod";
|
||||
|
||||
import { useZodForm } from "@homarr/form";
|
||||
import { createModal } from "@homarr/modals";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import { dynamicSectionOptionsSchema } from "@homarr/validation";
|
||||
|
||||
interface ModalProps {
|
||||
value: z.infer<typeof dynamicSectionOptionsSchema>;
|
||||
onSuccessfulEdit: (value: z.infer<typeof dynamicSectionOptionsSchema>) => void;
|
||||
}
|
||||
|
||||
export const DynamicSectionEditModal = createModal<ModalProps>(({ actions, innerProps }) => {
|
||||
const t = useI18n();
|
||||
const theme = useMantineTheme();
|
||||
|
||||
const form = useZodForm(dynamicSectionOptionsSchema, {
|
||||
mode: "controlled",
|
||||
initialValues: { ...innerProps.value },
|
||||
});
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={form.onSubmit((values) => {
|
||||
innerProps.onSuccessfulEdit(values);
|
||||
actions.closeModal();
|
||||
})}
|
||||
>
|
||||
<Stack>
|
||||
<ColorInput
|
||||
label={t("section.dynamic.option.borderColor.label")}
|
||||
format="hex"
|
||||
swatches={Object.values(theme.colors).map((color) => color[6])}
|
||||
rightSection={
|
||||
<CloseButton
|
||||
onClick={() => form.setFieldValue("borderColor", "")}
|
||||
style={{ display: form.getInputProps("borderColor").value ? undefined : "none" }}
|
||||
/>
|
||||
}
|
||||
{...form.getInputProps("borderColor")}
|
||||
/>
|
||||
<Group justify="end">
|
||||
<Group justify="end" w={{ base: "100%", xs: "auto" }}>
|
||||
<Button onClick={actions.closeModal} variant="subtle" color="gray">
|
||||
{t("common.action.cancel")}
|
||||
</Button>
|
||||
<Button type="submit" color="teal">
|
||||
{t("common.action.saveChanges")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Group>
|
||||
</Stack>
|
||||
</form>
|
||||
);
|
||||
}).withOptions({
|
||||
defaultTitle(t) {
|
||||
return t("item.edit.title");
|
||||
},
|
||||
size: "lg",
|
||||
});
|
||||
@@ -1,22 +1,37 @@
|
||||
import { ActionIcon, Menu } from "@mantine/core";
|
||||
import { IconDotsVertical, IconTrash } from "@tabler/icons-react";
|
||||
import { IconDotsVertical, IconPencil, IconTrash } from "@tabler/icons-react";
|
||||
|
||||
import { useEditMode } from "@homarr/boards/edit-mode";
|
||||
import { useConfirmModal } from "@homarr/modals";
|
||||
import { useConfirmModal, useModalAction } from "@homarr/modals";
|
||||
import { useI18n, useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import type { DynamicSection } from "~/app/[locale]/boards/_types";
|
||||
import type { DynamicSectionItem } from "~/app/[locale]/boards/_types";
|
||||
import { useDynamicSectionActions } from "./dynamic-actions";
|
||||
import { DynamicSectionEditModal } from "./dynamic-edit-modal";
|
||||
|
||||
export const BoardDynamicSectionMenu = ({ section }: { section: DynamicSection }) => {
|
||||
export const BoardDynamicSectionMenu = ({ section }: { section: DynamicSectionItem }) => {
|
||||
const t = useI18n();
|
||||
const tDynamic = useScopedI18n("section.dynamic");
|
||||
const { removeDynamicSection } = useDynamicSectionActions();
|
||||
const tItem = useScopedI18n("item");
|
||||
const { openModal } = useModalAction(DynamicSectionEditModal);
|
||||
const { updateDynamicSection, removeDynamicSection } = useDynamicSectionActions();
|
||||
const { openConfirmModal } = useConfirmModal();
|
||||
const [isEditMode] = useEditMode();
|
||||
|
||||
if (!isEditMode) return null;
|
||||
|
||||
const openEditModal = () => {
|
||||
openModal({
|
||||
value: section.options,
|
||||
onSuccessfulEdit: (options) => {
|
||||
updateDynamicSection({
|
||||
itemId: section.id,
|
||||
newOptions: options,
|
||||
});
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const openRemoveModal = () => {
|
||||
openConfirmModal({
|
||||
title: tDynamic("remove.title"),
|
||||
@@ -35,6 +50,11 @@ export const BoardDynamicSectionMenu = ({ section }: { section: DynamicSection }
|
||||
</ActionIcon>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown miw={128}>
|
||||
<Menu.Label>{tItem("menu.label.settings")}</Menu.Label>
|
||||
<Menu.Item leftSection={<IconPencil size={16} />} onClick={openEditModal}>
|
||||
{tItem("action.edit")}
|
||||
</Menu.Item>
|
||||
<Menu.Divider />
|
||||
<Menu.Label c="red.6">{t("common.dangerZone")}</Menu.Label>
|
||||
<Menu.Item c="red.6" leftSection={<IconTrash size={16} />} onClick={openRemoveModal}>
|
||||
{tDynamic("action.remove")}
|
||||
|
||||
@@ -11,14 +11,15 @@ interface Props {
|
||||
}
|
||||
|
||||
export const BoardEmptySection = ({ section }: Props) => {
|
||||
const { itemIds } = useSectionItems(section);
|
||||
const { items, innerSections } = useSectionItems(section.id);
|
||||
const totalLength = items.length + innerSections.length;
|
||||
const [isEditMode] = useEditMode();
|
||||
|
||||
return (
|
||||
<GridStack
|
||||
section={section}
|
||||
style={{ transitionDuration: "0s" }}
|
||||
className={combineClasses("min-row", itemIds.length > 0 || isEditMode ? undefined : "grid-stack-empty-wrapper")}
|
||||
className={combineClasses("min-row", totalLength > 0 || isEditMode ? undefined : "grid-stack-empty-wrapper")}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -4,23 +4,24 @@ import type { BoxProps } from "@mantine/core";
|
||||
import { Box } from "@mantine/core";
|
||||
import combineClasses from "clsx";
|
||||
|
||||
import type { Section } from "~/app/[locale]/boards/_types";
|
||||
import type { DynamicSectionItem, Section } from "~/app/[locale]/boards/_types";
|
||||
import { SectionContent } from "../content";
|
||||
import { SectionProvider } from "../section-context";
|
||||
import { useSectionItems } from "../use-section-items";
|
||||
import { useGridstack } from "./use-gridstack";
|
||||
|
||||
interface Props extends BoxProps {
|
||||
section: Section;
|
||||
section: Exclude<Section, { kind: "dynamic" }> | DynamicSectionItem;
|
||||
}
|
||||
|
||||
export const GridStack = ({ section, ...props }: Props) => {
|
||||
const { itemIds, innerSections } = useSectionItems(section);
|
||||
const { items, innerSections } = useSectionItems(section.id);
|
||||
const itemIds = [...items, ...innerSections].map((item) => item.id);
|
||||
|
||||
const { refs } = useGridstack(section, itemIds);
|
||||
|
||||
return (
|
||||
<SectionProvider value={{ section, innerSections, refs }}>
|
||||
<SectionProvider value={{ section, items, innerSections, refs }}>
|
||||
<Box
|
||||
{...props}
|
||||
data-kind={section.kind}
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { RefObject } from "react";
|
||||
import { createRef, useCallback, useEffect, useRef } from "react";
|
||||
import { useElementSize } from "@mantine/hooks";
|
||||
|
||||
import { useRequiredBoard } from "@homarr/boards/context";
|
||||
import { useCurrentLayout, useRequiredBoard } from "@homarr/boards/context";
|
||||
import { useEditMode } from "@homarr/boards/edit-mode";
|
||||
import type { GridHTMLElement, GridItemHTMLElement, GridStack, GridStackNode } from "@homarr/gridstack";
|
||||
|
||||
@@ -68,10 +68,13 @@ export const useGridstack = (section: Omit<Section, "items">, itemIds: string[])
|
||||
|
||||
const board = useRequiredBoard();
|
||||
|
||||
const currentLayoutId = useCurrentLayout();
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
const currentLayout = board.layouts.find((layout) => layout.id === currentLayoutId)!;
|
||||
const columnCount =
|
||||
section.kind === "dynamic" && "width" in section && typeof section.width === "number"
|
||||
? section.width
|
||||
: board.columnCount;
|
||||
: currentLayout.columnCount;
|
||||
|
||||
const itemRefKeys = Object.keys(itemRefs.current);
|
||||
// define items in itemRefs for easy access and reference to items
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useCallback } from "react";
|
||||
|
||||
import { getCurrentLayout } from "@homarr/boards/context";
|
||||
import { useUpdateBoard } from "@homarr/boards/updater";
|
||||
|
||||
interface MoveAndResizeInnerSection {
|
||||
@@ -28,9 +29,19 @@ export const useSectionActions = () => {
|
||||
sections: previous.sections.map((section) => {
|
||||
// Return same section if section is not the one we're moving
|
||||
if (section.id !== innerSectionId) return section;
|
||||
if (section.kind !== "dynamic") return section;
|
||||
|
||||
const currentLayout = getCurrentLayout(previous);
|
||||
|
||||
return {
|
||||
...section,
|
||||
...positionProps,
|
||||
layouts: section.layouts.map((layout) => {
|
||||
if (layout.layoutId !== currentLayout) return layout;
|
||||
return {
|
||||
...layout,
|
||||
...positionProps,
|
||||
};
|
||||
}),
|
||||
};
|
||||
}),
|
||||
}));
|
||||
@@ -46,10 +57,20 @@ export const useSectionActions = () => {
|
||||
sections: previous.sections.map((section) => {
|
||||
// Return section without changes when not the section we're moving
|
||||
if (section.id !== innerSectionId) return section;
|
||||
if (section.kind !== "dynamic") return section;
|
||||
|
||||
const currentLayout = getCurrentLayout(previous);
|
||||
|
||||
return {
|
||||
...section,
|
||||
...positionProps,
|
||||
parentSectionId: sectionId,
|
||||
layouts: section.layouts.map((layout) => {
|
||||
if (layout.layoutId !== currentLayout) return layout;
|
||||
return {
|
||||
...layout,
|
||||
...positionProps,
|
||||
parentSectionId: sectionId,
|
||||
};
|
||||
}),
|
||||
};
|
||||
}),
|
||||
};
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { createContext, useContext } from "react";
|
||||
|
||||
import type { Section } from "~/app/[locale]/boards/_types";
|
||||
import type { DynamicSectionItem, Section, SectionItem } from "~/app/[locale]/boards/_types";
|
||||
import type { UseGridstackRefs } from "./gridstack/use-gridstack";
|
||||
|
||||
interface SectionContextProps {
|
||||
section: Section;
|
||||
innerSections: Exclude<Section, { kind: "category" } | { kind: "empty" }>[];
|
||||
section: Exclude<Section, { kind: "dynamic" }> | DynamicSectionItem;
|
||||
innerSections: DynamicSectionItem[];
|
||||
items: SectionItem[];
|
||||
refs: UseGridstackRefs;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,16 +1,53 @@
|
||||
import { useRequiredBoard } from "@homarr/boards/context";
|
||||
import { useMemo } from "react";
|
||||
|
||||
import type { Section } from "~/app/[locale]/boards/_types";
|
||||
import { useCurrentLayout, useRequiredBoard } from "@homarr/boards/context";
|
||||
|
||||
export const useSectionItems = (section: Section) => {
|
||||
import type { DynamicSectionItem, SectionItem } from "~/app/[locale]/boards/_types";
|
||||
|
||||
export const useSectionItems = (sectionId: string): { innerSections: DynamicSectionItem[]; items: SectionItem[] } => {
|
||||
const board = useRequiredBoard();
|
||||
const innerSections = board.sections.filter(
|
||||
(innerSection): innerSection is Exclude<Section, { kind: "category" } | { kind: "empty" }> =>
|
||||
innerSection.kind === "dynamic" && innerSection.parentSectionId === section.id,
|
||||
const currentLayoutId = useCurrentLayout();
|
||||
|
||||
const innerSections = useMemo(
|
||||
() =>
|
||||
board.sections
|
||||
.filter((innerSection) => innerSection.kind === "dynamic")
|
||||
.map(({ layouts, ...innerSection }) => {
|
||||
const layout = layouts.find((layout) => layout.layoutId === currentLayoutId);
|
||||
|
||||
if (!layout) return null;
|
||||
|
||||
return {
|
||||
...layout,
|
||||
...innerSection,
|
||||
type: "section" as const,
|
||||
};
|
||||
})
|
||||
.filter((item) => item !== null)
|
||||
.filter((innerSection) => innerSection.parentSectionId === sectionId),
|
||||
[board.sections, currentLayoutId, sectionId],
|
||||
);
|
||||
|
||||
const items = useMemo(
|
||||
() =>
|
||||
board.items
|
||||
.map(({ layouts, ...item }) => {
|
||||
const layout = layouts.find((layout) => layout.layoutId === currentLayoutId);
|
||||
if (!layout) return null;
|
||||
|
||||
return {
|
||||
...layout,
|
||||
...item,
|
||||
type: "item" as const,
|
||||
};
|
||||
})
|
||||
.filter((item) => item !== null)
|
||||
.filter((item) => item.sectionId === sectionId),
|
||||
[board.items, currentLayoutId, sectionId],
|
||||
);
|
||||
|
||||
return {
|
||||
innerSections,
|
||||
itemIds: section.items.map((item) => item.id).concat(innerSections.map((section) => section.id)),
|
||||
items,
|
||||
};
|
||||
};
|
||||
@@ -1,33 +0,0 @@
|
||||
import { createEnv } from "@t3-oss/env-nextjs";
|
||||
import { z } from "zod";
|
||||
|
||||
import { shouldSkipEnvValidation } from "@homarr/common/env-validation";
|
||||
|
||||
export const env = createEnv({
|
||||
shared: {
|
||||
PORT: z.coerce.number().default(3000),
|
||||
NODE_ENV: z.enum(["development", "production", "test"]).default("development"),
|
||||
},
|
||||
/**
|
||||
* Specify your server-side environment variables schema here. This way you can ensure the app isn't
|
||||
* built with invalid env vars.
|
||||
*/
|
||||
server: {},
|
||||
/**
|
||||
* Specify your client-side environment variables schema here.
|
||||
* For them to be exposed to the client, prefix them with `NEXT_PUBLIC_`.
|
||||
*/
|
||||
client: {
|
||||
// NEXT_PUBLIC_CLIENTVAR: z.string(),
|
||||
},
|
||||
/**
|
||||
* Destructure all variables from `process.env` to make sure they aren't tree-shaken away.
|
||||
*/
|
||||
runtimeEnv: {
|
||||
PORT: process.env.PORT,
|
||||
NODE_ENV: process.env.NODE_ENV,
|
||||
// NEXT_PUBLIC_CLIENTVAR: process.env.NEXT_PUBLIC_CLIENTVAR,
|
||||
},
|
||||
skipValidation: shouldSkipEnvValidation(),
|
||||
emptyStringAsUndefined: true,
|
||||
});
|
||||
@@ -2,7 +2,7 @@
|
||||
"name": "@homarr/tasks",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"license": "MIT",
|
||||
"license": "Apache-2.0",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": "./src/index.ts"
|
||||
@@ -38,17 +38,17 @@
|
||||
"dayjs": "^1.11.13",
|
||||
"dotenv": "^16.4.7",
|
||||
"superjson": "2.2.2",
|
||||
"undici": "7.3.0"
|
||||
"undici": "7.4.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@homarr/eslint-config": "workspace:^0.2.0",
|
||||
"@homarr/prettier-config": "workspace:^0.1.0",
|
||||
"@homarr/tsconfig": "workspace:^0.1.0",
|
||||
"@types/node": "^22.13.4",
|
||||
"@types/node": "^22.13.9",
|
||||
"dotenv-cli": "^8.0.0",
|
||||
"eslint": "^9.20.1",
|
||||
"prettier": "^3.4.2",
|
||||
"tsx": "4.19.2",
|
||||
"typescript": "^5.7.3"
|
||||
"eslint": "^9.21.0",
|
||||
"prettier": "^3.5.3",
|
||||
"tsx": "4.19.3",
|
||||
"typescript": "^5.8.2"
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
"name": "@homarr/websocket",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"license": "MIT",
|
||||
"license": "Apache-2.0",
|
||||
"type": "module",
|
||||
"main": "./src/main.ts",
|
||||
"types": "./src/main.ts",
|
||||
@@ -26,16 +26,16 @@
|
||||
"@homarr/redis": "workspace:^0.1.0",
|
||||
"@homarr/validation": "workspace:^0.1.0",
|
||||
"dotenv": "^16.4.7",
|
||||
"tsx": "4.19.2",
|
||||
"ws": "^8.18.0"
|
||||
"tsx": "4.19.3",
|
||||
"ws": "^8.18.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@homarr/eslint-config": "workspace:^0.2.0",
|
||||
"@homarr/prettier-config": "workspace:^0.1.0",
|
||||
"@homarr/tsconfig": "workspace:^0.1.0",
|
||||
"@types/ws": "^8.5.14",
|
||||
"eslint": "^9.20.1",
|
||||
"prettier": "^3.4.2",
|
||||
"typescript": "^5.7.3"
|
||||
"@types/ws": "^8.18.0",
|
||||
"eslint": "^9.21.0",
|
||||
"prettier": "^3.5.3",
|
||||
"typescript": "^5.8.2"
|
||||
}
|
||||
}
|
||||
@@ -1 +1,2 @@
|
||||
docker run -p 7575:7575 homarr:latest
|
||||
:: Please do not run this command in production. It is only for local testing.
|
||||
docker run -p 7575:7575 -e SECRET_ENCRYPTION_KEY=0000000000000000000000000000000000000000000000000000000000000000 homarr:latest
|
||||
+5
-5
@@ -1,5 +1,5 @@
|
||||
<!-- Project Title -->
|
||||

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

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

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

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

|
||||
[](https://opencollective.com/homarr)
|
||||
|
||||
<br/>
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@ export class OnboardingActions {
|
||||
await this.db.insert(sqliteSchema.groups).values({
|
||||
id: createId(),
|
||||
name: input.group,
|
||||
position: 1,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
+11
-11
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "homarr",
|
||||
"version": "1.6.0",
|
||||
"version": "1.10.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build": "cross-env CI=true turbo build",
|
||||
@@ -38,22 +38,22 @@
|
||||
"@semantic-release/github": "^11.0.1",
|
||||
"@semantic-release/npm": "^12.0.1",
|
||||
"@semantic-release/release-notes-generator": "^14.0.3",
|
||||
"@turbo/gen": "^2.4.2",
|
||||
"@turbo/gen": "^2.4.4",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"@vitest/coverage-v8": "^3.0.5",
|
||||
"@vitest/ui": "^3.0.5",
|
||||
"@vitest/coverage-v8": "^3.0.8",
|
||||
"@vitest/ui": "^3.0.8",
|
||||
"conventional-changelog-conventionalcommits": "^8.0.0",
|
||||
"cross-env": "^7.0.3",
|
||||
"jsdom": "^26.0.0",
|
||||
"prettier": "^3.4.2",
|
||||
"semantic-release": "^24.2.2",
|
||||
"prettier": "^3.5.3",
|
||||
"semantic-release": "^24.2.3",
|
||||
"testcontainers": "^10.18.0",
|
||||
"turbo": "^2.4.2",
|
||||
"typescript": "^5.7.3",
|
||||
"turbo": "^2.4.4",
|
||||
"typescript": "^5.8.2",
|
||||
"vite-tsconfig-paths": "^5.1.4",
|
||||
"vitest": "^3.0.5"
|
||||
"vitest": "^3.0.8"
|
||||
},
|
||||
"packageManager": "pnpm@10.4.0",
|
||||
"packageManager": "pnpm@10.5.2",
|
||||
"engines": {
|
||||
"node": ">=22.14.0"
|
||||
},
|
||||
@@ -71,7 +71,7 @@
|
||||
],
|
||||
"allowNonAppliedPatches": true,
|
||||
"overrides": {
|
||||
"proxmox-api>undici": "7.3.0"
|
||||
"proxmox-api>undici": "7.4.0"
|
||||
},
|
||||
"patchedDependencies": {
|
||||
"pretty-print-error": "patches/pretty-print-error.patch"
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"name": "@homarr/analytics",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"license": "MIT",
|
||||
"license": "Apache-2.0",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": "./index.ts"
|
||||
@@ -32,7 +32,7 @@
|
||||
"@homarr/eslint-config": "workspace:^0.2.0",
|
||||
"@homarr/prettier-config": "workspace:^0.1.0",
|
||||
"@homarr/tsconfig": "workspace:^0.1.0",
|
||||
"eslint": "^9.20.1",
|
||||
"typescript": "^5.7.3"
|
||||
"eslint": "^9.21.0",
|
||||
"typescript": "^5.8.2"
|
||||
}
|
||||
}
|
||||
@@ -36,7 +36,7 @@ const sendWidgetDataAsync = async (umamiInstance: Umami, analyticsSettings: type
|
||||
if (!analyticsSettings.enableWidgetData) {
|
||||
return;
|
||||
}
|
||||
const widgetCount = (await db.select({ count: count(items.id) }).from(items))[0]?.count ?? 0;
|
||||
const widgetCount = await db.$count(items);
|
||||
|
||||
const response = await umamiInstance.track("server-widget-data", {
|
||||
countWidgets: widgetCount,
|
||||
@@ -52,7 +52,7 @@ const sendUserDataAsync = async (umamiInstance: Umami, analyticsSettings: typeof
|
||||
if (!analyticsSettings.enableUserData) {
|
||||
return;
|
||||
}
|
||||
const userCount = (await db.select({ count: count(users.id) }).from(users))[0]?.count ?? 0;
|
||||
const userCount = await db.$count(users);
|
||||
|
||||
const response = await umamiInstance.track("server-user-data", {
|
||||
countUsers: userCount,
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"name": "@homarr/api",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"license": "MIT",
|
||||
"license": "Apache-2.0",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
@@ -56,8 +56,8 @@
|
||||
"@homarr/eslint-config": "workspace:^0.2.0",
|
||||
"@homarr/prettier-config": "workspace:^0.1.0",
|
||||
"@homarr/tsconfig": "workspace:^0.1.0",
|
||||
"eslint": "^9.20.1",
|
||||
"prettier": "^3.4.2",
|
||||
"typescript": "^5.7.3"
|
||||
"eslint": "^9.21.0",
|
||||
"prettier": "^3.5.3",
|
||||
"typescript": "^5.8.2"
|
||||
}
|
||||
}
|
||||
@@ -37,15 +37,7 @@ export const createOneIntegrationMiddleware = <TKind extends IntegrationKind>(
|
||||
userPermissions: true,
|
||||
items: {
|
||||
with: {
|
||||
item: {
|
||||
with: {
|
||||
section: {
|
||||
columns: {
|
||||
boardId: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
item: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -107,15 +99,7 @@ export const createManyIntegrationMiddleware = <TKind extends IntegrationKind>(
|
||||
secrets: true,
|
||||
items: {
|
||||
with: {
|
||||
item: {
|
||||
with: {
|
||||
section: {
|
||||
columns: {
|
||||
boardId: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
item: true,
|
||||
},
|
||||
},
|
||||
userPermissions: true,
|
||||
|
||||
@@ -56,7 +56,11 @@ export const appRouter = createTRPCRouter({
|
||||
}),
|
||||
selectable: protectedProcedure
|
||||
.input(z.void())
|
||||
.output(z.array(selectAppSchema.pick({ id: true, name: true, iconUrl: true, href: true, description: true })))
|
||||
.output(
|
||||
z.array(
|
||||
selectAppSchema.pick({ id: true, name: true, iconUrl: true, href: true, pingUrl: true, description: true }),
|
||||
),
|
||||
)
|
||||
.meta({
|
||||
openapi: {
|
||||
method: "GET",
|
||||
@@ -73,6 +77,7 @@ export const appRouter = createTRPCRouter({
|
||||
iconUrl: true,
|
||||
description: true,
|
||||
href: true,
|
||||
pingUrl: true,
|
||||
},
|
||||
orderBy: asc(apps.name),
|
||||
});
|
||||
@@ -111,16 +116,20 @@ export const appRouter = createTRPCRouter({
|
||||
create: permissionRequiredProcedure
|
||||
.requiresPermission("app-create")
|
||||
.input(validation.app.manage)
|
||||
.output(z.void())
|
||||
.output(z.object({ appId: z.string() }))
|
||||
.meta({ openapi: { method: "POST", path: "/api/apps", tags: ["apps"], protect: true } })
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const id = createId();
|
||||
await ctx.db.insert(apps).values({
|
||||
id: createId(),
|
||||
id,
|
||||
name: input.name,
|
||||
description: input.description,
|
||||
iconUrl: input.iconUrl,
|
||||
href: input.href,
|
||||
pingUrl: input.pingUrl === "" ? null : input.pingUrl,
|
||||
});
|
||||
|
||||
return { appId: id };
|
||||
}),
|
||||
createMany: permissionRequiredProcedure
|
||||
.requiresPermission("app-create")
|
||||
@@ -161,6 +170,7 @@ export const appRouter = createTRPCRouter({
|
||||
description: input.description,
|
||||
iconUrl: input.iconUrl,
|
||||
href: input.href,
|
||||
pingUrl: input.pingUrl === "" ? null : input.pingUrl,
|
||||
})
|
||||
.where(eq(apps.id, input.id));
|
||||
}),
|
||||
|
||||
@@ -21,21 +21,16 @@ const getAllAppIdsOnPublicBoardsAsync = async () => {
|
||||
const itemsWithApps = await db.query.items.findMany({
|
||||
where: or(eq(items.kind, "app"), eq(items.kind, "bookmarks")),
|
||||
with: {
|
||||
section: {
|
||||
columns: {}, // Nothing
|
||||
with: {
|
||||
board: {
|
||||
columns: {
|
||||
isPublic: true,
|
||||
},
|
||||
},
|
||||
board: {
|
||||
columns: {
|
||||
isPublic: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return itemsWithApps
|
||||
.filter((item) => item.section.board.isPublic)
|
||||
.filter((item) => item.board.isPublic)
|
||||
.flatMap((item) => {
|
||||
if (item.kind === "app") {
|
||||
const parsedOptions = SuperJSON.parse<WidgetComponentProps<"app">["options"]>(item.options);
|
||||
|
||||
+696
-166
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,186 @@
|
||||
export interface GridAlgorithmItem {
|
||||
id: string;
|
||||
type: "item" | "section";
|
||||
width: number;
|
||||
height: number;
|
||||
xOffset: number;
|
||||
yOffset: number;
|
||||
sectionId: string;
|
||||
}
|
||||
|
||||
interface GridAlgorithmInput {
|
||||
items: GridAlgorithmItem[];
|
||||
width: number;
|
||||
previousWidth: number;
|
||||
sectionId: string;
|
||||
}
|
||||
|
||||
interface GridAlgorithmOutput {
|
||||
height: number;
|
||||
items: GridAlgorithmItem[];
|
||||
}
|
||||
|
||||
export const generateResponsiveGridFor = ({
|
||||
items,
|
||||
previousWidth,
|
||||
width,
|
||||
sectionId,
|
||||
}: GridAlgorithmInput): GridAlgorithmOutput => {
|
||||
const itemsOfCurrentSection = items
|
||||
.filter((item) => item.sectionId === sectionId)
|
||||
.sort((itemA, itemB) =>
|
||||
itemA.yOffset === itemB.yOffset ? itemA.xOffset - itemB.xOffset : itemA.yOffset - itemB.yOffset,
|
||||
);
|
||||
const normalizedItems = normalizeItems(itemsOfCurrentSection, width);
|
||||
|
||||
if (itemsOfCurrentSection.length === 0) {
|
||||
return {
|
||||
height: 0,
|
||||
items: [],
|
||||
};
|
||||
}
|
||||
|
||||
const newItems: GridAlgorithmItem[] = [];
|
||||
|
||||
// Fix height of dynamic sections
|
||||
const dynamicSectionHeightMap = new Map<string, number>();
|
||||
const dynamicSectionsOfCurrentSection = normalizedItems.filter((item) => item.type === "section");
|
||||
for (const dynamicSection of dynamicSectionsOfCurrentSection) {
|
||||
const result = generateResponsiveGridFor({
|
||||
items,
|
||||
previousWidth: dynamicSection.previousWidth,
|
||||
width: dynamicSection.width,
|
||||
sectionId: dynamicSection.id,
|
||||
});
|
||||
newItems.push(...result.items);
|
||||
dynamicSectionHeightMap.set(dynamicSection.id, result.height);
|
||||
}
|
||||
|
||||
// Return same positions for items in the current section
|
||||
if (width >= previousWidth) {
|
||||
return {
|
||||
height: Math.max(...itemsOfCurrentSection.map((item) => item.yOffset + item.height)),
|
||||
items: newItems.concat(normalizedItems),
|
||||
};
|
||||
}
|
||||
|
||||
const occupied2d: boolean[][] = [];
|
||||
|
||||
for (const item of normalizedItems) {
|
||||
const itemWithHeight = {
|
||||
...item,
|
||||
height: item.type === "section" ? Math.max(dynamicSectionHeightMap.get(item.id) ?? 1, item.height) : item.height,
|
||||
};
|
||||
const position = nextFreeSpot(occupied2d, itemWithHeight, width);
|
||||
if (!position) throw new Error("No free spot available");
|
||||
|
||||
addItemToOccupied(occupied2d, itemWithHeight, position, width);
|
||||
newItems.push({
|
||||
...itemWithHeight,
|
||||
xOffset: position.x,
|
||||
yOffset: position.y,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
height: occupied2d.length,
|
||||
items: newItems,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Reduces the width of the items to fit the new column count.
|
||||
* @param items items to normalize
|
||||
* @param columnCount new column count
|
||||
*/
|
||||
const normalizeItems = (items: GridAlgorithmItem[], columnCount: number) => {
|
||||
return items.map((item) => ({ ...item, previousWidth: item.width, width: Math.min(columnCount, item.width) }));
|
||||
};
|
||||
|
||||
/**
|
||||
* Adds the item to the occupied spots.
|
||||
* @param occupied2d array of occupied spots
|
||||
* @param item item to place
|
||||
* @param position position to place the item
|
||||
*/
|
||||
const addItemToOccupied = (
|
||||
occupied2d: boolean[][],
|
||||
item: GridAlgorithmItem,
|
||||
position: { x: number; y: number },
|
||||
columnCount: number,
|
||||
) => {
|
||||
for (let yOffset = 0; yOffset < item.height; yOffset++) {
|
||||
let row = occupied2d[position.y + yOffset];
|
||||
if (!row) {
|
||||
addRow(occupied2d, columnCount);
|
||||
// After adding it, it must exist
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
row = occupied2d[position.y + yOffset]!;
|
||||
}
|
||||
|
||||
for (let xOffset = 0; xOffset < item.width; xOffset++) {
|
||||
row[position.x + xOffset] = true;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Adds a new row to the grid.
|
||||
* @param occupied2d array of occupied spots
|
||||
* @param columnCount column count of section
|
||||
*/
|
||||
const addRow = (occupied2d: boolean[][], columnCount: number) => {
|
||||
occupied2d.push(new Array<boolean>(columnCount).fill(false));
|
||||
};
|
||||
|
||||
/**
|
||||
* Searches for the next free spot in the grid.
|
||||
* @param occupied2d array of occupied spots
|
||||
* @param item item to place
|
||||
* @param columnCount column count of section
|
||||
* @returns the position of the next free spot or null if no spot is available
|
||||
*/
|
||||
const nextFreeSpot = (occupied2d: boolean[][], item: GridAlgorithmItem, columnCount: number) => {
|
||||
for (let offsetY = 0; offsetY < 99999; offsetY++) {
|
||||
for (let offsetX = 0; offsetX < columnCount; offsetX++) {
|
||||
if (hasHorizontalSpace(columnCount, item, offsetX) && isFree(occupied2d, item, { x: offsetX, y: offsetY })) {
|
||||
return { x: offsetX, y: offsetY };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if the item fits into the grid horizontally.
|
||||
* @param columnCount available width
|
||||
* @param item item to place
|
||||
* @param offsetX current x position
|
||||
* @returns true if the item fits horizontally
|
||||
*/
|
||||
const hasHorizontalSpace = (columnCount: number, item: GridAlgorithmItem, offsetX: number) => {
|
||||
return offsetX + item.width <= columnCount;
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if the spot is free.
|
||||
* @param occupied2d array of occupied spots
|
||||
* @param item item to place
|
||||
* @param position position to check
|
||||
* @returns true if the spot is free
|
||||
*/
|
||||
const isFree = (occupied2d: boolean[][], item: GridAlgorithmItem, position: { x: number; y: number }) => {
|
||||
for (let yOffset = 0; yOffset < item.height; yOffset++) {
|
||||
const row = occupied2d[position.y + yOffset];
|
||||
if (!row) return true; // Empty row is free
|
||||
|
||||
for (let xOffset = 0; xOffset < item.width; xOffset++) {
|
||||
if (row[position.x + xOffset]) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
Loaded 100 of 373 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user