diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5fe6ae745..0d24b8267 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -8,7 +8,7 @@ on: types: [closed] branches: ["**"] push: - branches: [main, dev, beta, next, v2] + branches: [main, dev, beta, next, v2, release/v2] merge_group: workflow_dispatch: inputs: @@ -108,6 +108,7 @@ jobs: needs: fast-gate if: | github.event_name == 'merge_group' || + (github.event_name == 'push' && github.ref == 'refs/heads/release/v2') || (github.event_name == 'pull_request' && github.event.pull_request.draft == false) || (github.event_name == 'workflow_dispatch' && (inputs.operation == 'validate' || inputs.operation == 'publish')) runs-on: ubuntu-latest @@ -204,6 +205,7 @@ jobs: !env.ACT && ( (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository) || + (github.event_name == 'push' && github.ref == 'refs/heads/release/v2') || (github.event_name == 'workflow_dispatch' && inputs.operation == 'publish') ) uses: docker/login-action@v4 @@ -218,6 +220,7 @@ jobs: !env.ACT && ( (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository) || + (github.event_name == 'push' && github.ref == 'refs/heads/release/v2') || (github.event_name == 'workflow_dispatch' && inputs.operation == 'publish') ) uses: docker/build-push-action@v7 @@ -243,6 +246,7 @@ jobs: if: | ( (github.event_name == 'pull_request' && github.event.pull_request.draft == false && github.event.pull_request.head.repo.full_name == github.repository) || + (github.event_name == 'push' && github.ref == 'refs/heads/release/v2') || (github.event_name == 'workflow_dispatch' && inputs.operation == 'publish') ) runs-on: ubuntu-24.04-arm @@ -294,6 +298,7 @@ jobs: if: | ( (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository) || + (github.event_name == 'push' && github.ref == 'refs/heads/release/v2') || (github.event_name == 'workflow_dispatch' && inputs.operation == 'publish') ) runs-on: ubuntu-latest @@ -303,7 +308,7 @@ jobs: packages: write pull-requests: read env: - PREVIEW_TAG: pr-${{ github.event.pull_request.number || inputs.pr_number }} + PREVIEW_TAG: ${{ github.event_name == 'push' && 'v2' || format('pr-{0}', github.event.pull_request.number || inputs.pr_number) }} steps: - name: Log in to GHCR if: "!env.ACT" diff --git a/apps/nextjs/src/app/[locale]/auth/login/_login-form.tsx b/apps/nextjs/src/app/[locale]/auth/login/_login-form.tsx index 2458b2171..792e08903 100644 --- a/apps/nextjs/src/app/[locale]/auth/login/_login-form.tsx +++ b/apps/nextjs/src/app/[locale]/auth/login/_login-form.tsx @@ -4,7 +4,7 @@ import type { PropsWithChildren } from "react"; import { useCallback, useEffect, useRef, useState } from "react"; import { useRouter, useSearchParams } from "next/navigation"; import { Anchor, Button, Card, Code, Collapse, Divider, PasswordInput, Stack, Text, TextInput } from "@mantine/core"; -import { useDisclosure, useMounted } from "@mantine/hooks"; +import { useDisclosure } from "@mantine/hooks"; import { z } from "zod/v4"; import { signIn } from "@homarr/auth/client"; @@ -32,7 +32,6 @@ export const LoginForm = ({ providers, oidcClientName, isOidcAutoLoginEnabled, c const searchParams = useSearchParams(); const isError = searchParams.has("error"); const router = useRouter(); - const isMounted = useMounted(); const [isPending, setIsPending] = useState(false); const form = useZodForm(extendedValidation, { initialValues: { @@ -140,7 +139,7 @@ export const LoginForm = ({ providers, oidcClientName, isOidcAutoLoginEnabled, c {providers.includes("credentials") && ( - + {t("action.login.label")} @@ -148,7 +147,7 @@ export const LoginForm = ({ providers, oidcClientName, isOidcAutoLoginEnabled, c )} {providers.includes("ldap") && ( - + {t("action.login.labelWith", { provider: "LDAP" })} )} @@ -159,13 +158,7 @@ export const LoginForm = ({ providers, oidcClientName, isOidcAutoLoginEnabled, c )} {providers.includes("oidc") && ( - )} @@ -175,13 +168,12 @@ export const LoginForm = ({ providers, oidcClientName, isOidcAutoLoginEnabled, c }; interface SubmitButtonProps { - isMounted: boolean; isPending: boolean; form: UseFormReturnType; provider: "credentials" | "ldap"; } -const SubmitButton = ({ isMounted, isPending, form, provider, children }: PropsWithChildren) => { +const SubmitButton = ({ isPending, form, provider, children }: PropsWithChildren) => { const isCurrentProviderActive = form.getValues().provider === provider; return ( @@ -192,7 +184,7 @@ const SubmitButton = ({ isMounted, isPending, form, provider, children }: PropsW fullWidth onClick={() => form.setFieldValue("provider", provider)} loading={isPending && isCurrentProviderActive} - disabled={!isMounted || (isPending && !isCurrentProviderActive)} + disabled={isPending && !isCurrentProviderActive} > {children} 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 441ccec03..02ee78c46 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,10 @@ 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"; +import { downloadJson } from "~/components/custom-widgets/download"; const iconProps = { size: 16, stroke: 1.5 }; @@ -40,12 +43,11 @@ 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); - const copyMigrationPrompt = async () => { try { const result = await utils.customWidget.legacyMigrationPrompt.fetch({ id: widget.id }); @@ -62,30 +64,41 @@ export const CustomWidgetRowActions = ({ widget }: { widget: WidgetRef }) => { } }; + const copyLegacyDefinition = async () => { + try { + const data = await utils.customWidget.exportLegacy.fetch({ id: widget.id }); + await navigator.clipboard.writeText(JSON.stringify(data, null, 2)); + showSuccessNotification({ + title: t("action.copyLegacyDefinition"), + message: t("notification.legacyDefinitionCopied"), + }); + } catch (error) { + showErrorNotification({ + title: t("action.copyLegacyDefinition"), + message: error instanceof Error ? error.message : t("notification.legacyDefinitionCopyError"), + }); + } + }; + const queueMigratedWidget = (text: string) => { const result = parseCustomWidgetClipboardDetailed(text); if (!result.success) { - showErrorNotification({ - title: t("action.migrate"), - message: formatCustomWidgetImportIssues(result.issues), - }); + showErrorNotification({ title: t("action.migrate"), message: formatCustomWidgetImportIssues(result.issues) }); return; } setMigratedWidget(result.widget); migrationControls.open(); }; + const notifyMigrationError = () => + showErrorNotification({ title: t("action.migrate"), message: t("notification.migrationError") }); const pasteMigratedWidget = async () => { try { queueMigratedWidget(await navigator.clipboard.readText()); } catch { - showErrorNotification({ - title: t("action.migrate"), - message: t("notification.migrationError"), - }); + notifyMigrationError(); } }; - const handleMigrationFile = (event: React.ChangeEvent) => { const file = event.target.files?.[0]; if (!file) return; @@ -94,15 +107,10 @@ export const CustomWidgetRowActions = ({ widget }: { widget: WidgetRef }) => { try { queueMigratedWidget(loadEvent.target?.result as string); } catch { - showErrorNotification({ - title: t("action.migrate"), - message: t("notification.migrationError"), - }); + notifyMigrationError(); } }); - reader.addEventListener("error", () => { - showErrorNotification({ title: t("action.migrate"), message: t("notification.migrationError") }); - }); + reader.addEventListener("error", notifyMigrationError); reader.readAsText(file); event.target.value = ""; }; @@ -110,18 +118,41 @@ export const CustomWidgetRowActions = ({ widget }: { widget: WidgetRef }) => { const handleExport = async () => { try { const data = await utils.customWidget.export.fetch({ id: widget.id }); - const blob = new Blob([JSON.stringify(data, null, 2)], { type: "application/json" }); - const url = URL.createObjectURL(blob); - const a = document.createElement("a"); - a.href = url; - a.download = `${widget.name}.json`; - a.click(); - URL.revokeObjectURL(url); + downloadJson(data, `${widget.name}.json`); } catch { showErrorNotification({ title: t("action.export"), message: t("notification.exportError") }); } }; + const exportLegacyDefinition = async () => { + try { + const data = await utils.customWidget.exportLegacy.fetch({ id: widget.id }); + downloadJson(data, `${widget.name}.legacy.json`); + } catch { + showErrorNotification({ title: t("action.exportLegacyDefinition"), message: t("notification.exportError") }); + } + }; + + 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 }, @@ -146,22 +177,24 @@ export const CustomWidgetRowActions = ({ widget }: { widget: WidgetRef }) => { title: t("action.delete"), children: t("action.deleteConfirm", { name: widget.name }), onConfirm: () => { - const callbacks = { - onSuccess: () => { - showSuccessNotification({ - title: t("action.delete"), - message: t("notification.deleted", { name: widget.name }), - }); - void utils.customWidget.list.invalidate(); - void utils.customWidget.available.invalidate(); - void utils.widget.customApi.getData.invalidate(); - void revalidatePathActionAsync("/manage/custom-widgets"); + deleteMutation.mutate( + { id: widget.id }, + { + onSuccess: () => { + showSuccessNotification({ + title: t("action.delete"), + message: t("notification.deleted", { name: widget.name }), + }); + void utils.customWidget.list.invalidate(); + void utils.customWidget.available.invalidate(); + void utils.widget.customApi.getData.invalidate(); + void revalidatePathActionAsync("/manage/custom-widgets"); + }, + onError: () => { + showErrorNotification({ title: t("action.delete"), message: t("notification.deleteError") }); + }, }, - onError: () => { - showErrorNotification({ title: t("action.delete"), message: t("notification.deleteError") }); - }, - }; - deleteMutation.mutate({ id: widget.id }, callbacks); + ); }, }); }; @@ -177,6 +210,14 @@ export const CustomWidgetRowActions = ({ widget }: { widget: WidgetRef }) => { {widget.valid && ( <> + : } + disabled={toggleMutation.isPending} + > + {widget.enabled ? t("action.disable") : t("action.enable")} + + } @@ -187,7 +228,11 @@ export const CustomWidgetRowActions = ({ widget }: { widget: WidgetRef }) => { void handleExport()} leftSection={}> {t("action.export")} - }> + } + > {t("action.publishWorkshop")} @@ -195,6 +240,13 @@ export const CustomWidgetRowActions = ({ widget }: { widget: WidgetRef }) => { )} {widget.migrationRequired && ( <> + void copyLegacyDefinition()} leftSection={}> + {t("action.copyLegacyDefinition")} + + void exportLegacyDefinition()} leftSection={}> + {t("action.exportLegacyDefinition")} + + void copyMigrationPrompt()} leftSection={}> {t("action.copyMigrationPrompt")} @@ -222,7 +274,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 + /> +