feat: add live app preview panel with iframe, URL bar, and reload
Made-with: Cursor
This commit is contained in:
@@ -917,6 +917,113 @@ function FileViewer({ selectedPath, fileContent, fileLoading, fileName, rootPath
|
||||
);
|
||||
}
|
||||
|
||||
// ── Preview panel ─────────────────────────────────────────────────────────────
|
||||
|
||||
interface PreviewApp { name: string; url: string | null; status: string; }
|
||||
|
||||
function PreviewContent({ projectId, apps, activePreviewApp, onSelectApp }: {
|
||||
projectId: string;
|
||||
apps: PreviewApp[];
|
||||
activePreviewApp: PreviewApp | null;
|
||||
onSelectApp: (app: PreviewApp) => void;
|
||||
}) {
|
||||
const [urlInput, setUrlInput] = useState(activePreviewApp?.url ?? "");
|
||||
const [iframeSrc, setIframeSrc] = useState(activePreviewApp?.url ?? "");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const iframeRef = useRef<HTMLIFrameElement>(null);
|
||||
|
||||
// Sync when active app changes
|
||||
useEffect(() => {
|
||||
const u = activePreviewApp?.url ?? "";
|
||||
setUrlInput(u);
|
||||
setIframeSrc(u);
|
||||
}, [activePreviewApp]);
|
||||
|
||||
const navigate = (url: string) => {
|
||||
const u = url.startsWith("http") ? url : `https://${url}`;
|
||||
setUrlInput(u);
|
||||
setIframeSrc(u);
|
||||
setLoading(true);
|
||||
};
|
||||
|
||||
if (!activePreviewApp?.url) {
|
||||
return (
|
||||
<div style={{ flex: 1, display: "flex", flexDirection: "column", alignItems: "center", justifyContent: "center", gap: 16, padding: 60, textAlign: "center", background: "#faf8f5" }}>
|
||||
<div style={{ width: 52, height: 52, borderRadius: 13, background: "#f0ece4", display: "flex", alignItems: "center", justifyContent: "center", fontSize: "1.5rem" }}>🖥</div>
|
||||
<div>
|
||||
<div style={{ fontSize: "0.92rem", fontWeight: 600, color: "#1a1a1a", marginBottom: 6, fontFamily: "Outfit, sans-serif" }}>No deployment URL yet</div>
|
||||
<div style={{ fontSize: "0.8rem", color: "#a09a90", maxWidth: 320, lineHeight: 1.6, fontFamily: "Outfit, sans-serif" }}>
|
||||
Deploy an app via Coolify to see a live preview here. Once deployed, the URL will appear automatically.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ flex: 1, display: "flex", flexDirection: "column", overflow: "hidden" }}>
|
||||
{/* Browser chrome */}
|
||||
<div style={{
|
||||
height: 44, flexShrink: 0, display: "flex", alignItems: "center",
|
||||
gap: 8, padding: "0 12px",
|
||||
background: "#fff", borderBottom: "1px solid #e8e4dc",
|
||||
}}>
|
||||
{/* Reload */}
|
||||
<button
|
||||
onClick={() => { setIframeSrc(""); setTimeout(() => setIframeSrc(urlInput), 50); setLoading(true); }}
|
||||
title="Reload"
|
||||
style={{ width: 28, height: 28, border: "none", background: "transparent", cursor: "pointer", borderRadius: 6, display: "flex", alignItems: "center", justifyContent: "center", color: "#9a9490", fontSize: "0.85rem" }}
|
||||
onMouseEnter={e => (e.currentTarget as HTMLElement).style.background = "#f0ece4"}
|
||||
onMouseLeave={e => (e.currentTarget as HTMLElement).style.background = "transparent"}
|
||||
>↻</button>
|
||||
|
||||
{/* URL bar */}
|
||||
<form onSubmit={e => { e.preventDefault(); navigate(urlInput); }} style={{ flex: 1, display: "flex" }}>
|
||||
<input
|
||||
value={urlInput}
|
||||
onChange={e => setUrlInput(e.target.value)}
|
||||
style={{
|
||||
flex: 1, height: 30, border: "1px solid #e8e4dc", borderRadius: 7,
|
||||
padding: "0 10px", fontSize: "0.76rem", fontFamily: "IBM Plex Mono, monospace",
|
||||
color: "#1a1a1a", background: "#faf8f5", outline: "none",
|
||||
}}
|
||||
onFocus={e => (e.currentTarget as HTMLElement).style.borderColor = "#1a1a1a"}
|
||||
onBlur={e => (e.currentTarget as HTMLElement).style.borderColor = "#e8e4dc"}
|
||||
/>
|
||||
</form>
|
||||
|
||||
{/* Open in new tab */}
|
||||
<a
|
||||
href={urlInput}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
title="Open in new tab"
|
||||
style={{ width: 28, height: 28, border: "none", background: "transparent", cursor: "pointer", borderRadius: 6, display: "flex", alignItems: "center", justifyContent: "center", color: "#9a9490", fontSize: "0.78rem", textDecoration: "none" }}
|
||||
onMouseEnter={e => (e.currentTarget as HTMLElement).style.background = "#f0ece4"}
|
||||
onMouseLeave={e => (e.currentTarget as HTMLElement).style.background = "transparent"}
|
||||
>↗</a>
|
||||
|
||||
{/* Loading indicator */}
|
||||
{loading && (
|
||||
<div style={{ width: 6, height: 6, borderRadius: "50%", background: "#3d5afe", flexShrink: 0, animation: "pulse 1s infinite" }} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* iframe */}
|
||||
{iframeSrc && (
|
||||
<iframe
|
||||
ref={iframeRef}
|
||||
src={iframeSrc}
|
||||
onLoad={() => setLoading(false)}
|
||||
style={{ flex: 1, border: "none", background: "#fff" }}
|
||||
title="App preview"
|
||||
sandbox="allow-scripts allow-same-origin allow-forms allow-popups allow-modals"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Main Build hub ────────────────────────────────────────────────────────────
|
||||
|
||||
function BuildHubInner() {
|
||||
@@ -936,6 +1043,10 @@ function BuildHubInner() {
|
||||
const [surfaces, setSurfaces] = useState<SurfaceEntry[]>([]);
|
||||
const [activeSurfaceId, setActiveSurfaceId] = useState<string>(activeSurfaceParam);
|
||||
|
||||
// Preview state
|
||||
const [previewApps, setPreviewApps] = useState<PreviewApp[]>([]);
|
||||
const [activePreviewApp, setActivePreviewApp] = useState<PreviewApp | null>(null);
|
||||
|
||||
// File viewer state — shared between inner nav (tree) and viewer panel
|
||||
const [selectedFilePath, setSelectedFilePath] = useState<string | null>(null);
|
||||
const [fileContent, setFileContent] = useState<string | null>(null);
|
||||
@@ -943,6 +1054,14 @@ function BuildHubInner() {
|
||||
const [fileName, setFileName] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetch(`/api/projects/${projectId}/preview-url`)
|
||||
.then(r => r.json())
|
||||
.then(d => {
|
||||
const pa: PreviewApp[] = d.apps ?? [];
|
||||
setPreviewApps(pa);
|
||||
if (pa.length > 0 && !activePreviewApp) setActivePreviewApp(pa[0]);
|
||||
}).catch(() => {});
|
||||
|
||||
fetch(`/api/projects/${projectId}/apps`)
|
||||
.then(r => r.json())
|
||||
.then(d => { setApps(d.apps ?? []); }).catch(() => {});
|
||||
@@ -1040,6 +1159,21 @@ function BuildHubInner() {
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Preview: deployed apps list */}
|
||||
{section === "preview" && (
|
||||
<div style={{ flex: 1, display: "flex", flexDirection: "column", overflow: "hidden" }}>
|
||||
<div style={NAV_GROUP_LABEL}>Apps</div>
|
||||
{previewApps.length > 0 ? previewApps.map(app => (
|
||||
<NavItem key={app.name} label={app.name} indent
|
||||
active={activePreviewApp?.name === app.name}
|
||||
onClick={() => setActivePreviewApp(app)}
|
||||
/>
|
||||
)) : (
|
||||
<div style={{ padding: "8px 22px", fontSize: "0.74rem", color: "#b5b0a6", fontFamily: "Outfit, sans-serif" }}>No deployments yet</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Main content panel */}
|
||||
@@ -1061,6 +1195,14 @@ function BuildHubInner() {
|
||||
{section === "infrastructure" && (
|
||||
<InfraContent tab={activeInfra} projectId={projectId} workspace={workspace} />
|
||||
)}
|
||||
{section === "preview" && (
|
||||
<PreviewContent
|
||||
projectId={projectId}
|
||||
apps={previewApps}
|
||||
activePreviewApp={activePreviewApp}
|
||||
onSelectApp={setActivePreviewApp}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user