From 9037da96b5857bdcc3d512a58bba648e93f6a387 Mon Sep 17 00:00:00 2001 From: Thomas Camlong Date: Mon, 13 Jul 2026 19:24:31 +0200 Subject: [PATCH] feat: rebuild workshop --- .env.example | 4 +- .../docs/management/custom-widgets/index.mdx | 4 + apps/docs/docs/management/workshop/index.mdx | 40 ++ apps/docs/docusaurus.config.ts | 20 + apps/docs/package.json | 1 + .../src/components/workshop/WorkshopAdmin.tsx | 300 ++++++++ .../workshop/WorkshopDetailRoutePage.tsx | 16 + .../src/components/workshop/WorkshopPage.tsx | 668 ++++++++++++++++++ .../components/workshop/workshop.module.css | 299 ++++++++ apps/docs/src/pages/workshop/admin/index.tsx | 16 + apps/docs/src/pages/workshop/index.tsx | 16 + apps/nextjs/package.json | 1 + .../boards/[name]/settings/_customCss.tsx | 9 +- .../custom-widgets/_custom-widget-actions.tsx | 13 + .../[locale]/manage/custom-widgets/page.tsx | 2 + .../nextjs/src/app/[locale]/manage/layout.tsx | 7 + .../src/app/[locale]/manage/workshop/page.tsx | 18 + .../components/workshop/workshop-actions.tsx | 72 ++ .../components/workshop/workshop-browser.tsx | 339 +++++++++ apps/store/.env.example | 5 + apps/store/.gitignore | 4 + apps/store/Dockerfile | 31 + apps/store/README.md | 45 ++ apps/store/docker-compose.yml | 24 + apps/store/entrypoint.sh | 14 + apps/store/pb_hooks/workshop-utils.js | 102 +++ apps/store/pb_hooks/workshop.pb.js | 168 +++++ .../pb_migrations/1783900000_workshop_v2.js | 248 +++++++ apps/store/tests/run-integration.sh | 26 + apps/store/tests/workshop.integration.mjs | 165 +++++ audit.md | 78 ++ package.json | 2 + packages/translation/src/lang/en.json | 56 ++ packages/workshop/.gitignore | 1 + packages/workshop/package.json | 46 ++ packages/workshop/src/client.ts | 289 ++++++++ packages/workshop/src/index.ts | 2 + packages/workshop/src/schema.spec.ts | 67 ++ packages/workshop/src/schema.ts | 175 +++++ packages/workshop/tsconfig.json | 8 + pnpm-lock.yaml | 36 + pnpm-workspace.yaml | 1 + 42 files changed, 3436 insertions(+), 2 deletions(-) create mode 100644 apps/docs/docs/management/workshop/index.mdx create mode 100644 apps/docs/src/components/workshop/WorkshopAdmin.tsx create mode 100644 apps/docs/src/components/workshop/WorkshopDetailRoutePage.tsx create mode 100644 apps/docs/src/components/workshop/WorkshopPage.tsx create mode 100644 apps/docs/src/components/workshop/workshop.module.css create mode 100644 apps/docs/src/pages/workshop/admin/index.tsx create mode 100644 apps/docs/src/pages/workshop/index.tsx create mode 100644 apps/nextjs/src/app/[locale]/manage/workshop/page.tsx create mode 100644 apps/nextjs/src/components/workshop/workshop-actions.tsx create mode 100644 apps/nextjs/src/components/workshop/workshop-browser.tsx create mode 100644 apps/store/.env.example create mode 100644 apps/store/.gitignore create mode 100644 apps/store/Dockerfile create mode 100644 apps/store/README.md create mode 100644 apps/store/docker-compose.yml create mode 100644 apps/store/entrypoint.sh create mode 100644 apps/store/pb_hooks/workshop-utils.js create mode 100644 apps/store/pb_hooks/workshop.pb.js create mode 100644 apps/store/pb_migrations/1783900000_workshop_v2.js create mode 100755 apps/store/tests/run-integration.sh create mode 100644 apps/store/tests/workshop.integration.mjs create mode 100644 audit.md create mode 100644 packages/workshop/.gitignore create mode 100644 packages/workshop/package.json create mode 100644 packages/workshop/src/client.ts create mode 100644 packages/workshop/src/index.ts create mode 100644 packages/workshop/src/schema.spec.ts create mode 100644 packages/workshop/src/schema.ts create mode 100644 packages/workshop/tsconfig.json diff --git a/.env.example b/.env.example index 22dca3adb..9c7bc8292 100644 --- a/.env.example +++ b/.env.example @@ -66,4 +66,6 @@ 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 \ No newline at end of file +# DEMO_READ_ONLY=false +# Central Homarr Workshop API. Override only when developing the Workshop locally. +# NEXT_PUBLIC_WORKSHOP_API_URL=http://localhost:8090 diff --git a/apps/docs/docs/management/custom-widgets/index.mdx b/apps/docs/docs/management/custom-widgets/index.mdx index 0395253dc..c33e00876 100644 --- a/apps/docs/docs/management/custom-widgets/index.mdx +++ b/apps/docs/docs/management/custom-widgets/index.mdx @@ -82,6 +82,10 @@ Custom widgets can be exported and imported as JSON files. To export, select the The import format uses `"$schema": "homarr-custom-widget-v2"` to identify the widget version. +## Community Workshop + +Use **Install widget** on **Management → Workshop** to inspect and import community widgets. Use **Share in Workshop** from a widget's action menu to publish its canonical export without manually copying JSON. Secrets are never included in exports and must be configured on each Homarr instance. + ## Available Mantine Components for Custom JSX When using the `customJsx` display type, your template has access to a whitelist of [Mantine v9](https://mantine.dev) components. Unrecognized components, event handlers, and dangerous tags are stripped for security. diff --git a/apps/docs/docs/management/workshop/index.mdx b/apps/docs/docs/management/workshop/index.mdx new file mode 100644 index 000000000..977ec05ff --- /dev/null +++ b/apps/docs/docs/management/workshop/index.mdx @@ -0,0 +1,40 @@ +--- +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. + +- 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 + +Use **Share in Workshop** beside a custom widget or in a board's custom CSS settings. Sign in with GitHub, provide a public title and description, inspect the exported source, and publish it. You can also publish from the Workshop website by pasting an exported widget or CSS source. + +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. Workshop moderators can remove submissions, prevent an account from publishing, disable an account, and resolve reports. These actions require a reason and are recorded. + +The Workshop is a central service operated by Homarr at `store.homarr.dev`. Running the backend locally is supported for contributors developing or testing this feature, not as a private production marketplace. diff --git a/apps/docs/docusaurus.config.ts b/apps/docs/docusaurus.config.ts index 0edf8a8f3..354da65aa 100644 --- a/apps/docs/docusaurus.config.ts +++ b/apps/docs/docusaurus.config.ts @@ -16,6 +16,9 @@ const config: Config = { defaultLocale: "en", locales: ["en"], }, + customFields: { + workshopApiUrl: process.env.NEXT_PUBLIC_WORKSHOP_API_URL ?? "https://store.homarr.dev", + }, onBrokenLinks: "throw", onBrokenAnchors: "throw", onDuplicateRoutes: "throw", @@ -124,6 +127,11 @@ const config: Config = { position: "left", to: "/blog", }, + { + label: "Workshop", + position: "left", + to: "/workshop", + }, { label: "About us", position: "left", @@ -309,6 +317,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 { diff --git a/apps/docs/package.json b/apps/docs/package.json index da3a8a1cd..9be4f01cc 100644 --- a/apps/docs/package.json +++ b/apps/docs/package.json @@ -27,6 +27,7 @@ "@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:", diff --git a/apps/docs/src/components/workshop/WorkshopAdmin.tsx b/apps/docs/src/components/workshop/WorkshopAdmin.tsx new file mode 100644 index 000000000..9e5e230c2 --- /dev/null +++ b/apps/docs/src/components/workshop/WorkshopAdmin.tsx @@ -0,0 +1,300 @@ +import React, { useCallback, useEffect, useMemo, useState } from "react"; +import useDocusaurusContext from "@docusaurus/useDocusaurusContext"; + +import { + WORKSHOP_API_URL, + WorkshopClient, + type WorkshopAccountState, + type WorkshopModerationAction, + type WorkshopReport, + type WorkshopRole, + type WorkshopUser, +} from "@homarr/workshop"; + +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() { + const { siteConfig } = useDocusaurusContext(); + const client = useMemo( + () => new WorkshopClient((siteConfig.customFields?.workshopApiUrl as string | undefined) ?? WORKSHOP_API_URL), + [siteConfig], + ); + const [user, setUser] = useState(null); + const [reports, setReports] = useState([]); + const [users, setUsers] = useState([]); + const [actions, setActions] = useState([]); + const [pending, setPending] = useState(null); + const [error, setError] = useState(""); + + const load = useCallback(async () => { + const current = await client.refreshAuth(); + setUser(current); + if (!current || (current.role !== "moderator" && current.role !== "admin")) return; + try { + const [nextReports, nextUsers, nextActions] = await Promise.all([ + client.listReports(), + client.listUsers(), + client.listModerationActions(), + ]); + setReports(nextReports); + setUsers(nextUsers); + setActions(nextActions); + } catch (caught) { + setError(caught instanceof Error ? caught.message : "Could not load moderation data"); + } + }, [client]); + useEffect(() => client.subscribeToAuth(setUser), [client]); + useEffect(() => { + void load(); + }, [load]); + + if (!user) + return ( +
+

Workshop moderation

+

Sign in with a promoted GitHub account to continue.

+ +
+ ); + if (user.role === "member") + return ( +
+

Workshop moderation

+
+ Your account does not have moderation access. +
+
+ ); + + return ( +
+
+
+

Workshop moderation

+

Review reports and apply the smallest action needed. Every action requires a reason and is recorded.

+
+
+ {error && ( +
+ {error} +
+ )} +
+

Open reports

+ {reports.length === 0 ? ( +

No open reports.

+ ) : ( +
+ + + + + + + + + + + + {reports.map((report) => ( + + + + + + + + ))} + +
CategorySubmissionDetailsCreatedAction
{report.category}{report.submission}{report.explanation}{new Date(report.created).toLocaleString()} +
+ + +
+
+
+ )} +
+
+

Community accounts

+
+ + + + + + + + + + + {users.map((account) => ( + + + + + + + ))} + +
UserRoleStateModerate
{account.displayName}{account.role}{account.state} + {account.id === user.id ? ( + "Current account" + ) : ( +
+ + + {user.role === "admin" && ( + + )} +
+ )} +
+
+
+
+

Recent actions

+
+ + + + + + + + + + + {actions.map((action) => ( + + + + + + + ))} + +
ActionTargetReasonCreated
{action.action} + {action.targetType}:{action.targetId} + {action.reason}{new Date(action.created).toLocaleString()}
+
+
+ {pending && ( + setPending(null)} + onConfirm={async (reason) => { + if (pending.kind === "report") await client.resolveReport(pending.id, pending.status, reason); + else if (pending.kind === "state") await client.updateAccountState(pending.id, pending.state, reason); + else await client.updateRole(pending.id, pending.role, reason); + setPending(null); + await load(); + }} + /> + )} +
+ ); +} + +function ReasonDialog({ + action, + onClose, + onConfirm, +}: { + action: PendingAction; + onClose: () => void; + onConfirm: (reason: string) => Promise; +}) { + const [reason, setReason] = useState(""); + const [error, setError] = useState(""); + const [saving, setSaving] = useState(false); + return ( +
+ +

Confirm moderation action

+

+ This will apply {action.kind} to the selected record. +

+
{ + event.preventDefault(); + setSaving(true); + try { + await onConfirm(reason); + } catch (caught) { + setError(caught instanceof Error ? caught.message : "Action failed"); + setSaving(false); + } + }} + > +