diff --git a/.cursor/rules/codebase-context.mdc b/.agents/skills/codebase-context/SKILL.md similarity index 89% rename from .cursor/rules/codebase-context.mdc rename to .agents/skills/codebase-context/SKILL.md index b04b1db1d..d10509bff 100644 --- a/.cursor/rules/codebase-context.mdc +++ b/.agents/skills/codebase-context/SKILL.md @@ -1,6 +1,6 @@ --- -description: Comprehensive Homarr codebase architecture and conventions reference -alwaysApply: true +name: codebase-context +description: Comprehensive Homarr codebase architecture and conventions reference. Use when orienting in the monorepo, understanding package dependency layers, the three runtime services and ports, tRPC router structure, multi-database support, widget/integration/cron system architecture, routing, auth providers, env vars, or key patterns before writing code. --- # Homarr Codebase Context @@ -27,7 +27,7 @@ Homarr is an open-source, self-hosted dashboard for managing homelab services. I ## Repository Structure -``` +```text homarr/ ├── apps/ │ ├── nextjs/ # Main Next.js application (port 3000) @@ -66,8 +66,6 @@ homarr/ │ ├── analytics/ # Server-side analytics (Umami) │ ├── server-settings/ # Server setting keys/types │ ├── settings/ # User-facing settings UI context -│ ├── old-import/ # Legacy Homarr import -│ ├── old-schema/ # Legacy Homarr zod schemas │ └── cli/ # Node CLI for ops (brocli) ├── tooling/ │ ├── typescript/ # Base tsconfig @@ -79,11 +77,11 @@ homarr/ ## Three Runtime Services -| Service | Port | Role | -|---------|------|------| -| Next.js | 3000 | Main web app (SSR + API routes) | -| WebSocket | 3001 | tRPC subscriptions via `ws` | -| Tasks | 3002 | Cron job runner + management API via Fastify | +| Service | Port | Role | +| --------- | ---- | -------------------------------------------- | +| Next.js | 3000 | Main web app (SSR + API routes) | +| WebSocket | 3001 | tRPC subscriptions via `ws` | +| Tasks | 3002 | Cron job runner + management API via Fastify | In Docker, nginx listens on **7575** and proxies to all three. The tasks service authenticates via `CRON_JOB_API_KEY` header. @@ -112,6 +110,7 @@ In Docker, nginx listens on **7575** and proxies to all three. The tasks service ### Multi-Database Support Drizzle schemas exist in three parallel implementations: + - `packages/db/schema/sqlite.ts` - `packages/db/schema/mysql.ts` - `packages/db/schema/postgresql.ts` @@ -121,6 +120,7 @@ Selected at runtime via `DB_DRIVER` env (`better-sqlite3` | `mysql2` | `node-pos ### Widget System Widgets are registered in `packages/widgets/src/index.tsx` as `widgetImports` (must satisfy `Record`). Each widget: + - Has a `definition` created via `createWidgetDefinition(kind, { icon, createOptions, supportedIntegrations? })` - Uses `optionsBuilder.from(factory => ({ ... }))` for typed options - Loads dynamically via `next/dynamic` @@ -133,6 +133,7 @@ Integrations are defined in `packages/definitions/src/integration.ts` (`integrat ### Cron Job System Jobs defined in `packages/cron-jobs/src/jobs/`. Each job: + - Uses `createCronJob` with a cron expression - Publishes results to Redis channels - Widget subscriptions consume those channels via tRPC subscriptions @@ -160,6 +161,7 @@ Configured via `AUTH_PROVIDERS` env. Supports: `credentials` (local), `ldap`, `o ## Environment Variables Key env vars (from `.env.example`): + - `DB_DRIVER`: `better-sqlite3` | `mysql2` | `node-postgres` - `DB_URL`: Database connection string - `AUTH_SECRET`: NextAuth secret @@ -191,16 +193,3 @@ Key env vars (from `.env.example`): - Drag-and-drop via `@dnd-kit/*` - `typescript.ignoreBuildErrors: true` in next.config (types checked separately via `typecheck`) - `serverExternalPackages`: `dockerode`, `isomorphic-dompurify`, `jsdom` - -## Documentation (`apps/docs`) - -Docusaurus 3 site at `apps/docs/`, package name `@homarr/docs`. - -- Run with `pnpm dev:docs` from root -- Build with `pnpm turbo build --filter=@homarr/docs` -- Uses Tailwind CSS 3 for custom pages (not Mantine) -- Imports `@homarr/definitions` for type-safe integration/widget metadata -- When adding or modifying widgets, integrations, cron jobs, API routes, env vars, or user-facing features, you MUST also update corresponding docs in `apps/docs/docs/` -- Integration docs: `apps/docs/docs/integrations//index.mdx` + `index.ts` -- Widget docs: `apps/docs/docs/widgets//index.mdx` + `index.ts` -- Strict link checking: broken links/anchors cause build failure diff --git a/.cursor/rules/mantine-datatable-migration.mdc b/.agents/skills/datatable-migration/SKILL.md similarity index 80% rename from .cursor/rules/mantine-datatable-migration.mdc rename to .agents/skills/datatable-migration/SKILL.md index 08d2b8202..62bbc94c3 100644 --- a/.cursor/rules/mantine-datatable-migration.mdc +++ b/.agents/skills/datatable-migration/SKILL.md @@ -1,3 +1,8 @@ +--- +name: datatable-migration +description: Playbook for migrating tables from mantine-react-table (MRT) to mantine-datatable. Use when converting any table component from mantine-react-table to mantine-datatable, working on DataTable column/sort/expansion/context-menu code, or implementing responsive column visibility, column persistence, or transparent tables in widgets. +--- + # Migrating Tables from mantine-react-table to mantine-datatable This document captures every pattern, fix, and decision from the downloads widget rework (PR #6430). Use it as a playbook when porting other tables. @@ -7,10 +12,12 @@ This document captures every pattern, fix, and decision from the downloads widge Current `mantine-react-table` consumers that could be migrated: **Widgets (in `packages/widgets/`):** + - `docker/component.tsx` — Docker container table - `media-server/component.tsx` — Media server sessions **Admin pages (in `apps/nextjs/src/app/[locale]/manage/`):** + - `tools/docker/docker-table.tsx` - `tools/tasks/_components/tasks-table.tsx` - `tools/api/components/api-keys.tsx` @@ -19,12 +26,12 @@ Current `mantine-react-table` consumers that could be migrated: - `tools/kubernetes/` — 8 Kubernetes resource tables **Modals:** + - `packages/modals-collection/src/search-engines/request-media-modal.tsx` **Shared hook to remove once all migrated:** -- `packages/ui/src/hooks/use-translated-mantine-react-table.ts` ---- +- `packages/ui/src/hooks/use-translated-mantine-react-table.ts` ## 1. Core Migration: mantine-react-table → mantine-datatable @@ -67,6 +74,7 @@ const columns: DataTableColumn[] = [ ``` Key differences: + - `accessorKey` → `accessor` - `header` → `title` - `size` → `width` @@ -118,8 +126,6 @@ const sortedData = useMemo(() => { ``` ---- - ## 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. @@ -144,13 +150,21 @@ Create a CSS file alongside the component: /* Hover: translucent instead of opaque */ .my-table .mantine-datatable-table tbody tr:hover { - background-color: color-mix(in srgb, var(--mantine-color-default-hover) 40%, transparent) !important; + background-color: color-mix( + in srgb, + var(--mantine-color-default-hover) 40%, + transparent + ) !important; } /* Headers: frosted glass effect */ .my-table th { white-space: nowrap; - background-color: color-mix(in srgb, var(--mantine-color-body) 60%, transparent) !important; + background-color: color-mix( + in srgb, + var(--mantine-color-body) 60%, + transparent + ) !important; backdrop-filter: blur(8px); } @@ -172,14 +186,16 @@ Create a CSS file alongside the component: /* Row expansion area */ .my-table .mantine-datatable-row-expansion-cell { - background-color: color-mix(in srgb, var(--mantine-color-body) 40%, transparent) !important; + background-color: color-mix( + in srgb, + var(--mantine-color-body) 40%, + transparent + ) !important; } ``` Import it: `import "./styles.css";` and apply via `className="my-table"`. ---- - ## 3. Responsive Column Visibility Use a lookup table of width breakpoints instead of media queries. The widget receives `width` as a prop. @@ -195,11 +211,38 @@ interface SizeConfig { } const SIZE_BREAKPOINTS: { maxWidth: number; config: SizeConfig }[] = [ - { maxWidth: 300, config: { fontSize: "xs", iconSize: 12, cellPadding: 2, showSpeedColumns: false, showTimeColumn: false, showStateColumn: false } }, - { maxWidth: 500, config: { fontSize: "xs", iconSize: 14, cellPadding: 4, showSpeedColumns: false, showTimeColumn: true, showStateColumn: false } }, + { + maxWidth: 300, + config: { + fontSize: "xs", + iconSize: 12, + cellPadding: 2, + showSpeedColumns: false, + showTimeColumn: false, + showStateColumn: false, + }, + }, + { + maxWidth: 500, + config: { + fontSize: "xs", + iconSize: 14, + cellPadding: 4, + showSpeedColumns: false, + showTimeColumn: true, + showStateColumn: false, + }, + }, ]; -const DEFAULT_SIZE_CONFIG: SizeConfig = { fontSize: "sm", iconSize: 14, cellPadding: 4, showSpeedColumns: true, showTimeColumn: true, showStateColumn: true }; +const DEFAULT_SIZE_CONFIG: SizeConfig = { + fontSize: "sm", + iconSize: 14, + cellPadding: 4, + showSpeedColumns: true, + showTimeColumn: true, + showStateColumn: true, +}; function getSizeConfig(width: number): SizeConfig { for (const { maxWidth, config } of SIZE_BREAKPOINTS) { @@ -215,7 +258,9 @@ const size = useMemo(() => getSizeConfig(width), [width]); Then use a visibility check lookup table for columns: ```typescript -const columnVisibilityChecks: Partial boolean>> = { +const columnVisibilityChecks: Partial< + Record boolean> +> = { upSpeed: (ctx) => ctx.hasTorrents && ctx.size.showSpeedColumns, downSpeed: (ctx) => ctx.size.showSpeedColumns, time: (ctx) => ctx.size.showTimeColumn, @@ -230,8 +275,6 @@ function shouldIncludeColumn(accessor: string, ctx: ColumnContext): boolean { } ``` ---- - ## 4. Column Persistence (Drag/Resize → Server) `useDataTableColumns` stores column order and widths in localStorage by default. For widgets, this must persist server-side so it works across devices. @@ -263,15 +306,6 @@ Even hidden options need translation keys or the translation test fails: "widget.mywidget.option.columnWidths.label": "Column widths" ``` -### Old-import mappings - -Add to `packages/old-import/src/widgets/options.ts`: - -```typescript -columnOrder: () => undefined, -columnWidths: () => undefined, -``` - ### Dynamic storeKey Use a key that includes `itemId` and sorted column names. This prevents cross-widget localStorage bleeding: @@ -285,8 +319,13 @@ 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: ```typescript -const { effectiveColumns, columnsOrder, columnsWidth, setColumnsOrder, setMultipleColumnWidths } = - useDataTableColumns({ key: storeKey, columns }); +const { + effectiveColumns, + columnsOrder, + columnsWidth, + setColumnsOrder, + setMultipleColumnWidths, +} = useDataTableColumns({ key: storeKey, columns }); const lastStoreKey = useRef(storeKey); const hydrated = useRef(false); @@ -300,11 +339,22 @@ useEffect(() => { if (savedOrder.length > 0) setColumnsOrder(savedOrder); if (Object.keys(savedWidths).length > 0) { setMultipleColumnWidths( - Object.entries(savedWidths).map(([accessor, w]) => ({ accessor, width: w })), + Object.entries(savedWidths).map(([accessor, w]) => ({ + accessor, + width: w, + })), ); } - requestAnimationFrame(() => { hydrated.current = true; }); -}, [storeKey, savedOrder, savedWidths, setColumnsOrder, setMultipleColumnWidths]); + requestAnimationFrame(() => { + hydrated.current = true; + }); +}, [ + storeKey, + savedOrder, + savedWidths, + setColumnsOrder, + setMultipleColumnWidths, +]); ``` ### Persist localStorage → server @@ -317,22 +367,26 @@ const prevWidths = useRef(columnsWidth); useEffect(() => { if (!hydrated.current) return; - const orderChanged = JSON.stringify(columnsOrder) !== JSON.stringify(prevOrder.current); - const widthsChanged = JSON.stringify(columnsWidth) !== JSON.stringify(prevWidths.current); + const orderChanged = + JSON.stringify(columnsOrder) !== JSON.stringify(prevOrder.current); + const widthsChanged = + JSON.stringify(columnsWidth) !== JSON.stringify(prevWidths.current); prevOrder.current = columnsOrder; prevWidths.current = columnsWidth; if (!orderChanged && !widthsChanged) return; - if (orderChanged) persistOption({ columnOrder: JSON.stringify(columnsOrder) }); + if (orderChanged) + persistOption({ columnOrder: JSON.stringify(columnsOrder) }); if (widthsChanged) { const widthMap: Record = {}; for (const entry of columnsWidth) { const key = Object.keys(entry)[0]; if (!key) continue; - const w = entry[key as keyof typeof entry]; - if (typeof w === "number") widthMap[key] = w; - else if (typeof w === "string" && w.endsWith("px")) widthMap[key] = parseInt(w, 10); + const v = entry[key as keyof typeof entry]; + if (typeof v === "number") widthMap[key] = v; + else if (typeof v === "string" && v.endsWith("px")) + widthMap[key] = parseInt(v, 10); } persistOption({ columnWidths: JSON.stringify(widthMap) }); } @@ -341,8 +395,6 @@ useEffect(() => { The `persistOption` helper calls both `setOptions` (local state) and `saveItemOptions` (server mutation). ---- - ## 5. Context Menu (Right-Click) Do NOT install `mantine-contextmenu`. Use Mantine's `Menu` + `Portal` directly: @@ -374,15 +426,41 @@ const handleContextMenu = useCallback( The menu component uses `Portal` for correct z-index: ```tsx -function RowContextMenu({ state, onClose }: { state: ContextMenuState; onClose: () => void }) { +function RowContextMenu({ + state, + onClose, +}: { + state: ContextMenuState; + onClose: () => void; +}) { return ( - + - + - { /* action */ onClose(); }}>Action + { + /* action */ onClose(); + }} + > + Action + @@ -392,8 +470,6 @@ function RowContextMenu({ state, onClose }: { state: ContextMenuState; onClose: **Key fix:** Use `closeOnItemClick={false}` if any menu item needs a two-step confirmation (like delete → confirm). Close the menu on scroll: `onScroll={() => { if (contextMenu) closeContextMenu(); }}`. ---- - ## 6. Row Expansion ```typescript @@ -413,8 +489,6 @@ function RowContextMenu({ state, onClose }: { state: ContextMenuState; onClose: **Critical:** Do NOT set `onRowClick` when using `rowExpansion` with `trigger: "click"`. They conflict and the expansion won't work. ---- - ## 7. Common Pitfalls & Fixes ### Status indicator dots @@ -475,8 +549,11 @@ Always wrap in try/catch: ```typescript function parseJsonOption(value: string | undefined, fallback: T): T { if (!value) return fallback; - try { return JSON.parse(value) as T; } - catch { return fallback; } + try { + return JSON.parse(value) as T; + } catch { + return fallback; + } } ``` @@ -494,20 +571,19 @@ If the widget has configurable default sort, sync it: ```typescript useEffect(() => { - setSortStatus({ columnAccessor: options.defaultSort, direction: defaultSortDirection }); + setSortStatus({ + columnAccessor: options.defaultSort, + direction: defaultSortDirection, + }); }, [options.defaultSort, defaultSortDirection]); ``` ---- - ## 8. Accessibility - All `ActionIcon` components need `aria-label` - Filter toggles (client badges, status badges) need `aria-pressed={isActive}` - `textSelectionDisabled` on DataTable prevents accidental text selection during drag/resize ---- - ## 9. Error Handling - Every mutation needs an `onError` callback with `showErrorNotification` @@ -518,12 +594,14 @@ useEffect(() => { ```typescript const mutationOptions = { onSettled: () => void utils.myQuery.invalidate(), - onError: () => showErrorNotification({ title: t("errors.actionFailed"), message: t("errors.actionFailedMessage") }), + onError: () => + showErrorNotification({ + title: t("errors.actionFailed"), + message: t("errors.actionFailedMessage"), + }), }; ``` ---- - ## 10. i18n - No hardcoded strings. Every user-visible string goes through `useScopedI18n` @@ -534,8 +612,6 @@ const mutationOptions = { - Errors: `t("errors.{errorKey}")` - Hidden options still need `.label` translation keys ---- - ## 11. Edit Mode Disable pointer events on the table during edit mode to prevent conflicts with the board's drag-and-drop: @@ -550,8 +626,6 @@ if (isEditMode) rowContextMenuHandler = undefined; ``` ---- - ## 12. DataTable Props Reference (Commonly Used) ```typescript @@ -590,8 +664,6 @@ if (isEditMode) rowContextMenuHandler = undefined; /> ``` ---- - ## 13. Layout Stability - Don't use `table-layout: fixed` — it fights mantine-datatable's resize logic @@ -600,8 +672,6 @@ if (isEditMode) rowContextMenuHandler = undefined; - Names should truncate at the cell boundary, not a character count, so the layout is stable regardless of content length - `flexShrink: 0` on badges/icons prevents them from being squeezed by long names ---- - ## 14. Checklist for Each Migration 1. Replace imports (`mantine-react-table` → `mantine-datatable`) @@ -610,13 +680,12 @@ if (isEditMode) rowContextMenuHandler = undefined; 4. Add CSS file for transparent backgrounds (if widget context) 5. Add responsive column visibility (if widget context) 6. Add hidden options + hydration logic (if column persistence needed) -7. Add old-import mappings for new options -8. Add translation keys (including hidden option labels) -9. Add context menu if row actions exist -10. Add row expansion if detail view exists -11. Add error handling on all mutations -12. Add accessibility attributes -13. Test at multiple widget widths -14. Update docs in `apps/docs/docs/widgets/` if applicable -15. Run `pnpm turbo typecheck` — catches old-import mismatches -16. Run translation spec — catches missing keys +7. Add translation keys (including hidden option labels) +8. Add context menu if row actions exist +9. Add row expansion if detail view exists +10. Add error handling on all mutations +11. Add accessibility attributes +12. Test at multiple widget widths +13. Update docs in `apps/docs/docs/widgets/` if applicable +14. Run `pnpm turbo typecheck` +15. Run translation spec — catches missing keys diff --git a/.agents/skills/documentation-sync/SKILL.md b/.agents/skills/documentation-sync/SKILL.md new file mode 100644 index 000000000..48aed0e7e --- /dev/null +++ b/.agents/skills/documentation-sync/SKILL.md @@ -0,0 +1,44 @@ +--- +name: documentation-sync +description: Keep Homarr documentation in sync with code changes. Use when modifying code that affects user-facing behavior, or when adding/modifying widgets, integrations, cron jobs, API routes, env vars, permissions, auth providers, or UI in apps/docs. Covers the docs/ mapping table, integration doc pattern, and widget doc pattern. +--- + +# Documentation Sync + +When modifying code that affects user-facing behavior, you MUST also update the documentation in `apps/docs/`. + +## What triggers a doc update + +| Code change | Doc location | +| ---------------------- | ----------------------------------------------------------- | +| New integration | `apps/docs/docs/integrations//index.mdx` + `index.ts` | +| New widget | `apps/docs/docs/widgets//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//`: + +- `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//`: + +- `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 diff --git a/.cursor/rules/mcp-integration.mdc b/.agents/skills/mcp-integration/SKILL.md similarity index 81% rename from .cursor/rules/mcp-integration.mdc rename to .agents/skills/mcp-integration/SKILL.md index 989b0c4f6..38658da5a 100644 --- a/.cursor/rules/mcp-integration.mdc +++ b/.agents/skills/mcp-integration/SKILL.md @@ -1,13 +1,14 @@ --- -description: How to expose tRPC procedures as MCP tools for AI clients -globs: packages/api/src/router/**/*.ts -alwaysApply: false +name: mcp-integration +description: Expose tRPC procedures as MCP tools for AI clients. Use when modifying procedures in packages/api/src/router, adding a new tRPC procedure or router, or deciding what to expose via Homarr's MCP interface. Covers .meta({ mcp }), router registration in packages/api/src/mcp.ts, input schema rules, and what to expose vs skip. --- -# MCP Integration Guide +# MCP Integration Every new tRPC procedure that provides useful functionality should be exposed as an MCP tool so AI assistants (Claude, Cursor, etc.) can use it. This is a first-class feature of Homarr. +Exposing a procedure makes it callable by any AI client with an API key. Before adding `.meta({ mcp })`, review what the tool exposes: authorization requirements, sensitive data, tenant isolation, auditability, and whether the action is destructive. Prefer exposing read-only queries over mutations unless the mutation is safe and permission-scoped. When in doubt, ask for a security review before shipping a new MCP tool. + ## Adding MCP to a procedure Add `.meta({ mcp: { enabled: true, description: "..." } })` to the procedure chain: @@ -16,7 +17,7 @@ Add `.meta({ mcp: { enabled: true, description: "..." } })` to the procedure cha // Read-only tool (query) myProcedure: protectedProcedure .meta({ mcp: { enabled: true, description: "Get all items with their status and metadata" } }) - .input(z.object({ limit: z.number().default(50) })) + .input(z.object({ limit: z.number().default(10) })) .query(async ({ ctx, input }) => { ... }) // Write tool (mutation) @@ -38,23 +39,27 @@ The `.meta()` call must come **before** `.input()` and `.query()`/`.mutation()` The description is the **only thing** an AI sees to decide when and how to use the tool. Write it like you're explaining to a colleague who has never used Homarr. **Must include:** + - What the tool returns or does - Which integrations/services it works with (if applicable) - How to get required IDs (e.g., "Use integration_all to get the integrationId") - Permission requirements (if non-obvious) **Good:** -``` + +```text "Get calendar events for upcoming and recent media releases. Fetches from all connected Sonarr (TV), Radarr (movies), Lidarr (music), and Readarr (books) integrations. Requires integrationIds from integration_all" ``` **Bad:** -``` + +```text "Get calendar events" ``` **For tools that return permission fields**, explain what they mean: -``` + +```text "List all integrations. Returns permissions.hasUseAccess (read) and permissions.hasInteractAccess (actions) — false means the API key lacks that permission, not an error" ``` @@ -77,11 +82,13 @@ export const mcpRouter = createTRPCRouter({ ## What to expose vs. what to skip **Expose:** + - Data queries (list, search, get by ID) - Actions users would ask an AI to do (toggle, create, delete, approve) - Status checks (health, stats, summaries) **Skip:** + - Subscriptions (WebSocket-only, not supported by MCP) - Internal procedures (session management, onboarding steps) - File upload/download procedures diff --git a/.claude/skills b/.claude/skills new file mode 120000 index 000000000..2b7a412b8 --- /dev/null +++ b/.claude/skills @@ -0,0 +1 @@ +../.agents/skills \ No newline at end of file diff --git a/.cursor/rules/documentation-sync.mdc b/.cursor/rules/documentation-sync.mdc deleted file mode 100644 index a05ca5d84..000000000 --- a/.cursor/rules/documentation-sync.mdc +++ /dev/null @@ -1,50 +0,0 @@ ---- -description: Keep documentation in sync with code changes -globs: - - packages/definitions/src/** - - packages/widgets/src/** - - packages/integrations/src/** - - packages/api/src/** - - apps/nextjs/src/** - - apps/docs/** ---- - -# Documentation Sync Rule - -When modifying code that affects user-facing behavior, you MUST also update the documentation in `apps/docs/`. - -## What triggers a doc update - -| Code change | Doc location | -|---|---| -| New integration | `apps/docs/docs/integrations//index.mdx` + `index.ts` | -| New widget | `apps/docs/docs/widgets//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//`: - -- `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//`: - -- `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 diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index d4b730726..bc12193f0 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -33,6 +33,8 @@ body: options: # The below comment is used to insert a new version with on-release.yml #NEXT_VERSION# + - 1.76.0 + - 1.75.0 - 1.74.0 - 1.73.0 - 1.72.0 diff --git a/.gitignore b/.gitignore index 71a27829a..932d6c9c8 100644 --- a/.gitignore +++ b/.gitignore @@ -71,7 +71,8 @@ apps/nextjs/public/images/background.png # personal agent config .opencode -.agents/ +.agents/* +!.agents/skills/ skills-lock.json # docusaurus diff --git a/.mcp.json b/.mcp.json new file mode 100644 index 000000000..515d300c4 --- /dev/null +++ b/.mcp.json @@ -0,0 +1,8 @@ +{ + "mcpServers": { + "mantine": { + "command": "npx", + "args": ["-y", "@mantine/mcp-server"] + } + } +} \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md index 6c3d36da4..42e69efdd 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -84,6 +84,16 @@ When modifying user-facing code, you MUST also update the corresponding document - Lint: oxlint (not ESLint) - Format: oxfmt (not Prettier) - UI: Mantine (not Tailwind) — Tailwind is only used in docs app +- Mantine: use the `mantine` MCP server (configured in `.mcp.json`; tools `get_item_doc` / `get_item_props` / `search_docs`) for current v9 APIs before writing component code. Prefer built-in primitives (`Combobox`/`useCombobox` for selects, the polymorphic `component` prop, `@mantine/hooks`) over hand-rolled equivalents. Check `packages/ui/` for existing conventions first. + +## MCP servers + +`.mcp.json` at the repo root declares project-scoped MCP servers (the standard Claude Code layout; other tools can import the same `mcpServers` block). See the `mantine` entry there for the Mantine v9 server, and the upstream docs at https://mantine.dev/guides/llms/ for per-tool setup (OpenCode: `.opencode/opencode.json`, Codex: `.codex/config.toml`). Only keyless server definitions belong in `.mcp.json`; anything needing secrets stays in local dotfiles. + +## Agent Skills + +Portable agent skills live in `.agents/skills/` (the Agent Skills open standard). Cursor, Codex, and OpenCode read `.agents/skills/` natively. Claude Code does not scan it, so `.claude/skills/` is a symlink back to `.agents/skills/`. Install skills at `.agents/skills//SKILL.md` — that single copy is used everywhere. See `.agents/skills/` for available skills (documentation-sync, mcp-integration, codebase-context, datatable-migration). + - Icons: @tabler/icons-react - Docs app can import from `@homarr/definitions` for shared types - Run `pnpm dev:cli -- dev` to browse local `homarr:*` images and remote PR images. diff --git a/CHANGELOG.md b/CHANGELOG.md index a829e2042..e347e7660 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,7 @@ +## [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) diff --git a/CODEOWNERS b/CODEOWNERS index 7082b9bac..5565a85fc 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -206,6 +206,14 @@ packages/integrations/src/unraid @manuel-rw apps/docs/docs/integrations/uptime-kuma @ajnart packages/integrations/src/uptime-kuma @ajnart +# What's Up Docker (WUD) - introduced in https://github.com/homarr-labs/homarr/pull/6523 by @tiimonn +apps/docs/docs/integrations/whats-up-docker @tiimonn +apps/docs/docs/widgets/whats-up-docker @tiimonn +packages/api/src/router/widgets/wud.ts @tiimonn +packages/integrations/src/wud @tiimonn +packages/request-handler/src/wud.ts @tiimonn +packages/widgets/src/wud @tiimonn + # Traefik - by @Nerdy-Technician apps/docs/docs/integrations/traefik @Nerdy-Technician apps/docs/docs/widgets/traefik @Nerdy-Technician diff --git a/apps/docs/README.md b/apps/docs/README.md index b66530488..118c04aef 100644 --- a/apps/docs/README.md +++ b/apps/docs/README.md @@ -55,8 +55,8 @@ pnpm typecheck docs/ ├── getting-started/ # Installation guides, glossary, prerequisites ├── management/ # Boards, apps, users, integrations, settings, tasks, API -├── integrations/ # 55 integration guides (one folder each) -├── widgets/ # 42 widget guides (one folder each) +├── integrations/ # 56 integration guides (one folder each) +├── widgets/ # 43 widget guides (one folder each) ├── advanced/ # SSO, env vars, proxy, styling, CLI, development └── community/ # FAQ, donate, license, translations, get-in-touch ``` diff --git a/apps/docs/docs/advanced/icons/index.mdx b/apps/docs/docs/advanced/icons/index.mdx index b73d49e02..0931c6e43 100644 --- a/apps/docs/docs/advanced/icons/index.mdx +++ b/apps/docs/docs/advanced/icons/index.mdx @@ -9,17 +9,19 @@ tags: --- ## Icon picker + Icons in Homarr are automatically requested from multiple sources: - - [Dashboard Icons](https://dashboardicons.com/) - Our recommended source with over 1888+ curated, high-quality icons - - [selfh.st icons](https://github.com/selfhst/icons) - - [Simple icons](https://github.com/simple-icons/simple-icons) - - [Tabler icons](https://tabler-icons.io/) - - [Papirus icons](https://github.com/PapirusDevelopmentTeam/papirus-icon-theme) - - [Homelab Svg assets](https://github.com/loganmarchione/homelab-svg-assets) - - **Local icons**: automatically fetched from the [medias feature](/docs/management/media/) + +- [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) +- [Simple icons](https://github.com/simple-icons/simple-icons) +- [Tabler icons](https://tabler-icons.io/) +- [Papirus icons](https://github.com/PapirusDevelopmentTeam/papirus-icon-theme) +- [Homelab Svg assets](https://github.com/loganmarchione/homelab-svg-assets) +- **Local icons**: automatically fetched from the [medias feature](/docs/management/media/) Using these icon sources, Homarr provides a total collection of over 11,000 icons at your fingertips. -The icon picker is a fast searcher that helps you to find your desired icon simply by entering a search term. +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. ### Finding Missing Icons @@ -29,6 +31,13 @@ If you can't find the icon you're looking for in Homarr's built-in picker, we st Icons that are high quality and printable ([SVG](https://wikipedia.org/wiki/Scalable_Vector_Graphics)) are marked with a red badge "SVG" at the top right. +You can clear the selected image with the clear button or the usual keyboard shortcut for deleting a line (Command+Backspace on macOS or Control+Backspace on other platforms). Clearing the field does not restore the previous image. + ### Using external icons + If you don't find the icon you're looking for on Dashboard Icons or wish to use a different source, you can simply enter a URL that points to an icon. -Direct HTTP(S) image URLs are used as entered instead of being searched in the built-in repositories. It is important that the URL directly points to the file without any advertisements or elements around it. +Direct HTTP(S) image URLs are used as entered instead of being searched in the built-in repositories. The picker previews the image and shows whether it loaded successfully. It is important that the URL directly points to the file without any advertisements or elements around it. + +### Uploading a local image + +Users with media upload permission can select **Upload image** next to the field. After the upload finishes, the new local image is selected immediately and is available at the top of future searches. diff --git a/apps/docs/docs/getting-started/installation/helm.md b/apps/docs/docs/getting-started/installation/helm.md index bd969e5e5..1f68e810b 100644 --- a/apps/docs/docs/getting-started/installation/helm.md +++ b/apps/docs/docs/getting-started/installation/helm.md @@ -2,9 +2,9 @@ homarr logo -![Version: 8.26.0](https://img.shields.io/badge/Version-8.26.0-informational?style=flat) +![Version: 8.28.0](https://img.shields.io/badge/Version-8.28.0-informational?style=flat) ![Type: application](https://img.shields.io/badge/Type-application-informational?style=flat) -![AppVersion: v1.74.0](https://img.shields.io/badge/AppVersion-v1.74.0-informational?style=flat) +![AppVersion: v1.76.0](https://img.shields.io/badge/AppVersion-v1.76.0-informational?style=flat) 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 | | image.pullPolicy | string | `"IfNotPresent"` | Image pull policy | | image.repository | string | `"ghcr.io/homarr-labs/homarr"` | Image repository | -| image.tag | string | `"v1.74.0"` | Overrides the image tag whose default is the chart appVersion | +| image.tag | string | `"v1.76.0"` | Overrides the image tag whose default is the chart appVersion | | imagePullSecrets | list | `[]` | Secrets for Docker registry | | ingress.annotations | object | `{}` | Ingress annotations | | ingress.enabled | bool | `false` | Enable ingress | diff --git a/apps/docs/docs/integrations/truenas/index.mdx b/apps/docs/docs/integrations/truenas/index.mdx index 5d29a7019..00f8f0093 100644 --- a/apps/docs/docs/integrations/truenas/index.mdx +++ b/apps/docs/docs/integrations/truenas/index.mdx @@ -27,6 +27,8 @@ 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 diff --git a/apps/docs/docs/integrations/whats-up-docker/index.mdx b/apps/docs/docs/integrations/whats-up-docker/index.mdx new file mode 100644 index 000000000..31a0127f5 --- /dev/null +++ b/apps/docs/docs/integrations/whats-up-docker/index.mdx @@ -0,0 +1,48 @@ +--- +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"; + + + +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 + + + +### Adding the integration + + + +:::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. + + diff --git a/apps/docs/docs/integrations/whats-up-docker/index.ts b/apps/docs/docs/integrations/whats-up-docker/index.ts new file mode 100644 index 000000000..a69dcafa4 --- /dev/null +++ b/apps/docs/docs/integrations/whats-up-docker/index.ts @@ -0,0 +1,9 @@ +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", +}; diff --git a/apps/docs/docs/management/apps/index.mdx b/apps/docs/docs/management/apps/index.mdx index 7c84df588..efd42277d 100644 --- a/apps/docs/docs/management/apps/index.mdx +++ b/apps/docs/docs/management/apps/index.mdx @@ -28,7 +28,7 @@ The name must be at least one character long and can be chosen freely. ### Icon URL This is the URL to the icon that will be displayed on the app widget. -You can paste a direct HTTP(S) URL to an image, search the included icon repositories by name, or upload a local file through the upload button. Direct image URLs are used as entered and are not searched in the icon repositories. +You can paste a direct HTTP(S) URL to an image, search the included icon repositories by name, or upload a local file through the upload button. 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. ![Icon URL](./img/app-icon-url.png) diff --git a/apps/docs/docs/management/boards/index.mdx b/apps/docs/docs/management/boards/index.mdx index f1eb3c572..93bf78ae3 100644 --- a/apps/docs/docs/management/boards/index.mdx +++ b/apps/docs/docs/management/boards/index.mdx @@ -9,11 +9,11 @@ tags: - Layout --- -import iconColorImage from './img/icon-color.png' -import itemRadiusImage from './img/item-radius.png' -import layoutLargeImage from './img/layout-large.png' -import layoutMediumImage from './img/layout-medium.png' -import layoutSmallImage from './img/layout-small.png' +import iconColorImage from "./img/icon-color.png"; +import itemRadiusImage from "./img/item-radius.png"; +import layoutLargeImage from "./img/layout-large.png"; +import layoutMediumImage from "./img/layout-medium.png"; +import layoutSmallImage from "./img/layout-small.png"; Each dashboard is called a "board". You can create as many boards as you want. @@ -24,24 +24,29 @@ Other boards can be viewed by direct links (e.g. my-homarr.com/board/smart-home) Names of boards must be unique because this will be used in the URL to load the desired board. ## Create a new board + When creating a board, the availability of the board name will be checked for you. ![](./img/create-board-modal.png) ### Name + The name must not include any special characters and must be at least one character long. Underscores and hyphen-minus are allowed. ### Column count -Homarr has a complex drag and drop system that can be used from the browser. + +Homarr has a complex drag-and-drop system that can be used from the browser. The column count defines how many horizontal columns are available to drag and drop elements to. ### Public + This will enable public access for everyone - regardless of whether they are logged in or not. -This can be really useful if you want to have public boards in your company or for family and fiends. +This can be really useful if you want to have public boards in your company or for family and friends. Modifications for public boards must be allowed explicitly using user groups and their permissions. ## Board settings + You navigate to the board settings by clicking on these three dots: ![](./img/navigate-to-settings.png) @@ -50,8 +55,12 @@ Alternatively, you can also navigate from the board page itself using the cog ic ![](./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 + #### Page title + This is the title that will be visible at the top left in the navigation bar: ![](./img/page-title.png) @@ -59,6 +68,7 @@ This is the title that will be visible at the top left in the navigation bar: This field is optional and Homarr will fall back to "Homarr" if nothing is set. #### Meta title + This is the secondary title of the page that is not directly visible in the page itself. Browser will use it to set the title of tabs: @@ -67,10 +77,12 @@ Browser will use it to set the title of tabs: Homarr will fall back to the board name and a suffix if nothing is set. #### Logo image URL + This is the URL that is used for the image at the top left. Homarr will fall back to the Homarr logo if nothing is set. #### Favicon image URL + This is the URL that is used for the favicon in the tab title. Homarr will fall back to the Homarr logo if nothing is set. @@ -94,51 +106,65 @@ Amount of columns for this layout. Defines how many items can be placed in a row #### Breakpoint -Minimum width of the screen where the layout will be used. -Once the minmum of the next layout is reached the layout will change. +Minimum width of the screen where the layout will be used. +Once the minimum of the next layout is reached the layout will change. #### Preview - - - + + + ### Background + #### Background image URL + Homarr can display a background image behind apps and widgets. This URL can be used to define what image should be shown. #### Background image attachment + This defines whether the background should move when the user scrolls. #### Background image size + This defines whether the background should cover the entire page (and possibly be cropped) or never be cropped but may not cover the entire page. #### Background image repeat + This defines whether the background should be repeated if it cannot cover the entire page. This option can be useful if you have patterns that can be repeated easily (e.g. patterns): ![](./img/background-repeat.png) ### Appearance + #### Primary color + Color that can be chosen freely to give accents to active elements. + #### Secondary color + Color that can be chosen freely to give accents. + #### Opacity + Opacity of the colors. + #### Icon color + Change the color of your bookmark / app icons. - + #### Item radius + Change the radius of your items / categories. - - + ### Custom CSS + This is the field where you can enter custom CSS. The styling will only be applied on the respective board page. @@ -147,14 +173,16 @@ The styling will only be applied on the respective board page. ### Behaviour #### Disable app status + This will disable the status check for apps. ### Access control + With the access control, you can permit a single user or a group to your board. The users and groups can have one of these three levels of access: | **Permission Level** | View board | Edit mode | Settings | Danger zone (eg. delete) | -|----------------------|------------|-----------|----------|--------------------------| +| -------------------- | ---------- | --------- | -------- | ------------------------ | | View board | X | | | | | Modify board | X | X | X | | | Full access | X | X | X | X | @@ -173,22 +201,27 @@ After editing permissions, the save button below the table must be pressed. Otherwise, all changes will be discarded. ### Danger zone + #### Rename + Renaming a board will break all existing links to it permanently with no redirect. #### Board visibility + By default, boards will be private. This means only authenticated users that are authorized have access. Making a board public will expose it to everyone - including unauthenticated users. #### Delete + This will permanently delete the board. This action is irreversible and cannot be undone. ## Delete boards + The deletion of boards is permanent and cannot be undone. There are two ways to delete a board: -- Navigate to the settings of your board, open the "Danger zone" dropdown and click on the "delete this board" button. +- Navigate to the settings of your board, open the "Danger zone" section 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". Both actions will execute the same deletion process. @@ -196,7 +229,7 @@ Both actions will execute the same deletion process. ## Duplicate boards :::note -Duplicating a board will not copy the users and groups that have access to the board +Duplicating a board will not copy the users and groups that have access to the board nor the integrations the user does not have access to. ::: @@ -206,47 +239,51 @@ You can duplicate a board by clicking on the three dots in the list of boards an ![](./img/duplicate-board-modal.png) ## Setting boards as your home board + The home board is the dashboard, that will be shown at the root path of Homarr or if no board name was passed in the URL. All the following paths will lead to the home board: -- ``my-homarr.com`` -- ``my-homarr.com/boards`` + +- `my-homarr.com` +- `my-homarr.com/boards` Your home board will have a special small badge at the top right in the list of boards: ![](./img/home-board-flag.png) ### For yourself + To set the home board yourself, the easiest option is to set it directly in the list of boards: ![](./img/set-as-home-board-menu.png) Alternatively, you can also define it via your user profile: + 1. Click on your user profile at the top right -2. Click on ``Your preferences`` -3. Change the home board in the ``home board`` dropdown and click on ``Save``. +2. Click on `Your preferences` +3. Change the home board in the `home board` dropdown and click on `Save`. ### For other users -1. Navigate to the users: ``Management`` > ``Users`` > ``Manage`` +1. Navigate to the users: `Management` > `Users` > `Manage` 2. Click on the username of the user that you want to adjust -3. Select the desired home board via the dropdown and click on ``Save`` \ -![](./img/set-home-board-other-user.png) +3. Select the desired home board via the dropdown and click on `Save` \ + ![](./img/set-home-board-other-user.png) ### For user groups -1. Navigate to the user groups: ``Management`` > ``Users`` > ``Groups`` > ``Manage`` -2. Go to tab ``Settings`` -3. Select the desired home board via the dropdown and click on ``Save`` \ -![](./img/set-home-board-user-group.png) +1. Navigate to the user groups: `Management` > `Users` > `Groups` > `Manage` +2. Go to tab `Settings` +3. Select the desired home board via the dropdown and click on `Save` \ + ![](./img/set-home-board-user-group.png) ### For whole server You can also set a global home board that will be used for all users that have not set a home board themselves. It will also be used for anonymous users. -1. Navigate to the settings: ``Management`` > ``Settings`` > ``Boards`` -2. Select the desired home board via the dropdown and click on ``Save`` \ -![](./img/set-home-board-global.png) +1. Navigate to the settings: `Management` > `Settings` > `Boards` +2. Select the desired home board via the dropdown and click on `Save` \ + ![](./img/set-home-board-global.png) :::note For the global home board you can only select boards that are public. @@ -255,4 +292,4 @@ For the global home board you can only select boards that are public. ### Mobile home board Same as the home board, you can also set a mobile home board. -It will be used for devices that are detected as mobile devices. (Using User-Agent header) \ No newline at end of file +It will be used for devices that are detected as mobile devices. (Using User-Agent header) diff --git a/apps/docs/docs/management/certificates/index.mdx b/apps/docs/docs/management/certificates/index.mdx index cceaa54fd..010e9f9a9 100644 --- a/apps/docs/docs/management/certificates/index.mdx +++ b/apps/docs/docs/management/certificates/index.mdx @@ -52,14 +52,21 @@ 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. ## 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. Most of the time you can find the certificate in the file system of the integration. +The certificate file needs to be uploaded to Homarr. To first get the file on your computer you can either: +- use `scp user@host:/path/to/file ./certificate.pem` +- use `cat /path/to/file` to print the certificate. Then recreate the file on your machine. + Below you can find instructions for some of the integrations. ### Pi-hole (v6) - Since version 6 of Pi-hole, the web interface can be accessed through HTTPS. -The certificate can be found in the path `/etc/pihole/tls_ca.crt`. To add the certificate, this file needs to be copied and uploaded to Homarr. -You can find more details regarding SSL / TLS for Pi-hole [here](https://docs.pi-hole.net/api/tls/). \ No newline at end of file +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 +By default, Proxmox VE uses a node certificate signed by the cluster. You only need to configure Homarr with the certificate of a single node. +The certificate can be found in any node on the path `/etc/pve/pve-root-ca.pem`. +You can learn more about certificate management in Proxmox [here](https://pve.proxmox.com/wiki/Certificate_Management). diff --git a/apps/docs/docs/management/settings/index.mdx b/apps/docs/docs/management/settings/index.mdx index 016d56d10..8507d2b4f 100644 --- a/apps/docs/docs/management/settings/index.mdx +++ b/apps/docs/docs/management/settings/index.mdx @@ -13,6 +13,8 @@ tags: The settings page applies options to the whole Homarr application. 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 Homarr uses [PostHog](https://posthog.com/) to collect anonymous usage data. diff --git a/apps/docs/docs/widgets/smart-home-entity-state/index.mdx b/apps/docs/docs/widgets/smart-home-entity-state/index.mdx index d101d6376..0c991ccc6 100644 --- a/apps/docs/docs/widgets/smart-home-entity-state/index.mdx +++ b/apps/docs/docs/widgets/smart-home-entity-state/index.mdx @@ -43,6 +43,7 @@ import temperature from './img/temperature.png'; ### Interactions - Toggle entity state by clicking on the entity (only when clickable enabled). +- After a successful toggle, the displayed state refreshes immediately. ### Adding the widget @@ -64,4 +65,4 @@ import retrieveEntityId from './img/retrieve-entity-id.png';
homeassistant retrieve-entity-id -
\ No newline at end of file + diff --git a/apps/docs/docs/widgets/smart-home-execute-automation/index.mdx b/apps/docs/docs/widgets/smart-home-execute-automation/index.mdx index 522c113ca..ce73df9d3 100644 --- a/apps/docs/docs/widgets/smart-home-execute-automation/index.mdx +++ b/apps/docs/docs/widgets/smart-home-execute-automation/index.mdx @@ -47,4 +47,7 @@ import withDisplayname from './img/with-displayname.png'; ### Configuration + +Use the Home Assistant entity ID (for example, `automation.turn_on_lights`) in the **Automation ID** field, not the automation's internal ID. + diff --git a/apps/docs/docs/widgets/whats-up-docker/index.mdx b/apps/docs/docs/widgets/whats-up-docker/index.mdx new file mode 100644 index 000000000..0786be36b --- /dev/null +++ b/apps/docs/docs/widgets/whats-up-docker/index.mdx @@ -0,0 +1,35 @@ +--- +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"; + + + +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 + + + +### Adding the widget + + + +### Configuration + + diff --git a/apps/docs/docs/widgets/whats-up-docker/index.ts b/apps/docs/docs/widgets/whats-up-docker/index.ts new file mode 100644 index 000000000..80a516328 --- /dev/null +++ b/apps/docs/docs/widgets/whats-up-docker/index.ts @@ -0,0 +1,37 @@ +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", + }, + ], + }, +}; diff --git a/apps/nextjs/src/app/[locale]/boards/[name]/settings/_appereance.tsx b/apps/nextjs/src/app/[locale]/boards/[name]/settings/_appereance.tsx index 4af4af15f..b03168912 100644 --- a/apps/nextjs/src/app/[locale]/boards/[name]/settings/_appereance.tsx +++ b/apps/nextjs/src/app/[locale]/boards/[name]/settings/_appereance.tsx @@ -19,130 +19,106 @@ import { import { useDisclosure } from "@mantine/hooks"; import { IconX } from "@tabler/icons-react"; -import { useZodForm } from "@homarr/form"; +import type { UseFormReturnType } from "@homarr/form"; import { useI18n } from "@homarr/translation/client"; -import { boardSavePartialSettingsSchema } from "@homarr/validation/board"; -import type { Board } from "../../_types"; +import { SectionCard } from "~/components/manage/section-card"; import { generateColors } from "../../(content)/_theme"; -import { useSavePartialSettingsMutation } from "./_shared"; +import type { FormValues } from "./_settings-form"; interface Props { - board: Board; + form: UseFormReturnType; } const hexRegex = /^#[0-9a-fA-F]{6}$/; const progressPercentageLabel = (value: number) => `${value}%`; -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, - }, - }); +export const ColorSettingsContent = ({ form }: Props) => { const [showPreview, { toggle }] = useDisclosure(false); const t = useI18n(); const theme = useMantineTheme(); - const { mutate: savePartialSettings, isPending } = useSavePartialSettingsMutation(board); return ( -
{ - savePartialSettings({ - id: board.id, - ...values, - }); - })} - > - - - - - color[6])} - {...form.getInputProps("primaryColor")} - /> - - - + + + + color[6])} - {...form.getInputProps("secondaryColor")} + {...form.getInputProps("primaryColor")} /> - - - {showPreview ? t("common.preview.hide") : t("common.preview.show")} - - - - - - - - - - - - - - - - - color[6])} - flex={1} - {...form.getInputProps("iconColor")} - /> - - - - - + + + ); }; diff --git a/apps/nextjs/src/app/[locale]/boards/[name]/settings/_background.tsx b/apps/nextjs/src/app/[locale]/boards/[name]/settings/_background.tsx index 7b105fe9c..8a796560f 100644 --- a/apps/nextjs/src/app/[locale]/boards/[name]/settings/_background.tsx +++ b/apps/nextjs/src/app/[locale]/boards/[name]/settings/_background.tsx @@ -1,41 +1,32 @@ "use client"; import { startTransition } from "react"; -import { ActionIcon, Autocomplete, Button, Center, Grid, Group, Popover, Stack, Text } from "@mantine/core"; +import { ActionIcon, Autocomplete, Center, Grid, Group, Popover, Stack, Text } from "@mantine/core"; import { useDebouncedValue } from "@mantine/hooks"; import { IconPhotoOff, IconUpload } from "@tabler/icons-react"; import { clientApi } from "@homarr/api/client"; import { useSession } from "@homarr/auth/client"; import { backgroundImageAttachments, backgroundImageRepeats, backgroundImageSizes } from "@homarr/definitions"; -import { useZodForm } from "@homarr/form"; +import type { UseFormReturnType } from "@homarr/form"; import { UploadMedia } from "@homarr/forms-collection"; import type { TranslationObject } from "@homarr/translation"; import { useI18n } from "@homarr/translation/client"; import type { SelectItemWithDescriptionBadge } from "@homarr/ui"; import { SelectWithDescriptionBadge } from "@homarr/ui"; -import { boardSavePartialSettingsSchema } from "@homarr/validation/board"; -import type { Board } from "../../_types"; -import { useSavePartialSettingsMutation } from "./_shared"; +import { SectionCard } from "~/components/manage/section-card"; +import type { FormValues } from "./_settings-form"; interface Props { - board: Board; + form: UseFormReturnType; } -export const BackgroundSettingsContent = ({ board }: Props) => { + +export const BackgroundSettingsContent = ({ form }: Props) => { const t = useI18n(); 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({ page: 1, pageSize: 10, @@ -53,121 +44,106 @@ export const BackgroundSettingsContent = ({ board }: Props) => { const backgroundImageRepeatData = useBackgroundOptionData("backgroundImageRepeat", backgroundImageRepeats); return ( - { - savePartialSettings({ - id: board.id, - ...values, - }); - })} - > - - - - - = 2 && ( - - -
- -
-
- - - -
- ) - } - // 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; + + + + + = 2 && ( + + +
+ +
+
+ + + +
+ ) + } + // 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 ( - - - - {current.name} - - {option.value} - - - - ); + return ( + + + + {current.name} + + {option.value} + + + + ); + }} + 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") && ( + { + const first = uploadedMedias.at(0); + if (!first) return; + + startTransition(() => { + form.setFieldValue("backgroundImageUrl", first.url); + }); }} - 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") && ( - { - const first = medias.at(0); - if (!first) return; - - startTransition(() => { - form.setFieldValue("backgroundImageUrl", first.url); - }); - }} - > - {({ onClick, loading }) => ( - - - - )} - - )} -
-
- - - - - - - - - -
- - - - -
- + > + {({ onClick, loading }) => ( + + + + )} + + )} + +
+ + + + + + + + + +
+ ); }; diff --git a/apps/nextjs/src/app/[locale]/boards/[name]/settings/_behavior.tsx b/apps/nextjs/src/app/[locale]/boards/[name]/settings/_behavior.tsx index 9eded5ec4..563fb97eb 100644 --- a/apps/nextjs/src/app/[locale]/boards/[name]/settings/_behavior.tsx +++ b/apps/nextjs/src/app/[locale]/boards/[name]/settings/_behavior.tsx @@ -1,48 +1,27 @@ "use client"; -import { Button, Group, Stack, Switch } from "@mantine/core"; +import { Switch } from "@mantine/core"; -import { useForm } from "@homarr/form"; +import type { UseFormReturnType } from "@homarr/form"; import { useI18n } from "@homarr/translation/client"; -import type { Board } from "../../_types"; -import { useSavePartialSettingsMutation } from "./_shared"; +import { SectionCard } from "~/components/manage/section-card"; +import type { FormValues } from "./_settings-form"; interface Props { - board: Board; + form: UseFormReturnType; } -export const BehaviorSettingsContent = ({ board }: Props) => { +export const BehaviorSettingsContent = ({ form }: Props) => { const t = useI18n(); - const { mutate: savePartialSettings, isPending } = useSavePartialSettingsMutation(board); - const form = useForm({ - initialValues: { - disableStatus: board.disableStatus, - }, - }); return ( -
{ - savePartialSettings({ - id: board.id, - ...values, - }); - })} - > - - - - - - - -
+ + + ); }; diff --git a/apps/nextjs/src/app/[locale]/boards/[name]/settings/_customCss.tsx b/apps/nextjs/src/app/[locale]/boards/[name]/settings/_customCss.tsx index e0d8ec565..e5df59a9f 100644 --- a/apps/nextjs/src/app/[locale]/boards/[name]/settings/_customCss.tsx +++ b/apps/nextjs/src/app/[locale]/boards/[name]/settings/_customCss.tsx @@ -1,6 +1,6 @@ "use client"; -import { Alert, Button, Group, Input, Stack } from "@mantine/core"; +import { Alert, Input } from "@mantine/core"; import { highlight, languages } from "prismjs"; import Editor from "react-simple-code-editor"; @@ -8,59 +8,39 @@ import "~/styles/prismjs.scss"; import { IconInfoCircle } from "@tabler/icons-react"; -import { useForm } from "@homarr/form"; +import type { UseFormReturnType } from "@homarr/form"; import { useI18n, useScopedI18n } from "@homarr/translation/client"; -import type { Board } from "../../_types"; -import { useSavePartialSettingsMutation } from "./_shared"; +import { SectionCard } from "~/components/manage/section-card"; +import type { FormValues } from "./_settings-form"; import classes from "./customcss.module.css"; interface Props { - board: Board; + form: UseFormReturnType; } -export const CustomCssSettingsContent = ({ board }: Props) => { +export const CustomCssSettingsContent = ({ form }: Props) => { const t = useI18n(); const customCssT = useScopedI18n("board.field.customCss"); - const { mutate: savePartialSettings, isPending } = useSavePartialSettingsMutation(board); - const form = useForm({ - initialValues: { - customCss: board.customCss ?? "", - }, - }); return ( -
{ - savePartialSettings({ - id: board.id, - ...values, - }); - })} - > - - + + - }> - {customCssT("customClassesAlert.description")} - - - - - - -
+ }> + {customCssT("customClassesAlert.description")} + + ); }; interface CustomCssInputProps { value?: string; onChange: (value: string) => void; + error?: React.ReactNode; } -const CustomCssInput = ({ value, onChange }: CustomCssInputProps) => { +const CustomCssInput = ({ value, onChange, error }: CustomCssInputProps) => { const customCssT = useScopedI18n("board.field.customCss"); return ( @@ -70,6 +50,7 @@ const CustomCssInput = ({ value, onChange }: CustomCssInputProps) => { htmlFor: "custom-css", }} description={customCssT("description")} + error={error} inputWrapperOrder={["label", "description", "input", "error"]} >
diff --git a/apps/nextjs/src/app/[locale]/boards/[name]/settings/_danger.tsx b/apps/nextjs/src/app/[locale]/boards/[name]/settings/_danger.tsx index 699da6003..92454f20c 100644 --- a/apps/nextjs/src/app/[locale]/boards/[name]/settings/_danger.tsx +++ b/apps/nextjs/src/app/[locale]/boards/[name]/settings/_danger.tsx @@ -2,7 +2,7 @@ import { useCallback } from "react"; import { useRouter } from "next/navigation"; -import { Button, Divider, Group, Stack, Text } from "@mantine/core"; +import { Button } from "@mantine/core"; import { clientApi } from "@homarr/api/client"; import { useRequiredBoard } from "@homarr/boards/context"; @@ -10,7 +10,7 @@ import { useConfirmModal, useModalAction } from "@homarr/modals"; import { useScopedI18n } from "@homarr/translation/client"; import { BoardRenameModal } from "~/components/board/modals/board-rename-modal"; -import classes from "./danger.module.css"; +import { DangerZoneItem, DangerZoneRoot } from "~/components/manage/danger-zone"; export const DangerZoneSettingsContent = ({ hideVisibility }: { hideVisibility: boolean }) => { const board = useRequiredBoard(); @@ -82,60 +82,36 @@ export const DangerZoneSettingsContent = ({ hideVisibility }: { hideVisibility: }, [board.id, deleteBoard, router, t, openConfirmModal]); return ( - - - + + {t("section.dangerZone.action.rename.button")} + + } /> - {hideVisibility ? null : ( - <> - - - + {!hideVisibility && ( + + {t(`section.dangerZone.action.visibility.button.${visibility}`)} + + } + /> )} - - + {t("section.dangerZone.action.delete.button")} + + } /> - - ); -}; - -interface DangerZoneRowProps { - label: string; - description: string; - buttonText: string; - isPending?: boolean; - onClick: () => void; -} - -const DangerZoneRow = ({ label, description, buttonText, onClick, isPending }: DangerZoneRowProps) => { - return ( - - - - {label} - - {description} - - - - - + ); }; diff --git a/apps/nextjs/src/app/[locale]/boards/[name]/settings/_general.tsx b/apps/nextjs/src/app/[locale]/boards/[name]/settings/_general.tsx index 0bfc79173..2fcb119a0 100644 --- a/apps/nextjs/src/app/[locale]/boards/[name]/settings/_general.tsx +++ b/apps/nextjs/src/app/[locale]/boards/[name]/settings/_general.tsx @@ -1,128 +1,66 @@ "use client"; -import { useEffect, useRef } from "react"; -import { Button, Grid, Group, Loader, Stack, TextInput } from "@mantine/core"; +import { useEffect } from "react"; +import { Grid, Loader, TextInput } from "@mantine/core"; import { useDebouncedValue, useDocumentTitle } from "@mantine/hooks"; import { useUpdateBoard } from "@homarr/boards/updater"; -import { useZodForm } from "@homarr/form"; +import type { UseFormReturnType } from "@homarr/form"; import { IconPicker } from "@homarr/forms-collection"; import { useI18n } from "@homarr/translation/client"; -import { boardSavePartialSettingsSchema } from "@homarr/validation/board"; -import { homarrLogoPath } from "~/components/layout/logo/homarr-logo"; +import { SectionCard } from "~/components/manage/section-card"; import { createMetaTitle } from "~/metadata"; import type { Board } from "../../_types"; -import { useSavePartialSettingsMutation } from "./_shared"; +import type { FormValues } from "./_settings-form"; interface Props { board: Board; + form: UseFormReturnType; } -export const GeneralSettingsContent = ({ board }: Props) => { +export const GeneralSettingsContent = ({ board, form }: Props) => { 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); 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 ( -
{ - // Save the current values to the ref so that it does not reset if the form is submitted - ref.current = { - pageTitle: values.pageTitle, - logoImageUrl: values.logoImageUrl, - }; - updateFavicon(values.faviconImageUrl ?? homarrLogoPath); - savePartialSettings({ - id: board.id, - ...values, - }); - })} - > - - - - - - - } - {...form.getInputProps("metaTitle")} - /> - - - - - - - - - - - - -
+ + + + + + + } + {...form.getInputProps("metaTitle")} + /> + + + + + + + + + ); }; @@ -146,14 +84,3 @@ const useMetaTitlePreview = (title: string | null) => { 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; -}; diff --git a/apps/nextjs/src/app/[locale]/boards/[name]/settings/_layout.tsx b/apps/nextjs/src/app/[locale]/boards/[name]/settings/_layout.tsx index 97a576a30..c592381ff 100644 --- a/apps/nextjs/src/app/[locale]/boards/[name]/settings/_layout.tsx +++ b/apps/nextjs/src/app/[locale]/boards/[name]/settings/_layout.tsx @@ -2,114 +2,79 @@ import { Button, Fieldset, Grid, Group, Input, NumberInput, Slider, Stack, Text, TextInput } from "@mantine/core"; -import { clientApi } from "@homarr/api/client"; import { createId } from "@homarr/common"; -import { useZodForm } from "@homarr/form"; +import type { UseFormReturnType } from "@homarr/form"; import { useI18n } from "@homarr/translation/client"; -import { boardSaveLayoutsSchema } from "@homarr/validation/board"; -import type { Board } from "../../_types"; +import { SectionCard } from "~/components/manage/section-card"; +import type { FormValues } from "./_settings-form"; interface Props { - board: Board; + form: UseFormReturnType; } -export const LayoutSettingsContent = ({ board }: Props) => { + +export const LayoutSettingsContent = ({ form }: Props) => { 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(boardSaveLayoutsSchema.omit({ id: true }).required(), { - initialValues: { - layouts: board.layouts, - }, - }); return ( -
{ - saveLayouts({ - id: board.id, - ...values, - }); - })} - > - - - - {t("board.setting.section.layout.responsive.title")} - - - - {form.values.layouts.map((layout, index) => ( -
- - - - - - - - - - - - - - - - {form.values.layouts.length >= 2 && ( - - - - )} -
- ))} -
- - - + + {form.values.layouts.map((layout, index) => ( +
+ + + + + + + + + + + + + + + + {form.values.layouts.length >= 2 && ( + + + + )} +
+ ))}
-
+ ); }; diff --git a/apps/nextjs/src/app/[locale]/boards/[name]/settings/_settings-form.tsx b/apps/nextjs/src/app/[locale]/boards/[name]/settings/_settings-form.tsx new file mode 100644 index 000000000..b8e66cf4b --- /dev/null +++ b/apps/nextjs/src/app/[locale]/boards/[name]/settings/_settings-form.tsx @@ -0,0 +1,219 @@ +"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; + +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 = (...fields: TKey[]) => + fields.some((field) => values[field] !== defaults[field]); + + const { layouts, ...partialSettings } = values; + + const saveActions: { when: boolean; action: () => Promise }[] = [ + { + 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 ( +
void handleSubmitAsync(values))}> + + + + + + + + + {hasFullAccess && ( + + + + )} + + {hasFullAccess && } + + {form.isDirty() && ( + + + + + )} + +
+ ); +}; + +// 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('link[rel="icon"]'); + if (!link) return; + link.href = url; +}; diff --git a/apps/nextjs/src/app/[locale]/boards/[name]/settings/_shared.tsx b/apps/nextjs/src/app/[locale]/boards/[name]/settings/_shared.tsx index 9fa888be2..d0fedf522 100644 --- a/apps/nextjs/src/app/[locale]/boards/[name]/settings/_shared.tsx +++ b/apps/nextjs/src/app/[locale]/boards/[name]/settings/_shared.tsx @@ -11,3 +11,13 @@ 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(); + }, + }); +}; diff --git a/apps/nextjs/src/app/[locale]/boards/[name]/settings/danger.module.css b/apps/nextjs/src/app/[locale]/boards/[name]/settings/danger.module.css deleted file mode 100644 index 39a60b735..000000000 --- a/apps/nextjs/src/app/[locale]/boards/[name]/settings/danger.module.css +++ /dev/null @@ -1,5 +0,0 @@ -@media (min-width: 36em) { - .dangerZoneGroup { - --group-wrap: nowrap !important; - } -} diff --git a/apps/nextjs/src/app/[locale]/boards/[name]/settings/page.tsx b/apps/nextjs/src/app/[locale]/boards/[name]/settings/page.tsx index a728e8949..318d5196a 100644 --- a/apps/nextjs/src/app/[locale]/boards/[name]/settings/page.tsx +++ b/apps/nextjs/src/app/[locale]/boards/[name]/settings/page.tsx @@ -1,44 +1,20 @@ -import type { PropsWithChildren } from "react"; import { notFound } from "next/navigation"; -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 { Container, Stack, Title } from "@mantine/core"; import { TRPCError } from "@trpc/server"; import { api } from "@homarr/api/server"; import { capitalize } from "@homarr/common"; import { db } from "@homarr/db"; import { getServerSettingByKeyAsync } from "@homarr/db/queries"; -import type { TranslationObject } from "@homarr/translation"; import { getScopedI18n } from "@homarr/translation/server"; -import type { TablerIcon } from "@homarr/ui"; import { getBoardPermissionsAsync } from "~/components/board/permissions/server"; -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"; +import { BoardSettingsForm } from "./_settings-form"; interface Props { params: Promise<{ name: string; }>; - searchParams: Promise<{ - tab?: keyof TranslationObject["board"]["setting"]["section"]; - }>; } const getBoardAndPermissionsAsync = async (params: Awaited) => { @@ -57,11 +33,7 @@ const getBoardAndPermissionsAsync = async (params: Awaited) => } catch (error) { // Ignore not found errors and redirect to 404 // error is already logged in _layout-creator.tsx - if (error instanceof TRPCError && error.code === "NOT_FOUND") { - notFound(); - } - - if (error instanceof TRPCError && error.code === "BAD_REQUEST") { + if (error instanceof TRPCError && (error.code === "NOT_FOUND" || error.code === "BAD_REQUEST")) { notFound(); } @@ -70,7 +42,6 @@ const getBoardAndPermissionsAsync = async (params: Awaited) => }; export default async function BoardSettingsPage(props: Props) { - const searchParams = await props.searchParams; const params = await props.params; const { board, permissions } = await getBoardAndPermissionsAsync(params); const boardSettings = await getServerSettingByKeyAsync(db, "board"); @@ -85,76 +56,13 @@ export default async function BoardSettingsPage(props: Props) { {t("title", { boardName: capitalize(board.name) })} - - - - - - - - - - - - - - - - - - - - {hasFullAccess && ( - <> - - - - - - - - )} - + ); } - -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 ( - - }> - - {t(`${value}.title`)} - - - - {children} - - - ); -}; diff --git a/apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-form.tsx b/apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-form.tsx index c2820757f..446658f80 100644 --- a/apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-form.tsx +++ b/apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-form.tsx @@ -697,7 +697,11 @@ export function CustomWidgetForm({ mode, initialValues, definitionId }: CustomWi