Files
vibn-frontend/components/project-main/MigrateMain.tsx
Mark Henderson bada63452f feat(ui): apply Justine ink & parchment design system
- Map Justine tokens to shadcn CSS variables (--vibn-* aliases)
- Switch fonts to Inter + Lora via next/font (IBM Plex Mono for code)
- Base typography: body Inter, h1–h3 Lora; marketing hero + wordmark serif
- Project shell and global chrome use semantic colors
- Replace Outfit/Newsreader references across TSX inline styles

Made-with: Cursor
2026-04-01 21:03:40 -07:00

354 lines
20 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { useEffect, useState } from "react";
import { useRouter, useParams } from "next/navigation";
interface MigrateMainProps {
projectId: string;
projectName: string;
sourceData?: { repoUrl?: string; liveUrl?: string; hosting?: string };
analysisResult?: Record<string, unknown>;
migrationPlan?: string;
creationStage?: string;
}
type Stage = "input" | "auditing" | "review" | "planning" | "plan";
const HOSTING_OPTIONS = [
{ value: "", label: "Select hosting provider" },
{ value: "vercel", label: "Vercel" },
{ value: "aws", label: "AWS" },
{ value: "heroku", label: "Heroku" },
{ value: "digitalocean", label: "DigitalOcean" },
{ value: "gcp", label: "Google Cloud Platform" },
{ value: "azure", label: "Microsoft Azure" },
{ value: "railway", label: "Railway" },
{ value: "render", label: "Render" },
{ value: "netlify", label: "Netlify" },
{ value: "self-hosted", label: "Self-hosted / VPS" },
{ value: "other", label: "Other" },
];
function MarkdownRenderer({ md }: { md: string }) {
const lines = md.split('\n');
return (
<div style={{ fontFamily: "var(--font-inter), ui-sans-serif, sans-serif", fontSize: "0.85rem", color: "#1a1a1a", lineHeight: 1.7 }}>
{lines.map((line, i) => {
if (line.startsWith('## ')) return <h2 key={i} style={{ fontFamily: "var(--font-lora), ui-serif, serif", fontSize: "1.2rem", fontWeight: 500, margin: "24px 0 10px", color: "#1a1a1a" }}>{line.slice(3)}</h2>;
if (line.startsWith('### ')) return <h3 key={i} style={{ fontSize: "0.88rem", fontWeight: 700, margin: "18px 0 6px", color: "#1a1a1a" }}>{line.slice(4)}</h3>;
if (line.startsWith('# ')) return <h1 key={i} style={{ fontFamily: "var(--font-lora), ui-serif, serif", fontSize: "1.5rem", fontWeight: 400, margin: "0 0 16px", color: "#1a1a1a" }}>{line.slice(2)}</h1>;
if (line.match(/^- \[ \] /)) return (
<div key={i} style={{ display: "flex", alignItems: "flex-start", gap: 8, marginBottom: 5 }}>
<input type="checkbox" style={{ marginTop: 3, accentColor: "#1a1a1a" }} />
<span>{line.slice(6)}</span>
</div>
);
if (line.match(/^- \[x\] /i)) return (
<div key={i} style={{ display: "flex", alignItems: "flex-start", gap: 8, marginBottom: 5 }}>
<input type="checkbox" defaultChecked style={{ marginTop: 3, accentColor: "#1a1a1a" }} />
<span style={{ textDecoration: "line-through", color: "#a09a90" }}>{line.slice(6)}</span>
</div>
);
if (line.startsWith('- ') || line.startsWith('* ')) return <div key={i} style={{ paddingLeft: 16, marginBottom: 4 }}> {line.slice(2)}</div>;
if (line.startsWith('---')) return <hr key={i} style={{ border: "none", borderTop: "1px solid #e8e4dc", margin: "16px 0" }} />;
if (!line.trim()) return <div key={i} style={{ height: "0.6em" }} />;
// Bold inline
const parts = line.split(/(\*\*.*?\*\*)/g).map((seg, j) =>
seg.startsWith("**") && seg.endsWith("**")
? <strong key={j}>{seg.slice(2, -2)}</strong>
: <span key={j}>{seg}</span>
);
return <p key={i} style={{ margin: "0 0 4px" }}>{parts}</p>;
})}
</div>
);
}
export function MigrateMain({
projectId,
projectName,
sourceData,
analysisResult: initialAnalysis,
migrationPlan: initialPlan,
creationStage,
}: MigrateMainProps) {
const router = useRouter();
const params = useParams();
const workspace = params?.workspace as string;
const getInitialStage = (): Stage => {
if (initialPlan) return "plan";
if (creationStage === "planning") return "planning";
if (creationStage === "review" || initialAnalysis) return "review";
if (sourceData?.repoUrl || sourceData?.liveUrl) return "auditing";
return "input";
};
const [stage, setStage] = useState<Stage>(getInitialStage);
const [repoUrl, setRepoUrl] = useState(sourceData?.repoUrl ?? "");
const [liveUrl, setLiveUrl] = useState(sourceData?.liveUrl ?? "");
const [hosting, setHosting] = useState(sourceData?.hosting ?? "");
const [analysisResult, setAnalysisResult] = useState<Record<string, unknown> | null>(initialAnalysis ?? null);
const [migrationPlan, setMigrationPlan] = useState<string>(initialPlan ?? "");
const [progressStep, setProgressStep] = useState<string>("cloning");
const [error, setError] = useState<string | null>(null);
// Poll during audit
useEffect(() => {
if (stage !== "auditing") return;
const interval = setInterval(async () => {
try {
const res = await fetch(`/api/projects/${projectId}/analysis-status`);
const data = await res.json();
setProgressStep(data.stage ?? "cloning");
if (data.stage === "done" && data.analysisResult) {
setAnalysisResult(data.analysisResult);
clearInterval(interval);
setStage("review");
}
} catch { /* keep polling */ }
}, 2500);
return () => clearInterval(interval);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [stage]);
const startAudit = async () => {
setError(null);
setStage("auditing");
if (repoUrl) {
try {
await fetch(`/api/projects/${projectId}/analyze-repo`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ repoUrl, liveUrl, hosting }),
});
} catch (e) {
setError(e instanceof Error ? e.message : "Failed to start audit");
setStage("input");
}
} else {
// No repo — just use live URL fingerprinting via generate-migration-plan directly
setStage("review");
setAnalysisResult({ summary: `Live product at ${liveUrl}`, rows: [], suggestedSurfaces: [] });
}
};
const startPlanning = async () => {
setStage("planning");
setError(null);
try {
const res = await fetch(`/api/projects/${projectId}/generate-migration-plan`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ analysisResult, sourceData: { repoUrl, liveUrl, hosting } }),
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || "Planning failed");
setMigrationPlan(data.migrationPlan);
setStage("plan");
} catch (e) {
setError(e instanceof Error ? e.message : "Planning failed");
setStage("review");
}
};
// ── Stage: input ──────────────────────────────────────────────────────────
if (stage === "input") {
const canProceed = repoUrl.trim().startsWith("http") || liveUrl.trim().startsWith("http");
return (
<div style={{ height: "100%", overflow: "auto", display: "flex", alignItems: "center", justifyContent: "center", padding: 32 }}>
<div style={{ width: "100%", maxWidth: 540, fontFamily: "var(--font-inter), ui-sans-serif, sans-serif" }}>
<div style={{ marginBottom: 28 }}>
<h2 style={{ fontFamily: "var(--font-lora), ui-serif, serif", fontSize: "1.7rem", fontWeight: 400, color: "#1a1a1a", margin: 0, marginBottom: 6 }}>
Tell us about your product
</h2>
<p style={{ fontSize: "0.82rem", color: "#a09a90", margin: 0 }}>
{projectName} Atlas will audit your current setup and build a safe migration plan.
</p>
</div>
{error && (
<div style={{ padding: "12px 16px", borderRadius: 8, background: "#fff0f0", border: "1px solid #fca5a5", color: "#991b1b", fontSize: "0.8rem", marginBottom: 16 }}>
{error}
</div>
)}
<label style={{ display: "block", fontSize: "0.72rem", fontWeight: 600, color: "#6b6560", marginBottom: 6, letterSpacing: "0.02em" }}>
Repository URL (recommended)
</label>
<input type="text" value={repoUrl} onChange={e => setRepoUrl(e.target.value)}
placeholder="https://github.com/yourorg/your-repo"
style={{ width: "100%", padding: "11px 14px", marginBottom: 16, borderRadius: 8, border: "1px solid #e0dcd4", background: "#faf8f5", fontSize: "0.9rem", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif", color: "#1a1a1a", outline: "none", boxSizing: "border-box" }}
onFocus={e => (e.currentTarget.style.borderColor = "#1a1a1a")}
onBlur={e => (e.currentTarget.style.borderColor = "#e0dcd4")} autoFocus
/>
<label style={{ display: "block", fontSize: "0.72rem", fontWeight: 600, color: "#6b6560", marginBottom: 6, letterSpacing: "0.02em" }}>
Live URL (optional)
</label>
<input type="text" value={liveUrl} onChange={e => setLiveUrl(e.target.value)}
placeholder="https://yourproduct.com"
style={{ width: "100%", padding: "11px 14px", marginBottom: 16, borderRadius: 8, border: "1px solid #e0dcd4", background: "#faf8f5", fontSize: "0.9rem", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif", color: "#1a1a1a", outline: "none", boxSizing: "border-box" }}
onFocus={e => (e.currentTarget.style.borderColor = "#1a1a1a")}
onBlur={e => (e.currentTarget.style.borderColor = "#e0dcd4")}
/>
<label style={{ display: "block", fontSize: "0.72rem", fontWeight: 600, color: "#6b6560", marginBottom: 6, letterSpacing: "0.02em" }}>
Current hosting provider
</label>
<select value={hosting} onChange={e => setHosting(e.target.value)}
style={{ width: "100%", padding: "11px 14px", marginBottom: 20, borderRadius: 8, border: "1px solid #e0dcd4", background: "#faf8f5", fontSize: "0.88rem", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif", color: hosting ? "#1a1a1a" : "#a09a90", outline: "none", boxSizing: "border-box", appearance: "none" }}
>
{HOSTING_OPTIONS.map(o => <option key={o.value} value={o.value}>{o.label}</option>)}
</select>
<div style={{ fontSize: "0.75rem", color: "#a09a90", marginBottom: 20, lineHeight: 1.55, padding: "12px 14px", background: "#faf8f5", borderRadius: 8, border: "1px solid #f0ece4" }}>
<strong style={{ color: "#4a2a5a" }}>Non-destructive.</strong> Your existing product stays live throughout. Atlas duplicates, never deletes.
</div>
<button onClick={startAudit} disabled={!canProceed}
style={{ width: "100%", padding: "13px", borderRadius: 8, border: "none", background: canProceed ? "#1a1a1a" : "#e0dcd4", color: canProceed ? "#fff" : "#b5b0a6", fontSize: "0.9rem", fontWeight: 600, fontFamily: "var(--font-inter), ui-sans-serif, sans-serif", cursor: canProceed ? "pointer" : "not-allowed" }}
>
Start audit
</button>
</div>
</div>
);
}
// ── Stage: auditing ───────────────────────────────────────────────────────
if (stage === "auditing") {
const steps = [
{ key: "cloning", label: "Cloning repository" },
{ key: "reading", label: "Reading configuration" },
{ key: "analyzing", label: "Auditing infrastructure" },
{ key: "done", label: "Audit complete" },
];
const currentIdx = steps.findIndex(s => s.key === progressStep);
return (
<div style={{ height: "100%", display: "flex", alignItems: "center", justifyContent: "center", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif" }}>
<div style={{ textAlign: "center", maxWidth: 400 }}>
<div style={{ width: 52, height: 52, borderRadius: "50%", border: "3px solid #e0dcd4", borderTopColor: "#4a2a5a", animation: "vibn-mig-spin 0.85s linear infinite", margin: "0 auto 24px" }} />
<style>{`@keyframes vibn-mig-spin { to { transform:rotate(360deg); } }`}</style>
<h3 style={{ fontSize: "1.1rem", fontWeight: 600, color: "#1a1a1a", margin: "0 0 8px" }}>Auditing your product</h3>
<p style={{ fontSize: "0.8rem", color: "#a09a90", margin: "0 0 28px" }}>This is non-destructive your live product is untouched</p>
<div style={{ display: "flex", flexDirection: "column", gap: 8, textAlign: "left" }}>
{steps.map((step, i) => {
const done = i < currentIdx;
const active = i === currentIdx;
return (
<div key={step.key} style={{ display: "flex", alignItems: "center", gap: 10 }}>
<div style={{ width: 22, height: 22, borderRadius: "50%", flexShrink: 0, background: done ? "#4a2a5a" : "#f6f4f0", border: active ? "2px solid #4a2a5a" : done ? "none" : "2px solid #e0dcd4", display: "flex", alignItems: "center", justifyContent: "center", fontSize: "0.6rem", fontWeight: 700, color: done ? "#fff" : "#a09a90" }}>
{done ? "✓" : active ? <span style={{ width: 8, height: 8, borderRadius: "50%", background: "#4a2a5a", display: "block" }} /> : ""}
</div>
<span style={{ fontSize: "0.8rem", fontWeight: active ? 600 : 400, color: done ? "#6b6560" : active ? "#1a1a1a" : "#b5b0a6" }}>{step.label}</span>
</div>
);
})}
</div>
</div>
</div>
);
}
// ── Stage: review ─────────────────────────────────────────────────────────
if (stage === "review") {
const rows = (analysisResult?.rows as Array<{ category: string; item: string; status: string; detail?: string }>) ?? [];
const summary = (analysisResult?.summary as string) ?? '';
return (
<div style={{ height: "100%", overflow: "auto", padding: "32px 40px", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif" }}>
<div style={{ maxWidth: 760, margin: "0 auto" }}>
<div style={{ marginBottom: 24 }}>
<h2 style={{ fontFamily: "var(--font-lora), ui-serif, serif", fontSize: "1.7rem", fontWeight: 400, color: "#1a1a1a", margin: 0, marginBottom: 6 }}>Audit complete</h2>
<p style={{ fontSize: "0.8rem", color: "#a09a90", margin: 0 }}>{summary || `${projectName} — review your current infrastructure below.`}</p>
</div>
{rows.length > 0 && (
<div style={{ background: "#fff", borderRadius: 12, border: "1px solid #e8e4dc", overflow: "hidden", marginBottom: 24 }}>
{rows.map((row, i) => {
const colorMap = { found: { bg: "#f0fdf4", text: "#15803d", label: "Found" }, partial: { bg: "#fffbeb", text: "#b45309", label: "Partial" }, missing: { bg: "#fff1f2", text: "#be123c", label: "Missing" } };
const sc = colorMap[row.status as keyof typeof colorMap] ?? colorMap.found;
return (
<div key={i} style={{ display: "flex", alignItems: "center", gap: 12, padding: "12px 20px", borderTop: i > 0 ? "1px solid #f6f4f0" : "none" }}>
<div style={{ fontSize: "0.7rem", color: "#a09a90", width: 110, flexShrink: 0 }}>{row.category}</div>
<div style={{ flex: 1, fontSize: "0.82rem", color: "#1a1a1a", fontWeight: 500 }}>{row.item}</div>
{row.detail && <div style={{ fontSize: "0.75rem", color: "#8a8478", flex: 2 }}>{row.detail}</div>}
<div style={{ padding: "3px 10px", borderRadius: 4, background: sc.bg, color: sc.text, fontSize: "0.68rem", fontWeight: 700, flexShrink: 0 }}>{sc.label}</div>
</div>
);
})}
</div>
)}
{error && (
<div style={{ padding: "12px 16px", borderRadius: 8, background: "#fff0f0", border: "1px solid #fca5a5", color: "#991b1b", fontSize: "0.8rem", marginBottom: 16 }}>
{error}
</div>
)}
<div style={{ background: "#1a1a1a", borderRadius: 12, padding: "22px 24px", display: "flex", alignItems: "center", justifyContent: "space-between", gap: 16 }}>
<div>
<div style={{ fontSize: "0.88rem", fontWeight: 700, color: "#fff", marginBottom: 3 }}>Ready to build the migration plan?</div>
<div style={{ fontSize: "0.75rem", color: "#8a8478" }}>Atlas will generate a phased migration doc with Mirror, Validate, Cutover, and Decommission phases.</div>
</div>
<button onClick={startPlanning}
style={{ padding: "11px 22px", borderRadius: 8, border: "none", background: "#fff", color: "#1a1a1a", fontSize: "0.85rem", fontWeight: 700, fontFamily: "var(--font-inter), ui-sans-serif, sans-serif", cursor: "pointer", flexShrink: 0 }}
onMouseEnter={e => (e.currentTarget.style.opacity = "0.88")}
onMouseLeave={e => (e.currentTarget.style.opacity = "1")}
>
Generate plan
</button>
</div>
</div>
</div>
);
}
// ── Stage: planning ───────────────────────────────────────────────────────
if (stage === "planning") {
return (
<div style={{ height: "100%", display: "flex", alignItems: "center", justifyContent: "center", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif" }}>
<div style={{ textAlign: "center" }}>
<div style={{ width: 52, height: 52, borderRadius: "50%", border: "3px solid #e0dcd4", borderTopColor: "#4a2a5a", animation: "vibn-mig-spin 0.85s linear infinite", margin: "0 auto 20px" }} />
<h3 style={{ fontSize: "1.05rem", fontWeight: 600, color: "#1a1a1a", margin: "0 0 6px" }}>Generating migration plan</h3>
<p style={{ fontSize: "0.8rem", color: "#a09a90", margin: 0 }}>Atlas is designing a safe, phased migration strategy</p>
</div>
</div>
);
}
// ── Stage: plan ───────────────────────────────────────────────────────────
return (
<div style={{ height: "100%", overflow: "auto", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif" }}>
{/* Non-destructive banner */}
<div style={{ background: "#4a2a5a12", borderBottom: "1px solid #4a2a5a30", padding: "12px 32px", display: "flex", alignItems: "center", gap: 10, flexShrink: 0 }}>
<span style={{ fontSize: "1rem" }}>🛡</span>
<div>
<span style={{ fontSize: "0.8rem", fontWeight: 700, color: "#4a2a5a" }}>Non-destructive migration </span>
<span style={{ fontSize: "0.8rem", color: "#6b6560" }}>your existing product stays live throughout every phase. Atlas duplicates, never deletes.</span>
</div>
</div>
<div style={{ padding: "32px 40px" }}>
<div style={{ maxWidth: 760, margin: "0 auto" }}>
<div style={{ marginBottom: 28 }}>
<h2 style={{ fontFamily: "var(--font-lora), ui-serif, serif", fontSize: "1.7rem", fontWeight: 400, color: "#1a1a1a", margin: 0, marginBottom: 6 }}>Migration Plan</h2>
<p style={{ fontSize: "0.8rem", color: "#a09a90", margin: 0 }}>{projectName} four phased migration with rollback plan</p>
</div>
<div style={{ background: "#fff", borderRadius: 12, border: "1px solid #e8e4dc", padding: "28px 32px" }}>
<MarkdownRenderer md={migrationPlan} />
</div>
<div style={{ marginTop: 20, display: "flex", gap: 10 }}>
<button
onClick={() => router.push(`/${workspace}/project/${projectId}/design`)}
style={{ padding: "11px 22px", borderRadius: 8, border: "none", background: "#1a1a1a", color: "#fff", fontSize: "0.85rem", fontWeight: 600, fontFamily: "var(--font-inter), ui-sans-serif, sans-serif", cursor: "pointer" }}
>
Go to Design
</button>
<button
onClick={() => window.print()}
style={{ padding: "11px 22px", borderRadius: 8, border: "1px solid #e0dcd4", background: "#fff", color: "#6b6560", fontSize: "0.85rem", fontWeight: 500, fontFamily: "var(--font-inter), ui-sans-serif, sans-serif", cursor: "pointer" }}
>
Print / Export
</button>
</div>
</div>
</div>
</div>
);
}