Compare commits

..
9 Commits
495 changed files with 47837 additions and 53444 deletions

No files matched your search

@@ -1,44 +0,0 @@
---
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
View File
@@ -1 +0,0 @@
../.agents/skills
@@ -1,6 +1,6 @@
--- ---
name: codebase-context description: Comprehensive Homarr codebase architecture and conventions reference
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. alwaysApply: true
--- ---
# Homarr Codebase Context # Homarr Codebase Context
@@ -27,7 +27,7 @@ Homarr is an open-source, self-hosted dashboard for managing homelab services. I
## Repository Structure ## Repository Structure
```text ```
homarr/ homarr/
├── apps/ ├── apps/
│ ├── nextjs/ # Main Next.js application (port 3000) │ ├── nextjs/ # Main Next.js application (port 3000)
@@ -66,6 +66,8 @@ homarr/
│ ├── analytics/ # Server-side analytics (Umami) │ ├── analytics/ # Server-side analytics (Umami)
│ ├── server-settings/ # Server setting keys/types │ ├── server-settings/ # Server setting keys/types
│ ├── settings/ # User-facing settings UI context │ ├── settings/ # User-facing settings UI context
│ ├── old-import/ # Legacy Homarr import
│ ├── old-schema/ # Legacy Homarr zod schemas
│ └── cli/ # Node CLI for ops (brocli) │ └── cli/ # Node CLI for ops (brocli)
├── tooling/ ├── tooling/
│ ├── typescript/ # Base tsconfig │ ├── typescript/ # Base tsconfig
@@ -77,11 +79,11 @@ homarr/
## Three Runtime Services ## Three Runtime Services
| Service | Port | Role | | Service | Port | Role |
| --------- | ---- | -------------------------------------------- | |---------|------|------|
| Next.js | 3000 | Main web app (SSR + API routes) | | Next.js | 3000 | Main web app (SSR + API routes) |
| WebSocket | 3001 | tRPC subscriptions via `ws` | | WebSocket | 3001 | tRPC subscriptions via `ws` |
| Tasks | 3002 | Cron job runner + management API via Fastify | | 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. In Docker, nginx listens on **7575** and proxies to all three. The tasks service authenticates via `CRON_JOB_API_KEY` header.
@@ -110,7 +112,6 @@ In Docker, nginx listens on **7575** and proxies to all three. The tasks service
### Multi-Database Support ### Multi-Database Support
Drizzle schemas exist in three parallel implementations: Drizzle schemas exist in three parallel implementations:
- `packages/db/schema/sqlite.ts` - `packages/db/schema/sqlite.ts`
- `packages/db/schema/mysql.ts` - `packages/db/schema/mysql.ts`
- `packages/db/schema/postgresql.ts` - `packages/db/schema/postgresql.ts`
@@ -120,7 +121,6 @@ Selected at runtime via `DB_DRIVER` env (`better-sqlite3` | `mysql2` | `node-pos
### Widget System ### Widget System
Widgets are registered in `packages/widgets/src/index.tsx` as `widgetImports` (must satisfy `Record<WidgetKind, ...>`). Each widget: 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? })` - Has a `definition` created via `createWidgetDefinition(kind, { icon, createOptions, supportedIntegrations? })`
- Uses `optionsBuilder.from(factory => ({ ... }))` for typed options - Uses `optionsBuilder.from(factory => ({ ... }))` for typed options
- Loads dynamically via `next/dynamic` - Loads dynamically via `next/dynamic`
@@ -133,7 +133,6 @@ Integrations are defined in `packages/definitions/src/integration.ts` (`integrat
### Cron Job System ### Cron Job System
Jobs defined in `packages/cron-jobs/src/jobs/`. Each job: Jobs defined in `packages/cron-jobs/src/jobs/`. Each job:
- Uses `createCronJob` with a cron expression - Uses `createCronJob` with a cron expression
- Publishes results to Redis channels - Publishes results to Redis channels
- Widget subscriptions consume those channels via tRPC subscriptions - Widget subscriptions consume those channels via tRPC subscriptions
@@ -161,7 +160,6 @@ Configured via `AUTH_PROVIDERS` env. Supports: `credentials` (local), `ldap`, `o
## Environment Variables ## Environment Variables
Key env vars (from `.env.example`): Key env vars (from `.env.example`):
- `DB_DRIVER`: `better-sqlite3` | `mysql2` | `node-postgres` - `DB_DRIVER`: `better-sqlite3` | `mysql2` | `node-postgres`
- `DB_URL`: Database connection string - `DB_URL`: Database connection string
- `AUTH_SECRET`: NextAuth secret - `AUTH_SECRET`: NextAuth secret
@@ -193,3 +191,16 @@ Key env vars (from `.env.example`):
- Drag-and-drop via `@dnd-kit/*` - Drag-and-drop via `@dnd-kit/*`
- `typescript.ignoreBuildErrors: true` in next.config (types checked separately via `typecheck`) - `typescript.ignoreBuildErrors: true` in next.config (types checked separately via `typecheck`)
- `serverExternalPackages`: `dockerode`, `isomorphic-dompurify`, `jsdom` - `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
+50
View File
@@ -0,0 +1,50 @@
---
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
@@ -1,8 +1,3 @@
---
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 # 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. This document captures every pattern, fix, and decision from the downloads widget rework (PR #6430). Use it as a playbook when porting other tables.
@@ -12,12 +7,10 @@ This document captures every pattern, fix, and decision from the downloads widge
Current `mantine-react-table` consumers that could be migrated: Current `mantine-react-table` consumers that could be migrated:
**Widgets (in `packages/widgets/`):** **Widgets (in `packages/widgets/`):**
- `docker/component.tsx` — Docker container table - `docker/component.tsx` — Docker container table
- `media-server/component.tsx` — Media server sessions - `media-server/component.tsx` — Media server sessions
**Admin pages (in `apps/nextjs/src/app/[locale]/manage/`):** **Admin pages (in `apps/nextjs/src/app/[locale]/manage/`):**
- `tools/docker/docker-table.tsx` - `tools/docker/docker-table.tsx`
- `tools/tasks/_components/tasks-table.tsx` - `tools/tasks/_components/tasks-table.tsx`
- `tools/api/components/api-keys.tsx` - `tools/api/components/api-keys.tsx`
@@ -26,13 +19,13 @@ Current `mantine-react-table` consumers that could be migrated:
- `tools/kubernetes/` — 8 Kubernetes resource tables - `tools/kubernetes/` — 8 Kubernetes resource tables
**Modals:** **Modals:**
- `packages/modals-collection/src/search-engines/request-media-modal.tsx` - `packages/modals-collection/src/search-engines/request-media-modal.tsx`
**Shared hook to remove once all migrated:** **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 ## 1. Core Migration: mantine-react-table → mantine-datatable
### Import changes ### Import changes
@@ -74,7 +67,6 @@ const columns: DataTableColumn<MyRow>[] = [
``` ```
Key differences: Key differences:
- `accessorKey` → `accessor` - `accessorKey` → `accessor`
- `header` → `title` - `header` → `title`
- `size` → `width` - `size` → `width`
@@ -126,6 +118,8 @@ const sortedData = useMemo(() => {
<DataTable records={sortedData} sortStatus={sortStatus} onSortStatusChange={setSortStatus} /> <DataTable records={sortedData} sortStatus={sortStatus} onSortStatusChange={setSortStatus} />
``` ```
---
## 2. Making Tables Transparent (Widget Context) ## 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. 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.
@@ -150,21 +144,13 @@ Create a CSS file alongside the component:
/* Hover: translucent instead of opaque */ /* Hover: translucent instead of opaque */
.my-table .mantine-datatable-table tbody tr:hover { .my-table .mantine-datatable-table tbody tr:hover {
background-color: color-mix( background-color: color-mix(in srgb, var(--mantine-color-default-hover) 40%, transparent) !important;
in srgb,
var(--mantine-color-default-hover) 40%,
transparent
) !important;
} }
/* Headers: frosted glass effect */ /* Headers: frosted glass effect */
.my-table th { .my-table th {
white-space: nowrap; white-space: nowrap;
background-color: color-mix( background-color: color-mix(in srgb, var(--mantine-color-body) 60%, transparent) !important;
in srgb,
var(--mantine-color-body) 60%,
transparent
) !important;
backdrop-filter: blur(8px); backdrop-filter: blur(8px);
} }
@@ -186,16 +172,14 @@ Create a CSS file alongside the component:
/* Row expansion area */ /* Row expansion area */
.my-table .mantine-datatable-row-expansion-cell { .my-table .mantine-datatable-row-expansion-cell {
background-color: color-mix( background-color: color-mix(in srgb, var(--mantine-color-body) 40%, transparent) !important;
in srgb,
var(--mantine-color-body) 40%,
transparent
) !important;
} }
``` ```
Import it: `import "./styles.css";` and apply via `className="my-table"`. Import it: `import "./styles.css";` and apply via `className="my-table"`.
---
## 3. Responsive Column Visibility ## 3. Responsive Column Visibility
Use a lookup table of width breakpoints instead of media queries. The widget receives `width` as a prop. Use a lookup table of width breakpoints instead of media queries. The widget receives `width` as a prop.
@@ -211,38 +195,11 @@ interface SizeConfig {
} }
const SIZE_BREAKPOINTS: { maxWidth: number; config: SizeConfig }[] = [ const SIZE_BREAKPOINTS: { maxWidth: number; config: SizeConfig }[] = [
{ { maxWidth: 300, config: { fontSize: "xs", iconSize: 12, cellPadding: 2, showSpeedColumns: false, showTimeColumn: false, showStateColumn: false } },
maxWidth: 300, { maxWidth: 500, config: { fontSize: "xs", iconSize: 14, cellPadding: 4, showSpeedColumns: false, showTimeColumn: true, showStateColumn: false } },
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 = { const DEFAULT_SIZE_CONFIG: SizeConfig = { fontSize: "sm", iconSize: 14, cellPadding: 4, showSpeedColumns: true, showTimeColumn: true, showStateColumn: true };
fontSize: "sm",
iconSize: 14,
cellPadding: 4,
showSpeedColumns: true,
showTimeColumn: true,
showStateColumn: true,
};
function getSizeConfig(width: number): SizeConfig { function getSizeConfig(width: number): SizeConfig {
for (const { maxWidth, config } of SIZE_BREAKPOINTS) { for (const { maxWidth, config } of SIZE_BREAKPOINTS) {
@@ -258,9 +215,7 @@ const size = useMemo(() => getSizeConfig(width), [width]);
Then use a visibility check lookup table for columns: Then use a visibility check lookup table for columns:
```typescript ```typescript
const columnVisibilityChecks: Partial< const columnVisibilityChecks: Partial<Record<string, (ctx: ColumnContext) => boolean>> = {
Record<string, (ctx: ColumnContext) => boolean>
> = {
upSpeed: (ctx) => ctx.hasTorrents && ctx.size.showSpeedColumns, upSpeed: (ctx) => ctx.hasTorrents && ctx.size.showSpeedColumns,
downSpeed: (ctx) => ctx.size.showSpeedColumns, downSpeed: (ctx) => ctx.size.showSpeedColumns,
time: (ctx) => ctx.size.showTimeColumn, time: (ctx) => ctx.size.showTimeColumn,
@@ -275,6 +230,8 @@ function shouldIncludeColumn(accessor: string, ctx: ColumnContext): boolean {
} }
``` ```
---
## 4. Column Persistence (Drag/Resize → Server) ## 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. `useDataTableColumns` stores column order and widths in localStorage by default. For widgets, this must persist server-side so it works across devices.
@@ -306,6 +263,15 @@ Even hidden options need translation keys or the translation test fails:
"widget.mywidget.option.columnWidths.label": "Column widths" "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 ### Dynamic storeKey
Use a key that includes `itemId` and sorted column names. This prevents cross-widget localStorage bleeding: Use a key that includes `itemId` and sorted column names. This prevents cross-widget localStorage bleeding:
@@ -319,13 +285,8 @@ const storeKey = `mytable-${itemId ?? "preview"}-${[...options.columns].toSorted
On mount, seed `useDataTableColumns` with server-persisted values. Use a `hydrated` ref to prevent the persist effect from immediately writing defaults back: On mount, seed `useDataTableColumns` with server-persisted values. Use a `hydrated` ref to prevent the persist effect from immediately writing defaults back:
```typescript ```typescript
const { const { effectiveColumns, columnsOrder, columnsWidth, setColumnsOrder, setMultipleColumnWidths } =
effectiveColumns, useDataTableColumns<MyRow>({ key: storeKey, columns });
columnsOrder,
columnsWidth,
setColumnsOrder,
setMultipleColumnWidths,
} = useDataTableColumns<MyRow>({ key: storeKey, columns });
const lastStoreKey = useRef(storeKey); const lastStoreKey = useRef(storeKey);
const hydrated = useRef(false); const hydrated = useRef(false);
@@ -339,22 +300,11 @@ useEffect(() => {
if (savedOrder.length > 0) setColumnsOrder(savedOrder); if (savedOrder.length > 0) setColumnsOrder(savedOrder);
if (Object.keys(savedWidths).length > 0) { if (Object.keys(savedWidths).length > 0) {
setMultipleColumnWidths( setMultipleColumnWidths(
Object.entries(savedWidths).map(([accessor, w]) => ({ Object.entries(savedWidths).map(([accessor, w]) => ({ accessor, width: w })),
accessor,
width: w,
})),
); );
} }
requestAnimationFrame(() => { requestAnimationFrame(() => { hydrated.current = true; });
hydrated.current = true; }, [storeKey, savedOrder, savedWidths, setColumnsOrder, setMultipleColumnWidths]);
});
}, [
storeKey,
savedOrder,
savedWidths,
setColumnsOrder,
setMultipleColumnWidths,
]);
``` ```
### Persist localStorage → server ### Persist localStorage → server
@@ -367,26 +317,22 @@ const prevWidths = useRef(columnsWidth);
useEffect(() => { useEffect(() => {
if (!hydrated.current) return; if (!hydrated.current) return;
const orderChanged = const orderChanged = JSON.stringify(columnsOrder) !== JSON.stringify(prevOrder.current);
JSON.stringify(columnsOrder) !== JSON.stringify(prevOrder.current); const widthsChanged = JSON.stringify(columnsWidth) !== JSON.stringify(prevWidths.current);
const widthsChanged =
JSON.stringify(columnsWidth) !== JSON.stringify(prevWidths.current);
prevOrder.current = columnsOrder; prevOrder.current = columnsOrder;
prevWidths.current = columnsWidth; prevWidths.current = columnsWidth;
if (!orderChanged && !widthsChanged) return; if (!orderChanged && !widthsChanged) return;
if (orderChanged) if (orderChanged) persistOption({ columnOrder: JSON.stringify(columnsOrder) });
persistOption({ columnOrder: JSON.stringify(columnsOrder) });
if (widthsChanged) { if (widthsChanged) {
const widthMap: Record<string, number> = {}; const widthMap: Record<string, number> = {};
for (const entry of columnsWidth) { for (const entry of columnsWidth) {
const key = Object.keys(entry)[0]; const key = Object.keys(entry)[0];
if (!key) continue; if (!key) continue;
const v = entry[key as keyof typeof entry]; const w = entry[key as keyof typeof entry];
if (typeof v === "number") widthMap[key] = v; if (typeof w === "number") widthMap[key] = w;
else if (typeof v === "string" && v.endsWith("px")) else if (typeof w === "string" && w.endsWith("px")) widthMap[key] = parseInt(w, 10);
widthMap[key] = parseInt(v, 10);
} }
persistOption({ columnWidths: JSON.stringify(widthMap) }); persistOption({ columnWidths: JSON.stringify(widthMap) });
} }
@@ -395,6 +341,8 @@ useEffect(() => {
The `persistOption` helper calls both `setOptions` (local state) and `saveItemOptions` (server mutation). The `persistOption` helper calls both `setOptions` (local state) and `saveItemOptions` (server mutation).
---
## 5. Context Menu (Right-Click) ## 5. Context Menu (Right-Click)
Do NOT install `mantine-contextmenu`. Use Mantine's `Menu` + `Portal` directly: Do NOT install `mantine-contextmenu`. Use Mantine's `Menu` + `Portal` directly:
@@ -426,41 +374,15 @@ const handleContextMenu = useCallback(
The menu component uses `Portal` for correct z-index: The menu component uses `Portal` for correct z-index:
```tsx ```tsx
function RowContextMenu({ function RowContextMenu({ state, onClose }: { state: ContextMenuState; onClose: () => void }) {
state,
onClose,
}: {
state: ContextMenuState;
onClose: () => void;
}) {
return ( return (
<Portal> <Portal>
<Menu <Menu opened onClose={onClose} closeOnItemClick={false} position="right-start" offset={0}>
opened
onClose={onClose}
closeOnItemClick={false}
position="right-start"
offset={0}
>
<Menu.Target> <Menu.Target>
<Box <Box style={{ position: "fixed", left: state.x, top: state.y, width: 0, height: 0 }} />
style={{
position: "fixed",
left: state.x,
top: state.y,
width: 0,
height: 0,
}}
/>
</Menu.Target> </Menu.Target>
<Menu.Dropdown> <Menu.Dropdown>
<Menu.Item <Menu.Item onClick={() => { /* action */ onClose(); }}>Action</Menu.Item>
onClick={() => {
/* action */ onClose();
}}
>
Action
</Menu.Item>
</Menu.Dropdown> </Menu.Dropdown>
</Menu> </Menu>
</Portal> </Portal>
@@ -470,6 +392,8 @@ function RowContextMenu({
**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(); }}`. **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 ## 6. Row Expansion
```typescript ```typescript
@@ -489,6 +413,8 @@ function RowContextMenu({
**Critical:** Do NOT set `onRowClick` when using `rowExpansion` with `trigger: "click"`. They conflict and the expansion won't work. **Critical:** Do NOT set `onRowClick` when using `rowExpansion` with `trigger: "click"`. They conflict and the expansion won't work.
---
## 7. Common Pitfalls & Fixes ## 7. Common Pitfalls & Fixes
### Status indicator dots ### Status indicator dots
@@ -549,11 +475,8 @@ Always wrap in try/catch:
```typescript ```typescript
function parseJsonOption<T>(value: string | undefined, fallback: T): T { function parseJsonOption<T>(value: string | undefined, fallback: T): T {
if (!value) return fallback; if (!value) return fallback;
try { try { return JSON.parse(value) as T; }
return JSON.parse(value) as T; catch { return fallback; }
} catch {
return fallback;
}
} }
``` ```
@@ -571,19 +494,20 @@ If the widget has configurable default sort, sync it:
```typescript ```typescript
useEffect(() => { useEffect(() => {
setSortStatus({ setSortStatus({ columnAccessor: options.defaultSort, direction: defaultSortDirection });
columnAccessor: options.defaultSort,
direction: defaultSortDirection,
});
}, [options.defaultSort, defaultSortDirection]); }, [options.defaultSort, defaultSortDirection]);
``` ```
---
## 8. Accessibility ## 8. Accessibility
- All `ActionIcon` components need `aria-label` - All `ActionIcon` components need `aria-label`
- Filter toggles (client badges, status badges) need `aria-pressed={isActive}` - Filter toggles (client badges, status badges) need `aria-pressed={isActive}`
- `textSelectionDisabled` on DataTable prevents accidental text selection during drag/resize - `textSelectionDisabled` on DataTable prevents accidental text selection during drag/resize
---
## 9. Error Handling ## 9. Error Handling
- Every mutation needs an `onError` callback with `showErrorNotification` - Every mutation needs an `onError` callback with `showErrorNotification`
@@ -594,14 +518,12 @@ useEffect(() => {
```typescript ```typescript
const mutationOptions = { const mutationOptions = {
onSettled: () => void utils.myQuery.invalidate(), onSettled: () => void utils.myQuery.invalidate(),
onError: () => onError: () => showErrorNotification({ title: t("errors.actionFailed"), message: t("errors.actionFailedMessage") }),
showErrorNotification({
title: t("errors.actionFailed"),
message: t("errors.actionFailedMessage"),
}),
}; };
``` ```
---
## 10. i18n ## 10. i18n
- No hardcoded strings. Every user-visible string goes through `useScopedI18n` - No hardcoded strings. Every user-visible string goes through `useScopedI18n`
@@ -612,6 +534,8 @@ const mutationOptions = {
- Errors: `t("errors.{errorKey}")` - Errors: `t("errors.{errorKey}")`
- Hidden options still need `.label` translation keys - Hidden options still need `.label` translation keys
---
## 11. Edit Mode ## 11. Edit Mode
Disable pointer events on the table during edit mode to prevent conflicts with the board's drag-and-drop: Disable pointer events on the table during edit mode to prevent conflicts with the board's drag-and-drop:
@@ -626,6 +550,8 @@ if (isEditMode) rowContextMenuHandler = undefined;
<DataTable style={{ pointerEvents: tablePointerEvents }} /> <DataTable style={{ pointerEvents: tablePointerEvents }} />
``` ```
---
## 12. DataTable Props Reference (Commonly Used) ## 12. DataTable Props Reference (Commonly Used)
```typescript ```typescript
@@ -664,6 +590,8 @@ if (isEditMode) rowContextMenuHandler = undefined;
/> />
``` ```
---
## 13. Layout Stability ## 13. Layout Stability
- Don't use `table-layout: fixed` — it fights mantine-datatable's resize logic - Don't use `table-layout: fixed` — it fights mantine-datatable's resize logic
@@ -672,6 +600,8 @@ if (isEditMode) rowContextMenuHandler = undefined;
- Names should truncate at the cell boundary, not a character count, so the layout is stable regardless of content length - 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 - `flexShrink: 0` on badges/icons prevents them from being squeezed by long names
---
## 14. Checklist for Each Migration ## 14. Checklist for Each Migration
1. Replace imports (`mantine-react-table` → `mantine-datatable`) 1. Replace imports (`mantine-react-table` → `mantine-datatable`)
@@ -680,12 +610,13 @@ if (isEditMode) rowContextMenuHandler = undefined;
4. Add CSS file for transparent backgrounds (if widget context) 4. Add CSS file for transparent backgrounds (if widget context)
5. Add responsive column visibility (if widget context) 5. Add responsive column visibility (if widget context)
6. Add hidden options + hydration logic (if column persistence needed) 6. Add hidden options + hydration logic (if column persistence needed)
7. Add translation keys (including hidden option labels) 7. Add old-import mappings for new options
8. Add context menu if row actions exist 8. Add translation keys (including hidden option labels)
9. Add row expansion if detail view exists 9. Add context menu if row actions exist
10. Add error handling on all mutations 10. Add row expansion if detail view exists
11. Add accessibility attributes 11. Add error handling on all mutations
12. Test at multiple widget widths 12. Add accessibility attributes
13. Update docs in `apps/docs/docs/widgets/` if applicable 13. Test at multiple widget widths
14. Run `pnpm turbo typecheck` 14. Update docs in `apps/docs/docs/widgets/` if applicable
15. Run translation spec — catches missing keys 15. Run `pnpm turbo typecheck` — catches old-import mismatches
16. Run translation spec — catches missing keys
@@ -1,14 +1,13 @@
--- ---
name: mcp-integration description: How to expose tRPC procedures as MCP tools for AI clients
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. globs: packages/api/src/router/**/*.ts
alwaysApply: false
--- ---
# MCP Integration # MCP Integration Guide
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. 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 ## Adding MCP to a procedure
Add `.meta({ mcp: { enabled: true, description: "..." } })` to the procedure chain: Add `.meta({ mcp: { enabled: true, description: "..." } })` to the procedure chain:
@@ -17,7 +16,7 @@ Add `.meta({ mcp: { enabled: true, description: "..." } })` to the procedure cha
// Read-only tool (query) // Read-only tool (query)
myProcedure: protectedProcedure myProcedure: protectedProcedure
.meta({ mcp: { enabled: true, description: "Get all items with their status and metadata" } }) .meta({ mcp: { enabled: true, description: "Get all items with their status and metadata" } })
.input(z.object({ limit: z.number().default(10) })) .input(z.object({ limit: z.number().default(50) }))
.query(async ({ ctx, input }) => { ... }) .query(async ({ ctx, input }) => { ... })
// Write tool (mutation) // Write tool (mutation)
@@ -39,27 +38,23 @@ 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. 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:** **Must include:**
- What the tool returns or does - What the tool returns or does
- Which integrations/services it works with (if applicable) - Which integrations/services it works with (if applicable)
- How to get required IDs (e.g., "Use integration_all to get the integrationId") - How to get required IDs (e.g., "Use integration_all to get the integrationId")
- Permission requirements (if non-obvious) - Permission requirements (if non-obvious)
**Good:** **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" "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:** **Bad:**
```
```text
"Get calendar events" "Get calendar events"
``` ```
**For tools that return permission fields**, explain what they mean: **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" "List all integrations. Returns permissions.hasUseAccess (read) and permissions.hasInteractAccess (actions) — false means the API key lacks that permission, not an error"
``` ```
@@ -82,13 +77,11 @@ export const mcpRouter = createTRPCRouter({
## What to expose vs. what to skip ## What to expose vs. what to skip
**Expose:** **Expose:**
- Data queries (list, search, get by ID) - Data queries (list, search, get by ID)
- Actions users would ask an AI to do (toggle, create, delete, approve) - Actions users would ask an AI to do (toggle, create, delete, approve)
- Status checks (health, stats, summaries) - Status checks (health, stats, summaries)
**Skip:** **Skip:**
- Subscriptions (WebSocket-only, not supported by MCP) - Subscriptions (WebSocket-only, not supported by MCP)
- Internal procedures (session management, onboarding steps) - Internal procedures (session management, onboarding steps)
- File upload/download procedures - File upload/download procedures
+1 -2
View File
@@ -17,8 +17,7 @@ coverage
dev dev
.build .build
e2e e2e
apps/docs/* apps/docs
!apps/docs/package.json
apps/nextjs/.next apps/nextjs/.next
.env .env
.env.* .env.*
-7
View File
@@ -33,13 +33,6 @@ body:
options: options:
# The below comment is used to insert a new version with on-release.yml # The below comment is used to insert a new version with on-release.yml
#NEXT_VERSION# #NEXT_VERSION#
- 1.77.0
- 1.76.2
- 1.76.1
- 1.76.0
- 1.75.0
- 1.74.0
- 1.73.0
- 1.72.0 - 1.72.0
- 1.71.0 - 1.71.0
- 1.70.0 - 1.70.0
+35
View File
@@ -0,0 +1,35 @@
name: "[Dependency Updates] Auto Approve"
on:
pull_request:
types: [opened, synchronize]
permissions: {}
jobs:
approve-automatic-prs:
runs-on: ubuntu-latest
timeout-minutes: 2
# 158783068: homarr-renovate[bot]
# 190541745: homarr-crowdin[bot]
# 210161987: homarr-update-contributors[bot]
# 175486441: homarr-releases[bot]
# Extracted from https://api.github.com/users/<username>
if: github.actor_id == 158783068 || github.actor_id == 190541745 || github.actor_id == 210161987 || github.actor_id == 175486441
steps:
- name: Checkout code
uses: actions/checkout@v7
- name: Obtain token
id: obtainToken
uses: actions/create-github-app-token@v3
with:
private-key: ${{ secrets.RENOVATE_APPROVE_PRIVATE_KEY }}
client-id: ${{ secrets.RENOVATE_APPROVE_APP_ID }}
permission-pull-requests: write # required to approve pull request
- name: Install GitHub CLI
run: sudo apt-get install -y gh
- name: Approve automatic PRs
env:
GITHUB_TOKEN: ${{ steps.obtainToken.outputs.token }}
run: |
gh pr review ${{github.event.pull_request.number}} --approve --body "Automatically approved by GitHub Action"
@@ -1,43 +0,0 @@
name: "[Bots] Reconcile Auto-merge"
on:
schedule:
- cron: "*/30 * * * *"
workflow_dispatch:
permissions: {}
jobs:
reconcile:
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- name: Checkout code
uses: actions/checkout@v7
- name: Obtain token
id: obtainToken
uses: actions/create-github-app-token@v3
with:
private-key: ${{ secrets.RENOVATE_APPROVE_PRIVATE_KEY }}
client-id: ${{ secrets.RENOVATE_APPROVE_APP_ID }}
permission-pull-requests: write # required to enable auto-merge
- name: Install GitHub CLI
run: sudo apt-get install -y gh
- name: Re-enable auto-merge for prepared bot PRs
env:
GITHUB_TOKEN: ${{ steps.obtainToken.outputs.token }}
run: |
# 158783068: homarr-renovate[bot]
# 190541745: homarr-crowdin[bot]
# 210161987: homarr-update-contributors[bot]
# 175441: homarr-releases[bot]
gh pr list --repo "${{ github.repository }}" --base dev --state open --limit 100 \
--json number,author,reviewDecision,mergeStateStatus,autoMergeRequest \
--jq '.[] | select(.author.login as $login | ["app/homarr-renovate", "app/homarr-crowdin", "app/homarr-update-contributors", "app/homarr-releases"] | index($login)) | select(.reviewDecision == "APPROVED") | select(.mergeStateStatus == "CLEAN") | select(.autoMergeRequest == null) | .number' \
| while read -r pr; do
echo "Re-enabling auto-merge on PR #$pr"
gh pr merge "$pr" --auto --squash
done
-47
View File
@@ -1,47 +0,0 @@
name: "[Bots] Approve and Auto-merge"
on:
pull_request:
types: [opened, synchronize, reopened, ready_for_review]
permissions: {}
jobs:
approve-and-automerge:
runs-on: ubuntu-latest
timeout-minutes: 5
# 158783068: homarr-renovate[bot]
# 190541745: homarr-crowdin[bot]
# 210161987: homarr-update-contributors[bot]
# 175441: homarr-releases[bot]
# Extracted from https://api.github.com/users/<username>
if: |
(github.actor_id == 158783068 || github.actor_id == 190541745 || github.actor_id == 210161987 || github.actor_id == 175441) &&
github.event.pull_request.head.repo.full_name == github.repository &&
github.event.pull_request.base.ref == 'dev'
steps:
- name: Checkout code
uses: actions/checkout@v7
- name: Obtain token
id: obtainToken
uses: actions/create-github-app-token@v3
with:
private-key: ${{ secrets.RENOVATE_APPROVE_PRIVATE_KEY }}
client-id: ${{ secrets.RENOVATE_APPROVE_APP_ID }}
permission-pull-requests: write # required to approve a pull request and enable auto-merge
- name: Install GitHub CLI
run: sudo apt-get install -y gh
- name: Approve and enable auto-merge
env:
GITHUB_TOKEN: ${{ steps.obtainToken.outputs.token }}
run: |
PR_NUMBER="${{ github.event.pull_request.number }}"
if [ "$(gh pr view "$PR_NUMBER" --json reviewDecision --jq .reviewDecision)" != "APPROVED" ]; then
gh pr review "$PR_NUMBER" --approve --body "Automatically approved by Homarr bot policy"
fi
if [ "$(gh pr view "$PR_NUMBER" --json autoMergeRequest --jq '.autoMergeRequest != null')" != "true" ]; then
gh pr merge "$PR_NUMBER" --auto --squash
fi
+1 -1
View File
@@ -82,7 +82,7 @@ jobs:
run: cp .env.example .env run: cp .env.example .env
- name: Lint, typecheck, and build affected workspaces - name: Lint, typecheck, and build affected workspaces
run: pnpm turbo run lint typecheck build --affected --continue=dependencies-successful --concurrency=2 run: pnpm turbo run lint typecheck build --affected --continue=dependencies-successful
- name: Validate workspace configuration - name: Validate workspace configuration
run: pnpm lint:ws run: pnpm lint:ws
@@ -68,6 +68,8 @@ jobs:
with: with:
node-version: 24.18.0 node-version: 24.18.0
package-manager-cache: false package-manager-cache: false
- run: npm i -g pnpm
if: env.SKIP_RELEASE == 'false'
- name: Install dependencies - name: Install dependencies
if: env.SKIP_RELEASE == 'false' if: env.SKIP_RELEASE == 'false'
run: | run: |
+1 -3
View File
@@ -71,12 +71,10 @@ apps/nextjs/public/images/background.png
# personal agent config # personal agent config
.opencode .opencode
.agents/* .agents/
!.agents/skills/
skills-lock.json skills-lock.json
# docusaurus # docusaurus
apps/docs/.docusaurus apps/docs/.docusaurus
apps/docs/build apps/docs/build
.pnpm-store .pnpm-store
benchmark-results
-8
View File
@@ -1,8 +0,0 @@
{
"mcpServers": {
"mantine": {
"command": "npx",
"args": ["-y", "@mantine/mcp-server"]
}
}
}
-19
View File
@@ -46,8 +46,6 @@ homarr/
├── tooling/ ├── tooling/
│ ├── typescript/ # Base tsconfig │ ├── typescript/ # Base tsconfig
│ └── github/ # CI setup action │ └── github/ # CI setup action
├── tools/
│ └── homarr-dev/ # Go CLI for local and PR Docker images
├── development/ # Dev docker-compose (Redis, MySQL, PostgreSQL) ├── development/ # Dev docker-compose (Redis, MySQL, PostgreSQL)
├── e2e/ # E2E test specs ├── e2e/ # E2E test specs
└── Dockerfile # Multi-stage production build └── Dockerfile # Multi-stage production build
@@ -70,9 +68,6 @@ When modifying user-facing code, you MUST also update the corresponding document
## Monorepo Commands ## Monorepo Commands
- `pnpm dev` — Next.js app only - `pnpm dev` — Next.js app only
- `pnpm dev:cli -- dev` — run the developer CLI without installing a global binary
- `pnpm db:seed` — seed default database data explicitly
- `pnpm docker:dev:up` — start the Redis development service in the background
- `pnpm dev:docs` — Docusaurus docs site only - `pnpm dev:docs` — Docusaurus docs site only
- `pnpm turbo build` — build all packages - `pnpm turbo build` — build all packages
- `pnpm turbo build --filter=@homarr/docs` — build docs only - `pnpm turbo build --filter=@homarr/docs` — build docs only
@@ -84,19 +79,5 @@ When modifying user-facing code, you MUST also update the corresponding document
- Lint: oxlint (not ESLint) - Lint: oxlint (not ESLint)
- Format: oxfmt (not Prettier) - Format: oxfmt (not Prettier)
- UI: Mantine (not Tailwind) — Tailwind is only used in docs app - 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 - Icons: @tabler/icons-react
- Docs app can import from `@homarr/definitions` for shared types - Docs app can import from `@homarr/definitions` for shared types
- Run `pnpm dev:cli -- dev` to browse local `homarr:*` images and remote PR images.
- Run `pnpm dev:cli -- build <name>` from a Homarr checkout to build `homarr:<name>` with rebuild provenance.
- Run `pnpm dev:cli -- build --pr <number>` to build a PR locally from a temporary checkout.
- Run Go checks from `tools/homarr-dev` with `go test ./...` and `go vet ./...`.
-14
View File
@@ -1,17 +1,3 @@
## [1.77.1](https://github.com/homarr-labs/homarr/compare/v1.77.0...v1.77.1) (2026-09-11)
## [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) ## [1.73.0](https://github.com/homarr-labs/homarr/compare/v1.72.0...v1.73.0) (2026-07-31)
## [1.72.0](https://github.com/homarr-labs/homarr/compare/v1.71.0...v1.72.0) (2026-07-24) ## [1.72.0](https://github.com/homarr-labs/homarr/compare/v1.71.0...v1.72.0) (2026-07-24)
-8
View File
@@ -206,14 +206,6 @@ packages/integrations/src/unraid @manuel-rw
apps/docs/docs/integrations/uptime-kuma @ajnart apps/docs/docs/integrations/uptime-kuma @ajnart
packages/integrations/src/uptime-kuma @ajnart packages/integrations/src/uptime-kuma @ajnart
# What's Up Docker (WUD) - introduced in https://github.com/homarr-labs/homarr/pull/6523 by @tiimonn
apps/docs/docs/integrations/whats-up-docker @tiimonn
apps/docs/docs/widgets/whats-up-docker @tiimonn
packages/api/src/router/widgets/wud.ts @tiimonn
packages/integrations/src/wud @tiimonn
packages/request-handler/src/wud.ts @tiimonn
packages/widgets/src/wud @tiimonn
# Traefik - by @Nerdy-Technician # Traefik - by @Nerdy-Technician
apps/docs/docs/integrations/traefik @Nerdy-Technician apps/docs/docs/integrations/traefik @Nerdy-Technician
apps/docs/docs/widgets/traefik @Nerdy-Technician apps/docs/docs/widgets/traefik @Nerdy-Technician
+8 -19
View File
@@ -3,18 +3,12 @@
FROM node:24.18.0-alpine AS base FROM node:24.18.0-alpine AS base
FROM base AS builder FROM base AS builder
ARG TARGETPLATFORM
WORKDIR /app WORKDIR /app
# Native dependencies normally use prebuilds, but the build must remain RUN apk add --no-cache libc6-compat curl bash && apk update
# reproducible when a prebuild download times out and compilation is required.
RUN apk add --no-cache libc6-compat curl bash python3 make g++
RUN corepack enable pnpm RUN corepack enable pnpm
COPY .npmrc pnpm-lock.yaml pnpm-workspace.yaml package.json ./ COPY pnpm-lock.yaml pnpm-workspace.yaml package.json ./
COPY patches ./patches COPY patches ./patches
COPY --parents ./apps/*/package.json ./packages/*/package.json ./tooling/*/package.json ./
# @homarr/definitions generates documentation types during install.
COPY --parents ./packages/definitions/src ./
# Workaround for pnpm/pnpm#5268: pnpm fetch crashes when patchedDependencies # Workaround for pnpm/pnpm#5268: pnpm fetch crashes when patchedDependencies
# are configured with nodeLinker: hoisted. The applyPatchToDir function tries # are configured with nodeLinker: hoisted. The applyPatchToDir function tries
# to chdir into node_modules/<pkg> which doesn't exist during fetch (only the # to chdir into node_modules/<pkg> which doesn't exist during fetch (only the
@@ -25,16 +19,14 @@ COPY --parents ./packages/definitions/src ./
RUN --mount=type=cache,id=pnpm-store,target=/pnpm/store \ RUN --mount=type=cache,id=pnpm-store,target=/pnpm/store \
pnpm config set store-dir /pnpm/store && \ pnpm config set store-dir /pnpm/store && \
sed -i 's/nodeLinker: hoisted/nodeLinker: isolated/' pnpm-workspace.yaml && \ sed -i 's/nodeLinker: hoisted/nodeLinker: isolated/' pnpm-workspace.yaml && \
pnpm fetch --ignore-scripts && \ pnpm fetch && \
sed -i 's/nodeLinker: isolated/nodeLinker: hoisted/' pnpm-workspace.yaml sed -i 's/nodeLinker: isolated/nodeLinker: hoisted/' pnpm-workspace.yaml
# Install only from the fetched, committed lockfile so local and Docker builds
# resolve the same dependency graph. Serial lifecycle builds avoid esbuild's
# atomic binary replacement racing across the hoisted workspace (pnpm/pnpm#8200).
RUN --mount=type=cache,id=pnpm-store,target=/pnpm/store \
npm_config_nodedir=/usr/local pnpm install --recursive --offline --frozen-lockfile --child-concurrency=1
COPY . . COPY . .
# Follow the pnpm fetch pattern from https://pnpm.io/cli/fetch
# --frozen-lockfile is omitted as recommended by the pnpm fetch docs
RUN --mount=type=cache,id=pnpm-store,target=/pnpm/store \
pnpm install --recursive
ARG SKIP_ENV_VALIDATION='true' ARG SKIP_ENV_VALIDATION='true'
ARG CI='true' ARG CI='true'
@@ -45,9 +37,7 @@ RUN --mount=type=secret,id=TURBO_API,env=TURBO_API \
--mount=type=secret,id=TURBO_TEAM,env=TURBO_TEAM \ --mount=type=secret,id=TURBO_TEAM,env=TURBO_TEAM \
--mount=type=secret,id=TURBO_TOKEN,env=TURBO_TOKEN \ --mount=type=secret,id=TURBO_TOKEN,env=TURBO_TOKEN \
--mount=type=secret,id=TURBO_REMOTE_CACHE_SIGNATURE_KEY,env=TURBO_REMOTE_CACHE_SIGNATURE_KEY \ --mount=type=secret,id=TURBO_REMOTE_CACHE_SIGNATURE_KEY,env=TURBO_REMOTE_CACHE_SIGNATURE_KEY \
--mount=type=cache,id=homarr-next-build-${TARGETPLATFORM},target=/app/apps/nextjs/.next/cache,sharing=locked \ TURBO_PLATFORM="${TARGETPLATFORM:-linux/amd64}" pnpm build
TURBO_PLATFORM="${TARGETPLATFORM:-linux/amd64}" \
pnpm turbo build --filter=@homarr/nextjs... --filter=@homarr/cli
FROM base AS runner FROM base AS runner
WORKDIR /app WORKDIR /app
@@ -95,6 +85,5 @@ ENV AUTH_PROVIDERS='credentials'
ENV REDIS_IS_EXTERNAL='false' ENV REDIS_IS_EXTERNAL='false'
ENV NODE_ENV='production' ENV NODE_ENV='production'
EXPOSE 7575
ENTRYPOINT [ "/app/entrypoint.sh" ] ENTRYPOINT [ "/app/entrypoint.sh" ]
CMD ["sh", "run.sh"] CMD ["sh", "run.sh"]
+29
View File
@@ -0,0 +1,29 @@
# Product
## Users
Homarr serves people and teams who run self-hosted applications and want one dependable dashboard for launching apps, monitoring services, and viewing live operational data. They use it across wall displays, desktops, tablets, and smaller devices, often returning to the same board many times per day.
## Product Purpose
Homarr makes self-hosted services easy to find, understand, and operate without hand-editing configuration files. A successful board loads immediately, stays visually stable, keeps widgets legible across screen sizes, and makes editing feel direct and forgiving.
## Brand Personality
Fast, capable, and approachable. Homarr should feel like a mature productivity tool: dense when useful, calm in view mode, and explicit about state during editing.
## Anti-references
Avoid cramped tile spacing, undersized text, layout shifts, decorative motion, hidden drag targets, modal-heavy positioning controls, and dashboards that require users to understand grid internals. Editing must not make the read-only experience heavier or slower.
## Design Principles
- Preserve the board: viewing is the primary workflow and must render before editor code is needed.
- Make spatial actions direct: moving, resizing, nesting, and using fixed sidebars should happen on the canvas with immediate feedback.
- Keep content predictable: fixed logical tile sizes preserve widget composition, typography, and controls at every viewport.
- Recover gracefully: collision handling, reflow, loading states, and persistence failures must never lose or obscure content.
- Use familiar controls: Mantine patterns, clear labels, keyboard support, and visible focus states take priority over novel decoration.
## Accessibility & Inclusion
Target WCAG 2.2 AA. Maintain keyboard equivalents for drag and resize operations, useful landmarks and announcements, sufficient contrast, readable control sizes, reduced-motion support, and horizontal scrolling before scaling content below an accessible minimum.
+2 -2
View File
@@ -55,8 +55,8 @@ pnpm typecheck
docs/ docs/
├── getting-started/ # Installation guides, glossary, prerequisites ├── getting-started/ # Installation guides, glossary, prerequisites
├── management/ # Boards, apps, users, integrations, settings, tasks, API ├── management/ # Boards, apps, users, integrations, settings, tasks, API
├── integrations/ # 56 integration guides (one folder each) ├── integrations/ # 55 integration guides (one folder each)
├── widgets/ # 43 widget guides (one folder each) ├── widgets/ # 42 widget guides (one folder each)
├── advanced/ # SSO, env vars, proxy, styling, CLI, development ├── advanced/ # SSO, env vars, proxy, styling, CLI, development
└── community/ # FAQ, donate, license, translations, get-in-touch └── community/ # FAQ, donate, license, translations, get-in-touch
``` ```
@@ -1,41 +0,0 @@
# Developer Docker CLI
The host-side developer CLI builds and runs local Homarr images and pull-request previews. Run it directly from a checkout with `pnpm dev:cli -- ...`; no shell-specific setup script is required. It is separate from the recovery CLI included in production images.
To install an optional `homarr` binary, run `pnpm dev:cli:install` and make your Go bin directory available on `PATH`. GitHub CLI authentication is only needed for pull-request features.
## Browse development images
```bash
pnpm dev:cli --
```
The main screen combines local `homarr:*` images with open pull requests and their GHCR preview status. Select an image to start or stop it, use `R` to build a remote pull request locally in the background or rebuild a local image, or use `p` to force a remote preview pull. Press `d` to switch to the instance dashboard and `d` again to return without losing the browser state. The explicit `dev` and `dash` subcommands start on either screen.
A locally built PR image is folded into its remote PR row only when its recorded revision exactly matches the current GitHub head SHA. The row is marked `◆ local`, and starting it prefers that local image without a registry pull. Outdated local builds remain separate rows. Navigation and dashboard switching remain available during background builds.
## Build images
```bash
pnpm dev:cli -- build <name>
pnpm dev:cli -- build --pr <number> [name]
```
The first command builds the current checkout as `homarr:<name>`. The pull-request form uses a depth-one clone in a temporary directory and defaults to `homarr:pr-<number>` when `name` is omitted.
## Rebuild images
```bash
pnpm dev:cli -- rebuild <name>
```
Images built by this CLI record their source checkout, revision, and optional pull-request number. Rebuild uses that provenance to select the original checkout or create a fresh temporary pull-request checkout.
## Run images
```bash
homarr run <name>
homarr run --pr <number>
```
Add `--detach` to run in the background, `--demo` to enable demo data, or repeat `--env KEY=VALUE` to provide additional environment variables.
@@ -14,12 +14,11 @@ This guide will help you set up your development environment to start contributi
### Prerequisites ### Prerequisites
- **Node.js 24.18 or newer**: You can download it from the [official website](https://nodejs.org/). - **Node.js (lts)**: You can download it from the [official website](https://nodejs.org/).
- **Corepack**: Run `corepack enable` so the repository selects its pinned pnpm version. - **pnpm (latest)**: You can install it by running `npm install -g pnpm`.
- **Git**: You can download it from the [official website](https://git-scm.com/). - **Git**: You can download it from the [official website](https://git-scm.com/).
- **Docker or Docker Desktop**: Required for the Redis service and the developer Docker CLI. - **Docker or Docker Desktop**: You can download it from the [official website](https://www.docker.com/).
- **Go 1.25+**: Required only for the developer Docker CLI. You can download it from the [official website](https://go.dev/). - **A GitHub account**: You can create one on the [official website](https://github.com/).
- **GitHub CLI**: Required only for pull-request image features. Install it from the [official website](https://cli.github.com/), then authenticate with `gh auth login`.
### Setting up the project ### Setting up the project
@@ -35,11 +34,11 @@ This guide will help you set up your development environment to start contributi
4. Install the dependencies with `pnpm install`. 4. Install the dependencies with `pnpm install`.
5. Create `.env` from `.env.example`. In a POSIX shell, run `cp .env.example .env`; in PowerShell, run `Copy-Item .env.example .env`. Set `DB_URL` to an absolute path for your SQLite file. 5. Start redis by running `pnpm run docker:dev`. This will run a docker-compose file with a redis service.
6. Start Redis in the background with `pnpm docker:dev:up` if your development workflow needs it. 6. Copy the `.env.example` file to `.env` and replace `DB_URL` with the full path you want to use for the sqlite database file. For example `DB_URL='C:\\Dev\\homarr\\packages\\db\\db.sqlite'`
7. Create or update the database with `pnpm db:migration:sqlite:run`. This also seeds the default data. To run the seed explicitly later, use `pnpm db:seed`. 7. Run the migrations to create the sqlite db file with `pnpm run db:migration:sqlite:run`.
8. Start the development server with `pnpm dev` and wait for a message that says `✓ Ready in 10.7s` 8. Start the development server with `pnpm dev` and wait for a message that says `✓ Ready in 10.7s`
@@ -49,19 +48,9 @@ This guide will help you set up your development environment to start contributi
#### During development #### During development
- `pnpm dev`: Start the Next.js development server. - `pnpm dev`: Start the development server.
- `pnpm dev:benchmark`: Run the local authenticated-board benchmark. Set `DEV_BENCHMARK_BOARD_URL` and
`DEV_BENCHMARK_STORAGE_STATE`; readiness-only measurements require the explicit `--smoke` flag and are not
suitable for performance claims.
- `pnpm dev:cli -- dev`: Browse and run local images and remote pull-request images.
- `pnpm dev:cli -- build <name>`: Build the current checkout as `homarr:<name>`.
- `pnpm dev:cli -- build --pr <number>`: Build a pull request locally from a temporary checkout.
- `pnpm dev:cli -- rebuild <name>`: Rebuild a local image from its recorded checkout or pull request.
- `pnpm dev:cli:install`: Optionally install the developer CLI as a `homarr` binary.
- `pnpm cli`: Run cli commands. - `pnpm cli`: Run cli commands.
- `pnpm db:migration:sqlite:run`: Run the migrations to create or update the sqlite db file. - `pnpm db:migration:sqlite:run`: Run the migrations to create or update the sqlite db file.
- `pnpm db:seed`: Explicitly seed the default database data.
- `pnpm docker:dev:up`: Start the Redis development service in the background.
- `pnpm db:migration:sqlite:generate`: Generate a new migration file for the sqlite db. Also works with `mysql`. - `pnpm db:migration:sqlite:generate`: Generate a new migration file for the sqlite db. Also works with `mysql`.
- `pnpm db:studio`: Open a visual studio for the database. - `pnpm db:studio`: Open a visual studio for the database.
- `pnpm package:new`: Create a new package in the monorepo. - `pnpm package:new`: Create a new package in the monorepo.
@@ -74,8 +63,6 @@ This guide will help you set up your development environment to start contributi
- `pnpm typecheck`: Run the typescript type checker. - `pnpm typecheck`: Run the typescript type checker.
#### Building the project #### Building the project
- `pnpm build`: Build the project for production. - `pnpm build`: Build the project for production.
- `pnpm dev:cli -- build <name>`: Build a `homarr:<name>` image and record its checkout for later rebuilds.
- `docker build -t homarr .`: Build a docker image for the project. - `docker build -t homarr .`: Build a docker image for the project.
- `docker run -p 7575:7575 -e SECRET_ENCRYPTION_KEY='your_64_character_hex_string' homarr:<name>`: Run the created docker image. - `docker run -p 7575:7575 -e SECRET_ENCRYPTION_KEY='your_64_character_hex_string' homarr:latest`: Run the created docker image.
@@ -25,14 +25,15 @@ This is especially useful for [docker secrets](https://docs.docker.com/engine/sw
Using the `PUID` and `PGID` will require you to set the correct permissions on the mounted volumes and if used the docker socket. See more in the [Running as a different user](/docs/advanced/running-as-different-user) documentation. Using the `PUID` and `PGID` will require you to set the correct permissions on the mounted volumes and if used the docker socket. See more in the [Running as a different user](/docs/advanced/running-as-different-user) documentation.
::: :::
| Environment Variable | Description | Possible values | Default | | Environment Variable | Description | Possible values | Default |
| ------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------- | ------- | | ------------------------ | ----------- | --------------- | ------- |
| `PUID` | User ID to run the container as | Any valid user ID | `0` | | ``PUID`` | User ID to run the container as | Any valid user ID | ``0`` |
| `PGID` | Group ID to run the container as | Any valid group ID | `0` | | ``PGID`` | Group ID to run the container as | Any valid group ID | ``0`` |
| `LOG_LEVEL` | Log level to use | `debug` / `info` / `warn` / `error` | `info` | | ``LOG_LEVEL`` | Log level to use | ``debug`` / ``info`` / ``warn`` / ``error`` | ``info`` |
| `NO_EXTERNAL_CONNECTION` | Disables Homarr's internet-backed requests, including update checks, weather, and RSS feeds | `true` or `false` | `false` | | ``NO_EXTERNAL_CONNECTION`` | Disables some requests that need internet connection | `true` or `false` | `false` |
| `ENABLE_DNS_CACHING` | Enables dns caching. Enabled by default, but if you experience issues with IPv6 or static IPs, see [#4006](https://github.com/homarr-labs/homarr/issues/4006) or disable this flag | `true` or `false` | `true` | | ``ENABLE_DNS_CACHING`` | Enables dns caching. Enabled by default, but if you experience issues with IPv6 or static IPs, see [#4006](https://github.com/homarr-labs/homarr/issues/4006) or disable this flag | `true` or `false` | `true` |
| `HOMARR_DISABLE_IPV6` | Disables the IPv6 listener of the internal nginx proxy, even when the host supports IPv6. IPv6 is normally disabled automatically when the host has no IPv6 configured. See [#4596](https://github.com/homarr-labs/homarr/issues/4596) | `true` or `false` | `false` |
## Authentication ## Authentication
@@ -42,9 +43,9 @@ See [Single Sign-On](/docs/advanced/single-sign-on) for more informations.
The `SECRET_ENCRYPTION_KEY` is required. If none is specified before starting the container, a random key will be shown in the error message and the container will exit. The `SECRET_ENCRYPTION_KEY` is required. If none is specified before starting the container, a random key will be shown in the error message and the container will exit.
| Environment Variable | Description | Possible values | Default | | Environment Variable | Description | Possible values | Default |
| ----------------------- | ------------------------------------------- | ----------------------- | ------- | | ------------------------ | ----------- | --------------- | ------- |
| `SECRET_ENCRYPTION_KEY` | Secret used to encrypt secrets in database. | 64 character hex string | - | | ``SECRET_ENCRYPTION_KEY`` | Secret used to encrypt secrets in database. | 64 character hex string | - |
:::info :::info
A random secret can be generated by using the following command: `openssl rand -hex 32` A random secret can be generated by using the following command: `openssl rand -hex 32`
@@ -52,27 +53,27 @@ A random secret can be generated by using the following command: `openssl rand -
## Docker ## Docker
| Environment Variable | Description | Possible values | Default | | Environment Variable | Description | Possible values | Default |
| --------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------- | ------- | | ------------------------ | ----------- | --------------- | ------- |
| `DOCKER_HOSTNAMES` | Comma-separated list of hostnames to connect to | For example `localhost,docker.example.com` | - | | ``DOCKER_HOSTNAMES`` | Comma-separated list of hostnames to connect to | For example `localhost,docker.example.com` | - |
| `DOCKER_PORTS` | Comma-separated list of ports to connect to | For example `2375,2376` | - | | ``DOCKER_PORTS`` | Comma-separated list of ports to connect to | For example `2375,2376` | - |
| `DOCKER_SOCKET_PATHS` | Comma-separated list of mounted Docker-compatible socket paths. See the [Docker and Podman integration guide](/docs/integrations/docker) | For example `/var/run/docker.sock,/var/run/podman.sock` | - | | ``DOCKER_SOCKET_PATHS`` | Comma-separated list of mounted Docker-compatible socket paths. See the [Docker and Podman integration guide](/docs/integrations/docker) | For example `/var/run/docker.sock,/var/run/podman.sock` | - |
## Database ## Database
| Environment Variable | Description | Possible values | Default |
| Environment Variable | Description | Possible values | Default | | ------------------------ | ----------- | --------------- | ------- |
| -------------------- | ---------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------- | ----------------------- | | ``DB_DRIVER`` | Database driver to use. Currently `better-sqlite3` is used for sqlite, `mysql2` for mysql and `node-postgres` for postgresql | ``better-sqlite3`` / ``mysql2`` / ``node-postgres`` | ``better-sqlite3`` |
| `DB_DRIVER` | Database driver to use. Currently `better-sqlite3` is used for sqlite, `mysql2` for mysql and `node-postgres` for postgresql | `better-sqlite3` / `mysql2` / `node-postgres` | `better-sqlite3` | | ``DB_DIALECT`` | Database dialect to use. | ``sqlite`` / ``mysql`` / ``postgresql`` | ``sqlite`` |
| `DB_DIALECT` | Database dialect to use. | `sqlite` / `mysql` / `postgresql` | `sqlite` | | ``DB_URL`` | Database URL to connect to. | Any valid database URL | ``/appdata/db/db.sqlite`` |
| `DB_URL` | Database URL to connect to. | Any valid database URL | `/appdata/db/db.sqlite` | | ``DB_HOST`` | Database host to connect to. | Any valid database host | - |
| `DB_HOST` | Database host to connect to. | Any valid database host | - | | ``DB_PORT`` | Database port to connect to. | Any valid database port | - |
| `DB_PORT` | Database port to connect to. | Any valid database port | - | | ``DB_NAME`` | Database name to connect to. | Any valid database name | - |
| `DB_NAME` | Database name to connect to. | Any valid database name | - | | ``DB_USER`` | Database user to connect with. | Any valid database user | - |
| `DB_USER` | Database user to connect with. | Any valid database user | - | | ``DB_PASSWORD`` | Database password to connect with. | Any valid database password | - |
| `DB_PASSWORD` | Database password to connect with. | Any valid database password | - |
You can either use the url or host, port, name and credentials combined. The URL will be prioritized over the other values. You can either use the url or host, port, name and credentials combined. The URL will be prioritized over the other values.
## Redis ## Redis
By default Redis is running within the installation. (for example in Docker). By default Redis is running within the installation. (for example in Docker).
@@ -82,23 +83,25 @@ However for example for K8s it can be useful to use an external Redis instance.
External redis is currently not supported with Proxmox Community Scripts External redis is currently not supported with Proxmox Community Scripts
::: :::
| Environment Variable | Description | Possible values | Default | | Environment Variable | Description | Possible values | Default |
| ---------------------- | ---------------------------------------------------------------------------------------------------- | ------------------------ | ------- | | ------------------------ | ----------- | --------------- | ------- |
| `REDIS_IS_EXTERNAL` | Whether Redis is running externally. | `true` / `false` | `false` | | `REDIS_IS_EXTERNAL` | Whether Redis is running externally. | `true` / `false` | `false` |
| `REDIS_HOST` | Hostname of the Redis instance. | Any valid hostname | - | | `REDIS_HOST` | Hostname of the Redis instance. | Any valid hostname | - |
| `REDIS_PORT` | Port of the Redis instance. | Any valid port | `6379` | | `REDIS_PORT` | Port of the Redis instance. | Any valid port | `6379` |
| `REDIS_USERNAME` | Username to connect to the Redis instance. | Any valid username | - | | `REDIS_USERNAME` | Username to connect to the Redis instance. | Any valid username | - |
| `REDIS_PASSWORD` | Password to connect to the Redis instance. | Any valid password | - | | `REDIS_PASSWORD` | Password to connect to the Redis instance. | Any valid password | - |
| `REDIS_DATABASE_INDEX` | Select which database of your redis instance should be used. | Any numeric index | - | | `REDIS_DATABASE_INDEX` | Select which database of your redis instance should be used. | Any numeric index | - |
| `REDIS_TLS_CA` | CA certificate for Redis TLS connections. If a certificate is specified the connection will use TLS. | Any valid CA certificate | - | | `REDIS_TLS_CA` | CA certificate for Redis TLS connections. If a certificate is specified the connection will use TLS. | Any valid CA certificate | - |
## Proxy ## Proxy
| Environment Variable | Description | Possible values | Default | | Environment Variable | Description | Possible values | Default |
| -------------------- | ------------------------------------------------------------------- | ------------------------- | ------- | | ------------------------ | ----------- | --------------- | ------- |
| `HTTP_PROXY` | HTTP proxy to use | Any valid HTTP proxy URL | - | | ``HTTP_PROXY`` | HTTP proxy to use | Any valid HTTP proxy URL | - |
| `HTTPS_PROXY` | HTTPS proxy to use | Any valid HTTPS proxy URL | - | | ``HTTPS_PROXY`` | HTTPS proxy to use | Any valid HTTPS proxy URL | - |
| `NO_PROXY` | Comma separated list of hosts that should be excluded from proxying | Any valid hostnames | - | | ``NO_PROXY`` | Comma separated list of hosts that should be excluded from proxying | Any valid hostnames | - |
## Advanced deployments ## Advanced deployments
@@ -106,6 +109,6 @@ External redis is currently not supported with Proxmox Community Scripts
The advanced deployments environment variables should only be used if you know what you are doing. The advanced deployments environment variables should only be used if you know what you are doing.
::: :::
| Environment Variable | Description | | Environment Variable | Description |
| ------------------------ | -------------------------------------------------- | | ------------------------ | ----------- |
| `DB_MIGRATIONS_DISABLED` | Disable db migrations. For example for helm charts | | ``DB_MIGRATIONS_DISABLED`` | Disable db migrations. For example for helm charts |
+9 -18
View File
@@ -9,19 +9,17 @@ tags:
--- ---
## Icon picker ## Icon picker
Icons in Homarr are automatically requested from multiple sources: Icons in Homarr are automatically requested from multiple sources:
- [Dashboard Icons](https://dashboardicons.com/) - Our recommended source with over 1888+ curated, high-quality icons
- [Dashboard Icons](https://dashboardicons.com/) - Our recommended source with more than 1,888 curated, high-quality icons - [selfh.st icons](https://github.com/selfhst/icons)
- [selfh.st icons](https://github.com/selfhst/icons) - [Simple icons](https://github.com/simple-icons/simple-icons)
- [Simple icons](https://github.com/simple-icons/simple-icons) - [Tabler icons](https://tabler-icons.io/)
- [Tabler icons](https://tabler-icons.io/) - [Papirus icons](https://github.com/PapirusDevelopmentTeam/papirus-icon-theme)
- [Papirus icons](https://github.com/PapirusDevelopmentTeam/papirus-icon-theme) - [Homelab Svg assets](https://github.com/loganmarchione/homelab-svg-assets)
- [Homelab Svg assets](https://github.com/loganmarchione/homelab-svg-assets) - **Local icons**: automatically fetched from the [medias feature](/docs/management/media/)
- **Local icons**: automatically fetched from the [medias feature](/docs/management/media/)
Using these icon sources, Homarr provides a total collection of over 11,000 icons at your fingertips. Using these icon sources, Homarr provides a total collection of over 11,000 icons at your fingertips.
The image picker searches this collection after a short typing delay. When a name is available, such as an app or search engine name, the picker uses it as the initial search. Results show your locally uploaded images first, followed by SVG icons and then other image formats. The icon picker is a fast searcher that helps you to find your desired icon simply by entering a search term.
### Finding Missing Icons ### Finding Missing Icons
@@ -31,13 +29,6 @@ If you can't find the icon you're looking for in Homarr's built-in picker, we st
Icons that are high quality and printable ([SVG](https://wikipedia.org/wiki/Scalable_Vector_Graphics)) are marked with a red badge "SVG" at the top right. Icons that are high quality and printable ([SVG](https://wikipedia.org/wiki/Scalable_Vector_Graphics)) are marked with a red badge "SVG" at the top right.
You can clear the selected image with the clear button or the usual keyboard shortcut for deleting a line (Command+Backspace on macOS or Control+Backspace on other platforms). Clearing the field does not restore the previous image.
### Using external icons ### Using external icons
If you don't find the icon you're looking for on Dashboard Icons or wish to use a different source, you can simply enter a URL that points to an icon. If you don't find the icon you're looking for on Dashboard Icons or wish to use a different source, you can simply enter a URL that points to an icon.
Direct HTTP(S) image URLs are used as entered instead of being searched in the built-in repositories. The picker previews the image and shows whether it loaded successfully. It is important that the URL directly points to the file without any advertisements or elements around it. It is important that the URL directly points to the file without any advertisements or elements around it.
### Uploading a local image
Users with media upload permission can select **Upload image** next to the field. After the upload finishes, the new local image is selected immediately and is available at the top of future searches.
+1
View File
@@ -47,6 +47,7 @@ services:
traefik.http.routers.homarr.rule: Host(`your.internal.dns.address.here.com`) traefik.http.routers.homarr.rule: Host(`your.internal.dns.address.here.com`)
traefik.http.routers.homarr.entrypoints: websecure traefik.http.routers.homarr.entrypoints: websecure
traefik.http.routers.homarr-secure.app: homarr traefik.http.routers.homarr-secure.app: homarr
traefik.http.apps.homarr.loadbalancer.server.port: 7575
networks: networks:
proxy: proxy:
@@ -54,11 +54,7 @@ You must choose a username and password (both can later be changed).
We enforce strict password requirements when you enter your password to protect your instance if you accidentally We enforce strict password requirements when you enter your password to protect your instance if you accidentally
expose it to the internet. expose it to the internet.
<img <img src={imageStartFromScratchStepAdminUser} alt={"Screenshot form to create admin user"} width={300} />
src={imageStartFromScratchStepAdminUser}
alt={"Screenshot form to create admin user"}
width={300}
/>
If you selected external providers (such as LDAP or OIDC), you'll be prompted to create an admin group. If you selected external providers (such as LDAP or OIDC), you'll be prompted to create an admin group.
This group will be used to synchronize users from your provider to Homarr. This group will be used to synchronize users from your provider to Homarr.
@@ -116,22 +112,14 @@ The onboarding can no longer be started again.
From here you can click on one of the provided links to continue your journey. From here you can click on one of the provided links to continue your journey.
We recommend you to continue with the section [creating your first board on this page](#creating-your-first-board). We recommend you to continue with the section [creating your first board on this page](#creating-your-first-board).
<img <img src={imageStartFromScratchStepFinish} alt={"Screenshot of the finish onboarding process with links"} width={300} />
src={imageStartFromScratchStepFinish}
alt={"Screenshot of the finish onboarding process with links"}
width={300}
/>
### Importing a ZIP from version before 1.0.0 ### Importing a ZIP from version before 1.0.0
This is recommended when you used Homarr before and want to re-import your data. This is recommended when you used Homarr before and want to re-import your data.
It is important that you first migrate correctly using [the list of breaking changes for 1.0](/blog/2024/09/23/version-1.0). It is important that you first migrate correctly using [the list of breaking changes for 1.0](/blog/2024/09/23/version-1.0).
<img <img src={manageExportOldHomarr} alt="Export from old Homarr" style={{ maxWidth: 500 }} />
src={manageExportOldHomarr}
alt="Export from old Homarr"
style={{ maxWidth: 500 }}
/>
**How to export and import data** **How to export and import data**
@@ -149,7 +137,8 @@ It is important that you first migrate correctly using [the list of breaking cha
## Creating your first board ## Creating your first board
In Homarr, you organize your apps and widgets on boards. In Homarr, you organize your apps and widgets on boards.
Boards can be organized using the interactive drag and drop. 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.
Let's create your first board by navigating here: `Manage` > `Boards` > `New board`: Let's create your first board by navigating here: `Manage` > `Boards` > `New board`:
![](./img/manage-create-board-button.png) ![](./img/manage-create-board-button.png)
@@ -197,38 +186,23 @@ Here you have the following options in the header:
When entering the edit mode, you'll get some additional buttons to work with: When entering the edit mode, you'll get some additional buttons to work with:
<img <img src={manageBoardHeaderEditMode} alt={"Screenshot of buttons in the edit mode"} width={200} />
src={manageBoardHeaderEditMode}
alt={"Screenshot of buttons in the edit mode"}
width={200}
/>
- **Add item**: Click this button to add new elements to your page. - **Add item**: Click this button to add new elements to your page.
- **Exit edit mode**: Click this btuton to save your changes and exit edit mode. - **Exit edit mode**: Click this button to save your changes and exit edit mode.
To continue, click the option "New Item": To continue, click the option "New Item":
<img <img src={manageBoardHeaderAddItem} alt={"Screenshot of the add item menu"} width={200} />
src={manageBoardHeaderAddItem}
alt={"Screenshot of the add item menu"}
width={200}
/>
Next, you can choose what item you'd like to add. Next, you can choose what item you'd like to add.
We will discuss the other options in the following sections - for now choose "App" to continue. We will discuss the other options in the following sections - for now choose "App" to continue.
<img <img src={manageBoardHeaderChooseItem} alt={"Screenshot of choosing the app item in the modal"} width={300} />
src={manageBoardHeaderChooseItem}
alt={"Screenshot of choosing the app item in the modal"}
width={300}
/>
As soon as you chose the app, the modal will close and you'll get the following item on your board: As soon as you chose the app, the modal will close and you'll get the following item on your board:
<img <img src={manageBoardNoApp} alt="Screenshot of the No app element on the board" />
src={manageBoardNoApp}
alt="Screenshot of the No app element on the board"
/>
This is expected, because you haven't told Homarr what app it should show in this item. This is expected, because you haven't told Homarr what app it should show in this item.
To configure this, click on the small 3 dots at the top of the item in the edit mode and choose "Edit item" in the menu. To configure this, click on the small 3 dots at the top of the item in the edit mode and choose "Edit item" in the menu.
@@ -240,21 +214,28 @@ In the modal you can edit the app that you want to display:
![](./img/manage-board-choose-app.png) ![](./img/manage-board-choose-app.png)
As soon as you save your changes, the tile on the board will refresh and display your app. As soon as you save your changes, the tile on the board will refresh and display your app.
Using the small bidirectional arrow at the bottom right you can resize the element. Use the resize handle at the bottom right to resize the tile.
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. Press and hold the handle, move the pointer, and release it when the tile has the size you want.
![](./img/resize-item.gif) ![](./img/resize-item.gif)
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. To move a tile in edit mode, press and hold its content, move it to the desired position, and release it.
As soon as you release your finger, the item will snap in place in the grid. 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.
![](./img/move-item.gif) ![](./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.
It stops shrinking at 75% and lets you scroll horizontally instead of making text and controls too small.
It does not squeeze or reflow the content of individual widgets, so a widget keeps the same design and proportions across screen sizes.
:::note :::note
You cannot move items freely on the vertical axis. Viewing a board initially uses the lightweight, non-interactive layout.
We always align all items to the top of the page to simply organization. For users with edit access, Homarr prepares drag-and-drop and resize controls in the background after the board becomes interactive.
To move items to the bottom, you must add more items or resize items. If you enter edit mode before that finishes, the existing board stays visible until the editor is ready.
::: :::
@@ -265,8 +246,10 @@ This helps you to explore the capabilities of Homarr before you build your board
::: :::
Users on touch or mobile devices may not be able to drag and drop or move items. Keyboard users can focus a tile, press Enter or Space, then use arrow keys to move and Shift plus arrow keys to resize.
They can use the "Move / resize item" option in the edit menu of items instead: 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:
![](./img/manage-board-move-resize-item-menu.png) ![](./img/manage-board-move-resize-item-menu.png)
@@ -338,43 +321,39 @@ As with every change, don't forget to save your changes using the exit edit mode
![](./img/exit-edit-mode.png) ![](./img/exit-edit-mode.png)
## Grouping your items ## Organizing items with sections
As the last step, let's organize your freshly created items using two different options. Sections are resizable containers for apps, widgets, and other sections.
You can choose either or both of them on your boards. The same section works on the main canvas and inside a fixed sidebar.
Each section can configure:
### Using dynamic sections - Show or hide its label.
- Allow or disable collapsing.
Dynamic sections are a simple, resizable visual group to group items. - Show or hide **Open all in new tabs**.
- Change its border color and custom CSS classes.
![](./img/create-dynamic-section.png) ![](./img/create-dynamic-section.png)
After adding a dynamic section, you can simply drag and drop items into the section: After adding a section, drag and resize it like any other tile, then drag items into it:
![](./img/dynamic-section.gif) ![](./img/dynamic-section.gif)
### Using categories ### Labeled and collapsible sections
Categories are more advanced and complicated than dynamic sections. Open the add-content menu and choose **New section**.
To create a category, open the add item modal and choose "new category": Use its settings menu to add a label and enable the behavior you want.
In edit mode, move items into or out of the section directly.
In view mode, each user can collapse eligible sections without changing the saved board layout.
If **Open all in new tabs** is enabled, the section menu can open every app link it contains.
![](./img/add-category.png) ### Fixed sidebars
Every category must have a name. After you click "Add category", you will get a new item on your board: Open the board's **Layout** settings to enable the left or right sidebar for each responsive layout.
Choose one, two, or three columns with the slider and use the preview to check the remaining canvas width.
The sidebar reserves those columns from the dashboard, so Homarr moves canvas items into the next available positions.
Sidebar content stays visible while the page scrolls and remains editable.
![](./img/category.gif) Existing categories become sections automatically, and category rails become fixed sidebars.
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 ## Guided tours
@@ -2,9 +2,9 @@
<img src="https://raw.githubusercontent.com/homarr-labs/charts/refs/heads/main/charts/homarr/icon.svg" align="right" width="92" alt="homarr logo"> <img src="https://raw.githubusercontent.com/homarr-labs/charts/refs/heads/main/charts/homarr/icon.svg" align="right" width="92" alt="homarr logo">
![Version: 8.28.1](https://img.shields.io/badge/Version-8.28.1-informational?style=flat) ![Version: 8.24.0](https://img.shields.io/badge/Version-8.24.0-informational?style=flat)
![Type: application](https://img.shields.io/badge/Type-application-informational?style=flat) ![Type: application](https://img.shields.io/badge/Type-application-informational?style=flat)
![AppVersion: v1.76.1](https://img.shields.io/badge/AppVersion-v1.76.1-informational?style=flat) ![AppVersion: v1.72.0](https://img.shields.io/badge/AppVersion-v1.72.0-informational?style=flat)
A Helm chart to deploy homarr for Kubernetes A Helm chart to deploy homarr for Kubernetes
@@ -429,7 +429,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 | | httproute.rules[0].matches[0].path.value | string | `"/"` | Path value to match |
| image.pullPolicy | string | `"IfNotPresent"` | Image pull policy | | image.pullPolicy | string | `"IfNotPresent"` | Image pull policy |
| image.repository | string | `"ghcr.io/homarr-labs/homarr"` | Image repository | | image.repository | string | `"ghcr.io/homarr-labs/homarr"` | Image repository |
| image.tag | string | `"v1.76.1"` | Overrides the image tag whose default is the chart appVersion | | image.tag | string | `"v1.72.0"` | Overrides the image tag whose default is the chart appVersion |
| imagePullSecrets | list | `[]` | Secrets for Docker registry | | imagePullSecrets | list | `[]` | Secrets for Docker registry |
| ingress.annotations | object | `{}` | Ingress annotations | | ingress.annotations | object | `{}` | Ingress annotations |
| ingress.enabled | bool | `false` | Enable ingress | | ingress.enabled | bool | `false` | Enable ingress |
@@ -27,8 +27,6 @@ import Alert from '@theme/Admonition';
]} ]}
/> />
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 ### Adding the integration
<AddingIntegration /> <AddingIntegration />
@@ -1,48 +0,0 @@
---
title: "What's Up Docker"
description: "What's Up Docker (WUD) watches your Docker containers and reports which ones have newer image versions available."
hide_title: true
---
import { IntegrationHeader } from "@site/src/components/integrations/header";
import { IntegrationCapabilites } from "@site/src/components/integrations/widgets";
import { AddingIntegration } from "@site/src/components/integrations/adding";
import { IntegrationSecrets } from "@site/src/components/integrations/secrets";
import { wudIntegration } from ".";
import { wudWidget } from "@site/docs/widgets/whats-up-docker";
<IntegrationHeader integration={wudIntegration} categories={["Health Monitoring"]} />
The What's Up Docker integration lets Homarr connect to your [WUD](https://github.com/getwud/wud) instance so you can see how many of your watched containers have image updates available directly from your dashboard.
### Widgets & Capabilities
<IntegrationCapabilites items={[{ widget: wudWidget }]} />
### Adding the integration
<AddingIntegration />
:::info URL configuration
Use the root URL of your WUD instance, for example `https://wud.example.com`. Homarr calls the `/api/app` and `/api/containers` endpoints under that URL automatically.
:::
### Secrets
WUD does not require authentication by default. If your instance is protected with [Basic Authentication](https://github.com/getwud/wud/blob/main/docs/configuration/authentications/basic/README.md), you can provide a username and password.
<IntegrationSecrets
secrets={[
{
credentials: [],
steps: [],
},
{
credentials: ["username", "password"],
steps: [
"Open your WUD instance's configuration and configure a WUD_AUTH_BASIC_* user as described in the WUD documentation",
"Enter that username and password in Homarr — Basic authentication is handled automatically",
],
},
]}
/>
@@ -1,9 +0,0 @@
import { IntegrationDefinition } from "@site/src/types";
export const wudIntegration: IntegrationDefinition = {
name: "What's Up Docker",
description:
"What's Up Docker (WUD) watches your Docker containers and reports which ones have newer image versions available.",
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/whats-up-docker.svg",
path: "../../integrations/whats-up-docker",
};
+7
View File
@@ -83,3 +83,10 @@ The OpenAPI and MCP APIs include board management endpoints for common automatio
- Configure global board defaults such as public home boards and status behavior - Configure global board defaults such as public home boards and status behavior
Use the interactive API reference to inspect the exact request body for each endpoint under the `boards` and `settings` tags. 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.
+1 -4
View File
@@ -28,7 +28,7 @@ The name must be at least one character long and can be chosen freely.
### Icon URL ### Icon URL
This is the URL to the icon that will be displayed on the app widget. This is the URL to the icon that will be displayed on the app widget.
You can paste a direct HTTP(S) URL to an image, search the included icon repositories by name, or upload a local file through the upload button. The app name is pre-filled as the initial library search when no image is selected. Local images appear first, followed by SVG icons and other formats. Direct image URLs are previewed, used as entered, and are not searched in the icon repositories. It can be a url to an image in the internet, one from the included icon repositories or a local file uploaded through the upload button.
![Icon URL](./img/app-icon-url.png) ![Icon URL](./img/app-icon-url.png)
@@ -40,14 +40,11 @@ An optional description that can be shown as tooltip on the app widget.
The URL to the website you want to link to. Clicking on the app widget or bookmark will open this URL. The URL to the website you want to link to. Clicking on the app widget or bookmark will open this URL.
You can also use custom URI schemes such as `vscode://file/path`, `microsoft-edge:https://example.com`, or `myapp://open` to link to local applications or third-party programs. We trust that you know what you are doing — only `javascript:` URIs are blocked for security reasons.
### Ping URL ### Ping URL
When you want to use another url for the simple status check you can set it here. When you want to use another url for the simple status check you can set it here.
This will allow you to set a different URL for the ping than the URL that is opened when clicking on the app widget. This will allow you to set a different URL for the ping than the URL that is opened when clicking on the app widget.
It's useful when you want to ping the service internally for example with a custom ip but open the website externally. It's useful when you want to ping the service internally for example with a custom ip but open the website externally.
Note that the ping URL only accepts `http://` and `https://` URLs — custom URI schemes are not supported for pings.
## List of apps ## List of apps
+72 -11
View File
@@ -36,8 +36,9 @@ Underscores and hyphen-minus are allowed.
### Column count ### Column count
Homarr has a complex drag-and-drop system that can be used from the browser. The column count defines how many fixed-width cells are available in each row.
The column count defines how many horizontal columns are available to drag and drop elements to. One cell contains a `200 × 200` logical-pixel tile.
Homarr scales the whole board uniformly instead of changing widget proportions, with horizontal scrolling when further shrinking would make controls too small.
### Public ### Public
@@ -55,8 +56,6 @@ Alternatively, you can also navigate from the board page itself using the cog ic
![](./img/navigate-to-settings-2.png) ![](./img/navigate-to-settings-2.png)
All board settings are edited on a single page. Click the **Save changes** button in the save bar at the bottom to apply them, or **Discard** to restore the last saved values. The save bar only appears when you have unsaved changes. Access control and the Danger zone are independent sections and are applied immediately.
### General ### General
#### Page title #### Page title
@@ -88,26 +87,71 @@ Homarr will fall back to the Homarr logo if nothing is set.
### Layout ### Layout
In this section you can configure the different responsive layouts. Depending on your screens width the layout will change. 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.
![](./img/layout-settings.png) ![](./img/layout-settings.png)
On each element the position and size of items can be changed seperately. 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 scale never drops below 75%; on narrower screens the canvas scrolls horizontally instead of shrinking controls below an accessible size.
The position and size of every item can be changed separately in each responsive layout.
When adding a new layout the position and sizes will automatically be generated depending on the column count. 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. New items use their widget's default grid size, while a new inline section starts at a useful `4 × 3` size when the layout has enough columns.
Both are placed in the first available position.
Moving content downward in edit mode extends the canvas automatically, while view mode trims it back to the content.
Nested sections keep their own resizable bounds.
#### Name #### Name
The name of the layout can be choosen freely. It is only used for the management and will not be visible on the board. The name of the layout can be chosen freely. It is only used for management and will not be visible on the board.
#### Column count #### Column count
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. Total number of columns for this layout.
When changed, Homarr moves items into the next available positions without overlap.
#### Fixed sidebars
Each responsive layout can enable a fixed left sidebar, a fixed right sidebar, or both.
Set a sidebar 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.
Sidebar columns are reserved from the layout's total column count.
For example, a 12-column layout with a two-column left sidebar leaves ten columns for the canvas.
Enabling or widening a sidebar reflows canvas items into the remaining columns.
Disabling a sidebar moves its contents back into the next available canvas positions.
Sidebars remain visible while the page scrolls.
Apps, widgets, and sections inside them still use the same fixed `200 × 200` cells and can be rearranged in edit mode.
#### Breakpoint #### Breakpoint
Minimum width of the screen where the layout will be used. Minimum width of the screen where the layout will be used.
Once the minimum of the next layout is reached the layout will change. 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; the visible grip and tile outline indicate the active move surface, while the tile's settings button remains available.
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.
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.
The **Move / resize item** action in the edit menu remains available for precise numeric or touch input.
#### Preview #### Preview
@@ -115,6 +159,23 @@ Once the minimum of the next layout is reached the layout will change.
<img src={layoutMediumImage} height={256} /> <img src={layoutMediumImage} height={256} />
<img src={layoutLargeImage} height={256} /> <img src={layoutLargeImage} height={256} />
### Sections and sidebars
Sections can contain apps, widgets, and nested sections.
Every section uses the same model whether it is on the canvas or inside a sidebar.
Sections can be moved and resized directly in edit mode.
Expanding it creates space for its contents and pushes following items out of the way; collapsing it compacts the board.
Each section can configure:
- Whether its label is visible.
- Whether users may collapse it.
- Whether its menu shows **Open all in new tabs**.
- Its label, border color, and custom CSS classes.
Existing categories and dynamic sections are converted into this unified section model.
Existing category rails become board sidebars automatically.
### Background ### Background
#### Background image URL #### Background image URL
@@ -221,7 +282,7 @@ This action is irreversible and cannot be undone.
The deletion of boards is permanent and cannot be undone. The deletion of boards is permanent and cannot be undone.
There are two ways to delete a board: There are two ways to delete a board:
- Navigate to the settings of your board, open the "Danger zone" section and click on the "delete this board" button. - Navigate to the settings of your board, open the "Danger zone" dropdown and click on the "delete this board" button.
- Navigate to the list of boards in the management pages, click the three dots and click on "delete permanently". - Navigate to the list of boards in the management pages, click the three dots and click on "delete permanently".
Both actions will execute the same deletion process. Both actions will execute the same deletion process.
@@ -52,21 +52,14 @@ The certificates are stored in the `/appdata/trusted-certificates` directory whi
This means you can automate the update of the certificates by simply replacing or adding a new file in the directory. This means you can automate the update of the certificates by simply replacing or adding a new file in the directory.
## Obtaining certificates ## Obtaining certificates
Every integration has its own way of obtaining certificates. Every integration has its own way of obtaining certificates.
Generally it is recommended to search for the documentation of the integration you are using. 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. 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. Below you can find instructions for some of the integrations.
### Pi-hole (v6) ### Pi-hole (v6)
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`.
You can find more details regarding SSL / TLS for Pi-hole [here](https://docs.pi-hole.net/api/tls/).
### Proxmox Since version 6 of Pi-hole, the web interface can be accessed through HTTPS.
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 the path `/etc/pihole/tls_ca.crt`. To add the certificate, this file needs to be copied and uploaded to Homarr.
The certificate can be found in any node on the path `/etc/pve/pve-root-ca.pem`. You can find more details regarding SSL / TLS for Pi-hole [here](https://docs.pi-hole.net/api/tls/).
You can learn more about certificate management in Proxmox [here](https://pve.proxmox.com/wiki/Certificate_Management).
+53 -83
View File
@@ -9,127 +9,97 @@ tags:
--- ---
# Settings # Settings
The settings page has a few options that will be applied to the entire Homarr application.
The settings page applies options to the whole Homarr application. They may require a full page reload for changes to apply.
Some changes need a full page reload before they take effect.
All changes are collected in a single form. Click the **Save changes** button in the save bar at the bottom of the page to apply them. The save bar only appears when you have unsaved changes, and the **Discard** button reverts your changes.
## Analytics ## Analytics
By default, Homarr is collecting anonymous analytics to identify issues and prioritize bugfixes and improvements.
The data is never shared with any third-parties and is fully GDPR & CCPA compliant.
The software being used is [Umami Analytics](https://umami.is/) which is fully open source.
The main server software can be found [here](https://github.com/umami-software/umami) and [this node library](https://github.com/umami-software/node) to perform server side calls to the API.
Homarr uses [PostHog](https://posthog.com/) to collect anonymous usage data. A full list of the collected data by default can be found here: https://umami.is/docs/metric-definitions.
We collect this data to see what features people ACTUALLY use.
We use this data to decide where we spend our development time.
We do not sell the data. We do not share the data with third parties. We understand that you might not want Homarr to communicate with our services.
The data does not contain personal information and does not identify you or your installation. By unchecking the ``Send anonymous analytics`` checkbox and reloading the full page, all communication by Umami will be disabled and no data will be collected.
If you are paranoid, you can permanently block ``umami.homarr.dev`` from your DNS / firewall.
Homarr sends the data to our own PostHog server at `https://hog.homarr.dev`. Additionally, you opt in to more data collection to help us provide more useful data.
The server sends the data once a week and when the server starts.
### What we collect ### Integration data
Enabling this optional data will send the types of integrations (e.g. SABnzbd, Sonarr, ...) and how many you have configured of them.
This will not include any URLs, names, secrets, errors and others. This is disabled by default.
Homarr collects technical data about your installation: ### Widget data
Enabling this optional data will send the types of widgets (e.g. downloads, media streams, iframe, ...) and how many you have configured of them.
This will not include any URLs, names, secrets, errors and others. This is disabled by default.
- Your Homarr version ### Users data
- Your database type (SQLite, MySQL, or PostgreSQL) Enabling this optional data will send the count of users and whether you activated SSO.
- Whether Docker or Kubernetes is enabled This will not include any usernames, passwords, emails, URLs, authentication flows or client IDs.
- Your enabled authentication providers This is disabled by default.
- Your operating system and CPU architecture
- Your default language
- The number of boards, apps, sections, layouts, users, groups, search engines, icon repositories, cron jobs, API keys, invites, sessions, and accounts
- The integration types you use and how many you have of each (for example, Sonarr or SABnzbd)
- The widget types you use and how many you have of each
Homarr does NOT collect:
- URLs
- Names
- Usernames, emails, or passwords
- Integration secrets or API keys
- Error messages or logs
- Any data that can identify you
Each installation gets a random ID. We use this ID only to count unique installations. We cannot use this ID to identify you.
Homarr does not use cookies for analytics. Homarr does not track page views automatically. Homarr only sends the data listed above.
### Disable analytics
Analytics are enabled by default. To disable analytics:
1. Open the Settings page.
2. Uncheck the "Help us improve Homarr" checkbox.
3. Reload the full page.
When analytics are disabled, Homarr does not send any data to PostHog.
You can also set the `NO_EXTERNAL_CONNECTION` environment variable to `true`. This stops all external requests, including analytics. See the [environment variables](/docs/advanced/environment-variables) page for more information.
You can block `hog.homarr.dev` in your DNS or firewall. Homarr cannot send data when this address is blocked.
## Search Engine Optimization (SEO) ## Search Engine Optimization (SEO)
Big companies [like Google, Microsoft, Baidu and Yandex](https://de.wikipedia.org/wiki/Web-Index) will use [web crawlers](https://de.wikipedia.org/wiki/Webcrawler) to index the Internet for new websites & pages.
Search engines use web crawlers to index websites. This can cause unwanted traffic to your dashboard. This is desirable in many cases (for example when selling products in an online shop) but can lead to unwanted traffic and attention to your personal dashboard.
Homarr asks crawlers to not index your instance by default. Use these settings to change this request. Homarr is requesting the crawlers to not index Homarr instances by default for this reason.
If you use Homarr as a public website and want it to be searchable on these search engines, you can disable these settings.
Homarr cannot control what the crawlers do. A crawler can ignore the request. Your website can still appear in an index. Please note that Homarr doesn't have control over what the crawlers do; it is a mere request to them to not perform these actions but will not guarantee that your website doesn't land on some index.
### No index ### No index
This will ask crawlers to not index your Homarr instance on any indexes.
This setting asks crawlers to not index your instance. It is up to the crawler to properly respect this request.
This setting is enabled by default. Disable this setting to allow indexing. By default, this setting is enabled. Disabling it will allow indexing.
### No follow ### No follow
If indexing is allowed, crawlers will also attempt to follow any links on your page to further "discover" new parts of the Internet and on your website.
Crawlers follow links to discover new pages. By default, this setting is enabled and will ask crawlers to not follow any links.
This setting asks crawlers to not follow links. This setting is enabled by default. Crawler of different companies may behave differently when indexing is prohibited and following is allowed.
Crawler behavior can differ between search engines.
### No translate ### No translate
Google and Chrome on Android will prompt users to translate pages that are not in their device language.
Some browsers offer to translate pages. This can lead to some shifting and can be undesired in some cases. By enabling this,
This setting asks crawlers to not offer translation. Some browsers ignore this setting. you ask crawlers to never translate and never prompt users for translation.
It should be noted that some browsers will ignore this and translate regardless.
### No site links search box ### No site links search box
This setting is exclusive to Google.
This setting applies to Google only. [Google will create a box below your domain](https://developers.google.com/search/docs/appearance/structured-data/sitelinks-searchbox) that lists the paths that were discovered by the "folowing".
Google can show a search box below your domain. The user can search them directly in Google.
This setting asks Google to not show this box. This setting is disabled by default. You can ask the crawlers to not allow searches by enabling this setting.
This setting is disabled by default.
## Boards ## Boards
### Global home board ### Global home board
You can set a global home board that will be used for all users that have not set a home board themselves.
You can set a global home board. It will also be used for anonymous users.
This board is the fallback for users without their own home board. It is also the fallback for anonymous users. Note that the board must be public to be used as a global home board.
The board must be public.
### Global mobile board ### Global mobile board
You can set a global mobile board. You can set a global mobile board. It will be shown instead of the global home board on mobile devices that can be detected by Homarr. (Using the User-Agent header).
Homarr shows this board on mobile devices instead of the global home board. It will similar to the global home board be as fallback for normal users and for any anonymous users.
Homarr detects mobile devices with the User-Agent header. It also has to be public.
The board must be public.
### App status ### App status
#### Enable status by default #### Enable status by default
You can decide if app status is enabled when you add a new app. You can decide if the app status should be enabled by default when adding a new app to the board.
#### Force disable status #### Force disable status
You can decide if app status is disabled on all boards. You can decide if the app status should be disabled for all boards.
###
## Appearance ## Appearance
### Default color scheme ### Default color scheme
You can set the default color scheme for all users that have not set a color scheme themselves.
You can set the default color scheme for users without their own color scheme.
## Culture ## Culture
### Default language ### Default language
You can set the default language for all users that have not set a language themselves.
You can set the default language for users without their own language.
+15 -13
View File
@@ -1,27 +1,30 @@
--- ---
title: "App" title: 'App'
description: "Embeds an app into the board." description: 'Embeds an app into the board.'
hide_title: true hide_title: true
--- ---
import { AddingWidget } from "@site/src/components/widgets/adding"; import { AddingWidget } from '@site/src/components/widgets/adding';
import { WidgetConfig } from "@site/src/components/widgets/configuration"; import { WidgetConfig } from '@site/src/components/widgets/configuration';
import { WidgetHeader } from "@site/src/components/widgets/header"; import { WidgetHeader } from '@site/src/components/widgets/header';
import { appWidget } from "."; import { appWidget } from '.';
<WidgetHeader widget={appWidget} categories={["Links", "Apps"]} />
<WidgetHeader
widget={appWidget}
categories={['Links', 'Apps']}
/>
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. 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. 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 ### Screenshots
import githubApp from "./img/github.png"; import githubApp from './img/github.png';
import jellyseerrApp from "./img/jellyseerr-description-tooltip.png"; import jellyseerrApp from './img/jellyseerr-description-tooltip.png';
import piHoleApp from "./img/pi-hole-ping-icon.png"; import piHoleApp from './img/pi-hole-ping-icon.png';
<div className="flex gap-1"> <div className='flex gap-1'>
<img src={githubApp} alt="app github with-status-check" width={128 * 1.5} height={128 * 1.5} /> <img src={githubApp} alt="app github with-status-check" width={128 * 1.5} height={128 * 1.5} />
<img src={piHoleApp} alt="app pi-hole with-status-check status-check-icon" width={128 * 1.5} height={128 * 1.5} /> <img src={piHoleApp} alt="app pi-hole with-status-check status-check-icon" width={128 * 1.5} height={128 * 1.5} />
<img src={jellyseerrApp} alt="app jellyseerr with-description-tooltip" height={128 * 1.5} /> <img src={jellyseerrApp} alt="app jellyseerr with-description-tooltip" height={128 * 1.5} />
@@ -31,5 +34,4 @@ import piHoleApp from "./img/pi-hole-ping-icon.png";
<AddingWidget /> <AddingWidget />
### Configuration ### Configuration
<WidgetConfig configuration={appWidget.configuration} /> <WidgetConfig configuration={appWidget.configuration} />
@@ -17,17 +17,10 @@ import { beszelIntegration } from '@site/docs/integrations/beszel';
categories={['System Monitoring']} categories={['System Monitoring']}
/> />
This widget displays all your Beszel-monitored systems in a sortable data table. Each row shows system metrics like CPU, memory, disk, network, temperature, and more. Columns can be toggled on or off, dragged into a different order, and resized from their headers. Column order and widths are saved automatically for each widget. When Beszel reports extra filesystems, the disk column adds a hover breakdown for each mount alongside the root disk. This widget displays all your Beszel-monitored systems in a sortable data table. Each row shows system metrics like CPU, memory, disk, network, temperature, and more. Columns can be toggled on or off, and the table supports sorting by any column. When Beszel reports extra filesystems, the disk column adds a hover breakdown for each mount alongside the root disk.
Select a system row to open the complete statistics view, including system, filesystem, Docker, historical, and realtime charts. Select a system row to open the complete statistics view, including system, filesystem, Docker, historical, and realtime charts.
### Interactions
- **Reorder columns** by hovering a header to reveal its grab handle, then dragging it
- **Resize columns** by dragging a header's right edge
- **Sort systems** by selecting a sortable column header
- **Open system statistics** by selecting a row
### Supported Integrations ### Supported Integrations
<WidgetIntegrations items={[{ <WidgetIntegrations items={[{
+16 -14
View File
@@ -1,26 +1,29 @@
--- ---
title: "Bookmarks" title: 'Bookmarks'
description: "Displays multiple app links" description: 'Displays multiple app links'
hide_title: true hide_title: true
--- ---
import { AddingWidget } from "@site/src/components/widgets/adding"; import { AddingWidget } from '@site/src/components/widgets/adding';
import { WidgetConfig } from "@site/src/components/widgets/configuration"; import { WidgetConfig } from '@site/src/components/widgets/configuration';
import { WidgetHeader } from "@site/src/components/widgets/header"; import { WidgetHeader } from '@site/src/components/widgets/header';
import { bookmarksWidget } from "."; import { bookmarksWidget } from '.';
<WidgetHeader widget={bookmarksWidget} categories={["Links", "Apps"]} />
<WidgetHeader
widget={bookmarksWidget}
categories={['Links', 'Apps']}
/>
The bookmarks widget allows you to display multiple app links in a single widget. 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. 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 ### Screenshots
import gridIconsOnly from "./img/grid-icons-only.png"; import gridIconsOnly from './img/grid-icons-only.png';
import gridWithTitle from "./img/grid-with-title.png"; import gridWithTitle from './img/grid-with-title.png';
import verticalListAll from "./img/vertical-list-all.png"; import verticalListAll from './img/vertical-list-all.png';
import vericalListSimple from "./img/vertical-list-simple.png"; import vericalListSimple from './img/vertical-list-simple.png';
<div className='flex gap-1'> <div className='flex gap-1'>
<div className='flex flex-col gap-1'> <div className='flex flex-col gap-1'>
@@ -30,13 +33,12 @@ import vericalListSimple from "./img/vertical-list-simple.png";
<img src={gridWithTitle} alt="bookmarks grid with-title" width={256 + 4} height={256 + 4} /> <img src={gridWithTitle} alt="bookmarks grid with-title" width={256 + 4} height={256 + 4} />
<img src={verticalListAll} alt="bookmarks vertical-list all-elements" width={128} height={256 + 4} /> <img src={verticalListAll} alt="bookmarks vertical-list all-elements" width={128} height={256 + 4} />
</div> </div>
### Adding the widget ### Adding the widget
<AddingWidget /> <AddingWidget />
### Configuration ### Configuration
<WidgetConfig configuration={bookmarksWidget.configuration} /> <WidgetConfig configuration={bookmarksWidget.configuration} />
@@ -40,19 +40,12 @@ import dockerTable from './img/table.png';
### Interactions ### Interactions
Hover a column header to reveal its grab handle, then drag it to reorder the table or drag the header's edge to resize it. The layout is saved automatically for each widget instance and restored after a reload. These controls are disabled while the board is in edit mode. Each container row has an action menu (accessible via the three-dot icon) with the following options:
Each container row has an action menu, accessible via the three-dot icon or by right-clicking the row, with the following options:
- **View logs** - opens a dedicated terminal page streaming the container's logs - **View logs** - opens a dedicated terminal page streaming the container's logs
- **Start** / **Stop** / **Restart** - manage the container's state - **Start** / **Stop** / **Restart** - manage the container's state
- **Remove** - permanently remove the container after a second confirmation click - **Remove** - remove the container
- **Add to Homarr** - create a bookmark app from the container - **Add to Homarr** - create a bookmark app from the container
### Adding the widget ### Adding the widget
<AddingWidget /> <AddingWidget />
### Configuration
<WidgetConfig configuration={dockerContainersWidget.configuration} />
@@ -8,12 +8,6 @@ export const dockerContainersWidget: WidgetDefinition = {
path: "../../widgets/docker-containers", path: "../../widgets/docker-containers",
configuration: { configuration: {
items: [ items: [
{
name: "Columns to show",
description: "Select which columns are visible in the table",
values: "Name, State, Host, CPU usage, Memory usage, and Actions",
defaultValue: "All columns",
},
{ {
name: "Enable items sorting", name: "Enable items sorting",
description: "Allows to sort containers by clicking on the column headers", description: "Allows to sort containers by clicking on the column headers",
+1 -1
View File
@@ -44,7 +44,7 @@ import contextMenuImg from './img/context-menu.png';
- **Responsive table** — columns automatically adapt to widget width, hiding less important columns at smaller sizes - **Responsive table** — columns automatically adapt to widget width, hiding less important columns at smaller sizes
- **Sortable columns** — click any column header to sort ascending/descending - **Sortable columns** — click any column header to sort ascending/descending
- **Column customization** — choose which columns to display, then hover a header to reveal its grab handle for reordering or drag its edge to resize it; layouts save automatically per widget - **Column customization** — choose which columns to display via the widget options panel
- **Right-click context menu** — pause, resume, delete downloads, or copy download names directly from the context menu - **Right-click context menu** — pause, resume, delete downloads, or copy download names directly from the context menu
- **Row expansion** — click any row to see full details inline without opening a separate modal - **Row expansion** — click any row to see full details inline without opening a separate modal
- **Hover tooltips** — hover a download name to see size, speeds, ETA, ratio, and category - **Hover tooltips** — hover a download name to see size, speeds, ETA, ratio, and category
@@ -43,7 +43,6 @@ import temperature from './img/temperature.png';
### Interactions ### Interactions
- Toggle entity state by clicking on the entity (only when clickable enabled). - Toggle entity state by clicking on the entity (only when clickable enabled).
- After a successful toggle, the displayed state refreshes immediately.
### Adding the widget ### Adding the widget
@@ -47,7 +47,4 @@ import withDisplayname from './img/with-displayname.png';
<AddingWidget /> <AddingWidget />
### Configuration ### 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} /> <WidgetConfig configuration={smartHomeExecuteAutomationWidget.configuration} />
@@ -1,35 +0,0 @@
---
title: "What's Up Docker"
description: "Displays how many watched containers have image updates available."
hide_title: true
---
import { WidgetHeader } from "@site/src/components/widgets/header";
import { AddingWidget } from "@site/src/components/widgets/adding";
import { WidgetConfig } from "@site/src/components/widgets/configuration";
import { WidgetIntegrations } from "@site/src/components/widgets/integrations";
import { wudWidget } from ".";
import { wudIntegration } from "@site/docs/integrations/whats-up-docker";
<WidgetHeader widget={wudWidget} categories={["Health Monitoring"]} />
This widget shows two stats from your WUD instance: the total number of monitored containers, and how many of them currently have an image update available. Optionally, it can also list each outdated container with its current and new version, linking to the release notes when WUD provides one.
### Supported Integrations
<WidgetIntegrations
items={[
{
integration: wudIntegration,
note: "Displays monitored container and available-update counts",
},
]}
/>
### Adding the widget
<AddingWidget />
### Configuration
<WidgetConfig configuration={wudWidget.configuration} />
@@ -1,37 +0,0 @@
import { WidgetDefinition } from "@site/src/types";
import { IconBrandDocker } from "@tabler/icons-react";
export const wudWidget: WidgetDefinition = {
icon: IconBrandDocker,
name: "What's Up Docker",
description: "Displays how many watched containers have image updates available.",
path: "../../widgets/whats-up-docker",
configuration: {
items: [
{
name: "Show title",
description: "Displays the icon and \"What's Up Docker\" label above the stats",
values: { type: "boolean" },
defaultValue: "yes",
},
{
name: "Show update ring",
description: "Displays a ring showing the percentage of monitored containers with a pending update",
values: { type: "boolean" },
defaultValue: "yes",
},
{
name: "Layout",
description: "Arrange the two stats side by side (Horizontal) or stacked (Vertical)",
values: { type: "select", options: ["Horizontal", "Vertical"] },
defaultValue: "Horizontal",
},
{
name: "Show container update list",
description: "Lists each monitored container that has an image update available, linking to its release notes when known",
values: { type: "boolean" },
defaultValue: "no",
},
],
},
};
+15 -22
View File
@@ -1,6 +1,4 @@
// Importing env files here to validate on build // Importing env files here to validate on build
import path from "node:path";
import "@homarr/auth/env"; import "@homarr/auth/env";
import "@homarr/core/infrastructure/db/env"; import "@homarr/core/infrastructure/db/env";
import "@homarr/common/env"; import "@homarr/common/env";
@@ -15,21 +13,7 @@ const withNextIntl = createNextIntlPlugin({
requestConfig: "../../packages/translation/src/request.ts", requestConfig: "../../packages/translation/src/request.ts",
}); });
const getDevelopmentServiceAliases = () => {
if (process.env.NODE_ENV !== "development") {
return undefined;
}
return {
"@homarr/tasks": "./src/instrumentation-noop.ts",
"@homarr/websocket": "./src/instrumentation-noop.ts",
};
};
const nextConfig: NextConfig = { const nextConfig: NextConfig = {
// Next previews otherwise create agent instruction files in the application
// directory during development.
agentRules: false,
env: { env: {
HOMARR_VERSION: process.env.HOMARR_VERSION ?? "unknown", HOMARR_VERSION: process.env.HOMARR_VERSION ?? "unknown",
}, },
@@ -46,14 +30,19 @@ const nextConfig: NextConfig = {
serverExternalPackages: ["dockerode", "isomorphic-dompurify", "jsdom", "better-sqlite3"], serverExternalPackages: ["dockerode", "isomorphic-dompurify", "jsdom", "better-sqlite3"],
experimental: { experimental: {
optimizePackageImports: ["@mantine/core", "@mantine/hooks", "@tabler/icons-react"], optimizePackageImports: ["@mantine/core", "@mantine/hooks", "@tabler/icons-react"],
turbopackFileSystemCacheForBuild: true, turbopackFileSystemCacheForDev: true,
useTypeScriptCli: true, webpackMemoryOptimizations: true,
}, },
turbopack: { turbopack: {
root: path.resolve(import.meta.dirname, "../.."), // ponytail: known Turbopack NFT warning from path.join(process.cwd(), …) in
// Development runs tasks and WebSocket as separate processes. These aliases // src/app/api/backup/{route,shared}.ts. No working placement in 16.2.x
// keep their production-only instrumentation imports out of the dev graph. // (see vercel/next.js#95125). Suppress until upstream fix lands.
resolveAlias: getDevelopmentServiceAliases(), ignoreIssue: [
{
path: "**/*",
title: "Encountered unexpected file in NFT list",
},
],
}, },
transpilePackages: ["@homarr/ui", "@homarr/notifications", "@homarr/modals", "@homarr/spotlight", "@homarr/widgets"], transpilePackages: ["@homarr/ui", "@homarr/notifications", "@homarr/modals", "@homarr/spotlight", "@homarr/widgets"],
images: { images: {
@@ -79,6 +68,10 @@ const nextConfig: NextConfig = {
{ {
source: "/(.*)", // Apply CSP to all routes source: "/(.*)", // Apply CSP to all routes
headers: [ headers: [
{
key: "Accept-CH",
value: "Sec-CH-Viewport-Width",
},
{ {
key: "Content-Security-Policy", 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 // 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
+3 -2
View File
@@ -31,7 +31,6 @@
"@homarr/docker": "workspace:^0.1.0", "@homarr/docker": "workspace:^0.1.0",
"@homarr/form": "workspace:^0.1.0", "@homarr/form": "workspace:^0.1.0",
"@homarr/forms-collection": "workspace:^0.1.0", "@homarr/forms-collection": "workspace:^0.1.0",
"@homarr/gridstack": "catalog:",
"@homarr/icons": "workspace:^0.1.0", "@homarr/icons": "workspace:^0.1.0",
"@homarr/image-proxy": "workspace:^0.1.0", "@homarr/image-proxy": "workspace:^0.1.0",
"@homarr/integrations": "workspace:^0.1.0", "@homarr/integrations": "workspace:^0.1.0",
@@ -59,10 +58,11 @@
"@modelcontextprotocol/sdk": "catalog:", "@modelcontextprotocol/sdk": "catalog:",
"@scalar/api-reference-react": "catalog:", "@scalar/api-reference-react": "catalog:",
"@tabler/icons-react": "catalog:", "@tabler/icons-react": "catalog:",
"@tanstack/react-devtools": "catalog:", "@tanstack/query-async-storage-persister": "catalog:",
"@tanstack/react-query": "catalog:", "@tanstack/react-query": "catalog:",
"@tanstack/react-query-devtools": "catalog:", "@tanstack/react-query-devtools": "catalog:",
"@tanstack/react-query-next-experimental": "catalog:", "@tanstack/react-query-next-experimental": "catalog:",
"@tanstack/react-query-persist-client": "catalog:",
"@trpc/client": "catalog:", "@trpc/client": "catalog:",
"@trpc/next": "catalog:", "@trpc/next": "catalog:",
"@trpc/react-query": "catalog:", "@trpc/react-query": "catalog:",
@@ -80,6 +80,7 @@
"drizzle-orm": "catalog:", "drizzle-orm": "catalog:",
"flag-icons": "catalog:", "flag-icons": "catalog:",
"glob": "catalog:", "glob": "catalog:",
"gridstack": "catalog:",
"isomorphic-dompurify": "catalog:", "isomorphic-dompurify": "catalog:",
"jotai": "catalog:", "jotai": "catalog:",
"jszip": "catalog:", "jszip": "catalog:",
@@ -1,120 +0,0 @@
"use client";
import { useEffect, useState } from "react";
import { usePathname } from "next/navigation";
import { TanStackDevtools } from "@tanstack/react-devtools";
import type { TanStackDevtoolsReactPlugin } from "@tanstack/react-devtools";
import type { QueryClient } from "@tanstack/react-query";
import { useQueryClient } from "@tanstack/react-query";
import { ReactQueryDevtoolsPanel } from "@tanstack/react-query-devtools";
const valueStyle = { fontFamily: "monospace", overflowWrap: "anywhere" } as const;
const metricStyle = {
background: "color-mix(in srgb, currentColor 4%, transparent)",
border: "1px solid color-mix(in srgb, currentColor 18%, transparent)",
borderRadius: 8,
padding: 12,
} as const;
const readDiagnostics = (queryClient: QueryClient) => {
const queries = queryClient.getQueryCache().getAll();
const mutations = queryClient.getMutationCache().getAll();
const board = document.querySelector("[data-homarr-dev-benchmark-board]");
const widgetItems = board?.querySelectorAll('[data-type="item"]').length ?? 0;
const readyWidgets = board?.querySelectorAll("[data-homarr-widget-ready]").length ?? 0;
const resources = performance.getEntriesByType("resource") as PerformanceResourceTiming[];
return {
active: queries.filter((query) => query.getObserversCount() > 0).length,
errors: queries.filter((query) => query.state.status === "error").length,
fetching: queries.filter((query) => query.state.fetchStatus === "fetching").length,
pendingMutations: mutations.filter((mutation) => mutation.state.status === "pending").length,
stale: queries.filter((query) => query.isStale()).length,
total: queries.length,
readyWidgets,
widgetItems,
scriptKiB: Math.round(
resources
.filter((resource) => resource.initiatorType === "script")
.reduce((total, resource) => total + resource.decodedBodySize, 0) / 1024,
),
};
};
const Metric = ({ label, value }: { label: string; value: number | string }) => (
<div style={metricStyle}>
<div style={{ fontSize: 12, opacity: 0.7 }}>{label}</div>
<output style={{ ...valueStyle, display: "block", fontSize: 22, fontWeight: 600, marginTop: 4 }}>{value}</output>
</div>
);
const HomarrDevtoolsPanel = ({ active }: { active: boolean }) => {
const pathname = usePathname();
const queryClient = useQueryClient();
const [diagnostics, setDiagnostics] = useState(() => readDiagnostics(queryClient));
useEffect(() => {
if (!active) return;
const update = () => setDiagnostics(readDiagnostics(queryClient));
let frame: number | undefined;
const scheduleUpdate = () => {
if (frame !== undefined) return;
frame = window.requestAnimationFrame(() => {
frame = undefined;
update();
});
};
const unsubscribeQueries = queryClient.getQueryCache().subscribe(scheduleUpdate);
const unsubscribeMutations = queryClient.getMutationCache().subscribe(scheduleUpdate);
const observer = new MutationObserver(scheduleUpdate);
observer.observe(document.body, { childList: true, subtree: true });
update();
return () => {
unsubscribeQueries();
unsubscribeMutations();
observer.disconnect();
if (frame !== undefined) window.cancelAnimationFrame(frame);
};
}, [active, pathname, queryClient]);
if (!active) return null;
return (
<section data-homarr-devtools-panel style={{ fontFamily: "system-ui, sans-serif", padding: 16 }}>
<header style={{ marginBottom: 16 }}>
<h2 style={{ margin: 0 }}>Homarr performance</h2>
<p style={{ marginBlock: "4px 0", opacity: 0.7 }}>Live state for {pathname}</p>
</header>
<div style={{ display: "grid", gap: 8, gridTemplateColumns: "repeat(auto-fit, minmax(110px, 1fr))" }}>
<Metric label="Queries" value={diagnostics.total} />
<Metric label="Active" value={diagnostics.active} />
<Metric label="Fetching" value={diagnostics.fetching} />
<Metric label="Stale" value={diagnostics.stale} />
<Metric label="Errors" value={diagnostics.errors} />
<Metric label="Mutations" value={diagnostics.pendingMutations} />
<Metric label="Widgets ready" value={`${diagnostics.readyWidgets}/${diagnostics.widgetItems}`} />
<Metric label="Script KiB" value={diagnostics.scriptKiB} />
</div>
</section>
);
};
const developmentToolsPlugins = [
{
id: "tanstack-query",
name: "TanStack Query",
render: () => <ReactQueryDevtoolsPanel />,
},
{
id: "homarr-performance",
name: "Homarr",
defaultOpen: true,
render: (_element, { devtoolsOpen }) => <HomarrDevtoolsPanel active={devtoolsOpen} />,
},
] satisfies TanStackDevtoolsReactPlugin[];
export const DevelopmentTools = () => (
<TanStackDevtools config={{ position: "bottom-right", hideUntilHover: true }} plugins={developmentToolsPlugins} />
);
@@ -0,0 +1,32 @@
import { createAsyncStoragePersister } from "@tanstack/query-async-storage-persister";
import superjson from "superjson";
import { fetchApi } from "@homarr/api/client";
import { getActiveQueryCacheBoardId, queryCacheStoragePrefix } from "@homarr/api/query-cache";
const queryCacheStorage = {
getItem: (_key: string) => null as string | null,
async setItem(_key: string, value: string) {
const boardId = getActiveQueryCacheBoardId();
if (!boardId) return;
await fetchApi.queryCache.setItem.mutate({ boardId, value }).catch((error) => {
console.warn("[query-cache] persist failed", error);
});
},
async removeItem(_key: string) {
const boardId = getActiveQueryCacheBoardId();
if (!boardId) return;
await fetchApi.queryCache.removeItem.mutate({ boardId }).catch((error) => {
console.warn("[query-cache] remove failed", error);
});
},
};
export const createWidgetQueryPersister = () =>
createAsyncStoragePersister({
storage: typeof window === "undefined" ? undefined : queryCacheStorage,
key: queryCacheStoragePrefix,
throttleTime: 2000,
serialize: (data) => superjson.stringify(data),
deserialize: (data) => superjson.parse(data),
});
@@ -1,27 +0,0 @@
import { describe, expect, it } from "vitest";
import { TRPCClientError } from "@trpc/client";
import { createQueryRetry } from "./query-retry";
const createTrpcError = (code: string) => {
const error = Object.assign(new Error(code), { data: { code } });
Object.setPrototypeOf(error, TRPCClientError.prototype);
return error;
};
describe("createQueryRetry", () => {
it.each(["BAD_REQUEST", "UNAUTHORIZED", "FORBIDDEN", "NOT_FOUND", "UNPROCESSABLE_CONTENT"])(
"does not retry the permanent %s response",
(code) => {
expect(createQueryRetry(3)(0, createTrpcError(code))).toBe(false);
},
);
it("retries transient and network failures only up to the configured limit", () => {
const retry = createQueryRetry(3);
expect(retry(0, createTrpcError("INTERNAL_SERVER_ERROR"))).toBe(true);
expect(retry(2, new Error("connection reset"))).toBe(true);
expect(retry(3, new Error("connection reset"))).toBe(false);
});
});
@@ -1,20 +0,0 @@
import { TRPCClientError } from "@trpc/client";
const nonRetryableTrpcCodes = new Set([
"PARSE_ERROR",
"BAD_REQUEST",
"UNAUTHORIZED",
"FORBIDDEN",
"NOT_FOUND",
"METHOD_NOT_SUPPORTED",
"CONFLICT",
"PRECONDITION_FAILED",
"PAYLOAD_TOO_LARGE",
"UNPROCESSABLE_CONTENT",
]);
export const createQueryRetry = (maxRetries: number) => (failureCount: number, error: unknown) => {
if (failureCount >= maxRetries) return false;
if (error instanceof TRPCClientError && error.data?.code && nonRetryableTrpcCodes.has(error.data.code)) return false;
return true;
};
@@ -1,52 +0,0 @@
// @vitest-environment jsdom
import { act, createElement } from "react";
import { createRoot } from "react-dom/client";
import { describe, expect, it, vi } from "vitest";
import { getSessionQueryScope, SessionQueryScopeGuard } from "./session-query-scope";
describe("getSessionQueryScope", () => {
it("is stable when permission order changes", () => {
expect(getSessionQueryScope({ user: { id: "user-a", permissions: ["board-view", "integration-use"] } })).toBe(
getSessionQueryScope({ user: { id: "user-a", permissions: ["integration-use", "board-view"] } }),
);
});
it("changes for a different user or permission set", () => {
const original = getSessionQueryScope({ user: { id: "user-a", permissions: ["board-view"] } });
expect(getSessionQueryScope({ user: { id: "user-b", permissions: ["board-view"] } })).not.toBe(original);
expect(getSessionQueryScope({ user: { id: "user-a", permissions: [] } })).not.toBe(original);
expect(getSessionQueryScope(null)).toBeNull();
});
it("removes the previous session subtree before requesting a document reload", async () => {
const host = document.createElement("div");
const root = createRoot(host);
const onScopeChange = vi.fn();
const renderScope = async (currentScope: string) => {
await act(async () => {
root.render(
createElement(
SessionQueryScopeGuard,
{ currentScope, initialScope: "a", onScopeChange },
createElement("span", null, "session-a-secret"),
),
);
});
};
try {
await renderScope("a");
expect(host.textContent).toBe("session-a-secret");
await renderScope("b");
expect(host.textContent).not.toContain("session-a-secret");
expect(host.querySelector('[role="status"]')?.getAttribute("aria-busy")).toBe("true");
expect(onScopeChange).toHaveBeenCalledOnce();
} finally {
await act(async () => root.unmount());
}
});
});
@@ -1,39 +0,0 @@
import type { PropsWithChildren } from "react";
import { createElement, useEffect } from "react";
interface QuerySession {
user: {
id: string;
permissions: string[];
};
}
export const getSessionQueryScope = (session: QuerySession | null | undefined) =>
session ? JSON.stringify([session.user.id, session.user.permissions.toSorted()]) : null;
interface SessionQueryScopeGuardProps {
initialScope: string | null;
currentScope: string | null;
onScopeChange: () => void;
}
export const SessionQueryScopeGuard = ({
initialScope,
currentScope,
onScopeChange,
children,
}: PropsWithChildren<SessionQueryScopeGuardProps>) => {
const hasScopeChanged = initialScope !== currentScope;
useEffect(() => {
if (hasScopeChanged) onScopeChange();
}, [hasScopeChanged, onScopeChange]);
if (!hasScopeChanged) return children;
return createElement("div", {
"aria-busy": true,
"aria-label": "Refreshing session",
role: "status",
style: { minHeight: "100dvh" },
});
};
@@ -1,10 +1,12 @@
"use client"; "use client";
import type { PropsWithChildren } from "react"; import type { PropsWithChildren } from "react";
import { useEffect, useMemo, useState } from "react"; import { useState } from "react";
import dynamic from "next/dynamic"; import type { QueryKey } from "@tanstack/react-query";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { QueryClient } from "@tanstack/react-query";
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
import { ReactQueryStreamedHydration } from "@tanstack/react-query-next-experimental"; import { ReactQueryStreamedHydration } from "@tanstack/react-query-next-experimental";
import { PersistQueryClientProvider } from "@tanstack/react-query-persist-client";
import { import {
createWSClient, createWSClient,
httpBatchStreamLink, httpBatchStreamLink,
@@ -23,24 +25,18 @@ import { TRPCClientError } from "@trpc/client";
import type { AppRouter } from "@homarr/api"; import type { AppRouter } from "@homarr/api";
import { clientApi } from "@homarr/api/client"; import { clientApi } from "@homarr/api/client";
import { import {
dashboardSupportingQueryPolicies, isPersistableWidgetQueryKey,
queryCacheBuster,
queryCacheDefaultGcTimeMs, queryCacheDefaultGcTimeMs,
queryCacheDefaultRefetchIntervalMs, queryCacheDefaultRefetchIntervalMs,
queryCacheDefaultStaleTimeMs, queryCacheDefaultStaleTimeMs,
} from "@homarr/api/query-cache"; } from "@homarr/api/query-cache";
import { createHeadersCallbackForSource, getTrpcUrl } from "@homarr/api/shared"; import { createHeadersCallbackForSource, getTrpcUrl } from "@homarr/api/shared";
import { useSession } from "@homarr/auth/client";
import { env } from "@homarr/common/env"; import { env } from "@homarr/common/env";
import { showWarningNotification } from "@homarr/notifications"; import { showWarningNotification } from "@homarr/notifications";
import { widgetQueryRefetchIntervals } from "@homarr/widgets/refetch-intervals"; import { widgetImports } from "@homarr/widgets";
import { getSessionQueryScope, SessionQueryScopeGuard } from "./session-query-scope"; import { createWidgetQueryPersister } from "./query-cache-persister";
import { createQueryRetry } from "./query-retry";
const DevelopmentTools =
process.env.NODE_ENV === "development"
? dynamic(() => import("./development-tools").then(({ DevelopmentTools: Tools }) => Tools), { ssr: false })
: () => null;
const getWebSocketProtocol = () => { const getWebSocketProtocol = () => {
if (typeof window === "undefined") { if (typeof window === "undefined") {
@@ -63,46 +59,21 @@ const constructWebsocketUrl = () => {
return `${getWebSocketProtocol()}://${window.location.hostname}:${window.location.port}/websockets`; return `${getWebSocketProtocol()}://${window.location.hostname}:${window.location.port}/websockets`;
}; };
export function TRPCReactProvider({ children }: PropsWithChildren) { const wsClient = createWSClient({
const { data: session } = useSession(); url: constructWebsocketUrl(),
const sessionQueryScope = getSessionQueryScope(session); });
const [initialSessionQueryScope] = useState(() => sessionQueryScope);
return ( export function TRPCReactProvider(props: PropsWithChildren) {
<SessionQueryScopeGuard const [persister] = useState(() => createWidgetQueryPersister());
initialScope={initialSessionQueryScope}
currentScope={sessionQueryScope}
onScopeChange={reloadPage}
>
<ScopedTRPCReactProvider>{children}</ScopedTRPCReactProvider>
</SessionQueryScopeGuard>
);
}
const reloadPage = () => window.location.reload();
const ScopedTRPCReactProvider = ({ children }: PropsWithChildren) => {
const wsClient = useMemo(
() =>
createWSClient({
url: constructWebsocketUrl(),
lazy: { enabled: true, closeMs: 30_000 },
}),
[],
);
useEffect(
() => () => {
void wsClient.close();
},
[wsClient],
);
const [queryClient] = useState(() => { const [queryClient] = useState(() => {
const client = new QueryClient({ const client = new QueryClient({
defaultOptions: { defaultOptions: {
queries: { queries: {
staleTime: queryCacheDefaultStaleTimeMs, staleTime: queryCacheDefaultStaleTimeMs,
gcTime: queryCacheDefaultGcTimeMs, gcTime: queryCacheDefaultGcTimeMs,
retry: createQueryRetry(env.NODE_ENV === "development" ? 1 : 3), refetchOnWindowFocus: false,
refetchOnReconnect: false,
retry: 3,
}, },
mutations: { mutations: {
onError(error) { onError(error) {
@@ -120,29 +91,23 @@ const ScopedTRPCReactProvider = ({ children }: PropsWithChildren) => {
}, },
}, },
}); });
client.setQueryDefaults([["widget"]], { client.setQueryDefaults([["widget"]], { refetchInterval: queryCacheDefaultRefetchIntervalMs });
refetchInterval: queryCacheDefaultRefetchIntervalMs, for (const { definition } of Object.values(widgetImports)) {
refetchOnWindowFocus: false, const def = definition as { refetchInterval?: number | null; queryKey?: QueryKey; kind: string };
refetchOnReconnect: false, if (def.refetchInterval === undefined) continue;
}); const key = def.queryKey ?? [["widget", def.kind]];
for (const { queryKey, intervalSeconds } of widgetQueryRefetchIntervals) { const interval = def.refetchInterval === null ? false : def.refetchInterval * 1000;
client.setQueryDefaults(queryKey, { client.setQueryDefaults(key, { refetchInterval: interval });
refetchInterval: intervalSeconds === null ? false : intervalSeconds * 1000,
});
}
for (const { queryKey, ...policy } of dashboardSupportingQueryPolicies) {
client.setQueryDefaults(queryKey, policy);
} }
return client; return client;
}); });
useEffect(() => () => queryClient.clear(), [queryClient]); const [trpcClient] = useState(() => {
const trpcClient = useMemo(() => {
return clientApi.createClient({ return clientApi.createClient({
links: [ links: [
loggerLink({ loggerLink({
enabled: (opts) => opts.direction === "down" && opts.result instanceof Error, enabled: (opts) =>
env.NODE_ENV === "development" || (opts.direction === "down" && opts.result instanceof Error),
}), }),
splitLink({ splitLink({
condition: ({ type }) => type === "subscription", condition: ({ type }) => type === "subscription",
@@ -182,14 +147,25 @@ const ScopedTRPCReactProvider = ({ children }: PropsWithChildren) => {
}), }),
], ],
}); });
}, [wsClient]); });
return ( return (
<clientApi.Provider client={trpcClient} queryClient={queryClient}> <clientApi.Provider client={trpcClient} queryClient={queryClient}>
<QueryClientProvider client={queryClient}> <PersistQueryClientProvider
<ReactQueryStreamedHydration transformer={superjson}>{children}</ReactQueryStreamedHydration> client={queryClient}
{process.env.NODE_ENV === "development" && <DevelopmentTools />} persistOptions={{
</QueryClientProvider> persister,
buster: queryCacheBuster,
maxAge: queryCacheDefaultGcTimeMs,
dehydrateOptions: {
shouldDehydrateQuery: (query) =>
query.state.status === "success" && isPersistableWidgetQueryKey(query.queryKey),
},
}}
>
<ReactQueryStreamedHydration transformer={superjson}>{props.children}</ReactQueryStreamedHydration>
<ReactQueryDevtools initialIsOpen={false} />
</PersistQueryClientProvider>
</clientApi.Provider> </clientApi.Provider>
); );
}; }
@@ -1,9 +1,13 @@
import { getHomeBoardAsync } from "@homarr/api/board-server"; import { cache } from "react";
import { api } from "@homarr/api/server";
import { createBoardContentPage } from "../_creator"; import { createBoardContentPage } from "../_creator";
const getHomeBoardCachedAsync = cache(async () => await api.board.getHomeBoard());
export default createBoardContentPage<{ locale: string }>({ export default createBoardContentPage<{ locale: string }>({
async getInitialBoardAsync() { async getInitialBoardAsync() {
return await getHomeBoardAsync(); return await getHomeBoardCachedAsync();
}, },
}); });
@@ -1,9 +1,13 @@
import { getBoardByNameAsync } from "@homarr/api/board-server"; import { cache } from "react";
import { api } from "@homarr/api/server";
import { createBoardContentPage } from "../../_creator"; import { createBoardContentPage } from "../../_creator";
const getBoardByNameCachedAsync = cache(async (name: string) => await api.board.getBoardByName({ name }));
export default createBoardContentPage<{ locale: string; name: string }>({ export default createBoardContentPage<{ locale: string; name: string }>({
async getInitialBoardAsync({ name }) { async getInitialBoardAsync({ name }) {
return await getBoardByNameAsync(name); return await getBoardByNameCachedAsync(name);
}, },
}); });
@@ -0,0 +1,20 @@
.columns {
display: grid;
align-items: start;
gap: 24px;
}
.lane {
min-width: 0;
}
.gutter {
position: sticky;
top: 0;
align-self: start;
max-height: calc(100dvh - var(--app-shell-header-offset, 0px) - var(--mantine-spacing-md));
overflow-y: auto;
overscroll-behavior: contain;
scrollbar-gutter: stable;
transform: translateY(var(--board-gutter-sticky-offset, 0px));
}
@@ -1,33 +1,168 @@
"use client"; "use client";
import { Box, Stack } from "@mantine/core"; import { useEffect, useRef } from "react";
import { Box } from "@mantine/core";
import { useCurrentLayout, useRequiredBoard } from "@homarr/boards/context"; import { useCurrentLayout, useInitialViewportWidth, useRequiredBoard } from "@homarr/boards/context";
import { useScopedI18n } from "@homarr/translation/client";
import { BoardCategorySection } from "~/components/board/sections/category-section"; import {
getBoardLaneColumnCount,
getInitialBoardLogicalHeight,
getLogicalTrackSize,
LOGICAL_GRID_GAP,
getRootSectionForLane,
} from "~/components/board/layout";
import { ScaledBoardCanvas } from "~/components/board/layout/scaled-board-canvas";
import { BoardEmptySection } from "~/components/board/sections/empty-section"; import { BoardEmptySection } from "~/components/board/sections/empty-section";
import { BoardGridPortalHost } from "~/components/board/sections/grid/grid-portal-host";
import { BoardSectionCollapseProvider } from "~/components/board/sections/section-collapse";
import { BoardBackgroundVideo } from "~/components/layout/background"; import { BoardBackgroundVideo } from "~/components/layout/background";
import classes from "./_client.module.css";
const APP_SHELL_INLINE_PADDING = 32;
const useFixedBoardGutters = () => {
const columnsRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const columns = columnsRef.current;
if (!columns) return;
let frame = 0;
const update = () => {
frame = 0;
const columnsRect = columns.getBoundingClientRect();
const headerBottom = document.querySelector("header")?.getBoundingClientRect().bottom ?? 0;
const stickyTop = Math.max(0, headerBottom + 16);
for (const gutter of columns.querySelectorAll<HTMLElement>("[data-board-gutter]")) {
const rect = gutter.getBoundingClientRect();
if (gutter.offsetWidth <= 0) continue;
const effectiveScale = rect.width / gutter.offsetWidth;
if (!Number.isFinite(effectiveScale) || effectiveScale <= 0) continue;
const previousOffset = Number(gutter.dataset.stickyOffset ?? 0);
const uncorrectedTop = rect.top - previousOffset * effectiveScale;
const maximumTop = columnsRect.bottom - rect.height;
const targetTop = Math.min(stickyTop, maximumTop);
const nextOffset = Math.max(0, targetTop - uncorrectedTop) / effectiveScale;
gutter.dataset.stickyOffset = String(nextOffset);
gutter.style.setProperty("--board-gutter-sticky-offset", `${nextOffset}px`);
}
};
const scheduleUpdate = () => {
if (frame !== 0) return;
frame = window.requestAnimationFrame(update);
};
const resizeObserver = new ResizeObserver(scheduleUpdate);
resizeObserver.observe(columns);
document.addEventListener("scroll", scheduleUpdate, { capture: true, passive: true });
window.addEventListener("resize", scheduleUpdate, { passive: true });
scheduleUpdate();
return () => {
window.cancelAnimationFrame(frame);
resizeObserver.disconnect();
document.removeEventListener("scroll", scheduleUpdate, { capture: true });
window.removeEventListener("resize", scheduleUpdate);
};
}, []);
return columnsRef;
};
export const ClientBoard = () => { export const ClientBoard = () => {
const board = useRequiredBoard(); const board = useRequiredBoard();
const t = useScopedI18n("board.landmark");
const currentLayoutId = useCurrentLayout(); const currentLayoutId = useCurrentLayout();
const initialViewportWidth = useInitialViewportWidth();
const columnsRef = useFixedBoardGutters();
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 fullWidthSortedSections = board.sections const mainSection = getRootSectionForLane(board, "main");
.filter((section) => section.kind === "empty" || section.kind === "category") const leftSection = getRootSectionForLane(board, "left");
.toSorted((sectionA, sectionB) => sectionA.yOffset - sectionB.yOffset); 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);
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(" ");
return ( return (
<Box h="100%" pos="relative" data-homarr-dev-benchmark-board> <Box h="100%" pos="relative">
<BoardBackgroundVideo /> <BoardBackgroundVideo />
<Stack h="100%"> <BoardSectionCollapseProvider>
{fullWidthSortedSections.map((section) => <BoardGridPortalHost>
section.kind === "empty" ? ( <ScaledBoardCanvas
<BoardEmptySection key={`${currentLayoutId}-${section.id}`} section={section} /> logicalWidth={logicalWidth}
) : ( initialLogicalHeight={initialLogicalHeight}
<BoardCategorySection key={`${currentLayoutId}-${section.id}`} section={section} /> initialAvailableWidth={initialAvailableWidth}
), label={board.name}
)} >
</Stack> <div ref={columnsRef} className={classes.columns} style={{ gridTemplateColumns }}>
{leftColumnCount > 0 && leftSection && (
<aside
className={`${classes.lane} ${classes.gutter}`}
aria-label={t("leftRail")}
data-board-gutter="left"
>
<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"
>
<BoardEmptySection
key={`${currentLayoutId}-${rightSection.id}`}
section={rightSection}
columnCount={rightColumnCount}
requestedRowCount={0}
railPlacement="right"
/>
</aside>
)}
</div>
</ScaledBoardCanvas>
</BoardGridPortalHost>
</BoardSectionCollapseProvider>
</Box> </Box>
); );
}; };
@@ -1,19 +1,19 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import { cache } from "react"; import { Suspense } from "react";
import { TRPCError } from "@trpc/server"; 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 { dehydrate, HydrationBoundary } from "@tanstack/react-query";
import { makeQueryClient } from "@homarr/api/shared"; import type { PersistedClient } from "@tanstack/react-query-persist-client";
import { IntegrationProvider } from "@homarr/auth/client"; import superjson from "superjson";
import { queryCacheBuster } from "@homarr/api/query-cache";
import { getQueryClient } from "@homarr/api/server";
import { auth } from "@homarr/auth/next"; import { auth } from "@homarr/auth/next";
import { getIntegrationsWithPermissionsAsync } from "@homarr/auth/server"; import { getIntegrationsWithPermissionsAsync } from "@homarr/auth/server";
import { isNullOrWhitespace } from "@homarr/common"; import { isNullOrWhitespace } from "@homarr/common";
import { createLogger } from "@homarr/core/infrastructure/logs"; import { createLogger } from "@homarr/core/infrastructure/logs";
import { ErrorWithMetadata } from "@homarr/core/infrastructure/logs/error"; import { ErrorWithMetadata } from "@homarr/core/infrastructure/logs/error";
import type { WidgetKind } from "@homarr/definitions"; import type { WidgetKind } from "@homarr/definitions";
import { getQueryCacheAsync } from "@homarr/redis";
import { getI18n } from "@homarr/translation/server"; import { getI18n } from "@homarr/translation/server";
import { prefetchForKindAsync } from "@homarr/widgets/prefetch"; import { prefetchForKindAsync } from "@homarr/widgets/prefetch";
@@ -21,11 +21,11 @@ import { createMetaTitle } from "~/metadata";
import { env } from "~/env"; import { env } from "~/env";
import { createBoardLayout } from "../_layout-creator"; import { createBoardLayout } from "../_layout-creator";
import type { Board, Item } from "../_types"; import type { Board, Item } from "../_types";
import { DynamicClientBoard } from "./_dynamic-client"; import { ClientBoard } from "./_client";
import { BoardContentHeaderActions } from "./_header-actions"; import { BoardContentHeaderActions } from "./_header-actions";
import { BoardIntegrationProvider } from "./_integration-provider";
const logger = createLogger({ module: "createBoardContentPage" }); const logger = createLogger({ module: "createBoardContentPage" });
const getQueryClient = cache(makeQueryClient);
export type Params = Record<string, unknown>; export type Params = Record<string, unknown>;
@@ -58,6 +58,7 @@ export const createBoardContentPage = <TParams extends Record<string, unknown>>(
} }
return acc; return acc;
}, new Map<WidgetKind, Item[]>()); }, new Map<WidgetKind, Item[]>());
const [integrations] = await Promise.all([ const [integrations] = await Promise.all([
getIntegrationsWithPermissionsAsync(session), getIntegrationsWithPermissionsAsync(session),
...Array.from(itemsMap).map(([kind, items]) => ...Array.from(itemsMap).map(([kind, items]) =>
@@ -73,12 +74,19 @@ export const createBoardContentPage = <TParams extends Record<string, unknown>>(
), ),
]); ]);
const userId = session?.user.id ?? "anonymous";
return ( return (
<HydrationBoundary state={dehydrate(queryClient)}> <>
<IntegrationProvider integrations={integrations}> <Suspense>
<DynamicClientBoard /> <QueryCacheHydration userId={userId} boardId={board.id} />
</IntegrationProvider> </Suspense>
</HydrationBoundary> <HydrationBoundary state={dehydrate(queryClient)}>
<BoardIntegrationProvider initialIntegrations={integrations}>
<ClientBoard />
</BoardIntegrationProvider>
</HydrationBoundary>
</>
); );
}, },
generateMetadataAsync: async ({ params }: { params: Promise<TParams> }): Promise<Metadata> => { generateMetadataAsync: async ({ params }: { params: Promise<TParams> }): Promise<Metadata> => {
@@ -109,3 +117,19 @@ export const createBoardContentPage = <TParams extends Record<string, unknown>>(
}, },
}; };
}; };
async function QueryCacheHydration({ userId, boardId }: { userId: string; boardId: string }) {
try {
const serialized = await getQueryCacheAsync(userId, boardId);
if (!serialized) return null;
const persisted = superjson.parse<PersistedClient | undefined>(serialized);
if (!persisted || persisted.buster !== queryCacheBuster) return null;
if (!persisted.clientState?.queries?.length) return null;
return <HydrationBoundary state={persisted.clientState} />;
} catch (error) {
logger.warn(new ErrorWithMetadata("Failed to hydrate query cache", { userId, boardId }, { cause: error }));
return null;
}
}
@@ -1,24 +0,0 @@
"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,8 +1,7 @@
"use client"; "use client";
import { useCallback } from "react";
import { Group, Menu } from "@mantine/core"; import { Group, Menu } from "@mantine/core";
import { IconBox, IconBoxAlignTop, IconChevronDown, IconPlug, IconPlus, IconResize } from "@tabler/icons-react"; import { IconBox, IconChevronDown, IconLayoutGridAdd, IconPlug, IconPlus, IconResize } from "@tabler/icons-react";
import { useSession } from "@homarr/auth/client"; import { useSession } from "@homarr/auth/client";
import { useModalAction } from "@homarr/modals"; import { useModalAction } from "@homarr/modals";
@@ -11,42 +10,47 @@ import { useI18n } from "@homarr/translation/client";
import { useItemActions } from "~/components/board/items/item-actions"; import { useItemActions } from "~/components/board/items/item-actions";
import { ItemSelectModal } from "~/components/board/items/item-select-modal"; import { ItemSelectModal } from "~/components/board/items/item-select-modal";
import { useCategoryActions } from "~/components/board/sections/category/category-actions";
import { CategoryEditModal } from "~/components/board/sections/category/category-edit-modal";
import { useDynamicSectionActions } from "~/components/board/sections/dynamic/dynamic-actions"; import { useDynamicSectionActions } from "~/components/board/sections/dynamic/dynamic-actions";
import { IntegrationSelectModal } from "~/components/integration/integration-select-modal"; import { IntegrationSelectModal } from "~/components/integration/integration-select-modal";
import { HeaderButton } from "~/components/layout/header/button"; import { HeaderButton } from "~/components/layout/header/button";
export const BoardAddMenu = () => { export default function BoardEditActions() {
return <AddMenu />;
}
const AddMenu = () => {
const { data: session } = useSession(); const { data: session } = useSession();
const { openModal: openCategoryEditModal } = useModalAction(CategoryEditModal);
const { openModal: openItemSelectModal } = useModalAction(ItemSelectModal); const { openModal: openItemSelectModal } = useModalAction(ItemSelectModal);
const { openModal: openAppSelectModal } = useModalAction(AppSelectModal); const { openModal: openAppSelectModal } = useModalAction(AppSelectModal);
const { openModal: openIntegrationSelectModal } = useModalAction(IntegrationSelectModal); const { openModal: openIntegrationSelectModal } = useModalAction(IntegrationSelectModal);
const { addCategoryToEnd } = useCategoryActions();
const { addDynamicSection } = useDynamicSectionActions(); const { addDynamicSection } = useDynamicSectionActions();
const { createItem } = useItemActions(); const { createItem } = useItemActions();
const t = useI18n(); const t = useI18n();
const handleAddCategory = useCallback( const handleSelectItem = () => {
() => openItemSelectModal();
openCategoryEditModal( };
{
category: { id: "new", name: "" }, const handleSelectApp = () => {
onSuccess({ name }) { openAppSelectModal({
addCategoryToEnd({ name }); onSelect: (app) => {
}, createItem({
submitLabel: t("section.category.create.submit"), kind: "app",
}, options: { appId: app.id },
{ title: (translate) => translate("section.category.create.title") }, });
), },
[addCategoryToEnd, openCategoryEditModal, t], withCreate: session?.user.permissions.includes("app-create") ?? false,
); });
};
const handleAddIntegration = () => {
openIntegrationSelectModal({});
};
return ( return (
<Menu position="bottom-end"> <Menu position="bottom-end">
<Menu.Target> <Menu.Target>
<HeaderButton w="auto" px={4}> <HeaderButton w="auto" px={4} aria-label={t("board.action.addContent")}>
<Group gap={4} wrap="nowrap"> <Group gap={4} wrap="nowrap">
<IconPlus stroke={1.5} /> <IconPlus stroke={1.5} />
<IconChevronDown color="gray" size={16} /> <IconChevronDown color="gray" size={16} />
@@ -54,28 +58,17 @@ export const BoardAddMenu = () => {
</HeaderButton> </HeaderButton>
</Menu.Target> </Menu.Target>
<Menu.Dropdown style={{ transform: "translate(-3px, 0)" }}> <Menu.Dropdown style={{ transform: "translate(-3px, 0)" }}>
<Menu.Item leftSection={<IconResize size={20} />} onClick={() => openItemSelectModal()}> <Menu.Item leftSection={<IconResize size={20} />} onClick={handleSelectItem}>
{t("item.action.create")} {t("item.action.create")}
</Menu.Item> </Menu.Item>
<Menu.Item <Menu.Item leftSection={<IconBox size={20} />} onClick={handleSelectApp}>
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")} {t("app.action.add")}
</Menu.Item> </Menu.Item>
<Menu.Item leftSection={<IconPlug size={20} />} onClick={() => openIntegrationSelectModal({})}> <Menu.Item leftSection={<IconPlug size={20} />} onClick={handleAddIntegration}>
{t("integration.action.create")} {t("integration.action.create")}
</Menu.Item> </Menu.Item>
<Menu.Divider /> <Menu.Divider />
<Menu.Item leftSection={<IconBoxAlignTop size={20} />} onClick={handleAddCategory}> <Menu.Item leftSection={<IconLayoutGridAdd size={20} />} onClick={addDynamicSection}>
{t("section.category.action.create")}
</Menu.Item>
<Menu.Item leftSection={<IconResize size={20} />} onClick={addDynamicSection}>
{t("section.dynamic.action.create")} {t("section.dynamic.action.create")}
</Menu.Item> </Menu.Item>
</Menu.Dropdown> </Menu.Dropdown>
@@ -1,11 +1,12 @@
"use client"; "use client";
import { useCallback, useEffect, useState } from "react"; import { startTransition, useCallback, useEffect, useState } from "react";
import dynamic from "next/dynamic"; import dynamic from "next/dynamic";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { Box, Center, Loader, Menu, ScrollArea } from "@mantine/core"; import { OnboardingTour } from "@gfazioli/mantine-onboarding-tour";
import { Box, Menu, ScrollArea } from "@mantine/core";
import { useHotkeys } from "@mantine/hooks"; import { useHotkeys } from "@mantine/hooks";
import { IconLayoutBoard, IconPencil, IconPencilOff, IconPlus, IconReplace, IconSettings } from "@tabler/icons-react"; import { IconLayoutBoard, IconPencil, IconPencilOff, IconReplace, IconSettings } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client"; import { clientApi } from "@homarr/api/client";
import { useRequiredBoard } from "@homarr/boards/context"; import { useRequiredBoard } from "@homarr/boards/context";
@@ -19,22 +20,31 @@ import { useI18n, useScopedI18n } from "@homarr/translation/client";
import { Link } from "@homarr/ui"; import { Link } from "@homarr/ui";
import { useBoardPermissions } from "~/components/board/permissions/client"; 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 { HeaderButton } from "~/components/layout/header/button";
import { TourTarget } from "~/components/layout/header/tour-target"; import type * as EditActionsModule from "./_edit-actions";
const loadBoardAddMenu = () => import("./_board-add-menu"); let editActionsModulePromise: Promise<typeof EditActionsModule> | undefined;
const preloadBoardAddMenu = () => void loadBoardAddMenu().catch(() => undefined); const loadEditActionsAsync = () => {
const BoardAddMenu = dynamic(() => loadBoardAddMenu().then(({ BoardAddMenu: AddMenu }) => AddMenu), { editActionsModulePromise ??= import("./_edit-actions").catch((error: unknown) => {
loading: () => ( editActionsModulePromise = undefined;
<HeaderButton loading> throw error;
<IconPlus stroke={1.5} /> });
</HeaderButton> return editActionsModulePromise;
), };
}); const loadBoardEditorAsync = async () =>
await Promise.all([
loadGridEditorAsync(),
loadEditActionsAsync(),
import("~/components/board/items/item-menu"),
import("~/components/board/sections/dynamic/dynamic-menu"),
]);
const BoardEditActions = dynamic(loadEditActionsAsync, { ssr: false });
export const BoardContentHeaderActions = ({ demoReadOnly }: { demoReadOnly: boolean }) => { export const BoardContentHeaderActions = ({ demoReadOnly }: { demoReadOnly: boolean }) => {
const [isEditMode] = useEditMode(); const [isEditMode] = useEditMode();
const board = useRequiredBoard(); const board = useRequiredBoard();
const t = useI18n();
const { hasChangeAccess } = useBoardPermissions(board); const { hasChangeAccess } = useBoardPermissions(board);
if (!hasChangeAccess) { if (!hasChangeAccess) {
@@ -43,16 +53,16 @@ export const BoardContentHeaderActions = ({ demoReadOnly }: { demoReadOnly: bool
return ( return (
<> <>
{isEditMode && <BoardAddMenu />} {isEditMode && <BoardEditActions />}
<EditModeMenu demoReadOnly={demoReadOnly} /> <EditModeMenu demoReadOnly={demoReadOnly} />
{!demoReadOnly && ( {!demoReadOnly && (
<TourTarget id="board-settings"> <OnboardingTour.Target id="board-settings">
<HeaderButton href={`/boards/${board.name}/settings`}> <HeaderButton href={`/boards/${board.name}/settings`} aria-label={t("board.action.settings")}>
<IconSettings stroke={1.5} /> <IconSettings stroke={1.5} />
</HeaderButton> </HeaderButton>
</TourTarget> </OnboardingTour.Target>
)} )}
<SelectBoardsMenu /> <SelectBoardsMenu />
@@ -62,6 +72,8 @@ export const BoardContentHeaderActions = ({ demoReadOnly }: { demoReadOnly: bool
const EditModeMenu = ({ demoReadOnly }: { demoReadOnly: boolean }) => { const EditModeMenu = ({ demoReadOnly }: { demoReadOnly: boolean }) => {
const [isEditMode, { open, close }] = useEditMode(); const [isEditMode, { open, close }] = useEditMode();
const [editorLoadState, setEditorLoadState] = useState<"scheduled" | "loading" | "ready" | "error">("scheduled");
const [isEnteringEditMode, setIsEnteringEditMode] = useState(false);
const board = useRequiredBoard(); const board = useRequiredBoard();
const utils = clientApi.useUtils(); const utils = clientApi.useUtils();
const t = useScopedI18n("board.action.edit"); const t = useScopedI18n("board.action.edit");
@@ -88,53 +100,94 @@ const EditModeMenu = ({ demoReadOnly }: { demoReadOnly: boolean }) => {
close(); close();
}, [utils, board.name, close]); }, [utils, board.name, close]);
const toggle = useCallback(() => { 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 () => {
if (isEditMode) { if (isEditMode) {
if (demoReadOnly) return discardDemoChanges(); if (demoReadOnly) return discardDemoChanges();
return saveBoard(board); return saveBoard(board);
} }
open();
}, [board, isEditMode, demoReadOnly, saveBoard, open, discardDemoChanges]);
useHotkeys([[hotkeys.toggleBoardEdit, toggle]]); setIsEnteringEditMode(true);
try {
await prepareEditorAsync();
startTransition(open);
} catch {
showErrorNotification({
title: t("notification.loadError.title"),
message: t("notification.loadError.message"),
});
} finally {
setIsEnteringEditMode(false);
}
}, [board, demoReadOnly, discardDemoChanges, isEditMode, open, prepareEditorAsync, saveBoard, t]);
useHotkeys([[hotkeys.toggleBoardEdit, () => void toggle()]]);
usePreventLeaveWithDirty(isEditMode); usePreventLeaveWithDirty(isEditMode);
return ( return (
<TourTarget id="board-edit-mode"> <OnboardingTour.Target id="board-edit-mode">
<HeaderButton <HeaderButton
onClick={toggle} onClick={() => void toggle()}
loading={isPending} onFocus={prewarmEditor}
onFocus={preloadBoardAddMenu} onPointerEnter={prewarmEditor}
onPointerEnter={preloadBoardAddMenu} loading={isPending || isEnteringEditMode}
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-pressed={isEditMode}
> >
{isEditMode ? <IconPencilOff stroke={1.5} /> : <IconPencil stroke={1.5} />} {isEditMode ? <IconPencilOff stroke={1.5} /> : <IconPencil stroke={1.5} />}
</HeaderButton> </HeaderButton>
</TourTarget> </OnboardingTour.Target>
); );
}; };
const SelectBoardsMenu = () => { const SelectBoardsMenu = () => {
const [isOpen, setIsOpen] = useState(false); const { data: boards = [] } = clientApi.board.getAllBoards.useQuery();
const utils = clientApi.useUtils(); const t = useI18n();
const { data: boards = [], isPending } = clientApi.board.getAllBoards.useQuery(undefined, { enabled: isOpen });
const preloadBoards = () => void utils.board.getAllBoards.prefetch();
return ( return (
<TourTarget id="board-switcher"> <OnboardingTour.Target id="board-switcher">
<Box onFocus={preloadBoards} onPointerEnter={preloadBoards}> <Box>
<Menu position="bottom-end" opened={isOpen} onChange={setIsOpen}> <Menu position="bottom-end">
<Menu.Target> <Menu.Target>
<HeaderButton w="auto" px={4}> <HeaderButton w="auto" px={4} aria-label={t("board.action.switch")}>
<IconReplace stroke={1.5} /> <IconReplace stroke={1.5} />
</HeaderButton> </HeaderButton>
</Menu.Target> </Menu.Target>
<Menu.Dropdown style={{ transform: "translate(-7px, 0)" }}> <Menu.Dropdown style={{ transform: "translate(-7px, 0)" }}>
<ScrollArea.Autosize mah={300}> <ScrollArea.Autosize mah={300}>
{isPending && (
<Center p="xs">
<Loader size="xs" />
</Center>
)}
{boards.map((board) => ( {boards.map((board) => (
<Menu.Item <Menu.Item
key={board.id} key={board.id}
@@ -149,7 +202,7 @@ const SelectBoardsMenu = () => {
</Menu.Dropdown> </Menu.Dropdown>
</Menu> </Menu>
</Box> </Box>
</TourTarget> </OnboardingTour.Target>
); );
}; };
@@ -162,13 +215,15 @@ const usePreventLeaveWithDirty = (isDirty: boolean) => {
useEffect(() => { useEffect(() => {
if (!isDirty) return; if (!isDirty) return;
const handleClick = (event: MouseEvent) => { const handleClick = (event: Event) => {
if (event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return; const target = (event.target as HTMLElement).closest("a");
const target = event.target instanceof Element ? event.target.closest<HTMLAnchorElement>(anchorSelector) : null;
if (!target) return; if (!target) {
console.warn("No anchor element found for click event", event);
return;
}
event.preventDefault(); event.preventDefault();
event.stopPropagation();
openConfirmModal({ openConfirmModal({
title: t("board.action.edit.confirmLeave.title"), title: t("board.action.edit.confirmLeave.title"),
@@ -194,12 +249,17 @@ const usePreventLeaveWithDirty = (isDirty: boolean) => {
event.returnValue = true; event.returnValue = true;
}; };
document.addEventListener("click", handleClick, true); const anchors = document.querySelectorAll(anchorSelector);
anchors.forEach((link) => {
link.addEventListener("click", handleClick);
});
window.addEventListener("popstate", handlePopState); window.addEventListener("popstate", handlePopState);
window.addEventListener("beforeunload", handleBeforeUnload); window.addEventListener("beforeunload", handleBeforeUnload);
return () => { return () => {
document.removeEventListener("click", handleClick, true); anchors.forEach((link) => {
link.removeEventListener("click", handleClick);
});
window.removeEventListener("popstate", handlePopState); window.removeEventListener("popstate", handlePopState);
window.removeEventListener("beforeunload", handleBeforeUnload); window.removeEventListener("beforeunload", handleBeforeUnload);
}; };
@@ -0,0 +1,54 @@
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);
});
});
@@ -0,0 +1,14 @@
"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,4 +1,4 @@
import { getBoardByNameAsync } from "@homarr/api/board-server"; import { api } from "@homarr/api/server";
import { BoardOtherHeaderActions } from "../_header-actions"; import { BoardOtherHeaderActions } from "../_header-actions";
import { createBoardLayout } from "../_layout-creator"; import { createBoardLayout } from "../_layout-creator";
@@ -6,6 +6,6 @@ import { createBoardLayout } from "../_layout-creator";
export default createBoardLayout<{ locale: string; name: string }>({ export default createBoardLayout<{ locale: string; name: string }>({
headerActions: <BoardOtherHeaderActions />, headerActions: <BoardOtherHeaderActions />,
async getInitialBoardAsync({ name }) { async getInitialBoardAsync({ name }) {
return await getBoardByNameAsync(name); return await api.board.getBoardByName({ name });
}, },
}); });
@@ -19,106 +19,130 @@ import {
import { useDisclosure } from "@mantine/hooks"; import { useDisclosure } from "@mantine/hooks";
import { IconX } from "@tabler/icons-react"; import { IconX } from "@tabler/icons-react";
import type { UseFormReturnType } from "@homarr/form"; import { useZodForm } from "@homarr/form";
import { useI18n } from "@homarr/translation/client"; import { useI18n } from "@homarr/translation/client";
import { boardSavePartialSettingsSchema } from "@homarr/validation/board";
import { SectionCard } from "~/components/manage/section-card"; import type { Board } from "../../_types";
import { generateColors } from "../../(content)/_theme"; import { generateColors } from "../../(content)/_theme";
import type { FormValues } from "./_settings-form"; import { useSavePartialSettingsMutation } from "./_shared";
interface Props { interface Props {
form: UseFormReturnType<FormValues>; board: Board;
} }
const hexRegex = /^#[0-9a-fA-F]{6}$/; const hexRegex = /^#[0-9a-fA-F]{6}$/;
const progressPercentageLabel = (value: number) => `${value}%`; const progressPercentageLabel = (value: number) => `${value}%`;
export const ColorSettingsContent = ({ form }: Props) => { export const ColorSettingsContent = ({ board }: Props) => {
const form = useZodForm(boardSavePartialSettingsSchema, {
initialValues: {
primaryColor: board.primaryColor,
secondaryColor: board.secondaryColor,
opacity: board.opacity,
iconColor: board.iconColor ?? "",
itemRadius: board.itemRadius,
},
});
const [showPreview, { toggle }] = useDisclosure(false); const [showPreview, { toggle }] = useDisclosure(false);
const t = useI18n(); const t = useI18n();
const theme = useMantineTheme(); const theme = useMantineTheme();
const { mutate: savePartialSettings, isPending } = useSavePartialSettingsMutation(board);
return ( return (
<SectionCard title={t("board.setting.section.appearance.title")}> <form
<Grid> onSubmit={form.onSubmit((values) => {
<Grid.Col span={{ sm: 12, md: 6 }}> savePartialSettings({
<Stack gap="xs"> id: board.id,
<ColorInput ...values,
label={t("board.field.primaryColor.label")} });
format="hex" })}
swatches={Object.values(theme.colors).map((color) => color[6])} >
{...form.getInputProps("primaryColor")} <Stack>
/> <Grid>
</Stack> <Grid.Col span={{ sm: 12, md: 6 }}>
</Grid.Col> <Stack gap="xs">
<Grid.Col span={{ sm: 12, md: 6 }}> <ColorInput
<ColorInput label={t("board.field.primaryColor.label")}
label={t("board.field.secondaryColor.label")} format="hex"
format="hex" swatches={Object.values(theme.colors).map((color) => color[6])}
swatches={Object.values(theme.colors).map((color) => color[6])} {...form.getInputProps("primaryColor")}
{...form.getInputProps("secondaryColor")} />
/>
</Grid.Col>
<Grid.Col span={12}>
<Anchor onClick={toggle}>{showPreview ? t("common.preview.hide") : t("common.preview.show")}</Anchor>
</Grid.Col>
<Grid.Col span={12}>
<Collapse expanded={showPreview}>
<Stack>
<ColorsPreview previewColor={form.values.primaryColor} />
<ColorsPreview previewColor={form.values.secondaryColor} />
</Stack> </Stack>
</Collapse> </Grid.Col>
</Grid.Col> <Grid.Col span={{ sm: 12, md: 6 }}>
<Grid.Col span={{ sm: 12, md: 6 }}>
<InputWrapper label={t("board.field.opacity.label")}>
<Slider
my={6}
min={0}
max={100}
step={5}
label={progressPercentageLabel}
{...form.getInputProps("opacity")}
/>
</InputWrapper>
</Grid.Col>
<Grid.Col span={{ sm: 12, md: 6 }}>
<Group align="end">
<ColorInput <ColorInput
label={t("board.field.iconColor.label")} label={t("board.field.secondaryColor.label")}
format="hex" format="hex"
swatches={Object.values(theme.colors).map((color) => color[6])} swatches={Object.values(theme.colors).map((color) => color[6])}
flex={1} {...form.getInputProps("secondaryColor")}
{...form.getInputProps("iconColor")}
/> />
<Button </Grid.Col>
type="button" <Grid.Col span={12}>
variant="subtle" <Anchor onClick={toggle}>{showPreview ? t("common.preview.hide") : t("common.preview.show")}</Anchor>
leftSection={<IconX />} </Grid.Col>
onClick={() => form.setFieldValue("iconColor", "")} <Grid.Col span={12}>
disabled={!form.values.iconColor} <Collapse expanded={showPreview}>
> <Stack>
{t("board.field.clearColor.label")} <ColorsPreview previewColor={form.values.primaryColor} />
</Button> <ColorsPreview previewColor={form.values.secondaryColor} />
</Group> </Stack>
</Grid.Col> </Collapse>
<Grid.Col span={{ sm: 12, md: 6 }}> </Grid.Col>
<Select <Grid.Col span={{ sm: 12, md: 6 }}>
label={t("board.field.itemRadius.label")} <InputWrapper label={t("board.field.opacity.label")}>
description={t("board.field.itemRadius.description")} <Slider
data={[ my={6}
{ label: t("board.field.itemRadius.option.xs"), value: "xs" }, min={0}
{ label: t("board.field.itemRadius.option.sm"), value: "sm" }, max={100}
{ label: t("board.field.itemRadius.option.md"), value: "md" }, step={5}
{ label: t("board.field.itemRadius.option.lg"), value: "lg" }, label={progressPercentageLabel}
{ label: t("board.field.itemRadius.option.xl"), value: "xl" }, {...form.getInputProps("opacity")}
]} />
{...form.getInputProps("itemRadius")} </InputWrapper>
/> </Grid.Col>
</Grid.Col> <Grid.Col span={{ sm: 12, md: 6 }}>
</Grid> <Group align="end">
</SectionCard> <ColorInput
label={t("board.field.iconColor.label")}
format="hex"
swatches={Object.values(theme.colors).map((color) => color[6])}
flex={1}
{...form.getInputProps("iconColor")}
/>
<Button
variant="subtle"
leftSection={<IconX />}
onClick={() => form.setFieldValue("iconColor", "")}
disabled={!form.values.iconColor}
>
{t("board.field.clearColor.label")}
</Button>
</Group>
</Grid.Col>
<Grid.Col span={{ sm: 12, md: 6 }}>
<Select
label={t("board.field.itemRadius.label")}
description={t("board.field.itemRadius.description")}
data={[
{ label: t("board.field.itemRadius.option.xs"), value: "xs" },
{ label: t("board.field.itemRadius.option.sm"), value: "sm" },
{ label: t("board.field.itemRadius.option.md"), value: "md" },
{ label: t("board.field.itemRadius.option.lg"), value: "lg" },
{ label: t("board.field.itemRadius.option.xl"), value: "xl" },
]}
{...form.getInputProps("itemRadius")}
/>
</Grid.Col>
</Grid>
<Group justify="end">
<Button type="submit" loading={isPending}>
{t("common.action.saveChanges")}
</Button>
</Group>
</Stack>
</form>
); );
}; };
@@ -1,32 +1,41 @@
"use client"; "use client";
import { startTransition } from "react"; import { startTransition } from "react";
import { ActionIcon, Autocomplete, Center, Grid, Group, Popover, Stack, Text } from "@mantine/core"; import { ActionIcon, Autocomplete, Button, Center, Grid, Group, Popover, Stack, Text } from "@mantine/core";
import { useDebouncedValue } from "@mantine/hooks"; import { useDebouncedValue } from "@mantine/hooks";
import { IconPhotoOff, IconUpload } from "@tabler/icons-react"; import { IconPhotoOff, IconUpload } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client"; import { clientApi } from "@homarr/api/client";
import { useSession } from "@homarr/auth/client"; import { useSession } from "@homarr/auth/client";
import { backgroundImageAttachments, backgroundImageRepeats, backgroundImageSizes } from "@homarr/definitions"; import { backgroundImageAttachments, backgroundImageRepeats, backgroundImageSizes } from "@homarr/definitions";
import type { UseFormReturnType } from "@homarr/form"; import { useZodForm } from "@homarr/form";
import { UploadMedia } from "@homarr/forms-collection"; import { UploadMedia } from "@homarr/forms-collection";
import type { TranslationObject } from "@homarr/translation"; import type { TranslationObject } from "@homarr/translation";
import { useI18n } from "@homarr/translation/client"; import { useI18n } from "@homarr/translation/client";
import type { SelectItemWithDescriptionBadge } from "@homarr/ui"; import type { SelectItemWithDescriptionBadge } from "@homarr/ui";
import { SelectWithDescriptionBadge } from "@homarr/ui"; import { SelectWithDescriptionBadge } from "@homarr/ui";
import { boardSavePartialSettingsSchema } from "@homarr/validation/board";
import { SectionCard } from "~/components/manage/section-card"; import type { Board } from "../../_types";
import type { FormValues } from "./_settings-form"; import { useSavePartialSettingsMutation } from "./_shared";
interface Props { interface Props {
form: UseFormReturnType<FormValues>; board: Board;
} }
export const BackgroundSettingsContent = ({ board }: Props) => {
export const BackgroundSettingsContent = ({ form }: Props) => {
const t = useI18n(); const t = useI18n();
const { data: session } = useSession(); const { data: session } = useSession();
const { mutate: savePartialSettings, isPending } = useSavePartialSettingsMutation(board);
const form = useZodForm(boardSavePartialSettingsSchema, {
initialValues: {
backgroundImageUrl: board.backgroundImageUrl ?? "",
backgroundImageAttachment: board.backgroundImageAttachment,
backgroundImageRepeat: board.backgroundImageRepeat,
backgroundImageSize: board.backgroundImageSize,
},
});
const [debouncedSearch] = useDebouncedValue(form.values.backgroundImageUrl ?? "", 200); const [debouncedSearch] = useDebouncedValue(form.values.backgroundImageUrl, 200);
const medias = clientApi.media.getPaginated.useQuery({ const medias = clientApi.media.getPaginated.useQuery({
page: 1, page: 1,
pageSize: 10, pageSize: 10,
@@ -44,106 +53,121 @@ export const BackgroundSettingsContent = ({ form }: Props) => {
const backgroundImageRepeatData = useBackgroundOptionData("backgroundImageRepeat", backgroundImageRepeats); const backgroundImageRepeatData = useBackgroundOptionData("backgroundImageRepeat", backgroundImageRepeats);
return ( return (
<SectionCard title={t("board.setting.section.background.title")}> <form
<Grid> onSubmit={form.onSubmit((values) => {
<Grid.Col span={12}> savePartialSettings({
<Group wrap="nowrap" gap="xs" w="100%" align="start"> id: board.id,
<Autocomplete ...values,
flex={1} });
leftSection={ })}
form.values.backgroundImageUrl && >
form.values.backgroundImageUrl.trim().length >= 2 && ( <Stack>
<Popover width={300} withArrow> <Grid>
<Popover.Target> <Grid.Col span={12}>
<Center h="100%"> <Group wrap="nowrap" gap="xs" w="100%" align="start">
<ImagePreview src={form.values.backgroundImageUrl} w={20} h={20} /> <Autocomplete
</Center> flex={1}
</Popover.Target> leftSection={
<Popover.Dropdown> form.values.backgroundImageUrl &&
<ImagePreview src={form.values.backgroundImageUrl} w="100%" /> form.values.backgroundImageUrl.trim().length >= 2 && (
</Popover.Dropdown> <Popover width={300} withArrow>
</Popover> <Popover.Target>
) <Center h="100%">
} <ImagePreview src={form.values.backgroundImageUrl} w={20} h={20} />
// We filter it on the server </Center>
filter={({ options }) => options} </Popover.Target>
label={t("board.field.backgroundImageUrl.label")} <Popover.Dropdown>
placeholder={`${t("board.field.backgroundImageUrl.placeholder")}...`} <ImagePreview src={form.values.backgroundImageUrl} w="100%" />
renderOption={({ option }) => { </Popover.Dropdown>
const current = imageMap.get(option.value); </Popover>
if (!current) return null; )
}
// We filter it on the server
filter={({ options }) => options}
label={t("board.field.backgroundImageUrl.label")}
placeholder={`${t("board.field.backgroundImageUrl.placeholder")}...`}
renderOption={({ option }) => {
const current = imageMap.get(option.value);
if (!current) return null;
return ( return (
<Group gap="sm"> <Group gap="sm">
<ImagePreview src={option.value} w={20} h={20} /> <ImagePreview src={option.value} w={20} h={20} />
<Stack gap={0}> <Stack gap={0}>
<Text size="sm">{current.name}</Text> <Text size="sm">{current.name}</Text>
<Text size="xs" c="dimmed"> <Text size="xs" c="dimmed">
{option.value} {option.value}
</Text> </Text>
</Stack> </Stack>
</Group> </Group>
); );
}}
data={[
{
group: t("board.field.backgroundImageUrl.group.your"),
items: images
.filter((media) => media.creatorId === session?.user.id)
.map((media) => `/api/user-medias/${media.id}`),
},
{
group: t("board.field.backgroundImageUrl.group.other"),
items: images
.filter((media) => media.creatorId !== session?.user.id)
.map((media) => `/api/user-medias/${media.id}`),
},
]}
{...form.getInputProps("backgroundImageUrl")}
/>
{session?.user.permissions.includes("media-upload") && (
<UploadMedia
onSuccess={(uploadedMedias) => {
const first = uploadedMedias.at(0);
if (!first) return;
startTransition(() => {
form.setFieldValue("backgroundImageUrl", first.url);
});
}} }}
> data={[
{({ onClick, loading }) => ( {
<ActionIcon onClick={onClick} loading={loading} mt={24} size={36} variant="default"> group: t("board.field.backgroundImageUrl.group.your"),
<IconUpload size={16} stroke={1.5} /> items: images
</ActionIcon> .filter((media) => media.creatorId === session?.user.id)
)} .map((media) => `/api/user-medias/${media.id}`),
</UploadMedia> },
)} {
</Group> group: t("board.field.backgroundImageUrl.group.other"),
</Grid.Col> items: images
<Grid.Col span={12}> .filter((media) => media.creatorId !== session?.user.id)
<SelectWithDescriptionBadge .map((media) => `/api/user-medias/${media.id}`),
label={t("board.field.backgroundImageAttachment.label")} },
data={backgroundImageAttachmentData} ]}
{...form.getInputProps("backgroundImageAttachment")} {...form.getInputProps("backgroundImageUrl")}
/> />
</Grid.Col> {session?.user.permissions.includes("media-upload") && (
<Grid.Col span={12}> <UploadMedia
<SelectWithDescriptionBadge onSuccess={(medias) => {
label={t("board.field.backgroundImageSize.label")} const first = medias.at(0);
data={backgroundImageSizeData} if (!first) return;
{...form.getInputProps("backgroundImageSize")}
/> startTransition(() => {
</Grid.Col> form.setFieldValue("backgroundImageUrl", first.url);
<Grid.Col span={12}> });
<SelectWithDescriptionBadge }}
label={t("board.field.backgroundImageRepeat.label")} >
data={backgroundImageRepeatData} {({ onClick, loading }) => (
{...form.getInputProps("backgroundImageRepeat")} <ActionIcon onClick={onClick} loading={loading} mt={24} size={36} variant="default">
/> <IconUpload size={16} stroke={1.5} />
</Grid.Col> </ActionIcon>
</Grid> )}
</SectionCard> </UploadMedia>
)}
</Group>
</Grid.Col>
<Grid.Col span={12}>
<SelectWithDescriptionBadge
label={t("board.field.backgroundImageAttachment.label")}
data={backgroundImageAttachmentData}
{...form.getInputProps("backgroundImageAttachment")}
/>
</Grid.Col>
<Grid.Col span={12}>
<SelectWithDescriptionBadge
label={t("board.field.backgroundImageSize.label")}
data={backgroundImageSizeData}
{...form.getInputProps("backgroundImageSize")}
/>
</Grid.Col>
<Grid.Col span={12}>
<SelectWithDescriptionBadge
label={t("board.field.backgroundImageRepeat.label")}
data={backgroundImageRepeatData}
{...form.getInputProps("backgroundImageRepeat")}
/>
</Grid.Col>
</Grid>
<Group justify="end">
<Button type="submit" loading={isPending}>
{t("common.action.saveChanges")}
</Button>
</Group>
</Stack>
</form>
); );
}; };
@@ -1,27 +1,48 @@
"use client"; "use client";
import { Switch } from "@mantine/core"; import { Button, Group, Stack, Switch } from "@mantine/core";
import type { UseFormReturnType } from "@homarr/form"; import { useForm } from "@homarr/form";
import { useI18n } from "@homarr/translation/client"; import { useI18n } from "@homarr/translation/client";
import { SectionCard } from "~/components/manage/section-card"; import type { Board } from "../../_types";
import type { FormValues } from "./_settings-form"; import { useSavePartialSettingsMutation } from "./_shared";
interface Props { interface Props {
form: UseFormReturnType<FormValues>; board: Board;
} }
export const BehaviorSettingsContent = ({ form }: Props) => { export const BehaviorSettingsContent = ({ board }: Props) => {
const t = useI18n(); const t = useI18n();
const { mutate: savePartialSettings, isPending } = useSavePartialSettingsMutation(board);
const form = useForm({
initialValues: {
disableStatus: board.disableStatus,
},
});
return ( return (
<SectionCard title={t("board.setting.section.behavior.title")}> <form
<Switch onSubmit={form.onSubmit((values) => {
label={t("board.field.disableStatus.label")} savePartialSettings({
description={t("board.field.disableStatus.description")} id: board.id,
{...form.getInputProps("disableStatus", { type: "checkbox" })} ...values,
/> });
</SectionCard> })}
>
<Stack>
<Switch
label={t("board.field.disableStatus.label")}
description={t("board.field.disableStatus.description")}
{...form.getInputProps("disableStatus", { type: "checkbox" })}
/>
<Group justify="end">
<Button type="submit" loading={isPending}>
{t("common.action.saveChanges")}
</Button>
</Group>
</Stack>
</form>
); );
}; };
@@ -1,6 +1,6 @@
"use client"; "use client";
import { Alert, Input } from "@mantine/core"; import { Alert, Button, Group, Input, Stack } from "@mantine/core";
import { highlight, languages } from "prismjs"; import { highlight, languages } from "prismjs";
import Editor from "react-simple-code-editor"; import Editor from "react-simple-code-editor";
@@ -8,39 +8,59 @@ import "~/styles/prismjs.scss";
import { IconInfoCircle } from "@tabler/icons-react"; import { IconInfoCircle } from "@tabler/icons-react";
import type { UseFormReturnType } from "@homarr/form"; import { useForm } from "@homarr/form";
import { useI18n, useScopedI18n } from "@homarr/translation/client"; import { useI18n, useScopedI18n } from "@homarr/translation/client";
import { SectionCard } from "~/components/manage/section-card"; import type { Board } from "../../_types";
import type { FormValues } from "./_settings-form"; import { useSavePartialSettingsMutation } from "./_shared";
import classes from "./customcss.module.css"; import classes from "./customcss.module.css";
interface Props { interface Props {
form: UseFormReturnType<FormValues>; board: Board;
} }
export const CustomCssSettingsContent = ({ form }: Props) => { export const CustomCssSettingsContent = ({ board }: Props) => {
const t = useI18n(); const t = useI18n();
const customCssT = useScopedI18n("board.field.customCss"); const customCssT = useScopedI18n("board.field.customCss");
const { mutate: savePartialSettings, isPending } = useSavePartialSettingsMutation(board);
const form = useForm({
initialValues: {
customCss: board.customCss ?? "",
},
});
return ( return (
<SectionCard title={t("board.setting.section.customCss.title")}> <form
<CustomCssInput {...form.getInputProps("customCss")} /> onSubmit={form.onSubmit((values) => {
savePartialSettings({
id: board.id,
...values,
});
})}
>
<Stack>
<CustomCssInput {...form.getInputProps("customCss")} />
<Alert variant="light" color="cyan" title={customCssT("customClassesAlert.title")} icon={<IconInfoCircle />}> <Alert variant="light" color="cyan" title={customCssT("customClassesAlert.title")} icon={<IconInfoCircle />}>
{customCssT("customClassesAlert.description")} {customCssT("customClassesAlert.description")}
</Alert> </Alert>
</SectionCard>
<Group justify="end">
<Button type="submit" loading={isPending}>
{t("common.action.saveChanges")}
</Button>
</Group>
</Stack>
</form>
); );
}; };
interface CustomCssInputProps { interface CustomCssInputProps {
value?: string; value?: string;
onChange: (value: string) => void; onChange: (value: string) => void;
error?: React.ReactNode;
} }
const CustomCssInput = ({ value, onChange, error }: CustomCssInputProps) => { const CustomCssInput = ({ value, onChange }: CustomCssInputProps) => {
const customCssT = useScopedI18n("board.field.customCss"); const customCssT = useScopedI18n("board.field.customCss");
return ( return (
@@ -50,7 +70,6 @@ const CustomCssInput = ({ value, onChange, error }: CustomCssInputProps) => {
htmlFor: "custom-css", htmlFor: "custom-css",
}} }}
description={customCssT("description")} description={customCssT("description")}
error={error}
inputWrapperOrder={["label", "description", "input", "error"]} inputWrapperOrder={["label", "description", "input", "error"]}
> >
<div className={classes.codeEditorRoot}> <div className={classes.codeEditorRoot}>
@@ -2,7 +2,7 @@
import { useCallback } from "react"; import { useCallback } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { Button } from "@mantine/core"; import { Button, Divider, Group, Stack, Text } from "@mantine/core";
import { clientApi } from "@homarr/api/client"; import { clientApi } from "@homarr/api/client";
import { useRequiredBoard } from "@homarr/boards/context"; import { useRequiredBoard } from "@homarr/boards/context";
@@ -10,7 +10,7 @@ import { useConfirmModal, useModalAction } from "@homarr/modals";
import { useScopedI18n } from "@homarr/translation/client"; import { useScopedI18n } from "@homarr/translation/client";
import { BoardRenameModal } from "~/components/board/modals/board-rename-modal"; import { BoardRenameModal } from "~/components/board/modals/board-rename-modal";
import { DangerZoneItem, DangerZoneRoot } from "~/components/manage/danger-zone"; import classes from "./danger.module.css";
export const DangerZoneSettingsContent = ({ hideVisibility }: { hideVisibility: boolean }) => { export const DangerZoneSettingsContent = ({ hideVisibility }: { hideVisibility: boolean }) => {
const board = useRequiredBoard(); const board = useRequiredBoard();
@@ -82,36 +82,60 @@ export const DangerZoneSettingsContent = ({ hideVisibility }: { hideVisibility:
}, [board.id, deleteBoard, router, t, openConfirmModal]); }, [board.id, deleteBoard, router, t, openConfirmModal]);
return ( return (
<DangerZoneRoot> <Stack gap="sm">
<DangerZoneItem <Divider />
<DangerZoneRow
label={t("section.dangerZone.action.rename.label")} label={t("section.dangerZone.action.rename.label")}
description={t("section.dangerZone.action.rename.description")} description={t("section.dangerZone.action.rename.description")}
action={ buttonText={t("section.dangerZone.action.rename.button")}
<Button variant="subtle" color="red" onClick={onRenameClick}> onClick={onRenameClick}
{t("section.dangerZone.action.rename.button")}
</Button>
}
/> />
{!hideVisibility && ( {hideVisibility ? null : (
<DangerZoneItem <>
label={t("section.dangerZone.action.visibility.label")} <Divider />
description={t(`section.dangerZone.action.visibility.description.${visibility}`)} <DangerZoneRow
action={ label={t("section.dangerZone.action.visibility.label")}
<Button variant="subtle" color="red" loading={isChangeVisibilityPending} onClick={onVisibilityClick}> description={t(`section.dangerZone.action.visibility.description.${visibility}`)}
{t(`section.dangerZone.action.visibility.button.${visibility}`)} buttonText={t(`section.dangerZone.action.visibility.button.${visibility}`)}
</Button> onClick={onVisibilityClick}
} isPending={isChangeVisibilityPending}
/> />
</>
)} )}
<DangerZoneItem <Divider />
<DangerZoneRow
label={t("section.dangerZone.action.delete.label")} label={t("section.dangerZone.action.delete.label")}
description={t("section.dangerZone.action.delete.description")} description={t("section.dangerZone.action.delete.description")}
action={ buttonText={t("section.dangerZone.action.delete.button")}
<Button variant="subtle" color="red" loading={isDeletePending} onClick={onDeleteClick}> onClick={onDeleteClick}
{t("section.dangerZone.action.delete.button")} isPending={isDeletePending}
</Button>
}
/> />
</DangerZoneRoot> </Stack>
);
};
interface DangerZoneRowProps {
label: string;
description: string;
buttonText: string;
isPending?: boolean;
onClick: () => void;
}
const DangerZoneRow = ({ label, description, buttonText, onClick, isPending }: DangerZoneRowProps) => {
return (
<Group justify="space-between" px="md" className={classes.dangerZoneGroup}>
<Stack gap={0}>
<Text fw="bold" size="sm">
{label}
</Text>
<Text size="sm">{description}</Text>
</Stack>
<Group justify="end" w={{ base: "100%", xs: "auto" }}>
<Button variant="subtle" color="red" loading={isPending} onClick={onClick}>
{buttonText}
</Button>
</Group>
</Group>
); );
}; };
@@ -1,66 +1,128 @@
"use client"; "use client";
import { useEffect } from "react"; import { useEffect, useRef } from "react";
import { Grid, Loader, TextInput } from "@mantine/core"; import { Button, Grid, Group, Loader, Stack, TextInput } from "@mantine/core";
import { useDebouncedValue, useDocumentTitle } from "@mantine/hooks"; import { useDebouncedValue, useDocumentTitle } from "@mantine/hooks";
import { useUpdateBoard } from "@homarr/boards/updater"; import { useUpdateBoard } from "@homarr/boards/updater";
import type { UseFormReturnType } from "@homarr/form"; import { useZodForm } from "@homarr/form";
import { IconPicker } from "@homarr/forms-collection"; import { IconPicker } from "@homarr/forms-collection";
import { useI18n } from "@homarr/translation/client"; import { useI18n } from "@homarr/translation/client";
import { boardSavePartialSettingsSchema } from "@homarr/validation/board";
import { SectionCard } from "~/components/manage/section-card"; import { homarrLogoPath } from "~/components/layout/logo/homarr-logo";
import { createMetaTitle } from "~/metadata"; import { createMetaTitle } from "~/metadata";
import type { Board } from "../../_types"; import type { Board } from "../../_types";
import type { FormValues } from "./_settings-form"; import { useSavePartialSettingsMutation } from "./_shared";
interface Props { interface Props {
board: Board; board: Board;
form: UseFormReturnType<FormValues>;
} }
export const GeneralSettingsContent = ({ board, form }: Props) => { export const GeneralSettingsContent = ({ board }: Props) => {
const t = useI18n(); const t = useI18n();
const ref = useRef({
pageTitle: board.pageTitle,
logoImageUrl: board.logoImageUrl,
});
const { updateBoard } = useUpdateBoard();
const { mutate: savePartialSettings, isPending } = useSavePartialSettingsMutation(board);
const form = useZodForm(
boardSavePartialSettingsSchema
.pick({
pageTitle: true,
logoImageUrl: true,
metaTitle: true,
faviconImageUrl: true,
})
.required(),
{
initialValues: {
pageTitle: board.pageTitle ?? "",
logoImageUrl: board.logoImageUrl ?? "",
metaTitle: board.metaTitle ?? "",
faviconImageUrl: board.faviconImageUrl ?? "",
},
onValuesChange({ pageTitle }) {
updateBoard((previous) => ({
...previous,
pageTitle,
}));
},
},
);
useLogoPreview(form.values.logoImageUrl); useLogoPreview(form.values.logoImageUrl);
const metaTitleStatus = useMetaTitlePreview(form.values.metaTitle); const metaTitleStatus = useMetaTitlePreview(form.values.metaTitle);
// Cleanup for not applied changes of the page title and logo image URL
useEffect(() => {
return () => {
updateBoard((previous) => ({
...previous,
pageTitle: ref.current.pageTitle,
logoImageUrl: ref.current.logoImageUrl,
}));
};
}, [updateBoard]);
return ( return (
<SectionCard title={t("board.setting.section.general.title")}> <form
<Grid> onSubmit={form.onSubmit((values) => {
<Grid.Col span={{ xs: 12, md: 6 }}> // Save the current values to the ref so that it does not reset if the form is submitted
<TextInput ref.current = {
label={t("board.field.pageTitle.label")} pageTitle: values.pageTitle,
placeholder="Homarr" logoImageUrl: values.logoImageUrl,
{...form.getInputProps("pageTitle")} };
/> updateFavicon(values.faviconImageUrl ?? homarrLogoPath);
</Grid.Col> savePartialSettings({
<Grid.Col span={{ xs: 12, md: 6 }}> id: board.id,
<TextInput ...values,
label={t("board.field.metaTitle.label")} });
placeholder={createMetaTitle(t("board.content.metaTitle", { boardName: board.name }))} })}
rightSection={metaTitleStatus.isPending && <Loader size="xs" />} >
{...form.getInputProps("metaTitle")} <Stack>
/> <Grid>
</Grid.Col> <Grid.Col span={{ xs: 12, md: 6 }}>
<Grid.Col span={{ xs: 12, md: 6 }}> <TextInput
<IconPicker label={t("board.field.pageTitle.label")}
{...form.getInputProps("logoImageUrl")} placeholder="Homarr"
label={t("board.field.logoImageUrl.label")} {...form.getInputProps("pageTitle")}
placeholder="/logo/logo.png" />
withAsterisk={false} </Grid.Col>
/> <Grid.Col span={{ xs: 12, md: 6 }}>
</Grid.Col> <TextInput
<Grid.Col span={{ xs: 12, md: 6 }}> label={t("board.field.metaTitle.label")}
<IconPicker placeholder={createMetaTitle(t("board.content.metaTitle", { boardName: board.name }))}
{...form.getInputProps("faviconImageUrl")} rightSection={metaTitleStatus.isPending && <Loader size="xs" />}
label={t("board.field.faviconImageUrl.label")} {...form.getInputProps("metaTitle")}
placeholder="/logo/logo.png" />
withAsterisk={false} </Grid.Col>
/> <Grid.Col span={{ xs: 12, md: 6 }}>
</Grid.Col> <IconPicker
</Grid> {...form.getInputProps("logoImageUrl")}
</SectionCard> label={t("board.field.logoImageUrl.label")}
placeholder="/logo/logo.png"
withAsterisk={false}
/>
</Grid.Col>
<Grid.Col span={{ xs: 12, md: 6 }}>
<IconPicker
{...form.getInputProps("faviconImageUrl")}
label={t("board.field.faviconImageUrl.label")}
placeholder="/logo/logo.png"
withAsterisk={false}
/>
</Grid.Col>
</Grid>
<Group justify="end">
<Button type="submit" loading={isPending}>
{t("common.action.saveChanges")}
</Button>
</Group>
</Stack>
</form>
); );
}; };
@@ -84,3 +146,14 @@ const useMetaTitlePreview = (title: string | null) => {
isPending: (title ?? "") !== metaTitleDebounced, isPending: (title ?? "") !== metaTitleDebounced,
}; };
}; };
// Previously we used the useFavicon hook from Mantine
// However if the user tried to click on a link to a different page
// it caused an error "Cannot read properties of null (reading 'removeChild')"
// Probably because the head element was null (https://github.com/mantinedev/mantine/blob/b90d9b81031f45f5d15e75f25138ed6477f65bce/packages/%40mantine/hooks/src/use-favicon/use-favicon.ts#L21)
// See https://github.com/homarr-labs/homarr/issues/4905
const updateFavicon = (url: string) => {
const link: HTMLLinkElement | null = document.querySelector('link[rel="icon"]');
if (!link) return;
link.href = url;
};
@@ -0,0 +1,47 @@
.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,80 +1,277 @@
"use client"; "use client";
import { Button, Fieldset, Grid, Group, Input, NumberInput, Slider, Stack, Text, TextInput } from "@mantine/core"; import {
Box,
Button,
Fieldset,
Grid,
Group,
Input,
NumberInput,
Paper,
Slider,
Stack,
Switch,
Text,
TextInput,
} from "@mantine/core";
import { clientApi } from "@homarr/api/client";
import { createId } from "@homarr/common"; import { createId } from "@homarr/common";
import type { UseFormReturnType } from "@homarr/form"; import { useZodForm } from "@homarr/form";
import { useI18n } from "@homarr/translation/client"; import { useI18n } from "@homarr/translation/client";
import { boardSaveLayoutsSchema } from "@homarr/validation/board";
import { SectionCard } from "~/components/manage/section-card"; import type { Board } from "../../_types";
import type { FormValues } from "./_settings-form"; import classes from "./_layout.module.css";
const layoutSettingsSchema = boardSaveLayoutsSchema.omit({ id: true });
interface Props { interface Props {
form: UseFormReturnType<FormValues>; board: Board;
} }
export const LayoutSettingsContent = ({ board }: Props) => {
export const LayoutSettingsContent = ({ form }: Props) => {
const t = useI18n(); const t = useI18n();
const utils = clientApi.useUtils();
const { mutate: saveLayouts, isPending } = clientApi.board.saveLayouts.useMutation({
onSettled() {
void utils.board.getBoardByName.invalidate({ name: board.name });
void utils.board.getHomeBoard.invalidate();
},
});
const form = useZodForm(layoutSettingsSchema, {
initialValues: {
layouts: board.layouts,
},
});
return ( return (
<SectionCard title={t("board.setting.section.layout.title")}> <form
<Stack gap="sm"> onSubmit={form.onSubmit((values) => {
<Group justify="space-between" align="center"> saveLayouts({
<Text fw={500}>{t("board.setting.section.layout.responsive.title")}</Text> id: board.id,
<Button layouts: values.layouts,
type="button" });
variant="subtle" })}
onClick={() => { >
form.setValues((previous) => ({ <Stack>
...previous, <Stack gap="sm">
layouts: [...(previous.layouts ?? []), { id: createId(), name: "", columnCount: 10, breakpoint: 0 }], <Group justify="space-between" align="center">
})); <Text fw={500}>{t("board.setting.section.layout.responsive.title")}</Text>
}} <Button
> variant="subtle"
{t("board.setting.section.layout.responsive.action.add")} onClick={() => {
form.setValues({
layouts: [
...form.values.layouts,
{
id: createId(),
name: "",
columnCount: 10,
leftGutterColumnCount: 0,
rightGutterColumnCount: 0,
breakpoint: 0,
},
],
});
}}
>
{t("board.setting.section.layout.responsive.action.add")}
</Button>
</Group>
{form.values.layouts.map((layout, index) => (
<Fieldset key={layout.id} legend={layout.name} bg="transparent">
<Grid>
<Grid.Col span={{ sm: 12, md: 6 }}>
<TextInput {...form.getInputProps(`layouts.${index}.name`)} label={t("layout.field.name.label")} />
</Grid.Col>
<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);
}}
/>
</Input.Wrapper>
</Grid.Col>
<Grid.Col span={{ sm: 12, md: 6 }}>
<NumberInput
{...form.getInputProps(`layouts.${index}.breakpoint`)}
label={t("layout.field.breakpoint.label")}
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">
<Button
variant="subtle"
onClick={() => {
form.setValues((previous) =>
previous.layouts !== undefined && previous.layouts.length >= 2
? {
layouts: form.values.layouts.filter((filteredLayout) => filteredLayout.id !== layout.id),
}
: previous,
);
}}
>
{t("common.action.remove")}
</Button>
</Group>
)}
</Fieldset>
))}
</Stack>
<Group justify="end">
<Button type="submit" loading={isPending}>
{t("common.action.saveChanges")}
</Button> </Button>
</Group> </Group>
{form.values.layouts.map((layout, index) => (
<Fieldset key={layout.id} legend={layout.name} bg="transparent">
<Grid>
<Grid.Col span={{ sm: 12, md: 6 }}>
<TextInput {...form.getInputProps(`layouts.${index}.name`)} label={t("layout.field.name.label")} />
</Grid.Col>
<Grid.Col span={{ sm: 12, md: 6 }}>
<Input.Wrapper label={t("layout.field.columnCount.label")}>
<Slider mt="xs" min={1} max={24} step={1} {...form.getInputProps(`layouts.${index}.columnCount`)} />
</Input.Wrapper>
</Grid.Col>
<Grid.Col span={{ sm: 12, md: 6 }}>
<NumberInput
{...form.getInputProps(`layouts.${index}.breakpoint`)}
label={t("layout.field.breakpoint.label")}
description={t("layout.field.breakpoint.description")}
/>
</Grid.Col>
</Grid>
{form.values.layouts.length >= 2 && (
<Group justify="end">
<Button
type="button"
variant="subtle"
onClick={() => {
form.setValues((previous) => ({
...previous,
layouts: form.values.layouts.filter((filteredLayout) => filteredLayout.id !== layout.id),
}));
}}
>
{t("common.action.remove")}
</Button>
</Group>
)}
</Fieldset>
))}
</Stack> </Stack>
</SectionCard> </form>
);
};
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,221 +0,0 @@
"use client";
import { useEffect, useRef } from "react";
import { Button, Stack } from "@mantine/core";
import type { z } from "zod/v4";
import type { RouterOutputs } from "@homarr/api";
import { useUpdateBoard } from "@homarr/boards/updater";
import { revalidatePathActionAsync } from "@homarr/common/client";
import { env } from "@homarr/common/env";
import { useZodForm } from "@homarr/form";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useI18n, useScopedI18n } from "@homarr/translation/client";
import { boardSaveLayoutsSchema, boardSavePartialSettingsSchema } from "@homarr/validation/board";
import { homarrLogoPath } from "~/components/layout/logo/homarr-logo";
import { SectionCard } from "~/components/manage/section-card";
import { UnsavedChangesBar } from "~/components/manage/unsaved-changes-bar";
import type { Board } from "../../_types";
import { ColorSettingsContent } from "./_appereance";
import { BackgroundSettingsContent } from "./_background";
import { BehaviorSettingsContent } from "./_behavior";
import { BoardAccessSettings } from "./_board-access";
import { CustomCssSettingsContent } from "./_customCss";
import { DangerZoneSettingsContent } from "./_danger";
import { GeneralSettingsContent } from "./_general";
import { LayoutSettingsContent } from "./_layout";
import { useSaveLayoutsMutation, useSavePartialSettingsMutation } from "./_shared";
const boardSettingsFormSchema = boardSavePartialSettingsSchema
.extend({
layouts: boardSaveLayoutsSchema.shape.layouts,
})
.required();
export type FormValues = z.infer<typeof boardSettingsFormSchema>;
const PARTIAL_FORM_KEYS = [
"pageTitle",
"metaTitle",
"logoImageUrl",
"faviconImageUrl",
"backgroundImageUrl",
"backgroundImageAttachment",
"backgroundImageRepeat",
"backgroundImageSize",
"primaryColor",
"secondaryColor",
"opacity",
"iconColor",
"itemRadius",
"customCss",
"disableStatus",
] as const;
const buildInitialValues = (board: Board): FormValues => ({
pageTitle: board.pageTitle ?? "",
metaTitle: board.metaTitle ?? "",
logoImageUrl: board.logoImageUrl ?? "",
faviconImageUrl: board.faviconImageUrl ?? "",
backgroundImageUrl: board.backgroundImageUrl ?? "",
backgroundImageAttachment: board.backgroundImageAttachment,
backgroundImageRepeat: board.backgroundImageRepeat,
backgroundImageSize: board.backgroundImageSize,
primaryColor: board.primaryColor,
secondaryColor: board.secondaryColor,
opacity: board.opacity,
iconColor: board.iconColor ?? "",
itemRadius: board.itemRadius,
customCss: board.customCss ?? "",
disableStatus: board.disableStatus,
layouts: board.layouts,
});
interface BoardSettingsFormProps {
board: Board;
permissions: RouterOutputs["board"]["getBoardPermissions"];
hasFullAccess: boolean;
hideVisibility: boolean;
}
export const BoardSettingsForm = ({ board, permissions, hasFullAccess, hideVisibility }: BoardSettingsFormProps) => {
const t = useI18n();
const tSection = useScopedI18n("board.setting.section");
const { updateBoard } = useUpdateBoard();
const savePartialSettings = useSavePartialSettingsMutation(board);
const saveLayouts = useSaveLayoutsMutation(board);
const form = useZodForm(boardSettingsFormSchema, {
initialValues: buildInitialValues(board),
onValuesChange({ pageTitle }) {
updateBoard((previous) => ({
...previous,
pageTitle,
}));
},
});
const initialValuesRef = useRef(buildInitialValues(board));
const lastSavedRef = useRef({ pageTitle: board.pageTitle, logoImageUrl: board.logoImageUrl });
const isDirtyRef = useRef(false);
isDirtyRef.current = form.isDirty();
useEffect(() => {
return () => {
updateBoard((previous) => ({
...previous,
pageTitle: lastSavedRef.current.pageTitle,
logoImageUrl: lastSavedRef.current.logoImageUrl,
}));
};
}, [updateBoard]);
useEffect(() => {
const handler = (event: BeforeUnloadEvent) => {
if (env.NODE_ENV === "development") return;
if (isDirtyRef.current) event.preventDefault();
};
window.addEventListener("beforeunload", handler);
return () => window.removeEventListener("beforeunload", handler);
}, []);
const handleSubmitAsync = async (values: FormValues) => {
const defaults = initialValuesRef.current;
const changed = <TKey extends keyof FormValues>(...fields: TKey[]) =>
fields.some((field) => values[field] !== defaults[field]);
const { layouts, ...partialSettings } = values;
const saveActions: { when: boolean; action: () => Promise<unknown> }[] = [
{
when: changed(...PARTIAL_FORM_KEYS),
action: () =>
savePartialSettings.mutateAsync({ id: board.id, ...partialSettings }).then(() => {
updateFavicon(values.faviconImageUrl ?? homarrLogoPath);
}),
},
{
when: changed("layouts"),
action: () => saveLayouts.mutateAsync({ id: board.id, layouts }),
},
];
const promises = saveActions.filter((saveAction) => saveAction.when).map((saveAction) => saveAction.action());
if (promises.length === 0) return;
try {
await Promise.all(promises);
lastSavedRef.current = { pageTitle: values.pageTitle, logoImageUrl: values.logoImageUrl };
initialValuesRef.current = values;
form.setInitialValues(values);
form.resetDirty();
await revalidatePathActionAsync(`/boards/${board.name}/settings`);
showSuccessNotification({
title: t("common.notification.update.success"),
message: t("common.notification.update.success"),
});
} catch {
showErrorNotification({
title: t("common.notification.update.error"),
message: t("common.notification.update.error"),
});
}
};
const handleDiscard = () => {
form.setValues(initialValuesRef.current);
form.resetDirty();
updateBoard((previous) => ({
...previous,
pageTitle: lastSavedRef.current.pageTitle,
logoImageUrl: lastSavedRef.current.logoImageUrl,
}));
};
const isPending = savePartialSettings.isPending || saveLayouts.isPending;
return (
<Stack gap="xl">
<form onSubmit={form.onSubmit((values) => void handleSubmitAsync(values))}>
<Stack gap="xl">
<GeneralSettingsContent board={board} form={form} />
<LayoutSettingsContent form={form} />
<BackgroundSettingsContent form={form} />
<ColorSettingsContent form={form} />
<CustomCssSettingsContent form={form} />
<BehaviorSettingsContent form={form} />
{form.isDirty() && (
<UnsavedChangesBar>
<Button type="button" disabled={isPending} variant="default" onClick={handleDiscard}>
{t("common.action.discard")}
</Button>
<Button loading={isPending} type="submit" disabled={!form.isValid()}>
{t("common.action.saveChanges")}
</Button>
</UnsavedChangesBar>
)}
</Stack>
</form>
{hasFullAccess && (
<SectionCard title={tSection("access.title")}>
<BoardAccessSettings board={board} initialPermissions={permissions} />
</SectionCard>
)}
{hasFullAccess && <DangerZoneSettingsContent hideVisibility={hideVisibility} />}
</Stack>
);
};
// Previously part of the general settings section; applied on the unified save
// https://github.com/homarr-labs/homarr/issues/4905
const updateFavicon = (url: string) => {
const link = document.querySelector<HTMLLinkElement>('link[rel="icon"]');
if (!link) return;
link.href = url;
};
@@ -11,13 +11,3 @@ export const useSavePartialSettingsMutation = (board: Board) => {
}, },
}); });
}; };
export const useSaveLayoutsMutation = (board: Board) => {
const utils = clientApi.useUtils();
return clientApi.board.saveLayouts.useMutation({
onSettled() {
void utils.board.getBoardByName.invalidate({ name: board.name });
void utils.board.getHomeBoard.invalidate();
},
});
};
@@ -0,0 +1,5 @@
@media (min-width: 36em) {
.dangerZoneGroup {
--group-wrap: nowrap !important;
}
}
@@ -1,20 +1,44 @@
import type { PropsWithChildren } from "react";
import { notFound } from "next/navigation"; import { notFound } from "next/navigation";
import { Container, Stack, Title } from "@mantine/core"; import { AccordionControl, AccordionItem, AccordionPanel, Container, Stack, Text, Title } from "@mantine/core";
import {
IconAlertTriangle,
IconBrush,
IconClick,
IconFileTypeCss,
IconLayout,
IconPhoto,
IconSettings,
IconUser,
} from "@tabler/icons-react";
import { TRPCError } from "@trpc/server"; import { TRPCError } from "@trpc/server";
import { api } from "@homarr/api/server"; import { api } from "@homarr/api/server";
import { capitalize } from "@homarr/common"; import { capitalize } from "@homarr/common";
import { db } from "@homarr/db"; import { db } from "@homarr/db";
import { getServerSettingByKeyAsync } from "@homarr/db/queries"; import { getServerSettingByKeyAsync } from "@homarr/db/queries";
import type { TranslationObject } from "@homarr/translation";
import { getScopedI18n } from "@homarr/translation/server"; import { getScopedI18n } from "@homarr/translation/server";
import type { TablerIcon } from "@homarr/ui";
import { getBoardPermissionsAsync } from "~/components/board/permissions/server"; import { getBoardPermissionsAsync } from "~/components/board/permissions/server";
import { BoardSettingsForm } from "./_settings-form"; import { ActiveTabAccordion } from "../../../../../components/active-tab-accordion";
import { ColorSettingsContent } from "./_appereance";
import { BackgroundSettingsContent } from "./_background";
import { BehaviorSettingsContent } from "./_behavior";
import { BoardAccessSettings } from "./_board-access";
import { CustomCssSettingsContent } from "./_customCss";
import { DangerZoneSettingsContent } from "./_danger";
import { GeneralSettingsContent } from "./_general";
import { LayoutSettingsContent } from "./_layout";
interface Props { interface Props {
params: Promise<{ params: Promise<{
name: string; name: string;
}>; }>;
searchParams: Promise<{
tab?: keyof TranslationObject["board"]["setting"]["section"];
}>;
} }
const getBoardAndPermissionsAsync = async (params: Awaited<Props["params"]>) => { const getBoardAndPermissionsAsync = async (params: Awaited<Props["params"]>) => {
@@ -33,7 +57,11 @@ const getBoardAndPermissionsAsync = async (params: Awaited<Props["params"]>) =>
} catch (error) { } catch (error) {
// Ignore not found errors and redirect to 404 // Ignore not found errors and redirect to 404
// error is already logged in _layout-creator.tsx // error is already logged in _layout-creator.tsx
if (error instanceof TRPCError && (error.code === "NOT_FOUND" || error.code === "BAD_REQUEST")) { if (error instanceof TRPCError && error.code === "NOT_FOUND") {
notFound();
}
if (error instanceof TRPCError && error.code === "BAD_REQUEST") {
notFound(); notFound();
} }
@@ -42,6 +70,7 @@ const getBoardAndPermissionsAsync = async (params: Awaited<Props["params"]>) =>
}; };
export default async function BoardSettingsPage(props: Props) { export default async function BoardSettingsPage(props: Props) {
const searchParams = await props.searchParams;
const params = await props.params; const params = await props.params;
const { board, permissions } = await getBoardAndPermissionsAsync(params); const { board, permissions } = await getBoardAndPermissionsAsync(params);
const boardSettings = await getServerSettingByKeyAsync(db, "board"); const boardSettings = await getServerSettingByKeyAsync(db, "board");
@@ -56,13 +85,76 @@ export default async function BoardSettingsPage(props: Props) {
<Container> <Container>
<Stack> <Stack>
<Title>{t("title", { boardName: capitalize(board.name) })}</Title> <Title>{t("title", { boardName: capitalize(board.name) })}</Title>
<BoardSettingsForm <ActiveTabAccordion variant="separated" defaultValue={searchParams.tab ?? "general"}>
board={board} <AccordionItemFor value="general" icon={IconSettings}>
permissions={permissions} <GeneralSettingsContent board={board} />
hasFullAccess={hasFullAccess} </AccordionItemFor>
hideVisibility={boardSettings.homeBoardId === board.id || boardSettings.mobileHomeBoardId === board.id} <AccordionItemFor value="layout" icon={IconLayout}>
/> <LayoutSettingsContent board={board} />
</AccordionItemFor>
<AccordionItemFor value="background" icon={IconPhoto}>
<BackgroundSettingsContent board={board} />
</AccordionItemFor>
<AccordionItemFor value="appearance" icon={IconBrush}>
<ColorSettingsContent board={board} />
</AccordionItemFor>
<AccordionItemFor value="customCss" icon={IconFileTypeCss}>
<CustomCssSettingsContent board={board} />
</AccordionItemFor>
<AccordionItemFor value="behavior" icon={IconClick}>
<BehaviorSettingsContent board={board} />
</AccordionItemFor>
{hasFullAccess && (
<>
<AccordionItemFor value="access" icon={IconUser}>
<BoardAccessSettings board={board} initialPermissions={permissions} />
</AccordionItemFor>
<AccordionItemFor value="dangerZone" icon={IconAlertTriangle} danger noPadding>
<DangerZoneSettingsContent
hideVisibility={
boardSettings.homeBoardId === board.id || boardSettings.mobileHomeBoardId === board.id
}
/>
</AccordionItemFor>
</>
)}
</ActiveTabAccordion>
</Stack> </Stack>
</Container> </Container>
); );
} }
type AccordionItemForProps = PropsWithChildren<{
value: keyof TranslationObject["board"]["setting"]["section"];
icon: TablerIcon;
danger?: boolean;
noPadding?: boolean;
}>;
const AccordionItemFor = async ({ value, children, icon: Icon, danger, noPadding }: AccordionItemForProps) => {
const t = await getScopedI18n("board.setting.section");
return (
<AccordionItem
value={value}
styles={
danger
? {
item: {
"--__item-border-color": "rgba(248,81,73,0.4)",
borderWidth: 4,
},
}
: undefined
}
>
<AccordionControl icon={<Icon />}>
<Text fw="bold" size="lg">
{t(`${value}.title`)}
</Text>
</AccordionControl>
<AccordionPanel styles={noPadding ? { content: { paddingRight: 0, paddingLeft: 0 } } : undefined}>
{children}
</AccordionPanel>
</AccordionItem>
);
};
@@ -1,19 +1,19 @@
import type { JSX, PropsWithChildren } from "react"; import type { JSX, PropsWithChildren } from "react";
import { cookies, headers } from "next/headers";
import { notFound, redirect } from "next/navigation"; import { notFound, redirect } from "next/navigation";
import { AppShellMain } from "@mantine/core"; import { AppShellMain } from "@mantine/core";
import { TRPCError } from "@trpc/server"; import { TRPCError } from "@trpc/server";
import { getRscUserSettingsAsync } from "@homarr/api/user-server";
import { auth } from "@homarr/auth/next"; import { auth } from "@homarr/auth/next";
import { BoardProvider } from "@homarr/boards/context"; import { BoardProvider } from "@homarr/boards/context";
import { EditModeProvider } from "@homarr/boards/edit-mode"; import { EditModeProvider } from "@homarr/boards/edit-mode";
import { boardViewportWidthCookieName, getLayoutIdForViewportWidth } from "@homarr/boards/layout-selection";
import { createLogger } from "@homarr/core/infrastructure/logs"; import { createLogger } from "@homarr/core/infrastructure/logs";
import { MainHeader } from "~/components/layout/header"; import { MainHeader } from "~/components/layout/header";
import { BoardLogoWithTitle } from "~/components/layout/logo/board-logo"; import { BoardLogoWithTitle } from "~/components/layout/logo/board-logo";
import { ClientShell } from "~/components/layout/shell"; import { ClientShell } from "~/components/layout/shell";
import { BoardTourGate } from "~/components/onboarding/board-tour-gate"; import { BoardTourProvider } from "~/components/onboarding/board-tour";
import { env } from "~/env";
import { getCurrentColorSchemeAsync } from "~/theme/color-scheme"; import { getCurrentColorSchemeAsync } from "~/theme/color-scheme";
import type { Board } from "./_types"; import type { Board } from "./_types";
import type { Params } from "./(content)/_creator"; import type { Params } from "./(content)/_creator";
@@ -23,6 +23,11 @@ import { BoardMantineProvider } from "./(content)/_theme";
const logger = createLogger({ module: "createBoardLayout" }); const logger = createLogger({ module: "createBoardLayout" });
const BoardTourWrapper = ({ hasSession, children }: PropsWithChildren<{ hasSession: boolean }>) => {
if (!hasSession) return <>{children}</>;
return <BoardTourProvider>{children}</BoardTourProvider>;
};
interface CreateBoardLayoutProps<TParams extends Params> { interface CreateBoardLayoutProps<TParams extends Params> {
headerActions: JSX.Element; headerActions: JSX.Element;
getInitialBoardAsync: (params: TParams) => Promise<Board>; getInitialBoardAsync: (params: TParams) => Promise<Board>;
@@ -40,32 +45,8 @@ export const createBoardLayout = <TParams extends Params>({
}: PropsWithChildren<{ }: PropsWithChildren<{
params: Promise<TParams>; params: Promise<TParams>;
}>) => { }>) => {
const resolvedParams = await params; const session = await auth();
const sessionPromise = auth(); const initialBoard = await getInitialBoard(await params).catch((error) => {
const initialBoardPromise = getInitialBoard(resolvedParams).then(
(board) => ({ status: "fulfilled", board }) as const,
(error: unknown) => ({ status: "rejected", error }) as const,
);
const colorSchemePromise = getCurrentColorSchemeAsync();
const shouldRunBoardTourPromise = sessionPromise.then(async (session) => {
if (!withTour || !session || env.DEMO_MODE) return false;
try {
const user = await getRscUserSettingsAsync(session.user.id);
return user !== undefined && !user.completedBoardTour;
} catch (error) {
logger.error(new Error("Failed to load the board tour status", { cause: error }));
return false;
}
});
const [session, initialBoardResult, colorScheme, shouldRunBoardTour] = await Promise.all([
sessionPromise,
initialBoardPromise,
colorSchemePromise,
shouldRunBoardTourPromise,
]);
if (initialBoardResult.status === "rejected") {
const { error } = initialBoardResult;
if (error instanceof TRPCError && error.code === "NOT_FOUND") { if (error instanceof TRPCError && error.code === "NOT_FOUND") {
if (!session) { if (!session) {
logger.debug("No home board found for anonymous user, redirecting to login"); logger.debug("No home board found for anonymous user, redirecting to login");
@@ -81,16 +62,22 @@ export const createBoardLayout = <TParams extends Params>({
} }
throw error; throw error;
} });
const initialBoard = initialBoardResult.board; const colorScheme = await getCurrentColorSchemeAsync();
const initialViewportWidth = await getInitialViewportWidthAsync();
const initialLayoutId = getLayoutIdForViewportWidth(initialBoard.layouts, initialViewportWidth);
return ( return (
<BoardProvider initialBoard={initialBoard}> <BoardProvider
initialBoard={initialBoard}
initialLayoutId={initialLayoutId}
initialViewportWidth={initialViewportWidth}
>
<BoardReadyProvider> <BoardReadyProvider>
<EditModeProvider> <EditModeProvider>
<BoardMantineProvider defaultColorScheme={colorScheme}> <BoardMantineProvider defaultColorScheme={colorScheme}>
<CustomCss /> <CustomCss />
<BoardTourGate enabled={shouldRunBoardTour}> <BoardTourWrapper hasSession={withTour && !!session}>
<ClientShell hasNavigation={false}> <ClientShell hasNavigation={false}>
<MainHeader <MainHeader
logo={<BoardLogoWithTitle size="md" hideTitleOnMobile />} logo={<BoardLogoWithTitle size="md" hideTitleOnMobile />}
@@ -99,7 +86,7 @@ export const createBoardLayout = <TParams extends Params>({
/> />
<AppShellMain>{children}</AppShellMain> <AppShellMain>{children}</AppShellMain>
</ClientShell> </ClientShell>
</BoardTourGate> </BoardTourWrapper>
</BoardMantineProvider> </BoardMantineProvider>
</EditModeProvider> </EditModeProvider>
</BoardReadyProvider> </BoardReadyProvider>
@@ -109,3 +96,22 @@ export const createBoardLayout = <TParams extends Params>({
return Layout; 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;
};
+2 -9
View File
@@ -1,8 +1,8 @@
import type { JSX } from "react"; import type { JSX } from "react";
import { Box, Center, Stack, Text, Title } from "@mantine/core"; import { Box, Center, Stack, Text, Title } from "@mantine/core";
import { api } from "@homarr/api/server";
import type { MaybePromise } from "@homarr/common/types"; import type { MaybePromise } from "@homarr/common/types";
import { db } from "@homarr/db";
import type { OnboardingStep } from "@homarr/definitions"; import type { OnboardingStep } from "@homarr/definitions";
import { getScopedI18n } from "@homarr/translation/server"; import { getScopedI18n } from "@homarr/translation/server";
@@ -29,16 +29,9 @@ const stepComponents: Record<OnboardingStep, null | (() => MaybePromise<JSX.Elem
finish: InitFinish, finish: InitFinish,
}; };
const getCurrentOnboardingStepAsync = async () => {
const value = await db.query.onboarding.findFirst();
if (!value) return { current: "start" as const, previous: null };
return { current: value.step, previous: value.previousStep };
};
export default async function InitPage() { export default async function InitPage() {
const t = await getScopedI18n("init.step"); const t = await getScopedI18n("init.step");
const currentStep = await getCurrentOnboardingStepAsync(); const currentStep = await api.onboard.currentStep();
const CurrentComponent = stepComponents[currentStep.current]; const CurrentComponent = stepComponents[currentStep.current];
+13 -23
View File
@@ -13,11 +13,11 @@ import { notFound } from "next/navigation";
import type { DayOfWeek } from "@mantine/dates"; import type { DayOfWeek } from "@mantine/dates";
import { NextIntlClientProvider } from "next-intl"; import { NextIntlClientProvider } from "next-intl";
import { getRscServerSettingsAsync } from "@homarr/api/server-settings-server"; import { api } from "@homarr/api/server";
import { getRscUserSettingsAsync } from "@homarr/api/user-server";
import { env } from "@homarr/auth/env"; import { env } from "@homarr/auth/env";
import { auth } from "@homarr/auth/next"; import { auth } from "@homarr/auth/next";
import { createLogger } from "@homarr/core/infrastructure/logs"; import { db } from "@homarr/db";
import { getServerSettingsAsync } from "@homarr/db/queries";
import { ModalProvider } from "@homarr/modals"; import { ModalProvider } from "@homarr/modals";
import { Notifications } from "@homarr/notifications"; import { Notifications } from "@homarr/notifications";
import { SettingsProvider } from "@homarr/settings"; import { SettingsProvider } from "@homarr/settings";
@@ -30,6 +30,7 @@ import { CrowdinLiveTranslation } from "~/components/layout/crowdin-live-transla
import { SearchEngineOptimization } from "~/components/layout/search-engine-optimization"; import { SearchEngineOptimization } from "~/components/layout/search-engine-optimization";
import { ServiceWorkerRegistration } from "~/components/layout/service-worker-registration"; import { ServiceWorkerRegistration } from "~/components/layout/service-worker-registration";
import { ViewportHint } from "~/components/layout/viewport-hint";
import { getCurrentColorSchemeAsync } from "~/theme/color-scheme"; import { getCurrentColorSchemeAsync } from "~/theme/color-scheme";
import { DayJsLoader } from "./_client-providers/dayjs-loader"; import { DayJsLoader } from "./_client-providers/dayjs-loader";
import { JotaiProvider } from "./_client-providers/jotai"; import { JotaiProvider } from "./_client-providers/jotai";
@@ -43,8 +44,6 @@ const fontSans = Inter({
variable: "--font-sans", variable: "--font-sans",
}); });
const logger = createLogger({ module: "rootLayout" });
// eslint-disable-next-line no-restricted-syntax // eslint-disable-next-line no-restricted-syntax
export const generateMetadata = async (): Promise<Metadata> => ({ export const generateMetadata = async (): Promise<Metadata> => ({
title: "Homarr", title: "Homarr",
@@ -80,27 +79,15 @@ export default async function Layout(props: {
children: React.ReactNode; children: React.ReactNode;
params: Promise<{ locale: SupportedLanguage }>; params: Promise<{ locale: SupportedLanguage }>;
}) { }) {
const { locale } = await props.params; if (!isLocaleSupported((await props.params).locale)) {
if (!isLocaleSupported(locale)) {
notFound(); notFound();
} }
const sessionPromise = auth(); const session = await auth();
const userPromise = sessionPromise.then((session) => const user = session ? await api.user.getById({ userId: session.user.id }).catch(() => null) : null;
session const serverSettings = await getServerSettingsAsync(db);
? getRscUserSettingsAsync(session.user.id).catch((error: unknown) => { const colorScheme = await getCurrentColorSchemeAsync();
logger.error(new Error("Failed to load the authenticated user in the root layout", { cause: error })); const direction = isLocaleRTL((await props.params).locale) ? "rtl" : "ltr";
return null;
})
: null,
);
const [session, user, serverSettings, colorScheme] = await Promise.all([
sessionPromise,
userPromise,
getRscServerSettingsAsync(),
getCurrentColorSchemeAsync(),
]);
const direction = isLocaleRTL(locale) ? "rtl" : "ltr";
const StackedProvider = composeWrappers([ const StackedProvider = composeWrappers([
(innerProps) => { (innerProps) => {
@@ -139,6 +126,8 @@ export default async function Layout(props: {
(innerProps) => <SpotlightProvider {...innerProps} />, (innerProps) => <SpotlightProvider {...innerProps} />,
]); ]);
const { locale } = await props.params;
return ( return (
// Instead of ColorSchemScript we use data-mantine-color-scheme to prevent flickering // Instead of ColorSchemScript we use data-mantine-color-scheme to prevent flickering
<html <html
@@ -156,6 +145,7 @@ export default async function Layout(props: {
</head> </head>
<body className={[fontSans.className, fontSans.variable].join(" ")} suppressHydrationWarning> <body className={[fontSans.className, fontSans.variable].join(" ")} suppressHydrationWarning>
<Analytics enabled={serverSettings.analytics.enableGeneral} /> <Analytics enabled={serverSettings.analytics.enableGeneral} />
<ViewportHint />
<StackedProvider> <StackedProvider>
<Notifications pauseResetOnHover="notification" /> <Notifications pauseResetOnHover="notification" />
<ServiceWorkerRegistration /> <ServiceWorkerRegistration />
@@ -697,11 +697,7 @@ export function CustomWidgetForm({ mode, initialValues, definitionId }: CustomWi
<Stack gap="sm"> <Stack gap="sm">
<TextInput label={t("field.name")} required {...form.getInputProps("name")} /> <TextInput label={t("field.name")} required {...form.getInputProps("name")} />
<Textarea label={t("field.description")} {...form.getInputProps("description")} /> <Textarea label={t("field.description")} {...form.getInputProps("description")} />
<IconPicker <IconPicker withAsterisk={false} {...form.getInputProps("iconUrl")} />
withAsterisk={false}
suggestedSearch={mode === "create" ? form.values.name : undefined}
{...form.getInputProps("iconUrl")}
/>
</Stack> </Stack>
</Fieldset> </Fieldset>
+6 -28
View File
@@ -27,10 +27,8 @@ import {
IconUsersGroup, IconUsersGroup,
} from "@tabler/icons-react"; } from "@tabler/icons-react";
import { getRscUserSettingsAsync } from "@homarr/api/user-server";
import { auth } from "@homarr/auth/next"; import { auth } from "@homarr/auth/next";
import { isProviderEnabled } from "@homarr/auth/server"; import { isProviderEnabled } from "@homarr/auth/server";
import { createLogger } from "@homarr/core/infrastructure/logs";
import { createDocumentationLink } from "@homarr/definitions"; import { createDocumentationLink } from "@homarr/definitions";
import { dbEnv } from "@homarr/core/infrastructure/db/env"; import { dbEnv } from "@homarr/core/infrastructure/db/env";
import { env } from "@homarr/docker/env"; import { env } from "@homarr/docker/env";
@@ -41,29 +39,11 @@ import { homarrLogoPath } from "~/components/layout/logo/homarr-logo";
import type { NavigationLink } from "~/components/layout/navigation"; import type { NavigationLink } from "~/components/layout/navigation";
import { MainNavigation } from "~/components/layout/navigation"; import { MainNavigation } from "~/components/layout/navigation";
import { ClientShell } from "~/components/layout/shell"; import { ClientShell } from "~/components/layout/shell";
import { ManageTourGate } from "~/components/onboarding/manage-tour-gate"; import { ManageTourProvider } from "~/components/onboarding/manage-tour";
import { env as nextEnv } from "~/env";
const logger = createLogger({ module: "manageLayout" });
export default async function ManageLayout({ children }: PropsWithChildren) { export default async function ManageLayout({ children }: PropsWithChildren) {
const sessionPromise = auth(); const t = await getScopedI18n("management.navbar");
const shouldRunManageTourPromise = sessionPromise.then(async (session) => { const session = await auth();
if (!session || nextEnv.DEMO_MODE) return false;
try {
const user = await getRscUserSettingsAsync(session.user.id);
return user !== undefined && !user.completedManageTour;
} catch (error) {
logger.error(new Error("Failed to load the management tour status", { cause: error }));
return false;
}
});
const [t, session, shouldRunManageTour] = await Promise.all([
getScopedI18n("management.navbar"),
sessionPromise,
shouldRunManageTourPromise,
]);
const navigationLinks: NavigationLink[] = [ const navigationLinks: NavigationLink[] = [
{ {
label: t("items.home"), label: t("items.home"),
@@ -245,9 +225,7 @@ export default async function ManageLayout({ children }: PropsWithChildren) {
</ClientShell> </ClientShell>
); );
return ( if (!session) return shell;
<ManageTourGate enabled={shouldRunManageTour} isAdmin={isAdmin}>
{shell} return <ManageTourProvider isAdmin={isAdmin}>{shell}</ManageTourProvider>;
</ManageTourGate>
);
} }
@@ -57,10 +57,7 @@ export const SearchEngineForm = (props: SearchEngineFormProps) => {
/> />
</Grid.Col> </Grid.Col>
</Grid> </Grid>
<IconPicker <IconPicker {...form.getInputProps("iconUrl")} />
{...form.getInputProps("iconUrl")}
suggestedSearch={initialValues === undefined ? form.values.name : undefined}
/>
<Fieldset legend={t("search.engine.page.edit.configControl")}> <Fieldset legend={t("search.engine.page.edit.configControl")}>
<SegmentedControl <SegmentedControl
@@ -1,22 +1,53 @@
"use client"; "use client";
import type { UseFormReturnType } from "@homarr/form"; import React from "react";
import { Card, LoadingOverlay, Stack, Title } from "@mantine/core";
import { clientApi } from "@homarr/api/client";
import { revalidatePathActionAsync } from "@homarr/common/client";
import { useForm } from "@homarr/form";
import type { defaultServerSettings } from "@homarr/server-settings";
import { useScopedI18n } from "@homarr/translation/client"; import { useScopedI18n } from "@homarr/translation/client";
import { SectionCard } from "~/components/manage/section-card"; import { SwitchSetting } from "~/app/[locale]/manage/settings/_components/setting-switch";
import type { FormValues } from "./settings-form";
import { SwitchSetting } from "./setting-switch";
interface AnalyticsSettingsProps { interface AnalyticsSettingsProps {
form: UseFormReturnType<FormValues>; initialData: typeof defaultServerSettings.analytics;
} }
export const AnalyticsSettings = ({ form }: AnalyticsSettingsProps) => { export const AnalyticsSettings = ({ initialData }: AnalyticsSettingsProps) => {
const t = useScopedI18n("management.page.settings.section.analytics"); const t = useScopedI18n("management.page.settings.section.analytics");
const { instanceId: _, ...formDefaults } = initialData;
const form = useForm({
initialValues: formDefaults,
onValuesChange: (updatedValues, _prev) => {
if (!form.isValid()) {
return;
}
void mutateAsync({
settingsKey: "analytics",
value: updatedValues,
});
},
});
const { mutateAsync, isPending } = clientApi.serverSettings.saveSettings.useMutation({
onSettled: async () => {
await revalidatePathActionAsync("/manage/settings");
},
});
return ( return (
<SectionCard title={t("title")}> <>
<SwitchSetting form={form} formKey="enableGeneral" title={t("general.title")} text={t("general.text")} /> <Title order={2}>{t("title")}</Title>
</SectionCard>
<Card pos="relative">
<LoadingOverlay visible={isPending} />
<Stack>
<SwitchSetting form={form} formKey="enableGeneral" title={t("general.title")} text={t("general.text")} />
</Stack>
</Card>
</>
); );
}; };
@@ -5,32 +5,31 @@ import { IconDeviceDesktop, IconMoon, IconSun } from "@tabler/icons-react";
import type { ColorScheme } from "@homarr/definitions"; import type { ColorScheme } from "@homarr/definitions";
import { colorSchemes } from "@homarr/definitions"; import { colorSchemes } from "@homarr/definitions";
import type { UseFormReturnType } from "@homarr/form"; import type { ServerSettings } from "@homarr/server-settings";
import { useScopedI18n } from "@homarr/translation/client"; import { useScopedI18n } from "@homarr/translation/client";
import { SelectWithCustomItems } from "@homarr/ui"; import { SelectWithCustomItems } from "@homarr/ui";
import { SectionCard } from "~/components/manage/section-card"; import { CommonSettingsForm } from "./common-form";
import type { FormValues } from "./settings-form";
interface AppearanceSettingsFormProps { export const AppearanceSettingsForm = ({ defaultValues }: { defaultValues: ServerSettings["appearance"] }) => {
form: UseFormReturnType<FormValues>; const tApperance = useScopedI18n("management.page.settings.section.appearance");
}
export const AppearanceSettingsForm = ({ form }: AppearanceSettingsFormProps) => {
const tAppearance = useScopedI18n("management.page.settings.section.appearance");
return ( return (
<SectionCard title={tAppearance("title")}> <CommonSettingsForm settingKey="appearance" defaultValues={defaultValues}>
<SelectWithCustomItems {(form) => (
label={tAppearance("defaultColorScheme.label")} <>
data={colorSchemes.map((scheme) => ({ <SelectWithCustomItems
value: scheme, label={tApperance("defaultColorScheme.label")}
label: tAppearance(`defaultColorScheme.options.${scheme}`), data={colorSchemes.map((scheme) => ({
}))} value: scheme,
{...form.getInputProps("defaultColorScheme")} label: tApperance(`defaultColorScheme.options.${scheme}`),
SelectOption={AppearanceCustomOption} }))}
/> {...form.getInputProps("defaultColorScheme")}
</SectionCard> SelectOption={ApperanceCustomOption}
/>
</>
)}
</CommonSettingsForm>
); );
}; };
@@ -40,7 +39,7 @@ const appearanceIcons = {
dark: IconMoon, dark: IconMoon,
}; };
const AppearanceCustomOption = ({ value, label }: { value: ColorScheme; label: string }) => { const ApperanceCustomOption = ({ value, label }: { value: ColorScheme; label: string }) => {
const Icon = appearanceIcons[value]; const Icon = appearanceIcons[value];
return ( return (
@@ -3,50 +3,49 @@
import { Switch, Text } from "@mantine/core"; import { Switch, Text } from "@mantine/core";
import { clientApi } from "@homarr/api/client"; import { clientApi } from "@homarr/api/client";
import type { UseFormReturnType } from "@homarr/form"; import type { ServerSettings } from "@homarr/server-settings";
import { useScopedI18n } from "@homarr/translation/client"; import { useScopedI18n } from "@homarr/translation/client";
import { BoardSelect } from "~/components/board/board-select"; import { BoardSelect } from "~/components/board/board-select";
import { SectionCard } from "~/components/manage/section-card"; import { CommonSettingsForm } from "./common-form";
import type { FormValues } from "./settings-form";
interface BoardSettingsFormProps { export const BoardSettingsForm = ({ defaultValues }: { defaultValues: ServerSettings["board"] }) => {
form: UseFormReturnType<FormValues>;
}
export const BoardSettingsForm = ({ form }: BoardSettingsFormProps) => {
const tBoard = useScopedI18n("management.page.settings.section.board"); const tBoard = useScopedI18n("management.page.settings.section.board");
const [selectableBoards] = clientApi.board.getPublicBoards.useSuspenseQuery(); const [selectableBoards] = clientApi.board.getPublicBoards.useSuspenseQuery();
return ( return (
<SectionCard title={tBoard("title")}> <CommonSettingsForm settingKey="board" defaultValues={defaultValues}>
<BoardSelect {(form) => (
label={tBoard("homeBoard.label")} <>
description={tBoard("homeBoard.description")} <BoardSelect
clearable label={tBoard("homeBoard.label")}
boards={selectableBoards} description={tBoard("homeBoard.description")}
{...form.getInputProps("homeBoardId")} clearable
/> boards={selectableBoards}
{...form.getInputProps("homeBoardId")}
/>
<BoardSelect <BoardSelect
label={tBoard("homeBoard.mobileLabel")} label={tBoard("homeBoard.mobileLabel")}
description={tBoard("homeBoard.description")} description={tBoard("homeBoard.description")}
clearable clearable
boards={selectableBoards} boards={selectableBoards}
{...form.getInputProps("mobileHomeBoardId")} {...form.getInputProps("mobileHomeBoardId")}
/> />
<Text fw={500}>{tBoard("status.title")}</Text> <Text fw={500}>{tBoard("status.title")}</Text>
<Switch <Switch
{...form.getInputProps("enableStatusByDefault", { type: "checkbox" })} {...form.getInputProps("enableStatusByDefault", { type: "checkbox" })}
label={tBoard("status.enableStatusByDefault.label")} label={tBoard("status.enableStatusByDefault.label")}
description={tBoard("status.enableStatusByDefault.description")} description={tBoard("status.enableStatusByDefault.description")}
/> />
<Switch <Switch
{...form.getInputProps("forceDisableStatus", { type: "checkbox" })} {...form.getInputProps("forceDisableStatus", { type: "checkbox" })}
label={tBoard("status.forceDisableStatus.label")} label={tBoard("status.forceDisableStatus.label")}
description={tBoard("status.forceDisableStatus.description")} description={tBoard("status.forceDisableStatus.description")}
/> />
</SectionCard> </>
)}
</CommonSettingsForm>
); );
}; };
@@ -0,0 +1,57 @@
"use client";
import { Button, Group, Stack } from "@mantine/core";
import { clientApi } from "@homarr/api/client";
import { useForm } from "@homarr/form";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import type { ServerSettings } from "@homarr/server-settings";
import { useI18n, useScopedI18n } from "@homarr/translation/client";
export const CommonSettingsForm = <TKey extends keyof ServerSettings>({
settingKey,
defaultValues,
children,
}: {
settingKey: TKey;
defaultValues: ServerSettings[TKey];
children: (form: ReturnType<typeof useForm<ServerSettings[TKey]>>) => React.ReactNode;
}) => {
const t = useI18n();
const tSettings = useScopedI18n("management.page.settings");
const { mutateAsync, isPending } = clientApi.serverSettings.saveSettings.useMutation({
onSuccess() {
showSuccessNotification({
message: tSettings("notification.success.message"),
});
},
onError() {
showErrorNotification({
message: tSettings("notification.error.message"),
});
},
});
const form = useForm({
initialValues: defaultValues,
});
const handleSubmitAsync = async (values: ServerSettings[TKey]) => {
await mutateAsync({
settingsKey: settingKey,
value: values,
});
};
return (
<form onSubmit={form.onSubmit((values) => void handleSubmitAsync(values))}>
<Stack gap="sm">
{children(form)}
<Group justify="end">
<Button type="submit" loading={isPending}>
{t("common.action.save")}
</Button>
</Group>
</Stack>
</form>
);
};
@@ -1,35 +1,68 @@
"use client"; "use client";
import { Text } from "@mantine/core"; import React from "react";
import { Card, LoadingOverlay, Stack, Text, Title } from "@mantine/core";
import type { UseFormReturnType } from "@homarr/form"; import { clientApi } from "@homarr/api/client";
import { revalidatePathActionAsync } from "@homarr/common/client";
import { useForm } from "@homarr/form";
import type { defaultServerSettings } from "@homarr/server-settings";
import { useScopedI18n } from "@homarr/translation/client"; import { useScopedI18n } from "@homarr/translation/client";
import { SectionCard } from "~/components/manage/section-card"; import { SwitchSetting } from "~/app/[locale]/manage/settings/_components/setting-switch";
import type { FormValues } from "./settings-form";
import { SwitchSetting } from "./setting-switch";
interface CrawlingAndIndexingSettingsProps { interface CrawlingAndIndexingSettingsProps {
form: UseFormReturnType<FormValues>; initialData: typeof defaultServerSettings.crawlingAndIndexing;
} }
export const CrawlingAndIndexingSettings = ({ form }: CrawlingAndIndexingSettingsProps) => { export const CrawlingAndIndexingSettings = ({ initialData }: CrawlingAndIndexingSettingsProps) => {
const t = useScopedI18n("management.page.settings.section.crawlingAndIndexing"); const t = useScopedI18n("management.page.settings.section.crawlingAndIndexing");
const form = useForm({
initialValues: initialData,
onValuesChange: (updatedValues, _) => {
if (!form.isValid()) {
return;
}
void mutateAsync({
settingsKey: "crawlingAndIndexing",
value: updatedValues,
});
},
});
const { mutateAsync, isPending } = clientApi.serverSettings.saveSettings.useMutation({
onSettled: async () => {
await revalidatePathActionAsync("/manage/settings");
},
});
return ( return (
<SectionCard title={t("title")}> <>
<Text c="dimmed" size="sm"> <Title order={2}>{t("title")}</Title>
{t("warning")}
</Text> <Card pos="relative">
<SwitchSetting form={form} formKey="noIndex" title={t("noIndex.title")} text={t("noIndex.text")} /> <Text c={"dimmed"} mb={"lg"}>
<SwitchSetting form={form} formKey="noFollow" title={t("noFollow.title")} text={t("noFollow.text")} /> {t("warning")}
<SwitchSetting form={form} formKey="noTranslate" title={t("noTranslate.title")} text={t("noTranslate.text")} /> </Text>
<SwitchSetting <LoadingOverlay visible={isPending} />
form={form} <Stack>
formKey="noSiteLinksSearchBox" <SwitchSetting form={form} formKey="noIndex" title={t("noIndex.title")} text={t("noIndex.text")} />
title={t("noSiteLinksSearchBox.title")} <SwitchSetting form={form} formKey="noFollow" title={t("noFollow.title")} text={t("noFollow.text")} />
text={t("noSiteLinksSearchBox.text")} <SwitchSetting
/> form={form}
</SectionCard> formKey="noTranslate"
title={t("noTranslate.title")}
text={t("noTranslate.text")}
/>
<SwitchSetting
form={form}
formKey="noSiteLinksSearchBox"
title={t("noSiteLinksSearchBox.title")}
text={t("noSiteLinksSearchBox.text")}
/>
</Stack>
</Card>
</>
); );
}; };
@@ -1,28 +1,26 @@
"use client"; "use client";
import type { UseFormReturnType } from "@homarr/form"; import type { ServerSettings } from "@homarr/server-settings";
import type { SupportedLanguage } from "@homarr/translation"; import type { SupportedLanguage } from "@homarr/translation";
import { useScopedI18n } from "@homarr/translation/client"; import { useScopedI18n } from "@homarr/translation/client";
import { LanguageCombobox } from "~/components/language/language-combobox"; import { LanguageCombobox } from "~/components/language/language-combobox";
import { SectionCard } from "~/components/manage/section-card"; import { CommonSettingsForm } from "./common-form";
import type { FormValues } from "./settings-form";
interface CultureSettingsFormProps { export const CultureSettingsForm = ({ defaultValues }: { defaultValues: ServerSettings["culture"] }) => {
form: UseFormReturnType<FormValues>;
}
export const CultureSettingsForm = ({ form }: CultureSettingsFormProps) => {
const tCulture = useScopedI18n("management.page.settings.section.culture"); const tCulture = useScopedI18n("management.page.settings.section.culture");
const localeInputProps = form.getInputProps("defaultLocale");
return ( return (
<SectionCard title={tCulture("title")}> <CommonSettingsForm settingKey="culture" defaultValues={defaultValues}>
<LanguageCombobox {(form) => (
label={tCulture("defaultLocale.label")} <>
{...localeInputProps} <LanguageCombobox
value={localeInputProps.value as SupportedLanguage} label={tCulture("defaultLocale.label")}
/> value={form.getInputProps("defaultLocale").value as SupportedLanguage}
</SectionCard> {...form.getInputProps("defaultLocale")}
/>
</>
)}
</CommonSettingsForm>
); );
}; };
@@ -3,28 +3,27 @@
import { Select } from "@mantine/core"; import { Select } from "@mantine/core";
import { clientApi } from "@homarr/api/client"; import { clientApi } from "@homarr/api/client";
import type { UseFormReturnType } from "@homarr/form"; import type { ServerSettings } from "@homarr/server-settings";
import { useScopedI18n } from "@homarr/translation/client"; import { useScopedI18n } from "@homarr/translation/client";
import { SectionCard } from "~/components/manage/section-card"; import { CommonSettingsForm } from "./common-form";
import type { FormValues } from "./settings-form";
interface SearchSettingsFormProps { export const SearchSettingsForm = ({ defaultValues }: { defaultValues: ServerSettings["search"] }) => {
form: UseFormReturnType<FormValues>;
}
export const SearchSettingsForm = ({ form }: SearchSettingsFormProps) => {
const tSearch = useScopedI18n("management.page.settings.section.search"); const tSearch = useScopedI18n("management.page.settings.section.search");
const [selectableSearchEngines] = clientApi.searchEngine.getSelectable.useSuspenseQuery({ withIntegrations: false }); const [selectableSearchEngines] = clientApi.searchEngine.getSelectable.useSuspenseQuery({ withIntegrations: false });
return ( return (
<SectionCard title={tSearch("title")}> <CommonSettingsForm settingKey="search" defaultValues={defaultValues}>
<Select {(form) => (
label={tSearch("defaultSearchEngine.label")} <>
description={tSearch("defaultSearchEngine.description")} <Select
data={selectableSearchEngines} label={tSearch("defaultSearchEngine.label")}
{...form.getInputProps("defaultSearchEngineId")} description={tSearch("defaultSearchEngine.description")}
/> data={selectableSearchEngines}
</SectionCard> {...form.getInputProps("defaultSearchEngineId")}
/>
</>
)}
</CommonSettingsForm>
); );
}; };
@@ -3,13 +3,10 @@ import React from "react";
import type { MantineSpacing } from "@mantine/core"; import type { MantineSpacing } from "@mantine/core";
import { Group, Stack, Switch, Text, UnstyledButton } from "@mantine/core"; import { Group, Stack, Switch, Text, UnstyledButton } from "@mantine/core";
import type { Modify } from "@homarr/common/types";
import type { UseFormReturnType } from "@homarr/form"; import type { UseFormReturnType } from "@homarr/form";
type BooleanKeys<TFormValue> = { export const SwitchSetting = <TFormValue extends Record<string, boolean>>({
[TKey in keyof TFormValue]: TFormValue[TKey] extends boolean ? TKey : never;
}[keyof TFormValue];
export const SwitchSetting = <TFormValue extends Record<string, unknown>>({
form, form,
ms, ms,
title, title,
@@ -17,8 +14,13 @@ export const SwitchSetting = <TFormValue extends Record<string, unknown>>({
formKey, formKey,
disabled, disabled,
}: { }: {
form: UseFormReturnType<TFormValue, TFormValue>; form: Modify<
formKey: string & BooleanKeys<TFormValue>; UseFormReturnType<TFormValue, TFormValue>,
{
setFieldValue: (key: keyof TFormValue, value: (previous: boolean) => boolean) => void;
}
>;
formKey: keyof TFormValue;
ms?: MantineSpacing; ms?: MantineSpacing;
title: string; title: string;
text: ReactNode; text: ReactNode;
@@ -29,7 +31,7 @@ export const SwitchSetting = <TFormValue extends Record<string, unknown>>({
return; return;
} }
form.setFieldValue(formKey, (previous) => !previous as never); form.setFieldValue(formKey, (previous) => !previous);
}, [form, formKey, disabled]); }, [form, formKey, disabled]);
return ( return (
@@ -42,7 +44,7 @@ export const SwitchSetting = <TFormValue extends Record<string, unknown>>({
</Text> </Text>
</Stack> </Stack>
</UnstyledButton> </UnstyledButton>
<Switch disabled={disabled} onClick={handleClick} checked={Boolean(form.values[formKey])} /> <Switch disabled={disabled} onClick={handleClick} checked={form.values[formKey] && !disabled} />
</Group> </Group>
); );
}; };
@@ -1,193 +0,0 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { Button, Stack } from "@mantine/core";
import { z } from "zod/v4";
import { clientApi } from "@homarr/api/client";
import { revalidatePathActionAsync } from "@homarr/common/client";
import { env } from "@homarr/common/env";
import { colorSchemes } from "@homarr/definitions";
import { useZodForm } from "@homarr/form";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import type { ServerSettings, defaultServerSettingsKeys } from "@homarr/server-settings";
import { useI18n, useScopedI18n } from "@homarr/translation/client";
import { UnsavedChangesBar } from "~/components/manage/unsaved-changes-bar";
import { AnalyticsSettings } from "./analytics.settings";
import { AppearanceSettingsForm } from "./appearance-settings-form";
import { BoardSettingsForm } from "./board-settings-form";
import { CrawlingAndIndexingSettings } from "./crawling-and-indexing.settings";
import { CultureSettingsForm } from "./culture-settings-form";
import { SearchSettingsForm } from "./search-settings-form";
import { UserSettingsForm } from "./user-settings-form";
const settingsFormSchema = z.object({
enableGeneral: z.boolean(),
noIndex: z.boolean(),
noFollow: z.boolean(),
noTranslate: z.boolean(),
noSiteLinksSearchBox: z.boolean(),
homeBoardId: z.string().nullable(),
mobileHomeBoardId: z.string().nullable(),
enableStatusByDefault: z.boolean(),
forceDisableStatus: z.boolean(),
enableGravatar: z.boolean(),
defaultSearchEngineId: z.string().nullable(),
defaultColorScheme: z.enum(colorSchemes),
defaultLocale: z.string(),
});
export type FormValues = z.infer<typeof settingsFormSchema>;
const buildInitialValues = (initialData: ServerSettings): FormValues => ({
enableGeneral: initialData.analytics.enableGeneral,
noIndex: initialData.crawlingAndIndexing.noIndex,
noFollow: initialData.crawlingAndIndexing.noFollow,
noTranslate: initialData.crawlingAndIndexing.noTranslate,
noSiteLinksSearchBox: initialData.crawlingAndIndexing.noSiteLinksSearchBox,
homeBoardId: initialData.board.homeBoardId,
mobileHomeBoardId: initialData.board.mobileHomeBoardId,
enableStatusByDefault: initialData.board.enableStatusByDefault,
forceDisableStatus: initialData.board.forceDisableStatus,
enableGravatar: initialData.user.enableGravatar,
defaultSearchEngineId: initialData.search.defaultSearchEngineId,
defaultColorScheme: initialData.appearance.defaultColorScheme,
defaultLocale: initialData.culture.defaultLocale,
});
interface SettingsFormProps {
initialData: ServerSettings;
}
export const SettingsForm = ({ initialData }: SettingsFormProps) => {
const t = useI18n();
const tSettings = useScopedI18n("management.page.settings");
const initialValues = buildInitialValues(initialData);
const initialValuesRef = useRef(initialValues);
const form = useZodForm(settingsFormSchema, {
initialValues,
});
const isDirtyRef = useRef(false);
isDirtyRef.current = form.isDirty();
const [isSubmitting, setIsSubmitting] = useState(false);
const saveSettingsMutation = clientApi.serverSettings.saveSettings.useMutation({
onError(error) {
showErrorNotification({ title: t("common.notification.update.error"), message: error.message });
},
});
useEffect(() => {
const handler = (event: BeforeUnloadEvent) => {
if (env.NODE_ENV === "development") return;
if (isDirtyRef.current) event.preventDefault();
};
window.addEventListener("beforeunload", handler);
return () => window.removeEventListener("beforeunload", handler);
}, []);
const handleSubmitAsync = async (values: FormValues) => {
const defaults = initialValuesRef.current;
const changed = <TKey extends keyof FormValues>(...fields: TKey[]) =>
fields.some((field) => values[field] !== defaults[field]);
const groups: {
settingsKey: (typeof defaultServerSettingsKeys)[number];
when: boolean;
value: Record<string, unknown>;
}[] = [
{ settingsKey: "analytics", when: changed("enableGeneral"), value: { enableGeneral: values.enableGeneral } },
{
settingsKey: "crawlingAndIndexing",
when: changed("noIndex", "noFollow", "noTranslate", "noSiteLinksSearchBox"),
value: {
noIndex: values.noIndex,
noFollow: values.noFollow,
noTranslate: values.noTranslate,
noSiteLinksSearchBox: values.noSiteLinksSearchBox,
},
},
{
settingsKey: "board",
when: changed("homeBoardId", "mobileHomeBoardId", "enableStatusByDefault", "forceDisableStatus"),
value: {
homeBoardId: values.homeBoardId,
mobileHomeBoardId: values.mobileHomeBoardId,
enableStatusByDefault: values.enableStatusByDefault,
forceDisableStatus: values.forceDisableStatus,
},
},
{ settingsKey: "user", when: changed("enableGravatar"), value: { enableGravatar: values.enableGravatar } },
{
settingsKey: "search",
when: changed("defaultSearchEngineId"),
value: { defaultSearchEngineId: values.defaultSearchEngineId },
},
{
settingsKey: "appearance",
when: changed("defaultColorScheme"),
value: { defaultColorScheme: values.defaultColorScheme },
},
{ settingsKey: "culture", when: changed("defaultLocale"), value: { defaultLocale: values.defaultLocale } },
];
const promises = groups
.filter((group) => group.when)
.map((group) => saveSettingsMutation.mutateAsync({ settingsKey: group.settingsKey, value: group.value }));
if (promises.length === 0) return;
setIsSubmitting(true);
try {
const results = await Promise.allSettled(promises);
if (!results.every((result) => result.status === "fulfilled")) {
return;
}
initialValuesRef.current = values;
form.setInitialValues(values);
form.resetDirty();
await revalidatePathActionAsync("/manage/settings");
showSuccessNotification({
title: t("common.notification.update.success"),
message: tSettings("notification.success.message"),
});
} finally {
setIsSubmitting(false);
}
};
const handleDiscard = () => {
form.setValues(initialValuesRef.current);
form.resetDirty();
};
return (
<form onSubmit={form.onSubmit((values) => void handleSubmitAsync(values))}>
<Stack gap="xl">
<AnalyticsSettings form={form} />
<CrawlingAndIndexingSettings form={form} />
<BoardSettingsForm form={form} />
<UserSettingsForm form={form} />
<SearchSettingsForm form={form} />
<AppearanceSettingsForm form={form} />
<CultureSettingsForm form={form} />
{form.isDirty() && (
<UnsavedChangesBar>
<Button type="button" disabled={isSubmitting} variant="default" onClick={handleDiscard}>
{t("common.action.discard")}
</Button>
<Button loading={isSubmitting} type="submit" disabled={!form.isValid()}>
{t("common.action.saveChanges")}
</Button>
</UnsavedChangesBar>
)}
</Stack>
</form>
);
};
@@ -2,26 +2,25 @@
import { Switch } from "@mantine/core"; import { Switch } from "@mantine/core";
import type { UseFormReturnType } from "@homarr/form"; import type { ServerSettings } from "@homarr/server-settings";
import { useScopedI18n } from "@homarr/translation/client"; import { useScopedI18n } from "@homarr/translation/client";
import { SectionCard } from "~/components/manage/section-card"; import { CommonSettingsForm } from "./common-form";
import type { FormValues } from "./settings-form";
interface UserSettingsFormProps { export const UserSettingsForm = ({ defaultValues }: { defaultValues: ServerSettings["user"] }) => {
form: UseFormReturnType<FormValues>;
}
export const UserSettingsForm = ({ form }: UserSettingsFormProps) => {
const tUser = useScopedI18n("management.page.settings.section.user"); const tUser = useScopedI18n("management.page.settings.section.user");
return ( return (
<SectionCard title={tUser("title")}> <CommonSettingsForm settingKey="user" defaultValues={defaultValues}>
<Switch {(form) => (
{...form.getInputProps("enableGravatar", { type: "checkbox" })} <>
label={tUser("enableGravatar.label")} <Switch
description={tUser("enableGravatar.description")} {...form.getInputProps("enableGravatar", { type: "checkbox" })}
/> label={tUser("enableGravatar.label")}
</SectionCard> description={tUser("enableGravatar.description")}
/>
</>
)}
</CommonSettingsForm>
); );
}; };
@@ -1,9 +0,0 @@
import { Center, Loader } from "@mantine/core";
export default function SettingsLoading() {
return (
<Center h="40vh">
<Loader />
</Center>
);
}
@@ -5,8 +5,14 @@ import { api } from "@homarr/api/server";
import { auth } from "@homarr/auth/next"; import { auth } from "@homarr/auth/next";
import { getScopedI18n } from "@homarr/translation/server"; import { getScopedI18n } from "@homarr/translation/server";
import { CrawlingAndIndexingSettings } from "~/app/[locale]/manage/settings/_components/crawling-and-indexing.settings";
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb"; import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
import { SettingsForm } from "./_components/settings-form"; import { AnalyticsSettings } from "./_components/analytics.settings";
import { AppearanceSettingsForm } from "./_components/appearance-settings-form";
import { BoardSettingsForm } from "./_components/board-settings-form";
import { CultureSettingsForm } from "./_components/culture-settings-form";
import { SearchSettingsForm } from "./_components/search-settings-form";
import { UserSettingsForm } from "./_components/user-settings-form";
export async function generateMetadata() { export async function generateMetadata() {
const t = await getScopedI18n("management"); const t = await getScopedI18n("management");
@@ -26,13 +32,33 @@ export default async function SettingsPage() {
const serverSettings = await api.serverSettings.getAll(); const serverSettings = await api.serverSettings.getAll();
const tSettings = await getScopedI18n("management.page.settings"); const tSettings = await getScopedI18n("management.page.settings");
return ( return (
<> <>
<DynamicBreadcrumb /> <DynamicBreadcrumb />
<Stack> <Stack>
<Title order={1}>{tSettings("title")}</Title> <Title order={1}>{tSettings("title")}</Title>
<SettingsForm initialData={serverSettings} /> <AnalyticsSettings initialData={serverSettings.analytics} />
<CrawlingAndIndexingSettings initialData={serverSettings.crawlingAndIndexing} />
<Stack>
<Title order={2}>{tSettings("section.board.title")}</Title>
<BoardSettingsForm defaultValues={serverSettings.board} />
</Stack>
<Stack>
<Title order={2}>{tSettings("section.user.title")}</Title>
<UserSettingsForm defaultValues={serverSettings.user} />
</Stack>
<Stack>
<Title order={2}>{tSettings("section.search.title")}</Title>
<SearchSettingsForm defaultValues={serverSettings.search} />
</Stack>
<Stack>
<Title order={2}>{tSettings("section.appearance.title")}</Title>
<AppearanceSettingsForm defaultValues={serverSettings.appearance} />
</Stack>
<Stack>
<Title order={2}>{tSettings("section.culture.title")}</Title>
<CultureSettingsForm defaultValues={serverSettings.culture} />
</Stack>
</Stack> </Stack>
</> </>
); );
@@ -1,6 +1,5 @@
"use client"; "use client";
import { useRouter } from "next/navigation";
import { Badge, Button, Group, Stack, Text } from "@mantine/core"; import { Badge, Button, Group, Stack, Text } from "@mantine/core";
import { IconRefresh } from "@tabler/icons-react"; import { IconRefresh } from "@tabler/icons-react";
@@ -10,7 +9,6 @@ import { useScopedI18n } from "@homarr/translation/client";
export const ResetTours = () => { export const ResetTours = () => {
const t = useScopedI18n("management.page.user.setting.general.item.onboardingTours"); const t = useScopedI18n("management.page.user.setting.general.item.onboardingTours");
const router = useRouter();
const utils = clientApi.useUtils(); const utils = clientApi.useUtils();
const { data: tourStatus } = clientApi.user.getTourStatus.useQuery(); const { data: tourStatus } = clientApi.user.getTourStatus.useQuery();
const { mutate: resetTours, isPending } = clientApi.user.resetTours.useMutation({ const { mutate: resetTours, isPending } = clientApi.user.resetTours.useMutation({
@@ -20,7 +18,6 @@ export const ResetTours = () => {
message: t("resetSuccess.message"), message: t("resetSuccess.message"),
}); });
void utils.user.getTourStatus.invalidate(); void utils.user.getTourStatus.invalidate();
router.refresh();
}, },
}); });
@@ -1,38 +1,41 @@
"use client"; "use client";
import { use, useCallback, useState } from "react"; import { useCallback, useMemo, useState } from "react";
import { ActionIcon, Affix, Card } from "@mantine/core"; import { ActionIcon, Affix, Card } from "@mantine/core";
import { IconDimensions, IconPencil, IconToggleLeft, IconToggleRight } from "@tabler/icons-react"; import { IconDimensions, IconPencil, IconToggleLeft, IconToggleRight } from "@tabler/icons-react";
import { QueryErrorResetBoundary } from "@tanstack/react-query"; import { QueryErrorResetBoundary } from "@tanstack/react-query";
import { ErrorBoundary } from "react-error-boundary"; import { ErrorBoundary } from "react-error-boundary";
import { clientApi } from "@homarr/api/client"; import type { IntegrationKind, WidgetKind } from "@homarr/definitions";
import type { WidgetKind } from "@homarr/definitions";
import { useModalAction } from "@homarr/modals"; import { useModalAction } from "@homarr/modals";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications"; import { showSuccessNotification } from "@homarr/notifications";
import { useSettings } from "@homarr/settings"; import { useSettings } from "@homarr/settings";
import { useI18n } from "@homarr/translation/client"; import { useI18n } from "@homarr/translation/client";
import type { BoardItemAdvancedOptions } from "@homarr/validation/shared"; import type { BoardItemAdvancedOptions } from "@homarr/validation/shared";
import { loadWidgetDynamic, reduceWidgetOptionsWithDefaultValues, widgetImports } from "@homarr/widgets";
import { WidgetError } from "@homarr/widgets/errors"; import { WidgetError } from "@homarr/widgets/errors";
import { loadWidgetResources, reduceWidgetOptionsWithDefinition } from "@homarr/widgets/manifest"; import { WidgetEditModal } from "@homarr/widgets/modals";
import { LazyWidgetEditModal, preloadWidgetEditModal } from "~/components/board/items/lazy-widget-edit-modal";
import type { Dimensions } from "./_dimension-modal"; import type { Dimensions } from "./_dimension-modal";
import { PreviewDimensionsModal } from "./_dimension-modal"; import { PreviewDimensionsModal } from "./_dimension-modal";
interface WidgetPreviewPageContentProps { interface WidgetPreviewPageContentProps {
kind: WidgetKind; kind: WidgetKind;
integrationData: {
id: string;
name: string;
url: string;
kind: IntegrationKind;
}[];
} }
export const WidgetPreviewPageContent = ({ kind }: WidgetPreviewPageContentProps) => { export const WidgetPreviewPageContent = ({ kind, integrationData }: WidgetPreviewPageContentProps) => {
const settings = useSettings(); const settings = useSettings();
const t = useI18n(); const t = useI18n();
const utils = clientApi.useUtils(); const { openModal: openWidgetEditModal } = useModalAction(WidgetEditModal);
const { openModal: openWidgetEditModal } = useModalAction(LazyWidgetEditModal);
const { openModal: openPreviewDimensionsModal } = useModalAction(PreviewDimensionsModal); const { openModal: openPreviewDimensionsModal } = useModalAction(PreviewDimensionsModal);
const { definition: currentDefinition, Component } = use(loadWidgetResources(kind)); const currentDefinition = useMemo(() => widgetImports[kind].definition, [kind]);
const [editMode, setEditMode] = useState(false); const [editMode, setEditMode] = useState(false);
const [isEditorLoading, setIsEditorLoading] = useState(false);
const [dimensions, setDimensions] = useState<Dimensions>({ const [dimensions, setDimensions] = useState<Dimensions>({
width: 128, width: 128,
height: 128, height: 128,
@@ -42,7 +45,7 @@ export const WidgetPreviewPageContent = ({ kind }: WidgetPreviewPageContentProps
integrationIds: string[]; integrationIds: string[];
advancedOptions: BoardItemAdvancedOptions; advancedOptions: BoardItemAdvancedOptions;
}>({ }>({
options: reduceWidgetOptionsWithDefinition(currentDefinition, settings, {}), options: reduceWidgetOptionsWithDefaultValues(kind, settings, {}),
integrationIds: [], integrationIds: [],
advancedOptions: { advancedOptions: {
title: null, title: null,
@@ -51,41 +54,31 @@ export const WidgetPreviewPageContent = ({ kind }: WidgetPreviewPageContentProps
}, },
}); });
const handleOpenEditWidgetModal = useCallback(async () => { const handleOpenEditWidgetModal = useCallback(() => {
const hasIntegrationSupport = "supportedIntegrations" in currentDefinition; openWidgetEditModal(
setIsEditorLoading(true); {
preloadWidgetEditModal(); kind,
try { value: state,
const integrationData = hasIntegrationSupport ? await utils.integration.all.ensureData() : []; onSuccessfulEdit: (value) => {
openWidgetEditModal( setState(value);
{
kind,
definition: currentDefinition,
value: state,
onSuccessfulEdit: (value) => {
setState(value);
},
integrationData: integrationData.filter((integration) =>
(currentDefinition.supportedIntegrations ?? []).includes(integration.kind),
),
integrationSupport: hasIntegrationSupport,
settings,
}, },
{ integrationData: integrationData.filter(
title(translate) { (integration) =>
return `${translate("item.edit.title")} - ${translate(`widget.${kind}.name`)}`; "supportedIntegrations" in currentDefinition &&
}, (currentDefinition.supportedIntegrations as string[]).some((kind) => kind === integration.kind),
),
integrationSupport: "supportedIntegrations" in currentDefinition,
settings,
},
{
title(t) {
return `${t("item.edit.title")} - ${t(`widget.${kind}.name`)}`;
}, },
); },
} catch (error) { );
showErrorNotification({ }, [currentDefinition, integrationData, kind, openWidgetEditModal, settings, state]);
title: t("common.error"),
message: error instanceof Error ? error.message : String(error), const Comp = loadWidgetDynamic(kind);
});
} finally {
setIsEditorLoading(false);
}
}, [currentDefinition, kind, openWidgetEditModal, settings, state, t, utils]);
const toggleEditMode = useCallback(() => { const toggleEditMode = useCallback(() => {
setEditMode((editMode) => !editMode); setEditMode((editMode) => !editMode);
@@ -101,11 +94,8 @@ export const WidgetPreviewPageContent = ({ kind }: WidgetPreviewPageContentProps
}); });
}, [dimensions, openPreviewDimensionsModal]); }, [dimensions, openPreviewDimensionsModal]);
const updateOptions = useCallback( const updateOptions = ({ newOptions }: { newOptions: Record<string, unknown> }) =>
({ newOptions }: { newOptions: Record<string, unknown> }) => setState({ ...state, options: { ...state.options, newOptions } });
setState((current) => ({ ...current, options: { ...current.options, ...newOptions } })),
[],
);
return ( return (
<> <>
@@ -115,10 +105,10 @@ export const WidgetPreviewPageContent = ({ kind }: WidgetPreviewPageContentProps
<ErrorBoundary <ErrorBoundary
onReset={reset} onReset={reset}
fallbackRender={({ resetErrorBoundary, error }) => ( fallbackRender={({ resetErrorBoundary, error }) => (
<WidgetError definition={currentDefinition} error={error} resetErrorBoundary={resetErrorBoundary} /> <WidgetError kind={kind} error={error} resetErrorBoundary={resetErrorBoundary} />
)} )}
> >
<Component <Comp
options={state.options as never} options={state.options as never}
integrationIds={state.integrationIds} integrationIds={state.integrationIds}
width={dimensions.width} width={dimensions.width}
@@ -137,10 +127,7 @@ export const WidgetPreviewPageContent = ({ kind }: WidgetPreviewPageContentProps
size={48} size={48}
variant="default" variant="default"
radius="xl" radius="xl"
onClick={() => void handleOpenEditWidgetModal()} onClick={handleOpenEditWidgetModal}
onFocus={preloadWidgetEditModal}
onPointerEnter={preloadWidgetEditModal}
loading={isEditorLoading}
aria-label={t("common.action.edit")} aria-label={t("common.action.edit")}
> >
<IconPencil size={24} /> <IconPencil size={24} />
@@ -1,18 +1,23 @@
import type { PropsWithChildren } from "react"; import type { PropsWithChildren } from "react";
import { widgetKinds } from "@homarr/definitions"; import { widgetImports } from "@homarr/widgets";
import { widgetCatalogIcons } from "@homarr/widgets/catalog";
import { MainNavigation } from "~/components/layout/navigation"; import { MainNavigation } from "~/components/layout/navigation";
import { ClientShell } from "~/components/layout/shell"; import { ClientShell } from "~/components/layout/shell";
const links = widgetKinds.map((kind) => ({ const getLinks = () => {
href: `/widgets/${kind}`, return Object.entries(widgetImports).map(([key, value]) => {
icon: widgetCatalogIcons[kind], return {
label: kind, href: `/widgets/${key}`,
})); icon: value.definition.icon,
label: value.definition.kind,
};
});
};
export default function WidgetPreviewLayout({ children }: PropsWithChildren) { export default function WidgetPreviewLayout({ children }: PropsWithChildren) {
const links = getLinks();
return ( return (
<ClientShell hasHeader={false}> <ClientShell hasHeader={false}>
<MainNavigation links={links} /> <MainNavigation links={links} />
@@ -1,9 +1,10 @@
import { Suspense } from "react";
import { notFound } from "next/navigation"; import { notFound } from "next/navigation";
import { Center, Loader } from "@mantine/core"; import { Center } from "@mantine/core";
import { env } from "@homarr/common/env";
import { db } from "@homarr/db";
import type { WidgetKind } from "@homarr/definitions"; import type { WidgetKind } from "@homarr/definitions";
import { widgetKinds } from "@homarr/widgets/manifest"; import { widgetImports } from "@homarr/widgets";
import { WidgetPreviewPageContent } from "./_content"; import { WidgetPreviewPageContent } from "./_content";
@@ -12,16 +13,24 @@ interface Props {
} }
export default async function WidgetPreview(props: Props) { export default async function WidgetPreview(props: Props) {
const { kind } = await props.params; if (!((await props.params).kind in widgetImports || env.NODE_ENV !== "development")) {
if (!widgetKinds.includes(kind as WidgetKind)) {
notFound(); notFound();
} }
const integrationData = await db.query.integrations.findMany({
columns: {
id: true,
name: true,
url: true,
kind: true,
},
});
const sort = (await props.params).kind as WidgetKind;
return ( return (
<Center h="100vh"> <Center h="100vh">
<Suspense fallback={<Loader size="sm" />}> <WidgetPreviewPageContent kind={sort} integrationData={integrationData} />
<WidgetPreviewPageContent key={kind} kind={kind as WidgetKind} />
</Suspense>
</Center> </Center>
); );
} }
Loaded 100 of 495 files, more files were not shown because too many files have changed in this diff. Show more