From 020a67574d784e4eb3f819ad4167d740c52dca1f Mon Sep 17 00:00:00 2001 From: ciregenz Date: Fri, 19 Jun 2026 04:26:43 -0700 Subject: [PATCH] [eric] publish: PublishModal (scan -> Cancel/Fix/Publish-anyway -> live link) wired into ShareModal --- .../src/app/components/share/PublishModal.tsx | 275 ++++++++++++++++++ .../src/app/components/share/ShareModal.tsx | 44 ++- 2 files changed, 310 insertions(+), 9 deletions(-) create mode 100644 frontend/src/app/components/share/PublishModal.tsx diff --git a/frontend/src/app/components/share/PublishModal.tsx b/frontend/src/app/components/share/PublishModal.tsx new file mode 100644 index 00000000..c487429c --- /dev/null +++ b/frontend/src/app/components/share/PublishModal.tsx @@ -0,0 +1,275 @@ +// Publish an app to {slug}.openswarm.dev. Flow: scan the code (AST + an aux-LLM +// pass, on the user's own creds) -> if findings, show them with Cancel/Fix/Publish +// Anyway -> build + upload -> show the live link. Already-published apps open +// straight to the manage view (visit / copy / unpublish). +import React, { useCallback, useEffect, useState } from 'react'; +import Box from '@mui/material/Box'; +import Typography from '@mui/material/Typography'; +import Dialog from '@mui/material/Dialog'; +import Button from '@mui/material/Button'; +import IconButton from '@mui/material/IconButton'; +import CircularProgress from '@mui/material/CircularProgress'; +import Snackbar from '@mui/material/Snackbar'; +import Fade from '@mui/material/Fade'; +import CloseIcon from '@mui/icons-material/Close'; +import PublicIcon from '@mui/icons-material/Public'; +import WarningAmberIcon from '@mui/icons-material/WarningAmber'; +import CheckCircleOutlineIcon from '@mui/icons-material/CheckCircleOutline'; +import ContentCopyIcon from '@mui/icons-material/ContentCopy'; +import OpenInNewIcon from '@mui/icons-material/OpenInNew'; + +import { useClaudeTokens } from '@/shared/styles/ThemeContext'; +import { useAppDispatch, useAppSelector } from '@/shared/hooks'; +import { setOutputPublishState } from '@/shared/state/outputsSlice'; + +import { ReviewSummary } from './shareTypes'; +import { publishApp, publishPreflight, unpublishApp } from './publishApi'; + +interface Props { + outputId: string; + outputName: string; + open: boolean; + onClose: () => void; +} + +type Phase = 'scanning' | 'review' | 'publishing' | 'done' | 'error'; + +const PublishModal: React.FC = ({ outputId, outputName, open, onClose }) => { + const c = useClaudeTokens(); + const dispatch = useAppDispatch(); + const publishedUrl = useAppSelector((s) => s.outputs.items[outputId]?.published_url) ?? null; + + const [phase, setPhase] = useState('scanning'); + const [review, setReview] = useState(null); + const [showDetails, setShowDetails] = useState(false); + const [errorMsg, setErrorMsg] = useState(''); + const [liveUrl, setLiveUrl] = useState(null); + const [busy, setBusy] = useState(false); + const [toast, setToast] = useState(''); + + const runScan = useCallback(() => { + setPhase('scanning'); + setReview(null); + setErrorMsg(''); + let alive = true; + publishPreflight(outputId) + .then((r) => alive && (setReview(r), setPhase('review'))) + .catch((e) => alive && (setErrorMsg(e?.message || "We couldn't check this app."), setPhase('error'))); + return () => { + alive = false; + }; + }, [outputId]); + + useEffect(() => { + if (!open) return; + setShowDetails(false); + if (publishedUrl) { + setLiveUrl(publishedUrl); + setPhase('done'); + return; + } + return runScan(); + }, [open, publishedUrl, runScan]); + + const doPublish = async (force: boolean) => { + setPhase('publishing'); + try { + const res = await publishApp(outputId, { force }); + if (res.ok && res.published_url) { + dispatch( + setOutputPublishState({ + id: outputId, + published_slug: res.published_slug ?? null, + published_url: res.published_url, + publish_status: 'published', + }), + ); + setLiveUrl(res.published_url); + setPhase('done'); + } else if (res.blocked && res.review) { + setReview(res.review); + setPhase('review'); + } else { + setErrorMsg(res.error || 'Publishing failed. Please try again.'); + setPhase('error'); + } + } catch (e: any) { + setErrorMsg(e?.message || 'Publishing failed. Please try again.'); + setPhase('error'); + } + }; + + const doUnpublish = async () => { + setBusy(true); + try { + await unpublishApp(outputId); + dispatch(setOutputPublishState({ id: outputId, published_slug: null, published_url: null, publish_status: null })); + setToast('App unpublished'); + onClose(); + } catch (e: any) { + setToast(e?.message || "We couldn't unpublish."); + } finally { + setBusy(false); + } + }; + + const copyLink = async () => { + if (!liveUrl) return; + await navigator.clipboard.writeText(liveUrl); + setToast('Link copied'); + }; + + const findings = review?.findings ?? []; + const hasFindings = (review && review.verdict !== 'clean') || findings.length > 0; + + const center = (node: React.ReactNode) => ( + + {node} + + ); + + const body = () => { + if (phase === 'scanning') { + return center( + <> + + Checking your app before it goes live... + , + ); + } + if (phase === 'publishing') { + return center( + <> + + Building and publishing your app... + , + ); + } + if (phase === 'error') { + return center( + <> + Something went wrong + {errorMsg} + , + ); + } + if (phase === 'done') { + return ( + + + + Your app is live + + + + {liveUrl} + + + liveUrl && window.open(liveUrl, '_blank')} sx={{ color: c.text.tertiary }}> + + + ); + } + // review + if (!hasFindings) { + return center( + <> + + Looks good. Ready to publish. + , + ); + } + return ( + + + + + {findings.length} security risk{findings.length === 1 ? '' : 's'} found + + + + You can still publish, fix them first, or cancel. + + + {showDetails && ( + + {findings.map((f, i) => ( +
  • {f}
  • + ))} +
    + )} +
    + ); + }; + + const actions = () => { + if (phase === 'scanning' || phase === 'publishing') return null; + if (phase === 'error') { + return ( + <> + + + + ); + } + if (phase === 'done') { + return ( + <> + + + + ); + } + // review + if (!hasFindings) { + return ( + <> + + + + ); + } + return ( + <> + + + + + ); + }; + + return ( + <> + + + + + Publish {outputName} + + + + {body()} + {actions()} + + setToast('')} message={toast} anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }} /> + + ); +}; + +export default PublishModal; diff --git a/frontend/src/app/components/share/ShareModal.tsx b/frontend/src/app/components/share/ShareModal.tsx index 5a8568a1..75a7fba4 100644 --- a/frontend/src/app/components/share/ShareModal.tsx +++ b/frontend/src/app/components/share/ShareModal.tsx @@ -13,10 +13,12 @@ import Alert from '@mui/material/Alert'; import CloseIcon from '@mui/icons-material/Close'; import DownloadIcon from '@mui/icons-material/Download'; import LinkIcon from '@mui/icons-material/Link'; +import PublicIcon from '@mui/icons-material/Public'; import { useClaudeTokens } from '@/shared/styles/ThemeContext'; import IncludesList from './IncludesList'; +import PublishModal from './PublishModal'; import { downloadSwarm, exportPreflight } from './shareApi'; import { ExportPreflight, ShareTarget } from './shareTypes'; @@ -33,6 +35,7 @@ const ShareModal: React.FC = ({ target, open, onClose }) => { const [error, setError] = useState(''); const [downloading, setDownloading] = useState(false); const [toast, setToast] = useState(''); + const [publishOpen, setPublishOpen] = useState(false); const load = useCallback(() => { setPreflight(null); @@ -74,8 +77,10 @@ const ShareModal: React.FC = ({ target, open, onClose }) => { subtitle: string, disabled?: boolean, chip?: string, + onClick?: () => void, ) => ( = ({ target, open, onClose }) => { border: `1px solid ${selected ? c.accent.primary : c.border.subtle}`, bgcolor: selected ? `${c.accent.primary}0d` : 'transparent', opacity: disabled ? 0.5 : 1, - cursor: disabled ? 'default' : 'default', + cursor: !disabled && onClick ? 'pointer' : 'default', + transition: 'background-color 0.12s, border-color 0.12s', + '&:hover': !disabled && onClick ? { bgcolor: c.bg.secondary, borderColor: c.border.medium } : undefined, }} > {icon} @@ -151,14 +158,24 @@ const ShareModal: React.FC = ({ target, open, onClose }) => { {optionRow(true, , 'Download .swarm file', 'Save a file you can send to anyone.')} - {optionRow( - false, - , - 'Create share link', - 'A link that opens straight in OpenSwarm.', - true, - 'Coming soon', - )} + {target.kind === 'app' + ? optionRow( + false, + , + 'Publish to web', + 'A public link anyone can open, hosted at openswarm.dev.', + false, + undefined, + () => setPublishOpen(true), + ) + : optionRow( + false, + , + 'Create share link', + 'A link that opens straight in OpenSwarm.', + true, + 'Coming soon', + )}