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 ────────────────────────────────────────────────────────────
|
// ── Main Build hub ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function BuildHubInner() {
|
function BuildHubInner() {
|
||||||
@@ -936,6 +1043,10 @@ function BuildHubInner() {
|
|||||||
const [surfaces, setSurfaces] = useState<SurfaceEntry[]>([]);
|
const [surfaces, setSurfaces] = useState<SurfaceEntry[]>([]);
|
||||||
const [activeSurfaceId, setActiveSurfaceId] = useState<string>(activeSurfaceParam);
|
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
|
// File viewer state — shared between inner nav (tree) and viewer panel
|
||||||
const [selectedFilePath, setSelectedFilePath] = useState<string | null>(null);
|
const [selectedFilePath, setSelectedFilePath] = useState<string | null>(null);
|
||||||
const [fileContent, setFileContent] = useState<string | null>(null);
|
const [fileContent, setFileContent] = useState<string | null>(null);
|
||||||
@@ -943,6 +1054,14 @@ function BuildHubInner() {
|
|||||||
const [fileName, setFileName] = useState<string | null>(null);
|
const [fileName, setFileName] = useState<string | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
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`)
|
fetch(`/api/projects/${projectId}/apps`)
|
||||||
.then(r => r.json())
|
.then(r => r.json())
|
||||||
.then(d => { setApps(d.apps ?? []); }).catch(() => {});
|
.then(d => { setApps(d.apps ?? []); }).catch(() => {});
|
||||||
@@ -1040,6 +1159,21 @@ function BuildHubInner() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
|
||||||
{/* Main content panel */}
|
{/* Main content panel */}
|
||||||
@@ -1061,6 +1195,14 @@ function BuildHubInner() {
|
|||||||
{section === "infrastructure" && (
|
{section === "infrastructure" && (
|
||||||
<InfraContent tab={activeInfra} projectId={projectId} workspace={workspace} />
|
<InfraContent tab={activeInfra} projectId={projectId} workspace={workspace} />
|
||||||
)}
|
)}
|
||||||
|
{section === "preview" && (
|
||||||
|
<PreviewContent
|
||||||
|
projectId={projectId}
|
||||||
|
apps={previewApps}
|
||||||
|
activePreviewApp={activePreviewApp}
|
||||||
|
onSelectApp={setActivePreviewApp}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</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