Compare commits

..
Author SHA1 Message Date
ajnart 8c18dfef42 test: remove obsolete advanced interactions e2e 2026-08-14 15:15:12 +02:00
ajnart f3946388fc test: remove advanced focus support assertion 2026-08-14 14:45:20 +02:00
ajnart 08588da0d1 test: stabilize advanced view keyboard flow 2026-08-14 11:31:55 +02:00
ajnart 464f30799a fix: refine advanced widget access 2026-08-14 11:07:09 +02:00
ajnart bd4bbcaca2 chore: reconcile widget layer after stack rebase 2026-08-14 11:07:09 +02:00
Thomas Camlong 09b82ba171 fix: limit advanced widget focus 2026-08-14 11:07:09 +02:00
Thomas Camlong f38eb034da fix: address widget review findings 2026-08-14 11:07:09 +02:00
Thomas Camlong a3a89db727 test: align widget modernization fixtures 2026-08-14 11:07:09 +02:00
Thomas Camlong c099a8eef6 feat: expand advanced widget experiences 2026-08-14 11:07:09 +02:00
Thomas Camlong 9437d211c8 chore: drop manual translations 2026-08-14 11:07:09 +02:00
Thomas Camlong 096cf56ac9 fix: rework media session details 2026-08-14 11:07:08 +02:00
Thomas Camlong 851129e62e fix: refine Beszel stats period controls 2026-08-14 11:07:08 +02:00
Thomas Camlong cc1b22f052 fix: show complete Immich photos in advanced mode 2026-08-14 11:07:08 +02:00
Thomas Camlong 5df4358f0d test: preserve advanced preview hover ownership 2026-08-14 11:07:08 +02:00
Thomas Camlong 821b5a555d feat: improve advanced preview activation 2026-08-14 11:07:08 +02:00
Thomas Camlong a0f4a58386 fix: smooth advanced focus entrance 2026-08-14 11:07:08 +02:00
Thomas Camlong 7c840bcfa0 Remove Docker advanced focus 2026-08-14 11:07:08 +02:00
Thomas Camlong 38d30acb87 fix: require advanced widget focus opt-in 2026-08-14 11:07:08 +02:00
Thomas Camlong ae515584cc test(widgets): stabilize advanced focus hover 2026-08-14 11:07:08 +02:00
Thomas Camlong 9338d6d4ec test(mcp): review smart home entity details tool 2026-08-14 11:07:08 +02:00
Thomas Camlong d98b7797c1 style(widgets): split query matcher type imports 2026-08-14 11:07:08 +02:00
Thomas Camlong 3c739dc636 fix(widgets): keep board graph imports scoped 2026-08-14 11:07:08 +02:00
Thomas Camlong 639cb8d35f fix(ci): make modernization independently installable 2026-08-14 11:07:08 +02:00
Thomas Camlong f1ac482684 style: format modernization notes 2026-08-14 11:07:08 +02:00
Thomas Camlong 9cc7fd2051 fix: preserve app links and hover previews 2026-08-14 11:07:08 +02:00
Thomas Camlong d4d951b99c fix: stabilize advanced widget interactions 2026-08-14 11:07:08 +02:00
Thomas Camlong a4f115064e fix: finish widget modernization audit 2026-08-14 11:07:08 +02:00
Thomas Camlong 07e0642192 fix: address widget review feedback 2026-08-14 11:07:08 +02:00
Thomas Camlong 965ccb32d6 fix: scope advanced preview controls 2026-08-14 11:07:08 +02:00
Thomas Camlong be08f70c8d fix: restore notifications query scope 2026-08-14 11:07:08 +02:00
Thomas Camlong 4892c399ba fix: remove stale notifications import 2026-08-14 11:07:08 +02:00
ajnart 687fb323f7 style: auto-fix lint and format 2026-08-14 11:07:08 +02:00
Thomas Camlong 56cc021f98 fix: refine widget interactions 2026-08-14 11:07:08 +02:00
Thomas Camlong 836a4cd46b feat: modernize widget experience 2026-08-14 11:07:08 +02:00
ajnart a607dff50a docs: add widget modernization audit 2026-08-14 11:07:08 +02:00
Thomas Camlong 68751d85dc restore context menu focus 2026-08-14 11:07:08 +02:00
Thomas Camlong 869dd45de5 fix context menu keyboard focus 2026-08-14 11:07:08 +02:00
Thomas Camlong 352aa7dd64 fix board E2E login race 2026-08-14 11:07:08 +02:00
Thomas Camlong 801221d2da fix SVG focus scrolling 2026-08-14 11:07:08 +02:00
Thomas Camlong 861538e9a0 fix focused pane scrolling 2026-08-14 11:07:08 +02:00
Thomas Camlong cb7b49b3e7 fix focus scroll layering 2026-08-14 11:07:08 +02:00
Thomas Camlong f5cced4080 fix widget focus interactions 2026-08-14 11:07:08 +02:00
Thomas Camlong e429d7540a fix: align responsive widget actions 2026-08-14 11:07:08 +02:00
Thomas Camlong 3bd80dcc58 fix: simplify widget feedback 2026-08-14 11:07:08 +02:00
Thomas Camlong 0e01ac67df fix: harden widget error feedback 2026-08-14 11:07:08 +02:00
Thomas Camlong 8259df58f6 fix: address final widget review 2026-08-14 11:07:08 +02:00
Thomas Camlong 3485ef0877 fix: localize widget chart formatting 2026-08-14 11:07:08 +02:00
Thomas Camlong 2214acd17e fix: align widget accessibility and locales 2026-08-14 11:07:08 +02:00
Thomas Camlong 25b835dca9 fix: canonicalize widget date locales 2026-08-14 11:07:08 +02:00
Thomas Camlong 2efa6bcd4c fix: close final widget review gaps 2026-08-14 11:07:08 +02:00
Thomas Camlong c67c893f8f fix: harden widget review follow-ups 2026-08-14 11:07:08 +02:00
Thomas Camlong e7e44375ef fix: address widget review findings 2026-08-14 11:07:08 +02:00
Thomas Camlong 842e86012d feat: refine widget interactions 2026-08-14 11:07:08 +02:00
Thomas Camlong a8a7545f40 fix: secure widget navigation links 2026-08-14 11:07:08 +02:00
Thomas Camlong ed7b30dc5f fix: polish widget failure feedback 2026-08-14 11:07:08 +02:00
Thomas Camlong c09ed96978 fix: address final widget review findings 2026-08-14 11:07:08 +02:00
Thomas Camlong 44b55a401c fix: complete widget review cleanup 2026-08-14 11:07:08 +02:00
Thomas Camlong f00f5b83b5 fix: address remaining widget review feedback 2026-08-14 11:07:08 +02:00
Thomas Camlong 5553c4e0c5 fix: allow dynamic database schema access 2026-08-14 11:07:08 +02:00
Thomas Camlong 6cdda54e10 fix: make integration warnings keyboard accessible 2026-08-14 11:07:08 +02:00
Thomas Camlong 0f9d038aaf fix: complete widget modernization review 2026-08-14 11:07:08 +02:00
Thomas Camlong a38e731209 fix: address widget review feedback 2026-08-14 11:07:08 +02:00
ajnart b1b17ad69f style: auto-fix lint and format 2026-08-14 11:07:08 +02:00
Thomas Camlong add40370aa fix: refine widget modernization 2026-08-14 11:07:07 +02:00
Thomas Camlong a2763d1f6f feat: modernize dashboard widgets 2026-08-14 11:07:07 +02:00
ajnart f3048f4804 docs: correct board interaction guidance 2026-08-14 11:07:07 +02:00
ajnart 6bfa9c33d0 chore: reconcile grid layer after stack rebase 2026-08-14 11:07:07 +02:00
Thomas Camlong 16982aa178 fix: simplify grid interaction previews 2026-08-14 11:07:07 +02:00
Thomas Camlong a2400f8412 perf: isolate board grid previews 2026-08-14 11:07:07 +02:00
Thomas Camlong 6e455760a3 fix: disable app context menu 2026-08-14 11:07:07 +02:00
Thomas Camlong 0b9b77c632 perf: isolate grid interaction renders 2026-08-14 11:07:07 +02:00
Thomas Camlong 346bff540f fix: stabilize rail and resize interactions 2026-08-14 11:07:07 +02:00
Thomas Camlong f8771c371f chore: leave translations to Crowdin 2026-08-14 11:07:07 +02:00
Thomas Camlong 71cc3abbe9 fix: prevent horizontal rail auto-scroll 2026-08-14 11:07:07 +02:00
Thomas Camlong a098c42013 Revert "ci: use self-hosted runners for stack"
This reverts commit a1d80515b0e00240bb662180626a44bfb205656e.
2026-08-14 11:07:07 +02:00
Thomas Camlong 4cf83443fd ci: use self-hosted runners for stack 2026-08-14 11:07:07 +02:00
Thomas Camlong f5b4889663 fix: anchor dashboard rail labels 2026-08-14 11:07:07 +02:00
Thomas Camlong 06aa5caf0b fix: size dashboard rails to viewport 2026-08-14 11:07:07 +02:00
Thomas Camlong d3534dfcf8 style: format board grid test 2026-08-14 11:07:07 +02:00
Thomas Camlong f7591dd196 test: allow dnd-kit DOM modifier import 2026-08-14 11:07:07 +02:00
Thomas Camlong 0c5f7a45bb fix: preserve narrow board interactions 2026-08-14 11:07:07 +02:00
Thomas Camlong cdbba92f31 fix: keep scaled board rails sticky 2026-08-14 11:07:07 +02:00
Thomas Camlong 3b0633f6a8 test: assert clipped zoom overflow 2026-08-14 11:07:07 +02:00
Thomas Camlong d0880aa04c test: avoid physical zoom size assumption 2026-08-14 11:07:07 +02:00
Thomas Camlong 7cf6a5d5ab test: assert zoomed canvas fit invariant 2026-08-14 11:07:07 +02:00
Thomas Camlong d5763737c3 style: format board grid coverage 2026-08-14 11:07:07 +02:00
Thomas Camlong 00b0b215b0 fix: stabilize board rails 2026-08-14 11:07:07 +02:00
Thomas Camlong 5f08327c5c Fix board grid bounds and live growth 2026-08-14 11:07:07 +02:00
Thomas Camlong 2645cb426a fix: accept legacy dynamic sections 2026-08-14 11:07:07 +02:00
Thomas Camlong 65e866064e Rename layout gutter migrations 2026-08-14 11:07:07 +02:00
Thomas Camlong 392df12d8a style: format dnd migration metadata 2026-08-14 11:07:07 +02:00
Thomas Camlong 6209980b27 test(e2e): wait for settled grid geometry 2026-08-14 11:07:07 +02:00
Thomas Camlong 3f04cf83a2 fix container header spacing 2026-08-14 11:07:07 +02:00
Thomas Camlong e5a8194c03 fix: contain board canvas overflow 2026-08-14 11:07:07 +02:00
Thomas Camlong 90cf0e1127 fix: contain collapsed layout and widget overflow 2026-08-14 11:07:07 +02:00
Thomas Camlong 498481d539 test: wait for resized grid geometry 2026-08-14 11:07:07 +02:00
Thomas Camlong 65da74858b test: stabilize board grid authentication 2026-08-14 11:07:07 +02:00
ajnart af2b7b3157 style: auto-fix lint and format 2026-08-14 11:07:07 +02:00
Thomas Camlong db40779492 fix: smooth container collapse behavior 2026-08-14 11:07:07 +02:00
ajnart 91aeac8377 style: auto-fix lint and format 2026-08-14 11:07:06 +02:00
Thomas Camlong dfe2ddf34b fix: refine container interactions 2026-08-14 11:07:06 +02:00
Thomas Camlong 34f2a0bd24 fix: polish board grid interactions 2026-08-14 11:07:06 +02:00
Thomas Camlong 4227e392b5 fix: address board grid CI and review 2026-08-14 11:07:06 +02:00
ajnart 6893205ee0 style: auto-fix lint and format 2026-08-14 11:07:06 +02:00
Thomas Camlong 28ac23e66b fix: address board grid review 2026-08-14 11:07:06 +02:00
Thomas Camlong cddae6719e feat: rebuild board grid with dnd-kit 2026-08-14 11:07:06 +02:00
ajnart 0cb75982fc test: wait for grid reconciliation between drags 2026-08-14 11:07:06 +02:00
ajnart 36de8131c8 fix: clarify board grid editing affordances 2026-08-14 11:07:06 +02:00
ajnart 2f28f45ff1 fix: improve board grid interactions 2026-08-14 11:07:06 +02:00
Thomas Camlong 915af9ae96 test: target visible layout preview 2026-08-14 11:07:06 +02:00
Thomas Camlong db5d088330 test: tolerate sticky rail subpixel rounding 2026-08-14 11:07:06 +02:00
Thomas Camlong d1a2ed8fd7 test: avoid grid drag rounding boundary 2026-08-14 11:07:06 +02:00
ajnart d6b114e423 feat: rebuild board grid experience 2026-08-14 11:07:06 +02:00
ajnart e41d938a5f test: match custom widget cache version 2026-08-14 11:07:02 +02:00
ajnart afa54755fd fix custom widget review findings 2026-08-14 10:35:21 +02:00
ajnart 0482c040ec chore: reconcile v2 after stack rebase 2026-08-13 19:59:46 +02:00
Thomas Camlong 5dc4dbeaef style: format v2 workshop files 2026-08-13 19:51:44 +02:00
Thomas f3f2778f1f test: cover public custom widgets for guests (#6558) 2026-08-13 19:51:44 +02:00
Thomas c08b8979ee feat: rewrite image picker (#6556)
* feat: rewrite image picker

* style: auto-fix lint and format

* fix: address image picker review

* fix: preserve unicode icon searches

* test: stabilize custom widget login

* fix: suggest image searches only on create
2026-08-13 19:51:44 +02:00
Thomas Camlong 9a7a828618 ci: retry flaky container-backed unit tests in fast gate
Integration tests (nzbget, nextcloud, pi-hole, etc.) start Docker
containers and intermittently time out under shared-runner load, which
fails the fast gate and skips the container job that publishes the PR
preview image. Retry failing unit tests twice so transient container
startup failures do not block the image publish.
2026-08-13 19:51:44 +02:00
Thomas Camlong 352a8ed0d0 ci: make e2e tests resilient to CI resource contention
E2E specs spin up Docker containers in parallel and intermittently time
out waiting for UI elements under shared-runner load. Limit parallel
workers to 2 and retry flaky failures twice so the container job can
complete and publish the PR preview image.
2026-08-13 19:51:44 +02:00
ajnart 6d3f7e3e36 test: wait for Home Assistant API readiness 2026-08-13 19:51:44 +02:00
Thomas Camlong 7833cca86b feat(workshop): add social previews 2026-08-13 19:51:44 +02:00
Thomas Camlong 6edf78acd7 fix: persist Workshop PocketBase data 2026-08-13 19:51:44 +02:00
Thomas Camlong 7d43f6e5d9 fix: defer Spotlight without loading feedback 2026-08-13 19:51:44 +02:00
Thomas Camlong 1723a9e744 fix: polish settings and command menu 2026-08-13 19:51:44 +02:00
Thomas Camlong 2ba31fd37b fix(workshop): streamline import review 2026-08-13 19:51:44 +02:00
Thomas Camlong 327e5c15ae fix(workshop): stack widget install flow 2026-08-13 19:51:44 +02:00
Thomas Camlong 3efe64a062 fix(workshop): expose moderation 2026-08-13 19:51:44 +02:00
Thomas Camlong 40b726b8bc fix: simplify custom widget preview controls 2026-08-13 19:51:44 +02:00
Thomas Camlong d2d0d16e4d fix(workshop): use PocketBase name 2026-08-13 19:51:44 +02:00
Thomas Camlong 63ad879022 fix(workshop): use GitHub handles in Homarr 2026-08-13 19:51:44 +02:00
Thomas Camlong 2ae0c2ab43 fix(docs): exclude generated build from typecheck 2026-08-13 19:51:44 +02:00
Thomas Camlong 2410ba5f5c fix(workshop): simplify GitHub identities 2026-08-13 19:51:25 +02:00
Thomas Camlong 8f0039245d docs: update WUD OpenAPI schema 2026-08-13 19:51:25 +02:00
Thomas Camlong 0871e4cf0d test: validate Workshop source locale 2026-08-13 19:51:25 +02:00
Thomas Camlong db9f42520a fix: stabilize v2 CI 2026-08-13 19:51:25 +02:00
Thomas Camlong 3ebcedec26 feat: add Workshop staging deployment 2026-08-13 19:51:25 +02:00
Thomas Camlong c41a3d427e fix: allow Workshop runtime config writes 2026-08-13 19:51:25 +02:00
Thomas Camlong 71c39aa18e fix: restore Workshop runtime URLs 2026-08-13 19:51:25 +02:00
Thomas Camlong 000c51a131 test: harden integration icon checks 2026-08-13 19:51:25 +02:00
Thomas Camlong 2c44c7f2b8 test: fix custom option repair fixture 2026-08-13 19:51:25 +02:00
Thomas Camlong c2cf25b4fb fix: repair stale custom widget options 2026-08-13 19:51:25 +02:00
Thomas Camlong 03ebb8c2db test(e2e): serialize container specs 2026-08-13 19:51:25 +02:00
Thomas Camlong f0b153aa37 fix(settings): avoid nested forms 2026-08-13 19:51:25 +02:00
Thomas Camlong 965d130ce7 fix(clock): defer live time until mount 2026-08-13 19:51:25 +02:00
Thomas Camlong a87a16edb1 fix(e2e): stabilize initial board hydration 2026-08-13 19:51:25 +02:00
Thomas Camlong 1493252e28 fix(clock): suppress live timestamp hydration noise 2026-08-13 19:51:25 +02:00
Thomas Camlong 46ff20b0a0 test(v2): make board tour dismissal optional 2026-08-13 19:51:25 +02:00
Thomas Camlong 1bc84dd90e test(v2): stabilize production E2E flows 2026-08-13 19:51:24 +02:00
Thomas Camlong 14cbc03621 fix(ci): exclude e2e project from unit gate 2026-08-13 19:51:08 +02:00
Thomas Camlong 9ec35b0cda fix(ci): exclude e2e tests from unit gate 2026-08-13 19:51:08 +02:00
Thomas Camlong 2d6dd2f0d0 fix(ci): detect custom widget runtime chunks 2026-08-13 19:51:08 +02:00
Thomas Camlong 572bc71396 fix(ci): refresh v2 generated artifacts 2026-08-13 19:51:08 +02:00
Thomas Camlong d4b1739c46 fix(ci): align Workshop pnpm version 2026-08-13 19:50:46 +02:00
ajnart 76495948e2 ci(workshop): restore image publishing 2026-08-13 19:50:46 +02:00
Thomas a95052c787 fix(workshop): harden deployment and deep links (#6542) 2026-08-13 19:50:46 +02:00
Thomas 400127b45c fix: harden v2 custom widgets and Workshop (#6480) 2026-08-13 19:50:46 +02:00
Thomas f77c47b94b Delete review.md 2026-08-13 19:49:25 +02:00
Thomas 825636c4f6 fix(custom-widgets): address final v2 review feedback (#6451) 2026-08-13 19:49:25 +02:00
Thomas 3aa14e22ed fix(custom-widgets): address v2 merge review (#6446) 2026-08-13 19:49:25 +02:00
Thomas acdfcbb18e feat(custom-widgets): complete Custom JSX v2 (#6390) 2026-08-13 19:48:57 +02:00
Thomas 3a1245cbc5 fix(custom-widgets): fix v2 editor crash and definition-not-found race (#6377) 2026-08-13 19:47:16 +02:00
Thomas Camlong f0963405ca test(integrations): wait for Home Assistant restart 2026-08-13 19:47:16 +02:00
Thomas Camlong d078d761d9 ci: publish multi-platform preview images 2026-08-13 19:47:16 +02:00
Thomas Camlong 84fcf02729 test(integrations): allow recurring calendar setup 2026-08-13 19:47:06 +02:00
Thomas Camlong c308ae5c6e fix(ci): prevent autofix race 2026-08-13 19:46:51 +02:00
Thomas Camlong 93192cfedc fix(ci): autofix commits skip CI to prevent race cancellation 2026-08-13 19:46:45 +02:00
Thomas Camlong 0f9ae13560 fix(ci): remove coverage step, fix auto-fix to format all affected workspaces 2026-08-13 19:46:13 +02:00
Thomas Camlong f4a6f37415 style: fix formatting in v2 files 2026-08-13 19:46:00 +02:00
homarr-renovate[bot] 95bfaf5127 chore(deps): update dependency @gfazioli/mantine-onboarding-tour to ^4.0.7 (#6343)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-08-13 19:46:00 +02:00
Thomas 83e3b22f9a feat(custom-widgets): Full Mantine v9 + SubFetch support for custom JSX (#6304) 2026-08-13 19:45:44 +02:00
Thomas cd71404288 Delete .opencode directory 2026-08-13 19:44:18 +02:00
Thomasandcopilot-swe-agent[bot] 7a089ab177 feat: community workshop for custom widgets and CSS (#5984)
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
2026-08-13 19:44:18 +02:00
Thomas 2513e8454f fix: open user menu instantly (#6568) 2026-08-13 11:44:10 +02:00
Thomas 527e911eef fix(beszel): let Recharts own tooltip lifecycle (#6560) 2026-08-11 15:35:17 +02:00
Thomas ff2cdecc86 feat: rewrite image picker (#6556)
* feat: rewrite image picker

* style: auto-fix lint and format

* fix: address image picker review

* fix: preserve unicode icon searches

* test: stabilize custom widget login

* fix: suggest image searches only on create
2026-08-11 14:49:52 +02:00
Sven 742bde1d27 fix(synology): support vol_info in storage_v2 responses (#6554) 2026-08-11 08:59:28 +00:00
homarr-update-contributors[bot] 64febbce12 chore: update integrations list readme (#6551)
Co-authored-by: Homarr Update Contributors <210161987+homarr-update-contributors[bot]@users.noreply.github.com>
2026-08-10 11:44:39 +00:00
TimonandThomas 98bb1aecf8 feat(integrations): add What's Up Docker (WUD) integration and widget (#6523)
Co-authored-by: Thomas <thomas@ajnart.fr>
2026-08-10 08:42:23 +00:00
homarr-crowdin[bot]andThomas 06c489bd48 chore(lang): updated translations from crowdin (#6548)
Co-authored-by: Thomas <thomas@ajnart.fr>
2026-08-10 09:06:56 +02:00
360 changed files with 11834 additions and 4160 deletions

No files matched your search

+8
View File
@@ -206,6 +206,14 @@ packages/integrations/src/unraid @manuel-rw
apps/docs/docs/integrations/uptime-kuma @ajnart
packages/integrations/src/uptime-kuma @ajnart
# What's Up Docker (WUD) - introduced in https://github.com/homarr-labs/homarr/pull/6523 by @tiimonn
apps/docs/docs/integrations/whats-up-docker @tiimonn
apps/docs/docs/widgets/whats-up-docker @tiimonn
packages/api/src/router/widgets/wud.ts @tiimonn
packages/integrations/src/wud @tiimonn
packages/request-handler/src/wud.ts @tiimonn
packages/widgets/src/wud @tiimonn
# Traefik - by @Nerdy-Technician
apps/docs/docs/integrations/traefik @Nerdy-Technician
apps/docs/docs/widgets/traefik @Nerdy-Technician
+2 -2
View File
@@ -61,8 +61,8 @@ pnpm typecheck
docs/
├── getting-started/ # Installation guides, glossary, prerequisites
├── management/ # Boards, apps, users, integrations, settings, tasks, API
├── integrations/ # 55 integration guides (one folder each)
├── widgets/ # 42 widget guides (one folder each)
├── integrations/ # 56 integration guides (one folder each)
├── widgets/ # 43 widget guides (one folder each)
├── advanced/ # SSO, env vars, proxy, styling, CLI, development
└── community/ # FAQ, donate, license, translations, get-in-touch
```
+9 -3
View File
@@ -12,7 +12,7 @@ tags:
Icons in Homarr are automatically requested from multiple sources:
- [Dashboard Icons](https://dashboardicons.com/) - Our recommended source with over 1888+ curated, high-quality icons
- [Dashboard Icons](https://dashboardicons.com/) - Our recommended source with more than 1,888 curated, high-quality icons
- [selfh.st icons](https://github.com/selfhst/icons)
- [Simple icons](https://github.com/simple-icons/simple-icons)
- [Tabler icons](https://tabler-icons.io/)
@@ -21,7 +21,7 @@ Icons in Homarr are automatically requested from multiple sources:
- **Local icons**: automatically fetched from the [medias feature](/docs/management/media/)
Using these icon sources, Homarr provides a total collection of over 11,000 icons at your fingertips.
The icon picker is a fast searcher that helps you to find your desired icon simply by entering a search term.
The image picker searches this collection after a short typing delay. When a name is available, such as an app or search engine name, the picker uses it as the initial search. Results show your locally uploaded images first, followed by SVG icons and then other image formats.
### Finding Missing Icons
@@ -31,7 +31,13 @@ If you can't find the icon you're looking for in Homarr's built-in picker, we st
Icons that are high quality and printable ([SVG](https://wikipedia.org/wiki/Scalable_Vector_Graphics)) are marked with a red badge "SVG" at the top right.
You can clear the selected image with the clear button or the usual keyboard shortcut for deleting a line (Command+Backspace on macOS or Control+Backspace on other platforms). Clearing the field does not restore the previous image.
### Using external icons
If you don't find the icon you're looking for on Dashboard Icons or wish to use a different source, you can simply enter a URL that points to an icon.
Direct HTTP(S) image URLs are used as entered instead of being searched in the built-in repositories. It is important that the URL directly points to the file without any advertisements or elements around it.
Direct HTTP(S) image URLs are used as entered instead of being searched in the built-in repositories. The picker previews the image and shows whether it loaded successfully. It is important that the URL directly points to the file without any advertisements or elements around it.
### Uploading a local image
Users with media upload permission can select **Upload image** next to the field. After the upload finishes, the new local image is selected immediately and is available at the top of future searches.
@@ -15,6 +15,20 @@ Homarr has the following keyboard shortcuts:
| `Shift` + `Enter` on a supported widget | Opens its advanced view until it is closed |
| `Esc` | Closes an open advanced view or board menu |
## Board editing
When a tile is focused in edit mode:
| Shortcut | Description |
| -------------------- | --------------------------------------------------------------------------- |
| `Enter` or `Space` | Starts or stops keyboard editing |
| Arrow keys | Moves the focused tile within its current canvas, rail, or Container |
| `Shift` + Arrow keys | Resizes from the tile's lower-right corner: Left/Up shrink; Right/Down grow |
| `Esc` | Stops keyboard editing without undoing its changes |
Keyboard moves and resizes apply immediately. To move an item between a canvas, rail, or Container, drag it there or use **Move / resize item** for precise numeric placement.
Commands at a grid boundary or at the tile's minimum size are ignored and announced instead of moving or shrinking the tile.
:::tip
Here `MOD` refers to the modifier key, which is `Ctrl` on Windows and `Cmd` on macOS.
@@ -233,7 +233,8 @@ The tile snaps to the grid. Equally-sized tiles swap when one is dropped into th
Every `1 × 1` tile has a logical content size of `200 × 200` pixels.
Larger tiles use multiples of that fixed unit, with a consistent gap between cells.
Homarr scales the complete board uniformly, like zooming a page.
It stops shrinking at 75% and lets you scroll horizontally instead of making text and controls too small.
The complete canvas always fits the available width without horizontal board scrolling.
Below 100%, Homarr preserves the widget's readable typography and control density instead of shrinking each widget independently.
It does not squeeze or reflow the content of individual widgets, so a widget keeps the same design and proportions across screen sizes.
:::note
@@ -0,0 +1,48 @@
---
title: "What's Up Docker"
description: "What's Up Docker (WUD) watches your Docker containers and reports which ones have newer image versions available."
hide_title: true
---
import { IntegrationHeader } from "@site/src/components/integrations/header";
import { IntegrationCapabilites } from "@site/src/components/integrations/widgets";
import { AddingIntegration } from "@site/src/components/integrations/adding";
import { IntegrationSecrets } from "@site/src/components/integrations/secrets";
import { wudIntegration } from ".";
import { wudWidget } from "@site/docs/widgets/whats-up-docker";
<IntegrationHeader integration={wudIntegration} categories={["Health Monitoring"]} />
The What's Up Docker integration lets Homarr connect to your [WUD](https://github.com/getwud/wud) instance so you can see how many of your watched containers have image updates available directly from your dashboard.
### Widgets & Capabilities
<IntegrationCapabilites items={[{ widget: wudWidget }]} />
### Adding the integration
<AddingIntegration />
:::info URL configuration
Use the root URL of your WUD instance, for example `https://wud.example.com`. Homarr calls the `/api/app` and `/api/containers` endpoints under that URL automatically.
:::
### Secrets
WUD does not require authentication by default. If your instance is protected with [Basic Authentication](https://github.com/getwud/wud/blob/main/docs/configuration/authentications/basic/README.md), you can provide a username and password.
<IntegrationSecrets
secrets={[
{
credentials: [],
steps: [],
},
{
credentials: ["username", "password"],
steps: [
"Open your WUD instance's configuration and configure a WUD_AUTH_BASIC_* user as described in the WUD documentation",
"Enter that username and password in Homarr — Basic authentication is handled automatically",
],
},
]}
/>
@@ -0,0 +1,9 @@
import { IntegrationDefinition } from "@site/src/types";
export const wudIntegration: IntegrationDefinition = {
name: "What's Up Docker",
description:
"What's Up Docker (WUD) watches your Docker containers and reports which ones have newer image versions available.",
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/whats-up-docker.svg",
path: "../../integrations/whats-up-docker",
};
+1 -1
View File
@@ -28,7 +28,7 @@ The name must be at least one character long and can be chosen freely.
### Icon URL
This is the URL to the icon that will be displayed on the app widget.
You can paste a direct HTTP(S) URL to an image, search the included icon repositories by name, or upload a local file through the upload button. Direct image URLs are used as entered and are not searched in the icon repositories.
You can paste a direct HTTP(S) URL to an image, search the included icon repositories by name, or upload a local file through the upload button. The app name is pre-filled as the initial library search when no image is selected. Local images appear first, followed by SVG icons and other formats. Direct image URLs are previewed, used as entered, and are not searched in the icon repositories.
![Icon URL](./img/app-icon-url.png)
+1 -1
View File
@@ -134,7 +134,7 @@ For example, a 12-column layout with a two-column left rail leaves ten columns f
Enabling or widening a rail reflows canvas items into the remaining columns.
Disabling a rail moves its contents back into the next available canvas positions.
Rails fill the available window height and remain fixed while the page scrolls.
Rails fill the available window height and remain fixed while the page scrolls. They do not grow or scroll independently.
Apps, widgets, and Containers inside them still use the same fixed `200 × 200` cells and can be rearranged in edit mode, where each rail shows its name and boundary.
#### Breakpoint
@@ -25,10 +25,10 @@ Every option has a default. Supported controls include text, textarea, number, s
## ChatGPT or Claude
Enter the desired widget and optional API documentation URL, then select **Copy AI prompt**. The single self-contained prompt is below 12,000 characters, starts with your request, includes the lean contract and two polished examples, and never claims that an offline chat has MCP tools.
Open **Create or fix with any AI**, enter the desired widget and optional API documentation URL, then select the copy action beside the accordion title. The single self-contained prompt is below 12,000 characters, starts with your request, includes the lean contract and two polished examples, and never claims that an offline chat has MCP tools.
The model must return one fenced `json` block containing the complete widget, including the JSX in its `template`
field. Copy that single block and select **Paste AI response**. Homarr fills the visual builders and the JSX editor
field. Copy that single block and select **Paste**. Homarr fills the visual builders and the JSX editor
without any placeholder replacement or second paste.
Homarr shows all actionable schema or JSX errors inline. Enter credentials separately. The prompt deliberately
@@ -90,15 +90,17 @@ while the replacement exists; deleting the v2 record surfaces the v1 entry as mi
legacy deletion is intentionally unavailable during v2 stabilization. Credentials are never included in prompts,
logs, or exports.
For administrators adding widgets while editing a board, the Custom Widgets section includes **Install from
Workshop**. Non-administrators do not see the Custom Widget picker or Workshop actions. The Workshop and API source
review open above the widget picker without closing it. After import, the new definition appears in the picker. Its tile
For administrators adding widgets while editing a board, the Custom Widgets section includes **Import from
Workshop**. Non-administrators do not see the Custom Widget picker or Workshop actions. Selecting **Install widget**
opens a two-step modal flow: first review the Workshop entry and its complete source in the read-only editor, then
confirm API sources and credentials. The steps keep the same dimensions and the previous step remains available when
you go back. After import, the new definition appears in the picker. Its tile
waits for the board to be saved before server requests run and loads automatically after a successful save; no page
reload is required.
## Import and Workshop
Import review shows origins, authentication modes, network scopes, methods, actions, and permissions. Every source has a suggested URL, but private and loopback sources must be confirmed or replaced for the current Homarr installation. The same screen accepts API keys, tokens, usernames, and passwords; credentials remain optional at install time and missing values stay visible on the Custom Widgets page.
The install flow always links to the corresponding public Workshop entry using `WORKSHOP_WEB_URL`. Import confirmation shows origins, authentication modes, network scopes, methods, actions, and permissions. Every source has a suggested URL, but private and loopback sources must be confirmed or replaced for the current Homarr installation. The same screen accepts API keys, tokens, usernames, and passwords; credentials remain optional at install time and missing values stay visible on the Custom Widgets page.
For example, installing **Tautulli Activity** asks for both the user's Tautulli URL and API key. A stable public source such as PokéAPI keeps its published URL without creating unnecessary setup work.
+18 -7
View File
@@ -35,7 +35,8 @@ Back up your instance before installing untrusted content.
- Sign in with **GitHub** to upvote/downvote, submit your own creations, or report a submission.
- Each submission shows its title, description, linked GitHub author, revision, optional screenshots, comments, and full content. Widget pages also summarize API sources, actions, options, network access, and required credentials before installation.
- A new submission receives one real upvote owned by its author. Votes remain unique per user and submission.
- Report details and reporter identities are visible only to Workshop moderators. Reported submissions are highlighted in the moderation page, where each open report can be dismissed or the submission can be deleted. Reports do not automatically remove a submission.
- Open report counts are public on submission cards so visitors can make an informed installation decision. The submission author and Workshop moderators can read each report category and explanation; reporter identities remain moderator-only.
- Signed-in moderators see a **Moderation** button in the Workshop header. The moderation queue is sorted by open report count and lets moderators inspect every report, dismiss it, or delete the submission. Reports do not automatically remove a submission.
- The manual **Outdated** badge warns visitors that a submission may need an update.
## Comments
@@ -52,6 +53,12 @@ Comments are publicly readable and appear in chronological conversation bubbles
3. Review the widget JSON or CSS in the syntax-aware editor, then add a title, description, optional screenshots, and changelog.
4. Submit the entry.
Custom widget JSON is normalized to readable multi-line formatting when it is published, and the Workshop detail page
also formats older compact submissions before displaying them.
Community members can report submissions as outdated or no longer working, malicious, spam, copyright-infringing,
inappropriate, or another concern. An outdated report does not automatically mark or remove the submission.
Submissions are published immediately. Reported entries are reviewed and may be removed.
Authors can edit a submission later to publish a new revision. The edit form can keep or remove existing screenshots and add new PNG, JPG, or WebP images without changing the Workshop URL.
@@ -74,10 +81,9 @@ that primarily promotes unrelated products or services.
You can install workshop entries without leaving Homarr:
- **Custom widgets (Homarr administrators only)** — go to **Manage → Custom Widgets**, or use **Install from Workshop** in the
board widget picker. Pick an entry, review its capabilities, and enter any required local secrets before import.
- **Custom CSS from Workshop (Homarr administrators only)** — open a board's **Settings → Custom CSS**, click **Install from Workshop**, and
pick a theme to load it into the editor before saving.
- **Custom widgets (Homarr administrators only)** — go to **Manage → Custom Widgets**, or use **Import from Workshop** in the
board widget picker. Choose **Install widget**, review the complete source and capabilities, then confirm API sources and any local secrets in the second modal step.
- **Custom CSS from Workshop (Homarr administrators only)** — open a board's **Settings → Custom CSS**, click **Import from Workshop**, choose a theme, review its full CSS in the second same-size modal step, and click **Import CSS** once. The board setting is still saved separately.
Alternatively, open a submission in the Workshop and use **Download widget JSON** or **Copy CSS**. The submission page shows the matching manual installation steps before the source code.
@@ -87,6 +93,8 @@ Custom Widget imports detect required bearer, basic, header, or query credential
Workshop is powered by [PocketBase](https://pocketbase.io). The production image compiles the current Docusaurus site and serves it together with the API from one origin. The deployment lives in `apps/workshop/` with a Dockerfile, Compose configuration, version-controlled migrations, hooks, a smoke test, and ACT-local workflow instructions.
Submission links include Open Graph and X/Twitter metadata for the submission title, description, and type. The first uploaded screenshot is used as the social preview image; submissions without screenshots use the Homarr logo.
Run `pnpm docker:workshop` for PocketBase on port 8090, then run
`WORKSHOP_API_URL=http://127.0.0.1:8090 pnpm dev:docs` for Docusaurus on port 3003. `pnpm docker:docs` builds the
combined production layout. Normal start and stop commands retain data; `down --volumes` is an intentional,
@@ -95,8 +103,11 @@ irreversible development reset.
GitHub needs only the central callback `https://<workshop-host>/api/oauth2-redirect`. Keep PocketBase origins unrestricted
unless every self-hosted Homarr caller is explicitly listed. Configure both `GITHUB_CLIENT_ID` and
`GITHUB_CLIENT_SECRET`; supplying only one is a startup error. Set `WORKSHOP_PUBLIC_ORIGIN` in production so generated
links and email use the public host. Workshop uses the OAuth provider username as the public display name instead of
the profile's full name. Configure SMTP for comment, report, and removal notices.
links and email use the public host. Workshop stores only the OAuth provider username in `users.name`, and
users cannot rename it. Map **OAuth2 username** to `name`; leave OAuth2 id, full name, and avatar mappings
empty. Profile and avatar URLs are derived from `https://github.com/<username>` and
`https://github.com/<username>.png`, so PocketBase does not store duplicate profile metadata or avatar files. Configure
SMTP for comment, report, and removal notices.
The public URL contract is:
@@ -17,6 +17,8 @@ import { anchorIntegration } from "@site/docs/integrations/anchor";
The Anchor Note widget shows a selected note from your Anchor integration. If your note permissions allow it, you can also edit the note title and content directly in Homarr.
Advanced view always shows the note title and update time alongside the complete formatted document, permission, creation date, and pinned or archived state. Compact title and update-time switches do not hide that information in advanced view.
### Screenshots
import anchorNoteWidgetScreenshot from "./img/widget.png";
@@ -14,7 +14,7 @@ import { archiveTeamWarriorWidget } from ".";
<WidgetHeader widget={archiveTeamWarriorWidget} categories={["Archiving", "Monitoring"]} />
Use this widget to see what your ArchiveTeam Warrior is preserving without leaving Homarr. The compact view keeps the current project, runner state, job counts, bandwidth, and broadcast message visible. Advanced view adds the complete active-job list and exact update time.
Use this widget to see what your ArchiveTeam Warrior is preserving without leaving Homarr. The compact view keeps the current project, runner state, job counts, bandwidth, and optional broadcast message visible. Advanced view adds the complete active-job list, job start times, project and bandwidth-session identifiers, session transfer totals, exact update time, and the full broadcast message whenever one is available. A failed refresh keeps the last snapshot visible with a stale-data warning.
### Supported integration
+3 -1
View File
@@ -36,7 +36,9 @@ import widgetOverview from "./img/widget-overview.png";
]}
/>
<WidgetInteractionGuide advancedView={false} />
Advanced view shows every missing-subtitle and health summary regardless of compact visibility options, together with the already-fetched Sonarr and Radarr SignalR states and announcement count.
<WidgetInteractionGuide advancedView />
### Adding the widget
@@ -34,7 +34,7 @@ This widget displays your Beszel alert configurations separated by status (trigg
]}
/>
<WidgetInteractionGuide />
<WidgetInteractionGuide advancedView={false} />
### Adding the widget
@@ -4,6 +4,8 @@ description: "Card grid view of all Beszel-monitored systems with real-time metr
hide_title: true
---
import { WidgetInteractionGuide } from "@site/src/components/widgets/interactions";
import { WidgetHeader } from "@site/src/components/widgets/header";
import { AddingWidget } from "@site/src/components/widgets/adding";
import { WidgetConfig } from "@site/src/components/widgets/configuration";
@@ -34,6 +36,8 @@ Select a system card to open the complete statistics view, including system, fil
]}
/>
<WidgetInteractionGuide advancedView={false} />
### Adding the widget
<AddingWidget />
@@ -4,6 +4,8 @@ description: "Table view of all Beszel-monitored systems with sortable columns a
hide_title: true
---
import { WidgetInteractionGuide } from "@site/src/components/widgets/interactions";
import { WidgetHeader } from "@site/src/components/widgets/header";
import { AddingWidget } from "@site/src/components/widgets/adding";
import { WidgetConfig } from "@site/src/components/widgets/configuration";
@@ -35,6 +37,8 @@ Select a system row to open the complete statistics view.
]}
/>
<WidgetInteractionGuide advancedView={false} />
### Adding the widget
<AddingWidget />
@@ -16,6 +16,7 @@ import { bookmarksWidget } from ".";
The bookmarks widget allows you to display multiple app links in a single widget.
You can add links to your favorite applications, websites, or any other resources you want quick access to.
Its normal view adapts each configured layout to the available width and height: lower-priority hostnames are hidden before names or icons, and larger collections scroll instead of crushing cards.
Advanced view shows every bookmark as a roomy card with its full title, hostname, icon, and complete description regardless of the compact layout and visibility choices.
### Screenshots
+1 -1
View File
@@ -72,7 +72,7 @@ import withoutIntegrations from "./img/without-integrations.png";
### Interactions
- Hover over a marked date to preview its events in a popover.
- The advanced view replaces the month grid with a grouped event list. Use its previous and next controls to change months.
- The advanced view replaces the month grid with a grouped event list, keeps every event attributed to its source integration, and expands titles, locations, and descriptions. Use its previous and next controls to change months.
- If one selected integration is unavailable, its warning indicator remains visible while events from healthy integrations continue to load.
<WidgetInteractionGuide />
+1 -1
View File
@@ -27,7 +27,7 @@ import tokyoClock from "./img/tokyo-12-hour.png";
<img src={tokyoClock} alt="clock format-12-hours timezone-tokyo" width={256} height={128} />
</div>
<WidgetInteractionGuide />
<WidgetInteractionGuide advancedView={false} />
### Adding the widget
+5 -1
View File
@@ -27,7 +27,11 @@ The Coolify widget displays a compact overview of your Coolify instance. It can
]}
/>
<WidgetInteractionGuide advancedView={false} />
Advanced view opens the Servers, Applications, and Services sections regardless of their compact visibility switches. It identifies each Coolify integration, shows server `is_reachable` and `is_usable` values, and lists applications nested in services when the API provides them. Healthy integrations remain visible if another source fails, with the failed source identified by a warning.
IP addresses remain masked in advanced view until you explicitly use the eye control. Public resource, Coolify dashboard, and log links retain the widget's safe new-tab handling.
<WidgetInteractionGuide advancedView />
### Adding the widget
@@ -39,6 +39,9 @@ Action buttons can only be executed by administrators.
Each named request declares its minimum board permission. This permission does not depend on the HTTP method.
Anonymous visitors can load queries from a Custom API widget on a public board. Definitions placed only on private
boards remain unavailable to them.
Queries and actions can use GET, POST, PUT, or PATCH. DELETE is valid only for actions so it can never run
automatically while a board loads. The `kind` value controls the remaining runtime behavior.
@@ -20,6 +20,8 @@ import { technitiumDnsIntegration } from "@site/docs/integrations/technitium-dns
<WidgetHeader widget={dnsHoleControlsWidget} categories={["DNS Hole"]} />
When several DNS integrations are selected, Homarr keeps healthy controls available if one source fails and shows a warning identifying the unavailable integration.
This widget lets permitted users enable, disable, or temporarily pause one or more DNS services. It shares the summary query and cache with DNS Hole Summary. Every selected source stays available in the normal scrollable view, while cards and controls adapt to the widget&apos;s width and height.
### Screenshots
@@ -17,6 +17,8 @@ import { dnsHoleSummaryWidget } from ".";
<WidgetHeader widget={dnsHoleSummaryWidget} categories={["DNS Hole"]} />
When several DNS integrations are selected, Homarr keeps healthy sources visible if one source fails and shows a warning identifying the unavailable integration.
This widget combines statistics from every selected DNS service, including total queries, blocked queries, and blocklist size. Its data is cached under the same DNS query family as DNS Hole Controls, so placing both widgets on a board does not create a second integration request. When the widget has enough room, it adds a scrollable status row naming each selected source.
### Screenshots
+6 -6
View File
@@ -43,12 +43,12 @@ import contextMenuImg from "./img/context-menu.png";
- **Responsive table** — columns automatically adapt to widget width, hiding less important columns at smaller sizes
- **Sortable columns** — click any column header to sort ascending/descending
- **Column customization** — choose which columns to display, then hover a header to reveal its grab handle for reordering or drag its edge to resize it; layouts save automatically per widget
- **Right-click context menu** — pause, resume, delete downloads, or copy download names directly from the context menu
- **Right-click context menu** — copy download names, or pause, resume, and delete when your account has Interact access and the client supports those actions
- **Row expansion** — click any row to see full details inline without opening a separate modal
- **Hover tooltips** — hover a download name to see size, speeds, ETA, ratio, and category
- **Hover tooltips** — hover a download name to see size, speeds, ETA, ratio, and category; open tooltips close when switching between compact and advanced mode
- **Global stats bar** — toggle a summary bar showing queue progress, total size, speeds, ratio, and per-state counts
- **Client filtering** — filter downloads by integration or status using the footer filter panel
- **Global controls** — pause/resume all downloads across all clients from the footer bar
- **Global controls** — pause/resume downloads from supported clients when your account has Interact access; read-only clients such as slskd do not expose these controls
- **Status indicators** — color-coded badges show download state at a glance
- **Speed overview** — total download and upload speeds displayed in the footer
@@ -89,13 +89,13 @@ import contextMenuImg from "./img/context-menu.png";
### Interactions
- **Right-click** any download to open a context menu with pause/resume, delete, and copy actions
- **Right-click** any download to copy its name; pause/resume and delete require Interact access and client support
- **Click** any row to expand it and see full download details inline
- **Hover** a download name for a quick stats tooltip
- **Stats toggle** — show or hide the global stats bar from the footer
- **Pause / Resume** all downloads using the footer controls
- **Pause / Resume** downloads from supported clients using the footer controls when you have Interact access; slskd remains read-only
- **Filter** by integration or status using the filter button in the footer
- **Sort** by clicking column headers (with persistent sort direction)
- **Sort** the current view by clicking column headers to switch direction
<WidgetInteractionGuide />
+1 -1
View File
@@ -67,7 +67,7 @@ export const downloadsWidget: WidgetDefinition = {
{
name: "Hide completed torrent under this threshold (in kiB/s)",
description:
"This will hide completed torrent entries that have a download speed below the specified threshold.",
"This will hide completed torrent entries that have an upload speed below the specified threshold.",
values: "Any number above 0, 0 to disable",
defaultValue: "0",
},
@@ -23,6 +23,8 @@ import { glancesIntegration } from "@site/docs/integrations/glances";
This widget displays information about the health and status of your system(s), including CPU, memory, disk usage, and more.
Advanced view is a complete health workspace: it shows every available CPU, temperature, memory, GPU, filesystem, update, reboot, and system-information section even when compact visibility switches hide some of them on the board. Proxmox clusters likewise show uptime, CPU, memory, and every node, VM, container, and storage section. Storage-volume filters, units, and permissions remain in effect.
### Screenshots
import systemView from "./img/system-view.png";
@@ -33,7 +33,7 @@ import screen from "./img/screen.png";
<WidgetInteractionGuide />
Hover or focus the widget to pause its slideshow; touch devices keep the control available. A paused slideshow keeps its resume control visible. The advanced view groups previous, pause or resume, and next together and adds a thumbnail strip for quick navigation.
Hover or focus the widget to pause its slideshow; touch devices keep the control available. A paused slideshow keeps its resume control visible. The advanced view displays the complete photo without cropping, groups previous, pause or resume, and next together, and includes every loaded photo in its thumbnail strip. If an Immich refresh fails, the last loaded album stays visible with a stale-data warning.
### Adding the widget
@@ -18,6 +18,8 @@ import { sonarrIntegration } from "@site/docs/integrations/sonarr";
Monitor which movies and episodes are missing from your library, and track what is currently being downloaded. The widget pulls data from Radarr and Sonarr and groups results by type in two tabs.
Advanced view always shows both Missing and Queued panels side by side when space allows, requests at least 50 items per source, exposes queue status and time remaining, and keeps partial-integration warnings visible. Compact tab visibility and page-size choices continue to shape the board tile.
### Screenshots
import widgetMissing from "./img/missing.png";
@@ -19,6 +19,10 @@ import { plexIntegration } from "@site/docs/integrations/plex";
Provides a list of newly added media or upcoming releases from your media server.
Releases retain the name and icon of the integration that supplied them. If one integration is unavailable, releases from healthy integrations remain visible and a warning identifies the failed source without exposing connection details. The widget reports an error only when every selected integration fails.
Advanced view displays every release in a roomy poster layout with its complete available metadata, description, tags, and visible source name, regardless of the compact layout and visibility options.
### Screenshots
import backdrop from "./img/backdrop.png";
@@ -24,7 +24,7 @@ import { plexIntegration } from "@site/docs/integrations/plex";
The media server widget shows active sessions across your media servers.
It shows playback progress, pause state, time remaining, and whether each session is direct play, direct stream, or transcoding audio or video. Location and bitrate can be toggled independently.
Select a session to inspect its media metadata. Advanced mode adds search, sorting, and a summary of playing and transcoding sessions while keeping separate integration instances identifiable.
Select a session to inspect its media metadata in an anchored popover. Advanced mode adds search, sorting, location, bitrate, and a summary of playing and transcoding sessions while keeping separate integration instances identifiable. Compact visibility switches do not hide location or bitrate from the complete advanced session view.
If one selected server is unavailable, a warning indicator identifies the degraded result while sessions from healthy servers remain usable.
Sessions without user metadata remain visible and are labeled as an unknown user.
@@ -20,6 +20,7 @@ import { tdarrIntegration } from "@site/docs/integrations/tdarr";
The media transcoding widget provides you with an overview of your media transcoding status.
It shows you the current queue, worker status, and statistics about your transcoding jobs.
Advanced view presents all three panels together. If refreshes fail after data has loaded, Homarr keeps the last useful result visible and marks it as stale.
### Screenshots
@@ -20,6 +20,8 @@ import { networkControllerStatusWidget } from ".";
Shows connected users and connected guest devices on wired or wireless networks.
Compact view aggregates the configured controllers into the selected Wi-Fi or wired total. Advanced view shows both totals separately for every controller, preserving each integration's name, type, and exact update time while keeping partial-source and stale-data warnings visible.
### Screenshots
import wifiCard from "./img/wifi.png";
@@ -36,7 +36,9 @@ import summaryCard from "./img/summary.png";
]}
/>
<WidgetInteractionGuide advancedView={false} />
In advanced view, each controller gets its own information matrix showing source, last update, WAN and web status, latency, ping, web uptime, separate Wi-Fi and LAN user and guest counts, and connected VPN users. The compact board view keeps its progressive aggregate layout.
<WidgetInteractionGuide />
### Adding the widget
@@ -34,7 +34,9 @@ import notificationsScreenshot from "./img/notifications.png";
items={[{ integration: ntfyIntegration }, { integration: nextcloudIntegration }, { integration: gotifyIntegration }]}
/>
<WidgetInteractionGuide advancedView={false} />
Advanced view always shows available notification logos and sources and removes the compact body line limit. External notification links retain the same safe new-tab behavior.
<WidgetInteractionGuide advancedView />
### Adding the widget
+5 -1
View File
@@ -72,6 +72,10 @@ The widget adapts its layout based on the available space:
When the widget has enough space, a "last updated" timestamp appears at the bottom showing how recently PatchMon refreshed its data. Narrow layouts use a shortened timestamp such as `5m ago`; wider short layouts can show the full translated text.
### Advanced view
Advanced view is a complete PatchMon posture view. It always shows the compliance hero, all eight host and package statistics, the full OS distribution with versions, and the last-updated time. Compact visibility switches and OS limits still shape the board tile, but do not hide already-fetched security information in advanced view. Threshold and chart-style choices remain in effect.
### Supported Integrations
<WidgetIntegrations
@@ -83,7 +87,7 @@ When the widget has enough space, a "last updated" timestamp appears at the bott
]}
/>
<WidgetInteractionGuide advancedView={false} />
<WidgetInteractionGuide />
### Adding the widget
+3 -1
View File
@@ -35,7 +35,9 @@ GitHub, Docker Hub, GitLab, NPM, Codeberg, GitHub Container Registry, Quay, and
For GitHub Container Registry, use `owner/package` identifiers such as `homarr-labs/homarr`.
Full image references such as `ghcr.io/homarr-labs/homarr:latest` are also accepted.
<WidgetInteractionGuide advancedView={false} />
Advanced view shows every configured repository even when compact view is limited to highlighted or top releases. Repository names and details remain visible, and selecting a repository still expands its description and links.
<WidgetInteractionGuide />
### Adding the widget
+3 -1
View File
@@ -21,7 +21,9 @@ import screenshot from "./img/screenshot.png";
<img src={screenshot} alt="rss-feed noted-lol" width={128 * 4} />
<WidgetInteractionGuide advancedView={false} />
Advanced view keeps the configured feeds and post limit unchanged while showing each fetched post's full description, image, source, relative time, and exact publication timestamp. Right-to-left text and safe external links continue to follow the widget configuration.
<WidgetInteractionGuide advancedView />
### Adding the widget
@@ -22,6 +22,8 @@ The entity state enables you to display the current state of an entity.
This works with most entities and enables you display almost any data.
It is recommended that you transform data on the integration as much as possible since Homarr's capabilities in transformation are very limited.
Advanced view shows the safe Home Assistant metadata owned by Homarr: the friendly name, entity ID, unit, device class, icon, last-changed time, and last-updated time. Arbitrary attributes remain intentionally excluded.
### Screenshots
import sun from "./img/sun.png";
@@ -20,6 +20,8 @@ import { homeAssistantIntegration } from "@site/docs/integrations/home-assistant
The execute automation widget allows you to trigger an automation with one click.
Advanced view shows the automation ID, interaction permission, last successful run in the current browser session, and the latest action error together. Session-only history is not presented as Home Assistant history.
### Screenshots
import empty from "./img/empty.png";
@@ -18,6 +18,10 @@ import { speedtestTrackerIntegration } from "@site/docs/integrations/speedtest-t
This widget displays internet speed test results from your [Speedtest Tracker](https://docs.speedtest-tracker.dev/) instance,
including the latest result, historical averages, and a live chart of the last 12 hours.
Advanced view renders a source-labeled panel for every healthy integration. Each panel exposes that source's latest test, statistics, speed and ping charts, plus 12-hour, 24-hour, and truthful **Last 30** result ranges regardless of compact visibility switches. Compact mode keeps the responsive aggregate overview and names its healthy sources when multiple integrations are selected.
When multiple Speedtest Tracker integrations are selected, results from healthy sources remain visible if another source fails. A warning indicator identifies each unavailable integration without exposing private request details. If every selected integration fails, the widget reports an error; if cached data remains visible after a later total failure, a stale-data warning is shown.
### Screenshots
import screenshot from "./img/screenshot.png";
+3 -1
View File
@@ -16,6 +16,8 @@ import { stockPriceWidget } from ".";
Using the Stocks widget, you can show the current stock prices of your favorite companies on your dashboard.
The widget prioritizes the symbol, current price, and trend. Company name, change, time range, and a taller chart appear progressively when both widget dimensions can support them.
In advanced view, the widget always shows the returned symbol and company name, configured range and interval, current and previous close, absolute and percentage change, range minimum and maximum, and the full price-history chart. These values use the same configured Yahoo Finance query as the compact widget; advanced view does not change the symbol, range, or interval.
### Screenshots
import apple from "./img/apple.png";
@@ -34,7 +36,7 @@ import nvidia from "./img/nvidia.png";
<img src={nvidia} alt="stock-price nvidia" width={128 * 2 + 2} height={128 * 2 + 4} />
</div>
<WidgetInteractionGuide advancedView={false} />
<WidgetInteractionGuide />
### Adding the widget
@@ -44,7 +44,9 @@ This widget displays filesystem and SMART disk usage for supported server and NA
]}
/>
<WidgetInteractionGuide advancedView={false} />
Advanced view keeps your configured volume filters while showing each integration name, usage percentage, used and total space, free-space percentage, SMART status, available temperature, and the usage background bar regardless of compact display options.
<WidgetInteractionGuide />
### Adding the widget
@@ -17,6 +17,8 @@ import { tracearrIntegration } from "@site/docs/integrations/tracearr";
The Tracearr widget provides a real-time monitoring dashboard for your media server activity. It displays active streams, user statistics, recent watching history, and policy violations.
When multiple Tracearr integrations are selected, every stream, violation, and activity row keeps its source identity and an integration-scoped key. Data from healthy sources remains visible if another source fails, and a warning identifies each unavailable integration without exposing private request details. If every selected integration fails, the widget reports an error; if cached data remains visible after a later total failure, a stale-data warning is shown.
### Sections
| Section | Description |
+4
View File
@@ -17,6 +17,10 @@ import { traefikIntegration } from "@site/docs/integrations/traefik";
This widget displays a compact overview of your Traefik instance, including total routers, entry points, protocol-specific router/service/middleware counts, and warning or error totals when Traefik reports them.
Advanced view shows every protocol and entry point, identifies every healthy Traefik source and version, and labels each resource or failed endpoint with its owning integration. Compact mode keeps the responsive aggregate overview and names each healthy source when multiple integrations are selected.
When multiple Traefik integrations are selected, data from healthy sources remains visible if another source fails. A warning indicator identifies each unavailable integration without exposing private request details. If every selected integration fails, the widget reports an error; if cached data remains visible after a later total failure, a stale-data warning is shown.
### Supported Integrations
<WidgetIntegrations
+4
View File
@@ -19,6 +19,10 @@ This widget displays the status of the UPS devices reported by a Network UPS Too
status (online, on battery, charging or low battery), battery charge and estimated runtime, output load, and input and
output voltage.
Every device keeps an integration-scoped identity. Advanced view always labels each device with its owning integration, while compact layouts add the source label only when multiple healthy integrations are present.
When multiple UPS integrations are selected, devices from healthy sources remain visible if another source fails. A warning indicator identifies each unavailable integration without exposing private request details. If every selected integration fails, the widget reports an error; if cached data remains visible after a later total failure, a stale-data warning is shown.
### Layouts
import screenshotFull from "./img/screenshot.png";
@@ -17,6 +17,10 @@ import { uptimeKumaIntegration } from "@site/docs/integrations/uptime-kuma";
This widget displays uptime statistics from your Uptime Kuma status page, including a visual uptime ring, average uptime percentage, and per-status monitor counts.
Advanced view always shows the uptime ring, all status totals, and the complete monitor list with source identity, regardless of compact visibility switches.
When multiple Uptime Kuma integrations are selected, monitors from healthy sources remain visible if another source fails. A warning indicator identifies each unavailable integration without exposing private request details. If every selected integration fails, the widget reports an error; if cached data remains visible after a later total failure, a stale-data warning is shown.
### Supported Integrations
<WidgetIntegrations
@@ -0,0 +1,40 @@
---
title: "What's Up Docker"
description: "Displays how many watched containers have image updates available."
hide_title: true
---
import { WidgetHeader } from "@site/src/components/widgets/header";
import { AddingWidget } from "@site/src/components/widgets/adding";
import { WidgetConfig } from "@site/src/components/widgets/configuration";
import { WidgetIntegrations } from "@site/src/components/widgets/integrations";
import { WidgetInteractionGuide } from "@site/src/components/widgets/interactions";
import { wudWidget } from ".";
import { wudIntegration } from "@site/docs/integrations/whats-up-docker";
<WidgetHeader widget={wudWidget} categories={["Health Monitoring"]} />
This widget shows two stats from your WUD instance: the total number of monitored containers, and how many of them currently have an image update available. Optionally, it can also list each outdated container with its current and new version, linking to the release notes when WUD provides one.
### Supported Integrations
<WidgetIntegrations
items={[
{
integration: wudIntegration,
note: "Displays monitored container and available-update counts",
},
]}
/>
<WidgetInteractionGuide />
In advanced view, the widget always shows its title, update ring, monitored-container summary, and the full list of available container updates. The configured horizontal or vertical layout still applies. Visibility options only affect the compact board view. A failed refresh keeps the last WUD snapshot visible with a stale-data warning.
### Adding the widget
<AddingWidget />
### Configuration
<WidgetConfig configuration={wudWidget.configuration} />
@@ -0,0 +1,38 @@
import { WidgetDefinition } from "@site/src/types";
import { IconBrandDocker } from "@tabler/icons-react";
export const wudWidget: WidgetDefinition = {
icon: IconBrandDocker,
name: "What's Up Docker",
description: "Displays how many watched containers have image updates available.",
path: "../../widgets/whats-up-docker",
configuration: {
items: [
{
name: "Show title",
description: 'Displays the icon and "What\'s Up Docker" label above the stats',
values: { type: "boolean" },
defaultValue: "yes",
},
{
name: "Show update ring",
description: "Displays a ring showing the percentage of monitored containers with a pending update",
values: { type: "boolean" },
defaultValue: "yes",
},
{
name: "Layout",
description: "Arrange the two stats side by side (Horizontal) or stacked (Vertical)",
values: { type: "select", options: ["Horizontal", "Vertical"] },
defaultValue: "Horizontal",
},
{
name: "Show container update list",
description:
"Lists each monitored container that has an image update available, linking to its release notes when known",
values: { type: "boolean" },
defaultValue: "no",
},
],
},
};
+1
View File
@@ -402,6 +402,7 @@ html[data-workshop-detail-loading] #workshop-detail-route-fallback {
path: "/workshop/:id",
component: "@site/src/components/workshop/WorkshopDetailRoutePage",
exact: true,
priority: -1,
});
},
};
@@ -3,6 +3,7 @@ import { IconCheck, IconEdit, IconMessageCircle, IconRefresh, IconSend, IconTras
import type { WorkshopComment } from "@site/src/lib/pocketbase";
import { getWorkshopBackend } from "@site/src/lib/pocketbase";
import { githubAvatarUrl, githubProfileUrl } from "@homarr/workshop/schema";
import type { WorkshopUser } from "@homarr/workshop/schema";
import { errorMessage } from "@site/src/lib/utils";
@@ -158,13 +159,10 @@ export const CommentsSection = ({ submissionId, backend, currentUser, onRequireA
const canDelete = ownComment || currentUser?.isAdmin === true;
const isEditing = editingId === comment.id;
const edited = comment.updated !== comment.created;
const authorName = ownComment ? (currentUser?.displayName ?? comment.authorName) : comment.authorName;
const avatarUrl = ownComment
? (currentUser?.avatarUrl ?? comment.authorAvatarUrl)
: comment.authorAvatarUrl;
const profileUrl = ownComment
? (currentUser?.githubProfileUrl ?? comment.authorGithubProfileUrl)
: comment.authorGithubProfileUrl;
const authorHandle = ownComment ? (currentUser?.name ?? comment.authorName) : comment.authorName;
const authorName = authorHandle || "Community member";
const avatarUrl = githubAvatarUrl(authorHandle);
const profileUrl = githubProfileUrl(authorHandle);
return (
<article
@@ -292,10 +290,8 @@ export const CommentsSection = ({ submissionId, backend, currentUser, onRequireA
<div className="rounded-xl border border-border bg-muted/30 p-3">
<div className="flex items-start gap-2.5">
<Avatar className="mt-0.5 size-9 bg-background ring-1 ring-border">
{currentUser?.avatarUrl && <AvatarImage src={currentUser.avatarUrl} alt="" />}
<AvatarFallback className="bg-background">
{avatarFallback(currentUser?.displayName ?? "Guest")}
</AvatarFallback>
{currentUser?.name && <AvatarImage src={githubAvatarUrl(currentUser.name)} alt="" />}
<AvatarFallback className="bg-background">{avatarFallback(currentUser?.name ?? "Guest")}</AvatarFallback>
</Avatar>
<Textarea
className="min-h-16 flex-1 resize-none bg-background"
@@ -1,5 +1,6 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
import BrowserOnly from "@docusaurus/BrowserOnly";
import Head from "@docusaurus/Head";
import Link from "@docusaurus/Link";
import { useLocation } from "@docusaurus/router";
import useDocusaurusContext from "@docusaurus/useDocusaurusContext";
@@ -33,6 +34,9 @@ import { getRuntimeWorkshopApiUrl } from "@site/src/lib/runtime-config";
import type { SubmissionType } from "@site/src/lib/workshop-schema";
import type { HomarrCustomWidgetV2 } from "@homarr/custom-widgets/core";
import {
githubAvatarUrl,
githubProfileUrl,
formatWorkshopContent,
MAX_WORKSHOP_SCREENSHOTS,
MAX_WORKSHOP_SCREENSHOT_BYTES,
WORKSHOP_SCREENSHOT_MIME_TYPES,
@@ -66,6 +70,7 @@ import { WorkshopErrorBoundary } from "./WorkshopErrorBoundary";
import { downloadSubmissionJson, voteAndReconcile } from "./workshop-utils";
const typeLabels: Record<SubmissionType, string> = { customCss: "CSS", customWidget: "Widget" };
const typeSocialLabels: Record<SubmissionType, string> = { customCss: "Custom CSS", customWidget: "Custom widget" };
const typeDotColors: Record<SubmissionType, string> = { customCss: "bg-blue-500", customWidget: "bg-yellow-500" };
const contentLanguages: Record<SubmissionType, string> = { customCss: "css", customWidget: "json" };
const copyState = [
@@ -449,7 +454,7 @@ const MarketplaceDetail = ({ workshopUrl }: { workshopUrl: string }) => {
try {
await backend.report(
submission.id,
reportCategory as "malicious" | "spam" | "copyright" | "inappropriate" | "other",
reportCategory as "outdated" | "malicious" | "spam" | "copyright" | "inappropriate" | "other",
reportExplanation,
);
setReportOpen(false);
@@ -524,17 +529,40 @@ const MarketplaceDetail = ({ workshopUrl }: { workshopUrl: string }) => {
const canManage = user?.id === submission.author || user?.isAdmin === true;
const canEdit = user?.id === submission.author;
const signedInAuthor = user?.id === submission.author ? user : null;
const authorName = signedInAuthor?.displayName || submission.authorName;
const authorAvatarUrl = signedInAuthor?.avatarUrl || submission.authorAvatarUrl;
const authorGithubUsername = signedInAuthor?.githubUsername || submission.authorGithubUsername;
const authorGithubProfileUrl = signedInAuthor?.githubProfileUrl || submission.authorGithubProfileUrl;
const authorHandle = signedInAuthor?.name || submission.authorName;
const authorName = authorHandle || "Community member";
const authorAvatarUrl = githubAvatarUrl(authorHandle);
const authorGithubProfileUrl = githubProfileUrl(authorHandle);
const sources = widgetDefinition ? Object.entries(widgetDefinition.sources) : [];
const requests = widgetDefinition ? Object.entries(widgetDefinition.requests) : [];
const options = widgetDefinition ? Object.entries(widgetDefinition.options) : [];
const protectedSources = sources.filter(([, source]) => sourceNeedsSecret(source.auth));
const displayedContent = formatWorkshopContent(submission.type, submission.content);
const socialTitle = `${submission.title} · Homarr Workshop`;
const socialDescription = `${typeSocialLabels[submission.type]} for Homarr. ${submission.description}`.trim();
const socialUrl = `${window.location.origin}${window.location.pathname}`;
const socialImage = screenshotUrls[0] ?? `${window.location.origin}/img/logo.png`;
return (
<div className="mx-auto max-w-[90rem] px-4 pb-20 pt-8 sm:px-6 lg:px-8">
<Head>
<title>{socialTitle}</title>
<meta name="description" content={socialDescription} />
<link rel="canonical" href={socialUrl} />
<meta property="og:type" content="article" />
<meta property="og:site_name" content="Homarr Workshop" />
<meta property="og:title" content={socialTitle} />
<meta property="og:description" content={socialDescription} />
<meta property="og:url" content={socialUrl} />
<meta property="og:image" content={socialImage} />
<meta property="og:image:alt" content={`${submission.title} preview`} />
<meta property="article:section" content={typeSocialLabels[submission.type]} />
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content={socialTitle} />
<meta name="twitter:description" content={socialDescription} />
<meta name="twitter:image" content={socialImage} />
<meta name="twitter:image:alt" content={`${submission.title} preview`} />
</Head>
<Toaster position="bottom-right" richColors />
<Link
to="/workshop"
@@ -593,10 +621,7 @@ const MarketplaceDetail = ({ workshopUrl }: { workshopUrl: string }) => {
{authorName}
{authorGithubProfileUrl && <IconBrandGithub size={14} />}
</a>
<p className="text-xs text-muted-foreground">
{authorGithubUsername ? `@${authorGithubUsername} · ` : ""}
Published {formatRelativeTime(submission.created)}
</p>
<p className="text-xs text-muted-foreground">Published {formatRelativeTime(submission.created)}</p>
</div>
</div>
</div>
@@ -698,12 +723,12 @@ const MarketplaceDetail = ({ workshopUrl }: { workshopUrl: string }) => {
<CustomWidgetCodeExample
id={`workshop-${submission.id}`}
label="widget.json"
code={submission.content}
code={displayedContent}
language="json"
height="min(58vh, 640px)"
/>
) : (
<CodeBlock content={submission.content} language={contentLanguages[submission.type]} />
<CodeBlock content={displayedContent} language={contentLanguages[submission.type]} />
)}
</section>
@@ -882,6 +907,7 @@ const MarketplaceDetail = ({ workshopUrl }: { workshopUrl: string }) => {
<SelectValue>{(value) => String(value).replace(/^./u, (letter) => letter.toUpperCase())}</SelectValue>
</SelectTrigger>
<SelectContent align="start">
<SelectItem value="outdated">Outdated or no longer working</SelectItem>
<SelectItem value="malicious">Malicious</SelectItem>
<SelectItem value="spam">Spam</SelectItem>
<SelectItem value="copyright">Copyright</SelectItem>
@@ -1,6 +1,7 @@
import React, { useEffect, useRef, useState } from "react";
import { IconBrandGithub, IconChevronDown, IconExternalLink, IconLogout } from "@tabler/icons-react";
import { githubAvatarUrl, githubProfileUrl } from "@homarr/workshop/schema";
import type { WorkshopUser } from "@homarr/workshop/schema";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
@@ -9,10 +10,6 @@ import { cn } from "@/lib/utils";
const avatarFallback = (name: string) => name.trim().slice(0, 1).toUpperCase() || "?";
const githubProfileUrl = (user: WorkshopUser) =>
user.githubProfileUrl ||
(user.githubUsername ? `https://github.com/${encodeURIComponent(user.githubUsername)}` : undefined);
interface WorkshopAccountMenuProps {
user: WorkshopUser;
onSignOut: () => void;
@@ -21,8 +18,9 @@ interface WorkshopAccountMenuProps {
export const WorkshopAccountMenu = ({ user, onSignOut }: WorkshopAccountMenuProps) => {
const [open, setOpen] = useState(false);
const rootRef = useRef<HTMLDivElement>(null);
const profileUrl = githubProfileUrl(user);
const accountLabel = user.githubUsername ? `@${user.githubUsername}` : user.displayName || "Account";
const profileUrl = githubProfileUrl(user.name);
const avatarUrl = githubAvatarUrl(user.name);
const accountLabel = user.name ? `@${user.name}` : "Account";
useEffect(() => {
if (!open) return;
@@ -54,8 +52,8 @@ export const WorkshopAccountMenu = ({ user, onSignOut }: WorkshopAccountMenuProp
onClick={() => setOpen((value) => !value)}
>
<Avatar className="size-6">
{user.avatarUrl && <AvatarImage src={user.avatarUrl} alt="" />}
<AvatarFallback className="text-[10px]">{avatarFallback(user.displayName)}</AvatarFallback>
{avatarUrl && <AvatarImage src={avatarUrl} alt="" />}
<AvatarFallback className="text-[10px]">{avatarFallback(user.name)}</AvatarFallback>
</Avatar>
<span className="max-w-36 truncate">{accountLabel}</span>
<IconChevronDown
@@ -73,12 +71,11 @@ export const WorkshopAccountMenu = ({ user, onSignOut }: WorkshopAccountMenuProp
>
<div className="flex items-center gap-3 px-2 py-2">
<Avatar className="size-9">
{user.avatarUrl && <AvatarImage src={user.avatarUrl} alt="" />}
<AvatarFallback>{avatarFallback(user.displayName)}</AvatarFallback>
{avatarUrl && <AvatarImage src={avatarUrl} alt="" />}
<AvatarFallback>{avatarFallback(user.name)}</AvatarFallback>
</Avatar>
<div className="min-w-0">
<p className="truncate text-sm font-medium">{user.displayName}</p>
{user.githubUsername && <p className="truncate text-xs text-muted-foreground">@{user.githubUsername}</p>}
<p className="truncate text-sm font-medium">{accountLabel}</p>
</div>
</div>
@@ -10,6 +10,7 @@ import {
} from "@tabler/icons-react";
import { WorkshopBackend } from "@homarr/workshop/backend";
import { githubAvatarUrl, githubProfileUrl } from "@homarr/workshop/schema";
import type { WorkshopReport, WorkshopSubmissionSummary, WorkshopUser } from "@homarr/workshop/schema";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
@@ -108,7 +109,11 @@ export function WorkshopAdmin({ workshopUrl }: { workshopUrl?: string }) {
submission,
reports: reports.filter((report) => report.submission === submission.id),
}))
.sort((left, right) => Number(right.reports.length > 0) - Number(left.reports.length > 0));
.sort(
(left, right) =>
right.reports.length - left.reports.length ||
Date.parse(right.submission.updated) - Date.parse(left.submission.updated),
);
return (
<main className="marketplace mx-auto max-w-6xl px-4 pb-20 text-foreground sm:px-6">
@@ -194,18 +199,18 @@ export function WorkshopAdmin({ workshopUrl }: { workshopUrl?: string }) {
<CardHeader className="border-b border-border pb-4 sm:grid-cols-[1fr_auto]">
<div className="flex min-w-0 items-center gap-3">
<Avatar className="size-10">
{submission.authorAvatarUrl && <AvatarImage src={submission.authorAvatarUrl} alt="" />}
{submission.authorName && <AvatarImage src={githubAvatarUrl(submission.authorName)} alt="" />}
<AvatarFallback>{avatarFallback(submission.authorName)}</AvatarFallback>
</Avatar>
<div className="min-w-0">
<CardTitle className="truncate text-base">{submission.title}</CardTitle>
<a
href={submission.authorGithubProfileUrl || undefined}
target={submission.authorGithubProfileUrl ? "_blank" : undefined}
href={githubProfileUrl(submission.authorName) || undefined}
target={submission.authorName ? "_blank" : undefined}
rel="noreferrer"
className="text-xs text-muted-foreground hover:text-foreground hover:underline"
>
{submission.authorName}
{submission.authorName || "Community member"}
</a>
</div>
</div>
@@ -237,7 +242,8 @@ export function WorkshopAdmin({ workshopUrl }: { workshopUrl?: string }) {
<div className="min-w-0">
<AlertTitle className="col-auto capitalize">{report.category} report</AlertTitle>
<p className="m-0 mt-1 text-xs text-destructive/75">
Reported by {report.reporterName} · {new Date(report.created).toLocaleString()}
Reported by {report.reporterName || "Community member"} ·{" "}
{new Date(report.created).toLocaleString()}
</p>
</div>
<Button
@@ -8,16 +8,19 @@ import {
IconChevronLeft,
IconChevronRight,
IconEye,
IconFlag,
IconMessage,
IconPackage,
IconPlus,
IconPuzzle,
IconRefresh,
IconSearch,
IconShield,
} from "@tabler/icons-react";
import type { WorkshopSubmission } from "@site/src/lib/pocketbase";
import type { SubmissionType } from "@site/src/lib/workshop-schema";
import { githubAvatarUrl, githubProfileUrl } from "@homarr/workshop/schema";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
@@ -115,6 +118,7 @@ export const WorkshopApp = ({ workshopUrl }: { workshopUrl: string }) => {
const availableTypeFilters = workshop.user
? [...typeFilters, { value: "yours" as const, label: "Yours" }]
: typeFilters;
const openReportCount = workshop.submissions.reduce((total, submission) => total + submission.reportCount, 0);
return (
<div className="mx-auto max-w-7xl px-4 pb-16">
@@ -125,9 +129,19 @@ export const WorkshopApp = ({ workshopUrl }: { workshopUrl: string }) => {
Discover community-made widgets and CSS. Review the source, then import it into Homarr.
</p>
</div>
<div className="flex items-center gap-2 self-start sm:self-auto">
<div className="flex flex-wrap items-center gap-2 self-start sm:self-auto">
{workshop.user ? (
<>
{workshop.user.isAdmin && (
<Button
variant="outline"
className="h-10 sm:h-8"
nativeButton={false}
render={<a href="/workshop/admin" aria-label={`Open moderation, ${openReportCount} open reports`} />}
>
<IconShield size={14} /> Moderation{openReportCount > 0 ? ` (${openReportCount})` : ""}
</Button>
)}
<Button className="h-10 sm:h-8" onClick={() => setShowSubmit(true)}>
<IconPlus size={14} /> Share yours
</Button>
@@ -356,16 +370,16 @@ const SubmissionCard = ({ submission, backend, userVote, onVote }: SubmissionCar
</div>
<CardDescription className="flex min-w-0 items-center gap-1 text-xs">
<a
href={submission.authorGithubProfileUrl || undefined}
target={submission.authorGithubProfileUrl ? "_blank" : undefined}
href={githubProfileUrl(submission.authorName) || undefined}
target={submission.authorName ? "_blank" : undefined}
rel="noreferrer"
className="inline-flex min-w-0 items-center gap-1.5 hover:text-foreground"
>
<Avatar className="size-4">
{submission.authorAvatarUrl && <AvatarImage src={submission.authorAvatarUrl} alt="" />}
{submission.authorName && <AvatarImage src={githubAvatarUrl(submission.authorName)} alt="" />}
<AvatarFallback className="text-[9px]">{avatarFallback(submission.authorName)}</AvatarFallback>
</Avatar>
<span className="truncate">{submission.authorName}</span>
<span className="truncate">{submission.authorName || "Community member"}</span>
</a>
<span className="shrink-0 whitespace-nowrap">
· v{submission.revision} · {formatRelativeTime(submission.created)}
@@ -405,9 +419,14 @@ const SubmissionCard = ({ submission, backend, userVote, onVote }: SubmissionCar
</CardHeader>
<CardContent className="min-h-[4.5rem] flex-1 overflow-hidden">
{submission.outdated && (
{(submission.outdated || submission.reportCount > 0) && (
<div className="mb-2 flex flex-wrap gap-1.5">
<Badge variant="secondary">Outdated</Badge>
{submission.outdated && <Badge variant="secondary">Outdated</Badge>}
{submission.reportCount > 0 && (
<Badge variant="destructive" className="gap-1">
<IconFlag size={12} /> {submission.reportCount} {submission.reportCount === 1 ? "report" : "reports"}
</Badge>
)}
</div>
)}
{submission.description && (
@@ -162,7 +162,7 @@ export const useWorkshop = (workshopUrl: string) => {
const report = useCallback(
async (
submissionId: string,
category: "malicious" | "spam" | "copyright" | "inappropriate" | "other",
category: "outdated" | "malicious" | "spam" | "copyright" | "inappropriate" | "other",
explanation: string,
) => {
try {
+2 -1
View File
@@ -2151,7 +2151,8 @@
"beszelAlerts",
"beszelSystemStats",
"traefik",
"customApi"
"customApi",
"wud"
]
},
"options": {
+1 -1
View File
@@ -20,5 +20,5 @@
}
},
"include": ["**/*.ts", "**/*.jsx", "**/*.js", "**/*.tsx", "**/*.md", "**/*.mdx"],
"exclude": ["node_modules"]
"exclude": ["build", "node_modules"]
}
@@ -21,10 +21,7 @@
height: var(--board-gutter-viewport-height);
padding-top: 24px;
margin-top: -24px;
overflow-x: hidden;
overflow-y: auto;
overscroll-behavior: contain;
scrollbar-gutter: stable;
overflow: clip;
}
.gutter[data-board-editing="true"] {
@@ -53,7 +53,7 @@ export default async function BoardSettingsPage(props: Props) {
}
return (
<Container>
<Container size="xl">
<Stack>
<Title>{t("title", { boardName: capitalize(board.name) })}</Title>
<BoardSettingsForm
+1
View File
@@ -161,6 +161,7 @@ export default async function Layout(props: {
<head>
<meta name="homarr-website-url" content={publicUrls.homarrWebsiteUrl} />
<meta name="homarr-workshop-api-url" content={publicUrls.workshopApiUrl} />
<meta name="homarr-workshop-web-url" content={publicUrls.workshopWebUrl} />
<SearchEngineOptimization />
<CrowdinLiveTranslation locale={locale} />
</head>
@@ -30,7 +30,13 @@ describe("Copy AI prompt", () => {
`${process.cwd()}/apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-form.module.css`,
"utf8",
);
expect(card).toContain("<SimpleGrid cols={{ base: 1, sm: 2 }}>");
const actions = readFileSync(
`${process.cwd()}/apps/nextjs/src/app/[locale]/manage/custom-widgets/_copy-ai-prompt-button.tsx`,
"utf8",
);
expect(card).toContain('<Accordion variant="contained">');
expect(card).not.toContain("defaultValue");
expect(actions).toContain("<ActionIcon");
expect(styles).toContain(".sectionNav > a");
expect(styles).toContain("flex: 0 0 auto");
});
@@ -1,7 +1,7 @@
"use client";
import { Button } from "@mantine/core";
import { IconSparkles } from "@tabler/icons-react";
import { ActionIcon, Tooltip } from "@mantine/core";
import { IconCopy } from "@tabler/icons-react";
import { buildCustomWidgetAiPrompt } from "@homarr/custom-widgets/authoring-prompt";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
@@ -36,15 +36,16 @@ export const CopyAiPromptButton = ({
};
return (
<Button
type="button"
variant="light"
leftSection={<IconSparkles size={16} />}
onClick={() => void handleCopy()}
fullWidth
size="sm"
>
{t("action.copyAiPrompt")}
</Button>
<Tooltip label={t("action.copyAiPrompt")}>
<ActionIcon
type="button"
variant="subtle"
size="lg"
aria-label={t("action.copyAiPrompt")}
onClick={() => void handleCopy()}
>
<IconCopy size={16} />
</ActionIcon>
</Tooltip>
);
};
@@ -1,7 +1,7 @@
"use client";
import { Button, Card, Group, SimpleGrid, Stack, Text, TextInput, Textarea } from "@mantine/core";
import { IconSparkles } from "@tabler/icons-react";
import { Accordion, Button, Group, Stack, Text, TextInput, Textarea } from "@mantine/core";
import { IconClipboard, IconSparkles } from "@tabler/icons-react";
import type { HomarrCustomWidgetV2 } from "@homarr/custom-widgets/core";
import { useScopedI18n } from "@homarr/translation/client";
@@ -20,38 +20,41 @@ interface AiCardProps {
export function CustomWidgetAiCard(props: AiCardProps) {
const t = useScopedI18n("customWidget.workbench.ai");
return (
<Card withBorder p="md">
<Stack gap="sm">
<Group gap="xs">
<IconSparkles size={18} />
<Text fw={600}>{t("title")}</Text>
</Group>
<Text size="sm" c="dimmed">
{t("description")}
</Text>
<Textarea
label={t("request")}
value={props.request}
onChange={(event) => props.onRequestChange(event.currentTarget.value)}
autosize
minRows={2}
/>
<TextInput
label={t("documentationUrl")}
value={props.documentationUrl}
onChange={(event) => props.onDocumentationUrlChange(event.currentTarget.value)}
/>
<SimpleGrid cols={{ base: 1, sm: 2 }}>
<Accordion variant="contained">
<Accordion.Item value="ai">
<Group gap={0} wrap="nowrap">
<Accordion.Control icon={<IconSparkles size={18} />} style={{ flex: 1 }}>
{t("title")}
</Accordion.Control>
<CopyAiPromptButton
currentConfig={props.candidate}
request={props.request}
documentationUrl={props.documentationUrl}
/>
<Button type="button" variant="light" leftSection={<IconSparkles size={16} />} onClick={props.onPaste}>
{t("paste")}
</Button>
</SimpleGrid>
</Stack>
</Card>
</Group>
<Accordion.Panel>
<Stack gap="sm">
<Text size="sm" c="dimmed">
{t("description")}
</Text>
<Textarea
label={t("request")}
value={props.request}
onChange={(event) => props.onRequestChange(event.currentTarget.value)}
autosize
minRows={2}
/>
<TextInput
label={t("documentationUrl")}
value={props.documentationUrl}
onChange={(event) => props.onDocumentationUrlChange(event.currentTarget.value)}
/>
<Button type="button" variant="light" leftSection={<IconClipboard size={16} />} onClick={props.onPaste}>
{t("paste")}
</Button>
</Stack>
</Accordion.Panel>
</Accordion.Item>
</Accordion>
);
}
@@ -66,7 +66,6 @@ export function CustomWidgetForm({ mode, initialValues, definitionId }: CustomWi
const [documentationUrl, setDocumentationUrl] = useState("");
const [preview, setPreview] = useState<PreviewState>({ data: {}, status: {}, session: null, outcome: "idle" });
const [previewSize, setPreviewSize] = useState("standard");
const [previewTheme, setPreviewTheme] = useState<"light" | "dark">("dark");
const [optionsSnapshot, setOptionsSnapshot] = useState<Record<string, unknown>>(() => {
const initialDefinition = buildDefinition(formInitialValues);
return initialDefinition.success ? getDefinitionDefaults(initialDefinition.data) : {};
@@ -145,6 +144,7 @@ export function CustomWidgetForm({ mode, initialValues, definitionId }: CustomWi
key={id}
component="a"
href={`#${id}`}
onClick={() => setMobilePane(id === "preview" ? "preview" : "configure")}
size="compact-sm"
variant="subtle"
color={invalidSections.has(id) ? "red" : undefined}
@@ -171,7 +171,11 @@ export function CustomWidgetForm({ mode, initialValues, definitionId }: CustomWi
<EditorSection id="general" title={w("generalInformation")} icon={IconSettings}>
<TextInput label={t("field.name")} required {...form.getInputProps("name")} />
<Textarea label={t("field.description")} autosize minRows={2} {...form.getInputProps("description")} />
<IconPicker withAsterisk={false} {...form.getInputProps("iconUrl")} />
<IconPicker
withAsterisk={false}
suggestedSearch={mode === "create" ? form.values.name : undefined}
{...form.getInputProps("iconUrl")}
/>
</EditorSection>
<EditorSection id="sources" title={w("sources.title")} icon={IconApi}>
<CustomWidgetSourcesEditor form={form} definitionId={definitionId} />
@@ -266,8 +270,6 @@ export function CustomWidgetForm({ mode, initialValues, definitionId }: CustomWi
preview={preview}
size={previewSize}
onSizeChange={setPreviewSize}
theme={previewTheme}
onThemeChange={setPreviewTheme}
optionsSnapshot={optionsSnapshot}
onOptionsChange={setOptionsSnapshot}
onLiveActionsChange={(enabled) =>
@@ -1,5 +1,6 @@
// @vitest-environment jsdom
import { readFileSync } from "node:fs";
import { act } from "react";
import { createRoot } from "react-dom/client";
import type { Root } from "react-dom/client";
@@ -21,6 +22,7 @@ vi.mock("@homarr/api/client", () => ({
vi.mock("@homarr/translation/client", () => ({ useScopedI18n: () => (key: string) => key }));
vi.mock("@homarr/notifications", () => ({ showErrorNotification: vi.fn() }));
const rendererAttempt = vi.hoisted(() => vi.fn());
const codeEditorProps = vi.hoisted(() => vi.fn());
vi.mock("@homarr/widgets/custom-api/custom-jsx-display", () => ({
default: ({ data }: { data: Record<string, unknown> }) => {
@@ -31,7 +33,12 @@ vi.mock("@homarr/widgets/custom-api/custom-jsx-display", () => ({
return <div>widget-renderer</div>;
},
}));
vi.mock("./_code-editor", () => ({ CodeEditor: ({ label }: { label: string }) => <div>{label}</div> }));
vi.mock("./_code-editor", () => ({
CodeEditor: (props: { label: string; readOnly?: boolean }) => {
codeEditorProps(props);
return <div>{props.label}</div>;
},
}));
vi.mock("./_custom-widget-preview-action", () => ({ PreviewActionControl: () => <div>action-control</div> }));
let root: Root;
@@ -69,6 +76,16 @@ afterEach(async () => {
});
describe("Custom Widget preview panel", () => {
test("inherits the application theme instead of forcing a reload-sensitive preview theme", () => {
const source = readFileSync(
`${process.cwd()}/apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-preview-panel.tsx`,
"utf8",
);
expect(source).not.toContain("forceColorScheme");
expect(source).not.toContain("onThemeChange");
});
test("contains invalid definitions inside the widget canvas while keeping every inspection tab", async () => {
await act(async () => {
root.render(
@@ -79,8 +96,6 @@ describe("Custom Widget preview panel", () => {
preview={{ data: { previous: true }, status: {}, session: null, outcome: "idle" }}
size="standard"
onSizeChange={vi.fn()}
theme="dark"
onThemeChange={vi.fn()}
optionsSnapshot={{}}
onOptionsChange={vi.fn()}
onLiveActionsChange={vi.fn()}
@@ -100,6 +115,7 @@ describe("Custom Widget preview panel", () => {
);
await act(async () => dataTab?.click());
expect(host.textContent).toContain("requestData");
expect(codeEditorProps).toHaveBeenCalledWith(expect.objectContaining({ label: "requestData", readOnly: true }));
expect(host.textContent).not.toContain("invalid");
const optionsTab = [...host.querySelectorAll<HTMLElement>('[role="tab"]')].find((element) =>
@@ -124,6 +140,8 @@ describe("Custom Widget preview panel", () => {
expect(host.textContent).toContain("sources.0.baseUrl");
expect(host.textContent).toContain("Enter a complete URL.");
expect(host.textContent).not.toContain("sessionActive");
expect(host.textContent).not.toContain("simulationPending");
});
test("contains renderer failures inside the widget tab and keeps the inspection tabs usable", async () => {
@@ -149,8 +167,6 @@ describe("Custom Widget preview panel", () => {
preview={preview}
size="standard"
onSizeChange={vi.fn()}
theme="dark"
onThemeChange={vi.fn()}
optionsSnapshot={{ ...failingOptions }}
onOptionsChange={vi.fn()}
onLiveActionsChange={vi.fn()}
@@ -175,8 +191,6 @@ describe("Custom Widget preview panel", () => {
preview={preview}
size="standard"
onSizeChange={vi.fn()}
theme="dark"
onThemeChange={vi.fn()}
optionsSnapshot={failingOptions}
onOptionsChange={vi.fn()}
onLiveActionsChange={vi.fn()}
@@ -211,8 +225,6 @@ describe("Custom Widget preview panel", () => {
preview={preview}
size="standard"
onSizeChange={vi.fn()}
theme="dark"
onThemeChange={vi.fn()}
optionsSnapshot={{}}
onOptionsChange={vi.fn()}
onLiveActionsChange={vi.fn()}
@@ -1,20 +1,7 @@
"use client";
import { useState } from "react";
import {
Alert,
Badge,
Box,
Card,
Group,
MantineProvider,
Paper,
SegmentedControl,
Stack,
Switch,
Tabs,
Text,
} from "@mantine/core";
import { Alert, Box, Card, Group, Paper, SegmentedControl, Stack, Switch, Tabs, Text } from "@mantine/core";
import { clientApi } from "@homarr/api/client";
import type { HomarrCustomWidgetV2 } from "@homarr/custom-widgets/core";
@@ -44,8 +31,6 @@ interface PreviewPanelProps {
preview: PreviewState;
size: string;
onSizeChange(value: string): void;
theme: "light" | "dark";
onThemeChange(value: "light" | "dark"): void;
optionsSnapshot: Record<string, unknown>;
onOptionsChange(value: Record<string, unknown>): void;
onLiveActionsChange(enabled: boolean): void;
@@ -98,39 +83,51 @@ export function CustomWidgetPreviewPanel(props: PreviewPanelProps) {
return (
<Card withBorder p="md">
<Stack gap="md">
<Group grow>
<Group gap="xs" justify="space-between">
<SegmentedControl
size="xs"
value={props.size}
onChange={props.onSizeChange}
data={[
{ value: "compact", label: t("size.compact") },
{ value: "standard", label: t("size.standard") },
{ value: "wide", label: t("size.wide") },
{
value: "compact",
label: (
<span aria-label={t("size.compact")} title={t("size.compact")}>
S
</span>
),
},
{
value: "standard",
label: (
<span aria-label={t("size.standard")} title={t("size.standard")}>
M
</span>
),
},
{
value: "wide",
label: (
<span aria-label={t("size.wide")} title={t("size.wide")}>
L
</span>
),
},
]}
/>
<SegmentedControl
size="xs"
value={props.theme}
onChange={(value) => props.onThemeChange(value as "light" | "dark")}
value={fixture}
disabled={!candidate}
onChange={(value) => setFixture(value as typeof fixture)}
data={[
{ value: "light", label: t("theme.light") },
{ value: "dark", label: t("theme.dark") },
{ value: "live", label: t("fixture.live") },
{ value: "loading", label: t("fixture.loading") },
{ value: "empty", label: t("fixture.empty") },
{ value: "error", label: t("fixture.error") },
]}
/>
</Group>
<SegmentedControl
size="xs"
value={fixture}
disabled={!candidate}
onChange={(value) => setFixture(value as typeof fixture)}
data={[
{ value: "live", label: t("fixture.live") },
{ value: "loading", label: t("fixture.loading") },
{ value: "empty", label: t("fixture.empty") },
{ value: "error", label: t("fixture.error") },
]}
/>
{candidate && (
<PreviewResult
outcome={props.preview.outcome}
@@ -153,22 +150,20 @@ export function CustomWidgetPreviewPanel(props: PreviewPanelProps) {
</Tabs.List>
<Tabs.Panel value="widget" pt="sm">
<Box className={classes.previewCanvas}>
<MantineProvider forceColorScheme={props.theme}>
<Paper withBorder p="sm" h={360} w={widths[props.size] ?? 480} maw="100%" style={{ overflow: "auto" }}>
{displayData ? (
<PreviewErrorBoundary
title={errorBoundaryMessages("title")}
description={t("containedFailures")}
retryLabel={errorBoundaryMessages("retry")}
resetKeys={[rendererResetKey]}
>
<CustomJsxDisplay data={displayData} />
</PreviewErrorBoundary>
) : (
<Alert color="yellow">{t("invalid")}</Alert>
)}
</Paper>
</MantineProvider>
<Paper withBorder p="sm" h={360} w={widths[props.size] ?? 480} maw="100%" style={{ overflow: "auto" }}>
{displayData ? (
<PreviewErrorBoundary
title={errorBoundaryMessages("title")}
description={t("containedFailures")}
retryLabel={errorBoundaryMessages("retry")}
resetKeys={[rendererResetKey]}
>
<CustomJsxDisplay data={displayData} />
</PreviewErrorBoundary>
) : (
<Alert color="yellow">{t("invalid")}</Alert>
)}
</Paper>
</Box>
</Tabs.Panel>
<Tabs.Panel value="data" pt="sm">
@@ -177,6 +172,7 @@ export function CustomWidgetPreviewPanel(props: PreviewPanelProps) {
label={t("requestData")}
language="json"
value={JSON.stringify({ data: props.preview.data, status: props.preview.status }, null, 2)}
readOnly
onChange={() => undefined}
/>
</Tabs.Panel>
@@ -221,11 +217,6 @@ export function CustomWidgetPreviewPanel(props: PreviewPanelProps) {
</Tabs.Panel>
<Tabs.Panel value="diagnostics" pt="sm">
<Stack gap="xs">
{props.preview.session ? (
<Badge color="green">{t("sessionActive")}</Badge>
) : (
<Badge color="gray">{t("simulationPending")}</Badge>
)}
<Text size="sm" c="dimmed">
{t("containedFailures")}
</Text>
@@ -261,6 +252,7 @@ export function CustomWidgetPreviewPanel(props: PreviewPanelProps) {
label={t("journal")}
language="json"
value={JSON.stringify(journalQuery.data ?? [], null, 2)}
readOnly
onChange={() => undefined}
/>
)}
@@ -87,7 +87,9 @@ export function CustomWidgetSourcesEditor({
form.setFieldValue("secrets", [...current, { sourceId, kind, value, hasValue: existing?.hasValue }]);
};
const addSource = () => {
const id = sources.length === 0 ? "default" : `source-${sources.length + 1}`;
let suffix = sources.length + 1;
while (sources.some(({ id }) => id === `source-${suffix}`)) suffix += 1;
const id = sources.length === 0 ? "default" : `source-${suffix}`;
form.setFieldValue(
"sources",
JSON.stringify(
@@ -0,0 +1,46 @@
// @vitest-environment jsdom
import { act } from "react";
import { createRoot } from "react-dom/client";
import type { Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { JsonPreviewEditor } from "./_json-preview-editor";
const editorProps = vi.hoisted(() => vi.fn());
vi.mock("@homarr/translation/client", () => ({ useScopedI18n: () => (key: string) => key }));
vi.mock("./_code-editor", () => ({
CodeEditor: (props: { error?: string; onChange(value: string): void }) => {
editorProps(props);
return <button onClick={() => props.onChange("{invalid")}>edit</button>;
},
}));
let root: Root;
let host: HTMLDivElement;
beforeEach(() => {
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
host = document.createElement("div");
document.body.append(host);
root = createRoot(host);
});
afterEach(async () => {
await act(() => root.unmount());
host.remove();
});
describe("preview JSON editor", () => {
it("surfaces invalid JSON and clears the previously rendered options", async () => {
const onChange = vi.fn();
await act(async () => {
root.render(<JsonPreviewEditor id="options" label="Options" value={{ stale: true }} onChange={onChange} />);
});
await act(async () => (host.querySelector("button") as HTMLButtonElement).click());
expect(onChange).toHaveBeenCalledWith({});
expect(editorProps).toHaveBeenLastCalledWith(expect.objectContaining({ error: "invalidJson" }));
});
});
@@ -2,6 +2,8 @@
import { useEffect, useState } from "react";
import { useScopedI18n } from "@homarr/translation/client";
import { CodeEditor } from "./_code-editor";
import { isRecord, parseJson } from "./_custom-widget-form-utils";
@@ -16,19 +18,26 @@ export function JsonPreviewEditor({
value: Record<string, unknown>;
onChange(value: Record<string, unknown>): void;
}) {
const t = useScopedI18n("customWidget.workbench.builder");
const serialized = JSON.stringify(value, null, 2);
const [draft, setDraft] = useState(serialized);
useEffect(() => setDraft(serialized), [serialized]);
const [invalid, setInvalid] = useState(false);
useEffect(() => {
if (!invalid) setDraft(serialized);
}, [invalid, serialized]);
return (
<CodeEditor
id={id}
label={label}
language="json"
value={draft}
error={invalid ? t("invalidJson") : undefined}
onChange={(next) => {
setDraft(next);
const parsed = parseJson(next);
if (isRecord(parsed)) onChange(parsed);
const valid = isRecord(parsed);
setInvalid(!valid);
onChange(valid ? parsed : {});
}}
/>
);
@@ -48,4 +48,17 @@ describe("custom widget unsaved changes guard", () => {
expect(listLink.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true, button: 0 }))).toBe(true);
expect(confirmNavigation).not.toHaveBeenCalled();
});
it("restores the current page and confirms browser history navigation", () => {
window.history.replaceState(null, "", "/en/manage/custom-widgets");
window.history.pushState(null, "", "/en/manage/custom-widgets/edit/widget-1");
const confirmNavigation = vi.fn();
cleanups.push(registerUnsavedChangesGuard({ isDirty: () => true, confirmNavigation }));
window.history.replaceState(null, "", "/en/manage/custom-widgets");
window.dispatchEvent(new PopStateEvent("popstate"));
expect(window.location.pathname).toBe("/en/manage/custom-widgets/edit/widget-1");
expect(confirmNavigation).toHaveBeenCalledWith("/en/manage/custom-widgets");
});
});
@@ -4,6 +4,7 @@ interface UnsavedChangesGuardOptions {
}
export function registerUnsavedChangesGuard({ isDirty, confirmNavigation }: UnsavedChangesGuardOptions) {
let currentHref = `${window.location.pathname}${window.location.search}${window.location.hash}`;
const handleClick = (event: MouseEvent) => {
if (!isDirty() || event.defaultPrevented || event.button !== 0) return;
if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;
@@ -32,11 +33,22 @@ export function registerUnsavedChangesGuard({ isDirty, confirmNavigation }: Unsa
event.preventDefault();
event.returnValue = true;
};
const handlePopState = () => {
const destination = `${window.location.pathname}${window.location.search}${window.location.hash}`;
if (!isDirty()) {
currentHref = destination;
return;
}
window.history.pushState(window.history.state, "", currentHref);
confirmNavigation(destination);
};
document.addEventListener("click", handleClick, true);
window.addEventListener("beforeunload", handleBeforeUnload);
window.addEventListener("popstate", handlePopState);
return () => {
document.removeEventListener("click", handleClick, true);
window.removeEventListener("beforeunload", handleBeforeUnload);
window.removeEventListener("popstate", handlePopState);
};
}
@@ -57,7 +57,10 @@ export const SearchEngineForm = (props: SearchEngineFormProps) => {
/>
</Grid.Col>
</Grid>
<IconPicker {...form.getInputProps("iconUrl")} />
<IconPicker
{...form.getInputProps("iconUrl")}
suggestedSearch={initialValues === undefined ? form.values.name : undefined}
/>
<Fieldset legend={t("search.engine.page.edit.configControl")}>
<SegmentedControl
@@ -1,5 +1,9 @@
"use client";
import { Anchor } from "@mantine/core";
import { IconExternalLink } from "@tabler/icons-react";
import { createDocumentationLink } from "@homarr/definitions";
import type { UseFormReturnType } from "@homarr/form";
import { useScopedI18n } from "@homarr/translation/client";
@@ -12,11 +16,25 @@ interface AnalyticsSettingsProps {
}
export const AnalyticsSettings = ({ form }: AnalyticsSettingsProps) => {
const t = useScopedI18n("management.page.settings.section.analytics");
const t = useScopedI18n("management.page.settings.section");
return (
<SectionCard title={t("title")}>
<SwitchSetting form={form} formKey="enableGeneral" title={t("general.title")} text={t("general.text")} />
<SectionCard title={t("analytics.title")}>
<SwitchSetting
form={form}
formKey="enableGeneral"
title={t("analytics.general.title")}
text={t("analytics.general.text")}
/>
<Anchor
href={createDocumentationLink("/docs/management/settings", "#analytics")}
target="_blank"
rel="noopener noreferrer"
size="sm"
>
{t("analyticsDocumentation")}
<IconExternalLink size={14} style={{ marginInlineStart: 4, verticalAlign: "middle" }} />
</Anchor>
</SectionCard>
);
};
@@ -1,11 +1,10 @@
import { notFound } from "next/navigation";
import { Stack, Title } from "@mantine/core";
import { api } from "@homarr/api/server";
import { auth } from "@homarr/auth/next";
import { getScopedI18n } from "@homarr/translation/server";
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
import { ManagePageLayout } from "~/components/manage/manage-page-layout";
import { SettingsForm } from "./_components/settings-form";
export async function generateMetadata() {
@@ -28,12 +27,8 @@ export default async function SettingsPage() {
const tSettings = await getScopedI18n("management.page.settings");
return (
<>
<DynamicBreadcrumb />
<Stack>
<Title order={1}>{tSettings("title")}</Title>
<SettingsForm initialData={serverSettings} />
</Stack>
</>
<ManagePageLayout title={tSettings("title")}>
<SettingsForm initialData={serverSettings} />
</ManagePageLayout>
);
}
@@ -22,7 +22,6 @@ export default async function WorkshopPage() {
return (
<ManagePageLayout
title={t("title")}
size="xl"
primaryAction={
<Button
component="a"
@@ -3,7 +3,7 @@
import type { CSSProperties, PropsWithChildren } from "react";
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
import { Overlay, Portal } from "@mantine/core";
import { useIsomorphicEffect } from "@mantine/hooks";
import { useIsomorphicEffect, useViewportSize } from "@mantine/hooks";
import { useEditMode } from "@homarr/boards/edit-mode";
@@ -14,6 +14,7 @@ import type { FocusRect } from "./geometry";
interface ActiveFocus {
itemId: string;
source: HTMLElement;
restoreFocusTarget: HTMLElement;
sourceRect: FocusRect;
activation: "preview" | "manual";
autofocusClose: boolean;
@@ -24,10 +25,12 @@ interface ActiveFocus {
interface OpenAdvancedFocusOptions {
activation?: ActiveFocus["activation"];
autofocusClose?: boolean;
restoreFocusTarget?: HTMLElement;
}
interface AdvancedFocusContextValue {
active: ActiveFocus | null;
viewportSize: { width: number; height: number };
open: (itemId: string, source: HTMLElement, options?: OpenAdvancedFocusOptions) => void;
close: (restoreFocus?: boolean) => void;
dismiss: (itemId: string) => void;
@@ -46,6 +49,7 @@ const isEditableTarget = (target: EventTarget | null) =>
export const BoardAdvancedFocusProvider = ({ children }: PropsWithChildren) => {
const [isEditMode] = useEditMode();
const viewportSize = useViewportSize();
const [active, setActive] = useState<ActiveFocus | null>(null);
const [isHoldPending, setIsHoldPending] = useState(false);
const activeRef = useRef<ActiveFocus | null>(null);
@@ -92,6 +96,7 @@ export const BoardAdvancedFocusProvider = ({ children }: PropsWithChildren) => {
updateActive({
itemId,
source,
restoreFocusTarget: options.restoreFocusTarget ?? source,
sourceRect: { left: rect.left, top: rect.top, width: rect.width, height: rect.height },
activation: options.activation ?? "manual",
autofocusClose: options.autofocusClose ?? false,
@@ -113,7 +118,8 @@ export const BoardAdvancedFocusProvider = ({ children }: PropsWithChildren) => {
closeTimerRef.current = setTimeout(() => {
closeTimerRef.current = null;
updateActive(null);
if (restoreFocus && current.source.isConnected) requestAnimationFrame(() => current.source.focus());
if (restoreFocus && current.restoreFocusTarget.isConnected)
requestAnimationFrame(() => current.restoreFocusTarget.focus());
}, closeDuration);
},
[cancelHoverTimer, cancelPreviewLeaveTimer, updateActive],
@@ -210,7 +216,7 @@ export const BoardAdvancedFocusProvider = ({ children }: PropsWithChildren) => {
return;
}
if (underlyingSource.hasAttribute("aria-keyshortcuts")) {
if (underlyingSource.hasAttribute("data-advanced-focus-enabled")) {
hover(itemId, underlyingSource);
return;
}
@@ -310,8 +316,8 @@ export const BoardAdvancedFocusProvider = ({ children }: PropsWithChildren) => {
}, [active?.activation]);
const value = useMemo(
() => ({ active, open, close, dismiss, hover, leave }),
[active, close, dismiss, hover, leave, open],
() => ({ active, viewportSize, open, close, dismiss, hover, leave }),
[active, close, dismiss, hover, leave, open, viewportSize],
);
return (
@@ -24,3 +24,18 @@
pointer-events: none !important;
user-select: none !important;
}
.advancedFocusTrigger {
position: absolute;
top: var(--mantine-spacing-xs);
left: 50%;
z-index: 2;
opacity: 0;
pointer-events: none;
transform: translateX(-50%);
}
.advancedFocusTrigger:focus-visible {
opacity: 1;
pointer-events: auto;
}
@@ -1,10 +1,10 @@
import type { ComponentType, CSSProperties, KeyboardEvent, MutableRefObject } from "react";
import type { ComponentType, CSSProperties, MutableRefObject } from "react";
import { memo, Suspense, use, useCallback, useEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
import dynamic from "next/dynamic";
import type { CardProps } from "@mantine/core";
import { Badge, Box, Card, Center, Loader, Portal } from "@mantine/core";
import { useElementSize, useIsomorphicEffect, useViewportSize } from "@mantine/hooks";
import { Badge, Box, Button, Card, Center, Loader, Portal } from "@mantine/core";
import { useElementSize, useIsomorphicEffect } from "@mantine/hooks";
import { QueryErrorResetBoundary, useQueryClient } from "@tanstack/react-query";
import combineClasses from "clsx";
import { NoIntegrationSelectedError } from "@homarr/widgets/errors/classes";
@@ -153,15 +153,16 @@ const LoadedBoardItemContent = ({
}: LoadedBoardItemContentProps) => {
const { definition, Component } = use(loadWidgetResources(item.kind));
const sourceRef = useRef<HTMLDivElement>(null);
const advancedFocusTriggerRef = useRef<HTMLButtonElement>(null);
const cardRef = useRef<HTMLDivElement>(null);
const previewEntranceFrameRef = useRef<number | null>(null);
const { width: viewportWidth, height: viewportHeight } = useViewportSize();
const board = useRequiredBoard();
const t = useI18n();
const [isEditMode] = useEditMode();
const [manualSurface, setManualSurface] = useState<HTMLDivElement | null>(null);
const [surfacePortalTarget, setSurfacePortalTarget] = useState<HTMLDivElement | null>(null);
const { active, open, close, dismiss, hover, leave } = useAdvancedFocus();
const { active, viewportSize, open, close, dismiss, hover, leave } = useAdvancedFocus();
const { width: viewportWidth, height: viewportHeight } = viewportSize;
const supportsAdvancedFocus = definitionSupportsAdvancedFocus(definition);
const widgetName = t(`widget.${item.kind}.name`);
const advancedViewLabel = t("item.advancedFocus.label", { widget: widgetName });
@@ -185,17 +186,13 @@ const LoadedBoardItemContent = ({
useEffect(() => () => dismiss(item.id), [dismiss, item.id]);
const handleKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
if (
event.currentTarget !== event.target ||
event.key !== "Enter" ||
!event.shiftKey ||
isEditMode ||
!supportsAdvancedFocus
)
return;
event.preventDefault();
if (sourceRef.current) open(item.id, sourceRef.current, { activation: "manual", autofocusClose: true });
const openAdvancedView = () => {
if (sourceRef.current)
open(item.id, sourceRef.current, {
activation: "manual",
autofocusClose: true,
restoreFocusTarget: advancedFocusTriggerRef.current ?? sourceRef.current,
});
};
useEffect(() => {
@@ -353,16 +350,10 @@ const LoadedBoardItemContent = ({
w="100%"
h="100%"
data-advanced-focus-source
data-advanced-focus-enabled={supportsAdvancedFocus || undefined}
data-item-id={item.id}
tabIndex={!isEditMode && supportsAdvancedFocus ? 0 : undefined}
role={!isEditMode && supportsAdvancedFocus ? "group" : undefined}
aria-label={
!isEditMode && supportsAdvancedFocus ? `${widgetName}: ${t("item.advancedFocus.open")}` : undefined
}
aria-expanded={supportsAdvancedFocus ? isAdvanced : undefined}
aria-controls={isAdvanced ? advancedViewId : undefined}
aria-keyshortcuts={supportsAdvancedFocus ? "Shift+Enter" : undefined}
onKeyDown={supportsAdvancedFocus ? handleKeyDown : undefined}
aria-label={!isEditMode && supportsAdvancedFocus ? widgetName : undefined}
onPointerEnter={() => {
if (supportsAdvancedFocus && sourceRef.current) hover(item.id, sourceRef.current);
}}
@@ -371,6 +362,21 @@ const LoadedBoardItemContent = ({
}}
className={combineClasses(isAdvanced && advancedFocusClasses.sourcePlaceholder)}
>
{!isEditMode && supportsAdvancedFocus && (
<Button
ref={advancedFocusTriggerRef}
type="button"
size="compact-xs"
className={itemContentClasses.advancedFocusTrigger}
aria-expanded={isAdvanced}
aria-controls={isAdvanced ? advancedViewId : undefined}
aria-keyshortcuts="Shift+Enter"
data-advanced-focus-trigger
onClick={openAdvancedView}
>
{t("item.advancedFocus.open")}
</Button>
)}
<div ref={mountCompactHost} className={advancedFocusClasses.surfaceHost}>
{!supportsAdvancedFocus && widgetCard}
</div>
@@ -205,7 +205,7 @@ export const WidgetContextMenu = ({
[setItemOptions],
);
if (!session || !settings.enableRightClickOnWidgets || isEditMode) return <>{children}</>;
if (!session || !settings.enableRightClickOnWidgets || isEditMode || item.kind === "app") return <>{children}</>;
const visibleWidgetActions = widgetContextActions.filter((action) => !action.hidden);
@@ -227,7 +227,12 @@ export const WidgetContextMenu = ({
closeMenuOnClick
leftSection={<IconMaximize size={16} />}
onClick={() => {
if (sourceRef.current) openAdvancedFocus(item.id, sourceRef.current);
if (sourceRef.current)
openAdvancedFocus(item.id, sourceRef.current, {
restoreFocusTarget:
sourceRef.current.querySelector<HTMLElement>("[data-advanced-focus-trigger]") ??
sourceRef.current,
});
}}
>
{t("item.advancedFocus.open")}
@@ -0,0 +1,25 @@
export const observeGridDragHandle = (
container: HTMLElement,
selector: string,
bind: (element: HTMLElement | null) => void,
) => {
let observer: MutationObserver | null = null;
const bindFirstHandle = () => {
const handle = container.querySelector<HTMLElement>(selector);
if (!handle) return false;
bind(handle);
observer?.disconnect();
observer = null;
return true;
};
if (!bindFirstHandle()) {
observer = new MutationObserver(bindFirstHandle);
observer.observe(container, { childList: true, subtree: true });
}
return () => {
observer?.disconnect();
bind(null);
};
};
@@ -8,11 +8,13 @@
.board-grid-entry {
z-index: 1;
overflow: visible;
transition:
left 100ms ease,
top 100ms ease,
width 100ms ease,
height 100ms ease;
}
.board-grid-entry[data-grid-preview="true"] {
width: var(--board-grid-preview-width) !important;
height: var(--board-grid-preview-height) !important;
translate: var(--board-grid-preview-x) var(--board-grid-preview-y);
will-change: translate;
}
.board-grid-entry[data-dnd-active="true"],
@@ -73,8 +75,8 @@
zoom: var(--board-grid-overlay-content-zoom, 1);
}
body[data-board-grid-interacting="drag"] .board-grid-entry,
body[data-board-grid-interacting="drag"] .board-grid-placeholder {
body[data-board-grid-interacting] .board-grid-entry,
body[data-board-grid-interacting] .board-grid-placeholder {
transition: none;
}
@@ -116,11 +118,6 @@ body[data-board-grid-interacting="resize"] .board-grid-entry[data-dnd-active="tr
pointer-events: none;
}
.board-grid-resize-outline[data-grid-resize-valid="false"] {
background: color-mix(in srgb, var(--mantine-color-red-filled) 8%, transparent);
border-color: var(--mantine-color-red-filled);
}
.board-grid-editor[data-dnd-drop-target="true"] {
outline: 2px solid color-mix(in srgb, var(--mantine-primary-color-filled) 60%, transparent);
outline-offset: 4px;
@@ -145,6 +142,14 @@ body[data-board-grid-interacting="resize"] .board-grid-entry[data-dnd-active="tr
pointer-events: none;
}
body[data-board-grid-interacting] .board-grid-resize-handle {
pointer-events: none;
}
body[data-board-grid-interacting] .board-grid-resize-handle::after {
opacity: 0;
}
.board-grid-entry[data-grid-item-type="section"][data-grid-section-chrome-active="true"] {
outline: 2px solid var(--mantine-primary-color-filled);
outline-offset: 4px;
@@ -1,7 +1,18 @@
"use client";
import type { CSSProperties, PointerEvent as ReactPointerEvent, PropsWithChildren } from "react";
import { createContext, useCallback, useContext, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import {
createContext,
memo,
useCallback,
useContext,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
useSyncExternalStore,
} from "react";
import "./grid-editor.css"; // oxlint-disable-line import/no-unassigned-import
@@ -25,12 +36,7 @@ import { DragDropProvider, DragOverlay, useDragDropManager, useDraggable, useDro
import { useCurrentLayout, useRequiredBoard } from "@homarr/boards/context";
import { useI18n } from "@homarr/translation/client";
import {
getLayoutRowCount,
getLogicalItemStyle,
getLogicalTrackSize,
LOGICAL_GRID_PITCH,
} from "~/components/board/layout";
import { getLogicalItemStyle, getLogicalTrackSize, LOGICAL_GRID_PITCH } from "~/components/board/layout";
import type { GridPlacement } from "~/components/board/layout";
import { useSectionContext } from "../section-context";
import {
@@ -47,8 +53,15 @@ import {
previewGridResize,
} from "./dnd";
import type { DragProjectionOrigin, GridResizeDirection, GridTransaction, TransactionalGridState } from "./dnd";
import type { DndGridEntryProps } from "./grid-entry-memo";
import { areDndGridEntryPropsEqual } from "./grid-entry-memo";
import { getGridDepth } from "./grid-depth";
import { observeGridDragHandle } from "./grid-drag-handle";
import { createGridInteractionStore } from "./grid-interaction-store";
import { useBoardGridPortalHost } from "./grid-portal-host";
import type { GridInteraction } from "./grid-preview-layer";
import { GridPreviewLayer } from "./grid-preview-layer";
import { createGridResizeOutlineController } from "./grid-resize-outline";
import type { CommitSectionGridInput, SectionGridPlacement } from "./use-grid-layout-actions";
import { useGridLayoutActions } from "./use-grid-layout-actions";
@@ -59,6 +72,7 @@ interface GridEditorProps {
sectionId: string;
columnCount: number;
rowCount: number;
maxRowCount: number | null;
placements: readonly SectionGridPlacement[];
className: string;
}
@@ -86,17 +100,6 @@ interface RegisteredGrid {
element: HTMLElement;
}
interface GridInteraction {
activeId: string;
sourceGridId: string;
targetGridId: string | null;
targetPlacement: SectionGridPlacement | null;
state: TransactionalGridState<SectionGridPlacement>;
mode: "drag" | "resize";
valid: boolean;
previewRevision: number;
}
interface ActiveTransaction {
transaction: GridTransaction<SectionGridPlacement>;
label: string;
@@ -123,8 +126,7 @@ interface ResizePreviewInput {
minHeight: number;
}
interface BoardGridEditorContextValue {
interaction: GridInteraction | null;
interface BoardGridEditorActionsContextValue {
registerGrid: (grid: RegisteredGrid) => () => void;
getDepth: (gridId: string) => number;
isDropTargetEligible: (source: AbstractDraggable, targetGridId: string) => boolean;
@@ -135,14 +137,28 @@ interface BoardGridEditorContextValue {
cancelResize: () => void;
}
const BoardGridEditorContext = createContext<BoardGridEditorContextValue | null>(null);
const BoardGridEditorActionsContext = createContext<BoardGridEditorActionsContextValue | null>(null);
const BoardGridEditorInteractionContext = createContext<ReturnType<
typeof createGridInteractionStore<GridInteraction>
> | null>(null);
const useBoardGridEditor = () => {
const value = useContext(BoardGridEditorContext);
const useBoardGridEditorActions = () => {
const value = useContext(BoardGridEditorActionsContext);
if (!value) throw new Error("BoardGridEditorProvider is required");
return value;
};
const useBoardGridEditorInteractionStore = () => {
const store = useContext(BoardGridEditorInteractionContext);
if (!store) throw new Error("BoardGridEditorProvider is required");
return store;
};
const useBoardGridEditorInteraction = () => {
const store = useBoardGridEditorInteractionStore();
return useSyncExternalStore(store.subscribe, store.getSnapshot, store.getSnapshot);
};
/** One provider coordinates every root and nested grid on the board. */
export const BoardGridEditorProvider = ({ children }: PropsWithChildren) => {
const board = useRequiredBoard();
@@ -154,7 +170,7 @@ export const BoardGridEditorProvider = ({ children }: PropsWithChildren) => {
const activeRef = useRef<ActiveTransaction | null>(null);
const renderedInteractionRef = useRef<GridInteraction | null>(null);
const collisionFrameRef = useRef(0);
const [interaction, setInteraction] = useState<GridInteraction | null>(null);
const [interactionStore] = useState(() => createGridInteractionStore<GridInteraction>());
const [overlayZoom, setOverlayZoom] = useState(1);
const parentGridById = useMemo(
() =>
@@ -175,21 +191,9 @@ export const BoardGridEditorProvider = ({ children }: PropsWithChildren) => {
collisionFrameRef.current = 0;
activeRef.current = null;
renderedInteractionRef.current = null;
setInteraction(null);
interactionStore.publish(null);
document.body.removeAttribute("data-board-grid-interacting");
}, []);
useLayoutEffect(() => {
const active = activeRef.current;
renderedInteractionRef.current =
interaction &&
active &&
interaction.mode === active.mode &&
interaction.activeId === active.transaction.activeId &&
interaction.sourceGridId === active.transaction.sourceGridId
? interaction
: null;
}, [interaction]);
}, [interactionStore]);
useEffect(() => clearInteraction, [clearInteraction, currentLayoutId]);
@@ -264,7 +268,7 @@ export const BoardGridEditorProvider = ({ children }: PropsWithChildren) => {
previewRevision: 0,
};
document.body.setAttribute("data-board-grid-interacting", mode);
setInteraction({
interactionStore.publish({
activeId,
sourceGridId,
targetGridId: mode === "resize" ? sourceGridId : null,
@@ -281,7 +285,7 @@ export const BoardGridEditorProvider = ({ children }: PropsWithChildren) => {
return false;
}
},
[announce, clearInteraction, getCurrentState, t],
[announce, clearInteraction, getCurrentState, interactionStore, t],
);
const updateDragPreview = useCallback(
@@ -296,7 +300,7 @@ export const BoardGridEditorProvider = ({ children }: PropsWithChildren) => {
const publishPreview = (preview: Omit<GridInteraction, "previewRevision">) => {
active.previewRevision += 1;
setInteraction({ ...preview, previewRevision: active.previewRevision });
interactionStore.publish({ ...preview, previewRevision: active.previewRevision });
};
const showInvalidPreview = (targetGridId: string | null, previewKey: string) => {
@@ -405,7 +409,7 @@ export const BoardGridEditorProvider = ({ children }: PropsWithChildren) => {
valid: true,
});
},
[canAcceptDrop, isDropTargetEligible],
[canAcceptDrop, interactionStore, isDropTargetEligible],
);
const commitActiveTransaction = useCallback(
@@ -572,49 +576,52 @@ export const BoardGridEditorProvider = ({ children }: PropsWithChildren) => {
[beginTransaction],
);
const applyResizePreview = useCallback((input: ResizePreviewInput) => {
const active = activeRef.current;
if (!active) return false;
active.mode = "resize";
active.targetGridId = active.transaction.sourceGridId;
const applyResizePreview = useCallback(
(input: ResizePreviewInput) => {
const active = activeRef.current;
if (!active) return false;
active.mode = "resize";
active.targetGridId = active.transaction.sourceGridId;
const key = `${input.direction}:${input.deltaColumns}:${input.deltaRows}:${input.minWidth}:${input.minHeight}`;
if (active.lastPreviewKey === key) return active.valid;
active.lastPreviewKey = key;
const result = previewGridResize(active.transaction, input);
active.valid = result.accepted;
if (!result.accepted) {
const key = `${input.direction}:${input.deltaColumns}:${input.deltaRows}:${input.minWidth}:${input.minHeight}`;
if (active.lastPreviewKey === key) return active.valid;
active.lastPreviewKey = key;
const result = previewGridResize(active.transaction, input);
active.valid = result.accepted;
if (!result.accepted) {
active.previewRevision += 1;
interactionStore.publish({
activeId: active.transaction.activeId,
sourceGridId: active.transaction.sourceGridId,
targetGridId: active.transaction.sourceGridId,
targetPlacement: null,
state: cancelGridTransaction(active.transaction),
mode: "resize",
valid: false,
previewRevision: active.previewRevision,
});
return false;
}
active.transaction = result.transaction;
active.previewRevision += 1;
setInteraction({
interactionStore.publish({
activeId: active.transaction.activeId,
sourceGridId: active.transaction.sourceGridId,
targetGridId: active.transaction.sourceGridId,
targetPlacement: null,
state: cancelGridTransaction(active.transaction),
targetPlacement:
result.transaction.preview.grids
.find((grid) => grid.id === active.transaction.sourceGridId)
?.placements.find((placement) => placement.id === active.transaction.activeId) ?? null,
state: result.transaction.preview,
mode: "resize",
valid: false,
valid: true,
previewRevision: active.previewRevision,
});
return false;
}
active.transaction = result.transaction;
active.previewRevision += 1;
setInteraction({
activeId: active.transaction.activeId,
sourceGridId: active.transaction.sourceGridId,
targetGridId: active.transaction.sourceGridId,
targetPlacement:
result.transaction.preview.grids
.find((grid) => grid.id === active.transaction.sourceGridId)
?.placements.find((placement) => placement.id === active.transaction.activeId) ?? null,
state: result.transaction.preview,
mode: "resize",
valid: true,
previewRevision: active.previewRevision,
});
return true;
}, []);
return true;
},
[interactionStore],
);
const previewResize = useCallback(
(input: ResizePreviewInput) => {
@@ -625,9 +632,8 @@ export const BoardGridEditorProvider = ({ children }: PropsWithChildren) => {
[applyResizePreview],
);
const value = useMemo<BoardGridEditorContextValue>(
const actions = useMemo<BoardGridEditorActionsContextValue>(
() => ({
interaction,
registerGrid,
getDepth,
isDropTargetEligible,
@@ -642,113 +648,115 @@ export const BoardGridEditorProvider = ({ children }: PropsWithChildren) => {
canAcceptDrop,
cancelActiveInteraction,
commitActiveTransaction,
interaction,
isDropTargetEligible,
previewResize,
getDepth,
registerGrid,
],
);
useEffect(() => {
if (interaction?.mode !== "resize") return;
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key !== "Escape") return;
event.preventDefault();
cancelActiveInteraction();
};
window.addEventListener("keydown", handleKeyDown, { capture: true });
return () => window.removeEventListener("keydown", handleKeyDown, { capture: true });
}, [cancelActiveInteraction, interaction?.mode]);
const syncRenderedInteraction = useCallback((interaction: GridInteraction | null) => {
const active = activeRef.current;
renderedInteractionRef.current =
interaction &&
active &&
interaction.mode === active.mode &&
interaction.activeId === active.transaction.activeId &&
interaction.sourceGridId === active.transaction.sourceGridId
? interaction
: null;
}, []);
return (
<BoardGridEditorContext.Provider value={value}>
<DragDropProvider
plugins={configureEditorPlugins}
onCollision={handleCollision}
onBeforeDragStart={handleBeforeDragStart}
onDragStart={handleDragStart}
onDragMove={handleDragMove}
onDragOver={handleDragOver}
onDragEnd={handleDragEnd}
>
{children}
<DragOverlay
className="board-grid-drag-overlay"
dropAnimation={null}
style={
{
zoom: overlayZoom,
"--board-grid-overlay-content-zoom": 1 / overlayZoom,
} as CSSProperties
}
<BoardGridEditorActionsContext.Provider value={actions}>
<BoardGridEditorInteractionContext.Provider value={interactionStore}>
<DragDropProvider
plugins={configureEditorPlugins}
onCollision={handleCollision}
onBeforeDragStart={handleBeforeDragStart}
onDragStart={handleDragStart}
onDragMove={handleDragMove}
onDragOver={handleDragOver}
onDragEnd={handleDragEnd}
>
{(source) => {
const data = getGridEntryData(source);
return data ? <div className="board-grid-drag-overlay-card">{data.label}</div> : null;
}}
</DragOverlay>
<GridCollisionSynchronizer interaction={interaction} />
</DragDropProvider>
</BoardGridEditorContext.Provider>
{children}
<DragOverlay
className="board-grid-drag-overlay"
dropAnimation={null}
style={
{
zoom: overlayZoom,
"--board-grid-overlay-content-zoom": 1 / overlayZoom,
} as CSSProperties
}
>
{(source) => {
const data = getGridEntryData(source);
return data ? <div className="board-grid-drag-overlay-card">{data.label}</div> : null;
}}
</DragOverlay>
<GridInteractionEffects syncRenderedInteraction={syncRenderedInteraction} />
</DragDropProvider>
</BoardGridEditorInteractionContext.Provider>
</BoardGridEditorActionsContext.Provider>
);
};
const GridCollisionSynchronizer = ({ interaction }: { interaction: GridInteraction | null }) => {
const GridInteractionEffects = ({
syncRenderedInteraction,
}: {
syncRenderedInteraction: (interaction: GridInteraction | null) => void;
}) => {
const interaction = useBoardGridEditorInteraction();
useLayoutEffect(() => syncRenderedInteraction(interaction), [interaction, syncRenderedInteraction]);
return <GridCollisionSynchronizer previewRevision={interaction?.previewRevision ?? null} />;
};
const GridCollisionSynchronizer = ({ previewRevision }: { previewRevision: number | null }) => {
const manager = useDragDropManager();
const geometryKey = interaction ? getGridGeometryKey(interaction.state) : null;
useLayoutEffect(() => {
if (!geometryKey || !manager) return;
if (previewRevision === null || !manager) return;
for (const target of manager.registry.droppables.value) target.refreshShape();
manager.collisionObserver.forceUpdate(true);
}, [geometryKey, manager]);
}, [manager, previewRevision]);
return null;
};
/** A section-local dnd-kit canvas registered with the board transaction. */
export default function GridEditor({ sectionId, columnCount, rowCount, placements, className }: GridEditorProps) {
export default function GridEditor({
sectionId,
columnCount,
rowCount,
maxRowCount,
placements,
className,
}: GridEditorProps) {
const t = useI18n();
const { section, items, innerSections } = useSectionContext();
const { interaction, registerGrid, getDepth, isDropTargetEligible } = useBoardGridEditor();
const { registerGrid, getDepth, isDropTargetEligible } = useBoardGridEditorActions();
const interactionStore = useBoardGridEditorInteractionStore();
const gridRef = useRef<HTMLDivElement>(null);
const [entryElements] = useState(() => new Map<string, HTMLElement>());
const parentGridId = "parentSectionId" in section ? section.parentSectionId : null;
const ownerPlacementId = "parentSectionId" in section ? section.id : null;
const depth = getDepth(sectionId);
const previewGrid = interaction?.state.grids.find((grid) => grid.id === sectionId);
const controlledById = useMemo(() => new Map(placements.map((placement) => [placement.id, placement])), [placements]);
const renderPlacements = useMemo(() => {
if (!previewGrid || !interaction) return placements;
if (interaction.sourceGridId === sectionId) {
if (interaction.mode === "resize") return previewGrid.placements;
const original = controlledById.get(interaction.activeId);
if (!original) return previewGrid.placements;
return previewGrid.placements.some((placement) => placement.id === interaction.activeId)
? previewGrid.placements.map((placement) => (placement.id === interaction.activeId ? original : placement))
: [...previewGrid.placements, original];
}
return previewGrid.placements.filter((placement) => placement.id !== interaction.activeId);
}, [controlledById, interaction, placements, previewGrid, sectionId]);
const entryById = useMemo(
() => new Map([...items, ...innerSections].map((entry) => [entry.id, entry])),
[innerSections, items],
const labelById = useMemo(
() =>
new Map(
[...items, ...innerSections].map((entry) => [
entry.id,
entry.type === "item"
? entry.advancedOptions.title?.trim() || t(`widget.${entry.kind}.name`)
: entry.options.title.trim() || t("section.container.untitled"),
]),
),
[innerSections, items, t],
);
const targetPlacement =
interaction?.valid && interaction.targetGridId === sectionId ? interaction.targetPlacement : null;
const growsDuringDrag =
interaction?.mode === "drag" &&
(interaction.targetGridId === sectionId ||
(interaction.targetGridId === null && interaction.sourceGridId === sectionId));
const participatesInInteraction =
interaction && (interaction.sourceGridId === sectionId || interaction.targetGridId === sectionId);
const previewRowCount = getLayoutRowCount(previewGrid?.placements ?? renderPlacements);
const renderedRowCount =
section.kind !== "container" && participatesInInteraction
? Math.max(rowCount, previewRowCount) + (growsDuringDrag ? 1 : 0)
: rowCount;
const { ref: droppableRef, isDropTarget } = useDroppable<GridTargetData>({
const { ref: droppableRef } = useDroppable<GridTargetData>({
id: `board-grid:${sectionId}`,
type: GRID_TARGET_TYPE,
data: { kind: GRID_TARGET_TYPE, sectionId },
@@ -759,7 +767,6 @@ export default function GridEditor({ sectionId, columnCount, rowCount, placement
// through to an accepting parent grid underneath the pointer.
accept: (source) => isDropTargetEligible(source, sectionId),
});
const isIntentTarget = interaction?.mode === "drag" ? interaction.targetGridId === sectionId : isDropTarget;
const setGridRef = useCallback(
(element: HTMLDivElement | null) => {
@@ -768,6 +775,13 @@ export default function GridEditor({ sectionId, columnCount, rowCount, placement
},
[droppableRef],
);
const registerEntryElement = useCallback(
(id: string, element: HTMLElement | null) => {
if (element) entryElements.set(id, element);
else entryElements.delete(id);
},
[entryElements],
);
useLayoutEffect(() => {
const element = gridRef.current;
@@ -775,23 +789,14 @@ export default function GridEditor({ sectionId, columnCount, rowCount, placement
return registerGrid({
id: sectionId,
columnCount,
maxRowCount: section.kind === "container" ? rowCount : null,
maxRowCount,
parentGridId,
ownerPlacementId,
placements,
depth,
element,
});
}, [columnCount, depth, ownerPlacementId, parentGridId, placements, registerGrid, rowCount, section.kind, sectionId]);
useLayoutEffect(() => {
const viewport = gridRef.current?.parentElement;
if (!viewport?.hasAttribute("data-section-id")) return;
viewport.style.setProperty("--board-grid-drag-height", `${getLogicalTrackSize(renderedRowCount)}px`);
return () => {
viewport.style.removeProperty("--board-grid-drag-height");
};
}, [renderedRowCount]);
}, [columnCount, depth, maxRowCount, ownerPlacementId, parentGridId, placements, registerGrid, sectionId]);
return (
<div
@@ -799,48 +804,36 @@ export default function GridEditor({ sectionId, columnCount, rowCount, placement
className={className}
data-grid-section-id={sectionId}
data-grid-depth={depth}
data-dnd-drop-target={isIntentTarget ? "true" : "false"}
data-dnd-drop-valid={isIntentTarget ? String(targetPlacement !== null) : undefined}
data-dnd-preview-revision={interaction?.targetGridId === sectionId ? interaction.previewRevision : undefined}
style={
{
width: getLogicalTrackSize(columnCount),
height: getLogicalTrackSize(renderedRowCount),
height: getLogicalTrackSize(rowCount),
"--board-grid-pitch": `${LOGICAL_GRID_PITCH}px`,
} as CSSProperties
}
>
{renderPlacements.map((placement) => {
const entry = entryById.get(placement.id);
const label = entry
? entry.type === "item"
? entry.advancedOptions.title?.trim() || t(`widget.${entry.kind}.name`)
: entry.options.title.trim() || t("section.container.untitled")
: placement.id;
{placements.map((placement) => {
return (
<DndGridEntry
key={placement.id}
sectionId={sectionId}
placement={placement}
label={label}
label={labelById.get(placement.id) ?? placement.id}
columnCount={columnCount}
maxRowCount={section.kind === "container" ? rowCount : null}
maxRowCount={maxRowCount}
registerElement={registerEntryElement}
/>
);
})}
{targetPlacement && (
<div
className="board-grid-placeholder"
style={getLogicalItemStyle(targetPlacement)}
data-grid-placeholder-for={interaction?.activeId}
data-grid-placeholder-mode={interaction?.mode}
data-grid-x={targetPlacement.x}
data-grid-y={targetPlacement.y}
data-grid-w={targetPlacement.w}
data-grid-h={targetPlacement.h}
aria-hidden="true"
/>
)}
<GridPreviewLayer
sectionId={sectionId}
rowCount={rowCount}
maxRowCount={maxRowCount}
placements={placements}
gridRef={gridRef}
entryElements={entryElements}
interactionStore={interactionStore}
/>
<span
data-grid-runtime="homarr-dnd-kit-v1"
data-editor-section-id={sectionId}
@@ -851,24 +844,29 @@ export default function GridEditor({ sectionId, columnCount, rowCount, placement
);
}
interface DndGridEntryProps {
sectionId: string;
placement: SectionGridPlacement;
label: string;
columnCount: number;
maxRowCount: number | null;
}
const DndGridEntry = ({ sectionId, placement, label, columnCount, maxRowCount }: DndGridEntryProps) => {
const DndGridEntryComponent = ({
sectionId,
placement,
label,
columnCount,
maxRowCount,
registerElement,
}: DndGridEntryProps) => {
const { acquireContainer } = useBoardGridPortalHost();
const { interaction, beginResize, previewResize, completeResize, cancelResize } = useBoardGridEditor();
const { beginResize, previewResize, completeResize, cancelResize } = useBoardGridEditorActions();
const [isSectionChromeActive, setIsSectionChromeActive] = useState(false);
const [continuousResize, setContinuousResize] = useState<{
placement: SectionGridPlacement;
valid: boolean;
} | null>(null);
const mountRef = useRef<HTMLDivElement>(null);
const shellRef = useRef<HTMLDivElement>(null);
const placementRef = useRef(placement);
placementRef.current = placement;
const resizeOutlineController = useMemo(
() =>
createGridResizeOutlineController(
() => shellRef.current,
() => placementRef.current,
),
[],
);
const sourceData = useMemo<GridEntryData>(
() => ({ kind: GRID_ENTRY_TYPE, sectionId, placement, label }),
[label, placement, sectionId],
@@ -896,7 +894,7 @@ const DndGridEntry = ({ sectionId, placement, label, columnCount, maxRowCount }:
const {
ref: draggableRef,
handleRef,
isDragging,
isDragSource,
isDropping,
} = useDraggable<GridEntryData>({
id: placement.id,
@@ -908,11 +906,14 @@ const DndGridEntry = ({ sectionId, placement, label, columnCount, maxRowCount }:
const setShellRef = useCallback(
(element: HTMLDivElement | null) => {
shellRef.current = element;
registerElement(placement.id, element);
draggableRef(element);
},
[draggableRef],
[draggableRef, placement.id, registerElement],
);
useEffect(() => () => resizeOutlineController.destroy(), [resizeOutlineController]);
useLayoutEffect(() => {
const mount = mountRef.current;
if (!mount) return;
@@ -921,15 +922,7 @@ const DndGridEntry = ({ sectionId, placement, label, columnCount, maxRowCount }:
const handleSelector =
placement.type === "section" ? "[data-grid-container-drag-handle]" : "[data-editor-grid-entry]";
const bindHandle = () => handleRef(container.querySelector<HTMLElement>(handleSelector));
bindHandle();
const observer = new MutationObserver(bindHandle);
observer.observe(container, { childList: true, subtree: true });
return () => {
observer.disconnect();
handleRef(null);
};
return observeGridDragHandle(container, handleSelector, handleRef);
}, [acquireContainer, handleRef, placement.id, placement.type]);
useLayoutEffect(() => {
@@ -955,22 +948,11 @@ const DndGridEntry = ({ sectionId, placement, label, columnCount, maxRowCount }:
return () => shell.removeEventListener("pointerdown", handlePointerDownCapture, { capture: true });
}, [placement.type]);
const isActive = interaction?.activeId === placement.id;
const style = getLogicalItemStyle(placement) as CSSProperties;
const continuousResizeStyle =
continuousResize && isActive
? getLogicalItemStyle({
...continuousResize.placement,
x: continuousResize.placement.x - placement.x,
y: continuousResize.placement.y - placement.y,
})
: undefined;
return (
<div
ref={setShellRef}
className="board-grid-entry"
style={style}
style={getLogicalItemStyle(placement) as CSSProperties}
data-grid-item-id={placement.id}
data-grid-item-type={placement.type}
data-grid-x={placement.x}
@@ -978,37 +960,29 @@ const DndGridEntry = ({ sectionId, placement, label, columnCount, maxRowCount }:
data-grid-w={placement.w}
data-grid-h={placement.h}
data-grid-section-chrome-active={placement.type === "section" ? String(isSectionChromeActive) : undefined}
data-dnd-active={isActive ? "true" : undefined}
data-dnd-drag-source={isDragging ? "true" : undefined}
data-dnd-drag-source={isDragSource ? "true" : undefined}
data-dnd-dropping={isDropping ? "true" : undefined}
>
<div ref={mountRef} className="board-grid-content-mount" />
{continuousResizeStyle && (
<div
className="board-grid-resize-outline"
style={continuousResizeStyle}
data-grid-resize-outline
data-grid-resize-valid={String(continuousResize?.valid)}
aria-hidden="true"
/>
)}
<GridResizeHandles
sectionId={sectionId}
placement={placement}
label={label}
disabled={interaction !== null || (placement.type === "section" && !isSectionChromeActive)}
disabled={placement.type === "section" && !isSectionChromeActive}
columnCount={columnCount}
maxRowCount={maxRowCount}
beginResize={beginResize}
previewResize={previewResize}
completeResize={completeResize}
cancelResize={cancelResize}
setContinuousResize={setContinuousResize}
scheduleResizeOutline={resizeOutlineController.schedule}
/>
</div>
);
};
const DndGridEntry = memo(DndGridEntryComponent, areDndGridEntryPropsEqual);
interface GridResizeHandlesProps {
sectionId: string;
placement: SectionGridPlacement;
@@ -1016,11 +990,11 @@ interface GridResizeHandlesProps {
disabled: boolean;
columnCount: number;
maxRowCount: number | null;
beginResize: BoardGridEditorContextValue["beginResize"];
previewResize: BoardGridEditorContextValue["previewResize"];
completeResize: BoardGridEditorContextValue["completeResize"];
cancelResize: BoardGridEditorContextValue["cancelResize"];
setContinuousResize: (preview: { placement: SectionGridPlacement; valid: boolean } | null) => void;
beginResize: BoardGridEditorActionsContextValue["beginResize"];
previewResize: BoardGridEditorActionsContextValue["previewResize"];
completeResize: BoardGridEditorActionsContextValue["completeResize"];
cancelResize: BoardGridEditorActionsContextValue["cancelResize"];
scheduleResizeOutline: (preview: { placement: SectionGridPlacement; valid: boolean } | null) => void;
}
const RESIZE_DIRECTIONS: readonly GridResizeDirection[] = ["n", "ne", "e", "se", "s", "sw", "w", "nw"];
@@ -1036,15 +1010,26 @@ const GridResizeHandles = ({
previewResize,
completeResize,
cancelResize,
setContinuousResize,
scheduleResizeOutline,
}: GridResizeHandlesProps) => {
const pointerStartRef = useRef<{
id: number;
handle: HTMLSpanElement;
x: number;
y: number;
direction: GridResizeDirection;
visualScale: { x: number; y: number };
placement: SectionGridPlacement;
} | null>(null);
const escapeListenerRef = useRef<((event: KeyboardEvent) => void) | null>(null);
useEffect(
() => () => {
const listener = escapeListenerRef.current;
if (listener) window.removeEventListener("keydown", listener, { capture: true });
},
[],
);
const start = (direction: GridResizeDirection) =>
beginResize({ activeId: placement.id, sourceGridId: sectionId, label }) && direction;
@@ -1058,6 +1043,44 @@ const GridResizeHandles = ({
minHeight: placement.minH ?? 1,
});
const removeEscapeListener = () => {
const listener = escapeListenerRef.current;
if (!listener) return;
window.removeEventListener("keydown", listener, { capture: true });
escapeListenerRef.current = null;
};
const finishResize = (pointerId: number, current: { x: number; y: number } | null, canceled: boolean) => {
const started = pointerStartRef.current;
if (!started || started.id !== pointerId) return;
pointerStartRef.current = null;
scheduleResizeOutline(null);
removeEscapeListener();
if (started.handle.hasPointerCapture(pointerId)) started.handle.releasePointerCapture(pointerId);
if (canceled || !current) {
cancelResize();
return;
}
const delta = getSnappedGridDelta({
initial: { x: started.x, y: started.y },
current,
visualScale: started.visualScale,
});
if (delta) update(started.direction, delta.x, delta.y);
completeResize();
};
const handleEscape = (event: KeyboardEvent) => {
const started = pointerStartRef.current;
if (event.key !== "Escape" || !started) return;
event.preventDefault();
event.stopPropagation();
finishResize(started.id, null, true);
};
const handlePointerDown = (event: ReactPointerEvent<HTMLSpanElement>, direction: GridResizeDirection) => {
const visualScale = getEntryVisualScale(event.currentTarget.parentElement, placement);
if (
@@ -1077,7 +1100,17 @@ const GridResizeHandles = ({
cancelResize();
return;
}
pointerStartRef.current = { id: event.pointerId, x: event.clientX, y: event.clientY, direction, visualScale };
pointerStartRef.current = {
id: event.pointerId,
handle: event.currentTarget,
x: event.clientX,
y: event.clientY,
direction,
visualScale,
placement: { ...placement },
};
escapeListenerRef.current = handleEscape;
window.addEventListener("keydown", handleEscape, { capture: true });
};
const handlePointerMove = (event: ReactPointerEvent<HTMLSpanElement>) => {
@@ -1093,38 +1126,23 @@ const GridResizeHandles = ({
const continuousDelta = getContinuousGridDelta(input);
if (!snappedDelta || !continuousDelta) return;
const valid = update(started.direction, snappedDelta.x, snappedDelta.y);
setContinuousResize({
scheduleResizeOutline({
placement: getContinuousResizePlacement({
placement,
placement: started.placement,
direction: started.direction,
deltaColumns: continuousDelta.x,
deltaRows: continuousDelta.y,
columnCount,
maxRowCount,
minWidth: placement.minW ?? 1,
minHeight: placement.minH ?? 1,
minWidth: started.placement.minW ?? 1,
minHeight: started.placement.minH ?? 1,
}),
valid,
});
};
const handlePointerEnd = (event: ReactPointerEvent<HTMLSpanElement>, canceled: boolean) => {
const started = pointerStartRef.current;
if (!started || started.id !== event.pointerId) return;
pointerStartRef.current = null;
setContinuousResize(null);
if (event.currentTarget.hasPointerCapture(event.pointerId))
event.currentTarget.releasePointerCapture(event.pointerId);
if (canceled) cancelResize();
else {
const delta = getSnappedGridDelta({
initial: { x: started.x, y: started.y },
current: { x: event.clientX, y: event.clientY },
visualScale: started.visualScale,
});
if (delta) update(started.direction, delta.x, delta.y);
completeResize();
}
finishResize(event.pointerId, { x: event.clientX, y: event.clientY }, canceled);
};
return RESIZE_DIRECTIONS.map((direction) => (
@@ -1138,12 +1156,7 @@ const GridResizeHandles = ({
onPointerMove={handlePointerMove}
onPointerUp={(event) => handlePointerEnd(event, false)}
onPointerCancel={(event) => handlePointerEnd(event, true)}
onLostPointerCapture={(event) => {
if (pointerStartRef.current?.id !== event.pointerId) return;
pointerStartRef.current = null;
setContinuousResize(null);
cancelResize();
}}
onLostPointerCapture={(event) => finishResize(event.pointerId, null, true)}
/>
));
};
@@ -1339,18 +1352,3 @@ const getGridTargetData = (target: { data: Record<string, unknown> }): GridTarge
const data = target.data as Partial<GridTargetData>;
return data.kind === GRID_TARGET_TYPE && data.sectionId ? (data as GridTargetData) : null;
};
const getGridGeometryKey = (state: TransactionalGridState<SectionGridPlacement>) =>
JSON.stringify(
state.grids
.map((grid) => ({
id: grid.id,
columnCount: grid.columnCount,
maxRowCount: grid.maxRowCount,
parentGridId: grid.parentGridId,
placements: grid.placements
.map(({ id, x, y, w, h }) => ({ id, x, y, w, h }))
.toSorted((first, second) => first.id.localeCompare(second.id)),
}))
.toSorted((first, second) => first.id.localeCompare(second.id)),
);
@@ -0,0 +1,25 @@
import type { SectionGridPlacement } from "./use-grid-layout-actions";
export interface DndGridEntryProps {
sectionId: string;
placement: SectionGridPlacement;
label: string;
columnCount: number;
maxRowCount: number | null;
registerElement: (id: string, element: HTMLElement | null) => void;
}
export const areDndGridEntryPropsEqual = (previous: DndGridEntryProps, next: DndGridEntryProps) =>
previous.sectionId === next.sectionId &&
previous.label === next.label &&
previous.columnCount === next.columnCount &&
previous.maxRowCount === next.maxRowCount &&
previous.registerElement === next.registerElement &&
previous.placement.id === next.placement.id &&
previous.placement.type === next.placement.type &&
previous.placement.x === next.placement.x &&
previous.placement.y === next.placement.y &&
previous.placement.w === next.placement.w &&
previous.placement.h === next.placement.h &&
previous.placement.minW === next.placement.minW &&
previous.placement.minH === next.placement.minH;
@@ -0,0 +1,58 @@
interface GridScopedInteraction {
sourceGridId: string;
targetGridId: string | null;
}
export interface GridInteractionStore<TInteraction extends GridScopedInteraction> {
getSnapshot: () => TInteraction | null;
getGridSnapshot: (sectionId: string) => TInteraction | null;
publish: (interaction: TInteraction | null) => void;
subscribe: (listener: () => void) => () => void;
subscribeGrid: (sectionId: string, listener: () => void) => () => void;
}
const getAffectedGridIds = (interaction: GridScopedInteraction | null) => {
const ids = new Set<string>();
if (interaction) {
ids.add(interaction.sourceGridId);
if (interaction.targetGridId) ids.add(interaction.targetGridId);
}
return ids;
};
export const createGridInteractionStore = <
TInteraction extends GridScopedInteraction,
>(): GridInteractionStore<TInteraction> => {
let snapshot: TInteraction | null = null;
const listeners = new Set<() => void>();
const gridListeners = new Map<string, Set<() => void>>();
return {
getSnapshot: () => snapshot,
getGridSnapshot: (sectionId) =>
snapshot && (snapshot.sourceGridId === sectionId || snapshot.targetGridId === sectionId) ? snapshot : null,
publish: (interaction) => {
if (Object.is(snapshot, interaction)) return;
const affectedGridIds = getAffectedGridIds(snapshot);
for (const sectionId of getAffectedGridIds(interaction)) affectedGridIds.add(sectionId);
snapshot = interaction;
for (const listener of listeners) listener();
for (const sectionId of affectedGridIds) {
for (const listener of gridListeners.get(sectionId) ?? []) listener();
}
},
subscribe: (listener) => {
listeners.add(listener);
return () => listeners.delete(listener);
},
subscribeGrid: (sectionId, listener) => {
const sectionListeners = gridListeners.get(sectionId) ?? new Set<() => void>();
sectionListeners.add(listener);
gridListeners.set(sectionId, sectionListeners);
return () => {
sectionListeners.delete(listener);
if (sectionListeners.size === 0) gridListeners.delete(sectionId);
};
},
};
};
@@ -0,0 +1,101 @@
import { getLogicalTrackSize, LOGICAL_GRID_PITCH } from "~/components/board/layout";
import type { GridPlacement } from "~/components/board/layout";
export interface GridPreviewDomState {
activeId: string | null;
changedIds: Set<string>;
}
interface SyncGridPreviewGeometryInput {
elements: ReadonlyMap<string, HTMLElement>;
placements: readonly GridPlacement[];
previewPlacements: readonly GridPlacement[] | null;
activeId: string | null;
mode: "drag" | "resize" | null;
previous: GridPreviewDomState;
}
export const createGridPreviewDomState = (): GridPreviewDomState => ({
activeId: null,
changedIds: new Set(),
});
export const syncGridPreviewGeometry = ({
elements,
placements,
previewPlacements,
activeId,
mode,
previous,
}: SyncGridPreviewGeometryInput): GridPreviewDomState => {
const controlledById = new Map(placements.map((placement) => [placement.id, placement]));
const changedIds = new Set<string>();
if (previewPlacements && mode) {
for (const preview of previewPlacements) {
const controlled = controlledById.get(preview.id);
const element = elements.get(preview.id);
if (!controlled || !element || (mode === "drag" && preview.id === activeId)) continue;
if (arePlacementsEqual(controlled, preview)) continue;
applyPreviewGeometry(element, controlled, preview);
changedIds.add(preview.id);
}
}
for (const id of previous.changedIds) {
if (changedIds.has(id)) continue;
const element = elements.get(id);
const controlled = controlledById.get(id);
if (element && controlled) clearPreviewGeometry(element, controlled);
}
if (previous.activeId && previous.activeId !== activeId) {
elements.get(previous.activeId)?.removeAttribute("data-dnd-active");
}
if (activeId) elements.get(activeId)?.setAttribute("data-dnd-active", "true");
return { activeId, changedIds };
};
export const clearGridPreviewGeometry = (
elements: ReadonlyMap<string, HTMLElement>,
placements: readonly GridPlacement[],
state: GridPreviewDomState,
) => {
const controlledById = new Map(placements.map((placement) => [placement.id, placement]));
for (const id of state.changedIds) {
const element = elements.get(id);
const controlled = controlledById.get(id);
if (element && controlled) clearPreviewGeometry(element, controlled);
}
if (state.activeId) elements.get(state.activeId)?.removeAttribute("data-dnd-active");
};
const applyPreviewGeometry = (element: HTMLElement, controlled: GridPlacement, preview: GridPlacement) => {
element.style.setProperty("--board-grid-preview-x", `${(preview.x - controlled.x) * LOGICAL_GRID_PITCH}px`);
element.style.setProperty("--board-grid-preview-y", `${(preview.y - controlled.y) * LOGICAL_GRID_PITCH}px`);
element.style.setProperty("--board-grid-preview-width", `${getLogicalTrackSize(preview.w)}px`);
element.style.setProperty("--board-grid-preview-height", `${getLogicalTrackSize(preview.h)}px`);
element.setAttribute("data-grid-preview", "true");
setPlacementAttributes(element, preview);
};
const clearPreviewGeometry = (element: HTMLElement, controlled: GridPlacement) => {
element.style.removeProperty("--board-grid-preview-x");
element.style.removeProperty("--board-grid-preview-y");
element.style.removeProperty("--board-grid-preview-width");
element.style.removeProperty("--board-grid-preview-height");
element.removeAttribute("data-grid-preview");
setPlacementAttributes(element, controlled);
};
const setPlacementAttributes = (element: HTMLElement, placement: GridPlacement) => {
element.dataset.gridX = String(placement.x);
element.dataset.gridY = String(placement.y);
element.dataset.gridW = String(placement.w);
element.dataset.gridH = String(placement.h);
};
const arePlacementsEqual = (first: GridPlacement, second: GridPlacement) =>
first.x === second.x && first.y === second.y && first.w === second.w && first.h === second.h;
@@ -0,0 +1,136 @@
"use client";
import type { RefObject } from "react";
import { useCallback, useLayoutEffect, useRef, useSyncExternalStore } from "react";
import { getLayoutRowCount, getLogicalItemStyle, getLogicalTrackSize } from "~/components/board/layout";
import type { TransactionalGridState } from "./dnd";
import type { GridInteractionStore } from "./grid-interaction-store";
import { clearGridPreviewGeometry, createGridPreviewDomState, syncGridPreviewGeometry } from "./grid-preview-geometry";
import type { SectionGridPlacement } from "./use-grid-layout-actions";
export interface GridInteraction {
activeId: string;
sourceGridId: string;
targetGridId: string | null;
targetPlacement: SectionGridPlacement | null;
state: TransactionalGridState<SectionGridPlacement>;
mode: "drag" | "resize";
valid: boolean;
previewRevision: number;
}
interface GridPreviewLayerProps {
sectionId: string;
rowCount: number;
maxRowCount: number | null;
placements: readonly SectionGridPlacement[];
gridRef: RefObject<HTMLDivElement | null>;
entryElements: ReadonlyMap<string, HTMLElement>;
interactionStore: GridInteractionStore<GridInteraction>;
}
export const GridPreviewLayer = ({
sectionId,
rowCount,
maxRowCount,
placements,
gridRef,
entryElements,
interactionStore,
}: GridPreviewLayerProps) => {
const subscribe = useCallback(
(listener: () => void) => interactionStore.subscribeGrid(sectionId, listener),
[interactionStore, sectionId],
);
const getSnapshot = useCallback(() => interactionStore.getGridSnapshot(sectionId), [interactionStore, sectionId]);
const interaction = useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
const previewGrid = interaction?.state.grids.find((grid) => grid.id === sectionId);
const previewStateRef = useRef(createGridPreviewDomState());
const placementsRef = useRef(placements);
const isDragging = interaction?.mode === "drag";
const isTargetGrid = isDragging && interaction.targetGridId === sectionId;
let targetPlacement: SectionGridPlacement | null = null;
if (interaction?.valid && interaction.targetGridId === sectionId) {
targetPlacement = interaction.targetPlacement;
}
const growsDuringDrag =
isDragging && (isTargetGrid || (interaction.targetGridId === null && interaction.sourceGridId === sectionId));
const previewRowCount = getLayoutRowCount(previewGrid?.placements ?? placements);
let renderedRowCount = rowCount;
if (maxRowCount === null && interaction) {
renderedRowCount = Math.max(rowCount, previewRowCount);
if (growsDuringDrag) renderedRowCount += 1;
}
useLayoutEffect(() => {
placementsRef.current = placements;
previewStateRef.current = syncGridPreviewGeometry({
elements: entryElements,
placements,
previewPlacements: previewGrid?.placements ?? null,
activeId: interaction?.activeId ?? null,
mode: interaction?.mode ?? null,
previous: previewStateRef.current,
});
const grid = gridRef.current;
if (!grid) return;
grid.dataset.dndDropTarget = String(isTargetGrid);
if (isTargetGrid) grid.dataset.dndDropValid = String(targetPlacement !== null);
else delete grid.dataset.dndDropValid;
if (interaction?.targetGridId === sectionId) {
grid.dataset.dndPreviewRevision = String(interaction.previewRevision);
} else {
delete grid.dataset.dndPreviewRevision;
}
grid.style.height = `${getLogicalTrackSize(renderedRowCount)}px`;
const viewport = grid.parentElement;
if (viewport?.hasAttribute("data-section-id")) {
viewport.style.setProperty("--board-grid-drag-height", `${getLogicalTrackSize(renderedRowCount)}px`);
}
}, [
entryElements,
gridRef,
interaction,
isTargetGrid,
placements,
previewGrid?.placements,
renderedRowCount,
sectionId,
targetPlacement,
]);
useLayoutEffect(
() => () => {
clearGridPreviewGeometry(entryElements, placementsRef.current, previewStateRef.current);
const grid = gridRef.current;
if (!grid) return;
grid.removeAttribute("data-dnd-drop-target");
grid.removeAttribute("data-dnd-drop-valid");
grid.removeAttribute("data-dnd-preview-revision");
grid.parentElement?.style.removeProperty("--board-grid-drag-height");
},
[entryElements, gridRef],
);
if (!targetPlacement) return null;
return (
<div
className="board-grid-placeholder"
style={getLogicalItemStyle(targetPlacement)}
data-grid-placeholder-for={interaction?.activeId}
data-grid-placeholder-mode={interaction?.mode}
data-grid-x={targetPlacement.x}
data-grid-y={targetPlacement.y}
data-grid-w={targetPlacement.w}
data-grid-h={targetPlacement.h}
aria-hidden="true"
/>
);
};
@@ -0,0 +1,72 @@
import { getLogicalTrackSize, LOGICAL_GRID_PITCH } from "~/components/board/layout";
import type { GridPlacement } from "~/components/board/layout";
interface ResizeOutlinePreview {
placement: GridPlacement;
valid: boolean;
}
export const createGridResizeOutlineController = (
getShell: () => HTMLElement | null,
getControlledPlacement: () => GridPlacement,
) => {
let frame = 0;
let pending: ResizeOutlinePreview | null = null;
const remove = () => {
getShell()?.querySelector(":scope > [data-grid-resize-outline]")?.remove();
};
const flush = () => {
frame = 0;
const shell = getShell();
const preview = pending;
if (!shell || !preview) return;
const controlled = getControlledPlacement();
const visualX = toFiniteNumber(shell.dataset.gridX, controlled.x);
const visualY = toFiniteNumber(shell.dataset.gridY, controlled.y);
const outline =
shell.querySelector<HTMLElement>(":scope > [data-grid-resize-outline]") ?? createResizeOutline(shell);
outline.style.position = "absolute";
outline.style.left = `${(preview.placement.x - visualX) * LOGICAL_GRID_PITCH}px`;
outline.style.top = `${(preview.placement.y - visualY) * LOGICAL_GRID_PITCH}px`;
outline.style.width = `${getLogicalTrackSize(preview.placement.w)}px`;
outline.style.height = `${getLogicalTrackSize(preview.placement.h)}px`;
outline.dataset.gridResizeValid = String(preview.valid);
};
return {
schedule(preview: ResizeOutlinePreview | null) {
pending = preview;
if (!preview) {
window.cancelAnimationFrame(frame);
frame = 0;
remove();
return;
}
if (frame !== 0) return;
frame = window.requestAnimationFrame(flush);
},
destroy() {
pending = null;
window.cancelAnimationFrame(frame);
frame = 0;
remove();
},
};
};
const toFiniteNumber = (value: string | undefined, fallback: number) => {
const number = Number(value);
return Number.isFinite(number) ? number : fallback;
};
const createResizeOutline = (shell: HTMLElement) => {
const outline = document.createElement("div");
outline.className = "board-grid-resize-outline";
outline.dataset.gridResizeOutline = "";
outline.setAttribute("aria-hidden", "true");
shell.append(outline);
return outline;
};
@@ -150,6 +150,7 @@ export const SectionGrid = ({
const minimumViewportRowCount = useMinimumViewportRowCount(section.kind === "empty", canvasScale);
const contentRowCount = Math.max(1, getLayoutRowCount(displayPlacements));
const rowCount = Math.max(contentRowCount, requestedRowCount, minimumViewportRowCount);
const maxRowCount = section.kind === "container" || railPlacement !== "main" ? rowCount : null;
const logicalWidth = getLogicalTrackSize(columnCount);
const logicalHeight = getLogicalTrackSize(rowCount);
const canvasAttributes = isEditMode
@@ -164,7 +165,7 @@ export const SectionGrid = ({
innerSections: displayedInnerSections,
integrations,
columnCount,
maxRowCount: section.kind === "container" ? rowCount : null,
maxRowCount,
announce,
}}
>
@@ -182,6 +183,7 @@ export const SectionGrid = ({
sectionId={section.id}
columnCount={columnCount}
rowCount={rowCount}
maxRowCount={maxRowCount}
placements={displayPlacements}
className={combineClasses("board-grid-editor", classes.editorGrid)}
/>
@@ -0,0 +1,33 @@
import { describe, expect, test, vi } from "vitest";
import { observeGridDragHandle } from "../grid-drag-handle";
describe("grid drag handle", () => {
test("disconnects the observer after binding the first handle", async () => {
const container = document.createElement("div");
document.body.append(container);
const bind = vi.fn();
const cleanup = observeGridDragHandle(container, "[data-drag-handle]", bind);
expect(bind).not.toHaveBeenCalled();
container.append(document.createElement("span"));
await Promise.resolve();
expect(bind).not.toHaveBeenCalled();
const handle = document.createElement("button");
handle.dataset.dragHandle = "";
container.append(handle);
await vi.waitFor(() => expect(bind).toHaveBeenCalledOnce());
expect(bind).toHaveBeenLastCalledWith(handle);
handle.append(document.createElement("span"));
container.prepend(document.createElement("button"));
await Promise.resolve();
expect(bind).toHaveBeenCalledOnce();
cleanup();
expect(bind).toHaveBeenLastCalledWith(null);
container.remove();
});
});
@@ -0,0 +1,202 @@
import { act, createElement, memo } from "react";
import { createRoot } from "react-dom/client";
import type { Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, test } from "vitest";
import type { DndGridEntryProps } from "../grid-entry-memo";
import { areDndGridEntryPropsEqual } from "../grid-entry-memo";
import { createGridInteractionStore } from "../grid-interaction-store";
import type { GridInteraction } from "../grid-preview-layer";
import { GridPreviewLayer } from "../grid-preview-layer";
describe("grid entry memoization", () => {
let container: HTMLDivElement;
let root: Root;
let renders = 0;
let widgetRenders = 0;
const Probe = memo((props: DndGridEntryProps) => {
renders += 1;
return createElement(
"span",
{
"data-grid-id": props.placement.id,
"data-x": props.placement.x,
ref: (element: HTMLSpanElement | null) => props.registerElement(props.placement.id, element),
},
createElement(WidgetProbe),
);
}, areDndGridEntryPropsEqual);
const WidgetProbe = () => {
widgetRenders += 1;
return null;
};
beforeEach(() => {
container = document.createElement("div");
document.body.append(container);
root = createRoot(container);
renders = 0;
widgetRenders = 0;
});
afterEach(() => {
act(() => root.unmount());
container.remove();
});
test("skips cloned unchanged preview placements and renders changed entries", () => {
const props = createProps();
render(props);
expect(renders).toBe(1);
render({ ...props, placement: { ...props.placement } });
expect(renders).toBe(1);
render({ ...props, placement: { ...props.placement, x: 2 } });
expect(renders).toBe(2);
});
test("rerenders only the changed entry on a 100 item preview", () => {
const entries = Array.from({ length: 100 }, (_, index) => ({
...createProps(),
placement: { ...createProps().placement, id: `item-${index}`, x: index % 10, y: Math.floor(index / 10) },
}));
renderBoard(entries);
expect(renders).toBe(100);
renderBoard(entries.map((entry) => ({ ...entry, placement: { ...entry.placement } })));
expect(renders).toBe(100);
renderBoard(
entries.map((entry, index) =>
index === 42 ? { ...entry, placement: { ...entry.placement, y: entry.placement.y + 1 } } : entry,
),
);
expect(renders).toBe(101);
});
test("keeps 100 entry and widget renders frozen across interaction store previews", () => {
const elements = new Map<string, HTMLElement>();
const interactionStore = createGridInteractionStore<GridInteraction>();
const gridRef = { current: null as HTMLDivElement | null };
const setGridRef = (element: HTMLDivElement | null) => {
gridRef.current = element;
};
const registerElement = (id: string, element: HTMLElement | null) => {
if (element) elements.set(id, element);
else elements.delete(id);
};
const entries = Array.from({ length: 100 }, (_, index) => ({
...createProps(),
registerElement,
placement: { ...createProps().placement, id: `item-${index}`, x: index % 10, y: Math.floor(index / 10) },
}));
const renderPreviewBoard = (nextEntries: DndGridEntryProps[]) => {
act(() =>
root.render(
createElement(
"div",
{ "data-section-id": "root" },
createElement(
"div",
{ ref: setGridRef },
nextEntries.map((entry) => createElement(Probe, { ...entry, key: entry.placement.id })),
createElement(GridPreviewLayer, {
sectionId: "root",
rowCount: 10,
maxRowCount: null,
placements: nextEntries.map((entry) => entry.placement),
gridRef,
entryElements: elements,
interactionStore,
}),
),
),
),
);
};
renderPreviewBoard(entries);
expect(renders).toBe(100);
expect(widgetRenders).toBe(100);
for (let revision = 1; revision <= 20; revision += 1) {
const previewPlacements = entries.map((entry, index) =>
index === 42 ? { ...entry.placement, y: entry.placement.y + revision } : entry.placement,
);
act(() => {
interactionStore.publish({
activeId: "item-0",
sourceGridId: "root",
targetGridId: "root",
targetPlacement: previewPlacements[0] ?? null,
state: { grids: [{ id: "root", columnCount: 12, maxRowCount: null, placements: previewPlacements }] },
mode: "drag",
valid: true,
previewRevision: revision,
});
});
}
expect(renders).toBe(100);
expect(widgetRenders).toBe(100);
expect(elements.get("item-42")?.getAttribute("data-grid-preview")).toBe("true");
act(() => interactionStore.publish(null));
renderPreviewBoard(
entries.map((entry, index) =>
index === 42 ? { ...entry, placement: { ...entry.placement, y: entry.placement.y + 20 } } : entry,
),
);
expect(renders).toBe(101);
expect(widgetRenders).toBe(101);
expect(gridRef.current?.dataset.dndDropTarget).toBe("false");
expect(gridRef.current?.hasAttribute("data-dnd-preview-revision")).toBe(false);
});
test.each([
["label", (props: DndGridEntryProps) => ({ ...props, label: "Changed" })],
["grid limits", (props: DndGridEntryProps) => ({ ...props, maxRowCount: 10 })],
["element registration", (props: DndGridEntryProps) => ({ ...props, registerElement: () => undefined })],
[
"minimum size",
(props: DndGridEntryProps) => ({
...props,
placement: { ...props.placement, minW: 2 },
}),
],
])("renders when %s changes", (_name, update) => {
const props = createProps();
render(props);
render(update(props));
expect(renders).toBe(2);
});
const render = (props: DndGridEntryProps) => {
act(() => root.render(createElement(Probe, props)));
};
const renderBoard = (entries: DndGridEntryProps[]) => {
act(() =>
root.render(
createElement(
"div",
null,
entries.map((entry) => createElement(Probe, { ...entry, key: entry.placement.id })),
),
),
);
};
});
const createProps = (): DndGridEntryProps => ({
sectionId: "root",
placement: { id: "weather", type: "item", x: 0, y: 0, w: 1, h: 1 },
label: "Weather",
columnCount: 12,
maxRowCount: null,
registerElement: () => undefined,
});
@@ -0,0 +1,54 @@
import { describe, expect, test, vi } from "vitest";
import { createGridInteractionStore } from "../grid-interaction-store";
describe("grid interaction store", () => {
test("notifies only source, current target and previous target grids", () => {
const store = createGridInteractionStore<TestInteraction>();
const globalListener = vi.fn();
const sourceListener = vi.fn();
const firstTargetListener = vi.fn();
const secondTargetListener = vi.fn();
const unrelatedListener = vi.fn();
store.subscribe(globalListener);
store.subscribeGrid("source", sourceListener);
store.subscribeGrid("target-a", firstTargetListener);
store.subscribeGrid("target-b", secondTargetListener);
store.subscribeGrid("unrelated", unrelatedListener);
store.publish(createInteraction("target-a", 1));
expect(sourceListener).toHaveBeenCalledTimes(1);
expect(firstTargetListener).toHaveBeenCalledTimes(1);
expect(secondTargetListener).not.toHaveBeenCalled();
expect(unrelatedListener).not.toHaveBeenCalled();
expect(store.getGridSnapshot("target-a")?.previewRevision).toBe(1);
store.publish(createInteraction("target-b", 2));
expect(sourceListener).toHaveBeenCalledTimes(2);
expect(firstTargetListener).toHaveBeenCalledTimes(2);
expect(secondTargetListener).toHaveBeenCalledTimes(1);
expect(unrelatedListener).not.toHaveBeenCalled();
expect(store.getGridSnapshot("target-a")).toBeNull();
expect(store.getGridSnapshot("target-b")?.previewRevision).toBe(2);
store.publish(null);
expect(sourceListener).toHaveBeenCalledTimes(3);
expect(firstTargetListener).toHaveBeenCalledTimes(2);
expect(secondTargetListener).toHaveBeenCalledTimes(2);
expect(unrelatedListener).not.toHaveBeenCalled();
expect(globalListener).toHaveBeenCalledTimes(3);
});
});
interface TestInteraction {
sourceGridId: "source";
targetGridId: string;
previewRevision: number;
}
const createInteraction = (targetGridId: string, previewRevision: number): TestInteraction => ({
sourceGridId: "source",
targetGridId,
previewRevision,
});
@@ -0,0 +1,86 @@
import { beforeEach, describe, expect, test } from "vitest";
import { createGridPreviewDomState, syncGridPreviewGeometry } from "../grid-preview-geometry";
describe("grid preview geometry", () => {
let elements: Map<string, HTMLElement>;
beforeEach(() => {
elements = new Map([
["active", createEntry("active", 0, 0, 2, 2)],
["displaced", createEntry("displaced", 2, 0, 1, 1)],
]);
});
test("moves displaced entries without moving the active drag source", () => {
const placements = [placement("active", 0, 0, 2, 2), placement("displaced", 2, 0, 1, 1)];
const state = syncGridPreviewGeometry({
elements,
placements,
previewPlacements: [placement("active", 2, 0, 2, 2), placement("displaced", 0, 2, 1, 1)],
activeId: "active",
mode: "drag",
previous: createGridPreviewDomState(),
});
expect(elements.get("active")?.hasAttribute("data-grid-preview")).toBe(false);
expect(elements.get("active")?.getAttribute("data-dnd-active")).toBe("true");
expect(elements.get("displaced")?.style.getPropertyValue("--board-grid-preview-x")).toBe("-448px");
expect(elements.get("displaced")?.style.getPropertyValue("--board-grid-preview-y")).toBe("448px");
expect(elements.get("displaced")?.dataset.gridX).toBe("0");
expect(elements.get("displaced")?.dataset.gridY).toBe("2");
syncGridPreviewGeometry({
elements,
placements,
previewPlacements: null,
activeId: null,
mode: null,
previous: state,
});
expect(elements.get("active")?.hasAttribute("data-dnd-active")).toBe(false);
expect(elements.get("displaced")?.hasAttribute("data-grid-preview")).toBe(false);
expect(elements.get("displaced")?.dataset.gridX).toBe("2");
expect(elements.get("displaced")?.dataset.gridY).toBe("0");
});
test("applies snapped size and position to the active resize shell", () => {
syncGridPreviewGeometry({
elements,
placements: [placement("active", 0, 0, 2, 2)],
previewPlacements: [placement("active", 1, 1, 3, 4)],
activeId: "active",
mode: "resize",
previous: createGridPreviewDomState(),
});
const active = elements.get("active");
expect(active?.getAttribute("data-grid-preview")).toBe("true");
expect(active?.style.getPropertyValue("--board-grid-preview-x")).toBe("224px");
expect(active?.style.getPropertyValue("--board-grid-preview-y")).toBe("224px");
expect(active?.style.getPropertyValue("--board-grid-preview-width")).toBe("648px");
expect(active?.style.getPropertyValue("--board-grid-preview-height")).toBe("872px");
expect(active?.dataset.gridW).toBe("3");
expect(active?.dataset.gridH).toBe("4");
});
});
const placement = (id: string, x: number, y: number, w: number, h: number) => ({
id,
type: "item" as const,
x,
y,
w,
h,
});
const createEntry = (id: string, x: number, y: number, w: number, h: number) => {
const element = document.createElement("div");
element.dataset.gridItemId = id;
element.dataset.gridX = String(x);
element.dataset.gridY = String(y);
element.dataset.gridW = String(w);
element.dataset.gridH = String(h);
return element;
};
@@ -0,0 +1,49 @@
import { afterEach, describe, expect, test, vi } from "vitest";
import { createGridResizeOutlineController } from "../grid-resize-outline";
describe("grid resize outline", () => {
afterEach(() => vi.restoreAllMocks());
test("coalesces pointer updates and removes the outline synchronously", () => {
const frame = { callback: undefined as FrameRequestCallback | undefined };
const requestFrame = vi.spyOn(window, "requestAnimationFrame").mockImplementation((callback) => {
frame.callback = callback;
return 7;
});
const cancelFrame = vi.spyOn(window, "cancelAnimationFrame").mockImplementation(() => undefined);
const shell = document.createElement("div");
const controlled = placement(1, 2, 2, 2);
const controller = createGridResizeOutlineController(
() => shell,
() => controlled,
);
controller.schedule({ placement: placement(1, 2, 2, 2), valid: true });
controller.schedule({ placement: placement(1, 2, 1, 2), valid: false });
expect(requestFrame).toHaveBeenCalledTimes(1);
expect(shell.querySelector("[data-grid-resize-outline]")).toBeNull();
frame.callback?.(0);
const outline = shell.querySelector<HTMLElement>("[data-grid-resize-outline]");
expect(outline?.style.left).toBe("0px");
expect(outline?.style.top).toBe("0px");
expect(outline?.style.width).toBe("200px");
expect(outline?.style.height).toBe("424px");
expect(outline?.dataset.gridResizeValid).toBe("false");
controller.schedule(null);
expect(cancelFrame).toHaveBeenCalledWith(0);
expect(shell.querySelector("[data-grid-resize-outline]")).toBeNull();
controller.destroy();
});
});
const placement = (x: number, y: number, w: number, h: number) => ({
id: "item",
type: "item" as const,
x,
y,
w,
h,
});
@@ -2,6 +2,7 @@
import { useRouter } from "next/navigation";
import { useEffect, useMemo, useState } from "react";
import type { ModalProps } from "@mantine/core";
import { clientApi } from "@homarr/api/client";
import { revalidatePathActionAsync } from "@homarr/common/client";
@@ -27,6 +28,8 @@ interface CustomWidgetImportDialogProps {
legacyId?: string;
stackId?: string;
zIndex?: number;
modalProps?: Pick<ModalProps, "size" | "styles">;
labels?: { title?: string; description?: string; cancel?: string; confirm?: string };
onClose(): void;
onImported?(result: { id: string }): void;
}
@@ -37,6 +40,8 @@ export function CustomWidgetImportDialog({
legacyId,
stackId,
zIndex,
modalProps,
labels,
onClose,
onImported,
}: CustomWidgetImportDialogProps) {
@@ -103,14 +108,17 @@ export function CustomWidgetImportDialog({
opened={opened}
stackId={stackId}
zIndex={zIndex}
{...modalProps}
review={review}
pending={pending}
confirmDisabled={!isCustomWidgetSourceSetupReady(setups, values)}
onClose={onClose}
onConfirm={importWidget}
messages={{
title: legacyId ? t("importReview.migrationTitle") : t("importReview.title"),
description: legacyId ? t("importReview.migrationDescription") : t("importReview.description"),
title: legacyId ? t("importReview.migrationTitle") : (labels?.title ?? t("importReview.title")),
description: legacyId
? t("importReview.migrationDescription")
: (labels?.description ?? t("importReview.description")),
name: t("importReview.name"),
origin: t("importReview.origin"),
authentication: t("importReview.authentication"),
@@ -119,8 +127,8 @@ export function CustomWidgetImportDialog({
permissions: t("importReview.permissions"),
actionWarningTitle: t("importReview.actionWarning.title"),
actionWarningDescription: t("importReview.actionWarning.description"),
cancel: t("importReview.cancel"),
confirm: legacyId ? t("importReview.confirmMigration") : t("importReview.confirm"),
cancel: labels?.cancel ?? t("importReview.cancel"),
confirm: legacyId ? t("importReview.confirmMigration") : (labels?.confirm ?? t("importReview.confirm")),
permission: (permission) => t(`preview.request.permission.${permission}` as never),
}}
>
@@ -2,34 +2,17 @@
import { useEffect, useState } from "react";
import dynamic from "next/dynamic";
import { Center, Loader, Paper } from "@mantine/core";
import { useHotkeys } from "@mantine/hooks";
import { hotkeys } from "@homarr/definitions";
import { hasPendingSpotlightOpen, openSpotlight, spotlightOpenEvent } from "@homarr/spotlight/open";
const loadSpotlight = () => import("@homarr/spotlight/component");
const SpotlightLoading = () => (
<Center
data-homarr-dev-benchmark-spotlight-feedback
pos="fixed"
inset={0}
role="status"
aria-label="Loading search"
style={{ zIndex: 1000, background: "rgb(0 0 0 / 20%)", backdropFilter: "blur(2px)" }}
>
<Paper withBorder radius="md" p="xl" shadow="xl">
<Loader />
</Paper>
</Center>
);
const Spotlight = dynamic(() => loadSpotlight().then(({ Spotlight: Component }) => Component), {
ssr: false,
loading: SpotlightLoading,
loading: () => null,
});
export const preloadSpotlight = () => void loadSpotlight().catch(() => undefined);
export const LazySpotlight = () => {
const [isMounted, setIsMounted] = useState(false);
const [isPreloaded, setIsPreloaded] = useState(false);
@@ -8,7 +8,6 @@ import { useI18n } from "@homarr/translation/client";
import { HeaderButton } from "./button";
import classes from "./search.module.css";
import { preloadSpotlight } from "./lazy-spotlight";
export const DesktopSearchInput = () => {
const t = useI18n();
@@ -22,9 +21,6 @@ export const DesktopSearchInput = () => {
size="sm"
leftSection={<IconSearch size={20} stroke={1.5} />}
onClick={openSpotlight}
onFocus={preloadSpotlight}
onPointerDown={preloadSpotlight}
onPointerEnter={preloadSpotlight}
radius="xl"
>
{`${t("search.placeholder")}...`}
@@ -37,9 +33,6 @@ export const MobileSearchButton = () => {
<HeaderButton
data-homarr-dev-benchmark-interaction="search"
onClick={openSpotlight}
onFocus={preloadSpotlight}
onPointerDown={preloadSpotlight}
onPointerEnter={preloadSpotlight}
className={classes.mobileSearch}
>
<IconSearch size={20} stroke={1.5} />
@@ -1,38 +1,17 @@
"use client";
import type { ComponentProps, ReactNode } from "react";
import { Suspense, use, useEffect, useState } from "react";
import { Box, Loader, UnstyledButton, useMantineColorScheme } from "@mantine/core";
import type { ReactNode } from "react";
import { useEffect, useState } from "react";
import { UnstyledButton, useMantineColorScheme } from "@mantine/core";
import { useHotkeys } from "@mantine/hooks";
import { ErrorBoundary } from "react-error-boundary";
import { clientApi } from "@homarr/api/client";
import { useSession } from "@homarr/auth/client";
import { hotkeys } from "@homarr/definitions";
import { UserAvatarMenu } from "~/components/user-avatar-menu";
import { UpdateIndicator } from "./update";
type UserAvatarMenuModule = typeof import("~/components/user-avatar-menu");
type UserAvatarMenuProps = ComponentProps<UserAvatarMenuModule["UserAvatarMenu"]>;
let userAvatarMenuPromise: Promise<UserAvatarMenuModule> | undefined;
const loadUserAvatarMenu = () => {
if (userAvatarMenuPromise) return userAvatarMenuPromise;
const promise = import("~/components/user-avatar-menu");
userAvatarMenuPromise = promise;
void promise.catch(() => {
if (userAvatarMenuPromise === promise) userAvatarMenuPromise = undefined;
});
return promise;
};
const preloadUserAvatarMenu = () => void loadUserAvatarMenu().catch(() => undefined);
const LazyUserAvatarMenu = (props: UserAvatarMenuProps) => {
const { UserAvatarMenu } = use(loadUserAvatarMenu());
return <UserAvatarMenu {...props} />;
};
interface UserButtonClientProps {
avatar: ReactNode;
isAdmin: boolean;
@@ -41,8 +20,6 @@ interface UserButtonClientProps {
export const UserButtonClient = ({ avatar, isAdmin, isDockerEnabled }: UserButtonClientProps) => {
const [canCheckForUpdates, setCanCheckForUpdates] = useState(false);
const [isMenuMounted, setIsMenuMounted] = useState(false);
const [isMenuOpen, setIsMenuOpen] = useState(false);
const session = useSession();
const { toggleColorScheme } = useMantineColorScheme();
useHotkeys([[hotkeys.toggleColorScheme, toggleColorScheme]]);
@@ -66,60 +43,14 @@ export const UserButtonClient = ({ avatar, isAdmin, isDockerEnabled }: UserButto
staleTime: 60 * 60 * 1_000,
});
const visibleUpdates = isCurrentSessionAdmin ? availableUpdates : undefined;
const openMenu = () => {
setIsMenuMounted(true);
setIsMenuOpen(true);
};
const renderButton = (onClick: () => void, isLoading = false) => (
<UnstyledButton
onClick={onClick}
onFocus={preloadUserAvatarMenu}
onMouseEnter={preloadUserAvatarMenu}
onPointerDown={preloadUserAvatarMenu}
aria-busy={isLoading || undefined}
disabled={isLoading}
>
<UpdateIndicator availableUpdates={visibleUpdates} disabled={!isCurrentSessionAdmin}>
<Box pos="relative" style={{ display: "inline-flex" }}>
<Box opacity={isLoading ? 0.35 : 1}>{avatar}</Box>
{isLoading && (
<Loader
aria-label="Loading user menu"
size={18}
pos="absolute"
top="50%"
left="50%"
style={{ transform: "translate(-50%, -50%)" }}
/>
)}
</Box>
</UpdateIndicator>
</UnstyledButton>
);
const button = renderButton(openMenu);
const loadingButton = renderButton(() => undefined, true);
if (!isMenuMounted) return button;
return (
<ErrorBoundary
fallbackRender={({ resetErrorBoundary }) =>
renderButton(() => {
resetErrorBoundary();
openMenu();
})
}
>
<Suspense fallback={loadingButton}>
<LazyUserAvatarMenu
availableUpdates={visibleUpdates}
isDockerEnabled={isDockerEnabled}
opened={isMenuOpen}
onOpenChange={setIsMenuOpen}
>
{button}
</LazyUserAvatarMenu>
</Suspense>
</ErrorBoundary>
<UserAvatarMenu availableUpdates={visibleUpdates} isDockerEnabled={isDockerEnabled}>
<UnstyledButton>
<UpdateIndicator availableUpdates={visibleUpdates} disabled={!isCurrentSessionAdmin}>
{avatar}
</UpdateIndicator>
</UnstyledButton>
</UserAvatarMenu>
);
};
@@ -13,8 +13,8 @@ export const UnsavedChangesBar = ({ children }: UnsavedChangesBarProps) => {
const t = useI18n();
return (
<div style={{ position: "sticky", bottom: 20 }}>
<Card withBorder>
<div style={{ position: "sticky", bottom: 20, zIndex: "var(--mantine-z-index-app)" }}>
<Card withBorder bg="var(--mantine-color-body)">
<Group justify="space-between" wrap="wrap">
<Text fw={500}>{t("common.unsavedChanges")}</Text>
<Group>{children}</Group>
Loaded 100 of 360 files, more files were not shown because too many files have changed in this diff. Show more