Compare commits
148
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e41d938a5f | ||
|
|
afa54755fd | ||
|
|
0482c040ec | ||
|
|
5dc4dbeaef | ||
|
|
f3f2778f1f | ||
|
|
c08b8979ee | ||
|
|
9a7a828618 | ||
|
|
352a8ed0d0 | ||
|
|
6d3f7e3e36 | ||
|
|
7833cca86b | ||
|
|
6edf78acd7 | ||
|
|
7d43f6e5d9 | ||
|
|
1723a9e744 | ||
|
|
2ba31fd37b | ||
|
|
327e5c15ae | ||
|
|
3efe64a062 | ||
|
|
40b726b8bc | ||
|
|
d2d0d16e4d | ||
|
|
63ad879022 | ||
|
|
2ae0c2ab43 | ||
|
|
2410ba5f5c | ||
|
|
8f0039245d | ||
|
|
0871e4cf0d | ||
|
|
db9f42520a | ||
|
|
3ebcedec26 | ||
|
|
c41a3d427e | ||
|
|
71c39aa18e | ||
|
|
000c51a131 | ||
|
|
2c44c7f2b8 | ||
|
|
c2cf25b4fb | ||
|
|
03ebb8c2db | ||
|
|
f0b153aa37 | ||
|
|
965d130ce7 | ||
|
|
a87a16edb1 | ||
|
|
1493252e28 | ||
|
|
46ff20b0a0 | ||
|
|
1bc84dd90e | ||
|
|
14cbc03621 | ||
|
|
9ec35b0cda | ||
|
|
2d6dd2f0d0 | ||
|
|
572bc71396 | ||
|
|
d4b1739c46 | ||
|
|
76495948e2 | ||
|
|
a95052c787 | ||
|
|
400127b45c | ||
|
|
f77c47b94b | ||
|
|
825636c4f6 | ||
|
|
3aa14e22ed | ||
|
|
acdfcbb18e | ||
|
|
3a1245cbc5 | ||
|
|
f0963405ca | ||
|
|
d078d761d9 | ||
|
|
84fcf02729 | ||
|
|
c308ae5c6e | ||
|
|
93192cfedc | ||
|
|
0f9ae13560 | ||
|
|
f4a6f37415 | ||
|
|
95bfaf5127 | ||
|
|
83e3b22f9a | ||
|
|
cd71404288 | ||
|
|
7a089ab177 | ||
|
|
2513e8454f | ||
|
|
527e911eef | ||
|
|
ff2cdecc86 | ||
|
|
742bde1d27 | ||
|
|
64febbce12 | ||
|
|
98bb1aecf8 | ||
|
|
06c489bd48 | ||
|
|
464d98e124 | ||
|
|
987c9ade18 | ||
|
|
6141d505e4 | ||
|
|
6178843198 | ||
|
|
a7006d746e | ||
|
|
06724cfeb2 | ||
|
|
131a5558b5 | ||
|
|
b32ad82850 | ||
|
|
622e1dcfa5 | ||
|
|
9674bde758 | ||
|
|
b479ada14f | ||
|
|
bff3383ac9 | ||
|
|
d865733838 | ||
|
|
85fcea3837 | ||
|
|
f76a0f240a | ||
|
|
637f03e107 | ||
|
|
55214ccbc5 | ||
|
|
778533a62e | ||
|
|
b4fba47a23 | ||
|
|
76ddfceb13 | ||
|
|
2f6b96e5ba | ||
|
|
f1b7a5146a | ||
|
|
05292ae1f5 | ||
|
|
48fc8db2b4 | ||
|
|
018f84f80d | ||
|
|
d58a47b776 | ||
|
|
797bdd001f | ||
|
|
49314533bf | ||
|
|
27ca058088 | ||
|
|
f4449f9581 | ||
|
|
b52bc03c8d | ||
|
|
edb56f3fcd | ||
|
|
fcc5130068 | ||
|
|
c7751e293d | ||
|
|
bd669a1a8f | ||
|
|
9b5b79525f | ||
|
|
426929556f | ||
|
|
ade640dcea | ||
|
|
61128ddcc8 | ||
|
|
6a3cee8107 | ||
|
|
06d9c07d22 | ||
|
|
49d0f1a141 | ||
|
|
ac73b9d891 | ||
|
|
489980814c | ||
|
|
14654acced | ||
|
|
2885f7a297 | ||
|
|
906c9f11fa | ||
|
|
3419805a49 | ||
|
|
51a6cbe6d4 | ||
|
|
d08eb2afa6 | ||
|
|
91a6b19ba1 | ||
|
|
e66979bab6 | ||
|
|
4ef7641cb2 | ||
|
|
f33fc838cf | ||
|
|
f0019fa213 | ||
|
|
ba8536b36d | ||
|
|
965aec5155 | ||
|
|
387db030cd | ||
|
|
8e557c9924 | ||
|
|
f26e60edf0 | ||
|
|
7bbfd74454 | ||
|
|
018b1ea4da | ||
|
|
14c3fa2e76 | ||
|
|
9168f8114b | ||
|
|
9cabb42edb | ||
|
|
bfba3c9215 | ||
|
|
7184bc09c1 | ||
|
|
9bb0c0f811 | ||
|
|
cf0e469465 | ||
|
|
8e7fa782b3 | ||
|
|
97652802f0 | ||
|
|
1706289124 | ||
|
|
dd23de7aaa | ||
|
|
f1602f67d4 | ||
|
|
c03cd97d3a | ||
|
|
a8e9a074d3 | ||
|
|
1e9d3e0656 | ||
|
|
01e0f3612d | ||
|
|
0015a6d88d | ||
|
|
ac6a0c9254 |
No files matched your search
@@ -0,0 +1,622 @@
|
||||
# Migrating Tables from mantine-react-table to mantine-datatable
|
||||
|
||||
This document captures every pattern, fix, and decision from the downloads widget rework (PR #6430). Use it as a playbook when porting other tables.
|
||||
|
||||
## Migration Candidates
|
||||
|
||||
Current `mantine-react-table` consumers that could be migrated:
|
||||
|
||||
**Widgets (in `packages/widgets/`):**
|
||||
- `docker/component.tsx` — Docker container table
|
||||
- `media-server/component.tsx` — Media server sessions
|
||||
|
||||
**Admin pages (in `apps/nextjs/src/app/[locale]/manage/`):**
|
||||
- `tools/docker/docker-table.tsx`
|
||||
- `tools/tasks/_components/tasks-table.tsx`
|
||||
- `tools/api/components/api-keys.tsx`
|
||||
- `users/_components/user-list.tsx`
|
||||
- `users/invites/_components/invite-list.tsx`
|
||||
- `tools/kubernetes/` — 8 Kubernetes resource tables
|
||||
|
||||
**Modals:**
|
||||
- `packages/modals-collection/src/search-engines/request-media-modal.tsx`
|
||||
|
||||
**Shared hook to remove once all migrated:**
|
||||
- `packages/ui/src/hooks/use-translated-mantine-react-table.ts`
|
||||
|
||||
---
|
||||
|
||||
## 1. Core Migration: mantine-react-table → mantine-datatable
|
||||
|
||||
### Import changes
|
||||
|
||||
```typescript
|
||||
// BEFORE
|
||||
import { MantineReactTable, useMantineReactTable } from "mantine-react-table";
|
||||
import type { MRT_ColumnDef } from "mantine-react-table";
|
||||
|
||||
// AFTER
|
||||
import { DataTable, useDataTableColumns } from "mantine-datatable";
|
||||
import type { DataTableColumn, DataTableSortStatus } from "mantine-datatable";
|
||||
```
|
||||
|
||||
### Column definition shape
|
||||
|
||||
```typescript
|
||||
// BEFORE (MRT)
|
||||
const columns: MRT_ColumnDef<MyRow>[] = [
|
||||
{
|
||||
accessorKey: "name",
|
||||
header: "Name",
|
||||
size: 200,
|
||||
enableSorting: true,
|
||||
Cell: ({ row }) => <Text>{row.original.name}</Text>,
|
||||
},
|
||||
];
|
||||
|
||||
// AFTER (mantine-datatable)
|
||||
const columns: DataTableColumn<MyRow>[] = [
|
||||
{
|
||||
accessor: "name", // not accessorKey
|
||||
title: "Name", // not header
|
||||
width: 200, // not size
|
||||
sortable: true, // not enableSorting
|
||||
render: (record) => <Text>{record.name}</Text>, // not Cell
|
||||
},
|
||||
];
|
||||
```
|
||||
|
||||
Key differences:
|
||||
- `accessorKey` → `accessor`
|
||||
- `header` → `title`
|
||||
- `size` → `width`
|
||||
- `Cell: ({ row }) =>` → `render: (record) =>`
|
||||
- `enableSorting` → `sortable`
|
||||
- `enableColumnActions` → does not exist (no column action menus)
|
||||
- `mantineTableBodyCellProps` → `cellsStyle` in `defaultColumnProps`
|
||||
|
||||
### Table component
|
||||
|
||||
```typescript
|
||||
// BEFORE
|
||||
const table = useTranslatedMantineReactTable({ columns, data, ... });
|
||||
return <MantineReactTable table={table} />;
|
||||
|
||||
// AFTER
|
||||
return (
|
||||
<DataTable
|
||||
records={data}
|
||||
columns={columns}
|
||||
sortStatus={sortStatus}
|
||||
onSortStatusChange={setSortStatus}
|
||||
...
|
||||
/>
|
||||
);
|
||||
```
|
||||
|
||||
### Sorting is manual
|
||||
|
||||
mantine-datatable does NOT sort for you. You must sort the data yourself:
|
||||
|
||||
```typescript
|
||||
const [sortStatus, setSortStatus] = useState<DataTableSortStatus<MyRow>>({
|
||||
columnAccessor: "name",
|
||||
direction: "asc",
|
||||
});
|
||||
|
||||
const sortedData = useMemo(() => {
|
||||
const mult = sortStatus.direction === "desc" ? -1 : 1;
|
||||
return [...data].toSorted((a, b) => {
|
||||
const aVal = a[sortStatus.columnAccessor];
|
||||
const bVal = b[sortStatus.columnAccessor];
|
||||
if (typeof aVal === "string") return aVal.localeCompare(bVal as string) * mult;
|
||||
return ((aVal as number) - (bVal as number)) * mult;
|
||||
});
|
||||
}, [data, sortStatus]);
|
||||
|
||||
// Pass sortedData to records, not data
|
||||
<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.
|
||||
|
||||
Create a CSS file alongside the component:
|
||||
|
||||
```css
|
||||
/* styles.css */
|
||||
.my-table,
|
||||
.my-table .mantine-datatable-table,
|
||||
.my-table .mantine-datatable-table thead,
|
||||
.my-table .mantine-datatable-table tbody,
|
||||
.my-table .mantine-datatable-table tfoot,
|
||||
.my-table th,
|
||||
.my-table td {
|
||||
background-color: transparent !important;
|
||||
}
|
||||
|
||||
.my-table .mantine-datatable-table tr {
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
/* Hover: translucent instead of opaque */
|
||||
.my-table .mantine-datatable-table tbody tr:hover {
|
||||
background-color: color-mix(in srgb, var(--mantine-color-default-hover) 40%, transparent) !important;
|
||||
}
|
||||
|
||||
/* Headers: frosted glass effect */
|
||||
.my-table th {
|
||||
white-space: nowrap;
|
||||
background-color: color-mix(in srgb, var(--mantine-color-body) 60%, transparent) !important;
|
||||
backdrop-filter: blur(8px);
|
||||
}
|
||||
|
||||
/* Cell overflow */
|
||||
.my-table td {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
/* Hide sort icon until hover */
|
||||
.my-table .mantine-datatable-header-cell-sortable-icon {
|
||||
opacity: 0;
|
||||
transition: opacity 150ms;
|
||||
}
|
||||
.my-table th:hover .mantine-datatable-header-cell-sortable-icon {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Row expansion area */
|
||||
.my-table .mantine-datatable-row-expansion-cell {
|
||||
background-color: color-mix(in srgb, var(--mantine-color-body) 40%, transparent) !important;
|
||||
}
|
||||
```
|
||||
|
||||
Import it: `import "./styles.css";` and apply via `className="my-table"`.
|
||||
|
||||
---
|
||||
|
||||
## 3. Responsive Column Visibility
|
||||
|
||||
Use a lookup table of width breakpoints instead of media queries. The widget receives `width` as a prop.
|
||||
|
||||
```typescript
|
||||
interface SizeConfig {
|
||||
fontSize: "xs" | "sm";
|
||||
iconSize: number;
|
||||
cellPadding: number;
|
||||
showSpeedColumns: boolean;
|
||||
showTimeColumn: boolean;
|
||||
showStateColumn: boolean;
|
||||
}
|
||||
|
||||
const SIZE_BREAKPOINTS: { maxWidth: number; config: SizeConfig }[] = [
|
||||
{ maxWidth: 300, config: { fontSize: "xs", iconSize: 12, cellPadding: 2, showSpeedColumns: false, showTimeColumn: false, showStateColumn: false } },
|
||||
{ maxWidth: 500, config: { fontSize: "xs", iconSize: 14, cellPadding: 4, showSpeedColumns: false, showTimeColumn: true, showStateColumn: false } },
|
||||
];
|
||||
|
||||
const DEFAULT_SIZE_CONFIG: SizeConfig = { fontSize: "sm", iconSize: 14, cellPadding: 4, showSpeedColumns: true, showTimeColumn: true, showStateColumn: true };
|
||||
|
||||
function getSizeConfig(width: number): SizeConfig {
|
||||
for (const { maxWidth, config } of SIZE_BREAKPOINTS) {
|
||||
if (width < maxWidth) return config;
|
||||
}
|
||||
return DEFAULT_SIZE_CONFIG;
|
||||
}
|
||||
|
||||
// Usage: memoize it
|
||||
const size = useMemo(() => getSizeConfig(width), [width]);
|
||||
```
|
||||
|
||||
Then use a visibility check lookup table for columns:
|
||||
|
||||
```typescript
|
||||
const columnVisibilityChecks: Partial<Record<string, (ctx: ColumnContext) => boolean>> = {
|
||||
upSpeed: (ctx) => ctx.hasTorrents && ctx.size.showSpeedColumns,
|
||||
downSpeed: (ctx) => ctx.size.showSpeedColumns,
|
||||
time: (ctx) => ctx.size.showTimeColumn,
|
||||
state: (ctx) => ctx.size.showStateColumn,
|
||||
};
|
||||
|
||||
function shouldIncludeColumn(accessor: string, ctx: ColumnContext): boolean {
|
||||
if (!ctx.optionsColumnSet.has(accessor)) return false;
|
||||
const check = columnVisibilityChecks[accessor];
|
||||
if (check && !check(ctx)) return false;
|
||||
return true;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Column Persistence (Drag/Resize → Server)
|
||||
|
||||
`useDataTableColumns` stores column order and widths in localStorage by default. For widgets, this must persist server-side so it works across devices.
|
||||
|
||||
### Hidden widget options
|
||||
|
||||
In `index.ts`, add hidden text options:
|
||||
|
||||
```typescript
|
||||
columnOrder: factory.text({ defaultValue: "" }),
|
||||
columnWidths: factory.text({ defaultValue: "" }),
|
||||
```
|
||||
|
||||
With visibility override:
|
||||
|
||||
```typescript
|
||||
{
|
||||
columnOrder: { shouldHide: () => true },
|
||||
columnWidths: { shouldHide: () => true },
|
||||
}
|
||||
```
|
||||
|
||||
### Translation keys required
|
||||
|
||||
Even hidden options need translation keys or the translation test fails:
|
||||
|
||||
```json
|
||||
"widget.mywidget.option.columnOrder.label": "Column order",
|
||||
"widget.mywidget.option.columnWidths.label": "Column widths"
|
||||
```
|
||||
|
||||
### Old-import mappings
|
||||
|
||||
Add to `packages/old-import/src/widgets/options.ts`:
|
||||
|
||||
```typescript
|
||||
columnOrder: () => undefined,
|
||||
columnWidths: () => undefined,
|
||||
```
|
||||
|
||||
### Dynamic storeKey
|
||||
|
||||
Use a key that includes `itemId` and sorted column names. This prevents cross-widget localStorage bleeding:
|
||||
|
||||
```typescript
|
||||
const storeKey = `mytable-${itemId ?? "preview"}-${[...options.columns].toSorted().join(",")}`;
|
||||
```
|
||||
|
||||
### Hydration from server → localStorage
|
||||
|
||||
On mount, seed `useDataTableColumns` with server-persisted values. Use a `hydrated` ref to prevent the persist effect from immediately writing defaults back:
|
||||
|
||||
```typescript
|
||||
const { effectiveColumns, columnsOrder, columnsWidth, setColumnsOrder, setMultipleColumnWidths } =
|
||||
useDataTableColumns<MyRow>({ key: storeKey, columns });
|
||||
|
||||
const lastStoreKey = useRef(storeKey);
|
||||
const hydrated = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (lastStoreKey.current !== storeKey) {
|
||||
lastStoreKey.current = storeKey;
|
||||
hydrated.current = false;
|
||||
}
|
||||
if (hydrated.current) return;
|
||||
if (savedOrder.length > 0) setColumnsOrder(savedOrder);
|
||||
if (Object.keys(savedWidths).length > 0) {
|
||||
setMultipleColumnWidths(
|
||||
Object.entries(savedWidths).map(([accessor, w]) => ({ accessor, width: w })),
|
||||
);
|
||||
}
|
||||
requestAnimationFrame(() => { hydrated.current = true; });
|
||||
}, [storeKey, savedOrder, savedWidths, setColumnsOrder, setMultipleColumnWidths]);
|
||||
```
|
||||
|
||||
### Persist localStorage → server
|
||||
|
||||
Watch for changes and write back:
|
||||
|
||||
```typescript
|
||||
const prevOrder = useRef(columnsOrder);
|
||||
const prevWidths = useRef(columnsWidth);
|
||||
|
||||
useEffect(() => {
|
||||
if (!hydrated.current) return;
|
||||
const orderChanged = JSON.stringify(columnsOrder) !== JSON.stringify(prevOrder.current);
|
||||
const widthsChanged = JSON.stringify(columnsWidth) !== JSON.stringify(prevWidths.current);
|
||||
prevOrder.current = columnsOrder;
|
||||
prevWidths.current = columnsWidth;
|
||||
|
||||
if (!orderChanged && !widthsChanged) return;
|
||||
|
||||
if (orderChanged) persistOption({ columnOrder: JSON.stringify(columnsOrder) });
|
||||
if (widthsChanged) {
|
||||
const widthMap: Record<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);
|
||||
}
|
||||
persistOption({ columnWidths: JSON.stringify(widthMap) });
|
||||
}
|
||||
}, [columnsOrder, columnsWidth, persistOption]);
|
||||
```
|
||||
|
||||
The `persistOption` helper calls both `setOptions` (local state) and `saveItemOptions` (server mutation).
|
||||
|
||||
---
|
||||
|
||||
## 5. Context Menu (Right-Click)
|
||||
|
||||
Do NOT install `mantine-contextmenu`. Use Mantine's `Menu` + `Portal` directly:
|
||||
|
||||
```typescript
|
||||
interface ContextMenuState {
|
||||
x: number;
|
||||
y: number;
|
||||
item: MyRow;
|
||||
}
|
||||
|
||||
const [contextMenu, setContextMenu] = useState<ContextMenuState | null>(null);
|
||||
|
||||
const handleContextMenu = useCallback(
|
||||
({ record, event }: { record: MyRow; event: React.MouseEvent }) => {
|
||||
event.preventDefault();
|
||||
setContextMenu({ x: event.clientX, y: event.clientY, item: record });
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
// On DataTable:
|
||||
<DataTable onRowContextMenu={handleContextMenu} />
|
||||
|
||||
// Render:
|
||||
{contextMenu && <RowContextMenu state={contextMenu} onClose={closeContextMenu} />}
|
||||
```
|
||||
|
||||
The menu component uses `Portal` for correct z-index:
|
||||
|
||||
```tsx
|
||||
function RowContextMenu({ state, onClose }: { state: ContextMenuState; onClose: () => void }) {
|
||||
return (
|
||||
<Portal>
|
||||
<Menu opened onClose={onClose} closeOnItemClick={false} position="right-start" offset={0}>
|
||||
<Menu.Target>
|
||||
<Box style={{ position: "fixed", left: state.x, top: state.y, width: 0, height: 0 }} />
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown>
|
||||
<Menu.Item onClick={() => { /* action */ onClose(); }}>Action</Menu.Item>
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
</Portal>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
**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
|
||||
<DataTable
|
||||
rowExpansion={{
|
||||
trigger: "click",
|
||||
allowMultiple: true,
|
||||
collapseProps: {
|
||||
transitionDuration: 200,
|
||||
animateOpacity: true,
|
||||
transitionTimingFunction: "ease-out",
|
||||
},
|
||||
content: ({ record, collapse }) => <ExpandedRow item={record} collapse={collapse} />,
|
||||
}}
|
||||
/>
|
||||
```
|
||||
|
||||
**Critical:** Do NOT set `onRowClick` when using `rowExpansion` with `trigger: "click"`. They conflict and the expansion won't work.
|
||||
|
||||
---
|
||||
|
||||
## 7. Common Pitfalls & Fixes
|
||||
|
||||
### Status indicator dots
|
||||
|
||||
Don't use `<Badge variant="dot" />` without children — it renders a pill with empty space. Use a plain `Box`:
|
||||
|
||||
```tsx
|
||||
<Box
|
||||
w={8}
|
||||
h={8}
|
||||
style={{
|
||||
borderRadius: "50%",
|
||||
backgroundColor: `var(--mantine-color-${color}-filled)`,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
/>
|
||||
```
|
||||
|
||||
### Tooltip overflow on long text columns
|
||||
|
||||
When a column has `ellipsis: true`, the tooltip target element can still extend beyond the cell bounds if inner elements don't clip. Fix:
|
||||
|
||||
```tsx
|
||||
<Tooltip position="bottom-start" ...>
|
||||
<Group style={{ overflow: "hidden" }}>
|
||||
...
|
||||
</Group>
|
||||
</Tooltip>
|
||||
```
|
||||
|
||||
Use `position="bottom-start"` instead of `position="right"` so the tooltip never escapes the widget horizontally.
|
||||
|
||||
### Dark mode tooltips
|
||||
|
||||
Mantine tooltips default to the theme's tooltip color which may be white in light mode and stay white in dark mode. Always set `color="dark"` on informational tooltips inside widgets.
|
||||
|
||||
### Division by zero
|
||||
|
||||
Guard all ratio calculations:
|
||||
|
||||
```typescript
|
||||
if (item.size > 0) ratio = item.sent / item.size;
|
||||
if (totalDown > 0) ratio = totalUp / totalDown;
|
||||
```
|
||||
|
||||
### Composite idAccessor
|
||||
|
||||
When rows can come from multiple integrations, IDs may collide. Use a composite key:
|
||||
|
||||
```typescript
|
||||
idAccessor={(record) => `${record.integration.id}:${record.id}`}
|
||||
```
|
||||
|
||||
### JSON option parsing
|
||||
|
||||
Always wrap in try/catch:
|
||||
|
||||
```typescript
|
||||
function parseJsonOption<T>(value: string | undefined, fallback: T): T {
|
||||
if (!value) return fallback;
|
||||
try { return JSON.parse(value) as T; }
|
||||
catch { return fallback; }
|
||||
}
|
||||
```
|
||||
|
||||
### Clipboard API
|
||||
|
||||
`navigator.clipboard.writeText()` can throw in non-HTTPS contexts. Always catch:
|
||||
|
||||
```typescript
|
||||
void navigator.clipboard.writeText(text).catch(() => {});
|
||||
```
|
||||
|
||||
### Sync sortStatus with widget options
|
||||
|
||||
If the widget has configurable default sort, sync it:
|
||||
|
||||
```typescript
|
||||
useEffect(() => {
|
||||
setSortStatus({ columnAccessor: options.defaultSort, direction: defaultSortDirection });
|
||||
}, [options.defaultSort, defaultSortDirection]);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. Accessibility
|
||||
|
||||
- All `ActionIcon` components need `aria-label`
|
||||
- Filter toggles (client badges, status badges) need `aria-pressed={isActive}`
|
||||
- `textSelectionDisabled` on DataTable prevents accidental text selection during drag/resize
|
||||
|
||||
---
|
||||
|
||||
## 9. Error Handling
|
||||
|
||||
- Every mutation needs an `onError` callback with `showErrorNotification`
|
||||
- The `saveItemOptions` mutation (column persistence) also needs `onError`
|
||||
- Check `isError` on the main query and show a fallback UI
|
||||
- Use a shared `mutationOptions` object to avoid repeating `onSettled`/`onError`:
|
||||
|
||||
```typescript
|
||||
const mutationOptions = {
|
||||
onSettled: () => void utils.myQuery.invalidate(),
|
||||
onError: () => showErrorNotification({ title: t("errors.actionFailed"), message: t("errors.actionFailedMessage") }),
|
||||
};
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 10. i18n
|
||||
|
||||
- No hardcoded strings. Every user-visible string goes through `useScopedI18n`
|
||||
- Column titles: `t("items.{accessor}.columnTitle")`
|
||||
- Detail labels: `t("items.{accessor}.detailsTitle")`
|
||||
- States: `t("states.{state}")`
|
||||
- Actions: `t("actions.{action}")`
|
||||
- Errors: `t("errors.{errorKey}")`
|
||||
- Hidden options still need `.label` translation keys
|
||||
|
||||
---
|
||||
|
||||
## 11. Edit Mode
|
||||
|
||||
Disable pointer events on the table during edit mode to prevent conflicts with the board's drag-and-drop:
|
||||
|
||||
```typescript
|
||||
let tablePointerEvents: React.CSSProperties["pointerEvents"];
|
||||
if (isEditMode) tablePointerEvents = "none";
|
||||
|
||||
let rowContextMenuHandler = handleContextMenu;
|
||||
if (isEditMode) rowContextMenuHandler = undefined;
|
||||
|
||||
<DataTable style={{ pointerEvents: tablePointerEvents }} />
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 12. DataTable Props Reference (Commonly Used)
|
||||
|
||||
```typescript
|
||||
<DataTable
|
||||
records={sortedData}
|
||||
columns={effectiveColumns}
|
||||
storeColumnsKey={storeKey}
|
||||
withTableBorder={false}
|
||||
borderRadius={0}
|
||||
highlightOnHover
|
||||
striped="odd"
|
||||
stripedColor={{ dark: "dark.7", light: "gray.0" }}
|
||||
highlightOnHoverColor={{ dark: "dark.5", light: "gray.1" }}
|
||||
verticalAlign="center"
|
||||
fetching={isFetching && data.length === 0}
|
||||
loaderBackgroundBlur={2}
|
||||
fz={size.fontSize}
|
||||
textSelectionDisabled
|
||||
sortStatus={sortStatus}
|
||||
onSortStatusChange={setSortStatus}
|
||||
noRecordsText={t("errors.noItems")}
|
||||
idAccessor={(record) => record.uniqueKey}
|
||||
height="100%"
|
||||
className="my-table"
|
||||
defaultColumnProps={{
|
||||
noWrap: true,
|
||||
draggable: true,
|
||||
resizable: true,
|
||||
cellsStyle: () => ({ padding: `${size.cellPadding}px 8px` }),
|
||||
}}
|
||||
scrollAreaProps={{ type: "auto", scrollbarSize: 6 }}
|
||||
rowBackgroundColor={(record) => conditionalBgMap[record.state]}
|
||||
onRowContextMenu={handleContextMenu}
|
||||
rowExpansion={{ ... }}
|
||||
onScroll={() => { if (contextMenu) closeContextMenu(); }}
|
||||
/>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 13. Layout Stability
|
||||
|
||||
- Don't use `table-layout: fixed` — it fights mantine-datatable's resize logic
|
||||
- Give non-name columns explicit `width` values so only the name column flexes
|
||||
- Use `ellipsis: true` on the name column — it handles `text-overflow: ellipsis` internally
|
||||
- Names should truncate at the cell boundary, not a character count, so the layout is stable regardless of content length
|
||||
- `flexShrink: 0` on badges/icons prevents them from being squeezed by long names
|
||||
|
||||
---
|
||||
|
||||
## 14. Checklist for Each Migration
|
||||
|
||||
1. Replace imports (`mantine-react-table` → `mantine-datatable`)
|
||||
2. Convert column definitions (`accessorKey` → `accessor`, `header` → `title`, `Cell` → `render`, etc.)
|
||||
3. Implement manual sorting with `useState` + `useMemo`
|
||||
4. Add CSS file for transparent backgrounds (if widget context)
|
||||
5. Add responsive column visibility (if widget context)
|
||||
6. Add hidden options + hydration logic (if column persistence needed)
|
||||
7. Add old-import mappings for new options
|
||||
8. Add translation keys (including hidden option labels)
|
||||
9. Add context menu if row actions exist
|
||||
10. Add row expansion if detail view exists
|
||||
11. Add error handling on all mutations
|
||||
12. Add accessibility attributes
|
||||
13. Test at multiple widget widths
|
||||
14. Update docs in `apps/docs/docs/widgets/` if applicable
|
||||
15. Run `pnpm turbo typecheck` — catches old-import mismatches
|
||||
16. Run translation spec — catches missing keys
|
||||
+2
-1
@@ -17,7 +17,8 @@ coverage
|
||||
dev
|
||||
.build
|
||||
e2e
|
||||
apps/docs
|
||||
apps/docs/*
|
||||
!apps/docs/package.json
|
||||
apps/nextjs/.next
|
||||
.env
|
||||
.env.*
|
||||
|
||||
+20
-3
@@ -61,9 +61,26 @@ TURBO_TELEMETRY_DISABLED=1
|
||||
# Enable mock integration
|
||||
UNSAFE_ENABLE_MOCK_INTEGRATION=true
|
||||
|
||||
# Public Workshop API origin used by Homarr's Workshop browser. The default is https://homarr.dev.
|
||||
# This is read when the server starts, including in the production Docker image.
|
||||
# WORKSHOP_API_URL=http://127.0.0.1:3003
|
||||
# Public URL contract. Values must be absolute HTTP(S) URLs without credentials,
|
||||
# query strings, or fragments. Trailing slashes are normalized at startup.
|
||||
HOMARR_WEBSITE_URL=https://homarr.dev
|
||||
# PocketBase API used by Homarr and by the standalone documentation site.
|
||||
# Defaults to HOMARR_WEBSITE_URL.
|
||||
# WORKSHOP_API_URL=https://homarr.dev
|
||||
# Public Workshop page opened from Homarr. Defaults to HOMARR_WEBSITE_URL/workshop.
|
||||
# WORKSHOP_WEB_URL=https://homarr.dev/workshop
|
||||
|
||||
# Documentation and PocketBase deployment settings.
|
||||
# WORKSHOP_PUBLIC_ORIGIN=https://homarr.dev
|
||||
# PB_ALLOWED_ORIGINS=*
|
||||
|
||||
# Local Workshop stack:
|
||||
# HOMARR_WEBSITE_URL=http://127.0.0.1:3003
|
||||
# WORKSHOP_API_URL=http://127.0.0.1:8090
|
||||
# WORKSHOP_WEB_URL=http://127.0.0.1:3003/workshop
|
||||
# WORKSHOP_PUBLIC_ORIGIN=http://127.0.0.1:3003
|
||||
# Optional: GITHUB_CLIENT_ID=
|
||||
# Optional: GITHUB_CLIENT_SECRET=
|
||||
|
||||
# Enable demo mode (seeds a demo user "demo"/"demo", a sample board and a mock integration)
|
||||
# DEMO_MODE=true
|
||||
|
||||
@@ -33,6 +33,10 @@ body:
|
||||
options:
|
||||
# The below comment is used to insert a new version with on-release.yml
|
||||
#NEXT_VERSION#
|
||||
- 1.74.0
|
||||
- 1.73.0
|
||||
- 1.72.0
|
||||
- 1.71.0
|
||||
- 1.70.0
|
||||
- 1.69.2
|
||||
- 1.69.0
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
$schema: "https://docs.renovatebot.com/renovate-schema.json",
|
||||
extends: ["config:recommended"],
|
||||
extends: ["config:recommended", ":dependencyDashboardApproval"],
|
||||
packageRules: [
|
||||
{
|
||||
matchPackageNames: ["^@homarr/"],
|
||||
@@ -47,7 +47,7 @@
|
||||
rangeStrategy: "bump",
|
||||
automerge: false,
|
||||
baseBranchPatterns: ["dev"],
|
||||
dependencyDashboard: false,
|
||||
dependencyDashboard: true,
|
||||
// prevent special characters in branch names (otherwise git errors occur on windows)
|
||||
branchNameStrict: true,
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
name: "[Dependency Updates] Auto Approve"
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, synchronize]
|
||||
|
||||
permissions: {}
|
||||
|
||||
jobs:
|
||||
approve-automatic-prs:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 2
|
||||
# 158783068: homarr-renovate[bot]
|
||||
# 190541745: homarr-crowdin[bot]
|
||||
# 210161987: homarr-update-contributors[bot]
|
||||
# 175486441: homarr-releases[bot]
|
||||
# Extracted from https://api.github.com/users/<username>
|
||||
if: github.actor_id == 158783068 || github.actor_id == 190541745 || github.actor_id == 210161987 || github.actor_id == 175486441
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v7
|
||||
- name: Obtain token
|
||||
id: obtainToken
|
||||
uses: actions/create-github-app-token@v3
|
||||
with:
|
||||
private-key: ${{ secrets.RENOVATE_APPROVE_PRIVATE_KEY }}
|
||||
client-id: ${{ secrets.RENOVATE_APPROVE_APP_ID }}
|
||||
permission-pull-requests: write # required to approve pull request
|
||||
|
||||
- name: Install GitHub CLI
|
||||
run: sudo apt-get install -y gh
|
||||
- name: Approve automatic PRs
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ steps.obtainToken.outputs.token }}
|
||||
run: |
|
||||
gh pr review ${{github.event.pull_request.number}} --approve --body "Automatically approved by GitHub Action"
|
||||
@@ -0,0 +1,43 @@
|
||||
name: "[Bots] Reconcile Auto-merge"
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: "*/30 * * * *"
|
||||
workflow_dispatch:
|
||||
|
||||
permissions: {}
|
||||
|
||||
jobs:
|
||||
reconcile:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Obtain token
|
||||
id: obtainToken
|
||||
uses: actions/create-github-app-token@v3
|
||||
with:
|
||||
private-key: ${{ secrets.RENOVATE_APPROVE_PRIVATE_KEY }}
|
||||
client-id: ${{ secrets.RENOVATE_APPROVE_APP_ID }}
|
||||
permission-pull-requests: write # required to enable auto-merge
|
||||
|
||||
- name: Install GitHub CLI
|
||||
run: sudo apt-get install -y gh
|
||||
|
||||
- name: Re-enable auto-merge for prepared bot PRs
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ steps.obtainToken.outputs.token }}
|
||||
run: |
|
||||
# 158783068: homarr-renovate[bot]
|
||||
# 190541745: homarr-crowdin[bot]
|
||||
# 210161987: homarr-update-contributors[bot]
|
||||
# 175441: homarr-releases[bot]
|
||||
gh pr list --repo "${{ github.repository }}" --base dev --state open --limit 100 \
|
||||
--json number,author,reviewDecision,mergeStateStatus,autoMergeRequest \
|
||||
--jq '.[] | select(.author.login as $login | ["app/homarr-renovate", "app/homarr-crowdin", "app/homarr-update-contributors", "app/homarr-releases"] | index($login)) | select(.reviewDecision == "APPROVED") | select(.mergeStateStatus == "CLEAN") | select(.autoMergeRequest == null) | .number' \
|
||||
| while read -r pr; do
|
||||
echo "Re-enabling auto-merge on PR #$pr"
|
||||
gh pr merge "$pr" --auto --squash
|
||||
done
|
||||
@@ -0,0 +1,47 @@
|
||||
name: "[Bots] Approve and Auto-merge"
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, synchronize, reopened, ready_for_review]
|
||||
|
||||
permissions: {}
|
||||
|
||||
jobs:
|
||||
approve-and-automerge:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
# 158783068: homarr-renovate[bot]
|
||||
# 190541745: homarr-crowdin[bot]
|
||||
# 210161987: homarr-update-contributors[bot]
|
||||
# 175441: homarr-releases[bot]
|
||||
# Extracted from https://api.github.com/users/<username>
|
||||
if: |
|
||||
(github.actor_id == 158783068 || github.actor_id == 190541745 || github.actor_id == 210161987 || github.actor_id == 175441) &&
|
||||
github.event.pull_request.head.repo.full_name == github.repository &&
|
||||
github.event.pull_request.base.ref == 'dev'
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Obtain token
|
||||
id: obtainToken
|
||||
uses: actions/create-github-app-token@v3
|
||||
with:
|
||||
private-key: ${{ secrets.RENOVATE_APPROVE_PRIVATE_KEY }}
|
||||
client-id: ${{ secrets.RENOVATE_APPROVE_APP_ID }}
|
||||
permission-pull-requests: write # required to approve a pull request and enable auto-merge
|
||||
|
||||
- name: Install GitHub CLI
|
||||
run: sudo apt-get install -y gh
|
||||
|
||||
- name: Approve and enable auto-merge
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ steps.obtainToken.outputs.token }}
|
||||
run: |
|
||||
PR_NUMBER="${{ github.event.pull_request.number }}"
|
||||
if [ "$(gh pr view "$PR_NUMBER" --json reviewDecision --jq .reviewDecision)" != "APPROVED" ]; then
|
||||
gh pr review "$PR_NUMBER" --approve --body "Automatically approved by Homarr bot policy"
|
||||
fi
|
||||
if [ "$(gh pr view "$PR_NUMBER" --json autoMergeRequest --jq '.autoMergeRequest != null')" != "true" ]; then
|
||||
gh pr merge "$PR_NUMBER" --auto --squash
|
||||
fi
|
||||
@@ -84,7 +84,7 @@ jobs:
|
||||
run: cp .env.example .env
|
||||
|
||||
- name: Lint, typecheck, and build affected workspaces
|
||||
run: pnpm turbo run lint typecheck build --affected --continue=dependencies-successful --concurrency=4 --log-order=stream
|
||||
run: pnpm turbo run lint typecheck build --affected --continue=dependencies-successful --concurrency=2
|
||||
|
||||
- name: Validate workspace configuration
|
||||
run: pnpm lint:ws
|
||||
|
||||
@@ -3,7 +3,7 @@ name: Workshop
|
||||
on:
|
||||
pull_request:
|
||||
push:
|
||||
branches: [dev, v2]
|
||||
branches: [dev, v2, main]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -19,10 +19,45 @@ jobs:
|
||||
node-version: 24.18.0
|
||||
- uses: pnpm/action-setup@v6
|
||||
with:
|
||||
version: 11.11.0
|
||||
run_install: false
|
||||
- run: pnpm install --frozen-lockfile
|
||||
- run: pnpm --filter @homarr/workshop typecheck
|
||||
- run: pnpm --filter @homarr/docs typecheck
|
||||
- run: docker compose --env-file apps/workshop/.env.staging.example -f apps/workshop/docker-compose.staging.yml config --quiet
|
||||
- run: pnpm test:workshop
|
||||
- run: docker build --target production -f apps/workshop/Dockerfile .
|
||||
- run: pnpm test:workshop-image
|
||||
|
||||
publish:
|
||||
if: github.event_name == 'push' && (github.ref_name == 'v2' || github.ref_name == 'main')
|
||||
needs: validate
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 30
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: docker/setup-qemu-action@v4
|
||||
- uses: docker/setup-buildx-action@v4
|
||||
- uses: docker/login-action@v4
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
- uses: docker/metadata-action@v6
|
||||
id: metadata
|
||||
with:
|
||||
images: ghcr.io/homarr-labs/workshop
|
||||
tags: |
|
||||
type=sha,prefix=sha-,format=long
|
||||
type=raw,value=v2,enable=${{ github.ref_name == 'v2' }}
|
||||
type=raw,value=latest,enable=${{ github.ref_name == 'main' }}
|
||||
- uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: .
|
||||
file: apps/workshop/Dockerfile
|
||||
target: production
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
tags: ${{ steps.metadata.outputs.tags }}
|
||||
labels: ${{ steps.metadata.outputs.labels }}
|
||||
@@ -83,3 +83,4 @@ skills-lock.json
|
||||
apps/docs/.docusaurus
|
||||
apps/docs/build
|
||||
.pnpm-store
|
||||
benchmark-results
|
||||
@@ -46,6 +46,8 @@ homarr/
|
||||
├── tooling/
|
||||
│ ├── typescript/ # Base tsconfig
|
||||
│ └── github/ # CI setup action
|
||||
├── tools/
|
||||
│ └── homarr-dev/ # Go CLI for local and PR Docker images
|
||||
├── development/ # Dev docker-compose (Redis, MySQL, PostgreSQL)
|
||||
├── e2e/ # E2E test specs
|
||||
└── Dockerfile # Multi-stage production build
|
||||
@@ -68,6 +70,9 @@ When modifying user-facing code, you MUST also update the corresponding document
|
||||
## Monorepo Commands
|
||||
|
||||
- `pnpm dev` — Next.js app only
|
||||
- `pnpm dev:cli -- dev` — run the developer CLI without installing a global binary
|
||||
- `pnpm db:seed` — seed default database data explicitly
|
||||
- `pnpm docker:dev:up` — start the Redis development service in the background
|
||||
- `pnpm dev:docs` — Docusaurus docs site only
|
||||
- `pnpm turbo build` — build all packages
|
||||
- `pnpm turbo build --filter=@homarr/docs` — build docs only
|
||||
@@ -81,3 +86,7 @@ When modifying user-facing code, you MUST also update the corresponding document
|
||||
- UI: Mantine (not Tailwind) — Tailwind is only used in docs app
|
||||
- Icons: @tabler/icons-react
|
||||
- Docs app can import from `@homarr/definitions` for shared types
|
||||
- Run `pnpm dev:cli -- dev` to browse local `homarr:*` images and remote PR images.
|
||||
- Run `pnpm dev:cli -- build <name>` from a Homarr checkout to build `homarr:<name>` with rebuild provenance.
|
||||
- Run `pnpm dev:cli -- build --pr <number>` to build a PR locally from a temporary checkout.
|
||||
- Run Go checks from `tools/homarr-dev` with `go test ./...` and `go vet ./...`.
|
||||
@@ -1,3 +1,9 @@
|
||||
## [1.74.0](https://github.com/homarr-labs/homarr/compare/v1.73.0...v1.74.0) (2026-08-07)
|
||||
|
||||
## [1.73.0](https://github.com/homarr-labs/homarr/compare/v1.72.0...v1.73.0) (2026-07-31)
|
||||
|
||||
## [1.72.0](https://github.com/homarr-labs/homarr/compare/v1.71.0...v1.72.0) (2026-07-24)
|
||||
|
||||
## [1.71.0](https://github.com/homarr-labs/homarr/compare/v1.70.0...v1.71.0) (2026-07-17)
|
||||
|
||||
## [1.70.0](https://github.com/homarr-labs/homarr/compare/v1.69.2...v1.70.0) (2026-07-10)
|
||||
|
||||
@@ -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
|
||||
|
||||
+20
-9
@@ -3,12 +3,18 @@
|
||||
FROM node:24.18.0-alpine AS base
|
||||
|
||||
FROM base AS builder
|
||||
ARG TARGETPLATFORM
|
||||
WORKDIR /app
|
||||
RUN apk add --no-cache libc6-compat curl bash && apk update
|
||||
# Native dependencies normally use prebuilds, but the build must remain
|
||||
# reproducible when a prebuild download times out and compilation is required.
|
||||
RUN apk add --no-cache libc6-compat curl bash python3 make g++
|
||||
|
||||
RUN corepack enable pnpm
|
||||
COPY pnpm-lock.yaml pnpm-workspace.yaml package.json ./
|
||||
COPY .npmrc pnpm-lock.yaml pnpm-workspace.yaml package.json ./
|
||||
COPY patches ./patches
|
||||
COPY --parents ./apps/*/package.json ./packages/*/package.json ./tooling/*/package.json ./
|
||||
# @homarr/definitions generates documentation types during install.
|
||||
COPY --parents ./packages/definitions/src ./
|
||||
# Workaround for pnpm/pnpm#5268: pnpm fetch crashes when patchedDependencies
|
||||
# are configured with nodeLinker: hoisted. The applyPatchToDir function tries
|
||||
# to chdir into node_modules/<pkg> which doesn't exist during fetch (only the
|
||||
@@ -16,17 +22,19 @@ COPY patches ./patches
|
||||
# isolated linker, patches apply inside the virtual store (node_modules/.pnpm/...)
|
||||
# which IS created by pnpm fetch. The original hoisted linker is restored
|
||||
# before the install step so the final node_modules layout stays flat.
|
||||
RUN --mount=type=cache,id=pnpm-store,target=/pnpm/store \
|
||||
RUN --mount=type=cache,id=pnpm-store,target=/pnpm/store,sharing=locked \
|
||||
pnpm config set store-dir /pnpm/store && \
|
||||
sed -i 's/nodeLinker: hoisted/nodeLinker: isolated/' pnpm-workspace.yaml && \
|
||||
pnpm fetch && \
|
||||
pnpm fetch --ignore-scripts && \
|
||||
sed -i 's/nodeLinker: isolated/nodeLinker: hoisted/' pnpm-workspace.yaml
|
||||
|
||||
# Install only from the fetched, committed lockfile so local and Docker builds
|
||||
# resolve the same dependency graph. Serial lifecycle builds avoid esbuild's
|
||||
# atomic binary replacement racing across the hoisted workspace (pnpm/pnpm#8200).
|
||||
RUN --mount=type=cache,id=pnpm-store,target=/pnpm/store,sharing=locked \
|
||||
npm_config_nodedir=/usr/local pnpm install --recursive --offline --frozen-lockfile --child-concurrency=1
|
||||
|
||||
COPY . .
|
||||
# Follow the pnpm fetch pattern from https://pnpm.io/cli/fetch
|
||||
# --frozen-lockfile is omitted as recommended by the pnpm fetch docs
|
||||
RUN --mount=type=cache,id=pnpm-store,target=/pnpm/store \
|
||||
pnpm install --recursive
|
||||
|
||||
ARG SKIP_ENV_VALIDATION='true'
|
||||
ARG CI='true'
|
||||
@@ -37,7 +45,9 @@ RUN --mount=type=secret,id=TURBO_API,env=TURBO_API \
|
||||
--mount=type=secret,id=TURBO_TEAM,env=TURBO_TEAM \
|
||||
--mount=type=secret,id=TURBO_TOKEN,env=TURBO_TOKEN \
|
||||
--mount=type=secret,id=TURBO_REMOTE_CACHE_SIGNATURE_KEY,env=TURBO_REMOTE_CACHE_SIGNATURE_KEY \
|
||||
TURBO_PLATFORM="${TARGETPLATFORM:-linux/amd64}" pnpm build
|
||||
--mount=type=cache,id=homarr-next-build-${TARGETPLATFORM},target=/app/apps/nextjs/.next/cache,sharing=locked \
|
||||
TURBO_PLATFORM="${TARGETPLATFORM:-linux/amd64}" \
|
||||
pnpm turbo build --filter=@homarr/nextjs... --filter=@homarr/cli
|
||||
|
||||
FROM base AS runner
|
||||
WORKDIR /app
|
||||
@@ -85,5 +95,6 @@ ENV AUTH_PROVIDERS='credentials'
|
||||
ENV REDIS_IS_EXTERNAL='false'
|
||||
ENV NODE_ENV='production'
|
||||
|
||||
EXPOSE 7575
|
||||
ENTRYPOINT [ "/app/entrypoint.sh" ]
|
||||
CMD ["sh", "run.sh"]
|
||||
+10
-4
@@ -21,8 +21,14 @@ pnpm dev:docs
|
||||
|
||||
# Or from within this directory
|
||||
pnpm dev
|
||||
|
||||
# Point the docs at a local PocketBase instance
|
||||
WORKSHOP_API_URL=http://127.0.0.1:8090 pnpm dev:docs
|
||||
```
|
||||
|
||||
The standalone docs use `WORKSHOP_API_URL` for PocketBase and `HOMARR_WEBSITE_URL` for the public site origin. The old
|
||||
`WORKSHOP_URL` name is a deprecated one-release alias.
|
||||
|
||||
## Build
|
||||
|
||||
```bash
|
||||
@@ -40,7 +46,7 @@ Uses oxlint and oxfmt (same as the rest of the monorepo):
|
||||
```bash
|
||||
pnpm lint # oxlint
|
||||
pnpm format # oxfmt (check)
|
||||
pnpm format -- --write # oxfmt (fix)
|
||||
pnpm format:fix # oxfmt (fix)
|
||||
```
|
||||
|
||||
## Typecheck
|
||||
@@ -55,8 +61,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
|
||||
```
|
||||
@@ -93,7 +99,7 @@ pnpm verify:search
|
||||
| Layer | Technology |
|
||||
| -------------- | ------------------------------------------- |
|
||||
| Site generator | Docusaurus 3 (`@docusaurus/preset-classic`) |
|
||||
| Styling | Tailwind CSS 3 + Docusaurus Infima |
|
||||
| Styling | Tailwind CSS 4 + Docusaurus Infima |
|
||||
| Icons | `@tabler/icons-react` |
|
||||
| Diagrams | Mermaid (`@docusaurus/theme-mermaid`) |
|
||||
| Search | Algolia DocSearch |
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
# Developer Docker CLI
|
||||
|
||||
The host-side developer CLI builds and runs local Homarr images and pull-request previews. Run it directly from a checkout with `pnpm dev:cli -- ...`; no shell-specific setup script is required. It is separate from the recovery CLI included in production images.
|
||||
|
||||
To install an optional `homarr` binary, run `pnpm dev:cli:install` and make your Go bin directory available on `PATH`. GitHub CLI authentication is only needed for pull-request features.
|
||||
|
||||
## Browse development images
|
||||
|
||||
```bash
|
||||
pnpm dev:cli --
|
||||
```
|
||||
|
||||
The main screen combines local `homarr:*` images with open pull requests and their GHCR preview status. Select an image to start or stop it, use `R` to build a remote pull request locally in the background or rebuild a local image, or use `p` to force a remote preview pull. Press `d` to switch to the instance dashboard and `d` again to return without losing the browser state. The explicit `dev` and `dash` subcommands start on either screen.
|
||||
|
||||
A locally built PR image is folded into its remote PR row only when its recorded revision exactly matches the current GitHub head SHA. The row is marked `◆ local`, and starting it prefers that local image without a registry pull. Outdated local builds remain separate rows. Navigation and dashboard switching remain available during background builds.
|
||||
|
||||
## Build images
|
||||
|
||||
```bash
|
||||
pnpm dev:cli -- build <name>
|
||||
pnpm dev:cli -- build --pr <number> [name]
|
||||
```
|
||||
|
||||
The first command builds the current checkout as `homarr:<name>`. The pull-request form uses a depth-one clone in a temporary directory and defaults to `homarr:pr-<number>` when `name` is omitted.
|
||||
|
||||
## Rebuild images
|
||||
|
||||
```bash
|
||||
pnpm dev:cli -- rebuild <name>
|
||||
```
|
||||
|
||||
Images built by this CLI record their source checkout, revision, and optional pull-request number. Rebuild uses that provenance to select the original checkout or create a fresh temporary pull-request checkout.
|
||||
|
||||
## Run images
|
||||
|
||||
```bash
|
||||
homarr run <name>
|
||||
homarr run --pr <number>
|
||||
```
|
||||
|
||||
Add `--detach` to run in the background, `--demo` to enable demo data, or repeat `--env KEY=VALUE` to provide additional environment variables.
|
||||
@@ -8,62 +8,121 @@ tags:
|
||||
sidebar_position: 1
|
||||
---
|
||||
|
||||
## Setting up your development environment
|
||||
|
||||
This guide will help you set up your development environment to start contributing to homarr.
|
||||
## Set up your development environment
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- **Node.js (lts)**: You can download it from the [official website](https://nodejs.org/).
|
||||
- **pnpm (latest)**: You can install it by running `npm install -g pnpm`.
|
||||
- **Node.js 24.18 or newer**: You can download it from the [official website](https://nodejs.org/).
|
||||
- **Corepack**: Run `corepack enable` so the repository selects its pinned pnpm 11 version.
|
||||
- **Git**: You can download it from the [official website](https://git-scm.com/).
|
||||
- **Docker or Docker Desktop**: You can download it from the [official website](https://www.docker.com/).
|
||||
- **A GitHub account**: You can create one on the [official website](https://github.com/).
|
||||
- **Docker or Docker Desktop**: Required for the Redis service and the developer Docker CLI.
|
||||
- **Go 1.25+**: Required only for the developer Docker CLI. You can download it from the [official website](https://go.dev/).
|
||||
- **GitHub CLI**: Required only for pull-request image features. Install it from the [official website](https://cli.github.com/), then authenticate with `gh auth login`.
|
||||
|
||||
### Setting up the project
|
||||
The versions in the root `package.json` are the supported baseline. Let Corepack select the repository version instead
|
||||
of installing an unrelated global pnpm:
|
||||
|
||||
1. Fork the repository by clicking on the "Fork" button on the top right corner of the repository page.
|
||||
```sh
|
||||
corepack enable
|
||||
corepack install
|
||||
pnpm install --frozen-lockfile
|
||||
```
|
||||
|
||||

|
||||
### Run the Homarr application
|
||||
|
||||
2. Clone the repository to your local machine with `git clone`. You can find the url to use by clicking on the "Code" button on the top right corner of your forks repository page.
|
||||
1. Copy `.env.example` to `.env`.
|
||||
2. Set `DB_URL` to an absolute writable SQLite file path.
|
||||
3. Keep Redis and the optional local databases running in one terminal.
|
||||
4. Apply migrations and start Next.js in another terminal.
|
||||
|
||||

|
||||
```sh
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
3. Change directory to the project folder with `cd homarr`.
|
||||
Terminal 1:
|
||||
|
||||
4. Install the dependencies with `pnpm install`.
|
||||
```sh
|
||||
pnpm docker:dev
|
||||
```
|
||||
|
||||
5. Start redis by running `pnpm run docker:dev`. This will run a docker-compose file with a redis service.
|
||||
Terminal 2:
|
||||
|
||||
6. Copy the `.env.example` file to `.env` and replace `DB_URL` with the full path you want to use for the sqlite database file. For example `DB_URL='C:\\Dev\\homarr\\packages\\db\\db.sqlite'`
|
||||
```sh
|
||||
pnpm db:migration:sqlite:run
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
7. Run the migrations to create the sqlite db file with `pnpm run db:migration:sqlite:run`.
|
||||
The infrastructure command remains in the foreground. Open `http://127.0.0.1:3000`; the first user created through
|
||||
Homarr becomes the instance administrator.
|
||||
Use `pnpm docker:dev:up` instead when you only need Redis in the background. Running the migration also seeds the
|
||||
default data; use `pnpm db:seed` to seed it explicitly later.
|
||||
|
||||
8. Start the development server with `pnpm dev` and wait for a message that says `✓ Ready in 10.7s`
|
||||
### Run PocketBase and documentation
|
||||
|
||||
9. Open your browser and navigate to [http://localhost:3000](http://localhost:3000). (Loading the page might take up to 2 minutes the first time)
|
||||
Start PocketBase in another terminal:
|
||||
|
||||
### Useful npm scripts
|
||||
```sh
|
||||
cp apps/workshop/.env.example apps/workshop/.env
|
||||
docker compose --env-file apps/workshop/.env -f apps/workshop/docker-compose.yml up --build workshop
|
||||
```
|
||||
|
||||
#### During development
|
||||
Then start Docusaurus in a third terminal:
|
||||
|
||||
- `pnpm dev`: Start the development server.
|
||||
- `pnpm cli`: Run cli commands.
|
||||
- `pnpm db:migration:sqlite:run`: Run the migrations to create or update the sqlite db file.
|
||||
- `pnpm db:migration:sqlite:generate`: Generate a new migration file for the sqlite db. Also works with `mysql`.
|
||||
- `pnpm db:studio`: Open a visual studio for the database.
|
||||
- `pnpm package:new`: Create a new package in the monorepo.
|
||||
```sh
|
||||
WORKSHOP_API_URL=http://127.0.0.1:8090 pnpm dev:docs
|
||||
```
|
||||
|
||||
#### Checking code quality
|
||||
PocketBase runs on port 8090 and Docusaurus on port 3003. Stopping the services preserves the PocketBase volume.
|
||||
|
||||
- `pnpm lint`: Run the linter to check for code quality issues.
|
||||
- `pnpm format:fix`: Run the formatter to fix code style issues.
|
||||
- `pnpm test`: Run the tests. You can also use `pnpm test:ui` to get a UI for the tests.
|
||||
- `pnpm typecheck`: Run the typescript type checker.
|
||||
Open `http://127.0.0.1:8090/_/` and create the first PocketBase superuser. The Workshop UI is available at
|
||||
`http://127.0.0.1:3003/workshop`.
|
||||
|
||||
#### Building the project
|
||||
Point Homarr at the local services with:
|
||||
|
||||
- `pnpm build`: Build the project for production.
|
||||
- `docker build -t homarr .`: Build a docker image for the project.
|
||||
- `docker run -p 7575:7575 -e SECRET_ENCRYPTION_KEY='your_64_character_hex_string' homarr:latest`: Run the created docker image.
|
||||
```dotenv
|
||||
HOMARR_WEBSITE_URL=http://127.0.0.1:3003
|
||||
WORKSHOP_API_URL=http://127.0.0.1:8090
|
||||
WORKSHOP_WEB_URL=http://127.0.0.1:3003/workshop
|
||||
```
|
||||
|
||||
Restart `pnpm dev` after changing `.env`. GitHub OAuth is optional locally. See
|
||||
[`apps/workshop/README.md`](https://github.com/homarr-labs/homarr/blob/HEAD/apps/workshop/README.md) for OAuth,
|
||||
Workshop moderator promotion, logs, tests, backup, restore, stopping, and intentional volume reset.
|
||||
|
||||
### URL testing
|
||||
|
||||
Docusaurus reads `HOMARR_WEBSITE_URL` and `WORKSHOP_API_URL` when it starts or builds. Homarr also reads
|
||||
`WORKSHOP_WEB_URL` when its server starts, so these values do not rely on `NEXT_PUBLIC_*` build variables. Test a
|
||||
separate API origin before release:
|
||||
|
||||
```sh
|
||||
HOMARR_WEBSITE_URL=http://127.0.0.1:3003 \
|
||||
WORKSHOP_API_URL=http://127.0.0.1:8090 \
|
||||
WORKSHOP_WEB_URL=http://127.0.0.1:3003/workshop \
|
||||
pnpm dev:docs
|
||||
```
|
||||
|
||||
### Useful commands
|
||||
|
||||
- `pnpm dev` — Next.js on port 3000.
|
||||
- `pnpm dev:benchmark` — authenticated-board benchmark; readiness-only `--smoke` runs are not performance claims.
|
||||
- `pnpm dev:cli -- dev` — browse and run local images and remote pull-request images.
|
||||
- `pnpm dev:cli -- build <name>` — build the current checkout as `homarr:<name>`.
|
||||
- `pnpm dev:cli -- build --pr <number>` — build a pull request from a temporary checkout.
|
||||
- `pnpm dev:cli -- rebuild <name>` — rebuild an image from its recorded checkout or pull request.
|
||||
- `pnpm dev:cli:install` — optionally install the developer CLI as a `homarr` binary.
|
||||
- `pnpm dev:docs` — Docusaurus only on port 3003.
|
||||
- `pnpm docker:workshop` — local PocketBase through Docker Compose.
|
||||
- `pnpm docker:dev:up` — Redis development service in the background.
|
||||
- `pnpm cli` — Homarr operations CLI.
|
||||
- `pnpm db:migration:sqlite:run` — create or update a SQLite database.
|
||||
- `pnpm db:seed` — seed the default database data explicitly.
|
||||
- `pnpm db:studio` — inspect the database.
|
||||
- `pnpm format` / `pnpm format:fix` — check or apply oxfmt.
|
||||
- `pnpm lint` / `pnpm lint:fix` — check or apply oxlint.
|
||||
- `pnpm typecheck` — typecheck all workspaces.
|
||||
- `pnpm test` — unit and contract suites.
|
||||
- `pnpm test:workshop` — disposable Workshop integration suite.
|
||||
- `pnpm build` — production monorepo build.
|
||||
- `docker build -t homarr .` — build a production Docker image.
|
||||
- `docker run -p 7575:7575 -e SECRET_ENCRYPTION_KEY='your_64_character_hex_string' homarr:<name>` — run it.
|
||||
@@ -25,13 +25,17 @@ This is especially useful for [docker secrets](https://docs.docker.com/engine/sw
|
||||
Using the `PUID` and `PGID` will require you to set the correct permissions on the mounted volumes and if used the docker socket. See more in the [Running as a different user](/docs/advanced/running-as-different-user) documentation.
|
||||
:::
|
||||
|
||||
| Environment Variable | Description | Possible values | Default |
|
||||
| ------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------- | ------- |
|
||||
| `PUID` | User ID to run the container as | Any valid user ID | `0` |
|
||||
| `PGID` | Group ID to run the container as | Any valid group ID | `0` |
|
||||
| `LOG_LEVEL` | Log level to use | `debug` / `info` / `warn` / `error` | `info` |
|
||||
| `NO_EXTERNAL_CONNECTION` | Disables some requests that need internet connection | `true` or `false` | `false` |
|
||||
| `ENABLE_DNS_CACHING` | Enables dns caching. Enabled by default, but if you experience issues with IPv6 or static IPs, see [#4006](https://github.com/homarr-labs/homarr/issues/4006) or disable this flag | `true` or `false` | `true` |
|
||||
| Environment Variable | Description | Possible values | Default |
|
||||
| ------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------- | ----------------------------- |
|
||||
| `PUID` | User ID to run the container as | Any valid user ID | `0` |
|
||||
| `PGID` | Group ID to run the container as | Any valid group ID | `0` |
|
||||
| `LOG_LEVEL` | Log level to use | `debug` / `info` / `warn` / `error` | `info` |
|
||||
| `NO_EXTERNAL_CONNECTION` | Disables Homarr's internet-backed requests, including update checks, weather, and RSS feeds | `true` or `false` | `false` |
|
||||
| `ENABLE_DNS_CACHING` | Enables dns caching. Enabled by default, but if you experience issues with IPv6 or static IPs, see [#4006](https://github.com/homarr-labs/homarr/issues/4006) or disable this flag | `true` or `false` | `true` |
|
||||
| `HOMARR_DISABLE_IPV6` | Disables the IPv6 listener of the internal nginx proxy, even when the host supports IPv6. IPv6 is normally disabled automatically when the host has no IPv6 configured. See [#4596](https://github.com/homarr-labs/homarr/issues/4596) | `true` or `false` | `false` |
|
||||
| `HOMARR_WEBSITE_URL` | Main Homarr documentation/site base used by runtime links. Must be an absolute HTTP(S) URL. | HTTP(S) URL | `https://homarr.dev` |
|
||||
| `WORKSHOP_API_URL` | PocketBase API used by Homarr and standalone documentation. | HTTP(S) URL | `HOMARR_WEBSITE_URL` |
|
||||
| `WORKSHOP_WEB_URL` | Public Workshop page opened from Homarr. | HTTP(S) URL | `HOMARR_WEBSITE_URL/workshop` |
|
||||
|
||||
## Authentication
|
||||
|
||||
@@ -51,11 +55,11 @@ A random secret can be generated by using the following command: `openssl rand -
|
||||
|
||||
## Docker
|
||||
|
||||
| Environment Variable | Description | Possible values | Default |
|
||||
| --------------------- | ----------------------------------------------- | -------------------------------------------------------------------- | ------- |
|
||||
| `DOCKER_HOSTNAMES` | Comma seperated list of hostnames to connect to | For example `localhost,docker.example.com` | - |
|
||||
| `DOCKER_PORTS` | Comma seperated list of ports to connect to | For example `2375,2376` | - |
|
||||
| `DOCKER_SOCKET_PATHS` | Comma seperated list of paths to sockets | For example `/var/run/docker.sock,/run/user/1000/podman/podman.sock` | - |
|
||||
| Environment Variable | Description | Possible values | Default |
|
||||
| --------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------- | ------- |
|
||||
| `DOCKER_HOSTNAMES` | Comma-separated list of hostnames to connect to | For example `localhost,docker.example.com` | - |
|
||||
| `DOCKER_PORTS` | Comma-separated list of ports to connect to | For example `2375,2376` | - |
|
||||
| `DOCKER_SOCKET_PATHS` | Comma-separated list of mounted Docker-compatible socket paths. See the [Docker and Podman integration guide](/docs/integrations/docker) | For example `/var/run/docker.sock,/var/run/podman.sock` | - |
|
||||
|
||||
## Database
|
||||
|
||||
@@ -105,7 +109,12 @@ External redis is currently not supported with Proxmox Community Scripts
|
||||
The advanced deployments environment variables should only be used if you know what you are doing.
|
||||
:::
|
||||
|
||||
| Environment Variable | Description |
|
||||
| ------------------------ | --------------------------------------------------------------------------------------------------------------- |
|
||||
| `DB_MIGRATIONS_DISABLED` | Disable db migrations. For example for helm charts |
|
||||
| `WORKSHOP_API_URL` | Runtime public origin of the separate Workshop PocketBase service. Homarr uses `https://homarr.dev` by default. |
|
||||
| Environment Variable | Description |
|
||||
| ------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `DB_MIGRATIONS_DISABLED` | Disable db migrations. For example for helm charts. |
|
||||
| `WORKSHOP_PUBLIC_ORIGIN` | Origin used in PocketBase-generated links and email. It must be set explicitly in production and must match the externally reachable Workshop origin. |
|
||||
| `PB_ALLOWED_ORIGINS` | Browser origins accepted by PocketBase. Use `*` for the central public community so arbitrary self-hosted Homarr instances can use the API and OAuth; private staging may use a comma-separated origin allowlist. |
|
||||
|
||||
All public URL variables reject non-HTTP protocols, embedded credentials, query strings, and fragments, and normalize
|
||||
trailing slashes when Homarr or Docusaurus starts. The Docusaurus-only `WORKSHOP_URL` name is accepted as a deprecated alias for
|
||||
`WORKSHOP_API_URL` for one release. `WORKSHOP_API_URL` takes precedence.
|
||||
@@ -12,7 +12,7 @@ tags:
|
||||
|
||||
Icons in Homarr are automatically requested from multiple sources:
|
||||
|
||||
- [Dashboard Icons](https://dashboardicons.com/) - Our recommended source with over 1888+ curated, high-quality icons
|
||||
- [Dashboard Icons](https://dashboardicons.com/) - Our recommended source with more than 1,888 curated, high-quality icons
|
||||
- [selfh.st icons](https://github.com/selfhst/icons)
|
||||
- [Simple icons](https://github.com/simple-icons/simple-icons)
|
||||
- [Tabler icons](https://tabler-icons.io/)
|
||||
@@ -21,7 +21,7 @@ Icons in Homarr are automatically requested from multiple sources:
|
||||
- **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
|
||||
|
||||
@@ -31,7 +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.
|
||||
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.
|
||||
@@ -49,7 +49,6 @@ services:
|
||||
traefik.http.routers.homarr.rule: Host(`your.internal.dns.address.here.com`)
|
||||
traefik.http.routers.homarr.entrypoints: websecure
|
||||
traefik.http.routers.homarr-secure.app: homarr
|
||||
traefik.http.apps.homarr.loadbalancer.server.port: 7575
|
||||
|
||||
networks:
|
||||
proxy:
|
||||
|
||||
@@ -128,6 +128,8 @@ Homarr supports multiple authentication options, from internal userbase (credent
|
||||
|
||||
If you'd rather manage group memberships for OIDC users locally (instead of via the IdP), set `AUTH_OIDC_GROUPS_LOCAL_MANAGEMENT=true`. While enabled, Homarr stops syncing OIDC users' group memberships from the groups claim, and admins can add/remove OIDC users on a group's members page. If you turn this back to `false`, the next login of each user resumes the regular sync and reconciles their groups with the IdP's claim again, which may remove memberships that were added manually while local management was enabled.
|
||||
|
||||
To let an existing credentials user also sign in with OIDC, set `AUTH_OIDC_ENABLE_DANGEROUS_CREDENTIALS_LINKING=true`. On the first OIDC sign-in, Homarr links a credentials user with the same email address instead of creating a second user. The user keeps their password login, local username and profile, groups, permissions, boards, settings, and API keys. OIDC profile and group claims do not overwrite the credentials-owned data. Homarr requires the OIDC profile to contain `email_verified: true` while this option is enabled. Only enable this when your OIDC provider verifies email addresses, because a matching email could otherwise be used to take over an existing account.
|
||||
|
||||
<details>
|
||||
<summary>
|
||||
Example Setup
|
||||
@@ -205,6 +207,7 @@ Homarr supports multiple authentication options, from internal userbase (credent
|
||||
| ``AUTH_OIDC_NAME_ATTRIBUTE_OVERWRITE`` | Overwrite name attribute. By default it will use preferred_username if it does not contain a `@` and otherwise name. | --- |
|
||||
| ``AUTH_OIDC_FORCE_USERINFO`` | Force userinfo endpoint to be used for user information. | false |
|
||||
| ``AUTH_OIDC_ENABLE_DANGEROUS_ACCOUNT_LINKING`` | Enable account linking for OIDC provider. This will link the OIDC accounts by email. Be sure that you have verified emails to prevent stealing accounts. | false |
|
||||
| ``AUTH_OIDC_ENABLE_DANGEROUS_CREDENTIALS_LINKING`` | Link an OIDC identity to an existing credentials user with the same email. The credentials login and existing user data are preserved. Requires the OIDC profile to contain `email_verified: true`. | false |
|
||||
| ``AUTH_OIDC_TOKEN_ENDPOINT_AUTH_METHOD`` | Override method for the token endpoint authentication. Supported values are `client_secret_basic`, `client_secret_post`, `client_secret_jwt` and `none`. | client_secret_basic |
|
||||
|
||||
### Permission System
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -263,7 +263,7 @@ httproute:
|
||||
value: /
|
||||
backendRefs:
|
||||
- name: homarr
|
||||
port: 8080
|
||||
port: 7575
|
||||
```
|
||||
|
||||
</details>
|
||||
@@ -427,17 +427,17 @@ All available values are listed on the [artifacthub](https://artifacthub.io/pack
|
||||
| envSecrets.dbEncryption.key | string | `"db-encryption-key"` | Secret key for SECRET_ENCRYPTION_KEY can be generated with `openssl rand -hex 32` |
|
||||
| fullnameOverride | string | `""` | Overrides chart's fullname |
|
||||
| hostAliases | list | `[]` | Add static entries to /etc/hosts in the Pod. This is useful in the following cases: - You are running in a dual-stack cluster (IPv4 + IPv6) and want to force usage of IPv4 for specific hostnames - Your application is having DNS resolution issues or IPv6 preference issues - You need to override or simulate DNS entries without changing global DNS - You are running in an air-gapped or isolated environment without external DNS Example: hostAliases: - ip: "192.168.1.10" hostnames: - "example.com" - "example.internal" |
|
||||
| httproute | object | `{"enabled":false,"hostnames":["chart-example.local"],"parentRefs":[{"name":"my-gateway","namespace":"default"}],"rules":[{"backendRefs":[{"name":"homarr","port":8080}],"filters":[],"matches":[{"path":{"type":"PathPrefix","value":"/"}}]}]}` | Gateway API HTTPRoute configuration |
|
||||
| httproute | object | `{"enabled":false,"hostnames":["chart-example.local"],"parentRefs":[{"name":"my-gateway","namespace":"default"}],"rules":[{"backendRefs":[{"name":"homarr","port":7575}],"filters":[],"matches":[{"path":{"type":"PathPrefix","value":"/"}}]}]}` | Gateway API HTTPRoute configuration |
|
||||
| httproute.enabled | bool | `false` | Enable HTTPRoute |
|
||||
| httproute.hostnames | list | `["chart-example.local"]` | Hostnames this route matches (similar to ingress.hosts.host) |
|
||||
| httproute.parentRefs | list | `[{"name":"my-gateway","namespace":"default"}]` | References to the parent Gateway(s) this route attaches to. Each item must include at least a `name`, and optionally a `namespace`. |
|
||||
| httproute.rules | list | `[{"backendRefs":[{"name":"homarr","port":8080}],"filters":[],"matches":[{"path":{"type":"PathPrefix","value":"/"}}]}]` | List of routing rules. Each rule can include: - matches: path/header/query matching - filters: optional transformations (redirects, header modifications, etc.) - backendRefs: one or more Kubernetes Services to forward traffic to |
|
||||
| httproute.rules | list | `[{"backendRefs":[{"name":"homarr","port":7575}],"filters":[],"matches":[{"path":{"type":"PathPrefix","value":"/"}}]}]` | List of routing rules. Each rule can include: - matches: path/header/query matching - filters: optional transformations (redirects, header modifications, etc.) - backendRefs: one or more Kubernetes Services to forward traffic to |
|
||||
| httproute.rules[0].filters | list | `[]` | Optional filters for this rule (default: empty) |
|
||||
| httproute.rules[0].matches[0].path.type | string | `"PathPrefix"` | Path match type. One of: Exact, PathPrefix, RegularExpression |
|
||||
| httproute.rules[0].matches[0].path.value | string | `"/"` | Path value to match |
|
||||
| image.pullPolicy | string | `"IfNotPresent"` | Image pull policy |
|
||||
| image.repository | string | `"ghcr.io/homarr-labs/homarr"` | Image repository |
|
||||
| image.tag | string | `"v1.70.0"` | Overrides the image tag whose default is the chart appVersion |
|
||||
| image.tag | string | `"v1.74.0"` | Overrides the image tag whose default is the chart appVersion |
|
||||
| imagePullSecrets | list | `[]` | Secrets for Docker registry |
|
||||
| ingress.annotations | object | `{}` | Ingress annotations |
|
||||
| ingress.enabled | bool | `false` | Enable ingress |
|
||||
|
||||
@@ -54,6 +54,74 @@ services:
|
||||
With docker run, add this parameter: `-v /var/run/docker.sock:/var/run/docker.sock`.
|
||||
In Windows, you'll need to adjust the slashes: `-v //var/run/docker.sock:/var/run/docker.sock`
|
||||
|
||||
### Using Podman
|
||||
|
||||
On Linux, Homarr can connect to Podman's [Docker-compatible API](https://docs.podman.io/en/latest/markdown/podman-system-service.1.html).
|
||||
The Podman API socket must be running and mounted into the Homarr container.
|
||||
The socket grants full access to Podman, including arbitrary code execution as the user running the API, so only mount it into trusted containers.
|
||||
|
||||
For rootless Podman, enable the socket for the user that runs Homarr:
|
||||
|
||||
```bash
|
||||
systemctl --user enable --now podman.socket
|
||||
```
|
||||
|
||||
The rootless socket is located at `$XDG_RUNTIME_DIR/podman/podman.sock` (usually `/run/user/<uid>/podman/podman.sock`).
|
||||
You can confirm the path with:
|
||||
|
||||
```bash
|
||||
podman info --format '{{.Host.RemoteSocket.Path}}'
|
||||
```
|
||||
|
||||
Mount that socket into the Homarr container and point `DOCKER_SOCKET_PATHS` to its path **inside** the container:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
homarr:
|
||||
container_name: homarr
|
||||
image: ghcr.io/homarr-labs/homarr:latest
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- ${XDG_RUNTIME_DIR}/podman/podman.sock:/var/run/podman.sock
|
||||
- ./homarr/appdata:/appdata
|
||||
environment:
|
||||
SECRET_ENCRYPTION_KEY: your_64_character_hex_string
|
||||
DOCKER_SOCKET_PATHS: /var/run/podman.sock
|
||||
ports:
|
||||
- "7575:7575"
|
||||
```
|
||||
|
||||
Start Homarr with `podman compose up -d`.
|
||||
Run the compose project as the same user that owns the rootless Podman socket so `$XDG_RUNTIME_DIR` resolves to the correct path.
|
||||
To keep the user socket available after logout and across reboots, enable lingering with `loginctl enable-linger <user>`.
|
||||
|
||||
For rootful Podman, enable the system socket with `sudo systemctl enable --now podman.socket`.
|
||||
Its default path is `/run/podman/podman.sock`; mount that path instead and start Homarr with `sudo podman compose up -d`.
|
||||
|
||||
:::note SELinux
|
||||
On systems with SELinux enabled, Podman's documentation recommends disabling label separation for a container that accesses the API socket.
|
||||
Add the following to the Homarr service:
|
||||
|
||||
```yaml
|
||||
security_opt:
|
||||
- label=disable
|
||||
```
|
||||
|
||||
:::
|
||||
|
||||
You can connect Homarr to multiple Docker-compatible sockets by mounting each socket and listing their in-container paths, separated by commas:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
homarr:
|
||||
image: ghcr.io/homarr-labs/homarr:latest
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
- ${XDG_RUNTIME_DIR}/podman/podman.sock:/var/run/podman.sock
|
||||
environment:
|
||||
DOCKER_SOCKET_PATHS: /var/run/docker.sock,/var/run/podman.sock
|
||||
```
|
||||
|
||||
### Managing your Docker containers within Homarr
|
||||
|
||||
Homarr allows you to interact with Docker containers running on your system.
|
||||
|
||||
@@ -43,12 +43,11 @@ import { immichServerStatsWidget } from "../../widgets/immich-server-stats";
|
||||
<code>user.read</code>,{" "}
|
||||
<code>user.update</code>,{" "}
|
||||
<code>asset.read</code>,{" "}
|
||||
<code>asset.view</code>,{" "}
|
||||
<code>asset.download</code>,{" "}
|
||||
<code>asset.statistics</code>,{" "}
|
||||
<code>server.statistics</code>,{" "}
|
||||
<code>server.about</code>.
|
||||
<br />
|
||||
Additional permissions may be required depending on your Immich version. If you encounter authorization errors, grant any missing permissions requested by the server.
|
||||
|
||||
</span>
|
||||
|
||||
|
||||
@@ -10,14 +10,15 @@ import { AddingIntegration } from "@site/src/components/integrations/adding";
|
||||
import { IntegrationSecrets } from "@site/src/components/integrations/secrets";
|
||||
import { navidromeIntegration } from ".";
|
||||
import { audioStatsWidget } from "@site/docs/widgets/audio-stats";
|
||||
import { mediaServerWidget } from "@site/docs/widgets/media-server";
|
||||
|
||||
<IntegrationHeader integration={navidromeIntegration} categories={["Media"]} />
|
||||
<IntegrationHeader integration={navidromeIntegration} categories={["Media", "Media server"]} />
|
||||
|
||||
Navidrome provides music library statistics including artist, album, and song counts, as well as now playing information via the Subsonic API.
|
||||
Navidrome provides music library statistics including artist, album, and song counts, as well as now playing audio streams via the Subsonic API.
|
||||
|
||||
### Widgets & Capabilities
|
||||
|
||||
<IntegrationCapabilites items={[{ widget: audioStatsWidget }]} />
|
||||
<IntegrationCapabilites items={[{ widget: audioStatsWidget }, { widget: mediaServerWidget }]} />
|
||||
|
||||
### Adding the integration
|
||||
|
||||
|
||||
@@ -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.
|
||||
It can be a url to an image in the internet, one from the included icon repositories or a local file uploaded through the upload button.
|
||||
You can paste a direct HTTP(S) URL to an image, search the included icon repositories by name, or upload a local file through the upload button. 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.
|
||||
|
||||

|
||||
|
||||
@@ -40,11 +40,14 @@ An optional description that can be shown as tooltip on the app widget.
|
||||
|
||||
The URL to the website you want to link to. Clicking on the app widget or bookmark will open this URL.
|
||||
|
||||
You can also use custom URI schemes such as `vscode://file/path`, `microsoft-edge:https://example.com`, or `myapp://open` to link to local applications or third-party programs. We trust that you know what you are doing — only `javascript:` URIs are blocked for security reasons.
|
||||
|
||||
### Ping URL
|
||||
|
||||
When you want to use another url for the simple status check you can set it here.
|
||||
This will allow you to set a different URL for the ping than the URL that is opened when clicking on the app widget.
|
||||
It's useful when you want to ping the service internally for example with a custom ip but open the website externally.
|
||||
Note that the ping URL only accepts `http://` and `https://` URLs — custom URI schemes are not supported for pings.
|
||||
|
||||
## List of apps
|
||||
|
||||
|
||||
@@ -36,13 +36,13 @@ Underscores and hyphen-minus are allowed.
|
||||
|
||||
### Column count
|
||||
|
||||
Homarr has a complex drag and drop system that can be used from the browser.
|
||||
Homarr has a complex drag-and-drop system that can be used from the browser.
|
||||
The column count defines how many horizontal columns are available to drag and drop elements to.
|
||||
|
||||
### Public
|
||||
|
||||
This will enable public access for everyone - regardless of whether they are logged in or not.
|
||||
This can be really useful if you want to have public boards in your company or for family and fiends.
|
||||
This can be really useful if you want to have public boards in your company or for family and friends.
|
||||
Modifications for public boards must be allowed explicitly using user groups and their permissions.
|
||||
|
||||
## Board settings
|
||||
@@ -55,6 +55,8 @@ Alternatively, you can also navigate from the board page itself using the cog ic
|
||||
|
||||

|
||||
|
||||
All board settings are edited on a single page. Click the **Save changes** button in the save bar at the bottom to apply them, or **Discard** to restore the last saved values. The save bar only appears when you have unsaved changes. Access control and the Danger zone are independent sections and are applied immediately.
|
||||
|
||||
### General
|
||||
|
||||
#### Page title
|
||||
@@ -105,7 +107,7 @@ Amount of columns for this layout. Defines how many items can be placed in a row
|
||||
#### Breakpoint
|
||||
|
||||
Minimum width of the screen where the layout will be used.
|
||||
Once the minmum of the next layout is reached the layout will change.
|
||||
Once the minimum of the next layout is reached the layout will change.
|
||||
|
||||
#### Preview
|
||||
|
||||
@@ -219,7 +221,7 @@ This action is irreversible and cannot be undone.
|
||||
The deletion of boards is permanent and cannot be undone.
|
||||
There are two ways to delete a board:
|
||||
|
||||
- Navigate to the settings of your board, open the "Danger zone" dropdown and click on the "delete this board" button.
|
||||
- Navigate to the settings of your board, open the "Danger zone" section and click on the "delete this board" button.
|
||||
- Navigate to the list of boards in the management pages, click the three dots and click on "delete permanently".
|
||||
|
||||
Both actions will execute the same deletion process.
|
||||
|
||||
@@ -4,6 +4,9 @@ An external agent creates one widget at a time with the Homarr MCP server. The a
|
||||
|
||||
Homarr does not create widget packs or drafts for multiple widgets.
|
||||
|
||||
The MCP API key must belong to a Homarr administrator. Non-administrator keys do not list Custom Widget tools, prompts,
|
||||
or resources. The same administrator requirement applies to the matching HTTP authoring endpoints.
|
||||
|
||||
<a className="button button--primary" href="https://www.skills.sh/homarr-labs/homarr/homarr-custom-widget">
|
||||
View the Custom Widget Skill on skills.sh
|
||||
</a>
|
||||
@@ -67,7 +70,10 @@ These interfaces do not return an archive URL or archive hash.
|
||||
12. Update the saved definition if the final verification finds a problem.
|
||||
13. Repeat the procedure for the next widget.
|
||||
|
||||
Each source-configuration URL applies to one definition or preview and one source. The user confirms the server URL, network scope, and required credential fields directly in Homarr. The agent receives only request status and never receives plaintext credentials. Preview-scoped values expire with that preview; definition-scoped values persist.
|
||||
Each source-configuration URL applies to one definition or preview and one source. It is short-lived, single-use, and
|
||||
can only be opened and completed by an authenticated Homarr administrator. The administrator confirms the server URL,
|
||||
network scope, and required credential fields directly in Homarr. The agent receives only request status and never
|
||||
receives plaintext credentials. Preview-scoped values expire with that preview; definition-scoped values persist.
|
||||
|
||||
Agents can install existing widgets without regenerating them:
|
||||
|
||||
@@ -88,8 +94,11 @@ Homarr maintainers can run the same self-contained prompt that the workbench cop
|
||||
OPENROUTER_API_KEY=... pnpm --filter @homarr/custom-widgets test:ai
|
||||
```
|
||||
|
||||
The suite uses DeepSeek V4 Pro to author five complex widgets and DeepSeek V4 Flash to judge only widgets that pass Homarr's real parser and canonical schema. It saves every response, validated `widget.json`, `widget.jsx`, and detailed category scores under `packages/custom-widgets/.ai-evaluations/`.
|
||||
The suite uses DeepSeek V4 Pro to author Pokédex, Portainer, football, Jellyfin, and Home Assistant widgets. Before DeepSeek V4 Flash judges a result, it must pass Homarr's real clipboard parser, canonical schema, and deterministic scenario checks for the documented API routes, authentication, bindings, actions, and essential runtime components. It saves every response, validated widget manifest, and detailed category scores under `packages/custom-widgets/.ai-evaluations/`.
|
||||
|
||||
The run has a hard budget of ten generation attempts across all scenarios. Override the models or budget with `OPENROUTER_GENERATOR_MODEL`, `OPENROUTER_JUDGE_MODEL`, and `CUSTOM_WIDGET_AI_MAX_LOOPS`. Generated artifacts are local and ignored by Git; promote a reviewed, validated result into the bundled-widget source deliberately.
|
||||
Each scenario stops as soon as it passes and has a hard limit of ten generation attempts. Override the models or
|
||||
per-scenario limit with `OPENROUTER_GENERATOR_MODEL`, `OPENROUTER_JUDGE_MODEL`, and
|
||||
`CUSTOM_WIDGET_AI_MAX_LOOPS`. Generated artifacts are local and ignored by Git; promote a reviewed, validated result
|
||||
into the bundled-widget source deliberately.
|
||||
|
||||
The reference API check uses a real PokeAPI list/detail request and a read-only Portainer container query when `PORTAINER_BASE_URL` and `PORTAINER_API_KEY` are set. Use `pnpm --filter @homarr/custom-widgets test:reference-apis -- --pokeapi-only` for the public PokeAPI check alone. To exercise start, restart, and stop against a disposable container, also set `PORTAINER_TEST_CONTAINER_ID` and the explicit safeguard `PORTAINER_ALLOW_MUTATIONS=1`; the script restores the container's original running state.
|
||||
@@ -25,13 +25,14 @@ Every option has a default. Supported controls include text, textarea, number, s
|
||||
|
||||
## ChatGPT or Claude
|
||||
|
||||
Enter the desired widget and optional API documentation URL, then select **Copy AI prompt**. The single self-contained prompt is below 12,000 characters, starts with your request, includes the lean contract and two polished examples, and never claims that an offline chat has MCP tools.
|
||||
Open **Create or fix with any AI**, enter the desired widget and optional API documentation URL, then select the copy action beside the accordion title. The single self-contained prompt is below 12,000 characters, starts with your request, includes the lean contract and two polished examples, and never claims that an offline chat has MCP tools.
|
||||
|
||||
The model must return exactly:
|
||||
The model must return one fenced `json` block containing the complete widget, including the JSX in its `template`
|
||||
field. Copy that single block and select **Paste**. Homarr fills the visual builders and the JSX editor
|
||||
without any placeholder replacement or second paste.
|
||||
|
||||
1. One fenced `json` block with `template: "__HOMARR_TEMPLATE__"`.
|
||||
2. One fenced `jsx` block with the readable template.
|
||||
|
||||
Select **Paste AI response**. Homarr fills the visual builders and shows all actionable schema or JSX errors inline. Enter credentials separately. The prompt deliberately recommends a focused Mantine component set, visual hierarchy, responsive layout, theme compatibility, and loading, empty, failure, and success states.
|
||||
Homarr shows all actionable schema or JSX errors inline. Enter credentials separately. The prompt deliberately
|
||||
recommends a focused Mantine component set, visual hierarchy, responsive layout, theme compatibility, and loading,
|
||||
empty, failure, and success states.
|
||||
|
||||
Connected agents should use the skills.sh skill and retrieve the live schema and individual component metadata only as needed.
|
||||
@@ -38,7 +38,8 @@ Bounded regex literals are supported for string matching, splitting, search, and
|
||||
|
||||
- `SubFetch` runs a manual query with its `params`. Set `triggerContent` to a card or image to make that content the accessible click and keyboard trigger instead of showing the default load button.
|
||||
- `ActionButton` and `ToggleSwitch` run actions.
|
||||
- `RefreshButton` renders a compact, accessible icon control that refreshes load queries.
|
||||
- `RefreshButton` renders a compact, accessible icon control that refreshes load queries for authenticated viewers. It
|
||||
is disabled for anonymous public-board viewers because refreshing invalidates shared server cache state.
|
||||
- `Icon`/`TablerIcon` resolves installed safe icons.
|
||||
|
||||
Always include responsive loading, empty, error, and success states. Runtime failures remain inside the widget tile.
|
||||
@@ -49,6 +49,21 @@ interface HomarrCustomWidgetV2 {
|
||||
|
||||
The browser renders JSX. Homarr's server executes named requests, applies board permissions and network protections, and injects credentials. The browser never receives source credentials.
|
||||
|
||||
## Permissions
|
||||
|
||||
Custom Widget management is an administrator-only surface. Only a Homarr administrator can create, import, migrate,
|
||||
preview, publish, install, configure, or write secrets, including through HTTP authoring resources or MCP. An API key
|
||||
only lists Custom Widget MCP tools, prompts, and resources when its owner is an administrator.
|
||||
|
||||
Adding or configuring a Custom Widget placement requires both administrator access and permission to modify that
|
||||
board. A non-administrator with board-modify access can move, resize, change presentation settings, or remove an
|
||||
existing placement, but cannot add one, change its definition, refresh interval, or Custom Widget options. Duplicating
|
||||
a board that contains Custom Widgets also requires an administrator.
|
||||
|
||||
Once an administrator has placed a widget, ordinary board permissions apply to rendering and requests. Authorized
|
||||
non-administrators can use it on private boards, and queries can render for anonymous viewers on public boards.
|
||||
Mutations remain authenticated and enforce each request's view, modify, or full permission.
|
||||
|
||||
## Authoring paths
|
||||
|
||||
- [Create a Custom Widget](./authoring) visually or with one copied AI prompt.
|
||||
@@ -60,20 +75,54 @@ The browser renders JSX. Homarr's server executes named requests, applies board
|
||||
|
||||
The workbench keeps one continuous layout with visual source, request, and option editors. Advanced raw JSON remains available for experts and AI paste. Invalid values stay inline and disable both preview and Save/Create until they are corrected.
|
||||
|
||||
Exports, imports, Workshop, MCP, and storage all validate the same schema. Widgets saved in the legacy format remain visible with a **Migration required** badge after upgrading. Use **Copy migration prompt**, give the redacted prompt to an LLM, then import its v2 JSON from that widget's action menu. Homarr keeps the legacy record until the replacement validates and is confirmed; credentials are never included in the prompt.
|
||||
Exports, imports, the official Workshop client, and MCP validate the same schema. PocketBase stores community
|
||||
submissions without interpreting their content, and Homarr validates them before installation. Widgets saved in the v1
|
||||
format remain visible to administrators with a **Migration required** badge after upgrading. Use **Copy migration
|
||||
prompt**, give the redacted prompt to an LLM, copy its single complete JSON block, then select **Paste migrated widget**
|
||||
from that widget's action menu. Homarr reads the `template` value into the JSX editor automatically. The prompt keeps safe
|
||||
request metadata but deliberately omits the v1 display configuration because legacy free-text and JSX can contain
|
||||
hard-coded credentials. Add a credential-free description of the widget's visible layout and behavior when sending the
|
||||
prompt.
|
||||
|
||||
When adding widgets while editing a board, the Custom Widgets section always includes **Install from Workshop**. The Workshop and API source review open above the widget picker without closing it. After import, the new definition appears in the picker. Its tile waits for the board to be saved before server requests run and loads automatically after a successful save; no page reload is required.
|
||||
Homarr keeps the original v1 definition and encrypted secret as a compatibility backup even after the validated v2
|
||||
replacement is created. The v2 record uses the same stable ID and takes precedence. The archived v1 copy stays hidden
|
||||
while the replacement exists; deleting the v2 record surfaces the v1 entry as migration-required again. Automatic
|
||||
legacy deletion is intentionally unavailable during v2 stabilization. Credentials are never included in prompts,
|
||||
logs, or exports.
|
||||
|
||||
For administrators adding widgets while editing a board, the Custom Widgets section includes **Import from
|
||||
Workshop**. Non-administrators do not see the Custom Widget picker or Workshop actions. Selecting **Install widget**
|
||||
opens a two-step modal flow: first review the Workshop entry and its complete source in the read-only editor, then
|
||||
confirm API sources and credentials. The steps keep the same dimensions and the previous step remains available when
|
||||
you go back. After import, the new definition appears in the picker. Its tile
|
||||
waits for the board to be saved before server requests run and loads automatically after a successful save; no page
|
||||
reload is required.
|
||||
|
||||
## Import and Workshop
|
||||
|
||||
Import review shows origins, authentication modes, network scopes, methods, actions, and permissions. Every source has a suggested URL, but private and loopback sources must be confirmed or replaced for the current Homarr installation. The same screen accepts API keys, tokens, usernames, and passwords; credentials remain optional at install time and missing values stay visible on the Custom Widgets page.
|
||||
The install flow always links to the corresponding public Workshop entry using `WORKSHOP_WEB_URL`. Import confirmation shows origins, authentication modes, network scopes, methods, actions, and permissions. Every source has a suggested URL, but private and loopback sources must be confirmed or replaced for the current Homarr installation. The same screen accepts API keys, tokens, usernames, and passwords; credentials remain optional at install time and missing values stay visible on the Custom Widgets page.
|
||||
|
||||
For example, installing **Tautulli Activity** asks for both the user's Tautulli URL and API key. A stable public source such as PokéAPI keeps its published URL without creating unnecessary setup work.
|
||||
|
||||
Workshop publishes technically valid widgets immediately; users can vote or report them, and administrators can delete submissions. Publishers should replace personal private-network hosts with a clear service placeholder before publishing.
|
||||
Workshop publishes technically valid widgets immediately; users can vote or report them, and Workshop moderators can
|
||||
delete submissions. Community moderation is separate from Homarr administration and is not a security boundary.
|
||||
Publishers should replace personal private-network hosts with a clear service placeholder before publishing. Homarr
|
||||
performs definitive validation again at install time.
|
||||
|
||||
Connected agents can search, inspect, and install Workshop widgets with `customWidget_workshopSearch`, `customWidget_workshopGet`, and `customWidget_workshopInstall`. After installation they can set a source URL and credentials with `customWidget_sourceConfigure`, or create a secure user-facing setup URL with `customWidget_configurationRequestUser`. Secret values are never returned through MCP.
|
||||
|
||||
These tools are only exposed to API keys owned by Homarr administrators. Opening and completing a source-configuration
|
||||
URL also requires the same authenticated administrator; the URL is short-lived, single-use, and redacted.
|
||||
|
||||
## Reliability and rollback
|
||||
|
||||
Custom Widget response caches and action invalidation use shared Redis state, so multiple Homarr replicas see the same
|
||||
versioned cache generation. Deployments must point every replica at the same Redis service.
|
||||
|
||||
Before upgrading, back up the database and `SECRET_ENCRYPTION_KEY`; a v1 binary can still read preserved v1 tables
|
||||
during application rollback. Widgets created only in v2 may be unavailable to the rolled-back binary, but do not
|
||||
replace the v1 backup.
|
||||
|
||||
## Bundled examples
|
||||
|
||||
Homarr seeds five disabled examples without credentials and never overwrites an edited seed. They use the same public contract. The Tautulli example is the highest-scoring widget preserved from Homarr's AI authoring evaluation:
|
||||
|
||||
@@ -12,7 +12,9 @@ Requests are keyed by ID. `source`, `kind`, `method`, `trigger`, `auth`, and `pe
|
||||
|
||||
The request `path` is appended to the source `baseUrl`, including a base path such as `/api/v2`.
|
||||
|
||||
GET, POST, PUT, PATCH, and DELETE work for queries and actions. Kind—not method—controls behavior. Load queries run automatically, manual queries run through `SubFetch`, and actions run only from `ActionButton` or `ToggleSwitch`.
|
||||
GET, POST, PUT, and PATCH work for queries and actions. DELETE is restricted to actions so it can never run while a
|
||||
board loads. Kind controls the remaining behavior: load queries run automatically, manual queries run through
|
||||
`SubFetch`, and actions run only from `ActionButton` or `ToggleSwitch`.
|
||||
|
||||
Bind values directly where they are used:
|
||||
|
||||
@@ -40,3 +42,12 @@ Paths and query-string values must resolve to text, numbers, or booleans. JSON b
|
||||
Every request uses Homarr's existing server executor: same-origin source enforcement, network scope, DNS and redirect validation, SSRF protection, board permissions, rate and request limits, body and response limits, timeouts, and encrypted credential injection. Static headers cannot override credentials, cookies, routing, proxy, forwarding, or hop-by-hop headers.
|
||||
|
||||
Queries default to view permission and actions to modify. DELETE always requires full permission and confirmation. Actions are never cached, retried, or run on load.
|
||||
|
||||
Management and runtime are intentionally separate. A Homarr administrator with board-modify permission places and
|
||||
configures the widget. After placement, query and action authorization follows the request's board permission. Public
|
||||
boards may execute queries for anonymous viewers, but mutations stay authenticated. This includes shared-cache
|
||||
invalidation: `RefreshButton` is disabled for anonymous viewers, while ordinary scheduled query rendering remains
|
||||
available.
|
||||
|
||||
Responses use bounded, versioned shared-Redis cache keys. Action invalidation advances the shared version so every
|
||||
Homarr replica stops serving the previous response without scanning or deleting a key prefix.
|
||||
@@ -4,7 +4,16 @@
|
||||
|
||||
The definition does not exist. If it was deleted, remove the unavailable tile from the board.
|
||||
|
||||
If the Custom Widgets page shows the definition with a **Migration required** badge, open its action menu and select **Copy migration prompt**. Give that redacted prompt to an LLM, then import the resulting v2 JSON from the same menu. Homarr keeps the legacy definition until the replacement validates and you confirm the migration.
|
||||
If the Custom Widgets page shows the definition with a **Migration required** badge, an administrator can open its
|
||||
action menu and select **Copy migration prompt**. Give that redacted prompt to an LLM, then import the resulting v2 JSON
|
||||
from the same menu. The LLM returns one complete JSON block; copy it once and select **Paste migrated widget**. Homarr
|
||||
places its `template` value into the JSX editor automatically. Because arbitrary v1 display text or JSX may contain
|
||||
credentials, the prompt omits that display configuration; describe the old widget's visible layout and behavior without
|
||||
including secrets. Other viewers see **Contact an administrator** instead of a migration link.
|
||||
|
||||
Homarr keeps the v1 definition and encrypted secret after migration. The validated v2 replacement uses the same ID and
|
||||
takes precedence; the archived v1 record reappears if the v2 replacement is deleted. There is no v1 permanent-delete
|
||||
action during the v2 stabilization period.
|
||||
|
||||
Otherwise, install or create a Custom JSX v2 definition.
|
||||
|
||||
@@ -34,6 +43,11 @@ An invalid definition disables widget preview and Save/Create. The Data, Options
|
||||
|
||||
The Diagnostics tab shows the current schema errors.
|
||||
|
||||
## Workshop unavailable
|
||||
|
||||
If Workshop is unavailable, check `WORKSHOP_API_URL` and PocketBase health. Widgets that are already installed continue
|
||||
to work.
|
||||
|
||||
## Correct request errors
|
||||
|
||||
Check the API source network scope. Check the base URL and credentials.
|
||||
@@ -44,6 +58,9 @@ For “invalid placeholder,” use `{option:name}` or `{param:name}`. Load queri
|
||||
|
||||
The journal shows the method, request ID, path template, status, and duration.
|
||||
|
||||
In a multi-instance deployment, all Homarr replicas must share Redis. Versioned cache invalidation is distributed
|
||||
through Redis; replicas connected to different Redis databases can serve stale query responses after an action.
|
||||
|
||||
## Correct a Mantine prop warning
|
||||
|
||||
`UNKNOWN_MANTINE_PROP` is a warning. Homarr passes the prop to Mantine.
|
||||
@@ -57,3 +74,10 @@ Use safe widget options, temporary inputs, or named requests instead.
|
||||
The widget option definitions changed and the saved values are invalid. Open the item settings.
|
||||
|
||||
Correct the marked configuration values. Homarr does not infer widget option migrations.
|
||||
|
||||
## Roll back safely
|
||||
|
||||
Before an application upgrade, back up the database and preserve the exact `SECRET_ENCRYPTION_KEY`. The v2 schema keeps
|
||||
the physical v1 definition and secret tables readable by a v1 binary. Roll back the application without running
|
||||
manual cleanup, verify legacy widgets, and restore the database backup if the migration itself must be reversed.
|
||||
PocketBase Workshop data is a separate backup and restore operation.
|
||||
@@ -92,6 +92,10 @@ MCP supports two authentication methods:
|
||||
|
||||
API key authentication is simpler and recommended for most setups. The API key inherits the permissions of the user who created it. Tools that require higher privileges will return permission errors if the key lacks access.
|
||||
|
||||
Custom Widget and Workshop authoring are different: their tools, prompts, and resources are exposed only when the API
|
||||
key owner is a Homarr administrator. Non-administrator keys cannot discover or invoke those capabilities. Creating an
|
||||
API key never elevates the owner.
|
||||
|
||||
## Testing the Connection
|
||||
|
||||
Verify your MCP endpoint is working:
|
||||
@@ -168,6 +172,9 @@ The tools table on the MCP tab is dynamically generated - it always reflects the
|
||||
|
||||
An agent creates one `homarr-custom-widget-v2` definition at a time. Start with the native prompt `homarr-custom-widget-author`.
|
||||
|
||||
The API key owner must be a Homarr administrator. The same rule applies to management, import, migration, preview,
|
||||
secrets, publication, Workshop installation, and the HTTP authoring resources.
|
||||
|
||||
Then, read the live schema and component resources. These resources describe the installed Mantine components.
|
||||
|
||||
1. Call `customWidget_getAuthoringPrompt` and `customWidget_schema`.
|
||||
@@ -194,7 +201,8 @@ Discovery resources:
|
||||
- `homarr://custom-widgets/examples/{name}`
|
||||
- `homarr://custom-widgets/skill`
|
||||
|
||||
An HTTP-only harness can use the matching `/api/custom-widgets/authoring-prompt`, `/schema`, `/components`, and `/skill` endpoints instead of MCP resource URIs.
|
||||
An authenticated administrator can use the matching `/api/custom-widgets/authoring-prompt`, `/schema`, `/components`,
|
||||
and `/skill` endpoints instead of MCP resource URIs. These endpoints are not public schema downloads.
|
||||
|
||||
The Custom Widget tools are:
|
||||
|
||||
@@ -219,7 +227,13 @@ The Custom Widget tools are:
|
||||
- `customWidget_getAuthoringPrompt`
|
||||
- `customWidget_getSkill`
|
||||
|
||||
The `custom-widget-manage` permission permits authoring. A direct credential write also requires the `custom-widget-secret-write` permission.
|
||||
Custom Widget permissions are not assignable group capabilities. Administrator status is required for the complete
|
||||
authoring surface. A source-configuration URL is short-lived and single-use, requires the same logged-in administrator
|
||||
to open and complete it, and never returns plaintext credentials to the agent.
|
||||
|
||||
Adding a `customApi` item through `board_addItem`, changing its definition or options, and duplicating a board that
|
||||
contains one also require an administrator with board-modify access. Existing placements continue to render and run
|
||||
authorized requests according to normal board permissions.
|
||||
|
||||
Homarr applies its request security controls during request execution. These controls include SSRF, DNS, redirect, size, timeout, rate, and permission controls.
|
||||
|
||||
|
||||
@@ -10,109 +10,126 @@ tags:
|
||||
|
||||
# Settings
|
||||
|
||||
The settings page has a few options that will be applied to the entire Homarr application.
|
||||
They may require a full page reload for changes to apply.
|
||||
The settings page applies options to the whole Homarr application.
|
||||
Some changes need a full page reload before they take effect.
|
||||
|
||||
All changes are collected in a single form. Click the **Save changes** button in the save bar at the bottom of the page to apply them. The save bar only appears when you have unsaved changes, and the **Discard** button reverts your changes.
|
||||
|
||||
## Analytics
|
||||
|
||||
By default, Homarr is collecting anonymous analytics to identify issues and prioritize bugfixes and improvements.
|
||||
The data is never shared with any third-parties and is fully GDPR & CCPA compliant.
|
||||
The software being used is [Umami Analytics](https://umami.is/) which is fully open source.
|
||||
The main server software can be found [here](https://github.com/umami-software/umami) and [this node library](https://github.com/umami-software/node) to perform server side calls to the API.
|
||||
Homarr uses [PostHog](https://posthog.com/) to collect anonymous usage data.
|
||||
We collect this data to see what features people ACTUALLY use.
|
||||
We use this data to decide where we spend our development time.
|
||||
|
||||
A full list of the collected data by default can be found here: https://umami.is/docs/metric-definitions.
|
||||
We do not sell the data. We do not share the data with third parties.
|
||||
The data does not contain personal information and does not identify you or your installation.
|
||||
|
||||
We understand that you might not want Homarr to communicate with our services.
|
||||
By unchecking the `Send anonymous analytics` checkbox and reloading the full page, all communication by Umami will be disabled and no data will be collected.
|
||||
If you are paranoid, you can permanently block `umami.homarr.dev` from your DNS / firewall.
|
||||
Homarr sends the data to our own PostHog server at `https://hog.homarr.dev`.
|
||||
The server sends the data once a week and when the server starts.
|
||||
|
||||
Additionally, you opt in to more data collection to help us provide more useful data.
|
||||
### What we collect
|
||||
|
||||
### Integration data
|
||||
Homarr collects technical data about your installation:
|
||||
|
||||
Enabling this optional data will send the types of integrations (e.g. SABnzbd, Sonarr, ...) and how many you have configured of them.
|
||||
This will not include any URLs, names, secrets, errors and others. This is disabled by default.
|
||||
- Your Homarr version
|
||||
- Your database type (SQLite, MySQL, or PostgreSQL)
|
||||
- Whether Docker or Kubernetes is enabled
|
||||
- Your enabled authentication providers
|
||||
- Your operating system and CPU architecture
|
||||
- Your default language
|
||||
- The number of boards, apps, sections, layouts, users, groups, search engines, icon repositories, cron jobs, API keys, invites, sessions, and accounts
|
||||
- The integration types you use and how many you have of each (for example, Sonarr or SABnzbd)
|
||||
- The widget types you use and how many you have of each
|
||||
|
||||
### Widget data
|
||||
Homarr does NOT collect:
|
||||
|
||||
Enabling this optional data will send the types of widgets (e.g. downloads, media streams, iframe, ...) and how many you have configured of them.
|
||||
This will not include any URLs, names, secrets, errors and others. This is disabled by default.
|
||||
- URLs
|
||||
- Names
|
||||
- Usernames, emails, or passwords
|
||||
- Integration secrets or API keys
|
||||
- Error messages or logs
|
||||
- Any data that can identify you
|
||||
|
||||
### Users data
|
||||
Each installation gets a random ID. We use this ID only to count unique installations. We cannot use this ID to identify you.
|
||||
|
||||
Enabling this optional data will send the count of users and whether you activated SSO.
|
||||
This will not include any usernames, passwords, emails, URLs, authentication flows or client IDs.
|
||||
This is disabled by default.
|
||||
Homarr does not use cookies for analytics. Homarr does not track page views automatically. Homarr only sends the data listed above.
|
||||
|
||||
### Disable analytics
|
||||
|
||||
Analytics are enabled by default. To disable analytics:
|
||||
|
||||
1. Open the Settings page.
|
||||
2. Uncheck the "Help us improve Homarr" checkbox.
|
||||
3. Reload the full page.
|
||||
|
||||
When analytics are disabled, Homarr does not send any data to PostHog.
|
||||
|
||||
You can also set the `NO_EXTERNAL_CONNECTION` environment variable to `true`. This stops all external requests, including analytics. See the [environment variables](/docs/advanced/environment-variables) page for more information.
|
||||
|
||||
You can block `hog.homarr.dev` in your DNS or firewall. Homarr cannot send data when this address is blocked.
|
||||
|
||||
## Search Engine Optimization (SEO)
|
||||
|
||||
Big companies [like Google, Microsoft, Baidu and Yandex](https://de.wikipedia.org/wiki/Web-Index) will use [web crawlers](https://de.wikipedia.org/wiki/Webcrawler) to index the Internet for new websites & pages.
|
||||
This is desirable in many cases (for example when selling products in an online shop) but can lead to unwanted traffic and attention to your personal dashboard.
|
||||
Homarr is requesting the crawlers to not index Homarr instances by default for this reason.
|
||||
If you use Homarr as a public website and want it to be searchable on these search engines, you can disable these settings.
|
||||
Please note that Homarr doesn't have control over what the crawlers do; it is a mere request to them to not perform these actions but will not guarantee that your website doesn't land on some index.
|
||||
Search engines use web crawlers to index websites. This can cause unwanted traffic to your dashboard.
|
||||
Homarr asks crawlers to not index your instance by default. Use these settings to change this request.
|
||||
|
||||
Homarr cannot control what the crawlers do. A crawler can ignore the request. Your website can still appear in an index.
|
||||
|
||||
### No index
|
||||
|
||||
This will ask crawlers to not index your Homarr instance on any indexes.
|
||||
It is up to the crawler to properly respect this request.
|
||||
By default, this setting is enabled. Disabling it will allow indexing.
|
||||
This setting asks crawlers to not index your instance.
|
||||
This setting is enabled by default. Disable this setting to allow indexing.
|
||||
|
||||
### No follow
|
||||
|
||||
If indexing is allowed, crawlers will also attempt to follow any links on your page to further "discover" new parts of the Internet and on your website.
|
||||
By default, this setting is enabled and will ask crawlers to not follow any links.
|
||||
Crawler of different companies may behave differently when indexing is prohibited and following is allowed.
|
||||
Crawlers follow links to discover new pages.
|
||||
This setting asks crawlers to not follow links. This setting is enabled by default.
|
||||
Crawler behavior can differ between search engines.
|
||||
|
||||
### No translate
|
||||
|
||||
Google and Chrome on Android will prompt users to translate pages that are not in their device language.
|
||||
This can lead to some shifting and can be undesired in some cases. By enabling this,
|
||||
you ask crawlers to never translate and never prompt users for translation.
|
||||
It should be noted that some browsers will ignore this and translate regardless.
|
||||
Some browsers offer to translate pages.
|
||||
This setting asks crawlers to not offer translation. Some browsers ignore this setting.
|
||||
|
||||
### No site links search box
|
||||
|
||||
This setting is exclusive to Google.
|
||||
[Google will create a box below your domain](https://developers.google.com/search/docs/appearance/structured-data/sitelinks-searchbox) that lists the paths that were discovered by the "folowing".
|
||||
The user can search them directly in Google.
|
||||
You can ask the crawlers to not allow searches by enabling this setting.
|
||||
This setting is disabled by default.
|
||||
This setting applies to Google only.
|
||||
Google can show a search box below your domain.
|
||||
This setting asks Google to not show this box. This setting is disabled by default.
|
||||
|
||||
## Boards
|
||||
|
||||
### Global home board
|
||||
|
||||
You can set a global home board that will be used for all users that have not set a home board themselves.
|
||||
It will also be used for anonymous users.
|
||||
Note that the board must be public to be used as a global home board.
|
||||
You can set a global home board.
|
||||
This board is the fallback for users without their own home board. It is also the fallback for anonymous users.
|
||||
The board must be public.
|
||||
|
||||
### Global mobile board
|
||||
|
||||
You can set a global mobile board. It will be shown instead of the global home board on mobile devices that can be detected by Homarr. (Using the User-Agent header).
|
||||
It will similar to the global home board be as fallback for normal users and for any anonymous users.
|
||||
It also has to be public.
|
||||
You can set a global mobile board.
|
||||
Homarr shows this board on mobile devices instead of the global home board.
|
||||
Homarr detects mobile devices with the User-Agent header.
|
||||
The board must be public.
|
||||
|
||||
### App status
|
||||
|
||||
#### Enable status by default
|
||||
|
||||
You can decide if the app status should be enabled by default when adding a new app to the board.
|
||||
You can decide if app status is enabled when you add a new app.
|
||||
|
||||
#### Force disable status
|
||||
|
||||
You can decide if the app status should be disabled for all boards.
|
||||
|
||||
###
|
||||
You can decide if app status is disabled on all boards.
|
||||
|
||||
## Appearance
|
||||
|
||||
### Default color scheme
|
||||
|
||||
You can set the default color scheme for all users that have not set a color scheme themselves.
|
||||
You can set the default color scheme for users without their own color scheme.
|
||||
|
||||
## Culture
|
||||
|
||||
### Default language
|
||||
|
||||
You can set the default language for all users that have not set a language themselves.
|
||||
You can set the default language for users without their own language.
|
||||
@@ -177,6 +177,11 @@ Currently, the following permissions are available:
|
||||
- **Full integration access**: Allow members to manage, use and interact with any integration
|
||||
- **Administrator**: Members with this permission have full access to all features and settings
|
||||
|
||||
Custom Widget authoring, import, migration, preview, secrets, Workshop access, and placement configuration are reserved
|
||||
for administrators and are not separate assignable group permissions. Board modifiers can still move, resize, change
|
||||
presentation settings, or remove an existing Custom Widget placement, and board viewers can use placed widgets as
|
||||
allowed by each request.
|
||||
|
||||
When you changed permissions, you must confirm your changes by clicking the banner at the bottom:
|
||||
|
||||

|
||||
|
||||
@@ -17,9 +17,15 @@ The [Community Workshop](pathname:///workshop) is where Homarr users share and d
|
||||
**custom CSS themes** and **custom widgets**. You can browse submissions, upvote the ones
|
||||
you like, report abuse, and install entries directly into your own Homarr instance.
|
||||
|
||||
The central `homarr.dev` Workshop is a public community. Browsing it on the website is public. Browsing, installing,
|
||||
publishing, or importing Workshop content **inside a Homarr instance is restricted to Homarr administrators**.
|
||||
|
||||
:::warning
|
||||
|
||||
The workshop contains community-made content. Widgets may contain unwanted or malicious content and it is your duty to check & verify safety of the content. While we try to remove malicious content as best as we can, we take no responsibility. Always review the widget JSON and all CSS before installing it on your instance and take frequent backups as prevention measures.
|
||||
The Workshop contains community-made content. Moderation is not a security boundary, and PocketBase does not interpret
|
||||
or validate submission content. Always review widget capabilities, network origins, actions, and CSS before installing.
|
||||
Homarr's installation-time validator is definitive and can reject content that the public service accepted earlier.
|
||||
Back up your instance before installing untrusted content.
|
||||
|
||||
:::
|
||||
|
||||
@@ -29,12 +35,13 @@ The workshop contains community-made content. Widgets may contain unwanted or ma
|
||||
- Sign in with **GitHub** to upvote/downvote, submit your own creations, or report a submission.
|
||||
- Each submission shows its title, description, linked GitHub author, revision, optional screenshots, comments, and full content. Widget pages also summarize API sources, actions, options, network access, and required credentials before installation.
|
||||
- A new submission receives one real upvote owned by its author. Votes remain unique per user and submission.
|
||||
- Report details and reporter identities are visible only to Workshop administrators. Reported submissions are highlighted in the administration page, where each open report can be dismissed or the submission can be deleted. Reports do not automatically remove a submission.
|
||||
- Open report counts are public on submission cards so visitors can make an informed installation decision. The submission author and Workshop moderators can read each report category and explanation; reporter identities remain moderator-only.
|
||||
- Signed-in moderators see a **Moderation** button in the Workshop header. The moderation queue is sorted by open report count and lets moderators inspect every report, dismiss it, or delete the submission. Reports do not automatically remove a submission.
|
||||
- The manual **Outdated** badge warns visitors that a submission may need an update.
|
||||
|
||||
## Comments
|
||||
|
||||
Comments are publicly readable and appear in chronological conversation bubbles with the author's avatar and GitHub profile. Signed-in users can post; authors can edit or delete their own comments, and Workshop administrators can delete any comment. Failed posts keep the text you typed. When SMTP and `WORKSHOP_PUBLIC_ORIGIN` are configured, the submission author receives one email for a comment from another user. Self-comments, edits, and deletions do not send mail.
|
||||
Comments are publicly readable and appear in chronological conversation bubbles with the author's avatar and GitHub profile. Signed-in users can post; authors can edit or delete their own comments, and Workshop moderators can delete any comment. Failed posts keep the text you typed. When SMTP and `WORKSHOP_PUBLIC_ORIGIN` are configured, the submission author receives one email for a comment from another user. Self-comments, edits, and deletions do not send mail.
|
||||
|
||||
## Submitting
|
||||
|
||||
@@ -46,10 +53,23 @@ Comments are publicly readable and appear in chronological conversation bubbles
|
||||
3. Review the widget JSON or CSS in the syntax-aware editor, then add a title, description, optional screenshots, and changelog.
|
||||
4. Submit the entry.
|
||||
|
||||
Custom widget JSON is normalized to readable multi-line formatting when it is published, and the Workshop detail page
|
||||
also formats older compact submissions before displaying them.
|
||||
|
||||
Community members can report submissions as outdated or no longer working, malicious, spam, copyright-infringing,
|
||||
inappropriate, or another concern. An outdated report does not automatically mark or remove the submission.
|
||||
|
||||
Submissions are published immediately. Reported entries are reviewed and may be removed.
|
||||
Authors can edit a submission later to publish a new revision. The edit form can keep or remove existing screenshots and add new PNG, JPG, or WebP images without changing the Workshop URL.
|
||||
|
||||
When SMTP and `WORKSHOP_PUBLIC_ORIGIN` are configured, every current Workshop administrator receives an email when a report is submitted. If an administrator deletes the reported submission, its author receives a separate removal email. Author self-deletion does not send a moderation email.
|
||||
When SMTP and `WORKSHOP_PUBLIC_ORIGIN` are configured, every current Workshop moderator receives an email when a report is submitted. If a moderator deletes the reported submission, its author receives a separate removal email. Author self-deletion does not send a moderation email.
|
||||
|
||||
:::info Separate roles
|
||||
|
||||
A Workshop moderator is a user with PocketBase's internal `isAdmin` field. This role moderates the central community
|
||||
and is unrelated to a Homarr instance administrator.
|
||||
|
||||
:::
|
||||
|
||||
## Submission guidelines
|
||||
|
||||
@@ -61,10 +81,9 @@ that primarily promotes unrelated products or services.
|
||||
|
||||
You can install workshop entries without leaving Homarr:
|
||||
|
||||
- **Custom widgets** — go to **Manage → Custom Widgets**, or use **Install from Workshop** in the
|
||||
board widget picker. Pick an entry, review its capabilities, and enter any required local secrets before import.
|
||||
- **Custom CSS** — open a board's **Settings → Custom CSS**, click **Install from Workshop**, and
|
||||
pick a theme to load it into the editor before saving.
|
||||
- **Custom widgets (Homarr administrators only)** — go to **Manage → Custom Widgets**, or use **Import from Workshop** in the
|
||||
board widget picker. Choose **Install widget**, review the complete source and capabilities, then confirm API sources and any local secrets in the second modal step.
|
||||
- **Custom CSS from Workshop (Homarr administrators only)** — open a board's **Settings → Custom CSS**, click **Import from Workshop**, choose a theme, review its full CSS in the second same-size modal step, and click **Import CSS** once. The board setting is still saved separately.
|
||||
|
||||
Alternatively, open a submission in the Workshop and use **Download widget JSON** or **Copy CSS**. The submission page shows the matching manual installation steps before the source code.
|
||||
|
||||
@@ -74,13 +93,74 @@ Custom Widget imports detect required bearer, basic, header, or query credential
|
||||
|
||||
Workshop is powered by [PocketBase](https://pocketbase.io). The production image compiles the current Docusaurus site and serves it together with the API from one origin. The deployment lives in `apps/workshop/` with a Dockerfile, Compose configuration, version-controlled migrations, hooks, a smoke test, and ACT-local workflow instructions.
|
||||
|
||||
Run `pnpm docker:workshop` for the PocketBase development service or `pnpm docker:docs` for the built documentation and Workshop site. Both use `apps/workshop/docker-compose.yml` and the persistent `pb_data` volume. Normal start and stop commands retain data; `docker compose -f apps/workshop/docker-compose.yml down --volumes` intentionally recreates the database.
|
||||
Submission links include Open Graph and X/Twitter metadata for the submission title, description, and type. The first uploaded screenshot is used as the social preview image; submissions without screenshots use the Homarr logo.
|
||||
|
||||
Because Workshop is still unreleased, reset an older development volume if it predates the current schema and comments report that their collection context is missing. No compatibility migrations are maintained for these pre-release databases.
|
||||
Run `pnpm docker:workshop` for PocketBase on port 8090, then run
|
||||
`WORKSHOP_API_URL=http://127.0.0.1:8090 pnpm dev:docs` for Docusaurus on port 3003. `pnpm docker:docs` builds the
|
||||
combined production layout. Normal start and stop commands retain data; `down --volumes` is an intentional,
|
||||
irreversible development reset.
|
||||
|
||||
GitHub needs only the central callback `https://<workshop-host>/api/oauth2-redirect`. Keep PocketBase origins unrestricted unless every self-hosted Homarr caller is explicitly listed. Configure `GITHUB_CLIENT_ID`, `GITHUB_CLIENT_SECRET`, `WORKSHOP_PUBLIC_ORIGIN`, and PocketBase SMTP settings for authentication, comment email, report alerts, and submission-removal notices.
|
||||
GitHub needs only the central callback `https://<workshop-host>/api/oauth2-redirect`. Keep PocketBase origins unrestricted
|
||||
unless every self-hosted Homarr caller is explicitly listed. Configure both `GITHUB_CLIENT_ID` and
|
||||
`GITHUB_CLIENT_SECRET`; supplying only one is a startup error. Set `WORKSHOP_PUBLIC_ORIGIN` in production so generated
|
||||
links and email use the public host. Workshop stores only the OAuth provider username in `users.name`, and
|
||||
users cannot rename it. Map **OAuth2 username** to `name`; leave OAuth2 id, full name, and avatar mappings
|
||||
empty. Profile and avatar URLs are derived from `https://github.com/<username>` and
|
||||
`https://github.com/<username>.png`, so PocketBase does not store duplicate profile metadata or avatar files. Configure
|
||||
SMTP for comment, report, and removal notices.
|
||||
|
||||
Homarr instances use the server-side `WORKSHOP_API_URL` variable to reach the central API. The Docusaurus site uses `WORKSHOP_URL` when its development server runs separately; the combined production image always uses its own origin. Restart the corresponding process after changing either value because both are read at startup.
|
||||
The public URL contract is:
|
||||
|
||||
| Variable | Purpose | Default |
|
||||
| ------------------------ | ------------------------------------------------- | ----------------------------- |
|
||||
| `HOMARR_WEBSITE_URL` | Documentation/site base used by Homarr links | `https://homarr.dev` |
|
||||
| `WORKSHOP_API_URL` | PocketBase API used by Homarr and standalone docs | `HOMARR_WEBSITE_URL` |
|
||||
| `WORKSHOP_WEB_URL` | Public Workshop page linked from Homarr | `HOMARR_WEBSITE_URL/workshop` |
|
||||
| `WORKSHOP_PUBLIC_ORIGIN` | PocketBase-generated links and email | Required in production |
|
||||
| `PB_ALLOWED_ORIGINS` | PocketBase browser origins | `*` for the central community |
|
||||
|
||||
The deprecated docs-only `WORKSHOP_URL` alias remains for one release; `WORKSHOP_API_URL` takes precedence. The
|
||||
combined Workshop image writes these public URLs into its browser runtime configuration whenever the container starts;
|
||||
the Docker build arguments remain fallbacks. Restart the container after changing these values, without rebuilding it.
|
||||
|
||||
### Staging and demo deployment
|
||||
|
||||
Use `apps/workshop/docker-compose.staging.yml` with the published `ghcr.io/homarr-labs/workshop:v2` image for a v2
|
||||
preview. Copy `apps/workshop/.env.staging.example` to `.env.staging`; its default public contract is:
|
||||
|
||||
```dotenv
|
||||
HOMARR_WEBSITE_URL=https://v2.preview.homarr.dev
|
||||
WORKSHOP_API_URL=https://v2.preview.homarr.dev
|
||||
WORKSHOP_WEB_URL=https://v2.preview.homarr.dev/workshop
|
||||
WORKSHOP_PUBLIC_ORIGIN=https://v2.preview.homarr.dev
|
||||
PB_ALLOWED_ORIGINS=*
|
||||
```
|
||||
|
||||
Start or update it from the repository root:
|
||||
|
||||
```sh
|
||||
docker compose --env-file apps/workshop/.env.staging \
|
||||
-f apps/workshop/docker-compose.staging.yml pull
|
||||
docker compose --env-file apps/workshop/.env.staging \
|
||||
-f apps/workshop/docker-compose.staging.yml up --detach
|
||||
curl --fail http://127.0.0.1:8090/api/health
|
||||
```
|
||||
|
||||
The service binds to `127.0.0.1:8090`. Terminate TLS at a reverse proxy and forward the **entire hostname**, including
|
||||
`/api`, `/_/`, and `/workshop-runtime-config.js`; forwarding only `/workshop` breaks PocketBase and runtime URL loading.
|
||||
This combined image serves both the documentation site and PocketBase, so use a dedicated Workshop hostname if the
|
||||
chosen hostname already serves Homarr itself. In that case, replace the hostname in all four URL values and keep
|
||||
`/workshop` only on `WORKSHOP_WEB_URL`.
|
||||
|
||||
The Compose volume preserves PocketBase data across pulls and container recreation. The mutable `v2` tag is suitable
|
||||
for previews; use the `sha-<commit>` tag for promotion. For the public central Workshop, `PB_ALLOWED_ORIGINS=*` allows
|
||||
self-hosted Homarr origins to use the API and OAuth. A private demo may use a comma-separated origin allowlist instead.
|
||||
GitHub OAuth requires both credentials and the callback
|
||||
`https://v2.preview.homarr.dev/api/oauth2-redirect`. Restart after changing environment values, then verify the health
|
||||
endpoint, `/workshop`, a public listing request, GitHub sign-in, and an installation from the Homarr preview.
|
||||
|
||||
Back up application and PocketBase data before deployment. Publish the combined image by immutable digest, test the
|
||||
exact digest in staging, rehearse application rollback and PocketBase restore, then promote that same digest.
|
||||
|
||||
## Client architecture
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
title: "Audio Stats"
|
||||
description: "Displays library statistics from Navidrome or Audiobookshelf, adapting its display based on the linked integration."
|
||||
description: "Displays media and listening statistics from Navidrome or Audiobookshelf, adapting its display based on the linked integration."
|
||||
hide_title: true
|
||||
---
|
||||
|
||||
@@ -16,7 +16,7 @@ import { audiobookshelfIntegration } from "@site/docs/integrations/audiobookshel
|
||||
|
||||
This widget adapts its display based on the linked integration:
|
||||
|
||||
- **Navidrome**: Shows artist, album, and song counts, plus a now playing section with active streams
|
||||
- **Navidrome**: Shows artist, album, and song counts
|
||||
- **Audiobookshelf**: Shows library count, audiobook/podcast totals, listening time, and active sessions
|
||||
|
||||
Only options relevant to your linked integration are shown in the configuration panel.
|
||||
@@ -27,7 +27,7 @@ Only options relevant to your linked integration are shown in the configuration
|
||||
items={[
|
||||
{
|
||||
integration: navidromeIntegration,
|
||||
note: "Displays music library stats and now playing",
|
||||
note: "Displays music library statistics",
|
||||
},
|
||||
{
|
||||
integration: audiobookshelfIntegration,
|
||||
|
||||
@@ -27,24 +27,6 @@ export const audioStatsWidget: WidgetDefinition = {
|
||||
values: { type: "boolean" },
|
||||
defaultValue: "yes",
|
||||
},
|
||||
{
|
||||
name: "Show now playing",
|
||||
description: "Displays currently playing tracks (Navidrome only)",
|
||||
values: { type: "boolean" },
|
||||
defaultValue: "yes",
|
||||
},
|
||||
{
|
||||
name: "Show now playing list",
|
||||
description: "Displays a detailed list of now playing tracks (Navidrome only)",
|
||||
values: { type: "boolean" },
|
||||
defaultValue: "yes",
|
||||
},
|
||||
{
|
||||
name: "Max now playing items",
|
||||
description: "Maximum number of now playing tracks to display (Navidrome only)",
|
||||
values: "Number (1-5)",
|
||||
defaultValue: "3",
|
||||
},
|
||||
{
|
||||
name: "Show library count",
|
||||
description: "Displays the number of libraries (Audiobookshelf only)",
|
||||
|
||||
@@ -15,7 +15,7 @@ import beszelSystemStats from "./img/beszel-system-stats.png";
|
||||
|
||||
<WidgetHeader widget={beszelSystemStatsWidget} categories={["System Monitoring", "Charts"]} />
|
||||
|
||||
This widget displays time-series charts for a selected Beszel system, including CPU, memory, disk, network, and Docker container metrics. The Disk Usage chart combines the root disk and every extra filesystem reported by Beszel. You can use the realtime Live view or select a historical period ranging from 1 hour to 30 days. If the live stream drops, the widget now shows the connection failure state immediately and can switch back to historical data.
|
||||
This widget displays time-series charts for a selected Beszel system, including CPU, memory, disk, network, and Docker container metrics. The Disk Usage chart combines the root disk and every extra filesystem reported by Beszel. You can use the realtime Live view or select a historical period ranging from 1 hour to 30 days. Live view keeps a one-minute window and pauses while the browser tab is hidden. If the live stream drops, the widget now shows the connection failure state immediately and can switch back to historical data.
|
||||
|
||||
### Screenshots
|
||||
|
||||
|
||||
@@ -13,10 +13,17 @@ import { beszelIntegration } from "@site/docs/integrations/beszel";
|
||||
|
||||
<WidgetHeader widget={beszelSystemTableWidget} categories={["System Monitoring"]} />
|
||||
|
||||
This widget displays all your Beszel-monitored systems in a sortable data table. Each row shows system metrics like CPU, memory, disk, network, temperature, and more. Columns can be toggled on or off, and the table supports sorting by any column. When Beszel reports extra filesystems, the disk column adds a hover breakdown for each mount alongside the root disk.
|
||||
This widget displays all your Beszel-monitored systems in a sortable data table. Each row shows system metrics like CPU, memory, disk, network, temperature, and more. Columns can be toggled on or off, dragged into a different order, and resized from their headers. Column order and widths are saved automatically for each widget. When Beszel reports extra filesystems, the disk column adds a hover breakdown for each mount alongside the root disk.
|
||||
|
||||
Select a system row to open the complete statistics view, including system, filesystem, Docker, historical, and realtime charts.
|
||||
|
||||
### Interactions
|
||||
|
||||
- **Reorder columns** by hovering a header to reveal its grab handle, then dragging it
|
||||
- **Resize columns** by dragging a header's right edge
|
||||
- **Sort systems** by selecting a sortable column header
|
||||
- **Open system statistics** by selecting a row
|
||||
|
||||
### Supported Integrations
|
||||
|
||||
<WidgetIntegrations
|
||||
|
||||
@@ -38,7 +38,11 @@ Enable the definition before you use it. The definition must also be available t
|
||||
|
||||
Each named request declares its minimum board permission. This permission does not depend on the HTTP method.
|
||||
|
||||
Queries and actions can use GET, POST, PUT, PATCH, or DELETE. The `kind` value controls runtime behavior.
|
||||
Anonymous visitors can load queries from a Custom API widget on a public board. Definitions placed only on private
|
||||
boards remain unavailable to them.
|
||||
|
||||
Queries and actions can use GET, POST, PUT, or PATCH. DELETE is valid only for actions so it can never run
|
||||
automatically while a board loads. The `kind` value controls the remaining runtime behavior.
|
||||
|
||||
A DELETE request requires full board permission and confirmation. An anonymous viewer cannot run an action.
|
||||
|
||||
|
||||
@@ -40,13 +40,19 @@ import dockerTable from "./img/table.png";
|
||||
|
||||
### Interactions
|
||||
|
||||
Each container row has an action menu (accessible via the three-dot icon) with the following options:
|
||||
Hover a column header to reveal its grab handle, then drag it to reorder the table or drag the header's edge to resize it. The layout is saved automatically for each widget instance and restored after a reload. These controls are disabled while the board is in edit mode.
|
||||
|
||||
Each container row has an action menu, accessible via the three-dot icon or by right-clicking the row, with the following options:
|
||||
|
||||
- **View logs** - opens a dedicated terminal page streaming the container's logs
|
||||
- **Start** / **Stop** / **Restart** - manage the container's state
|
||||
- **Remove** - remove the container
|
||||
- **Remove** - permanently remove the container after a second confirmation click
|
||||
- **Add to Homarr** - create a bookmark app from the container
|
||||
|
||||
### Adding the widget
|
||||
|
||||
<AddingWidget />
|
||||
|
||||
### Configuration
|
||||
|
||||
<WidgetConfig configuration={dockerContainersWidget.configuration} />
|
||||
@@ -8,6 +8,12 @@ export const dockerContainersWidget: WidgetDefinition = {
|
||||
path: "../../widgets/docker-containers",
|
||||
configuration: {
|
||||
items: [
|
||||
{
|
||||
name: "Columns to show",
|
||||
description: "Select which columns are visible in the table",
|
||||
values: "Name, State, Host, CPU usage, Memory usage, and Actions",
|
||||
defaultValue: "All columns",
|
||||
},
|
||||
{
|
||||
name: "Enable items sorting",
|
||||
description: "Allows to sort containers by clicking on the column headers",
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 210 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 21 KiB After Width: | Height: | Size: 122 KiB |
@@ -4,6 +4,7 @@ description: "Allows you to view and manage your Downloads from both Torrent and
|
||||
hide_title: true
|
||||
---
|
||||
|
||||
import { aria2Integration } from "@site/docs/integrations/aria2";
|
||||
import { delugeIntegration } from "@site/docs/integrations/deluge";
|
||||
import { nzbgetIntegration } from "@site/docs/integrations/nzbget";
|
||||
import { qBittorentIntegration } from "@site/docs/integrations/q-bittorent";
|
||||
@@ -14,23 +15,41 @@ import { AddingWidget } from "@site/src/components/widgets/adding";
|
||||
import { WidgetConfig } from "@site/src/components/widgets/configuration";
|
||||
import { WidgetHeader } from "@site/src/components/widgets/header";
|
||||
import { WidgetIntegrations } from "@site/src/components/widgets/integrations";
|
||||
import { IconAd } from "@tabler/icons-react";
|
||||
import TabItem from "@theme/TabItem";
|
||||
import Tabs from "@theme/Tabs";
|
||||
import { downloadsWidget } from ".";
|
||||
|
||||
<WidgetHeader widget={downloadsWidget} categories={["Torrent client", "Usenet client", "Downloads"]} />
|
||||
|
||||
This widget displays a list of torrents or usenet-articles that are currently being downloaded, along with their progress and other relevant information.
|
||||
This widget displays a list of torrents or usenet-articles that are currently being downloaded, along with their progress and other relevant information. It supports 7 download client integrations and provides a rich, responsive table interface.
|
||||
|
||||
### Screenshots
|
||||
|
||||
import downloadsCard from "./img/screenshot.png";
|
||||
import contextMenuImg from "./img/context-menu.png";
|
||||
|
||||
<div>
|
||||
<img src={downloadsCard} alt="downloads" width={128 * 4} />
|
||||
<img src={downloadsCard} alt="Downloads widget showing torrent and usenet items" width={128 * 4} />
|
||||
</div>
|
||||
|
||||
#### Right-click context menu
|
||||
|
||||
<div>
|
||||
<img src={contextMenuImg} alt="Downloads widget context menu with pause, delete, and copy actions" width={128 * 4} />
|
||||
</div>
|
||||
|
||||
### Features
|
||||
|
||||
- **Responsive table** — columns automatically adapt to widget width, hiding less important columns at smaller sizes
|
||||
- **Sortable columns** — click any column header to sort ascending/descending
|
||||
- **Column customization** — choose which columns to display, then hover a header to reveal its grab handle for reordering or drag its edge to resize it; layouts save automatically per widget
|
||||
- **Right-click context menu** — pause, resume, delete downloads, or copy download names directly from the context menu
|
||||
- **Row expansion** — click any row to see full details inline without opening a separate modal
|
||||
- **Hover tooltips** — hover a download name to see size, speeds, ETA, ratio, and category
|
||||
- **Global stats bar** — toggle a summary bar showing queue progress, total size, speeds, ratio, and per-state counts
|
||||
- **Client filtering** — filter downloads by integration or status using the footer filter panel
|
||||
- **Global controls** — pause/resume all downloads across all clients from the footer bar
|
||||
- **Status indicators** — color-coded badges show download state at a glance
|
||||
- **Speed overview** — total download and upload speeds displayed in the footer
|
||||
|
||||
### Supported Integrations
|
||||
|
||||
<WidgetIntegrations
|
||||
@@ -55,18 +74,26 @@ import downloadsCard from "./img/screenshot.png";
|
||||
integration: qBittorentIntegration,
|
||||
note: "Torrent client",
|
||||
},
|
||||
{
|
||||
integration: aria2Integration,
|
||||
note: "Multi-protocol download client",
|
||||
},
|
||||
{
|
||||
integration: slskdIntegration,
|
||||
note: "Soulseek client",
|
||||
note: "Soulseek client (read-only)",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
### Interactions
|
||||
|
||||
- Pause / Resume one download by clicking on the according icon.
|
||||
- Delete one download by clicking on the trash icon. (Can also delete the files)
|
||||
- Pause / Resume all downloads by using the bottom button.
|
||||
- **Right-click** any download to open a context menu with pause/resume, delete, and copy actions
|
||||
- **Click** any row to expand it and see full download details inline
|
||||
- **Hover** a download name for a quick stats tooltip
|
||||
- **Stats toggle** — show or hide the global stats bar from the footer
|
||||
- **Pause / Resume** all downloads using the footer controls
|
||||
- **Filter** by integration or status using the filter button in the footer
|
||||
- **Sort** by clicking column headers (with persistent sort direction)
|
||||
|
||||
### Adding the widget
|
||||
|
||||
|
||||
@@ -2,26 +2,23 @@ import { WidgetDefinition } from "@site/src/types";
|
||||
import { IconDownload } from "@tabler/icons-react";
|
||||
|
||||
const columnsList = [
|
||||
"id",
|
||||
"actions",
|
||||
"added",
|
||||
"category",
|
||||
"downSpeed",
|
||||
"index",
|
||||
"integration",
|
||||
"name",
|
||||
"progress",
|
||||
"size",
|
||||
"downSpeed",
|
||||
"upSpeed",
|
||||
"time",
|
||||
"state",
|
||||
"added",
|
||||
"ratio",
|
||||
"received",
|
||||
"sent",
|
||||
"size",
|
||||
"state",
|
||||
"time",
|
||||
"category",
|
||||
"integration",
|
||||
"index",
|
||||
"type",
|
||||
"upSpeed",
|
||||
];
|
||||
const sortingExclusion = ["actions", "id", "state"];
|
||||
const columnsSort = columnsList.filter((column) => !sortingExclusion.some((exclusion) => exclusion === column));
|
||||
const sortColumns = columnsList.filter((c) => !["state", "category", "integration"].includes(c));
|
||||
|
||||
export const downloadsWidget: WidgetDefinition = {
|
||||
icon: IconDownload,
|
||||
@@ -32,25 +29,20 @@ export const downloadsWidget: WidgetDefinition = {
|
||||
items: [
|
||||
{
|
||||
name: "Columns to show",
|
||||
description: "Select the columns you want to display in the widget.",
|
||||
description:
|
||||
"Select the columns you want to display in the widget. Columns automatically hide/show based on widget width.",
|
||||
values: `List of columns: ${columnsList.join(", ")}`,
|
||||
defaultValue: ["integration", "name", "progress", "time", "actions"].join(", "),
|
||||
defaultValue: ["name", "progress", "downSpeed", "time", "state"].join(", "),
|
||||
},
|
||||
{
|
||||
name: "Enable items sorting",
|
||||
description: "Enables the sorting in the table.",
|
||||
values: { type: "boolean" },
|
||||
defaultValue: "no",
|
||||
},
|
||||
{
|
||||
name: "Column used for sorting by default",
|
||||
description: "By default it will sort by this column.",
|
||||
defaultValue: "type",
|
||||
values: `List of columns: ${columnsSort.join(", ")}`,
|
||||
name: "Default sort column",
|
||||
description: "The column used for initial sorting when the widget loads.",
|
||||
defaultValue: "progress",
|
||||
values: `List of columns: ${sortColumns.join(", ")}`,
|
||||
},
|
||||
{
|
||||
name: "Invert sorting",
|
||||
description: "This will invert the sorting order of the table.",
|
||||
description: "This will invert the default sorting order.",
|
||||
values: { type: "boolean" },
|
||||
defaultValue: "no",
|
||||
},
|
||||
@@ -76,13 +68,13 @@ export const downloadsWidget: WidgetDefinition = {
|
||||
name: "Hide completed torrent under this threshold (in kiB/s)",
|
||||
description:
|
||||
"This will hide completed torrent entries that have a download speed below the specified threshold.",
|
||||
values: "Any number above 1, 0 to disable",
|
||||
values: "Any number above 0, 0 to disable",
|
||||
defaultValue: "0",
|
||||
},
|
||||
{
|
||||
name: "Categories/names to filter",
|
||||
description: "You can filter the items by categories or names. Use a comma to separate multiple values.",
|
||||
values: "Comma-separated list of categories or names",
|
||||
name: "Categories/labels to filter",
|
||||
description: "You can filter the items by categories or labels. Use a comma to separate multiple values.",
|
||||
values: "Comma-separated list of categories or labels",
|
||||
defaultValue: "-",
|
||||
},
|
||||
{
|
||||
@@ -94,13 +86,13 @@ export const downloadsWidget: WidgetDefinition = {
|
||||
},
|
||||
{
|
||||
name: "Use filter to calculate Ratio",
|
||||
description: "This will use the filter to calculate the ratio of items shown in the widget.",
|
||||
description: "This will use the category filter when calculating the global torrent ratio.",
|
||||
values: { type: "boolean" },
|
||||
defaultValue: "yes",
|
||||
},
|
||||
{
|
||||
name: "Limit items per integration",
|
||||
description: "This will limit the number of items shown per integration, not globally",
|
||||
description: "This will limit the number of items shown per integration, not globally.",
|
||||
values: "Any number above 1",
|
||||
defaultValue: "50",
|
||||
},
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
title: "Immich Album Carousel"
|
||||
description: "Shows a slideshow of your album pictures from Immich"
|
||||
description: "Shows a slideshow of pictures from an Immich album or your full library"
|
||||
hide_title: true
|
||||
---
|
||||
|
||||
|
||||
@@ -4,15 +4,16 @@ import { IconPhoto } from "@tabler/icons-react";
|
||||
export const immichAlbumCarouselWidget: WidgetDefinition = {
|
||||
icon: IconPhoto,
|
||||
name: "Immich Album",
|
||||
description: "Shows a slideshow of your album pictures from Immich",
|
||||
description: "Shows a slideshow of pictures from an Immich album or your full library",
|
||||
path: "../../widgets/immich-album-carousel",
|
||||
configuration: {
|
||||
items: [
|
||||
{
|
||||
name: "Album",
|
||||
description: "Select the Immich album to show in the slideshow.",
|
||||
description:
|
||||
"Select an Immich album or All photos. An empty selection also shows random photos from your full library.",
|
||||
values: { type: "string" },
|
||||
defaultValue: "",
|
||||
defaultValue: "All photos",
|
||||
},
|
||||
{
|
||||
name: "Interval in seconds",
|
||||
|
||||
@@ -14,6 +14,7 @@ import TabItem from "@theme/TabItem";
|
||||
import { mediaServerWidget } from ".";
|
||||
import { embyIntegration } from "@site/docs/integrations/emby";
|
||||
import { jellyfinIntegration } from "@site/docs/integrations/jellyfin";
|
||||
import { navidromeIntegration } from "@site/docs/integrations/navidrome";
|
||||
import { plexIntegration } from "@site/docs/integrations/plex";
|
||||
|
||||
<WidgetHeader widget={mediaServerWidget} categories={["Media server"]} />
|
||||
@@ -37,6 +38,10 @@ import mediaTable from "./img/table.png";
|
||||
{
|
||||
integration: jellyfinIntegration,
|
||||
},
|
||||
{
|
||||
integration: navidromeIntegration,
|
||||
note: "Will always show only currently playing audio streams, not all active sessions.",
|
||||
},
|
||||
{
|
||||
integration: plexIntegration,
|
||||
note: "Will always show only currently playing streams, not all active sessions.",
|
||||
|
||||
@@ -10,7 +10,7 @@ export const mediaServerWidget: WidgetDefinition = {
|
||||
items: [
|
||||
{
|
||||
name: "Show only currently playing",
|
||||
description: "Disabling this will not work for plex.",
|
||||
description: "Plex and Navidrome always show only currently playing streams.",
|
||||
values: { type: "boolean" },
|
||||
defaultValue: "yes",
|
||||
},
|
||||
|
||||
@@ -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,38 @@
|
||||
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",
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
@@ -1,20 +1,33 @@
|
||||
import type * as Preset from "@docusaurus/preset-classic";
|
||||
import type { Config } from "@docusaurus/types";
|
||||
import { themes as prismThemes } from "prism-react-renderer";
|
||||
import { resolveHomarrUrlConfig } from "@homarr/workshop/schema";
|
||||
const a11yEmoji = require("@fec/remark-a11y-emoji");
|
||||
|
||||
const deprecatedWorkshopUrl = process.env.WORKSHOP_URL;
|
||||
if (deprecatedWorkshopUrl && !process.env.WORKSHOP_API_URL) {
|
||||
console.warn("WORKSHOP_URL is deprecated; use WORKSHOP_API_URL instead.");
|
||||
}
|
||||
|
||||
const publicUrls = resolveHomarrUrlConfig({
|
||||
homarrWebsiteUrl: process.env.HOMARR_WEBSITE_URL,
|
||||
workshopApiUrl: process.env.WORKSHOP_API_URL ?? deprecatedWorkshopUrl,
|
||||
workshopWebUrl: process.env.WORKSHOP_WEB_URL,
|
||||
});
|
||||
const config: Config = {
|
||||
title: "Homarr documentation",
|
||||
tagline: "A simple yet powerful dashboard for your server.",
|
||||
url: "https://homarr.dev",
|
||||
url: publicUrls.homarrWebsiteUrl,
|
||||
baseUrl: "/",
|
||||
trailingSlash: undefined,
|
||||
favicon: "img/logo.png",
|
||||
organizationName: "homarr-labs",
|
||||
projectName: "homarr",
|
||||
customFields: {
|
||||
workshopUrl: process.env.WORKSHOP_URL ?? "",
|
||||
workshopUrl: publicUrls.workshopApiUrl,
|
||||
workshopWebUrl: publicUrls.workshopWebUrl,
|
||||
},
|
||||
scripts: [{ src: "/workshop-runtime-config.js" }],
|
||||
i18n: {
|
||||
defaultLocale: "en",
|
||||
locales: ["en"],
|
||||
@@ -335,11 +348,61 @@ const config: Config = {
|
||||
function workshopRoutesPlugin() {
|
||||
return {
|
||||
name: "workshop-routes",
|
||||
injectHtmlTags() {
|
||||
return {
|
||||
headTags: [
|
||||
{
|
||||
tagName: "style",
|
||||
innerHTML: `
|
||||
#workshop-detail-route-fallback { display: none; }
|
||||
html[data-workshop-detail-loading] #__docusaurus { display: none; }
|
||||
html[data-workshop-detail-loading] #workshop-detail-route-fallback {
|
||||
align-items: center;
|
||||
background: #fff;
|
||||
box-sizing: border-box;
|
||||
color: #4b5563;
|
||||
display: flex;
|
||||
font: 500 0.875rem/1.5 Inter, system-ui, sans-serif;
|
||||
justify-content: center;
|
||||
min-height: 100vh;
|
||||
padding: 2rem;
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
html[data-workshop-detail-loading] #workshop-detail-route-fallback {
|
||||
background: #18191a;
|
||||
color: #d1d5db;
|
||||
}
|
||||
}`,
|
||||
},
|
||||
{
|
||||
tagName: "script",
|
||||
innerHTML: `(function () {
|
||||
var match = window.location.pathname.match(/^\\/workshop\\/([^/]+)\\/?$/);
|
||||
if (match && match[1] !== "admin") {
|
||||
document.documentElement.setAttribute("data-workshop-detail-loading", "");
|
||||
}
|
||||
})();`,
|
||||
},
|
||||
],
|
||||
preBodyTags: [
|
||||
{
|
||||
tagName: "div",
|
||||
attributes: {
|
||||
id: "workshop-detail-route-fallback",
|
||||
role: "status",
|
||||
"aria-live": "polite",
|
||||
},
|
||||
innerHTML: "Loading Workshop…",
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
async contentLoaded({ actions }) {
|
||||
actions.addRoute({
|
||||
path: "/workshop/:id",
|
||||
component: "@site/src/components/workshop/WorkshopDetailRoutePage",
|
||||
exact: true,
|
||||
priority: -1,
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
@@ -84,7 +84,6 @@ export function BundledCustomWidgetGalleryClient() {
|
||||
const [selectedId, setSelectedId] = useState(BUNDLED_CUSTOM_WIDGETS[0]?.id ?? null);
|
||||
const selected = BUNDLED_CUSTOM_WIDGETS.find(({ id }) => id === selectedId) ?? BUNDLED_CUSTOM_WIDGETS[0];
|
||||
if (!selected) return null;
|
||||
const manifest = { ...selected.widget, template: "__HOMARR_TEMPLATE__" };
|
||||
|
||||
return (
|
||||
<MantineProvider forceColorScheme={colorMode}>
|
||||
@@ -103,19 +102,11 @@ export function BundledCustomWidgetGalleryClient() {
|
||||
</Text>
|
||||
<ReadOnlyCustomWidgetCode
|
||||
id={`${selected.id}-manifest`}
|
||||
label="widget.json"
|
||||
label="Complete widget.json"
|
||||
language="json"
|
||||
value={JSON.stringify(manifest, null, 2)}
|
||||
value={JSON.stringify(selected.widget, null, 2)}
|
||||
messages={messages}
|
||||
height="340px"
|
||||
/>
|
||||
<ReadOnlyCustomWidgetCode
|
||||
id={`${selected.id}-template`}
|
||||
label="widget.jsx"
|
||||
language="jsx"
|
||||
value={selected.widget.template}
|
||||
messages={messages}
|
||||
height="340px"
|
||||
height="680px"
|
||||
/>
|
||||
</Stack>
|
||||
</MantineProvider>
|
||||
|
||||
@@ -3,6 +3,7 @@ import { IconCheck, IconEdit, IconMessageCircle, IconRefresh, IconSend, IconTras
|
||||
|
||||
import type { WorkshopComment } from "@site/src/lib/pocketbase";
|
||||
import { getWorkshopBackend } from "@site/src/lib/pocketbase";
|
||||
import { githubAvatarUrl, githubProfileUrl } from "@homarr/workshop/schema";
|
||||
import type { WorkshopUser } from "@homarr/workshop/schema";
|
||||
import { errorMessage } from "@site/src/lib/utils";
|
||||
|
||||
@@ -158,13 +159,10 @@ export const CommentsSection = ({ submissionId, backend, currentUser, onRequireA
|
||||
const canDelete = ownComment || currentUser?.isAdmin === true;
|
||||
const isEditing = editingId === comment.id;
|
||||
const edited = comment.updated !== comment.created;
|
||||
const authorName = ownComment ? (currentUser?.displayName ?? comment.authorName) : comment.authorName;
|
||||
const avatarUrl = ownComment
|
||||
? (currentUser?.avatarUrl ?? comment.authorAvatarUrl)
|
||||
: comment.authorAvatarUrl;
|
||||
const profileUrl = ownComment
|
||||
? (currentUser?.githubProfileUrl ?? comment.authorGithubProfileUrl)
|
||||
: comment.authorGithubProfileUrl;
|
||||
const authorHandle = ownComment ? (currentUser?.name ?? comment.authorName) : comment.authorName;
|
||||
const authorName = authorHandle || "Community member";
|
||||
const avatarUrl = githubAvatarUrl(authorHandle);
|
||||
const profileUrl = githubProfileUrl(authorHandle);
|
||||
|
||||
return (
|
||||
<article
|
||||
@@ -292,10 +290,8 @@ export const CommentsSection = ({ submissionId, backend, currentUser, onRequireA
|
||||
<div className="rounded-xl border border-border bg-muted/30 p-3">
|
||||
<div className="flex items-start gap-2.5">
|
||||
<Avatar className="mt-0.5 size-9 bg-background ring-1 ring-border">
|
||||
{currentUser?.avatarUrl && <AvatarImage src={currentUser.avatarUrl} alt="" />}
|
||||
<AvatarFallback className="bg-background">
|
||||
{avatarFallback(currentUser?.displayName ?? "Guest")}
|
||||
</AvatarFallback>
|
||||
{currentUser?.name && <AvatarImage src={githubAvatarUrl(currentUser.name)} alt="" />}
|
||||
<AvatarFallback className="bg-background">{avatarFallback(currentUser?.name ?? "Guest")}</AvatarFallback>
|
||||
</Avatar>
|
||||
<Textarea
|
||||
className="min-h-16 flex-1 resize-none bg-background"
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import BrowserOnly from "@docusaurus/BrowserOnly";
|
||||
import Head from "@docusaurus/Head";
|
||||
import Link from "@docusaurus/Link";
|
||||
import { useLocation } from "@docusaurus/router";
|
||||
import useDocusaurusContext from "@docusaurus/useDocusaurusContext";
|
||||
import Layout from "@theme/Layout";
|
||||
|
||||
import {
|
||||
IconArrowBigDown,
|
||||
IconArrowBigUp,
|
||||
@@ -28,9 +30,13 @@ import { toast } from "sonner";
|
||||
|
||||
import type { WorkshopSubmission, WorkshopVote } from "@site/src/lib/pocketbase";
|
||||
import { getWorkshopBackend } from "@site/src/lib/pocketbase";
|
||||
import { getRuntimeWorkshopApiUrl } from "@site/src/lib/runtime-config";
|
||||
import type { SubmissionType } from "@site/src/lib/workshop-schema";
|
||||
import type { HomarrCustomWidgetV2 } from "@homarr/custom-widgets/core";
|
||||
import {
|
||||
githubAvatarUrl,
|
||||
githubProfileUrl,
|
||||
formatWorkshopContent,
|
||||
MAX_WORKSHOP_SCREENSHOTS,
|
||||
MAX_WORKSHOP_SCREENSHOT_BYTES,
|
||||
WORKSHOP_SCREENSHOT_MIME_TYPES,
|
||||
@@ -64,6 +70,7 @@ import { WorkshopErrorBoundary } from "./WorkshopErrorBoundary";
|
||||
import { downloadSubmissionJson, voteAndReconcile } from "./workshop-utils";
|
||||
|
||||
const typeLabels: Record<SubmissionType, string> = { customCss: "CSS", customWidget: "Widget" };
|
||||
const typeSocialLabels: Record<SubmissionType, string> = { customCss: "Custom CSS", customWidget: "Custom widget" };
|
||||
const typeDotColors: Record<SubmissionType, string> = { customCss: "bg-blue-500", customWidget: "bg-yellow-500" };
|
||||
const contentLanguages: Record<SubmissionType, string> = { customCss: "css", customWidget: "json" };
|
||||
const copyState = [
|
||||
@@ -335,7 +342,7 @@ const MarketplaceDetail = ({ workshopUrl }: { workshopUrl: string }) => {
|
||||
if (!submission) return;
|
||||
try {
|
||||
const updated = await backend.toggleOutdated(submission.id, !submission.outdated);
|
||||
setSubmission({ ...submission, outdated: updated.outdated });
|
||||
setSubmission(updated);
|
||||
setError(null);
|
||||
toast.success(updated.outdated ? "Submission marked as outdated" : "Submission marked as current");
|
||||
} catch (caught) {
|
||||
@@ -447,13 +454,13 @@ const MarketplaceDetail = ({ workshopUrl }: { workshopUrl: string }) => {
|
||||
try {
|
||||
await backend.report(
|
||||
submission.id,
|
||||
reportCategory as "malicious" | "spam" | "copyright" | "inappropriate" | "other",
|
||||
reportCategory as "outdated" | "malicious" | "spam" | "copyright" | "inappropriate" | "other",
|
||||
reportExplanation,
|
||||
);
|
||||
setReportOpen(false);
|
||||
setReportExplanation("");
|
||||
setError(null);
|
||||
toast.success("Report submitted", { description: "A Workshop administrator can now review it." });
|
||||
toast.success("Report submitted", { description: "A Workshop moderator can now review it." });
|
||||
} catch (caught) {
|
||||
setReportError(errorMessage(caught, "Failed to submit report"));
|
||||
} finally {
|
||||
@@ -522,17 +529,40 @@ const MarketplaceDetail = ({ workshopUrl }: { workshopUrl: string }) => {
|
||||
const canManage = user?.id === submission.author || user?.isAdmin === true;
|
||||
const canEdit = user?.id === submission.author;
|
||||
const signedInAuthor = user?.id === submission.author ? user : null;
|
||||
const authorName = signedInAuthor?.displayName || submission.authorName;
|
||||
const authorAvatarUrl = signedInAuthor?.avatarUrl || submission.authorAvatarUrl;
|
||||
const authorGithubUsername = signedInAuthor?.githubUsername || submission.authorGithubUsername;
|
||||
const authorGithubProfileUrl = signedInAuthor?.githubProfileUrl || submission.authorGithubProfileUrl;
|
||||
const authorHandle = signedInAuthor?.name || submission.authorName;
|
||||
const authorName = authorHandle || "Community member";
|
||||
const authorAvatarUrl = githubAvatarUrl(authorHandle);
|
||||
const authorGithubProfileUrl = githubProfileUrl(authorHandle);
|
||||
const sources = widgetDefinition ? Object.entries(widgetDefinition.sources) : [];
|
||||
const requests = widgetDefinition ? Object.entries(widgetDefinition.requests) : [];
|
||||
const options = widgetDefinition ? Object.entries(widgetDefinition.options) : [];
|
||||
const protectedSources = sources.filter(([, source]) => sourceNeedsSecret(source.auth));
|
||||
const displayedContent = formatWorkshopContent(submission.type, submission.content);
|
||||
const socialTitle = `${submission.title} · Homarr Workshop`;
|
||||
const socialDescription = `${typeSocialLabels[submission.type]} for Homarr. ${submission.description}`.trim();
|
||||
const socialUrl = `${window.location.origin}${window.location.pathname}`;
|
||||
const socialImage = screenshotUrls[0] ?? `${window.location.origin}/img/logo.png`;
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-[90rem] px-4 pb-20 pt-8 sm:px-6 lg:px-8">
|
||||
<Head>
|
||||
<title>{socialTitle}</title>
|
||||
<meta name="description" content={socialDescription} />
|
||||
<link rel="canonical" href={socialUrl} />
|
||||
<meta property="og:type" content="article" />
|
||||
<meta property="og:site_name" content="Homarr Workshop" />
|
||||
<meta property="og:title" content={socialTitle} />
|
||||
<meta property="og:description" content={socialDescription} />
|
||||
<meta property="og:url" content={socialUrl} />
|
||||
<meta property="og:image" content={socialImage} />
|
||||
<meta property="og:image:alt" content={`${submission.title} preview`} />
|
||||
<meta property="article:section" content={typeSocialLabels[submission.type]} />
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
<meta name="twitter:title" content={socialTitle} />
|
||||
<meta name="twitter:description" content={socialDescription} />
|
||||
<meta name="twitter:image" content={socialImage} />
|
||||
<meta name="twitter:image:alt" content={`${submission.title} preview`} />
|
||||
</Head>
|
||||
<Toaster position="bottom-right" richColors />
|
||||
<Link
|
||||
to="/workshop"
|
||||
@@ -591,10 +621,7 @@ const MarketplaceDetail = ({ workshopUrl }: { workshopUrl: string }) => {
|
||||
{authorName}
|
||||
{authorGithubProfileUrl && <IconBrandGithub size={14} />}
|
||||
</a>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{authorGithubUsername ? `@${authorGithubUsername} · ` : ""}
|
||||
Published {formatRelativeTime(submission.created)}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">Published {formatRelativeTime(submission.created)}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -696,12 +723,12 @@ const MarketplaceDetail = ({ workshopUrl }: { workshopUrl: string }) => {
|
||||
<CustomWidgetCodeExample
|
||||
id={`workshop-${submission.id}`}
|
||||
label="widget.json"
|
||||
code={submission.content}
|
||||
code={displayedContent}
|
||||
language="json"
|
||||
height="min(58vh, 640px)"
|
||||
/>
|
||||
) : (
|
||||
<CodeBlock content={submission.content} language={contentLanguages[submission.type]} />
|
||||
<CodeBlock content={displayedContent} language={contentLanguages[submission.type]} />
|
||||
)}
|
||||
</section>
|
||||
|
||||
@@ -863,7 +890,7 @@ const MarketplaceDetail = ({ workshopUrl }: { workshopUrl: string }) => {
|
||||
<DialogHeader>
|
||||
<DialogTitle>Report submission</DialogTitle>
|
||||
<DialogDescription>
|
||||
Only Workshop administrators can review report details and reporter identities.
|
||||
Only Workshop moderators can review report details and reporter identities.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
{reportError && (
|
||||
@@ -880,6 +907,7 @@ const MarketplaceDetail = ({ workshopUrl }: { workshopUrl: string }) => {
|
||||
<SelectValue>{(value) => String(value).replace(/^./u, (letter) => letter.toUpperCase())}</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent align="start">
|
||||
<SelectItem value="outdated">Outdated or no longer working</SelectItem>
|
||||
<SelectItem value="malicious">Malicious</SelectItem>
|
||||
<SelectItem value="spam">Spam</SelectItem>
|
||||
<SelectItem value="copyright">Copyright</SelectItem>
|
||||
@@ -1017,13 +1045,17 @@ export default function MarketplaceDetailPage() {
|
||||
const { siteConfig } = useDocusaurusContext();
|
||||
const configuredWorkshopUrl = (siteConfig.customFields?.workshopUrl as string | undefined) ?? "";
|
||||
|
||||
useEffect(() => {
|
||||
document.documentElement.removeAttribute("data-workshop-detail-loading");
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Layout title="Workshop" description="Community custom CSS and custom widgets for Homarr">
|
||||
<main className="marketplace bg-background text-foreground min-h-[80vh]">
|
||||
<BrowserOnly fallback={<DetailSkeleton />}>
|
||||
{() => (
|
||||
<WorkshopErrorBoundary>
|
||||
<MarketplaceDetail workshopUrl={configuredWorkshopUrl || window.location.origin} />
|
||||
<MarketplaceDetail workshopUrl={getRuntimeWorkshopApiUrl(configuredWorkshopUrl)} />
|
||||
</WorkshopErrorBoundary>
|
||||
)}
|
||||
</BrowserOnly>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
import { IconBrandGithub, IconChevronDown, IconExternalLink, IconLogout } from "@tabler/icons-react";
|
||||
|
||||
import { githubAvatarUrl, githubProfileUrl } from "@homarr/workshop/schema";
|
||||
import type { WorkshopUser } from "@homarr/workshop/schema";
|
||||
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||
@@ -9,10 +10,6 @@ import { cn } from "@/lib/utils";
|
||||
|
||||
const avatarFallback = (name: string) => name.trim().slice(0, 1).toUpperCase() || "?";
|
||||
|
||||
const githubProfileUrl = (user: WorkshopUser) =>
|
||||
user.githubProfileUrl ||
|
||||
(user.githubUsername ? `https://github.com/${encodeURIComponent(user.githubUsername)}` : undefined);
|
||||
|
||||
interface WorkshopAccountMenuProps {
|
||||
user: WorkshopUser;
|
||||
onSignOut: () => void;
|
||||
@@ -21,8 +18,9 @@ interface WorkshopAccountMenuProps {
|
||||
export const WorkshopAccountMenu = ({ user, onSignOut }: WorkshopAccountMenuProps) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
const profileUrl = githubProfileUrl(user);
|
||||
const accountLabel = user.githubUsername ? `@${user.githubUsername}` : user.displayName || "Account";
|
||||
const profileUrl = githubProfileUrl(user.name);
|
||||
const avatarUrl = githubAvatarUrl(user.name);
|
||||
const accountLabel = user.name ? `@${user.name}` : "Account";
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
@@ -54,8 +52,8 @@ export const WorkshopAccountMenu = ({ user, onSignOut }: WorkshopAccountMenuProp
|
||||
onClick={() => setOpen((value) => !value)}
|
||||
>
|
||||
<Avatar className="size-6">
|
||||
{user.avatarUrl && <AvatarImage src={user.avatarUrl} alt="" />}
|
||||
<AvatarFallback className="text-[10px]">{avatarFallback(user.displayName)}</AvatarFallback>
|
||||
{avatarUrl && <AvatarImage src={avatarUrl} alt="" />}
|
||||
<AvatarFallback className="text-[10px]">{avatarFallback(user.name)}</AvatarFallback>
|
||||
</Avatar>
|
||||
<span className="max-w-36 truncate">{accountLabel}</span>
|
||||
<IconChevronDown
|
||||
@@ -73,12 +71,11 @@ export const WorkshopAccountMenu = ({ user, onSignOut }: WorkshopAccountMenuProp
|
||||
>
|
||||
<div className="flex items-center gap-3 px-2 py-2">
|
||||
<Avatar className="size-9">
|
||||
{user.avatarUrl && <AvatarImage src={user.avatarUrl} alt="" />}
|
||||
<AvatarFallback>{avatarFallback(user.displayName)}</AvatarFallback>
|
||||
{avatarUrl && <AvatarImage src={avatarUrl} alt="" />}
|
||||
<AvatarFallback>{avatarFallback(user.name)}</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-sm font-medium">{user.displayName}</p>
|
||||
{user.githubUsername && <p className="truncate text-xs text-muted-foreground">@{user.githubUsername}</p>}
|
||||
<p className="truncate text-sm font-medium">{accountLabel}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
} from "@tabler/icons-react";
|
||||
|
||||
import { WorkshopBackend } from "@homarr/workshop/backend";
|
||||
import { githubAvatarUrl, githubProfileUrl } from "@homarr/workshop/schema";
|
||||
import type { WorkshopReport, WorkshopSubmissionSummary, WorkshopUser } from "@homarr/workshop/schema";
|
||||
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
@@ -52,7 +53,7 @@ export function WorkshopAdmin({ workshopUrl }: { workshopUrl?: string }) {
|
||||
setSubmissions(nextSubmissions);
|
||||
setError("");
|
||||
} catch (cause) {
|
||||
setError(cause instanceof Error ? cause.message : "Unable to load Workshop administration");
|
||||
setError(cause instanceof Error ? cause.message : "Unable to load Workshop moderation");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -108,7 +109,11 @@ export function WorkshopAdmin({ workshopUrl }: { workshopUrl?: string }) {
|
||||
submission,
|
||||
reports: reports.filter((report) => report.submission === submission.id),
|
||||
}))
|
||||
.sort((left, right) => Number(right.reports.length > 0) - Number(left.reports.length > 0));
|
||||
.sort(
|
||||
(left, right) =>
|
||||
right.reports.length - left.reports.length ||
|
||||
Date.parse(right.submission.updated) - Date.parse(left.submission.updated),
|
||||
);
|
||||
|
||||
return (
|
||||
<main className="marketplace mx-auto max-w-6xl px-4 pb-20 text-foreground sm:px-6">
|
||||
@@ -118,7 +123,7 @@ export function WorkshopAdmin({ workshopUrl }: { workshopUrl?: string }) {
|
||||
<IconShield size={18} />
|
||||
<span className="text-sm font-medium">Restricted area</span>
|
||||
</div>
|
||||
<h1 className="text-3xl font-semibold tracking-tight">Workshop administration</h1>
|
||||
<h1 className="text-3xl font-semibold tracking-tight">Workshop moderation</h1>
|
||||
<p className="mt-2 max-w-2xl text-sm text-muted-foreground">
|
||||
Review reports and remove unsafe or outdated submissions. PocketBase rules enforce every action.
|
||||
</p>
|
||||
@@ -145,14 +150,14 @@ export function WorkshopAdmin({ workshopUrl }: { workshopUrl?: string }) {
|
||||
{user && !user.isAdmin && (
|
||||
<Alert variant="destructive" className="mb-5">
|
||||
<IconShield />
|
||||
<AlertTitle>Administrator access required</AlertTitle>
|
||||
<AlertTitle>Workshop moderator access required</AlertTitle>
|
||||
<AlertDescription>This GitHub account cannot access Workshop moderation.</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
{error && (
|
||||
<Alert variant="destructive" className="mb-5">
|
||||
<IconFlag />
|
||||
<AlertTitle>Workshop administration failed</AlertTitle>
|
||||
<AlertTitle>Workshop moderation failed</AlertTitle>
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
@@ -194,18 +199,18 @@ export function WorkshopAdmin({ workshopUrl }: { workshopUrl?: string }) {
|
||||
<CardHeader className="border-b border-border pb-4 sm:grid-cols-[1fr_auto]">
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
<Avatar className="size-10">
|
||||
{submission.authorAvatarUrl && <AvatarImage src={submission.authorAvatarUrl} alt="" />}
|
||||
{submission.authorName && <AvatarImage src={githubAvatarUrl(submission.authorName)} alt="" />}
|
||||
<AvatarFallback>{avatarFallback(submission.authorName)}</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="min-w-0">
|
||||
<CardTitle className="truncate text-base">{submission.title}</CardTitle>
|
||||
<a
|
||||
href={submission.authorGithubProfileUrl || undefined}
|
||||
target={submission.authorGithubProfileUrl ? "_blank" : undefined}
|
||||
href={githubProfileUrl(submission.authorName) || undefined}
|
||||
target={submission.authorName ? "_blank" : undefined}
|
||||
rel="noreferrer"
|
||||
className="text-xs text-muted-foreground hover:text-foreground hover:underline"
|
||||
>
|
||||
{submission.authorName}
|
||||
{submission.authorName || "Community member"}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
@@ -237,7 +242,8 @@ export function WorkshopAdmin({ workshopUrl }: { workshopUrl?: string }) {
|
||||
<div className="min-w-0">
|
||||
<AlertTitle className="col-auto capitalize">{report.category} report</AlertTitle>
|
||||
<p className="m-0 mt-1 text-xs text-destructive/75">
|
||||
Reported by {report.reporterName} · {new Date(report.created).toLocaleString()}
|
||||
Reported by {report.reporterName || "Community member"} ·{" "}
|
||||
{new Date(report.created).toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
|
||||
@@ -8,16 +8,19 @@ import {
|
||||
IconChevronLeft,
|
||||
IconChevronRight,
|
||||
IconEye,
|
||||
IconFlag,
|
||||
IconMessage,
|
||||
IconPackage,
|
||||
IconPlus,
|
||||
IconPuzzle,
|
||||
IconRefresh,
|
||||
IconSearch,
|
||||
IconShield,
|
||||
} from "@tabler/icons-react";
|
||||
|
||||
import type { WorkshopSubmission } from "@site/src/lib/pocketbase";
|
||||
import type { SubmissionType } from "@site/src/lib/workshop-schema";
|
||||
import { githubAvatarUrl, githubProfileUrl } from "@homarr/workshop/schema";
|
||||
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||
@@ -115,6 +118,7 @@ export const WorkshopApp = ({ workshopUrl }: { workshopUrl: string }) => {
|
||||
const availableTypeFilters = workshop.user
|
||||
? [...typeFilters, { value: "yours" as const, label: "Yours" }]
|
||||
: typeFilters;
|
||||
const openReportCount = workshop.submissions.reduce((total, submission) => total + submission.reportCount, 0);
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-7xl px-4 pb-16">
|
||||
@@ -125,9 +129,19 @@ export const WorkshopApp = ({ workshopUrl }: { workshopUrl: string }) => {
|
||||
Discover community-made widgets and CSS. Review the source, then import it into Homarr.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 self-start sm:self-auto">
|
||||
<div className="flex flex-wrap items-center gap-2 self-start sm:self-auto">
|
||||
{workshop.user ? (
|
||||
<>
|
||||
{workshop.user.isAdmin && (
|
||||
<Button
|
||||
variant="outline"
|
||||
className="h-10 sm:h-8"
|
||||
nativeButton={false}
|
||||
render={<a href="/workshop/admin" aria-label={`Open moderation, ${openReportCount} open reports`} />}
|
||||
>
|
||||
<IconShield size={14} /> Moderation{openReportCount > 0 ? ` (${openReportCount})` : ""}
|
||||
</Button>
|
||||
)}
|
||||
<Button className="h-10 sm:h-8" onClick={() => setShowSubmit(true)}>
|
||||
<IconPlus size={14} /> Share yours
|
||||
</Button>
|
||||
@@ -356,16 +370,16 @@ const SubmissionCard = ({ submission, backend, userVote, onVote }: SubmissionCar
|
||||
</div>
|
||||
<CardDescription className="flex min-w-0 items-center gap-1 text-xs">
|
||||
<a
|
||||
href={submission.authorGithubProfileUrl || undefined}
|
||||
target={submission.authorGithubProfileUrl ? "_blank" : undefined}
|
||||
href={githubProfileUrl(submission.authorName) || undefined}
|
||||
target={submission.authorName ? "_blank" : undefined}
|
||||
rel="noreferrer"
|
||||
className="inline-flex min-w-0 items-center gap-1.5 hover:text-foreground"
|
||||
>
|
||||
<Avatar className="size-4">
|
||||
{submission.authorAvatarUrl && <AvatarImage src={submission.authorAvatarUrl} alt="" />}
|
||||
{submission.authorName && <AvatarImage src={githubAvatarUrl(submission.authorName)} alt="" />}
|
||||
<AvatarFallback className="text-[9px]">{avatarFallback(submission.authorName)}</AvatarFallback>
|
||||
</Avatar>
|
||||
<span className="truncate">{submission.authorName}</span>
|
||||
<span className="truncate">{submission.authorName || "Community member"}</span>
|
||||
</a>
|
||||
<span className="shrink-0 whitespace-nowrap">
|
||||
· v{submission.revision} · {formatRelativeTime(submission.created)}
|
||||
@@ -405,9 +419,14 @@ const SubmissionCard = ({ submission, backend, userVote, onVote }: SubmissionCar
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="min-h-[4.5rem] flex-1 overflow-hidden">
|
||||
{submission.outdated && (
|
||||
{(submission.outdated || submission.reportCount > 0) && (
|
||||
<div className="mb-2 flex flex-wrap gap-1.5">
|
||||
<Badge variant="secondary">Outdated</Badge>
|
||||
{submission.outdated && <Badge variant="secondary">Outdated</Badge>}
|
||||
{submission.reportCount > 0 && (
|
||||
<Badge variant="destructive" className="gap-1">
|
||||
<IconFlag size={12} /> {submission.reportCount} {submission.reportCount === 1 ? "report" : "reports"}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{submission.description && (
|
||||
|
||||
@@ -162,7 +162,7 @@ export const useWorkshop = (workshopUrl: string) => {
|
||||
const report = useCallback(
|
||||
async (
|
||||
submissionId: string,
|
||||
category: "malicious" | "spam" | "copyright" | "inappropriate" | "other",
|
||||
category: "outdated" | "malicious" | "spam" | "copyright" | "inappropriate" | "other",
|
||||
explanation: string,
|
||||
) => {
|
||||
try {
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import { afterEach, describe, expect, test } from "vitest";
|
||||
|
||||
import { getRuntimeWorkshopApiUrl } from "./runtime-config";
|
||||
|
||||
describe("Workshop runtime configuration", () => {
|
||||
afterEach(() => {
|
||||
delete window.homarrRuntimeConfig;
|
||||
});
|
||||
|
||||
test("prefers the URL supplied when the container starts", () => {
|
||||
window.homarrRuntimeConfig = { workshopApiUrl: "https://v2.preview.homarr.dev" };
|
||||
|
||||
expect(getRuntimeWorkshopApiUrl("https://homarr.dev")).toBe("https://v2.preview.homarr.dev");
|
||||
});
|
||||
|
||||
test("keeps the build-time URL as a fallback", () => {
|
||||
expect(getRuntimeWorkshopApiUrl("https://homarr.dev")).toBe("https://homarr.dev");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,16 @@
|
||||
interface HomarrRuntimeConfig {
|
||||
homarrWebsiteUrl?: string;
|
||||
workshopApiUrl?: string;
|
||||
workshopWebUrl?: string;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
homarrRuntimeConfig?: Readonly<HomarrRuntimeConfig>;
|
||||
}
|
||||
}
|
||||
|
||||
export const getRuntimeWorkshopApiUrl = (configuredUrl: string): string => {
|
||||
if (typeof window === "undefined") return configuredUrl;
|
||||
return window.homarrRuntimeConfig?.workshopApiUrl || configuredUrl || window.location.origin;
|
||||
};
|
||||
@@ -3,13 +3,14 @@ import useDocusaurusContext from "@docusaurus/useDocusaurusContext";
|
||||
import Layout from "@theme/Layout";
|
||||
|
||||
import { WorkshopAdmin } from "@site/src/components/workshop/WorkshopAdmin";
|
||||
import { getRuntimeWorkshopApiUrl } from "@site/src/lib/runtime-config";
|
||||
|
||||
export default function WorkshopAdminRoute() {
|
||||
const { siteConfig } = useDocusaurusContext();
|
||||
const workshopUrl = (siteConfig.customFields?.workshopUrl as string | undefined) ?? "";
|
||||
return (
|
||||
<Layout title="Workshop administration" noFooter>
|
||||
<WorkshopAdmin workshopUrl={workshopUrl} />
|
||||
<Layout title="Workshop moderation" noFooter>
|
||||
<WorkshopAdmin workshopUrl={getRuntimeWorkshopApiUrl(workshopUrl)} />
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import Layout from "@theme/Layout";
|
||||
|
||||
import { WorkshopApp, WorkshopListingFallback } from "@site/src/components/workshop/WorkshopApp";
|
||||
import { WorkshopErrorBoundary } from "@site/src/components/workshop/WorkshopErrorBoundary";
|
||||
import { getRuntimeWorkshopApiUrl } from "@site/src/lib/runtime-config";
|
||||
|
||||
export default function WorkshopPage() {
|
||||
const { siteConfig } = useDocusaurusContext();
|
||||
@@ -16,7 +17,7 @@ export default function WorkshopPage() {
|
||||
<BrowserOnly fallback={<WorkshopListingFallback />}>
|
||||
{() => (
|
||||
<WorkshopErrorBoundary>
|
||||
<WorkshopApp workshopUrl={configuredWorkshopUrl || window.location.origin} />
|
||||
<WorkshopApp workshopUrl={getRuntimeWorkshopApiUrl(configuredWorkshopUrl)} />
|
||||
</WorkshopErrorBoundary>
|
||||
)}
|
||||
</BrowserOnly>
|
||||
|
||||
+4
-5
@@ -310,8 +310,7 @@
|
||||
{
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string",
|
||||
"pattern": "^(?!javascript)[a-zA-Z]*:\\/\\/"
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
@@ -873,8 +872,7 @@
|
||||
{
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string",
|
||||
"pattern": "^(?!javascript)[a-zA-Z]*:\\/\\/"
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
@@ -2153,7 +2151,8 @@
|
||||
"beszelAlerts",
|
||||
"beszelSystemStats",
|
||||
"traefik",
|
||||
"customApi"
|
||||
"customApi",
|
||||
"wud"
|
||||
]
|
||||
},
|
||||
"options": {
|
||||
|
||||
+1
@@ -0,0 +1 @@
|
||||
globalThis.homarrRuntimeConfig ??= Object.freeze({});
|
||||
@@ -20,5 +20,5 @@
|
||||
}
|
||||
},
|
||||
"include": ["**/*.ts", "**/*.jsx", "**/*.js", "**/*.tsx", "**/*.md", "**/*.mdx"],
|
||||
"exclude": ["node_modules"]
|
||||
"exclude": ["build", "node_modules"]
|
||||
}
|
||||
+37
-17
@@ -1,28 +1,48 @@
|
||||
# Create T3 App
|
||||
# Homarr web application
|
||||
|
||||
This is a [T3 Stack](https://create.t3.gg/) project bootstrapped with `create-t3-app`.
|
||||
This workspace contains Homarr's Next.js application. It consumes the shared API, authentication, database, board,
|
||||
widget, translation, and UI packages from this monorepo.
|
||||
|
||||
## What's next? How do I make an app with this?
|
||||
Run commands from the repository root:
|
||||
|
||||
We try to keep this project as simple as possible, so you can start with just the scaffolding we set up for you, and add additional things later when they become necessary.
|
||||
```sh
|
||||
pnpm install --frozen-lockfile
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
If you are not familiar with the different technologies used in this project, please refer to the respective docs. If you still are in the wind, please join our [Discord](https://t3.gg/discord) and ask for help.
|
||||
Keep infrastructure running in the first terminal:
|
||||
|
||||
- [Next.js](https://nextjs.org)
|
||||
- [NextAuth.js](https://next-auth.js.org)
|
||||
- [Drizzle](https://orm.drizzle.team)
|
||||
- [Tailwind CSS](https://tailwindcss.com)
|
||||
- [tRPC](https://trpc.io)
|
||||
```sh
|
||||
pnpm docker:dev
|
||||
```
|
||||
|
||||
## Learn More
|
||||
Apply migrations and start the application in a second terminal:
|
||||
|
||||
To learn more about the [T3 Stack](https://create.t3.gg/), take a look at the following resources:
|
||||
```sh
|
||||
pnpm db:migration:sqlite:run
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
- [Documentation](https://create.t3.gg/)
|
||||
- [Learn the T3 Stack](https://create.t3.gg/en/faq#what-learning-resources-are-currently-available) — Check out these awesome tutorials
|
||||
The app listens on `http://127.0.0.1:3000`. Configure an absolute writable `DB_URL`, `AUTH_SECRET`, and
|
||||
`SECRET_ENCRYPTION_KEY` in `.env` before first startup.
|
||||
|
||||
You can check out the [create-t3-app GitHub repository](https://github.com/t3-oss/create-t3-app) — your feedback and contributions are welcome!
|
||||
To test Custom Widgets against a local Community Workshop, start PocketBase and the docs in separate terminals:
|
||||
|
||||
## How do I deploy this?
|
||||
```sh
|
||||
pnpm docker:workshop
|
||||
WORKSHOP_API_URL=http://127.0.0.1:8090 pnpm dev:docs
|
||||
```
|
||||
|
||||
Follow our deployment guides for [Vercel](https://create.t3.gg/en/deployment/vercel) and [Docker](https://create.t3.gg/en/deployment/docker) for more information.
|
||||
Then set:
|
||||
|
||||
```dotenv
|
||||
HOMARR_WEBSITE_URL=http://127.0.0.1:3003
|
||||
WORKSHOP_API_URL=http://127.0.0.1:8090
|
||||
WORKSHOP_WEB_URL=http://127.0.0.1:3003/workshop
|
||||
```
|
||||
|
||||
Server-rendered runtime configuration exposes those URLs to the browser, so changing a production container's
|
||||
environment does not require rebuilding the Next.js bundle.
|
||||
|
||||
See [`apps/docs/docs/advanced/development/getting-started.mdx`](../docs/docs/advanced/development/getting-started.mdx)
|
||||
for the full contributor setup.
|
||||
+22
-11
@@ -1,4 +1,6 @@
|
||||
// Importing env files here to validate on build
|
||||
import path from "node:path";
|
||||
|
||||
import "@homarr/auth/env";
|
||||
import "@homarr/core/infrastructure/db/env";
|
||||
import "@homarr/common/env";
|
||||
@@ -13,7 +15,21 @@ const withNextIntl = createNextIntlPlugin({
|
||||
requestConfig: "../../packages/translation/src/request.ts",
|
||||
});
|
||||
|
||||
const getDevelopmentServiceAliases = () => {
|
||||
if (process.env.NODE_ENV !== "development") {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
"@homarr/tasks": "./src/instrumentation-noop.ts",
|
||||
"@homarr/websocket": "./src/instrumentation-noop.ts",
|
||||
};
|
||||
};
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
// Next previews otherwise create agent instruction files in the application
|
||||
// directory during development.
|
||||
agentRules: false,
|
||||
env: {
|
||||
HOMARR_VERSION: process.env.HOMARR_VERSION ?? "unknown",
|
||||
},
|
||||
@@ -30,19 +46,14 @@ const nextConfig: NextConfig = {
|
||||
serverExternalPackages: ["dockerode", "isomorphic-dompurify", "jsdom", "better-sqlite3"],
|
||||
experimental: {
|
||||
optimizePackageImports: ["@mantine/core", "@mantine/hooks", "@tabler/icons-react"],
|
||||
turbopackFileSystemCacheForDev: true,
|
||||
webpackMemoryOptimizations: true,
|
||||
turbopackFileSystemCacheForBuild: true,
|
||||
useTypeScriptCli: true,
|
||||
},
|
||||
turbopack: {
|
||||
// ponytail: known Turbopack NFT warning from path.join(process.cwd(), …) in
|
||||
// src/app/api/backup/{route,shared}.ts. No working placement in 16.2.x
|
||||
// (see vercel/next.js#95125). Suppress until upstream fix lands.
|
||||
ignoreIssue: [
|
||||
{
|
||||
path: "**/*",
|
||||
title: "Encountered unexpected file in NFT list",
|
||||
},
|
||||
],
|
||||
root: path.resolve(import.meta.dirname, "../.."),
|
||||
// Development runs tasks and WebSocket as separate processes. These aliases
|
||||
// keep their production-only instrumentation imports out of the dev graph.
|
||||
resolveAlias: getDevelopmentServiceAliases(),
|
||||
},
|
||||
transpilePackages: ["@homarr/ui", "@homarr/notifications", "@homarr/modals", "@homarr/spotlight", "@homarr/widgets"],
|
||||
images: {
|
||||
|
||||
@@ -62,11 +62,10 @@
|
||||
"@modelcontextprotocol/sdk": "catalog:",
|
||||
"@scalar/api-reference-react": "catalog:",
|
||||
"@tabler/icons-react": "catalog:",
|
||||
"@tanstack/query-async-storage-persister": "catalog:",
|
||||
"@tanstack/react-devtools": "catalog:",
|
||||
"@tanstack/react-query": "catalog:",
|
||||
"@tanstack/react-query-devtools": "catalog:",
|
||||
"@tanstack/react-query-next-experimental": "catalog:",
|
||||
"@tanstack/react-query-persist-client": "catalog:",
|
||||
"@trpc/client": "catalog:",
|
||||
"@trpc/next": "catalog:",
|
||||
"@trpc/react-query": "catalog:",
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { TanStackDevtools } from "@tanstack/react-devtools";
|
||||
import type { TanStackDevtoolsReactPlugin } from "@tanstack/react-devtools";
|
||||
import type { QueryClient } from "@tanstack/react-query";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { ReactQueryDevtoolsPanel } from "@tanstack/react-query-devtools";
|
||||
|
||||
const valueStyle = { fontFamily: "monospace", overflowWrap: "anywhere" } as const;
|
||||
const metricStyle = {
|
||||
background: "color-mix(in srgb, currentColor 4%, transparent)",
|
||||
border: "1px solid color-mix(in srgb, currentColor 18%, transparent)",
|
||||
borderRadius: 8,
|
||||
padding: 12,
|
||||
} as const;
|
||||
|
||||
const readDiagnostics = (queryClient: QueryClient) => {
|
||||
const queries = queryClient.getQueryCache().getAll();
|
||||
const mutations = queryClient.getMutationCache().getAll();
|
||||
const board = document.querySelector("[data-homarr-dev-benchmark-board]");
|
||||
const widgetItems = board?.querySelectorAll('[data-type="item"]').length ?? 0;
|
||||
const readyWidgets = board?.querySelectorAll("[data-homarr-widget-ready]").length ?? 0;
|
||||
const resources = performance.getEntriesByType("resource") as PerformanceResourceTiming[];
|
||||
|
||||
return {
|
||||
active: queries.filter((query) => query.getObserversCount() > 0).length,
|
||||
errors: queries.filter((query) => query.state.status === "error").length,
|
||||
fetching: queries.filter((query) => query.state.fetchStatus === "fetching").length,
|
||||
pendingMutations: mutations.filter((mutation) => mutation.state.status === "pending").length,
|
||||
stale: queries.filter((query) => query.isStale()).length,
|
||||
total: queries.length,
|
||||
readyWidgets,
|
||||
widgetItems,
|
||||
scriptKiB: Math.round(
|
||||
resources
|
||||
.filter((resource) => resource.initiatorType === "script")
|
||||
.reduce((total, resource) => total + resource.decodedBodySize, 0) / 1024,
|
||||
),
|
||||
};
|
||||
};
|
||||
|
||||
const Metric = ({ label, value }: { label: string; value: number | string }) => (
|
||||
<div style={metricStyle}>
|
||||
<div style={{ fontSize: 12, opacity: 0.7 }}>{label}</div>
|
||||
<output style={{ ...valueStyle, display: "block", fontSize: 22, fontWeight: 600, marginTop: 4 }}>{value}</output>
|
||||
</div>
|
||||
);
|
||||
|
||||
const HomarrDevtoolsPanel = ({ active }: { active: boolean }) => {
|
||||
const pathname = usePathname();
|
||||
const queryClient = useQueryClient();
|
||||
const [diagnostics, setDiagnostics] = useState(() => readDiagnostics(queryClient));
|
||||
|
||||
useEffect(() => {
|
||||
if (!active) return;
|
||||
|
||||
const update = () => setDiagnostics(readDiagnostics(queryClient));
|
||||
let frame: number | undefined;
|
||||
const scheduleUpdate = () => {
|
||||
if (frame !== undefined) return;
|
||||
frame = window.requestAnimationFrame(() => {
|
||||
frame = undefined;
|
||||
update();
|
||||
});
|
||||
};
|
||||
const unsubscribeQueries = queryClient.getQueryCache().subscribe(scheduleUpdate);
|
||||
const unsubscribeMutations = queryClient.getMutationCache().subscribe(scheduleUpdate);
|
||||
const observer = new MutationObserver(scheduleUpdate);
|
||||
observer.observe(document.body, { childList: true, subtree: true });
|
||||
update();
|
||||
|
||||
return () => {
|
||||
unsubscribeQueries();
|
||||
unsubscribeMutations();
|
||||
observer.disconnect();
|
||||
if (frame !== undefined) window.cancelAnimationFrame(frame);
|
||||
};
|
||||
}, [active, pathname, queryClient]);
|
||||
|
||||
if (!active) return null;
|
||||
|
||||
return (
|
||||
<section data-homarr-devtools-panel style={{ fontFamily: "system-ui, sans-serif", padding: 16 }}>
|
||||
<header style={{ marginBottom: 16 }}>
|
||||
<h2 style={{ margin: 0 }}>Homarr performance</h2>
|
||||
<p style={{ marginBlock: "4px 0", opacity: 0.7 }}>Live state for {pathname}</p>
|
||||
</header>
|
||||
<div style={{ display: "grid", gap: 8, gridTemplateColumns: "repeat(auto-fit, minmax(110px, 1fr))" }}>
|
||||
<Metric label="Queries" value={diagnostics.total} />
|
||||
<Metric label="Active" value={diagnostics.active} />
|
||||
<Metric label="Fetching" value={diagnostics.fetching} />
|
||||
<Metric label="Stale" value={diagnostics.stale} />
|
||||
<Metric label="Errors" value={diagnostics.errors} />
|
||||
<Metric label="Mutations" value={diagnostics.pendingMutations} />
|
||||
<Metric label="Widgets ready" value={`${diagnostics.readyWidgets}/${diagnostics.widgetItems}`} />
|
||||
<Metric label="Script KiB" value={diagnostics.scriptKiB} />
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
const developmentToolsPlugins = [
|
||||
{
|
||||
id: "tanstack-query",
|
||||
name: "TanStack Query",
|
||||
render: () => <ReactQueryDevtoolsPanel />,
|
||||
},
|
||||
{
|
||||
id: "homarr-performance",
|
||||
name: "Homarr",
|
||||
defaultOpen: true,
|
||||
render: (_element, { devtoolsOpen }) => <HomarrDevtoolsPanel active={devtoolsOpen} />,
|
||||
},
|
||||
] satisfies TanStackDevtoolsReactPlugin[];
|
||||
|
||||
export const DevelopmentTools = () => (
|
||||
<TanStackDevtools config={{ position: "bottom-right", hideUntilHover: true }} plugins={developmentToolsPlugins} />
|
||||
);
|
||||
@@ -1,32 +0,0 @@
|
||||
import { createAsyncStoragePersister } from "@tanstack/query-async-storage-persister";
|
||||
import superjson from "superjson";
|
||||
|
||||
import { fetchApi } from "@homarr/api/client";
|
||||
import { getActiveQueryCacheBoardId, queryCacheStoragePrefix } from "@homarr/api/query-cache";
|
||||
|
||||
const queryCacheStorage = {
|
||||
getItem: (_key: string) => null as string | null,
|
||||
async setItem(_key: string, value: string) {
|
||||
const boardId = getActiveQueryCacheBoardId();
|
||||
if (!boardId) return;
|
||||
await fetchApi.queryCache.setItem.mutate({ boardId, value }).catch((error) => {
|
||||
console.warn("[query-cache] persist failed", error);
|
||||
});
|
||||
},
|
||||
async removeItem(_key: string) {
|
||||
const boardId = getActiveQueryCacheBoardId();
|
||||
if (!boardId) return;
|
||||
await fetchApi.queryCache.removeItem.mutate({ boardId }).catch((error) => {
|
||||
console.warn("[query-cache] remove failed", error);
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
export const createWidgetQueryPersister = () =>
|
||||
createAsyncStoragePersister({
|
||||
storage: typeof window === "undefined" ? undefined : queryCacheStorage,
|
||||
key: queryCacheStoragePrefix,
|
||||
throttleTime: 2000,
|
||||
serialize: (data) => superjson.stringify(data),
|
||||
deserialize: (data) => superjson.parse(data),
|
||||
});
|
||||
@@ -0,0 +1,27 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { TRPCClientError } from "@trpc/client";
|
||||
|
||||
import { createQueryRetry } from "./query-retry";
|
||||
|
||||
const createTrpcError = (code: string) => {
|
||||
const error = Object.assign(new Error(code), { data: { code } });
|
||||
Object.setPrototypeOf(error, TRPCClientError.prototype);
|
||||
return error;
|
||||
};
|
||||
|
||||
describe("createQueryRetry", () => {
|
||||
it.each(["BAD_REQUEST", "UNAUTHORIZED", "FORBIDDEN", "NOT_FOUND", "UNPROCESSABLE_CONTENT"])(
|
||||
"does not retry the permanent %s response",
|
||||
(code) => {
|
||||
expect(createQueryRetry(3)(0, createTrpcError(code))).toBe(false);
|
||||
},
|
||||
);
|
||||
|
||||
it("retries transient and network failures only up to the configured limit", () => {
|
||||
const retry = createQueryRetry(3);
|
||||
|
||||
expect(retry(0, createTrpcError("INTERNAL_SERVER_ERROR"))).toBe(true);
|
||||
expect(retry(2, new Error("connection reset"))).toBe(true);
|
||||
expect(retry(3, new Error("connection reset"))).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,20 @@
|
||||
import { TRPCClientError } from "@trpc/client";
|
||||
|
||||
const nonRetryableTrpcCodes = new Set([
|
||||
"PARSE_ERROR",
|
||||
"BAD_REQUEST",
|
||||
"UNAUTHORIZED",
|
||||
"FORBIDDEN",
|
||||
"NOT_FOUND",
|
||||
"METHOD_NOT_SUPPORTED",
|
||||
"CONFLICT",
|
||||
"PRECONDITION_FAILED",
|
||||
"PAYLOAD_TOO_LARGE",
|
||||
"UNPROCESSABLE_CONTENT",
|
||||
]);
|
||||
|
||||
export const createQueryRetry = (maxRetries: number) => (failureCount: number, error: unknown) => {
|
||||
if (failureCount >= maxRetries) return false;
|
||||
if (error instanceof TRPCClientError && error.data?.code && nonRetryableTrpcCodes.has(error.data.code)) return false;
|
||||
return true;
|
||||
};
|
||||
@@ -0,0 +1,52 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { act, createElement } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { getSessionQueryScope, SessionQueryScopeGuard } from "./session-query-scope";
|
||||
|
||||
describe("getSessionQueryScope", () => {
|
||||
it("is stable when permission order changes", () => {
|
||||
expect(getSessionQueryScope({ user: { id: "user-a", permissions: ["board-view", "integration-use"] } })).toBe(
|
||||
getSessionQueryScope({ user: { id: "user-a", permissions: ["integration-use", "board-view"] } }),
|
||||
);
|
||||
});
|
||||
|
||||
it("changes for a different user or permission set", () => {
|
||||
const original = getSessionQueryScope({ user: { id: "user-a", permissions: ["board-view"] } });
|
||||
|
||||
expect(getSessionQueryScope({ user: { id: "user-b", permissions: ["board-view"] } })).not.toBe(original);
|
||||
expect(getSessionQueryScope({ user: { id: "user-a", permissions: [] } })).not.toBe(original);
|
||||
expect(getSessionQueryScope(null)).toBeNull();
|
||||
});
|
||||
|
||||
it("removes the previous session subtree before requesting a document reload", async () => {
|
||||
const host = document.createElement("div");
|
||||
const root = createRoot(host);
|
||||
const onScopeChange = vi.fn();
|
||||
const renderScope = async (currentScope: string) => {
|
||||
await act(async () => {
|
||||
root.render(
|
||||
createElement(
|
||||
SessionQueryScopeGuard,
|
||||
{ currentScope, initialScope: "a", onScopeChange },
|
||||
createElement("span", null, "session-a-secret"),
|
||||
),
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
try {
|
||||
await renderScope("a");
|
||||
expect(host.textContent).toBe("session-a-secret");
|
||||
|
||||
await renderScope("b");
|
||||
expect(host.textContent).not.toContain("session-a-secret");
|
||||
expect(host.querySelector('[role="status"]')?.getAttribute("aria-busy")).toBe("true");
|
||||
expect(onScopeChange).toHaveBeenCalledOnce();
|
||||
} finally {
|
||||
await act(async () => root.unmount());
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
import type { PropsWithChildren } from "react";
|
||||
import { createElement, useEffect } from "react";
|
||||
|
||||
interface QuerySession {
|
||||
user: {
|
||||
id: string;
|
||||
permissions: string[];
|
||||
};
|
||||
}
|
||||
|
||||
export const getSessionQueryScope = (session: QuerySession | null | undefined) =>
|
||||
session ? JSON.stringify([session.user.id, session.user.permissions.toSorted()]) : null;
|
||||
|
||||
interface SessionQueryScopeGuardProps {
|
||||
initialScope: string | null;
|
||||
currentScope: string | null;
|
||||
onScopeChange: () => void;
|
||||
}
|
||||
|
||||
export const SessionQueryScopeGuard = ({
|
||||
initialScope,
|
||||
currentScope,
|
||||
onScopeChange,
|
||||
children,
|
||||
}: PropsWithChildren<SessionQueryScopeGuardProps>) => {
|
||||
const hasScopeChanged = initialScope !== currentScope;
|
||||
useEffect(() => {
|
||||
if (hasScopeChanged) onScopeChange();
|
||||
}, [hasScopeChanged, onScopeChange]);
|
||||
|
||||
if (!hasScopeChanged) return children;
|
||||
|
||||
return createElement("div", {
|
||||
"aria-busy": true,
|
||||
"aria-label": "Refreshing session",
|
||||
role: "status",
|
||||
style: { minHeight: "100dvh" },
|
||||
});
|
||||
};
|
||||
@@ -1,12 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import type { PropsWithChildren } from "react";
|
||||
import { useState } from "react";
|
||||
import type { QueryKey } from "@tanstack/react-query";
|
||||
import { QueryClient } from "@tanstack/react-query";
|
||||
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import dynamic from "next/dynamic";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { ReactQueryStreamedHydration } from "@tanstack/react-query-next-experimental";
|
||||
import { PersistQueryClientProvider } from "@tanstack/react-query-persist-client";
|
||||
import {
|
||||
createWSClient,
|
||||
httpBatchStreamLink,
|
||||
@@ -25,18 +23,24 @@ import { TRPCClientError } from "@trpc/client";
|
||||
import type { AppRouter } from "@homarr/api";
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import {
|
||||
isPersistableWidgetQueryKey,
|
||||
queryCacheBuster,
|
||||
dashboardSupportingQueryPolicies,
|
||||
queryCacheDefaultGcTimeMs,
|
||||
queryCacheDefaultRefetchIntervalMs,
|
||||
queryCacheDefaultStaleTimeMs,
|
||||
} from "@homarr/api/query-cache";
|
||||
import { createHeadersCallbackForSource, getTrpcUrl } from "@homarr/api/shared";
|
||||
import { useSession } from "@homarr/auth/client";
|
||||
import { env } from "@homarr/common/env";
|
||||
import { showWarningNotification } from "@homarr/notifications";
|
||||
import { widgetImports } from "@homarr/widgets";
|
||||
import { widgetQueryRefetchIntervals } from "@homarr/widgets/refetch-intervals";
|
||||
|
||||
import { createWidgetQueryPersister } from "./query-cache-persister";
|
||||
import { getSessionQueryScope, SessionQueryScopeGuard } from "./session-query-scope";
|
||||
import { createQueryRetry } from "./query-retry";
|
||||
|
||||
const DevelopmentTools =
|
||||
process.env.NODE_ENV === "development"
|
||||
? dynamic(() => import("./development-tools").then(({ DevelopmentTools: Tools }) => Tools), { ssr: false })
|
||||
: () => null;
|
||||
|
||||
const getWebSocketProtocol = () => {
|
||||
if (typeof window === "undefined") {
|
||||
@@ -59,21 +63,46 @@ const constructWebsocketUrl = () => {
|
||||
return `${getWebSocketProtocol()}://${window.location.hostname}:${window.location.port}/websockets`;
|
||||
};
|
||||
|
||||
const wsClient = createWSClient({
|
||||
url: constructWebsocketUrl(),
|
||||
});
|
||||
export function TRPCReactProvider({ children }: PropsWithChildren) {
|
||||
const { data: session } = useSession();
|
||||
const sessionQueryScope = getSessionQueryScope(session);
|
||||
const [initialSessionQueryScope] = useState(() => sessionQueryScope);
|
||||
|
||||
export function TRPCReactProvider(props: PropsWithChildren) {
|
||||
const [persister] = useState(() => createWidgetQueryPersister());
|
||||
return (
|
||||
<SessionQueryScopeGuard
|
||||
initialScope={initialSessionQueryScope}
|
||||
currentScope={sessionQueryScope}
|
||||
onScopeChange={reloadPage}
|
||||
>
|
||||
<ScopedTRPCReactProvider>{children}</ScopedTRPCReactProvider>
|
||||
</SessionQueryScopeGuard>
|
||||
);
|
||||
}
|
||||
|
||||
const reloadPage = () => window.location.reload();
|
||||
|
||||
const ScopedTRPCReactProvider = ({ children }: PropsWithChildren) => {
|
||||
const wsClient = useMemo(
|
||||
() =>
|
||||
createWSClient({
|
||||
url: constructWebsocketUrl(),
|
||||
lazy: { enabled: true, closeMs: 30_000 },
|
||||
}),
|
||||
[],
|
||||
);
|
||||
useEffect(
|
||||
() => () => {
|
||||
void wsClient.close();
|
||||
},
|
||||
[wsClient],
|
||||
);
|
||||
const [queryClient] = useState(() => {
|
||||
const client = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
staleTime: queryCacheDefaultStaleTimeMs,
|
||||
gcTime: queryCacheDefaultGcTimeMs,
|
||||
refetchOnWindowFocus: false,
|
||||
refetchOnReconnect: false,
|
||||
retry: 3,
|
||||
retry: createQueryRetry(env.NODE_ENV === "development" ? 1 : 3),
|
||||
},
|
||||
mutations: {
|
||||
onError(error) {
|
||||
@@ -91,23 +120,29 @@ export function TRPCReactProvider(props: PropsWithChildren) {
|
||||
},
|
||||
},
|
||||
});
|
||||
client.setQueryDefaults([["widget"]], { refetchInterval: queryCacheDefaultRefetchIntervalMs });
|
||||
for (const { definition } of Object.values(widgetImports)) {
|
||||
const def = definition as { refetchInterval?: number | null; queryKey?: QueryKey; kind: string };
|
||||
if (def.refetchInterval === undefined) continue;
|
||||
const key = def.queryKey ?? [["widget", def.kind]];
|
||||
const interval = def.refetchInterval === null ? false : def.refetchInterval * 1000;
|
||||
client.setQueryDefaults(key, { refetchInterval: interval });
|
||||
client.setQueryDefaults([["widget"]], {
|
||||
refetchInterval: queryCacheDefaultRefetchIntervalMs,
|
||||
refetchOnWindowFocus: false,
|
||||
refetchOnReconnect: false,
|
||||
});
|
||||
for (const { queryKey, intervalSeconds } of widgetQueryRefetchIntervals) {
|
||||
client.setQueryDefaults(queryKey, {
|
||||
refetchInterval: intervalSeconds === null ? false : intervalSeconds * 1000,
|
||||
});
|
||||
}
|
||||
for (const { queryKey, ...policy } of dashboardSupportingQueryPolicies) {
|
||||
client.setQueryDefaults(queryKey, policy);
|
||||
}
|
||||
return client;
|
||||
});
|
||||
|
||||
const [trpcClient] = useState(() => {
|
||||
useEffect(() => () => queryClient.clear(), [queryClient]);
|
||||
|
||||
const trpcClient = useMemo(() => {
|
||||
return clientApi.createClient({
|
||||
links: [
|
||||
loggerLink({
|
||||
enabled: (opts) =>
|
||||
env.NODE_ENV === "development" || (opts.direction === "down" && opts.result instanceof Error),
|
||||
enabled: (opts) => opts.direction === "down" && opts.result instanceof Error,
|
||||
}),
|
||||
splitLink({
|
||||
condition: ({ type }) => type === "subscription",
|
||||
@@ -147,25 +182,14 @@ export function TRPCReactProvider(props: PropsWithChildren) {
|
||||
}),
|
||||
],
|
||||
});
|
||||
});
|
||||
}, [wsClient]);
|
||||
|
||||
return (
|
||||
<clientApi.Provider client={trpcClient} queryClient={queryClient}>
|
||||
<PersistQueryClientProvider
|
||||
client={queryClient}
|
||||
persistOptions={{
|
||||
persister,
|
||||
buster: queryCacheBuster,
|
||||
maxAge: queryCacheDefaultGcTimeMs,
|
||||
dehydrateOptions: {
|
||||
shouldDehydrateQuery: (query) =>
|
||||
query.state.status === "success" && isPersistableWidgetQueryKey(query.queryKey),
|
||||
},
|
||||
}}
|
||||
>
|
||||
<ReactQueryStreamedHydration transformer={superjson}>{props.children}</ReactQueryStreamedHydration>
|
||||
<ReactQueryDevtools initialIsOpen={false} />
|
||||
</PersistQueryClientProvider>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ReactQueryStreamedHydration transformer={superjson}>{children}</ReactQueryStreamedHydration>
|
||||
{process.env.NODE_ENV === "development" && <DevelopmentTools />}
|
||||
</QueryClientProvider>
|
||||
</clientApi.Provider>
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -1,9 +1,9 @@
|
||||
import { api } from "@homarr/api/server";
|
||||
import { getHomeBoardAsync } from "@homarr/api/board-server";
|
||||
|
||||
import { createBoardContentPage } from "../_creator";
|
||||
|
||||
export default createBoardContentPage<{ locale: string }>({
|
||||
async getInitialBoardAsync() {
|
||||
return await api.board.getHomeBoard();
|
||||
return await getHomeBoardAsync();
|
||||
},
|
||||
});
|
||||
@@ -1,9 +1,9 @@
|
||||
import { api } from "@homarr/api/server";
|
||||
import { getBoardByNameAsync } from "@homarr/api/board-server";
|
||||
|
||||
import { createBoardContentPage } from "../../_creator";
|
||||
|
||||
export default createBoardContentPage<{ locale: string; name: string }>({
|
||||
async getInitialBoardAsync({ name }) {
|
||||
return await api.board.getBoardByName({ name });
|
||||
return await getBoardByNameAsync(name);
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,86 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback } from "react";
|
||||
import { Group, Menu } from "@mantine/core";
|
||||
import { IconBox, IconBoxAlignTop, IconChevronDown, IconPlug, IconPlus, IconResize } from "@tabler/icons-react";
|
||||
|
||||
import { useSession } from "@homarr/auth/client";
|
||||
import { useRequiredBoard } from "@homarr/boards/context";
|
||||
import { useModalAction } from "@homarr/modals";
|
||||
import { AppSelectModal } from "@homarr/modals-collection";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
|
||||
import { useItemActions } from "~/components/board/items/item-actions";
|
||||
import { ItemSelectModal } from "~/components/board/items/item-select-modal";
|
||||
import { useCategoryActions } from "~/components/board/sections/category/category-actions";
|
||||
import { CategoryEditModal } from "~/components/board/sections/category/category-edit-modal";
|
||||
import { useDynamicSectionActions } from "~/components/board/sections/dynamic/dynamic-actions";
|
||||
import { IntegrationSelectModal } from "~/components/integration/integration-select-modal";
|
||||
import { HeaderButton } from "~/components/layout/header/button";
|
||||
|
||||
export const BoardAddMenu = () => {
|
||||
const { data: session } = useSession();
|
||||
const board = useRequiredBoard();
|
||||
const { openModal: openCategoryEditModal } = useModalAction(CategoryEditModal);
|
||||
const { openModal: openItemSelectModal } = useModalAction(ItemSelectModal);
|
||||
const { openModal: openAppSelectModal } = useModalAction(AppSelectModal);
|
||||
const { openModal: openIntegrationSelectModal } = useModalAction(IntegrationSelectModal);
|
||||
const { addCategoryToEnd } = useCategoryActions();
|
||||
const { addDynamicSection } = useDynamicSectionActions();
|
||||
const { createItem } = useItemActions();
|
||||
const t = useI18n();
|
||||
|
||||
const handleAddCategory = useCallback(
|
||||
() =>
|
||||
openCategoryEditModal(
|
||||
{
|
||||
category: { id: "new", name: "" },
|
||||
onSuccess({ name }) {
|
||||
addCategoryToEnd({ name });
|
||||
},
|
||||
submitLabel: t("section.category.create.submit"),
|
||||
},
|
||||
{ title: (translate) => translate("section.category.create.title") },
|
||||
),
|
||||
[addCategoryToEnd, openCategoryEditModal, t],
|
||||
);
|
||||
|
||||
return (
|
||||
<Menu position="bottom-end">
|
||||
<Menu.Target>
|
||||
<HeaderButton w="auto" px={4} aria-label={t("board.action.addContent")}>
|
||||
<Group gap={4} wrap="nowrap">
|
||||
<IconPlus stroke={1.5} />
|
||||
<IconChevronDown color="gray" size={16} />
|
||||
</Group>
|
||||
</HeaderButton>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown style={{ transform: "translate(-3px, 0)" }}>
|
||||
<Menu.Item leftSection={<IconResize size={20} />} onClick={() => openItemSelectModal({ boardId: board.id })}>
|
||||
{t("item.action.create")}
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
leftSection={<IconBox size={20} />}
|
||||
onClick={() =>
|
||||
openAppSelectModal({
|
||||
onSelect: (app) => createItem({ kind: "app", options: { appId: app.id } }),
|
||||
withCreate: session?.user.permissions.includes("app-create") ?? false,
|
||||
})
|
||||
}
|
||||
>
|
||||
{t("app.action.add")}
|
||||
</Menu.Item>
|
||||
<Menu.Item leftSection={<IconPlug size={20} />} onClick={() => openIntegrationSelectModal({})}>
|
||||
{t("integration.action.create")}
|
||||
</Menu.Item>
|
||||
<Menu.Divider />
|
||||
<Menu.Item leftSection={<IconBoxAlignTop size={20} />} onClick={handleAddCategory}>
|
||||
{t("section.category.action.create")}
|
||||
</Menu.Item>
|
||||
<Menu.Item leftSection={<IconResize size={20} />} onClick={addDynamicSection}>
|
||||
{t("section.dynamic.action.create")}
|
||||
</Menu.Item>
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
);
|
||||
};
|
||||
@@ -14,10 +14,10 @@ export const ClientBoard = () => {
|
||||
|
||||
const fullWidthSortedSections = board.sections
|
||||
.filter((section) => section.kind === "empty" || section.kind === "category")
|
||||
.sort((sectionA, sectionB) => sectionA.yOffset - sectionB.yOffset);
|
||||
.toSorted((sectionA, sectionB) => sectionA.yOffset - sectionB.yOffset);
|
||||
|
||||
return (
|
||||
<Box h="100%" pos="relative">
|
||||
<Box h="100%" pos="relative" data-homarr-dev-benchmark-board>
|
||||
<BoardBackgroundVideo />
|
||||
<Stack h="100%">
|
||||
{fullWidthSortedSections.map((section) =>
|
||||
|
||||
@@ -1,15 +1,12 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Suspense } from "react";
|
||||
import { cache } from "react";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
|
||||
// Placed here because gridstack styles are used for board content
|
||||
import "~/styles/gridstack.scss";
|
||||
|
||||
import { dehydrate, HydrationBoundary } from "@tanstack/react-query";
|
||||
import type { PersistedClient } from "@tanstack/react-query-persist-client";
|
||||
import superjson from "superjson";
|
||||
import { queryCacheBuster } from "@homarr/api/query-cache";
|
||||
import { getQueryClient } from "@homarr/api/server";
|
||||
import { makeQueryClient } from "@homarr/api/shared";
|
||||
import { IntegrationProvider } from "@homarr/auth/client";
|
||||
import { auth } from "@homarr/auth/next";
|
||||
import { getIntegrationsWithPermissionsAsync } from "@homarr/auth/server";
|
||||
@@ -17,7 +14,6 @@ import { isNullOrWhitespace } from "@homarr/common";
|
||||
import { createLogger } from "@homarr/core/infrastructure/logs";
|
||||
import { ErrorWithMetadata } from "@homarr/core/infrastructure/logs/error";
|
||||
import type { WidgetKind } from "@homarr/definitions";
|
||||
import { getQueryCacheAsync } from "@homarr/redis";
|
||||
import { getI18n } from "@homarr/translation/server";
|
||||
import { prefetchForKindAsync } from "@homarr/widgets/prefetch";
|
||||
|
||||
@@ -29,6 +25,7 @@ import { DynamicClientBoard } from "./_dynamic-client";
|
||||
import { BoardContentHeaderActions } from "./_header-actions";
|
||||
|
||||
const logger = createLogger({ module: "createBoardContentPage" });
|
||||
const getQueryClient = cache(makeQueryClient);
|
||||
|
||||
export type Params = Record<string, unknown>;
|
||||
|
||||
@@ -61,7 +58,6 @@ export const createBoardContentPage = <TParams extends Record<string, unknown>>(
|
||||
}
|
||||
return acc;
|
||||
}, new Map<WidgetKind, Item[]>());
|
||||
|
||||
const [integrations] = await Promise.all([
|
||||
getIntegrationsWithPermissionsAsync(session),
|
||||
...Array.from(itemsMap).map(([kind, items]) =>
|
||||
@@ -77,19 +73,12 @@ export const createBoardContentPage = <TParams extends Record<string, unknown>>(
|
||||
),
|
||||
]);
|
||||
|
||||
const userId = session?.user.id ?? "anonymous";
|
||||
|
||||
return (
|
||||
<>
|
||||
<Suspense>
|
||||
<QueryCacheHydration userId={userId} boardId={board.id} />
|
||||
</Suspense>
|
||||
<HydrationBoundary state={dehydrate(queryClient)}>
|
||||
<IntegrationProvider integrations={integrations}>
|
||||
<DynamicClientBoard />
|
||||
</IntegrationProvider>
|
||||
</HydrationBoundary>
|
||||
</>
|
||||
<HydrationBoundary state={dehydrate(queryClient)}>
|
||||
<IntegrationProvider integrations={integrations}>
|
||||
<DynamicClientBoard />
|
||||
</IntegrationProvider>
|
||||
</HydrationBoundary>
|
||||
);
|
||||
},
|
||||
generateMetadataAsync: async ({ params }: { params: Promise<TParams> }): Promise<Metadata> => {
|
||||
@@ -120,19 +109,3 @@ export const createBoardContentPage = <TParams extends Record<string, unknown>>(
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
async function QueryCacheHydration({ userId, boardId }: { userId: string; boardId: string }) {
|
||||
try {
|
||||
const serialized = await getQueryCacheAsync(userId, boardId);
|
||||
if (!serialized) return null;
|
||||
|
||||
const persisted = superjson.parse<PersistedClient | undefined>(serialized);
|
||||
if (!persisted || persisted.buster !== queryCacheBuster) return null;
|
||||
if (!persisted.clientState?.queries?.length) return null;
|
||||
|
||||
return <HydrationBoundary state={persisted.clientState} />;
|
||||
} catch (error) {
|
||||
logger.warn(new ErrorWithMetadata("Failed to hydrate query cache", { userId, boardId }, { cause: error }));
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,24 @@
|
||||
"use client";
|
||||
|
||||
import dynamic from "next/dynamic";
|
||||
import { SimpleGrid, Skeleton, Stack } from "@mantine/core";
|
||||
|
||||
const BoardLoadingShell = () => (
|
||||
<Stack h="100%" p="md" aria-busy aria-label="Loading dashboard" role="status">
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
|
||||
{[2, 1, 2, 2, 1, 2].map((rows, index) => (
|
||||
<Skeleton
|
||||
key={index}
|
||||
data-homarr-dev-benchmark-board-shell
|
||||
height={rows * 96}
|
||||
radius="var(--mantine-radius-default)"
|
||||
/>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
</Stack>
|
||||
);
|
||||
|
||||
export const DynamicClientBoard = dynamic(() => import("./_client").then((mod) => mod.ClientBoard), {
|
||||
ssr: false,
|
||||
loading: BoardLoadingShell,
|
||||
});
|
||||
@@ -1,45 +1,36 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useRef } from "react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import dynamic from "next/dynamic";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { OnboardingTour } from "@gfazioli/mantine-onboarding-tour";
|
||||
import { Box, Group, Menu, ScrollArea } from "@mantine/core";
|
||||
import { Box, Center, Loader, Menu, ScrollArea } from "@mantine/core";
|
||||
import { useHotkeys } from "@mantine/hooks";
|
||||
import {
|
||||
IconBox,
|
||||
IconBoxAlignTop,
|
||||
IconChevronDown,
|
||||
IconLayoutBoard,
|
||||
IconPencil,
|
||||
IconPencilOff,
|
||||
IconPlug,
|
||||
IconPlus,
|
||||
IconReplace,
|
||||
IconResize,
|
||||
IconSettings,
|
||||
} from "@tabler/icons-react";
|
||||
import { IconLayoutBoard, IconPencil, IconPencilOff, IconPlus, IconReplace, IconSettings } from "@tabler/icons-react";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { useSession } from "@homarr/auth/client";
|
||||
import { useRequiredBoard } from "@homarr/boards/context";
|
||||
import { useEditMode } from "@homarr/boards/edit-mode";
|
||||
import { revalidatePathActionAsync } from "@homarr/common/client";
|
||||
import { env } from "@homarr/common/env";
|
||||
import { hotkeys } from "@homarr/definitions";
|
||||
import { useConfirmModal, useModalAction } from "@homarr/modals";
|
||||
import { AppSelectModal } from "@homarr/modals-collection";
|
||||
import { useConfirmModal } from "@homarr/modals";
|
||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||
import { useI18n, useScopedI18n } from "@homarr/translation/client";
|
||||
import { Link } from "@homarr/ui";
|
||||
|
||||
import { useItemActions } from "~/components/board/items/item-actions";
|
||||
import { ItemSelectModal } from "~/components/board/items/item-select-modal";
|
||||
import { useBoardPermissions } from "~/components/board/permissions/client";
|
||||
import { useCategoryActions } from "~/components/board/sections/category/category-actions";
|
||||
import { CategoryEditModal } from "~/components/board/sections/category/category-edit-modal";
|
||||
import { useDynamicSectionActions } from "~/components/board/sections/dynamic/dynamic-actions";
|
||||
import { IntegrationSelectModal } from "~/components/integration/integration-select-modal";
|
||||
import { HeaderButton } from "~/components/layout/header/button";
|
||||
import { TourTarget } from "~/components/layout/header/tour-target";
|
||||
|
||||
const loadBoardAddMenu = () => import("./_board-add-menu");
|
||||
const preloadBoardAddMenu = () => void loadBoardAddMenu().catch(() => undefined);
|
||||
const BoardAddMenu = dynamic(() => loadBoardAddMenu().then(({ BoardAddMenu: AddMenu }) => AddMenu), {
|
||||
loading: () => (
|
||||
<HeaderButton loading>
|
||||
<IconPlus stroke={1.5} />
|
||||
</HeaderButton>
|
||||
),
|
||||
});
|
||||
|
||||
export const BoardContentHeaderActions = ({ demoReadOnly }: { demoReadOnly: boolean }) => {
|
||||
const [isEditMode] = useEditMode();
|
||||
@@ -52,16 +43,16 @@ export const BoardContentHeaderActions = ({ demoReadOnly }: { demoReadOnly: bool
|
||||
|
||||
return (
|
||||
<>
|
||||
{isEditMode && <AddMenu />}
|
||||
{isEditMode && <BoardAddMenu />}
|
||||
|
||||
<EditModeMenu demoReadOnly={demoReadOnly} />
|
||||
|
||||
{!demoReadOnly && (
|
||||
<OnboardingTour.Target id="board-settings">
|
||||
<TourTarget id="board-settings">
|
||||
<HeaderButton href={`/boards/${board.name}/settings`}>
|
||||
<IconSettings stroke={1.5} />
|
||||
</HeaderButton>
|
||||
</OnboardingTour.Target>
|
||||
</TourTarget>
|
||||
)}
|
||||
|
||||
<SelectBoardsMenu />
|
||||
@@ -69,95 +60,6 @@ export const BoardContentHeaderActions = ({ demoReadOnly }: { demoReadOnly: bool
|
||||
);
|
||||
};
|
||||
|
||||
const AddMenu = () => {
|
||||
const board = useRequiredBoard();
|
||||
const { data: session } = useSession();
|
||||
const { openModal: openCategoryEditModal } = useModalAction(CategoryEditModal);
|
||||
const { openModal: openItemSelectModal } = useModalAction(ItemSelectModal);
|
||||
const { openModal: openAppSelectModal } = useModalAction(AppSelectModal);
|
||||
const { openModal: openIntegrationSelectModal } = useModalAction(IntegrationSelectModal);
|
||||
const { addCategoryToEnd } = useCategoryActions();
|
||||
const { addDynamicSection } = useDynamicSectionActions();
|
||||
const { createItem } = useItemActions();
|
||||
const t = useI18n();
|
||||
|
||||
const handleAddCategory = useCallback(
|
||||
() =>
|
||||
openCategoryEditModal(
|
||||
{
|
||||
category: {
|
||||
id: "new",
|
||||
name: "",
|
||||
},
|
||||
onSuccess({ name }) {
|
||||
addCategoryToEnd({ name });
|
||||
},
|
||||
submitLabel: t("section.category.create.submit"),
|
||||
},
|
||||
{
|
||||
title: (t) => t("section.category.create.title"),
|
||||
},
|
||||
),
|
||||
[addCategoryToEnd, openCategoryEditModal, t],
|
||||
);
|
||||
|
||||
const handleSelectItem = useCallback(() => {
|
||||
openItemSelectModal({ boardId: board.id });
|
||||
}, [board.id, openItemSelectModal]);
|
||||
|
||||
const handleSelectApp = useCallback(() => {
|
||||
openAppSelectModal({
|
||||
onSelect: (app) => {
|
||||
createItem({
|
||||
kind: "app",
|
||||
options: { appId: app.id },
|
||||
});
|
||||
},
|
||||
withCreate: session?.user.permissions.includes("app-create") ?? false,
|
||||
});
|
||||
}, [openAppSelectModal, createItem]);
|
||||
|
||||
const handleAddIntegration = useCallback(() => {
|
||||
openIntegrationSelectModal({});
|
||||
}, [openIntegrationSelectModal]);
|
||||
|
||||
return (
|
||||
<Menu position="bottom-end">
|
||||
<Menu.Target>
|
||||
<HeaderButton w="auto" px={4} aria-label={t("item.action.create")}>
|
||||
<Group gap={4} wrap="nowrap">
|
||||
<IconPlus stroke={1.5} />
|
||||
<IconChevronDown color="gray" size={16} />
|
||||
</Group>
|
||||
</HeaderButton>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown style={{ transform: "translate(-3px, 0)" }}>
|
||||
<Menu.Item leftSection={<IconResize size={20} />} onClick={handleSelectItem}>
|
||||
{t("item.action.create")}
|
||||
</Menu.Item>
|
||||
|
||||
<Menu.Item leftSection={<IconBox size={20} />} onClick={handleSelectApp}>
|
||||
{t("app.action.add")}
|
||||
</Menu.Item>
|
||||
|
||||
<Menu.Item leftSection={<IconPlug size={20} />} onClick={handleAddIntegration}>
|
||||
{t("integration.action.create")}
|
||||
</Menu.Item>
|
||||
|
||||
<Menu.Divider />
|
||||
|
||||
<Menu.Item leftSection={<IconBoxAlignTop size={20} />} onClick={handleAddCategory}>
|
||||
{t("section.category.action.create")}
|
||||
</Menu.Item>
|
||||
|
||||
<Menu.Item leftSection={<IconResize size={20} />} onClick={addDynamicSection}>
|
||||
{t("section.dynamic.action.create")}
|
||||
</Menu.Item>
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
);
|
||||
};
|
||||
|
||||
const EditModeMenu = ({ demoReadOnly }: { demoReadOnly: boolean }) => {
|
||||
const [isEditMode, { open, close }] = useEditMode();
|
||||
const board = useRequiredBoard();
|
||||
@@ -204,25 +106,32 @@ const EditModeMenu = ({ demoReadOnly }: { demoReadOnly: boolean }) => {
|
||||
usePreventLeaveWithDirty(isEditMode);
|
||||
|
||||
return (
|
||||
<OnboardingTour.Target id="board-edit-mode">
|
||||
<TourTarget id="board-edit-mode">
|
||||
<HeaderButton
|
||||
onClick={toggle}
|
||||
loading={isPending}
|
||||
data-testid="board-edit-mode-toggle"
|
||||
aria-label={isEditMode ? commonT("common.action.save") : commonT("common.action.edit")}
|
||||
aria-pressed={isEditMode}
|
||||
onFocus={preloadBoardAddMenu}
|
||||
onPointerEnter={preloadBoardAddMenu}
|
||||
>
|
||||
{isEditMode ? <IconPencilOff stroke={1.5} /> : <IconPencil stroke={1.5} />}
|
||||
</HeaderButton>
|
||||
</OnboardingTour.Target>
|
||||
</TourTarget>
|
||||
);
|
||||
};
|
||||
|
||||
const SelectBoardsMenu = () => {
|
||||
const { data: boards = [] } = clientApi.board.getAllBoards.useQuery();
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const utils = clientApi.useUtils();
|
||||
const { data: boards = [], isPending } = clientApi.board.getAllBoards.useQuery(undefined, { enabled: isOpen });
|
||||
const preloadBoards = () => void utils.board.getAllBoards.prefetch();
|
||||
|
||||
return (
|
||||
<OnboardingTour.Target id="board-switcher">
|
||||
<Box>
|
||||
<Menu position="bottom-end">
|
||||
<TourTarget id="board-switcher">
|
||||
<Box onFocus={preloadBoards} onPointerEnter={preloadBoards}>
|
||||
<Menu position="bottom-end" opened={isOpen} onChange={setIsOpen}>
|
||||
<Menu.Target>
|
||||
<HeaderButton w="auto" px={4}>
|
||||
<IconReplace stroke={1.5} />
|
||||
@@ -230,6 +139,11 @@ const SelectBoardsMenu = () => {
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown style={{ transform: "translate(-7px, 0)" }}>
|
||||
<ScrollArea.Autosize mah={300}>
|
||||
{isPending && (
|
||||
<Center p="xs">
|
||||
<Loader size="xs" />
|
||||
</Center>
|
||||
)}
|
||||
{boards.map((board) => (
|
||||
<Menu.Item
|
||||
key={board.id}
|
||||
@@ -244,7 +158,7 @@ const SelectBoardsMenu = () => {
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
</Box>
|
||||
</OnboardingTour.Target>
|
||||
</TourTarget>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -257,15 +171,13 @@ const usePreventLeaveWithDirty = (isDirty: boolean) => {
|
||||
useEffect(() => {
|
||||
if (!isDirty) return;
|
||||
|
||||
const handleClick = (event: Event) => {
|
||||
const target = (event.target as HTMLElement).closest("a");
|
||||
|
||||
if (!target) {
|
||||
console.warn("No anchor element found for click event", event);
|
||||
return;
|
||||
}
|
||||
const handleClick = (event: MouseEvent) => {
|
||||
if (event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;
|
||||
const target = event.target instanceof Element ? event.target.closest<HTMLAnchorElement>(anchorSelector) : null;
|
||||
if (!target) return;
|
||||
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
openConfirmModal({
|
||||
title: t("board.action.edit.confirmLeave.title"),
|
||||
@@ -291,19 +203,14 @@ const usePreventLeaveWithDirty = (isDirty: boolean) => {
|
||||
event.returnValue = true;
|
||||
};
|
||||
|
||||
const anchors = document.querySelectorAll(anchorSelector);
|
||||
anchors.forEach((link) => {
|
||||
link.addEventListener("click", handleClick);
|
||||
});
|
||||
document.addEventListener("click", handleClick, true);
|
||||
window.addEventListener("popstate", handlePopState);
|
||||
window.addEventListener("beforeunload", handleBeforeUnload);
|
||||
|
||||
return () => {
|
||||
anchors.forEach((link) => {
|
||||
link.removeEventListener("click", handleClick);
|
||||
});
|
||||
document.removeEventListener("click", handleClick, true);
|
||||
window.removeEventListener("popstate", handlePopState);
|
||||
window.removeEventListener("beforeunload", handleBeforeUnload);
|
||||
};
|
||||
}, [isDirty]);
|
||||
}, [isDirty, openConfirmModal, router, t]);
|
||||
};
|
||||
@@ -1,4 +1,4 @@
|
||||
import { api } from "@homarr/api/server";
|
||||
import { getBoardByNameAsync } from "@homarr/api/board-server";
|
||||
|
||||
import { BoardOtherHeaderActions } from "../_header-actions";
|
||||
import { createBoardLayout } from "../_layout-creator";
|
||||
@@ -6,6 +6,6 @@ import { createBoardLayout } from "../_layout-creator";
|
||||
export default createBoardLayout<{ locale: string; name: string }>({
|
||||
headerActions: <BoardOtherHeaderActions />,
|
||||
async getInitialBoardAsync({ name }) {
|
||||
return await api.board.getBoardByName({ name });
|
||||
return await getBoardByNameAsync(name);
|
||||
},
|
||||
});
|
||||
@@ -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,62 +8,44 @@ import "~/styles/prismjs.scss";
|
||||
|
||||
import { IconInfoCircle } from "@tabler/icons-react";
|
||||
|
||||
import { useForm } from "@homarr/form";
|
||||
import { useSession } from "@homarr/auth/client";
|
||||
import type { UseFormReturnType } from "@homarr/form";
|
||||
import { useI18n, useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import { SectionCard } from "~/components/manage/section-card";
|
||||
import { WorkshopCssImportButton } from "~/components/workshop/workshop-css-import-button";
|
||||
|
||||
import type { Board } from "../../_types";
|
||||
import { useSavePartialSettingsMutation } from "./_shared";
|
||||
import type { FormValues } from "./_settings-form";
|
||||
import classes from "./customcss.module.css";
|
||||
|
||||
interface Props {
|
||||
board: Board;
|
||||
form: UseFormReturnType<FormValues>;
|
||||
}
|
||||
|
||||
export const CustomCssSettingsContent = ({ board }: Props) => {
|
||||
export const CustomCssSettingsContent = ({ form }: Props) => {
|
||||
const { data: session } = useSession();
|
||||
const isAdmin = session?.user.permissions.includes("admin") ?? false;
|
||||
const t = useI18n();
|
||||
const customCssT = useScopedI18n("board.field.customCss");
|
||||
const { mutate: savePartialSettings, isPending } = useSavePartialSettingsMutation(board);
|
||||
const form = useForm({
|
||||
initialValues: {
|
||||
customCss: board.customCss ?? "",
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<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="space-between">
|
||||
<WorkshopCssImportButton onImport={(css) => form.setFieldValue("customCss", css)} />
|
||||
<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>
|
||||
{isAdmin && <WorkshopCssImportButton onImport={(css) => form.setFieldValue("customCss", css)} />}
|
||||
</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 (
|
||||
@@ -73,6 +55,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;
|
||||
}
|
||||
}
|
||||
Loaded 100 of 612 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user