From 206905ea2d08e48a58206cc0bf6ba455be7d4c88 Mon Sep 17 00:00:00 2001 From: Thomas Camlong Date: Mon, 17 Aug 2026 01:14:38 +0200 Subject: [PATCH] feat(workshop): rebuild install and publish as pages Replace the stacked install modals (browse -> details -> import review) with page-based flows so the primary action is always reachable: - /manage/custom-widgets/workshop browses submissions as a card grid with large previews, sitting in a tab next to the installed widgets - /manage/custom-widgets/workshop/[id] merges the listing, security review and source setup onto one page with a sticky install footer - /manage/custom-widgets/publish/[id] replaces the publish modal - /manage/workshop redirects; its navbar entry is gone - the Custom CSS picker collapses from three modals to one Extract useCustomWidgetImport and ImportReviewContent so the Workshop page and the file/clipboard dialog share one import pipeline. Votes now show the viewer's own choice filled Reddit-style, backed by a new user-votes query, and screenshots get a zoomable lightbox. Rework the installed list onto ManageCollectionItem: show the widget description, replace the cryptic "JSX - 2" badge with plain request and origin facts, and swap the toggle and bare pencil for a status badge, a labelled Edit button and a menu entry for enable/disable. Fixes found while reviewing: - importing could run twice and duplicate a widget; guard on pending and succeeded, and return to the installed list instead of the heavy edit form - a background refetch of the submission (window focus, or the invalidation a vote triggers) reset the source setup and silently wiped part-typed credentials; key the reset on the sources, not the object - breadcrumbs raised MISSING_MESSAGE for unmapped dynamic segments; fall back to the raw segment via t.has - publishing had no in-flight guard and could post duplicate listings - tooltips on disabled vote and report controls never fired, making the sign-in hint unreachable --- .../custom-widgets/_custom-widget-actions.tsx | 41 +- .../_custom-widget-legacy-migration.spec.ts | 16 +- .../custom-widgets/_custom-widget-list.tsx | 260 +++--- .../custom-widgets/_custom-widget-tabs.tsx | 38 + .../[locale]/manage/custom-widgets/page.tsx | 6 +- .../publish/[id]/_workshop-publish-form.tsx | 248 ++++++ .../custom-widgets/publish/[id]/page.tsx | 21 + .../workshop/[id]/_workshop-detail.tsx | 284 +++++++ .../custom-widgets/workshop/[id]/page.tsx | 18 + .../workshop/_workshop-browse.tsx | 155 ++++ .../manage/custom-widgets/workshop/page.tsx | 31 + .../nextjs/src/app/[locale]/manage/layout.tsx | 7 - .../src/app/[locale]/manage/workshop/page.tsx | 48 +- .../board/items/item-select-modal.tsx | 16 +- .../custom-widget-import-dialog.tsx | 138 +--- .../use-custom-widget-import.ts | 167 ++++ .../components/manage/manage-collection.tsx | 15 +- .../components/manage/manage-page-layout.tsx | 5 +- .../manage/manage-sticky-footer.tsx | 35 + .../navigation/dynamic-breadcrumb.spec.ts | 17 + .../navigation/dynamic-breadcrumb.tsx | 19 +- .../components/workshop/workshop-browser.tsx | 748 ------------------ .../workshop/workshop-code-viewer.tsx | 66 ++ .../workshop/workshop-css-import-button.tsx | 178 +++-- .../workshop/workshop-install-button.tsx | 110 --- .../workshop/workshop-install-flow.spec.ts | 145 +++- .../workshop/workshop-installer.tsx | 36 - .../workshop/workshop-publish-modal.tsx | 275 ------- .../workshop/workshop-report-modal.tsx | 79 ++ .../workshop/workshop-screenshots.tsx | 141 ++++ .../components/workshop/workshop-session.tsx | 79 ++ .../workshop/workshop-submission-card.tsx | 84 ++ .../workshop/workshop-submission-grid.tsx | 84 ++ .../workshop/workshop-vote-control.tsx | 90 +++ .../src/test/workshop-translations.spec.ts | 47 +- .../src/workbench/import-review.tsx | 139 ++-- packages/translation/src/lang/en.json | 100 +-- packages/workshop/src/backend.ts | 17 +- 38 files changed, 2303 insertions(+), 1700 deletions(-) create mode 100644 apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-tabs.tsx create mode 100644 apps/nextjs/src/app/[locale]/manage/custom-widgets/publish/[id]/_workshop-publish-form.tsx create mode 100644 apps/nextjs/src/app/[locale]/manage/custom-widgets/publish/[id]/page.tsx create mode 100644 apps/nextjs/src/app/[locale]/manage/custom-widgets/workshop/[id]/_workshop-detail.tsx create mode 100644 apps/nextjs/src/app/[locale]/manage/custom-widgets/workshop/[id]/page.tsx create mode 100644 apps/nextjs/src/app/[locale]/manage/custom-widgets/workshop/_workshop-browse.tsx create mode 100644 apps/nextjs/src/app/[locale]/manage/custom-widgets/workshop/page.tsx create mode 100644 apps/nextjs/src/components/custom-widgets/use-custom-widget-import.ts create mode 100644 apps/nextjs/src/components/manage/manage-sticky-footer.tsx create mode 100644 apps/nextjs/src/components/navigation/dynamic-breadcrumb.spec.ts delete mode 100644 apps/nextjs/src/components/workshop/workshop-browser.tsx create mode 100644 apps/nextjs/src/components/workshop/workshop-code-viewer.tsx delete mode 100644 apps/nextjs/src/components/workshop/workshop-install-button.tsx delete mode 100644 apps/nextjs/src/components/workshop/workshop-installer.tsx delete mode 100644 apps/nextjs/src/components/workshop/workshop-publish-modal.tsx create mode 100644 apps/nextjs/src/components/workshop/workshop-report-modal.tsx create mode 100644 apps/nextjs/src/components/workshop/workshop-screenshots.tsx create mode 100644 apps/nextjs/src/components/workshop/workshop-session.tsx create mode 100644 apps/nextjs/src/components/workshop/workshop-submission-card.tsx create mode 100644 apps/nextjs/src/components/workshop/workshop-submission-grid.tsx create mode 100644 apps/nextjs/src/components/workshop/workshop-vote-control.tsx diff --git a/apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-actions.tsx b/apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-actions.tsx index bf38141a8..f707ab0bf 100644 --- a/apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-actions.tsx +++ b/apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-actions.tsx @@ -9,6 +9,8 @@ import { IconCopy, IconDots, IconDownload, + IconPlayerPause, + IconPlayerPlay, IconSparkles, IconTrash, IconUpload, @@ -21,9 +23,9 @@ import type { HomarrCustomWidgetV2 } from "@homarr/custom-widgets/core"; import { useConfirmModal } from "@homarr/modals"; import { showErrorNotification, showSuccessNotification } from "@homarr/notifications"; import { useScopedI18n } from "@homarr/translation/client"; +import { Link } from "@homarr/ui"; import { CustomWidgetImportDialog } from "~/components/custom-widgets/custom-widget-import-dialog"; -import { WorkshopPublishModal } from "~/components/workshop/workshop-publish-modal"; const iconProps = { size: 16, stroke: 1.5 }; @@ -40,8 +42,8 @@ export const CustomWidgetRowActions = ({ widget }: { widget: WidgetRef }) => { const { openConfirmModal } = useConfirmModal(); const deleteMutation = clientApi.customWidget.delete.useMutation(); const duplicateMutation = clientApi.customWidget.duplicate.useMutation(); + const toggleMutation = clientApi.customWidget.toggleEnabled.useMutation(); const utils = clientApi.useUtils(); - const [publishOpened, publishControls] = useDisclosure(false); const [migrationOpened, migrationControls] = useDisclosure(false); const migrationFileInputRef = useRef(null); const [migratedWidget, setMigratedWidget] = useState(null); @@ -147,6 +149,26 @@ export const CustomWidgetRowActions = ({ widget }: { widget: WidgetRef }) => { } }; + const handleToggleEnabled = () => { + toggleMutation.mutate( + { id: widget.id, enabled: !widget.enabled }, + { + onSuccess: async () => { + await utils.widget.customApi.getData.cancel(); + void utils.customWidget.list.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"), + }); + }, + }, + ); + }; + const handleDuplicate = () => { duplicateMutation.mutate( { id: widget.id }, @@ -202,6 +224,14 @@ export const CustomWidgetRowActions = ({ widget }: { widget: WidgetRef }) => { {widget.valid && ( <> + : } + disabled={toggleMutation.isPending} + > + {widget.enabled ? t("action.disable") : t("action.enable")} + + } @@ -212,7 +242,11 @@ export const CustomWidgetRowActions = ({ widget }: { widget: WidgetRef }) => { void handleExport()} leftSection={}> {t("action.export")} - }> + } + > {t("action.publishWorkshop")} @@ -254,7 +288,6 @@ export const CustomWidgetRowActions = ({ widget }: { widget: WidgetRef }) => { )} - {widget.valid && } {widget.migrationRequired && ( <> { }); it("replaces the legacy record through migration and refreshes every consumer", () => { + // The import pipeline is shared by the dialog and the Workshop install page. + const importer = readSource("apps/nextjs/src/components/custom-widgets/use-custom-widget-import.ts"); const dialog = readSource("apps/nextjs/src/components/custom-widgets/custom-widget-import-dialog.tsx"); - expect(dialog).toContain("customWidget.migrateLegacy.useMutation"); - expect(dialog).toContain("migrateMutation.mutate({ id: legacyId, widget: configuredWidget, secrets })"); - expect(dialog).toContain("utils.customWidget.list.invalidate()"); - expect(dialog).toContain("utils.customWidget.available.invalidate()"); - expect(dialog).toContain("utils.widget.customApi.getData.invalidate()"); + expect(importer).toContain("customWidget.migrateLegacy.useMutation"); + expect(importer).toMatch( + /migrateMutation\.mutate\(\{\s*id: legacyId,\s*widget: configuredWidget,\s*secrets,?\s*\}\)/u, + ); + expect(importer).toContain("utils.customWidget.list.invalidate()"); + expect(importer).toContain("utils.customWidget.available.invalidate()"); + expect(importer).toContain("utils.widget.customApi.getData.invalidate()"); + expect(dialog).toContain("useCustomWidgetImport({"); + expect(dialog).toContain("legacyId,"); }); }); diff --git a/apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-list.tsx b/apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-list.tsx index 7e4386d20..228231d4b 100644 --- a/apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-list.tsx +++ b/apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-list.tsx @@ -1,15 +1,13 @@ "use client"; -import { ActionIcon, Avatar, Badge, Card, Group, Stack, Switch, Text, Tooltip } from "@mantine/core"; +import { Avatar, Badge, Button, Group, Text, Tooltip } from "@mantine/core"; import { IconAlertTriangle, IconApi, IconPencil, IconSparkles } 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"; +import { ManageCollectionItem, ManageCollectionList } from "~/components/manage/manage-collection"; import { NoResults } from "~/components/no-results"; import { CustomWidgetRowActions } from "./_custom-widget-actions"; @@ -31,161 +29,69 @@ export const CustomWidgetList = ({ definitions }: CustomWidgetListProps) => { } return ( - - {definitions.map((def) => ( - + + {definitions.map((definition) => ( + ))} - + ); }; type WidgetDef = RouterOutputs["customWidget"]["list"][number]; -function CustomWidgetCard({ widget }: { widget: WidgetDef }) { +function CustomWidgetRow({ 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: async () => { - await utils.widget.customApi.getData.cancel(); - void utils.customWidget.list.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"), - }); - }, - }, - ); - }; + const origins = [...new Set(widget.sources.map((source) => source.origin))]; return ( - + + } - style={{ - opacity: widget.valid && !widget.enabled ? 0.55 : 1, - transition: "opacity 150ms ease", - }} - > - - - {widget.iconUrl ? ( - - ) : ( - - - - )} - - - {widget.name} + title={ + + {widget.name} + + } + badges={} + description={ + + {widget.description || t("page.list.noDescription")} + + } + metadata={ + + + {t("page.list.requestCount", { count: widget.requestCount })} + + {origins.map((origin) => ( + + · {origin} - {widget.migrationRequired ? ( - - {t("page.list.migrationDescription")} - - ) : !widget.valid ? ( - - {t("page.list.invalidDefinitionDescription")} - - ) : null} - {widget.sources[0] && ( - - {widget.sources.map((source) => source.origin).join(" · ")} - - )} - - {widget.migrationRequired ? ( - - } - style={{ flexShrink: 0 }} - > - {t("page.list.migrationRequired")} - - - ) : widget.valid ? ( - - JSX · {widget.requestCount} - - ) : ( - (issue.path ? `${issue.path}: ${issue.message}` : issue.message)) - .join("\n")} - multiline - maw={520} - > - } - style={{ flexShrink: 0 }} - > - {t("page.list.invalidDefinition")} - - - )} - {widget.missingSecrets.length > 0 && ( - - } - style={{ flexShrink: 0 }} - > - {t("page.list.missingCredentials", { count: widget.missingSecrets.length })} - - - )} + ))} - - + } + actions={ + {widget.valid && ( - <> - - - - - - - + )} - - + } + /> + ); +} + +/** Everything that decides whether this widget can actually run right now. */ +function CustomWidgetStatusBadges({ widget }: { widget: WidgetDef }) { + const t = useScopedI18n("customWidget"); + + if (widget.migrationRequired) { + return ( + + }> + {t("page.list.migrationRequired")} + + + ); + } + + if (!widget.valid) { + return ( + (issue.path ? `${issue.path}: ${issue.message}` : issue.message)) + .join("\n")} + multiline + maw={520} + > + }> + {t("page.list.invalidDefinition")} + + + ); + } + + return ( + + {widget.enabled ? ( + + {t("page.list.enabled")} + + ) : ( + + + {t("page.list.disabled")} + + + )} + {widget.missingSecrets.length > 0 && ( + + }> + {t("page.list.missingCredentials", { count: widget.missingSecrets.length })} + + + )} + ); } diff --git a/apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-tabs.tsx b/apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-tabs.tsx new file mode 100644 index 000000000..1819d915f --- /dev/null +++ b/apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-tabs.tsx @@ -0,0 +1,38 @@ +"use client"; + +import { Tabs } from "@mantine/core"; +import { IconApi, IconBuildingStore } from "@tabler/icons-react"; + +import { useScopedI18n } from "@homarr/translation/client"; +import { Link } from "@homarr/ui"; + +const tabs = { + installed: "/manage/custom-widgets", + workshop: "/manage/custom-widgets/workshop", +} as const; + +/** Switches between the widgets you have installed and the community Workshop. */ +export const CustomWidgetTabs = ({ active }: { active: keyof typeof tabs }) => { + const t = useScopedI18n("customWidget.page.tabs"); + + return ( + + + } + renderRoot={(props) => } + > + {t("installed")} + + } + renderRoot={(props) => } + > + {t("workshop")} + + + + ); +}; diff --git a/apps/nextjs/src/app/[locale]/manage/custom-widgets/page.tsx b/apps/nextjs/src/app/[locale]/manage/custom-widgets/page.tsx index 21541929f..b0798e9f9 100644 --- a/apps/nextjs/src/app/[locale]/manage/custom-widgets/page.tsx +++ b/apps/nextjs/src/app/[locale]/manage/custom-widgets/page.tsx @@ -10,9 +10,9 @@ import { Link } from "@homarr/ui"; import { ManagePageLayout } from "~/components/manage/manage-page-layout"; import { MobileAffixButton } from "~/components/manage/mobile-affix-button"; import { CustomWidgetBetaBanner } from "./_beta-banner"; -import { WorkshopInstallButton } from "~/components/workshop/workshop-install-button"; -import { ImportCustomWidgetButton } from "./_import-custom-widget-button"; import { CustomWidgetList } from "./_custom-widget-list"; +import { CustomWidgetTabs } from "./_custom-widget-tabs"; +import { ImportCustomWidgetButton } from "./_import-custom-widget-button"; export default async function CustomWidgetsPage() { const session = await auth(); @@ -38,13 +38,13 @@ export default async function CustomWidgetsPage() { > {t("action.downloadSkill")} - }> {t("action.create")} } + toolbar={} > diff --git a/apps/nextjs/src/app/[locale]/manage/custom-widgets/publish/[id]/_workshop-publish-form.tsx b/apps/nextjs/src/app/[locale]/manage/custom-widgets/publish/[id]/_workshop-publish-form.tsx new file mode 100644 index 000000000..d0e174176 --- /dev/null +++ b/apps/nextjs/src/app/[locale]/manage/custom-widgets/publish/[id]/_workshop-publish-form.tsx @@ -0,0 +1,248 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { + Alert, + Box, + Button, + Checkbox, + FileInput, + Group, + Paper, + Stack, + Text, + Textarea, + TextInput, + ThemeIcon, + Title, +} from "@mantine/core"; +import { + IconArrowLeft, + IconBuildingStore, + IconCheck, + IconExternalLink, + IconPhoto, + IconShieldCheck, +} from "@tabler/icons-react"; + +import { clientApi } from "@homarr/api/client"; +import { useScopedI18n } from "@homarr/translation/client"; +import { Link } from "@homarr/ui"; +import { useWorkshopCreateMutation } from "@homarr/workshop/backend"; +import { workshopScreenshotsSchema } from "@homarr/workshop/schema"; + +import { ManagePageLayout } from "~/components/manage/manage-page-layout"; +import { ManageStickyFooter } from "~/components/manage/manage-sticky-footer"; +import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb"; +import { getWorkshopWebUrl } from "~/components/workshop/workshop-client"; +import { + getPrivateWorkshopSourceNames, + publishWorkshopDefinition, + serializeWorkshopDefinition, +} from "~/components/workshop/workshop-publish-definition"; +import { WorkshopAccountButton, useWorkshopSession } from "~/components/workshop/workshop-session"; + +const listHref = "/manage/custom-widgets"; + +export function WorkshopPublishForm({ widget }: { widget: { id: string; name: string } }) { + const t = useScopedI18n("workshop"); + const session = useWorkshopSession(); + const [title, setTitle] = useState(widget.name); + const [description, setDescription] = useState(""); + const [screenshots, setScreenshots] = useState([]); + const [sourceUrlsReviewed, setSourceUrlsReviewed] = useState(false); + const [error, setError] = useState(null); + const [publishedSubmissionId, setPublishedSubmissionId] = useState(null); + + const createSubmission = useWorkshopCreateMutation(session.client); + const definition = clientApi.customWidget.export.useQuery({ id: widget.id }); + const privateSourceNames = getPrivateWorkshopSourceNames(definition.data); + const definitionFingerprint = definition.data ? serializeWorkshopDefinition(definition.data) : null; + useEffect(() => setSourceUrlsReviewed(false), [definitionFingerprint]); + + const breadcrumb = ; + + const publish = async () => { + // Publishing is immediate and public; a second in-flight call would create a + // duplicate listing. + if (createSubmission.isPending) return; + setError(null); + try { + if (!definition.data) throw new Error(t("publish.error")); + if (!workshopScreenshotsSchema.safeParse(screenshots).success) throw new Error(t("publish.invalidScreenshot")); + + let submissionId: string | null = null; + const result = await publishWorkshopDefinition({ + inspectedDefinition: definition.data, + refetchDefinition: async () => (await definition.refetch()).data, + publish: async (content) => { + const submission = await createSubmission.mutateAsync({ + input: { type: "customWidget", title, description, content }, + screenshots, + }); + submissionId = submission.id; + }, + }); + if (result === "unavailable") throw new Error(t("publish.error")); + if (result === "changed") { + setSourceUrlsReviewed(false); + setError(t("publish.definitionChanged")); + return; + } + if (!submissionId) throw new Error(t("publish.error")); + setPublishedSubmissionId(submissionId); + } catch (cause) { + setError(cause instanceof Error ? cause.message : t("publish.error")); + } + }; + + if (publishedSubmissionId) { + return ( + + + + + + + + {t("publish.success")} + + + {t("publish.manageDescription")} + + + + + + + + + ); + } + + const blocked = + !session.user || + createSubmission.isPending || + !definition.data || + definition.isError || + title.trim().length < 3 || + (privateSourceNames.length > 0 && !sourceUrlsReviewed); + + return ( + + {t("publish.title")} + + {widget.name} + + + } + breadcrumb={breadcrumb} + primaryAction={} + > + + + + + + + + {t("publish.introTitle")} + + {t("publish.introDescription")} + + + + + + {!session.user && {t("publish.signInHint")}} + + + + + + {t("publish.listingDetails")} + + + {t("publish.listingDetailsDescription")} + + + setTitle(event.currentTarget.value)} + required + /> +