Compare commits
213
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
678e24cd2b | ||
|
|
8307024abe | ||
|
|
7d560e0675 | ||
|
|
aafd5f4c9c | ||
|
|
1b489cc2e0 | ||
|
|
a547e29607 | ||
|
|
8cbd53d362 | ||
|
|
7cf27c3f39 | ||
|
|
8a3f8d5c12 | ||
|
|
8f75954ca9 | ||
|
|
1028752cbc | ||
|
|
b8bdbc2f3e | ||
|
|
889d732e03 | ||
|
|
54bd8d6343 | ||
|
|
6f1c81bc04 | ||
|
|
00688d5c1a | ||
|
|
fc8b9178a3 | ||
|
|
fb7c7d35d9 | ||
|
|
198fda7854 | ||
|
|
66a1d5b210 | ||
|
|
5e68aab970 | ||
|
|
ca9d836fac | ||
|
|
82a50de403 | ||
|
|
41606cef86 | ||
|
|
7d9caf407c | ||
|
|
59528ca1c3 | ||
|
|
9373e3d69a | ||
|
|
8acc134a36 | ||
|
|
a4ff57ed85 | ||
|
|
c530190638 | ||
|
|
d6b6b696b4 | ||
|
|
1473605445 | ||
|
|
255b544a6c | ||
|
|
bcbde92c2e | ||
|
|
63f82f1a35 | ||
|
|
0a8e4fead0 | ||
|
|
03ecfef429 | ||
|
|
7a97b7187b | ||
|
|
899dfd7230 | ||
|
|
fc1dbd37f1 | ||
|
|
b9c89c61a2 | ||
|
|
b5dbeae864 | ||
|
|
f214c25e9c | ||
|
|
fcee8177af | ||
|
|
0d6f9337b4 | ||
|
|
d513c7fe46 | ||
|
|
0425c3630c | ||
|
|
84db5ccfe7 | ||
|
|
db7f1a0374 | ||
|
|
1f045b2abf | ||
|
|
8dad83f720 | ||
|
|
da99dc3080 | ||
|
|
52ab119a1f | ||
|
|
bbc23a6c65 | ||
|
|
1db3b2c2cf | ||
|
|
1b663e56ee | ||
|
|
5ee14136ac | ||
|
|
4a47fc4551 | ||
|
|
3abdae490e | ||
|
|
9d3d40d4bd | ||
|
|
1367e07f20 | ||
|
|
bbdbae636a | ||
|
|
10719bc72e | ||
|
|
48d8517ed1 | ||
|
|
074902ecc8 | ||
|
|
d17752188f | ||
|
|
7619492516 | ||
|
|
5e035e14ed | ||
|
|
9f36be0284 | ||
|
|
ca6a019c1e | ||
|
|
05eebc097f | ||
|
|
0bd9c6f290 | ||
|
|
bc896c0b03 | ||
|
|
14380e1cdf | ||
|
|
0ece179558 | ||
|
|
cae976b6d8 | ||
|
|
c2064faa95 | ||
|
|
6d1538d3b1 | ||
|
|
215f9a7d21 | ||
|
|
eae55ce2f0 | ||
|
|
12412947ee | ||
|
|
dec46f3ee4 | ||
|
|
e65c98c290 | ||
|
|
1b6c4a9022 | ||
|
|
3246dc4c91 | ||
|
|
97e89a936c | ||
|
|
aaf86a9d13 | ||
|
|
298937b00c | ||
|
|
25ac7b6a9d | ||
|
|
b937d4eb18 | ||
|
|
8d35c0c71f | ||
|
|
a5ce2e9b7a | ||
|
|
2efc225617 | ||
|
|
f24d757ec8 | ||
|
|
9edeba5cd2 | ||
|
|
93276abc8c | ||
|
|
dd63d5600b | ||
|
|
25be53244b | ||
|
|
8ab3f136b8 | ||
|
|
4bb862ee76 | ||
|
|
0c5251a971 | ||
|
|
454752e46d | ||
|
|
596bfc317d | ||
|
|
97444d209a | ||
|
|
ee51227a55 | ||
|
|
db7c850596 | ||
|
|
30344bdaae | ||
|
|
7847eea1a3 | ||
|
|
fbe381cbb5 | ||
|
|
8d98408cfe | ||
|
|
9234ca0f32 | ||
|
|
61a2f8cb6c | ||
|
|
596833f227 | ||
|
|
cc71ab8cd9 | ||
|
|
49d623e5c2 | ||
|
|
ab21a9fece | ||
|
|
41fffbb79b | ||
|
|
08da71d67f | ||
|
|
65ff0ecb34 | ||
|
|
80d46b0d85 | ||
|
|
6e15f8bcf8 | ||
|
|
294217fcba | ||
|
|
3c99c75ad7 | ||
|
|
20dbdcedf2 | ||
|
|
a2f5668c44 | ||
|
|
09e9cd4388 | ||
|
|
5c9fd28ae1 | ||
|
|
8f0c9435ff | ||
|
|
96e92485a5 | ||
|
|
f9aae23f46 | ||
|
|
fd024e32d2 | ||
|
|
5010ecf188 | ||
|
|
f0d8e97f8d | ||
|
|
ed8ef15f12 | ||
|
|
d97f1bd34b | ||
|
|
d86293de0f | ||
|
|
54e444d69d | ||
|
|
d5c3aebd26 | ||
|
|
1777ca4446 | ||
|
|
56e3bd1cf2 | ||
|
|
7b7045202d | ||
|
|
1e0bf40040 | ||
|
|
bdffb63ebc | ||
|
|
4db4d947db | ||
|
|
0755fc1e26 | ||
|
|
d460ac8bbb | ||
|
|
1d73513989 | ||
|
|
e3f679237d | ||
|
|
45ff97972c | ||
|
|
36b75a071b | ||
|
|
3f7d934fa5 | ||
|
|
76974f676d | ||
|
|
5ddbf184fd | ||
|
|
c61695b142 | ||
|
|
b9b938c658 | ||
|
|
6f27ede8bf | ||
|
|
79ae42f0c3 | ||
|
|
c754946089 | ||
|
|
ab7d385a13 | ||
|
|
c53b38a738 | ||
|
|
d0c7ba401f | ||
|
|
d94cadd3e7 | ||
|
|
cce3e455fa | ||
|
|
b28beec2c6 | ||
|
|
d421046d15 | ||
|
|
d6c1a87a03 | ||
|
|
50907c7289 | ||
|
|
9c8fa8158b | ||
|
|
e6e413bcb7 | ||
|
|
81d14e39ab | ||
|
|
f207587791 | ||
|
|
5cc2df19ed | ||
|
|
0a80d70730 | ||
|
|
25c9955cc6 | ||
|
|
557ebe0a2f | ||
|
|
e8a2c87c2d | ||
|
|
4f6e335f72 | ||
|
|
df967751f6 | ||
|
|
6aceb2fd1f | ||
|
|
60cbd7bb7a | ||
|
|
c0f1d6624c | ||
|
|
67d146220a | ||
|
|
41db737905 | ||
|
|
3b128585a3 | ||
|
|
cdaf2a838c | ||
|
|
926a6856fb | ||
|
|
c6160da222 | ||
|
|
e729dfd891 | ||
|
|
eb71f936c6 | ||
|
|
c10ebbc863 | ||
|
|
106c3bfa90 | ||
|
|
27a6467896 | ||
|
|
8331a58471 | ||
|
|
ec74c36a6a | ||
|
|
487d98275c | ||
|
|
187a479bd5 | ||
|
|
0bfe6ddabe | ||
|
|
51d8a3cc53 | ||
|
|
06db42129c | ||
|
|
a11583feb1 | ||
|
|
446931bc0a | ||
|
|
34bcc62415 | ||
|
|
a2e3c5d3bc | ||
|
|
ed9646b0c6 | ||
|
|
e0de370340 | ||
|
|
7e5453d47c | ||
|
|
3b2fc2527e | ||
|
|
0a0b3a12c9 | ||
|
|
ba263dbe47 | ||
|
|
8a7c4875bf | ||
|
|
377f2f6a20 | ||
|
|
c69e33ac8c | ||
|
|
121a83040d |
No files matched your search
@@ -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
@@ -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
|
||||
```
|
||||
|
||||
@@ -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",
|
||||
};
|
||||
@@ -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.
|
||||
|
||||

|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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'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
|
||||
|
||||
@@ -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 />
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
@@ -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
@@ -2151,8 +2151,7 @@
|
||||
"beszelAlerts",
|
||||
"beszelSystemStats",
|
||||
"traefik",
|
||||
"customApi",
|
||||
"wud"
|
||||
"customApi"
|
||||
]
|
||||
},
|
||||
"options": {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
+9
-21
@@ -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()}
|
||||
|
||||
+57
-49
@@ -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}
|
||||
/>
|
||||
)}
|
||||
|
||||
+1
-3
@@ -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
Reference in new issue
Block a user