diff --git a/apps/docs/docs/management/custom-widgets/index.mdx b/apps/docs/docs/management/custom-widgets/index.mdx
index 508dde70c..3490a5d41 100644
--- a/apps/docs/docs/management/custom-widgets/index.mdx
+++ b/apps/docs/docs/management/custom-widgets/index.mdx
@@ -90,15 +90,17 @@ while the replacement exists; deleting the v2 record surfaces the v1 entry as mi
legacy deletion is intentionally unavailable during v2 stabilization. Credentials are never included in prompts,
logs, or exports.
-For administrators adding widgets while editing a board, the Custom Widgets section includes **Install from
-Workshop**. Non-administrators do not see the Custom Widget picker or Workshop actions. The Workshop and API source
-review open above the widget picker without closing it. After import, the new definition appears in the picker. Its tile
+For administrators adding widgets while editing a board, the Custom Widgets section includes **Import from
+Workshop**. Non-administrators do not see the Custom Widget picker or Workshop actions. Selecting **Install widget**
+opens a two-step modal flow: first review the Workshop entry and its complete source in the read-only editor, then
+confirm API sources and credentials. The steps keep the same dimensions and the previous step remains available when
+you go back. After import, the new definition appears in the picker. Its tile
waits for the board to be saved before server requests run and loads automatically after a successful save; no page
reload is required.
## Import and Workshop
-Import review shows origins, authentication modes, network scopes, methods, actions, and permissions. Every source has a suggested URL, but private and loopback sources must be confirmed or replaced for the current Homarr installation. The same screen accepts API keys, tokens, usernames, and passwords; credentials remain optional at install time and missing values stay visible on the Custom Widgets page.
+The install flow always links to the corresponding public Workshop entry using `WORKSHOP_WEB_URL`. Import confirmation shows origins, authentication modes, network scopes, methods, actions, and permissions. Every source has a suggested URL, but private and loopback sources must be confirmed or replaced for the current Homarr installation. The same screen accepts API keys, tokens, usernames, and passwords; credentials remain optional at install time and missing values stay visible on the Custom Widgets page.
For example, installing **Tautulli Activity** asks for both the user's Tautulli URL and API key. A stable public source such as PokéAPI keeps its published URL without creating unnecessary setup work.
diff --git a/apps/docs/docs/management/workshop/index.mdx b/apps/docs/docs/management/workshop/index.mdx
index 1d18f2c97..a8c3e153c 100644
--- a/apps/docs/docs/management/workshop/index.mdx
+++ b/apps/docs/docs/management/workshop/index.mdx
@@ -78,8 +78,8 @@ that primarily promotes unrelated products or services.
You can install workshop entries without leaving Homarr:
-- **Custom widgets (Homarr administrators only)** — go to **Manage → Custom Widgets**, or use **Install from Workshop** in the
- board widget picker. Pick an entry, review its capabilities, and enter any required local secrets before import.
+- **Custom widgets (Homarr administrators only)** — go to **Manage → Custom Widgets**, or use **Import from Workshop** in the
+ board widget picker. Choose **Install widget**, review the complete source and capabilities, then confirm API sources and any local secrets in the second modal step.
- **Custom CSS from Workshop (Homarr administrators only)** — open a board's **Settings → Custom CSS**, click **Install from Workshop**, and
pick a theme to load it into the editor before saving.
diff --git a/apps/nextjs/src/app/[locale]/layout.tsx b/apps/nextjs/src/app/[locale]/layout.tsx
index 7be6cafa8..5c5171d63 100644
--- a/apps/nextjs/src/app/[locale]/layout.tsx
+++ b/apps/nextjs/src/app/[locale]/layout.tsx
@@ -160,6 +160,7 @@ export default async function Layout(props: {
+
diff --git a/apps/nextjs/src/components/custom-widgets/custom-widget-import-dialog.tsx b/apps/nextjs/src/components/custom-widgets/custom-widget-import-dialog.tsx
index f406a99be..af28e22e5 100644
--- a/apps/nextjs/src/components/custom-widgets/custom-widget-import-dialog.tsx
+++ b/apps/nextjs/src/components/custom-widgets/custom-widget-import-dialog.tsx
@@ -2,6 +2,7 @@
import { useRouter } from "next/navigation";
import { useEffect, useMemo, useState } from "react";
+import type { ModalProps } from "@mantine/core";
import { clientApi } from "@homarr/api/client";
import { revalidatePathActionAsync } from "@homarr/common/client";
@@ -27,6 +28,8 @@ interface CustomWidgetImportDialogProps {
legacyId?: string;
stackId?: string;
zIndex?: number;
+ modalProps?: Pick;
+ labels?: { title?: string; description?: string; cancel?: string; confirm?: string };
onClose(): void;
onImported?(result: { id: string }): void;
}
@@ -37,6 +40,8 @@ export function CustomWidgetImportDialog({
legacyId,
stackId,
zIndex,
+ modalProps,
+ labels,
onClose,
onImported,
}: CustomWidgetImportDialogProps) {
@@ -103,14 +108,17 @@ export function CustomWidgetImportDialog({
opened={opened}
stackId={stackId}
zIndex={zIndex}
+ {...modalProps}
review={review}
pending={pending}
confirmDisabled={!isCustomWidgetSourceSetupReady(setups, values)}
onClose={onClose}
onConfirm={importWidget}
messages={{
- title: legacyId ? t("importReview.migrationTitle") : t("importReview.title"),
- description: legacyId ? t("importReview.migrationDescription") : t("importReview.description"),
+ title: legacyId ? t("importReview.migrationTitle") : (labels?.title ?? t("importReview.title")),
+ description: legacyId
+ ? t("importReview.migrationDescription")
+ : (labels?.description ?? t("importReview.description")),
name: t("importReview.name"),
origin: t("importReview.origin"),
authentication: t("importReview.authentication"),
@@ -119,8 +127,8 @@ export function CustomWidgetImportDialog({
permissions: t("importReview.permissions"),
actionWarningTitle: t("importReview.actionWarning.title"),
actionWarningDescription: t("importReview.actionWarning.description"),
- cancel: t("importReview.cancel"),
- confirm: legacyId ? t("importReview.confirmMigration") : t("importReview.confirm"),
+ cancel: labels?.cancel ?? t("importReview.cancel"),
+ confirm: legacyId ? t("importReview.confirmMigration") : (labels?.confirm ?? t("importReview.confirm")),
permission: (permission) => t(`preview.request.permission.${permission}` as never),
}}
>
diff --git a/apps/nextjs/src/components/workshop/workshop-browser.tsx b/apps/nextjs/src/components/workshop/workshop-browser.tsx
index cdc042dd5..548c4c7d2 100644
--- a/apps/nextjs/src/components/workshop/workshop-browser.tsx
+++ b/apps/nextjs/src/components/workshop/workshop-browser.tsx
@@ -1,6 +1,6 @@
"use client";
-import { useEffect, useMemo, useState } from "react";
+import { useEffect, useId, useMemo, useState } from "react";
import {
Alert,
Badge,
@@ -22,11 +22,14 @@ import {
TextInput,
Title,
} from "@mantine/core";
+import type { ModalProps } from "@mantine/core";
import { useDebouncedValue, useDisclosure } from "@mantine/hooks";
import {
IconAlertTriangle,
+ IconArrowRight,
IconBuildingStore,
IconDownload,
+ IconExternalLink,
IconFlag,
IconRefresh,
IconSearch,
@@ -35,6 +38,8 @@ import {
} from "@tabler/icons-react";
import { CUSTOM_WIDGET_SCHEMA } from "@homarr/custom-widgets/core";
import type { HomarrCustomWidgetV2 } from "@homarr/custom-widgets/core";
+import { ReadOnlyCustomWidgetCode } from "@homarr/custom-widgets/workbench";
+import type { CustomWidgetEditorMessages } from "@homarr/custom-widgets/workbench";
import {
useWorkshopCssImportMutation,
useWorkshopQuery,
@@ -58,7 +63,7 @@ import {
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useScopedI18n } from "@homarr/translation/client";
-import { createWorkshopClient } from "./workshop-client";
+import { createWorkshopClient, getWorkshopWebUrl } from "./workshop-client";
function downloadWorkshopSubmission(submission: WorkshopSubmissionDetail) {
const url = URL.createObjectURL(
@@ -75,9 +80,21 @@ interface WorkshopBrowserProps {
type?: WorkshopSubmissionType;
onInstall?(widget: HomarrCustomWidgetV2): Promise;
onUseCss?(css: string): void;
+ modalStack?: {
+ modalProps: Pick;
+ details: WorkshopModalStep;
+ report: WorkshopModalStep;
+ };
}
-export function WorkshopBrowser({ type = "customWidget", onInstall, onUseCss }: WorkshopBrowserProps) {
+interface WorkshopModalStep {
+ opened: boolean;
+ stackId?: string;
+ open(): void;
+ close(): void;
+}
+
+export function WorkshopBrowser({ type = "customWidget", onInstall, onUseCss, modalStack }: WorkshopBrowserProps) {
const t = useScopedI18n("workshop");
const client = useMemo(createWorkshopClient, []);
const [user, setUser] = useState(null);
@@ -86,12 +103,25 @@ export function WorkshopBrowser({ type = "customWidget", onInstall, onUseCss }:
const [search, setSearch] = useState("");
const [debouncedSearch] = useDebouncedValue(search, 250);
const [selectedId, setSelectedId] = useState(null);
- const [reportOpened, reportControls] = useDisclosure(false);
+ const [internalReportOpened, reportControls] = useDisclosure(false);
const [reportCategory, setReportCategory] = useState("other");
const [reportExplanation, setReportExplanation] = useState("");
const [loginPending, setLoginPending] = useState(false);
const [loginError, setLoginError] = useState(null);
const [cssAwaitingConfirmation, setCssAwaitingConfirmation] = useState(null);
+ const workshopWebUrl = useMemo(getWorkshopWebUrl, []);
+ const detailsOpened = modalStack?.details.opened ?? selectedId !== null;
+ const reportOpened = modalStack?.report.opened ?? internalReportOpened;
+ const openDetails = (id: string) => {
+ setSelectedId(id);
+ modalStack?.details.open();
+ };
+ const closeDetails = () => {
+ modalStack?.details.close();
+ setSelectedId(null);
+ };
+ const openReport = modalStack?.report.open ?? reportControls.open;
+ const closeReport = modalStack?.report.close ?? reportControls.close;
useEffect(() => {
const unsubscribe = client.subscribeToAuth(setUser);
@@ -123,7 +153,7 @@ export function WorkshopBrowser({ type = "customWidget", onInstall, onUseCss }:
})
.finally(() => setLoginPending(false));
};
- const install = useWorkshopWidgetImportMutation(onInstall, () => setSelectedId(null));
+ const install = useWorkshopWidgetImportMutation(onInstall);
const useCss = useWorkshopCssImportMutation(onUseCss, () => {
setCssAwaitingConfirmation(null);
setSelectedId(null);
@@ -135,7 +165,7 @@ export function WorkshopBrowser({ type = "customWidget", onInstall, onUseCss }:
{ submission: selectedId ?? "", category: reportCategory, explanation: reportExplanation },
{
onSuccess: () => {
- reportControls.close();
+ closeReport();
setReportExplanation("");
showSuccessNotification({ title: t("reportSent"), message: t("reportSentDescription") });
},
@@ -161,25 +191,38 @@ export function WorkshopBrowser({ type = "customWidget", onInstall, onUseCss }:
- {user ? (
-
- {user.name}
- {
- client.signOut();
- setUser(null);
- }}
- >
- {t("signOut")}
-
-
- ) : (
-
- {t("signIn")}
+
+ }
+ >
+ {t("openCommunity")}
- )}
+ {user ? (
+ <>
+ {user.name}
+ {
+ client.signOut();
+ setUser(null);
+ }}
+ >
+ {t("signOut")}
+
+ >
+ ) : (
+
+ {t("signIn")}
+
+ )}
+
}>
@@ -260,9 +303,6 @@ export function WorkshopBrowser({ type = "customWidget", onInstall, onUseCss }:
{item.upvotes} · {item.downvotes}
-
- {item.widgetSchema}
-
{item.outdated && Outdated }
{item.reportCount > 0 && (
@@ -277,8 +317,8 @@ export function WorkshopBrowser({ type = "customWidget", onInstall, onUseCss }:
{item.description || t("noDescription")}
- setSelectedId(item.id)}>
- {type === "customCss" ? t("inspectCss") : t("inspect")}
+ openDetails(item.id)}>
+ {type === "customCss" ? t("inspectCss") : t("install")}
@@ -294,21 +334,38 @@ export function WorkshopBrowser({ type = "customWidget", onInstall, onUseCss }:
)}
setSelectedId(null)}
- title={detail.data?.title ?? t("details")}
- size="xl"
+ {...modalStack?.modalProps}
+ opened={detailsOpened}
+ onClose={closeDetails}
+ stackId={modalStack?.details.stackId}
+ title={type === "customCss" ? t("inspectCss") : t("install")}
+ size={modalStack?.modalProps.size ?? "xl"}
>
{detail.isPending ? (
) : detail.isError || !detail.data ? (
{detail.error instanceof Error ? detail.error.message : t("loadError")}
) : (
-
- {detail.data.description}
-
- {t("author", { name: detail.data.authorName || "Community member" })}
-
+
+
+
+ {detail.data.title}
+
+ {t("author", { name: detail.data.authorName || "Community member" })}
+
+
+ }
+ >
+ {t("openCommunity")}
+
+
+ {detail.data.description && {detail.data.description} }
{detail.data.outdated && (
}>
The author marked this submission as outdated. Review it carefully before importing.
@@ -346,9 +403,7 @@ export function WorkshopBrowser({ type = "customWidget", onInstall, onUseCss }:
{t("installErrorDescription")}
)}
-
- {detail.data.content}
-
+
}
disabled={!user}
- onClick={reportControls.open}
+ onClick={openReport}
>
{t("report")}
@@ -390,13 +445,14 @@ export function WorkshopBrowser({ type = "customWidget", onInstall, onUseCss }:
}
onClick={() =>
detailValidation?.success &&
typeof detailValidation.data !== "string" &&
install.mutate(detailValidation.data)
}
>
- {t("install")}
+ {t("continueInstall")}
)}
{onUseCss && (
@@ -424,7 +480,13 @@ export function WorkshopBrowser({ type = "customWidget", onInstall, onUseCss }:
)}
-
+
t("componentReference.count", { count }),
+ insertStarter: t("action.insertStarter"),
+ format: t("action.format"),
+ copy: t("action.copy"),
+ copied: t("action.copied"),
+ schema: t("action.schema"),
+ schemaTab: t("reference.schema"),
+ minimalTab: t("reference.minimal"),
+ fullTab: t("reference.full"),
+ errors: (count) => t("status.errors", { count }),
+ warnings: (count) => t("status.warnings", { count }),
+ ready: t("status.ready"),
+ position: (cursor) => t("status.position", cursor),
+ characters: (count, limit) =>
+ limit ? t("status.charactersWithLimit", { count, limit }) : t("status.characters", { count }),
+ diagnosticsTitle: t("diagnostics.title"),
+ diagnostic: (diagnostic) => diagnostic.value ?? diagnostic.code,
+ };
+ let displayValue = value;
+ if (language === "json") {
+ try {
+ displayValue = JSON.stringify(JSON.parse(value) as unknown, null, 2);
+ } catch {}
+ }
+ return (
+
+ );
+}
+
function CapabilitySummary({ widget }: { widget: HomarrCustomWidgetV2 }) {
const t = useScopedI18n("workshop");
return (
diff --git a/apps/nextjs/src/components/workshop/workshop-client.ts b/apps/nextjs/src/components/workshop/workshop-client.ts
index 4d68e542f..95618f512 100644
--- a/apps/nextjs/src/components/workshop/workshop-client.ts
+++ b/apps/nextjs/src/components/workshop/workshop-client.ts
@@ -1,5 +1,5 @@
import { WorkshopBackend } from "@homarr/workshop/backend";
-import { WORKSHOP_API_URL } from "@homarr/workshop/schema";
+import { WORKSHOP_API_URL, WORKSHOP_WEB_URL } from "@homarr/workshop/schema";
let browserClient: WorkshopBackend | undefined;
@@ -12,3 +12,12 @@ export function createWorkshopClient() {
browserClient = new WorkshopBackend(runtimeUrl || WORKSHOP_API_URL);
return browserClient;
}
+
+export function getWorkshopWebUrl(submissionId?: string) {
+ const runtimeUrl =
+ typeof document === "undefined"
+ ? undefined
+ : document.querySelector('meta[name="homarr-workshop-web-url"]')?.content;
+ const workshopUrl = (runtimeUrl || WORKSHOP_WEB_URL).replace(/\/+$/u, "");
+ return submissionId ? `${workshopUrl}/${encodeURIComponent(submissionId)}` : workshopUrl;
+}
diff --git a/apps/nextjs/src/components/workshop/workshop-install-button.tsx b/apps/nextjs/src/components/workshop/workshop-install-button.tsx
index d9f1813e6..2f00b14f8 100644
--- a/apps/nextjs/src/components/workshop/workshop-install-button.tsx
+++ b/apps/nextjs/src/components/workshop/workshop-install-button.tsx
@@ -2,7 +2,8 @@
import type { ReactNode } from "react";
import { useState } from "react";
-import { Button, getDefaultZIndex, Modal, useModalsStack } from "@mantine/core";
+import { Button, Modal, useModalsStack } from "@mantine/core";
+import type { ModalProps } from "@mantine/core";
import { IconBuildingStore } from "@tabler/icons-react";
import type { HomarrCustomWidgetV2 } from "@homarr/custom-widgets/core";
@@ -16,11 +17,20 @@ interface WorkshopInstallButtonProps {
fullWidth?: boolean;
}
+const installFlowModalProps = {
+ size: "90%",
+ styles: {
+ content: { display: "flex", flexDirection: "column", height: "min(85dvh, 900px)" },
+ body: { flex: 1, overflowY: "auto" },
+ },
+} satisfies Pick;
+
export function WorkshopInstallButton({ children, fullWidth }: WorkshopInstallButtonProps = {}) {
const t = useScopedI18n("workshop");
- const stack = useModalsStack(["workshop", "review"]);
+ const stack = useModalsStack(["workshop", "details", "report", "review"]);
const [pendingWidget, setPendingWidget] = useState(null);
- const zIndex = getDefaultZIndex("modal") + 10;
+ const detailsModal = stack.register("details");
+ const reportModal = stack.register("report");
const closeReview = () => {
stack.close("review");
@@ -35,11 +45,26 @@ export function WorkshopInstallButton({ children, fullWidth }: WorkshopInstallBu
onClick={() => stack.open("workshop")}
fullWidth={fullWidth}
>
- {children ?? t("title")}
+ {children ?? t("importFromWorkshop")}
-
+
stack.open("details"),
+ close: () => stack.close("details"),
+ },
+ report: {
+ opened: reportModal.opened,
+ stackId: reportModal.stackId,
+ open: () => stack.open("report"),
+ close: () => stack.close("report"),
+ },
+ }}
onInstall={async (widget) => {
setPendingWidget(widget);
stack.open("review");
@@ -49,7 +74,13 @@ export function WorkshopInstallButton({ children, fullWidth }: WorkshopInstallBu
{
stack.closeAll();
diff --git a/apps/nextjs/src/components/workshop/workshop-install-flow.spec.ts b/apps/nextjs/src/components/workshop/workshop-install-flow.spec.ts
new file mode 100644
index 000000000..2415d0370
--- /dev/null
+++ b/apps/nextjs/src/components/workshop/workshop-install-flow.spec.ts
@@ -0,0 +1,34 @@
+// @vitest-environment jsdom
+
+import { readFileSync } from "node:fs";
+import { afterEach, describe, expect, it } from "vitest";
+
+import { getWorkshopWebUrl } from "./workshop-client";
+
+describe("Workshop widget install flow", () => {
+ afterEach(() => {
+ document.head.innerHTML = "";
+ });
+
+ it("links to the runtime-configured Workshop entry", () => {
+ document.head.innerHTML = ' ';
+
+ expect(getWorkshopWebUrl()).toBe("https://preview.example/workshop");
+ expect(getWorkshopWebUrl("widget/id")).toBe("https://preview.example/workshop/widget%2Fid");
+ });
+
+ it("uses one registered modal stack for browsing, details, reports, and confirmation", () => {
+ const installButton = readFileSync(
+ `${process.cwd()}/apps/nextjs/src/components/workshop/workshop-install-button.tsx`,
+ "utf8",
+ );
+ const browser = readFileSync(`${process.cwd()}/apps/nextjs/src/components/workshop/workshop-browser.tsx`, "utf8");
+
+ expect(installButton).toContain('useModalsStack(["workshop", "details", "report", "review"])');
+ expect(installButton).toContain("");
+ expect(installButton).toContain('height: "min(85dvh, 900px)"');
+ expect(installButton).not.toContain("getDefaultZIndex");
+ expect(browser).toContain("ReadOnlyCustomWidgetCode");
+ expect(browser).not.toContain("{item.widgetSchema}");
+ });
+});
diff --git a/packages/custom-widgets/src/workbench/import-review.tsx b/packages/custom-widgets/src/workbench/import-review.tsx
index 365c4b0fb..ea1300502 100644
--- a/packages/custom-widgets/src/workbench/import-review.tsx
+++ b/packages/custom-widgets/src/workbench/import-review.tsx
@@ -30,6 +30,8 @@ export interface ImportReviewDialogProps {
children?: ReactNode;
stackId?: string;
zIndex?: ModalProps["zIndex"];
+ size?: ModalProps["size"];
+ styles?: ModalProps["styles"];
onClose(): void;
onConfirm(): void;
}
@@ -54,6 +56,8 @@ export function ImportReviewDialog({
children,
stackId,
zIndex,
+ size,
+ styles,
onClose,
onConfirm,
}: ImportReviewDialogProps) {
@@ -63,9 +67,10 @@ export function ImportReviewDialog({
onClose={onClose}
title={messages.title}
centered
- size="lg"
stackId={stackId}
zIndex={zIndex}
+ size={size ?? "lg"}
+ styles={styles}
>
{review && (
diff --git a/packages/translation/src/lang/en.json b/packages/translation/src/lang/en.json
index b75a89d05..ec29cd732 100644
--- a/packages/translation/src/lang/en.json
+++ b/packages/translation/src/lang/en.json
@@ -1130,7 +1130,8 @@
"installError": "Could not install widget",
"installErrorDescription": "The Workshop submission is not a valid Custom JSX v2 widget.",
"title": "Workshop",
- "openCommunity": "Open public Workshop",
+ "importFromWorkshop": "Import from Workshop",
+ "openCommunity": "Open Workshop",
"description": "Community Custom JSX v2 widgets. Inspect the source and network capabilities before installing.",
"descriptionCss": "Community Custom CSS themes. Preview every rule before importing it into a board.",
"securityNotice": "Workshop widgets can call their declared API sources and perform declared actions with credentials you configure locally. Secrets are never included.",
@@ -1150,9 +1151,8 @@
"emptyCss": "No CSS themes match this search.",
"noDescription": "No description",
"author": "by {name}",
- "inspect": "Inspect widget",
"inspectCss": "Inspect CSS",
- "details": "Widget details",
+ "sourceCode": "Workshop entry",
"loadError": "This submission could not be loaded.",
"sources": "Declared API sources",
"requests": "Declared queries and actions",
@@ -1160,6 +1160,10 @@
"export": "Export",
"report": "Report",
"install": "Install widget",
+ "continueInstall": "Continue installation",
+ "confirmInstallTitle": "Confirm widget installation",
+ "confirmInstallDescription": "Confirm where this widget connects and add any credentials required by your Homarr installation.",
+ "backToWidget": "Back to widget",
"useCss": "Import CSS",
"signInHint": "Sign in to vote or report. Installing does not require a Workshop account.",
"reportTitle": "Report submission",