Compare commits
77
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fa319b5696 | ||
|
|
7859faaa1d | ||
|
|
bff6ec735e | ||
|
|
1fbe844598 | ||
|
|
365ef7f02b | ||
|
|
2b1a03849d | ||
|
|
4c243bc374 | ||
|
|
ecd02f73c0 | ||
|
|
cd23b36f3c | ||
|
|
f7f29eade9 | ||
|
|
32a51c1e60 | ||
|
|
da32acb4f0 | ||
|
|
1da13d9bb3 | ||
|
|
cb0fec088f | ||
|
|
a2fe8dd461 | ||
|
|
b6dfcd69ba | ||
|
|
a38b0ef71d | ||
|
|
2cf3027eeb | ||
|
|
fa18f6fc71 | ||
|
|
331925c197 | ||
|
|
0e51f745d3 | ||
|
|
d6f720d0e6 | ||
|
|
5324bf12d4 | ||
|
|
2922a02ecf | ||
|
|
87cdcf28e0 | ||
|
|
441b181e79 | ||
|
|
eba639b59c | ||
|
|
da36283ad1 | ||
|
|
c050368782 | ||
|
|
82b50f0fd7 | ||
|
|
a0149d5894 | ||
|
|
8579fcbfc2 | ||
|
|
b6f3aba23e | ||
|
|
31e218295d | ||
|
|
aca3b5d861 | ||
|
|
a7a2f0223a | ||
|
|
7773fdc10b | ||
|
|
7019a1d0a4 | ||
|
|
2d535caeac | ||
|
|
2013fbe80d | ||
|
|
219ef58926 | ||
|
|
97195bc463 | ||
|
|
73a742154e | ||
|
|
993a267e0c | ||
|
|
f1c49d1fbf | ||
|
|
95c66c61eb | ||
|
|
ec2f8c6c2c | ||
|
|
05ba57c2fa | ||
|
|
ddbf05524b | ||
|
|
df438b4cd2 | ||
|
|
a1f91eb385 | ||
|
|
8d5ffb2dd9 | ||
|
|
56e6b23af6 | ||
|
|
5488bf4d18 | ||
|
|
621ea07549 | ||
|
|
f64c01456b | ||
|
|
56198c3f02 | ||
|
|
ca1bb6f341 | ||
|
|
bf6b6663cd | ||
|
|
5c2d86767f | ||
|
|
7c75642d4d | ||
|
|
7652eb8c1c | ||
|
|
e761c10f32 | ||
|
|
2513e8454f | ||
|
|
f6137b2dcc | ||
|
|
425dd90871 | ||
|
|
527e911eef | ||
|
|
ff2cdecc86 | ||
|
|
742bde1d27 | ||
|
|
64febbce12 | ||
|
|
98bb1aecf8 | ||
|
|
06c489bd48 | ||
|
|
464d98e124 | ||
|
|
987c9ade18 | ||
|
|
6141d505e4 | ||
|
|
6178843198 | ||
|
|
a7006d746e |
No files matched your search
@@ -1,6 +1,6 @@
|
||||
---
|
||||
description: Comprehensive Homarr codebase architecture and conventions reference
|
||||
alwaysApply: true
|
||||
name: codebase-context
|
||||
description: Comprehensive Homarr codebase architecture and conventions reference. Use when orienting in the monorepo, understanding package dependency layers, the three runtime services and ports, tRPC router structure, multi-database support, widget/integration/cron system architecture, routing, auth providers, env vars, or key patterns before writing code.
|
||||
---
|
||||
|
||||
# Homarr Codebase Context
|
||||
@@ -27,7 +27,7 @@ Homarr is an open-source, self-hosted dashboard for managing homelab services. I
|
||||
|
||||
## Repository Structure
|
||||
|
||||
```
|
||||
```text
|
||||
homarr/
|
||||
├── apps/
|
||||
│ ├── nextjs/ # Main Next.js application (port 3000)
|
||||
@@ -66,8 +66,6 @@ homarr/
|
||||
│ ├── analytics/ # Server-side analytics (Umami)
|
||||
│ ├── server-settings/ # Server setting keys/types
|
||||
│ ├── settings/ # User-facing settings UI context
|
||||
│ ├── old-import/ # Legacy Homarr import
|
||||
│ ├── old-schema/ # Legacy Homarr zod schemas
|
||||
│ └── cli/ # Node CLI for ops (brocli)
|
||||
├── tooling/
|
||||
│ ├── typescript/ # Base tsconfig
|
||||
@@ -79,11 +77,11 @@ homarr/
|
||||
|
||||
## Three Runtime Services
|
||||
|
||||
| Service | Port | Role |
|
||||
|---------|------|------|
|
||||
| Next.js | 3000 | Main web app (SSR + API routes) |
|
||||
| WebSocket | 3001 | tRPC subscriptions via `ws` |
|
||||
| Tasks | 3002 | Cron job runner + management API via Fastify |
|
||||
| Service | Port | Role |
|
||||
| --------- | ---- | -------------------------------------------- |
|
||||
| Next.js | 3000 | Main web app (SSR + API routes) |
|
||||
| WebSocket | 3001 | tRPC subscriptions via `ws` |
|
||||
| Tasks | 3002 | Cron job runner + management API via Fastify |
|
||||
|
||||
In Docker, nginx listens on **7575** and proxies to all three. The tasks service authenticates via `CRON_JOB_API_KEY` header.
|
||||
|
||||
@@ -112,6 +110,7 @@ In Docker, nginx listens on **7575** and proxies to all three. The tasks service
|
||||
### Multi-Database Support
|
||||
|
||||
Drizzle schemas exist in three parallel implementations:
|
||||
|
||||
- `packages/db/schema/sqlite.ts`
|
||||
- `packages/db/schema/mysql.ts`
|
||||
- `packages/db/schema/postgresql.ts`
|
||||
@@ -121,6 +120,7 @@ Selected at runtime via `DB_DRIVER` env (`better-sqlite3` | `mysql2` | `node-pos
|
||||
### Widget System
|
||||
|
||||
Widgets are registered in `packages/widgets/src/index.tsx` as `widgetImports` (must satisfy `Record<WidgetKind, ...>`). Each widget:
|
||||
|
||||
- Has a `definition` created via `createWidgetDefinition(kind, { icon, createOptions, supportedIntegrations? })`
|
||||
- Uses `optionsBuilder.from(factory => ({ ... }))` for typed options
|
||||
- Loads dynamically via `next/dynamic`
|
||||
@@ -133,6 +133,7 @@ Integrations are defined in `packages/definitions/src/integration.ts` (`integrat
|
||||
### Cron Job System
|
||||
|
||||
Jobs defined in `packages/cron-jobs/src/jobs/`. Each job:
|
||||
|
||||
- Uses `createCronJob` with a cron expression
|
||||
- Publishes results to Redis channels
|
||||
- Widget subscriptions consume those channels via tRPC subscriptions
|
||||
@@ -160,6 +161,7 @@ Configured via `AUTH_PROVIDERS` env. Supports: `credentials` (local), `ldap`, `o
|
||||
## Environment Variables
|
||||
|
||||
Key env vars (from `.env.example`):
|
||||
|
||||
- `DB_DRIVER`: `better-sqlite3` | `mysql2` | `node-postgres`
|
||||
- `DB_URL`: Database connection string
|
||||
- `AUTH_SECRET`: NextAuth secret
|
||||
@@ -191,16 +193,3 @@ Key env vars (from `.env.example`):
|
||||
- Drag-and-drop via `@dnd-kit/*`
|
||||
- `typescript.ignoreBuildErrors: true` in next.config (types checked separately via `typecheck`)
|
||||
- `serverExternalPackages`: `dockerode`, `isomorphic-dompurify`, `jsdom`
|
||||
|
||||
## Documentation (`apps/docs`)
|
||||
|
||||
Docusaurus 3 site at `apps/docs/`, package name `@homarr/docs`.
|
||||
|
||||
- Run with `pnpm dev:docs` from root
|
||||
- Build with `pnpm turbo build --filter=@homarr/docs`
|
||||
- Uses Tailwind CSS 3 for custom pages (not Mantine)
|
||||
- Imports `@homarr/definitions` for type-safe integration/widget metadata
|
||||
- When adding or modifying widgets, integrations, cron jobs, API routes, env vars, or user-facing features, you MUST also update corresponding docs in `apps/docs/docs/`
|
||||
- Integration docs: `apps/docs/docs/integrations/<slug>/index.mdx` + `index.ts`
|
||||
- Widget docs: `apps/docs/docs/widgets/<slug>/index.mdx` + `index.ts`
|
||||
- Strict link checking: broken links/anchors cause build failure
|
||||
+142
-73
@@ -1,3 +1,8 @@
|
||||
---
|
||||
name: datatable-migration
|
||||
description: Playbook for migrating tables from mantine-react-table (MRT) to mantine-datatable. Use when converting any table component from mantine-react-table to mantine-datatable, working on DataTable column/sort/expansion/context-menu code, or implementing responsive column visibility, column persistence, or transparent tables in widgets.
|
||||
---
|
||||
|
||||
# Migrating Tables from mantine-react-table to mantine-datatable
|
||||
|
||||
This document captures every pattern, fix, and decision from the downloads widget rework (PR #6430). Use it as a playbook when porting other tables.
|
||||
@@ -7,10 +12,12 @@ This document captures every pattern, fix, and decision from the downloads widge
|
||||
Current `mantine-react-table` consumers that could be migrated:
|
||||
|
||||
**Widgets (in `packages/widgets/`):**
|
||||
|
||||
- `docker/component.tsx` — Docker container table
|
||||
- `media-server/component.tsx` — Media server sessions
|
||||
|
||||
**Admin pages (in `apps/nextjs/src/app/[locale]/manage/`):**
|
||||
|
||||
- `tools/docker/docker-table.tsx`
|
||||
- `tools/tasks/_components/tasks-table.tsx`
|
||||
- `tools/api/components/api-keys.tsx`
|
||||
@@ -19,12 +26,12 @@ Current `mantine-react-table` consumers that could be migrated:
|
||||
- `tools/kubernetes/` — 8 Kubernetes resource tables
|
||||
|
||||
**Modals:**
|
||||
|
||||
- `packages/modals-collection/src/search-engines/request-media-modal.tsx`
|
||||
|
||||
**Shared hook to remove once all migrated:**
|
||||
- `packages/ui/src/hooks/use-translated-mantine-react-table.ts`
|
||||
|
||||
---
|
||||
- `packages/ui/src/hooks/use-translated-mantine-react-table.ts`
|
||||
|
||||
## 1. Core Migration: mantine-react-table → mantine-datatable
|
||||
|
||||
@@ -67,6 +74,7 @@ const columns: DataTableColumn<MyRow>[] = [
|
||||
```
|
||||
|
||||
Key differences:
|
||||
|
||||
- `accessorKey` → `accessor`
|
||||
- `header` → `title`
|
||||
- `size` → `width`
|
||||
@@ -118,8 +126,6 @@ const sortedData = useMemo(() => {
|
||||
<DataTable records={sortedData} sortStatus={sortStatus} onSortStatusChange={setSortStatus} />
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. Making Tables Transparent (Widget Context)
|
||||
|
||||
Widgets sit inside board cards that have user-controlled opacity. The table must be fully transparent so the card background shows through. mantine-datatable defaults to `var(--mantine-color-body)` which is opaque.
|
||||
@@ -144,13 +150,21 @@ Create a CSS file alongside the component:
|
||||
|
||||
/* Hover: translucent instead of opaque */
|
||||
.my-table .mantine-datatable-table tbody tr:hover {
|
||||
background-color: color-mix(in srgb, var(--mantine-color-default-hover) 40%, transparent) !important;
|
||||
background-color: color-mix(
|
||||
in srgb,
|
||||
var(--mantine-color-default-hover) 40%,
|
||||
transparent
|
||||
) !important;
|
||||
}
|
||||
|
||||
/* Headers: frosted glass effect */
|
||||
.my-table th {
|
||||
white-space: nowrap;
|
||||
background-color: color-mix(in srgb, var(--mantine-color-body) 60%, transparent) !important;
|
||||
background-color: color-mix(
|
||||
in srgb,
|
||||
var(--mantine-color-body) 60%,
|
||||
transparent
|
||||
) !important;
|
||||
backdrop-filter: blur(8px);
|
||||
}
|
||||
|
||||
@@ -172,14 +186,16 @@ Create a CSS file alongside the component:
|
||||
|
||||
/* Row expansion area */
|
||||
.my-table .mantine-datatable-row-expansion-cell {
|
||||
background-color: color-mix(in srgb, var(--mantine-color-body) 40%, transparent) !important;
|
||||
background-color: color-mix(
|
||||
in srgb,
|
||||
var(--mantine-color-body) 40%,
|
||||
transparent
|
||||
) !important;
|
||||
}
|
||||
```
|
||||
|
||||
Import it: `import "./styles.css";` and apply via `className="my-table"`.
|
||||
|
||||
---
|
||||
|
||||
## 3. Responsive Column Visibility
|
||||
|
||||
Use a lookup table of width breakpoints instead of media queries. The widget receives `width` as a prop.
|
||||
@@ -195,11 +211,38 @@ interface SizeConfig {
|
||||
}
|
||||
|
||||
const SIZE_BREAKPOINTS: { maxWidth: number; config: SizeConfig }[] = [
|
||||
{ maxWidth: 300, config: { fontSize: "xs", iconSize: 12, cellPadding: 2, showSpeedColumns: false, showTimeColumn: false, showStateColumn: false } },
|
||||
{ maxWidth: 500, config: { fontSize: "xs", iconSize: 14, cellPadding: 4, showSpeedColumns: false, showTimeColumn: true, showStateColumn: false } },
|
||||
{
|
||||
maxWidth: 300,
|
||||
config: {
|
||||
fontSize: "xs",
|
||||
iconSize: 12,
|
||||
cellPadding: 2,
|
||||
showSpeedColumns: false,
|
||||
showTimeColumn: false,
|
||||
showStateColumn: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
maxWidth: 500,
|
||||
config: {
|
||||
fontSize: "xs",
|
||||
iconSize: 14,
|
||||
cellPadding: 4,
|
||||
showSpeedColumns: false,
|
||||
showTimeColumn: true,
|
||||
showStateColumn: false,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const DEFAULT_SIZE_CONFIG: SizeConfig = { fontSize: "sm", iconSize: 14, cellPadding: 4, showSpeedColumns: true, showTimeColumn: true, showStateColumn: true };
|
||||
const DEFAULT_SIZE_CONFIG: SizeConfig = {
|
||||
fontSize: "sm",
|
||||
iconSize: 14,
|
||||
cellPadding: 4,
|
||||
showSpeedColumns: true,
|
||||
showTimeColumn: true,
|
||||
showStateColumn: true,
|
||||
};
|
||||
|
||||
function getSizeConfig(width: number): SizeConfig {
|
||||
for (const { maxWidth, config } of SIZE_BREAKPOINTS) {
|
||||
@@ -215,7 +258,9 @@ const size = useMemo(() => getSizeConfig(width), [width]);
|
||||
Then use a visibility check lookup table for columns:
|
||||
|
||||
```typescript
|
||||
const columnVisibilityChecks: Partial<Record<string, (ctx: ColumnContext) => boolean>> = {
|
||||
const columnVisibilityChecks: Partial<
|
||||
Record<string, (ctx: ColumnContext) => boolean>
|
||||
> = {
|
||||
upSpeed: (ctx) => ctx.hasTorrents && ctx.size.showSpeedColumns,
|
||||
downSpeed: (ctx) => ctx.size.showSpeedColumns,
|
||||
time: (ctx) => ctx.size.showTimeColumn,
|
||||
@@ -230,8 +275,6 @@ function shouldIncludeColumn(accessor: string, ctx: ColumnContext): boolean {
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Column Persistence (Drag/Resize → Server)
|
||||
|
||||
`useDataTableColumns` stores column order and widths in localStorage by default. For widgets, this must persist server-side so it works across devices.
|
||||
@@ -263,15 +306,6 @@ Even hidden options need translation keys or the translation test fails:
|
||||
"widget.mywidget.option.columnWidths.label": "Column widths"
|
||||
```
|
||||
|
||||
### Old-import mappings
|
||||
|
||||
Add to `packages/old-import/src/widgets/options.ts`:
|
||||
|
||||
```typescript
|
||||
columnOrder: () => undefined,
|
||||
columnWidths: () => undefined,
|
||||
```
|
||||
|
||||
### Dynamic storeKey
|
||||
|
||||
Use a key that includes `itemId` and sorted column names. This prevents cross-widget localStorage bleeding:
|
||||
@@ -285,8 +319,13 @@ const storeKey = `mytable-${itemId ?? "preview"}-${[...options.columns].toSorted
|
||||
On mount, seed `useDataTableColumns` with server-persisted values. Use a `hydrated` ref to prevent the persist effect from immediately writing defaults back:
|
||||
|
||||
```typescript
|
||||
const { effectiveColumns, columnsOrder, columnsWidth, setColumnsOrder, setMultipleColumnWidths } =
|
||||
useDataTableColumns<MyRow>({ key: storeKey, columns });
|
||||
const {
|
||||
effectiveColumns,
|
||||
columnsOrder,
|
||||
columnsWidth,
|
||||
setColumnsOrder,
|
||||
setMultipleColumnWidths,
|
||||
} = useDataTableColumns<MyRow>({ key: storeKey, columns });
|
||||
|
||||
const lastStoreKey = useRef(storeKey);
|
||||
const hydrated = useRef(false);
|
||||
@@ -300,11 +339,22 @@ useEffect(() => {
|
||||
if (savedOrder.length > 0) setColumnsOrder(savedOrder);
|
||||
if (Object.keys(savedWidths).length > 0) {
|
||||
setMultipleColumnWidths(
|
||||
Object.entries(savedWidths).map(([accessor, w]) => ({ accessor, width: w })),
|
||||
Object.entries(savedWidths).map(([accessor, w]) => ({
|
||||
accessor,
|
||||
width: w,
|
||||
})),
|
||||
);
|
||||
}
|
||||
requestAnimationFrame(() => { hydrated.current = true; });
|
||||
}, [storeKey, savedOrder, savedWidths, setColumnsOrder, setMultipleColumnWidths]);
|
||||
requestAnimationFrame(() => {
|
||||
hydrated.current = true;
|
||||
});
|
||||
}, [
|
||||
storeKey,
|
||||
savedOrder,
|
||||
savedWidths,
|
||||
setColumnsOrder,
|
||||
setMultipleColumnWidths,
|
||||
]);
|
||||
```
|
||||
|
||||
### Persist localStorage → server
|
||||
@@ -317,22 +367,26 @@ const prevWidths = useRef(columnsWidth);
|
||||
|
||||
useEffect(() => {
|
||||
if (!hydrated.current) return;
|
||||
const orderChanged = JSON.stringify(columnsOrder) !== JSON.stringify(prevOrder.current);
|
||||
const widthsChanged = JSON.stringify(columnsWidth) !== JSON.stringify(prevWidths.current);
|
||||
const orderChanged =
|
||||
JSON.stringify(columnsOrder) !== JSON.stringify(prevOrder.current);
|
||||
const widthsChanged =
|
||||
JSON.stringify(columnsWidth) !== JSON.stringify(prevWidths.current);
|
||||
prevOrder.current = columnsOrder;
|
||||
prevWidths.current = columnsWidth;
|
||||
|
||||
if (!orderChanged && !widthsChanged) return;
|
||||
|
||||
if (orderChanged) persistOption({ columnOrder: JSON.stringify(columnsOrder) });
|
||||
if (orderChanged)
|
||||
persistOption({ columnOrder: JSON.stringify(columnsOrder) });
|
||||
if (widthsChanged) {
|
||||
const widthMap: Record<string, number> = {};
|
||||
for (const entry of columnsWidth) {
|
||||
const key = Object.keys(entry)[0];
|
||||
if (!key) continue;
|
||||
const w = entry[key as keyof typeof entry];
|
||||
if (typeof w === "number") widthMap[key] = w;
|
||||
else if (typeof w === "string" && w.endsWith("px")) widthMap[key] = parseInt(w, 10);
|
||||
const v = entry[key as keyof typeof entry];
|
||||
if (typeof v === "number") widthMap[key] = v;
|
||||
else if (typeof v === "string" && v.endsWith("px"))
|
||||
widthMap[key] = parseInt(v, 10);
|
||||
}
|
||||
persistOption({ columnWidths: JSON.stringify(widthMap) });
|
||||
}
|
||||
@@ -341,8 +395,6 @@ useEffect(() => {
|
||||
|
||||
The `persistOption` helper calls both `setOptions` (local state) and `saveItemOptions` (server mutation).
|
||||
|
||||
---
|
||||
|
||||
## 5. Context Menu (Right-Click)
|
||||
|
||||
Do NOT install `mantine-contextmenu`. Use Mantine's `Menu` + `Portal` directly:
|
||||
@@ -374,15 +426,41 @@ const handleContextMenu = useCallback(
|
||||
The menu component uses `Portal` for correct z-index:
|
||||
|
||||
```tsx
|
||||
function RowContextMenu({ state, onClose }: { state: ContextMenuState; onClose: () => void }) {
|
||||
function RowContextMenu({
|
||||
state,
|
||||
onClose,
|
||||
}: {
|
||||
state: ContextMenuState;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
return (
|
||||
<Portal>
|
||||
<Menu opened onClose={onClose} closeOnItemClick={false} position="right-start" offset={0}>
|
||||
<Menu
|
||||
opened
|
||||
onClose={onClose}
|
||||
closeOnItemClick={false}
|
||||
position="right-start"
|
||||
offset={0}
|
||||
>
|
||||
<Menu.Target>
|
||||
<Box style={{ position: "fixed", left: state.x, top: state.y, width: 0, height: 0 }} />
|
||||
<Box
|
||||
style={{
|
||||
position: "fixed",
|
||||
left: state.x,
|
||||
top: state.y,
|
||||
width: 0,
|
||||
height: 0,
|
||||
}}
|
||||
/>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown>
|
||||
<Menu.Item onClick={() => { /* action */ onClose(); }}>Action</Menu.Item>
|
||||
<Menu.Item
|
||||
onClick={() => {
|
||||
/* action */ onClose();
|
||||
}}
|
||||
>
|
||||
Action
|
||||
</Menu.Item>
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
</Portal>
|
||||
@@ -392,8 +470,6 @@ function RowContextMenu({ state, onClose }: { state: ContextMenuState; onClose:
|
||||
|
||||
**Key fix:** Use `closeOnItemClick={false}` if any menu item needs a two-step confirmation (like delete → confirm). Close the menu on scroll: `onScroll={() => { if (contextMenu) closeContextMenu(); }}`.
|
||||
|
||||
---
|
||||
|
||||
## 6. Row Expansion
|
||||
|
||||
```typescript
|
||||
@@ -413,8 +489,6 @@ function RowContextMenu({ state, onClose }: { state: ContextMenuState; onClose:
|
||||
|
||||
**Critical:** Do NOT set `onRowClick` when using `rowExpansion` with `trigger: "click"`. They conflict and the expansion won't work.
|
||||
|
||||
---
|
||||
|
||||
## 7. Common Pitfalls & Fixes
|
||||
|
||||
### Status indicator dots
|
||||
@@ -475,8 +549,11 @@ Always wrap in try/catch:
|
||||
```typescript
|
||||
function parseJsonOption<T>(value: string | undefined, fallback: T): T {
|
||||
if (!value) return fallback;
|
||||
try { return JSON.parse(value) as T; }
|
||||
catch { return fallback; }
|
||||
try {
|
||||
return JSON.parse(value) as T;
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
@@ -494,20 +571,19 @@ If the widget has configurable default sort, sync it:
|
||||
|
||||
```typescript
|
||||
useEffect(() => {
|
||||
setSortStatus({ columnAccessor: options.defaultSort, direction: defaultSortDirection });
|
||||
setSortStatus({
|
||||
columnAccessor: options.defaultSort,
|
||||
direction: defaultSortDirection,
|
||||
});
|
||||
}, [options.defaultSort, defaultSortDirection]);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. Accessibility
|
||||
|
||||
- All `ActionIcon` components need `aria-label`
|
||||
- Filter toggles (client badges, status badges) need `aria-pressed={isActive}`
|
||||
- `textSelectionDisabled` on DataTable prevents accidental text selection during drag/resize
|
||||
|
||||
---
|
||||
|
||||
## 9. Error Handling
|
||||
|
||||
- Every mutation needs an `onError` callback with `showErrorNotification`
|
||||
@@ -518,12 +594,14 @@ useEffect(() => {
|
||||
```typescript
|
||||
const mutationOptions = {
|
||||
onSettled: () => void utils.myQuery.invalidate(),
|
||||
onError: () => showErrorNotification({ title: t("errors.actionFailed"), message: t("errors.actionFailedMessage") }),
|
||||
onError: () =>
|
||||
showErrorNotification({
|
||||
title: t("errors.actionFailed"),
|
||||
message: t("errors.actionFailedMessage"),
|
||||
}),
|
||||
};
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 10. i18n
|
||||
|
||||
- No hardcoded strings. Every user-visible string goes through `useScopedI18n`
|
||||
@@ -534,8 +612,6 @@ const mutationOptions = {
|
||||
- Errors: `t("errors.{errorKey}")`
|
||||
- Hidden options still need `.label` translation keys
|
||||
|
||||
---
|
||||
|
||||
## 11. Edit Mode
|
||||
|
||||
Disable pointer events on the table during edit mode to prevent conflicts with the board's drag-and-drop:
|
||||
@@ -550,8 +626,6 @@ if (isEditMode) rowContextMenuHandler = undefined;
|
||||
<DataTable style={{ pointerEvents: tablePointerEvents }} />
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 12. DataTable Props Reference (Commonly Used)
|
||||
|
||||
```typescript
|
||||
@@ -590,8 +664,6 @@ if (isEditMode) rowContextMenuHandler = undefined;
|
||||
/>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 13. Layout Stability
|
||||
|
||||
- Don't use `table-layout: fixed` — it fights mantine-datatable's resize logic
|
||||
@@ -600,8 +672,6 @@ if (isEditMode) rowContextMenuHandler = undefined;
|
||||
- Names should truncate at the cell boundary, not a character count, so the layout is stable regardless of content length
|
||||
- `flexShrink: 0` on badges/icons prevents them from being squeezed by long names
|
||||
|
||||
---
|
||||
|
||||
## 14. Checklist for Each Migration
|
||||
|
||||
1. Replace imports (`mantine-react-table` → `mantine-datatable`)
|
||||
@@ -610,13 +680,12 @@ if (isEditMode) rowContextMenuHandler = undefined;
|
||||
4. Add CSS file for transparent backgrounds (if widget context)
|
||||
5. Add responsive column visibility (if widget context)
|
||||
6. Add hidden options + hydration logic (if column persistence needed)
|
||||
7. Add old-import mappings for new options
|
||||
8. Add translation keys (including hidden option labels)
|
||||
9. Add context menu if row actions exist
|
||||
10. Add row expansion if detail view exists
|
||||
11. Add error handling on all mutations
|
||||
12. Add accessibility attributes
|
||||
13. Test at multiple widget widths
|
||||
14. Update docs in `apps/docs/docs/widgets/` if applicable
|
||||
15. Run `pnpm turbo typecheck` — catches old-import mismatches
|
||||
16. Run translation spec — catches missing keys
|
||||
7. Add translation keys (including hidden option labels)
|
||||
8. Add context menu if row actions exist
|
||||
9. Add row expansion if detail view exists
|
||||
10. Add error handling on all mutations
|
||||
11. Add accessibility attributes
|
||||
12. Test at multiple widget widths
|
||||
13. Update docs in `apps/docs/docs/widgets/` if applicable
|
||||
14. Run `pnpm turbo typecheck`
|
||||
15. Run translation spec — catches missing keys
|
||||
@@ -0,0 +1,44 @@
|
||||
---
|
||||
name: documentation-sync
|
||||
description: Keep Homarr documentation in sync with code changes. Use when modifying code that affects user-facing behavior, or when adding/modifying widgets, integrations, cron jobs, API routes, env vars, permissions, auth providers, or UI in apps/docs. Covers the docs/ mapping table, integration doc pattern, and widget doc pattern.
|
||||
---
|
||||
|
||||
# Documentation Sync
|
||||
|
||||
When modifying code that affects user-facing behavior, you MUST also update the documentation in `apps/docs/`.
|
||||
|
||||
## What triggers a doc update
|
||||
|
||||
| Code change | Doc location |
|
||||
| ---------------------- | ----------------------------------------------------------- |
|
||||
| New integration | `apps/docs/docs/integrations/<slug>/index.mdx` + `index.ts` |
|
||||
| New widget | `apps/docs/docs/widgets/<slug>/index.mdx` + `index.ts` |
|
||||
| Changed API surface | `apps/docs/docs/management/api.mdx` |
|
||||
| New/changed env vars | `apps/docs/docs/advanced/` |
|
||||
| New CLI commands | `apps/docs/docs/advanced/command-line/` |
|
||||
| Changed auth providers | `apps/docs/docs/advanced/` SSO pages |
|
||||
| UI/UX changes | Relevant getting-started or management pages |
|
||||
| New cron job | `apps/docs/docs/management/tasks.mdx` |
|
||||
| Changed permissions | `apps/docs/docs/management/users.mdx` |
|
||||
|
||||
## Integration doc pattern
|
||||
|
||||
Each integration has a folder under `apps/docs/docs/integrations/<slug>/`:
|
||||
|
||||
- `index.ts` — exports a typed `IntegrationDefinition` with name, description, iconUrl, path
|
||||
- `index.mdx` — uses shared components: `IntegrationHeader`, `IntegrationCapabilites`, `IntegrationSecrets`
|
||||
- Import metadata from `@homarr/definitions` where possible
|
||||
|
||||
## Widget doc pattern
|
||||
|
||||
Each widget has a folder under `apps/docs/docs/widgets/<slug>/`:
|
||||
|
||||
- `index.ts` — exports a typed `WidgetDefinition` with icon, name, description, path, configuration
|
||||
- `index.mdx` — uses shared components: `WidgetHeader`, `WidgetConfig`, `WidgetAdding`
|
||||
|
||||
## Development
|
||||
|
||||
- Run docs locally: `pnpm dev:docs` from root
|
||||
- Build docs: `pnpm turbo build --filter=@homarr/docs`
|
||||
- The docs app can import from `@homarr/definitions` for type-safe integration/widget metadata
|
||||
- Docusaurus strict mode throws on broken links — fix any broken references before committing
|
||||
@@ -1,13 +1,14 @@
|
||||
---
|
||||
description: How to expose tRPC procedures as MCP tools for AI clients
|
||||
globs: packages/api/src/router/**/*.ts
|
||||
alwaysApply: false
|
||||
name: mcp-integration
|
||||
description: Expose tRPC procedures as MCP tools for AI clients. Use when modifying procedures in packages/api/src/router, adding a new tRPC procedure or router, or deciding what to expose via Homarr's MCP interface. Covers .meta({ mcp }), router registration in packages/api/src/mcp.ts, input schema rules, and what to expose vs skip.
|
||||
---
|
||||
|
||||
# MCP Integration Guide
|
||||
# MCP Integration
|
||||
|
||||
Every new tRPC procedure that provides useful functionality should be exposed as an MCP tool so AI assistants (Claude, Cursor, etc.) can use it. This is a first-class feature of Homarr.
|
||||
|
||||
Exposing a procedure makes it callable by any AI client with an API key. Before adding `.meta({ mcp })`, review what the tool exposes: authorization requirements, sensitive data, tenant isolation, auditability, and whether the action is destructive. Prefer exposing read-only queries over mutations unless the mutation is safe and permission-scoped. When in doubt, ask for a security review before shipping a new MCP tool.
|
||||
|
||||
## Adding MCP to a procedure
|
||||
|
||||
Add `.meta({ mcp: { enabled: true, description: "..." } })` to the procedure chain:
|
||||
@@ -16,7 +17,7 @@ Add `.meta({ mcp: { enabled: true, description: "..." } })` to the procedure cha
|
||||
// Read-only tool (query)
|
||||
myProcedure: protectedProcedure
|
||||
.meta({ mcp: { enabled: true, description: "Get all items with their status and metadata" } })
|
||||
.input(z.object({ limit: z.number().default(50) }))
|
||||
.input(z.object({ limit: z.number().default(10) }))
|
||||
.query(async ({ ctx, input }) => { ... })
|
||||
|
||||
// Write tool (mutation)
|
||||
@@ -38,23 +39,27 @@ The `.meta()` call must come **before** `.input()` and `.query()`/`.mutation()`
|
||||
The description is the **only thing** an AI sees to decide when and how to use the tool. Write it like you're explaining to a colleague who has never used Homarr.
|
||||
|
||||
**Must include:**
|
||||
|
||||
- What the tool returns or does
|
||||
- Which integrations/services it works with (if applicable)
|
||||
- How to get required IDs (e.g., "Use integration_all to get the integrationId")
|
||||
- Permission requirements (if non-obvious)
|
||||
|
||||
**Good:**
|
||||
```
|
||||
|
||||
```text
|
||||
"Get calendar events for upcoming and recent media releases. Fetches from all connected Sonarr (TV), Radarr (movies), Lidarr (music), and Readarr (books) integrations. Requires integrationIds from integration_all"
|
||||
```
|
||||
|
||||
**Bad:**
|
||||
```
|
||||
|
||||
```text
|
||||
"Get calendar events"
|
||||
```
|
||||
|
||||
**For tools that return permission fields**, explain what they mean:
|
||||
```
|
||||
|
||||
```text
|
||||
"List all integrations. Returns permissions.hasUseAccess (read) and permissions.hasInteractAccess (actions) — false means the API key lacks that permission, not an error"
|
||||
```
|
||||
|
||||
@@ -77,11 +82,13 @@ export const mcpRouter = createTRPCRouter({
|
||||
## What to expose vs. what to skip
|
||||
|
||||
**Expose:**
|
||||
|
||||
- Data queries (list, search, get by ID)
|
||||
- Actions users would ask an AI to do (toggle, create, delete, approve)
|
||||
- Status checks (health, stats, summaries)
|
||||
|
||||
**Skip:**
|
||||
|
||||
- Subscriptions (WebSocket-only, not supported by MCP)
|
||||
- Internal procedures (session management, onboarding steps)
|
||||
- File upload/download procedures
|
||||
Symlink
+1
@@ -0,0 +1 @@
|
||||
../.agents/skills
|
||||
@@ -1,50 +0,0 @@
|
||||
---
|
||||
description: Keep documentation in sync with code changes
|
||||
globs:
|
||||
- packages/definitions/src/**
|
||||
- packages/widgets/src/**
|
||||
- packages/integrations/src/**
|
||||
- packages/api/src/**
|
||||
- apps/nextjs/src/**
|
||||
- apps/docs/**
|
||||
---
|
||||
|
||||
# Documentation Sync Rule
|
||||
|
||||
When modifying code that affects user-facing behavior, you MUST also update the documentation in `apps/docs/`.
|
||||
|
||||
## What triggers a doc update
|
||||
|
||||
| Code change | Doc location |
|
||||
|---|---|
|
||||
| New integration | `apps/docs/docs/integrations/<slug>/index.mdx` + `index.ts` |
|
||||
| New widget | `apps/docs/docs/widgets/<slug>/index.mdx` + `index.ts` |
|
||||
| Changed API surface | `apps/docs/docs/management/api.mdx` |
|
||||
| New/changed env vars | `apps/docs/docs/advanced/` |
|
||||
| New CLI commands | `apps/docs/docs/advanced/command-line/` |
|
||||
| Changed auth providers | `apps/docs/docs/advanced/` SSO pages |
|
||||
| UI/UX changes | Relevant getting-started or management pages |
|
||||
| New cron job | `apps/docs/docs/management/tasks.mdx` |
|
||||
| Changed permissions | `apps/docs/docs/management/users.mdx` |
|
||||
|
||||
## Integration doc pattern
|
||||
|
||||
Each integration has a folder under `apps/docs/docs/integrations/<slug>/`:
|
||||
|
||||
- `index.ts` — exports a typed `IntegrationDefinition` with name, description, iconUrl, path
|
||||
- `index.mdx` — uses shared components: `IntegrationHeader`, `IntegrationCapabilites`, `IntegrationSecrets`
|
||||
- Import metadata from `@homarr/definitions` where possible
|
||||
|
||||
## Widget doc pattern
|
||||
|
||||
Each widget has a folder under `apps/docs/docs/widgets/<slug>/`:
|
||||
|
||||
- `index.ts` — exports a typed `WidgetDefinition` with icon, name, description, path, configuration
|
||||
- `index.mdx` — uses shared components: `WidgetHeader`, `WidgetConfig`, `WidgetAdding`
|
||||
|
||||
## Development
|
||||
|
||||
- Run docs locally: `pnpm dev:docs` from root
|
||||
- Build docs: `pnpm turbo build --filter=@homarr/docs`
|
||||
- The docs app can import from `@homarr/definitions` for type-safe integration/widget metadata
|
||||
- Docusaurus strict mode throws on broken links — fix any broken references before committing
|
||||
@@ -33,6 +33,11 @@ body:
|
||||
options:
|
||||
# The below comment is used to insert a new version with on-release.yml
|
||||
#NEXT_VERSION#
|
||||
- 1.77.0
|
||||
- 1.76.2
|
||||
- 1.76.1
|
||||
- 1.76.0
|
||||
- 1.75.0
|
||||
- 1.74.0
|
||||
- 1.73.0
|
||||
- 1.72.0
|
||||
|
||||
@@ -68,8 +68,6 @@ jobs:
|
||||
with:
|
||||
node-version: 24.18.0
|
||||
package-manager-cache: false
|
||||
- run: npm i -g pnpm
|
||||
if: env.SKIP_RELEASE == 'false'
|
||||
- name: Install dependencies
|
||||
if: env.SKIP_RELEASE == 'false'
|
||||
run: |
|
||||
|
||||
+2
-1
@@ -71,7 +71,8 @@ apps/nextjs/public/images/background.png
|
||||
|
||||
# personal agent config
|
||||
.opencode
|
||||
.agents/
|
||||
.agents/*
|
||||
!.agents/skills/
|
||||
skills-lock.json
|
||||
|
||||
# docusaurus
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"mcpServers": {
|
||||
"mantine": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "@mantine/mcp-server"]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -84,6 +84,16 @@ When modifying user-facing code, you MUST also update the corresponding document
|
||||
- Lint: oxlint (not ESLint)
|
||||
- Format: oxfmt (not Prettier)
|
||||
- UI: Mantine (not Tailwind) — Tailwind is only used in docs app
|
||||
- Mantine: use the `mantine` MCP server (configured in `.mcp.json`; tools `get_item_doc` / `get_item_props` / `search_docs`) for current v9 APIs before writing component code. Prefer built-in primitives (`Combobox`/`useCombobox` for selects, the polymorphic `component` prop, `@mantine/hooks`) over hand-rolled equivalents. Check `packages/ui/` for existing conventions first.
|
||||
|
||||
## MCP servers
|
||||
|
||||
`.mcp.json` at the repo root declares project-scoped MCP servers (the standard Claude Code layout; other tools can import the same `mcpServers` block). See the `mantine` entry there for the Mantine v9 server, and the upstream docs at https://mantine.dev/guides/llms/ for per-tool setup (OpenCode: `.opencode/opencode.json`, Codex: `.codex/config.toml`). Only keyless server definitions belong in `.mcp.json`; anything needing secrets stays in local dotfiles.
|
||||
|
||||
## Agent Skills
|
||||
|
||||
Portable agent skills live in `.agents/skills/` (the Agent Skills open standard). Cursor, Codex, and OpenCode read `.agents/skills/` natively. Claude Code does not scan it, so `.claude/skills/` is a symlink back to `.agents/skills/`. Install skills at `.agents/skills/<name>/SKILL.md` — that single copy is used everywhere. See `.agents/skills/` for available skills (documentation-sync, mcp-integration, codebase-context, datatable-migration).
|
||||
|
||||
- Icons: @tabler/icons-react
|
||||
- Docs app can import from `@homarr/definitions` for shared types
|
||||
- Run `pnpm dev:cli -- dev` to browse local `homarr:*` images and remote PR images.
|
||||
|
||||
@@ -1,3 +1,15 @@
|
||||
## [1.77.1](https://github.com/homarr-labs/homarr/compare/v1.77.0...v1.77.1) (2026-09-11)
|
||||
|
||||
## [1.77.0](https://github.com/homarr-labs/homarr/compare/v1.76.2...v1.77.0) (2026-09-06)
|
||||
|
||||
## [1.76.2](https://github.com/homarr-labs/homarr/compare/v1.76.1...v1.76.2) (2026-08-27)
|
||||
|
||||
## [1.76.1](https://github.com/homarr-labs/homarr/compare/v1.76.0...v1.76.1) (2026-08-26)
|
||||
|
||||
## [1.76.0](https://github.com/homarr-labs/homarr/compare/v1.75.0...v1.76.0) (2026-08-21)
|
||||
|
||||
## [1.75.0](https://github.com/homarr-labs/homarr/compare/v1.74.0...v1.75.0) (2026-08-14)
|
||||
|
||||
## [1.74.0](https://github.com/homarr-labs/homarr/compare/v1.73.0...v1.74.0) (2026-08-07)
|
||||
|
||||
## [1.73.0](https://github.com/homarr-labs/homarr/compare/v1.72.0...v1.73.0) (2026-07-31)
|
||||
|
||||
@@ -206,6 +206,14 @@ packages/integrations/src/unraid @manuel-rw
|
||||
apps/docs/docs/integrations/uptime-kuma @ajnart
|
||||
packages/integrations/src/uptime-kuma @ajnart
|
||||
|
||||
# What's Up Docker (WUD) - introduced in https://github.com/homarr-labs/homarr/pull/6523 by @tiimonn
|
||||
apps/docs/docs/integrations/whats-up-docker @tiimonn
|
||||
apps/docs/docs/widgets/whats-up-docker @tiimonn
|
||||
packages/api/src/router/widgets/wud.ts @tiimonn
|
||||
packages/integrations/src/wud @tiimonn
|
||||
packages/request-handler/src/wud.ts @tiimonn
|
||||
packages/widgets/src/wud @tiimonn
|
||||
|
||||
# Traefik - by @Nerdy-Technician
|
||||
apps/docs/docs/integrations/traefik @Nerdy-Technician
|
||||
apps/docs/docs/widgets/traefik @Nerdy-Technician
|
||||
|
||||
@@ -95,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"]
|
||||
+2
-2
@@ -55,8 +55,8 @@ pnpm typecheck
|
||||
docs/
|
||||
├── getting-started/ # Installation guides, glossary, prerequisites
|
||||
├── management/ # Boards, apps, users, integrations, settings, tasks, API
|
||||
├── integrations/ # 55 integration guides (one folder each)
|
||||
├── widgets/ # 42 widget guides (one folder each)
|
||||
├── integrations/ # 56 integration guides (one folder each)
|
||||
├── widgets/ # 43 widget guides (one folder each)
|
||||
├── advanced/ # SSO, env vars, proxy, styling, CLI, development
|
||||
└── community/ # FAQ, donate, license, translations, get-in-touch
|
||||
```
|
||||
|
||||
@@ -25,13 +25,14 @@ This is especially useful for [docker secrets](https://docs.docker.com/engine/sw
|
||||
Using the `PUID` and `PGID` will require you to set the correct permissions on the mounted volumes and if used the docker socket. See more in the [Running as a different user](/docs/advanced/running-as-different-user) documentation.
|
||||
:::
|
||||
|
||||
| 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` |
|
||||
| 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` |
|
||||
|
||||
## Authentication
|
||||
|
||||
|
||||
@@ -9,17 +9,19 @@ tags:
|
||||
---
|
||||
|
||||
## Icon picker
|
||||
|
||||
Icons in Homarr are automatically requested from multiple sources:
|
||||
- [Dashboard Icons](https://dashboardicons.com/) - Our recommended source with over 1888+ curated, high-quality icons
|
||||
- [selfh.st icons](https://github.com/selfhst/icons)
|
||||
- [Simple icons](https://github.com/simple-icons/simple-icons)
|
||||
- [Tabler icons](https://tabler-icons.io/)
|
||||
- [Papirus icons](https://github.com/PapirusDevelopmentTeam/papirus-icon-theme)
|
||||
- [Homelab Svg assets](https://github.com/loganmarchione/homelab-svg-assets)
|
||||
- **Local icons**: automatically fetched from the [medias feature](/docs/management/media/)
|
||||
|
||||
- [Dashboard Icons](https://dashboardicons.com/) - Our recommended source with more than 1,888 curated, high-quality icons
|
||||
- [selfh.st icons](https://github.com/selfhst/icons)
|
||||
- [Simple icons](https://github.com/simple-icons/simple-icons)
|
||||
- [Tabler icons](https://tabler-icons.io/)
|
||||
- [Papirus icons](https://github.com/PapirusDevelopmentTeam/papirus-icon-theme)
|
||||
- [Homelab Svg assets](https://github.com/loganmarchione/homelab-svg-assets)
|
||||
- **Local icons**: automatically fetched from the [medias feature](/docs/management/media/)
|
||||
|
||||
Using these icon sources, Homarr provides a total collection of over 11,000 icons at your fingertips.
|
||||
The icon picker is a fast searcher that helps you to find your desired icon simply by entering a search term.
|
||||
The image picker searches this collection after a short typing delay. When a name is available, such as an app or search engine name, the picker uses it as the initial search. Results show your locally uploaded images first, followed by SVG icons and then other image formats.
|
||||
|
||||
### Finding Missing Icons
|
||||
|
||||
@@ -29,6 +31,13 @@ If you can't find the icon you're looking for in Homarr's built-in picker, we st
|
||||
|
||||
Icons that are high quality and printable ([SVG](https://wikipedia.org/wiki/Scalable_Vector_Graphics)) are marked with a red badge "SVG" at the top right.
|
||||
|
||||
You can clear the selected image with the clear button or the usual keyboard shortcut for deleting a line (Command+Backspace on macOS or Control+Backspace on other platforms). Clearing the field does not restore the previous image.
|
||||
|
||||
### Using external icons
|
||||
|
||||
If you don't find the icon you're looking for on Dashboard Icons or wish to use a different source, you can simply enter a URL that points to an icon.
|
||||
Direct HTTP(S) image URLs are used as entered instead of being searched in the built-in repositories. It is important that the URL directly points to the file without any advertisements or elements around it.
|
||||
Direct HTTP(S) image URLs are used as entered instead of being searched in the built-in repositories. The picker previews the image and shows whether it loaded successfully. It is important that the URL directly points to the file without any advertisements or elements around it.
|
||||
|
||||
### Uploading a local image
|
||||
|
||||
Users with media upload permission can select **Upload image** next to the field. After the upload finishes, the new local image is selected immediately and is available at the top of future searches.
|
||||
@@ -47,7 +47,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:
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
|
||||
<img src="https://raw.githubusercontent.com/homarr-labs/charts/refs/heads/main/charts/homarr/icon.svg" align="right" width="92" alt="homarr logo">
|
||||
|
||||

|
||||

|
||||

|
||||

|
||||

|
||||
|
||||
A Helm chart to deploy homarr for Kubernetes
|
||||
|
||||
@@ -429,7 +429,7 @@ All available values are listed on the [artifacthub](https://artifacthub.io/pack
|
||||
| httproute.rules[0].matches[0].path.value | string | `"/"` | Path value to match |
|
||||
| image.pullPolicy | string | `"IfNotPresent"` | Image pull policy |
|
||||
| image.repository | string | `"ghcr.io/homarr-labs/homarr"` | Image repository |
|
||||
| image.tag | string | `"v1.73.0"` | Overrides the image tag whose default is the chart appVersion |
|
||||
| image.tag | string | `"v1.77.1"` | Overrides the image tag whose default is the chart appVersion |
|
||||
| imagePullSecrets | list | `[]` | Secrets for Docker registry |
|
||||
| ingress.annotations | object | `{}` | Ingress annotations |
|
||||
| ingress.enabled | bool | `false` | Enable ingress |
|
||||
|
||||
@@ -27,6 +27,8 @@ import Alert from '@theme/Admonition';
|
||||
]}
|
||||
/>
|
||||
|
||||
The storage values shown by these widgets come from each pool's root ZFS dataset, so they already account for RAIDZ parity overhead and dataset limits and match what `zfs list` and the TrueNAS web interface report. When a pool cannot report its dataset space, Homarr falls back to the pool's physical values, which read higher on RAIDZ.
|
||||
|
||||
### Adding the integration
|
||||
<AddingIntegration />
|
||||
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
---
|
||||
title: "What's Up Docker"
|
||||
description: "What's Up Docker (WUD) watches your Docker containers and reports which ones have newer image versions available."
|
||||
hide_title: true
|
||||
---
|
||||
|
||||
import { IntegrationHeader } from "@site/src/components/integrations/header";
|
||||
import { IntegrationCapabilites } from "@site/src/components/integrations/widgets";
|
||||
import { AddingIntegration } from "@site/src/components/integrations/adding";
|
||||
import { IntegrationSecrets } from "@site/src/components/integrations/secrets";
|
||||
import { wudIntegration } from ".";
|
||||
import { wudWidget } from "@site/docs/widgets/whats-up-docker";
|
||||
|
||||
<IntegrationHeader integration={wudIntegration} categories={["Health Monitoring"]} />
|
||||
|
||||
The What's Up Docker integration lets Homarr connect to your [WUD](https://github.com/getwud/wud) instance so you can see how many of your watched containers have image updates available directly from your dashboard.
|
||||
|
||||
### Widgets & Capabilities
|
||||
|
||||
<IntegrationCapabilites items={[{ widget: wudWidget }]} />
|
||||
|
||||
### Adding the integration
|
||||
|
||||
<AddingIntegration />
|
||||
|
||||
:::info URL configuration
|
||||
Use the root URL of your WUD instance, for example `https://wud.example.com`. Homarr calls the `/api/app` and `/api/containers` endpoints under that URL automatically.
|
||||
:::
|
||||
|
||||
### Secrets
|
||||
|
||||
WUD does not require authentication by default. If your instance is protected with [Basic Authentication](https://github.com/getwud/wud/blob/main/docs/configuration/authentications/basic/README.md), you can provide a username and password.
|
||||
|
||||
<IntegrationSecrets
|
||||
secrets={[
|
||||
{
|
||||
credentials: [],
|
||||
steps: [],
|
||||
},
|
||||
{
|
||||
credentials: ["username", "password"],
|
||||
steps: [
|
||||
"Open your WUD instance's configuration and configure a WUD_AUTH_BASIC_* user as described in the WUD documentation",
|
||||
"Enter that username and password in Homarr — Basic authentication is handled automatically",
|
||||
],
|
||||
},
|
||||
]}
|
||||
/>
|
||||
@@ -0,0 +1,9 @@
|
||||
import { IntegrationDefinition } from "@site/src/types";
|
||||
|
||||
export const wudIntegration: IntegrationDefinition = {
|
||||
name: "What's Up Docker",
|
||||
description:
|
||||
"What's Up Docker (WUD) watches your Docker containers and reports which ones have newer image versions available.",
|
||||
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/whats-up-docker.svg",
|
||||
path: "../../integrations/whats-up-docker",
|
||||
};
|
||||
@@ -28,7 +28,7 @@ The name must be at least one character long and can be chosen freely.
|
||||
### Icon URL
|
||||
|
||||
This is the URL to the icon that will be displayed on the app widget.
|
||||
You can paste a direct HTTP(S) URL to an image, search the included icon repositories by name, or upload a local file through the upload button. Direct image URLs are used as entered and are not searched in the icon repositories.
|
||||
You can paste a direct HTTP(S) URL to an image, search the included icon repositories by name, or upload a local file through the upload button. The app name is pre-filled as the initial library search when no image is selected. Local images appear first, followed by SVG icons and other formats. Direct image URLs are previewed, used as entered, and are not searched in the icon repositories.
|
||||
|
||||

|
||||
|
||||
|
||||
@@ -9,11 +9,11 @@ tags:
|
||||
- Layout
|
||||
---
|
||||
|
||||
import iconColorImage from './img/icon-color.png'
|
||||
import itemRadiusImage from './img/item-radius.png'
|
||||
import layoutLargeImage from './img/layout-large.png'
|
||||
import layoutMediumImage from './img/layout-medium.png'
|
||||
import layoutSmallImage from './img/layout-small.png'
|
||||
import iconColorImage from "./img/icon-color.png";
|
||||
import itemRadiusImage from "./img/item-radius.png";
|
||||
import layoutLargeImage from "./img/layout-large.png";
|
||||
import layoutMediumImage from "./img/layout-medium.png";
|
||||
import layoutSmallImage from "./img/layout-small.png";
|
||||
|
||||
Each dashboard is called a "board".
|
||||
You can create as many boards as you want.
|
||||
@@ -24,24 +24,29 @@ Other boards can be viewed by direct links (e.g. my-homarr.com/board/smart-home)
|
||||
Names of boards must be unique because this will be used in the URL to load the desired board.
|
||||
|
||||
## Create a new board
|
||||
|
||||
When creating a board, the availability of the board name will be checked for you.
|
||||
|
||||

|
||||
|
||||
### Name
|
||||
|
||||
The name must not include any special characters and must be at least one character long.
|
||||
Underscores and hyphen-minus are allowed.
|
||||
|
||||
### Column count
|
||||
Homarr has a complex drag and drop system that can be used from the browser.
|
||||
|
||||
Homarr has a complex drag-and-drop system that can be used from the browser.
|
||||
The column count defines how many horizontal columns are available to drag and drop elements to.
|
||||
|
||||
### Public
|
||||
|
||||
This will enable public access for everyone - regardless of whether they are logged in or not.
|
||||
This can be really useful if you want to have public boards in your company or for family and fiends.
|
||||
This can be really useful if you want to have public boards in your company or for family and friends.
|
||||
Modifications for public boards must be allowed explicitly using user groups and their permissions.
|
||||
|
||||
## Board settings
|
||||
|
||||
You navigate to the board settings by clicking on these three dots:
|
||||
|
||||

|
||||
@@ -50,8 +55,12 @@ Alternatively, you can also navigate from the board page itself using the cog ic
|
||||
|
||||

|
||||
|
||||
All board settings are edited on a single page. Click the **Save changes** button in the save bar at the bottom to apply them, or **Discard** to restore the last saved values. The save bar only appears when you have unsaved changes. Access control and the Danger zone are independent sections and are applied immediately.
|
||||
|
||||
### General
|
||||
|
||||
#### Page title
|
||||
|
||||
This is the title that will be visible at the top left in the navigation bar:
|
||||
|
||||

|
||||
@@ -59,6 +68,7 @@ This is the title that will be visible at the top left in the navigation bar:
|
||||
This field is optional and Homarr will fall back to "Homarr" if nothing is set.
|
||||
|
||||
#### Meta title
|
||||
|
||||
This is the secondary title of the page that is not directly visible in the page itself.
|
||||
Browser will use it to set the title of tabs:
|
||||
|
||||
@@ -67,10 +77,12 @@ Browser will use it to set the title of tabs:
|
||||
Homarr will fall back to the board name and a suffix if nothing is set.
|
||||
|
||||
#### Logo image URL
|
||||
|
||||
This is the URL that is used for the image at the top left.
|
||||
Homarr will fall back to the Homarr logo if nothing is set.
|
||||
|
||||
#### Favicon image URL
|
||||
|
||||
This is the URL that is used for the favicon in the tab title.
|
||||
Homarr will fall back to the Homarr logo if nothing is set.
|
||||
|
||||
@@ -94,51 +106,65 @@ Amount of columns for this layout. Defines how many items can be placed in a row
|
||||
|
||||
#### Breakpoint
|
||||
|
||||
Minimum width of the screen where the layout will be used.
|
||||
Once the minmum of the next layout is reached the layout will change.
|
||||
Minimum width of the screen where the layout will be used.
|
||||
Once the minimum of the next layout is reached the layout will change.
|
||||
|
||||
#### Preview
|
||||
|
||||
<img src={layoutSmallImage} height={256} />
|
||||
<img src={layoutMediumImage} height={256} />
|
||||
<img src={layoutLargeImage} height={256} />
|
||||
<img src={layoutSmallImage} height={256} />
|
||||
<img src={layoutMediumImage} height={256} />
|
||||
<img src={layoutLargeImage} height={256} />
|
||||
|
||||
### Background
|
||||
|
||||
#### Background image URL
|
||||
|
||||
Homarr can display a background image behind apps and widgets.
|
||||
This URL can be used to define what image should be shown.
|
||||
|
||||
#### Background image attachment
|
||||
|
||||
This defines whether the background should move when the user scrolls.
|
||||
|
||||
#### Background image size
|
||||
|
||||
This defines whether the background should cover the entire page (and possibly be cropped) or never be cropped but may not cover the entire page.
|
||||
|
||||
#### Background image repeat
|
||||
|
||||
This defines whether the background should be repeated if it cannot cover the entire page.
|
||||
This option can be useful if you have patterns that can be repeated easily (e.g. patterns):
|
||||
|
||||

|
||||
|
||||
### Appearance
|
||||
|
||||
#### Primary color
|
||||
|
||||
Color that can be chosen freely to give accents to active elements.
|
||||
|
||||
#### Secondary color
|
||||
|
||||
Color that can be chosen freely to give accents.
|
||||
|
||||
#### Opacity
|
||||
|
||||
Opacity of the colors.
|
||||
|
||||
#### Icon color
|
||||
|
||||
Change the color of your bookmark / app icons.
|
||||
|
||||
<img src={iconColorImage} height={100} />
|
||||
<img src={iconColorImage} height={100} />
|
||||
|
||||
#### Item radius
|
||||
|
||||
Change the radius of your items / categories.
|
||||
|
||||
<img src={itemRadiusImage} height={100} />
|
||||
|
||||
<img src={itemRadiusImage} height={100} />
|
||||
|
||||
### Custom CSS
|
||||
|
||||
This is the field where you can enter custom CSS.
|
||||
The styling will only be applied on the respective board page.
|
||||
|
||||
@@ -147,14 +173,16 @@ The styling will only be applied on the respective board page.
|
||||
### Behaviour
|
||||
|
||||
#### Disable app status
|
||||
|
||||
This will disable the status check for apps.
|
||||
|
||||
### Access control
|
||||
|
||||
With the access control, you can permit a single user or a group to your board.
|
||||
The users and groups can have one of these three levels of access:
|
||||
|
||||
| **Permission Level** | View board | Edit mode | Settings | Danger zone (eg. delete) |
|
||||
|----------------------|------------|-----------|----------|--------------------------|
|
||||
| -------------------- | ---------- | --------- | -------- | ------------------------ |
|
||||
| View board | X | | | |
|
||||
| Modify board | X | X | X | |
|
||||
| Full access | X | X | X | X |
|
||||
@@ -173,22 +201,27 @@ After editing permissions, the save button below the table must be pressed.
|
||||
Otherwise, all changes will be discarded.
|
||||
|
||||
### Danger zone
|
||||
|
||||
#### Rename
|
||||
|
||||
Renaming a board will break all existing links to it permanently with no redirect.
|
||||
|
||||
#### Board visibility
|
||||
|
||||
By default, boards will be private. This means only authenticated users that are authorized have access.
|
||||
Making a board public will expose it to everyone - including unauthenticated users.
|
||||
|
||||
#### Delete
|
||||
|
||||
This will permanently delete the board.
|
||||
This action is irreversible and cannot be undone.
|
||||
|
||||
## Delete boards
|
||||
|
||||
The deletion of boards is permanent and cannot be undone.
|
||||
There are two ways to delete a board:
|
||||
|
||||
- Navigate to the settings of your board, open the "Danger zone" dropdown and click on the "delete this board" button.
|
||||
- Navigate to the settings of your board, open the "Danger zone" section and click on the "delete this board" button.
|
||||
- Navigate to the list of boards in the management pages, click the three dots and click on "delete permanently".
|
||||
|
||||
Both actions will execute the same deletion process.
|
||||
@@ -196,7 +229,7 @@ Both actions will execute the same deletion process.
|
||||
## Duplicate boards
|
||||
|
||||
:::note
|
||||
Duplicating a board will not copy the users and groups that have access to the board
|
||||
Duplicating a board will not copy the users and groups that have access to the board
|
||||
nor the integrations the user does not have access to.
|
||||
:::
|
||||
|
||||
@@ -206,47 +239,51 @@ You can duplicate a board by clicking on the three dots in the list of boards an
|
||||

|
||||
|
||||
## Setting boards as your home board
|
||||
|
||||
The home board is the dashboard, that will be shown at the root path of Homarr or if no board name was passed in the URL.
|
||||
All the following paths will lead to the home board:
|
||||
- ``my-homarr.com``
|
||||
- ``my-homarr.com/boards``
|
||||
|
||||
- `my-homarr.com`
|
||||
- `my-homarr.com/boards`
|
||||
|
||||
Your home board will have a special small badge at the top right in the list of boards:
|
||||
|
||||

|
||||
|
||||
### For yourself
|
||||
|
||||
To set the home board yourself, the easiest option is to set it directly in the list of boards:
|
||||
|
||||

|
||||
|
||||
Alternatively, you can also define it via your user profile:
|
||||
|
||||
1. Click on your user profile at the top right
|
||||
2. Click on ``Your preferences``
|
||||
3. Change the home board in the ``home board`` dropdown and click on ``Save``.
|
||||
2. Click on `Your preferences`
|
||||
3. Change the home board in the `home board` dropdown and click on `Save`.
|
||||
|
||||
### For other users
|
||||
|
||||
1. Navigate to the users: ``Management`` > ``Users`` > ``Manage``
|
||||
1. Navigate to the users: `Management` > `Users` > `Manage`
|
||||
2. Click on the username of the user that you want to adjust
|
||||
3. Select the desired home board via the dropdown and click on ``Save`` \
|
||||

|
||||
3. Select the desired home board via the dropdown and click on `Save` \
|
||||

|
||||
|
||||
### For user groups
|
||||
|
||||
1. Navigate to the user groups: ``Management`` > ``Users`` > ``Groups`` > ``Manage``
|
||||
2. Go to tab ``Settings``
|
||||
3. Select the desired home board via the dropdown and click on ``Save`` \
|
||||

|
||||
1. Navigate to the user groups: `Management` > `Users` > `Groups` > `Manage`
|
||||
2. Go to tab `Settings`
|
||||
3. Select the desired home board via the dropdown and click on `Save` \
|
||||

|
||||
|
||||
### For whole server
|
||||
|
||||
You can also set a global home board that will be used for all users that have not set a home board themselves.
|
||||
It will also be used for anonymous users.
|
||||
|
||||
1. Navigate to the settings: ``Management`` > ``Settings`` > ``Boards``
|
||||
2. Select the desired home board via the dropdown and click on ``Save`` \
|
||||

|
||||
1. Navigate to the settings: `Management` > `Settings` > `Boards`
|
||||
2. Select the desired home board via the dropdown and click on `Save` \
|
||||

|
||||
|
||||
:::note
|
||||
For the global home board you can only select boards that are public.
|
||||
@@ -255,4 +292,4 @@ For the global home board you can only select boards that are public.
|
||||
### Mobile home board
|
||||
|
||||
Same as the home board, you can also set a mobile home board.
|
||||
It will be used for devices that are detected as mobile devices. (Using User-Agent header)
|
||||
It will be used for devices that are detected as mobile devices. (Using User-Agent header)
|
||||
@@ -52,14 +52,21 @@ The certificates are stored in the `/appdata/trusted-certificates` directory whi
|
||||
This means you can automate the update of the certificates by simply replacing or adding a new file in the directory.
|
||||
|
||||
## Obtaining certificates
|
||||
|
||||
Every integration has its own way of obtaining certificates.
|
||||
Generally it is recommended to search for the documentation of the integration you are using.
|
||||
Most of the time you can find the certificate in the file system of the integration.
|
||||
The certificate file needs to be uploaded to Homarr. To first get the file on your computer you can either:
|
||||
- use `scp user@host:/path/to/file ./certificate.pem`
|
||||
- use `cat /path/to/file` to print the certificate. Then recreate the file on your machine.
|
||||
|
||||
Below you can find instructions for some of the integrations.
|
||||
|
||||
### Pi-hole (v6)
|
||||
|
||||
Since version 6 of Pi-hole, the web interface can be accessed through HTTPS.
|
||||
The certificate can be found in the path `/etc/pihole/tls_ca.crt`. To add the certificate, this file needs to be copied and uploaded to Homarr.
|
||||
You can find more details regarding SSL / TLS for Pi-hole [here](https://docs.pi-hole.net/api/tls/).
|
||||
The certificate can be found in the path `/etc/pihole/tls_ca.crt`.
|
||||
You can find more details regarding SSL / TLS for Pi-hole [here](https://docs.pi-hole.net/api/tls/).
|
||||
|
||||
### Proxmox
|
||||
By default, Proxmox VE uses a node certificate signed by the cluster. You only need to configure Homarr with the certificate of a single node.
|
||||
The certificate can be found in any node on the path `/etc/pve/pve-root-ca.pem`.
|
||||
You can learn more about certificate management in Proxmox [here](https://pve.proxmox.com/wiki/Certificate_Management).
|
||||
@@ -13,6 +13,8 @@ tags:
|
||||
The settings page applies options to the whole Homarr application.
|
||||
Some changes need a full page reload before they take effect.
|
||||
|
||||
All changes are collected in a single form. Click the **Save changes** button in the save bar at the bottom of the page to apply them. The save bar only appears when you have unsaved changes, and the **Discard** button reverts your changes.
|
||||
|
||||
## Analytics
|
||||
|
||||
Homarr uses [PostHog](https://posthog.com/) to collect anonymous usage data.
|
||||
|
||||
@@ -1,30 +1,27 @@
|
||||
---
|
||||
title: 'App'
|
||||
description: 'Embeds an app into the board.'
|
||||
title: "App"
|
||||
description: "Embeds an app into the board."
|
||||
hide_title: true
|
||||
---
|
||||
|
||||
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 { appWidget } from '.';
|
||||
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 { appWidget } from ".";
|
||||
|
||||
|
||||
<WidgetHeader
|
||||
widget={appWidget}
|
||||
categories={['Links', 'Apps']}
|
||||
/>
|
||||
<WidgetHeader widget={appWidget} categories={["Links", "Apps"]} />
|
||||
|
||||
The app widget allows you to add a link to an application or a website that you want to access quickly from your Homarr board.
|
||||
It also supports a status check to indicate whether Homarr can reach the application or website.
|
||||
In view mode, right-clicking uses the browser's link menu. Homarr's widget menu remains available in edit mode.
|
||||
|
||||
### Screenshots
|
||||
|
||||
import githubApp from './img/github.png';
|
||||
import jellyseerrApp from './img/jellyseerr-description-tooltip.png';
|
||||
import piHoleApp from './img/pi-hole-ping-icon.png';
|
||||
import githubApp from "./img/github.png";
|
||||
import jellyseerrApp from "./img/jellyseerr-description-tooltip.png";
|
||||
import piHoleApp from "./img/pi-hole-ping-icon.png";
|
||||
|
||||
<div className='flex gap-1'>
|
||||
<div className="flex gap-1">
|
||||
<img src={githubApp} alt="app github with-status-check" width={128 * 1.5} height={128 * 1.5} />
|
||||
<img src={piHoleApp} alt="app pi-hole with-status-check status-check-icon" width={128 * 1.5} height={128 * 1.5} />
|
||||
<img src={jellyseerrApp} alt="app jellyseerr with-description-tooltip" height={128 * 1.5} />
|
||||
@@ -34,4 +31,5 @@ import piHoleApp from './img/pi-hole-ping-icon.png';
|
||||
<AddingWidget />
|
||||
|
||||
### Configuration
|
||||
|
||||
<WidgetConfig configuration={appWidget.configuration} />
|
||||
@@ -1,29 +1,26 @@
|
||||
---
|
||||
title: 'Bookmarks'
|
||||
description: 'Displays multiple app links'
|
||||
title: "Bookmarks"
|
||||
description: "Displays multiple app links"
|
||||
hide_title: true
|
||||
---
|
||||
|
||||
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 { bookmarksWidget } from '.';
|
||||
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 { bookmarksWidget } from ".";
|
||||
|
||||
|
||||
<WidgetHeader
|
||||
widget={bookmarksWidget}
|
||||
categories={['Links', 'Apps']}
|
||||
/>
|
||||
<WidgetHeader widget={bookmarksWidget} categories={["Links", "Apps"]} />
|
||||
|
||||
The bookmarks widget allows you to display multiple app links in a single widget.
|
||||
You can add links to your favorite applications, websites, or any other resources you want quick access to.
|
||||
In view mode, right-clicking uses the browser's link menu. Homarr's widget menu remains available in edit mode.
|
||||
|
||||
### Screenshots
|
||||
|
||||
import gridIconsOnly from './img/grid-icons-only.png';
|
||||
import gridWithTitle from './img/grid-with-title.png';
|
||||
import verticalListAll from './img/vertical-list-all.png';
|
||||
import vericalListSimple from './img/vertical-list-simple.png';
|
||||
import gridIconsOnly from "./img/grid-icons-only.png";
|
||||
import gridWithTitle from "./img/grid-with-title.png";
|
||||
import verticalListAll from "./img/vertical-list-all.png";
|
||||
import vericalListSimple from "./img/vertical-list-simple.png";
|
||||
|
||||
<div className='flex gap-1'>
|
||||
<div className='flex flex-col gap-1'>
|
||||
@@ -33,12 +30,13 @@ import vericalListSimple from './img/vertical-list-simple.png';
|
||||
|
||||
<img src={gridWithTitle} alt="bookmarks grid with-title" width={256 + 4} height={256 + 4} />
|
||||
<img src={verticalListAll} alt="bookmarks vertical-list all-elements" width={128} height={256 + 4} />
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
### Adding the widget
|
||||
|
||||
<AddingWidget />
|
||||
|
||||
### Configuration
|
||||
|
||||
<WidgetConfig configuration={bookmarksWidget.configuration} />
|
||||
@@ -43,6 +43,7 @@ import temperature from './img/temperature.png';
|
||||
### Interactions
|
||||
|
||||
- Toggle entity state by clicking on the entity (only when clickable enabled).
|
||||
- After a successful toggle, the displayed state refreshes immediately.
|
||||
|
||||
### Adding the widget
|
||||
|
||||
@@ -64,4 +65,4 @@ import retrieveEntityId from './img/retrieve-entity-id.png';
|
||||
|
||||
<div>
|
||||
<img src={retrieveEntityId} alt="homeassistant retrieve-entity-id" width={256 * 2} />
|
||||
</div>
|
||||
</div>
|
||||
@@ -47,4 +47,7 @@ import withDisplayname from './img/with-displayname.png';
|
||||
<AddingWidget />
|
||||
|
||||
### Configuration
|
||||
|
||||
Use the Home Assistant entity ID (for example, `automation.turn_on_lights`) in the **Automation ID** field, not the automation's internal ID.
|
||||
|
||||
<WidgetConfig configuration={smartHomeExecuteAutomationWidget.configuration} />
|
||||
@@ -0,0 +1,35 @@
|
||||
---
|
||||
title: "What's Up Docker"
|
||||
description: "Displays how many watched containers have image updates available."
|
||||
hide_title: true
|
||||
---
|
||||
|
||||
import { WidgetHeader } from "@site/src/components/widgets/header";
|
||||
import { AddingWidget } from "@site/src/components/widgets/adding";
|
||||
import { WidgetConfig } from "@site/src/components/widgets/configuration";
|
||||
import { WidgetIntegrations } from "@site/src/components/widgets/integrations";
|
||||
import { wudWidget } from ".";
|
||||
import { wudIntegration } from "@site/docs/integrations/whats-up-docker";
|
||||
|
||||
<WidgetHeader widget={wudWidget} categories={["Health Monitoring"]} />
|
||||
|
||||
This widget shows two stats from your WUD instance: the total number of monitored containers, and how many of them currently have an image update available. Optionally, it can also list each outdated container with its current and new version, linking to the release notes when WUD provides one.
|
||||
|
||||
### Supported Integrations
|
||||
|
||||
<WidgetIntegrations
|
||||
items={[
|
||||
{
|
||||
integration: wudIntegration,
|
||||
note: "Displays monitored container and available-update counts",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
### Adding the widget
|
||||
|
||||
<AddingWidget />
|
||||
|
||||
### Configuration
|
||||
|
||||
<WidgetConfig configuration={wudWidget.configuration} />
|
||||
@@ -0,0 +1,37 @@
|
||||
import { WidgetDefinition } from "@site/src/types";
|
||||
import { IconBrandDocker } from "@tabler/icons-react";
|
||||
|
||||
export const wudWidget: WidgetDefinition = {
|
||||
icon: IconBrandDocker,
|
||||
name: "What's Up Docker",
|
||||
description: "Displays how many watched containers have image updates available.",
|
||||
path: "../../widgets/whats-up-docker",
|
||||
configuration: {
|
||||
items: [
|
||||
{
|
||||
name: "Show title",
|
||||
description: "Displays the icon and \"What's Up Docker\" label above the stats",
|
||||
values: { type: "boolean" },
|
||||
defaultValue: "yes",
|
||||
},
|
||||
{
|
||||
name: "Show update ring",
|
||||
description: "Displays a ring showing the percentage of monitored containers with a pending update",
|
||||
values: { type: "boolean" },
|
||||
defaultValue: "yes",
|
||||
},
|
||||
{
|
||||
name: "Layout",
|
||||
description: "Arrange the two stats side by side (Horizontal) or stacked (Vertical)",
|
||||
values: { type: "select", options: ["Horizontal", "Vertical"] },
|
||||
defaultValue: "Horizontal",
|
||||
},
|
||||
{
|
||||
name: "Show container update list",
|
||||
description: "Lists each monitored container that has an image update available, linking to its release notes when known",
|
||||
values: { type: "boolean" },
|
||||
defaultValue: "no",
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
@@ -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<FormValues>;
|
||||
}
|
||||
|
||||
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 (
|
||||
<form
|
||||
onSubmit={form.onSubmit((values) => {
|
||||
savePartialSettings({
|
||||
id: board.id,
|
||||
...values,
|
||||
});
|
||||
})}
|
||||
>
|
||||
<Stack>
|
||||
<Grid>
|
||||
<Grid.Col span={{ sm: 12, md: 6 }}>
|
||||
<Stack gap="xs">
|
||||
<ColorInput
|
||||
label={t("board.field.primaryColor.label")}
|
||||
format="hex"
|
||||
swatches={Object.values(theme.colors).map((color) => color[6])}
|
||||
{...form.getInputProps("primaryColor")}
|
||||
/>
|
||||
</Stack>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={{ sm: 12, md: 6 }}>
|
||||
<SectionCard title={t("board.setting.section.appearance.title")}>
|
||||
<Grid>
|
||||
<Grid.Col span={{ sm: 12, md: 6 }}>
|
||||
<Stack gap="xs">
|
||||
<ColorInput
|
||||
label={t("board.field.secondaryColor.label")}
|
||||
label={t("board.field.primaryColor.label")}
|
||||
format="hex"
|
||||
swatches={Object.values(theme.colors).map((color) => color[6])}
|
||||
{...form.getInputProps("secondaryColor")}
|
||||
{...form.getInputProps("primaryColor")}
|
||||
/>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={12}>
|
||||
<Anchor onClick={toggle}>{showPreview ? t("common.preview.hide") : t("common.preview.show")}</Anchor>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={12}>
|
||||
<Collapse expanded={showPreview}>
|
||||
<Stack>
|
||||
<ColorsPreview previewColor={form.values.primaryColor} />
|
||||
<ColorsPreview previewColor={form.values.secondaryColor} />
|
||||
</Stack>
|
||||
</Collapse>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={{ sm: 12, md: 6 }}>
|
||||
<InputWrapper label={t("board.field.opacity.label")}>
|
||||
<Slider
|
||||
my={6}
|
||||
min={0}
|
||||
max={100}
|
||||
step={5}
|
||||
label={progressPercentageLabel}
|
||||
{...form.getInputProps("opacity")}
|
||||
/>
|
||||
</InputWrapper>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={{ sm: 12, md: 6 }}>
|
||||
<Group align="end">
|
||||
<ColorInput
|
||||
label={t("board.field.iconColor.label")}
|
||||
format="hex"
|
||||
swatches={Object.values(theme.colors).map((color) => color[6])}
|
||||
flex={1}
|
||||
{...form.getInputProps("iconColor")}
|
||||
/>
|
||||
<Button
|
||||
variant="subtle"
|
||||
leftSection={<IconX />}
|
||||
onClick={() => form.setFieldValue("iconColor", "")}
|
||||
disabled={!form.values.iconColor}
|
||||
>
|
||||
{t("board.field.clearColor.label")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={{ sm: 12, md: 6 }}>
|
||||
<Select
|
||||
label={t("board.field.itemRadius.label")}
|
||||
description={t("board.field.itemRadius.description")}
|
||||
data={[
|
||||
{ label: t("board.field.itemRadius.option.xs"), value: "xs" },
|
||||
{ label: t("board.field.itemRadius.option.sm"), value: "sm" },
|
||||
{ label: t("board.field.itemRadius.option.md"), value: "md" },
|
||||
{ label: t("board.field.itemRadius.option.lg"), value: "lg" },
|
||||
{ label: t("board.field.itemRadius.option.xl"), value: "xl" },
|
||||
]}
|
||||
{...form.getInputProps("itemRadius")}
|
||||
</Stack>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={{ sm: 12, md: 6 }}>
|
||||
<ColorInput
|
||||
label={t("board.field.secondaryColor.label")}
|
||||
format="hex"
|
||||
swatches={Object.values(theme.colors).map((color) => color[6])}
|
||||
{...form.getInputProps("secondaryColor")}
|
||||
/>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={12}>
|
||||
<Anchor onClick={toggle}>{showPreview ? t("common.preview.hide") : t("common.preview.show")}</Anchor>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={12}>
|
||||
<Collapse expanded={showPreview}>
|
||||
<Stack>
|
||||
<ColorsPreview previewColor={form.values.primaryColor} />
|
||||
<ColorsPreview previewColor={form.values.secondaryColor} />
|
||||
</Stack>
|
||||
</Collapse>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={{ sm: 12, md: 6 }}>
|
||||
<InputWrapper label={t("board.field.opacity.label")}>
|
||||
<Slider
|
||||
my={6}
|
||||
min={0}
|
||||
max={100}
|
||||
step={5}
|
||||
label={progressPercentageLabel}
|
||||
{...form.getInputProps("opacity")}
|
||||
/>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
<Group justify="end">
|
||||
<Button type="submit" loading={isPending}>
|
||||
{t("common.action.saveChanges")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</form>
|
||||
</InputWrapper>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={{ sm: 12, md: 6 }}>
|
||||
<Group align="end">
|
||||
<ColorInput
|
||||
label={t("board.field.iconColor.label")}
|
||||
format="hex"
|
||||
swatches={Object.values(theme.colors).map((color) => color[6])}
|
||||
flex={1}
|
||||
{...form.getInputProps("iconColor")}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="subtle"
|
||||
leftSection={<IconX />}
|
||||
onClick={() => form.setFieldValue("iconColor", "")}
|
||||
disabled={!form.values.iconColor}
|
||||
>
|
||||
{t("board.field.clearColor.label")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={{ sm: 12, md: 6 }}>
|
||||
<Select
|
||||
label={t("board.field.itemRadius.label")}
|
||||
description={t("board.field.itemRadius.description")}
|
||||
data={[
|
||||
{ label: t("board.field.itemRadius.option.xs"), value: "xs" },
|
||||
{ label: t("board.field.itemRadius.option.sm"), value: "sm" },
|
||||
{ label: t("board.field.itemRadius.option.md"), value: "md" },
|
||||
{ label: t("board.field.itemRadius.option.lg"), value: "lg" },
|
||||
{ label: t("board.field.itemRadius.option.xl"), value: "xl" },
|
||||
]}
|
||||
{...form.getInputProps("itemRadius")}
|
||||
/>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
</SectionCard>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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<FormValues>;
|
||||
}
|
||||
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 (
|
||||
<form
|
||||
onSubmit={form.onSubmit((values) => {
|
||||
savePartialSettings({
|
||||
id: board.id,
|
||||
...values,
|
||||
});
|
||||
})}
|
||||
>
|
||||
<Stack>
|
||||
<Grid>
|
||||
<Grid.Col span={12}>
|
||||
<Group wrap="nowrap" gap="xs" w="100%" align="start">
|
||||
<Autocomplete
|
||||
flex={1}
|
||||
leftSection={
|
||||
form.values.backgroundImageUrl &&
|
||||
form.values.backgroundImageUrl.trim().length >= 2 && (
|
||||
<Popover width={300} withArrow>
|
||||
<Popover.Target>
|
||||
<Center h="100%">
|
||||
<ImagePreview src={form.values.backgroundImageUrl} w={20} h={20} />
|
||||
</Center>
|
||||
</Popover.Target>
|
||||
<Popover.Dropdown>
|
||||
<ImagePreview src={form.values.backgroundImageUrl} w="100%" />
|
||||
</Popover.Dropdown>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
// 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;
|
||||
<SectionCard title={t("board.setting.section.background.title")}>
|
||||
<Grid>
|
||||
<Grid.Col span={12}>
|
||||
<Group wrap="nowrap" gap="xs" w="100%" align="start">
|
||||
<Autocomplete
|
||||
flex={1}
|
||||
leftSection={
|
||||
form.values.backgroundImageUrl &&
|
||||
form.values.backgroundImageUrl.trim().length >= 2 && (
|
||||
<Popover width={300} withArrow>
|
||||
<Popover.Target>
|
||||
<Center h="100%">
|
||||
<ImagePreview src={form.values.backgroundImageUrl} w={20} h={20} />
|
||||
</Center>
|
||||
</Popover.Target>
|
||||
<Popover.Dropdown>
|
||||
<ImagePreview src={form.values.backgroundImageUrl} w="100%" />
|
||||
</Popover.Dropdown>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
// 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 (
|
||||
<Group gap="sm">
|
||||
<ImagePreview src={option.value} w={20} h={20} />
|
||||
<Stack gap={0}>
|
||||
<Text size="sm">{current.name}</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{option.value}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Group>
|
||||
);
|
||||
return (
|
||||
<Group gap="sm">
|
||||
<ImagePreview src={option.value} w={20} h={20} />
|
||||
<Stack gap={0}>
|
||||
<Text size="sm">{current.name}</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{option.value}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Group>
|
||||
);
|
||||
}}
|
||||
data={[
|
||||
{
|
||||
group: t("board.field.backgroundImageUrl.group.your"),
|
||||
items: images
|
||||
.filter((media) => media.creatorId === session?.user.id)
|
||||
.map((media) => `/api/user-medias/${media.id}`),
|
||||
},
|
||||
{
|
||||
group: t("board.field.backgroundImageUrl.group.other"),
|
||||
items: images
|
||||
.filter((media) => media.creatorId !== session?.user.id)
|
||||
.map((media) => `/api/user-medias/${media.id}`),
|
||||
},
|
||||
]}
|
||||
{...form.getInputProps("backgroundImageUrl")}
|
||||
/>
|
||||
{session?.user.permissions.includes("media-upload") && (
|
||||
<UploadMedia
|
||||
onSuccess={(uploadedMedias) => {
|
||||
const first = uploadedMedias.at(0);
|
||||
if (!first) return;
|
||||
|
||||
startTransition(() => {
|
||||
form.setFieldValue("backgroundImageUrl", first.url);
|
||||
});
|
||||
}}
|
||||
data={[
|
||||
{
|
||||
group: t("board.field.backgroundImageUrl.group.your"),
|
||||
items: images
|
||||
.filter((media) => media.creatorId === session?.user.id)
|
||||
.map((media) => `/api/user-medias/${media.id}`),
|
||||
},
|
||||
{
|
||||
group: t("board.field.backgroundImageUrl.group.other"),
|
||||
items: images
|
||||
.filter((media) => media.creatorId !== session?.user.id)
|
||||
.map((media) => `/api/user-medias/${media.id}`),
|
||||
},
|
||||
]}
|
||||
{...form.getInputProps("backgroundImageUrl")}
|
||||
/>
|
||||
{session?.user.permissions.includes("media-upload") && (
|
||||
<UploadMedia
|
||||
onSuccess={(medias) => {
|
||||
const first = medias.at(0);
|
||||
if (!first) return;
|
||||
|
||||
startTransition(() => {
|
||||
form.setFieldValue("backgroundImageUrl", first.url);
|
||||
});
|
||||
}}
|
||||
>
|
||||
{({ onClick, loading }) => (
|
||||
<ActionIcon onClick={onClick} loading={loading} mt={24} size={36} variant="default">
|
||||
<IconUpload size={16} stroke={1.5} />
|
||||
</ActionIcon>
|
||||
)}
|
||||
</UploadMedia>
|
||||
)}
|
||||
</Group>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={12}>
|
||||
<SelectWithDescriptionBadge
|
||||
label={t("board.field.backgroundImageAttachment.label")}
|
||||
data={backgroundImageAttachmentData}
|
||||
{...form.getInputProps("backgroundImageAttachment")}
|
||||
/>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={12}>
|
||||
<SelectWithDescriptionBadge
|
||||
label={t("board.field.backgroundImageSize.label")}
|
||||
data={backgroundImageSizeData}
|
||||
{...form.getInputProps("backgroundImageSize")}
|
||||
/>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={12}>
|
||||
<SelectWithDescriptionBadge
|
||||
label={t("board.field.backgroundImageRepeat.label")}
|
||||
data={backgroundImageRepeatData}
|
||||
{...form.getInputProps("backgroundImageRepeat")}
|
||||
/>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
|
||||
<Group justify="end">
|
||||
<Button type="submit" loading={isPending}>
|
||||
{t("common.action.saveChanges")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</form>
|
||||
>
|
||||
{({ onClick, loading }) => (
|
||||
<ActionIcon onClick={onClick} loading={loading} mt={24} size={36} variant="default">
|
||||
<IconUpload size={16} stroke={1.5} />
|
||||
</ActionIcon>
|
||||
)}
|
||||
</UploadMedia>
|
||||
)}
|
||||
</Group>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={12}>
|
||||
<SelectWithDescriptionBadge
|
||||
label={t("board.field.backgroundImageAttachment.label")}
|
||||
data={backgroundImageAttachmentData}
|
||||
{...form.getInputProps("backgroundImageAttachment")}
|
||||
/>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={12}>
|
||||
<SelectWithDescriptionBadge
|
||||
label={t("board.field.backgroundImageSize.label")}
|
||||
data={backgroundImageSizeData}
|
||||
{...form.getInputProps("backgroundImageSize")}
|
||||
/>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={12}>
|
||||
<SelectWithDescriptionBadge
|
||||
label={t("board.field.backgroundImageRepeat.label")}
|
||||
data={backgroundImageRepeatData}
|
||||
{...form.getInputProps("backgroundImageRepeat")}
|
||||
/>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
</SectionCard>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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<FormValues>;
|
||||
}
|
||||
|
||||
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 (
|
||||
<form
|
||||
onSubmit={form.onSubmit((values) => {
|
||||
savePartialSettings({
|
||||
id: board.id,
|
||||
...values,
|
||||
});
|
||||
})}
|
||||
>
|
||||
<Stack>
|
||||
<Switch
|
||||
label={t("board.field.disableStatus.label")}
|
||||
description={t("board.field.disableStatus.description")}
|
||||
{...form.getInputProps("disableStatus", { type: "checkbox" })}
|
||||
/>
|
||||
|
||||
<Group justify="end">
|
||||
<Button type="submit" loading={isPending}>
|
||||
{t("common.action.saveChanges")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</form>
|
||||
<SectionCard title={t("board.setting.section.behavior.title")}>
|
||||
<Switch
|
||||
label={t("board.field.disableStatus.label")}
|
||||
description={t("board.field.disableStatus.description")}
|
||||
{...form.getInputProps("disableStatus", { type: "checkbox" })}
|
||||
/>
|
||||
</SectionCard>
|
||||
);
|
||||
};
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { Alert, Button, Group, Input, Stack } from "@mantine/core";
|
||||
import { Alert, Input } from "@mantine/core";
|
||||
import { highlight, languages } from "prismjs";
|
||||
import Editor from "react-simple-code-editor";
|
||||
|
||||
@@ -8,59 +8,39 @@ import "~/styles/prismjs.scss";
|
||||
|
||||
import { IconInfoCircle } from "@tabler/icons-react";
|
||||
|
||||
import { useForm } from "@homarr/form";
|
||||
import type { UseFormReturnType } from "@homarr/form";
|
||||
import { useI18n, useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import type { Board } from "../../_types";
|
||||
import { useSavePartialSettingsMutation } from "./_shared";
|
||||
import { SectionCard } from "~/components/manage/section-card";
|
||||
import type { FormValues } from "./_settings-form";
|
||||
import classes from "./customcss.module.css";
|
||||
|
||||
interface Props {
|
||||
board: Board;
|
||||
form: UseFormReturnType<FormValues>;
|
||||
}
|
||||
|
||||
export const CustomCssSettingsContent = ({ board }: Props) => {
|
||||
export const CustomCssSettingsContent = ({ form }: Props) => {
|
||||
const t = useI18n();
|
||||
const customCssT = useScopedI18n("board.field.customCss");
|
||||
const { mutate: savePartialSettings, isPending } = useSavePartialSettingsMutation(board);
|
||||
const form = useForm({
|
||||
initialValues: {
|
||||
customCss: board.customCss ?? "",
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={form.onSubmit((values) => {
|
||||
savePartialSettings({
|
||||
id: board.id,
|
||||
...values,
|
||||
});
|
||||
})}
|
||||
>
|
||||
<Stack>
|
||||
<CustomCssInput {...form.getInputProps("customCss")} />
|
||||
<SectionCard title={t("board.setting.section.customCss.title")}>
|
||||
<CustomCssInput {...form.getInputProps("customCss")} />
|
||||
|
||||
<Alert variant="light" color="cyan" title={customCssT("customClassesAlert.title")} icon={<IconInfoCircle />}>
|
||||
{customCssT("customClassesAlert.description")}
|
||||
</Alert>
|
||||
|
||||
<Group justify="end">
|
||||
<Button type="submit" loading={isPending}>
|
||||
{t("common.action.saveChanges")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</form>
|
||||
<Alert variant="light" color="cyan" title={customCssT("customClassesAlert.title")} icon={<IconInfoCircle />}>
|
||||
{customCssT("customClassesAlert.description")}
|
||||
</Alert>
|
||||
</SectionCard>
|
||||
);
|
||||
};
|
||||
|
||||
interface CustomCssInputProps {
|
||||
value?: string;
|
||||
onChange: (value: string) => void;
|
||||
error?: React.ReactNode;
|
||||
}
|
||||
|
||||
const CustomCssInput = ({ value, onChange }: CustomCssInputProps) => {
|
||||
const CustomCssInput = ({ value, onChange, error }: CustomCssInputProps) => {
|
||||
const customCssT = useScopedI18n("board.field.customCss");
|
||||
|
||||
return (
|
||||
@@ -70,6 +50,7 @@ const CustomCssInput = ({ value, onChange }: CustomCssInputProps) => {
|
||||
htmlFor: "custom-css",
|
||||
}}
|
||||
description={customCssT("description")}
|
||||
error={error}
|
||||
inputWrapperOrder={["label", "description", "input", "error"]}
|
||||
>
|
||||
<div className={classes.codeEditorRoot}>
|
||||
|
||||
@@ -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 (
|
||||
<Stack gap="sm">
|
||||
<Divider />
|
||||
<DangerZoneRow
|
||||
<DangerZoneRoot>
|
||||
<DangerZoneItem
|
||||
label={t("section.dangerZone.action.rename.label")}
|
||||
description={t("section.dangerZone.action.rename.description")}
|
||||
buttonText={t("section.dangerZone.action.rename.button")}
|
||||
onClick={onRenameClick}
|
||||
action={
|
||||
<Button variant="subtle" color="red" onClick={onRenameClick}>
|
||||
{t("section.dangerZone.action.rename.button")}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
{hideVisibility ? null : (
|
||||
<>
|
||||
<Divider />
|
||||
<DangerZoneRow
|
||||
label={t("section.dangerZone.action.visibility.label")}
|
||||
description={t(`section.dangerZone.action.visibility.description.${visibility}`)}
|
||||
buttonText={t(`section.dangerZone.action.visibility.button.${visibility}`)}
|
||||
onClick={onVisibilityClick}
|
||||
isPending={isChangeVisibilityPending}
|
||||
/>
|
||||
</>
|
||||
{!hideVisibility && (
|
||||
<DangerZoneItem
|
||||
label={t("section.dangerZone.action.visibility.label")}
|
||||
description={t(`section.dangerZone.action.visibility.description.${visibility}`)}
|
||||
action={
|
||||
<Button variant="subtle" color="red" loading={isChangeVisibilityPending} onClick={onVisibilityClick}>
|
||||
{t(`section.dangerZone.action.visibility.button.${visibility}`)}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<Divider />
|
||||
<DangerZoneRow
|
||||
<DangerZoneItem
|
||||
label={t("section.dangerZone.action.delete.label")}
|
||||
description={t("section.dangerZone.action.delete.description")}
|
||||
buttonText={t("section.dangerZone.action.delete.button")}
|
||||
onClick={onDeleteClick}
|
||||
isPending={isDeletePending}
|
||||
action={
|
||||
<Button variant="subtle" color="red" loading={isDeletePending} onClick={onDeleteClick}>
|
||||
{t("section.dangerZone.action.delete.button")}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
interface DangerZoneRowProps {
|
||||
label: string;
|
||||
description: string;
|
||||
buttonText: string;
|
||||
isPending?: boolean;
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
const DangerZoneRow = ({ label, description, buttonText, onClick, isPending }: DangerZoneRowProps) => {
|
||||
return (
|
||||
<Group justify="space-between" px="md" className={classes.dangerZoneGroup}>
|
||||
<Stack gap={0}>
|
||||
<Text fw="bold" size="sm">
|
||||
{label}
|
||||
</Text>
|
||||
<Text size="sm">{description}</Text>
|
||||
</Stack>
|
||||
<Group justify="end" w={{ base: "100%", xs: "auto" }}>
|
||||
<Button variant="subtle" color="red" loading={isPending} onClick={onClick}>
|
||||
{buttonText}
|
||||
</Button>
|
||||
</Group>
|
||||
</Group>
|
||||
</DangerZoneRoot>
|
||||
);
|
||||
};
|
||||
@@ -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<FormValues>;
|
||||
}
|
||||
|
||||
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 (
|
||||
<form
|
||||
onSubmit={form.onSubmit((values) => {
|
||||
// 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,
|
||||
});
|
||||
})}
|
||||
>
|
||||
<Stack>
|
||||
<Grid>
|
||||
<Grid.Col span={{ xs: 12, md: 6 }}>
|
||||
<TextInput
|
||||
label={t("board.field.pageTitle.label")}
|
||||
placeholder="Homarr"
|
||||
{...form.getInputProps("pageTitle")}
|
||||
/>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={{ xs: 12, md: 6 }}>
|
||||
<TextInput
|
||||
label={t("board.field.metaTitle.label")}
|
||||
placeholder={createMetaTitle(t("board.content.metaTitle", { boardName: board.name }))}
|
||||
rightSection={metaTitleStatus.isPending && <Loader size="xs" />}
|
||||
{...form.getInputProps("metaTitle")}
|
||||
/>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={{ xs: 12, md: 6 }}>
|
||||
<IconPicker
|
||||
{...form.getInputProps("logoImageUrl")}
|
||||
label={t("board.field.logoImageUrl.label")}
|
||||
placeholder="/logo/logo.png"
|
||||
withAsterisk={false}
|
||||
/>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={{ xs: 12, md: 6 }}>
|
||||
<IconPicker
|
||||
{...form.getInputProps("faviconImageUrl")}
|
||||
label={t("board.field.faviconImageUrl.label")}
|
||||
placeholder="/logo/logo.png"
|
||||
withAsterisk={false}
|
||||
/>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
<Group justify="end">
|
||||
<Button type="submit" loading={isPending}>
|
||||
{t("common.action.saveChanges")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</form>
|
||||
<SectionCard title={t("board.setting.section.general.title")}>
|
||||
<Grid>
|
||||
<Grid.Col span={{ xs: 12, md: 6 }}>
|
||||
<TextInput
|
||||
label={t("board.field.pageTitle.label")}
|
||||
placeholder="Homarr"
|
||||
{...form.getInputProps("pageTitle")}
|
||||
/>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={{ xs: 12, md: 6 }}>
|
||||
<TextInput
|
||||
label={t("board.field.metaTitle.label")}
|
||||
placeholder={createMetaTitle(t("board.content.metaTitle", { boardName: board.name }))}
|
||||
rightSection={metaTitleStatus.isPending && <Loader size="xs" />}
|
||||
{...form.getInputProps("metaTitle")}
|
||||
/>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={{ xs: 12, md: 6 }}>
|
||||
<IconPicker
|
||||
{...form.getInputProps("logoImageUrl")}
|
||||
label={t("board.field.logoImageUrl.label")}
|
||||
placeholder="/logo/logo.png"
|
||||
withAsterisk={false}
|
||||
/>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={{ xs: 12, md: 6 }}>
|
||||
<IconPicker
|
||||
{...form.getInputProps("faviconImageUrl")}
|
||||
label={t("board.field.faviconImageUrl.label")}
|
||||
placeholder="/logo/logo.png"
|
||||
withAsterisk={false}
|
||||
/>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
</SectionCard>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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<FormValues>;
|
||||
}
|
||||
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 (
|
||||
<form
|
||||
onSubmit={form.onSubmit((values) => {
|
||||
saveLayouts({
|
||||
id: board.id,
|
||||
...values,
|
||||
});
|
||||
})}
|
||||
>
|
||||
<Stack>
|
||||
<Stack gap="sm">
|
||||
<Group justify="space-between" align="center">
|
||||
<Text fw={500}>{t("board.setting.section.layout.responsive.title")}</Text>
|
||||
<Button
|
||||
variant="subtle"
|
||||
onClick={() => {
|
||||
form.setValues({
|
||||
layouts: [
|
||||
...form.values.layouts,
|
||||
{
|
||||
id: createId(),
|
||||
name: "",
|
||||
columnCount: 10,
|
||||
breakpoint: 0,
|
||||
},
|
||||
],
|
||||
});
|
||||
}}
|
||||
>
|
||||
{t("board.setting.section.layout.responsive.action.add")}
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
{form.values.layouts.map((layout, index) => (
|
||||
<Fieldset key={layout.id} legend={layout.name} bg="transparent">
|
||||
<Grid>
|
||||
<Grid.Col span={{ sm: 12, md: 6 }}>
|
||||
<TextInput {...form.getInputProps(`layouts.${index}.name`)} label={t("layout.field.name.label")} />
|
||||
</Grid.Col>
|
||||
|
||||
<Grid.Col span={{ sm: 12, md: 6 }}>
|
||||
<Input.Wrapper label={t("layout.field.columnCount.label")}>
|
||||
<Slider mt="xs" min={1} max={24} step={1} {...form.getInputProps(`layouts.${index}.columnCount`)} />
|
||||
</Input.Wrapper>
|
||||
</Grid.Col>
|
||||
|
||||
<Grid.Col span={{ sm: 12, md: 6 }}>
|
||||
<NumberInput
|
||||
{...form.getInputProps(`layouts.${index}.breakpoint`)}
|
||||
label={t("layout.field.breakpoint.label")}
|
||||
description={t("layout.field.breakpoint.description")}
|
||||
/>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
{form.values.layouts.length >= 2 && (
|
||||
<Group justify="end">
|
||||
<Button
|
||||
variant="subtle"
|
||||
onClick={() => {
|
||||
form.setValues((previous) =>
|
||||
previous.layouts !== undefined && previous.layouts.length >= 2
|
||||
? {
|
||||
layouts: form.values.layouts.filter((filteredLayout) => filteredLayout.id !== layout.id),
|
||||
}
|
||||
: previous,
|
||||
);
|
||||
}}
|
||||
>
|
||||
{t("common.action.remove")}
|
||||
</Button>
|
||||
</Group>
|
||||
)}
|
||||
</Fieldset>
|
||||
))}
|
||||
</Stack>
|
||||
|
||||
<Group justify="end">
|
||||
<Button type="submit" loading={isPending}>
|
||||
{t("common.action.saveChanges")}
|
||||
<SectionCard title={t("board.setting.section.layout.title")}>
|
||||
<Stack gap="sm">
|
||||
<Group justify="space-between" align="center">
|
||||
<Text fw={500}>{t("board.setting.section.layout.responsive.title")}</Text>
|
||||
<Button
|
||||
type="button"
|
||||
variant="subtle"
|
||||
onClick={() => {
|
||||
form.setValues((previous) => ({
|
||||
...previous,
|
||||
layouts: [...(previous.layouts ?? []), { id: createId(), name: "", columnCount: 10, breakpoint: 0 }],
|
||||
}));
|
||||
}}
|
||||
>
|
||||
{t("board.setting.section.layout.responsive.action.add")}
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
{form.values.layouts.map((layout, index) => (
|
||||
<Fieldset key={layout.id} legend={layout.name} bg="transparent">
|
||||
<Grid>
|
||||
<Grid.Col span={{ sm: 12, md: 6 }}>
|
||||
<TextInput {...form.getInputProps(`layouts.${index}.name`)} label={t("layout.field.name.label")} />
|
||||
</Grid.Col>
|
||||
|
||||
<Grid.Col span={{ sm: 12, md: 6 }}>
|
||||
<Input.Wrapper label={t("layout.field.columnCount.label")}>
|
||||
<Slider mt="xs" min={1} max={24} step={1} {...form.getInputProps(`layouts.${index}.columnCount`)} />
|
||||
</Input.Wrapper>
|
||||
</Grid.Col>
|
||||
|
||||
<Grid.Col span={{ sm: 12, md: 6 }}>
|
||||
<NumberInput
|
||||
{...form.getInputProps(`layouts.${index}.breakpoint`)}
|
||||
label={t("layout.field.breakpoint.label")}
|
||||
description={t("layout.field.breakpoint.description")}
|
||||
/>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
{form.values.layouts.length >= 2 && (
|
||||
<Group justify="end">
|
||||
<Button
|
||||
type="button"
|
||||
variant="subtle"
|
||||
onClick={() => {
|
||||
form.setValues((previous) => ({
|
||||
...previous,
|
||||
layouts: form.values.layouts.filter((filteredLayout) => filteredLayout.id !== layout.id),
|
||||
}));
|
||||
}}
|
||||
>
|
||||
{t("common.action.remove")}
|
||||
</Button>
|
||||
</Group>
|
||||
)}
|
||||
</Fieldset>
|
||||
))}
|
||||
</Stack>
|
||||
</form>
|
||||
</SectionCard>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,221 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
import { Button, Stack } from "@mantine/core";
|
||||
import type { z } from "zod/v4";
|
||||
|
||||
import type { RouterOutputs } from "@homarr/api";
|
||||
import { useUpdateBoard } from "@homarr/boards/updater";
|
||||
import { revalidatePathActionAsync } from "@homarr/common/client";
|
||||
import { env } from "@homarr/common/env";
|
||||
import { useZodForm } from "@homarr/form";
|
||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||
import { useI18n, useScopedI18n } from "@homarr/translation/client";
|
||||
import { boardSaveLayoutsSchema, boardSavePartialSettingsSchema } from "@homarr/validation/board";
|
||||
|
||||
import { homarrLogoPath } from "~/components/layout/logo/homarr-logo";
|
||||
import { SectionCard } from "~/components/manage/section-card";
|
||||
import { UnsavedChangesBar } from "~/components/manage/unsaved-changes-bar";
|
||||
|
||||
import type { Board } from "../../_types";
|
||||
import { ColorSettingsContent } from "./_appereance";
|
||||
import { BackgroundSettingsContent } from "./_background";
|
||||
import { BehaviorSettingsContent } from "./_behavior";
|
||||
import { BoardAccessSettings } from "./_board-access";
|
||||
import { CustomCssSettingsContent } from "./_customCss";
|
||||
import { DangerZoneSettingsContent } from "./_danger";
|
||||
import { GeneralSettingsContent } from "./_general";
|
||||
import { LayoutSettingsContent } from "./_layout";
|
||||
import { useSaveLayoutsMutation, useSavePartialSettingsMutation } from "./_shared";
|
||||
|
||||
const boardSettingsFormSchema = boardSavePartialSettingsSchema
|
||||
.extend({
|
||||
layouts: boardSaveLayoutsSchema.shape.layouts,
|
||||
})
|
||||
.required();
|
||||
|
||||
export type FormValues = z.infer<typeof boardSettingsFormSchema>;
|
||||
|
||||
const PARTIAL_FORM_KEYS = [
|
||||
"pageTitle",
|
||||
"metaTitle",
|
||||
"logoImageUrl",
|
||||
"faviconImageUrl",
|
||||
"backgroundImageUrl",
|
||||
"backgroundImageAttachment",
|
||||
"backgroundImageRepeat",
|
||||
"backgroundImageSize",
|
||||
"primaryColor",
|
||||
"secondaryColor",
|
||||
"opacity",
|
||||
"iconColor",
|
||||
"itemRadius",
|
||||
"customCss",
|
||||
"disableStatus",
|
||||
] as const;
|
||||
|
||||
const buildInitialValues = (board: Board): FormValues => ({
|
||||
pageTitle: board.pageTitle ?? "",
|
||||
metaTitle: board.metaTitle ?? "",
|
||||
logoImageUrl: board.logoImageUrl ?? "",
|
||||
faviconImageUrl: board.faviconImageUrl ?? "",
|
||||
backgroundImageUrl: board.backgroundImageUrl ?? "",
|
||||
backgroundImageAttachment: board.backgroundImageAttachment,
|
||||
backgroundImageRepeat: board.backgroundImageRepeat,
|
||||
backgroundImageSize: board.backgroundImageSize,
|
||||
primaryColor: board.primaryColor,
|
||||
secondaryColor: board.secondaryColor,
|
||||
opacity: board.opacity,
|
||||
iconColor: board.iconColor ?? "",
|
||||
itemRadius: board.itemRadius,
|
||||
customCss: board.customCss ?? "",
|
||||
disableStatus: board.disableStatus,
|
||||
layouts: board.layouts,
|
||||
});
|
||||
|
||||
interface BoardSettingsFormProps {
|
||||
board: Board;
|
||||
permissions: RouterOutputs["board"]["getBoardPermissions"];
|
||||
hasFullAccess: boolean;
|
||||
hideVisibility: boolean;
|
||||
}
|
||||
|
||||
export const BoardSettingsForm = ({ board, permissions, hasFullAccess, hideVisibility }: BoardSettingsFormProps) => {
|
||||
const t = useI18n();
|
||||
const tSection = useScopedI18n("board.setting.section");
|
||||
const { updateBoard } = useUpdateBoard();
|
||||
const savePartialSettings = useSavePartialSettingsMutation(board);
|
||||
const saveLayouts = useSaveLayoutsMutation(board);
|
||||
|
||||
const form = useZodForm(boardSettingsFormSchema, {
|
||||
initialValues: buildInitialValues(board),
|
||||
onValuesChange({ pageTitle }) {
|
||||
updateBoard((previous) => ({
|
||||
...previous,
|
||||
pageTitle,
|
||||
}));
|
||||
},
|
||||
});
|
||||
|
||||
const initialValuesRef = useRef(buildInitialValues(board));
|
||||
const lastSavedRef = useRef({ pageTitle: board.pageTitle, logoImageUrl: board.logoImageUrl });
|
||||
|
||||
const isDirtyRef = useRef(false);
|
||||
isDirtyRef.current = form.isDirty();
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
updateBoard((previous) => ({
|
||||
...previous,
|
||||
pageTitle: lastSavedRef.current.pageTitle,
|
||||
logoImageUrl: lastSavedRef.current.logoImageUrl,
|
||||
}));
|
||||
};
|
||||
}, [updateBoard]);
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (event: BeforeUnloadEvent) => {
|
||||
if (env.NODE_ENV === "development") return;
|
||||
if (isDirtyRef.current) event.preventDefault();
|
||||
};
|
||||
window.addEventListener("beforeunload", handler);
|
||||
return () => window.removeEventListener("beforeunload", handler);
|
||||
}, []);
|
||||
|
||||
const handleSubmitAsync = async (values: FormValues) => {
|
||||
const defaults = initialValuesRef.current;
|
||||
const changed = <TKey extends keyof FormValues>(...fields: TKey[]) =>
|
||||
fields.some((field) => values[field] !== defaults[field]);
|
||||
|
||||
const { layouts, ...partialSettings } = values;
|
||||
|
||||
const saveActions: { when: boolean; action: () => Promise<unknown> }[] = [
|
||||
{
|
||||
when: changed(...PARTIAL_FORM_KEYS),
|
||||
action: () =>
|
||||
savePartialSettings.mutateAsync({ id: board.id, ...partialSettings }).then(() => {
|
||||
updateFavicon(values.faviconImageUrl ?? homarrLogoPath);
|
||||
}),
|
||||
},
|
||||
{
|
||||
when: changed("layouts"),
|
||||
action: () => saveLayouts.mutateAsync({ id: board.id, layouts }),
|
||||
},
|
||||
];
|
||||
|
||||
const promises = saveActions.filter((saveAction) => saveAction.when).map((saveAction) => saveAction.action());
|
||||
if (promises.length === 0) return;
|
||||
|
||||
try {
|
||||
await Promise.all(promises);
|
||||
lastSavedRef.current = { pageTitle: values.pageTitle, logoImageUrl: values.logoImageUrl };
|
||||
initialValuesRef.current = values;
|
||||
form.setInitialValues(values);
|
||||
form.resetDirty();
|
||||
await revalidatePathActionAsync(`/boards/${board.name}/settings`);
|
||||
showSuccessNotification({
|
||||
title: t("common.notification.update.success"),
|
||||
message: t("common.notification.update.success"),
|
||||
});
|
||||
} catch {
|
||||
showErrorNotification({
|
||||
title: t("common.notification.update.error"),
|
||||
message: t("common.notification.update.error"),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleDiscard = () => {
|
||||
form.setValues(initialValuesRef.current);
|
||||
form.resetDirty();
|
||||
updateBoard((previous) => ({
|
||||
...previous,
|
||||
pageTitle: lastSavedRef.current.pageTitle,
|
||||
logoImageUrl: lastSavedRef.current.logoImageUrl,
|
||||
}));
|
||||
};
|
||||
|
||||
const isPending = savePartialSettings.isPending || saveLayouts.isPending;
|
||||
|
||||
return (
|
||||
<Stack gap="xl">
|
||||
<form onSubmit={form.onSubmit((values) => void handleSubmitAsync(values))}>
|
||||
<Stack gap="xl">
|
||||
<GeneralSettingsContent board={board} form={form} />
|
||||
<LayoutSettingsContent form={form} />
|
||||
<BackgroundSettingsContent form={form} />
|
||||
<ColorSettingsContent form={form} />
|
||||
<CustomCssSettingsContent form={form} />
|
||||
<BehaviorSettingsContent form={form} />
|
||||
|
||||
{form.isDirty() && (
|
||||
<UnsavedChangesBar>
|
||||
<Button type="button" disabled={isPending} variant="default" onClick={handleDiscard}>
|
||||
{t("common.action.discard")}
|
||||
</Button>
|
||||
<Button loading={isPending} type="submit" disabled={!form.isValid()}>
|
||||
{t("common.action.saveChanges")}
|
||||
</Button>
|
||||
</UnsavedChangesBar>
|
||||
)}
|
||||
</Stack>
|
||||
</form>
|
||||
|
||||
{hasFullAccess && (
|
||||
<SectionCard title={tSection("access.title")}>
|
||||
<BoardAccessSettings board={board} initialPermissions={permissions} />
|
||||
</SectionCard>
|
||||
)}
|
||||
|
||||
{hasFullAccess && <DangerZoneSettingsContent hideVisibility={hideVisibility} />}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
// Previously part of the general settings section; applied on the unified save
|
||||
// https://github.com/homarr-labs/homarr/issues/4905
|
||||
const updateFavicon = (url: string) => {
|
||||
const link = document.querySelector<HTMLLinkElement>('link[rel="icon"]');
|
||||
if (!link) return;
|
||||
link.href = url;
|
||||
};
|
||||
@@ -11,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();
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -1,5 +0,0 @@
|
||||
@media (min-width: 36em) {
|
||||
.dangerZoneGroup {
|
||||
--group-wrap: nowrap !important;
|
||||
}
|
||||
}
|
||||
@@ -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<Props["params"]>) => {
|
||||
@@ -57,11 +33,7 @@ const getBoardAndPermissionsAsync = async (params: Awaited<Props["params"]>) =>
|
||||
} 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<Props["params"]>) =>
|
||||
};
|
||||
|
||||
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) {
|
||||
<Container>
|
||||
<Stack>
|
||||
<Title>{t("title", { boardName: capitalize(board.name) })}</Title>
|
||||
<ActiveTabAccordion variant="separated" defaultValue={searchParams.tab ?? "general"}>
|
||||
<AccordionItemFor value="general" icon={IconSettings}>
|
||||
<GeneralSettingsContent board={board} />
|
||||
</AccordionItemFor>
|
||||
<AccordionItemFor value="layout" icon={IconLayout}>
|
||||
<LayoutSettingsContent board={board} />
|
||||
</AccordionItemFor>
|
||||
<AccordionItemFor value="background" icon={IconPhoto}>
|
||||
<BackgroundSettingsContent board={board} />
|
||||
</AccordionItemFor>
|
||||
<AccordionItemFor value="appearance" icon={IconBrush}>
|
||||
<ColorSettingsContent board={board} />
|
||||
</AccordionItemFor>
|
||||
<AccordionItemFor value="customCss" icon={IconFileTypeCss}>
|
||||
<CustomCssSettingsContent board={board} />
|
||||
</AccordionItemFor>
|
||||
<AccordionItemFor value="behavior" icon={IconClick}>
|
||||
<BehaviorSettingsContent board={board} />
|
||||
</AccordionItemFor>
|
||||
{hasFullAccess && (
|
||||
<>
|
||||
<AccordionItemFor value="access" icon={IconUser}>
|
||||
<BoardAccessSettings board={board} initialPermissions={permissions} />
|
||||
</AccordionItemFor>
|
||||
<AccordionItemFor value="dangerZone" icon={IconAlertTriangle} danger noPadding>
|
||||
<DangerZoneSettingsContent
|
||||
hideVisibility={
|
||||
boardSettings.homeBoardId === board.id || boardSettings.mobileHomeBoardId === board.id
|
||||
}
|
||||
/>
|
||||
</AccordionItemFor>
|
||||
</>
|
||||
)}
|
||||
</ActiveTabAccordion>
|
||||
<BoardSettingsForm
|
||||
board={board}
|
||||
permissions={permissions}
|
||||
hasFullAccess={hasFullAccess}
|
||||
hideVisibility={boardSettings.homeBoardId === board.id || boardSettings.mobileHomeBoardId === board.id}
|
||||
/>
|
||||
</Stack>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
type AccordionItemForProps = PropsWithChildren<{
|
||||
value: keyof TranslationObject["board"]["setting"]["section"];
|
||||
icon: TablerIcon;
|
||||
danger?: boolean;
|
||||
noPadding?: boolean;
|
||||
}>;
|
||||
|
||||
const AccordionItemFor = async ({ value, children, icon: Icon, danger, noPadding }: AccordionItemForProps) => {
|
||||
const t = await getScopedI18n("board.setting.section");
|
||||
return (
|
||||
<AccordionItem
|
||||
value={value}
|
||||
styles={
|
||||
danger
|
||||
? {
|
||||
item: {
|
||||
"--__item-border-color": "rgba(248,81,73,0.4)",
|
||||
borderWidth: 4,
|
||||
},
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<AccordionControl icon={<Icon />}>
|
||||
<Text fw="bold" size="lg">
|
||||
{t(`${value}.title`)}
|
||||
</Text>
|
||||
</AccordionControl>
|
||||
<AccordionPanel styles={noPadding ? { content: { paddingRight: 0, paddingLeft: 0 } } : undefined}>
|
||||
{children}
|
||||
</AccordionPanel>
|
||||
</AccordionItem>
|
||||
);
|
||||
};
|
||||
@@ -697,7 +697,11 @@ export function CustomWidgetForm({ mode, initialValues, definitionId }: CustomWi
|
||||
<Stack gap="sm">
|
||||
<TextInput label={t("field.name")} required {...form.getInputProps("name")} />
|
||||
<Textarea label={t("field.description")} {...form.getInputProps("description")} />
|
||||
<IconPicker withAsterisk={false} {...form.getInputProps("iconUrl")} />
|
||||
<IconPicker
|
||||
withAsterisk={false}
|
||||
suggestedSearch={mode === "create" ? form.values.name : undefined}
|
||||
{...form.getInputProps("iconUrl")}
|
||||
/>
|
||||
</Stack>
|
||||
</Fieldset>
|
||||
|
||||
|
||||
@@ -57,7 +57,10 @@ export const SearchEngineForm = (props: SearchEngineFormProps) => {
|
||||
/>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
<IconPicker {...form.getInputProps("iconUrl")} />
|
||||
<IconPicker
|
||||
{...form.getInputProps("iconUrl")}
|
||||
suggestedSearch={initialValues === undefined ? form.values.name : undefined}
|
||||
/>
|
||||
|
||||
<Fieldset legend={t("search.engine.page.edit.configControl")}>
|
||||
<SegmentedControl
|
||||
|
||||
@@ -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<FormValues>;
|
||||
}
|
||||
|
||||
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 (
|
||||
<>
|
||||
<Title order={2}>{t("title")}</Title>
|
||||
|
||||
<Card pos="relative">
|
||||
<LoadingOverlay visible={isPending} />
|
||||
<Stack>
|
||||
<SwitchSetting form={form} formKey="enableGeneral" title={t("general.title")} text={t("general.text")} />
|
||||
</Stack>
|
||||
</Card>
|
||||
</>
|
||||
<SectionCard title={t("title")}>
|
||||
<SwitchSetting form={form} formKey="enableGeneral" title={t("general.title")} text={t("general.text")} />
|
||||
</SectionCard>
|
||||
);
|
||||
};
|
||||
+21
-20
@@ -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<FormValues>;
|
||||
}
|
||||
|
||||
export const AppearanceSettingsForm = ({ form }: AppearanceSettingsFormProps) => {
|
||||
const tAppearance = useScopedI18n("management.page.settings.section.appearance");
|
||||
|
||||
return (
|
||||
<CommonSettingsForm settingKey="appearance" defaultValues={defaultValues}>
|
||||
{(form) => (
|
||||
<>
|
||||
<SelectWithCustomItems
|
||||
label={tApperance("defaultColorScheme.label")}
|
||||
data={colorSchemes.map((scheme) => ({
|
||||
value: scheme,
|
||||
label: tApperance(`defaultColorScheme.options.${scheme}`),
|
||||
}))}
|
||||
{...form.getInputProps("defaultColorScheme")}
|
||||
SelectOption={ApperanceCustomOption}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</CommonSettingsForm>
|
||||
<SectionCard title={tAppearance("title")}>
|
||||
<SelectWithCustomItems
|
||||
label={tAppearance("defaultColorScheme.label")}
|
||||
data={colorSchemes.map((scheme) => ({
|
||||
value: scheme,
|
||||
label: tAppearance(`defaultColorScheme.options.${scheme}`),
|
||||
}))}
|
||||
{...form.getInputProps("defaultColorScheme")}
|
||||
SelectOption={AppearanceCustomOption}
|
||||
/>
|
||||
</SectionCard>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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<FormValues>;
|
||||
}
|
||||
|
||||
export const BoardSettingsForm = ({ form }: BoardSettingsFormProps) => {
|
||||
const tBoard = useScopedI18n("management.page.settings.section.board");
|
||||
const [selectableBoards] = clientApi.board.getPublicBoards.useSuspenseQuery();
|
||||
|
||||
return (
|
||||
<CommonSettingsForm settingKey="board" defaultValues={defaultValues}>
|
||||
{(form) => (
|
||||
<>
|
||||
<BoardSelect
|
||||
label={tBoard("homeBoard.label")}
|
||||
description={tBoard("homeBoard.description")}
|
||||
clearable
|
||||
boards={selectableBoards}
|
||||
{...form.getInputProps("homeBoardId")}
|
||||
/>
|
||||
<SectionCard title={tBoard("title")}>
|
||||
<BoardSelect
|
||||
label={tBoard("homeBoard.label")}
|
||||
description={tBoard("homeBoard.description")}
|
||||
clearable
|
||||
boards={selectableBoards}
|
||||
{...form.getInputProps("homeBoardId")}
|
||||
/>
|
||||
|
||||
<BoardSelect
|
||||
label={tBoard("homeBoard.mobileLabel")}
|
||||
description={tBoard("homeBoard.description")}
|
||||
clearable
|
||||
boards={selectableBoards}
|
||||
{...form.getInputProps("mobileHomeBoardId")}
|
||||
/>
|
||||
<BoardSelect
|
||||
label={tBoard("homeBoard.mobileLabel")}
|
||||
description={tBoard("homeBoard.description")}
|
||||
clearable
|
||||
boards={selectableBoards}
|
||||
{...form.getInputProps("mobileHomeBoardId")}
|
||||
/>
|
||||
|
||||
<Text fw={500}>{tBoard("status.title")}</Text>
|
||||
<Switch
|
||||
{...form.getInputProps("enableStatusByDefault", { type: "checkbox" })}
|
||||
label={tBoard("status.enableStatusByDefault.label")}
|
||||
description={tBoard("status.enableStatusByDefault.description")}
|
||||
/>
|
||||
<Switch
|
||||
{...form.getInputProps("forceDisableStatus", { type: "checkbox" })}
|
||||
label={tBoard("status.forceDisableStatus.label")}
|
||||
description={tBoard("status.forceDisableStatus.description")}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</CommonSettingsForm>
|
||||
<Text fw={500}>{tBoard("status.title")}</Text>
|
||||
<Switch
|
||||
{...form.getInputProps("enableStatusByDefault", { type: "checkbox" })}
|
||||
label={tBoard("status.enableStatusByDefault.label")}
|
||||
description={tBoard("status.enableStatusByDefault.description")}
|
||||
/>
|
||||
<Switch
|
||||
{...form.getInputProps("forceDisableStatus", { type: "checkbox" })}
|
||||
label={tBoard("status.forceDisableStatus.label")}
|
||||
description={tBoard("status.forceDisableStatus.description")}
|
||||
/>
|
||||
</SectionCard>
|
||||
);
|
||||
};
|
||||
@@ -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 = <TKey extends keyof ServerSettings>({
|
||||
settingKey,
|
||||
defaultValues,
|
||||
children,
|
||||
}: {
|
||||
settingKey: TKey;
|
||||
defaultValues: ServerSettings[TKey];
|
||||
children: (form: ReturnType<typeof useForm<ServerSettings[TKey]>>) => React.ReactNode;
|
||||
}) => {
|
||||
const t = useI18n();
|
||||
const tSettings = useScopedI18n("management.page.settings");
|
||||
const { mutateAsync, isPending } = clientApi.serverSettings.saveSettings.useMutation({
|
||||
onSuccess() {
|
||||
showSuccessNotification({
|
||||
message: tSettings("notification.success.message"),
|
||||
});
|
||||
},
|
||||
onError() {
|
||||
showErrorNotification({
|
||||
message: tSettings("notification.error.message"),
|
||||
});
|
||||
},
|
||||
});
|
||||
const form = useForm({
|
||||
initialValues: defaultValues,
|
||||
});
|
||||
|
||||
const handleSubmitAsync = async (values: ServerSettings[TKey]) => {
|
||||
await mutateAsync({
|
||||
settingsKey: settingKey,
|
||||
value: values,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={form.onSubmit((values) => void handleSubmitAsync(values))}>
|
||||
<Stack gap="sm">
|
||||
{children(form)}
|
||||
<Group justify="end">
|
||||
<Button type="submit" loading={isPending}>
|
||||
{t("common.action.save")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
+21
-54
@@ -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<FormValues>;
|
||||
}
|
||||
|
||||
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 (
|
||||
<>
|
||||
<Title order={2}>{t("title")}</Title>
|
||||
|
||||
<Card pos="relative">
|
||||
<Text c={"dimmed"} mb={"lg"}>
|
||||
{t("warning")}
|
||||
</Text>
|
||||
<LoadingOverlay visible={isPending} />
|
||||
<Stack>
|
||||
<SwitchSetting form={form} formKey="noIndex" title={t("noIndex.title")} text={t("noIndex.text")} />
|
||||
<SwitchSetting form={form} formKey="noFollow" title={t("noFollow.title")} text={t("noFollow.text")} />
|
||||
<SwitchSetting
|
||||
form={form}
|
||||
formKey="noTranslate"
|
||||
title={t("noTranslate.title")}
|
||||
text={t("noTranslate.text")}
|
||||
/>
|
||||
<SwitchSetting
|
||||
form={form}
|
||||
formKey="noSiteLinksSearchBox"
|
||||
title={t("noSiteLinksSearchBox.title")}
|
||||
text={t("noSiteLinksSearchBox.text")}
|
||||
/>
|
||||
</Stack>
|
||||
</Card>
|
||||
</>
|
||||
<SectionCard title={t("title")}>
|
||||
<Text c="dimmed" size="sm">
|
||||
{t("warning")}
|
||||
</Text>
|
||||
<SwitchSetting form={form} formKey="noIndex" title={t("noIndex.title")} text={t("noIndex.text")} />
|
||||
<SwitchSetting form={form} formKey="noFollow" title={t("noFollow.title")} text={t("noFollow.text")} />
|
||||
<SwitchSetting form={form} formKey="noTranslate" title={t("noTranslate.title")} text={t("noTranslate.text")} />
|
||||
<SwitchSetting
|
||||
form={form}
|
||||
formKey="noSiteLinksSearchBox"
|
||||
title={t("noSiteLinksSearchBox.title")}
|
||||
text={t("noSiteLinksSearchBox.text")}
|
||||
/>
|
||||
</SectionCard>
|
||||
);
|
||||
};
|
||||
+16
-14
@@ -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<FormValues>;
|
||||
}
|
||||
|
||||
export const CultureSettingsForm = ({ form }: CultureSettingsFormProps) => {
|
||||
const tCulture = useScopedI18n("management.page.settings.section.culture");
|
||||
const localeInputProps = form.getInputProps("defaultLocale");
|
||||
|
||||
return (
|
||||
<CommonSettingsForm settingKey="culture" defaultValues={defaultValues}>
|
||||
{(form) => (
|
||||
<>
|
||||
<LanguageCombobox
|
||||
label={tCulture("defaultLocale.label")}
|
||||
value={form.getInputProps("defaultLocale").value as SupportedLanguage}
|
||||
{...form.getInputProps("defaultLocale")}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</CommonSettingsForm>
|
||||
<SectionCard title={tCulture("title")}>
|
||||
<LanguageCombobox
|
||||
label={tCulture("defaultLocale.label")}
|
||||
{...localeInputProps}
|
||||
value={localeInputProps.value as SupportedLanguage}
|
||||
/>
|
||||
</SectionCard>
|
||||
);
|
||||
};
|
||||
@@ -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<FormValues>;
|
||||
}
|
||||
|
||||
export const SearchSettingsForm = ({ form }: SearchSettingsFormProps) => {
|
||||
const tSearch = useScopedI18n("management.page.settings.section.search");
|
||||
const [selectableSearchEngines] = clientApi.searchEngine.getSelectable.useSuspenseQuery({ withIntegrations: false });
|
||||
|
||||
return (
|
||||
<CommonSettingsForm settingKey="search" defaultValues={defaultValues}>
|
||||
{(form) => (
|
||||
<>
|
||||
<Select
|
||||
label={tSearch("defaultSearchEngine.label")}
|
||||
description={tSearch("defaultSearchEngine.description")}
|
||||
data={selectableSearchEngines}
|
||||
{...form.getInputProps("defaultSearchEngineId")}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</CommonSettingsForm>
|
||||
<SectionCard title={tSearch("title")}>
|
||||
<Select
|
||||
label={tSearch("defaultSearchEngine.label")}
|
||||
description={tSearch("defaultSearchEngine.description")}
|
||||
data={selectableSearchEngines}
|
||||
{...form.getInputProps("defaultSearchEngineId")}
|
||||
/>
|
||||
</SectionCard>
|
||||
);
|
||||
};
|
||||
@@ -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 = <TFormValue extends Record<string, boolean>>({
|
||||
type BooleanKeys<TFormValue> = {
|
||||
[TKey in keyof TFormValue]: TFormValue[TKey] extends boolean ? TKey : never;
|
||||
}[keyof TFormValue];
|
||||
|
||||
export const SwitchSetting = <TFormValue extends Record<string, unknown>>({
|
||||
form,
|
||||
ms,
|
||||
title,
|
||||
@@ -14,13 +17,8 @@ export const SwitchSetting = <TFormValue extends Record<string, boolean>>({
|
||||
formKey,
|
||||
disabled,
|
||||
}: {
|
||||
form: Modify<
|
||||
UseFormReturnType<TFormValue, TFormValue>,
|
||||
{
|
||||
setFieldValue: (key: keyof TFormValue, value: (previous: boolean) => boolean) => void;
|
||||
}
|
||||
>;
|
||||
formKey: keyof TFormValue;
|
||||
form: UseFormReturnType<TFormValue, TFormValue>;
|
||||
formKey: string & BooleanKeys<TFormValue>;
|
||||
ms?: MantineSpacing;
|
||||
title: string;
|
||||
text: ReactNode;
|
||||
@@ -31,7 +29,7 @@ export const SwitchSetting = <TFormValue extends Record<string, boolean>>({
|
||||
return;
|
||||
}
|
||||
|
||||
form.setFieldValue(formKey, (previous) => !previous);
|
||||
form.setFieldValue(formKey, (previous) => !previous as never);
|
||||
}, [form, formKey, disabled]);
|
||||
|
||||
return (
|
||||
@@ -44,7 +42,7 @@ export const SwitchSetting = <TFormValue extends Record<string, boolean>>({
|
||||
</Text>
|
||||
</Stack>
|
||||
</UnstyledButton>
|
||||
<Switch disabled={disabled} onClick={handleClick} checked={form.values[formKey] && !disabled} />
|
||||
<Switch disabled={disabled} onClick={handleClick} checked={Boolean(form.values[formKey])} />
|
||||
</Group>
|
||||
);
|
||||
};
|
||||
@@ -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<typeof settingsFormSchema>;
|
||||
|
||||
const buildInitialValues = (initialData: ServerSettings): FormValues => ({
|
||||
enableGeneral: initialData.analytics.enableGeneral,
|
||||
noIndex: initialData.crawlingAndIndexing.noIndex,
|
||||
noFollow: initialData.crawlingAndIndexing.noFollow,
|
||||
noTranslate: initialData.crawlingAndIndexing.noTranslate,
|
||||
noSiteLinksSearchBox: initialData.crawlingAndIndexing.noSiteLinksSearchBox,
|
||||
homeBoardId: initialData.board.homeBoardId,
|
||||
mobileHomeBoardId: initialData.board.mobileHomeBoardId,
|
||||
enableStatusByDefault: initialData.board.enableStatusByDefault,
|
||||
forceDisableStatus: initialData.board.forceDisableStatus,
|
||||
enableGravatar: initialData.user.enableGravatar,
|
||||
defaultSearchEngineId: initialData.search.defaultSearchEngineId,
|
||||
defaultColorScheme: initialData.appearance.defaultColorScheme,
|
||||
defaultLocale: initialData.culture.defaultLocale,
|
||||
});
|
||||
|
||||
interface SettingsFormProps {
|
||||
initialData: ServerSettings;
|
||||
}
|
||||
|
||||
export const SettingsForm = ({ initialData }: SettingsFormProps) => {
|
||||
const t = useI18n();
|
||||
const tSettings = useScopedI18n("management.page.settings");
|
||||
|
||||
const initialValues = buildInitialValues(initialData);
|
||||
const initialValuesRef = useRef(initialValues);
|
||||
|
||||
const form = useZodForm(settingsFormSchema, {
|
||||
initialValues,
|
||||
});
|
||||
|
||||
const isDirtyRef = useRef(false);
|
||||
isDirtyRef.current = form.isDirty();
|
||||
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
const saveSettingsMutation = clientApi.serverSettings.saveSettings.useMutation({
|
||||
onError(error) {
|
||||
showErrorNotification({ title: t("common.notification.update.error"), message: error.message });
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (event: BeforeUnloadEvent) => {
|
||||
if (env.NODE_ENV === "development") return;
|
||||
if (isDirtyRef.current) event.preventDefault();
|
||||
};
|
||||
window.addEventListener("beforeunload", handler);
|
||||
return () => window.removeEventListener("beforeunload", handler);
|
||||
}, []);
|
||||
|
||||
const handleSubmitAsync = async (values: FormValues) => {
|
||||
const defaults = initialValuesRef.current;
|
||||
const changed = <TKey extends keyof FormValues>(...fields: TKey[]) =>
|
||||
fields.some((field) => values[field] !== defaults[field]);
|
||||
|
||||
const groups: {
|
||||
settingsKey: (typeof defaultServerSettingsKeys)[number];
|
||||
when: boolean;
|
||||
value: Record<string, unknown>;
|
||||
}[] = [
|
||||
{ settingsKey: "analytics", when: changed("enableGeneral"), value: { enableGeneral: values.enableGeneral } },
|
||||
{
|
||||
settingsKey: "crawlingAndIndexing",
|
||||
when: changed("noIndex", "noFollow", "noTranslate", "noSiteLinksSearchBox"),
|
||||
value: {
|
||||
noIndex: values.noIndex,
|
||||
noFollow: values.noFollow,
|
||||
noTranslate: values.noTranslate,
|
||||
noSiteLinksSearchBox: values.noSiteLinksSearchBox,
|
||||
},
|
||||
},
|
||||
{
|
||||
settingsKey: "board",
|
||||
when: changed("homeBoardId", "mobileHomeBoardId", "enableStatusByDefault", "forceDisableStatus"),
|
||||
value: {
|
||||
homeBoardId: values.homeBoardId,
|
||||
mobileHomeBoardId: values.mobileHomeBoardId,
|
||||
enableStatusByDefault: values.enableStatusByDefault,
|
||||
forceDisableStatus: values.forceDisableStatus,
|
||||
},
|
||||
},
|
||||
{ settingsKey: "user", when: changed("enableGravatar"), value: { enableGravatar: values.enableGravatar } },
|
||||
{
|
||||
settingsKey: "search",
|
||||
when: changed("defaultSearchEngineId"),
|
||||
value: { defaultSearchEngineId: values.defaultSearchEngineId },
|
||||
},
|
||||
{
|
||||
settingsKey: "appearance",
|
||||
when: changed("defaultColorScheme"),
|
||||
value: { defaultColorScheme: values.defaultColorScheme },
|
||||
},
|
||||
{ settingsKey: "culture", when: changed("defaultLocale"), value: { defaultLocale: values.defaultLocale } },
|
||||
];
|
||||
|
||||
const promises = groups
|
||||
.filter((group) => group.when)
|
||||
.map((group) => saveSettingsMutation.mutateAsync({ settingsKey: group.settingsKey, value: group.value }));
|
||||
if (promises.length === 0) return;
|
||||
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
const results = await Promise.allSettled(promises);
|
||||
if (!results.every((result) => result.status === "fulfilled")) {
|
||||
return;
|
||||
}
|
||||
|
||||
initialValuesRef.current = values;
|
||||
form.setInitialValues(values);
|
||||
form.resetDirty();
|
||||
await revalidatePathActionAsync("/manage/settings");
|
||||
showSuccessNotification({
|
||||
title: t("common.notification.update.success"),
|
||||
message: tSettings("notification.success.message"),
|
||||
});
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDiscard = () => {
|
||||
form.setValues(initialValuesRef.current);
|
||||
form.resetDirty();
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={form.onSubmit((values) => void handleSubmitAsync(values))}>
|
||||
<Stack gap="xl">
|
||||
<AnalyticsSettings form={form} />
|
||||
<CrawlingAndIndexingSettings form={form} />
|
||||
<BoardSettingsForm form={form} />
|
||||
<UserSettingsForm form={form} />
|
||||
<SearchSettingsForm form={form} />
|
||||
<AppearanceSettingsForm form={form} />
|
||||
<CultureSettingsForm form={form} />
|
||||
|
||||
{form.isDirty() && (
|
||||
<UnsavedChangesBar>
|
||||
<Button type="button" disabled={isSubmitting} variant="default" onClick={handleDiscard}>
|
||||
{t("common.action.discard")}
|
||||
</Button>
|
||||
<Button loading={isSubmitting} type="submit" disabled={!form.isValid()}>
|
||||
{t("common.action.saveChanges")}
|
||||
</Button>
|
||||
</UnsavedChangesBar>
|
||||
)}
|
||||
</Stack>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
@@ -2,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<FormValues>;
|
||||
}
|
||||
|
||||
export const UserSettingsForm = ({ form }: UserSettingsFormProps) => {
|
||||
const tUser = useScopedI18n("management.page.settings.section.user");
|
||||
|
||||
return (
|
||||
<CommonSettingsForm settingKey="user" defaultValues={defaultValues}>
|
||||
{(form) => (
|
||||
<>
|
||||
<Switch
|
||||
{...form.getInputProps("enableGravatar", { type: "checkbox" })}
|
||||
label={tUser("enableGravatar.label")}
|
||||
description={tUser("enableGravatar.description")}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</CommonSettingsForm>
|
||||
<SectionCard title={tUser("title")}>
|
||||
<Switch
|
||||
{...form.getInputProps("enableGravatar", { type: "checkbox" })}
|
||||
label={tUser("enableGravatar.label")}
|
||||
description={tUser("enableGravatar.description")}
|
||||
/>
|
||||
</SectionCard>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,9 @@
|
||||
import { Center, Loader } from "@mantine/core";
|
||||
|
||||
export default function SettingsLoading() {
|
||||
return (
|
||||
<Center h="40vh">
|
||||
<Loader />
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<>
|
||||
<DynamicBreadcrumb />
|
||||
<Stack>
|
||||
<Title order={1}>{tSettings("title")}</Title>
|
||||
<AnalyticsSettings initialData={serverSettings.analytics} />
|
||||
<CrawlingAndIndexingSettings initialData={serverSettings.crawlingAndIndexing} />
|
||||
<Stack>
|
||||
<Title order={2}>{tSettings("section.board.title")}</Title>
|
||||
<BoardSettingsForm defaultValues={serverSettings.board} />
|
||||
</Stack>
|
||||
<Stack>
|
||||
<Title order={2}>{tSettings("section.user.title")}</Title>
|
||||
<UserSettingsForm defaultValues={serverSettings.user} />
|
||||
</Stack>
|
||||
<Stack>
|
||||
<Title order={2}>{tSettings("section.search.title")}</Title>
|
||||
<SearchSettingsForm defaultValues={serverSettings.search} />
|
||||
</Stack>
|
||||
<Stack>
|
||||
<Title order={2}>{tSettings("section.appearance.title")}</Title>
|
||||
<AppearanceSettingsForm defaultValues={serverSettings.appearance} />
|
||||
</Stack>
|
||||
<Stack>
|
||||
<Title order={2}>{tSettings("section.culture.title")}</Title>
|
||||
<CultureSettingsForm defaultValues={serverSettings.culture} />
|
||||
</Stack>
|
||||
<SettingsForm initialData={serverSettings} />
|
||||
</Stack>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -14,6 +14,7 @@ import { db } from "@homarr/db";
|
||||
|
||||
import { getPackageVersion } from "~/versions/package-reader";
|
||||
import { extractMcpTools } from "../_extract-tools";
|
||||
import { serializeMcpToolResult } from "../_serialize-result";
|
||||
|
||||
const logger = createLogger({ module: "mcpRoute" });
|
||||
|
||||
@@ -203,7 +204,7 @@ const mcpHandler = createMcpHandler(
|
||||
const input = args && Object.keys(args).length > 0 ? args : undefined;
|
||||
const result = await procedure(input);
|
||||
return {
|
||||
content: [{ type: "text" as const, text: JSON.stringify(result) }],
|
||||
content: [{ type: "text" as const, text: serializeMcpToolResult(result) }],
|
||||
};
|
||||
} catch (error) {
|
||||
const message = sanitizeErrorMessage(error);
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { serializeMcpToolResult } from "./_serialize-result";
|
||||
|
||||
describe("serializeMcpToolResult", () => {
|
||||
it("serializes objects as JSON strings", () => {
|
||||
expect(serializeMcpToolResult({ boardId: "1" })).toBe('{"boardId":"1"}');
|
||||
});
|
||||
|
||||
it("serializes primitive values", () => {
|
||||
expect(serializeMcpToolResult("hello")).toBe('"hello"');
|
||||
expect(serializeMcpToolResult(42)).toBe("42");
|
||||
expect(serializeMcpToolResult(true)).toBe("true");
|
||||
});
|
||||
|
||||
it("serializes null as the string null", () => {
|
||||
expect(serializeMcpToolResult(null)).toBe("null");
|
||||
});
|
||||
|
||||
it("serializes void results as the string null so MCP content text is always a string", () => {
|
||||
expect(serializeMcpToolResult(undefined)).toBe("null");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,3 @@
|
||||
export function serializeMcpToolResult(result: unknown): string {
|
||||
return JSON.stringify(result) ?? "null";
|
||||
}
|
||||
@@ -44,9 +44,13 @@ interface WidgetContextMenuProps {
|
||||
|
||||
export const WidgetContextMenu = ({ item, definition, widgetStateRef, children }: WidgetContextMenuProps) => {
|
||||
const { data: session } = useSession();
|
||||
const [isEditMode] = useEditMode();
|
||||
const settings = useSettings();
|
||||
|
||||
if (!session || !settings.enableRightClickOnWidgets) return <>{children}</>;
|
||||
const isLinkWidget = item.kind === "app" || item.kind === "bookmarks";
|
||||
// Keep the Homarr menu in edit mode so these widgets can still be edited or removed.
|
||||
if (isLinkWidget && !isEditMode) return <>{children}</>;
|
||||
|
||||
return (
|
||||
<WidgetContextMenuInner item={item} definition={definition} widgetStateRef={widgetStateRef} settings={settings}>
|
||||
|
||||
@@ -301,6 +301,8 @@ interface UseCssVariableConfiguration {
|
||||
isDynamic: boolean;
|
||||
}
|
||||
|
||||
const minimumResizeWidthDelta = 20;
|
||||
|
||||
/**
|
||||
* This hook is used to configure the css variables for the gridstack
|
||||
* Those css variables are used to define the size of the gridstack items
|
||||
@@ -319,9 +321,30 @@ const useCssVariableConfiguration = ({
|
||||
columnCount,
|
||||
isDynamic,
|
||||
}: UseCssVariableConfiguration) => {
|
||||
const prevSizeRef = useRef<{ width: number; height: number } | null>(null);
|
||||
|
||||
const onResize = useCallback(() => {
|
||||
if (!wrapperRef.current) return;
|
||||
if (!gridRef.current) return;
|
||||
|
||||
const currentWidth = wrapperRef.current.clientWidth;
|
||||
const currentHeight = wrapperRef.current.clientHeight;
|
||||
const prevSize = prevSizeRef.current;
|
||||
|
||||
// Only skip recalculation when the width change is insignificant
|
||||
// and, for dynamic sections, the height is unchanged. This prevents infinite
|
||||
// feedback loops when the vertical scrollbar appears/disappears, causing
|
||||
// clientWidth to oscillate by ~15px, while still reacting to height changes.
|
||||
if (
|
||||
prevSize !== null &&
|
||||
Math.abs(currentWidth - prevSize.width) < minimumResizeWidthDelta &&
|
||||
(!isDynamic || currentHeight === prevSize.height)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
prevSizeRef.current = { width: currentWidth, height: currentHeight };
|
||||
|
||||
handleResizeChange(
|
||||
wrapperRef.current,
|
||||
gridRef.current,
|
||||
|
||||
@@ -1,38 +1,17 @@
|
||||
"use client";
|
||||
|
||||
import type { ComponentProps, ReactNode } from "react";
|
||||
import { Suspense, use, useEffect, useState } from "react";
|
||||
import { Box, Loader, UnstyledButton, useMantineColorScheme } from "@mantine/core";
|
||||
import type { ReactNode } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { UnstyledButton, useMantineColorScheme } from "@mantine/core";
|
||||
import { useHotkeys } from "@mantine/hooks";
|
||||
import { ErrorBoundary } from "react-error-boundary";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { useSession } from "@homarr/auth/client";
|
||||
import { hotkeys } from "@homarr/definitions";
|
||||
|
||||
import { UserAvatarMenu } from "~/components/user-avatar-menu";
|
||||
import { UpdateIndicator } from "./update";
|
||||
|
||||
type UserAvatarMenuModule = typeof import("~/components/user-avatar-menu");
|
||||
type UserAvatarMenuProps = ComponentProps<UserAvatarMenuModule["UserAvatarMenu"]>;
|
||||
|
||||
let userAvatarMenuPromise: Promise<UserAvatarMenuModule> | undefined;
|
||||
const loadUserAvatarMenu = () => {
|
||||
if (userAvatarMenuPromise) return userAvatarMenuPromise;
|
||||
|
||||
const promise = import("~/components/user-avatar-menu");
|
||||
userAvatarMenuPromise = promise;
|
||||
void promise.catch(() => {
|
||||
if (userAvatarMenuPromise === promise) userAvatarMenuPromise = undefined;
|
||||
});
|
||||
return promise;
|
||||
};
|
||||
const preloadUserAvatarMenu = () => void loadUserAvatarMenu().catch(() => undefined);
|
||||
|
||||
const LazyUserAvatarMenu = (props: UserAvatarMenuProps) => {
|
||||
const { UserAvatarMenu } = use(loadUserAvatarMenu());
|
||||
return <UserAvatarMenu {...props} />;
|
||||
};
|
||||
|
||||
interface UserButtonClientProps {
|
||||
avatar: ReactNode;
|
||||
isAdmin: boolean;
|
||||
@@ -41,8 +20,6 @@ interface UserButtonClientProps {
|
||||
|
||||
export const UserButtonClient = ({ avatar, isAdmin, isDockerEnabled }: UserButtonClientProps) => {
|
||||
const [canCheckForUpdates, setCanCheckForUpdates] = useState(false);
|
||||
const [isMenuMounted, setIsMenuMounted] = useState(false);
|
||||
const [isMenuOpen, setIsMenuOpen] = useState(false);
|
||||
const session = useSession();
|
||||
const { toggleColorScheme } = useMantineColorScheme();
|
||||
useHotkeys([[hotkeys.toggleColorScheme, toggleColorScheme]]);
|
||||
@@ -66,60 +43,14 @@ export const UserButtonClient = ({ avatar, isAdmin, isDockerEnabled }: UserButto
|
||||
staleTime: 60 * 60 * 1_000,
|
||||
});
|
||||
const visibleUpdates = isCurrentSessionAdmin ? availableUpdates : undefined;
|
||||
const openMenu = () => {
|
||||
setIsMenuMounted(true);
|
||||
setIsMenuOpen(true);
|
||||
};
|
||||
const renderButton = (onClick: () => void, isLoading = false) => (
|
||||
<UnstyledButton
|
||||
onClick={onClick}
|
||||
onFocus={preloadUserAvatarMenu}
|
||||
onMouseEnter={preloadUserAvatarMenu}
|
||||
onPointerDown={preloadUserAvatarMenu}
|
||||
aria-busy={isLoading || undefined}
|
||||
disabled={isLoading}
|
||||
>
|
||||
<UpdateIndicator availableUpdates={visibleUpdates} disabled={!isCurrentSessionAdmin}>
|
||||
<Box pos="relative" style={{ display: "inline-flex" }}>
|
||||
<Box opacity={isLoading ? 0.35 : 1}>{avatar}</Box>
|
||||
{isLoading && (
|
||||
<Loader
|
||||
aria-label="Loading user menu"
|
||||
size={18}
|
||||
pos="absolute"
|
||||
top="50%"
|
||||
left="50%"
|
||||
style={{ transform: "translate(-50%, -50%)" }}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
</UpdateIndicator>
|
||||
</UnstyledButton>
|
||||
);
|
||||
const button = renderButton(openMenu);
|
||||
const loadingButton = renderButton(() => undefined, true);
|
||||
|
||||
if (!isMenuMounted) return button;
|
||||
|
||||
return (
|
||||
<ErrorBoundary
|
||||
fallbackRender={({ resetErrorBoundary }) =>
|
||||
renderButton(() => {
|
||||
resetErrorBoundary();
|
||||
openMenu();
|
||||
})
|
||||
}
|
||||
>
|
||||
<Suspense fallback={loadingButton}>
|
||||
<LazyUserAvatarMenu
|
||||
availableUpdates={visibleUpdates}
|
||||
isDockerEnabled={isDockerEnabled}
|
||||
opened={isMenuOpen}
|
||||
onOpenChange={setIsMenuOpen}
|
||||
>
|
||||
{button}
|
||||
</LazyUserAvatarMenu>
|
||||
</Suspense>
|
||||
</ErrorBoundary>
|
||||
<UserAvatarMenu availableUpdates={visibleUpdates} isDockerEnabled={isDockerEnabled}>
|
||||
<UnstyledButton>
|
||||
<UpdateIndicator availableUpdates={visibleUpdates} disabled={!isCurrentSessionAdmin}>
|
||||
{avatar}
|
||||
</UpdateIndicator>
|
||||
</UnstyledButton>
|
||||
</UserAvatarMenu>
|
||||
);
|
||||
};
|
||||
@@ -1,19 +1,21 @@
|
||||
"use client";
|
||||
|
||||
import { Fragment } from "react";
|
||||
import { Card, CardSection, Divider, Group, Stack, Text, Title } from "@mantine/core";
|
||||
|
||||
import { getI18n } from "@homarr/translation/server";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
interface DangerZoneRootProps {
|
||||
children: React.ReactNode[] | React.ReactNode;
|
||||
}
|
||||
|
||||
export const DangerZoneRoot = async ({ children }: DangerZoneRootProps) => {
|
||||
const t = await getI18n();
|
||||
export const DangerZoneRoot = ({ children }: DangerZoneRootProps) => {
|
||||
const t = useScopedI18n("common");
|
||||
|
||||
return (
|
||||
<Stack gap="sm">
|
||||
<Title c="red.8" order={2}>
|
||||
{t("common.dangerZone")}
|
||||
{t("dangerZone")}
|
||||
</Title>
|
||||
<Card style={{ borderColor: "var(--mantine-color-red-8)", borderWidth: 3 }}>
|
||||
<Stack gap="sm">
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { Card, Divider, Stack, Title } from "@mantine/core";
|
||||
|
||||
interface SectionCardProps {
|
||||
title: ReactNode;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export const SectionCard = ({ title, children }: SectionCardProps) => {
|
||||
return (
|
||||
<Card withBorder bg="transparent">
|
||||
<Stack gap="md">
|
||||
<Title order={3}>{title}</Title>
|
||||
<Divider />
|
||||
{children}
|
||||
</Stack>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,25 @@
|
||||
"use client";
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import { Card, Group, Text } from "@mantine/core";
|
||||
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
|
||||
interface UnsavedChangesBarProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export const UnsavedChangesBar = ({ children }: UnsavedChangesBarProps) => {
|
||||
const t = useI18n();
|
||||
|
||||
return (
|
||||
<div style={{ position: "sticky", bottom: 20 }}>
|
||||
<Card withBorder>
|
||||
<Group justify="space-between" wrap="wrap">
|
||||
<Text fw={500}>{t("common.unsavedChanges")}</Text>
|
||||
<Group>{children}</Group>
|
||||
</Group>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -21,17 +21,9 @@ interface UserAvatarMenuProps {
|
||||
children: ReactNode;
|
||||
availableUpdates?: RouterOutputs["updateChecker"]["getAvailableUpdates"];
|
||||
isDockerEnabled?: boolean;
|
||||
opened: boolean;
|
||||
onOpenChange: (opened: boolean) => void;
|
||||
}
|
||||
|
||||
export const UserAvatarMenu = ({
|
||||
children,
|
||||
availableUpdates,
|
||||
isDockerEnabled,
|
||||
opened,
|
||||
onOpenChange,
|
||||
}: UserAvatarMenuProps) => {
|
||||
export const UserAvatarMenu = ({ children, availableUpdates, isDockerEnabled }: UserAvatarMenuProps) => {
|
||||
const t = useScopedI18n("common.userAvatar.menu");
|
||||
const session = useSession();
|
||||
|
||||
@@ -48,7 +40,7 @@ export const UserAvatarMenu = ({
|
||||
|
||||
return (
|
||||
// We use keepMounted so we can add event listeners to prevent navigating away without saving the board
|
||||
<Menu width={300} withinPortal keepMounted opened={opened} onChange={onOpenChange}>
|
||||
<Menu width={300} withinPortal keepMounted>
|
||||
<Menu.Dropdown>
|
||||
<AvailableUpdatesMenuItem availableUpdates={availableUpdates} />
|
||||
<Menu.Item component={Link} href="/boards" leftSection={<IconHome size="1rem" />}>
|
||||
|
||||
@@ -429,6 +429,13 @@
|
||||
<br/>
|
||||
<p align="center">Uptime<br/>Kuma</p>
|
||||
</a>
|
||||
</td>
|
||||
<td align="center">
|
||||
<a href="https://homarr.dev/docs/integrations/whats-up-docker" target="_blank" rel="noreferrer noopener">
|
||||
<img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/whats-up-docker.svg" alt="What's Up Docker" width="90" height="90" />
|
||||
<br/>
|
||||
<p align="center">What's<br/>Up<br/>Docker</p>
|
||||
</a>
|
||||
</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
@@ -45,7 +45,10 @@ describe("Custom JSX custom widgets", () => {
|
||||
await page.goto(`${baseUrl}/auth/login`);
|
||||
await page.getByLabel("Username").fill(adminCredentials.username);
|
||||
await page.locator("#password").fill(adminCredentials.password);
|
||||
const loginNavigation = page.waitForURL(baseUrl, { timeout: 15_000, waitUntil: "commit" });
|
||||
const loginNavigation = page.waitForURL((url) => url.origin === baseUrl && url.pathname !== "/auth/login", {
|
||||
timeout: 60_000,
|
||||
waitUntil: "commit",
|
||||
});
|
||||
await page.locator("css=button[type='submit']").click();
|
||||
await loginNavigation;
|
||||
|
||||
|
||||
@@ -7,42 +7,6 @@ import { createHomarrContainer } from "./shared/create-homarr-container";
|
||||
import { createSqliteDbFileAsync } from "./shared/e2e-db";
|
||||
|
||||
describe("Onboarding", () => {
|
||||
test("Credentials onboarding should be successful", async () => {
|
||||
// Arrange
|
||||
const { db, localMountPath } = await createSqliteDbFileAsync();
|
||||
const homarrContainer = await createHomarrContainer({
|
||||
mounts: {
|
||||
"/appdata": localMountPath,
|
||||
},
|
||||
}).start();
|
||||
|
||||
const browser = await chromium.launch();
|
||||
const context = await browser.newContext();
|
||||
const page = await context.newPage();
|
||||
const actions = new OnboardingActions(page, db);
|
||||
const assertions = new OnboardingAssertions(page, db);
|
||||
|
||||
try {
|
||||
// Act
|
||||
await page.goto(`http://${homarrContainer.getHost()}:${homarrContainer.getMappedPort(7575)}`);
|
||||
await actions.startOnboardingAsync("scratch");
|
||||
await actions.processUserStepAsync({
|
||||
username: "admin",
|
||||
password: "Comp(exP4sswOrd",
|
||||
confirmPassword: "Comp(exP4sswOrd",
|
||||
});
|
||||
await actions.processSettingsStepAsync();
|
||||
await actions.processIntegrationsStepAsync();
|
||||
|
||||
// Assert
|
||||
await assertions.assertFinishStepVisibleAsync();
|
||||
await assertions.assertUserAndAdminGroupInsertedAsync("admin");
|
||||
await assertions.assertDbOnboardingStepAsync("finish");
|
||||
} finally {
|
||||
await Promise.all([browser.close(), homarrContainer.stop()]);
|
||||
}
|
||||
}, 60_000);
|
||||
|
||||
test("External provider onboarding setup should be successful", async () => {
|
||||
// Arrange
|
||||
const { db, localMountPath } = await createSqliteDbFileAsync();
|
||||
|
||||
+3
-3
@@ -5,11 +5,11 @@ events {
|
||||
http {
|
||||
server {
|
||||
listen 7575;
|
||||
listen [::]:7575;
|
||||
${NGINX_LISTEN_IPV6}
|
||||
|
||||
# Route websockets traffic to port 3001
|
||||
location /websockets {
|
||||
proxy_pass http://${HOSTNAME}:3001;
|
||||
proxy_pass http://127.0.0.1:3001;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "Upgrade";
|
||||
@@ -18,7 +18,7 @@ http {
|
||||
|
||||
# Route all other traffic to port 3000
|
||||
location / {
|
||||
proxy_pass http://${HOSTNAME}:3000;
|
||||
proxy_pass http://127.0.0.1:3000;
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "homarr",
|
||||
"version": "1.74.0",
|
||||
"version": "1.77.1",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build": "cross-env CI=true turbo build",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { and, like } from "@homarr/db";
|
||||
import { getImageMatchRank, normalizeImageName } from "@homarr/common";
|
||||
import { icons } from "@homarr/db/schema";
|
||||
import { iconsFindSchema } from "@homarr/validation/icons";
|
||||
|
||||
@@ -15,38 +15,41 @@ export const iconsRouter = createTRPCRouter({
|
||||
})
|
||||
.input(iconsFindSchema)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const term = input.searchText?.toLowerCase().trim() ?? "";
|
||||
const keywords = (input.searchText ?? "").split(" ").filter((keyword) => keyword.length > 0);
|
||||
|
||||
let whereCondition = undefined;
|
||||
if (term.length > 0) {
|
||||
whereCondition = and(...keywords.map((keyword) => like(icons.name, `%${keyword}%`)));
|
||||
}
|
||||
const term = normalizeImageName(input.searchText ?? "");
|
||||
const repositories = await ctx.db.query.iconRepositories.findMany({
|
||||
orderBy: (table, { asc, sql }) => [sql`CASE WHEN ${table.slug} = 'local' THEN 0 ELSE 1 END`, asc(table.slug)],
|
||||
with: {
|
||||
icons: {
|
||||
columns: { id: true, name: true, url: true },
|
||||
orderBy: (table, { asc, sql }) => [
|
||||
sql`CASE WHEN ${table.name} LIKE '%.svg' THEN 0 ELSE 1 END`,
|
||||
asc(table.name),
|
||||
],
|
||||
limit: term.length === 0 ? input.limitPerGroup : undefined,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
icons: await ctx.db.query.iconRepositories.findMany({
|
||||
with: {
|
||||
icons: {
|
||||
columns: { id: true, name: true, url: true },
|
||||
where: whereCondition,
|
||||
orderBy: (table, { asc, sql }) => {
|
||||
const svgFirst = sql`CASE WHEN ${table.name} LIKE '%.svg' THEN 0 ELSE 1 END`;
|
||||
const nameAsc = asc(table.name);
|
||||
if (term.length === 0) {
|
||||
return [svgFirst, nameAsc];
|
||||
}
|
||||
const tier = sql`CASE
|
||||
WHEN LOWER(${table.name}) = ${term} THEN 0
|
||||
WHEN LOWER(${table.name}) LIKE ${term + "%"} THEN 1
|
||||
WHEN LOWER(${table.name}) LIKE ${`%${term}%`} THEN 2
|
||||
ELSE 3
|
||||
END`;
|
||||
return [tier, svgFirst, nameAsc];
|
||||
},
|
||||
limit: input.limitPerGroup,
|
||||
},
|
||||
},
|
||||
}),
|
||||
icons:
|
||||
term.length === 0
|
||||
? repositories
|
||||
: repositories.map((repository) => ({
|
||||
...repository,
|
||||
icons: repository.icons
|
||||
.flatMap((icon) => {
|
||||
const rank = getImageMatchRank(term, icon.name || icon.url);
|
||||
return rank === null ? [] : [{ icon, rank }];
|
||||
})
|
||||
.toSorted((a, b) =>
|
||||
a.rank !== b.rank
|
||||
? a.rank - b.rank
|
||||
: Number(b.icon.name.toLowerCase().endsWith(".svg")) -
|
||||
Number(a.icon.name.toLowerCase().endsWith(".svg")) || a.icon.name.localeCompare(b.icon.name),
|
||||
)
|
||||
.slice(0, input.limitPerGroup)
|
||||
.map(({ icon }) => icon),
|
||||
})),
|
||||
countIcons: await ctx.db.$count(icons),
|
||||
};
|
||||
}),
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { iconRepositories, icons } from "@homarr/db/schema";
|
||||
import { createDb } from "@homarr/db/test";
|
||||
|
||||
import { iconsRouter } from "../icons";
|
||||
|
||||
describe("findIcons", () => {
|
||||
test.each(["homeassistant", "home_assistant", "home assistant.png"])(
|
||||
"matches normalized search %s before applying the group limit",
|
||||
async (searchText) => {
|
||||
const db = createDb();
|
||||
const caller = iconsRouter.createCaller({ db, deviceType: undefined, session: null });
|
||||
|
||||
await db.insert(iconRepositories).values({ id: "repository", slug: "dashboard-icons" });
|
||||
await db.insert(icons).values([
|
||||
{
|
||||
id: "irrelevant",
|
||||
name: "000-unrelated.svg",
|
||||
url: "https://example.com/000-unrelated.svg",
|
||||
checksum: "irrelevant",
|
||||
iconRepositoryId: "repository",
|
||||
},
|
||||
{
|
||||
id: "home-assistant",
|
||||
name: "home-assistant.svg",
|
||||
url: "https://example.com/home-assistant.svg",
|
||||
checksum: "home-assistant",
|
||||
iconRepositoryId: "repository",
|
||||
},
|
||||
]);
|
||||
|
||||
const result = await caller.findIcons({ searchText, limitPerGroup: 1 });
|
||||
|
||||
expect(result.icons[0]?.icons).toEqual([
|
||||
{
|
||||
id: "home-assistant",
|
||||
name: "home-assistant.svg",
|
||||
url: "https://example.com/home-assistant.svg",
|
||||
},
|
||||
]);
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -44,4 +44,5 @@ export const widgetRouter = createTRPCRouter({
|
||||
traefik: lazy(() => import("./traefik").then((mod) => mod.traefikRouter)),
|
||||
customApi: lazy(() => import("./custom-api").then((mod) => mod.customApiRouter)),
|
||||
secrets: lazy(() => import("./widget-secrets").then((mod) => mod.widgetSecretsRouter)),
|
||||
wud: lazy(() => import("./wud").then((mod) => mod.wudRouter)),
|
||||
});
|
||||
@@ -1,3 +1,4 @@
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { getIntegrationKindsByCategory } from "@homarr/definitions";
|
||||
@@ -41,6 +42,13 @@ export const smartHomeRouter = createTRPCRouter({
|
||||
const client = await createIntegrationAsync(integration);
|
||||
const success = await client.triggerToggleAsync(input.entityId);
|
||||
|
||||
if (!success) {
|
||||
throw new TRPCError({
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
message: "Home Assistant failed to toggle the entity",
|
||||
});
|
||||
}
|
||||
|
||||
return success;
|
||||
}),
|
||||
executeAutomation: protectedProcedure
|
||||
@@ -55,6 +63,13 @@ export const smartHomeRouter = createTRPCRouter({
|
||||
.input(z.object({ automationId: z.string() }))
|
||||
.mutation(async ({ ctx: { integration }, input }) => {
|
||||
const client = await createIntegrationAsync(integration);
|
||||
await client.triggerAutomationAsync(input.automationId);
|
||||
const success = await client.triggerAutomationAsync(input.automationId);
|
||||
|
||||
if (!success) {
|
||||
throw new TRPCError({
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
message: "Home Assistant failed to trigger the automation",
|
||||
});
|
||||
}
|
||||
}),
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
import { wudStatsRequestHandler } from "@homarr/request-handler/wud";
|
||||
|
||||
import { createOneIntegrationMiddleware } from "../../middlewares/integration";
|
||||
import { createTRPCRouter, publicProcedure } from "../../trpc";
|
||||
|
||||
export const wudRouter = createTRPCRouter({
|
||||
getStats: publicProcedure
|
||||
.meta({
|
||||
mcp: {
|
||||
enabled: true,
|
||||
description:
|
||||
"Returns monitored-container counts and available updates from a What's Up Docker integration. REQUIRED: integrationId from integration_all. The caller needs query permission for that integration.",
|
||||
},
|
||||
})
|
||||
.concat(createOneIntegrationMiddleware("query", "wud"))
|
||||
.query(async ({ ctx }) => {
|
||||
const handler = wudStatsRequestHandler.handler(ctx.integration, {});
|
||||
const { data, timestamp } = await handler.getDataAsync();
|
||||
|
||||
return {
|
||||
stats: data,
|
||||
updatedAt: timestamp,
|
||||
};
|
||||
}),
|
||||
});
|
||||
@@ -43,7 +43,7 @@ async function importCookiesAsync() {
|
||||
|
||||
function getBaseUrl() {
|
||||
if (typeof window !== "undefined") return window.location.origin;
|
||||
return `http://${process.env.HOSTNAME ?? "localhost"}:3000`;
|
||||
return "http://127.0.0.1:3000";
|
||||
}
|
||||
|
||||
export const trpcPath = "/api/trpc";
|
||||
|
||||
@@ -28,3 +28,34 @@ export const bestMatch = <TItem extends Record<string, unknown>>(
|
||||
return best;
|
||||
}, null);
|
||||
};
|
||||
|
||||
export const normalizeImageName = (value: string) => {
|
||||
const fileName = value.split(/[?#]/, 1)[0]?.split("/").pop() ?? value;
|
||||
const withoutExtension = fileName.replace(/\.(?:avif|gif|jpe?g|png|svg|webp)$/i, "");
|
||||
|
||||
return withoutExtension
|
||||
.normalize("NFKD")
|
||||
.replace(/[\u0300-\u036f]/g, "")
|
||||
.replace(/&/g, " and ")
|
||||
.replace(/[^\p{L}\p{M}\p{N}]+/gu, " ")
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
};
|
||||
|
||||
export const getImageMatchRank = (normalizedSearch: string, candidateValue: string): number | null => {
|
||||
const candidate = normalizeImageName(candidateValue);
|
||||
if (!candidate) return null;
|
||||
|
||||
const compactSearch = normalizedSearch.replaceAll(" ", "");
|
||||
const compactCandidate = candidate.replaceAll(" ", "");
|
||||
if (candidate === normalizedSearch) return 0;
|
||||
if (compactCandidate === compactSearch) return 1;
|
||||
if (candidate.startsWith(`${normalizedSearch} `)) return 2;
|
||||
|
||||
const searchTokens = normalizedSearch.split(" ");
|
||||
const candidateTokens = candidate.split(" ");
|
||||
if (searchTokens.every((term) => candidateTokens.some((token) => token === term))) return 3;
|
||||
if (searchTokens.every((term) => candidateTokens.some((token) => token.startsWith(term)))) return 4;
|
||||
if (candidate.includes(normalizedSearch)) return 5;
|
||||
return null;
|
||||
};
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { bestMatch, capitalize } from "../string";
|
||||
import { bestMatch, capitalize, getImageMatchRank, normalizeImageName } from "../string";
|
||||
|
||||
const capitalizeTestCases = [
|
||||
["hello", "Hello"],
|
||||
@@ -54,3 +54,27 @@ describe("bestMatch should find the best match in an array of options", () => {
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("image name matching", () => {
|
||||
test.each([
|
||||
["home-assistant.svg", "home assistant"],
|
||||
["https://example.com/Home_Assistant.PNG?v=1", "home assistant"],
|
||||
["jelly.fin.webp", "jelly fin"],
|
||||
["Caf\u00e9.svg", "cafe"],
|
||||
["\u6771\u4eac.svg", "\u6771\u4eac"],
|
||||
["\u0939\u093f\u0928\u094d\u0926\u0940.svg", "\u0939\u093f\u0928\u094d\u0926\u0940"],
|
||||
])("normalizes %s", (value, expected) => {
|
||||
expect(normalizeImageName(value)).toBe(expected);
|
||||
});
|
||||
|
||||
test.each([
|
||||
["homeassistant", "home-assistant.svg", 1],
|
||||
["home assistant", "home_assistant.png", 0],
|
||||
["home assistant", "Home Assistant.webp", 0],
|
||||
["\u6771\u4eac", "\u6771\u4eac.svg", 0],
|
||||
["\u0939\u093f\u0928\u094d\u0926\u0940", "\u0939\u093f\u0928\u094d\u0926\u0940.png", 0],
|
||||
["home assistant", "unrelated.svg", null],
|
||||
])("ranks %s against %s", (search, candidate, expected) => {
|
||||
expect(getImageMatchRank(normalizeImageName(search), candidate)).toBe(expected);
|
||||
});
|
||||
});
|
||||
@@ -13,7 +13,6 @@ import { UndiciHttpAgent } from "@homarr/core/infrastructure/http";
|
||||
|
||||
import type { TrustedCertificateHostname } from "../certificates/hostnames";
|
||||
import { withTimeoutAsync } from "./timeout";
|
||||
|
||||
export const createCustomCheckServerIdentity = (
|
||||
trustedHostnames: TrustedCertificateHostname[],
|
||||
): typeof checkServerIdentity => {
|
||||
|
||||
@@ -210,6 +210,7 @@ export type HomarrDocumentationPath =
|
||||
| "/docs/integrations/unifi-controller"
|
||||
| "/docs/integrations/unraid"
|
||||
| "/docs/integrations/uptime-kuma"
|
||||
| "/docs/integrations/whats-up-docker"
|
||||
| "/docs/management/api"
|
||||
| "/docs/management/apps"
|
||||
| "/docs/management/backup"
|
||||
@@ -276,6 +277,7 @@ export type HomarrDocumentationPath =
|
||||
| "/docs/widgets/video"
|
||||
| "/docs/widgets/vpn"
|
||||
| "/docs/widgets/weather"
|
||||
| "/docs/widgets/whats-up-docker"
|
||||
| ""
|
||||
| "/sitemap.xml"
|
||||
| "/docs/integrations/sabnzbd"
|
||||
@@ -330,6 +332,7 @@ export type HomarrDocumentationPath =
|
||||
| "/docs/integrations/peanut"
|
||||
| "/docs/integrations/beszel"
|
||||
| "/docs/integrations/traefik"
|
||||
| "/docs/integrations/whats-up-docker"
|
||||
| "/docs/widgets/clock"
|
||||
| "/docs/widgets/weather"
|
||||
| "/docs/widgets/app"
|
||||
@@ -382,4 +385,5 @@ export type HomarrDocumentationPath =
|
||||
| "/docs/widgets/beszel-alerts"
|
||||
| "/docs/widgets/beszel-system-stats"
|
||||
| "/docs/widgets/traefik"
|
||||
| "/docs/widgets/custom-api";
|
||||
| "/docs/widgets/custom-api"
|
||||
| "/docs/widgets/whats-up-docker";
|
||||
@@ -54,5 +54,6 @@ export const integrationDocSlugs: Record<IntegrationKind, string | null> = {
|
||||
beszel: "beszel",
|
||||
traefik: "traefik",
|
||||
archiveTeamWarrior: null,
|
||||
wud: "whats-up-docker",
|
||||
mock: null,
|
||||
};
|
||||
@@ -55,4 +55,5 @@ export const widgetDocSlugs: Record<WidgetKind, string | null> = {
|
||||
beszelSystemStats: "beszel-system-stats",
|
||||
traefik: "traefik",
|
||||
customApi: "custom-api",
|
||||
wud: "whats-up-docker",
|
||||
};
|
||||
@@ -468,6 +468,14 @@ export const integrationDefs = {
|
||||
category: ["archiving"],
|
||||
documentationUrl: null,
|
||||
},
|
||||
wud: {
|
||||
name: "What's Up Docker",
|
||||
secretKinds: [[], ["username", "password"]],
|
||||
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/whats-up-docker.svg",
|
||||
category: ["healthMonitoring"],
|
||||
documentationUrl: createDocumentationLink("/docs/integrations/whats-up-docker"),
|
||||
defaultPort: 3000,
|
||||
},
|
||||
// This integration only returns mock data, it is used during development (but can also be used in production by directly going to the create page)
|
||||
mock: {
|
||||
name: "Mock",
|
||||
|
||||
@@ -24,7 +24,9 @@ export const widgetIntegrationSupport: Partial<Record<WidgetKind, readonly Integ
|
||||
networkControllerSummary: getIntegrationKindsByCategory("networkController"),
|
||||
networkControllerStatus: getIntegrationKindsByCategory("networkController"),
|
||||
indexerManager: getIntegrationKindsByCategory("indexerManager"),
|
||||
healthMonitoring: getIntegrationKindsByCategory("healthMonitoring").filter((kind) => kind !== "patchmon"),
|
||||
healthMonitoring: getIntegrationKindsByCategory("healthMonitoring").filter(
|
||||
(kind) => kind !== "patchmon" && kind !== "wud",
|
||||
),
|
||||
firewall: getIntegrationKindsByCategory("firewall"),
|
||||
notifications: getIntegrationKindsByCategory("notifications"),
|
||||
mediaReleases: ["mock", "emby", "jellyfin", "plex"],
|
||||
@@ -50,6 +52,7 @@ export const widgetIntegrationSupport: Partial<Record<WidgetKind, readonly Integ
|
||||
archiveTeamWarrior: ["archiveTeamWarrior"],
|
||||
anchorNote: ["anchor"],
|
||||
traefik: ["traefik"],
|
||||
wud: ["wud"],
|
||||
};
|
||||
|
||||
export const getWidgetKindsForIntegration = (integrationKind: IntegrationKind): WidgetKind[] => {
|
||||
|
||||
@@ -53,6 +53,7 @@ export const widgetKinds = [
|
||||
"beszelSystemStats",
|
||||
"traefik",
|
||||
"customApi",
|
||||
"wud",
|
||||
] as const;
|
||||
export type WidgetKind = (typeof widgetKinds)[number];
|
||||
|
||||
|
||||
@@ -1,3 +1,89 @@
|
||||
[data-combobox-selected="true"] .iconCard {
|
||||
border-color: var(--mantine-primary-color-6);
|
||||
.dropdown {
|
||||
min-width: min(32rem, calc(100vw - 2rem));
|
||||
}
|
||||
|
||||
.options {
|
||||
max-height: min(28rem, 60vh);
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
|
||||
.iconGrid {
|
||||
grid-template-columns: repeat(auto-fill, minmax(3.25rem, 1fr));
|
||||
}
|
||||
|
||||
.iconOption {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 0;
|
||||
height: 3.25rem;
|
||||
padding: var(--mantine-spacing-xs);
|
||||
border: 1px solid var(--mantine-color-default-border);
|
||||
border-radius: var(--mantine-radius-md);
|
||||
background: var(--mantine-color-body);
|
||||
transition:
|
||||
border-color 160ms ease,
|
||||
background-color 160ms ease,
|
||||
transform 160ms ease;
|
||||
}
|
||||
|
||||
.iconOption:hover,
|
||||
.iconOption[data-combobox-selected="true"] {
|
||||
border-color: var(--mantine-primary-color-6);
|
||||
background: var(--mantine-primary-color-light);
|
||||
}
|
||||
|
||||
.iconOption:active {
|
||||
transform: scale(0.96);
|
||||
}
|
||||
|
||||
.formatBadge,
|
||||
.sectionBadge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: var(--mantine-radius-xs);
|
||||
background: var(--mantine-color-red-9);
|
||||
color: var(--mantine-color-white);
|
||||
font-size: 0.625rem;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.formatBadge {
|
||||
position: absolute;
|
||||
top: 0.2rem;
|
||||
right: 0.2rem;
|
||||
padding: 0.15rem 0.2rem;
|
||||
}
|
||||
|
||||
.sectionBadge {
|
||||
padding: 0.2rem 0.3rem;
|
||||
}
|
||||
|
||||
.urlPreview {
|
||||
background: var(--mantine-color-default-hover);
|
||||
}
|
||||
|
||||
.previewFrame {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 3rem;
|
||||
height: 3rem;
|
||||
flex: 0 0 3rem;
|
||||
}
|
||||
|
||||
.previewLoader {
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.iconOption {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
@@ -4,31 +4,84 @@ import { afterEach, beforeAll, beforeEach, describe, expect, test, vi } from "vi
|
||||
import { MantineProvider } from "@mantine/core";
|
||||
|
||||
import type { Root } from "react-dom/client";
|
||||
import type { IconGroup } from "./icon-picker.utils";
|
||||
|
||||
const { findIconsQuery } = vi.hoisted(() => ({
|
||||
findIconsQuery: vi.fn((_query: { searchText: string }) => ({
|
||||
data: { icons: [], countIcons: 0 },
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
refetch: vi.fn(),
|
||||
})),
|
||||
const { findIconsQuery, refetch, uploadSelection, sessionPermissions } = vi.hoisted(() => ({
|
||||
findIconsQuery: vi.fn(),
|
||||
refetch: vi.fn(),
|
||||
uploadSelection: vi.fn(),
|
||||
sessionPermissions: { current: [] as string[] },
|
||||
}));
|
||||
|
||||
interface QueryData {
|
||||
icons: IconGroup[];
|
||||
countIcons: number;
|
||||
}
|
||||
|
||||
const emptyResult: QueryData = { icons: [], countIcons: 11_000 };
|
||||
let queryResult: {
|
||||
data?: QueryData;
|
||||
isLoading: boolean;
|
||||
isFetching: boolean;
|
||||
isError: boolean;
|
||||
};
|
||||
|
||||
vi.mock("@homarr/api/client", () => ({
|
||||
clientApi: { icon: { findIcons: { useQuery: findIconsQuery } } },
|
||||
clientApi: {
|
||||
icon: {
|
||||
findIcons: {
|
||||
useQuery: (input: { searchText: string; limitPerGroup: number }, options: { enabled: boolean }) => {
|
||||
findIconsQuery(input, options);
|
||||
return { ...queryResult, refetch };
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}));
|
||||
vi.mock("@homarr/auth/client", () => ({
|
||||
useSession: () => ({ data: { user: { permissions: [] } } }),
|
||||
useSession: () => ({ data: { user: { permissions: sessionPermissions.current } } }),
|
||||
}));
|
||||
vi.mock("@homarr/translation", () => ({ supportedLanguages: [] }));
|
||||
vi.mock("@homarr/translation/client", () => ({
|
||||
useScopedI18n: () => (key: string) => key,
|
||||
useScopedI18n: () => (key: string, params?: Record<string, string>) =>
|
||||
params ? `${key}:${Object.values(params).join(",")}` : key,
|
||||
}));
|
||||
vi.mock("../upload-media/upload-media", () => ({
|
||||
UploadMedia: ({
|
||||
children,
|
||||
onSuccess,
|
||||
}: {
|
||||
children: (props: object) => unknown;
|
||||
onSuccess: (medias: unknown) => void;
|
||||
}) =>
|
||||
children({
|
||||
loading: false,
|
||||
onClick: () => {
|
||||
uploadSelection();
|
||||
onSuccess([{ id: "uploaded", url: "/api/user-medias/uploaded" }]);
|
||||
},
|
||||
}),
|
||||
}));
|
||||
vi.mock("../upload-media/upload-media", () => ({ UploadMedia: () => null }));
|
||||
|
||||
import { IconPicker } from "./icon-picker";
|
||||
|
||||
const directIconUrl = "https://cdn.example.com/shopify.svg";
|
||||
const directImageUrl = "https://cdn.example.com/shopify.svg";
|
||||
|
||||
const iconGroups = [
|
||||
{
|
||||
id: "remote",
|
||||
slug: "dashboard-icons",
|
||||
icons: [
|
||||
{ id: "png", name: "shopify.png", url: "https://cdn.example.com/shopify.png" },
|
||||
{ id: "svg", name: "shopify.svg", url: "https://cdn.example.com/shopify.svg" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "local",
|
||||
slug: "local",
|
||||
icons: [{ id: "local-icon", name: "my-shop.png", url: "/api/user-medias/local-icon" }],
|
||||
},
|
||||
];
|
||||
|
||||
const setInputValue = (input: HTMLInputElement, value: string) => {
|
||||
const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")?.set;
|
||||
@@ -37,6 +90,18 @@ const setInputValue = (input: HTMLInputElement, value: string) => {
|
||||
input.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
};
|
||||
|
||||
const pressKey = (input: HTMLInputElement, key: string, init?: KeyboardEventInit) => {
|
||||
input.dispatchEvent(new KeyboardEvent("keydown", { key, bubbles: true, cancelable: true, ...init }));
|
||||
};
|
||||
|
||||
const focusInput = async (input: HTMLInputElement) => {
|
||||
await act(async () => input.focus());
|
||||
};
|
||||
|
||||
const advanceDebounce = async () => {
|
||||
await act(async () => vi.advanceTimersByTimeAsync(300));
|
||||
};
|
||||
|
||||
describe("IconPicker", () => {
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
@@ -45,9 +110,9 @@ describe("IconPicker", () => {
|
||||
Object.defineProperty(globalThis, "IS_REACT_ACT_ENVIRONMENT", { value: true, writable: true });
|
||||
Object.defineProperty(window, "matchMedia", {
|
||||
writable: true,
|
||||
value: (query: string) => ({
|
||||
value: (media: string) => ({
|
||||
matches: false,
|
||||
media: query,
|
||||
media,
|
||||
onchange: null,
|
||||
addListener: () => undefined,
|
||||
removeListener: () => undefined,
|
||||
@@ -56,54 +121,186 @@ describe("IconPicker", () => {
|
||||
dispatchEvent: () => false,
|
||||
}),
|
||||
});
|
||||
Object.defineProperty(HTMLElement.prototype, "scrollIntoView", { value: () => undefined, writable: true });
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
findIconsQuery.mockClear();
|
||||
refetch.mockClear();
|
||||
uploadSelection.mockClear();
|
||||
sessionPermissions.current = [];
|
||||
queryResult = { data: emptyResult, isLoading: false, isFetching: false, isError: false };
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
root.unmount();
|
||||
act(() => root.unmount());
|
||||
container.remove();
|
||||
document.querySelectorAll("[data-combobox-dropdown]").forEach((element) => element.remove());
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
const renderPicker = async (onChange: (value: string) => void) => {
|
||||
const renderPicker = async (
|
||||
onChange: (value: string) => void,
|
||||
props: Partial<React.ComponentProps<typeof IconPicker>> = {},
|
||||
) => {
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
await act(async () => {
|
||||
root.render(createElement(MantineProvider, null, createElement(IconPicker, { value: "", onChange })));
|
||||
root.render(createElement(MantineProvider, null, createElement(IconPicker, { value: "", onChange, ...props })));
|
||||
});
|
||||
const input = container.querySelector("input");
|
||||
if (!input) throw new Error("Icon picker input was not rendered");
|
||||
return input;
|
||||
};
|
||||
|
||||
test("keeps a direct URL visible without searching the icon repositories", async () => {
|
||||
test("clears an existing value without restoring it", async () => {
|
||||
const onChange = vi.fn();
|
||||
const input = await renderPicker(onChange);
|
||||
const input = await renderPicker(onChange, { value: directImageUrl });
|
||||
|
||||
await act(async () => {
|
||||
setInputValue(input, directIconUrl);
|
||||
await new Promise((resolve) => setTimeout(resolve, 150));
|
||||
});
|
||||
await act(async () => setInputValue(input, ""));
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith(directIconUrl);
|
||||
expect(input.value).toBe(directIconUrl);
|
||||
expect(findIconsQuery.mock.calls.some(([query]) => query.searchText === directIconUrl)).toBe(false);
|
||||
expect(onChange).toHaveBeenCalledWith("");
|
||||
expect(input.value).toBe("");
|
||||
});
|
||||
|
||||
test("keeps icon names in repository search mode", async () => {
|
||||
const onChange = vi.fn();
|
||||
const input = await renderPicker(onChange);
|
||||
test.each([{ metaKey: true }, { ctrlKey: true }])(
|
||||
"clears the entire value with a line-delete shortcut",
|
||||
async (modifier) => {
|
||||
const onChange = vi.fn();
|
||||
const input = await renderPicker(onChange, { value: directImageUrl });
|
||||
|
||||
await act(async () => {
|
||||
setInputValue(input, "shopify");
|
||||
await new Promise((resolve) => setTimeout(resolve, 150));
|
||||
});
|
||||
await act(async () => pressKey(input, "Backspace", modifier));
|
||||
|
||||
expect(onChange).not.toHaveBeenCalled();
|
||||
expect(input.value).toBe("shopify");
|
||||
expect(onChange).toHaveBeenCalledWith("");
|
||||
expect(input.value).toBe("");
|
||||
},
|
||||
);
|
||||
|
||||
test("debounces repository searches", async () => {
|
||||
const input = await renderPicker(vi.fn());
|
||||
findIconsQuery.mockClear();
|
||||
|
||||
await act(async () => setInputValue(input, "shopify"));
|
||||
expect(findIconsQuery.mock.calls.some(([query]) => query.searchText === "shopify")).toBe(false);
|
||||
|
||||
await advanceDebounce();
|
||||
expect(findIconsQuery.mock.calls.some(([query]) => query.searchText === "shopify")).toBe(true);
|
||||
});
|
||||
|
||||
test("pre-searches the suggested app name when opened", async () => {
|
||||
const input = await renderPicker(vi.fn(), { suggestedSearch: "Jellyfin" });
|
||||
|
||||
await focusInput(input);
|
||||
expect(input.value).toBe("Jellyfin");
|
||||
await advanceDebounce();
|
||||
|
||||
expect(findIconsQuery.mock.calls.some(([query]) => query.searchText === "Jellyfin")).toBe(true);
|
||||
});
|
||||
|
||||
test("shows local images before SVG and raster library images", async () => {
|
||||
queryResult = { data: { icons: iconGroups, countIcons: 3 }, isLoading: false, isFetching: false, isError: false };
|
||||
const input = await renderPicker(vi.fn());
|
||||
|
||||
await focusInput(input);
|
||||
const content = document.body.textContent ?? "";
|
||||
|
||||
expect(document.body.querySelector('[role="listbox"]')?.getAttribute("aria-label")).toBe("iconPicker.results");
|
||||
expect(content.indexOf("iconPicker.localImages")).toBeLessThan(content.indexOf("iconPicker.svgIcons"));
|
||||
expect(content.indexOf("iconPicker.svgIcons")).toBeLessThan(content.indexOf("iconPicker.otherImages"));
|
||||
});
|
||||
|
||||
test("selects a library image with a pointer", async () => {
|
||||
queryResult = { data: { icons: iconGroups, countIcons: 3 }, isLoading: false, isFetching: false, isError: false };
|
||||
const onChange = vi.fn();
|
||||
const input = await renderPicker(onChange);
|
||||
await focusInput(input);
|
||||
|
||||
const option = document.body.querySelector<HTMLElement>(
|
||||
'[data-combobox-option][value="/api/user-medias/local-icon"]',
|
||||
);
|
||||
if (!option) throw new Error("Local image option was not rendered");
|
||||
await act(async () => option.click());
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith("/api/user-medias/local-icon");
|
||||
expect(input.value).toBe("/api/user-medias/local-icon");
|
||||
});
|
||||
|
||||
test("selects a library image with the keyboard", async () => {
|
||||
queryResult = { data: { icons: iconGroups, countIcons: 3 }, isLoading: false, isFetching: false, isError: false };
|
||||
const onChange = vi.fn();
|
||||
const input = await renderPicker(onChange);
|
||||
await focusInput(input);
|
||||
|
||||
await act(async () => pressKey(input, "ArrowDown"));
|
||||
await act(async () => pressKey(input, "Enter"));
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith("/api/user-medias/local-icon");
|
||||
});
|
||||
|
||||
test("keeps a direct URL visible and reports a successful preview", async () => {
|
||||
const onChange = vi.fn();
|
||||
const input = await renderPicker(onChange);
|
||||
|
||||
await act(async () => setInputValue(input, directImageUrl));
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith(directImageUrl);
|
||||
expect(input.value).toBe(directImageUrl);
|
||||
expect(findIconsQuery.mock.calls.at(-1)?.[1]).toMatchObject({ enabled: false });
|
||||
expect(document.body.textContent).toContain("iconPicker.urlLoading");
|
||||
|
||||
const preview = document.body.querySelector('[data-testid="direct-url-preview"]');
|
||||
if (!preview) throw new Error("Direct URL preview was not rendered");
|
||||
await act(async () => preview.dispatchEvent(new Event("load")));
|
||||
expect(document.body.textContent).toContain("iconPicker.urlReady");
|
||||
});
|
||||
|
||||
test("keeps a failed direct URL editable and shows an error", async () => {
|
||||
const input = await renderPicker(vi.fn());
|
||||
await act(async () => setInputValue(input, "https://invalid.example/image.png"));
|
||||
|
||||
const preview = document.body.querySelector('[data-testid="direct-url-preview"]');
|
||||
if (!preview) throw new Error("Direct URL preview was not rendered");
|
||||
await act(async () => preview.dispatchEvent(new Event("error")));
|
||||
|
||||
expect(input.value).toBe("https://invalid.example/image.png");
|
||||
expect(document.body.textContent).toContain("iconPicker.urlError");
|
||||
});
|
||||
|
||||
test("restores the committed value when an unfinished search is cancelled", async () => {
|
||||
const input = await renderPicker(vi.fn(), { value: directImageUrl });
|
||||
|
||||
await act(async () => setInputValue(input, "unfinished search"));
|
||||
await act(async () => pressKey(input, "Escape"));
|
||||
|
||||
expect(input.value).toBe(directImageUrl);
|
||||
});
|
||||
|
||||
test("offers retry when the repository search fails", async () => {
|
||||
queryResult = { data: undefined, isLoading: false, isFetching: false, isError: true };
|
||||
const input = await renderPicker(vi.fn());
|
||||
await focusInput(input);
|
||||
|
||||
const retry = Array.from(document.body.querySelectorAll("button")).find(
|
||||
(button) => button.textContent === "action.tryAgain",
|
||||
);
|
||||
if (!retry) throw new Error("Retry button was not rendered");
|
||||
await act(async () => retry.click());
|
||||
|
||||
expect(refetch).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
test("selects an uploaded local image when the user has permission", async () => {
|
||||
sessionPermissions.current = ["media-upload"];
|
||||
const onChange = vi.fn();
|
||||
await renderPicker(onChange);
|
||||
|
||||
const upload = container.querySelector<HTMLButtonElement>('[aria-label="iconPicker.uploadImage"]');
|
||||
if (!upload) throw new Error("Upload button was not rendered");
|
||||
await act(async () => upload.click());
|
||||
|
||||
expect(uploadSelection).toHaveBeenCalledOnce();
|
||||
expect(onChange).toHaveBeenCalledWith("/api/user-medias/uploaded");
|
||||
});
|
||||
});
|
||||
@@ -1,27 +1,36 @@
|
||||
"use client";
|
||||
|
||||
import type { FocusEventHandler } from "react";
|
||||
import { startTransition, useEffect, useState } from "react";
|
||||
import type { FocusEventHandler, KeyboardEventHandler } from "react";
|
||||
import { startTransition, useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
ActionIcon,
|
||||
Box,
|
||||
Button,
|
||||
Card,
|
||||
CloseButton,
|
||||
Combobox,
|
||||
Flex,
|
||||
Group,
|
||||
Image,
|
||||
Indicator,
|
||||
InputBase,
|
||||
Loader,
|
||||
Paper,
|
||||
SimpleGrid,
|
||||
Skeleton,
|
||||
Stack,
|
||||
Text,
|
||||
UnstyledButton,
|
||||
ThemeIcon,
|
||||
Tooltip,
|
||||
useCombobox,
|
||||
} from "@mantine/core";
|
||||
import { useDebouncedValue, useUncontrolled } from "@mantine/hooks";
|
||||
import { IconMoodEmpty, IconSearchOff, IconUpload } from "@tabler/icons-react";
|
||||
import {
|
||||
IconCheck,
|
||||
IconCloud,
|
||||
IconMoodEmpty,
|
||||
IconPhoto,
|
||||
IconPhotoOff,
|
||||
IconSearchOff,
|
||||
IconUpload,
|
||||
} from "@tabler/icons-react";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { useSession } from "@homarr/auth/client";
|
||||
@@ -29,6 +38,7 @@ import { supportedLanguages } from "@homarr/translation";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import { UploadMedia } from "../upload-media/upload-media";
|
||||
import { arrangeIconPickerSections, isDirectImageUrl, isImageSource, isSvgImage } from "./icon-picker.utils";
|
||||
import classes from "./icon-picker.module.css";
|
||||
|
||||
interface IconPickerProps {
|
||||
@@ -39,9 +49,12 @@ interface IconPickerProps {
|
||||
onBlur?: FocusEventHandler;
|
||||
label?: string;
|
||||
placeholder?: string;
|
||||
suggestedSearch?: string | null;
|
||||
withAsterisk?: boolean;
|
||||
}
|
||||
|
||||
type PreviewState = "idle" | "loading" | "ready" | "error";
|
||||
|
||||
export const IconPicker = ({
|
||||
value: propsValue,
|
||||
onChange,
|
||||
@@ -51,188 +64,403 @@ export const IconPicker = ({
|
||||
withAsterisk = true,
|
||||
label,
|
||||
placeholder,
|
||||
suggestedSearch,
|
||||
}: IconPickerProps) => {
|
||||
const [value, setValue] = useUncontrolled({ value: propsValue, onChange });
|
||||
const [query, setQuery] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
setQuery("");
|
||||
}, [value]);
|
||||
|
||||
const [value, setValue] = useUncontrolled({ value: propsValue, onChange, defaultValue: "" });
|
||||
const [draft, setDraft] = useState(propsValue ?? "");
|
||||
const [hasEdited, setHasEdited] = useState(false);
|
||||
const [keyboardIndex, setKeyboardIndex] = useState(-1);
|
||||
const [previewState, setPreviewState] = useState<PreviewState>("idle");
|
||||
const { data: session } = useSession();
|
||||
const tCommon = useScopedI18n("common");
|
||||
const [debouncedQuery] = useDebouncedValue(query, 100);
|
||||
const [debouncedDraft] = useDebouncedValue(draft, 250);
|
||||
|
||||
const searchText = !isDirectIconUrl(debouncedQuery) && debouncedQuery !== (value ?? "") ? debouncedQuery : "";
|
||||
const directUrl = isDirectImageUrl(draft) ? draft.trim() : null;
|
||||
const isCommittedValueDraft = debouncedDraft === (value ?? "");
|
||||
const searchText = directUrl !== null || isCommittedValueDraft ? "" : debouncedDraft.trim();
|
||||
const canUpload = session?.user.permissions.includes("media-upload") ?? false;
|
||||
|
||||
const { data, isLoading, isError, refetch } = clientApi.icon.findIcons.useQuery(
|
||||
{ searchText },
|
||||
{ placeholderData: (prev) => prev },
|
||||
const query = clientApi.icon.findIcons.useQuery(
|
||||
{ searchText, limitPerGroup: 24 },
|
||||
{ enabled: directUrl === null, placeholderData: (previous) => previous },
|
||||
);
|
||||
|
||||
const combobox = useCombobox({
|
||||
onDropdownClose: () => combobox.resetSelectedOption(),
|
||||
onDropdownOpen: () => combobox.updateSelectedOptionIndex("active"),
|
||||
});
|
||||
|
||||
const totalOptions = data?.icons.reduce((acc, group) => acc + group.icons.length, 0) ?? 0;
|
||||
const groups = data?.icons.map((group) => {
|
||||
const options = group.icons.map((item) => (
|
||||
<Combobox.Option
|
||||
key={item.id}
|
||||
value={item.url}
|
||||
p={0}
|
||||
h="calc(2*var(--mantine-spacing-sm)+25px)"
|
||||
w="calc(2*var(--mantine-spacing-sm)+25px)"
|
||||
>
|
||||
<UnstyledButton
|
||||
onClick={() => {
|
||||
startTransition(() => {
|
||||
setValue(item.url);
|
||||
setQuery("");
|
||||
combobox.closeDropdown();
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Indicator label="SVG" disabled={!item.url.endsWith(".svg")} size={16}>
|
||||
<Card p="sm" pos="relative" style={{ overflow: "visible", cursor: "pointer" }} className={classes.iconCard}>
|
||||
<Box w={25} h={25}>
|
||||
<Image src={item.url} w={25} h={25} />
|
||||
</Box>
|
||||
</Card>
|
||||
</Indicator>
|
||||
</UnstyledButton>
|
||||
</Combobox.Option>
|
||||
));
|
||||
useEffect(() => {
|
||||
setDraft(value ?? "");
|
||||
setHasEdited(false);
|
||||
}, [value]);
|
||||
|
||||
return (
|
||||
<Paper p="xs" key={group.slug} pt={2}>
|
||||
<Text mb={8} size="sm" fw="bold">
|
||||
{group.slug}
|
||||
</Text>
|
||||
<Flex gap={8} wrap={"wrap"}>
|
||||
{options}
|
||||
</Flex>
|
||||
</Paper>
|
||||
);
|
||||
});
|
||||
useEffect(() => {
|
||||
setPreviewState(directUrl ? "loading" : "idle");
|
||||
}, [directUrl]);
|
||||
|
||||
const inputValue = query || value || "";
|
||||
const leftSection = shouldShowPreview(value) && <img src={value} alt="" style={{ width: 20, height: 20 }} />;
|
||||
const placeholderText =
|
||||
placeholder ||
|
||||
(data && tCommon("iconPicker.header", { countIcons: String(data.countIcons) })) ||
|
||||
tCommon("iconPicker.headerLoading");
|
||||
const sections = useMemo(
|
||||
() => arrangeIconPickerSections(query.data?.icons ?? [], searchText),
|
||||
[query.data?.icons, searchText],
|
||||
);
|
||||
const orderedOptions = useMemo(
|
||||
() => [...sections.local, ...sections.svg, ...sections.other],
|
||||
[sections.local, sections.svg, sections.other],
|
||||
);
|
||||
const totalOptions = sections.local.length + sections.svg.length + sections.other.length;
|
||||
|
||||
const renderDropdown = () => {
|
||||
if (isError) {
|
||||
const commitValue = (nextValue: string) => {
|
||||
startTransition(() => {
|
||||
setValue(nextValue);
|
||||
setDraft(nextValue);
|
||||
setHasEdited(false);
|
||||
setKeyboardIndex(-1);
|
||||
});
|
||||
};
|
||||
|
||||
const clearValue = () => {
|
||||
commitValue("");
|
||||
combobox.openDropdown("keyboard");
|
||||
};
|
||||
|
||||
const openWithSuggestedSearch = () => {
|
||||
if (!draft && !value && !hasEdited && suggestedSearch?.trim()) {
|
||||
setDraft(suggestedSearch.trim());
|
||||
}
|
||||
combobox.openDropdown();
|
||||
};
|
||||
|
||||
const handleKeyDown: KeyboardEventHandler<HTMLInputElement> = (event) => {
|
||||
if ((event.metaKey || event.ctrlKey) && event.key === "Backspace") {
|
||||
event.preventDefault();
|
||||
clearValue();
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key === "Escape") {
|
||||
setDraft(value ?? "");
|
||||
setHasEdited(false);
|
||||
combobox.closeDropdown("keyboard");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!directUrl && orderedOptions.length > 0 && (event.key === "ArrowDown" || event.key === "ArrowUp")) {
|
||||
event.preventDefault();
|
||||
combobox.openDropdown("keyboard");
|
||||
setKeyboardIndex((current) => {
|
||||
const next =
|
||||
event.key === "ArrowDown"
|
||||
? current >= orderedOptions.length - 1
|
||||
? 0
|
||||
: current + 1
|
||||
: current <= 0
|
||||
? orderedOptions.length - 1
|
||||
: current - 1;
|
||||
combobox.selectOption(next);
|
||||
return next;
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key === "Enter") {
|
||||
if (keyboardIndex >= 0 && orderedOptions[keyboardIndex]) {
|
||||
event.preventDefault();
|
||||
commitValue(orderedOptions[keyboardIndex].url);
|
||||
combobox.closeDropdown("keyboard");
|
||||
} else if (isImageSource(draft)) {
|
||||
event.preventDefault();
|
||||
commitValue(draft.trim());
|
||||
combobox.closeDropdown("keyboard");
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const renderIconOptions = (icons: typeof sections.local, offset: number) => (
|
||||
<SimpleGrid className={classes.iconGrid} spacing={6} verticalSpacing={6}>
|
||||
{icons.map((icon, index) => (
|
||||
<Tooltip key={icon.id} label={`${icon.name} · ${icon.repositorySlug}`} openDelay={500}>
|
||||
<Combobox.Option
|
||||
value={icon.url}
|
||||
aria-label={icon.name}
|
||||
active={icon.url === value || keyboardIndex === offset + index}
|
||||
className={classes.iconOption}
|
||||
>
|
||||
<Image src={icon.url} alt="" w={30} h={30} fit="contain" fallbackSrc="/logo/logo.png" />
|
||||
{isSvgImage(icon.url) && <span className={classes.formatBadge}>SVG</span>}
|
||||
</Combobox.Option>
|
||||
</Tooltip>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
);
|
||||
|
||||
const renderSearchResults = () => {
|
||||
if (query.isError) {
|
||||
return (
|
||||
<Stack align="center" gap="xs" p="md">
|
||||
<IconSearchOff size={32} stroke={1.5} />
|
||||
<Text size="sm">{tCommon("iconPicker.error")}</Text>
|
||||
<Button size="xs" variant="default" onClick={() => refetch()}>
|
||||
{tCommon("action.tryAgain")}
|
||||
</Button>
|
||||
</Stack>
|
||||
<PickerMessage
|
||||
icon={<IconSearchOff size={22} />}
|
||||
title={tCommon("iconPicker.error")}
|
||||
action={
|
||||
<Button size="xs" variant="default" onClick={() => query.refetch()}>
|
||||
{tCommon("action.tryAgain")}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (isLoading) {
|
||||
return Array(15)
|
||||
.fill(0)
|
||||
.map((_, index) => (
|
||||
<Combobox.Option value={`skeleton-${index}`} key={index} disabled>
|
||||
<Skeleton height={25} visible />
|
||||
</Combobox.Option>
|
||||
));
|
||||
|
||||
if (query.isLoading) {
|
||||
return (
|
||||
<SimpleGrid className={classes.iconGrid} spacing={6} verticalSpacing={6} p="xs">
|
||||
{Array.from({ length: 12 }, (_, index) => (
|
||||
<Skeleton key={index} height={52} radius="md" />
|
||||
))}
|
||||
</SimpleGrid>
|
||||
);
|
||||
}
|
||||
if (totalOptions > 0) {
|
||||
return <Stack gap={4}>{groups}</Stack>;
|
||||
|
||||
if (totalOptions === 0) {
|
||||
return (
|
||||
<PickerMessage
|
||||
icon={<IconMoodEmpty size={22} />}
|
||||
title={tCommon("iconPicker.noResults", { search: debouncedDraft.trim() })}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack align="center" gap="xs" p="md">
|
||||
<IconMoodEmpty size={32} stroke={1.5} />
|
||||
<Text size="sm">{tCommon("iconPicker.noResults", { search: debouncedQuery })}</Text>
|
||||
<Stack gap="sm" p="xs">
|
||||
{sections.local.length > 0 && (
|
||||
<PickerSection icon={<IconPhoto size={15} />} label={tCommon("iconPicker.localImages")}>
|
||||
{renderIconOptions(sections.local, 0)}
|
||||
</PickerSection>
|
||||
)}
|
||||
{sections.svg.length > 0 && (
|
||||
<PickerSection label={tCommon("iconPicker.svgIcons")} badge="SVG">
|
||||
{renderIconOptions(sections.svg, sections.local.length)}
|
||||
</PickerSection>
|
||||
)}
|
||||
{sections.other.length > 0 && (
|
||||
<PickerSection icon={<IconCloud size={15} />} label={tCommon("iconPicker.otherImages")}>
|
||||
{renderIconOptions(sections.other, sections.local.length + sections.svg.length)}
|
||||
</PickerSection>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
const leftSection = (() => {
|
||||
if (directUrl && previewState === "loading") return <Loader size={15} />;
|
||||
if (directUrl && previewState === "error") return <IconPhotoOff size={17} color="var(--mantine-color-red-6)" />;
|
||||
if (shouldShowPreview(value)) {
|
||||
return (
|
||||
<Image
|
||||
src={value}
|
||||
alt=""
|
||||
w={22}
|
||||
h={22}
|
||||
fit="contain"
|
||||
onLoad={() => directUrl && setPreviewState("ready")}
|
||||
onError={() => directUrl && setPreviewState("error")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return <IconPhoto size={17} stroke={1.5} />;
|
||||
})();
|
||||
|
||||
const placeholderText =
|
||||
placeholder ??
|
||||
(query.data
|
||||
? tCommon("iconPicker.header", { countIcons: String(query.data.countIcons) })
|
||||
: tCommon("iconPicker.headerLoading"));
|
||||
|
||||
return (
|
||||
<Combobox store={combobox} withinPortal>
|
||||
<Combobox.Target>
|
||||
<Group wrap="nowrap" gap="xs" w="100%" align="start">
|
||||
<InputBase
|
||||
flex={1}
|
||||
rightSection={<Combobox.Chevron />}
|
||||
leftSection={leftSection}
|
||||
value={inputValue}
|
||||
onChange={(event) => {
|
||||
const nextValue = event.currentTarget.value;
|
||||
const trimmedValue = nextValue.trim();
|
||||
setQuery(nextValue);
|
||||
if (isDirectIconUrl(trimmedValue)) {
|
||||
setValue(trimmedValue);
|
||||
combobox.closeDropdown();
|
||||
} else {
|
||||
combobox.openDropdown();
|
||||
}
|
||||
}}
|
||||
onClick={() => combobox.openDropdown()}
|
||||
onFocus={(event) => {
|
||||
onFocus?.(event);
|
||||
combobox.openDropdown();
|
||||
}}
|
||||
onBlur={(event) => {
|
||||
onBlur?.(event);
|
||||
combobox.closeDropdown();
|
||||
setQuery("");
|
||||
}}
|
||||
rightSectionPointerEvents="none"
|
||||
withAsterisk={withAsterisk}
|
||||
error={error}
|
||||
label={label ?? tCommon("iconPicker.label")}
|
||||
placeholder={placeholderText}
|
||||
/>
|
||||
{session?.user.permissions.includes("media-upload") && (
|
||||
<UploadMedia
|
||||
onSuccess={(medias) => {
|
||||
const first = medias.at(0);
|
||||
if (!first) return;
|
||||
startTransition(() => {
|
||||
setValue(first.url);
|
||||
});
|
||||
}}
|
||||
>
|
||||
{({ onClick, loading }) => (
|
||||
<ActionIcon onClick={onClick} loading={loading} mt={24} size={36} variant="default">
|
||||
<IconUpload size={16} stroke={1.5} />
|
||||
</ActionIcon>
|
||||
<Combobox
|
||||
store={combobox}
|
||||
withinPortal
|
||||
resetSelectionOnOptionHover
|
||||
onOptionSubmit={(url) => {
|
||||
commitValue(url);
|
||||
combobox.closeDropdown();
|
||||
}}
|
||||
>
|
||||
<Combobox.Target withExpandedAttribute>
|
||||
<InputBase
|
||||
flex={1}
|
||||
leftSection={leftSection}
|
||||
rightSection={
|
||||
<Group gap={2} wrap="nowrap">
|
||||
{canUpload && (
|
||||
<UploadMedia
|
||||
onSuccess={(medias) => {
|
||||
const first = medias.at(0);
|
||||
if (!first) return;
|
||||
commitValue(first.url);
|
||||
combobox.closeDropdown();
|
||||
}}
|
||||
>
|
||||
{({ onClick, loading }) => (
|
||||
<Tooltip label={tCommon("iconPicker.uploadImage")}>
|
||||
<ActionIcon
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onClick();
|
||||
}}
|
||||
loading={loading}
|
||||
size="sm"
|
||||
variant="subtle"
|
||||
aria-label={tCommon("iconPicker.uploadImage")}
|
||||
>
|
||||
<IconUpload size={16} stroke={1.5} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
)}
|
||||
</UploadMedia>
|
||||
)}
|
||||
</UploadMedia>
|
||||
)}
|
||||
</Group>
|
||||
{(draft || value) && (
|
||||
<CloseButton
|
||||
size="sm"
|
||||
aria-label={tCommon("iconPicker.clear")}
|
||||
onMouseDown={(event) => event.preventDefault()}
|
||||
onClick={clearValue}
|
||||
/>
|
||||
)}
|
||||
{query.isFetching && !directUrl ? <Loader size={14} /> : <Combobox.Chevron />}
|
||||
</Group>
|
||||
}
|
||||
rightSectionWidth={canUpload ? 86 : 56}
|
||||
rightSectionPointerEvents="all"
|
||||
value={draft}
|
||||
onChange={(event) => {
|
||||
const nextValue = event.currentTarget.value;
|
||||
setDraft(nextValue);
|
||||
setHasEdited(true);
|
||||
setKeyboardIndex(-1);
|
||||
combobox.openDropdown("keyboard");
|
||||
combobox.updateSelectedOptionIndex();
|
||||
|
||||
if (!nextValue) {
|
||||
setValue("");
|
||||
} else if (isImageSource(nextValue)) {
|
||||
setValue(nextValue.trim());
|
||||
}
|
||||
}}
|
||||
onKeyDown={handleKeyDown}
|
||||
onClick={openWithSuggestedSearch}
|
||||
onFocus={(event) => {
|
||||
onFocus?.(event);
|
||||
openWithSuggestedSearch();
|
||||
}}
|
||||
onBlur={(event) => {
|
||||
onBlur?.(event);
|
||||
combobox.closeDropdown();
|
||||
setDraft(value ?? "");
|
||||
setHasEdited(false);
|
||||
}}
|
||||
withAsterisk={withAsterisk}
|
||||
error={error}
|
||||
label={label ?? tCommon("iconPicker.label")}
|
||||
description={tCommon("iconPicker.description")}
|
||||
styles={{
|
||||
description: { color: "var(--mantine-color-text)" },
|
||||
input: { color: "var(--mantine-color-text)" },
|
||||
error: { color: "light-dark(var(--mantine-color-red-9), var(--mantine-color-red-2))" },
|
||||
}}
|
||||
placeholder={placeholderText}
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
/>
|
||||
</Combobox.Target>
|
||||
|
||||
<Combobox.Dropdown>
|
||||
<Combobox.Options mah={350} style={{ overflowY: "auto" }}>
|
||||
{renderDropdown()}
|
||||
<Combobox.Dropdown className={classes.dropdown}>
|
||||
<Combobox.Options className={classes.options} aria-label={tCommon("iconPicker.results")}>
|
||||
{directUrl ? (
|
||||
<Paper p="sm" m="xs" className={classes.urlPreview}>
|
||||
<Group wrap="nowrap" align="center">
|
||||
<Box className={classes.previewFrame}>
|
||||
{previewState === "loading" && <Loader size={18} className={classes.previewLoader} />}
|
||||
{previewState === "error" ? (
|
||||
<ThemeIcon variant="light" color="red" size={40}>
|
||||
<IconPhotoOff size={21} />
|
||||
</ThemeIcon>
|
||||
) : (
|
||||
<Image
|
||||
src={directUrl}
|
||||
alt=""
|
||||
w={40}
|
||||
h={40}
|
||||
fit="contain"
|
||||
data-testid="direct-url-preview"
|
||||
onLoad={() => setPreviewState("ready")}
|
||||
onError={() => setPreviewState("error")}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
<Box flex={1} miw={0}>
|
||||
<Text size="sm" fw={600}>
|
||||
{previewState === "error"
|
||||
? tCommon("iconPicker.urlError")
|
||||
: previewState === "ready"
|
||||
? tCommon("iconPicker.urlReady")
|
||||
: tCommon("iconPicker.urlLoading")}
|
||||
</Text>
|
||||
<Text size="xs" truncate>
|
||||
{directUrl}
|
||||
</Text>
|
||||
</Box>
|
||||
{previewState === "ready" && (
|
||||
<ThemeIcon variant="light" color="green" size="sm" aria-hidden>
|
||||
<IconCheck size={14} />
|
||||
</ThemeIcon>
|
||||
)}
|
||||
</Group>
|
||||
</Paper>
|
||||
) : (
|
||||
renderSearchResults()
|
||||
)}
|
||||
</Combobox.Options>
|
||||
</Combobox.Dropdown>
|
||||
</Combobox>
|
||||
);
|
||||
};
|
||||
|
||||
// This regex is used to prevent loading a preview like en or /en which would trigger a language change
|
||||
// See https://github.com/homarr-labs/homarr/issues/3070
|
||||
interface PickerSectionProps {
|
||||
label: string;
|
||||
icon?: React.ReactNode;
|
||||
badge?: string;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
const PickerSection = ({ label, icon, badge, children }: PickerSectionProps) => (
|
||||
<Stack gap={6}>
|
||||
<Group gap={6} px={2}>
|
||||
{icon}
|
||||
<Text size="xs" fw={600}>
|
||||
{label}
|
||||
</Text>
|
||||
{badge && <span className={classes.sectionBadge}>{badge}</span>}
|
||||
</Group>
|
||||
{children}
|
||||
</Stack>
|
||||
);
|
||||
|
||||
interface PickerMessageProps {
|
||||
icon: React.ReactNode;
|
||||
title: string;
|
||||
action?: React.ReactNode;
|
||||
}
|
||||
|
||||
const PickerMessage = ({ icon, title, action }: PickerMessageProps) => (
|
||||
<Stack align="center" gap="xs" p="lg" ta="center">
|
||||
<ThemeIcon variant="light" color="gray" size="lg">
|
||||
{icon}
|
||||
</ThemeIcon>
|
||||
<Text size="sm">{title}</Text>
|
||||
{action}
|
||||
</Stack>
|
||||
);
|
||||
|
||||
const localizationPathRegex = new RegExp(`^/?(${supportedLanguages.join("|")})(/.*)?$`, "i");
|
||||
const shouldShowPreview = (value: string | null | undefined): value is string => {
|
||||
if (!value) return false;
|
||||
return !localizationPathRegex.test(value);
|
||||
};
|
||||
|
||||
const isDirectIconUrl = (value: string) => {
|
||||
try {
|
||||
const url = new URL(value.trim());
|
||||
return (url.protocol === "http:" || url.protocol === "https:") && Boolean(url.hostname);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,50 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import type { IconGroup } from "./icon-picker.utils";
|
||||
import { arrangeIconPickerSections, isDirectImageUrl, isImageSource, isSvgImage } from "./icon-picker.utils";
|
||||
|
||||
const groups = [
|
||||
{
|
||||
id: "remote",
|
||||
slug: "dashboard-icons",
|
||||
icons: [
|
||||
{ id: "long", name: "home-assistant-alt.svg", url: "https://cdn.example.com/home-assistant-alt.svg" },
|
||||
{ id: "raster", name: "Home Assistant.png", url: "https://cdn.example.com/home-assistant.png" },
|
||||
{ id: "svg", name: "home-assistant.svg", url: "https://cdn.example.com/home-assistant.svg?v=1" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "local",
|
||||
slug: "local",
|
||||
icons: [{ id: "local", name: "Home Assistant.png", url: "/api/user-medias/home-assistant" }],
|
||||
},
|
||||
] as IconGroup[];
|
||||
|
||||
describe("icon picker utilities", () => {
|
||||
test("arranges local images before remote SVG and raster images", () => {
|
||||
const result = arrangeIconPickerSections(groups, "home assistant");
|
||||
|
||||
expect(result.local.map((icon) => icon.id)).toEqual(["local"]);
|
||||
expect(result.svg.map((icon) => icon.id)).toEqual(["svg", "long"]);
|
||||
expect(result.other.map((icon) => icon.id)).toEqual(["raster"]);
|
||||
});
|
||||
|
||||
test.each([
|
||||
["https://example.com/icon.png", true],
|
||||
["http://localhost:3000/icon", true],
|
||||
["ftp://example.com/icon.png", false],
|
||||
["javascript:alert(1)", false],
|
||||
["example.com/icon.png", false],
|
||||
])("validates direct image URLs", (value, expected) => {
|
||||
expect(isDirectImageUrl(value)).toBe(expected);
|
||||
});
|
||||
|
||||
test("accepts internal image paths without treating search text as a source", () => {
|
||||
expect(isImageSource("/api/user-medias/123")).toBe(true);
|
||||
expect(isImageSource("shopify")).toBe(false);
|
||||
});
|
||||
|
||||
test("recognizes SVG URLs with query strings case-insensitively", () => {
|
||||
expect(isSvgImage("https://example.com/ICON.SVG?raw=1")).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,67 @@
|
||||
import type { inferRouterOutputs } from "@trpc/server";
|
||||
|
||||
import type { AppRouter } from "@homarr/api";
|
||||
import { getImageMatchRank, normalizeImageName } from "@homarr/common";
|
||||
|
||||
type RouterOutput = inferRouterOutputs<AppRouter>;
|
||||
export type IconGroup = RouterOutput["icon"]["findIcons"]["icons"][number];
|
||||
export type PickerIcon = IconGroup["icons"][number] & { repositorySlug: string };
|
||||
|
||||
export interface IconPickerSections {
|
||||
local: PickerIcon[];
|
||||
svg: PickerIcon[];
|
||||
other: PickerIcon[];
|
||||
}
|
||||
|
||||
export const arrangeIconPickerSections = (iconGroups: IconGroup[], searchTerm = ""): IconPickerSections => {
|
||||
const sections: IconPickerSections = { local: [], svg: [], other: [] };
|
||||
|
||||
for (const group of iconGroups) {
|
||||
for (const icon of group.icons) {
|
||||
const pickerIcon = { ...icon, repositorySlug: group.slug };
|
||||
if (group.slug === "local") {
|
||||
sections.local.push(pickerIcon);
|
||||
} else if (isSvgImage(icon.url)) {
|
||||
sections.svg.push(pickerIcon);
|
||||
} else {
|
||||
sections.other.push(pickerIcon);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const normalizedSearch = normalizeImageName(searchTerm);
|
||||
if (normalizedSearch) {
|
||||
const compareMatches = (a: PickerIcon, b: PickerIcon) => {
|
||||
const aRank = getImageMatchRank(normalizedSearch, a.name || a.url) ?? Number.MAX_SAFE_INTEGER;
|
||||
const bRank = getImageMatchRank(normalizedSearch, b.name || b.url) ?? Number.MAX_SAFE_INTEGER;
|
||||
return aRank - bRank || a.name.localeCompare(b.name);
|
||||
};
|
||||
return {
|
||||
local: sections.local.toSorted(compareMatches),
|
||||
svg: sections.svg.toSorted(compareMatches),
|
||||
other: sections.other.toSorted(compareMatches),
|
||||
};
|
||||
}
|
||||
|
||||
return sections;
|
||||
};
|
||||
|
||||
export const isDirectImageUrl = (value: string) => {
|
||||
try {
|
||||
const url = new URL(value.trim());
|
||||
return (url.protocol === "http:" || url.protocol === "https:") && Boolean(url.hostname);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
export const isImageSource = (value: string) => isDirectImageUrl(value) || value.trim().startsWith("/");
|
||||
|
||||
export const isSvgImage = (value: string) => {
|
||||
try {
|
||||
const url = new URL(value, "http://homarr.local");
|
||||
return url.pathname.toLowerCase().endsWith(".svg");
|
||||
} catch {
|
||||
return value.toLowerCase().split(/[?#]/, 1)[0]?.endsWith(".svg") ?? false;
|
||||
}
|
||||
};
|
||||
@@ -2,7 +2,6 @@ export * from "./new-app/_app-new-form";
|
||||
export * from "./new-app/_form";
|
||||
|
||||
export * from "./icon-picker/icon-picker";
|
||||
export * from "./new-app/icon-matcher";
|
||||
|
||||
export * from "./upload-media/upload-media";
|
||||
|
||||
|
||||
@@ -3,17 +3,15 @@
|
||||
import type { ChangeEventHandler } from "react";
|
||||
import { useEffect, useImperativeHandle, useRef } from "react";
|
||||
import { Button, Checkbox, Collapse, Group, Stack, Textarea, TextInput } from "@mantine/core";
|
||||
import { useDebouncedValue, useDisclosure } from "@mantine/hooks";
|
||||
import { useDisclosure } from "@mantine/hooks";
|
||||
import type { z } from "zod/v4";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { useZodForm } from "@homarr/form";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import { Link } from "@homarr/ui";
|
||||
import { appManageSchema } from "@homarr/validation/app";
|
||||
|
||||
import { IconPicker } from "../icon-picker/icon-picker";
|
||||
import { findBestIconMatch } from "./icon-matcher";
|
||||
|
||||
type FormType = z.infer<typeof appManageSchema>;
|
||||
|
||||
@@ -81,9 +79,6 @@ export const AppForm = ({
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [initialValuesKey]);
|
||||
|
||||
// Debounce the name value with 200ms delay
|
||||
const [debouncedName] = useDebouncedValue(form.values.name, 200);
|
||||
|
||||
const shouldCreateAnother = useRef(false);
|
||||
const handleSubmit = (values: FormType) => {
|
||||
const redirect = !shouldCreateAnother.current;
|
||||
@@ -130,29 +125,13 @@ export const AppForm = ({
|
||||
}
|
||||
};
|
||||
|
||||
// Auto-select icon based on app name with debounced search
|
||||
const { data: iconsData } = clientApi.icon.findIcons.useQuery(
|
||||
{
|
||||
searchText: debouncedName,
|
||||
},
|
||||
{
|
||||
enabled: debouncedName.length > 3,
|
||||
},
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (debouncedName && !form.values.iconUrl && iconsData?.icons) {
|
||||
const bestMatch = findBestIconMatch(debouncedName, iconsData.icons);
|
||||
if (bestMatch) {
|
||||
form.setFieldValue("iconUrl", bestMatch);
|
||||
}
|
||||
}
|
||||
}, [debouncedName, iconsData]);
|
||||
|
||||
const formFields = (
|
||||
<Stack>
|
||||
<TextInput {...form.getInputProps("name")} withAsterisk label={t("app.field.name.label")} />
|
||||
<IconPicker {...form.getInputProps("iconUrl")} />
|
||||
<IconPicker
|
||||
{...form.getInputProps("iconUrl")}
|
||||
suggestedSearch={initialValues === undefined ? form.values.name : undefined}
|
||||
/>
|
||||
<Textarea
|
||||
{...form.getInputProps("description")}
|
||||
label={t("app.field.description.label")}
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
import type { inferRouterOutputs } from "@trpc/server";
|
||||
|
||||
import type { AppRouter } from "@homarr/api";
|
||||
|
||||
type RouterOutput = inferRouterOutputs<AppRouter>;
|
||||
type IconGroupsOutput = RouterOutput["icon"]["findIcons"]["icons"];
|
||||
|
||||
export const findBestIconMatch = (searchTerm: string, iconGroups: IconGroupsOutput): string | null => {
|
||||
const nameLower = searchTerm.toLowerCase();
|
||||
const allIcons = iconGroups.flatMap((group) => group.icons);
|
||||
|
||||
const getIconPriority = (iconUrl: string) => {
|
||||
const fileName = iconUrl.toLowerCase().split("/").pop()?.split(".")[0];
|
||||
if (!fileName) return -1;
|
||||
|
||||
const isSvg = iconUrl.endsWith(".svg");
|
||||
const isExactMatch = fileName === nameLower;
|
||||
|
||||
if (isExactMatch) return isSvg ? 0 : 1;
|
||||
if (fileName.includes(nameLower)) return isSvg ? 2 : 3;
|
||||
return -1;
|
||||
};
|
||||
|
||||
for (let priority = 0; priority <= 3; priority++) {
|
||||
const match = allIcons.find((icon) => getIconPriority(icon.url) === priority);
|
||||
if (match) return match.url;
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
@@ -54,6 +54,7 @@ import { UmamiIntegration } from "../umami/umami-integration";
|
||||
import { UptimeKumaIntegration } from "../uptime-kuma/uptime-kuma-integration";
|
||||
import { UnifiControllerIntegration } from "../unifi-controller/unifi-controller-integration";
|
||||
import { UnraidIntegration } from "../unraid/unraid-integration";
|
||||
import { WudIntegration } from "../wud/wud-integration";
|
||||
import type { Integration, IntegrationInput } from "./integration";
|
||||
|
||||
export const createIntegrationAsync = async <TKind extends keyof typeof integrationCreators>(
|
||||
@@ -133,6 +134,7 @@ export const integrationCreators = {
|
||||
beszel: BeszelIntegration,
|
||||
bazarr: BazarrIntegration,
|
||||
traefik: TraefikIntegration,
|
||||
wud: WudIntegration,
|
||||
} satisfies Record<IntegrationKind, IntegrationInstance | [(input: IntegrationInput) => Promise<Integration>]>;
|
||||
|
||||
type IntegrationInstanceOfKind<TKind extends keyof typeof integrationCreators> = {
|
||||
|
||||
@@ -134,7 +134,10 @@ export class HomeAssistantIntegration extends Integration implements ISmartHomeI
|
||||
*/
|
||||
private async postAsync(path: `/api/${string}`, body: Record<string, string>) {
|
||||
return await fetchWithTrustedCertificatesAsync(this.url(path), {
|
||||
headers: this.getAuthHeaders(),
|
||||
headers: {
|
||||
...this.getAuthHeaders(),
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
method: "POST",
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// General integrations
|
||||
export { ArchiveTeamWarriorIntegration } from "./archive-team-warrior/archive-team-warrior-integration";
|
||||
export { WudIntegration } from "./wud/wud-integration";
|
||||
export { AdGuardHomeIntegration } from "./adguard-home/adguard-home-integration";
|
||||
export { TechnitiumDnsIntegration } from "./technitium/technitium-integration";
|
||||
export { AnchorIntegration } from "./anchor/anchor-integration";
|
||||
@@ -91,6 +92,7 @@ export type {
|
||||
ArchiveTeamWarriorItem,
|
||||
ArchiveTeamWarriorStatus,
|
||||
} from "./archive-team-warrior/archive-team-warrior-types";
|
||||
export type { WudStats, WudContainerUpdate } from "./wud/wud-types";
|
||||
|
||||
// Schemas
|
||||
export { anchorNotesListInputSchema } from "./anchor/anchor-types";
|
||||
|
||||
@@ -85,6 +85,11 @@ export class SynologyClient {
|
||||
if (volumesFromV2.length > 0) {
|
||||
return volumesFromV2;
|
||||
}
|
||||
|
||||
const legacyVolumesFromV2 = this.mapLegacyVolumes(storageV2.vol_info);
|
||||
if (legacyVolumesFromV2.length > 0) {
|
||||
return legacyVolumesFromV2;
|
||||
}
|
||||
} catch (error) {
|
||||
logger.debug("storage_v2 unavailable, falling back to legacy storage info", {
|
||||
integrationId: this.integrationId,
|
||||
|
||||
@@ -78,6 +78,7 @@ export const synologyStorageV2VolumeSchema = z
|
||||
|
||||
export const synologyStorageV2DataSchema = z.object({
|
||||
volumes: z.array(synologyStorageV2VolumeSchema).optional(),
|
||||
vol_info: z.array(synologyVolumeInfoSchema).optional(),
|
||||
});
|
||||
|
||||
export const synologyUtilizationCpuSchema = z.object({
|
||||
|
||||
Loaded 100 of 169 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user