Compare commits
85
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d6c1a87a03 | ||
|
|
50907c7289 | ||
|
|
9c8fa8158b | ||
|
|
e6e413bcb7 | ||
|
|
81d14e39ab | ||
|
|
f207587791 | ||
|
|
5cc2df19ed | ||
|
|
0a80d70730 | ||
|
|
25c9955cc6 | ||
|
|
fcc5130068 | ||
|
|
c7751e293d | ||
|
|
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 | ||
|
|
77dc317da2 | ||
|
|
6aeda3faaf | ||
|
|
ac6a0c9254 | ||
|
|
ca358d9f8b | ||
|
|
03e0683892 | ||
|
|
6ac4c6a7b9 | ||
|
|
a0e0bf65ff | ||
|
|
bc018e1fe0 | ||
|
|
5e39c8813a | ||
|
|
06719cdef0 | ||
|
|
5144e14da4 | ||
|
|
8528d5068b | ||
|
|
c6c4428f7d | ||
|
|
7e4cb0201b | ||
|
|
7887ce12c0 | ||
|
|
2d450239ac | ||
|
|
bf4ccd8cb3 | ||
|
|
c473e80243 | ||
|
|
bf55bcce92 | ||
|
|
49248cedaf | ||
|
|
a0034b2cfc | ||
|
|
4a297180fd | ||
|
|
5fa3a3313a | ||
|
|
29ee87c4aa | ||
|
|
94a247e24a | ||
|
|
6d37710fec | ||
|
|
c016beb2e5 | ||
|
|
f9e1b85c6a | ||
|
|
38ba19e665 | ||
|
|
f55c99d2d6 |
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
|
||||
+1
-13
@@ -66,16 +66,4 @@ UNSAFE_ENABLE_MOCK_INTEGRATION=true
|
||||
|
||||
# Whether demo mode is read-only. Defaults to true (the public demo blocks all mutations).
|
||||
# Set to false for writable preview deployments so the demo user can modify the dashboard.
|
||||
# DEMO_READ_ONLY=false
|
||||
# Central Workshop API is https://homarr.dev. Override only when developing
|
||||
# the standalone Docusaurus or Homarr clients against a local backend.
|
||||
# NEXT_PUBLIC_WORKSHOP_API_URL=http://localhost:8090
|
||||
# WORKSHOP_API_URL=http://localhost:8090
|
||||
|
||||
# Workshop development ports. PocketBase superusers and GitHub OAuth are
|
||||
# configured once through the persistent PocketBase dashboard, not environment variables.
|
||||
PB_EXPOSE_PORT=8090
|
||||
WORKSHOP_WEB_PORT=3003
|
||||
# Workshop is intentionally consumable by arbitrary self-hosted Homarr origins.
|
||||
# PocketBase is stateless and authorizes writes with bearer tokens, not cookies.
|
||||
PB_ALLOWED_ORIGINS=*
|
||||
# DEMO_READ_ONLY=false
|
||||
@@ -33,6 +33,8 @@ body:
|
||||
options:
|
||||
# The below comment is used to insert a new version with on-release.yml
|
||||
#NEXT_VERSION#
|
||||
- 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,
|
||||
}
|
||||
@@ -23,7 +23,6 @@ jobs:
|
||||
with:
|
||||
fetch-depth: 0
|
||||
ref: ${{ github.head_ref }}
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup
|
||||
uses: ./tooling/github/setup
|
||||
|
||||
@@ -60,7 +60,7 @@ jobs:
|
||||
TURBO_PLATFORM: linux/amd64
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
if: '!env.ACT'
|
||||
if: "!env.ACT"
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
@@ -81,9 +81,6 @@ jobs:
|
||||
- name: Copy environment defaults
|
||||
run: cp .env.example .env
|
||||
|
||||
- name: Check formatting in affected workspaces
|
||||
run: pnpm turbo run format --affected --continue -- --check
|
||||
|
||||
- name: Lint, typecheck, and build affected workspaces
|
||||
run: pnpm turbo run lint typecheck build --affected --continue=dependencies-successful
|
||||
|
||||
@@ -93,12 +90,8 @@ jobs:
|
||||
- name: Run unit tests
|
||||
run: pnpm test
|
||||
|
||||
- name: Report coverage
|
||||
if: always() && !env.ACT
|
||||
uses: davelosert/vitest-coverage-report-action@v2
|
||||
|
||||
container:
|
||||
name: Container, E2E, and memory
|
||||
name: Container and E2E
|
||||
needs: fast-gate
|
||||
if: |
|
||||
github.event_name == 'merge_group' ||
|
||||
@@ -111,7 +104,7 @@ jobs:
|
||||
packages: write
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
if: '!env.ACT'
|
||||
if: "!env.ACT"
|
||||
with:
|
||||
persist-credentials: false
|
||||
ref: ${{ github.event_name == 'workflow_dispatch' && inputs.ref || '' }}
|
||||
@@ -120,11 +113,11 @@ jobs:
|
||||
uses: ./tooling/github/setup
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
if: '!env.ACT'
|
||||
if: "!env.ACT"
|
||||
uses: docker/setup-buildx-action@v4
|
||||
|
||||
- name: Build container once
|
||||
if: '!env.ACT'
|
||||
if: "!env.ACT"
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: .
|
||||
@@ -165,7 +158,7 @@ jobs:
|
||||
--tag homarr-ci --tag homarr-e2e "${secret_args[@]}" .
|
||||
|
||||
- name: Cache Playwright browsers
|
||||
if: '!env.ACT'
|
||||
if: "!env.ACT"
|
||||
id: playwright-cache
|
||||
uses: actions/cache@v6
|
||||
with:
|
||||
@@ -191,82 +184,6 @@ jobs:
|
||||
path: e2e/test-results/
|
||||
retention-days: 7
|
||||
|
||||
- name: Run memory regression test
|
||||
env:
|
||||
CONTAINER_NAME: homarr-mem-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
SECRET_ENCRYPTION_KEY: 0000000000000000000000000000000000000000000000000000000000000000
|
||||
run: |
|
||||
docker run -d --name "$CONTAINER_NAME" -p 127.0.0.1::7575 \
|
||||
-e NO_EXTERNAL_CONNECTION=true \
|
||||
-e SECRET_ENCRYPTION_KEY="$SECRET_ENCRYPTION_KEY" \
|
||||
homarr-ci
|
||||
PORT=$(docker port "$CONTAINER_NAME" 7575/tcp | sed 's/.*://')
|
||||
[[ "$PORT" =~ ^[0-9]+$ ]] || { echo "ERROR: invalid port mapping: '$PORT'"; exit 1; }
|
||||
URL="http://127.0.0.1:${PORT}"
|
||||
|
||||
healthy=false
|
||||
for i in $(seq 1 30); do
|
||||
if curl -sf "$URL/api/health/ready" >/dev/null 2>&1; then
|
||||
echo "Container healthy after $((i * 2))s"
|
||||
healthy=true
|
||||
break
|
||||
fi
|
||||
sleep 2
|
||||
done
|
||||
|
||||
if [ "$healthy" != "true" ]; then
|
||||
echo "ERROR: Container never became healthy"
|
||||
docker logs "$CONTAINER_NAME" --tail 100
|
||||
exit 1
|
||||
fi
|
||||
|
||||
warmup_ok=0
|
||||
for _ in $(seq 1 5); do
|
||||
curl -sf "$URL/" >/dev/null 2>&1 && warmup_ok=$((warmup_ok + 1))
|
||||
sleep 2
|
||||
done
|
||||
if [ "$warmup_ok" -eq 0 ]; then
|
||||
echo "ERROR: All warmup requests failed"
|
||||
docker logs "$CONTAINER_NAME" --tail 50
|
||||
exit 1
|
||||
fi
|
||||
|
||||
docker inspect -f '{{.State.Running}}' "$CONTAINER_NAME" | grep -qx true || {
|
||||
echo "ERROR: Container died before memory measurement"
|
||||
docker logs "$CONTAINER_NAME" --tail 100
|
||||
exit 1
|
||||
}
|
||||
|
||||
RAW_MEMORY=$(docker stats --no-stream --format '{{.MemUsage}}' "$CONTAINER_NAME" | sed 's| /.*||')
|
||||
MEMORY_MB=$(echo "$RAW_MEMORY" | awk '
|
||||
/GiB/ { gsub(/GiB/, ""); print $1 * 1024; next }
|
||||
/MiB/ { gsub(/MiB/, ""); print $1; next }
|
||||
/KiB/ { gsub(/KiB/, ""); print $1 / 1024; next }
|
||||
{ print $1 / 1048576 }
|
||||
')
|
||||
|
||||
echo "Memory usage: ${MEMORY_MB} MB (${RAW_MEMORY})"
|
||||
[[ "$MEMORY_MB" =~ ^[0-9]+(\.[0-9]+)?$ ]] || {
|
||||
echo "ERROR: non-numeric memory value: '$MEMORY_MB' (raw: '$RAW_MEMORY')"
|
||||
docker logs "$CONTAINER_NAME" --tail 100
|
||||
exit 1
|
||||
}
|
||||
if awk "BEGIN { exit !($MEMORY_MB < 50) }"; then
|
||||
echo "ERROR: Memory suspiciously low (${MEMORY_MB} MB) — container may have crashed"
|
||||
docker logs "$CONTAINER_NAME" --tail 100
|
||||
exit 1
|
||||
elif awk "BEGIN { exit !($MEMORY_MB > 700) }"; then
|
||||
echo "ERROR: Memory usage ${MEMORY_MB} MB exceeds 700 MB"
|
||||
docker logs "$CONTAINER_NAME" --tail 100
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Clean up memory test container
|
||||
if: always()
|
||||
env:
|
||||
CONTAINER_NAME: homarr-mem-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
run: docker rm -f "$CONTAINER_NAME" >/dev/null 2>&1 || true
|
||||
|
||||
- name: Determine preview tag
|
||||
id: preview
|
||||
if: |
|
||||
@@ -316,7 +233,7 @@ jobs:
|
||||
run: test -n "$PR_NUMBER" || { echo "pr_number is required for cleanup"; exit 1; }
|
||||
|
||||
- name: Delete GHCR package version
|
||||
if: '!env.ACT'
|
||||
if: "!env.ACT"
|
||||
uses: actions/github-script@v8
|
||||
with:
|
||||
script: |
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
name: "[Deployment] Documentation"
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ["dev"]
|
||||
paths:
|
||||
- "apps/docs/**"
|
||||
- "packages/definitions/src/docs/**"
|
||||
- ".github/workflows/deployment-docs.yml"
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pages: write
|
||||
id-token: write
|
||||
|
||||
concurrency:
|
||||
group: "pages"
|
||||
cancel-in-progress: false
|
||||
|
||||
env:
|
||||
SKIP_ENV_VALIDATION: true
|
||||
TURBO_API: ${{ secrets.TURBO_API }}
|
||||
TURBO_TEAM: ${{ secrets.TURBO_TEAM }}
|
||||
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
|
||||
TURBO_REMOTE_CACHE_SIGNATURE_KEY: ${{ secrets.TURBO_REMOTE_CACHE_SIGNATURE_KEY }}
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- name: Setup
|
||||
uses: ./tooling/github/setup
|
||||
- name: Build docs
|
||||
run: pnpm turbo build --filter=@homarr/docs
|
||||
env:
|
||||
CI: "true"
|
||||
- name: Setup Pages
|
||||
uses: actions/configure-pages@v6
|
||||
- name: Upload pages artifact
|
||||
uses: actions/upload-pages-artifact@v5
|
||||
with:
|
||||
path: "apps/docs/build"
|
||||
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
needs: build
|
||||
environment:
|
||||
name: github-pages
|
||||
url: ${{ steps.deployment.outputs.page_url }}
|
||||
steps:
|
||||
- name: Deploy to GitHub Pages
|
||||
id: deployment
|
||||
uses: actions/deploy-pages@v5
|
||||
@@ -1,50 +0,0 @@
|
||||
name: Workshop
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, synchronize, reopened, ready_for_review]
|
||||
paths: &workshop-paths
|
||||
- "apps/workshop/**"
|
||||
- "apps/docs/docusaurus.config.ts"
|
||||
- "apps/docs/package.json"
|
||||
- "apps/docs/src/components/workshop/**"
|
||||
- "apps/docs/src/pages/workshop/**"
|
||||
- "apps/docs/src/theme/Root.tsx"
|
||||
- "apps/docs/static/workshop-*"
|
||||
- "apps/docs/docs/management/workshop/**"
|
||||
- "packages/workshop/**"
|
||||
- "packages/validation/**"
|
||||
- "packages/definitions/**"
|
||||
- "patches/**"
|
||||
- "package.json"
|
||||
- "pnpm-lock.yaml"
|
||||
- "pnpm-workspace.yaml"
|
||||
- "turbo.json"
|
||||
- ".github/workflows/workshop.yml"
|
||||
push:
|
||||
branches: [dev]
|
||||
paths: *workshop-paths
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: workshop-${{ github.ref }}-${{ github.event.pull_request.number || github.sha }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
production-preview:
|
||||
name: Production preview
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- name: Setup
|
||||
uses: ./tooling/github/setup
|
||||
- name: Copy environment defaults
|
||||
run: cp .env.example .env
|
||||
- name: Test Workshop backend
|
||||
run: pnpm test:workshop-backend
|
||||
- name: Build and smoke-test Workshop
|
||||
run: pnpm test:workshop-preview
|
||||
@@ -1,87 +0,0 @@
|
||||
---
|
||||
score: 9
|
||||
p0: 0
|
||||
p1: 1
|
||||
timestamp: 2026-07-13T21-26-11Z
|
||||
slug: apps-docs-src-components-workshop
|
||||
---
|
||||
# Workshop V2 audit
|
||||
|
||||
## Purpose
|
||||
|
||||
The Workshop is Homarr's central community catalog for custom widgets and CSS themes. It must let people discover, inspect, export, publish, update, report, and install creations from homarr.dev and from Homarr itself without requiring raw copy-and-paste as the default workflow.
|
||||
|
||||
### Feature North Star
|
||||
|
||||
A Homarr user can move from a useful local creation to a safe community submission, or from a community listing to an installed local creation, in one understandable flow with source visibility and an explicit confirmation boundary.
|
||||
|
||||
### Developer-experience North Star
|
||||
|
||||
Workshop behavior is defined once: one typed contract, one client, versioned PocketBase migrations, deterministic local startup, shared validation fixtures, typed failures, and UI components that contain presentation rather than PocketBase queries or authorization rules.
|
||||
|
||||
## Baseline audit
|
||||
|
||||
The abandoned prototype is preserved at `origin/feat/widget-store-prototype-backup` (`89260b875`). This rewrite starts at `dev` commit `ba7718bde`.
|
||||
|
||||
| Area | Severity | Baseline finding | V2 resolution |
|
||||
| ------------- | -------- | ----------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------ |
|
||||
| Branch health | P0 | PR #5984 was based on an obsolete `dev` commit and conflicted with the current branch. | Rebuild from current `dev`; keep the old head on a backup ref. |
|
||||
| Security | P0 | Submission validation ran in browsers and could be bypassed with direct PocketBase writes. | Enforce validation, ownership, account state, immutable fields, and upload limits in PocketBase hooks and rules. |
|
||||
| Moderation | P0 | There were no moderator roles, posting bans, disabled accounts, least-privilege actions, or durable moderation history. | Add roles, account states, explicit moderation routes, a staff UI, and append-only actions. |
|
||||
| Architecture | P1 | Docs and Next.js defined separate record types, queries, mutations, sorting, voting, and error behavior. | Add `@homarr/workshop` as the only public contract and API client. |
|
||||
| Performance | P1 | The browser downloaded every submission and full content, then searched, sorted, and filtered locally. | Server-side pagination/filtering/sorting; summary views exclude content. |
|
||||
| Product | P1 | Website and app flows were disconnected and sharing still depended on manual content transfer. | Add contextual install and direct share actions backed by canonical exports. |
|
||||
| UX/UI | P1 | A large bespoke Docusaurus component set and a separate Mantine modal produced inconsistent interaction and state behavior. | Use each host's native components with shared flows, copy rules, contracts, and states. |
|
||||
| Accessibility | P1 | Several custom interactive elements bypassed semantic controls and touch targets; focus and reduced-motion behavior was incomplete. | Require semantic controls, visible focus, keyboard operation, 44px touch targets, AA contrast, and reduced motion. |
|
||||
| Operations | P1 | A third-party PocketBase image and incomplete bootstrap docs made builds and recovery non-deterministic. | Build a pinned official binary with checksum verification; document health, backup, restore, OAuth, and upgrades. |
|
||||
| Testing | P1 | Tests covered a narrow client schema and did not exercise API rules, roles, auth states, moderation, or cross-surface flows. | Add contract, backend permission-matrix, UI, and end-to-end coverage. |
|
||||
| Documentation | P2 | Documentation advertised a supported self-hosted Workshop even though V2 is a central Homarr service. | Document the central service and local-development overrides only. |
|
||||
|
||||
## Decisions
|
||||
|
||||
- Public name: **Workshop**.
|
||||
- Launch content: custom widgets (`homarr-custom-widget-v2`) and CSS (`homarr-custom-css-v1`).
|
||||
- Identity: GitHub OAuth only.
|
||||
- Community features: votes and reports; comments are removed.
|
||||
- Hosting: one Homarr-operated image and origin at `homarr.dev`; PocketBase serves both compiled docs and `/api`.
|
||||
- Resilience: the public shell and successfully loaded query data are cached independently; the rest of Homarr never depends on Workshop availability.
|
||||
- Publishing: immediate, with source inspection, reporting, and staff removal.
|
||||
- Staff: GitHub-authenticated moderators/admins use `/workshop/admin`; PocketBase superusers are bootstrap/emergency access.
|
||||
|
||||
## Launch acceptance
|
||||
|
||||
- [x] Anonymous users can browse, search, filter, inspect, copy, and download without receiving full content in listing responses.
|
||||
- [x] Members can publish, update, delete, vote, and report; ownership and schemas are enforced server-side.
|
||||
- [x] Posting-banned users cannot create or update submissions but can browse, vote, and report.
|
||||
- [x] Disabled accounts cannot authenticate or write, including with an existing token.
|
||||
- [x] Moderators can resolve reports, remove submissions, posting-ban users, and disable/restore non-admin users.
|
||||
- [x] Only admins can change roles; staff actions are append-only and attributable.
|
||||
- [x] Homarr can export/share local widgets and CSS and validate/confirm Workshop installs.
|
||||
- [x] Website and app implement loading, empty, offline, validation, permission, and destructive-action states.
|
||||
- [x] Keyboard semantics, mobile layout, dark mode, reduced motion, and horizontal overflow received a browser smoke test.
|
||||
- [x] Clean PocketBase startup, migration, health, backup, and restore procedures are reproducible and documented.
|
||||
- [x] Documentation and Workshop API are built into one reproducible image and served from `homarr.dev` without production cross-origin requests.
|
||||
- [ ] Add automated authenticated cross-surface E2E coverage before changing the PR from draft to ready.
|
||||
|
||||
## Deferred after V2
|
||||
|
||||
Comments, private Workshop deployments, automated approval queues, follows, collections, notifications, arbitrary package types, historical version installation, and personalized recommendations.
|
||||
|
||||
## Exit scorecard
|
||||
|
||||
The baseline has three P0, seven P1, and one P2 findings. The rewrite closes every product, security, architecture, moderation, performance, operations, documentation, UX, and DX finding. One P1 release-verification item remains: automated authenticated E2E across both public and in-app surfaces. Keep PR #5984 in draft until it lands.
|
||||
|
||||
| Gate | Result | Evidence |
|
||||
| --------------------------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Branch ancestry | Pass | Rebuilt from `dev` at `ba7718bde`; prototype preserved at `origin/feat/widget-store-prototype-backup`. |
|
||||
| Contract | Pass | Five Vitest cases cover widget/CSS fixtures, filenames, limits, screenshot validation, and round trips. |
|
||||
| Backend | Pass | Disposable PocketBase integration matrix covers roles, account states, ownership forgery, schema rejection, duplicate votes, private reports, escalation, deletion cascades, and audit snapshots. |
|
||||
| Backend operations | Pass | PocketBase 0.39.6 runs unprivileged and builds with checksum verification; clean boot and a cloned legacy SQLite volume both migrate and become healthy. |
|
||||
| Type safety | Pass | Scoped Turbo typecheck passes for Workshop, Docs, and Next.js. |
|
||||
| Lint | Pass | Scoped lint exits clean; remaining warnings predate the rewrite. Workshop has zero warnings. |
|
||||
| Format | Pass | Every changed supported file passes `oxfmt --check`; repository-wide Docs formatting still reports pre-existing drift. |
|
||||
| Production builds | Pass | Docusaurus and Next.js production builds pass under Node 24.18.0. |
|
||||
| Browser smoke | Pass | Desktop/mobile discovery and the disconnected API state render without uncaught errors; cached data remains readable and retry is explicit. |
|
||||
| Automated cross-surface E2E | Open P1 | Authenticated website-to-Homarr publish/install and moderator flows still need browser automation. |
|
||||
|
||||
Final implementation scores (10 is best): security 9, architecture/DX 9, moderation 9, performance 9, responsive UI 9, theming 9, accessibility 8, automated testing 7. Accessibility remains below 9 until authenticated dialogs and destructive flows receive a full keyboard/screen-reader pass.
|
||||
@@ -1,3 +1,9 @@
|
||||
## [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)
|
||||
|
||||
## [1.69.2](https://github.com/homarr-labs/homarr/compare/v1.69.1...v1.69.2) (2026-07-06)
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
# Workshop design system
|
||||
|
||||
Workshop is a trustworthy community workbench inside Homarr, not a separate storefront brand.
|
||||
|
||||
## Visual direction
|
||||
|
||||
- Reuse the host surface: Docusaurus tokens on `homarr.dev`, Mantine components inside Homarr.
|
||||
- Use Homarr red only for primary action and focus; warnings use restrained amber and errors use red with explanatory text.
|
||||
- Keep surfaces flat, bordered, and readable. Cards may lift by 3px on pointer hover; no gradients, glass panels, or decorative dashboards.
|
||||
- Typography follows each host. Headings are compact and confident; body copy stays warm, concrete, and short.
|
||||
- Spacing follows an 8px rhythm, 12-16px corners, and 44px minimum controls.
|
||||
|
||||
## Interaction direction
|
||||
|
||||
- The catalog progression is discover → inspect → confirm → use.
|
||||
- Every remote state has a named loading, empty, cached, unavailable, validation, permission, and success outcome.
|
||||
- Community source is never executed on the website. Installation always follows source inspection and local validation.
|
||||
- Motion is limited to 150-200ms hover/state feedback and skeleton loading, with reduced-motion fallbacks.
|
||||
- Native dialogs or Mantine modals own focus, Escape, backdrop dismissal, and keyboard order.
|
||||
|
||||
## Voice
|
||||
|
||||
Clear, dependable, and community-minded. Explain what remains usable during failures and provide one direct recovery action. Avoid playful language during moderation, destructive actions, or service failures.
|
||||
+3
-3
@@ -4,7 +4,6 @@ FROM node:24.18.0-alpine AS base
|
||||
|
||||
FROM base AS builder
|
||||
WORKDIR /app
|
||||
ARG CI='true'
|
||||
RUN apk add --no-cache libc6-compat curl bash && apk update
|
||||
|
||||
RUN corepack enable pnpm
|
||||
@@ -25,12 +24,12 @@ RUN --mount=type=cache,id=pnpm-store,target=/pnpm/store \
|
||||
|
||||
COPY . .
|
||||
# Follow the pnpm fetch pattern from https://pnpm.io/cli/fetch
|
||||
# --offline enforces no registry access (all packages are in the store from 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 --offline
|
||||
pnpm install --recursive
|
||||
|
||||
ARG SKIP_ENV_VALIDATION='true'
|
||||
ARG CI='true'
|
||||
ARG DISABLE_REDIS_LOGS='true'
|
||||
ARG TARGETPLATFORM
|
||||
|
||||
@@ -82,6 +81,7 @@ COPY nginx.conf /etc/nginx/templates/nginx.conf
|
||||
ENV DB_URL='/appdata/db/db.sqlite'
|
||||
ENV DB_DIALECT='sqlite'
|
||||
ENV DB_DRIVER='better-sqlite3'
|
||||
ENV AUTH_PROVIDERS='credentials'
|
||||
ENV REDIS_IS_EXTERNAL='false'
|
||||
ENV NODE_ENV='production'
|
||||
|
||||
|
||||
+10
-14
@@ -1,33 +1,29 @@
|
||||
# Product
|
||||
|
||||
## Register
|
||||
|
||||
product
|
||||
|
||||
## Users
|
||||
|
||||
Homarr serves people who organize and operate self-hosted services, from individual home-lab users to administrators managing shared instances. Workshop also serves community creators who publish and install custom widgets and CSS themes. Users need the core dashboard to remain useful even when optional community services are offline.
|
||||
Homarr serves people and teams who run self-hosted applications and want one dependable dashboard for launching apps, monitoring services, and viewing live operational data. They use it across wall displays, desktops, tablets, and smaller devices, often returning to the same board many times per day.
|
||||
|
||||
## Product Purpose
|
||||
|
||||
Homarr provides a dependable dashboard for discovering, organizing, and operating applications and integrations. Workshop adds a central community catalog where users can inspect, export, publish, report, and install creations with clear source visibility and explicit confirmation.
|
||||
Homarr makes self-hosted services easy to find, understand, and operate without hand-editing configuration files. A successful board loads immediately, stays visually stable, keeps widgets legible across screen sizes, and makes editing feel direct and forgiving.
|
||||
|
||||
## Brand Personality
|
||||
|
||||
Clear, dependable, and community-minded. Interface copy should be direct and practical, with enough context for users to recover from failures without exposing internal implementation details.
|
||||
Fast, capable, and approachable. Homarr should feel like a mature productivity tool: dense when useful, calm in view mode, and explicit about state during editing.
|
||||
|
||||
## Anti-references
|
||||
|
||||
Avoid fragmented feature surfaces, generic failure messages, decorative dashboard patterns, unexpected network dependencies, and optional services that can crash or block core Homarr workflows.
|
||||
Avoid cramped tile spacing, undersized text, layout shifts, decorative motion, hidden drag targets, modal-heavy positioning controls, and dashboards that require users to understand grid internals. Editing must not make the read-only experience heavier or slower.
|
||||
|
||||
## Design Principles
|
||||
|
||||
- Keep core Homarr workflows available when optional services fail.
|
||||
- Explain failures in user terms and always offer a useful recovery action.
|
||||
- Use native Homarr patterns and shared contracts instead of feature-specific interaction systems.
|
||||
- Make community content inspectable and require explicit confirmation before installation.
|
||||
- Keep operational behavior deterministic for contributors and instance administrators.
|
||||
- Preserve the board: viewing is the primary workflow and must render before editor code is needed.
|
||||
- Make spatial actions direct: moving, resizing, nesting, and using fixed sidebars should happen on the canvas with immediate feedback.
|
||||
- Keep content predictable: fixed logical tile sizes preserve widget composition, typography, and controls at every viewport.
|
||||
- Recover gracefully: collision handling, reflow, loading states, and persistence failures must never lose or obscure content.
|
||||
- Use familiar controls: Mantine patterns, clear labels, keyboard support, and visible focus states take priority over novel decoration.
|
||||
|
||||
## Accessibility & Inclusion
|
||||
|
||||
Meet WCAG 2.1 AA for contrast, focus, semantics, and keyboard operation. Respect reduced-motion preferences, preserve useful layouts at 200% zoom and on mobile, and ensure status changes and recovery actions are understandable without relying on color alone.
|
||||
Target WCAG 2.2 AA. Maintain keyboard equivalents for drag and resize operations, useful landmarks and announcements, sufficient contrast, readable control sizes, reduced-motion support, and horizontal scrolling before scaling content below an accessible minimum.
|
||||
@@ -23,9 +23,6 @@
|
||||
"react-hooks/exhaustive-deps": "warn",
|
||||
"eslint/no-unused-expressions": "warn"
|
||||
},
|
||||
"options": {
|
||||
"reportUnusedDisableDirectives": "off"
|
||||
},
|
||||
"ignorePatterns": ["**/*.config.js", "**/*.config.mjs", "**/*.cjs", ".docusaurus/**", "build/**"],
|
||||
"settings": {
|
||||
"react": { "version": "19" }
|
||||
|
||||
@@ -55,9 +55,9 @@ A random secret can be generated by using the following command: `openssl rand -
|
||||
|
||||
| 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` | - |
|
||||
| ``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
|
||||
| Environment Variable | Description | Possible values | Default |
|
||||
|
||||
@@ -127,6 +127,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
|
||||
@@ -204,6 +206,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
|
||||
|
||||
@@ -54,11 +54,7 @@ You must choose a username and password (both can later be changed).
|
||||
We enforce strict password requirements when you enter your password to protect your instance if you accidentally
|
||||
expose it to the internet.
|
||||
|
||||
<img
|
||||
src={imageStartFromScratchStepAdminUser}
|
||||
alt={"Screenshot form to create admin user"}
|
||||
width={300}
|
||||
/>
|
||||
<img src={imageStartFromScratchStepAdminUser} alt={"Screenshot form to create admin user"} width={300} />
|
||||
|
||||
If you selected external providers (such as LDAP or OIDC), you'll be prompted to create an admin group.
|
||||
This group will be used to synchronize users from your provider to Homarr.
|
||||
@@ -116,22 +112,14 @@ The onboarding can no longer be started again.
|
||||
From here you can click on one of the provided links to continue your journey.
|
||||
We recommend you to continue with the section [creating your first board on this page](#creating-your-first-board).
|
||||
|
||||
<img
|
||||
src={imageStartFromScratchStepFinish}
|
||||
alt={"Screenshot of the finish onboarding process with links"}
|
||||
width={300}
|
||||
/>
|
||||
<img src={imageStartFromScratchStepFinish} alt={"Screenshot of the finish onboarding process with links"} width={300} />
|
||||
|
||||
### Importing a ZIP from version before 1.0.0
|
||||
|
||||
This is recommended when you used Homarr before and want to re-import your data.
|
||||
It is important that you first migrate correctly using [the list of breaking changes for 1.0](/blog/2024/09/23/version-1.0).
|
||||
|
||||
<img
|
||||
src={manageExportOldHomarr}
|
||||
alt="Export from old Homarr"
|
||||
style={{ maxWidth: 500 }}
|
||||
/>
|
||||
<img src={manageExportOldHomarr} alt="Export from old Homarr" style={{ maxWidth: 500 }} />
|
||||
|
||||
**How to export and import data**
|
||||
|
||||
@@ -149,7 +137,8 @@ It is important that you first migrate correctly using [the list of breaking cha
|
||||
## Creating your first board
|
||||
|
||||
In Homarr, you organize your apps and widgets on boards.
|
||||
Boards can be organized using the interactive drag and drop.
|
||||
Boards use fixed-size tiles so the same widget keeps the same proportions on every screen.
|
||||
Drag, resize, and section controls are available in edit mode.
|
||||
Let's create your first board by navigating here: `Manage` > `Boards` > `New board`:
|
||||

|
||||
|
||||
@@ -197,38 +186,23 @@ Here you have the following options in the header:
|
||||
|
||||
When entering the edit mode, you'll get some additional buttons to work with:
|
||||
|
||||
<img
|
||||
src={manageBoardHeaderEditMode}
|
||||
alt={"Screenshot of buttons in the edit mode"}
|
||||
width={200}
|
||||
/>
|
||||
<img src={manageBoardHeaderEditMode} alt={"Screenshot of buttons in the edit mode"} width={200} />
|
||||
|
||||
- **Add item**: Click this button to add new elements to your page.
|
||||
- **Exit edit mode**: Click this btuton to save your changes and exit edit mode.
|
||||
- **Exit edit mode**: Click this button to save your changes and exit edit mode.
|
||||
|
||||
To continue, click the option "New Item":
|
||||
|
||||
<img
|
||||
src={manageBoardHeaderAddItem}
|
||||
alt={"Screenshot of the add item menu"}
|
||||
width={200}
|
||||
/>
|
||||
<img src={manageBoardHeaderAddItem} alt={"Screenshot of the add item menu"} width={200} />
|
||||
|
||||
Next, you can choose what item you'd like to add.
|
||||
We will discuss the other options in the following sections - for now choose "App" to continue.
|
||||
|
||||
<img
|
||||
src={manageBoardHeaderChooseItem}
|
||||
alt={"Screenshot of choosing the app item in the modal"}
|
||||
width={300}
|
||||
/>
|
||||
<img src={manageBoardHeaderChooseItem} alt={"Screenshot of choosing the app item in the modal"} width={300} />
|
||||
|
||||
As soon as you chose the app, the modal will close and you'll get the following item on your board:
|
||||
|
||||
<img
|
||||
src={manageBoardNoApp}
|
||||
alt="Screenshot of the No app element on the board"
|
||||
/>
|
||||
<img src={manageBoardNoApp} alt="Screenshot of the No app element on the board" />
|
||||
|
||||
This is expected, because you haven't told Homarr what app it should show in this item.
|
||||
To configure this, click on the small 3 dots at the top of the item in the edit mode and choose "Edit item" in the menu.
|
||||
@@ -240,21 +214,28 @@ In the modal you can edit the app that you want to display:
|
||||

|
||||
|
||||
As soon as you save your changes, the tile on the board will refresh and display your app.
|
||||
Using the small bidirectional arrow at the bottom right you can resize the element.
|
||||
Start pressing with your mouse button down, move your mouse to resize and release your finger as soon your you're happy with the size.
|
||||
Use the resize handle at the bottom right to resize the tile.
|
||||
Press and hold the handle, move the pointer, and release it when the tile has the size you want.
|
||||
|
||||

|
||||
|
||||
To move your item to a different position on the board, simply hold the mouse button anywhere on the item and move with the mouse to the desired position.
|
||||
As soon as you release your finger, the item will snap in place in the grid.
|
||||
To move a tile in edit mode, press and hold its content, move it to the desired position, and release it.
|
||||
Widget content is disabled while editing so its controls do not intercept the drag; use the tile's settings button for edit actions.
|
||||
The tile snaps to the grid. Equally-sized tiles swap when one is dropped into the other's slot, while other neighboring tiles move out of the way automatically.
|
||||
|
||||

|
||||
|
||||
Every `1 × 1` tile has a logical content size of `200 × 200` pixels.
|
||||
Larger tiles use multiples of that fixed unit, with a consistent gap between cells.
|
||||
Homarr scales the complete board uniformly, like zooming a page.
|
||||
It stops shrinking at 75% and lets you scroll horizontally instead of making text and controls too small.
|
||||
It does not squeeze or reflow the content of individual widgets, so a widget keeps the same design and proportions across screen sizes.
|
||||
|
||||
:::note
|
||||
|
||||
You cannot move items freely on the vertical axis.
|
||||
We always align all items to the top of the page to simply organization.
|
||||
To move items to the bottom, you must add more items or resize items.
|
||||
Viewing a board initially uses the lightweight, non-interactive layout.
|
||||
For users with edit access, Homarr prepares drag-and-drop and resize controls in the background after the board becomes interactive.
|
||||
If you enter edit mode before that finishes, the existing board stays visible until the editor is ready.
|
||||
|
||||
:::
|
||||
|
||||
@@ -265,8 +246,10 @@ This helps you to explore the capabilities of Homarr before you build your board
|
||||
|
||||
:::
|
||||
|
||||
Users on touch or mobile devices may not be able to drag and drop or move items.
|
||||
They can use the "Move / resize item" option in the edit menu of items instead:
|
||||
Keyboard users can focus a tile, press Enter or Space, then use arrow keys to move and Shift plus arrow keys to resize.
|
||||
Press Escape to stop.
|
||||
For precise numeric controls or touch input, choose **Move / resize item** from the tile's edit menu.
|
||||
The dialog can also move the tile to another section or fixed sidebar:
|
||||
|
||||

|
||||
|
||||
@@ -338,43 +321,39 @@ As with every change, don't forget to save your changes using the exit edit mode
|
||||
|
||||

|
||||
|
||||
## Grouping your items
|
||||
## Organizing items with sections
|
||||
|
||||
As the last step, let's organize your freshly created items using two different options.
|
||||
You can choose either or both of them on your boards.
|
||||
Sections are resizable containers for apps, widgets, and other sections.
|
||||
The same section works on the main canvas and inside a fixed sidebar.
|
||||
Each section can configure:
|
||||
|
||||
### Using dynamic sections
|
||||
|
||||
Dynamic sections are a simple, resizable visual group to group items.
|
||||
- Show or hide its label.
|
||||
- Allow or disable collapsing.
|
||||
- Show or hide **Open all in new tabs**.
|
||||
- Change its border color and custom CSS classes.
|
||||
|
||||

|
||||
|
||||
After adding a dynamic section, you can simply drag and drop items into the section:
|
||||
After adding a section, drag and resize it like any other tile, then drag items into it:
|
||||
|
||||

|
||||
|
||||
### Using categories
|
||||
### Labeled and collapsible sections
|
||||
|
||||
Categories are more advanced and complicated than dynamic sections.
|
||||
To create a category, open the add item modal and choose "new category":
|
||||
Open the add-content menu and choose **New section**.
|
||||
Use its settings menu to add a label and enable the behavior you want.
|
||||
In edit mode, move items into or out of the section directly.
|
||||
In view mode, each user can collapse eligible sections without changing the saved board layout.
|
||||
If **Open all in new tabs** is enabled, the section menu can open every app link it contains.
|
||||
|
||||

|
||||
### Fixed sidebars
|
||||
|
||||
Every category must have a name. After you click "Add category", you will get a new item on your board:
|
||||
Open the board's **Layout** settings to enable the left or right sidebar for each responsive layout.
|
||||
Choose one, two, or three columns with the slider and use the preview to check the remaining canvas width.
|
||||
The sidebar reserves those columns from the dashboard, so Homarr moves canvas items into the next available positions.
|
||||
Sidebar content stays visible while the page scrolls and remains editable.
|
||||
|
||||

|
||||
|
||||
You can drag and drop items in to this category in the edit mode.
|
||||
Categories will have a big space before and after them in the edit mode to
|
||||
enable you to drag and drop items outside the category.
|
||||
You can also collapse or open categories in edit mode to facilitate your editing process.
|
||||
|
||||
:::note
|
||||
|
||||
A current limitation of categories is, that they will always have a width of 100%.
|
||||
You cannot reduce their horizontal width.
|
||||
|
||||
:::
|
||||
Existing categories become sections automatically, and category rails become fixed sidebars.
|
||||
|
||||
## Guided tours
|
||||
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
|
||||
<img src="https://raw.githubusercontent.com/homarr-labs/charts/refs/heads/main/charts/homarr/icon.svg" align="right" width="92" alt="homarr logo">
|
||||
|
||||

|
||||

|
||||

|
||||

|
||||

|
||||
|
||||
A Helm chart to deploy homarr for Kubernetes
|
||||
|
||||
@@ -259,7 +259,7 @@ httproute:
|
||||
value: /
|
||||
backendRefs:
|
||||
- name: homarr
|
||||
port: 8080
|
||||
port: 7575
|
||||
````
|
||||
</details>
|
||||
|
||||
@@ -419,17 +419,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.72.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 |
|
||||
|
||||
@@ -55,6 +55,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.
|
||||
Each container row has an action menu with the following options:
|
||||
|
||||
@@ -50,12 +50,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,19 +10,21 @@ 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']}
|
||||
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 },
|
||||
{ widget: mediaServerWidget },
|
||||
]}
|
||||
/>
|
||||
|
||||
|
||||
@@ -83,3 +83,10 @@ The OpenAPI and MCP APIs include board management endpoints for common automatio
|
||||
- Configure global board defaults such as public home boards and status behavior
|
||||
|
||||
Use the interactive API reference to inspect the exact request body for each endpoint under the `boards` and `settings` tags.
|
||||
|
||||
### Saving dashboard layouts
|
||||
|
||||
The dashboard editor saves item positions and sections as one board update.
|
||||
Canvas height follows its content automatically and does not require a separate API setting.
|
||||
Responsive layouts store optional left and right gutter widths from zero to three columns.
|
||||
Gutter columns are reserved from the layout's total column count, and changing them reflows affected items.
|
||||
@@ -24,10 +24,10 @@ Navigate to `Management` → `Tools` → `Backup`. You must have **admin** permi
|
||||
|
||||
Click the **Export** button in the export card. You'll be prompted to type `I understand` to confirm. Once confirmed, a ZIP file downloads containing:
|
||||
|
||||
| File | Description |
|
||||
| --------------- | ------------------------------------------------------------------- |
|
||||
| `db.sqlite` | Full SQLite database with all entities |
|
||||
| `metadata.json` | Backup metadata: Homarr version, creation timestamp, encryption key |
|
||||
| File | Description |
|
||||
| -------------------- | ------------------------------------------------------------------- |
|
||||
| `db.sqlite` | Full SQLite database with all entities |
|
||||
| `metadata.json` | Backup metadata: Homarr version, creation timestamp, encryption key |
|
||||
|
||||
The backup includes everything: apps, integrations, secrets, boards, users, groups, permissions, search engines, custom widgets, and server settings. (It just backs-up the whole database)
|
||||
|
||||
@@ -54,7 +54,6 @@ Type `I understand` to acknowledge that restoring will replace your current data
|
||||
The backup ZIP is sent to the server, validated, and applied. After a successful restore:
|
||||
|
||||
- **The Next.js server restarts automatically** to reload the new database state
|
||||
- **Server-side database migrations run before Next.js restarts**, including migrations added after the backup was created
|
||||
- Nginx and Redis (if running in Docker) stay up - only the Next.js process restarts
|
||||
- The page reloads once the server is ready
|
||||
|
||||
|
||||
@@ -9,11 +9,11 @@ tags:
|
||||
- Layout
|
||||
---
|
||||
|
||||
import iconColorImage from './img/icon-color.png'
|
||||
import itemRadiusImage from './img/item-radius.png'
|
||||
import layoutLargeImage from './img/layout-large.png'
|
||||
import layoutMediumImage from './img/layout-medium.png'
|
||||
import layoutSmallImage from './img/layout-small.png'
|
||||
import iconColorImage from "./img/icon-color.png";
|
||||
import itemRadiusImage from "./img/item-radius.png";
|
||||
import layoutLargeImage from "./img/layout-large.png";
|
||||
import layoutMediumImage from "./img/layout-medium.png";
|
||||
import layoutSmallImage from "./img/layout-small.png";
|
||||
|
||||
Each dashboard is called a "board".
|
||||
You can create as many boards as you want.
|
||||
@@ -24,24 +24,30 @@ Other boards can be viewed by direct links (e.g. my-homarr.com/board/smart-home)
|
||||
Names of boards must be unique because this will be used in the URL to load the desired board.
|
||||
|
||||
## Create a new board
|
||||
|
||||
When creating a board, the availability of the board name will be checked for you.
|
||||
|
||||

|
||||
|
||||
### Name
|
||||
|
||||
The name must not include any special characters and must be at least one character long.
|
||||
Underscores and hyphen-minus are allowed.
|
||||
|
||||
### Column count
|
||||
Homarr has a complex drag and drop system that can be used from the browser.
|
||||
The column count defines how many horizontal columns are available to drag and drop elements to.
|
||||
|
||||
The column count defines how many fixed-width cells are available in each row.
|
||||
One cell contains a `200 × 200` logical-pixel tile.
|
||||
Homarr scales the whole board uniformly instead of changing widget proportions, with horizontal scrolling when further shrinking would make controls too small.
|
||||
|
||||
### Public
|
||||
|
||||
This will enable public access for everyone - regardless of whether they are logged in or not.
|
||||
This can be really useful if you want to have public boards in your company or for family and fiends.
|
||||
This can be really useful if you want to have public boards in your company or for family and friends.
|
||||
Modifications for public boards must be allowed explicitly using user groups and their permissions.
|
||||
|
||||
## Board settings
|
||||
|
||||
You navigate to the board settings by clicking on these three dots:
|
||||
|
||||

|
||||
@@ -51,7 +57,9 @@ Alternatively, you can also navigate from the board page itself using the cog ic
|
||||

|
||||
|
||||
### General
|
||||
|
||||
#### Page title
|
||||
|
||||
This is the title that will be visible at the top left in the navigation bar:
|
||||
|
||||

|
||||
@@ -59,6 +67,7 @@ This is the title that will be visible at the top left in the navigation bar:
|
||||
This field is optional and Homarr will fall back to "Homarr" if nothing is set.
|
||||
|
||||
#### Meta title
|
||||
|
||||
This is the secondary title of the page that is not directly visible in the page itself.
|
||||
Browser will use it to set the title of tabs:
|
||||
|
||||
@@ -67,78 +76,156 @@ Browser will use it to set the title of tabs:
|
||||
Homarr will fall back to the board name and a suffix if nothing is set.
|
||||
|
||||
#### Logo image URL
|
||||
|
||||
This is the URL that is used for the image at the top left.
|
||||
Homarr will fall back to the Homarr logo if nothing is set.
|
||||
|
||||
#### Favicon image URL
|
||||
|
||||
This is the URL that is used for the favicon in the tab title.
|
||||
Homarr will fall back to the Homarr logo if nothing is set.
|
||||
|
||||
### Layout
|
||||
|
||||
In this section you can configure the different responsive layouts. Depending on your screens width the layout will change.
|
||||
In this section you can configure the different responsive layouts.
|
||||
Homarr selects a layout from the screen width, then scales its complete fixed-size canvas uniformly.
|
||||
|
||||

|
||||
|
||||
On each element the position and size of items can be changed seperately.
|
||||
Every `1 × 1` tile has a `200 × 200` logical-pixel content area.
|
||||
The `24` logical-pixel gap between tiles is separate from that size.
|
||||
A `2 × 1` tile therefore contains two fixed cell widths plus the gap between them.
|
||||
Widgets see their logical size, while the board's visual scale makes the complete layout larger or smaller.
|
||||
When fitting below 100%, Homarr keeps widget typography and control density at their normal readable size.
|
||||
On larger canvases, the complete board grows together like browser zoom.
|
||||
The scale never drops below 75%; on narrower screens the canvas scrolls horizontally instead of shrinking controls below an accessible size.
|
||||
|
||||
The position and size of every item can be changed separately in each responsive layout.
|
||||
When adding a new layout the position and sizes will automatically be generated depending on the column count.
|
||||
When adding a new item / dynamic section that will always be 1x1 on each layout and placed on the first available position.
|
||||
New items use their widget's default grid size, while a new inline section starts at a useful `4 × 3` size when the layout has enough columns.
|
||||
Both are placed in the first available position.
|
||||
|
||||
Moving content downward in edit mode extends the canvas automatically, while view mode trims it back to the content.
|
||||
Nested sections keep their own resizable bounds.
|
||||
|
||||
#### Name
|
||||
|
||||
The name of the layout can be choosen freely. It is only used for the management and will not be visible on the board.
|
||||
The name of the layout can be chosen freely. It is only used for management and will not be visible on the board.
|
||||
|
||||
#### Column count
|
||||
|
||||
Amount of columns for this layout. Defines how many items can be placed in a row. When changed it will automatically adjust the position and size of the items.
|
||||
Total number of columns for this layout.
|
||||
When changed, Homarr moves items into the next available positions without overlap.
|
||||
|
||||
#### Fixed sidebars
|
||||
|
||||
Each responsive layout can enable a fixed left sidebar, a fixed right sidebar, or both.
|
||||
Set a sidebar width to zero to disable it for that layout.
|
||||
Choose a width from one to three columns with the three-step slider.
|
||||
The preview shows how much space remains for the main canvas.
|
||||
|
||||
Sidebar columns are reserved from the layout's total column count.
|
||||
For example, a 12-column layout with a two-column left sidebar leaves ten columns for the canvas.
|
||||
Enabling or widening a sidebar reflows canvas items into the remaining columns.
|
||||
Disabling a sidebar moves its contents back into the next available canvas positions.
|
||||
|
||||
Sidebars remain visible while the page scrolls.
|
||||
Apps, widgets, and sections inside them still use the same fixed `200 × 200` cells and can be rearranged in edit mode.
|
||||
|
||||
#### Breakpoint
|
||||
|
||||
Minimum width of the screen where the layout will be used.
|
||||
Once the minmum of the next layout is reached the layout will change.
|
||||
Minimum width of the screen where the layout will be used.
|
||||
Once the minimum of the next layout is reached, the layout will change.
|
||||
|
||||
#### View and edit modes
|
||||
|
||||
View mode renders a static board without executing the drag-and-drop editor.
|
||||
After the board is interactive, Homarr prepares the editor in the background for users who can modify it.
|
||||
Entering edit mode keeps the static board visible until that preparation finishes, then enables collision-aware movement and resizing without changing the board geometry.
|
||||
Widget content is inert in edit mode, so dragging anywhere on the content moves the tile instead of activating the widget; the visible grip and tile outline indicate the active move surface, while the tile's settings button remains available.
|
||||
Dropping a tile into another tile's exact slot swaps their positions when both resulting placements fit without overlapping other content.
|
||||
For other collisions, or when a tile grows into occupied space, nearby tiles are compacted out of the way.
|
||||
The visible corner controls provide a 44-pixel resize target, and edge controls appear on hover for horizontal or vertical resizing.
|
||||
Resize handles stop at the tile's minimum allowed size while the pointer is still moving.
|
||||
Exit edit mode to save the resulting positions.
|
||||
|
||||
Keyboard users can focus a tile, press Enter or Space, then use arrow keys to move and Shift plus arrow keys to resize.
|
||||
Press Escape to stop.
|
||||
The **Move / resize item** action in the edit menu remains available for precise numeric or touch input.
|
||||
|
||||
#### Preview
|
||||
|
||||
<img src={layoutSmallImage} height={256} />
|
||||
<img src={layoutMediumImage} height={256} />
|
||||
<img src={layoutLargeImage} height={256} />
|
||||
<img src={layoutSmallImage} height={256} />
|
||||
<img src={layoutMediumImage} height={256} />
|
||||
<img src={layoutLargeImage} height={256} />
|
||||
|
||||
### Sections and sidebars
|
||||
|
||||
Sections can contain apps, widgets, and nested sections.
|
||||
Every section uses the same model whether it is on the canvas or inside a sidebar.
|
||||
Sections can be moved and resized directly in edit mode.
|
||||
Expanding it creates space for its contents and pushes following items out of the way; collapsing it compacts the board.
|
||||
|
||||
Each section can configure:
|
||||
|
||||
- Whether its label is visible.
|
||||
- Whether users may collapse it.
|
||||
- Whether its menu shows **Open all in new tabs**.
|
||||
- Its label, border color, and custom CSS classes.
|
||||
|
||||
Existing categories and dynamic sections are converted into this unified section model.
|
||||
Existing category rails become board sidebars automatically.
|
||||
|
||||
### Background
|
||||
|
||||
#### Background image URL
|
||||
|
||||
Homarr can display a background image behind apps and widgets.
|
||||
This URL can be used to define what image should be shown.
|
||||
|
||||
#### Background image attachment
|
||||
|
||||
This defines whether the background should move when the user scrolls.
|
||||
|
||||
#### Background image size
|
||||
|
||||
This defines whether the background should cover the entire page (and possibly be cropped) or never be cropped but may not cover the entire page.
|
||||
|
||||
#### Background image repeat
|
||||
|
||||
This defines whether the background should be repeated if it cannot cover the entire page.
|
||||
This option can be useful if you have patterns that can be repeated easily (e.g. patterns):
|
||||
|
||||

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

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

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

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

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

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

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

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

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

|
||||
|
||||
:::note
|
||||
For the global home board you can only select boards that are public.
|
||||
@@ -255,4 +353,4 @@ For the global home board you can only select boards that are public.
|
||||
### Mobile home board
|
||||
|
||||
Same as the home board, you can also set a mobile home board.
|
||||
It will be used for devices that are detected as mobile devices. (Using User-Agent header)
|
||||
It will be used for devices that are detected as mobile devices. (Using User-Agent header)
|
||||
@@ -82,10 +82,6 @@ Custom widgets can be exported and imported as JSON files. To export, select the
|
||||
|
||||
The import format uses `"$schema": "homarr-custom-widget-v2"` to identify the widget version.
|
||||
|
||||
## Community Workshop
|
||||
|
||||
Use **Install widget** on **Management → Workshop** to inspect and import community widgets. To publish your own widget, export it from Homarr and upload the JSON file in the Workshop submission form. Secrets are never included in exports and must be configured on each Homarr instance.
|
||||
|
||||
## Available Mantine Components for Custom JSX
|
||||
|
||||
When using the `customJsx` display type, your template has access to a whitelist of [Mantine v9](https://mantine.dev) components. Unrecognized components, event handlers, and dangerous tags are stripped for security.
|
||||
|
||||
@@ -1,46 +0,0 @@
|
||||
---
|
||||
tags:
|
||||
- Workshop
|
||||
- Custom Widgets
|
||||
- Custom CSS
|
||||
---
|
||||
|
||||
# Community Workshop
|
||||
|
||||
The [Workshop](pathname:///workshop) is Homarr's community catalog for custom widgets and CSS themes. You can inspect every creation before downloading it or using it in Homarr.
|
||||
|
||||
:::warning Community content
|
||||
|
||||
Workshop entries are created by community members. Review widget endpoints, templates, and CSS before using them. Keep a current Homarr backup and never add secrets to an exported widget.
|
||||
|
||||
:::
|
||||
|
||||
## Discover and install
|
||||
|
||||
In Homarr, open **Management → Workshop** to browse, search, filter, inspect, download, and install creations. The custom-widget management page and a board's custom CSS settings also provide contextual Workshop actions.
|
||||
|
||||
Recently loaded listings and details are saved in the browser for up to 24 hours. If Workshop is temporarily unreachable, Homarr shows the saved catalog with a clear offline notice; browsing the rest of Homarr is unaffected. Publishing, voting, reporting, and installation remain unavailable until the connection returns.
|
||||
|
||||
The public site also caches pages you have visited. If your network drops, a previously opened Workshop or documentation page can still load while the saved catalog remains on the device. Use **Check again** after connectivity returns.
|
||||
|
||||
- Widgets are validated against `homarr-custom-widget-v2` before installation. API keys and other secrets are not included and must be configured locally.
|
||||
- CSS is loaded into the board editor for review. It is not saved until you choose **Save changes**.
|
||||
- The public website never executes submitted widget templates or CSS.
|
||||
|
||||
## Share a creation
|
||||
|
||||
Export a custom widget from Homarr, then choose or drop the JSON file in the Workshop submission form. CSS themes can be pasted into the same form. Sign in with GitHub, provide a public title and description, inspect the source, and publish it.
|
||||
|
||||
Submissions are public immediately. Authors can update or delete their own submissions. Updating content increases its revision and shows the supplied changelog.
|
||||
|
||||
## Votes and reports
|
||||
|
||||
Signed-in members can vote to help useful creations rank higher. Report malicious content, spam, copyright problems, or inappropriate material from a submission's detail page. Reports are visible only to Workshop staff.
|
||||
|
||||
## Accounts and moderation
|
||||
|
||||
Workshop accounts use GitHub OAuth and are separate from users in a self-hosted Homarr instance. PocketBase creates OAuth accounts directly; staff privileges come only from the server-managed `workshop_staff` collection. Workshop moderators can remove submissions, disable an account, and resolve reports. These actions require a reason and are recorded.
|
||||
|
||||
The Workshop is a central service operated by Homarr at `homarr.dev`. The documentation website and Workshop API ship in one image and use the same origin. Running it locally is supported for contributors developing or testing this feature, not as a private production marketplace.
|
||||
|
||||
The service image is built directly from the Homarr repository rather than published separately. Contributors can run `pnpm dev:workshop` to start PocketBase in Docker while Docusaurus and the Workshop contracts watch on the host. Use `pnpm preview:workshop` to build the combined production image, or `pnpm reset:workshop` to explicitly clear the gitignored local PocketBase database. Superusers and GitHub OAuth are configured once in the persistent dashboard instead of being passed as environment variables.
|
||||
@@ -1,43 +1,45 @@
|
||||
---
|
||||
title: 'Audio Stats'
|
||||
description: 'Displays library statistics from Navidrome or Audiobookshelf, adapting its display based on the linked integration.'
|
||||
title: "Audio Stats"
|
||||
description: "Displays media and listening statistics from Navidrome or Audiobookshelf, adapting its display based on the linked integration."
|
||||
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 { audioStatsWidget } from '.';
|
||||
import { navidromeIntegration } from '@site/docs/integrations/navidrome';
|
||||
import { audiobookshelfIntegration } from '@site/docs/integrations/audiobookshelf';
|
||||
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 { audioStatsWidget } from ".";
|
||||
import { navidromeIntegration } from "@site/docs/integrations/navidrome";
|
||||
import { audiobookshelfIntegration } from "@site/docs/integrations/audiobookshelf";
|
||||
|
||||
|
||||
<WidgetHeader
|
||||
widget={audioStatsWidget}
|
||||
categories={['Media']}
|
||||
/>
|
||||
<WidgetHeader widget={audioStatsWidget} categories={["Media"]} />
|
||||
|
||||
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.
|
||||
|
||||
### Supported Integrations
|
||||
|
||||
<WidgetIntegrations items={[{
|
||||
integration: navidromeIntegration,
|
||||
note: "Displays music library stats and now playing"
|
||||
}, {
|
||||
integration: audiobookshelfIntegration,
|
||||
note: "Displays audiobook/podcast library stats and listening time"
|
||||
}]} />
|
||||
<WidgetIntegrations
|
||||
items={[
|
||||
{
|
||||
integration: navidromeIntegration,
|
||||
note: "Displays music library statistics",
|
||||
},
|
||||
{
|
||||
integration: audiobookshelfIntegration,
|
||||
note: "Displays audiobook/podcast library stats and listening time",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
### Adding the widget
|
||||
|
||||
<AddingWidget />
|
||||
|
||||
### Configuration
|
||||
|
||||
<WidgetConfig configuration={audioStatsWidget.configuration} />
|
||||
@@ -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
|
||||
|
||||
|
||||
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,9 +15,6 @@ 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 '.';
|
||||
|
||||
|
||||
@@ -25,16 +23,37 @@ import { downloadsWidget } from '.';
|
||||
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 via the widget options panel
|
||||
- **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 items={[{
|
||||
@@ -52,16 +71,23 @@ 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
|
||||
---
|
||||
|
||||
@@ -37,4 +37,3 @@ import screen from './img/screen.png';
|
||||
|
||||
### Configuration
|
||||
<WidgetConfig configuration={immichAlbumCarouselWidget.configuration} />
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -1,41 +1,44 @@
|
||||
---
|
||||
title: 'Media Request List'
|
||||
description: 'See a list of all media requests from your integration'
|
||||
title: "Media Request List"
|
||||
description: "See a list of all media requests from your integration"
|
||||
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 { 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 { IconAd } from "@tabler/icons-react";
|
||||
import Tabs from '@theme/Tabs';
|
||||
import TabItem from '@theme/TabItem';
|
||||
import { mediaRequestListWidget } from '.';
|
||||
import { jellyseerrIntegration } from '@site/docs/integrations/jellyseerr';
|
||||
import { overseerrIntegration } from '@site/docs/integrations/overseerr';
|
||||
import Tabs from "@theme/Tabs";
|
||||
import TabItem from "@theme/TabItem";
|
||||
import { mediaRequestListWidget } from ".";
|
||||
import { jellyseerrIntegration } from "@site/docs/integrations/jellyseerr";
|
||||
import { overseerrIntegration } from "@site/docs/integrations/overseerr";
|
||||
|
||||
|
||||
<WidgetHeader
|
||||
widget={mediaRequestListWidget}
|
||||
categories={['Media requests']}
|
||||
/>
|
||||
<WidgetHeader widget={mediaRequestListWidget} categories={["Media requests"]} />
|
||||
|
||||
Provides a list of all open and recently closed requests. It also provides the functionality to approve or reject requests.
|
||||
|
||||
You can limit the list to specific request statuses (for example, only show `pending`) and restrict it to requests created within the last N days.
|
||||
|
||||
### Screenshots
|
||||
|
||||
import mediaRequestList from './img/list.png';
|
||||
import mediaRequestList from "./img/list.png";
|
||||
|
||||
<img src={mediaRequestList} alt="media-request-list" width={256} height={256} />
|
||||
|
||||
### Supported Integrations
|
||||
|
||||
<WidgetIntegrations items={[{
|
||||
integration: jellyseerrIntegration,
|
||||
}, {
|
||||
integration: overseerrIntegration,
|
||||
}]} />
|
||||
<WidgetIntegrations
|
||||
items={[
|
||||
{
|
||||
integration: jellyseerrIntegration,
|
||||
},
|
||||
{
|
||||
integration: overseerrIntegration,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
### Interactions
|
||||
|
||||
@@ -47,4 +50,5 @@ import mediaRequestList from './img/list.png';
|
||||
<AddingWidget />
|
||||
|
||||
### Configuration
|
||||
|
||||
<WidgetConfig configuration={mediaRequestListWidget.configuration} />
|
||||
@@ -14,6 +14,18 @@ export const mediaRequestListWidget: WidgetDefinition = {
|
||||
values: { type: "boolean" },
|
||||
defaultValue: "yes",
|
||||
},
|
||||
{
|
||||
name: "Statuses to show",
|
||||
description: "Only requests with these statuses will be shown. Defaults to all statuses.",
|
||||
values: "List of: 'pending', 'approved', 'declined', 'failed', 'completed'",
|
||||
defaultValue: "All statuses",
|
||||
},
|
||||
{
|
||||
name: "Show only recent requests",
|
||||
description: "Limit to requests created within this many days. Set to 0 to disable the time filter.",
|
||||
values: "0-365",
|
||||
defaultValue: "0",
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -39,6 +40,9 @@ import mediaTable from './img/table.png';
|
||||
integration: embyIntegration,
|
||||
}, {
|
||||
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."
|
||||
@@ -50,4 +54,3 @@ import mediaTable from './img/table.png';
|
||||
|
||||
### Configuration
|
||||
<WidgetConfig configuration={mediaServerWidget.configuration} />
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
|
||||
@@ -3,10 +3,6 @@ import type { Config } from "@docusaurus/types";
|
||||
import { themes as prismThemes } from "prism-react-renderer";
|
||||
const a11yEmoji = require("@fec/remark-a11y-emoji");
|
||||
|
||||
// Production uses PocketBase's own origin. The explicit override exists only
|
||||
// for the standalone Docusaurus development server.
|
||||
const workshopApiUrl = process.env.WORKSHOP_API_URL ?? process.env.NEXT_PUBLIC_WORKSHOP_API_URL ?? "";
|
||||
|
||||
const config: Config = {
|
||||
title: "Homarr documentation",
|
||||
tagline: "A simple yet powerful dashboard for your server.",
|
||||
@@ -20,9 +16,6 @@ const config: Config = {
|
||||
defaultLocale: "en",
|
||||
locales: ["en"],
|
||||
},
|
||||
customFields: {
|
||||
workshopApiUrl,
|
||||
},
|
||||
onBrokenLinks: "throw",
|
||||
onBrokenAnchors: "throw",
|
||||
onDuplicateRoutes: "throw",
|
||||
@@ -131,11 +124,6 @@ const config: Config = {
|
||||
position: "left",
|
||||
to: "/blog",
|
||||
},
|
||||
{
|
||||
label: "Workshop",
|
||||
position: "left",
|
||||
to: "/workshop",
|
||||
},
|
||||
{
|
||||
label: "About us",
|
||||
position: "left",
|
||||
@@ -309,7 +297,6 @@ const config: Config = {
|
||||
],
|
||||
"docusaurus-plugin-image-zoom",
|
||||
require.resolve("./plugins/validate-docs-coverage"),
|
||||
require.resolve("./plugins/offline-manifest.mjs"),
|
||||
function disableExpensiveBundlerOptimizationPlugin() {
|
||||
return {
|
||||
name: "disable-expensive-bundler-optimizations",
|
||||
@@ -322,18 +309,6 @@ const config: Config = {
|
||||
},
|
||||
};
|
||||
},
|
||||
function workshopRoutesPlugin() {
|
||||
return {
|
||||
name: "workshop-routes",
|
||||
async contentLoaded({ actions }) {
|
||||
actions.addRoute({
|
||||
path: "/workshop/:id",
|
||||
component: "@site/src/components/workshop/WorkshopDetailRoutePage",
|
||||
exact: true,
|
||||
});
|
||||
},
|
||||
};
|
||||
},
|
||||
"@signalwire/docusaurus-plugin-llms-txt",
|
||||
async function tailwindCssPlugin() {
|
||||
return {
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"license": "Apache-2.0",
|
||||
"scripts": {
|
||||
"docusaurus": "docusaurus",
|
||||
"dev": "docusaurus start --no-open --host 0.0.0.0 --port 3003",
|
||||
"dev": "docusaurus start --no-open --port 3003",
|
||||
"start": "docusaurus start --port 3003",
|
||||
"build": "docusaurus build",
|
||||
"serve": "docusaurus serve",
|
||||
@@ -27,16 +27,12 @@
|
||||
"@docusaurus/plugin-sitemap": "^3.10.1",
|
||||
"@docusaurus/preset-classic": "^3.10.1",
|
||||
"@homarr/definitions": "workspace:^",
|
||||
"@homarr/workshop": "workspace:^0.1.0",
|
||||
"@mdx-js/react": "^3.1.1",
|
||||
"@nivo/line": "^0.99.0",
|
||||
"@scalar/api-reference-react": "catalog:",
|
||||
"@signalwire/docusaurus-plugin-llms-txt": "^1.2.2",
|
||||
"@tabler/icons-react": "catalog:",
|
||||
"@tailwindcss/postcss": "^4.3.2",
|
||||
"@tanstack/query-async-storage-persister": "catalog:",
|
||||
"@tanstack/react-query": "catalog:",
|
||||
"@tanstack/react-query-persist-client": "catalog:",
|
||||
"clsx": "catalog:",
|
||||
"dayjs": "catalog:",
|
||||
"docusaurus-plugin-image-zoom": "^3.0.1",
|
||||
@@ -47,7 +43,7 @@
|
||||
"random-word-slugs": "^0.1.7",
|
||||
"react": "catalog:",
|
||||
"react-dom": "catalog:",
|
||||
"swiper": "^14.0.2",
|
||||
"swiper": "^14.0.5",
|
||||
"tailwindcss": "^4.3.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
import { readdir, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
|
||||
const collectFiles = async (directory, root = directory) => {
|
||||
const entries = await readdir(directory, { withFileTypes: true });
|
||||
const files = await Promise.all(
|
||||
entries.map((entry) => {
|
||||
const absolutePath = path.join(directory, entry.name);
|
||||
return entry.isDirectory() ? collectFiles(absolutePath, root) : path.relative(root, absolutePath);
|
||||
}),
|
||||
);
|
||||
return files.flat();
|
||||
};
|
||||
|
||||
export default function offlineManifestPlugin() {
|
||||
return {
|
||||
name: "workshop-offline-manifest",
|
||||
async postBuild({ outDir }) {
|
||||
const assetsDirectory = path.join(outDir, "assets");
|
||||
const assets = (await collectFiles(assetsDirectory))
|
||||
.filter((file) => /\.(?:css|js|woff2?)$/.test(file))
|
||||
.map((file) => `/assets/${file.split(path.sep).join("/")}`);
|
||||
await writeFile(path.join(outDir, "workshop-assets.json"), JSON.stringify([...assets, "/img/logo.png"]));
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,330 +0,0 @@
|
||||
import React, { useEffect, useMemo, useState } from "react";
|
||||
import useDocusaurusContext from "@docusaurus/useDocusaurusContext";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import { WorkshopClient, type WorkshopAccountState, type WorkshopRole, type WorkshopUser } from "@homarr/workshop";
|
||||
|
||||
import { WorkshopQueryProvider } from "./WorkshopQueryProvider";
|
||||
import { WorkshopDialog, WorkshopServiceState } from "./WorkshopUi";
|
||||
import styles from "./workshop.module.css";
|
||||
|
||||
type PendingAction =
|
||||
| { kind: "report"; id: string; status: "resolved" | "dismissed" }
|
||||
| { kind: "state"; id: string; state: WorkshopAccountState }
|
||||
| { kind: "role"; id: string; role: WorkshopRole };
|
||||
|
||||
export function WorkshopAdmin() {
|
||||
return (
|
||||
<WorkshopQueryProvider>
|
||||
<WorkshopAdminContent />
|
||||
</WorkshopQueryProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function WorkshopAdminContent() {
|
||||
const { siteConfig } = useDocusaurusContext();
|
||||
const client = useMemo(
|
||||
() => new WorkshopClient((siteConfig.customFields?.workshopApiUrl as string | undefined) || window.location.origin),
|
||||
[siteConfig],
|
||||
);
|
||||
const queryClient = useQueryClient();
|
||||
const [user, setUser] = useState<WorkshopUser | null>(null);
|
||||
const [pending, setPending] = useState<PendingAction | null>(null);
|
||||
const [error, setError] = useState("");
|
||||
const [authLoading, setAuthLoading] = useState(true);
|
||||
|
||||
const canModerate = user?.role === "moderator" || user?.role === "admin";
|
||||
const moderationQuery = useQuery({
|
||||
queryKey: ["workshop", "moderation"],
|
||||
queryFn: async () => {
|
||||
const [reports, users, actions] = await Promise.all([
|
||||
client.listReports(),
|
||||
client.listUsers(),
|
||||
client.listModerationActions(),
|
||||
]);
|
||||
return { reports, users, actions };
|
||||
},
|
||||
enabled: canModerate,
|
||||
});
|
||||
const reports = moderationQuery.data?.reports ?? [];
|
||||
const users = moderationQuery.data?.users ?? [];
|
||||
const actions = moderationQuery.data?.actions ?? [];
|
||||
const actionMutation = useMutation({
|
||||
mutationKey: ["workshop", "moderation", "action"],
|
||||
mutationFn: async ({ action, reason }: { action: PendingAction; reason: string }) => {
|
||||
if (action.kind === "report") await client.resolveReport(action.id, action.status, reason);
|
||||
else if (action.kind === "state") await client.updateAccountState(action.id, action.state, reason);
|
||||
else await client.updateRole(action.id, action.role, reason);
|
||||
},
|
||||
onSuccess: async () => {
|
||||
setPending(null);
|
||||
await queryClient.invalidateQueries({ queryKey: ["workshop", "moderation"] });
|
||||
},
|
||||
});
|
||||
const signInMutation = useMutation({
|
||||
mutationKey: ["workshop", "auth", "moderator"],
|
||||
mutationFn: () => client.signInWithGitHub(),
|
||||
onSuccess: setUser,
|
||||
onError: (caught) => setError(caught instanceof Error ? caught.message : "GitHub sign-in is unavailable"),
|
||||
});
|
||||
|
||||
useEffect(() => client.subscribeToAuth(setUser), [client]);
|
||||
useEffect(() => {
|
||||
void client
|
||||
.refreshAuth()
|
||||
.then(setUser)
|
||||
.catch((caught: unknown) =>
|
||||
setError(caught instanceof Error ? caught.message : "Workshop authentication is unavailable"),
|
||||
)
|
||||
.finally(() => setAuthLoading(false));
|
||||
}, [client]);
|
||||
|
||||
if (authLoading)
|
||||
return (
|
||||
<main className={styles.page}>
|
||||
<WorkshopAdminSkeleton />
|
||||
</main>
|
||||
);
|
||||
if (!user)
|
||||
return (
|
||||
<main className={styles.page}>
|
||||
<h1>Workshop moderation</h1>
|
||||
<p>Sign in with a promoted GitHub account to continue.</p>
|
||||
{error && (
|
||||
<div className={styles.error} role="alert">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
className="button button--primary"
|
||||
disabled={signInMutation.isPending}
|
||||
onClick={() => {
|
||||
setError("");
|
||||
signInMutation.mutate();
|
||||
}}
|
||||
>
|
||||
{signInMutation.isPending ? "Connecting…" : "Sign in with GitHub"}
|
||||
</button>
|
||||
</main>
|
||||
);
|
||||
if (user.role === "member")
|
||||
return (
|
||||
<main className={styles.page}>
|
||||
<h1>Workshop moderation</h1>
|
||||
<div className={styles.error} role="alert">
|
||||
Your account does not have moderation access.
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
|
||||
return (
|
||||
<main className={styles.page}>
|
||||
<header className={styles.hero}>
|
||||
<div>
|
||||
<h1>Workshop moderation</h1>
|
||||
<p>Review reports and apply the smallest action needed. Every action requires a reason and is recorded.</p>
|
||||
</div>
|
||||
</header>
|
||||
{(error || moderationQuery.isError) && (
|
||||
<WorkshopServiceState
|
||||
cached={Boolean(moderationQuery.data)}
|
||||
message={error || "Moderation data is unavailable. No action can be applied until Workshop reconnects."}
|
||||
onRetry={() => void moderationQuery.refetch()}
|
||||
/>
|
||||
)}
|
||||
<section className={styles.adminSection}>
|
||||
<h2>Open reports</h2>
|
||||
{reports.length === 0 ? (
|
||||
<p>No open reports.</p>
|
||||
) : (
|
||||
<div className={styles.tableWrap}>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Category</th>
|
||||
<th>Submission</th>
|
||||
<th>Details</th>
|
||||
<th>Created</th>
|
||||
<th>Action</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{reports.map((report) => (
|
||||
<tr key={report.id}>
|
||||
<td>{report.category}</td>
|
||||
<td>{report.submission}</td>
|
||||
<td>{report.explanation}</td>
|
||||
<td>{new Date(report.created).toLocaleString()}</td>
|
||||
<td aria-label="Report actions">
|
||||
<div className={styles.rowActions}>
|
||||
<button onClick={() => setPending({ kind: "report", id: report.id, status: "resolved" })}>
|
||||
Resolve
|
||||
</button>
|
||||
<button onClick={() => setPending({ kind: "report", id: report.id, status: "dismissed" })}>
|
||||
Dismiss
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
<section className={styles.adminSection}>
|
||||
<h2>Community accounts</h2>
|
||||
<div className={styles.tableWrap}>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>User</th>
|
||||
<th>Role</th>
|
||||
<th>State</th>
|
||||
<th>Moderate</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{users.map((account) => (
|
||||
<tr key={account.id}>
|
||||
<td>{account.displayName}</td>
|
||||
<td>{account.role}</td>
|
||||
<td>{account.state}</td>
|
||||
<td>
|
||||
{account.id === user.id ? (
|
||||
"Current account"
|
||||
) : (
|
||||
<div className={styles.rowActions}>
|
||||
<button
|
||||
onClick={() =>
|
||||
setPending({
|
||||
kind: "state",
|
||||
id: account.id,
|
||||
state: account.state === "disabled" ? "active" : "disabled",
|
||||
})
|
||||
}
|
||||
>
|
||||
{account.state === "disabled" ? "Restore" : "Disable"}
|
||||
</button>
|
||||
{user.role === "admin" && (
|
||||
<button
|
||||
onClick={() =>
|
||||
setPending({
|
||||
kind: "role",
|
||||
id: account.id,
|
||||
role: account.role === "member" ? "moderator" : "member",
|
||||
})
|
||||
}
|
||||
>
|
||||
{account.role === "member" ? "Promote" : "Set member"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
<section className={styles.adminSection}>
|
||||
<h2>Recent actions</h2>
|
||||
<div className={styles.tableWrap}>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Action</th>
|
||||
<th>Target</th>
|
||||
<th>Reason</th>
|
||||
<th>Created</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{actions.map((action) => (
|
||||
<tr key={action.id}>
|
||||
<td>{action.action}</td>
|
||||
<td>
|
||||
{action.targetType}:{action.targetId}
|
||||
</td>
|
||||
<td>{action.reason}</td>
|
||||
<td>{new Date(action.created).toLocaleString()}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
{pending && (
|
||||
<ReasonDialog
|
||||
action={pending}
|
||||
onClose={() => setPending(null)}
|
||||
onConfirm={(reason) => actionMutation.mutateAsync({ action: pending, reason })}
|
||||
/>
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
function WorkshopAdminSkeleton() {
|
||||
return (
|
||||
<div aria-label="Loading Workshop moderation">
|
||||
<h1>Workshop moderation</h1>
|
||||
<p>Loading account and moderation data…</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ReasonDialog({
|
||||
action,
|
||||
onClose,
|
||||
onConfirm,
|
||||
}: {
|
||||
action: PendingAction;
|
||||
onClose: () => void;
|
||||
onConfirm: (reason: string) => Promise<void>;
|
||||
}) {
|
||||
const [reason, setReason] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
const [saving, setSaving] = useState(false);
|
||||
return (
|
||||
<WorkshopDialog titleId="moderation-reason-title" onClose={onClose}>
|
||||
<h2 id="moderation-reason-title">Confirm moderation action</h2>
|
||||
<p>
|
||||
This will apply <strong>{action.kind}</strong> to the selected record.
|
||||
</p>
|
||||
<form
|
||||
onSubmit={async (event) => {
|
||||
event.preventDefault();
|
||||
setSaving(true);
|
||||
try {
|
||||
await onConfirm(reason);
|
||||
} catch (caught) {
|
||||
setError(caught instanceof Error ? caught.message : "Action failed");
|
||||
setSaving(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<label>
|
||||
Reason
|
||||
<textarea
|
||||
required
|
||||
minLength={3}
|
||||
maxLength={1000}
|
||||
value={reason}
|
||||
onChange={(event) => setReason(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
{error && <p className={styles.error}>{error}</p>}
|
||||
<div className={styles.formActions}>
|
||||
<button type="button" className="button button--secondary" onClick={onClose}>
|
||||
Cancel
|
||||
</button>
|
||||
<button disabled={saving} className="button button--danger">
|
||||
{saving ? "Applying…" : "Apply action"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</WorkshopDialog>
|
||||
);
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
import React from "react";
|
||||
import BrowserOnly from "@docusaurus/BrowserOnly";
|
||||
import Layout from "@theme/Layout";
|
||||
|
||||
export default function WorkshopDetailRoutePage() {
|
||||
return (
|
||||
<Layout title="Workshop submission">
|
||||
<BrowserOnly fallback={<main style={{ minHeight: "70vh" }} />}>
|
||||
{() => {
|
||||
const { WorkshopDetailRoute } = require("./WorkshopPage");
|
||||
return <WorkshopDetailRoute />;
|
||||
}}
|
||||
</BrowserOnly>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
@@ -1,740 +0,0 @@
|
||||
import React, { useEffect, useMemo, useState } from "react";
|
||||
import Link from "@docusaurus/Link";
|
||||
import { useHistory, useLocation } from "@docusaurus/router";
|
||||
import useDocusaurusContext from "@docusaurus/useDocusaurusContext";
|
||||
import {
|
||||
IconAlertTriangle,
|
||||
IconBrandGithub,
|
||||
IconCopy,
|
||||
IconDownload,
|
||||
IconFlag,
|
||||
IconHeart,
|
||||
IconPackage,
|
||||
IconSearch,
|
||||
IconSparkles,
|
||||
IconUpload,
|
||||
} from "@tabler/icons-react";
|
||||
|
||||
import {
|
||||
MAX_CONTENT_LENGTH,
|
||||
MAX_SCREENSHOTS,
|
||||
WorkshopClient,
|
||||
type WorkshopSubmissionDetail,
|
||||
type WorkshopSubmissionInput,
|
||||
type WorkshopSubmissionSummary,
|
||||
type WorkshopSubmissionType,
|
||||
type WorkshopUser,
|
||||
workshopExportFilename,
|
||||
} from "@homarr/workshop";
|
||||
import { keepPreviousData, useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import { WorkshopQueryProvider } from "./WorkshopQueryProvider";
|
||||
import { WorkshopDialog, WorkshopServiceState } from "./WorkshopUi";
|
||||
import styles from "./workshop.module.css";
|
||||
|
||||
const useWorkshop = () => {
|
||||
const { siteConfig } = useDocusaurusContext();
|
||||
const apiUrl = (siteConfig.customFields?.workshopApiUrl as string | undefined) || window.location.origin;
|
||||
return useMemo(() => new WorkshopClient(apiUrl), [apiUrl]);
|
||||
};
|
||||
|
||||
const saveContent = (submission: WorkshopSubmissionDetail) => {
|
||||
const url = URL.createObjectURL(
|
||||
new Blob([submission.content], { type: submission.type === "css" ? "text/css" : "application/json" }),
|
||||
);
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.download = workshopExportFilename(submission.title, submission.type);
|
||||
link.click();
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
export function WorkshopApp() {
|
||||
return (
|
||||
<WorkshopQueryProvider>
|
||||
<WorkshopAppContent />
|
||||
</WorkshopQueryProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function WorkshopAppContent() {
|
||||
const client = useWorkshop();
|
||||
const queryClient = useQueryClient();
|
||||
const [user, setUser] = useState<WorkshopUser | null>(null);
|
||||
const [page, setPage] = useState(1);
|
||||
const [type, setType] = useState<WorkshopSubmissionType | "all">("all");
|
||||
const [sort, setSort] = useState<"top" | "newest">("top");
|
||||
const [search, setSearch] = useState("");
|
||||
const [debouncedSearch, setDebouncedSearch] = useState("");
|
||||
const [mine, setMine] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
const signInMutation = useMutation({
|
||||
mutationKey: ["workshop", "auth", "sign-in"],
|
||||
mutationFn: () => client.signInWithGitHub(),
|
||||
onSuccess: async () => queryClient.invalidateQueries({ queryKey: ["workshop"] }),
|
||||
onError: (caught) => setError(caught instanceof Error ? caught.message : "Sign-in failed"),
|
||||
});
|
||||
|
||||
useEffect(() => client.subscribeToAuth(setUser), [client]);
|
||||
useEffect(() => {
|
||||
void client.refreshAuth().catch(() => undefined);
|
||||
}, [client]);
|
||||
useEffect(() => {
|
||||
const timeout = window.setTimeout(() => setDebouncedSearch(search), 250);
|
||||
return () => window.clearTimeout(timeout);
|
||||
}, [search]);
|
||||
|
||||
const listQuery = useQuery({
|
||||
queryKey: ["workshop", "list", page, type, sort, debouncedSearch, mine ? user?.id : null],
|
||||
queryFn: ({ signal }) =>
|
||||
client.list({
|
||||
page,
|
||||
type,
|
||||
sort,
|
||||
search: debouncedSearch,
|
||||
author: mine ? user?.id : undefined,
|
||||
signal,
|
||||
}),
|
||||
placeholderData: keepPreviousData,
|
||||
});
|
||||
const items = listQuery.data?.items ?? [];
|
||||
const totalPages = listQuery.data?.totalPages ?? 1;
|
||||
|
||||
return (
|
||||
<main className={styles.page}>
|
||||
<header className={styles.hero}>
|
||||
<div>
|
||||
<span className={styles.eyebrow}>Made by the Homarr community</span>
|
||||
<h1>Workshop</h1>
|
||||
<p>Discover useful creations, inspect every line, then bring the ones you trust into Homarr.</p>
|
||||
<ul className={styles.heroPromises} aria-label="Workshop principles">
|
||||
<li>
|
||||
<IconSparkles size={16} /> Community-made
|
||||
</li>
|
||||
<li>
|
||||
<IconPackage size={16} /> Ready to export
|
||||
</li>
|
||||
<li>
|
||||
<IconHeart size={16} /> Ranked by members
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div className={styles.accountActions}>
|
||||
{user ? (
|
||||
<>
|
||||
{(user.role === "moderator" || user.role === "admin") && (
|
||||
<Link className="button button--secondary" to="/workshop/admin">
|
||||
Moderate
|
||||
</Link>
|
||||
)}
|
||||
<button className="button button--primary" onClick={() => setShowForm(true)}>
|
||||
<IconUpload size={17} /> Share creation
|
||||
</button>
|
||||
<button className="button button--secondary" onClick={() => client.signOut()}>
|
||||
Sign out
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<button
|
||||
className="button button--primary"
|
||||
disabled={signInMutation.isPending}
|
||||
onClick={() => signInMutation.mutate()}
|
||||
>
|
||||
<IconBrandGithub size={18} /> {signInMutation.isPending ? "Connecting…" : "Sign in with GitHub"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section className={styles.safety} aria-label="Community content warning">
|
||||
<IconAlertTriangle aria-hidden size={20} />
|
||||
<p>
|
||||
Workshop content is community-provided. Inspect source before installing it and keep a current Homarr backup.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<div className={styles.toolbar}>
|
||||
<label className={styles.search}>
|
||||
<span className="sr-only">Search Workshop</span>
|
||||
<IconSearch aria-hidden size={18} />
|
||||
<input
|
||||
value={search}
|
||||
onChange={(event) => {
|
||||
setSearch(event.target.value);
|
||||
setPage(1);
|
||||
}}
|
||||
placeholder="Search creations"
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Type
|
||||
<select
|
||||
value={type}
|
||||
onChange={(event) => {
|
||||
setType(event.target.value as typeof type);
|
||||
setPage(1);
|
||||
}}
|
||||
>
|
||||
<option value="all">All</option>
|
||||
<option value="widget">Widgets</option>
|
||||
<option value="css">CSS themes</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Sort
|
||||
<select
|
||||
value={sort}
|
||||
onChange={(event) => {
|
||||
setSort(event.target.value as typeof sort);
|
||||
setPage(1);
|
||||
}}
|
||||
>
|
||||
<option value="top">Top rated</option>
|
||||
<option value="newest">Newest</option>
|
||||
</select>
|
||||
</label>
|
||||
{user && (
|
||||
<label className={styles.checkbox}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={mine}
|
||||
onChange={(event) => {
|
||||
setMine(event.target.checked);
|
||||
setPage(1);
|
||||
}}
|
||||
/>{" "}
|
||||
My submissions
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{(error || listQuery.isError || listQuery.fetchStatus === "paused") && (
|
||||
<WorkshopServiceState
|
||||
cached={items.length > 0}
|
||||
message={error || undefined}
|
||||
onRetry={() => void listQuery.refetch()}
|
||||
/>
|
||||
)}
|
||||
{listQuery.isPending ? (
|
||||
<WorkshopSkeleton />
|
||||
) : items.length ? (
|
||||
<div className={styles.grid}>
|
||||
{items.map((item) => (
|
||||
<WorkshopCard client={client} item={item} key={item.id} />
|
||||
))}
|
||||
</div>
|
||||
) : listQuery.isError || listQuery.fetchStatus === "paused" ? null : (
|
||||
<section className={styles.empty}>
|
||||
<IconSearch size={32} aria-hidden />
|
||||
<h2>No creations found</h2>
|
||||
<p>Try a broader search or clear the filters to explore everything the community has shared.</p>
|
||||
<button
|
||||
className="button button--secondary button--sm"
|
||||
onClick={() => {
|
||||
setSearch("");
|
||||
setType("all");
|
||||
setSort("top");
|
||||
setPage(1);
|
||||
}}
|
||||
>
|
||||
Clear filters
|
||||
</button>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{totalPages > 1 && (
|
||||
<nav className={styles.pagination} aria-label="Workshop pages">
|
||||
<button disabled={page === 1} onClick={() => setPage((value) => value - 1)}>
|
||||
Previous
|
||||
</button>
|
||||
<span>
|
||||
Page {page} of {totalPages}
|
||||
</span>
|
||||
<button disabled={page === totalPages} onClick={() => setPage((value) => value + 1)}>
|
||||
Next
|
||||
</button>
|
||||
</nav>
|
||||
)}
|
||||
{showForm && (
|
||||
<SubmissionForm
|
||||
client={client}
|
||||
onClose={() => setShowForm(false)}
|
||||
onSaved={async () => {
|
||||
setShowForm(false);
|
||||
await queryClient.invalidateQueries({ queryKey: ["workshop"] });
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
function WorkshopCard({ client, item }: { client: WorkshopClient; item: WorkshopSubmissionSummary }) {
|
||||
const image = item.screenshots[0];
|
||||
return (
|
||||
<article className={styles.card}>
|
||||
{image ? (
|
||||
<img src={client.fileUrl(item.id, image, "480x320")} alt="" loading="lazy" />
|
||||
) : (
|
||||
<div className={styles.cardPlaceholder} aria-hidden>
|
||||
{item.type === "widget" ? "{}" : "CSS"}
|
||||
</div>
|
||||
)}
|
||||
<div className={styles.cardBody}>
|
||||
<div className={styles.cardMeta}>
|
||||
<span>{item.type === "widget" ? "Widget" : "CSS theme"}</span>
|
||||
<span>
|
||||
{item.score >= 0 ? "+" : ""}
|
||||
{item.score}
|
||||
</span>
|
||||
</div>
|
||||
<h2>
|
||||
<Link className={styles.cardLink} to={`/workshop/${item.id}`}>
|
||||
{item.title}
|
||||
</Link>
|
||||
</h2>
|
||||
<p>{item.description || "No description provided."}</p>
|
||||
<small>
|
||||
By {item.authorName} · revision {item.revision}
|
||||
</small>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
export function WorkshopDetailRoute() {
|
||||
return (
|
||||
<WorkshopQueryProvider>
|
||||
<WorkshopDetailRouteContent />
|
||||
</WorkshopQueryProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function WorkshopDetailRouteContent() {
|
||||
const client = useWorkshop();
|
||||
const queryClient = useQueryClient();
|
||||
const location = useLocation();
|
||||
const id = location.pathname.split("/").filter(Boolean).at(-1) ?? "";
|
||||
const history = useHistory();
|
||||
const [user, setUser] = useState<WorkshopUser | null>(null);
|
||||
const [reporting, setReporting] = useState(false);
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [moderating, setModerating] = useState(false);
|
||||
const [actionError, setActionError] = useState("");
|
||||
|
||||
const detailQuery = useQuery({
|
||||
queryKey: ["workshop", "detail", id],
|
||||
queryFn: ({ signal }) => client.get(id, signal),
|
||||
enabled: id.length > 0,
|
||||
});
|
||||
const voteMutation = useMutation({
|
||||
mutationKey: ["workshop", "vote", id],
|
||||
mutationFn: async () => {
|
||||
await (client.currentUser ?? client.signInWithGitHub());
|
||||
return client.vote(id, 1);
|
||||
},
|
||||
onSuccess: async () => queryClient.invalidateQueries({ queryKey: ["workshop", "detail", id] }),
|
||||
onError: (caught) => setActionError(caught instanceof Error ? caught.message : "The vote could not be saved."),
|
||||
});
|
||||
const deleteMutation = useMutation({
|
||||
mutationKey: ["workshop", "delete", id],
|
||||
mutationFn: () => client.delete(id),
|
||||
onSuccess: () => history.push("/workshop"),
|
||||
onError: (caught) =>
|
||||
setActionError(caught instanceof Error ? caught.message : "The submission could not be deleted."),
|
||||
});
|
||||
useEffect(() => client.subscribeToAuth(setUser), [client]);
|
||||
useEffect(() => {
|
||||
void client.refreshAuth();
|
||||
}, [client]);
|
||||
|
||||
if (detailQuery.isError)
|
||||
return (
|
||||
<main className={styles.page}>
|
||||
<div className={styles.error} role="alert">
|
||||
This creation is not saved on this device and Workshop could not be reached.{" "}
|
||||
<button onClick={() => void detailQuery.refetch()}>Try again</button>
|
||||
</div>
|
||||
<Link to="/workshop">Back to Workshop</Link>
|
||||
</main>
|
||||
);
|
||||
if (!detailQuery.data) return <WorkshopSkeleton />;
|
||||
|
||||
const submission = detailQuery.data;
|
||||
return (
|
||||
<main className={styles.page}>
|
||||
<Link to="/workshop">← Back to Workshop</Link>
|
||||
<article className={styles.detail}>
|
||||
<header>
|
||||
<span className={styles.type}>{submission.type === "widget" ? "Widget" : "CSS theme"}</span>
|
||||
<h1>{submission.title}</h1>
|
||||
<p>{submission.description}</p>
|
||||
<small>
|
||||
By {submission.authorName} · revision {submission.revision}
|
||||
</small>
|
||||
</header>
|
||||
{submission.screenshots.length > 0 && (
|
||||
<div className={styles.gallery}>
|
||||
{submission.screenshots.map((image, index) => (
|
||||
<img
|
||||
key={image}
|
||||
src={client.fileUrl(submission.id, image, "960x640")}
|
||||
alt={`${submission.title} screenshot ${index + 1}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className={styles.detailActions}>
|
||||
<button className="button button--primary" onClick={() => saveContent(submission)}>
|
||||
<IconDownload size={17} /> Download {submission.type === "widget" ? "JSON" : "CSS"}
|
||||
</button>
|
||||
<button
|
||||
className="button button--secondary"
|
||||
onClick={() => void navigator.clipboard.writeText(submission.content)}
|
||||
>
|
||||
<IconCopy size={17} /> Copy source
|
||||
</button>
|
||||
<button
|
||||
className="button button--secondary"
|
||||
disabled={voteMutation.isPending}
|
||||
onClick={() => {
|
||||
setActionError("");
|
||||
voteMutation.mutate();
|
||||
}}
|
||||
>
|
||||
Upvote ({submission.upvotes})
|
||||
</button>
|
||||
<button className="button button--secondary" onClick={() => setReporting(true)}>
|
||||
<IconFlag size={16} /> Report
|
||||
</button>
|
||||
{user?.id === submission.author && (
|
||||
<>
|
||||
<button className="button button--secondary" onClick={() => setEditing(true)}>
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
className="button button--danger"
|
||||
disabled={deleteMutation.isPending}
|
||||
onClick={() => {
|
||||
if (window.confirm("Delete this submission permanently?")) {
|
||||
setActionError("");
|
||||
deleteMutation.mutate();
|
||||
}
|
||||
}}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{(user?.role === "moderator" || user?.role === "admin") && user.id !== submission.author && (
|
||||
<button className="button button--danger" onClick={() => setModerating(true)}>
|
||||
Remove submission
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{actionError && (
|
||||
<div className={styles.error} role="alert">
|
||||
{actionError}
|
||||
</div>
|
||||
)}
|
||||
<section>
|
||||
<h2>Source</h2>
|
||||
<p className={styles.sourceHint}>
|
||||
Review this content before using it. Workshop content is never executed on homarr.dev.
|
||||
</p>
|
||||
<pre className={styles.source}>
|
||||
<code>{submission.content}</code>
|
||||
</pre>
|
||||
</section>
|
||||
{submission.changelog && (
|
||||
<section>
|
||||
<h2>Latest changes</h2>
|
||||
<p>{submission.changelog}</p>
|
||||
</section>
|
||||
)}
|
||||
</article>
|
||||
{reporting && <ReportForm client={client} submissionId={submission.id} onClose={() => setReporting(false)} />}
|
||||
{editing && (
|
||||
<SubmissionForm
|
||||
client={client}
|
||||
existing={submission}
|
||||
onClose={() => setEditing(false)}
|
||||
onSaved={async () => {
|
||||
setEditing(false);
|
||||
await queryClient.invalidateQueries({ queryKey: ["workshop", "detail", id] });
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{moderating && (
|
||||
<ModerationRemovalForm
|
||||
client={client}
|
||||
submission={submission}
|
||||
onClose={() => setModerating(false)}
|
||||
onRemoved={() => history.push("/workshop/admin")}
|
||||
/>
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
function ModerationRemovalForm({
|
||||
client,
|
||||
submission,
|
||||
onClose,
|
||||
onRemoved,
|
||||
}: {
|
||||
client: WorkshopClient;
|
||||
submission: WorkshopSubmissionDetail;
|
||||
onClose: () => void;
|
||||
onRemoved: () => void;
|
||||
}) {
|
||||
const [reason, setReason] = useState("");
|
||||
const removeMutation = useMutation({
|
||||
mutationKey: ["workshop", "moderation", "remove", submission.id],
|
||||
mutationFn: () => client.removeSubmission(submission.id, reason),
|
||||
onSuccess: onRemoved,
|
||||
});
|
||||
return (
|
||||
<WorkshopDialog titleId="remove-submission-title" onClose={onClose}>
|
||||
<h2 id="remove-submission-title">Remove {submission.title}</h2>
|
||||
<p>
|
||||
This permanently deletes the submission, files, votes, and reports. A metadata snapshot is retained in
|
||||
moderation history.
|
||||
</p>
|
||||
<form
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
removeMutation.mutate();
|
||||
}}
|
||||
>
|
||||
<label>
|
||||
Reason
|
||||
<textarea
|
||||
required
|
||||
minLength={3}
|
||||
maxLength={1000}
|
||||
value={reason}
|
||||
onChange={(event) => setReason(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
{removeMutation.error && (
|
||||
<p className={styles.error} role="alert">
|
||||
{removeMutation.error instanceof Error ? removeMutation.error.message : "Removal failed"}
|
||||
</p>
|
||||
)}
|
||||
<div className={styles.formActions}>
|
||||
<button type="button" className="button button--secondary" onClick={onClose}>
|
||||
Cancel
|
||||
</button>
|
||||
<button disabled={removeMutation.isPending} className="button button--danger">
|
||||
{removeMutation.isPending ? "Removing…" : "Remove permanently"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</WorkshopDialog>
|
||||
);
|
||||
}
|
||||
|
||||
function SubmissionForm({
|
||||
client,
|
||||
existing,
|
||||
onClose,
|
||||
onSaved,
|
||||
}: {
|
||||
client: WorkshopClient;
|
||||
existing?: WorkshopSubmissionDetail;
|
||||
onClose: () => void;
|
||||
onSaved: () => void;
|
||||
}) {
|
||||
const [type, setType] = useState<WorkshopSubmissionType>(existing?.type ?? "widget");
|
||||
const [title, setTitle] = useState(existing?.title ?? "");
|
||||
const [description, setDescription] = useState(existing?.description ?? "");
|
||||
const [content, setContent] = useState(existing?.content ?? "");
|
||||
const [changelog, setChangelog] = useState(existing?.changelog ?? "");
|
||||
const [screenshots, setScreenshots] = useState<File[]>([]);
|
||||
const [removedScreenshots, setRemovedScreenshots] = useState<string[]>([]);
|
||||
const retainedScreenshotCount = (existing?.screenshots.length ?? 0) - removedScreenshots.length;
|
||||
const remainingScreenshotSlots = Math.max(0, MAX_SCREENSHOTS - retainedScreenshotCount);
|
||||
useEffect(() => {
|
||||
setScreenshots((current) => current.slice(0, remainingScreenshotSlots));
|
||||
}, [remainingScreenshotSlots]);
|
||||
const saveMutation = useMutation({
|
||||
mutationKey: ["workshop", existing ? "update" : "create", existing?.id],
|
||||
mutationFn: async (input: WorkshopSubmissionInput) => {
|
||||
if (!client.currentUser) await client.signInWithGitHub();
|
||||
if (existing) {
|
||||
return client.update(existing.id, input, screenshots, removedScreenshots);
|
||||
}
|
||||
return client.create(input, screenshots);
|
||||
},
|
||||
onSuccess: onSaved,
|
||||
});
|
||||
return (
|
||||
<WorkshopDialog titleId="submission-form-title" onClose={onClose}>
|
||||
<h2 id="submission-form-title">{existing ? "Update creation" : "Share with the community"}</h2>
|
||||
<form
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
saveMutation.mutate({ type, title, description, content, changelog });
|
||||
}}
|
||||
>
|
||||
<label>
|
||||
Type
|
||||
<select
|
||||
disabled={Boolean(existing)}
|
||||
value={type}
|
||||
onChange={(event) => setType(event.target.value as WorkshopSubmissionType)}
|
||||
>
|
||||
<option value="widget">Custom widget</option>
|
||||
<option value="css">CSS theme</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Title
|
||||
<input
|
||||
required
|
||||
minLength={3}
|
||||
maxLength={100}
|
||||
value={title}
|
||||
onChange={(event) => setTitle(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Description
|
||||
<textarea maxLength={2000} value={description} onChange={(event) => setDescription(event.target.value)} />
|
||||
</label>
|
||||
<label>
|
||||
{type === "widget" ? "Widget JSON" : "CSS source"}
|
||||
<textarea
|
||||
className={styles.editor}
|
||||
required
|
||||
maxLength={MAX_CONTENT_LENGTH}
|
||||
value={content}
|
||||
onChange={(event) => setContent(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Changelog
|
||||
<textarea maxLength={2000} value={changelog} onChange={(event) => setChangelog(event.target.value)} />
|
||||
</label>
|
||||
<label>
|
||||
Screenshots (PNG, JPG, or WebP, {remainingScreenshotSlots} available)
|
||||
<input
|
||||
type="file"
|
||||
accept="image/png,image/jpeg,image/webp"
|
||||
multiple
|
||||
onChange={(event) =>
|
||||
setScreenshots(Array.from(event.target.files ?? []).slice(0, remainingScreenshotSlots))
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
{existing?.screenshots.map((screenshot) => (
|
||||
<label className={styles.checkbox} key={screenshot}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={removedScreenshots.includes(screenshot)}
|
||||
onChange={(event) =>
|
||||
setRemovedScreenshots((current) =>
|
||||
event.target.checked
|
||||
? [...current, screenshot]
|
||||
: current.filter((filename) => filename !== screenshot),
|
||||
)
|
||||
}
|
||||
/>
|
||||
Remove existing screenshot {screenshot}
|
||||
</label>
|
||||
))}
|
||||
{saveMutation.error && (
|
||||
<p className={styles.error} role="alert">
|
||||
{saveMutation.error instanceof Error ? saveMutation.error.message : "Could not save submission"}
|
||||
</p>
|
||||
)}
|
||||
<div className={styles.formActions}>
|
||||
<button type="button" className="button button--secondary" onClick={onClose}>
|
||||
Cancel
|
||||
</button>
|
||||
<button disabled={saveMutation.isPending} className="button button--primary">
|
||||
{saveMutation.isPending ? "Saving…" : existing ? "Save update" : "Publish creation"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</WorkshopDialog>
|
||||
);
|
||||
}
|
||||
|
||||
function ReportForm({
|
||||
client,
|
||||
submissionId,
|
||||
onClose,
|
||||
}: {
|
||||
client: WorkshopClient;
|
||||
submissionId: string;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const [category, setCategory] = useState<"malicious" | "spam" | "copyright" | "inappropriate" | "other">("malicious");
|
||||
const [explanation, setExplanation] = useState("");
|
||||
const reportMutation = useMutation({
|
||||
mutationKey: ["workshop", "report", submissionId],
|
||||
mutationFn: async () => {
|
||||
if (!client.currentUser) await client.signInWithGitHub();
|
||||
return client.report(submissionId, category, explanation);
|
||||
},
|
||||
onSuccess: onClose,
|
||||
});
|
||||
return (
|
||||
<WorkshopDialog titleId="report-title" onClose={onClose}>
|
||||
<h2 id="report-title">Report submission</h2>
|
||||
<form
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
reportMutation.mutate();
|
||||
}}
|
||||
>
|
||||
<label>
|
||||
Reason
|
||||
<select value={category} onChange={(event) => setCategory(event.target.value as typeof category)}>
|
||||
<option value="malicious">Malicious</option>
|
||||
<option value="spam">Spam</option>
|
||||
<option value="copyright">Copyright</option>
|
||||
<option value="inappropriate">Inappropriate</option>
|
||||
<option value="other">Other</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Details
|
||||
<textarea
|
||||
required
|
||||
minLength={3}
|
||||
maxLength={1000}
|
||||
value={explanation}
|
||||
onChange={(event) => setExplanation(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
{reportMutation.error && (
|
||||
<p className={styles.error} role="alert">
|
||||
{reportMutation.error instanceof Error ? reportMutation.error.message : "Could not send report"}
|
||||
</p>
|
||||
)}
|
||||
<div className={styles.formActions}>
|
||||
<button type="button" className="button button--secondary" onClick={onClose}>
|
||||
Cancel
|
||||
</button>
|
||||
<button disabled={reportMutation.isPending} className="button button--primary">
|
||||
{reportMutation.isPending ? "Sending…" : "Submit report"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</WorkshopDialog>
|
||||
);
|
||||
}
|
||||
|
||||
function WorkshopSkeleton() {
|
||||
return (
|
||||
<div className={styles.grid} aria-label="Loading Workshop">
|
||||
{Array.from({ length: 6 }, (_, index) => (
|
||||
<div className={styles.skeleton} key={index} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
import React, { type PropsWithChildren, useState } from "react";
|
||||
import { createAsyncStoragePersister } from "@tanstack/query-async-storage-persister";
|
||||
import { QueryClient } from "@tanstack/react-query";
|
||||
import { PersistQueryClientProvider } from "@tanstack/react-query-persist-client";
|
||||
|
||||
const maxAge = 1000 * 60 * 60 * 24;
|
||||
|
||||
export function WorkshopQueryProvider({ children }: PropsWithChildren) {
|
||||
const [client] = useState(
|
||||
() =>
|
||||
new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
gcTime: maxAge,
|
||||
networkMode: "offlineFirst",
|
||||
retry: 1,
|
||||
staleTime: 1000 * 60 * 5,
|
||||
},
|
||||
mutations: { networkMode: "online", retry: false },
|
||||
},
|
||||
}),
|
||||
);
|
||||
const [persister] = useState(() =>
|
||||
createAsyncStoragePersister({
|
||||
key: "homarr-workshop-website-cache-v1",
|
||||
storage: window.localStorage,
|
||||
}),
|
||||
);
|
||||
|
||||
return (
|
||||
<PersistQueryClientProvider
|
||||
client={client}
|
||||
persistOptions={{
|
||||
persister,
|
||||
maxAge,
|
||||
buster: "workshop-v1",
|
||||
dehydrateOptions: { shouldDehydrateQuery: (query) => query.state.status === "success" },
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</PersistQueryClientProvider>
|
||||
);
|
||||
}
|
||||
@@ -1,69 +0,0 @@
|
||||
import React, { type PropsWithChildren, useEffect, useRef } from "react";
|
||||
import Link from "@docusaurus/Link";
|
||||
import { IconBooks, IconCloudOff, IconRefresh } from "@tabler/icons-react";
|
||||
|
||||
import styles from "./workshop.module.css";
|
||||
|
||||
export function WorkshopServiceState({
|
||||
cached,
|
||||
message,
|
||||
onRetry,
|
||||
}: {
|
||||
cached: boolean;
|
||||
message?: string;
|
||||
onRetry: () => void;
|
||||
}) {
|
||||
return (
|
||||
<section className={styles.serviceState} aria-live="polite">
|
||||
<span className={styles.serviceStateIcon} aria-hidden>
|
||||
<IconCloudOff size={24} />
|
||||
</span>
|
||||
<div>
|
||||
<h2>{cached ? "Browsing saved creations" : "Workshop is taking a break"}</h2>
|
||||
<p>
|
||||
{message ??
|
||||
(cached
|
||||
? "The catalog saved on this device is still available. Live details and community actions will return when the service reconnects."
|
||||
: "Community creations cannot be reached right now. Homarr and its documentation remain fully available.")}
|
||||
</p>
|
||||
<div className={styles.serviceStateActions}>
|
||||
<button className="button button--primary button--sm" onClick={onRetry}>
|
||||
<IconRefresh size={16} /> Check again
|
||||
</button>
|
||||
<Link className="button button--secondary button--sm" to="/docs/management/workshop">
|
||||
<IconBooks size={16} /> Read the guide
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export function WorkshopDialog({
|
||||
titleId,
|
||||
onClose,
|
||||
children,
|
||||
}: PropsWithChildren<{ titleId: string; onClose: () => void }>) {
|
||||
const ref = useRef<HTMLDialogElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const dialog = ref.current;
|
||||
if (!dialog) return;
|
||||
dialog.showModal();
|
||||
return () => dialog.close();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<dialog
|
||||
ref={ref}
|
||||
className={styles.dialog}
|
||||
aria-labelledby={titleId}
|
||||
onCancel={(event) => {
|
||||
event.preventDefault();
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
<div className={styles.dialogSurface}>{children}</div>
|
||||
</dialog>
|
||||
);
|
||||
}
|
||||
@@ -1,592 +0,0 @@
|
||||
.page {
|
||||
--workshop-ease: cubic-bezier(0.25, 1, 0.5, 1);
|
||||
max-width: 1180px;
|
||||
min-height: 70vh;
|
||||
margin: 0 auto;
|
||||
padding: clamp(2rem, 5vw, 4.5rem) 1.25rem 5rem;
|
||||
}
|
||||
|
||||
.hero {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: 2rem;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
display: block;
|
||||
margin-bottom: 0.45rem;
|
||||
color: var(--ifm-color-primary);
|
||||
font-size: 0.76rem;
|
||||
font-weight: 750;
|
||||
letter-spacing: 0.09em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.hero h1,
|
||||
.detail h1 {
|
||||
margin: 0;
|
||||
font-size: clamp(2.35rem, 6vw, 4.25rem);
|
||||
line-height: 0.98;
|
||||
letter-spacing: -0.045em;
|
||||
}
|
||||
|
||||
.hero p {
|
||||
max-width: 62ch;
|
||||
margin: 0.85rem 0 0;
|
||||
color: var(--ifm-color-emphasis-700);
|
||||
font-size: clamp(1rem, 2vw, 1.12rem);
|
||||
line-height: 1.65;
|
||||
}
|
||||
|
||||
.heroPromises {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.55rem 1rem;
|
||||
padding: 0;
|
||||
margin: 1.1rem 0 0;
|
||||
color: var(--ifm-color-emphasis-700);
|
||||
font-size: 0.82rem;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.heroPromises li,
|
||||
.accountActions,
|
||||
.detailActions,
|
||||
.formActions,
|
||||
.serviceStateActions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.heroPromises li {
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.accountActions,
|
||||
.detailActions,
|
||||
.formActions,
|
||||
.serviceStateActions {
|
||||
flex-wrap: wrap;
|
||||
gap: 0.7rem;
|
||||
}
|
||||
|
||||
.accountActions :global(.button),
|
||||
.detailActions :global(.button),
|
||||
.serviceStateActions :global(.button) {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.safety {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.75rem;
|
||||
padding: 0.9rem 1rem;
|
||||
margin-bottom: 1rem;
|
||||
border: 1px solid color-mix(in srgb, var(--ifm-color-warning) 55%, var(--ifm-color-emphasis-300));
|
||||
border-radius: 12px;
|
||||
background: color-mix(in srgb, var(--ifm-color-warning) 8%, var(--ifm-background-surface-color));
|
||||
}
|
||||
|
||||
.safety svg {
|
||||
flex: none;
|
||||
margin-top: 0.1rem;
|
||||
}
|
||||
|
||||
.safety p {
|
||||
margin: 0;
|
||||
color: var(--ifm-color-emphasis-800);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(260px, 1fr) 150px 150px auto;
|
||||
align-items: end;
|
||||
gap: 0.75rem;
|
||||
padding: 0.85rem;
|
||||
margin-bottom: 1.25rem;
|
||||
border: 1px solid var(--ifm-color-emphasis-300);
|
||||
border-radius: 14px;
|
||||
background: var(--ifm-background-surface-color);
|
||||
box-shadow: 0 8px 28px rgb(0 0 0 / 0.04);
|
||||
}
|
||||
|
||||
.toolbar label,
|
||||
.dialog label {
|
||||
display: grid;
|
||||
gap: 0.35rem;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.toolbar input,
|
||||
.toolbar select,
|
||||
.dialog input,
|
||||
.dialog select,
|
||||
.dialog textarea {
|
||||
width: 100%;
|
||||
min-height: 44px;
|
||||
padding: 0.6rem 0.72rem;
|
||||
border: 1px solid var(--ifm-color-emphasis-400);
|
||||
border-radius: 9px;
|
||||
color: var(--ifm-font-color-base);
|
||||
background: var(--ifm-background-color);
|
||||
font: inherit;
|
||||
transition:
|
||||
border-color 150ms ease,
|
||||
box-shadow 150ms ease;
|
||||
}
|
||||
|
||||
.toolbar input:focus-visible,
|
||||
.toolbar select:focus-visible,
|
||||
.dialog input:focus-visible,
|
||||
.dialog select:focus-visible,
|
||||
.dialog textarea:focus-visible,
|
||||
.pagination button:focus-visible,
|
||||
.rowActions button:focus-visible {
|
||||
outline: 2px solid var(--ifm-color-primary);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.search {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.search svg {
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
left: 0.8rem;
|
||||
bottom: 0.82rem;
|
||||
color: var(--ifm-color-emphasis-600);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.search input {
|
||||
padding-left: 2.35rem;
|
||||
}
|
||||
|
||||
.checkbox {
|
||||
display: flex !important;
|
||||
grid-auto-flow: column;
|
||||
align-items: center;
|
||||
min-height: 44px;
|
||||
}
|
||||
|
||||
.checkbox input {
|
||||
width: auto;
|
||||
min-height: auto;
|
||||
}
|
||||
|
||||
.serviceState {
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr);
|
||||
gap: 1rem;
|
||||
padding: clamp(1.1rem, 3vw, 1.5rem);
|
||||
margin: 1.25rem 0;
|
||||
border: 1px solid color-mix(in srgb, var(--ifm-color-warning) 45%, var(--ifm-color-emphasis-300));
|
||||
border-radius: 14px;
|
||||
background: color-mix(in srgb, var(--ifm-color-warning) 7%, var(--ifm-background-surface-color));
|
||||
}
|
||||
|
||||
.serviceStateIcon {
|
||||
display: grid;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
place-items: center;
|
||||
border-radius: 10px;
|
||||
color: var(--ifm-color-warning-darkest);
|
||||
background: color-mix(in srgb, var(--ifm-color-warning) 18%, transparent);
|
||||
}
|
||||
|
||||
.serviceState h2 {
|
||||
margin: 0 0 0.25rem;
|
||||
font-size: 1.05rem;
|
||||
}
|
||||
|
||||
.serviceState p {
|
||||
max-width: 70ch;
|
||||
margin: 0 0 0.85rem;
|
||||
color: var(--ifm-color-emphasis-700);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(min(100%, 270px), 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.card {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--ifm-color-emphasis-300);
|
||||
border-radius: 14px;
|
||||
background: var(--ifm-background-surface-color);
|
||||
box-shadow: 0 4px 14px rgb(0 0 0 / 0.035);
|
||||
transition:
|
||||
transform 200ms var(--workshop-ease),
|
||||
border-color 200ms ease,
|
||||
box-shadow 200ms var(--workshop-ease);
|
||||
}
|
||||
|
||||
.card:hover,
|
||||
.card:focus-within {
|
||||
border-color: color-mix(in srgb, var(--ifm-color-primary) 55%, var(--ifm-color-emphasis-300));
|
||||
box-shadow: 0 14px 34px rgb(0 0 0 / 0.1);
|
||||
transform: translateY(-3px);
|
||||
}
|
||||
|
||||
.card img,
|
||||
.cardPlaceholder {
|
||||
width: 100%;
|
||||
aspect-ratio: 3 / 2;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.cardPlaceholder {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: var(--ifm-color-emphasis-600);
|
||||
background: color-mix(in srgb, var(--ifm-color-primary) 6%, var(--ifm-color-emphasis-100));
|
||||
font: 750 1.45rem var(--ifm-font-family-monospace);
|
||||
}
|
||||
|
||||
.cardBody {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.cardBody h2 {
|
||||
margin: 0.45rem 0;
|
||||
font-size: 1.08rem;
|
||||
}
|
||||
|
||||
.cardLink::after {
|
||||
position: absolute;
|
||||
content: "";
|
||||
inset: 0;
|
||||
}
|
||||
|
||||
.cardLink:focus-visible {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.card:has(.cardLink:focus-visible) {
|
||||
outline: 3px solid var(--ifm-color-primary);
|
||||
outline-offset: 3px;
|
||||
}
|
||||
|
||||
.cardBody p {
|
||||
min-height: 2.7em;
|
||||
margin: 0 0 0.7rem;
|
||||
color: var(--ifm-color-emphasis-700);
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
|
||||
.cardBody small {
|
||||
color: var(--ifm-color-emphasis-600);
|
||||
}
|
||||
|
||||
.cardMeta {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
color: var(--ifm-color-emphasis-700);
|
||||
font-size: 0.72rem;
|
||||
font-weight: 750;
|
||||
letter-spacing: 0.035em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.error {
|
||||
padding: 0.85rem 1rem;
|
||||
margin: 1rem 0;
|
||||
border: 1px solid color-mix(in srgb, var(--ifm-color-danger) 45%, transparent);
|
||||
border-radius: 10px;
|
||||
color: var(--ifm-color-danger-contrast-foreground);
|
||||
background: var(--ifm-color-danger-contrast-background);
|
||||
}
|
||||
|
||||
.empty {
|
||||
display: grid;
|
||||
max-width: 620px;
|
||||
min-height: 310px;
|
||||
place-items: center;
|
||||
align-content: center;
|
||||
gap: 0.45rem;
|
||||
padding: 3rem 1.25rem;
|
||||
margin: 0 auto;
|
||||
text-align: center;
|
||||
border: 1px dashed var(--ifm-color-emphasis-400);
|
||||
border-radius: 16px;
|
||||
color: var(--ifm-color-emphasis-700);
|
||||
}
|
||||
|
||||
.empty h2,
|
||||
.empty p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.empty svg {
|
||||
color: var(--ifm-color-primary);
|
||||
}
|
||||
|
||||
.empty :global(.button) {
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
|
||||
.pagination {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
margin-top: 2rem;
|
||||
}
|
||||
|
||||
.pagination button {
|
||||
min-height: 44px;
|
||||
padding: 0.5rem 0.85rem;
|
||||
}
|
||||
|
||||
.skeleton {
|
||||
min-height: 310px;
|
||||
border-radius: 14px;
|
||||
background: var(--ifm-color-emphasis-200);
|
||||
animation: pulse 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.detail {
|
||||
margin-top: 1.75rem;
|
||||
}
|
||||
|
||||
.detail header {
|
||||
max-width: 75ch;
|
||||
}
|
||||
|
||||
.detail h1 {
|
||||
margin-top: 0.5rem;
|
||||
font-size: clamp(2.1rem, 5vw, 3.6rem);
|
||||
}
|
||||
|
||||
.type {
|
||||
display: inline-block;
|
||||
color: var(--ifm-color-primary);
|
||||
font-size: 0.78rem;
|
||||
font-weight: 750;
|
||||
letter-spacing: 0.07em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.gallery {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
overflow-x: auto;
|
||||
padding-bottom: 0.6rem;
|
||||
margin: 1.5rem 0;
|
||||
scroll-snap-type: x mandatory;
|
||||
}
|
||||
|
||||
.gallery img {
|
||||
width: min(720px, 85vw);
|
||||
max-height: 480px;
|
||||
object-fit: contain;
|
||||
border: 1px solid var(--ifm-color-emphasis-300);
|
||||
border-radius: 12px;
|
||||
background: var(--ifm-color-emphasis-100);
|
||||
scroll-snap-align: start;
|
||||
}
|
||||
|
||||
.detailActions {
|
||||
margin: 1.5rem 0 2rem;
|
||||
}
|
||||
|
||||
.sourceHint {
|
||||
color: var(--ifm-color-emphasis-700);
|
||||
}
|
||||
|
||||
.source {
|
||||
max-height: 560px;
|
||||
overflow: auto;
|
||||
padding: 1rem;
|
||||
border: 1px solid var(--ifm-color-emphasis-300);
|
||||
border-radius: 10px;
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.dialog {
|
||||
width: min(700px, calc(100vw - 2rem));
|
||||
max-height: min(90vh, 900px);
|
||||
padding: 0;
|
||||
overflow: visible;
|
||||
border: 0;
|
||||
border-radius: 16px;
|
||||
color: var(--ifm-font-color-base);
|
||||
background: transparent;
|
||||
box-shadow: 0 24px 80px rgb(0 0 0 / 0.35);
|
||||
}
|
||||
|
||||
.dialog::backdrop {
|
||||
background: rgb(0 0 0 / 0.68);
|
||||
backdrop-filter: blur(3px);
|
||||
}
|
||||
|
||||
.dialogSurface {
|
||||
max-height: min(90vh, 900px);
|
||||
overflow-y: auto;
|
||||
padding: clamp(1.15rem, 4vw, 1.6rem);
|
||||
border: 1px solid var(--ifm-color-emphasis-300);
|
||||
border-radius: 16px;
|
||||
background: var(--ifm-background-color);
|
||||
}
|
||||
|
||||
.dialogSurface > h2 {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.dialog form {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.dialog textarea {
|
||||
min-height: 96px;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.dialog .editor {
|
||||
min-height: 240px;
|
||||
font-family: var(--ifm-font-family-monospace);
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.formActions {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.adminSection {
|
||||
margin: 2rem 0;
|
||||
}
|
||||
|
||||
.tableWrap {
|
||||
overflow-x: auto;
|
||||
border: 1px solid var(--ifm-color-emphasis-300);
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.tableWrap table {
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.tableWrap th,
|
||||
.tableWrap td {
|
||||
padding: 0.75rem;
|
||||
border: 0;
|
||||
border-bottom: 1px solid var(--ifm-color-emphasis-300);
|
||||
text-align: left;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.tableWrap tr:last-child td {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.rowActions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.rowActions button {
|
||||
min-height: 36px;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
50% {
|
||||
opacity: 0.55;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 820px) {
|
||||
.hero {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 1.4rem;
|
||||
}
|
||||
|
||||
.accountActions > * {
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
|
||||
.search {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 520px) {
|
||||
.page {
|
||||
padding-inline: 1rem;
|
||||
}
|
||||
|
||||
.hero h1 {
|
||||
font-size: 2.75rem;
|
||||
}
|
||||
|
||||
.heroPromises {
|
||||
display: grid;
|
||||
}
|
||||
|
||||
.accountActions,
|
||||
.detailActions,
|
||||
.formActions,
|
||||
.serviceStateActions {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.accountActions > *,
|
||||
.detailActions > *,
|
||||
.formActions > *,
|
||||
.serviceStateActions > * {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.search {
|
||||
grid-column: auto;
|
||||
}
|
||||
|
||||
.serviceState {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.pagination {
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.skeleton {
|
||||
animation: none;
|
||||
}
|
||||
|
||||
.card {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.card:hover,
|
||||
.card:focus-within {
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
import React from "react";
|
||||
import BrowserOnly from "@docusaurus/BrowserOnly";
|
||||
import Layout from "@theme/Layout";
|
||||
|
||||
export default function WorkshopAdminPage() {
|
||||
return (
|
||||
<Layout title="Workshop moderation" noFooter>
|
||||
<BrowserOnly fallback={<main style={{ minHeight: "70vh" }} />}>
|
||||
{() => {
|
||||
const { WorkshopAdmin } = require("@site/src/components/workshop/WorkshopAdmin");
|
||||
return <WorkshopAdmin />;
|
||||
}}
|
||||
</BrowserOnly>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
import React from "react";
|
||||
import BrowserOnly from "@docusaurus/BrowserOnly";
|
||||
import Layout from "@theme/Layout";
|
||||
|
||||
export default function WorkshopPage() {
|
||||
return (
|
||||
<Layout title="Workshop" description="Community widgets and CSS themes for Homarr">
|
||||
<BrowserOnly fallback={<main style={{ minHeight: "70vh" }} />}>
|
||||
{() => {
|
||||
const { WorkshopApp } = require("@site/src/components/workshop/WorkshopPage");
|
||||
return <WorkshopApp />;
|
||||
}}
|
||||
</BrowserOnly>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
import React, { type PropsWithChildren, useEffect } from "react";
|
||||
|
||||
export default function Root({ children }: PropsWithChildren) {
|
||||
useEffect(() => {
|
||||
if (process.env.NODE_ENV === "production" && "serviceWorker" in navigator) {
|
||||
void navigator.serviceWorker.register("/workshop-sw.js", { scope: "/" });
|
||||
}
|
||||
}, []);
|
||||
|
||||
return children;
|
||||
}
|
||||
Vendored
-46
@@ -1,46 +0,0 @@
|
||||
const CACHE_NAME = "homarr-site-v2";
|
||||
const SHELL_PATHS = ["/", "/workshop/", "/docs/management/workshop/", "/img/logo.png"];
|
||||
|
||||
self.addEventListener("install", (event) => {
|
||||
event.waitUntil(
|
||||
caches.open(CACHE_NAME).then(async (cache) => {
|
||||
const manifest = await fetch("/workshop-assets.json")
|
||||
.then((response) => response.json())
|
||||
.catch(() => []);
|
||||
await Promise.allSettled([...SHELL_PATHS, ...manifest].map((path) => cache.add(path)));
|
||||
}),
|
||||
);
|
||||
self.skipWaiting();
|
||||
});
|
||||
|
||||
self.addEventListener("activate", (event) => {
|
||||
event.waitUntil(
|
||||
caches
|
||||
.keys()
|
||||
.then((keys) => Promise.all(keys.filter((key) => key !== CACHE_NAME).map((key) => caches.delete(key))))
|
||||
.then(() => self.clients.claim()),
|
||||
);
|
||||
});
|
||||
|
||||
self.addEventListener("fetch", (event) => {
|
||||
const request = event.request;
|
||||
const url = new URL(request.url);
|
||||
if (request.method !== "GET" || url.origin !== self.location.origin || url.pathname.startsWith("/api/")) return;
|
||||
|
||||
event.respondWith(
|
||||
fetch(request)
|
||||
.then((response) => {
|
||||
if (response.ok) {
|
||||
const copy = response.clone();
|
||||
event.waitUntil(caches.open(CACHE_NAME).then((cache) => cache.put(request, copy)));
|
||||
}
|
||||
return response;
|
||||
})
|
||||
.catch(async () => {
|
||||
const cached = await caches.match(request);
|
||||
if (cached) return cached;
|
||||
if (request.mode === "navigate") return (await caches.match("/workshop/")) ?? Response.error();
|
||||
return Response.error();
|
||||
}),
|
||||
);
|
||||
});
|
||||
@@ -68,6 +68,10 @@ const nextConfig: NextConfig = {
|
||||
{
|
||||
source: "/(.*)", // Apply CSP to all routes
|
||||
headers: [
|
||||
{
|
||||
key: "Accept-CH",
|
||||
value: "Sec-CH-Viewport-Width",
|
||||
},
|
||||
{
|
||||
key: "Content-Security-Policy",
|
||||
// worker-src / media-src with blob: is necessary for video.js, see https://github.com/homarr-labs/homarr/issues/3912 and https://stackoverflow.com/questions/65792855/problem-with-video-js-and-content-security-policy-csp
|
||||
|
||||
@@ -31,7 +31,6 @@
|
||||
"@homarr/docker": "workspace:^0.1.0",
|
||||
"@homarr/form": "workspace:^0.1.0",
|
||||
"@homarr/forms-collection": "workspace:^0.1.0",
|
||||
"@homarr/gridstack": "catalog:",
|
||||
"@homarr/icons": "workspace:^0.1.0",
|
||||
"@homarr/image-proxy": "workspace:^0.1.0",
|
||||
"@homarr/integrations": "workspace:^0.1.0",
|
||||
@@ -50,7 +49,6 @@
|
||||
"@homarr/validation": "workspace:^0.1.0",
|
||||
"@homarr/websocket": "workspace:^0.1.0",
|
||||
"@homarr/widgets": "workspace:^0.1.0",
|
||||
"@homarr/workshop": "workspace:^0.1.0",
|
||||
"@mantine/colors-generator": "catalog:",
|
||||
"@mantine/core": "catalog:",
|
||||
"@mantine/dropzone": "catalog:",
|
||||
@@ -82,6 +80,7 @@
|
||||
"drizzle-orm": "catalog:",
|
||||
"flag-icons": "catalog:",
|
||||
"glob": "catalog:",
|
||||
"gridstack": "catalog:",
|
||||
"isomorphic-dompurify": "catalog:",
|
||||
"jotai": "catalog:",
|
||||
"jszip": "catalog:",
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
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 { ReactQueryStreamedHydration } from "@tanstack/react-query-next-experimental";
|
||||
@@ -33,6 +34,7 @@ import {
|
||||
import { createHeadersCallbackForSource, getTrpcUrl } from "@homarr/api/shared";
|
||||
import { env } from "@homarr/common/env";
|
||||
import { showWarningNotification } from "@homarr/notifications";
|
||||
import { widgetImports } from "@homarr/widgets";
|
||||
|
||||
import { createWidgetQueryPersister } from "./query-cache-persister";
|
||||
|
||||
@@ -90,6 +92,13 @@ 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 });
|
||||
}
|
||||
return client;
|
||||
});
|
||||
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
import { cache } from "react";
|
||||
|
||||
import { api } from "@homarr/api/server";
|
||||
|
||||
import { createBoardContentPage } from "../_creator";
|
||||
|
||||
const getHomeBoardCachedAsync = cache(async () => await api.board.getHomeBoard());
|
||||
|
||||
export default createBoardContentPage<{ locale: string }>({
|
||||
async getInitialBoardAsync() {
|
||||
return await api.board.getHomeBoard();
|
||||
return await getHomeBoardCachedAsync();
|
||||
},
|
||||
});
|
||||
@@ -1,9 +1,13 @@
|
||||
import { cache } from "react";
|
||||
|
||||
import { api } from "@homarr/api/server";
|
||||
|
||||
import { createBoardContentPage } from "../../_creator";
|
||||
|
||||
const getBoardByNameCachedAsync = cache(async (name: string) => await api.board.getBoardByName({ name }));
|
||||
|
||||
export default createBoardContentPage<{ locale: string; name: string }>({
|
||||
async getInitialBoardAsync({ name }) {
|
||||
return await api.board.getBoardByName({ name });
|
||||
return await getBoardByNameCachedAsync(name);
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,20 @@
|
||||
.columns {
|
||||
display: grid;
|
||||
align-items: start;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.lane {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.gutter {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
align-self: start;
|
||||
max-height: calc(100dvh - var(--app-shell-header-offset, 0px) - var(--mantine-spacing-md));
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
scrollbar-gutter: stable;
|
||||
transform: translateY(var(--board-gutter-sticky-offset, 0px));
|
||||
}
|
||||
@@ -1,33 +1,168 @@
|
||||
"use client";
|
||||
|
||||
import { Box, Stack } from "@mantine/core";
|
||||
import { useEffect, useRef } from "react";
|
||||
import { Box } from "@mantine/core";
|
||||
|
||||
import { useCurrentLayout, useRequiredBoard } from "@homarr/boards/context";
|
||||
import { useCurrentLayout, useInitialViewportWidth, useRequiredBoard } from "@homarr/boards/context";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import { BoardCategorySection } from "~/components/board/sections/category-section";
|
||||
import {
|
||||
getBoardLaneColumnCount,
|
||||
getInitialBoardLogicalHeight,
|
||||
getLogicalTrackSize,
|
||||
LOGICAL_GRID_GAP,
|
||||
getRootSectionForLane,
|
||||
} from "~/components/board/layout";
|
||||
import { ScaledBoardCanvas } from "~/components/board/layout/scaled-board-canvas";
|
||||
import { BoardEmptySection } from "~/components/board/sections/empty-section";
|
||||
import { BoardGridPortalHost } from "~/components/board/sections/grid/grid-portal-host";
|
||||
import { BoardSectionCollapseProvider } from "~/components/board/sections/section-collapse";
|
||||
import { BoardBackgroundVideo } from "~/components/layout/background";
|
||||
import classes from "./_client.module.css";
|
||||
|
||||
const APP_SHELL_INLINE_PADDING = 32;
|
||||
|
||||
const useFixedBoardGutters = () => {
|
||||
const columnsRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const columns = columnsRef.current;
|
||||
if (!columns) return;
|
||||
|
||||
let frame = 0;
|
||||
const update = () => {
|
||||
frame = 0;
|
||||
const columnsRect = columns.getBoundingClientRect();
|
||||
const headerBottom = document.querySelector("header")?.getBoundingClientRect().bottom ?? 0;
|
||||
const stickyTop = Math.max(0, headerBottom + 16);
|
||||
|
||||
for (const gutter of columns.querySelectorAll<HTMLElement>("[data-board-gutter]")) {
|
||||
const rect = gutter.getBoundingClientRect();
|
||||
if (gutter.offsetWidth <= 0) continue;
|
||||
|
||||
const effectiveScale = rect.width / gutter.offsetWidth;
|
||||
if (!Number.isFinite(effectiveScale) || effectiveScale <= 0) continue;
|
||||
|
||||
const previousOffset = Number(gutter.dataset.stickyOffset ?? 0);
|
||||
const uncorrectedTop = rect.top - previousOffset * effectiveScale;
|
||||
const maximumTop = columnsRect.bottom - rect.height;
|
||||
const targetTop = Math.min(stickyTop, maximumTop);
|
||||
const nextOffset = Math.max(0, targetTop - uncorrectedTop) / effectiveScale;
|
||||
|
||||
gutter.dataset.stickyOffset = String(nextOffset);
|
||||
gutter.style.setProperty("--board-gutter-sticky-offset", `${nextOffset}px`);
|
||||
}
|
||||
};
|
||||
const scheduleUpdate = () => {
|
||||
if (frame !== 0) return;
|
||||
frame = window.requestAnimationFrame(update);
|
||||
};
|
||||
|
||||
const resizeObserver = new ResizeObserver(scheduleUpdate);
|
||||
resizeObserver.observe(columns);
|
||||
document.addEventListener("scroll", scheduleUpdate, { capture: true, passive: true });
|
||||
window.addEventListener("resize", scheduleUpdate, { passive: true });
|
||||
scheduleUpdate();
|
||||
|
||||
return () => {
|
||||
window.cancelAnimationFrame(frame);
|
||||
resizeObserver.disconnect();
|
||||
document.removeEventListener("scroll", scheduleUpdate, { capture: true });
|
||||
window.removeEventListener("resize", scheduleUpdate);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return columnsRef;
|
||||
};
|
||||
|
||||
export const ClientBoard = () => {
|
||||
const board = useRequiredBoard();
|
||||
const t = useScopedI18n("board.landmark");
|
||||
const currentLayoutId = useCurrentLayout();
|
||||
const initialViewportWidth = useInitialViewportWidth();
|
||||
const columnsRef = useFixedBoardGutters();
|
||||
const currentLayout = board.layouts.find((layout) => layout.id === currentLayoutId) ?? board.layouts.at(0);
|
||||
if (!currentLayout) throw new Error("Expected the board to contain a layout");
|
||||
|
||||
const fullWidthSortedSections = board.sections
|
||||
.filter((section) => section.kind === "empty" || section.kind === "category")
|
||||
.sort((sectionA, sectionB) => sectionA.yOffset - sectionB.yOffset);
|
||||
const mainSection = getRootSectionForLane(board, "main");
|
||||
const leftSection = getRootSectionForLane(board, "left");
|
||||
const rightSection = getRootSectionForLane(board, "right");
|
||||
if (!mainSection) throw new Error("Expected the board to contain a main canvas section");
|
||||
|
||||
const leftColumnCount = getBoardLaneColumnCount(currentLayout, "left");
|
||||
const mainColumnCount = getBoardLaneColumnCount(currentLayout, "main");
|
||||
const rightColumnCount = getBoardLaneColumnCount(currentLayout, "right");
|
||||
const laneWidths = [leftColumnCount, mainColumnCount, rightColumnCount]
|
||||
.filter((columnCount) => columnCount > 0)
|
||||
.map(getLogicalTrackSize);
|
||||
const logicalWidth =
|
||||
laneWidths.reduce((total, width) => total + width, 0) + (laneWidths.length - 1) * LOGICAL_GRID_GAP;
|
||||
const initialLogicalHeight = getInitialBoardLogicalHeight(board, currentLayoutId);
|
||||
const initialAvailableWidth = Math.max(1, initialViewportWidth - APP_SHELL_INLINE_PADDING);
|
||||
const gridTemplateColumns = [
|
||||
leftColumnCount > 0 ? `${getLogicalTrackSize(leftColumnCount)}px` : null,
|
||||
`${getLogicalTrackSize(mainColumnCount)}px`,
|
||||
rightColumnCount > 0 ? `${getLogicalTrackSize(rightColumnCount)}px` : null,
|
||||
]
|
||||
.filter((value) => value !== null)
|
||||
.join(" ");
|
||||
|
||||
return (
|
||||
<Box h="100%" pos="relative">
|
||||
<BoardBackgroundVideo />
|
||||
<Stack h="100%">
|
||||
{fullWidthSortedSections.map((section) =>
|
||||
section.kind === "empty" ? (
|
||||
<BoardEmptySection key={`${currentLayoutId}-${section.id}`} section={section} />
|
||||
) : (
|
||||
<BoardCategorySection key={`${currentLayoutId}-${section.id}`} section={section} />
|
||||
),
|
||||
)}
|
||||
</Stack>
|
||||
<BoardSectionCollapseProvider>
|
||||
<BoardGridPortalHost>
|
||||
<ScaledBoardCanvas
|
||||
logicalWidth={logicalWidth}
|
||||
initialLogicalHeight={initialLogicalHeight}
|
||||
initialAvailableWidth={initialAvailableWidth}
|
||||
label={board.name}
|
||||
>
|
||||
<div ref={columnsRef} className={classes.columns} style={{ gridTemplateColumns }}>
|
||||
{leftColumnCount > 0 && leftSection && (
|
||||
<aside
|
||||
className={`${classes.lane} ${classes.gutter}`}
|
||||
aria-label={t("leftRail")}
|
||||
data-board-gutter="left"
|
||||
>
|
||||
<BoardEmptySection
|
||||
key={`${currentLayoutId}-${leftSection.id}`}
|
||||
section={leftSection}
|
||||
columnCount={leftColumnCount}
|
||||
requestedRowCount={0}
|
||||
railPlacement="left"
|
||||
/>
|
||||
</aside>
|
||||
)}
|
||||
|
||||
<section className={classes.lane} aria-label={t("canvas")}>
|
||||
<BoardEmptySection
|
||||
key={`${currentLayoutId}-${mainSection.id}`}
|
||||
section={mainSection}
|
||||
columnCount={mainColumnCount}
|
||||
requestedRowCount={0}
|
||||
/>
|
||||
</section>
|
||||
|
||||
{rightColumnCount > 0 && rightSection && (
|
||||
<aside
|
||||
className={`${classes.lane} ${classes.gutter}`}
|
||||
aria-label={t("rightRail")}
|
||||
data-board-gutter="right"
|
||||
>
|
||||
<BoardEmptySection
|
||||
key={`${currentLayoutId}-${rightSection.id}`}
|
||||
section={rightSection}
|
||||
columnCount={rightColumnCount}
|
||||
requestedRowCount={0}
|
||||
railPlacement="right"
|
||||
/>
|
||||
</aside>
|
||||
)}
|
||||
</div>
|
||||
</ScaledBoardCanvas>
|
||||
</BoardGridPortalHost>
|
||||
</BoardSectionCollapseProvider>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -2,15 +2,11 @@ import type { Metadata } from "next";
|
||||
import { Suspense } 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 { IntegrationProvider } from "@homarr/auth/client";
|
||||
import { auth } from "@homarr/auth/next";
|
||||
import { getIntegrationsWithPermissionsAsync } from "@homarr/auth/server";
|
||||
import { isNullOrWhitespace } from "@homarr/common";
|
||||
@@ -25,8 +21,9 @@ import { createMetaTitle } from "~/metadata";
|
||||
import { env } from "~/env";
|
||||
import { createBoardLayout } from "../_layout-creator";
|
||||
import type { Board, Item } from "../_types";
|
||||
import { DynamicClientBoard } from "./_dynamic-client";
|
||||
import { ClientBoard } from "./_client";
|
||||
import { BoardContentHeaderActions } from "./_header-actions";
|
||||
import { BoardIntegrationProvider } from "./_integration-provider";
|
||||
|
||||
const logger = createLogger({ module: "createBoardContentPage" });
|
||||
|
||||
@@ -85,9 +82,9 @@ export const createBoardContentPage = <TParams extends Record<string, unknown>>(
|
||||
<QueryCacheHydration userId={userId} boardId={board.id} />
|
||||
</Suspense>
|
||||
<HydrationBoundary state={dehydrate(queryClient)}>
|
||||
<IntegrationProvider integrations={integrations}>
|
||||
<DynamicClientBoard />
|
||||
</IntegrationProvider>
|
||||
<BoardIntegrationProvider initialIntegrations={integrations}>
|
||||
<ClientBoard />
|
||||
</BoardIntegrationProvider>
|
||||
</HydrationBoundary>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import dynamic from "next/dynamic";
|
||||
|
||||
export const DynamicClientBoard = dynamic(() => import("./_client").then((mod) => mod.ClientBoard), {
|
||||
ssr: false,
|
||||
});
|
||||
@@ -0,0 +1,77 @@
|
||||
"use client";
|
||||
|
||||
import { Group, Menu } from "@mantine/core";
|
||||
import { IconBox, IconChevronDown, IconLayoutGridAdd, IconPlug, IconPlus, IconResize } from "@tabler/icons-react";
|
||||
|
||||
import { useSession } from "@homarr/auth/client";
|
||||
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 { useDynamicSectionActions } from "~/components/board/sections/dynamic/dynamic-actions";
|
||||
import { IntegrationSelectModal } from "~/components/integration/integration-select-modal";
|
||||
import { HeaderButton } from "~/components/layout/header/button";
|
||||
|
||||
export default function BoardEditActions() {
|
||||
return <AddMenu />;
|
||||
}
|
||||
|
||||
const AddMenu = () => {
|
||||
const { data: session } = useSession();
|
||||
const { openModal: openItemSelectModal } = useModalAction(ItemSelectModal);
|
||||
const { openModal: openAppSelectModal } = useModalAction(AppSelectModal);
|
||||
const { openModal: openIntegrationSelectModal } = useModalAction(IntegrationSelectModal);
|
||||
const { addDynamicSection } = useDynamicSectionActions();
|
||||
const { createItem } = useItemActions();
|
||||
const t = useI18n();
|
||||
|
||||
const handleSelectItem = () => {
|
||||
openItemSelectModal();
|
||||
};
|
||||
|
||||
const handleSelectApp = () => {
|
||||
openAppSelectModal({
|
||||
onSelect: (app) => {
|
||||
createItem({
|
||||
kind: "app",
|
||||
options: { appId: app.id },
|
||||
});
|
||||
},
|
||||
withCreate: session?.user.permissions.includes("app-create") ?? false,
|
||||
});
|
||||
};
|
||||
|
||||
const handleAddIntegration = () => {
|
||||
openIntegrationSelectModal({});
|
||||
};
|
||||
|
||||
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={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={<IconLayoutGridAdd size={20} />} onClick={addDynamicSection}>
|
||||
{t("section.dynamic.action.create")}
|
||||
</Menu.Item>
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
);
|
||||
};
|
||||
@@ -1,49 +1,50 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect } from "react";
|
||||
import { startTransition, useCallback, useEffect, 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, 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, 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 { loadGridEditorAsync, scheduleGridEditorWarmup } from "~/components/board/sections/grid/grid-editor-loader";
|
||||
import { HeaderButton } from "~/components/layout/header/button";
|
||||
import type * as EditActionsModule from "./_edit-actions";
|
||||
|
||||
let editActionsModulePromise: Promise<typeof EditActionsModule> | undefined;
|
||||
const loadEditActionsAsync = () => {
|
||||
editActionsModulePromise ??= import("./_edit-actions").catch((error: unknown) => {
|
||||
editActionsModulePromise = undefined;
|
||||
throw error;
|
||||
});
|
||||
return editActionsModulePromise;
|
||||
};
|
||||
const loadBoardEditorAsync = async () =>
|
||||
await Promise.all([
|
||||
loadGridEditorAsync(),
|
||||
loadEditActionsAsync(),
|
||||
import("~/components/board/items/item-menu"),
|
||||
import("~/components/board/sections/dynamic/dynamic-menu"),
|
||||
]);
|
||||
const BoardEditActions = dynamic(loadEditActionsAsync, { ssr: false });
|
||||
|
||||
export const BoardContentHeaderActions = ({ demoReadOnly }: { demoReadOnly: boolean }) => {
|
||||
const [isEditMode] = useEditMode();
|
||||
const board = useRequiredBoard();
|
||||
const t = useI18n();
|
||||
const { hasChangeAccess } = useBoardPermissions(board);
|
||||
|
||||
if (!hasChangeAccess) {
|
||||
@@ -52,13 +53,13 @@ export const BoardContentHeaderActions = ({ demoReadOnly }: { demoReadOnly: bool
|
||||
|
||||
return (
|
||||
<>
|
||||
{isEditMode && <AddMenu />}
|
||||
{isEditMode && <BoardEditActions />}
|
||||
|
||||
<EditModeMenu demoReadOnly={demoReadOnly} />
|
||||
|
||||
{!demoReadOnly && (
|
||||
<OnboardingTour.Target id="board-settings">
|
||||
<HeaderButton href={`/boards/${board.name}/settings`}>
|
||||
<HeaderButton href={`/boards/${board.name}/settings`} aria-label={t("board.action.settings")}>
|
||||
<IconSettings stroke={1.5} />
|
||||
</HeaderButton>
|
||||
</OnboardingTour.Target>
|
||||
@@ -69,96 +70,10 @@ export const BoardContentHeaderActions = ({ demoReadOnly }: { demoReadOnly: bool
|
||||
);
|
||||
};
|
||||
|
||||
const AddMenu = () => {
|
||||
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();
|
||||
}, [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}>
|
||||
<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 [editorLoadState, setEditorLoadState] = useState<"scheduled" | "loading" | "ready" | "error">("scheduled");
|
||||
const [isEnteringEditMode, setIsEnteringEditMode] = useState(false);
|
||||
const board = useRequiredBoard();
|
||||
const utils = clientApi.useUtils();
|
||||
const t = useScopedI18n("board.action.edit");
|
||||
@@ -185,20 +100,73 @@ const EditModeMenu = ({ demoReadOnly }: { demoReadOnly: boolean }) => {
|
||||
close();
|
||||
}, [utils, board.name, close]);
|
||||
|
||||
const toggle = useCallback(() => {
|
||||
const prepareEditorAsync = useCallback(async () => {
|
||||
if (editorLoadState === "ready") return;
|
||||
|
||||
setEditorLoadState("loading");
|
||||
try {
|
||||
await loadBoardEditorAsync();
|
||||
setEditorLoadState("ready");
|
||||
} catch {
|
||||
setEditorLoadState("error");
|
||||
throw new Error("Unable to load the board editor");
|
||||
}
|
||||
}, [editorLoadState]);
|
||||
|
||||
useEffect(() => {
|
||||
return scheduleGridEditorWarmup(async () => {
|
||||
setEditorLoadState("loading");
|
||||
try {
|
||||
await loadBoardEditorAsync();
|
||||
setEditorLoadState("ready");
|
||||
} catch {
|
||||
setEditorLoadState("error");
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
|
||||
const prewarmEditor = useCallback(() => {
|
||||
void prepareEditorAsync().catch(() => {
|
||||
// A click or hotkey retries and reports the error.
|
||||
});
|
||||
}, [prepareEditorAsync]);
|
||||
|
||||
const toggle = useCallback(async () => {
|
||||
if (isEditMode) {
|
||||
if (demoReadOnly) return discardDemoChanges();
|
||||
return saveBoard(board);
|
||||
}
|
||||
open();
|
||||
}, [board, isEditMode, demoReadOnly, saveBoard, open, discardDemoChanges]);
|
||||
|
||||
useHotkeys([[hotkeys.toggleBoardEdit, toggle]]);
|
||||
setIsEnteringEditMode(true);
|
||||
try {
|
||||
await prepareEditorAsync();
|
||||
startTransition(open);
|
||||
} catch {
|
||||
showErrorNotification({
|
||||
title: t("notification.loadError.title"),
|
||||
message: t("notification.loadError.message"),
|
||||
});
|
||||
} finally {
|
||||
setIsEnteringEditMode(false);
|
||||
}
|
||||
}, [board, demoReadOnly, discardDemoChanges, isEditMode, open, prepareEditorAsync, saveBoard, t]);
|
||||
|
||||
useHotkeys([[hotkeys.toggleBoardEdit, () => void toggle()]]);
|
||||
usePreventLeaveWithDirty(isEditMode);
|
||||
|
||||
return (
|
||||
<OnboardingTour.Target id="board-edit-mode">
|
||||
<HeaderButton onClick={toggle} loading={isPending}>
|
||||
<HeaderButton
|
||||
onClick={() => void toggle()}
|
||||
onFocus={prewarmEditor}
|
||||
onPointerEnter={prewarmEditor}
|
||||
loading={isPending || isEnteringEditMode}
|
||||
data-testid="board-edit-mode-toggle"
|
||||
data-board-editor-preload-state={editorLoadState}
|
||||
aria-busy={isPending || isEnteringEditMode}
|
||||
aria-label={isEditMode ? (demoReadOnly ? t("exit") : t("save")) : t("enter")}
|
||||
aria-pressed={isEditMode}
|
||||
>
|
||||
{isEditMode ? <IconPencilOff stroke={1.5} /> : <IconPencil stroke={1.5} />}
|
||||
</HeaderButton>
|
||||
</OnboardingTour.Target>
|
||||
@@ -207,13 +175,14 @@ const EditModeMenu = ({ demoReadOnly }: { demoReadOnly: boolean }) => {
|
||||
|
||||
const SelectBoardsMenu = () => {
|
||||
const { data: boards = [] } = clientApi.board.getAllBoards.useQuery();
|
||||
const t = useI18n();
|
||||
|
||||
return (
|
||||
<OnboardingTour.Target id="board-switcher">
|
||||
<Box>
|
||||
<Menu position="bottom-end">
|
||||
<Menu.Target>
|
||||
<HeaderButton w="auto" px={4}>
|
||||
<HeaderButton w="auto" px={4} aria-label={t("board.action.switch")}>
|
||||
<IconReplace stroke={1.5} />
|
||||
</HeaderButton>
|
||||
</Menu.Target>
|
||||
@@ -294,5 +263,5 @@ const usePreventLeaveWithDirty = (isDirty: boolean) => {
|
||||
window.removeEventListener("popstate", handlePopState);
|
||||
window.removeEventListener("beforeunload", handleBeforeUnload);
|
||||
};
|
||||
}, [isDirty]);
|
||||
}, [isDirty, openConfirmModal, router, t]);
|
||||
};
|
||||
@@ -0,0 +1,54 @@
|
||||
import { createElement, act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import type { Root } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
import { BoardIntegrationProvider } from "./_integration-provider";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
providedIntegrations: undefined as unknown,
|
||||
}));
|
||||
|
||||
vi.mock("@homarr/auth/client", async () => {
|
||||
const React = await import("react");
|
||||
|
||||
return {
|
||||
IntegrationProvider: ({ children, integrations }: { children: React.ReactNode; integrations: unknown }) => {
|
||||
mocks.providedIntegrations = integrations;
|
||||
return React.createElement(React.Fragment, null, children);
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
describe("board integration provider", () => {
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
|
||||
beforeEach(() => {
|
||||
container = document.createElement("div");
|
||||
document.body.append(container);
|
||||
root = createRoot(container);
|
||||
mocks.providedIntegrations = undefined;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
act(() => root.unmount());
|
||||
container.remove();
|
||||
});
|
||||
|
||||
test("provides the permission-filtered server result without a second client query", () => {
|
||||
const initialIntegrations = [{ id: "initial" }];
|
||||
|
||||
act(() => {
|
||||
root.render(
|
||||
createElement(
|
||||
BoardIntegrationProvider,
|
||||
{ initialIntegrations: initialIntegrations as never },
|
||||
createElement("span", null, "board"),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
expect(mocks.providedIntegrations).toBe(initialIntegrations);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,14 @@
|
||||
"use client";
|
||||
|
||||
import type { PropsWithChildren } from "react";
|
||||
|
||||
import type { RouterOutputs } from "@homarr/api";
|
||||
import { IntegrationProvider } from "@homarr/auth/client";
|
||||
|
||||
interface Props {
|
||||
initialIntegrations: RouterOutputs["integration"]["all"];
|
||||
}
|
||||
|
||||
export const BoardIntegrationProvider = ({ initialIntegrations, children }: PropsWithChildren<Props>) => {
|
||||
return <IntegrationProvider integrations={initialIntegrations}>{children}</IntegrationProvider>;
|
||||
};
|
||||
@@ -11,8 +11,6 @@ import { IconInfoCircle } from "@tabler/icons-react";
|
||||
import { useForm } from "@homarr/form";
|
||||
import { useI18n, useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import { UseCssFromWorkshopButton } from "~/components/workshop/workshop-actions";
|
||||
|
||||
import type { Board } from "../../_types";
|
||||
import { useSavePartialSettingsMutation } from "./_shared";
|
||||
import classes from "./customcss.module.css";
|
||||
@@ -47,8 +45,7 @@ export const CustomCssSettingsContent = ({ board }: Props) => {
|
||||
{customCssT("customClassesAlert.description")}
|
||||
</Alert>
|
||||
|
||||
<Group justify="space-between">
|
||||
<UseCssFromWorkshopButton onUse={(css) => form.setFieldValue("customCss", css)} />
|
||||
<Group justify="end">
|
||||
<Button type="submit" loading={isPending}>
|
||||
{t("common.action.saveChanges")}
|
||||
</Button>
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
.gutterSettings {
|
||||
background: color-mix(in srgb, var(--mantine-color-default) 55%, transparent);
|
||||
}
|
||||
|
||||
.gutterPreview {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
width: 100%;
|
||||
min-height: 112px;
|
||||
padding: 8px;
|
||||
overflow: hidden;
|
||||
background: var(--mantine-color-body);
|
||||
border: 1px solid var(--mantine-color-default-border);
|
||||
border-radius: var(--mantine-radius-md);
|
||||
transition: grid-template-columns 180ms ease-out;
|
||||
}
|
||||
|
||||
.previewCanvas {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.previewCanvas > div,
|
||||
.previewGutter {
|
||||
background: var(--mantine-color-default);
|
||||
border-radius: var(--mantine-radius-sm);
|
||||
}
|
||||
|
||||
.previewCanvas > div:nth-child(1) {
|
||||
grid-row: span 2;
|
||||
}
|
||||
|
||||
.previewCanvas > div:nth-child(4) {
|
||||
grid-column: span 2;
|
||||
}
|
||||
|
||||
.previewGutter {
|
||||
min-width: 12px;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.gutterPreview {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,20 @@
|
||||
"use client";
|
||||
|
||||
import { Button, Fieldset, Grid, Group, Input, NumberInput, Slider, Stack, Text, TextInput } from "@mantine/core";
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Fieldset,
|
||||
Grid,
|
||||
Group,
|
||||
Input,
|
||||
NumberInput,
|
||||
Paper,
|
||||
Slider,
|
||||
Stack,
|
||||
Switch,
|
||||
Text,
|
||||
TextInput,
|
||||
} from "@mantine/core";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { createId } from "@homarr/common";
|
||||
@@ -9,6 +23,9 @@ import { useI18n } from "@homarr/translation/client";
|
||||
import { boardSaveLayoutsSchema } from "@homarr/validation/board";
|
||||
|
||||
import type { Board } from "../../_types";
|
||||
import classes from "./_layout.module.css";
|
||||
|
||||
const layoutSettingsSchema = boardSaveLayoutsSchema.omit({ id: true });
|
||||
|
||||
interface Props {
|
||||
board: Board;
|
||||
@@ -22,7 +39,7 @@ export const LayoutSettingsContent = ({ board }: Props) => {
|
||||
void utils.board.getHomeBoard.invalidate();
|
||||
},
|
||||
});
|
||||
const form = useZodForm(boardSaveLayoutsSchema.omit({ id: true }).required(), {
|
||||
const form = useZodForm(layoutSettingsSchema, {
|
||||
initialValues: {
|
||||
layouts: board.layouts,
|
||||
},
|
||||
@@ -33,7 +50,7 @@ export const LayoutSettingsContent = ({ board }: Props) => {
|
||||
onSubmit={form.onSubmit((values) => {
|
||||
saveLayouts({
|
||||
id: board.id,
|
||||
...values,
|
||||
layouts: values.layouts,
|
||||
});
|
||||
})}
|
||||
>
|
||||
@@ -51,6 +68,8 @@ export const LayoutSettingsContent = ({ board }: Props) => {
|
||||
id: createId(),
|
||||
name: "",
|
||||
columnCount: 10,
|
||||
leftGutterColumnCount: 0,
|
||||
rightGutterColumnCount: 0,
|
||||
breakpoint: 0,
|
||||
},
|
||||
],
|
||||
@@ -70,7 +89,21 @@ export const LayoutSettingsContent = ({ board }: Props) => {
|
||||
|
||||
<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`)} />
|
||||
<Slider
|
||||
mt="xs"
|
||||
min={1}
|
||||
max={24}
|
||||
step={1}
|
||||
value={layout.columnCount}
|
||||
thumbLabel={t("layout.field.columnCount.label")}
|
||||
onChange={(columnCount) => {
|
||||
const left = Math.min(layout.leftGutterColumnCount, Math.max(0, columnCount - 1));
|
||||
const right = Math.min(layout.rightGutterColumnCount, Math.max(0, columnCount - left - 1));
|
||||
form.setFieldValue(`layouts.${index}.columnCount`, columnCount);
|
||||
form.setFieldValue(`layouts.${index}.leftGutterColumnCount`, left);
|
||||
form.setFieldValue(`layouts.${index}.rightGutterColumnCount`, right);
|
||||
}}
|
||||
/>
|
||||
</Input.Wrapper>
|
||||
</Grid.Col>
|
||||
|
||||
@@ -81,6 +114,15 @@ export const LayoutSettingsContent = ({ board }: Props) => {
|
||||
description={t("layout.field.breakpoint.description")}
|
||||
/>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={12}>
|
||||
<GutterSettings
|
||||
left={layout.leftGutterColumnCount}
|
||||
right={layout.rightGutterColumnCount}
|
||||
columnCount={layout.columnCount}
|
||||
onLeftChange={(value) => form.setFieldValue(`layouts.${index}.leftGutterColumnCount`, value)}
|
||||
onRightChange={(value) => form.setFieldValue(`layouts.${index}.rightGutterColumnCount`, value)}
|
||||
/>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
{form.values.layouts.length >= 2 && (
|
||||
<Group justify="end">
|
||||
@@ -113,3 +155,123 @@ export const LayoutSettingsContent = ({ board }: Props) => {
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
interface GutterSettingsProps {
|
||||
left: number;
|
||||
right: number;
|
||||
columnCount: number;
|
||||
onLeftChange: (value: number) => void;
|
||||
onRightChange: (value: number) => void;
|
||||
}
|
||||
|
||||
const gutterMarks = [
|
||||
{ value: 1, label: "1" },
|
||||
{ value: 2, label: "2" },
|
||||
{ value: 3, label: "3" },
|
||||
];
|
||||
|
||||
const GutterSettings = ({ left, right, columnCount, onLeftChange, onRightChange }: GutterSettingsProps) => {
|
||||
const t = useI18n();
|
||||
const maxGutterWidth = Math.min(3, Math.max(1, columnCount - 1));
|
||||
|
||||
return (
|
||||
<Paper className={classes.gutterSettings} p="md">
|
||||
<Stack gap="md">
|
||||
<Box>
|
||||
<Text fw={600}>{t("layout.field.gutters.label")}</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
{t("layout.field.gutters.description")}
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
<GutterPreview left={left} right={right} columnCount={columnCount} />
|
||||
|
||||
<Grid>
|
||||
<Grid.Col span={{ base: 12, md: 6 }}>
|
||||
<GutterControl
|
||||
side="left"
|
||||
value={left}
|
||||
max={Math.min(maxGutterWidth, columnCount - right - 1)}
|
||||
onChange={onLeftChange}
|
||||
/>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={{ base: 12, md: 6 }}>
|
||||
<GutterControl
|
||||
side="right"
|
||||
value={right}
|
||||
max={Math.min(maxGutterWidth, columnCount - left - 1)}
|
||||
onChange={onRightChange}
|
||||
/>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
</Stack>
|
||||
</Paper>
|
||||
);
|
||||
};
|
||||
|
||||
const GutterControl = ({
|
||||
side,
|
||||
value,
|
||||
max,
|
||||
onChange,
|
||||
}: {
|
||||
side: "left" | "right";
|
||||
value: number;
|
||||
max: number;
|
||||
onChange: (value: number) => void;
|
||||
}) => {
|
||||
const t = useI18n();
|
||||
const enabled = value > 0;
|
||||
|
||||
return (
|
||||
<Stack gap="sm">
|
||||
<Switch
|
||||
checked={enabled}
|
||||
disabled={max < 1}
|
||||
onChange={(event) => onChange(event.currentTarget.checked ? Math.min(2, max) : 0)}
|
||||
label={t(`layout.field.gutters.${side}.label`)}
|
||||
description={t(`layout.field.gutters.${side}.description`)}
|
||||
/>
|
||||
{enabled && (
|
||||
<Input.Wrapper label={t("layout.field.gutters.width.label")}>
|
||||
<Slider
|
||||
mt="xs"
|
||||
min={1}
|
||||
max={max}
|
||||
value={Math.min(value, max)}
|
||||
marks={gutterMarks.filter((mark) => mark.value <= max)}
|
||||
restrictToMarks
|
||||
thumbLabel={t(`layout.field.gutters.${side}.thumbLabel`)}
|
||||
onChange={(nextValue) => onChange(Math.min(nextValue, max))}
|
||||
/>
|
||||
</Input.Wrapper>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
const GutterPreview = ({ left, right, columnCount }: { left: number; right: number; columnCount: number }) => {
|
||||
const t = useI18n();
|
||||
const safeColumnCount = Math.max(1, columnCount);
|
||||
const main = Math.max(1, safeColumnCount - left - right);
|
||||
const tracks = [left > 0 ? `${left}fr` : null, `${main}fr`, right > 0 ? `${right}fr` : null]
|
||||
.filter((track) => track !== null)
|
||||
.join(" ");
|
||||
|
||||
return (
|
||||
<Box
|
||||
className={classes.gutterPreview}
|
||||
aria-label={t("layout.field.gutters.preview.label")}
|
||||
style={{ gridTemplateColumns: tracks }}
|
||||
>
|
||||
{left > 0 && <Box className={classes.previewGutter} data-side="left" />}
|
||||
<Box className={classes.previewCanvas}>
|
||||
<Box />
|
||||
<Box />
|
||||
<Box />
|
||||
<Box />
|
||||
</Box>
|
||||
{right > 0 && <Box className={classes.previewGutter} data-side="right" />}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { JSX, PropsWithChildren } from "react";
|
||||
import { cookies, headers } from "next/headers";
|
||||
import { notFound, redirect } from "next/navigation";
|
||||
import { AppShellMain } from "@mantine/core";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
@@ -6,6 +7,7 @@ import { TRPCError } from "@trpc/server";
|
||||
import { auth } from "@homarr/auth/next";
|
||||
import { BoardProvider } from "@homarr/boards/context";
|
||||
import { EditModeProvider } from "@homarr/boards/edit-mode";
|
||||
import { boardViewportWidthCookieName, getLayoutIdForViewportWidth } from "@homarr/boards/layout-selection";
|
||||
import { createLogger } from "@homarr/core/infrastructure/logs";
|
||||
|
||||
import { MainHeader } from "~/components/layout/header";
|
||||
@@ -62,9 +64,15 @@ export const createBoardLayout = <TParams extends Params>({
|
||||
throw error;
|
||||
});
|
||||
const colorScheme = await getCurrentColorSchemeAsync();
|
||||
const initialViewportWidth = await getInitialViewportWidthAsync();
|
||||
const initialLayoutId = getLayoutIdForViewportWidth(initialBoard.layouts, initialViewportWidth);
|
||||
|
||||
return (
|
||||
<BoardProvider initialBoard={initialBoard}>
|
||||
<BoardProvider
|
||||
initialBoard={initialBoard}
|
||||
initialLayoutId={initialLayoutId}
|
||||
initialViewportWidth={initialViewportWidth}
|
||||
>
|
||||
<BoardReadyProvider>
|
||||
<EditModeProvider>
|
||||
<BoardMantineProvider defaultColorScheme={colorScheme}>
|
||||
@@ -88,3 +96,22 @@ export const createBoardLayout = <TParams extends Params>({
|
||||
|
||||
return Layout;
|
||||
};
|
||||
|
||||
const getInitialViewportWidthAsync = async () => {
|
||||
const cookieValue = (await cookies()).get(boardViewportWidthCookieName)?.value;
|
||||
const cookieWidth = Number(cookieValue);
|
||||
if (Number.isInteger(cookieWidth) && cookieWidth >= 200 && cookieWidth <= 10_000) {
|
||||
return cookieWidth;
|
||||
}
|
||||
|
||||
const requestHeaders = await headers();
|
||||
const clientHintWidth = Number(requestHeaders.get("sec-ch-viewport-width"));
|
||||
if (Number.isInteger(clientHintWidth) && clientHintWidth >= 200 && clientHintWidth <= 10_000) {
|
||||
return clientHintWidth;
|
||||
}
|
||||
|
||||
const userAgent = requestHeaders.get("user-agent") ?? "";
|
||||
if (/iPad|Tablet|PlayBook|Silk/i.test(userAgent)) return 1024;
|
||||
if (/Mobi|Android|iPhone|iPod|IEMobile|Opera Mini/i.test(userAgent)) return 390;
|
||||
return 1440;
|
||||
};
|
||||
@@ -30,6 +30,7 @@ import { CrowdinLiveTranslation } from "~/components/layout/crowdin-live-transla
|
||||
|
||||
import { SearchEngineOptimization } from "~/components/layout/search-engine-optimization";
|
||||
import { ServiceWorkerRegistration } from "~/components/layout/service-worker-registration";
|
||||
import { ViewportHint } from "~/components/layout/viewport-hint";
|
||||
import { getCurrentColorSchemeAsync } from "~/theme/color-scheme";
|
||||
import { DayJsLoader } from "./_client-providers/dayjs-loader";
|
||||
import { JotaiProvider } from "./_client-providers/jotai";
|
||||
@@ -144,6 +145,7 @@ export default async function Layout(props: {
|
||||
</head>
|
||||
<body className={[fontSans.className, fontSans.variable].join(" ")} suppressHydrationWarning>
|
||||
<Analytics enabled={serverSettings.analytics.enableGeneral} />
|
||||
<ViewportHint />
|
||||
<StackedProvider>
|
||||
<Notifications pauseResetOnHover="notification" />
|
||||
<ServiceWorkerRegistration />
|
||||
|
||||
@@ -16,7 +16,10 @@ export default async function AppEditPage(props: AppEditPageProps) {
|
||||
const params = await props.params;
|
||||
const session = await auth();
|
||||
|
||||
if (!session?.user.permissions.includes("app-modify-all")) {
|
||||
if (
|
||||
!session?.user.permissions.includes("board-modify-all") ||
|
||||
!session?.user.permissions.includes("app-modify-all")
|
||||
) {
|
||||
notFound();
|
||||
}
|
||||
const app = await api.app.byId({ id: params.id });
|
||||
|
||||
@@ -10,7 +10,7 @@ import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
|
||||
export default async function AppNewPage() {
|
||||
const session = await auth();
|
||||
|
||||
if (!session?.user.permissions.includes("app-create")) {
|
||||
if (!session?.user.permissions.includes("board-modify-all") || !session?.user.permissions.includes("app-create")) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Fragment } from "react";
|
||||
import { redirect } from "next/navigation";
|
||||
import { notFound, redirect } from "next/navigation";
|
||||
import { ActionIcon, ActionIconGroup, Anchor, Avatar, Card, Group, Stack, Text } from "@mantine/core";
|
||||
import { IconBox, IconPencil } from "@tabler/icons-react";
|
||||
import { z } from "zod/v4";
|
||||
@@ -33,6 +33,9 @@ export default async function AppsPage(props: AppsPageProps) {
|
||||
if (!session) {
|
||||
redirect("/auth/login");
|
||||
}
|
||||
if (!session.user.permissions.includes("board-modify-all")) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const searchParams = searchParamsSchema.parse(await props.searchParams);
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ import { showErrorNotification, showSuccessNotification } from "@homarr/notifica
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import { MobileAffixButton } from "~/components/manage/mobile-affix-button";
|
||||
|
||||
const iconProps = { size: 16, stroke: 1.5 };
|
||||
|
||||
interface WidgetRef {
|
||||
|
||||
@@ -9,7 +9,6 @@ import { Link } from "@homarr/ui";
|
||||
|
||||
import { ManagePageLayout } from "~/components/manage/manage-page-layout";
|
||||
import { MobileAffixButton } from "~/components/manage/mobile-affix-button";
|
||||
import { InstallWidgetFromWorkshopButton } from "~/components/workshop/workshop-actions";
|
||||
import { CustomWidgetBetaBanner } from "./_beta-banner";
|
||||
import { ImportCustomWidgetButton } from "./_custom-widget-actions";
|
||||
import { CustomWidgetList } from "./_custom-widget-list";
|
||||
@@ -29,7 +28,6 @@ export default async function CustomWidgetsPage() {
|
||||
title={t("page.list.title")}
|
||||
primaryAction={
|
||||
<Group gap="xs">
|
||||
<InstallWidgetFromWorkshopButton />
|
||||
<ImportCustomWidgetButton />
|
||||
<MobileAffixButton component={Link} href="/manage/custom-widgets/new" leftSection={<IconPlus size={16} />}>
|
||||
{t("action.create")}
|
||||
|
||||
@@ -6,7 +6,6 @@ import {
|
||||
AccordionPanel,
|
||||
ActionIcon,
|
||||
ActionIconGroup,
|
||||
Alert,
|
||||
Anchor,
|
||||
Divider,
|
||||
Group,
|
||||
@@ -19,14 +18,14 @@ import {
|
||||
TableTr,
|
||||
Text,
|
||||
} from "@mantine/core";
|
||||
import { IconAlertTriangle, IconPencil, IconPlugX } from "@tabler/icons-react";
|
||||
import { IconPencil, IconPlugX } from "@tabler/icons-react";
|
||||
|
||||
import type { RouterOutputs } from "@homarr/api";
|
||||
import { api } from "@homarr/api/server";
|
||||
import { auth } from "@homarr/auth/next";
|
||||
import { objectEntries } from "@homarr/common";
|
||||
import type { IntegrationKind } from "@homarr/definitions";
|
||||
import { getIntegrationName, isIntegrationKind } from "@homarr/definitions";
|
||||
import { getIntegrationName } from "@homarr/definitions";
|
||||
import { getScopedI18n } from "@homarr/translation/server";
|
||||
import { CountBadge, IntegrationAvatar, Link } from "@homarr/ui";
|
||||
|
||||
@@ -88,14 +87,11 @@ const IntegrationList = async ({ integrations, activeTab }: IntegrationListProps
|
||||
const session = await auth();
|
||||
const hasFullAccess = session?.user.permissions.includes("integration-full-all") ?? false;
|
||||
|
||||
const supportedIntegrations = integrations.filter((integration) => isIntegrationKind(integration.kind));
|
||||
const unsupportedCount = integrations.length - supportedIntegrations.length;
|
||||
|
||||
if (supportedIntegrations.length === 0 && unsupportedCount === 0) {
|
||||
if (integrations.length === 0) {
|
||||
return <NoResults icon={IconPlugX} title={t("page.list.noResults.title")} />;
|
||||
}
|
||||
|
||||
const groupedIntegrations = supportedIntegrations.reduce(
|
||||
const groupedIntegrations = integrations.reduce(
|
||||
(acc, integration) => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
||||
if (!acc[integration.kind]) {
|
||||
@@ -112,105 +108,88 @@ const IntegrationList = async ({ integrations, activeTab }: IntegrationListProps
|
||||
const entries = objectEntries(groupedIntegrations);
|
||||
|
||||
return (
|
||||
<Stack>
|
||||
{unsupportedCount > 0 && (
|
||||
<Alert color="yellow" icon={<IconAlertTriangle size={18} />} title={t("page.list.outdated.title")}>
|
||||
{t("page.list.outdated.description", { count: unsupportedCount })}
|
||||
</Alert>
|
||||
)}
|
||||
{supportedIntegrations.length === 0 ? (
|
||||
<NoResults icon={IconPlugX} title={t("page.list.noResults.title")} />
|
||||
) : (
|
||||
<ActiveTabAccordion defaultValue={activeTab} radius="lg" classNames={classes}>
|
||||
{entries.map(([kind, kindIntegrations], index) => (
|
||||
<AccordionItem key={kind} value={kind} data-first={index === 0} data-last={index === entries.length - 1}>
|
||||
<AccordionControl icon={<IntegrationAvatar size="sm" kind={kind} radius="sm" />}>
|
||||
<Group>
|
||||
<Text>{getIntegrationName(kind)}</Text>
|
||||
<CountBadge count={kindIntegrations.length} />
|
||||
</Group>
|
||||
</AccordionControl>
|
||||
<AccordionPanel>
|
||||
<Table visibleFrom="md">
|
||||
<TableThead>
|
||||
<TableTr>
|
||||
<TableTh>{t("field.name.label")}</TableTh>
|
||||
<TableTh>{t("field.url.label")}</TableTh>
|
||||
<TableTh />
|
||||
</TableTr>
|
||||
</TableThead>
|
||||
<TableTbody>
|
||||
{kindIntegrations.map((integration) => (
|
||||
<TableTr key={integration.id}>
|
||||
<TableTd>{integration.name}</TableTd>
|
||||
<TableTd>
|
||||
<Anchor href={integration.url} target="_blank" rel="noreferrer" size="sm">
|
||||
{integration.url}
|
||||
</Anchor>
|
||||
</TableTd>
|
||||
<TableTd>
|
||||
<Group justify="end">
|
||||
{(hasFullAccess || integration.permissions.hasFullAccess) && (
|
||||
<ActionIconGroup>
|
||||
<ActionIcon
|
||||
component={Link}
|
||||
href={`/manage/integrations/edit/${integration.id}`}
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
aria-label={t("page.edit.title", { name: getIntegrationName(integration.kind) })}
|
||||
>
|
||||
<IconPencil size={16} stroke={1.5} />
|
||||
</ActionIcon>
|
||||
<DeleteIntegrationActionButton
|
||||
integration={integration}
|
||||
count={kindIntegrations.length}
|
||||
/>
|
||||
</ActionIconGroup>
|
||||
)}
|
||||
</Group>
|
||||
</TableTd>
|
||||
</TableTr>
|
||||
))}
|
||||
</TableTbody>
|
||||
</Table>
|
||||
<ActiveTabAccordion defaultValue={activeTab} radius="lg" classNames={classes}>
|
||||
{entries.map(([kind, kindIntegrations], index) => (
|
||||
<AccordionItem key={kind} value={kind} data-first={index === 0} data-last={index === entries.length - 1}>
|
||||
<AccordionControl icon={<IntegrationAvatar size="sm" kind={kind} radius="sm" />}>
|
||||
<Group>
|
||||
<Text>{getIntegrationName(kind)}</Text>
|
||||
<CountBadge count={kindIntegrations.length} />
|
||||
</Group>
|
||||
</AccordionControl>
|
||||
<AccordionPanel>
|
||||
<Table visibleFrom="md">
|
||||
<TableThead>
|
||||
<TableTr>
|
||||
<TableTh>{t("field.name.label")}</TableTh>
|
||||
<TableTh>{t("field.url.label")}</TableTh>
|
||||
<TableTh />
|
||||
</TableTr>
|
||||
</TableThead>
|
||||
<TableTbody>
|
||||
{kindIntegrations.map((integration) => (
|
||||
<TableTr key={integration.id}>
|
||||
<TableTd>{integration.name}</TableTd>
|
||||
<TableTd>
|
||||
<Anchor href={integration.url} target="_blank" rel="noreferrer" size="sm">
|
||||
{integration.url}
|
||||
</Anchor>
|
||||
</TableTd>
|
||||
<TableTd>
|
||||
<Group justify="end">
|
||||
{(hasFullAccess || integration.permissions.hasFullAccess) && (
|
||||
<ActionIconGroup>
|
||||
<ActionIcon
|
||||
component={Link}
|
||||
href={`/manage/integrations/edit/${integration.id}`}
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
aria-label={t("page.edit.title", { name: getIntegrationName(integration.kind) })}
|
||||
>
|
||||
<IconPencil size={16} stroke={1.5} />
|
||||
</ActionIcon>
|
||||
<DeleteIntegrationActionButton integration={integration} count={kindIntegrations.length} />
|
||||
</ActionIconGroup>
|
||||
)}
|
||||
</Group>
|
||||
</TableTd>
|
||||
</TableTr>
|
||||
))}
|
||||
</TableTbody>
|
||||
</Table>
|
||||
|
||||
<Stack gap="xs" hiddenFrom="md">
|
||||
{kindIntegrations.map((integration, integrationIndex) => (
|
||||
<Fragment key={integration.id}>
|
||||
{integrationIndex !== 0 && <Divider />}
|
||||
<Stack gap={0}>
|
||||
<Group justify="space-between" align="center" wrap="nowrap">
|
||||
<Text>{integration.name}</Text>
|
||||
{(hasFullAccess || integration.permissions.hasFullAccess) && (
|
||||
<ActionIconGroup>
|
||||
<ActionIcon
|
||||
component={Link}
|
||||
href={`/manage/integrations/edit/${integration.id}`}
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
aria-label={t("page.edit.title", { name: getIntegrationName(integration.kind) })}
|
||||
>
|
||||
<IconPencil size={16} stroke={1.5} />
|
||||
</ActionIcon>
|
||||
<DeleteIntegrationActionButton
|
||||
integration={integration}
|
||||
count={kindIntegrations.length}
|
||||
/>
|
||||
</ActionIconGroup>
|
||||
)}
|
||||
</Group>
|
||||
<Anchor href={integration.url} target="_blank" rel="noreferrer" size="sm">
|
||||
{integration.url}
|
||||
</Anchor>
|
||||
</Stack>
|
||||
</Fragment>
|
||||
))}
|
||||
</Stack>
|
||||
</AccordionPanel>
|
||||
</AccordionItem>
|
||||
))}
|
||||
</ActiveTabAccordion>
|
||||
)}
|
||||
</Stack>
|
||||
<Stack gap="xs" hiddenFrom="md">
|
||||
{kindIntegrations.map((integration, integrationIndex) => (
|
||||
<Fragment key={integration.id}>
|
||||
{integrationIndex !== 0 && <Divider />}
|
||||
<Stack gap={0}>
|
||||
<Group justify="space-between" align="center" wrap="nowrap">
|
||||
<Text>{integration.name}</Text>
|
||||
{(hasFullAccess || integration.permissions.hasFullAccess) && (
|
||||
<ActionIconGroup>
|
||||
<ActionIcon
|
||||
component={Link}
|
||||
href={`/manage/integrations/edit/${integration.id}`}
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
aria-label={t("page.edit.title", { name: getIntegrationName(integration.kind) })}
|
||||
>
|
||||
<IconPencil size={16} stroke={1.5} />
|
||||
</ActionIcon>
|
||||
<DeleteIntegrationActionButton integration={integration} count={kindIntegrations.length} />
|
||||
</ActionIconGroup>
|
||||
)}
|
||||
</Group>
|
||||
<Anchor href={integration.url} target="_blank" rel="noreferrer" size="sm">
|
||||
{integration.url}
|
||||
</Anchor>
|
||||
</Stack>
|
||||
</Fragment>
|
||||
))}
|
||||
</Stack>
|
||||
</AccordionPanel>
|
||||
</AccordionItem>
|
||||
))}
|
||||
</ActiveTabAccordion>
|
||||
);
|
||||
};
|
||||
@@ -18,7 +18,6 @@ import {
|
||||
IconHomeFilled,
|
||||
IconLayoutDashboardFilled,
|
||||
IconMailForward,
|
||||
IconBuildingStore,
|
||||
IconPhotoFilled,
|
||||
IconPointerFilled,
|
||||
IconSearch,
|
||||
@@ -62,7 +61,7 @@ export default async function ManageLayout({ children }: PropsWithChildren) {
|
||||
icon: IconBox,
|
||||
href: "/manage/apps",
|
||||
label: t("items.apps"),
|
||||
hidden: !session,
|
||||
hidden: !session?.user.permissions.includes("app-create"),
|
||||
iconProps: {
|
||||
strokeWidth: 2.5,
|
||||
},
|
||||
@@ -72,26 +71,20 @@ export default async function ManageLayout({ children }: PropsWithChildren) {
|
||||
icon: IconAffiliateFilled,
|
||||
href: "/manage/integrations",
|
||||
label: t("items.integrations"),
|
||||
hidden: !session,
|
||||
hidden: !session?.user.permissions.includes("integration-create"),
|
||||
"data-onboarding-tour-id": "manage-integrations",
|
||||
},
|
||||
{
|
||||
icon: IconApi,
|
||||
href: "/manage/custom-widgets",
|
||||
label: t("items.customWidgets"),
|
||||
hidden: !session,
|
||||
},
|
||||
{
|
||||
icon: IconBuildingStore,
|
||||
href: "/manage/workshop",
|
||||
label: t("items.workshop"),
|
||||
hidden: !session?.user.permissions.includes("admin"),
|
||||
},
|
||||
{
|
||||
icon: IconSearch,
|
||||
href: "/manage/search-engines",
|
||||
label: t("items.searchEngies"),
|
||||
hidden: !session,
|
||||
hidden: !session?.user.permissions.includes("search-engine-create"),
|
||||
iconProps: {
|
||||
strokeWidth: 2.5,
|
||||
},
|
||||
@@ -101,7 +94,7 @@ export default async function ManageLayout({ children }: PropsWithChildren) {
|
||||
icon: IconPhotoFilled,
|
||||
href: "/manage/medias",
|
||||
label: t("items.medias"),
|
||||
hidden: !session,
|
||||
hidden: !session?.user.permissions.includes("media-upload"),
|
||||
"data-onboarding-tour-id": "manage-medias",
|
||||
},
|
||||
{
|
||||
|
||||
@@ -193,6 +193,10 @@ export function DockerTable({ initialData }: DockerTableProps) {
|
||||
positionToolbarAlertBanner: "top",
|
||||
enableTableFooter: false,
|
||||
enableBottomToolbar: false,
|
||||
// MRT's built-in full-screen mode does not stretch the table to fill the
|
||||
// viewport, so with few containers it renders as a thin strip (#6230). Disable
|
||||
// it, matching the other management tables (users, invites).
|
||||
enableFullScreenToggle: false,
|
||||
positionGlobalFilter: "right",
|
||||
mantineSearchTextInputProps: {
|
||||
placeholder: tDocker("table.search", { count: String(containers.length) }),
|
||||
|
||||
+18
@@ -31,6 +31,7 @@ import {
|
||||
userChangeHomeBoardsSchema,
|
||||
userChangeSearchPreferencesSchema,
|
||||
userEditProfileSchema,
|
||||
userEnableRightClickOnWidgetsSchema,
|
||||
userFirstDayOfWeekSchema,
|
||||
userPingIconsEnabledSchema,
|
||||
} from "@homarr/validation/user";
|
||||
@@ -51,6 +52,7 @@ const userGeneralSettingsSchema = z.object({
|
||||
ddgBangsEnabled: userChangeSearchPreferencesSchema.shape.ddgBangsEnabled,
|
||||
firstDayOfWeek: userFirstDayOfWeekSchema.shape.firstDayOfWeek,
|
||||
pingIconsEnabled: userPingIconsEnabledSchema.shape.pingIconsEnabled,
|
||||
enableRightClickOnWidgets: userEnableRightClickOnWidgetsSchema.shape.enableRightClickOnWidgets,
|
||||
});
|
||||
|
||||
type FormValues = z.infer<typeof userGeneralSettingsSchema>;
|
||||
@@ -78,6 +80,7 @@ const buildInitialValues = (user: RouterOutputs["user"]["getById"]): FormValues
|
||||
ddgBangsEnabled: user.ddgBangs,
|
||||
firstDayOfWeek: user.firstDayOfWeek as DayOfWeek,
|
||||
pingIconsEnabled: user.pingIconsEnabled,
|
||||
enableRightClickOnWidgets: user.enableRightClickOnWidgets,
|
||||
});
|
||||
|
||||
export const UserGeneralSettingsForm = ({
|
||||
@@ -95,6 +98,7 @@ export const UserGeneralSettingsForm = ({
|
||||
const changeSearchPreferencesMutation = clientApi.user.changeSearchPreferences.useMutation();
|
||||
const changeFirstDayOfWeekMutation = clientApi.user.changeFirstDayOfWeek.useMutation();
|
||||
const changePingIconsEnabledMutation = clientApi.user.changePingIconsEnabled.useMutation();
|
||||
const changeEnableRightClickOnWidgetsMutation = clientApi.user.changeEnableRightClickOnWidgets.useMutation();
|
||||
|
||||
const initialValues = buildInitialValues(user);
|
||||
const initialValuesRef = useRef(initialValues);
|
||||
@@ -114,6 +118,7 @@ export const UserGeneralSettingsForm = ({
|
||||
changeSearchPreferencesMutation,
|
||||
changeFirstDayOfWeekMutation,
|
||||
changePingIconsEnabledMutation,
|
||||
changeEnableRightClickOnWidgetsMutation,
|
||||
];
|
||||
const isPending = mutations.some((m) => m.isPending);
|
||||
|
||||
@@ -169,6 +174,14 @@ export const UserGeneralSettingsForm = ({
|
||||
action: () =>
|
||||
changePingIconsEnabledMutation.mutateAsync({ id: user.id, pingIconsEnabled: parsed.data.pingIconsEnabled }),
|
||||
},
|
||||
{
|
||||
when: changed("enableRightClickOnWidgets"),
|
||||
action: () =>
|
||||
changeEnableRightClickOnWidgetsMutation.mutateAsync({
|
||||
id: user.id,
|
||||
enableRightClickOnWidgets: parsed.data.enableRightClickOnWidgets,
|
||||
}),
|
||||
},
|
||||
];
|
||||
|
||||
const promises = saveActions.filter((s) => s.when).map((s) => s.action());
|
||||
@@ -305,6 +318,11 @@ export const UserGeneralSettingsForm = ({
|
||||
label={t("user.field.pingIconsEnabled.label")}
|
||||
{...form.getInputProps("pingIconsEnabled", { type: "checkbox" })}
|
||||
/>
|
||||
<Switch
|
||||
label={t("user.field.enableRightClickOnWidgets.label")}
|
||||
description={t("user.field.enableRightClickOnWidgets.description")}
|
||||
{...form.getInputProps("enableRightClickOnWidgets", { type: "checkbox" })}
|
||||
/>
|
||||
</Stack>
|
||||
</Card>
|
||||
</SimpleGrid>
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
import { auth } from "@homarr/auth/next";
|
||||
import { getScopedI18n } from "@homarr/translation/server";
|
||||
|
||||
import { ManagePageLayout } from "~/components/manage/manage-page-layout";
|
||||
import { WorkshopBrowser } from "~/components/workshop/workshop-browser";
|
||||
|
||||
export default async function WorkshopPage() {
|
||||
const session = await auth();
|
||||
if (!session?.user.permissions.includes("admin")) redirect(session ? "/" : "/auth/login");
|
||||
const t = await getScopedI18n("workshop");
|
||||
return (
|
||||
<ManagePageLayout title={t("title")} size="xl">
|
||||
<WorkshopBrowser />
|
||||
</ManagePageLayout>
|
||||
);
|
||||
}
|
||||
@@ -179,8 +179,8 @@ export async function POST(req: Request) {
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
console.log("Database restored, restarting server and applying migrations...");
|
||||
process.exit(101);
|
||||
console.log("Database restored, restarting server...");
|
||||
process.exit(0);
|
||||
}, 500);
|
||||
|
||||
return NextResponse.json({ success: true, message: "Database restored. Server is restarting..." });
|
||||
|
||||
@@ -1,19 +1,25 @@
|
||||
import { notFound } from "next/navigation";
|
||||
|
||||
import { ImageProxy } from "@homarr/image-proxy";
|
||||
|
||||
export const GET = async (_request: Request, props: { params: Promise<{ id: string }> }) => {
|
||||
const { id } = await props.params;
|
||||
|
||||
const imageProxy = new ImageProxy();
|
||||
const image = await imageProxy.forwardImageAsync(id);
|
||||
if (!image) {
|
||||
notFound();
|
||||
const result = await imageProxy.forwardImageAsync(id);
|
||||
|
||||
if ("error" in result) {
|
||||
const status = result.error === "upstream-error" ? result.statusCode : result.error === "fetch-error" ? 502 : 404;
|
||||
return new Response(null, {
|
||||
status,
|
||||
headers: {
|
||||
"X-Homarr-Image-Proxy-Error": result.error,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return new Response(image, {
|
||||
return new Response(result.image, {
|
||||
headers: {
|
||||
"Cache-Control": "public, max-age=3600, immutable", // Cache for 1 hour
|
||||
...(result.contentType ? { "Content-Type": result.contentType } : {}),
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -1,10 +1,12 @@
|
||||
import { getBoardLayouts } from "@homarr/boards/context";
|
||||
import { createId } from "@homarr/common";
|
||||
import type { Modify } from "@homarr/common/types";
|
||||
import { getRootSectionLane } from "@homarr/definitions";
|
||||
import type { WidgetKind } from "@homarr/definitions";
|
||||
import { widgetDefaultSizes } from "@homarr/definitions";
|
||||
|
||||
import type { Board, EmptySection, Item, ItemLayout } from "~/app/[locale]/boards/_types";
|
||||
import { getBoardLaneColumnCount } from "~/components/board/layout";
|
||||
import { getFirstEmptyPosition } from "./empty-position";
|
||||
import { getSectionElements } from "./section-elements";
|
||||
|
||||
@@ -19,17 +21,21 @@ export const createItemCallback =
|
||||
({ id, kind, options = {}, integrationIds = [] }: CreateItemInput) =>
|
||||
(previous: Board): Board => {
|
||||
const firstSection = previous.sections
|
||||
.filter((section): section is EmptySection => section.kind === "empty")
|
||||
.filter(
|
||||
(section): section is EmptySection =>
|
||||
section.kind === "empty" && getRootSectionLane(section.xOffset) === "main",
|
||||
)
|
||||
.toSorted((sectionA, sectionB) => sectionA.yOffset - sectionB.yOffset)
|
||||
.at(0);
|
||||
|
||||
if (!firstSection) return previous;
|
||||
|
||||
const itemLayouts = createItemLayouts(previous, firstSection, kind);
|
||||
const widget = {
|
||||
id: id ?? createId(),
|
||||
kind,
|
||||
options,
|
||||
layouts: createItemLayouts(previous, firstSection, kind),
|
||||
layouts: itemLayouts,
|
||||
integrationIds,
|
||||
advancedOptions: {
|
||||
title: null,
|
||||
@@ -60,9 +66,14 @@ const createItemLayouts = (board: Board, currentSection: EmptySection, kind: Wid
|
||||
return layouts.map((layoutId) => {
|
||||
const boardLayout = board.layouts.find((layout) => layout.id === layoutId);
|
||||
const elements = getSectionElements(board, { sectionId: currentSection.id, layoutId });
|
||||
const columnCount = boardLayout ? getBoardLaneColumnCount(boardLayout, "main") : itemSize.width;
|
||||
const size = {
|
||||
width: Math.min(itemSize.width, columnCount),
|
||||
height: itemSize.height,
|
||||
};
|
||||
|
||||
const emptyPosition = boardLayout
|
||||
? getFirstEmptyPosition(elements, boardLayout.columnCount, 9999, itemSize)
|
||||
? getFirstEmptyPosition(elements, columnCount, undefined, size)
|
||||
: { xOffset: 0, yOffset: 0 };
|
||||
|
||||
if (!emptyPosition) {
|
||||
@@ -70,8 +81,8 @@ const createItemLayouts = (board: Board, currentSection: EmptySection, kind: Wid
|
||||
}
|
||||
|
||||
return {
|
||||
width: itemSize.width,
|
||||
height: itemSize.height,
|
||||
width: size.width,
|
||||
height: size.height,
|
||||
...emptyPosition,
|
||||
sectionId: currentSection.id,
|
||||
layoutId,
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { createId } from "@homarr/common";
|
||||
import { getRootSectionLane } from "@homarr/definitions";
|
||||
|
||||
import type { Board, EmptySection, ItemLayout, Section } from "~/app/[locale]/boards/_types";
|
||||
import { getBoardLaneColumnCount } from "~/components/board/layout";
|
||||
import { getFirstEmptyPosition } from "./empty-position";
|
||||
import { getSectionElements } from "./section-elements";
|
||||
|
||||
@@ -15,16 +17,17 @@ export const duplicateItemCallback =
|
||||
if (!itemToDuplicate) return previous;
|
||||
|
||||
const clonedItem = structuredClone(itemToDuplicate);
|
||||
const clonedLayouts = clonedItem.layouts.map((layout) => ({
|
||||
...layout,
|
||||
...getNextPosition(previous, layout),
|
||||
}));
|
||||
|
||||
return {
|
||||
...previous,
|
||||
items: previous.items.concat({
|
||||
...clonedItem,
|
||||
id: createId(),
|
||||
layouts: clonedItem.layouts.map((layout) => ({
|
||||
...layout,
|
||||
...getNextPosition(previous, layout),
|
||||
})),
|
||||
layouts: clonedLayouts,
|
||||
}),
|
||||
};
|
||||
};
|
||||
@@ -42,8 +45,10 @@ const getNextPosition = (board: Board, layout: ItemLayout): { xOffset: number; y
|
||||
}
|
||||
|
||||
const firstSection = board.sections
|
||||
.filter((section): section is EmptySection => section.kind === "empty")
|
||||
.sort((sectionA, sectionB) => sectionA.yOffset - sectionB.yOffset)
|
||||
.filter(
|
||||
(section): section is EmptySection => section.kind === "empty" && getRootSectionLane(section.xOffset) === "main",
|
||||
)
|
||||
.toSorted((sectionA, sectionB) => sectionA.yOffset - sectionB.yOffset)
|
||||
.at(0);
|
||||
|
||||
if (!firstSection) {
|
||||
@@ -67,18 +72,24 @@ const getEmptySectionPosition = (
|
||||
layout: ItemLayout,
|
||||
section: Section,
|
||||
): { xOffset: number; yOffset: number } | undefined => {
|
||||
const boardLayout = board.layouts.find((boardLayout) => boardLayout.id === layout.layoutId);
|
||||
const boardLayout = board.layouts.find((candidate) => candidate.id === layout.layoutId);
|
||||
if (!boardLayout) return;
|
||||
|
||||
const sectionElements = getSectionElements(board, { sectionId: layout.sectionId, layoutId: layout.layoutId });
|
||||
const sectionElements = getSectionElements(board, { sectionId: section.id, layoutId: layout.layoutId });
|
||||
if (section.kind !== "dynamic") {
|
||||
return getFirstEmptyPosition(sectionElements, boardLayout.columnCount, undefined, {
|
||||
const columnCount =
|
||||
section.kind === "empty"
|
||||
? getBoardLaneColumnCount(boardLayout, getRootSectionLane(section.xOffset))
|
||||
: boardLayout.columnCount;
|
||||
|
||||
const size = {
|
||||
width: layout.width,
|
||||
height: layout.height,
|
||||
});
|
||||
};
|
||||
return getFirstEmptyPosition(sectionElements, columnCount, undefined, size);
|
||||
}
|
||||
|
||||
const sectionLayout = section.layouts.find((sectionLayout) => sectionLayout.layoutId === layout.layoutId);
|
||||
const sectionLayout = section.layouts.find((candidate) => candidate.layoutId === layout.layoutId);
|
||||
if (!sectionLayout) return;
|
||||
|
||||
return getFirstEmptyPosition(sectionElements, sectionLayout.width, sectionLayout.height, {
|
||||
|
||||
@@ -7,7 +7,7 @@ export const getFirstEmptyPosition = (
|
||||
size: { width: number; height: number } = { width: 1, height: 1 },
|
||||
) => {
|
||||
for (let yOffset = 0; yOffset < rowCount + 1 - size.height; yOffset++) {
|
||||
for (let xOffset = 0; xOffset < columnCount; xOffset++) {
|
||||
for (let xOffset = 0; xOffset < columnCount + 1 - size.width; xOffset++) {
|
||||
const isOccupied = elements.some(
|
||||
(element) =>
|
||||
element.yOffset < yOffset + size.height &&
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
import * as boardContext from "@homarr/boards/context";
|
||||
|
||||
@@ -10,6 +10,10 @@ import { ItemMockBuilder } from "./mocks/item-mock";
|
||||
import { LayoutMockBuilder } from "./mocks/layout-mock";
|
||||
|
||||
describe("item actions create-item", () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
test("should add it to first section", () => {
|
||||
// Arrange
|
||||
const itemKind = "clock";
|
||||
@@ -52,7 +56,7 @@ describe("item actions create-item", () => {
|
||||
],
|
||||
}),
|
||||
);
|
||||
expect(emptyPositionSpy).toHaveBeenCalledWith([], layout.columnCount, 9999, { height: 1, width: 1 });
|
||||
expect(emptyPositionSpy).toHaveBeenCalledWith([], layout.columnCount, undefined, { height: 1, width: 1 });
|
||||
});
|
||||
test("should correctly pass dynamic section and items to getFirstEmptyPosition", () => {
|
||||
// Arrange
|
||||
@@ -94,8 +98,10 @@ describe("item actions create-item", () => {
|
||||
|
||||
// Assert
|
||||
expect(result.items.length).toBe(3);
|
||||
const item = result.items.find((item) => item.id !== itemInFirstSection.id && item.id !== otherItem.id);
|
||||
expect(item).toEqual(
|
||||
const createdItem = result.items.find(
|
||||
(candidate) => candidate.id !== itemInFirstSection.id && candidate.id !== otherItem.id,
|
||||
);
|
||||
expect(createdItem).toEqual(
|
||||
expect.objectContaining({
|
||||
kind: itemKind,
|
||||
layouts: [{ ...emptyPosition, height: 1, width: 1, sectionId: firstSectionId, layoutId }],
|
||||
@@ -104,8 +110,35 @@ describe("item actions create-item", () => {
|
||||
expect(spy).toHaveBeenCalledWith(
|
||||
[expect.objectContaining(itemAndSectionPosition), expect.objectContaining(itemAndSectionPosition)],
|
||||
layout.columnCount,
|
||||
9999,
|
||||
undefined,
|
||||
{ height: 1, width: 1 },
|
||||
);
|
||||
});
|
||||
|
||||
test("clamps wide defaults and places below a full automatic canvas", () => {
|
||||
const board = new BoardMockBuilder().addEmptySection({ id: "canvas", yOffset: 0 }).build();
|
||||
const layout = board.layouts[0];
|
||||
if (!layout) throw new Error("Expected a board layout");
|
||||
layout.id = "layout";
|
||||
layout.columnCount = 1;
|
||||
board.items.push(
|
||||
new ItemMockBuilder({ id: "existing" })
|
||||
.addLayout({ layoutId: layout.id, sectionId: "canvas", width: 1, height: 1 })
|
||||
.build(),
|
||||
);
|
||||
|
||||
const result = createItemCallback({ id: "created", kind: "mediaMissing" })(board);
|
||||
const created = result.items.find((item) => item.id === "created");
|
||||
|
||||
expect(created?.layouts).toEqual([
|
||||
expect.objectContaining({
|
||||
layoutId: layout.id,
|
||||
sectionId: "canvas",
|
||||
width: 1,
|
||||
height: 3,
|
||||
xOffset: 0,
|
||||
yOffset: 1,
|
||||
}),
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
import { duplicateItemCallback } from "../duplicate-item";
|
||||
import * as emptyPositionModule from "../empty-position";
|
||||
@@ -7,6 +7,10 @@ import { ItemMockBuilder } from "./mocks/item-mock";
|
||||
import { LayoutMockBuilder } from "./mocks/layout-mock";
|
||||
|
||||
describe("item actions duplicate-item", () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
test("should copy it in the same section", () => {
|
||||
// Arrange
|
||||
const itemKind = "minecraftServerStatus";
|
||||
@@ -61,4 +65,28 @@ describe("item actions duplicate-item", () => {
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
test("uses the next automatic canvas row when it is the first available space", () => {
|
||||
const board = new BoardMockBuilder().addEmptySection({ id: "canvas", yOffset: 0 }).build();
|
||||
const layout = board.layouts[0];
|
||||
if (!layout) throw new Error("Expected a board layout");
|
||||
layout.id = "layout";
|
||||
layout.columnCount = 1;
|
||||
const currentItem = new ItemMockBuilder({ id: "source" })
|
||||
.addLayout({ layoutId: layout.id, sectionId: "canvas", width: 1, height: 1 })
|
||||
.build();
|
||||
board.items.push(currentItem);
|
||||
|
||||
const result = duplicateItemCallback({ itemId: currentItem.id })(board);
|
||||
const duplicated = result.items.find((item) => item.id !== currentItem.id);
|
||||
|
||||
expect(duplicated?.layouts[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
layoutId: layout.id,
|
||||
sectionId: "canvas",
|
||||
xOffset: 0,
|
||||
yOffset: 1,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,123 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { getMoveTargets } from "../../item-move-modal";
|
||||
import { BoardMockBuilder } from "./mocks/board-mock";
|
||||
import { DynamicSectionMockBuilder } from "./mocks/dynamic-section-mock";
|
||||
import { EmptySectionMockBuilder } from "./mocks/empty-section-mock";
|
||||
import { ItemMockBuilder } from "./mocks/item-mock";
|
||||
|
||||
describe("item move targets", () => {
|
||||
test("offers the canvas, enabled gutters, and nested sections", () => {
|
||||
const board = new BoardMockBuilder().build();
|
||||
const layout = board.layouts[0];
|
||||
if (!layout) throw new Error("Expected the board mock to contain a layout");
|
||||
layout.id = "layout";
|
||||
layout.columnCount = 6;
|
||||
layout.leftGutterColumnCount = 1;
|
||||
layout.rightGutterColumnCount = 1;
|
||||
|
||||
board.sections.push(
|
||||
new EmptySectionMockBuilder({ id: "canvas", xOffset: 0 }).build(),
|
||||
new EmptySectionMockBuilder({ id: "left-gutter", xOffset: -1 }).build(),
|
||||
new EmptySectionMockBuilder({ id: "right-gutter", xOffset: 1 }).build(),
|
||||
new DynamicSectionMockBuilder({
|
||||
id: "dynamic",
|
||||
options: {
|
||||
title: "",
|
||||
customCssClasses: [],
|
||||
borderColor: "",
|
||||
showLabel: true,
|
||||
collapsible: false,
|
||||
showOpenAll: false,
|
||||
},
|
||||
})
|
||||
.addLayout({
|
||||
layoutId: layout.id,
|
||||
parentSectionId: "canvas",
|
||||
width: 2,
|
||||
height: 2,
|
||||
})
|
||||
.build(),
|
||||
);
|
||||
|
||||
const targets = getMoveTargets(
|
||||
board,
|
||||
layout.id,
|
||||
{
|
||||
id: "weather",
|
||||
type: "item",
|
||||
xOffset: 0,
|
||||
yOffset: 0,
|
||||
width: 1,
|
||||
height: 1,
|
||||
},
|
||||
{
|
||||
canvas: "Dashboard canvas",
|
||||
dynamicSection: "New section",
|
||||
leftRail: "Left dashboard rail",
|
||||
rightRail: "Right dashboard rail",
|
||||
numbered: (name, index) => `${name}, destination ${index}`,
|
||||
located: (name, location, index) => `${name}, ${location}, destination ${index}`,
|
||||
},
|
||||
);
|
||||
|
||||
expect(targets.map((target) => target.label)).toEqual([
|
||||
"Dashboard canvas, destination 1",
|
||||
"Left dashboard rail, destination 2",
|
||||
"Right dashboard rail, destination 3",
|
||||
"New section, destination 4",
|
||||
]);
|
||||
expect(new Set(targets.map((target) => target.label))).toHaveLength(targets.length);
|
||||
});
|
||||
|
||||
test("does not offer canvases that are narrower than a nested section's contents", () => {
|
||||
const board = new BoardMockBuilder().build();
|
||||
const layout = board.layouts[0];
|
||||
if (!layout) throw new Error("Expected the board mock to contain a layout");
|
||||
layout.id = "layout";
|
||||
layout.columnCount = 5;
|
||||
layout.leftGutterColumnCount = 2;
|
||||
|
||||
const section = new DynamicSectionMockBuilder({ id: "source" })
|
||||
.addLayout({
|
||||
layoutId: layout.id,
|
||||
parentSectionId: "canvas",
|
||||
width: 4,
|
||||
height: 2,
|
||||
})
|
||||
.build();
|
||||
board.sections.push(
|
||||
new EmptySectionMockBuilder({ id: "canvas" }).build(),
|
||||
new EmptySectionMockBuilder({ id: "left-gutter", xOffset: -1 }).build(),
|
||||
section,
|
||||
);
|
||||
board.items.push(
|
||||
new ItemMockBuilder({ id: "nested" })
|
||||
.addLayout({
|
||||
layoutId: layout.id,
|
||||
sectionId: section.id,
|
||||
xOffset: 2,
|
||||
width: 2,
|
||||
})
|
||||
.build(),
|
||||
);
|
||||
|
||||
const sourceLayout = section.layouts[0];
|
||||
if (!sourceLayout) throw new Error("Expected the source section to contain a layout");
|
||||
const targets = getMoveTargets(
|
||||
board,
|
||||
layout.id,
|
||||
{ ...sourceLayout, id: section.id, type: "section" },
|
||||
{
|
||||
canvas: "Canvas",
|
||||
dynamicSection: "Section",
|
||||
leftRail: "Left",
|
||||
rightRail: "Right",
|
||||
numbered: (name, index) => `${name} ${index}`,
|
||||
located: (name, location, index) => `${name} ${location} ${index}`,
|
||||
},
|
||||
);
|
||||
|
||||
expect(targets).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -45,6 +45,8 @@ export class BoardMockBuilder {
|
||||
id: createId(),
|
||||
name: "Base",
|
||||
columnCount: 12,
|
||||
leftGutterColumnCount: 0,
|
||||
rightGutterColumnCount: 0,
|
||||
breakpoint: 0,
|
||||
},
|
||||
],
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
import { createId } from "@homarr/common";
|
||||
|
||||
import type { CategorySection } from "~/app/[locale]/boards/_types";
|
||||
|
||||
export class CategorySectionMockBuilder {
|
||||
private readonly section: CategorySection;
|
||||
|
||||
constructor(section?: Partial<CategorySection>) {
|
||||
this.section = {
|
||||
id: createId(),
|
||||
kind: "category",
|
||||
xOffset: 0,
|
||||
yOffset: 0,
|
||||
name: "Category",
|
||||
collapsed: false,
|
||||
...section,
|
||||
} satisfies CategorySection;
|
||||
}
|
||||
|
||||
public build(): CategorySection {
|
||||
return this.section;
|
||||
}
|
||||
}
|
||||
@@ -1,21 +1,23 @@
|
||||
import { createId } from "@homarr/common";
|
||||
import { dynamicSectionOptionsSchema } from "@homarr/validation/shared";
|
||||
|
||||
import type { DynamicSection } from "~/app/[locale]/boards/_types";
|
||||
|
||||
type DynamicSectionMock = Omit<Partial<DynamicSection>, "options"> & {
|
||||
options?: Partial<DynamicSection["options"]>;
|
||||
};
|
||||
|
||||
export class DynamicSectionMockBuilder {
|
||||
private readonly section: DynamicSection;
|
||||
|
||||
constructor(section?: Partial<DynamicSection>) {
|
||||
constructor(section?: DynamicSectionMock) {
|
||||
this.section = {
|
||||
id: createId(),
|
||||
kind: "dynamic",
|
||||
options: {
|
||||
title: "",
|
||||
borderColor: "",
|
||||
customCssClasses: [],
|
||||
},
|
||||
layouts: [],
|
||||
...section,
|
||||
collapsed: section?.collapsed ?? false,
|
||||
options: dynamicSectionOptionsSchema.parse(section?.options),
|
||||
} satisfies DynamicSection;
|
||||
}
|
||||
|
||||
|
||||
@@ -10,6 +10,8 @@ export class LayoutMockBuilder {
|
||||
id: createId(),
|
||||
name: "Base",
|
||||
columnCount: 12,
|
||||
leftGutterColumnCount: 0,
|
||||
rightGutterColumnCount: 0,
|
||||
breakpoint: 0,
|
||||
...layout,
|
||||
} satisfies Board["layouts"][0];
|
||||
|
||||
@@ -10,3 +10,14 @@
|
||||
background-color: var(--background-color) !important;
|
||||
border-color: var(--border-color) !important;
|
||||
}
|
||||
|
||||
.editModeInertContent {
|
||||
display: contents;
|
||||
pointer-events: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.editModeInertContent * {
|
||||
pointer-events: none !important;
|
||||
user-select: none !important;
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { MutableRefObject } from "react";
|
||||
import { useRef } from "react";
|
||||
import dynamic from "next/dynamic";
|
||||
import { Badge, Card } from "@mantine/core";
|
||||
import { useElementSize } from "@mantine/hooks";
|
||||
import { QueryErrorResetBoundary, useQueryClient } from "@tanstack/react-query";
|
||||
@@ -17,8 +18,12 @@ import type { SectionItem } from "~/app/[locale]/boards/_types";
|
||||
import classes from "../sections/item.module.css";
|
||||
import { useItemActions } from "./item-actions";
|
||||
import itemContentClasses from "./item-content.module.css";
|
||||
import { BoardItemMenu } from "./item-menu";
|
||||
import { WidgetContextMenu } from "./widget-context-menu";
|
||||
import { removePersistedWidgetQueries } from "./widget-query-recovery";
|
||||
|
||||
const BoardItemMenu = dynamic(() => import("./item-menu").then((module) => module.BoardItemMenu), {
|
||||
ssr: false,
|
||||
});
|
||||
|
||||
interface BoardItemContentProps {
|
||||
item: SectionItem;
|
||||
@@ -40,6 +45,9 @@ export const BoardItemContent = ({ item }: BoardItemContentProps) => {
|
||||
<WidgetContextMenu item={item} widgetStateRef={widgetStateRef}>
|
||||
<Card
|
||||
ref={ref}
|
||||
w="100%"
|
||||
h="100%"
|
||||
data-grid-item-content
|
||||
className={combineClasses(
|
||||
classes.itemCard,
|
||||
`${item.kind}-wrapper`,
|
||||
@@ -112,13 +120,19 @@ const InnerContent = ({ item, ...dimensions }: InnerContentProps) => {
|
||||
{({ reset }) => (
|
||||
<ErrorBoundary
|
||||
onReset={() => {
|
||||
void queryClient.invalidateQueries({ queryKey: [["widget"]] });
|
||||
removePersistedWidgetQueries(queryClient);
|
||||
reset();
|
||||
}}
|
||||
fallbackRender={({ resetErrorBoundary, error }) => (
|
||||
<>
|
||||
<BoardItemMenu offset={4} item={newItem} resetErrorBoundary={resetErrorBoundary} />
|
||||
<WidgetError kind={item.kind} error={error} resetErrorBoundary={resetErrorBoundary} />
|
||||
{isEditMode && <BoardItemMenu offset={4} item={newItem} resetErrorBoundary={resetErrorBoundary} />}
|
||||
<div
|
||||
className={itemContentClasses.editModeInertContent}
|
||||
data-board-grid-inert-content={isEditMode ? "true" : undefined}
|
||||
inert={isEditMode}
|
||||
>
|
||||
<WidgetError kind={item.kind} error={error} resetErrorBoundary={resetErrorBoundary} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
>
|
||||
@@ -130,23 +144,29 @@ const InnerContent = ({ item, ...dimensions }: InnerContentProps) => {
|
||||
(!("integrationsRequired" in definition) || definition.integrationsRequired !== false)
|
||||
}
|
||||
/>
|
||||
<BoardItemMenu offset={4} item={newItem} />
|
||||
<Comp
|
||||
options={options as never}
|
||||
integrationIds={item.integrationIds}
|
||||
isEditMode={isEditMode}
|
||||
boardId={board.id}
|
||||
itemId={item.id}
|
||||
setOptions={(partialNewOptions) =>
|
||||
updateOptions({
|
||||
newOptions: {
|
||||
...options,
|
||||
...partialNewOptions.newOptions,
|
||||
},
|
||||
})
|
||||
}
|
||||
{...dimensions}
|
||||
/>
|
||||
{isEditMode && <BoardItemMenu offset={4} item={newItem} />}
|
||||
<div
|
||||
className={itemContentClasses.editModeInertContent}
|
||||
data-board-grid-inert-content={isEditMode ? "true" : undefined}
|
||||
inert={isEditMode}
|
||||
>
|
||||
<Comp
|
||||
options={options as never}
|
||||
integrationIds={item.integrationIds}
|
||||
isEditMode={isEditMode}
|
||||
boardId={board.id}
|
||||
itemId={item.id}
|
||||
setOptions={(partialNewOptions) =>
|
||||
updateOptions({
|
||||
newOptions: {
|
||||
...options,
|
||||
...partialNewOptions.newOptions,
|
||||
},
|
||||
})
|
||||
}
|
||||
{...dimensions}
|
||||
/>
|
||||
</div>
|
||||
</ErrorBoundary>
|
||||
)}
|
||||
</QueryErrorResetBoundary>
|
||||
|
||||
@@ -2,8 +2,7 @@ import { useEffect, useMemo, useRef } from "react";
|
||||
import { ActionIcon, Menu } from "@mantine/core";
|
||||
import { IconCopy, IconDotsVertical, IconLayoutKanban, IconPencil, IconTrash } from "@tabler/icons-react";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { useSession } from "@homarr/auth/client";
|
||||
import { useIntegrations, useSession } from "@homarr/auth/client";
|
||||
import { useEditMode } from "@homarr/boards/edit-mode";
|
||||
import { useConfirmModal, useModalAction } from "@homarr/modals";
|
||||
import { useSettings } from "@homarr/settings";
|
||||
@@ -14,7 +13,7 @@ import { WidgetEditModal } from "@homarr/widgets/modals";
|
||||
import type { SectionItem } from "~/app/[locale]/boards/_types";
|
||||
import { useSectionContext } from "../sections/section-context";
|
||||
import { useItemActions } from "./item-actions";
|
||||
import { ItemMoveModal } from "./item-move-modal";
|
||||
import { useOpenItemMoveModal } from "./item-move-modal";
|
||||
|
||||
interface BoardItemMenuProps {
|
||||
offset: number;
|
||||
@@ -34,15 +33,17 @@ const BoardItemMenuInner = ({ offset, item, resetErrorBoundary }: BoardItemMenuP
|
||||
const tItem = useScopedI18n("item");
|
||||
const t = useI18n();
|
||||
const { openModal } = useModalAction(WidgetEditModal);
|
||||
const { openModal: openMoveModal } = useModalAction(ItemMoveModal);
|
||||
const openMoveModal = useOpenItemMoveModal();
|
||||
const { openConfirmModal } = useConfirmModal();
|
||||
const [isEditMode] = useEditMode();
|
||||
const { updateItemOptions, updateItemAdvancedOptions, updateItemIntegrations, duplicateItem, removeItem } =
|
||||
useItemActions();
|
||||
const { data: integrationData, isPending } = clientApi.integration.all.useQuery();
|
||||
const integrationData = useIntegrations();
|
||||
const currentDefinition = useMemo(() => widgetImports[item.kind].definition, [item.kind]);
|
||||
const { gridstack } = useSectionContext().refs;
|
||||
const { section } = useSectionContext();
|
||||
const settings = useSettings();
|
||||
const label = item.advancedOptions.title?.trim() || t(`widget.${item.kind}.name`);
|
||||
const menuRightOffset = section.kind === "dynamic" ? offset + 40 : offset;
|
||||
|
||||
// Reset error boundary on next render if item has been edited
|
||||
useEffect(() => {
|
||||
@@ -52,7 +53,7 @@ const BoardItemMenuInner = ({ offset, item, resetErrorBoundary }: BoardItemMenuP
|
||||
}
|
||||
}, [item, resetErrorBoundary]);
|
||||
|
||||
if (!isEditMode || isPending) return null;
|
||||
if (!isEditMode) return null;
|
||||
|
||||
const openEditModal = () => {
|
||||
openModal(
|
||||
@@ -78,7 +79,7 @@ const BoardItemMenuInner = ({ offset, item, resetErrorBoundary }: BoardItemMenuP
|
||||
});
|
||||
refResetErrorBoundaryOnNextRender.current = true;
|
||||
},
|
||||
integrationData: (integrationData ?? []).filter(
|
||||
integrationData: integrationData.filter(
|
||||
(integration) =>
|
||||
"supportedIntegrations" in currentDefinition &&
|
||||
(currentDefinition.supportedIntegrations as string[]).some((kind) => kind === integration.kind),
|
||||
@@ -89,8 +90,8 @@ const BoardItemMenuInner = ({ offset, item, resetErrorBoundary }: BoardItemMenuP
|
||||
appId: item.kind === "app" ? (item.options.appId as string | undefined) : undefined,
|
||||
},
|
||||
{
|
||||
title(t) {
|
||||
return `${t("item.edit.title")} - ${t(`widget.${item.kind}.name`)}`;
|
||||
title(translate) {
|
||||
return `${translate("item.edit.title")} - ${translate(`widget.${item.kind}.name`)}`;
|
||||
},
|
||||
},
|
||||
);
|
||||
@@ -114,9 +115,9 @@ const BoardItemMenuInner = ({ offset, item, resetErrorBoundary }: BoardItemMenuP
|
||||
radius={"xl"}
|
||||
pos="absolute"
|
||||
top={offset}
|
||||
right={offset}
|
||||
right={menuRightOffset}
|
||||
style={{ zIndex: 10 }}
|
||||
aria-label={tItem("menu.label.settings")}
|
||||
aria-label={tItem("menu.label.settingsFor", { name: label })}
|
||||
>
|
||||
<IconDotsVertical size={"1rem"} />
|
||||
</ActionIcon>
|
||||
@@ -129,8 +130,10 @@ const BoardItemMenuInner = ({ offset, item, resetErrorBoundary }: BoardItemMenuP
|
||||
<Menu.Item
|
||||
leftSection={<IconLayoutKanban size={16} />}
|
||||
onClick={() => {
|
||||
if (!gridstack.current) return;
|
||||
openMoveModal({ item, columnCount: gridstack.current.getColumn(), gridStack: gridstack.current });
|
||||
openMoveModal({
|
||||
entry: item,
|
||||
sourceSectionId: section.id,
|
||||
});
|
||||
}}
|
||||
>
|
||||
{tItem("action.moveResize")}
|
||||
|
||||
@@ -1,94 +1,209 @@
|
||||
import { useCallback, useRef } from "react";
|
||||
import { Grid, NumberInput, Stack } from "@mantine/core";
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { Grid, NumberInput, Select, Stack } from "@mantine/core";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { useCurrentLayout, useRequiredBoard } from "@homarr/boards/context";
|
||||
import { getRootSectionLane } from "@homarr/definitions";
|
||||
import { useZodForm } from "@homarr/form";
|
||||
import type { GridStack } from "@homarr/gridstack";
|
||||
import { createModal, ModalFormFooter, modalSizeForm } from "@homarr/modals";
|
||||
import { createModal, ModalFormFooter, modalSizeForm, useModalAction } from "@homarr/modals";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
|
||||
import type { Item, SectionItem } from "~/app/[locale]/boards/_types";
|
||||
import type { Board, DynamicSectionItem, SectionItem } from "~/app/[locale]/boards/_types";
|
||||
import { getBoardLaneColumnCount, getLayoutRowCount, reflowVerticalLayout } from "../layout";
|
||||
import type { CommitSectionGridInput, SectionGridPlacement } from "../sections/grid/use-grid-layout-actions";
|
||||
import { useGridLayoutActions } from "../sections/grid/use-grid-layout-actions";
|
||||
|
||||
interface InnerProps {
|
||||
gridStack: GridStack;
|
||||
item: Pick<SectionItem, "id" | "width" | "height" | "xOffset" | "yOffset">;
|
||||
columnCount: number;
|
||||
export type MovableEntry = Pick<
|
||||
SectionItem | DynamicSectionItem,
|
||||
"id" | "type" | "width" | "height" | "xOffset" | "yOffset"
|
||||
>;
|
||||
|
||||
export interface OpenItemMoveModalInput {
|
||||
entry: MovableEntry;
|
||||
sourceSectionId: string;
|
||||
}
|
||||
|
||||
interface InnerProps extends OpenItemMoveModalInput {
|
||||
board: Board;
|
||||
currentLayoutId: string;
|
||||
commitSectionGrids: (snapshots: readonly CommitSectionGridInput[]) => void;
|
||||
}
|
||||
|
||||
interface MoveTarget {
|
||||
id: string;
|
||||
label: string;
|
||||
columnCount: number;
|
||||
maxRowCount: number | null;
|
||||
}
|
||||
|
||||
interface MoveTargetLabels {
|
||||
canvas: string;
|
||||
dynamicSection: string;
|
||||
leftRail: string;
|
||||
rightRail: string;
|
||||
numbered: (name: string, index: number) => string;
|
||||
located: (name: string, location: string, index: number) => string;
|
||||
}
|
||||
|
||||
type MoveTargetCandidate = Omit<MoveTarget, "label"> & {
|
||||
name: string;
|
||||
location?: string;
|
||||
};
|
||||
|
||||
export const ItemMoveModal = createModal<InnerProps>(({ actions, innerProps }) => {
|
||||
const t = useI18n();
|
||||
const maxWidthRef = useRef(innerProps.columnCount - innerProps.item.xOffset);
|
||||
const { board, commitSectionGrids, currentLayoutId, entry, sourceSectionId } = innerProps;
|
||||
const minimumSize = useMemo(
|
||||
() => getEntryMinimumSize(board, currentLayoutId, entry),
|
||||
[board, currentLayoutId, entry],
|
||||
);
|
||||
const targets = useMemo(
|
||||
() =>
|
||||
getMoveTargets(board, currentLayoutId, entry, {
|
||||
canvas: t("board.landmark.canvas"),
|
||||
dynamicSection: t("section.dynamic.action.create"),
|
||||
leftRail: t("board.landmark.leftRail"),
|
||||
rightRail: t("board.landmark.rightRail"),
|
||||
numbered: (name, index) => t("item.moveResize.target.numbered", { name, index: String(index) }),
|
||||
located: (name, location, index) =>
|
||||
t("item.moveResize.target.located", {
|
||||
name,
|
||||
location,
|
||||
index: String(index),
|
||||
}),
|
||||
}),
|
||||
[board, currentLayoutId, entry, t],
|
||||
);
|
||||
const targetById = useMemo(() => new Map(targets.map((target) => [target.id, target])), [targets]);
|
||||
const initialTarget = targetById.get(sourceSectionId) ?? targets[0];
|
||||
|
||||
if (!initialTarget) {
|
||||
throw new Error("No valid dashboard section is available for this item");
|
||||
}
|
||||
|
||||
const form = useZodForm(
|
||||
z.object({
|
||||
xOffset: z
|
||||
.number()
|
||||
.min(0)
|
||||
.max(innerProps.columnCount - 1),
|
||||
yOffset: z.number().min(0),
|
||||
width: z.number().min(1).max(maxWidthRef.current),
|
||||
height: z.number().min(1),
|
||||
sectionId: z.string().min(1),
|
||||
xOffset: z.number().int().min(0).max(32767),
|
||||
yOffset: z.number().int().min(0).max(32767),
|
||||
width: z.number().int().min(1).max(32767),
|
||||
height: z.number().int().min(1).max(32767),
|
||||
}),
|
||||
{
|
||||
initialValues: {
|
||||
xOffset: innerProps.item.xOffset,
|
||||
yOffset: innerProps.item.yOffset,
|
||||
width: innerProps.item.width,
|
||||
height: innerProps.item.height,
|
||||
},
|
||||
onValuesChange(values, previous) {
|
||||
if (values.xOffset !== previous.xOffset) {
|
||||
maxWidthRef.current = innerProps.columnCount - values.xOffset;
|
||||
}
|
||||
sectionId: initialTarget.id,
|
||||
xOffset: entry.xOffset,
|
||||
yOffset: entry.yOffset,
|
||||
width: entry.width,
|
||||
height: entry.height,
|
||||
},
|
||||
},
|
||||
);
|
||||
const selectedTarget = targetById.get(form.values.sectionId) ?? initialTarget;
|
||||
|
||||
const handleSubmit = useCallback(
|
||||
(values: Pick<Item["layouts"][number], "height" | "width" | "xOffset" | "yOffset">) => {
|
||||
const gridItem = innerProps.gridStack
|
||||
.getGridItems()
|
||||
.find((item) => item.getAttribute("data-id") === innerProps.item.id);
|
||||
if (!gridItem) return;
|
||||
innerProps.gridStack.update(gridItem, {
|
||||
h: values.height,
|
||||
w: values.width,
|
||||
x: values.xOffset,
|
||||
(values: { sectionId: string; xOffset: number; yOffset: number; width: number; height: number }) => {
|
||||
const target = targetById.get(values.sectionId);
|
||||
if (!target) return;
|
||||
|
||||
const width = Math.max(minimumSize.width, Math.min(values.width, target.columnCount));
|
||||
const height = Math.max(minimumSize.height, values.height);
|
||||
const nextPlacement: SectionGridPlacement = {
|
||||
id: entry.id,
|
||||
type: entry.type,
|
||||
x: Math.min(values.xOffset, target.columnCount - width),
|
||||
y: values.yOffset,
|
||||
w: width,
|
||||
h: height,
|
||||
};
|
||||
const targetPlacements = getSectionPlacements(board, currentLayoutId, target.id).filter(
|
||||
(placement) => placement.id !== entry.id,
|
||||
);
|
||||
const resolvedTarget = reflowVerticalLayout([...targetPlacements, nextPlacement], {
|
||||
columnCount: target.columnCount,
|
||||
pinnedItemId: entry.id,
|
||||
});
|
||||
if (target.maxRowCount !== null && getLayoutRowCount(resolvedTarget) > target.maxRowCount) {
|
||||
form.setFieldError("height", t("item.moveResize.keyboard.boundary"));
|
||||
return;
|
||||
}
|
||||
|
||||
const snapshots =
|
||||
sourceSectionId === target.id
|
||||
? [{ layoutId: currentLayoutId, sectionId: target.id, placements: resolvedTarget }]
|
||||
: [
|
||||
{
|
||||
layoutId: currentLayoutId,
|
||||
sectionId: sourceSectionId,
|
||||
placements: reflowVerticalLayout(
|
||||
getSectionPlacements(board, currentLayoutId, sourceSectionId).filter(
|
||||
(placement) => placement.id !== entry.id,
|
||||
),
|
||||
{
|
||||
columnCount: getSectionColumnCount(board, currentLayoutId, sourceSectionId),
|
||||
},
|
||||
),
|
||||
},
|
||||
{ layoutId: currentLayoutId, sectionId: target.id, placements: resolvedTarget },
|
||||
];
|
||||
|
||||
commitSectionGrids(snapshots);
|
||||
actions.closeModal();
|
||||
focusMovedEntry(entry.id, target.id);
|
||||
},
|
||||
[actions, innerProps.gridStack, innerProps.item.id],
|
||||
[actions, board, commitSectionGrids, currentLayoutId, entry, form, minimumSize, sourceSectionId, t, targetById],
|
||||
);
|
||||
|
||||
return (
|
||||
<form onSubmit={form.onSubmit(handleSubmit, console.error)}>
|
||||
<Stack>
|
||||
<Select
|
||||
data={targets.map((target) => ({ value: target.id, label: target.label }))}
|
||||
label={t("item.moveResize.field.section.label")}
|
||||
data-autofocus
|
||||
allowDeselect={false}
|
||||
searchable
|
||||
{...form.getInputProps("sectionId")}
|
||||
/>
|
||||
<Grid>
|
||||
<Grid.Col span={{ base: 12, md: 6 }}>
|
||||
<NumberInput
|
||||
label={t("item.moveResize.field.xOffset.label")}
|
||||
data-autofocus
|
||||
min={0}
|
||||
max={innerProps.columnCount - 1}
|
||||
max={selectedTarget.columnCount - 1}
|
||||
{...form.getInputProps("xOffset")}
|
||||
/>
|
||||
</Grid.Col>
|
||||
|
||||
<Grid.Col span={{ base: 12, md: 6 }}>
|
||||
<NumberInput label={t("item.moveResize.field.yOffset.label")} min={0} {...form.getInputProps("yOffset")} />
|
||||
<NumberInput
|
||||
label={t("item.moveResize.field.yOffset.label")}
|
||||
min={0}
|
||||
max={selectedTarget.maxRowCount === null ? undefined : selectedTarget.maxRowCount - 1}
|
||||
{...form.getInputProps("yOffset")}
|
||||
/>
|
||||
</Grid.Col>
|
||||
|
||||
<Grid.Col span={{ base: 12, md: 6 }}>
|
||||
<NumberInput
|
||||
label={t("item.moveResize.field.width.label")}
|
||||
min={1}
|
||||
max={innerProps.columnCount - form.values.xOffset}
|
||||
min={minimumSize.width}
|
||||
max={selectedTarget.columnCount - Math.min(form.values.xOffset, selectedTarget.columnCount - 1)}
|
||||
{...form.getInputProps("width")}
|
||||
/>
|
||||
</Grid.Col>
|
||||
|
||||
<Grid.Col span={{ base: 12, md: 6 }}>
|
||||
<NumberInput label={t("item.moveResize.field.height.label")} min={1} {...form.getInputProps("height")} />
|
||||
<NumberInput
|
||||
label={t("item.moveResize.field.height.label")}
|
||||
min={minimumSize.height}
|
||||
max={
|
||||
selectedTarget.maxRowCount === null
|
||||
? undefined
|
||||
: selectedTarget.maxRowCount - Math.min(form.values.yOffset, selectedTarget.maxRowCount - 1)
|
||||
}
|
||||
{...form.getInputProps("height")}
|
||||
/>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
<ModalFormFooter onCancel={actions.closeModal} />
|
||||
@@ -101,3 +216,177 @@ export const ItemMoveModal = createModal<InnerProps>(({ actions, innerProps }) =
|
||||
},
|
||||
size: modalSizeForm,
|
||||
});
|
||||
|
||||
export const useOpenItemMoveModal = () => {
|
||||
const board = useRequiredBoard();
|
||||
const currentLayoutId = useCurrentLayout();
|
||||
const { commitSectionGrids } = useGridLayoutActions();
|
||||
const { openModal } = useModalAction(ItemMoveModal);
|
||||
|
||||
return useCallback(
|
||||
(input: OpenItemMoveModalInput) => {
|
||||
openModal({
|
||||
...input,
|
||||
board,
|
||||
currentLayoutId,
|
||||
commitSectionGrids,
|
||||
});
|
||||
},
|
||||
[board, commitSectionGrids, currentLayoutId, openModal],
|
||||
);
|
||||
};
|
||||
|
||||
export const getMoveTargets = (
|
||||
board: Board,
|
||||
layoutId: string,
|
||||
entry: MovableEntry,
|
||||
labels: MoveTargetLabels,
|
||||
): MoveTarget[] => {
|
||||
const minimum = getEntryMinimumSize(board, layoutId, entry);
|
||||
|
||||
const candidates = board.sections.flatMap((section): MoveTargetCandidate[] => {
|
||||
if (section.kind === "empty") {
|
||||
const columnCount = getSectionColumnCount(board, layoutId, section.id);
|
||||
const lane = getRootSectionLane(section.xOffset);
|
||||
if (columnCount === 0) return [];
|
||||
return minimum.width <= columnCount
|
||||
? [
|
||||
{
|
||||
id: section.id,
|
||||
name: lane === "left" ? labels.leftRail : lane === "right" ? labels.rightRail : labels.canvas,
|
||||
columnCount,
|
||||
maxRowCount: getSectionMaxRowCount(board, layoutId, section.id),
|
||||
},
|
||||
]
|
||||
: [];
|
||||
}
|
||||
|
||||
if (section.kind === "category") return [];
|
||||
|
||||
const layout = section.layouts.find((candidate) => candidate.layoutId === layoutId);
|
||||
if (!layout) return [];
|
||||
if (
|
||||
(entry.type === "section" && isSameOrDescendant(board, layoutId, section.id, entry.id)) ||
|
||||
minimum.width > layout.width ||
|
||||
minimum.height > layout.height
|
||||
) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
id: section.id,
|
||||
name: section.options.title || labels.dynamicSection,
|
||||
columnCount: layout.width,
|
||||
maxRowCount: layout.height,
|
||||
},
|
||||
];
|
||||
});
|
||||
|
||||
return candidates.map(({ name, location, ...target }, index) => ({
|
||||
...target,
|
||||
label: location ? labels.located(name, location, index + 1) : labels.numbered(name, index + 1),
|
||||
}));
|
||||
};
|
||||
|
||||
const getSectionPlacements = (board: Board, layoutId: string, sectionId: string): SectionGridPlacement[] => [
|
||||
...board.items.flatMap((item) => {
|
||||
const layout = item.layouts.find((candidate) => candidate.layoutId === layoutId);
|
||||
return layout?.sectionId === sectionId
|
||||
? [
|
||||
{
|
||||
id: item.id,
|
||||
type: "item" as const,
|
||||
x: layout.xOffset,
|
||||
y: layout.yOffset,
|
||||
w: layout.width,
|
||||
h: layout.height,
|
||||
},
|
||||
]
|
||||
: [];
|
||||
}),
|
||||
...board.sections.flatMap((section) => {
|
||||
if (section.kind !== "dynamic") return [];
|
||||
const layout = section.layouts.find((candidate) => candidate.layoutId === layoutId);
|
||||
return layout?.parentSectionId === sectionId
|
||||
? [
|
||||
{
|
||||
id: section.id,
|
||||
type: "section" as const,
|
||||
x: layout.xOffset,
|
||||
y: layout.yOffset,
|
||||
w: layout.width,
|
||||
h: layout.height,
|
||||
},
|
||||
]
|
||||
: [];
|
||||
}),
|
||||
];
|
||||
|
||||
const getSectionColumnCount = (board: Board, layoutId: string, sectionId: string) => {
|
||||
const layout = board.layouts.find((candidate) => candidate.id === layoutId);
|
||||
if (!layout) throw new Error(`Layout "${layoutId}" was not found`);
|
||||
const section = board.sections.find((candidate) => candidate.id === sectionId);
|
||||
if (!section) throw new Error(`Section "${sectionId}" was not found`);
|
||||
|
||||
if (section.kind === "dynamic") {
|
||||
const sectionLayout = section.layouts.find((candidate) => candidate.layoutId === layoutId);
|
||||
if (!sectionLayout) throw new Error(`Section "${sectionId}" has no current layout`);
|
||||
return sectionLayout.width;
|
||||
}
|
||||
if (section.kind === "category") return layout.columnCount;
|
||||
return getBoardLaneColumnCount(layout, getRootSectionLane(section.xOffset));
|
||||
};
|
||||
|
||||
const getSectionMaxRowCount = (board: Board, layoutId: string, sectionId: string) => {
|
||||
const section = board.sections.find((candidate) => candidate.id === sectionId);
|
||||
if (!section) return null;
|
||||
if (section.kind === "dynamic") {
|
||||
return section.layouts.find((candidate) => candidate.layoutId === layoutId)?.height ?? null;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const getEntryMinimumSize = (board: Board, layoutId: string, entry: MovableEntry) => {
|
||||
if (entry.type === "item") return { width: 1, height: 1 };
|
||||
const children = getSectionPlacements(board, layoutId, entry.id);
|
||||
return {
|
||||
width: Math.max(1, ...children.map((child) => child.x + child.w)),
|
||||
height: Math.max(1, ...children.map((child) => child.y + child.h)),
|
||||
};
|
||||
};
|
||||
|
||||
const isSameOrDescendant = (board: Board, layoutId: string, candidateId: string, ancestorId: string) => {
|
||||
let current: string | undefined = candidateId;
|
||||
const visited = new Set<string>();
|
||||
while (current && !visited.has(current)) {
|
||||
if (current === ancestorId) return true;
|
||||
visited.add(current);
|
||||
const section = board.sections.find((candidate) => candidate.id === current && candidate.kind === "dynamic");
|
||||
if (!section || section.kind !== "dynamic") return false;
|
||||
current = section.layouts.find((layout) => layout.layoutId === layoutId)?.parentSectionId;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const focusMovedEntry = (entryId: string, sectionId: string) => {
|
||||
let attempts = 0;
|
||||
const tryFocus = () => {
|
||||
const section = Array.from(document.querySelectorAll<HTMLElement>("[data-grid-section-id]")).find(
|
||||
(candidate) => candidate.dataset.gridSectionId === sectionId,
|
||||
);
|
||||
const entry = Array.from(section?.querySelectorAll<HTMLElement>(":scope > [data-grid-item-id]") ?? []).find(
|
||||
(candidate) => candidate.dataset.gridItemId === entryId,
|
||||
);
|
||||
const editorEntry = entry?.querySelector<HTMLElement>("[data-editor-grid-entry]");
|
||||
if (editorEntry) {
|
||||
editorEntry.focus();
|
||||
return;
|
||||
}
|
||||
|
||||
attempts += 1;
|
||||
if (attempts < 10) requestAnimationFrame(tryFocus);
|
||||
};
|
||||
|
||||
requestAnimationFrame(tryFocus);
|
||||
};
|
||||
@@ -21,11 +21,11 @@ export const ItemSelectModal = createModal<void>(({ actions }) => {
|
||||
const t = useI18n();
|
||||
const { createItem, updateItemOptions, updateItemAdvancedOptions, updateItemIntegrations } = useItemActions();
|
||||
const { openModal: openEditModal } = useModalAction(WidgetEditModal);
|
||||
const { data: integrationData } = clientApi.integration.all.useQuery();
|
||||
const { data: integrationData = [] } = clientApi.integration.all.useQuery();
|
||||
const { data: customWidgetDefs } = clientApi.customWidget.all.useQuery();
|
||||
const settings = useSettings();
|
||||
|
||||
const availableKinds = useMemo(() => new Set((integrationData ?? []).map((i) => i.kind)), [integrationData]);
|
||||
const availableKinds = useMemo(() => new Set(integrationData.map((i) => i.kind)), [integrationData]);
|
||||
|
||||
const items = useMemo(
|
||||
() =>
|
||||
@@ -79,7 +79,7 @@ export const ItemSelectModal = createModal<void>(({ actions }) => {
|
||||
const hasIntegrationSupport = "supportedIntegrations" in definition;
|
||||
|
||||
const matchingIntegrations = hasIntegrationSupport
|
||||
? (integrationData ?? []).filter((integration) =>
|
||||
? integrationData.filter((integration) =>
|
||||
(definition.supportedIntegrations as string[]).includes(integration.kind),
|
||||
)
|
||||
: [];
|
||||
|
||||
@@ -1,20 +1,30 @@
|
||||
"use client";
|
||||
|
||||
import type { MutableRefObject, ReactNode } from "react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { Group, Loader, Menu, Switch, Text } from "@mantine/core";
|
||||
import { IconCopy, IconLayoutKanban, IconRefresh, IconSettings, IconTrash } from "@tabler/icons-react";
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { Group, Loader, Menu, Switch, Text, Tooltip } from "@mantine/core";
|
||||
import {
|
||||
IconAlertTriangle,
|
||||
IconCircleCheck,
|
||||
IconCopy,
|
||||
IconLayoutKanban,
|
||||
IconRefresh,
|
||||
IconSettings,
|
||||
IconTrash,
|
||||
} from "@tabler/icons-react";
|
||||
import type { QueryClient, QueryKey } from "@tanstack/react-query";
|
||||
import { useIsFetching, useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { useSession } from "@homarr/auth/client";
|
||||
import { useIntegrations, useSession } from "@homarr/auth/client";
|
||||
import { useRequiredBoard } from "@homarr/boards/context";
|
||||
import { useEditMode } from "@homarr/boards/edit-mode";
|
||||
import { useTimeAgo } from "@homarr/common";
|
||||
import { useConfirmModal, useModalAction } from "@homarr/modals";
|
||||
import { useSettings } from "@homarr/settings";
|
||||
import { translateIfNecessary } from "@homarr/translation";
|
||||
import { useI18n, useScopedI18n } from "@homarr/translation/client";
|
||||
import type { TranslationFunction } from "@homarr/translation";
|
||||
import type { WidgetContextMenuAction } from "@homarr/widgets";
|
||||
import { reduceWidgetOptionsWithDefaultValues, widgetImports } from "@homarr/widgets";
|
||||
import { WidgetEditModal } from "@homarr/widgets/modals";
|
||||
@@ -22,7 +32,7 @@ import { WidgetEditModal } from "@homarr/widgets/modals";
|
||||
import type { SectionItem } from "~/app/[locale]/boards/_types";
|
||||
import { useSectionContext } from "../sections/section-context";
|
||||
import { useItemActions } from "./item-actions";
|
||||
import { ItemMoveModal } from "./item-move-modal";
|
||||
import { useOpenItemMoveModal } from "./item-move-modal";
|
||||
|
||||
interface WidgetContextMenuProps {
|
||||
item: SectionItem;
|
||||
@@ -38,15 +48,16 @@ export const WidgetContextMenu = ({ item, widgetStateRef, children }: WidgetCont
|
||||
const tMenu = useScopedI18n("item.menu.label");
|
||||
const t = useI18n();
|
||||
const settings = useSettings();
|
||||
const isRightClickEnabled = settings.enableRightClickOnWidgets;
|
||||
const { openModal } = useModalAction(WidgetEditModal);
|
||||
const { openModal: openMoveModal } = useModalAction(ItemMoveModal);
|
||||
const openMoveModal = useOpenItemMoveModal();
|
||||
const { openConfirmModal } = useConfirmModal();
|
||||
const { updateItemOptions, updateItemAdvancedOptions, updateItemIntegrations, duplicateItem, removeItem } =
|
||||
useItemActions();
|
||||
const { data: integrationData, isPending } = clientApi.integration.all.useQuery();
|
||||
const integrationData = useIntegrations();
|
||||
const { mutate: saveBoard } = clientApi.board.saveBoard.useMutation();
|
||||
const currentDefinition = useMemo(() => widgetImports[item.kind].definition, [item.kind]);
|
||||
const { gridstack } = useSectionContext().refs;
|
||||
const { section } = useSectionContext();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const widgetQueryKey = useMemo(
|
||||
@@ -120,7 +131,7 @@ export const WidgetContextMenu = ({ item, widgetStateRef, children }: WidgetCont
|
||||
);
|
||||
}
|
||||
},
|
||||
integrationData: (integrationData ?? []).filter(
|
||||
integrationData: integrationData.filter(
|
||||
(integration) =>
|
||||
"supportedIntegrations" in currentDefinition &&
|
||||
(currentDefinition.supportedIntegrations as string[]).some((kind) => kind === integration.kind),
|
||||
@@ -158,6 +169,7 @@ export const WidgetContextMenu = ({ item, widgetStateRef, children }: WidgetCont
|
||||
);
|
||||
|
||||
if (!session) return <>{children}</>;
|
||||
if (!isRightClickEnabled || isEditMode) return <>{children}</>;
|
||||
|
||||
const visibleWidgetActions = widgetContextActions.filter((a) => !a.hidden);
|
||||
|
||||
@@ -209,8 +221,10 @@ export const WidgetContextMenu = ({ item, widgetStateRef, children }: WidgetCont
|
||||
closeMenuOnClick
|
||||
leftSection={<IconLayoutKanban size={16} />}
|
||||
onClick={() => {
|
||||
if (!gridstack.current) return;
|
||||
openMoveModal({ item, columnCount: gridstack.current.getColumn(), gridStack: gridstack.current });
|
||||
openMoveModal({
|
||||
entry: item,
|
||||
sourceSectionId: section.id,
|
||||
});
|
||||
}}
|
||||
>
|
||||
{tItem("action.moveResize")}
|
||||
@@ -232,19 +246,17 @@ export const WidgetContextMenu = ({ item, widgetStateRef, children }: WidgetCont
|
||||
onClick={handleRefetch}
|
||||
disabled={isWidgetFetching}
|
||||
>
|
||||
<Group justify="space-between" wrap="nowrap">
|
||||
<Group justify="space-between" wrap="nowrap" gap="sm">
|
||||
{tMenu("refresh")}
|
||||
<Text size="xs" c="dimmed">
|
||||
<WidgetCacheAge queryClient={queryClient} queryKey={widgetQueryKey} isFetching={isWidgetFetching} />
|
||||
</Text>
|
||||
<WidgetQueryStatus
|
||||
queryClient={queryClient}
|
||||
queryKey={widgetQueryKey}
|
||||
isFetching={isWidgetFetching}
|
||||
t={t}
|
||||
/>
|
||||
</Group>
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
closeMenuOnClick
|
||||
leftSection={<IconSettings size={16} />}
|
||||
onClick={openEditModal}
|
||||
disabled={isPending}
|
||||
>
|
||||
<Menu.Item closeMenuOnClick leftSection={<IconSettings size={16} />} onClick={openEditModal}>
|
||||
{tMenu("settings")}
|
||||
</Menu.Item>
|
||||
</>
|
||||
@@ -273,34 +285,65 @@ export const WidgetContextMenu = ({ item, widgetStateRef, children }: WidgetCont
|
||||
);
|
||||
};
|
||||
|
||||
const WidgetCacheAge = ({
|
||||
queryClient,
|
||||
queryKey,
|
||||
isFetching,
|
||||
}: {
|
||||
interface WidgetQueryStatusProps {
|
||||
queryClient: QueryClient;
|
||||
queryKey: QueryKey;
|
||||
isFetching: boolean;
|
||||
}) => {
|
||||
// 1s tick for "just now" → "1s ago" label transition; isFetching prop handles refetch reactivity
|
||||
const [, setTick] = useState(0);
|
||||
useEffect(() => {
|
||||
const id = setInterval(() => setTick((n) => n + 1), 1000);
|
||||
return () => clearInterval(id);
|
||||
}, []);
|
||||
t: TranslationFunction;
|
||||
}
|
||||
|
||||
if (isFetching) return <Loader size={12} />;
|
||||
const WidgetQueryStatus = ({ queryClient, queryKey, isFetching, t }: WidgetQueryStatusProps) => {
|
||||
const queries = queryClient.getQueryCache().findAll({ queryKey });
|
||||
const timestamps = queries.map((query) => query.state.dataUpdatedAt).filter(Boolean);
|
||||
const latest = timestamps.length > 0 ? Math.max(...timestamps) : 0;
|
||||
const ageLabel = useTimeAgo(new Date(latest));
|
||||
|
||||
const timestamps = queryClient
|
||||
.getQueryCache()
|
||||
.findAll({ queryKey })
|
||||
.map((q) => q.state.dataUpdatedAt)
|
||||
.filter(Boolean);
|
||||
if (timestamps.length === 0) return null;
|
||||
if (isFetching) {
|
||||
return (
|
||||
<Group gap={4} wrap="nowrap">
|
||||
<Loader size={12} />
|
||||
<Text size="xs" c="dimmed">
|
||||
{t("item.menu.status.loading")}
|
||||
</Text>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
const latest = Math.max(...timestamps);
|
||||
const seconds = Math.floor((Date.now() - latest) / 1000);
|
||||
if (seconds < 5) return "just now";
|
||||
if (seconds < 60) return `${seconds}s ago`;
|
||||
return `${Math.floor(seconds / 60)}m ago`;
|
||||
if (queries.length === 0) {
|
||||
return (
|
||||
<Text size="xs" c="dimmed">
|
||||
{t("item.menu.status.idle")}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
const hasError = queries.some((q) => q.state.status === "error");
|
||||
|
||||
if (hasError) {
|
||||
const errorQuery = queries.find((q) => q.state.status === "error");
|
||||
const errorMessage =
|
||||
errorQuery?.state.error instanceof Error
|
||||
? errorQuery.state.error.message
|
||||
: String(errorQuery?.state.error ?? "Unknown error");
|
||||
|
||||
return (
|
||||
<Tooltip label={errorMessage} multiline position="left" w={250}>
|
||||
<Group gap={4} wrap="nowrap">
|
||||
<IconAlertTriangle size={12} color="var(--mantine-color-red-6)" />
|
||||
<Text size="xs" c="red.6">
|
||||
{t("item.menu.status.error")} · {ageLabel}
|
||||
</Text>
|
||||
</Group>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Group gap={4} wrap="nowrap">
|
||||
<IconCircleCheck size={12} color="var(--mantine-color-green-6)" />
|
||||
<Text size="xs" c="dimmed">
|
||||
{t("item.menu.status.success")} · {ageLabel}
|
||||
</Text>
|
||||
</Group>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,54 @@
|
||||
import { QueryClient } from "@tanstack/react-query";
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { removePersistedWidgetQueries } from "./widget-query-recovery";
|
||||
|
||||
describe("removePersistedWidgetQueries", () => {
|
||||
test("removes stale widget queries and allows fresh data to be fetched", async () => {
|
||||
const queryClient = new QueryClient();
|
||||
const crashedWidgetQueryKey = [["widget", "mediaServer", "getCurrentStreams"], { input: {} }];
|
||||
const unrelatedQueryKey = [["user", "me"], { input: {} }];
|
||||
|
||||
queryClient.setQueryData(crashedWidgetQueryKey, "stale");
|
||||
queryClient.setQueryData(unrelatedQueryKey, "unrelated");
|
||||
|
||||
removePersistedWidgetQueries(queryClient);
|
||||
|
||||
expect(queryClient.getQueryData(crashedWidgetQueryKey)).toBeUndefined();
|
||||
expect(queryClient.getQueryData(unrelatedQueryKey)).toBe("unrelated");
|
||||
await expect(
|
||||
queryClient.fetchQuery({ queryKey: crashedWidgetQueryKey, queryFn: () => Promise.resolve("fresh") }),
|
||||
).resolves.toBe("fresh");
|
||||
});
|
||||
|
||||
test("removes persisted queries outside the widget router", () => {
|
||||
const queryClient = new QueryClient();
|
||||
const dockerQueryKey = [["docker", "getContainers"], { input: {} }];
|
||||
const appQueryKey = [["app", "byId"], { input: {} }];
|
||||
const unrelatedQueryKey = [["user", "me"], { input: {} }];
|
||||
|
||||
queryClient.setQueryData(dockerQueryKey, "stale");
|
||||
queryClient.setQueryData(appQueryKey, "stale");
|
||||
queryClient.setQueryData(unrelatedQueryKey, "unrelated");
|
||||
|
||||
removePersistedWidgetQueries(queryClient);
|
||||
|
||||
expect(queryClient.getQueryData(dockerQueryKey)).toBeUndefined();
|
||||
expect(queryClient.getQueryData(appQueryKey)).toBeUndefined();
|
||||
expect(queryClient.getQueryData(unrelatedQueryKey)).toBe("unrelated");
|
||||
});
|
||||
|
||||
test("removes persisted widget queries when the widget kind and query path differ", () => {
|
||||
const queryClient = new QueryClient();
|
||||
const crashedWidgetQueryKey = [["widget", "anchorNotes", "getNote"], { input: {} }];
|
||||
const unrelatedQueryKey = [["user", "me"], { input: {} }];
|
||||
|
||||
queryClient.setQueryData(crashedWidgetQueryKey, "stale");
|
||||
queryClient.setQueryData(unrelatedQueryKey, "unrelated");
|
||||
|
||||
removePersistedWidgetQueries(queryClient);
|
||||
|
||||
expect(queryClient.getQueryData(crashedWidgetQueryKey)).toBeUndefined();
|
||||
expect(queryClient.getQueryData(unrelatedQueryKey)).toBe("unrelated");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,7 @@
|
||||
import type { QueryClient } from "@tanstack/react-query";
|
||||
|
||||
import { isPersistableWidgetQueryKey } from "@homarr/api/query-cache";
|
||||
|
||||
export const removePersistedWidgetQueries = (queryClient: QueryClient) => {
|
||||
queryClient.removeQueries({ predicate: (query) => isPersistableWidgetQueryKey(query.queryKey) });
|
||||
};
|
||||
Loaded 100 of 389 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user