Compare commits

..
Author SHA1 Message Date
Thomas Camlong 6040d09630 chore: snapshot workshop v2 rewrite 2026-07-14 18:42:08 +02:00
Thomas Camlong aff5a84a1d docs: keep Workshop audit current 2026-07-14 01:22:07 +02:00
Thomas Camlong 8cdc4f8030 docs: refresh Workshop audit 2026-07-14 01:21:53 +02:00
Thomas Camlong c6e0cb27e7 style: format workshop client 2026-07-14 01:21:53 +02:00
Thomas Camlong 9f979f683a feat: unify Workshop experience 2026-07-14 01:21:53 +02:00
Thomas Camlong 066090d99e fix: report migration failures 2026-07-14 01:21:53 +02:00
Thomas Camlong 31e2e5ac7f fix: harden workshop availability 2026-07-14 01:21:53 +02:00
Thomas Camlong 699134a576 refactor: move workshop backend app 2026-07-14 01:21:53 +02:00
Thomas Camlong 55ba12473c fix: support local workshop authentication 2026-07-14 01:21:53 +02:00
Thomas Camlong c72e4d6dfe fix: repair workshop development startup 2026-07-14 01:21:53 +02:00
Thomas Camlong a8644c4c6b feat: rebuild workshop 2026-07-14 01:21:53 +02: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
277 changed files with 9893 additions and 19331 deletions

No files matched your search

+13 -1
View File
@@ -66,4 +66,16 @@ 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
# DEMO_READ_ONLY=false
# Central Workshop API is https://homarr.dev. Override only when developing
# the standalone Docusaurus or Homarr clients against a local backend.
# NEXT_PUBLIC_WORKSHOP_API_URL=http://localhost:8090
# WORKSHOP_API_URL=http://localhost:8090
# Workshop development ports. PocketBase superusers and GitHub OAuth are
# configured once through the persistent PocketBase dashboard, not environment variables.
PB_EXPOSE_PORT=8090
WORKSHOP_WEB_PORT=3003
# Workshop is intentionally consumable by arbitrary self-hosted Homarr origins.
# PocketBase is stateless and authorizes writes with bearer tokens, not cookies.
PB_ALLOWED_ORIGINS=*
+1
View File
@@ -23,6 +23,7 @@ jobs:
with:
fetch-depth: 0
ref: ${{ github.head_ref }}
persist-credentials: false
- name: Setup
uses: ./tooling/github/setup
-56
View File
@@ -1,56 +0,0 @@
name: "[Deployment] Documentation"
on:
push:
branches: ["dev"]
paths:
- "apps/docs/**"
- "packages/definitions/src/docs/**"
- ".github/workflows/deployment-docs.yml"
workflow_dispatch:
permissions:
contents: read
pages: write
id-token: write
concurrency:
group: "pages"
cancel-in-progress: false
env:
SKIP_ENV_VALIDATION: true
TURBO_API: ${{ secrets.TURBO_API }}
TURBO_TEAM: ${{ secrets.TURBO_TEAM }}
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
TURBO_REMOTE_CACHE_SIGNATURE_KEY: ${{ secrets.TURBO_REMOTE_CACHE_SIGNATURE_KEY }}
jobs:
build:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v7
- name: Setup
uses: ./tooling/github/setup
- name: Build docs
run: pnpm turbo build --filter=@homarr/docs
env:
CI: "true"
- name: Setup Pages
uses: actions/configure-pages@v6
- name: Upload pages artifact
uses: actions/upload-pages-artifact@v5
with:
path: "apps/docs/build"
deploy:
runs-on: ubuntu-latest
needs: build
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
steps:
- name: Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@v5
+50
View File
@@ -0,0 +1,50 @@
name: Workshop
on:
pull_request:
types: [opened, synchronize, reopened, ready_for_review]
paths: &workshop-paths
- "apps/workshop/**"
- "apps/docs/docusaurus.config.ts"
- "apps/docs/package.json"
- "apps/docs/src/components/workshop/**"
- "apps/docs/src/pages/workshop/**"
- "apps/docs/src/theme/Root.tsx"
- "apps/docs/static/workshop-*"
- "apps/docs/docs/management/workshop/**"
- "packages/workshop/**"
- "packages/validation/**"
- "packages/definitions/**"
- "patches/**"
- "package.json"
- "pnpm-lock.yaml"
- "pnpm-workspace.yaml"
- "turbo.json"
- ".github/workflows/workshop.yml"
push:
branches: [dev]
paths: *workshop-paths
workflow_dispatch:
permissions:
contents: read
concurrency:
group: workshop-${{ github.ref }}-${{ github.event.pull_request.number || github.sha }}
cancel-in-progress: true
jobs:
production-preview:
name: Production preview
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- uses: actions/checkout@v7
- name: Setup
uses: ./tooling/github/setup
- name: Copy environment defaults
run: cp .env.example .env
- name: Test Workshop backend
run: pnpm test:workshop-backend
- name: Build and smoke-test Workshop
run: pnpm test:workshop-preview
@@ -0,0 +1,87 @@
---
score: 9
p0: 0
p1: 1
timestamp: 2026-07-13T21-26-11Z
slug: apps-docs-src-components-workshop
---
# Workshop V2 audit
## Purpose
The Workshop is Homarr's central community catalog for custom widgets and CSS themes. It must let people discover, inspect, export, publish, update, report, and install creations from homarr.dev and from Homarr itself without requiring raw copy-and-paste as the default workflow.
### Feature North Star
A Homarr user can move from a useful local creation to a safe community submission, or from a community listing to an installed local creation, in one understandable flow with source visibility and an explicit confirmation boundary.
### Developer-experience North Star
Workshop behavior is defined once: one typed contract, one client, versioned PocketBase migrations, deterministic local startup, shared validation fixtures, typed failures, and UI components that contain presentation rather than PocketBase queries or authorization rules.
## Baseline audit
The abandoned prototype is preserved at `origin/feat/widget-store-prototype-backup` (`89260b875`). This rewrite starts at `dev` commit `ba7718bde`.
| Area | Severity | Baseline finding | V2 resolution |
| ------------- | -------- | ----------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------ |
| Branch health | P0 | PR #5984 was based on an obsolete `dev` commit and conflicted with the current branch. | Rebuild from current `dev`; keep the old head on a backup ref. |
| Security | P0 | Submission validation ran in browsers and could be bypassed with direct PocketBase writes. | Enforce validation, ownership, account state, immutable fields, and upload limits in PocketBase hooks and rules. |
| Moderation | P0 | There were no moderator roles, posting bans, disabled accounts, least-privilege actions, or durable moderation history. | Add roles, account states, explicit moderation routes, a staff UI, and append-only actions. |
| Architecture | P1 | Docs and Next.js defined separate record types, queries, mutations, sorting, voting, and error behavior. | Add `@homarr/workshop` as the only public contract and API client. |
| Performance | P1 | The browser downloaded every submission and full content, then searched, sorted, and filtered locally. | Server-side pagination/filtering/sorting; summary views exclude content. |
| Product | P1 | Website and app flows were disconnected and sharing still depended on manual content transfer. | Add contextual install and direct share actions backed by canonical exports. |
| UX/UI | P1 | A large bespoke Docusaurus component set and a separate Mantine modal produced inconsistent interaction and state behavior. | Use each host's native components with shared flows, copy rules, contracts, and states. |
| Accessibility | P1 | Several custom interactive elements bypassed semantic controls and touch targets; focus and reduced-motion behavior was incomplete. | Require semantic controls, visible focus, keyboard operation, 44px touch targets, AA contrast, and reduced motion. |
| Operations | P1 | A third-party PocketBase image and incomplete bootstrap docs made builds and recovery non-deterministic. | Build a pinned official binary with checksum verification; document health, backup, restore, OAuth, and upgrades. |
| Testing | P1 | Tests covered a narrow client schema and did not exercise API rules, roles, auth states, moderation, or cross-surface flows. | Add contract, backend permission-matrix, UI, and end-to-end coverage. |
| Documentation | P2 | Documentation advertised a supported self-hosted Workshop even though V2 is a central Homarr service. | Document the central service and local-development overrides only. |
## Decisions
- Public name: **Workshop**.
- Launch content: custom widgets (`homarr-custom-widget-v2`) and CSS (`homarr-custom-css-v1`).
- Identity: GitHub OAuth only.
- Community features: votes and reports; comments are removed.
- Hosting: one Homarr-operated image and origin at `homarr.dev`; PocketBase serves both compiled docs and `/api`.
- Resilience: the public shell and successfully loaded query data are cached independently; the rest of Homarr never depends on Workshop availability.
- Publishing: immediate, with source inspection, reporting, and staff removal.
- Staff: GitHub-authenticated moderators/admins use `/workshop/admin`; PocketBase superusers are bootstrap/emergency access.
## Launch acceptance
- [x] Anonymous users can browse, search, filter, inspect, copy, and download without receiving full content in listing responses.
- [x] Members can publish, update, delete, vote, and report; ownership and schemas are enforced server-side.
- [x] Posting-banned users cannot create or update submissions but can browse, vote, and report.
- [x] Disabled accounts cannot authenticate or write, including with an existing token.
- [x] Moderators can resolve reports, remove submissions, posting-ban users, and disable/restore non-admin users.
- [x] Only admins can change roles; staff actions are append-only and attributable.
- [x] Homarr can export/share local widgets and CSS and validate/confirm Workshop installs.
- [x] Website and app implement loading, empty, offline, validation, permission, and destructive-action states.
- [x] Keyboard semantics, mobile layout, dark mode, reduced motion, and horizontal overflow received a browser smoke test.
- [x] Clean PocketBase startup, migration, health, backup, and restore procedures are reproducible and documented.
- [x] Documentation and Workshop API are built into one reproducible image and served from `homarr.dev` without production cross-origin requests.
- [ ] Add automated authenticated cross-surface E2E coverage before changing the PR from draft to ready.
## Deferred after V2
Comments, private Workshop deployments, automated approval queues, follows, collections, notifications, arbitrary package types, historical version installation, and personalized recommendations.
## Exit scorecard
The baseline has three P0, seven P1, and one P2 findings. The rewrite closes every product, security, architecture, moderation, performance, operations, documentation, UX, and DX finding. One P1 release-verification item remains: automated authenticated E2E across both public and in-app surfaces. Keep PR #5984 in draft until it lands.
| Gate | Result | Evidence |
| --------------------------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Branch ancestry | Pass | Rebuilt from `dev` at `ba7718bde`; prototype preserved at `origin/feat/widget-store-prototype-backup`. |
| Contract | Pass | Five Vitest cases cover widget/CSS fixtures, filenames, limits, screenshot validation, and round trips. |
| Backend | Pass | Disposable PocketBase integration matrix covers roles, account states, ownership forgery, schema rejection, duplicate votes, private reports, escalation, deletion cascades, and audit snapshots. |
| Backend operations | Pass | PocketBase 0.39.6 runs unprivileged and builds with checksum verification; clean boot and a cloned legacy SQLite volume both migrate and become healthy. |
| Type safety | Pass | Scoped Turbo typecheck passes for Workshop, Docs, and Next.js. |
| Lint | Pass | Scoped lint exits clean; remaining warnings predate the rewrite. Workshop has zero warnings. |
| Format | Pass | Every changed supported file passes `oxfmt --check`; repository-wide Docs formatting still reports pre-existing drift. |
| Production builds | Pass | Docusaurus and Next.js production builds pass under Node 24.18.0. |
| Browser smoke | Pass | Desktop/mobile discovery and the disconnected API state render without uncaught errors; cached data remains readable and retry is explicit. |
| Automated cross-surface E2E | Open P1 | Authenticated website-to-Homarr publish/install and moderator flows still need browser automation. |
Final implementation scores (10 is best): security 9, architecture/DX 9, moderation 9, performance 9, responsive UI 9, theming 9, accessibility 8, automated testing 7. Accessibility remains below 9 until authenticated dialogs and destructive flows receive a full keyboard/screen-reader pass.
-535
View File
@@ -1,535 +0,0 @@
# Custom Widgets Architecture Audit and North Star
> Status: implemented in the current working tree; baseline retained below for traceability
> Evidence snapshot: `c5703e7e5` on PR #6304
> Scope: built-in custom-widget displays, Custom JSX, network execution, authoring, preview, import/export, AI prompts, and MCP tools
## Implementation outcome
The migration described by this audit is implemented. `@homarr/custom-widgets` now owns the domain through explicit `core`, `jsx`, `runtime`, `server`, and `workbench` exports. API, Widgets, and Next.js retain only authorization, persistence, transport, board registration, navigation, and localized adapter concerns.
Key outcomes:
- One typed configuration and display-descriptor model drives schemas, defaults, form hydration, serialization, import/export, preview, examples, diagnostics, generated references, and AI prompts.
- Display-data extraction moved out of API and Widgets into the domain package; the former API-to-Widgets re-export chain no longer exists.
- The interpreter, shared security policy, registry, wrappers, named-request runtime, hardened executor, preview sessions, rate limits, and workbench UI have focused package-owned modules and tests.
- Runtime and workbench network behavior is injected through typed ports. The domain package has no dependency on API, Widgets, Next.js routes, notification infrastructure, or modal infrastructure.
- Public tRPC and MCP procedures are characterized, v2 and v3 import envelopes remain accepted, display-only v1 remains covered, and MCP offers iterative schema, validation, template read/write/patch, create/import/update, preview query, simulation, and journal tools.
- The AI prompt uses a multiline two-block authoring bundle, documents the `SubFetch` render-child contract, and is generated from the canonical registry and tested examples.
- Architecture enforcement currently scans 110 production modules for dependency direction, cycles, file budgets, dynamic loading, and untranslated JSX literals. Generated-reference drift and every Mantine export classification are test-enforced.
- The latest production build measured the largest Custom JSX route payload at 99,852 gzip bytes across two chunks, below the 768 KiB budget.
Focused verification is green: 278 package tests, 35 API/MCP tests, 52 Widgets renderer tests, 7 E2E tests, affected typecheck, affected lint, docs build, production Next.js build, architecture checks, generated drift, and the bundle budget. The root `pnpm test` command is presently blocked on this workstation by unrelated Docker-backed integration suites timing out while the shared Docker daemon is under external load; no Custom Widget test failed in those runs.
## Executive summary
The feature has a strong security direction and a capable authoring experience, but its implementation has outgrown its current ownership model. PR #6304 changes 56 files with 13,206 additions. The primary feature directories contain 10,437 lines, spread across `@homarr/widgets`, `@homarr/api`, `@homarr/definitions`, `@homarr/validation`, and the Next.js management route.
The main problem is not the absolute line count. It is that domain policy, transport, persistence, rendering, form state, compatibility, and documentation generation are interleaved across package boundaries. A change to one display type can require synchronized edits in schemas, form defaults, import conversion, preview extraction, runtime rendering, documentation, and AI instructions. Several of those paths use untyped records, duplicated conversion logic, or direct API dependencies.
The north star is a single `@homarr/custom-widgets` domain package with explicit `core`, `jsx`, `runtime`, `server`, and `workbench` entry points. Existing applications and packages become thin adapters. The extraction must preserve public APIs, import formats, permission behavior, and the current UI while improving internal cohesion.
This audit does not authorize a big-bang rewrite. The migration should be a sequence of behavior-preserving PRs, beginning with characterization tests and ending with enforced dependency and complexity budgets.
## Measured baseline
### Change footprint
| Measure | Current value |
| ------------------------------- | ------------: |
| PR files changed | 56 |
| PR additions | 13,206 |
| PR deletions | 1,385 |
| Primary feature-directory lines | 10,437 |
Generated component-reference JSON accounts for 2,491 added lines and is not treated as source complexity.
### Largest production files
| File | Lines | Responsibilities currently combined |
| ------------------------------------------------------------------------------- | ----: | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-form.tsx` | 1,911 | Schema refinement, defaults, server-error mapping, form conversion, submission, preview state, focus management, display picker, connection fields, and every display-specific editor |
| `packages/api/src/router/custom-widget/custom-widget-router.ts` | 978 | CRUD, import/export, template patching, MCP descriptions, preview sessions, preview execution, journals, and action simulation/execution |
| `packages/widgets/src/custom-api/safe-jsx-interpreter.tsx` | 944 | AST model, parsing, environments, budgets, sanitization, expression evaluation, safe methods, callbacks, and React rendering |
| `apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-preview.tsx` | 834 | Preview orchestration, error boundary, tabs, response tree, diagnostics, request journal, sample data, and renderer selection |
| `packages/widgets/src/custom-api/jsx-whitelist.ts` | 775 | Mantine imports, wrappers, icon mapping, runtime component map, registry filtering, data sanitization, and safe bindings |
| `packages/widgets/src/custom-api/jsx-sub-fetch.tsx` | 599 | Query rendering, render props, path display, action execution, buttons, toggles, refresh, notifications, and confirmations |
These files exceed the proposed 300-line production-module target. The interpreter is a reasonable candidate for a documented 400-line exception after its independent policies, evaluators, and renderer are extracted.
### Current ownership
```mermaid
flowchart LR
D["@homarr/definitions<br/>registry and examples"]
V["@homarr/validation<br/>schemas and AST diagnostics"]
W["@homarr/widgets<br/>renderers and interpreter"]
A["@homarr/api<br/>CRUD, permissions, execution and MCP"]
N["Next.js route<br/>workbench, preview and prompt"]
X["Docs<br/>generated reference and guides"]
N --> A
N --> V
N --> D
N --> W
W --> A
W --> D
A --> V
X --> D
```
The `@homarr/widgets → @homarr/api` edge is particularly costly. Runtime request components call the generated tRPC client directly, so moving the domain into a package that is also consumed by the API would create a dependency cycle unless request execution is inverted behind a typed port.
## What is working well
- Custom JSX v2 replaces raw runtime URLs and methods with named, typed requests.
- Board permissions, network scope, DNS validation, redirect policy, response limits, caching, concurrency, and action rules are server-enforced.
- The safe interpreter no longer treats an upstream JSX parser as a sandbox.
- Component metadata drives runtime classification, editor completion, docs generation, and part of the AI prompt.
- The workbench supports multiline JSX, diagnostics, real preview sizes, response inspection, request journals, import review, undo/redo, and paste-to-import.
- MCP exposes schema, validation, preview, journal, template read/write/patch, import, and update workflows without exposing stored secrets.
- Shared examples cover simple metrics, nested queries, charts, actions, and richer interactive layouts.
The migration must preserve these capabilities. Package extraction is not a reason to weaken the security boundary or remove authoring features.
## Findings
### P1 — Domain ownership is fragmented
Custom-widget behavior is implemented in five packages/applications. None of them is the clear source of truth for the complete lifecycle. `@homarr/definitions` owns component metadata and examples, `@homarr/validation` owns schemas and template diagnostics, `@homarr/widgets` owns runtime evaluation, `@homarr/api` owns network and persistence flows, and Next.js owns authoring transformations.
Impact:
- Maintainers must search unrelated packages to understand one behavior.
- Changes have a large synchronization surface and can drift silently.
- The domain cannot be tested or reused as a coherent unit.
- Package names describe infrastructure location rather than feature ownership.
### P1 — Giant modules mix unrelated reasons to change
The largest files are not merely long; they combine policy, orchestration, state, and presentation. The form contains all display editors. The router contains every management and preview procedure. The interpreter contains parsing through React emission. The request component file contains both query and mutation interaction models.
Impact:
- Reviews are broad and regression-prone.
- Unit-test seams are difficult to identify.
- Merge conflicts concentrate in a small number of files.
- Small feature changes require understanding thousands of lines of adjacent behavior.
### P1 — Runtime code depends directly on the API client
`packages/widgets/src/custom-api/component.tsx` and `jsx-sub-fetch.tsx` import `@homarr/api/client`. They also directly invoke notifications and confirmation modals.
Impact:
- The renderer cannot be used or tested without application infrastructure.
- A future `@homarr/custom-widgets` package would cycle with `@homarr/api` if both retained these imports.
- Preview and board runtime cannot share the same renderer through different request implementations without branching inside the components.
Required direction: a runtime provider must receive typed query, action, confirmation, notification, and invalidation ports. The board and preview layers supply adapters.
### P1 — Configuration transformations are duplicated
Create defaults and builders live in the form, edit hydration lives in the edit page, and import hydration lives in `_custom-widget-import-utils.ts`. These paths independently translate between stored display configs and form values. They rely on display-type switches, record casts, and JSON text fields such as the request manifest.
Impact:
- Create, edit, paste replacement, import, export, and MCP can disagree.
- Adding a field requires updating multiple transforms.
- Round trips are difficult to prove lossless.
Required direction: each display type supplies one typed descriptor containing schema, defaults, `toForm`, `fromForm`, preview projection, and migration behavior.
### P1 — Type safety degrades at domain boundaries
`Record<string, unknown>`, `as never`, and `as unknown as` are frequent in form conversion, display extraction, preview state, imports, registry wrappers, and API parsing. Some casts are necessary at the interpreter/React boundary, but most domain-level casts indicate missing discriminated unions or validated adapters.
Impact:
- Invalid combinations survive until runtime.
- Refactors do not receive complete compiler guidance.
- Server and client transformations can diverge while continuing to compile.
Required direction: parse unknown values once at ingress and use typed domain objects afterward. Unsafe casts must be isolated to named boundary modules and justified by tests.
### P1 — Security policy is represented more than once
The template validator and interpreter both parse JSX and maintain concepts such as blocked properties, blocked props, AST depth, and operation limits. Runtime component filtering adds another policy layer.
The analyzer and interpreter have different jobs, so they should not be collapsed into one evaluator. They must, however, consume one immutable policy definition and shared AST normalization utilities.
Impact:
- A property can be rejected by one layer and missed by another.
- Security changes require parallel edits and duplicate tests.
- Diagnostics can describe behavior that differs from runtime enforcement.
### P1 — The API router combines transport and application behavior
The router owns tRPC metadata, Zod inputs, database writes, encryption, import/export, template revisions, preview execution, rate-limit acquisition, journal recording, and MCP documentation.
Impact:
- Domain logic is only testable through tRPC context setup.
- MCP and browser endpoints can accidentally implement different workflows.
- Framework-specific `TRPCError` values leak into lower-level executor and manifest code.
Required direction: tRPC procedures authorize, load persistence models, call a domain service, and map domain errors. They do not implement request rendering, template patching, preview execution, or import conversion inline.
### P2 — Metadata generation is only partially centralized
The component registry and examples are shared, but runtime maps, wrappers, safe bindings, display-type metadata, AI prompt prose, form choices, and some docs remain separately assembled. The AI prompt is built inside a client button component.
Impact:
- Agent instructions can drift from validation and runtime behavior.
- Prompt generation requires UI and network context.
- Display types lack the same single-registry guarantee as JSX components.
Required direction: prompt generation is a pure function in `core`, using the schema, display descriptors, component registry, and tested fixtures. The UI only fetches context and copies the result.
### P2 — Compatibility behavior is mixed into primary flows
V1 detection, migration warnings, legacy network diagnostics, v2 imports, v3 exports, and fallback display parsing are embedded beside current authoring/runtime logic.
Impact:
- Happy paths are harder to read.
- Legacy behavior can accidentally influence v2 code.
- Compatibility retirement has no obvious deletion boundary.
Required direction: version detection and migrations live under `core/migrations`. Runtime and workbench receive a normalized current model plus explicit review state.
### P2 — Test topology does not mirror the architecture
Interpreter coverage is substantial, and request-executor/router tests exist, but tests are colocated under their current infrastructure owners instead of a feature package. A focused renderer run passed 52 tests during review, while the selected API suites failed before collecting tests because the root Vitest client environment rejected server-only environment access.
Impact:
- The complete feature cannot be verified with one package command.
- Server test failures can be configuration failures rather than behavioral failures.
- Form conversion and workbench orchestration have limited direct characterization coverage relative to their size.
Required direction: the package owns focused test projects or environment-specific configs for core, DOM runtime/workbench, and Node server tests. API adapter tests remain in `@homarr/api`.
### P2 — A small amount of user-facing copy bypasses translation
Examples found during the snapshot include the raw-response tooltip and a default empty template message. Dynamic translation keys also require repeated `as never` casts, reducing translation-key safety.
Impact:
- Locale completeness gates are unreliable.
- Refactoring copy is harder than changing typed translation keys.
Required direction: all package UI receives a typed message catalog or uses typed scoped translations. No visible fallback literals are allowed in production components.
## North-star package
```text
packages/custom-widgets/
├── src/core/
│ ├── config/ # discriminated display configurations and form models
│ ├── displays/ # typed display descriptors and built-in display metadata
│ ├── import/ # v2/v3 parsing, export, review, and authoring bundles
│ ├── migrations/ # v1 detection, normalization, and review requirements
│ ├── prompts/ # pure AI prompt generation
│ └── examples/ # typed fixtures shared by tests, docs, editor, and prompts
├── src/jsx/
│ ├── ast/ # parser adapter, AST types, traversal, and normalization
│ ├── policy/ # blocked properties, props, URLs, and budgets
│ ├── analyzer/ # authoring diagnostics
│ ├── interpreter/ # expression and collection evaluation
│ ├── registry/ # component descriptors, wrappers, and export classification
│ └── bindings/ # branded safe helpers and sanitized data
├── src/runtime/
│ ├── displays/ # built-in renderers
│ ├── jsx/ # Custom JSX renderer and error containment
│ ├── requests/ # SubFetch, actions, toggles, refresh, and state handling
│ └── provider/ # injected runtime capabilities
├── src/server/
│ ├── network/ # hardened executor and destination policy
│ ├── requests/ # manifest resolution and parameter substitution
│ ├── limits/ # rate and concurrency limits
│ └── preview/ # sessions, simulation, and journals
└── src/workbench/
├── form/ # shared state and connection/display sections
├── editor/ # CodeMirror and registry-driven diagnostics
├── preview/ # widget, response, journal, and diagnostic views
└── import/ # clipboard parsing and review UI
```
Public exports are explicit:
```json
{
"exports": {
"./core": "./src/core/index.ts",
"./jsx": "./src/jsx/index.ts",
"./runtime": "./src/runtime/index.ts",
"./server": "./src/server/index.ts",
"./workbench": "./src/workbench/index.ts"
}
}
```
There is no broad root barrel. Internal folders cannot be imported by consumers.
### Dependency rules
```mermaid
flowchart LR
C["custom-widgets/core"]
J["custom-widgets/jsx"]
R["custom-widgets/runtime"]
S["custom-widgets/server"]
B["custom-widgets/workbench"]
API["@homarr/api adapter"]
W["@homarr/widgets adapter"]
N["Next.js route adapter"]
DOC["Docs generator"]
J --> C
R --> C
R --> J
S --> C
B --> C
B --> R
API --> C
API --> S
W --> C
W --> R
N --> C
N --> B
DOC --> C
DOC --> J
```
Hard rules:
1. `@homarr/custom-widgets` never imports `@homarr/api`, `@homarr/widgets`, or Next.js route modules.
2. `core` has no React, Mantine, tRPC, database, Redis, or browser dependency.
3. `jsx` has no API, persistence, Redis, notification, or modal dependency.
4. `runtime` performs no direct tRPC calls. It consumes `CustomWidgetRuntimePort`.
5. `workbench` performs no direct tRPC calls. It consumes `CustomWidgetWorkbenchPort` callbacks.
6. `server` has no tRPC or database-query dependency. API adapters load authorized records and map errors.
7. Cross-entry imports follow the arrows above; reverse imports fail CI.
### Runtime ports
The exact transport remains owned by adapters. The domain contract should cover behavior rather than tRPC details:
```ts
interface CustomWidgetRuntimePort {
getData(input: { itemId: string; signal?: AbortSignal }): Promise<CustomWidgetRequestResult>;
query(input: NamedRequestInvocation & { signal?: AbortSignal }): Promise<CustomWidgetRequestResult>;
action(input: NamedRequestInvocation & { confirmed?: boolean }): Promise<CustomWidgetRequestResult>;
confirm(input: ActionConfirmation): Promise<boolean>;
notify(input: RuntimeNotification): void;
invalidate(input: RuntimeInvalidation): Promise<void>;
}
```
Board runtime and preview runtime implement the same port. Preview simulation is an adapter capability, not a conditional tRPC import inside `ActionButton`.
### Display descriptors
Every display type is represented once:
```ts
interface CustomWidgetDisplayDescriptor<TConfig, TForm> {
type: CustomWidgetDisplayType;
configSchema: ZodType<TConfig>;
formSchema: ZodType<TForm>;
createDefault(): TConfig;
toForm(config: TConfig): TForm;
fromForm(form: TForm): TConfig;
migrate(input: unknown): DisplayMigrationResult<TConfig>;
extract(response: unknown, config: TConfig): DisplayModel;
capabilities(config: TConfig): CustomWidgetCapability[];
}
```
UI fields and renderer components may be registered in runtime/workbench companion maps keyed by the same typed display identifier. Domain descriptors remain serializable and framework-independent.
## Developer-experience north star
### Maintainers
- Start at `packages/custom-widgets` for every feature behavior.
- Find one module per concept and one descriptor per display type.
- Add a display type without editing central switches in multiple packages.
- Run one package command for core, DOM, and server tests.
- Receive compile-time failures when a configuration field is not handled by forms, preview, migration, or export.
- Review PRs that move one domain boundary at a time.
### Administrators and template authors
- Preserve the current continuous form and live workbench.
- Keep preview usable before a live response exists through schema-valid defaults and editable sample data.
- Keep diagnostics actionable, localized, clickable, and consistent with runtime policy.
- Keep imports reviewable and secrets excluded.
- Keep query and action capabilities visible without noisy permanent warnings.
### AI agents
- Obtain schema, registry, limits, examples, and prompt rules from the same typed sources.
- Use readable multiline templates through authoring bundles and line-based template tools.
- Validate, preview, inspect journals, patch, and revalidate without rewriting unrelated configuration.
- Receive stable machine-actionable issue codes in addition to localized or human-readable messages.
- Never need to infer runtime behavior from UI-only prompt strings.
## Fragmentation rules
- Production modules target at most 300 lines, excluding generated files, fixtures, and type-only declarations.
- Algorithmic modules can reach 400 lines only with a file-level rationale and focused tests.
- A module has one primary reason to change; moving lines without separating policy from orchestration does not satisfy the target.
- React components do not contain schema definitions or persistence transforms.
- Routers do not contain reusable domain algorithms.
- Domain functions do not throw framework-specific errors.
- Unknown input is validated once. Parsed values are not carried as `Record<string, unknown>` through internal layers.
- Compatibility code lives in versioned migration modules and has explicit removal criteria.
- Generated artifacts are reproducible and checked for drift, but excluded from line budgets.
## Migration roadmap
### PR 1 — Characterize contracts
- Add round-trip tests for every display descriptor: default → form → config, stored config → form → config, and import → export.
- Add fixtures for v1 display-only, v1 network-review-required, v2 import, and v3 export.
- Add prompt snapshots sourced from the component registry and examples.
- Split Node and DOM Vitest projects so server suites collect and run reliably.
- Record bundle and render baselines before moving modules.
Gate: all existing behavior is represented by tests, with no production moves yet.
### PR 2 — Establish `core`
- Create `@homarr/custom-widgets` and explicit exports.
- Move custom-widget schemas, typed configs, display descriptors, examples, import/export, migration, capability analysis, and prompt generation into `core`.
- Replace form/edit/import conversion duplication with descriptor calls.
- Temporarily re-export legacy imports from definitions and validation to keep consumers stable.
Gate: create, edit, import, export, MCP validation, and AI prompt generation use the same domain contracts.
### PR 3 — Extract JSX policy and interpreter
- Move AST types, parser adapter, shared security policy, analyzer, evaluator, budgets, bindings, and registry into `jsx`.
- Separate evaluator modules by expression family and React emission.
- Generate diagnostics and runtime enforcement from the same policy constants.
- Keep the Mantine export-classification CI check.
Gate: the security suite passes unchanged, analyzer/runtime policy drift tests pass, and no consumer imports JSX internals.
### PR 4 — Extract runtime
- Move built-in renderers, Custom JSX display, SubFetch, actions, toggles, refresh, and error containment into `runtime`.
- Introduce the runtime port and adapters for board and preview contexts.
- Remove direct imports of the API client, modals, and notifications from domain components.
- Split request components into query, action, toggle, refresh, feedback, and shared-state modules.
Gate: board rendering and workbench preview use the same runtime components through different adapters; actions retain confirmation, rollback, invalidation, and permission behavior.
### PR 5 — Extract server services
- Move executor, manifests, network policy, limits, caching/single-flight, and preview-session logic into `server`.
- Replace `TRPCError` below the adapter with typed domain errors.
- Split API procedures into management, templates, preview, and runtime routers that delegate to package services.
- Keep authorization, database lookup, encryption, and tRPC/MCP metadata in `@homarr/api`.
Gate: browser and MCP flows call the same services; request security tests run as Node package tests; public procedure names and inputs remain compatible.
### PR 6 — Extract and fragment workbench
- Move reusable editor, preview, response tree, import review, connection section, display picker, and display-specific fields into `workbench`.
- Introduce a workbench port for validate, preview, query, simulate action, journal, and save callbacks.
- Keep Next.js pages responsible only for routing, loading initial data, and constructing adapters.
- Replace the monolithic form with small sections and descriptor-driven display fields while preserving the continuous form, two-pane scrolling, autofocus, and mobile modes.
Gate: no workbench source file exceeds the agreed budget, and create/edit/paste replacement share one state model.
### PR 7 — Remove scaffolding and enforce boundaries
- Remove compatibility re-exports and old implementations after all consumers move.
- Add CI checks for forbidden imports, dependency cycles, source-size budgets, untranslated literals, generated-artifact drift, component classification, and custom-JSX chunk size.
- Update contributor and architecture documentation with package entry points and extension recipes.
Gate: old ownership paths contain only thin adapters or are deleted.
## Verification strategy
### Core
- Every display descriptor accepts valid data and rejects mismatched configuration.
- All create/edit/import/export transformations are lossless where expected.
- V1/v2/v3 compatibility and review states are deterministic.
- Prompt output, authoring bundles, fixtures, docs data, and completion data are generated from shared sources.
### JSX
- Computed and Unicode-obfuscated reflective access remains blocked.
- Analyzer and interpreter share property, prop, URL, and budget policy.
- Own-property resolution, branded calls, callback accounting, and node/operation limits remain enforced.
- Every upstream Mantine export is classified and every enabled runtime component resolves.
### Runtime and workbench
- Query cancellation, stale results, retries, render children, action locks, DELETE confirmation, toggle rollback, and invalidation behave identically in preview and boards.
- Error boundaries isolate template and display failures.
- Empty, sample, stale, loading, failure, and success states are localized and accessible.
- Keyboard, focus, responsive, reduced-motion, and narrow-widget behavior remain covered.
### Server and adapters
- Board IDOR, permission levels, anonymous actions, named-request kinds, substitutions, DNS classes, redirects, headers, response limits, caching, and rate limits remain covered.
- Server tests run under an explicit Node environment rather than failing during client-environment initialization.
- MCP schemas and descriptions remain discoverable, stable, and absent from unsafe runtime procedures.
### Required commands
At the end of the migration, the package and repository must pass:
```sh
pnpm --filter @homarr/custom-widgets test
pnpm test
pnpm test:e2e
pnpm turbo typecheck --affected
pnpm turbo lint --affected
pnpm turbo build --filter=@homarr/docs
pnpm check:custom-widget-bundle # after the Next.js production build
```
CI additionally checks dependency direction, cycles, file budgets, generated references, translations, and client bundle size.
## Completion criteria
- `@homarr/custom-widgets` owns every reusable custom-widget domain behavior.
- No circular dependencies exist.
- The package does not import `@homarr/api`, `@homarr/widgets`, or Next.js route modules.
- Runtime and workbench networking uses injected typed ports.
- One typed descriptor is the source of truth for each display type.
- One shared JSX policy drives authoring diagnostics and runtime enforcement.
- Create, edit, import, export, preview, MCP, and board runtime use the same typed transformations.
- Public tRPC/MCP names, import formats, stored configuration, and permission behavior remain compatible.
- V1 display-only migration and network-review states remain tested.
- Production modules satisfy the 300/400-line budgets.
- No untranslated user-facing strings remain.
- Core, JSX, runtime, server, workbench, integration, E2E, accessibility, docs, and bundle checks pass.
- A maintainer can locate a custom-widget behavior from the package structure without searching unrelated packages.
## Explicit non-goals
- Replacing Mantine or Homarr's visual language.
- Allowing arbitrary cross-origin requests.
- Replacing named requests with general JavaScript execution.
- Changing stored secrets or exposing them through export or MCP.
- Redesigning the current management UX during the package extraction.
- Removing compatibility formats before migration and review flows are proven.
## Decision record
- The package owns all Custom Widget behavior, not only Custom JSX.
- Security and correctness are hard constraints.
- Maintainer simplicity is the primary tie-breaker after those constraints.
- Current user and agent workflows are preserved during extraction.
- Generated artifacts do not count toward source-size budgets.
- Migration occurs through reviewable, behavior-preserving PRs rather than a single rewrite.
+23
View File
@@ -0,0 +1,23 @@
# Workshop design system
Workshop is a trustworthy community workbench inside Homarr, not a separate storefront brand.
## Visual direction
- Reuse the host surface: Docusaurus tokens on `homarr.dev`, Mantine components inside Homarr.
- Use Homarr red only for primary action and focus; warnings use restrained amber and errors use red with explanatory text.
- Keep surfaces flat, bordered, and readable. Cards may lift by 3px on pointer hover; no gradients, glass panels, or decorative dashboards.
- Typography follows each host. Headings are compact and confident; body copy stays warm, concrete, and short.
- Spacing follows an 8px rhythm, 12-16px corners, and 44px minimum controls.
## Interaction direction
- The catalog progression is discover → inspect → confirm → use.
- Every remote state has a named loading, empty, cached, unavailable, validation, permission, and success outcome.
- Community source is never executed on the website. Installation always follows source inspection and local validation.
- Motion is limited to 150-200ms hover/state feedback and skeleton loading, with reduced-motion fallbacks.
- Native dialogs or Mantine modals own focus, Escape, backdrop dismissal, and keyboard order.
## Voice
Clear, dependable, and community-minded. Explain what remains usable during failures and provide one direct recovery action. Avoid playful language during moderation, destructive actions, or service failures.
+3 -3
View File
@@ -4,6 +4,7 @@ FROM node:24.18.0-alpine AS base
FROM base AS builder
WORKDIR /app
ARG CI='true'
RUN apk add --no-cache libc6-compat curl bash && apk update
RUN corepack enable pnpm
@@ -24,12 +25,12 @@ RUN --mount=type=cache,id=pnpm-store,target=/pnpm/store \
COPY . .
# Follow the pnpm fetch pattern from https://pnpm.io/cli/fetch
# --offline enforces no registry access (all packages are in the store from fetch)
# --frozen-lockfile is omitted as recommended by the pnpm fetch docs
RUN --mount=type=cache,id=pnpm-store,target=/pnpm/store \
pnpm install --recursive
pnpm install --recursive --offline
ARG SKIP_ENV_VALIDATION='true'
ARG CI='true'
ARG DISABLE_REDIS_LOGS='true'
ARG TARGETPLATFORM
@@ -81,7 +82,6 @@ COPY nginx.conf /etc/nginx/templates/nginx.conf
ENV DB_URL='/appdata/db/db.sqlite'
ENV DB_DIALECT='sqlite'
ENV DB_DRIVER='better-sqlite3'
ENV AUTH_PROVIDERS='credentials'
ENV REDIS_IS_EXTERNAL='false'
ENV NODE_ENV='production'
+33
View File
@@ -0,0 +1,33 @@
# Product
## Register
product
## Users
Homarr serves people who organize and operate self-hosted services, from individual home-lab users to administrators managing shared instances. Workshop also serves community creators who publish and install custom widgets and CSS themes. Users need the core dashboard to remain useful even when optional community services are offline.
## Product Purpose
Homarr provides a dependable dashboard for discovering, organizing, and operating applications and integrations. Workshop adds a central community catalog where users can inspect, export, publish, report, and install creations with clear source visibility and explicit confirmation.
## Brand Personality
Clear, dependable, and community-minded. Interface copy should be direct and practical, with enough context for users to recover from failures without exposing internal implementation details.
## Anti-references
Avoid fragmented feature surfaces, generic failure messages, decorative dashboard patterns, unexpected network dependencies, and optional services that can crash or block core Homarr workflows.
## Design Principles
- Keep core Homarr workflows available when optional services fail.
- Explain failures in user terms and always offer a useful recovery action.
- Use native Homarr patterns and shared contracts instead of feature-specific interaction systems.
- Make community content inspectable and require explicit confirmation before installation.
- Keep operational behavior deterministic for contributors and instance administrators.
## Accessibility & Inclusion
Meet WCAG 2.1 AA for contrast, focus, semantics, and keyboard operation. Respect reduced-motion preferences, preserve useful layouts at 200% zoom and on mobile, and ensure status changes and recovery actions are understandable without relying on color alone.
+14 -24
View File
@@ -5,47 +5,43 @@ tags:
- Help
---
import Tabs from "@theme/Tabs";
import TabItem from "@theme/TabItem";
import Tabs from '@theme/Tabs';
import TabItem from '@theme/TabItem';
### Can I install Homarr on a Raspberry Pi?
Yes, Homarr is compatible with the architecture of the Raspberry Pi. Have a look in our <a href="/docs/category/installation-1">installation guide</a> on how to do so.
### I have a suggestion / idea for improvement or want to report a bug
You can report issues, suggestions, or ideas for improvements <a href="https://github.com/homarr-labs/homarr/issues/new/choose">here</a>.
### Can I use my own custom icons for apps?
Yes, check out our guide on <a href="/docs/advanced/customizations/icons">how to add your custom icons</a>.
### Some of my online icons are not working (anymore)
It seems the `cdn.jsdelivr.net` database of icon sometimes appear in the lists for DNS-Holes (PiHole, AdGuard Home...). <br />
So make sure to manually whitelist this address.
### Can I add my own widgets? Do I need programming skills?
We have decided, not to support custom widgets, until Homarr is in a stable and highly polished state.
Enabling users to write their own widgets is easy at the first glance, but there are some major issues with it:
Yes. Administrators can create [Custom Widgets](/docs/management/custom-widgets) that read an HTTP API and render the response with a built-in display type. These display types require no programming.
- There are mainly two options for adding plugins or custom widgets to Homarr. The first option is, to allow and inject custom code in Homarr. Apps like Nextcloud can do this easily, because they use the Runtime to render all components of the application (In reality, the process is still a bit more complicated). In contrast, Homarr is using Next.js - a framework that needs to be compiled in production. That means, that each time you would modify a custom widget or plugin, the app would have to be restarted. There are options to avoid this, but they are quite flakey and bring disadvantages in our experience. The second option is, to write widgets declaratively instead of imperatively. This way, we would be able to render and modify widgets without any restarting at all. However, we'd have to write our own interpreter for said declarative schema. This is totally possible, but requires a lot of planning and architecture design to function properly. The required work to implement either option is therefore quite substantial.
- Custom widgets would need to be able to request data, so they work properly. We use tRPC for this. Many APIs use different formats and we'd have to convert and read those.
- Using custom widgets, one could easily create harmful widgets, leak your personal data (potentially including your tokens) and trick your users. A proper implementation (eg. official plugin store, where we can ban harmful widgets from) would resolve this problem to a certain extent, but again requires additional work and time to be put in.
Custom JSX is available when you need a more specific layout or an interactive control. It uses a restricted declarative JSX interpreter with a classified Mantine component set. Network operations are declared as named, same-origin requests and are checked against board permissions on the server. It does not execute arbitrary JavaScript or expose browser `fetch`.
You should be comfortable reading JSON and JSX to author complex templates. The management workbench includes examples, diagnostics, a component reference, API response inspection, and a prompt you can copy into an AI assistant. Always review imported templates and request capabilities before saving them.
If you still want to add a widget, please consider to contribute it directly to Homarr. For that, basic programming skills are required. Our [Developer Guides](/docs/advanced/development/getting-started) explain this even more detailed.
### Is it secure to expose Homarr to the web?
Yes, since v0.14.0, we are confident that Homarr is secure enough to be exposed to the web.
Yes, since v0.14.0, we are confident that Homarr is secure enough to be exposed to the web.
Make sure to enable the "allow anonymous access" [option in the settings](/docs/management/boards#access-control), if you want to allow people without an account to be able to see your dashboard (not edit)
### How do I open the console / log?
There are two different sources of information:
- console: This is your client (browser). It can tell us, if there is an issue on the client (crash, bug, ...).
- log: This is your server. It will tell us, if there are issues with connections for the integrations, crashes and errors.
Both variants _can_ contain personal data. It's recommended, that you don't post them publicly or remove personal data from it.
Both variants *can* contain personal data. It's recommended, that you don't post them publicly or remove personal data from it.
<Tabs>
<TabItem value="apple" label="Read console" default>
@@ -53,45 +49,39 @@ Both variants _can_ contain personal data. It's recommended, that you don't post
> Note: This will not work on most mobile browsers.
Open your browser and ensure, that Homarr is your current tab.
Press `CTRL` + `SHIFT` + `I` on your keyboard. If this shortcut doesn't work, please use one of the alternatives: https://developer.chrome.com/docs/devtools/open/.
Click on `Console` in the new window, that opened. There, you should see white, orange and sometimes red messages. You can use the Windows Snip & Sketch tool (or your respective tool on Linux / MacOS) to screenshot those messages.
Press ``CTRL`` + ``SHIFT`` + ``I`` on your keyboard. If this shortcut doesn't work, please use one of the alternatives: https://developer.chrome.com/docs/devtools/open/.
Click on ``Console`` in the new window, that opened. There, you should see white, orange and sometimes red messages. You can use the Windows Snip & Sketch tool (or your respective tool on Linux / MacOS) to screenshot those messages.
> Please never execute any commands in the console. Scammers are using the console to let you execute harmful commands. If you want to resolve an issue in Homarr, **you'll never have to execute any commands here**
</TabItem>
<TabItem value="orange" label="Read log">
This depends on your installation method. If you use Unraid, you can simply click on the App and on `Log`. If you have a bare Docker install, use `docker container logs homarr`. If you used a different name than `homarr` for your container, you need to replace said argument. Here is some additional information about the command: https://geekflare.com/check-docker-logs/.
This depends on your installation method. If you use Unraid, you can simply click on the App and on ``Log``. If you have a bare Docker install, use ``docker container logs homarr``. If you used a different name than ``homarr`` for your container, you need to replace said argument. Here is some additional information about the command: https://geekflare.com/check-docker-logs/.
</TabItem>
</Tabs>
### How do I post logs?
Please check [the section above](#how-do-i-open-the-console--log), on how to obtain logs. Logs should always be posted to https://pastebin.com/ or https://rentry.co/.
### Can I use Homarr at my company?
Yes, you can! But please consider a donation, if our work has been useful to you.
### I think I found a bug. What can I do?
If you think, that you found a bug, please create an issue <a href="https://github.com/homarr-labs/homarr/issues/new/choose">here</a>.
### Why can't I use my custom icons?
You need to restart the Homarr Docker container after adding icons.
### Why can't homarr see/ping containers running locally behind GluetunVPN?
This is because GluetunVPN, and many other docker VPN services, secure their VPN connections with strict firewalls. These firewalls often block access to local containers, even on the same docker network. The workaround for <a href="https://github.com/qdm12/gluetun-wiki/blob/main/setup/options/firewall.md">GluetunVPN</a> is to add "- FIREWALL_OUTBOUND_SUBNETS=192.168.1.0/24" into the environmental variables of the docker compose file. If you are using a firewall on your system, you will need to add a rule that allows requests from ip's in the range of 192.168.xxx.xxx . If you are using UFW the command would be "sudo ufw allow from 192.168.0.0/16".
Tip: If you are running your torrent client behind a VPN, and running Sonarr (or something similar) normally, make sure to open your torrent client's port in your firewall. If you don't, Sonarr will not be able to see it. Furthermore, use your machines IP (192.168.x.x) rather then 127.0.0.1 or localhost.
### Can I get in touch with you?
Yes, go <a href="/docs/community/get-in-touch">here</a> to get with us in touch.
<hr />
<hr/>
:::tip
+5 -4
View File
@@ -24,10 +24,10 @@ Navigate to `Management` → `Tools` → `Backup`. You must have **admin** permi
Click the **Export** button in the export card. You'll be prompted to type `I understand` to confirm. Once confirmed, a ZIP file downloads containing:
| File | Description |
| -------------------- | ------------------------------------------------------------------- |
| `db.sqlite` | Full SQLite database with all entities |
| `metadata.json` | Backup metadata: Homarr version, creation timestamp, encryption key |
| File | Description |
| --------------- | ------------------------------------------------------------------- |
| `db.sqlite` | Full SQLite database with all entities |
| `metadata.json` | Backup metadata: Homarr version, creation timestamp, encryption key |
The backup includes everything: apps, integrations, secrets, boards, users, groups, permissions, search engines, custom widgets, and server settings. (It just backs-up the whole database)
@@ -54,6 +54,7 @@ Type `I understand` to acknowledge that restoring will replace your current data
The backup ZIP is sent to the server, validated, and applied. After a successful restore:
- **The Next.js server restarts automatically** to reload the new database state
- **Server-side database migrations run before Next.js restarts**, including migrations added after the backup was created
- Nginx and Redis (if running in Docker) stay up - only the Next.js process restarts
- The page reloads once the server is ready
@@ -1,26 +0,0 @@
---
sidebar_label: AI and MCP authoring
---
# AI and MCP authoring
Homarr exposes an iterative, validation-first workflow to authenticated administrators. Agents should continue until validation, preview, and diagnostics are clean; they must not invent inline network access.
## Recommended loop
1. Read `customWidget_schema` and the generated component/capability reference.
2. Read an existing definition with `customWidget_byId`, or create a v3 draft.
3. Call `customWidget_validate`; fix every issue using its exact path.
4. Call `customWidget_preview` for the base GET and retain the preview session ID.
5. Test each named query with `customWidget_previewQuery`.
6. Dry-run every action with `customWidget_simulatePreviewAction`.
7. Inspect `customWidget_previewJournal` for status, duration, and method coverage.
8. Revalidate, then call import/create or a focused update/patch operation.
Stored secret values are never readable. Omitting secrets during an update preserves them. MCP actions are simulation-only; enabling live preview actions remains a browser-only administrator decision.
## Template format
Use multiline JSX when reading or patching templates. Prefer focused template read/write/patch tools over rewriting an escaped one-line JSON string. When a complete portable artifact is needed, use the authoring bundle placeholder plus a separate fenced JSX block.
The AI prompt is generated from the same registry, examples, request rules, and limits as the validator. Agents should still trust validation output over prompt memory because the installed Homarr version is authoritative.
@@ -1,29 +0,0 @@
---
sidebar_label: Maintainer architecture
---
# Maintainer architecture
Reusable Custom Widget behavior belongs to `@homarr/custom-widgets`:
- `core` owns schemas, descriptors, migrations, import/export, examples, diagnostics metadata, and prompt generation.
- `jsx` owns AST parsing, shared policy, interpreter, bindings, registry, wrappers, and analysis.
- `runtime` owns renderers and named-request controls through injected ports.
- `server` owns manifests, network execution, caching, limits, preview sessions, and typed domain errors.
- `workbench` owns form contracts, editor analysis, preview UI, response tree, and import review through injected ports.
The API package retains persistence and board authorization adapters. Widgets retain board registration and runtime transport wiring. Next.js routes retain navigation and shells. The domain package must never import API, widgets, or route modules.
## Add a display type
Extend the typed descriptor and discriminated schema, defaults/hydration/serialization, renderer, workbench section, preview, import round-trip fixtures, docs, and translations. Create, edit, import, export, MCP, and preview must all use the same transform.
## Add a JSX component
Classify the upstream export, document safety mode and supported props, add a wrapper if behavior needs constraining, and update registry tests. CI fails when a Mantine upgrade introduces an unclassified export.
## Change networking
Implement policy below the framework-neutral server boundary and expose infrastructure through a typed port. Add tests for permissions, typed substitution, DNS classes, redirects, headers, timeouts, limits, redaction, cache behavior, and action non-retry semantics before wiring the tRPC adapter.
Production files target 300 lines. Algorithmic AST/network modules may use a documented 400-line ceiling. Dependency-boundary, cycle, file-size, translation, generated-artifact, component-classification, focused unit, integration, E2E, accessibility, and docs-build gates protect the architecture. After a Next.js production build, `pnpm check:custom-widget-bundle` also enforces a 768 KiB gzip ceiling across the client chunks referenced by Custom JSX.
@@ -1,36 +0,0 @@
---
sidebar_label: Authoring workbench
---
# Authoring workbench
The Custom Widget form is a continuous workflow: choose a display, configure the HTTP connection, configure the display, then test it in the adjacent preview. Each section stays visible so you can revisit earlier choices without losing context.
## Choose a display
The display grid is the first product decision. Built-in displays are best for common metrics, tables, statuses, and actions. Choose Custom JSX when the layout needs multiple Mantine components, conditional presentation, charts, dates, or named requests.
Changing display type preserves shared connection fields and initializes safe display defaults. A Custom JSX template is never blank; new and migrated forms receive the tested starter fixture.
## Test and preview
The preview has four tabs:
- **Widget** renders at compact, standard, or wide board-like sizes.
- **Response** uses a tree to inspect data, copy paths, and insert paths into JSX. The external-response action opens a temporary local blob and never publishes the response.
- **Request** shows the redacted base request, named capabilities, status, duration, and preview journal.
- **Diagnostics** shows template size, named requests, network scope, and blocking or advisory issues.
Only GET base requests run automatically. Preview actions are simulated until an administrator explicitly enables live actions for the five-minute preview session. DELETE still requires confirmation.
## Keyboard and clipboard
The JSX and JSON editors support native Ctrl/Cmd+Z and redo, plus visible toolbar controls. Cursor position, character count, and the 50,000-character limit remain visible.
On the list page, paste a complete v3 export with Ctrl+V or Command+V to import it as new. On an edit page, pasting opens a replacement review. Nothing is applied before the origin, methods, credential inheritance, and permissions are shown.
For AI output, prefer the authoring bundle format: a JSON block containing `"template": "__HOMARR_TEMPLATE__"`, followed by a JSX block. This keeps multiline JSX readable and avoids JSON escape errors.
## Save behavior
Errors block save; warnings do not. Server validation runs again on every create or update. Leaving with dirty changes triggers protection. Secrets omitted during edit remain unchanged, and secrets are never exported.
@@ -1,49 +0,0 @@
---
sidebar_label: Custom JSX language
---
# Custom JSX language
Custom JSX is a declarative expression language, not browser JavaScript. Homarr parses an explicit AST subset and emits React elements from a classified component registry. Templates cannot access browser globals, native prototypes, constructors, refs, portals, raw event handlers, or direct `fetch`.
## Data and rendering
The base GET response is available as `data`. Safe member access, conditionals, arrays, object literals, and interpreter-created collection callbacks can compose displays:
```jsx
<Stack gap="xs">
{data.services
.filter((service) => service.visible)
.map((service) => (
<Group key={service.id} justify="space-between">
<Text>{service.name}</Text>
<Badge color={service.online ? "green" : "red"}>{service.status}</Badge>
</Group>
))}
</Stack>
```
Properties are resolved own-only. Reflective names such as `constructor`, `prototype`, `__proto__`, `caller`, `callee`, `arguments`, `bind`, `call`, and `apply` are rejected even when computed or Unicode escaped.
## Components and props
Every Mantine export is classified as allowed, wrapped, or denied. The [generated component reference](./#component-reference) is authoritative. Props are allowlisted per component; unsupported props produce warnings and are ignored. Polymorphic roots, unsafe URLs, portals, positioning escapes, refs, and function props are stripped.
`SubData` is a convenience for simple response paths. Prefer the render-child pattern for rich nested results:
```jsx
<SubFetch requestId="detail" params={{ id: data.selectedId }}>
{(detail, meta) => (
<Stack>
<Title>{detail.name}</Title>
<Text>HTTP {meta.status}</Text>
</Stack>
)}
</SubFetch>
```
## Safe helpers and budgets
Bindings include restricted conversion, string, array, object, JSON, math, URI, and pure date helpers. Native `Date` objects and native prototypes are not exposed.
Templates are limited to 50,000 characters, 10,000 rendered nodes, and explicit AST-depth, operation, collection, and string budgets. Budget errors stop only the Custom JSX renderer and are contained by its error boundary.
@@ -1,66 +0,0 @@
---
sidebar_label: Extension recipes
---
# Custom Widget extension recipes
These recipes keep every authoring and runtime path on the same typed source of truth. Run `pnpm check:custom-widgets` after each structural change and add focused package tests before changing an adapter.
## Add a built-in display type
1. Add the discriminated configuration schema in `@homarr/custom-widgets/core`.
2. Add one descriptor with its icon, category, localized metadata keys, defaults, hydration, serialization, and validation.
3. Add the renderer to the runtime display map. It must consume normalized display data and injected actions only.
4. Add a bounded workbench section that edits the descriptor-owned form fields.
5. Add one fixture and assert create, edit, v2/v3 import, export, MCP, and preview round trips against it.
6. Add translation keys and update the display table in the user guide.
Do not add route-specific conversion code. `buildDisplayFormValues` and `buildDisplayConfigFromFormValues` are the only form conversion boundary.
## Add or update a JSX component
1. Classify the Mantine export as `allowed`, `wrapped`, or `denied` in the component registry. Denied entries require a reason.
2. Declare supported props and subcomponents. Function props, refs, portals, polymorphic roots, and unsafe positioning stay denied unless a wrapper removes the unsafe behavior.
3. Implement wrappers in the JSX package and register them through the package runtime-component factory.
4. Add analyzer and renderer tests for accepted props, stripped props, reflective-property attacks, and the rendered-node budget.
5. Regenerate the docs reference and verify the editor completion and AI prompt include the same entry.
The component-classification test must pass against the locked Mantine version. Never expose an upstream namespace or native prototype as a shortcut.
## Add a named-request capability
1. Extend the request schema and manifest renderer in `core` and `server` without accepting a raw runtime URL, method, header, or body.
2. Enforce permission, network scope, redirect, DNS, header, body, response, concurrency, and rate policies in the framework-neutral server service.
3. Expose infrastructure through a typed server port. Translate domain errors only in the API adapter.
4. Add the control to runtime through the injected request port. Queries use query semantics; actions require a gesture and use mutation semantics.
5. Add preview simulation and redacted-journal coverage. Live actions remain an explicit administrator-only browser choice.
6. Update MCP validation and simulation tools without exposing stored secrets or unrestricted action execution.
Tests must prove actions are never mounted automatically, polled, retried, cached, or redirected. DELETE must retain full permission and confirmation.
## Add a workbench feature
1. Put reusable state, analysis, or presentation in `@homarr/custom-widgets/workbench`.
2. Receive localized messages and typed callbacks as props. The package cannot import the tRPC client, Next.js navigation, notifications, or Homarr widget adapters.
3. Keep the Next.js component as a transport and page-shell adapter.
4. Cover keyboard operation, focus order, reduced motion, 320/768/desktop layouts, dirty-state behavior, and error-boundary recovery.
5. Keep each production module below 300 lines and preserve the continuous two-pane form behavior.
## Change examples or AI authoring
Examples live as typed shared fixtures. Tests, snippets, documentation data, and the AI prompt consume those fixtures. AI output uses an authoring bundle: a JSON block with `__HOMARR_TEMPLATE__`, followed by a readable multiline JSX block. Do not require agents to escape an entire template into one JSON string.
After changing a fixture, validate every example, render it, regenerate the component reference, and run the prompt snapshot tests. MCP agents must be able to read a widget, patch template lines with a revision, validate, preview named queries, simulate actions, inspect the journal, and update the definition without receiving secret values.
## Required checks
```bash
pnpm check:custom-widgets
pnpm test
pnpm test:e2e
pnpm turbo typecheck --affected
pnpm turbo lint --affected
pnpm turbo build --filter=@homarr/docs
```
Also run the component-classification, generated-reference drift, bundle-budget, accessibility, and focused package/API suites configured by the repository. A change is incomplete if any create, edit, import, export, MCP, preview, or board-runtime path uses a separate transform.
+166 -158
View File
@@ -6,199 +6,207 @@ tags:
- JSX
---
import { CustomJsxComponentReference } from "@site/src/components/custom-jsx-component-reference";
# Custom Widgets
Custom widgets connect a dashboard item to an HTTP API without compiling a new Homarr widget. Administrators choose the endpoint and authentication, then use a built-in display type or a Custom JSX template to render the response.
:::warning
## Guides
- [Authoring workbench](./authoring): create, edit, preview, paste, undo, import, and export.
- [Custom JSX language](./custom-jsx): supported syntax, components, bindings, diagnostics, and limits.
- [Named requests and security](./requests-and-security): permissions, network scopes, request manifests, and server protections.
- [AI and MCP authoring](./agent-authoring): an iterative agent workflow that validates and previews before saving.
- [Compatibility and migration](./migration): v1/v2 JSX and v2/v3 import behavior.
- [Troubleshooting](./troubleshooting): common editor, preview, permission, and networking failures.
- [Maintainer architecture](./architecture): package boundaries, extension points, and required tests.
- [Extension recipes](./extension-recipes): step-by-step changes for displays, JSX components, requests, and workbench features.
:::caution
A custom widget can expose data or perform actions with the credentials configured for it. Import definitions only from sources you trust and review every network capability before using the widget on a shared board.
Custom widgets are a new feature. Share feedback, ask questions, and discuss ideas in our [GitHub Discussions](https://github.com/homarr-labs/homarr/discussions/categories/custom-widgets).
:::
## Create a widget
Custom widgets allow you to fetch data from any HTTP API and display it on your Homarr dashboard. You define the API endpoint, authentication, and how the response should be rendered - no code compilation or server restart required.
Open **Management → Custom Widgets**, select **Add**, and configure:
## Creating a Custom Widget
- **Name, description, and icon**: identify the widget in management and the widget picker.
- **URL**: the full HTTP or HTTPS base endpoint.
- **Authentication**: none, bearer token, basic authentication, API-key header, or API-key query parameter.
- **Display type**: a built-in renderer or Custom JSX.
To create a custom widget, navigate to the **Custom Widgets** section in the management sidebar and click the **Add** button. Fill in the configuration:
Use **Test** to run the current GET request. The preview workbench separates the rendered widget, sanitized response tree, redacted request journal, and diagnostics. It marks sample data as stale whenever the connection or display configuration changes after a test.
- **Name** - Display name shown in the widget picker and on the dashboard
- **Description** - Optional description shown in tooltips and management UI
- **Icon URL** - Optional URL to an icon image for the widget
- **URL** - The API endpoint to fetch data from (must include protocol, e.g. `https://api.example.com/stats`)
- **Authentication** - How the API request is authenticated:
- `none` - Public APIs with no authentication
- `bearer` - Bearer token authentication
- `basic` - HTTP Basic auth (username + password)
- `apiKeyHeader` - API key sent as a custom HTTP header
- `apiKeyQuery` - API key sent as a query parameter
- **Header Name** - Custom header or query parameter name for API key auth (e.g. `X-API-Key`)
- **HTTP Method** - `GET`, `POST`, `PUT`, `PATCH`, or `DELETE`
- **Request Body** - JSON string body for `POST`/`PUT`/`PATCH` requests
- **Display Type** - How the API response is rendered (see below)
Testing creates a five-minute, administrator-bound preview session for unsaved named queries. Action controls are simulated by default. An administrator can explicitly enable live actions for that session; DELETE still requires confirmation.
## Display Types
## Display types
| Display Type | Description | Best For |
| -------------------- | ---------------------------------------------------- | ---------------------------------------- |
| **Single Value** | Extracts a single value with a label and unit | Metrics: temperature, uptime, user count |
| **Key Value** | Multiple labeled key-value pairs with optional units | System stats: CPU, memory, disk |
| **Table** | API array data rendered as a sortable table | Lists of items with columns |
| **Stat Grid** | Labeled values in colored stat cards | Counts: movies, series, albums |
| **Progress Bars** | Values as progress bars with percentages | Usage: disk, quota, capacity |
| **Status Indicator** | Green/red status dots for health checks | Service monitoring |
| **Count Grid** | Simple counts in a grid (no colors) | Library counts |
| **Raw** | Pretty-prints the raw JSON response | Debugging |
| **Action Button** | Clickable button executing the API request | Triggers: restart, clear cache |
| **Custom JSX** | Custom JSX template with Mantine v9 components | Full layout control |
| Display type | Use it for |
| ---------------- | --------------------------------------------------------- |
| Single Value | One metric with a label and unit |
| Key Value | Several labeled values |
| Table | Arrays of records |
| Stat Grid | Compact colored metrics |
| Progress Bars | Capacity, quota, and completion |
| Status Indicator | Health and availability |
| Count Grid | Compact counters |
| Raw | Sanitized JSON inspection |
| Action Button | One explicit API action |
| Custom JSX | A declarative Mantine layout with optional named requests |
### AI-Powered JSX Generation
## Custom JSX workbench
When editing or creating a custom widget with the **Custom JSX** display type, you'll find a **Copy Prompt** button. Clicking it copies a pre-formatted prompt describing your widget's name, API endpoint, and available Mantine components. Paste this into your preferred AI assistant (ChatGPT, Claude, Copilot, etc.) to generate a JSX template tailored to your data.
The JSX editor provides syntax highlighting, formatting, undo/redo buttons and Ctrl/Cmd+Z shortcuts, a searchable component reference, character and cursor status, and diagnostics. Errors block saving; warnings identify behavior that still receives server-side validation. Templates can contain up to 50,000 characters and render up to 10,000 nodes.
The template reads the base GET response from `data`:
```jsx
<Stack gap="xs" p="sm">
<Text fw={600}>{data.name}</Text>
<Badge color={data.online ? "green" : "red"}>{data.status}</Badge>
</Stack>
```
The **Copy AI prompt** action is available on desktop and mobile. Its prompt, component list, and examples are generated from the same metadata used by the renderer, so the reference stays aligned with the installed Homarr version.
### JSX API versions
- **Version 2** is the default. Network access is declared in a named-request manifest and enforced by the server.
- **Version 1** remains available for display-only templates. Inline network components are disabled until the definition is reviewed and migrated.
Definitions without `jsxApiVersion` are treated as version 1. Display-only templates can be migrated by selecting version 2 and saving an empty request list.
## Named requests
Version 2 templates never choose an arbitrary URL, method, header, or body at runtime. An administrator declares each allowed operation once, and the template references its `requestId` with typed parameters.
<details>
<summary>Example: Pokédex widget using Custom JSX</summary>
```json
{
"type": "customJsx",
"jsxApiVersion": 2,
"networkScope": "public",
"template": "<SubFetch requestId=\"service-detail\" params={{ id: data.id }}><SubData path=\"status\" as=\"Badge\" /></SubFetch>",
"requests": [
{
"id": "service-detail",
"kind": "query",
"method": "GET",
"pathTemplate": "/api/services/{id}",
"parameters": { "id": "string" },
"auth": "inherit",
"minimumBoardPermission": "view",
"cacheTtlSeconds": 30
}
]
}
```
Path placeholders use `{parameter}` and are URL-encoded by the server. JSON body placeholders use `{ "$param": "parameter" }` and preserve the declared string, number, or boolean type.
`SubData` remains available for simple paths. For richer nested output, pass an interpreter-created render child; it receives the response data and safe status metadata:
```jsx
<SubFetch requestId="service-detail" params={{ id: data.id }}>
{(detail, meta) => (
<Stack gap="xs">
<Badge color={detail.online ? "green" : "red"}>{detail.status}</Badge>
<Text size="xs">HTTP {meta.status}</Text>
</Stack>
)}
</SubFetch>
```
| Component | Request kind | Behavior |
| --------------- | ------------ | ------------------------------------------------------------- |
| `SubFetch` | `query` | Loads a named GET request automatically or manually |
| `ActionButton` | `action` | Runs a named POST, PUT, PATCH, or DELETE after a user gesture |
| `ToggleSwitch` | `action` | Passes declared on/off parameters and rolls back on failure |
| `RefreshButton` | None | Refreshes the parent GET response |
Queries require board **view** permission. POST, PUT, and PATCH actions require **modify** by default. DELETE requires **full** permission and confirmation. Allowing viewers to execute a non-DELETE action requires explicit administrator approval. Anonymous viewers cannot execute actions.
### Network scopes
| Scope | Allowed destinations |
| ---------- | ------------------------------------------------------------ |
| `public` | Public IP addresses only |
| `private` | Public addresses plus RFC1918 and IPv6 ULA private addresses |
| `loopback` | Public, private, and loopback addresses |
Link-local, cloud-metadata, unspecified, multicast, documentation, carrier-grade NAT, and IPv4-mapped bypass addresses remain blocked in every scope.
### Example with a named query
The editor and AI prompt include a shared Pokédex fixture that loads details only for the visible page. Its named request is equivalent to:
```json
{
"id": "pokemon-detail",
"kind": "query",
"$schema": "homarr-custom-widget-v2",
"name": "Pokédex",
"description": "Browseable Pokémon list",
"iconUrl": "https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/items/poke-ball.png",
"url": "https://pokeapi.co/api/v2/pokemon?limit=75",
"authType": "none",
"method": "GET",
"pathTemplate": "/api/v2/pokemon/{name}",
"parameters": { "name": "string" },
"auth": "none",
"minimumBoardPermission": "view",
"cacheTtlSeconds": 300
"displayType": "customJsx",
"displayConfig": {
"type": "customJsx",
"template": "<Stack gap=\"md\" p=\"xs\">\n <Card withBorder radius=\"xl\" p=\"md\" shadow=\"md\">\n <Group justify=\"space-between\">\n <Title order={3}>Pokédex</Title>\n </Group>\n </Card>\n <PaginatedList pageSize={12}>\n <Grid gutter=\"sm\">\n {data.results.map((pokemon, i) =>\n <Grid.Col span={1.5}>\n <Anchor href={pokemon.url} target=\"_blank\" underline=\"never\">\n <Card withBorder radius=\"xl\" p=\"xs\" shadow=\"md\">\n <Stack gap=\"sm\" align=\"center\">\n <Group justify=\"space-between\" style={{ width: \"100%\" }}>\n <Badge size=\"sm\" color=\"red\">#{i + 1}</Badge>\n <Text fw={800} tt=\"capitalize\">{pokemon.name}</Text>\n </Group>\n <Avatar\n src={\"https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/\" + (i + 1) + \".png\"}\n size={92}\n radius=\"xl\"\n />\n </Stack>\n </Card>\n </Anchor>\n </Grid.Col>\n )}\n </Grid>\n </PaginatedList>\n</Stack>"
}
}
```
## Agent authoring through MCP
</details>
Administrators can use Homarr's MCP server to iterate on custom widgets without bypassing validation or network controls. A typical agent workflow is:
## Importing and Exporting
1. Call `customWidget_schema` to inspect the current import format.
2. Generate a complete `homarr-custom-widget-v3` draft.
3. Call `customWidget_validate` and correct every returned issue.
4. Call `customWidget_preview` to test the base GET request through Homarr's hardened executor.
5. Use `customWidget_previewQuery` for named queries and `customWidget_simulatePreviewAction` to verify action parameters without sending mutations.
6. Inspect `customWidget_previewJournal`, then validate again.
7. Call `customWidget_import`, or read an existing widget with `customWidget_byId` and apply a focused `customWidget_update`.
Custom widgets can be exported and imported as JSON files. To export, select the widget in the list and click the export button. To import, click the import button and paste the JSON. Secrets (API keys, passwords) are never included in exports and must be configured after import.
MCP preview actions are simulation-only. Enabling live preview actions remains an explicit administrator choice in the browser workbench. Stored secret values are never returned by MCP; omit `secrets` when updating an existing widget to preserve them.
The import format uses `"$schema": "homarr-custom-widget-v2"` to identify the widget version.
## Component reference
## Community Workshop
Custom JSX exposes a classified subset of Mantine Core, Charts, Dates, and Homarr components. Allowed components render directly. Wrapped components receive safe props or read-only behavior. Denied components remain in the registry so dependency upgrades cannot silently expand the template surface.
Use **Install widget** on **Management → Workshop** to inspect and import community widgets. To publish your own widget, export it from Homarr and upload the JSON file in the Workshop submission form. Secrets are never included in exports and must be configured on each Homarr instance.
<CustomJsxComponentReference />
## Available Mantine Components for Custom JSX
This table and the searchable component list in the editor are generated from Homarr's runtime registry.
When using the `customJsx` display type, your template has access to a whitelist of [Mantine v9](https://mantine.dev) components. Unrecognized components, event handlers, and dangerous tags are stripped for security.
Component props are filtered. Polymorphic roots, portals, refs, unsafe positioning, raw event handlers, and `dangerouslySetInnerHTML` are not exposed. Direct browser globals and direct `fetch` calls are unavailable, while ordinary text containing the word “fetch” is valid.
### Layout
Safe bindings include conversion helpers, a restricted `Math` and `JSON` surface, object and array helpers, URI encoding helpers, and pure date formatting helpers. Collection callbacks and rendered output are subject to interpreter budgets.
| Component | Doc |
| ------------------- | --------------------------------------------- |
| `Stack` | [docs](https://mantine.dev/core/stack) |
| `Group` | [docs](https://mantine.dev/core/group) |
| `Flex` | [docs](https://mantine.dev/core/flex) |
| `Grid` / `Grid.Col` | [docs](https://mantine.dev/core/grid) |
| `SimpleGrid` | [docs](https://mantine.dev/core/simple-grid) |
| `Center` | [docs](https://mantine.dev/core/center) |
| `Container` | [docs](https://mantine.dev/core/container) |
| `Space` | [docs](https://mantine.dev/core/space) |
| `AspectRatio` | [docs](https://mantine.dev/core/aspect-ratio) |
## HTTP protections and limits
### Typography & Content
All base, preview, query, and action requests use the same server executor:
| Component | Doc |
| ----------------- | ------------------------------------------------- |
| `Text` | [docs](https://mantine.dev/core/text) |
| `Title` | [docs](https://mantine.dev/core/title) |
| `Code` | [docs](https://mantine.dev/core/code) |
| `Highlight` | [docs](https://mantine.dev/core/highlight) |
| `Mark` | [docs](https://mantine.dev/core/mark) |
| `Kbd` | [docs](https://mantine.dev/core/kbd) |
| `Blockquote` | [docs](https://mantine.dev/core/blockquote) |
| `Anchor` | [docs](https://mantine.dev/core/anchor) |
| `NumberFormatter` | [docs](https://mantine.dev/core/number-formatter) |
- Only HTTP and HTTPS are accepted. URL credentials and fragments are rejected.
- Named paths must stay on the base endpoint origin.
- DNS results are validated against the selected network scope and pinned for the request.
- Queries can follow up to three revalidated same-origin redirects. Actions do not follow redirects.
- Authorization, cookie, host, forwarding, proxy, content-length, and hop-by-hop headers cannot be supplied by a template.
- Request bodies are limited to 10 KiB. Decompressed responses are limited to 1 MiB, 32 JSON levels, and 50,000 JSON nodes.
- GET requests can be cached and deduplicated. Actions are never cached, polled, or retried automatically.
### Data Display
## Import and export
| Component | Doc |
| ------------------------- | ------------------------------------------------- |
| `Badge` | [docs](https://mantine.dev/core/badge) |
| `Card` / `Card.Section` | [docs](https://mantine.dev/core/card) |
| `Paper` | [docs](https://mantine.dev/core/paper) |
| `Alert` | [docs](https://mantine.dev/core/alert) |
| `ThemeIcon` | [docs](https://mantine.dev/core/theme-icon) |
| `ColorSwatch` | [docs](https://mantine.dev/core/color-swatch) |
| `Avatar` / `Avatar.Group` | [docs](https://mantine.dev/core/avatar) |
| `Image` | [docs](https://mantine.dev/core/image) |
| `BackgroundImage` | [docs](https://mantine.dev/core/background-image) |
| `Indicator` | [docs](https://mantine.dev/core/indicator) |
| `Pill` | [docs](https://mantine.dev/core/pill) |
| `Spoiler` | [docs](https://mantine.dev/core/spoiler) |
New exports use `"$schema": "homarr-custom-widget-v3"`. Version 2 imports remain accepted. Secrets are never exported and must be entered again after import.
### Tables
Review the base origin, network scope, named request methods, inherited authentication, and required permissions before saving an imported widget. A version 1 template that contains inline network components stays disabled until it is converted to named requests.
| Component | Doc |
| --------------------------------------- | -------------------------------------- |
| `Table` / `Table.Thead` / `Table.Tbody` | [docs](https://mantine.dev/core/table) |
| `Table.Tr` / `Table.Th` / `Table.Td` | [docs](https://mantine.dev/core/table) |
AI-generated Custom JSX can also be pasted as a Homarr authoring bundle: one fenced JSON block with `"template": "__HOMARR_TEMPLATE__"`, followed by one fenced JSX block containing the readable multiline template. Homarr combines the blocks before validation, avoiding JSON newline and quote escaping. Raw JSON imports remain supported.
### Lists
On the custom-widget list, paste a complete export with Ctrl+V or Command+V to open the import review and create it as a new definition. On an edit page, the same shortcut opens a replacement review and fills the existing form after confirmation. Pasted definitions never include secrets.
| Component | Doc |
| ---------------------------- | ----------------------------------------- |
| `List` / `List.Item` | [docs](https://mantine.dev/core/list) |
| `Timeline` / `Timeline.Item` | [docs](https://mantine.dev/core/timeline) |
### Feedback & Loading
| Component | Doc |
| ------------------------------- | ---------------------------------------------- |
| `Progress` / `Progress.Section` | [docs](https://mantine.dev/core/progress) |
| `RingProgress` | [docs](https://mantine.dev/core/ring-progress) |
| `Skeleton` | [docs](https://mantine.dev/core/skeleton) |
| `Loader` | [docs](https://mantine.dev/core/loader) |
### Navigation & Overlay
| Component | Doc |
| ------------------------------------------------------------------------ | -------------------------------------------- |
| `TabsContainer` / `TabPanel` | Custom interactive component |
| `Accordion` / `Accordion.Item` / `Accordion.Control` / `Accordion.Panel` | [docs](https://mantine.dev/core/accordion) |
| `ScrollArea` | [docs](https://mantine.dev/core/scroll-area) |
| `Tooltip` | [docs](https://mantine.dev/core/tooltip) |
### Charts
| Component | Doc |
| ---------------- | --------------------------------------------------- |
| `AreaChart` | [docs](https://mantine.dev/charts/area-chart) |
| `BarChart` | [docs](https://mantine.dev/charts/bar-chart) |
| `LineChart` | [docs](https://mantine.dev/charts/line-chart) |
| `DonutChart` | [docs](https://mantine.dev/charts/donut-chart) |
| `PieChart` | [docs](https://mantine.dev/charts/pie-chart) |
| `RadarChart` | [docs](https://mantine.dev/charts/radar-chart) |
| `RadialBarChart` | [docs](https://mantine.dev/charts/radial-bar-chart) |
| `Sparkline` | [docs](https://mantine.dev/charts/sparkline) |
### Interactive Components
| Component | Description |
| --------------- | ---------------------------------------- |
| `PaginatedList` | Client-side pagination wrapper |
| `Collapsible` | Expandable/collapsible section |
| `StatBar` | Horizontal stat bar |
| `TypeBadge` | Type-styled badge |
| `Divider` | [docs](https://mantine.dev/core/divider) |
## Template Bindings
Your template receives:
- `data` - The sanitized API response object. Access fields via `data.fieldName` or `data.results[0].name`.
- `String(v)` - Type-safe string conversion
- `Number(v)` - Type-safe number conversion
- `Boolean(v)` - Type-safe boolean conversion
- `Math` - Safe subset: `round`, `floor`, `ceil`, `abs`, `min`, `max`, `pow`, `sqrt`, `PI`
- `JSON.stringify(v)` - JSON serialization
- `Array.isArray(v)` - Array type check
- `Object.keys(v)`, `Object.values(v)`, `Object.entries(v)` - Object utilities
## Security Constraints
- Event handlers (`onClick`, `onChange`, etc.) are stripped
- Dangerous HTML attributes (`dangerouslySetInnerHTML`) are stripped
- `script`, `iframe`, `object`, `embed`, `form`, `style`, `link`, `meta`, `base` tags are forbidden
- Template strings containing `eval`, `Function`, `import`, `require`, `globalThis`, `window`, `document`, `fetch` are rejected
- Anchor `href` attributes are validated to only allow `https://`, relative (`/`), and hash (`#`) URLs
@@ -1,19 +0,0 @@
---
sidebar_label: Compatibility and migration
---
# Compatibility and migration
Homarr preserves existing display definitions while moving network-enabled Custom JSX to the named-request model.
## JSX API versions
Missing `jsxApiVersion` means v1. Display-only v1 templates can be opened and saved as v2 with an empty request manifest. Inline `url`, `method`, `headers`, and body props are inert in v2.
Static v1 network components can be converted by creating a named request and replacing inline capability props with `requestId` and typed `params`. Dynamic target construction requires an administrator to design an explicit path template and parameter list. Existing non-GET base definitions stop polling and show a network-review state.
## Import versions
Exports use `homarr-custom-widget-v3`; v2 imports remain accepted. Import migration normalizes display configuration, preserves v1 semantics, and never imports secret values. The review screen must be accepted before persistence.
Test migration with the same base response and compare rendered output, query results, action simulation, permissions, and journal entries before enabling the migrated definition on shared boards.
@@ -1,32 +0,0 @@
---
sidebar_label: Requests and security
---
# Named requests and security
Custom JSX v2 uses a saved request manifest. Runtime components pass only a `requestId` and declared primitive parameters; they cannot change the URL, method, headers, authentication, or body shape.
## Capability kinds
`SubFetch` references a GET `query`. `ActionButton` and `ToggleSwitch` reference a POST, PUT, PATCH, or DELETE `action`. Base data is GET-only. Actions require an explicit gesture and are never polled, cached, retried automatically, or run on mount.
Default board permissions are view for queries, modify for POST/PUT/PATCH, and full for DELETE. Viewer actions require explicit high-risk approval; anonymous users cannot execute actions. DELETE always requires confirmation.
## Parameter substitution
Path placeholders use `{name}` and are URL-encoded by the server. Runtime parameters must exactly match all declared names and types. JSON bodies substitute `{ "$param": "name" }` recursively and preserve string, number, or boolean types.
## Network boundary
Named paths stay on the base URL origin. HTTP and HTTPS are accepted; URL credentials and fragments are rejected. DNS answers are classified and pinned. Public, private, and loopback scopes progressively allow destinations, but link-local, metadata, unspecified, multicast, documentation, carrier-grade NAT, and IPv4-mapped addresses are always blocked.
Queries may follow three revalidated same-origin redirects. Actions do not follow redirects. Credentials never cross origins. Reserved authentication, cookie, host, forwarding, proxy, content-length, security, and hop-by-hop headers cannot be declared.
## Limits
- Request body: 10 KiB UTF-8.
- Decompressed response: 1 MiB, 32 JSON levels, 50,000 JSON nodes.
- Per user/item: 60 queries, 10 actions, and 3 deletes per minute.
- Concurrency: 4 per user/item and 8 per definition.
Successful GETs can use manifest cache TTLs and single-flight deduplication. Logs contain redacted origin/method/error metadata, never secrets, request bodies, or query values.
@@ -1,33 +0,0 @@
---
sidebar_label: Troubleshooting
---
# Troubleshooting
## The preview says the template is required
New Custom JSX forms always include the starter fixture. If an imported definition has an empty template, reopen its import review; malformed imports must be corrected rather than previewed as JSX.
## A prop is ignored
The diagnostics name the component and prop. Check the generated component reference. Unsupported props are intentionally removed; use a supported layout primitive or wrapper prop instead of polymorphic, portal, ref, positioning, or raw handler props.
## A request does not run
Confirm that the component kind matches the manifest: `SubFetch` needs a query; action controls need an action. Parameter names and primitive types must match exactly. Base requests and queries require GET. Actions only run from a gesture, and edit mode disables controls.
## Network scope errors
Resolve the hostname from the Homarr server environment. Select private or loopback scope only when required and understood. Link-local and metadata destinations cannot be enabled by any scope. Redirects must remain on the base origin.
## Permission errors
The placed board item is the authorization anchor. A definition ID alone cannot authorize credential use. Verify board view/modify/full access, explicit viewer-action approval, and that the caller is not anonymous.
## Stale or failed preview data
Connection, authentication, body, method, secret, or manifest changes mark samples stale. Run the GET test again. Background query failures keep prior content visible and offer retry; actions roll toggles back and show error feedback.
## Import paste is ignored
Paste a complete v2/v3 JSON export or authoring bundle while the list or edit page is focused. Ordinary text and partial JSON are ignored so normal typing is not intercepted.
+26 -78
View File
@@ -73,7 +73,13 @@ Replace `<your-api-key>` with your actual API key. Never commit API keys to vers
"mcpServers": {
"homarr": {
"command": "npx",
"args": ["-y", "mcp-remote", "https://your-homarr-instance.com/api/mcp/mcp", "--header", "ApiKey:<your-api-key>"]
"args": [
"-y",
"mcp-remote",
"https://your-homarr-instance.com/api/mcp/mcp",
"--header",
"ApiKey:<your-api-key>"
]
}
}
}
@@ -120,26 +126,25 @@ curl -s -X POST \
Homarr exposes 50+ tools organized by namespace. Each tool is either a **query** (GET, read-only) or **mutation** (POST, write action).
| Namespace | Example Tools | Type |
| ------------------ | --------------------------------------------------------- | ---------- |
| `app` | List all apps, get app by ID, create/update/delete app | GET + POST |
| `apiKeys` | List API keys | GET |
| `beszel` | Systems status, alerts, historical system metrics | GET |
| `board` | List boards, get board by ID, create/update/delete board | GET + POST |
| `docker` | List containers, inspect container, start/stop/restart | GET + POST |
| `icon` | Search icons | GET |
| `info` | Server health, version info | GET |
| `integration` | List integrations, test connection, delete integration | GET + POST |
| `invite` | List invitations, delete invite | GET + POST |
| `calendar` | Calendar events from media servers | GET |
| `customWidget` | Validate, preview, create, and iteratively update widgets | GET + POST |
| `dnsHole` | DNS hole summary, toggle blocking | GET + POST |
| `downloads` | Active downloads, pause/resume/delete | GET + POST |
| `healthMonitoring` | Resource usage stats | GET |
| `patchmon` | Host patch and security update statistics | GET |
| `mediaRequests` | Approve/decline media requests | GET + POST |
| `mediaServer` | Media server sessions and stats | GET |
| `smartHome` | Execute automations, device states | GET + POST |
| Namespace | Example Tools | Type |
| ------------------ | -------------------------------------------------------- | ---------- |
| `app` | List all apps, get app by ID, create/update/delete app | GET + POST |
| `apiKeys` | List API keys | GET |
| `beszel` | Systems status, alerts, historical system metrics | GET |
| `board` | List boards, get board by ID, create/update/delete board | GET + POST |
| `docker` | List containers, inspect container, start/stop/restart | GET + POST |
| `icon` | Search icons | GET |
| `info` | Server health, version info | GET |
| `integration` | List integrations, test connection, delete integration | GET + POST |
| `invite` | List invitations, delete invite | GET + POST |
| `calendar` | Calendar events from media servers | GET |
| `dnsHole` | DNS hole summary, toggle blocking | GET + POST |
| `downloads` | Active downloads, pause/resume/delete | GET + POST |
| `healthMonitoring` | Resource usage stats | GET |
| `patchmon` | Host patch and security update statistics | GET |
| `mediaRequests` | Approve/decline media requests | GET + POST |
| `mediaServer` | Media server sessions and stats | GET |
| `smartHome` | Execute automations, device states | GET + POST |
The tools table on the MCP tab is dynamically generated - it always reflects the latest available procedures from your Homarr instance.
@@ -149,8 +154,6 @@ The tools table on the MCP tab is dynamically generated - it always reflects the
- Tools inherit the permissions of the API key's owner
- OAuth endpoints are rate-limited to prevent abuse
- Tool execution errors are contained - a failing tool call does not crash the MCP server
- Custom-widget MCP actions are simulation-only; live preview actions must be enabled explicitly in the browser workbench
- Custom-widget secret values are never returned through MCP
- All communication uses HTTPS (configure HTTP → HTTPS redirects on your reverse proxy)
## Use Cases
@@ -162,58 +165,3 @@ The tools table on the MCP tab is dynamically generated - it always reflects the
- **"How many users are on my Homarr instance?"** - AI lists all users (admin only)
- **"Toggle Pi-hole blocking for 5 minutes"** - AI interacts with DNS controls
- **"How are my servers doing?"** - AI fetches Beszel system stats including CPU, memory, disk, and alerts
- **"Build and refine a custom widget for this API"** - AI validates the draft, tests GET data and named queries, simulates actions, and creates or updates the widget
## Custom Widget Workflow for AI Agents
AI agents can create and iterate on custom widgets using these tools in sequence.
:::tip Editing an existing widget?
Skip to step 5. Use `customWidget_byId` to fetch it and `customWidget_readTemplate` / `customWidget_writeTemplate` to edit its template directly.
:::
### 1. Get the schema
Call `customWidget_schema` to receive the JSON Schema for the `homarr-custom-widget-v3` format. This tells the agent what fields are required and their types.
### 2. Draft and validate
Build a widget JSON object and call `customWidget_validate` with it. The tool returns structured issues (path + message) so the agent can fix and re-validate until the draft is clean.
### 3. Create the widget
Call `customWidget_import` with the validated `homarr-custom-widget-v3` draft. This returns the new widget `id`. Use `customWidget_create` only when you intentionally need its lower-level creation input, including secret fields.
### 4. Test with real data
Call `customWidget_preview` with the widget's connection info. This makes a real GET request through Homarr's hardened executor and returns the sanitized response plus a preview session ID.
For Custom JSX v2 widgets with named requests, use `customWidget_previewQuery` to test each named GET query, and `customWidget_simulatePreviewAction` to dry-run actions without network calls.
### 5. Iterate on the template
Use `customWidget_readTemplate` to get the current JSX template as plain text, then `customWidget_writeTemplate` to push updates. The write tool accepts either:
- `template` — a single string (newlines as `\n`)
- `templateLines` — an array of strings (joined with newlines server-side, avoids `\n` escaping)
Both options validate the template AST before saving. This avoids re-sending the full widget JSON for template-only edits.
For small changes, prefer `customWidget_patchTemplate`. Pass the `revision` returned by `customWidget_readTemplate` and one or more line edits. Homarr rejects stale revisions, applies all edits atomically, and validates the complete template against its named request manifest before saving.
### 6. Update non-template fields
Call `customWidget_byId` to get the full widget, modify fields, and call `customWidget_update` with only the changed fields.
### Available components in templates
Custom JSX templates support 100+ Mantine components plus:
- `<TablerIcon name="server" size={20} color="blue" />` — 100+ curated Tabler icons by kebab-case name (e.g. `"player-play"`, `"alert-triangle"`)
- `<SubFetch>` / `<ActionButton>` / `<ToggleSwitch>` — network-capable components for named requests
- Array methods: `map`, `filter`, `find`, `findIndex`, `some`, `every`, `reduce`, `sort`, `slice`, `flat`, `reverse`, `at`, `pop`, `join`, `includes`, `indexOf` (note: `pop` returns last element without mutating)
- String methods: `toUpperCase`, `toLowerCase`, `trim`, `trimStart`, `trimEnd`, `slice`, `substring`, `includes`, `startsWith`, `endsWith`, `split`, `replace`, `replaceAll`, `repeat`, `padStart`, `padEnd`, `charAt`, `indexOf`, `lastIndexOf`
:::note
All `customWidget_*` tools require admin-level API key permissions. The preview executor enforces rate limits (60 queries/min, 10 actions/min).
:::
@@ -0,0 +1,46 @@
---
tags:
- Workshop
- Custom Widgets
- Custom CSS
---
# Community Workshop
The [Workshop](pathname:///workshop) is Homarr's community catalog for custom widgets and CSS themes. You can inspect every creation before downloading it or using it in Homarr.
:::warning Community content
Workshop entries are created by community members. Review widget endpoints, templates, and CSS before using them. Keep a current Homarr backup and never add secrets to an exported widget.
:::
## Discover and install
In Homarr, open **Management → Workshop** to browse, search, filter, inspect, download, and install creations. The custom-widget management page and a board's custom CSS settings also provide contextual Workshop actions.
Recently loaded listings and details are saved in the browser for up to 24 hours. If Workshop is temporarily unreachable, Homarr shows the saved catalog with a clear offline notice; browsing the rest of Homarr is unaffected. Publishing, voting, reporting, and installation remain unavailable until the connection returns.
The public site also caches pages you have visited. If your network drops, a previously opened Workshop or documentation page can still load while the saved catalog remains on the device. Use **Check again** after connectivity returns.
- Widgets are validated against `homarr-custom-widget-v2` before installation. API keys and other secrets are not included and must be configured locally.
- CSS is loaded into the board editor for review. It is not saved until you choose **Save changes**.
- The public website never executes submitted widget templates or CSS.
## Share a creation
Export a custom widget from Homarr, then choose or drop the JSON file in the Workshop submission form. CSS themes can be pasted into the same form. Sign in with GitHub, provide a public title and description, inspect the source, and publish it.
Submissions are public immediately. Authors can update or delete their own submissions. Updating content increases its revision and shows the supplied changelog.
## Votes and reports
Signed-in members can vote to help useful creations rank higher. Report malicious content, spam, copyright problems, or inappropriate material from a submission's detail page. Reports are visible only to Workshop staff.
## Accounts and moderation
Workshop accounts use GitHub OAuth and are separate from users in a self-hosted Homarr instance. PocketBase creates OAuth accounts directly; staff privileges come only from the server-managed `workshop_staff` collection. Workshop moderators can remove submissions, disable an account, and resolve reports. These actions require a reason and are recorded.
The Workshop is a central service operated by Homarr at `homarr.dev`. The documentation website and Workshop API ship in one image and use the same origin. Running it locally is supported for contributors developing or testing this feature, not as a private production marketplace.
The service image is built directly from the Homarr repository rather than published separately. Contributors can run `pnpm dev:workshop` to start PocketBase in Docker while Docusaurus and the Workshop contracts watch on the host. Use `pnpm preview:workshop` to build the combined production image, or `pnpm reset:workshop` to explicitly clear the gitignored local PocketBase database. Superusers and GitHub OAuth are configured once in the persistent dashboard instead of being passed as environment variables.
+17 -27
View File
@@ -1,39 +1,29 @@
---
title: "Custom API"
description: "Displays data from an administrator-defined HTTP API using built-in renderers or safe Custom JSX."
title: 'Custom API'
description: 'Displays data from a custom API endpoint using configurable display types and optional JSX templates.'
hide_title: true
---
import { WidgetHeader } from "@site/src/components/widgets/header";
import { AddingWidget } from "@site/src/components/widgets/adding";
import { customApiWidget } from ".";
import { WidgetHeader } from '@site/src/components/widgets/header';
import { AddingWidget } from '@site/src/components/widgets/adding';
import { customApiWidget } from '.';
<WidgetHeader widget={customApiWidget} categories={["Custom", "API"]} />
The Custom API widget displays an API definition created by an administrator in [Management → Custom Widgets](/docs/management/custom-widgets). A definition owns the endpoint, credentials, display configuration, and any named network capabilities used by Custom JSX.
<WidgetHeader
widget={customApiWidget}
categories={['Custom', 'API']}
/>
### Add the widget
This widget fetches data from a custom API endpoint defined in [Custom Widgets management](/docs/management/custom-widgets) and renders it using configurable display types.
Custom API widgets are created and configured through the admin management page. Each widget definition specifies the API endpoint, authentication, display type, and refresh interval.
See the [Custom Widgets documentation](/docs/management/custom-widgets) for full details on creating and configuring custom widgets.
### Adding the widget
<AddingWidget />
Choose a custom-widget definition and a refresh interval. The board item stores only the selected definition and display options; credentials remain server-side.
:::note
A definition must be enabled and available to the board before it appears in the widget picker.
Custom API widgets must first be created in **Management → Custom Widgets** before they appear in the widget picker.
:::
### Permissions and actions
The base response and named query requests require permission to view the board. Named POST, PUT, and PATCH actions require permission to modify the board by default. DELETE requires full board permission and a confirmation. Anonymous viewers cannot execute actions.
Interactive controls are disabled while the board is in edit mode. Actions run only after an explicit user gesture and are never triggered by mounting, refresh polling, or automatic retries. Authoring previews simulate actions unless an administrator explicitly enables live actions for the short-lived preview session.
### Refresh behavior
The refresh interval applies to the base GET response. A Custom JSX query can declare its own cache duration and can be refreshed manually. Named actions can invalidate the parent response or related named queries after they succeed.
If the definition needs a non-GET base request, migrate it to a GET base response plus a named action. Existing non-GET Custom JSX definitions require review before network access is enabled.
See [Custom Widgets management](/docs/management/custom-widgets) for the editor, display types, named-request manifest, network scopes, security limits, and migration guidance.
+25
View File
@@ -3,6 +3,10 @@ import type { Config } from "@docusaurus/types";
import { themes as prismThemes } from "prism-react-renderer";
const a11yEmoji = require("@fec/remark-a11y-emoji");
// Production uses PocketBase's own origin. The explicit override exists only
// for the standalone Docusaurus development server.
const workshopApiUrl = process.env.WORKSHOP_API_URL ?? process.env.NEXT_PUBLIC_WORKSHOP_API_URL ?? "";
const config: Config = {
title: "Homarr documentation",
tagline: "A simple yet powerful dashboard for your server.",
@@ -16,6 +20,9 @@ const config: Config = {
defaultLocale: "en",
locales: ["en"],
},
customFields: {
workshopApiUrl,
},
onBrokenLinks: "throw",
onBrokenAnchors: "throw",
onDuplicateRoutes: "throw",
@@ -124,6 +131,11 @@ const config: Config = {
position: "left",
to: "/blog",
},
{
label: "Workshop",
position: "left",
to: "/workshop",
},
{
label: "About us",
position: "left",
@@ -297,6 +309,7 @@ const config: Config = {
],
"docusaurus-plugin-image-zoom",
require.resolve("./plugins/validate-docs-coverage"),
require.resolve("./plugins/offline-manifest.mjs"),
function disableExpensiveBundlerOptimizationPlugin() {
return {
name: "disable-expensive-bundler-optimizations",
@@ -309,6 +322,18 @@ const config: Config = {
},
};
},
function workshopRoutesPlugin() {
return {
name: "workshop-routes",
async contentLoaded({ actions }) {
actions.addRoute({
path: "/workshop/:id",
component: "@site/src/components/workshop/WorkshopDetailRoutePage",
exact: true,
});
},
};
},
"@signalwire/docusaurus-plugin-llms-txt",
async function tailwindCssPlugin() {
return {
+6 -2
View File
@@ -5,7 +5,7 @@
"license": "Apache-2.0",
"scripts": {
"docusaurus": "docusaurus",
"dev": "docusaurus start --no-open --port 3003",
"dev": "docusaurus start --no-open --host 0.0.0.0 --port 3003",
"start": "docusaurus start --port 3003",
"build": "docusaurus build",
"serve": "docusaurus serve",
@@ -27,12 +27,16 @@
"@docusaurus/plugin-sitemap": "^3.10.1",
"@docusaurus/preset-classic": "^3.10.1",
"@homarr/definitions": "workspace:^",
"@homarr/workshop": "workspace:^0.1.0",
"@mdx-js/react": "^3.1.1",
"@nivo/line": "^0.99.0",
"@scalar/api-reference-react": "catalog:",
"@signalwire/docusaurus-plugin-llms-txt": "^1.2.2",
"@tabler/icons-react": "catalog:",
"@tailwindcss/postcss": "^4.3.2",
"@tanstack/query-async-storage-persister": "catalog:",
"@tanstack/react-query": "catalog:",
"@tanstack/react-query-persist-client": "catalog:",
"clsx": "catalog:",
"dayjs": "catalog:",
"docusaurus-plugin-image-zoom": "^3.0.1",
@@ -43,7 +47,7 @@
"random-word-slugs": "^0.1.7",
"react": "catalog:",
"react-dom": "catalog:",
"swiper": "^14.0.5",
"swiper": "^14.0.2",
"tailwindcss": "^4.3.2"
},
"devDependencies": {
+26
View File
@@ -0,0 +1,26 @@
import { readdir, writeFile } from "node:fs/promises";
import path from "node:path";
const collectFiles = async (directory, root = directory) => {
const entries = await readdir(directory, { withFileTypes: true });
const files = await Promise.all(
entries.map((entry) => {
const absolutePath = path.join(directory, entry.name);
return entry.isDirectory() ? collectFiles(absolutePath, root) : path.relative(root, absolutePath);
}),
);
return files.flat();
};
export default function offlineManifestPlugin() {
return {
name: "workshop-offline-manifest",
async postBuild({ outDir }) {
const assetsDirectory = path.join(outDir, "assets");
const assets = (await collectFiles(assetsDirectory))
.filter((file) => /\.(?:css|js|woff2?)$/.test(file))
.map((file) => `/assets/${file.split(path.sep).join("/")}`);
await writeFile(path.join(outDir, "workshop-assets.json"), JSON.stringify([...assets, "/img/logo.png"]));
},
};
}
@@ -1,66 +0,0 @@
import React from "react";
import customJsxComponentRegistry from "../generated/custom-jsx-components.json";
const enabledComponents = customJsxComponentRegistry.filter((component) => component.safety !== "denied");
const deniedComponents = customJsxComponentRegistry.filter((component) => component.safety === "denied");
const categories = [...new Set(enabledComponents.map((component) => component.category))];
export function CustomJsxComponentReference() {
return (
<>
<h3>Enabled and wrapped components</h3>
<table>
<thead>
<tr>
<th>Category</th>
<th>Components</th>
</tr>
</thead>
<tbody>
{categories.map((category) => (
<tr key={category}>
<td>
<code>{category}</code>
</td>
<td>
{enabledComponents
.filter((component) => component.category === category)
.map((component) => (
<span key={component.name} style={{ display: "inline-block", margin: "0 0.5rem 0.35rem 0" }}>
<a href={component.documentationUrl}>
<code>{component.name}</code>
</a>
{component.safety === "wrapped" && <small> (wrapped)</small>}
</span>
))}
</td>
</tr>
))}
</tbody>
</table>
<h3>Denied components</h3>
<table>
<thead>
<tr>
<th>Component</th>
<th>Reason</th>
</tr>
</thead>
<tbody>
{deniedComponents.map((component) => (
<tr key={component.name}>
<td>
<a href={component.documentationUrl} aria-label={component.name}>
<code>{component.name}</code>
</a>
</td>
<td>{component.reason}</td>
</tr>
))}
</tbody>
</table>
</>
);
}
@@ -0,0 +1,330 @@
import React, { useEffect, useMemo, useState } from "react";
import useDocusaurusContext from "@docusaurus/useDocusaurusContext";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { WorkshopClient, type WorkshopAccountState, type WorkshopRole, type WorkshopUser } from "@homarr/workshop";
import { WorkshopQueryProvider } from "./WorkshopQueryProvider";
import { WorkshopDialog, WorkshopServiceState } from "./WorkshopUi";
import styles from "./workshop.module.css";
type PendingAction =
| { kind: "report"; id: string; status: "resolved" | "dismissed" }
| { kind: "state"; id: string; state: WorkshopAccountState }
| { kind: "role"; id: string; role: WorkshopRole };
export function WorkshopAdmin() {
return (
<WorkshopQueryProvider>
<WorkshopAdminContent />
</WorkshopQueryProvider>
);
}
function WorkshopAdminContent() {
const { siteConfig } = useDocusaurusContext();
const client = useMemo(
() => new WorkshopClient((siteConfig.customFields?.workshopApiUrl as string | undefined) || window.location.origin),
[siteConfig],
);
const queryClient = useQueryClient();
const [user, setUser] = useState<WorkshopUser | null>(null);
const [pending, setPending] = useState<PendingAction | null>(null);
const [error, setError] = useState("");
const [authLoading, setAuthLoading] = useState(true);
const canModerate = user?.role === "moderator" || user?.role === "admin";
const moderationQuery = useQuery({
queryKey: ["workshop", "moderation"],
queryFn: async () => {
const [reports, users, actions] = await Promise.all([
client.listReports(),
client.listUsers(),
client.listModerationActions(),
]);
return { reports, users, actions };
},
enabled: canModerate,
});
const reports = moderationQuery.data?.reports ?? [];
const users = moderationQuery.data?.users ?? [];
const actions = moderationQuery.data?.actions ?? [];
const actionMutation = useMutation({
mutationKey: ["workshop", "moderation", "action"],
mutationFn: async ({ action, reason }: { action: PendingAction; reason: string }) => {
if (action.kind === "report") await client.resolveReport(action.id, action.status, reason);
else if (action.kind === "state") await client.updateAccountState(action.id, action.state, reason);
else await client.updateRole(action.id, action.role, reason);
},
onSuccess: async () => {
setPending(null);
await queryClient.invalidateQueries({ queryKey: ["workshop", "moderation"] });
},
});
const signInMutation = useMutation({
mutationKey: ["workshop", "auth", "moderator"],
mutationFn: () => client.signInWithGitHub(),
onSuccess: setUser,
onError: (caught) => setError(caught instanceof Error ? caught.message : "GitHub sign-in is unavailable"),
});
useEffect(() => client.subscribeToAuth(setUser), [client]);
useEffect(() => {
void client
.refreshAuth()
.then(setUser)
.catch((caught: unknown) =>
setError(caught instanceof Error ? caught.message : "Workshop authentication is unavailable"),
)
.finally(() => setAuthLoading(false));
}, [client]);
if (authLoading)
return (
<main className={styles.page}>
<WorkshopAdminSkeleton />
</main>
);
if (!user)
return (
<main className={styles.page}>
<h1>Workshop moderation</h1>
<p>Sign in with a promoted GitHub account to continue.</p>
{error && (
<div className={styles.error} role="alert">
{error}
</div>
)}
<button
className="button button--primary"
disabled={signInMutation.isPending}
onClick={() => {
setError("");
signInMutation.mutate();
}}
>
{signInMutation.isPending ? "Connecting…" : "Sign in with GitHub"}
</button>
</main>
);
if (user.role === "member")
return (
<main className={styles.page}>
<h1>Workshop moderation</h1>
<div className={styles.error} role="alert">
Your account does not have moderation access.
</div>
</main>
);
return (
<main className={styles.page}>
<header className={styles.hero}>
<div>
<h1>Workshop moderation</h1>
<p>Review reports and apply the smallest action needed. Every action requires a reason and is recorded.</p>
</div>
</header>
{(error || moderationQuery.isError) && (
<WorkshopServiceState
cached={Boolean(moderationQuery.data)}
message={error || "Moderation data is unavailable. No action can be applied until Workshop reconnects."}
onRetry={() => void moderationQuery.refetch()}
/>
)}
<section className={styles.adminSection}>
<h2>Open reports</h2>
{reports.length === 0 ? (
<p>No open reports.</p>
) : (
<div className={styles.tableWrap}>
<table>
<thead>
<tr>
<th>Category</th>
<th>Submission</th>
<th>Details</th>
<th>Created</th>
<th>Action</th>
</tr>
</thead>
<tbody>
{reports.map((report) => (
<tr key={report.id}>
<td>{report.category}</td>
<td>{report.submission}</td>
<td>{report.explanation}</td>
<td>{new Date(report.created).toLocaleString()}</td>
<td aria-label="Report actions">
<div className={styles.rowActions}>
<button onClick={() => setPending({ kind: "report", id: report.id, status: "resolved" })}>
Resolve
</button>
<button onClick={() => setPending({ kind: "report", id: report.id, status: "dismissed" })}>
Dismiss
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</section>
<section className={styles.adminSection}>
<h2>Community accounts</h2>
<div className={styles.tableWrap}>
<table>
<thead>
<tr>
<th>User</th>
<th>Role</th>
<th>State</th>
<th>Moderate</th>
</tr>
</thead>
<tbody>
{users.map((account) => (
<tr key={account.id}>
<td>{account.displayName}</td>
<td>{account.role}</td>
<td>{account.state}</td>
<td>
{account.id === user.id ? (
"Current account"
) : (
<div className={styles.rowActions}>
<button
onClick={() =>
setPending({
kind: "state",
id: account.id,
state: account.state === "disabled" ? "active" : "disabled",
})
}
>
{account.state === "disabled" ? "Restore" : "Disable"}
</button>
{user.role === "admin" && (
<button
onClick={() =>
setPending({
kind: "role",
id: account.id,
role: account.role === "member" ? "moderator" : "member",
})
}
>
{account.role === "member" ? "Promote" : "Set member"}
</button>
)}
</div>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</section>
<section className={styles.adminSection}>
<h2>Recent actions</h2>
<div className={styles.tableWrap}>
<table>
<thead>
<tr>
<th>Action</th>
<th>Target</th>
<th>Reason</th>
<th>Created</th>
</tr>
</thead>
<tbody>
{actions.map((action) => (
<tr key={action.id}>
<td>{action.action}</td>
<td>
{action.targetType}:{action.targetId}
</td>
<td>{action.reason}</td>
<td>{new Date(action.created).toLocaleString()}</td>
</tr>
))}
</tbody>
</table>
</div>
</section>
{pending && (
<ReasonDialog
action={pending}
onClose={() => setPending(null)}
onConfirm={(reason) => actionMutation.mutateAsync({ action: pending, reason })}
/>
)}
</main>
);
}
function WorkshopAdminSkeleton() {
return (
<div aria-label="Loading Workshop moderation">
<h1>Workshop moderation</h1>
<p>Loading account and moderation data…</p>
</div>
);
}
function ReasonDialog({
action,
onClose,
onConfirm,
}: {
action: PendingAction;
onClose: () => void;
onConfirm: (reason: string) => Promise<void>;
}) {
const [reason, setReason] = useState("");
const [error, setError] = useState("");
const [saving, setSaving] = useState(false);
return (
<WorkshopDialog titleId="moderation-reason-title" onClose={onClose}>
<h2 id="moderation-reason-title">Confirm moderation action</h2>
<p>
This will apply <strong>{action.kind}</strong> to the selected record.
</p>
<form
onSubmit={async (event) => {
event.preventDefault();
setSaving(true);
try {
await onConfirm(reason);
} catch (caught) {
setError(caught instanceof Error ? caught.message : "Action failed");
setSaving(false);
}
}}
>
<label>
Reason
<textarea
required
minLength={3}
maxLength={1000}
value={reason}
onChange={(event) => setReason(event.target.value)}
/>
</label>
{error && <p className={styles.error}>{error}</p>}
<div className={styles.formActions}>
<button type="button" className="button button--secondary" onClick={onClose}>
Cancel
</button>
<button disabled={saving} className="button button--danger">
{saving ? "Applying…" : "Apply action"}
</button>
</div>
</form>
</WorkshopDialog>
);
}
@@ -0,0 +1,16 @@
import React from "react";
import BrowserOnly from "@docusaurus/BrowserOnly";
import Layout from "@theme/Layout";
export default function WorkshopDetailRoutePage() {
return (
<Layout title="Workshop submission">
<BrowserOnly fallback={<main style={{ minHeight: "70vh" }} />}>
{() => {
const { WorkshopDetailRoute } = require("./WorkshopPage");
return <WorkshopDetailRoute />;
}}
</BrowserOnly>
</Layout>
);
}
@@ -0,0 +1,740 @@
import React, { useEffect, useMemo, useState } from "react";
import Link from "@docusaurus/Link";
import { useHistory, useLocation } from "@docusaurus/router";
import useDocusaurusContext from "@docusaurus/useDocusaurusContext";
import {
IconAlertTriangle,
IconBrandGithub,
IconCopy,
IconDownload,
IconFlag,
IconHeart,
IconPackage,
IconSearch,
IconSparkles,
IconUpload,
} from "@tabler/icons-react";
import {
MAX_CONTENT_LENGTH,
MAX_SCREENSHOTS,
WorkshopClient,
type WorkshopSubmissionDetail,
type WorkshopSubmissionInput,
type WorkshopSubmissionSummary,
type WorkshopSubmissionType,
type WorkshopUser,
workshopExportFilename,
} from "@homarr/workshop";
import { keepPreviousData, useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { WorkshopQueryProvider } from "./WorkshopQueryProvider";
import { WorkshopDialog, WorkshopServiceState } from "./WorkshopUi";
import styles from "./workshop.module.css";
const useWorkshop = () => {
const { siteConfig } = useDocusaurusContext();
const apiUrl = (siteConfig.customFields?.workshopApiUrl as string | undefined) || window.location.origin;
return useMemo(() => new WorkshopClient(apiUrl), [apiUrl]);
};
const saveContent = (submission: WorkshopSubmissionDetail) => {
const url = URL.createObjectURL(
new Blob([submission.content], { type: submission.type === "css" ? "text/css" : "application/json" }),
);
const link = document.createElement("a");
link.href = url;
link.download = workshopExportFilename(submission.title, submission.type);
link.click();
URL.revokeObjectURL(url);
};
export function WorkshopApp() {
return (
<WorkshopQueryProvider>
<WorkshopAppContent />
</WorkshopQueryProvider>
);
}
function WorkshopAppContent() {
const client = useWorkshop();
const queryClient = useQueryClient();
const [user, setUser] = useState<WorkshopUser | null>(null);
const [page, setPage] = useState(1);
const [type, setType] = useState<WorkshopSubmissionType | "all">("all");
const [sort, setSort] = useState<"top" | "newest">("top");
const [search, setSearch] = useState("");
const [debouncedSearch, setDebouncedSearch] = useState("");
const [mine, setMine] = useState(false);
const [error, setError] = useState("");
const [showForm, setShowForm] = useState(false);
const signInMutation = useMutation({
mutationKey: ["workshop", "auth", "sign-in"],
mutationFn: () => client.signInWithGitHub(),
onSuccess: async () => queryClient.invalidateQueries({ queryKey: ["workshop"] }),
onError: (caught) => setError(caught instanceof Error ? caught.message : "Sign-in failed"),
});
useEffect(() => client.subscribeToAuth(setUser), [client]);
useEffect(() => {
void client.refreshAuth().catch(() => undefined);
}, [client]);
useEffect(() => {
const timeout = window.setTimeout(() => setDebouncedSearch(search), 250);
return () => window.clearTimeout(timeout);
}, [search]);
const listQuery = useQuery({
queryKey: ["workshop", "list", page, type, sort, debouncedSearch, mine ? user?.id : null],
queryFn: ({ signal }) =>
client.list({
page,
type,
sort,
search: debouncedSearch,
author: mine ? user?.id : undefined,
signal,
}),
placeholderData: keepPreviousData,
});
const items = listQuery.data?.items ?? [];
const totalPages = listQuery.data?.totalPages ?? 1;
return (
<main className={styles.page}>
<header className={styles.hero}>
<div>
<span className={styles.eyebrow}>Made by the Homarr community</span>
<h1>Workshop</h1>
<p>Discover useful creations, inspect every line, then bring the ones you trust into Homarr.</p>
<ul className={styles.heroPromises} aria-label="Workshop principles">
<li>
<IconSparkles size={16} /> Community-made
</li>
<li>
<IconPackage size={16} /> Ready to export
</li>
<li>
<IconHeart size={16} /> Ranked by members
</li>
</ul>
</div>
<div className={styles.accountActions}>
{user ? (
<>
{(user.role === "moderator" || user.role === "admin") && (
<Link className="button button--secondary" to="/workshop/admin">
Moderate
</Link>
)}
<button className="button button--primary" onClick={() => setShowForm(true)}>
<IconUpload size={17} /> Share creation
</button>
<button className="button button--secondary" onClick={() => client.signOut()}>
Sign out
</button>
</>
) : (
<button
className="button button--primary"
disabled={signInMutation.isPending}
onClick={() => signInMutation.mutate()}
>
<IconBrandGithub size={18} /> {signInMutation.isPending ? "Connecting…" : "Sign in with GitHub"}
</button>
)}
</div>
</header>
<section className={styles.safety} aria-label="Community content warning">
<IconAlertTriangle aria-hidden size={20} />
<p>
Workshop content is community-provided. Inspect source before installing it and keep a current Homarr backup.
</p>
</section>
<div className={styles.toolbar}>
<label className={styles.search}>
<span className="sr-only">Search Workshop</span>
<IconSearch aria-hidden size={18} />
<input
value={search}
onChange={(event) => {
setSearch(event.target.value);
setPage(1);
}}
placeholder="Search creations"
/>
</label>
<label>
Type
<select
value={type}
onChange={(event) => {
setType(event.target.value as typeof type);
setPage(1);
}}
>
<option value="all">All</option>
<option value="widget">Widgets</option>
<option value="css">CSS themes</option>
</select>
</label>
<label>
Sort
<select
value={sort}
onChange={(event) => {
setSort(event.target.value as typeof sort);
setPage(1);
}}
>
<option value="top">Top rated</option>
<option value="newest">Newest</option>
</select>
</label>
{user && (
<label className={styles.checkbox}>
<input
type="checkbox"
checked={mine}
onChange={(event) => {
setMine(event.target.checked);
setPage(1);
}}
/>{" "}
My submissions
</label>
)}
</div>
{(error || listQuery.isError || listQuery.fetchStatus === "paused") && (
<WorkshopServiceState
cached={items.length > 0}
message={error || undefined}
onRetry={() => void listQuery.refetch()}
/>
)}
{listQuery.isPending ? (
<WorkshopSkeleton />
) : items.length ? (
<div className={styles.grid}>
{items.map((item) => (
<WorkshopCard client={client} item={item} key={item.id} />
))}
</div>
) : listQuery.isError || listQuery.fetchStatus === "paused" ? null : (
<section className={styles.empty}>
<IconSearch size={32} aria-hidden />
<h2>No creations found</h2>
<p>Try a broader search or clear the filters to explore everything the community has shared.</p>
<button
className="button button--secondary button--sm"
onClick={() => {
setSearch("");
setType("all");
setSort("top");
setPage(1);
}}
>
Clear filters
</button>
</section>
)}
{totalPages > 1 && (
<nav className={styles.pagination} aria-label="Workshop pages">
<button disabled={page === 1} onClick={() => setPage((value) => value - 1)}>
Previous
</button>
<span>
Page {page} of {totalPages}
</span>
<button disabled={page === totalPages} onClick={() => setPage((value) => value + 1)}>
Next
</button>
</nav>
)}
{showForm && (
<SubmissionForm
client={client}
onClose={() => setShowForm(false)}
onSaved={async () => {
setShowForm(false);
await queryClient.invalidateQueries({ queryKey: ["workshop"] });
}}
/>
)}
</main>
);
}
function WorkshopCard({ client, item }: { client: WorkshopClient; item: WorkshopSubmissionSummary }) {
const image = item.screenshots[0];
return (
<article className={styles.card}>
{image ? (
<img src={client.fileUrl(item.id, image, "480x320")} alt="" loading="lazy" />
) : (
<div className={styles.cardPlaceholder} aria-hidden>
{item.type === "widget" ? "{}" : "CSS"}
</div>
)}
<div className={styles.cardBody}>
<div className={styles.cardMeta}>
<span>{item.type === "widget" ? "Widget" : "CSS theme"}</span>
<span>
{item.score >= 0 ? "+" : ""}
{item.score}
</span>
</div>
<h2>
<Link className={styles.cardLink} to={`/workshop/${item.id}`}>
{item.title}
</Link>
</h2>
<p>{item.description || "No description provided."}</p>
<small>
By {item.authorName} · revision {item.revision}
</small>
</div>
</article>
);
}
export function WorkshopDetailRoute() {
return (
<WorkshopQueryProvider>
<WorkshopDetailRouteContent />
</WorkshopQueryProvider>
);
}
function WorkshopDetailRouteContent() {
const client = useWorkshop();
const queryClient = useQueryClient();
const location = useLocation();
const id = location.pathname.split("/").filter(Boolean).at(-1) ?? "";
const history = useHistory();
const [user, setUser] = useState<WorkshopUser | null>(null);
const [reporting, setReporting] = useState(false);
const [editing, setEditing] = useState(false);
const [moderating, setModerating] = useState(false);
const [actionError, setActionError] = useState("");
const detailQuery = useQuery({
queryKey: ["workshop", "detail", id],
queryFn: ({ signal }) => client.get(id, signal),
enabled: id.length > 0,
});
const voteMutation = useMutation({
mutationKey: ["workshop", "vote", id],
mutationFn: async () => {
await (client.currentUser ?? client.signInWithGitHub());
return client.vote(id, 1);
},
onSuccess: async () => queryClient.invalidateQueries({ queryKey: ["workshop", "detail", id] }),
onError: (caught) => setActionError(caught instanceof Error ? caught.message : "The vote could not be saved."),
});
const deleteMutation = useMutation({
mutationKey: ["workshop", "delete", id],
mutationFn: () => client.delete(id),
onSuccess: () => history.push("/workshop"),
onError: (caught) =>
setActionError(caught instanceof Error ? caught.message : "The submission could not be deleted."),
});
useEffect(() => client.subscribeToAuth(setUser), [client]);
useEffect(() => {
void client.refreshAuth();
}, [client]);
if (detailQuery.isError)
return (
<main className={styles.page}>
<div className={styles.error} role="alert">
This creation is not saved on this device and Workshop could not be reached.{" "}
<button onClick={() => void detailQuery.refetch()}>Try again</button>
</div>
<Link to="/workshop">Back to Workshop</Link>
</main>
);
if (!detailQuery.data) return <WorkshopSkeleton />;
const submission = detailQuery.data;
return (
<main className={styles.page}>
<Link to="/workshop">← Back to Workshop</Link>
<article className={styles.detail}>
<header>
<span className={styles.type}>{submission.type === "widget" ? "Widget" : "CSS theme"}</span>
<h1>{submission.title}</h1>
<p>{submission.description}</p>
<small>
By {submission.authorName} · revision {submission.revision}
</small>
</header>
{submission.screenshots.length > 0 && (
<div className={styles.gallery}>
{submission.screenshots.map((image, index) => (
<img
key={image}
src={client.fileUrl(submission.id, image, "960x640")}
alt={`${submission.title} screenshot ${index + 1}`}
/>
))}
</div>
)}
<div className={styles.detailActions}>
<button className="button button--primary" onClick={() => saveContent(submission)}>
<IconDownload size={17} /> Download {submission.type === "widget" ? "JSON" : "CSS"}
</button>
<button
className="button button--secondary"
onClick={() => void navigator.clipboard.writeText(submission.content)}
>
<IconCopy size={17} /> Copy source
</button>
<button
className="button button--secondary"
disabled={voteMutation.isPending}
onClick={() => {
setActionError("");
voteMutation.mutate();
}}
>
Upvote ({submission.upvotes})
</button>
<button className="button button--secondary" onClick={() => setReporting(true)}>
<IconFlag size={16} /> Report
</button>
{user?.id === submission.author && (
<>
<button className="button button--secondary" onClick={() => setEditing(true)}>
Edit
</button>
<button
className="button button--danger"
disabled={deleteMutation.isPending}
onClick={() => {
if (window.confirm("Delete this submission permanently?")) {
setActionError("");
deleteMutation.mutate();
}
}}
>
Delete
</button>
</>
)}
{(user?.role === "moderator" || user?.role === "admin") && user.id !== submission.author && (
<button className="button button--danger" onClick={() => setModerating(true)}>
Remove submission
</button>
)}
</div>
{actionError && (
<div className={styles.error} role="alert">
{actionError}
</div>
)}
<section>
<h2>Source</h2>
<p className={styles.sourceHint}>
Review this content before using it. Workshop content is never executed on homarr.dev.
</p>
<pre className={styles.source}>
<code>{submission.content}</code>
</pre>
</section>
{submission.changelog && (
<section>
<h2>Latest changes</h2>
<p>{submission.changelog}</p>
</section>
)}
</article>
{reporting && <ReportForm client={client} submissionId={submission.id} onClose={() => setReporting(false)} />}
{editing && (
<SubmissionForm
client={client}
existing={submission}
onClose={() => setEditing(false)}
onSaved={async () => {
setEditing(false);
await queryClient.invalidateQueries({ queryKey: ["workshop", "detail", id] });
}}
/>
)}
{moderating && (
<ModerationRemovalForm
client={client}
submission={submission}
onClose={() => setModerating(false)}
onRemoved={() => history.push("/workshop/admin")}
/>
)}
</main>
);
}
function ModerationRemovalForm({
client,
submission,
onClose,
onRemoved,
}: {
client: WorkshopClient;
submission: WorkshopSubmissionDetail;
onClose: () => void;
onRemoved: () => void;
}) {
const [reason, setReason] = useState("");
const removeMutation = useMutation({
mutationKey: ["workshop", "moderation", "remove", submission.id],
mutationFn: () => client.removeSubmission(submission.id, reason),
onSuccess: onRemoved,
});
return (
<WorkshopDialog titleId="remove-submission-title" onClose={onClose}>
<h2 id="remove-submission-title">Remove {submission.title}</h2>
<p>
This permanently deletes the submission, files, votes, and reports. A metadata snapshot is retained in
moderation history.
</p>
<form
onSubmit={(event) => {
event.preventDefault();
removeMutation.mutate();
}}
>
<label>
Reason
<textarea
required
minLength={3}
maxLength={1000}
value={reason}
onChange={(event) => setReason(event.target.value)}
/>
</label>
{removeMutation.error && (
<p className={styles.error} role="alert">
{removeMutation.error instanceof Error ? removeMutation.error.message : "Removal failed"}
</p>
)}
<div className={styles.formActions}>
<button type="button" className="button button--secondary" onClick={onClose}>
Cancel
</button>
<button disabled={removeMutation.isPending} className="button button--danger">
{removeMutation.isPending ? "Removing…" : "Remove permanently"}
</button>
</div>
</form>
</WorkshopDialog>
);
}
function SubmissionForm({
client,
existing,
onClose,
onSaved,
}: {
client: WorkshopClient;
existing?: WorkshopSubmissionDetail;
onClose: () => void;
onSaved: () => void;
}) {
const [type, setType] = useState<WorkshopSubmissionType>(existing?.type ?? "widget");
const [title, setTitle] = useState(existing?.title ?? "");
const [description, setDescription] = useState(existing?.description ?? "");
const [content, setContent] = useState(existing?.content ?? "");
const [changelog, setChangelog] = useState(existing?.changelog ?? "");
const [screenshots, setScreenshots] = useState<File[]>([]);
const [removedScreenshots, setRemovedScreenshots] = useState<string[]>([]);
const retainedScreenshotCount = (existing?.screenshots.length ?? 0) - removedScreenshots.length;
const remainingScreenshotSlots = Math.max(0, MAX_SCREENSHOTS - retainedScreenshotCount);
useEffect(() => {
setScreenshots((current) => current.slice(0, remainingScreenshotSlots));
}, [remainingScreenshotSlots]);
const saveMutation = useMutation({
mutationKey: ["workshop", existing ? "update" : "create", existing?.id],
mutationFn: async (input: WorkshopSubmissionInput) => {
if (!client.currentUser) await client.signInWithGitHub();
if (existing) {
return client.update(existing.id, input, screenshots, removedScreenshots);
}
return client.create(input, screenshots);
},
onSuccess: onSaved,
});
return (
<WorkshopDialog titleId="submission-form-title" onClose={onClose}>
<h2 id="submission-form-title">{existing ? "Update creation" : "Share with the community"}</h2>
<form
onSubmit={(event) => {
event.preventDefault();
saveMutation.mutate({ type, title, description, content, changelog });
}}
>
<label>
Type
<select
disabled={Boolean(existing)}
value={type}
onChange={(event) => setType(event.target.value as WorkshopSubmissionType)}
>
<option value="widget">Custom widget</option>
<option value="css">CSS theme</option>
</select>
</label>
<label>
Title
<input
required
minLength={3}
maxLength={100}
value={title}
onChange={(event) => setTitle(event.target.value)}
/>
</label>
<label>
Description
<textarea maxLength={2000} value={description} onChange={(event) => setDescription(event.target.value)} />
</label>
<label>
{type === "widget" ? "Widget JSON" : "CSS source"}
<textarea
className={styles.editor}
required
maxLength={MAX_CONTENT_LENGTH}
value={content}
onChange={(event) => setContent(event.target.value)}
/>
</label>
<label>
Changelog
<textarea maxLength={2000} value={changelog} onChange={(event) => setChangelog(event.target.value)} />
</label>
<label>
Screenshots (PNG, JPG, or WebP, {remainingScreenshotSlots} available)
<input
type="file"
accept="image/png,image/jpeg,image/webp"
multiple
onChange={(event) =>
setScreenshots(Array.from(event.target.files ?? []).slice(0, remainingScreenshotSlots))
}
/>
</label>
{existing?.screenshots.map((screenshot) => (
<label className={styles.checkbox} key={screenshot}>
<input
type="checkbox"
checked={removedScreenshots.includes(screenshot)}
onChange={(event) =>
setRemovedScreenshots((current) =>
event.target.checked
? [...current, screenshot]
: current.filter((filename) => filename !== screenshot),
)
}
/>
Remove existing screenshot {screenshot}
</label>
))}
{saveMutation.error && (
<p className={styles.error} role="alert">
{saveMutation.error instanceof Error ? saveMutation.error.message : "Could not save submission"}
</p>
)}
<div className={styles.formActions}>
<button type="button" className="button button--secondary" onClick={onClose}>
Cancel
</button>
<button disabled={saveMutation.isPending} className="button button--primary">
{saveMutation.isPending ? "Saving…" : existing ? "Save update" : "Publish creation"}
</button>
</div>
</form>
</WorkshopDialog>
);
}
function ReportForm({
client,
submissionId,
onClose,
}: {
client: WorkshopClient;
submissionId: string;
onClose: () => void;
}) {
const [category, setCategory] = useState<"malicious" | "spam" | "copyright" | "inappropriate" | "other">("malicious");
const [explanation, setExplanation] = useState("");
const reportMutation = useMutation({
mutationKey: ["workshop", "report", submissionId],
mutationFn: async () => {
if (!client.currentUser) await client.signInWithGitHub();
return client.report(submissionId, category, explanation);
},
onSuccess: onClose,
});
return (
<WorkshopDialog titleId="report-title" onClose={onClose}>
<h2 id="report-title">Report submission</h2>
<form
onSubmit={(event) => {
event.preventDefault();
reportMutation.mutate();
}}
>
<label>
Reason
<select value={category} onChange={(event) => setCategory(event.target.value as typeof category)}>
<option value="malicious">Malicious</option>
<option value="spam">Spam</option>
<option value="copyright">Copyright</option>
<option value="inappropriate">Inappropriate</option>
<option value="other">Other</option>
</select>
</label>
<label>
Details
<textarea
required
minLength={3}
maxLength={1000}
value={explanation}
onChange={(event) => setExplanation(event.target.value)}
/>
</label>
{reportMutation.error && (
<p className={styles.error} role="alert">
{reportMutation.error instanceof Error ? reportMutation.error.message : "Could not send report"}
</p>
)}
<div className={styles.formActions}>
<button type="button" className="button button--secondary" onClick={onClose}>
Cancel
</button>
<button disabled={reportMutation.isPending} className="button button--primary">
{reportMutation.isPending ? "Sending…" : "Submit report"}
</button>
</div>
</form>
</WorkshopDialog>
);
}
function WorkshopSkeleton() {
return (
<div className={styles.grid} aria-label="Loading Workshop">
{Array.from({ length: 6 }, (_, index) => (
<div className={styles.skeleton} key={index} />
))}
</div>
);
}
@@ -0,0 +1,43 @@
import React, { type PropsWithChildren, useState } from "react";
import { createAsyncStoragePersister } from "@tanstack/query-async-storage-persister";
import { QueryClient } from "@tanstack/react-query";
import { PersistQueryClientProvider } from "@tanstack/react-query-persist-client";
const maxAge = 1000 * 60 * 60 * 24;
export function WorkshopQueryProvider({ children }: PropsWithChildren) {
const [client] = useState(
() =>
new QueryClient({
defaultOptions: {
queries: {
gcTime: maxAge,
networkMode: "offlineFirst",
retry: 1,
staleTime: 1000 * 60 * 5,
},
mutations: { networkMode: "online", retry: false },
},
}),
);
const [persister] = useState(() =>
createAsyncStoragePersister({
key: "homarr-workshop-website-cache-v1",
storage: window.localStorage,
}),
);
return (
<PersistQueryClientProvider
client={client}
persistOptions={{
persister,
maxAge,
buster: "workshop-v1",
dehydrateOptions: { shouldDehydrateQuery: (query) => query.state.status === "success" },
}}
>
{children}
</PersistQueryClientProvider>
);
}
@@ -0,0 +1,69 @@
import React, { type PropsWithChildren, useEffect, useRef } from "react";
import Link from "@docusaurus/Link";
import { IconBooks, IconCloudOff, IconRefresh } from "@tabler/icons-react";
import styles from "./workshop.module.css";
export function WorkshopServiceState({
cached,
message,
onRetry,
}: {
cached: boolean;
message?: string;
onRetry: () => void;
}) {
return (
<section className={styles.serviceState} aria-live="polite">
<span className={styles.serviceStateIcon} aria-hidden>
<IconCloudOff size={24} />
</span>
<div>
<h2>{cached ? "Browsing saved creations" : "Workshop is taking a break"}</h2>
<p>
{message ??
(cached
? "The catalog saved on this device is still available. Live details and community actions will return when the service reconnects."
: "Community creations cannot be reached right now. Homarr and its documentation remain fully available.")}
</p>
<div className={styles.serviceStateActions}>
<button className="button button--primary button--sm" onClick={onRetry}>
<IconRefresh size={16} /> Check again
</button>
<Link className="button button--secondary button--sm" to="/docs/management/workshop">
<IconBooks size={16} /> Read the guide
</Link>
</div>
</div>
</section>
);
}
export function WorkshopDialog({
titleId,
onClose,
children,
}: PropsWithChildren<{ titleId: string; onClose: () => void }>) {
const ref = useRef<HTMLDialogElement>(null);
useEffect(() => {
const dialog = ref.current;
if (!dialog) return;
dialog.showModal();
return () => dialog.close();
}, []);
return (
<dialog
ref={ref}
className={styles.dialog}
aria-labelledby={titleId}
onCancel={(event) => {
event.preventDefault();
onClose();
}}
>
<div className={styles.dialogSurface}>{children}</div>
</dialog>
);
}
@@ -0,0 +1,592 @@
.page {
--workshop-ease: cubic-bezier(0.25, 1, 0.5, 1);
max-width: 1180px;
min-height: 70vh;
margin: 0 auto;
padding: clamp(2rem, 5vw, 4.5rem) 1.25rem 5rem;
}
.hero {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: center;
gap: 2rem;
margin-bottom: 2rem;
}
.eyebrow {
display: block;
margin-bottom: 0.45rem;
color: var(--ifm-color-primary);
font-size: 0.76rem;
font-weight: 750;
letter-spacing: 0.09em;
text-transform: uppercase;
}
.hero h1,
.detail h1 {
margin: 0;
font-size: clamp(2.35rem, 6vw, 4.25rem);
line-height: 0.98;
letter-spacing: -0.045em;
}
.hero p {
max-width: 62ch;
margin: 0.85rem 0 0;
color: var(--ifm-color-emphasis-700);
font-size: clamp(1rem, 2vw, 1.12rem);
line-height: 1.65;
}
.heroPromises {
display: flex;
flex-wrap: wrap;
gap: 0.55rem 1rem;
padding: 0;
margin: 1.1rem 0 0;
color: var(--ifm-color-emphasis-700);
font-size: 0.82rem;
list-style: none;
}
.heroPromises li,
.accountActions,
.detailActions,
.formActions,
.serviceStateActions {
display: flex;
align-items: center;
}
.heroPromises li {
gap: 0.35rem;
}
.accountActions,
.detailActions,
.formActions,
.serviceStateActions {
flex-wrap: wrap;
gap: 0.7rem;
}
.accountActions :global(.button),
.detailActions :global(.button),
.serviceStateActions :global(.button) {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.4rem;
}
.safety {
display: flex;
align-items: flex-start;
gap: 0.75rem;
padding: 0.9rem 1rem;
margin-bottom: 1rem;
border: 1px solid color-mix(in srgb, var(--ifm-color-warning) 55%, var(--ifm-color-emphasis-300));
border-radius: 12px;
background: color-mix(in srgb, var(--ifm-color-warning) 8%, var(--ifm-background-surface-color));
}
.safety svg {
flex: none;
margin-top: 0.1rem;
}
.safety p {
margin: 0;
color: var(--ifm-color-emphasis-800);
font-size: 0.9rem;
}
.toolbar {
display: grid;
grid-template-columns: minmax(260px, 1fr) 150px 150px auto;
align-items: end;
gap: 0.75rem;
padding: 0.85rem;
margin-bottom: 1.25rem;
border: 1px solid var(--ifm-color-emphasis-300);
border-radius: 14px;
background: var(--ifm-background-surface-color);
box-shadow: 0 8px 28px rgb(0 0 0 / 0.04);
}
.toolbar label,
.dialog label {
display: grid;
gap: 0.35rem;
font-size: 0.78rem;
font-weight: 700;
}
.toolbar input,
.toolbar select,
.dialog input,
.dialog select,
.dialog textarea {
width: 100%;
min-height: 44px;
padding: 0.6rem 0.72rem;
border: 1px solid var(--ifm-color-emphasis-400);
border-radius: 9px;
color: var(--ifm-font-color-base);
background: var(--ifm-background-color);
font: inherit;
transition:
border-color 150ms ease,
box-shadow 150ms ease;
}
.toolbar input:focus-visible,
.toolbar select:focus-visible,
.dialog input:focus-visible,
.dialog select:focus-visible,
.dialog textarea:focus-visible,
.pagination button:focus-visible,
.rowActions button:focus-visible {
outline: 2px solid var(--ifm-color-primary);
outline-offset: 2px;
}
.search {
position: relative;
}
.search svg {
position: absolute;
z-index: 1;
left: 0.8rem;
bottom: 0.82rem;
color: var(--ifm-color-emphasis-600);
pointer-events: none;
}
.search input {
padding-left: 2.35rem;
}
.checkbox {
display: flex !important;
grid-auto-flow: column;
align-items: center;
min-height: 44px;
}
.checkbox input {
width: auto;
min-height: auto;
}
.serviceState {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
gap: 1rem;
padding: clamp(1.1rem, 3vw, 1.5rem);
margin: 1.25rem 0;
border: 1px solid color-mix(in srgb, var(--ifm-color-warning) 45%, var(--ifm-color-emphasis-300));
border-radius: 14px;
background: color-mix(in srgb, var(--ifm-color-warning) 7%, var(--ifm-background-surface-color));
}
.serviceStateIcon {
display: grid;
width: 44px;
height: 44px;
place-items: center;
border-radius: 10px;
color: var(--ifm-color-warning-darkest);
background: color-mix(in srgb, var(--ifm-color-warning) 18%, transparent);
}
.serviceState h2 {
margin: 0 0 0.25rem;
font-size: 1.05rem;
}
.serviceState p {
max-width: 70ch;
margin: 0 0 0.85rem;
color: var(--ifm-color-emphasis-700);
font-size: 0.9rem;
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(min(100%, 270px), 1fr));
gap: 1rem;
}
.card {
position: relative;
overflow: hidden;
border: 1px solid var(--ifm-color-emphasis-300);
border-radius: 14px;
background: var(--ifm-background-surface-color);
box-shadow: 0 4px 14px rgb(0 0 0 / 0.035);
transition:
transform 200ms var(--workshop-ease),
border-color 200ms ease,
box-shadow 200ms var(--workshop-ease);
}
.card:hover,
.card:focus-within {
border-color: color-mix(in srgb, var(--ifm-color-primary) 55%, var(--ifm-color-emphasis-300));
box-shadow: 0 14px 34px rgb(0 0 0 / 0.1);
transform: translateY(-3px);
}
.card img,
.cardPlaceholder {
width: 100%;
aspect-ratio: 3 / 2;
object-fit: cover;
}
.cardPlaceholder {
display: grid;
place-items: center;
color: var(--ifm-color-emphasis-600);
background: color-mix(in srgb, var(--ifm-color-primary) 6%, var(--ifm-color-emphasis-100));
font: 750 1.45rem var(--ifm-font-family-monospace);
}
.cardBody {
padding: 1rem;
}
.cardBody h2 {
margin: 0.45rem 0;
font-size: 1.08rem;
}
.cardLink::after {
position: absolute;
content: "";
inset: 0;
}
.cardLink:focus-visible {
outline: none;
}
.card:has(.cardLink:focus-visible) {
outline: 3px solid var(--ifm-color-primary);
outline-offset: 3px;
}
.cardBody p {
min-height: 2.7em;
margin: 0 0 0.7rem;
color: var(--ifm-color-emphasis-700);
font-size: 0.88rem;
}
.cardBody small {
color: var(--ifm-color-emphasis-600);
}
.cardMeta {
display: flex;
justify-content: space-between;
color: var(--ifm-color-emphasis-700);
font-size: 0.72rem;
font-weight: 750;
letter-spacing: 0.035em;
text-transform: uppercase;
}
.error {
padding: 0.85rem 1rem;
margin: 1rem 0;
border: 1px solid color-mix(in srgb, var(--ifm-color-danger) 45%, transparent);
border-radius: 10px;
color: var(--ifm-color-danger-contrast-foreground);
background: var(--ifm-color-danger-contrast-background);
}
.empty {
display: grid;
max-width: 620px;
min-height: 310px;
place-items: center;
align-content: center;
gap: 0.45rem;
padding: 3rem 1.25rem;
margin: 0 auto;
text-align: center;
border: 1px dashed var(--ifm-color-emphasis-400);
border-radius: 16px;
color: var(--ifm-color-emphasis-700);
}
.empty h2,
.empty p {
margin: 0;
}
.empty svg {
color: var(--ifm-color-primary);
}
.empty :global(.button) {
margin-top: 0.75rem;
}
.pagination {
display: flex;
justify-content: center;
align-items: center;
gap: 1rem;
margin-top: 2rem;
}
.pagination button {
min-height: 44px;
padding: 0.5rem 0.85rem;
}
.skeleton {
min-height: 310px;
border-radius: 14px;
background: var(--ifm-color-emphasis-200);
animation: pulse 1.5s ease-in-out infinite;
}
.detail {
margin-top: 1.75rem;
}
.detail header {
max-width: 75ch;
}
.detail h1 {
margin-top: 0.5rem;
font-size: clamp(2.1rem, 5vw, 3.6rem);
}
.type {
display: inline-block;
color: var(--ifm-color-primary);
font-size: 0.78rem;
font-weight: 750;
letter-spacing: 0.07em;
text-transform: uppercase;
}
.gallery {
display: flex;
gap: 1rem;
overflow-x: auto;
padding-bottom: 0.6rem;
margin: 1.5rem 0;
scroll-snap-type: x mandatory;
}
.gallery img {
width: min(720px, 85vw);
max-height: 480px;
object-fit: contain;
border: 1px solid var(--ifm-color-emphasis-300);
border-radius: 12px;
background: var(--ifm-color-emphasis-100);
scroll-snap-align: start;
}
.detailActions {
margin: 1.5rem 0 2rem;
}
.sourceHint {
color: var(--ifm-color-emphasis-700);
}
.source {
max-height: 560px;
overflow: auto;
padding: 1rem;
border: 1px solid var(--ifm-color-emphasis-300);
border-radius: 10px;
font-size: 0.78rem;
}
.dialog {
width: min(700px, calc(100vw - 2rem));
max-height: min(90vh, 900px);
padding: 0;
overflow: visible;
border: 0;
border-radius: 16px;
color: var(--ifm-font-color-base);
background: transparent;
box-shadow: 0 24px 80px rgb(0 0 0 / 0.35);
}
.dialog::backdrop {
background: rgb(0 0 0 / 0.68);
backdrop-filter: blur(3px);
}
.dialogSurface {
max-height: min(90vh, 900px);
overflow-y: auto;
padding: clamp(1.15rem, 4vw, 1.6rem);
border: 1px solid var(--ifm-color-emphasis-300);
border-radius: 16px;
background: var(--ifm-background-color);
}
.dialogSurface > h2 {
margin-top: 0;
}
.dialog form {
display: grid;
gap: 1rem;
}
.dialog textarea {
min-height: 96px;
resize: vertical;
}
.dialog .editor {
min-height: 240px;
font-family: var(--ifm-font-family-monospace);
font-size: 0.78rem;
}
.formActions {
justify-content: flex-end;
}
.adminSection {
margin: 2rem 0;
}
.tableWrap {
overflow-x: auto;
border: 1px solid var(--ifm-color-emphasis-300);
border-radius: 12px;
}
.tableWrap table {
width: 100%;
margin: 0;
border-collapse: collapse;
}
.tableWrap th,
.tableWrap td {
padding: 0.75rem;
border: 0;
border-bottom: 1px solid var(--ifm-color-emphasis-300);
text-align: left;
vertical-align: top;
}
.tableWrap tr:last-child td {
border-bottom: 0;
}
.rowActions {
display: flex;
flex-wrap: wrap;
gap: 0.4rem;
}
.rowActions button {
min-height: 36px;
}
@keyframes pulse {
50% {
opacity: 0.55;
}
}
@media (max-width: 820px) {
.hero {
grid-template-columns: 1fr;
gap: 1.4rem;
}
.accountActions > * {
flex: 1 1 auto;
}
.toolbar {
grid-template-columns: 1fr 1fr;
}
.search {
grid-column: 1 / -1;
}
}
@media (max-width: 520px) {
.page {
padding-inline: 1rem;
}
.hero h1 {
font-size: 2.75rem;
}
.heroPromises {
display: grid;
}
.accountActions,
.detailActions,
.formActions,
.serviceStateActions {
align-items: stretch;
flex-direction: column;
}
.accountActions > *,
.detailActions > *,
.formActions > *,
.serviceStateActions > * {
width: 100%;
}
.toolbar {
grid-template-columns: 1fr;
}
.search {
grid-column: auto;
}
.serviceState {
grid-template-columns: 1fr;
}
.pagination {
justify-content: space-between;
gap: 0.5rem;
}
}
@media (prefers-reduced-motion: reduce) {
.skeleton {
animation: none;
}
.card {
transition: none;
}
.card:hover,
.card:focus-within {
transform: none;
}
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,16 @@
import React from "react";
import BrowserOnly from "@docusaurus/BrowserOnly";
import Layout from "@theme/Layout";
export default function WorkshopAdminPage() {
return (
<Layout title="Workshop moderation" noFooter>
<BrowserOnly fallback={<main style={{ minHeight: "70vh" }} />}>
{() => {
const { WorkshopAdmin } = require("@site/src/components/workshop/WorkshopAdmin");
return <WorkshopAdmin />;
}}
</BrowserOnly>
</Layout>
);
}
+16
View File
@@ -0,0 +1,16 @@
import React from "react";
import BrowserOnly from "@docusaurus/BrowserOnly";
import Layout from "@theme/Layout";
export default function WorkshopPage() {
return (
<Layout title="Workshop" description="Community widgets and CSS themes for Homarr">
<BrowserOnly fallback={<main style={{ minHeight: "70vh" }} />}>
{() => {
const { WorkshopApp } = require("@site/src/components/workshop/WorkshopPage");
return <WorkshopApp />;
}}
</BrowserOnly>
</Layout>
);
}
+11
View File
@@ -0,0 +1,11 @@
import React, { type PropsWithChildren, useEffect } from "react";
export default function Root({ children }: PropsWithChildren) {
useEffect(() => {
if (process.env.NODE_ENV === "production" && "serviceWorker" in navigator) {
void navigator.serviceWorker.register("/workshop-sw.js", { scope: "/" });
}
}, []);
return children;
}
+46
View File
@@ -0,0 +1,46 @@
const CACHE_NAME = "homarr-site-v2";
const SHELL_PATHS = ["/", "/workshop/", "/docs/management/workshop/", "/img/logo.png"];
self.addEventListener("install", (event) => {
event.waitUntil(
caches.open(CACHE_NAME).then(async (cache) => {
const manifest = await fetch("/workshop-assets.json")
.then((response) => response.json())
.catch(() => []);
await Promise.allSettled([...SHELL_PATHS, ...manifest].map((path) => cache.add(path)));
}),
);
self.skipWaiting();
});
self.addEventListener("activate", (event) => {
event.waitUntil(
caches
.keys()
.then((keys) => Promise.all(keys.filter((key) => key !== CACHE_NAME).map((key) => caches.delete(key))))
.then(() => self.clients.claim()),
);
});
self.addEventListener("fetch", (event) => {
const request = event.request;
const url = new URL(request.url);
if (request.method !== "GET" || url.origin !== self.location.origin || url.pathname.startsWith("/api/")) return;
event.respondWith(
fetch(request)
.then((response) => {
if (response.ok) {
const copy = response.clone();
event.waitUntil(caches.open(CACHE_NAME).then((cache) => cache.put(request, copy)));
}
return response;
})
.catch(async () => {
const cached = await caches.match(request);
if (cached) return cached;
if (request.mode === "navigate") return (await caches.match("/workshop/")) ?? Response.error();
return Response.error();
}),
);
});
+1 -8
View File
@@ -14,12 +14,6 @@
"with-env": "dotenv -e ../../.env --"
},
"dependencies": {
"@codemirror/autocomplete": "catalog:",
"@codemirror/commands": "catalog:",
"@codemirror/lang-javascript": "catalog:",
"@codemirror/lang-json": "catalog:",
"@codemirror/lint": "catalog:",
"@codemirror/view": "catalog:",
"@dnd-kit/core": "catalog:",
"@dnd-kit/modifiers": "catalog:",
"@dnd-kit/sortable": "catalog:",
@@ -32,7 +26,6 @@
"@homarr/common": "workspace:^0.1.0",
"@homarr/core": "workspace:^0.1.0",
"@homarr/cron-job-status": "workspace:^0.1.0",
"@homarr/custom-widgets": "workspace:^0.1.0",
"@homarr/db": "workspace:^0.1.0",
"@homarr/definitions": "workspace:^0.1.0",
"@homarr/docker": "workspace:^0.1.0",
@@ -57,6 +50,7 @@
"@homarr/validation": "workspace:^0.1.0",
"@homarr/websocket": "workspace:^0.1.0",
"@homarr/widgets": "workspace:^0.1.0",
"@homarr/workshop": "workspace:^0.1.0",
"@mantine/colors-generator": "catalog:",
"@mantine/core": "catalog:",
"@mantine/dropzone": "catalog:",
@@ -75,7 +69,6 @@
"@trpc/next": "catalog:",
"@trpc/react-query": "catalog:",
"@trpc/server": "catalog:",
"@uiw/react-codemirror": "catalog:",
"@xterm/addon-canvas": "catalog:",
"@xterm/addon-fit": "catalog:",
"@xterm/xterm": "catalog:",
@@ -2,7 +2,6 @@
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";
@@ -34,7 +33,6 @@ 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";
@@ -92,13 +90,6 @@ 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,6 +11,8 @@ import { IconInfoCircle } from "@tabler/icons-react";
import { useForm } from "@homarr/form";
import { useI18n, useScopedI18n } from "@homarr/translation/client";
import { UseCssFromWorkshopButton } from "~/components/workshop/workshop-actions";
import type { Board } from "../../_types";
import { useSavePartialSettingsMutation } from "./_shared";
import classes from "./customcss.module.css";
@@ -45,7 +47,8 @@ export const CustomCssSettingsContent = ({ board }: Props) => {
{customCssT("customClassesAlert.description")}
</Alert>
<Group justify="end">
<Group justify="space-between">
<UseCssFromWorkshopButton onUse={(css) => form.setFieldValue("customCss", css)} />
<Button type="submit" loading={isPending}>
{t("common.action.saveChanges")}
</Button>
@@ -1,21 +1,18 @@
"use client";
import { Alert, Anchor, Text } from "@mantine/core";
import { IconFlask } from "@tabler/icons-react";
import { useScopedI18n } from "@homarr/translation/client";
const FEEDBACK_URL = "https://github.com/homarr-labs/homarr/discussions/categories/custom-widgets";
export function CustomWidgetBetaBanner() {
const t = useScopedI18n("customWidget.betaBanner");
return (
<Alert variant="light" color="yellow" title={t("title")} icon={<IconFlask />}>
<Alert variant="light" color="yellow" title="Custom Widgets are in Beta" icon={<IconFlask />}>
<Text size="sm">
{t("description")}{" "}
This feature is experimental and will probably change a lot in the future. If you encounter bugs, wish it did
more, or find something confusing, then please complain to us as much as possible! Only feedback posted on the{" "}
<Anchor href={FEEDBACK_URL} target="_blank" fw={600}>
{t("link")}
official discussion thread
</Anchor>{" "}
{t("closing")}
will be taken into account. Happy Hacking!
</Text>
</Alert>
);
@@ -1,51 +0,0 @@
import type { EditorView } from "@codemirror/view";
import { Alert, Stack, Text, UnstyledButton } from "@mantine/core";
import { IconAlertTriangle } from "@tabler/icons-react";
import type { EditorDiagnostic } from "@homarr/custom-widgets/workbench";
import classes from "./_code-editor.module.css";
interface CodeEditorDiagnosticsProps {
diagnostics: EditorDiagnostic[];
editorView: EditorView | null;
title: string;
formatDiagnostic: (diagnostic: EditorDiagnostic) => string;
}
export function CodeEditorDiagnostics({
diagnostics,
editorView,
title,
formatDiagnostic,
}: CodeEditorDiagnosticsProps) {
if (diagnostics.length === 0) return null;
return (
<Stack component="ul" gap={6} mt="xs" pl={0} style={{ listStyle: "none" }} aria-label={title}>
{diagnostics.map((diagnostic, index) => (
<li key={`${diagnostic.code}-${diagnostic.line ?? 0}-${index}`}>
<UnstyledButton
type="button"
w="100%"
className={classes.diagnosticButton}
onClick={() => {
if (!editorView || diagnostic.index === undefined) return;
const anchor = Math.min(diagnostic.index, editorView.state.doc.length);
editorView.dispatch({ selection: { anchor }, scrollIntoView: true });
editorView.focus();
}}
>
<Alert
color={diagnostic.severity === "error" ? "red" : "yellow"}
variant="light"
p="xs"
icon={<IconAlertTriangle size={15} />}
>
<Text size="xs">{formatDiagnostic(diagnostic)}</Text>
</Alert>
</UnstyledButton>
</li>
))}
</Stack>
);
}
@@ -1,80 +0,0 @@
.root {
margin-top: 4px;
border: 1px solid light-dark(var(--mantine-color-gray-4), var(--mantine-color-dark-4));
border-radius: var(--mantine-radius-sm);
background: light-dark(var(--mantine-color-white), var(--mantine-color-dark-7));
overflow: hidden;
}
.root:focus-within {
border-color: var(--mantine-primary-color-filled);
outline: 2px solid color-mix(in srgb, var(--mantine-primary-color-filled) 20%, transparent);
outline-offset: 1px;
}
.toolbar,
.footer {
padding: 7px 10px;
background: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-8));
}
.toolbar {
border-bottom: 1px solid light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-5));
}
.footer {
border-top: 1px solid light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-5));
}
.viewport {
max-height: min(62vh, 680px);
overflow: auto;
}
.viewport :global(.cm-editor) {
background: light-dark(var(--mantine-color-white), var(--mantine-color-dark-7));
color: light-dark(var(--mantine-color-black), var(--mantine-color-gray-0));
font-family: "Fira Code", "SFMono-Regular", Consolas, monospace;
font-size: 12px;
}
.viewport :global(.cm-editor.cm-focused) {
outline: none;
}
.viewport :global(.cm-scroller) {
line-height: 1.65;
overflow: auto;
}
.viewport :global(.cm-gutters) {
background: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-8));
border-color: light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-5));
}
.diagnosticButton {
min-height: 44px;
border-radius: var(--mantine-radius-sm);
}
.diagnosticButton:focus-visible {
outline: 2px solid var(--mantine-primary-color-filled);
outline-offset: 2px;
}
@media (max-width: 48em) {
.viewport {
max-height: 52vh;
}
.toolbar,
.footer {
align-items: flex-start;
}
}
@media (prefers-reduced-motion: reduce) {
.root {
scroll-behavior: auto;
}
}
@@ -1,271 +0,0 @@
"use client";
import { useMemo, useState } from "react";
import dynamic from "next/dynamic";
import { autocompletion } from "@codemirror/autocomplete";
import type { CompletionSource } from "@codemirror/autocomplete";
import { redo, redoDepth, undo, undoDepth } from "@codemirror/commands";
import { javascript } from "@codemirror/lang-javascript";
import { json } from "@codemirror/lang-json";
import { linter } from "@codemirror/lint";
import { EditorView } from "@codemirror/view";
import { Badge, Button, Group, Input, Loader, Text, useComputedColorScheme } from "@mantine/core";
import { IconArrowBackUp, IconArrowForwardUp, IconCheck, IconCode, IconFileCode, IconWand } from "@tabler/icons-react";
import { enabledCustomJsxComponents } from "@homarr/custom-widgets/core";
import { ComponentReference } from "@homarr/custom-widgets/workbench";
import type { EditorDiagnostic } from "@homarr/custom-widgets/workbench";
import { useScopedI18n } from "@homarr/translation/client";
import { CodeEditorDiagnostics } from "./_code-editor-diagnostics";
import classes from "./_code-editor.module.css";
const Editor = dynamic(() => import("@uiw/react-codemirror").then((module) => module.default), {
ssr: false,
loading: () => (
<Group justify="center" p="xl">
<Loader size="sm" />
</Group>
),
});
const customJsxCompletionSource: CompletionSource = (context) => {
const word = context.matchBefore(/[A-Z][\w.]*/u);
if (!word && !context.explicit) return null;
return {
from: word?.from ?? context.pos,
options: enabledCustomJsxComponents.map((component) => ({
label: component.name,
type: "class",
detail: `${component.category} · ${component.safety}`,
})),
};
};
interface CodeEditorProps {
id: string;
value: string;
onChange: (value: string) => void;
label: string;
description?: string;
placeholder?: string;
language: "jsx" | "json";
diagnostics?: EditorDiagnostic[];
error?: React.ReactNode;
required?: boolean;
maxLength?: number;
starter?: string;
}
export function CodeEditor({
id,
value,
onChange,
label,
description,
placeholder,
language,
diagnostics = [],
error,
required,
maxLength,
starter,
}: CodeEditorProps) {
const t = useScopedI18n("customWidget.editor");
const [cursor, setCursor] = useState({ line: 1, column: 1 });
const [editorView, setEditorView] = useState<EditorView | null>(null);
const [historyDepth, setHistoryDepth] = useState({ undo: 0, redo: 0 });
const colorScheme = useComputedColorScheme("light");
const extensions = useMemo(() => {
const diagnosticsExtension = linter((view) =>
diagnostics.map((diagnostic) => {
const from = Math.min(diagnostic.index ?? 0, view.state.doc.length);
return {
from,
to: Math.min(from + 1, view.state.doc.length),
severity: diagnostic.severity,
message: t(`diagnostics.${diagnostic.code}` as never, { value: diagnostic.value ?? "" } as never),
};
}),
);
const accessibilityExtension = EditorView.contentAttributes.of({
id,
"aria-label": label,
});
return language === "jsx"
? [
javascript({ jsx: true }),
autocompletion({ override: [customJsxCompletionSource] }),
diagnosticsExtension,
accessibilityExtension,
]
: [json(), diagnosticsExtension, accessibilityExtension];
}, [diagnostics, id, label, language, t]);
const errorCount = diagnostics.filter((diagnostic) => diagnostic.severity === "error").length;
const warningCount = diagnostics.length - errorCount;
const formattedValue = useMemo(() => {
if (language === "json") {
try {
return `${JSON.stringify(JSON.parse(value) as unknown, null, 2)}\n`;
} catch {
return value;
}
}
return value
.replace(/\r\n?/gu, "\n")
.split("\n")
.map((line) => line.trimEnd())
.join("\n")
.trim();
}, [language, value]);
return (
<Input.Wrapper
label={label}
description={description}
error={error}
required={required}
labelProps={{ htmlFor: id }}
inputWrapperOrder={["label", "description", "input", "error"]}
>
<div className={classes.root}>
<Group className={classes.toolbar} justify="space-between" gap="xs" wrap="wrap">
<Group gap={6}>
<IconCode size={16} aria-hidden />
<Text size="xs" fw={600}>
{language === "jsx" ? t("language.jsx") : t("language.json")}
</Text>
</Group>
<Group gap={6}>
<Button
type="button"
size="compact-xs"
variant="subtle"
leftSection={<IconArrowBackUp size={14} />}
onClick={() => editorView && undo(editorView)}
disabled={!editorView || historyDepth.undo === 0}
>
{t("action.undo")}
</Button>
<Button
type="button"
size="compact-xs"
variant="subtle"
leftSection={<IconArrowForwardUp size={14} />}
onClick={() => editorView && redo(editorView)}
disabled={!editorView || historyDepth.redo === 0}
>
{t("action.redo")}
</Button>
{language === "jsx" && (
<ComponentReference
messages={{
action: t("action.components"),
search: t("componentReference.search"),
empty: t("componentReference.empty"),
count: (count) => t("componentReference.count", { count }),
}}
/>
)}
{starter && value.trim().length === 0 && (
<Button
type="button"
size="compact-xs"
variant="subtle"
leftSection={<IconFileCode size={14} />}
onClick={() => onChange(starter)}
>
{t("action.insertStarter")}
</Button>
)}
<Button
type="button"
size="compact-xs"
variant="subtle"
leftSection={<IconWand size={14} />}
onClick={() => onChange(formattedValue)}
disabled={formattedValue === value}
>
{t("action.format")}
</Button>
</Group>
</Group>
<div className={classes.viewport}>
<Editor
id={`${id}-root`}
value={value}
onChange={onChange}
placeholder={placeholder}
extensions={extensions}
theme={colorScheme}
height={language === "jsx" ? "320px" : "220px"}
basicSetup={{
autocompletion: language !== "jsx",
bracketMatching: true,
closeBrackets: true,
foldGutter: true,
history: true,
highlightActiveLine: true,
highlightActiveLineGutter: true,
indentOnInput: true,
lineNumbers: true,
}}
onCreateEditor={setEditorView}
onUpdate={(update) => {
if (!update.selectionSet && !update.docChanged) return;
const head = update.state.selection.main.head;
const line = update.state.doc.lineAt(head);
setCursor({ line: line.number, column: head - line.from + 1 });
setHistoryDepth({ undo: undoDepth(update.state), redo: redoDepth(update.state) });
}}
/>
</div>
<Group className={classes.footer} justify="space-between" gap="xs">
<Group gap={6}>
{errorCount > 0 ? (
<Badge size="xs" color="red" variant="light">
{t("status.errors", { count: errorCount })}
</Badge>
) : warningCount > 0 ? (
<Badge size="xs" color="yellow" variant="light">
{t("status.warnings", { count: warningCount })}
</Badge>
) : (
<Group gap={4}>
<IconCheck size={13} color="var(--mantine-color-green-6)" aria-hidden />
<Text size="xs" c="dimmed">
{t("status.ready")}
</Text>
</Group>
)}
<Text size="xs" c="dimmed">
{t("status.position", cursor)}
</Text>
</Group>
<Text
size="xs"
c={maxLength && value.length > maxLength * 0.95 ? (value.length > maxLength ? "red" : "orange") : "dimmed"}
>
{maxLength
? t("status.charactersWithLimit", { count: value.length, limit: maxLength })
: t("status.characters", { count: value.length })}
</Text>
</Group>
</div>
<CodeEditorDiagnostics
diagnostics={diagnostics}
editorView={editorView}
title={t("diagnostics.title")}
formatDiagnostic={(diagnostic) =>
`${diagnostic.line ? `${t("diagnostics.line", { line: diagnostic.line })}: ` : ""}${t(
`diagnostics.${diagnostic.code}` as never,
{ value: diagnostic.value ?? "" } as never,
)}`
}
/>
</Input.Wrapper>
);
}
@@ -5,10 +5,289 @@ import { useDisclosure } from "@mantine/hooks";
import { IconCopy, IconSparkles } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
import { buildCustomWidgetAiPrompt } from "@homarr/custom-widgets/core";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useScopedI18n } from "@homarr/translation/client";
const PROMPT_HEADER = `You are helping configure a Homarr custom widget. Homarr is a self-hosted dashboard that can display data from any API endpoint as a widget.
## Your Task
Generate a valid JSON configuration for a Homarr custom widget based on the user's description and the API response below. If you need clarification, ask specific questions.
## Output Format
- Output the JSON inside a \`\`\`json code block for syntax highlighting. If \`\`\`json is not supported, use a generic \`\`\` code block instead.
- Do NOT include any text before or after the JSON block unless you have clarifying questions.
- If your environment supports structured/JSON output mode, use it.
## JSON Schema
The output must conform to this JSON Schema:
`;
const PROMPT_RULES = `
## Key Rules
- The \`displayConfig.type\` field MUST match the \`displayType\` field exactly
- All \`jsonPath\` fields use JSONPath syntax (e.g. \`$.data.count\`, \`$.items[*].name\`)
- The \`$schema\` field should be \`"homarr-custom-widget-v2"\`
- Do NOT include secrets/passwords in the output — those are configured separately in the UI
- \`url\` must be a full URL including protocol (e.g. \`https://...\`)
- For arrays of items, inspect the API response to find the correct paths
## Display Type Guide
- \`singleValue\`: Show one prominent number/text (e.g. total count, status)
- \`keyValue\`: Show labeled pairs like "CPU: 45%" — use \`mappings\` array
- \`table\`: Show tabular data from an array — needs \`tablePath\` for the array and \`columns\` for headers
- \`statGrid\`: Grid of stat cards with optional colors — great for dashboards (e.g. "Movies: 38, Series: 63")
- \`progressBars\`: Visual progress bars with value/max — for storage, quotas, etc.
- \`statusIndicator\`: Green/red dots based on value matching — for service health checks
- \`countGrid\`: Simple grid of counts — like statGrid but simpler, no colors
- \`raw\`: Raw JSON display — for debugging or complex data
- \`actionButton\`: A button that triggers the API call on click — for POST/PUT actions
- \`customJsx\`: Custom JSX layout using whitelisted Mantine components — full creative control over presentation. Set \`displayConfig.template\` to a JSX string. Access API data via \`{data.fieldName}\` bindings (e.g. \`{data.name}\`, \`{data.items[0].title}\`).
## Custom JSX (\`customJsx\`) — Available Mantine Components
When using \`customJsx\`, the template supports whitelisted Mantine UI components. These are React components from the Mantine v9 library. Use their props as documented below.
### Layout Components
- **Stack** — Vertical flex container. Props: \`gap\` (xs|sm|md|lg|xl or number), \`align\` (stretch|center|flex-start|flex-end), \`justify\`
- **Group** — Horizontal flex container. Props: \`gap\`, \`justify\` (flex-start|center|space-between|space-around|flex-end), \`wrap\` (wrap|nowrap), \`grow\` (boolean)
- **Flex** — Generic flex container. Props: \`direction\` (row|column), \`gap\`, \`align\`, \`justify\`, \`wrap\`
- **Grid** + **Grid.Col** — CSS grid layout. Grid props: \`gutter\` (gap). Grid.Col props: \`span\` (1-12 or "auto"), \`offset\`
- **SimpleGrid** — Auto-column grid. Props: \`cols\` (number), \`spacing\`, \`verticalSpacing\`
- **Center** — Centers content. Props: \`inline\` (boolean)
- **Space** — Empty spacing. Props: \`h\` (height), \`w\` (width)
- **Container** — Max-width wrapper. Props: \`size\` (xs|sm|md|lg|xl), \`fluid\` (boolean)
- **AspectRatio** — Maintains aspect ratio. Props: \`ratio\` (number, e.g. 16/9)
### Typography Components
- **Text** — Body text. Props: \`size\` (xs|sm|md|lg|xl), \`fw\` (font-weight number, e.g. 700), \`c\` (color: "dimmed"|"red"|"blue"|...), \`ta\` (text-align), \`tt\` (text-transform: "capitalize"|"uppercase"|"lowercase"), \`td\` (text-decoration), \`lineClamp\` (number), \`truncate\` (boolean), \`span\` (renders as span)
- **Title** — Heading. Props: \`order\` (1-6, maps to h1-h6), \`size\`, \`c\`, \`ta\`, \`tt\`
- **Code** — Inline code. Props: \`color\`, \`block\` (boolean for code block)
- **Highlight** — Text with highlighted substring. Props: \`highlight\` (string or string[])
- **Mark** — Highlighted text background. Props: \`color\`
- **Kbd** — Keyboard key display
- **Blockquote** — Styled quote. Props: \`color\`, \`cite\`, \`icon\`
- **Anchor** — Link. Props: \`href\`, \`target\`, \`underline\` ("always"|"hover"|"never")
- **NumberFormatter** — Formatted number display. Props: \`value\` (number), \`prefix\` (e.g. "$"), \`suffix\` (e.g. "%"), \`thousandSeparator\` (boolean or string), \`decimalScale\` (number)
### Data Display Components
- **Badge** — Label badge. Props: \`color\`, \`variant\` (filled|light|outline|dot|default), \`size\` (xs|sm|md|lg|xl), \`radius\`, \`tt\`
- **Card** + **Card.Section** — Content card. Card props: \`shadow\` (xs|sm|md|lg|xl), \`padding\`/\`p\`, \`radius\`, \`withBorder\` (boolean). Card.Section props: \`withBorder\`, \`inheritPadding\`
- **Paper** — Surface container. Props: \`shadow\`, \`p\`, \`radius\`, \`withBorder\`
- **Alert** — Notification box. Props: \`color\`, \`variant\` (filled|light|outline), \`title\`, \`icon\`
- **ThemeIcon** — Icon container. Props: \`color\`, \`variant\`, \`size\`, \`radius\`
- **ColorSwatch** — Color preview. Props: \`color\` (CSS color string)
- **Table** + **Table.Thead**, **Table.Tbody**, **Table.Tr**, **Table.Th**, **Table.Td** — HTML table. Table props: \`striped\` (boolean), \`withTableBorder\`, \`withColumnBorders\`, \`highlightOnHover\`
- **List** + **List.Item** — Ordered/unordered list. List props: \`type\` ("ordered"|"unordered"), \`icon\`, \`spacing\`
- **Timeline** + **Timeline.Item** — Vertical timeline. Timeline props: \`active\` (number), \`bulletSize\`. Item props: \`title\`, \`bullet\`
- **Accordion** + **.Item** + **.Control** + **.Panel** — Collapsible sections. Item props: \`value\` (string). Control: clickable header. Panel: content.
- **Indicator** — Dot indicator on content. Props: \`color\`, \`position\`, \`size\`, \`label\`, \`processing\` (boolean)
- **Pill** — Removable tag. Props: \`size\`, \`withRemoveButton\`
- **Spoiler** — Show more/less. Props: \`maxHeight\` (number), \`showLabel\`, \`hideLabel\`
### Feedback Components
- **Progress** + **Progress.Section** — Progress bar. Progress props: \`value\` (0-100), \`color\`, \`size\`, \`radius\`, \`striped\`, \`animated\`. Section props: \`value\`, \`color\`
- **RingProgress** — Circular progress. Props: \`sections\` (array of {value, color}), \`size\`, \`thickness\`, \`label\` (ReactNode)
- **Skeleton** — Loading placeholder. Props: \`height\`, \`width\`, \`radius\`, \`visible\` (boolean)
- **Loader** — Spinner. Props: \`size\`, \`color\`, \`type\` (oval|bars|dots)
### Media Components
- **Image** — Responsive image. Props: \`src\`, \`alt\`, \`w\` (width), \`h\` (height), \`radius\`, \`fit\` (cover|contain|fill)
- **Avatar** + **Avatar.Group** — User avatar. Props: \`src\`, \`alt\`, \`size\` (xs|sm|md|lg|xl or number), \`radius\` (sm|xl), \`color\`
- **BackgroundImage** — Background image container. Props: \`src\`, \`radius\`
- **Tooltip** — Hover tooltip. Props: \`label\` (string), \`position\` (top|bottom|left|right), \`withArrow\`
- **Divider** — Horizontal/vertical rule. Props: \`orientation\` (horizontal|vertical), \`label\`, \`labelPosition\`, \`size\`
- **ScrollArea** — Scrollable container. Props: \`h\` (height), \`type\` (auto|always|scroll|hover)
### Charts (@mantine/charts)
- **AreaChart**, **BarChart**, **LineChart** — Props: \`data\` (array of objects), \`dataKey\` (x-axis key), \`series\` (array of {name, color}), \`h\` (height), \`curveType\` (for Area/Line)
- **DonutChart**, **PieChart** — Props: \`data\` (array of {name, value, color}), \`h\`, \`withLabels\`, \`withLabelsLine\`
- **RadarChart** — Props: \`data\`, \`dataKey\`, \`series\`
- **RadialBarChart** — Props: \`data\` (array of {name, value, color}), \`h\`
- **Sparkline** — Props: \`data\` (flat number array), \`h\`, \`w\`, \`color\`, \`curveType\`
### Common Prop Patterns
- **Spacing/sizing:** Most components accept \`p\` (padding), \`m\` (margin), \`px\`/\`py\`/\`mx\`/\`my\`/\`pt\`/\`pb\`/\`pl\`/\`pr\`/\`mt\`/\`mb\`/\`ml\`/\`mr\` with values xs|sm|md|lg|xl or numbers
- **Colors:** Use Mantine color names: "red", "blue", "green", "orange", "violet", "pink", "cyan", "grape", "teal", "yellow", "lime", "indigo", "gray", "dark". Add shade suffix: "red.6", "blue.4"
- **Styles:** Use \`style={{...}}\` for inline CSS. E.g. \`style={{flex: 1, minWidth: 0}}\`
- **Conditional rendering:** Use ternaries: \`{condition ? <Text>Yes</Text> : <Text>No</Text>}\`
- **Responsive:** Most size/spacing props accept object: \`{{ base: "sm", md: "lg" }}\`
### Interactive Components (built-in, state-managed)
These components manage their own internal state — they provide interactivity without event handlers:
**PaginatedList** — Paginates its children with prev/next buttons.
Props: \`pageSize\` (number, default 6)
Usage: Wrap a \`.map()\` expression to paginate results.
\`\`\`jsx
<PaginatedList pageSize={8}>
{data.results.map(item =>
<Card withBorder p="xs" mb="xs">
<Text>{item.name}</Text>
</Card>
)}
</PaginatedList>
\`\`\`
**TabsContainer + TabPanel** — Tabbed interface with automatic tab switching.
TabsContainer props: \`defaultTab\` (string, optional — defaults to first tab)
TabPanel props: \`value\` (string, required — unique tab ID), \`label\` (string, optional — tab display text)
\`\`\`jsx
<TabsContainer defaultTab="overview">
<TabPanel value="overview" label="Overview">
<Text>{data.description}</Text>
</TabPanel>
<TabPanel value="stats" label="Statistics">
<Text>Total: {data.count}</Text>
</TabPanel>
</TabsContainer>
\`\`\`
**Collapsible** — Expandable/collapsible section with a title.
Props: \`title\` (string, required), \`defaultOpen\` (boolean, default false)
\`\`\`jsx
<Collapsible title="Details" defaultOpen={true}>
<Text>{data.details}</Text>
</Collapsible>
\`\`\`
**StatBar** — A horizontal stat bar with label/value (great for RPG-style stats, progress).
Props: \`value\` (number), \`max\` (number, default 100), \`label\` (string), \`color\` (Mantine color)
\`\`\`jsx
<StatBar label="HP" value={45} max={100} color="red" />
<StatBar label="ATK" value={80} max={255} color="orange" />
\`\`\`
**TypeBadge** — A colored badge that maps common type names to colors (normal, fire, water, electric, grass, ice, fighting, poison, ground, flying, psychic, bug, rock, ghost, dragon, dark, steel, fairy).
Props: \`type\` (string), \`size\` (xs|sm|md|lg|xl, default sm)
\`\`\`jsx
<Group gap="xs">
<TypeBadge type="fire" />
<TypeBadge type="flying" />
</Group>
\`\`\`
**Available bindings in templates:**
- \`data\` — the full API response JSON object
- \`String(v)\`, \`Number(v)\`, \`Boolean(v)\` — type coercion helpers
- \`Math.round\`, \`Math.floor\`, \`Math.ceil\`, \`Math.abs\`, \`Math.min\`, \`Math.max\`, \`Math.pow\`, \`Math.sqrt\`, \`Math.PI\`
- \`JSON.stringify(v)\`, \`Array.isArray(v)\`, \`Object.keys(v)\`, \`Object.values(v)\`, \`Object.entries(v)\`
- Expression arrows for \`.map()\`, \`.filter()\`, \`.slice()\` — e.g. \`{data.items.map(item => <Text>{item.name}</Text>)}\`
- Ternaries for conditionals — e.g. \`{data.count > 0 ? "active" : "idle"}\`
**FORBIDDEN keywords (template will be rejected):** constructor, __proto__, eval, Function, import, require, globalThis, window, document, fetch
**Chart data formats:**
- BarChart/LineChart/AreaChart: \`data={data.items}\` + \`dataKey="month"\` (x-axis key) + \`series={[{ name: "count", color: "blue" }]}\` where series \`name\` matches a numeric key in each data row
- DonutChart/PieChart: \`data={data.items}\` where each item is \`{ name: "Label", value: 42, color: "blue" }\` — NO dataKey or series
- Sparkline: \`data={[1,2,3]}\` — flat number array
- NumberFormatter: \`<NumberFormatter value={data.price} thousandSeparator prefix="$" />\`
### Full Pokémon Card Example (demonstrating TypeBadge, StatBar, TabsContainer, Collapsible)
API: \`https://pokeapi.co/api/v2/pokemon/charizard\`
\`\`\`jsx
<Stack gap="sm" p="xs">
<Group wrap="nowrap">
<Avatar src={data.sprites.front_default} size={80} radius="sm" />
<Stack gap={4} style={{flex: 1}}>
<Title order={3} tt="capitalize">{data.name}</Title>
<Text size="xs" c="dimmed">#{String(data.id).padStart(3, "0")}</Text>
<Group gap="xs">
{data.types.map(t => <TypeBadge type={t.type.name} />)}
</Group>
</Stack>
</Group>
<TabsContainer defaultTab="stats">
<TabPanel value="stats" label="Stats">
<Stack gap="xs" pt="xs">
{data.stats.map(s =>
<StatBar label={s.stat.name} value={s.base_stat} max={255} color={s.base_stat > 100 ? "green" : "red"} />
)}
</Stack>
</TabPanel>
<TabPanel value="abilities" label="Abilities">
<Stack gap="xs" pt="xs">
{data.abilities.map(a =>
<Badge variant={a.is_hidden ? "outline" : "filled"} tt="capitalize">
{a.ability.name}{a.is_hidden ? " (hidden)" : ""}
</Badge>
)}
</Stack>
</TabPanel>
<TabPanel value="moves" label="Moves">
<Collapsible title="Move list">
<Group gap="xs" pt="xs">
{data.moves.slice(0, 20).map(m =>
<Badge size="xs" variant="light" tt="capitalize">{m.move.name}</Badge>
)}
</Group>
</Collapsible>
</TabPanel>
</TabsContainer>
</Stack>
\`\`\`
### Paginated List Example
API: \`https://pokeapi.co/api/v2/pokemon?limit=50\`
\`\`\`jsx
<Stack gap="sm" p="xs">
<Group justify="space-between">
<Title order={3}>Pokédex</Title>
<Badge size="lg" color="red">{data.count} Pokémon</Badge>
</Group>
<PaginatedList pageSize={10}>
{data.results.map((pokemon, i) =>
<Card withBorder p="xs" mb="xs">
<Group wrap="nowrap">
<Avatar
src={"https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/" + String(i + 1) + ".png"}
size="lg"
radius="sm"
/>
<Stack gap={0} style={{ flex: 1 }}>
<Text fw={700} tt="capitalize">{pokemon.name}</Text>
<Text size="xs" c="dimmed">#{String(i + 1)}</Text>
</Stack>
</Group>
</Card>
)}
</PaginatedList>
</Stack>
\`\`\`
## API Response
`;
const PROMPT_NO_RESPONSE = `Paste the raw JSON response from your API endpoint below:
\`\`\`json
PASTE_YOUR_API_RESPONSE_HERE
\`\`\`
`;
const PROMPT_FOOTER = `
## Your Request
Describe what you want the widget to show:
`;
function buildAiPrompt(
jsonSchema: unknown,
rawResponse?: string | null,
currentConfig?: Record<string, unknown> | null,
) {
const schemaStr = JSON.stringify(jsonSchema, null, 2);
const responseSection = rawResponse
? `The API returned the following JSON:\n\n\`\`\`json\n${rawResponse}\n\`\`\`\n`
: PROMPT_NO_RESPONSE;
const configSection = currentConfig
? `\n## Current Widget Configuration\nThe widget is currently configured as follows. Use this as a starting point and modify based on the user's request:\n\n\`\`\`json\n${JSON.stringify(currentConfig, null, 2)}\n\`\`\`\n`
: "";
return PROMPT_HEADER + schemaStr + PROMPT_RULES + responseSection + configSection + PROMPT_FOOTER;
}
interface CopyAiPromptButtonProps {
rawResponse?: string | null;
currentConfig?: Record<string, unknown> | null;
@@ -21,8 +300,9 @@ export const CopyAiPromptButton = ({ rawResponse, currentConfig }: CopyAiPromptB
const handleCopy = async () => {
if (!schema) return;
const prompt = buildAiPrompt(schema, rawResponse, currentConfig);
try {
await navigator.clipboard.writeText(buildCustomWidgetAiPrompt(schema, rawResponse, currentConfig));
await navigator.clipboard.writeText(prompt);
close();
showSuccessNotification({ title: t("action.copyAiPrompt"), message: t("notification.aiPromptCopied") });
} catch {
@@ -34,7 +314,6 @@ export const CopyAiPromptButton = ({ rawResponse, currentConfig }: CopyAiPromptB
<Popover opened={opened} onClose={close} width={320} position="bottom" shadow="md" withinPortal>
<Popover.Target>
<Button
type="button"
variant="light"
leftSection={<IconSparkles size={16} />}
onClick={open}
@@ -54,7 +333,7 @@ export const CopyAiPromptButton = ({ rawResponse, currentConfig }: CopyAiPromptB
{t("notification.aiPromptNoResponse")}
</Text>
)}
<Button type="button" leftSection={<IconCopy size={16} />} onClick={() => void handleCopy()} fullWidth>
<Button leftSection={<IconCopy size={16} />} onClick={() => void handleCopy()} fullWidth>
{t("notification.aiPromptCopy")}
</Button>
</Stack>
@@ -1,20 +1,24 @@
"use client";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useRef } from "react";
import { ActionIcon, Menu } from "@mantine/core";
import { useDisclosure } from "@mantine/hooks";
import { IconCopy, IconDots, IconDownload, IconTrash, IconUpload } from "@tabler/icons-react";
import {
IconCopy,
IconDots,
IconDownload,
IconToggleLeft,
IconToggleRight,
IconTrash,
IconUpload,
} from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
import { revalidatePathActionAsync } from "@homarr/common/client";
import { getImportReview, parseCustomWidgetClipboard } from "@homarr/custom-widgets/core";
import { ImportReviewDialog } from "@homarr/custom-widgets/workbench";
import { useConfirmModal } from "@homarr/modals";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useScopedI18n } from "@homarr/translation/client";
import { MobileAffixButton } from "~/components/manage/mobile-affix-button";
const iconProps = { size: 16, stroke: 1.5 };
interface WidgetRef {
@@ -28,8 +32,28 @@ export const CustomWidgetRowActions = ({ widget }: { widget: WidgetRef }) => {
const { openConfirmModal } = useConfirmModal();
const deleteMutation = clientApi.customWidget.delete.useMutation();
const duplicateMutation = clientApi.customWidget.duplicate.useMutation();
const toggleEnabledMutation = clientApi.customWidget.toggleEnabled.useMutation();
const utils = clientApi.useUtils();
const handleToggleEnabled = () => {
toggleEnabledMutation.mutate(
{ id: widget.id, enabled: !widget.enabled },
{
onSuccess: () => {
void utils.customWidget.all.invalidate();
void utils.widget.customApi.getData.invalidate({ definitionId: widget.id });
void revalidatePathActionAsync("/manage/custom-widgets");
},
onError: () => {
showErrorNotification({
title: widget.enabled ? t("action.disable") : t("action.enable"),
message: t("notification.toggleError"),
});
},
},
);
};
const handleExport = async () => {
try {
const data = await utils.customWidget.export.fetch({ id: widget.id });
@@ -78,7 +102,7 @@ export const CustomWidgetRowActions = ({ widget }: { widget: WidgetRef }) => {
message: t("notification.deleted", { name: widget.name }),
});
void utils.customWidget.all.invalidate();
void utils.widget.customApi.getData.invalidate();
void utils.widget.customApi.getData.invalidate({ definitionId: widget.id });
void revalidatePathActionAsync("/manage/custom-widgets");
},
onError: () => {
@@ -98,6 +122,14 @@ export const CustomWidgetRowActions = ({ widget }: { widget: WidgetRef }) => {
</ActionIcon>
</Menu.Target>
<Menu.Dropdown>
<Menu.Item
onClick={handleToggleEnabled}
leftSection={widget.enabled ? <IconToggleLeft {...iconProps} /> : <IconToggleRight {...iconProps} />}
disabled={toggleEnabledMutation.isPending}
>
{widget.enabled ? t("action.disable") : t("action.enable")}
</Menu.Item>
<Menu.Divider />
<Menu.Item
onClick={handleDuplicate}
leftSection={<IconCopy {...iconProps} />}
@@ -125,13 +157,9 @@ export const CustomWidgetRowActions = ({ widget }: { widget: WidgetRef }) => {
export const ImportCustomWidgetButton = () => {
const t = useScopedI18n("customWidget");
const fileInputRef = useRef<HTMLInputElement>(null);
const [pendingImport, setPendingImport] = useState<Record<string, unknown> | null>(null);
const [reviewOpened, { open: openReview, close: closeReview }] = useDisclosure(false);
const utils = clientApi.useUtils();
const importMutation = clientApi.customWidget.import.useMutation({
onSuccess: () => {
closeReview();
setPendingImport(null);
showSuccessNotification({ title: t("action.import"), message: t("notification.imported") });
void utils.customWidget.all.invalidate();
void revalidatePathActionAsync("/manage/custom-widgets");
@@ -140,45 +168,23 @@ export const ImportCustomWidgetButton = () => {
showErrorNotification({ title: t("action.import"), message: t("notification.importError") });
},
});
const review = useMemo(() => getImportReview(pendingImport), [pendingImport]);
const queueImport = useCallback(
(value: Record<string, unknown>) => {
setPendingImport(value);
openReview();
},
[openReview],
);
useEffect(() => {
const handlePaste = (event: ClipboardEvent) => {
const target = event.target as HTMLElement | null;
if (target?.matches("input, textarea, [contenteditable='true']")) return;
const widget = parseCustomWidgetClipboard(event.clipboardData?.getData("text/plain") ?? "");
if (!widget) return;
event.preventDefault();
queueImport(widget);
};
window.addEventListener("paste", handlePaste);
return () => window.removeEventListener("paste", handlePaste);
}, [queueImport]);
const handleImport = (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (!file) return;
const reader = new FileReader();
reader.addEventListener("load", (e) => {
reader.onload = (e) => {
try {
const widget = parseCustomWidgetClipboard(e.target?.result as string);
if (!widget) throw new Error("Invalid import");
queueImport(widget);
const json = JSON.parse(e.target?.result as string);
importMutation.mutate(json);
} catch {
showErrorNotification({ title: t("action.import"), message: t("notification.importError") });
}
});
reader.addEventListener("error", () => {
};
reader.onerror = () => {
showErrorNotification({ title: t("action.import"), message: t("notification.importError") });
});
};
reader.readAsText(file);
event.target.value = "";
};
@@ -190,39 +196,16 @@ export const ImportCustomWidgetButton = () => {
leftSection={<IconUpload size={16} />}
onClick={() => fileInputRef.current?.click()}
loading={importMutation.isPending}
title={t("action.pasteImportHint")}
>
{t("action.import")}
</MobileAffixButton>
<input
ref={fileInputRef}
type="file"
accept=".json,.md,text/markdown,application/json"
accept=".json"
hidden
onChange={handleImport}
aria-label={t("importReview.fileLabel")}
/>
<ImportReviewDialog
opened={reviewOpened}
review={review}
pending={importMutation.isPending}
onClose={closeReview}
onConfirm={() => pendingImport && importMutation.mutate(pendingImport as never)}
messages={{
title: t("importReview.title"),
description: t("importReview.description"),
name: t("importReview.name"),
origin: t("importReview.origin"),
authentication: t("importReview.authentication"),
networkScope: t("importReview.networkScope"),
methods: t("importReview.methods"),
permissions: t("importReview.permissions"),
actionWarningTitle: t("importReview.actionWarning.title"),
actionWarningDescription: t("importReview.actionWarning.description"),
cancel: t("importReview.cancel"),
confirm: t("importReview.confirm"),
permission: (permission) => t(`preview.request.permission.${permission}` as never),
}}
aria-label="Import custom widget"
/>
</>
);
@@ -1,42 +0,0 @@
const serverToFormFieldMap: Record<string, Record<string, string>> = {
statGrid: { items: "statGridItems" },
countGrid: { items: "countGridItems" },
statusIndicator: { items: "statusItems" },
progressBars: { bars: "progressBars" },
keyValue: { mappings: "mappings" },
table: { columns: "columns", tablePath: "tablePath" },
singleValue: { jsonPath: "jsonPath", label: "label", unit: "unit" },
raw: { jsonPath: "rawJsonPath" },
actionButton: { buttonLabel: "buttonLabel" },
customJsx: { template: "template", requests: "requestManifest", networkScope: "networkScope" },
};
export function extractServerErrors(error: unknown, displayType: string): Record<string, string> {
const errors: Record<string, string> = {};
const trpcError = error as {
data?: { zodError?: { fieldErrors?: Record<string, string[]> } };
message?: string;
};
if (trpcError.data?.zodError?.fieldErrors) {
for (const [field, messages] of Object.entries(trpcError.data.zodError.fieldErrors)) {
if (messages?.[0]) errors[field] = messages[0];
}
return errors;
}
try {
const issues = JSON.parse(trpcError.message ?? "[]") as Array<{ path: (string | number)[]; message: string }>;
const fieldMap = serverToFormFieldMap[displayType] ?? {};
for (const issue of issues) {
const path = [...issue.path];
if (path[0] === "displayConfig") {
path.shift();
const serverField = String(path[0]);
path[0] = fieldMap[serverField] ?? serverField;
}
errors[path.join(".")] = issue.message;
}
} catch {
// Non-validation server errors are handled by the caller's notification.
}
return errors;
}
@@ -1,238 +0,0 @@
import type { RefObject } from "react";
import {
Button,
Fieldset,
Group,
PasswordInput,
SegmentedControl,
Select,
Stack,
Text,
TextInput,
Textarea,
ThemeIcon,
} from "@mantine/core";
import { IconCode, IconLayoutGrid, IconPlayerPlay } from "@tabler/icons-react";
import { IconPicker } from "@homarr/forms-collection";
import { CUSTOM_JSX_STARTER } from "@homarr/custom-widgets/core";
import {
CUSTOM_WIDGET_AUTH_SECRET_FIELDS,
CUSTOM_WIDGET_AUTH_USES_HEADER_NAME,
} from "@homarr/custom-widgets/workbench";
import type { DisplayFieldsProps } from "./_display-field-types";
import { DISPLAY_TYPE_ICONS, DisplayTypeFields, DisplayTypePicker } from "./_display-type-fields";
import classes from "./_custom-widget-form.module.css";
interface SharedSectionProps extends DisplayFieldsProps {
sectionRef: RefObject<HTMLElement | null>;
}
interface FormatSectionProps extends SharedSectionProps {
onContinue: () => void;
}
export function FormatSection({ form, t, sectionRef, onContinue }: FormatSectionProps) {
return (
<section ref={sectionRef} className={classes.formSection} tabIndex={-1}>
<ThemeIcon className={classes.sectionMarker} variant="filled" size={40} radius="xl">
<IconLayoutGrid size={20} />
</ThemeIcon>
<Stack gap="xl">
<SectionHeading label={t("steps.format.label")} description={t("steps.format.description")} />
<Fieldset legend={t("fieldset.general")}>
<Stack gap="sm">
<TextInput label={t("field.name")} required {...form.getInputProps("name")} />
<Textarea label={t("field.description")} {...form.getInputProps("description")} />
<IconPicker withAsterisk={false} {...form.getInputProps("iconUrl")} />
</Stack>
</Fieldset>
<Fieldset legend={t("field.displayType")}>
<Stack gap="sm">
<Text size="sm" c="dimmed">
{t("steps.format.help")}
</Text>
<DisplayTypePicker
value={form.values.displayType}
onChange={(value) => {
form.setFieldValue("displayType", value);
if (value === "customJsx" && !form.values.template.trim())
form.setFieldValue("template", CUSTOM_JSX_STARTER);
}}
t={t}
/>
</Stack>
</Fieldset>
<ContinueButton label={t("steps.continueTo", { step: t("steps.connection.label") })} onClick={onContinue} />
</Stack>
</section>
);
}
interface ConnectionSectionProps extends SharedSectionProps {
isTesting: boolean;
onTest: () => void;
onContinue: () => void;
}
export function ConnectionSection({ form, t, sectionRef, isTesting, onTest, onContinue }: ConnectionSectionProps) {
const secretFields = CUSTOM_WIDGET_AUTH_SECRET_FIELDS[form.values.authType] ?? [];
const authTypeOptions = ["none", "bearer", "basic", "apiKeyHeader", "apiKeyQuery"].map((value) => ({
value,
label: t(`authType.${value}` as never),
}));
const handleAuthTypeChange = (value: string) => {
form.setFieldValue("authType", value);
form.setFieldValue(
"secrets",
(CUSTOM_WIDGET_AUTH_SECRET_FIELDS[value] ?? []).map((field) => ({
kind: field.kind,
value: form.values.secrets.find((secret) => secret.kind === field.kind)?.value ?? "",
})),
);
};
return (
<section ref={sectionRef} className={classes.formSection} tabIndex={-1}>
<ThemeIcon className={classes.sectionMarker} variant="light" size={40} radius="xl">
<IconPlayerPlay size={20} />
</ThemeIcon>
<Stack gap="xl">
<SectionHeading label={t("steps.connection.label")} description={t("steps.connection.description")} />
<Fieldset legend={t("fieldset.connection")}>
<Stack gap="sm">
<Group align="end" wrap="wrap" gap="xs">
<Select
label={t("field.method")}
data={["GET", "POST", "PUT", "DELETE", "PATCH"].map((value) => ({
value,
label: t(`method.${value}` as never),
}))}
w={110}
{...form.getInputProps("method")}
allowDeselect={false}
disabled={form.values.displayType === "customJsx" && form.values.jsxApiVersion === "2"}
/>
<TextInput
label={t("field.url")}
required
placeholder={t("placeholder.url")}
style={{ flex: "1 1 260px" }}
{...form.getInputProps("url")}
/>
<Button
type="button"
size="sm"
variant="light"
leftSection={<IconPlayerPlay size={16} />}
onClick={onTest}
loading={isTesting}
disabled={!form.values.url || form.values.method !== "GET"}
>
{t("preview.test")}
</Button>
</Group>
<div>
<Text size="sm" fw={500} mb={4}>
{t("field.authType")}
</Text>
<Select
hiddenFrom="sm"
value={form.values.authType}
data={authTypeOptions}
onChange={(value) => handleAuthTypeChange(value ?? "none")}
allowDeselect={false}
aria-label={t("field.authType")}
/>
<SegmentedControl
visibleFrom="sm"
fullWidth
value={form.values.authType}
data={authTypeOptions}
onChange={handleAuthTypeChange}
/>
</div>
{CUSTOM_WIDGET_AUTH_USES_HEADER_NAME[form.values.authType] && (
<TextInput
label={t("field.headerName")}
placeholder={t("placeholder.headerName")}
{...form.getInputProps("headerName")}
/>
)}
{secretFields.map((field) => {
const index = form.values.secrets.findIndex((secret) => secret.kind === field.kind);
const secret = form.values.secrets[index];
if (index === -1 || !secret) return null;
const props = {
label: t(`secret.${field.labelKey}` as never),
placeholder: secret.hasValue && !secret.value ? t("secret.savedPlaceholder" as never) : undefined,
...form.getInputProps(`secrets.${index}.value`),
};
return field.isPassword ? (
<PasswordInput key={field.kind} {...props} />
) : (
<TextInput key={field.kind} {...props} />
);
})}
{form.values.method !== "GET" && (
<Textarea label={t("field.requestBody")} minRows={3} {...form.getInputProps("requestBody")} />
)}
</Stack>
</Fieldset>
<ContinueButton label={t("steps.continueTo", { step: t("steps.configure.label") })} onClick={onContinue} />
</Stack>
</section>
);
}
export function ConfigureSection({ form, t, previewJson, sectionRef }: SharedSectionProps) {
const SelectedIcon = DISPLAY_TYPE_ICONS[form.values.displayType as keyof typeof DISPLAY_TYPE_ICONS];
return (
<section ref={sectionRef} className={classes.formSection} tabIndex={-1}>
<ThemeIcon className={classes.sectionMarker} variant="light" size={40} radius="xl">
<IconCode size={20} />
</ThemeIcon>
<Stack gap="xl">
<SectionHeading label={t("steps.configure.label")} description={t("steps.configure.description")} />
<Fieldset legend={t("fieldset.display")}>
<Stack gap="sm">
<Group gap="xs">
<ThemeIcon variant="light" size="lg">
{SelectedIcon ? <SelectedIcon size={20} /> : null}
</ThemeIcon>
<div>
<Text fw={600}>{t(`displayType.${form.values.displayType}` as never)}</Text>
<Text size="xs" c="dimmed">
{t(`displayTypeDescription.${form.values.displayType}` as never)}
</Text>
</div>
</Group>
<DisplayTypeFields form={form} t={t} previewJson={previewJson} />
</Stack>
</Fieldset>
</Stack>
</section>
);
}
function SectionHeading({ label, description }: { label: string; description: string }) {
return (
<div>
<Text fw={700} size="lg">
{label}
</Text>
<Text size="sm" c="dimmed">
{description}
</Text>
</div>
);
}
function ContinueButton({ label, onClick }: { label: string; onClick: () => void }) {
return (
<Group justify="flex-end">
<Button type="button" variant="light" size="md" onClick={onClick}>
{label}
</Button>
</Group>
);
}
@@ -1,151 +0,0 @@
.form {
min-width: 0;
}
.workbench {
display: grid;
gap: var(--mantine-spacing-xl);
align-items: start;
}
.paneSwitcher {
position: sticky;
top: var(--mantine-spacing-xs);
z-index: 10;
margin-bottom: var(--mantine-spacing-lg);
background: var(--mantine-color-body);
}
@media (max-width: 74.9375em) {
.workbench[data-mobile-pane="configure"] .previewPane,
.workbench[data-mobile-pane="preview"] .configuration {
display: none;
}
}
.configuration,
.previewPane {
min-width: 0;
}
.previewPane {
display: flex;
flex-direction: column;
gap: var(--mantine-spacing-md);
}
.actionBar {
position: sticky;
bottom: var(--mantine-spacing-sm);
z-index: 10;
background: var(--mantine-color-body);
}
.mobileSaveBar {
position: sticky;
bottom: var(--mantine-spacing-sm);
z-index: 10;
background: var(--mantine-color-body);
}
.formFlow {
--flow-marker-size: 2.5rem;
}
.formSection {
position: relative;
padding: 0 0 var(--mantine-spacing-xl) calc(var(--flow-marker-size) + var(--mantine-spacing-lg));
scroll-margin-top: 5rem;
outline: none;
}
.formSection:not(:last-child)::before {
position: absolute;
top: var(--flow-marker-size);
bottom: 0;
left: calc(var(--flow-marker-size) / 2);
width: 1px;
background: var(--mantine-color-default-border);
content: "";
}
.formSection:focus-visible {
border-radius: var(--mantine-radius-md);
outline: 2px solid var(--mantine-primary-color-filled);
outline-offset: 4px;
}
.sectionMarker {
position: absolute;
top: 0;
left: 0;
z-index: 1;
}
.displayTypeOption {
display: flex;
align-items: flex-start;
gap: var(--mantine-spacing-sm);
min-height: 7rem;
padding: var(--mantine-spacing-md);
border: 1px solid var(--mantine-color-default-border);
border-radius: var(--mantine-radius-md);
background: var(--mantine-color-body);
transition:
border-color 160ms ease,
background-color 160ms ease;
}
.displayTypeOption:hover {
background: var(--mantine-color-default-hover);
}
.displayTypeOption[data-selected] {
border-color: var(--mantine-primary-color-filled);
background: var(--mantine-primary-color-light);
}
.displayTypeOption:focus-visible {
outline: 2px solid var(--mantine-primary-color-filled);
outline-offset: 2px;
}
.displayTypeCopy {
min-width: 0;
}
@media (prefers-reduced-motion: reduce) {
.displayTypeOption {
transition: none;
}
}
@media (min-width: 75em) {
.paneSwitcher {
display: none;
}
.mobileSaveBar {
display: none;
}
.workbench {
grid-template-columns: 1fr 1fr;
}
.previewPane {
position: sticky;
top: 5rem;
}
.actionBar {
top: 5rem;
bottom: auto;
}
}
@media (min-width: 100em) {
.workbench {
grid-template-columns: 0.9fr 1.1fr;
}
}
File diff suppressed because it is too large. Load diff
@@ -1,12 +1,9 @@
"use client";
import { ActionIcon, Avatar, Badge, Card, Group, Stack, Switch, Text, Tooltip } from "@mantine/core";
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 } from "@homarr/notifications";
import { useScopedI18n } from "@homarr/translation/client";
import { Link } from "@homarr/ui";
@@ -26,6 +23,8 @@ const displayTypeBadgeColors: Record<string, string> = {
customJsx: "pink",
};
const iconProps = { size: 16, stroke: 1.5 };
interface CustomWidgetListProps {
definitions: RouterOutputs["customWidget"]["all"];
}
@@ -44,99 +43,59 @@ export const CustomWidgetList = ({ definitions }: CustomWidgetListProps) => {
}
return (
<Stack gap="sm">
{definitions.map((def) => (
<CustomWidgetCard key={def.id} widget={def} />
))}
<Stack gap="md">
<Table striped highlightOnHover withTableBorder>
<Table.Thead>
<Table.Tr>
<Table.Th>{t("table.name")}</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>
<Group gap="xs" wrap="nowrap">
{def.iconUrl ? (
<Avatar size={20} radius="sm" src={def.iconUrl} styles={{ image: { objectFit: "contain" } }} />
) : (
<IconApi size={16} />
)}
<Text size="sm" fw={500} lineClamp={1} c={def.enabled ? undefined : "dimmed"} style={{ minWidth: 0 }}>
{def.name}
</Text>
</Group>
</Table.Td>
<Table.Td>
<Text size="sm" c="dimmed" lineClamp={1}>
{def.url}
</Text>
</Table.Td>
<Table.Td>
<Badge color={displayTypeBadgeColors[def.displayType] ?? "gray"} size="sm">
{t(`displayType.${def.displayType}` as never)}
</Badge>
</Table.Td>
<Table.Td>
<Group gap={4} justify="flex-end" wrap="nowrap">
<ActionIcon
component={Link}
href={`/manage/custom-widgets/edit/${def.id}`}
variant="filled"
color="red"
aria-label={t("action.edit")}
>
<IconPencil {...iconProps} />
</ActionIcon>
<CustomWidgetRowActions widget={{ id: def.id, name: def.name, enabled: def.enabled }} />
</Group>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
</Stack>
);
};
type WidgetDef = RouterOutputs["customWidget"]["all"][number];
function CustomWidgetCard({ widget }: { widget: WidgetDef }) {
const t = useScopedI18n("customWidget");
const toggleMutation = clientApi.customWidget.toggleEnabled.useMutation();
const utils = clientApi.useUtils();
const handleToggle = () => {
toggleMutation.mutate(
{ id: widget.id, enabled: !widget.enabled },
{
onSuccess: () => {
void utils.customWidget.all.invalidate();
void utils.widget.customApi.getData.invalidate();
void revalidatePathActionAsync("/manage/custom-widgets");
},
onError: () => {
showErrorNotification({
title: widget.enabled ? t("action.disable") : t("action.enable"),
message: t("notification.toggleError"),
});
},
},
);
};
return (
<Card
padding="sm"
style={{
opacity: widget.enabled ? 1 : 0.55,
transition: "opacity 150ms ease",
}}
>
<Group justify="space-between" wrap="nowrap" gap="md">
<Group wrap="nowrap" gap="sm" style={{ flex: 1, minWidth: 0 }}>
{widget.iconUrl ? (
<Avatar size={36} radius="sm" src={widget.iconUrl} styles={{ image: { objectFit: "contain" } }} />
) : (
<Avatar size={36} radius="sm" color="red">
<IconApi size={18} />
</Avatar>
)}
<Stack gap={2} style={{ minWidth: 0, flex: 1 }}>
<Text size="sm" fw={600} lineClamp={1} style={{ minWidth: 0 }}>
{widget.name}
</Text>
{widget.url && (
<Text size="xs" c="dimmed" ff="monospace" lineClamp={1} style={{ wordBreak: "break-all", minWidth: 0 }}>
{widget.url}
</Text>
)}
</Stack>
<Badge
color={displayTypeBadgeColors[widget.displayType] ?? "gray"}
size="sm"
variant="light"
style={{ flexShrink: 0 }}
>
{t(`displayType.${widget.displayType}` as never)}
</Badge>
</Group>
<Group gap="xs" wrap="nowrap" style={{ flexShrink: 0 }}>
<Tooltip label={widget.enabled ? t("action.disable") : t("action.enable")}>
<Switch
size="sm"
checked={widget.enabled}
onChange={handleToggle}
disabled={toggleMutation.isPending}
aria-label={widget.enabled ? t("action.disable") : t("action.enable")}
/>
</Tooltip>
<ActionIcon
component={Link}
href={`/manage/custom-widgets/edit/${widget.id}`}
variant="subtle"
color="gray"
aria-label={t("action.edit")}
>
<IconPencil size={16} stroke={1.5} />
</ActionIcon>
<CustomWidgetRowActions widget={{ id: widget.id, name: widget.name, enabled: widget.enabled }} />
</Group>
</Group>
</Card>
);
}
@@ -1,42 +1,23 @@
"use client";
import { useMemo, useState } from "react";
import { Card, Stack, Tabs } from "@mantine/core";
import { useMemo } from "react";
import { Alert, Badge, Button, Card, Center, Code, Group, Loader, ScrollArea, Stack, Text, Title } from "@mantine/core";
import { IconAlertTriangle, IconExternalLink, IconPlayerPlay } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
import type {
CustomWidgetAuthType,
CustomWidgetDisplayType,
CustomWidgetMethod,
CustomWidgetSecretKind,
DisplayConfig,
} from "@homarr/custom-widgets/core";
import { extractDisplayData } from "@homarr/custom-widgets/core";
import {
analyzeJsxTemplate,
getPreviewLegacyMethods,
getPreviewNamedRequests,
PreviewDiagnosticsPanel,
PreviewErrorBoundary,
PreviewHeader,
PreviewRequestPanel,
PreviewResponsePanel,
redactPreviewUrl,
} from "@homarr/custom-widgets/workbench";
import { CUSTOM_JSX_METHOD_COLORS } from "@homarr/widgets/custom-api/custom-jsx-display";
import { useScopedI18n } from "@homarr/translation/client";
import { extractDisplayData } from "@homarr/widgets/custom-api/extract-display-data";
import { PreviewWidgetPanel } from "./_preview-widget-panel";
import { displayComponents } from "@homarr/widgets/custom-api/component";
export interface PreviewInput {
interface PreviewInput {
url: string;
method: CustomWidgetMethod;
authType: CustomWidgetAuthType;
method: string;
authType: string;
headerName?: string;
requestBody?: string;
displayType: CustomWidgetDisplayType;
displayConfig: DisplayConfig;
secrets: Array<{ kind: CustomWidgetSecretKind; value: string }>;
displayType: string;
displayConfig: Record<string, unknown>;
secrets: Array<{ kind: string; value: string }>;
definitionId?: string;
}
@@ -45,7 +26,6 @@ export interface PreviewFetchResult {
error?: string;
responseInfo: { status: number; statusText: string } | null;
rawResponse: string | null;
previewSession?: { id: string; expiresAt: number; liveActions: boolean } | null;
}
interface CustomWidgetPreviewProps {
@@ -55,223 +35,146 @@ interface CustomWidgetPreviewProps {
cachedJson: unknown;
onTest: () => void;
isTesting: boolean;
isSampleStale?: boolean;
testError?: string | null;
onInsertDataPath?: (path: string) => void;
onSetPreviewLiveActions?: (enabled: boolean) => void;
isUpdatingPreviewActions?: boolean;
onSampleDataChange?: (value: unknown) => void;
}
const methodColor = (method: string) => CUSTOM_JSX_METHOD_COLORS[method] ?? "gray";
export function CustomWidgetPreview(props: CustomWidgetPreviewProps) {
const t = useScopedI18n("customWidget.preview");
return (
<PreviewErrorBoundary
title={t("errorBoundary.title")}
description={t("errorBoundary.description")}
retryLabel={t("errorBoundary.retry")}
>
<CustomWidgetPreviewContent {...props} />
</PreviewErrorBoundary>
);
}
function CustomWidgetPreviewContent({
export function CustomWidgetPreview({
getFormValues,
formValues,
fetchResult,
cachedJson,
onTest,
isTesting,
isSampleStale = false,
testError,
onInsertDataPath,
onSetPreviewLiveActions,
isUpdatingPreviewActions = false,
onSampleDataChange,
}: CustomWidgetPreviewProps) {
const t = useScopedI18n("customWidget");
const editorT = useScopedI18n("customWidget.editor");
const [previewSize, setPreviewSize] = useState("standard");
const displayConfig = formValues.displayConfig as DisplayConfig & Record<string, unknown>;
const previewSessionId = fetchResult?.previewSession?.id ?? "";
const journalQuery = clientApi.customWidget.previewJournal.useQuery(
{ sessionId: previewSessionId },
{ enabled: previewSessionId.length > 0, retry: false, refetchInterval: 1_000 },
);
const displayData = useMemo((): Record<string, unknown> | null => {
const { displayType } = formValues;
const { displayType, displayConfig } = formValues;
if (displayType === "actionButton") {
return extractDisplayData(null, displayType, displayConfig) as Record<string, unknown>;
}
if (!fetchResult?.success || cachedJson == null) return null;
const extracted = extractDisplayData(cachedJson, displayType, displayConfig) as Record<string, unknown>;
return displayType === "customJsx" && fetchResult.previewSession
? {
...extracted,
previewSessionId: fetchResult.previewSession.id,
previewLiveActions: fetchResult.previewSession.liveActions,
}
: extracted;
}, [fetchResult, cachedJson, formValues, displayConfig]);
const namedRequests = useMemo(() => getPreviewNamedRequests(displayConfig), [displayConfig]);
const httpMethods = useMemo(() => {
if (formValues.displayType !== "customJsx") return [];
if (namedRequests.length > 0) return [...new Set(namedRequests.map((request) => request.method))];
return getPreviewLegacyMethods((displayConfig.template as string) ?? "");
}, [formValues.displayType, displayConfig.template, namedRequests]);
const hasNamedActions = namedRequests.some((request) => request.kind === "action");
const templateDiagnostics = useMemo(
() =>
formValues.displayType === "customJsx"
? analyzeJsxTemplate((displayConfig.template as string) ?? "", {
apiVersion: displayConfig.jsxApiVersion === 2 ? 2 : 1,
requestIds: namedRequests.map((request) => request.id),
})
: [],
[displayConfig, formValues.displayType, namedRequests],
);
return extractDisplayData(cachedJson, displayType, displayConfig) as Record<string, unknown>;
}, [fetchResult?.success, cachedJson, formValues]);
const handleTest = () => {
const values = getFormValues();
if (!values.url || values.method !== "GET") return;
if (!values.url) return;
onTest();
};
return (
<Card withBorder p="lg">
<Stack gap="md">
<PreviewHeader
method={formValues.method}
url={formValues.url}
methods={httpMethods}
hasNamedActions={hasNamedActions}
hasPreviewSession={Boolean(fetchResult?.previewSession)}
liveActions={fetchResult?.previewSession?.liveActions ?? false}
isUpdatingLiveActions={isUpdatingPreviewActions}
isTesting={isTesting}
isSampleStale={isSampleStale}
testError={testError}
responseError={fetchResult?.success === false ? fetchResult.error : undefined}
responseStatus={fetchResult?.success === false ? fetchResult.responseInfo : undefined}
onTest={handleTest}
onSetLiveActions={onSetPreviewLiveActions}
methodColor={methodColor}
messages={{
title: t("preview.title"),
interactive: t("preview.capabilities.interactive"),
capabilitiesTitle: t("preview.capabilities.title"),
capabilitiesDescription: t("preview.capabilities.description"),
liveActions: t("preview.capabilities.liveActions"),
liveActionsDescription: t("preview.capabilities.liveActionsDescription"),
runTestFirst: t("preview.capabilities.runTestFirst"),
simulated: t("preview.capabilities.simulated"),
test: t("preview.test"),
mutationDisabled: t("preview.mutationDisabled"),
staleTitle: t("preview.stale.title"),
staleDescription: t("preview.stale.description"),
}}
/>
<Card withBorder shadow="sm" p="md">
<Stack gap="sm">
<Group justify="space-between" align="center">
<Title order={5}>{t("preview.title")}</Title>
<Button
size="xs"
variant="light"
leftSection={isTesting ? <Loader size={14} /> : <IconPlayerPlay size={14} />}
onClick={handleTest}
loading={isTesting}
>
{t("preview.test")}
</Button>
</Group>
<Tabs defaultValue="widget" keepMounted={false}>
<Tabs.List grow>
<Tabs.Tab value="widget">{t("preview.tab.widget")}</Tabs.Tab>
<Tabs.Tab value="response">{t("preview.tab.response")}</Tabs.Tab>
<Tabs.Tab value="request">{t("preview.tab.request")}</Tabs.Tab>
<Tabs.Tab value="diagnostics">{t("preview.tab.diagnostics")}</Tabs.Tab>
</Tabs.List>
{testError && (
<Alert color="red" icon={<IconAlertTriangle size={16} />} p="xs">
<Text size="xs">{testError}</Text>
</Alert>
)}
<Tabs.Panel value="widget" pt="sm">
<PreviewWidgetPanel
data={displayData}
displayType={formValues.displayType}
success={fetchResult?.success ?? false}
responseInfo={fetchResult?.responseInfo ?? null}
previewSize={previewSize}
onPreviewSizeChange={setPreviewSize}
/>
</Tabs.Panel>
{fetchResult && !fetchResult.success && (
<Alert color="red" icon={<IconAlertTriangle size={16} />} p="xs">
<Text size="xs" fw={500}>
{fetchResult.error}
</Text>
{fetchResult.responseInfo && (
<Badge size="xs" color="red" variant="light" mt={4}>
{fetchResult.responseInfo.status} {fetchResult.responseInfo.statusText}
</Badge>
)}
</Alert>
)}
<Tabs.Panel value="response" pt="sm">
<PreviewResponsePanel
value={cachedJson}
rawResponse={fetchResult?.rawResponse}
onInsertDataPath={onInsertDataPath}
onSampleDataChange={onSampleDataChange}
messages={{
empty: t("preview.response.empty"),
sampleHint: t("preview.response.sampleHint"),
editSample: t("preview.response.editSample"),
addSample: t("preview.response.addSample"),
copied: t("preview.response.copied"),
copy: t("preview.response.copy"),
sampleLabel: t("preview.response.sampleLabel"),
sampleDescription: t("preview.response.sampleDescription"),
invalidSample: t("preview.response.invalidSample"),
cancelSample: t("preview.response.cancelSample"),
applySample: t("preview.response.applySample"),
copyPath: t("preview.response.copyPath"),
pathCopied: t("preview.response.pathCopied"),
insertPath: t("preview.response.insertPath"),
openRaw: t("preview.rawResponse"),
}}
/>
</Tabs.Panel>
{displayData && (formValues.displayType === "actionButton" || fetchResult?.success) && (
<Stack gap="xs">
{fetchResult?.success && (
<Group gap="xs">
<Badge size="xs" color="green" variant="light">
{fetchResult.responseInfo?.status} {fetchResult.responseInfo?.statusText}
</Badge>
</Group>
)}
<Tabs.Panel value="request" pt="sm">
<PreviewRequestPanel
method={formValues.method}
authentication={formValues.authType}
endpoint={redactPreviewUrl(formValues.url)}
status={fetchResult?.responseInfo?.status}
namedRequests={namedRequests}
journal={journalQuery.data}
methodColor={methodColor}
messages={{
method: t("preview.request.method"),
authentication: t("preview.request.authentication"),
endpoint: t("preview.request.endpoint"),
status: t("preview.request.status"),
notRun: t("preview.request.notRun"),
named: t("preview.request.named", { count: namedRequests.length }),
journal: t("preview.request.journal"),
journalEmpty: t("preview.request.journalEmpty"),
simulated: t("preview.request.simulated"),
redacted: t("preview.request.redacted"),
permission: (permission) => t(`preview.request.permission.${permission}` as never),
duration: (duration) => t("preview.request.duration", { duration }),
}}
/>
</Tabs.Panel>
<Card withBorder p="xs" bg="var(--mantine-color-dark-7)" mah={320} style={{ overflow: "auto" }}>
<PreviewDisplay data={displayData} />
</Card>
<Tabs.Panel value="diagnostics" pt="sm">
<PreviewDiagnosticsPanel
templateLength={(displayConfig.template as string | undefined)?.length ?? 0}
namedRequestCount={namedRequests.length}
methods={httpMethods.join(", ") || "GET"}
networkScope={(displayConfig.networkScope as string | undefined) ?? t("preview.diagnostics.legacy")}
diagnostics={templateDiagnostics}
messages={{
templateSize: t("preview.diagnostics.templateSize"),
characters: (count) => t("preview.diagnostics.characters", { count }),
namedRequests: t("preview.diagnostics.namedRequests"),
methods: t("preview.diagnostics.methods"),
networkScope: t("preview.diagnostics.networkScope"),
ready: t("preview.diagnostics.ready"),
diagnostic: (diagnostic) =>
`${diagnostic.line ? `${editorT("diagnostics.line", { line: diagnostic.line })}: ` : ""}${editorT(
`diagnostics.${diagnostic.code}` as never,
{ value: diagnostic.value ?? "" } as never,
)}`,
}}
/>
</Tabs.Panel>
</Tabs>
{fetchResult?.success && fetchResult.rawResponse && (
<Button
size="xs"
variant="subtle"
leftSection={<IconExternalLink size={14} />}
onClick={() => {
const blob = new Blob([fetchResult.rawResponse ?? ""], {
type: "application/json",
});
const blobUrl = URL.createObjectURL(blob);
window.open(blobUrl);
setTimeout(() => URL.revokeObjectURL(blobUrl), 60_000);
}}
>
{t("preview.rawResponse")}
</Button>
)}
</Stack>
)}
{!displayData && !testError && !isTesting && (
<Text size="xs" c="dimmed" ta="center">
{t("preview.hint")}
</Text>
)}
</Stack>
</Card>
);
}
function PreviewDisplay({ data }: { data: unknown }) {
const typed = data as Record<string, unknown>;
if (!typed || typeof typed !== "object") {
return (
<Text size="xs" c="dimmed">
No data
</Text>
);
}
const dataType = typed.type as string | undefined;
if (dataType === "actionButton") {
return (
<Center p="sm">
<Button size="sm" color={(typed.buttonColor as string) ?? "blue"} disabled>
{(typed.buttonLabel as string) ?? "Execute"}
</Button>
</Center>
);
}
const Component = dataType ? displayComponents[dataType] : undefined;
if (Component) {
return <Component data={typed} />;
}
return (
<ScrollArea h={100}>
<Code block style={{ fontSize: 11 }}>
{JSON.stringify(data, null, 2)}
</Code>
</ScrollArea>
);
}
@@ -1,39 +0,0 @@
export const listItemDefaults = {
mapping: { label: "", jsonPath: "$", unit: "" },
column: { header: "", jsonPath: "$" },
statGridItem: { label: "", jsonPath: "$", unit: "", color: "blue" },
progressBar: { label: "", valuePath: "$", maxPath: "", unit: "", color: "blue" },
statusItem: { label: "", jsonPath: "$", goodValues: "online,true" },
countGridItem: { label: "", jsonPath: "$", unit: "" },
} as const;
export function cloneLast<T extends Record<string, unknown>>(items: T[], fallback: T): T {
return { ...(items.at(-1) ?? fallback) };
}
export const MANTINE_COLORS = [
"blue",
"teal",
"green",
"red",
"orange",
"yellow",
"violet",
"pink",
"cyan",
"grape",
"indigo",
"lime",
] as const;
export const REQUEST_MANIFEST_STARTER = `[
{
"id": "details",
"kind": "query",
"method": "GET",
"pathTemplate": "/api/details/{id}",
"parameters": { "id": "string" },
"auth": "inherit",
"minimumBoardPermission": "view",
"cacheTtlSeconds": 30
}
]`;
@@ -1,12 +0,0 @@
import type { useZodForm } from "@homarr/form";
import type { useScopedI18n } from "@homarr/translation/client";
import type { customWidgetFormSchema } from "@homarr/custom-widgets/workbench";
export type CustomWidgetFormInstance = ReturnType<typeof useZodForm<typeof customWidgetFormSchema>>;
export type CustomWidgetTranslator = ReturnType<typeof useScopedI18n<"customWidget">>;
export interface DisplayFieldsProps {
form: CustomWidgetFormInstance;
t: CustomWidgetTranslator;
previewJson: unknown;
}
@@ -1,178 +0,0 @@
import { ActionIcon, Box, Button, Group, NumberInput, Select, Stack, Switch, Text, TextInput } from "@mantine/core";
import { IconPlus, IconTrash } from "@tabler/icons-react";
import { JsonPathTreePicker } from "@homarr/widgets/_inputs/json-path-tree-picker";
import { cloneLast, listItemDefaults } from "./_display-field-shared";
import type { DisplayFieldsProps } from "./_display-field-types";
export function BasicDisplayFields({ form, t, previewJson }: DisplayFieldsProps) {
const dt = form.values.displayType;
if (dt === "singleValue") {
return (
<>
<JsonPathTreePicker
json={previewJson}
label={t("field.jsonPath")}
description={t("field.jsonPathHint")}
required
placeholder={t("placeholder.jsonPath")}
{...form.getInputProps("jsonPath")}
/>
<TextInput
label={t("field.label")}
placeholder={t("placeholder.exampleLabel")}
{...form.getInputProps("label")}
/>
<TextInput label={t("field.unit")} placeholder={t("placeholder.exampleUnit")} {...form.getInputProps("unit")} />
<Group grow>
<Select
label={t("field.valueSize")}
data={["sm", "md", "lg", "xl"].map((v) => ({
value: v,
label: t(`sizeOption.${v}` as never),
}))}
{...form.getInputProps("valueSize")}
allowDeselect={false}
/>
<Select
label={t("field.labelPosition")}
data={["above", "below"].map((v) => ({
value: v,
label: t(`labelPositionOption.${v}` as never),
}))}
{...form.getInputProps("labelPosition")}
allowDeselect={false}
/>
</Group>
</>
);
}
if (dt === "keyValue") {
return (
<Stack gap="xs">
<Group grow>
<Select
label={t("field.kvLayout")}
data={["list", "grid"].map((v) => ({
value: v,
label: t(`layoutOption.${v}` as never),
}))}
{...form.getInputProps("kvLayout")}
allowDeselect={false}
/>
{form.values.kvLayout === "grid" && (
<NumberInput label={t("field.gridColumns")} min={1} max={3} {...form.getInputProps("kvColumns")} />
)}
</Group>
<Text size="sm" fw={500}>
{t("field.mappings")}
</Text>
{form.values.mappings.map((_m, i) => (
<Group key={i} align="end" wrap="nowrap">
<TextInput
label={t("field.label")}
placeholder={t("placeholder.exampleLabelShort")}
style={{ flex: 1 }}
{...form.getInputProps(`mappings.${i}.label`)}
/>
<Box style={{ flex: 1 }}>
<JsonPathTreePicker
json={previewJson}
label={t("field.jsonPath")}
placeholder={t("placeholder.jsonPath")}
{...form.getInputProps(`mappings.${i}.jsonPath`)}
/>
</Box>
<TextInput
label={t("field.unit")}
placeholder={t("placeholder.exampleUnit")}
style={{ width: 80 }}
{...form.getInputProps(`mappings.${i}.unit`)}
/>
<ActionIcon
type="button"
color="red"
variant="subtle"
onClick={() => form.removeListItem("mappings", i)}
disabled={form.values.mappings.length <= 1}
aria-label={t("action.removeMapping")}
>
<IconTrash size={16} />
</ActionIcon>
</Group>
))}
<Button
type="button"
variant="light"
size="xs"
leftSection={<IconPlus size={14} />}
onClick={() => form.insertListItem("mappings", cloneLast(form.values.mappings, listItemDefaults.mapping))}
>
{t("action.addMapping")}
</Button>
</Stack>
);
}
if (dt === "table") {
return (
<Stack gap="xs">
<JsonPathTreePicker
json={previewJson}
label={t("field.tablePath")}
description={t("field.tablePathHint")}
required
placeholder={t("placeholder.tablePath")}
{...form.getInputProps("tablePath")}
/>
<Group grow>
<Switch label={t("field.striped")} {...form.getInputProps("striped", { type: "checkbox" })} />
<Switch label={t("field.compact")} {...form.getInputProps("compact", { type: "checkbox" })} />
</Group>
<Text size="sm" fw={500}>
{t("field.columns")}
</Text>
{form.values.columns.map((_col, i) => (
<Group key={i} align="end" wrap="nowrap">
<TextInput
label={t("field.header")}
placeholder={t("placeholder.exampleHeader")}
style={{ flex: 1 }}
{...form.getInputProps(`columns.${i}.header`)}
/>
<Box style={{ flex: 1 }}>
<JsonPathTreePicker
json={previewJson}
label={t("field.jsonPath")}
placeholder={t("placeholder.columnJsonPath")}
{...form.getInputProps(`columns.${i}.jsonPath`)}
/>
</Box>
<ActionIcon
type="button"
color="red"
variant="subtle"
onClick={() => form.removeListItem("columns", i)}
disabled={form.values.columns.length <= 1}
aria-label={t("action.removeColumn")}
>
<IconTrash size={16} />
</ActionIcon>
</Group>
))}
<Button
type="button"
variant="light"
size="xs"
leftSection={<IconPlus size={14} />}
onClick={() => form.insertListItem("columns", cloneLast(form.values.columns, listItemDefaults.column))}
>
{t("action.addColumn")}
</Button>
</Stack>
);
}
return null;
}
@@ -1,97 +0,0 @@
import { Alert, SegmentedControl, Select, Stack, Text } from "@mantine/core";
import { IconAlertTriangle } from "@tabler/icons-react";
import { CUSTOM_JSX_STARTER } from "@homarr/custom-widgets/core";
import {
analyzeJsxTemplate,
analyzeRequestManifest,
CUSTOM_JSX_TEMPLATE_LIMIT,
parseRequestManifest,
} from "@homarr/custom-widgets/workbench";
import { CodeEditor } from "./_code-editor";
import { REQUEST_MANIFEST_STARTER } from "./_display-field-shared";
import type { DisplayFieldsProps } from "./_display-field-types";
export function CustomJsxDisplayFields({ form, t }: DisplayFieldsProps) {
const dt = form.values.displayType;
if (dt === "customJsx") {
const parsedRequests = parseRequestManifest(form.values.requestManifest);
const requestIds = parsedRequests
.map((request) => (request && typeof request === "object" && "id" in request ? request.id : null))
.filter((id): id is string => typeof id === "string");
const templateDiagnostics = analyzeJsxTemplate(form.values.template, {
apiVersion: form.values.jsxApiVersion === "2" ? 2 : 1,
requestIds,
});
const manifestDiagnostics =
form.values.jsxApiVersion === "2" ? analyzeRequestManifest(form.values.requestManifest) : [];
return (
<Stack gap="md">
<div>
<Text size="sm" fw={500} mb={4}>
{t("field.jsxApiVersion.label")}
</Text>
<SegmentedControl
fullWidth
value={form.values.jsxApiVersion}
onChange={(value) => form.setFieldValue("jsxApiVersion", value)}
data={[
{ value: "2", label: t("field.jsxApiVersion.v2") },
{ value: "1", label: t("field.jsxApiVersion.v1") },
]}
/>
</div>
{form.values.jsxApiVersion === "1" ? (
<Alert color="yellow" variant="light" icon={<IconAlertTriangle size={16} />}>
<Text size="sm">{t("field.jsxApiVersion.legacyWarning")}</Text>
</Alert>
) : (
<Stack gap="sm">
<Select
label={t("field.networkScope.label")}
description={t(`field.networkScope.description.${form.values.networkScope}` as never)}
value={form.values.networkScope}
onChange={(value) => form.setFieldValue("networkScope", value ?? "public")}
data={(["public", "private", "loopback"] as const).map((value) => ({
value,
label: t(`field.networkScope.option.${value}` as never),
}))}
allowDeselect={false}
/>
<CodeEditor
id="custom-widget-request-manifest"
language="json"
label={t("field.requestManifest.label")}
description={t("field.requestManifest.description")}
value={form.values.requestManifest}
onChange={(value) => form.setFieldValue("requestManifest", value)}
placeholder={REQUEST_MANIFEST_STARTER}
starter={REQUEST_MANIFEST_STARTER}
diagnostics={manifestDiagnostics}
error={form.errors.requestManifest ? t("field.requestManifest.invalid") : undefined}
/>
</Stack>
)}
<CodeEditor
id="custom-widget-jsx-template"
language="jsx"
label={t("field.template.label")}
description={t("field.template.description")}
value={form.values.template}
onChange={(value) => form.setFieldValue("template", value)}
placeholder={CUSTOM_JSX_STARTER}
starter={CUSTOM_JSX_STARTER}
diagnostics={templateDiagnostics}
error={form.errors.template}
required
maxLength={CUSTOM_JSX_TEMPLATE_LIMIT}
/>
</Stack>
);
}
return null;
}
@@ -1,167 +0,0 @@
import { ActionIcon, Box, Button, Group, Select, Stack, Switch, Text, TextInput, NumberInput } from "@mantine/core";
import { IconPlus, IconTrash } from "@tabler/icons-react";
import { JsonPathTreePicker } from "@homarr/widgets/_inputs/json-path-tree-picker";
import { cloneLast, listItemDefaults, MANTINE_COLORS } from "./_display-field-shared";
import type { DisplayFieldsProps } from "./_display-field-types";
export function MetricDisplayFields({ form, t, previewJson }: DisplayFieldsProps) {
const dt = form.values.displayType;
if (dt === "statGrid") {
return (
<Stack gap="xs">
<Group grow>
<NumberInput label={t("field.gridColumns")} min={1} max={4} {...form.getInputProps("statGridColumns")} />
<Select
label={t("field.cardStyle")}
data={["filled", "outline", "subtle"].map((v) => ({
value: v,
label: t(`cardStyleOption.${v}` as never),
}))}
{...form.getInputProps("cardStyle")}
allowDeselect={false}
/>
</Group>
<Text size="sm" fw={500}>
{t("field.items")}
</Text>
{form.values.statGridItems.map((_item, i) => (
<Group key={i} align="end" wrap="nowrap">
<TextInput
label={t("field.label")}
placeholder={t("placeholder.exampleLabelShort")}
style={{ flex: 1 }}
{...form.getInputProps(`statGridItems.${i}.label`)}
/>
<Box style={{ flex: 1 }}>
<JsonPathTreePicker
json={previewJson}
label={t("field.jsonPath")}
placeholder={t("placeholder.jsonPath")}
{...form.getInputProps(`statGridItems.${i}.jsonPath`)}
/>
</Box>
<TextInput
label={t("field.unit")}
placeholder={t("placeholder.exampleUnit")}
style={{ width: 80 }}
{...form.getInputProps(`statGridItems.${i}.unit`)}
/>
<Select
label={t("field.color")}
data={MANTINE_COLORS.map((c) => ({ value: c, label: c }))}
style={{ width: 100 }}
{...form.getInputProps(`statGridItems.${i}.color`)}
allowDeselect={false}
/>
<ActionIcon
type="button"
color="red"
variant="subtle"
onClick={() => form.removeListItem("statGridItems", i)}
disabled={form.values.statGridItems.length <= 1}
aria-label={t("action.removeItem")}
>
<IconTrash size={16} />
</ActionIcon>
</Group>
))}
<Button
type="button"
variant="light"
size="xs"
leftSection={<IconPlus size={14} />}
onClick={() =>
form.insertListItem("statGridItems", cloneLast(form.values.statGridItems, listItemDefaults.statGridItem))
}
>
{t("action.addItem")}
</Button>
</Stack>
);
}
if (dt === "progressBars") {
return (
<Stack gap="xs">
<Group grow>
<Switch label={t("field.showPercentage")} {...form.getInputProps("showPercentage", { type: "checkbox" })} />
<Select
label={t("field.barSize")}
data={["sm", "md", "lg"].map((v) => ({
value: v,
label: t(`sizeOption.${v}` as never),
}))}
{...form.getInputProps("barSize")}
allowDeselect={false}
/>
</Group>
<Text size="sm" fw={500}>
{t("field.bars")}
</Text>
{form.values.progressBars.map((_bar, i) => (
<Group key={i} align="end" wrap="nowrap">
<TextInput
label={t("field.label")}
placeholder={t("placeholder.exampleLabelShort")}
style={{ flex: 1 }}
{...form.getInputProps(`progressBars.${i}.label`)}
/>
<Box style={{ flex: 1 }}>
<JsonPathTreePicker
json={previewJson}
label={t("field.valuePath")}
placeholder={t("placeholder.valuePath")}
{...form.getInputProps(`progressBars.${i}.valuePath`)}
/>
</Box>
<Box style={{ flex: 1 }}>
<JsonPathTreePicker
json={previewJson}
label={t("field.maxPath")}
placeholder={t("placeholder.maxPath")}
{...form.getInputProps(`progressBars.${i}.maxPath`)}
/>
</Box>
<TextInput
label={t("field.unit")}
placeholder={t("placeholder.exampleUnit")}
style={{ width: 60 }}
{...form.getInputProps(`progressBars.${i}.unit`)}
/>
<Select
label={t("field.color")}
data={MANTINE_COLORS.map((c) => ({ value: c, label: c }))}
style={{ width: 100 }}
{...form.getInputProps(`progressBars.${i}.color`)}
allowDeselect={false}
/>
<ActionIcon
type="button"
color="red"
variant="subtle"
onClick={() => form.removeListItem("progressBars", i)}
disabled={form.values.progressBars.length <= 1}
aria-label={t("action.removeItem")}
>
<IconTrash size={16} />
</ActionIcon>
</Group>
))}
<Button
type="button"
variant="light"
size="xs"
leftSection={<IconPlus size={14} />}
onClick={() =>
form.insertListItem("progressBars", cloneLast(form.values.progressBars, listItemDefaults.progressBar))
}
>
{t("action.addBar")}
</Button>
</Stack>
);
}
return null;
}
@@ -1,198 +0,0 @@
import { ActionIcon, Box, Button, Group, NumberInput, Select, Stack, Text, TextInput } from "@mantine/core";
import { IconPlus, IconTrash } from "@tabler/icons-react";
import { JsonPathTreePicker } from "@homarr/widgets/_inputs/json-path-tree-picker";
import { cloneLast, listItemDefaults, MANTINE_COLORS } from "./_display-field-shared";
import type { DisplayFieldsProps } from "./_display-field-types";
export function StatusDisplayFields({ form, t, previewJson }: DisplayFieldsProps) {
const dt = form.values.displayType;
if (dt === "statusIndicator") {
return (
<Stack gap="xs">
<Group grow>
<Select
label={t("field.kvLayout")}
data={["list", "grid"].map((v) => ({
value: v,
label: t(`layoutOption.${v}` as never),
}))}
{...form.getInputProps("statusLayout")}
allowDeselect={false}
/>
<Select
label={t("field.dotSize")}
data={["sm", "md", "lg"].map((v) => ({
value: v,
label: t(`sizeOption.${v}` as never),
}))}
{...form.getInputProps("dotSize")}
allowDeselect={false}
/>
</Group>
<Text size="sm" fw={500}>
{t("field.items")}
</Text>
{form.values.statusItems.map((_item, i) => (
<Group key={i} align="end" wrap="nowrap">
<TextInput
label={t("field.label")}
placeholder={t("placeholder.exampleLabelShort")}
style={{ flex: 1 }}
{...form.getInputProps(`statusItems.${i}.label`)}
/>
<Box style={{ flex: 1 }}>
<JsonPathTreePicker
json={previewJson}
label={t("field.jsonPath")}
placeholder={t("placeholder.jsonPath")}
{...form.getInputProps(`statusItems.${i}.jsonPath`)}
/>
</Box>
<TextInput
label={t("field.goodValues")}
placeholder={t("placeholder.goodValues")}
style={{ flex: 1 }}
{...form.getInputProps(`statusItems.${i}.goodValues`)}
/>
<ActionIcon
type="button"
color="red"
variant="subtle"
onClick={() => form.removeListItem("statusItems", i)}
disabled={form.values.statusItems.length <= 1}
aria-label={t("action.removeItem")}
>
<IconTrash size={16} />
</ActionIcon>
</Group>
))}
<Button
type="button"
variant="light"
size="xs"
leftSection={<IconPlus size={14} />}
onClick={() =>
form.insertListItem("statusItems", cloneLast(form.values.statusItems, listItemDefaults.statusItem))
}
>
{t("action.addItem")}
</Button>
</Stack>
);
}
if (dt === "countGrid") {
return (
<Stack gap="xs">
<Group grow>
<NumberInput label={t("field.gridColumns")} min={2} max={4} {...form.getInputProps("countGridColumns")} />
<Select
label={t("field.valueSize")}
data={["sm", "md", "lg"].map((v) => ({
value: v,
label: t(`sizeOption.${v}` as never),
}))}
{...form.getInputProps("countValueSize")}
allowDeselect={false}
/>
</Group>
<Text size="sm" fw={500}>
{t("field.items")}
</Text>
{form.values.countGridItems.map((_item, i) => (
<Group key={i} align="end" wrap="nowrap">
<TextInput
label={t("field.label")}
placeholder={t("placeholder.exampleLabelShort")}
style={{ flex: 1 }}
{...form.getInputProps(`countGridItems.${i}.label`)}
/>
<Box style={{ flex: 1 }}>
<JsonPathTreePicker
json={previewJson}
label={t("field.jsonPath")}
placeholder={t("placeholder.jsonPath")}
{...form.getInputProps(`countGridItems.${i}.jsonPath`)}
/>
</Box>
<TextInput
label={t("field.unit")}
placeholder={t("placeholder.exampleUnit")}
style={{ width: 80 }}
{...form.getInputProps(`countGridItems.${i}.unit`)}
/>
<ActionIcon
type="button"
color="red"
variant="subtle"
onClick={() => form.removeListItem("countGridItems", i)}
disabled={form.values.countGridItems.length <= 1}
aria-label={t("action.removeItem")}
>
<IconTrash size={16} />
</ActionIcon>
</Group>
))}
<Button
type="button"
variant="light"
size="xs"
leftSection={<IconPlus size={14} />}
onClick={() =>
form.insertListItem("countGridItems", cloneLast(form.values.countGridItems, listItemDefaults.countGridItem))
}
>
{t("action.addItem")}
</Button>
</Stack>
);
}
if (dt === "raw") {
return (
<>
<JsonPathTreePicker
json={previewJson}
label={t("field.jsonPath")}
description={t("field.jsonPathHint")}
placeholder={t("placeholder.jsonPath")}
{...form.getInputProps("rawJsonPath")}
/>
<NumberInput
label={t("field.maxHeight")}
min={50}
max={1000}
step={50}
{...form.getInputProps("rawMaxHeight")}
/>
</>
);
}
if (dt === "actionButton") {
return (
<Stack gap="sm">
<TextInput label={t("field.buttonLabel")} required {...form.getInputProps("buttonLabel")} />
<Select
label={t("field.buttonColor")}
data={MANTINE_COLORS.map((c) => ({ value: c, label: c }))}
{...form.getInputProps("buttonColor")}
allowDeselect={false}
/>
<TextInput
label={t("field.confirmText")}
description={t("field.confirmTextHint")}
{...form.getInputProps("confirmText")}
/>
<TextInput
label={t("field.successMessage")}
description={t("field.successMessageHint")}
{...form.getInputProps("successMessage")}
/>
</Stack>
);
}
return null;
}
@@ -1,89 +0,0 @@
import { SimpleGrid, Text, ThemeIcon, UnstyledButton } from "@mantine/core";
import {
Icon123,
IconActivityHeartbeat,
IconBraces,
IconCode,
IconHash,
IconLayoutGrid,
IconListDetails,
IconPointer,
IconProgress,
IconTable,
} from "@tabler/icons-react";
import type { useScopedI18n } from "@homarr/translation/client";
import type { CustomWidgetDisplayType } from "@homarr/custom-widgets/core";
import { customWidgetDisplayDescriptors } from "@homarr/custom-widgets/core";
import { BasicDisplayFields } from "./_display-fields-basic";
import { CustomJsxDisplayFields } from "./_display-fields-jsx";
import { MetricDisplayFields } from "./_display-fields-metric";
import { StatusDisplayFields } from "./_display-fields-status";
import type { DisplayFieldsProps } from "./_display-field-types";
import classes from "./_custom-widget-form.module.css";
export const DISPLAY_TYPE_ICONS = {
number: Icon123,
keyValue: IconListDetails,
table: IconTable,
grid: IconLayoutGrid,
progress: IconProgress,
status: IconActivityHeartbeat,
count: IconHash,
json: IconBraces,
action: IconPointer,
code: IconCode,
} as const;
export function DisplayTypePicker({
value,
onChange,
t,
}: {
value: string;
onChange: (value: CustomWidgetDisplayType) => void;
t: ReturnType<typeof useScopedI18n<"customWidget">>;
}) {
return (
<SimpleGrid cols={{ base: 1, xs: 2 }} spacing="sm">
{customWidgetDisplayDescriptors.map((descriptor) => {
const displayType = descriptor.type;
const Icon = DISPLAY_TYPE_ICONS[descriptor.icon];
const selected = value === displayType;
return (
<UnstyledButton
type="button"
key={displayType}
className={classes.displayTypeOption}
data-selected={selected || undefined}
onClick={() => onChange(displayType)}
aria-pressed={selected}
>
<ThemeIcon variant={selected ? "filled" : "light"} size={42} radius="md">
<Icon size={22} />
</ThemeIcon>
<div className={classes.displayTypeCopy}>
<Text fw={600} size="sm">
{t(`displayType.${displayType}` as never)}
</Text>
<Text size="xs" c="dimmed" lineClamp={3}>
{t(`displayTypeDescription.${displayType}` as never)}
</Text>
</div>
</UnstyledButton>
);
})}
</SimpleGrid>
);
}
export function DisplayTypeFields(props: DisplayFieldsProps) {
return (
<>
<BasicDisplayFields {...props} />
<MetricDisplayFields {...props} />
<StatusDisplayFields {...props} />
<CustomJsxDisplayFields {...props} />
</>
);
}
@@ -1,99 +0,0 @@
"use client";
import { useMemo } from "react";
import { Badge, Button, Center, Code, Paper, ScrollArea, SegmentedControl, Stack, Text } from "@mantine/core";
import { IconPlayerPlay } from "@tabler/icons-react";
import { useScopedI18n } from "@homarr/translation/client";
import { createDisplayComponents } from "@homarr/widgets/custom-api/component";
interface PreviewWidgetPanelProps {
data: Record<string, unknown> | null;
displayType: string;
success: boolean;
responseInfo: { status: number; statusText: string } | null;
previewSize: string;
onPreviewSizeChange: (value: string) => void;
}
const HEIGHTS: Record<string, number> = { compact: 240, standard: 360, wide: 520 };
export function PreviewWidgetPanel({
data,
displayType,
success,
responseInfo,
previewSize,
onPreviewSizeChange,
}: PreviewWidgetPanelProps) {
const t = useScopedI18n("customWidget");
const height = HEIGHTS[previewSize] ?? HEIGHTS.standard;
return (
<Stack gap="xs">
<SegmentedControl
size="xs"
fullWidth
value={previewSize}
onChange={onPreviewSizeChange}
data={(["compact", "standard", "wide"] as const).map((value) => ({
value,
label: t(`preview.size.${value}` as never),
}))}
/>
{data && (displayType === "actionButton" || success) ? (
<>
{success && (
<Badge
size="xs"
color={responseInfo ? "green" : "gray"}
variant="light"
style={{ alignSelf: "flex-start" }}
>
{responseInfo ? `${responseInfo.status} ${responseInfo.statusText}` : t("preview.response.localSample")}
</Badge>
)}
<Paper withBorder p="xs" h={height} style={{ overflow: "auto" }}>
<PreviewDisplay data={data} />
</Paper>
</>
) : (
<Center h={height}>
<Stack gap={4} align="center" maw={280}>
<IconPlayerPlay size={22} color="var(--mantine-color-dimmed)" />
<Text size="sm" fw={500} ta="center">
{t("preview.empty.title")}
</Text>
<Text size="xs" c="dimmed" ta="center">
{t("preview.hint")}
</Text>
</Stack>
</Center>
)}
</Stack>
);
}
function PreviewDisplay({ data }: { data: Record<string, unknown> }) {
const t = useScopedI18n("customWidget");
const widgetT = useScopedI18n("widget.customApi");
const displayComponents = useMemo(() => createDisplayComponents(widgetT("openJson")), [widgetT]);
const dataType = data.type as string | undefined;
if (dataType === "actionButton") {
return (
<Center p="sm" h="100%">
<Button type="button" size="sm" color={(data.buttonColor as string) ?? "blue"} disabled>
{(data.buttonLabel as string) ?? t("preview.execute")}
</Button>
</Center>
);
}
const Component = dataType ? displayComponents[dataType] : undefined;
if (Component) return <Component data={data} />;
return (
<ScrollArea h="100%">
<Code block style={{ fontSize: 11 }}>
{JSON.stringify(data, null, 2)}
</Code>
</ScrollArea>
);
}
@@ -1,87 +0,0 @@
"use client";
import { useCallback, useEffect } from "react";
import { Alert, Badge, Group, Stack, Text } from "@mantine/core";
import { IconAlertTriangle } from "@tabler/icons-react";
import { buildDisplayFormValues, getImportReview, parseCustomWidgetClipboard } from "@homarr/custom-widgets/core";
import { CUSTOM_WIDGET_AUTH_SECRET_FIELDS } from "@homarr/custom-widgets/workbench";
import { useConfirmModal } from "@homarr/modals";
import { showSuccessNotification } from "@homarr/notifications";
import type { CustomWidgetFormInstance, CustomWidgetTranslator } from "./_display-field-types";
interface UseCustomWidgetClipboardOptions {
enabled: boolean;
form: CustomWidgetFormInstance;
t: CustomWidgetTranslator;
onReplace: () => void;
}
export function useCustomWidgetClipboard({ enabled, form, t, onReplace }: UseCustomWidgetClipboardOptions) {
const { openConfirmModal } = useConfirmModal();
const queueReplacement = useCallback(
(widget: Record<string, unknown>) => {
const review = getImportReview(widget);
if (!review) return;
openConfirmModal({
title: t("importReview.replaceTitle"),
children: (
<Stack gap="sm">
<Text size="sm">{t("importReview.replaceDescription", { name: review.name, origin: review.origin })}</Text>
<Group gap={6}>
{review.methods.map((method) => (
<Badge key={method} color={{ DELETE: "red", GET: "blue" }[method] ?? "orange"}>
{method}
</Badge>
))}
</Group>
{review.hasActions && (
<Alert color="yellow" icon={<IconAlertTriangle size={16} />}>
<Text size="sm">{t("importReview.actionWarning.description")}</Text>
</Alert>
)}
</Stack>
),
labels: { confirm: t("importReview.replaceConfirm"), cancel: t("importReview.cancel") },
onConfirm: () => {
const displayType = String(widget.displayType);
const displayConfig = widget.displayConfig as Record<string, unknown>;
const authType = typeof widget.authType === "string" ? widget.authType : "none";
const secrets = (CUSTOM_WIDGET_AUTH_SECRET_FIELDS[authType] ?? []).map((field) => {
const existing = form.values.secrets.find((secret) => secret.kind === field.kind);
return { kind: field.kind, value: existing?.value ?? "", hasValue: existing?.hasValue ?? false };
});
form.setValues({
...form.values,
name: String(widget.name),
description: typeof widget.description === "string" ? widget.description : "",
iconUrl: typeof widget.iconUrl === "string" ? widget.iconUrl : "",
url: String(widget.url),
authType,
headerName: typeof widget.headerName === "string" ? widget.headerName : "",
method: typeof widget.method === "string" ? widget.method : "GET",
requestBody: typeof widget.requestBody === "string" ? widget.requestBody : "",
...buildDisplayFormValues(displayType, displayConfig),
secrets,
});
onReplace();
showSuccessNotification({ title: t("action.import"), message: t("notification.pastedForReplacement") });
},
});
},
[form, onReplace, openConfirmModal, t],
);
useEffect(() => {
if (!enabled) return;
const handlePaste = (event: ClipboardEvent) => {
const widget = parseCustomWidgetClipboard(event.clipboardData?.getData("text/plain") ?? "");
if (!widget) return;
event.preventDefault();
queueReplacement(widget);
};
window.addEventListener("paste", handlePaste);
return () => window.removeEventListener("paste", handlePaste);
}, [enabled, queueReplacement]);
}
@@ -1,156 +0,0 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import { clientApi } from "@homarr/api/client";
import type {
CustomWidgetAuthType,
CustomWidgetDisplayType,
CustomWidgetMethod,
CustomWidgetSecretKind,
DisplayConfig,
} from "@homarr/custom-widgets/core";
import { buildDisplayConfigFromFormValues } from "@homarr/custom-widgets/core";
import { showErrorNotification } from "@homarr/notifications";
import type { CustomWidgetFormInstance, CustomWidgetTranslator } from "./_display-field-types";
import type { PreviewFetchResult, PreviewInput } from "./_custom-widget-preview";
interface UseCustomWidgetPreviewOptions {
form: CustomWidgetFormInstance;
definitionId?: string;
refreshSignal: number;
onOpenPreview: () => void;
t: CustomWidgetTranslator;
}
export function useCustomWidgetPreview({
form,
definitionId,
refreshSignal,
onOpenPreview,
t,
}: UseCustomWidgetPreviewOptions) {
const previewMutation = clientApi.customWidget.preview.useMutation();
const liveActionsMutation = clientApi.customWidget.setPreviewLiveActions.useMutation();
const [json, setJson] = useState<unknown>(null);
const [fetchResult, setFetchResult] = useState<PreviewFetchResult | null>(null);
const [lastFingerprint, setLastFingerprint] = useState<string | null>(null);
const hasTestedRef = useRef(false);
const getInput = useCallback((): PreviewInput => {
const values = form.values;
return {
url: values.url,
method: values.method as CustomWidgetMethod,
authType: values.authType as CustomWidgetAuthType,
headerName: values.headerName || undefined,
requestBody: values.requestBody || undefined,
displayType: values.displayType as CustomWidgetDisplayType,
displayConfig: buildDisplayConfigFromFormValues(values) as DisplayConfig,
secrets: values.secrets
.filter((secret) => secret.value)
.map((secret) => ({ kind: secret.kind as CustomWidgetSecretKind, value: secret.value })),
definitionId,
};
}, [definitionId, form.values]);
const test = useCallback(async () => {
const input = getInput();
if (!input.url || input.method !== "GET") return;
onOpenPreview();
setLastFingerprint(JSON.stringify(input));
try {
const result = await previewMutation.mutateAsync(input);
hasTestedRef.current = true;
setFetchResult({
success: result.success,
error: result.success ? undefined : result.error,
responseInfo: result.responseInfo,
rawResponse: result.rawResponse,
previewSession: result.previewSession,
});
if (!result.success || !result.rawResponse) return setJson(null);
try {
setJson(JSON.parse(result.rawResponse));
} catch {
setJson(null);
}
} catch {
setFetchResult({ success: false, error: t("notification.previewError"), responseInfo: null, rawResponse: null });
setJson(null);
}
}, [getInput, onOpenPreview, previewMutation, t]);
const setLiveActions = useCallback(
async (enabled: boolean) => {
const sessionId = fetchResult?.previewSession?.id;
if (!sessionId) return;
try {
const previewSession = await liveActionsMutation.mutateAsync({ sessionId, enabled });
setFetchResult((current) => (current ? { ...current, previewSession } : current));
} catch {
showErrorNotification({
title: t("preview.capabilities.liveActions"),
message: t("notification.previewActionsError"),
});
}
},
[fetchResult?.previewSession?.id, liveActionsMutation, t],
);
const testRef = useRef(test);
testRef.current = test;
const getInputRef = useRef(getInput);
getInputRef.current = getInput;
const pendingRefreshRef = useRef(false);
useEffect(() => {
if (refreshSignal <= 0 || !hasTestedRef.current) return;
if (previewMutation.isPending) return void (pendingRefreshRef.current = true);
if (getInputRef.current().method === "GET") void testRef.current();
// eslint-disable-next-line react-hooks/exhaustive-deps -- refreshSignal is the explicit event source
}, [refreshSignal]);
useEffect(() => {
if (previewMutation.isPending || !pendingRefreshRef.current) return;
pendingRefreshRef.current = false;
if (getInputRef.current().method === "GET" && hasTestedRef.current) void testRef.current();
}, [previewMutation.isPending]);
const input = getInput();
const isStale = lastFingerprint !== null && lastFingerprint !== JSON.stringify(input);
const insertDataPath = useCallback(
(path: string) => {
if (form.values.displayType !== "customJsx") return;
const template = form.values.template.trimEnd();
const snippet = `<Text>{${path}}</Text>`;
form.setFieldValue("template", template ? `${template}\n${snippet}` : snippet);
},
[form],
);
const setSampleData = useCallback((value: unknown) => {
setJson(value);
setFetchResult({ success: true, responseInfo: null, rawResponse: JSON.stringify(value, null, 2) });
setLastFingerprint(null);
}, []);
const reset = useCallback(() => {
setJson(null);
setFetchResult(null);
setLastFingerprint(null);
}, []);
return {
input,
getInput,
json,
fetchResult,
isStale,
test,
setLiveActions,
insertDataPath,
setSampleData,
reset,
isTesting: previewMutation.isPending,
testError: previewMutation.error,
isUpdatingLiveActions: liveActionsMutation.isPending,
};
}
@@ -3,7 +3,6 @@ import { Container, Stack, Title } from "@mantine/core";
import { api } from "@homarr/api/server";
import { auth } from "@homarr/auth/next";
import { buildDisplayFormValues } from "@homarr/custom-widgets/core";
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
import { catchTrpcNotFound } from "~/errors/trpc-catch-error";
@@ -29,6 +28,122 @@ function buildInitialSecrets(authType: string, dbSecrets: Array<{ kind: string;
return result;
}
function buildDisplayInitialValues(displayType: string, config: Record<string, unknown>) {
const base = {
displayType,
jsonPath: "$",
label: "",
unit: "",
valueSize: "lg",
labelPosition: "below",
mappings: [{ label: "", jsonPath: "$", unit: "" }],
kvLayout: "list",
kvColumns: 2,
tablePath: "$",
columns: [{ header: "", jsonPath: "$" }],
striped: true,
compact: false,
statGridItems: [{ label: "", jsonPath: "$", unit: "", color: "blue" }],
statGridColumns: 2,
cardStyle: "filled",
progressBars: [{ label: "", valuePath: "$", maxPath: "", unit: "", color: "blue" }],
showPercentage: true,
barSize: "md",
statusItems: [{ label: "", jsonPath: "$", goodValues: "online,true" }],
statusLayout: "list",
dotSize: "md",
countGridItems: [{ label: "", jsonPath: "$", unit: "" }],
countGridColumns: 2,
countValueSize: "md",
rawJsonPath: "$",
rawMaxHeight: 300,
buttonLabel: "Execute",
buttonColor: "blue",
confirmText: "",
successMessage: "",
template: "",
};
const typeOverrides: Record<string, () => Record<string, unknown>> = {
singleValue: () => ({
jsonPath: (config.jsonPath as string) ?? "$",
label: (config.label as string) ?? "",
unit: (config.unit as string) ?? "",
valueSize: (config.valueSize as string) ?? "lg",
labelPosition: (config.labelPosition as string) ?? "below",
}),
keyValue: () => ({
mappings: (config.mappings as Array<{ label: string; jsonPath: string; unit: string }>) ?? base.mappings,
kvLayout: (config.layout as string) ?? "list",
kvColumns: (config.columns as number) ?? 2,
}),
table: () => ({
tablePath: (config.tablePath as string) ?? "$",
columns: (config.columns as Array<{ header: string; jsonPath: string }>) ?? base.columns,
striped: (config.striped as boolean) ?? true,
compact: (config.compact as boolean) ?? false,
}),
statGrid: () => ({
statGridItems: (
(config.items as Array<{ label: string; jsonPath: string; unit: string; color?: string }>) ?? []
).map((item) => ({
label: item.label,
jsonPath: item.jsonPath,
unit: item.unit,
color: item.color ?? "blue",
})),
statGridColumns: (config.columns as number) ?? 2,
cardStyle: (config.cardStyle as string) ?? "filled",
}),
progressBars: () => ({
progressBars: (
(config.bars as Array<{ label: string; valuePath: string; maxPath?: string; unit: string; color?: string }>) ??
[]
).map((bar) => ({
label: bar.label,
valuePath: bar.valuePath,
maxPath: bar.maxPath ?? "",
unit: bar.unit,
color: bar.color ?? "blue",
})),
showPercentage: (config.showPercentage as boolean) ?? true,
barSize: (config.barSize as string) ?? "md",
}),
statusIndicator: () => ({
statusItems: ((config.items as Array<{ label: string; jsonPath: string; goodValues: string[] }>) ?? []).map(
(item) => ({
label: item.label,
jsonPath: item.jsonPath,
goodValues: item.goodValues.join(", "),
}),
),
statusLayout: (config.layout as string) ?? "list",
dotSize: (config.dotSize as string) ?? "md",
}),
countGrid: () => ({
countGridItems: (config.items as Array<{ label: string; jsonPath: string; unit: string }>) ?? base.countGridItems,
countGridColumns: (config.columns as number) ?? 2,
countValueSize: (config.valueSize as string) ?? "md",
}),
raw: () => ({
rawJsonPath: (config.jsonPath as string) ?? "$",
rawMaxHeight: (config.maxHeight as number) ?? 300,
}),
actionButton: () => ({
buttonLabel: (config.buttonLabel as string) ?? "Execute",
buttonColor: (config.buttonColor as string) ?? "blue",
confirmText: (config.confirmText as string) ?? "",
successMessage: (config.successMessage as string) ?? "",
}),
customJsx: () => ({
template: (config.template as string) ?? "",
}),
};
const overrides = typeOverrides[displayType]?.() ?? {};
return { ...base, ...overrides };
}
interface EditCustomWidgetPageProps {
params: Promise<{ id: string }>;
}
@@ -43,7 +158,7 @@ export default async function EditCustomWidgetPage(props: EditCustomWidgetPagePr
const definition = await api.customWidget.byId({ id: params.id }).catch(catchTrpcNotFound);
const displayConfig = definition.displayConfig as Record<string, unknown>;
const displayValues = buildDisplayFormValues(definition.displayType, displayConfig);
const displayValues = buildDisplayInitialValues(definition.displayType, displayConfig);
return (
<>
@@ -9,6 +9,7 @@ import { Link } from "@homarr/ui";
import { ManagePageLayout } from "~/components/manage/manage-page-layout";
import { MobileAffixButton } from "~/components/manage/mobile-affix-button";
import { InstallWidgetFromWorkshopButton } from "~/components/workshop/workshop-actions";
import { CustomWidgetBetaBanner } from "./_beta-banner";
import { ImportCustomWidgetButton } from "./_custom-widget-actions";
import { CustomWidgetList } from "./_custom-widget-list";
@@ -28,6 +29,7 @@ export default async function CustomWidgetsPage() {
title={t("page.list.title")}
primaryAction={
<Group gap="xs">
<InstallWidgetFromWorkshopButton />
<ImportCustomWidgetButton />
<MobileAffixButton component={Link} href="/manage/custom-widgets/new" leftSection={<IconPlus size={16} />}>
{t("action.create")}
@@ -6,6 +6,7 @@ import {
AccordionPanel,
ActionIcon,
ActionIconGroup,
Alert,
Anchor,
Divider,
Group,
@@ -18,14 +19,14 @@ import {
TableTr,
Text,
} from "@mantine/core";
import { IconPencil, IconPlugX } from "@tabler/icons-react";
import { IconAlertTriangle, IconPencil, IconPlugX } from "@tabler/icons-react";
import type { RouterOutputs } from "@homarr/api";
import { api } from "@homarr/api/server";
import { auth } from "@homarr/auth/next";
import { objectEntries } from "@homarr/common";
import type { IntegrationKind } from "@homarr/definitions";
import { getIntegrationName } from "@homarr/definitions";
import { getIntegrationName, isIntegrationKind } from "@homarr/definitions";
import { getScopedI18n } from "@homarr/translation/server";
import { CountBadge, IntegrationAvatar, Link } from "@homarr/ui";
@@ -87,11 +88,14 @@ const IntegrationList = async ({ integrations, activeTab }: IntegrationListProps
const session = await auth();
const hasFullAccess = session?.user.permissions.includes("integration-full-all") ?? false;
if (integrations.length === 0) {
const supportedIntegrations = integrations.filter((integration) => isIntegrationKind(integration.kind));
const unsupportedCount = integrations.length - supportedIntegrations.length;
if (supportedIntegrations.length === 0 && unsupportedCount === 0) {
return <NoResults icon={IconPlugX} title={t("page.list.noResults.title")} />;
}
const groupedIntegrations = integrations.reduce(
const groupedIntegrations = supportedIntegrations.reduce(
(acc, integration) => {
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
if (!acc[integration.kind]) {
@@ -108,88 +112,105 @@ const IntegrationList = async ({ integrations, activeTab }: IntegrationListProps
const entries = objectEntries(groupedIntegrations);
return (
<ActiveTabAccordion defaultValue={activeTab} radius="lg" classNames={classes}>
{entries.map(([kind, kindIntegrations], index) => (
<AccordionItem key={kind} value={kind} data-first={index === 0} data-last={index === entries.length - 1}>
<AccordionControl icon={<IntegrationAvatar size="sm" kind={kind} radius="sm" />}>
<Group>
<Text>{getIntegrationName(kind)}</Text>
<CountBadge count={kindIntegrations.length} />
</Group>
</AccordionControl>
<AccordionPanel>
<Table visibleFrom="md">
<TableThead>
<TableTr>
<TableTh>{t("field.name.label")}</TableTh>
<TableTh>{t("field.url.label")}</TableTh>
<TableTh />
</TableTr>
</TableThead>
<TableTbody>
{kindIntegrations.map((integration) => (
<TableTr key={integration.id}>
<TableTd>{integration.name}</TableTd>
<TableTd>
<Anchor href={integration.url} target="_blank" rel="noreferrer" size="sm">
{integration.url}
</Anchor>
</TableTd>
<TableTd>
<Group justify="end">
{(hasFullAccess || integration.permissions.hasFullAccess) && (
<ActionIconGroup>
<ActionIcon
component={Link}
href={`/manage/integrations/edit/${integration.id}`}
variant="subtle"
color="gray"
aria-label={t("page.edit.title", { name: getIntegrationName(integration.kind) })}
>
<IconPencil size={16} stroke={1.5} />
</ActionIcon>
<DeleteIntegrationActionButton integration={integration} count={kindIntegrations.length} />
</ActionIconGroup>
)}
</Group>
</TableTd>
</TableTr>
))}
</TableTbody>
</Table>
<Stack>
{unsupportedCount > 0 && (
<Alert color="yellow" icon={<IconAlertTriangle size={18} />} title={t("page.list.outdated.title")}>
{t("page.list.outdated.description", { count: unsupportedCount })}
</Alert>
)}
{supportedIntegrations.length === 0 ? (
<NoResults icon={IconPlugX} title={t("page.list.noResults.title")} />
) : (
<ActiveTabAccordion defaultValue={activeTab} radius="lg" classNames={classes}>
{entries.map(([kind, kindIntegrations], index) => (
<AccordionItem key={kind} value={kind} data-first={index === 0} data-last={index === entries.length - 1}>
<AccordionControl icon={<IntegrationAvatar size="sm" kind={kind} radius="sm" />}>
<Group>
<Text>{getIntegrationName(kind)}</Text>
<CountBadge count={kindIntegrations.length} />
</Group>
</AccordionControl>
<AccordionPanel>
<Table visibleFrom="md">
<TableThead>
<TableTr>
<TableTh>{t("field.name.label")}</TableTh>
<TableTh>{t("field.url.label")}</TableTh>
<TableTh />
</TableTr>
</TableThead>
<TableTbody>
{kindIntegrations.map((integration) => (
<TableTr key={integration.id}>
<TableTd>{integration.name}</TableTd>
<TableTd>
<Anchor href={integration.url} target="_blank" rel="noreferrer" size="sm">
{integration.url}
</Anchor>
</TableTd>
<TableTd>
<Group justify="end">
{(hasFullAccess || integration.permissions.hasFullAccess) && (
<ActionIconGroup>
<ActionIcon
component={Link}
href={`/manage/integrations/edit/${integration.id}`}
variant="subtle"
color="gray"
aria-label={t("page.edit.title", { name: getIntegrationName(integration.kind) })}
>
<IconPencil size={16} stroke={1.5} />
</ActionIcon>
<DeleteIntegrationActionButton
integration={integration}
count={kindIntegrations.length}
/>
</ActionIconGroup>
)}
</Group>
</TableTd>
</TableTr>
))}
</TableTbody>
</Table>
<Stack gap="xs" hiddenFrom="md">
{kindIntegrations.map((integration, integrationIndex) => (
<Fragment key={integration.id}>
{integrationIndex !== 0 && <Divider />}
<Stack gap={0}>
<Group justify="space-between" align="center" wrap="nowrap">
<Text>{integration.name}</Text>
{(hasFullAccess || integration.permissions.hasFullAccess) && (
<ActionIconGroup>
<ActionIcon
component={Link}
href={`/manage/integrations/edit/${integration.id}`}
variant="subtle"
color="gray"
aria-label={t("page.edit.title", { name: getIntegrationName(integration.kind) })}
>
<IconPencil size={16} stroke={1.5} />
</ActionIcon>
<DeleteIntegrationActionButton integration={integration} count={kindIntegrations.length} />
</ActionIconGroup>
)}
</Group>
<Anchor href={integration.url} target="_blank" rel="noreferrer" size="sm">
{integration.url}
</Anchor>
</Stack>
</Fragment>
))}
</Stack>
</AccordionPanel>
</AccordionItem>
))}
</ActiveTabAccordion>
<Stack gap="xs" hiddenFrom="md">
{kindIntegrations.map((integration, integrationIndex) => (
<Fragment key={integration.id}>
{integrationIndex !== 0 && <Divider />}
<Stack gap={0}>
<Group justify="space-between" align="center" wrap="nowrap">
<Text>{integration.name}</Text>
{(hasFullAccess || integration.permissions.hasFullAccess) && (
<ActionIconGroup>
<ActionIcon
component={Link}
href={`/manage/integrations/edit/${integration.id}`}
variant="subtle"
color="gray"
aria-label={t("page.edit.title", { name: getIntegrationName(integration.kind) })}
>
<IconPencil size={16} stroke={1.5} />
</ActionIcon>
<DeleteIntegrationActionButton
integration={integration}
count={kindIntegrations.length}
/>
</ActionIconGroup>
)}
</Group>
<Anchor href={integration.url} target="_blank" rel="noreferrer" size="sm">
{integration.url}
</Anchor>
</Stack>
</Fragment>
))}
</Stack>
</AccordionPanel>
</AccordionItem>
))}
</ActiveTabAccordion>
)}
</Stack>
);
};
@@ -18,6 +18,7 @@ import {
IconHomeFilled,
IconLayoutDashboardFilled,
IconMailForward,
IconBuildingStore,
IconPhotoFilled,
IconPointerFilled,
IconSearch,
@@ -80,6 +81,12 @@ export default async function ManageLayout({ children }: PropsWithChildren) {
label: t("items.customWidgets"),
hidden: !session,
},
{
icon: IconBuildingStore,
href: "/manage/workshop",
label: t("items.workshop"),
hidden: !session?.user.permissions.includes("admin"),
},
{
icon: IconSearch,
href: "/manage/search-engines",
@@ -0,0 +1,18 @@
import { redirect } from "next/navigation";
import { auth } from "@homarr/auth/next";
import { getScopedI18n } from "@homarr/translation/server";
import { ManagePageLayout } from "~/components/manage/manage-page-layout";
import { WorkshopBrowser } from "~/components/workshop/workshop-browser";
export default async function WorkshopPage() {
const session = await auth();
if (!session?.user.permissions.includes("admin")) redirect(session ? "/" : "/auth/login");
const t = await getScopedI18n("workshop");
return (
<ManagePageLayout title={t("title")} size="xl">
<WorkshopBrowser />
</ManagePageLayout>
);
}
@@ -179,8 +179,8 @@ export async function POST(req: Request) {
}
setTimeout(() => {
console.log("Database restored, restarting server...");
process.exit(0);
console.log("Database restored, restarting server and applying migrations...");
process.exit(101);
}, 500);
return NextResponse.json({ success: true, message: "Database restored. Server is restarting..." });
@@ -165,7 +165,7 @@ export const ItemSelectModal = createModal<void>(({ actions }) => {
</Text>
</Group>
<Text lh={1.2} style={{ whiteSpace: "normal" }} size="xs" c="dimmed" lineClamp={1}>
{def.description ?? ""}
{def.description ?? def.url}
</Text>
</Stack>
<Box
@@ -1,17 +1,9 @@
"use client";
import type { MutableRefObject, ReactNode } from "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 { 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 type { QueryClient, QueryKey } from "@tanstack/react-query";
import { useIsFetching, useQueryClient } from "@tanstack/react-query";
@@ -19,12 +11,10 @@ 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";
@@ -242,14 +232,11 @@ export const WidgetContextMenu = ({ item, widgetStateRef, children }: WidgetCont
onClick={handleRefetch}
disabled={isWidgetFetching}
>
<Group justify="space-between" wrap="nowrap" gap="sm">
<Group justify="space-between" wrap="nowrap">
{tMenu("refresh")}
<WidgetQueryStatus
queryClient={queryClient}
queryKey={widgetQueryKey}
isFetching={isWidgetFetching}
t={t}
/>
<Text size="xs" c="dimmed">
<WidgetCacheAge queryClient={queryClient} queryKey={widgetQueryKey} isFetching={isWidgetFetching} />
</Text>
</Group>
</Menu.Item>
<Menu.Item
@@ -286,65 +273,34 @@ export const WidgetContextMenu = ({ item, widgetStateRef, children }: WidgetCont
);
};
interface WidgetQueryStatusProps {
const WidgetCacheAge = ({
queryClient,
queryKey,
isFetching,
}: {
queryClient: QueryClient;
queryKey: QueryKey;
isFetching: boolean;
t: TranslationFunction;
}
}) => {
// 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);
}, []);
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));
if (isFetching) return <Loader size={12} />;
if (isFetching) {
return (
<Group gap={4} wrap="nowrap">
<Loader size={12} />
<Text size="xs" c="dimmed">
{t("item.menu.status.loading")}
</Text>
</Group>
);
}
const timestamps = queryClient
.getQueryCache()
.findAll({ queryKey })
.map((q) => q.state.dataUpdatedAt)
.filter(Boolean);
if (timestamps.length === 0) return null;
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>
);
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`;
};
@@ -0,0 +1,72 @@
"use client";
import { Button, Modal } from "@mantine/core";
import { useDisclosure } from "@mantine/hooks";
import { IconBuildingStore } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
import { revalidatePathActionAsync } from "@homarr/common/client";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useScopedI18n } from "@homarr/translation/client";
import { customWidgetImportSchema } from "@homarr/validation/custom-widget";
import { validateWorkshopContent } from "@homarr/workshop";
import { WorkshopBrowser } from "./workshop-browser";
export function InstallWidgetFromWorkshopButton() {
const t = useScopedI18n("workshop");
const [opened, { open, close }] = useDisclosure(false);
const utils = clientApi.useUtils();
const mutation = clientApi.customWidget.import.useMutation({
onSuccess: () => {
showSuccessNotification({ title: t("title"), message: t("action.install") });
void utils.customWidget.all.invalidate();
void revalidatePathActionAsync("/manage/custom-widgets");
close();
},
onError: (error) => showErrorNotification({ title: t("title"), message: error.message }),
});
return (
<>
<Button variant="default" leftSection={<IconBuildingStore size={16} />} onClick={open}>
{t("action.install")}
</Button>
<Modal opened={opened} onClose={close} title={t("title")} size="90%" fullScreen={false}>
<WorkshopBrowser
initialType="widget"
lockedType="widget"
useLabel={t("action.install")}
onUse={async (submission) => {
const parsed = customWidgetImportSchema.parse(JSON.parse(submission.content));
await mutation.mutateAsync(parsed);
}}
/>
</Modal>
</>
);
}
export function UseCssFromWorkshopButton({ onUse }: { onUse: (css: string) => void }) {
const t = useScopedI18n("workshop");
const [opened, { open, close }] = useDisclosure(false);
return (
<>
<Button variant="default" leftSection={<IconBuildingStore size={16} />} onClick={open}>
{t("action.useCss")}
</Button>
<Modal opened={opened} onClose={close} title={t("title")} size="90%">
<WorkshopBrowser
initialType="css"
lockedType="css"
useLabel={t("action.useCss")}
onUse={(submission) => {
const validation = validateWorkshopContent("css", submission.content);
if (!validation.success) throw new Error(validation.error);
onUse(submission.content);
close();
}}
/>
</Modal>
</>
);
}
@@ -0,0 +1,354 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import {
Alert,
Badge,
Box,
Button,
Card,
Code,
Group,
Image,
Modal,
Paper,
Pagination,
Select,
SimpleGrid,
Skeleton,
Stack,
Text,
TextInput,
} from "@mantine/core";
import { useDebouncedValue } from "@mantine/hooks";
import {
IconAlertTriangle,
IconBuildingStore,
IconDownload,
IconExternalLink,
IconSearch,
IconWifiOff,
} from "@tabler/icons-react";
import { keepPreviousData, useMutation, useQuery } from "@tanstack/react-query";
import type { WorkshopSubmissionDetail, WorkshopSubmissionType } from "@homarr/workshop";
import { WORKSHOP_API_URL, WORKSHOP_WEB_URL, WorkshopClient, workshopExportFilename } from "@homarr/workshop";
import { useScopedI18n } from "@homarr/translation/client";
import { WorkshopQueryProvider } from "./workshop-query-provider";
const apiUrl = process.env.NEXT_PUBLIC_WORKSHOP_API_URL ?? WORKSHOP_API_URL;
export interface WorkshopBrowserProps {
initialType?: WorkshopSubmissionType | "all";
lockedType?: WorkshopSubmissionType;
onUse?: (submission: WorkshopSubmissionDetail) => Promise<void> | void;
useLabel?: string;
}
export function WorkshopBrowser(props: WorkshopBrowserProps) {
return (
<WorkshopQueryProvider>
<WorkshopBrowserContent {...props} />
</WorkshopQueryProvider>
);
}
function WorkshopBrowserContent({ initialType = "all", lockedType, onUse, useLabel }: WorkshopBrowserProps) {
const t = useScopedI18n("workshop");
const client = useMemo(() => new WorkshopClient(apiUrl), []);
const [page, setPage] = useState(1);
const [type, setType] = useState<WorkshopSubmissionType | "all">(initialType);
const [sort, setSort] = useState<"top" | "newest">("top");
const [search, setSearch] = useState("");
const [debouncedSearch] = useDebouncedValue(search, 250);
const [selectedId, setSelectedId] = useState<string | null>(null);
const [online, setOnline] = useState(true);
useEffect(() => {
const updateOnline = () => setOnline(window.navigator.onLine);
updateOnline();
window.addEventListener("online", updateOnline);
window.addEventListener("offline", updateOnline);
return () => {
window.removeEventListener("online", updateOnline);
window.removeEventListener("offline", updateOnline);
};
}, []);
const query = useQuery({
queryKey: ["workshop", "list", page, type, sort, debouncedSearch],
queryFn: ({ signal }) => client.list({ page, type, sort, search: debouncedSearch, perPage: 12, signal }),
placeholderData: keepPreviousData,
});
const detailQuery = useQuery({
queryKey: ["workshop", "detail", selectedId],
queryFn: ({ signal }) => client.get(selectedId ?? "", signal),
enabled: selectedId !== null,
});
const useMutationState = useMutation({
mutationKey: ["workshop", "use"],
mutationFn: async (submission: WorkshopSubmissionDetail) => {
if (lockedType && submission.type !== lockedType) throw new Error(t("error.wrongType"));
if (!onUse) return;
await onUse(submission);
},
onSuccess: () => setSelectedId(null),
});
const selected = detailQuery.data ?? null;
const hasCachedResults = Boolean(query.data?.items.length);
const download = (submission: WorkshopSubmissionDetail) => {
const url = URL.createObjectURL(
new Blob([submission.content], { type: submission.type === "css" ? "text/css" : "application/json" }),
);
const anchor = document.createElement("a");
anchor.href = url;
anchor.download = workshopExportFilename(submission.title, submission.type);
anchor.click();
URL.revokeObjectURL(url);
};
return (
<Stack gap="lg">
<Paper withBorder p="lg" radius="md">
<Group justify="space-between" align="flex-start" wrap="wrap">
<Group align="flex-start" wrap="nowrap">
<Box
p="sm"
bg="var(--mantine-color-default-hover)"
style={{ borderRadius: "var(--mantine-radius-md)", lineHeight: 0 }}
>
<IconBuildingStore size={24} aria-hidden />
</Box>
<Box>
<Text fw={700} size="lg">
{t("intro.title")}
</Text>
<Text c="dimmed" size="sm" maw={620}>
{t("intro.description")}
</Text>
</Box>
</Group>
<Badge color={online ? "green" : "yellow"} variant="light">
{online ? t("status.connected") : t("status.offline")}
</Badge>
</Group>
</Paper>
<Alert icon={<IconAlertTriangle size={18} />} color="yellow" title={t("safety.title")}>
{t("safety.description")}
</Alert>
<SimpleGrid cols={{ base: 1, sm: lockedType ? 2 : 3 }} spacing="sm">
<TextInput
label={t("search.label")}
placeholder={t("search.placeholder")}
leftSection={<IconSearch size={16} />}
value={search}
onChange={(event) => {
setSearch(event.currentTarget.value);
setPage(1);
}}
/>
<Select
label={t("filter.type")}
value={type}
disabled={Boolean(lockedType)}
onChange={(value) => {
setType((value ?? "all") as typeof type);
setPage(1);
}}
data={[
{ value: "all", label: t("filter.all") },
{ value: "widget", label: t("filter.widgets") },
{ value: "css", label: t("filter.css") },
]}
/>
<Select
label={t("filter.sort")}
value={sort}
onChange={(value) => {
setSort((value ?? "top") as typeof sort);
setPage(1);
}}
data={[
{ value: "top", label: t("filter.top") },
{ value: "newest", label: t("filter.newest") },
]}
/>
</SimpleGrid>
{(query.isError || query.fetchStatus === "paused") && (
<Alert
icon={<IconWifiOff size={18} />}
color={hasCachedResults ? "yellow" : "red"}
title={hasCachedResults ? t("offline.cachedTitle") : t("offline.title")}
>
<Stack gap="sm">
<Text size="sm">
{hasCachedResults
? t("offline.cachedDescription")
: online
? t("offline.unavailableDescription")
: t("offline.description")}
</Text>
<Group wrap="wrap">
<Button size="xs" variant="light" onClick={() => void query.refetch()}>
{t("action.retry")}
</Button>
<Button
size="xs"
variant="subtle"
component="a"
href="https://homarr.dev/docs/management/workshop"
target="_blank"
>
{t("action.website")}
</Button>
</Group>
</Stack>
</Alert>
)}
{query.isLoading ? (
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }}>
<Skeleton h={280} />
<Skeleton h={280} />
<Skeleton h={280} />
</SimpleGrid>
) : query.data?.items.length ? (
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }}>
{query.data.items.map((item) => (
<Card key={item.id} withBorder padding="md">
{item.screenshots[0] ? (
<Card.Section>
<Image src={client.fileUrl(item.id, item.screenshots[0], "480x320")} h={170} alt="" />
</Card.Section>
) : (
<Card.Section>
<Box
h={170}
bg="var(--mantine-color-default-hover)"
style={{ display: "grid", placeItems: "center" }}
>
<Text ff="monospace" fw={700}>
{item.type === "widget" ? "{}" : "CSS"}
</Text>
</Box>
</Card.Section>
)}
<Stack gap="xs" mt="md">
<Group justify="space-between">
<Badge variant="light">{item.type === "widget" ? t("type.widget") : t("type.css")}</Badge>
<Text size="sm" c="dimmed">
{item.score >= 0 ? "+" : ""}
{item.score}
</Text>
</Group>
<Text fw={600} lineClamp={1}>
{item.title}
</Text>
<Text size="sm" c="dimmed" lineClamp={2}>
{item.description || t("empty.description")}
</Text>
<Text size="xs" c="dimmed">
{t("by", { name: item.authorName, revision: item.revision })}
</Text>
<Button variant="light" onClick={() => setSelectedId(item.id)}>
{t("action.inspect")}
</Button>
</Stack>
</Card>
))}
</SimpleGrid>
) : query.isError || query.fetchStatus === "paused" ? null : (
<Stack align="center" py="xl">
<Text fw={600}>{t("empty.title")}</Text>
<Text c="dimmed">{t("empty.description")}</Text>
</Stack>
)}
{(query.data?.totalPages ?? 0) > 1 && (
<Pagination total={query.data?.totalPages ?? 1} value={page} onChange={setPage} mx="auto" />
)}
<Modal
opened={selectedId !== null}
onClose={() => {
setSelectedId(null);
useMutationState.reset();
}}
title={selected?.title ?? t("loading")}
size="xl"
>
{detailQuery.isPending ? (
<Skeleton h={400} />
) : detailQuery.isError || !selected ? (
<Alert icon={<IconWifiOff size={18} />} color="red" title={t("error.detailTitle")}>
<Stack gap="sm">
<Text size="sm">{t("error.detailDescription")}</Text>
<Button size="xs" variant="light" onClick={() => void detailQuery.refetch()}>
{t("action.retry")}
</Button>
</Stack>
</Alert>
) : (
<Stack>
<Group>
<Badge>{selected.type === "widget" ? t("type.widget") : t("type.css")}</Badge>
<Text size="sm" c="dimmed">
{t("by", { name: selected.authorName, revision: selected.revision })}
</Text>
</Group>
<Text>{selected.description}</Text>
{selected.screenshots.length > 0 && (
<SimpleGrid cols={{ base: 1, sm: 2 }}>
{selected.screenshots.map((screenshot, index) => (
<Image
key={screenshot}
src={client.fileUrl(selected.id, screenshot, "960x640")}
radius="sm"
alt={t("screenshotAlt", { title: selected.title, number: index + 1 })}
/>
))}
</SimpleGrid>
)}
<Alert icon={<IconAlertTriangle size={18} />} color="yellow">
{t("safety.inspect")}
</Alert>
<Code block mah={360} style={{ overflow: "auto", whiteSpace: "pre" }}>
{selected.content}
</Code>
<Group justify="space-between">
<Group>
<Button variant="default" leftSection={<IconDownload size={16} />} onClick={() => download(selected)}>
{t("action.download")}
</Button>
<Button
component="a"
href={`${WORKSHOP_WEB_URL}/${selected.id}`}
target="_blank"
variant="subtle"
leftSection={<IconExternalLink size={16} />}
>
{t("action.website")}
</Button>
</Group>
{onUse && (
<Button
loading={useMutationState.isPending}
disabled={!online}
onClick={() => useMutationState.mutate(selected)}
>
{useLabel ?? t("action.use")}
</Button>
)}
</Group>
{!online && onUse && <Alert color="yellow">{t("offline.actionUnavailable")}</Alert>}
{useMutationState.error && (
<Alert color="red">
{useMutationState.error instanceof Error ? useMutationState.error.message : t("error.install")}
</Alert>
)}
</Stack>
)}
</Modal>
</Stack>
);
}
@@ -0,0 +1,51 @@
"use client";
import type { PropsWithChildren } from "react";
import { useState } from "react";
import { createAsyncStoragePersister } from "@tanstack/query-async-storage-persister";
import { QueryClient } from "@tanstack/react-query";
import { PersistQueryClientProvider } from "@tanstack/react-query-persist-client";
const workshopCacheMaxAge = 1000 * 60 * 60 * 24;
export function WorkshopQueryProvider({ children }: PropsWithChildren) {
const [queryClient] = useState(
() =>
new QueryClient({
defaultOptions: {
queries: {
gcTime: workshopCacheMaxAge,
networkMode: "offlineFirst",
refetchOnReconnect: true,
retry: 1,
staleTime: 1000 * 60 * 5,
},
mutations: {
networkMode: "online",
retry: false,
},
},
}),
);
const [persister] = useState(() =>
createAsyncStoragePersister({
key: "homarr-workshop-cache-v1",
storage: typeof window === "undefined" ? undefined : window.localStorage,
throttleTime: 1000,
}),
);
return (
<PersistQueryClientProvider
client={queryClient}
persistOptions={{
persister,
maxAge: workshopCacheMaxAge,
buster: "workshop-v1",
dehydrateOptions: { shouldDehydrateQuery: (query) => query.state.status === "success" },
}}
>
{children}
</PersistQueryClientProvider>
);
}
+1
View File
@@ -0,0 +1 @@
pb_data/
+77
View File
@@ -0,0 +1,77 @@
# syntax=docker/dockerfile:1.10
FROM node:24.18.0-alpine AS dependencies
WORKDIR /app
ENV COREPACK_HOME=/corepack
RUN mkdir -p /corepack && \
corepack enable pnpm && \
corepack install --global pnpm@11.10.0
COPY pnpm-lock.yaml pnpm-workspace.yaml package.json turbo.json ./
COPY patches ./patches
RUN --mount=type=cache,id=workshop-pnpm-store,target=/pnpm/store \
pnpm config set store-dir /pnpm/store && \
pnpm --config.node-linker=isolated fetch --ignore-scripts && \
rm -rf node_modules
COPY apps/docs ./apps/docs
COPY packages/definitions ./packages/definitions
COPY packages/validation ./packages/validation
COPY packages/workshop ./packages/workshop
COPY tooling/typescript ./tooling/typescript
RUN --mount=type=cache,id=workshop-pnpm-store,target=/pnpm/store \
pnpm install --recursive --offline --frozen-lockfile
FROM dependencies AS website
RUN CI=true SKIP_ENV_VALIDATION=true pnpm turbo build --filter=@homarr/docs
FROM alpine:3.22 AS pocketbase
ARG TARGETARCH
ARG POCKETBASE_VERSION=0.39.6
RUN apk add --no-cache ca-certificates curl unzip
RUN case "${TARGETARCH}" in \
amd64) PB_ARCH=amd64 ;; \
arm64) PB_ARCH=arm64 ;; \
arm) PB_ARCH=armv7 ;; \
*) echo "Unsupported architecture: ${TARGETARCH}" >&2; exit 1 ;; \
esac && \
BASE_URL="https://github.com/pocketbase/pocketbase/releases/download/v${POCKETBASE_VERSION}" && \
ARCHIVE="pocketbase_${POCKETBASE_VERSION}_linux_${PB_ARCH}.zip" && \
curl --fail --location --silent --show-error "${BASE_URL}/${ARCHIVE}" --output "/tmp/${ARCHIVE}" && \
curl --fail --location --silent --show-error "${BASE_URL}/checksums.txt" --output /tmp/checksums.txt && \
cd /tmp && grep " ${ARCHIVE}$" checksums.txt | sha256sum -c - && \
unzip "/tmp/${ARCHIVE}" pocketbase -d /out
FROM alpine:3.22 AS development
RUN apk add --no-cache ca-certificates tzdata
COPY --from=pocketbase /out/pocketbase /usr/local/bin/pocketbase
COPY apps/workshop/entrypoint.dev.sh /usr/local/bin/workshop-dev-entrypoint
COPY apps/workshop/dev-public /pb_public
RUN chmod +x /usr/local/bin/workshop-dev-entrypoint && \
mkdir -p /pb_data /pb_hooks /pb_migrations
EXPOSE 8090
HEALTHCHECK --interval=10s --timeout=5s --start-period=15s --retries=6 CMD wget -q --spider http://127.0.0.1:8090/api/health || exit 1
ENTRYPOINT ["workshop-dev-entrypoint"]
FROM alpine:3.22 AS production
RUN apk add --no-cache ca-certificates tzdata && addgroup -S pocketbase && adduser -S -G pocketbase pocketbase
COPY --from=pocketbase /out/pocketbase /usr/local/bin/pocketbase
COPY --from=website /app/apps/docs/build /pb_public
COPY apps/workshop/entrypoint.sh /usr/local/bin/workshop-entrypoint
COPY apps/workshop/pb_hooks /pb_hooks
COPY apps/workshop/pb_migrations /pb_migrations
RUN chmod +x /usr/local/bin/workshop-entrypoint && \
mkdir -p /pb_data && \
chown -R pocketbase:pocketbase /pb_data /pb_hooks /pb_migrations /pb_public
USER pocketbase
EXPOSE 8090
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 CMD wget -q --spider http://127.0.0.1:8090/api/health || exit 1
ENTRYPOINT ["workshop-entrypoint"]
+21
View File
@@ -0,0 +1,21 @@
**
!package.json
!pnpm-lock.yaml
!pnpm-workspace.yaml
!turbo.json
!patches/**
!apps/docs/**
!apps/workshop/entrypoint.sh
!apps/workshop/entrypoint.dev.sh
!apps/workshop/dev-public/**
!apps/workshop/pb_hooks/**
!apps/workshop/pb_migrations/**
!packages/definitions/**
!packages/validation/**
!packages/workshop/**
!tooling/typescript/**
**/node_modules
**/.docusaurus
**/build
**/dist
**/.turbo
+74
View File
@@ -0,0 +1,74 @@
# Homarr Workshop service
Workshop combines the public Docusaurus experience with a PocketBase community API. Development keeps the two processes separate; the production image serves both from one origin.
## Development
From the repository root, run:
```sh
pnpm dev:workshop
```
This starts:
- PocketBase in Docker with `--dev` at `http://localhost:8090`;
- the PocketBase dashboard at `http://localhost:8090/_/`;
- `@homarr/workshop` in watch mode on the host;
- Docusaurus with HMR at `http://localhost:3003/workshop`.
PocketBase data is bind-mounted at `apps/workshop/pb_data/`. The directory is gitignored and survives container rebuilds. Hooks and migrations are mounted read-only and PocketBase reloads them in development mode.
No users, superusers, OAuth providers, or submissions are seeded. Create the first superuser manually in the PocketBase dashboard, configure GitHub OAuth on the `users` collection, and use `http://localhost:8090/api/oauth2-redirect` as the local callback URL. OAuth account creation is handled by PocketBase directly. Promote moderators and administrators by creating a `workshop_staff` record for the user; that record is the authorization source of truth.
Optional ports and CORS behavior are configured only in the repository-root `.env`:
```dotenv
PB_EXPOSE_PORT=8090
WORKSHOP_WEB_PORT=3003
PB_ALLOWED_ORIGINS=*
```
## Production-like preview
Run the exact combined image shape used for deployment:
```sh
pnpm preview:workshop
```
The multi-stage image builds Docusaurus, verifies and installs PocketBase `0.39.6`, copies the hooks and migrations, and serves everything from `http://localhost:8090`:
- website: `http://localhost:8090/workshop`;
- API: `http://localhost:8090/api`;
- dashboard: `http://localhost:8090/_/`.
The preview reuses `apps/workshop/pb_data/`, so OAuth configuration and local records remain available when switching between development and preview. `--remove-orphans` replaces the previous service shape without deleting data.
## Reset local data
Resetting is always explicit:
```sh
pnpm reset:workshop
```
The command asks for confirmation, stops both service variants, and clears `apps/workshop/pb_data/`. For automation, use `pnpm reset:workshop -- --force`.
Authentication failures for an old email are requests made by the PocketBase dashboard against the persisted database; Workshop startup never attempts to authenticate or seed that account.
## Build and validation
`pnpm build` builds both `@homarr/workshop` and Docusaurus as part of the monorepo build. The production image can be validated locally with:
```sh
pnpm test:workshop-preview
```
The test uses a temporary database, builds and starts the production image, checks the website, API, service worker, and migrated collections, then removes all test state. The dedicated Workshop CI workflow runs this test only when Workshop-related sources or build inputs change; it does not publish an image.
## Operations
The live service uses the production target behind the `homarr.dev` TLS proxy. PocketBase superuser access is bootstrap/emergency access only; normal moderation belongs in `/workshop/admin`.
Back up `/pb_data` before PocketBase or schema upgrades. It contains records, uploads, OAuth settings, and credentials. Production must also restrict `/_/`, monitor health and authentication failures, and validate backup restoration regularly.
+10
View File
@@ -0,0 +1,10 @@
self.addEventListener("install", () => self.skipWaiting());
self.addEventListener("activate", (event) => {
event.waitUntil(
Promise.all([
caches.keys().then((keys) => Promise.all(keys.map((key) => caches.delete(key)))),
self.registration.unregister(),
]),
);
});
+19
View File
@@ -0,0 +1,19 @@
name: homarr-workshop
services:
workshop:
build:
context: ../..
dockerfile: apps/workshop/Dockerfile
target: production
args:
POCKETBASE_VERSION: 0.39.6
restart: unless-stopped
environment:
PB_ALLOWED_ORIGINS: ${PB_ALLOWED_ORIGINS:-*}
ports:
- "127.0.0.1:${PB_EXPOSE_PORT:-8090}:8090"
volumes:
- ${WORKSHOP_DATA_DIR:-./pb_data}:/pb_data
security_opt:
- no-new-privileges:true
+21
View File
@@ -0,0 +1,21 @@
name: homarr-workshop
services:
workshop:
build:
context: ../..
dockerfile: apps/workshop/Dockerfile
target: development
args:
POCKETBASE_VERSION: 0.39.6
restart: unless-stopped
environment:
PB_ALLOWED_ORIGINS: ${PB_ALLOWED_ORIGINS:-*}
ports:
- "127.0.0.1:${PB_EXPOSE_PORT:-8090}:8090"
volumes:
- ${WORKSHOP_DATA_DIR:-./pb_data}:/pb_data
- ./pb_hooks:/pb_hooks:ro
- ./pb_migrations:/pb_migrations:ro
security_opt:
- no-new-privileges:true
+16
View File
@@ -0,0 +1,16 @@
#!/bin/sh
set -eu
echo "Workshop development API: http://localhost:8090/api"
echo "PocketBase dashboard: http://localhost:8090/_/"
exec pocketbase serve \
--dev \
--automigrate=false \
--http=0.0.0.0:8090 \
--origins="${PB_ALLOWED_ORIGINS:-*}" \
--dir=/pb_data \
--hooksDir=/pb_hooks \
--migrationsDir=/pb_migrations \
--publicDir=/pb_public \
--indexFallback=true
+14
View File
@@ -0,0 +1,14 @@
#!/bin/sh
set -eu
echo "Workshop is listening on port 8090"
echo "Workshop API is available at /api"
exec pocketbase serve \
--http=0.0.0.0:8090 \
--origins="${PB_ALLOWED_ORIGINS:-*}" \
--dir=/pb_data \
--hooksDir=/pb_hooks \
--migrationsDir=/pb_migrations \
--publicDir=/pb_public \
--indexFallback=true
+135
View File
@@ -0,0 +1,135 @@
const WIDGET_SCHEMA = "homarr-custom-widget-v2";
const CSS_SCHEMA = "homarr-custom-css-v1";
const CSS_LIMIT = 16384;
const WIDGET_AUTH_TYPES = ["none", "bearer", "basic", "apiKeyHeader", "apiKeyQuery"];
const WIDGET_METHODS = ["GET", "POST", "PUT", "DELETE", "PATCH"];
const WIDGET_DISPLAY_TYPES = [
"singleValue",
"keyValue",
"table",
"statGrid",
"progressBars",
"statusIndicator",
"countGrid",
"raw",
"actionButton",
"customJsx",
];
function fail(ErrorType, _code, message) {
throw new ErrorType(message);
}
function requireWritableAccount(app, auth) {
if (!auth) fail(UnauthorizedError, "authentication_required", "Authentication is required");
const account = app.findRecordById("users", auth.id);
if (account.getString("state") === "disabled") fail(ForbiddenError, "account_disabled", "Account is disabled");
return account;
}
function validateSubmission(record) {
const type = record.getString("type");
const content = record.getString("content");
if (type === "css") {
if (!content.trim()) fail(BadRequestError, "invalid_submission", "CSS cannot be empty");
if (content.length > CSS_LIMIT) fail(BadRequestError, "invalid_submission", "CSS is too large");
record.set("schemaVersion", CSS_SCHEMA);
return;
}
if (type !== "widget") fail(BadRequestError, "invalid_submission", "Unsupported submission type");
let widget;
try {
widget = JSON.parse(content);
} catch {
fail(BadRequestError, "invalid_submission", "Widget content is not valid JSON");
}
if (
!widget ||
widget.$schema !== WIDGET_SCHEMA ||
typeof widget.name !== "string" ||
widget.name.length < 1 ||
widget.name.length > 128 ||
typeof widget.url !== "string" ||
widget.url.length < 1 ||
!WIDGET_AUTH_TYPES.includes(widget.authType) ||
!WIDGET_METHODS.includes(widget.method) ||
!WIDGET_DISPLAY_TYPES.includes(widget.displayType) ||
typeof widget.displayConfig !== "object" ||
Array.isArray(widget.displayConfig) ||
Object.prototype.hasOwnProperty.call(widget, "secrets")
) {
fail(BadRequestError, "invalid_submission", "Widget does not match homarr-custom-widget-v2");
}
if (!widget.displayConfig || widget.displayConfig.type !== widget.displayType) {
fail(BadRequestError, "invalid_submission", "Widget displayConfig.type must match displayType");
}
record.set("schemaVersion", WIDGET_SCHEMA);
}
function snapshot(record) {
const exported = record.publicExport();
if (typeof exported.content === "string") {
exported.contentLength = exported.content.length;
delete exported.content;
}
return JSON.stringify(exported);
}
function requireStaff(app, auth) {
requireWritableAccount(app, auth);
let staff;
try {
staff = app.findFirstRecordByFilter("workshop_staff", "user = {:user}", { user: auth.id });
} catch {
fail(ForbiddenError, "forbidden", "Staff access is required");
}
return staff.getString("role");
}
function setStaffRole(app, user, role) {
let staff = null;
try {
staff = app.findFirstRecordByFilter("workshop_staff", "user = {:user}", { user: user.id });
} catch {
// Members do not have a staff record.
}
if (role === "member") {
if (staff) app.delete(staff);
return;
}
if (!staff) staff = new Record(app.findCollectionByNameOrId("workshop_staff"));
staff.set("user", user.id);
staff.set("role", role);
app.save(staff);
}
function body(e) {
const data = new DynamicModel({ reason: "", state: "", role: "", status: "" });
e.bindBody(data);
if (!data.reason || data.reason.trim().length < 3)
fail(BadRequestError, "invalid_submission", "A reason is required");
return data;
}
function audit(app, actor, action, targetType, targetId, reason, targetSnapshot) {
const collection = app.findCollectionByNameOrId("moderation_actions");
const record = new Record(collection);
record.set("actor", actor.id);
record.set("action", action);
record.set("targetType", targetType);
record.set("targetId", targetId);
record.set("reason", reason.trim());
record.set("snapshot", targetSnapshot || "");
app.save(record);
}
module.exports = {
audit,
body,
fail,
requireStaff,
requireWritableAccount,
setStaffRole,
snapshot,
validateSubmission,
};
+141
View File
@@ -0,0 +1,141 @@
/// <reference path="../pb_data/types.d.ts" />
onRecordCreateRequest((e) => {
const utils = require(`${__hooks}/workshop-utils.js`);
const account = utils.requireWritableAccount(e.app, e.auth, true);
utils.validateSubmission(e.record);
e.record.set("author", e.auth.id);
e.record.set("authorName", account.getString("displayName"));
e.record.set("revision", 1);
e.next();
}, "submissions");
onRecordUpdateRequest((e) => {
const utils = require(`${__hooks}/workshop-utils.js`);
utils.requireWritableAccount(e.app, e.auth, true);
const original = e.app.findRecordById("submissions", e.record.id);
e.record.set("author", original.get("author"));
e.record.set("authorName", original.get("authorName"));
e.record.set("type", original.get("type"));
e.record.set("schemaVersion", original.get("schemaVersion"));
e.record.set(
"revision",
original.getInt("revision") + (e.record.getString("content") !== original.getString("content") ? 1 : 0),
);
utils.validateSubmission(e.record);
e.next();
}, "submissions");
onRecordCreateRequest((e) => {
const utils = require(`${__hooks}/workshop-utils.js`);
utils.requireWritableAccount(e.app, e.auth, false);
const value = e.record.getInt("value");
if (value !== 1 && value !== -1) throw new BadRequestError("Vote must be 1 or -1");
e.record.set("user", e.auth.id);
e.next();
}, "votes");
onRecordUpdateRequest((e) => {
const utils = require(`${__hooks}/workshop-utils.js`);
utils.requireWritableAccount(e.app, e.auth, false);
const original = e.app.findRecordById("votes", e.record.id);
const value = e.record.getInt("value");
if (value !== 1 && value !== -1) throw new BadRequestError("Vote must be 1 or -1");
e.record.set("user", original.get("user"));
e.record.set("submission", original.get("submission"));
e.next();
}, "votes");
onRecordCreateRequest((e) => {
const utils = require(`${__hooks}/workshop-utils.js`);
utils.requireWritableAccount(e.app, e.auth, false);
e.record.set("reporter", e.auth.id);
e.record.set("status", "open");
e.next();
}, "reports");
routerAdd(
"POST",
"/api/workshop/moderation/submissions/{id}/remove",
(e) => {
const utils = require(`${__hooks}/workshop-utils.js`);
utils.requireStaff(e.app, e.auth);
const data = utils.body(e);
const id = e.request.pathValue("id");
e.app.runInTransaction((tx) => {
const target = tx.findRecordById("submissions", id);
utils.audit(tx, e.auth, "remove_submission", "submission", id, data.reason, utils.snapshot(target));
tx.delete(target);
});
return e.json(200, { success: true });
},
$apis.requireAuth("users"),
);
routerAdd(
"POST",
"/api/workshop/moderation/users/{id}/state",
(e) => {
const utils = require(`${__hooks}/workshop-utils.js`);
const actorRole = utils.requireStaff(e.app, e.auth);
const data = utils.body(e);
if (!["active", "disabled"].includes(data.state)) throw new BadRequestError("Invalid account state");
const id = e.request.pathValue("id");
if (id === e.auth.id) throw new BadRequestError("You cannot moderate your own account");
e.app.runInTransaction((tx) => {
const target = tx.findRecordById("users", id);
if (target.getString("role") === "admin" && actorRole !== "admin") throw new ForbiddenError("forbidden");
const previous = utils.snapshot(target);
target.set("state", data.state);
target.set("moderationReason", data.reason);
tx.save(target);
utils.audit(tx, e.auth, "set_account_state", "user", id, data.reason, previous);
});
return e.json(200, { success: true });
},
$apis.requireAuth("users"),
);
routerAdd(
"POST",
"/api/workshop/moderation/users/{id}/role",
(e) => {
const utils = require(`${__hooks}/workshop-utils.js`);
if (utils.requireStaff(e.app, e.auth) !== "admin") throw new ForbiddenError("forbidden");
const data = utils.body(e);
if (!["member", "moderator", "admin"].includes(data.role)) throw new BadRequestError("Invalid role");
const id = e.request.pathValue("id");
if (id === e.auth.id) throw new BadRequestError("You cannot change your own role");
e.app.runInTransaction((tx) => {
const target = tx.findRecordById("users", id);
const previous = utils.snapshot(target);
target.set("role", data.role);
tx.save(target);
utils.setStaffRole(tx, target, data.role);
utils.audit(tx, e.auth, "set_role", "user", id, data.reason, previous);
});
return e.json(200, { success: true });
},
$apis.requireAuth("users"),
);
routerAdd(
"POST",
"/api/workshop/moderation/reports/{id}/resolve",
(e) => {
const utils = require(`${__hooks}/workshop-utils.js`);
utils.requireStaff(e.app, e.auth);
const data = utils.body(e);
if (!["resolved", "dismissed"].includes(data.status)) throw new BadRequestError("Invalid report status");
const id = e.request.pathValue("id");
e.app.runInTransaction((tx) => {
const target = tx.findRecordById("reports", id);
const previous = utils.snapshot(target);
target.set("status", data.status);
tx.save(target);
utils.audit(tx, e.auth, "resolve_report", "report", id, data.reason, previous);
});
return e.json(200, { success: true });
},
$apis.requireAuth("users"),
);
@@ -0,0 +1,286 @@
/// <reference path="../pb_data/types.d.ts" />
migrate(
(app) => {
let users;
try {
users = app.findCollectionByNameOrId("users");
} catch {
users = new Collection({
type: "auth",
name: "users",
});
}
users.listRule = "@request.auth.role = 'moderator' || @request.auth.role = 'admin'";
users.viewRule = "id = @request.auth.id || @request.auth.role = 'moderator' || @request.auth.role = 'admin'";
users.updateRule =
"id = @request.auth.id && @request.auth.state != 'disabled' && @request.body.role:changed = false && @request.body.state:changed = false && @request.body.moderationReason:changed = false";
users.deleteRule = null;
users.passwordAuth = { enabled: false };
// PocketBase creates a default users auth collection on first boot, so
// Workshop fields must be added to the existing collection explicitly.
users.fields.add(
new TextField({ name: "displayName", required: true, min: 1, max: 100 }),
new URLField({ name: "avatarUrl" }),
new SelectField({ name: "role", required: true, maxSelect: 1, values: ["member", "moderator", "admin"] }),
new SelectField({
name: "state",
required: true,
maxSelect: 1,
values: ["active", "disabled"],
}),
new TextField({ name: "moderationReason", max: 1000 }),
);
app.save(users);
const workshopStaff = new Collection({
type: "base",
name: "workshop_staff",
listRule: "user = @request.auth.id",
viewRule: "user = @request.auth.id",
createRule: null,
updateRule: null,
deleteRule: null,
fields: [
{ type: "relation", name: "user", required: true, maxSelect: 1, collectionId: users.id, cascadeDelete: true },
{ type: "select", name: "role", required: true, maxSelect: 1, values: ["moderator", "admin"] },
{ type: "autodate", name: "created", onCreate: true },
{ type: "autodate", name: "updated", onCreate: true, onUpdate: true },
],
});
workshopStaff.addIndex("idx_workshop_staff_user", true, "user", "");
app.save(workshopStaff);
const staffRule = "@collection.workshop_staff.user ?= @request.auth.id";
users.listRule = staffRule;
users.viewRule = `id = @request.auth.id || ${staffRule}`;
app.save(users);
const writableRule = "@request.auth.id != '' && @request.auth.state != 'disabled'";
const authorRule = "author = @request.auth.id && @request.auth.state = 'active'";
const submissions = new Collection({
type: "base",
name: "submissions",
// Full source is intentionally available only through getOne; discovery
// must use workshop_listings so list payloads never contain content.
listRule: null,
viewRule: "",
createRule: null,
updateRule: null,
deleteRule: null,
fields: [
{ type: "select", name: "type", required: true, maxSelect: 1, values: ["widget", "css"] },
{ type: "text", name: "title", required: true, min: 3, max: 100 },
{ type: "text", name: "description", max: 2000 },
{ type: "text", name: "schemaVersion", required: true, max: 100 },
{ type: "text", name: "content", required: true, max: 1000000 },
{
type: "file",
name: "screenshots",
maxSelect: 5,
maxSize: 5242880,
mimeTypes: ["image/png", "image/jpeg", "image/webp"],
thumbs: ["480x320", "960x640"],
},
{
type: "relation",
name: "author",
required: true,
maxSelect: 1,
collectionId: users.id,
cascadeDelete: true,
},
{ type: "text", name: "authorName", required: true, max: 100 },
{ type: "number", name: "revision", required: true, onlyInt: true, min: 1 },
{ type: "text", name: "changelog", max: 2000 },
{ type: "autodate", name: "created", onCreate: true },
{ type: "autodate", name: "updated", onCreate: true, onUpdate: true },
],
});
app.save(submissions);
submissions.createRule = "@request.auth.id != ''";
submissions.updateRule = authorRule;
submissions.deleteRule = authorRule;
submissions.addIndex("idx_submissions_author", false, "author", "");
submissions.addIndex("idx_submissions_type_created", false, "type, created", "");
app.save(submissions);
const votes = new Collection({
type: "base",
name: "votes",
listRule: null,
viewRule: null,
createRule: null,
updateRule: null,
deleteRule: null,
fields: [
{
type: "relation",
name: "submission",
required: true,
maxSelect: 1,
collectionId: submissions.id,
cascadeDelete: true,
},
{ type: "relation", name: "user", required: true, maxSelect: 1, collectionId: users.id, cascadeDelete: true },
{ type: "number", name: "value", required: true, onlyInt: true, min: -1, max: 1 },
{ type: "autodate", name: "created", onCreate: true },
{ type: "autodate", name: "updated", onCreate: true, onUpdate: true },
],
});
app.save(votes);
votes.listRule = "user = @request.auth.id";
votes.viewRule = "user = @request.auth.id";
votes.createRule = writableRule;
votes.updateRule = "user = @request.auth.id && @request.auth.state != 'disabled'";
votes.deleteRule = "user = @request.auth.id && @request.auth.state != 'disabled'";
votes.addIndex("idx_votes_user_submission", true, "user, submission", "");
votes.addIndex("idx_votes_submission", false, "submission", "");
app.save(votes);
const reports = new Collection({
type: "base",
name: "reports",
listRule: null,
viewRule: null,
createRule: null,
updateRule: null,
deleteRule: null,
fields: [
{
type: "relation",
name: "submission",
required: true,
maxSelect: 1,
collectionId: submissions.id,
cascadeDelete: true,
},
{
type: "relation",
name: "reporter",
required: true,
maxSelect: 1,
collectionId: users.id,
cascadeDelete: true,
},
{
type: "select",
name: "category",
required: true,
maxSelect: 1,
values: ["malicious", "spam", "copyright", "inappropriate", "other"],
},
{ type: "text", name: "explanation", required: true, min: 3, max: 1000 },
{ type: "select", name: "status", required: true, maxSelect: 1, values: ["open", "resolved", "dismissed"] },
{ type: "autodate", name: "created", onCreate: true },
{ type: "autodate", name: "updated", onCreate: true, onUpdate: true },
],
});
app.save(reports);
reports.listRule = staffRule;
reports.viewRule = staffRule;
reports.createRule = writableRule;
reports.addIndex("idx_reports_reporter_submission", true, "reporter, submission", "");
reports.addIndex("idx_reports_status_created", false, "status, created", "");
app.save(reports);
const moderationActions = new Collection({
type: "base",
name: "moderation_actions",
listRule: staffRule,
viewRule: staffRule,
createRule: null,
updateRule: null,
deleteRule: null,
fields: [
{ type: "relation", name: "actor", required: true, maxSelect: 1, collectionId: users.id },
{
type: "select",
name: "action",
required: true,
maxSelect: 1,
values: ["remove_submission", "set_account_state", "set_role", "resolve_report"],
},
{ type: "text", name: "targetType", required: true, max: 30 },
{ type: "text", name: "targetId", required: true, max: 30 },
{ type: "text", name: "reason", required: true, min: 3, max: 1000 },
{ type: "text", name: "snapshot", max: 100000 },
{ type: "autodate", name: "created", onCreate: true },
],
});
app.save(moderationActions);
moderationActions.addIndex("idx_moderation_actions_created", false, "created", "");
app.save(moderationActions);
const listings = new Collection({
type: "view",
name: "workshop_listings",
listRule: "",
viewRule: "",
viewQuery: `
SELECT s.id, s.type, s.title, s.description, s.schemaVersion, s.screenshots,
s.revision, s.changelog, s.author, s.authorName, s.created, s.updated,
COALESCE(SUM(CASE WHEN v.value = 1 THEN 1 ELSE 0 END), 0) AS upvotes,
COALESCE(SUM(CASE WHEN v.value = -1 THEN 1 ELSE 0 END), 0) AS downvotes,
COALESCE(SUM(v.value), 0) AS score
FROM submissions s
LEFT JOIN votes v ON v.submission = s.id
GROUP BY s.id
`,
});
app.save(listings);
const settings = app.settings();
const workshopRateLimitLabels = [
"submissions:create",
"votes:create",
"reports:create",
"/api/workshop/moderation/",
];
const workshopRateLimits = [
{ label: "submissions:create", audience: "@auth", duration: 60, maxRequests: 10 },
{ label: "votes:create", audience: "@auth", duration: 10, maxRequests: 20 },
{ label: "reports:create", audience: "@auth", duration: 60, maxRequests: 5 },
{ label: "/api/workshop/moderation/", audience: "@auth", duration: 60, maxRequests: 30 },
];
settings.rateLimits.enabled = true;
settings.rateLimits.rules = [
...settings.rateLimits.rules.filter((rule) => !workshopRateLimitLabels.includes(rule.label)),
...workshopRateLimits,
];
settings.logs.maxDays = 30;
settings.logs.logAuthId = true;
settings.logs.logIP = true;
app.save(settings);
},
(app) => {
// PocketBase creates the users auth collection before this migration. Keep
// it intact on rollback so reverting Workshop can never delete accounts.
for (const name of [
"workshop_listings",
"moderation_actions",
"reports",
"votes",
"submissions",
"workshop_staff",
]) {
try {
app.delete(app.findCollectionByNameOrId(name));
} catch {
// Collection was not created or was already removed.
}
}
const settings = app.settings();
const workshopRateLimitLabels = [
"submissions:create",
"votes:create",
"reports:create",
"/api/workshop/moderation/",
];
settings.rateLimits.rules = settings.rateLimits.rules.filter(
(rule) => !workshopRateLimitLabels.includes(rule.label),
);
app.save(settings);
},
);
@@ -0,0 +1,73 @@
/// <reference path="../pb_data/types.d.ts" />
migrate(
(app) => {
const users = app.findCollectionByNameOrId("users");
const state = users.fields.getByName("state");
if (state) state.values = ["active", "disabled"];
let staff;
try {
staff = app.findCollectionByNameOrId("workshop_staff");
} catch {
staff = new Collection({
type: "base",
name: "workshop_staff",
listRule: "user = @request.auth.id",
viewRule: "user = @request.auth.id",
createRule: null,
updateRule: null,
deleteRule: null,
fields: [
{
type: "relation",
name: "user",
required: true,
maxSelect: 1,
collectionId: users.id,
cascadeDelete: true,
},
{ type: "select", name: "role", required: true, maxSelect: 1, values: ["moderator", "admin"] },
{ type: "autodate", name: "created", onCreate: true },
{ type: "autodate", name: "updated", onCreate: true, onUpdate: true },
],
});
staff.addIndex("idx_workshop_staff_user", true, "user", "");
app.save(staff);
}
for (const user of app.findRecordsByFilter("users", "role = 'moderator' || role = 'admin'", "", 0, 0)) {
if (!user) continue;
let record;
try {
record = app.findFirstRecordByFilter("workshop_staff", "user = {:user}", { user: user.id });
} catch {
record = new Record(staff);
record.set("user", user.id);
}
record.set("role", user.getString("role"));
app.save(record);
}
const staffRule = "@collection.workshop_staff.user ?= @request.auth.id";
users.listRule = staffRule;
users.viewRule = `id = @request.auth.id || ${staffRule}`;
users.updateRule =
"id = @request.auth.id && @request.auth.state != 'disabled' && @request.body.role:changed = false && @request.body.state:changed = false && @request.body.moderationReason:changed = false";
app.save(users);
},
(app) => {
const users = app.findCollectionByNameOrId("users");
users.listRule = "@request.auth.role = 'moderator' || @request.auth.role = 'admin'";
users.viewRule = "id = @request.auth.id || @request.auth.role = 'moderator' || @request.auth.role = 'admin'";
users.updateRule = "id = @request.auth.id && @request.auth.state != 'disabled'";
const state = users.fields.getByName("state");
if (state) state.values = ["active", "posting_banned", "disabled"];
app.save(users);
try {
app.delete(app.findCollectionByNameOrId("workshop_staff"));
} catch {
// Already removed.
}
},
);
+20
View File
@@ -0,0 +1,20 @@
#!/bin/sh
set -eu
data_dir="${WORKSHOP_DATA_DIR:-apps/workshop/pb_data}"
if [ "${1:-}" != "--force" ]; then
printf "Delete the local Workshop database at %s? [y/N] " "$data_dir"
read -r answer
case "$answer" in
y|Y|yes|YES) ;;
*) echo "Reset cancelled"; exit 0 ;;
esac
fi
docker compose --env-file .env --file apps/workshop/docker-compose.yml down --remove-orphans >/dev/null 2>&1 || true
docker compose --env-file .env --file apps/workshop/docker-compose.preview.yml down --remove-orphans >/dev/null 2>&1 || true
mkdir -p "$data_dir"
docker run --rm --volume "$(cd "$data_dir" && pwd):/data" alpine:3.22 sh -c 'rm -rf /data/.[!.]* /data/..?* /data/*'
echo "Workshop database reset"
+53
View File
@@ -0,0 +1,53 @@
#!/bin/sh
set -eu
project="homarr-workshop-integration"
port="${WORKSHOP_TEST_PORT:-18091}"
email="integration@homarr.test"
password="${WORKSHOP_TEST_PASSWORD:-WorkshopIntegrationPassword123!}"
data_dir="$(mktemp -d)"
cleanup() {
PB_EXPOSE_PORT="$port" WORKSHOP_DATA_DIR="$data_dir" docker compose --env-file .env.example --file apps/workshop/docker-compose.yml --project-name "$project" down --remove-orphans >/dev/null 2>&1 || true
docker run --rm --volume "$data_dir:/data" alpine:3.22 sh -c 'rm -rf /data/.[!.]* /data/..?* /data/*' >/dev/null 2>&1 || true
rmdir "$data_dir" >/dev/null 2>&1 || true
}
trap cleanup EXIT INT TERM
PB_EXPOSE_PORT="$port" WORKSHOP_DATA_DIR="$data_dir" docker compose --env-file .env.example --file apps/workshop/docker-compose.yml --project-name "$project" down --remove-orphans >/dev/null 2>&1 || true
PB_EXPOSE_PORT="$port" WORKSHOP_DATA_DIR="$data_dir" docker compose \
--env-file .env.example \
--file apps/workshop/docker-compose.yml \
--project-name "$project" \
up --detach --build --wait
WORKSHOP_DATA_DIR="$data_dir" docker compose \
--env-file .env.example \
--file apps/workshop/docker-compose.yml \
--project-name "$project" \
exec --no-TTY workshop pocketbase superuser upsert "$email" "$password" --dir=/pb_data
if ! WORKSHOP_TEST_URL="http://127.0.0.1:$port" \
WORKSHOP_TEST_SUPERUSER_EMAIL="$email" \
WORKSHOP_TEST_SUPERUSER_PASSWORD="$password" \
node apps/workshop/tests/workshop.integration.mjs; then
PB_EXPOSE_PORT="$port" WORKSHOP_DATA_DIR="$data_dir" docker compose --env-file .env.example --file apps/workshop/docker-compose.yml --project-name "$project" logs --no-color workshop
exit 1
fi
PB_EXPOSE_PORT="$port" WORKSHOP_DATA_DIR="$data_dir" docker compose \
--env-file .env.example \
--file apps/workshop/docker-compose.yml \
--project-name "$project" \
restart workshop >/dev/null
PB_EXPOSE_PORT="$port" WORKSHOP_DATA_DIR="$data_dir" docker compose \
--env-file .env.example \
--file apps/workshop/docker-compose.yml \
--project-name "$project" \
up --detach --wait >/dev/null
WORKSHOP_TEST_URL="http://127.0.0.1:$port" \
WORKSHOP_TEST_SUPERUSER_EMAIL="$email" \
WORKSHOP_TEST_SUPERUSER_PASSWORD="$password" \
WORKSHOP_VERIFY_CONFIGURATION_ONLY=true \
node apps/workshop/tests/workshop.integration.mjs
+51
View File
@@ -0,0 +1,51 @@
#!/bin/sh
set -eu
project="homarr-workshop-preview-test"
port="${WORKSHOP_PREVIEW_TEST_PORT:-18092}"
email="preview@homarr.test"
password="${WORKSHOP_PREVIEW_TEST_PASSWORD:-WorkshopPreviewPassword123!}"
data_dir="$(mktemp -d)"
base_url="http://127.0.0.1:$port"
cleanup() {
PB_EXPOSE_PORT="$port" WORKSHOP_DATA_DIR="$data_dir" docker compose --env-file .env.example --file apps/workshop/docker-compose.preview.yml --project-name "$project" down --remove-orphans >/dev/null 2>&1 || true
docker run --rm --volume "$data_dir:/data" alpine:3.22 sh -c 'rm -rf /data/.[!.]* /data/..?* /data/*' >/dev/null 2>&1 || true
rmdir "$data_dir" >/dev/null 2>&1 || true
}
trap cleanup EXIT INT TERM
PB_EXPOSE_PORT="$port" WORKSHOP_DATA_DIR="$data_dir" docker compose --env-file .env.example --file apps/workshop/docker-compose.preview.yml --project-name "$project" down --remove-orphans >/dev/null 2>&1 || true
PB_EXPOSE_PORT="$port" WORKSHOP_DATA_DIR="$data_dir" docker compose \
--env-file .env.example \
--file apps/workshop/docker-compose.preview.yml \
--project-name "$project" \
up --detach --build --wait
curl --fail --silent --show-error "$base_url/api/health" >/dev/null
curl --fail --silent --show-error --location "$base_url/workshop" >/dev/null
curl --fail --silent --show-error "$base_url/workshop-sw.js" >/dev/null
PB_EXPOSE_PORT="$port" WORKSHOP_DATA_DIR="$data_dir" docker compose \
--env-file .env.example \
--file apps/workshop/docker-compose.preview.yml \
--project-name "$project" \
exec --no-TTY workshop pocketbase superuser upsert "$email" "$password" --dir=/pb_data >/dev/null
token="$(curl --fail --silent --show-error \
--header 'Content-Type: application/json' \
--data "{\"identity\":\"$email\",\"password\":\"$password\"}" \
"$base_url/api/collections/_superusers/auth-with-password" | jq --raw-output '.token')"
collections="$(curl --fail --silent --show-error \
--header "Authorization: $token" \
"$base_url/api/collections?perPage=200")"
for collection in users submissions votes reports moderation_actions workshop_listings; do
echo "$collections" | jq --exit-status --arg name "$collection" '.items | any(.name == $name)' >/dev/null || {
echo "Missing Workshop collection: $collection" >&2
exit 1
}
done
echo "Workshop production preview passed"
@@ -0,0 +1,200 @@
import assert from "node:assert/strict";
import PocketBase, { ClientResponseError } from "pocketbase";
const baseUrl = process.env.WORKSHOP_TEST_URL;
const superuserEmail = process.env.WORKSHOP_TEST_SUPERUSER_EMAIL;
const superuserPassword = process.env.WORKSHOP_TEST_SUPERUSER_PASSWORD;
const verifyConfigurationOnly = process.env.WORKSHOP_VERIFY_CONFIGURATION_ONLY === "true";
assert(baseUrl && superuserEmail && superuserPassword, "Workshop integration test environment is incomplete");
const root = new PocketBase(baseUrl);
await root.collection("_superusers").authWithPassword(superuserEmail, superuserPassword);
if (!verifyConfigurationOnly) {
await root.collections.update("users", {
oauth2: {
enabled: true,
providers: [{ name: "github", clientId: "integration-client", clientSecret: "integration-secret" }],
},
});
}
const authMethods = await root.collection("users").listAuthMethods();
assert.equal(authMethods.oauth2.enabled, true, "GitHub OAuth must retain its persisted PocketBase configuration");
assert.equal(authMethods.password.enabled, false, "password authentication must remain disabled");
assert.deepEqual(
authMethods.oauth2.providers.map((provider) => provider.name),
["github"],
);
if (verifyConfigurationOnly) {
console.log("Workshop PocketBase configuration persisted across restart");
process.exit(0);
}
const createUser = async (name, role = "member", state = "active") => {
const password = `Workshop${name}Password123!`;
return root.collection("users").create({
email: `${name.toLowerCase()}@homarr.test`,
password,
passwordConfirm: password,
verified: true,
displayName: name,
role,
state,
});
};
const expectStatus = async (operation, status) => {
const statuses = Array.isArray(status) ? status : [status];
await assert.rejects(operation, (error) => error instanceof ClientResponseError && statuses.includes(error.status));
};
const memberRecord = await createUser("Member");
const secondMemberRecord = await createUser("SecondMember");
const disabledRecord = await createUser("Disabled");
const moderatorRecord = await createUser("Moderator", "moderator");
const adminRecord = await createUser("Admin", "admin");
await root.collection("workshop_staff").create({ user: moderatorRecord.id, role: "moderator" });
await root.collection("workshop_staff").create({ user: adminRecord.id, role: "admin" });
const member = await root.collection("users").impersonate(memberRecord.id, 3600);
const secondMember = await root.collection("users").impersonate(secondMemberRecord.id, 3600);
const disabled = await root.collection("users").impersonate(disabledRecord.id, 3600);
const moderator = await root.collection("users").impersonate(moderatorRecord.id, 3600);
const admin = await root.collection("users").impersonate(adminRecord.id, 3600);
assert.equal((await member.collection("users").getList()).totalItems, 0, "members must not list Workshop accounts");
await expectStatus(
() =>
member.collection("users").update(memberRecord.id, {
role: "admin",
state: "disabled",
moderationReason: "self escalation",
}),
[403, 404],
);
await admin.send(`/api/workshop/moderation/users/${disabledRecord.id}/state`, {
method: "POST",
body: { state: "disabled", reason: "Integration disable" },
});
const submission = await member.collection("submissions").create({
type: "widget",
title: "Integration widget",
description: "A backend integration fixture",
schemaVersion: "forged-version",
content: JSON.stringify({
$schema: "homarr-custom-widget-v2",
name: "Integration widget",
url: "https://example.com/status",
authType: "none",
method: "GET",
displayType: "raw",
displayConfig: { type: "raw" },
integrationTestPadding: "x".repeat(150_000),
}),
author: secondMemberRecord.id,
authorName: "Forged",
revision: 99,
});
assert.equal(submission.author, memberRecord.id, "server must enforce ownership");
assert.equal(submission.authorName, "Member", "server must snapshot the authenticated author name");
assert.equal(submission.revision, 1, "server must own the initial revision");
assert.equal(submission.schemaVersion, "homarr-custom-widget-v2", "server must own schemaVersion");
await expectStatus(() => disabled.collection("votes").create({ submission: submission.id, value: 1 }), [400, 403]);
await expectStatus(
() =>
member.collection("submissions").create({
type: "widget",
title: "Invalid widget",
content: JSON.stringify({ $schema: "homarr-custom-widget-v2", name: "Missing contract" }),
}),
400,
);
const vote = await member.collection("votes").create({ submission: submission.id, value: 1 });
await expectStatus(() => member.collection("votes").create({ submission: submission.id, value: -1 }), 400);
assert.equal(
(await member.collection("votes").update(vote.id, { submission: "forged", user: "forged", value: -1 })).submission,
submission.id,
);
const report = await secondMember.collection("reports").create({
submission: submission.id,
reporter: memberRecord.id,
category: "spam",
explanation: "Integration report",
status: "dismissed",
});
assert.equal(report.reporter, secondMemberRecord.id);
assert.equal(report.status, "open");
assert.equal((await member.collection("reports").getList()).totalItems, 0, "reports must remain private from members");
assert.equal((await moderator.collection("reports").getList()).items.length, 1);
await expectStatus(
() =>
member.send(`/api/workshop/moderation/users/${secondMemberRecord.id}/state`, {
method: "POST",
body: { state: "disabled", reason: "No permission" },
}),
403,
);
await expectStatus(
() =>
moderator.send(`/api/workshop/moderation/users/${adminRecord.id}/state`, {
method: "POST",
body: { state: "disabled", reason: "Cannot alter admin" },
}),
403,
);
await expectStatus(
() =>
moderator.send(`/api/workshop/moderation/users/${secondMemberRecord.id}/role`, {
method: "POST",
body: { role: "moderator", reason: "Cannot promote" },
}),
403,
);
await admin.send(`/api/workshop/moderation/users/${secondMemberRecord.id}/role`, {
method: "POST",
body: { role: "moderator", reason: "Integration promotion" },
});
assert.equal((await root.collection("users").getOne(secondMemberRecord.id)).role, "moderator");
assert.equal(
(await root.collection("workshop_staff").getFirstListItem(`user = '${secondMemberRecord.id}'`)).role,
"moderator",
);
await moderator.send(`/api/workshop/moderation/reports/${report.id}/resolve`, {
method: "POST",
body: { status: "resolved", reason: "Integration resolution" },
});
assert.equal((await root.collection("reports").getOne(report.id)).status, "resolved");
await moderator.send(`/api/workshop/moderation/submissions/${submission.id}/remove`, {
method: "POST",
body: { reason: "Integration removal" },
});
await expectStatus(() => root.collection("submissions").getOne(submission.id), 404);
await expectStatus(() => root.collection("votes").getOne(vote.id), 404);
await expectStatus(() => root.collection("reports").getOne(report.id), 404);
const actions = await root.collection("moderation_actions").getFullList({ sort: "created" });
assert.deepEqual(
actions.map((action) => action.action),
["set_account_state", "set_role", "resolve_report", "remove_submission"],
);
assert(actions.at(-1)?.snapshot.includes("Integration widget"), "removal audit must retain a target snapshot");
assert(!actions.at(-1)?.snapshot.includes("integrationTestPadding"), "removal audit must omit large source content");
assert(actions.at(-1)?.snapshot.includes('"contentLength"'), "removal audit must retain the source length");
const anonymous = new PocketBase(baseUrl);
const listings = await anonymous.collection("workshop_listings").getList();
assert.equal(listings.totalItems, 0);
await expectStatus(() => anonymous.collection("submissions").getList(), 403);
console.log("Workshop backend integration matrix passed");
+83
View File
@@ -0,0 +1,83 @@
# Workshop V2 audit
## Purpose
The Workshop is Homarr's central community catalog for custom widgets and CSS themes. It must let people discover, inspect, export, publish, update, report, and install creations from homarr.dev and from Homarr itself without requiring raw copy-and-paste as the default workflow.
### Feature North Star
A Homarr user can move from a useful local creation to a safe community submission, or from a community listing to an installed local creation, in one understandable flow with source visibility and an explicit confirmation boundary.
### Developer-experience North Star
Workshop behavior is defined once: one typed contract, one client, versioned PocketBase migrations, deterministic local startup, shared validation fixtures, typed failures, and UI components that contain presentation rather than PocketBase queries or authorization rules.
## Baseline audit
The abandoned prototype is preserved at `origin/feat/widget-store-prototype-backup` (`89260b875`). The rewritten branch is rebased onto the current `origin/dev` before each final push.
| Area | Severity | Baseline finding | V2 resolution |
| ------------- | -------- | ----------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------ |
| Branch health | P0 | PR #5984 was based on an obsolete `dev` commit and conflicted with the current branch. | Rebuild from current `dev`; keep the old head on a backup ref. |
| Security | P0 | Submission validation ran in browsers and could be bypassed with direct PocketBase writes. | Enforce validation, ownership, account state, immutable fields, and upload limits in PocketBase hooks and rules. |
| Moderation | P0 | There were no moderator roles, posting bans, disabled accounts, least-privilege actions, or durable moderation history. | Add roles, account states, explicit moderation routes, a staff UI, and append-only actions. |
| Architecture | P1 | Docs and Next.js defined separate record types, queries, mutations, sorting, voting, and error behavior. | Add `@homarr/workshop` as the only public contract and API client. |
| Performance | P1 | The browser downloaded every submission and full content, then searched, sorted, and filtered locally. | Server-side pagination/filtering/sorting; summary views exclude content. |
| Product | P1 | Website and app flows were disconnected and sharing still depended on manual content transfer. | Add contextual install and direct share actions backed by canonical exports. |
| UX/UI | P1 | A large bespoke Docusaurus component set and a separate Mantine modal produced inconsistent interaction and state behavior. | Use each host's native components with shared flows, copy rules, contracts, and states. |
| Accessibility | P1 | Several custom interactive elements bypassed semantic controls and touch targets; focus and reduced-motion behavior was incomplete. | Require semantic controls, visible focus, keyboard operation, 44px touch targets, AA contrast, and reduced motion. |
| Operations | P1 | A third-party PocketBase image and incomplete bootstrap docs made builds and recovery non-deterministic. | Build a pinned official binary with checksum verification; document health, backup, restore, OAuth, and upgrades. |
| Testing | P1 | Tests covered a narrow client schema and did not exercise API rules, roles, auth states, moderation, or cross-surface flows. | Add contract, backend permission-matrix, UI, and end-to-end coverage. |
| Documentation | P2 | Documentation advertised a supported self-hosted Workshop even though V2 is a central Homarr service. | Document the central service and local-development overrides only. |
## Decisions
- Public name: **Workshop**.
- Launch content: custom widgets (`homarr-custom-widget-v2`) and CSS (`homarr-custom-css-v1`).
- Identity: GitHub OAuth only.
- Community features: votes and reports; comments are removed.
- Hosting: one Homarr-operated image and origin at `homarr.dev`; PocketBase serves both compiled docs and `/api`.
- Deployment: the Workshop image is built locally from this repository by Compose or the Homarr deployment pipeline; no separate registry publication is maintained.
- Configuration: PocketBase superusers and GitHub OAuth are configured once in each persistent volume and are never rewritten from startup environment variables.
- Local development: PocketBase runs alone in Docker with a gitignored bind-mounted database; Docusaurus and contract watchers run on the host, and the production-like preview reuses the same data.
- Resilience: the public shell and successfully loaded query data are cached independently; the rest of Homarr never depends on Workshop availability.
- Publishing: immediate, with source inspection, reporting, and staff removal.
- Staff: GitHub-authenticated moderators/admins use `/workshop/admin`; PocketBase superusers are bootstrap/emergency access.
## Launch acceptance
- [x] Anonymous users can browse, search, filter, inspect, copy, and download without receiving full content in listing responses.
- [x] Members can publish, update, delete, vote, and report; ownership and schemas are enforced server-side.
- [x] Posting-banned users cannot create or update submissions but can browse, vote, and report.
- [x] Disabled accounts cannot authenticate or write, including with an existing token.
- [x] Moderators can resolve reports, remove submissions, posting-ban users, and disable/restore non-admin users.
- [x] Only admins can change roles; staff actions are append-only and attributable.
- [x] Homarr can export/share local widgets and CSS and validate/confirm Workshop installs.
- [x] Website and app implement loading, empty, offline, validation, permission, and destructive-action states.
- [x] Keyboard semantics, mobile layout, dark mode, reduced motion, and horizontal overflow received a browser smoke test.
- [x] Clean PocketBase startup, migration, health, backup, and restore procedures are reproducible and documented.
- [x] Documentation and Workshop API are built into one reproducible image and served from `homarr.dev` without production cross-origin requests.
- [ ] Add automated authenticated cross-surface E2E coverage before changing the PR from draft to ready.
## Deferred after V2
Comments, private Workshop deployments, automated approval queues, follows, collections, notifications, arbitrary package types, historical version installation, and personalized recommendations.
## Exit scorecard
The baseline has three P0, seven P1, and one P2 findings. The rewrite closes every product, security, architecture, moderation, performance, operations, documentation, UX, and DX finding. One P1 release-verification item remains: automated authenticated E2E across both public and in-app surfaces. Keep PR #5984 in draft until it lands.
| Gate | Result | Evidence |
| --------------------------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Branch ancestry | Pass | Current `origin/dev` is an ancestor; the prototype remains preserved at `origin/feat/widget-store-prototype-backup`. |
| Contract | Pass | Five Vitest cases cover widget/CSS fixtures, filenames, limits, screenshot validation, and round trips. |
| Backend | Pass | Disposable PocketBase integration matrix covers roles, account states, ownership forgery, schema rejection, duplicate votes, private reports, escalation, deletion cascades, and audit snapshots. |
| Backend operations | Pass | PocketBase 0.39.6 builds with checksum verification; clean dev boot, persistence, disposable integration tests, and the combined production preview migrate and become healthy. |
| Type safety | Pass | Scoped Turbo typecheck passes for Workshop, Docs, and Next.js. |
| Lint | Pass | Scoped lint exits clean; remaining warnings predate the rewrite. Workshop has zero warnings. |
| Format | Pass | Every changed supported file passes `oxfmt --check`; repository-wide Docs formatting still reports pre-existing drift. |
| Production builds | Pass | Docusaurus, Workshop contracts, the combined PocketBase preview image, and Next.js build under Node 24.18.0; path-filtered CI smoke-tests the combined image. |
| Browser smoke | Pass | Desktop/mobile discovery, disconnected API recovery, and a first-visit online-to-offline reload render without uncaught errors. |
| Automated cross-surface E2E | Open P1 | Authenticated website-to-Homarr publish/install and moderator flows still need browser automation. |
Final implementation scores (10 is best): security 9, architecture/DX 9, moderation 9, performance 9, responsive UI 9, theming 9, accessibility 8, automated testing 7. Accessibility remains below 9 until authenticated dialogs and destructive flows receive a full keyboard/screen-reader pass.
+2 -2
View File
@@ -6,7 +6,7 @@
},
"engines": {
"node": ">=24.18.0",
"pnpm": ">=11.11.0"
"pnpm": ">=11.10.0"
},
"packageManager": "pnpm@11.11.0"
"packageManager": "pnpm@11.10.0"
}
+10 -11
View File
@@ -3,7 +3,7 @@ import { describe, test } from "vitest";
import { createHomarrContainer } from "./shared/create-homarr-container";
import { createSqliteDbFileAsync } from "./shared/e2e-db";
import { startMockApiContainerAsync } from "./shared/mock-api-container";
import { startMockApiServerAsync } from "./shared/mock-api-server";
import { seedAdminUserAsync } from "./shared/seed-admin-user";
const adminCredentials = {
@@ -18,7 +18,7 @@ const updatedTemplate = '<Stack gap="xs"><Text>{data.title}</Text><Badge color="
describe("Custom JSX custom widgets", () => {
test("creates, previews, and edits a customJsx widget", async () => {
const mockApi = await startMockApiContainerAsync({
const mockApi = await startMockApiServerAsync({
title: "E2E Widget",
status: "online",
value: 42,
@@ -52,13 +52,12 @@ describe("Custom JSX custom widgets", () => {
await page.waitForURL("**/manage/custom-widgets/new", { timeout: 15_000 });
await page.getByRole("textbox", { name: "Name" }).fill("E2E Custom JSX");
await page.getByRole("textbox", { name: "URL", exact: true }).fill(`${mockApi.url}/status`);
await page.getByRole("button", { name: /Custom JSX/u }).click();
await page.getByRole("combobox", { name: "Network scope" }).click();
await page.getByRole("option", { name: "Private networks" }).click();
await page.getByRole("combobox", { name: "Display Type" }).click();
await page.getByRole("option", { name: "Custom JSX" }).click();
await page.getByLabel("JSX Template").fill(initialTemplate);
const previewPanel = page.getByRole("complementary", { name: "Preview" });
await previewPanel.getByRole("button", { name: "Test" }).click();
await page.getByRole("button", { name: "Test" }).first().click();
const previewPanel = page.locator(".mantine-Card-root").filter({ hasText: "Preview" });
await expect(previewPanel.getByRole("heading", { name: "E2E Widget" }).first()).toBeVisible({
timeout: 15_000,
});
@@ -66,18 +65,18 @@ describe("Custom JSX custom widgets", () => {
await page.getByRole("button", { name: "Create" }).first().click();
await expect(page.getByText('Widget "E2E Custom JSX" created successfully.')).toBeVisible({ timeout: 15_000 });
await page.waitForURL("**/manage/custom-widgets/edit/**", { timeout: 15_000 });
await expect(page.getByLabel("JSX Template")).toHaveText(initialTemplate);
await expect(page.getByLabel("JSX Template")).toHaveValue(initialTemplate);
await page.getByLabel("JSX Template").fill(updatedTemplate);
await expect(page.getByLabel("JSX Template")).toHaveText(updatedTemplate);
await expect(page.getByLabel("JSX Template")).toHaveValue(updatedTemplate);
await page.getByRole("button", { name: "Save" }).first().click();
await expect(page.getByText('Widget "E2E Custom JSX" updated successfully.')).toBeVisible({ timeout: 15_000 });
await expect(page.getByLabel("JSX Template")).toHaveText(updatedTemplate);
await expect(page.getByLabel("JSX Template")).toHaveValue(updatedTemplate);
} finally {
await browser.close();
await homarrContainer.stop();
await mockApi.stop();
await mockApi.close();
}
}, 120_000);
});
-27
View File
@@ -1,27 +0,0 @@
import { GenericContainer, Wait } from "testcontainers";
import type { StartedTestContainer } from "testcontainers";
export interface MockApiContainer {
url: string;
stop: () => Promise<void>;
}
export async function startMockApiContainerAsync(responseBody: unknown): Promise<MockApiContainer> {
const body = JSON.stringify(responseBody);
const script = [
'const { createServer } = require("node:http");',
`const body = ${JSON.stringify(body)};`,
'createServer((_request, response) => { response.writeHead(200, { "content-type": "application/json" }); response.end(body); }).listen(8080, "0.0.0.0");',
].join("\n");
const container: StartedTestContainer = await new GenericContainer("node:24-alpine")
.withCommand(["node", "-e", script])
.withExposedPorts(8080)
.withWaitStrategy(Wait.forHttp("/status", 8080))
.start();
return {
url: `http://${container.getIpAddress("bridge")}:8080`,
stop: async () => void (await container.stop()),
};
}
+35
View File
@@ -0,0 +1,35 @@
import { createServer } from "node:http";
import type { Server } from "node:http";
export interface MockApiServer {
url: string;
port: number;
close: () => Promise<void>;
}
export const startMockApiServerAsync = async (responseBody: unknown): Promise<MockApiServer> => {
const body = JSON.stringify(responseBody);
const server: Server = await new Promise((resolve) => {
const s = createServer((_req, res) => {
res.writeHead(200, { "Content-Type": "application/json" });
res.end(body);
});
s.listen(0, "0.0.0.0", () => resolve(s));
});
const address = server.address();
if (!address || typeof address === "string") {
throw new Error("Failed to start mock API server");
}
return {
port: address.port,
url: `http://host.docker.internal:${address.port}`,
close: () =>
new Promise((resolve, reject) => {
server.close((error) => (error ? reject(error) : resolve()));
}),
};
};
+12 -6
View File
@@ -6,8 +6,6 @@
"build": "cross-env CI=true turbo build",
"clean": "git clean -xdf node_modules",
"clean:workspaces": "turbo clean",
"check:custom-widgets": "pnpm --filter @homarr/custom-widgets check:architecture",
"check:custom-widget-bundle": "node ./scripts/check-custom-widget-bundle.mjs",
"ci:act:all": "bash scripts/ci/act.sh all",
"ci:act:cleanup": "bash scripts/ci/act.sh cleanup",
"ci:act:docker": "bash scripts/ci/act.sh docker",
@@ -23,6 +21,9 @@
"db:studio": "pnpm -F db studio",
"dev": "pnpm -F nextjs dev",
"dev:docs": "pnpm -F @homarr/docs dev",
"dev:workshop": "pnpm -F @homarr/workshop build && concurrently --kill-others-on-fail --names pocketbase,contracts,website \"pnpm dev:workshop:backend\" \"pnpm -F @homarr/workshop dev\" \"pnpm dev:workshop:website\"",
"dev:workshop:backend": "docker compose --env-file .env --file apps/workshop/docker-compose.yml up --build --remove-orphans",
"dev:workshop:website": "pnpm with-env sh -c 'WORKSHOP_API_URL=${WORKSHOP_API_URL:-http://localhost:${PB_EXPOSE_PORT:-8090}} pnpm -F @homarr/docs docusaurus start --no-open --host 0.0.0.0 --port ${WORKSHOP_WEB_PORT:-3003}'",
"docker:dev": "docker compose -f ./development/development.docker-compose.yml up",
"format": "turbo format --continue -- --check",
"format:fix": "turbo format --continue",
@@ -30,11 +31,15 @@
"lint:fix": "turbo lint --continue -- --fix",
"lint:ws": "pnpm dlx sherif@latest",
"release": "semantic-release",
"preview:workshop": "docker compose --env-file .env --file apps/workshop/docker-compose.preview.yml up --build --remove-orphans",
"reset:workshop": "sh apps/workshop/reset.sh",
"scripts:update-bug-report-template": "tsx ./scripts/update-bug-report-template.mts",
"scripts:update-readme-integrations": "tsx ./scripts/update-integration-list.mts",
"start": "concurrently \"pnpm with-env node apps/tasks/tasks.cjs\" \"pnpm with-env node apps/websocket/wssServer.cjs\" \"pnpm -F nextjs start\"",
"test": "cross-env NODE_ENV=development CI=true vitest run --project api-node --project dom --coverage.enabled",
"test:e2e": "cross-env NODE_ENV=development CI=true vitest run --project e2e",
"test": "cross-env NODE_ENV=development CI=true vitest run --exclude e2e --coverage.enabled ",
"test:e2e": "cross-env NODE_ENV=development CI=true vitest e2e",
"test:workshop-backend": "bash apps/workshop/tests/run-integration.sh",
"test:workshop-preview": "bash apps/workshop/tests/run-preview.sh",
"test:ui": "cross-env NODE_ENV=development CI=true vitest --exclude e2e --ui --coverage.enabled",
"typecheck": "turbo typecheck",
"with-env": "dotenv -e .env --"
@@ -55,6 +60,7 @@
"@vitejs/plugin-react": "catalog:",
"@vitest/coverage-v8": "catalog:",
"@vitest/ui": "catalog:",
"concurrently": "catalog:",
"conventional-changelog-conventionalcommits": "catalog:",
"cross-env": "catalog:",
"jsdom": "catalog:",
@@ -71,7 +77,7 @@
},
"engines": {
"node": ">=24.18.0",
"pnpm": ">=11.11.0"
"pnpm": ">=11.10.0"
},
"packageManager": "pnpm@11.11.0"
"packageManager": "pnpm@11.10.0"
}
+2 -2
View File
@@ -14,6 +14,7 @@
"./websocket": "./src/websocket.ts",
"./shared": "./src/shared.ts",
"./open-api": "./src/open-api.ts",
"./custom-widget/display-data": "./src/router/custom-widget/display-data.ts",
"./mcp": "./src/mcp.ts"
},
"scripts": {
@@ -29,7 +30,6 @@
"@homarr/core": "workspace:^0.1.0",
"@homarr/cron-job-status": "workspace:^0.1.0",
"@homarr/cron-jobs": "workspace:^0.1.0",
"@homarr/custom-widgets": "workspace:^0.1.0",
"@homarr/db": "workspace:^0.1.0",
"@homarr/definitions": "workspace:^0.1.0",
"@homarr/docker": "workspace:^0.1.0",
@@ -49,6 +49,7 @@
"@trpc/react-query": "catalog:",
"@trpc/server": "catalog:",
"@trpc/tanstack-react-query": "catalog:",
"jsonpath-plus": "catalog:",
"lodash.clonedeep": "catalog:",
"next": "catalog:",
"react": "catalog:",
@@ -56,7 +57,6 @@
"superjson": "catalog:",
"trpc-to-mcp": "catalog:",
"trpc-to-openapi": "catalog:",
"undici": "catalog:",
"zod": "catalog:"
},
"devDependencies": {
-2
View File
@@ -13,7 +13,6 @@ import { bazarrRouter } from "./router/widgets/bazarr";
import { serverSettingsRouter } from "./router/serverSettings";
import { beszelRouter } from "./router/widgets/beszel";
import { calendarRouter } from "./router/widgets/calendar";
import { customWidgetRouter } from "./router/custom-widget/custom-widget-router";
import { dnsHoleRouter } from "./router/widgets/dns-hole";
import { downloadsRouter } from "./router/widgets/downloads";
import { healthMonitoringRouter } from "./router/widgets/health-monitoring";
@@ -36,7 +35,6 @@ export const mcpRouter = createTRPCRouter({
serverSettings: serverSettingsRouter,
beszel: beszelRouter,
calendar: calendarRouter,
customWidget: customWidgetRouter,
dnsHole: dnsHoleRouter,
downloads: downloadsRouter,
healthMonitoring: healthMonitoringRouter,
+13 -17
View File
@@ -1,19 +1,15 @@
type HeaderSetter = Pick<Headers, "set">;
export const AUTH_HANDLERS: Record<
string,
(headers: HeaderSetter, url: URL, apiKey: string, headerName?: string) => void
> = {
bearer: (headers, _url, apiKey) => {
headers.set("Authorization", `Bearer ${apiKey}`);
},
apiKeyHeader: (headers, _url, apiKey, headerName) => {
headers.set(headerName ?? "X-API-Key", apiKey);
},
apiKeyQuery: (_headers, url, apiKey, headerName) => {
url.searchParams.set(headerName ?? "api_key", apiKey);
},
};
export const AUTH_HANDLERS: Record<string, (headers: Headers, url: URL, apiKey: string, headerName?: string) => void> =
{
bearer: (headers, _url, apiKey) => {
headers.set("Authorization", `Bearer ${apiKey}`);
},
apiKeyHeader: (headers, _url, apiKey, headerName) => {
headers.set(headerName ?? "X-API-Key", apiKey);
},
apiKeyQuery: (_headers, url, apiKey, headerName) => {
url.searchParams.set(headerName ?? "api_key", apiKey);
},
};
export function buildSecretMap(secrets: Array<{ kind: string; value: string }>): Record<string, string> {
const map: Record<string, string> = {};
@@ -24,7 +20,7 @@ export function buildSecretMap(secrets: Array<{ kind: string; value: string }>):
}
export function applyAuth(
headers: HeaderSetter,
headers: Headers,
url: URL,
authType: string,
secrets: Array<{ kind: string; value: string }>,
Loaded 100 of 277 files, more files were not shown because too many files have changed in this diff. Show more