diff --git a/.cursor/rules/mantine-datatable-migration.mdc b/.cursor/rules/mantine-datatable-migration.mdc new file mode 100644 index 000000000..08d2b8202 --- /dev/null +++ b/.cursor/rules/mantine-datatable-migration.mdc @@ -0,0 +1,622 @@ +# Migrating Tables from mantine-react-table to mantine-datatable + +This document captures every pattern, fix, and decision from the downloads widget rework (PR #6430). Use it as a playbook when porting other tables. + +## Migration Candidates + +Current `mantine-react-table` consumers that could be migrated: + +**Widgets (in `packages/widgets/`):** +- `docker/component.tsx` — Docker container table +- `media-server/component.tsx` — Media server sessions + +**Admin pages (in `apps/nextjs/src/app/[locale]/manage/`):** +- `tools/docker/docker-table.tsx` +- `tools/tasks/_components/tasks-table.tsx` +- `tools/api/components/api-keys.tsx` +- `users/_components/user-list.tsx` +- `users/invites/_components/invite-list.tsx` +- `tools/kubernetes/` — 8 Kubernetes resource tables + +**Modals:** +- `packages/modals-collection/src/search-engines/request-media-modal.tsx` + +**Shared hook to remove once all migrated:** +- `packages/ui/src/hooks/use-translated-mantine-react-table.ts` + +--- + +## 1. Core Migration: mantine-react-table → mantine-datatable + +### Import changes + +```typescript +// BEFORE +import { MantineReactTable, useMantineReactTable } from "mantine-react-table"; +import type { MRT_ColumnDef } from "mantine-react-table"; + +// AFTER +import { DataTable, useDataTableColumns } from "mantine-datatable"; +import type { DataTableColumn, DataTableSortStatus } from "mantine-datatable"; +``` + +### Column definition shape + +```typescript +// BEFORE (MRT) +const columns: MRT_ColumnDef[] = [ + { + accessorKey: "name", + header: "Name", + size: 200, + enableSorting: true, + Cell: ({ row }) => {row.original.name}, + }, +]; + +// AFTER (mantine-datatable) +const columns: DataTableColumn[] = [ + { + accessor: "name", // not accessorKey + title: "Name", // not header + width: 200, // not size + sortable: true, // not enableSorting + render: (record) => {record.name}, // not Cell + }, +]; +``` + +Key differences: +- `accessorKey` → `accessor` +- `header` → `title` +- `size` → `width` +- `Cell: ({ row }) =>` → `render: (record) =>` +- `enableSorting` → `sortable` +- `enableColumnActions` → does not exist (no column action menus) +- `mantineTableBodyCellProps` → `cellsStyle` in `defaultColumnProps` + +### Table component + +```typescript +// BEFORE +const table = useTranslatedMantineReactTable({ columns, data, ... }); +return ; + +// AFTER +return ( + +); +``` + +### Sorting is manual + +mantine-datatable does NOT sort for you. You must sort the data yourself: + +```typescript +const [sortStatus, setSortStatus] = useState>({ + columnAccessor: "name", + direction: "asc", +}); + +const sortedData = useMemo(() => { + const mult = sortStatus.direction === "desc" ? -1 : 1; + return [...data].toSorted((a, b) => { + const aVal = a[sortStatus.columnAccessor]; + const bVal = b[sortStatus.columnAccessor]; + if (typeof aVal === "string") return aVal.localeCompare(bVal as string) * mult; + return ((aVal as number) - (bVal as number)) * mult; + }); +}, [data, sortStatus]); + +// Pass sortedData to records, not data + +``` + +--- + +## 2. Making Tables Transparent (Widget Context) + +Widgets sit inside board cards that have user-controlled opacity. The table must be fully transparent so the card background shows through. mantine-datatable defaults to `var(--mantine-color-body)` which is opaque. + +Create a CSS file alongside the component: + +```css +/* styles.css */ +.my-table, +.my-table .mantine-datatable-table, +.my-table .mantine-datatable-table thead, +.my-table .mantine-datatable-table tbody, +.my-table .mantine-datatable-table tfoot, +.my-table th, +.my-table td { + background-color: transparent !important; +} + +.my-table .mantine-datatable-table tr { + background-color: transparent; +} + +/* Hover: translucent instead of opaque */ +.my-table .mantine-datatable-table tbody tr:hover { + background-color: color-mix(in srgb, var(--mantine-color-default-hover) 40%, transparent) !important; +} + +/* Headers: frosted glass effect */ +.my-table th { + white-space: nowrap; + background-color: color-mix(in srgb, var(--mantine-color-body) 60%, transparent) !important; + backdrop-filter: blur(8px); +} + +/* Cell overflow */ +.my-table td { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +/* Hide sort icon until hover */ +.my-table .mantine-datatable-header-cell-sortable-icon { + opacity: 0; + transition: opacity 150ms; +} +.my-table th:hover .mantine-datatable-header-cell-sortable-icon { + opacity: 1; +} + +/* Row expansion area */ +.my-table .mantine-datatable-row-expansion-cell { + background-color: color-mix(in srgb, var(--mantine-color-body) 40%, transparent) !important; +} +``` + +Import it: `import "./styles.css";` and apply via `className="my-table"`. + +--- + +## 3. Responsive Column Visibility + +Use a lookup table of width breakpoints instead of media queries. The widget receives `width` as a prop. + +```typescript +interface SizeConfig { + fontSize: "xs" | "sm"; + iconSize: number; + cellPadding: number; + showSpeedColumns: boolean; + showTimeColumn: boolean; + showStateColumn: boolean; +} + +const SIZE_BREAKPOINTS: { maxWidth: number; config: SizeConfig }[] = [ + { maxWidth: 300, config: { fontSize: "xs", iconSize: 12, cellPadding: 2, showSpeedColumns: false, showTimeColumn: false, showStateColumn: false } }, + { maxWidth: 500, config: { fontSize: "xs", iconSize: 14, cellPadding: 4, showSpeedColumns: false, showTimeColumn: true, showStateColumn: false } }, +]; + +const DEFAULT_SIZE_CONFIG: SizeConfig = { fontSize: "sm", iconSize: 14, cellPadding: 4, showSpeedColumns: true, showTimeColumn: true, showStateColumn: true }; + +function getSizeConfig(width: number): SizeConfig { + for (const { maxWidth, config } of SIZE_BREAKPOINTS) { + if (width < maxWidth) return config; + } + return DEFAULT_SIZE_CONFIG; +} + +// Usage: memoize it +const size = useMemo(() => getSizeConfig(width), [width]); +``` + +Then use a visibility check lookup table for columns: + +```typescript +const columnVisibilityChecks: Partial boolean>> = { + upSpeed: (ctx) => ctx.hasTorrents && ctx.size.showSpeedColumns, + downSpeed: (ctx) => ctx.size.showSpeedColumns, + time: (ctx) => ctx.size.showTimeColumn, + state: (ctx) => ctx.size.showStateColumn, +}; + +function shouldIncludeColumn(accessor: string, ctx: ColumnContext): boolean { + if (!ctx.optionsColumnSet.has(accessor)) return false; + const check = columnVisibilityChecks[accessor]; + if (check && !check(ctx)) return false; + return true; +} +``` + +--- + +## 4. Column Persistence (Drag/Resize → Server) + +`useDataTableColumns` stores column order and widths in localStorage by default. For widgets, this must persist server-side so it works across devices. + +### Hidden widget options + +In `index.ts`, add hidden text options: + +```typescript +columnOrder: factory.text({ defaultValue: "" }), +columnWidths: factory.text({ defaultValue: "" }), +``` + +With visibility override: + +```typescript +{ + columnOrder: { shouldHide: () => true }, + columnWidths: { shouldHide: () => true }, +} +``` + +### Translation keys required + +Even hidden options need translation keys or the translation test fails: + +```json +"widget.mywidget.option.columnOrder.label": "Column order", +"widget.mywidget.option.columnWidths.label": "Column widths" +``` + +### Old-import mappings + +Add to `packages/old-import/src/widgets/options.ts`: + +```typescript +columnOrder: () => undefined, +columnWidths: () => undefined, +``` + +### Dynamic storeKey + +Use a key that includes `itemId` and sorted column names. This prevents cross-widget localStorage bleeding: + +```typescript +const storeKey = `mytable-${itemId ?? "preview"}-${[...options.columns].toSorted().join(",")}`; +``` + +### Hydration from server → localStorage + +On mount, seed `useDataTableColumns` with server-persisted values. Use a `hydrated` ref to prevent the persist effect from immediately writing defaults back: + +```typescript +const { effectiveColumns, columnsOrder, columnsWidth, setColumnsOrder, setMultipleColumnWidths } = + useDataTableColumns({ key: storeKey, columns }); + +const lastStoreKey = useRef(storeKey); +const hydrated = useRef(false); + +useEffect(() => { + if (lastStoreKey.current !== storeKey) { + lastStoreKey.current = storeKey; + hydrated.current = false; + } + if (hydrated.current) return; + if (savedOrder.length > 0) setColumnsOrder(savedOrder); + if (Object.keys(savedWidths).length > 0) { + setMultipleColumnWidths( + Object.entries(savedWidths).map(([accessor, w]) => ({ accessor, width: w })), + ); + } + requestAnimationFrame(() => { hydrated.current = true; }); +}, [storeKey, savedOrder, savedWidths, setColumnsOrder, setMultipleColumnWidths]); +``` + +### Persist localStorage → server + +Watch for changes and write back: + +```typescript +const prevOrder = useRef(columnsOrder); +const prevWidths = useRef(columnsWidth); + +useEffect(() => { + if (!hydrated.current) return; + const orderChanged = JSON.stringify(columnsOrder) !== JSON.stringify(prevOrder.current); + const widthsChanged = JSON.stringify(columnsWidth) !== JSON.stringify(prevWidths.current); + prevOrder.current = columnsOrder; + prevWidths.current = columnsWidth; + + if (!orderChanged && !widthsChanged) return; + + if (orderChanged) persistOption({ columnOrder: JSON.stringify(columnsOrder) }); + if (widthsChanged) { + const widthMap: Record = {}; + for (const entry of columnsWidth) { + const key = Object.keys(entry)[0]; + if (!key) continue; + const w = entry[key as keyof typeof entry]; + if (typeof w === "number") widthMap[key] = w; + else if (typeof w === "string" && w.endsWith("px")) widthMap[key] = parseInt(w, 10); + } + persistOption({ columnWidths: JSON.stringify(widthMap) }); + } +}, [columnsOrder, columnsWidth, persistOption]); +``` + +The `persistOption` helper calls both `setOptions` (local state) and `saveItemOptions` (server mutation). + +--- + +## 5. Context Menu (Right-Click) + +Do NOT install `mantine-contextmenu`. Use Mantine's `Menu` + `Portal` directly: + +```typescript +interface ContextMenuState { + x: number; + y: number; + item: MyRow; +} + +const [contextMenu, setContextMenu] = useState(null); + +const handleContextMenu = useCallback( + ({ record, event }: { record: MyRow; event: React.MouseEvent }) => { + event.preventDefault(); + setContextMenu({ x: event.clientX, y: event.clientY, item: record }); + }, + [], +); + +// On DataTable: + + +// Render: +{contextMenu && } +``` + +The menu component uses `Portal` for correct z-index: + +```tsx +function RowContextMenu({ state, onClose }: { state: ContextMenuState; onClose: () => void }) { + return ( + + + + + + + { /* action */ onClose(); }}>Action + + + + ); +} +``` + +**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 + , + }} +/> +``` + +**Critical:** Do NOT set `onRowClick` when using `rowExpansion` with `trigger: "click"`. They conflict and the expansion won't work. + +--- + +## 7. Common Pitfalls & Fixes + +### Status indicator dots + +Don't use `` without children — it renders a pill with empty space. Use a plain `Box`: + +```tsx + +``` + +### Tooltip overflow on long text columns + +When a column has `ellipsis: true`, the tooltip target element can still extend beyond the cell bounds if inner elements don't clip. Fix: + +```tsx + + + ... + + +``` + +Use `position="bottom-start"` instead of `position="right"` so the tooltip never escapes the widget horizontally. + +### Dark mode tooltips + +Mantine tooltips default to the theme's tooltip color which may be white in light mode and stay white in dark mode. Always set `color="dark"` on informational tooltips inside widgets. + +### Division by zero + +Guard all ratio calculations: + +```typescript +if (item.size > 0) ratio = item.sent / item.size; +if (totalDown > 0) ratio = totalUp / totalDown; +``` + +### Composite idAccessor + +When rows can come from multiple integrations, IDs may collide. Use a composite key: + +```typescript +idAccessor={(record) => `${record.integration.id}:${record.id}`} +``` + +### JSON option parsing + +Always wrap in try/catch: + +```typescript +function parseJsonOption(value: string | undefined, fallback: T): T { + if (!value) return fallback; + try { return JSON.parse(value) as T; } + catch { return fallback; } +} +``` + +### Clipboard API + +`navigator.clipboard.writeText()` can throw in non-HTTPS contexts. Always catch: + +```typescript +void navigator.clipboard.writeText(text).catch(() => {}); +``` + +### Sync sortStatus with widget options + +If the widget has configurable default sort, sync it: + +```typescript +useEffect(() => { + setSortStatus({ columnAccessor: options.defaultSort, direction: defaultSortDirection }); +}, [options.defaultSort, defaultSortDirection]); +``` + +--- + +## 8. Accessibility + +- All `ActionIcon` components need `aria-label` +- Filter toggles (client badges, status badges) need `aria-pressed={isActive}` +- `textSelectionDisabled` on DataTable prevents accidental text selection during drag/resize + +--- + +## 9. Error Handling + +- Every mutation needs an `onError` callback with `showErrorNotification` +- The `saveItemOptions` mutation (column persistence) also needs `onError` +- Check `isError` on the main query and show a fallback UI +- Use a shared `mutationOptions` object to avoid repeating `onSettled`/`onError`: + +```typescript +const mutationOptions = { + onSettled: () => void utils.myQuery.invalidate(), + onError: () => showErrorNotification({ title: t("errors.actionFailed"), message: t("errors.actionFailedMessage") }), +}; +``` + +--- + +## 10. i18n + +- No hardcoded strings. Every user-visible string goes through `useScopedI18n` +- Column titles: `t("items.{accessor}.columnTitle")` +- Detail labels: `t("items.{accessor}.detailsTitle")` +- States: `t("states.{state}")` +- Actions: `t("actions.{action}")` +- Errors: `t("errors.{errorKey}")` +- Hidden options still need `.label` translation keys + +--- + +## 11. Edit Mode + +Disable pointer events on the table during edit mode to prevent conflicts with the board's drag-and-drop: + +```typescript +let tablePointerEvents: React.CSSProperties["pointerEvents"]; +if (isEditMode) tablePointerEvents = "none"; + +let rowContextMenuHandler = handleContextMenu; +if (isEditMode) rowContextMenuHandler = undefined; + + +``` + +--- + +## 12. DataTable Props Reference (Commonly Used) + +```typescript + record.uniqueKey} + height="100%" + className="my-table" + defaultColumnProps={{ + noWrap: true, + draggable: true, + resizable: true, + cellsStyle: () => ({ padding: `${size.cellPadding}px 8px` }), + }} + scrollAreaProps={{ type: "auto", scrollbarSize: 6 }} + rowBackgroundColor={(record) => conditionalBgMap[record.state]} + onRowContextMenu={handleContextMenu} + rowExpansion={{ ... }} + onScroll={() => { if (contextMenu) closeContextMenu(); }} +/> +``` + +--- + +## 13. Layout Stability + +- Don't use `table-layout: fixed` — it fights mantine-datatable's resize logic +- Give non-name columns explicit `width` values so only the name column flexes +- Use `ellipsis: true` on the name column — it handles `text-overflow: ellipsis` internally +- Names should truncate at the cell boundary, not a character count, so the layout is stable regardless of content length +- `flexShrink: 0` on badges/icons prevents them from being squeezed by long names + +--- + +## 14. Checklist for Each Migration + +1. Replace imports (`mantine-react-table` → `mantine-datatable`) +2. Convert column definitions (`accessorKey` → `accessor`, `header` → `title`, `Cell` → `render`, etc.) +3. Implement manual sorting with `useState` + `useMemo` +4. Add CSS file for transparent backgrounds (if widget context) +5. Add responsive column visibility (if widget context) +6. Add hidden options + hydration logic (if column persistence needed) +7. Add old-import mappings for new options +8. Add translation keys (including hidden option labels) +9. Add context menu if row actions exist +10. Add row expansion if detail view exists +11. Add error handling on all mutations +12. Add accessibility attributes +13. Test at multiple widget widths +14. Update docs in `apps/docs/docs/widgets/` if applicable +15. Run `pnpm turbo typecheck` — catches old-import mismatches +16. Run translation spec — catches missing keys diff --git a/.dockerignore b/.dockerignore index 521c55109..c0f2129a3 100644 --- a/.dockerignore +++ b/.dockerignore @@ -17,7 +17,8 @@ coverage dev .build e2e -apps/docs +apps/docs/* +!apps/docs/package.json apps/nextjs/.next .env .env.* diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index 42f647d87..d4b730726 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -33,6 +33,10 @@ body: options: # The below comment is used to insert a new version with on-release.yml #NEXT_VERSION# + - 1.74.0 + - 1.73.0 + - 1.72.0 + - 1.71.0 - 1.70.0 - 1.69.2 - 1.69.0 diff --git a/.github/renovate.json5 b/.github/renovate.json5 index 98c7d1693..900c3d97e 100644 --- a/.github/renovate.json5 +++ b/.github/renovate.json5 @@ -1,6 +1,6 @@ { $schema: "https://docs.renovatebot.com/renovate-schema.json", - extends: ["config:recommended"], + extends: ["config:recommended", ":dependencyDashboardApproval"], packageRules: [ { matchPackageNames: ["^@homarr/"], @@ -47,7 +47,7 @@ rangeStrategy: "bump", automerge: false, baseBranchPatterns: ["dev"], - dependencyDashboard: false, + dependencyDashboard: true, // prevent special characters in branch names (otherwise git errors occur on windows) branchNameStrict: true, } diff --git a/.github/workflows/automatic-approval.yml b/.github/workflows/automatic-approval.yml deleted file mode 100644 index 9e246f838..000000000 --- a/.github/workflows/automatic-approval.yml +++ /dev/null @@ -1,35 +0,0 @@ -name: "[Dependency Updates] Auto Approve" -on: - pull_request: - types: [opened, synchronize] - -permissions: {} - -jobs: - approve-automatic-prs: - runs-on: ubuntu-latest - timeout-minutes: 2 - # 158783068: homarr-renovate[bot] - # 190541745: homarr-crowdin[bot] - # 210161987: homarr-update-contributors[bot] - # 175486441: homarr-releases[bot] - # Extracted from https://api.github.com/users/ - 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" diff --git a/.github/workflows/bot-auto-merge-reconcile.yml b/.github/workflows/bot-auto-merge-reconcile.yml new file mode 100644 index 000000000..d82f759f5 --- /dev/null +++ b/.github/workflows/bot-auto-merge-reconcile.yml @@ -0,0 +1,43 @@ +name: "[Bots] Reconcile Auto-merge" + +on: + schedule: + - cron: "*/30 * * * *" + workflow_dispatch: + +permissions: {} + +jobs: + reconcile: + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - name: Checkout code + uses: actions/checkout@v7 + + - name: Obtain token + id: obtainToken + uses: actions/create-github-app-token@v3 + with: + private-key: ${{ secrets.RENOVATE_APPROVE_PRIVATE_KEY }} + client-id: ${{ secrets.RENOVATE_APPROVE_APP_ID }} + permission-pull-requests: write # required to enable auto-merge + + - name: Install GitHub CLI + run: sudo apt-get install -y gh + + - name: Re-enable auto-merge for prepared bot PRs + env: + GITHUB_TOKEN: ${{ steps.obtainToken.outputs.token }} + run: | + # 158783068: homarr-renovate[bot] + # 190541745: homarr-crowdin[bot] + # 210161987: homarr-update-contributors[bot] + # 175441: homarr-releases[bot] + gh pr list --repo "${{ github.repository }}" --base dev --state open --limit 100 \ + --json number,author,reviewDecision,mergeStateStatus,autoMergeRequest \ + --jq '.[] | select(.author.login as $login | ["app/homarr-renovate", "app/homarr-crowdin", "app/homarr-update-contributors", "app/homarr-releases"] | index($login)) | select(.reviewDecision == "APPROVED") | select(.mergeStateStatus == "CLEAN") | select(.autoMergeRequest == null) | .number' \ + | while read -r pr; do + echo "Re-enabling auto-merge on PR #$pr" + gh pr merge "$pr" --auto --squash + done diff --git a/.github/workflows/bot-pr-automerge.yml b/.github/workflows/bot-pr-automerge.yml new file mode 100644 index 000000000..35c5461fa --- /dev/null +++ b/.github/workflows/bot-pr-automerge.yml @@ -0,0 +1,47 @@ +name: "[Bots] Approve and Auto-merge" + +on: + pull_request: + types: [opened, synchronize, reopened, ready_for_review] + +permissions: {} + +jobs: + approve-and-automerge: + runs-on: ubuntu-latest + timeout-minutes: 5 + # 158783068: homarr-renovate[bot] + # 190541745: homarr-crowdin[bot] + # 210161987: homarr-update-contributors[bot] + # 175441: homarr-releases[bot] + # Extracted from https://api.github.com/users/ + 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 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 57745bd94..643c4f436 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -84,7 +84,7 @@ jobs: run: cp .env.example .env - name: Lint, typecheck, and build affected workspaces - run: pnpm turbo run lint typecheck build --affected --continue=dependencies-successful --concurrency=4 --log-order=stream + run: pnpm turbo run lint typecheck build --affected --continue=dependencies-successful --concurrency=2 - name: Validate workspace configuration run: pnpm lint:ws diff --git a/.gitignore b/.gitignore index fbb2fdea6..82e02993b 100644 --- a/.gitignore +++ b/.gitignore @@ -83,3 +83,4 @@ skills-lock.json apps/docs/.docusaurus apps/docs/build .pnpm-store +benchmark-results diff --git a/AGENTS.md b/AGENTS.md index be1afe9fb..6c3d36da4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -46,6 +46,8 @@ homarr/ ├── tooling/ │ ├── typescript/ # Base tsconfig │ └── github/ # CI setup action +├── tools/ +│ └── homarr-dev/ # Go CLI for local and PR Docker images ├── development/ # Dev docker-compose (Redis, MySQL, PostgreSQL) ├── e2e/ # E2E test specs └── Dockerfile # Multi-stage production build @@ -68,6 +70,9 @@ When modifying user-facing code, you MUST also update the corresponding document ## Monorepo Commands - `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 turbo build` — build all packages - `pnpm turbo build --filter=@homarr/docs` — build docs only @@ -81,3 +86,7 @@ When modifying user-facing code, you MUST also update the corresponding document - UI: Mantine (not Tailwind) — Tailwind is only used in docs app - 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. +- Run `pnpm dev:cli -- build ` from a Homarr checkout to build `homarr:` with rebuild provenance. +- Run `pnpm dev:cli -- build --pr ` to build a PR locally from a temporary checkout. +- Run Go checks from `tools/homarr-dev` with `go test ./...` and `go vet ./...`. diff --git a/CHANGELOG.md b/CHANGELOG.md index 028617cdc..a829e2042 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,9 @@ +## [1.74.0](https://github.com/homarr-labs/homarr/compare/v1.73.0...v1.74.0) (2026-08-07) + +## [1.73.0](https://github.com/homarr-labs/homarr/compare/v1.72.0...v1.73.0) (2026-07-31) + +## [1.72.0](https://github.com/homarr-labs/homarr/compare/v1.71.0...v1.72.0) (2026-07-24) + ## [1.71.0](https://github.com/homarr-labs/homarr/compare/v1.70.0...v1.71.0) (2026-07-17) ## [1.70.0](https://github.com/homarr-labs/homarr/compare/v1.69.2...v1.70.0) (2026-07-10) diff --git a/Dockerfile b/Dockerfile index 8f4c5e49a..f07fcae3b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -3,12 +3,18 @@ FROM node:24.18.0-alpine AS base FROM base AS builder +ARG TARGETPLATFORM WORKDIR /app -RUN apk add --no-cache libc6-compat curl bash && apk update +# Native dependencies normally use prebuilds, but the build must remain +# reproducible when a prebuild download times out and compilation is required. +RUN apk add --no-cache libc6-compat curl bash python3 make g++ RUN corepack enable pnpm -COPY pnpm-lock.yaml pnpm-workspace.yaml package.json ./ +COPY .npmrc pnpm-lock.yaml pnpm-workspace.yaml package.json ./ 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 # are configured with nodeLinker: hoisted. The applyPatchToDir function tries # to chdir into node_modules/ which doesn't exist during fetch (only the @@ -19,14 +25,16 @@ COPY patches ./patches RUN --mount=type=cache,id=pnpm-store,target=/pnpm/store \ pnpm config set store-dir /pnpm/store && \ sed -i 's/nodeLinker: hoisted/nodeLinker: isolated/' pnpm-workspace.yaml && \ - pnpm fetch && \ + pnpm fetch --ignore-scripts && \ sed -i 's/nodeLinker: isolated/nodeLinker: hoisted/' pnpm-workspace.yaml -COPY . . -# Follow the pnpm fetch pattern from https://pnpm.io/cli/fetch -# --frozen-lockfile is omitted as recommended by the pnpm fetch docs +# 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 \ - pnpm install --recursive + npm_config_nodedir=/usr/local pnpm install --recursive --offline --frozen-lockfile --child-concurrency=1 + +COPY . . ARG SKIP_ENV_VALIDATION='true' ARG CI='true' @@ -37,7 +45,9 @@ RUN --mount=type=secret,id=TURBO_API,env=TURBO_API \ --mount=type=secret,id=TURBO_TEAM,env=TURBO_TEAM \ --mount=type=secret,id=TURBO_TOKEN,env=TURBO_TOKEN \ --mount=type=secret,id=TURBO_REMOTE_CACHE_SIGNATURE_KEY,env=TURBO_REMOTE_CACHE_SIGNATURE_KEY \ - TURBO_PLATFORM="${TARGETPLATFORM:-linux/amd64}" pnpm build + --mount=type=cache,id=homarr-next-build-${TARGETPLATFORM},target=/app/apps/nextjs/.next/cache,sharing=locked \ + TURBO_PLATFORM="${TARGETPLATFORM:-linux/amd64}" \ + pnpm turbo build --filter=@homarr/nextjs... --filter=@homarr/cli FROM base AS runner WORKDIR /app @@ -85,5 +95,6 @@ ENV AUTH_PROVIDERS='credentials' ENV REDIS_IS_EXTERNAL='false' ENV NODE_ENV='production' +EXPOSE 7575 ENTRYPOINT [ "/app/entrypoint.sh" ] CMD ["sh", "run.sh"] diff --git a/apps/docs/docs/advanced/command-line/development/index.mdx b/apps/docs/docs/advanced/command-line/development/index.mdx new file mode 100644 index 000000000..cdbffc29a --- /dev/null +++ b/apps/docs/docs/advanced/command-line/development/index.mdx @@ -0,0 +1,41 @@ +# Developer Docker CLI + +The host-side developer CLI builds and runs local Homarr images and pull-request previews. Run it directly from a checkout with `pnpm dev:cli -- ...`; no shell-specific setup script is required. It is separate from the recovery CLI included in production images. + +To install an optional `homarr` binary, run `pnpm dev:cli:install` and make your Go bin directory available on `PATH`. GitHub CLI authentication is only needed for pull-request features. + +## Browse development images + +```bash +pnpm dev:cli -- +``` + +The main screen combines local `homarr:*` images with open pull requests and their GHCR preview status. Select an image to start or stop it, use `R` to build a remote pull request locally in the background or rebuild a local image, or use `p` to force a remote preview pull. Press `d` to switch to the instance dashboard and `d` again to return without losing the browser state. The explicit `dev` and `dash` subcommands start on either screen. + +A locally built PR image is folded into its remote PR row only when its recorded revision exactly matches the current GitHub head SHA. The row is marked `◆ local`, and starting it prefers that local image without a registry pull. Outdated local builds remain separate rows. Navigation and dashboard switching remain available during background builds. + +## Build images + +```bash +pnpm dev:cli -- build +pnpm dev:cli -- build --pr [name] +``` + +The first command builds the current checkout as `homarr:`. The pull-request form uses a depth-one clone in a temporary directory and defaults to `homarr:pr-` when `name` is omitted. + +## Rebuild images + +```bash +pnpm dev:cli -- rebuild +``` + +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 +homarr run --pr +``` + +Add `--detach` to run in the background, `--demo` to enable demo data, or repeat `--env KEY=VALUE` to provide additional environment variables. diff --git a/apps/docs/docs/advanced/development/getting-started.mdx b/apps/docs/docs/advanced/development/getting-started.mdx index 3a8639f74..de119072c 100644 --- a/apps/docs/docs/advanced/development/getting-started.mdx +++ b/apps/docs/docs/advanced/development/getting-started.mdx @@ -12,10 +12,12 @@ sidebar_position: 1 ### Prerequisites -- Node.js 24.18 or newer -- Corepack and pnpm 11.11 -- Git -- Docker with Docker Compose +- **Node.js 24.18 or newer**: You can download it from the [official website](https://nodejs.org/). +- **Corepack**: Run `corepack enable` so the repository selects its pinned pnpm 11 version. +- **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. +- **Go 1.25+**: Required only for the developer Docker CLI. You can download it from the [official website](https://go.dev/). +- **GitHub CLI**: Required only for pull-request image features. Install it from the [official website](https://cli.github.com/), then authenticate with `gh auth login`. The versions in the root `package.json` are the supported baseline. Let Corepack select the repository version instead of installing an unrelated global pnpm: @@ -52,6 +54,8 @@ pnpm dev The infrastructure command remains in the foreground. Open `http://127.0.0.1:3000`; the first user created through Homarr becomes the instance administrator. +Use `pnpm docker:dev:up` instead when you only need Redis in the background. Running the migration also seeds the +default data; use `pnpm db:seed` to seed it explicitly later. ### Run PocketBase and documentation @@ -101,10 +105,18 @@ pnpm dev:docs ### Useful commands - `pnpm dev` — Next.js on port 3000. +- `pnpm dev:benchmark` — authenticated-board benchmark; readiness-only `--smoke` runs are not performance claims. +- `pnpm dev:cli -- dev` — browse and run local images and remote pull-request images. +- `pnpm dev:cli -- build ` — build the current checkout as `homarr:`. +- `pnpm dev:cli -- build --pr ` — build a pull request from a temporary checkout. +- `pnpm dev:cli -- rebuild ` — rebuild an image from its recorded checkout or pull request. +- `pnpm dev:cli:install` — optionally install the developer CLI as a `homarr` binary. - `pnpm dev:docs` — Docusaurus only on port 3003. - `pnpm docker:workshop` — local PocketBase through Docker Compose. +- `pnpm docker:dev:up` — Redis development service in the background. - `pnpm cli` — Homarr operations CLI. - `pnpm db:migration:sqlite:run` — create or update a SQLite database. +- `pnpm db:seed` — seed the default database data explicitly. - `pnpm db:studio` — inspect the database. - `pnpm format` / `pnpm format:fix` — check or apply oxfmt. - `pnpm lint` / `pnpm lint:fix` — check or apply oxlint. @@ -112,3 +124,5 @@ pnpm dev:docs - `pnpm test` — unit and contract suites. - `pnpm test:workshop` — disposable Workshop integration suite. - `pnpm build` — production monorepo build. +- `docker build -t homarr .` — build a production Docker image. +- `docker run -p 7575:7575 -e SECRET_ENCRYPTION_KEY='your_64_character_hex_string' homarr:` — run it. diff --git a/apps/docs/docs/advanced/environment-variables/index.mdx b/apps/docs/docs/advanced/environment-variables/index.mdx index e740884fc..3c6d0f1a6 100644 --- a/apps/docs/docs/advanced/environment-variables/index.mdx +++ b/apps/docs/docs/advanced/environment-variables/index.mdx @@ -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. ::: -| Environment Variable | Description | Possible values | Default | -| ------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------- | ----------------------------- | -| `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` | -| `LOG_LEVEL` | Log level to use | `debug` / `info` / `warn` / `error` | `info` | -| `NO_EXTERNAL_CONNECTION` | Disables some requests that need internet connection | `true` or `false` | `false` | -| `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_WEBSITE_URL` | Main Homarr documentation/site base used by runtime links. Must be an absolute HTTP(S) URL. | HTTP(S) URL | `https://homarr.dev` | +| Environment Variable | Description | Possible values | Default | +| ------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------- | ----------------------------- | +| `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` | +| `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` | +| `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` | +| `HOMARR_WEBSITE_URL` | Main Homarr documentation/site base used by runtime links. Must be an absolute HTTP(S) URL. | HTTP(S) URL | `https://homarr.dev` | | `WORKSHOP_API_URL` | PocketBase API used by Homarr and standalone documentation. | HTTP(S) URL | `HOMARR_WEBSITE_URL` | | `WORKSHOP_WEB_URL` | Public Workshop page opened from Homarr. | HTTP(S) URL | `HOMARR_WEBSITE_URL/workshop` | @@ -54,11 +55,11 @@ A random secret can be generated by using the following command: `openssl rand - ## Docker -| Environment Variable | Description | Possible values | Default | -| --------------------- | ----------------------------------------------- | -------------------------------------------------------------------- | ------- | -| `DOCKER_HOSTNAMES` | Comma seperated list of hostnames to connect to | For example `localhost,docker.example.com` | - | -| `DOCKER_PORTS` | Comma seperated list of ports to connect to | For example `2375,2376` | - | -| `DOCKER_SOCKET_PATHS` | Comma seperated list of paths to sockets | For example `/var/run/docker.sock,/run/user/1000/podman/podman.sock` | - | +| Environment Variable | Description | Possible values | Default | +| --------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------- | ------- | +| `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_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 diff --git a/apps/docs/docs/advanced/icons/index.mdx b/apps/docs/docs/advanced/icons/index.mdx index 04b5f3890..4fd21d95f 100644 --- a/apps/docs/docs/advanced/icons/index.mdx +++ b/apps/docs/docs/advanced/icons/index.mdx @@ -34,4 +34,4 @@ Icons that are high quality and printable ([SVG](https://wikipedia.org/wiki/Scal ### 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. -It is important that the URL directly points to the file without any advertisements or elements around it. +Direct HTTP(S) image URLs are used as entered instead of being searched in the built-in repositories. It is important that the URL directly points to the file without any advertisements or elements around it. diff --git a/apps/docs/docs/advanced/proxy/index.mdx b/apps/docs/docs/advanced/proxy/index.mdx index 02af599ec..fbe33bfa4 100644 --- a/apps/docs/docs/advanced/proxy/index.mdx +++ b/apps/docs/docs/advanced/proxy/index.mdx @@ -49,7 +49,6 @@ services: traefik.http.routers.homarr.rule: Host(`your.internal.dns.address.here.com`) traefik.http.routers.homarr.entrypoints: websecure traefik.http.routers.homarr-secure.app: homarr - traefik.http.apps.homarr.loadbalancer.server.port: 7575 networks: proxy: diff --git a/apps/docs/docs/advanced/single-sign-on/index.mdx b/apps/docs/docs/advanced/single-sign-on/index.mdx index f8e4c1ca7..505fe9867 100644 --- a/apps/docs/docs/advanced/single-sign-on/index.mdx +++ b/apps/docs/docs/advanced/single-sign-on/index.mdx @@ -128,6 +128,8 @@ Homarr supports multiple authentication options, from internal userbase (credent If you'd rather manage group memberships for OIDC users locally (instead of via the IdP), set `AUTH_OIDC_GROUPS_LOCAL_MANAGEMENT=true`. While enabled, Homarr stops syncing OIDC users' group memberships from the groups claim, and admins can add/remove OIDC users on a group's members page. If you turn this back to `false`, the next login of each user resumes the regular sync and reconciles their groups with the IdP's claim again, which may remove memberships that were added manually while local management was enabled. + To let an existing credentials user also sign in with OIDC, set `AUTH_OIDC_ENABLE_DANGEROUS_CREDENTIALS_LINKING=true`. On the first OIDC sign-in, Homarr links a credentials user with the same email address instead of creating a second user. The user keeps their password login, local username and profile, groups, permissions, boards, settings, and API keys. OIDC profile and group claims do not overwrite the credentials-owned data. Homarr requires the OIDC profile to contain `email_verified: true` while this option is enabled. Only enable this when your OIDC provider verifies email addresses, because a matching email could otherwise be used to take over an existing account. +
Example Setup @@ -205,6 +207,7 @@ Homarr supports multiple authentication options, from internal userbase (credent | ``AUTH_OIDC_NAME_ATTRIBUTE_OVERWRITE`` | Overwrite name attribute. By default it will use preferred_username if it does not contain a `@` and otherwise name. | --- | | ``AUTH_OIDC_FORCE_USERINFO`` | Force userinfo endpoint to be used for user information. | false | | ``AUTH_OIDC_ENABLE_DANGEROUS_ACCOUNT_LINKING`` | Enable account linking for OIDC provider. This will link the OIDC accounts by email. Be sure that you have verified emails to prevent stealing accounts. | false | + | ``AUTH_OIDC_ENABLE_DANGEROUS_CREDENTIALS_LINKING`` | Link an OIDC identity to an existing credentials user with the same email. The credentials login and existing user data are preserved. Requires the OIDC profile to contain `email_verified: true`. | false | | ``AUTH_OIDC_TOKEN_ENDPOINT_AUTH_METHOD`` | Override method for the token endpoint authentication. Supported values are `client_secret_basic`, `client_secret_post`, `client_secret_jwt` and `none`. | client_secret_basic | ### Permission System diff --git a/apps/docs/docs/getting-started/installation/helm.md b/apps/docs/docs/getting-started/installation/helm.md index 75f132d14..f3ad08b66 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.22.0](https://img.shields.io/badge/Version-8.22.0-informational?style=flat) +![Version: 8.26.0](https://img.shields.io/badge/Version-8.26.0-informational?style=flat) ![Type: application](https://img.shields.io/badge/Type-application-informational?style=flat) -![AppVersion: v1.70.0](https://img.shields.io/badge/AppVersion-v1.70.0-informational?style=flat) +![AppVersion: v1.74.0](https://img.shields.io/badge/AppVersion-v1.74.0-informational?style=flat) A Helm chart to deploy homarr for Kubernetes @@ -263,9 +263,8 @@ httproute: value: / backendRefs: - name: homarr - port: 8080 -``` - + port: 7575 +````
#### Notes: @@ -380,114 +379,114 @@ All available values are listed on the [artifacthub](https://artifacthub.io/pack ## Values -| Key | Type | Default | Description | -| -------------------------------------------------- | ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| additionalObjects | list | `[]` | Additional resources to deploy. These objects are templated. | -| affinity | object | `{}` | Node affinity for pod scheduling | -| autoscaling.enabled | bool | `false` | Enable autoscaling | -| autoscaling.maxReplicas | int | `100` | Maximum replicas | -| autoscaling.minReplicas | int | `1` | Minimum replicas | -| autoscaling.targetCPUUtilizationPercentage | int | `80` | Target CPU utilization for autoscaling | -| containerPorts | object | `{"http":{"port":7575,"protocol":"TCP"}}` | containerPorts defines the ports to open on the container. It is a map where each entry specifies: - `port` (int) (required): The port number to expose inside the container. - `protocol` (string) (required): The network protocol (TCP or UDP) used for the port. - `disabled` (bool) : Optional flag to disable this port (defaults to false). Can be overridden via Helm values. By default, this configuration exposes TCP port 7575 with the name `http`. | -| database.migrationEnabled | bool | `true` | Database migration configuration. DB_MIGRATIONS_DISABLED Set to `true` to disable database migrations. Migrations are enabled by default (`false`). | -| database.type | string | `"sqlite"` | Database type: sqlite, mysql or postgresql | -| env.AUTH_COOKIE_PREFIX | string | `"homarr"` | Prefix used for all authentication cookies. Change if you run another Auth.js/NextAuth app on the same hostname to avoid cookie name collisions. Only letters, numbers, hyphens and underscores. | -| env.AUTH_LDAP_BASE | string | `nil` | Base dn of your LDAP server | -| env.AUTH_LDAP_BIND_DN | string | `nil` | User used for finding users and groups | -| env.AUTH_LDAP_GROUP_CLASS | string | `"groupOfUniqueNames"` | Class used for querying groups | -| env.AUTH_LDAP_GROUP_FILTER_EXTRA_ARG | string | `nil` | Extra arguments for user's groups search filter (& based) | -| env.AUTH_LDAP_GROUP_MEMBER_ATTRIBUTE | string | `"member"` | Attribute used for querying group member | -| env.AUTH_LDAP_GROUP_MEMBER_USER_ATTRIBUTE | string | `"dn"` | User attribute used for comparing with group member | -| env.AUTH_LDAP_SEARCH_SCOPE | string | `"base"` | LDAP search scope between base, one or sub | -| env.AUTH_LDAP_URI | string | `nil` | URI of your LDAP server | -| env.AUTH_LDAP_USERNAME_ATTRIBUTE | string | `"uid"` | Attribute used for username | -| env.AUTH_LDAP_USERNAME_FILTER_EXTRA_ARG | string | `nil` | Extra arguments for user search filter (& based) | -| env.AUTH_LDAP_USER_MAIL_ATTRIBUTE | string | `"mail"` | Attribute used for mail field | -| env.AUTH_LOGOUT_REDIRECT_URL | string | `nil` | URL to redirect to after clicking logging out. | -| env.AUTH_OIDC_AUTO_LOGIN | string | `"false"` | Automatically redirect to OIDC login | -| env.AUTH_OIDC_CLIENT_NAME | string | `"OIDC"` | Display name of provider (in login screen) | -| env.AUTH_OIDC_GROUPS_ATTRIBUTE | string | `"groups"` | Attribute used for groups (roles) claim | -| env.AUTH_OIDC_ISSUER | string | `nil` | Issuer URI of OIDC provider without trailing slash (/) | -| env.AUTH_OIDC_NAME_ATTRIBUTE_OVERWRITE | string | `nil` | Overwrite name attribute. By default, it will use preferred_username if it does not contain a @ and otherwise name. | -| env.AUTH_OIDC_SCOPE_OVERWRITE | string | `"openid email profile groups"` | Override the OIDC scopes | -| env.AUTH_PROVIDERS | string | `"credentials"` | Enabled authentication methods. Multiple providers can be enabled with by separating them with , (ex. AUTH_PROVIDERS=credentials,oidc, it is highly recommended to just enable one provider). | -| env.AUTH_SESSION_EXPIRY_TIME | string | `"30d"` | Time for the session to time out. Can be set as pure number, which will automatically be used in seconds, or followed by s, m, h or d for seconds, minutes, hours or days. (ex: "30m") | -| env.ENABLE_DNS_CACHING | string | `"false"` | Enables dns caching. This is not yet working for all users. See #4006 | -| env.LOG_LEVEL | string | `"info"` | Log level to use. Possible values: debug/info/warn/error | -| env.NO_EXTERNAL_CONNECTION | string | `"false"` | Disables some requests that need internet connection | -| env.TZ | string | `"Europe/Paris"` | Your local time zone | -| envSecrets.authLdapCredentials.existingSecret | string | `"auth-ldap-secret"` | Name of existing secret containing LDAP credentials | -| envSecrets.authLdapCredentials.ldapBindingPassword | string | `"bind-password"` | Password for bind user secret key | -| envSecrets.authOidcCredentials.existingSecret | string | `"auth-oidc-secret"` | Name of existing secret containing OIDC credentials | -| envSecrets.authOidcCredentials.oidcClientId | string | `"oidc-client-id"` | ID of OIDC client (application) secret key | -| envSecrets.authOidcCredentials.oidcClientSecret | string | `"oidc-client-secret"` | Secret of OIDC client (application) secret key | -| envSecrets.dbCredentials.dbUrlKey | string | `"db-url"` | Secret key for DB_URL Example for external database: `mysql://username:password@host:port/homarrdb` or `postgresql://username:password@host:port/homarrdb` | -| envSecrets.dbCredentials.existingSecret | string | `"db-secret"` | Name of existing secret containing DB credentials | -| envSecrets.dbEncryption.existingSecret | string | `"db-encryption"` | Name of existing secret containing DB encryption | -| envSecrets.dbEncryption.key | string | `"db-encryption-key"` | Secret key for SECRET_ENCRYPTION_KEY can be generated with `openssl rand -hex 32` | -| fullnameOverride | string | `""` | Overrides chart's fullname | -| hostAliases | list | `[]` | Add static entries to /etc/hosts in the Pod. This is useful in the following cases: - You are running in a dual-stack cluster (IPv4 + IPv6) and want to force usage of IPv4 for specific hostnames - Your application is having DNS resolution issues or IPv6 preference issues - You need to override or simulate DNS entries without changing global DNS - You are running in an air-gapped or isolated environment without external DNS Example: hostAliases: - ip: "192.168.1.10" hostnames: - "example.com" - "example.internal" | -| httproute | object | `{"enabled":false,"hostnames":["chart-example.local"],"parentRefs":[{"name":"my-gateway","namespace":"default"}],"rules":[{"backendRefs":[{"name":"homarr","port":8080}],"filters":[],"matches":[{"path":{"type":"PathPrefix","value":"/"}}]}]}` | Gateway API HTTPRoute configuration | -| httproute.enabled | bool | `false` | Enable HTTPRoute | -| httproute.hostnames | list | `["chart-example.local"]` | Hostnames this route matches (similar to ingress.hosts.host) | -| httproute.parentRefs | list | `[{"name":"my-gateway","namespace":"default"}]` | References to the parent Gateway(s) this route attaches to. Each item must include at least a `name`, and optionally a `namespace`. | -| httproute.rules | list | `[{"backendRefs":[{"name":"homarr","port":8080}],"filters":[],"matches":[{"path":{"type":"PathPrefix","value":"/"}}]}]` | List of routing rules. Each rule can include: - matches: path/header/query matching - filters: optional transformations (redirects, header modifications, etc.) - backendRefs: one or more Kubernetes Services to forward traffic to | -| httproute.rules[0].filters | list | `[]` | Optional filters for this rule (default: empty) | -| httproute.rules[0].matches[0].path.type | string | `"PathPrefix"` | Path match type. One of: Exact, PathPrefix, RegularExpression | -| httproute.rules[0].matches[0].path.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.70.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 | -| ingress.hosts | list | `[{"host":"chart-example.local","paths":[{"path":"/"}]}]` | Ingress hosts configuration | -| ingress.ingressClassName | string | `""` | Ingress class name | -| ingress.tls | list | `[]` | Ingress TLS configuration | -| livenessProbe.failureThreshold | int | `3` | Failure threshold for liveness probe - number of consecutive failures before pod is restarted | -| livenessProbe.httpGet.path | string | `"/api/health/live"` | This is the liveness check endpoint used by Kubernetes to determine if the application is still running. | -| livenessProbe.httpGet.port | int | `7575` | The port on which the liveness check will be performed. This must be the same as the container port exposed by the application. | -| livenessProbe.initialDelaySeconds | int | `10` | Initial delay in seconds before the liveness probe starts | -| livenessProbe.periodSeconds | int | `10` | Period in seconds between liveness probe checks | -| livenessProbe.timeoutSeconds | int | `1` | Timeout in seconds for each liveness probe check | -| nameOverride | string | `""` | Overrides chart's name | -| nodeSelector | object | `{}` | Node selectors for pod scheduling | -| persistence.homarrDatabase.accessMode | string | `"ReadWriteOnce"` | homarr-database access mode | -| persistence.homarrDatabase.enabled | bool | `false` | Enable homarr-database persistent storage | -| persistence.homarrDatabase.mountPath | string | `"/appdata"` | homarr-database mount path inside the pod | -| persistence.homarrDatabase.name | string | `"homarr-database"` | homarr-database persistent storage name | -| persistence.homarrDatabase.size | string | `"50Mi"` | homarr-database storage size | -| persistence.homarrDatabase.storageClassName | string | `"local-path"` | homarr-database storage class name | -| persistence.homarrDatabase.volumeClaimName | string | `""` | homarr-database optional volumeClaimName to target specific PV | -| persistence.homarrTrustedCerts.certificates | string | `nil` | homarr-trusted-certificates certificates, each entry will become a new trusted certificate as a dedicated file (works only for "configmap" and "secret" mode) | -| persistence.homarrTrustedCerts.enabled | bool | `false` | Enable trusted certificates persistence | -| persistence.homarrTrustedCerts.existingSecretKeys | string | `nil` | List of keys (filenames) to mount from the existing secret (used only when type is "existingSecret") | -| persistence.homarrTrustedCerts.existingSecretName | string | `""` | Name of the existing Kubernetes Secret to mount (required if type is "existingSecret") | -| persistence.homarrTrustedCerts.mountPath | string | `"/appdata/trusted-certificates"` | homarr-trusted-certificates mount path inside the pod | -| persistence.homarrTrustedCerts.type | string | `"configmap"` | Persistence mode can be : configmap (declarative), secret (declarative) or existingSecret (mount an existing Kubernetes Secret by name and specify which keys to mount as files) | -| podAnnotations | object | `{}` | Pod annotations | -| podLabels | object | `{}` | Pod labels | -| podSecurityContext | object | `{}` | Pod security context | -| rbac | object | `{"enabled":false}` | Enable RBAC resources for Kubernetes integration Creates Role, ClusterRole, and associated bindings for Homarr's Kubernetes features | -| rbac.enabled | bool | `false` | Enable to create RBAC resources and activate Kubernetes integration | -| readinessProbe.failureThreshold | int | `3` | Failure threshold for readiness probe - number of consecutive failures before pod is considered unready | -| readinessProbe.httpGet.path | string | `"/api/health/ready"` | This is the readiness check endpoint used by Kubernetes to determine if the application is ready to handle traffic. | -| readinessProbe.httpGet.port | int | `7575` | The port on which the readiness check will be performed. This must match the container's exposed port. | -| readinessProbe.initialDelaySeconds | int | `10` | Initial delay in seconds before the readiness probe starts. increase this value if the pod is slow to fully start. | -| readinessProbe.periodSeconds | int | `10` | Period in seconds between readiness probe checks | -| readinessProbe.timeoutSeconds | int | `1` | Timeout in seconds for each readiness probe check | -| replicaCount | int | `1` | Number of replicas | -| resources | object | `{}` | Resource configuration | -| securityContext | object | `{}` | Security context | -| service.enabled | bool | `true` | Enable service | -| service.ipFamilies | list | `[]` | List of IP families to use for the service. Examples: - ["IPv4"] - ["IPv6"] - ["IPv4", "IPv6"] for dual-stack Leave empty to use cluster default behavior | -| service.ipFamilyPolicy | string | `"SingleStack"` | Defines how the service assigns IP families (IPv4/IPv6) Possible values: - SingleStack (default): Only one IP family, usually IPv4 - PreferDualStack: Use dual-stack if the cluster supports it, fallback to single - RequireDualStack: Fail if dual-stack cannot be assigned | -| service.ports.app.port | int | `7575` | Service port | -| service.ports.app.protocol | string | `"TCP"` | Service protocol | -| service.ports.app.targetPort | string | `"http"` | Service target port | -| service.type | string | `"ClusterIP"` | Service type | -| strategyType | string | `"RollingUpdate"` | `strategyType` specifies the strategy used to replace old Pods by new ones. `strategyType` can be `"Recreate"` or `"RollingUpdate"`. `"RollingUpdate"` is the default value and updates Pods in a rolling update fashion. `"Recreate"` will kill all existing Pods before new ones are created. The `"Recreate"` strategy is necessary when persistent volume's `accessMode` is set to `"ReadWriteOnce"` when using `helm upgrade`, as pod volume attachments to an existing PersistentVolumeClaim need to be cleared before a new pod can attach to it. | -| tolerations | list | `[]` | Node tolerations for pod scheduling | +| Key | Type | Default | Description | +|-----|------|---------|-------------| +| additionalObjects | list | `[]` | Additional resources to deploy. These objects are templated. | +| affinity | object | `{}` | Node affinity for pod scheduling | +| autoscaling.enabled | bool | `false` | Enable autoscaling | +| autoscaling.maxReplicas | int | `100` | Maximum replicas | +| autoscaling.minReplicas | int | `1` | Minimum replicas | +| autoscaling.targetCPUUtilizationPercentage | int | `80` | Target CPU utilization for autoscaling | +| containerPorts | object | `{"http":{"port":7575,"protocol":"TCP"}}` | containerPorts defines the ports to open on the container. It is a map where each entry specifies: - `port` (int) (required): The port number to expose inside the container. - `protocol` (string) (required): The network protocol (TCP or UDP) used for the port. - `disabled` (bool) : Optional flag to disable this port (defaults to false). Can be overridden via Helm values. By default, this configuration exposes TCP port 7575 with the name `http`. | +| database.migrationEnabled | bool | `true` | Database migration configuration. DB_MIGRATIONS_DISABLED Set to `true` to disable database migrations. Migrations are enabled by default (`false`). | +| database.type | string | `"sqlite"` | Database type: sqlite, mysql or postgresql | +| env.AUTH_COOKIE_PREFIX | string | `"homarr"` | Prefix used for all authentication cookies. Change if you run another Auth.js/NextAuth app on the same hostname to avoid cookie name collisions. Only letters, numbers, hyphens and underscores. | +| env.AUTH_LDAP_BASE | string | `nil` | Base dn of your LDAP server | +| env.AUTH_LDAP_BIND_DN | string | `nil` | User used for finding users and groups | +| env.AUTH_LDAP_GROUP_CLASS | string | `"groupOfUniqueNames"` | Class used for querying groups | +| env.AUTH_LDAP_GROUP_FILTER_EXTRA_ARG | string | `nil` | Extra arguments for user's groups search filter (& based) | +| env.AUTH_LDAP_GROUP_MEMBER_ATTRIBUTE | string | `"member"` | Attribute used for querying group member | +| env.AUTH_LDAP_GROUP_MEMBER_USER_ATTRIBUTE | string | `"dn"` | User attribute used for comparing with group member | +| env.AUTH_LDAP_SEARCH_SCOPE | string | `"base"` | LDAP search scope between base, one or sub | +| env.AUTH_LDAP_URI | string | `nil` | URI of your LDAP server | +| env.AUTH_LDAP_USERNAME_ATTRIBUTE | string | `"uid"` | Attribute used for username | +| env.AUTH_LDAP_USERNAME_FILTER_EXTRA_ARG | string | `nil` | Extra arguments for user search filter (& based) | +| env.AUTH_LDAP_USER_MAIL_ATTRIBUTE | string | `"mail"` | Attribute used for mail field | +| env.AUTH_LOGOUT_REDIRECT_URL | string | `nil` | URL to redirect to after clicking logging out. | +| env.AUTH_OIDC_AUTO_LOGIN | string | `"false"` | Automatically redirect to OIDC login | +| env.AUTH_OIDC_CLIENT_NAME | string | `"OIDC"` | Display name of provider (in login screen) | +| env.AUTH_OIDC_GROUPS_ATTRIBUTE | string | `"groups"` | Attribute used for groups (roles) claim | +| env.AUTH_OIDC_ISSUER | string | `nil` | Issuer URI of OIDC provider without trailing slash (/) | +| env.AUTH_OIDC_NAME_ATTRIBUTE_OVERWRITE | string | `nil` | Overwrite name attribute. By default, it will use preferred_username if it does not contain a @ and otherwise name. | +| env.AUTH_OIDC_SCOPE_OVERWRITE | string | `"openid email profile groups"` | Override the OIDC scopes | +| env.AUTH_PROVIDERS | string | `"credentials"` | Enabled authentication methods. Multiple providers can be enabled with by separating them with , (ex. AUTH_PROVIDERS=credentials,oidc, it is highly recommended to just enable one provider). | +| env.AUTH_SESSION_EXPIRY_TIME | string | `"30d"` | Time for the session to time out. Can be set as pure number, which will automatically be used in seconds, or followed by s, m, h or d for seconds, minutes, hours or days. (ex: "30m") | +| env.ENABLE_DNS_CACHING | string | `"false"` | Enables dns caching. This is not yet working for all users. See #4006 | +| env.LOG_LEVEL | string | `"info"` | Log level to use. Possible values: debug/info/warn/error | +| env.NO_EXTERNAL_CONNECTION | string | `"false"` | Disables some requests that need internet connection | +| env.TZ | string | `"Europe/Paris"` | Your local time zone | +| envSecrets.authLdapCredentials.existingSecret | string | `"auth-ldap-secret"` | Name of existing secret containing LDAP credentials | +| envSecrets.authLdapCredentials.ldapBindingPassword | string | `"bind-password"` | Password for bind user secret key | +| envSecrets.authOidcCredentials.existingSecret | string | `"auth-oidc-secret"` | Name of existing secret containing OIDC credentials | +| envSecrets.authOidcCredentials.oidcClientId | string | `"oidc-client-id"` | ID of OIDC client (application) secret key | +| envSecrets.authOidcCredentials.oidcClientSecret | string | `"oidc-client-secret"` | Secret of OIDC client (application) secret key | +| envSecrets.dbCredentials.dbUrlKey | string | `"db-url"` | Secret key for DB_URL Example for external database: `mysql://username:password@host:port/homarrdb` or `postgresql://username:password@host:port/homarrdb` | +| envSecrets.dbCredentials.existingSecret | string | `"db-secret"` | Name of existing secret containing DB credentials | +| envSecrets.dbEncryption.existingSecret | string | `"db-encryption"` | Name of existing secret containing DB encryption | +| envSecrets.dbEncryption.key | string | `"db-encryption-key"` | Secret key for SECRET_ENCRYPTION_KEY can be generated with `openssl rand -hex 32` | +| fullnameOverride | string | `""` | Overrides chart's fullname | +| hostAliases | list | `[]` | Add static entries to /etc/hosts in the Pod. This is useful in the following cases: - You are running in a dual-stack cluster (IPv4 + IPv6) and want to force usage of IPv4 for specific hostnames - Your application is having DNS resolution issues or IPv6 preference issues - You need to override or simulate DNS entries without changing global DNS - You are running in an air-gapped or isolated environment without external DNS Example: hostAliases: - ip: "192.168.1.10" hostnames: - "example.com" - "example.internal" | +| httproute | object | `{"enabled":false,"hostnames":["chart-example.local"],"parentRefs":[{"name":"my-gateway","namespace":"default"}],"rules":[{"backendRefs":[{"name":"homarr","port":7575}],"filters":[],"matches":[{"path":{"type":"PathPrefix","value":"/"}}]}]}` | Gateway API HTTPRoute configuration | +| httproute.enabled | bool | `false` | Enable HTTPRoute | +| httproute.hostnames | list | `["chart-example.local"]` | Hostnames this route matches (similar to ingress.hosts.host) | +| httproute.parentRefs | list | `[{"name":"my-gateway","namespace":"default"}]` | References to the parent Gateway(s) this route attaches to. Each item must include at least a `name`, and optionally a `namespace`. | +| httproute.rules | list | `[{"backendRefs":[{"name":"homarr","port":7575}],"filters":[],"matches":[{"path":{"type":"PathPrefix","value":"/"}}]}]` | List of routing rules. Each rule can include: - matches: path/header/query matching - filters: optional transformations (redirects, header modifications, etc.) - backendRefs: one or more Kubernetes Services to forward traffic to | +| httproute.rules[0].filters | list | `[]` | Optional filters for this rule (default: empty) | +| httproute.rules[0].matches[0].path.type | string | `"PathPrefix"` | Path match type. One of: Exact, PathPrefix, RegularExpression | +| httproute.rules[0].matches[0].path.value | string | `"/"` | Path value to match | +| 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 | +| imagePullSecrets | list | `[]` | Secrets for Docker registry | +| ingress.annotations | object | `{}` | Ingress annotations | +| ingress.enabled | bool | `false` | Enable ingress | +| ingress.hosts | list | `[{"host":"chart-example.local","paths":[{"path":"/"}]}]` | Ingress hosts configuration | +| ingress.ingressClassName | string | `""` | Ingress class name | +| ingress.tls | list | `[]` | Ingress TLS configuration | +| livenessProbe.failureThreshold | int | `3` | Failure threshold for liveness probe - number of consecutive failures before pod is restarted | +| livenessProbe.httpGet.path | string | `"/api/health/live"` | This is the liveness check endpoint used by Kubernetes to determine if the application is still running. | +| livenessProbe.httpGet.port | int | `7575` | The port on which the liveness check will be performed. This must be the same as the container port exposed by the application. | +| livenessProbe.initialDelaySeconds | int | `10` | Initial delay in seconds before the liveness probe starts | +| livenessProbe.periodSeconds | int | `10` | Period in seconds between liveness probe checks | +| livenessProbe.timeoutSeconds | int | `1` | Timeout in seconds for each liveness probe check | +| nameOverride | string | `""` | Overrides chart's name | +| nodeSelector | object | `{}` | Node selectors for pod scheduling | +| persistence.homarrDatabase.accessMode | string | `"ReadWriteOnce"` | homarr-database access mode | +| persistence.homarrDatabase.enabled | bool | `false` | Enable homarr-database persistent storage | +| persistence.homarrDatabase.mountPath | string | `"/appdata"` | homarr-database mount path inside the pod | +| persistence.homarrDatabase.name | string | `"homarr-database"` | homarr-database persistent storage name | +| persistence.homarrDatabase.size | string | `"50Mi"` | homarr-database storage size | +| persistence.homarrDatabase.storageClassName | string | `"local-path"` | homarr-database storage class name | +| persistence.homarrDatabase.volumeClaimName | string | `""` | homarr-database optional volumeClaimName to target specific PV | +| persistence.homarrTrustedCerts.certificates | string | `nil` | homarr-trusted-certificates certificates, each entry will become a new trusted certificate as a dedicated file (works only for "configmap" and "secret" mode) | +| persistence.homarrTrustedCerts.enabled | bool | `false` | Enable trusted certificates persistence | +| persistence.homarrTrustedCerts.existingSecretKeys | string | `nil` | List of keys (filenames) to mount from the existing secret (used only when type is "existingSecret") | +| persistence.homarrTrustedCerts.existingSecretName | string | `""` | Name of the existing Kubernetes Secret to mount (required if type is "existingSecret") | +| persistence.homarrTrustedCerts.mountPath | string | `"/appdata/trusted-certificates"` | homarr-trusted-certificates mount path inside the pod | +| persistence.homarrTrustedCerts.type | string | `"configmap"` | Persistence mode can be : configmap (declarative), secret (declarative) or existingSecret (mount an existing Kubernetes Secret by name and specify which keys to mount as files) | +| podAnnotations | object | `{}` | Pod annotations | +| podLabels | object | `{}` | Pod labels | +| podSecurityContext | object | `{}` | Pod security context | +| rbac | object | `{"enabled":false}` | Enable RBAC resources for Kubernetes integration Creates Role, ClusterRole, and associated bindings for Homarr's Kubernetes features | +| rbac.enabled | bool | `false` | Enable to create RBAC resources and activate Kubernetes integration | +| readinessProbe.failureThreshold | int | `3` | Failure threshold for readiness probe - number of consecutive failures before pod is considered unready | +| readinessProbe.httpGet.path | string | `"/api/health/ready"` | This is the readiness check endpoint used by Kubernetes to determine if the application is ready to handle traffic. | +| readinessProbe.httpGet.port | int | `7575` | The port on which the readiness check will be performed. This must match the container's exposed port. | +| readinessProbe.initialDelaySeconds | int | `10` | Initial delay in seconds before the readiness probe starts. increase this value if the pod is slow to fully start. | +| readinessProbe.periodSeconds | int | `10` | Period in seconds between readiness probe checks | +| readinessProbe.timeoutSeconds | int | `1` | Timeout in seconds for each readiness probe check | +| replicaCount | int | `1` | Number of replicas | +| resources | object | `{}` | Resource configuration | +| securityContext | object | `{}` | Security context | +| service.enabled | bool | `true` | Enable service | +| service.ipFamilies | list | `[]` | List of IP families to use for the service. Examples: - ["IPv4"] - ["IPv6"] - ["IPv4", "IPv6"] for dual-stack Leave empty to use cluster default behavior | +| service.ipFamilyPolicy | string | `"SingleStack"` | Defines how the service assigns IP families (IPv4/IPv6) Possible values: - SingleStack (default): Only one IP family, usually IPv4 - PreferDualStack: Use dual-stack if the cluster supports it, fallback to single - RequireDualStack: Fail if dual-stack cannot be assigned | +| service.ports.app.port | int | `7575` | Service port | +| service.ports.app.protocol | string | `"TCP"` | Service protocol | +| service.ports.app.targetPort | string | `"http"` | Service target port | +| service.type | string | `"ClusterIP"` | Service type | +| strategyType | string | `"RollingUpdate"` | `strategyType` specifies the strategy used to replace old Pods by new ones. `strategyType` can be `"Recreate"` or `"RollingUpdate"`. `"RollingUpdate"` is the default value and updates Pods in a rolling update fashion. `"Recreate"` will kill all existing Pods before new ones are created. The `"Recreate"` strategy is necessary when persistent volume's `accessMode` is set to `"ReadWriteOnce"` when using `helm upgrade`, as pod volume attachments to an existing PersistentVolumeClaim need to be cleared before a new pod can attach to it. | +| tolerations | list | `[]` | Node tolerations for pod scheduling | --- diff --git a/apps/docs/docs/integrations/docker/index.mdx b/apps/docs/docs/integrations/docker/index.mdx index 568bf8be4..40ade4912 100644 --- a/apps/docs/docs/integrations/docker/index.mdx +++ b/apps/docs/docs/integrations/docker/index.mdx @@ -54,6 +54,74 @@ services: With docker run, add this parameter: `-v /var/run/docker.sock:/var/run/docker.sock`. In Windows, you'll need to adjust the slashes: `-v //var/run/docker.sock:/var/run/docker.sock` +### Using Podman + +On Linux, Homarr can connect to Podman's [Docker-compatible API](https://docs.podman.io/en/latest/markdown/podman-system-service.1.html). +The Podman API socket must be running and mounted into the Homarr container. +The socket grants full access to Podman, including arbitrary code execution as the user running the API, so only mount it into trusted containers. + +For rootless Podman, enable the socket for the user that runs Homarr: + +```bash +systemctl --user enable --now podman.socket +``` + +The rootless socket is located at `$XDG_RUNTIME_DIR/podman/podman.sock` (usually `/run/user//podman/podman.sock`). +You can confirm the path with: + +```bash +podman info --format '{{.Host.RemoteSocket.Path}}' +``` + +Mount that socket into the Homarr container and point `DOCKER_SOCKET_PATHS` to its path **inside** the container: + +```yaml +services: + homarr: + container_name: homarr + image: ghcr.io/homarr-labs/homarr:latest + restart: unless-stopped + volumes: + - ${XDG_RUNTIME_DIR}/podman/podman.sock:/var/run/podman.sock + - ./homarr/appdata:/appdata + environment: + SECRET_ENCRYPTION_KEY: your_64_character_hex_string + DOCKER_SOCKET_PATHS: /var/run/podman.sock + ports: + - "7575:7575" +``` + +Start Homarr with `podman compose up -d`. +Run the compose project as the same user that owns the rootless Podman socket so `$XDG_RUNTIME_DIR` resolves to the correct path. +To keep the user socket available after logout and across reboots, enable lingering with `loginctl enable-linger `. + +For rootful Podman, enable the system socket with `sudo systemctl enable --now podman.socket`. +Its default path is `/run/podman/podman.sock`; mount that path instead and start Homarr with `sudo podman compose up -d`. + +:::note SELinux +On systems with SELinux enabled, Podman's documentation recommends disabling label separation for a container that accesses the API socket. +Add the following to the Homarr service: + +```yaml +security_opt: + - label=disable +``` + +::: + +You can connect Homarr to multiple Docker-compatible sockets by mounting each socket and listing their in-container paths, separated by commas: + +```yaml +services: + homarr: + image: ghcr.io/homarr-labs/homarr:latest + volumes: + - /var/run/docker.sock:/var/run/docker.sock + - ${XDG_RUNTIME_DIR}/podman/podman.sock:/var/run/podman.sock + environment: + DOCKER_SOCKET_PATHS: /var/run/docker.sock,/var/run/podman.sock +``` + ### Managing your Docker containers within Homarr Homarr allows you to interact with Docker containers running on your system. diff --git a/apps/docs/docs/integrations/immich/index.mdx b/apps/docs/docs/integrations/immich/index.mdx index 952921bfb..d29e6cdab 100644 --- a/apps/docs/docs/integrations/immich/index.mdx +++ b/apps/docs/docs/integrations/immich/index.mdx @@ -43,13 +43,11 @@ import { immichServerStatsWidget } from "../../widgets/immich-server-stats"; user.read,{" "} user.update,{" "} asset.read,{" "} + asset.view,{" "} asset.download,{" "} asset.statistics,{" "} server.statistics,{" "} server.about. -
- Additional permissions may be required depending on your Immich version. If you encounter authorization errors, grant any missing permissions requested by the server. - ), diff --git a/apps/docs/docs/integrations/navidrome/index.mdx b/apps/docs/docs/integrations/navidrome/index.mdx index 93c5b076b..2f623f009 100644 --- a/apps/docs/docs/integrations/navidrome/index.mdx +++ b/apps/docs/docs/integrations/navidrome/index.mdx @@ -4,20 +4,29 @@ description: "Navidrome is a self-hosted music server and streamer compatible wi 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 { navidromeIntegration } from "."; -import { audioStatsWidget } from "@site/docs/widgets/audio-stats"; +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 { navidromeIntegration } from '.'; +import { audioStatsWidget } from '@site/docs/widgets/audio-stats'; +import { mediaServerWidget } from '@site/docs/widgets/media-server'; - -Navidrome provides music library statistics including artist, album, and song counts, as well as now playing information via the Subsonic API. + + +Navidrome provides music library statistics including artist, album, and song counts, as well as now playing audio streams via the Subsonic API. ### Widgets & Capabilities - - + ### Adding the integration diff --git a/apps/docs/docs/management/apps/index.mdx b/apps/docs/docs/management/apps/index.mdx index 955a8a3fc..7c84df588 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. -It can be a url to an image in the internet, one from the included icon repositories or a local file uploaded through the upload button. +You can paste a direct HTTP(S) URL to an image, search the included icon repositories by name, or upload a local file through the upload button. Direct image URLs are used as entered and are not searched in the icon repositories. ![Icon URL](./img/app-icon-url.png) @@ -40,11 +40,14 @@ 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. +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 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. 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 diff --git a/apps/docs/docs/management/boards/index.mdx b/apps/docs/docs/management/boards/index.mdx index 1240c7a76..93bf78ae3 100644 --- a/apps/docs/docs/management/boards/index.mdx +++ b/apps/docs/docs/management/boards/index.mdx @@ -36,13 +36,13 @@ 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 @@ -55,6 +55,8 @@ 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 @@ -105,7 +107,7 @@ 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. +Once the minimum of the next layout is reached the layout will change. #### Preview @@ -219,7 +221,7 @@ This action is irreversible and cannot be undone. 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. diff --git a/apps/docs/docs/management/settings/index.mdx b/apps/docs/docs/management/settings/index.mdx index d5ffcff8b..8507d2b4f 100644 --- a/apps/docs/docs/management/settings/index.mdx +++ b/apps/docs/docs/management/settings/index.mdx @@ -10,109 +10,126 @@ tags: # Settings -The settings page has a few options that will be applied to the entire Homarr application. -They may require a full page reload for changes to apply. +The settings page applies options to the whole Homarr application. +Some changes need a full page reload before they take effect. + +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 -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. +We collect this data to see what features people ACTUALLY use. +We use this data to decide where we spend our development time. -A full list of the collected data by default can be found here: https://umami.is/docs/metric-definitions. +We do not sell the data. We do not share the data with third parties. +The data does not contain personal information and does not identify you or your installation. -We understand that you might not want Homarr to communicate with our services. -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`. +The server sends the data once a week and when the server starts. -Additionally, you opt in to more data collection to help us provide more useful data. +### What we collect -### Integration data +Homarr collects technical data about your installation: -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. +- Your Homarr version +- Your database type (SQLite, MySQL, or PostgreSQL) +- Whether Docker or Kubernetes is enabled +- Your enabled authentication providers +- Your operating system and CPU architecture +- Your default language +- The number of boards, apps, sections, layouts, users, groups, search engines, icon repositories, cron jobs, API keys, invites, sessions, and accounts +- The integration types you use and how many you have of each (for example, Sonarr or SABnzbd) +- The widget types you use and how many you have of each -### Widget data +Homarr does NOT collect: -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. +- URLs +- Names +- Usernames, emails, or passwords +- Integration secrets or API keys +- Error messages or logs +- Any data that can identify you -### Users data +Each installation gets a random ID. We use this ID only to count unique installations. We cannot use this ID to identify you. -Enabling this optional data will send the count of users and whether you activated SSO. -This will not include any usernames, passwords, emails, URLs, authentication flows or client IDs. -This is disabled by default. +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) -Big companies [like Google, Microsoft, Baidu and Yandex](https://de.wikipedia.org/wiki/Web-Index) will use [web crawlers](https://de.wikipedia.org/wiki/Webcrawler) to index the Internet for new websites & pages. -This is desirable in many cases (for example when selling products in an online shop) but can lead to unwanted traffic and attention to your personal dashboard. -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. -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. +Search engines use web crawlers to index websites. This can cause unwanted traffic to your dashboard. +Homarr asks crawlers to not index your instance by default. Use these settings to change this request. + +Homarr cannot control what the crawlers do. A crawler can ignore the request. Your website can still appear in an index. ### No index -This will ask crawlers to not index your Homarr instance on any indexes. -It is up to the crawler to properly respect this request. -By default, this setting is enabled. Disabling it will allow indexing. +This setting asks crawlers to not index your instance. +This setting is enabled by default. Disable this setting to allow indexing. ### No follow -If indexing is allowed, crawlers will also attempt to follow any links on your page to further "discover" new parts of the Internet and on your website. -By default, this setting is enabled and will ask crawlers to not follow any links. -Crawler of different companies may behave differently when indexing is prohibited and following is allowed. +Crawlers follow links to discover new pages. +This setting asks crawlers to not follow links. This setting is enabled by default. +Crawler behavior can differ between search engines. ### No translate -Google and Chrome on Android will prompt users to translate pages that are not in their device language. -This can lead to some shifting and can be undesired in some cases. By enabling this, -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. +Some browsers offer to translate pages. +This setting asks crawlers to not offer translation. Some browsers ignore this setting. ### No site links search box -This setting is exclusive to Google. -[Google will create a box below your domain](https://developers.google.com/search/docs/appearance/structured-data/sitelinks-searchbox) that lists the paths that were discovered by the "folowing". -The user can search them directly in Google. -You can ask the crawlers to not allow searches by enabling this setting. -This setting is disabled by default. +This setting applies to Google only. +Google can show a search box below your domain. +This setting asks Google to not show this box. This setting is disabled by default. ## Boards ### Global home board -You can set a global home board that will be used for all users that have not set a home board themselves. -It will also be used for anonymous users. -Note that the board must be public to be used as a global home board. +You can set a global home board. +This board is the fallback for users without their own home board. It is also the fallback for anonymous users. +The board must be public. ### Global mobile board -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). -It will similar to the global home board be as fallback for normal users and for any anonymous users. -It also has to be public. +You can set a global mobile board. +Homarr shows this board on mobile devices instead of the global home board. +Homarr detects mobile devices with the User-Agent header. +The board must be public. ### App status #### Enable status by default -You can decide if the app status should be enabled by default when adding a new app to the board. +You can decide if app status is enabled when you add a new app. #### Force disable status -You can decide if the app status should be disabled for all boards. - -### +You can decide if app status is disabled on all boards. ## Appearance ### 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 ### 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. diff --git a/apps/docs/docs/widgets/audio-stats/index.mdx b/apps/docs/docs/widgets/audio-stats/index.mdx index 687c1cde9..88fa87f79 100644 --- a/apps/docs/docs/widgets/audio-stats/index.mdx +++ b/apps/docs/docs/widgets/audio-stats/index.mdx @@ -1,6 +1,6 @@ --- title: "Audio Stats" -description: "Displays library statistics from Navidrome or Audiobookshelf, adapting its display based on the linked integration." +description: "Displays media and listening statistics from Navidrome or Audiobookshelf, adapting its display based on the linked integration." hide_title: true --- @@ -16,7 +16,7 @@ import { audiobookshelfIntegration } from "@site/docs/integrations/audiobookshel This widget adapts its display based on the linked integration: -- **Navidrome**: Shows artist, album, and song counts, plus a now playing section with active streams +- **Navidrome**: Shows artist, album, and song counts - **Audiobookshelf**: Shows library count, audiobook/podcast totals, listening time, and active sessions Only options relevant to your linked integration are shown in the configuration panel. @@ -27,7 +27,7 @@ Only options relevant to your linked integration are shown in the configuration items={[ { integration: navidromeIntegration, - note: "Displays music library stats and now playing", + note: "Displays music library statistics", }, { integration: audiobookshelfIntegration, diff --git a/apps/docs/docs/widgets/audio-stats/index.ts b/apps/docs/docs/widgets/audio-stats/index.ts index ab9bd7f2f..21ef8714d 100644 --- a/apps/docs/docs/widgets/audio-stats/index.ts +++ b/apps/docs/docs/widgets/audio-stats/index.ts @@ -27,24 +27,6 @@ export const audioStatsWidget: WidgetDefinition = { values: { type: "boolean" }, defaultValue: "yes", }, - { - name: "Show now playing", - description: "Displays currently playing tracks (Navidrome only)", - values: { type: "boolean" }, - defaultValue: "yes", - }, - { - name: "Show now playing list", - description: "Displays a detailed list of now playing tracks (Navidrome only)", - values: { type: "boolean" }, - defaultValue: "yes", - }, - { - name: "Max now playing items", - description: "Maximum number of now playing tracks to display (Navidrome only)", - values: "Number (1-5)", - defaultValue: "3", - }, { name: "Show library count", description: "Displays the number of libraries (Audiobookshelf only)", diff --git a/apps/docs/docs/widgets/beszel-system-stats/index.mdx b/apps/docs/docs/widgets/beszel-system-stats/index.mdx index 1e9c896b9..e29d083ba 100644 --- a/apps/docs/docs/widgets/beszel-system-stats/index.mdx +++ b/apps/docs/docs/widgets/beszel-system-stats/index.mdx @@ -15,7 +15,7 @@ import beszelSystemStats from "./img/beszel-system-stats.png"; -This widget displays time-series charts for a selected Beszel system, including CPU, memory, disk, network, and Docker container metrics. The Disk Usage chart combines the root disk and every extra filesystem reported by Beszel. You can use the realtime Live view or select a historical period ranging from 1 hour to 30 days. If the live stream drops, the widget now shows the connection failure state immediately and can switch back to historical data. +This widget displays time-series charts for a selected Beszel system, including CPU, memory, disk, network, and Docker container metrics. The Disk Usage chart combines the root disk and every extra filesystem reported by Beszel. You can use the realtime Live view or select a historical period ranging from 1 hour to 30 days. Live view keeps a one-minute window and pauses while the browser tab is hidden. If the live stream drops, the widget now shows the connection failure state immediately and can switch back to historical data. ### Screenshots diff --git a/apps/docs/docs/widgets/beszel-system-table/index.mdx b/apps/docs/docs/widgets/beszel-system-table/index.mdx index 87cb16ac5..9063727fa 100644 --- a/apps/docs/docs/widgets/beszel-system-table/index.mdx +++ b/apps/docs/docs/widgets/beszel-system-table/index.mdx @@ -13,10 +13,17 @@ import { beszelIntegration } from "@site/docs/integrations/beszel"; -This widget displays all your Beszel-monitored systems in a sortable data table. Each row shows system metrics like CPU, memory, disk, network, temperature, and more. Columns can be toggled on or off, and the table supports sorting by any column. When Beszel reports extra filesystems, the disk column adds a hover breakdown for each mount alongside the root disk. +This widget displays all your Beszel-monitored systems in a sortable data table. Each row shows system metrics like CPU, memory, disk, network, temperature, and more. Columns can be toggled on or off, dragged into a different order, and resized from their headers. Column order and widths are saved automatically for each widget. When Beszel reports extra filesystems, the disk column adds a hover breakdown for each mount alongside the root disk. Select a system row to open the complete statistics view, including system, filesystem, Docker, historical, and realtime charts. +### 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 + +### Configuration + + diff --git a/apps/docs/docs/widgets/docker-containers/index.ts b/apps/docs/docs/widgets/docker-containers/index.ts index 3e4a17ab6..0517938d3 100644 --- a/apps/docs/docs/widgets/docker-containers/index.ts +++ b/apps/docs/docs/widgets/docker-containers/index.ts @@ -8,6 +8,12 @@ export const dockerContainersWidget: WidgetDefinition = { path: "../../widgets/docker-containers", configuration: { 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", description: "Allows to sort containers by clicking on the column headers", diff --git a/apps/docs/docs/widgets/downloads/img/context-menu.png b/apps/docs/docs/widgets/downloads/img/context-menu.png new file mode 100644 index 000000000..350051d00 Binary files /dev/null and b/apps/docs/docs/widgets/downloads/img/context-menu.png differ diff --git a/apps/docs/docs/widgets/downloads/img/screenshot.png b/apps/docs/docs/widgets/downloads/img/screenshot.png index c6cdde381..8ef3a1701 100644 Binary files a/apps/docs/docs/widgets/downloads/img/screenshot.png and b/apps/docs/docs/widgets/downloads/img/screenshot.png differ diff --git a/apps/docs/docs/widgets/downloads/index.mdx b/apps/docs/docs/widgets/downloads/index.mdx index 20c7fbfc0..9c05b714c 100644 --- a/apps/docs/docs/widgets/downloads/index.mdx +++ b/apps/docs/docs/widgets/downloads/index.mdx @@ -4,69 +4,86 @@ description: "Allows you to view and manage your Downloads from both Torrent and hide_title: true --- -import { delugeIntegration } from "@site/docs/integrations/deluge"; -import { nzbgetIntegration } from "@site/docs/integrations/nzbget"; -import { qBittorentIntegration } from "@site/docs/integrations/q-bittorent"; -import { sabnzbdIntegration } from "@site/docs/integrations/sabnzbd"; -import { transmissionIntegration } from "@site/docs/integrations/transmission"; -import { slskdIntegration } from "@site/docs/integrations/slskd"; -import { AddingWidget } from "@site/src/components/widgets/adding"; -import { WidgetConfig } from "@site/src/components/widgets/configuration"; -import { WidgetHeader } from "@site/src/components/widgets/header"; -import { WidgetIntegrations } from "@site/src/components/widgets/integrations"; -import { IconAd } from "@tabler/icons-react"; -import TabItem from "@theme/TabItem"; -import Tabs from "@theme/Tabs"; -import { downloadsWidget } from "."; +import { aria2Integration } from '@site/docs/integrations/aria2'; +import { delugeIntegration } from '@site/docs/integrations/deluge'; +import { nzbgetIntegration } from '@site/docs/integrations/nzbget'; +import { qBittorentIntegration } from '@site/docs/integrations/q-bittorent'; +import { sabnzbdIntegration } from '@site/docs/integrations/sabnzbd'; +import { transmissionIntegration } from '@site/docs/integrations/transmission'; +import { slskdIntegration } from '@site/docs/integrations/slskd'; +import { AddingWidget } from '@site/src/components/widgets/adding'; +import { WidgetConfig } from '@site/src/components/widgets/configuration'; +import { WidgetHeader } from '@site/src/components/widgets/header'; +import { WidgetIntegrations } from '@site/src/components/widgets/integrations'; +import { downloadsWidget } from '.'; -This widget displays a list of torrents or usenet-articles that are currently being downloaded, along with their progress and other relevant information. +This widget displays a list of torrents or usenet-articles that are currently being downloaded, along with their progress and other relevant information. It supports 7 download client integrations and provides a rich, responsive table interface. ### Screenshots -import downloadsCard from "./img/screenshot.png"; +import downloadsCard from './img/screenshot.png'; +import contextMenuImg from './img/context-menu.png';
- downloads + Downloads widget showing torrent and usenet items
+#### Right-click context menu + +
+ Downloads widget context menu with pause, delete, and copy actions +
+ +### Features + +- **Responsive table** — columns automatically adapt to widget width, hiding less important columns at smaller sizes +- **Sortable columns** — click any column header to sort ascending/descending +- **Column customization** — choose which columns to display, then hover a header to reveal its grab handle for reordering or drag its edge to resize it; layouts save automatically per widget +- **Right-click context menu** — pause, resume, delete downloads, or copy download names directly from the context menu +- **Row expansion** — click any row to see full details inline without opening a separate modal +- **Hover tooltips** — hover a download name to see size, speeds, ETA, ratio, and category +- **Global stats bar** — toggle a summary bar showing queue progress, total size, speeds, ratio, and per-state counts +- **Client filtering** — filter downloads by integration or status using the footer filter panel +- **Global controls** — pause/resume all downloads across all clients from the footer bar +- **Status indicators** — color-coded badges show download state at a glance +- **Speed overview** — total download and upload speeds displayed in the footer + ### Supported Integrations - + ### Interactions -- Pause / Resume one download by clicking on the according icon. -- Delete one download by clicking on the trash icon. (Can also delete the files) -- Pause / Resume all downloads by using the bottom button. +- **Right-click** any download to open a context menu with pause/resume, delete, and copy actions +- **Click** any row to expand it and see full download details inline +- **Hover** a download name for a quick stats tooltip +- **Stats toggle** — show or hide the global stats bar from the footer +- **Pause / Resume** all downloads using the footer controls +- **Filter** by integration or status using the filter button in the footer +- **Sort** by clicking column headers (with persistent sort direction) ### Adding the widget diff --git a/apps/docs/docs/widgets/downloads/index.ts b/apps/docs/docs/widgets/downloads/index.ts index 4810aec89..4245af310 100644 --- a/apps/docs/docs/widgets/downloads/index.ts +++ b/apps/docs/docs/widgets/downloads/index.ts @@ -2,26 +2,23 @@ import { WidgetDefinition } from "@site/src/types"; import { IconDownload } from "@tabler/icons-react"; const columnsList = [ - "id", - "actions", - "added", - "category", - "downSpeed", - "index", - "integration", "name", "progress", + "size", + "downSpeed", + "upSpeed", + "time", + "state", + "added", "ratio", "received", "sent", - "size", - "state", - "time", + "category", + "integration", + "index", "type", - "upSpeed", ]; -const sortingExclusion = ["actions", "id", "state"]; -const columnsSort = columnsList.filter((column) => !sortingExclusion.some((exclusion) => exclusion === column)); +const sortColumns = columnsList.filter((c) => !["state", "category", "integration"].includes(c)); export const downloadsWidget: WidgetDefinition = { icon: IconDownload, @@ -32,25 +29,20 @@ export const downloadsWidget: WidgetDefinition = { items: [ { name: "Columns to show", - description: "Select the columns you want to display in the widget.", + description: + "Select the columns you want to display in the widget. Columns automatically hide/show based on widget width.", values: `List of columns: ${columnsList.join(", ")}`, - defaultValue: ["integration", "name", "progress", "time", "actions"].join(", "), + defaultValue: ["name", "progress", "downSpeed", "time", "state"].join(", "), }, { - name: "Enable items sorting", - description: "Enables the sorting in the table.", - values: { type: "boolean" }, - defaultValue: "no", - }, - { - name: "Column used for sorting by default", - description: "By default it will sort by this column.", - defaultValue: "type", - values: `List of columns: ${columnsSort.join(", ")}`, + name: "Default sort column", + description: "The column used for initial sorting when the widget loads.", + defaultValue: "progress", + values: `List of columns: ${sortColumns.join(", ")}`, }, { name: "Invert sorting", - description: "This will invert the sorting order of the table.", + description: "This will invert the default sorting order.", values: { type: "boolean" }, defaultValue: "no", }, @@ -76,13 +68,13 @@ export const downloadsWidget: WidgetDefinition = { name: "Hide completed torrent under this threshold (in kiB/s)", description: "This will hide completed torrent entries that have a download speed below the specified threshold.", - values: "Any number above 1, 0 to disable", + values: "Any number above 0, 0 to disable", defaultValue: "0", }, { - name: "Categories/names to filter", - description: "You can filter the items by categories or names. Use a comma to separate multiple values.", - values: "Comma-separated list of categories or names", + name: "Categories/labels to filter", + description: "You can filter the items by categories or labels. Use a comma to separate multiple values.", + values: "Comma-separated list of categories or labels", defaultValue: "-", }, { @@ -94,13 +86,13 @@ export const downloadsWidget: WidgetDefinition = { }, { name: "Use filter to calculate Ratio", - description: "This will use the filter to calculate the ratio of items shown in the widget.", + description: "This will use the category filter when calculating the global torrent ratio.", values: { type: "boolean" }, defaultValue: "yes", }, { name: "Limit items per integration", - description: "This will limit the number of items shown per integration, not globally", + description: "This will limit the number of items shown per integration, not globally.", values: "Any number above 1", defaultValue: "50", }, diff --git a/apps/docs/docs/widgets/immich-album-carousel/index.mdx b/apps/docs/docs/widgets/immich-album-carousel/index.mdx index d33bf184b..64f820ea1 100644 --- a/apps/docs/docs/widgets/immich-album-carousel/index.mdx +++ b/apps/docs/docs/widgets/immich-album-carousel/index.mdx @@ -1,6 +1,6 @@ --- -title: "Immich Album Carousel" -description: "Shows a slideshow of your album pictures from Immich" +title: 'Immich Album Carousel' +description: 'Shows a slideshow of pictures from an Immich album or your full library' hide_title: true --- @@ -34,5 +34,4 @@ import screen from "./img/screen.png"; ### Configuration - diff --git a/apps/docs/docs/widgets/immich-album-carousel/index.ts b/apps/docs/docs/widgets/immich-album-carousel/index.ts index f6c4b224c..72a0b2211 100644 --- a/apps/docs/docs/widgets/immich-album-carousel/index.ts +++ b/apps/docs/docs/widgets/immich-album-carousel/index.ts @@ -4,15 +4,16 @@ import { IconPhoto } from "@tabler/icons-react"; export const immichAlbumCarouselWidget: WidgetDefinition = { icon: IconPhoto, name: "Immich Album", - description: "Shows a slideshow of your album pictures from Immich", + description: "Shows a slideshow of pictures from an Immich album or your full library", path: "../../widgets/immich-album-carousel", configuration: { items: [ { name: "Album", - description: "Select the Immich album to show in the slideshow.", + description: + "Select an Immich album or All photos. An empty selection also shows random photos from your full library.", values: { type: "string" }, - defaultValue: "", + defaultValue: "All photos", }, { name: "Interval in seconds", diff --git a/apps/docs/docs/widgets/media-server/index.mdx b/apps/docs/docs/widgets/media-server/index.mdx index f73e07b5c..fae83d04b 100644 --- a/apps/docs/docs/widgets/media-server/index.mdx +++ b/apps/docs/docs/widgets/media-server/index.mdx @@ -9,12 +9,13 @@ 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 { IconAd } from "@tabler/icons-react"; -import Tabs from "@theme/Tabs"; -import TabItem from "@theme/TabItem"; -import { mediaServerWidget } from "."; -import { embyIntegration } from "@site/docs/integrations/emby"; -import { jellyfinIntegration } from "@site/docs/integrations/jellyfin"; -import { plexIntegration } from "@site/docs/integrations/plex"; +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; +import { mediaServerWidget } from '.'; +import { embyIntegration } from '@site/docs/integrations/emby'; +import { jellyfinIntegration } from '@site/docs/integrations/jellyfin'; +import { navidromeIntegration } from '@site/docs/integrations/navidrome'; +import { plexIntegration } from '@site/docs/integrations/plex'; @@ -29,25 +30,21 @@ import mediaTable from "./img/table.png"; ### Supported Integrations - + ### Adding the widget ### Configuration - diff --git a/apps/docs/docs/widgets/media-server/index.ts b/apps/docs/docs/widgets/media-server/index.ts index b9f5ec76f..419f860a1 100644 --- a/apps/docs/docs/widgets/media-server/index.ts +++ b/apps/docs/docs/widgets/media-server/index.ts @@ -10,7 +10,7 @@ export const mediaServerWidget: WidgetDefinition = { items: [ { name: "Show only currently playing", - description: "Disabling this will not work for plex.", + description: "Plex and Navidrome always show only currently playing streams.", values: { type: "boolean" }, defaultValue: "yes", }, diff --git a/apps/nextjs/next.config.ts b/apps/nextjs/next.config.ts index 4ff31b9aa..2105259be 100644 --- a/apps/nextjs/next.config.ts +++ b/apps/nextjs/next.config.ts @@ -1,4 +1,6 @@ // Importing env files here to validate on build +import path from "node:path"; + import "@homarr/auth/env"; import "@homarr/core/infrastructure/db/env"; import "@homarr/common/env"; @@ -13,7 +15,21 @@ const withNextIntl = createNextIntlPlugin({ 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 = { + // Next previews otherwise create agent instruction files in the application + // directory during development. + agentRules: false, env: { HOMARR_VERSION: process.env.HOMARR_VERSION ?? "unknown", }, @@ -30,19 +46,14 @@ const nextConfig: NextConfig = { serverExternalPackages: ["dockerode", "isomorphic-dompurify", "jsdom", "better-sqlite3"], experimental: { optimizePackageImports: ["@mantine/core", "@mantine/hooks", "@tabler/icons-react"], - turbopackFileSystemCacheForDev: true, - webpackMemoryOptimizations: true, + turbopackFileSystemCacheForBuild: true, + useTypeScriptCli: true, }, turbopack: { - // ponytail: known Turbopack NFT warning from path.join(process.cwd(), …) in - // src/app/api/backup/{route,shared}.ts. No working placement in 16.2.x - // (see vercel/next.js#95125). Suppress until upstream fix lands. - ignoreIssue: [ - { - path: "**/*", - title: "Encountered unexpected file in NFT list", - }, - ], + root: path.resolve(import.meta.dirname, "../.."), + // Development runs tasks and WebSocket as separate processes. These aliases + // keep their production-only instrumentation imports out of the dev graph. + resolveAlias: getDevelopmentServiceAliases(), }, transpilePackages: ["@homarr/ui", "@homarr/notifications", "@homarr/modals", "@homarr/spotlight", "@homarr/widgets"], images: { diff --git a/apps/nextjs/package.json b/apps/nextjs/package.json index 433dc7a7e..157a012a7 100644 --- a/apps/nextjs/package.json +++ b/apps/nextjs/package.json @@ -62,11 +62,10 @@ "@modelcontextprotocol/sdk": "catalog:", "@scalar/api-reference-react": "catalog:", "@tabler/icons-react": "catalog:", - "@tanstack/query-async-storage-persister": "catalog:", + "@tanstack/react-devtools": "catalog:", "@tanstack/react-query": "catalog:", "@tanstack/react-query-devtools": "catalog:", "@tanstack/react-query-next-experimental": "catalog:", - "@tanstack/react-query-persist-client": "catalog:", "@trpc/client": "catalog:", "@trpc/next": "catalog:", "@trpc/react-query": "catalog:", diff --git a/apps/nextjs/src/app/[locale]/_client-providers/development-tools.tsx b/apps/nextjs/src/app/[locale]/_client-providers/development-tools.tsx new file mode 100644 index 000000000..a23164547 --- /dev/null +++ b/apps/nextjs/src/app/[locale]/_client-providers/development-tools.tsx @@ -0,0 +1,120 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { usePathname } from "next/navigation"; +import { TanStackDevtools } from "@tanstack/react-devtools"; +import type { TanStackDevtoolsReactPlugin } from "@tanstack/react-devtools"; +import type { QueryClient } from "@tanstack/react-query"; +import { useQueryClient } from "@tanstack/react-query"; +import { ReactQueryDevtoolsPanel } from "@tanstack/react-query-devtools"; + +const valueStyle = { fontFamily: "monospace", overflowWrap: "anywhere" } as const; +const metricStyle = { + background: "color-mix(in srgb, currentColor 4%, transparent)", + border: "1px solid color-mix(in srgb, currentColor 18%, transparent)", + borderRadius: 8, + padding: 12, +} as const; + +const readDiagnostics = (queryClient: QueryClient) => { + const queries = queryClient.getQueryCache().getAll(); + const mutations = queryClient.getMutationCache().getAll(); + const board = document.querySelector("[data-homarr-dev-benchmark-board]"); + const widgetItems = board?.querySelectorAll('[data-type="item"]').length ?? 0; + const readyWidgets = board?.querySelectorAll("[data-homarr-widget-ready]").length ?? 0; + const resources = performance.getEntriesByType("resource") as PerformanceResourceTiming[]; + + return { + active: queries.filter((query) => query.getObserversCount() > 0).length, + errors: queries.filter((query) => query.state.status === "error").length, + fetching: queries.filter((query) => query.state.fetchStatus === "fetching").length, + pendingMutations: mutations.filter((mutation) => mutation.state.status === "pending").length, + stale: queries.filter((query) => query.isStale()).length, + total: queries.length, + readyWidgets, + widgetItems, + scriptKiB: Math.round( + resources + .filter((resource) => resource.initiatorType === "script") + .reduce((total, resource) => total + resource.decodedBodySize, 0) / 1024, + ), + }; +}; + +const Metric = ({ label, value }: { label: string; value: number | string }) => ( +
+
{label}
+ {value} +
+); + +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 ( +
+
+

Homarr performance

+

Live state for {pathname}

+
+
+ + + + + + + + +
+
+ ); +}; + +const developmentToolsPlugins = [ + { + id: "tanstack-query", + name: "TanStack Query", + render: () => , + }, + { + id: "homarr-performance", + name: "Homarr", + defaultOpen: true, + render: (_element, { devtoolsOpen }) => , + }, +] satisfies TanStackDevtoolsReactPlugin[]; + +export const DevelopmentTools = () => ( + +); diff --git a/apps/nextjs/src/app/[locale]/_client-providers/query-cache-persister.ts b/apps/nextjs/src/app/[locale]/_client-providers/query-cache-persister.ts deleted file mode 100644 index 3af24c508..000000000 --- a/apps/nextjs/src/app/[locale]/_client-providers/query-cache-persister.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { createAsyncStoragePersister } from "@tanstack/query-async-storage-persister"; -import superjson from "superjson"; - -import { fetchApi } from "@homarr/api/client"; -import { getActiveQueryCacheBoardId, queryCacheStoragePrefix } from "@homarr/api/query-cache"; - -const queryCacheStorage = { - getItem: (_key: string) => null as string | null, - async setItem(_key: string, value: string) { - const boardId = getActiveQueryCacheBoardId(); - if (!boardId) return; - await fetchApi.queryCache.setItem.mutate({ boardId, value }).catch((error) => { - console.warn("[query-cache] persist failed", error); - }); - }, - async removeItem(_key: string) { - const boardId = getActiveQueryCacheBoardId(); - if (!boardId) return; - await fetchApi.queryCache.removeItem.mutate({ boardId }).catch((error) => { - console.warn("[query-cache] remove failed", error); - }); - }, -}; - -export const createWidgetQueryPersister = () => - createAsyncStoragePersister({ - storage: typeof window === "undefined" ? undefined : queryCacheStorage, - key: queryCacheStoragePrefix, - throttleTime: 2000, - serialize: (data) => superjson.stringify(data), - deserialize: (data) => superjson.parse(data), - }); diff --git a/apps/nextjs/src/app/[locale]/_client-providers/query-retry.spec.ts b/apps/nextjs/src/app/[locale]/_client-providers/query-retry.spec.ts new file mode 100644 index 000000000..77aa7c918 --- /dev/null +++ b/apps/nextjs/src/app/[locale]/_client-providers/query-retry.spec.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from "vitest"; +import { TRPCClientError } from "@trpc/client"; + +import { createQueryRetry } from "./query-retry"; + +const createTrpcError = (code: string) => { + const error = Object.assign(new Error(code), { data: { code } }); + Object.setPrototypeOf(error, TRPCClientError.prototype); + return error; +}; + +describe("createQueryRetry", () => { + it.each(["BAD_REQUEST", "UNAUTHORIZED", "FORBIDDEN", "NOT_FOUND", "UNPROCESSABLE_CONTENT"])( + "does not retry the permanent %s response", + (code) => { + expect(createQueryRetry(3)(0, createTrpcError(code))).toBe(false); + }, + ); + + it("retries transient and network failures only up to the configured limit", () => { + const retry = createQueryRetry(3); + + expect(retry(0, createTrpcError("INTERNAL_SERVER_ERROR"))).toBe(true); + expect(retry(2, new Error("connection reset"))).toBe(true); + expect(retry(3, new Error("connection reset"))).toBe(false); + }); +}); diff --git a/apps/nextjs/src/app/[locale]/_client-providers/query-retry.ts b/apps/nextjs/src/app/[locale]/_client-providers/query-retry.ts new file mode 100644 index 000000000..09b5f19c3 --- /dev/null +++ b/apps/nextjs/src/app/[locale]/_client-providers/query-retry.ts @@ -0,0 +1,20 @@ +import { TRPCClientError } from "@trpc/client"; + +const nonRetryableTrpcCodes = new Set([ + "PARSE_ERROR", + "BAD_REQUEST", + "UNAUTHORIZED", + "FORBIDDEN", + "NOT_FOUND", + "METHOD_NOT_SUPPORTED", + "CONFLICT", + "PRECONDITION_FAILED", + "PAYLOAD_TOO_LARGE", + "UNPROCESSABLE_CONTENT", +]); + +export const createQueryRetry = (maxRetries: number) => (failureCount: number, error: unknown) => { + if (failureCount >= maxRetries) return false; + if (error instanceof TRPCClientError && error.data?.code && nonRetryableTrpcCodes.has(error.data.code)) return false; + return true; +}; diff --git a/apps/nextjs/src/app/[locale]/_client-providers/session-query-scope.spec.ts b/apps/nextjs/src/app/[locale]/_client-providers/session-query-scope.spec.ts new file mode 100644 index 000000000..f82001a5e --- /dev/null +++ b/apps/nextjs/src/app/[locale]/_client-providers/session-query-scope.spec.ts @@ -0,0 +1,52 @@ +// @vitest-environment jsdom + +import { act, createElement } from "react"; +import { createRoot } from "react-dom/client"; +import { describe, expect, it, vi } from "vitest"; + +import { getSessionQueryScope, SessionQueryScopeGuard } from "./session-query-scope"; + +describe("getSessionQueryScope", () => { + it("is stable when permission order changes", () => { + expect(getSessionQueryScope({ user: { id: "user-a", permissions: ["board-view", "integration-use"] } })).toBe( + getSessionQueryScope({ user: { id: "user-a", permissions: ["integration-use", "board-view"] } }), + ); + }); + + it("changes for a different user or permission set", () => { + const original = getSessionQueryScope({ user: { id: "user-a", permissions: ["board-view"] } }); + + expect(getSessionQueryScope({ user: { id: "user-b", permissions: ["board-view"] } })).not.toBe(original); + expect(getSessionQueryScope({ user: { id: "user-a", permissions: [] } })).not.toBe(original); + expect(getSessionQueryScope(null)).toBeNull(); + }); + + it("removes the previous session subtree before requesting a document reload", async () => { + const host = document.createElement("div"); + const root = createRoot(host); + const onScopeChange = vi.fn(); + const renderScope = async (currentScope: string) => { + await act(async () => { + root.render( + createElement( + SessionQueryScopeGuard, + { currentScope, initialScope: "a", onScopeChange }, + createElement("span", null, "session-a-secret"), + ), + ); + }); + }; + + try { + await renderScope("a"); + expect(host.textContent).toBe("session-a-secret"); + + await renderScope("b"); + expect(host.textContent).not.toContain("session-a-secret"); + expect(host.querySelector('[role="status"]')?.getAttribute("aria-busy")).toBe("true"); + expect(onScopeChange).toHaveBeenCalledOnce(); + } finally { + await act(async () => root.unmount()); + } + }); +}); diff --git a/apps/nextjs/src/app/[locale]/_client-providers/session-query-scope.ts b/apps/nextjs/src/app/[locale]/_client-providers/session-query-scope.ts new file mode 100644 index 000000000..d2fa1e732 --- /dev/null +++ b/apps/nextjs/src/app/[locale]/_client-providers/session-query-scope.ts @@ -0,0 +1,39 @@ +import type { PropsWithChildren } from "react"; +import { createElement, useEffect } from "react"; + +interface QuerySession { + user: { + id: string; + permissions: string[]; + }; +} + +export const getSessionQueryScope = (session: QuerySession | null | undefined) => + session ? JSON.stringify([session.user.id, session.user.permissions.toSorted()]) : null; + +interface SessionQueryScopeGuardProps { + initialScope: string | null; + currentScope: string | null; + onScopeChange: () => void; +} + +export const SessionQueryScopeGuard = ({ + initialScope, + currentScope, + onScopeChange, + children, +}: PropsWithChildren) => { + 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" }, + }); +}; diff --git a/apps/nextjs/src/app/[locale]/_client-providers/trpc.tsx b/apps/nextjs/src/app/[locale]/_client-providers/trpc.tsx index 3e167dd8c..535d88e1c 100644 --- a/apps/nextjs/src/app/[locale]/_client-providers/trpc.tsx +++ b/apps/nextjs/src/app/[locale]/_client-providers/trpc.tsx @@ -1,12 +1,10 @@ "use client"; import type { PropsWithChildren } from "react"; -import { useState } from "react"; -import type { QueryKey } from "@tanstack/react-query"; -import { QueryClient } from "@tanstack/react-query"; -import { ReactQueryDevtools } from "@tanstack/react-query-devtools"; +import { useEffect, useMemo, useState } from "react"; +import dynamic from "next/dynamic"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { ReactQueryStreamedHydration } from "@tanstack/react-query-next-experimental"; -import { PersistQueryClientProvider } from "@tanstack/react-query-persist-client"; import { createWSClient, httpBatchStreamLink, @@ -25,18 +23,24 @@ import { TRPCClientError } from "@trpc/client"; import type { AppRouter } from "@homarr/api"; import { clientApi } from "@homarr/api/client"; import { - isPersistableWidgetQueryKey, - queryCacheBuster, + dashboardSupportingQueryPolicies, queryCacheDefaultGcTimeMs, queryCacheDefaultRefetchIntervalMs, queryCacheDefaultStaleTimeMs, } from "@homarr/api/query-cache"; import { createHeadersCallbackForSource, getTrpcUrl } from "@homarr/api/shared"; +import { useSession } from "@homarr/auth/client"; import { env } from "@homarr/common/env"; import { showWarningNotification } from "@homarr/notifications"; -import { widgetImports } from "@homarr/widgets"; +import { widgetQueryRefetchIntervals } from "@homarr/widgets/refetch-intervals"; -import { createWidgetQueryPersister } from "./query-cache-persister"; +import { getSessionQueryScope, SessionQueryScopeGuard } from "./session-query-scope"; +import { createQueryRetry } from "./query-retry"; + +const DevelopmentTools = + process.env.NODE_ENV === "development" + ? dynamic(() => import("./development-tools").then(({ DevelopmentTools: Tools }) => Tools), { ssr: false }) + : () => null; const getWebSocketProtocol = () => { if (typeof window === "undefined") { @@ -59,21 +63,46 @@ const constructWebsocketUrl = () => { return `${getWebSocketProtocol()}://${window.location.hostname}:${window.location.port}/websockets`; }; -const wsClient = createWSClient({ - url: constructWebsocketUrl(), -}); +export function TRPCReactProvider({ children }: PropsWithChildren) { + const { data: session } = useSession(); + const sessionQueryScope = getSessionQueryScope(session); + const [initialSessionQueryScope] = useState(() => sessionQueryScope); -export function TRPCReactProvider(props: PropsWithChildren) { - const [persister] = useState(() => createWidgetQueryPersister()); + return ( + + {children} + + ); +} + +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 client = new QueryClient({ defaultOptions: { queries: { staleTime: queryCacheDefaultStaleTimeMs, gcTime: queryCacheDefaultGcTimeMs, - refetchOnWindowFocus: false, - refetchOnReconnect: false, - retry: 3, + retry: createQueryRetry(env.NODE_ENV === "development" ? 1 : 3), }, mutations: { onError(error) { @@ -91,23 +120,29 @@ export function TRPCReactProvider(props: PropsWithChildren) { }, }, }); - client.setQueryDefaults([["widget"]], { refetchInterval: queryCacheDefaultRefetchIntervalMs }); - for (const { definition } of Object.values(widgetImports)) { - const def = definition as { refetchInterval?: number | null; queryKey?: QueryKey; kind: string }; - if (def.refetchInterval === undefined) continue; - const key = def.queryKey ?? [["widget", def.kind]]; - const interval = def.refetchInterval === null ? false : def.refetchInterval * 1000; - client.setQueryDefaults(key, { refetchInterval: interval }); + client.setQueryDefaults([["widget"]], { + refetchInterval: queryCacheDefaultRefetchIntervalMs, + refetchOnWindowFocus: false, + refetchOnReconnect: false, + }); + for (const { queryKey, intervalSeconds } of widgetQueryRefetchIntervals) { + client.setQueryDefaults(queryKey, { + refetchInterval: intervalSeconds === null ? false : intervalSeconds * 1000, + }); + } + for (const { queryKey, ...policy } of dashboardSupportingQueryPolicies) { + client.setQueryDefaults(queryKey, policy); } return client; }); - const [trpcClient] = useState(() => { + useEffect(() => () => queryClient.clear(), [queryClient]); + + const trpcClient = useMemo(() => { return clientApi.createClient({ links: [ loggerLink({ - enabled: (opts) => - env.NODE_ENV === "development" || (opts.direction === "down" && opts.result instanceof Error), + enabled: (opts) => opts.direction === "down" && opts.result instanceof Error, }), splitLink({ condition: ({ type }) => type === "subscription", @@ -147,25 +182,14 @@ export function TRPCReactProvider(props: PropsWithChildren) { }), ], }); - }); + }, [wsClient]); return ( - - query.state.status === "success" && isPersistableWidgetQueryKey(query.queryKey), - }, - }} - > - {props.children} - - + + {children} + {process.env.NODE_ENV === "development" && } + ); -} +}; diff --git a/apps/nextjs/src/app/[locale]/boards/(content)/(home)/_definition.ts b/apps/nextjs/src/app/[locale]/boards/(content)/(home)/_definition.ts index 2357d7424..d64fdb56e 100644 --- a/apps/nextjs/src/app/[locale]/boards/(content)/(home)/_definition.ts +++ b/apps/nextjs/src/app/[locale]/boards/(content)/(home)/_definition.ts @@ -1,9 +1,9 @@ -import { api } from "@homarr/api/server"; +import { getHomeBoardAsync } from "@homarr/api/board-server"; import { createBoardContentPage } from "../_creator"; export default createBoardContentPage<{ locale: string }>({ async getInitialBoardAsync() { - return await api.board.getHomeBoard(); + return await getHomeBoardAsync(); }, }); diff --git a/apps/nextjs/src/app/[locale]/boards/(content)/[name]/(board)/_definition.tsx b/apps/nextjs/src/app/[locale]/boards/(content)/[name]/(board)/_definition.tsx index d3b65a5e9..f0924bb6e 100644 --- a/apps/nextjs/src/app/[locale]/boards/(content)/[name]/(board)/_definition.tsx +++ b/apps/nextjs/src/app/[locale]/boards/(content)/[name]/(board)/_definition.tsx @@ -1,9 +1,9 @@ -import { api } from "@homarr/api/server"; +import { getBoardByNameAsync } from "@homarr/api/board-server"; import { createBoardContentPage } from "../../_creator"; export default createBoardContentPage<{ locale: string; name: string }>({ async getInitialBoardAsync({ name }) { - return await api.board.getBoardByName({ name }); + return await getBoardByNameAsync(name); }, }); diff --git a/apps/nextjs/src/app/[locale]/boards/(content)/_board-add-menu.tsx b/apps/nextjs/src/app/[locale]/boards/(content)/_board-add-menu.tsx new file mode 100644 index 000000000..3aaeb9131 --- /dev/null +++ b/apps/nextjs/src/app/[locale]/boards/(content)/_board-add-menu.tsx @@ -0,0 +1,86 @@ +"use client"; + +import { useCallback } from "react"; +import { Group, Menu } from "@mantine/core"; +import { IconBox, IconBoxAlignTop, IconChevronDown, IconPlug, IconPlus, IconResize } from "@tabler/icons-react"; + +import { useSession } from "@homarr/auth/client"; +import { useRequiredBoard } from "@homarr/boards/context"; +import { useModalAction } from "@homarr/modals"; +import { AppSelectModal } from "@homarr/modals-collection"; +import { useI18n } from "@homarr/translation/client"; + +import { useItemActions } from "~/components/board/items/item-actions"; +import { ItemSelectModal } from "~/components/board/items/item-select-modal"; +import { useCategoryActions } from "~/components/board/sections/category/category-actions"; +import { CategoryEditModal } from "~/components/board/sections/category/category-edit-modal"; +import { useDynamicSectionActions } from "~/components/board/sections/dynamic/dynamic-actions"; +import { IntegrationSelectModal } from "~/components/integration/integration-select-modal"; +import { HeaderButton } from "~/components/layout/header/button"; + +export const BoardAddMenu = () => { + const { data: session } = useSession(); + const board = useRequiredBoard(); + const { openModal: openCategoryEditModal } = useModalAction(CategoryEditModal); + const { openModal: openItemSelectModal } = useModalAction(ItemSelectModal); + const { openModal: openAppSelectModal } = useModalAction(AppSelectModal); + const { openModal: openIntegrationSelectModal } = useModalAction(IntegrationSelectModal); + const { addCategoryToEnd } = useCategoryActions(); + const { addDynamicSection } = useDynamicSectionActions(); + const { createItem } = useItemActions(); + const t = useI18n(); + + const handleAddCategory = useCallback( + () => + openCategoryEditModal( + { + category: { id: "new", name: "" }, + onSuccess({ name }) { + addCategoryToEnd({ name }); + }, + submitLabel: t("section.category.create.submit"), + }, + { title: (translate) => translate("section.category.create.title") }, + ), + [addCategoryToEnd, openCategoryEditModal, t], + ); + + return ( + + + + + + + + + + + } onClick={() => openItemSelectModal({ boardId: board.id })}> + {t("item.action.create")} + + } + onClick={() => + openAppSelectModal({ + onSelect: (app) => createItem({ kind: "app", options: { appId: app.id } }), + withCreate: session?.user.permissions.includes("app-create") ?? false, + }) + } + > + {t("app.action.add")} + + } onClick={() => openIntegrationSelectModal({})}> + {t("integration.action.create")} + + + } onClick={handleAddCategory}> + {t("section.category.action.create")} + + } onClick={addDynamicSection}> + {t("section.dynamic.action.create")} + + + + ); +}; diff --git a/apps/nextjs/src/app/[locale]/boards/(content)/_client.tsx b/apps/nextjs/src/app/[locale]/boards/(content)/_client.tsx index 0ba502a9a..1b4308d38 100644 --- a/apps/nextjs/src/app/[locale]/boards/(content)/_client.tsx +++ b/apps/nextjs/src/app/[locale]/boards/(content)/_client.tsx @@ -14,10 +14,10 @@ export const ClientBoard = () => { const fullWidthSortedSections = board.sections .filter((section) => section.kind === "empty" || section.kind === "category") - .sort((sectionA, sectionB) => sectionA.yOffset - sectionB.yOffset); + .toSorted((sectionA, sectionB) => sectionA.yOffset - sectionB.yOffset); return ( - + {fullWidthSortedSections.map((section) => diff --git a/apps/nextjs/src/app/[locale]/boards/(content)/_creator.tsx b/apps/nextjs/src/app/[locale]/boards/(content)/_creator.tsx index 16be5c478..2bb868f7d 100644 --- a/apps/nextjs/src/app/[locale]/boards/(content)/_creator.tsx +++ b/apps/nextjs/src/app/[locale]/boards/(content)/_creator.tsx @@ -1,15 +1,12 @@ import type { Metadata } from "next"; -import { Suspense } from "react"; +import { cache } from "react"; import { TRPCError } from "@trpc/server"; // Placed here because gridstack styles are used for board content import "~/styles/gridstack.scss"; import { dehydrate, HydrationBoundary } from "@tanstack/react-query"; -import type { PersistedClient } from "@tanstack/react-query-persist-client"; -import superjson from "superjson"; -import { queryCacheBuster } from "@homarr/api/query-cache"; -import { getQueryClient } from "@homarr/api/server"; +import { makeQueryClient } from "@homarr/api/shared"; import { IntegrationProvider } from "@homarr/auth/client"; import { auth } from "@homarr/auth/next"; import { getIntegrationsWithPermissionsAsync } from "@homarr/auth/server"; @@ -17,7 +14,6 @@ import { isNullOrWhitespace } from "@homarr/common"; import { createLogger } from "@homarr/core/infrastructure/logs"; import { ErrorWithMetadata } from "@homarr/core/infrastructure/logs/error"; import type { WidgetKind } from "@homarr/definitions"; -import { getQueryCacheAsync } from "@homarr/redis"; import { getI18n } from "@homarr/translation/server"; import { prefetchForKindAsync } from "@homarr/widgets/prefetch"; @@ -29,6 +25,7 @@ import { DynamicClientBoard } from "./_dynamic-client"; import { BoardContentHeaderActions } from "./_header-actions"; const logger = createLogger({ module: "createBoardContentPage" }); +const getQueryClient = cache(makeQueryClient); export type Params = Record; @@ -61,7 +58,6 @@ export const createBoardContentPage = >( } return acc; }, new Map()); - const [integrations] = await Promise.all([ getIntegrationsWithPermissionsAsync(session), ...Array.from(itemsMap).map(([kind, items]) => @@ -77,19 +73,12 @@ export const createBoardContentPage = >( ), ]); - const userId = session?.user.id ?? "anonymous"; - return ( - <> - - - - - - - - - + + + + + ); }, generateMetadataAsync: async ({ params }: { params: Promise }): Promise => { @@ -120,19 +109,3 @@ export const createBoardContentPage = >( }, }; }; - -async function QueryCacheHydration({ userId, boardId }: { userId: string; boardId: string }) { - try { - const serialized = await getQueryCacheAsync(userId, boardId); - if (!serialized) return null; - - const persisted = superjson.parse(serialized); - if (!persisted || persisted.buster !== queryCacheBuster) return null; - if (!persisted.clientState?.queries?.length) return null; - - return ; - } catch (error) { - logger.warn(new ErrorWithMetadata("Failed to hydrate query cache", { userId, boardId }, { cause: error })); - return null; - } -} diff --git a/apps/nextjs/src/app/[locale]/boards/(content)/_dynamic-client.tsx b/apps/nextjs/src/app/[locale]/boards/(content)/_dynamic-client.tsx index 50b0aff66..44d0a77c6 100644 --- a/apps/nextjs/src/app/[locale]/boards/(content)/_dynamic-client.tsx +++ b/apps/nextjs/src/app/[locale]/boards/(content)/_dynamic-client.tsx @@ -1,7 +1,24 @@ "use client"; import dynamic from "next/dynamic"; +import { SimpleGrid, Skeleton, Stack } from "@mantine/core"; + +const BoardLoadingShell = () => ( + + + {[2, 1, 2, 2, 1, 2].map((rows, index) => ( + + ))} + + +); export const DynamicClientBoard = dynamic(() => import("./_client").then((mod) => mod.ClientBoard), { ssr: false, + loading: BoardLoadingShell, }); diff --git a/apps/nextjs/src/app/[locale]/boards/(content)/_header-actions.tsx b/apps/nextjs/src/app/[locale]/boards/(content)/_header-actions.tsx index 472424556..fa21341d6 100644 --- a/apps/nextjs/src/app/[locale]/boards/(content)/_header-actions.tsx +++ b/apps/nextjs/src/app/[locale]/boards/(content)/_header-actions.tsx @@ -1,45 +1,36 @@ "use client"; -import { useCallback, useEffect, useRef } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; +import dynamic from "next/dynamic"; import { useRouter } from "next/navigation"; -import { OnboardingTour } from "@gfazioli/mantine-onboarding-tour"; -import { Box, Group, Menu, ScrollArea } from "@mantine/core"; +import { Box, Center, Loader, Menu, ScrollArea } from "@mantine/core"; import { useHotkeys } from "@mantine/hooks"; -import { - IconBox, - IconBoxAlignTop, - IconChevronDown, - IconLayoutBoard, - IconPencil, - IconPencilOff, - IconPlug, - IconPlus, - IconReplace, - IconResize, - IconSettings, -} from "@tabler/icons-react"; +import { IconLayoutBoard, IconPencil, IconPencilOff, IconPlus, IconReplace, IconSettings } from "@tabler/icons-react"; import { clientApi } from "@homarr/api/client"; -import { useSession } from "@homarr/auth/client"; import { useRequiredBoard } from "@homarr/boards/context"; import { useEditMode } from "@homarr/boards/edit-mode"; import { revalidatePathActionAsync } from "@homarr/common/client"; import { env } from "@homarr/common/env"; import { hotkeys } from "@homarr/definitions"; -import { useConfirmModal, useModalAction } from "@homarr/modals"; -import { AppSelectModal } from "@homarr/modals-collection"; +import { useConfirmModal } from "@homarr/modals"; import { showErrorNotification, showSuccessNotification } from "@homarr/notifications"; import { useI18n, useScopedI18n } from "@homarr/translation/client"; import { Link } from "@homarr/ui"; -import { useItemActions } from "~/components/board/items/item-actions"; -import { ItemSelectModal } from "~/components/board/items/item-select-modal"; import { useBoardPermissions } from "~/components/board/permissions/client"; -import { useCategoryActions } from "~/components/board/sections/category/category-actions"; -import { CategoryEditModal } from "~/components/board/sections/category/category-edit-modal"; -import { useDynamicSectionActions } from "~/components/board/sections/dynamic/dynamic-actions"; -import { IntegrationSelectModal } from "~/components/integration/integration-select-modal"; import { HeaderButton } from "~/components/layout/header/button"; +import { TourTarget } from "~/components/layout/header/tour-target"; + +const loadBoardAddMenu = () => import("./_board-add-menu"); +const preloadBoardAddMenu = () => void loadBoardAddMenu().catch(() => undefined); +const BoardAddMenu = dynamic(() => loadBoardAddMenu().then(({ BoardAddMenu: AddMenu }) => AddMenu), { + loading: () => ( + + + + ), +}); export const BoardContentHeaderActions = ({ demoReadOnly }: { demoReadOnly: boolean }) => { const [isEditMode] = useEditMode(); @@ -52,16 +43,16 @@ export const BoardContentHeaderActions = ({ demoReadOnly }: { demoReadOnly: bool return ( <> - {isEditMode && } + {isEditMode && } {!demoReadOnly && ( - + - + )} @@ -69,95 +60,6 @@ export const BoardContentHeaderActions = ({ demoReadOnly }: { demoReadOnly: bool ); }; -const AddMenu = () => { - const board = useRequiredBoard(); - const { data: session } = useSession(); - const { openModal: openCategoryEditModal } = useModalAction(CategoryEditModal); - const { openModal: openItemSelectModal } = useModalAction(ItemSelectModal); - const { openModal: openAppSelectModal } = useModalAction(AppSelectModal); - const { openModal: openIntegrationSelectModal } = useModalAction(IntegrationSelectModal); - const { addCategoryToEnd } = useCategoryActions(); - const { addDynamicSection } = useDynamicSectionActions(); - const { createItem } = useItemActions(); - const t = useI18n(); - - const handleAddCategory = useCallback( - () => - openCategoryEditModal( - { - category: { - id: "new", - name: "", - }, - onSuccess({ name }) { - addCategoryToEnd({ name }); - }, - submitLabel: t("section.category.create.submit"), - }, - { - title: (t) => t("section.category.create.title"), - }, - ), - [addCategoryToEnd, openCategoryEditModal, t], - ); - - const handleSelectItem = useCallback(() => { - openItemSelectModal({ boardId: board.id }); - }, [board.id, openItemSelectModal]); - - const handleSelectApp = useCallback(() => { - openAppSelectModal({ - onSelect: (app) => { - createItem({ - kind: "app", - options: { appId: app.id }, - }); - }, - withCreate: session?.user.permissions.includes("app-create") ?? false, - }); - }, [openAppSelectModal, createItem]); - - const handleAddIntegration = useCallback(() => { - openIntegrationSelectModal({}); - }, [openIntegrationSelectModal]); - - return ( - - - - - - - - - - - } onClick={handleSelectItem}> - {t("item.action.create")} - - - } onClick={handleSelectApp}> - {t("app.action.add")} - - - } onClick={handleAddIntegration}> - {t("integration.action.create")} - - - - - } onClick={handleAddCategory}> - {t("section.category.action.create")} - - - } onClick={addDynamicSection}> - {t("section.dynamic.action.create")} - - - - ); -}; - const EditModeMenu = ({ demoReadOnly }: { demoReadOnly: boolean }) => { const [isEditMode, { open, close }] = useEditMode(); const board = useRequiredBoard(); @@ -204,25 +106,30 @@ const EditModeMenu = ({ demoReadOnly }: { demoReadOnly: boolean }) => { usePreventLeaveWithDirty(isEditMode); return ( - + {isEditMode ? : } - + ); }; const SelectBoardsMenu = () => { - const { data: boards = [] } = clientApi.board.getAllBoards.useQuery(); + const [isOpen, setIsOpen] = useState(false); + const utils = clientApi.useUtils(); + const { data: boards = [], isPending } = clientApi.board.getAllBoards.useQuery(undefined, { enabled: isOpen }); + const preloadBoards = () => void utils.board.getAllBoards.prefetch(); return ( - - - + + + @@ -230,6 +137,11 @@ const SelectBoardsMenu = () => { + {isPending && ( +
+ +
+ )} {boards.map((board) => ( {
- +
); }; @@ -257,15 +169,13 @@ const usePreventLeaveWithDirty = (isDirty: boolean) => { useEffect(() => { if (!isDirty) return; - const handleClick = (event: Event) => { - const target = (event.target as HTMLElement).closest("a"); - - if (!target) { - console.warn("No anchor element found for click event", event); - return; - } + const handleClick = (event: MouseEvent) => { + if (event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return; + const target = event.target instanceof Element ? event.target.closest(anchorSelector) : null; + if (!target) return; event.preventDefault(); + event.stopPropagation(); openConfirmModal({ title: t("board.action.edit.confirmLeave.title"), @@ -291,19 +201,14 @@ const usePreventLeaveWithDirty = (isDirty: boolean) => { event.returnValue = true; }; - const anchors = document.querySelectorAll(anchorSelector); - anchors.forEach((link) => { - link.addEventListener("click", handleClick); - }); + document.addEventListener("click", handleClick, true); window.addEventListener("popstate", handlePopState); window.addEventListener("beforeunload", handleBeforeUnload); return () => { - anchors.forEach((link) => { - link.removeEventListener("click", handleClick); - }); + document.removeEventListener("click", handleClick, true); window.removeEventListener("popstate", handlePopState); window.removeEventListener("beforeunload", handleBeforeUnload); }; - }, [isDirty]); + }, [isDirty, openConfirmModal, router, t]); }; diff --git a/apps/nextjs/src/app/[locale]/boards/[name]/layout.tsx b/apps/nextjs/src/app/[locale]/boards/[name]/layout.tsx index 1f129e4c1..0c5fd10e0 100644 --- a/apps/nextjs/src/app/[locale]/boards/[name]/layout.tsx +++ b/apps/nextjs/src/app/[locale]/boards/[name]/layout.tsx @@ -1,4 +1,4 @@ -import { api } from "@homarr/api/server"; +import { getBoardByNameAsync } from "@homarr/api/board-server"; import { BoardOtherHeaderActions } from "../_header-actions"; import { createBoardLayout } from "../_layout-creator"; @@ -6,6 +6,6 @@ import { createBoardLayout } from "../_layout-creator"; export default createBoardLayout<{ locale: string; name: string }>({ headerActions: , async getInitialBoardAsync({ name }) { - return await api.board.getBoardByName({ name }); + return await getBoardByNameAsync(name); }, }); 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 42e64a3ab..2ad05962c 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,65 +8,44 @@ import "~/styles/prismjs.scss"; import { IconInfoCircle } from "@tabler/icons-react"; -import { useForm } from "@homarr/form"; import { useSession } from "@homarr/auth/client"; +import type { UseFormReturnType } from "@homarr/form"; import { useI18n, useScopedI18n } from "@homarr/translation/client"; +import { SectionCard } from "~/components/manage/section-card"; import { WorkshopCssImportButton } from "~/components/workshop/workshop-css-import-button"; - -import type { Board } from "../../_types"; -import { useSavePartialSettingsMutation } from "./_shared"; +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 { data: session } = useSession(); const isAdmin = session?.user.permissions.includes("admin") ?? false; 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")} - - - - {isAdmin ? form.setFieldValue("customCss", css)} /> : } - - - -
+ }> + {customCssT("customClassesAlert.description")} + + {isAdmin && form.setFieldValue("customCss", css)} />} + ); }; 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 ( @@ -76,6 +55,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]/boards/_layout-creator.tsx b/apps/nextjs/src/app/[locale]/boards/_layout-creator.tsx index 711596e65..d7fad3752 100644 --- a/apps/nextjs/src/app/[locale]/boards/_layout-creator.tsx +++ b/apps/nextjs/src/app/[locale]/boards/_layout-creator.tsx @@ -3,6 +3,7 @@ import { notFound, redirect } from "next/navigation"; import { AppShellMain } from "@mantine/core"; import { TRPCError } from "@trpc/server"; +import { getRscUserSettingsAsync } from "@homarr/api/user-server"; import { auth } from "@homarr/auth/next"; import { BoardProvider } from "@homarr/boards/context"; import { EditModeProvider } from "@homarr/boards/edit-mode"; @@ -12,7 +13,8 @@ import { ModalProvider } from "@homarr/modals"; import { MainHeader } from "~/components/layout/header"; import { BoardLogoWithTitle } from "~/components/layout/logo/board-logo"; import { ClientShell } from "~/components/layout/shell"; -import { BoardTourProvider } from "~/components/onboarding/board-tour"; +import { BoardTourGate } from "~/components/onboarding/board-tour-gate"; +import { env } from "~/env"; import { getCurrentColorSchemeAsync } from "~/theme/color-scheme"; import type { Board } from "./_types"; import type { Params } from "./(content)/_creator"; @@ -22,11 +24,6 @@ import { BoardMantineProvider } from "./(content)/_theme"; const logger = createLogger({ module: "createBoardLayout" }); -const BoardTourWrapper = ({ hasSession, children }: PropsWithChildren<{ hasSession: boolean }>) => { - if (!hasSession) return <>{children}; - return {children}; -}; - interface CreateBoardLayoutProps { headerActions: JSX.Element; getInitialBoardAsync: (params: TParams) => Promise; @@ -44,8 +41,32 @@ export const createBoardLayout = ({ }: PropsWithChildren<{ params: Promise; }>) => { - const session = await auth(); - const initialBoard = await getInitialBoard(await params).catch((error) => { + const resolvedParams = await params; + const sessionPromise = auth(); + const initialBoardPromise = getInitialBoard(resolvedParams).then( + (board) => ({ status: "fulfilled", board }) as const, + (error: unknown) => ({ status: "rejected", error }) as const, + ); + const colorSchemePromise = getCurrentColorSchemeAsync(); + const shouldRunBoardTourPromise = sessionPromise.then(async (session) => { + if (!withTour || !session || env.DEMO_MODE) return false; + + try { + const user = await getRscUserSettingsAsync(session.user.id); + return user !== undefined && !user.completedBoardTour; + } catch (error) { + logger.error(new Error("Failed to load the board tour status", { cause: error })); + return false; + } + }); + const [session, initialBoardResult, colorScheme, shouldRunBoardTour] = await Promise.all([ + sessionPromise, + initialBoardPromise, + colorSchemePromise, + shouldRunBoardTourPromise, + ]); + if (initialBoardResult.status === "rejected") { + const { error } = initialBoardResult; if (error instanceof TRPCError && error.code === "NOT_FOUND") { if (!session) { logger.debug("No home board found for anonymous user, redirecting to login"); @@ -61,8 +82,8 @@ export const createBoardLayout = ({ } throw error; - }); - const colorScheme = await getCurrentColorSchemeAsync(); + } + const initialBoard = initialBoardResult.board; return ( @@ -71,7 +92,7 @@ export const createBoardLayout = ({ - + } @@ -80,7 +101,7 @@ export const createBoardLayout = ({ /> {children} - + diff --git a/apps/nextjs/src/app/[locale]/init/page.tsx b/apps/nextjs/src/app/[locale]/init/page.tsx index 212c74e73..0013c3170 100644 --- a/apps/nextjs/src/app/[locale]/init/page.tsx +++ b/apps/nextjs/src/app/[locale]/init/page.tsx @@ -1,8 +1,8 @@ import type { JSX } from "react"; import { Box, Center, Stack, Text, Title } from "@mantine/core"; -import { api } from "@homarr/api/server"; import type { MaybePromise } from "@homarr/common/types"; +import { db } from "@homarr/db"; import type { OnboardingStep } from "@homarr/definitions"; import { getScopedI18n } from "@homarr/translation/server"; @@ -29,9 +29,16 @@ const stepComponents: Record MaybePromise { + 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() { const t = await getScopedI18n("init.step"); - const currentStep = await api.onboard.currentStep(); + const currentStep = await getCurrentOnboardingStepAsync(); const CurrentComponent = stepComponents[currentStep.current]; diff --git a/apps/nextjs/src/app/[locale]/layout.tsx b/apps/nextjs/src/app/[locale]/layout.tsx index d394d9c31..7be6cafa8 100644 --- a/apps/nextjs/src/app/[locale]/layout.tsx +++ b/apps/nextjs/src/app/[locale]/layout.tsx @@ -13,11 +13,11 @@ import { notFound } from "next/navigation"; import type { DayOfWeek } from "@mantine/dates"; import { NextIntlClientProvider } from "next-intl"; -import { api } from "@homarr/api/server"; import { env as authEnv } from "@homarr/auth/env"; +import { getRscServerSettingsAsync } from "@homarr/api/server-settings-server"; +import { getRscUserSettingsAsync } from "@homarr/api/user-server"; import { auth } from "@homarr/auth/next"; -import { db } from "@homarr/db"; -import { getServerSettingsAsync } from "@homarr/db/queries"; +import { createLogger } from "@homarr/core/infrastructure/logs"; import { ModalProvider } from "@homarr/modals"; import { Notifications } from "@homarr/notifications"; import { SettingsProvider } from "@homarr/settings"; @@ -45,6 +45,8 @@ const fontSans = Inter({ variable: "--font-sans", }); +const logger = createLogger({ module: "rootLayout" }); + // eslint-disable-next-line no-restricted-syntax export const generateMetadata = async (): Promise => ({ title: "Homarr", @@ -80,15 +82,27 @@ export default async function Layout(props: { children: React.ReactNode; params: Promise<{ locale: SupportedLanguage }>; }) { - if (!isLocaleSupported((await props.params).locale)) { + const { locale } = await props.params; + if (!isLocaleSupported(locale)) { notFound(); } - const session = await auth(); - const user = session ? await api.user.getById({ userId: session.user.id }).catch(() => null) : null; - const serverSettings = await getServerSettingsAsync(db); - const colorScheme = await getCurrentColorSchemeAsync(); - const direction = isLocaleRTL((await props.params).locale) ? "rtl" : "ltr"; + const sessionPromise = auth(); + const userPromise = sessionPromise.then((session) => + session + ? getRscUserSettingsAsync(session.user.id).catch((error: unknown) => { + logger.error(new Error("Failed to load the authenticated user in the root layout", { cause: error })); + return null; + }) + : null, + ); + const [session, user, serverSettings, colorScheme] = await Promise.all([ + sessionPromise, + userPromise, + getRscServerSettingsAsync(), + getCurrentColorSchemeAsync(), + ]); + const direction = isLocaleRTL(locale) ? "rtl" : "ltr"; const publicUrls = resolveHomarrUrlConfig({ homarrWebsiteUrl: env.HOMARR_WEBSITE_URL, workshopApiUrl: env.WORKSHOP_API_URL, @@ -132,8 +146,6 @@ export default async function Layout(props: { (innerProps) => , ]); - const { locale } = await props.params; - return ( // Instead of ColorSchemScript we use data-mantine-color-scheme to prevent flickering { + 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[] = [ { label: t("items.home"), @@ -62,7 +82,7 @@ export default async function ManageLayout({ children }: PropsWithChildren) { icon: IconBox, href: "/manage/apps", label: t("items.apps"), - hidden: !session, + hidden: !session?.user.permissions.includes("app-create"), iconProps: { strokeWidth: 2.5, }, @@ -72,7 +92,7 @@ export default async function ManageLayout({ children }: PropsWithChildren) { icon: IconAffiliateFilled, href: "/manage/integrations", label: t("items.integrations"), - hidden: !session, + hidden: !session?.user.permissions.includes("integration-create"), "data-onboarding-tour-id": "manage-integrations", }, { @@ -91,7 +111,7 @@ export default async function ManageLayout({ children }: PropsWithChildren) { icon: IconSearch, href: "/manage/search-engines", label: t("items.searchEngies"), - hidden: !session, + hidden: !session?.user.permissions.includes("search-engine-create"), iconProps: { strokeWidth: 2.5, }, @@ -101,7 +121,7 @@ export default async function ManageLayout({ children }: PropsWithChildren) { icon: IconPhotoFilled, href: "/manage/medias", label: t("items.medias"), - hidden: !session, + hidden: !session?.user.permissions.includes("media-upload"), "data-onboarding-tour-id": "manage-medias", }, { @@ -232,7 +252,9 @@ export default async function ManageLayout({ children }: PropsWithChildren) { ); - if (!session) return shell; - - return {shell}; + return ( + + {shell} + + ); } diff --git a/apps/nextjs/src/app/[locale]/manage/settings/_components/analytics.settings.tsx b/apps/nextjs/src/app/[locale]/manage/settings/_components/analytics.settings.tsx index 9ad50b432..b68e28e90 100644 --- a/apps/nextjs/src/app/[locale]/manage/settings/_components/analytics.settings.tsx +++ b/apps/nextjs/src/app/[locale]/manage/settings/_components/analytics.settings.tsx @@ -1,53 +1,22 @@ "use client"; -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 type { UseFormReturnType } from "@homarr/form"; import { useScopedI18n } from "@homarr/translation/client"; -import { SwitchSetting } from "~/app/[locale]/manage/settings/_components/setting-switch"; +import { SectionCard } from "~/components/manage/section-card"; +import type { FormValues } from "./settings-form"; +import { SwitchSetting } from "./setting-switch"; interface AnalyticsSettingsProps { - initialData: typeof defaultServerSettings.analytics; + form: UseFormReturnType; } -export const AnalyticsSettings = ({ initialData }: AnalyticsSettingsProps) => { +export const AnalyticsSettings = ({ form }: AnalyticsSettingsProps) => { 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 ( - <> - {t("title")} - - - - - - - - + + + ); }; diff --git a/apps/nextjs/src/app/[locale]/manage/settings/_components/appearance-settings-form.tsx b/apps/nextjs/src/app/[locale]/manage/settings/_components/appearance-settings-form.tsx index 900caf0e4..e33afe1b6 100644 --- a/apps/nextjs/src/app/[locale]/manage/settings/_components/appearance-settings-form.tsx +++ b/apps/nextjs/src/app/[locale]/manage/settings/_components/appearance-settings-form.tsx @@ -5,31 +5,32 @@ import { IconDeviceDesktop, IconMoon, IconSun } from "@tabler/icons-react"; import type { ColorScheme } from "@homarr/definitions"; import { colorSchemes } from "@homarr/definitions"; -import type { ServerSettings } from "@homarr/server-settings"; +import type { UseFormReturnType } from "@homarr/form"; import { useScopedI18n } from "@homarr/translation/client"; import { SelectWithCustomItems } from "@homarr/ui"; -import { CommonSettingsForm } from "./common-form"; +import { SectionCard } from "~/components/manage/section-card"; +import type { FormValues } from "./settings-form"; -export const AppearanceSettingsForm = ({ defaultValues }: { defaultValues: ServerSettings["appearance"] }) => { - const tApperance = useScopedI18n("management.page.settings.section.appearance"); +interface AppearanceSettingsFormProps { + form: UseFormReturnType; +} + +export const AppearanceSettingsForm = ({ form }: AppearanceSettingsFormProps) => { + const tAppearance = useScopedI18n("management.page.settings.section.appearance"); return ( - - {(form) => ( - <> - ({ - value: scheme, - label: tApperance(`defaultColorScheme.options.${scheme}`), - }))} - {...form.getInputProps("defaultColorScheme")} - SelectOption={ApperanceCustomOption} - /> - - )} - + + ({ + value: scheme, + label: tAppearance(`defaultColorScheme.options.${scheme}`), + }))} + {...form.getInputProps("defaultColorScheme")} + SelectOption={AppearanceCustomOption} + /> + ); }; @@ -39,7 +40,7 @@ const appearanceIcons = { dark: IconMoon, }; -const ApperanceCustomOption = ({ value, label }: { value: ColorScheme; label: string }) => { +const AppearanceCustomOption = ({ value, label }: { value: ColorScheme; label: string }) => { const Icon = appearanceIcons[value]; return ( diff --git a/apps/nextjs/src/app/[locale]/manage/settings/_components/board-settings-form.tsx b/apps/nextjs/src/app/[locale]/manage/settings/_components/board-settings-form.tsx index 615c6ba38..801e0df9f 100644 --- a/apps/nextjs/src/app/[locale]/manage/settings/_components/board-settings-form.tsx +++ b/apps/nextjs/src/app/[locale]/manage/settings/_components/board-settings-form.tsx @@ -3,49 +3,50 @@ import { Switch, Text } from "@mantine/core"; import { clientApi } from "@homarr/api/client"; -import type { ServerSettings } from "@homarr/server-settings"; +import type { UseFormReturnType } from "@homarr/form"; import { useScopedI18n } from "@homarr/translation/client"; import { BoardSelect } from "~/components/board/board-select"; -import { CommonSettingsForm } from "./common-form"; +import { SectionCard } from "~/components/manage/section-card"; +import type { FormValues } from "./settings-form"; -export const BoardSettingsForm = ({ defaultValues }: { defaultValues: ServerSettings["board"] }) => { +interface BoardSettingsFormProps { + form: UseFormReturnType; +} + +export const BoardSettingsForm = ({ form }: BoardSettingsFormProps) => { const tBoard = useScopedI18n("management.page.settings.section.board"); const [selectableBoards] = clientApi.board.getPublicBoards.useSuspenseQuery(); return ( - - {(form) => ( - <> - + + - + - {tBoard("status.title")} - - - - )} - + {tBoard("status.title")} + + + ); }; diff --git a/apps/nextjs/src/app/[locale]/manage/settings/_components/common-form.tsx b/apps/nextjs/src/app/[locale]/manage/settings/_components/common-form.tsx deleted file mode 100644 index 8a932dee1..000000000 --- a/apps/nextjs/src/app/[locale]/manage/settings/_components/common-form.tsx +++ /dev/null @@ -1,57 +0,0 @@ -"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 = ({ - settingKey, - defaultValues, - children, -}: { - settingKey: TKey; - defaultValues: ServerSettings[TKey]; - children: (form: ReturnType>) => 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 ( -
void handleSubmitAsync(values))}> - - {children(form)} - - - - -
- ); -}; diff --git a/apps/nextjs/src/app/[locale]/manage/settings/_components/crawling-and-indexing.settings.tsx b/apps/nextjs/src/app/[locale]/manage/settings/_components/crawling-and-indexing.settings.tsx index 29206a473..15852c274 100644 --- a/apps/nextjs/src/app/[locale]/manage/settings/_components/crawling-and-indexing.settings.tsx +++ b/apps/nextjs/src/app/[locale]/manage/settings/_components/crawling-and-indexing.settings.tsx @@ -1,68 +1,35 @@ "use client"; -import React from "react"; -import { Card, LoadingOverlay, Stack, Text, Title } from "@mantine/core"; +import { Text } 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 type { UseFormReturnType } from "@homarr/form"; import { useScopedI18n } from "@homarr/translation/client"; -import { SwitchSetting } from "~/app/[locale]/manage/settings/_components/setting-switch"; +import { SectionCard } from "~/components/manage/section-card"; +import type { FormValues } from "./settings-form"; +import { SwitchSetting } from "./setting-switch"; interface CrawlingAndIndexingSettingsProps { - initialData: typeof defaultServerSettings.crawlingAndIndexing; + form: UseFormReturnType; } -export const CrawlingAndIndexingSettings = ({ initialData }: CrawlingAndIndexingSettingsProps) => { +export const CrawlingAndIndexingSettings = ({ form }: CrawlingAndIndexingSettingsProps) => { 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 ( - <> - {t("title")} - - - - {t("warning")} - - - - - - - - - - + + + {t("warning")} + + + + + + ); }; diff --git a/apps/nextjs/src/app/[locale]/manage/settings/_components/culture-settings-form.tsx b/apps/nextjs/src/app/[locale]/manage/settings/_components/culture-settings-form.tsx index 7d9dff99e..da34c7019 100644 --- a/apps/nextjs/src/app/[locale]/manage/settings/_components/culture-settings-form.tsx +++ b/apps/nextjs/src/app/[locale]/manage/settings/_components/culture-settings-form.tsx @@ -1,26 +1,28 @@ "use client"; -import type { ServerSettings } from "@homarr/server-settings"; +import type { UseFormReturnType } from "@homarr/form"; import type { SupportedLanguage } from "@homarr/translation"; import { useScopedI18n } from "@homarr/translation/client"; import { LanguageCombobox } from "~/components/language/language-combobox"; -import { CommonSettingsForm } from "./common-form"; +import { SectionCard } from "~/components/manage/section-card"; +import type { FormValues } from "./settings-form"; -export const CultureSettingsForm = ({ defaultValues }: { defaultValues: ServerSettings["culture"] }) => { +interface CultureSettingsFormProps { + form: UseFormReturnType; +} + +export const CultureSettingsForm = ({ form }: CultureSettingsFormProps) => { const tCulture = useScopedI18n("management.page.settings.section.culture"); + const localeInputProps = form.getInputProps("defaultLocale"); return ( - - {(form) => ( - <> - - - )} - + + + ); }; diff --git a/apps/nextjs/src/app/[locale]/manage/settings/_components/search-settings-form.tsx b/apps/nextjs/src/app/[locale]/manage/settings/_components/search-settings-form.tsx index a39cab70d..904dd4b08 100644 --- a/apps/nextjs/src/app/[locale]/manage/settings/_components/search-settings-form.tsx +++ b/apps/nextjs/src/app/[locale]/manage/settings/_components/search-settings-form.tsx @@ -3,27 +3,28 @@ import { Select } from "@mantine/core"; import { clientApi } from "@homarr/api/client"; -import type { ServerSettings } from "@homarr/server-settings"; +import type { UseFormReturnType } from "@homarr/form"; import { useScopedI18n } from "@homarr/translation/client"; -import { CommonSettingsForm } from "./common-form"; +import { SectionCard } from "~/components/manage/section-card"; +import type { FormValues } from "./settings-form"; -export const SearchSettingsForm = ({ defaultValues }: { defaultValues: ServerSettings["search"] }) => { +interface SearchSettingsFormProps { + form: UseFormReturnType; +} + +export const SearchSettingsForm = ({ form }: SearchSettingsFormProps) => { const tSearch = useScopedI18n("management.page.settings.section.search"); const [selectableSearchEngines] = clientApi.searchEngine.getSelectable.useSuspenseQuery({ withIntegrations: false }); return ( - - {(form) => ( - <> - + ); }; diff --git a/apps/nextjs/src/app/[locale]/manage/settings/_components/setting-switch.tsx b/apps/nextjs/src/app/[locale]/manage/settings/_components/setting-switch.tsx index adec086ad..4e1194d42 100644 --- a/apps/nextjs/src/app/[locale]/manage/settings/_components/setting-switch.tsx +++ b/apps/nextjs/src/app/[locale]/manage/settings/_components/setting-switch.tsx @@ -3,10 +3,13 @@ import React from "react"; import type { MantineSpacing } 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"; -export const SwitchSetting = >({ +type BooleanKeys = { + [TKey in keyof TFormValue]: TFormValue[TKey] extends boolean ? TKey : never; +}[keyof TFormValue]; + +export const SwitchSetting = >({ form, ms, title, @@ -14,13 +17,8 @@ export const SwitchSetting = >({ formKey, disabled, }: { - form: Modify< - UseFormReturnType, - { - setFieldValue: (key: keyof TFormValue, value: (previous: boolean) => boolean) => void; - } - >; - formKey: keyof TFormValue; + form: UseFormReturnType; + formKey: string & BooleanKeys; ms?: MantineSpacing; title: string; text: ReactNode; @@ -31,7 +29,7 @@ export const SwitchSetting = >({ return; } - form.setFieldValue(formKey, (previous) => !previous); + form.setFieldValue(formKey, (previous) => !previous as never); }, [form, formKey, disabled]); return ( @@ -44,7 +42,7 @@ export const SwitchSetting = >({ - + ); }; diff --git a/apps/nextjs/src/app/[locale]/manage/settings/_components/settings-form.tsx b/apps/nextjs/src/app/[locale]/manage/settings/_components/settings-form.tsx new file mode 100644 index 000000000..ce64a3404 --- /dev/null +++ b/apps/nextjs/src/app/[locale]/manage/settings/_components/settings-form.tsx @@ -0,0 +1,193 @@ +"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; + +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 = (...fields: TKey[]) => + fields.some((field) => values[field] !== defaults[field]); + + const groups: { + settingsKey: (typeof defaultServerSettingsKeys)[number]; + when: boolean; + value: Record; + }[] = [ + { 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 ( +
void handleSubmitAsync(values))}> + + + + + + + + + + {form.isDirty() && ( + + + + + )} + +
+ ); +}; diff --git a/apps/nextjs/src/app/[locale]/manage/settings/_components/user-settings-form.tsx b/apps/nextjs/src/app/[locale]/manage/settings/_components/user-settings-form.tsx index 49b8f7219..41abb2882 100644 --- a/apps/nextjs/src/app/[locale]/manage/settings/_components/user-settings-form.tsx +++ b/apps/nextjs/src/app/[locale]/manage/settings/_components/user-settings-form.tsx @@ -2,25 +2,26 @@ import { Switch } from "@mantine/core"; -import type { ServerSettings } from "@homarr/server-settings"; +import type { UseFormReturnType } from "@homarr/form"; import { useScopedI18n } from "@homarr/translation/client"; -import { CommonSettingsForm } from "./common-form"; +import { SectionCard } from "~/components/manage/section-card"; +import type { FormValues } from "./settings-form"; -export const UserSettingsForm = ({ defaultValues }: { defaultValues: ServerSettings["user"] }) => { +interface UserSettingsFormProps { + form: UseFormReturnType; +} + +export const UserSettingsForm = ({ form }: UserSettingsFormProps) => { const tUser = useScopedI18n("management.page.settings.section.user"); return ( - - {(form) => ( - <> - - - )} - + + + ); }; diff --git a/apps/nextjs/src/app/[locale]/manage/settings/loading.tsx b/apps/nextjs/src/app/[locale]/manage/settings/loading.tsx new file mode 100644 index 000000000..097406c3d --- /dev/null +++ b/apps/nextjs/src/app/[locale]/manage/settings/loading.tsx @@ -0,0 +1,9 @@ +import { Center, Loader } from "@mantine/core"; + +export default function SettingsLoading() { + return ( +
+ +
+ ); +} diff --git a/apps/nextjs/src/app/[locale]/manage/settings/page.tsx b/apps/nextjs/src/app/[locale]/manage/settings/page.tsx index 4a7ea9bc0..eee563069 100644 --- a/apps/nextjs/src/app/[locale]/manage/settings/page.tsx +++ b/apps/nextjs/src/app/[locale]/manage/settings/page.tsx @@ -5,14 +5,8 @@ import { api } from "@homarr/api/server"; import { auth } from "@homarr/auth/next"; 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 { 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"; +import { SettingsForm } from "./_components/settings-form"; export async function generateMetadata() { const t = await getScopedI18n("management"); @@ -32,33 +26,13 @@ export default async function SettingsPage() { const serverSettings = await api.serverSettings.getAll(); const tSettings = await getScopedI18n("management.page.settings"); + return ( <> {tSettings("title")} - - - - {tSettings("section.board.title")} - - - - {tSettings("section.user.title")} - - - - {tSettings("section.search.title")} - - - - {tSettings("section.appearance.title")} - - - - {tSettings("section.culture.title")} - - + ); diff --git a/apps/nextjs/src/app/[locale]/manage/users/[userId]/general/_components/_reset-tours.tsx b/apps/nextjs/src/app/[locale]/manage/users/[userId]/general/_components/_reset-tours.tsx index 76f33a8db..82e50d218 100644 --- a/apps/nextjs/src/app/[locale]/manage/users/[userId]/general/_components/_reset-tours.tsx +++ b/apps/nextjs/src/app/[locale]/manage/users/[userId]/general/_components/_reset-tours.tsx @@ -1,5 +1,6 @@ "use client"; +import { useRouter } from "next/navigation"; import { Badge, Button, Group, Stack, Text } from "@mantine/core"; import { IconRefresh } from "@tabler/icons-react"; @@ -9,6 +10,7 @@ import { useScopedI18n } from "@homarr/translation/client"; export const ResetTours = () => { const t = useScopedI18n("management.page.user.setting.general.item.onboardingTours"); + const router = useRouter(); const utils = clientApi.useUtils(); const { data: tourStatus } = clientApi.user.getTourStatus.useQuery(); const { mutate: resetTours, isPending } = clientApi.user.resetTours.useMutation({ @@ -18,6 +20,7 @@ export const ResetTours = () => { message: t("resetSuccess.message"), }); void utils.user.getTourStatus.invalidate(); + router.refresh(); }, }); diff --git a/apps/nextjs/src/app/[locale]/widgets/[kind]/_content.tsx b/apps/nextjs/src/app/[locale]/widgets/[kind]/_content.tsx index 9bbc0ac93..882842ee7 100644 --- a/apps/nextjs/src/app/[locale]/widgets/[kind]/_content.tsx +++ b/apps/nextjs/src/app/[locale]/widgets/[kind]/_content.tsx @@ -1,41 +1,38 @@ "use client"; -import { useCallback, useMemo, useState } from "react"; +import { use, useCallback, useState } from "react"; import { ActionIcon, Affix, Card } from "@mantine/core"; import { IconDimensions, IconPencil, IconToggleLeft, IconToggleRight } from "@tabler/icons-react"; import { QueryErrorResetBoundary } from "@tanstack/react-query"; import { ErrorBoundary } from "react-error-boundary"; -import type { IntegrationKind, WidgetKind } from "@homarr/definitions"; +import { clientApi } from "@homarr/api/client"; +import type { WidgetKind } from "@homarr/definitions"; import { useModalAction } from "@homarr/modals"; -import { showSuccessNotification } from "@homarr/notifications"; +import { showErrorNotification, showSuccessNotification } from "@homarr/notifications"; import { useSettings } from "@homarr/settings"; import { useI18n } from "@homarr/translation/client"; import type { BoardItemAdvancedOptions } from "@homarr/validation/shared"; -import { loadWidgetDynamic, reduceWidgetOptionsWithDefaultValues, widgetImports } from "@homarr/widgets"; import { WidgetError } from "@homarr/widgets/errors"; -import { WidgetEditModal } from "@homarr/widgets/modals"; +import { loadWidgetResources, reduceWidgetOptionsWithDefinition } from "@homarr/widgets/manifest"; +import { LazyWidgetEditModal, preloadWidgetEditModal } from "~/components/board/items/lazy-widget-edit-modal"; import type { Dimensions } from "./_dimension-modal"; import { PreviewDimensionsModal } from "./_dimension-modal"; interface WidgetPreviewPageContentProps { kind: WidgetKind; - integrationData: { - id: string; - name: string; - url: string; - kind: IntegrationKind; - }[]; } -export const WidgetPreviewPageContent = ({ kind, integrationData }: WidgetPreviewPageContentProps) => { +export const WidgetPreviewPageContent = ({ kind }: WidgetPreviewPageContentProps) => { const settings = useSettings(); const t = useI18n(); - const { openModal: openWidgetEditModal } = useModalAction(WidgetEditModal); + const utils = clientApi.useUtils(); + const { openModal: openWidgetEditModal } = useModalAction(LazyWidgetEditModal); const { openModal: openPreviewDimensionsModal } = useModalAction(PreviewDimensionsModal); - const currentDefinition = useMemo(() => widgetImports[kind].definition, [kind]); + const { definition: currentDefinition, Component } = use(loadWidgetResources(kind)); const [editMode, setEditMode] = useState(false); + const [isEditorLoading, setIsEditorLoading] = useState(false); const [dimensions, setDimensions] = useState({ width: 128, height: 128, @@ -45,7 +42,7 @@ export const WidgetPreviewPageContent = ({ kind, integrationData }: WidgetPrevie integrationIds: string[]; advancedOptions: BoardItemAdvancedOptions; }>({ - options: reduceWidgetOptionsWithDefaultValues(kind, settings, {}), + options: reduceWidgetOptionsWithDefinition(currentDefinition, settings, {}), integrationIds: [], advancedOptions: { title: null, @@ -54,31 +51,41 @@ export const WidgetPreviewPageContent = ({ kind, integrationData }: WidgetPrevie }, }); - const handleOpenEditWidgetModal = useCallback(() => { - openWidgetEditModal( - { - kind, - value: state, - onSuccessfulEdit: (value) => { - setState(value); + const handleOpenEditWidgetModal = useCallback(async () => { + const hasIntegrationSupport = "supportedIntegrations" in currentDefinition; + setIsEditorLoading(true); + preloadWidgetEditModal(); + try { + const integrationData = hasIntegrationSupport ? await utils.integration.all.ensureData() : []; + openWidgetEditModal( + { + kind, + definition: currentDefinition, + value: state, + onSuccessfulEdit: (value) => { + setState(value); + }, + integrationData: integrationData.filter((integration) => + (currentDefinition.supportedIntegrations ?? []).includes(integration.kind), + ), + integrationSupport: hasIntegrationSupport, + settings, }, - integrationData: integrationData.filter( - (integration) => - "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`)}`; + { + title(translate) { + return `${translate("item.edit.title")} - ${translate(`widget.${kind}.name`)}`; + }, }, - }, - ); - }, [currentDefinition, integrationData, kind, openWidgetEditModal, settings, state]); - - const Comp = loadWidgetDynamic(kind); + ); + } catch (error) { + showErrorNotification({ + title: t("common.error"), + message: error instanceof Error ? error.message : String(error), + }); + } finally { + setIsEditorLoading(false); + } + }, [currentDefinition, kind, openWidgetEditModal, settings, state, t, utils]); const toggleEditMode = useCallback(() => { setEditMode((editMode) => !editMode); @@ -94,8 +101,11 @@ export const WidgetPreviewPageContent = ({ kind, integrationData }: WidgetPrevie }); }, [dimensions, openPreviewDimensionsModal]); - const updateOptions = ({ newOptions }: { newOptions: Record }) => - setState({ ...state, options: { ...state.options, newOptions } }); + const updateOptions = useCallback( + ({ newOptions }: { newOptions: Record }) => + setState((current) => ({ ...current, options: { ...current.options, ...newOptions } })), + [], + ); return ( <> @@ -105,10 +115,10 @@ export const WidgetPreviewPageContent = ({ kind, integrationData }: WidgetPrevie ( - + )} > - void handleOpenEditWidgetModal()} + onFocus={preloadWidgetEditModal} + onPointerEnter={preloadWidgetEditModal} + loading={isEditorLoading} aria-label={t("common.action.edit")} > diff --git a/apps/nextjs/src/app/[locale]/widgets/[kind]/layout.tsx b/apps/nextjs/src/app/[locale]/widgets/[kind]/layout.tsx index cfd213f9d..9cdf91241 100644 --- a/apps/nextjs/src/app/[locale]/widgets/[kind]/layout.tsx +++ b/apps/nextjs/src/app/[locale]/widgets/[kind]/layout.tsx @@ -1,23 +1,18 @@ import type { PropsWithChildren } from "react"; -import { widgetImports } from "@homarr/widgets"; +import { widgetKinds } from "@homarr/definitions"; +import { widgetCatalogIcons } from "@homarr/widgets/catalog"; import { MainNavigation } from "~/components/layout/navigation"; import { ClientShell } from "~/components/layout/shell"; -const getLinks = () => { - return Object.entries(widgetImports).map(([key, value]) => { - return { - href: `/widgets/${key}`, - icon: value.definition.icon, - label: value.definition.kind, - }; - }); -}; +const links = widgetKinds.map((kind) => ({ + href: `/widgets/${kind}`, + icon: widgetCatalogIcons[kind], + label: kind, +})); export default function WidgetPreviewLayout({ children }: PropsWithChildren) { - const links = getLinks(); - return ( diff --git a/apps/nextjs/src/app/[locale]/widgets/[kind]/page.tsx b/apps/nextjs/src/app/[locale]/widgets/[kind]/page.tsx index fbcd741c2..454e7a805 100644 --- a/apps/nextjs/src/app/[locale]/widgets/[kind]/page.tsx +++ b/apps/nextjs/src/app/[locale]/widgets/[kind]/page.tsx @@ -1,10 +1,9 @@ +import { Suspense } from "react"; import { notFound } from "next/navigation"; -import { Center } from "@mantine/core"; +import { Center, Loader } from "@mantine/core"; -import { env } from "@homarr/common/env"; -import { db } from "@homarr/db"; import type { WidgetKind } from "@homarr/definitions"; -import { widgetImports } from "@homarr/widgets"; +import { widgetKinds } from "@homarr/widgets/manifest"; import { WidgetPreviewPageContent } from "./_content"; @@ -13,24 +12,16 @@ interface Props { } export default async function WidgetPreview(props: Props) { - if (!((await props.params).kind in widgetImports || env.NODE_ENV !== "development")) { + const { kind } = await props.params; + if (!widgetKinds.includes(kind as WidgetKind)) { notFound(); } - 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 (
- + }> + +
); } diff --git a/apps/nextjs/src/app/api/image-proxy/[id]/route.ts b/apps/nextjs/src/app/api/image-proxy/[id]/route.ts index 26eb70dd8..1235d54a4 100644 --- a/apps/nextjs/src/app/api/image-proxy/[id]/route.ts +++ b/apps/nextjs/src/app/api/image-proxy/[id]/route.ts @@ -1,19 +1,25 @@ -import { notFound } from "next/navigation"; - import { ImageProxy } from "@homarr/image-proxy"; export const GET = async (_request: Request, props: { params: Promise<{ id: string }> }) => { const { id } = await props.params; const imageProxy = new ImageProxy(); - const image = await imageProxy.forwardImageAsync(id); - if (!image) { - notFound(); + const result = await imageProxy.forwardImageAsync(id); + + if ("error" in result) { + const status = result.error === "upstream-error" ? result.statusCode : result.error === "fetch-error" ? 502 : 404; + return new Response(null, { + status, + headers: { + "X-Homarr-Image-Proxy-Error": result.error, + }, + }); } - return new Response(image, { + return new Response(result.image, { headers: { "Cache-Control": "public, max-age=3600, immutable", // Cache for 1 hour + ...(result.contentType ? { "Content-Type": result.contentType } : {}), }, }); }; diff --git a/apps/nextjs/src/components/board/items/item-content.tsx b/apps/nextjs/src/components/board/items/item-content.tsx index 66dd2838a..aaeb4161e 100644 --- a/apps/nextjs/src/components/board/items/item-content.tsx +++ b/apps/nextjs/src/components/board/items/item-content.tsx @@ -1,17 +1,21 @@ -import type { MutableRefObject } from "react"; -import { useRef } from "react"; -import { Badge, Card } from "@mantine/core"; +import type { ComponentType, MutableRefObject } from "react"; +import { Suspense, use, useRef } from "react"; +import type { CardProps } from "@mantine/core"; +import { Badge, Card, Center, Loader } from "@mantine/core"; import { useElementSize } from "@mantine/hooks"; import { QueryErrorResetBoundary, useQueryClient } from "@tanstack/react-query"; import combineClasses from "clsx"; import { NoIntegrationSelectedError } from "@homarr/widgets/errors/classes"; +import type { FallbackProps } from "react-error-boundary"; import { ErrorBoundary } from "react-error-boundary"; import { useRequiredBoard } from "@homarr/boards/context"; import { useEditMode } from "@homarr/boards/edit-mode"; import { useSettings } from "@homarr/settings"; -import { loadWidgetDynamic, reduceWidgetOptionsWithDefaultValues, widgetImports } from "@homarr/widgets"; import { WidgetError } from "@homarr/widgets/errors"; +import { getWidgetQueryKeys } from "@homarr/widgets/definition"; +import type { WidgetComponentProps, WidgetDefinition } from "@homarr/widgets/definition"; +import { loadWidgetResources, reduceWidgetOptionsWithDefinition } from "@homarr/widgets/manifest"; import type { SectionItem } from "~/app/[locale]/boards/_types"; import classes from "../sections/item.module.css"; @@ -19,6 +23,7 @@ import { useItemActions } from "./item-actions"; import itemContentClasses from "./item-content.module.css"; import { BoardItemMenu } from "./item-menu"; import { WidgetContextMenu } from "./widget-context-menu"; +import { removeWidgetDataQueries } from "./widget-query-recovery"; interface BoardItemContentProps { item: SectionItem; @@ -30,6 +35,53 @@ const getOverflowFromKind = (kind: SectionItem["kind"]) => { return undefined; }; +type BoardItemCardProps = CardProps & { + item: SectionItem; + innerRef: (element: HTMLDivElement | null) => void; +}; + +const BoardItemCard = ({ item, innerRef, children, ...cardProps }: BoardItemCardProps) => { + const board = useRequiredBoard(); + + return ( + + {children} + + ); +}; + +const WidgetDefinitionLoadError = ({ + error, + resetErrorBoundary, + item, + innerRef, +}: FallbackProps & Pick) => ( + +
+ +
+
+); + export const BoardItemContent = ({ item }: BoardItemContentProps) => { const { ref, width, height } = useElementSize(); const board = useRequiredBoard(); @@ -37,29 +89,28 @@ export const BoardItemContent = ({ item }: BoardItemContentProps) => { return ( <> - - } + > + +
+ +
+ + } > - -
-
+ + +
{item.advancedOptions.title?.trim() && ( { ); }; +interface LoadedBoardItemContentProps extends Omit { + innerRef: (element: HTMLDivElement | null) => void; +} + +const LoadedBoardItemContent = ({ item, innerRef, ...innerContentProps }: LoadedBoardItemContentProps) => { + const { definition, Component } = use(loadWidgetResources(item.kind)); + + return ( + + + ( + <> + + + + )} + > + + + + + ); +}; + interface InnerContentProps { item: SectionItem; width: number; height: number; widgetStateRef: MutableRefObject | null>; + definition: WidgetDefinition; + Component: ComponentType>; } -const InnerContent = ({ item, ...dimensions }: InnerContentProps) => { +const InnerContent = ({ item, definition, Component, ...dimensions }: InnerContentProps) => { const settings = useSettings(); const board = useRequiredBoard(); const [isEditMode] = useEditMode(); - const Comp = loadWidgetDynamic(item.kind); - const { definition } = widgetImports[item.kind]; - const options = reduceWidgetOptionsWithDefaultValues(item.kind, settings, item.options); + const options = reduceWidgetOptionsWithDefinition(definition, settings, item.options); const newItem = { ...item, options }; const { removeItem, updateItemOptions } = useItemActions(); const updateOptions = ({ newOptions }: { newOptions: Record }) => updateItemOptions({ itemId: item.id, newOptions }); const widgetSupportsIntegrations = - "supportedIntegrations" in definition && definition.supportedIntegrations.length >= 1; + "supportedIntegrations" in definition && (definition.supportedIntegrations?.length ?? 0) >= 1; const queryClient = useQueryClient(); + const widgetQueryKeys = getWidgetQueryKeys(definition, item.kind); return ( {({ reset }) => ( { - void queryClient.invalidateQueries({ queryKey: [["widget"]] }); + removeWidgetDataQueries(queryClient, widgetQueryKeys); reset(); }} fallbackRender={({ resetErrorBoundary, error }) => ( <> - - + + )} > @@ -130,24 +213,26 @@ const InnerContent = ({ item, ...dimensions }: InnerContentProps) => { (!("integrationsRequired" in definition) || definition.integrationsRequired !== false) } /> - - removeItem({ itemId: item.id })} - setOptions={(partialNewOptions) => - updateOptions({ - newOptions: { - ...options, - ...partialNewOptions.newOptions, - }, - }) - } - {...dimensions} - /> + +
+ removeItem({ itemId: item.id })} + setOptions={(partialNewOptions) => + updateOptions({ + newOptions: { + ...options, + ...partialNewOptions.newOptions, + }, + }) + } + {...dimensions} + /> +
)}
diff --git a/apps/nextjs/src/components/board/items/item-menu.tsx b/apps/nextjs/src/components/board/items/item-menu.tsx index 55a7b1f1e..7268eccc9 100644 --- a/apps/nextjs/src/components/board/items/item-menu.tsx +++ b/apps/nextjs/src/components/board/items/item-menu.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useRef } from "react"; +import { useEffect, useRef, useState } from "react"; import { ActionIcon, Menu } from "@mantine/core"; import { IconCopy, IconDotsVertical, IconLayoutKanban, IconPencil, IconTrash } from "@tabler/icons-react"; @@ -8,41 +8,46 @@ import { useEditMode } from "@homarr/boards/edit-mode"; import { useConfirmModal, useModalAction } from "@homarr/modals"; import { useSettings } from "@homarr/settings"; import { useI18n, useScopedI18n } from "@homarr/translation/client"; -import { widgetImports } from "@homarr/widgets"; -import { WidgetEditModal } from "@homarr/widgets/modals"; +import type { WidgetDefinition } from "@homarr/widgets/definition"; import type { SectionItem } from "~/app/[locale]/boards/_types"; import { useSectionContext } from "../sections/section-context"; import { useItemActions } from "./item-actions"; -import { ItemMoveModal } from "./item-move-modal"; +import { LazyItemMoveModal, preloadItemMoveModal } from "./lazy-item-move-modal"; +import { LazyWidgetEditModal, preloadWidgetEditModal } from "./lazy-widget-edit-modal"; interface BoardItemMenuProps { offset: number; item: SectionItem; + definition: WidgetDefinition; resetErrorBoundary?: () => void; } export const BoardItemMenu = (props: BoardItemMenuProps) => { const { data: session } = useSession(); - if (!session) return null; + const [isEditMode] = useEditMode(); + if (!session || !isEditMode) return null; return ; }; -const BoardItemMenuInner = ({ offset, item, resetErrorBoundary }: BoardItemMenuProps) => { +const BoardItemMenuInner = ({ offset, item, definition, resetErrorBoundary }: BoardItemMenuProps) => { const { data: session } = useSession(); const canDuplicate = item.kind !== "customApi" || (session?.user.permissions.includes("admin") ?? false); const refResetErrorBoundaryOnNextRender = useRef(false); const tItem = useScopedI18n("item"); const t = useI18n(); - const { openModal } = useModalAction(WidgetEditModal); - const { openModal: openMoveModal } = useModalAction(ItemMoveModal); + const { openModal } = useModalAction(LazyWidgetEditModal); + const { openModal: openMoveModal } = useModalAction(LazyItemMoveModal); const { openConfirmModal } = useConfirmModal(); - const [isEditMode] = useEditMode(); + const [isMenuOpen, setIsMenuOpen] = useState(false); + const hasSupportedIntegrations = + "supportedIntegrations" in definition && (definition.supportedIntegrations?.length ?? 0) > 0; const { updateItemOptions, updateItemAdvancedOptions, updateItemIntegrations, duplicateItem, removeItem } = useItemActions(); - const { data: integrationData, isPending } = clientApi.integration.all.useQuery(); - const currentDefinition = useMemo(() => widgetImports[item.kind].definition, [item.kind]); + const { data: integrationData, isPending } = clientApi.integration.all.useQuery(undefined, { + enabled: isMenuOpen && hasSupportedIntegrations, + }); const { gridstack } = useSectionContext().refs; const settings = useSettings(); @@ -54,12 +59,11 @@ const BoardItemMenuInner = ({ offset, item, resetErrorBoundary }: BoardItemMenuP } }, [item, resetErrorBoundary]); - if (!isEditMode || isPending) return null; - const openEditModal = () => { openModal( { kind: item.kind, + definition, value: { advancedOptions: item.advancedOptions, options: item.options, @@ -82,10 +86,10 @@ const BoardItemMenuInner = ({ offset, item, resetErrorBoundary }: BoardItemMenuP }, integrationData: (integrationData ?? []).filter( (integration) => - "supportedIntegrations" in currentDefinition && - (currentDefinition.supportedIntegrations as string[]).some((kind) => kind === integration.kind), + "supportedIntegrations" in definition && + (definition.supportedIntegrations as string[]).some((kind) => kind === integration.kind), ), - integrationSupport: "supportedIntegrations" in currentDefinition, + integrationSupport: "supportedIntegrations" in definition, settings, itemId: item.id, appId: item.kind === "app" ? (item.options.appId as string | undefined) : undefined, @@ -109,7 +113,7 @@ const BoardItemMenuInner = ({ offset, item, resetErrorBoundary }: BoardItemMenuP }; return ( - + {tItem("menu.label.settings")} - } onClick={openEditModal}> + } + onClick={openEditModal} + onFocus={preloadWidgetEditModal} + onPointerEnter={preloadWidgetEditModal} + disabled={hasSupportedIntegrations && isPending} + > {tItem("action.edit")} } + onFocus={preloadItemMoveModal} + onPointerEnter={preloadItemMoveModal} onClick={() => { if (!gridstack.current) return; openMoveModal({ item, columnCount: gridstack.current.getColumn(), gridStack: gridstack.current }); diff --git a/apps/nextjs/src/components/board/items/item-move-modal.tsx b/apps/nextjs/src/components/board/items/item-move-modal.tsx index 38e362ede..276e1124e 100644 --- a/apps/nextjs/src/components/board/items/item-move-modal.tsx +++ b/apps/nextjs/src/components/board/items/item-move-modal.tsx @@ -9,13 +9,13 @@ import { useI18n } from "@homarr/translation/client"; import type { Item, SectionItem } from "~/app/[locale]/boards/_types"; -interface InnerProps { +export interface ItemMoveModalProps { gridStack: GridStack; item: Pick; columnCount: number; } -export const ItemMoveModal = createModal(({ actions, innerProps }) => { +export const ItemMoveModal = createModal(({ actions, innerProps }) => { const t = useI18n(); const maxWidthRef = useRef(innerProps.columnCount - innerProps.item.xOffset); const form = useZodForm( diff --git a/apps/nextjs/src/components/board/items/item-select-data.spec.ts b/apps/nextjs/src/components/board/items/item-select-data.spec.ts new file mode 100644 index 000000000..4d0e77860 --- /dev/null +++ b/apps/nextjs/src/components/board/items/item-select-data.spec.ts @@ -0,0 +1,48 @@ +import { describe, expect, it, vi } from "vitest"; + +import { resolveMatchingIntegrationsAsync, tryLockSelection, unlockSelection } from "./item-select-data"; + +describe("widget selection lock", () => { + it("rejects repeated selection synchronously until the active selection finishes", () => { + const lock = { current: false }; + + expect(tryLockSelection(lock)).toBe(true); + expect(tryLockSelection(lock)).toBe(false); + + unlockSelection(lock); + expect(tryLockSelection(lock)).toBe(true); + }); +}); + +describe("resolveMatchingIntegrationsAsync", () => { + it("waits for the shared integration query before a cold integration-backed selection", async () => { + const ensureDataAsync = vi.fn(async () => [ + { id: "matching", kind: "sonarr" as const }, + { id: "other", kind: "radarr" as const }, + ]); + + await expect( + resolveMatchingIntegrationsAsync({ + hasIntegrationSupport: true, + supportedIntegrations: ["sonarr"], + currentData: undefined, + ensureDataAsync, + }), + ).resolves.toEqual([{ id: "matching", kind: "sonarr" }]); + expect(ensureDataAsync).toHaveBeenCalledOnce(); + }); + + it("does not wait for integrations when the widget has no integration support", async () => { + const ensureDataAsync = vi.fn(async () => [{ id: "unused", kind: "sonarr" as const }]); + + await expect( + resolveMatchingIntegrationsAsync({ + hasIntegrationSupport: false, + supportedIntegrations: [], + currentData: undefined, + ensureDataAsync, + }), + ).resolves.toEqual([]); + expect(ensureDataAsync).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/nextjs/src/components/board/items/item-select-data.ts b/apps/nextjs/src/components/board/items/item-select-data.ts new file mode 100644 index 000000000..f2b37f700 --- /dev/null +++ b/apps/nextjs/src/components/board/items/item-select-data.ts @@ -0,0 +1,28 @@ +import type { IntegrationKind } from "@homarr/definitions"; + +export const tryLockSelection = (lock: { current: boolean }) => { + if (lock.current) return false; + lock.current = true; + return true; +}; + +export const unlockSelection = (lock: { current: boolean }) => { + lock.current = false; +}; + +export const resolveMatchingIntegrationsAsync = async ({ + hasIntegrationSupport, + supportedIntegrations, + currentData, + ensureDataAsync, +}: { + hasIntegrationSupport: boolean; + supportedIntegrations: readonly IntegrationKind[]; + currentData: TIntegration[] | undefined; + ensureDataAsync: () => Promise; +}) => { + if (!hasIntegrationSupport) return []; + + const integrations = currentData ?? (await ensureDataAsync()); + return integrations.filter((integration) => supportedIntegrations.includes(integration.kind)); +}; diff --git a/apps/nextjs/src/components/board/items/item-select-modal.module.css b/apps/nextjs/src/components/board/items/item-select-modal.module.css new file mode 100644 index 000000000..ef8c56d8d --- /dev/null +++ b/apps/nextjs/src/components/board/items/item-select-modal.module.css @@ -0,0 +1,21 @@ +.action { + opacity: 0; + transition: opacity 150ms ease; +} + +.card:hover .action, +.card:focus-within .action { + opacity: 1; +} + +@media (hover: none), (pointer: coarse) { + .action { + opacity: 1; + } +} + +@media (prefers-reduced-motion: reduce) { + .action { + transition: none; + } +} diff --git a/apps/nextjs/src/components/board/items/item-select-modal.tsx b/apps/nextjs/src/components/board/items/item-select-modal.tsx index fc75f26d1..1c2e8ce48 100644 --- a/apps/nextjs/src/components/board/items/item-select-modal.tsx +++ b/apps/nextjs/src/components/board/items/item-select-modal.tsx @@ -1,53 +1,68 @@ -import { useMemo, useState } from "react"; +import { useMemo, useRef, useState } from "react"; import { Avatar, Box, Button, Card, Center, Divider, Group, Image, Stack, Text, Tooltip } from "@mantine/core"; import { IconApi } from "@tabler/icons-react"; +import type { RouterOutputs } from "@homarr/api"; import { clientApi } from "@homarr/api/client"; import { useSession } from "@homarr/auth/client"; -import { createId, objectEntries } from "@homarr/common"; -import { getIconUrl, getIntegrationName } from "@homarr/definitions"; +import { createId } from "@homarr/common"; +import { getIconUrl, getIntegrationName, widgetIntegrationSupport, widgetKinds } from "@homarr/definitions"; import type { IntegrationKind, WidgetKind } from "@homarr/definitions"; import { createModal, modalSizeSelect, useModalAction } from "@homarr/modals"; +import { showErrorNotification } from "@homarr/notifications"; import { useSettings } from "@homarr/settings"; import { useI18n } from "@homarr/translation/client"; import { SelectGridLayout, selectGridCardHeight } from "@homarr/ui"; import type { TablerIcon } from "@homarr/ui"; -import { reduceWidgetOptionsWithDefaultValues, widgetImports } from "@homarr/widgets"; -import { WidgetEditModal } from "@homarr/widgets/modals"; +import { widgetCatalogIcons } from "@homarr/widgets/catalog"; +import { loadWidgetDefinition, reduceWidgetOptionsWithDefinition } from "@homarr/widgets/manifest"; import { WorkshopInstallButton } from "~/components/workshop/workshop-install-button"; import { useItemActions } from "./item-actions"; +import { resolveMatchingIntegrationsAsync, tryLockSelection, unlockSelection } from "./item-select-data"; +import { LazyWidgetEditModal, preloadWidgetEditModal } from "./lazy-widget-edit-modal"; +import classes from "./item-select-modal.module.css"; -export const ItemSelectModal = createModal<{ boardId: string }>(({ actions, innerProps }) => { - const { data: session } = useSession(); - const isAdmin = session?.user.permissions.includes("admin") ?? false; +interface ItemSelectModalContentProps { + actions: { closeModal: () => void }; + integrationData: RouterOutputs["integration"]["all"] | undefined; + customWidgetDefs: RouterOutputs["customWidget"]["available"] | undefined; + ensureIntegrationDataAsync: () => Promise; + isAdmin: boolean; +} + +const ItemSelectModalContent = ({ + actions, + integrationData, + customWidgetDefs, + ensureIntegrationDataAsync, + isAdmin, +}: ItemSelectModalContentProps) => { const [search, setSearch] = useState(""); + const [loadingSelection, setLoadingSelection] = useState(null); + const selectionLock = useRef(false); const t = useI18n(); const { createItem, updateItemOptions, updateItemAdvancedOptions, updateItemIntegrations } = useItemActions(); - const { openModal: openEditModal } = useModalAction(WidgetEditModal); - const { data: integrationData } = clientApi.integration.all.useQuery(); - const { data: customWidgetDefs } = clientApi.customWidget.available.useQuery( - { boardId: innerProps.boardId }, - { enabled: isAdmin }, - ); + const { openModal: openEditModal } = useModalAction(LazyWidgetEditModal); const settings = useSettings(); const availableKinds = useMemo(() => new Set((integrationData ?? []).map((i) => i.kind)), [integrationData]); const items = useMemo( () => - objectEntries(widgetImports) - .filter(([kind]) => kind !== "customApi") - .map(([kind, value]) => ({ - kind, - supportedIntegrations: - "supportedIntegrations" in value.definition - ? value.definition.supportedIntegrations.filter((integration) => integration !== "mock") - : [], - icon: value.definition.icon, - name: t(`widget.${kind}.name`), - description: t(`widget.${kind}.description`), - })) + widgetKinds + .filter((kind) => kind !== "customApi") + .map((kind) => { + return { + kind, + supportedIntegrations: (widgetIntegrationSupport[kind] ?? []).filter( + (integration) => integration !== "mock", + ), + icon: widgetCatalogIcons[kind], + name: t(`widget.${kind}.name`), + description: t(`widget.${kind}.description`), + }; + }) .sort((itemA, itemB) => { if (itemA.kind === "app") return -1; if (itemB.kind === "app") return 1; @@ -72,77 +87,108 @@ export const ItemSelectModal = createModal<{ boardId: string }>(({ actions, inne [customWidgetDefs, search], ); - const handleAddCustomWidget = (definition: NonNullable[number]) => { - const itemId = createId(); - const defaultOptions = reduceWidgetOptionsWithDefaultValues("customApi", settings); - const options = { - ...defaultOptions, - definitionId: definition.id, - configuration: definition.defaultOptions, - configurationVersion: definition.updatedAt.getTime(), - }; - actions.closeModal(); - openEditModal( - { - kind: "customApi", - value: { - advancedOptions: { title: null, customCssClasses: [], borderColor: "" }, - options, - integrationIds: [], - }, - onSuccessfulEdit: ({ options: configuredOptions, advancedOptions }) => { - createItem({ id: itemId, kind: "customApi", integrationIds: [] }); - updateItemOptions({ itemId, newOptions: configuredOptions }); - updateItemAdvancedOptions({ itemId, newAdvancedOptions: advancedOptions }); - }, - integrationData: [], - integrationSupport: false, - settings, - }, - { - title: (titleT) => `${titleT("item.edit.title")} - ${definition.name}`, - }, - ); + const notifyDefinitionLoadError = (error: unknown) => { + showErrorNotification({ + title: t("common.error"), + message: error instanceof Error ? error.message : String(error), + }); }; - const handleAdd = (kind: WidgetKind) => { - const definition = widgetImports[kind].definition; - const hasIntegrationSupport = "supportedIntegrations" in definition; - - const matchingIntegrations = hasIntegrationSupport - ? (integrationData ?? []).filter((integration) => - (definition.supportedIntegrations as string[]).includes(integration.kind), - ) - : []; - - const integrationIds = matchingIntegrations.map((i) => i.id); - const itemId = createId(); - const defaultOptions = reduceWidgetOptionsWithDefaultValues(kind, settings); - - createItem({ id: itemId, kind, integrationIds }); - actions.closeModal(); - - openEditModal( - { - kind, - value: { - advancedOptions: { title: null, customCssClasses: [], borderColor: "" }, - options: defaultOptions, - integrationIds, + const handleAddCustomWidget = async (customWidgetDefinition: NonNullable[number]) => { + if (!tryLockSelection(selectionLock)) return; + setLoadingSelection(`custom:${customWidgetDefinition.id}`); + preloadWidgetEditModal(); + try { + const definition = await loadWidgetDefinition("customApi"); + const itemId = createId(); + const defaultOptions = reduceWidgetOptionsWithDefinition(definition, settings); + const options = { + ...defaultOptions, + definitionId: customWidgetDefinition.id, + configuration: customWidgetDefinition.defaultOptions, + configurationVersion: customWidgetDefinition.updatedAt.getTime(), + }; + actions.closeModal(); + openEditModal( + { + kind: "customApi", + definition, + value: { + advancedOptions: { title: null, customCssClasses: [], borderColor: "" }, + options, + integrationIds: [], + }, + onSuccessfulEdit: ({ options: configuredOptions, advancedOptions }) => { + createItem({ id: itemId, kind: "customApi", integrationIds: [] }); + updateItemOptions({ itemId, newOptions: configuredOptions }); + updateItemAdvancedOptions({ itemId, newAdvancedOptions: advancedOptions }); + }, + integrationData: [], + integrationSupport: false, + settings, }, - onSuccessfulEdit: ({ options, integrationIds: newIntegrationIds, advancedOptions }) => { - updateItemOptions({ itemId, newOptions: options }); - updateItemAdvancedOptions({ itemId, newAdvancedOptions: advancedOptions }); - updateItemIntegrations({ itemId, newIntegrations: newIntegrationIds }); + { + title: (titleT) => `${titleT("item.edit.title")} - ${customWidgetDefinition.name}`, }, - integrationData: matchingIntegrations, - integrationSupport: hasIntegrationSupport, - settings, - }, - { - title: (titleT) => `${titleT("item.edit.title")} - ${titleT(`widget.${kind}.name`)}`, - }, - ); + ); + } catch (error) { + notifyDefinitionLoadError(error); + } finally { + unlockSelection(selectionLock); + setLoadingSelection(null); + } + }; + + const handleAdd = async (kind: WidgetKind) => { + if (!tryLockSelection(selectionLock)) return; + setLoadingSelection(kind); + preloadWidgetEditModal(); + try { + const definition = await loadWidgetDefinition(kind); + const hasIntegrationSupport = "supportedIntegrations" in definition; + + const matchingIntegrations = await resolveMatchingIntegrationsAsync({ + hasIntegrationSupport, + supportedIntegrations: definition.supportedIntegrations ?? [], + currentData: integrationData, + ensureDataAsync: ensureIntegrationDataAsync, + }); + + const integrationIds = matchingIntegrations.map((i) => i.id); + const itemId = createId(); + const defaultOptions = reduceWidgetOptionsWithDefinition(definition, settings); + + createItem({ id: itemId, kind, integrationIds }); + actions.closeModal(); + + openEditModal( + { + kind, + definition, + value: { + advancedOptions: { title: null, customCssClasses: [], borderColor: "" }, + options: defaultOptions, + integrationIds, + }, + onSuccessfulEdit: ({ options, integrationIds: newIntegrationIds, advancedOptions }) => { + updateItemOptions({ itemId, newOptions: options }); + updateItemAdvancedOptions({ itemId, newAdvancedOptions: advancedOptions }); + updateItemIntegrations({ itemId, newIntegrations: newIntegrationIds }); + }, + integrationData: matchingIntegrations, + integrationSupport: hasIntegrationSupport, + settings, + }, + { + title: (titleT) => `${titleT("item.edit.title")} - ${titleT(`widget.${kind}.name`)}`, + }, + ); + } catch (error) { + notifyDefinitionLoadError(error); + } finally { + unlockSelection(selectionLock); + setLoadingSelection(null); + } }; return ( @@ -151,9 +197,9 @@ export const ItemSelectModal = createModal<{ boardId: string }>(({ actions, inne onSearchChange={setSearch} placeholder={`${t("item.create.search")}...`} onSearchKeyDown={(event) => { - if (event.key === "Enter" && filteredItems.length === 1) { - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - handleAdd(filteredItems[0]!.kind); + if (event.key === "Enter" && loadingSelection === null && filteredItems.length === 1) { + const [item] = filteredItems; + if (item) void handleAdd(item.kind); } }} > @@ -161,7 +207,13 @@ export const ItemSelectModal = createModal<{ boardId: string }>(({ actions, inne handleAdd(item.kind)} + onSelect={() => void handleAdd(item.kind)} + onIntent={() => { + void loadWidgetDefinition(item.kind).catch(() => undefined); + preloadWidgetEditModal(); + }} + disabled={loadingSelection !== null} + loading={loadingSelection === item.kind} hasMatchingIntegration={item.supportedIntegrations.some((kind) => availableKinds.has(kind))} /> ))} @@ -177,12 +229,12 @@ export const ItemSelectModal = createModal<{ boardId: string }>(({ actions, inne {filteredCustomWidgets.map((def) => ( e.currentTarget.style.setProperty("--_hover-opacity", "1")} - onMouseLeave={(e) => e.currentTarget.style.setProperty("--_hover-opacity", "0")} + style={{ overflow: "hidden" }} + onPointerEnter={() => void loadWidgetDefinition("customApi").catch(() => undefined)} > @@ -200,18 +252,24 @@ export const ItemSelectModal = createModal<{ boardId: string }>(({ actions, inne - @@ -231,7 +289,30 @@ export const ItemSelectModal = createModal<{ boardId: string }>(({ actions, inne )} ); -}).withOptions({ +}; + +const ItemSelectModalFrame = ({ actions, innerProps }: { actions: { closeModal: () => void }; innerProps: { boardId: string } }) => { + const utils = clientApi.useUtils(); + const { data: session } = useSession(); + const isAdmin = session?.user.permissions.includes("admin") ?? false; + const { data: integrationData } = clientApi.integration.all.useQuery(); + const { data: customWidgetDefs } = clientApi.customWidget.available.useQuery( + { boardId: innerProps.boardId }, + { enabled: isAdmin }, + ); + + return ( + utils.integration.all.ensureData()} + isAdmin={isAdmin} + /> + ); +}; + +export const ItemSelectModal = createModal<{ boardId: string }>((props) => ).withOptions({ defaultTitle: (t) => t("item.create.title"), size: modalSizeSelect, }); @@ -239,6 +320,9 @@ export const ItemSelectModal = createModal<{ boardId: string }>(({ actions, inne const WidgetItem = ({ item, onSelect, + onIntent, + disabled, + loading, hasMatchingIntegration, }: { item: { @@ -249,23 +333,27 @@ const WidgetItem = ({ icon: TablerIcon; }; onSelect: () => void; + onIntent: () => void; + disabled: boolean; + loading: boolean; hasMatchingIntegration: boolean; }) => { const t = useI18n(); return ( e.currentTarget.style.setProperty("--_hover-opacity", "1")} - onMouseLeave={(e) => e.currentTarget.style.setProperty("--_hover-opacity", "0")} + onFocus={onIntent} + onPointerEnter={onIntent} + aria-busy={loading || undefined} > @@ -282,18 +370,17 @@ const WidgetItem = ({ - diff --git a/apps/nextjs/src/components/board/items/lazy-item-move-modal.tsx b/apps/nextjs/src/components/board/items/lazy-item-move-modal.tsx new file mode 100644 index 000000000..836017bc1 --- /dev/null +++ b/apps/nextjs/src/components/board/items/lazy-item-move-modal.tsx @@ -0,0 +1,67 @@ +"use client"; + +import { Suspense, use } from "react"; +import { Center, Loader } from "@mantine/core"; +import { ErrorBoundary } from "react-error-boundary"; + +import { createModal, modalSizeForm } from "@homarr/modals"; +import { WidgetError } from "@homarr/widgets/errors"; + +import type { ItemMoveModalProps } from "./item-move-modal"; + +let itemMoveModalPromise: Promise | undefined; + +const loadItemMoveModal = () => { + if (itemMoveModalPromise) return itemMoveModalPromise; + + const currentPromise = import("./item-move-modal"); + itemMoveModalPromise = currentPromise; + void currentPromise.catch(() => { + if (itemMoveModalPromise === currentPromise) itemMoveModalPromise = undefined; + }); + return currentPromise; +}; + +export const preloadItemMoveModal = () => void loadItemMoveModal().catch(() => undefined); + +interface LazyItemMoveModalContentProps { + actions: { closeModal: () => void }; + innerProps: ItemMoveModalProps; +} + +const LazyItemMoveModalContent = (props: LazyItemMoveModalContentProps) => { + const { ItemMoveModal } = use(loadItemMoveModal()); + const Component = ItemMoveModal.component; + return ; +}; + +export const LazyItemMoveModal = createModal((props) => ( + ( +
+ { + itemMoveModalPromise = undefined; + resetErrorBoundary(); + }} + /> +
+ )} + > + + + + } + > + + +
+)).withOptions({ + defaultTitle(t) { + return t("item.moveResize.title"); + }, + size: modalSizeForm, +}); diff --git a/apps/nextjs/src/components/board/items/lazy-widget-edit-modal.tsx b/apps/nextjs/src/components/board/items/lazy-widget-edit-modal.tsx new file mode 100644 index 000000000..fb7a26bcd --- /dev/null +++ b/apps/nextjs/src/components/board/items/lazy-widget-edit-modal.tsx @@ -0,0 +1,68 @@ +"use client"; + +import { Suspense, use } from "react"; +import { Center, Loader } from "@mantine/core"; +import { ErrorBoundary } from "react-error-boundary"; + +import type { WidgetKind } from "@homarr/definitions"; +import { createModal, modalSizeForm } from "@homarr/modals"; +import { WidgetError } from "@homarr/widgets/errors"; +import type { WidgetEditModalProps } from "@homarr/widgets/modals"; + +let widgetEditModalPromise: Promise | undefined; + +export const loadWidgetEditModal = () => { + if (widgetEditModalPromise) return widgetEditModalPromise; + + const currentPromise = import("@homarr/widgets/modals"); + widgetEditModalPromise = currentPromise; + void currentPromise.catch(() => { + if (widgetEditModalPromise === currentPromise) widgetEditModalPromise = undefined; + }); + return currentPromise; +}; + +export const preloadWidgetEditModal = () => void loadWidgetEditModal().catch(() => undefined); + +interface LazyWidgetEditModalContentProps { + actions: { closeModal: () => void }; + innerProps: WidgetEditModalProps; +} + +const LazyWidgetEditModalContent = (props: LazyWidgetEditModalContentProps) => { + const { WidgetEditModal } = use(loadWidgetEditModal()); + const Component = WidgetEditModal.component; + return ; +}; + +export const LazyWidgetEditModal = createModal>((props) => ( + ( +
+ { + widgetEditModalPromise = undefined; + resetErrorBoundary(); + }} + /> +
+ )} + > + + + + } + > + + +
+)).withOptions({ + keepMounted: true, + defaultTitle(t) { + return t("item.edit.title"); + }, + size: modalSizeForm, +}); diff --git a/apps/nextjs/src/components/board/items/widget-context-menu.tsx b/apps/nextjs/src/components/board/items/widget-context-menu.tsx index 8f97c6a2c..d475865c3 100644 --- a/apps/nextjs/src/components/board/items/widget-context-menu.tsx +++ b/apps/nextjs/src/components/board/items/widget-context-menu.tsx @@ -1,7 +1,7 @@ "use client"; import type { MutableRefObject, ReactNode } from "react"; -import { useCallback, useMemo } from "react"; +import { useCallback, useMemo, useState } from "react"; import { Group, Loader, Menu, Switch, Text, Tooltip } from "@mantine/core"; import { IconAlertTriangle, @@ -13,7 +13,7 @@ import { IconTrash, } from "@tabler/icons-react"; import type { QueryClient, QueryKey } from "@tanstack/react-query"; -import { useIsFetching, useQueryClient } from "@tanstack/react-query"; +import { partialMatchKey, useIsFetching, useQueryClient } from "@tanstack/react-query"; import { clientApi } from "@homarr/api/client"; import { useSession } from "@homarr/auth/client"; @@ -25,65 +25,119 @@ import { useSettings } from "@homarr/settings"; import { translateIfNecessary } from "@homarr/translation"; import { useI18n, useScopedI18n } from "@homarr/translation/client"; import type { TranslationFunction } from "@homarr/translation"; -import type { WidgetContextMenuAction } from "@homarr/widgets"; -import { reduceWidgetOptionsWithDefaultValues, widgetImports } from "@homarr/widgets"; -import { WidgetEditModal } from "@homarr/widgets/modals"; +import type { WidgetContextMenuAction, WidgetDefinition } from "@homarr/widgets/definition"; +import { getWidgetQueryKeys } from "@homarr/widgets/definition"; +import { reduceWidgetOptionsWithDefinition } from "@homarr/widgets/manifest"; import type { SectionItem } from "~/app/[locale]/boards/_types"; import { useSectionContext } from "../sections/section-context"; import { useItemActions } from "./item-actions"; -import { ItemMoveModal } from "./item-move-modal"; +import { LazyItemMoveModal, preloadItemMoveModal } from "./lazy-item-move-modal"; +import { LazyWidgetEditModal, preloadWidgetEditModal } from "./lazy-widget-edit-modal"; interface WidgetContextMenuProps { item: SectionItem; + definition: WidgetDefinition; widgetStateRef: MutableRefObject | null>; children: ReactNode; } -export const WidgetContextMenu = ({ item, widgetStateRef, children }: WidgetContextMenuProps) => { +export const WidgetContextMenu = ({ item, definition, widgetStateRef, children }: WidgetContextMenuProps) => { const { data: session } = useSession(); + const settings = useSettings(); + + if (!session || !settings.enableRightClickOnWidgets) return <>{children}; + + return ( + + {children} + + ); +}; + +const WidgetContextMenuInner = ({ + item, + definition, + widgetStateRef, + children, + settings, +}: WidgetContextMenuProps & { settings: ReturnType }) => { + const [isMenuOpen, setIsMenuOpen] = useState(false); + + return ( + + {children} + + {isMenuOpen && ( + + )} + + + ); +}; + +type WidgetContextMenuDropdownProps = Omit & { + settings: ReturnType; +}; + +const WidgetContextMenuDropdown = ({ item, definition, widgetStateRef, settings }: WidgetContextMenuDropdownProps) => { const [isEditMode] = useEditMode(); const board = useRequiredBoard(); const tItem = useScopedI18n("item"); const tMenu = useScopedI18n("item.menu.label"); const t = useI18n(); - const settings = useSettings(); - const isRightClickEnabled = settings.enableRightClickOnWidgets; + const { data: session } = useSession(); const canConfigureWidget = item.kind !== "customApi" || (session?.user.permissions.includes("admin") ?? false); - const { openModal } = useModalAction(WidgetEditModal); - const { openModal: openMoveModal } = useModalAction(ItemMoveModal); + const hasSupportedIntegrations = + "supportedIntegrations" in definition && (definition.supportedIntegrations?.length ?? 0) > 0; + const { openModal } = useModalAction(LazyWidgetEditModal); + const { openModal: openMoveModal } = useModalAction(LazyItemMoveModal); const { openConfirmModal } = useConfirmModal(); const { updateItemOptions, updateItemAdvancedOptions, updateItemIntegrations, duplicateItem, removeItem } = useItemActions(); - const { data: integrationData, isPending } = clientApi.integration.all.useQuery(); + const { data: integrationData, isPending } = clientApi.integration.all.useQuery(undefined, { + enabled: hasSupportedIntegrations, + }); const { mutate: saveBoard } = clientApi.board.saveBoard.useMutation(); - const currentDefinition = useMemo(() => widgetImports[item.kind].definition, [item.kind]); const { gridstack } = useSectionContext().refs; const queryClient = useQueryClient(); - const widgetQueryKey = useMemo( - () => ("queryKey" in currentDefinition ? (currentDefinition.queryKey as QueryKey) : [["widget", item.kind]]), - [currentDefinition, item.kind], - ); - const isWidgetFetching = useIsFetching({ queryKey: widgetQueryKey }) > 0; + const widgetQueryKeys = useMemo(() => getWidgetQueryKeys(definition, item.kind), [definition, item.kind]); + const isWidgetFetching = + useIsFetching({ + predicate: (query) => widgetQueryKeys.some((queryKey) => partialMatchKey(query.queryKey, queryKey)), + }) > 0; const handleRefetch = useCallback(() => { - void queryClient.refetchQueries({ queryKey: widgetQueryKey, type: "all" }); - }, [queryClient, widgetQueryKey]); + void Promise.all(widgetQueryKeys.map((queryKey) => queryClient.refetchQueries({ queryKey, type: "all" }))); + }, [queryClient, widgetQueryKeys]); const options = useMemo( - () => reduceWidgetOptionsWithDefaultValues(item.kind, settings, item.options) as Record, - [item.kind, settings, item.options], + () => reduceWidgetOptionsWithDefinition(definition, settings, item.options), + [definition, settings, item.options], ); type OptionDef = { type: string; skipContextMenu?: boolean }; const toggleOptions = useMemo(() => { if (!canConfigureWidget) return []; - const rawOptions = currentDefinition.createOptions(settings) as unknown as Record; + const rawOptions = definition.createOptions(settings) as unknown as Record; return Object.entries(rawOptions).filter(([, def]) => def.type === "switch" && !def.skipContextMenu); - }, [canConfigureWidget, currentDefinition, settings]); + }, [canConfigureWidget, definition, settings]); const widgetContextActions = useMemo(() => { - const def = currentDefinition as Record; + const def = definition as unknown as Record; if (typeof def.contextActions !== "function") return []; // eslint-disable-next-line @typescript-eslint/no-explicit-any const actions = (def.contextActions as any)({ @@ -96,7 +150,7 @@ export const WidgetContextMenu = ({ item, widgetStateRef, children }: WidgetCont widgetStateRef, }); return (Array.isArray(actions) ? actions : []) as WidgetContextMenuAction[]; - }, [currentDefinition, options, item, updateItemOptions, isEditMode, board.id, widgetStateRef]); + }, [definition, options, item, updateItemOptions, isEditMode, board.id, widgetStateRef]); const persistBoard = useCallback( (updatedItems: typeof board.items) => { @@ -109,6 +163,7 @@ export const WidgetContextMenu = ({ item, widgetStateRef, children }: WidgetCont openModal( { kind: item.kind, + definition, value: { advancedOptions: item.advancedOptions, options: item.options, @@ -135,10 +190,10 @@ export const WidgetContextMenu = ({ item, widgetStateRef, children }: WidgetCont }, integrationData: (integrationData ?? []).filter( (integration) => - "supportedIntegrations" in currentDefinition && - (currentDefinition.supportedIntegrations as string[]).some((kind) => kind === integration.kind), + "supportedIntegrations" in definition && + (definition.supportedIntegrations as string[]).some((kind) => kind === integration.kind), ), - integrationSupport: "supportedIntegrations" in currentDefinition, + integrationSupport: "supportedIntegrations" in definition, settings, appId: item.kind === "app" ? (item.options.appId as string | undefined) : undefined, }, @@ -152,7 +207,7 @@ export const WidgetContextMenu = ({ item, widgetStateRef, children }: WidgetCont updateItemAdvancedOptions, updateItemIntegrations, integrationData, - currentDefinition, + definition, settings, isEditMode, persistBoard, @@ -170,137 +225,135 @@ export const WidgetContextMenu = ({ item, widgetStateRef, children }: WidgetCont [options, item.id, updateItemOptions, persistBoard, board], ); - if (!session) return <>{children}; - if (!isRightClickEnabled) return <>{children}; - const visibleWidgetActions = widgetContextActions.filter((a) => !a.hidden); return ( - - {children} - - {toggleOptions.length > 0 && ( - <> - {tMenu("options")} - {toggleOptions.map(([key]) => ( - handleToggle(key)(!options[key])}> - {/* eslint-disable-next-line @typescript-eslint/no-explicit-any */} - - {String(translateIfNecessary(t, ((fn: any) => fn(`widget.${item.kind}.option.${key}.label`)) as any))} - - - - ))} - - )} - - {visibleWidgetActions.length > 0 && ( - <> - {toggleOptions.length > 0 && } - {tMenu("actions")} - {visibleWidgetActions.map((action) => { - const Icon = action.icon; - return ( - : undefined} - onClick={action.onClick} - disabled={action.disabled} - color={action.color} - > - {String(translateIfNecessary(t, action.label))} - - ); - })} - - )} - - {isEditMode && ( - <> - {(toggleOptions.length > 0 || visibleWidgetActions.length > 0) && } - } - onClick={() => { - if (!gridstack.current) return; - openMoveModal({ item, columnCount: gridstack.current.getColumn(), gridStack: gridstack.current }); - }} - > - {tItem("action.moveResize")} - - {canConfigureWidget && ( - } - onClick={() => duplicateItem({ itemId: item.id })} - > - {tItem("action.duplicate")} - - )} - - )} - + <> + {toggleOptions.length > 0 && ( <> - {(toggleOptions.length > 0 || visibleWidgetActions.length > 0 || isEditMode) && } - : } - onClick={handleRefetch} - disabled={isWidgetFetching} - > - - {tMenu("refresh")} - - - + {tMenu("options")} + {toggleOptions.map(([key]) => ( + handleToggle(key)(!options[key])}> + {/* eslint-disable-next-line @typescript-eslint/no-explicit-any */} + + {String(translateIfNecessary(t, ((fn: any) => fn(`widget.${item.kind}.option.${key}.label`)) as any))} + + + + ))} + + )} + + {visibleWidgetActions.length > 0 && ( + <> + {toggleOptions.length > 0 && } + {tMenu("actions")} + {visibleWidgetActions.map((action) => { + const Icon = action.icon; + return ( + : undefined} + onClick={action.onClick} + disabled={action.disabled} + color={action.color} + > + {String(translateIfNecessary(t, action.label))} + + ); + })} + + )} + + {isEditMode && ( + <> + {(toggleOptions.length > 0 || visibleWidgetActions.length > 0) && } } - onClick={openEditModal} - disabled={isPending} + leftSection={} + onFocus={preloadItemMoveModal} + onPointerEnter={preloadItemMoveModal} + onClick={() => { + if (!gridstack.current) return; + openMoveModal({ item, columnCount: gridstack.current.getColumn(), gridStack: gridstack.current }); + }} > - {tMenu("settings")} + {tItem("action.moveResize")} - - - {isEditMode && ( - <> - + {canConfigureWidget && ( } - onClick={() => { - openConfirmModal({ - title: tItem("remove.title"), - children: tItem("remove.message"), - onConfirm: () => removeItem({ itemId: item.id }), - }); - }} + leftSection={} + onClick={() => duplicateItem({ itemId: item.id })} > - {tItem("action.remove")} + {tItem("action.duplicate")} - - )} - - + )} + + )} + + <> + {(toggleOptions.length > 0 || visibleWidgetActions.length > 0 || isEditMode) && } + : } + onClick={handleRefetch} + disabled={isWidgetFetching} + > + + {tMenu("refresh")} + + + + } + onClick={openEditModal} + onFocus={preloadWidgetEditModal} + onPointerEnter={preloadWidgetEditModal} + disabled={hasSupportedIntegrations && isPending} + > + {tMenu("settings")} + + + + {isEditMode && ( + <> + + } + onClick={() => { + openConfirmModal({ + title: tItem("remove.title"), + children: tItem("remove.message"), + onConfirm: () => removeItem({ itemId: item.id }), + }); + }} + > + {tItem("action.remove")} + + + )} + ); }; interface WidgetQueryStatusProps { queryClient: QueryClient; - queryKey: QueryKey; + queryKeys: QueryKey[]; isFetching: boolean; t: TranslationFunction; } -const WidgetQueryStatus = ({ queryClient, queryKey, isFetching, t }: WidgetQueryStatusProps) => { - const queries = queryClient.getQueryCache().findAll({ queryKey }); +const WidgetQueryStatus = ({ queryClient, queryKeys, isFetching, t }: WidgetQueryStatusProps) => { + const queries = [...new Set(queryKeys.flatMap((queryKey) => queryClient.getQueryCache().findAll({ queryKey })))]; const timestamps = queries.map((query) => query.state.dataUpdatedAt).filter(Boolean); const latest = timestamps.length > 0 ? Math.max(...timestamps) : 0; const ageLabel = useTimeAgo(new Date(latest)); @@ -338,7 +391,8 @@ const WidgetQueryStatus = ({ queryClient, queryKey, isFetching, t }: WidgetQuery - {t("item.menu.status.error")} · {ageLabel} + {t("item.menu.status.error")} + {latest > 0 && ` · ${ageLabel}`} @@ -349,7 +403,8 @@ const WidgetQueryStatus = ({ queryClient, queryKey, isFetching, t }: WidgetQuery - {t("item.menu.status.success")} · {ageLabel} + {t("item.menu.status.success")} + {latest > 0 && ` · ${ageLabel}`} ); diff --git a/apps/nextjs/src/components/board/items/widget-hover-metadata.ts b/apps/nextjs/src/components/board/items/widget-hover-metadata.ts deleted file mode 100644 index 22a75c9b3..000000000 --- a/apps/nextjs/src/components/board/items/widget-hover-metadata.ts +++ /dev/null @@ -1,155 +0,0 @@ -import { objectEntries } from "@homarr/common"; -import type { IntegrationKind, WidgetKind } from "@homarr/definitions"; -import type { SettingsContextProps } from "@homarr/settings/creator"; -import type { WidgetOptionDefinition, WidgetOptionType } from "@homarr/widgets"; -import { reduceWidgetOptionsWithDefaultValues, widgetImports } from "@homarr/widgets"; - -import type { SectionItem } from "~/app/[locale]/boards/_types"; - -export type Translator = { - (key: string): string; -}; - -export interface HoverMetadataItem { - label: string; - value: string; -} - -const maxMetadataItems = 3; - -const skippedOptionTypes: Partial> = { - app: true, - anchorNote: true, - dynamicSelect: true, - sortableItemList: true, - multiReleasesRepositories: true, - umamiEventName: true, - umamiEventNames: true, - umamiWebsite: true, -}; - -const isEqualValue = (left: unknown, right: unknown) => JSON.stringify(left) === JSON.stringify(right); - -type OptionDefinitionWithRules = WidgetOptionDefinition & { - shouldHide?: (options: never, integrationKinds: IntegrationKind[]) => boolean; - options?: readonly { value: unknown; label: unknown }[]; -}; - -type OptionFormatter = (value: unknown, definition: OptionDefinitionWithRules, t: Translator) => string | null; - -const resolveSelectLabel = (value: unknown, definition: OptionDefinitionWithRules, t: Translator) => { - const match = definition.options?.find((option) => option.value === value); - const label = match?.label; - const fallbackLabel = () => String(value); - const labelResolvers: Record string> = { - function: () => (label as (translator: Translator) => string)(t), - string: () => label as string, - missing: fallbackLabel, - }; - const labelType = typeof label; - const resolveLabel: () => string = labelResolvers[labelType] ?? fallbackLabel; - return resolveLabel(); -}; - -const optionTypeFormatters: Partial> = { - switch: (value, _, t) => { - const switchLabels: Record = { - true: t("common.action.show"), - false: t("common.action.hide"), - }; - return switchLabels[String(Boolean(value))] ?? null; - }, - text: (value) => { - const text = String(value).trim(); - return text.length > 0 ? text : null; - }, - number: (value) => String(value), - slider: (value) => String(value), - select: (value, definition, t) => resolveSelectLabel(value, definition, t), - multiSelect: (value) => { - const items = value as unknown[]; - return items.length > 0 ? String(items.length) : null; - }, - multiText: (value) => { - const items = (value as string[]).filter((entry) => entry.trim().length > 0); - return items.length > 0 ? items.join(", ") : null; - }, - location: (value) => { - const location = value as { name?: string }; - const name = location.name?.trim() ?? ""; - return name.length > 0 ? name : null; - }, -}; - -const formatOptionValue = ( - type: WidgetOptionType, - value: unknown, - definition: OptionDefinitionWithRules, - t: Translator, -) => { - const formatter = optionTypeFormatters[type]; - return formatter?.(value, definition, t) ?? null; -}; - -const getOptionLabel = (kind: WidgetKind, key: string, t: Translator) => { - const labelKey = `widget.${kind}.option.${key}.label`; - const translated = t(labelKey); - return translated === labelKey ? key : translated; -}; - -export const collectWidgetHoverMetadata = ( - item: SectionItem, - settings: Pick, - integrationKinds: IntegrationKind[], - t: Translator, -): HoverMetadataItem[] => { - const { definition } = widgetImports[item.kind]; - const optionDefinitions = definition.createOptions(settings) as Record; - const resolvedOptions = reduceWidgetOptionsWithDefaultValues(item.kind, settings, item.options); - - return objectEntries(optionDefinitions) - .filter(([key, optionDefinition]) => { - const skip = skippedOptionTypes[optionDefinition.type]; - const hidden = optionDefinition.shouldHide?.(resolvedOptions as never, integrationKinds); - const unchanged = isEqualValue(resolvedOptions[key], optionDefinition.defaultValue); - return !skip && !hidden && !unchanged; - }) - .map(([key, optionDefinition]) => { - const formattedValue = formatOptionValue(optionDefinition.type, resolvedOptions[key], optionDefinition, t); - return { - label: getOptionLabel(item.kind, key, t), - value: formattedValue ?? "", - }; - }) - .filter((entry) => entry.value.length > 0) - .slice(0, maxMetadataItems); -}; - -interface DisplayNameSource { - matches: (item: SectionItem, options: Record) => boolean; - resolve: (item: SectionItem, options: Record) => string; -} - -const displayNameSources: DisplayNameSource[] = [ - { - matches: (item) => Boolean(item.advancedOptions.title?.trim()), - resolve: (item) => item.advancedOptions.title?.trim() ?? "", - }, - { - matches: (item, options) => - item.kind === "clock" && - Boolean(options.customTitleToggle) && - String(options.customTitle ?? "").trim().length > 0, - resolve: (_, options) => String(options.customTitle).trim(), - }, -]; - -export const resolveWidgetDisplayName = ( - item: SectionItem, - settings: Pick, - t: Translator, -) => { - const resolvedOptions = reduceWidgetOptionsWithDefaultValues(item.kind, settings, item.options); - const source = displayNameSources.find((entry) => entry.matches(item, resolvedOptions)); - return source?.resolve(item, resolvedOptions) ?? t(`widget.${item.kind}.name`); -}; diff --git a/apps/nextjs/src/components/board/items/widget-hover-overlay.tsx b/apps/nextjs/src/components/board/items/widget-hover-overlay.tsx index 6fcdc4a93..6529eed12 100644 --- a/apps/nextjs/src/components/board/items/widget-hover-overlay.tsx +++ b/apps/nextjs/src/components/board/items/widget-hover-overlay.tsx @@ -1,12 +1,13 @@ "use client"; +import { Suspense, use } from "react"; import { Avatar, Text, Tooltip } from "@mantine/core"; import type { RouterOutputs } from "@homarr/api"; import { useEditMode } from "@homarr/boards/edit-mode"; import { getIconUrl } from "@homarr/definitions"; import { useI18n } from "@homarr/translation/client"; -import { widgetImports } from "@homarr/widgets"; +import { loadWidgetDefinition } from "@homarr/widgets/manifest"; import type { SectionItem } from "~/app/[locale]/boards/_types"; import classes from "./widget-hover-overlay.module.css"; @@ -18,8 +19,19 @@ interface WidgetHoverOverlayProps { export const WidgetHoverOverlay = ({ item, integrations }: WidgetHoverOverlayProps) => { const [isEditMode] = useEditMode(); + + if (!isEditMode) return null; + + return ( + + + + ); +}; + +const LoadedWidgetHoverOverlay = ({ item, integrations }: WidgetHoverOverlayProps) => { const t = useI18n(); - const { definition } = widgetImports[item.kind]; + const definition = use(loadWidgetDefinition(item.kind)); const WidgetIcon = definition.icon; const displayName = item.advancedOptions.title?.trim() || t(`widget.${item.kind}.name`); @@ -28,10 +40,6 @@ export const WidgetHoverOverlay = ({ item, integrations }: WidgetHoverOverlayPro item.integrationIds.includes(integration.id), ); - if (!isEditMode) { - return null; - } - return (
diff --git a/apps/nextjs/src/components/board/items/widget-query-recovery.spec.ts b/apps/nextjs/src/components/board/items/widget-query-recovery.spec.ts new file mode 100644 index 000000000..dabb464f3 --- /dev/null +++ b/apps/nextjs/src/components/board/items/widget-query-recovery.spec.ts @@ -0,0 +1,63 @@ +import { QueryClient } from "@tanstack/react-query"; +import { describe, expect, test } from "vitest"; + +import { removeWidgetDataQueries } from "./widget-query-recovery"; + +describe("removeWidgetDataQueries", () => { + test("removes only the failed widget prefix and allows fresh data to be fetched", async () => { + const queryClient = new QueryClient(); + const crashedWidgetQueryKey = [["widget", "mediaServer", "getCurrentStreams"], { input: {} }]; + const sameWidgetQueryKey = [["widget", "mediaServer", "getLibraries"], { input: {} }]; + const unrelatedWidgetQueryKey = [["widget", "calendar", "getEvents"], { input: {} }]; + const appQueryKey = [["app", "byId"], { input: {} }]; + const unrelatedQueryKey = [["user", "me"], { input: {} }]; + + queryClient.setQueryData(crashedWidgetQueryKey, "stale"); + queryClient.setQueryData(sameWidgetQueryKey, "related"); + queryClient.setQueryData(unrelatedWidgetQueryKey, "other-widget"); + queryClient.setQueryData(appQueryKey, "app"); + queryClient.setQueryData(unrelatedQueryKey, "unrelated"); + + removeWidgetDataQueries(queryClient, [[["widget", "mediaServer"]]]); + + expect(queryClient.getQueryData(crashedWidgetQueryKey)).toBeUndefined(); + expect(queryClient.getQueryData(sameWidgetQueryKey)).toBeUndefined(); + expect(queryClient.getQueryData(unrelatedWidgetQueryKey)).toBe("other-widget"); + expect(queryClient.getQueryData(appQueryKey)).toBe("app"); + expect(queryClient.getQueryData(unrelatedQueryKey)).toBe("unrelated"); + await expect( + queryClient.fetchQuery({ queryKey: crashedWidgetQueryKey, queryFn: () => Promise.resolve("fresh") }), + ).resolves.toBe("fresh"); + }); + + test("uses an explicit supporting query prefix without purging other supporting data", () => { + const queryClient = new QueryClient(); + const dockerQueryKey = [["docker", "getContainers"], { input: {} }]; + const appQueryKey = [["app", "byId"], { input: {} }]; + const unrelatedQueryKey = [["user", "me"], { input: {} }]; + + queryClient.setQueryData(dockerQueryKey, "stale"); + queryClient.setQueryData(appQueryKey, "stale"); + queryClient.setQueryData(unrelatedQueryKey, "unrelated"); + + removeWidgetDataQueries(queryClient, [[["docker", "getContainers"]]]); + + expect(queryClient.getQueryData(dockerQueryKey)).toBeUndefined(); + expect(queryClient.getQueryData(appQueryKey)).toBe("stale"); + expect(queryClient.getQueryData(unrelatedQueryKey)).toBe("unrelated"); + }); + + test("uses the definition query prefix when the widget kind and query path differ", () => { + const queryClient = new QueryClient(); + const crashedWidgetQueryKey = [["widget", "anchorNotes", "getNote"], { input: {} }]; + const unrelatedQueryKey = [["user", "me"], { input: {} }]; + + queryClient.setQueryData(crashedWidgetQueryKey, "stale"); + queryClient.setQueryData(unrelatedQueryKey, "unrelated"); + + removeWidgetDataQueries(queryClient, [[["widget", "anchorNotes"]]]); + + expect(queryClient.getQueryData(crashedWidgetQueryKey)).toBeUndefined(); + expect(queryClient.getQueryData(unrelatedQueryKey)).toBe("unrelated"); + }); +}); diff --git a/apps/nextjs/src/components/board/items/widget-query-recovery.ts b/apps/nextjs/src/components/board/items/widget-query-recovery.ts new file mode 100644 index 000000000..9bb0b81c5 --- /dev/null +++ b/apps/nextjs/src/components/board/items/widget-query-recovery.ts @@ -0,0 +1,5 @@ +import type { QueryClient, QueryKey } from "@tanstack/react-query"; + +export const removeWidgetDataQueries = (queryClient: QueryClient, queryKeys: QueryKey[]) => { + queryKeys.forEach((queryKey) => queryClient.removeQueries({ queryKey })); +}; diff --git a/apps/nextjs/src/components/board/sections/category/category-menu-actions.tsx b/apps/nextjs/src/components/board/sections/category/category-menu-actions.tsx index 906abdea6..894bcbad6 100644 --- a/apps/nextjs/src/components/board/sections/category/category-menu-actions.tsx +++ b/apps/nextjs/src/components/board/sections/category/category-menu-actions.tsx @@ -105,12 +105,14 @@ export const useCategoryMenuActions = (category: CategorySection) => { const settings = useSettings(); const openAllInNewTabs = useCallback(async () => { const currentLayoutId = getCurrentLayout(board); - const appIds = filterByItemKind( - board.items.filter( - (item) => item.layouts.find((layout) => layout.layoutId === currentLayoutId)?.sectionId === category.id, - ), - settings, - "app", + const appIds = ( + await filterByItemKind( + board.items.filter( + (item) => item.layouts.find((layout) => layout.layoutId === currentLayoutId)?.sectionId === category.id, + ), + settings, + "app", + ) ).map((item) => { return item.options.appId; }); diff --git a/apps/nextjs/src/components/board/sections/category/filter.ts b/apps/nextjs/src/components/board/sections/category/filter.ts index 72bb4443f..3866a0075 100644 --- a/apps/nextjs/src/components/board/sections/category/filter.ts +++ b/apps/nextjs/src/components/board/sections/category/filter.ts @@ -1,21 +1,23 @@ import type { WidgetKind } from "@homarr/definitions"; import type { SettingsContextProps } from "@homarr/settings/creator"; -import type { WidgetComponentProps } from "@homarr/widgets"; -import { reduceWidgetOptionsWithDefaultValues } from "@homarr/widgets"; +import type { WidgetComponentProps } from "@homarr/widgets/definition"; +import { loadWidgetDefinition, reduceWidgetOptionsWithDefinition } from "@homarr/widgets/manifest"; import type { Item } from "~/app/[locale]/boards/_types"; -export const filterByItemKind = ( +export const filterByItemKind = async ( items: Item[], settings: SettingsContextProps, kind: TKind, ) => { + const definition = await loadWidgetDefinition(kind); + return items .filter((item) => item.kind === kind) .map((item) => ({ ...item, - options: reduceWidgetOptionsWithDefaultValues( - kind, + options: reduceWidgetOptionsWithDefinition( + definition, settings, item.options, ) as WidgetComponentProps["options"], diff --git a/apps/nextjs/src/components/board/sections/content.tsx b/apps/nextjs/src/components/board/sections/content.tsx index ced30c796..aa3b5adc2 100644 --- a/apps/nextjs/src/components/board/sections/content.tsx +++ b/apps/nextjs/src/components/board/sections/content.tsx @@ -4,6 +4,8 @@ import type { GridItemHTMLElement } from "@homarr/gridstack"; import type { RouterOutputs } from "@homarr/api"; import { clientApi } from "@homarr/api/client"; +import { useSession } from "@homarr/auth/client"; +import { useEditMode } from "@homarr/boards/edit-mode"; import type { DynamicSectionItem, SectionItem } from "~/app/[locale]/boards/_types"; import { BoardItemContent } from "../items/item-content"; @@ -15,7 +17,11 @@ import { useSectionItems } from "./use-section-items"; export const SectionContent = () => { const { innerSections, items, refs } = useSectionContext(); - const { data: integrations } = clientApi.integration.all.useQuery(); + const { data: session } = useSession(); + const [isEditMode] = useEditMode(); + const { data: integrations } = clientApi.integration.all.useQuery(undefined, { + enabled: Boolean(session) && isEditMode, + }); /** * IMPORTANT: THE ORDER OF THE BELOW ITEMS HAS TO MATCH THE ORDER OF diff --git a/apps/nextjs/src/components/integration/integration-grid-shared.ts b/apps/nextjs/src/components/integration/integration-grid-shared.ts index 7451af1e7..03056c130 100644 --- a/apps/nextjs/src/components/integration/integration-grid-shared.ts +++ b/apps/nextjs/src/components/integration/integration-grid-shared.ts @@ -1,4 +1,3 @@ -import { objectEntries } from "@homarr/common"; import type { IntegrationKind, WidgetKind } from "@homarr/definitions"; import { featuredIntegrations, @@ -6,30 +5,8 @@ import { hiddenFromOnboarding, integrationDefs, integrationKinds, + getWidgetKindsForIntegration, } from "@homarr/definitions"; -import { widgetImports } from "@homarr/widgets"; - -const buildWidgetsByIntegration = () => { - const map = Object.fromEntries(integrationKinds.map((kind) => [kind, [] as WidgetKind[]])) as Record< - IntegrationKind, - WidgetKind[] - >; - - for (const [widgetKind, widget] of objectEntries(widgetImports)) { - const supported = - "supportedIntegrations" in widget.definition - ? (widget.definition.supportedIntegrations as IntegrationKind[]) - : []; - for (const kind of supported) { - if (kind in map) { - map[kind].push(widgetKind); - } - } - } - return map; -}; - -export const widgetsByIntegration = buildWidgetsByIntegration(); export const categoryTranslationKeys: Record = { dnsHole: "integration.category.dnsHole", @@ -70,11 +47,11 @@ export interface IntegrationGridItem { export const buildSortedIntegrations = ( options: { enableMockIntegration?: boolean; onboarding?: boolean } = {}, -): IntegrationGridItem[] => - integrationKinds +): IntegrationGridItem[] => { + return integrationKinds .filter((kind) => { if (options.onboarding && hiddenFromOnboarding.has(kind)) return false; - if (options.onboarding && widgetsByIntegration[kind].length === 0) return false; + if (options.onboarding && getWidgetKindsForIntegration(kind).length === 0) return false; if (!options.enableMockIntegration && kind === "mock") return false; return true; }) @@ -82,9 +59,9 @@ export const buildSortedIntegrations = ( kind, name: getIntegrationName(kind), categories: [...new Set(integrationDefs[kind].category.flat())] as string[], - widgets: widgetsByIntegration[kind], + widgets: getWidgetKindsForIntegration(kind), })) - .sort((left, right) => { + .toSorted((left, right) => { const leftIdx = featuredIntegrations.indexOf(left.kind); const rightIdx = featuredIntegrations.indexOf(right.kind); if (leftIdx !== -1 && rightIdx !== -1) return leftIdx - rightIdx; @@ -92,6 +69,7 @@ export const buildSortedIntegrations = ( if (rightIdx !== -1) return 1; return right.widgets.length - left.widgets.length || left.name.localeCompare(right.name); }); +}; export const filterIntegrations = (items: IntegrationGridItem[], search: string) => items.filter((i) => i.name.toLowerCase().includes(search.toLowerCase().trim())); diff --git a/apps/nextjs/src/components/layout/header.tsx b/apps/nextjs/src/components/layout/header.tsx index e9d93f7fc..2c46608c0 100644 --- a/apps/nextjs/src/components/layout/header.tsx +++ b/apps/nextjs/src/components/layout/header.tsx @@ -1,7 +1,6 @@ import type { ReactNode } from "react"; import { AppShellHeader, Group, UnstyledButton } from "@mantine/core"; -import { Spotlight } from "@homarr/spotlight"; import { Link } from "@homarr/ui"; import { ClientBurger } from "./header/burger"; @@ -9,6 +8,7 @@ import { DesktopSearchInput, MobileSearchButton } from "./header/search"; import { TourTarget } from "./header/tour-target"; import { UserButton } from "./header/user"; import { HomarrLogoWithTitle } from "./logo/homarr-logo"; +import { LazySpotlight } from "./header/lazy-spotlight"; interface Props { logo?: ReactNode; @@ -37,7 +37,7 @@ export const MainHeader = ({ logo, actions, hasNavigation = true }: Props) => { - + ); }; diff --git a/apps/nextjs/src/components/layout/header/button.tsx b/apps/nextjs/src/components/layout/header/button.tsx index e8d858b6a..27d48445d 100644 --- a/apps/nextjs/src/components/layout/header/button.tsx +++ b/apps/nextjs/src/components/layout/header/button.tsx @@ -14,6 +14,9 @@ type HeaderButtonProps = ( } ) & { children: ReactNode; + onFocus?: () => void; + onPointerDown?: () => void; + onPointerEnter?: () => void; } & Partial; const headerButtonActionIconProps: ActionIconProps = { diff --git a/apps/nextjs/src/components/layout/header/lazy-spotlight.tsx b/apps/nextjs/src/components/layout/header/lazy-spotlight.tsx new file mode 100644 index 000000000..958c0d537 --- /dev/null +++ b/apps/nextjs/src/components/layout/header/lazy-spotlight.tsx @@ -0,0 +1,89 @@ +"use client"; + +import { useEffect, useState } from "react"; +import dynamic from "next/dynamic"; +import { Center, Loader, Paper } from "@mantine/core"; +import { useHotkeys } from "@mantine/hooks"; + +import { hotkeys } from "@homarr/definitions"; +import { hasPendingSpotlightOpen, openSpotlight, spotlightOpenEvent } from "@homarr/spotlight/open"; + +const loadSpotlight = () => import("@homarr/spotlight/component"); +const SpotlightLoading = () => ( +
+ + + +
+); +const Spotlight = dynamic(() => loadSpotlight().then(({ Spotlight: Component }) => Component), { + ssr: false, + loading: SpotlightLoading, +}); + +export const preloadSpotlight = () => void loadSpotlight().catch(() => undefined); + +export const LazySpotlight = () => { + const [isMounted, setIsMounted] = useState(false); + const [isPreloaded, setIsPreloaded] = useState(false); + + useHotkeys( + isMounted + ? [] + : [ + [ + hotkeys.openSpotlight, + () => { + setIsMounted(true); + openSpotlight(); + }, + ], + ], + ); + + useEffect(() => { + let isActive = true; + const mount = () => setIsMounted(true); + window.addEventListener(spotlightOpenEvent, mount); + if (hasPendingSpotlightOpen()) mount(); + + const preload = () => { + void loadSpotlight().then( + () => { + if (isActive) setIsPreloaded(true); + }, + () => undefined, + ); + }; + let idleId: number | undefined; + const delayId = window.setTimeout(() => { + idleId = window.requestIdleCallback?.(preload, { timeout: 1_000 }); + if (idleId === undefined) preload(); + }, 1_000); + return () => { + isActive = false; + window.removeEventListener(spotlightOpenEvent, mount); + window.clearTimeout(delayId); + if (idleId !== undefined) window.cancelIdleCallback?.(idleId); + }; + }, []); + + return isMounted || isPreloaded ? ( + <> + {isPreloaded &&