feat: add live app preview panel with iframe, URL bar, and reload

Made-with: Cursor
This commit is contained in:
2026-03-09 17:07:33 -07:00
parent 70c94dc60c
commit 5778abe6c3
2 changed files with 199 additions and 0 deletions

View File

@@ -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>
);

View File

@@ -0,0 +1,57 @@
import { NextResponse } from 'next/server';
import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth/authOptions';
import { query } from '@/lib/db-postgres';
export interface PreviewApp {
name: string;
url: string | null;
status: 'live' | 'deploying' | 'failed' | 'unknown';
}
export async function GET(
_req: Request,
{ params }: { params: Promise<{ projectId: string }> }
) {
const { projectId } = await params;
const session = await getServerSession(authOptions);
if (!session?.user?.email) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const rows = await query<{ data: Record<string, unknown> }>(
`SELECT p.data FROM fs_projects p
JOIN fs_users u ON u.id = p.user_id
WHERE p.id = $1 AND u.data->>'email' = $2 LIMIT 1`,
[projectId, session.user.email]
);
if (rows.length === 0) {
return NextResponse.json({ error: 'Project not found' }, { status: 404 });
}
const data = rows[0].data ?? {};
// Gather apps with their deployment URLs
const storedApps = (data.apps as Array<{ name: string; fqdn?: string; coolifyServiceUuid?: string }>) ?? [];
const lastDeployment = (data as any).contextSnapshot?.lastDeployment ?? null;
const apps: PreviewApp[] = storedApps.map(app => ({
name: app.name,
url: app.fqdn ? (app.fqdn.startsWith('http') ? app.fqdn : `https://${app.fqdn}`) : null,
status: app.fqdn ? 'live' : 'unknown',
}));
// If no stored apps but we have a last deployment URL, surface it
if (apps.length === 0 && lastDeployment?.url) {
apps.push({
name: 'app',
url: lastDeployment.url.startsWith('http') ? lastDeployment.url : `https://${lastDeployment.url}`,
status: lastDeployment.status === 'finished' ? 'live' : lastDeployment.status === 'in_progress' ? 'deploying' : 'unknown',
});
}
// Also expose the raw last deployment for the panel header
return NextResponse.json({ apps, lastDeployment });
}