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>
|
||||
);
|
||||
|
||||
57
app/api/projects/[projectId]/preview-url/route.ts
Normal file
57
app/api/projects/[projectId]/preview-url/route.ts
Normal 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 });
|
||||
}
|
||||
Reference in New Issue
Block a user