Compare commits

..
Author SHA1 Message Date
ajnart 1735aeb139 style: format merged dev files 2026-09-10 19:58:44 +02:00
ajnart 92a7a83e92 chore: retain evaluation artifact ignore 2026-09-10 19:56:05 +02:00
ajnart 0b8e2e0927 merge: sync v2 stack base with dev 2026-09-10 19:53:57 +02:00
Thomas 1fbe844598 fix(settings): move board access control out of the nested form (#6662) 2026-09-10 18:13:56 +02:00
Thomas 365ef7f02b Revert "feat: announce Homarr v2 beta" (#6813) 2026-09-10 16:36:54 +02:00
homarr-releases[bot] 2b1a03849d chore: update bug report template (#6798) 2026-09-06 22:31:26 +02:00
Releases Homarr 4c243bc374 chore(release): 1.77.0 [skip ci] 2026-09-06 19:31:37 +00:00
homarr-releases[bot] ecd02f73c0 chore(release): automatic release v1.77.0 2026-09-06 19:30:29 +00:00
Thomas cd23b36f3c fix(ci): use pinned pnpm for releases (#6796) 2026-09-06 21:18:30 +02:00
homarr-releases[bot] f7f29eade9 chore(release): automatic release v1.77.0 2026-09-06 18:47:01 +00:00
Thomas 32a51c1e60 feat: announce Homarr v2 beta (#6793)
* feat: announce Homarr v2 beta

* chore: refine v2 beta announcement

* feat: expand v2 beta guidance

* chore: refine v2 beta resources

* chore: simplify v2 beta modal

* chore: improve v2 beta feedback links

* fix: keep v2 modal above header

* chore: sharpen v2 beta message

* fix: clear header for v2 modal
2026-09-06 20:36:04 +02:00
homarr-crowdin[bot] da32acb4f0 chore(lang): updated translations from crowdin
* chore(lang): update translations from crowdin

* chore(lang): update translations from crowdin

* chore(lang): update translations from crowdin

---------

Co-authored-by: Crowdin Homarr <190541745+homarr-crowdin[bot]@users.noreply.github.com>
2026-09-06 01:52:04 +00:00
homarr-update-contributors[bot] 1da13d9bb3 chore: update contributors (#6767)
Co-authored-by: Homarr Update Contributors <210161987+homarr-update-contributors[bot]@users.noreply.github.com>
2026-09-04 15:58:09 +00:00
homarr-crowdin[bot] cb0fec088f chore(lang): updated translations from crowdin
* chore(lang): update translations from crowdin

* chore(lang): update translations from crowdin

---------

Co-authored-by: Crowdin Homarr <190541745+homarr-crowdin[bot]@users.noreply.github.com>
2026-09-03 02:05:21 +00:00
homarr-crowdin[bot] a2fe8dd461 chore(lang): updated translations from crowdin
Co-authored-by: Crowdin Homarr <190541745+homarr-crowdin[bot]@users.noreply.github.com>
2026-09-01 02:36:36 +00:00
homarr-update-contributors[bot] b6dfcd69ba chore: update contributors (#6735)
Co-authored-by: Homarr Update Contributors <210161987+homarr-update-contributors[bot]@users.noreply.github.com>
2026-08-28 21:54:00 +00:00
homarr-crowdin[bot]andThomas a38b0ef71d chore(lang): updated translations from crowdin (#6732)
Co-authored-by: Thomas <thomas@ajnart.fr>
2026-08-28 14:07:42 +02:00
Thomas 2cf3027eeb Merge pull request #6720 from homarr-labs/docs/update-helm-docs
docs(helm): update chart installation page
2026-08-28 08:32:40 +02:00
homarr-releases[bot] fa18f6fc71 chore: update bug report template (#6724) 2026-08-28 08:32:03 +02:00
Releases Homarr 331925c197 chore(release): 1.76.2 [skip ci] 2026-08-27 20:09:22 +00:00
Thomas 0e51f745d3 Merge pull request #6723 from homarr-labs/dev
chore(release): automatic release v1.76.2
2026-08-27 22:08:05 +02:00
Thomas d6f720d0e6 Revert "feat(http): set custom User-Agent on outbound integration requests (#6606)" (#6722)
This reverts commit ddbf05524b.
2026-08-27 22:00:23 +02:00
Thomas 5324bf12d4 fix(startup): use stable internal service addresses (#6721) 2026-08-27 21:52:54 +02:00
github-actions[bot] 2922a02ecf docs(helm): update chart installation page 2026-08-27 17:09:27 +00:00
Thomas 87cdcf28e0 fix(mcp): return valid string for void tool results (#6703) 2026-08-27 11:10:37 +02:00
homarr-releases[bot] 441b181e79 chore: update bug report template (#6716)
Co-authored-by: Releases Homarr <175486441+homarr-releases[bot]@users.noreply.github.com>
2026-08-27 11:08:43 +02:00
Releases Homarr eba639b59c chore(release): 1.76.1 [skip ci] 2026-08-26 16:43:52 +00:00
Thomas Camlong da36283ad1 chore(release): retrigger release pipeline 2026-08-26 18:30:12 +02:00
Thomas Camlong c050368782 chore(release): trigger release pipeline 2026-08-26 18:16:26 +02:00
homarr-releases[bot] 82b50f0fd7 chore(release): automatic release v1.76.1 2026-08-26 15:50:55 +00:00
Thomas a0149d5894 Merge pull request #6673 from homarr-labs/investigate-6672-immich-401 2026-08-22 19:44:03 +02:00
ajnart 8579fcbfc2 test(http,immich): address CodeRabbit review
- guarantee server teardown via try/finally in both wire-level tests
- add tuple-array HeadersInit coverage to the helper suite
2026-08-22 17:21:38 +02:00
ajnart b6f3aba23e test(http,immich): prove User-Agent fix at the wire level
Add two regression tests that fail on v1.76.0 and pass with the fix:
- wire-level test: a Headers instance (the shape @immich/sdk hands over)
  must reach the remote server with all headers intact
- real-SDK e2e: getServerStatistics through fetchWithTrustedCertificatesAsync
  against a live local server must deliver x-api-key
2026-08-22 17:16:31 +02:00
ajnart 31e218295d fix(http): preserve SDK headers when injecting User-Agent
The v1.76.0 User-Agent injection object-spread `fetchOptions.headers`, which
silently drops all headers when the value is a Headers instance. The Immich
SDK passes a Headers object with the merged x-api-key header, so requests
lost their API key and returned 401 (issue #6672). Build a real Headers
instance instead, keeping case-insensitive user-agent handling.
2026-08-22 17:12:43 +02:00
Thomas aca3b5d861 fix: correct Unraid CPU, link context menus, and board resizing (#6510)
Fix Unraid logical CPU averaging, preserve native context menus for app and bookmark widgets in view mode, and prevent scrollbar-width GridStack resize oscillation.
2026-08-22 10:53:26 +02:00
homarr-crowdin[bot] a7a2f0223a chore(lang): updated translations from crowdin (#6596)
Co-authored-by: Crowdin Homarr <190541745+homarr-crowdin[bot]@users.noreply.github.com>
2026-08-22 08:49:51 +02:00
homarr-releases[bot] 7773fdc10b chore: update bug report template (#6655) 2026-08-21 22:14:45 +02:00
Thomas 7019a1d0a4 Merge pull request #6656 from homarr-labs/docs/update-helm-docs 2026-08-21 22:14:26 +02:00
github-actions[bot] 2d535caeac docs(helm): update chart installation page 2026-08-21 17:41:09 +00:00
Releases Homarr 2013fbe80d chore(release): 1.76.0 [skip ci] 2026-08-21 17:20:50 +00:00
homarr-releases[bot] 219ef58926 chore(release): automatic release v1.76.0 2026-08-21 17:19:40 +00:00
homarr-update-contributors[bot] 97195bc463 chore: update contributors (#6649)
Co-authored-by: Homarr Update Contributors <210161987+homarr-update-contributors[bot]@users.noreply.github.com>
2026-08-21 12:53:40 +00:00
Danura30082 73a742154e docs: Proxmox certificate obtention instructions (#6613) 2026-08-20 18:51:58 +02:00
Afonso Jorge Ramos 993a267e0c fix(truenas): keep reporting pools without usable dataset space (#6635) 2026-08-20 15:37:10 +02:00
Thomas f1c49d1fbf fix(startup): only chown runtime paths for custom PUID/PGID (#6598) 2026-08-19 08:29:17 +02:00
Thomas 95c66c61eb fix(widgets): stop Health Monitoring system tab firing NOT_FOUND when Proxmox is mixed in (#6623) 2026-08-18 19:56:47 +02:00
Thomas ec2f8c6c2c fix(widgets): match OMV disk temperature via normalized partition names (#6615) (#6619)
OMV reports filesystem devices (sda1) without the /dev/ prefix, so the
partition-suffix normalization never applied and filesystem-to-SMART
matching returned no temperature. Strip partition suffixes from
unprefixed sd/vd, nvme and mmcblk names, and use the shared normalized
matcher in both the health monitoring and system disks widgets.
2026-08-18 19:39:27 +02:00
Thomas 05ba57c2fa chore: migrate cursor rules to portable agent skills + mantine MCP guidance
* chore: migrate cursor rules to portable agent skills and add mantine MCP guidance

Replace Cursor-only .cursor/rules/*.mdc with Agent Skills open-standard
SKILL.md files in .agents/skills/ (canonical), mirrored to .cursor/skills/
and .claude/skills/ so they work across Claude Code, Cursor, Codex, and
OpenCode. Add mantine MCP server guidance to AGENTS.md and un-ignore
.agents/skills/ in .gitignore.

* refactor: single-source agent skills at .agents/skills

Cursor, Codex, and OpenCode all read .agents/skills/ natively, so the
.cursor/skills/ mirror was redundant. Claude Code reads .claude/skills/,
which is now a symlink back to .agents/skills/ so the content lives once.
Deletes .cursor/skills/ and converts .claude/skills/ to a symlink.

* chore: ship mantine MCP server via project-scoped .mcp.json

Declares the keyless @mantine/mcp-server in .mcp.json (Claude Code
project-scope standard, committable) and documents MCP server setup and
the mantine entry in AGENTS.md.

* chore: address coderabbit security-review and markdown lint findings

Add explicit security-review guidance before exposing MCP tools and label
plain-text code fences to satisfy markdownlint MD040.
2026-08-16 16:27:51 +02:00
Thomas ddbf05524b feat(http): set custom User-Agent on outbound integration requests (#6606)
* feat(http): set custom User-Agent on outbound integration requests

Set a default 'User-Agent: Homarr/<version> (+https://homarr.dev)' header
on all outbound HTTP requests made via fetchWithTrustedCertificatesAsync,
so Homarr's integration traffic is distinguishable in reverse-proxy,
WAF, and security-monitoring logs instead of appearing as generic undici.

Closes #6604

* refactor(version): centralize Homarr version in @homarr/version package

Replace fragile deep-relative 'package.json' imports across the monorepo
with a single source of truth: the new @homarr/version leaf package reads
the version once from the root package.json and exports it as VERSION.

Consumers now import by package name incl. the new User-Agent header in
fetchWithTrustedCertificatesAsync (packages/core):
  - core, api (info router), analytics, request-handler (update checker)
This also removes all manually-maintained relative version imports.

* fix(http): normalize User-Agent injection with Headers

Object-spreading fetchOptions.headers breaks when callers pass a Headers
instance (spread yields no entries) or a tuple array (numeric keys), and is
case-sensitive. Build a Headers instance and set the default User-Agent only
when no case-insensitive User-Agent entry exists, via a shared helper in both
fetch paths.

Adds unit tests covering record, Headers, tuple-array, and casing variants.

* revert(version): undo version refactor and tests, keep single-file change

Keep the User-Agent change as a single edit to
packages/core/src/infrastructure/http/request.ts. Remove the @homarr/version
package, the consumer migrations, and the unit tests.
2026-08-16 16:09:15 +02:00
Thomas df438b4cd2 Merge pull request #6571 from homarr-labs/feat/fix-home-assistant-actions 2026-08-16 00:23:14 +02:00
Thomas a1f91eb385 Merge pull request #6567 from homarr-labs/feat/fix-truenas-free-space 2026-08-15 20:20:35 +02:00
Thomas 8d5ffb2dd9 feat(paseo): add worktree install script (#6599)
* feat(paseo): add worktree install script

* feat(paseo): inline worktree setup in paseo.json

* feat(paseo): add scripts and teardown to paseo.json

* feat(paseo): expand workspace scripts catalog

* feat(paseo): reset encryption key in worktree env
2026-08-15 12:29:52 +02:00
Thomas Camlong 56e6b23af6 test(e2e): remove flaky credentials onboarding test 2026-08-15 11:05:06 +02:00
Thomas Camlong 5488bf4d18 fix(truenas): use lowercase in operator for dataset query 2026-08-15 01:33:20 +02:00
homarr-crowdin[bot] 621ea07549 chore(lang): updated translations from crowdin (#6553) 2026-08-14 20:39:02 +02:00
homarr-update-contributors[bot] f64c01456b chore: update contributors (#6588) 2026-08-14 20:38:42 +02:00
homarr-releases[bot] 56198c3f02 chore: update bug report template (#6591) 2026-08-14 20:38:26 +02:00
Thomas ca1bb6f341 Merge pull request #6592 from homarr-labs/docs/update-helm-docs
docs(helm): update chart installation page
2026-08-14 20:38:10 +02:00
github-actions[bot] bf6b6663cd docs(helm): update chart installation page 2026-08-14 18:25:32 +00:00
Releases Homarr 5c2d86767f chore(release): 1.75.0 [skip ci] 2026-08-14 17:45:24 +00:00
homarr-releases[bot] 7c75642d4d chore(release): automatic release v1.75.0 2026-08-14 17:43:56 +00:00
Thomas Camlong 7652eb8c1c preserve smart home toggle response 2026-08-13 17:04:00 +02:00
Thomas Camlong e761c10f32 fix Home Assistant widget actions 2026-08-13 16:21:48 +02:00
ajnart f6137b2dcc fix(truenas): guard empty dataset percentage 2026-08-12 18:24:33 +02:00
ajnart 425dd90871 fix(truenas): report usable dataset space 2026-08-12 18:19:07 +02:00
259 changed files with 28842 additions and 45417 deletions

No files matched your search

@@ -1,6 +1,6 @@
---
description: Comprehensive Homarr codebase architecture and conventions reference
alwaysApply: true
name: codebase-context
description: Comprehensive Homarr codebase architecture and conventions reference. Use when orienting in the monorepo, understanding package dependency layers, the three runtime services and ports, tRPC router structure, multi-database support, widget/integration/cron system architecture, routing, auth providers, env vars, or key patterns before writing code.
---
# Homarr Codebase Context
@@ -27,7 +27,7 @@ Homarr is an open-source, self-hosted dashboard for managing homelab services. I
## Repository Structure
```
```text
homarr/
├── apps/
│ ├── nextjs/ # Main Next.js application (port 3000)
@@ -66,8 +66,6 @@ homarr/
│ ├── analytics/ # Server-side analytics (Umami)
│ ├── server-settings/ # Server setting keys/types
│ ├── settings/ # User-facing settings UI context
│ ├── old-import/ # Legacy Homarr import
│ ├── old-schema/ # Legacy Homarr zod schemas
│ └── cli/ # Node CLI for ops (brocli)
├── tooling/
│ ├── typescript/ # Base tsconfig
@@ -79,11 +77,11 @@ homarr/
## Three Runtime Services
| Service | Port | Role |
|---------|------|------|
| Next.js | 3000 | Main web app (SSR + API routes) |
| WebSocket | 3001 | tRPC subscriptions via `ws` |
| Tasks | 3002 | Cron job runner + management API via Fastify |
| Service | Port | Role |
| --------- | ---- | -------------------------------------------- |
| Next.js | 3000 | Main web app (SSR + API routes) |
| WebSocket | 3001 | tRPC subscriptions via `ws` |
| Tasks | 3002 | Cron job runner + management API via Fastify |
In Docker, nginx listens on **7575** and proxies to all three. The tasks service authenticates via `CRON_JOB_API_KEY` header.
@@ -112,6 +110,7 @@ In Docker, nginx listens on **7575** and proxies to all three. The tasks service
### Multi-Database Support
Drizzle schemas exist in three parallel implementations:
- `packages/db/schema/sqlite.ts`
- `packages/db/schema/mysql.ts`
- `packages/db/schema/postgresql.ts`
@@ -121,6 +120,7 @@ Selected at runtime via `DB_DRIVER` env (`better-sqlite3` | `mysql2` | `node-pos
### Widget System
Widgets are registered in `packages/widgets/src/index.tsx` as `widgetImports` (must satisfy `Record<WidgetKind, ...>`). Each widget:
- Has a `definition` created via `createWidgetDefinition(kind, { icon, createOptions, supportedIntegrations? })`
- Uses `optionsBuilder.from(factory => ({ ... }))` for typed options
- Loads dynamically via `next/dynamic`
@@ -133,6 +133,7 @@ Integrations are defined in `packages/definitions/src/integration.ts` (`integrat
### Cron Job System
Jobs defined in `packages/cron-jobs/src/jobs/`. Each job:
- Uses `createCronJob` with a cron expression
- Publishes results to Redis channels
- Widget subscriptions consume those channels via tRPC subscriptions
@@ -160,6 +161,7 @@ Configured via `AUTH_PROVIDERS` env. Supports: `credentials` (local), `ldap`, `o
## Environment Variables
Key env vars (from `.env.example`):
- `DB_DRIVER`: `better-sqlite3` | `mysql2` | `node-postgres`
- `DB_URL`: Database connection string
- `AUTH_SECRET`: NextAuth secret
@@ -191,16 +193,3 @@ Key env vars (from `.env.example`):
- Drag-and-drop via `@dnd-kit/*`
- `typescript.ignoreBuildErrors: true` in next.config (types checked separately via `typecheck`)
- `serverExternalPackages`: `dockerode`, `isomorphic-dompurify`, `jsdom`
## Documentation (`apps/docs`)
Docusaurus 3 site at `apps/docs/`, package name `@homarr/docs`.
- Run with `pnpm dev:docs` from root
- Build with `pnpm turbo build --filter=@homarr/docs`
- Uses Tailwind CSS 3 for custom pages (not Mantine)
- Imports `@homarr/definitions` for type-safe integration/widget metadata
- When adding or modifying widgets, integrations, cron jobs, API routes, env vars, or user-facing features, you MUST also update corresponding docs in `apps/docs/docs/`
- Integration docs: `apps/docs/docs/integrations/<slug>/index.mdx` + `index.ts`
- Widget docs: `apps/docs/docs/widgets/<slug>/index.mdx` + `index.ts`
- Strict link checking: broken links/anchors cause build failure
@@ -1,3 +1,8 @@
---
name: datatable-migration
description: Playbook for migrating tables from mantine-react-table (MRT) to mantine-datatable. Use when converting any table component from mantine-react-table to mantine-datatable, working on DataTable column/sort/expansion/context-menu code, or implementing responsive column visibility, column persistence, or transparent tables in widgets.
---
# 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.
@@ -7,10 +12,12 @@ This document captures every pattern, fix, and decision from the downloads widge
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`
@@ -19,12 +26,12 @@ Current `mantine-react-table` consumers that could be migrated:
- `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`
---
- `packages/ui/src/hooks/use-translated-mantine-react-table.ts`
## 1. Core Migration: mantine-react-table → mantine-datatable
@@ -67,6 +74,7 @@ const columns: DataTableColumn<MyRow>[] = [
```
Key differences:
- `accessorKey` → `accessor`
- `header` → `title`
- `size` → `width`
@@ -118,8 +126,6 @@ const sortedData = useMemo(() => {
<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.
@@ -178,8 +184,6 @@ Create a CSS file alongside the component:
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.
@@ -195,11 +199,38 @@ interface SizeConfig {
}
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 } },
{
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 };
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) {
@@ -230,8 +261,6 @@ function shouldIncludeColumn(accessor: string, ctx: ColumnContext): boolean {
}
```
---
## 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.
@@ -263,15 +292,6 @@ Even hidden options need translation keys or the translation test fails:
"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:
@@ -300,10 +320,15 @@ useEffect(() => {
if (savedOrder.length > 0) setColumnsOrder(savedOrder);
if (Object.keys(savedWidths).length > 0) {
setMultipleColumnWidths(
Object.entries(savedWidths).map(([accessor, w]) => ({ accessor, width: w })),
Object.entries(savedWidths).map(([accessor, w]) => ({
accessor,
width: w,
})),
);
}
requestAnimationFrame(() => { hydrated.current = true; });
requestAnimationFrame(() => {
hydrated.current = true;
});
}, [storeKey, savedOrder, savedWidths, setColumnsOrder, setMultipleColumnWidths]);
```
@@ -330,9 +355,9 @@ useEffect(() => {
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);
const v = entry[key as keyof typeof entry];
if (typeof v === "number") widthMap[key] = v;
else if (typeof v === "string" && v.endsWith("px")) widthMap[key] = parseInt(v, 10);
}
persistOption({ columnWidths: JSON.stringify(widthMap) });
}
@@ -341,8 +366,6 @@ useEffect(() => {
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:
@@ -379,10 +402,24 @@ function RowContextMenu({ state, onClose }: { state: ContextMenuState; onClose:
<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 }} />
<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.Item
onClick={() => {
/* action */ onClose();
}}
>
Action
</Menu.Item>
</Menu.Dropdown>
</Menu>
</Portal>
@@ -392,8 +429,6 @@ function RowContextMenu({ state, onClose }: { state: ContextMenuState; onClose:
**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
@@ -413,8 +448,6 @@ function RowContextMenu({ state, onClose }: { state: ContextMenuState; onClose:
**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
@@ -475,8 +508,11 @@ 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; }
try {
return JSON.parse(value) as T;
} catch {
return fallback;
}
}
```
@@ -494,20 +530,19 @@ If the widget has configurable default sort, sync it:
```typescript
useEffect(() => {
setSortStatus({ columnAccessor: options.defaultSort, direction: defaultSortDirection });
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`
@@ -518,12 +553,14 @@ useEffect(() => {
```typescript
const mutationOptions = {
onSettled: () => void utils.myQuery.invalidate(),
onError: () => showErrorNotification({ title: t("errors.actionFailed"), message: t("errors.actionFailedMessage") }),
onError: () =>
showErrorNotification({
title: t("errors.actionFailed"),
message: t("errors.actionFailedMessage"),
}),
};
```
---
## 10. i18n
- No hardcoded strings. Every user-visible string goes through `useScopedI18n`
@@ -534,8 +571,6 @@ const mutationOptions = {
- 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:
@@ -550,8 +585,6 @@ if (isEditMode) rowContextMenuHandler = undefined;
<DataTable style={{ pointerEvents: tablePointerEvents }} />
```
---
## 12. DataTable Props Reference (Commonly Used)
```typescript
@@ -590,8 +623,6 @@ if (isEditMode) rowContextMenuHandler = undefined;
/>
```
---
## 13. Layout Stability
- Don't use `table-layout: fixed` — it fights mantine-datatable's resize logic
@@ -600,8 +631,6 @@ if (isEditMode) rowContextMenuHandler = undefined;
- 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`)
@@ -610,13 +639,12 @@ if (isEditMode) rowContextMenuHandler = undefined;
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
7. Add translation keys (including hidden option labels)
8. Add context menu if row actions exist
9. Add row expansion if detail view exists
10. Add error handling on all mutations
11. Add accessibility attributes
12. Test at multiple widget widths
13. Update docs in `apps/docs/docs/widgets/` if applicable
14. Run `pnpm turbo typecheck`
15. Run translation spec — catches missing keys
@@ -0,0 +1,44 @@
---
name: documentation-sync
description: Keep Homarr documentation in sync with code changes. Use when modifying code that affects user-facing behavior, or when adding/modifying widgets, integrations, cron jobs, API routes, env vars, permissions, auth providers, or UI in apps/docs. Covers the docs/ mapping table, integration doc pattern, and widget doc pattern.
---
# Documentation Sync
When modifying code that affects user-facing behavior, you MUST also update the documentation in `apps/docs/`.
## What triggers a doc update
| Code change | Doc location |
| ---------------------- | ----------------------------------------------------------- |
| New integration | `apps/docs/docs/integrations/<slug>/index.mdx` + `index.ts` |
| New widget | `apps/docs/docs/widgets/<slug>/index.mdx` + `index.ts` |
| Changed API surface | `apps/docs/docs/management/api.mdx` |
| New/changed env vars | `apps/docs/docs/advanced/` |
| New CLI commands | `apps/docs/docs/advanced/command-line/` |
| Changed auth providers | `apps/docs/docs/advanced/` SSO pages |
| UI/UX changes | Relevant getting-started or management pages |
| New cron job | `apps/docs/docs/management/tasks.mdx` |
| Changed permissions | `apps/docs/docs/management/users.mdx` |
## Integration doc pattern
Each integration has a folder under `apps/docs/docs/integrations/<slug>/`:
- `index.ts` — exports a typed `IntegrationDefinition` with name, description, iconUrl, path
- `index.mdx` — uses shared components: `IntegrationHeader`, `IntegrationCapabilites`, `IntegrationSecrets`
- Import metadata from `@homarr/definitions` where possible
## Widget doc pattern
Each widget has a folder under `apps/docs/docs/widgets/<slug>/`:
- `index.ts` — exports a typed `WidgetDefinition` with icon, name, description, path, configuration
- `index.mdx` — uses shared components: `WidgetHeader`, `WidgetConfig`, `WidgetAdding`
## Development
- Run docs locally: `pnpm dev:docs` from root
- Build docs: `pnpm turbo build --filter=@homarr/docs`
- The docs app can import from `@homarr/definitions` for type-safe integration/widget metadata
- Docusaurus strict mode throws on broken links — fix any broken references before committing
@@ -1,13 +1,14 @@
---
description: How to expose tRPC procedures as MCP tools for AI clients
globs: packages/api/src/router/**/*.ts
alwaysApply: false
name: mcp-integration
description: Expose tRPC procedures as MCP tools for AI clients. Use when modifying procedures in packages/api/src/router, adding a new tRPC procedure or router, or deciding what to expose via Homarr's MCP interface. Covers .meta({ mcp }), router registration in packages/api/src/mcp.ts, input schema rules, and what to expose vs skip.
---
# MCP Integration Guide
# MCP Integration
Every new tRPC procedure that provides useful functionality should be exposed as an MCP tool so AI assistants (Claude, Cursor, etc.) can use it. This is a first-class feature of Homarr.
Exposing a procedure makes it callable by any AI client with an API key. Before adding `.meta({ mcp })`, review what the tool exposes: authorization requirements, sensitive data, tenant isolation, auditability, and whether the action is destructive. Prefer exposing read-only queries over mutations unless the mutation is safe and permission-scoped. When in doubt, ask for a security review before shipping a new MCP tool.
## Adding MCP to a procedure
Add `.meta({ mcp: { enabled: true, description: "..." } })` to the procedure chain:
@@ -16,7 +17,7 @@ Add `.meta({ mcp: { enabled: true, description: "..." } })` to the procedure cha
// Read-only tool (query)
myProcedure: protectedProcedure
.meta({ mcp: { enabled: true, description: "Get all items with their status and metadata" } })
.input(z.object({ limit: z.number().default(50) }))
.input(z.object({ limit: z.number().default(10) }))
.query(async ({ ctx, input }) => { ... })
// Write tool (mutation)
@@ -38,23 +39,27 @@ The `.meta()` call must come **before** `.input()` and `.query()`/`.mutation()`
The description is the **only thing** an AI sees to decide when and how to use the tool. Write it like you're explaining to a colleague who has never used Homarr.
**Must include:**
- What the tool returns or does
- Which integrations/services it works with (if applicable)
- How to get required IDs (e.g., "Use integration_all to get the integrationId")
- Permission requirements (if non-obvious)
**Good:**
```
```text
"Get calendar events for upcoming and recent media releases. Fetches from all connected Sonarr (TV), Radarr (movies), Lidarr (music), and Readarr (books) integrations. Requires integrationIds from integration_all"
```
**Bad:**
```
```text
"Get calendar events"
```
**For tools that return permission fields**, explain what they mean:
```
```text
"List all integrations. Returns permissions.hasUseAccess (read) and permissions.hasInteractAccess (actions) — false means the API key lacks that permission, not an error"
```
@@ -77,11 +82,13 @@ export const mcpRouter = createTRPCRouter({
## What to expose vs. what to skip
**Expose:**
- Data queries (list, search, get by ID)
- Actions users would ask an AI to do (toggle, create, delete, approve)
- Status checks (health, stats, summaries)
**Skip:**
- Subscriptions (WebSocket-only, not supported by MCP)
- Internal procedures (session management, onboarding steps)
- File upload/download procedures
+1
View File
@@ -0,0 +1 @@
../.agents/skills
-50
View File
@@ -1,50 +0,0 @@
---
description: Keep documentation in sync with code changes
globs:
- packages/definitions/src/**
- packages/widgets/src/**
- packages/integrations/src/**
- packages/api/src/**
- apps/nextjs/src/**
- apps/docs/**
---
# Documentation Sync Rule
When modifying code that affects user-facing behavior, you MUST also update the documentation in `apps/docs/`.
## What triggers a doc update
| Code change | Doc location |
|---|---|
| New integration | `apps/docs/docs/integrations/<slug>/index.mdx` + `index.ts` |
| New widget | `apps/docs/docs/widgets/<slug>/index.mdx` + `index.ts` |
| Changed API surface | `apps/docs/docs/management/api.mdx` |
| New/changed env vars | `apps/docs/docs/advanced/` |
| New CLI commands | `apps/docs/docs/advanced/command-line/` |
| Changed auth providers | `apps/docs/docs/advanced/` SSO pages |
| UI/UX changes | Relevant getting-started or management pages |
| New cron job | `apps/docs/docs/management/tasks.mdx` |
| Changed permissions | `apps/docs/docs/management/users.mdx` |
## Integration doc pattern
Each integration has a folder under `apps/docs/docs/integrations/<slug>/`:
- `index.ts` — exports a typed `IntegrationDefinition` with name, description, iconUrl, path
- `index.mdx` — uses shared components: `IntegrationHeader`, `IntegrationCapabilites`, `IntegrationSecrets`
- Import metadata from `@homarr/definitions` where possible
## Widget doc pattern
Each widget has a folder under `apps/docs/docs/widgets/<slug>/`:
- `index.ts` — exports a typed `WidgetDefinition` with icon, name, description, path, configuration
- `index.mdx` — uses shared components: `WidgetHeader`, `WidgetConfig`, `WidgetAdding`
## Development
- Run docs locally: `pnpm dev:docs` from root
- Build docs: `pnpm turbo build --filter=@homarr/docs`
- The docs app can import from `@homarr/definitions` for type-safe integration/widget metadata
- Docusaurus strict mode throws on broken links — fix any broken references before committing
+5
View File
@@ -33,6 +33,11 @@ body:
options:
# The below comment is used to insert a new version with on-release.yml
#NEXT_VERSION#
- 1.77.0
- 1.76.2
- 1.76.1
- 1.76.0
- 1.75.0
- 1.74.0
- 1.73.0
- 1.72.0
@@ -68,8 +68,6 @@ jobs:
with:
node-version: 24.18.0
package-manager-cache: false
- run: npm i -g pnpm
if: env.SKIP_RELEASE == 'false'
- name: Install dependencies
if: env.SKIP_RELEASE == 'false'
run: |
-3
View File
@@ -74,9 +74,6 @@ apps/nextjs/public/images/background.png
.opencode
.agents/*
!.agents/skills/
.agents/skills/*
!.agents/skills/homarr-custom-widget/
!.agents/skills/homarr-custom-widget/**
skills-lock.json
# docusaurus
+8
View File
@@ -0,0 +1,8 @@
{
"mcpServers": {
"mantine": {
"command": "npx",
"args": ["-y", "@mantine/mcp-server"]
}
}
}
+10
View File
@@ -84,6 +84,16 @@ When modifying user-facing code, you MUST also update the corresponding document
- Lint: oxlint (not ESLint)
- Format: oxfmt (not Prettier)
- UI: Mantine (not Tailwind) — Tailwind is only used in docs app
- Mantine: use the `mantine` MCP server (configured in `.mcp.json`; tools `get_item_doc` / `get_item_props` / `search_docs`) for current v9 APIs before writing component code. Prefer built-in primitives (`Combobox`/`useCombobox` for selects, the polymorphic `component` prop, `@mantine/hooks`) over hand-rolled equivalents. Check `packages/ui/` for existing conventions first.
## MCP servers
`.mcp.json` at the repo root declares project-scoped MCP servers (the standard Claude Code layout; other tools can import the same `mcpServers` block). See the `mantine` entry there for the Mantine v9 server, and the upstream docs at https://mantine.dev/guides/llms/ for per-tool setup (OpenCode: `.opencode/opencode.json`, Codex: `.codex/config.toml`). Only keyless server definitions belong in `.mcp.json`; anything needing secrets stays in local dotfiles.
## Agent Skills
Portable agent skills live in `.agents/skills/` (the Agent Skills open standard). Cursor, Codex, and OpenCode read `.agents/skills/` natively. Claude Code does not scan it, so `.claude/skills/` is a symlink back to `.agents/skills/`. Install skills at `.agents/skills/<name>/SKILL.md` — that single copy is used everywhere. See `.agents/skills/` for available skills (documentation-sync, mcp-integration, codebase-context, datatable-migration).
- Icons: @tabler/icons-react
- Docs app can import from `@homarr/definitions` for shared types
- Run `pnpm dev:cli -- dev` to browse local `homarr:*` images and remote PR images.
+10
View File
@@ -1,3 +1,13 @@
## [1.77.0](https://github.com/homarr-labs/homarr/compare/v1.76.2...v1.77.0) (2026-09-06)
## [1.76.2](https://github.com/homarr-labs/homarr/compare/v1.76.1...v1.76.2) (2026-08-27)
## [1.76.1](https://github.com/homarr-labs/homarr/compare/v1.76.0...v1.76.1) (2026-08-26)
## [1.76.0](https://github.com/homarr-labs/homarr/compare/v1.75.0...v1.76.0) (2026-08-21)
## [1.75.0](https://github.com/homarr-labs/homarr/compare/v1.74.0...v1.75.0) (2026-08-14)
## [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)
+2 -4
View File
@@ -10,7 +10,7 @@ Homarr is used by self-hosters and administrators who assemble dashboards for se
## Product Purpose
Homarr provides one place to organize, observe, and operate self-hosted services. Success means a user can build a useful dashboard quickly, understand its state, recover from configuration errors, and extend it without requiring a bespoke Homarr feature for every service. Boards must load immediately, stay visually stable, keep widgets legible across screen sizes, and make editing feel direct and forgiving.
Homarr provides one place to organize, observe, and operate self-hosted services. Success means a user can build a useful dashboard quickly, understand its state, recover from configuration errors, and extend it without requiring a bespoke Homarr feature for every service.
## Brand Personality
@@ -27,9 +27,7 @@ Avoid decorative admin dashboards, wizard flows that fight ordinary scrolling, h
3. Make simple tasks short without limiting advanced use cases.
4. Isolate failures so one service or widget cannot disrupt the dashboard.
5. Use the existing Mantine design language consistently across management and board surfaces.
6. Keep spatial actions direct: moving, resizing, nesting, and using fixed rails happen on the canvas with immediate feedback.
7. Preserve predictable widget composition with fixed logical tile sizes and horizontal scrolling before controls become inaccessible.
## Accessibility & Inclusion
Target WCAG 2.2 AA. Support keyboard navigation, screen readers, visible focus, sufficient contrast, reduced motion, non-color status cues, responsive layouts, actionable error messages, and keyboard equivalents for drag and resize operations.
Target WCAG 2.2 AA. Support keyboard navigation, screen readers, visible focus, sufficient contrast, reduced motion, non-color status cues, responsive layouts, and actionable error messages.
@@ -12,20 +12,6 @@ Homarr has the following keyboard shortcuts:
| `MOD` + `J` | Toggles Dark Mode |
| `MOD` + `E` | Toggles Edit Mode |
## Board editing
When a tile is focused in edit mode:
| Shortcut | Description |
| -------------------- | --------------------------------------------------------------------------- |
| `Enter` or `Space` | Starts or stops keyboard editing |
| Arrow keys | Moves the focused tile within its current canvas, rail, or Container |
| `Shift` + Arrow keys | Resizes from the tile's lower-right corner: Left/Up shrink; Right/Down grow |
| `Esc` | Stops keyboard editing without undoing its changes |
Keyboard moves and resizes apply immediately. To move an item between a canvas, rail, or Container, drag it there or use **Move / resize item** for precise numeric placement.
Commands at a grid boundary or at the tile's minimum size are ignored and announced instead of moving or shrinking the tile.
:::tip
Here `MOD` refers to the modifier key, which is `Ctrl` on Windows and `Cmd` on macOS.
@@ -137,8 +137,7 @@ It is important that you first migrate correctly using [the list of breaking cha
## Creating your first board
In Homarr, you organize your apps and widgets on boards.
Boards use fixed-size tiles so the same widget keeps the same proportions on every screen.
Drag, resize, and section controls are available in edit mode.
Boards can be organized using the interactive drag and drop.
Let's create your first board by navigating here: `Manage` > `Boards` > `New board`:
![](./img/manage-create-board-button.png)
@@ -189,7 +188,7 @@ When entering the edit mode, you'll get some additional buttons to work with:
<img src={manageBoardHeaderEditMode} alt={"Screenshot of buttons in the edit mode"} width={200} />
- **Add item**: Click this button to add new elements to your page.
- **Exit edit mode**: Click this button to save your changes and exit edit mode.
- **Exit edit mode**: Click this btuton to save your changes and exit edit mode.
To continue, click the option "New Item":
@@ -214,29 +213,21 @@ In the modal you can edit the app that you want to display:
![](./img/manage-board-choose-app.png)
As soon as you save your changes, the tile on the board will refresh and display your app.
Use the resize handle at the bottom right to resize the tile.
Press and hold the handle, move the pointer, and release it when the tile has the size you want.
Using the small bidirectional arrow at the bottom right you can resize the element.
Start pressing with your mouse button down, move your mouse to resize and release your finger as soon your you're happy with the size.
![](./img/resize-item.gif)
To move a tile in edit mode, press and hold its content, move it to the desired position, and release it.
Widget content is disabled while editing so its controls do not intercept the drag; use the tile's settings button for edit actions.
The tile snaps to the grid. Equally-sized tiles swap when one is dropped into the other's slot, while other neighboring tiles move out of the way automatically.
To move your item to a different position on the board, simply hold the mouse button anywhere on the item and move with the mouse to the desired position.
As soon as you release your finger, the item will snap in place in the grid.
![](./img/move-item.gif)
Every `1 × 1` tile has a logical content size of `200 × 200` pixels.
Larger tiles use multiples of that fixed unit, with a consistent gap between cells.
Homarr scales the complete board uniformly, like zooming a page.
The complete canvas always fits the available width without horizontal board scrolling.
Below 100%, Homarr preserves the widget's readable typography and control density instead of shrinking each widget independently.
It does not squeeze or reflow the content of individual widgets, so a widget keeps the same design and proportions across screen sizes.
:::note
Viewing a board initially uses the lightweight, non-interactive layout.
For users with edit access, Homarr prepares drag-and-drop and resize controls in the background after the board becomes interactive.
If you enter edit mode before that finishes, the existing board stays visible until the editor is ready.
You cannot move items freely on the vertical axis.
We always align all items to the top of the page to simply organization.
To move items to the bottom, you must add more items or resize items.
:::
@@ -247,10 +238,8 @@ This helps you to explore the capabilities of Homarr before you build your board
:::
Keyboard users can focus a tile, press Enter or Space, then use arrow keys to move and Shift plus arrow keys to resize.
Press Escape to stop.
For precise numeric controls or touch input, choose **Move / resize item** from the tile's edit menu.
The dialog can also move the tile to another section or fixed sidebar:
Users on touch or mobile devices may not be able to drag and drop or move items.
They can use the "Move / resize item" option in the edit menu of items instead:
![](./img/manage-board-move-resize-item-menu.png)
@@ -322,36 +311,43 @@ As with every change, don't forget to save your changes using the exit edit mode
![](./img/exit-edit-mode.png)
## Organizing items with Containers
## Grouping your items
Containers are resizable areas for apps, widgets, and nested Containers.
The same Container works on the main canvas, inside a fixed rail, or inside another Container.
Each Container can configure:
As the last step, let's organize your freshly created items using two different options.
You can choose either or both of them on your boards.
- Its label and whether that label is visible.
- Allow or disable collapsing.
- Show or hide **Open all in new tabs**.
- Change its border color and custom CSS classes.
### Using dynamic sections
Open the add-content menu and choose **New Container**.
In edit mode, move and resize it like any other item, then drag items into or out of it.
Nested content stays within the Container's bounds.
While resizing, the solid outline follows the pointer continuously and the dashed grid preview shows the size that will be saved on release.
Dynamic sections are a simple, resizable visual group to group items.
### Collapsing and opening apps
![](./img/create-dynamic-section.png)
Users can collapse an eligible Container like an accordion in view mode without changing its saved expanded size.
The full-width header becomes the compact expansion bar, so the half-row state has no empty pill-shaped spacer.
Expanding it restores its bounds and makes room for its contents.
If **Open all in new tabs** is enabled, the Container menu can open every app link it contains.
After adding a dynamic section, you can simply drag and drop items into the section:
### Fixed rails
![](./img/dynamic-section.gif)
Open the board's **Layout** settings to enable the left or right rail for each responsive layout.
Choose one, two, or three columns with the slider and use the preview to check the remaining canvas width.
The rail reserves those columns from the dashboard, so Homarr moves canvas items into the next available positions.
Rail content stays visible while the page scrolls and remains editable.
Each responsive layout keeps its own rail widths, item positions, and item sizes.
### Using categories
Categories are more advanced and complicated than dynamic sections.
To create a category, open the add item modal and choose "new category":
![](./img/add-category.png)
Every category must have a name. After you click "Add category", you will get a new item on your board:
![](./img/category.gif)
You can drag and drop items in to this category in the edit mode.
Categories will have a big space before and after them in the edit mode to
enable you to drag and drop items outside the category.
You can also collapse or open categories in edit mode to facilitate your editing process.
:::note
A current limitation of categories is, that they will always have a width of 100%.
You cannot reduce their horizontal width.
:::
## Guided tours
+10 -10
View File
@@ -1,13 +1,13 @@
# Glossary
This glossary explains Homarr's main components and the terms used for them.
In the glossary, you'll learn the different components and their names of Homarr.
| Term | Definition |
| ----------- | --------------------------------------------------------------------------------------- |
| Apps | A piece of software that you or a third party hosts (for example, Google) |
| Integration | A connection to a service outside of Homarr |
| Container | A resizable area that can hold apps, widgets, and nested Containers |
| Widget | A tile that uses your integrations to display data or enables you to interact with them |
| Edit mode | A temporary state where you can drag, resize, edit, or customize your board |
| Board | A customizable page where apps, widgets, and Containers are arranged |
| Tasks | Workloads that are performed in the background, even if no user is present |
| Term | Definition |
| --------------- | -------------------------------------------------------------------------------------------------------------- |
| Apps | An piece of software that you or a third party hosts (eg. Google) |
| Integration | A connection to a service outside of Homarr |
| Dynamic section | Resizeable area where items on your board can be placed and grouped |
| Widget | A tile that uses your integrations to display data or enables you to interact with with them |
| Edit mode | Temporary state that you can enter where you can drag and drop, edit or customize your board |
| Board | Customizable page which can be edited using the edit mode. This is where apps, sections and widgets are placed |
| Tasks | Workloads that are performed in the background - even if there is no user present |
@@ -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">
![Version: 8.26.0](https://img.shields.io/badge/Version-8.26.0-informational?style=flat)
![Version: 8.28.1](https://img.shields.io/badge/Version-8.28.1-informational?style=flat)
![Type: application](https://img.shields.io/badge/Type-application-informational?style=flat)
![AppVersion: v1.74.0](https://img.shields.io/badge/AppVersion-v1.74.0-informational?style=flat)
![AppVersion: v1.76.1](https://img.shields.io/badge/AppVersion-v1.76.1-informational?style=flat)
A Helm chart to deploy homarr for Kubernetes
@@ -437,7 +437,7 @@ All available values are listed on the [artifacthub](https://artifacthub.io/pack
| httproute.rules[0].matches[0].path.value | string | `"/"` | Path value to match |
| image.pullPolicy | string | `"IfNotPresent"` | Image pull policy |
| image.repository | string | `"ghcr.io/homarr-labs/homarr"` | Image repository |
| image.tag | string | `"v1.74.0"` | Overrides the image tag whose default is the chart appVersion |
| image.tag | string | `"v1.76.1"` | Overrides the image tag whose default is the chart appVersion |
| imagePullSecrets | list | `[]` | Secrets for Docker registry |
| ingress.annotations | object | `{}` | Ingress annotations |
| ingress.enabled | bool | `false` | Enable ingress |
@@ -19,6 +19,8 @@ import Alert from "@theme/Admonition";
<IntegrationCapabilites items={[{ widget: healthMonitoringWidget }, { widget: systemResourcesWidget }]} />
The storage values shown by these widgets come from each pool's root ZFS dataset, so they already account for RAIDZ parity overhead and dataset limits and match what `zfs list` and the TrueNAS web interface report. When a pool cannot report its dataset space, Homarr falls back to the pool's physical values, which read higher on RAIDZ.
### Adding the integration
<AddingIntegration />
-7
View File
@@ -84,13 +84,6 @@ The OpenAPI and MCP APIs include board management endpoints for common automatio
Use the interactive API reference to inspect the exact request body for each endpoint under the `boards` and `settings` tags.
### Saving dashboard layouts
The dashboard editor saves item positions and sections as one board update.
Canvas height follows its content automatically and does not require a separate API setting.
Responsive layouts store optional left and right gutter widths from zero to three columns.
Gutter columns are reserved from the layout's total column count, and changing them reflows affected items.
## Get Custom Widget authoring resources
Custom JSX v2 provides four HTTP discovery endpoints:
+10 -82
View File
@@ -36,10 +36,8 @@ Underscores and hyphen-minus are allowed.
### Column count
The column count defines how many fixed-width cells are available in each row.
One cell contains a `200 × 200` logical-pixel tile.
Homarr scales the whole board uniformly instead of changing widget proportions, and always fits it to the available width without horizontal scrolling.
The main canvas and enabled rails stay at least one viewport tall, and the canvas grows automatically while you drag or resize items near its lower edge.
Homarr has a complex drag-and-drop system that can be used from the browser.
The column count defines how many horizontal columns are available to drag and drop elements to.
### Public
@@ -90,78 +88,26 @@ Homarr will fall back to the Homarr logo if nothing is set.
### Layout
In this section you can configure the different responsive layouts.
Homarr selects a layout from the screen width, then scales its complete fixed-size canvas uniformly.
In this section you can configure the different responsive layouts. Depending on your screens width the layout will change.
![](./img/layout-settings.png)
Every `1 × 1` tile has a `200 × 200` logical-pixel content area.
The `24` logical-pixel gap between tiles is separate from that size.
A `2 × 1` tile therefore contains two fixed cell widths plus the gap between them.
Widgets see their logical size, while the board's visual scale makes the complete layout larger or smaller.
When fitting below 100%, Homarr keeps widget typography and control density at their normal readable size.
On larger canvases, the complete board grows together like browser zoom.
The complete canvas always fits the available width and never adds horizontal board scrolling.
The position and size of every item are independent in each responsive layout.
Moving or resizing an item in one layout does not change any other layout.
When adding a new layout, its initial positions and sizes are generated from the column count.
New items use their widget's default grid size, while a new Container starts at a useful `4 × 3` size when the layout has enough columns.
Both are placed in the first available position.
The canvas is at least one window tall, even when it is empty.
Its left and right edges are hard drag boundaries, while its bottom edge extends automatically as a tile is dragged or resized downward.
Nested Containers keep their own resizable bounds.
On each element the position and size of items can be changed seperately.
When adding a new layout the position and sizes will automatically be generated depending on the column count.
When adding a new item / dynamic section that will always be 1x1 on each layout and placed on the first available position.
#### Name
The name of the layout can be chosen freely. It is only used for management and will not be visible on the board.
The name of the layout can be choosen freely. It is only used for the management and will not be visible on the board.
#### Column count
Total number of columns for this layout.
When changed, Homarr moves items into the next available positions without overlap.
#### Fixed rails
Each responsive layout can enable a fixed left rail, a fixed right rail, or both.
Set a rail width to zero to disable it for that layout.
Choose a width from one to three columns with the three-step slider.
The preview shows how much space remains for the main canvas.
Rail columns are reserved from the layout's total column count.
For example, a 12-column layout with a two-column left rail leaves ten columns for the canvas.
Enabling or widening a rail reflows canvas items into the remaining columns.
Disabling a rail moves its contents back into the next available canvas positions.
Rails fill the available window height and remain fixed while the page scrolls. They do not grow or scroll independently.
Apps, widgets, and Containers inside them still use the same fixed `200 × 200` cells and can be rearranged in edit mode, where each rail shows its name and boundary.
Amount of columns for this layout. Defines how many items can be placed in a row. When changed it will automatically adjust the position and size of the items.
#### Breakpoint
Minimum width of the screen where the layout will be used.
Once the minimum of the next layout is reached, the layout will change.
#### View and edit modes
View mode renders a static board without executing the drag-and-drop editor.
After the board is interactive, Homarr prepares the editor in the background for users who can modify it.
Entering edit mode keeps the static board visible until that preparation finishes, then enables collision-aware movement and resizing without changing the board geometry.
Widget content is inert in edit mode, so dragging anywhere on the content moves the tile instead of activating the widget; its settings button remains visible in the compact header above the tile.
Dropping a tile into another tile's exact slot swaps their positions when both resulting placements fit without overlapping other content.
For other collisions, or when a tile grows into occupied space, nearby tiles are compacted out of the way.
The visible corner controls provide a 44-pixel resize target, and edge controls appear on hover for horizontal or vertical resizing.
Resize handles stop at the tile's minimum allowed size while the pointer is still moving.
Exit edit mode to save the resulting positions.
Pointer users can drag anywhere on a widget tile. Containers use the grab button at their top-left corner.
Resize tiles from any edge or corner handle.
On a touch screen, press and hold the tile before moving it; resize handles support direct touch input.
Keyboard users can focus a tile, press Enter or Space to start editing, use the arrow keys to move it, and use Shift plus the arrow keys to resize it.
Press Enter, Space, or Escape to stop keyboard editing.
Keyboard moves and resizes apply immediately; Escape stops keyboard editing without undoing those changes.
During a pointer or touch interaction, Escape cancels the move or resize and restores the layout from before that interaction.
The **Move / resize item** action in the edit menu remains available for precise numeric input.
Once the minimum of the next layout is reached the layout will change.
#### Preview
@@ -169,24 +115,6 @@ The **Move / resize item** action in the edit menu remains available for precise
<img src={layoutMediumImage} height={256} />
<img src={layoutLargeImage} height={256} />
### Containers and rails
Containers can hold apps, widgets, and nested Containers.
The same Container model works on the main canvas, inside a fixed rail, or inside another Container.
Move and resize Containers directly in edit mode; nested content stays within the Container's local bounds.
Resize outlines follow the pointer continuously while a dashed preview marks the grid size that will be applied on release.
Each responsive layout stores its own Container position and size.
Each Container can configure:
- Its label and whether that label is visible.
- Whether users can collapse and expand it like an accordion.
- Whether its menu shows **Open all in new tabs** for the app links it contains.
- Its border color and custom CSS classes.
Collapsing a Container reduces its full-width header to a half-row expansion bar and compacts the surrounding layout without losing its expanded size.
Expanding it smoothly restores those bounds and moves following items out of the way without reloading the widgets inside it.
### Background
#### Background image URL
@@ -231,7 +159,7 @@ Change the color of your bookmark / app icons.
#### Item radius
Change the radius of your items and Containers.
Change the radius of your items / categories.
<img src={itemRadiusImage} height={100} />
@@ -58,6 +58,11 @@ This means you can automate the update of the certificates by simply replacing o
Every integration has its own way of obtaining certificates.
Generally it is recommended to search for the documentation of the integration you are using.
Most of the time you can find the certificate in the file system of the integration.
The certificate file needs to be uploaded to Homarr. To first get the file on your computer you can either:
- use `scp user@host:/path/to/file ./certificate.pem`
- use `cat /path/to/file` to print the certificate. Then recreate the file on your machine.
Below you can find instructions for some of the integrations.
### Pi-hole (v6)
@@ -65,3 +70,9 @@ Below you can find instructions for some of the integrations.
Since version 6 of Pi-hole, the web interface can be accessed through HTTPS.
The certificate can be found in the path `/etc/pihole/tls_ca.crt`. To add the certificate, this file needs to be copied and uploaded to Homarr.
You can find more details regarding SSL / TLS for Pi-hole [here](https://docs.pi-hole.net/api/tls/).
### Proxmox
By default, Proxmox VE uses a node certificate signed by the cluster. You only need to configure Homarr with the certificate of a single node.
The certificate can be found in any node on the path `/etc/pve/pve-root-ca.pem`.
You can learn more about certificate management in Proxmox [here](https://pve.proxmox.com/wiki/Certificate_Management).
+1
View File
@@ -13,6 +13,7 @@ import { appWidget } from ".";
The app widget allows you to add a link to an application or a website that you want to access quickly from your Homarr board.
It also supports a status check to indicate whether Homarr can reach the application or website.
In view mode, right-clicking uses the browser's link menu. Homarr's widget menu remains available in edit mode.
### Screenshots
@@ -13,6 +13,7 @@ import { bookmarksWidget } from ".";
The bookmarks widget allows you to display multiple app links in a single widget.
You can add links to your favorite applications, websites, or any other resources you want quick access to.
In view mode, right-clicking uses the browser's link menu. Homarr's widget menu remains available in edit mode.
### Screenshots
@@ -43,6 +43,7 @@ import temperature from "./img/temperature.png";
### Interactions
- Toggle entity state by clicking on the entity (only when clickable enabled).
- After a successful toggle, the displayed state refreshes immediately.
### Adding the widget
@@ -53,4 +53,6 @@ import withDisplayname from "./img/with-displayname.png";
### Configuration
Use the Home Assistant entity ID (for example, `automation.turn_on_lights`) in the **Automation ID** field, not the automation's internal ID.
<WidgetConfig configuration={smartHomeExecuteAutomationWidget.configuration} />
-4
View File
@@ -79,10 +79,6 @@ const nextConfig: NextConfig = {
{
source: "/(.*)", // Apply CSP to all routes
headers: [
{
key: "Accept-CH",
value: "Sec-CH-Viewport-Width",
},
{
key: "Content-Security-Policy",
// worker-src / media-src with blob: is necessary for video.js, see https://github.com/homarr-labs/homarr/issues/3912 and https://stackoverflow.com/questions/65792855/problem-with-video-js-and-content-security-policy-csp
+1 -4
View File
@@ -14,12 +14,8 @@
"with-env": "dotenv -e ../../.env --"
},
"dependencies": {
"@dnd-kit/abstract": "catalog:",
"@dnd-kit/collision": "catalog:",
"@dnd-kit/core": "catalog:",
"@dnd-kit/dom": "catalog:",
"@dnd-kit/modifiers": "catalog:",
"@dnd-kit/react": "catalog:",
"@dnd-kit/sortable": "catalog:",
"@dnd-kit/utilities": "catalog:",
"@gfazioli/mantine-onboarding-tour": "catalog:",
@@ -36,6 +32,7 @@
"@homarr/docker": "workspace:^0.1.0",
"@homarr/form": "workspace:^0.1.0",
"@homarr/forms-collection": "workspace:^0.1.0",
"@homarr/gridstack": "catalog:",
"@homarr/icons": "workspace:^0.1.0",
"@homarr/image-proxy": "workspace:^0.1.0",
"@homarr/integrations": "workspace:^0.1.0",
@@ -1,7 +1,8 @@
"use client";
import { useCallback } from "react";
import { Group, Menu } from "@mantine/core";
import { IconBox, IconChevronDown, IconLayoutGridAdd, IconPlug, IconPlus, IconResize } from "@tabler/icons-react";
import { IconBox, IconBoxAlignTop, IconChevronDown, IconPlug, IconPlus, IconResize } from "@tabler/icons-react";
import { useSession } from "@homarr/auth/client";
import { useRequiredBoard } from "@homarr/boards/context";
@@ -11,43 +12,38 @@ import { useI18n } from "@homarr/translation/client";
import { useItemActions } from "~/components/board/items/item-actions";
import { ItemSelectModal } from "~/components/board/items/item-select-modal";
import { useContainerActions } from "~/components/board/sections/container/container-actions";
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 default function BoardEditActions() {
return <AddMenu />;
}
const AddMenu = () => {
export const BoardAddMenu = () => {
const { data: session } = useSession();
const board = useRequiredBoard();
const { openModal: openCategoryEditModal } = useModalAction(CategoryEditModal);
const { openModal: openItemSelectModal } = useModalAction(ItemSelectModal);
const { openModal: openAppSelectModal } = useModalAction(AppSelectModal);
const { openModal: openIntegrationSelectModal } = useModalAction(IntegrationSelectModal);
const { addContainer } = useContainerActions();
const { addCategoryToEnd } = useCategoryActions();
const { addDynamicSection } = useDynamicSectionActions();
const { createItem } = useItemActions();
const t = useI18n();
const handleSelectItem = () => {
openItemSelectModal({ boardId: board.id });
};
const handleSelectApp = () => {
openAppSelectModal({
onSelect: (app) => {
createItem({
kind: "app",
options: { appId: app.id },
});
},
withCreate: session?.user.permissions.includes("app-create") ?? false,
});
};
const handleAddIntegration = () => {
openIntegrationSelectModal({});
};
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">
@@ -60,18 +56,29 @@ const AddMenu = () => {
</HeaderButton>
</Menu.Target>
<Menu.Dropdown style={{ transform: "translate(-3px, 0)" }}>
<Menu.Item leftSection={<IconResize size={20} />} onClick={handleSelectItem}>
<Menu.Item leftSection={<IconResize size={20} />} onClick={() => openItemSelectModal({ boardId: board.id })}>
{t("item.action.create")}
</Menu.Item>
<Menu.Item leftSection={<IconBox size={20} />} onClick={handleSelectApp}>
<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={handleAddIntegration}>
<Menu.Item leftSection={<IconPlug size={20} />} onClick={() => openIntegrationSelectModal({})}>
{t("integration.action.create")}
</Menu.Item>
<Menu.Divider />
<Menu.Item leftSection={<IconLayoutGridAdd size={20} />} onClick={addContainer}>
{t("section.container.action.create")}
<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>
@@ -1,53 +0,0 @@
.columns {
display: grid;
align-items: start;
gap: 24px;
}
.lane {
min-width: 0;
}
.gutter {
--board-gutter-sticky-top: calc((var(--app-shell-header-offset, 0px) + 1rem) * var(--board-canvas-inverse-scale, 1));
--board-gutter-viewport-height: calc(
(100dvh - var(--app-shell-header-offset, 0px) - 1rem) * var(--board-canvas-inverse-scale, 1)
);
position: sticky;
top: var(--board-gutter-sticky-top);
align-self: start;
box-sizing: border-box;
height: var(--board-gutter-viewport-height);
padding-top: 24px;
margin-top: -24px;
overflow: clip;
}
.gutter[data-board-editing="true"] {
outline: 1px dashed var(--mantine-color-default-border);
outline-offset: -1px;
border-radius: var(--mantine-radius-md);
}
.gutter[data-board-editing="true"]::before {
position: sticky;
top: 0;
inset-inline-start: 0;
z-index: 2;
display: block;
width: fit-content;
height: 24px;
padding-inline: var(--mantine-spacing-xs);
margin-top: -24px;
overflow: hidden;
font-size: var(--mantine-font-size-xs);
line-height: 24px;
color: var(--mantine-color-dimmed);
text-overflow: ellipsis;
white-space: nowrap;
pointer-events: none;
content: attr(aria-label);
background: color-mix(in srgb, var(--mantine-color-body) 88%, transparent);
border-radius: var(--mantine-radius-sm);
}
@@ -1,121 +1,33 @@
"use client";
import { Box } from "@mantine/core";
import { Box, Stack } from "@mantine/core";
import { useCurrentLayout, useInitialViewportWidth, useRequiredBoard } from "@homarr/boards/context";
import { useEditMode } from "@homarr/boards/edit-mode";
import { useScopedI18n } from "@homarr/translation/client";
import { useCurrentLayout, useRequiredBoard } from "@homarr/boards/context";
import {
getBoardLaneColumnCount,
getInitialBoardLogicalHeight,
getLogicalTrackSize,
LOGICAL_GRID_GAP,
getRootSectionForLane,
} from "~/components/board/layout";
import { ScaledBoardCanvas } from "~/components/board/layout/scaled-board-canvas";
import { BoardCategorySection } from "~/components/board/sections/category-section";
import { BoardEmptySection } from "~/components/board/sections/empty-section";
import { BoardGridEditorBoundary } from "~/components/board/sections/grid/board-grid-editor-boundary";
import { BoardGridPortalHost, BoardGridPortalRenderer } from "~/components/board/sections/grid/grid-portal-host";
import { BoardSectionCollapseProvider } from "~/components/board/sections/section-collapse";
import { BoardBackgroundVideo } from "~/components/layout/background";
import classes from "./_client.module.css";
const APP_SHELL_INLINE_PADDING = 32;
export const ClientBoard = () => {
const board = useRequiredBoard();
const t = useScopedI18n("board.landmark");
const [isEditMode] = useEditMode();
const currentLayoutId = useCurrentLayout();
const initialViewportWidth = useInitialViewportWidth();
const currentLayout = board.layouts.find((layout) => layout.id === currentLayoutId) ?? board.layouts.at(0);
if (!currentLayout) throw new Error("Expected the board to contain a layout");
const mainSection = getRootSectionForLane(board, "main");
const leftSection = getRootSectionForLane(board, "left");
const rightSection = getRootSectionForLane(board, "right");
if (!mainSection) throw new Error("Expected the board to contain a main canvas section");
const leftColumnCount = getBoardLaneColumnCount(currentLayout, "left");
const mainColumnCount = getBoardLaneColumnCount(currentLayout, "main");
const rightColumnCount = getBoardLaneColumnCount(currentLayout, "right");
const laneWidths = [leftColumnCount, mainColumnCount, rightColumnCount]
.filter((columnCount) => columnCount > 0)
.map(getLogicalTrackSize);
const logicalWidth =
laneWidths.reduce((total, width) => total + width, 0) + (laneWidths.length - 1) * LOGICAL_GRID_GAP;
const initialLogicalHeight = getInitialBoardLogicalHeight(board, currentLayoutId) + LOGICAL_GRID_GAP;
const initialAvailableWidth = Math.max(1, initialViewportWidth - APP_SHELL_INLINE_PADDING);
const gridTemplateColumns = [
leftColumnCount > 0 ? `${getLogicalTrackSize(leftColumnCount)}px` : null,
`${getLogicalTrackSize(mainColumnCount)}px`,
rightColumnCount > 0 ? `${getLogicalTrackSize(rightColumnCount)}px` : null,
]
.filter((value) => value !== null)
.join(" ");
const fullWidthSortedSections = board.sections
.filter((section) => section.kind === "empty" || section.kind === "category")
.toSorted((sectionA, sectionB) => sectionA.yOffset - sectionB.yOffset);
return (
<Box h="100%" pos="relative" data-homarr-dev-benchmark-board>
<BoardBackgroundVideo />
<BoardSectionCollapseProvider>
<BoardGridPortalHost>
<ScaledBoardCanvas
logicalWidth={logicalWidth}
initialLogicalHeight={initialLogicalHeight}
initialAvailableWidth={initialAvailableWidth}
label={board.name}
>
<BoardGridEditorBoundary key={currentLayoutId}>
<BoardGridPortalRenderer />
<div className={classes.columns} style={{ gridTemplateColumns, paddingTop: LOGICAL_GRID_GAP }}>
{leftColumnCount > 0 && leftSection && (
<aside
className={`${classes.lane} ${classes.gutter}`}
aria-label={t("leftRail")}
data-board-gutter="left"
data-board-editing={isEditMode ? "true" : undefined}
>
<BoardEmptySection
key={`${currentLayoutId}-${leftSection.id}`}
section={leftSection}
columnCount={leftColumnCount}
requestedRowCount={0}
railPlacement="left"
/>
</aside>
)}
<section className={classes.lane} aria-label={t("canvas")}>
<BoardEmptySection
key={`${currentLayoutId}-${mainSection.id}`}
section={mainSection}
columnCount={mainColumnCount}
requestedRowCount={0}
/>
</section>
{rightColumnCount > 0 && rightSection && (
<aside
className={`${classes.lane} ${classes.gutter}`}
aria-label={t("rightRail")}
data-board-gutter="right"
data-board-editing={isEditMode ? "true" : undefined}
>
<BoardEmptySection
key={`${currentLayoutId}-${rightSection.id}`}
section={rightSection}
columnCount={rightColumnCount}
requestedRowCount={0}
railPlacement="right"
/>
</aside>
)}
</div>
</BoardGridEditorBoundary>
</ScaledBoardCanvas>
</BoardGridPortalHost>
</BoardSectionCollapseProvider>
<Stack h="100%">
{fullWidthSortedSections.map((section) =>
section.kind === "empty" ? (
<BoardEmptySection key={`${currentLayoutId}-${section.id}`} section={section} />
) : (
<BoardCategorySection key={`${currentLayoutId}-${section.id}`} section={section} />
),
)}
</Stack>
</Box>
);
};
@@ -2,6 +2,9 @@ import type { Metadata } from "next";
import { cache } from "react";
import { TRPCError } from "@trpc/server";
// Placed here because gridstack styles are used for board content
import "~/styles/gridstack.scss";
import { dehydrate, HydrationBoundary } from "@tanstack/react-query";
import { makeQueryClient } from "@homarr/api/shared";
import { IntegrationProvider } from "@homarr/auth/client";
@@ -18,7 +21,7 @@ import { createMetaTitle } from "~/metadata";
import { env } from "~/env";
import { createBoardLayout } from "../_layout-creator";
import type { Board, Item } from "../_types";
import { ClientBoard } from "./_client";
import { DynamicClientBoard } from "./_dynamic-client";
import { BoardContentHeaderActions } from "./_header-actions";
const logger = createLogger({ module: "createBoardContentPage" });
@@ -73,7 +76,7 @@ export const createBoardContentPage = <TParams extends Record<string, unknown>>(
return (
<HydrationBoundary state={dehydrate(queryClient)}>
<IntegrationProvider integrations={integrations}>
<ClientBoard />
<DynamicClientBoard />
</IntegrationProvider>
</HydrationBoundary>
);
@@ -0,0 +1,24 @@
"use client";
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), {
ssr: false,
loading: BoardLoadingShell,
});
@@ -1,11 +1,11 @@
"use client";
import { startTransition, useCallback, useEffect, useRef, useState } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import dynamic from "next/dynamic";
import { useRouter } from "next/navigation";
import { Box, Center, Loader, Menu, ScrollArea } from "@mantine/core";
import { useHotkeys } from "@mantine/hooks";
import { IconLayoutBoard, IconPencil, IconPencilOff, IconReplace, IconSettings } from "@tabler/icons-react";
import { IconLayoutBoard, IconPencil, IconPencilOff, IconPlus, IconReplace, IconSettings } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
import { useRequiredBoard } from "@homarr/boards/context";
@@ -19,32 +19,22 @@ import { useI18n, useScopedI18n } from "@homarr/translation/client";
import { Link } from "@homarr/ui";
import { useBoardPermissions } from "~/components/board/permissions/client";
import { loadGridEditorAsync, scheduleGridEditorWarmup } from "~/components/board/sections/grid/grid-editor-loader";
import { HeaderButton } from "~/components/layout/header/button";
import { TourTarget } from "~/components/layout/header/tour-target";
import type * as EditActionsModule from "./_edit-actions";
let editActionsModulePromise: Promise<typeof EditActionsModule> | undefined;
const loadEditActionsAsync = () => {
editActionsModulePromise ??= import("./_edit-actions").catch((error: unknown) => {
editActionsModulePromise = undefined;
throw error;
});
return editActionsModulePromise;
};
const loadBoardEditorAsync = async () =>
await Promise.all([
loadGridEditorAsync(),
loadEditActionsAsync(),
import("~/components/board/items/item-menu"),
import("~/components/board/sections/container/container-menu"),
]);
const BoardEditActions = dynamic(loadEditActionsAsync, { ssr: false });
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 }) => {
const [isEditMode] = useEditMode();
const board = useRequiredBoard();
const t = useI18n();
const { hasChangeAccess } = useBoardPermissions(board);
if (!hasChangeAccess) {
@@ -53,13 +43,13 @@ export const BoardContentHeaderActions = ({ demoReadOnly }: { demoReadOnly: bool
return (
<>
{isEditMode && <BoardEditActions />}
{isEditMode && <BoardAddMenu />}
<EditModeMenu demoReadOnly={demoReadOnly} />
{!demoReadOnly && (
<TourTarget id="board-settings">
<HeaderButton href={`/boards/${board.name}/settings`} aria-label={t("board.action.settings")}>
<HeaderButton href={`/boards/${board.name}/settings`}>
<IconSettings stroke={1.5} />
</HeaderButton>
</TourTarget>
@@ -72,11 +62,10 @@ export const BoardContentHeaderActions = ({ demoReadOnly }: { demoReadOnly: bool
const EditModeMenu = ({ demoReadOnly }: { demoReadOnly: boolean }) => {
const [isEditMode, { open, close }] = useEditMode();
const [editorLoadState, setEditorLoadState] = useState<"scheduled" | "loading" | "ready" | "error">("scheduled");
const [isEnteringEditMode, setIsEnteringEditMode] = useState(false);
const board = useRequiredBoard();
const utils = clientApi.useUtils();
const t = useScopedI18n("board.action.edit");
const commonT = useI18n();
const latestBoardRef = useRef(board);
latestBoardRef.current = board;
@@ -105,71 +94,27 @@ const EditModeMenu = ({ demoReadOnly }: { demoReadOnly: boolean }) => {
close();
}, [utils, board.name, close]);
const prepareEditorAsync = useCallback(async () => {
if (editorLoadState === "ready") return;
setEditorLoadState("loading");
try {
await loadBoardEditorAsync();
setEditorLoadState("ready");
} catch {
setEditorLoadState("error");
throw new Error("Unable to load the board editor");
}
}, [editorLoadState]);
useEffect(() => {
return scheduleGridEditorWarmup(async () => {
setEditorLoadState("loading");
try {
await loadBoardEditorAsync();
setEditorLoadState("ready");
} catch {
setEditorLoadState("error");
}
});
}, []);
const prewarmEditor = useCallback(() => {
void prepareEditorAsync().catch(() => {
// A click or hotkey retries and reports the error.
});
}, [prepareEditorAsync]);
const toggle = useCallback(async () => {
const toggle = useCallback(() => {
if (isEditMode) {
if (demoReadOnly) return discardDemoChanges();
return saveBoard(latestBoardRef.current);
}
setIsEnteringEditMode(true);
try {
await prepareEditorAsync();
startTransition(open);
} catch {
showErrorNotification({
title: t("notification.loadError.title"),
message: t("notification.loadError.message"),
});
} finally {
setIsEnteringEditMode(false);
}
}, [demoReadOnly, discardDemoChanges, isEditMode, open, prepareEditorAsync, saveBoard, t]);
open();
}, [isEditMode, demoReadOnly, saveBoard, open, discardDemoChanges]);
useHotkeys([[hotkeys.toggleBoardEdit, () => void toggle()]]);
useHotkeys([[hotkeys.toggleBoardEdit, toggle]]);
usePreventLeaveWithDirty(isEditMode);
return (
<TourTarget id="board-edit-mode">
<HeaderButton
onClick={() => void toggle()}
onFocus={prewarmEditor}
onPointerEnter={prewarmEditor}
loading={isPending || isEnteringEditMode}
onClick={toggle}
loading={isPending}
data-testid="board-edit-mode-toggle"
data-board-editor-preload-state={editorLoadState}
aria-busy={isPending || isEnteringEditMode}
aria-label={isEditMode ? (demoReadOnly ? t("exit") : t("save")) : t("enter")}
aria-label={isEditMode ? commonT("common.action.save") : commonT("common.action.edit")}
aria-pressed={isEditMode}
onFocus={preloadBoardAddMenu}
onPointerEnter={preloadBoardAddMenu}
>
{isEditMode ? <IconPencilOff stroke={1.5} /> : <IconPencil stroke={1.5} />}
</HeaderButton>
@@ -178,7 +123,6 @@ const EditModeMenu = ({ demoReadOnly }: { demoReadOnly: boolean }) => {
};
const SelectBoardsMenu = () => {
const t = useI18n();
const [isOpen, setIsOpen] = useState(false);
const utils = clientApi.useUtils();
const { data: boards = [], isPending } = clientApi.board.getAllBoards.useQuery(undefined, { enabled: isOpen });
@@ -189,7 +133,7 @@ const SelectBoardsMenu = () => {
<Box onFocus={preloadBoards} onPointerEnter={preloadBoards}>
<Menu position="bottom-end" opened={isOpen} onChange={setIsOpen}>
<Menu.Target>
<HeaderButton w="auto" px={4} aria-label={t("board.action.switch")}>
<HeaderButton w="auto" px={4}>
<IconReplace stroke={1.5} />
</HeaderButton>
</Menu.Target>
@@ -1,54 +0,0 @@
import { createElement, act } from "react";
import { createRoot } from "react-dom/client";
import type { Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { BoardIntegrationProvider } from "./_integration-provider";
const mocks = vi.hoisted(() => ({
providedIntegrations: undefined as unknown,
}));
vi.mock("@homarr/auth/client", async () => {
const React = await import("react");
return {
IntegrationProvider: ({ children, integrations }: { children: React.ReactNode; integrations: unknown }) => {
mocks.providedIntegrations = integrations;
return React.createElement(React.Fragment, null, children);
},
};
});
describe("board integration provider", () => {
let container: HTMLDivElement;
let root: Root;
beforeEach(() => {
container = document.createElement("div");
document.body.append(container);
root = createRoot(container);
mocks.providedIntegrations = undefined;
});
afterEach(() => {
act(() => root.unmount());
container.remove();
});
test("provides the permission-filtered server result without a second client query", () => {
const initialIntegrations = [{ id: "initial" }];
act(() => {
root.render(
createElement(
BoardIntegrationProvider,
{ initialIntegrations: initialIntegrations as never },
createElement("span", null, "board"),
),
);
});
expect(mocks.providedIntegrations).toBe(initialIntegrations);
});
});
@@ -1,14 +0,0 @@
"use client";
import type { PropsWithChildren } from "react";
import type { RouterOutputs } from "@homarr/api";
import { IntegrationProvider } from "@homarr/auth/client";
interface Props {
initialIntegrations: RouterOutputs["integration"]["all"];
}
export const BoardIntegrationProvider = ({ initialIntegrations, children }: PropsWithChildren<Props>) => {
return <IntegrationProvider integrations={initialIntegrations}>{children}</IntegrationProvider>;
};
@@ -1,47 +0,0 @@
.gutterSettings {
background: color-mix(in srgb, var(--mantine-color-default) 55%, transparent);
}
.gutterPreview {
display: grid;
gap: 6px;
width: 100%;
min-height: 112px;
padding: 8px;
overflow: hidden;
background: var(--mantine-color-body);
border: 1px solid var(--mantine-color-default-border);
border-radius: var(--mantine-radius-md);
transition: grid-template-columns 180ms ease-out;
}
.previewCanvas {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 6px;
min-width: 0;
}
.previewCanvas > div,
.previewGutter {
background: var(--mantine-color-default);
border-radius: var(--mantine-radius-sm);
}
.previewCanvas > div:nth-child(1) {
grid-row: span 2;
}
.previewCanvas > div:nth-child(4) {
grid-column: span 2;
}
.previewGutter {
min-width: 12px;
}
@media (prefers-reduced-motion: reduce) {
.gutterPreview {
transition: none;
}
}
@@ -1,20 +1,6 @@
"use client";
import {
Box,
Button,
Fieldset,
Grid,
Group,
Input,
NumberInput,
Paper,
Slider,
Stack,
Switch,
Text,
TextInput,
} from "@mantine/core";
import { Button, Fieldset, Grid, Group, Input, NumberInput, Slider, Stack, Text, TextInput } from "@mantine/core";
import { createId } from "@homarr/common";
import type { UseFormReturnType } from "@homarr/form";
@@ -22,7 +8,6 @@ import { useI18n } from "@homarr/translation/client";
import { SectionCard } from "~/components/manage/section-card";
import type { FormValues } from "./_settings-form";
import classes from "./_layout.module.css";
interface Props {
form: UseFormReturnType<FormValues>;
@@ -42,17 +27,7 @@ export const LayoutSettingsContent = ({ form }: Props) => {
onClick={() => {
form.setValues((previous) => ({
...previous,
layouts: [
...(previous.layouts ?? []),
{
id: createId(),
name: "",
columnCount: 10,
leftGutterColumnCount: 0,
rightGutterColumnCount: 0,
breakpoint: 0,
},
],
layouts: [...(previous.layouts ?? []), { id: createId(), name: "", columnCount: 10, breakpoint: 0 }],
}));
}}
>
@@ -69,21 +44,7 @@ export const LayoutSettingsContent = ({ form }: Props) => {
<Grid.Col span={{ sm: 12, md: 6 }}>
<Input.Wrapper label={t("layout.field.columnCount.label")}>
<Slider
mt="xs"
min={1}
max={24}
step={1}
value={layout.columnCount}
thumbLabel={t("layout.field.columnCount.label")}
onChange={(columnCount) => {
const left = Math.min(layout.leftGutterColumnCount, Math.max(0, columnCount - 1));
const right = Math.min(layout.rightGutterColumnCount, Math.max(0, columnCount - left - 1));
form.setFieldValue(`layouts.${index}.columnCount`, columnCount);
form.setFieldValue(`layouts.${index}.leftGutterColumnCount`, left);
form.setFieldValue(`layouts.${index}.rightGutterColumnCount`, right);
}}
/>
<Slider mt="xs" min={1} max={24} step={1} {...form.getInputProps(`layouts.${index}.columnCount`)} />
</Input.Wrapper>
</Grid.Col>
@@ -94,15 +55,6 @@ export const LayoutSettingsContent = ({ form }: Props) => {
description={t("layout.field.breakpoint.description")}
/>
</Grid.Col>
<Grid.Col span={12}>
<GutterSettings
left={layout.leftGutterColumnCount}
right={layout.rightGutterColumnCount}
columnCount={layout.columnCount}
onLeftChange={(value) => form.setFieldValue(`layouts.${index}.leftGutterColumnCount`, value)}
onRightChange={(value) => form.setFieldValue(`layouts.${index}.rightGutterColumnCount`, value)}
/>
</Grid.Col>
</Grid>
{form.values.layouts.length >= 2 && (
<Group justify="end">
@@ -126,123 +78,3 @@ export const LayoutSettingsContent = ({ form }: Props) => {
</SectionCard>
);
};
interface GutterSettingsProps {
left: number;
right: number;
columnCount: number;
onLeftChange: (value: number) => void;
onRightChange: (value: number) => void;
}
const gutterMarks = [
{ value: 1, label: "1" },
{ value: 2, label: "2" },
{ value: 3, label: "3" },
];
const GutterSettings = ({ left, right, columnCount, onLeftChange, onRightChange }: GutterSettingsProps) => {
const t = useI18n();
const maxGutterWidth = Math.min(3, Math.max(1, columnCount - 1));
return (
<Paper className={classes.gutterSettings} p="md">
<Stack gap="md">
<Box>
<Text fw={600}>{t("layout.field.gutters.label")}</Text>
<Text size="sm" c="dimmed">
{t("layout.field.gutters.description")}
</Text>
</Box>
<GutterPreview left={left} right={right} columnCount={columnCount} />
<Grid>
<Grid.Col span={{ base: 12, md: 6 }}>
<GutterControl
side="left"
value={left}
max={Math.min(maxGutterWidth, columnCount - right - 1)}
onChange={onLeftChange}
/>
</Grid.Col>
<Grid.Col span={{ base: 12, md: 6 }}>
<GutterControl
side="right"
value={right}
max={Math.min(maxGutterWidth, columnCount - left - 1)}
onChange={onRightChange}
/>
</Grid.Col>
</Grid>
</Stack>
</Paper>
);
};
const GutterControl = ({
side,
value,
max,
onChange,
}: {
side: "left" | "right";
value: number;
max: number;
onChange: (value: number) => void;
}) => {
const t = useI18n();
const enabled = value > 0;
return (
<Stack gap="sm">
<Switch
checked={enabled}
disabled={max < 1}
onChange={(event) => onChange(event.currentTarget.checked ? Math.min(2, max) : 0)}
label={t(`layout.field.gutters.${side}.label`)}
description={t(`layout.field.gutters.${side}.description`)}
/>
{enabled && (
<Input.Wrapper label={t("layout.field.gutters.width.label")}>
<Slider
mt="xs"
min={1}
max={max}
value={Math.min(value, max)}
marks={gutterMarks.filter((mark) => mark.value <= max)}
restrictToMarks
thumbLabel={t(`layout.field.gutters.${side}.thumbLabel`)}
onChange={(nextValue) => onChange(Math.min(nextValue, max))}
/>
</Input.Wrapper>
)}
</Stack>
);
};
const GutterPreview = ({ left, right, columnCount }: { left: number; right: number; columnCount: number }) => {
const t = useI18n();
const safeColumnCount = Math.max(1, columnCount);
const main = Math.max(1, safeColumnCount - left - right);
const tracks = [left > 0 ? `${left}fr` : null, `${main}fr`, right > 0 ? `${right}fr` : null]
.filter((track) => track !== null)
.join(" ");
return (
<Box
className={classes.gutterPreview}
aria-label={t("layout.field.gutters.preview.label")}
style={{ gridTemplateColumns: tracks }}
>
{left > 0 && <Box className={classes.previewGutter} data-side="left" />}
<Box className={classes.previewCanvas}>
<Box />
<Box />
<Box />
<Box />
</Box>
{right > 0 && <Box className={classes.previewGutter} data-side="right" />}
</Box>
);
};
@@ -1,5 +1,4 @@
import type { JSX, PropsWithChildren } from "react";
import { cookies, headers } from "next/headers";
import { notFound, redirect } from "next/navigation";
import { AppShellMain } from "@mantine/core";
import { TRPCError } from "@trpc/server";
@@ -8,7 +7,6 @@ import { getRscUserSettingsAsync } from "@homarr/api/user-server";
import { auth } from "@homarr/auth/next";
import { BoardProvider } from "@homarr/boards/context";
import { EditModeProvider } from "@homarr/boards/edit-mode";
import { boardViewportWidthCookieName, getLayoutIdForViewportWidth } from "@homarr/boards/layout-selection";
import { createLogger } from "@homarr/core/infrastructure/logs";
import { ModalProvider } from "@homarr/modals";
@@ -50,7 +48,6 @@ export const createBoardLayout = <TParams extends Params>({
(error: unknown) => ({ status: "rejected", error }) as const,
);
const colorSchemePromise = getCurrentColorSchemeAsync();
const initialViewportWidthPromise = getInitialViewportWidthAsync();
const shouldRunBoardTourPromise = sessionPromise.then(async (session) => {
if (!withTour || !session || env.DEMO_MODE) return false;
@@ -62,12 +59,11 @@ export const createBoardLayout = <TParams extends Params>({
return false;
}
});
const [session, initialBoardResult, colorScheme, shouldRunBoardTour, initialViewportWidth] = await Promise.all([
const [session, initialBoardResult, colorScheme, shouldRunBoardTour] = await Promise.all([
sessionPromise,
initialBoardPromise,
colorSchemePromise,
shouldRunBoardTourPromise,
initialViewportWidthPromise,
]);
if (initialBoardResult.status === "rejected") {
const { error } = initialBoardResult;
@@ -88,14 +84,9 @@ export const createBoardLayout = <TParams extends Params>({
throw error;
}
const initialBoard = initialBoardResult.board;
const initialLayoutId = getLayoutIdForViewportWidth(initialBoard.layouts, initialViewportWidth);
return (
<BoardProvider
initialBoard={initialBoard}
initialLayoutId={initialLayoutId}
initialViewportWidth={initialViewportWidth}
>
<BoardProvider initialBoard={initialBoard}>
<BoardReadyProvider>
<EditModeProvider>
<BoardMantineProvider defaultColorScheme={colorScheme}>
@@ -121,22 +112,3 @@ export const createBoardLayout = <TParams extends Params>({
return Layout;
};
const getInitialViewportWidthAsync = async () => {
const cookieValue = (await cookies()).get(boardViewportWidthCookieName)?.value;
const cookieWidth = Number(cookieValue);
if (Number.isInteger(cookieWidth) && cookieWidth >= 200 && cookieWidth <= 10_000) {
return cookieWidth;
}
const requestHeaders = await headers();
const clientHintWidth = Number(requestHeaders.get("sec-ch-viewport-width"));
if (Number.isInteger(clientHintWidth) && clientHintWidth >= 200 && clientHintWidth <= 10_000) {
return clientHintWidth;
}
const userAgent = requestHeaders.get("user-agent") ?? "";
if (/iPad|Tablet|PlayBook|Silk/i.test(userAgent)) return 1024;
if (/Mobi|Android|iPhone|iPod|IEMobile|Opera Mini/i.test(userAgent)) return 390;
return 1440;
};
@@ -7,9 +7,10 @@ export type Item = Board["items"][number];
export type ItemLayout = Item["layouts"][number];
export type SectionItem = Omit<Item, "layouts"> & ItemLayout & { type: "item" };
export type CategorySection = Extract<Section, { kind: "category" }>;
export type EmptySection = Extract<Section, { kind: "empty" }>;
export type ContainerSection = Extract<Section, { kind: "container" }>;
export type ContainerSectionLayout = ContainerSection["layouts"][number];
export type ContainerSectionItem = Omit<ContainerSection, "layouts"> & ContainerSectionLayout & { type: "section" };
export type DynamicSection = Extract<Section, { kind: "dynamic" }>;
export type DynamicSectionLayout = DynamicSection["layouts"][number];
export type DynamicSectionItem = Omit<DynamicSection, "layouts"> & DynamicSectionLayout & { type: "section" };
export type ItemOfKind<TKind extends WidgetKind> = Extract<Item, { kind: TKind }>;
-2
View File
@@ -32,7 +32,6 @@ import { env } from "~/env";
import { SearchEngineOptimization } from "~/components/layout/search-engine-optimization";
import { ServiceWorkerRegistration } from "~/components/layout/service-worker-registration";
import { ViewportHint } from "~/components/layout/viewport-hint";
import { getCurrentColorSchemeAsync } from "~/theme/color-scheme";
import { DayJsLoader } from "./_client-providers/dayjs-loader";
import { JotaiProvider } from "./_client-providers/jotai";
@@ -167,7 +166,6 @@ export default async function Layout(props: {
</head>
<body className={[fontSans.className, fontSans.variable].join(" ")} suppressHydrationWarning>
<Analytics enabled={serverSettings.analytics.enableGeneral} />
<ViewportHint />
<StackedProvider>
<Notifications pauseResetOnHover="notification" />
<ServiceWorkerRegistration />
@@ -29,6 +29,7 @@ import { db } from "@homarr/db";
import { getPackageVersion } from "~/versions/package-reader";
import { extractMcpTools } from "../_extract-tools";
import { serializeMcpToolResult } from "../_serialize-result";
const logger = createLogger({ module: "mcpRoute" });
@@ -340,7 +341,7 @@ const mcpHandler = createMcpHandler(
const input = args && Object.keys(args).length > 0 ? args : undefined;
const result = await procedure(input);
return {
content: [{ type: "text" as const, text: JSON.stringify(result) }],
content: [{ type: "text" as const, text: serializeMcpToolResult(result) }],
};
} catch (error) {
const message = sanitizeErrorMessage(error);
@@ -0,0 +1,23 @@
import { describe, expect, it } from "vitest";
import { serializeMcpToolResult } from "./_serialize-result";
describe("serializeMcpToolResult", () => {
it("serializes objects as JSON strings", () => {
expect(serializeMcpToolResult({ boardId: "1" })).toBe('{"boardId":"1"}');
});
it("serializes primitive values", () => {
expect(serializeMcpToolResult("hello")).toBe('"hello"');
expect(serializeMcpToolResult(42)).toBe("42");
expect(serializeMcpToolResult(true)).toBe("true");
});
it("serializes null as the string null", () => {
expect(serializeMcpToolResult(null)).toBe("null");
});
it("serializes void results as the string null so MCP content text is always a string", () => {
expect(serializeMcpToolResult(undefined)).toBe("null");
});
});
@@ -0,0 +1,3 @@
export function serializeMcpToolResult(result: unknown): string {
return JSON.stringify(result) ?? "null";
}
@@ -1,12 +1,10 @@
import { getBoardLayouts } from "@homarr/boards/context";
import { createId } from "@homarr/common";
import type { Modify } from "@homarr/common/types";
import { getRootSectionLane } from "@homarr/definitions";
import type { WidgetKind } from "@homarr/definitions";
import { widgetDefaultSizes } from "@homarr/definitions";
import type { Board, EmptySection, Item, ItemLayout } from "~/app/[locale]/boards/_types";
import { getBoardLaneColumnCount } from "~/components/board/layout";
import { getFirstEmptyPosition } from "./empty-position";
import { getSectionElements } from "./section-elements";
@@ -21,21 +19,17 @@ export const createItemCallback =
({ id, kind, options = {}, integrationIds = [] }: CreateItemInput) =>
(previous: Board): Board => {
const firstSection = previous.sections
.filter(
(section): section is EmptySection =>
section.kind === "empty" && getRootSectionLane(section.xOffset) === "main",
)
.filter((section): section is EmptySection => section.kind === "empty")
.toSorted((sectionA, sectionB) => sectionA.yOffset - sectionB.yOffset)
.at(0);
if (!firstSection) return previous;
const itemLayouts = createItemLayouts(previous, firstSection, kind);
const widget = {
id: id ?? createId(),
kind,
options,
layouts: itemLayouts,
layouts: createItemLayouts(previous, firstSection, kind),
integrationIds,
advancedOptions: {
title: null,
@@ -66,14 +60,9 @@ const createItemLayouts = (board: Board, currentSection: EmptySection, kind: Wid
return layouts.map((layoutId) => {
const boardLayout = board.layouts.find((layout) => layout.id === layoutId);
const elements = getSectionElements(board, { sectionId: currentSection.id, layoutId });
const columnCount = boardLayout ? getBoardLaneColumnCount(boardLayout, "main") : itemSize.width;
const size = {
width: Math.min(itemSize.width, columnCount),
height: itemSize.height,
};
const emptyPosition = boardLayout
? getFirstEmptyPosition(elements, columnCount, undefined, size)
? getFirstEmptyPosition(elements, boardLayout.columnCount, 9999, itemSize)
: { xOffset: 0, yOffset: 0 };
if (!emptyPosition) {
@@ -81,8 +70,8 @@ const createItemLayouts = (board: Board, currentSection: EmptySection, kind: Wid
}
return {
width: size.width,
height: size.height,
width: itemSize.width,
height: itemSize.height,
...emptyPosition,
sectionId: currentSection.id,
layoutId,
@@ -1,8 +1,6 @@
import { createId } from "@homarr/common";
import { getRootSectionLane } from "@homarr/definitions";
import type { Board, EmptySection, ItemLayout, Section } from "~/app/[locale]/boards/_types";
import { getBoardLaneColumnCount } from "~/components/board/layout";
import { getFirstEmptyPosition } from "./empty-position";
import { getSectionElements } from "./section-elements";
@@ -17,17 +15,16 @@ export const duplicateItemCallback =
if (!itemToDuplicate) return previous;
const clonedItem = structuredClone(itemToDuplicate);
const clonedLayouts = clonedItem.layouts.map((layout) => ({
...layout,
...getNextPosition(previous, layout),
}));
return {
...previous,
items: previous.items.concat({
...clonedItem,
id: createId(),
layouts: clonedLayouts,
layouts: clonedItem.layouts.map((layout) => ({
...layout,
...getNextPosition(previous, layout),
})),
}),
};
};
@@ -45,10 +42,8 @@ const getNextPosition = (board: Board, layout: ItemLayout): { xOffset: number; y
}
const firstSection = board.sections
.filter(
(section): section is EmptySection => section.kind === "empty" && getRootSectionLane(section.xOffset) === "main",
)
.toSorted((sectionA, sectionB) => sectionA.yOffset - sectionB.yOffset)
.filter((section): section is EmptySection => section.kind === "empty")
.sort((sectionA, sectionB) => sectionA.yOffset - sectionB.yOffset)
.at(0);
if (!firstSection) {
@@ -72,21 +67,18 @@ const getEmptySectionPosition = (
layout: ItemLayout,
section: Section,
): { xOffset: number; yOffset: number } | undefined => {
const boardLayout = board.layouts.find((candidate) => candidate.id === layout.layoutId);
const boardLayout = board.layouts.find((boardLayout) => boardLayout.id === layout.layoutId);
if (!boardLayout) return;
const sectionElements = getSectionElements(board, { sectionId: section.id, layoutId: layout.layoutId });
if (section.kind === "empty") {
const columnCount = getBoardLaneColumnCount(boardLayout, getRootSectionLane(section.xOffset));
const size = {
const sectionElements = getSectionElements(board, { sectionId: layout.sectionId, layoutId: layout.layoutId });
if (section.kind !== "dynamic") {
return getFirstEmptyPosition(sectionElements, boardLayout.columnCount, undefined, {
width: layout.width,
height: layout.height,
};
return getFirstEmptyPosition(sectionElements, columnCount, undefined, size);
});
}
const sectionLayout = section.layouts.find((candidate) => candidate.layoutId === layout.layoutId);
const sectionLayout = section.layouts.find((sectionLayout) => sectionLayout.layoutId === layout.layoutId);
if (!sectionLayout) return;
return getFirstEmptyPosition(sectionElements, sectionLayout.width, sectionLayout.height, {
@@ -7,7 +7,7 @@ export const getFirstEmptyPosition = (
size: { width: number; height: number } = { width: 1, height: 1 },
) => {
for (let yOffset = 0; yOffset < rowCount + 1 - size.height; yOffset++) {
for (let xOffset = 0; xOffset < columnCount + 1 - size.width; xOffset++) {
for (let xOffset = 0; xOffset < columnCount; xOffset++) {
const isOccupied = elements.some(
(element) =>
element.yOffset < yOffset + size.height &&
@@ -1,8 +1,8 @@
import type { Board } from "~/app/[locale]/boards/_types";
export const getSectionElements = (board: Board, { sectionId, layoutId }: { sectionId: string; layoutId: string }) => {
const containersOfSection = board.sections
.filter((section) => section.kind === "container")
const dynamicSectionsOfFirstSection = board.sections
.filter((section) => section.kind === "dynamic")
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
.map(({ layouts, ...section }) => ({ ...section, ...layouts.find((layout) => layout.layoutId === layoutId)! }))
.filter((section) => section.parentSectionId === sectionId);
@@ -14,5 +14,5 @@ export const getSectionElements = (board: Board, { sectionId, layoutId }: { sect
}))
.filter((item) => item.sectionId === sectionId);
return [...items, ...containersOfSection];
return [...items, ...dynamicSectionsOfFirstSection];
};
@@ -1,19 +1,15 @@
import { afterEach, describe, expect, test, vi } from "vitest";
import { describe, expect, test, vi } from "vitest";
import * as boardContext from "@homarr/boards/context";
import { createItemCallback } from "../create-item";
import * as emptyPositionModule from "../empty-position";
import { BoardMockBuilder } from "./mocks/board-mock";
import { ContainerSectionMockBuilder } from "./mocks/container-section-mock";
import { DynamicSectionMockBuilder } from "./mocks/dynamic-section-mock";
import { ItemMockBuilder } from "./mocks/item-mock";
import { LayoutMockBuilder } from "./mocks/layout-mock";
describe("item actions create-item", () => {
afterEach(() => {
vi.restoreAllMocks();
});
test("should add it to first section", () => {
// Arrange
const itemKind = "clock";
@@ -56,9 +52,9 @@ describe("item actions create-item", () => {
],
}),
);
expect(emptyPositionSpy).toHaveBeenCalledWith([], layout.columnCount, undefined, { height: 1, width: 1 });
expect(emptyPositionSpy).toHaveBeenCalledWith([], layout.columnCount, 9999, { height: 1, width: 1 });
});
test("should correctly pass containers and items to getFirstEmptyPosition", () => {
test("should correctly pass dynamic section and items to getFirstEmptyPosition", () => {
// Arrange
const itemKind = "clock";
const emptyPosition = { xOffset: 5, yOffset: 5 };
@@ -67,21 +63,21 @@ describe("item actions create-item", () => {
const itemAndSectionPosition = { height: 2, width: 3, yOffset: 2, xOffset: 1 };
const layout = new LayoutMockBuilder({ id: layoutId, columnCount: 4 }).build();
const containerInFirstSection = new ContainerSectionMockBuilder({ id: "4" })
const dynamicSectionInFirstSection = new DynamicSectionMockBuilder({ id: "4" })
.addLayout({ ...itemAndSectionPosition, layoutId, parentSectionId: firstSectionId })
.build();
const itemInFirstSection = new ItemMockBuilder({ id: "12" })
.addLayout({ ...itemAndSectionPosition, layoutId, sectionId: firstSectionId })
.build();
const otherContainer = new ContainerSectionMockBuilder({ id: "5" }).addLayout({ layoutId }).build();
const otherDynamicSection = new DynamicSectionMockBuilder({ id: "5" }).addLayout({ layoutId }).build();
const otherItem = new ItemMockBuilder({ id: "13" }).addLayout({ layoutId }).build();
const board = new BoardMockBuilder()
.addLayout(layout)
.addEmptySection({ id: "1", yOffset: 2 })
.addEmptySection({ id: firstSectionId, yOffset: 0 })
.addEmptySection({ id: "3", yOffset: 1 })
.addSection(containerInFirstSection)
.addSection(otherContainer)
.addSection(dynamicSectionInFirstSection)
.addSection(otherDynamicSection)
.addItem(itemInFirstSection)
.addItem(otherItem)
.build();
@@ -98,10 +94,8 @@ describe("item actions create-item", () => {
// Assert
expect(result.items.length).toBe(3);
const createdItem = result.items.find(
(candidate) => candidate.id !== itemInFirstSection.id && candidate.id !== otherItem.id,
);
expect(createdItem).toEqual(
const item = result.items.find((item) => item.id !== itemInFirstSection.id && item.id !== otherItem.id);
expect(item).toEqual(
expect.objectContaining({
kind: itemKind,
layouts: [{ ...emptyPosition, height: 1, width: 1, sectionId: firstSectionId, layoutId }],
@@ -110,35 +104,8 @@ describe("item actions create-item", () => {
expect(spy).toHaveBeenCalledWith(
[expect.objectContaining(itemAndSectionPosition), expect.objectContaining(itemAndSectionPosition)],
layout.columnCount,
undefined,
9999,
{ height: 1, width: 1 },
);
});
test("clamps wide defaults and places below a full automatic canvas", () => {
const board = new BoardMockBuilder().addEmptySection({ id: "canvas", yOffset: 0 }).build();
const layout = board.layouts[0];
if (!layout) throw new Error("Expected a board layout");
layout.id = "layout";
layout.columnCount = 1;
board.items.push(
new ItemMockBuilder({ id: "existing" })
.addLayout({ layoutId: layout.id, sectionId: "canvas", width: 1, height: 1 })
.build(),
);
const result = createItemCallback({ id: "created", kind: "mediaMissing" })(board);
const created = result.items.find((item) => item.id === "created");
expect(created?.layouts).toEqual([
expect.objectContaining({
layoutId: layout.id,
sectionId: "canvas",
width: 1,
height: 3,
xOffset: 0,
yOffset: 1,
}),
]);
});
});
@@ -1,4 +1,4 @@
import { afterEach, describe, expect, test, vi } from "vitest";
import { describe, expect, test, vi } from "vitest";
import { duplicateItemCallback } from "../duplicate-item";
import * as emptyPositionModule from "../empty-position";
@@ -7,10 +7,6 @@ import { ItemMockBuilder } from "./mocks/item-mock";
import { LayoutMockBuilder } from "./mocks/layout-mock";
describe("item actions duplicate-item", () => {
afterEach(() => {
vi.restoreAllMocks();
});
test("should copy it in the same section", () => {
// Arrange
const itemKind = "minecraftServerStatus";
@@ -65,28 +61,4 @@ describe("item actions duplicate-item", () => {
}),
);
});
test("uses the next automatic canvas row when it is the first available space", () => {
const board = new BoardMockBuilder().addEmptySection({ id: "canvas", yOffset: 0 }).build();
const layout = board.layouts[0];
if (!layout) throw new Error("Expected a board layout");
layout.id = "layout";
layout.columnCount = 1;
const currentItem = new ItemMockBuilder({ id: "source" })
.addLayout({ layoutId: layout.id, sectionId: "canvas", width: 1, height: 1 })
.build();
board.items.push(currentItem);
const result = duplicateItemCallback({ itemId: currentItem.id })(board);
const duplicated = result.items.find((item) => item.id !== currentItem.id);
expect(duplicated?.layouts[0]).toEqual(
expect.objectContaining({
layoutId: layout.id,
sectionId: "canvas",
xOffset: 0,
yOffset: 1,
}),
);
});
});
@@ -1,123 +0,0 @@
import { describe, expect, test } from "vitest";
import { getMoveTargets } from "../../item-move-modal";
import { BoardMockBuilder } from "./mocks/board-mock";
import { ContainerSectionMockBuilder } from "./mocks/container-section-mock";
import { EmptySectionMockBuilder } from "./mocks/empty-section-mock";
import { ItemMockBuilder } from "./mocks/item-mock";
describe("item move targets", () => {
test("offers the canvas, enabled gutters, and nested sections", () => {
const board = new BoardMockBuilder().build();
const layout = board.layouts[0];
if (!layout) throw new Error("Expected the board mock to contain a layout");
layout.id = "layout";
layout.columnCount = 6;
layout.leftGutterColumnCount = 1;
layout.rightGutterColumnCount = 1;
board.sections.push(
new EmptySectionMockBuilder({ id: "canvas", xOffset: 0 }).build(),
new EmptySectionMockBuilder({ id: "left-gutter", xOffset: -1 }).build(),
new EmptySectionMockBuilder({ id: "right-gutter", xOffset: 1 }).build(),
new ContainerSectionMockBuilder({
id: "container",
options: {
title: "",
customCssClasses: [],
borderColor: "",
showLabel: true,
collapsible: false,
showOpenAll: false,
},
})
.addLayout({
layoutId: layout.id,
parentSectionId: "canvas",
width: 2,
height: 2,
})
.build(),
);
const targets = getMoveTargets(
board,
layout.id,
{
id: "weather",
type: "item",
xOffset: 0,
yOffset: 0,
width: 1,
height: 1,
},
{
canvas: "Dashboard canvas",
container: "New section",
leftRail: "Left dashboard rail",
rightRail: "Right dashboard rail",
numbered: (name, index) => `${name}, destination ${index}`,
located: (name, location, index) => `${name}, ${location}, destination ${index}`,
},
);
expect(targets.map((target) => target.label)).toEqual([
"Dashboard canvas, destination 1",
"Left dashboard rail, destination 2",
"Right dashboard rail, destination 3",
"New section, destination 4",
]);
expect(new Set(targets.map((target) => target.label))).toHaveLength(targets.length);
});
test("does not offer canvases that are narrower than a nested section's contents", () => {
const board = new BoardMockBuilder().build();
const layout = board.layouts[0];
if (!layout) throw new Error("Expected the board mock to contain a layout");
layout.id = "layout";
layout.columnCount = 5;
layout.leftGutterColumnCount = 2;
const section = new ContainerSectionMockBuilder({ id: "source" })
.addLayout({
layoutId: layout.id,
parentSectionId: "canvas",
width: 4,
height: 2,
})
.build();
board.sections.push(
new EmptySectionMockBuilder({ id: "canvas" }).build(),
new EmptySectionMockBuilder({ id: "left-gutter", xOffset: -1 }).build(),
section,
);
board.items.push(
new ItemMockBuilder({ id: "nested" })
.addLayout({
layoutId: layout.id,
sectionId: section.id,
xOffset: 2,
width: 2,
})
.build(),
);
const sourceLayout = section.layouts[0];
if (!sourceLayout) throw new Error("Expected the source section to contain a layout");
const targets = getMoveTargets(
board,
layout.id,
{ ...sourceLayout, id: section.id, type: "section" },
{
canvas: "Canvas",
container: "Section",
leftRail: "Left",
rightRail: "Right",
numbered: (name, index) => `${name} ${index}`,
located: (name, location, index) => `${name} ${location} ${index}`,
},
);
expect(targets).toEqual([]);
});
});
@@ -1,7 +1,7 @@
import { createId } from "@homarr/common";
import type { Board, ContainerSection, EmptySection, Item, Section } from "~/app/[locale]/boards/_types";
import { ContainerSectionMockBuilder } from "./container-section-mock";
import type { Board, DynamicSection, EmptySection, Item, Section } from "~/app/[locale]/boards/_types";
import { DynamicSectionMockBuilder } from "./dynamic-section-mock";
import { EmptySectionMockBuilder } from "./empty-section-mock";
import { ItemMockBuilder } from "./item-mock";
import { LayoutMockBuilder } from "./layout-mock";
@@ -45,8 +45,6 @@ export class BoardMockBuilder {
id: createId(),
name: "Base",
columnCount: 12,
leftGutterColumnCount: 0,
rightGutterColumnCount: 0,
breakpoint: 0,
},
],
@@ -58,8 +56,8 @@ export class BoardMockBuilder {
return this.addSection(new EmptySectionMockBuilder(emptySection).build());
}
public addContainer(container?: Partial<ContainerSection>): BoardMockBuilder {
return this.addSection(new ContainerSectionMockBuilder(container).build());
public addDynamicSection(dynamicSection?: Partial<DynamicSection>): BoardMockBuilder {
return this.addSection(new DynamicSectionMockBuilder(dynamicSection).build());
}
public addSection(section: Section): BoardMockBuilder {
@@ -0,0 +1,23 @@
import { createId } from "@homarr/common";
import type { CategorySection } from "~/app/[locale]/boards/_types";
export class CategorySectionMockBuilder {
private readonly section: CategorySection;
constructor(section?: Partial<CategorySection>) {
this.section = {
id: createId(),
kind: "category",
xOffset: 0,
yOffset: 0,
name: "Category",
collapsed: false,
...section,
} satisfies CategorySection;
}
public build(): CategorySection {
return this.section;
}
}
@@ -1,38 +0,0 @@
import { createId } from "@homarr/common";
import { containerSectionOptionsSchema } from "@homarr/validation/shared";
import type { ContainerSection } from "~/app/[locale]/boards/_types";
type ContainerSectionMock = Omit<Partial<ContainerSection>, "options"> & {
options?: Partial<ContainerSection["options"]>;
};
export class ContainerSectionMockBuilder {
private readonly section: ContainerSection;
constructor(section?: ContainerSectionMock) {
this.section = {
id: section?.id ?? createId(),
kind: "container",
collapsed: section?.collapsed ?? false,
layouts: section?.layouts ?? [],
options: containerSectionOptionsSchema.parse(section?.options),
};
}
public addLayout(layout?: Partial<ContainerSection["layouts"][0]>): ContainerSectionMockBuilder {
this.section.layouts.push({
layoutId: layout?.layoutId ?? createId(),
parentSectionId: layout?.parentSectionId ?? createId(),
xOffset: layout?.xOffset ?? 0,
yOffset: layout?.yOffset ?? 0,
width: layout?.width ?? 1,
height: layout?.height ?? 1,
});
return this;
}
public build(): ContainerSection {
return structuredClone(this.section);
}
}
@@ -0,0 +1,38 @@
import { createId } from "@homarr/common";
import type { DynamicSection } from "~/app/[locale]/boards/_types";
export class DynamicSectionMockBuilder {
private readonly section: DynamicSection;
constructor(section?: Partial<DynamicSection>) {
this.section = {
id: createId(),
kind: "dynamic",
options: {
title: "",
borderColor: "",
customCssClasses: [],
},
layouts: [],
...section,
} satisfies DynamicSection;
}
public addLayout(layout?: Partial<DynamicSection["layouts"][0]>): DynamicSectionMockBuilder {
this.section.layouts.push({
layoutId: "1",
height: 1,
width: 1,
xOffset: 0,
yOffset: 0,
parentSectionId: "0",
...layout,
} satisfies DynamicSection["layouts"][0]);
return this;
}
public build(): DynamicSection {
return this.section;
}
}
@@ -10,8 +10,6 @@ export class LayoutMockBuilder {
id: createId(),
name: "Base",
columnCount: 12,
leftGutterColumnCount: 0,
rightGutterColumnCount: 0,
breakpoint: 0,
...layout,
} satisfies Board["layouts"][0];
@@ -10,17 +10,3 @@
background-color: var(--background-color) !important;
border-color: var(--border-color) !important;
}
.editModeInertContent {
display: contents;
}
.editModeInertContent[data-board-grid-inert-content="true"] {
pointer-events: none;
user-select: none;
}
.editModeInertContent[data-board-grid-inert-content="true"] * {
pointer-events: none !important;
user-select: none !important;
}
@@ -1,6 +1,5 @@
import type { ComponentType, MutableRefObject } from "react";
import { Suspense, use, useRef } from "react";
import dynamic from "next/dynamic";
import type { CardProps } from "@mantine/core";
import { Badge, Card, Center, Loader } from "@mantine/core";
import { useElementSize } from "@mantine/hooks";
@@ -22,22 +21,18 @@ import type { SectionItem } from "~/app/[locale]/boards/_types";
import classes from "../sections/item.module.css";
import { useItemActions } from "./item-actions";
import itemContentClasses from "./item-content.module.css";
import { BoardItemMenu } from "./item-menu";
import { WidgetContextMenu } from "./widget-context-menu";
import { removeWidgetDataQueries } from "./widget-query-recovery";
const BoardItemMenu = dynamic(() => import("./item-menu").then((module) => module.BoardItemMenu), {
ssr: false,
});
interface BoardItemContentProps {
item: SectionItem;
}
const getOverflowFromKind = (kind: SectionItem["kind"], hasCustomCssClasses: boolean) => {
if (kind === "systemResources") return { overflowX: "visible", overflowY: "visible" } as const;
if (kind === "iframe") return { overflowX: "hidden", overflowY: "hidden" } as const;
if (hasCustomCssClasses) return {};
return { overflowX: "hidden", overflowY: "auto" } as const;
const getOverflowFromKind = (kind: SectionItem["kind"]) => {
if (kind === "iframe") return "hidden";
if (kind === "systemResources") return "visible";
return undefined;
};
type BoardItemCardProps = CardProps & {
@@ -52,13 +47,10 @@ const BoardItemCard = ({ item, innerRef, children, ...cardProps }: BoardItemCard
<Card
{...cardProps}
ref={innerRef}
w="100%"
h="100%"
data-grid-item-content
className={combineClasses(
classes.itemCard,
`${item.kind}-wrapper`,
"board-grid-item-content",
"grid-stack-item-content",
item.advancedOptions.customCssClasses.join(" "),
)}
radius={board.itemRadius}
@@ -66,7 +58,7 @@ const BoardItemCard = ({ item, innerRef, children, ...cardProps }: BoardItemCard
root: {
"--opacity": board.opacity / 100,
containerType: "size",
...getOverflowFromKind(item.kind, item.advancedOptions.customCssClasses.length > 0),
overflow: getOverflowFromKind(item.kind),
"--border-color": item.advancedOptions.borderColor !== "" ? item.advancedOptions.borderColor : undefined,
},
}}
@@ -150,7 +142,6 @@ interface LoadedBoardItemContentProps extends Omit<InnerContentProps, "definitio
const LoadedBoardItemContent = ({ item, innerRef, ...innerContentProps }: LoadedBoardItemContentProps) => {
const { definition, Component } = use(loadWidgetResources(item.kind));
const [isEditMode] = useEditMode();
return (
<WidgetContextMenu item={item} definition={definition} widgetStateRef={innerContentProps.widgetStateRef}>
@@ -159,14 +150,8 @@ const LoadedBoardItemContent = ({ item, innerRef, ...innerContentProps }: Loaded
resetKeys={[item.kind]}
fallbackRender={({ resetErrorBoundary, error }) => (
<>
<BoardItemMenu item={item} definition={definition} resetErrorBoundary={resetErrorBoundary} />
<div
className={itemContentClasses.editModeInertContent}
data-board-grid-inert-content={isEditMode ? "true" : undefined}
inert={isEditMode}
>
<WidgetError definition={definition} error={error} resetErrorBoundary={resetErrorBoundary} />
</div>
<BoardItemMenu offset={4} item={item} definition={definition} resetErrorBoundary={resetErrorBoundary} />
<WidgetError definition={definition} error={error} resetErrorBoundary={resetErrorBoundary} />
</>
)}
>
@@ -210,16 +195,13 @@ const InnerContent = ({ item, definition, Component, ...dimensions }: InnerConte
}}
fallbackRender={({ resetErrorBoundary, error }) => (
<>
{isEditMode && (
<BoardItemMenu item={newItem} definition={definition} resetErrorBoundary={resetErrorBoundary} />
)}
<div
className={itemContentClasses.editModeInertContent}
data-board-grid-inert-content={isEditMode ? "true" : undefined}
inert={isEditMode}
>
<WidgetError definition={definition} error={error} resetErrorBoundary={resetErrorBoundary} />
</div>
<BoardItemMenu
offset={4}
item={newItem}
definition={definition}
resetErrorBoundary={resetErrorBoundary}
/>
<WidgetError definition={definition} error={error} resetErrorBoundary={resetErrorBoundary} />
</>
)}
>
@@ -231,13 +213,8 @@ const InnerContent = ({ item, definition, Component, ...dimensions }: InnerConte
(!("integrationsRequired" in definition) || definition.integrationsRequired !== false)
}
/>
{isEditMode && <BoardItemMenu item={newItem} definition={definition} />}
<div
className={itemContentClasses.editModeInertContent}
data-board-grid-inert-content={isEditMode ? "true" : undefined}
data-homarr-widget-ready={item.kind}
inert={isEditMode}
>
<BoardItemMenu offset={4} item={newItem} definition={definition} />
<div data-homarr-widget-ready={item.kind} style={{ display: "contents" }}>
<Component
options={options as never}
integrationIds={item.integrationIds}
@@ -2,6 +2,7 @@ import { useEffect, useRef, useState } from "react";
import { ActionIcon, Menu } from "@mantine/core";
import { IconCopy, IconDotsVertical, IconLayoutKanban, IconPencil, IconTrash } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
import { useSession } from "@homarr/auth/client";
import { useEditMode } from "@homarr/boards/edit-mode";
import { useConfirmModal, useModalAction } from "@homarr/modals";
@@ -12,10 +13,11 @@ import type { WidgetDefinition } from "@homarr/widgets/definition";
import type { SectionItem } from "~/app/[locale]/boards/_types";
import { useSectionContext } from "../sections/section-context";
import { useItemActions } from "./item-actions";
import { useOpenItemMoveModal } from "./item-move-modal";
import { LazyItemMoveModal, preloadItemMoveModal } from "./lazy-item-move-modal";
import { LazyWidgetEditModal, preloadWidgetEditModal } from "./lazy-widget-edit-modal";
interface BoardItemMenuProps {
offset: number;
item: SectionItem;
definition: WidgetDefinition;
resetErrorBoundary?: () => void;
@@ -29,21 +31,25 @@ export const BoardItemMenu = (props: BoardItemMenuProps) => {
return <BoardItemMenuInner {...props} />;
};
const BoardItemMenuInner = ({ item, definition, resetErrorBoundary }: BoardItemMenuProps) => {
const BoardItemMenuInner = ({ offset, item, definition, resetErrorBoundary }: BoardItemMenuProps) => {
const { data: session } = useSession();
const canDuplicate = item.kind !== "customApi" || (session?.user.permissions.includes("admin") ?? false);
const refResetErrorBoundaryOnNextRender = useRef(false);
const tItem = useScopedI18n("item");
const t = useI18n();
const { openModal } = useModalAction(LazyWidgetEditModal);
const openMoveModal = useOpenItemMoveModal();
const { openModal: openMoveModal } = useModalAction(LazyItemMoveModal);
const { openConfirmModal } = useConfirmModal();
const [isMenuOpen, setIsMenuOpen] = useState(false);
const hasSupportedIntegrations =
"supportedIntegrations" in definition && (definition.supportedIntegrations?.length ?? 0) > 0;
const { updateItemOptions, updateItemAdvancedOptions, updateItemIntegrations, duplicateItem, removeItem } =
useItemActions();
const { integrations: integrationData = [], section } = useSectionContext();
const { data: integrationData, isPending } = clientApi.integration.all.useQuery(undefined, {
enabled: isMenuOpen && hasSupportedIntegrations,
});
const { gridstack } = useSectionContext().refs;
const settings = useSettings();
const label = item.advancedOptions.title?.trim() || t(`widget.${item.kind}.name`);
// Reset error boundary on next render if item has been edited
useEffect(() => {
@@ -78,7 +84,7 @@ const BoardItemMenuInner = ({ item, definition, resetErrorBoundary }: BoardItemM
});
refResetErrorBoundaryOnNextRender.current = true;
},
integrationData: integrationData.filter(
integrationData: (integrationData ?? []).filter(
(integration) =>
"supportedIntegrations" in definition &&
(definition.supportedIntegrations as string[]).some((kind) => kind === integration.kind),
@@ -89,8 +95,8 @@ const BoardItemMenuInner = ({ item, definition, resetErrorBoundary }: BoardItemM
appId: item.kind === "app" ? (item.options.appId as string | undefined) : undefined,
},
{
title(translate) {
return `${translate("item.edit.title")} - ${translate(`widget.${item.kind}.name`)}`;
title(t) {
return `${t("item.edit.title")} - ${t(`widget.${item.kind}.name`)}`;
},
},
);
@@ -111,16 +117,14 @@ const BoardItemMenuInner = ({ item, definition, resetErrorBoundary }: BoardItemM
<Menu.Target>
<ActionIcon
variant="default"
size={24}
radius="sm"
radius={"xl"}
pos="absolute"
top={12}
right={8}
top={offset}
right={offset}
style={{ zIndex: 10 }}
data-board-widget-settings
aria-label={tItem("menu.label.settingsFor", { name: label })}
aria-label={tItem("menu.label.settings")}
>
<IconDotsVertical size="1rem" />
<IconDotsVertical size={"1rem"} />
</ActionIcon>
</Menu.Target>
<Menu.Dropdown miw={128}>
@@ -130,16 +134,17 @@ const BoardItemMenuInner = ({ item, definition, resetErrorBoundary }: BoardItemM
onClick={openEditModal}
onFocus={preloadWidgetEditModal}
onPointerEnter={preloadWidgetEditModal}
disabled={hasSupportedIntegrations && isPending}
>
{tItem("action.edit")}
</Menu.Item>
<Menu.Item
leftSection={<IconLayoutKanban size={16} />}
onFocus={preloadItemMoveModal}
onPointerEnter={preloadItemMoveModal}
onClick={() => {
openMoveModal({
entry: item,
sourceSectionId: section.id,
});
if (!gridstack.current) return;
openMoveModal({ item, columnCount: gridstack.current.getColumn(), gridStack: gridstack.current });
}}
>
{tItem("action.moveResize")}
@@ -1,220 +1,94 @@
import { useCallback, useMemo } from "react";
import { Grid, NumberInput, Select, Stack } from "@mantine/core";
import { useCallback, useRef } from "react";
import { Grid, NumberInput, Stack } from "@mantine/core";
import { z } from "zod/v4";
import { useCurrentLayout, useRequiredBoard } from "@homarr/boards/context";
import { getRootSectionLane } from "@homarr/definitions";
import { useZodForm } from "@homarr/form";
import { createModal, ModalFormFooter, modalSizeForm, useModalAction } from "@homarr/modals";
import { useScopedI18n } from "@homarr/translation/client";
import type { GridStack } from "@homarr/gridstack";
import { createModal, ModalFormFooter, modalSizeForm } from "@homarr/modals";
import { useI18n } from "@homarr/translation/client";
import type { Board, ContainerSectionItem, SectionItem } from "~/app/[locale]/boards/_types";
import { getLayoutRowCount } from "../layout";
import { resolvePinnedGridCollisions } from "../sections/grid/dnd";
import { getSectionGridColumnCount, getSectionGridPlacements } from "../sections/grid/section-grid-placements";
import type { CommitSectionGridInput, SectionGridPlacement } from "../sections/grid/use-grid-layout-actions";
import { useGridLayoutActions } from "../sections/grid/use-grid-layout-actions";
import type { Item, SectionItem } from "~/app/[locale]/boards/_types";
export type MovableEntry = Pick<
SectionItem | ContainerSectionItem,
"id" | "type" | "width" | "height" | "xOffset" | "yOffset"
>;
export interface OpenItemMoveModalInput {
entry: MovableEntry;
sourceSectionId: string;
}
interface InnerProps extends OpenItemMoveModalInput {
board: Board;
currentLayoutId: string;
commitSectionGrids: (snapshots: readonly CommitSectionGridInput[]) => void;
}
interface MoveTarget {
id: string;
label: string;
export interface ItemMoveModalProps {
gridStack: GridStack;
item: Pick<SectionItem, "id" | "width" | "height" | "xOffset" | "yOffset">;
columnCount: number;
maxRowCount: number | null;
}
interface MoveTargetLabels {
canvas: string;
container: string;
leftRail: string;
rightRail: string;
numbered: (name: string, index: number) => string;
located: (name: string, location: string, index: number) => string;
}
type MoveTargetCandidate = Omit<MoveTarget, "label"> & {
name: string;
location?: string;
};
export const ItemMoveModal = createModal<InnerProps>(({ actions, innerProps }) => {
const tLandmark = useScopedI18n("board.landmark");
const tContainer = useScopedI18n("section.container");
const tMoveResize = useScopedI18n("item.moveResize");
const { board, commitSectionGrids, currentLayoutId, entry, sourceSectionId } = innerProps;
const minimumSize = useMemo(
() => getEntryMinimumSize(board, currentLayoutId, entry),
[board, currentLayoutId, entry],
);
const targets = useMemo(
() =>
getMoveTargets(board, currentLayoutId, entry, {
canvas: tLandmark("canvas"),
container: tContainer("action.create"),
leftRail: tLandmark("leftRail"),
rightRail: tLandmark("rightRail"),
numbered: (name, index) => tMoveResize("target.numbered", { name, index: String(index) }),
located: (name, location, index) =>
tMoveResize("target.located", {
name,
location,
index: String(index),
}),
}),
[board, currentLayoutId, entry, tContainer, tLandmark, tMoveResize],
);
const targetById = useMemo(() => new Map(targets.map((target) => [target.id, target])), [targets]);
const initialTarget = targetById.get(sourceSectionId) ?? targets[0];
if (!initialTarget) {
throw new Error("No valid dashboard section is available for this item");
}
export const ItemMoveModal = createModal<ItemMoveModalProps>(({ actions, innerProps }) => {
const t = useI18n();
const maxWidthRef = useRef(innerProps.columnCount - innerProps.item.xOffset);
const form = useZodForm(
z.object({
sectionId: z.string().min(1),
xOffset: z.number().int().min(0).max(32767),
yOffset: z.number().int().min(0).max(32767),
width: z.number().int().min(1).max(32767),
height: z.number().int().min(1).max(32767),
xOffset: z
.number()
.min(0)
.max(innerProps.columnCount - 1),
yOffset: z.number().min(0),
width: z.number().min(1).max(maxWidthRef.current),
height: z.number().min(1),
}),
{
initialValues: {
sectionId: initialTarget.id,
xOffset: entry.xOffset,
yOffset: entry.yOffset,
width: entry.width,
height: entry.height,
xOffset: innerProps.item.xOffset,
yOffset: innerProps.item.yOffset,
width: innerProps.item.width,
height: innerProps.item.height,
},
onValuesChange(values, previous) {
if (values.xOffset !== previous.xOffset) {
maxWidthRef.current = innerProps.columnCount - values.xOffset;
}
},
},
);
const selectedTarget = targetById.get(form.values.sectionId) ?? initialTarget;
const handleSubmit = useCallback(
(values: { sectionId: string; xOffset: number; yOffset: number; width: number; height: number }) => {
const target = targetById.get(values.sectionId);
if (!target) return;
const width = Math.max(minimumSize.width, Math.min(values.width, target.columnCount));
const height = Math.max(minimumSize.height, values.height);
const nextPlacement: SectionGridPlacement = {
id: entry.id,
type: entry.type,
x: Math.min(values.xOffset, target.columnCount - width),
(values: Pick<Item["layouts"][number], "height" | "width" | "xOffset" | "yOffset">) => {
const gridItem = innerProps.gridStack
.getGridItems()
.find((item) => item.getAttribute("data-id") === innerProps.item.id);
if (!gridItem) return;
innerProps.gridStack.update(gridItem, {
h: values.height,
w: values.width,
x: values.xOffset,
y: values.yOffset,
w: width,
h: height,
};
const targetPlacements = getSectionGridPlacements(board, currentLayoutId, target.id).filter(
(placement) => placement.id !== entry.id,
);
const resolvedTarget = resolvePinnedGridCollisions(
[...targetPlacements, nextPlacement],
nextPlacement,
target.columnCount,
);
if (target.maxRowCount !== null && getLayoutRowCount(resolvedTarget) > target.maxRowCount) {
form.setFieldError("height", tMoveResize("keyboard.boundary"));
return;
}
const snapshots =
sourceSectionId === target.id
? [{ layoutId: currentLayoutId, sectionId: target.id, placements: resolvedTarget }]
: [
{
layoutId: currentLayoutId,
sectionId: sourceSectionId,
placements: getSectionGridPlacements(board, currentLayoutId, sourceSectionId).filter(
(placement) => placement.id !== entry.id,
),
},
{ layoutId: currentLayoutId, sectionId: target.id, placements: resolvedTarget },
];
commitSectionGrids(snapshots);
});
actions.closeModal();
focusMovedEntry(entry.id, target.id);
},
[
actions,
board,
commitSectionGrids,
currentLayoutId,
entry,
form,
minimumSize,
sourceSectionId,
targetById,
tMoveResize,
],
[actions, innerProps.gridStack, innerProps.item.id],
);
return (
<form onSubmit={form.onSubmit(handleSubmit, console.error)}>
<Stack>
<Select
data={targets.map((target) => ({ value: target.id, label: target.label }))}
label={tMoveResize("field.section.label")}
data-autofocus
allowDeselect={false}
searchable
{...form.getInputProps("sectionId")}
/>
<Grid>
<Grid.Col span={{ base: 12, md: 6 }}>
<NumberInput
label={tMoveResize("field.xOffset.label")}
label={t("item.moveResize.field.xOffset.label")}
data-autofocus
min={0}
max={selectedTarget.columnCount - 1}
max={innerProps.columnCount - 1}
{...form.getInputProps("xOffset")}
/>
</Grid.Col>
<Grid.Col span={{ base: 12, md: 6 }}>
<NumberInput
label={tMoveResize("field.yOffset.label")}
min={0}
max={selectedTarget.maxRowCount === null ? undefined : selectedTarget.maxRowCount - 1}
{...form.getInputProps("yOffset")}
/>
<NumberInput label={t("item.moveResize.field.yOffset.label")} min={0} {...form.getInputProps("yOffset")} />
</Grid.Col>
<Grid.Col span={{ base: 12, md: 6 }}>
<NumberInput
label={tMoveResize("field.width.label")}
min={minimumSize.width}
max={selectedTarget.columnCount - Math.min(form.values.xOffset, selectedTarget.columnCount - 1)}
label={t("item.moveResize.field.width.label")}
min={1}
max={innerProps.columnCount - form.values.xOffset}
{...form.getInputProps("width")}
/>
</Grid.Col>
<Grid.Col span={{ base: 12, md: 6 }}>
<NumberInput
label={tMoveResize("field.height.label")}
min={minimumSize.height}
max={
selectedTarget.maxRowCount === null
? undefined
: selectedTarget.maxRowCount - Math.min(form.values.yOffset, selectedTarget.maxRowCount - 1)
}
{...form.getInputProps("height")}
/>
<NumberInput label={t("item.moveResize.field.height.label")} min={1} {...form.getInputProps("height")} />
</Grid.Col>
</Grid>
<ModalFormFooter onCancel={actions.closeModal} />
@@ -227,137 +101,3 @@ export const ItemMoveModal = createModal<InnerProps>(({ actions, innerProps }) =
},
size: modalSizeForm,
});
export const useOpenItemMoveModal = () => {
const board = useRequiredBoard();
const currentLayoutId = useCurrentLayout();
const { commitSectionGrids } = useGridLayoutActions();
const { openModal } = useModalAction(ItemMoveModal);
return useCallback(
(input: OpenItemMoveModalInput) => {
openModal({
...input,
board,
currentLayoutId,
commitSectionGrids,
});
},
[board, commitSectionGrids, currentLayoutId, openModal],
);
};
export const getMoveTargets = (
board: Board,
layoutId: string,
entry: MovableEntry,
labels: MoveTargetLabels,
): MoveTarget[] => {
const minimum = getEntryMinimumSize(board, layoutId, entry);
const candidates = board.sections.flatMap((section): MoveTargetCandidate[] => {
if (section.kind === "empty") {
const columnCount = getSectionColumnCount(board, layoutId, section.id);
const lane = getRootSectionLane(section.xOffset);
if (columnCount === 0) return [];
return minimum.width <= columnCount
? [
{
id: section.id,
name: lane === "left" ? labels.leftRail : lane === "right" ? labels.rightRail : labels.canvas,
columnCount,
maxRowCount: getSectionMaxRowCount(board, layoutId, section.id),
},
]
: [];
}
const layout = section.layouts.find((candidate) => candidate.layoutId === layoutId);
if (!layout) return [];
if (
(entry.type === "section" && isSameOrDescendant(board, layoutId, section.id, entry.id)) ||
minimum.width > layout.width ||
minimum.height > layout.height
) {
return [];
}
return [
{
id: section.id,
name: section.options.title || labels.container,
columnCount: layout.width,
maxRowCount: layout.height,
},
];
});
return candidates.map(({ name, location, ...target }, index) => ({
...target,
label: location ? labels.located(name, location, index + 1) : labels.numbered(name, index + 1),
}));
};
const getSectionColumnCount = (board: Board, layoutId: string, sectionId: string) => {
const layout = board.layouts.find((candidate) => candidate.id === layoutId);
if (!layout) throw new Error(`Layout "${layoutId}" was not found`);
const section = board.sections.find((candidate) => candidate.id === sectionId);
if (!section) throw new Error(`Section "${sectionId}" was not found`);
const columnCount = getSectionGridColumnCount(board, layoutId, sectionId);
if (columnCount === null) throw new Error(`Section "${sectionId}" has no current layout`);
return columnCount;
};
const getSectionMaxRowCount = (board: Board, layoutId: string, sectionId: string) => {
const section = board.sections.find((candidate) => candidate.id === sectionId);
if (!section) return null;
if (section.kind === "container") {
return section.layouts.find((candidate) => candidate.layoutId === layoutId)?.height ?? null;
}
return null;
};
const getEntryMinimumSize = (board: Board, layoutId: string, entry: MovableEntry) => {
if (entry.type === "item") return { width: 1, height: 1 };
const children = getSectionGridPlacements(board, layoutId, entry.id);
return {
width: Math.max(1, ...children.map((child) => child.x + child.w)),
height: Math.max(1, ...children.map((child) => child.y + child.h)),
};
};
const isSameOrDescendant = (board: Board, layoutId: string, candidateId: string, ancestorId: string) => {
let current: string | undefined = candidateId;
const visited = new Set<string>();
while (current && !visited.has(current)) {
if (current === ancestorId) return true;
visited.add(current);
const section = board.sections.find((candidate) => candidate.id === current && candidate.kind === "container");
if (!section || section.kind !== "container") return false;
current = section.layouts.find((layout) => layout.layoutId === layoutId)?.parentSectionId;
}
return false;
};
const focusMovedEntry = (entryId: string, sectionId: string) => {
let attempts = 0;
const tryFocus = () => {
const section = Array.from(document.querySelectorAll<HTMLElement>("[data-grid-section-id]")).find(
(candidate) => candidate.dataset.gridSectionId === sectionId,
);
const entry = Array.from(section?.querySelectorAll<HTMLElement>(":scope > [data-grid-item-id]") ?? []).find(
(candidate) => candidate.dataset.gridItemId === entryId,
);
const editorEntry = entry?.querySelector<HTMLElement>("[data-editor-grid-entry]");
if (editorEntry) {
editorEntry.focus();
return;
}
attempts += 1;
if (attempts < 10) requestAnimationFrame(tryFocus);
};
requestAnimationFrame(tryFocus);
};
@@ -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,
});
@@ -3,7 +3,15 @@
import type { MutableRefObject, ReactNode } from "react";
import { useCallback, useMemo, useState } from "react";
import { Group, Loader, Menu, Switch, Text, Tooltip } from "@mantine/core";
import { IconAlertTriangle, IconCircleCheck, IconRefresh, IconSettings } 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 { partialMatchKey, useIsFetching, useQueryClient } from "@tanstack/react-query";
@@ -12,7 +20,7 @@ import { useSession } from "@homarr/auth/client";
import { useRequiredBoard } from "@homarr/boards/context";
import { useEditMode } from "@homarr/boards/edit-mode";
import { useTimeAgo } from "@homarr/common";
import { useModalAction } from "@homarr/modals";
import { useConfirmModal, useModalAction } from "@homarr/modals";
import { useSettings } from "@homarr/settings";
import { translateIfNecessary } from "@homarr/translation";
import { useI18n, useScopedI18n } from "@homarr/translation/client";
@@ -22,7 +30,9 @@ import { getWidgetQueryKeys } from "@homarr/widgets/definition";
import { reduceWidgetOptionsWithDefinition } from "@homarr/widgets/manifest";
import type { SectionItem } from "~/app/[locale]/boards/_types";
import { useSectionContext } from "../sections/section-context";
import { useItemActions } from "./item-actions";
import { LazyItemMoveModal, preloadItemMoveModal } from "./lazy-item-move-modal";
import { LazyWidgetEditModal, preloadWidgetEditModal } from "./lazy-widget-edit-modal";
interface WidgetContextMenuProps {
@@ -37,7 +47,10 @@ export const WidgetContextMenu = ({ item, definition, widgetStateRef, children }
const [isEditMode] = useEditMode();
const settings = useSettings();
if (!session || !settings.enableRightClickOnWidgets || isEditMode || item.kind === "app") return <>{children}</>;
if (!session || !settings.enableRightClickOnWidgets) return <>{children}</>;
const isLinkWidget = item.kind === "app" || item.kind === "bookmarks";
// Keep the Homarr menu in edit mode so these widgets can still be edited or removed.
if (isLinkWidget && !isEditMode) return <>{children}</>;
return (
<WidgetContextMenuInner item={item} definition={definition} widgetStateRef={widgetStateRef} settings={settings}>
@@ -87,6 +100,7 @@ type WidgetContextMenuDropdownProps = Omit<WidgetContextMenuProps, "children"> &
const WidgetContextMenuDropdown = ({ item, definition, widgetStateRef, settings }: WidgetContextMenuDropdownProps) => {
const [isEditMode] = useEditMode();
const board = useRequiredBoard();
const tItem = useScopedI18n("item");
const tMenu = useScopedI18n("item.menu.label");
const t = useI18n();
const { data: session } = useSession();
@@ -94,11 +108,15 @@ const WidgetContextMenuDropdown = ({ item, definition, widgetStateRef, settings
const hasSupportedIntegrations =
"supportedIntegrations" in definition && (definition.supportedIntegrations?.length ?? 0) > 0;
const { openModal } = useModalAction(LazyWidgetEditModal);
const { updateItemOptions, updateItemAdvancedOptions, updateItemIntegrations } = useItemActions();
const { data: integrationData = [], isPending } = clientApi.integration.all.useQuery(undefined, {
const { openModal: openMoveModal } = useModalAction(LazyItemMoveModal);
const { openConfirmModal } = useConfirmModal();
const { updateItemOptions, updateItemAdvancedOptions, updateItemIntegrations, duplicateItem, removeItem } =
useItemActions();
const { data: integrationData, isPending } = clientApi.integration.all.useQuery(undefined, {
enabled: hasSupportedIntegrations,
});
const { mutate: saveBoard } = clientApi.board.saveBoard.useMutation();
const { gridstack } = useSectionContext().refs;
const queryClient = useQueryClient();
const widgetQueryKeys = useMemo(() => getWidgetQueryKeys(definition, item.kind), [definition, item.kind]);
@@ -174,7 +192,7 @@ const WidgetContextMenuDropdown = ({ item, definition, widgetStateRef, settings
);
}
},
integrationData: integrationData.filter(
integrationData: (integrationData ?? []).filter(
(integration) =>
"supportedIntegrations" in definition &&
(definition.supportedIntegrations as string[]).some((kind) => kind === integration.kind),
@@ -252,8 +270,35 @@ const WidgetContextMenuDropdown = ({ item, definition, widgetStateRef, settings
</>
)}
{isEditMode && (
<>
{(toggleOptions.length > 0 || visibleWidgetActions.length > 0) && <Menu.Divider />}
<Menu.Item
closeMenuOnClick
leftSection={<IconLayoutKanban size={16} />}
onFocus={preloadItemMoveModal}
onPointerEnter={preloadItemMoveModal}
onClick={() => {
if (!gridstack.current) return;
openMoveModal({ item, columnCount: gridstack.current.getColumn(), gridStack: gridstack.current });
}}
>
{tItem("action.moveResize")}
</Menu.Item>
{canConfigureWidget && (
<Menu.Item
closeMenuOnClick
leftSection={<IconCopy size={16} />}
onClick={() => duplicateItem({ itemId: item.id })}
>
{tItem("action.duplicate")}
</Menu.Item>
)}
</>
)}
<>
{(toggleOptions.length > 0 || visibleWidgetActions.length > 0) && <Menu.Divider />}
{(toggleOptions.length > 0 || visibleWidgetActions.length > 0 || isEditMode) && <Menu.Divider />}
<Menu.Item
leftSection={isWidgetFetching ? <Loader size={16} /> : <IconRefresh size={16} />}
onClick={handleRefetch}
@@ -275,11 +320,31 @@ const WidgetContextMenuDropdown = ({ item, definition, widgetStateRef, settings
onClick={openEditModal}
onFocus={preloadWidgetEditModal}
onPointerEnter={preloadWidgetEditModal}
disabled={!canConfigureWidget || (hasSupportedIntegrations && isPending)}
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>
</>
)}
</>
);
};
@@ -2,18 +2,19 @@
position: absolute;
left: 10px;
right: 10px;
top: 10px;
bottom: calc(100% - 10px);
margin-bottom: 1px;
z-index: 200;
pointer-events: none;
opacity: 0;
transform: translateY(-6px);
transform: translateY(6px);
transition:
opacity 150ms ease,
transform 150ms ease;
}
:global([data-type="item"]:not([data-kind="app"]):hover) .wrapper,
:global([data-type="item"]:not([data-kind="app"]):focus-within) .wrapper {
:global(.grid-stack-item[data-type="item"]:not([data-kind="app"]):hover) .wrapper,
:global(.grid-stack-item[data-type="item"]:not([data-kind="app"]):focus-within) .wrapper {
opacity: 1;
transform: translateY(0);
}
@@ -31,7 +32,7 @@
align-items: center;
gap: 8px;
min-height: 28px;
padding: 4px 36px 4px 8px;
padding: 4px 8px;
border: 1px solid var(--border-color);
border-radius: var(--mantine-radius-md);
background-color: var(--background-color);
@@ -41,7 +41,7 @@ const LoadedWidgetHoverOverlay = ({ item, integrations }: WidgetHoverOverlayProp
);
return (
<div className={classes.wrapper} data-board-widget-header>
<div className={classes.wrapper}>
<div className={classes.panel}>
<div className={classes.nameSection}>
<WidgetIcon size={14} stroke={1.75} className={classes.widgetIcon} />
@@ -1,29 +0,0 @@
# Fixed dashboard geometry
The board uses fixed logical units:
- one `1 × 1` item is exactly `200 × 200` logical pixels;
- the gap is always `24` logical pixels, giving a `224` pixel track pitch;
- multi-cell items include the gaps between their cells (`2 × 1` is `424 × 200`);
- the canvas is zoomed once with one uniform scale derived from its available width.
Items keep the same logical dimensions at every viewport. The viewport reserves
the painted size while the inner canvas remains at its logical dimensions, so a
widget's `clientWidth`, `clientHeight`, and `ResizeObserver` measurements stay
stable. The visual canvas always fits the available width, every root lane
keeps at least one viewport of height, and edit-mode previews extend it downward
as needed.
The read-only renderer should:
1. convert persisted `xOffset/yOffset/width/height` values to `x/y/w/h`;
2. apply collapsed container heights and call `getCollapsedDisplayLayout`;
3. render DOM children in `sortGridPlacementsForReadingOrder` order;
4. position children with `getLogicalItemStyle`;
5. wrap them with `getScaledCanvasStyles` and the read-only semantic helpers.
Edit mode can use any interaction library, but its final coordinates should be
normalized through this core. `placeGridItem` is the deterministic
keyboard/numeric fallback: the active item is pinned while collisions move out
of the way and all other items compact vertically. Pointer coordinates from a
scaled editor can be converted with `toLogicalCanvasPoint`.
@@ -1,94 +0,0 @@
import type { BoardLane } from "@homarr/definitions";
import { getBoardLaneColumnCount, getRootSectionLane } from "@homarr/definitions";
import type { Board, EmptySection } from "~/app/[locale]/boards/_types";
import { getLogicalTrackSize, getLayoutRowCount, normalizeGridPlacement } from "./geometry";
import { getCollapsedDisplayLayout } from "./reflow";
import type { GridPlacement } from "./types";
export { getBoardLaneColumnCount };
export const getRootSectionForLane = (board: Board, lane: BoardLane): EmptySection | undefined => {
const roots = board.sections.filter(
(section): section is EmptySection => section.kind === "empty" && getRootSectionLane(section.xOffset) === lane,
);
if (roots.length > 1) {
throw new Error(`Board "${board.id}" has multiple ${lane} canvas roots`);
}
return roots[0];
};
export const getInitialBoardLogicalHeight = (board: Board, layoutId: string) => {
const layout = board.layouts.find((candidate) => candidate.id === layoutId);
if (!layout) return 0;
const rowCounts = (["left", "main", "right"] as const).flatMap((lane) => {
const columnCount = getBoardLaneColumnCount(layout, lane);
const root = getRootSectionForLane(board, lane);
if (columnCount === 0 || !root) return [];
const placements: GridPlacement[] = [
...board.items.flatMap((item) => {
const itemLayout = item.layouts.find(
(candidate) => candidate.layoutId === layoutId && candidate.sectionId === root.id,
);
return itemLayout
? [
normalizeGridPlacement(
{
id: item.id,
type: "item" as const,
x: itemLayout.xOffset,
y: itemLayout.yOffset,
w: itemLayout.width,
h: itemLayout.height,
},
columnCount,
),
]
: [];
}),
...board.sections.flatMap((section) => {
if (section.kind !== "container") return [];
const sectionLayout = section.layouts.find(
(candidate) => candidate.layoutId === layoutId && candidate.parentSectionId === root.id,
);
return sectionLayout
? [
normalizeGridPlacement(
{
id: section.id,
type: "section" as const,
x: sectionLayout.xOffset,
y: sectionLayout.yOffset,
w: sectionLayout.width,
h: sectionLayout.height,
},
columnCount,
),
]
: [];
}),
];
const collapsedSectionIds = new Set(
board.sections
.filter(
(section) =>
section.kind === "container" &&
section.collapsed &&
section.options.collapsible &&
placements.some((placement) => placement.id === section.id),
)
.map((section) => section.id),
);
const displayPlacements =
collapsedSectionIds.size > 0
? getCollapsedDisplayLayout(placements, { columnCount, collapsedItemIds: collapsedSectionIds })
: placements;
return [getLayoutRowCount(displayPlacements)];
});
return getLogicalTrackSize(Math.max(0, ...rowCounts));
};
@@ -1,9 +0,0 @@
/**
* A grid unit is a fixed logical size. The canvas is scaled as one surface;
* individual cards never change their internal layout at different viewports.
*/
export const LOGICAL_GRID_CELL_SIZE = 200;
export const LOGICAL_GRID_GAP = 24;
export const LOGICAL_GRID_PITCH = LOGICAL_GRID_CELL_SIZE + LOGICAL_GRID_GAP;
export const COLLAPSED_SECTION_ROW_COUNT = 0.5;
@@ -1,61 +0,0 @@
import type { CSSProperties } from "react";
import { LOGICAL_GRID_CELL_SIZE, LOGICAL_GRID_GAP, LOGICAL_GRID_PITCH } from "./constants";
import type { GridCoordinates, GridPlacement } from "./types";
export const getLogicalTrackSize = (trackCount: number) => {
const normalizedTrackCount = Math.max(0, toFiniteNumber(trackCount, "trackCount"));
if (normalizedTrackCount === 0) return 0;
return normalizedTrackCount * LOGICAL_GRID_CELL_SIZE + (normalizedTrackCount - 1) * LOGICAL_GRID_GAP;
};
export const getLogicalItemStyle = ({ x, y, w, h }: GridCoordinates): CSSProperties => ({
position: "absolute",
left: x * LOGICAL_GRID_PITCH,
top: y * LOGICAL_GRID_PITCH,
width: getLogicalTrackSize(w),
height: getLogicalTrackSize(h),
});
export const getLayoutRowCount = (placements: readonly GridCoordinates[]) =>
placements.reduce((rowCount, placement) => Math.max(rowCount, placement.y + placement.h), 0);
export const getGridRowCountForVisualHeight = (visualHeight: number, visualScale: number) => {
if (!Number.isFinite(visualHeight) || visualHeight <= 0) return 1;
if (!Number.isFinite(visualScale) || visualScale <= 0) return 1;
return Math.max(1, Math.ceil((visualHeight / visualScale + LOGICAL_GRID_GAP) / LOGICAL_GRID_PITCH));
};
export const normalizeGridPlacement = <TPlacement extends GridPlacement>(
placement: TPlacement,
columnCount: number,
): TPlacement => {
const normalizedColumnCount = toPositiveInteger(columnCount, "columnCount");
const width = Math.min(toPositiveInteger(placement.w, "placement.w"), normalizedColumnCount);
const x = Math.min(Math.max(0, toNonNegativeInteger(placement.x)), normalizedColumnCount - width);
return {
...placement,
x,
y: toNonNegativeInteger(placement.y),
w: width,
h: toPositiveInteger(placement.h, "placement.h"),
};
};
const toNonNegativeInteger = (value: number) => Math.max(0, Math.trunc(toFiniteNumber(value, "value")));
const toPositiveInteger = (value: number, name: string) => {
const integer = Math.trunc(toFiniteNumber(value, name));
if (integer < 1) throw new RangeError(`${name} must be at least 1`);
return integer;
};
const toFiniteNumber = (value: number, name: string) => {
if (!Number.isFinite(value)) throw new TypeError(`${name} must be finite`);
return value;
};
@@ -1,6 +0,0 @@
export * from "./constants";
export * from "./board-lanes";
export * from "./geometry";
export * from "./reflow";
export * from "./semantics";
export * from "./types";
@@ -1,207 +0,0 @@
import { COLLAPSED_SECTION_ROW_COUNT } from "./constants";
import { normalizeGridPlacement } from "./geometry";
import type { GridPlacement } from "./types";
interface ReflowOptions {
columnCount: number;
/**
* A pinned item keeps its requested x/y while every other item compacts
* around it. Use this for the item currently being moved or resized.
*/
pinnedItemId?: string;
}
interface CollapsedLayoutOptions {
columnCount: number;
collapsedItemIds: ReadonlySet<string>;
collapsedRowCount?: number;
}
export const doGridPlacementsOverlap = (first: GridPlacement, second: GridPlacement) =>
first.x < second.x + second.w &&
first.x + first.w > second.x &&
first.y < second.y + second.h &&
first.y + first.h > second.y;
/**
* Resolves overlaps and vertically compacts items. Ordering is based on the
* persisted position and stable id, so the result does not depend on React or
* database array order.
*/
export const reflowVerticalLayout = <TPlacement extends GridPlacement>(
placements: readonly TPlacement[],
{ columnCount, pinnedItemId }: ReflowOptions,
): TPlacement[] => {
assertUniqueIds(placements);
const normalized = placements.map((placement) => normalizeGridPlacement(placement, columnCount));
const pinned = pinnedItemId ? normalized.find((placement) => placement.id === pinnedItemId) : undefined;
if (pinnedItemId && !pinned) {
throw new Error(`Pinned grid item "${pinnedItemId}" does not exist`);
}
const placed: TPlacement[] = pinned ? [pinned] : [];
const candidates = normalized.filter((placement) => placement.id !== pinnedItemId).toSorted(comparePlacements);
for (const candidate of candidates) {
placed.push({
...candidate,
y: findFirstAvailableRow(candidate, placed),
});
}
const byId = new Map(placed.map((placement) => [placement.id, placement]));
return normalized.map((placement) => getRequiredPlacement(byId, placement.id));
};
/**
* Produces the view-only layout after inline containers are collapsed. Only
* display geometry changes; callers retain the persisted expanded height.
*/
export const getCollapsedDisplayLayout = <TPlacement extends GridPlacement>(
placements: readonly TPlacement[],
{ columnCount, collapsedItemIds, collapsedRowCount = COLLAPSED_SECTION_ROW_COUNT }: CollapsedLayoutOptions,
): TPlacement[] => {
if (!Number.isFinite(collapsedRowCount) || collapsedRowCount <= 0) {
throw new RangeError("collapsedRowCount must be positive and finite");
}
const normalized = placements.map((placement) => normalizeGridPlacement(placement, columnCount));
const displayPlacements = normalized.map((placement) =>
collapsedItemIds.has(placement.id)
? {
...placement,
h: collapsedRowCount,
}
: placement,
);
return reflowCollapsedDisplayLayout(normalized, displayPlacements, collapsedItemIds, collapsedRowCount);
};
/**
* Applies the requested placement and pins it while neighbors compact into
* free space or move down. This is the deterministic fallback used by
* keyboard/numeric editing and can also validate a drag library result.
*/
export const placeGridItem = <TPlacement extends GridPlacement>(
placements: readonly TPlacement[],
nextPlacement: TPlacement,
columnCount: number,
) => {
if (!placements.some((placement) => placement.id === nextPlacement.id)) {
throw new Error(`Grid item "${nextPlacement.id}" does not exist`);
}
return reflowVerticalLayout(
placements.map((placement) => (placement.id === nextPlacement.id ? nextPlacement : placement)),
{
columnCount,
pinnedItemId: nextPlacement.id,
},
);
};
export const sortGridPlacementsForReadingOrder = <TPlacement extends GridPlacement>(
placements: readonly TPlacement[],
) => placements.toSorted(comparePlacements);
const findFirstAvailableRow = (candidate: GridPlacement, placed: readonly GridPlacement[]) => {
let y = 0;
for (;;) {
const atRow = { ...candidate, y };
const collisions = placed.filter((placement) => doGridPlacementsOverlap(atRow, placement));
if (collisions.length === 0) return y;
y = Math.max(...collisions.map((placement) => placement.y + placement.h));
}
};
const reflowCollapsedDisplayLayout = <TPlacement extends GridPlacement>(
expandedPlacements: readonly TPlacement[],
displayPlacements: readonly TPlacement[],
collapsedItemIds: ReadonlySet<string>,
collapsedRowCount: number,
): TPlacement[] => {
assertUniqueIds(expandedPlacements);
const displayById = new Map(displayPlacements.map((placement) => [placement.id, placement]));
const collapsedPlacements = expandedPlacements.filter((placement) => collapsedItemIds.has(placement.id));
const placed: TPlacement[] = [];
for (const expandedPlacement of expandedPlacements.toSorted(comparePlacements)) {
const candidate = getRequiredPlacement(displayById, expandedPlacement.id);
const verticalShift = getCollapsedVerticalShift(expandedPlacement, collapsedPlacements, collapsedRowCount);
placed.push({
...candidate,
y: findFirstAvailableRowAtOrAfter(candidate, placed, Math.max(0, candidate.y - verticalShift)),
});
}
const byId = new Map(placed.map((placement) => [placement.id, placement]));
return displayPlacements.map((placement) => getRequiredPlacement(byId, placement.id));
};
const getCollapsedVerticalShift = (
candidate: GridPlacement,
collapsedPlacements: readonly GridPlacement[],
collapsedRowCount: number,
) => {
const removedIntervals = collapsedPlacements
.filter(
(collapsed) =>
collapsed.id !== candidate.id &&
collapsed.y + collapsed.h <= candidate.y &&
doGridPlacementsOverlapHorizontally(candidate, collapsed),
)
.map((collapsed) => ({ start: collapsed.y + collapsedRowCount, end: collapsed.y + collapsed.h }))
.toSorted((first, second) => first.start - second.start || first.end - second.end);
let shift = 0;
let intervalEnd = Number.NEGATIVE_INFINITY;
for (const interval of removedIntervals) {
const start = Math.max(interval.start, intervalEnd);
if (interval.end > start) shift += interval.end - start;
intervalEnd = Math.max(intervalEnd, interval.end);
}
return shift;
};
const doGridPlacementsOverlapHorizontally = (first: GridPlacement, second: GridPlacement) =>
first.x < second.x + second.w && first.x + first.w > second.x;
const findFirstAvailableRowAtOrAfter = (
candidate: GridPlacement,
placed: readonly GridPlacement[],
minimumRow: number,
) => {
let y = minimumRow;
for (;;) {
const atRow = { ...candidate, y };
const collisions = placed.filter((placement) => doGridPlacementsOverlap(atRow, placement));
if (collisions.length === 0) return y;
y = Math.max(...collisions.map((placement) => placement.y + placement.h));
}
};
const comparePlacements = (first: GridPlacement, second: GridPlacement) =>
first.y - second.y || first.x - second.x || (first.id === second.id ? 0 : first.id < second.id ? -1 : 1);
const assertUniqueIds = (placements: readonly GridPlacement[]) => {
const ids = new Set<string>();
for (const placement of placements) {
if (ids.has(placement.id)) throw new Error(`Duplicate grid item id "${placement.id}"`);
ids.add(placement.id);
}
};
const getRequiredPlacement = <TPlacement extends GridPlacement>(
placements: ReadonlyMap<string, TPlacement>,
id: string,
) => {
const placement = placements.get(id);
if (!placement) throw new Error(`Grid item "${id}" was lost during layout`);
return placement;
};
@@ -1,42 +0,0 @@
.viewport {
width: 100%;
max-width: 100%;
min-width: 0;
overflow: clip;
}
.viewport[data-board-hydrated="false"] {
overflow: clip;
}
.sizer {
position: relative;
}
.canvas {
position: absolute;
inset: 0 auto auto 0;
/*
* Keep dashboard UI at its normal readable density while the fixed canvas
* is fitted below 100%. Above 100%, the canvas still behaves like browser
* zoom and enlarges everything together.
*/
--mantine-scale: var(--board-canvas-ui-scale, 1);
--mantine-spacing-xs: calc(0.625rem * var(--board-canvas-ui-scale, 1));
--mantine-spacing-sm: calc(0.75rem * var(--board-canvas-ui-scale, 1));
--mantine-spacing-md: calc(1rem * var(--board-canvas-ui-scale, 1));
--mantine-spacing-lg: calc(1.25rem * var(--board-canvas-ui-scale, 1));
--mantine-spacing-xl: calc(2rem * var(--board-canvas-ui-scale, 1));
--mantine-font-size-xs: calc(0.75rem * var(--board-canvas-ui-scale, 1));
--mantine-font-size-sm: calc(0.875rem * var(--board-canvas-ui-scale, 1));
--mantine-font-size-md: calc(1rem * var(--board-canvas-ui-scale, 1));
--mantine-font-size-lg: calc(1.125rem * var(--board-canvas-ui-scale, 1));
--mantine-font-size-xl: calc(1.25rem * var(--board-canvas-ui-scale, 1));
--mantine-h1-font-size: calc(2.125rem * var(--board-canvas-ui-scale, 1));
--mantine-h2-font-size: calc(1.625rem * var(--board-canvas-ui-scale, 1));
--mantine-h3-font-size: calc(1.375rem * var(--board-canvas-ui-scale, 1));
--mantine-h4-font-size: calc(1.125rem * var(--board-canvas-ui-scale, 1));
--mantine-h5-font-size: calc(1rem * var(--board-canvas-ui-scale, 1));
--mantine-h6-font-size: calc(0.875rem * var(--board-canvas-ui-scale, 1));
}
@@ -1,94 +0,0 @@
"use client";
import type { PropsWithChildren } from "react";
import { createContext, useContext, useEffect, useMemo, useState } from "react";
import { Box } from "@mantine/core";
import { useElementSize } from "@mantine/hooks";
import classes from "./scaled-board-canvas.module.css";
const BoardCanvasScaleContext = createContext(1);
export const useBoardCanvasScale = () => useContext(BoardCanvasScaleContext);
export const calculateBoardCanvasScale = (availableWidth: number, logicalWidth: number) => {
if (!Number.isFinite(availableWidth) || !Number.isFinite(logicalWidth)) return 1;
if (availableWidth <= 0 || logicalWidth <= 0) return 1;
return availableWidth / logicalWidth;
};
export const calculateBoardUiScale = (canvasScale: number) => {
if (!Number.isFinite(canvasScale) || canvasScale <= 0) return 1;
return canvasScale < 1 ? 1 / canvasScale : 1;
};
interface ScaledBoardCanvasProps {
logicalWidth: number;
initialLogicalHeight: number;
initialAvailableWidth: number;
label: string;
}
/**
* Scales the complete board as one surface while keeping widget layout in
* fixed logical pixels. The complete canvas always fits its viewport so board
* content can never widen the document or require horizontal scrolling.
*/
export const ScaledBoardCanvas = ({
logicalWidth,
initialLogicalHeight,
initialAvailableWidth,
label,
children,
}: PropsWithChildren<ScaledBoardCanvasProps>) => {
const { ref: viewportRef, width: availableWidth } = useElementSize<HTMLDivElement>();
const { ref: canvasRef, height: logicalHeight } = useElementSize<HTMLDivElement>();
const [isHydrated, setIsHydrated] = useState(false);
useEffect(() => {
setIsHydrated(true);
}, []);
const resolvedAvailableWidth = availableWidth > 0 ? availableWidth : initialAvailableWidth;
const resolvedLogicalHeight = logicalHeight > 0 ? logicalHeight : initialLogicalHeight;
const scale = useMemo(
() => calculateBoardCanvasScale(resolvedAvailableWidth, logicalWidth),
[logicalWidth, resolvedAvailableWidth],
);
const uiScale = calculateBoardUiScale(scale);
const inverseScale = scale > 0 ? 1 / scale : 1;
const visualWidth = logicalWidth * scale;
const hasHorizontalOverflow = resolvedAvailableWidth > 0 && visualWidth - resolvedAvailableWidth > 0.5;
return (
<Box
component="section"
ref={viewportRef}
className={classes.viewport}
data-testid="board-canvas"
data-board-hydrated={isHydrated ? "true" : "false"}
data-canvas-scale={scale}
data-canvas-overflow={hasHorizontalOverflow ? "true" : "false"}
data-canvas-initial-height={initialLogicalHeight}
data-canvas-initial-width={initialAvailableWidth}
aria-label={label}
>
<Box className={classes.sizer} w={visualWidth} h={resolvedLogicalHeight * scale}>
<Box
ref={canvasRef}
className={classes.canvas}
w={logicalWidth}
style={{
"--board-canvas-inverse-scale": inverseScale,
"--board-canvas-ui-scale": uiScale,
zoom: scale,
}}
>
<BoardCanvasScaleContext.Provider value={scale}>{children}</BoardCanvasScaleContext.Provider>
</Box>
</Box>
</Box>
);
};
@@ -1,56 +0,0 @@
import type { GridPlacement } from "./types";
interface ReadonlyCanvasAttributesInput {
label: string;
}
interface EditableCanvasAttributesInput extends ReadonlyCanvasAttributesInput {
columnCount: number;
rowCount: number;
}
interface EditableGridCellAttributesInput {
label: string;
placement: GridPlacement;
}
/**
* View mode is a labelled region, not an ARIA grid. It keeps the native
* semantics and focus behavior of links, buttons and widget controls inside.
*/
export const getReadonlyCanvasAttributes = ({ label }: ReadonlyCanvasAttributesInput) =>
({
role: "region",
"aria-label": label,
"data-grid-readonly": "true",
}) as const;
export const getReadonlyGridItemAttributes = (placement: GridPlacement) =>
({
"data-grid-id": placement.id,
"data-grid-x": placement.x,
"data-grid-y": placement.y,
"data-grid-w": placement.w,
"data-grid-h": placement.h,
}) as const;
/**
* Edit mode remains a labelled region. Dashboard placement is not an ARIA
* data grid: arrow keys edit geometry rather than navigate rows and columns.
* Each tile is one focusable direct-manipulation surface, while controls inside
* it keep their native semantics and tab order.
*/
export const getEditableCanvasAttributes = ({ label }: EditableCanvasAttributesInput) =>
({
role: "region",
"aria-label": label,
"data-grid-editable": "true",
}) as const;
export const getEditableGridCellAttributes = ({ label, placement }: EditableGridCellAttributesInput) =>
({
role: "group",
"aria-label": label,
tabIndex: 0,
...getReadonlyGridItemAttributes(placement),
}) as const;
@@ -1,112 +0,0 @@
import { describe, expect, test } from "vitest";
import type { Board } from "~/app/[locale]/boards/_types";
import { BoardMockBuilder } from "~/components/board/items/actions/test/mocks/board-mock";
import { ContainerSectionMockBuilder } from "~/components/board/items/actions/test/mocks/container-section-mock";
import { EmptySectionMockBuilder } from "~/components/board/items/actions/test/mocks/empty-section-mock";
import { ItemMockBuilder } from "~/components/board/items/actions/test/mocks/item-mock";
import { getBoardLaneColumnCount, getInitialBoardLogicalHeight, getRootSectionForLane } from "../index";
describe("board lanes", () => {
test("gutters consume columns from the main canvas", () => {
const layout = {
id: "layout",
name: "Base",
columnCount: 12,
leftGutterColumnCount: 2,
rightGutterColumnCount: 3,
breakpoint: 0,
} satisfies Board["layouts"][number];
expect(getBoardLaneColumnCount(layout, "left")).toBe(2);
expect(getBoardLaneColumnCount(layout, "main")).toBe(7);
expect(getBoardLaneColumnCount(layout, "right")).toBe(3);
});
test("clamps invalid gutters while preserving one main column", () => {
const layout = {
id: "layout",
name: "Base",
columnCount: 4,
leftGutterColumnCount: 3,
rightGutterColumnCount: 3,
breakpoint: 0,
} satisfies Board["layouts"][number];
expect(getBoardLaneColumnCount(layout, "left")).toBe(3);
expect(getBoardLaneColumnCount(layout, "main")).toBe(1);
expect(getBoardLaneColumnCount(layout, "right")).toBe(0);
});
test("rejects ambiguous duplicate roots", () => {
const board = new BoardMockBuilder()
.addSection(new EmptySectionMockBuilder({ id: "main-a" }).build())
.addSection(new EmptySectionMockBuilder({ id: "main-b" }).build())
.build();
expect(() => getRootSectionForLane(board, "main")).toThrow("multiple main canvas roots");
});
test("calculates a stable server height from the tallest visible lane", () => {
const layoutId = "layout";
const mainSectionId = "main";
const leftSectionId = "left";
const containerSectionId = "container";
const board = new BoardMockBuilder()
.addSection(new EmptySectionMockBuilder({ id: mainSectionId }).build())
.addSection(new EmptySectionMockBuilder({ id: leftSectionId, xOffset: -1 }).build())
.addSection(
new ContainerSectionMockBuilder({
id: containerSectionId,
collapsed: true,
options: { collapsible: true },
})
.addLayout({
layoutId,
parentSectionId: mainSectionId,
xOffset: 0,
yOffset: 0,
width: 2,
height: 3,
})
.build(),
)
.addItem(
new ItemMockBuilder()
.addLayout({
layoutId,
sectionId: mainSectionId,
xOffset: 0,
yOffset: 3,
width: 1,
height: 1,
})
.build(),
)
.addItem(
new ItemMockBuilder()
.addLayout({
layoutId,
sectionId: leftSectionId,
xOffset: 0,
yOffset: 0,
width: 1,
height: 4,
})
.build(),
)
.build();
board.layouts = [
{
id: layoutId,
name: "Base",
columnCount: 8,
leftGutterColumnCount: 1,
rightGutterColumnCount: 0,
breakpoint: 0,
},
];
expect(getInitialBoardLogicalHeight(board, layoutId)).toBe(872);
});
});
@@ -1,41 +0,0 @@
import { describe, expect, test } from "vitest";
import {
getGridRowCountForVisualHeight,
getLayoutRowCount,
getLogicalItemStyle,
getLogicalTrackSize,
normalizeGridPlacement,
} from "../index";
describe("fixed dashboard geometry", () => {
test("keeps a 1x1 item exactly 200px with a fixed 24px gap", () => {
expect(getLogicalTrackSize(1)).toBe(200);
expect(getLogicalTrackSize(0.5)).toBe(88);
expect(getLogicalTrackSize(2)).toBe(424);
expect(getLogicalTrackSize(3)).toBe(648);
expect(getLogicalItemStyle({ x: 1, y: 2, w: 2, h: 3 })).toEqual({
position: "absolute",
left: 224,
top: 448,
width: 424,
height: 648,
});
});
test("normalizes invalid bounds and derives the required row count", () => {
const placement = normalizeGridPlacement({ id: "wide", x: 7, y: -2, w: 8, h: 2 }, 4);
expect(placement).toEqual({ id: "wide", x: 0, y: 0, w: 4, h: 2 });
expect(getLayoutRowCount([placement, { x: 0, y: 4, w: 1, h: 3 }])).toBe(7);
});
test("derives enough logical rows to fill the visual viewport at any canvas scale", () => {
expect(getGridRowCountForVisualHeight(1200, 1)).toBe(6);
expect(getLogicalTrackSize(getGridRowCountForVisualHeight(1200, 1))).toBeGreaterThanOrEqual(1200);
expect(getGridRowCountForVisualHeight(1200, 0.5)).toBe(11);
expect(getLogicalTrackSize(getGridRowCountForVisualHeight(1200, 0.5)) * 0.5).toBeGreaterThanOrEqual(1200);
});
});
@@ -1,134 +0,0 @@
import { describe, expect, test } from "vitest";
import {
doGridPlacementsOverlap,
getCollapsedDisplayLayout,
placeGridItem,
reflowVerticalLayout,
sortGridPlacementsForReadingOrder,
} from "../index";
import type { GridPlacement } from "../index";
describe("dashboard layout reflow", () => {
test("compacts each horizontal lane and removes collisions", () => {
const result = reflowVerticalLayout(
[
{ id: "a", x: 0, y: 0, w: 1, h: 1 },
{ id: "b", x: 0, y: 3, w: 1, h: 1 },
{ id: "c", x: 1, y: 3, w: 1, h: 1 },
],
{ columnCount: 2 },
);
expect(result).toEqual([
{ id: "a", x: 0, y: 0, w: 1, h: 1 },
{ id: "b", x: 0, y: 1, w: 1, h: 1 },
{ id: "c", x: 1, y: 0, w: 1, h: 1 },
]);
expect(hasOverlap(result)).toBe(false);
});
test("shrinks a collapsed inline container without compacting unrelated columns", () => {
const result = getCollapsedDisplayLayout(
[
{ id: "section", x: 0, y: 0, w: 2, h: 3 },
{ id: "below", x: 0, y: 3, w: 2, h: 1 },
{ id: "other-lane", x: 2, y: 4, w: 1, h: 1 },
],
{
columnCount: 3,
collapsedItemIds: new Set(["section"]),
},
);
expect(result).toEqual([
{ id: "section", x: 0, y: 0, w: 2, h: 0.5 },
{ id: "below", x: 0, y: 0.5, w: 2, h: 1 },
{ id: "other-lane", x: 2, y: 4, w: 1, h: 1 },
]);
});
test("preserves intentional gaps below a collapsed container", () => {
const result = getCollapsedDisplayLayout(
[
{ id: "section", x: 0, y: 1, w: 2, h: 3 },
{ id: "below", x: 0, y: 6, w: 2, h: 1 },
{ id: "above", x: 0, y: 0, w: 2, h: 1 },
],
{ columnCount: 2, collapsedItemIds: new Set(["section"]) },
);
expect(result).toEqual([
{ id: "section", x: 0, y: 1, w: 2, h: 0.5 },
{ id: "below", x: 0, y: 3.5, w: 2, h: 1 },
{ id: "above", x: 0, y: 0, w: 2, h: 1 },
]);
});
test("does not double-count side-by-side collapsed space", () => {
const result = getCollapsedDisplayLayout(
[
{ id: "left", x: 0, y: 0, w: 1, h: 3 },
{ id: "right", x: 1, y: 0, w: 1, h: 3 },
{ id: "below", x: 0, y: 3, w: 2, h: 1 },
],
{ columnCount: 2, collapsedItemIds: new Set(["left", "right"]) },
);
expect(result).toEqual([
{ id: "left", x: 0, y: 0, w: 1, h: 0.5 },
{ id: "right", x: 1, y: 0, w: 1, h: 0.5 },
{ id: "below", x: 0, y: 0.5, w: 2, h: 1 },
]);
});
test("pins a moved or expanded item and pushes collisions out of the way", () => {
const result = placeGridItem(
[
{ id: "section", x: 0, y: 0, w: 2, h: 1 },
{ id: "neighbor", x: 0, y: 1, w: 2, h: 1 },
{ id: "side", x: 2, y: 2, w: 1, h: 1 },
],
{ id: "section", x: 0, y: 0, w: 2, h: 3 },
3,
);
expect(result).toEqual([
{ id: "section", x: 0, y: 0, w: 2, h: 3 },
{ id: "neighbor", x: 0, y: 3, w: 2, h: 1 },
{ id: "side", x: 2, y: 0, w: 1, h: 1 },
]);
expect(hasOverlap(result)).toBe(false);
});
test("is deterministic regardless of input array order", () => {
const first = [
{ id: "b", x: 0, y: 2, w: 1, h: 1 },
{ id: "a", x: 0, y: 2, w: 1, h: 1 },
{ id: "c", x: 1, y: 8, w: 1, h: 1 },
];
const second = first.toReversed();
expect(byId(reflowVerticalLayout(first, { columnCount: 2 }))).toEqual(
byId(reflowVerticalLayout(second, { columnCount: 2 })),
);
});
test("provides row-major DOM order for the read-only renderer", () => {
const items = [
{ id: "last", x: 0, y: 2, w: 1, h: 1 },
{ id: "right", x: 1, y: 0, w: 1, h: 1 },
{ id: "left", x: 0, y: 0, w: 1, h: 1 },
];
expect(sortGridPlacementsForReadingOrder(items).map((item) => item.id)).toEqual(["left", "right", "last"]);
});
});
const hasOverlap = (placements: readonly GridPlacement[]) =>
placements.some((placement, index) =>
placements.slice(index + 1).some((otherPlacement) => doGridPlacementsOverlap(placement, otherPlacement)),
);
const byId = (placements: readonly GridPlacement[]) =>
Object.fromEntries(placements.map((placement) => [placement.id, placement]));
@@ -1,60 +0,0 @@
import { describe, expect, test } from "vitest";
import { calculateBoardCanvasScale, calculateBoardUiScale } from "../scaled-board-canvas";
describe("board canvas scale policy", () => {
test("fits the logical canvas exactly", () => {
const scale = calculateBoardCanvasScale(840, 1000);
expect(scale).toBe(0.84);
expect(1000 * scale).toBe(840);
});
test("scales up uniformly on a larger screen", () => {
expect(calculateBoardCanvasScale(1800, 1000)).toBe(1.8);
});
test("keeps fitting narrow viewports without horizontal overflow", () => {
const availableWidth = 600;
const logicalWidth = 1000;
const scale = calculateBoardCanvasScale(availableWidth, logicalWidth);
expect(scale).toBe(0.6);
expect(logicalWidth * scale).toBe(availableWidth);
});
test("keeps the board fitted at 200% effective zoom", () => {
const logicalWidth = 1696;
const normalAvailableWidth = 1872;
const normalScale = calculateBoardCanvasScale(normalAvailableWidth, logicalWidth);
const zoomedScale = calculateBoardCanvasScale(normalAvailableWidth / 2, logicalWidth);
const normalPaintedCellSize = 200 * normalScale;
const zoomedPaintedCellSize = 200 * zoomedScale * 2;
expect(zoomedScale).toBeCloseTo(normalScale / 2);
expect(zoomedPaintedCellSize).toBeCloseTo(normalPaintedCellSize);
});
test.each([
[0, 1000],
[1000, 0],
[Number.NaN, 1000],
[1000, Number.POSITIVE_INFINITY],
])("uses a stable initial scale for unavailable geometry (%s, %s)", (availableWidth, logicalWidth) => {
expect(calculateBoardCanvasScale(availableWidth, logicalWidth)).toBe(1);
});
test("keeps UI typography and density at 100% while fitting the canvas down", () => {
expect(calculateBoardUiScale(0.75)).toBeCloseTo(4 / 3);
expect(calculateBoardUiScale(0.84)).toBeCloseTo(1 / 0.84);
expect(calculateBoardUiScale(1)).toBe(1);
expect(calculateBoardUiScale(1.5)).toBe(1);
});
test.each([0, -1, Number.NaN, Number.POSITIVE_INFINITY])(
"uses stable UI density for an invalid canvas scale (%s)",
(scale) => {
expect(calculateBoardUiScale(scale)).toBe(1);
},
);
});
@@ -1,57 +0,0 @@
import { describe, expect, test } from "vitest";
import {
getEditableCanvasAttributes,
getEditableGridCellAttributes,
getReadonlyCanvasAttributes,
getReadonlyGridItemAttributes,
} from "../index";
describe("dashboard layout semantics", () => {
const placement = { id: "weather", x: 1, y: 2, w: 3, h: 2 };
test("keeps read-only mode a native labelled region", () => {
expect(getReadonlyCanvasAttributes({ label: "Home dashboard" })).toEqual({
role: "region",
"aria-label": "Home dashboard",
"data-grid-readonly": "true",
});
expect(getReadonlyGridItemAttributes(placement)).toEqual({
"data-grid-id": "weather",
"data-grid-x": 1,
"data-grid-y": 2,
"data-grid-w": 3,
"data-grid-h": 2,
});
});
test("keeps edit mode a labelled region with spatial item metadata", () => {
expect(
getEditableCanvasAttributes({
label: "Edit home dashboard",
columnCount: 12,
rowCount: 8,
}),
).toEqual({
role: "region",
"aria-label": "Edit home dashboard",
"data-grid-editable": "true",
});
expect(
getEditableGridCellAttributes({
label: "Weather widget",
placement,
}),
).toEqual({
role: "group",
"aria-label": "Weather widget",
tabIndex: 0,
"data-grid-id": "weather",
"data-grid-x": 1,
"data-grid-y": 2,
"data-grid-w": 3,
"data-grid-h": 2,
});
});
});
@@ -1,10 +0,0 @@
export interface GridCoordinates {
x: number;
y: number;
w: number;
h: number;
}
export interface GridPlacement extends GridCoordinates {
id: string;
}
@@ -0,0 +1,65 @@
import { Card, Collapse, Group, Stack, Title, UnstyledButton } from "@mantine/core";
import { useDisclosure } from "@mantine/hooks";
import { IconChevronDown, IconChevronUp } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
import { useSession } from "@homarr/auth/client";
import { useRequiredBoard } from "@homarr/boards/context";
import type { CategorySection } from "~/app/[locale]/boards/_types";
import { CategoryMenu } from "./category/category-menu";
import { GridStack } from "./gridstack/gridstack";
import classes from "./item.module.css";
const localStorageKey = (sectionId: string) => `homarr-section-collapsed-${sectionId}`;
interface Props {
section: CategorySection;
}
export const BoardCategorySection = ({ section }: Props) => {
const { status } = useSession();
const { mutate } = clientApi.section.changeCollapsed.useMutation();
const board = useRequiredBoard();
const initialCollapsed =
typeof window !== "undefined"
? (localStorage.getItem(localStorageKey(section.id)) ?? String(section.collapsed)) === "true"
: section.collapsed;
const [opened, { toggle }] = useDisclosure(initialCollapsed, {
onOpen() {
if (status === "authenticated") {
mutate({ sectionId: section.id, collapsed: true });
} else if (status === "unauthenticated") {
localStorage.setItem(localStorageKey(section.id), "true");
}
},
onClose() {
if (status === "authenticated") {
mutate({ sectionId: section.id, collapsed: false });
} else if (status === "unauthenticated") {
localStorage.setItem(localStorageKey(section.id), "false");
}
},
});
return (
<Card style={{ "--opacity": board.opacity / 100 }} radius={board.itemRadius} p={0} className={classes.itemCard}>
<Stack>
<Group wrap="nowrap" gap="sm">
<UnstyledButton w="100%" p="sm" onClick={toggle}>
<Group wrap="nowrap">
{opened ? <IconChevronUp size={20} /> : <IconChevronDown size={20} />}
<Title order={3}>{section.name}</Title>
</Group>
</UnstyledButton>
<CategoryMenu category={section} />
</Group>
<Collapse expanded={opened} p="sm" pt={0}>
<GridStack section={section} />
</Collapse>
</Stack>
</Card>
);
};
@@ -0,0 +1,54 @@
import type { RouterOutputs } from "@homarr/api";
import type { CategorySection } from "~/app/[locale]/boards/_types";
export interface MoveCategoryInput {
id: string;
direction: "up" | "down";
}
export const moveCategoryCallback =
(input: MoveCategoryInput) =>
(previous: RouterOutputs["board"]["getHomeBoard"]): RouterOutputs["board"]["getHomeBoard"] => {
const currentCategory = previous.sections.find(
(section): section is CategorySection => section.kind === "category" && section.id === input.id,
);
if (!currentCategory) {
return previous;
}
if (currentCategory.yOffset === 1 && input.direction === "up") {
return previous;
}
if (currentCategory.yOffset === previous.sections.length - 2 && input.direction === "down") {
return previous;
}
return {
...previous,
sections: previous.sections.map((section) => {
if (section.kind !== "category" && section.kind !== "empty") {
return section;
}
const offset = input.direction === "up" ? -2 : 2;
// Move category and empty section
if (section.yOffset === currentCategory.yOffset || section.yOffset === currentCategory.yOffset + 1) {
return {
...section,
yOffset: section.yOffset + offset,
};
}
if (
section.yOffset === currentCategory.yOffset + offset ||
section.yOffset === currentCategory.yOffset + offset + 1
) {
return {
...section,
yOffset: section.yOffset - offset,
};
}
return section;
}),
};
};
@@ -0,0 +1,150 @@
import { getBoardLayouts } from "@homarr/boards/context";
import type { Board, CategorySection, EmptySection, Section } from "~/app/[locale]/boards/_types";
export interface RemoveCategoryInput {
id: string;
}
export const removeCategoryCallback =
(input: RemoveCategoryInput) =>
(previous: Board): Board => {
const currentCategory = previous.sections.find(
(section): section is CategorySection => section.kind === "category" && section.id === input.id,
);
if (!currentCategory) {
return previous;
}
const emptySectionsAbove = previous.sections.filter(
(section): section is EmptySection => section.kind === "empty" && section.yOffset < currentCategory.yOffset,
);
const aboveSection = emptySectionsAbove.sort((sectionA, sectionB) => sectionB.yOffset - sectionA.yOffset).at(0);
const emptySectionsBelow = previous.sections.filter(
(section): section is EmptySection => section.kind === "empty" && section.yOffset > currentCategory.yOffset,
);
const removedSection = emptySectionsBelow.sort((sectionA, sectionB) => sectionA.yOffset - sectionB.yOffset).at(0);
if (!aboveSection || !removedSection) {
return previous;
}
const aboveYOffsets = getBoardLayouts(previous).map((layoutId) => {
return {
layoutId,
yOffset: Math.max(
calculateYHeightWithOffsetForItemLayouts(previous, { sectionId: aboveSection.id, layoutId }),
calculateYHeightWithOffsetForDynamicSectionLayouts(previous.sections, {
sectionId: aboveSection.id,
layoutId,
}),
),
};
});
const categoryYOffsets = getBoardLayouts(previous).map((layoutId) => {
return {
layoutId,
yOffset: Math.max(
calculateYHeightWithOffsetForItemLayouts(previous, { sectionId: currentCategory.id, layoutId }),
calculateYHeightWithOffsetForDynamicSectionLayouts(previous.sections, {
sectionId: currentCategory.id,
layoutId,
}),
),
};
});
return {
...previous,
sections: previous.sections
.filter((section) => section.id !== currentCategory.id && section.id !== removedSection.id)
.map((section) =>
section.kind === "dynamic"
? {
...section,
layouts: section.layouts.map((layout) => {
const aboveYOffset = aboveYOffsets.find(({ layoutId }) => layout.layoutId === layoutId)?.yOffset ?? 0;
const categoryYOffset =
categoryYOffsets.find(({ layoutId }) => layout.layoutId === layoutId)?.yOffset ?? 0;
if (layout.parentSectionId === currentCategory.id) {
return {
...layout,
yOffset: layout.yOffset + aboveYOffset,
parentSectionId: aboveSection.id,
};
}
if (layout.parentSectionId === removedSection.id) {
return {
...layout,
yOffset: layout.yOffset + aboveYOffset + categoryYOffset,
parentSectionId: aboveSection.id,
};
}
return layout;
}),
}
: section,
),
items: previous.items.map((item) => ({
...item,
layouts: item.layouts.map((layout) => {
const aboveYOffset = aboveYOffsets.find(({ layoutId }) => layout.layoutId === layoutId)?.yOffset ?? 0;
const categoryYOffset = categoryYOffsets.find(({ layoutId }) => layout.layoutId === layoutId)?.yOffset ?? 0;
if (layout.sectionId === currentCategory.id) {
return {
...layout,
yOffset: layout.yOffset + aboveYOffset,
sectionId: aboveSection.id,
};
}
if (layout.sectionId === removedSection.id) {
return {
...layout,
yOffset: layout.yOffset + aboveYOffset + categoryYOffset,
sectionId: aboveSection.id,
};
}
return layout;
}),
})),
};
};
const calculateYHeightWithOffsetForDynamicSectionLayouts = (
sections: Section[],
{ sectionId, layoutId }: { sectionId: string; layoutId: string },
) => {
return sections
.filter((section) => section.kind === "dynamic")
.map((section) => section.layouts.find((layout) => layout.layoutId === layoutId))
.filter((layout) => layout !== undefined)
.filter((layout) => layout.parentSectionId === sectionId)
.reduce((acc, layout) => {
const yHeightWithOffset = layout.yOffset + layout.height;
if (yHeightWithOffset > acc) return yHeightWithOffset;
return acc;
}, 0);
};
const calculateYHeightWithOffsetForItemLayouts = (
board: Board,
{ sectionId, layoutId }: { sectionId: string; layoutId: string },
) =>
board.items
.map((item) => item.layouts.find((layout) => layout.layoutId === layoutId))
.filter((layout) => layout !== undefined)
.filter((layout) => layout.sectionId === sectionId)
.reduce((acc, layout) => {
const yHeightWithOffset = layout.yOffset + layout.height;
if (yHeightWithOffset > acc) return yHeightWithOffset;
return acc;
}, 0);
@@ -0,0 +1,79 @@
import { describe, expect, test } from "vitest";
import type { Section } from "~/app/[locale]/boards/_types";
import { moveCategoryCallback } from "../move-category";
describe("Move Category", () => {
test.each([
[3, [0, 3, 4, 1, 2, 5, 6]],
[5, [0, 1, 2, 5, 6, 3, 4]],
])("should move category up", (moveId, expectedOrder) => {
const sections = createSections(3);
const input = {
id: moveId.toString(),
direction: "up" as const,
};
const result = moveCategoryCallback(input)({ sections } as never);
expect(sortSections(result.sections).map((section) => parseInt(section.id, 10))).toEqual(expectedOrder);
});
test.each([
[1, [0, 3, 4, 1, 2, 5, 6]],
[3, [0, 1, 2, 5, 6, 3, 4]],
])("should move category down", (moveId, expectedOrder) => {
const sections = createSections(3);
const input = {
id: moveId.toString(),
direction: "down" as const,
};
const result = moveCategoryCallback(input)({ sections } as never);
expect(sortSections(result.sections).map((section) => parseInt(section.id, 10))).toEqual(expectedOrder);
});
test("should not move category up if it is at the top", () => {
const sections = createSections(3);
const input = {
id: "1",
direction: "up" as const,
};
const result = moveCategoryCallback(input)({ sections } as never);
expect(sortSections(result.sections).map((section) => parseInt(section.id, 10))).toEqual([0, 1, 2, 3, 4, 5, 6]);
});
test("should not move category down if it is at the bottom", () => {
const sections = createSections(3);
const input = {
id: "5",
direction: "down" as const,
};
const result = moveCategoryCallback(input)({ sections } as never);
expect(sortSections(result.sections).map((section) => parseInt(section.id, 10))).toEqual([0, 1, 2, 3, 4, 5, 6]);
});
});
const createSections = (categoryCount: number) => {
return Array.from({ length: categoryCount * 2 + 1 }, (_, index) => ({
id: index.toString(),
kind: index % 2 === 1 ? ("category" as const) : ("empty" as const),
name: `Category ${index}`,
yOffset: index,
xOffset: 0,
collapsed: false,
items: [],
})) satisfies Section[];
};
const sortSections = (sections: Section[]) => {
return sections
.filter((section) => section.kind !== "dynamic")
.sort((sectionA, sectionB) => sectionA.yOffset - sectionB.yOffset);
};
@@ -0,0 +1,145 @@
/* eslint-disable @typescript-eslint/no-non-null-assertion */
import { describe, expect, test, vi } from "vitest";
import * as boardContext from "@homarr/boards/context";
import type { DynamicSection, Section } from "~/app/[locale]/boards/_types";
import { BoardMockBuilder } from "~/components/board/items/actions/test/mocks/board-mock";
import { CategorySectionMockBuilder } from "~/components/board/items/actions/test/mocks/category-section-mock";
import { DynamicSectionMockBuilder } from "~/components/board/items/actions/test/mocks/dynamic-section-mock";
import { EmptySectionMockBuilder } from "~/components/board/items/actions/test/mocks/empty-section-mock";
import { ItemMockBuilder } from "~/components/board/items/actions/test/mocks/item-mock";
import { removeCategoryCallback } from "../remove-category";
describe("Remove Category", () => {
test.each([
[3, [0, 1, 2, 3, 4, 5, 6], [0, 1, 2, 5, 6], [3, 4], 2],
[5, [0, 1, 2, 3, 4, 5, 6], [0, 1, 2, 3, 4], [5, 6], 4],
[1, [0, 1, 2, 3, 4, 5, 6], [0, 3, 4, 5, 6], [1, 2], 0],
[3, [0, 3, 6, 7, 8], [0, 7, 8], [3, 6], 0],
])(
"should remove category",
(removeId, initialYOffsets, expectedYOffsets, expectedRemovals, expectedLocationOfItems) => {
// Arrange
const layoutId = "1";
const input = removeId.toString();
const board = new BoardMockBuilder()
.addLayout({ id: layoutId })
.addSections(createSections(initialYOffsets))
.addItems(createSectionItems(initialYOffsets, layoutId))
.build();
vi.spyOn(boardContext, "getBoardLayouts").mockReturnValue([layoutId]);
// Act
const result = removeCategoryCallback({ id: input })(board);
// Assert
expect(result.sections.map((section) => parseInt(section.id, 10))).toEqual(expectedYOffsets);
expectedRemovals.forEach((expectedRemoval) => {
expect(result.sections.find((section) => section.id === expectedRemoval.toString())).toBeUndefined();
});
const aboveSectionItems = result.items.filter(
(item) => item.layouts[0]?.sectionId === expectedLocationOfItems.toString(),
);
expect(aboveSectionItems.map((item) => parseInt(item.id, 10))).toEqual(expect.arrayContaining(expectedRemovals));
},
);
test("should correctly move items to above empty section", () => {
// Arrange
const layoutId = "1";
const sectionIds = {
above: "2",
category: "3",
below: "4",
dynamic: "7",
};
const initialYOffsets = [0, 1, 2, 3, 4, 5, 6];
const board = new BoardMockBuilder()
.addLayout({ id: layoutId })
.addSections(createSections(initialYOffsets))
.addItems(createSectionItems([0, 1, 5, 6], layoutId)) // Only add items to other sections
.addDynamicSection(
new DynamicSectionMockBuilder({ id: sectionIds.dynamic })
.addLayout({ layoutId, parentSectionId: sectionIds.category, yOffset: 7, height: 3 })
.build(),
)
.addItem(new ItemMockBuilder({ id: "above-1" }).addLayout({ layoutId, sectionId: sectionIds.above }).build())
.addItem(
new ItemMockBuilder({ id: "above-2" })
.addLayout({ layoutId, sectionId: sectionIds.above, yOffset: 3, xOffset: 2, height: 2 })
.build(),
)
.addItem(
new ItemMockBuilder({ id: "category-1" }).addLayout({ layoutId, sectionId: sectionIds.category }).build(),
)
.addItem(
new ItemMockBuilder({ id: "category-2" })
.addLayout({ layoutId, sectionId: sectionIds.category, yOffset: 2, xOffset: 4, width: 4 })
.build(),
)
.addItem(
new ItemMockBuilder({ id: "below-1" }).addLayout({ layoutId, sectionId: sectionIds.below, xOffset: 5 }).build(),
)
.addItem(
new ItemMockBuilder({ id: "below-2" })
.addLayout({ layoutId, sectionId: sectionIds.below, yOffset: 1, xOffset: 1, height: 2 })
.build(),
)
.addItem(new ItemMockBuilder({ id: "dynamic-1" }).addLayout({ layoutId, sectionId: sectionIds.dynamic }).build())
.build();
vi.spyOn(boardContext, "getBoardLayouts").mockReturnValue([layoutId]);
// Act
const result = removeCategoryCallback({ id: sectionIds.category })(board);
// Assert
expect(result.sections.map((section) => parseInt(section.id, 10))).toEqual([0, 1, 2, 5, 6, 7]);
const aboveSectionItems = result.items.filter((item) => item.layouts[0]?.sectionId === sectionIds.above);
expect(aboveSectionItems.length).toBe(6);
expect(
aboveSectionItems
.map((item) => ({
...item,
...item.layouts[0]!,
}))
.sort((itemA, itemB) => itemA.yOffset - itemB.yOffset),
).toEqual([
expect.objectContaining({ id: "above-1", yOffset: 0, xOffset: 0 }),
expect.objectContaining({ id: "above-2", yOffset: 3, xOffset: 2, height: 2 }),
expect.objectContaining({ id: "category-1", yOffset: 5, xOffset: 0 }),
expect.objectContaining({ id: "category-2", yOffset: 7, xOffset: 4, width: 4 }),
expect.objectContaining({ id: "below-1", yOffset: 15, xOffset: 5 }),
expect.objectContaining({ id: "below-2", yOffset: 16, xOffset: 1, height: 2 }),
]);
const dynamicSection = result.sections.find((section): section is DynamicSection => section.id === "7")!;
expect(dynamicSection.layouts.at(0)?.yOffset).toBe(12);
expect(dynamicSection.layouts[0]?.parentSectionId).toBe("2");
});
});
const createSections = (initialYOffsets: number[]) => {
return initialYOffsets.map((yOffset, index) =>
index % 2 === 0
? new EmptySectionMockBuilder({
id: yOffset.toString(),
yOffset,
}).build()
: new CategorySectionMockBuilder({
id: yOffset.toString(),
yOffset,
}).build(),
) satisfies Section[];
};
const createSectionItems = (initialYOffsets: number[], layoutId: string) => {
return initialYOffsets.map((yOffset) =>
new ItemMockBuilder({ id: yOffset.toString() }).addLayout({ layoutId, sectionId: yOffset.toString() }).build(),
);
};
@@ -0,0 +1,145 @@
import { useCallback } from "react";
import { useUpdateBoard } from "@homarr/boards/updater";
import { createId } from "@homarr/common";
import type { CategorySection, EmptySection, Section } from "~/app/[locale]/boards/_types";
import type { MoveCategoryInput } from "./actions/move-category";
import { moveCategoryCallback } from "./actions/move-category";
import type { RemoveCategoryInput } from "./actions/remove-category";
import { removeCategoryCallback } from "./actions/remove-category";
interface AddCategory {
name: string;
yOffset: number;
}
interface RenameCategory {
id: string;
name: string;
}
export const useCategoryActions = () => {
const { updateBoard } = useUpdateBoard();
const addCategory = useCallback(
({ name, yOffset }: AddCategory) => {
if (yOffset <= -1) {
return;
}
updateBoard((previous) => ({
...previous,
sections: [
// Place sections before the new category
...previous.sections.filter(
(section) => (section.kind === "category" || section.kind === "empty") && section.yOffset < yOffset,
),
{
id: createId(),
name,
kind: "category",
yOffset,
xOffset: 0,
collapsed: false,
},
{
id: createId(),
kind: "empty",
yOffset: yOffset + 1,
xOffset: 0,
},
// Place sections after the new category
...previous.sections
.filter(
(section): section is CategorySection | EmptySection =>
(section.kind === "category" || section.kind === "empty") && section.yOffset >= yOffset,
)
.map((section) => ({
...section,
yOffset: section.yOffset + 2,
})),
...previous.sections.filter((section) => section.kind !== "category" && section.kind !== "empty"),
] satisfies Section[],
}));
},
[updateBoard],
);
const addCategoryToEnd = useCallback(
({ name }: { name: string }) => {
updateBoard((previous) => {
const lastSection = previous.sections
.filter(
(section): section is CategorySection | EmptySection =>
section.kind === "empty" || section.kind === "category",
)
.sort((sectionA, sectionB) => sectionB.yOffset - sectionA.yOffset)
.at(0);
if (!lastSection) return previous;
const lastYOffset = lastSection.yOffset;
return {
...previous,
sections: [
...previous.sections,
{
id: createId(),
name,
kind: "category",
yOffset: lastYOffset + 1,
xOffset: 0,
collapsed: false,
},
{
id: createId(),
kind: "empty",
yOffset: lastYOffset + 2,
xOffset: 0,
},
] satisfies Section[],
};
});
},
[updateBoard],
);
const renameCategory = useCallback(
({ id: categoryId, name }: RenameCategory) => {
updateBoard((previous) => ({
...previous,
sections: previous.sections.map((section) => {
if (section.kind !== "category") return section;
if (section.id !== categoryId) return section;
return {
...section,
name,
};
}),
}));
},
[updateBoard],
);
const moveCategory = useCallback(
(input: MoveCategoryInput) => {
updateBoard(moveCategoryCallback(input));
},
[updateBoard],
);
const removeCategory = useCallback(
(input: RemoveCategoryInput) => {
updateBoard(removeCategoryCallback(input));
},
[updateBoard],
);
return {
addCategory,
addCategoryToEnd,
renameCategory,
moveCategory,
removeCategory,
};
};
@@ -0,0 +1,45 @@
import { Stack, TextInput } from "@mantine/core";
import { z } from "zod/v4";
import { useZodForm } from "@homarr/form";
import { createModal, ModalFormFooter, modalSizeForm } from "@homarr/modals";
import { useI18n } from "@homarr/translation/client";
interface Category {
id: string;
name: string;
}
interface InnerProps {
submitLabel: string;
category: Category;
onSuccess: (category: Category) => void;
}
export const CategoryEditModal = createModal<InnerProps>(({ actions, innerProps }) => {
const t = useI18n();
const form = useZodForm(z.object({ name: z.string().min(1) }), {
initialValues: {
name: innerProps.category.name,
},
});
return (
<form
onSubmit={form.onSubmit((values) => {
void innerProps.onSuccess({
...innerProps.category,
name: values.name,
});
actions.closeModal();
})}
>
<Stack>
<TextInput label={t("section.category.field.name.label")} data-autofocus {...form.getInputProps("name")} />
<ModalFormFooter onCancel={actions.closeModal} submitLabel={innerProps.submitLabel} />
</Stack>
</form>
);
}).withOptions({
size: modalSizeForm,
});
@@ -0,0 +1,146 @@
import { useCallback } from "react";
import { fetchApi } from "@homarr/api/client";
import { getCurrentLayout, useRequiredBoard } from "@homarr/boards/context";
import { createId } from "@homarr/common";
import { useConfirmModal, useModalAction } from "@homarr/modals";
import { useSettings } from "@homarr/settings";
import { useI18n } from "@homarr/translation/client";
import type { CategorySection } from "~/app/[locale]/boards/_types";
import { useCategoryActions } from "./category-actions";
import { CategoryEditModal } from "./category-edit-modal";
import { filterByItemKind } from "./filter";
export const useCategoryMenuActions = (category: CategorySection) => {
const { openModal } = useModalAction(CategoryEditModal);
const { openConfirmModal } = useConfirmModal();
const { addCategory, moveCategory, removeCategory, renameCategory } = useCategoryActions();
const t = useI18n();
const board = useRequiredBoard();
const createCategoryAtYOffset = useCallback(
(position: number) => {
openModal(
{
category: {
id: createId(),
name: t("section.category.create.title"),
},
onSuccess: (category) => {
addCategory({
name: category.name,
yOffset: position,
});
},
submitLabel: t("section.category.create.submit"),
},
{
title: (t) => t("section.category.create.title"),
},
);
},
[addCategory, t, openModal],
);
// creates a new category above the current
const addCategoryAbove = useCallback(() => {
const aboveYOffset = category.yOffset;
createCategoryAtYOffset(aboveYOffset);
}, [category.yOffset, createCategoryAtYOffset]);
// creates a new category below the current
const addCategoryBelow = useCallback(() => {
const belowYOffset = category.yOffset + 2;
createCategoryAtYOffset(belowYOffset);
}, [category.yOffset, createCategoryAtYOffset]);
const moveCategoryUp = useCallback(() => {
moveCategory({
id: category.id,
direction: "up",
});
}, [category.id, moveCategory]);
const moveCategoryDown = useCallback(() => {
moveCategory({
id: category.id,
direction: "down",
});
}, [category.id, moveCategory]);
// Removes the current category
const remove = useCallback(() => {
openConfirmModal({
title: t("section.category.remove.title"),
children: t("section.category.remove.message", {
name: category.name,
}),
onConfirm: () => {
removeCategory({
id: category.id,
});
},
});
}, [category.id, category.name, removeCategory, t, openConfirmModal]);
const edit = useCallback(() => {
openModal(
{
category,
submitLabel: t("section.category.edit.submit"),
onSuccess: (category) => {
renameCategory({
id: category.id,
name: category.name,
});
},
},
{
title: (t) => t("section.category.edit.title"),
},
);
}, [category, openModal, renameCategory, t]);
const settings = useSettings();
const openAllInNewTabs = useCallback(async () => {
const currentLayoutId = getCurrentLayout(board);
const appIds = (
await filterByItemKind(
board.items.filter(
(item) => item.layouts.find((layout) => layout.layoutId === currentLayoutId)?.sectionId === category.id,
),
settings,
"app",
)
).map((item) => {
return item.options.appId;
});
const apps = await fetchApi.app.byIds.query(appIds);
const appsWithUrls = apps.filter((app) => app.href && app.href.length > 0);
for (const app of appsWithUrls) {
const openedWindow = window.open(app.href ?? undefined);
if (openedWindow) {
continue;
}
openConfirmModal({
title: t("section.category.openAllInNewTabs.title"),
children: t("section.category.openAllInNewTabs.text"),
});
break;
}
}, [category, board, t, openConfirmModal, settings]);
return {
addCategoryAbove,
addCategoryBelow,
moveCategoryUp,
moveCategoryDown,
remove,
edit,
openAllInNewTabs,
};
};
@@ -0,0 +1,128 @@
"use client";
import React, { useMemo } from "react";
import { ActionIcon, Menu } from "@mantine/core";
import {
IconDotsVertical,
IconEdit,
IconExternalLink,
IconRowInsertBottom,
IconRowInsertTop,
IconTransitionBottom,
IconTransitionTop,
IconTrash,
} from "@tabler/icons-react";
import { useEditMode } from "@homarr/boards/edit-mode";
import type { MaybePromise } from "@homarr/common/types";
import { useScopedI18n } from "@homarr/translation/client";
import type { TablerIcon } from "@homarr/ui";
import type { CategorySection } from "~/app/[locale]/boards/_types";
import { useCategoryMenuActions } from "./category-menu-actions";
interface Props {
category: CategorySection;
}
export const CategoryMenu = ({ category }: Props) => {
const actions = useActions(category);
const t = useScopedI18n("section.category");
return (
<Menu>
<Menu.Target>
<ActionIcon mr="sm" variant="transparent" aria-label={t("menu.label.settings")}>
<IconDotsVertical size={20} />
</ActionIcon>
</Menu.Target>
<Menu.Dropdown>
{actions.map((action) => {
return (
<React.Fragment key={action.label}>
{"group" in action && <Menu.Label>{t(action.group)}</Menu.Label>}
<Menu.Item
leftSection={<action.icon size="1rem" />}
onClick={action.onClick}
color={"color" in action ? action.color : undefined}
>
{t(action.label)}
</Menu.Item>
</React.Fragment>
);
})}
</Menu.Dropdown>
</Menu>
);
};
const useActions = (category: CategorySection) => {
const [isEditMode] = useEditMode();
const editModeActions = useEditModeActions(category);
const nonEditModeActions = useNonEditModeActions(category);
return useMemo(
() => (isEditMode ? editModeActions : nonEditModeActions),
[isEditMode, editModeActions, nonEditModeActions],
);
};
const useEditModeActions = (category: CategorySection) => {
const { addCategoryAbove, addCategoryBelow, moveCategoryUp, moveCategoryDown, edit, remove } =
useCategoryMenuActions(category);
return [
{
icon: IconEdit,
label: "action.edit",
onClick: edit,
},
{
icon: IconTrash,
color: "red",
label: "action.remove",
onClick: remove,
},
{
group: "menu.label.changePosition",
icon: IconTransitionTop,
label: "action.moveUp",
onClick: moveCategoryUp,
},
{
icon: IconTransitionBottom,
label: "action.moveDown",
onClick: moveCategoryDown,
},
{
group: "menu.label.create",
icon: IconRowInsertTop,
label: "action.createAbove",
onClick: addCategoryAbove,
},
{
icon: IconRowInsertBottom,
label: "action.createBelow",
onClick: addCategoryBelow,
},
] as const satisfies ActionDefinition[];
};
const useNonEditModeActions = (category: CategorySection) => {
const { openAllInNewTabs } = useCategoryMenuActions(category);
return [
{
icon: IconExternalLink,
label: "action.openAllInNewTabs",
onClick: openAllInNewTabs,
},
] as const satisfies ActionDefinition[];
};
interface ActionDefinition {
icon: TablerIcon;
label: string;
onClick: () => MaybePromise<void>;
color?: string;
group?: string;
}
@@ -0,0 +1,25 @@
import type { WidgetKind } from "@homarr/definitions";
import type { SettingsContextProps } from "@homarr/settings/creator";
import type { WidgetComponentProps } from "@homarr/widgets/definition";
import { loadWidgetDefinition, reduceWidgetOptionsWithDefinition } from "@homarr/widgets/manifest";
import type { Item } from "~/app/[locale]/boards/_types";
export const filterByItemKind = async <TKind extends WidgetKind>(
items: Item[],
settings: SettingsContextProps,
kind: TKind,
) => {
const definition = await loadWidgetDefinition(kind);
return items
.filter((item) => item.kind === kind)
.map((item) => ({
...item,
options: reduceWidgetOptionsWithDefinition(
definition,
settings,
item.options,
) as WidgetComponentProps<TKind>["options"],
}));
};
@@ -1,175 +0,0 @@
import dynamic from "next/dynamic";
import { ActionIcon, Badge, Box, Button, Card } from "@mantine/core";
import { IconChevronDown, IconChevronUp, IconExternalLink, IconGripVertical } from "@tabler/icons-react";
import combineClasses from "clsx";
import { useRequiredBoard } from "@homarr/boards/context";
import { useEditMode } from "@homarr/boards/edit-mode";
import { useI18n, useScopedI18n } from "@homarr/translation/client";
import type { ContainerSectionItem } from "~/app/[locale]/boards/_types";
import { SectionGrid } from "./grid/section-grid";
import { useSectionCollapse } from "./section-collapse";
import { useOpenSectionApps } from "./use-open-section-apps";
import classes from "./item.module.css";
const BoardContainerMenu = dynamic(
() => import("./container/container-menu").then((module) => module.BoardContainerMenu),
{ ssr: false },
);
interface Props {
section: ContainerSectionItem;
}
export const BoardContainerSection = ({ section }: Props) => {
const board = useRequiredBoard();
const [isEditMode] = useEditMode();
const t = useScopedI18n("section.container");
const tAll = useI18n();
const options = section.options;
const { open: openAllInNewTabs, isLoading: areAppsLoading } = useOpenSectionApps(
section.id,
options.showOpenAll && !isEditMode,
);
const { isVisuallyCollapsed, toggle } = useSectionCollapse({
sectionId: section.id,
collapsible: options.collapsible,
});
const label = options.title.trim() || t("untitled");
const contentId = `board-container-${section.id}-content`;
const labelLeft = isEditMode ? 40 : 8;
const labelRight = isEditMode ? 48 : options.showOpenAll ? 40 : 8;
return (
<Box className="board-grid-item-content" data-grid-item-content w="100%" h="100%" style={{ overflow: "visible" }}>
<Card
className={combineClasses(
classes.itemCard,
options.customCssClasses.join(" "),
isVisuallyCollapsed && classes.collapsedContainerCard,
)}
w="100%"
h="100%"
data-board-container-collapsed={isVisuallyCollapsed ? "true" : "false"}
styles={{
root: {
overflow: "visible",
"--opacity": board.opacity / 100,
"--border-color": options.borderColor || undefined,
},
}}
radius={board.itemRadius}
p={0}
>
{options.collapsible && !isEditMode && (
<Button
className={classes.containerToggle}
pos="absolute"
top={isVisuallyCollapsed ? 0 : -24}
left={0}
w="100%"
h={isVisuallyCollapsed ? "100%" : 24}
px={12}
pe={options.showOpenAll ? 40 : 12}
radius="sm"
variant="default"
justify={options.showLabel ? "flex-start" : "center"}
leftSection={
options.showLabel ? (
isVisuallyCollapsed ? (
<IconChevronDown size={16} />
) : (
<IconChevronUp size={16} />
)
) : undefined
}
onClick={toggle}
aria-expanded={!isVisuallyCollapsed}
aria-controls={contentId}
aria-label={`${t(isVisuallyCollapsed ? "action.expand" : "action.collapse")}: ${label}`}
data-board-container-collapsed-control={isVisuallyCollapsed ? "true" : undefined}
data-board-container-label
title={options.showLabel ? label : undefined}
>
{options.showLabel ? (
label
) : isVisuallyCollapsed ? (
<IconChevronDown size={16} />
) : (
<IconChevronUp size={16} />
)}
</Button>
)}
{isEditMode && (
<ActionIcon
component="span"
pos="absolute"
top={4}
left={4}
style={{ zIndex: 10, cursor: "grab", touchAction: "none" }}
variant="default"
size={24}
radius="sm"
data-grid-container-drag-handle
aria-hidden="true"
>
<IconGripVertical size={16} />
</ActionIcon>
)}
{!isVisuallyCollapsed && (!options.collapsible || isEditMode) && options.showLabel && options.title && (
<Badge
pos="absolute"
top={-24}
left={labelLeft}
maw={`calc(100% - ${labelLeft + labelRight}px)`}
size="md"
radius="sm"
variant="default"
c="var(--mantine-color-text)"
style={{
zIndex: 9,
overflow: "hidden",
pointerEvents: "none",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
backgroundColor: "var(--background-color)",
borderColor: "var(--border-color)",
}}
title={options.title}
data-board-container-label
>
{options.title}
</Badge>
)}
{options.showOpenAll && !isEditMode && (
<ActionIcon
pos="absolute"
top={isVisuallyCollapsed ? "50%" : -24}
right={8}
style={{ zIndex: 10, transform: isVisuallyCollapsed ? "translateY(-50%)" : undefined }}
variant={options.collapsible ? "subtle" : "default"}
size={24}
radius="sm"
loading={areAppsLoading}
onClick={openAllInNewTabs}
aria-label={tAll("section.action.openAllInNewTabsFor", { name: label })}
>
<IconExternalLink size={16} />
</ActionIcon>
)}
<Box
id={contentId}
className={classes.containerBody}
h="100%"
data-collapsed={isVisuallyCollapsed ? "true" : "false"}
aria-hidden={isVisuallyCollapsed}
inert={isVisuallyCollapsed}
>
<SectionGrid section={section} columnCount={section.width} requestedRowCount={section.height} label={label} />
</Box>
</Card>
{isEditMode && <BoardContainerMenu section={section} />}
</Box>
);
};
@@ -1,61 +0,0 @@
import { getBoardLayouts } from "@homarr/boards/context";
import { createId } from "@homarr/common";
import { getRootSectionLane } from "@homarr/definitions";
import { containerSectionOptionsSchema } from "@homarr/validation/shared";
import type { Board, ContainerSection, ContainerSectionLayout, EmptySection } from "~/app/[locale]/boards/_types";
import { getBoardLaneColumnCount } from "~/components/board/layout";
import { getFirstEmptyPosition } from "~/components/board/items/actions/empty-position";
import { getSectionElements } from "~/components/board/items/actions/section-elements";
export const addContainerCallback = () => (board: Board) => {
const firstSection = board.sections
.filter(
(section): section is EmptySection => section.kind === "empty" && getRootSectionLane(section.xOffset) === "main",
)
.toSorted((sectionA, sectionB) => sectionA.yOffset - sectionB.yOffset)
.at(0);
if (!firstSection) return board;
const sectionLayouts = createContainerLayouts(board, firstSection);
const newSection = {
id: createId(),
kind: "container",
collapsed: false,
options: containerSectionOptionsSchema.parse(undefined),
layouts: sectionLayouts,
} satisfies ContainerSection;
return {
...board,
sections: board.sections.concat(newSection),
};
};
const createContainerLayouts = (board: Board, currentSection: EmptySection): ContainerSectionLayout[] => {
const layouts = getBoardLayouts(board);
return layouts.map((layoutId) => {
const boardLayout = board.layouts.find((layout) => layout.id === layoutId);
const elements = getSectionElements(board, { sectionId: currentSection.id, layoutId });
const columnCount = boardLayout ? getBoardLaneColumnCount(boardLayout, "main") : 1;
const size = {
width: Math.min(4, columnCount),
height: 3,
};
const emptyPosition = boardLayout
? getFirstEmptyPosition(elements, columnCount, undefined, size)
: { xOffset: 0, yOffset: 0 };
if (!emptyPosition) throw new Error("Your board is full");
return {
...size,
...emptyPosition,
parentSectionId: currentSection.id,
layoutId,
};
});
};
@@ -1,185 +0,0 @@
import { afterEach, describe, expect, test, vi } from "vitest";
import * as boardContext from "@homarr/boards/context";
import type { ContainerSection } from "~/app/[locale]/boards/_types";
import * as emptyPositionModule from "~/components/board/items/actions/empty-position";
import { BoardMockBuilder } from "~/components/board/items/actions/test/mocks/board-mock";
import { ContainerSectionMockBuilder } from "~/components/board/items/actions/test/mocks/container-section-mock";
import { ItemMockBuilder } from "~/components/board/items/actions/test/mocks/item-mock";
import { addContainerCallback } from "./add-container";
import { removeContainerCallback } from "./remove-container";
describe("container actions", () => {
afterEach(() => {
vi.restoreAllMocks();
});
test("creates a container in the first main root for every responsive layout", () => {
const board = new BoardMockBuilder().addEmptySection({ id: "root", yOffset: 0 }).build();
const layout = board.layouts[0];
if (!layout) throw new Error("Expected a board layout");
layout.id = "layout";
layout.columnCount = 2;
vi.spyOn(boardContext, "getBoardLayouts").mockReturnValue([layout.id]);
vi.spyOn(emptyPositionModule, "getFirstEmptyPosition").mockReturnValue({ xOffset: 0, yOffset: 4 });
const result = addContainerCallback()(board);
const created = result.sections.find((section) => section.kind === "container");
expect(created).toMatchObject({
kind: "container",
collapsed: false,
options: {
showLabel: true,
collapsible: false,
showOpenAll: false,
},
layouts: [
{
layoutId: "layout",
parentSectionId: "root",
xOffset: 0,
yOffset: 4,
width: 2,
height: 3,
},
],
});
});
test("removes a container and flattens its direct children into the parent", () => {
const removed = new ContainerSectionMockBuilder({ id: "removed" })
.addLayout({ layoutId: "layout", parentSectionId: "root", xOffset: 3, yOffset: 4, width: 6, height: 5 })
.build();
const child = new ContainerSectionMockBuilder({ id: "child" })
.addLayout({ layoutId: "layout", parentSectionId: removed.id, xOffset: 1, yOffset: 2, width: 2, height: 2 })
.build();
const nestedItem = new ItemMockBuilder({ id: "nested-item" })
.addLayout({ layoutId: "layout", sectionId: removed.id, xOffset: 2, yOffset: 1, width: 1, height: 1 })
.build();
const board = new BoardMockBuilder()
.addEmptySection({ id: "root" })
.addSection(removed)
.addSection(child)
.addItem(nestedItem)
.build();
const layout = board.layouts[0];
if (!layout) throw new Error("Expected a board layout");
layout.id = "layout";
const result = removeContainerCallback({ id: removed.id })(board);
const flattenedChild = result.sections.find(
(section): section is ContainerSection => section.kind === "container" && section.id === child.id,
);
expect(result.sections.some((section) => section.id === removed.id)).toBe(false);
expect(flattenedChild?.layouts[0]).toMatchObject({
parentSectionId: "root",
xOffset: 4,
yOffset: 6,
});
expect(result.items.find((item) => item.id === nestedItem.id)?.layouts[0]).toMatchObject({
sectionId: "root",
xOffset: 5,
yOffset: 5,
});
});
test("normalizes, pins the top-left child, and resolves collisions while flattening malformed geometry", () => {
const removed = new ContainerSectionMockBuilder({ id: "removed" })
.addLayout({ layoutId: "layout", parentSectionId: "root", xOffset: 2, yOffset: 0, width: 2, height: 2 })
.build();
const existing = new ItemMockBuilder({ id: "existing" })
.addLayout({ layoutId: "layout", sectionId: "root", xOffset: 0, yOffset: 0, width: 2, height: 1 })
.build();
const childA = new ItemMockBuilder({ id: "a" })
.addLayout({ layoutId: "layout", sectionId: removed.id, xOffset: 1, yOffset: 0, width: 2, height: 1 })
.build();
const childB = new ItemMockBuilder({ id: "b" })
.addLayout({ layoutId: "layout", sectionId: removed.id, xOffset: 0, yOffset: 0, width: 2, height: 1 })
.build();
const board = new BoardMockBuilder()
.addEmptySection({ id: "root" })
.addSection(removed)
.addItems([existing, childA, childB])
.build();
const layout = board.layouts[0];
if (!layout) throw new Error("Expected a board layout");
layout.id = "layout";
layout.columnCount = 4;
const result = removeContainerCallback({ id: removed.id })(board);
const layouts = Object.fromEntries(
result.items.map((item) => {
const itemLayout = item.layouts[0];
if (!itemLayout) throw new Error("Expected an item layout");
return [item.id, itemLayout];
}),
);
expect(layouts.existing).toMatchObject({ xOffset: 0, yOffset: 0, width: 2, height: 1 });
expect(layouts.a).toMatchObject({ sectionId: "root", xOffset: 2, yOffset: 1, width: 2, height: 1 });
expect(layouts.b).toMatchObject({ sectionId: "root", xOffset: 2, yOffset: 0, width: 2, height: 1 });
});
test("keeps the container when a child container has layout geometry that cannot be flattened", () => {
const removed = new ContainerSectionMockBuilder({ id: "removed" })
.addLayout({ layoutId: "other-layout", parentSectionId: "root", xOffset: 3, yOffset: 4 })
.build();
const child = new ContainerSectionMockBuilder({ id: "child" })
.addLayout({ layoutId: "layout", parentSectionId: removed.id, xOffset: 1, yOffset: 2 })
.build();
const board = new BoardMockBuilder().addSection(removed).addSection(child).build();
const result = removeContainerCallback({ id: removed.id })(board);
expect(result).toBe(board);
expect(result.sections).toContainEqual(removed);
});
test("keeps the container when a child item has layout geometry that cannot be flattened", () => {
const removed = new ContainerSectionMockBuilder({ id: "removed" })
.addLayout({ layoutId: "other-layout", parentSectionId: "root", xOffset: 3, yOffset: 4 })
.build();
const item = new ItemMockBuilder({ id: "item" })
.addLayout({ layoutId: "layout", sectionId: removed.id, xOffset: 2, yOffset: 1 })
.build();
const board = new BoardMockBuilder().addSection(removed).addItem(item).build();
const result = removeContainerCallback({ id: removed.id })(board);
expect(result).toBe(board);
expect(result.sections).toContainEqual(removed);
});
test("keeps the container when flattening would move a child back into the removed container", () => {
const removed = new ContainerSectionMockBuilder({ id: "removed" })
.addLayout({ layoutId: "layout", parentSectionId: "removed", xOffset: 3, yOffset: 4 })
.build();
const item = new ItemMockBuilder({ id: "item" })
.addLayout({ layoutId: "layout", sectionId: removed.id, xOffset: 2, yOffset: 1 })
.build();
const board = new BoardMockBuilder().addSection(removed).addItem(item).build();
const result = removeContainerCallback({ id: removed.id })(board);
expect(result).toBe(board);
expect(result.sections).toContainEqual(removed);
});
test("keeps the container when its destination is a child container", () => {
const removed = new ContainerSectionMockBuilder({ id: "removed" })
.addLayout({ layoutId: "layout", parentSectionId: "child", xOffset: 3, yOffset: 4 })
.build();
const child = new ContainerSectionMockBuilder({ id: "child" })
.addLayout({ layoutId: "layout", parentSectionId: removed.id, xOffset: 1, yOffset: 2 })
.build();
const board = new BoardMockBuilder().addSection(removed).addSection(child).build();
const result = removeContainerCallback({ id: removed.id })(board);
expect(result).toBe(board);
expect(result.sections).toContainEqual(removed);
});
});
@@ -1,181 +0,0 @@
import type { Board, ContainerSection } from "~/app/[locale]/boards/_types";
import { resolvePinnedGridCollisions } from "~/components/board/sections/grid/dnd";
import {
getSectionGridColumnCount,
getSectionGridPlacements,
} from "~/components/board/sections/grid/section-grid-placements";
import type { SectionGridPlacement } from "~/components/board/sections/grid/use-grid-layout-actions";
export interface RemoveContainerInput {
id: string;
}
export const removeContainerCallback =
({ id }: RemoveContainerInput) =>
(board: Board): Board => {
const sectionToRemove = board.sections.find(
(section): section is ContainerSection => section.id === id && section.kind === "container",
);
if (!sectionToRemove) return board;
const removedLayoutsById = new Map(sectionToRemove.layouts.map((layout) => [layout.layoutId, layout]));
const childLayoutIds = [
...board.sections.flatMap((section) =>
section.kind === "container"
? section.layouts.flatMap((layout) =>
layout.parentSectionId === sectionToRemove.id ? [layout.layoutId] : [],
)
: [],
),
...board.items.flatMap((item) =>
item.layouts.flatMap((layout) => (layout.sectionId === sectionToRemove.id ? [layout.layoutId] : [])),
),
];
const hasUnresolvableChildLayout = childLayoutIds.some((layoutId) => {
const removedLayout = removedLayoutsById.get(layoutId);
return (
!removedLayout || !isValidFlattenDestination(board, sectionToRemove.id, layoutId, removedLayout.parentSectionId)
);
});
if (hasUnresolvableChildLayout) return board;
const flattened = {
...board,
sections: board.sections
.filter((section) => section.id !== id)
.map((section) => {
if (section.kind !== "container") return section;
return {
...section,
layouts: section.layouts.map((layout) => {
if (layout.parentSectionId !== sectionToRemove.id) return layout;
const removedSectionLayout = sectionToRemove.layouts.find(
(layoutToRemove) => layoutToRemove.layoutId === layout.layoutId,
);
if (!removedSectionLayout) return layout;
return {
...layout,
xOffset: layout.xOffset + removedSectionLayout.xOffset,
yOffset: layout.yOffset + removedSectionLayout.yOffset,
parentSectionId: removedSectionLayout.parentSectionId,
};
}),
};
}),
items: board.items.map((item) => ({
...item,
layouts: item.layouts.map((layout) => {
if (layout.sectionId !== sectionToRemove.id) return layout;
const removedSectionLayout = sectionToRemove.layouts.find(
(layoutToRemove) => layoutToRemove.layoutId === layout.layoutId,
);
if (!removedSectionLayout) return layout;
return {
...layout,
xOffset: layout.xOffset + removedSectionLayout.xOffset,
yOffset: layout.yOffset + removedSectionLayout.yOffset,
sectionId: removedSectionLayout.parentSectionId,
};
}),
})),
};
return resolveFlattenedLayouts(flattened, sectionToRemove, board);
};
const isValidFlattenDestination = (
board: Board,
removedSectionId: string,
layoutId: string,
destinationSectionId: string,
) => {
const visited = new Set<string>();
let sectionId = destinationSectionId;
while (true) {
if (sectionId === removedSectionId || visited.has(sectionId)) return false;
visited.add(sectionId);
const section = board.sections.find((candidate) => candidate.id === sectionId);
if (!section) return false;
if (section.kind !== "container") return true;
const layout = section.layouts.find((candidate) => candidate.layoutId === layoutId);
if (!layout) return false;
sectionId = layout.parentSectionId;
}
};
const resolveFlattenedLayouts = (board: Board, removed: ContainerSection, original: Board): Board => {
const resolvedByLayoutAndId = new Map<string, SectionGridPlacement>();
for (const removedLayout of removed.layouts) {
const movedIds = [
...original.items.flatMap((item) => {
const layout = item.layouts.find((candidate) => candidate.layoutId === removedLayout.layoutId);
return layout?.sectionId === removed.id ? [item.id] : [];
}),
...original.sections.flatMap((section) => {
if (section.kind !== "container") return [];
const layout = section.layouts.find((candidate) => candidate.layoutId === removedLayout.layoutId);
return layout?.parentSectionId === removed.id ? [section.id] : [];
}),
];
const columnCount = getSectionGridColumnCount(board, removedLayout.layoutId, removedLayout.parentSectionId);
if (!columnCount) continue;
const placements = getSectionGridPlacements(board, removedLayout.layoutId, removedLayout.parentSectionId);
const movedIdSet = new Set(movedIds);
const pinned = placements
.filter((placement) => movedIdSet.has(placement.id))
.toSorted((first, second) => first.y - second.y || first.x - second.x || first.id.localeCompare(second.id))[0];
if (!pinned) continue;
for (const placement of resolvePinnedGridCollisions(placements, pinned, columnCount)) {
resolvedByLayoutAndId.set(`${removedLayout.layoutId}:${placement.id}`, placement);
}
}
if (resolvedByLayoutAndId.size === 0) return board;
return {
...board,
items: board.items.map((item) => ({
...item,
layouts: item.layouts.map((layout) => {
const placement = resolvedByLayoutAndId.get(`${layout.layoutId}:${item.id}`);
return placement?.type === "item"
? {
...layout,
xOffset: placement.x,
yOffset: placement.y,
width: placement.w,
height: placement.h,
}
: layout;
}),
})),
sections: board.sections.map((section) =>
section.kind !== "container"
? section
: {
...section,
layouts: section.layouts.map((layout) => {
const placement = resolvedByLayoutAndId.get(`${layout.layoutId}:${section.id}`);
return placement?.type === "section"
? {
...layout,
xOffset: placement.x,
yOffset: placement.y,
width: placement.w,
height: placement.h,
}
: layout;
}),
},
),
};
};
@@ -1,42 +0,0 @@
import { useCallback } from "react";
import { useUpdateBoard } from "@homarr/boards/updater";
import type { ContainerSectionOptions } from "@homarr/validation/shared";
import { addContainerCallback } from "./actions/add-container";
import type { RemoveContainerInput } from "./actions/remove-container";
import { removeContainerCallback } from "./actions/remove-container";
interface UpdateContainerOptions {
containerId: string;
newOptions: ContainerSectionOptions;
}
export const useContainerActions = () => {
const { updateBoard } = useUpdateBoard();
const addContainer = useCallback(() => {
updateBoard(addContainerCallback());
}, [updateBoard]);
const updateContainer = useCallback(
({ containerId, newOptions }: UpdateContainerOptions) => {
updateBoard((previous) => ({
...previous,
sections: previous.sections.map((item) =>
item.id !== containerId || item.kind !== "container" ? item : { ...item, options: newOptions },
),
}));
},
[updateBoard],
);
const removeContainer = useCallback(
(input: RemoveContainerInput) => {
updateBoard(removeContainerCallback(input));
},
[updateBoard],
);
return { addContainer, updateContainer, removeContainer };
};
@@ -1,74 +0,0 @@
"use client";
import { CloseButton, ColorInput, Stack, Switch, TextInput, useMantineTheme } from "@mantine/core";
import type { z } from "zod/v4";
import { useZodForm } from "@homarr/form";
import { createModal, ModalFormFooter, modalSizeForm } from "@homarr/modals";
import { useI18n } from "@homarr/translation/client";
import { TextMultiSelect } from "@homarr/ui";
import { containerSectionOptionsSchema } from "@homarr/validation/shared";
interface ModalProps {
value: z.infer<typeof containerSectionOptionsSchema>;
onSuccessfulEdit: (value: z.infer<typeof containerSectionOptionsSchema>) => void;
}
export const ContainerEditModal = createModal<ModalProps>(({ actions, innerProps }) => {
const t = useI18n();
const theme = useMantineTheme();
const form = useZodForm(containerSectionOptionsSchema.unwrap(), {
mode: "controlled",
initialValues: { ...innerProps.value },
});
return (
<form
onSubmit={form.onSubmit((values) => {
innerProps.onSuccessfulEdit(values);
actions.closeModal();
})}
>
<Stack>
<TextInput label={t("section.container.option.title.label")} data-autofocus {...form.getInputProps("title")} />
<Switch
label={t("section.option.showLabel.label")}
description={t("section.option.showLabel.description")}
{...form.getInputProps("showLabel", { type: "checkbox" })}
/>
<Switch
label={t("section.option.collapsible.label")}
description={t("section.option.collapsible.description")}
{...form.getInputProps("collapsible", { type: "checkbox" })}
/>
<Switch
label={t("section.option.showOpenAll.label")}
description={t("section.option.showOpenAll.description")}
{...form.getInputProps("showOpenAll", { type: "checkbox" })}
/>
<TextMultiSelect
label={t("section.container.option.customCssClasses.label")}
{...form.getInputProps("customCssClasses")}
/>
<ColorInput
label={t("section.container.option.borderColor.label")}
format="hex"
swatches={Object.values(theme.colors).map((color) => color[6])}
rightSection={
<CloseButton
onClick={() => form.setFieldValue("borderColor", "")}
style={{ display: form.getInputProps("borderColor").value ? undefined : "none" }}
/>
}
{...form.getInputProps("borderColor")}
/>
<ModalFormFooter onCancel={actions.closeModal} />
</Stack>
</form>
);
}).withOptions({
defaultTitle(t) {
return t("item.edit.title");
},
size: modalSizeForm,
});
@@ -1,79 +0,0 @@
import { ActionIcon, Menu } from "@mantine/core";
import { IconArrowsMove, IconLayoutKanban, IconPencil, IconTrash } from "@tabler/icons-react";
import { useEditMode } from "@homarr/boards/edit-mode";
import { useConfirmModal, useModalAction } from "@homarr/modals";
import { useI18n, useScopedI18n } from "@homarr/translation/client";
import type { ContainerSectionItem } from "~/app/[locale]/boards/_types";
import { useOpenItemMoveModal } from "../../items/item-move-modal";
import { useSectionContext } from "../section-context";
import { useContainerActions } from "./container-actions";
import { ContainerEditModal } from "./container-edit-modal";
export const BoardContainerMenu = ({ section }: { section: ContainerSectionItem }) => {
const t = useI18n();
const tContainer = useScopedI18n("section.container");
const tItem = useScopedI18n("item");
const { openModal } = useModalAction(ContainerEditModal);
const openMoveModal = useOpenItemMoveModal();
const { updateContainer, removeContainer } = useContainerActions();
const { openConfirmModal } = useConfirmModal();
const [isEditMode] = useEditMode();
const { section: parentSection } = useSectionContext();
const label = section.options.title || tContainer("action.create");
const menuRightOffset = parentSection.kind === "container" ? 44 : 4;
if (!isEditMode) return null;
const openEditModal = () => {
openModal({
value: section.options,
onSuccessfulEdit: (options) => updateContainer({ containerId: section.id, newOptions: options }),
});
};
const openRemoveModal = () => {
openConfirmModal({
title: tContainer("remove.title"),
children: tContainer("remove.message"),
onConfirm: () => removeContainer({ id: section.id }),
});
};
return (
<Menu withinPortal position="right-start" arrowPosition="center">
<Menu.Target>
<ActionIcon
variant="default"
size={24}
radius="sm"
pos="absolute"
top={-24}
right={menuRightOffset}
style={{ zIndex: 10 }}
aria-label={tItem("menu.label.settingsFor", { name: label })}
>
<IconLayoutKanban size="1rem" />
</ActionIcon>
</Menu.Target>
<Menu.Dropdown miw={128}>
<Menu.Label>{tItem("menu.label.settings")}</Menu.Label>
<Menu.Item leftSection={<IconPencil size={16} />} onClick={openEditModal}>
{tItem("action.edit")}
</Menu.Item>
<Menu.Item
leftSection={<IconArrowsMove size={16} />}
onClick={() => openMoveModal({ entry: section, sourceSectionId: section.parentSectionId })}
>
{tItem("action.moveResize")}
</Menu.Item>
<Menu.Divider />
<Menu.Label c="red.6">{t("common.dangerZone")}</Menu.Label>
<Menu.Item c="red.6" leftSection={<IconTrash size={16} />} onClick={openRemoveModal}>
{tContainer("action.remove")}
</Menu.Item>
</Menu.Dropdown>
</Menu>
);
};
@@ -1,19 +1,33 @@
import { useMemo } from "react";
import type { RouterOutputs } from "@homarr/api";
import type { GridItemHTMLElement } from "@homarr/gridstack";
import type { ContainerSectionItem, SectionItem } from "~/app/[locale]/boards/_types";
import type { RouterOutputs } from "@homarr/api";
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 { BoardItemContent } from "../items/item-content";
import { WidgetHoverOverlay } from "../items/widget-hover-overlay";
import { BoardContainerSection } from "./container-section";
import { FixedGridItem } from "./grid/fixed-grid-item";
import { BoardDynamicSection } from "./dynamic-section";
import { GridStackItem } from "./gridstack/gridstack-item";
import { useSectionContext } from "./section-context";
import { useSectionItems } from "./use-section-items";
export const SectionContent = () => {
const { innerSections, integrations, items } = useSectionContext();
const { innerSections, items, refs } = useSectionContext();
const { data: session } = useSession();
const [isEditMode] = useEditMode();
const { data: integrations } = clientApi.integration.all.useQuery(undefined, {
enabled: Boolean(session) && isEditMode,
});
/** Keep portal content order deterministic across static and edit renderers. */
/**
* IMPORTANT: THE ORDER OF THE BELOW ITEMS HAS TO MATCH THE ORDER OF
* THE ITEMS RENDERED WITH GRIDSTACK, OTHERWISE THE ITEMS WILL BE MIXED UP
* @see https://github.com/homarr-labs/homarr/pull/1770
*/
const sortedItems = useMemo(() => {
return [...items, ...innerSections].toSorted((itemA, itemB) => {
if (itemA.yOffset === itemB.yOffset) {
@@ -27,39 +41,57 @@ export const SectionContent = () => {
return (
<>
{sortedItems.map((item) => (
<SectionContentItem key={item.id} item={item} integrations={integrations} />
<Item key={item.id} item={item} innerRef={refs.items.current[item.id]} integrations={integrations} />
))}
</>
);
};
interface ItemProps {
item: ContainerSectionItem | SectionItem;
item: DynamicSectionItem | SectionItem;
innerRef: React.RefObject<GridItemHTMLElement | null> | undefined;
integrations: RouterOutputs["integration"]["all"] | undefined;
}
export const SectionContentItem = ({ item, integrations }: ItemProps) => {
const Item = ({ item, innerRef, integrations }: ItemProps) => {
const minWidth = useMinSize(item, "x");
const minHeight = useMinSize(item, "y");
return (
<FixedGridItem key={item.id} item={item} minWidth={minWidth} minHeight={minHeight}>
<GridStackItem
key={item.id}
innerRef={innerRef}
width={item.width}
height={item.height}
xOffset={item.xOffset}
yOffset={item.yOffset}
kind={item.kind}
id={item.id}
type={item.type}
minWidth={minWidth}
minHeight={minHeight}
>
{item.type === "item" && item.kind !== "app" && <WidgetHoverOverlay item={item} integrations={integrations} />}
{item.type === "item" ? <BoardItemContent item={item} /> : <BoardContainerSection section={item} />}
</FixedGridItem>
{item.type === "item" ? <BoardItemContent item={item} /> : <BoardDynamicSection section={item} />}
</GridStackItem>
);
};
/**
* Calculates a container's minimum width or height from its direct content.
* Calculates the min width / height of a section by taking the maximum of
* the sum of the offset and size of all items and dynamic sections inside.
* @param direction either "x" or "y"
* @param items items of the section
* @param sections sections of the board to look for dynamic sections
* @param parentSectionId the id of the section we want to calculate the min size for
* @returns the min size
*/
export const useMinSize = (item: ContainerSectionItem | SectionItem, direction: "x" | "y") => {
const useMinSize = (item: DynamicSectionItem | SectionItem, direction: "x" | "y") => {
const { items, innerSections } = useSectionItems(item.id);
if (item.type === "item") return undefined;
const size = direction === "x" ? "width" : "height";
return Math.max(
1,
...items.map((childItem) => childItem[`${direction}Offset`] + childItem[size]),
...innerSections.map((childSection) => childSection[`${direction}Offset`] + childSection[size]),
...items.map((item) => item[`${direction}Offset`] + item[size]),
...innerSections.map((item) => item[`${direction}Offset`] + item[size]),
);
};
@@ -0,0 +1,61 @@
import { Badge, Box, Card } from "@mantine/core";
import combineClasses from "clsx";
import { useCurrentLayout, useRequiredBoard } from "@homarr/boards/context";
import type { DynamicSectionItem } from "~/app/[locale]/boards/_types";
import { BoardDynamicSectionMenu } from "./dynamic/dynamic-menu";
import { GridStack } from "./gridstack/gridstack";
import classes from "./item.module.css";
interface Props {
section: DynamicSectionItem;
}
export const BoardDynamicSection = ({ section }: Props) => {
const board = useRequiredBoard();
const currentLayoutId = useCurrentLayout();
const options = section.options;
return (
<Box
className="grid-stack-item-content"
style={{
overflow: "visible",
}}
>
<Card
className={combineClasses(classes.itemCard, section.options.customCssClasses.join(" "))}
w="100%"
h="100%"
styles={{
root: {
overflow: "visible",
"--opacity": board.opacity / 100,
"--border-color": options.borderColor || undefined,
},
}}
radius={board.itemRadius}
p={0}
>
{options.title && (
<Badge
pos="absolute"
top={-15}
left={10}
size="md"
radius={board.itemRadius}
color="var(--background-color)"
c="var(--mantine-color-text)"
bd="1px solid var(--border-color)"
>
{options.title}
</Badge>
)}
{/* Use unique key by layout to reinitialize gridstack */}
<GridStack key={`${currentLayoutId}-${section.id}`} section={section} className="min-row" />
</Card>
<BoardDynamicSectionMenu section={section} />
</Box>
);
};
@@ -0,0 +1,56 @@
import { getBoardLayouts } from "@homarr/boards/context";
import { createId } from "@homarr/common";
import type { Board, DynamicSection, DynamicSectionLayout, EmptySection } from "~/app/[locale]/boards/_types";
import { getFirstEmptyPosition } from "~/components/board/items/actions/empty-position";
import { getSectionElements } from "~/components/board/items/actions/section-elements";
export const addDynamicSectionCallback = () => (board: Board) => {
const firstSection = board.sections
.filter((section) => section.kind === "empty")
.sort((sectionA, sectionB) => sectionA.yOffset - sectionB.yOffset)
.at(0);
if (!firstSection) return board;
const newSection = {
id: createId(),
kind: "dynamic",
options: {
title: "",
borderColor: "",
customCssClasses: [],
},
layouts: createDynamicSectionLayouts(board, firstSection),
} satisfies DynamicSection;
return {
...board,
sections: board.sections.concat(newSection as unknown as DynamicSection),
};
};
const createDynamicSectionLayouts = (board: Board, currentSection: EmptySection): DynamicSectionLayout[] => {
const layouts = getBoardLayouts(board);
return layouts.map((layoutId) => {
const boardLayout = board.layouts.find((layout) => layout.id === layoutId);
const elements = getSectionElements(board, { sectionId: currentSection.id, layoutId });
const emptyPosition = boardLayout
? getFirstEmptyPosition(elements, boardLayout.columnCount)
: { xOffset: 0, yOffset: 0 };
if (!emptyPosition) {
throw new Error("Your board is full");
}
return {
width: 1,
height: 1,
...emptyPosition,
parentSectionId: currentSection.id,
layoutId,
};
});
};
@@ -0,0 +1,62 @@
import type { Board, DynamicSection } from "~/app/[locale]/boards/_types";
export interface RemoveDynamicSectionInput {
id: string;
}
export const removeDynamicSectionCallback =
({ id }: RemoveDynamicSectionInput) =>
(board: Board): Board => {
const sectionToRemove = board.sections.find(
(section): section is DynamicSection => section.id === id && section.kind === "dynamic",
);
if (!sectionToRemove) return board;
return {
...board,
sections: board.sections
.filter((section) => section.id !== id)
.map((section) => {
if (section.kind !== "dynamic") return section;
// Change xOffset and yOffset of sections that were below the removed section and set parentSectionId to the parent of the removed section
return {
...section,
layouts: section.layouts.map((layout) => {
if (layout.parentSectionId !== sectionToRemove.id) return layout;
const removedSectionLayout = sectionToRemove.layouts.find(
(layoutToRemove) => layoutToRemove.layoutId === layout.layoutId,
);
if (!removedSectionLayout) throw new Error("Layout not found");
return {
...layout,
xOffset: layout.xOffset + removedSectionLayout.xOffset,
yOffset: layout.yOffset + removedSectionLayout.yOffset,
parentSectionId: removedSectionLayout.parentSectionId,
};
}),
};
}),
// Move all items in dynamic section to parent of the removed section
items: board.items.map((item) => ({
...item,
layouts: item.layouts.map((layout) => {
if (layout.sectionId !== sectionToRemove.id) return layout;
const removedSectionLayout = sectionToRemove.layouts.find(
(layoutToRemove) => layoutToRemove.layoutId === layout.layoutId,
);
if (!removedSectionLayout) throw new Error("Layout not found");
return {
...layout,
xOffset: layout.xOffset + removedSectionLayout.xOffset,
yOffset: layout.yOffset + removedSectionLayout.yOffset,
sectionId: removedSectionLayout.parentSectionId,
};
}),
})),
};
};
@@ -0,0 +1,45 @@
import { useCallback } from "react";
import type { z } from "zod/v4";
import { useUpdateBoard } from "@homarr/boards/updater";
import type { dynamicSectionOptionsSchema } from "@homarr/validation/shared";
import { addDynamicSectionCallback } from "./actions/add-dynamic-section";
import type { RemoveDynamicSectionInput } from "./actions/remove-dynamic-section";
import { removeDynamicSectionCallback } from "./actions/remove-dynamic-section";
interface UpdateDynamicOptions {
itemId: string;
newOptions: z.infer<typeof dynamicSectionOptionsSchema>;
}
export const useDynamicSectionActions = () => {
const { updateBoard } = useUpdateBoard();
const addDynamicSection = useCallback(() => {
updateBoard(addDynamicSectionCallback());
}, [updateBoard]);
const updateDynamicSection = useCallback(
({ itemId, newOptions }: UpdateDynamicOptions) => {
updateBoard((previous) => ({
...previous,
sections: previous.sections.map((item) => (item.id !== itemId ? item : { ...item, options: newOptions })),
}));
},
[updateBoard],
);
const removeDynamicSection = useCallback(
(input: RemoveDynamicSectionInput) => {
updateBoard(removeDynamicSectionCallback(input));
},
[updateBoard],
);
return {
addDynamicSection,
updateDynamicSection,
removeDynamicSection,
};
};
Loaded 100 of 259 files, more files were not shown because too many files have changed in this diff. Show more