Compare commits

..
Author SHA1 Message Date
Releases Homarr bff3383ac9 chore(release): 1.74.0 [skip ci] 2026-08-07 17:41:07 +00:00
homarr-releases[bot] d865733838 chore(release): automatic release v1.74.0 2026-08-07 17:39:49 +00:00
homarr-crowdin[bot] 85fcea3837 chore(lang): updated translations from crowdin (#6500) 2026-08-07 18:22:43 +02:00
Thomas f76a0f240a feat(apps): allow custom URI schemes in application links (#6521) 2026-08-06 21:52:56 +02:00
Thomas 637f03e107 docs(settings): update analytics docs to PostHog (#6525) 2026-08-06 21:47:04 +02:00
Thomas 55214ccbc5 fix(integrations): throw on total item-info failure to preserve stale-cache fallback (#6522) 2026-08-06 21:42:46 +02:00
Thomas 778533a62e fix: revert regressions from recent integration/releases/app-href changes (#6514) 2026-08-03 17:37:53 +02:00
Thomas b4fba47a23 fix(releases): add success flag to successful response for correct error detection (#6512) 2026-08-03 17:10:35 +02:00
Thomas 76ddfceb13 fix: restore auto-refresh for docker and notifications widgets (#6511) 2026-08-03 17:07:46 +02:00
Thomas 2f6b96e5ba feat: restrict integrations management to integration-full-all permission (#6509) 2026-08-03 17:04:35 +02:00
Thomas f1b7a5146a fix: allow [homarr_base] template variables in app href validation (#6508) 2026-08-03 16:47:40 +02:00
Thomas Camlong 05292ae1f5 fix: map legacy media server options 2026-08-03 15:24:30 +02:00
homarr-renovate[bot] 48fc8db2b4 chore(deps): update semantic-release monorepo (major) (#6486) 2026-08-03 14:39:28 +02:00
Thomas 018f84f80d feat(dev): add Docker development CLI (#6495) 2026-08-03 14:38:56 +02:00
Thomas d58a47b776 fix: restrict system health monitoring integrations (#6505) 2026-08-02 10:17:05 +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
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 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 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
homarr-renovate[bot] c5e8b83d8a chore(deps): update vitest monorepo to ^4.1.10 (#6286)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-07-13 23:01:00 +00:00
homarr-renovate[bot] d52d9cfed2 chore(deps): update dependency posthog-js to ^1.398.7 (#6319)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-07-13 21:58:26 +00:00
Thomas 93325642aa Merge pull request #6324 from homarr-labs/renovate/major-github-artifact-actions 2026-07-13 22:12:32 +02:00
Thomas 165e94abbc Merge pull request #6328 from homarr-labs/fix-pnpm-fetch-build 2026-07-13 22:12:11 +02:00
homarr-renovate[bot] 2e3127e766 chore(deps): update docker/dockerfile docker tag to v1.25 (#6321) 2026-07-13 22:10:35 +02:00
Thomas d37326d905 Merge pull request #6322 from homarr-labs/renovate/actions-cache-6-x 2026-07-13 22:09:57 +02:00
homarr-renovate[bot] e51e39ff38 chore(deps): update dependency typescript to v7 (#6325) 2026-07-13 22:09:30 +02:00
homarr-renovate[bot] 6c075b6d74 chore(deps): update dependency mysql2 to v3.22.6 (#6310)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-07-13 18:45:08 +00:00
ajnart b2f63c6e0f fix(docker): drop --frozen-lockfile from install step per pnpm fetch docs
The pnpm fetch docs recommend `pnpm install -r --offline` without
--frozen-lockfile. The --offline flag already enforces no registry
access, making --frozen-lockfile redundant.

https://pnpm.io/cli/fetch
2026-07-13 20:24:59 +02:00
ajnart 8c2d73e8af fix(docker): resolve pnpm fetch crash with patchedDependencies
PR #6296 introduced pnpm fetch in the Dockerfile for Docker cache
optimization. However, pnpm fetch crashes with ENOENT when
patchedDependencies are configured with nodeLinker: hoisted because
applyPatchToDir tries to chdir into node_modules/<pkg> which doesn't
exist during fetch (only the content-addressable store is populated).

Fix: temporarily switch nodeLinker to isolated during pnpm fetch so
patches apply inside the virtual store (node_modules/.pnpm/...) which
IS created by pnpm fetch. Restore hoisted before the install step so
the final node_modules layout stays flat.

Refs: pnpm/pnpm#5268, pnpm/pnpm#8726
2026-07-13 20:06:28 +02:00
homarr-renovate[bot] 1c0ff8930e chore(deps): update actions/upload-artifact action to v7 2026-07-13 16:39:27 +00:00
homarr-renovate[bot] e2b8355639 chore(deps): update actions/cache action to v6 2026-07-13 16:39:10 +00:00
Thomas ba7718bde5 feat: rework the ci (#6296) 2026-07-13 18:32:48 +02:00
317 changed files with 54181 additions and 30239 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
+7
View File
@@ -6,7 +6,14 @@ npm-debug.log
README.md
.next
.turbo
.pnpm-store
**/.pnpm-store
coverage
**/coverage
**/.docusaurus
**/build
.git
.github
dev
.build
e2e
+1 -8
View File
@@ -66,11 +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
# Community workshop (PocketBase) base URL used by the in-app "Install from Workshop" buttons
# Workshop API override for local Homarr development:
# NEXT_PUBLIC_WORKSHOP_API_URL=http://localhost:8090
# Docs site workshop URL used for "Open Workshop" links
# In production this defaults to https://homarr.dev/workshop
# NEXT_PUBLIC_WORKSHOP_URL=http://localhost:3003/workshop
# 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
View File
@@ -0,0 +1,2 @@
.secrets
!.secrets.example
+4
View File
@@ -0,0 +1,4 @@
TURBO_API=
TURBO_TEAM=
TURBO_TOKEN=
TURBO_REMOTE_CACHE_SIGNATURE_KEY=
+36
View File
@@ -0,0 +1,36 @@
# Running CI locally
Install [Act](https://nektosact.com/installation/index.html) and ensure Docker is running, then use:
```bash
pnpm ci:act:fast
pnpm ci:act:docker
pnpm ci:act:all
pnpm ci:act:cleanup
```
`fast` runs the quality gate. `docker` and `all` run the gate followed by the single container build, E2E suite, and memory regression test. `cleanup` validates the manual cleanup path without calling GitHub. The wrapper supports regular clones and Git worktrees, including non-default Docker contexts. Linux dependencies are kept in Docker volumes instead of overwriting host `node_modules`. The first run downloads the Act runner and Playwright browser dependencies.
Remote caching is optional locally. Copy `.github/act/.secrets.example` to `.github/act/.secrets`, populate it with rotated credentials, and run with:
```bash
ACT_SECRET_FILE=.github/act/.secrets pnpm ci:act:all
```
Act never publishes or deletes GHCR images. The secret file is ignored by Git.
## PR preview images
Successful non-draft pull requests from this repository publish an amd64 image:
```bash
docker pull ghcr.io/homarr-labs/homarr-test:pr-<number>
```
The tag is replaced when the pull request is updated and deleted when it closes. Production Homarr images remain the supported multi-architecture releases. The `homarr-test` package must be configured as public in GHCR for unauthenticated testers.
## Dokploy preview cache
Keep `needs-demo` as the Dokploy preview label. Under the application's **Preview Build-time Secrets**, configure `TURBO_API`, `TURBO_TEAM`, `TURBO_TOKEN`, and `TURBO_REMOTE_CACHE_SIGNATURE_KEY`. Dokploy passes these to the Dockerfile as BuildKit secrets. Do not configure the token or signature key as Docker build arguments.
Rotate any credentials that have appeared in chat, logs, or committed configuration before enabling the cache.
-19
View File
@@ -1,19 +0,0 @@
# Workshop deployment with ACT
The Workshop workflow builds the same image used on the VPS and smoke-tests both Docusaurus and PocketBase. Registry login and pushes are skipped under ACT.
From the repository root:
```sh
pnpm ci:act:workshop
```
The wrapper selects `.github/workflows/deployment-workshop.yml`, derives the active Docker socket (including Docker Desktop and OrbStack contexts), and runs only the `deploy` job using a `workflow_dispatch` event.
To inspect the workflow without running it:
```sh
act workflow_dispatch --list --workflows .github/workflows/deployment-workshop.yml
```
No GitHub or registry secrets are required for the local run.
+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,
}
+47
View File
@@ -0,0 +1,47 @@
name: Auto-fix lint and format
on:
pull_request:
types: [opened, synchronize, reopened]
branches: ["*"]
permissions:
contents: write
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number }}
cancel-in-progress: true
jobs:
autofix:
name: Auto-fix
if: github.event.pull_request.head.repo.id == github.repository_id
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- uses: actions/checkout@v7
with:
fetch-depth: 0
ref: ${{ github.head_ref }}
- name: Setup
uses: ./tooling/github/setup
- name: Get changed files
id: changed
env:
BASE_REF: ${{ github.base_ref }}
run: |
git diff --name-only --diff-filter=ACMR "origin/${BASE_REF}...HEAD" -- '*.ts' '*.tsx' '*.js' '*.jsx' > /tmp/changed-files
echo "count=$(wc -l < /tmp/changed-files | tr -d '[:space:]')" >> $GITHUB_OUTPUT
- name: Auto-fix lint + format
if: steps.changed.outputs.count != '0'
run: |
xargs -r -a /tmp/changed-files pnpm exec oxlint --fix || true
xargs -r -a /tmp/changed-files pnpm exec oxfmt --write
- name: Commit fixes
uses: stefanzweifel/git-auto-commit-action@v7
with:
commit_message: "style: auto-fix lint and format"
+264
View File
@@ -0,0 +1,264 @@
name: CI
on:
pull_request:
types: [opened, synchronize, reopened, ready_for_review]
branches: ["*"]
pull_request_target:
types: [closed]
branches: ["*"]
push:
branches: [main, dev, beta, next]
merge_group:
workflow_dispatch:
inputs:
operation:
description: Operation to run
required: true
default: validate
type: choice
options:
- validate
- publish
- cleanup
pr_number:
description: PR number for publish or cleanup
required: false
type: number
ref:
description: Git ref to validate or publish
required: false
type: string
permissions:
contents: read
concurrency:
group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.event.pull_request.number || inputs.pr_number || github.ref }}
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
env:
FORCE_COLOR: 3
SKIP_ENV_VALIDATION: true
TURBO_TELEMETRY_DISABLED: 1
REGISTRY: ghcr.io
PREVIEW_IMAGE: ghcr.io/homarr-labs/homarr-test
jobs:
fast-gate:
name: Fast gate
if: github.event_name != 'pull_request_target' && (github.event_name != 'workflow_dispatch' || inputs.operation != 'cleanup')
runs-on: ubuntu-latest
timeout-minutes: 15
env:
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 }}
TURBO_SCM_BASE: ${{ github.event.pull_request.base.sha || github.event.merge_group.base_sha || github.event.before || 'HEAD~1' }}
TURBO_SCM_HEAD: ${{ github.sha }}
TURBO_PLATFORM: linux/amd64
steps:
- uses: actions/checkout@v7
if: "!env.ACT"
with:
fetch-depth: 0
persist-credentials: false
ref: ${{ github.event_name == 'workflow_dispatch' && inputs.ref || '' }}
- name: Validate manual inputs
if: github.event_name == 'workflow_dispatch' && inputs.operation == 'publish'
env:
PR_NUMBER: ${{ inputs.pr_number }}
BUILD_REF: ${{ inputs.ref }}
run: |
test -n "$PR_NUMBER" || { echo "pr_number is required for publish"; exit 1; }
test -n "$BUILD_REF" || { echo "ref is required for publish"; exit 1; }
- name: Setup
uses: ./tooling/github/setup
- name: Copy environment defaults
run: cp .env.example .env
- name: Lint, typecheck, and build affected workspaces
run: pnpm turbo run lint typecheck build --affected --continue=dependencies-successful
- name: Validate workspace configuration
run: pnpm lint:ws
- name: Run unit tests
run: pnpm test
container:
name: Container and E2E
needs: fast-gate
if: |
github.event_name == 'merge_group' ||
(github.event_name == 'pull_request' && github.event.pull_request.draft == false) ||
(github.event_name == 'workflow_dispatch' && (inputs.operation == 'validate' || inputs.operation == 'publish'))
runs-on: ubuntu-latest
timeout-minutes: 30
permissions:
contents: read
packages: write
steps:
- uses: actions/checkout@v7
if: "!env.ACT"
with:
persist-credentials: false
ref: ${{ github.event_name == 'workflow_dispatch' && inputs.ref || '' }}
- name: Setup
uses: ./tooling/github/setup
- name: Set up Docker Buildx
if: "!env.ACT"
uses: docker/setup-buildx-action@v4
- name: Build container once
if: "!env.ACT"
uses: docker/build-push-action@v7
with:
context: .
network: host
platforms: linux/amd64
load: true
push: false
tags: |
homarr-ci
homarr-e2e
labels: |
org.opencontainers.image.source=${{ github.server_url }}/${{ github.repository }}
org.opencontainers.image.revision=${{ github.sha }}
org.opencontainers.image.ref.name=pr-${{ github.event.pull_request.number || inputs.pr_number || 'none' }}
cache-from: type=gha,scope=homarr-linux-amd64
cache-to: type=gha,scope=homarr-linux-amd64,mode=max
secrets: |
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 }}
- name: Build container once with Act
if: env.ACT
env:
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 }}
run: |
secret_args=()
for secret in TURBO_API TURBO_TEAM TURBO_TOKEN TURBO_REMOTE_CACHE_SIGNATURE_KEY; do
if [ -n "${!secret:-}" ]; then
secret_args+=(--secret "id=${secret},env=${secret}")
fi
done
DOCKER_BUILDKIT=1 docker build --platform linux/amd64 \
--tag homarr-ci --tag homarr-e2e "${secret_args[@]}" .
- name: Cache Playwright browsers
if: "!env.ACT"
id: playwright-cache
uses: actions/cache@v6
with:
path: ~/.cache/ms-playwright
key: playwright-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}
- name: Install Playwright Chromium
if: steps.playwright-cache.outputs.cache-hit != 'true' || env.ACT
run: pnpm exec playwright install --with-deps chromium
- name: Install Playwright system deps
if: steps.playwright-cache.outputs.cache-hit == 'true'
run: pnpm exec playwright install-deps chromium
- name: Run E2E tests
run: pnpm test:e2e
- name: Upload E2E artifacts
if: failure() && !env.ACT
uses: actions/upload-artifact@v7
with:
name: playwright-report
path: e2e/test-results/
retention-days: 7
- name: Determine preview tag
id: preview
if: |
!env.ACT &&
(
(github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository) ||
(github.event_name == 'workflow_dispatch' && inputs.operation == 'publish')
)
env:
PR_NUMBER: ${{ github.event.pull_request.number || inputs.pr_number }}
run: echo "tag=pr-${PR_NUMBER}" >> "$GITHUB_OUTPUT"
- name: Log in to GHCR
if: steps.preview.outputs.tag != ''
uses: docker/login-action@v4
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Push preview image
if: steps.preview.outputs.tag != ''
env:
PREVIEW_TAG: ${{ steps.preview.outputs.tag }}
run: |
docker tag homarr-ci "${PREVIEW_IMAGE}:${PREVIEW_TAG}"
docker push "${PREVIEW_IMAGE}:${PREVIEW_TAG}"
- name: Show Act publication result
if: env.ACT
run: echo "Act validated the publication path; external GHCR writes are disabled locally."
cleanup:
name: Clean up preview image
if: github.event_name == 'pull_request_target' || (github.event_name == 'workflow_dispatch' && inputs.operation == 'cleanup')
runs-on: ubuntu-latest
timeout-minutes: 5
permissions:
packages: write
env:
IMAGE_TAG: pr-${{ github.event.pull_request.number || inputs.pr_number }}
steps:
- name: Validate cleanup input
if: github.event_name == 'workflow_dispatch'
env:
PR_NUMBER: ${{ inputs.pr_number }}
run: test -n "$PR_NUMBER" || { echo "pr_number is required for cleanup"; exit 1; }
- name: Delete GHCR package version
if: "!env.ACT"
uses: actions/github-script@v8
with:
script: |
const packageName = "homarr-test";
const org = context.repo.owner;
const tag = process.env.IMAGE_TAG;
const versions = await github.paginate(
github.rest.packages.getAllPackageVersionsForPackageOwnedByOrg,
{ package_type: "container", package_name: packageName, org, per_page: 100 },
);
const target = versions.find((version) =>
version.metadata?.container?.tags?.includes(tag),
);
if (!target) {
core.info(`No package version found with tag "${tag}"`);
return;
}
await github.rest.packages.deletePackageVersionForOrg({
package_type: "container",
package_name: packageName,
org,
package_version_id: target.id,
});
core.info(`Deleted package version ${target.id} (${tag})`);
- name: Show Act cleanup result
if: env.ACT
run: echo "Act validated cleanup for ${IMAGE_TAG}; external GHCR writes are disabled locally."
-165
View File
@@ -1,165 +0,0 @@
name: "[Quality] Code Analysis"
permissions:
contents: read
on:
pull_request:
branches: ["*"]
push:
branches: ["main"]
merge_group:
concurrency:
group: "${{ github.workflow }}-${{ github.ref }}"
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
env:
FORCE_COLOR: 3
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 }}
TURBO_SCM_BASE: ${{ github.event.pull_request.base.sha || github.event.before || 'HEAD~1' }}
TURBO_SCM_HEAD: ${{ github.sha }}
jobs:
lint-format-autofix:
if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.id == github.repository_id
runs-on: ubuntu-latest
timeout-minutes: 5
permissions:
contents: write
steps:
- uses: actions/checkout@v7
with:
fetch-depth: 0
ref: ${{ github.head_ref }}
- name: Setup
uses: ./tooling/github/setup
- name: Get changed files
id: changed
run: |
git diff --name-only --diff-filter=ACMR origin/${{ github.base_ref }}...HEAD -- '*.ts' '*.tsx' '*.js' '*.jsx' > /tmp/changed-files
echo "count=$(wc -l < /tmp/changed-files | tr -d '[:space:]')" >> $GITHUB_OUTPUT
- name: Auto-fix lint + format
if: steps.changed.outputs.count != '0'
run: |
xargs -r -a /tmp/changed-files pnpm exec oxlint --fix || true
xargs -r -a /tmp/changed-files pnpm exec oxfmt --write
- name: Commit fixes
uses: stefanzweifel/git-auto-commit-action@v7
with:
commit_message: "style: auto-fix lint and format"
lint:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v7
with:
fetch-depth: 0
- name: Setup
uses: ./tooling/github/setup
- name: Copy env
shell: bash
run: cp .env.example .env
- name: Lint
run: pnpm turbo lint --affected && pnpm lint:ws
format:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v7
with:
fetch-depth: 0
- name: Setup
uses: ./tooling/github/setup
- name: Format
run: pnpm turbo format --affected --continue
typecheck:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v7
with:
fetch-depth: 0
- name: Setup
uses: ./tooling/github/setup
- name: Typecheck
run: pnpm turbo typecheck --affected
test:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v7
- name: Setup
uses: ./tooling/github/setup
- name: Test
run: pnpm test
- name: "Report Coverage"
# Set if: always() to also generate the report if tests are failing
# Only works if you set `reportOnFailure: true` in your vite config as specified above
if: always()
uses: davelosert/vitest-coverage-report-action@v2
e2e:
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@v7
- name: Setup
uses: ./tooling/github/setup
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
- name: Build docker image
id: build-docker-image
uses: docker/build-push-action@v7
with:
platforms: linux/amd64
context: .
push: false
load: true
tags: homarr-e2e
network: host
cache-from: type=gha
cache-to: type=gha,mode=max
env:
SKIP_ENV_VALIDATION: true
- name: Install playwright browsers
run: pnpm exec playwright install chromium
- name: Run E2E Tests
shell: bash
run: pnpm test:e2e
build:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v7
with:
fetch-depth: 0
- name: Setup
uses: ./tooling/github/setup
- name: Copy env
shell: bash
run: cp .env.example .env
- name: Build
run: pnpm turbo build --affected
@@ -148,6 +148,11 @@ jobs:
platforms: linux/amd64
labels: ${{ steps.meta.outputs.labels }}
outputs: type=image,"name=${{ env.GHCR_REPO }}",push-by-digest=true,name-canonical=true,push=true
secrets: |
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 }}
env:
SKIP_ENV_VALIDATION: true
build-arm64:
@@ -187,6 +192,11 @@ jobs:
platforms: linux/arm64
labels: ${{ steps.meta.outputs.labels }}
outputs: type=image,"name=${{ env.GHCR_REPO }}",push-by-digest=true,name-canonical=true,push=true
secrets: |
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 }}
env:
SKIP_ENV_VALIDATION: true
extract-asset-amd64:
+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
-47
View File
@@ -1,47 +0,0 @@
name: "[Deployment] Workshop"
on:
push:
branches: ["dev"]
paths:
- "apps/docs/**"
- "apps/workshop/**"
workflow_dispatch:
permissions:
contents: read
packages: write
concurrency:
group: workshop-deployment
cancel-in-progress: true
jobs:
deploy:
name: Build, test, and publish
runs-on: ubuntu-latest
timeout-minutes: 25
env:
LOCAL_IMAGE: homarr/workshop:ci
REGISTRY_IMAGE: ghcr.io/homarr-labs/workshop
steps:
- name: Check out repository
uses: actions/checkout@v7
- name: Build combined Workshop image
run: docker build --file apps/workshop/Dockerfile --tag "$LOCAL_IMAGE" .
- name: Smoke-test static docs and PocketBase
run: bash apps/workshop/scripts/smoke-test.sh "$LOCAL_IMAGE"
- name: Log in to GitHub Container Registry
if: ${{ env.ACT != 'true' }}
run: echo "${{ github.token }}" | docker login ghcr.io --username "${{ github.actor }}" --password-stdin
- name: Publish immutable and latest tags
if: ${{ env.ACT != 'true' }}
run: |
docker tag "$LOCAL_IMAGE" "$REGISTRY_IMAGE:sha-${GITHUB_SHA}"
docker tag "$LOCAL_IMAGE" "$REGISTRY_IMAGE:latest"
docker push "$REGISTRY_IMAGE:sha-${GITHUB_SHA}"
docker push "$REGISTRY_IMAGE:latest"
-28
View File
@@ -1,28 +0,0 @@
# Changes — 2026-07-03
## PR #5984 Review And Dev Merge Fixes
- **Why**: Make `feat/widget-store` mergeable with `dev` and address CodeRabbit review feedback before requesting another review.
- **What**: Merged `origin/dev`, resolved `apps/docs/package.json` and `pnpm-lock.yaml`, and fixed workshop vote state handling, 404-only vote recovery, PocketBase filename URL encoding, required widget `$schema` validation, install-button localization, public author email fallback, placeholder product copy, and workshop safety/submission guidance.
- **Files**: `apps/docs/src/components/store/useStore.ts`, `apps/docs/src/lib/pocketbase.ts`, `apps/docs/src/lib/store-schema.ts`, `apps/docs/src/lib/store-schema.spec.ts`, `apps/nextjs/src/components/store-install/install-from-store-buttons.tsx`, `packages/translation/src/lang/en.json`, `apps/store/pb_migrations/1750000002_marketplace_view.js`, `apps/docs/docs/management/store/index.mdx`, `PRODUCT.md`, `pnpm-lock.yaml`, `apps/docs/package.json`.
- **Tests**: `pnpm lint` passed with warnings, `pnpm typecheck` passed, `pnpm exec vitest run apps/docs/src/lib/store-schema.spec.ts` passed. Full `pnpm test` failed on unrelated timeout/external-dependency tests: Immich icon fetch, Pi-hole container, MySQL migration, and Nextcloud hook.
- **Docs**: Updated workshop management docs with a stronger safety warning and submission guidelines.
- **Risks**: The commit includes a large required `dev` merge, so review should distinguish upstream merge changes from the scoped CodeRabbit fixes.
## PR #5984 Additional CodeRabbit Fixes
- **Why**: Resolve the remaining actionable CodeRabbit comments before requesting Copilot review.
- **What**: Commented out opt-in example env flags, pinned the PocketBase image, ignored store-local env files, documented PocketBase admin bootstrap vars, enabled GitHub OAuth only when credentials are present, indexed `comments.submission`, added reduced-motion handling for submit-step animation, added accessible comment action labels, used created vote records directly on detail pages, tracked copy-failure timers for cleanup, prevented screenshot carousel controls from navigating card links, and normalized the `useStore` utils import alias.
- **Files**: `.env.example`, `apps/store/docker-compose.yml`, `apps/store/.gitignore`, `apps/store/README.md`, `apps/store/pb_migrations/1750000000_init_store.js`, `apps/docs/src/css/custom.css`, `apps/docs/src/components/store/DetailPage.tsx`, `apps/docs/src/components/store/StoreApp.tsx`, `apps/docs/src/components/store/useStore.ts`.
- **Tests**: `pnpm --filter @homarr/docs typecheck`, `pnpm --filter @homarr/docs lint`, `pnpm --filter nextjs typecheck`, and `pnpm exec vitest run apps/docs/src/lib/store-schema.spec.ts` passed; docs lint still reports pre-existing warnings outside this fix set.
- **Docs**: Updated `apps/store/README.md` with admin bootstrap variables.
- **Risks**: PocketBase image pin assumes `ghcr.io/muchobien/pocketbase:0.26.2` remains the intended runtime version for this new store service.
## PR #5984 Final Review Stability Fix
- **Why**: Address CodeRabbit's remaining outside-diff stability comments before the final push.
- **What**: Updated `useStore` to await PocketBase auth refresh before loading workshop data and to return `null` from `requireUserId` on auth failures, allowing vote lock cleanup to run.
- **Files**: `apps/docs/src/components/store/useStore.ts`, `.opencode/changelog/2026-07-03.md`.
- **Tests**: `pnpm --filter @homarr/docs typecheck`, `pnpm --filter @homarr/docs lint`, and `pnpm exec vitest run apps/docs/src/lib/store-schema.spec.ts` passed; docs lint still reports pre-existing warnings outside this fix.
- **Docs**: Changelog updated only.
- **Risks**: Low; the change preserves existing auth refresh behavior but sequences initial loading after auth state is known.
-9
View File
@@ -1,9 +0,0 @@
# Changes — 2026-07-06
## Workshop Review Cleanup
- **Why**: Address PR review feedback to use Day.js for date/time logic, split the oversized detail component, and avoid duplicated workshop helpers.
- **What**: Added shared Day.js relative-time formatting, shared download/vote helpers, extracted detail comments and detail UI sections into separate components, and recorded the Day.js/shared-helper convention in `AGENTS.md`.
- **Files**: `apps/docs/src/components/store/DetailPage.tsx`, `apps/docs/src/components/store/StoreApp.tsx`, `apps/docs/src/components/store/useStore.ts`, `apps/docs/src/components/store/DetailComments.tsx`, `apps/docs/src/components/store/DetailSections.tsx`, `apps/docs/src/components/store/format.ts`, `apps/docs/src/components/store/store-utils.ts`, `AGENTS.md`
- **Tests**: `pnpm --filter @homarr/docs typecheck`; `pnpm --filter @homarr/docs lint`; targeted `oxfmt --check` for changed store files.
- **Docs**: Updated `AGENTS.md` with date/time and shared-helper conventions.
- **Risks**: Refactor moves code without intentional behavior changes; UI smoke testing in browser not run.
+9 -2
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,5 +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
- Use `dayjs` for date/time formatting or comparisons; do not hand-roll relative time or date math.
- Extract shared helpers/components when the same logic appears in multiple workshop/store files.
- 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 ./...`.
+8
View File
@@ -1,3 +1,11 @@
## [1.74.0](https://github.com/homarr-labs/homarr/compare/v1.73.0...v1.74.0) (2026-08-07)
## [1.73.0](https://github.com/homarr-labs/homarr/compare/v1.72.0...v1.73.0) (2026-07-31)
## [1.72.0](https://github.com/homarr-labs/homarr/compare/v1.71.0...v1.72.0) (2026-07-24)
## [1.71.0](https://github.com/homarr-labs/homarr/compare/v1.70.0...v1.71.0) (2026-07-17)
## [1.70.0](https://github.com/homarr-labs/homarr/compare/v1.69.2...v1.70.0) (2026-07-10)
## [1.69.2](https://github.com/homarr-labs/homarr/compare/v1.69.1...v1.69.2) (2026-07-06)
+30 -11
View File
@@ -1,24 +1,43 @@
# syntax=docker/dockerfile:1.25
FROM node:24.18.0-alpine AS base
FROM base AS builder
RUN apk add --no-cache libc6-compat
RUN apk update
# Set working directory
WORKDIR /app
RUN apk add --no-cache libc6-compat curl bash
RUN apk update
RUN apk add --no-cache libc6-compat curl bash && apk update
RUN corepack enable pnpm
COPY pnpm-lock.yaml pnpm-workspace.yaml package.json ./
COPY patches ./patches
# Workaround for pnpm/pnpm#5268: pnpm fetch crashes when patchedDependencies
# are configured with nodeLinker: hoisted. The applyPatchToDir function tries
# to chdir into node_modules/<pkg> which doesn't exist during fetch (only the
# content-addressable store is populated). By temporarily switching to the
# isolated linker, patches apply inside the virtual store (node_modules/.pnpm/...)
# which IS created by pnpm fetch. The original hoisted linker is restored
# before the install step so the final node_modules layout stays flat.
RUN --mount=type=cache,id=pnpm-store,target=/pnpm/store \
pnpm config set store-dir /pnpm/store && \
sed -i 's/nodeLinker: hoisted/nodeLinker: isolated/' pnpm-workspace.yaml && \
pnpm fetch && \
sed -i 's/nodeLinker: isolated/nodeLinker: hoisted/' pnpm-workspace.yaml
COPY . .
# Follow the pnpm fetch pattern from https://pnpm.io/cli/fetch
# --frozen-lockfile is omitted as recommended by the pnpm fetch docs
RUN --mount=type=cache,id=pnpm-store,target=/pnpm/store \
pnpm install --recursive
RUN corepack enable pnpm && pnpm install --recursive --frozen-lockfile
# Copy static data as it is not part of the build
COPY static-data ./static-data
ARG SKIP_ENV_VALIDATION='true'
ARG CI='true'
ARG DISABLE_REDIS_LOGS='true'
ARG TARGETPLATFORM
RUN corepack enable pnpm && pnpm build
RUN --mount=type=secret,id=TURBO_API,env=TURBO_API \
--mount=type=secret,id=TURBO_TEAM,env=TURBO_TEAM \
--mount=type=secret,id=TURBO_TOKEN,env=TURBO_TOKEN \
--mount=type=secret,id=TURBO_REMOTE_CACHE_SIGNATURE_KEY,env=TURBO_REMOTE_CACHE_SIGNATURE_KEY \
TURBO_PLATFORM="${TARGETPLATFORM:-linux/amd64}" pnpm build
FROM base AS runner
WORKDIR /app
-35
View File
@@ -1,35 +0,0 @@
# Product
## Register
GitHub OAuth is used for workshop accounts so community members can submit, vote on, comment on, and report shared widgets or CSS themes without creating a separate Homarr-specific identity.
## Users
Self-hosting enthusiasts managing homelab services. Technical users comfortable with Docker, APIs, and configuration files. They use Homarr as a personal dashboard to monitor and control 50+ services (media servers, DNS, NAS, download clients). Context: desktop browser, often on a local network, task-focused.
## Product Purpose
Homarr is a self-hosted dashboard for homelab management. It aggregates service status, media libraries, system stats, and administrative controls into a single drag-and-drop interface with 39+ widgets. The marketplace extension lets users share and install custom widgets and CSS themes created by the community.
## Brand Personality
Practical, capable, community-driven.
## Anti-references
- Generic SaaS landing pages with gradient hero sections and metric grids
- Overly corporate enterprise dashboards (ServiceNow, Salesforce)
- Cluttered "hacker" UIs with no visual hierarchy
## Design Principles
1. **Tool disappears into the task.** The interface should feel invisible; users came to manage their homelab, not admire the dashboard.
2. **Density when it earns it.** Show useful information compactly; don't spread thin content across oversized cards.
3. **Community first.** Sharing, voting, and installing should feel effortless and trustworthy.
4. **Consistency across surfaces.** The marketplace in the docs app should feel like it belongs to the same product as the main Next.js app.
5. **Respect the user's time.** Fast loads, instant feedback, no choreographed animations.
## Accessibility & Inclusion
WCAG 2.1 AA. Support for reduced motion. Sufficient contrast ratios (4.5:1 body text, 3:1 large text). Keyboard navigable.
-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" }
-24
View File
@@ -1,24 +0,0 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "base-nova",
"rsc": false,
"tsx": true,
"tailwind": {
"config": "",
"css": "src/css/custom.css",
"baseColor": "mist",
"cssVariables": true,
"prefix": ""
},
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"iconLibrary": "tabler",
"rtl": false,
"menuColor": "default",
"menuAccent": "subtle"
}
@@ -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 },
]}
/>
+9 -6
View File
@@ -7,8 +7,8 @@ tags:
- Ping
---
Apps are the building blocks of your Homarr board.
Most of the time they are a simple link to a website, but used on your board they also support a simple ping to check if your service is up.
Apps are the building blocks of your Homarr board.
Most of the time they are a simple link to a website, but used on your board they also support a simple ping to check if your service is up.
Apps are used in the bookmark and app widget.
## Create an app
@@ -27,8 +27,8 @@ 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.
This is the URL to the icon that will be displayed on the app widget.
You can paste a direct HTTP(S) URL to an image, search the included icon repositories by name, or upload a local file through the upload button. Direct image URLs are used as entered and are not searched in the icon repositories.
![Icon URL](./img/app-icon-url.png)
@@ -40,14 +40,17 @@ An optional description that can be shown as tooltip on the app widget.
The URL to the website you want to link to. Clicking on the app widget or bookmark will open this URL.
You can also use custom URI schemes such as `vscode://file/path`, `microsoft-edge:https://example.com`, or `myapp://open` to link to local applications or third-party programs. We trust that you know what you are doing — only `javascript:` URIs are blocked for security reasons.
### Ping URL
When you want to use another url for the simple status check you can set it here.
When you want to use another url for the simple status check you can set it here.
This will allow you to set a different URL for the ping than the URL that is opened when clicking on the app widget.
It's useful when you want to ping the service internally for example with a custom ip but open the website externally.
Note that the ping URL only accepts `http://` and `https://` URLs — custom URI schemes are not supported for pings.
## List of apps
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)
+81 -53
View File
@@ -9,97 +9,125 @@ tags:
---
# Settings
The settings page has a few options that will be applied to the entire Homarr application.
They may require a full page reload for changes to apply.
The settings page applies options to the whole Homarr application.
Some changes need a full page reload before they take effect.
## Analytics
By default, Homarr is collecting anonymous analytics to identify issues and prioritize bugfixes and improvements.
The data is never shared with any third-parties and is fully GDPR & CCPA compliant.
The software being used is [Umami Analytics](https://umami.is/) which is fully open source.
The main server software can be found [here](https://github.com/umami-software/umami) and [this node library](https://github.com/umami-software/node) to perform server side calls to the API.
A full list of the collected data by default can be found here: https://umami.is/docs/metric-definitions.
Homarr uses [PostHog](https://posthog.com/) to collect anonymous usage data.
We collect this data to see what features people ACTUALLY use.
We use this data to decide where we spend our development time.
We understand that you might not want Homarr to communicate with our services.
By unchecking the ``Send anonymous analytics`` checkbox and reloading the full page, all communication by Umami will be disabled and no data will be collected.
If you are paranoid, you can permanently block ``umami.homarr.dev`` from your DNS / firewall.
We do not sell the data. We do not share the data with third parties.
The data does not contain personal information and does not identify you or your installation.
Additionally, you opt in to more data collection to help us provide more useful data.
Homarr sends the data to our own PostHog server at `https://hog.homarr.dev`.
The server sends the data once a week and when the server starts.
### Integration data
Enabling this optional data will send the types of integrations (e.g. SABnzbd, Sonarr, ...) and how many you have configured of them.
This will not include any URLs, names, secrets, errors and others. This is disabled by default.
### What we collect
### Widget data
Enabling this optional data will send the types of widgets (e.g. downloads, media streams, iframe, ...) and how many you have configured of them.
This will not include any URLs, names, secrets, errors and others. This is disabled by default.
Homarr collects technical data about your installation:
### Users data
Enabling this optional data will send the count of users and whether you activated SSO.
This will not include any usernames, passwords, emails, URLs, authentication flows or client IDs.
This is disabled by default.
- Your Homarr version
- Your database type (SQLite, MySQL, or PostgreSQL)
- Whether Docker or Kubernetes is enabled
- Your enabled authentication providers
- Your operating system and CPU architecture
- Your default language
- The number of boards, apps, sections, layouts, users, groups, search engines, icon repositories, cron jobs, API keys, invites, sessions, and accounts
- The integration types you use and how many you have of each (for example, Sonarr or SABnzbd)
- The widget types you use and how many you have of each
Homarr does NOT collect:
- URLs
- Names
- Usernames, emails, or passwords
- Integration secrets or API keys
- Error messages or logs
- Any data that can identify you
Each installation gets a random ID. We use this ID only to count unique installations. We cannot use this ID to identify you.
Homarr does not use cookies for analytics. Homarr does not track page views automatically. Homarr only sends the data listed above.
### Disable analytics
Analytics are enabled by default. To disable analytics:
1. Open the Settings page.
2. Uncheck the "Help us improve Homarr" checkbox.
3. Reload the full page.
When analytics are disabled, Homarr does not send any data to PostHog.
You can also set the `NO_EXTERNAL_CONNECTION` environment variable to `true`. This stops all external requests, including analytics. See the [environment variables](/docs/advanced/environment-variables) page for more information.
You can block `hog.homarr.dev` in your DNS or firewall. Homarr cannot send data when this address is blocked.
## Search Engine Optimization (SEO)
Big companies [like Google, Microsoft, Baidu and Yandex](https://de.wikipedia.org/wiki/Web-Index) will use [web crawlers](https://de.wikipedia.org/wiki/Webcrawler) to index the Internet for new websites & pages.
This is desirable in many cases (for example when selling products in an online shop) but can lead to unwanted traffic and attention to your personal dashboard.
Homarr is requesting the crawlers to not index Homarr instances by default for this reason.
If you use Homarr as a public website and want it to be searchable on these search engines, you can disable these settings.
Please note that Homarr doesn't have control over what the crawlers do; it is a mere request to them to not perform these actions but will not guarantee that your website doesn't land on some index.
Search engines use web crawlers to index websites. This can cause unwanted traffic to your dashboard.
Homarr asks crawlers to not index your instance by default. Use these settings to change this request.
Homarr cannot control what the crawlers do. A crawler can ignore the request. Your website can still appear in an index.
### No index
This will ask crawlers to not index your Homarr instance on any indexes.
It is up to the crawler to properly respect this request.
By default, this setting is enabled. Disabling it will allow indexing.
This setting asks crawlers to not index your instance.
This setting is enabled by default. Disable this setting to allow indexing.
### No follow
If indexing is allowed, crawlers will also attempt to follow any links on your page to further "discover" new parts of the Internet and on your website.
By default, this setting is enabled and will ask crawlers to not follow any links.
Crawler of different companies may behave differently when indexing is prohibited and following is allowed.
Crawlers follow links to discover new pages.
This setting asks crawlers to not follow links. This setting is enabled by default.
Crawler behavior can differ between search engines.
### No translate
Google and Chrome on Android will prompt users to translate pages that are not in their device language.
This can lead to some shifting and can be undesired in some cases. By enabling this,
you ask crawlers to never translate and never prompt users for translation.
It should be noted that some browsers will ignore this and translate regardless.
Some browsers offer to translate pages.
This setting asks crawlers to not offer translation. Some browsers ignore this setting.
### No site links search box
This setting is exclusive to Google.
[Google will create a box below your domain](https://developers.google.com/search/docs/appearance/structured-data/sitelinks-searchbox) that lists the paths that were discovered by the "folowing".
The user can search them directly in Google.
You can ask the crawlers to not allow searches by enabling this setting.
This setting is disabled by default.
This setting applies to Google only.
Google can show a search box below your domain.
This setting asks Google to not show this box. This setting is disabled by default.
## Boards
### Global home board
You can set a global home board that will be used for all users that have not set a home board themselves.
It will also be used for anonymous users.
Note that the board must be public to be used as a global home board.
You can set a global home board.
This board is the fallback for users without their own home board. It is also the fallback for anonymous users.
The board must be public.
### Global mobile board
You can set a global mobile board. It will be shown instead of the global home board on mobile devices that can be detected by Homarr. (Using the User-Agent header).
It will similar to the global home board be as fallback for normal users and for any anonymous users.
It also has to be public.
You can set a global mobile board.
Homarr shows this board on mobile devices instead of the global home board.
Homarr detects mobile devices with the User-Agent header.
The board must be public.
### App status
#### Enable status by default
You can decide if the app status should be enabled by default when adding a new app to the board.
You can decide if app status is enabled when you add a new app.
#### Force disable status
You can decide if the app status should be disabled for all boards.
###
You can decide if app status is disabled on all boards.
## Appearance
### Default color scheme
You can set the default color scheme for all users that have not set a color scheme themselves.
You can set the default color scheme for users without their own color scheme.
## Culture
### Default language
You can set the default language for all users that have not set a language themselves.
You can set the default language for users without their own language.
@@ -1,59 +0,0 @@
---
tags:
- Workshop
- Custom Widgets
- Custom CSS
---
# Community Workshop
The [Community Workshop](pathname:///workshop) is where Homarr users share and discover
**custom CSS themes** and **custom widgets**. You can browse submissions, upvote the ones
you like, report abuse, and install entries directly into your own Homarr instance.
:::warning
The workshop contains community-made content. Widgets may contain unwanted or malicious content and it is your duty to check & verify safety of the content. While we try to remove malicious content as best as we can, we take no responsibility. Always review the widget JSON and all CSS before installing it on your instance and take frequent backups as prevention measures.
:::
## Browsing and voting
- Filter by **Widgets** or **CSS**, and sort by **Top rated** or **Newest**.
- Sign in with **GitHub** to upvote/downvote, submit your own creations, or report a submission.
- Each submission shows its title, description, author, version, an optional screenshot, and the full content.
## Submitting
1. Sign in with GitHub.
2. Click **Submit** and choose the type:
- **Custom widget** — paste the JSON exported from **Manage → Custom Widgets → Export**.
It must match the `homarr-custom-widget-v2` schema (validated before submission).
- **Custom CSS** — paste your stylesheet (max 16384 characters).
3. Add a title, description, optional screenshot, and changelog, then submit.
Submissions are published immediately. Reported entries are reviewed and may be removed.
## Submission guidelines
Submissions must be useful to Homarr users and safe to install. Do not submit content that is
malicious, deceptive, illegal, hateful, sexually explicit, gore, gambling-focused, spam, or content
that primarily promotes unrelated products or services.
## Installing into Homarr
You can install workshop entries without leaving Homarr:
- **Custom widgets** — go to **Manage → Custom Widgets**, click **Install from Workshop**, pick an
entry, and it is imported (secrets such as API keys must be re-entered afterwards).
- **Custom CSS** — open a board's **Settings → Custom CSS**, click **Install from Workshop**, and
pick a theme to load it into the editor before saving.
Alternatively, use **Copy** (or **Download** for widgets) on any submission and import it
manually.
## Workshop deployment
Workshop is powered by [PocketBase](https://pocketbase.io). The production image compiles the current Docusaurus site and serves it together with the API from one origin. The deployment lives in `apps/workshop/` with a Dockerfile, Compose configuration, version-controlled migrations, hooks, a smoke test, and ACT-local workflow instructions.
Homarr instances use `NEXT_PUBLIC_WORKSHOP_API_URL` to reach the central API and `NEXT_PUBLIC_WORKSHOP_URL` for links to the public page. Docusaurus uses `WORKSHOP_URL` only when its development server runs separately; the combined production image always uses its own origin.
+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",
},
+1 -28
View File
@@ -20,11 +20,6 @@ const config: Config = {
onBrokenAnchors: "throw",
onDuplicateRoutes: "throw",
customFields: {
// Empty means same-origin, which is the production Workshop image shape.
workshopUrl: process.env.WORKSHOP_URL ?? "",
},
future: {
v4: {
removeLegacyPostBuildHeadAttribute: true,
@@ -129,11 +124,6 @@ const config: Config = {
position: "left",
to: "/blog",
},
{
label: "Workshop",
position: "left",
to: "/workshop",
},
{
label: "About us",
position: "left",
@@ -288,12 +278,7 @@ const config: Config = {
return {
name: "resolve-homarr-packages",
configureWebpack() {
return {
resolve: {
symlinks: false,
alias: { "@": require("path").resolve(__dirname, "src") },
},
};
return { resolve: { symlinks: false } };
},
};
},
@@ -324,18 +309,6 @@ const config: Config = {
},
};
},
function workshopDetailRoutePlugin() {
return {
name: "workshop-detail-route",
async contentLoaded({ actions }) {
actions.addRoute({
path: "/workshop/:id",
component: "@site/src/components/workshop/DetailPage",
exact: true,
});
},
};
},
"@signalwire/docusaurus-plugin-llms-txt",
async function tailwindCssPlugin() {
return {
+4 -8
View File
@@ -21,7 +21,6 @@
"verify:search": "node ./scripts/verify-algolia-search.mjs"
},
"dependencies": {
"@base-ui/react": "catalog:",
"@docusaurus/core": "^3.10.1",
"@docusaurus/faster": "^3.10.1",
"@docusaurus/plugin-content-docs": "^3.10.1",
@@ -34,22 +33,18 @@
"@signalwire/docusaurus-plugin-llms-txt": "^1.2.2",
"@tabler/icons-react": "catalog:",
"@tailwindcss/postcss": "^4.3.2",
"class-variance-authority": "catalog:",
"clsx": "catalog:",
"dayjs": "catalog:",
"docusaurus-plugin-image-zoom": "^3.0.1",
"leader-line-new": "^1.1.9",
"pocketbase": "catalog:",
"postcss": "^8.5.16",
"posthog-docusaurus": "^2.0.5",
"prism-react-renderer": "^2.4.1",
"random-word-slugs": "^0.1.7",
"react": "catalog:",
"react-dom": "catalog:",
"swiper": "^14.0.2",
"tailwind-merge": "catalog:",
"tailwindcss": "^4.3.2",
"zod": "catalog:"
"swiper": "^14.0.5",
"tailwindcss": "^4.3.2"
},
"devDependencies": {
"@docusaurus/module-type-aliases": "^3.10.1",
@@ -60,7 +55,8 @@
"@types/react": "catalog:",
"@types/react-dom": "catalog:",
"cheerio": "^1.2.0",
"typescript": "catalog:"
"typescript": "catalog:",
"zod": "catalog:"
},
"peerDependencies": {
"@docusaurus/theme-common": "^3.10.1",
+10 -46
View File
@@ -1,49 +1,13 @@
import { mergeProps } from "@base-ui/react/merge-props";
import { useRender } from "@base-ui/react/use-render";
import { cva, type VariantProps } from "class-variance-authority";
import { ReactNode } from "react";
import { cn } from "@/lib/utils";
const badgeVariants = cva(
"group/badge inline-flex h-5 w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-4xl border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-all focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3!",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
secondary: "bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80",
destructive:
"bg-destructive/10 text-destructive focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:focus-visible:ring-destructive/40 [a]:hover:bg-destructive/20",
outline: "border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground",
ghost: "hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50",
link: "text-primary underline-offset-4 hover:underline",
},
},
defaultVariants: {
variant: "default",
},
},
);
function Badge({
className,
variant = "default",
render,
...props
}: useRender.ComponentProps<"span"> & VariantProps<typeof badgeVariants>) {
return useRender({
defaultTagName: "span",
props: mergeProps<"span">(
{
className: cn(badgeVariants({ variant }), className),
},
props,
),
render,
state: {
slot: "badge",
variant,
},
});
interface BadgeProps {
children: ReactNode;
}
export { Badge, badgeVariants };
export const Badge = ({ children }: BadgeProps) => {
return (
<span className="inline-flex items-center justify-center rounded-md border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap overflow-hidden border-transparent dark:bg-slate-700 bg-slate-200">
{children}
</span>
);
};
-50
View File
@@ -1,50 +0,0 @@
import { Button as ButtonPrimitive } from "@base-ui/react/button";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const buttonVariants = cva(
"group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/80",
outline:
"border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
secondary:
"bg-secondary text-secondary-foreground hover:bg-[color-mix(in_oklch,var(--secondary),var(--foreground)_5%)] aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
ghost:
"hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50",
destructive:
"bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
lg: "h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
icon: "size-8",
"icon-xs":
"size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3",
"icon-sm": "size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg",
"icon-lg": "size-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
},
);
function Button({
className,
variant = "default",
size = "default",
...props
}: ButtonPrimitive.Props & VariantProps<typeof buttonVariants>) {
return <ButtonPrimitive data-slot="button" className={cn(buttonVariants({ variant, size, className }))} {...props} />;
}
export { Button, buttonVariants };
-70
View File
@@ -1,70 +0,0 @@
import * as React from "react";
import { cn } from "@/lib/utils";
function Card({ className, size = "default", ...props }: React.ComponentProps<"div"> & { size?: "default" | "sm" }) {
return (
<div
data-slot="card"
data-size={size}
className={cn(
"group/card flex break-inside-avoid flex-col gap-(--card-spacing) overflow-hidden rounded-xl bg-card py-(--card-spacing) text-sm text-card-foreground ring-1 ring-foreground/10 [--card-spacing:--spacing(4)] has-data-[slot=card-footer]:pb-0 has-[>img:first-child]:pt-0 data-[size=sm]:[--card-spacing:--spacing(3)] data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl",
className,
)}
{...props}
/>
);
}
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-header"
className={cn(
"group/card-header @container/card-header grid auto-rows-min items-start gap-1 rounded-t-xl px-(--card-spacing) has-data-[slot=card-action]:grid-cols-[1fr_auto] has-data-[slot=card-description]:grid-rows-[auto_auto] [.border-b]:pb-(--card-spacing)",
className,
)}
{...props}
/>
);
}
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-title"
className={cn("font-heading text-base leading-snug font-medium group-data-[size=sm]/card:text-sm", className)}
{...props}
/>
);
}
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
return <div data-slot="card-description" className={cn("text-sm text-muted-foreground", className)} {...props} />;
}
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-action"
className={cn("col-start-2 row-span-2 row-start-1 self-start justify-self-end", className)}
{...props}
/>
);
}
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return <div data-slot="card-content" className={cn("px-(--card-spacing)", className)} {...props} />;
}
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-footer"
className={cn("flex items-center rounded-b-xl border-t border-border bg-muted/50 p-(--card-spacing)", className)}
{...props}
/>
);
}
export { Card, CardHeader, CardFooter, CardTitle, CardAction, CardDescription, CardContent };
-134
View File
@@ -1,134 +0,0 @@
"use client";
import * as React from "react";
import { Dialog as DialogPrimitive } from "@base-ui/react/dialog";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import { IconX } from "@tabler/icons-react";
function Dialog({ ...props }: DialogPrimitive.Root.Props) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />;
}
function DialogTrigger({ ...props }: DialogPrimitive.Trigger.Props) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />;
}
function DialogPortal({ ...props }: DialogPrimitive.Portal.Props) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />;
}
function DialogClose({ ...props }: DialogPrimitive.Close.Props) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />;
}
function DialogOverlay({ className, ...props }: DialogPrimitive.Backdrop.Props) {
return (
<DialogPrimitive.Backdrop
data-slot="dialog-overlay"
className={cn(
"fixed inset-0 isolate z-50 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0",
className,
)}
{...props}
/>
);
}
function DialogContent({
className,
children,
showCloseButton = true,
...props
}: DialogPrimitive.Popup.Props & {
showCloseButton?: boolean;
}) {
return (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Popup
data-slot="dialog-content"
className={cn(
"fixed top-1/2 left-1/2 z-50 grid w-full max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 gap-4 rounded-xl bg-popover p-4 text-sm text-popover-foreground ring-1 ring-foreground/10 duration-100 outline-none sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
className,
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close
data-slot="dialog-close"
render={<Button variant="ghost" className="absolute top-2 right-2" size="icon-sm" />}
>
<IconX />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Popup>
</DialogPortal>
);
}
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
return <div data-slot="dialog-header" className={cn("flex flex-col gap-2", className)} {...props} />;
}
function DialogFooter({
className,
showCloseButton = false,
children,
...props
}: React.ComponentProps<"div"> & {
showCloseButton?: boolean;
}) {
return (
<div
data-slot="dialog-footer"
className={cn(
"-mx-4 -mb-4 flex flex-col-reverse gap-2 rounded-b-xl border-t bg-muted/50 p-4 sm:flex-row sm:justify-end",
className,
)}
{...props}
>
{children}
{showCloseButton && <DialogPrimitive.Close render={<Button variant="outline" />}>Close</DialogPrimitive.Close>}
</div>
);
}
function DialogTitle({ className, ...props }: DialogPrimitive.Title.Props) {
return (
<DialogPrimitive.Title
data-slot="dialog-title"
className={cn("font-heading text-base leading-none font-medium", className)}
{...props}
/>
);
}
function DialogDescription({ className, ...props }: DialogPrimitive.Description.Props) {
return (
<DialogPrimitive.Description
data-slot="dialog-description"
className={cn(
"text-sm text-muted-foreground *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground",
className,
)}
{...props}
/>
);
}
export {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
};
+2 -2
View File
@@ -1,4 +1,4 @@
import type { ReactNode } from "react";
import { ReactNode } from "react";
import { Badge } from "../ui/badge";
interface DocsHeaderProps {
@@ -18,7 +18,7 @@ export const DocsHeader = (props: DocsHeaderProps) => {
<h1 className="!mb-0 text-4xl">{props.title}</h1>
<div className="flex gap-2">
{props.categories.map((category) => (
<Badge key={category}>{category}</Badge>
<Badge>{category}</Badge>
))}
</div>
</div>
-20
View File
@@ -1,20 +0,0 @@
import * as React from "react";
import { Input as InputPrimitive } from "@base-ui/react/input";
import { cn } from "@/lib/utils";
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
return (
<InputPrimitive
type={type}
data-slot="input"
className={cn(
"h-8 w-full min-w-0 rounded-lg border border-input bg-transparent px-2.5 py-1 text-base transition-colors outline-none file:inline-flex file:h-6 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
className,
)}
{...props}
/>
);
}
export { Input };
-167
View File
@@ -1,167 +0,0 @@
"use client";
import * as React from "react";
import { Select as SelectPrimitive } from "@base-ui/react/select";
import { cn } from "@/lib/utils";
import { IconSelector, IconCheck, IconChevronUp, IconChevronDown } from "@tabler/icons-react";
const Select = SelectPrimitive.Root;
function SelectGroup({ className, ...props }: SelectPrimitive.Group.Props) {
return <SelectPrimitive.Group data-slot="select-group" className={cn("scroll-my-1 p-1", className)} {...props} />;
}
function SelectValue({ className, ...props }: SelectPrimitive.Value.Props) {
return (
<SelectPrimitive.Value data-slot="select-value" className={cn("flex flex-1 text-left", className)} {...props} />
);
}
function SelectTrigger({
className,
size = "default",
children,
...props
}: SelectPrimitive.Trigger.Props & {
size?: "sm" | "default";
}) {
return (
<SelectPrimitive.Trigger
data-slot="select-trigger"
data-size={size}
className={cn(
"flex w-fit items-center justify-between gap-1.5 rounded-lg border border-input bg-transparent py-2 pr-2 pl-2.5 text-sm whitespace-nowrap transition-colors outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-placeholder:text-muted-foreground data-[size=default]:h-8 data-[size=sm]:h-7 data-[size=sm]:rounded-[min(var(--radius-md),10px)] *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-1.5 dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
>
{children}
<SelectPrimitive.Icon render={<IconSelector className="pointer-events-none size-4 text-muted-foreground" />} />
</SelectPrimitive.Trigger>
);
}
function SelectContent({
className,
children,
side = "bottom",
sideOffset = 4,
align = "center",
alignOffset = 0,
alignItemWithTrigger = true,
...props
}: SelectPrimitive.Popup.Props &
Pick<SelectPrimitive.Positioner.Props, "align" | "alignOffset" | "side" | "sideOffset" | "alignItemWithTrigger">) {
return (
<SelectPrimitive.Portal>
<SelectPrimitive.Positioner
side={side}
sideOffset={sideOffset}
align={align}
alignOffset={alignOffset}
alignItemWithTrigger={alignItemWithTrigger}
className="isolate z-50"
>
<SelectPrimitive.Popup
data-slot="select-content"
data-align-trigger={alignItemWithTrigger}
className={cn(
"relative isolate z-50 max-h-(--available-height) w-(--anchor-width) min-w-36 origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 data-[align-trigger=true]:animate-none data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
className,
)}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.List>{children}</SelectPrimitive.List>
<SelectScrollDownButton />
</SelectPrimitive.Popup>
</SelectPrimitive.Positioner>
</SelectPrimitive.Portal>
);
}
function SelectLabel({ className, ...props }: SelectPrimitive.GroupLabel.Props) {
return (
<SelectPrimitive.GroupLabel
data-slot="select-label"
className={cn("px-1.5 py-1 text-xs text-muted-foreground", className)}
{...props}
/>
);
}
function SelectItem({ className, children, ...props }: SelectPrimitive.Item.Props) {
return (
<SelectPrimitive.Item
data-slot="select-item"
className={cn(
"relative flex w-full cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
className,
)}
{...props}
>
<SelectPrimitive.ItemText className="flex flex-1 shrink-0 gap-2 whitespace-nowrap">
{children}
</SelectPrimitive.ItemText>
<SelectPrimitive.ItemIndicator
render={<span className="pointer-events-none absolute right-2 flex size-4 items-center justify-center" />}
>
<IconCheck className="pointer-events-none" />
</SelectPrimitive.ItemIndicator>
</SelectPrimitive.Item>
);
}
function SelectSeparator({ className, ...props }: SelectPrimitive.Separator.Props) {
return (
<SelectPrimitive.Separator
data-slot="select-separator"
className={cn("pointer-events-none -mx-1 my-1 h-px bg-border", className)}
{...props}
/>
);
}
function SelectScrollUpButton({ className, ...props }: React.ComponentProps<typeof SelectPrimitive.ScrollUpArrow>) {
return (
<SelectPrimitive.ScrollUpArrow
data-slot="select-scroll-up-button"
className={cn(
"top-0 z-10 flex w-full cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
>
<IconChevronUp />
</SelectPrimitive.ScrollUpArrow>
);
}
function SelectScrollDownButton({ className, ...props }: React.ComponentProps<typeof SelectPrimitive.ScrollDownArrow>) {
return (
<SelectPrimitive.ScrollDownArrow
data-slot="select-scroll-down-button"
className={cn(
"bottom-0 z-10 flex w-full cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
>
<IconChevronDown />
</SelectPrimitive.ScrollDownArrow>
);
}
export {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectScrollDownButton,
SelectScrollUpButton,
SelectSeparator,
SelectTrigger,
SelectValue,
};
-19
View File
@@ -1,19 +0,0 @@
import { Separator as SeparatorPrimitive } from "@base-ui/react/separator";
import { cn } from "@/lib/utils";
function Separator({ className, orientation = "horizontal", ...props }: SeparatorPrimitive.Props) {
return (
<SeparatorPrimitive
data-slot="separator"
orientation={orientation}
className={cn(
"shrink-0 bg-border data-horizontal:h-px data-horizontal:w-full data-vertical:w-px data-vertical:self-stretch",
className,
)}
{...props}
/>
);
}
export { Separator };
-18
View File
@@ -1,18 +0,0 @@
import * as React from "react";
import { cn } from "@/lib/utils";
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
return (
<textarea
data-slot="textarea"
className={cn(
"flex field-sizing-content min-h-16 w-full rounded-lg border border-input bg-transparent px-2.5 py-2 text-base transition-colors outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
className,
)}
{...props}
/>
);
}
export { Textarea };
@@ -1,178 +0,0 @@
import React, { useEffect, useState } from "react";
import { IconCheck, IconEdit, IconSend, IconTrash, IconX } from "@tabler/icons-react";
import type { WorkshopComment } from "@site/src/lib/pocketbase";
import { getPocketBase } from "@site/src/lib/pocketbase";
import { errorMessage } from "@site/src/lib/utils";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { formatRelativeTime } from "./format";
const commentAuthorName = (comment: WorkshopComment) =>
comment.expand?.author?.name || comment.expand?.author?.username || "unknown";
interface CommentsSectionProps {
submissionId: string;
pb: ReturnType<typeof getPocketBase>;
currentUserId?: string;
onRequireAuth: (action: string) => Promise<string | null>;
onError: (message: string) => void;
}
export const CommentsSection = ({ submissionId, pb, currentUserId, onRequireAuth, onError }: CommentsSectionProps) => {
const [rows, setRows] = useState<WorkshopComment[]>([]);
const [loading, setLoading] = useState(true);
const [fetchError, setFetchError] = useState(false);
const [newComment, setNewComment] = useState("");
const [editingId, setEditingId] = useState<string | null>(null);
const [editContent, setEditContent] = useState("");
useEffect(() => {
let cancelled = false;
setLoading(true);
setFetchError(false);
pb.collection("comments")
.getFullList<WorkshopComment>({
filter: pb.filter("submission = {:id}", { id: submissionId }),
sort: "-created",
expand: "author",
})
.then((data) => {
if (!cancelled) setRows(data);
})
.catch(() => {
if (!cancelled) setFetchError(true);
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
}, [pb, submissionId]);
const handleAdd = async () => {
const trimmed = newComment.trim();
if (!trimmed) return;
const userId = await onRequireAuth("comment");
if (!userId) return;
try {
const created = await pb
.collection("comments")
.create<WorkshopComment>({ submission: submissionId, content: trimmed, author: userId }, { expand: "author" });
setRows((prev) => [created, ...prev]);
setNewComment("");
} catch (caught) {
onError(errorMessage(caught, "Failed to post comment"));
}
};
const handleUpdate = async (id: string) => {
const trimmed = editContent.trim();
if (!trimmed) return;
try {
const updated = await pb
.collection("comments")
.update<WorkshopComment>(id, { content: trimmed }, { expand: "author" });
setRows((prev) => prev.map((comment) => (comment.id === id ? updated : comment)));
setEditingId(null);
} catch (caught) {
onError(errorMessage(caught, "Failed to update comment"));
}
};
const handleDelete = async (id: string) => {
try {
await pb.collection("comments").delete(id);
setRows((prev) => prev.filter((comment) => comment.id !== id));
} catch (caught) {
onError(errorMessage(caught, "Failed to delete comment"));
}
};
return (
<div className="flex flex-col gap-3">
<h2 className="text-sm font-medium">Comments</h2>
<div className="flex items-center gap-2">
<Input
className="flex-1"
placeholder="Write a comment…"
aria-label="Write a comment"
value={newComment}
onChange={(event) => setNewComment(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter") void handleAdd();
}}
/>
<Button size="icon-sm" onClick={() => void handleAdd()} aria-label="Post comment">
<IconSend size={14} />
</Button>
</div>
{loading && <p className="py-4 text-center text-sm text-muted-foreground">Loading comments…</p>}
{!loading && fetchError && <p className="py-4 text-center text-sm text-destructive">Failed to load comments</p>}
{!loading && !fetchError && rows.length === 0 && (
<p className="py-4 text-center text-sm text-muted-foreground">No comments yet</p>
)}
<div className="space-y-2">
{rows.map((comment) => {
const isOwner = currentUserId === comment.author;
const isEditing = editingId === comment.id;
return (
<div key={comment.id} className="rounded-lg bg-muted/30 px-3 py-2 dark:bg-input/20">
<div className="flex items-center gap-2 text-xs">
<span className="font-medium">{commentAuthorName(comment)}</span>
<span className="text-muted-foreground/60">{formatRelativeTime(comment.created)}</span>
{isOwner && !isEditing && (
<div className="ml-auto flex gap-0.5">
<button
className="rounded p-1 hover:bg-accent"
aria-label="Edit comment"
onClick={() => {
setEditingId(comment.id);
setEditContent(comment.content);
}}
>
<IconEdit size={12} />
</button>
<button
className="rounded p-1 hover:bg-destructive/20 hover:text-destructive"
aria-label="Delete comment"
onClick={() => void handleDelete(comment.id)}
>
<IconTrash size={12} />
</button>
</div>
)}
</div>
{isEditing ? (
<div className="mt-2 flex items-center gap-1">
<Input
className="flex-1"
aria-label="Edit comment"
value={editContent}
onChange={(event) => setEditContent(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter") void handleUpdate(comment.id);
}}
/>
<Button size="icon-sm" aria-label="Save comment" onClick={() => void handleUpdate(comment.id)}>
<IconCheck size={12} />
</Button>
<Button variant="ghost" size="icon-sm" aria-label="Cancel editing" onClick={() => setEditingId(null)}>
<IconX size={12} />
</Button>
</div>
) : (
<p className="mt-1 text-sm leading-relaxed text-muted-foreground">{comment.content}</p>
)}
</div>
);
})}
</div>
</div>
);
};
@@ -1,361 +0,0 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
import BrowserOnly from "@docusaurus/BrowserOnly";
import Link from "@docusaurus/Link";
import { useLocation } from "@docusaurus/router";
import useDocusaurusContext from "@docusaurus/useDocusaurusContext";
import Layout from "@theme/Layout";
import {
IconArrowBigDown,
IconArrowBigUp,
IconArrowLeft,
IconCheck,
IconCopy,
IconDownload,
IconX,
} from "@tabler/icons-react";
import type { ClientResponseError } from "pocketbase";
import type { WorkshopSubmission, WorkshopVote } from "@site/src/lib/pocketbase";
import { getPocketBase, getSubmissionFileUrl } from "@site/src/lib/pocketbase";
import type { SubmissionType } from "@site/src/lib/workshop-schema";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { cn, errorMessage } from "@site/src/lib/utils";
import { CommentsSection } from "./DetailComments";
import { CodeBlock, DeleteConfirmButton, DetailSkeleton, ScreenshotGallery } from "./DetailSections";
import { formatRelativeTime } from "./format";
import { downloadSubmissionJson, voteDelta } from "./workshop-utils";
const typeLabels: Record<SubmissionType, string> = { css: "CSS", widget: "Widget" };
const typeDotColors: Record<SubmissionType, string> = { css: "bg-blue-500", widget: "bg-yellow-500" };
const contentLanguages: Record<SubmissionType, string> = { css: "css", widget: "json" };
const copyState = [
{ Icon: IconCopy, label: "Copy", iconClass: "" },
{ Icon: IconCheck, label: "Copied!", iconClass: "text-green-500" },
] as const;
const scoreClassBySign = { positive: "text-foreground", negative: "text-destructive", neutral: "" } as const;
const scoreSign = (score: number): keyof typeof scoreClassBySign => {
if (score > 0) return "positive";
if (score < 0) return "negative";
return "neutral";
};
const parseSubmissionId = (pathname: string) => {
const segments = pathname.split("/").filter(Boolean);
const last = segments[segments.length - 1];
if (!last || last === "workshop") return null;
return last;
};
const isNotFound = (caught: unknown) =>
typeof caught === "object" && caught !== null && "status" in caught && (caught as ClientResponseError).status === 404;
const MarketplaceDetail = ({ workshopUrl }: { workshopUrl: string }) => {
const location = useLocation();
const submissionId = parseSubmissionId(location.pathname);
const pb = useMemo(() => getPocketBase(workshopUrl), [workshopUrl]);
const [submission, setSubmission] = useState<WorkshopSubmission | null>(null);
const [userVote, setUserVote] = useState<WorkshopVote | undefined>();
const [user, setUser] = useState(pb.authStore.record);
const [loading, setLoading] = useState(true);
const [notFound, setNotFound] = useState(false);
const [error, setError] = useState<string | null>(null);
const [copied, setCopied] = useState(false);
const [copyFailed, setCopyFailed] = useState(false);
const copyTimer = useRef<ReturnType<typeof setTimeout>>(null);
const copyFailedTimer = useRef<ReturnType<typeof setTimeout>>(null);
const voting = useRef(false);
const requireUserId = useCallback(
async (action: string) => {
if (!pb.authStore.isValid) {
try {
await pb.collection("users").authWithOAuth2({ provider: "github" });
} catch (caught) {
setError(errorMessage(caught, "Sign in failed"));
return null;
}
}
const userId = pb.authStore.record?.id;
if (!userId) {
setError(`Sign in to ${action}`);
return null;
}
return userId;
},
[pb],
);
useEffect(() => {
if (pb.authStore.isValid)
pb.collection("users")
.authRefresh()
.catch(() => pb.authStore.clear());
return pb.authStore.onChange(() => setUser(pb.authStore.record));
}, [pb]);
useEffect(
() => () => {
if (copyTimer.current) clearTimeout(copyTimer.current);
if (copyFailedTimer.current) clearTimeout(copyFailedTimer.current);
},
[],
);
useEffect(() => {
if (!submissionId) {
setNotFound(true);
setLoading(false);
return;
}
let cancelled = false;
setLoading(true);
setNotFound(false);
setError(null);
const load = async () => {
try {
const record = await pb.collection("marketplace").getOne<WorkshopSubmission>(submissionId);
if (cancelled) return;
setSubmission(record);
if (pb.authStore.isValid && pb.authStore.record) {
const votes = await pb.collection("votes").getFullList<WorkshopVote>({
filter: pb.filter("user = {:uid} && submission = {:sid}", {
uid: pb.authStore.record.id,
sid: submissionId,
}),
});
if (!cancelled) setUserVote(votes[0]);
}
} catch (caught) {
if (cancelled) return;
if (isNotFound(caught)) setNotFound(true);
else setError(errorMessage(caught, "Failed to load submission"));
} finally {
if (!cancelled) setLoading(false);
}
};
void load();
return () => {
cancelled = true;
};
}, [pb, submissionId]);
const screenshotUrls = useMemo(
() => submission?.screenshots?.map((f) => getSubmissionFileUrl(pb.baseURL, submission.id, f)) ?? [],
[submission, pb],
);
const handleVote = async (value: 1 | -1) => {
if (!submission || voting.current) return;
voting.current = true;
const userId = await requireUserId("vote");
if (!userId) {
voting.current = false;
return;
}
const prev = userVote;
const isToggleOff = prev?.value === value;
const [upD, downD] = voteDelta(prev?.value, value);
setUserVote(
isToggleOff
? undefined
: ({ ...(prev ?? { id: "", submission: submission.id, user: userId }), value } as WorkshopVote),
);
setSubmission((s) => (s ? { ...s, upvotes: s.upvotes + upD, downvotes: s.downvotes + downD } : s));
try {
if (!prev) {
const created = await pb
.collection("votes")
.create<WorkshopVote>({ submission: submission.id, value, user: userId });
setUserVote(created);
} else if (isToggleOff) {
await pb.collection("votes").delete(prev.id);
} else {
await pb.collection("votes").update(prev.id, { value });
}
} catch (caught) {
setUserVote(prev);
setSubmission((s) => (s ? { ...s, upvotes: s.upvotes - upD, downvotes: s.downvotes - downD } : s));
setError(errorMessage(caught, "Failed to register your vote"));
} finally {
voting.current = false;
}
};
const handleDelete = async () => {
if (!submission) return;
try {
await pb.collection("submissions").delete(submission.id);
window.location.href = "/workshop/";
} catch (caught) {
setError(errorMessage(caught, "Failed to delete submission"));
}
};
const handleCopy = async () => {
if (!submission) return;
try {
await navigator.clipboard.writeText(submission.content);
setCopied(true);
setCopyFailed(false);
if (copyTimer.current) clearTimeout(copyTimer.current);
if (copyFailedTimer.current) clearTimeout(copyFailedTimer.current);
copyTimer.current = setTimeout(() => setCopied(false), 1500);
} catch {
setCopyFailed(true);
if (copyFailedTimer.current) clearTimeout(copyFailedTimer.current);
copyFailedTimer.current = setTimeout(() => setCopyFailed(false), 2000);
}
};
if (loading) return <DetailSkeleton />;
if (notFound || !submission) {
return (
<div className="mx-auto max-w-4xl px-4 py-16 text-center">
<p className="text-lg font-medium">Submission not found</p>
<p className="mt-1 text-sm text-muted-foreground">This listing may have been removed or the link is invalid.</p>
<Link
to="/workshop"
className="mt-6 inline-flex items-center gap-1.5 rounded-lg px-2.5 py-1.5 text-sm text-muted-foreground hover:bg-muted hover:text-foreground"
>
<IconArrowLeft size={14} /> Back to Workshop
</Link>
</div>
);
}
const score = submission.upvotes - submission.downvotes;
const { Icon: CopyIcon, label: copyLabel, iconClass: copyIconClass } = copyState[Number(copied)];
return (
<div className="mx-auto max-w-4xl px-4 pb-16 pt-8">
<Link
to="/workshop"
className="mb-6 inline-flex items-center gap-1.5 rounded-lg px-2.5 py-1.5 text-sm text-muted-foreground hover:bg-muted hover:text-foreground"
>
<IconArrowLeft size={14} /> Back to Workshop
</Link>
{error && (
<div className="mb-4 rounded-lg border border-destructive/30 bg-destructive/10 px-4 py-2.5 text-sm text-destructive">
{error}
</div>
)}
{screenshotUrls.length > 0 && (
<div className="mb-6">
<ScreenshotGallery urls={screenshotUrls} title={submission.title} />
</div>
)}
<div className="flex flex-wrap items-start justify-between gap-4">
<div>
<div className="flex items-center gap-2">
<h1 className="text-2xl font-semibold tracking-tight">{submission.title}</h1>
<Badge variant="secondary" className="gap-1.5">
<span className={cn("size-2 rounded-full", typeDotColors[submission.type])} />
{typeLabels[submission.type]}
</Badge>
</div>
<p className="mt-1 text-sm text-muted-foreground">
{submission.authorName} · v{submission.version} · {formatRelativeTime(submission.created)}
</p>
</div>
<div className="flex items-center gap-px rounded-md border border-border bg-muted/40 p-px">
<button
onClick={() => void handleVote(1)}
aria-label="Upvote"
className={cn(
"flex items-center justify-center rounded-[5px] p-1.5 transition-colors hover:bg-accent",
userVote?.value === 1 && "bg-primary/15 text-primary",
)}
>
<IconArrowBigUp size={18} />
</button>
<span
className={cn("min-w-6 text-center text-sm font-semibold tabular-nums", scoreClassBySign[scoreSign(score)])}
>
{score}
</span>
<button
onClick={() => void handleVote(-1)}
aria-label="Downvote"
className={cn(
"flex items-center justify-center rounded-[5px] p-1.5 transition-colors hover:bg-accent",
userVote?.value === -1 && "bg-primary/15 text-primary",
)}
>
<IconArrowBigDown size={18} />
</button>
</div>
</div>
{submission.description && (
<p className="mt-6 text-sm leading-relaxed text-muted-foreground">{submission.description}</p>
)}
<div className="mt-6 flex flex-wrap items-center gap-2">
<Button variant="outline" size="sm" onClick={() => void handleCopy()}>
{copyFailed ? (
<>
<IconX size={14} className="text-destructive" /> Copy failed
</>
) : (
<>
<CopyIcon size={14} className={copyIconClass} /> {copyLabel}
</>
)}
</Button>
{submission.type === "widget" && (
<Button variant="outline" size="sm" onClick={() => downloadSubmissionJson(submission)}>
<IconDownload size={14} /> Download
</Button>
)}
{user?.id === submission.author && <DeleteConfirmButton onConfirm={handleDelete} />}
</div>
<div className="mt-6">
<CodeBlock content={submission.content} language={contentLanguages[submission.type]} />
</div>
<div className="mt-10 border-t border-border pt-8">
<CommentsSection
submissionId={submission.id}
pb={pb}
currentUserId={user?.id}
onRequireAuth={requireUserId}
onError={setError}
/>
</div>
</div>
);
};
export default function MarketplaceDetailPage() {
const { siteConfig } = useDocusaurusContext();
const configuredWorkshopUrl = (siteConfig.customFields?.workshopUrl as string | undefined) ?? "";
return (
<Layout title="Workshop" description="Community custom CSS and custom widgets for Homarr">
<main className="marketplace bg-background text-foreground min-h-[80vh]">
<BrowserOnly fallback={<DetailSkeleton />}>
{() => <MarketplaceDetail workshopUrl={configuredWorkshopUrl || window.location.origin} />}
</BrowserOnly>
</main>
</Layout>
);
}
@@ -1,123 +0,0 @@
import React, { useEffect, useRef, useState } from "react";
import { useColorMode } from "@docusaurus/theme-common";
import { IconChevronLeft, IconChevronRight, IconTrash } from "@tabler/icons-react";
import { Highlight, themes } from "prism-react-renderer";
import { Button } from "@/components/ui/button";
import { cn } from "@site/src/lib/utils";
const prismThemes = { light: themes.github, dark: themes.dracula } as const;
const colorModeKeys = { dark: "dark", light: "light" } as const;
const dotClass = ["bg-white/40", "bg-white"];
export const DetailSkeleton = () => (
<div className="mx-auto max-w-4xl px-4 py-8 animate-pulse space-y-6">
<div className="h-4 w-24 rounded bg-muted" />
<div className="aspect-video w-full rounded-lg bg-muted" />
<div className="space-y-2">
<div className="h-7 w-2/3 rounded bg-muted" />
<div className="h-4 w-40 rounded bg-muted" />
</div>
<div className="h-24 rounded-lg bg-muted" />
<div className="h-48 rounded-lg bg-muted" />
</div>
);
export const ScreenshotGallery = ({ urls, title }: { urls: string[]; title: string }) => {
const [idx, setIdx] = useState(0);
return (
<div className="group/gallery relative w-full">
<div className="aspect-video overflow-hidden rounded-lg bg-muted">
<img className="h-full w-full object-cover" src={urls[idx]} alt={`${title} screenshot ${idx + 1}`} />
</div>
{urls.length > 1 && (
<>
<button
className="absolute left-3 top-1/2 flex size-8 -translate-y-1/2 items-center justify-center rounded-md bg-background/80 opacity-60 shadow transition-opacity hover:opacity-100"
onClick={() => setIdx((i) => (i - 1 + urls.length) % urls.length)}
aria-label="Previous screenshot"
>
<IconChevronLeft size={16} />
</button>
<button
className="absolute right-3 top-1/2 flex size-8 -translate-y-1/2 items-center justify-center rounded-md bg-background/80 opacity-60 shadow transition-opacity hover:opacity-100"
onClick={() => setIdx((i) => (i + 1) % urls.length)}
aria-label="Next screenshot"
>
<IconChevronRight size={16} />
</button>
<div className="absolute bottom-3 left-1/2 flex -translate-x-1/2 gap-1.5 rounded-full bg-black/50 px-2.5 py-1">
{urls.map((_, i) => (
<button
key={i}
onClick={() => setIdx(i)}
aria-label={`Screenshot ${i + 1}`}
className={cn("size-2 rounded-full transition-all", dotClass[Number(i === idx)])}
/>
))}
</div>
</>
)}
</div>
);
};
export const CodeBlock = ({ content, language }: { content: string; language: string }) => {
const { colorMode } = useColorMode();
const theme = prismThemes[colorModeKeys[colorMode as keyof typeof colorModeKeys] ?? "light"];
return (
<Highlight theme={theme} code={content} language={language}>
{({ className, style, tokens, getLineProps, getTokenProps }) => (
<pre
className={cn(className, "overflow-auto rounded-lg border border-border p-4 text-sm leading-relaxed")}
style={style}
>
{tokens.map((line, i) => (
<div key={i} {...getLineProps({ line })}>
{line.map((token, key) => (
<span key={key} {...getTokenProps({ token })} />
))}
</div>
))}
</pre>
)}
</Highlight>
);
};
export const DeleteConfirmButton = ({ onConfirm }: { onConfirm: () => void }) => {
const [pending, setPending] = useState(false);
const timer = useRef<ReturnType<typeof setTimeout>>(null);
useEffect(
() => () => {
if (timer.current) clearTimeout(timer.current);
},
[],
);
const handleClick = () => {
if (!pending) {
setPending(true);
timer.current = setTimeout(() => setPending(false), 3000);
return;
}
setPending(false);
onConfirm();
};
return (
<Button
variant="outline"
size="sm"
className={cn(
pending ? "border-destructive bg-destructive/10 text-destructive" : "text-destructive hover:bg-destructive/10",
)}
onClick={handleClick}
>
<IconTrash size={14} /> {pending ? "Confirm delete?" : "Delete"}
</Button>
);
};
@@ -1,542 +0,0 @@
import React, { useCallback, useEffect, useRef, useState } from "react";
import {
IconBraces,
IconCheck,
IconChevronLeft,
IconChevronRight,
IconFileUpload,
IconLoader2,
IconPalette,
IconPhoto,
IconUpload,
IconX,
} from "@tabler/icons-react";
import type { SubmissionType } from "@site/src/lib/workshop-schema";
import { validateSubmissionContent } from "@site/src/lib/workshop-schema";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { cn, errorMessage } from "@/lib/utils";
import type { SubmitInput } from "./useWorkshop";
interface Props {
onClose: () => void;
onSubmit: (input: SubmitInput) => Promise<void>;
}
const steps = ["Type", "Details", "Media"] as const;
const navDirection = [-1, 1];
const stepAnimClass = ["submit-step-from-left", "submit-step-from-right"];
const backLabels = ["Cancel", "Back"];
const submitLabels = ["Submit", "Submitting…"];
const connectorClass = ["bg-border", "bg-primary"];
const dropOverlayClass = ["pointer-events-none opacity-0", "opacity-100"];
const placeholders: Record<SubmissionType, string> = {
widget: '{\n "$schema": "homarr-custom-widget-v2",\n "name": "My widget",\n ...\n}',
css: ".grid-stack-item-content {\n border-radius: 16px;\n}",
};
const contentLabels: Record<SubmissionType, string> = {
widget: "Widget JSON (homarr-custom-widget-v2)",
css: "Custom CSS",
};
const parseJsonObject = (json: string): Record<string, unknown> | null => {
try {
const parsed = JSON.parse(json);
if (typeof parsed === "object" && parsed !== null) return parsed;
} catch {
/* invalid json */
}
return null;
};
export const SubmitForm = ({ onClose, onSubmit }: Props) => {
const [step, setStep] = useState(0);
const [direction, setDirection] = useState(1);
const [type, setType] = useState<SubmissionType | null>(null);
const [title, setTitle] = useState("");
const [description, setDescription] = useState("");
const [content, setContent] = useState("");
const [screenshots, setScreenshots] = useState<File[]>([]);
const [previews, setPreviews] = useState<string[]>([]);
const [error, setError] = useState<string | null>(null);
const [pending, setPending] = useState(false);
const [jsonDropHint, setJsonDropHint] = useState(false);
const previewUrlsRef = useRef<Set<string>>(new Set());
const canAdvance = [type !== null, title.trim().length >= 3 && content.trim().length > 0, true];
const goTo = (next: number) => {
setDirection(navDirection[Number(next > step)]);
setStep(next);
};
const setContentAndAutofill = useCallback((json: string, currentTitle: string, currentDesc: string) => {
setContent(json);
const meta = parseJsonObject(json);
if (!meta) return;
if (typeof meta.name === "string" && !currentTitle) setTitle(meta.name);
if (typeof meta.description === "string" && !currentDesc) setDescription(String(meta.description));
}, []);
const handleJsonFileDrop = useCallback(
(files: FileList | File[]) => {
const jsonFile = Array.from(files).find((f) => f.name.endsWith(".json") || f.type === "application/json");
if (!jsonFile) return false;
const reader = new FileReader();
reader.onload = (e) => {
const text = e.target?.result as string;
if (!type) setType("widget");
setContentAndAutofill(text, title, description);
if (step === 0) goTo(1);
};
reader.onerror = () => setError("Could not read the file");
reader.readAsText(jsonFile);
return true;
},
[type, step, title, description, setContentAndAutofill],
);
const addImageFiles = useCallback(
(files: FileList | File[]) => {
const incoming = Array.from(files)
.filter((f) => f.type.startsWith("image/"))
.slice(0, 5 - screenshots.length);
if (incoming.length === 0) return;
setScreenshots((prev) => [...prev, ...incoming].slice(0, 5));
const urls = incoming.map((f) => {
const url = URL.createObjectURL(f);
previewUrlsRef.current.add(url);
return url;
});
setPreviews((prev) => [...prev, ...urls].slice(0, 5));
},
[screenshots.length],
);
const removeScreenshot = (idx: number) => {
const url = previews[idx];
if (url) {
previewUrlsRef.current.delete(url);
URL.revokeObjectURL(url);
}
setScreenshots((prev) => prev.filter((_, i) => i !== idx));
setPreviews((prev) => prev.filter((_, i) => i !== idx));
};
useEffect(
() => () => {
previewUrlsRef.current.forEach(URL.revokeObjectURL);
previewUrlsRef.current.clear();
},
[],
);
const handleGlobalDrop = useCallback(
(e: React.DragEvent) => {
e.preventDefault();
setJsonDropHint(false);
if (!handleJsonFileDrop(e.dataTransfer.files) && step === 2) addImageFiles(e.dataTransfer.files);
},
[handleJsonFileDrop, step, addImageFiles],
);
const handleSubmit = async () => {
if (!type) return;
const validation = validateSubmissionContent(type, content);
if (!validation.success) {
setError(validation.error);
goTo(1);
return;
}
setError(null);
setPending(true);
try {
await onSubmit({ type, title, description, content, screenshots });
} catch (caught) {
setError(errorMessage(caught, "Submission failed"));
} finally {
setPending(false);
}
};
const onContentChange = (value: string) => {
if (type === "widget") setContentAndAutofill(value, title, description);
else setContent(value);
};
return (
<Dialog
open
onOpenChange={(open) => {
if (!open && !pending) onClose();
}}
>
{/* oxlint-disable-next-line jsx-a11y/no-noninteractive-element-interactions */}
<DialogContent
className="max-h-[90vh] overflow-y-auto sm:max-w-xl"
showCloseButton={!pending}
onDragOver={(e: React.DragEvent) => {
e.preventDefault();
setJsonDropHint(true);
}}
onDragLeave={() => setJsonDropHint(false)}
onDrop={handleGlobalDrop}
>
<div
aria-hidden={!jsonDropHint}
className={cn(
"absolute inset-0 z-50 flex flex-col items-center justify-center gap-3 rounded-xl bg-primary/10 backdrop-blur-sm transition-opacity duration-200",
dropOverlayClass[Number(jsonDropHint)],
)}
>
<div className="flex size-14 items-center justify-center rounded-2xl bg-primary text-primary-foreground">
<IconFileUpload size={28} />
</div>
<p className="text-sm font-semibold">Drop JSON file to import widget</p>
<p className="text-xs text-muted-foreground">Title and description will be auto-filled</p>
</div>
<DialogHeader>
<DialogTitle>Share with the community</DialogTitle>
<DialogDescription>Submit a custom CSS theme or widget to the Workshop.</DialogDescription>
</DialogHeader>
<div className="flex items-center gap-2">
{steps.map((label, i) => (
<React.Fragment key={label}>
{i > 0 && <div className={cn("h-px flex-1 transition-colors", connectorClass[Number(i <= step)])} />}
<button
onClick={() => {
if (i < step) goTo(i);
}}
className={cn(
"flex items-center gap-1.5 rounded-full px-3 py-1 text-xs font-medium transition-all",
i === step && "bg-primary text-primary-foreground shadow-sm",
i < step && "bg-primary/10 text-primary cursor-pointer hover:bg-primary/20",
i > step && "bg-muted text-muted-foreground",
)}
>
{i < step ? <IconCheck size={12} /> : <span className="tabular-nums">{i + 1}</span>}
{label}
</button>
</React.Fragment>
))}
</div>
{error && (
<div className="rounded-lg border border-destructive/30 bg-destructive/10 px-3 py-2 text-xs font-medium text-destructive">
{error}
</div>
)}
<div className="relative min-h-[280px] overflow-hidden">
<div key={step} className={cn("submit-step-enter", stepAnimClass[Number(direction > 0)])}>
{step === 0 && <StepType value={type} onChange={setType} onJsonDrop={handleJsonFileDrop} />}
{step === 1 && type && (
<StepDetails
type={type}
title={title}
setTitle={setTitle}
description={description}
setDescription={setDescription}
content={content}
onContentChange={onContentChange}
/>
)}
{step === 2 && (
<StepMedia previews={previews} addFiles={addImageFiles} removeScreenshot={removeScreenshot} />
)}
</div>
</div>
<DialogFooter className="sm:justify-between">
<Button
variant="ghost"
onClick={() => {
if (step === 0) onClose();
else goTo(step - 1);
}}
disabled={pending}
>
<IconChevronLeft size={14} /> {backLabels[Math.min(step, 1)]}
</Button>
<div className="flex items-center gap-2">
{step < 2 && (
<Button onClick={() => goTo(step + 1)} disabled={!canAdvance[step]}>
Next <IconChevronRight size={14} />
</Button>
)}
{step >= 2 && (
<Button onClick={() => void handleSubmit()} disabled={pending || !canAdvance[1]}>
{pending && <IconLoader2 size={14} className="animate-spin" />}
{submitLabels[Number(pending)]}
</Button>
)}
</div>
</DialogFooter>
</DialogContent>
</Dialog>
);
};
const typeCards: { type: SubmissionType; icon: typeof IconBraces; label: string; desc: string }[] = [
{
type: "widget",
icon: IconBraces,
label: "Custom Widget",
desc: "A JSON-based widget using the homarr-custom-widget-v2 schema",
},
{ type: "css", icon: IconPalette, label: "Custom CSS", desc: "A CSS theme or style override for Homarr dashboards" },
];
const StepType = ({
value,
onChange,
onJsonDrop,
}: {
value: SubmissionType | null;
onChange: (t: SubmissionType) => void;
onJsonDrop: (files: FileList | File[]) => boolean;
}) => {
const [dropActive, setDropActive] = useState(false);
return (
<div className="flex flex-col gap-3">
<div className="grid grid-cols-2 gap-3">
{typeCards.map(({ type, icon: Icon, label, desc }) => (
<button
key={type}
onClick={() => onChange(type)}
className={cn(
"group flex flex-col items-center gap-3 rounded-xl border-2 p-6 text-center transition-all hover:border-primary/50 hover:bg-primary/5",
value === type ? "border-primary bg-primary/10 ring-1 ring-primary/20" : "border-border bg-card",
)}
>
<div
className={cn(
"flex size-12 items-center justify-center rounded-xl transition-colors",
value === type
? "bg-primary text-primary-foreground"
: "bg-muted text-muted-foreground group-hover:bg-primary/10 group-hover:text-primary",
)}
>
<Icon size={24} />
</div>
<div>
<p className="font-heading text-sm font-semibold">{label}</p>
<p className="mt-1 text-xs leading-relaxed text-muted-foreground">{desc}</p>
</div>
{value === type && (
<div className="flex size-5 items-center justify-center rounded-full bg-primary text-primary-foreground">
<IconCheck size={12} />
</div>
)}
</button>
))}
</div>
{/* oxlint-disable-next-line jsx-a11y/no-noninteractive-element-interactions */}
<label
onDragOver={(e) => {
e.preventDefault();
e.stopPropagation();
setDropActive(true);
}}
onDragLeave={() => setDropActive(false)}
onDrop={(e) => {
e.preventDefault();
e.stopPropagation();
setDropActive(false);
onJsonDrop(e.dataTransfer.files);
}}
className={cn(
"flex cursor-pointer items-center gap-3 rounded-xl border border-dashed p-3 transition-all hover:border-primary/40 hover:bg-primary/5",
dropActive && "border-primary bg-primary/10",
)}
>
<div className="flex size-8 shrink-0 items-center justify-center rounded-lg bg-muted text-muted-foreground">
<IconFileUpload size={16} />
</div>
<div className="min-w-0">
<p className="text-xs font-medium">Or drop a .json file here</p>
<p className="text-xs text-muted-foreground">Auto-selects Widget type and fills title from JSON</p>
</div>
<input
type="file"
accept=".json,application/json"
className="hidden"
onChange={(e) => {
if (e.target.files) onJsonDrop(e.target.files);
}}
/>
</label>
</div>
);
};
const StepDetails = ({
type,
title,
setTitle,
description,
setDescription,
content,
onContentChange,
}: {
type: SubmissionType;
title: string;
setTitle: (v: string) => void;
description: string;
setDescription: (v: string) => void;
content: string;
onContentChange: (v: string) => void;
}) => (
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-1.5">
<label htmlFor="submit-content" className="text-xs font-medium text-muted-foreground">
{contentLabels[type]} *
</label>
<Textarea
id="submit-content"
className="font-mono text-xs"
value={content}
onChange={(e) => onContentChange(e.target.value)}
placeholder={placeholders[type]}
rows={8}
required
/>
{type === "widget" && (
<p className="text-xs text-muted-foreground">
Paste your widget JSON — title and description will auto-fill from the{" "}
<code className="rounded bg-muted px-1 py-0.5 text-foreground">name</code> field.
</p>
)}
</div>
<div className="flex flex-col gap-1.5">
<label htmlFor="submit-title" className="text-xs font-medium text-muted-foreground">
Title *
</label>
<Input
id="submit-title"
value={title}
onChange={(e) => setTitle(e.target.value)}
required
minLength={3}
maxLength={100}
placeholder="My awesome theme"
/>
</div>
<div className="flex flex-col gap-1.5">
<label htmlFor="submit-description" className="text-xs font-medium text-muted-foreground">
Description
</label>
<Textarea
id="submit-description"
value={description}
onChange={(e) => setDescription(e.target.value)}
maxLength={2000}
rows={2}
placeholder="A brief description of what this does"
/>
</div>
</div>
);
const mediaDropState = {
idle: { Icon: IconUpload, title: "Drag & drop or click to upload", iconClass: "bg-muted text-muted-foreground" },
active: { Icon: IconPhoto, title: "Drop images here", iconClass: "bg-primary text-primary-foreground" },
} as const;
const StepMedia = ({
previews,
addFiles,
removeScreenshot,
}: {
previews: string[];
addFiles: (files: FileList | File[]) => void;
removeScreenshot: (idx: number) => void;
}) => {
const [dragOver, setDragOver] = useState(false);
const { Icon: DropIcon, title: dropTitle, iconClass: dropIconClass } = mediaDropState[dragOver ? "active" : "idle"];
return (
<div className="flex flex-col gap-4">
<div>
<span className="text-xs font-medium text-muted-foreground">Screenshots (optional, up to 5)</span>
<p className="mt-0.5 text-xs text-muted-foreground/70">
Add screenshots to help others preview your submission.
</p>
</div>
{/* oxlint-disable-next-line jsx-a11y/no-noninteractive-element-interactions */}
<label
onDragOver={(e) => {
e.preventDefault();
e.stopPropagation();
setDragOver(true);
}}
onDragLeave={() => setDragOver(false)}
onDrop={(e) => {
e.preventDefault();
e.stopPropagation();
setDragOver(false);
addFiles(e.dataTransfer.files);
}}
className={cn(
"flex cursor-pointer flex-col items-center gap-2 rounded-xl border-2 border-dashed border-border p-8 text-center transition-all hover:border-primary/50 hover:bg-primary/5",
dragOver && "border-primary bg-primary/10",
previews.length >= 5 && "pointer-events-none opacity-50",
)}
>
<div className={cn("flex size-10 items-center justify-center rounded-xl transition-colors", dropIconClass)}>
<DropIcon size={20} />
</div>
<div>
<p className="font-heading text-sm font-medium">{dropTitle}</p>
<p className="mt-0.5 text-xs text-muted-foreground">PNG, JPG, WebP up to 5 images</p>
</div>
<input
type="file"
accept="image/*"
multiple
className="hidden"
onChange={(e) => {
if (e.target.files) addFiles(e.target.files);
}}
/>
</label>
{previews.length > 0 && (
<div className="grid grid-cols-5 gap-2">
{previews.map((src, i) => (
<div
key={i}
className="group relative aspect-square overflow-hidden rounded-xl border border-border bg-muted"
>
<img src={src} alt={`Preview ${i + 1}`} className="h-full w-full object-cover" />
<button
onClick={() => removeScreenshot(i)}
className="absolute inset-0 flex items-center justify-center bg-black/60 opacity-0 transition-opacity group-hover:opacity-100"
aria-label={`Remove screenshot ${i + 1}`}
>
<IconX size={16} className="text-white" />
</button>
</div>
))}
</div>
)}
</div>
);
};
@@ -1,523 +0,0 @@
import React, { useEffect, useMemo, useRef, useState } from "react";
import {
IconArrowBigDown,
IconArrowBigUp,
IconBrandCss3,
IconBrandGithub,
IconCheck,
IconChevronLeft,
IconChevronRight,
IconCopy,
IconDownload,
IconFlag,
IconLogout,
IconMessage,
IconPackage,
IconPlus,
IconPuzzle,
IconSearch,
IconTrash,
IconX,
} from "@tabler/icons-react";
import { getSubmissionFileUrl, type WorkshopSubmission } from "@site/src/lib/pocketbase";
import type { SubmissionType } from "@site/src/lib/workshop-schema";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Card,
CardAction,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { cn } from "@/lib/utils";
import { SubmitForm } from "./SubmitForm";
import { formatRelativeTime } from "./format";
import { downloadSubmissionJson } from "./workshop-utils";
import type { SortKey, TypeFilter } from "./useWorkshop";
import { useWorkshop } from "./useWorkshop";
const typeDotColors: Record<SubmissionType, string> = { css: "bg-blue-500", widget: "bg-yellow-500" };
const typeLabels: Record<SubmissionType, string> = { css: "CSS", widget: "Widget" };
const typeIcons: Record<SubmissionType, React.ComponentType<{ size: number; className?: string }>> = {
css: IconBrandCss3,
widget: IconPuzzle,
};
const typeBgColors: Record<SubmissionType, string> = { css: "bg-blue-500/5", widget: "bg-yellow-500/5" };
const filterActiveClass = "bg-background text-foreground shadow-sm";
const filterInactiveClass = "text-muted-foreground hover:text-foreground";
const copyState = [
{ Icon: IconCopy, label: "Copy", iconClass: "" },
{ Icon: IconCheck, label: "Copied!", iconClass: "text-green-500" },
] as const;
const emptyState = {
none: { title: "No submissions yet", hint: "Be the first to share something." },
filtered: { title: "No matching results", hint: "Try adjusting your filters or search." },
} as const;
const typeFilters: { value: TypeFilter; label: string; dot?: string }[] = [
{ value: "all", label: "All" },
{ value: "widget", label: "Widgets", dot: typeDotColors.widget },
{ value: "css", label: "CSS", dot: typeDotColors.css },
{ value: "yours", label: "Yours" },
];
const sortOptions: { value: SortKey; label: string }[] = [
{ value: "top", label: "Top rated" },
{ value: "new", label: "Newest" },
];
const stopCardNavigation = (event: React.MouseEvent<HTMLButtonElement>) => {
event.preventDefault();
event.stopPropagation();
};
export const WorkshopApp = ({ workshopUrl }: { workshopUrl: string }) => {
const workshop = useWorkshop(workshopUrl);
const [typeFilter, setTypeFilter] = useState<TypeFilter>("all");
const [sort, setSort] = useState<SortKey>("top");
const [search, setSearch] = useState("");
const [showSubmit, setShowSubmit] = useState(false);
// Stable sort order: only re-sort when filter/sort/search or submission set changes (not vote counts)
const submissionIds = useMemo(() => workshop.submissions.map((s) => s.id).join(","), [workshop.submissions]);
const sortedIds = useMemo(() => {
const q = search.toLowerCase();
return workshop.submissions
.filter((item) => {
if (typeFilter === "yours") return item.author === workshop.user?.id;
return typeFilter === "all" || item.type === typeFilter;
})
.filter((item) => !q || item.title.toLowerCase().includes(q))
.toSorted(workshop.sorters[sort])
.map((s) => s.id);
// eslint-disable-next-line react-hooks/exhaustive-deps -- intentionally keyed on submissionIds string, not the array
}, [submissionIds, workshop.sorters, typeFilter, sort, search, workshop.user?.id]);
const visible = useMemo(() => {
const byId = new Map(workshop.submissions.map((s) => [s.id, s]));
return sortedIds.map((id) => byId.get(id)).filter(Boolean) as WorkshopSubmission[];
}, [workshop.submissions, sortedIds]);
const empty = emptyState[workshop.submissions.length === 0 ? "none" : "filtered"];
return (
<div className="mx-auto max-w-7xl px-4 pb-16">
<div className="flex flex-col gap-6 py-8 sm:flex-row sm:items-end sm:justify-between">
<div>
<h1 className="text-2xl font-semibold tracking-tight">Workshop</h1>
<p className="mt-1 text-sm text-muted-foreground">Community custom widgets and CSS themes for Homarr.</p>
</div>
<div className="flex items-center gap-2">
{workshop.user ? (
<>
<Button onClick={() => setShowSubmit(true)}>
<IconPlus size={14} /> Share yours
</Button>
<Button variant="ghost" size="sm" onClick={workshop.logout}>
<IconLogout size={14} /> {workshop.user?.name || workshop.user?.username || "Account"}
</Button>
</>
) : (
<div className="flex flex-col items-end gap-0.5">
<Button onClick={() => void workshop.login()}>
<IconBrandGithub size={14} /> Sign in with GitHub
</Button>
<p className="text-xs text-muted-foreground">Vote and share your creations</p>
</div>
)}
</div>
</div>
<div className="mb-6 flex flex-wrap items-center justify-between gap-3 rounded-lg border border-border bg-card px-3 py-2">
<div className="flex items-center gap-2">
<div className="inline-flex rounded-md bg-muted p-0.5">
{typeFilters.map((opt) => (
<button
key={opt.value}
onClick={() => setTypeFilter(opt.value)}
className={cn(
"inline-flex items-center gap-1.5 rounded px-2.5 py-1 text-xs font-medium transition-colors",
typeFilter === opt.value ? filterActiveClass : filterInactiveClass,
)}
>
{opt.dot && <span className={cn("size-2 rounded-full", opt.dot)} />}
{opt.label}
</button>
))}
</div>
<select
aria-label="Sort order"
className="h-7 rounded-md border border-input bg-transparent px-2 text-xs outline-none dark:bg-input/30"
value={sort}
onChange={(e) => setSort(e.target.value as SortKey)}
>
{sortOptions.map((o) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
))}
</select>
</div>
<div className="relative">
<IconSearch size={14} className="absolute left-2.5 top-1/2 -translate-y-1/2 text-muted-foreground" />
<input
className="h-7 w-48 rounded-md border border-input bg-transparent pl-8 text-xs outline-none placeholder:text-muted-foreground"
placeholder="Search…"
aria-label="Search submissions"
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
</div>
</div>
{workshop.error && (
<div className="mb-4 rounded-lg border border-destructive/30 bg-destructive/10 px-4 py-2.5 text-sm text-destructive">
{workshop.error}
</div>
)}
{workshop.loading && (
<div className="columns-1 gap-4 lg:columns-2 xl:columns-3 2xl:columns-4 [&>*]:mb-4">
{Array.from({ length: 6 }, (_, i) => (
<SkeletonCard key={i} hasImage={i % 3 === 0} />
))}
</div>
)}
{!workshop.loading && visible.length === 0 && (
<div className="flex flex-col items-center gap-3 rounded-lg border border-dashed border-border py-16">
<IconPackage size={28} stroke={1.5} className="text-muted-foreground" />
<div className="text-center">
<p className="text-sm font-medium">{empty.title}</p>
<p className="mt-0.5 text-xs text-muted-foreground">{empty.hint}</p>
</div>
{workshop.user && workshop.submissions.length === 0 && (
<Button size="sm" onClick={() => setShowSubmit(true)}>
<IconPlus size={14} /> Create submission
</Button>
)}
</div>
)}
<div className="columns-1 gap-4 lg:columns-2 xl:columns-3 [&>*]:mb-4">
{visible.map((submission) => (
<SubmissionCard
key={submission.id}
submission={submission}
pb={workshop.pb}
userVote={workshop.votes[submission.id]?.value}
currentUserId={workshop.user?.id}
onVote={workshop.vote}
onReport={workshop.report}
onDelete={workshop.deleteSubmission}
/>
))}
</div>
{showSubmit && (
<SubmitForm
onClose={() => setShowSubmit(false)}
onSubmit={async (input) => {
if (await workshop.submit(input)) setShowSubmit(false);
}}
/>
)}
</div>
);
};
interface SubmissionCardProps {
submission: WorkshopSubmission;
pb: ReturnType<typeof useWorkshop>["pb"];
userVote?: 1 | -1;
currentUserId?: string;
onVote: (submissionId: string, value: 1 | -1) => void;
onReport: (submissionId: string, reason: string) => void;
onDelete: (submissionId: string) => Promise<boolean>;
}
const SubmissionCard = ({
submission,
pb,
userVote,
currentUserId,
onVote,
onReport,
onDelete,
}: SubmissionCardProps) => {
const isOwner = currentUserId === submission.author;
const [copied, setCopied] = useState(false);
const [confirmDelete, setConfirmDelete] = useState(false);
const confirmTimer = useRef<ReturnType<typeof setTimeout>>(null);
const copyTimer = useRef<ReturnType<typeof setTimeout>>(null);
const score = submission.upvotes - submission.downvotes;
const { Icon: CopyIcon, label: copyLabel, iconClass: copyIconClass } = copyState[Number(copied)];
const screenshotUrls = useMemo(
() => submission.screenshots?.map((f) => getSubmissionFileUrl(pb.baseURL, submission.id, f)) ?? [],
[submission, pb],
);
useEffect(
() => () => {
if (copyTimer.current) clearTimeout(copyTimer.current);
if (confirmTimer.current) clearTimeout(confirmTimer.current);
},
[],
);
const [copyFailed, setCopyFailed] = useState(false);
const handleCopy = async () => {
try {
await navigator.clipboard.writeText(submission.content);
setCopied(true);
setCopyFailed(false);
if (copyTimer.current) clearTimeout(copyTimer.current);
copyTimer.current = setTimeout(() => setCopied(false), 1500);
} catch {
setCopyFailed(true);
setTimeout(() => setCopyFailed(false), 2000);
}
};
const handleDelete = () => {
if (!confirmDelete) {
setConfirmDelete(true);
if (confirmTimer.current) clearTimeout(confirmTimer.current);
confirmTimer.current = setTimeout(() => setConfirmDelete(false), 3000);
return;
}
setConfirmDelete(false);
void onDelete(submission.id);
};
const hasScreenshots = screenshotUrls.length > 0;
const TypeIcon = typeIcons[submission.type];
return (
<Card className="relative transition-transform duration-150 hover:scale-[1.02]">
<a href={`/workshop/${submission.id}/`} className="block">
{hasScreenshots ? (
<div className="relative">
<Badge
variant="secondary"
className="absolute left-2 top-2 z-10 gap-1.5 bg-background/80 px-2 backdrop-blur-sm"
>
<span className={cn("size-2 rounded-full", typeDotColors[submission.type])} />
{typeLabels[submission.type]}
</Badge>
<ScreenshotGallery urls={screenshotUrls} title={submission.title} />
</div>
) : (
<div className={cn("flex items-center justify-center py-6", typeBgColors[submission.type])}>
<TypeIcon size={32} className="text-muted-foreground/20" />
</div>
)}
</a>
<CardHeader>
<div className="flex items-center gap-2">
<a href={`/workshop/${submission.id}/`} className="min-w-0 hover:underline" title={submission.title}>
<CardTitle className="truncate">{submission.title}</CardTitle>
</a>
{!hasScreenshots && (
<Badge variant="secondary" className="shrink-0 gap-1.5 px-2">
<span className={cn("size-2 rounded-full", typeDotColors[submission.type])} />
{typeLabels[submission.type]}
</Badge>
)}
</div>
<CardDescription className="text-xs">
{submission.authorName} · v{submission.version} · {formatRelativeTime(submission.created)}
</CardDescription>
<CardAction>
<div className="flex items-center gap-px rounded-md border border-border bg-muted/40 p-px">
<button
onClick={() => void onVote(submission.id, 1)}
aria-label="Upvote"
className={cn(
"flex items-center justify-center rounded-[5px] p-1 transition-colors hover:bg-accent",
userVote === 1 && "bg-primary/15 text-primary",
)}
>
<IconArrowBigUp size={14} />
</button>
<span
className={cn(
"min-w-5 text-center text-xs font-semibold tabular-nums",
score > 0 && "text-foreground",
score < 0 && "text-destructive",
)}
>
{score}
</span>
<button
onClick={() => void onVote(submission.id, -1)}
aria-label="Downvote"
className={cn(
"flex items-center justify-center rounded-[5px] p-1 transition-colors hover:bg-accent",
userVote === -1 && "bg-primary/15 text-primary",
)}
>
<IconArrowBigDown size={14} />
</button>
</div>
</CardAction>
</CardHeader>
<CardContent>
{submission.description && (
<p className="line-clamp-2 text-xs leading-relaxed text-muted-foreground">{submission.description}</p>
)}
</CardContent>
<CardFooter className="flex-col gap-0 p-0">
<div className="flex w-full flex-wrap items-center gap-0.5 px-2 py-1.5">
<Button
variant="ghost"
size="xs"
onClick={() => void handleCopy()}
className="text-muted-foreground hover:text-foreground"
>
{copyFailed ? (
<>
<IconX size={13} className="text-destructive" /> Failed
</>
) : (
<>
<CopyIcon size={13} className={copyIconClass} /> {copyLabel}
</>
)}
</Button>
{submission.type === "widget" && (
<Button
variant="ghost"
size="xs"
onClick={() => downloadSubmissionJson(submission)}
className="text-muted-foreground hover:text-foreground"
>
<IconDownload size={13} /> Download
</Button>
)}
{submission.commentCount > 0 && (
<a
href={`/workshop/${submission.id}/`}
className="inline-flex items-center gap-1 rounded px-1.5 py-0.5 text-xs text-muted-foreground hover:text-foreground"
>
<IconMessage size={13} /> {submission.commentCount}
</a>
)}
<div className="ml-auto flex items-center gap-px">
{isOwner && (
<button
className={cn(
"flex items-center gap-1 rounded px-1 py-0.5 text-xs transition-colors",
confirmDelete
? "bg-destructive/10 text-destructive"
: "text-muted-foreground/60 hover:text-destructive",
)}
onClick={handleDelete}
aria-label="Delete"
>
<IconTrash size={13} />
{confirmDelete && "Confirm?"}
</button>
)}
<button
className="flex items-center justify-center rounded p-1 text-muted-foreground/60 transition-colors hover:text-destructive"
onClick={() => void onReport(submission.id, "flagged")}
aria-label="Report"
>
<IconFlag size={13} />
</button>
</div>
</div>
</CardFooter>
</Card>
);
};
const SkeletonCard = ({ hasImage }: { hasImage: boolean }) => (
<Card>
{hasImage && <div className="aspect-video animate-pulse bg-muted" />}
<CardHeader>
<div className="flex items-center gap-2">
<div className="h-4 w-32 animate-pulse rounded bg-muted" />
<div className="h-4 w-12 animate-pulse rounded-full bg-muted" />
</div>
<div className="h-3 w-40 animate-pulse rounded bg-muted" />
</CardHeader>
<CardContent>
<div className="space-y-1.5">
<div className="h-3 w-full animate-pulse rounded bg-muted" />
<div className="h-3 w-2/3 animate-pulse rounded bg-muted" />
</div>
</CardContent>
<CardFooter className="p-0">
<div className="flex w-full gap-2 px-2 py-2">
<div className="h-5 w-12 animate-pulse rounded bg-muted" />
<div className="h-5 w-12 animate-pulse rounded bg-muted" />
<div className="h-5 w-16 animate-pulse rounded bg-muted" />
</div>
</CardFooter>
</Card>
);
const ScreenshotGallery = ({ urls, title }: { urls: string[]; title: string }) => {
const [idx, setIdx] = useState(0);
const dotClass = ["bg-white/40", "bg-white"];
return (
<div className="group/gallery relative">
<div className="aspect-video overflow-hidden bg-muted">
<img
className="h-full w-full object-cover"
src={urls[idx]}
alt={`${title} screenshot ${idx + 1}`}
loading="lazy"
/>
</div>
{urls.length > 1 && (
<>
<button
className="absolute left-2 top-1/2 flex size-6 -translate-y-1/2 items-center justify-center rounded-md bg-background/80 opacity-60 shadow transition-opacity hover:opacity-100"
onClick={(event) => {
stopCardNavigation(event);
setIdx((i) => (i - 1 + urls.length) % urls.length);
}}
aria-label="Previous screenshot"
>
<IconChevronLeft size={14} />
</button>
<button
className="absolute right-2 top-1/2 flex size-6 -translate-y-1/2 items-center justify-center rounded-md bg-background/80 opacity-60 shadow transition-opacity hover:opacity-100"
onClick={(event) => {
stopCardNavigation(event);
setIdx((i) => (i + 1) % urls.length);
}}
aria-label="Next screenshot"
>
<IconChevronRight size={14} />
</button>
<div className="absolute bottom-2 left-1/2 flex -translate-x-1/2 gap-1 rounded-full bg-black/50 px-2 py-1">
{urls.map((_, i) => (
<button
key={i}
onClick={(event) => {
stopCardNavigation(event);
setIdx(i);
}}
aria-label={`Screenshot ${i + 1}`}
className={cn("size-1.5 rounded-full transition-all", dotClass[Number(i === idx)])}
/>
))}
</div>
</>
)}
</div>
);
};
@@ -1,6 +0,0 @@
import dayjs from "dayjs";
import relativeTime from "dayjs/plugin/relativeTime";
dayjs.extend(relativeTime);
export const formatRelativeTime = (date: string) => dayjs(date).fromNow();
@@ -1,349 +0,0 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import dayjs from "dayjs";
import type { WorkshopComment, WorkshopSubmission, WorkshopVote } from "@site/src/lib/pocketbase";
import { getPocketBase } from "@site/src/lib/pocketbase";
import type { SubmissionType } from "@site/src/lib/workshop-schema";
import { schemaVersionByType, validateSubmissionContent } from "@site/src/lib/workshop-schema";
import { errorMessage } from "@site/src/lib/utils";
import { voteDelta } from "./workshop-utils";
export type SortKey = "top" | "new";
export type TypeFilter = "all" | "yours" | SubmissionType;
export interface SubmitInput {
type: SubmissionType;
title: string;
description: string;
content: string;
screenshots: File[];
}
export interface CommentActions {
fetch: (submissionId: string) => Promise<WorkshopComment[]>;
add: (submissionId: string, content: string) => Promise<WorkshopComment | null>;
update: (commentId: string, content: string) => Promise<WorkshopComment | null>;
delete: (commentId: string) => Promise<boolean>;
}
const sorters: Record<SortKey, (a: WorkshopSubmission, b: WorkshopSubmission) => number> = {
top: (a, b) => b.upvotes - b.downvotes - (a.upvotes - a.downvotes),
new: (a, b) => dayjs(b.created).valueOf() - dayjs(a.created).valueOf(),
};
const isNotFoundError = (error: unknown) =>
typeof error === "object" && error !== null && "status" in error && error.status === 404;
export const useWorkshop = (workshopUrl: string) => {
const pb = useMemo(() => getPocketBase(workshopUrl), [workshopUrl]);
const [submissions, setSubmissions] = useState<WorkshopSubmission[]>([]);
const [votes, setVotes] = useState<Record<string, WorkshopVote>>({});
const [user, setUser] = useState(pb.authStore.record);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const votingIds = useRef(new Set<string>());
const refreshVotes = useCallback(async () => {
if (!pb.authStore.isValid || !pb.authStore.record) {
setVotes({});
return;
}
const rows = await pb.collection("votes").getFullList<WorkshopVote>({
filter: pb.filter("user = {:id}", { id: pb.authStore.record.id }),
});
setVotes(Object.fromEntries(rows.map((row) => [row.submission, row])));
}, [pb]);
const refresh = useCallback(async () => {
setLoading(true);
setError(null);
try {
setSubmissions(await pb.collection("marketplace").getFullList<WorkshopSubmission>({ sort: "-created" }));
await refreshVotes();
} catch (caught) {
setError(errorMessage(caught, "Failed to load the workshop"));
} finally {
setLoading(false);
}
}, [pb, refreshVotes]);
useEffect(() => {
let cancelled = false;
const unsubscribe = pb.authStore.onChange(() => {
setUser(pb.authStore.record);
if (!pb.authStore.isValid) setVotes({});
});
const load = async () => {
if (pb.authStore.isValid) {
try {
await pb.collection("users").authRefresh();
} catch {
pb.authStore.clear();
}
}
if (!cancelled) await refresh();
};
void load();
return () => {
cancelled = true;
unsubscribe();
};
}, [pb, refresh]);
const ensureAuth = useCallback(async () => {
if (pb.authStore.isValid) {
try {
await pb.collection("users").authRefresh();
return true;
} catch {
pb.authStore.clear();
}
}
await pb.collection("users").authWithOAuth2({ provider: "github" });
await refreshVotes();
return pb.authStore.isValid;
}, [pb, refreshVotes]);
const requireUserId = useCallback(
async (action: string) => {
let authenticated = false;
try {
authenticated = await ensureAuth();
} catch (caught) {
setError(errorMessage(caught, `Sign in to ${action}`));
return null;
}
if (!authenticated) {
setError(`Sign in to ${action}`);
return null;
}
const userId = pb.authStore.record?.id;
if (!userId) {
setError(`Sign in to ${action}`);
return null;
}
return userId;
},
[pb, ensureAuth],
);
const login = useCallback(async () => {
try {
await ensureAuth();
setError(null);
} catch (caught) {
setError(errorMessage(caught, "Sign in failed"));
}
}, [ensureAuth]);
const vote = useCallback(
async (submissionId: string, value: 1 | -1) => {
if (votingIds.current.has(submissionId)) return;
votingIds.current.add(submissionId);
const userId = await requireUserId("vote");
if (!userId) {
votingIds.current.delete(submissionId);
return;
}
const prev = votes[submissionId];
const isToggleOff = prev?.value === value;
const [upD, downD] = voteDelta(prev?.value, value);
setVotes((v) => {
const next = { ...v };
if (isToggleOff) delete next[submissionId];
else
next[submissionId] = {
...(prev ?? { id: "", submission: submissionId, user: userId, created: "", updated: "" }),
value,
} as WorkshopVote;
return next;
});
setSubmissions((s) =>
s.map((sub) =>
sub.id === submissionId ? { ...sub, upvotes: sub.upvotes + upD, downvotes: sub.downvotes + downD } : sub,
),
);
try {
const existing = prev?.id ? prev : undefined;
if (!existing) {
const created = await pb
.collection("votes")
.create<WorkshopVote>({ submission: submissionId, value, user: userId });
setVotes((current) => ({ ...current, [submissionId]: created }));
} else if (isToggleOff) {
try {
await pb.collection("votes").delete(existing.id);
} catch (caught) {
if (!isNotFoundError(caught)) throw caught;
}
} else {
try {
const updated = await pb.collection("votes").update<WorkshopVote>(existing.id, { value });
setVotes((current) => ({ ...current, [submissionId]: updated }));
} catch (caught) {
if (!isNotFoundError(caught)) throw caught;
const created = await pb
.collection("votes")
.create<WorkshopVote>({ submission: submissionId, value, user: userId });
setVotes((current) => ({ ...current, [submissionId]: created }));
}
}
} catch (caught) {
setVotes((v) => {
const next = { ...v };
if (prev) next[submissionId] = prev;
else delete next[submissionId];
return next;
});
setSubmissions((s) =>
s.map((sub) =>
sub.id === submissionId ? { ...sub, upvotes: sub.upvotes - upD, downvotes: sub.downvotes - downD } : sub,
),
);
setError(errorMessage(caught, "Failed to register your vote"));
} finally {
votingIds.current.delete(submissionId);
}
},
[pb, votes, requireUserId],
);
const report = useCallback(
async (submissionId: string, reason: string) => {
try {
const userId = await requireUserId("report");
if (!userId) return;
await pb.collection("reports").create({ submission: submissionId, reason, user: userId });
} catch (caught) {
setError(errorMessage(caught, "Failed to submit your report"));
}
},
[pb, requireUserId],
);
const deleteSubmission = useCallback(
async (submissionId: string) => {
try {
await pb.collection("submissions").delete(submissionId);
setSubmissions((prev) => prev.filter((s) => s.id !== submissionId));
return true;
} catch (caught) {
setError(errorMessage(caught, "Failed to delete submission"));
return false;
}
},
[pb],
);
const submit = useCallback(
async (input: SubmitInput): Promise<boolean> => {
const validation = validateSubmissionContent(input.type, input.content);
if (!validation.success) throw new Error(validation.error);
if (!(await ensureAuth())) throw new Error("Sign in required to submit");
const userId = pb.authStore.record?.id;
if (!userId) throw new Error("Sign in required to submit");
const data = new FormData();
data.set("type", input.type);
data.set("title", input.title);
data.set("description", input.description);
data.set("schemaVersion", schemaVersionByType[input.type]);
data.set("content", input.content);
data.set("author", userId);
for (const file of input.screenshots) data.append("screenshots", file);
await pb.collection("submissions").create(data);
await refresh();
return true;
},
[pb, ensureAuth, refresh],
);
const fetchComments = useCallback(
(submissionId: string) =>
pb.collection("comments").getFullList<WorkshopComment>({
filter: pb.filter("submission = {:id}", { id: submissionId }),
sort: "-created",
expand: "author",
}),
[pb],
);
const addComment = useCallback(
async (submissionId: string, content: string) => {
const userId = await requireUserId("comment");
if (!userId) return null;
try {
return await pb
.collection("comments")
.create<WorkshopComment>({ submission: submissionId, content, author: userId }, { expand: "author" });
} catch (caught) {
setError(errorMessage(caught, "Failed to post comment"));
return null;
}
},
[pb, requireUserId],
);
const updateComment = useCallback(
async (commentId: string, content: string) => {
try {
return await pb.collection("comments").update<WorkshopComment>(commentId, { content }, { expand: "author" });
} catch (caught) {
setError(errorMessage(caught, "Failed to update comment"));
return null;
}
},
[pb],
);
const deleteComment = useCallback(
async (commentId: string) => {
try {
await pb.collection("comments").delete(commentId);
return true;
} catch (caught) {
setError(errorMessage(caught, "Failed to delete comment"));
return false;
}
},
[pb],
);
const logout = useCallback(() => pb.authStore.clear(), [pb]);
const comments = useMemo<CommentActions>(
() => ({
fetch: fetchComments,
add: addComment,
update: updateComment,
delete: deleteComment,
}),
[fetchComments, addComment, updateComment, deleteComment],
);
return {
pb,
submissions,
votes,
user,
loading,
error,
sorters,
comments,
refresh,
login,
logout,
vote,
report,
submit,
deleteSubmission,
};
};
@@ -1,16 +0,0 @@
import type { WorkshopSubmission } from "@site/src/lib/pocketbase";
export const downloadSubmissionJson = (submission: WorkshopSubmission) => {
const url = URL.createObjectURL(new Blob([submission.content], { type: "application/json" }));
Object.assign(document.createElement("a"), {
href: url,
download: `${submission.title.replace(/[^a-z0-9-_]+/gi, "-").toLowerCase()}.json`,
}).click();
URL.revokeObjectURL(url);
};
export const voteDelta = (prev: 1 | -1 | undefined, next: 1 | -1): [up: number, down: number] => {
if (!prev) return next === 1 ? [1, 0] : [0, 1];
if (prev === next) return next === 1 ? [-1, 0] : [0, -1];
return next === 1 ? [1, -1] : [-1, 1];
};
+14 -179
View File
@@ -4,53 +4,9 @@
@custom-variant dark (&:where([data-theme="dark"], [data-theme="dark"] *));
/* shadcn preset b4gOK4dHc — base-nova style, mist base color */
@theme inline {
@theme {
--spacing-128: 32rem;
--breakpoint-3xl: 1697px;
--font-heading: var(--font-sans);
--font-sans: "Work Sans", sans-serif;
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--color-chart-1: var(--chart-1);
--color-chart-2: var(--chart-2);
--color-chart-3: var(--chart-3);
--color-chart-4: var(--chart-4);
--color-chart-5: var(--chart-5);
--color-sidebar: var(--sidebar);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring);
--radius-sm: calc(var(--radius) * 0.6);
--radius-md: calc(var(--radius) * 0.8);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) * 1.4);
--radius-2xl: calc(var(--radius) * 1.8);
--radius-3xl: calc(var(--radius) * 2.2);
--radius-4xl: calc(var(--radius) * 2.6);
}
:root {
@@ -67,52 +23,14 @@
--aa-primary-color-rgb: #fa5252 !important;
--aa-muted-color-rgb: #fa5252 !important;
--aa-background-color-rgb: 200, 200, 200 !important;
--aa-background-color-rgb: 200,200,200 !important;
--ifm-font-family-base: "Work Sans", sans-serif;
--ifm-font-family-base: 'Work Sans', sans-serif;
--ifm-color-content-inverse: var(--ifm-color-emphasis-100);
/* Bridge Docusaurus to shadcn theme */
--ifm-background-color: var(--background);
--ifm-font-color-base: var(--foreground);
--ifm-navbar-background-color: var(--card);
--ifm-footer-background-color: var(--card);
--radius: 0.45rem;
--background: oklch(1 0 0);
--foreground: oklch(0.148 0.004 228.8);
--card: oklch(1 0 0);
--card-foreground: oklch(0.148 0.004 228.8);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.148 0.004 228.8);
--primary: oklch(0.514 0.222 16.935);
--primary-foreground: oklch(0.969 0.015 12.422);
--secondary: oklch(0.967 0.001 286.375);
--secondary-foreground: oklch(0.21 0.006 285.885);
--muted: oklch(0.963 0.002 197.1);
--muted-foreground: oklch(0.56 0.021 213.5);
--accent: oklch(0.963 0.002 197.1);
--accent-foreground: oklch(0.218 0.008 223.9);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.925 0.005 214.3);
--input: oklch(0.925 0.005 214.3);
--ring: oklch(0.723 0.014 214.4);
--chart-1: oklch(0.808 0.114 19.571);
--chart-2: oklch(0.637 0.237 25.331);
--chart-3: oklch(0.577 0.245 27.325);
--chart-4: oklch(0.505 0.213 27.518);
--chart-5: oklch(0.444 0.177 26.899);
--sidebar: oklch(0.987 0.002 197.1);
--sidebar-foreground: oklch(0.148 0.004 228.8);
--sidebar-primary: oklch(0.586 0.253 17.585);
--sidebar-primary-foreground: oklch(0.969 0.015 12.422);
--sidebar-accent: oklch(0.963 0.002 197.1);
--sidebar-accent-foreground: oklch(0.218 0.008 223.9);
--sidebar-border: oklch(0.925 0.005 214.3);
--sidebar-ring: oklch(0.723 0.014 214.4);
}
[data-theme="dark"] {
/* For readability concerns, you should choose a lighter palette in dark mode. */
[data-theme='dark'] {
--ifm-color-primary: #fa5252;
--ifm-color-primary-dark: #f93232;
--ifm-color-primary-darker: #f92222;
@@ -121,68 +39,13 @@
--ifm-color-primary-lighter: #fb8282;
--ifm-color-primary-lightest: #fdc3c3;
--docusaurus-highlighted-code-line-bg: rgba(0, 0, 0, 0.3);
--homarr-secondary-bg: oklch(0.218 0.008 223.9);
--homarr-secondary-bg: #181818;
--ifm-color-content-inverse: var(--ifm-color-emphasis-100);
--aa-primary-color-rgb: #fa5252 !important;
--aa-muted-color-rgb: #fa5252 !important;
--aa-background-color-rgb: 24, 24, 24 !important;
--background: oklch(0.148 0.004 228.8);
--foreground: oklch(0.987 0.002 197.1);
--card: oklch(0.218 0.008 223.9);
--card-foreground: oklch(0.987 0.002 197.1);
--popover: oklch(0.218 0.008 223.9);
--popover-foreground: oklch(0.987 0.002 197.1);
--primary: oklch(0.455 0.188 13.697);
--primary-foreground: oklch(0.969 0.015 12.422);
--secondary: oklch(0.274 0.006 286.033);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.275 0.011 216.9);
--muted-foreground: oklch(0.723 0.014 214.4);
--accent: oklch(0.275 0.011 216.9);
--accent-foreground: oklch(0.987 0.002 197.1);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.56 0.021 213.5);
--chart-1: oklch(0.808 0.114 19.571);
--chart-2: oklch(0.637 0.237 25.331);
--chart-3: oklch(0.577 0.245 27.325);
--chart-4: oklch(0.505 0.213 27.518);
--chart-5: oklch(0.444 0.177 26.899);
--sidebar: oklch(0.21 0.006 285.885);
--sidebar-foreground: oklch(0.987 0.002 197.1);
--sidebar-primary: oklch(0.645 0.246 16.439);
--sidebar-primary-foreground: oklch(0.969 0.015 12.422);
--sidebar-accent: oklch(0.275 0.011 216.9);
--sidebar-accent-foreground: oklch(0.987 0.002 197.1);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.56 0.021 213.5);
}
/* Reset form elements that don't inherit by default (no preflight in Docusaurus).
Must live inside @layer theme so layer(utilities) wins in the cascade. */
@layer theme {
:where(main.marketplace, [data-slot="dialog-portal"]) :where(button, input, select, textarea) {
font-family: inherit;
font-size: inherit;
line-height: inherit;
color: inherit;
background: transparent;
border: none;
padding: 0;
margin: 0;
}
:where(main.marketplace, [data-slot="dialog-portal"]) :where(button) {
cursor: pointer;
}
:where([data-slot="dialog-portal"]) {
font-family: var(--ifm-font-family-base);
color: var(--foreground);
}
--aa-background-color-rgb: 24,24,24 !important;
}
.aa-DetachedOverlay {
@@ -217,7 +80,7 @@
}
.footer__copyright span.copyright_text {
opacity: 0.6;
opacity: .6;
}
.medium-zoom-overlay {
@@ -234,7 +97,7 @@
.announcementBar_node_modules-\@docusaurus-theme-classic-lib-theme-AnnouncementBar-styles-module {
background-color: transparent !important;
background-image: radial-gradient(circle, rgb(253, 126, 20) 0%, rgb(250, 82, 82) 100%) !important;
background-image: radial-gradient(circle, rgb(253, 126, 20) 0%, rgb(250, 82, 82) 100%) !important;
font-weight: 600;
}
@@ -272,7 +135,7 @@ html[data-theme="dark"] div#__docusaurus div[role="banner"] button svg {
color: var(--ifm-color-primary);
}
:is(p, h3, h4, h5, h6):has(+ :is(ul, ol)) {
:is(p, h3, h4, h5, h6):has(+ :is(ul, ol)){
margin-bottom: 4px !important;
}
@@ -301,6 +164,7 @@ html[data-theme="dark"] div#__docusaurus div[role="banner"] button svg {
padding: 6px 12px;
}
.hero-cards {
transform: rotate(20deg) scale(0.8) skew(21deg);
-webkit-transform: rotate(20deg) scale(0.8) skew(21deg);
@@ -316,7 +180,7 @@ html[data-theme="dark"] div#__docusaurus div[role="banner"] button svg {
}
.app-card img {
transition: transform 0.1s ease-in-out;
transition: transform 0.1s ease-in-out;
}
.app-card:hover img {
@@ -324,7 +188,7 @@ html[data-theme="dark"] div#__docusaurus div[role="banner"] button svg {
}
.hover-animation {
animation: hover-animation 5s ease-in-out forwards;
animation: hover-animation 5s ease-in-out forwards;
animation-iteration-count: infinite;
}
@@ -339,32 +203,3 @@ html[data-theme="dark"] div#__docusaurus div[role="banner"] button svg {
transform: translateY(0);
}
}
.submit-step-enter {
animation: submit-step-enter 0.2s ease-in-out forwards;
}
.submit-step-from-right {
--submit-step-x: 80px;
}
.submit-step-from-left {
--submit-step-x: -80px;
}
@media (prefers-reduced-motion: reduce) {
.submit-step-enter {
animation: none;
}
}
@keyframes submit-step-enter {
from {
transform: translateX(var(--submit-step-x, 80px));
opacity: 0;
}
to {
transform: translateX(0);
opacity: 1;
}
}
-52
View File
@@ -1,52 +0,0 @@
import PocketBase from "pocketbase";
let client: PocketBase | undefined;
let clientUrl: string | undefined;
export const getPocketBase = (url: string): PocketBase => {
if (!client || clientUrl !== url) {
client = new PocketBase(url);
clientUrl = url;
}
return client;
};
export const getSubmissionFileUrl = (baseUrl: string, submissionId: string, filename: string) =>
`${baseUrl}/api/files/submissions/${submissionId}/${encodeURIComponent(filename)}`;
export interface WorkshopSubmission {
id: string;
collectionId: string;
collectionName: string;
type: "css" | "widget";
title: string;
description: string;
schemaVersion: string;
content: string;
screenshots: string[];
upvotes: number;
downvotes: number;
commentCount: number;
version: number;
changelog: string;
author: string;
authorName: string;
created: string;
}
export interface WorkshopVote {
id: string;
submission: string;
user: string;
value: 1 | -1;
}
export interface WorkshopComment {
id: string;
submission: string;
author: string;
content: string;
created: string;
updated: string;
expand?: { author?: { id: string; name?: string; username?: string } };
}
-11
View File
@@ -1,11 +0,0 @@
import type { ClassValue } from "clsx";
import { clsx } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
export function errorMessage(caught: unknown, fallback: string) {
return caught instanceof Error ? caught.message : fallback;
}
-49
View File
@@ -1,49 +0,0 @@
import { describe, expect, it } from "vitest";
import { validateSubmissionContent } from "./workshop-schema";
const validWidget = {
$schema: "homarr-custom-widget-v2",
name: "Example",
url: "https://example.com/api",
authType: "none",
method: "GET",
displayType: "singleValue",
displayConfig: { type: "singleValue", jsonPath: "$.count", label: "Count", unit: "" },
};
describe("validateSubmissionContent", () => {
it("accepts a valid homarr-custom-widget-v2 JSON", () => {
expect(validateSubmissionContent("widget", JSON.stringify(validWidget)).success).toBe(true);
});
it("rejects an old/unknown widget schema version", () => {
const result = validateSubmissionContent(
"widget",
JSON.stringify({ ...validWidget, $schema: "homarr-custom-widget-v1" }),
);
expect(result.success).toBe(false);
});
it("rejects widgets without a schema version", () => {
const widgetWithoutSchema = {
name: validWidget.name,
url: validWidget.url,
authType: validWidget.authType,
method: validWidget.method,
displayType: validWidget.displayType,
displayConfig: validWidget.displayConfig,
};
expect(validateSubmissionContent("widget", JSON.stringify(widgetWithoutSchema)).success).toBe(false);
});
it("rejects malformed widget JSON", () => {
expect(validateSubmissionContent("widget", "{ not json").success).toBe(false);
});
it("accepts non-empty CSS and rejects empty CSS", () => {
expect(validateSubmissionContent("css", ".card { color: red; }").success).toBe(true);
expect(validateSubmissionContent("css", " ").success).toBe(false);
});
});
-78
View File
@@ -1,78 +0,0 @@
import { z } from "zod";
export type SubmissionType = "css" | "widget";
export const WIDGET_SCHEMA_VERSION = "homarr-custom-widget-v2";
export const CSS_SCHEMA_VERSION = "homarr-custom-css-v1";
export const MAX_CSS_LENGTH = 16384;
export type WorkshopValidationResult = { success: true; data: unknown } | { success: false; error: string };
const authTypes = ["none", "bearer", "basic", "apiKeyHeader", "apiKeyQuery"] as const;
const methods = ["GET", "POST", "PUT", "DELETE", "PATCH"] as const;
const displayTypes = [
"singleValue",
"keyValue",
"table",
"statGrid",
"progressBars",
"statusIndicator",
"countGrid",
"raw",
"actionButton",
"customJsx",
] as const;
const widgetSchema = z
.object({
$schema: z.literal(WIDGET_SCHEMA_VERSION),
name: z.string().min(1).max(128),
url: z.string().min(1),
authType: z.enum(authTypes),
method: z.enum(methods),
displayType: z.enum(displayTypes),
displayConfig: z.object({ type: z.enum(displayTypes) }),
})
.refine((widget) => widget.displayConfig.type === widget.displayType, {
message: "displayConfig.type must match displayType",
});
const validateWidgetContent = (raw: string): WorkshopValidationResult => {
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch {
return { success: false, error: "Content is not valid JSON" };
}
const result = widgetSchema.safeParse(parsed);
if (!result.success) {
return { success: false, error: result.error.issues[0]?.message ?? "Invalid custom widget JSON" };
}
return { success: true, data: result.data };
};
const validateCssContent = (raw: string): WorkshopValidationResult => {
if (raw.trim().length === 0) {
return { success: false, error: "CSS cannot be empty" };
}
if (raw.length > MAX_CSS_LENGTH) {
return { success: false, error: `CSS must be at most ${MAX_CSS_LENGTH} characters` };
}
return { success: true, data: raw };
};
export const submissionValidators = {
widget: validateWidgetContent,
css: validateCssContent,
} satisfies Record<SubmissionType, (raw: string) => WorkshopValidationResult>;
export const schemaVersionByType = {
widget: WIDGET_SCHEMA_VERSION,
css: CSS_SCHEMA_VERSION,
} satisfies Record<SubmissionType, string>;
export const validateSubmissionContent = (type: SubmissionType, raw: string): WorkshopValidationResult =>
submissionValidators[type](raw);
-22
View File
@@ -1,22 +0,0 @@
import React from "react";
import BrowserOnly from "@docusaurus/BrowserOnly";
import useDocusaurusContext from "@docusaurus/useDocusaurusContext";
import Layout from "@theme/Layout";
export default function WorkshopPage() {
const { siteConfig } = useDocusaurusContext();
const configuredWorkshopUrl = (siteConfig.customFields?.workshopUrl as string | undefined) ?? "";
return (
<Layout title="Workshop" description="Community custom CSS and custom widgets for Homarr">
<main className="marketplace bg-background text-foreground min-h-[80vh]">
<BrowserOnly fallback={<div style={{ minHeight: "50vh" }} />}>
{() => {
const { WorkshopApp } = require("@site/src/components/workshop/WorkshopApp");
return <WorkshopApp workshopUrl={configuredWorkshopUrl || window.location.origin} />;
}}
</BrowserOnly>
</main>
</Layout>
);
}
-1
View File
@@ -15,7 +15,6 @@
"jsx": "preserve",
"incremental": true,
"paths": {
"@/*": ["./src/*"],
"@site/*": ["./*"]
},
},
@@ -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,7 +11,6 @@ import { IconInfoCircle } from "@tabler/icons-react";
import { useForm } from "@homarr/form";
import { useI18n, useScopedI18n } from "@homarr/translation/client";
import { InstallCssFromWorkshopButton } from "~/components/workshop-install/install-from-workshop-buttons";
import type { Board } from "../../_types";
import { useSavePartialSettingsMutation } from "./_shared";
import classes from "./customcss.module.css";
@@ -46,8 +45,7 @@ export const CustomCssSettingsContent = ({ board }: Props) => {
{customCssT("customClassesAlert.description")}
</Alert>
<Group justify="space-between">
<InstallCssFromWorkshopButton onSelect={(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);
@@ -1,6 +1,5 @@
"use client";
import { useEffect, useState } from "react";
import { Button, Popover, Stack, Text } from "@mantine/core";
import { useDisclosure } from "@mantine/hooks";
import { IconCopy, IconSparkles } from "@tabler/icons-react";
@@ -297,11 +296,8 @@ interface CopyAiPromptButtonProps {
export const CopyAiPromptButton = ({ rawResponse, currentConfig }: CopyAiPromptButtonProps) => {
const t = useScopedI18n("customWidget");
const [opened, { open, close }] = useDisclosure(false);
const [mounted, setMounted] = useState(false);
const { data: schema, isLoading } = clientApi.customWidget.schema.useQuery();
useEffect(() => setMounted(true), []);
const handleCopy = async () => {
if (!schema) return;
const prompt = buildAiPrompt(schema, rawResponse, currentConfig);
@@ -321,8 +317,8 @@ export const CopyAiPromptButton = ({ rawResponse, currentConfig }: CopyAiPromptB
variant="light"
leftSection={<IconSparkles size={16} />}
onClick={open}
loading={!mounted || isLoading}
disabled={!mounted || isLoading || !schema}
loading={isLoading}
disabled={!schema}
fullWidth
size="sm"
>
@@ -30,11 +30,9 @@ import { useScopedI18n } from "@homarr/translation/client";
import type {
CustomWidgetAuthType,
CustomWidgetDisplayType,
CustomWidgetImport,
CustomWidgetMethod,
CustomWidgetSecretKind,
} from "@homarr/validation/custom-widget";
import { customWidgetImportSchema } from "@homarr/validation/custom-widget";
import type { displayConfigSchema } from "@homarr/validation/custom-widget";
import { JsonPathTreePicker } from "@homarr/widgets/_inputs/json-path-tree-picker";
@@ -469,90 +467,6 @@ const serverToFormFieldMap: Record<string, Record<string, string>> = {
customJsx: { template: "template" },
};
const importToFormValues: Record<string, (config: Record<string, unknown>) => Record<string, unknown>> = {
singleValue: (c) => ({
jsonPath: c.jsonPath,
label: c.label ?? "",
unit: c.unit ?? "",
valueSize: c.valueSize ?? "lg",
labelPosition: c.labelPosition ?? "below",
}),
keyValue: (c) => ({
mappings:
(c.mappings as Array<Record<string, string>>)?.map((m) => ({
label: m.label,
jsonPath: m.jsonPath,
unit: m.unit ?? "",
})) ?? [],
kvLayout: c.layout ?? "list",
kvColumns: c.columns ?? 2,
}),
table: (c) => ({
tablePath: c.tablePath,
columns: c.columns ?? [],
striped: c.striped ?? true,
compact: c.compact ?? false,
}),
statGrid: (c) => ({
statGridItems: c.items ?? [],
statGridColumns: c.columns ?? 2,
cardStyle: c.cardStyle ?? "filled",
}),
progressBars: (c) => ({
progressBars:
(c.bars as Array<Record<string, unknown>>)?.map((b) => ({
label: b.label ?? "",
valuePath: b.valuePath ?? "",
maxPath: b.maxPath ?? "",
unit: b.unit ?? "",
color: b.color ?? "blue",
})) ?? [],
showPercentage: c.showPercentage ?? true,
barSize: c.barSize ?? "md",
}),
statusIndicator: (c) => ({
statusItems:
(c.items as Array<Record<string, unknown>>)?.map((i) => ({
label: i.label ?? "",
jsonPath: i.jsonPath ?? "",
goodValues: Array.isArray(i.goodValues) ? (i.goodValues as string[]).join(", ") : String(i.goodValues ?? ""),
})) ?? [],
statusLayout: c.layout ?? "list",
dotSize: c.dotSize ?? "md",
}),
countGrid: (c) => ({
countGridItems: c.items ?? [],
countGridColumns: c.columns ?? 2,
countValueSize: c.valueSize ?? "md",
}),
raw: (c) => ({ rawJsonPath: c.jsonPath ?? "$", rawMaxHeight: c.maxHeight ?? 300 }),
actionButton: (c) => ({
buttonLabel: c.buttonLabel ?? "",
buttonColor: c.buttonColor ?? "blue",
confirmText: c.confirmText ?? "",
successMessage: c.successMessage ?? "",
}),
customJsx: (c) => ({ template: c.template ?? "" }),
};
function importJsonToFormValues(imported: CustomWidgetImport): Partial<z.infer<typeof formSchema>> {
const displayFields =
importToFormValues[imported.displayType]?.(imported.displayConfig as unknown as Record<string, unknown>) ?? {};
return {
name: imported.name,
description: imported.description ?? "",
iconUrl: imported.iconUrl ?? "",
url: imported.url,
authType: imported.authType,
headerName: imported.headerName ?? "",
method: imported.method,
requestBody: imported.requestBody ?? "",
displayType: imported.displayType,
...displayFields,
secrets: [],
};
}
function extractServerErrors(err: unknown, displayType: string): Record<string, string> {
const errors: Record<string, string> = {};
const trpcErr = err as {
@@ -767,32 +681,6 @@ export function CustomWidgetForm({ mode, initialValues, definitionId }: CustomWi
}
}, [getPreviewInput, previewMutation]);
useEffect(() => {
if (mode !== "create") return;
const onPaste = (e: ClipboardEvent) => {
const raw = e.clipboardData?.getData("text/plain");
if (!raw) return;
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch {
return;
}
const result = customWidgetImportSchema.safeParse(parsed);
if (!result.success) return;
e.preventDefault();
const formValues = importJsonToFormValues(result.data);
form.setValues({ ...defaultCreateValues, ...formValues });
form.clearErrors();
showSuccessNotification({
title: t("action.paste"),
message: t("notification.imported", { name: result.data.name }),
});
};
document.addEventListener("paste", onPaste);
return () => document.removeEventListener("paste", onPaste);
}, [form, t, mode]);
useEffect(() => {
if (previewRefreshSignal > 0 && hasTestedRef.current) {
void handlePreviewTest();
@@ -1,16 +1,11 @@
"use client";
import { useEffect } from "react";
import { ActionIcon, Avatar, Badge, Group, Stack, Table, Text } from "@mantine/core";
import { IconApi, IconPencil } from "@tabler/icons-react";
import type { RouterOutputs } from "@homarr/api";
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 { Link } from "@homarr/ui";
import { customWidgetImportSchema } from "@homarr/validation/custom-widget";
import { NoResults } from "~/components/no-results";
import { CustomWidgetRowActions } from "./_custom-widget-actions";
@@ -36,36 +31,6 @@ interface CustomWidgetListProps {
export const CustomWidgetList = ({ definitions }: CustomWidgetListProps) => {
const t = useScopedI18n("customWidget");
const utils = clientApi.useUtils();
const importMutation = clientApi.customWidget.import.useMutation({
onSuccess: () => {
showSuccessNotification({ title: t("action.paste"), message: "Custom widget imported" });
void utils.customWidget.all.invalidate();
void revalidatePathActionAsync("/manage/custom-widgets");
},
onError: (err) => {
showErrorNotification({ title: t("action.paste"), message: err.message || "Failed to import" });
},
});
useEffect(() => {
const onPaste = (e: ClipboardEvent) => {
const raw = e.clipboardData?.getData("text/plain");
if (!raw) return;
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch {
return;
}
const result = customWidgetImportSchema.safeParse(parsed);
if (!result.success) return;
e.preventDefault();
importMutation.mutate(result.data);
};
document.addEventListener("paste", onPaste);
return () => document.removeEventListener("paste", onPaste);
}, [importMutation.mutate]);
if (definitions.length === 0) {
return (
@@ -79,40 +44,36 @@ export const CustomWidgetList = ({ definitions }: CustomWidgetListProps) => {
return (
<Stack gap="md">
<Table striped highlightOnHover withTableBorder layout="fixed">
<Table striped highlightOnHover withTableBorder>
<Table.Thead>
<Table.Tr>
<Table.Th>{t("table.name")}</Table.Th>
<Table.Th w="30%" visibleFrom="sm">
{t("table.url")}
</Table.Th>
<Table.Th w={120} visibleFrom="xs">
{t("table.display")}
</Table.Th>
<Table.Th>{t("table.url")}</Table.Th>
<Table.Th>{t("table.display")}</Table.Th>
<Table.Th w={90} />
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{definitions.map((def) => (
<Table.Tr key={def.id} style={{ opacity: def.enabled ? undefined : 0.5 }}>
<Table.Td style={{ overflow: "hidden" }}>
<Table.Td>
<Group gap="xs" wrap="nowrap">
{def.iconUrl ? (
<Avatar size={20} radius="sm" src={def.iconUrl} styles={{ image: { objectFit: "contain" } }} />
) : (
<IconApi size={16} style={{ flexShrink: 0 }} />
<IconApi size={16} />
)}
<Text size="sm" fw={500} truncate c={def.enabled ? undefined : "dimmed"}>
<Text size="sm" fw={500} lineClamp={1} c={def.enabled ? undefined : "dimmed"} style={{ minWidth: 0 }}>
{def.name}
</Text>
</Group>
</Table.Td>
<Table.Td style={{ overflow: "hidden" }} visibleFrom="sm">
<Text size="sm" c="dimmed" truncate>
<Table.Td>
<Text size="sm" c="dimmed" lineClamp={1}>
{def.url}
</Text>
</Table.Td>
<Table.Td visibleFrom="xs">
<Table.Td>
<Badge color={displayTypeBadgeColors[def.displayType] ?? "gray"} size="sm">
{t(`displayType.${def.displayType}` as never)}
</Badge>
@@ -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-install/install-from-workshop-buttons";
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")}
@@ -61,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,
},
@@ -71,20 +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,
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,
},
@@ -94,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,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,98 +0,0 @@
"use client";
import { useState } from "react";
import { Button } from "@mantine/core";
import { useDisclosure } from "@mantine/hooks";
import { IconUsersGroup } 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 { MobileAffixButton } from "~/components/manage/mobile-affix-button";
import { WorkshopBrowserModal } from "./workshop-browser-modal";
function parseWidgetContent(content: string) {
let parsed: unknown;
try {
parsed = JSON.parse(content);
} catch {
return { ok: false as const, message: "Submission content is not valid JSON" };
}
const result = customWidgetImportSchema.safeParse(parsed);
if (!result.success) {
return { ok: false as const, message: result.error.issues[0]?.message ?? "Invalid widget schema" };
}
return { ok: true as const, data: result.data };
}
export const InstallWidgetFromWorkshopButton = () => {
const t = useScopedI18n("customWidget.workshop");
const [opened, { open, close }] = useDisclosure(false);
const [pendingId, setPendingId] = useState<string | null>(null);
const utils = clientApi.useUtils();
const importMutation = clientApi.customWidget.import.useMutation({
onSuccess: () => {
showSuccessNotification({ title: t("title"), message: t("notification.widgetInstalled") });
void utils.customWidget.all.invalidate();
void revalidatePathActionAsync("/manage/custom-widgets");
setPendingId(null);
close();
},
onError: (err) => {
showErrorNotification({ title: t("title"), message: err.message || t("notification.widgetInstallFailed") });
setPendingId(null);
},
});
const handleSelect = (submission: { id: string; content: string }) => {
const parsed = parseWidgetContent(submission.content);
if (!parsed.ok) {
showErrorNotification({ title: t("title"), message: parsed.message });
return;
}
setPendingId(submission.id);
importMutation.mutate(parsed.data);
};
return (
<>
<MobileAffixButton variant="default" leftSection={<IconUsersGroup size={16} />} onClick={open}>
{t("action.installFromWorkshop")}
</MobileAffixButton>
<WorkshopBrowserModal
type="widget"
opened={opened}
onClose={close}
actionLabel={t("action.install")}
pendingId={pendingId}
onSelect={handleSelect}
/>
</>
);
};
export const InstallCssFromWorkshopButton = ({ onSelect }: { onSelect: (css: string) => void }) => {
const t = useScopedI18n("customWidget.workshop");
const [opened, { open, close }] = useDisclosure(false);
return (
<>
<Button variant="default" leftSection={<IconUsersGroup size={16} />} onClick={open}>
{t("action.installFromWorkshop")}
</Button>
<WorkshopBrowserModal
type="css"
opened={opened}
onClose={close}
actionLabel={t("action.use")}
onSelect={(submission) => {
onSelect(submission.content);
close();
}}
/>
</>
);
};
@@ -1,47 +0,0 @@
export const WORKSHOP_API_URL = process.env.NEXT_PUBLIC_WORKSHOP_API_URL || "https://workshop.homarr.dev";
export interface WorkshopSubmission {
id: string;
collectionId: string;
collectionName: string;
type: "css" | "widget";
title: string;
description: string;
schemaVersion: string;
content: string;
screenshots: string[];
upvotes: number;
downvotes: number;
version: number;
author: string;
authorName: string;
commentCount: number;
created: string;
}
interface PBListResponse {
page: number;
perPage: number;
totalPages: number;
totalItems: number;
items: WorkshopSubmission[];
}
export const getWorkshopFileUrl = (submission: WorkshopSubmission, filename: string) =>
`${WORKSHOP_API_URL}/api/files/submissions/${submission.id}/${encodeURIComponent(filename)}`;
export const fetchWorkshopSubmissions = async (
type: "css" | "widget",
page = 1,
perPage = 50,
): Promise<PBListResponse> => {
const params = new URLSearchParams({
page: String(page),
perPage: String(perPage),
sort: "-upvotes",
filter: `(type='${type}')`,
});
const response = await fetch(`${WORKSHOP_API_URL}/api/collections/marketplace/records?${params}`);
if (!response.ok) throw new Error(`Workshop request failed (${response.status})`);
return response.json() as Promise<PBListResponse>;
};
@@ -1,221 +0,0 @@
"use client";
import { useState } from "react";
import {
ActionIcon,
Anchor,
Alert,
Badge,
Box,
Button,
Card,
Center,
Group,
Image,
Loader,
Modal,
Pagination,
ScrollArea,
SimpleGrid,
Stack,
Text,
} from "@mantine/core";
import { IconChevronLeft, IconChevronRight, IconExternalLink } from "@tabler/icons-react";
import { useQuery } from "@tanstack/react-query";
import type { WorkshopSubmission } from "./workshop-api";
import { fetchWorkshopSubmissions, getWorkshopFileUrl } from "./workshop-api";
const WORKSHOP_URL = process.env.NEXT_PUBLIC_WORKSHOP_URL || "https://homarr.dev/workshop";
interface Props {
type: "css" | "widget";
opened: boolean;
onClose: () => void;
onSelect: (submission: WorkshopSubmission) => void;
actionLabel: string;
pendingId?: string | null;
}
const displayName = (submission: WorkshopSubmission) => submission.authorName;
export const WorkshopBrowserModal = ({ type, opened, onClose, onSelect, actionLabel, pendingId }: Props) => {
const [page, setPage] = useState(1);
const handleClose = () => {
setPage(1);
onClose();
};
const { data, isLoading, error } = useQuery({
queryKey: ["workshop-submissions", type, page],
queryFn: () => fetchWorkshopSubmissions(type, page),
enabled: opened,
});
const errorText = error instanceof Error ? error.message : "unknown error";
return (
<Modal
opened={opened}
onClose={handleClose}
title={
<Group gap="sm">
<Text fw={600}>Install from Workshop</Text>
<Anchor
href={WORKSHOP_URL}
target="_blank"
rel="noopener noreferrer"
size="xs"
style={{ display: "inline-flex", alignItems: "center", gap: 4 }}
>
Open Workshop <IconExternalLink size={12} />
</Anchor>
</Group>
}
size={1200}
scrollAreaComponent={ScrollArea.Autosize}
>
<Stack gap="md">
{isLoading && (
<Center py="xl">
<Loader />
</Center>
)}
{error && <Alert color="red">Failed to load the workshop: {errorText}</Alert>}
{data && data.items.length === 0 && (
<Text c="dimmed" ta="center" py="xl">
No submissions available yet.
</Text>
)}
{data && data.items.length > 0 && (
<>
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
{data.items.map((submission) => (
<SubmissionCard
key={submission.id}
submission={submission}
actionLabel={actionLabel}
pending={pendingId === submission.id}
onSelect={() => onSelect(submission)}
/>
))}
</SimpleGrid>
{data.totalPages > 1 && (
<Center>
<Pagination total={data.totalPages} value={page} onChange={setPage} />
</Center>
)}
</>
)}
</Stack>
</Modal>
);
};
const SubmissionCard = ({
submission,
actionLabel,
pending,
onSelect,
}: {
submission: WorkshopSubmission;
actionLabel: string;
pending: boolean;
onSelect: () => void;
}) => {
const screenshots = submission.screenshots ?? [];
const [imgIdx, setImgIdx] = useState(0);
const score = submission.upvotes - submission.downvotes;
const scorePrefix = score > 0 ? "+" : "";
return (
<Card withBorder padding={0}>
{screenshots.length > 0 && (
<Box pos="relative">
<Image
src={screenshots[imgIdx] ? getWorkshopFileUrl(submission, screenshots[imgIdx]) : undefined}
alt={submission.title}
h={180}
fit="cover"
/>
{screenshots.length > 1 && (
<>
<ActionIcon
variant="filled"
color="dark"
size="xs"
pos="absolute"
top="50%"
left={4}
style={{ transform: "translateY(-50%)" }}
onClick={() => setImgIdx((i) => (i - 1 + screenshots.length) % screenshots.length)}
>
<IconChevronLeft size={12} />
</ActionIcon>
<ActionIcon
variant="filled"
color="dark"
size="xs"
pos="absolute"
top="50%"
right={4}
style={{ transform: "translateY(-50%)" }}
onClick={() => setImgIdx((i) => (i + 1) % screenshots.length)}
>
<IconChevronRight size={12} />
</ActionIcon>
</>
)}
</Box>
)}
<Stack gap={4} p="sm">
<Group gap="xs" wrap="nowrap">
<Box
w={8}
h={8}
bg={submission.type === "css" ? "blue.5" : "yellow.5"}
style={{ borderRadius: "50%", flexShrink: 0 }}
/>
<Text fw={600} size="sm" truncate style={{ flex: 1 }}>
{submission.title}
</Text>
<Badge variant="light" size="sm" style={{ flexShrink: 0 }}>
{scorePrefix}
{score}
</Badge>
</Group>
<Text size="xs" c="dimmed">
{displayName(submission)} · v{submission.version}
</Text>
{submission.description && (
<Text size="xs" c="dimmed" lineClamp={2}>
{submission.description}
</Text>
)}
<Group gap="xs" mt={4}>
<Button size="xs" style={{ flex: 1 }} loading={pending} onClick={onSelect}>
{actionLabel}
</Button>
<ActionIcon
variant="light"
size="sm"
component="a"
href={`${WORKSHOP_URL}/${submission.id}/`}
target="_blank"
rel="noopener noreferrer"
aria-label="View in workshop"
>
<IconExternalLink size={14} />
</ActionIcon>
</Group>
</Stack>
</Card>
);
};
-3
View File
@@ -1,3 +0,0 @@
pb_data/
.env
.env.local
-54
View File
@@ -1,54 +0,0 @@
# syntax=docker/dockerfile:1.10
FROM node:24.18.0-alpine AS docs
WORKDIR /app
RUN corepack enable
COPY . .
RUN --mount=type=cache,id=workshop-pnpm,target=/pnpm/store \
pnpm config set store-dir /pnpm/store && \
pnpm install --frozen-lockfile --filter @homarr/docs...
RUN WORKSHOP_URL="" pnpm --filter @homarr/docs build
FROM alpine:3.22 AS pocketbase
ARG POCKETBASE_VERSION=0.39.6
ARG TARGETARCH
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 runtime
RUN apk add --no-cache ca-certificates tzdata
COPY --from=pocketbase /out/pocketbase /usr/local/bin/pocketbase
COPY --from=docs /app/apps/docs/build /pb_public
COPY apps/workshop/pb_hooks /pb_hooks
COPY apps/workshop/pb_migrations /pb_migrations
RUN addgroup -S workshop && \
adduser -S -G workshop -h /home/workshop workshop && \
mkdir -p /pb_data && \
chown -R workshop:workshop /pb_data /pb_public /pb_hooks /pb_migrations
USER workshop
EXPOSE 8090
VOLUME ["/pb_data"]
HEALTHCHECK --interval=10s --timeout=3s --start-period=10s --retries=10 \
CMD wget --quiet --spider http://127.0.0.1:8090/api/health || exit 1
ENTRYPOINT ["/usr/local/bin/pocketbase"]
CMD ["serve", "--http=0.0.0.0:8090", "--dir=/pb_data", "--publicDir=/pb_public", "--hooksDir=/pb_hooks", "--migrationsDir=/pb_migrations", "--indexFallback=true"]
-15
View File
@@ -1,15 +0,0 @@
.git
.github
.env
.env.*
!.env.example
**/.docusaurus
**/.next
**/.turbo
**/build
**/coverage
**/dist
**/node_modules
**/pb_data
.pnpm-store
e2e
Loaded 100 of 317 files, more files were not shown because too many files have changed in this diff. Show more