Compare commits

..
Author SHA1 Message Date
Thomas Camlong 678e24cd2b fix: refine Beszel stats period controls 2026-08-10 09:26:51 +02:00
Thomas Camlong 8307024abe fix: contain Beszel table status dots 2026-08-10 09:19:05 +02:00
Thomas Camlong 7d560e0675 Merge v2 CI repair through dnd kit grid 2026-08-09 22:27:54 +02:00
Thomas Camlong aafd5f4c9c Merge v2 CI repair into dnd kit grid 2026-08-09 22:27:48 +02:00
Thomas Camlong 1b489cc2e0 fix: stabilize v2 CI 2026-08-09 22:27:28 +02:00
Thomas Camlong a547e29607 Merge refreshed dnd kit grid into widget modernization 2026-08-09 22:00:05 +02:00
Thomas Camlong 8cbd53d362 Merge current v2 into dnd kit grid 2026-08-09 21:59:44 +02:00
Thomas Camlong 7cf27c3f39 feat: add Workshop staging deployment 2026-08-09 21:40:20 +02:00
Thomas Camlong 8a3f8d5c12 fix: allow Workshop runtime config writes 2026-08-09 21:21:01 +02:00
Thomas Camlong 8f75954ca9 fix: prevent horizontal rail auto-scroll 2026-08-09 21:17:41 +02:00
Thomas Camlong 1028752cbc fix: restore Workshop runtime URLs 2026-08-09 20:11:13 +02:00
Thomas Camlong b8bdbc2f3e fix: restore focus after preview exit 2026-08-09 18:06:26 +02:00
Thomas Camlong 889d732e03 test: preserve advanced preview hover ownership 2026-08-09 18:06:26 +02:00
Thomas Camlong 54bd8d6343 feat: improve advanced preview activation 2026-08-09 18:06:26 +02:00
Thomas Camlong 6f1c81bc04 fix: smooth advanced focus entrance 2026-08-09 18:06:26 +02:00
Thomas Camlong 00688d5c1a Remove Docker advanced focus 2026-08-09 18:06:26 +02:00
Thomas Camlong fc8b9178a3 Merge custom option test fix into widget modernization 2026-08-09 18:06:25 +02:00
Thomas Camlong fb7c7d35d9 Merge custom option test fix into dnd kit 2026-08-09 18:05:27 +02:00
Thomas Camlong 198fda7854 Revert "ci: use self-hosted runners for stack"
This reverts commit a1d80515b0e00240bb662180626a44bfb205656e.
2026-08-09 18:03:38 +02:00
Thomas Camlong 66a1d5b210 ci: use self-hosted runners for stack 2026-08-09 18:03:38 +02:00
Thomas Camlong 5e68aab970 fix: anchor dashboard rail labels 2026-08-09 18:03:38 +02:00
Thomas Camlong ca9d836fac Merge remote-tracking branch 'origin/feat/dnd-kit-grid' into HEAD 2026-08-09 18:03:38 +02:00
Thomas Camlong 82a50de403 fix: size dashboard rails to viewport 2026-08-09 18:03:37 +02:00
Thomas Camlong 41606cef86 fix: widen workshop CSS import 2026-08-09 18:03:37 +02:00
Thomas Camlong 7d9caf407c style: format board grid test 2026-08-09 18:03:37 +02:00
Thomas Camlong 59528ca1c3 test: allow dnd-kit DOM modifier import 2026-08-09 18:03:37 +02:00
Thomas Camlong 9373e3d69a fix: preserve narrow board interactions 2026-08-09 18:03:37 +02:00
Thomas Camlong 8acc134a36 fix: keep scaled board rails sticky 2026-08-09 18:03:37 +02:00
Thomas Camlong a4ff57ed85 test: assert clipped zoom overflow 2026-08-09 18:03:37 +02:00
Thomas Camlong c530190638 test: avoid physical zoom size assumption 2026-08-09 18:03:37 +02:00
Thomas Camlong d6b6b696b4 test: assert zoomed canvas fit invariant 2026-08-09 18:03:37 +02:00
Thomas Camlong 1473605445 style: format board grid coverage 2026-08-09 18:03:37 +02:00
Thomas Camlong 255b544a6c docs: align board geometry behavior 2026-08-09 18:03:37 +02:00
Thomas Camlong bcbde92c2e Merge sidebar stability fix into dnd kit 2026-08-09 18:03:37 +02:00
Thomas Camlong 63f82f1a35 Merge grid geometry fixes into dnd kit 2026-08-09 18:03:37 +02:00
Thomas Camlong 0a8e4fead0 fix: stabilize board rails 2026-08-09 18:03:37 +02:00
Thomas Camlong 03ecfef429 Merge layout compatibility fix into dnd kit 2026-08-09 18:03:37 +02:00
Thomas Camlong 7a97b7187b Fix board grid bounds and live growth 2026-08-09 18:03:37 +02:00
Thomas Camlong 899dfd7230 Merge custom option test fix into dnd kit 2026-08-09 18:03:37 +02:00
Thomas Camlong fc1dbd37f1 fix: accept legacy dynamic sections 2026-08-09 18:03:37 +02:00
Thomas Camlong b9c89c61a2 test: harden integration icon checks 2026-08-09 13:39:16 +02:00
Thomas Camlong b5dbeae864 test: fix custom option repair fixture 2026-08-09 09:21:55 +02:00
Thomas Camlong f214c25e9c fix: require advanced widget focus opt-in 2026-08-09 09:19:54 +02:00
Thomas Camlong fcee8177af Merge custom widget option repair into widget modernization 2026-08-09 09:18:01 +02:00
Thomas Camlong 0d6f9337b4 Merge v2 custom widget option repair 2026-08-09 09:17:58 +02:00
Thomas Camlong d513c7fe46 fix: repair stale custom widget options 2026-08-09 09:17:32 +02:00
Thomas Camlong 0425c3630c Merge migration renames into widget modernization 2026-08-09 08:14:06 +02:00
Thomas Camlong 84db5ccfe7 Rename layout gutter migrations 2026-08-09 08:12:46 +02:00
Thomas Camlong db7f1a0374 Merge branch 'feat/v2-stack-final-runtime-dnd' into feat/v2-stack-final-runtime-modernization 2026-08-09 02:56:26 +02:00
Thomas Camlong 1f045b2abf Merge branch 'feat/v2-stack-final-runtime-v2' into feat/v2-stack-final-runtime-dnd 2026-08-09 02:56:11 +02:00
Thomas Camlong 8dad83f720 test(e2e): serialize container specs 2026-08-09 02:55:51 +02:00
Thomas Camlong da99dc3080 Merge branch 'feat/v2-stack-final-runtime-dnd' into feat/v2-stack-final-runtime-modernization 2026-08-09 02:21:24 +02:00
Thomas Camlong 52ab119a1f Merge branch 'feat/v2-stack-final-runtime-v2' into feat/v2-stack-final-runtime-dnd 2026-08-09 02:21:13 +02:00
Thomas Camlong bbc23a6c65 fix(settings): avoid nested forms 2026-08-09 02:21:01 +02:00
Thomas Camlong 1db3b2c2cf Merge branch feat/v2-stack-final-runtime-dnd into feat/v2-stack-final-runtime-modernization
# Conflicts:
#	packages/widgets/src/clock/component.tsx
2026-08-09 01:47:20 +02:00
Thomas Camlong 1b663e56ee Merge branch feat/v2-stack-final-runtime-v2 into feat/v2-stack-final-runtime-dnd 2026-08-09 01:46:45 +02:00
Thomas Camlong 5ee14136ac fix(clock): defer live time until mount 2026-08-09 01:46:30 +02:00
Thomas Camlong 4a47fc4551 Merge branch 'feat/v2-stack-final-runtime-dnd' into feat/v2-stack-final-runtime-modernization
# Conflicts:
#	packages/widgets/src/clock/component.tsx
2026-08-09 01:12:32 +02:00
Thomas Camlong 3abdae490e Merge branch 'feat/v2-stack-final-runtime-v2' into feat/v2-stack-final-runtime-dnd 2026-08-09 01:12:12 +02:00
Thomas Camlong 9d3d40d4bd fix(e2e): stabilize initial board hydration 2026-08-09 01:11:38 +02:00
Thomas Camlong 1367e07f20 test(widgets): stabilize advanced focus hover 2026-08-09 00:39:38 +02:00
Thomas Camlong bbdbae636a Merge branch 'feat/v2-stack-e2e-fix-dnd' into feat/v2-stack-e2e-fix-modernization
# Conflicts:
#	packages/widgets/src/clock/component.tsx
2026-08-09 00:39:38 +02:00
Thomas Camlong 10719bc72e Merge branch 'feat/v2-stack-e2e-fix-v2' into feat/v2-stack-e2e-fix-dnd 2026-08-09 00:37:47 +02:00
Thomas Camlong 48d8517ed1 fix(clock): suppress live timestamp hydration noise 2026-08-09 00:37:33 +02:00
Thomas Camlong 074902ecc8 Merge branch 'feat/v2-stack-e2e-fix-dnd' into feat/v2-stack-e2e-fix-modernization 2026-08-09 00:14:32 +02:00
Thomas Camlong d17752188f Merge branch 'feat/v2-stack-e2e-fix-v2' into feat/v2-stack-e2e-fix-dnd 2026-08-09 00:14:22 +02:00
Thomas Camlong 7619492516 test(v2): make board tour dismissal optional 2026-08-09 00:14:10 +02:00
Thomas Camlong 5e035e14ed Merge commit '9f36be028' into feat/v2-stack-e2e-fix-modernization 2026-08-09 00:11:41 +02:00
Thomas Camlong 9f36be0284 Merge commit 'ca6a019c1' into feat/v2-stack-e2e-fix-dnd
# Conflicts:
#	apps/nextjs/src/app/[locale]/boards/(content)/_board-add-menu.tsx
#	apps/nextjs/src/app/[locale]/boards/(content)/_header-actions.tsx
#	packages/translation/src/lang/en.json
2026-08-09 00:11:33 +02:00
Thomas Camlong ca6a019c1e test(v2): stabilize production E2E flows 2026-08-09 00:10:49 +02:00
Thomas Camlong 05eebc097f test(mcp): review smart home entity details tool 2026-08-08 23:39:15 +02:00
Thomas Camlong 0bd9c6f290 style(widgets): split query matcher type imports 2026-08-08 23:28:33 +02:00
Thomas Camlong bc896c0b03 fix(widgets): keep board graph imports scoped 2026-08-08 23:21:35 +02:00
Thomas Camlong 14380e1cdf Merge dnd-kit grid into widget modernization 2026-08-08 23:07:00 +02:00
Thomas Camlong 0ece179558 Merge v2 unit-test project filter into dnd-kit grid 2026-08-08 23:06:54 +02:00
Thomas Camlong cae976b6d8 fix(ci): exclude e2e project from unit gate 2026-08-08 23:06:42 +02:00
Thomas Camlong c2064faa95 Merge dnd-kit grid into widget modernization 2026-08-08 22:51:17 +02:00
Thomas Camlong 6d1538d3b1 Merge v2 unit-test gate into dnd-kit grid 2026-08-08 22:51:11 +02:00
Thomas Camlong 215f9a7d21 fix(ci): exclude e2e tests from unit gate 2026-08-08 22:50:57 +02:00
Thomas Camlong eae55ce2f0 Merge dnd-kit grid into widget modernization 2026-08-08 22:39:10 +02:00
Thomas Camlong 12412947ee Merge v2 bundle checker into dnd-kit grid 2026-08-08 22:39:00 +02:00
Thomas Camlong dec46f3ee4 fix(ci): detect custom widget runtime chunks 2026-08-08 22:38:42 +02:00
Thomas Camlong e65c98c290 fix(ci): make modernization independently installable 2026-08-08 22:19:20 +02:00
Thomas Camlong 1b6c4a9022 Merge commit '3246dc4c91656dec3c87267ae074436267ad77ac' into feat/v2-stack-final-modernization
# Conflicts:
#	apps/docs/docs/widgets/downloads/index.mdx
#	apps/docs/docs/widgets/media-server/index.mdx
2026-08-08 22:17:04 +02:00
Thomas Camlong 3246dc4c91 style: format dnd migration metadata 2026-08-08 22:16:16 +02:00
Thomas Camlong 97e89a936c Merge commit 'aaf86a9d1' into feat/v2-stack-final-dnd 2026-08-08 22:15:41 +02:00
Thomas Camlong aaf86a9d13 fix(ci): refresh v2 generated artifacts 2026-08-08 22:15:24 +02:00
Thomas Camlong 298937b00c Merge commit '25ac7b6a9' into feat/v2-stack-e2e-modernization 2026-08-08 22:06:36 +02:00
Thomas Camlong 25ac7b6a9d test(e2e): wait for settled grid geometry 2026-08-08 22:06:19 +02:00
Thomas Camlong b937d4eb18 style: format modernization notes 2026-08-08 21:52:51 +02:00
Thomas Camlong 8d35c0c71f merge: propagate dnd stack CI fix 2026-08-08 21:52:32 +02:00
Thomas Camlong a5ce2e9b7a merge: propagate v2 CI fix 2026-08-08 21:52:16 +02:00
Thomas Camlong 2efc225617 fix(ci): align Workshop pnpm version 2026-08-08 21:52:01 +02:00
Thomas Camlong f24d757ec8 fix: preserve app links and hover previews 2026-08-08 21:42:13 +02:00
Thomas Camlong 9edeba5cd2 merge: stack widget modernization on dnd kit 2026-08-08 20:40:26 +02:00
Thomas Camlong 93276abc8c merge: stack v2 into dnd-kit grid 2026-08-08 20:16:04 +02:00
Thomas Camlong dd63d5600b merge dev into v2 2026-08-08 19:54:57 +02:00
ajnart 25be53244b ci(workshop): restore image publishing 2026-08-08 19:25:35 +02:00
Thomas 8ab3f136b8 fix(workshop): harden deployment and deep links (#6542) 2026-08-08 17:41:54 +02:00
Thomas Camlong 4bb862ee76 fix: stabilize advanced widget interactions 2026-08-03 21:45:00 +02:00
Thomas Camlong 0c5251a971 fix: finish widget modernization audit 2026-08-03 21:01:55 +02:00
Thomas Camlong 454752e46d fix: address widget review feedback 2026-08-03 20:04:56 +02:00
Thomas Camlong 596bfc317d fix: scope advanced preview controls 2026-08-03 19:42:43 +02:00
Thomas Camlong 97444d209a ci: limit turbo concurrency 2026-08-03 19:17:28 +02:00
Thomas Camlong ee51227a55 fix: restore notifications query scope 2026-08-03 18:53:26 +02:00
Thomas Camlong db7c850596 fix: remove stale notifications import 2026-08-03 18:52:02 +02:00
ajnart 30344bdaae style: auto-fix lint and format 2026-08-03 16:46:35 +00:00
Thomas Camlong 7847eea1a3 fix: refine widget interactions 2026-08-03 18:45:16 +02:00
ajnart fbe381cbb5 style: auto-fix lint and format 2026-08-03 15:39:36 +00:00
Thomas 8d98408cfe Merge branch 'dev' into feat/dnd-kit-grid 2026-08-03 17:38:51 +02:00
Thomas 9234ca0f32 Merge branch 'dev' into feat/widget-modernization 2026-08-03 17:38:28 +02:00
Thomas Camlong 61a2f8cb6c feat: modernize widget experience 2026-08-03 17:18:22 +02:00
Thomas Camlong 596833f227 fix container header spacing 2026-08-03 17:13:02 +02:00
Thomas Camlong cc71ab8cd9 fix: contain board canvas overflow 2026-08-03 16:46:43 +02:00
Thomas Camlong 49d623e5c2 fix: defer dependency scripts during container fetch 2026-08-03 15:18:07 +02:00
ajnart ab21a9fece style: auto-fix lint and format 2026-08-03 12:50:22 +00:00
Thomas Camlong 41fffbb79b Merge dev into feat/dnd-kit-grid 2026-08-03 14:48:31 +02:00
Thomas Camlong 08da71d67f fix: contain collapsed layout and widget overflow 2026-08-03 14:27:45 +02:00
ajnart 65ff0ecb34 docs: add widget modernization audit 2026-08-03 14:27:30 +02:00
Thomas Camlong 80d46b0d85 restore context menu focus 2026-08-03 13:28:12 +02:00
Thomas Camlong 6e15f8bcf8 fix context menu keyboard focus 2026-08-03 13:24:13 +02:00
Thomas Camlong 294217fcba fix board E2E login race 2026-08-03 13:09:56 +02:00
Thomas Camlong 3c99c75ad7 fix SVG focus scrolling 2026-08-03 12:51:10 +02:00
Thomas Camlong 20dbdcedf2 fix focused pane scrolling 2026-08-03 12:25:56 +02:00
Thomas Camlong a2f5668c44 fix focus scroll layering 2026-08-03 12:21:31 +02:00
Thomas Camlong 09e9cd4388 fix widget focus interactions 2026-08-03 12:15:12 +02:00
Thomas Camlong 5c9fd28ae1 test: wait for resized grid geometry 2026-08-02 19:27:09 +02:00
Thomas Camlong 8f0c9435ff test: stabilize board grid authentication 2026-08-02 19:14:40 +02:00
Thomas Camlong 96e92485a5 fix: align responsive widget actions 2026-08-02 18:10:25 +02:00
Thomas Camlong f9aae23f46 fix: keep session labels readable 2026-08-02 17:41:27 +02:00
Thomas Camlong fd024e32d2 fix: simplify widget feedback 2026-08-02 17:39:28 +02:00
Thomas Camlong 5010ecf188 fix: harden widget error feedback 2026-08-02 17:34:52 +02:00
Thomas Camlong f0d8e97f8d fix: address final widget review 2026-08-02 17:25:02 +02:00
Thomas Camlong ed8ef15f12 fix: localize widget chart formatting 2026-08-02 16:48:34 +02:00
Thomas Camlong d97f1bd34b fix: align widget accessibility and locales 2026-08-02 16:42:08 +02:00
Thomas Camlong d86293de0f fix: canonicalize widget date locales 2026-08-02 16:32:59 +02:00
Thomas Camlong 54e444d69d fix: close final widget review gaps 2026-08-02 16:26:04 +02:00
Thomas Camlong d5c3aebd26 fix: harden widget review follow-ups 2026-08-02 16:10:16 +02:00
ajnart 1777ca4446 style: auto-fix lint and format 2026-08-02 13:58:01 +00:00
Thomas Camlong 56e3bd1cf2 fix: smooth container collapse behavior 2026-08-02 15:57:05 +02:00
Thomas Camlong 7b7045202d fix: address widget review findings 2026-08-02 14:35:01 +02:00
Thomas Camlong 1e0bf40040 feat: refine widget interactions 2026-08-02 14:12:18 +02:00
Thomas Camlong bdffb63ebc Merge remote-tracking branch 'origin/dev' into feat/widget-modernization 2026-08-02 14:06:03 +02:00
ajnart 4db4d947db style: auto-fix lint and format 2026-08-02 09:04:11 +00:00
Thomas Camlong 0755fc1e26 fix: refine container interactions 2026-08-02 11:02:55 +02:00
Thomas Camlong d460ac8bbb fix: secure widget navigation links 2026-08-02 10:01:36 +02:00
Thomas Camlong 1d73513989 fix: polish widget failure feedback 2026-08-02 09:50:08 +02:00
Thomas Camlong e3f679237d fix: polish board grid interactions 2026-08-02 09:36:33 +02:00
Thomas Camlong 45ff97972c fix: address final widget review findings 2026-08-02 09:27:31 +02:00
Thomas Camlong 36b75a071b fix: complete widget review cleanup 2026-08-02 09:05:02 +02:00
Thomas Camlong 3f7d934fa5 fix: address remaining widget review feedback 2026-08-02 08:44:00 +02:00
Thomas Camlong 76974f676d fix: allow dynamic database schema access 2026-08-02 08:19:46 +02:00
Thomas Camlong 5ddbf184fd ci: limit fast gate concurrency 2026-08-02 08:10:14 +02:00
Thomas Camlong c61695b142 fix: make integration warnings keyboard accessible 2026-08-02 08:09:55 +02:00
Thomas Camlong b9b938c658 fix: address board grid CI and review 2026-08-02 08:05:25 +02:00
Thomas Camlong 6f27ede8bf fix: complete widget modernization review 2026-08-02 08:05:04 +02:00
Thomas Camlong 79ae42f0c3 fix: sync widget modernization with dev 2026-08-02 01:37:01 +02:00
Thomas Camlong c754946089 fix: address widget review feedback 2026-08-01 23:44:17 +02:00
ajnart ab7d385a13 style: auto-fix lint and format 2026-08-01 21:38:41 +00:00
Thomas c53b38a738 Merge branch 'dev' into feat/dnd-kit-grid 2026-08-01 23:37:27 +02:00
Thomas Camlong d0c7ba401f fix: address board grid review 2026-08-01 23:26:02 +02:00
Thomas Camlong d94cadd3e7 feat: rebuild board grid with dnd-kit 2026-08-01 22:30:40 +02:00
ajnart cce3e455fa style: auto-fix lint and format 2026-08-01 19:47:19 +00:00
Thomas Camlong b28beec2c6 fix: refine widget modernization 2026-08-01 21:45:01 +02:00
Thomas Camlong d421046d15 feat: modernize dashboard widgets 2026-08-01 21:14:33 +02:00
ajnart d6c1a87a03 test: wait for grid reconciliation between drags 2026-08-01 17:56:09 +02:00
ajnart 50907c7289 fix: clarify board grid editing affordances 2026-08-01 14:13:07 +02:00
ajnart 9c8fa8158b fix: improve board grid interactions 2026-08-01 00:43:36 +02:00
Thomas Camlong e6e413bcb7 test: target visible layout preview 2026-08-01 00:43:36 +02:00
Thomas Camlong 81d14e39ab test: tolerate sticky rail subpixel rounding 2026-08-01 00:43:36 +02:00
Thomas Camlong f207587791 test: avoid grid drag rounding boundary 2026-08-01 00:43:36 +02:00
Thomas Camlong 5cc2df19ed fix: satisfy database namespace lint 2026-08-01 00:43:36 +02:00
ajnart 0a80d70730 style: auto-fix lint and format 2026-08-01 00:43:36 +02:00
ajnart 25c9955cc6 feat: rebuild board grid experience 2026-08-01 00:43:28 +02:00
Thomas 557ebe0a2f fix: harden v2 custom widgets and Workshop (#6480) 2026-07-31 13:28:53 +02:00
Thomas e8a2c87c2d Delete review.md 2026-07-23 02:13:20 +02:00
Thomas 4f6e335f72 fix(custom-widgets): address final v2 review feedback (#6451) 2026-07-23 02:11:32 +02:00
Thomas df967751f6 fix(custom-widgets): address v2 merge review (#6446) 2026-07-23 01:55:15 +02:00
Thomas 6aceb2fd1f feat(custom-widgets): complete Custom JSX v2 (#6390) 2026-07-22 20:47:19 +02:00
Thomas 60cbd7bb7a Merge branch 'dev' into v2 2026-07-18 23:00:30 +02:00
Thomas c0f1d6624c fix(custom-widgets): fix v2 editor crash and definition-not-found race (#6377) 2026-07-17 14:39:29 +02:00
Thomas Camlong 67d146220a test(integrations): wait for Home Assistant restart 2026-07-16 16:25:48 +02:00
Thomas Camlong 41db737905 ci: publish multi-platform preview images 2026-07-16 16:16:01 +02:00
Thomas Camlong 3b128585a3 ci: remove memory regression test 2026-07-16 15:01:27 +02:00
Thomas Camlong cdaf2a838c fix(ci): preserve memory-test encryption key 2026-07-16 14:52:40 +02:00
Thomas Camlong 926a6856fb test(integrations): allow recurring calendar setup 2026-07-16 14:34:07 +02:00
Thomas Camlong c6160da222 fix(ci): stabilize affected workspace gate 2026-07-16 14:22:01 +02:00
Thomas Camlong e729dfd891 fix(ci): prevent autofix race 2026-07-16 14:13:39 +02:00
Thomas Camlong eb71f936c6 fix(custom-widgets): remove stale form helpers 2026-07-16 14:13:39 +02:00
ajnart c10ebbc863 style: auto-fix lint and format [skip ci] 2026-07-16 08:22:51 +00:00
Thomas 106c3bfa90 Merge branch 'dev' into v2 2026-07-16 10:21:40 +02:00
Thomas Camlong 27a6467896 fix(ci): don't cancel CI on autofix pushes, guard format step 2026-07-15 16:47:28 +02:00
ajnart 8331a58471 style: auto-fix lint and format [skip ci] 2026-07-15 14:20:37 +00:00
Thomas Camlong ec74c36a6a fix(oxlint): remove unsupported option from docs child config 2026-07-15 16:19:12 +02:00
Thomas Camlong 487d98275c fix(ci): autofix commits skip CI to prevent race cancellation 2026-07-15 16:17:59 +02:00
Thomas Camlong 187a479bd5 fix(ci): scope format check to only PR-changed files 2026-07-15 15:57:42 +02:00
Thomas Camlong 0bfe6ddabe fix(ci): don't fail fast-gate on format check (auto-fix handles it) 2026-07-15 15:55:00 +02:00
ajnart 51d8a3cc53 style: auto-fix lint and format 2026-07-15 13:54:18 +00:00
Thomas Camlong 06db42129c fix(ci): remove coverage step, fix auto-fix to format all affected workspaces 2026-07-15 15:47:11 +02:00
Thomas Camlong a11583feb1 Revert "style: format all files and remove coverage report step"
This reverts commit 446931bc0a.
2026-07-15 15:44:49 +02:00
Thomas Camlong 446931bc0a style: format all files and remove coverage report step 2026-07-15 15:42:13 +02:00
Thomas Camlong 34bcc62415 fix: disable cpu-features native build (incompatible with Node 24 V8 API) 2026-07-15 15:35:43 +02:00
Thomas Camlong a2e3c5d3bc style: fix formatting in v2 files 2026-07-15 15:34:15 +02:00
Thomas Camlong ed9646b0c6 fix: regenerate pnpm-lock.yaml after dev merge 2026-07-15 15:25:52 +02:00
Thomas Camlong e0de370340 Merge branch 'dev' into v2 2026-07-15 15:17:18 +02:00
homarr-renovate[bot] 7e5453d47c 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-07-15 12:16:38 +00:00
Thomas 3b2fc2527e feat(custom-widgets): Full Mantine v9 + SubFetch support for custom JSX (#6304) 2026-07-15 14:06:02 +02:00
Thomasandhomarr-renovate[bot] 0a0b3a12c9 bring dev changes to v2 (#6353)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-07-15 12:16:16 +02:00
Thomas ba263dbe47 Delete .opencode directory 2026-07-15 12:14:46 +02:00
Thomas Camlong 8a7c4875bf Revert "chore(merge): resolve v2 conflicts"
This reverts commit 377f2f6a20, reversing
changes made to 7e4cb0201b.
2026-07-15 12:12:33 +02:00
Thomas Camlong 377f2f6a20 chore(merge): resolve v2 conflicts 2026-07-15 12:09:51 +02:00
Thomasandcopilot-swe-agent[bot] c69e33ac8c feat: community workshop for custom widgets and CSS (#5984)
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
2026-07-15 11:53:15 +02:00
Thomas Camlong 121a83040d Setup v2 pre-release branch 2026-07-13 18:14:19 +02:00
360 changed files with 4165 additions and 11839 deletions

No files matched your search

-8
View File
@@ -206,14 +206,6 @@ 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/ # 56 integration guides (one folder each)
├── widgets/ # 43 widget guides (one folder each)
├── integrations/ # 55 integration guides (one folder each)
├── widgets/ # 42 widget guides (one folder each)
├── advanced/ # SSO, env vars, proxy, styling, CLI, development
└── community/ # FAQ, donate, license, translations, get-in-touch
```
+3 -9
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 more than 1,888 curated, high-quality icons
- [Dashboard Icons](https://dashboardicons.com/) - Our recommended source with over 1888+ 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 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.
The icon picker is a fast searcher that helps you to find your desired icon simply by entering a search term.
### Finding Missing Icons
@@ -31,13 +31,7 @@ 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. 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.
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.
@@ -15,20 +15,6 @@ 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,8 +233,7 @@ 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.
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 stops shrinking at 75% and lets you scroll horizontally instead of making text and controls too small.
It does not squeeze or reflow the content of individual widgets, so a widget keeps the same design and proportions across screen sizes.
:::note
@@ -1,48 +0,0 @@
---
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",
],
},
]}
/>
@@ -1,9 +0,0 @@
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. 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.
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.
![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. They do not grow or scroll independently.
Rails fill the available window height and remain fixed while the page scrolls.
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
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.
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.
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**. Homarr fills the visual builders and the JSX editor
field. Copy that single block and select **Paste AI response**. 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,17 +90,15 @@ 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 **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
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
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
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.
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.
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.
+7 -18
View File
@@ -35,8 +35,7 @@ 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.
- 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.
- 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.
- The manual **Outdated** badge warns visitors that a submission may need an update.
## Comments
@@ -53,12 +52,6 @@ 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.
@@ -81,9 +74,10 @@ 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 **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.
- **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.
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.
@@ -93,8 +87,6 @@ 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,
@@ -103,11 +95,8 @@ 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 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.
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.
The public URL contract is:
@@ -17,8 +17,6 @@ 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 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.
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.
### Supported integration
+1 -3
View File
@@ -36,9 +36,7 @@ import widgetOverview from "./img/widget-overview.png";
]}
/>
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 />
<WidgetInteractionGuide advancedView={false} />
### Adding the widget
@@ -34,7 +34,7 @@ This widget displays your Beszel alert configurations separated by status (trigg
]}
/>
<WidgetInteractionGuide advancedView={false} />
<WidgetInteractionGuide />
### Adding the widget
@@ -4,8 +4,6 @@ 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";
@@ -36,8 +34,6 @@ Select a system card to open the complete statistics view, including system, fil
]}
/>
<WidgetInteractionGuide advancedView={false} />
### Adding the widget
<AddingWidget />
@@ -4,8 +4,6 @@ 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";
@@ -37,8 +35,6 @@ Select a system row to open the complete statistics view.
]}
/>
<WidgetInteractionGuide advancedView={false} />
### Adding the widget
<AddingWidget />
@@ -16,7 +16,6 @@ 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, keeps every event attributed to its source integration, and expands titles, locations, and descriptions. Use its previous and next controls to change months.
- The advanced view replaces the month grid with a grouped event list. 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 advancedView={false} />
<WidgetInteractionGuide />
### Adding the widget
+1 -5
View File
@@ -27,11 +27,7 @@ The Coolify widget displays a compact overview of your Coolify instance. It can
]}
/>
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 />
<WidgetInteractionGuide advancedView={false} />
### Adding the widget
@@ -39,9 +39,6 @@ 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,8 +20,6 @@ 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,8 +17,6 @@ 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** — copy download names, or pause, resume, and delete when your account has Interact access and the client supports those actions
- **Right-click context menu** — pause, resume, delete downloads, or copy download names directly from the context menu
- **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; open tooltips close when switching between compact and advanced mode
- **Hover tooltips** — hover a download name to see size, speeds, ETA, ratio, and category
- **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 downloads from supported clients when your account has Interact access; read-only clients such as slskd do not expose these controls
- **Global controls** — pause/resume all downloads across all clients from the footer bar
- **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 copy its name; pause/resume and delete require Interact access and client support
- **Right-click** any download to open a context menu with pause/resume, delete, and copy actions
- **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** downloads from supported clients using the footer controls when you have Interact access; slskd remains read-only
- **Pause / Resume** all downloads using the footer controls
- **Filter** by integration or status using the filter button in the footer
- **Sort** the current view by clicking column headers to switch direction
- **Sort** by clicking column headers (with persistent sort 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 an upload speed below the specified threshold.",
"This will hide completed torrent entries that have a download speed below the specified threshold.",
values: "Any number above 0, 0 to disable",
defaultValue: "0",
},
@@ -23,8 +23,6 @@ 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 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.
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.
### Adding the widget
@@ -18,8 +18,6 @@ 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,10 +19,6 @@ 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 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.
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.
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,7 +20,6 @@ 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,8 +20,6 @@ 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,9 +36,7 @@ import summaryCard from "./img/summary.png";
]}
/>
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 />
<WidgetInteractionGuide advancedView={false} />
### Adding the widget
@@ -34,9 +34,7 @@ import notificationsScreenshot from "./img/notifications.png";
items={[{ integration: ntfyIntegration }, { integration: nextcloudIntegration }, { integration: gotifyIntegration }]}
/>
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 />
<WidgetInteractionGuide advancedView={false} />
### Adding the widget
+1 -5
View File
@@ -72,10 +72,6 @@ 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
@@ -87,7 +83,7 @@ Advanced view is a complete PatchMon posture view. It always shows the complianc
]}
/>
<WidgetInteractionGuide />
<WidgetInteractionGuide advancedView={false} />
### Adding the widget
+1 -3
View File
@@ -35,9 +35,7 @@ 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.
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 />
<WidgetInteractionGuide advancedView={false} />
### Adding the widget
+1 -3
View File
@@ -21,9 +21,7 @@ import screenshot from "./img/screenshot.png";
<img src={screenshot} alt="rss-feed noted-lol" width={128 * 4} />
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 />
<WidgetInteractionGuide advancedView={false} />
### Adding the widget
@@ -22,8 +22,6 @@ 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,8 +20,6 @@ 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,10 +18,6 @@ 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";
+1 -3
View File
@@ -16,8 +16,6 @@ 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";
@@ -36,7 +34,7 @@ import nvidia from "./img/nvidia.png";
<img src={nvidia} alt="stock-price nvidia" width={128 * 2 + 2} height={128 * 2 + 4} />
</div>
<WidgetInteractionGuide />
<WidgetInteractionGuide advancedView={false} />
### Adding the widget
@@ -44,9 +44,7 @@ This widget displays filesystem and SMART disk usage for supported server and NA
]}
/>
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 />
<WidgetInteractionGuide advancedView={false} />
### Adding the widget
@@ -17,8 +17,6 @@ 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,10 +17,6 @@ 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,10 +19,6 @@ 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,10 +17,6 @@ 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
@@ -1,40 +0,0 @@
---
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} />
@@ -1,38 +0,0 @@
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,7 +402,6 @@ html[data-workshop-detail-loading] #workshop-detail-route-fallback {
path: "/workshop/:id",
component: "@site/src/components/workshop/WorkshopDetailRoutePage",
exact: true,
priority: -1,
});
},
};
@@ -3,7 +3,6 @@ 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";
@@ -159,10 +158,13 @@ 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 authorHandle = ownComment ? (currentUser?.name ?? comment.authorName) : comment.authorName;
const authorName = authorHandle || "Community member";
const avatarUrl = githubAvatarUrl(authorHandle);
const profileUrl = githubProfileUrl(authorHandle);
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;
return (
<article
@@ -290,8 +292,10 @@ 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?.name && <AvatarImage src={githubAvatarUrl(currentUser.name)} alt="" />}
<AvatarFallback className="bg-background">{avatarFallback(currentUser?.name ?? "Guest")}</AvatarFallback>
{currentUser?.avatarUrl && <AvatarImage src={currentUser.avatarUrl} alt="" />}
<AvatarFallback className="bg-background">
{avatarFallback(currentUser?.displayName ?? "Guest")}
</AvatarFallback>
</Avatar>
<Textarea
className="min-h-16 flex-1 resize-none bg-background"
@@ -1,6 +1,5 @@
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";
@@ -34,9 +33,6 @@ 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,
@@ -70,7 +66,6 @@ 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 = [
@@ -454,7 +449,7 @@ const MarketplaceDetail = ({ workshopUrl }: { workshopUrl: string }) => {
try {
await backend.report(
submission.id,
reportCategory as "outdated" | "malicious" | "spam" | "copyright" | "inappropriate" | "other",
reportCategory as "malicious" | "spam" | "copyright" | "inappropriate" | "other",
reportExplanation,
);
setReportOpen(false);
@@ -529,40 +524,17 @@ 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 authorHandle = signedInAuthor?.name || submission.authorName;
const authorName = authorHandle || "Community member";
const authorAvatarUrl = githubAvatarUrl(authorHandle);
const authorGithubProfileUrl = githubProfileUrl(authorHandle);
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 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"
@@ -621,7 +593,10 @@ const MarketplaceDetail = ({ workshopUrl }: { workshopUrl: string }) => {
{authorName}
{authorGithubProfileUrl && <IconBrandGithub size={14} />}
</a>
<p className="text-xs text-muted-foreground">Published {formatRelativeTime(submission.created)}</p>
<p className="text-xs text-muted-foreground">
{authorGithubUsername ? `@${authorGithubUsername} · ` : ""}
Published {formatRelativeTime(submission.created)}
</p>
</div>
</div>
</div>
@@ -723,12 +698,12 @@ const MarketplaceDetail = ({ workshopUrl }: { workshopUrl: string }) => {
<CustomWidgetCodeExample
id={`workshop-${submission.id}`}
label="widget.json"
code={displayedContent}
code={submission.content}
language="json"
height="min(58vh, 640px)"
/>
) : (
<CodeBlock content={displayedContent} language={contentLanguages[submission.type]} />
<CodeBlock content={submission.content} language={contentLanguages[submission.type]} />
)}
</section>
@@ -907,7 +882,6 @@ 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,7 +1,6 @@
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";
@@ -10,6 +9,10 @@ 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;
@@ -18,9 +21,8 @@ interface WorkshopAccountMenuProps {
export const WorkshopAccountMenu = ({ user, onSignOut }: WorkshopAccountMenuProps) => {
const [open, setOpen] = useState(false);
const rootRef = useRef<HTMLDivElement>(null);
const profileUrl = githubProfileUrl(user.name);
const avatarUrl = githubAvatarUrl(user.name);
const accountLabel = user.name ? `@${user.name}` : "Account";
const profileUrl = githubProfileUrl(user);
const accountLabel = user.githubUsername ? `@${user.githubUsername}` : user.displayName || "Account";
useEffect(() => {
if (!open) return;
@@ -52,8 +54,8 @@ export const WorkshopAccountMenu = ({ user, onSignOut }: WorkshopAccountMenuProp
onClick={() => setOpen((value) => !value)}
>
<Avatar className="size-6">
{avatarUrl && <AvatarImage src={avatarUrl} alt="" />}
<AvatarFallback className="text-[10px]">{avatarFallback(user.name)}</AvatarFallback>
{user.avatarUrl && <AvatarImage src={user.avatarUrl} alt="" />}
<AvatarFallback className="text-[10px]">{avatarFallback(user.displayName)}</AvatarFallback>
</Avatar>
<span className="max-w-36 truncate">{accountLabel}</span>
<IconChevronDown
@@ -71,11 +73,12 @@ export const WorkshopAccountMenu = ({ user, onSignOut }: WorkshopAccountMenuProp
>
<div className="flex items-center gap-3 px-2 py-2">
<Avatar className="size-9">
{avatarUrl && <AvatarImage src={avatarUrl} alt="" />}
<AvatarFallback>{avatarFallback(user.name)}</AvatarFallback>
{user.avatarUrl && <AvatarImage src={user.avatarUrl} alt="" />}
<AvatarFallback>{avatarFallback(user.displayName)}</AvatarFallback>
</Avatar>
<div className="min-w-0">
<p className="truncate text-sm font-medium">{accountLabel}</p>
<p className="truncate text-sm font-medium">{user.displayName}</p>
{user.githubUsername && <p className="truncate text-xs text-muted-foreground">@{user.githubUsername}</p>}
</div>
</div>
@@ -10,7 +10,6 @@ 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";
@@ -109,11 +108,7 @@ export function WorkshopAdmin({ workshopUrl }: { workshopUrl?: string }) {
submission,
reports: reports.filter((report) => report.submission === submission.id),
}))
.sort(
(left, right) =>
right.reports.length - left.reports.length ||
Date.parse(right.submission.updated) - Date.parse(left.submission.updated),
);
.sort((left, right) => Number(right.reports.length > 0) - Number(left.reports.length > 0));
return (
<main className="marketplace mx-auto max-w-6xl px-4 pb-20 text-foreground sm:px-6">
@@ -199,18 +194,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.authorName && <AvatarImage src={githubAvatarUrl(submission.authorName)} alt="" />}
{submission.authorAvatarUrl && <AvatarImage src={submission.authorAvatarUrl} alt="" />}
<AvatarFallback>{avatarFallback(submission.authorName)}</AvatarFallback>
</Avatar>
<div className="min-w-0">
<CardTitle className="truncate text-base">{submission.title}</CardTitle>
<a
href={githubProfileUrl(submission.authorName) || undefined}
target={submission.authorName ? "_blank" : undefined}
href={submission.authorGithubProfileUrl || undefined}
target={submission.authorGithubProfileUrl ? "_blank" : undefined}
rel="noreferrer"
className="text-xs text-muted-foreground hover:text-foreground hover:underline"
>
{submission.authorName || "Community member"}
{submission.authorName}
</a>
</div>
</div>
@@ -242,8 +237,7 @@ 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 || "Community member"} ·{" "}
{new Date(report.created).toLocaleString()}
Reported by {report.reporterName} · {new Date(report.created).toLocaleString()}
</p>
</div>
<Button
@@ -8,19 +8,16 @@ 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";
@@ -118,7 +115,6 @@ 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">
@@ -129,19 +125,9 @@ 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 flex-wrap items-center gap-2 self-start sm:self-auto">
<div className="flex 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>
@@ -370,16 +356,16 @@ const SubmissionCard = ({ submission, backend, userVote, onVote }: SubmissionCar
</div>
<CardDescription className="flex min-w-0 items-center gap-1 text-xs">
<a
href={githubProfileUrl(submission.authorName) || undefined}
target={submission.authorName ? "_blank" : undefined}
href={submission.authorGithubProfileUrl || undefined}
target={submission.authorGithubProfileUrl ? "_blank" : undefined}
rel="noreferrer"
className="inline-flex min-w-0 items-center gap-1.5 hover:text-foreground"
>
<Avatar className="size-4">
{submission.authorName && <AvatarImage src={githubAvatarUrl(submission.authorName)} alt="" />}
{submission.authorAvatarUrl && <AvatarImage src={submission.authorAvatarUrl} alt="" />}
<AvatarFallback className="text-[9px]">{avatarFallback(submission.authorName)}</AvatarFallback>
</Avatar>
<span className="truncate">{submission.authorName || "Community member"}</span>
<span className="truncate">{submission.authorName}</span>
</a>
<span className="shrink-0 whitespace-nowrap">
· v{submission.revision} · {formatRelativeTime(submission.created)}
@@ -419,14 +405,9 @@ const SubmissionCard = ({ submission, backend, userVote, onVote }: SubmissionCar
</CardHeader>
<CardContent className="min-h-[4.5rem] flex-1 overflow-hidden">
{(submission.outdated || submission.reportCount > 0) && (
{submission.outdated && (
<div className="mb-2 flex flex-wrap gap-1.5">
{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>
)}
<Badge variant="secondary">Outdated</Badge>
</div>
)}
{submission.description && (
@@ -162,7 +162,7 @@ export const useWorkshop = (workshopUrl: string) => {
const report = useCallback(
async (
submissionId: string,
category: "outdated" | "malicious" | "spam" | "copyright" | "inappropriate" | "other",
category: "malicious" | "spam" | "copyright" | "inappropriate" | "other",
explanation: string,
) => {
try {
+1 -2
View File
@@ -2151,8 +2151,7 @@
"beszelAlerts",
"beszelSystemStats",
"traefik",
"customApi",
"wud"
"customApi"
]
},
"options": {
+1 -1
View File
@@ -20,5 +20,5 @@
}
},
"include": ["**/*.ts", "**/*.jsx", "**/*.js", "**/*.tsx", "**/*.md", "**/*.mdx"],
"exclude": ["build", "node_modules"]
"exclude": ["node_modules"]
}
@@ -21,7 +21,10 @@
height: var(--board-gutter-viewport-height);
padding-top: 24px;
margin-top: -24px;
overflow: clip;
overflow-x: hidden;
overflow-y: auto;
overscroll-behavior: contain;
scrollbar-gutter: stable;
}
.gutter[data-board-editing="true"] {
@@ -53,7 +53,7 @@ export default async function BoardSettingsPage(props: Props) {
}
return (
<Container size="xl">
<Container>
<Stack>
<Title>{t("title", { boardName: capitalize(board.name) })}</Title>
<BoardSettingsForm
-1
View File
@@ -161,7 +161,6 @@ 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,13 +30,7 @@ describe("Copy AI prompt", () => {
`${process.cwd()}/apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-form.module.css`,
"utf8",
);
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(card).toContain("<SimpleGrid cols={{ base: 1, sm: 2 }}>");
expect(styles).toContain(".sectionNav > a");
expect(styles).toContain("flex: 0 0 auto");
});
@@ -1,7 +1,7 @@
"use client";
import { ActionIcon, Tooltip } from "@mantine/core";
import { IconCopy } from "@tabler/icons-react";
import { Button } from "@mantine/core";
import { IconSparkles } from "@tabler/icons-react";
import { buildCustomWidgetAiPrompt } from "@homarr/custom-widgets/authoring-prompt";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
@@ -36,16 +36,15 @@ export const CopyAiPromptButton = ({
};
return (
<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>
<Button
type="button"
variant="light"
leftSection={<IconSparkles size={16} />}
onClick={() => void handleCopy()}
fullWidth
size="sm"
>
{t("action.copyAiPrompt")}
</Button>
);
};
@@ -1,7 +1,7 @@
"use client";
import { Accordion, Button, Group, Stack, Text, TextInput, Textarea } from "@mantine/core";
import { IconClipboard, IconSparkles } from "@tabler/icons-react";
import { Button, Card, Group, SimpleGrid, Stack, Text, TextInput, Textarea } from "@mantine/core";
import { IconSparkles } from "@tabler/icons-react";
import type { HomarrCustomWidgetV2 } from "@homarr/custom-widgets/core";
import { useScopedI18n } from "@homarr/translation/client";
@@ -20,41 +20,38 @@ interface AiCardProps {
export function CustomWidgetAiCard(props: AiCardProps) {
const t = useScopedI18n("customWidget.workbench.ai");
return (
<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>
<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 }}>
<CopyAiPromptButton
currentConfig={props.candidate}
request={props.request}
documentationUrl={props.documentationUrl}
/>
</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>
<Button type="button" variant="light" leftSection={<IconSparkles size={16} />} onClick={props.onPaste}>
{t("paste")}
</Button>
</SimpleGrid>
</Stack>
</Card>
);
}
@@ -66,6 +66,7 @@ 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) : {};
@@ -144,7 +145,6 @@ 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,11 +171,7 @@ 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}
suggestedSearch={mode === "create" ? form.values.name : undefined}
{...form.getInputProps("iconUrl")}
/>
<IconPicker withAsterisk={false} {...form.getInputProps("iconUrl")} />
</EditorSection>
<EditorSection id="sources" title={w("sources.title")} icon={IconApi}>
<CustomWidgetSourcesEditor form={form} definitionId={definitionId} />
@@ -270,6 +266,8 @@ export function CustomWidgetForm({ mode, initialValues, definitionId }: CustomWi
preview={preview}
size={previewSize}
onSizeChange={setPreviewSize}
theme={previewTheme}
onThemeChange={setPreviewTheme}
optionsSnapshot={optionsSnapshot}
onOptionsChange={setOptionsSnapshot}
onLiveActionsChange={(enabled) =>
@@ -1,6 +1,5 @@
// @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";
@@ -22,7 +21,6 @@ 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> }) => {
@@ -33,12 +31,7 @@ vi.mock("@homarr/widgets/custom-api/custom-jsx-display", () => ({
return <div>widget-renderer</div>;
},
}));
vi.mock("./_code-editor", () => ({
CodeEditor: (props: { label: string; readOnly?: boolean }) => {
codeEditorProps(props);
return <div>{props.label}</div>;
},
}));
vi.mock("./_code-editor", () => ({ CodeEditor: ({ label }: { label: string }) => <div>{label}</div> }));
vi.mock("./_custom-widget-preview-action", () => ({ PreviewActionControl: () => <div>action-control</div> }));
let root: Root;
@@ -76,16 +69,6 @@ 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(
@@ -96,6 +79,8 @@ 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()}
@@ -115,7 +100,6 @@ 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) =>
@@ -140,8 +124,6 @@ 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 () => {
@@ -167,6 +149,8 @@ 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()}
@@ -191,6 +175,8 @@ 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()}
@@ -225,6 +211,8 @@ 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,7 +1,20 @@
"use client";
import { useState } from "react";
import { Alert, Box, Card, Group, Paper, SegmentedControl, Stack, Switch, Tabs, Text } from "@mantine/core";
import {
Alert,
Badge,
Box,
Card,
Group,
MantineProvider,
Paper,
SegmentedControl,
Stack,
Switch,
Tabs,
Text,
} from "@mantine/core";
import { clientApi } from "@homarr/api/client";
import type { HomarrCustomWidgetV2 } from "@homarr/custom-widgets/core";
@@ -31,6 +44,8 @@ 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;
@@ -83,51 +98,39 @@ export function CustomWidgetPreviewPanel(props: PreviewPanelProps) {
return (
<Card withBorder p="md">
<Stack gap="md">
<Group gap="xs" justify="space-between">
<Group grow>
<SegmentedControl
size="xs"
value={props.size}
onChange={props.onSizeChange}
data={[
{
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>
),
},
{ value: "compact", label: t("size.compact") },
{ value: "standard", label: t("size.standard") },
{ value: "wide", label: t("size.wide") },
]}
/>
<SegmentedControl
size="xs"
value={fixture}
disabled={!candidate}
onChange={(value) => setFixture(value as typeof fixture)}
value={props.theme}
onChange={(value) => props.onThemeChange(value as "light" | "dark")}
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") },
{ value: "light", label: t("theme.light") },
{ value: "dark", label: t("theme.dark") },
]}
/>
</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}
@@ -150,20 +153,22 @@ export function CustomWidgetPreviewPanel(props: PreviewPanelProps) {
</Tabs.List>
<Tabs.Panel value="widget" pt="sm">
<Box className={classes.previewCanvas}>
<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 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>
</Box>
</Tabs.Panel>
<Tabs.Panel value="data" pt="sm">
@@ -172,7 +177,6 @@ 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>
@@ -217,6 +221,11 @@ 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>
@@ -252,7 +261,6 @@ export function CustomWidgetPreviewPanel(props: PreviewPanelProps) {
label={t("journal")}
language="json"
value={JSON.stringify(journalQuery.data ?? [], null, 2)}
readOnly
onChange={() => undefined}
/>
)}
@@ -87,9 +87,7 @@ export function CustomWidgetSourcesEditor({
form.setFieldValue("secrets", [...current, { sourceId, kind, value, hasValue: existing?.hasValue }]);
};
const addSource = () => {
let suffix = sources.length + 1;
while (sources.some(({ id }) => id === `source-${suffix}`)) suffix += 1;
const id = sources.length === 0 ? "default" : `source-${suffix}`;
const id = sources.length === 0 ? "default" : `source-${sources.length + 1}`;
form.setFieldValue(
"sources",
JSON.stringify(
@@ -1,46 +0,0 @@
// @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,8 +2,6 @@
import { useEffect, useState } from "react";
import { useScopedI18n } from "@homarr/translation/client";
import { CodeEditor } from "./_code-editor";
import { isRecord, parseJson } from "./_custom-widget-form-utils";
@@ -18,26 +16,19 @@ 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);
const [invalid, setInvalid] = useState(false);
useEffect(() => {
if (!invalid) setDraft(serialized);
}, [invalid, serialized]);
useEffect(() => setDraft(serialized), [serialized]);
return (
<CodeEditor
id={id}
label={label}
language="json"
value={draft}
error={invalid ? t("invalidJson") : undefined}
onChange={(next) => {
setDraft(next);
const parsed = parseJson(next);
const valid = isRecord(parsed);
setInvalid(!valid);
onChange(valid ? parsed : {});
if (isRecord(parsed)) onChange(parsed);
}}
/>
);
@@ -48,17 +48,4 @@ 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,7 +4,6 @@ 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;
@@ -33,22 +32,11 @@ 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,10 +57,7 @@ export const SearchEngineForm = (props: SearchEngineFormProps) => {
/>
</Grid.Col>
</Grid>
<IconPicker
{...form.getInputProps("iconUrl")}
suggestedSearch={initialValues === undefined ? form.values.name : undefined}
/>
<IconPicker {...form.getInputProps("iconUrl")} />
<Fieldset legend={t("search.engine.page.edit.configControl")}>
<SegmentedControl
@@ -1,9 +1,5 @@
"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";
@@ -16,25 +12,11 @@ interface AnalyticsSettingsProps {
}
export const AnalyticsSettings = ({ form }: AnalyticsSettingsProps) => {
const t = useScopedI18n("management.page.settings.section");
const t = useScopedI18n("management.page.settings.section.analytics");
return (
<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 title={t("title")}>
<SwitchSetting form={form} formKey="enableGeneral" title={t("general.title")} text={t("general.text")} />
</SectionCard>
);
};
@@ -1,10 +1,11 @@
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 { ManagePageLayout } from "~/components/manage/manage-page-layout";
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
import { SettingsForm } from "./_components/settings-form";
export async function generateMetadata() {
@@ -27,8 +28,12 @@ export default async function SettingsPage() {
const tSettings = await getScopedI18n("management.page.settings");
return (
<ManagePageLayout title={tSettings("title")}>
<SettingsForm initialData={serverSettings} />
</ManagePageLayout>
<>
<DynamicBreadcrumb />
<Stack>
<Title order={1}>{tSettings("title")}</Title>
<SettingsForm initialData={serverSettings} />
</Stack>
</>
);
}
@@ -22,6 +22,7 @@ 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, useViewportSize } from "@mantine/hooks";
import { useIsomorphicEffect } from "@mantine/hooks";
import { useEditMode } from "@homarr/boards/edit-mode";
@@ -14,7 +14,6 @@ import type { FocusRect } from "./geometry";
interface ActiveFocus {
itemId: string;
source: HTMLElement;
restoreFocusTarget: HTMLElement;
sourceRect: FocusRect;
activation: "preview" | "manual";
autofocusClose: boolean;
@@ -25,12 +24,10 @@ 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;
@@ -49,7 +46,6 @@ 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);
@@ -96,7 +92,6 @@ 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,
@@ -118,8 +113,7 @@ export const BoardAdvancedFocusProvider = ({ children }: PropsWithChildren) => {
closeTimerRef.current = setTimeout(() => {
closeTimerRef.current = null;
updateActive(null);
if (restoreFocus && current.restoreFocusTarget.isConnected)
requestAnimationFrame(() => current.restoreFocusTarget.focus());
if (restoreFocus && current.source.isConnected) requestAnimationFrame(() => current.source.focus());
}, closeDuration);
},
[cancelHoverTimer, cancelPreviewLeaveTimer, updateActive],
@@ -216,7 +210,7 @@ export const BoardAdvancedFocusProvider = ({ children }: PropsWithChildren) => {
return;
}
if (underlyingSource.hasAttribute("data-advanced-focus-enabled")) {
if (underlyingSource.hasAttribute("aria-keyshortcuts")) {
hover(itemId, underlyingSource);
return;
}
@@ -316,8 +310,8 @@ export const BoardAdvancedFocusProvider = ({ children }: PropsWithChildren) => {
}, [active?.activation]);
const value = useMemo(
() => ({ active, viewportSize, open, close, dismiss, hover, leave }),
[active, close, dismiss, hover, leave, open, viewportSize],
() => ({ active, open, close, dismiss, hover, leave }),
[active, close, dismiss, hover, leave, open],
);
return (
@@ -24,18 +24,3 @@
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, MutableRefObject } from "react";
import type { ComponentType, CSSProperties, KeyboardEvent, 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, Button, Card, Center, Loader, Portal } from "@mantine/core";
import { useElementSize, useIsomorphicEffect } from "@mantine/hooks";
import { Badge, Box, Card, Center, Loader, Portal } from "@mantine/core";
import { useElementSize, useIsomorphicEffect, useViewportSize } from "@mantine/hooks";
import { QueryErrorResetBoundary, useQueryClient } from "@tanstack/react-query";
import combineClasses from "clsx";
import { NoIntegrationSelectedError } from "@homarr/widgets/errors/classes";
@@ -153,16 +153,15 @@ 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, viewportSize, open, close, dismiss, hover, leave } = useAdvancedFocus();
const { width: viewportWidth, height: viewportHeight } = viewportSize;
const { active, open, close, dismiss, hover, leave } = useAdvancedFocus();
const supportsAdvancedFocus = definitionSupportsAdvancedFocus(definition);
const widgetName = t(`widget.${item.kind}.name`);
const advancedViewLabel = t("item.advancedFocus.label", { widget: widgetName });
@@ -186,13 +185,17 @@ const LoadedBoardItemContent = ({
useEffect(() => () => dismiss(item.id), [dismiss, item.id]);
const openAdvancedView = () => {
if (sourceRef.current)
open(item.id, sourceRef.current, {
activation: "manual",
autofocusClose: true,
restoreFocusTarget: advancedFocusTriggerRef.current ?? sourceRef.current,
});
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 });
};
useEffect(() => {
@@ -350,10 +353,16 @@ 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 : 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}
onPointerEnter={() => {
if (supportsAdvancedFocus && sourceRef.current) hover(item.id, sourceRef.current);
}}
@@ -362,21 +371,6 @@ 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 || item.kind === "app") return <>{children}</>;
if (!session || !settings.enableRightClickOnWidgets || isEditMode) return <>{children}</>;
const visibleWidgetActions = widgetContextActions.filter((action) => !action.hidden);
@@ -227,12 +227,7 @@ export const WidgetContextMenu = ({
closeMenuOnClick
leftSection={<IconMaximize size={16} />}
onClick={() => {
if (sourceRef.current)
openAdvancedFocus(item.id, sourceRef.current, {
restoreFocusTarget:
sourceRef.current.querySelector<HTMLElement>("[data-advanced-focus-trigger]") ??
sourceRef.current,
});
if (sourceRef.current) openAdvancedFocus(item.id, sourceRef.current);
}}
>
{t("item.advancedFocus.open")}
@@ -1,25 +0,0 @@
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,13 +8,11 @@
.board-grid-entry {
z-index: 1;
overflow: visible;
}
.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;
transition:
left 100ms ease,
top 100ms ease,
width 100ms ease,
height 100ms ease;
}
.board-grid-entry[data-dnd-active="true"],
@@ -75,8 +73,8 @@
zoom: var(--board-grid-overlay-content-zoom, 1);
}
body[data-board-grid-interacting] .board-grid-entry,
body[data-board-grid-interacting] .board-grid-placeholder {
body[data-board-grid-interacting="drag"] .board-grid-entry,
body[data-board-grid-interacting="drag"] .board-grid-placeholder {
transition: none;
}
@@ -118,6 +116,11 @@ 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;
@@ -142,14 +145,6 @@ 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,18 +1,7 @@
"use client";
import type { CSSProperties, PointerEvent as ReactPointerEvent, PropsWithChildren } from "react";
import {
createContext,
memo,
useCallback,
useContext,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
useSyncExternalStore,
} from "react";
import { createContext, useCallback, useContext, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import "./grid-editor.css"; // oxlint-disable-line import/no-unassigned-import
@@ -36,7 +25,12 @@ import { DragDropProvider, DragOverlay, useDragDropManager, useDraggable, useDro
import { useCurrentLayout, useRequiredBoard } from "@homarr/boards/context";
import { useI18n } from "@homarr/translation/client";
import { getLogicalItemStyle, getLogicalTrackSize, LOGICAL_GRID_PITCH } from "~/components/board/layout";
import {
getLayoutRowCount,
getLogicalItemStyle,
getLogicalTrackSize,
LOGICAL_GRID_PITCH,
} from "~/components/board/layout";
import type { GridPlacement } from "~/components/board/layout";
import { useSectionContext } from "../section-context";
import {
@@ -53,15 +47,8 @@ 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";
@@ -72,7 +59,6 @@ interface GridEditorProps {
sectionId: string;
columnCount: number;
rowCount: number;
maxRowCount: number | null;
placements: readonly SectionGridPlacement[];
className: string;
}
@@ -100,6 +86,17 @@ 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;
@@ -126,7 +123,8 @@ interface ResizePreviewInput {
minHeight: number;
}
interface BoardGridEditorActionsContextValue {
interface BoardGridEditorContextValue {
interaction: GridInteraction | null;
registerGrid: (grid: RegisteredGrid) => () => void;
getDepth: (gridId: string) => number;
isDropTargetEligible: (source: AbstractDraggable, targetGridId: string) => boolean;
@@ -137,28 +135,14 @@ interface BoardGridEditorActionsContextValue {
cancelResize: () => void;
}
const BoardGridEditorActionsContext = createContext<BoardGridEditorActionsContextValue | null>(null);
const BoardGridEditorInteractionContext = createContext<ReturnType<
typeof createGridInteractionStore<GridInteraction>
> | null>(null);
const BoardGridEditorContext = createContext<BoardGridEditorContextValue | null>(null);
const useBoardGridEditorActions = () => {
const value = useContext(BoardGridEditorActionsContext);
const useBoardGridEditor = () => {
const value = useContext(BoardGridEditorContext);
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();
@@ -170,7 +154,7 @@ export const BoardGridEditorProvider = ({ children }: PropsWithChildren) => {
const activeRef = useRef<ActiveTransaction | null>(null);
const renderedInteractionRef = useRef<GridInteraction | null>(null);
const collisionFrameRef = useRef(0);
const [interactionStore] = useState(() => createGridInteractionStore<GridInteraction>());
const [interaction, setInteraction] = useState<GridInteraction | null>(null);
const [overlayZoom, setOverlayZoom] = useState(1);
const parentGridById = useMemo(
() =>
@@ -191,9 +175,21 @@ export const BoardGridEditorProvider = ({ children }: PropsWithChildren) => {
collisionFrameRef.current = 0;
activeRef.current = null;
renderedInteractionRef.current = null;
interactionStore.publish(null);
setInteraction(null);
document.body.removeAttribute("data-board-grid-interacting");
}, [interactionStore]);
}, []);
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]);
useEffect(() => clearInteraction, [clearInteraction, currentLayoutId]);
@@ -268,7 +264,7 @@ export const BoardGridEditorProvider = ({ children }: PropsWithChildren) => {
previewRevision: 0,
};
document.body.setAttribute("data-board-grid-interacting", mode);
interactionStore.publish({
setInteraction({
activeId,
sourceGridId,
targetGridId: mode === "resize" ? sourceGridId : null,
@@ -285,7 +281,7 @@ export const BoardGridEditorProvider = ({ children }: PropsWithChildren) => {
return false;
}
},
[announce, clearInteraction, getCurrentState, interactionStore, t],
[announce, clearInteraction, getCurrentState, t],
);
const updateDragPreview = useCallback(
@@ -300,7 +296,7 @@ export const BoardGridEditorProvider = ({ children }: PropsWithChildren) => {
const publishPreview = (preview: Omit<GridInteraction, "previewRevision">) => {
active.previewRevision += 1;
interactionStore.publish({ ...preview, previewRevision: active.previewRevision });
setInteraction({ ...preview, previewRevision: active.previewRevision });
};
const showInvalidPreview = (targetGridId: string | null, previewKey: string) => {
@@ -409,7 +405,7 @@ export const BoardGridEditorProvider = ({ children }: PropsWithChildren) => {
valid: true,
});
},
[canAcceptDrop, interactionStore, isDropTargetEligible],
[canAcceptDrop, isDropTargetEligible],
);
const commitActiveTransaction = useCallback(
@@ -576,52 +572,49 @@ 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) {
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;
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({
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,
targetPlacement: null,
state: cancelGridTransaction(active.transaction),
mode: "resize",
valid: true,
valid: false,
previewRevision: active.previewRevision,
});
return true;
},
[interactionStore],
);
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;
}, []);
const previewResize = useCallback(
(input: ResizePreviewInput) => {
@@ -632,8 +625,9 @@ export const BoardGridEditorProvider = ({ children }: PropsWithChildren) => {
[applyResizePreview],
);
const actions = useMemo<BoardGridEditorActionsContextValue>(
const value = useMemo<BoardGridEditorContextValue>(
() => ({
interaction,
registerGrid,
getDepth,
isDropTargetEligible,
@@ -648,115 +642,113 @@ export const BoardGridEditorProvider = ({ children }: PropsWithChildren) => {
canAcceptDrop,
cancelActiveInteraction,
commitActiveTransaction,
interaction,
isDropTargetEligible,
previewResize,
getDepth,
registerGrid,
],
);
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;
}, []);
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]);
return (
<BoardGridEditorActionsContext.Provider value={actions}>
<BoardGridEditorInteractionContext.Provider value={interactionStore}>
<DragDropProvider
plugins={configureEditorPlugins}
onCollision={handleCollision}
onBeforeDragStart={handleBeforeDragStart}
onDragStart={handleDragStart}
onDragMove={handleDragMove}
onDragOver={handleDragOver}
onDragEnd={handleDragEnd}
<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
}
>
{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>
{(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>
);
};
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 GridCollisionSynchronizer = ({ interaction }: { interaction: GridInteraction | null }) => {
const manager = useDragDropManager();
const geometryKey = interaction ? getGridGeometryKey(interaction.state) : null;
useLayoutEffect(() => {
if (previewRevision === null || !manager) return;
if (!geometryKey || !manager) return;
for (const target of manager.registry.droppables.value) target.refreshShape();
manager.collisionObserver.forceUpdate(true);
}, [manager, previewRevision]);
}, [geometryKey, manager]);
return null;
};
/** A section-local dnd-kit canvas registered with the board transaction. */
export default function GridEditor({
sectionId,
columnCount,
rowCount,
maxRowCount,
placements,
className,
}: GridEditorProps) {
export default function GridEditor({ sectionId, columnCount, rowCount, placements, className }: GridEditorProps) {
const t = useI18n();
const { section, items, innerSections } = useSectionContext();
const { registerGrid, getDepth, isDropTargetEligible } = useBoardGridEditorActions();
const interactionStore = useBoardGridEditorInteractionStore();
const { interaction, registerGrid, getDepth, isDropTargetEligible } = useBoardGridEditor();
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 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 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 { ref: droppableRef } = useDroppable<GridTargetData>({
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>({
id: `board-grid:${sectionId}`,
type: GRID_TARGET_TYPE,
data: { kind: GRID_TARGET_TYPE, sectionId },
@@ -767,6 +759,7 @@ export default function GridEditor({
// 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) => {
@@ -775,13 +768,6 @@ export default function GridEditor({
},
[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;
@@ -789,14 +775,23 @@ export default function GridEditor({
return registerGrid({
id: sectionId,
columnCount,
maxRowCount,
maxRowCount: section.kind === "container" ? rowCount : null,
parentGridId,
ownerPlacementId,
placements,
depth,
element,
});
}, [columnCount, depth, maxRowCount, ownerPlacementId, parentGridId, placements, registerGrid, sectionId]);
}, [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]);
return (
<div
@@ -804,36 +799,48 @@ export default function GridEditor({
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(rowCount),
height: getLogicalTrackSize(renderedRowCount),
"--board-grid-pitch": `${LOGICAL_GRID_PITCH}px`,
} as CSSProperties
}
>
{placements.map((placement) => {
{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;
return (
<DndGridEntry
key={placement.id}
sectionId={sectionId}
placement={placement}
label={labelById.get(placement.id) ?? placement.id}
label={label}
columnCount={columnCount}
maxRowCount={maxRowCount}
registerElement={registerEntryElement}
maxRowCount={section.kind === "container" ? rowCount : null}
/>
);
})}
<GridPreviewLayer
sectionId={sectionId}
rowCount={rowCount}
maxRowCount={maxRowCount}
placements={placements}
gridRef={gridRef}
entryElements={entryElements}
interactionStore={interactionStore}
/>
{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"
/>
)}
<span
data-grid-runtime="homarr-dnd-kit-v1"
data-editor-section-id={sectionId}
@@ -844,29 +851,24 @@ export default function GridEditor({
);
}
const DndGridEntryComponent = ({
sectionId,
placement,
label,
columnCount,
maxRowCount,
registerElement,
}: DndGridEntryProps) => {
interface DndGridEntryProps {
sectionId: string;
placement: SectionGridPlacement;
label: string;
columnCount: number;
maxRowCount: number | null;
}
const DndGridEntry = ({ sectionId, placement, label, columnCount, maxRowCount }: DndGridEntryProps) => {
const { acquireContainer } = useBoardGridPortalHost();
const { beginResize, previewResize, completeResize, cancelResize } = useBoardGridEditorActions();
const { interaction, beginResize, previewResize, completeResize, cancelResize } = useBoardGridEditor();
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],
@@ -894,7 +896,7 @@ const DndGridEntryComponent = ({
const {
ref: draggableRef,
handleRef,
isDragSource,
isDragging,
isDropping,
} = useDraggable<GridEntryData>({
id: placement.id,
@@ -906,14 +908,11 @@ const DndGridEntryComponent = ({
const setShellRef = useCallback(
(element: HTMLDivElement | null) => {
shellRef.current = element;
registerElement(placement.id, element);
draggableRef(element);
},
[draggableRef, placement.id, registerElement],
[draggableRef],
);
useEffect(() => () => resizeOutlineController.destroy(), [resizeOutlineController]);
useLayoutEffect(() => {
const mount = mountRef.current;
if (!mount) return;
@@ -922,7 +921,15 @@ const DndGridEntryComponent = ({
const handleSelector =
placement.type === "section" ? "[data-grid-container-drag-handle]" : "[data-editor-grid-entry]";
return observeGridDragHandle(container, handleSelector, handleRef);
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);
};
}, [acquireContainer, handleRef, placement.id, placement.type]);
useLayoutEffect(() => {
@@ -948,11 +955,22 @@ const DndGridEntryComponent = ({
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={getLogicalItemStyle(placement) as CSSProperties}
style={style}
data-grid-item-id={placement.id}
data-grid-item-type={placement.type}
data-grid-x={placement.x}
@@ -960,29 +978,37 @@ const DndGridEntryComponent = ({
data-grid-w={placement.w}
data-grid-h={placement.h}
data-grid-section-chrome-active={placement.type === "section" ? String(isSectionChromeActive) : undefined}
data-dnd-drag-source={isDragSource ? "true" : undefined}
data-dnd-active={isActive ? "true" : undefined}
data-dnd-drag-source={isDragging ? "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={placement.type === "section" && !isSectionChromeActive}
disabled={interaction !== null || (placement.type === "section" && !isSectionChromeActive)}
columnCount={columnCount}
maxRowCount={maxRowCount}
beginResize={beginResize}
previewResize={previewResize}
completeResize={completeResize}
cancelResize={cancelResize}
scheduleResizeOutline={resizeOutlineController.schedule}
setContinuousResize={setContinuousResize}
/>
</div>
);
};
const DndGridEntry = memo(DndGridEntryComponent, areDndGridEntryPropsEqual);
interface GridResizeHandlesProps {
sectionId: string;
placement: SectionGridPlacement;
@@ -990,11 +1016,11 @@ interface GridResizeHandlesProps {
disabled: boolean;
columnCount: number;
maxRowCount: number | null;
beginResize: BoardGridEditorActionsContextValue["beginResize"];
previewResize: BoardGridEditorActionsContextValue["previewResize"];
completeResize: BoardGridEditorActionsContextValue["completeResize"];
cancelResize: BoardGridEditorActionsContextValue["cancelResize"];
scheduleResizeOutline: (preview: { placement: SectionGridPlacement; valid: boolean } | null) => void;
beginResize: BoardGridEditorContextValue["beginResize"];
previewResize: BoardGridEditorContextValue["previewResize"];
completeResize: BoardGridEditorContextValue["completeResize"];
cancelResize: BoardGridEditorContextValue["cancelResize"];
setContinuousResize: (preview: { placement: SectionGridPlacement; valid: boolean } | null) => void;
}
const RESIZE_DIRECTIONS: readonly GridResizeDirection[] = ["n", "ne", "e", "se", "s", "sw", "w", "nw"];
@@ -1010,26 +1036,15 @@ const GridResizeHandles = ({
previewResize,
completeResize,
cancelResize,
scheduleResizeOutline,
setContinuousResize,
}: 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;
@@ -1043,44 +1058,6 @@ 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 (
@@ -1100,17 +1077,7 @@ const GridResizeHandles = ({
cancelResize();
return;
}
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 });
pointerStartRef.current = { id: event.pointerId, x: event.clientX, y: event.clientY, direction, visualScale };
};
const handlePointerMove = (event: ReactPointerEvent<HTMLSpanElement>) => {
@@ -1126,23 +1093,38 @@ const GridResizeHandles = ({
const continuousDelta = getContinuousGridDelta(input);
if (!snappedDelta || !continuousDelta) return;
const valid = update(started.direction, snappedDelta.x, snappedDelta.y);
scheduleResizeOutline({
setContinuousResize({
placement: getContinuousResizePlacement({
placement: started.placement,
placement,
direction: started.direction,
deltaColumns: continuousDelta.x,
deltaRows: continuousDelta.y,
columnCount,
maxRowCount,
minWidth: started.placement.minW ?? 1,
minHeight: started.placement.minH ?? 1,
minWidth: placement.minW ?? 1,
minHeight: placement.minH ?? 1,
}),
valid,
});
};
const handlePointerEnd = (event: ReactPointerEvent<HTMLSpanElement>, canceled: boolean) => {
finishResize(event.pointerId, { x: event.clientX, y: event.clientY }, canceled);
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();
}
};
return RESIZE_DIRECTIONS.map((direction) => (
@@ -1156,7 +1138,12 @@ const GridResizeHandles = ({
onPointerMove={handlePointerMove}
onPointerUp={(event) => handlePointerEnd(event, false)}
onPointerCancel={(event) => handlePointerEnd(event, true)}
onLostPointerCapture={(event) => finishResize(event.pointerId, null, true)}
onLostPointerCapture={(event) => {
if (pointerStartRef.current?.id !== event.pointerId) return;
pointerStartRef.current = null;
setContinuousResize(null);
cancelResize();
}}
/>
));
};
@@ -1352,3 +1339,18 @@ 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)),
);
@@ -1,25 +0,0 @@
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;
@@ -1,58 +0,0 @@
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);
};
},
};
};
@@ -1,101 +0,0 @@
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;
@@ -1,136 +0,0 @@
"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"
/>
);
};
@@ -1,72 +0,0 @@
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,7 +150,6 @@ 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
@@ -165,7 +164,7 @@ export const SectionGrid = ({
innerSections: displayedInnerSections,
integrations,
columnCount,
maxRowCount,
maxRowCount: section.kind === "container" ? rowCount : null,
announce,
}}
>
@@ -183,7 +182,6 @@ export const SectionGrid = ({
sectionId={section.id}
columnCount={columnCount}
rowCount={rowCount}
maxRowCount={maxRowCount}
placements={displayPlacements}
className={combineClasses("board-grid-editor", classes.editorGrid)}
/>
@@ -1,33 +0,0 @@
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();
});
});
@@ -1,202 +0,0 @@
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,
});
@@ -1,54 +0,0 @@
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,
});
@@ -1,86 +0,0 @@
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;
};
@@ -1,49 +0,0 @@
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,7 +2,6 @@
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";
@@ -28,8 +27,6 @@ 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;
}
@@ -40,8 +37,6 @@ export function CustomWidgetImportDialog({
legacyId,
stackId,
zIndex,
modalProps,
labels,
onClose,
onImported,
}: CustomWidgetImportDialogProps) {
@@ -108,17 +103,14 @@ 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") : (labels?.title ?? t("importReview.title")),
description: legacyId
? t("importReview.migrationDescription")
: (labels?.description ?? t("importReview.description")),
title: legacyId ? t("importReview.migrationTitle") : t("importReview.title"),
description: legacyId ? t("importReview.migrationDescription") : t("importReview.description"),
name: t("importReview.name"),
origin: t("importReview.origin"),
authentication: t("importReview.authentication"),
@@ -127,8 +119,8 @@ export function CustomWidgetImportDialog({
permissions: t("importReview.permissions"),
actionWarningTitle: t("importReview.actionWarning.title"),
actionWarningDescription: t("importReview.actionWarning.description"),
cancel: labels?.cancel ?? t("importReview.cancel"),
confirm: legacyId ? t("importReview.confirmMigration") : (labels?.confirm ?? t("importReview.confirm")),
cancel: t("importReview.cancel"),
confirm: legacyId ? t("importReview.confirmMigration") : t("importReview.confirm"),
permission: (permission) => t(`preview.request.permission.${permission}` as never),
}}
>
@@ -2,17 +2,34 @@
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: () => null,
loading: SpotlightLoading,
});
export const preloadSpotlight = () => void loadSpotlight().catch(() => undefined);
export const LazySpotlight = () => {
const [isMounted, setIsMounted] = useState(false);
const [isPreloaded, setIsPreloaded] = useState(false);
@@ -8,6 +8,7 @@ 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();
@@ -21,6 +22,9 @@ 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")}...`}
@@ -33,6 +37,9 @@ 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,17 +1,38 @@
"use client";
import type { ReactNode } from "react";
import { useEffect, useState } from "react";
import { UnstyledButton, useMantineColorScheme } from "@mantine/core";
import type { ComponentProps, ReactNode } from "react";
import { Suspense, use, useEffect, useState } from "react";
import { Box, Loader, 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;
@@ -20,6 +41,8 @@ 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]]);
@@ -43,14 +66,60 @@ 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 (
<UserAvatarMenu availableUpdates={visibleUpdates} isDockerEnabled={isDockerEnabled}>
<UnstyledButton>
<UpdateIndicator availableUpdates={visibleUpdates} disabled={!isCurrentSessionAdmin}>
{avatar}
</UpdateIndicator>
</UnstyledButton>
</UserAvatarMenu>
<ErrorBoundary
fallbackRender={({ resetErrorBoundary }) =>
renderButton(() => {
resetErrorBoundary();
openMenu();
})
}
>
<Suspense fallback={loadingButton}>
<LazyUserAvatarMenu
availableUpdates={visibleUpdates}
isDockerEnabled={isDockerEnabled}
opened={isMenuOpen}
onOpenChange={setIsMenuOpen}
>
{button}
</LazyUserAvatarMenu>
</Suspense>
</ErrorBoundary>
);
};
@@ -13,8 +13,8 @@ export const UnsavedChangesBar = ({ children }: UnsavedChangesBarProps) => {
const t = useI18n();
return (
<div style={{ position: "sticky", bottom: 20, zIndex: "var(--mantine-z-index-app)" }}>
<Card withBorder bg="var(--mantine-color-body)">
<div style={{ position: "sticky", bottom: 20 }}>
<Card withBorder>
<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