Compare commits

...
Author SHA1 Message Date
ajnart c483b5608c fix: honor HOMARR_DISABLE_IPV6 override in nginx config generation
Review feedback on #6539: give users an explicit opt-out from the IPv6
listener even when the host reports IPv6. The issue reporter (#4596)
requested such an env var, and the pattern matches the existing
ENABLE_DNS_CACHING IPv6 toggle. Auto-detection behavior is unchanged when
the var is unset. Validated in containers: override wins, dual-stack
preserved by default, IPv4-only fallback on IPv6-less hosts.
2026-08-08 09:01:38 +02:00
ajnart cb53e2e548 fix: keep nginx IPv6 listen only when IPv6 is available (#4596)
Commit 2c48b1896 (1.45.2) made the internal nginx proxy listen on the IPv6
wildcard ('listen [::]:7575;'), which aborts nginx startup on hosts/VMs with
IPv6 disabled:

    nginx: [emerg] socket() [::]:7575 failed (97: Address family not supported by protocol)

A plain revert would reintroduce #2697 and break IPv6-only/dual-stack
environments where this listener is required. Instead, only emit the IPv6
listen directive when the host actually has IPv6 configured.

Reuse the envsubst step run.sh already performs: compute NGINX_LISTEN_IPV6
from /proc/net/if_inet6 and substitute it into the template. proc files always
report size 0, so presence is detected by grepping the file (not 'test -s').
2026-08-08 00:59:44 +02:00
Thomas 06724cfeb2 ci: checkout repo in bot auto-merge workflows (#6535)
* ci: drop --delete-branch from reconcile auto-merge call

The reconcile job runs without a git checkout, so gh pr merge --delete-branch
fails with 'not a git repository' before enabling auto-merge. Auto-merge
itself needs no local git state; branch cleanup is already handled by the
PR-creating workflows.

* ci: checkout repo in bot auto-merge workflows

gh pr merge fails with 'not a git repository' when no checkout is present,
which prevented the reconcile job from enabling auto-merge. Restore the
checkout the previous approval workflow relied on.
2026-08-07 20:31:56 +02:00
homarr-update-contributors[bot] 131a5558b5 chore: update contributors (#6527) 2026-08-07 20:26:34 +02:00
Thomas b32ad82850 perf: cut cold route compilation and unblock preview images (#6441) 2026-08-07 20:24:26 +02:00
Thomas 622e1dcfa5 ci: drop --delete-branch from reconcile auto-merge call (#6534)
The reconcile job runs without a git checkout, so gh pr merge --delete-branch
fails with 'not a git repository' before enabling auto-merge. Auto-merge
itself needs no local git state; branch cleanup is already handled by the
PR-creating workflows.
2026-08-07 20:12:37 +02:00
homarr-releases[bot] 9674bde758 chore: update bug report template (#6533)
Co-authored-by: Releases Homarr <175486441+homarr-releases[bot]@users.noreply.github.com>
2026-08-07 18:08:24 +00:00
Thomas b479ada14f ci: auto-approve and auto-merge trusted bot pull requests (#6531)
* ci: auto-approve and auto-merge trusted bot pull requests

Replace the approve-only automatic-approval workflow with a combined
approve + enable-auto-merge workflow that also re-runs on every push,
plus a scheduled reconcile job that re-enables auto-merge for prepared
bot PRs if GitHub ever disables it. Bot PRs are gated to the known bot
actors, same-repo head, and base branch dev.

* ci: match bot authors by app slug in reconcile filter

gh pr list reports pull request authors of GitHub Apps as app/<slug>;
adjust the allowlist so the reconcile job actually matches bot PRs.
2026-08-07 20:05:46 +02:00
Releases Homarr bff3383ac9 chore(release): 1.74.0 [skip ci] 2026-08-07 17:41:07 +00:00
homarr-releases[bot] d865733838 chore(release): automatic release v1.74.0 2026-08-07 17:39:49 +00:00
homarr-crowdin[bot] 85fcea3837 chore(lang): updated translations from crowdin (#6500) 2026-08-07 18:22:43 +02:00
Thomas f76a0f240a feat(apps): allow custom URI schemes in application links (#6521) 2026-08-06 21:52:56 +02:00
Thomas 637f03e107 docs(settings): update analytics docs to PostHog (#6525) 2026-08-06 21:47:04 +02:00
Thomas 55214ccbc5 fix(integrations): throw on total item-info failure to preserve stale-cache fallback (#6522) 2026-08-06 21:42:46 +02:00
Thomas 778533a62e fix: revert regressions from recent integration/releases/app-href changes (#6514) 2026-08-03 17:37:53 +02:00
Thomas b4fba47a23 fix(releases): add success flag to successful response for correct error detection (#6512) 2026-08-03 17:10:35 +02:00
Thomas 76ddfceb13 fix: restore auto-refresh for docker and notifications widgets (#6511) 2026-08-03 17:07:46 +02:00
Thomas 2f6b96e5ba feat: restrict integrations management to integration-full-all permission (#6509) 2026-08-03 17:04:35 +02:00
Thomas f1b7a5146a fix: allow [homarr_base] template variables in app href validation (#6508) 2026-08-03 16:47:40 +02:00
Thomas Camlong 05292ae1f5 fix: map legacy media server options 2026-08-03 15:24:30 +02:00
homarr-renovate[bot] 48fc8db2b4 chore(deps): update semantic-release monorepo (major) (#6486) 2026-08-03 14:39:28 +02:00
Thomas 018f84f80d feat(dev): add Docker development CLI (#6495) 2026-08-03 14:38:56 +02:00
Thomas d58a47b776 fix: restrict system health monitoring integrations (#6505) 2026-08-02 10:17:05 +02:00
Thomas 797bdd001f fix: keep direct icon URLs visible (#6504) 2026-08-01 23:28:18 +02:00
Bergasha 49314533bf feat(Media player widget): expands on Direct Play and Transcoding badges (#6489) 2026-08-01 23:21:13 +02:00
Thomas 27ca058088 feat(widgets): persist Beszel and Docker table layouts (#6490) 2026-08-01 23:20:09 +02:00
homarr-releases[bot] f4449f9581 chore: update bug report template (#6498) 2026-08-01 23:08:14 +02:00
Thomas b52bc03c8d Merge pull request #6499 from homarr-labs/docs/update-helm-docs
docs(helm): update chart installation page
2026-08-01 23:07:58 +02:00
Thomas edb56f3fcd fix: accept direct icon URLs
Use valid direct HTTP(S) icon URLs without repository search.
2026-08-01 23:06:26 +02:00
homarr-crowdin[bot] fcc5130068 chore(lang): update translations from crowdin (#6457) 2026-07-31 23:50:10 +02:00
homarr-update-contributors[bot] c7751e293d chore: update contributors (#6496) 2026-07-31 23:49:37 +02:00
github-actions[bot] bd669a1a8f docs(helm): update chart installation page 2026-07-31 19:35:37 +00:00
Releases Homarr 9b5b79525f chore(release): 1.73.0 [skip ci] 2026-07-31 18:16:44 +00:00
homarr-releases[bot] 426929556f chore(release): automatic release v1.73.0 2026-07-31 18:15:13 +00:00
Thomas ade640dcea test(navidrome): complete cleanup review (#6494)
* test(navidrome): hoist album fixture helper

* test(navidrome): address coderabbit cleanup feedback
2026-07-31 14:39:11 +02:00
Thomas 61128ddcc8 fix(navidrome): clean up audio stats integration (#6493) 2026-07-31 14:20:51 +02:00
Thomas 6a3cee8107 fix(navidrome): complete media server session data (#6492) 2026-07-31 13:53:06 +02:00
Matt Van Horn 06d9c07d22 feat: expose Navidrome as a media-server (now-playing) integration (#6070) 2026-07-31 13:30:44 +02:00
Thomas 49d0f1a141 docs: explain Podman socket setup (#6487)
* docs: explain Podman socket setup

* docs: clarify Podman runtime requirements

* docs: complete Podman compose examples
2026-07-29 16:53:29 +02:00
Clarkar00 ac73b9d891 fix(widgets): refetch after render errors (#6399)
Remove incompatible persisted widget query data before resetting a crashed widget so it can fetch fresh data instead of re-rendering the same stale payload.
2026-07-28 23:12:03 +02:00
Thomas 489980814c Merge pull request #6465 from homarr-labs/docs/update-helm-docs
docs(helm): update chart installation page
2026-07-28 22:59:08 +02:00
Thomas 14654acced chore: gate Renovate updates behind dashboard approval (#6485) 2026-07-28 22:55:32 +02:00
Thomas 2885f7a297 fix(widgets): preserve data on integration failures (#6484) 2026-07-28 22:18:08 +02:00
Thomas 906c9f11fa feat(widgets): rework downloads widget with mantine-datatable (#6430) 2026-07-28 21:50:09 +02:00
Thomas 3419805a49 fix(beszel): restore balanced grid layout for Beszel System Grid widget (#6389) 2026-07-28 21:49:04 +02:00
homarr-releases[bot] 51a6cbe6d4 chore: update bug report template (#6464) 2026-07-28 14:56:56 +02:00
homarr-update-contributors[bot] d08eb2afa6 chore: update contributors (#6461) 2026-07-28 14:56:42 +02:00
Tim MooreandTim Moore 91a6b19ba1 fix(beszel): expire stored sessions so the integration recovers from token expiry (#6476)
Co-authored-by: Tim Moore <tim.moore@ingenuity.com.au>
2026-07-28 14:55:33 +02:00
Thomas e66979bab6 feat(auth): link credentials users with OIDC (#6445) 2026-07-28 14:49:51 +02:00
homarr-renovate[bot] 4ef7641cb2 chore(deps): update pnpm to v11.15.1 (#6466)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-07-25 02:43:47 +00:00
github-actions[bot] f33fc838cf docs(helm): update chart installation page 2026-07-24 21:17:32 +00:00
Releases Homarr f0019fa213 chore(release): 1.72.0 [skip ci] 2026-07-24 18:11:58 +00:00
homarr-releases[bot] ba8536b36d chore(release): automatic release v1.72.0 2026-07-24 18:10:37 +00:00
homarr-renovate[bot] 965aec5155 chore(deps): update pnpm to v11.15.0 (#6455)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-07-23 16:29:37 +00:00
homarr-crowdin[bot] 387db030cd chore(lang): updated translations from crowdin (#6283) 2026-07-23 09:14:14 +02:00
homarr-renovate[bot] 8e557c9924 chore(deps): update pnpm to v11.14.0 (#6449)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-07-23 05:58:19 +00:00
Thomas f26e60edf0 fix(old-import): map new clock weather options (#6452) 2026-07-23 02:08:50 +02:00
Thomas 7bbfd74454 Merge pull request #6384 from homarr-labs/docs/update-helm-docs
docs(helm): update chart installation page
2026-07-23 02:04:11 +02:00
homarr-releases[bot] 018b1ea4da chore: update bug report template (#6382) 2026-07-23 02:03:43 +02:00
Thomas 14c3fa2e76 fix(beszel): prevent live subscription memory growth (#6448)
* fix(beszel): bound live stats subscriptions

* fix(beszel): avoid blocking subscription teardown

* fix(import): map new clock options
2026-07-23 02:03:08 +02:00
Thomas 9168f8114b fix(manage): hide sidebar nav items from unprivileged users (#6236) 2026-07-23 02:01:10 +02:00
Thomas 9cabb42edb fix(widgets): add 350ms hover delay before opening calendar day popover (#6431) 2026-07-23 01:54:23 +02:00
Thomas bfba3c9215 fix(releases): avoid unnecessary GitHub pagination for LinuxServer updates (#6444) 2026-07-23 00:20:54 +02:00
Bergasha 7184bc09c1 feat(clock widget): Adds animated weather and temp (#6372) 2026-07-23 00:04:32 +02:00
Thomas 9bb0c0f811 fix(media-requests): cache statistics responses (#6443) 2026-07-22 17:38:28 +02:00
Thomas cf0e469465 feat: restrict apps management to board modifiers (#6425) 2026-07-21 16:56:33 +02:00
Thomas 8e7fa782b3 feat(weather): add humidity display (#6429) 2026-07-21 16:36:03 +02:00
Thomas 97652802f0 feat(immich): show random photos without album (#6424) 2026-07-21 16:10:45 +02:00
Thomas 1706289124 fix(media-server): guard against undefined playback from stale cached data (#6433) 2026-07-21 16:01:37 +02:00
homarr-renovate[bot] dd23de7aaa chore(deps): update pnpm to v11.13.1 (#6422)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-07-21 05:54:52 +00:00
Thomas f1602f67d4 fix: stop auto-seeding ArchiveTeam Warrior integration and widget on default dashboards (#6413) 2026-07-20 17:01:07 +02:00
Thomas c03cd97d3a fix(test): use Home Assistant long-lived token JWT
* fix(test): use Home Assistant long-lived token

* fix(test): revert to correct Home Assistant long-lived token JWT
2026-07-20 15:27:50 +02:00
Thomas a8e9a074d3 Merge pull request #6411 from homarr-labs/mysql-migration-test-timeout
fix(db): increase mysql migration test timeout to 120s
2026-07-20 15:19:12 +02:00
Thomas Camlong 1e9d3e0656 fix(db): increase mysql migration test timeout to 120s 2026-07-20 15:18:50 +02:00
Thomas 01e0f3612d docs(immich): list Asset View permission (#6408) 2026-07-20 14:57:07 +02:00
Thomas 0015a6d88d fix(immich): use thumbnail endpoint instead of original for album carousel (#6396)
* fix(immich): use thumbnail endpoint instead of original for album carousel

Switches from /api/assets/:id/original to /api/assets/:id/thumbnail to fix
HEIC image incompatibility (thumbnails are always JPEG), reduce bandwidth,
and improve loading speed.

Closes #6388

* working on immich

* Improve image proxy error handling

* fix(image-proxy): use trusted fetch response type

* docs(immich): require Asset View permission
2026-07-20 14:53:27 +02:00
Thomas 77dc317da2 chore(ci): remove memory regression tests (#6395) 2026-07-18 22:55:38 +02:00
homarr-update-contributors[bot] 6aeda3faaf chore: update contributors (#6378) 2026-07-18 22:43:01 +02:00
github-actions[bot] ac6a0c9254 docs(helm): update chart installation page 2026-07-17 19:24:43 +00:00
Releases Homarr ca358d9f8b chore(release): 1.71.0 [skip ci] 2026-07-17 17:58:17 +00:00
homarr-releases[bot] 03e0683892 chore(release): automatic release v1.71.0 2026-07-17 17:57:04 +00:00
Junker der Provinz 6ac4c6a7b9 fix(search): let public dashboards use generic custom search engines (#6375) 2026-07-17 14:53:52 +02:00
Junker der Provinz a0e0bf65ff fix(releases): let LinuxServer.io version filters find older releases via GitHub (#6376) 2026-07-17 14:53:01 +02:00
Junker der Provinz bc018e1fe0 fix(docker): disable the broken full-screen toggle on the container table (#6374) 2026-07-17 14:43:37 +02:00
Thomas 5e39c8813a feat(preferences): add enableRightClickOnWidgets user preference (#6298) 2026-07-17 14:42:19 +02:00
Thomas 06719cdef0 fix(ci): remove coverage report, scope format to changed files, fix oxlint config (#6359) 2026-07-16 10:21:07 +02:00
Bergasha 5144e14da4 fix(media-server): populate playback progress, transcode status, and network location for streams (#6315) 2026-07-15 16:27:58 +02:00
Thomas 8528d5068b feat(media-requests): filter requests list by status and time window (#6320) 2026-07-15 16:27:24 +02:00
Thomas c6c4428f7d Revert "fix(docker): report current CPU usage instead of the lifetime average" (#6357) 2026-07-15 14:55:18 +02:00
Thomas 7e4cb0201b fix(widgets): add refetchInterval to WidgetDefinition for per-widget polling (#6352) 2026-07-15 11:52:35 +02:00
Thomas 7887ce12c0 fix(integrations): resolve intermittent Seerr/Overseerr crashes and add query status to context menu (#6347) 2026-07-15 11:41:18 +02:00
Thomas 2d450239ac fix(releases): add required octokit throttling rate limit handlers (#6338) 2026-07-15 11:27:32 +02:00
Thomas bf4ccd8cb3 fix(ci): remove persist-credentials: false to fix git-auto-commit-action push (#6346) 2026-07-15 10:33:32 +02:00
423 changed files with 64442 additions and 27314 deletions

No files matched your search

@@ -0,0 +1,622 @@
# Migrating Tables from mantine-react-table to mantine-datatable
This document captures every pattern, fix, and decision from the downloads widget rework (PR #6430). Use it as a playbook when porting other tables.
## Migration Candidates
Current `mantine-react-table` consumers that could be migrated:
**Widgets (in `packages/widgets/`):**
- `docker/component.tsx` — Docker container table
- `media-server/component.tsx` — Media server sessions
**Admin pages (in `apps/nextjs/src/app/[locale]/manage/`):**
- `tools/docker/docker-table.tsx`
- `tools/tasks/_components/tasks-table.tsx`
- `tools/api/components/api-keys.tsx`
- `users/_components/user-list.tsx`
- `users/invites/_components/invite-list.tsx`
- `tools/kubernetes/` — 8 Kubernetes resource tables
**Modals:**
- `packages/modals-collection/src/search-engines/request-media-modal.tsx`
**Shared hook to remove once all migrated:**
- `packages/ui/src/hooks/use-translated-mantine-react-table.ts`
---
## 1. Core Migration: mantine-react-table → mantine-datatable
### Import changes
```typescript
// BEFORE
import { MantineReactTable, useMantineReactTable } from "mantine-react-table";
import type { MRT_ColumnDef } from "mantine-react-table";
// AFTER
import { DataTable, useDataTableColumns } from "mantine-datatable";
import type { DataTableColumn, DataTableSortStatus } from "mantine-datatable";
```
### Column definition shape
```typescript
// BEFORE (MRT)
const columns: MRT_ColumnDef<MyRow>[] = [
{
accessorKey: "name",
header: "Name",
size: 200,
enableSorting: true,
Cell: ({ row }) => <Text>{row.original.name}</Text>,
},
];
// AFTER (mantine-datatable)
const columns: DataTableColumn<MyRow>[] = [
{
accessor: "name", // not accessorKey
title: "Name", // not header
width: 200, // not size
sortable: true, // not enableSorting
render: (record) => <Text>{record.name}</Text>, // not Cell
},
];
```
Key differences:
- `accessorKey` → `accessor`
- `header` → `title`
- `size` → `width`
- `Cell: ({ row }) =>` → `render: (record) =>`
- `enableSorting` → `sortable`
- `enableColumnActions` → does not exist (no column action menus)
- `mantineTableBodyCellProps` → `cellsStyle` in `defaultColumnProps`
### Table component
```typescript
// BEFORE
const table = useTranslatedMantineReactTable({ columns, data, ... });
return <MantineReactTable table={table} />;
// AFTER
return (
<DataTable
records={data}
columns={columns}
sortStatus={sortStatus}
onSortStatusChange={setSortStatus}
...
/>
);
```
### Sorting is manual
mantine-datatable does NOT sort for you. You must sort the data yourself:
```typescript
const [sortStatus, setSortStatus] = useState<DataTableSortStatus<MyRow>>({
columnAccessor: "name",
direction: "asc",
});
const sortedData = useMemo(() => {
const mult = sortStatus.direction === "desc" ? -1 : 1;
return [...data].toSorted((a, b) => {
const aVal = a[sortStatus.columnAccessor];
const bVal = b[sortStatus.columnAccessor];
if (typeof aVal === "string") return aVal.localeCompare(bVal as string) * mult;
return ((aVal as number) - (bVal as number)) * mult;
});
}, [data, sortStatus]);
// Pass sortedData to records, not data
<DataTable records={sortedData} sortStatus={sortStatus} onSortStatusChange={setSortStatus} />
```
---
## 2. Making Tables Transparent (Widget Context)
Widgets sit inside board cards that have user-controlled opacity. The table must be fully transparent so the card background shows through. mantine-datatable defaults to `var(--mantine-color-body)` which is opaque.
Create a CSS file alongside the component:
```css
/* styles.css */
.my-table,
.my-table .mantine-datatable-table,
.my-table .mantine-datatable-table thead,
.my-table .mantine-datatable-table tbody,
.my-table .mantine-datatable-table tfoot,
.my-table th,
.my-table td {
background-color: transparent !important;
}
.my-table .mantine-datatable-table tr {
background-color: transparent;
}
/* Hover: translucent instead of opaque */
.my-table .mantine-datatable-table tbody tr:hover {
background-color: color-mix(in srgb, var(--mantine-color-default-hover) 40%, transparent) !important;
}
/* Headers: frosted glass effect */
.my-table th {
white-space: nowrap;
background-color: color-mix(in srgb, var(--mantine-color-body) 60%, transparent) !important;
backdrop-filter: blur(8px);
}
/* Cell overflow */
.my-table td {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* Hide sort icon until hover */
.my-table .mantine-datatable-header-cell-sortable-icon {
opacity: 0;
transition: opacity 150ms;
}
.my-table th:hover .mantine-datatable-header-cell-sortable-icon {
opacity: 1;
}
/* Row expansion area */
.my-table .mantine-datatable-row-expansion-cell {
background-color: color-mix(in srgb, var(--mantine-color-body) 40%, transparent) !important;
}
```
Import it: `import "./styles.css";` and apply via `className="my-table"`.
---
## 3. Responsive Column Visibility
Use a lookup table of width breakpoints instead of media queries. The widget receives `width` as a prop.
```typescript
interface SizeConfig {
fontSize: "xs" | "sm";
iconSize: number;
cellPadding: number;
showSpeedColumns: boolean;
showTimeColumn: boolean;
showStateColumn: boolean;
}
const SIZE_BREAKPOINTS: { maxWidth: number; config: SizeConfig }[] = [
{ maxWidth: 300, config: { fontSize: "xs", iconSize: 12, cellPadding: 2, showSpeedColumns: false, showTimeColumn: false, showStateColumn: false } },
{ maxWidth: 500, config: { fontSize: "xs", iconSize: 14, cellPadding: 4, showSpeedColumns: false, showTimeColumn: true, showStateColumn: false } },
];
const DEFAULT_SIZE_CONFIG: SizeConfig = { fontSize: "sm", iconSize: 14, cellPadding: 4, showSpeedColumns: true, showTimeColumn: true, showStateColumn: true };
function getSizeConfig(width: number): SizeConfig {
for (const { maxWidth, config } of SIZE_BREAKPOINTS) {
if (width < maxWidth) return config;
}
return DEFAULT_SIZE_CONFIG;
}
// Usage: memoize it
const size = useMemo(() => getSizeConfig(width), [width]);
```
Then use a visibility check lookup table for columns:
```typescript
const columnVisibilityChecks: Partial<Record<string, (ctx: ColumnContext) => boolean>> = {
upSpeed: (ctx) => ctx.hasTorrents && ctx.size.showSpeedColumns,
downSpeed: (ctx) => ctx.size.showSpeedColumns,
time: (ctx) => ctx.size.showTimeColumn,
state: (ctx) => ctx.size.showStateColumn,
};
function shouldIncludeColumn(accessor: string, ctx: ColumnContext): boolean {
if (!ctx.optionsColumnSet.has(accessor)) return false;
const check = columnVisibilityChecks[accessor];
if (check && !check(ctx)) return false;
return true;
}
```
---
## 4. Column Persistence (Drag/Resize → Server)
`useDataTableColumns` stores column order and widths in localStorage by default. For widgets, this must persist server-side so it works across devices.
### Hidden widget options
In `index.ts`, add hidden text options:
```typescript
columnOrder: factory.text({ defaultValue: "" }),
columnWidths: factory.text({ defaultValue: "" }),
```
With visibility override:
```typescript
{
columnOrder: { shouldHide: () => true },
columnWidths: { shouldHide: () => true },
}
```
### Translation keys required
Even hidden options need translation keys or the translation test fails:
```json
"widget.mywidget.option.columnOrder.label": "Column order",
"widget.mywidget.option.columnWidths.label": "Column widths"
```
### Old-import mappings
Add to `packages/old-import/src/widgets/options.ts`:
```typescript
columnOrder: () => undefined,
columnWidths: () => undefined,
```
### Dynamic storeKey
Use a key that includes `itemId` and sorted column names. This prevents cross-widget localStorage bleeding:
```typescript
const storeKey = `mytable-${itemId ?? "preview"}-${[...options.columns].toSorted().join(",")}`;
```
### Hydration from server → localStorage
On mount, seed `useDataTableColumns` with server-persisted values. Use a `hydrated` ref to prevent the persist effect from immediately writing defaults back:
```typescript
const { effectiveColumns, columnsOrder, columnsWidth, setColumnsOrder, setMultipleColumnWidths } =
useDataTableColumns<MyRow>({ key: storeKey, columns });
const lastStoreKey = useRef(storeKey);
const hydrated = useRef(false);
useEffect(() => {
if (lastStoreKey.current !== storeKey) {
lastStoreKey.current = storeKey;
hydrated.current = false;
}
if (hydrated.current) return;
if (savedOrder.length > 0) setColumnsOrder(savedOrder);
if (Object.keys(savedWidths).length > 0) {
setMultipleColumnWidths(
Object.entries(savedWidths).map(([accessor, w]) => ({ accessor, width: w })),
);
}
requestAnimationFrame(() => { hydrated.current = true; });
}, [storeKey, savedOrder, savedWidths, setColumnsOrder, setMultipleColumnWidths]);
```
### Persist localStorage → server
Watch for changes and write back:
```typescript
const prevOrder = useRef(columnsOrder);
const prevWidths = useRef(columnsWidth);
useEffect(() => {
if (!hydrated.current) return;
const orderChanged = JSON.stringify(columnsOrder) !== JSON.stringify(prevOrder.current);
const widthsChanged = JSON.stringify(columnsWidth) !== JSON.stringify(prevWidths.current);
prevOrder.current = columnsOrder;
prevWidths.current = columnsWidth;
if (!orderChanged && !widthsChanged) return;
if (orderChanged) persistOption({ columnOrder: JSON.stringify(columnsOrder) });
if (widthsChanged) {
const widthMap: Record<string, number> = {};
for (const entry of columnsWidth) {
const key = Object.keys(entry)[0];
if (!key) continue;
const w = entry[key as keyof typeof entry];
if (typeof w === "number") widthMap[key] = w;
else if (typeof w === "string" && w.endsWith("px")) widthMap[key] = parseInt(w, 10);
}
persistOption({ columnWidths: JSON.stringify(widthMap) });
}
}, [columnsOrder, columnsWidth, persistOption]);
```
The `persistOption` helper calls both `setOptions` (local state) and `saveItemOptions` (server mutation).
---
## 5. Context Menu (Right-Click)
Do NOT install `mantine-contextmenu`. Use Mantine's `Menu` + `Portal` directly:
```typescript
interface ContextMenuState {
x: number;
y: number;
item: MyRow;
}
const [contextMenu, setContextMenu] = useState<ContextMenuState | null>(null);
const handleContextMenu = useCallback(
({ record, event }: { record: MyRow; event: React.MouseEvent }) => {
event.preventDefault();
setContextMenu({ x: event.clientX, y: event.clientY, item: record });
},
[],
);
// On DataTable:
<DataTable onRowContextMenu={handleContextMenu} />
// Render:
{contextMenu && <RowContextMenu state={contextMenu} onClose={closeContextMenu} />}
```
The menu component uses `Portal` for correct z-index:
```tsx
function RowContextMenu({ state, onClose }: { state: ContextMenuState; onClose: () => void }) {
return (
<Portal>
<Menu opened onClose={onClose} closeOnItemClick={false} position="right-start" offset={0}>
<Menu.Target>
<Box style={{ position: "fixed", left: state.x, top: state.y, width: 0, height: 0 }} />
</Menu.Target>
<Menu.Dropdown>
<Menu.Item onClick={() => { /* action */ onClose(); }}>Action</Menu.Item>
</Menu.Dropdown>
</Menu>
</Portal>
);
}
```
**Key fix:** Use `closeOnItemClick={false}` if any menu item needs a two-step confirmation (like delete → confirm). Close the menu on scroll: `onScroll={() => { if (contextMenu) closeContextMenu(); }}`.
---
## 6. Row Expansion
```typescript
<DataTable
rowExpansion={{
trigger: "click",
allowMultiple: true,
collapseProps: {
transitionDuration: 200,
animateOpacity: true,
transitionTimingFunction: "ease-out",
},
content: ({ record, collapse }) => <ExpandedRow item={record} collapse={collapse} />,
}}
/>
```
**Critical:** Do NOT set `onRowClick` when using `rowExpansion` with `trigger: "click"`. They conflict and the expansion won't work.
---
## 7. Common Pitfalls & Fixes
### Status indicator dots
Don't use `<Badge variant="dot" />` without children — it renders a pill with empty space. Use a plain `Box`:
```tsx
<Box
w={8}
h={8}
style={{
borderRadius: "50%",
backgroundColor: `var(--mantine-color-${color}-filled)`,
flexShrink: 0,
}}
/>
```
### Tooltip overflow on long text columns
When a column has `ellipsis: true`, the tooltip target element can still extend beyond the cell bounds if inner elements don't clip. Fix:
```tsx
<Tooltip position="bottom-start" ...>
<Group style={{ overflow: "hidden" }}>
...
</Group>
</Tooltip>
```
Use `position="bottom-start"` instead of `position="right"` so the tooltip never escapes the widget horizontally.
### Dark mode tooltips
Mantine tooltips default to the theme's tooltip color which may be white in light mode and stay white in dark mode. Always set `color="dark"` on informational tooltips inside widgets.
### Division by zero
Guard all ratio calculations:
```typescript
if (item.size > 0) ratio = item.sent / item.size;
if (totalDown > 0) ratio = totalUp / totalDown;
```
### Composite idAccessor
When rows can come from multiple integrations, IDs may collide. Use a composite key:
```typescript
idAccessor={(record) => `${record.integration.id}:${record.id}`}
```
### JSON option parsing
Always wrap in try/catch:
```typescript
function parseJsonOption<T>(value: string | undefined, fallback: T): T {
if (!value) return fallback;
try { return JSON.parse(value) as T; }
catch { return fallback; }
}
```
### Clipboard API
`navigator.clipboard.writeText()` can throw in non-HTTPS contexts. Always catch:
```typescript
void navigator.clipboard.writeText(text).catch(() => {});
```
### Sync sortStatus with widget options
If the widget has configurable default sort, sync it:
```typescript
useEffect(() => {
setSortStatus({ columnAccessor: options.defaultSort, direction: defaultSortDirection });
}, [options.defaultSort, defaultSortDirection]);
```
---
## 8. Accessibility
- All `ActionIcon` components need `aria-label`
- Filter toggles (client badges, status badges) need `aria-pressed={isActive}`
- `textSelectionDisabled` on DataTable prevents accidental text selection during drag/resize
---
## 9. Error Handling
- Every mutation needs an `onError` callback with `showErrorNotification`
- The `saveItemOptions` mutation (column persistence) also needs `onError`
- Check `isError` on the main query and show a fallback UI
- Use a shared `mutationOptions` object to avoid repeating `onSettled`/`onError`:
```typescript
const mutationOptions = {
onSettled: () => void utils.myQuery.invalidate(),
onError: () => showErrorNotification({ title: t("errors.actionFailed"), message: t("errors.actionFailedMessage") }),
};
```
---
## 10. i18n
- No hardcoded strings. Every user-visible string goes through `useScopedI18n`
- Column titles: `t("items.{accessor}.columnTitle")`
- Detail labels: `t("items.{accessor}.detailsTitle")`
- States: `t("states.{state}")`
- Actions: `t("actions.{action}")`
- Errors: `t("errors.{errorKey}")`
- Hidden options still need `.label` translation keys
---
## 11. Edit Mode
Disable pointer events on the table during edit mode to prevent conflicts with the board's drag-and-drop:
```typescript
let tablePointerEvents: React.CSSProperties["pointerEvents"];
if (isEditMode) tablePointerEvents = "none";
let rowContextMenuHandler = handleContextMenu;
if (isEditMode) rowContextMenuHandler = undefined;
<DataTable style={{ pointerEvents: tablePointerEvents }} />
```
---
## 12. DataTable Props Reference (Commonly Used)
```typescript
<DataTable
records={sortedData}
columns={effectiveColumns}
storeColumnsKey={storeKey}
withTableBorder={false}
borderRadius={0}
highlightOnHover
striped="odd"
stripedColor={{ dark: "dark.7", light: "gray.0" }}
highlightOnHoverColor={{ dark: "dark.5", light: "gray.1" }}
verticalAlign="center"
fetching={isFetching && data.length === 0}
loaderBackgroundBlur={2}
fz={size.fontSize}
textSelectionDisabled
sortStatus={sortStatus}
onSortStatusChange={setSortStatus}
noRecordsText={t("errors.noItems")}
idAccessor={(record) => record.uniqueKey}
height="100%"
className="my-table"
defaultColumnProps={{
noWrap: true,
draggable: true,
resizable: true,
cellsStyle: () => ({ padding: `${size.cellPadding}px 8px` }),
}}
scrollAreaProps={{ type: "auto", scrollbarSize: 6 }}
rowBackgroundColor={(record) => conditionalBgMap[record.state]}
onRowContextMenu={handleContextMenu}
rowExpansion={{ ... }}
onScroll={() => { if (contextMenu) closeContextMenu(); }}
/>
```
---
## 13. Layout Stability
- Don't use `table-layout: fixed` — it fights mantine-datatable's resize logic
- Give non-name columns explicit `width` values so only the name column flexes
- Use `ellipsis: true` on the name column — it handles `text-overflow: ellipsis` internally
- Names should truncate at the cell boundary, not a character count, so the layout is stable regardless of content length
- `flexShrink: 0` on badges/icons prevents them from being squeezed by long names
---
## 14. Checklist for Each Migration
1. Replace imports (`mantine-react-table` → `mantine-datatable`)
2. Convert column definitions (`accessorKey` → `accessor`, `header` → `title`, `Cell` → `render`, etc.)
3. Implement manual sorting with `useState` + `useMemo`
4. Add CSS file for transparent backgrounds (if widget context)
5. Add responsive column visibility (if widget context)
6. Add hidden options + hydration logic (if column persistence needed)
7. Add old-import mappings for new options
8. Add translation keys (including hidden option labels)
9. Add context menu if row actions exist
10. Add row expansion if detail view exists
11. Add error handling on all mutations
12. Add accessibility attributes
13. Test at multiple widget widths
14. Update docs in `apps/docs/docs/widgets/` if applicable
15. Run `pnpm turbo typecheck` — catches old-import mismatches
16. Run translation spec — catches missing keys
+2 -1
View File
@@ -17,7 +17,8 @@ coverage
dev dev
.build .build
e2e e2e
apps/docs apps/docs/*
!apps/docs/package.json
apps/nextjs/.next apps/nextjs/.next
.env .env
.env.* .env.*
+4
View File
@@ -33,6 +33,10 @@ body:
options: options:
# The below comment is used to insert a new version with on-release.yml # The below comment is used to insert a new version with on-release.yml
#NEXT_VERSION# #NEXT_VERSION#
- 1.74.0
- 1.73.0
- 1.72.0
- 1.71.0
- 1.70.0 - 1.70.0
- 1.69.2 - 1.69.2
- 1.69.0 - 1.69.0
+2 -2
View File
@@ -1,6 +1,6 @@
{ {
$schema: "https://docs.renovatebot.com/renovate-schema.json", $schema: "https://docs.renovatebot.com/renovate-schema.json",
extends: ["config:recommended"], extends: ["config:recommended", ":dependencyDashboardApproval"],
packageRules: [ packageRules: [
{ {
matchPackageNames: ["^@homarr/"], matchPackageNames: ["^@homarr/"],
@@ -47,7 +47,7 @@
rangeStrategy: "bump", rangeStrategy: "bump",
automerge: false, automerge: false,
baseBranchPatterns: ["dev"], baseBranchPatterns: ["dev"],
dependencyDashboard: false, dependencyDashboard: true,
// prevent special characters in branch names (otherwise git errors occur on windows) // prevent special characters in branch names (otherwise git errors occur on windows)
branchNameStrict: true, branchNameStrict: true,
} }
-1
View File
@@ -23,7 +23,6 @@ jobs:
with: with:
fetch-depth: 0 fetch-depth: 0
ref: ${{ github.head_ref }} ref: ${{ github.head_ref }}
persist-credentials: false
- name: Setup - name: Setup
uses: ./tooling/github/setup uses: ./tooling/github/setup
-35
View File
@@ -1,35 +0,0 @@
name: "[Dependency Updates] Auto Approve"
on:
pull_request:
types: [opened, synchronize]
permissions: {}
jobs:
approve-automatic-prs:
runs-on: ubuntu-latest
timeout-minutes: 2
# 158783068: homarr-renovate[bot]
# 190541745: homarr-crowdin[bot]
# 210161987: homarr-update-contributors[bot]
# 175486441: homarr-releases[bot]
# Extracted from https://api.github.com/users/<username>
if: github.actor_id == 158783068 || github.actor_id == 190541745 || github.actor_id == 210161987 || github.actor_id == 175486441
steps:
- name: Checkout code
uses: actions/checkout@v7
- name: Obtain token
id: obtainToken
uses: actions/create-github-app-token@v3
with:
private-key: ${{ secrets.RENOVATE_APPROVE_PRIVATE_KEY }}
client-id: ${{ secrets.RENOVATE_APPROVE_APP_ID }}
permission-pull-requests: write # required to approve pull request
- name: Install GitHub CLI
run: sudo apt-get install -y gh
- name: Approve automatic PRs
env:
GITHUB_TOKEN: ${{ steps.obtainToken.outputs.token }}
run: |
gh pr review ${{github.event.pull_request.number}} --approve --body "Automatically approved by GitHub Action"
@@ -0,0 +1,43 @@
name: "[Bots] Reconcile Auto-merge"
on:
schedule:
- cron: "*/30 * * * *"
workflow_dispatch:
permissions: {}
jobs:
reconcile:
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- name: Checkout code
uses: actions/checkout@v7
- name: Obtain token
id: obtainToken
uses: actions/create-github-app-token@v3
with:
private-key: ${{ secrets.RENOVATE_APPROVE_PRIVATE_KEY }}
client-id: ${{ secrets.RENOVATE_APPROVE_APP_ID }}
permission-pull-requests: write # required to enable auto-merge
- name: Install GitHub CLI
run: sudo apt-get install -y gh
- name: Re-enable auto-merge for prepared bot PRs
env:
GITHUB_TOKEN: ${{ steps.obtainToken.outputs.token }}
run: |
# 158783068: homarr-renovate[bot]
# 190541745: homarr-crowdin[bot]
# 210161987: homarr-update-contributors[bot]
# 175441: homarr-releases[bot]
gh pr list --repo "${{ github.repository }}" --base dev --state open --limit 100 \
--json number,author,reviewDecision,mergeStateStatus,autoMergeRequest \
--jq '.[] | select(.author.login as $login | ["app/homarr-renovate", "app/homarr-crowdin", "app/homarr-update-contributors", "app/homarr-releases"] | index($login)) | select(.reviewDecision == "APPROVED") | select(.mergeStateStatus == "CLEAN") | select(.autoMergeRequest == null) | .number' \
| while read -r pr; do
echo "Re-enabling auto-merge on PR #$pr"
gh pr merge "$pr" --auto --squash
done
+47
View File
@@ -0,0 +1,47 @@
name: "[Bots] Approve and Auto-merge"
on:
pull_request:
types: [opened, synchronize, reopened, ready_for_review]
permissions: {}
jobs:
approve-and-automerge:
runs-on: ubuntu-latest
timeout-minutes: 5
# 158783068: homarr-renovate[bot]
# 190541745: homarr-crowdin[bot]
# 210161987: homarr-update-contributors[bot]
# 175441: homarr-releases[bot]
# Extracted from https://api.github.com/users/<username>
if: |
(github.actor_id == 158783068 || github.actor_id == 190541745 || github.actor_id == 210161987 || github.actor_id == 175441) &&
github.event.pull_request.head.repo.full_name == github.repository &&
github.event.pull_request.base.ref == 'dev'
steps:
- name: Checkout code
uses: actions/checkout@v7
- name: Obtain token
id: obtainToken
uses: actions/create-github-app-token@v3
with:
private-key: ${{ secrets.RENOVATE_APPROVE_PRIVATE_KEY }}
client-id: ${{ secrets.RENOVATE_APPROVE_APP_ID }}
permission-pull-requests: write # required to approve a pull request and enable auto-merge
- name: Install GitHub CLI
run: sudo apt-get install -y gh
- name: Approve and enable auto-merge
env:
GITHUB_TOKEN: ${{ steps.obtainToken.outputs.token }}
run: |
PR_NUMBER="${{ github.event.pull_request.number }}"
if [ "$(gh pr view "$PR_NUMBER" --json reviewDecision --jq .reviewDecision)" != "APPROVED" ]; then
gh pr review "$PR_NUMBER" --approve --body "Automatically approved by Homarr bot policy"
fi
if [ "$(gh pr view "$PR_NUMBER" --json autoMergeRequest --jq '.autoMergeRequest != null')" != "true" ]; then
gh pr merge "$PR_NUMBER" --auto --squash
fi
+8 -91
View File
@@ -60,7 +60,7 @@ jobs:
TURBO_PLATFORM: linux/amd64 TURBO_PLATFORM: linux/amd64
steps: steps:
- uses: actions/checkout@v7 - uses: actions/checkout@v7
if: '!env.ACT' if: "!env.ACT"
with: with:
fetch-depth: 0 fetch-depth: 0
persist-credentials: false persist-credentials: false
@@ -81,11 +81,8 @@ jobs:
- name: Copy environment defaults - name: Copy environment defaults
run: cp .env.example .env run: cp .env.example .env
- name: Check formatting in affected workspaces
run: pnpm turbo run format --affected --continue -- --check
- name: Lint, typecheck, and build affected workspaces - name: Lint, typecheck, and build affected workspaces
run: pnpm turbo run lint typecheck build --affected --continue=dependencies-successful run: pnpm turbo run lint typecheck build --affected --continue=dependencies-successful --concurrency=2
- name: Validate workspace configuration - name: Validate workspace configuration
run: pnpm lint:ws run: pnpm lint:ws
@@ -93,12 +90,8 @@ jobs:
- name: Run unit tests - name: Run unit tests
run: pnpm test run: pnpm test
- name: Report coverage
if: always() && !env.ACT
uses: davelosert/vitest-coverage-report-action@v2
container: container:
name: Container, E2E, and memory name: Container and E2E
needs: fast-gate needs: fast-gate
if: | if: |
github.event_name == 'merge_group' || github.event_name == 'merge_group' ||
@@ -111,7 +104,7 @@ jobs:
packages: write packages: write
steps: steps:
- uses: actions/checkout@v7 - uses: actions/checkout@v7
if: '!env.ACT' if: "!env.ACT"
with: with:
persist-credentials: false persist-credentials: false
ref: ${{ github.event_name == 'workflow_dispatch' && inputs.ref || '' }} ref: ${{ github.event_name == 'workflow_dispatch' && inputs.ref || '' }}
@@ -120,11 +113,11 @@ jobs:
uses: ./tooling/github/setup uses: ./tooling/github/setup
- name: Set up Docker Buildx - name: Set up Docker Buildx
if: '!env.ACT' if: "!env.ACT"
uses: docker/setup-buildx-action@v4 uses: docker/setup-buildx-action@v4
- name: Build container once - name: Build container once
if: '!env.ACT' if: "!env.ACT"
uses: docker/build-push-action@v7 uses: docker/build-push-action@v7
with: with:
context: . context: .
@@ -165,7 +158,7 @@ jobs:
--tag homarr-ci --tag homarr-e2e "${secret_args[@]}" . --tag homarr-ci --tag homarr-e2e "${secret_args[@]}" .
- name: Cache Playwright browsers - name: Cache Playwright browsers
if: '!env.ACT' if: "!env.ACT"
id: playwright-cache id: playwright-cache
uses: actions/cache@v6 uses: actions/cache@v6
with: with:
@@ -191,82 +184,6 @@ jobs:
path: e2e/test-results/ path: e2e/test-results/
retention-days: 7 retention-days: 7
- name: Run memory regression test
env:
CONTAINER_NAME: homarr-mem-${{ github.run_id }}-${{ github.run_attempt }}
SECRET_ENCRYPTION_KEY: 0000000000000000000000000000000000000000000000000000000000000000
run: |
docker run -d --name "$CONTAINER_NAME" -p 127.0.0.1::7575 \
-e NO_EXTERNAL_CONNECTION=true \
-e SECRET_ENCRYPTION_KEY="$SECRET_ENCRYPTION_KEY" \
homarr-ci
PORT=$(docker port "$CONTAINER_NAME" 7575/tcp | sed 's/.*://')
[[ "$PORT" =~ ^[0-9]+$ ]] || { echo "ERROR: invalid port mapping: '$PORT'"; exit 1; }
URL="http://127.0.0.1:${PORT}"
healthy=false
for i in $(seq 1 30); do
if curl -sf "$URL/api/health/ready" >/dev/null 2>&1; then
echo "Container healthy after $((i * 2))s"
healthy=true
break
fi
sleep 2
done
if [ "$healthy" != "true" ]; then
echo "ERROR: Container never became healthy"
docker logs "$CONTAINER_NAME" --tail 100
exit 1
fi
warmup_ok=0
for _ in $(seq 1 5); do
curl -sf "$URL/" >/dev/null 2>&1 && warmup_ok=$((warmup_ok + 1))
sleep 2
done
if [ "$warmup_ok" -eq 0 ]; then
echo "ERROR: All warmup requests failed"
docker logs "$CONTAINER_NAME" --tail 50
exit 1
fi
docker inspect -f '{{.State.Running}}' "$CONTAINER_NAME" | grep -qx true || {
echo "ERROR: Container died before memory measurement"
docker logs "$CONTAINER_NAME" --tail 100
exit 1
}
RAW_MEMORY=$(docker stats --no-stream --format '{{.MemUsage}}' "$CONTAINER_NAME" | sed 's| /.*||')
MEMORY_MB=$(echo "$RAW_MEMORY" | awk '
/GiB/ { gsub(/GiB/, ""); print $1 * 1024; next }
/MiB/ { gsub(/MiB/, ""); print $1; next }
/KiB/ { gsub(/KiB/, ""); print $1 / 1024; next }
{ print $1 / 1048576 }
')
echo "Memory usage: ${MEMORY_MB} MB (${RAW_MEMORY})"
[[ "$MEMORY_MB" =~ ^[0-9]+(\.[0-9]+)?$ ]] || {
echo "ERROR: non-numeric memory value: '$MEMORY_MB' (raw: '$RAW_MEMORY')"
docker logs "$CONTAINER_NAME" --tail 100
exit 1
}
if awk "BEGIN { exit !($MEMORY_MB < 50) }"; then
echo "ERROR: Memory suspiciously low (${MEMORY_MB} MB) — container may have crashed"
docker logs "$CONTAINER_NAME" --tail 100
exit 1
elif awk "BEGIN { exit !($MEMORY_MB > 700) }"; then
echo "ERROR: Memory usage ${MEMORY_MB} MB exceeds 700 MB"
docker logs "$CONTAINER_NAME" --tail 100
exit 1
fi
- name: Clean up memory test container
if: always()
env:
CONTAINER_NAME: homarr-mem-${{ github.run_id }}-${{ github.run_attempt }}
run: docker rm -f "$CONTAINER_NAME" >/dev/null 2>&1 || true
- name: Determine preview tag - name: Determine preview tag
id: preview id: preview
if: | if: |
@@ -316,7 +233,7 @@ jobs:
run: test -n "$PR_NUMBER" || { echo "pr_number is required for cleanup"; exit 1; } run: test -n "$PR_NUMBER" || { echo "pr_number is required for cleanup"; exit 1; }
- name: Delete GHCR package version - name: Delete GHCR package version
if: '!env.ACT' if: "!env.ACT"
uses: actions/github-script@v8 uses: actions/github-script@v8
with: with:
script: | script: |
+1
View File
@@ -78,3 +78,4 @@ skills-lock.json
apps/docs/.docusaurus apps/docs/.docusaurus
apps/docs/build apps/docs/build
.pnpm-store .pnpm-store
benchmark-results
+9
View File
@@ -46,6 +46,8 @@ homarr/
├── tooling/ ├── tooling/
│ ├── typescript/ # Base tsconfig │ ├── typescript/ # Base tsconfig
│ └── github/ # CI setup action │ └── github/ # CI setup action
├── tools/
│ └── homarr-dev/ # Go CLI for local and PR Docker images
├── development/ # Dev docker-compose (Redis, MySQL, PostgreSQL) ├── development/ # Dev docker-compose (Redis, MySQL, PostgreSQL)
├── e2e/ # E2E test specs ├── e2e/ # E2E test specs
└── Dockerfile # Multi-stage production build └── Dockerfile # Multi-stage production build
@@ -68,6 +70,9 @@ When modifying user-facing code, you MUST also update the corresponding document
## Monorepo Commands ## Monorepo Commands
- `pnpm dev` — Next.js app only - `pnpm dev` — Next.js app only
- `pnpm dev:cli -- dev` — run the developer CLI without installing a global binary
- `pnpm db:seed` — seed default database data explicitly
- `pnpm docker:dev:up` — start the Redis development service in the background
- `pnpm dev:docs` — Docusaurus docs site only - `pnpm dev:docs` — Docusaurus docs site only
- `pnpm turbo build` — build all packages - `pnpm turbo build` — build all packages
- `pnpm turbo build --filter=@homarr/docs` — build docs only - `pnpm turbo build --filter=@homarr/docs` — build docs only
@@ -81,3 +86,7 @@ When modifying user-facing code, you MUST also update the corresponding document
- UI: Mantine (not Tailwind) — Tailwind is only used in docs app - UI: Mantine (not Tailwind) — Tailwind is only used in docs app
- Icons: @tabler/icons-react - Icons: @tabler/icons-react
- Docs app can import from `@homarr/definitions` for shared types - Docs app can import from `@homarr/definitions` for shared types
- Run `pnpm dev:cli -- dev` to browse local `homarr:*` images and remote PR images.
- Run `pnpm dev:cli -- build <name>` from a Homarr checkout to build `homarr:<name>` with rebuild provenance.
- Run `pnpm dev:cli -- build --pr <number>` to build a PR locally from a temporary checkout.
- Run Go checks from `tools/homarr-dev` with `go test ./...` and `go vet ./...`.
+8
View File
@@ -1,3 +1,11 @@
## [1.74.0](https://github.com/homarr-labs/homarr/compare/v1.73.0...v1.74.0) (2026-08-07)
## [1.73.0](https://github.com/homarr-labs/homarr/compare/v1.72.0...v1.73.0) (2026-07-31)
## [1.72.0](https://github.com/homarr-labs/homarr/compare/v1.71.0...v1.72.0) (2026-07-24)
## [1.71.0](https://github.com/homarr-labs/homarr/compare/v1.70.0...v1.71.0) (2026-07-17)
## [1.70.0](https://github.com/homarr-labs/homarr/compare/v1.69.2...v1.70.0) (2026-07-10) ## [1.70.0](https://github.com/homarr-labs/homarr/compare/v1.69.2...v1.70.0) (2026-07-10)
## [1.69.2](https://github.com/homarr-labs/homarr/compare/v1.69.1...v1.69.2) (2026-07-06) ## [1.69.2](https://github.com/homarr-labs/homarr/compare/v1.69.1...v1.69.2) (2026-07-06)
+18 -8
View File
@@ -3,12 +3,18 @@
FROM node:24.18.0-alpine AS base FROM node:24.18.0-alpine AS base
FROM base AS builder FROM base AS builder
ARG TARGETPLATFORM
WORKDIR /app WORKDIR /app
RUN apk add --no-cache libc6-compat curl bash && apk update # Native dependencies normally use prebuilds, but the build must remain
# reproducible when a prebuild download times out and compilation is required.
RUN apk add --no-cache libc6-compat curl bash python3 make g++
RUN corepack enable pnpm RUN corepack enable pnpm
COPY pnpm-lock.yaml pnpm-workspace.yaml package.json ./ COPY .npmrc pnpm-lock.yaml pnpm-workspace.yaml package.json ./
COPY patches ./patches COPY patches ./patches
COPY --parents ./apps/*/package.json ./packages/*/package.json ./tooling/*/package.json ./
# @homarr/definitions generates documentation types during install.
COPY --parents ./packages/definitions/src ./
# Workaround for pnpm/pnpm#5268: pnpm fetch crashes when patchedDependencies # Workaround for pnpm/pnpm#5268: pnpm fetch crashes when patchedDependencies
# are configured with nodeLinker: hoisted. The applyPatchToDir function tries # are configured with nodeLinker: hoisted. The applyPatchToDir function tries
# to chdir into node_modules/<pkg> which doesn't exist during fetch (only the # to chdir into node_modules/<pkg> which doesn't exist during fetch (only the
@@ -19,14 +25,16 @@ COPY patches ./patches
RUN --mount=type=cache,id=pnpm-store,target=/pnpm/store \ RUN --mount=type=cache,id=pnpm-store,target=/pnpm/store \
pnpm config set store-dir /pnpm/store && \ pnpm config set store-dir /pnpm/store && \
sed -i 's/nodeLinker: hoisted/nodeLinker: isolated/' pnpm-workspace.yaml && \ sed -i 's/nodeLinker: hoisted/nodeLinker: isolated/' pnpm-workspace.yaml && \
pnpm fetch && \ pnpm fetch --ignore-scripts && \
sed -i 's/nodeLinker: isolated/nodeLinker: hoisted/' pnpm-workspace.yaml sed -i 's/nodeLinker: isolated/nodeLinker: hoisted/' pnpm-workspace.yaml
COPY . . # Install only from the fetched, committed lockfile so local and Docker builds
# Follow the pnpm fetch pattern from https://pnpm.io/cli/fetch # resolve the same dependency graph. Serial lifecycle builds avoid esbuild's
# --frozen-lockfile is omitted as recommended by the pnpm fetch docs # atomic binary replacement racing across the hoisted workspace (pnpm/pnpm#8200).
RUN --mount=type=cache,id=pnpm-store,target=/pnpm/store \ RUN --mount=type=cache,id=pnpm-store,target=/pnpm/store \
pnpm install --recursive npm_config_nodedir=/usr/local pnpm install --recursive --offline --frozen-lockfile --child-concurrency=1
COPY . .
ARG SKIP_ENV_VALIDATION='true' ARG SKIP_ENV_VALIDATION='true'
ARG CI='true' ARG CI='true'
@@ -37,7 +45,9 @@ RUN --mount=type=secret,id=TURBO_API,env=TURBO_API \
--mount=type=secret,id=TURBO_TEAM,env=TURBO_TEAM \ --mount=type=secret,id=TURBO_TEAM,env=TURBO_TEAM \
--mount=type=secret,id=TURBO_TOKEN,env=TURBO_TOKEN \ --mount=type=secret,id=TURBO_TOKEN,env=TURBO_TOKEN \
--mount=type=secret,id=TURBO_REMOTE_CACHE_SIGNATURE_KEY,env=TURBO_REMOTE_CACHE_SIGNATURE_KEY \ --mount=type=secret,id=TURBO_REMOTE_CACHE_SIGNATURE_KEY,env=TURBO_REMOTE_CACHE_SIGNATURE_KEY \
TURBO_PLATFORM="${TARGETPLATFORM:-linux/amd64}" pnpm build --mount=type=cache,id=homarr-next-build-${TARGETPLATFORM},target=/app/apps/nextjs/.next/cache,sharing=locked \
TURBO_PLATFORM="${TARGETPLATFORM:-linux/amd64}" \
pnpm turbo build --filter=@homarr/nextjs... --filter=@homarr/cli
FROM base AS runner FROM base AS runner
WORKDIR /app WORKDIR /app
-3
View File
@@ -23,9 +23,6 @@
"react-hooks/exhaustive-deps": "warn", "react-hooks/exhaustive-deps": "warn",
"eslint/no-unused-expressions": "warn" "eslint/no-unused-expressions": "warn"
}, },
"options": {
"reportUnusedDisableDirectives": "off"
},
"ignorePatterns": ["**/*.config.js", "**/*.config.mjs", "**/*.cjs", ".docusaurus/**", "build/**"], "ignorePatterns": ["**/*.config.js", "**/*.config.mjs", "**/*.cjs", ".docusaurus/**", "build/**"],
"settings": { "settings": {
"react": { "version": "19" } "react": { "version": "19" }
@@ -0,0 +1,41 @@
# Developer Docker CLI
The host-side developer CLI builds and runs local Homarr images and pull-request previews. Run it directly from a checkout with `pnpm dev:cli -- ...`; no shell-specific setup script is required. It is separate from the recovery CLI included in production images.
To install an optional `homarr` binary, run `pnpm dev:cli:install` and make your Go bin directory available on `PATH`. GitHub CLI authentication is only needed for pull-request features.
## Browse development images
```bash
pnpm dev:cli --
```
The main screen combines local `homarr:*` images with open pull requests and their GHCR preview status. Select an image to start or stop it, use `R` to build a remote pull request locally in the background or rebuild a local image, or use `p` to force a remote preview pull. Press `d` to switch to the instance dashboard and `d` again to return without losing the browser state. The explicit `dev` and `dash` subcommands start on either screen.
A locally built PR image is folded into its remote PR row only when its recorded revision exactly matches the current GitHub head SHA. The row is marked `◆ local`, and starting it prefers that local image without a registry pull. Outdated local builds remain separate rows. Navigation and dashboard switching remain available during background builds.
## Build images
```bash
pnpm dev:cli -- build <name>
pnpm dev:cli -- build --pr <number> [name]
```
The first command builds the current checkout as `homarr:<name>`. The pull-request form uses a depth-one clone in a temporary directory and defaults to `homarr:pr-<number>` when `name` is omitted.
## Rebuild images
```bash
pnpm dev:cli -- rebuild <name>
```
Images built by this CLI record their source checkout, revision, and optional pull-request number. Rebuild uses that provenance to select the original checkout or create a fresh temporary pull-request checkout.
## Run images
```bash
homarr run <name>
homarr run --pr <number>
```
Add `--detach` to run in the background, `--demo` to enable demo data, or repeat `--env KEY=VALUE` to provide additional environment variables.
@@ -14,11 +14,12 @@ This guide will help you set up your development environment to start contributi
### Prerequisites ### Prerequisites
- **Node.js (lts)**: You can download it from the [official website](https://nodejs.org/). - **Node.js 24.18 or newer**: You can download it from the [official website](https://nodejs.org/).
- **pnpm (latest)**: You can install it by running `npm install -g pnpm`. - **Corepack**: Run `corepack enable` so the repository selects its pinned pnpm version.
- **Git**: You can download it from the [official website](https://git-scm.com/). - **Git**: You can download it from the [official website](https://git-scm.com/).
- **Docker or Docker Desktop**: You can download it from the [official website](https://www.docker.com/). - **Docker or Docker Desktop**: Required for the Redis service and the developer Docker CLI.
- **A GitHub account**: You can create one on the [official website](https://github.com/). - **Go 1.25+**: Required only for the developer Docker CLI. You can download it from the [official website](https://go.dev/).
- **GitHub CLI**: Required only for pull-request image features. Install it from the [official website](https://cli.github.com/), then authenticate with `gh auth login`.
### Setting up the project ### Setting up the project
@@ -34,11 +35,11 @@ This guide will help you set up your development environment to start contributi
4. Install the dependencies with `pnpm install`. 4. Install the dependencies with `pnpm install`.
5. Start redis by running `pnpm run docker:dev`. This will run a docker-compose file with a redis service. 5. Create `.env` from `.env.example`. In a POSIX shell, run `cp .env.example .env`; in PowerShell, run `Copy-Item .env.example .env`. Set `DB_URL` to an absolute path for your SQLite file.
6. Copy the `.env.example` file to `.env` and replace `DB_URL` with the full path you want to use for the sqlite database file. For example `DB_URL='C:\\Dev\\homarr\\packages\\db\\db.sqlite'` 6. Start Redis in the background with `pnpm docker:dev:up` if your development workflow needs it.
7. Run the migrations to create the sqlite db file with `pnpm run db:migration:sqlite:run`. 7. Create or update the database with `pnpm db:migration:sqlite:run`. This also seeds the default data. To run the seed explicitly later, use `pnpm db:seed`.
8. Start the development server with `pnpm dev` and wait for a message that says `✓ Ready in 10.7s` 8. Start the development server with `pnpm dev` and wait for a message that says `✓ Ready in 10.7s`
@@ -48,9 +49,19 @@ This guide will help you set up your development environment to start contributi
#### During development #### During development
- `pnpm dev`: Start the development server. - `pnpm dev`: Start the Next.js development server.
- `pnpm dev:benchmark`: Run the local authenticated-board benchmark. Set `DEV_BENCHMARK_BOARD_URL` and
`DEV_BENCHMARK_STORAGE_STATE`; readiness-only measurements require the explicit `--smoke` flag and are not
suitable for performance claims.
- `pnpm dev:cli -- dev`: Browse and run local images and remote pull-request images.
- `pnpm dev:cli -- build <name>`: Build the current checkout as `homarr:<name>`.
- `pnpm dev:cli -- build --pr <number>`: Build a pull request locally from a temporary checkout.
- `pnpm dev:cli -- rebuild <name>`: Rebuild a local image from its recorded checkout or pull request.
- `pnpm dev:cli:install`: Optionally install the developer CLI as a `homarr` binary.
- `pnpm cli`: Run cli commands. - `pnpm cli`: Run cli commands.
- `pnpm db:migration:sqlite:run`: Run the migrations to create or update the sqlite db file. - `pnpm db:migration:sqlite:run`: Run the migrations to create or update the sqlite db file.
- `pnpm db:seed`: Explicitly seed the default database data.
- `pnpm docker:dev:up`: Start the Redis development service in the background.
- `pnpm db:migration:sqlite:generate`: Generate a new migration file for the sqlite db. Also works with `mysql`. - `pnpm db:migration:sqlite:generate`: Generate a new migration file for the sqlite db. Also works with `mysql`.
- `pnpm db:studio`: Open a visual studio for the database. - `pnpm db:studio`: Open a visual studio for the database.
- `pnpm package:new`: Create a new package in the monorepo. - `pnpm package:new`: Create a new package in the monorepo.
@@ -63,6 +74,8 @@ This guide will help you set up your development environment to start contributi
- `pnpm typecheck`: Run the typescript type checker. - `pnpm typecheck`: Run the typescript type checker.
#### Building the project #### Building the project
- `pnpm build`: Build the project for production. - `pnpm build`: Build the project for production.
- `pnpm dev:cli -- build <name>`: Build a `homarr:<name>` image and record its checkout for later rebuilds.
- `docker build -t homarr .`: Build a docker image for the project. - `docker build -t homarr .`: Build a docker image for the project.
- `docker run -p 7575:7575 -e SECRET_ENCRYPTION_KEY='your_64_character_hex_string' homarr:latest`: Run the created docker image. - `docker run -p 7575:7575 -e SECRET_ENCRYPTION_KEY='your_64_character_hex_string' homarr:<name>`: Run the created docker image.
@@ -25,15 +25,14 @@ This is especially useful for [docker secrets](https://docs.docker.com/engine/sw
Using the `PUID` and `PGID` will require you to set the correct permissions on the mounted volumes and if used the docker socket. See more in the [Running as a different user](/docs/advanced/running-as-different-user) documentation. Using the `PUID` and `PGID` will require you to set the correct permissions on the mounted volumes and if used the docker socket. See more in the [Running as a different user](/docs/advanced/running-as-different-user) documentation.
::: :::
| Environment Variable | Description | Possible values | Default | | Environment Variable | Description | Possible values | Default |
| ------------------------ | ----------- | --------------- | ------- | | ------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------- | ------- |
| ``PUID`` | User ID to run the container as | Any valid user ID | ``0`` | | `PUID` | User ID to run the container as | Any valid user ID | `0` |
| ``PGID`` | Group ID to run the container as | Any valid group ID | ``0`` | | `PGID` | Group ID to run the container as | Any valid group ID | `0` |
| ``LOG_LEVEL`` | Log level to use | ``debug`` / ``info`` / ``warn`` / ``error`` | ``info`` | | `LOG_LEVEL` | Log level to use | `debug` / `info` / `warn` / `error` | `info` |
| ``NO_EXTERNAL_CONNECTION`` | Disables some requests that need internet connection | `true` or `false` | `false` | | `NO_EXTERNAL_CONNECTION` | Disables Homarr's internet-backed requests, including update checks, weather, and RSS feeds | `true` or `false` | `false` |
| ``ENABLE_DNS_CACHING`` | Enables dns caching. Enabled by default, but if you experience issues with IPv6 or static IPs, see [#4006](https://github.com/homarr-labs/homarr/issues/4006) or disable this flag | `true` or `false` | `true` | | `ENABLE_DNS_CACHING` | Enables dns caching. Enabled by default, but if you experience issues with IPv6 or static IPs, see [#4006](https://github.com/homarr-labs/homarr/issues/4006) or disable this flag | `true` or `false` | `true` |
| `HOMARR_DISABLE_IPV6` | Disables the IPv6 listener of the internal nginx proxy, even when the host supports IPv6. IPv6 is normally disabled automatically when the host has no IPv6 configured. See [#4596](https://github.com/homarr-labs/homarr/issues/4596) | `true` or `false` | `false` |
## Authentication ## Authentication
@@ -43,9 +42,9 @@ See [Single Sign-On](/docs/advanced/single-sign-on) for more informations.
The `SECRET_ENCRYPTION_KEY` is required. If none is specified before starting the container, a random key will be shown in the error message and the container will exit. The `SECRET_ENCRYPTION_KEY` is required. If none is specified before starting the container, a random key will be shown in the error message and the container will exit.
| Environment Variable | Description | Possible values | Default | | Environment Variable | Description | Possible values | Default |
| ------------------------ | ----------- | --------------- | ------- | | ----------------------- | ------------------------------------------- | ----------------------- | ------- |
| ``SECRET_ENCRYPTION_KEY`` | Secret used to encrypt secrets in database. | 64 character hex string | - | | `SECRET_ENCRYPTION_KEY` | Secret used to encrypt secrets in database. | 64 character hex string | - |
:::info :::info
A random secret can be generated by using the following command: `openssl rand -hex 32` A random secret can be generated by using the following command: `openssl rand -hex 32`
@@ -53,27 +52,27 @@ A random secret can be generated by using the following command: `openssl rand -
## Docker ## Docker
| Environment Variable | Description | Possible values | Default | | Environment Variable | Description | Possible values | Default |
| ------------------------ | ----------- | --------------- | ------- | | --------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------- | ------- |
| ``DOCKER_HOSTNAMES`` | Comma seperated list of hostnames to connect to | For example `localhost,docker.example.com` | - | | `DOCKER_HOSTNAMES` | Comma-separated list of hostnames to connect to | For example `localhost,docker.example.com` | - |
| ``DOCKER_PORTS`` | Comma seperated list of ports to connect to | For example `2375,2376` | - | | `DOCKER_PORTS` | Comma-separated list of ports to connect to | For example `2375,2376` | - |
| ``DOCKER_SOCKET_PATHS`` | Comma seperated list of paths to sockets | For example `/var/run/docker.sock,/run/user/1000/podman/podman.sock` | - | | `DOCKER_SOCKET_PATHS` | Comma-separated list of mounted Docker-compatible socket paths. See the [Docker and Podman integration guide](/docs/integrations/docker) | For example `/var/run/docker.sock,/var/run/podman.sock` | - |
## Database ## Database
| Environment Variable | Description | Possible values | Default |
| ------------------------ | ----------- | --------------- | ------- | | Environment Variable | Description | Possible values | Default |
| ``DB_DRIVER`` | Database driver to use. Currently `better-sqlite3` is used for sqlite, `mysql2` for mysql and `node-postgres` for postgresql | ``better-sqlite3`` / ``mysql2`` / ``node-postgres`` | ``better-sqlite3`` | | -------------------- | ---------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------- | ----------------------- |
| ``DB_DIALECT`` | Database dialect to use. | ``sqlite`` / ``mysql`` / ``postgresql`` | ``sqlite`` | | `DB_DRIVER` | Database driver to use. Currently `better-sqlite3` is used for sqlite, `mysql2` for mysql and `node-postgres` for postgresql | `better-sqlite3` / `mysql2` / `node-postgres` | `better-sqlite3` |
| ``DB_URL`` | Database URL to connect to. | Any valid database URL | ``/appdata/db/db.sqlite`` | | `DB_DIALECT` | Database dialect to use. | `sqlite` / `mysql` / `postgresql` | `sqlite` |
| ``DB_HOST`` | Database host to connect to. | Any valid database host | - | | `DB_URL` | Database URL to connect to. | Any valid database URL | `/appdata/db/db.sqlite` |
| ``DB_PORT`` | Database port to connect to. | Any valid database port | - | | `DB_HOST` | Database host to connect to. | Any valid database host | - |
| ``DB_NAME`` | Database name to connect to. | Any valid database name | - | | `DB_PORT` | Database port to connect to. | Any valid database port | - |
| ``DB_USER`` | Database user to connect with. | Any valid database user | - | | `DB_NAME` | Database name to connect to. | Any valid database name | - |
| ``DB_PASSWORD`` | Database password to connect with. | Any valid database password | - | | `DB_USER` | Database user to connect with. | Any valid database user | - |
| `DB_PASSWORD` | Database password to connect with. | Any valid database password | - |
You can either use the url or host, port, name and credentials combined. The URL will be prioritized over the other values. You can either use the url or host, port, name and credentials combined. The URL will be prioritized over the other values.
## Redis ## Redis
By default Redis is running within the installation. (for example in Docker). By default Redis is running within the installation. (for example in Docker).
@@ -83,25 +82,23 @@ However for example for K8s it can be useful to use an external Redis instance.
External redis is currently not supported with Proxmox Community Scripts External redis is currently not supported with Proxmox Community Scripts
::: :::
| Environment Variable | Description | Possible values | Default | | Environment Variable | Description | Possible values | Default |
| ------------------------ | ----------- | --------------- | ------- | | ---------------------- | ---------------------------------------------------------------------------------------------------- | ------------------------ | ------- |
| `REDIS_IS_EXTERNAL` | Whether Redis is running externally. | `true` / `false` | `false` | | `REDIS_IS_EXTERNAL` | Whether Redis is running externally. | `true` / `false` | `false` |
| `REDIS_HOST` | Hostname of the Redis instance. | Any valid hostname | - | | `REDIS_HOST` | Hostname of the Redis instance. | Any valid hostname | - |
| `REDIS_PORT` | Port of the Redis instance. | Any valid port | `6379` | | `REDIS_PORT` | Port of the Redis instance. | Any valid port | `6379` |
| `REDIS_USERNAME` | Username to connect to the Redis instance. | Any valid username | - | | `REDIS_USERNAME` | Username to connect to the Redis instance. | Any valid username | - |
| `REDIS_PASSWORD` | Password to connect to the Redis instance. | Any valid password | - | | `REDIS_PASSWORD` | Password to connect to the Redis instance. | Any valid password | - |
| `REDIS_DATABASE_INDEX` | Select which database of your redis instance should be used. | Any numeric index | - | | `REDIS_DATABASE_INDEX` | Select which database of your redis instance should be used. | Any numeric index | - |
| `REDIS_TLS_CA` | CA certificate for Redis TLS connections. If a certificate is specified the connection will use TLS. | Any valid CA certificate | - | | `REDIS_TLS_CA` | CA certificate for Redis TLS connections. If a certificate is specified the connection will use TLS. | Any valid CA certificate | - |
## Proxy ## Proxy
| Environment Variable | Description | Possible values | Default | | Environment Variable | Description | Possible values | Default |
| ------------------------ | ----------- | --------------- | ------- | | -------------------- | ------------------------------------------------------------------- | ------------------------- | ------- |
| ``HTTP_PROXY`` | HTTP proxy to use | Any valid HTTP proxy URL | - | | `HTTP_PROXY` | HTTP proxy to use | Any valid HTTP proxy URL | - |
| ``HTTPS_PROXY`` | HTTPS proxy to use | Any valid HTTPS proxy URL | - | | `HTTPS_PROXY` | HTTPS proxy to use | Any valid HTTPS proxy URL | - |
| ``NO_PROXY`` | Comma separated list of hosts that should be excluded from proxying | Any valid hostnames | - | | `NO_PROXY` | Comma separated list of hosts that should be excluded from proxying | Any valid hostnames | - |
## Advanced deployments ## Advanced deployments
@@ -109,6 +106,6 @@ External redis is currently not supported with Proxmox Community Scripts
The advanced deployments environment variables should only be used if you know what you are doing. The advanced deployments environment variables should only be used if you know what you are doing.
::: :::
| Environment Variable | Description | | Environment Variable | Description |
| ------------------------ | ----------- | | ------------------------ | -------------------------------------------------- |
| ``DB_MIGRATIONS_DISABLED`` | Disable db migrations. For example for helm charts | | `DB_MIGRATIONS_DISABLED` | Disable db migrations. For example for helm charts |
+1 -1
View File
@@ -31,4 +31,4 @@ Icons that are high quality and printable ([SVG](https://wikipedia.org/wiki/Scal
### Using external icons ### 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. 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.
It is important that the URL directly points to the file without any advertisements or elements around it. Direct HTTP(S) image URLs are used as entered instead of being searched in the built-in repositories. It is important that the URL directly points to the file without any advertisements or elements around it.
@@ -127,6 +127,8 @@ Homarr supports multiple authentication options, from internal userbase (credent
If you'd rather manage group memberships for OIDC users locally (instead of via the IdP), set `AUTH_OIDC_GROUPS_LOCAL_MANAGEMENT=true`. While enabled, Homarr stops syncing OIDC users' group memberships from the groups claim, and admins can add/remove OIDC users on a group's members page. If you turn this back to `false`, the next login of each user resumes the regular sync and reconciles their groups with the IdP's claim again, which may remove memberships that were added manually while local management was enabled. If you'd rather manage group memberships for OIDC users locally (instead of via the IdP), set `AUTH_OIDC_GROUPS_LOCAL_MANAGEMENT=true`. While enabled, Homarr stops syncing OIDC users' group memberships from the groups claim, and admins can add/remove OIDC users on a group's members page. If you turn this back to `false`, the next login of each user resumes the regular sync and reconciles their groups with the IdP's claim again, which may remove memberships that were added manually while local management was enabled.
To let an existing credentials user also sign in with OIDC, set `AUTH_OIDC_ENABLE_DANGEROUS_CREDENTIALS_LINKING=true`. On the first OIDC sign-in, Homarr links a credentials user with the same email address instead of creating a second user. The user keeps their password login, local username and profile, groups, permissions, boards, settings, and API keys. OIDC profile and group claims do not overwrite the credentials-owned data. Homarr requires the OIDC profile to contain `email_verified: true` while this option is enabled. Only enable this when your OIDC provider verifies email addresses, because a matching email could otherwise be used to take over an existing account.
<details> <details>
<summary> <summary>
Example Setup Example Setup
@@ -204,6 +206,7 @@ Homarr supports multiple authentication options, from internal userbase (credent
| ``AUTH_OIDC_NAME_ATTRIBUTE_OVERWRITE`` | Overwrite name attribute. By default it will use preferred_username if it does not contain a `@` and otherwise name. | --- | | ``AUTH_OIDC_NAME_ATTRIBUTE_OVERWRITE`` | Overwrite name attribute. By default it will use preferred_username if it does not contain a `@` and otherwise name. | --- |
| ``AUTH_OIDC_FORCE_USERINFO`` | Force userinfo endpoint to be used for user information. | false | | ``AUTH_OIDC_FORCE_USERINFO`` | Force userinfo endpoint to be used for user information. | false |
| ``AUTH_OIDC_ENABLE_DANGEROUS_ACCOUNT_LINKING`` | Enable account linking for OIDC provider. This will link the OIDC accounts by email. Be sure that you have verified emails to prevent stealing accounts. | false | | ``AUTH_OIDC_ENABLE_DANGEROUS_ACCOUNT_LINKING`` | Enable account linking for OIDC provider. This will link the OIDC accounts by email. Be sure that you have verified emails to prevent stealing accounts. | false |
| ``AUTH_OIDC_ENABLE_DANGEROUS_CREDENTIALS_LINKING`` | Link an OIDC identity to an existing credentials user with the same email. The credentials login and existing user data are preserved. Requires the OIDC profile to contain `email_verified: true`. | false |
| ``AUTH_OIDC_TOKEN_ENDPOINT_AUTH_METHOD`` | Override method for the token endpoint authentication. Supported values are `client_secret_basic`, `client_secret_post`, `client_secret_jwt` and `none`. | client_secret_basic | | ``AUTH_OIDC_TOKEN_ENDPOINT_AUTH_METHOD`` | Override method for the token endpoint authentication. Supported values are `client_secret_basic`, `client_secret_post`, `client_secret_jwt` and `none`. | client_secret_basic |
### Permission System ### Permission System
@@ -2,9 +2,9 @@
<img src="https://raw.githubusercontent.com/homarr-labs/charts/refs/heads/main/charts/homarr/icon.svg" align="right" width="92" alt="homarr logo"> <img src="https://raw.githubusercontent.com/homarr-labs/charts/refs/heads/main/charts/homarr/icon.svg" align="right" width="92" alt="homarr logo">
![Version: 8.22.0](https://img.shields.io/badge/Version-8.22.0-informational?style=flat) ![Version: 8.25.0](https://img.shields.io/badge/Version-8.25.0-informational?style=flat)
![Type: application](https://img.shields.io/badge/Type-application-informational?style=flat) ![Type: application](https://img.shields.io/badge/Type-application-informational?style=flat)
![AppVersion: v1.70.0](https://img.shields.io/badge/AppVersion-v1.70.0-informational?style=flat) ![AppVersion: v1.73.0](https://img.shields.io/badge/AppVersion-v1.73.0-informational?style=flat)
A Helm chart to deploy homarr for Kubernetes A Helm chart to deploy homarr for Kubernetes
@@ -259,7 +259,7 @@ httproute:
value: / value: /
backendRefs: backendRefs:
- name: homarr - name: homarr
port: 8080 port: 7575
```` ````
</details> </details>
@@ -419,17 +419,17 @@ All available values are listed on the [artifacthub](https://artifacthub.io/pack
| envSecrets.dbEncryption.key | string | `"db-encryption-key"` | Secret key for SECRET_ENCRYPTION_KEY can be generated with `openssl rand -hex 32` | | envSecrets.dbEncryption.key | string | `"db-encryption-key"` | Secret key for SECRET_ENCRYPTION_KEY can be generated with `openssl rand -hex 32` |
| fullnameOverride | string | `""` | Overrides chart's fullname | | fullnameOverride | string | `""` | Overrides chart's fullname |
| hostAliases | list | `[]` | Add static entries to /etc/hosts in the Pod. This is useful in the following cases: - You are running in a dual-stack cluster (IPv4 + IPv6) and want to force usage of IPv4 for specific hostnames - Your application is having DNS resolution issues or IPv6 preference issues - You need to override or simulate DNS entries without changing global DNS - You are running in an air-gapped or isolated environment without external DNS Example: hostAliases: - ip: "192.168.1.10" hostnames: - "example.com" - "example.internal" | | hostAliases | list | `[]` | Add static entries to /etc/hosts in the Pod. This is useful in the following cases: - You are running in a dual-stack cluster (IPv4 + IPv6) and want to force usage of IPv4 for specific hostnames - Your application is having DNS resolution issues or IPv6 preference issues - You need to override or simulate DNS entries without changing global DNS - You are running in an air-gapped or isolated environment without external DNS Example: hostAliases: - ip: "192.168.1.10" hostnames: - "example.com" - "example.internal" |
| httproute | object | `{"enabled":false,"hostnames":["chart-example.local"],"parentRefs":[{"name":"my-gateway","namespace":"default"}],"rules":[{"backendRefs":[{"name":"homarr","port":8080}],"filters":[],"matches":[{"path":{"type":"PathPrefix","value":"/"}}]}]}` | Gateway API HTTPRoute configuration | | httproute | object | `{"enabled":false,"hostnames":["chart-example.local"],"parentRefs":[{"name":"my-gateway","namespace":"default"}],"rules":[{"backendRefs":[{"name":"homarr","port":7575}],"filters":[],"matches":[{"path":{"type":"PathPrefix","value":"/"}}]}]}` | Gateway API HTTPRoute configuration |
| httproute.enabled | bool | `false` | Enable HTTPRoute | | httproute.enabled | bool | `false` | Enable HTTPRoute |
| httproute.hostnames | list | `["chart-example.local"]` | Hostnames this route matches (similar to ingress.hosts.host) | | httproute.hostnames | list | `["chart-example.local"]` | Hostnames this route matches (similar to ingress.hosts.host) |
| httproute.parentRefs | list | `[{"name":"my-gateway","namespace":"default"}]` | References to the parent Gateway(s) this route attaches to. Each item must include at least a `name`, and optionally a `namespace`. | | httproute.parentRefs | list | `[{"name":"my-gateway","namespace":"default"}]` | References to the parent Gateway(s) this route attaches to. Each item must include at least a `name`, and optionally a `namespace`. |
| httproute.rules | list | `[{"backendRefs":[{"name":"homarr","port":8080}],"filters":[],"matches":[{"path":{"type":"PathPrefix","value":"/"}}]}]` | List of routing rules. Each rule can include: - matches: path/header/query matching - filters: optional transformations (redirects, header modifications, etc.) - backendRefs: one or more Kubernetes Services to forward traffic to | | httproute.rules | list | `[{"backendRefs":[{"name":"homarr","port":7575}],"filters":[],"matches":[{"path":{"type":"PathPrefix","value":"/"}}]}]` | List of routing rules. Each rule can include: - matches: path/header/query matching - filters: optional transformations (redirects, header modifications, etc.) - backendRefs: one or more Kubernetes Services to forward traffic to |
| httproute.rules[0].filters | list | `[]` | Optional filters for this rule (default: empty) | | httproute.rules[0].filters | list | `[]` | Optional filters for this rule (default: empty) |
| httproute.rules[0].matches[0].path.type | string | `"PathPrefix"` | Path match type. One of: Exact, PathPrefix, RegularExpression | | httproute.rules[0].matches[0].path.type | string | `"PathPrefix"` | Path match type. One of: Exact, PathPrefix, RegularExpression |
| httproute.rules[0].matches[0].path.value | string | `"/"` | Path value to match | | httproute.rules[0].matches[0].path.value | string | `"/"` | Path value to match |
| image.pullPolicy | string | `"IfNotPresent"` | Image pull policy | | image.pullPolicy | string | `"IfNotPresent"` | Image pull policy |
| image.repository | string | `"ghcr.io/homarr-labs/homarr"` | Image repository | | image.repository | string | `"ghcr.io/homarr-labs/homarr"` | Image repository |
| image.tag | string | `"v1.70.0"` | Overrides the image tag whose default is the chart appVersion | | image.tag | string | `"v1.73.0"` | Overrides the image tag whose default is the chart appVersion |
| imagePullSecrets | list | `[]` | Secrets for Docker registry | | imagePullSecrets | list | `[]` | Secrets for Docker registry |
| ingress.annotations | object | `{}` | Ingress annotations | | ingress.annotations | object | `{}` | Ingress annotations |
| ingress.enabled | bool | `false` | Enable ingress | | ingress.enabled | bool | `false` | Enable ingress |
@@ -55,6 +55,74 @@ services:
With docker run, add this parameter: `-v /var/run/docker.sock:/var/run/docker.sock`. With docker run, add this parameter: `-v /var/run/docker.sock:/var/run/docker.sock`.
In Windows, you'll need to adjust the slashes: `-v //var/run/docker.sock:/var/run/docker.sock` In Windows, you'll need to adjust the slashes: `-v //var/run/docker.sock:/var/run/docker.sock`
### Using Podman
On Linux, Homarr can connect to Podman's [Docker-compatible API](https://docs.podman.io/en/latest/markdown/podman-system-service.1.html).
The Podman API socket must be running and mounted into the Homarr container.
The socket grants full access to Podman, including arbitrary code execution as the user running the API, so only mount it into trusted containers.
For rootless Podman, enable the socket for the user that runs Homarr:
```bash
systemctl --user enable --now podman.socket
```
The rootless socket is located at `$XDG_RUNTIME_DIR/podman/podman.sock` (usually `/run/user/<uid>/podman/podman.sock`).
You can confirm the path with:
```bash
podman info --format '{{.Host.RemoteSocket.Path}}'
```
Mount that socket into the Homarr container and point `DOCKER_SOCKET_PATHS` to its path **inside** the container:
```yaml
services:
homarr:
container_name: homarr
image: ghcr.io/homarr-labs/homarr:latest
restart: unless-stopped
volumes:
- ${XDG_RUNTIME_DIR}/podman/podman.sock:/var/run/podman.sock
- ./homarr/appdata:/appdata
environment:
SECRET_ENCRYPTION_KEY: your_64_character_hex_string
DOCKER_SOCKET_PATHS: /var/run/podman.sock
ports:
- "7575:7575"
```
Start Homarr with `podman compose up -d`.
Run the compose project as the same user that owns the rootless Podman socket so `$XDG_RUNTIME_DIR` resolves to the correct path.
To keep the user socket available after logout and across reboots, enable lingering with `loginctl enable-linger <user>`.
For rootful Podman, enable the system socket with `sudo systemctl enable --now podman.socket`.
Its default path is `/run/podman/podman.sock`; mount that path instead and start Homarr with `sudo podman compose up -d`.
:::note SELinux
On systems with SELinux enabled, Podman's documentation recommends disabling label separation for a container that accesses the API socket.
Add the following to the Homarr service:
```yaml
security_opt:
- label=disable
```
:::
You can connect Homarr to multiple Docker-compatible sockets by mounting each socket and listing their in-container paths, separated by commas:
```yaml
services:
homarr:
image: ghcr.io/homarr-labs/homarr:latest
volumes:
- /var/run/docker.sock:/var/run/docker.sock
- ${XDG_RUNTIME_DIR}/podman/podman.sock:/var/run/podman.sock
environment:
DOCKER_SOCKET_PATHS: /var/run/docker.sock,/var/run/podman.sock
```
### Managing your Docker containers within Homarr ### Managing your Docker containers within Homarr
Homarr allows you to interact with Docker containers running on your system. Homarr allows you to interact with Docker containers running on your system.
Each container row has an action menu with the following options: Each container row has an action menu with the following options:
+1 -2
View File
@@ -50,12 +50,11 @@ import { immichServerStatsWidget } from '../../widgets/immich-server-stats';
<code>user.read</code>,{" "} <code>user.read</code>,{" "}
<code>user.update</code>,{" "} <code>user.update</code>,{" "}
<code>asset.read</code>,{" "} <code>asset.read</code>,{" "}
<code>asset.view</code>,{" "}
<code>asset.download</code>,{" "} <code>asset.download</code>,{" "}
<code>asset.statistics</code>,{" "} <code>asset.statistics</code>,{" "}
<code>server.statistics</code>,{" "} <code>server.statistics</code>,{" "}
<code>server.about</code>. <code>server.about</code>.
<br />
Additional permissions may be required depending on your Immich version. If you encounter authorization errors, grant any missing permissions requested by the server.
</span> </span>
), ),
@@ -10,19 +10,21 @@ import { AddingIntegration } from '@site/src/components/integrations/adding';
import { IntegrationSecrets } from '@site/src/components/integrations/secrets'; import { IntegrationSecrets } from '@site/src/components/integrations/secrets';
import { navidromeIntegration } from '.'; import { navidromeIntegration } from '.';
import { audioStatsWidget } from '@site/docs/widgets/audio-stats'; import { audioStatsWidget } from '@site/docs/widgets/audio-stats';
import { mediaServerWidget } from '@site/docs/widgets/media-server';
<IntegrationHeader <IntegrationHeader
integration={navidromeIntegration} integration={navidromeIntegration}
categories={['Media']} categories={['Media', 'Media server']}
/> />
Navidrome provides music library statistics including artist, album, and song counts, as well as now playing information via the Subsonic API. Navidrome provides music library statistics including artist, album, and song counts, as well as now playing audio streams via the Subsonic API.
### Widgets & Capabilities ### Widgets & Capabilities
<IntegrationCapabilites <IntegrationCapabilites
items={[ items={[
{ widget: audioStatsWidget }, { widget: audioStatsWidget },
{ widget: mediaServerWidget },
]} ]}
/> />
+9 -6
View File
@@ -7,8 +7,8 @@ tags:
- Ping - Ping
--- ---
Apps are the building blocks of your Homarr board. Apps are the building blocks of your Homarr board.
Most of the time they are a simple link to a website, but used on your board they also support a simple ping to check if your service is up. Most of the time they are a simple link to a website, but used on your board they also support a simple ping to check if your service is up.
Apps are used in the bookmark and app widget. Apps are used in the bookmark and app widget.
## Create an app ## Create an app
@@ -27,8 +27,8 @@ The name must be at least one character long and can be chosen freely.
### Icon URL ### Icon URL
This is the URL to the icon that will be displayed on the app widget. This is the URL to the icon that will be displayed on the app widget.
It can be a url to an image in the internet, one from the included icon repositories or a local file uploaded through the upload button. You can paste a direct HTTP(S) URL to an image, search the included icon repositories by name, or upload a local file through the upload button. Direct image URLs are used as entered and are not searched in the icon repositories.
![Icon URL](./img/app-icon-url.png) ![Icon URL](./img/app-icon-url.png)
@@ -40,14 +40,17 @@ An optional description that can be shown as tooltip on the app widget.
The URL to the website you want to link to. Clicking on the app widget or bookmark will open this URL. The URL to the website you want to link to. Clicking on the app widget or bookmark will open this URL.
You can also use custom URI schemes such as `vscode://file/path`, `microsoft-edge:https://example.com`, or `myapp://open` to link to local applications or third-party programs. We trust that you know what you are doing — only `javascript:` URIs are blocked for security reasons.
### Ping URL ### Ping URL
When you want to use another url for the simple status check you can set it here. When you want to use another url for the simple status check you can set it here.
This will allow you to set a different URL for the ping than the URL that is opened when clicking on the app widget. This will allow you to set a different URL for the ping than the URL that is opened when clicking on the app widget.
It's useful when you want to ping the service internally for example with a custom ip but open the website externally. It's useful when you want to ping the service internally for example with a custom ip but open the website externally.
Note that the ping URL only accepts `http://` and `https://` URLs — custom URI schemes are not supported for pings.
## List of apps ## List of apps
On the apps management page you find all apps that exist in your Homarr instance. You can edit and delete them. On the apps management page you find all apps that exist in your Homarr instance. You can edit and delete them.
![Apps list](./img/app-list.png) ![Apps list](./img/app-list.png)
+81 -53
View File
@@ -9,97 +9,125 @@ tags:
--- ---
# Settings # Settings
The settings page has a few options that will be applied to the entire Homarr application.
They may require a full page reload for changes to apply. The settings page applies options to the whole Homarr application.
Some changes need a full page reload before they take effect.
## Analytics ## Analytics
By default, Homarr is collecting anonymous analytics to identify issues and prioritize bugfixes and improvements.
The data is never shared with any third-parties and is fully GDPR & CCPA compliant.
The software being used is [Umami Analytics](https://umami.is/) which is fully open source.
The main server software can be found [here](https://github.com/umami-software/umami) and [this node library](https://github.com/umami-software/node) to perform server side calls to the API.
A full list of the collected data by default can be found here: https://umami.is/docs/metric-definitions. Homarr uses [PostHog](https://posthog.com/) to collect anonymous usage data.
We collect this data to see what features people ACTUALLY use.
We use this data to decide where we spend our development time.
We understand that you might not want Homarr to communicate with our services. We do not sell the data. We do not share the data with third parties.
By unchecking the ``Send anonymous analytics`` checkbox and reloading the full page, all communication by Umami will be disabled and no data will be collected. The data does not contain personal information and does not identify you or your installation.
If you are paranoid, you can permanently block ``umami.homarr.dev`` from your DNS / firewall.
Additionally, you opt in to more data collection to help us provide more useful data. Homarr sends the data to our own PostHog server at `https://hog.homarr.dev`.
The server sends the data once a week and when the server starts.
### Integration data ### What we collect
Enabling this optional data will send the types of integrations (e.g. SABnzbd, Sonarr, ...) and how many you have configured of them.
This will not include any URLs, names, secrets, errors and others. This is disabled by default.
### Widget data Homarr collects technical data about your installation:
Enabling this optional data will send the types of widgets (e.g. downloads, media streams, iframe, ...) and how many you have configured of them.
This will not include any URLs, names, secrets, errors and others. This is disabled by default.
### Users data - Your Homarr version
Enabling this optional data will send the count of users and whether you activated SSO. - Your database type (SQLite, MySQL, or PostgreSQL)
This will not include any usernames, passwords, emails, URLs, authentication flows or client IDs. - Whether Docker or Kubernetes is enabled
This is disabled by default. - Your enabled authentication providers
- Your operating system and CPU architecture
- Your default language
- The number of boards, apps, sections, layouts, users, groups, search engines, icon repositories, cron jobs, API keys, invites, sessions, and accounts
- The integration types you use and how many you have of each (for example, Sonarr or SABnzbd)
- The widget types you use and how many you have of each
Homarr does NOT collect:
- URLs
- Names
- Usernames, emails, or passwords
- Integration secrets or API keys
- Error messages or logs
- Any data that can identify you
Each installation gets a random ID. We use this ID only to count unique installations. We cannot use this ID to identify you.
Homarr does not use cookies for analytics. Homarr does not track page views automatically. Homarr only sends the data listed above.
### Disable analytics
Analytics are enabled by default. To disable analytics:
1. Open the Settings page.
2. Uncheck the "Help us improve Homarr" checkbox.
3. Reload the full page.
When analytics are disabled, Homarr does not send any data to PostHog.
You can also set the `NO_EXTERNAL_CONNECTION` environment variable to `true`. This stops all external requests, including analytics. See the [environment variables](/docs/advanced/environment-variables) page for more information.
You can block `hog.homarr.dev` in your DNS or firewall. Homarr cannot send data when this address is blocked.
## Search Engine Optimization (SEO) ## Search Engine Optimization (SEO)
Big companies [like Google, Microsoft, Baidu and Yandex](https://de.wikipedia.org/wiki/Web-Index) will use [web crawlers](https://de.wikipedia.org/wiki/Webcrawler) to index the Internet for new websites & pages.
This is desirable in many cases (for example when selling products in an online shop) but can lead to unwanted traffic and attention to your personal dashboard. Search engines use web crawlers to index websites. This can cause unwanted traffic to your dashboard.
Homarr is requesting the crawlers to not index Homarr instances by default for this reason. Homarr asks crawlers to not index your instance by default. Use these settings to change this request.
If you use Homarr as a public website and want it to be searchable on these search engines, you can disable these settings.
Please note that Homarr doesn't have control over what the crawlers do; it is a mere request to them to not perform these actions but will not guarantee that your website doesn't land on some index. Homarr cannot control what the crawlers do. A crawler can ignore the request. Your website can still appear in an index.
### No index ### No index
This will ask crawlers to not index your Homarr instance on any indexes.
It is up to the crawler to properly respect this request. This setting asks crawlers to not index your instance.
By default, this setting is enabled. Disabling it will allow indexing. This setting is enabled by default. Disable this setting to allow indexing.
### No follow ### No follow
If indexing is allowed, crawlers will also attempt to follow any links on your page to further "discover" new parts of the Internet and on your website.
By default, this setting is enabled and will ask crawlers to not follow any links. Crawlers follow links to discover new pages.
Crawler of different companies may behave differently when indexing is prohibited and following is allowed. This setting asks crawlers to not follow links. This setting is enabled by default.
Crawler behavior can differ between search engines.
### No translate ### No translate
Google and Chrome on Android will prompt users to translate pages that are not in their device language.
This can lead to some shifting and can be undesired in some cases. By enabling this, Some browsers offer to translate pages.
you ask crawlers to never translate and never prompt users for translation. This setting asks crawlers to not offer translation. Some browsers ignore this setting.
It should be noted that some browsers will ignore this and translate regardless.
### No site links search box ### No site links search box
This setting is exclusive to Google.
[Google will create a box below your domain](https://developers.google.com/search/docs/appearance/structured-data/sitelinks-searchbox) that lists the paths that were discovered by the "folowing". This setting applies to Google only.
The user can search them directly in Google. Google can show a search box below your domain.
You can ask the crawlers to not allow searches by enabling this setting. This setting asks Google to not show this box. This setting is disabled by default.
This setting is disabled by default.
## Boards ## Boards
### Global home board ### Global home board
You can set a global home board that will be used for all users that have not set a home board themselves.
It will also be used for anonymous users. You can set a global home board.
Note that the board must be public to be used as a global home board. This board is the fallback for users without their own home board. It is also the fallback for anonymous users.
The board must be public.
### Global mobile board ### Global mobile board
You can set a global mobile board. It will be shown instead of the global home board on mobile devices that can be detected by Homarr. (Using the User-Agent header). You can set a global mobile board.
It will similar to the global home board be as fallback for normal users and for any anonymous users. Homarr shows this board on mobile devices instead of the global home board.
It also has to be public. Homarr detects mobile devices with the User-Agent header.
The board must be public.
### App status ### App status
#### Enable status by default #### Enable status by default
You can decide if the app status should be enabled by default when adding a new app to the board. You can decide if app status is enabled when you add a new app.
#### Force disable status #### Force disable status
You can decide if the app status should be disabled for all boards. You can decide if app status is disabled on all boards.
###
## Appearance ## Appearance
### Default color scheme ### Default color scheme
You can set the default color scheme for all users that have not set a color scheme themselves.
You can set the default color scheme for users without their own color scheme.
## Culture ## Culture
### Default language ### Default language
You can set the default language for all users that have not set a language themselves.
You can set the default language for users without their own language.
+24 -22
View File
@@ -1,43 +1,45 @@
--- ---
title: 'Audio Stats' title: "Audio Stats"
description: 'Displays library statistics from Navidrome or Audiobookshelf, adapting its display based on the linked integration.' description: "Displays media and listening statistics from Navidrome or Audiobookshelf, adapting its display based on the linked integration."
hide_title: true hide_title: true
--- ---
import { WidgetHeader } from '@site/src/components/widgets/header'; import { WidgetHeader } from "@site/src/components/widgets/header";
import { AddingWidget } from '@site/src/components/widgets/adding'; import { AddingWidget } from "@site/src/components/widgets/adding";
import { WidgetConfig } from '@site/src/components/widgets/configuration'; import { WidgetConfig } from "@site/src/components/widgets/configuration";
import { WidgetIntegrations } from '@site/src/components/widgets/integrations'; import { WidgetIntegrations } from "@site/src/components/widgets/integrations";
import { audioStatsWidget } from '.'; import { audioStatsWidget } from ".";
import { navidromeIntegration } from '@site/docs/integrations/navidrome'; import { navidromeIntegration } from "@site/docs/integrations/navidrome";
import { audiobookshelfIntegration } from '@site/docs/integrations/audiobookshelf'; import { audiobookshelfIntegration } from "@site/docs/integrations/audiobookshelf";
<WidgetHeader widget={audioStatsWidget} categories={["Media"]} />
<WidgetHeader
widget={audioStatsWidget}
categories={['Media']}
/>
This widget adapts its display based on the linked integration: This widget adapts its display based on the linked integration:
- **Navidrome**: Shows artist, album, and song counts, plus a now playing section with active streams - **Navidrome**: Shows artist, album, and song counts
- **Audiobookshelf**: Shows library count, audiobook/podcast totals, listening time, and active sessions - **Audiobookshelf**: Shows library count, audiobook/podcast totals, listening time, and active sessions
Only options relevant to your linked integration are shown in the configuration panel. Only options relevant to your linked integration are shown in the configuration panel.
### Supported Integrations ### Supported Integrations
<WidgetIntegrations items={[{ <WidgetIntegrations
integration: navidromeIntegration, items={[
note: "Displays music library stats and now playing" {
}, { integration: navidromeIntegration,
integration: audiobookshelfIntegration, note: "Displays music library statistics",
note: "Displays audiobook/podcast library stats and listening time" },
}]} /> {
integration: audiobookshelfIntegration,
note: "Displays audiobook/podcast library stats and listening time",
},
]}
/>
### Adding the widget ### Adding the widget
<AddingWidget /> <AddingWidget />
### Configuration ### Configuration
<WidgetConfig configuration={audioStatsWidget.configuration} /> <WidgetConfig configuration={audioStatsWidget.configuration} />
@@ -27,24 +27,6 @@ export const audioStatsWidget: WidgetDefinition = {
values: { type: "boolean" }, values: { type: "boolean" },
defaultValue: "yes", defaultValue: "yes",
}, },
{
name: "Show now playing",
description: "Displays currently playing tracks (Navidrome only)",
values: { type: "boolean" },
defaultValue: "yes",
},
{
name: "Show now playing list",
description: "Displays a detailed list of now playing tracks (Navidrome only)",
values: { type: "boolean" },
defaultValue: "yes",
},
{
name: "Max now playing items",
description: "Maximum number of now playing tracks to display (Navidrome only)",
values: "Number (1-5)",
defaultValue: "3",
},
{ {
name: "Show library count", name: "Show library count",
description: "Displays the number of libraries (Audiobookshelf only)", description: "Displays the number of libraries (Audiobookshelf only)",
@@ -15,7 +15,7 @@ import beszelSystemStats from "./img/beszel-system-stats.png";
<WidgetHeader widget={beszelSystemStatsWidget} categories={["System Monitoring", "Charts"]} /> <WidgetHeader widget={beszelSystemStatsWidget} categories={["System Monitoring", "Charts"]} />
This widget displays time-series charts for a selected Beszel system, including CPU, memory, disk, network, and Docker container metrics. The Disk Usage chart combines the root disk and every extra filesystem reported by Beszel. You can use the realtime Live view or select a historical period ranging from 1 hour to 30 days. If the live stream drops, the widget now shows the connection failure state immediately and can switch back to historical data. This widget displays time-series charts for a selected Beszel system, including CPU, memory, disk, network, and Docker container metrics. The Disk Usage chart combines the root disk and every extra filesystem reported by Beszel. You can use the realtime Live view or select a historical period ranging from 1 hour to 30 days. Live view keeps a one-minute window and pauses while the browser tab is hidden. If the live stream drops, the widget now shows the connection failure state immediately and can switch back to historical data.
### Screenshots ### Screenshots
@@ -17,10 +17,17 @@ import { beszelIntegration } from '@site/docs/integrations/beszel';
categories={['System Monitoring']} categories={['System Monitoring']}
/> />
This widget displays all your Beszel-monitored systems in a sortable data table. Each row shows system metrics like CPU, memory, disk, network, temperature, and more. Columns can be toggled on or off, and the table supports sorting by any column. When Beszel reports extra filesystems, the disk column adds a hover breakdown for each mount alongside the root disk. This widget displays all your Beszel-monitored systems in a sortable data table. Each row shows system metrics like CPU, memory, disk, network, temperature, and more. Columns can be toggled on or off, dragged into a different order, and resized from their headers. Column order and widths are saved automatically for each widget. When Beszel reports extra filesystems, the disk column adds a hover breakdown for each mount alongside the root disk.
Select a system row to open the complete statistics view, including system, filesystem, Docker, historical, and realtime charts. Select a system row to open the complete statistics view, including system, filesystem, Docker, historical, and realtime charts.
### Interactions
- **Reorder columns** by hovering a header to reveal its grab handle, then dragging it
- **Resize columns** by dragging a header's right edge
- **Sort systems** by selecting a sortable column header
- **Open system statistics** by selecting a row
### Supported Integrations ### Supported Integrations
<WidgetIntegrations items={[{ <WidgetIntegrations items={[{
@@ -40,12 +40,19 @@ import dockerTable from './img/table.png';
### Interactions ### Interactions
Each container row has an action menu (accessible via the three-dot icon) with the following options: Hover a column header to reveal its grab handle, then drag it to reorder the table or drag the header's edge to resize it. The layout is saved automatically for each widget instance and restored after a reload. These controls are disabled while the board is in edit mode.
Each container row has an action menu, accessible via the three-dot icon or by right-clicking the row, with the following options:
- **View logs** - opens a dedicated terminal page streaming the container's logs - **View logs** - opens a dedicated terminal page streaming the container's logs
- **Start** / **Stop** / **Restart** - manage the container's state - **Start** / **Stop** / **Restart** - manage the container's state
- **Remove** - remove the container - **Remove** - permanently remove the container after a second confirmation click
- **Add to Homarr** - create a bookmark app from the container - **Add to Homarr** - create a bookmark app from the container
### Adding the widget ### Adding the widget
<AddingWidget /> <AddingWidget />
### Configuration
<WidgetConfig configuration={dockerContainersWidget.configuration} />
@@ -8,6 +8,12 @@ export const dockerContainersWidget: WidgetDefinition = {
path: "../../widgets/docker-containers", path: "../../widgets/docker-containers",
configuration: { configuration: {
items: [ items: [
{
name: "Columns to show",
description: "Select which columns are visible in the table",
values: "Name, State, Host, CPU usage, Memory usage, and Actions",
defaultValue: "All columns",
},
{ {
name: "Enable items sorting", name: "Enable items sorting",
description: "Allows to sort containers by clicking on the column headers", description: "Allows to sort containers by clicking on the column headers",
Binary file not shown.

After

Width:  |  Height:  |  Size: 210 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

After

Width:  |  Height:  |  Size: 122 KiB

+35 -9
View File
@@ -4,6 +4,7 @@ description: 'Allows you to view and manage your Downloads from both Torrent and
hide_title: true hide_title: true
--- ---
import { aria2Integration } from '@site/docs/integrations/aria2';
import { delugeIntegration } from '@site/docs/integrations/deluge'; import { delugeIntegration } from '@site/docs/integrations/deluge';
import { nzbgetIntegration } from '@site/docs/integrations/nzbget'; import { nzbgetIntegration } from '@site/docs/integrations/nzbget';
import { qBittorentIntegration } from '@site/docs/integrations/q-bittorent'; import { qBittorentIntegration } from '@site/docs/integrations/q-bittorent';
@@ -14,9 +15,6 @@ import { AddingWidget } from '@site/src/components/widgets/adding';
import { WidgetConfig } from '@site/src/components/widgets/configuration'; import { WidgetConfig } from '@site/src/components/widgets/configuration';
import { WidgetHeader } from '@site/src/components/widgets/header'; import { WidgetHeader } from '@site/src/components/widgets/header';
import { WidgetIntegrations } from '@site/src/components/widgets/integrations'; import { WidgetIntegrations } from '@site/src/components/widgets/integrations';
import { IconAd } from "@tabler/icons-react";
import TabItem from '@theme/TabItem';
import Tabs from '@theme/Tabs';
import { downloadsWidget } from '.'; import { downloadsWidget } from '.';
@@ -25,16 +23,37 @@ import { downloadsWidget } from '.';
categories={['Torrent client', 'Usenet client', 'Downloads']} categories={['Torrent client', 'Usenet client', 'Downloads']}
/> />
This widget displays a list of torrents or usenet-articles that are currently being downloaded, along with their progress and other relevant information. This widget displays a list of torrents or usenet-articles that are currently being downloaded, along with their progress and other relevant information. It supports 7 download client integrations and provides a rich, responsive table interface.
### Screenshots ### Screenshots
import downloadsCard from './img/screenshot.png'; import downloadsCard from './img/screenshot.png';
import contextMenuImg from './img/context-menu.png';
<div> <div>
<img src={downloadsCard} alt="downloads" width={128 * 4} /> <img src={downloadsCard} alt="Downloads widget showing torrent and usenet items" width={128 * 4} />
</div> </div>
#### Right-click context menu
<div>
<img src={contextMenuImg} alt="Downloads widget context menu with pause, delete, and copy actions" width={128 * 4} />
</div>
### Features
- **Responsive table** — columns automatically adapt to widget width, hiding less important columns at smaller sizes
- **Sortable columns** — click any column header to sort ascending/descending
- **Column customization** — choose which columns to display, then hover a header to reveal its grab handle for reordering or drag its edge to resize it; layouts save automatically per widget
- **Right-click context menu** — pause, resume, delete downloads, or copy download names directly from the context menu
- **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
- **Global stats bar** — toggle a summary bar showing queue progress, total size, speeds, ratio, and per-state counts
- **Client filtering** — filter downloads by integration or status using the footer filter panel
- **Global controls** — pause/resume all downloads across all clients from the footer bar
- **Status indicators** — color-coded badges show download state at a glance
- **Speed overview** — total download and upload speeds displayed in the footer
### Supported Integrations ### Supported Integrations
<WidgetIntegrations items={[{ <WidgetIntegrations items={[{
@@ -52,16 +71,23 @@ import downloadsCard from './img/screenshot.png';
}, { }, {
integration: qBittorentIntegration, integration: qBittorentIntegration,
note: "Torrent client" note: "Torrent client"
}, {
integration: aria2Integration,
note: "Multi-protocol download client"
}, { }, {
integration: slskdIntegration, integration: slskdIntegration,
note: "Soulseek client" note: "Soulseek client (read-only)"
}]} /> }]} />
### Interactions ### Interactions
- Pause / Resume one download by clicking on the according icon. - **Right-click** any download to open a context menu with pause/resume, delete, and copy actions
- Delete one download by clicking on the trash icon. (Can also delete the files) - **Click** any row to expand it and see full download details inline
- Pause / Resume all downloads by using the bottom button. - **Hover** a download name for a quick stats tooltip
- **Stats toggle** — show or hide the global stats bar from the footer
- **Pause / Resume** all downloads using the footer controls
- **Filter** by integration or status using the filter button in the footer
- **Sort** by clicking column headers (with persistent sort direction)
### Adding the widget ### Adding the widget
+24 -32
View File
@@ -2,26 +2,23 @@ import { WidgetDefinition } from "@site/src/types";
import { IconDownload } from "@tabler/icons-react"; import { IconDownload } from "@tabler/icons-react";
const columnsList = [ const columnsList = [
"id",
"actions",
"added",
"category",
"downSpeed",
"index",
"integration",
"name", "name",
"progress", "progress",
"size",
"downSpeed",
"upSpeed",
"time",
"state",
"added",
"ratio", "ratio",
"received", "received",
"sent", "sent",
"size", "category",
"state", "integration",
"time", "index",
"type", "type",
"upSpeed",
]; ];
const sortingExclusion = ["actions", "id", "state"]; const sortColumns = columnsList.filter((c) => !["state", "category", "integration"].includes(c));
const columnsSort = columnsList.filter((column) => !sortingExclusion.some((exclusion) => exclusion === column));
export const downloadsWidget: WidgetDefinition = { export const downloadsWidget: WidgetDefinition = {
icon: IconDownload, icon: IconDownload,
@@ -32,25 +29,20 @@ export const downloadsWidget: WidgetDefinition = {
items: [ items: [
{ {
name: "Columns to show", name: "Columns to show",
description: "Select the columns you want to display in the widget.", description:
"Select the columns you want to display in the widget. Columns automatically hide/show based on widget width.",
values: `List of columns: ${columnsList.join(", ")}`, values: `List of columns: ${columnsList.join(", ")}`,
defaultValue: ["integration", "name", "progress", "time", "actions"].join(", "), defaultValue: ["name", "progress", "downSpeed", "time", "state"].join(", "),
}, },
{ {
name: "Enable items sorting", name: "Default sort column",
description: "Enables the sorting in the table.", description: "The column used for initial sorting when the widget loads.",
values: { type: "boolean" }, defaultValue: "progress",
defaultValue: "no", values: `List of columns: ${sortColumns.join(", ")}`,
},
{
name: "Column used for sorting by default",
description: "By default it will sort by this column.",
defaultValue: "type",
values: `List of columns: ${columnsSort.join(", ")}`,
}, },
{ {
name: "Invert sorting", name: "Invert sorting",
description: "This will invert the sorting order of the table.", description: "This will invert the default sorting order.",
values: { type: "boolean" }, values: { type: "boolean" },
defaultValue: "no", defaultValue: "no",
}, },
@@ -76,13 +68,13 @@ export const downloadsWidget: WidgetDefinition = {
name: "Hide completed torrent under this threshold (in kiB/s)", name: "Hide completed torrent under this threshold (in kiB/s)",
description: description:
"This will hide completed torrent entries that have a download speed below the specified threshold.", "This will hide completed torrent entries that have a download speed below the specified threshold.",
values: "Any number above 1, 0 to disable", values: "Any number above 0, 0 to disable",
defaultValue: "0", defaultValue: "0",
}, },
{ {
name: "Categories/names to filter", name: "Categories/labels to filter",
description: "You can filter the items by categories or names. Use a comma to separate multiple values.", description: "You can filter the items by categories or labels. Use a comma to separate multiple values.",
values: "Comma-separated list of categories or names", values: "Comma-separated list of categories or labels",
defaultValue: "-", defaultValue: "-",
}, },
{ {
@@ -94,13 +86,13 @@ export const downloadsWidget: WidgetDefinition = {
}, },
{ {
name: "Use filter to calculate Ratio", name: "Use filter to calculate Ratio",
description: "This will use the filter to calculate the ratio of items shown in the widget.", description: "This will use the category filter when calculating the global torrent ratio.",
values: { type: "boolean" }, values: { type: "boolean" },
defaultValue: "yes", defaultValue: "yes",
}, },
{ {
name: "Limit items per integration", name: "Limit items per integration",
description: "This will limit the number of items shown per integration, not globally", description: "This will limit the number of items shown per integration, not globally.",
values: "Any number above 1", values: "Any number above 1",
defaultValue: "50", defaultValue: "50",
}, },
@@ -1,6 +1,6 @@
--- ---
title: 'Immich Album Carousel' title: 'Immich Album Carousel'
description: 'Shows a slideshow of your album pictures from Immich' description: 'Shows a slideshow of pictures from an Immich album or your full library'
hide_title: true hide_title: true
--- ---
@@ -37,4 +37,3 @@ import screen from './img/screen.png';
### Configuration ### Configuration
<WidgetConfig configuration={immichAlbumCarouselWidget.configuration} /> <WidgetConfig configuration={immichAlbumCarouselWidget.configuration} />
@@ -4,15 +4,16 @@ import { IconPhoto } from "@tabler/icons-react";
export const immichAlbumCarouselWidget: WidgetDefinition = { export const immichAlbumCarouselWidget: WidgetDefinition = {
icon: IconPhoto, icon: IconPhoto,
name: "Immich Album", name: "Immich Album",
description: "Shows a slideshow of your album pictures from Immich", description: "Shows a slideshow of pictures from an Immich album or your full library",
path: "../../widgets/immich-album-carousel", path: "../../widgets/immich-album-carousel",
configuration: { configuration: {
items: [ items: [
{ {
name: "Album", name: "Album",
description: "Select the Immich album to show in the slideshow.", description:
"Select an Immich album or All photos. An empty selection also shows random photos from your full library.",
values: { type: "string" }, values: { type: "string" },
defaultValue: "", defaultValue: "All photos",
}, },
{ {
name: "Interval in seconds", name: "Interval in seconds",
@@ -1,41 +1,44 @@
--- ---
title: 'Media Request List' title: "Media Request List"
description: 'See a list of all media requests from your integration' description: "See a list of all media requests from your integration"
hide_title: true hide_title: true
--- ---
import { WidgetHeader } from '@site/src/components/widgets/header'; import { WidgetHeader } from "@site/src/components/widgets/header";
import { AddingWidget } from '@site/src/components/widgets/adding'; import { AddingWidget } from "@site/src/components/widgets/adding";
import { WidgetConfig } from '@site/src/components/widgets/configuration'; import { WidgetConfig } from "@site/src/components/widgets/configuration";
import { WidgetIntegrations } from '@site/src/components/widgets/integrations'; import { WidgetIntegrations } from "@site/src/components/widgets/integrations";
import { IconAd } from "@tabler/icons-react"; import { IconAd } from "@tabler/icons-react";
import Tabs from '@theme/Tabs'; import Tabs from "@theme/Tabs";
import TabItem from '@theme/TabItem'; import TabItem from "@theme/TabItem";
import { mediaRequestListWidget } from '.'; import { mediaRequestListWidget } from ".";
import { jellyseerrIntegration } from '@site/docs/integrations/jellyseerr'; import { jellyseerrIntegration } from "@site/docs/integrations/jellyseerr";
import { overseerrIntegration } from '@site/docs/integrations/overseerr'; import { overseerrIntegration } from "@site/docs/integrations/overseerr";
<WidgetHeader widget={mediaRequestListWidget} categories={["Media requests"]} />
<WidgetHeader
widget={mediaRequestListWidget}
categories={['Media requests']}
/>
Provides a list of all open and recently closed requests. It also provides the functionality to approve or reject requests. Provides a list of all open and recently closed requests. It also provides the functionality to approve or reject requests.
You can limit the list to specific request statuses (for example, only show `pending`) and restrict it to requests created within the last N days.
### Screenshots ### Screenshots
import mediaRequestList from './img/list.png'; import mediaRequestList from "./img/list.png";
<img src={mediaRequestList} alt="media-request-list" width={256} height={256} /> <img src={mediaRequestList} alt="media-request-list" width={256} height={256} />
### Supported Integrations ### Supported Integrations
<WidgetIntegrations items={[{ <WidgetIntegrations
integration: jellyseerrIntegration, items={[
}, { {
integration: overseerrIntegration, integration: jellyseerrIntegration,
}]} /> },
{
integration: overseerrIntegration,
},
]}
/>
### Interactions ### Interactions
@@ -47,4 +50,5 @@ import mediaRequestList from './img/list.png';
<AddingWidget /> <AddingWidget />
### Configuration ### Configuration
<WidgetConfig configuration={mediaRequestListWidget.configuration} /> <WidgetConfig configuration={mediaRequestListWidget.configuration} />
@@ -14,6 +14,18 @@ export const mediaRequestListWidget: WidgetDefinition = {
values: { type: "boolean" }, values: { type: "boolean" },
defaultValue: "yes", defaultValue: "yes",
}, },
{
name: "Statuses to show",
description: "Only requests with these statuses will be shown. Defaults to all statuses.",
values: "List of: 'pending', 'approved', 'declined', 'failed', 'completed'",
defaultValue: "All statuses",
},
{
name: "Show only recent requests",
description: "Limit to requests created within this many days. Set to 0 to disable the time filter.",
values: "0-365",
defaultValue: "0",
},
], ],
}, },
}; };
@@ -14,6 +14,7 @@ import TabItem from '@theme/TabItem';
import { mediaServerWidget } from '.'; import { mediaServerWidget } from '.';
import { embyIntegration } from '@site/docs/integrations/emby'; import { embyIntegration } from '@site/docs/integrations/emby';
import { jellyfinIntegration } from '@site/docs/integrations/jellyfin'; import { jellyfinIntegration } from '@site/docs/integrations/jellyfin';
import { navidromeIntegration } from '@site/docs/integrations/navidrome';
import { plexIntegration } from '@site/docs/integrations/plex'; import { plexIntegration } from '@site/docs/integrations/plex';
@@ -39,6 +40,9 @@ import mediaTable from './img/table.png';
integration: embyIntegration, integration: embyIntegration,
}, { }, {
integration: jellyfinIntegration, integration: jellyfinIntegration,
}, {
integration: navidromeIntegration,
note: "Will always show only currently playing audio streams, not all active sessions."
}, { }, {
integration: plexIntegration, integration: plexIntegration,
note: "Will always show only currently playing streams, not all active sessions." note: "Will always show only currently playing streams, not all active sessions."
@@ -50,4 +54,3 @@ import mediaTable from './img/table.png';
### Configuration ### Configuration
<WidgetConfig configuration={mediaServerWidget.configuration} /> <WidgetConfig configuration={mediaServerWidget.configuration} />
+1 -1
View File
@@ -10,7 +10,7 @@ export const mediaServerWidget: WidgetDefinition = {
items: [ items: [
{ {
name: "Show only currently playing", name: "Show only currently playing",
description: "Disabling this will not work for plex.", description: "Plex and Navidrome always show only currently playing streams.",
values: { type: "boolean" }, values: { type: "boolean" },
defaultValue: "yes", defaultValue: "yes",
}, },
+22 -11
View File
@@ -1,4 +1,6 @@
// Importing env files here to validate on build // Importing env files here to validate on build
import path from "node:path";
import "@homarr/auth/env"; import "@homarr/auth/env";
import "@homarr/core/infrastructure/db/env"; import "@homarr/core/infrastructure/db/env";
import "@homarr/common/env"; import "@homarr/common/env";
@@ -13,7 +15,21 @@ const withNextIntl = createNextIntlPlugin({
requestConfig: "../../packages/translation/src/request.ts", requestConfig: "../../packages/translation/src/request.ts",
}); });
const getDevelopmentServiceAliases = () => {
if (process.env.NODE_ENV !== "development") {
return undefined;
}
return {
"@homarr/tasks": "./src/instrumentation-noop.ts",
"@homarr/websocket": "./src/instrumentation-noop.ts",
};
};
const nextConfig: NextConfig = { const nextConfig: NextConfig = {
// Next previews otherwise create agent instruction files in the application
// directory during development.
agentRules: false,
env: { env: {
HOMARR_VERSION: process.env.HOMARR_VERSION ?? "unknown", HOMARR_VERSION: process.env.HOMARR_VERSION ?? "unknown",
}, },
@@ -30,19 +46,14 @@ const nextConfig: NextConfig = {
serverExternalPackages: ["dockerode", "isomorphic-dompurify", "jsdom", "better-sqlite3"], serverExternalPackages: ["dockerode", "isomorphic-dompurify", "jsdom", "better-sqlite3"],
experimental: { experimental: {
optimizePackageImports: ["@mantine/core", "@mantine/hooks", "@tabler/icons-react"], optimizePackageImports: ["@mantine/core", "@mantine/hooks", "@tabler/icons-react"],
turbopackFileSystemCacheForDev: true, turbopackFileSystemCacheForBuild: true,
webpackMemoryOptimizations: true, useTypeScriptCli: true,
}, },
turbopack: { turbopack: {
// ponytail: known Turbopack NFT warning from path.join(process.cwd(), …) in root: path.resolve(import.meta.dirname, "../.."),
// src/app/api/backup/{route,shared}.ts. No working placement in 16.2.x // Development runs tasks and WebSocket as separate processes. These aliases
// (see vercel/next.js#95125). Suppress until upstream fix lands. // keep their production-only instrumentation imports out of the dev graph.
ignoreIssue: [ resolveAlias: getDevelopmentServiceAliases(),
{
path: "**/*",
title: "Encountered unexpected file in NFT list",
},
],
}, },
transpilePackages: ["@homarr/ui", "@homarr/notifications", "@homarr/modals", "@homarr/spotlight", "@homarr/widgets"], transpilePackages: ["@homarr/ui", "@homarr/notifications", "@homarr/modals", "@homarr/spotlight", "@homarr/widgets"],
images: { images: {
+1 -2
View File
@@ -59,11 +59,10 @@
"@modelcontextprotocol/sdk": "catalog:", "@modelcontextprotocol/sdk": "catalog:",
"@scalar/api-reference-react": "catalog:", "@scalar/api-reference-react": "catalog:",
"@tabler/icons-react": "catalog:", "@tabler/icons-react": "catalog:",
"@tanstack/query-async-storage-persister": "catalog:", "@tanstack/react-devtools": "catalog:",
"@tanstack/react-query": "catalog:", "@tanstack/react-query": "catalog:",
"@tanstack/react-query-devtools": "catalog:", "@tanstack/react-query-devtools": "catalog:",
"@tanstack/react-query-next-experimental": "catalog:", "@tanstack/react-query-next-experimental": "catalog:",
"@tanstack/react-query-persist-client": "catalog:",
"@trpc/client": "catalog:", "@trpc/client": "catalog:",
"@trpc/next": "catalog:", "@trpc/next": "catalog:",
"@trpc/react-query": "catalog:", "@trpc/react-query": "catalog:",
@@ -0,0 +1,120 @@
"use client";
import { useEffect, useState } from "react";
import { usePathname } from "next/navigation";
import { TanStackDevtools } from "@tanstack/react-devtools";
import type { TanStackDevtoolsReactPlugin } from "@tanstack/react-devtools";
import type { QueryClient } from "@tanstack/react-query";
import { useQueryClient } from "@tanstack/react-query";
import { ReactQueryDevtoolsPanel } from "@tanstack/react-query-devtools";
const valueStyle = { fontFamily: "monospace", overflowWrap: "anywhere" } as const;
const metricStyle = {
background: "color-mix(in srgb, currentColor 4%, transparent)",
border: "1px solid color-mix(in srgb, currentColor 18%, transparent)",
borderRadius: 8,
padding: 12,
} as const;
const readDiagnostics = (queryClient: QueryClient) => {
const queries = queryClient.getQueryCache().getAll();
const mutations = queryClient.getMutationCache().getAll();
const board = document.querySelector("[data-homarr-dev-benchmark-board]");
const widgetItems = board?.querySelectorAll('[data-type="item"]').length ?? 0;
const readyWidgets = board?.querySelectorAll("[data-homarr-widget-ready]").length ?? 0;
const resources = performance.getEntriesByType("resource") as PerformanceResourceTiming[];
return {
active: queries.filter((query) => query.getObserversCount() > 0).length,
errors: queries.filter((query) => query.state.status === "error").length,
fetching: queries.filter((query) => query.state.fetchStatus === "fetching").length,
pendingMutations: mutations.filter((mutation) => mutation.state.status === "pending").length,
stale: queries.filter((query) => query.isStale()).length,
total: queries.length,
readyWidgets,
widgetItems,
scriptKiB: Math.round(
resources
.filter((resource) => resource.initiatorType === "script")
.reduce((total, resource) => total + resource.decodedBodySize, 0) / 1024,
),
};
};
const Metric = ({ label, value }: { label: string; value: number | string }) => (
<div style={metricStyle}>
<div style={{ fontSize: 12, opacity: 0.7 }}>{label}</div>
<output style={{ ...valueStyle, display: "block", fontSize: 22, fontWeight: 600, marginTop: 4 }}>{value}</output>
</div>
);
const HomarrDevtoolsPanel = ({ active }: { active: boolean }) => {
const pathname = usePathname();
const queryClient = useQueryClient();
const [diagnostics, setDiagnostics] = useState(() => readDiagnostics(queryClient));
useEffect(() => {
if (!active) return;
const update = () => setDiagnostics(readDiagnostics(queryClient));
let frame: number | undefined;
const scheduleUpdate = () => {
if (frame !== undefined) return;
frame = window.requestAnimationFrame(() => {
frame = undefined;
update();
});
};
const unsubscribeQueries = queryClient.getQueryCache().subscribe(scheduleUpdate);
const unsubscribeMutations = queryClient.getMutationCache().subscribe(scheduleUpdate);
const observer = new MutationObserver(scheduleUpdate);
observer.observe(document.body, { childList: true, subtree: true });
update();
return () => {
unsubscribeQueries();
unsubscribeMutations();
observer.disconnect();
if (frame !== undefined) window.cancelAnimationFrame(frame);
};
}, [active, pathname, queryClient]);
if (!active) return null;
return (
<section data-homarr-devtools-panel style={{ fontFamily: "system-ui, sans-serif", padding: 16 }}>
<header style={{ marginBottom: 16 }}>
<h2 style={{ margin: 0 }}>Homarr performance</h2>
<p style={{ marginBlock: "4px 0", opacity: 0.7 }}>Live state for {pathname}</p>
</header>
<div style={{ display: "grid", gap: 8, gridTemplateColumns: "repeat(auto-fit, minmax(110px, 1fr))" }}>
<Metric label="Queries" value={diagnostics.total} />
<Metric label="Active" value={diagnostics.active} />
<Metric label="Fetching" value={diagnostics.fetching} />
<Metric label="Stale" value={diagnostics.stale} />
<Metric label="Errors" value={diagnostics.errors} />
<Metric label="Mutations" value={diagnostics.pendingMutations} />
<Metric label="Widgets ready" value={`${diagnostics.readyWidgets}/${diagnostics.widgetItems}`} />
<Metric label="Script KiB" value={diagnostics.scriptKiB} />
</div>
</section>
);
};
const developmentToolsPlugins = [
{
id: "tanstack-query",
name: "TanStack Query",
render: () => <ReactQueryDevtoolsPanel />,
},
{
id: "homarr-performance",
name: "Homarr",
defaultOpen: true,
render: (_element, { devtoolsOpen }) => <HomarrDevtoolsPanel active={devtoolsOpen} />,
},
] satisfies TanStackDevtoolsReactPlugin[];
export const DevelopmentTools = () => (
<TanStackDevtools config={{ position: "bottom-right", hideUntilHover: true }} plugins={developmentToolsPlugins} />
);
@@ -1,32 +0,0 @@
import { createAsyncStoragePersister } from "@tanstack/query-async-storage-persister";
import superjson from "superjson";
import { fetchApi } from "@homarr/api/client";
import { getActiveQueryCacheBoardId, queryCacheStoragePrefix } from "@homarr/api/query-cache";
const queryCacheStorage = {
getItem: (_key: string) => null as string | null,
async setItem(_key: string, value: string) {
const boardId = getActiveQueryCacheBoardId();
if (!boardId) return;
await fetchApi.queryCache.setItem.mutate({ boardId, value }).catch((error) => {
console.warn("[query-cache] persist failed", error);
});
},
async removeItem(_key: string) {
const boardId = getActiveQueryCacheBoardId();
if (!boardId) return;
await fetchApi.queryCache.removeItem.mutate({ boardId }).catch((error) => {
console.warn("[query-cache] remove failed", error);
});
},
};
export const createWidgetQueryPersister = () =>
createAsyncStoragePersister({
storage: typeof window === "undefined" ? undefined : queryCacheStorage,
key: queryCacheStoragePrefix,
throttleTime: 2000,
serialize: (data) => superjson.stringify(data),
deserialize: (data) => superjson.parse(data),
});
@@ -0,0 +1,27 @@
import { describe, expect, it } from "vitest";
import { TRPCClientError } from "@trpc/client";
import { createQueryRetry } from "./query-retry";
const createTrpcError = (code: string) => {
const error = Object.assign(new Error(code), { data: { code } });
Object.setPrototypeOf(error, TRPCClientError.prototype);
return error;
};
describe("createQueryRetry", () => {
it.each(["BAD_REQUEST", "UNAUTHORIZED", "FORBIDDEN", "NOT_FOUND", "UNPROCESSABLE_CONTENT"])(
"does not retry the permanent %s response",
(code) => {
expect(createQueryRetry(3)(0, createTrpcError(code))).toBe(false);
},
);
it("retries transient and network failures only up to the configured limit", () => {
const retry = createQueryRetry(3);
expect(retry(0, createTrpcError("INTERNAL_SERVER_ERROR"))).toBe(true);
expect(retry(2, new Error("connection reset"))).toBe(true);
expect(retry(3, new Error("connection reset"))).toBe(false);
});
});
@@ -0,0 +1,20 @@
import { TRPCClientError } from "@trpc/client";
const nonRetryableTrpcCodes = new Set([
"PARSE_ERROR",
"BAD_REQUEST",
"UNAUTHORIZED",
"FORBIDDEN",
"NOT_FOUND",
"METHOD_NOT_SUPPORTED",
"CONFLICT",
"PRECONDITION_FAILED",
"PAYLOAD_TOO_LARGE",
"UNPROCESSABLE_CONTENT",
]);
export const createQueryRetry = (maxRetries: number) => (failureCount: number, error: unknown) => {
if (failureCount >= maxRetries) return false;
if (error instanceof TRPCClientError && error.data?.code && nonRetryableTrpcCodes.has(error.data.code)) return false;
return true;
};
@@ -0,0 +1,52 @@
// @vitest-environment jsdom
import { act, createElement } from "react";
import { createRoot } from "react-dom/client";
import { describe, expect, it, vi } from "vitest";
import { getSessionQueryScope, SessionQueryScopeGuard } from "./session-query-scope";
describe("getSessionQueryScope", () => {
it("is stable when permission order changes", () => {
expect(getSessionQueryScope({ user: { id: "user-a", permissions: ["board-view", "integration-use"] } })).toBe(
getSessionQueryScope({ user: { id: "user-a", permissions: ["integration-use", "board-view"] } }),
);
});
it("changes for a different user or permission set", () => {
const original = getSessionQueryScope({ user: { id: "user-a", permissions: ["board-view"] } });
expect(getSessionQueryScope({ user: { id: "user-b", permissions: ["board-view"] } })).not.toBe(original);
expect(getSessionQueryScope({ user: { id: "user-a", permissions: [] } })).not.toBe(original);
expect(getSessionQueryScope(null)).toBeNull();
});
it("removes the previous session subtree before requesting a document reload", async () => {
const host = document.createElement("div");
const root = createRoot(host);
const onScopeChange = vi.fn();
const renderScope = async (currentScope: string) => {
await act(async () => {
root.render(
createElement(
SessionQueryScopeGuard,
{ currentScope, initialScope: "a", onScopeChange },
createElement("span", null, "session-a-secret"),
),
);
});
};
try {
await renderScope("a");
expect(host.textContent).toBe("session-a-secret");
await renderScope("b");
expect(host.textContent).not.toContain("session-a-secret");
expect(host.querySelector('[role="status"]')?.getAttribute("aria-busy")).toBe("true");
expect(onScopeChange).toHaveBeenCalledOnce();
} finally {
await act(async () => root.unmount());
}
});
});
@@ -0,0 +1,39 @@
import type { PropsWithChildren } from "react";
import { createElement, useEffect } from "react";
interface QuerySession {
user: {
id: string;
permissions: string[];
};
}
export const getSessionQueryScope = (session: QuerySession | null | undefined) =>
session ? JSON.stringify([session.user.id, session.user.permissions.toSorted()]) : null;
interface SessionQueryScopeGuardProps {
initialScope: string | null;
currentScope: string | null;
onScopeChange: () => void;
}
export const SessionQueryScopeGuard = ({
initialScope,
currentScope,
onScopeChange,
children,
}: PropsWithChildren<SessionQueryScopeGuardProps>) => {
const hasScopeChanged = initialScope !== currentScope;
useEffect(() => {
if (hasScopeChanged) onScopeChange();
}, [hasScopeChanged, onScopeChange]);
if (!hasScopeChanged) return children;
return createElement("div", {
"aria-busy": true,
"aria-label": "Refreshing session",
role: "status",
style: { minHeight: "100dvh" },
});
};
@@ -1,11 +1,10 @@
"use client"; "use client";
import type { PropsWithChildren } from "react"; import type { PropsWithChildren } from "react";
import { useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { QueryClient } from "@tanstack/react-query"; import dynamic from "next/dynamic";
import { ReactQueryDevtools } from "@tanstack/react-query-devtools"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { ReactQueryStreamedHydration } from "@tanstack/react-query-next-experimental"; import { ReactQueryStreamedHydration } from "@tanstack/react-query-next-experimental";
import { PersistQueryClientProvider } from "@tanstack/react-query-persist-client";
import { import {
createWSClient, createWSClient,
httpBatchStreamLink, httpBatchStreamLink,
@@ -24,17 +23,24 @@ import { TRPCClientError } from "@trpc/client";
import type { AppRouter } from "@homarr/api"; import type { AppRouter } from "@homarr/api";
import { clientApi } from "@homarr/api/client"; import { clientApi } from "@homarr/api/client";
import { import {
isPersistableWidgetQueryKey, dashboardSupportingQueryPolicies,
queryCacheBuster,
queryCacheDefaultGcTimeMs, queryCacheDefaultGcTimeMs,
queryCacheDefaultRefetchIntervalMs, queryCacheDefaultRefetchIntervalMs,
queryCacheDefaultStaleTimeMs, queryCacheDefaultStaleTimeMs,
} from "@homarr/api/query-cache"; } from "@homarr/api/query-cache";
import { createHeadersCallbackForSource, getTrpcUrl } from "@homarr/api/shared"; import { createHeadersCallbackForSource, getTrpcUrl } from "@homarr/api/shared";
import { useSession } from "@homarr/auth/client";
import { env } from "@homarr/common/env"; import { env } from "@homarr/common/env";
import { showWarningNotification } from "@homarr/notifications"; import { showWarningNotification } from "@homarr/notifications";
import { widgetQueryRefetchIntervals } from "@homarr/widgets/refetch-intervals";
import { createWidgetQueryPersister } from "./query-cache-persister"; import { getSessionQueryScope, SessionQueryScopeGuard } from "./session-query-scope";
import { createQueryRetry } from "./query-retry";
const DevelopmentTools =
process.env.NODE_ENV === "development"
? dynamic(() => import("./development-tools").then(({ DevelopmentTools: Tools }) => Tools), { ssr: false })
: () => null;
const getWebSocketProtocol = () => { const getWebSocketProtocol = () => {
if (typeof window === "undefined") { if (typeof window === "undefined") {
@@ -57,21 +63,46 @@ const constructWebsocketUrl = () => {
return `${getWebSocketProtocol()}://${window.location.hostname}:${window.location.port}/websockets`; return `${getWebSocketProtocol()}://${window.location.hostname}:${window.location.port}/websockets`;
}; };
const wsClient = createWSClient({ export function TRPCReactProvider({ children }: PropsWithChildren) {
url: constructWebsocketUrl(), const { data: session } = useSession();
}); const sessionQueryScope = getSessionQueryScope(session);
const [initialSessionQueryScope] = useState(() => sessionQueryScope);
export function TRPCReactProvider(props: PropsWithChildren) { return (
const [persister] = useState(() => createWidgetQueryPersister()); <SessionQueryScopeGuard
initialScope={initialSessionQueryScope}
currentScope={sessionQueryScope}
onScopeChange={reloadPage}
>
<ScopedTRPCReactProvider>{children}</ScopedTRPCReactProvider>
</SessionQueryScopeGuard>
);
}
const reloadPage = () => window.location.reload();
const ScopedTRPCReactProvider = ({ children }: PropsWithChildren) => {
const wsClient = useMemo(
() =>
createWSClient({
url: constructWebsocketUrl(),
lazy: { enabled: true, closeMs: 30_000 },
}),
[],
);
useEffect(
() => () => {
void wsClient.close();
},
[wsClient],
);
const [queryClient] = useState(() => { const [queryClient] = useState(() => {
const client = new QueryClient({ const client = new QueryClient({
defaultOptions: { defaultOptions: {
queries: { queries: {
staleTime: queryCacheDefaultStaleTimeMs, staleTime: queryCacheDefaultStaleTimeMs,
gcTime: queryCacheDefaultGcTimeMs, gcTime: queryCacheDefaultGcTimeMs,
refetchOnWindowFocus: false, retry: createQueryRetry(env.NODE_ENV === "development" ? 1 : 3),
refetchOnReconnect: false,
retry: 3,
}, },
mutations: { mutations: {
onError(error) { onError(error) {
@@ -89,16 +120,29 @@ export function TRPCReactProvider(props: PropsWithChildren) {
}, },
}, },
}); });
client.setQueryDefaults([["widget"]], { refetchInterval: queryCacheDefaultRefetchIntervalMs }); client.setQueryDefaults([["widget"]], {
refetchInterval: queryCacheDefaultRefetchIntervalMs,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
});
for (const { queryKey, intervalSeconds } of widgetQueryRefetchIntervals) {
client.setQueryDefaults(queryKey, {
refetchInterval: intervalSeconds === null ? false : intervalSeconds * 1000,
});
}
for (const { queryKey, ...policy } of dashboardSupportingQueryPolicies) {
client.setQueryDefaults(queryKey, policy);
}
return client; return client;
}); });
const [trpcClient] = useState(() => { useEffect(() => () => queryClient.clear(), [queryClient]);
const trpcClient = useMemo(() => {
return clientApi.createClient({ return clientApi.createClient({
links: [ links: [
loggerLink({ loggerLink({
enabled: (opts) => enabled: (opts) => opts.direction === "down" && opts.result instanceof Error,
env.NODE_ENV === "development" || (opts.direction === "down" && opts.result instanceof Error),
}), }),
splitLink({ splitLink({
condition: ({ type }) => type === "subscription", condition: ({ type }) => type === "subscription",
@@ -138,25 +182,14 @@ export function TRPCReactProvider(props: PropsWithChildren) {
}), }),
], ],
}); });
}); }, [wsClient]);
return ( return (
<clientApi.Provider client={trpcClient} queryClient={queryClient}> <clientApi.Provider client={trpcClient} queryClient={queryClient}>
<PersistQueryClientProvider <QueryClientProvider client={queryClient}>
client={queryClient} <ReactQueryStreamedHydration transformer={superjson}>{children}</ReactQueryStreamedHydration>
persistOptions={{ {process.env.NODE_ENV === "development" && <DevelopmentTools />}
persister, </QueryClientProvider>
buster: queryCacheBuster,
maxAge: queryCacheDefaultGcTimeMs,
dehydrateOptions: {
shouldDehydrateQuery: (query) =>
query.state.status === "success" && isPersistableWidgetQueryKey(query.queryKey),
},
}}
>
<ReactQueryStreamedHydration transformer={superjson}>{props.children}</ReactQueryStreamedHydration>
<ReactQueryDevtools initialIsOpen={false} />
</PersistQueryClientProvider>
</clientApi.Provider> </clientApi.Provider>
); );
} };
@@ -1,9 +1,9 @@
import { api } from "@homarr/api/server"; import { getHomeBoardAsync } from "@homarr/api/board-server";
import { createBoardContentPage } from "../_creator"; import { createBoardContentPage } from "../_creator";
export default createBoardContentPage<{ locale: string }>({ export default createBoardContentPage<{ locale: string }>({
async getInitialBoardAsync() { async getInitialBoardAsync() {
return await api.board.getHomeBoard(); return await getHomeBoardAsync();
}, },
}); });
@@ -1,9 +1,9 @@
import { api } from "@homarr/api/server"; import { getBoardByNameAsync } from "@homarr/api/board-server";
import { createBoardContentPage } from "../../_creator"; import { createBoardContentPage } from "../../_creator";
export default createBoardContentPage<{ locale: string; name: string }>({ export default createBoardContentPage<{ locale: string; name: string }>({
async getInitialBoardAsync({ name }) { async getInitialBoardAsync({ name }) {
return await api.board.getBoardByName({ name }); return await getBoardByNameAsync(name);
}, },
}); });
@@ -0,0 +1,84 @@
"use client";
import { useCallback } from "react";
import { Group, Menu } from "@mantine/core";
import { IconBox, IconBoxAlignTop, IconChevronDown, IconPlug, IconPlus, IconResize } from "@tabler/icons-react";
import { useSession } from "@homarr/auth/client";
import { useModalAction } from "@homarr/modals";
import { AppSelectModal } from "@homarr/modals-collection";
import { useI18n } from "@homarr/translation/client";
import { useItemActions } from "~/components/board/items/item-actions";
import { ItemSelectModal } from "~/components/board/items/item-select-modal";
import { useCategoryActions } from "~/components/board/sections/category/category-actions";
import { CategoryEditModal } from "~/components/board/sections/category/category-edit-modal";
import { useDynamicSectionActions } from "~/components/board/sections/dynamic/dynamic-actions";
import { IntegrationSelectModal } from "~/components/integration/integration-select-modal";
import { HeaderButton } from "~/components/layout/header/button";
export const BoardAddMenu = () => {
const { data: session } = useSession();
const { openModal: openCategoryEditModal } = useModalAction(CategoryEditModal);
const { openModal: openItemSelectModal } = useModalAction(ItemSelectModal);
const { openModal: openAppSelectModal } = useModalAction(AppSelectModal);
const { openModal: openIntegrationSelectModal } = useModalAction(IntegrationSelectModal);
const { addCategoryToEnd } = useCategoryActions();
const { addDynamicSection } = useDynamicSectionActions();
const { createItem } = useItemActions();
const t = useI18n();
const handleAddCategory = useCallback(
() =>
openCategoryEditModal(
{
category: { id: "new", name: "" },
onSuccess({ name }) {
addCategoryToEnd({ name });
},
submitLabel: t("section.category.create.submit"),
},
{ title: (translate) => translate("section.category.create.title") },
),
[addCategoryToEnd, openCategoryEditModal, t],
);
return (
<Menu position="bottom-end">
<Menu.Target>
<HeaderButton w="auto" px={4}>
<Group gap={4} wrap="nowrap">
<IconPlus stroke={1.5} />
<IconChevronDown color="gray" size={16} />
</Group>
</HeaderButton>
</Menu.Target>
<Menu.Dropdown style={{ transform: "translate(-3px, 0)" }}>
<Menu.Item leftSection={<IconResize size={20} />} onClick={() => openItemSelectModal()}>
{t("item.action.create")}
</Menu.Item>
<Menu.Item
leftSection={<IconBox size={20} />}
onClick={() =>
openAppSelectModal({
onSelect: (app) => createItem({ kind: "app", options: { appId: app.id } }),
withCreate: session?.user.permissions.includes("app-create") ?? false,
})
}
>
{t("app.action.add")}
</Menu.Item>
<Menu.Item leftSection={<IconPlug size={20} />} onClick={() => openIntegrationSelectModal({})}>
{t("integration.action.create")}
</Menu.Item>
<Menu.Divider />
<Menu.Item leftSection={<IconBoxAlignTop size={20} />} onClick={handleAddCategory}>
{t("section.category.action.create")}
</Menu.Item>
<Menu.Item leftSection={<IconResize size={20} />} onClick={addDynamicSection}>
{t("section.dynamic.action.create")}
</Menu.Item>
</Menu.Dropdown>
</Menu>
);
};
@@ -14,10 +14,10 @@ export const ClientBoard = () => {
const fullWidthSortedSections = board.sections const fullWidthSortedSections = board.sections
.filter((section) => section.kind === "empty" || section.kind === "category") .filter((section) => section.kind === "empty" || section.kind === "category")
.sort((sectionA, sectionB) => sectionA.yOffset - sectionB.yOffset); .toSorted((sectionA, sectionB) => sectionA.yOffset - sectionB.yOffset);
return ( return (
<Box h="100%" pos="relative"> <Box h="100%" pos="relative" data-homarr-dev-benchmark-board>
<BoardBackgroundVideo /> <BoardBackgroundVideo />
<Stack h="100%"> <Stack h="100%">
{fullWidthSortedSections.map((section) => {fullWidthSortedSections.map((section) =>
@@ -1,15 +1,12 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import { Suspense } from "react"; import { cache } from "react";
import { TRPCError } from "@trpc/server"; import { TRPCError } from "@trpc/server";
// Placed here because gridstack styles are used for board content // Placed here because gridstack styles are used for board content
import "~/styles/gridstack.scss"; import "~/styles/gridstack.scss";
import { dehydrate, HydrationBoundary } from "@tanstack/react-query"; import { dehydrate, HydrationBoundary } from "@tanstack/react-query";
import type { PersistedClient } from "@tanstack/react-query-persist-client"; import { makeQueryClient } from "@homarr/api/shared";
import superjson from "superjson";
import { queryCacheBuster } from "@homarr/api/query-cache";
import { getQueryClient } from "@homarr/api/server";
import { IntegrationProvider } from "@homarr/auth/client"; import { IntegrationProvider } from "@homarr/auth/client";
import { auth } from "@homarr/auth/next"; import { auth } from "@homarr/auth/next";
import { getIntegrationsWithPermissionsAsync } from "@homarr/auth/server"; import { getIntegrationsWithPermissionsAsync } from "@homarr/auth/server";
@@ -17,7 +14,6 @@ import { isNullOrWhitespace } from "@homarr/common";
import { createLogger } from "@homarr/core/infrastructure/logs"; import { createLogger } from "@homarr/core/infrastructure/logs";
import { ErrorWithMetadata } from "@homarr/core/infrastructure/logs/error"; import { ErrorWithMetadata } from "@homarr/core/infrastructure/logs/error";
import type { WidgetKind } from "@homarr/definitions"; import type { WidgetKind } from "@homarr/definitions";
import { getQueryCacheAsync } from "@homarr/redis";
import { getI18n } from "@homarr/translation/server"; import { getI18n } from "@homarr/translation/server";
import { prefetchForKindAsync } from "@homarr/widgets/prefetch"; import { prefetchForKindAsync } from "@homarr/widgets/prefetch";
@@ -29,6 +25,7 @@ import { DynamicClientBoard } from "./_dynamic-client";
import { BoardContentHeaderActions } from "./_header-actions"; import { BoardContentHeaderActions } from "./_header-actions";
const logger = createLogger({ module: "createBoardContentPage" }); const logger = createLogger({ module: "createBoardContentPage" });
const getQueryClient = cache(makeQueryClient);
export type Params = Record<string, unknown>; export type Params = Record<string, unknown>;
@@ -61,7 +58,6 @@ export const createBoardContentPage = <TParams extends Record<string, unknown>>(
} }
return acc; return acc;
}, new Map<WidgetKind, Item[]>()); }, new Map<WidgetKind, Item[]>());
const [integrations] = await Promise.all([ const [integrations] = await Promise.all([
getIntegrationsWithPermissionsAsync(session), getIntegrationsWithPermissionsAsync(session),
...Array.from(itemsMap).map(([kind, items]) => ...Array.from(itemsMap).map(([kind, items]) =>
@@ -77,19 +73,12 @@ export const createBoardContentPage = <TParams extends Record<string, unknown>>(
), ),
]); ]);
const userId = session?.user.id ?? "anonymous";
return ( return (
<> <HydrationBoundary state={dehydrate(queryClient)}>
<Suspense> <IntegrationProvider integrations={integrations}>
<QueryCacheHydration userId={userId} boardId={board.id} /> <DynamicClientBoard />
</Suspense> </IntegrationProvider>
<HydrationBoundary state={dehydrate(queryClient)}> </HydrationBoundary>
<IntegrationProvider integrations={integrations}>
<DynamicClientBoard />
</IntegrationProvider>
</HydrationBoundary>
</>
); );
}, },
generateMetadataAsync: async ({ params }: { params: Promise<TParams> }): Promise<Metadata> => { generateMetadataAsync: async ({ params }: { params: Promise<TParams> }): Promise<Metadata> => {
@@ -120,19 +109,3 @@ export const createBoardContentPage = <TParams extends Record<string, unknown>>(
}, },
}; };
}; };
async function QueryCacheHydration({ userId, boardId }: { userId: string; boardId: string }) {
try {
const serialized = await getQueryCacheAsync(userId, boardId);
if (!serialized) return null;
const persisted = superjson.parse<PersistedClient | undefined>(serialized);
if (!persisted || persisted.buster !== queryCacheBuster) return null;
if (!persisted.clientState?.queries?.length) return null;
return <HydrationBoundary state={persisted.clientState} />;
} catch (error) {
logger.warn(new ErrorWithMetadata("Failed to hydrate query cache", { userId, boardId }, { cause: error }));
return null;
}
}
@@ -1,7 +1,24 @@
"use client"; "use client";
import dynamic from "next/dynamic"; import dynamic from "next/dynamic";
import { SimpleGrid, Skeleton, Stack } from "@mantine/core";
const BoardLoadingShell = () => (
<Stack h="100%" p="md" aria-busy aria-label="Loading dashboard" role="status">
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
{[2, 1, 2, 2, 1, 2].map((rows, index) => (
<Skeleton
key={index}
data-homarr-dev-benchmark-board-shell
height={rows * 96}
radius="var(--mantine-radius-default)"
/>
))}
</SimpleGrid>
</Stack>
);
export const DynamicClientBoard = dynamic(() => import("./_client").then((mod) => mod.ClientBoard), { export const DynamicClientBoard = dynamic(() => import("./_client").then((mod) => mod.ClientBoard), {
ssr: false, ssr: false,
loading: BoardLoadingShell,
}); });
@@ -1,45 +1,36 @@
"use client"; "use client";
import { useCallback, useEffect } from "react"; import { useCallback, useEffect, useState } from "react";
import dynamic from "next/dynamic";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { OnboardingTour } from "@gfazioli/mantine-onboarding-tour"; import { Box, Center, Loader, Menu, ScrollArea } from "@mantine/core";
import { Box, Group, Menu, ScrollArea } from "@mantine/core";
import { useHotkeys } from "@mantine/hooks"; import { useHotkeys } from "@mantine/hooks";
import { import { IconLayoutBoard, IconPencil, IconPencilOff, IconPlus, IconReplace, IconSettings } from "@tabler/icons-react";
IconBox,
IconBoxAlignTop,
IconChevronDown,
IconLayoutBoard,
IconPencil,
IconPencilOff,
IconPlug,
IconPlus,
IconReplace,
IconResize,
IconSettings,
} from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client"; import { clientApi } from "@homarr/api/client";
import { useSession } from "@homarr/auth/client";
import { useRequiredBoard } from "@homarr/boards/context"; import { useRequiredBoard } from "@homarr/boards/context";
import { useEditMode } from "@homarr/boards/edit-mode"; import { useEditMode } from "@homarr/boards/edit-mode";
import { revalidatePathActionAsync } from "@homarr/common/client"; import { revalidatePathActionAsync } from "@homarr/common/client";
import { env } from "@homarr/common/env"; import { env } from "@homarr/common/env";
import { hotkeys } from "@homarr/definitions"; import { hotkeys } from "@homarr/definitions";
import { useConfirmModal, useModalAction } from "@homarr/modals"; import { useConfirmModal } from "@homarr/modals";
import { AppSelectModal } from "@homarr/modals-collection";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications"; import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useI18n, useScopedI18n } from "@homarr/translation/client"; import { useI18n, useScopedI18n } from "@homarr/translation/client";
import { Link } from "@homarr/ui"; import { Link } from "@homarr/ui";
import { useItemActions } from "~/components/board/items/item-actions";
import { ItemSelectModal } from "~/components/board/items/item-select-modal";
import { useBoardPermissions } from "~/components/board/permissions/client"; import { useBoardPermissions } from "~/components/board/permissions/client";
import { useCategoryActions } from "~/components/board/sections/category/category-actions";
import { CategoryEditModal } from "~/components/board/sections/category/category-edit-modal";
import { useDynamicSectionActions } from "~/components/board/sections/dynamic/dynamic-actions";
import { IntegrationSelectModal } from "~/components/integration/integration-select-modal";
import { HeaderButton } from "~/components/layout/header/button"; import { HeaderButton } from "~/components/layout/header/button";
import { TourTarget } from "~/components/layout/header/tour-target";
const loadBoardAddMenu = () => import("./_board-add-menu");
const preloadBoardAddMenu = () => void loadBoardAddMenu().catch(() => undefined);
const BoardAddMenu = dynamic(() => loadBoardAddMenu().then(({ BoardAddMenu: AddMenu }) => AddMenu), {
loading: () => (
<HeaderButton loading>
<IconPlus stroke={1.5} />
</HeaderButton>
),
});
export const BoardContentHeaderActions = ({ demoReadOnly }: { demoReadOnly: boolean }) => { export const BoardContentHeaderActions = ({ demoReadOnly }: { demoReadOnly: boolean }) => {
const [isEditMode] = useEditMode(); const [isEditMode] = useEditMode();
@@ -52,16 +43,16 @@ export const BoardContentHeaderActions = ({ demoReadOnly }: { demoReadOnly: bool
return ( return (
<> <>
{isEditMode && <AddMenu />} {isEditMode && <BoardAddMenu />}
<EditModeMenu demoReadOnly={demoReadOnly} /> <EditModeMenu demoReadOnly={demoReadOnly} />
{!demoReadOnly && ( {!demoReadOnly && (
<OnboardingTour.Target id="board-settings"> <TourTarget id="board-settings">
<HeaderButton href={`/boards/${board.name}/settings`}> <HeaderButton href={`/boards/${board.name}/settings`}>
<IconSettings stroke={1.5} /> <IconSettings stroke={1.5} />
</HeaderButton> </HeaderButton>
</OnboardingTour.Target> </TourTarget>
)} )}
<SelectBoardsMenu /> <SelectBoardsMenu />
@@ -69,94 +60,6 @@ export const BoardContentHeaderActions = ({ demoReadOnly }: { demoReadOnly: bool
); );
}; };
const AddMenu = () => {
const { data: session } = useSession();
const { openModal: openCategoryEditModal } = useModalAction(CategoryEditModal);
const { openModal: openItemSelectModal } = useModalAction(ItemSelectModal);
const { openModal: openAppSelectModal } = useModalAction(AppSelectModal);
const { openModal: openIntegrationSelectModal } = useModalAction(IntegrationSelectModal);
const { addCategoryToEnd } = useCategoryActions();
const { addDynamicSection } = useDynamicSectionActions();
const { createItem } = useItemActions();
const t = useI18n();
const handleAddCategory = useCallback(
() =>
openCategoryEditModal(
{
category: {
id: "new",
name: "",
},
onSuccess({ name }) {
addCategoryToEnd({ name });
},
submitLabel: t("section.category.create.submit"),
},
{
title: (t) => t("section.category.create.title"),
},
),
[addCategoryToEnd, openCategoryEditModal, t],
);
const handleSelectItem = useCallback(() => {
openItemSelectModal();
}, [openItemSelectModal]);
const handleSelectApp = useCallback(() => {
openAppSelectModal({
onSelect: (app) => {
createItem({
kind: "app",
options: { appId: app.id },
});
},
withCreate: session?.user.permissions.includes("app-create") ?? false,
});
}, [openAppSelectModal, createItem]);
const handleAddIntegration = useCallback(() => {
openIntegrationSelectModal({});
}, [openIntegrationSelectModal]);
return (
<Menu position="bottom-end">
<Menu.Target>
<HeaderButton w="auto" px={4}>
<Group gap={4} wrap="nowrap">
<IconPlus stroke={1.5} />
<IconChevronDown color="gray" size={16} />
</Group>
</HeaderButton>
</Menu.Target>
<Menu.Dropdown style={{ transform: "translate(-3px, 0)" }}>
<Menu.Item leftSection={<IconResize size={20} />} onClick={handleSelectItem}>
{t("item.action.create")}
</Menu.Item>
<Menu.Item leftSection={<IconBox size={20} />} onClick={handleSelectApp}>
{t("app.action.add")}
</Menu.Item>
<Menu.Item leftSection={<IconPlug size={20} />} onClick={handleAddIntegration}>
{t("integration.action.create")}
</Menu.Item>
<Menu.Divider />
<Menu.Item leftSection={<IconBoxAlignTop size={20} />} onClick={handleAddCategory}>
{t("section.category.action.create")}
</Menu.Item>
<Menu.Item leftSection={<IconResize size={20} />} onClick={addDynamicSection}>
{t("section.dynamic.action.create")}
</Menu.Item>
</Menu.Dropdown>
</Menu>
);
};
const EditModeMenu = ({ demoReadOnly }: { demoReadOnly: boolean }) => { const EditModeMenu = ({ demoReadOnly }: { demoReadOnly: boolean }) => {
const [isEditMode, { open, close }] = useEditMode(); const [isEditMode, { open, close }] = useEditMode();
const board = useRequiredBoard(); const board = useRequiredBoard();
@@ -197,21 +100,29 @@ const EditModeMenu = ({ demoReadOnly }: { demoReadOnly: boolean }) => {
usePreventLeaveWithDirty(isEditMode); usePreventLeaveWithDirty(isEditMode);
return ( return (
<OnboardingTour.Target id="board-edit-mode"> <TourTarget id="board-edit-mode">
<HeaderButton onClick={toggle} loading={isPending}> <HeaderButton
onClick={toggle}
loading={isPending}
onFocus={preloadBoardAddMenu}
onPointerEnter={preloadBoardAddMenu}
>
{isEditMode ? <IconPencilOff stroke={1.5} /> : <IconPencil stroke={1.5} />} {isEditMode ? <IconPencilOff stroke={1.5} /> : <IconPencil stroke={1.5} />}
</HeaderButton> </HeaderButton>
</OnboardingTour.Target> </TourTarget>
); );
}; };
const SelectBoardsMenu = () => { const SelectBoardsMenu = () => {
const { data: boards = [] } = clientApi.board.getAllBoards.useQuery(); const [isOpen, setIsOpen] = useState(false);
const utils = clientApi.useUtils();
const { data: boards = [], isPending } = clientApi.board.getAllBoards.useQuery(undefined, { enabled: isOpen });
const preloadBoards = () => void utils.board.getAllBoards.prefetch();
return ( return (
<OnboardingTour.Target id="board-switcher"> <TourTarget id="board-switcher">
<Box> <Box onFocus={preloadBoards} onPointerEnter={preloadBoards}>
<Menu position="bottom-end"> <Menu position="bottom-end" opened={isOpen} onChange={setIsOpen}>
<Menu.Target> <Menu.Target>
<HeaderButton w="auto" px={4}> <HeaderButton w="auto" px={4}>
<IconReplace stroke={1.5} /> <IconReplace stroke={1.5} />
@@ -219,6 +130,11 @@ const SelectBoardsMenu = () => {
</Menu.Target> </Menu.Target>
<Menu.Dropdown style={{ transform: "translate(-7px, 0)" }}> <Menu.Dropdown style={{ transform: "translate(-7px, 0)" }}>
<ScrollArea.Autosize mah={300}> <ScrollArea.Autosize mah={300}>
{isPending && (
<Center p="xs">
<Loader size="xs" />
</Center>
)}
{boards.map((board) => ( {boards.map((board) => (
<Menu.Item <Menu.Item
key={board.id} key={board.id}
@@ -233,7 +149,7 @@ const SelectBoardsMenu = () => {
</Menu.Dropdown> </Menu.Dropdown>
</Menu> </Menu>
</Box> </Box>
</OnboardingTour.Target> </TourTarget>
); );
}; };
@@ -246,15 +162,13 @@ const usePreventLeaveWithDirty = (isDirty: boolean) => {
useEffect(() => { useEffect(() => {
if (!isDirty) return; if (!isDirty) return;
const handleClick = (event: Event) => { const handleClick = (event: MouseEvent) => {
const target = (event.target as HTMLElement).closest("a"); if (event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;
const target = event.target instanceof Element ? event.target.closest<HTMLAnchorElement>(anchorSelector) : null;
if (!target) { if (!target) return;
console.warn("No anchor element found for click event", event);
return;
}
event.preventDefault(); event.preventDefault();
event.stopPropagation();
openConfirmModal({ openConfirmModal({
title: t("board.action.edit.confirmLeave.title"), title: t("board.action.edit.confirmLeave.title"),
@@ -280,19 +194,14 @@ const usePreventLeaveWithDirty = (isDirty: boolean) => {
event.returnValue = true; event.returnValue = true;
}; };
const anchors = document.querySelectorAll(anchorSelector); document.addEventListener("click", handleClick, true);
anchors.forEach((link) => {
link.addEventListener("click", handleClick);
});
window.addEventListener("popstate", handlePopState); window.addEventListener("popstate", handlePopState);
window.addEventListener("beforeunload", handleBeforeUnload); window.addEventListener("beforeunload", handleBeforeUnload);
return () => { return () => {
anchors.forEach((link) => { document.removeEventListener("click", handleClick, true);
link.removeEventListener("click", handleClick);
});
window.removeEventListener("popstate", handlePopState); window.removeEventListener("popstate", handlePopState);
window.removeEventListener("beforeunload", handleBeforeUnload); window.removeEventListener("beforeunload", handleBeforeUnload);
}; };
}, [isDirty]); }, [isDirty, openConfirmModal, router, t]);
}; };
@@ -1,4 +1,4 @@
import { api } from "@homarr/api/server"; import { getBoardByNameAsync } from "@homarr/api/board-server";
import { BoardOtherHeaderActions } from "../_header-actions"; import { BoardOtherHeaderActions } from "../_header-actions";
import { createBoardLayout } from "../_layout-creator"; import { createBoardLayout } from "../_layout-creator";
@@ -6,6 +6,6 @@ import { createBoardLayout } from "../_layout-creator";
export default createBoardLayout<{ locale: string; name: string }>({ export default createBoardLayout<{ locale: string; name: string }>({
headerActions: <BoardOtherHeaderActions />, headerActions: <BoardOtherHeaderActions />,
async getInitialBoardAsync({ name }) { async getInitialBoardAsync({ name }) {
return await api.board.getBoardByName({ name }); return await getBoardByNameAsync(name);
}, },
}); });
@@ -3,6 +3,7 @@ import { notFound, redirect } from "next/navigation";
import { AppShellMain } from "@mantine/core"; import { AppShellMain } from "@mantine/core";
import { TRPCError } from "@trpc/server"; import { TRPCError } from "@trpc/server";
import { getRscUserSettingsAsync } from "@homarr/api/user-server";
import { auth } from "@homarr/auth/next"; import { auth } from "@homarr/auth/next";
import { BoardProvider } from "@homarr/boards/context"; import { BoardProvider } from "@homarr/boards/context";
import { EditModeProvider } from "@homarr/boards/edit-mode"; import { EditModeProvider } from "@homarr/boards/edit-mode";
@@ -11,7 +12,8 @@ import { createLogger } from "@homarr/core/infrastructure/logs";
import { MainHeader } from "~/components/layout/header"; import { MainHeader } from "~/components/layout/header";
import { BoardLogoWithTitle } from "~/components/layout/logo/board-logo"; import { BoardLogoWithTitle } from "~/components/layout/logo/board-logo";
import { ClientShell } from "~/components/layout/shell"; import { ClientShell } from "~/components/layout/shell";
import { BoardTourProvider } from "~/components/onboarding/board-tour"; import { BoardTourGate } from "~/components/onboarding/board-tour-gate";
import { env } from "~/env";
import { getCurrentColorSchemeAsync } from "~/theme/color-scheme"; import { getCurrentColorSchemeAsync } from "~/theme/color-scheme";
import type { Board } from "./_types"; import type { Board } from "./_types";
import type { Params } from "./(content)/_creator"; import type { Params } from "./(content)/_creator";
@@ -21,11 +23,6 @@ import { BoardMantineProvider } from "./(content)/_theme";
const logger = createLogger({ module: "createBoardLayout" }); const logger = createLogger({ module: "createBoardLayout" });
const BoardTourWrapper = ({ hasSession, children }: PropsWithChildren<{ hasSession: boolean }>) => {
if (!hasSession) return <>{children}</>;
return <BoardTourProvider>{children}</BoardTourProvider>;
};
interface CreateBoardLayoutProps<TParams extends Params> { interface CreateBoardLayoutProps<TParams extends Params> {
headerActions: JSX.Element; headerActions: JSX.Element;
getInitialBoardAsync: (params: TParams) => Promise<Board>; getInitialBoardAsync: (params: TParams) => Promise<Board>;
@@ -43,8 +40,32 @@ export const createBoardLayout = <TParams extends Params>({
}: PropsWithChildren<{ }: PropsWithChildren<{
params: Promise<TParams>; params: Promise<TParams>;
}>) => { }>) => {
const session = await auth(); const resolvedParams = await params;
const initialBoard = await getInitialBoard(await params).catch((error) => { const sessionPromise = auth();
const initialBoardPromise = getInitialBoard(resolvedParams).then(
(board) => ({ status: "fulfilled", board }) as const,
(error: unknown) => ({ status: "rejected", error }) as const,
);
const colorSchemePromise = getCurrentColorSchemeAsync();
const shouldRunBoardTourPromise = sessionPromise.then(async (session) => {
if (!withTour || !session || env.DEMO_MODE) return false;
try {
const user = await getRscUserSettingsAsync(session.user.id);
return user !== undefined && !user.completedBoardTour;
} catch (error) {
logger.error(new Error("Failed to load the board tour status", { cause: error }));
return false;
}
});
const [session, initialBoardResult, colorScheme, shouldRunBoardTour] = await Promise.all([
sessionPromise,
initialBoardPromise,
colorSchemePromise,
shouldRunBoardTourPromise,
]);
if (initialBoardResult.status === "rejected") {
const { error } = initialBoardResult;
if (error instanceof TRPCError && error.code === "NOT_FOUND") { if (error instanceof TRPCError && error.code === "NOT_FOUND") {
if (!session) { if (!session) {
logger.debug("No home board found for anonymous user, redirecting to login"); logger.debug("No home board found for anonymous user, redirecting to login");
@@ -60,8 +81,8 @@ export const createBoardLayout = <TParams extends Params>({
} }
throw error; throw error;
}); }
const colorScheme = await getCurrentColorSchemeAsync(); const initialBoard = initialBoardResult.board;
return ( return (
<BoardProvider initialBoard={initialBoard}> <BoardProvider initialBoard={initialBoard}>
@@ -69,7 +90,7 @@ export const createBoardLayout = <TParams extends Params>({
<EditModeProvider> <EditModeProvider>
<BoardMantineProvider defaultColorScheme={colorScheme}> <BoardMantineProvider defaultColorScheme={colorScheme}>
<CustomCss /> <CustomCss />
<BoardTourWrapper hasSession={withTour && !!session}> <BoardTourGate enabled={shouldRunBoardTour}>
<ClientShell hasNavigation={false}> <ClientShell hasNavigation={false}>
<MainHeader <MainHeader
logo={<BoardLogoWithTitle size="md" hideTitleOnMobile />} logo={<BoardLogoWithTitle size="md" hideTitleOnMobile />}
@@ -78,7 +99,7 @@ export const createBoardLayout = <TParams extends Params>({
/> />
<AppShellMain>{children}</AppShellMain> <AppShellMain>{children}</AppShellMain>
</ClientShell> </ClientShell>
</BoardTourWrapper> </BoardTourGate>
</BoardMantineProvider> </BoardMantineProvider>
</EditModeProvider> </EditModeProvider>
</BoardReadyProvider> </BoardReadyProvider>
+9 -2
View File
@@ -1,8 +1,8 @@
import type { JSX } from "react"; import type { JSX } from "react";
import { Box, Center, Stack, Text, Title } from "@mantine/core"; import { Box, Center, Stack, Text, Title } from "@mantine/core";
import { api } from "@homarr/api/server";
import type { MaybePromise } from "@homarr/common/types"; import type { MaybePromise } from "@homarr/common/types";
import { db } from "@homarr/db";
import type { OnboardingStep } from "@homarr/definitions"; import type { OnboardingStep } from "@homarr/definitions";
import { getScopedI18n } from "@homarr/translation/server"; import { getScopedI18n } from "@homarr/translation/server";
@@ -29,9 +29,16 @@ const stepComponents: Record<OnboardingStep, null | (() => MaybePromise<JSX.Elem
finish: InitFinish, finish: InitFinish,
}; };
const getCurrentOnboardingStepAsync = async () => {
const value = await db.query.onboarding.findFirst();
if (!value) return { current: "start" as const, previous: null };
return { current: value.step, previous: value.previousStep };
};
export default async function InitPage() { export default async function InitPage() {
const t = await getScopedI18n("init.step"); const t = await getScopedI18n("init.step");
const currentStep = await api.onboard.currentStep(); const currentStep = await getCurrentOnboardingStepAsync();
const CurrentComponent = stepComponents[currentStep.current]; const CurrentComponent = stepComponents[currentStep.current];
+23 -11
View File
@@ -13,11 +13,11 @@ import { notFound } from "next/navigation";
import type { DayOfWeek } from "@mantine/dates"; import type { DayOfWeek } from "@mantine/dates";
import { NextIntlClientProvider } from "next-intl"; import { NextIntlClientProvider } from "next-intl";
import { api } from "@homarr/api/server"; import { getRscServerSettingsAsync } from "@homarr/api/server-settings-server";
import { getRscUserSettingsAsync } from "@homarr/api/user-server";
import { env } from "@homarr/auth/env"; import { env } from "@homarr/auth/env";
import { auth } from "@homarr/auth/next"; import { auth } from "@homarr/auth/next";
import { db } from "@homarr/db"; import { createLogger } from "@homarr/core/infrastructure/logs";
import { getServerSettingsAsync } from "@homarr/db/queries";
import { ModalProvider } from "@homarr/modals"; import { ModalProvider } from "@homarr/modals";
import { Notifications } from "@homarr/notifications"; import { Notifications } from "@homarr/notifications";
import { SettingsProvider } from "@homarr/settings"; import { SettingsProvider } from "@homarr/settings";
@@ -43,6 +43,8 @@ const fontSans = Inter({
variable: "--font-sans", variable: "--font-sans",
}); });
const logger = createLogger({ module: "rootLayout" });
// eslint-disable-next-line no-restricted-syntax // eslint-disable-next-line no-restricted-syntax
export const generateMetadata = async (): Promise<Metadata> => ({ export const generateMetadata = async (): Promise<Metadata> => ({
title: "Homarr", title: "Homarr",
@@ -78,15 +80,27 @@ export default async function Layout(props: {
children: React.ReactNode; children: React.ReactNode;
params: Promise<{ locale: SupportedLanguage }>; params: Promise<{ locale: SupportedLanguage }>;
}) { }) {
if (!isLocaleSupported((await props.params).locale)) { const { locale } = await props.params;
if (!isLocaleSupported(locale)) {
notFound(); notFound();
} }
const session = await auth(); const sessionPromise = auth();
const user = session ? await api.user.getById({ userId: session.user.id }).catch(() => null) : null; const userPromise = sessionPromise.then((session) =>
const serverSettings = await getServerSettingsAsync(db); session
const colorScheme = await getCurrentColorSchemeAsync(); ? getRscUserSettingsAsync(session.user.id).catch((error: unknown) => {
const direction = isLocaleRTL((await props.params).locale) ? "rtl" : "ltr"; logger.error(new Error("Failed to load the authenticated user in the root layout", { cause: error }));
return null;
})
: null,
);
const [session, user, serverSettings, colorScheme] = await Promise.all([
sessionPromise,
userPromise,
getRscServerSettingsAsync(),
getCurrentColorSchemeAsync(),
]);
const direction = isLocaleRTL(locale) ? "rtl" : "ltr";
const StackedProvider = composeWrappers([ const StackedProvider = composeWrappers([
(innerProps) => { (innerProps) => {
@@ -125,8 +139,6 @@ export default async function Layout(props: {
(innerProps) => <SpotlightProvider {...innerProps} />, (innerProps) => <SpotlightProvider {...innerProps} />,
]); ]);
const { locale } = await props.params;
return ( return (
// Instead of ColorSchemScript we use data-mantine-color-scheme to prevent flickering // Instead of ColorSchemScript we use data-mantine-color-scheme to prevent flickering
<html <html
@@ -16,7 +16,10 @@ export default async function AppEditPage(props: AppEditPageProps) {
const params = await props.params; const params = await props.params;
const session = await auth(); const session = await auth();
if (!session?.user.permissions.includes("app-modify-all")) { if (
!session?.user.permissions.includes("board-modify-all") ||
!session?.user.permissions.includes("app-modify-all")
) {
notFound(); notFound();
} }
const app = await api.app.byId({ id: params.id }); const app = await api.app.byId({ id: params.id });
@@ -10,7 +10,7 @@ import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
export default async function AppNewPage() { export default async function AppNewPage() {
const session = await auth(); const session = await auth();
if (!session?.user.permissions.includes("app-create")) { if (!session?.user.permissions.includes("board-modify-all") || !session?.user.permissions.includes("app-create")) {
notFound(); notFound();
} }
@@ -1,5 +1,5 @@
import { Fragment } from "react"; import { Fragment } from "react";
import { redirect } from "next/navigation"; import { notFound, redirect } from "next/navigation";
import { ActionIcon, ActionIconGroup, Anchor, Avatar, Card, Group, Stack, Text } from "@mantine/core"; import { ActionIcon, ActionIconGroup, Anchor, Avatar, Card, Group, Stack, Text } from "@mantine/core";
import { IconBox, IconPencil } from "@tabler/icons-react"; import { IconBox, IconPencil } from "@tabler/icons-react";
import { z } from "zod/v4"; import { z } from "zod/v4";
@@ -33,6 +33,9 @@ export default async function AppsPage(props: AppsPageProps) {
if (!session) { if (!session) {
redirect("/auth/login"); redirect("/auth/login");
} }
if (!session.user.permissions.includes("board-modify-all")) {
notFound();
}
const searchParams = searchParamsSchema.parse(await props.searchParams); const searchParams = searchParamsSchema.parse(await props.searchParams);
+33 -11
View File
@@ -27,8 +27,10 @@ import {
IconUsersGroup, IconUsersGroup,
} from "@tabler/icons-react"; } from "@tabler/icons-react";
import { getRscUserSettingsAsync } from "@homarr/api/user-server";
import { auth } from "@homarr/auth/next"; import { auth } from "@homarr/auth/next";
import { isProviderEnabled } from "@homarr/auth/server"; import { isProviderEnabled } from "@homarr/auth/server";
import { createLogger } from "@homarr/core/infrastructure/logs";
import { createDocumentationLink } from "@homarr/definitions"; import { createDocumentationLink } from "@homarr/definitions";
import { dbEnv } from "@homarr/core/infrastructure/db/env"; import { dbEnv } from "@homarr/core/infrastructure/db/env";
import { env } from "@homarr/docker/env"; import { env } from "@homarr/docker/env";
@@ -39,11 +41,29 @@ import { homarrLogoPath } from "~/components/layout/logo/homarr-logo";
import type { NavigationLink } from "~/components/layout/navigation"; import type { NavigationLink } from "~/components/layout/navigation";
import { MainNavigation } from "~/components/layout/navigation"; import { MainNavigation } from "~/components/layout/navigation";
import { ClientShell } from "~/components/layout/shell"; import { ClientShell } from "~/components/layout/shell";
import { ManageTourProvider } from "~/components/onboarding/manage-tour"; import { ManageTourGate } from "~/components/onboarding/manage-tour-gate";
import { env as nextEnv } from "~/env";
const logger = createLogger({ module: "manageLayout" });
export default async function ManageLayout({ children }: PropsWithChildren) { export default async function ManageLayout({ children }: PropsWithChildren) {
const t = await getScopedI18n("management.navbar"); const sessionPromise = auth();
const session = await auth(); const shouldRunManageTourPromise = sessionPromise.then(async (session) => {
if (!session || nextEnv.DEMO_MODE) return false;
try {
const user = await getRscUserSettingsAsync(session.user.id);
return user !== undefined && !user.completedManageTour;
} catch (error) {
logger.error(new Error("Failed to load the management tour status", { cause: error }));
return false;
}
});
const [t, session, shouldRunManageTour] = await Promise.all([
getScopedI18n("management.navbar"),
sessionPromise,
shouldRunManageTourPromise,
]);
const navigationLinks: NavigationLink[] = [ const navigationLinks: NavigationLink[] = [
{ {
label: t("items.home"), label: t("items.home"),
@@ -61,7 +81,7 @@ export default async function ManageLayout({ children }: PropsWithChildren) {
icon: IconBox, icon: IconBox,
href: "/manage/apps", href: "/manage/apps",
label: t("items.apps"), label: t("items.apps"),
hidden: !session, hidden: !session?.user.permissions.includes("app-create"),
iconProps: { iconProps: {
strokeWidth: 2.5, strokeWidth: 2.5,
}, },
@@ -71,20 +91,20 @@ export default async function ManageLayout({ children }: PropsWithChildren) {
icon: IconAffiliateFilled, icon: IconAffiliateFilled,
href: "/manage/integrations", href: "/manage/integrations",
label: t("items.integrations"), label: t("items.integrations"),
hidden: !session, hidden: !session?.user.permissions.includes("integration-create"),
"data-onboarding-tour-id": "manage-integrations", "data-onboarding-tour-id": "manage-integrations",
}, },
{ {
icon: IconApi, icon: IconApi,
href: "/manage/custom-widgets", href: "/manage/custom-widgets",
label: t("items.customWidgets"), label: t("items.customWidgets"),
hidden: !session, hidden: !session?.user.permissions.includes("admin"),
}, },
{ {
icon: IconSearch, icon: IconSearch,
href: "/manage/search-engines", href: "/manage/search-engines",
label: t("items.searchEngies"), label: t("items.searchEngies"),
hidden: !session, hidden: !session?.user.permissions.includes("search-engine-create"),
iconProps: { iconProps: {
strokeWidth: 2.5, strokeWidth: 2.5,
}, },
@@ -94,7 +114,7 @@ export default async function ManageLayout({ children }: PropsWithChildren) {
icon: IconPhotoFilled, icon: IconPhotoFilled,
href: "/manage/medias", href: "/manage/medias",
label: t("items.medias"), label: t("items.medias"),
hidden: !session, hidden: !session?.user.permissions.includes("media-upload"),
"data-onboarding-tour-id": "manage-medias", "data-onboarding-tour-id": "manage-medias",
}, },
{ {
@@ -225,7 +245,9 @@ export default async function ManageLayout({ children }: PropsWithChildren) {
</ClientShell> </ClientShell>
); );
if (!session) return shell; return (
<ManageTourGate enabled={shouldRunManageTour} isAdmin={isAdmin}>
return <ManageTourProvider isAdmin={isAdmin}>{shell}</ManageTourProvider>; {shell}
</ManageTourGate>
);
} }
@@ -193,6 +193,10 @@ export function DockerTable({ initialData }: DockerTableProps) {
positionToolbarAlertBanner: "top", positionToolbarAlertBanner: "top",
enableTableFooter: false, enableTableFooter: false,
enableBottomToolbar: false, enableBottomToolbar: false,
// MRT's built-in full-screen mode does not stretch the table to fill the
// viewport, so with few containers it renders as a thin strip (#6230). Disable
// it, matching the other management tables (users, invites).
enableFullScreenToggle: false,
positionGlobalFilter: "right", positionGlobalFilter: "right",
mantineSearchTextInputProps: { mantineSearchTextInputProps: {
placeholder: tDocker("table.search", { count: String(containers.length) }), placeholder: tDocker("table.search", { count: String(containers.length) }),
@@ -31,6 +31,7 @@ import {
userChangeHomeBoardsSchema, userChangeHomeBoardsSchema,
userChangeSearchPreferencesSchema, userChangeSearchPreferencesSchema,
userEditProfileSchema, userEditProfileSchema,
userEnableRightClickOnWidgetsSchema,
userFirstDayOfWeekSchema, userFirstDayOfWeekSchema,
userPingIconsEnabledSchema, userPingIconsEnabledSchema,
} from "@homarr/validation/user"; } from "@homarr/validation/user";
@@ -51,6 +52,7 @@ const userGeneralSettingsSchema = z.object({
ddgBangsEnabled: userChangeSearchPreferencesSchema.shape.ddgBangsEnabled, ddgBangsEnabled: userChangeSearchPreferencesSchema.shape.ddgBangsEnabled,
firstDayOfWeek: userFirstDayOfWeekSchema.shape.firstDayOfWeek, firstDayOfWeek: userFirstDayOfWeekSchema.shape.firstDayOfWeek,
pingIconsEnabled: userPingIconsEnabledSchema.shape.pingIconsEnabled, pingIconsEnabled: userPingIconsEnabledSchema.shape.pingIconsEnabled,
enableRightClickOnWidgets: userEnableRightClickOnWidgetsSchema.shape.enableRightClickOnWidgets,
}); });
type FormValues = z.infer<typeof userGeneralSettingsSchema>; type FormValues = z.infer<typeof userGeneralSettingsSchema>;
@@ -78,6 +80,7 @@ const buildInitialValues = (user: RouterOutputs["user"]["getById"]): FormValues
ddgBangsEnabled: user.ddgBangs, ddgBangsEnabled: user.ddgBangs,
firstDayOfWeek: user.firstDayOfWeek as DayOfWeek, firstDayOfWeek: user.firstDayOfWeek as DayOfWeek,
pingIconsEnabled: user.pingIconsEnabled, pingIconsEnabled: user.pingIconsEnabled,
enableRightClickOnWidgets: user.enableRightClickOnWidgets,
}); });
export const UserGeneralSettingsForm = ({ export const UserGeneralSettingsForm = ({
@@ -95,6 +98,7 @@ export const UserGeneralSettingsForm = ({
const changeSearchPreferencesMutation = clientApi.user.changeSearchPreferences.useMutation(); const changeSearchPreferencesMutation = clientApi.user.changeSearchPreferences.useMutation();
const changeFirstDayOfWeekMutation = clientApi.user.changeFirstDayOfWeek.useMutation(); const changeFirstDayOfWeekMutation = clientApi.user.changeFirstDayOfWeek.useMutation();
const changePingIconsEnabledMutation = clientApi.user.changePingIconsEnabled.useMutation(); const changePingIconsEnabledMutation = clientApi.user.changePingIconsEnabled.useMutation();
const changeEnableRightClickOnWidgetsMutation = clientApi.user.changeEnableRightClickOnWidgets.useMutation();
const initialValues = buildInitialValues(user); const initialValues = buildInitialValues(user);
const initialValuesRef = useRef(initialValues); const initialValuesRef = useRef(initialValues);
@@ -114,6 +118,7 @@ export const UserGeneralSettingsForm = ({
changeSearchPreferencesMutation, changeSearchPreferencesMutation,
changeFirstDayOfWeekMutation, changeFirstDayOfWeekMutation,
changePingIconsEnabledMutation, changePingIconsEnabledMutation,
changeEnableRightClickOnWidgetsMutation,
]; ];
const isPending = mutations.some((m) => m.isPending); const isPending = mutations.some((m) => m.isPending);
@@ -169,6 +174,14 @@ export const UserGeneralSettingsForm = ({
action: () => action: () =>
changePingIconsEnabledMutation.mutateAsync({ id: user.id, pingIconsEnabled: parsed.data.pingIconsEnabled }), changePingIconsEnabledMutation.mutateAsync({ id: user.id, pingIconsEnabled: parsed.data.pingIconsEnabled }),
}, },
{
when: changed("enableRightClickOnWidgets"),
action: () =>
changeEnableRightClickOnWidgetsMutation.mutateAsync({
id: user.id,
enableRightClickOnWidgets: parsed.data.enableRightClickOnWidgets,
}),
},
]; ];
const promises = saveActions.filter((s) => s.when).map((s) => s.action()); const promises = saveActions.filter((s) => s.when).map((s) => s.action());
@@ -305,6 +318,11 @@ export const UserGeneralSettingsForm = ({
label={t("user.field.pingIconsEnabled.label")} label={t("user.field.pingIconsEnabled.label")}
{...form.getInputProps("pingIconsEnabled", { type: "checkbox" })} {...form.getInputProps("pingIconsEnabled", { type: "checkbox" })}
/> />
<Switch
label={t("user.field.enableRightClickOnWidgets.label")}
description={t("user.field.enableRightClickOnWidgets.description")}
{...form.getInputProps("enableRightClickOnWidgets", { type: "checkbox" })}
/>
</Stack> </Stack>
</Card> </Card>
</SimpleGrid> </SimpleGrid>
@@ -1,5 +1,6 @@
"use client"; "use client";
import { useRouter } from "next/navigation";
import { Badge, Button, Group, Stack, Text } from "@mantine/core"; import { Badge, Button, Group, Stack, Text } from "@mantine/core";
import { IconRefresh } from "@tabler/icons-react"; import { IconRefresh } from "@tabler/icons-react";
@@ -9,6 +10,7 @@ import { useScopedI18n } from "@homarr/translation/client";
export const ResetTours = () => { export const ResetTours = () => {
const t = useScopedI18n("management.page.user.setting.general.item.onboardingTours"); const t = useScopedI18n("management.page.user.setting.general.item.onboardingTours");
const router = useRouter();
const utils = clientApi.useUtils(); const utils = clientApi.useUtils();
const { data: tourStatus } = clientApi.user.getTourStatus.useQuery(); const { data: tourStatus } = clientApi.user.getTourStatus.useQuery();
const { mutate: resetTours, isPending } = clientApi.user.resetTours.useMutation({ const { mutate: resetTours, isPending } = clientApi.user.resetTours.useMutation({
@@ -18,6 +20,7 @@ export const ResetTours = () => {
message: t("resetSuccess.message"), message: t("resetSuccess.message"),
}); });
void utils.user.getTourStatus.invalidate(); void utils.user.getTourStatus.invalidate();
router.refresh();
}, },
}); });
@@ -1,41 +1,38 @@
"use client"; "use client";
import { useCallback, useMemo, useState } from "react"; import { use, useCallback, useState } from "react";
import { ActionIcon, Affix, Card } from "@mantine/core"; import { ActionIcon, Affix, Card } from "@mantine/core";
import { IconDimensions, IconPencil, IconToggleLeft, IconToggleRight } from "@tabler/icons-react"; import { IconDimensions, IconPencil, IconToggleLeft, IconToggleRight } from "@tabler/icons-react";
import { QueryErrorResetBoundary } from "@tanstack/react-query"; import { QueryErrorResetBoundary } from "@tanstack/react-query";
import { ErrorBoundary } from "react-error-boundary"; import { ErrorBoundary } from "react-error-boundary";
import type { IntegrationKind, WidgetKind } from "@homarr/definitions"; import { clientApi } from "@homarr/api/client";
import type { WidgetKind } from "@homarr/definitions";
import { useModalAction } from "@homarr/modals"; import { useModalAction } from "@homarr/modals";
import { showSuccessNotification } from "@homarr/notifications"; import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useSettings } from "@homarr/settings"; import { useSettings } from "@homarr/settings";
import { useI18n } from "@homarr/translation/client"; import { useI18n } from "@homarr/translation/client";
import type { BoardItemAdvancedOptions } from "@homarr/validation/shared"; import type { BoardItemAdvancedOptions } from "@homarr/validation/shared";
import { loadWidgetDynamic, reduceWidgetOptionsWithDefaultValues, widgetImports } from "@homarr/widgets";
import { WidgetError } from "@homarr/widgets/errors"; import { WidgetError } from "@homarr/widgets/errors";
import { WidgetEditModal } from "@homarr/widgets/modals"; import { loadWidgetResources, reduceWidgetOptionsWithDefinition } from "@homarr/widgets/manifest";
import { LazyWidgetEditModal, preloadWidgetEditModal } from "~/components/board/items/lazy-widget-edit-modal";
import type { Dimensions } from "./_dimension-modal"; import type { Dimensions } from "./_dimension-modal";
import { PreviewDimensionsModal } from "./_dimension-modal"; import { PreviewDimensionsModal } from "./_dimension-modal";
interface WidgetPreviewPageContentProps { interface WidgetPreviewPageContentProps {
kind: WidgetKind; kind: WidgetKind;
integrationData: {
id: string;
name: string;
url: string;
kind: IntegrationKind;
}[];
} }
export const WidgetPreviewPageContent = ({ kind, integrationData }: WidgetPreviewPageContentProps) => { export const WidgetPreviewPageContent = ({ kind }: WidgetPreviewPageContentProps) => {
const settings = useSettings(); const settings = useSettings();
const t = useI18n(); const t = useI18n();
const { openModal: openWidgetEditModal } = useModalAction(WidgetEditModal); const utils = clientApi.useUtils();
const { openModal: openWidgetEditModal } = useModalAction(LazyWidgetEditModal);
const { openModal: openPreviewDimensionsModal } = useModalAction(PreviewDimensionsModal); const { openModal: openPreviewDimensionsModal } = useModalAction(PreviewDimensionsModal);
const currentDefinition = useMemo(() => widgetImports[kind].definition, [kind]); const { definition: currentDefinition, Component } = use(loadWidgetResources(kind));
const [editMode, setEditMode] = useState(false); const [editMode, setEditMode] = useState(false);
const [isEditorLoading, setIsEditorLoading] = useState(false);
const [dimensions, setDimensions] = useState<Dimensions>({ const [dimensions, setDimensions] = useState<Dimensions>({
width: 128, width: 128,
height: 128, height: 128,
@@ -45,7 +42,7 @@ export const WidgetPreviewPageContent = ({ kind, integrationData }: WidgetPrevie
integrationIds: string[]; integrationIds: string[];
advancedOptions: BoardItemAdvancedOptions; advancedOptions: BoardItemAdvancedOptions;
}>({ }>({
options: reduceWidgetOptionsWithDefaultValues(kind, settings, {}), options: reduceWidgetOptionsWithDefinition(currentDefinition, settings, {}),
integrationIds: [], integrationIds: [],
advancedOptions: { advancedOptions: {
title: null, title: null,
@@ -54,31 +51,41 @@ export const WidgetPreviewPageContent = ({ kind, integrationData }: WidgetPrevie
}, },
}); });
const handleOpenEditWidgetModal = useCallback(() => { const handleOpenEditWidgetModal = useCallback(async () => {
openWidgetEditModal( const hasIntegrationSupport = "supportedIntegrations" in currentDefinition;
{ setIsEditorLoading(true);
kind, preloadWidgetEditModal();
value: state, try {
onSuccessfulEdit: (value) => { const integrationData = hasIntegrationSupport ? await utils.integration.all.ensureData() : [];
setState(value); openWidgetEditModal(
{
kind,
definition: currentDefinition,
value: state,
onSuccessfulEdit: (value) => {
setState(value);
},
integrationData: integrationData.filter((integration) =>
(currentDefinition.supportedIntegrations ?? []).includes(integration.kind),
),
integrationSupport: hasIntegrationSupport,
settings,
}, },
integrationData: integrationData.filter( {
(integration) => title(translate) {
"supportedIntegrations" in currentDefinition && return `${translate("item.edit.title")} - ${translate(`widget.${kind}.name`)}`;
(currentDefinition.supportedIntegrations as string[]).some((kind) => kind === integration.kind), },
),
integrationSupport: "supportedIntegrations" in currentDefinition,
settings,
},
{
title(t) {
return `${t("item.edit.title")} - ${t(`widget.${kind}.name`)}`;
}, },
}, );
); } catch (error) {
}, [currentDefinition, integrationData, kind, openWidgetEditModal, settings, state]); showErrorNotification({
title: t("common.error"),
const Comp = loadWidgetDynamic(kind); message: error instanceof Error ? error.message : String(error),
});
} finally {
setIsEditorLoading(false);
}
}, [currentDefinition, kind, openWidgetEditModal, settings, state, t, utils]);
const toggleEditMode = useCallback(() => { const toggleEditMode = useCallback(() => {
setEditMode((editMode) => !editMode); setEditMode((editMode) => !editMode);
@@ -94,8 +101,11 @@ export const WidgetPreviewPageContent = ({ kind, integrationData }: WidgetPrevie
}); });
}, [dimensions, openPreviewDimensionsModal]); }, [dimensions, openPreviewDimensionsModal]);
const updateOptions = ({ newOptions }: { newOptions: Record<string, unknown> }) => const updateOptions = useCallback(
setState({ ...state, options: { ...state.options, newOptions } }); ({ newOptions }: { newOptions: Record<string, unknown> }) =>
setState((current) => ({ ...current, options: { ...current.options, ...newOptions } })),
[],
);
return ( return (
<> <>
@@ -105,10 +115,10 @@ export const WidgetPreviewPageContent = ({ kind, integrationData }: WidgetPrevie
<ErrorBoundary <ErrorBoundary
onReset={reset} onReset={reset}
fallbackRender={({ resetErrorBoundary, error }) => ( fallbackRender={({ resetErrorBoundary, error }) => (
<WidgetError kind={kind} error={error} resetErrorBoundary={resetErrorBoundary} /> <WidgetError definition={currentDefinition} error={error} resetErrorBoundary={resetErrorBoundary} />
)} )}
> >
<Comp <Component
options={state.options as never} options={state.options as never}
integrationIds={state.integrationIds} integrationIds={state.integrationIds}
width={dimensions.width} width={dimensions.width}
@@ -127,7 +137,10 @@ export const WidgetPreviewPageContent = ({ kind, integrationData }: WidgetPrevie
size={48} size={48}
variant="default" variant="default"
radius="xl" radius="xl"
onClick={handleOpenEditWidgetModal} onClick={() => void handleOpenEditWidgetModal()}
onFocus={preloadWidgetEditModal}
onPointerEnter={preloadWidgetEditModal}
loading={isEditorLoading}
aria-label={t("common.action.edit")} aria-label={t("common.action.edit")}
> >
<IconPencil size={24} /> <IconPencil size={24} />
@@ -1,23 +1,18 @@
import type { PropsWithChildren } from "react"; import type { PropsWithChildren } from "react";
import { widgetImports } from "@homarr/widgets"; import { widgetKinds } from "@homarr/definitions";
import { widgetCatalogIcons } from "@homarr/widgets/catalog";
import { MainNavigation } from "~/components/layout/navigation"; import { MainNavigation } from "~/components/layout/navigation";
import { ClientShell } from "~/components/layout/shell"; import { ClientShell } from "~/components/layout/shell";
const getLinks = () => { const links = widgetKinds.map((kind) => ({
return Object.entries(widgetImports).map(([key, value]) => { href: `/widgets/${kind}`,
return { icon: widgetCatalogIcons[kind],
href: `/widgets/${key}`, label: kind,
icon: value.definition.icon, }));
label: value.definition.kind,
};
});
};
export default function WidgetPreviewLayout({ children }: PropsWithChildren) { export default function WidgetPreviewLayout({ children }: PropsWithChildren) {
const links = getLinks();
return ( return (
<ClientShell hasHeader={false}> <ClientShell hasHeader={false}>
<MainNavigation links={links} /> <MainNavigation links={links} />
@@ -1,10 +1,9 @@
import { Suspense } from "react";
import { notFound } from "next/navigation"; import { notFound } from "next/navigation";
import { Center } from "@mantine/core"; import { Center, Loader } from "@mantine/core";
import { env } from "@homarr/common/env";
import { db } from "@homarr/db";
import type { WidgetKind } from "@homarr/definitions"; import type { WidgetKind } from "@homarr/definitions";
import { widgetImports } from "@homarr/widgets"; import { widgetKinds } from "@homarr/widgets/manifest";
import { WidgetPreviewPageContent } from "./_content"; import { WidgetPreviewPageContent } from "./_content";
@@ -13,24 +12,16 @@ interface Props {
} }
export default async function WidgetPreview(props: Props) { export default async function WidgetPreview(props: Props) {
if (!((await props.params).kind in widgetImports || env.NODE_ENV !== "development")) { const { kind } = await props.params;
if (!widgetKinds.includes(kind as WidgetKind)) {
notFound(); notFound();
} }
const integrationData = await db.query.integrations.findMany({
columns: {
id: true,
name: true,
url: true,
kind: true,
},
});
const sort = (await props.params).kind as WidgetKind;
return ( return (
<Center h="100vh"> <Center h="100vh">
<WidgetPreviewPageContent kind={sort} integrationData={integrationData} /> <Suspense fallback={<Loader size="sm" />}>
<WidgetPreviewPageContent key={kind} kind={kind as WidgetKind} />
</Suspense>
</Center> </Center>
); );
} }
@@ -1,19 +1,25 @@
import { notFound } from "next/navigation";
import { ImageProxy } from "@homarr/image-proxy"; import { ImageProxy } from "@homarr/image-proxy";
export const GET = async (_request: Request, props: { params: Promise<{ id: string }> }) => { export const GET = async (_request: Request, props: { params: Promise<{ id: string }> }) => {
const { id } = await props.params; const { id } = await props.params;
const imageProxy = new ImageProxy(); const imageProxy = new ImageProxy();
const image = await imageProxy.forwardImageAsync(id); const result = await imageProxy.forwardImageAsync(id);
if (!image) {
notFound(); if ("error" in result) {
const status = result.error === "upstream-error" ? result.statusCode : result.error === "fetch-error" ? 502 : 404;
return new Response(null, {
status,
headers: {
"X-Homarr-Image-Proxy-Error": result.error,
},
});
} }
return new Response(image, { return new Response(result.image, {
headers: { headers: {
"Cache-Control": "public, max-age=3600, immutable", // Cache for 1 hour "Cache-Control": "public, max-age=3600, immutable", // Cache for 1 hour
...(result.contentType ? { "Content-Type": result.contentType } : {}),
}, },
}); });
}; };
@@ -1,17 +1,21 @@
import type { MutableRefObject } from "react"; import type { ComponentType, MutableRefObject } from "react";
import { useRef } from "react"; import { Suspense, use, useRef } from "react";
import { Badge, Card } from "@mantine/core"; import type { CardProps } from "@mantine/core";
import { Badge, Card, Center, Loader } from "@mantine/core";
import { useElementSize } from "@mantine/hooks"; import { useElementSize } from "@mantine/hooks";
import { QueryErrorResetBoundary, useQueryClient } from "@tanstack/react-query"; import { QueryErrorResetBoundary, useQueryClient } from "@tanstack/react-query";
import combineClasses from "clsx"; import combineClasses from "clsx";
import { NoIntegrationSelectedError } from "@homarr/widgets/errors/classes"; import { NoIntegrationSelectedError } from "@homarr/widgets/errors/classes";
import type { FallbackProps } from "react-error-boundary";
import { ErrorBoundary } from "react-error-boundary"; import { ErrorBoundary } from "react-error-boundary";
import { useRequiredBoard } from "@homarr/boards/context"; import { useRequiredBoard } from "@homarr/boards/context";
import { useEditMode } from "@homarr/boards/edit-mode"; import { useEditMode } from "@homarr/boards/edit-mode";
import { useSettings } from "@homarr/settings"; import { useSettings } from "@homarr/settings";
import { loadWidgetDynamic, reduceWidgetOptionsWithDefaultValues, widgetImports } from "@homarr/widgets";
import { WidgetError } from "@homarr/widgets/errors"; import { WidgetError } from "@homarr/widgets/errors";
import { getWidgetQueryKeys } from "@homarr/widgets/definition";
import type { WidgetComponentProps, WidgetDefinition } from "@homarr/widgets/definition";
import { loadWidgetResources, reduceWidgetOptionsWithDefinition } from "@homarr/widgets/manifest";
import type { SectionItem } from "~/app/[locale]/boards/_types"; import type { SectionItem } from "~/app/[locale]/boards/_types";
import classes from "../sections/item.module.css"; import classes from "../sections/item.module.css";
@@ -19,6 +23,7 @@ import { useItemActions } from "./item-actions";
import itemContentClasses from "./item-content.module.css"; import itemContentClasses from "./item-content.module.css";
import { BoardItemMenu } from "./item-menu"; import { BoardItemMenu } from "./item-menu";
import { WidgetContextMenu } from "./widget-context-menu"; import { WidgetContextMenu } from "./widget-context-menu";
import { removeWidgetDataQueries } from "./widget-query-recovery";
interface BoardItemContentProps { interface BoardItemContentProps {
item: SectionItem; item: SectionItem;
@@ -30,6 +35,53 @@ const getOverflowFromKind = (kind: SectionItem["kind"]) => {
return undefined; return undefined;
}; };
type BoardItemCardProps = CardProps & {
item: SectionItem;
innerRef: (element: HTMLDivElement | null) => void;
};
const BoardItemCard = ({ item, innerRef, children, ...cardProps }: BoardItemCardProps) => {
const board = useRequiredBoard();
return (
<Card
{...cardProps}
ref={innerRef}
className={combineClasses(
classes.itemCard,
`${item.kind}-wrapper`,
"grid-stack-item-content",
item.advancedOptions.customCssClasses.join(" "),
)}
radius={board.itemRadius}
styles={{
root: {
"--opacity": board.opacity / 100,
containerType: "size",
overflow: getOverflowFromKind(item.kind),
"--border-color": item.advancedOptions.borderColor !== "" ? item.advancedOptions.borderColor : undefined,
},
}}
p={0}
>
{children}
</Card>
);
};
const WidgetDefinitionLoadError = ({
error,
resetErrorBoundary,
item,
innerRef,
}: FallbackProps & Pick<BoardItemCardProps, "item" | "innerRef">) => (
<BoardItemCard item={item} innerRef={innerRef}>
<Center h="100%">
<WidgetError error={error} resetErrorBoundary={resetErrorBoundary} />
</Center>
</BoardItemCard>
);
export const BoardItemContent = ({ item }: BoardItemContentProps) => { export const BoardItemContent = ({ item }: BoardItemContentProps) => {
const { ref, width, height } = useElementSize<HTMLDivElement>(); const { ref, width, height } = useElementSize<HTMLDivElement>();
const board = useRequiredBoard(); const board = useRequiredBoard();
@@ -37,29 +89,28 @@ export const BoardItemContent = ({ item }: BoardItemContentProps) => {
return ( return (
<> <>
<WidgetContextMenu item={item} widgetStateRef={widgetStateRef}> <ErrorBoundary
<Card resetKeys={[item.kind]}
ref={ref} fallbackRender={(fallbackProps) => <WidgetDefinitionLoadError {...fallbackProps} item={item} innerRef={ref} />}
className={combineClasses( >
classes.itemCard, <Suspense
`${item.kind}-wrapper`, fallback={
"grid-stack-item-content", <BoardItemCard item={item} innerRef={ref}>
item.advancedOptions.customCssClasses.join(" "), <Center h="100%">
)} <Loader size="sm" />
radius={board.itemRadius} </Center>
styles={{ </BoardItemCard>
root: { }
"--opacity": board.opacity / 100,
containerType: "size",
overflow: getOverflowFromKind(item.kind),
"--border-color": item.advancedOptions.borderColor !== "" ? item.advancedOptions.borderColor : undefined,
},
}}
p={0}
> >
<InnerContent item={item} width={width} height={height} widgetStateRef={widgetStateRef} /> <LoadedBoardItemContent
</Card> item={item}
</WidgetContextMenu> width={width}
height={height}
widgetStateRef={widgetStateRef}
innerRef={ref}
/>
</Suspense>
</ErrorBoundary>
{item.advancedOptions.title?.trim() && ( {item.advancedOptions.title?.trim() && (
<Badge <Badge
pos="absolute" pos="absolute"
@@ -85,40 +136,72 @@ export const BoardItemContent = ({ item }: BoardItemContentProps) => {
); );
}; };
interface LoadedBoardItemContentProps extends Omit<InnerContentProps, "definition" | "Component"> {
innerRef: (element: HTMLDivElement | null) => void;
}
const LoadedBoardItemContent = ({ item, innerRef, ...innerContentProps }: LoadedBoardItemContentProps) => {
const { definition, Component } = use(loadWidgetResources(item.kind));
return (
<WidgetContextMenu item={item} definition={definition} widgetStateRef={innerContentProps.widgetStateRef}>
<BoardItemCard item={item} innerRef={innerRef}>
<ErrorBoundary
resetKeys={[item.kind]}
fallbackRender={({ resetErrorBoundary, error }) => (
<>
<BoardItemMenu offset={4} item={item} definition={definition} resetErrorBoundary={resetErrorBoundary} />
<WidgetError definition={definition} error={error} resetErrorBoundary={resetErrorBoundary} />
</>
)}
>
<InnerContent item={item} definition={definition} Component={Component} {...innerContentProps} />
</ErrorBoundary>
</BoardItemCard>
</WidgetContextMenu>
);
};
interface InnerContentProps { interface InnerContentProps {
item: SectionItem; item: SectionItem;
width: number; width: number;
height: number; height: number;
widgetStateRef: MutableRefObject<Record<string, unknown> | null>; widgetStateRef: MutableRefObject<Record<string, unknown> | null>;
definition: WidgetDefinition;
Component: ComponentType<WidgetComponentProps<SectionItem["kind"]>>;
} }
const InnerContent = ({ item, ...dimensions }: InnerContentProps) => { const InnerContent = ({ item, definition, Component, ...dimensions }: InnerContentProps) => {
const settings = useSettings(); const settings = useSettings();
const board = useRequiredBoard(); const board = useRequiredBoard();
const [isEditMode] = useEditMode(); const [isEditMode] = useEditMode();
const Comp = loadWidgetDynamic(item.kind); const options = reduceWidgetOptionsWithDefinition(definition, settings, item.options);
const { definition } = widgetImports[item.kind];
const options = reduceWidgetOptionsWithDefaultValues(item.kind, settings, item.options);
const newItem = { ...item, options }; const newItem = { ...item, options };
const { updateItemOptions } = useItemActions(); const { updateItemOptions } = useItemActions();
const updateOptions = ({ newOptions }: { newOptions: Record<string, unknown> }) => const updateOptions = ({ newOptions }: { newOptions: Record<string, unknown> }) =>
updateItemOptions({ itemId: item.id, newOptions }); updateItemOptions({ itemId: item.id, newOptions });
const widgetSupportsIntegrations = const widgetSupportsIntegrations =
"supportedIntegrations" in definition && definition.supportedIntegrations.length >= 1; "supportedIntegrations" in definition && (definition.supportedIntegrations?.length ?? 0) >= 1;
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const widgetQueryKeys = getWidgetQueryKeys(definition, item.kind);
return ( return (
<QueryErrorResetBoundary> <QueryErrorResetBoundary>
{({ reset }) => ( {({ reset }) => (
<ErrorBoundary <ErrorBoundary
onReset={() => { onReset={() => {
void queryClient.invalidateQueries({ queryKey: [["widget"]] }); removeWidgetDataQueries(queryClient, widgetQueryKeys);
reset(); reset();
}} }}
fallbackRender={({ resetErrorBoundary, error }) => ( fallbackRender={({ resetErrorBoundary, error }) => (
<> <>
<BoardItemMenu offset={4} item={newItem} resetErrorBoundary={resetErrorBoundary} /> <BoardItemMenu
<WidgetError kind={item.kind} error={error} resetErrorBoundary={resetErrorBoundary} /> offset={4}
item={newItem}
definition={definition}
resetErrorBoundary={resetErrorBoundary}
/>
<WidgetError definition={definition} error={error} resetErrorBoundary={resetErrorBoundary} />
</> </>
)} )}
> >
@@ -130,23 +213,25 @@ const InnerContent = ({ item, ...dimensions }: InnerContentProps) => {
(!("integrationsRequired" in definition) || definition.integrationsRequired !== false) (!("integrationsRequired" in definition) || definition.integrationsRequired !== false)
} }
/> />
<BoardItemMenu offset={4} item={newItem} /> <BoardItemMenu offset={4} item={newItem} definition={definition} />
<Comp <div data-homarr-widget-ready={item.kind} style={{ display: "contents" }}>
options={options as never} <Component
integrationIds={item.integrationIds} options={options as never}
isEditMode={isEditMode} integrationIds={item.integrationIds}
boardId={board.id} isEditMode={isEditMode}
itemId={item.id} boardId={board.id}
setOptions={(partialNewOptions) => itemId={item.id}
updateOptions({ setOptions={(partialNewOptions) =>
newOptions: { updateOptions({
...options, newOptions: {
...partialNewOptions.newOptions, ...options,
}, ...partialNewOptions.newOptions,
}) },
} })
{...dimensions} }
/> {...dimensions}
/>
</div>
</ErrorBoundary> </ErrorBoundary>
)} )}
</QueryErrorResetBoundary> </QueryErrorResetBoundary>
@@ -1,4 +1,4 @@
import { useEffect, useMemo, useRef } from "react"; import { useEffect, useRef, useState } from "react";
import { ActionIcon, Menu } from "@mantine/core"; import { ActionIcon, Menu } from "@mantine/core";
import { IconCopy, IconDotsVertical, IconLayoutKanban, IconPencil, IconTrash } from "@tabler/icons-react"; import { IconCopy, IconDotsVertical, IconLayoutKanban, IconPencil, IconTrash } from "@tabler/icons-react";
@@ -8,39 +8,44 @@ import { useEditMode } from "@homarr/boards/edit-mode";
import { useConfirmModal, useModalAction } from "@homarr/modals"; import { useConfirmModal, useModalAction } from "@homarr/modals";
import { useSettings } from "@homarr/settings"; import { useSettings } from "@homarr/settings";
import { useI18n, useScopedI18n } from "@homarr/translation/client"; import { useI18n, useScopedI18n } from "@homarr/translation/client";
import { widgetImports } from "@homarr/widgets"; import type { WidgetDefinition } from "@homarr/widgets/definition";
import { WidgetEditModal } from "@homarr/widgets/modals";
import type { SectionItem } from "~/app/[locale]/boards/_types"; import type { SectionItem } from "~/app/[locale]/boards/_types";
import { useSectionContext } from "../sections/section-context"; import { useSectionContext } from "../sections/section-context";
import { useItemActions } from "./item-actions"; import { useItemActions } from "./item-actions";
import { ItemMoveModal } from "./item-move-modal"; import { LazyItemMoveModal, preloadItemMoveModal } from "./lazy-item-move-modal";
import { LazyWidgetEditModal, preloadWidgetEditModal } from "./lazy-widget-edit-modal";
interface BoardItemMenuProps { interface BoardItemMenuProps {
offset: number; offset: number;
item: SectionItem; item: SectionItem;
definition: WidgetDefinition;
resetErrorBoundary?: () => void; resetErrorBoundary?: () => void;
} }
export const BoardItemMenu = (props: BoardItemMenuProps) => { export const BoardItemMenu = (props: BoardItemMenuProps) => {
const { data: session } = useSession(); const { data: session } = useSession();
if (!session) return null; const [isEditMode] = useEditMode();
if (!session || !isEditMode) return null;
return <BoardItemMenuInner {...props} />; return <BoardItemMenuInner {...props} />;
}; };
const BoardItemMenuInner = ({ offset, item, resetErrorBoundary }: BoardItemMenuProps) => { const BoardItemMenuInner = ({ offset, item, definition, resetErrorBoundary }: BoardItemMenuProps) => {
const refResetErrorBoundaryOnNextRender = useRef(false); const refResetErrorBoundaryOnNextRender = useRef(false);
const tItem = useScopedI18n("item"); const tItem = useScopedI18n("item");
const t = useI18n(); const t = useI18n();
const { openModal } = useModalAction(WidgetEditModal); const { openModal } = useModalAction(LazyWidgetEditModal);
const { openModal: openMoveModal } = useModalAction(ItemMoveModal); const { openModal: openMoveModal } = useModalAction(LazyItemMoveModal);
const { openConfirmModal } = useConfirmModal(); const { openConfirmModal } = useConfirmModal();
const [isEditMode] = useEditMode(); const [isMenuOpen, setIsMenuOpen] = useState(false);
const hasSupportedIntegrations =
"supportedIntegrations" in definition && (definition.supportedIntegrations?.length ?? 0) > 0;
const { updateItemOptions, updateItemAdvancedOptions, updateItemIntegrations, duplicateItem, removeItem } = const { updateItemOptions, updateItemAdvancedOptions, updateItemIntegrations, duplicateItem, removeItem } =
useItemActions(); useItemActions();
const { data: integrationData, isPending } = clientApi.integration.all.useQuery(); const { data: integrationData, isPending } = clientApi.integration.all.useQuery(undefined, {
const currentDefinition = useMemo(() => widgetImports[item.kind].definition, [item.kind]); enabled: isMenuOpen && hasSupportedIntegrations,
});
const { gridstack } = useSectionContext().refs; const { gridstack } = useSectionContext().refs;
const settings = useSettings(); const settings = useSettings();
@@ -52,12 +57,11 @@ const BoardItemMenuInner = ({ offset, item, resetErrorBoundary }: BoardItemMenuP
} }
}, [item, resetErrorBoundary]); }, [item, resetErrorBoundary]);
if (!isEditMode || isPending) return null;
const openEditModal = () => { const openEditModal = () => {
openModal( openModal(
{ {
kind: item.kind, kind: item.kind,
definition,
value: { value: {
advancedOptions: item.advancedOptions, advancedOptions: item.advancedOptions,
options: item.options, options: item.options,
@@ -80,10 +84,10 @@ const BoardItemMenuInner = ({ offset, item, resetErrorBoundary }: BoardItemMenuP
}, },
integrationData: (integrationData ?? []).filter( integrationData: (integrationData ?? []).filter(
(integration) => (integration) =>
"supportedIntegrations" in currentDefinition && "supportedIntegrations" in definition &&
(currentDefinition.supportedIntegrations as string[]).some((kind) => kind === integration.kind), (definition.supportedIntegrations as string[]).some((kind) => kind === integration.kind),
), ),
integrationSupport: "supportedIntegrations" in currentDefinition, integrationSupport: "supportedIntegrations" in definition,
settings, settings,
itemId: item.id, itemId: item.id,
appId: item.kind === "app" ? (item.options.appId as string | undefined) : undefined, appId: item.kind === "app" ? (item.options.appId as string | undefined) : undefined,
@@ -107,7 +111,7 @@ const BoardItemMenuInner = ({ offset, item, resetErrorBoundary }: BoardItemMenuP
}; };
return ( return (
<Menu withinPortal position="right-start" arrowPosition="center"> <Menu withinPortal position="right-start" arrowPosition="center" opened={isMenuOpen} onChange={setIsMenuOpen}>
<Menu.Target> <Menu.Target>
<ActionIcon <ActionIcon
variant="default" variant="default"
@@ -123,11 +127,19 @@ const BoardItemMenuInner = ({ offset, item, resetErrorBoundary }: BoardItemMenuP
</Menu.Target> </Menu.Target>
<Menu.Dropdown miw={128}> <Menu.Dropdown miw={128}>
<Menu.Label>{tItem("menu.label.settings")}</Menu.Label> <Menu.Label>{tItem("menu.label.settings")}</Menu.Label>
<Menu.Item leftSection={<IconPencil size={16} />} onClick={openEditModal}> <Menu.Item
leftSection={<IconPencil size={16} />}
onClick={openEditModal}
onFocus={preloadWidgetEditModal}
onPointerEnter={preloadWidgetEditModal}
disabled={hasSupportedIntegrations && isPending}
>
{tItem("action.edit")} {tItem("action.edit")}
</Menu.Item> </Menu.Item>
<Menu.Item <Menu.Item
leftSection={<IconLayoutKanban size={16} />} leftSection={<IconLayoutKanban size={16} />}
onFocus={preloadItemMoveModal}
onPointerEnter={preloadItemMoveModal}
onClick={() => { onClick={() => {
if (!gridstack.current) return; if (!gridstack.current) return;
openMoveModal({ item, columnCount: gridstack.current.getColumn(), gridStack: gridstack.current }); openMoveModal({ item, columnCount: gridstack.current.getColumn(), gridStack: gridstack.current });
@@ -9,13 +9,13 @@ import { useI18n } from "@homarr/translation/client";
import type { Item, SectionItem } from "~/app/[locale]/boards/_types"; import type { Item, SectionItem } from "~/app/[locale]/boards/_types";
interface InnerProps { export interface ItemMoveModalProps {
gridStack: GridStack; gridStack: GridStack;
item: Pick<SectionItem, "id" | "width" | "height" | "xOffset" | "yOffset">; item: Pick<SectionItem, "id" | "width" | "height" | "xOffset" | "yOffset">;
columnCount: number; columnCount: number;
} }
export const ItemMoveModal = createModal<InnerProps>(({ actions, innerProps }) => { export const ItemMoveModal = createModal<ItemMoveModalProps>(({ actions, innerProps }) => {
const t = useI18n(); const t = useI18n();
const maxWidthRef = useRef(innerProps.columnCount - innerProps.item.xOffset); const maxWidthRef = useRef(innerProps.columnCount - innerProps.item.xOffset);
const form = useZodForm( const form = useZodForm(
@@ -0,0 +1,48 @@
import { describe, expect, it, vi } from "vitest";
import { resolveMatchingIntegrationsAsync, tryLockSelection, unlockSelection } from "./item-select-data";
describe("widget selection lock", () => {
it("rejects repeated selection synchronously until the active selection finishes", () => {
const lock = { current: false };
expect(tryLockSelection(lock)).toBe(true);
expect(tryLockSelection(lock)).toBe(false);
unlockSelection(lock);
expect(tryLockSelection(lock)).toBe(true);
});
});
describe("resolveMatchingIntegrationsAsync", () => {
it("waits for the shared integration query before a cold integration-backed selection", async () => {
const ensureDataAsync = vi.fn(async () => [
{ id: "matching", kind: "sonarr" as const },
{ id: "other", kind: "radarr" as const },
]);
await expect(
resolveMatchingIntegrationsAsync({
hasIntegrationSupport: true,
supportedIntegrations: ["sonarr"],
currentData: undefined,
ensureDataAsync,
}),
).resolves.toEqual([{ id: "matching", kind: "sonarr" }]);
expect(ensureDataAsync).toHaveBeenCalledOnce();
});
it("does not wait for integrations when the widget has no integration support", async () => {
const ensureDataAsync = vi.fn(async () => [{ id: "unused", kind: "sonarr" as const }]);
await expect(
resolveMatchingIntegrationsAsync({
hasIntegrationSupport: false,
supportedIntegrations: [],
currentData: undefined,
ensureDataAsync,
}),
).resolves.toEqual([]);
expect(ensureDataAsync).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,28 @@
import type { IntegrationKind } from "@homarr/definitions";
export const tryLockSelection = (lock: { current: boolean }) => {
if (lock.current) return false;
lock.current = true;
return true;
};
export const unlockSelection = (lock: { current: boolean }) => {
lock.current = false;
};
export const resolveMatchingIntegrationsAsync = async <TIntegration extends { kind: IntegrationKind }>({
hasIntegrationSupport,
supportedIntegrations,
currentData,
ensureDataAsync,
}: {
hasIntegrationSupport: boolean;
supportedIntegrations: readonly IntegrationKind[];
currentData: TIntegration[] | undefined;
ensureDataAsync: () => Promise<TIntegration[]>;
}) => {
if (!hasIntegrationSupport) return [];
const integrations = currentData ?? (await ensureDataAsync());
return integrations.filter((integration) => supportedIntegrations.includes(integration.kind));
};
@@ -0,0 +1,21 @@
.action {
opacity: 0;
transition: opacity 150ms ease;
}
.card:hover .action,
.card:focus-within .action {
opacity: 1;
}
@media (hover: none), (pointer: coarse) {
.action {
opacity: 1;
}
}
@media (prefers-reduced-motion: reduce) {
.action {
transition: none;
}
}
@@ -1,46 +1,65 @@
import { useMemo, useState } from "react"; import { useMemo, useRef, useState } from "react";
import { Avatar, Box, Button, Card, Center, Divider, Group, Image, Stack, Text, Tooltip } from "@mantine/core"; import { Avatar, Box, Button, Card, Center, Divider, Group, Image, Stack, Text, Tooltip } from "@mantine/core";
import { IconApi } from "@tabler/icons-react"; import { IconApi } from "@tabler/icons-react";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client"; import { clientApi } from "@homarr/api/client";
import { createId, objectEntries } from "@homarr/common"; import { createId } from "@homarr/common";
import { getIconUrl, getIntegrationName } from "@homarr/definitions"; import { getIconUrl, getIntegrationName, widgetIntegrationSupport, widgetKinds } from "@homarr/definitions";
import type { IntegrationKind, WidgetKind } from "@homarr/definitions"; import type { IntegrationKind, WidgetKind } from "@homarr/definitions";
import { createModal, modalSizeSelect, useModalAction } from "@homarr/modals"; import { createModal, modalSizeSelect, useModalAction } from "@homarr/modals";
import { showErrorNotification } from "@homarr/notifications";
import { useSettings } from "@homarr/settings"; import { useSettings } from "@homarr/settings";
import { useI18n } from "@homarr/translation/client"; import { useI18n } from "@homarr/translation/client";
import { SelectGridLayout, selectGridCardHeight } from "@homarr/ui"; import { SelectGridLayout, selectGridCardHeight } from "@homarr/ui";
import type { TablerIcon } from "@homarr/ui"; import type { TablerIcon } from "@homarr/ui";
import { reduceWidgetOptionsWithDefaultValues, widgetImports } from "@homarr/widgets"; import { widgetCatalogIcons } from "@homarr/widgets/catalog";
import { WidgetEditModal } from "@homarr/widgets/modals"; import { loadWidgetDefinition, reduceWidgetOptionsWithDefinition } from "@homarr/widgets/manifest";
import { useItemActions } from "./item-actions"; import { useItemActions } from "./item-actions";
import { resolveMatchingIntegrationsAsync, tryLockSelection, unlockSelection } from "./item-select-data";
import { LazyWidgetEditModal, preloadWidgetEditModal } from "./lazy-widget-edit-modal";
import classes from "./item-select-modal.module.css";
export const ItemSelectModal = createModal<void>(({ actions }) => { interface ItemSelectModalContentProps {
actions: { closeModal: () => void };
innerProps: void;
integrationData: RouterOutputs["integration"]["all"] | undefined;
customWidgetDefs: RouterOutputs["customWidget"]["all"] | undefined;
ensureIntegrationDataAsync: () => Promise<RouterOutputs["integration"]["all"]>;
}
const ItemSelectModalContent = ({
actions,
integrationData,
customWidgetDefs,
ensureIntegrationDataAsync,
}: ItemSelectModalContentProps) => {
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
const [loadingSelection, setLoadingSelection] = useState<string | null>(null);
const selectionLock = useRef(false);
const t = useI18n(); const t = useI18n();
const { createItem, updateItemOptions, updateItemAdvancedOptions, updateItemIntegrations } = useItemActions(); const { createItem, updateItemOptions, updateItemAdvancedOptions, updateItemIntegrations } = useItemActions();
const { openModal: openEditModal } = useModalAction(WidgetEditModal); const { openModal: openEditModal } = useModalAction(LazyWidgetEditModal);
const { data: integrationData } = clientApi.integration.all.useQuery();
const { data: customWidgetDefs } = clientApi.customWidget.all.useQuery();
const settings = useSettings(); const settings = useSettings();
const availableKinds = useMemo(() => new Set((integrationData ?? []).map((i) => i.kind)), [integrationData]); const availableKinds = useMemo(() => new Set((integrationData ?? []).map((i) => i.kind)), [integrationData]);
const items = useMemo( const items = useMemo(
() => () =>
objectEntries(widgetImports) widgetKinds
.filter(([kind]) => kind !== "customApi") .filter((kind) => kind !== "customApi")
.map(([kind, value]) => ({ .map((kind) => {
kind, return {
supportedIntegrations: kind,
"supportedIntegrations" in value.definition supportedIntegrations: (widgetIntegrationSupport[kind] ?? []).filter(
? value.definition.supportedIntegrations.filter((integration) => integration !== "mock") (integration) => integration !== "mock",
: [], ),
icon: value.definition.icon, icon: widgetCatalogIcons[kind],
name: t(`widget.${kind}.name`), name: t(`widget.${kind}.name`),
description: t(`widget.${kind}.description`), description: t(`widget.${kind}.description`),
})) };
})
.sort((itemA, itemB) => { .sort((itemA, itemB) => {
if (itemA.kind === "app") return -1; if (itemA.kind === "app") return -1;
if (itemB.kind === "app") return 1; if (itemB.kind === "app") return 1;
@@ -66,52 +85,81 @@ export const ItemSelectModal = createModal<void>(({ actions }) => {
[customWidgetDefs, search], [customWidgetDefs, search],
); );
const handleAddCustomWidget = (definitionId: string) => { const notifyDefinitionLoadError = (error: unknown) => {
const itemId = createId(); showErrorNotification({
const defaultOptions = reduceWidgetOptionsWithDefaultValues("customApi", settings); title: t("common.error"),
createItem({ id: itemId, kind: "customApi", integrationIds: [] }); message: error instanceof Error ? error.message : String(error),
updateItemOptions({ itemId, newOptions: { ...defaultOptions, definitionId } }); });
actions.closeModal();
}; };
const handleAdd = (kind: WidgetKind) => { const handleAddCustomWidget = async (definitionId: string) => {
const definition = widgetImports[kind].definition; if (!tryLockSelection(selectionLock)) return;
const hasIntegrationSupport = "supportedIntegrations" in definition; setLoadingSelection(`custom:${definitionId}`);
try {
const definition = await loadWidgetDefinition("customApi");
const itemId = createId();
const defaultOptions = reduceWidgetOptionsWithDefinition(definition, settings);
createItem({ id: itemId, kind: "customApi", integrationIds: [] });
updateItemOptions({ itemId, newOptions: { ...defaultOptions, definitionId } });
actions.closeModal();
} catch (error) {
notifyDefinitionLoadError(error);
} finally {
unlockSelection(selectionLock);
setLoadingSelection(null);
}
};
const matchingIntegrations = hasIntegrationSupport const handleAdd = async (kind: WidgetKind) => {
? (integrationData ?? []).filter((integration) => if (!tryLockSelection(selectionLock)) return;
(definition.supportedIntegrations as string[]).includes(integration.kind), setLoadingSelection(kind);
) preloadWidgetEditModal();
: []; try {
const definition = await loadWidgetDefinition(kind);
const hasIntegrationSupport = "supportedIntegrations" in definition;
const integrationIds = matchingIntegrations.map((i) => i.id); const matchingIntegrations = await resolveMatchingIntegrationsAsync({
const itemId = createId(); hasIntegrationSupport,
const defaultOptions = reduceWidgetOptionsWithDefaultValues(kind, settings); supportedIntegrations: definition.supportedIntegrations ?? [],
currentData: integrationData,
ensureDataAsync: ensureIntegrationDataAsync,
});
createItem({ id: itemId, kind, integrationIds }); const integrationIds = matchingIntegrations.map((i) => i.id);
actions.closeModal(); const itemId = createId();
const defaultOptions = reduceWidgetOptionsWithDefinition(definition, settings);
openEditModal( createItem({ id: itemId, kind, integrationIds });
{ actions.closeModal();
kind,
value: { openEditModal(
advancedOptions: { title: null, customCssClasses: [], borderColor: "" }, {
options: defaultOptions, kind,
integrationIds, definition,
value: {
advancedOptions: { title: null, customCssClasses: [], borderColor: "" },
options: defaultOptions,
integrationIds,
},
onSuccessfulEdit: ({ options, integrationIds: newIntegrationIds, advancedOptions }) => {
updateItemOptions({ itemId, newOptions: options });
updateItemAdvancedOptions({ itemId, newAdvancedOptions: advancedOptions });
updateItemIntegrations({ itemId, newIntegrations: newIntegrationIds });
},
integrationData: matchingIntegrations,
integrationSupport: hasIntegrationSupport,
settings,
}, },
onSuccessfulEdit: ({ options, integrationIds: newIntegrationIds, advancedOptions }) => { {
updateItemOptions({ itemId, newOptions: options }); title: (titleT) => `${titleT("item.edit.title")} - ${titleT(`widget.${kind}.name`)}`,
updateItemAdvancedOptions({ itemId, newAdvancedOptions: advancedOptions });
updateItemIntegrations({ itemId, newIntegrations: newIntegrationIds });
}, },
integrationData: matchingIntegrations, );
integrationSupport: hasIntegrationSupport, } catch (error) {
settings, notifyDefinitionLoadError(error);
}, } finally {
{ unlockSelection(selectionLock);
title: (titleT) => `${titleT("item.edit.title")} - ${titleT(`widget.${kind}.name`)}`, setLoadingSelection(null);
}, }
);
}; };
return ( return (
@@ -120,9 +168,9 @@ export const ItemSelectModal = createModal<void>(({ actions }) => {
onSearchChange={setSearch} onSearchChange={setSearch}
placeholder={`${t("item.create.search")}...`} placeholder={`${t("item.create.search")}...`}
onSearchKeyDown={(event) => { onSearchKeyDown={(event) => {
if (event.key === "Enter" && filteredItems.length === 1) { if (event.key === "Enter" && loadingSelection === null && filteredItems.length === 1) {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion const [item] = filteredItems;
handleAdd(filteredItems[0]!.kind); if (item) void handleAdd(item.kind);
} }
}} }}
> >
@@ -130,7 +178,13 @@ export const ItemSelectModal = createModal<void>(({ actions }) => {
<WidgetItem <WidgetItem
key={item.kind} key={item.kind}
item={item} item={item}
onSelect={() => handleAdd(item.kind)} onSelect={() => void handleAdd(item.kind)}
onIntent={() => {
void loadWidgetDefinition(item.kind).catch(() => undefined);
preloadWidgetEditModal();
}}
disabled={loadingSelection !== null}
loading={loadingSelection === item.kind}
hasMatchingIntegration={item.supportedIntegrations.some((kind) => availableKinds.has(kind))} hasMatchingIntegration={item.supportedIntegrations.some((kind) => availableKinds.has(kind))}
/> />
))} ))}
@@ -146,12 +200,12 @@ export const ItemSelectModal = createModal<void>(({ actions }) => {
{filteredCustomWidgets.map((def) => ( {filteredCustomWidgets.map((def) => (
<Card <Card
key={def.id} key={def.id}
className={classes.card}
h={selectGridCardHeight} h={selectGridCardHeight}
withBorder withBorder
pos="relative" pos="relative"
style={{ overflow: "hidden", "--_hover-opacity": "0" }} style={{ overflow: "hidden" }}
onMouseEnter={(e) => e.currentTarget.style.setProperty("--_hover-opacity", "1")} onPointerEnter={() => void loadWidgetDefinition("customApi").catch(() => undefined)}
onMouseLeave={(e) => e.currentTarget.style.setProperty("--_hover-opacity", "0")}
> >
<Stack h="100%" gap="xs"> <Stack h="100%" gap="xs">
<Group gap="sm" wrap="nowrap" align="flex-start"> <Group gap="sm" wrap="nowrap" align="flex-start">
@@ -169,18 +223,24 @@ export const ItemSelectModal = createModal<void>(({ actions }) => {
</Text> </Text>
</Stack> </Stack>
<Box <Box
className={classes.action}
pos="absolute" pos="absolute"
bottom={0} bottom={0}
left={0} left={0}
right={0} right={0}
p="xs" p="xs"
style={{ style={{
opacity: "var(--_hover-opacity)",
transition: "opacity 150ms ease",
background: "linear-gradient(transparent, var(--mantine-color-body) 30%)", background: "linear-gradient(transparent, var(--mantine-color-body) 30%)",
}} }}
> >
<Button onClick={() => handleAddCustomWidget(def.id)} variant="light" size="xs" fullWidth> <Button
onClick={() => void handleAddCustomWidget(def.id)}
variant="light"
size="xs"
fullWidth
disabled={loadingSelection !== null}
loading={loadingSelection === `custom:${def.id}`}
>
{t("item.create.addToBoard")} {t("item.create.addToBoard")}
</Button> </Button>
</Box> </Box>
@@ -196,7 +256,24 @@ export const ItemSelectModal = createModal<void>(({ actions }) => {
)} )}
</SelectGridLayout> </SelectGridLayout>
); );
}).withOptions({ };
const ItemSelectModalFrame = (props: Pick<ItemSelectModalContentProps, "actions" | "innerProps">) => {
const utils = clientApi.useUtils();
const { data: integrationData } = clientApi.integration.all.useQuery();
const { data: customWidgetDefs } = clientApi.customWidget.all.useQuery();
return (
<ItemSelectModalContent
{...props}
integrationData={integrationData}
customWidgetDefs={customWidgetDefs}
ensureIntegrationDataAsync={() => utils.integration.all.ensureData()}
/>
);
};
export const ItemSelectModal = createModal<void>((props) => <ItemSelectModalFrame {...props} />).withOptions({
defaultTitle: (t) => t("item.create.title"), defaultTitle: (t) => t("item.create.title"),
size: modalSizeSelect, size: modalSizeSelect,
}); });
@@ -204,6 +281,9 @@ export const ItemSelectModal = createModal<void>(({ actions }) => {
const WidgetItem = ({ const WidgetItem = ({
item, item,
onSelect, onSelect,
onIntent,
disabled,
loading,
hasMatchingIntegration, hasMatchingIntegration,
}: { }: {
item: { item: {
@@ -214,23 +294,27 @@ const WidgetItem = ({
icon: TablerIcon; icon: TablerIcon;
}; };
onSelect: () => void; onSelect: () => void;
onIntent: () => void;
disabled: boolean;
loading: boolean;
hasMatchingIntegration: boolean; hasMatchingIntegration: boolean;
}) => { }) => {
const t = useI18n(); const t = useI18n();
return ( return (
<Card <Card
className={classes.card}
h={selectGridCardHeight} h={selectGridCardHeight}
withBorder withBorder
pos="relative" pos="relative"
style={{ style={{
overflow: "hidden", overflow: "hidden",
"--_hover-opacity": "0",
borderColor: hasMatchingIntegration ? "var(--mantine-color-blue-6)" : undefined, borderColor: hasMatchingIntegration ? "var(--mantine-color-blue-6)" : undefined,
borderWidth: hasMatchingIntegration ? 2 : undefined, borderWidth: hasMatchingIntegration ? 2 : undefined,
}} }}
onMouseEnter={(e) => e.currentTarget.style.setProperty("--_hover-opacity", "1")} onFocus={onIntent}
onMouseLeave={(e) => e.currentTarget.style.setProperty("--_hover-opacity", "0")} onPointerEnter={onIntent}
aria-busy={loading || undefined}
> >
<Stack h="100%" gap="xs"> <Stack h="100%" gap="xs">
<Group gap="sm" wrap="nowrap" align="flex-start"> <Group gap="sm" wrap="nowrap" align="flex-start">
@@ -247,18 +331,17 @@ const WidgetItem = ({
<SupportedIntegrations integrations={item.supportedIntegrations} /> <SupportedIntegrations integrations={item.supportedIntegrations} />
</Stack> </Stack>
<Box <Box
className={classes.action}
pos="absolute" pos="absolute"
bottom={0} bottom={0}
left={0} left={0}
right={0} right={0}
p="xs" p="xs"
style={{ style={{
opacity: "var(--_hover-opacity)",
transition: "opacity 150ms ease",
background: "linear-gradient(transparent, var(--mantine-color-body) 30%)", background: "linear-gradient(transparent, var(--mantine-color-body) 30%)",
}} }}
> >
<Button onClick={onSelect} variant="light" size="xs" fullWidth> <Button onClick={onSelect} variant="light" size="xs" fullWidth disabled={disabled} loading={loading}>
{t(`item.create.addToBoard`)} {t(`item.create.addToBoard`)}
</Button> </Button>
</Box> </Box>
@@ -0,0 +1,67 @@
"use client";
import { Suspense, use } from "react";
import { Center, Loader } from "@mantine/core";
import { ErrorBoundary } from "react-error-boundary";
import { createModal, modalSizeForm } from "@homarr/modals";
import { WidgetError } from "@homarr/widgets/errors";
import type { ItemMoveModalProps } from "./item-move-modal";
let itemMoveModalPromise: Promise<typeof import("./item-move-modal")> | undefined;
const loadItemMoveModal = () => {
if (itemMoveModalPromise) return itemMoveModalPromise;
const currentPromise = import("./item-move-modal");
itemMoveModalPromise = currentPromise;
void currentPromise.catch(() => {
if (itemMoveModalPromise === currentPromise) itemMoveModalPromise = undefined;
});
return currentPromise;
};
export const preloadItemMoveModal = () => void loadItemMoveModal().catch(() => undefined);
interface LazyItemMoveModalContentProps {
actions: { closeModal: () => void };
innerProps: ItemMoveModalProps;
}
const LazyItemMoveModalContent = (props: LazyItemMoveModalContentProps) => {
const { ItemMoveModal } = use(loadItemMoveModal());
const Component = ItemMoveModal.component;
return <Component {...props} />;
};
export const LazyItemMoveModal = createModal<ItemMoveModalProps>((props) => (
<ErrorBoundary
fallbackRender={({ error, resetErrorBoundary }) => (
<Center py="xl">
<WidgetError
error={error}
resetErrorBoundary={() => {
itemMoveModalPromise = undefined;
resetErrorBoundary();
}}
/>
</Center>
)}
>
<Suspense
fallback={
<Center py="xl">
<Loader size="sm" />
</Center>
}
>
<LazyItemMoveModalContent {...props} />
</Suspense>
</ErrorBoundary>
)).withOptions({
defaultTitle(t) {
return t("item.moveResize.title");
},
size: modalSizeForm,
});
@@ -0,0 +1,68 @@
"use client";
import { Suspense, use } from "react";
import { Center, Loader } from "@mantine/core";
import { ErrorBoundary } from "react-error-boundary";
import type { WidgetKind } from "@homarr/definitions";
import { createModal, modalSizeForm } from "@homarr/modals";
import { WidgetError } from "@homarr/widgets/errors";
import type { WidgetEditModalProps } from "@homarr/widgets/modals";
let widgetEditModalPromise: Promise<typeof import("@homarr/widgets/modals")> | undefined;
export const loadWidgetEditModal = () => {
if (widgetEditModalPromise) return widgetEditModalPromise;
const currentPromise = import("@homarr/widgets/modals");
widgetEditModalPromise = currentPromise;
void currentPromise.catch(() => {
if (widgetEditModalPromise === currentPromise) widgetEditModalPromise = undefined;
});
return currentPromise;
};
export const preloadWidgetEditModal = () => void loadWidgetEditModal().catch(() => undefined);
interface LazyWidgetEditModalContentProps {
actions: { closeModal: () => void };
innerProps: WidgetEditModalProps<WidgetKind>;
}
const LazyWidgetEditModalContent = (props: LazyWidgetEditModalContentProps) => {
const { WidgetEditModal } = use(loadWidgetEditModal());
const Component = WidgetEditModal.component;
return <Component {...props} />;
};
export const LazyWidgetEditModal = createModal<WidgetEditModalProps<WidgetKind>>((props) => (
<ErrorBoundary
fallbackRender={({ error, resetErrorBoundary }) => (
<Center py="xl">
<WidgetError
error={error}
resetErrorBoundary={() => {
widgetEditModalPromise = undefined;
resetErrorBoundary();
}}
/>
</Center>
)}
>
<Suspense
fallback={
<Center py="xl">
<Loader size="sm" />
</Center>
}
>
<LazyWidgetEditModalContent {...props} />
</Suspense>
</ErrorBoundary>
)).withOptions({
keepMounted: true,
defaultTitle(t) {
return t("item.edit.title");
},
size: modalSizeForm,
});
@@ -1,76 +1,140 @@
"use client"; "use client";
import type { MutableRefObject, ReactNode } from "react"; import type { MutableRefObject, ReactNode } from "react";
import { useCallback, useEffect, useMemo, useState } from "react"; import { useCallback, useMemo, useState } from "react";
import { Group, Loader, Menu, Switch, Text } from "@mantine/core"; import { Group, Loader, Menu, Switch, Text, Tooltip } from "@mantine/core";
import { IconCopy, IconLayoutKanban, IconRefresh, IconSettings, IconTrash } from "@tabler/icons-react"; import {
IconAlertTriangle,
IconCircleCheck,
IconCopy,
IconLayoutKanban,
IconRefresh,
IconSettings,
IconTrash,
} from "@tabler/icons-react";
import type { QueryClient, QueryKey } from "@tanstack/react-query"; import type { QueryClient, QueryKey } from "@tanstack/react-query";
import { useIsFetching, useQueryClient } from "@tanstack/react-query"; import { partialMatchKey, useIsFetching, useQueryClient } from "@tanstack/react-query";
import { clientApi } from "@homarr/api/client"; import { clientApi } from "@homarr/api/client";
import { useSession } from "@homarr/auth/client"; import { useSession } from "@homarr/auth/client";
import { useRequiredBoard } from "@homarr/boards/context"; import { useRequiredBoard } from "@homarr/boards/context";
import { useEditMode } from "@homarr/boards/edit-mode"; import { useEditMode } from "@homarr/boards/edit-mode";
import { useTimeAgo } from "@homarr/common";
import { useConfirmModal, useModalAction } from "@homarr/modals"; import { useConfirmModal, useModalAction } from "@homarr/modals";
import { useSettings } from "@homarr/settings"; import { useSettings } from "@homarr/settings";
import { translateIfNecessary } from "@homarr/translation"; import { translateIfNecessary } from "@homarr/translation";
import { useI18n, useScopedI18n } from "@homarr/translation/client"; import { useI18n, useScopedI18n } from "@homarr/translation/client";
import type { WidgetContextMenuAction } from "@homarr/widgets"; import type { TranslationFunction } from "@homarr/translation";
import { reduceWidgetOptionsWithDefaultValues, widgetImports } from "@homarr/widgets"; import type { WidgetContextMenuAction, WidgetDefinition } from "@homarr/widgets/definition";
import { WidgetEditModal } from "@homarr/widgets/modals"; import { getWidgetQueryKeys } from "@homarr/widgets/definition";
import { reduceWidgetOptionsWithDefinition } from "@homarr/widgets/manifest";
import type { SectionItem } from "~/app/[locale]/boards/_types"; import type { SectionItem } from "~/app/[locale]/boards/_types";
import { useSectionContext } from "../sections/section-context"; import { useSectionContext } from "../sections/section-context";
import { useItemActions } from "./item-actions"; import { useItemActions } from "./item-actions";
import { ItemMoveModal } from "./item-move-modal"; import { LazyItemMoveModal, preloadItemMoveModal } from "./lazy-item-move-modal";
import { LazyWidgetEditModal, preloadWidgetEditModal } from "./lazy-widget-edit-modal";
interface WidgetContextMenuProps { interface WidgetContextMenuProps {
item: SectionItem; item: SectionItem;
definition: WidgetDefinition;
widgetStateRef: MutableRefObject<Record<string, unknown> | null>; widgetStateRef: MutableRefObject<Record<string, unknown> | null>;
children: ReactNode; children: ReactNode;
} }
export const WidgetContextMenu = ({ item, widgetStateRef, children }: WidgetContextMenuProps) => { export const WidgetContextMenu = ({ item, definition, widgetStateRef, children }: WidgetContextMenuProps) => {
const { data: session } = useSession(); const { data: session } = useSession();
const settings = useSettings();
if (!session || !settings.enableRightClickOnWidgets) return <>{children}</>;
return (
<WidgetContextMenuInner item={item} definition={definition} widgetStateRef={widgetStateRef} settings={settings}>
{children}
</WidgetContextMenuInner>
);
};
const WidgetContextMenuInner = ({
item,
definition,
widgetStateRef,
children,
settings,
}: WidgetContextMenuProps & { settings: ReturnType<typeof useSettings> }) => {
const [isMenuOpen, setIsMenuOpen] = useState(false);
return (
<Menu
shadow="md"
width={300}
closeOnItemClick={false}
position="right-start"
offset={4}
opened={isMenuOpen}
onChange={setIsMenuOpen}
>
<Menu.ContextMenu>{children}</Menu.ContextMenu>
<Menu.Dropdown>
{isMenuOpen && (
<WidgetContextMenuDropdown
item={item}
definition={definition}
widgetStateRef={widgetStateRef}
settings={settings}
/>
)}
</Menu.Dropdown>
</Menu>
);
};
type WidgetContextMenuDropdownProps = Omit<WidgetContextMenuProps, "children"> & {
settings: ReturnType<typeof useSettings>;
};
const WidgetContextMenuDropdown = ({ item, definition, widgetStateRef, settings }: WidgetContextMenuDropdownProps) => {
const [isEditMode] = useEditMode(); const [isEditMode] = useEditMode();
const board = useRequiredBoard(); const board = useRequiredBoard();
const tItem = useScopedI18n("item"); const tItem = useScopedI18n("item");
const tMenu = useScopedI18n("item.menu.label"); const tMenu = useScopedI18n("item.menu.label");
const t = useI18n(); const t = useI18n();
const settings = useSettings(); const hasSupportedIntegrations =
const { openModal } = useModalAction(WidgetEditModal); "supportedIntegrations" in definition && (definition.supportedIntegrations?.length ?? 0) > 0;
const { openModal: openMoveModal } = useModalAction(ItemMoveModal); const { openModal } = useModalAction(LazyWidgetEditModal);
const { openModal: openMoveModal } = useModalAction(LazyItemMoveModal);
const { openConfirmModal } = useConfirmModal(); const { openConfirmModal } = useConfirmModal();
const { updateItemOptions, updateItemAdvancedOptions, updateItemIntegrations, duplicateItem, removeItem } = const { updateItemOptions, updateItemAdvancedOptions, updateItemIntegrations, duplicateItem, removeItem } =
useItemActions(); useItemActions();
const { data: integrationData, isPending } = clientApi.integration.all.useQuery(); const { data: integrationData, isPending } = clientApi.integration.all.useQuery(undefined, {
enabled: hasSupportedIntegrations,
});
const { mutate: saveBoard } = clientApi.board.saveBoard.useMutation(); const { mutate: saveBoard } = clientApi.board.saveBoard.useMutation();
const currentDefinition = useMemo(() => widgetImports[item.kind].definition, [item.kind]);
const { gridstack } = useSectionContext().refs; const { gridstack } = useSectionContext().refs;
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const widgetQueryKey = useMemo( const widgetQueryKeys = useMemo(() => getWidgetQueryKeys(definition, item.kind), [definition, item.kind]);
() => ("queryKey" in currentDefinition ? (currentDefinition.queryKey as QueryKey) : [["widget", item.kind]]), const isWidgetFetching =
[currentDefinition, item.kind], useIsFetching({
); predicate: (query) => widgetQueryKeys.some((queryKey) => partialMatchKey(query.queryKey, queryKey)),
const isWidgetFetching = useIsFetching({ queryKey: widgetQueryKey }) > 0; }) > 0;
const handleRefetch = useCallback(() => { const handleRefetch = useCallback(() => {
void queryClient.refetchQueries({ queryKey: widgetQueryKey, type: "all" }); void Promise.all(widgetQueryKeys.map((queryKey) => queryClient.refetchQueries({ queryKey, type: "all" })));
}, [queryClient, widgetQueryKey]); }, [queryClient, widgetQueryKeys]);
const options = useMemo( const options = useMemo(
() => reduceWidgetOptionsWithDefaultValues(item.kind, settings, item.options) as Record<string, unknown>, () => reduceWidgetOptionsWithDefinition(definition, settings, item.options),
[item.kind, settings, item.options], [definition, settings, item.options],
); );
type OptionDef = { type: string; skipContextMenu?: boolean }; type OptionDef = { type: string; skipContextMenu?: boolean };
const toggleOptions = useMemo(() => { const toggleOptions = useMemo(() => {
const rawOptions = currentDefinition.createOptions(settings) as unknown as Record<string, OptionDef>; const rawOptions = definition.createOptions(settings) as unknown as Record<string, OptionDef>;
return Object.entries(rawOptions).filter(([, def]) => def.type === "switch" && !def.skipContextMenu); return Object.entries(rawOptions).filter(([, def]) => def.type === "switch" && !def.skipContextMenu);
}, [currentDefinition, settings]); }, [definition, settings]);
const widgetContextActions = useMemo(() => { const widgetContextActions = useMemo(() => {
const def = currentDefinition as Record<string, unknown>; const def = definition as unknown as Record<string, unknown>;
if (typeof def.contextActions !== "function") return []; if (typeof def.contextActions !== "function") return [];
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
const actions = (def.contextActions as any)({ const actions = (def.contextActions as any)({
@@ -83,7 +147,7 @@ export const WidgetContextMenu = ({ item, widgetStateRef, children }: WidgetCont
widgetStateRef, widgetStateRef,
}); });
return (Array.isArray(actions) ? actions : []) as WidgetContextMenuAction[]; return (Array.isArray(actions) ? actions : []) as WidgetContextMenuAction[];
}, [currentDefinition, options, item, updateItemOptions, isEditMode, board.id, widgetStateRef]); }, [definition, options, item, updateItemOptions, isEditMode, board.id, widgetStateRef]);
const persistBoard = useCallback( const persistBoard = useCallback(
(updatedItems: typeof board.items) => { (updatedItems: typeof board.items) => {
@@ -96,6 +160,7 @@ export const WidgetContextMenu = ({ item, widgetStateRef, children }: WidgetCont
openModal( openModal(
{ {
kind: item.kind, kind: item.kind,
definition,
value: { value: {
advancedOptions: item.advancedOptions, advancedOptions: item.advancedOptions,
options: item.options, options: item.options,
@@ -122,10 +187,10 @@ export const WidgetContextMenu = ({ item, widgetStateRef, children }: WidgetCont
}, },
integrationData: (integrationData ?? []).filter( integrationData: (integrationData ?? []).filter(
(integration) => (integration) =>
"supportedIntegrations" in currentDefinition && "supportedIntegrations" in definition &&
(currentDefinition.supportedIntegrations as string[]).some((kind) => kind === integration.kind), (definition.supportedIntegrations as string[]).some((kind) => kind === integration.kind),
), ),
integrationSupport: "supportedIntegrations" in currentDefinition, integrationSupport: "supportedIntegrations" in definition,
settings, settings,
appId: item.kind === "app" ? (item.options.appId as string | undefined) : undefined, appId: item.kind === "app" ? (item.options.appId as string | undefined) : undefined,
}, },
@@ -139,7 +204,7 @@ export const WidgetContextMenu = ({ item, widgetStateRef, children }: WidgetCont
updateItemAdvancedOptions, updateItemAdvancedOptions,
updateItemIntegrations, updateItemIntegrations,
integrationData, integrationData,
currentDefinition, definition,
settings, settings,
isEditMode, isEditMode,
persistBoard, persistBoard,
@@ -157,150 +222,185 @@ export const WidgetContextMenu = ({ item, widgetStateRef, children }: WidgetCont
[options, item.id, updateItemOptions, persistBoard, board], [options, item.id, updateItemOptions, persistBoard, board],
); );
if (!session) return <>{children}</>;
const visibleWidgetActions = widgetContextActions.filter((a) => !a.hidden); const visibleWidgetActions = widgetContextActions.filter((a) => !a.hidden);
return ( return (
<Menu shadow="md" width={300} closeOnItemClick={false} position="right-start" offset={4}> <>
<Menu.ContextMenu>{children}</Menu.ContextMenu> {toggleOptions.length > 0 && (
<Menu.Dropdown>
{toggleOptions.length > 0 && (
<>
<Menu.Label>{tMenu("options")}</Menu.Label>
{toggleOptions.map(([key]) => (
<Menu.Item key={key} onClick={() => handleToggle(key)(!options[key])}>
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
<Group wrap="nowrap">
{String(translateIfNecessary(t, ((fn: any) => fn(`widget.${item.kind}.option.${key}.label`)) as any))}
<Switch size="xs" checked={Boolean(options[key])} readOnly tabIndex={-1} ml="auto" />
</Group>
</Menu.Item>
))}
</>
)}
{visibleWidgetActions.length > 0 && (
<>
{toggleOptions.length > 0 && <Menu.Divider />}
<Menu.Label>{tMenu("actions")}</Menu.Label>
{visibleWidgetActions.map((action) => {
const Icon = action.icon;
return (
<Menu.Item
key={action.key}
closeMenuOnClick
leftSection={Icon ? <Icon size={16} /> : undefined}
onClick={action.onClick}
disabled={action.disabled}
color={action.color}
>
{String(translateIfNecessary(t, action.label))}
</Menu.Item>
);
})}
</>
)}
{isEditMode && (
<>
{(toggleOptions.length > 0 || visibleWidgetActions.length > 0) && <Menu.Divider />}
<Menu.Item
closeMenuOnClick
leftSection={<IconLayoutKanban size={16} />}
onClick={() => {
if (!gridstack.current) return;
openMoveModal({ item, columnCount: gridstack.current.getColumn(), gridStack: gridstack.current });
}}
>
{tItem("action.moveResize")}
</Menu.Item>
<Menu.Item
closeMenuOnClick
leftSection={<IconCopy size={16} />}
onClick={() => duplicateItem({ itemId: item.id })}
>
{tItem("action.duplicate")}
</Menu.Item>
</>
)}
<> <>
{(toggleOptions.length > 0 || visibleWidgetActions.length > 0 || isEditMode) && <Menu.Divider />} <Menu.Label>{tMenu("options")}</Menu.Label>
{toggleOptions.map(([key]) => (
<Menu.Item key={key} onClick={() => handleToggle(key)(!options[key])}>
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
<Group wrap="nowrap">
{String(translateIfNecessary(t, ((fn: any) => fn(`widget.${item.kind}.option.${key}.label`)) as any))}
<Switch size="xs" checked={Boolean(options[key])} readOnly tabIndex={-1} ml="auto" />
</Group>
</Menu.Item>
))}
</>
)}
{visibleWidgetActions.length > 0 && (
<>
{toggleOptions.length > 0 && <Menu.Divider />}
<Menu.Label>{tMenu("actions")}</Menu.Label>
{visibleWidgetActions.map((action) => {
const Icon = action.icon;
return (
<Menu.Item
key={action.key}
closeMenuOnClick
leftSection={Icon ? <Icon size={16} /> : undefined}
onClick={action.onClick}
disabled={action.disabled}
color={action.color}
>
{String(translateIfNecessary(t, action.label))}
</Menu.Item>
);
})}
</>
)}
{isEditMode && (
<>
{(toggleOptions.length > 0 || visibleWidgetActions.length > 0) && <Menu.Divider />}
<Menu.Item <Menu.Item
leftSection={isWidgetFetching ? <Loader size={16} /> : <IconRefresh size={16} />} closeMenuOnClick
onClick={handleRefetch} leftSection={<IconLayoutKanban size={16} />}
disabled={isWidgetFetching} onFocus={preloadItemMoveModal}
onPointerEnter={preloadItemMoveModal}
onClick={() => {
if (!gridstack.current) return;
openMoveModal({ item, columnCount: gridstack.current.getColumn(), gridStack: gridstack.current });
}}
> >
<Group justify="space-between" wrap="nowrap"> {tItem("action.moveResize")}
{tMenu("refresh")}
<Text size="xs" c="dimmed">
<WidgetCacheAge queryClient={queryClient} queryKey={widgetQueryKey} isFetching={isWidgetFetching} />
</Text>
</Group>
</Menu.Item> </Menu.Item>
<Menu.Item <Menu.Item
closeMenuOnClick closeMenuOnClick
leftSection={<IconSettings size={16} />} leftSection={<IconCopy size={16} />}
onClick={openEditModal} onClick={() => duplicateItem({ itemId: item.id })}
disabled={isPending}
> >
{tMenu("settings")} {tItem("action.duplicate")}
</Menu.Item> </Menu.Item>
</> </>
)}
{isEditMode && ( <>
<> {(toggleOptions.length > 0 || visibleWidgetActions.length > 0 || isEditMode) && <Menu.Divider />}
<Menu.Divider /> <Menu.Item
<Menu.Item leftSection={isWidgetFetching ? <Loader size={16} /> : <IconRefresh size={16} />}
closeMenuOnClick onClick={handleRefetch}
color="red" disabled={isWidgetFetching}
leftSection={<IconTrash size={16} />} >
onClick={() => { <Group justify="space-between" wrap="nowrap" gap="sm">
openConfirmModal({ {tMenu("refresh")}
title: tItem("remove.title"), <WidgetQueryStatus
children: tItem("remove.message"), queryClient={queryClient}
onConfirm: () => removeItem({ itemId: item.id }), queryKeys={widgetQueryKeys}
}); isFetching={isWidgetFetching}
}} t={t}
> />
{tItem("action.remove")} </Group>
</Menu.Item> </Menu.Item>
</> <Menu.Item
)} closeMenuOnClick
</Menu.Dropdown> leftSection={<IconSettings size={16} />}
</Menu> onClick={openEditModal}
onFocus={preloadWidgetEditModal}
onPointerEnter={preloadWidgetEditModal}
disabled={hasSupportedIntegrations && isPending}
>
{tMenu("settings")}
</Menu.Item>
</>
{isEditMode && (
<>
<Menu.Divider />
<Menu.Item
closeMenuOnClick
color="red"
leftSection={<IconTrash size={16} />}
onClick={() => {
openConfirmModal({
title: tItem("remove.title"),
children: tItem("remove.message"),
onConfirm: () => removeItem({ itemId: item.id }),
});
}}
>
{tItem("action.remove")}
</Menu.Item>
</>
)}
</>
); );
}; };
const WidgetCacheAge = ({ interface WidgetQueryStatusProps {
queryClient,
queryKey,
isFetching,
}: {
queryClient: QueryClient; queryClient: QueryClient;
queryKey: QueryKey; queryKeys: QueryKey[];
isFetching: boolean; isFetching: boolean;
}) => { t: TranslationFunction;
// 1s tick for "just now" → "1s ago" label transition; isFetching prop handles refetch reactivity }
const [, setTick] = useState(0);
useEffect(() => {
const id = setInterval(() => setTick((n) => n + 1), 1000);
return () => clearInterval(id);
}, []);
if (isFetching) return <Loader size={12} />; const WidgetQueryStatus = ({ queryClient, queryKeys, isFetching, t }: WidgetQueryStatusProps) => {
const queries = [...new Set(queryKeys.flatMap((queryKey) => queryClient.getQueryCache().findAll({ queryKey })))];
const timestamps = queries.map((query) => query.state.dataUpdatedAt).filter(Boolean);
const latest = timestamps.length > 0 ? Math.max(...timestamps) : 0;
const ageLabel = useTimeAgo(new Date(latest));
const timestamps = queryClient if (isFetching) {
.getQueryCache() return (
.findAll({ queryKey }) <Group gap={4} wrap="nowrap">
.map((q) => q.state.dataUpdatedAt) <Loader size={12} />
.filter(Boolean); <Text size="xs" c="dimmed">
if (timestamps.length === 0) return null; {t("item.menu.status.loading")}
</Text>
</Group>
);
}
const latest = Math.max(...timestamps); if (queries.length === 0) {
const seconds = Math.floor((Date.now() - latest) / 1000); return (
if (seconds < 5) return "just now"; <Text size="xs" c="dimmed">
if (seconds < 60) return `${seconds}s ago`; {t("item.menu.status.idle")}
return `${Math.floor(seconds / 60)}m ago`; </Text>
);
}
const hasError = queries.some((q) => q.state.status === "error");
if (hasError) {
const errorQuery = queries.find((q) => q.state.status === "error");
const errorMessage =
errorQuery?.state.error instanceof Error
? errorQuery.state.error.message
: String(errorQuery?.state.error ?? "Unknown error");
return (
<Tooltip label={errorMessage} multiline position="left" w={250}>
<Group gap={4} wrap="nowrap">
<IconAlertTriangle size={12} color="var(--mantine-color-red-6)" />
<Text size="xs" c="red.6">
{t("item.menu.status.error")}
{latest > 0 && ` · ${ageLabel}`}
</Text>
</Group>
</Tooltip>
);
}
return (
<Group gap={4} wrap="nowrap">
<IconCircleCheck size={12} color="var(--mantine-color-green-6)" />
<Text size="xs" c="dimmed">
{t("item.menu.status.success")}
{latest > 0 && ` · ${ageLabel}`}
</Text>
</Group>
);
}; };
@@ -1,155 +0,0 @@
import { objectEntries } from "@homarr/common";
import type { IntegrationKind, WidgetKind } from "@homarr/definitions";
import type { SettingsContextProps } from "@homarr/settings/creator";
import type { WidgetOptionDefinition, WidgetOptionType } from "@homarr/widgets";
import { reduceWidgetOptionsWithDefaultValues, widgetImports } from "@homarr/widgets";
import type { SectionItem } from "~/app/[locale]/boards/_types";
export type Translator = {
(key: string): string;
};
export interface HoverMetadataItem {
label: string;
value: string;
}
const maxMetadataItems = 3;
const skippedOptionTypes: Partial<Record<WidgetOptionType, true>> = {
app: true,
anchorNote: true,
dynamicSelect: true,
sortableItemList: true,
multiReleasesRepositories: true,
umamiEventName: true,
umamiEventNames: true,
umamiWebsite: true,
};
const isEqualValue = (left: unknown, right: unknown) => JSON.stringify(left) === JSON.stringify(right);
type OptionDefinitionWithRules = WidgetOptionDefinition & {
shouldHide?: (options: never, integrationKinds: IntegrationKind[]) => boolean;
options?: readonly { value: unknown; label: unknown }[];
};
type OptionFormatter = (value: unknown, definition: OptionDefinitionWithRules, t: Translator) => string | null;
const resolveSelectLabel = (value: unknown, definition: OptionDefinitionWithRules, t: Translator) => {
const match = definition.options?.find((option) => option.value === value);
const label = match?.label;
const fallbackLabel = () => String(value);
const labelResolvers: Record<string, () => string> = {
function: () => (label as (translator: Translator) => string)(t),
string: () => label as string,
missing: fallbackLabel,
};
const labelType = typeof label;
const resolveLabel: () => string = labelResolvers[labelType] ?? fallbackLabel;
return resolveLabel();
};
const optionTypeFormatters: Partial<Record<WidgetOptionType, OptionFormatter>> = {
switch: (value, _, t) => {
const switchLabels: Record<string, string> = {
true: t("common.action.show"),
false: t("common.action.hide"),
};
return switchLabels[String(Boolean(value))] ?? null;
},
text: (value) => {
const text = String(value).trim();
return text.length > 0 ? text : null;
},
number: (value) => String(value),
slider: (value) => String(value),
select: (value, definition, t) => resolveSelectLabel(value, definition, t),
multiSelect: (value) => {
const items = value as unknown[];
return items.length > 0 ? String(items.length) : null;
},
multiText: (value) => {
const items = (value as string[]).filter((entry) => entry.trim().length > 0);
return items.length > 0 ? items.join(", ") : null;
},
location: (value) => {
const location = value as { name?: string };
const name = location.name?.trim() ?? "";
return name.length > 0 ? name : null;
},
};
const formatOptionValue = (
type: WidgetOptionType,
value: unknown,
definition: OptionDefinitionWithRules,
t: Translator,
) => {
const formatter = optionTypeFormatters[type];
return formatter?.(value, definition, t) ?? null;
};
const getOptionLabel = (kind: WidgetKind, key: string, t: Translator) => {
const labelKey = `widget.${kind}.option.${key}.label`;
const translated = t(labelKey);
return translated === labelKey ? key : translated;
};
export const collectWidgetHoverMetadata = (
item: SectionItem,
settings: Pick<SettingsContextProps, "enableStatusByDefault" | "forceDisableStatus">,
integrationKinds: IntegrationKind[],
t: Translator,
): HoverMetadataItem[] => {
const { definition } = widgetImports[item.kind];
const optionDefinitions = definition.createOptions(settings) as Record<string, OptionDefinitionWithRules>;
const resolvedOptions = reduceWidgetOptionsWithDefaultValues(item.kind, settings, item.options);
return objectEntries(optionDefinitions)
.filter(([key, optionDefinition]) => {
const skip = skippedOptionTypes[optionDefinition.type];
const hidden = optionDefinition.shouldHide?.(resolvedOptions as never, integrationKinds);
const unchanged = isEqualValue(resolvedOptions[key], optionDefinition.defaultValue);
return !skip && !hidden && !unchanged;
})
.map(([key, optionDefinition]) => {
const formattedValue = formatOptionValue(optionDefinition.type, resolvedOptions[key], optionDefinition, t);
return {
label: getOptionLabel(item.kind, key, t),
value: formattedValue ?? "",
};
})
.filter((entry) => entry.value.length > 0)
.slice(0, maxMetadataItems);
};
interface DisplayNameSource {
matches: (item: SectionItem, options: Record<string, unknown>) => boolean;
resolve: (item: SectionItem, options: Record<string, unknown>) => string;
}
const displayNameSources: DisplayNameSource[] = [
{
matches: (item) => Boolean(item.advancedOptions.title?.trim()),
resolve: (item) => item.advancedOptions.title?.trim() ?? "",
},
{
matches: (item, options) =>
item.kind === "clock" &&
Boolean(options.customTitleToggle) &&
String(options.customTitle ?? "").trim().length > 0,
resolve: (_, options) => String(options.customTitle).trim(),
},
];
export const resolveWidgetDisplayName = (
item: SectionItem,
settings: Pick<SettingsContextProps, "enableStatusByDefault" | "forceDisableStatus">,
t: Translator,
) => {
const resolvedOptions = reduceWidgetOptionsWithDefaultValues(item.kind, settings, item.options);
const source = displayNameSources.find((entry) => entry.matches(item, resolvedOptions));
return source?.resolve(item, resolvedOptions) ?? t(`widget.${item.kind}.name`);
};
@@ -1,12 +1,13 @@
"use client"; "use client";
import { Suspense, use } from "react";
import { Avatar, Text, Tooltip } from "@mantine/core"; import { Avatar, Text, Tooltip } from "@mantine/core";
import type { RouterOutputs } from "@homarr/api"; import type { RouterOutputs } from "@homarr/api";
import { useEditMode } from "@homarr/boards/edit-mode"; import { useEditMode } from "@homarr/boards/edit-mode";
import { getIconUrl } from "@homarr/definitions"; import { getIconUrl } from "@homarr/definitions";
import { useI18n } from "@homarr/translation/client"; import { useI18n } from "@homarr/translation/client";
import { widgetImports } from "@homarr/widgets"; import { loadWidgetDefinition } from "@homarr/widgets/manifest";
import type { SectionItem } from "~/app/[locale]/boards/_types"; import type { SectionItem } from "~/app/[locale]/boards/_types";
import classes from "./widget-hover-overlay.module.css"; import classes from "./widget-hover-overlay.module.css";
@@ -18,8 +19,19 @@ interface WidgetHoverOverlayProps {
export const WidgetHoverOverlay = ({ item, integrations }: WidgetHoverOverlayProps) => { export const WidgetHoverOverlay = ({ item, integrations }: WidgetHoverOverlayProps) => {
const [isEditMode] = useEditMode(); const [isEditMode] = useEditMode();
if (!isEditMode) return null;
return (
<Suspense fallback={null}>
<LoadedWidgetHoverOverlay item={item} integrations={integrations} />
</Suspense>
);
};
const LoadedWidgetHoverOverlay = ({ item, integrations }: WidgetHoverOverlayProps) => {
const t = useI18n(); const t = useI18n();
const { definition } = widgetImports[item.kind]; const definition = use(loadWidgetDefinition(item.kind));
const WidgetIcon = definition.icon; const WidgetIcon = definition.icon;
const displayName = item.advancedOptions.title?.trim() || t(`widget.${item.kind}.name`); const displayName = item.advancedOptions.title?.trim() || t(`widget.${item.kind}.name`);
@@ -28,10 +40,6 @@ export const WidgetHoverOverlay = ({ item, integrations }: WidgetHoverOverlayPro
item.integrationIds.includes(integration.id), item.integrationIds.includes(integration.id),
); );
if (!isEditMode) {
return null;
}
return ( return (
<div className={classes.wrapper}> <div className={classes.wrapper}>
<div className={classes.panel}> <div className={classes.panel}>
@@ -0,0 +1,63 @@
import { QueryClient } from "@tanstack/react-query";
import { describe, expect, test } from "vitest";
import { removeWidgetDataQueries } from "./widget-query-recovery";
describe("removeWidgetDataQueries", () => {
test("removes only the failed widget prefix and allows fresh data to be fetched", async () => {
const queryClient = new QueryClient();
const crashedWidgetQueryKey = [["widget", "mediaServer", "getCurrentStreams"], { input: {} }];
const sameWidgetQueryKey = [["widget", "mediaServer", "getLibraries"], { input: {} }];
const unrelatedWidgetQueryKey = [["widget", "calendar", "getEvents"], { input: {} }];
const appQueryKey = [["app", "byId"], { input: {} }];
const unrelatedQueryKey = [["user", "me"], { input: {} }];
queryClient.setQueryData(crashedWidgetQueryKey, "stale");
queryClient.setQueryData(sameWidgetQueryKey, "related");
queryClient.setQueryData(unrelatedWidgetQueryKey, "other-widget");
queryClient.setQueryData(appQueryKey, "app");
queryClient.setQueryData(unrelatedQueryKey, "unrelated");
removeWidgetDataQueries(queryClient, [[["widget", "mediaServer"]]]);
expect(queryClient.getQueryData(crashedWidgetQueryKey)).toBeUndefined();
expect(queryClient.getQueryData(sameWidgetQueryKey)).toBeUndefined();
expect(queryClient.getQueryData(unrelatedWidgetQueryKey)).toBe("other-widget");
expect(queryClient.getQueryData(appQueryKey)).toBe("app");
expect(queryClient.getQueryData(unrelatedQueryKey)).toBe("unrelated");
await expect(
queryClient.fetchQuery({ queryKey: crashedWidgetQueryKey, queryFn: () => Promise.resolve("fresh") }),
).resolves.toBe("fresh");
});
test("uses an explicit supporting query prefix without purging other supporting data", () => {
const queryClient = new QueryClient();
const dockerQueryKey = [["docker", "getContainers"], { input: {} }];
const appQueryKey = [["app", "byId"], { input: {} }];
const unrelatedQueryKey = [["user", "me"], { input: {} }];
queryClient.setQueryData(dockerQueryKey, "stale");
queryClient.setQueryData(appQueryKey, "stale");
queryClient.setQueryData(unrelatedQueryKey, "unrelated");
removeWidgetDataQueries(queryClient, [[["docker", "getContainers"]]]);
expect(queryClient.getQueryData(dockerQueryKey)).toBeUndefined();
expect(queryClient.getQueryData(appQueryKey)).toBe("stale");
expect(queryClient.getQueryData(unrelatedQueryKey)).toBe("unrelated");
});
test("uses the definition query prefix when the widget kind and query path differ", () => {
const queryClient = new QueryClient();
const crashedWidgetQueryKey = [["widget", "anchorNotes", "getNote"], { input: {} }];
const unrelatedQueryKey = [["user", "me"], { input: {} }];
queryClient.setQueryData(crashedWidgetQueryKey, "stale");
queryClient.setQueryData(unrelatedQueryKey, "unrelated");
removeWidgetDataQueries(queryClient, [[["widget", "anchorNotes"]]]);
expect(queryClient.getQueryData(crashedWidgetQueryKey)).toBeUndefined();
expect(queryClient.getQueryData(unrelatedQueryKey)).toBe("unrelated");
});
});
@@ -0,0 +1,5 @@
import type { QueryClient, QueryKey } from "@tanstack/react-query";
export const removeWidgetDataQueries = (queryClient: QueryClient, queryKeys: QueryKey[]) => {
queryKeys.forEach((queryKey) => queryClient.removeQueries({ queryKey }));
};
@@ -105,12 +105,14 @@ export const useCategoryMenuActions = (category: CategorySection) => {
const settings = useSettings(); const settings = useSettings();
const openAllInNewTabs = useCallback(async () => { const openAllInNewTabs = useCallback(async () => {
const currentLayoutId = getCurrentLayout(board); const currentLayoutId = getCurrentLayout(board);
const appIds = filterByItemKind( const appIds = (
board.items.filter( await filterByItemKind(
(item) => item.layouts.find((layout) => layout.layoutId === currentLayoutId)?.sectionId === category.id, board.items.filter(
), (item) => item.layouts.find((layout) => layout.layoutId === currentLayoutId)?.sectionId === category.id,
settings, ),
"app", settings,
"app",
)
).map((item) => { ).map((item) => {
return item.options.appId; return item.options.appId;
}); });
@@ -1,21 +1,23 @@
import type { WidgetKind } from "@homarr/definitions"; import type { WidgetKind } from "@homarr/definitions";
import type { SettingsContextProps } from "@homarr/settings/creator"; import type { SettingsContextProps } from "@homarr/settings/creator";
import type { WidgetComponentProps } from "@homarr/widgets"; import type { WidgetComponentProps } from "@homarr/widgets/definition";
import { reduceWidgetOptionsWithDefaultValues } from "@homarr/widgets"; import { loadWidgetDefinition, reduceWidgetOptionsWithDefinition } from "@homarr/widgets/manifest";
import type { Item } from "~/app/[locale]/boards/_types"; import type { Item } from "~/app/[locale]/boards/_types";
export const filterByItemKind = <TKind extends WidgetKind>( export const filterByItemKind = async <TKind extends WidgetKind>(
items: Item[], items: Item[],
settings: SettingsContextProps, settings: SettingsContextProps,
kind: TKind, kind: TKind,
) => { ) => {
const definition = await loadWidgetDefinition(kind);
return items return items
.filter((item) => item.kind === kind) .filter((item) => item.kind === kind)
.map((item) => ({ .map((item) => ({
...item, ...item,
options: reduceWidgetOptionsWithDefaultValues( options: reduceWidgetOptionsWithDefinition(
kind, definition,
settings, settings,
item.options, item.options,
) as WidgetComponentProps<TKind>["options"], ) as WidgetComponentProps<TKind>["options"],
@@ -4,6 +4,8 @@ import type { GridItemHTMLElement } from "@homarr/gridstack";
import type { RouterOutputs } from "@homarr/api"; import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client"; import { clientApi } from "@homarr/api/client";
import { useSession } from "@homarr/auth/client";
import { useEditMode } from "@homarr/boards/edit-mode";
import type { DynamicSectionItem, SectionItem } from "~/app/[locale]/boards/_types"; import type { DynamicSectionItem, SectionItem } from "~/app/[locale]/boards/_types";
import { BoardItemContent } from "../items/item-content"; import { BoardItemContent } from "../items/item-content";
@@ -15,7 +17,11 @@ import { useSectionItems } from "./use-section-items";
export const SectionContent = () => { export const SectionContent = () => {
const { innerSections, items, refs } = useSectionContext(); const { innerSections, items, refs } = useSectionContext();
const { data: integrations } = clientApi.integration.all.useQuery(); const { data: session } = useSession();
const [isEditMode] = useEditMode();
const { data: integrations } = clientApi.integration.all.useQuery(undefined, {
enabled: Boolean(session) && isEditMode,
});
/** /**
* IMPORTANT: THE ORDER OF THE BELOW ITEMS HAS TO MATCH THE ORDER OF * IMPORTANT: THE ORDER OF THE BELOW ITEMS HAS TO MATCH THE ORDER OF
@@ -1,4 +1,3 @@
import { objectEntries } from "@homarr/common";
import type { IntegrationKind, WidgetKind } from "@homarr/definitions"; import type { IntegrationKind, WidgetKind } from "@homarr/definitions";
import { import {
featuredIntegrations, featuredIntegrations,
@@ -6,30 +5,8 @@ import {
hiddenFromOnboarding, hiddenFromOnboarding,
integrationDefs, integrationDefs,
integrationKinds, integrationKinds,
getWidgetKindsForIntegration,
} from "@homarr/definitions"; } from "@homarr/definitions";
import { widgetImports } from "@homarr/widgets";
const buildWidgetsByIntegration = () => {
const map = Object.fromEntries(integrationKinds.map((kind) => [kind, [] as WidgetKind[]])) as Record<
IntegrationKind,
WidgetKind[]
>;
for (const [widgetKind, widget] of objectEntries(widgetImports)) {
const supported =
"supportedIntegrations" in widget.definition
? (widget.definition.supportedIntegrations as IntegrationKind[])
: [];
for (const kind of supported) {
if (kind in map) {
map[kind].push(widgetKind);
}
}
}
return map;
};
export const widgetsByIntegration = buildWidgetsByIntegration();
export const categoryTranslationKeys: Record<string, string> = { export const categoryTranslationKeys: Record<string, string> = {
dnsHole: "integration.category.dnsHole", dnsHole: "integration.category.dnsHole",
@@ -70,11 +47,11 @@ export interface IntegrationGridItem {
export const buildSortedIntegrations = ( export const buildSortedIntegrations = (
options: { enableMockIntegration?: boolean; onboarding?: boolean } = {}, options: { enableMockIntegration?: boolean; onboarding?: boolean } = {},
): IntegrationGridItem[] => ): IntegrationGridItem[] => {
integrationKinds return integrationKinds
.filter((kind) => { .filter((kind) => {
if (options.onboarding && hiddenFromOnboarding.has(kind)) return false; if (options.onboarding && hiddenFromOnboarding.has(kind)) return false;
if (options.onboarding && widgetsByIntegration[kind].length === 0) return false; if (options.onboarding && getWidgetKindsForIntegration(kind).length === 0) return false;
if (!options.enableMockIntegration && kind === "mock") return false; if (!options.enableMockIntegration && kind === "mock") return false;
return true; return true;
}) })
@@ -82,9 +59,9 @@ export const buildSortedIntegrations = (
kind, kind,
name: getIntegrationName(kind), name: getIntegrationName(kind),
categories: [...new Set(integrationDefs[kind].category.flat())] as string[], categories: [...new Set(integrationDefs[kind].category.flat())] as string[],
widgets: widgetsByIntegration[kind], widgets: getWidgetKindsForIntegration(kind),
})) }))
.sort((left, right) => { .toSorted((left, right) => {
const leftIdx = featuredIntegrations.indexOf(left.kind); const leftIdx = featuredIntegrations.indexOf(left.kind);
const rightIdx = featuredIntegrations.indexOf(right.kind); const rightIdx = featuredIntegrations.indexOf(right.kind);
if (leftIdx !== -1 && rightIdx !== -1) return leftIdx - rightIdx; if (leftIdx !== -1 && rightIdx !== -1) return leftIdx - rightIdx;
@@ -92,6 +69,7 @@ export const buildSortedIntegrations = (
if (rightIdx !== -1) return 1; if (rightIdx !== -1) return 1;
return right.widgets.length - left.widgets.length || left.name.localeCompare(right.name); return right.widgets.length - left.widgets.length || left.name.localeCompare(right.name);
}); });
};
export const filterIntegrations = (items: IntegrationGridItem[], search: string) => export const filterIntegrations = (items: IntegrationGridItem[], search: string) =>
items.filter((i) => i.name.toLowerCase().includes(search.toLowerCase().trim())); items.filter((i) => i.name.toLowerCase().includes(search.toLowerCase().trim()));
+2 -2
View File
@@ -1,7 +1,6 @@
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import { AppShellHeader, Group, UnstyledButton } from "@mantine/core"; import { AppShellHeader, Group, UnstyledButton } from "@mantine/core";
import { Spotlight } from "@homarr/spotlight";
import { Link } from "@homarr/ui"; import { Link } from "@homarr/ui";
import { ClientBurger } from "./header/burger"; import { ClientBurger } from "./header/burger";
@@ -9,6 +8,7 @@ import { DesktopSearchInput, MobileSearchButton } from "./header/search";
import { TourTarget } from "./header/tour-target"; import { TourTarget } from "./header/tour-target";
import { UserButton } from "./header/user"; import { UserButton } from "./header/user";
import { HomarrLogoWithTitle } from "./logo/homarr-logo"; import { HomarrLogoWithTitle } from "./logo/homarr-logo";
import { LazySpotlight } from "./header/lazy-spotlight";
interface Props { interface Props {
logo?: ReactNode; logo?: ReactNode;
@@ -37,7 +37,7 @@ export const MainHeader = ({ logo, actions, hasNavigation = true }: Props) => {
</TourTarget> </TourTarget>
</Group> </Group>
</Group> </Group>
<Spotlight /> <LazySpotlight />
</AppShellHeader> </AppShellHeader>
); );
}; };
@@ -14,6 +14,9 @@ type HeaderButtonProps = (
} }
) & { ) & {
children: ReactNode; children: ReactNode;
onFocus?: () => void;
onPointerDown?: () => void;
onPointerEnter?: () => void;
} & Partial<ActionIconProps>; } & Partial<ActionIconProps>;
const headerButtonActionIconProps: ActionIconProps = { const headerButtonActionIconProps: ActionIconProps = {
@@ -0,0 +1,89 @@
"use client";
import { useEffect, useState } from "react";
import dynamic from "next/dynamic";
import { Center, Loader, Paper } from "@mantine/core";
import { useHotkeys } from "@mantine/hooks";
import { hotkeys } from "@homarr/definitions";
import { hasPendingSpotlightOpen, openSpotlight, spotlightOpenEvent } from "@homarr/spotlight/open";
const loadSpotlight = () => import("@homarr/spotlight/component");
const SpotlightLoading = () => (
<Center
data-homarr-dev-benchmark-spotlight-feedback
pos="fixed"
inset={0}
role="status"
aria-label="Loading search"
style={{ zIndex: 1000, background: "rgb(0 0 0 / 20%)", backdropFilter: "blur(2px)" }}
>
<Paper withBorder radius="md" p="xl" shadow="xl">
<Loader />
</Paper>
</Center>
);
const Spotlight = dynamic(() => loadSpotlight().then(({ Spotlight: Component }) => Component), {
ssr: false,
loading: SpotlightLoading,
});
export const preloadSpotlight = () => void loadSpotlight().catch(() => undefined);
export const LazySpotlight = () => {
const [isMounted, setIsMounted] = useState(false);
const [isPreloaded, setIsPreloaded] = useState(false);
useHotkeys(
isMounted
? []
: [
[
hotkeys.openSpotlight,
() => {
setIsMounted(true);
openSpotlight();
},
],
],
);
useEffect(() => {
let isActive = true;
const mount = () => setIsMounted(true);
window.addEventListener(spotlightOpenEvent, mount);
if (hasPendingSpotlightOpen()) mount();
const preload = () => {
void loadSpotlight().then(
() => {
if (isActive) setIsPreloaded(true);
},
() => undefined,
);
};
let idleId: number | undefined;
const delayId = window.setTimeout(() => {
idleId = window.requestIdleCallback?.(preload, { timeout: 1_000 });
if (idleId === undefined) preload();
}, 1_000);
return () => {
isActive = false;
window.removeEventListener(spotlightOpenEvent, mount);
window.clearTimeout(delayId);
if (idleId !== undefined) window.cancelIdleCallback?.(idleId);
};
}, []);
return isMounted || isPreloaded ? (
<>
{isPreloaded && <span hidden data-homarr-dev-benchmark-spotlight-preloaded />}
{isMounted && (
<>
<span hidden data-homarr-dev-benchmark-spotlight-mounted />
<Spotlight />
</>
)}
</>
) : null;
};
@@ -3,23 +3,28 @@
import { TextInput, UnstyledButton } from "@mantine/core"; import { TextInput, UnstyledButton } from "@mantine/core";
import { IconSearch } from "@tabler/icons-react"; import { IconSearch } from "@tabler/icons-react";
import { openSpotlight } from "@homarr/spotlight"; import { openSpotlight } from "@homarr/spotlight/open";
import { useI18n } from "@homarr/translation/client"; import { useI18n } from "@homarr/translation/client";
import { HeaderButton } from "./button"; import { HeaderButton } from "./button";
import classes from "./search.module.css"; import classes from "./search.module.css";
import { preloadSpotlight } from "./lazy-spotlight";
export const DesktopSearchInput = () => { export const DesktopSearchInput = () => {
const t = useI18n(); const t = useI18n();
return ( return (
<TextInput <TextInput
data-homarr-dev-benchmark-interaction="search"
component={UnstyledButton} component={UnstyledButton}
className={classes.desktopSearch} className={classes.desktopSearch}
w={400} w={400}
size="sm" size="sm"
leftSection={<IconSearch size={20} stroke={1.5} />} leftSection={<IconSearch size={20} stroke={1.5} />}
onClick={openSpotlight} onClick={openSpotlight}
onFocus={preloadSpotlight}
onPointerDown={preloadSpotlight}
onPointerEnter={preloadSpotlight}
radius="xl" radius="xl"
> >
{`${t("search.placeholder")}...`} {`${t("search.placeholder")}...`}
@@ -29,7 +34,14 @@ export const DesktopSearchInput = () => {
export const MobileSearchButton = () => { export const MobileSearchButton = () => {
return ( return (
<HeaderButton onClick={openSpotlight} className={classes.mobileSearch}> <HeaderButton
data-homarr-dev-benchmark-interaction="search"
onClick={openSpotlight}
onFocus={preloadSpotlight}
onPointerDown={preloadSpotlight}
onPointerEnter={preloadSpotlight}
className={classes.mobileSearch}
>
<IconSearch size={20} stroke={1.5} /> <IconSearch size={20} stroke={1.5} />
</HeaderButton> </HeaderButton>
); );
@@ -0,0 +1,71 @@
import type { ReactNode } from "react";
import { act, createElement } from "react";
import { createRoot } from "react-dom/client";
import { MantineProvider } from "@mantine/core";
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
import { TourTarget, TourTargetsProvider } from "./tour-target";
vi.mock("@gfazioli/mantine-onboarding-tour", () => ({
OnboardingTour: {
Target: ({ children }: { children: ReactNode }) => children,
},
}));
const mountedRoots: Array<ReturnType<typeof createRoot>> = [];
beforeAll(() => {
Object.defineProperty(globalThis, "IS_REACT_ACT_ENVIRONMENT", { value: true, writable: true });
Object.defineProperty(window, "matchMedia", {
writable: true,
value: (query: string) => ({
matches: false,
media: query,
onchange: null,
addListener: () => undefined,
removeListener: () => undefined,
addEventListener: () => undefined,
removeEventListener: () => undefined,
dispatchEvent: () => false,
}),
});
});
afterEach(async () => {
await act(async () => {
for (const root of mountedRoots.splice(0)) root.unmount();
});
});
const renderTargetAsync = async (enabled: boolean) => {
const host = document.createElement("div");
const root = createRoot(host);
mountedRoots.push(root);
await act(async () => {
root.render(
createElement(
MantineProvider,
null,
createElement(TourTargetsProvider, {
enabled,
children: createElement(TourTarget, { id: "example", children: createElement("button", null, "Target") }),
}),
),
);
});
return host;
};
describe("TourTarget", () => {
it("keeps a stable route-transition marker while a tour is active", async () => {
const host = await renderTargetAsync(true);
expect(host.querySelector('[data-tour-target="example"] button')?.textContent).toBe("Target");
});
it("adds no wrapper for users without an active tour", async () => {
const host = await renderTargetAsync(false);
expect(host.querySelector("[data-tour-target]")).toBeNull();
expect(host.querySelector("button")?.parentElement).toBe(host);
});
});
@@ -1,11 +1,41 @@
"use client"; "use client";
import type { ReactNode } from "react"; import type { PropsWithChildren, ReactNode } from "react";
import { OnboardingTour } from "@gfazioli/mantine-onboarding-tour"; import { createContext, Suspense, use, useContext } from "react";
import { Box } from "@mantine/core"; import { Box } from "@mantine/core";
export const TourTarget = ({ id, children }: { id: string; children: ReactNode }) => ( const TourTargetsEnabledContext = createContext(false);
<OnboardingTour.Target id={id}> let tourLibraryPromise: Promise<typeof import("@gfazioli/mantine-onboarding-tour")> | undefined;
<Box data-tour-target={id}>{children}</Box>
</OnboardingTour.Target> const loadTourLibrary = () => {
if (tourLibraryPromise) return tourLibraryPromise;
const currentPromise = import("@gfazioli/mantine-onboarding-tour");
tourLibraryPromise = currentPromise;
void currentPromise.catch(() => {
if (tourLibraryPromise === currentPromise) tourLibraryPromise = undefined;
});
return currentPromise;
};
export const TourTargetsProvider = ({ enabled = true, children }: PropsWithChildren<{ enabled?: boolean }>) => (
<TourTargetsEnabledContext value={enabled}>{children}</TourTargetsEnabledContext>
); );
const LoadedTourTarget = ({ id, children }: { id: string; children: ReactNode }) => {
const { OnboardingTour } = use(loadTourLibrary());
return <OnboardingTour.Target id={id}>{children}</OnboardingTour.Target>;
};
export const TourTarget = ({ id, children }: { id: string; children: ReactNode }) => {
const isEnabled = useContext(TourTargetsEnabledContext);
if (!isEnabled) return children;
const target = <Box data-tour-target={id}>{children}</Box>;
return (
<Suspense fallback={target}>
<LoadedTourTarget id={id}>{target}</LoadedTourTarget>
</Suspense>
);
};
@@ -1,7 +1,6 @@
"use client"; "use client";
import type { PropsWithChildren } from "react"; import type { PropsWithChildren } from "react";
import { Suspense, use } from "react";
import { Indicator, Menu, Text } from "@mantine/core"; import { Indicator, Menu, Text } from "@mantine/core";
import { IconBellRinging } from "@tabler/icons-react"; import { IconBellRinging } from "@tabler/icons-react";
@@ -9,67 +8,28 @@ import type { RouterOutputs } from "@homarr/api";
import { useScopedI18n } from "@homarr/translation/client"; import { useScopedI18n } from "@homarr/translation/client";
interface UpdateIndicatorProps extends PropsWithChildren { interface UpdateIndicatorProps extends PropsWithChildren {
availableUpdatesPromise: Promise<RouterOutputs["updateChecker"]["getAvailableUpdates"]> | undefined; availableUpdates: RouterOutputs["updateChecker"]["getAvailableUpdates"] | undefined;
disabled: boolean; disabled: boolean;
} }
export const UpdateIndicator = ({ children, availableUpdatesPromise, disabled }: UpdateIndicatorProps) => { export const UpdateIndicator = ({ children, availableUpdates, disabled }: UpdateIndicatorProps) => {
if (disabled || availableUpdatesPromise === undefined) { if (disabled || availableUpdates === undefined) {
return children; return children;
} }
return ( return (
<Suspense fallback={children}> <Indicator disabled={availableUpdates.length === 0} size={15} processing withBorder>
<InnerUpdateIndicator availableUpdatesPromise={availableUpdatesPromise} disabled={disabled}>
{children}
</InnerUpdateIndicator>
</Suspense>
);
};
interface InnerUpdateIndicatorProps extends PropsWithChildren {
availableUpdatesPromise: Promise<RouterOutputs["updateChecker"]["getAvailableUpdates"]>;
disabled: boolean;
}
const InnerUpdateIndicator = ({ children, disabled, availableUpdatesPromise }: InnerUpdateIndicatorProps) => {
const availableUpdates = use(availableUpdatesPromise);
return (
<Indicator
disabled={!availableUpdates || availableUpdates.length === 0 || disabled}
size={15}
processing
withBorder
>
{children} {children}
</Indicator> </Indicator>
); );
}; };
interface AvailableUpdatesMenuItemProps { interface AvailableUpdatesMenuItemProps {
availableUpdatesPromise: Promise<RouterOutputs["updateChecker"]["getAvailableUpdates"]> | undefined; availableUpdates: RouterOutputs["updateChecker"]["getAvailableUpdates"] | undefined;
} }
export const AvailableUpdatesMenuItem = ({ availableUpdatesPromise }: AvailableUpdatesMenuItemProps) => { export const AvailableUpdatesMenuItem = ({ availableUpdates }: AvailableUpdatesMenuItemProps) => {
if (availableUpdatesPromise === undefined) {
return null;
}
return (
<Suspense fallback={null}>
<InnerAvailableUpdatesMenuItem availableUpdatesPromise={availableUpdatesPromise} />
</Suspense>
);
};
interface InnerAvailableUpdatesMenuItemProps {
availableUpdatesPromise: Promise<RouterOutputs["updateChecker"]["getAvailableUpdates"]>;
}
const InnerAvailableUpdatesMenuItem = ({ availableUpdatesPromise }: InnerAvailableUpdatesMenuItemProps) => {
const t = useScopedI18n("common.userAvatar.menu"); const t = useScopedI18n("common.userAvatar.menu");
const availableUpdates = use(availableUpdatesPromise);
if (availableUpdates === undefined || availableUpdates.length === 0) { if (availableUpdates === undefined || availableUpdates.length === 0) {
return null; return null;
} }
@@ -0,0 +1,125 @@
"use client";
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 { 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;
isDockerEnabled: boolean;
}
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]]);
const isCurrentSessionAdmin =
isAdmin && session.status === "authenticated" && session.data.user.permissions.includes("admin");
useEffect(() => {
if (!isCurrentSessionAdmin) {
setCanCheckForUpdates(false);
return;
}
const timeout = window.setTimeout(() => setCanCheckForUpdates(true), 2_000);
return () => window.clearTimeout(timeout);
}, [isCurrentSessionAdmin]);
const { data: availableUpdates } = clientApi.updateChecker.getAvailableUpdates.useQuery(undefined, {
enabled: canCheckForUpdates,
refetchOnWindowFocus: false,
retry: false,
staleTime: 60 * 60 * 1_000,
});
const visibleUpdates = isCurrentSessionAdmin ? availableUpdates : undefined;
const openMenu = () => {
setIsMenuMounted(true);
setIsMenuOpen(true);
};
const renderButton = (onClick: () => void, isLoading = false) => (
<UnstyledButton
onClick={onClick}
onFocus={preloadUserAvatarMenu}
onMouseEnter={preloadUserAvatarMenu}
onPointerDown={preloadUserAvatarMenu}
aria-busy={isLoading || undefined}
disabled={isLoading}
>
<UpdateIndicator availableUpdates={visibleUpdates} disabled={!isCurrentSessionAdmin}>
<Box pos="relative" style={{ display: "inline-flex" }}>
<Box opacity={isLoading ? 0.35 : 1}>{avatar}</Box>
{isLoading && (
<Loader
aria-label="Loading user menu"
size={18}
pos="absolute"
top="50%"
left="50%"
style={{ transform: "translate(-50%, -50%)" }}
/>
)}
</Box>
</UpdateIndicator>
</UnstyledButton>
);
const button = renderButton(openMenu);
const loadingButton = renderButton(() => undefined, true);
if (!isMenuMounted) return button;
return (
<ErrorBoundary
fallbackRender={({ resetErrorBoundary }) =>
renderButton(() => {
resetErrorBoundary();
openMenu();
})
}
>
<Suspense fallback={loadingButton}>
<LazyUserAvatarMenu
availableUpdates={visibleUpdates}
isDockerEnabled={isDockerEnabled}
opened={isMenuOpen}
onOpenChange={setIsMenuOpen}
>
{button}
</LazyUserAvatarMenu>
</Suspense>
</ErrorBoundary>
);
};
@@ -1,28 +1,14 @@
import { Suspense } from "react";
import { UnstyledButton } from "@mantine/core";
import { api } from "@homarr/api/server";
import { auth } from "@homarr/auth/next"; import { auth } from "@homarr/auth/next";
import { env } from "@homarr/docker/env"; import { env } from "@homarr/docker/env";
import { CurrentUserAvatar } from "~/components/user-avatar"; import { CurrentUserAvatar } from "~/components/user-avatar";
import { UserAvatarMenu } from "~/components/user-avatar-menu"; import { UserButtonClient } from "./user-client";
import { UpdateIndicator } from "./update";
export const UserButton = async () => { export const UserButton = async () => {
const session = await auth(); const session = await auth();
const isAdmin = session?.user.permissions.includes("admin"); const isAdmin = Boolean(session?.user.permissions.includes("admin"));
const isDockerEnabled = isAdmin && env.ENABLE_DOCKER; const isDockerEnabled = isAdmin && env.ENABLE_DOCKER;
const availableUpdatesPromise = isAdmin ? api.updateChecker.getAvailableUpdates() : undefined;
return ( return (
<UserAvatarMenu availableUpdatesPromise={availableUpdatesPromise} isDockerEnabled={isDockerEnabled}> <UserButtonClient avatar={<CurrentUserAvatar size="md" />} isAdmin={isAdmin} isDockerEnabled={isDockerEnabled} />
<UnstyledButton>
<Suspense fallback={<CurrentUserAvatar size="md" />}>
<UpdateIndicator availableUpdatesPromise={availableUpdatesPromise} disabled={!isAdmin}>
<CurrentUserAvatar size="md" />
</UpdateIndicator>
</Suspense>
</UnstyledButton>
</UserAvatarMenu>
); );
}; };
@@ -1,20 +1,19 @@
"use client"; "use client";
import type { ReactNode } from "react"; import type { ReactElement, ReactNode } from "react";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { usePathname } from "next/navigation"; import { usePathname } from "next/navigation";
import { OnboardingTour } from "@gfazioli/mantine-onboarding-tour";
import { NavLink } from "@mantine/core"; import { NavLink } from "@mantine/core";
import { Link } from "@homarr/ui"; import { Link } from "@homarr/ui";
import { TourTarget } from "./header/tour-target";
export const CommonNavLink = (props: ClientNavigationLink) => export const CommonNavLink = (props: ClientNavigationLink) =>
"href" in props ? <NavLinkHref {...props} /> : <NavLinkWithItems {...props} />; "href" in props ? <NavLinkHref {...props} /> : <NavLinkWithItems {...props} />;
const TourTarget = ({ id, children }: { id?: string; children: ReactNode }) => { const withOptionalTourTarget = (id: string | undefined, children: ReactElement) =>
if (!id) return <>{children}</>; id ? <TourTarget id={id}>{children}</TourTarget> : children;
return <OnboardingTour.Target id={id}>{children}</OnboardingTour.Target>;
};
const pathMatches = (pathname: string, href: string) => pathname === href || pathname.startsWith(`${href}/`); const pathMatches = (pathname: string, href: string) => pathname === href || pathname.startsWith(`${href}/`);
@@ -43,7 +42,7 @@ const NavLinkHref = (props: NavigationLinkHref) => {
active={isClient && pathMatches(pathname, props.href)} active={isClient && pathMatches(pathname, props.href)}
/> />
); );
return <TourTarget id={tourId}>{link}</TourTarget>; return withOptionalTourTarget(tourId, link);
}; };
const NavLinkWithItems = (props: NavigationLinkWithItems) => { const NavLinkWithItems = (props: NavigationLinkWithItems) => {
@@ -56,7 +55,7 @@ const NavLinkWithItems = (props: NavigationLinkWithItems) => {
))} ))}
</NavLink> </NavLink>
); );
return <TourTarget id={props["data-onboarding-tour-id"]}>{nav}</TourTarget>; return withOptionalTourTarget(props["data-onboarding-tour-id"], nav);
}; };
interface CommonNavigationLinkProps { interface CommonNavigationLinkProps {
Loaded 100 of 423 files, more files were not shown because too many files have changed in this diff. Show more