Compare commits

..
Author SHA1 Message Date
Thomas Camlong ae2a1df453 fix(dev): preserve background build errors 2026-08-02 08:26:56 +02:00
Thomas Camlong ca7e50fb96 docs(dev): explain local PR preference 2026-08-02 08:19:45 +02:00
Thomas Camlong db6aa0b26e feat(dev): prefer current local PR builds 2026-08-02 08:19:29 +02:00
Thomas Camlong d096aecd59 feat(dev): track pull request head revisions 2026-08-02 08:19:12 +02:00
Thomas Camlong 49a41431b9 docs(dev): document dashboard navigation 2026-08-01 23:49:23 +02:00
Thomas Camlong df587ec76a feat(dev): toggle dashboard screens 2026-08-01 23:49:23 +02:00
Thomas Camlong 3b450eb04a feat(dev): launch browser by default 2026-08-01 23:49:23 +02:00
Thomas 406fb4c0b6 Merge branch 'dev' into feat/dev-cli-tool 2026-08-01 23:46:16 +02:00
Thomas Camlong f23c35645c fix(dev): surface registry pull errors 2026-08-01 23:40:41 +02:00
Thomas Camlong 667bf62396 docs(dev): document remote PR builds 2026-08-01 23:36:38 +02:00
Thomas Camlong c5a056b8e5 feat(dev): build remote pull requests locally 2026-08-01 23:35:08 +02:00
Thomas Camlong 934b6e69f4 fix(dev): shallow clone pull requests 2026-08-01 23:35:08 +02:00
Thomas Camlong c8c5dbc506 fix(dev): cache GitHub pull requests 2026-08-01 23:35:08 +02:00
Thomas 797bdd001f fix: keep direct icon URLs visible (#6504) 2026-08-01 23:28:18 +02:00
Bergasha 49314533bf feat(Media player widget): expands on Direct Play and Transcoding badges (#6489) 2026-08-01 23:21:13 +02:00
Thomas 27ca058088 feat(widgets): persist Beszel and Docker table layouts (#6490) 2026-08-01 23:20:09 +02:00
homarr-releases[bot] f4449f9581 chore: update bug report template (#6498) 2026-08-01 23:08:14 +02:00
Thomas b52bc03c8d Merge pull request #6499 from homarr-labs/docs/update-helm-docs
docs(helm): update chart installation page
2026-08-01 23:07:58 +02:00
Thomas edb56f3fcd fix: accept direct icon URLs
Use valid direct HTTP(S) icon URLs without repository search.
2026-08-01 23:06:26 +02:00
Thomas Camlong b1e79e4641 fix(dev): keep optional tools optional 2026-08-01 14:01:52 +02:00
Thomas Camlong 5b04ed1ab5 refactor(dev): make setup explicit and portable 2026-07-31 23:52:14 +02:00
homarr-crowdin[bot] fcc5130068 chore(lang): update translations from crowdin (#6457) 2026-07-31 23:50:10 +02:00
homarr-update-contributors[bot] c7751e293d chore: update contributors (#6496) 2026-07-31 23:49:37 +02:00
github-actions[bot] bd669a1a8f docs(helm): update chart installation page 2026-07-31 19:35:37 +00:00
Releases Homarr 9b5b79525f chore(release): 1.73.0 [skip ci] 2026-07-31 18:16:44 +00:00
homarr-releases[bot] 426929556f chore(release): automatic release v1.73.0 2026-07-31 18:15:13 +00:00
Thomas Camlong b733ed6a79 fix(dev): make key creation atomic 2026-07-31 15:28:00 +02:00
Thomas Camlong ae3e9f7793 fix(dev): persist unique local encryption key 2026-07-31 15:11:38 +02:00
Thomas Camlong c8485ed7fc fix(dev): address CLI review feedback 2026-07-31 14:53:43 +02:00
Thomas ade640dcea test(navidrome): complete cleanup review (#6494)
* test(navidrome): hoist album fixture helper

* test(navidrome): address coderabbit cleanup feedback
2026-07-31 14:39:11 +02:00
Thomas Camlong e97eb7fc58 feat(dev): add Docker development CLI 2026-07-31 14:28:31 +02:00
Thomas 61128ddcc8 fix(navidrome): clean up audio stats integration (#6493) 2026-07-31 14:20:51 +02:00
Thomas 6a3cee8107 fix(navidrome): complete media server session data (#6492) 2026-07-31 13:53:06 +02:00
Matt Van Horn 06d9c07d22 feat: expose Navidrome as a media-server (now-playing) integration (#6070) 2026-07-31 13:30:44 +02:00
Thomas 49d0f1a141 docs: explain Podman socket setup (#6487)
* docs: explain Podman socket setup

* docs: clarify Podman runtime requirements

* docs: complete Podman compose examples
2026-07-29 16:53:29 +02:00
Clarkar00 ac73b9d891 fix(widgets): refetch after render errors (#6399)
Remove incompatible persisted widget query data before resetting a crashed widget so it can fetch fresh data instead of re-rendering the same stale payload.
2026-07-28 23:12:03 +02:00
Thomas 489980814c Merge pull request #6465 from homarr-labs/docs/update-helm-docs
docs(helm): update chart installation page
2026-07-28 22:59:08 +02:00
Thomas 14654acced chore: gate Renovate updates behind dashboard approval (#6485) 2026-07-28 22:55:32 +02:00
Thomas 2885f7a297 fix(widgets): preserve data on integration failures (#6484) 2026-07-28 22:18:08 +02:00
Thomas 906c9f11fa feat(widgets): rework downloads widget with mantine-datatable (#6430) 2026-07-28 21:50:09 +02:00
Thomas 3419805a49 fix(beszel): restore balanced grid layout for Beszel System Grid widget (#6389) 2026-07-28 21:49:04 +02:00
homarr-releases[bot] 51a6cbe6d4 chore: update bug report template (#6464) 2026-07-28 14:56:56 +02:00
homarr-update-contributors[bot] d08eb2afa6 chore: update contributors (#6461) 2026-07-28 14:56:42 +02:00
Tim MooreandTim Moore 91a6b19ba1 fix(beszel): expire stored sessions so the integration recovers from token expiry (#6476)
Co-authored-by: Tim Moore <tim.moore@ingenuity.com.au>
2026-07-28 14:55:33 +02:00
Thomas e66979bab6 feat(auth): link credentials users with OIDC (#6445) 2026-07-28 14:49:51 +02:00
homarr-renovate[bot] 4ef7641cb2 chore(deps): update pnpm to v11.15.1 (#6466)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-07-25 02:43:47 +00:00
github-actions[bot] f33fc838cf docs(helm): update chart installation page 2026-07-24 21:17:32 +00:00
Releases Homarr f0019fa213 chore(release): 1.72.0 [skip ci] 2026-07-24 18:11:58 +00:00
homarr-releases[bot] ba8536b36d chore(release): automatic release v1.72.0 2026-07-24 18:10:37 +00:00
homarr-renovate[bot] 965aec5155 chore(deps): update pnpm to v11.15.0 (#6455)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-07-23 16:29:37 +00:00
homarr-crowdin[bot] 387db030cd chore(lang): updated translations from crowdin (#6283) 2026-07-23 09:14:14 +02:00
homarr-renovate[bot] 8e557c9924 chore(deps): update pnpm to v11.14.0 (#6449)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-07-23 05:58:19 +00:00
Thomas f26e60edf0 fix(old-import): map new clock weather options (#6452) 2026-07-23 02:08:50 +02:00
Thomas 7bbfd74454 Merge pull request #6384 from homarr-labs/docs/update-helm-docs
docs(helm): update chart installation page
2026-07-23 02:04:11 +02:00
homarr-releases[bot] 018b1ea4da chore: update bug report template (#6382) 2026-07-23 02:03:43 +02:00
Thomas 14c3fa2e76 fix(beszel): prevent live subscription memory growth (#6448)
* fix(beszel): bound live stats subscriptions

* fix(beszel): avoid blocking subscription teardown

* fix(import): map new clock options
2026-07-23 02:03:08 +02:00
Thomas 9168f8114b fix(manage): hide sidebar nav items from unprivileged users (#6236) 2026-07-23 02:01:10 +02:00
Thomas 9cabb42edb fix(widgets): add 350ms hover delay before opening calendar day popover (#6431) 2026-07-23 01:54:23 +02:00
Thomas bfba3c9215 fix(releases): avoid unnecessary GitHub pagination for LinuxServer updates (#6444) 2026-07-23 00:20:54 +02:00
Bergasha 7184bc09c1 feat(clock widget): Adds animated weather and temp (#6372) 2026-07-23 00:04:32 +02:00
Thomas 9bb0c0f811 fix(media-requests): cache statistics responses (#6443) 2026-07-22 17:38:28 +02:00
Thomas cf0e469465 feat: restrict apps management to board modifiers (#6425) 2026-07-21 16:56:33 +02:00
Thomas 8e7fa782b3 feat(weather): add humidity display (#6429) 2026-07-21 16:36:03 +02:00
Thomas 97652802f0 feat(immich): show random photos without album (#6424) 2026-07-21 16:10:45 +02:00
Thomas 1706289124 fix(media-server): guard against undefined playback from stale cached data (#6433) 2026-07-21 16:01:37 +02:00
homarr-renovate[bot] dd23de7aaa chore(deps): update pnpm to v11.13.1 (#6422)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-07-21 05:54:52 +00:00
Thomas f1602f67d4 fix: stop auto-seeding ArchiveTeam Warrior integration and widget on default dashboards (#6413) 2026-07-20 17:01:07 +02:00
Thomas c03cd97d3a fix(test): use Home Assistant long-lived token JWT
* fix(test): use Home Assistant long-lived token

* fix(test): revert to correct Home Assistant long-lived token JWT
2026-07-20 15:27:50 +02:00
Thomas a8e9a074d3 Merge pull request #6411 from homarr-labs/mysql-migration-test-timeout
fix(db): increase mysql migration test timeout to 120s
2026-07-20 15:19:12 +02:00
Thomas Camlong 1e9d3e0656 fix(db): increase mysql migration test timeout to 120s 2026-07-20 15:18:50 +02:00
Thomas 01e0f3612d docs(immich): list Asset View permission (#6408) 2026-07-20 14:57:07 +02:00
Thomas 0015a6d88d fix(immich): use thumbnail endpoint instead of original for album carousel (#6396)
* fix(immich): use thumbnail endpoint instead of original for album carousel

Switches from /api/assets/:id/original to /api/assets/:id/thumbnail to fix
HEIC image incompatibility (thumbnails are always JPEG), reduce bandwidth,
and improve loading speed.

Closes #6388

* working on immich

* Improve image proxy error handling

* fix(image-proxy): use trusted fetch response type

* docs(immich): require Asset View permission
2026-07-20 14:53:27 +02:00
Thomas 77dc317da2 chore(ci): remove memory regression tests (#6395) 2026-07-18 22:55:38 +02:00
homarr-update-contributors[bot] 6aeda3faaf chore: update contributors (#6378) 2026-07-18 22:43:01 +02:00
github-actions[bot] ac6a0c9254 docs(helm): update chart installation page 2026-07-17 19:24:43 +00:00
Releases Homarr ca358d9f8b chore(release): 1.71.0 [skip ci] 2026-07-17 17:58:17 +00:00
homarr-releases[bot] 03e0683892 chore(release): automatic release v1.71.0 2026-07-17 17:57:04 +00:00
Junker der Provinz 6ac4c6a7b9 fix(search): let public dashboards use generic custom search engines (#6375) 2026-07-17 14:53:52 +02:00
Junker der Provinz a0e0bf65ff fix(releases): let LinuxServer.io version filters find older releases via GitHub (#6376) 2026-07-17 14:53:01 +02:00
Junker der Provinz bc018e1fe0 fix(docker): disable the broken full-screen toggle on the container table (#6374) 2026-07-17 14:43:37 +02:00
Thomas 5e39c8813a feat(preferences): add enableRightClickOnWidgets user preference (#6298) 2026-07-17 14:42:19 +02:00
Thomas 06719cdef0 fix(ci): remove coverage report, scope format to changed files, fix oxlint config (#6359) 2026-07-16 10:21:07 +02:00
Bergasha 5144e14da4 fix(media-server): populate playback progress, transcode status, and network location for streams (#6315) 2026-07-15 16:27:58 +02:00
Thomas 8528d5068b feat(media-requests): filter requests list by status and time window (#6320) 2026-07-15 16:27:24 +02:00
Thomas c6c4428f7d Revert "fix(docker): report current CPU usage instead of the lifetime average" (#6357) 2026-07-15 14:55:18 +02:00
Thomas 7e4cb0201b fix(widgets): add refetchInterval to WidgetDefinition for per-widget polling (#6352) 2026-07-15 11:52:35 +02:00
Thomas 7887ce12c0 fix(integrations): resolve intermittent Seerr/Overseerr crashes and add query status to context menu (#6347) 2026-07-15 11:41:18 +02:00
Thomas 2d450239ac fix(releases): add required octokit throttling rate limit handlers (#6338) 2026-07-15 11:27:32 +02:00
Thomas bf4ccd8cb3 fix(ci): remove persist-credentials: false to fix git-auto-commit-action push (#6346) 2026-07-15 10:33:32 +02:00
homarr-renovate[bot] c473e80243 chore(deps): update pnpm to v11.11.0 (#6342)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-07-15 02:31:21 +00:00
homarr-renovate[bot] bf55bcce92 fix(deps): update dependency swiper to ^14.0.5 (#6341)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-07-14 23:55:02 +00:00
homarr-renovate[bot] 49248cedaf chore(deps): update dependency posthog-js to ^1.399.1 (#6340)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-07-14 22:09:52 +00:00
homarr-renovate[bot] a0034b2cfc chore(deps): update dependency @immich/sdk to ^3.0.2 (#6339)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-07-14 21:01:58 +00:00
homarr-renovate[bot] 4a297180fd chore(deps): update dependency lucide-react to ^1.24.0 (#6334)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-07-14 19:23:26 +00:00
Thomas 5fa3a3313a Merge pull request #6337 from homarr-labs/fix/ts7-nextjs-build
fix(docker): pin TypeScript to v6 to fix Next.js 16.2.10 build in CI
2026-07-14 18:11:38 +02:00
Thomas Camlong 29ee87c4aa fix(docker): pin typescript to v6 via alias to fix Next.js 16.2.10 build 2026-07-14 18:11:15 +02:00
Thomas 94a247e24a fix(docker): remove --offline flag from pnpm install step (#6335) 2026-07-14 17:19:51 +02:00
homarr-renovate[bot] 6d37710fec chore(deps): update dependency vite@>=5.0.0 <=5.4.18 to >=8.1.4 (#6333)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-07-14 11:26:55 +00:00
homarr-renovate[bot] c016beb2e5 chore(deps): update dependency vite to ^8.1.4 (#6332)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-07-14 09:06:17 +00:00
homarr-renovate[bot] f9e1b85c6a chore(deps): update dependency posthog-node to ^5.40.0 (#6314)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-07-14 06:14:41 +00:00
homarr-renovate[bot] 38ba19e665 chore(deps): update dependency oxlint to ^1.73.0 (#6306)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-07-14 03:22:16 +00:00
homarr-renovate[bot] f55c99d2d6 chore(deps): update dependency posthog-js to ^1.399.0 (#6331)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-07-14 00:00:38 +00:00
313 changed files with 50956 additions and 28497 deletions

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
View File
@@ -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
+3
View File
@@ -33,6 +33,9 @@ body:
options:
# The below comment is used to insert a new version with on-release.yml
#NEXT_VERSION#
- 1.73.0
- 1.72.0
- 1.71.0
- 1.70.0
- 1.69.2
- 1.69.0
+2 -2
View File
@@ -1,6 +1,6 @@
{
$schema: "https://docs.renovatebot.com/renovate-schema.json",
extends: ["config:recommended"],
extends: ["config:recommended", ":dependencyDashboardApproval"],
packageRules: [
{
matchPackageNames: ["^@homarr/"],
@@ -47,7 +47,7 @@
rangeStrategy: "bump",
automerge: false,
baseBranchPatterns: ["dev"],
dependencyDashboard: false,
dependencyDashboard: true,
// prevent special characters in branch names (otherwise git errors occur on windows)
branchNameStrict: true,
}
-1
View File
@@ -23,7 +23,6 @@ jobs:
with:
fetch-depth: 0
ref: ${{ github.head_ref }}
persist-credentials: false
- name: Setup
uses: ./tooling/github/setup
+7 -90
View File
@@ -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: |
+56
View File
@@ -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
-50
View File
@@ -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.
+9
View File
@@ -46,6 +46,8 @@ homarr/
├── tooling/
│ ├── typescript/ # Base tsconfig
│ └── github/ # CI setup action
├── tools/
│ └── homarr-dev/ # Go CLI for local and PR Docker images
├── development/ # Dev docker-compose (Redis, MySQL, PostgreSQL)
├── e2e/ # E2E test specs
└── Dockerfile # Multi-stage production build
@@ -68,6 +70,9 @@ When modifying user-facing code, you MUST also update the corresponding document
## Monorepo Commands
- `pnpm dev` — Next.js app only
- `pnpm dev:cli -- dev` — run the developer CLI without installing a global binary
- `pnpm db:seed` — seed default database data explicitly
- `pnpm docker:dev:up` — start the Redis development service in the background
- `pnpm dev:docs` — Docusaurus docs site only
- `pnpm turbo build` — build all packages
- `pnpm turbo build --filter=@homarr/docs` — build docs only
@@ -81,3 +86,7 @@ When modifying user-facing code, you MUST also update the corresponding document
- UI: Mantine (not Tailwind) — Tailwind is only used in docs app
- Icons: @tabler/icons-react
- Docs app can import from `@homarr/definitions` for shared types
- Run `pnpm dev:cli -- dev` to browse local `homarr:*` images and remote PR images.
- Run `pnpm dev:cli -- build <name>` from a Homarr checkout to build `homarr:<name>` with rebuild provenance.
- Run `pnpm dev:cli -- build --pr <number>` to build a PR locally from a temporary checkout.
- Run Go checks from `tools/homarr-dev` with `go test ./...` and `go vet ./...`.
+6
View File
@@ -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)
-23
View File
@@ -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
View File
@@ -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'
-33
View File
@@ -1,33 +0,0 @@
# 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.
## 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.
## 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.
## 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.
## 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.
## 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.
-3
View File
@@ -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" }
@@ -0,0 +1,41 @@
# Developer Docker CLI
The host-side developer CLI builds and runs local Homarr images and pull-request previews. Run it directly from a checkout with `pnpm dev:cli -- ...`; no shell-specific setup script is required. It is separate from the recovery CLI included in production images.
To install an optional `homarr` binary, run `pnpm dev:cli:install` and make your Go bin directory available on `PATH`. GitHub CLI authentication is only needed for pull-request features.
## Browse development images
```bash
pnpm dev:cli --
```
The main screen combines local `homarr:*` images with open pull requests and their GHCR preview status. Select an image to start or stop it, use `R` to build a remote pull request locally in the background or rebuild a local image, or use `p` to force a remote preview pull. Press `d` to switch to the instance dashboard and `d` again to return without losing the browser state. The explicit `dev` and `dash` subcommands start on either screen.
A locally built PR image is folded into its remote PR row only when its recorded revision exactly matches the current GitHub head SHA. The row is marked `◆ local`, and starting it prefers that local image without a registry pull. Outdated local builds remain separate rows. Navigation and dashboard switching remain available during background builds.
## Build images
```bash
pnpm dev:cli -- build <name>
pnpm dev:cli -- build --pr <number> [name]
```
The first command builds the current checkout as `homarr:<name>`. The pull-request form uses a depth-one clone in a temporary directory and defaults to `homarr:pr-<number>` when `name` is omitted.
## Rebuild images
```bash
pnpm dev:cli -- rebuild <name>
```
Images built by this CLI record their source checkout, revision, and optional pull-request number. Rebuild uses that provenance to select the original checkout or create a fresh temporary pull-request checkout.
## Run images
```bash
homarr run <name>
homarr run --pr <number>
```
Add `--detach` to run in the background, `--demo` to enable demo data, or repeat `--env KEY=VALUE` to provide additional environment variables.
@@ -17,8 +17,9 @@ This guide will help you set up your development environment to start contributi
- **Node.js (lts)**: You can download it from the [official website](https://nodejs.org/).
- **pnpm (latest)**: You can install it by running `npm install -g pnpm`.
- **Git**: You can download it from the [official website](https://git-scm.com/).
- **Docker or Docker Desktop**: You can download it from the [official website](https://www.docker.com/).
- **A GitHub account**: You can create one on the [official website](https://github.com/).
- **Docker or Docker Desktop**: Required for the Redis service and the developer Docker CLI.
- **Go 1.25+**: Required only for the developer Docker CLI. You can download it from the [official website](https://go.dev/).
- **GitHub CLI**: Required only for pull-request image features. Install it from the [official website](https://cli.github.com/), then authenticate with `gh auth login`.
### Setting up the project
@@ -34,11 +35,11 @@ This guide will help you set up your development environment to start contributi
4. Install the dependencies with `pnpm install`.
5. Start redis by running `pnpm run docker:dev`. This will run a docker-compose file with a redis service.
5. Create `.env` from `.env.example`. In a POSIX shell, run `cp .env.example .env`; in PowerShell, run `Copy-Item .env.example .env`. Set `DB_URL` to an absolute path for your SQLite file.
6. Copy the `.env.example` file to `.env` and replace `DB_URL` with the full path you want to use for the sqlite database file. For example `DB_URL='C:\\Dev\\homarr\\packages\\db\\db.sqlite'`
6. Start Redis in the background with `pnpm docker:dev:up` if your development workflow needs it.
7. Run the migrations to create the sqlite db file with `pnpm run db:migration:sqlite:run`.
7. Create or update the database with `pnpm db:migration:sqlite:run`. This also seeds the default data. To run the seed explicitly later, use `pnpm db:seed`.
8. Start the development server with `pnpm dev` and wait for a message that says `✓ Ready in 10.7s`
@@ -49,8 +50,15 @@ This guide will help you set up your development environment to start contributi
#### During development
- `pnpm dev`: Start the development server.
- `pnpm dev:cli -- dev`: Browse and run local images and remote pull-request images.
- `pnpm dev:cli -- build <name>`: Build the current checkout as `homarr:<name>`.
- `pnpm dev:cli -- build --pr <number>`: Build a pull request locally from a temporary checkout.
- `pnpm dev:cli -- rebuild <name>`: Rebuild a local image from its recorded checkout or pull request.
- `pnpm dev:cli:install`: Optionally install the developer CLI as a `homarr` binary.
- `pnpm cli`: Run cli commands.
- `pnpm db:migration:sqlite:run`: Run the migrations to create or update the sqlite db file.
- `pnpm db:seed`: Explicitly seed the default database data.
- `pnpm docker:dev:up`: Start the Redis development service in the background.
- `pnpm db:migration:sqlite:generate`: Generate a new migration file for the sqlite db. Also works with `mysql`.
- `pnpm db:studio`: Open a visual studio for the database.
- `pnpm package:new`: Create a new package in the monorepo.
@@ -63,6 +71,7 @@ This guide will help you set up your development environment to start contributi
- `pnpm typecheck`: Run the typescript type checker.
#### Building the project
- `pnpm build`: Build the project for production.
- `docker build -t homarr .`: Build a docker image for the project.
- `docker run -p 7575:7575 -e SECRET_ENCRYPTION_KEY='your_64_character_hex_string' homarr:latest`: Run the created docker image.
- `pnpm dev:cli -- build <name>`: Build a `homarr:<name>` image and record its checkout for later rebuilds.
- `docker run -p 7575:7575 -e SECRET_ENCRYPTION_KEY='your_64_character_hex_string' homarr:<name>`: Run the created docker image.
@@ -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 |
+1 -1
View File
@@ -31,4 +31,4 @@ Icons that are high quality and printable ([SVG](https://wikipedia.org/wiki/Scal
### Using external icons
If you don't find the icon you're looking for on Dashboard Icons or wish to use a different source, you can simply enter a URL that points to an icon.
It is important that the URL directly points to the file without any advertisements or elements around it.
Direct HTTP(S) image URLs are used as entered instead of being searched in the built-in repositories. It is important that the URL directly points to the file without any advertisements or elements around it.
@@ -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
@@ -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">
![Version: 8.22.0](https://img.shields.io/badge/Version-8.22.0-informational?style=flat)
![Version: 8.25.0](https://img.shields.io/badge/Version-8.25.0-informational?style=flat)
![Type: application](https://img.shields.io/badge/Type-application-informational?style=flat)
![AppVersion: v1.70.0](https://img.shields.io/badge/AppVersion-v1.70.0-informational?style=flat)
![AppVersion: v1.73.0](https://img.shields.io/badge/AppVersion-v1.73.0-informational?style=flat)
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.73.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:
+1 -2
View File
@@ -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 },
]}
/>
+2 -2
View File
@@ -28,7 +28,7 @@ The name must be at least one character long and can be chosen freely.
### Icon URL
This is the URL to the icon that will be displayed on the app widget.
It can be a url to an image in the internet, one from the included icon repositories or a local file uploaded through the upload button.
You can paste a direct HTTP(S) URL to an image, search the included icon repositories by name, or upload a local file through the upload button. Direct image URLs are used as entered and are not searched in the icon repositories.
![Icon URL](./img/app-icon-url.png)
@@ -50,4 +50,4 @@ It's useful when you want to ping the service internally for example with a cust
On the apps management page you find all apps that exist in your Homarr instance. You can edit and delete them.
![Apps list](./img/app-list.png)
![Apps list](./img/app-list.png)
+4 -5
View File
@@ -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
@@ -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.
+24 -22
View File
@@ -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
@@ -17,10 +17,17 @@ import { beszelIntegration } from '@site/docs/integrations/beszel';
categories={['System Monitoring']}
/>
This widget displays all your Beszel-monitored systems in a sortable data table. Each row shows system metrics like CPU, memory, disk, network, temperature, and more. Columns can be toggled on or off, and the table supports sorting by any column. When Beszel reports extra filesystems, the disk column adds a hover breakdown for each mount alongside the root disk.
This widget displays all your Beszel-monitored systems in a sortable data table. Each row shows system metrics like CPU, memory, disk, network, temperature, and more. Columns can be toggled on or off, dragged into a different order, and resized from their headers. Column order and widths are saved automatically for each widget. When Beszel reports extra filesystems, the disk column adds a hover breakdown for each mount alongside the root disk.
Select a system row to open the complete statistics view, including system, filesystem, Docker, historical, and realtime charts.
### Interactions
- **Reorder columns** by hovering a header to reveal its grab handle, then dragging it
- **Resize columns** by dragging a header's right edge
- **Sort systems** by selecting a sortable column header
- **Open system statistics** by selecting a row
### Supported Integrations
<WidgetIntegrations items={[{
@@ -40,12 +40,19 @@ import dockerTable from './img/table.png';
### Interactions
Each container row has an action menu (accessible via the three-dot icon) with the following options:
Hover a column header to reveal its grab handle, then drag it to reorder the table or drag the header's edge to resize it. The layout is saved automatically for each widget instance and restored after a reload. These controls are disabled while the board is in edit mode.
Each container row has an action menu, accessible via the three-dot icon or by right-clicking the row, with the following options:
- **View logs** - opens a dedicated terminal page streaming the container's logs
- **Start** / **Stop** / **Restart** - manage the container's state
- **Remove** - remove the container
- **Remove** - permanently remove the container after a second confirmation click
- **Add to Homarr** - create a bookmark app from the container
### Adding the widget
<AddingWidget />
### Configuration
<WidgetConfig configuration={dockerContainersWidget.configuration} />
@@ -8,6 +8,12 @@ export const dockerContainersWidget: WidgetDefinition = {
path: "../../widgets/docker-containers",
configuration: {
items: [
{
name: "Columns to show",
description: "Select which columns are visible in the table",
values: "Name, State, Host, CPU usage, Memory usage, and Actions",
defaultValue: "All columns",
},
{
name: "Enable items sorting",
description: "Allows to sort containers by clicking on the column headers",
Binary file not shown.

After

Width:  |  Height:  |  Size: 210 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

After

Width:  |  Height:  |  Size: 122 KiB

+35 -9
View File
@@ -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, then hover a header to reveal its grab handle for reordering or drag its edge to resize it; layouts save automatically per widget
- **Right-click context menu** — pause, resume, delete downloads, or copy download names directly from the context menu
- **Row expansion** — click any row to see full details inline without opening a separate modal
- **Hover tooltips** — hover a download name to see size, speeds, ETA, ratio, and category
- **Global stats bar** — toggle a summary bar showing queue progress, total size, speeds, ratio, and per-state counts
- **Client filtering** — filter downloads by integration or status using the footer filter panel
- **Global controls** — pause/resume all downloads across all clients from the footer bar
- **Status indicators** — color-coded badges show download state at a glance
- **Speed overview** — total download and upload speeds displayed in the footer
### Supported Integrations
<WidgetIntegrations 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
+24 -32
View File
@@ -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} />
+1 -1
View File
@@ -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",
},
-25
View File
@@ -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 {
+2 -6
View File
@@ -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": {
-26
View File
@@ -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>
);
}
-16
View File
@@ -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>
);
}
-11
View File
@@ -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;
}
-46
View File
@@ -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();
}),
);
});
-1
View File
@@ -50,7 +50,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:",
@@ -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;
});
@@ -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>
@@ -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>
);
};
+4 -11
View File
@@ -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) }),
@@ -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 } : {}),
},
});
};
@@ -19,6 +19,7 @@ import { useItemActions } from "./item-actions";
import itemContentClasses from "./item-content.module.css";
import { BoardItemMenu } from "./item-menu";
import { WidgetContextMenu } from "./widget-context-menu";
import { removePersistedWidgetQueries } from "./widget-query-recovery";
interface BoardItemContentProps {
item: SectionItem;
@@ -112,7 +113,7 @@ const InnerContent = ({ item, ...dimensions }: InnerContentProps) => {
{({ reset }) => (
<ErrorBoundary
onReset={() => {
void queryClient.invalidateQueries({ queryKey: [["widget"]] });
removePersistedWidgetQueries(queryClient);
reset();
}}
fallbackRender={({ resetErrorBoundary, error }) => (
@@ -1,9 +1,17 @@
"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";
@@ -11,10 +19,12 @@ 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 { 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";
@@ -38,6 +48,7 @@ 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 { openConfirmModal } = useConfirmModal();
@@ -158,6 +169,7 @@ export const WidgetContextMenu = ({ item, widgetStateRef, children }: WidgetCont
);
if (!session) return <>{children}</>;
if (!isRightClickEnabled) return <>{children}</>;
const visibleWidgetActions = widgetContextActions.filter((a) => !a.hidden);
@@ -232,11 +244,14 @@ 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
@@ -273,34 +288,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) });
};
@@ -1,72 +0,0 @@
"use client";
import { Button, Modal } from "@mantine/core";
import { useDisclosure } from "@mantine/hooks";
import { IconBuildingStore } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
import { revalidatePathActionAsync } from "@homarr/common/client";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useScopedI18n } from "@homarr/translation/client";
import { customWidgetImportSchema } from "@homarr/validation/custom-widget";
import { validateWorkshopContent } from "@homarr/workshop";
import { WorkshopBrowser } from "./workshop-browser";
export function InstallWidgetFromWorkshopButton() {
const t = useScopedI18n("workshop");
const [opened, { open, close }] = useDisclosure(false);
const utils = clientApi.useUtils();
const mutation = clientApi.customWidget.import.useMutation({
onSuccess: () => {
showSuccessNotification({ title: t("title"), message: t("action.install") });
void utils.customWidget.all.invalidate();
void revalidatePathActionAsync("/manage/custom-widgets");
close();
},
onError: (error) => showErrorNotification({ title: t("title"), message: error.message }),
});
return (
<>
<Button variant="default" leftSection={<IconBuildingStore size={16} />} onClick={open}>
{t("action.install")}
</Button>
<Modal opened={opened} onClose={close} title={t("title")} size="90%" fullScreen={false}>
<WorkshopBrowser
initialType="widget"
lockedType="widget"
useLabel={t("action.install")}
onUse={async (submission) => {
const parsed = customWidgetImportSchema.parse(JSON.parse(submission.content));
await mutation.mutateAsync(parsed);
}}
/>
</Modal>
</>
);
}
export function UseCssFromWorkshopButton({ onUse }: { onUse: (css: string) => void }) {
const t = useScopedI18n("workshop");
const [opened, { open, close }] = useDisclosure(false);
return (
<>
<Button variant="default" leftSection={<IconBuildingStore size={16} />} onClick={open}>
{t("action.useCss")}
</Button>
<Modal opened={opened} onClose={close} title={t("title")} size="90%">
<WorkshopBrowser
initialType="css"
lockedType="css"
useLabel={t("action.useCss")}
onUse={(submission) => {
const validation = validateWorkshopContent("css", submission.content);
if (!validation.success) throw new Error(validation.error);
onUse(submission.content);
close();
}}
/>
</Modal>
</>
);
}
@@ -1,354 +0,0 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import {
Alert,
Badge,
Box,
Button,
Card,
Code,
Group,
Image,
Modal,
Paper,
Pagination,
Select,
SimpleGrid,
Skeleton,
Stack,
Text,
TextInput,
} from "@mantine/core";
import { useDebouncedValue } from "@mantine/hooks";
import {
IconAlertTriangle,
IconBuildingStore,
IconDownload,
IconExternalLink,
IconSearch,
IconWifiOff,
} from "@tabler/icons-react";
import { keepPreviousData, useMutation, useQuery } from "@tanstack/react-query";
import type { WorkshopSubmissionDetail, WorkshopSubmissionType } from "@homarr/workshop";
import { WORKSHOP_API_URL, WORKSHOP_WEB_URL, WorkshopClient, workshopExportFilename } from "@homarr/workshop";
import { useScopedI18n } from "@homarr/translation/client";
import { WorkshopQueryProvider } from "./workshop-query-provider";
const apiUrl = process.env.NEXT_PUBLIC_WORKSHOP_API_URL ?? WORKSHOP_API_URL;
export interface WorkshopBrowserProps {
initialType?: WorkshopSubmissionType | "all";
lockedType?: WorkshopSubmissionType;
onUse?: (submission: WorkshopSubmissionDetail) => Promise<void> | void;
useLabel?: string;
}
export function WorkshopBrowser(props: WorkshopBrowserProps) {
return (
<WorkshopQueryProvider>
<WorkshopBrowserContent {...props} />
</WorkshopQueryProvider>
);
}
function WorkshopBrowserContent({ initialType = "all", lockedType, onUse, useLabel }: WorkshopBrowserProps) {
const t = useScopedI18n("workshop");
const client = useMemo(() => new WorkshopClient(apiUrl), []);
const [page, setPage] = useState(1);
const [type, setType] = useState<WorkshopSubmissionType | "all">(initialType);
const [sort, setSort] = useState<"top" | "newest">("top");
const [search, setSearch] = useState("");
const [debouncedSearch] = useDebouncedValue(search, 250);
const [selectedId, setSelectedId] = useState<string | null>(null);
const [online, setOnline] = useState(true);
useEffect(() => {
const updateOnline = () => setOnline(window.navigator.onLine);
updateOnline();
window.addEventListener("online", updateOnline);
window.addEventListener("offline", updateOnline);
return () => {
window.removeEventListener("online", updateOnline);
window.removeEventListener("offline", updateOnline);
};
}, []);
const query = useQuery({
queryKey: ["workshop", "list", page, type, sort, debouncedSearch],
queryFn: ({ signal }) => client.list({ page, type, sort, search: debouncedSearch, perPage: 12, signal }),
placeholderData: keepPreviousData,
});
const detailQuery = useQuery({
queryKey: ["workshop", "detail", selectedId],
queryFn: ({ signal }) => client.get(selectedId ?? "", signal),
enabled: selectedId !== null,
});
const useMutationState = useMutation({
mutationKey: ["workshop", "use"],
mutationFn: async (submission: WorkshopSubmissionDetail) => {
if (lockedType && submission.type !== lockedType) throw new Error(t("error.wrongType"));
if (!onUse) return;
await onUse(submission);
},
onSuccess: () => setSelectedId(null),
});
const selected = detailQuery.data ?? null;
const hasCachedResults = Boolean(query.data?.items.length);
const download = (submission: WorkshopSubmissionDetail) => {
const url = URL.createObjectURL(
new Blob([submission.content], { type: submission.type === "css" ? "text/css" : "application/json" }),
);
const anchor = document.createElement("a");
anchor.href = url;
anchor.download = workshopExportFilename(submission.title, submission.type);
anchor.click();
URL.revokeObjectURL(url);
};
return (
<Stack gap="lg">
<Paper withBorder p="lg" radius="md">
<Group justify="space-between" align="flex-start" wrap="wrap">
<Group align="flex-start" wrap="nowrap">
<Box
p="sm"
bg="var(--mantine-color-default-hover)"
style={{ borderRadius: "var(--mantine-radius-md)", lineHeight: 0 }}
>
<IconBuildingStore size={24} aria-hidden />
</Box>
<Box>
<Text fw={700} size="lg">
{t("intro.title")}
</Text>
<Text c="dimmed" size="sm" maw={620}>
{t("intro.description")}
</Text>
</Box>
</Group>
<Badge color={online ? "green" : "yellow"} variant="light">
{online ? t("status.connected") : t("status.offline")}
</Badge>
</Group>
</Paper>
<Alert icon={<IconAlertTriangle size={18} />} color="yellow" title={t("safety.title")}>
{t("safety.description")}
</Alert>
<SimpleGrid cols={{ base: 1, sm: lockedType ? 2 : 3 }} spacing="sm">
<TextInput
label={t("search.label")}
placeholder={t("search.placeholder")}
leftSection={<IconSearch size={16} />}
value={search}
onChange={(event) => {
setSearch(event.currentTarget.value);
setPage(1);
}}
/>
<Select
label={t("filter.type")}
value={type}
disabled={Boolean(lockedType)}
onChange={(value) => {
setType((value ?? "all") as typeof type);
setPage(1);
}}
data={[
{ value: "all", label: t("filter.all") },
{ value: "widget", label: t("filter.widgets") },
{ value: "css", label: t("filter.css") },
]}
/>
<Select
label={t("filter.sort")}
value={sort}
onChange={(value) => {
setSort((value ?? "top") as typeof sort);
setPage(1);
}}
data={[
{ value: "top", label: t("filter.top") },
{ value: "newest", label: t("filter.newest") },
]}
/>
</SimpleGrid>
{(query.isError || query.fetchStatus === "paused") && (
<Alert
icon={<IconWifiOff size={18} />}
color={hasCachedResults ? "yellow" : "red"}
title={hasCachedResults ? t("offline.cachedTitle") : t("offline.title")}
>
<Stack gap="sm">
<Text size="sm">
{hasCachedResults
? t("offline.cachedDescription")
: online
? t("offline.unavailableDescription")
: t("offline.description")}
</Text>
<Group wrap="wrap">
<Button size="xs" variant="light" onClick={() => void query.refetch()}>
{t("action.retry")}
</Button>
<Button
size="xs"
variant="subtle"
component="a"
href="https://homarr.dev/docs/management/workshop"
target="_blank"
>
{t("action.website")}
</Button>
</Group>
</Stack>
</Alert>
)}
{query.isLoading ? (
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }}>
<Skeleton h={280} />
<Skeleton h={280} />
<Skeleton h={280} />
</SimpleGrid>
) : query.data?.items.length ? (
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }}>
{query.data.items.map((item) => (
<Card key={item.id} withBorder padding="md">
{item.screenshots[0] ? (
<Card.Section>
<Image src={client.fileUrl(item.id, item.screenshots[0], "480x320")} h={170} alt="" />
</Card.Section>
) : (
<Card.Section>
<Box
h={170}
bg="var(--mantine-color-default-hover)"
style={{ display: "grid", placeItems: "center" }}
>
<Text ff="monospace" fw={700}>
{item.type === "widget" ? "{}" : "CSS"}
</Text>
</Box>
</Card.Section>
)}
<Stack gap="xs" mt="md">
<Group justify="space-between">
<Badge variant="light">{item.type === "widget" ? t("type.widget") : t("type.css")}</Badge>
<Text size="sm" c="dimmed">
{item.score >= 0 ? "+" : ""}
{item.score}
</Text>
</Group>
<Text fw={600} lineClamp={1}>
{item.title}
</Text>
<Text size="sm" c="dimmed" lineClamp={2}>
{item.description || t("empty.description")}
</Text>
<Text size="xs" c="dimmed">
{t("by", { name: item.authorName, revision: item.revision })}
</Text>
<Button variant="light" onClick={() => setSelectedId(item.id)}>
{t("action.inspect")}
</Button>
</Stack>
</Card>
))}
</SimpleGrid>
) : query.isError || query.fetchStatus === "paused" ? null : (
<Stack align="center" py="xl">
<Text fw={600}>{t("empty.title")}</Text>
<Text c="dimmed">{t("empty.description")}</Text>
</Stack>
)}
{(query.data?.totalPages ?? 0) > 1 && (
<Pagination total={query.data?.totalPages ?? 1} value={page} onChange={setPage} mx="auto" />
)}
<Modal
opened={selectedId !== null}
onClose={() => {
setSelectedId(null);
useMutationState.reset();
}}
title={selected?.title ?? t("loading")}
size="xl"
>
{detailQuery.isPending ? (
<Skeleton h={400} />
) : detailQuery.isError || !selected ? (
<Alert icon={<IconWifiOff size={18} />} color="red" title={t("error.detailTitle")}>
<Stack gap="sm">
<Text size="sm">{t("error.detailDescription")}</Text>
<Button size="xs" variant="light" onClick={() => void detailQuery.refetch()}>
{t("action.retry")}
</Button>
</Stack>
</Alert>
) : (
<Stack>
<Group>
<Badge>{selected.type === "widget" ? t("type.widget") : t("type.css")}</Badge>
<Text size="sm" c="dimmed">
{t("by", { name: selected.authorName, revision: selected.revision })}
</Text>
</Group>
<Text>{selected.description}</Text>
{selected.screenshots.length > 0 && (
<SimpleGrid cols={{ base: 1, sm: 2 }}>
{selected.screenshots.map((screenshot, index) => (
<Image
key={screenshot}
src={client.fileUrl(selected.id, screenshot, "960x640")}
radius="sm"
alt={t("screenshotAlt", { title: selected.title, number: index + 1 })}
/>
))}
</SimpleGrid>
)}
<Alert icon={<IconAlertTriangle size={18} />} color="yellow">
{t("safety.inspect")}
</Alert>
<Code block mah={360} style={{ overflow: "auto", whiteSpace: "pre" }}>
{selected.content}
</Code>
<Group justify="space-between">
<Group>
<Button variant="default" leftSection={<IconDownload size={16} />} onClick={() => download(selected)}>
{t("action.download")}
</Button>
<Button
component="a"
href={`${WORKSHOP_WEB_URL}/${selected.id}`}
target="_blank"
variant="subtle"
leftSection={<IconExternalLink size={16} />}
>
{t("action.website")}
</Button>
</Group>
{onUse && (
<Button
loading={useMutationState.isPending}
disabled={!online}
onClick={() => useMutationState.mutate(selected)}
>
{useLabel ?? t("action.use")}
</Button>
)}
</Group>
{!online && onUse && <Alert color="yellow">{t("offline.actionUnavailable")}</Alert>}
{useMutationState.error && (
<Alert color="red">
{useMutationState.error instanceof Error ? useMutationState.error.message : t("error.install")}
</Alert>
)}
</Stack>
)}
</Modal>
</Stack>
);
}
@@ -1,51 +0,0 @@
"use client";
import type { PropsWithChildren } from "react";
import { 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 workshopCacheMaxAge = 1000 * 60 * 60 * 24;
export function WorkshopQueryProvider({ children }: PropsWithChildren) {
const [queryClient] = useState(
() =>
new QueryClient({
defaultOptions: {
queries: {
gcTime: workshopCacheMaxAge,
networkMode: "offlineFirst",
refetchOnReconnect: true,
retry: 1,
staleTime: 1000 * 60 * 5,
},
mutations: {
networkMode: "online",
retry: false,
},
},
}),
);
const [persister] = useState(() =>
createAsyncStoragePersister({
key: "homarr-workshop-cache-v1",
storage: typeof window === "undefined" ? undefined : window.localStorage,
throttleTime: 1000,
}),
);
return (
<PersistQueryClientProvider
client={queryClient}
persistOptions={{
persister,
maxAge: workshopCacheMaxAge,
buster: "workshop-v1",
dehydrateOptions: { shouldDehydrateQuery: (query) => query.state.status === "success" },
}}
>
{children}
</PersistQueryClientProvider>
);
}
-1
View File
@@ -1 +0,0 @@
pb_data/
-77
View File
@@ -1,77 +0,0 @@
# syntax=docker/dockerfile:1.10
FROM node:24.18.0-alpine AS dependencies
WORKDIR /app
ENV COREPACK_HOME=/corepack
RUN mkdir -p /corepack && \
corepack enable pnpm && \
corepack install --global pnpm@11.10.0
COPY pnpm-lock.yaml pnpm-workspace.yaml package.json turbo.json ./
COPY patches ./patches
RUN --mount=type=cache,id=workshop-pnpm-store,target=/pnpm/store \
pnpm config set store-dir /pnpm/store && \
pnpm --config.node-linker=isolated fetch --ignore-scripts && \
rm -rf node_modules
COPY apps/docs ./apps/docs
COPY packages/definitions ./packages/definitions
COPY packages/validation ./packages/validation
COPY packages/workshop ./packages/workshop
COPY tooling/typescript ./tooling/typescript
RUN --mount=type=cache,id=workshop-pnpm-store,target=/pnpm/store \
pnpm install --recursive --offline --frozen-lockfile
FROM dependencies AS website
RUN CI=true SKIP_ENV_VALIDATION=true pnpm turbo build --filter=@homarr/docs
FROM alpine:3.22 AS pocketbase
ARG TARGETARCH
ARG POCKETBASE_VERSION=0.39.6
RUN apk add --no-cache ca-certificates curl unzip
RUN case "${TARGETARCH}" in \
amd64) PB_ARCH=amd64 ;; \
arm64) PB_ARCH=arm64 ;; \
arm) PB_ARCH=armv7 ;; \
*) echo "Unsupported architecture: ${TARGETARCH}" >&2; exit 1 ;; \
esac && \
BASE_URL="https://github.com/pocketbase/pocketbase/releases/download/v${POCKETBASE_VERSION}" && \
ARCHIVE="pocketbase_${POCKETBASE_VERSION}_linux_${PB_ARCH}.zip" && \
curl --fail --location --silent --show-error "${BASE_URL}/${ARCHIVE}" --output "/tmp/${ARCHIVE}" && \
curl --fail --location --silent --show-error "${BASE_URL}/checksums.txt" --output /tmp/checksums.txt && \
cd /tmp && grep " ${ARCHIVE}$" checksums.txt | sha256sum -c - && \
unzip "/tmp/${ARCHIVE}" pocketbase -d /out
FROM alpine:3.22 AS development
RUN apk add --no-cache ca-certificates tzdata
COPY --from=pocketbase /out/pocketbase /usr/local/bin/pocketbase
COPY apps/workshop/entrypoint.dev.sh /usr/local/bin/workshop-dev-entrypoint
COPY apps/workshop/dev-public /pb_public
RUN chmod +x /usr/local/bin/workshop-dev-entrypoint && \
mkdir -p /pb_data /pb_hooks /pb_migrations
EXPOSE 8090
HEALTHCHECK --interval=10s --timeout=5s --start-period=15s --retries=6 CMD wget -q --spider http://127.0.0.1:8090/api/health || exit 1
ENTRYPOINT ["workshop-dev-entrypoint"]
FROM alpine:3.22 AS production
RUN apk add --no-cache ca-certificates tzdata && addgroup -S pocketbase && adduser -S -G pocketbase pocketbase
COPY --from=pocketbase /out/pocketbase /usr/local/bin/pocketbase
COPY --from=website /app/apps/docs/build /pb_public
COPY apps/workshop/entrypoint.sh /usr/local/bin/workshop-entrypoint
COPY apps/workshop/pb_hooks /pb_hooks
COPY apps/workshop/pb_migrations /pb_migrations
RUN chmod +x /usr/local/bin/workshop-entrypoint && \
mkdir -p /pb_data && \
chown -R pocketbase:pocketbase /pb_data /pb_hooks /pb_migrations /pb_public
USER pocketbase
EXPOSE 8090
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 CMD wget -q --spider http://127.0.0.1:8090/api/health || exit 1
ENTRYPOINT ["workshop-entrypoint"]
-21
View File
@@ -1,21 +0,0 @@
**
!package.json
!pnpm-lock.yaml
!pnpm-workspace.yaml
!turbo.json
!patches/**
!apps/docs/**
!apps/workshop/entrypoint.sh
!apps/workshop/entrypoint.dev.sh
!apps/workshop/dev-public/**
!apps/workshop/pb_hooks/**
!apps/workshop/pb_migrations/**
!packages/definitions/**
!packages/validation/**
!packages/workshop/**
!tooling/typescript/**
**/node_modules
**/.docusaurus
**/build
**/dist
**/.turbo
-74
View File
@@ -1,74 +0,0 @@
# Homarr Workshop service
Workshop combines the public Docusaurus experience with a PocketBase community API. Development keeps the two processes separate; the production image serves both from one origin.
## Development
From the repository root, run:
```sh
pnpm dev:workshop
```
This starts:
- PocketBase in Docker with `--dev` at `http://localhost:8090`;
- the PocketBase dashboard at `http://localhost:8090/_/`;
- `@homarr/workshop` in watch mode on the host;
- Docusaurus with HMR at `http://localhost:3003/workshop`.
PocketBase data is bind-mounted at `apps/workshop/pb_data/`. The directory is gitignored and survives container rebuilds. Hooks and migrations are mounted read-only and PocketBase reloads them in development mode.
No users, superusers, OAuth providers, or submissions are seeded. Create the first superuser manually in the PocketBase dashboard, configure GitHub OAuth on the `users` collection, and use `http://localhost:8090/api/oauth2-redirect` as the local callback URL. OAuth account creation is handled by PocketBase directly. Promote moderators and administrators by creating a `workshop_staff` record for the user; that record is the authorization source of truth.
Optional ports and CORS behavior are configured only in the repository-root `.env`:
```dotenv
PB_EXPOSE_PORT=8090
WORKSHOP_WEB_PORT=3003
PB_ALLOWED_ORIGINS=*
```
## Production-like preview
Run the exact combined image shape used for deployment:
```sh
pnpm preview:workshop
```
The multi-stage image builds Docusaurus, verifies and installs PocketBase `0.39.6`, copies the hooks and migrations, and serves everything from `http://localhost:8090`:
- website: `http://localhost:8090/workshop`;
- API: `http://localhost:8090/api`;
- dashboard: `http://localhost:8090/_/`.
The preview reuses `apps/workshop/pb_data/`, so OAuth configuration and local records remain available when switching between development and preview. `--remove-orphans` replaces the previous service shape without deleting data.
## Reset local data
Resetting is always explicit:
```sh
pnpm reset:workshop
```
The command asks for confirmation, stops both service variants, and clears `apps/workshop/pb_data/`. For automation, use `pnpm reset:workshop -- --force`.
Authentication failures for an old email are requests made by the PocketBase dashboard against the persisted database; Workshop startup never attempts to authenticate or seed that account.
## Build and validation
`pnpm build` builds both `@homarr/workshop` and Docusaurus as part of the monorepo build. The production image can be validated locally with:
```sh
pnpm test:workshop-preview
```
The test uses a temporary database, builds and starts the production image, checks the website, API, service worker, and migrated collections, then removes all test state. The dedicated Workshop CI workflow runs this test only when Workshop-related sources or build inputs change; it does not publish an image.
## Operations
The live service uses the production target behind the `homarr.dev` TLS proxy. PocketBase superuser access is bootstrap/emergency access only; normal moderation belongs in `/workshop/admin`.
Back up `/pb_data` before PocketBase or schema upgrades. It contains records, uploads, OAuth settings, and credentials. Production must also restrict `/_/`, monitor health and authentication failures, and validate backup restoration regularly.
-10
View File
@@ -1,10 +0,0 @@
self.addEventListener("install", () => self.skipWaiting());
self.addEventListener("activate", (event) => {
event.waitUntil(
Promise.all([
caches.keys().then((keys) => Promise.all(keys.map((key) => caches.delete(key)))),
self.registration.unregister(),
]),
);
});
-19
View File
@@ -1,19 +0,0 @@
name: homarr-workshop
services:
workshop:
build:
context: ../..
dockerfile: apps/workshop/Dockerfile
target: production
args:
POCKETBASE_VERSION: 0.39.6
restart: unless-stopped
environment:
PB_ALLOWED_ORIGINS: ${PB_ALLOWED_ORIGINS:-*}
ports:
- "127.0.0.1:${PB_EXPOSE_PORT:-8090}:8090"
volumes:
- ${WORKSHOP_DATA_DIR:-./pb_data}:/pb_data
security_opt:
- no-new-privileges:true
-21
View File
@@ -1,21 +0,0 @@
name: homarr-workshop
services:
workshop:
build:
context: ../..
dockerfile: apps/workshop/Dockerfile
target: development
args:
POCKETBASE_VERSION: 0.39.6
restart: unless-stopped
environment:
PB_ALLOWED_ORIGINS: ${PB_ALLOWED_ORIGINS:-*}
ports:
- "127.0.0.1:${PB_EXPOSE_PORT:-8090}:8090"
volumes:
- ${WORKSHOP_DATA_DIR:-./pb_data}:/pb_data
- ./pb_hooks:/pb_hooks:ro
- ./pb_migrations:/pb_migrations:ro
security_opt:
- no-new-privileges:true
-16
View File
@@ -1,16 +0,0 @@
#!/bin/sh
set -eu
echo "Workshop development API: http://localhost:8090/api"
echo "PocketBase dashboard: http://localhost:8090/_/"
exec pocketbase serve \
--dev \
--automigrate=false \
--http=0.0.0.0:8090 \
--origins="${PB_ALLOWED_ORIGINS:-*}" \
--dir=/pb_data \
--hooksDir=/pb_hooks \
--migrationsDir=/pb_migrations \
--publicDir=/pb_public \
--indexFallback=true
-14
View File
@@ -1,14 +0,0 @@
#!/bin/sh
set -eu
echo "Workshop is listening on port 8090"
echo "Workshop API is available at /api"
exec pocketbase serve \
--http=0.0.0.0:8090 \
--origins="${PB_ALLOWED_ORIGINS:-*}" \
--dir=/pb_data \
--hooksDir=/pb_hooks \
--migrationsDir=/pb_migrations \
--publicDir=/pb_public \
--indexFallback=true
-135
View File
@@ -1,135 +0,0 @@
const WIDGET_SCHEMA = "homarr-custom-widget-v2";
const CSS_SCHEMA = "homarr-custom-css-v1";
const CSS_LIMIT = 16384;
const WIDGET_AUTH_TYPES = ["none", "bearer", "basic", "apiKeyHeader", "apiKeyQuery"];
const WIDGET_METHODS = ["GET", "POST", "PUT", "DELETE", "PATCH"];
const WIDGET_DISPLAY_TYPES = [
"singleValue",
"keyValue",
"table",
"statGrid",
"progressBars",
"statusIndicator",
"countGrid",
"raw",
"actionButton",
"customJsx",
];
function fail(ErrorType, _code, message) {
throw new ErrorType(message);
}
function requireWritableAccount(app, auth) {
if (!auth) fail(UnauthorizedError, "authentication_required", "Authentication is required");
const account = app.findRecordById("users", auth.id);
if (account.getString("state") === "disabled") fail(ForbiddenError, "account_disabled", "Account is disabled");
return account;
}
function validateSubmission(record) {
const type = record.getString("type");
const content = record.getString("content");
if (type === "css") {
if (!content.trim()) fail(BadRequestError, "invalid_submission", "CSS cannot be empty");
if (content.length > CSS_LIMIT) fail(BadRequestError, "invalid_submission", "CSS is too large");
record.set("schemaVersion", CSS_SCHEMA);
return;
}
if (type !== "widget") fail(BadRequestError, "invalid_submission", "Unsupported submission type");
let widget;
try {
widget = JSON.parse(content);
} catch {
fail(BadRequestError, "invalid_submission", "Widget content is not valid JSON");
}
if (
!widget ||
widget.$schema !== WIDGET_SCHEMA ||
typeof widget.name !== "string" ||
widget.name.length < 1 ||
widget.name.length > 128 ||
typeof widget.url !== "string" ||
widget.url.length < 1 ||
!WIDGET_AUTH_TYPES.includes(widget.authType) ||
!WIDGET_METHODS.includes(widget.method) ||
!WIDGET_DISPLAY_TYPES.includes(widget.displayType) ||
typeof widget.displayConfig !== "object" ||
Array.isArray(widget.displayConfig) ||
Object.prototype.hasOwnProperty.call(widget, "secrets")
) {
fail(BadRequestError, "invalid_submission", "Widget does not match homarr-custom-widget-v2");
}
if (!widget.displayConfig || widget.displayConfig.type !== widget.displayType) {
fail(BadRequestError, "invalid_submission", "Widget displayConfig.type must match displayType");
}
record.set("schemaVersion", WIDGET_SCHEMA);
}
function snapshot(record) {
const exported = record.publicExport();
if (typeof exported.content === "string") {
exported.contentLength = exported.content.length;
delete exported.content;
}
return JSON.stringify(exported);
}
function requireStaff(app, auth) {
requireWritableAccount(app, auth);
let staff;
try {
staff = app.findFirstRecordByFilter("workshop_staff", "user = {:user}", { user: auth.id });
} catch {
fail(ForbiddenError, "forbidden", "Staff access is required");
}
return staff.getString("role");
}
function setStaffRole(app, user, role) {
let staff = null;
try {
staff = app.findFirstRecordByFilter("workshop_staff", "user = {:user}", { user: user.id });
} catch {
// Members do not have a staff record.
}
if (role === "member") {
if (staff) app.delete(staff);
return;
}
if (!staff) staff = new Record(app.findCollectionByNameOrId("workshop_staff"));
staff.set("user", user.id);
staff.set("role", role);
app.save(staff);
}
function body(e) {
const data = new DynamicModel({ reason: "", state: "", role: "", status: "" });
e.bindBody(data);
if (!data.reason || data.reason.trim().length < 3)
fail(BadRequestError, "invalid_submission", "A reason is required");
return data;
}
function audit(app, actor, action, targetType, targetId, reason, targetSnapshot) {
const collection = app.findCollectionByNameOrId("moderation_actions");
const record = new Record(collection);
record.set("actor", actor.id);
record.set("action", action);
record.set("targetType", targetType);
record.set("targetId", targetId);
record.set("reason", reason.trim());
record.set("snapshot", targetSnapshot || "");
app.save(record);
}
module.exports = {
audit,
body,
fail,
requireStaff,
requireWritableAccount,
setStaffRole,
snapshot,
validateSubmission,
};
-141
View File
@@ -1,141 +0,0 @@
/// <reference path="../pb_data/types.d.ts" />
onRecordCreateRequest((e) => {
const utils = require(`${__hooks}/workshop-utils.js`);
const account = utils.requireWritableAccount(e.app, e.auth, true);
utils.validateSubmission(e.record);
e.record.set("author", e.auth.id);
e.record.set("authorName", account.getString("displayName"));
e.record.set("revision", 1);
e.next();
}, "submissions");
onRecordUpdateRequest((e) => {
const utils = require(`${__hooks}/workshop-utils.js`);
utils.requireWritableAccount(e.app, e.auth, true);
const original = e.app.findRecordById("submissions", e.record.id);
e.record.set("author", original.get("author"));
e.record.set("authorName", original.get("authorName"));
e.record.set("type", original.get("type"));
e.record.set("schemaVersion", original.get("schemaVersion"));
e.record.set(
"revision",
original.getInt("revision") + (e.record.getString("content") !== original.getString("content") ? 1 : 0),
);
utils.validateSubmission(e.record);
e.next();
}, "submissions");
onRecordCreateRequest((e) => {
const utils = require(`${__hooks}/workshop-utils.js`);
utils.requireWritableAccount(e.app, e.auth, false);
const value = e.record.getInt("value");
if (value !== 1 && value !== -1) throw new BadRequestError("Vote must be 1 or -1");
e.record.set("user", e.auth.id);
e.next();
}, "votes");
onRecordUpdateRequest((e) => {
const utils = require(`${__hooks}/workshop-utils.js`);
utils.requireWritableAccount(e.app, e.auth, false);
const original = e.app.findRecordById("votes", e.record.id);
const value = e.record.getInt("value");
if (value !== 1 && value !== -1) throw new BadRequestError("Vote must be 1 or -1");
e.record.set("user", original.get("user"));
e.record.set("submission", original.get("submission"));
e.next();
}, "votes");
onRecordCreateRequest((e) => {
const utils = require(`${__hooks}/workshop-utils.js`);
utils.requireWritableAccount(e.app, e.auth, false);
e.record.set("reporter", e.auth.id);
e.record.set("status", "open");
e.next();
}, "reports");
routerAdd(
"POST",
"/api/workshop/moderation/submissions/{id}/remove",
(e) => {
const utils = require(`${__hooks}/workshop-utils.js`);
utils.requireStaff(e.app, e.auth);
const data = utils.body(e);
const id = e.request.pathValue("id");
e.app.runInTransaction((tx) => {
const target = tx.findRecordById("submissions", id);
utils.audit(tx, e.auth, "remove_submission", "submission", id, data.reason, utils.snapshot(target));
tx.delete(target);
});
return e.json(200, { success: true });
},
$apis.requireAuth("users"),
);
routerAdd(
"POST",
"/api/workshop/moderation/users/{id}/state",
(e) => {
const utils = require(`${__hooks}/workshop-utils.js`);
const actorRole = utils.requireStaff(e.app, e.auth);
const data = utils.body(e);
if (!["active", "disabled"].includes(data.state)) throw new BadRequestError("Invalid account state");
const id = e.request.pathValue("id");
if (id === e.auth.id) throw new BadRequestError("You cannot moderate your own account");
e.app.runInTransaction((tx) => {
const target = tx.findRecordById("users", id);
if (target.getString("role") === "admin" && actorRole !== "admin") throw new ForbiddenError("forbidden");
const previous = utils.snapshot(target);
target.set("state", data.state);
target.set("moderationReason", data.reason);
tx.save(target);
utils.audit(tx, e.auth, "set_account_state", "user", id, data.reason, previous);
});
return e.json(200, { success: true });
},
$apis.requireAuth("users"),
);
routerAdd(
"POST",
"/api/workshop/moderation/users/{id}/role",
(e) => {
const utils = require(`${__hooks}/workshop-utils.js`);
if (utils.requireStaff(e.app, e.auth) !== "admin") throw new ForbiddenError("forbidden");
const data = utils.body(e);
if (!["member", "moderator", "admin"].includes(data.role)) throw new BadRequestError("Invalid role");
const id = e.request.pathValue("id");
if (id === e.auth.id) throw new BadRequestError("You cannot change your own role");
e.app.runInTransaction((tx) => {
const target = tx.findRecordById("users", id);
const previous = utils.snapshot(target);
target.set("role", data.role);
tx.save(target);
utils.setStaffRole(tx, target, data.role);
utils.audit(tx, e.auth, "set_role", "user", id, data.reason, previous);
});
return e.json(200, { success: true });
},
$apis.requireAuth("users"),
);
routerAdd(
"POST",
"/api/workshop/moderation/reports/{id}/resolve",
(e) => {
const utils = require(`${__hooks}/workshop-utils.js`);
utils.requireStaff(e.app, e.auth);
const data = utils.body(e);
if (!["resolved", "dismissed"].includes(data.status)) throw new BadRequestError("Invalid report status");
const id = e.request.pathValue("id");
e.app.runInTransaction((tx) => {
const target = tx.findRecordById("reports", id);
const previous = utils.snapshot(target);
target.set("status", data.status);
tx.save(target);
utils.audit(tx, e.auth, "resolve_report", "report", id, data.reason, previous);
});
return e.json(200, { success: true });
},
$apis.requireAuth("users"),
);
@@ -1,286 +0,0 @@
/// <reference path="../pb_data/types.d.ts" />
migrate(
(app) => {
let users;
try {
users = app.findCollectionByNameOrId("users");
} catch {
users = new Collection({
type: "auth",
name: "users",
});
}
users.listRule = "@request.auth.role = 'moderator' || @request.auth.role = 'admin'";
users.viewRule = "id = @request.auth.id || @request.auth.role = 'moderator' || @request.auth.role = 'admin'";
users.updateRule =
"id = @request.auth.id && @request.auth.state != 'disabled' && @request.body.role:changed = false && @request.body.state:changed = false && @request.body.moderationReason:changed = false";
users.deleteRule = null;
users.passwordAuth = { enabled: false };
// PocketBase creates a default users auth collection on first boot, so
// Workshop fields must be added to the existing collection explicitly.
users.fields.add(
new TextField({ name: "displayName", required: true, min: 1, max: 100 }),
new URLField({ name: "avatarUrl" }),
new SelectField({ name: "role", required: true, maxSelect: 1, values: ["member", "moderator", "admin"] }),
new SelectField({
name: "state",
required: true,
maxSelect: 1,
values: ["active", "disabled"],
}),
new TextField({ name: "moderationReason", max: 1000 }),
);
app.save(users);
const workshopStaff = new Collection({
type: "base",
name: "workshop_staff",
listRule: "user = @request.auth.id",
viewRule: "user = @request.auth.id",
createRule: null,
updateRule: null,
deleteRule: null,
fields: [
{ type: "relation", name: "user", required: true, maxSelect: 1, collectionId: users.id, cascadeDelete: true },
{ type: "select", name: "role", required: true, maxSelect: 1, values: ["moderator", "admin"] },
{ type: "autodate", name: "created", onCreate: true },
{ type: "autodate", name: "updated", onCreate: true, onUpdate: true },
],
});
workshopStaff.addIndex("idx_workshop_staff_user", true, "user", "");
app.save(workshopStaff);
const staffRule = "@collection.workshop_staff.user ?= @request.auth.id";
users.listRule = staffRule;
users.viewRule = `id = @request.auth.id || ${staffRule}`;
app.save(users);
const writableRule = "@request.auth.id != '' && @request.auth.state != 'disabled'";
const authorRule = "author = @request.auth.id && @request.auth.state = 'active'";
const submissions = new Collection({
type: "base",
name: "submissions",
// Full source is intentionally available only through getOne; discovery
// must use workshop_listings so list payloads never contain content.
listRule: null,
viewRule: "",
createRule: null,
updateRule: null,
deleteRule: null,
fields: [
{ type: "select", name: "type", required: true, maxSelect: 1, values: ["widget", "css"] },
{ type: "text", name: "title", required: true, min: 3, max: 100 },
{ type: "text", name: "description", max: 2000 },
{ type: "text", name: "schemaVersion", required: true, max: 100 },
{ type: "text", name: "content", required: true, max: 1000000 },
{
type: "file",
name: "screenshots",
maxSelect: 5,
maxSize: 5242880,
mimeTypes: ["image/png", "image/jpeg", "image/webp"],
thumbs: ["480x320", "960x640"],
},
{
type: "relation",
name: "author",
required: true,
maxSelect: 1,
collectionId: users.id,
cascadeDelete: true,
},
{ type: "text", name: "authorName", required: true, max: 100 },
{ type: "number", name: "revision", required: true, onlyInt: true, min: 1 },
{ type: "text", name: "changelog", max: 2000 },
{ type: "autodate", name: "created", onCreate: true },
{ type: "autodate", name: "updated", onCreate: true, onUpdate: true },
],
});
app.save(submissions);
submissions.createRule = "@request.auth.id != ''";
submissions.updateRule = authorRule;
submissions.deleteRule = authorRule;
submissions.addIndex("idx_submissions_author", false, "author", "");
submissions.addIndex("idx_submissions_type_created", false, "type, created", "");
app.save(submissions);
const votes = new Collection({
type: "base",
name: "votes",
listRule: null,
viewRule: null,
createRule: null,
updateRule: null,
deleteRule: null,
fields: [
{
type: "relation",
name: "submission",
required: true,
maxSelect: 1,
collectionId: submissions.id,
cascadeDelete: true,
},
{ type: "relation", name: "user", required: true, maxSelect: 1, collectionId: users.id, cascadeDelete: true },
{ type: "number", name: "value", required: true, onlyInt: true, min: -1, max: 1 },
{ type: "autodate", name: "created", onCreate: true },
{ type: "autodate", name: "updated", onCreate: true, onUpdate: true },
],
});
app.save(votes);
votes.listRule = "user = @request.auth.id";
votes.viewRule = "user = @request.auth.id";
votes.createRule = writableRule;
votes.updateRule = "user = @request.auth.id && @request.auth.state != 'disabled'";
votes.deleteRule = "user = @request.auth.id && @request.auth.state != 'disabled'";
votes.addIndex("idx_votes_user_submission", true, "user, submission", "");
votes.addIndex("idx_votes_submission", false, "submission", "");
app.save(votes);
const reports = new Collection({
type: "base",
name: "reports",
listRule: null,
viewRule: null,
createRule: null,
updateRule: null,
deleteRule: null,
fields: [
{
type: "relation",
name: "submission",
required: true,
maxSelect: 1,
collectionId: submissions.id,
cascadeDelete: true,
},
{
type: "relation",
name: "reporter",
required: true,
maxSelect: 1,
collectionId: users.id,
cascadeDelete: true,
},
{
type: "select",
name: "category",
required: true,
maxSelect: 1,
values: ["malicious", "spam", "copyright", "inappropriate", "other"],
},
{ type: "text", name: "explanation", required: true, min: 3, max: 1000 },
{ type: "select", name: "status", required: true, maxSelect: 1, values: ["open", "resolved", "dismissed"] },
{ type: "autodate", name: "created", onCreate: true },
{ type: "autodate", name: "updated", onCreate: true, onUpdate: true },
],
});
app.save(reports);
reports.listRule = staffRule;
reports.viewRule = staffRule;
reports.createRule = writableRule;
reports.addIndex("idx_reports_reporter_submission", true, "reporter, submission", "");
reports.addIndex("idx_reports_status_created", false, "status, created", "");
app.save(reports);
const moderationActions = new Collection({
type: "base",
name: "moderation_actions",
listRule: staffRule,
viewRule: staffRule,
createRule: null,
updateRule: null,
deleteRule: null,
fields: [
{ type: "relation", name: "actor", required: true, maxSelect: 1, collectionId: users.id },
{
type: "select",
name: "action",
required: true,
maxSelect: 1,
values: ["remove_submission", "set_account_state", "set_role", "resolve_report"],
},
{ type: "text", name: "targetType", required: true, max: 30 },
{ type: "text", name: "targetId", required: true, max: 30 },
{ type: "text", name: "reason", required: true, min: 3, max: 1000 },
{ type: "text", name: "snapshot", max: 100000 },
{ type: "autodate", name: "created", onCreate: true },
],
});
app.save(moderationActions);
moderationActions.addIndex("idx_moderation_actions_created", false, "created", "");
app.save(moderationActions);
const listings = new Collection({
type: "view",
name: "workshop_listings",
listRule: "",
viewRule: "",
viewQuery: `
SELECT s.id, s.type, s.title, s.description, s.schemaVersion, s.screenshots,
s.revision, s.changelog, s.author, s.authorName, s.created, s.updated,
COALESCE(SUM(CASE WHEN v.value = 1 THEN 1 ELSE 0 END), 0) AS upvotes,
COALESCE(SUM(CASE WHEN v.value = -1 THEN 1 ELSE 0 END), 0) AS downvotes,
COALESCE(SUM(v.value), 0) AS score
FROM submissions s
LEFT JOIN votes v ON v.submission = s.id
GROUP BY s.id
`,
});
app.save(listings);
const settings = app.settings();
const workshopRateLimitLabels = [
"submissions:create",
"votes:create",
"reports:create",
"/api/workshop/moderation/",
];
const workshopRateLimits = [
{ label: "submissions:create", audience: "@auth", duration: 60, maxRequests: 10 },
{ label: "votes:create", audience: "@auth", duration: 10, maxRequests: 20 },
{ label: "reports:create", audience: "@auth", duration: 60, maxRequests: 5 },
{ label: "/api/workshop/moderation/", audience: "@auth", duration: 60, maxRequests: 30 },
];
settings.rateLimits.enabled = true;
settings.rateLimits.rules = [
...settings.rateLimits.rules.filter((rule) => !workshopRateLimitLabels.includes(rule.label)),
...workshopRateLimits,
];
settings.logs.maxDays = 30;
settings.logs.logAuthId = true;
settings.logs.logIP = true;
app.save(settings);
},
(app) => {
// PocketBase creates the users auth collection before this migration. Keep
// it intact on rollback so reverting Workshop can never delete accounts.
for (const name of [
"workshop_listings",
"moderation_actions",
"reports",
"votes",
"submissions",
"workshop_staff",
]) {
try {
app.delete(app.findCollectionByNameOrId(name));
} catch {
// Collection was not created or was already removed.
}
}
const settings = app.settings();
const workshopRateLimitLabels = [
"submissions:create",
"votes:create",
"reports:create",
"/api/workshop/moderation/",
];
settings.rateLimits.rules = settings.rateLimits.rules.filter(
(rule) => !workshopRateLimitLabels.includes(rule.label),
);
app.save(settings);
},
);
@@ -1,73 +0,0 @@
/// <reference path="../pb_data/types.d.ts" />
migrate(
(app) => {
const users = app.findCollectionByNameOrId("users");
const state = users.fields.getByName("state");
if (state) state.values = ["active", "disabled"];
let staff;
try {
staff = app.findCollectionByNameOrId("workshop_staff");
} catch {
staff = new Collection({
type: "base",
name: "workshop_staff",
listRule: "user = @request.auth.id",
viewRule: "user = @request.auth.id",
createRule: null,
updateRule: null,
deleteRule: null,
fields: [
{
type: "relation",
name: "user",
required: true,
maxSelect: 1,
collectionId: users.id,
cascadeDelete: true,
},
{ type: "select", name: "role", required: true, maxSelect: 1, values: ["moderator", "admin"] },
{ type: "autodate", name: "created", onCreate: true },
{ type: "autodate", name: "updated", onCreate: true, onUpdate: true },
],
});
staff.addIndex("idx_workshop_staff_user", true, "user", "");
app.save(staff);
}
for (const user of app.findRecordsByFilter("users", "role = 'moderator' || role = 'admin'", "", 0, 0)) {
if (!user) continue;
let record;
try {
record = app.findFirstRecordByFilter("workshop_staff", "user = {:user}", { user: user.id });
} catch {
record = new Record(staff);
record.set("user", user.id);
}
record.set("role", user.getString("role"));
app.save(record);
}
const staffRule = "@collection.workshop_staff.user ?= @request.auth.id";
users.listRule = staffRule;
users.viewRule = `id = @request.auth.id || ${staffRule}`;
users.updateRule =
"id = @request.auth.id && @request.auth.state != 'disabled' && @request.body.role:changed = false && @request.body.state:changed = false && @request.body.moderationReason:changed = false";
app.save(users);
},
(app) => {
const users = app.findCollectionByNameOrId("users");
users.listRule = "@request.auth.role = 'moderator' || @request.auth.role = 'admin'";
users.viewRule = "id = @request.auth.id || @request.auth.role = 'moderator' || @request.auth.role = 'admin'";
users.updateRule = "id = @request.auth.id && @request.auth.state != 'disabled'";
const state = users.fields.getByName("state");
if (state) state.values = ["active", "posting_banned", "disabled"];
app.save(users);
try {
app.delete(app.findCollectionByNameOrId("workshop_staff"));
} catch {
// Already removed.
}
},
);
-20
View File
@@ -1,20 +0,0 @@
#!/bin/sh
set -eu
data_dir="${WORKSHOP_DATA_DIR:-apps/workshop/pb_data}"
if [ "${1:-}" != "--force" ]; then
printf "Delete the local Workshop database at %s? [y/N] " "$data_dir"
read -r answer
case "$answer" in
y|Y|yes|YES) ;;
*) echo "Reset cancelled"; exit 0 ;;
esac
fi
docker compose --env-file .env --file apps/workshop/docker-compose.yml down --remove-orphans >/dev/null 2>&1 || true
docker compose --env-file .env --file apps/workshop/docker-compose.preview.yml down --remove-orphans >/dev/null 2>&1 || true
mkdir -p "$data_dir"
docker run --rm --volume "$(cd "$data_dir" && pwd):/data" alpine:3.22 sh -c 'rm -rf /data/.[!.]* /data/..?* /data/*'
echo "Workshop database reset"
-53
View File
@@ -1,53 +0,0 @@
#!/bin/sh
set -eu
project="homarr-workshop-integration"
port="${WORKSHOP_TEST_PORT:-18091}"
email="integration@homarr.test"
password="${WORKSHOP_TEST_PASSWORD:-WorkshopIntegrationPassword123!}"
data_dir="$(mktemp -d)"
cleanup() {
PB_EXPOSE_PORT="$port" WORKSHOP_DATA_DIR="$data_dir" docker compose --env-file .env.example --file apps/workshop/docker-compose.yml --project-name "$project" down --remove-orphans >/dev/null 2>&1 || true
docker run --rm --volume "$data_dir:/data" alpine:3.22 sh -c 'rm -rf /data/.[!.]* /data/..?* /data/*' >/dev/null 2>&1 || true
rmdir "$data_dir" >/dev/null 2>&1 || true
}
trap cleanup EXIT INT TERM
PB_EXPOSE_PORT="$port" WORKSHOP_DATA_DIR="$data_dir" docker compose --env-file .env.example --file apps/workshop/docker-compose.yml --project-name "$project" down --remove-orphans >/dev/null 2>&1 || true
PB_EXPOSE_PORT="$port" WORKSHOP_DATA_DIR="$data_dir" docker compose \
--env-file .env.example \
--file apps/workshop/docker-compose.yml \
--project-name "$project" \
up --detach --build --wait
WORKSHOP_DATA_DIR="$data_dir" docker compose \
--env-file .env.example \
--file apps/workshop/docker-compose.yml \
--project-name "$project" \
exec --no-TTY workshop pocketbase superuser upsert "$email" "$password" --dir=/pb_data
if ! WORKSHOP_TEST_URL="http://127.0.0.1:$port" \
WORKSHOP_TEST_SUPERUSER_EMAIL="$email" \
WORKSHOP_TEST_SUPERUSER_PASSWORD="$password" \
node apps/workshop/tests/workshop.integration.mjs; then
PB_EXPOSE_PORT="$port" WORKSHOP_DATA_DIR="$data_dir" docker compose --env-file .env.example --file apps/workshop/docker-compose.yml --project-name "$project" logs --no-color workshop
exit 1
fi
PB_EXPOSE_PORT="$port" WORKSHOP_DATA_DIR="$data_dir" docker compose \
--env-file .env.example \
--file apps/workshop/docker-compose.yml \
--project-name "$project" \
restart workshop >/dev/null
PB_EXPOSE_PORT="$port" WORKSHOP_DATA_DIR="$data_dir" docker compose \
--env-file .env.example \
--file apps/workshop/docker-compose.yml \
--project-name "$project" \
up --detach --wait >/dev/null
WORKSHOP_TEST_URL="http://127.0.0.1:$port" \
WORKSHOP_TEST_SUPERUSER_EMAIL="$email" \
WORKSHOP_TEST_SUPERUSER_PASSWORD="$password" \
WORKSHOP_VERIFY_CONFIGURATION_ONLY=true \
node apps/workshop/tests/workshop.integration.mjs
-51
View File
@@ -1,51 +0,0 @@
#!/bin/sh
set -eu
project="homarr-workshop-preview-test"
port="${WORKSHOP_PREVIEW_TEST_PORT:-18092}"
email="preview@homarr.test"
password="${WORKSHOP_PREVIEW_TEST_PASSWORD:-WorkshopPreviewPassword123!}"
data_dir="$(mktemp -d)"
base_url="http://127.0.0.1:$port"
cleanup() {
PB_EXPOSE_PORT="$port" WORKSHOP_DATA_DIR="$data_dir" docker compose --env-file .env.example --file apps/workshop/docker-compose.preview.yml --project-name "$project" down --remove-orphans >/dev/null 2>&1 || true
docker run --rm --volume "$data_dir:/data" alpine:3.22 sh -c 'rm -rf /data/.[!.]* /data/..?* /data/*' >/dev/null 2>&1 || true
rmdir "$data_dir" >/dev/null 2>&1 || true
}
trap cleanup EXIT INT TERM
PB_EXPOSE_PORT="$port" WORKSHOP_DATA_DIR="$data_dir" docker compose --env-file .env.example --file apps/workshop/docker-compose.preview.yml --project-name "$project" down --remove-orphans >/dev/null 2>&1 || true
PB_EXPOSE_PORT="$port" WORKSHOP_DATA_DIR="$data_dir" docker compose \
--env-file .env.example \
--file apps/workshop/docker-compose.preview.yml \
--project-name "$project" \
up --detach --build --wait
curl --fail --silent --show-error "$base_url/api/health" >/dev/null
curl --fail --silent --show-error --location "$base_url/workshop" >/dev/null
curl --fail --silent --show-error "$base_url/workshop-sw.js" >/dev/null
PB_EXPOSE_PORT="$port" WORKSHOP_DATA_DIR="$data_dir" docker compose \
--env-file .env.example \
--file apps/workshop/docker-compose.preview.yml \
--project-name "$project" \
exec --no-TTY workshop pocketbase superuser upsert "$email" "$password" --dir=/pb_data >/dev/null
token="$(curl --fail --silent --show-error \
--header 'Content-Type: application/json' \
--data "{\"identity\":\"$email\",\"password\":\"$password\"}" \
"$base_url/api/collections/_superusers/auth-with-password" | jq --raw-output '.token')"
collections="$(curl --fail --silent --show-error \
--header "Authorization: $token" \
"$base_url/api/collections?perPage=200")"
for collection in users submissions votes reports moderation_actions workshop_listings; do
echo "$collections" | jq --exit-status --arg name "$collection" '.items | any(.name == $name)' >/dev/null || {
echo "Missing Workshop collection: $collection" >&2
exit 1
}
done
echo "Workshop production preview passed"
@@ -1,200 +0,0 @@
import assert from "node:assert/strict";
import PocketBase, { ClientResponseError } from "pocketbase";
const baseUrl = process.env.WORKSHOP_TEST_URL;
const superuserEmail = process.env.WORKSHOP_TEST_SUPERUSER_EMAIL;
const superuserPassword = process.env.WORKSHOP_TEST_SUPERUSER_PASSWORD;
const verifyConfigurationOnly = process.env.WORKSHOP_VERIFY_CONFIGURATION_ONLY === "true";
assert(baseUrl && superuserEmail && superuserPassword, "Workshop integration test environment is incomplete");
const root = new PocketBase(baseUrl);
await root.collection("_superusers").authWithPassword(superuserEmail, superuserPassword);
if (!verifyConfigurationOnly) {
await root.collections.update("users", {
oauth2: {
enabled: true,
providers: [{ name: "github", clientId: "integration-client", clientSecret: "integration-secret" }],
},
});
}
const authMethods = await root.collection("users").listAuthMethods();
assert.equal(authMethods.oauth2.enabled, true, "GitHub OAuth must retain its persisted PocketBase configuration");
assert.equal(authMethods.password.enabled, false, "password authentication must remain disabled");
assert.deepEqual(
authMethods.oauth2.providers.map((provider) => provider.name),
["github"],
);
if (verifyConfigurationOnly) {
console.log("Workshop PocketBase configuration persisted across restart");
process.exit(0);
}
const createUser = async (name, role = "member", state = "active") => {
const password = `Workshop${name}Password123!`;
return root.collection("users").create({
email: `${name.toLowerCase()}@homarr.test`,
password,
passwordConfirm: password,
verified: true,
displayName: name,
role,
state,
});
};
const expectStatus = async (operation, status) => {
const statuses = Array.isArray(status) ? status : [status];
await assert.rejects(operation, (error) => error instanceof ClientResponseError && statuses.includes(error.status));
};
const memberRecord = await createUser("Member");
const secondMemberRecord = await createUser("SecondMember");
const disabledRecord = await createUser("Disabled");
const moderatorRecord = await createUser("Moderator", "moderator");
const adminRecord = await createUser("Admin", "admin");
await root.collection("workshop_staff").create({ user: moderatorRecord.id, role: "moderator" });
await root.collection("workshop_staff").create({ user: adminRecord.id, role: "admin" });
const member = await root.collection("users").impersonate(memberRecord.id, 3600);
const secondMember = await root.collection("users").impersonate(secondMemberRecord.id, 3600);
const disabled = await root.collection("users").impersonate(disabledRecord.id, 3600);
const moderator = await root.collection("users").impersonate(moderatorRecord.id, 3600);
const admin = await root.collection("users").impersonate(adminRecord.id, 3600);
assert.equal((await member.collection("users").getList()).totalItems, 0, "members must not list Workshop accounts");
await expectStatus(
() =>
member.collection("users").update(memberRecord.id, {
role: "admin",
state: "disabled",
moderationReason: "self escalation",
}),
[403, 404],
);
await admin.send(`/api/workshop/moderation/users/${disabledRecord.id}/state`, {
method: "POST",
body: { state: "disabled", reason: "Integration disable" },
});
const submission = await member.collection("submissions").create({
type: "widget",
title: "Integration widget",
description: "A backend integration fixture",
schemaVersion: "forged-version",
content: JSON.stringify({
$schema: "homarr-custom-widget-v2",
name: "Integration widget",
url: "https://example.com/status",
authType: "none",
method: "GET",
displayType: "raw",
displayConfig: { type: "raw" },
integrationTestPadding: "x".repeat(150_000),
}),
author: secondMemberRecord.id,
authorName: "Forged",
revision: 99,
});
assert.equal(submission.author, memberRecord.id, "server must enforce ownership");
assert.equal(submission.authorName, "Member", "server must snapshot the authenticated author name");
assert.equal(submission.revision, 1, "server must own the initial revision");
assert.equal(submission.schemaVersion, "homarr-custom-widget-v2", "server must own schemaVersion");
await expectStatus(() => disabled.collection("votes").create({ submission: submission.id, value: 1 }), [400, 403]);
await expectStatus(
() =>
member.collection("submissions").create({
type: "widget",
title: "Invalid widget",
content: JSON.stringify({ $schema: "homarr-custom-widget-v2", name: "Missing contract" }),
}),
400,
);
const vote = await member.collection("votes").create({ submission: submission.id, value: 1 });
await expectStatus(() => member.collection("votes").create({ submission: submission.id, value: -1 }), 400);
assert.equal(
(await member.collection("votes").update(vote.id, { submission: "forged", user: "forged", value: -1 })).submission,
submission.id,
);
const report = await secondMember.collection("reports").create({
submission: submission.id,
reporter: memberRecord.id,
category: "spam",
explanation: "Integration report",
status: "dismissed",
});
assert.equal(report.reporter, secondMemberRecord.id);
assert.equal(report.status, "open");
assert.equal((await member.collection("reports").getList()).totalItems, 0, "reports must remain private from members");
assert.equal((await moderator.collection("reports").getList()).items.length, 1);
await expectStatus(
() =>
member.send(`/api/workshop/moderation/users/${secondMemberRecord.id}/state`, {
method: "POST",
body: { state: "disabled", reason: "No permission" },
}),
403,
);
await expectStatus(
() =>
moderator.send(`/api/workshop/moderation/users/${adminRecord.id}/state`, {
method: "POST",
body: { state: "disabled", reason: "Cannot alter admin" },
}),
403,
);
await expectStatus(
() =>
moderator.send(`/api/workshop/moderation/users/${secondMemberRecord.id}/role`, {
method: "POST",
body: { role: "moderator", reason: "Cannot promote" },
}),
403,
);
await admin.send(`/api/workshop/moderation/users/${secondMemberRecord.id}/role`, {
method: "POST",
body: { role: "moderator", reason: "Integration promotion" },
});
assert.equal((await root.collection("users").getOne(secondMemberRecord.id)).role, "moderator");
assert.equal(
(await root.collection("workshop_staff").getFirstListItem(`user = '${secondMemberRecord.id}'`)).role,
"moderator",
);
await moderator.send(`/api/workshop/moderation/reports/${report.id}/resolve`, {
method: "POST",
body: { status: "resolved", reason: "Integration resolution" },
});
assert.equal((await root.collection("reports").getOne(report.id)).status, "resolved");
await moderator.send(`/api/workshop/moderation/submissions/${submission.id}/remove`, {
method: "POST",
body: { reason: "Integration removal" },
});
await expectStatus(() => root.collection("submissions").getOne(submission.id), 404);
await expectStatus(() => root.collection("votes").getOne(vote.id), 404);
await expectStatus(() => root.collection("reports").getOne(report.id), 404);
const actions = await root.collection("moderation_actions").getFullList({ sort: "created" });
assert.deepEqual(
actions.map((action) => action.action),
["set_account_state", "set_role", "resolve_report", "remove_submission"],
);
assert(actions.at(-1)?.snapshot.includes("Integration widget"), "removal audit must retain a target snapshot");
assert(!actions.at(-1)?.snapshot.includes("integrationTestPadding"), "removal audit must omit large source content");
assert(actions.at(-1)?.snapshot.includes('"contentLength"'), "removal audit must retain the source length");
const anonymous = new PocketBase(baseUrl);
const listings = await anonymous.collection("workshop_listings").getList();
assert.equal(listings.totalItems, 0);
await expectStatus(() => anonymous.collection("submissions").getList(), 403);
console.log("Workshop backend integration matrix passed");
-83
View File
@@ -1,83 +0,0 @@
# 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`). The rewritten branch is rebased onto the current `origin/dev` before each final push.
| 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`.
- Deployment: the Workshop image is built locally from this repository by Compose or the Homarr deployment pipeline; no separate registry publication is maintained.
- Configuration: PocketBase superusers and GitHub OAuth are configured once in each persistent volume and are never rewritten from startup environment variables.
- Local development: PocketBase runs alone in Docker with a gitignored bind-mounted database; Docusaurus and contract watchers run on the host, and the production-like preview reuses the same data.
- 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 | Current `origin/dev` is an ancestor; the prototype remains 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 builds with checksum verification; clean dev boot, persistence, disposable integration tests, and the combined production preview 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, Workshop contracts, the combined PocketBase preview image, and Next.js build under Node 24.18.0; path-filtered CI smoke-tests the combined image. |
| Browser smoke | Pass | Desktop/mobile discovery, disconnected API recovery, and a first-visit online-to-offline reload render without uncaught errors. |
| 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.
+2 -2
View File
@@ -6,7 +6,7 @@
},
"engines": {
"node": ">=24.18.0",
"pnpm": ">=11.10.0"
"pnpm": ">=11.15.1"
},
"packageManager": "pnpm@11.10.0"
"packageManager": "pnpm@11.15.1"
}
+7 -11
View File
@@ -1,6 +1,6 @@
{
"name": "homarr",
"version": "1.70.0",
"version": "1.73.0",
"private": true,
"scripts": {
"build": "cross-env CI=true turbo build",
@@ -17,29 +17,26 @@
"db:migration:postgresql:run": "pnpm -F db migration:postgresql:run",
"db:migration:sqlite:generate": "pnpm -F db migration:sqlite:generate",
"db:migration:sqlite:run": "pnpm -F db migration:sqlite:run",
"db:seed": "pnpm -F db seed",
"db:push": "pnpm -F db push:sqlite",
"db:studio": "pnpm -F db studio",
"dev": "pnpm -F nextjs dev",
"dev:cli": "go -C tools/homarr-dev run .",
"dev:cli:install": "go -C tools/homarr-dev install .",
"dev:docs": "pnpm -F @homarr/docs dev",
"dev:workshop": "pnpm -F @homarr/workshop build && concurrently --kill-others-on-fail --names pocketbase,contracts,website \"pnpm dev:workshop:backend\" \"pnpm -F @homarr/workshop dev\" \"pnpm dev:workshop:website\"",
"dev:workshop:backend": "docker compose --env-file .env --file apps/workshop/docker-compose.yml up --build --remove-orphans",
"dev:workshop:website": "pnpm with-env sh -c 'WORKSHOP_API_URL=${WORKSHOP_API_URL:-http://localhost:${PB_EXPOSE_PORT:-8090}} pnpm -F @homarr/docs docusaurus start --no-open --host 0.0.0.0 --port ${WORKSHOP_WEB_PORT:-3003}'",
"docker:dev": "docker compose -f ./development/development.docker-compose.yml up",
"docker:dev:up": "docker compose -f ./development/development.docker-compose.yml up -d redis",
"format": "turbo format --continue -- --check",
"format:fix": "turbo format --continue",
"lint": "turbo lint --continue",
"lint:fix": "turbo lint --continue -- --fix",
"lint:ws": "pnpm dlx sherif@latest",
"release": "semantic-release",
"preview:workshop": "docker compose --env-file .env --file apps/workshop/docker-compose.preview.yml up --build --remove-orphans",
"reset:workshop": "sh apps/workshop/reset.sh",
"scripts:update-bug-report-template": "tsx ./scripts/update-bug-report-template.mts",
"scripts:update-readme-integrations": "tsx ./scripts/update-integration-list.mts",
"start": "concurrently \"pnpm with-env node apps/tasks/tasks.cjs\" \"pnpm with-env node apps/websocket/wssServer.cjs\" \"pnpm -F nextjs start\"",
"test": "cross-env NODE_ENV=development CI=true vitest run --exclude e2e --coverage.enabled ",
"test:e2e": "cross-env NODE_ENV=development CI=true vitest e2e",
"test:workshop-backend": "bash apps/workshop/tests/run-integration.sh",
"test:workshop-preview": "bash apps/workshop/tests/run-preview.sh",
"test:ui": "cross-env NODE_ENV=development CI=true vitest --exclude e2e --ui --coverage.enabled",
"typecheck": "turbo typecheck",
"with-env": "dotenv -e .env --"
@@ -60,7 +57,6 @@
"@vitejs/plugin-react": "catalog:",
"@vitest/coverage-v8": "catalog:",
"@vitest/ui": "catalog:",
"concurrently": "catalog:",
"conventional-changelog-conventionalcommits": "catalog:",
"cross-env": "catalog:",
"jsdom": "catalog:",
@@ -77,7 +73,7 @@
},
"engines": {
"node": ">=24.18.0",
"pnpm": ">=11.10.0"
"pnpm": ">=11.15.1"
},
"packageManager": "pnpm@11.10.0"
"packageManager": "pnpm@11.15.1"
}
+2 -1
View File
@@ -18,7 +18,8 @@ import { AppAccessControl } from "./app/app-access-control";
const defaultIcon = "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/homarr.svg";
export const appRouter = createTRPCRouter({
getPaginated: protectedProcedure
getPaginated: permissionRequiredProcedure
.requiresPermission("board-modify-all")
.input(paginatedSchema)
.output(z.object({ items: z.array(selectAppSchema), totalCount: z.number() }))
.meta({
Loaded 100 of 313 files, more files were not shown because too many files have changed in this diff. Show more