feat: implement 4 project type flows with unique AI experiences
- New multi-step CreateProjectFlow replaces 2-step modal with TypeSelector and 4 setup components (Fresh Idea, Chat Import, Code Import, Migrate) - overview/page.tsx routes to unique main component per creationMode - FreshIdeaMain: wraps AtlasChat with post-discovery decision banner (Generate PRD vs Plan MVP Test) - ChatImportMain: 3-stage flow (intake → extracting → review) with editable insight buckets (decisions, ideas, questions, architecture, users) - CodeImportMain: 4-stage flow (input → cloning → mapping → surfaces) with architecture map and surface selection - MigrateMain: 5-stage flow with audit, review, planning, and migration plan doc with checkbox-tracked tasks and non-destructive warning banner - New API routes: analyze-chats, analyze-repo, analysis-status, generate-migration-plan (all using Gemini) - ProjectShell: accepts creationMode prop, filters/renames tabs per type (code-import hides PRD, migration hides PRD/Grow/Insights, renames Atlas tab) - Right panel adapts content based on creationMode Made-with: Cursor
This commit is contained in:
@@ -11,6 +11,7 @@ interface ProjectData {
|
|||||||
createdAt?: string;
|
createdAt?: string;
|
||||||
updatedAt?: string;
|
updatedAt?: string;
|
||||||
featureCount?: number;
|
featureCount?: number;
|
||||||
|
creationMode?: "fresh" | "chat-import" | "code-import" | "migration";
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getProjectData(projectId: string): Promise<ProjectData> {
|
async function getProjectData(projectId: string): Promise<ProjectData> {
|
||||||
@@ -31,6 +32,7 @@ async function getProjectData(projectId: string): Promise<ProjectData> {
|
|||||||
createdAt: created_at,
|
createdAt: created_at,
|
||||||
updatedAt: updated_at,
|
updatedAt: updated_at,
|
||||||
featureCount: Array.isArray(data?.features) ? data.features.length : (data?.featureCount ?? 0),
|
featureCount: Array.isArray(data?.features) ? data.features.length : (data?.featureCount ?? 0),
|
||||||
|
creationMode: data?.creationMode ?? "fresh",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -62,6 +64,7 @@ export default async function ProjectLayout({
|
|||||||
createdAt={project.createdAt}
|
createdAt={project.createdAt}
|
||||||
updatedAt={project.updatedAt}
|
updatedAt={project.updatedAt}
|
||||||
featureCount={project.featureCount}
|
featureCount={project.featureCount}
|
||||||
|
creationMode={project.creationMode}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</ProjectShell>
|
</ProjectShell>
|
||||||
|
|||||||
@@ -3,71 +3,33 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useParams } from "next/navigation";
|
import { useParams } from "next/navigation";
|
||||||
import { useSession } from "next-auth/react";
|
import { useSession } from "next-auth/react";
|
||||||
import { AtlasChat } from "@/components/AtlasChat";
|
|
||||||
import { OrchestratorChat } from "@/components/OrchestratorChat";
|
|
||||||
import { Loader2 } from "lucide-react";
|
import { Loader2 } from "lucide-react";
|
||||||
|
import { FreshIdeaMain } from "@/components/project-main/FreshIdeaMain";
|
||||||
function MobileQRButton({ projectId, workspace }: { projectId: string; workspace: string }) {
|
import { ChatImportMain } from "@/components/project-main/ChatImportMain";
|
||||||
const [show, setShow] = useState(false);
|
import { CodeImportMain } from "@/components/project-main/CodeImportMain";
|
||||||
const url = typeof window !== "undefined"
|
import { MigrateMain } from "@/components/project-main/MigrateMain";
|
||||||
? `${window.location.origin}/${workspace}/project/${projectId}/overview`
|
|
||||||
: "";
|
|
||||||
const qrSrc = `https://api.qrserver.com/v1/create-qr-code/?size=180x180&data=${encodeURIComponent(url)}&bgcolor=f6f4f0&color=1a1a1a&margin=2`;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div style={{ position: "relative" }}>
|
|
||||||
<button
|
|
||||||
onClick={() => setShow(s => !s)}
|
|
||||||
title="Open on your phone"
|
|
||||||
style={{
|
|
||||||
display: "flex", alignItems: "center", gap: 6,
|
|
||||||
padding: "6px 12px", borderRadius: 7,
|
|
||||||
background: "none", border: "1px solid #e0dcd4",
|
|
||||||
fontSize: "0.72rem", fontFamily: "Outfit, sans-serif",
|
|
||||||
color: "#8a8478", cursor: "pointer",
|
|
||||||
transition: "border-color 0.12s",
|
|
||||||
}}
|
|
||||||
onMouseEnter={e => (e.currentTarget.style.borderColor = "#b5b0a6")}
|
|
||||||
onMouseLeave={e => (e.currentTarget.style.borderColor = "#e0dcd4")}
|
|
||||||
>
|
|
||||||
📱 Open on phone
|
|
||||||
</button>
|
|
||||||
{show && (
|
|
||||||
<div style={{
|
|
||||||
position: "absolute", top: "calc(100% + 8px)", right: 0,
|
|
||||||
background: "#fff", borderRadius: 12,
|
|
||||||
border: "1px solid #e8e4dc",
|
|
||||||
boxShadow: "0 8px 24px #1a1a1a12",
|
|
||||||
padding: "16px", zIndex: 50,
|
|
||||||
display: "flex", flexDirection: "column", alignItems: "center", gap: 10,
|
|
||||||
minWidth: 220,
|
|
||||||
}}>
|
|
||||||
<img src={qrSrc} alt="QR code" width={180} height={180} style={{ borderRadius: 8 }} />
|
|
||||||
<p style={{ fontSize: "0.72rem", color: "#8a8478", textAlign: "center", margin: 0, fontFamily: "Outfit, sans-serif" }}>
|
|
||||||
Scan to open Atlas on your phone
|
|
||||||
</p>
|
|
||||||
<p style={{ fontSize: "0.65rem", color: "#b5b0a6", textAlign: "center", margin: 0, fontFamily: "IBM Plex Mono, monospace", wordBreak: "break-all" }}>
|
|
||||||
{url}
|
|
||||||
</p>
|
|
||||||
<button onClick={() => setShow(false)} style={{ fontSize: "0.68rem", color: "#a09a90", background: "none", border: "none", cursor: "pointer" }}>
|
|
||||||
Close
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Project {
|
interface Project {
|
||||||
id: string;
|
id: string;
|
||||||
productName: string;
|
productName: string;
|
||||||
|
name?: string;
|
||||||
stage?: "discovery" | "architecture" | "building" | "active";
|
stage?: "discovery" | "architecture" | "building" | "active";
|
||||||
|
creationMode?: "fresh" | "chat-import" | "code-import" | "migration";
|
||||||
|
creationStage?: string;
|
||||||
|
sourceData?: {
|
||||||
|
chatText?: string;
|
||||||
|
repoUrl?: string;
|
||||||
|
liveUrl?: string;
|
||||||
|
hosting?: string;
|
||||||
|
description?: string;
|
||||||
|
};
|
||||||
|
analysisResult?: Record<string, unknown>;
|
||||||
|
migrationPlan?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ProjectOverviewPage() {
|
export default function ProjectOverviewPage() {
|
||||||
const params = useParams();
|
const params = useParams();
|
||||||
const projectId = params.projectId as string;
|
const projectId = params.projectId as string;
|
||||||
const workspace = params.workspace as string;
|
|
||||||
const { status: authStatus } = useSession();
|
const { status: authStatus } = useSession();
|
||||||
const [project, setProject] = useState<Project | null>(null);
|
const [project, setProject] = useState<Project | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
@@ -78,8 +40,8 @@ export default function ProjectOverviewPage() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
fetch(`/api/projects/${projectId}`)
|
fetch(`/api/projects/${projectId}`)
|
||||||
.then((r) => r.json())
|
.then(r => r.json())
|
||||||
.then((d) => setProject(d.project))
|
.then(d => setProject(d.project))
|
||||||
.catch(() => {})
|
.catch(() => {})
|
||||||
.finally(() => setLoading(false));
|
.finally(() => setLoading(false));
|
||||||
}, [authStatus, projectId]);
|
}, [authStatus, projectId]);
|
||||||
@@ -100,21 +62,50 @@ export default function ProjectOverviewPage() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
const projectName = project.productName || project.name || "Untitled";
|
||||||
<div style={{ height: "100%", display: "flex", flexDirection: "column" }}>
|
const mode = project.creationMode ?? "fresh";
|
||||||
{/* Desktop-only: Open on phone button */}
|
|
||||||
<style>{`@media (max-width: 768px) { .vibn-phone-btn { display: none !important; } }`}</style>
|
|
||||||
<div className="vibn-phone-btn" style={{
|
|
||||||
position: "absolute", top: 14, right: 248,
|
|
||||||
zIndex: 20,
|
|
||||||
}}>
|
|
||||||
<MobileQRButton projectId={projectId} workspace={workspace} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<AtlasChat
|
if (mode === "chat-import") {
|
||||||
|
return (
|
||||||
|
<ChatImportMain
|
||||||
projectId={projectId}
|
projectId={projectId}
|
||||||
projectName={project.productName}
|
projectName={projectName}
|
||||||
|
sourceData={project.sourceData}
|
||||||
|
analysisResult={project.analysisResult as Parameters<typeof ChatImportMain>[0]["analysisResult"]}
|
||||||
/>
|
/>
|
||||||
</div>
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mode === "code-import") {
|
||||||
|
return (
|
||||||
|
<CodeImportMain
|
||||||
|
projectId={projectId}
|
||||||
|
projectName={projectName}
|
||||||
|
sourceData={project.sourceData}
|
||||||
|
analysisResult={project.analysisResult}
|
||||||
|
creationStage={project.creationStage}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mode === "migration") {
|
||||||
|
return (
|
||||||
|
<MigrateMain
|
||||||
|
projectId={projectId}
|
||||||
|
projectName={projectName}
|
||||||
|
sourceData={project.sourceData}
|
||||||
|
analysisResult={project.analysisResult}
|
||||||
|
migrationPlan={project.migrationPlan}
|
||||||
|
creationStage={project.creationStage}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default: "fresh" — wraps AtlasChat with decision banner
|
||||||
|
return (
|
||||||
|
<FreshIdeaMain
|
||||||
|
projectId={projectId}
|
||||||
|
projectName={projectName}
|
||||||
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
37
app/api/projects/[projectId]/analysis-status/route.ts
Normal file
37
app/api/projects/[projectId]/analysis-status/route.ts
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { getServerSession } from 'next-auth';
|
||||||
|
import { authOptions } from '@/lib/auth/authOptions';
|
||||||
|
import { query } from '@/lib/db-postgres';
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
_req: Request,
|
||||||
|
{ params }: { params: Promise<{ projectId: string }> }
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
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::text AND u.data->>'email' = $2::text LIMIT 1`,
|
||||||
|
[projectId, session.user.email]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (rows.length === 0) {
|
||||||
|
return NextResponse.json({ error: 'Project not found' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = rows[0].data ?? {};
|
||||||
|
const stage = (data.analysisStage as string) ?? 'cloning';
|
||||||
|
const analysisResult = stage === 'done' ? data.analysisResult : undefined;
|
||||||
|
|
||||||
|
return NextResponse.json({ stage, analysisResult });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[analysis-status]', err);
|
||||||
|
return NextResponse.json({ error: 'Internal error' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
126
app/api/projects/[projectId]/analyze-chats/route.ts
Normal file
126
app/api/projects/[projectId]/analyze-chats/route.ts
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { getServerSession } from 'next-auth';
|
||||||
|
import { authOptions } from '@/lib/auth/authOptions';
|
||||||
|
import { query } from '@/lib/db-postgres';
|
||||||
|
|
||||||
|
export const maxDuration = 60;
|
||||||
|
|
||||||
|
const GEMINI_API_KEY = process.env.GOOGLE_API_KEY || '';
|
||||||
|
const GEMINI_MODEL = process.env.GEMINI_MODEL || 'gemini-2.0-flash-exp';
|
||||||
|
const GEMINI_BASE_URL = 'https://generativelanguage.googleapis.com/v1beta/models';
|
||||||
|
|
||||||
|
async function callGemini(prompt: string): Promise<string> {
|
||||||
|
const res = await fetch(`${GEMINI_BASE_URL}/${GEMINI_MODEL}:generateContent?key=${GEMINI_API_KEY}`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
contents: [{ parts: [{ text: prompt }] }],
|
||||||
|
generationConfig: { temperature: 0.2, maxOutputTokens: 4096 },
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
const text = data?.candidates?.[0]?.content?.parts?.[0]?.text ?? '';
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseJsonBlock(raw: string): unknown {
|
||||||
|
const trimmed = raw.trim();
|
||||||
|
const cleaned = trimmed.startsWith('```')
|
||||||
|
? trimmed.replace(/^```(?:json)?/i, '').replace(/```$/, '').trim()
|
||||||
|
: trimmed;
|
||||||
|
return JSON.parse(cleaned);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(
|
||||||
|
req: Request,
|
||||||
|
{ params }: { params: Promise<{ projectId: string }> }
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const { projectId } = await params;
|
||||||
|
const session = await getServerSession(authOptions);
|
||||||
|
if (!session?.user?.email) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await req.json() as { chatText?: string };
|
||||||
|
const chatText = body.chatText?.trim() || '';
|
||||||
|
|
||||||
|
if (!chatText) {
|
||||||
|
return NextResponse.json({ error: 'chatText is required' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify project ownership
|
||||||
|
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::text AND u.data->>'email' = $2::text LIMIT 1`,
|
||||||
|
[projectId, session.user.email]
|
||||||
|
);
|
||||||
|
if (rows.length === 0) {
|
||||||
|
return NextResponse.json({ error: 'Project not found' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const extractionPrompt = `You are a product analyst. A founder has pasted AI chat conversation history below.
|
||||||
|
|
||||||
|
Extract and categorise the following from those conversations. Return ONLY valid JSON — no markdown, no explanation.
|
||||||
|
|
||||||
|
JSON schema:
|
||||||
|
{
|
||||||
|
"decisions": ["string — concrete decisions already made"],
|
||||||
|
"ideas": ["string — product ideas and features mentioned"],
|
||||||
|
"openQuestions": ["string — unresolved questions that still need answers"],
|
||||||
|
"architecture": ["string — technical architecture notes, stack choices, infra decisions"],
|
||||||
|
"targetUsers": ["string — user segments, personas, or target audiences mentioned"]
|
||||||
|
}
|
||||||
|
|
||||||
|
Each array can be empty if nothing was found for that category. Extract real content — be specific and concise. Max 10 items per bucket.
|
||||||
|
|
||||||
|
--- CHAT HISTORY START ---
|
||||||
|
${chatText.slice(0, 12000)}
|
||||||
|
--- CHAT HISTORY END ---
|
||||||
|
|
||||||
|
Return only the JSON object:`;
|
||||||
|
|
||||||
|
const raw = await callGemini(extractionPrompt);
|
||||||
|
|
||||||
|
let analysisResult: {
|
||||||
|
decisions: string[];
|
||||||
|
ideas: string[];
|
||||||
|
openQuestions: string[];
|
||||||
|
architecture: string[];
|
||||||
|
targetUsers: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
analysisResult = parseJsonBlock(raw) as typeof analysisResult;
|
||||||
|
} catch {
|
||||||
|
// Fallback: return empty buckets with a note
|
||||||
|
analysisResult = {
|
||||||
|
decisions: [],
|
||||||
|
ideas: [],
|
||||||
|
openQuestions: ["Could not parse extracted insights — try pasting more structured conversation"],
|
||||||
|
architecture: [],
|
||||||
|
targetUsers: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save analysis result to project data
|
||||||
|
const current = rows[0].data ?? {};
|
||||||
|
const updated = {
|
||||||
|
...current,
|
||||||
|
analysisResult,
|
||||||
|
creationStage: 'review',
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
await query(
|
||||||
|
`UPDATE fs_projects SET data = $2::jsonb WHERE id = $1::text`,
|
||||||
|
[projectId, JSON.stringify(updated)]
|
||||||
|
);
|
||||||
|
|
||||||
|
return NextResponse.json({ analysisResult });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[analyze-chats]', err);
|
||||||
|
return NextResponse.json({ error: 'Internal error' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
216
app/api/projects/[projectId]/analyze-repo/route.ts
Normal file
216
app/api/projects/[projectId]/analyze-repo/route.ts
Normal file
@@ -0,0 +1,216 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { getServerSession } from 'next-auth';
|
||||||
|
import { authOptions } from '@/lib/auth/authOptions';
|
||||||
|
import { query } from '@/lib/db-postgres';
|
||||||
|
import { execSync } from 'child_process';
|
||||||
|
import { existsSync, readdirSync, readFileSync, statSync, rmSync } from 'fs';
|
||||||
|
import { join } from 'path';
|
||||||
|
|
||||||
|
export const maxDuration = 120;
|
||||||
|
|
||||||
|
const GEMINI_API_KEY = process.env.GOOGLE_API_KEY || '';
|
||||||
|
const GEMINI_MODEL = process.env.GEMINI_MODEL || 'gemini-2.0-flash-exp';
|
||||||
|
const GEMINI_BASE_URL = 'https://generativelanguage.googleapis.com/v1beta/models';
|
||||||
|
|
||||||
|
async function callGemini(prompt: string): Promise<string> {
|
||||||
|
const res = await fetch(`${GEMINI_BASE_URL}/${GEMINI_MODEL}:generateContent?key=${GEMINI_API_KEY}`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
contents: [{ parts: [{ text: prompt }] }],
|
||||||
|
generationConfig: { temperature: 0.2, maxOutputTokens: 6000 },
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
return data?.candidates?.[0]?.content?.parts?.[0]?.text ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseJsonBlock(raw: string): unknown {
|
||||||
|
const trimmed = raw.trim();
|
||||||
|
const cleaned = trimmed.startsWith('```')
|
||||||
|
? trimmed.replace(/^```(?:json)?/i, '').replace(/```$/, '').trim()
|
||||||
|
: trimmed;
|
||||||
|
return JSON.parse(cleaned);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read a file safely, returning empty string on failure
|
||||||
|
function safeRead(path: string, maxBytes = 8000): string {
|
||||||
|
try {
|
||||||
|
if (!existsSync(path)) return '';
|
||||||
|
const content = readFileSync(path, 'utf8');
|
||||||
|
return content.slice(0, maxBytes);
|
||||||
|
} catch {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Walk directory and collect file listing (relative paths), limited to avoid huge outputs
|
||||||
|
function walkDir(dir: string, depth = 0, maxDepth = 4, acc: string[] = []): string[] {
|
||||||
|
if (depth > maxDepth) return acc;
|
||||||
|
try {
|
||||||
|
const entries = readdirSync(dir, { withFileTypes: true });
|
||||||
|
for (const e of entries) {
|
||||||
|
if (e.name.startsWith('.') || e.name === 'node_modules' || e.name === '__pycache__' || e.name === '.git') continue;
|
||||||
|
const full = join(dir, e.name);
|
||||||
|
const rel = full.replace(dir + '/', '');
|
||||||
|
if (e.isDirectory()) {
|
||||||
|
acc.push(rel + '/');
|
||||||
|
walkDir(full, depth + 1, maxDepth, acc);
|
||||||
|
} else {
|
||||||
|
acc.push(rel);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch { /* skip */ }
|
||||||
|
return acc;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateStage(projectId: string, currentData: Record<string, unknown>, stage: string) {
|
||||||
|
const updated = { ...currentData, analysisStage: stage, updatedAt: new Date().toISOString() };
|
||||||
|
await query(
|
||||||
|
`UPDATE fs_projects SET data = $2::jsonb WHERE id = $1::text`,
|
||||||
|
[projectId, JSON.stringify(updated)]
|
||||||
|
);
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(
|
||||||
|
req: Request,
|
||||||
|
{ params }: { params: Promise<{ projectId: string }> }
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const { projectId } = await params;
|
||||||
|
const session = await getServerSession(authOptions);
|
||||||
|
if (!session?.user?.email) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await req.json() as { repoUrl?: string };
|
||||||
|
const repoUrl = body.repoUrl?.trim() || '';
|
||||||
|
|
||||||
|
if (!repoUrl.startsWith('http')) {
|
||||||
|
return NextResponse.json({ error: 'Invalid repository URL' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify ownership
|
||||||
|
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::text AND u.data->>'email' = $2::text LIMIT 1`,
|
||||||
|
[projectId, session.user.email]
|
||||||
|
);
|
||||||
|
if (rows.length === 0) {
|
||||||
|
return NextResponse.json({ error: 'Project not found' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
let currentData = rows[0].data ?? {};
|
||||||
|
currentData = await updateStage(projectId, currentData, 'cloning');
|
||||||
|
|
||||||
|
// Clone repo into temp dir (fire and forget — status is polled separately)
|
||||||
|
const tmpDir = `/tmp/vibn-${projectId}`;
|
||||||
|
|
||||||
|
// Run async so the request returns quickly and client can poll
|
||||||
|
setImmediate(async () => {
|
||||||
|
try {
|
||||||
|
// Clean up any existing clone
|
||||||
|
if (existsSync(tmpDir)) {
|
||||||
|
rmSync(tmpDir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
execSync(`git clone --depth=1 "${repoUrl}" "${tmpDir}"`, {
|
||||||
|
timeout: 60_000,
|
||||||
|
stdio: 'ignore',
|
||||||
|
});
|
||||||
|
|
||||||
|
let data = { ...currentData };
|
||||||
|
data = await updateStage(projectId, data, 'reading');
|
||||||
|
|
||||||
|
// Read key files
|
||||||
|
const manifest: Record<string, string> = {};
|
||||||
|
const keyFiles = [
|
||||||
|
'package.json', 'package-lock.json', 'yarn.lock', 'pnpm-lock.yaml',
|
||||||
|
'requirements.txt', 'Pipfile', 'pyproject.toml',
|
||||||
|
'Dockerfile', 'docker-compose.yml', 'docker-compose.yaml',
|
||||||
|
'README.md', '.env.example', '.env.sample',
|
||||||
|
'next.config.js', 'next.config.ts', 'next.config.mjs',
|
||||||
|
'vite.config.ts', 'vite.config.js',
|
||||||
|
'tsconfig.json',
|
||||||
|
'prisma/schema.prisma', 'schema.prisma',
|
||||||
|
];
|
||||||
|
for (const f of keyFiles) {
|
||||||
|
const content = safeRead(join(tmpDir, f));
|
||||||
|
if (content) manifest[f] = content;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fileListing = walkDir(tmpDir).slice(0, 300).join('\n');
|
||||||
|
|
||||||
|
data = await updateStage(projectId, data, 'analyzing');
|
||||||
|
|
||||||
|
const analysisPrompt = `You are a senior full-stack architect. Analyse this repository and return a structured architecture map.
|
||||||
|
|
||||||
|
File listing (top-level):
|
||||||
|
${fileListing}
|
||||||
|
|
||||||
|
Key file contents:
|
||||||
|
${Object.entries(manifest).map(([k, v]) => `\n### ${k}\n${v}`).join('')}
|
||||||
|
|
||||||
|
Return ONLY valid JSON with this structure:
|
||||||
|
{
|
||||||
|
"summary": "1-2 sentence project summary",
|
||||||
|
"rows": [
|
||||||
|
{ "category": "Tech Stack", "item": "Next.js 15", "status": "found", "detail": "next.config.ts present" },
|
||||||
|
{ "category": "Database", "item": "PostgreSQL", "status": "found", "detail": "prisma/schema.prisma detected" },
|
||||||
|
{ "category": "Auth", "item": "Authentication", "status": "missing", "detail": "No auth library detected" }
|
||||||
|
],
|
||||||
|
"suggestedSurfaces": ["marketing", "admin"]
|
||||||
|
}
|
||||||
|
|
||||||
|
Categories to cover: Tech Stack, Infrastructure, Database, API Surface, Frontend, Auth, Third-party, Missing / Gaps
|
||||||
|
Status values: "found", "partial", "missing"
|
||||||
|
suggestedSurfaces should only include items from: ["marketing", "web-app", "admin", "api"]
|
||||||
|
Suggest surfaces that are MISSING or incomplete in the current codebase.
|
||||||
|
|
||||||
|
Return only the JSON:`;
|
||||||
|
|
||||||
|
const raw = await callGemini(analysisPrompt);
|
||||||
|
let analysisResult;
|
||||||
|
try {
|
||||||
|
analysisResult = parseJsonBlock(raw);
|
||||||
|
} catch {
|
||||||
|
analysisResult = {
|
||||||
|
summary: 'Could not fully parse the repository structure.',
|
||||||
|
rows: [{ category: 'Tech Stack', item: 'Repository detected', status: 'found', detail: fileListing.split('\n').slice(0, 5).join(', ') }],
|
||||||
|
suggestedSurfaces: ['marketing'],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save result and mark done
|
||||||
|
const finalData = {
|
||||||
|
...data,
|
||||||
|
analysisStage: 'done',
|
||||||
|
analysisResult,
|
||||||
|
creationStage: 'mapping',
|
||||||
|
sourceData: { ...(data.sourceData as object || {}), repoUrl },
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
await query(
|
||||||
|
`UPDATE fs_projects SET data = $2::jsonb WHERE id = $1::text`,
|
||||||
|
[projectId, JSON.stringify(finalData)]
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[analyze-repo] background error', err);
|
||||||
|
await query(
|
||||||
|
`UPDATE fs_projects SET data = $2::jsonb WHERE id = $1::text`,
|
||||||
|
[projectId, JSON.stringify({ ...currentData, analysisStage: 'error', analysisError: String(err) })]
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
// Clean up
|
||||||
|
try { if (existsSync(tmpDir)) rmSync(tmpDir, { recursive: true, force: true }); } catch { /* ok */ }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({ started: true });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[analyze-repo]', err);
|
||||||
|
return NextResponse.json({ error: 'Internal error' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
139
app/api/projects/[projectId]/generate-migration-plan/route.ts
Normal file
139
app/api/projects/[projectId]/generate-migration-plan/route.ts
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { getServerSession } from 'next-auth';
|
||||||
|
import { authOptions } from '@/lib/auth/authOptions';
|
||||||
|
import { query } from '@/lib/db-postgres';
|
||||||
|
|
||||||
|
export const maxDuration = 120;
|
||||||
|
|
||||||
|
const GEMINI_API_KEY = process.env.GOOGLE_API_KEY || '';
|
||||||
|
const GEMINI_MODEL = process.env.GEMINI_MODEL || 'gemini-2.0-flash-exp';
|
||||||
|
const GEMINI_BASE_URL = 'https://generativelanguage.googleapis.com/v1beta/models';
|
||||||
|
|
||||||
|
async function callGemini(prompt: string): Promise<string> {
|
||||||
|
const res = await fetch(`${GEMINI_BASE_URL}/${GEMINI_MODEL}:generateContent?key=${GEMINI_API_KEY}`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
contents: [{ parts: [{ text: prompt }] }],
|
||||||
|
generationConfig: { temperature: 0.3, maxOutputTokens: 8000 },
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
return data?.candidates?.[0]?.content?.parts?.[0]?.text ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(
|
||||||
|
req: Request,
|
||||||
|
{ params }: { params: Promise<{ projectId: string }> }
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const { projectId } = await params;
|
||||||
|
const session = await getServerSession(authOptions);
|
||||||
|
if (!session?.user?.email) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await req.json() as {
|
||||||
|
analysisResult?: Record<string, unknown>;
|
||||||
|
sourceData?: { repoUrl?: string; liveUrl?: string; hosting?: string };
|
||||||
|
};
|
||||||
|
|
||||||
|
// Verify ownership
|
||||||
|
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::text AND u.data->>'email' = $2::text LIMIT 1`,
|
||||||
|
[projectId, session.user.email]
|
||||||
|
);
|
||||||
|
if (rows.length === 0) {
|
||||||
|
return NextResponse.json({ error: 'Project not found' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const current = rows[0].data ?? {};
|
||||||
|
const projectName = (current.productName as string) || (current.name as string) || 'the product';
|
||||||
|
const { analysisResult, sourceData } = body;
|
||||||
|
|
||||||
|
const prompt = `You are a senior DevOps and platform migration architect. Generate a comprehensive, phased migration plan in Markdown for migrating an existing product into a new infrastructure (VIBN — a self-hosted PaaS).
|
||||||
|
|
||||||
|
Product: ${projectName}
|
||||||
|
Repo: ${sourceData?.repoUrl || 'Not provided'}
|
||||||
|
Live URL: ${sourceData?.liveUrl || 'Not provided'}
|
||||||
|
Current hosting: ${sourceData?.hosting || 'Unknown'}
|
||||||
|
|
||||||
|
Architecture audit summary:
|
||||||
|
${analysisResult?.summary || 'No audit data provided.'}
|
||||||
|
|
||||||
|
Detected components:
|
||||||
|
${JSON.stringify(analysisResult?.rows || [], null, 2).slice(0, 3000)}
|
||||||
|
|
||||||
|
Generate a complete migration plan with exactly these 4 phases:
|
||||||
|
|
||||||
|
# ${projectName} — Migration Plan
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
Brief 2-3 sentence description of the migration approach and guiding principle (non-destructive duplication).
|
||||||
|
|
||||||
|
## Phase 1: Mirror
|
||||||
|
Set up parallel infrastructure on VIBN without touching production.
|
||||||
|
- [ ] Clone repository to VIBN Gitea
|
||||||
|
- [ ] Configure Coolify application
|
||||||
|
- [ ] Set up identical database schema
|
||||||
|
- [ ] Configure environment variables
|
||||||
|
- [ ] Verify build passes
|
||||||
|
|
||||||
|
## Phase 2: Validate
|
||||||
|
Run both systems in parallel and compare outputs.
|
||||||
|
- [ ] Route 5% of traffic to new infrastructure (or test internally)
|
||||||
|
- [ ] Compare API responses between old and new
|
||||||
|
- [ ] Run full end-to-end test suite
|
||||||
|
- [ ] Validate data sync between databases
|
||||||
|
- [ ] Sign off on performance benchmarks
|
||||||
|
|
||||||
|
## Phase 3: Cutover
|
||||||
|
Redirect production traffic to the new infrastructure.
|
||||||
|
- [ ] Update DNS records to point to VIBN load balancer
|
||||||
|
- [ ] Monitor error rates and latency for 24h
|
||||||
|
- [ ] Validate all integrations (auth, payments, third-party APIs)
|
||||||
|
- [ ] Keep old infrastructure on standby for 7 days
|
||||||
|
|
||||||
|
## Phase 4: Decommission
|
||||||
|
Remove old infrastructure after successful validation period.
|
||||||
|
- [ ] Confirm all data has been migrated
|
||||||
|
- [ ] Archive old repository access
|
||||||
|
- [ ] Terminate old hosting resources
|
||||||
|
- [ ] Update all internal documentation
|
||||||
|
|
||||||
|
## Risk Register
|
||||||
|
| Risk | Likelihood | Impact | Mitigation |
|
||||||
|
|------|-----------|--------|------------|
|
||||||
|
| Database migration failure | Medium | High | Full backup before any migration step |
|
||||||
|
| DNS propagation delay | Low | Medium | Use low TTL before cutover |
|
||||||
|
| Third-party integration breakage | Medium | High | Test all webhooks and OAuth in Phase 2 |
|
||||||
|
|
||||||
|
## Rollback Plan
|
||||||
|
At any phase, revert by: pointing DNS back to original infrastructure. Data written during parallel run must be synced back manually. Old infrastructure MUST remain live until Phase 4 completes.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
Write a thorough, specific plan. Use real details from the audit where available. Every checklist item should be actionable. Return only the Markdown document.`;
|
||||||
|
|
||||||
|
const migrationPlan = await callGemini(prompt);
|
||||||
|
|
||||||
|
// Save to project
|
||||||
|
const updated = {
|
||||||
|
...current,
|
||||||
|
migrationPlan,
|
||||||
|
creationStage: 'plan',
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
await query(
|
||||||
|
`UPDATE fs_projects SET data = $2::jsonb WHERE id = $1::text`,
|
||||||
|
[projectId, JSON.stringify(updated)]
|
||||||
|
);
|
||||||
|
|
||||||
|
return NextResponse.json({ migrationPlan });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[generate-migration-plan]', err);
|
||||||
|
return NextResponse.json({ error: 'Internal error' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -19,9 +19,10 @@ interface ProjectShellProps {
|
|||||||
createdAt?: string;
|
createdAt?: string;
|
||||||
updatedAt?: string;
|
updatedAt?: string;
|
||||||
featureCount?: number;
|
featureCount?: number;
|
||||||
|
creationMode?: "fresh" | "chat-import" | "code-import" | "migration";
|
||||||
}
|
}
|
||||||
|
|
||||||
const TABS = [
|
const ALL_TABS = [
|
||||||
{ id: "overview", label: "Atlas", path: "overview" },
|
{ id: "overview", label: "Atlas", path: "overview" },
|
||||||
{ id: "prd", label: "PRD", path: "prd" },
|
{ id: "prd", label: "PRD", path: "prd" },
|
||||||
{ id: "design", label: "Design", path: "design" },
|
{ id: "design", label: "Design", path: "design" },
|
||||||
@@ -32,6 +33,23 @@ const TABS = [
|
|||||||
{ id: "settings", label: "Settings", path: "settings" },
|
{ id: "settings", label: "Settings", path: "settings" },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
function getTabsForMode(
|
||||||
|
mode: "fresh" | "chat-import" | "code-import" | "migration" = "fresh"
|
||||||
|
) {
|
||||||
|
switch (mode) {
|
||||||
|
case "code-import":
|
||||||
|
// Hide PRD — this project already has code; goal is go-to-market surfaces
|
||||||
|
return ALL_TABS.filter(t => t.id !== "prd");
|
||||||
|
case "migration":
|
||||||
|
// Hide PRD, rename overview, hide Grow and Insights (less relevant)
|
||||||
|
return ALL_TABS
|
||||||
|
.filter(t => !["prd", "grow", "insights"].includes(t.id))
|
||||||
|
.map(t => t.id === "overview" ? { ...t, label: "Migration Plan" } : t);
|
||||||
|
default:
|
||||||
|
return ALL_TABS;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const DISCOVERY_PHASES = [
|
const DISCOVERY_PHASES = [
|
||||||
{ id: "big_picture", label: "Big Picture" },
|
{ id: "big_picture", label: "Big Picture" },
|
||||||
{ id: "users_personas", label: "Users & Personas" },
|
{ id: "users_personas", label: "Users & Personas" },
|
||||||
@@ -101,8 +119,10 @@ export function ProjectShell({
|
|||||||
createdAt,
|
createdAt,
|
||||||
updatedAt,
|
updatedAt,
|
||||||
featureCount = 0,
|
featureCount = 0,
|
||||||
|
creationMode,
|
||||||
}: ProjectShellProps) {
|
}: ProjectShellProps) {
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
|
const TABS = getTabsForMode(creationMode);
|
||||||
const activeTab = TABS.find((t) => pathname?.includes(`/${t.path}`))?.id ?? "overview";
|
const activeTab = TABS.find((t) => pathname?.includes(`/${t.path}`))?.id ?? "overview";
|
||||||
const progress = projectProgress ?? 0;
|
const progress = projectProgress ?? 0;
|
||||||
|
|
||||||
@@ -250,68 +270,84 @@ export function ProjectShell({
|
|||||||
fontFamily: "Outfit, sans-serif",
|
fontFamily: "Outfit, sans-serif",
|
||||||
display: activeTab === "design" ? "none" : undefined,
|
display: activeTab === "design" ? "none" : undefined,
|
||||||
}}>
|
}}>
|
||||||
{/* Discovery phases */}
|
{/* Right panel content — varies by creation mode */}
|
||||||
<SectionLabel>Discovery</SectionLabel>
|
{(creationMode === "code-import" || creationMode === "migration") ? (
|
||||||
{DISCOVERY_PHASES.map((phase, i) => {
|
<>
|
||||||
const isDone = savedPhaseIds.has(phase.id);
|
<SectionLabel>
|
||||||
const isActive = !isDone && i === firstUnsavedIdx;
|
{creationMode === "migration" ? "Migration" : "Import"}
|
||||||
return (
|
</SectionLabel>
|
||||||
<div
|
<div style={{ fontSize: "0.78rem", color: "#a09a90", lineHeight: 1.5, marginBottom: 16 }}>
|
||||||
key={phase.id}
|
{creationMode === "migration"
|
||||||
style={{
|
? "Atlas will audit your existing product and generate a safe, phased migration plan."
|
||||||
display: "flex", alignItems: "center", gap: 10,
|
: "Atlas will clone your repository and map the architecture, then suggest surfaces to build."}
|
||||||
padding: "9px 0",
|
|
||||||
borderBottom: i < DISCOVERY_PHASES.length - 1 ? "1px solid #f0ece4" : "none",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div style={{
|
|
||||||
width: 20, height: 20, borderRadius: 5, flexShrink: 0,
|
|
||||||
background: isDone ? "#2e7d3210" : isActive ? "#d4a04a12" : "#f6f4f0",
|
|
||||||
display: "flex", alignItems: "center", justifyContent: "center",
|
|
||||||
fontSize: "0.58rem", fontWeight: 700,
|
|
||||||
color: isDone ? "#2e7d32" : isActive ? "#9a7b3a" : "#c5c0b8",
|
|
||||||
}}>
|
|
||||||
{isDone ? "✓" : isActive ? "→" : i + 1}
|
|
||||||
</div>
|
|
||||||
<span style={{
|
|
||||||
fontSize: "0.78rem",
|
|
||||||
fontWeight: isActive ? 600 : 400,
|
|
||||||
color: isDone ? "#6b6560" : isActive ? "#1a1a1a" : "#b5b0a6",
|
|
||||||
}}>
|
|
||||||
{phase.label}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
</>
|
||||||
})}
|
|
||||||
|
|
||||||
<div style={{ height: 1, background: "#f0ece4", margin: "16px 0" }} />
|
|
||||||
|
|
||||||
{/* Captured data — summaries from saved phases */}
|
|
||||||
<SectionLabel>Captured</SectionLabel>
|
|
||||||
{savedPhases.length > 0 ? (
|
|
||||||
savedPhases.map((p) => (
|
|
||||||
<div key={p.phase} style={{ marginBottom: 14 }}>
|
|
||||||
<div style={{
|
|
||||||
fontSize: "0.62rem", color: "#2e7d32",
|
|
||||||
textTransform: "uppercase", letterSpacing: "0.05em",
|
|
||||||
marginBottom: 3, fontWeight: 600, display: "flex", alignItems: "center", gap: 4,
|
|
||||||
}}>
|
|
||||||
<span>✓</span><span>{p.title}</span>
|
|
||||||
</div>
|
|
||||||
<div style={{ fontSize: "0.75rem", color: "#4a4640", lineHeight: 1.45 }}>
|
|
||||||
{p.summary}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))
|
|
||||||
) : (
|
) : (
|
||||||
<p style={{ fontSize: "0.78rem", color: "#c5c0b8", lineHeight: 1.5, margin: 0 }}>
|
<>
|
||||||
Atlas will capture key details here as you chat.
|
{/* Discovery phases */}
|
||||||
</p>
|
<SectionLabel>Discovery</SectionLabel>
|
||||||
|
{DISCOVERY_PHASES.map((phase, i) => {
|
||||||
|
const isDone = savedPhaseIds.has(phase.id);
|
||||||
|
const isActive = !isDone && i === firstUnsavedIdx;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={phase.id}
|
||||||
|
style={{
|
||||||
|
display: "flex", alignItems: "center", gap: 10,
|
||||||
|
padding: "9px 0",
|
||||||
|
borderBottom: i < DISCOVERY_PHASES.length - 1 ? "1px solid #f0ece4" : "none",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{
|
||||||
|
width: 20, height: 20, borderRadius: 5, flexShrink: 0,
|
||||||
|
background: isDone ? "#2e7d3210" : isActive ? "#d4a04a12" : "#f6f4f0",
|
||||||
|
display: "flex", alignItems: "center", justifyContent: "center",
|
||||||
|
fontSize: "0.58rem", fontWeight: 700,
|
||||||
|
color: isDone ? "#2e7d32" : isActive ? "#9a7b3a" : "#c5c0b8",
|
||||||
|
}}>
|
||||||
|
{isDone ? "✓" : isActive ? "→" : i + 1}
|
||||||
|
</div>
|
||||||
|
<span style={{
|
||||||
|
fontSize: "0.78rem",
|
||||||
|
fontWeight: isActive ? 600 : 400,
|
||||||
|
color: isDone ? "#6b6560" : isActive ? "#1a1a1a" : "#b5b0a6",
|
||||||
|
}}>
|
||||||
|
{phase.label}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
<div style={{ height: 1, background: "#f0ece4", margin: "16px 0" }} />
|
||||||
|
|
||||||
|
{/* Captured data — summaries from saved phases */}
|
||||||
|
<SectionLabel>Captured</SectionLabel>
|
||||||
|
{savedPhases.length > 0 ? (
|
||||||
|
savedPhases.map((p) => (
|
||||||
|
<div key={p.phase} style={{ marginBottom: 14 }}>
|
||||||
|
<div style={{
|
||||||
|
fontSize: "0.62rem", color: "#2e7d32",
|
||||||
|
textTransform: "uppercase", letterSpacing: "0.05em",
|
||||||
|
marginBottom: 3, fontWeight: 600, display: "flex", alignItems: "center", gap: 4,
|
||||||
|
}}>
|
||||||
|
<span>✓</span><span>{p.title}</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: "0.75rem", color: "#4a4640", lineHeight: 1.45 }}>
|
||||||
|
{p.summary}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<p style={{ fontSize: "0.78rem", color: "#c5c0b8", lineHeight: 1.5, margin: 0 }}>
|
||||||
|
Atlas will capture key details here as you chat.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div style={{ height: 1, background: "#f0ece4", margin: "16px 0" }} />
|
<div style={{ height: 1, background: "#f0ece4", margin: "16px 0" }} />
|
||||||
|
|
||||||
{/* Project info */}
|
{/* Project info — always shown */}
|
||||||
<SectionLabel>Project Info</SectionLabel>
|
<SectionLabel>Project Info</SectionLabel>
|
||||||
{[
|
{[
|
||||||
{ k: "Created", v: timeAgo(createdAt) },
|
{ k: "Created", v: timeAgo(createdAt) },
|
||||||
|
|||||||
@@ -1,278 +1,6 @@
|
|||||||
'use client';
|
"use client";
|
||||||
|
|
||||||
import { useState, useEffect, useRef } from 'react';
|
// Re-export the new multi-step creation flow as a drop-in replacement
|
||||||
import { createPortal } from 'react-dom';
|
// for the original 2-step ProjectCreationModal.
|
||||||
import { useRouter } from 'next/navigation';
|
export { CreateProjectFlow as ProjectCreationModal } from "./project-creation/CreateProjectFlow";
|
||||||
import { toast } from 'sonner';
|
export type { CreationMode } from "./project-creation/CreateProjectFlow";
|
||||||
|
|
||||||
interface ProjectCreationModalProps {
|
|
||||||
open: boolean;
|
|
||||||
onOpenChange: (open: boolean) => void;
|
|
||||||
workspace: string;
|
|
||||||
initialWorkspacePath?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const PROJECT_TYPES = [
|
|
||||||
{ id: 'web-app', label: 'Web App', icon: '⬡', desc: 'SaaS product users log into — dashboards, accounts, core features' },
|
|
||||||
{ id: 'website', label: 'Website', icon: '◎', desc: 'Marketing site, landing page, or content-driven public site' },
|
|
||||||
{ id: 'marketplace', label: 'Marketplace', icon: '⇄', desc: 'Two-sided platform connecting buyers and sellers or providers' },
|
|
||||||
{ id: 'mobile', label: 'Mobile App', icon: '▢', desc: 'iOS and Android app — touch-first, native feel' },
|
|
||||||
{ id: 'internal', label: 'Internal Tool', icon: '◫', desc: 'Admin panel, ops dashboard, or business process tool' },
|
|
||||||
{ id: 'ai-product', label: 'AI Product', icon: '◈', desc: 'AI-native product — copilot, agent, or model-powered workflow' },
|
|
||||||
];
|
|
||||||
|
|
||||||
export function ProjectCreationModal({ open, onOpenChange, workspace }: ProjectCreationModalProps) {
|
|
||||||
const router = useRouter();
|
|
||||||
const [step, setStep] = useState<1 | 2>(1);
|
|
||||||
const [productName, setProductName] = useState('');
|
|
||||||
const [projectType, setProjectType] = useState<string | null>(null);
|
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (open) {
|
|
||||||
setStep(1);
|
|
||||||
setProductName('');
|
|
||||||
setProjectType(null);
|
|
||||||
setLoading(false);
|
|
||||||
setTimeout(() => inputRef.current?.focus(), 80);
|
|
||||||
}
|
|
||||||
}, [open]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!open) return;
|
|
||||||
const handler = (e: KeyboardEvent) => { if (e.key === 'Escape') onOpenChange(false); };
|
|
||||||
window.addEventListener('keydown', handler);
|
|
||||||
return () => window.removeEventListener('keydown', handler);
|
|
||||||
}, [open, onOpenChange]);
|
|
||||||
|
|
||||||
const handleCreate = async () => {
|
|
||||||
if (!productName.trim() || !projectType) return;
|
|
||||||
setLoading(true);
|
|
||||||
try {
|
|
||||||
const res = await fetch('/api/projects/create', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({
|
|
||||||
projectName: productName.trim(),
|
|
||||||
projectType,
|
|
||||||
slug: productName.toLowerCase().replace(/[^a-z0-9]+/g, '-'),
|
|
||||||
product: { name: productName.trim(), type: projectType },
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
if (!res.ok) {
|
|
||||||
const err = await res.json();
|
|
||||||
toast.error(err.error || 'Failed to create project');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const data = await res.json();
|
|
||||||
onOpenChange(false);
|
|
||||||
router.push(`/${workspace}/project/${data.projectId}/overview`);
|
|
||||||
} catch {
|
|
||||||
toast.error('Something went wrong');
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!open) return null;
|
|
||||||
|
|
||||||
return createPortal(
|
|
||||||
<>
|
|
||||||
{/* Backdrop */}
|
|
||||||
<div
|
|
||||||
onClick={() => onOpenChange(false)}
|
|
||||||
style={{
|
|
||||||
position: 'fixed', inset: 0, zIndex: 50,
|
|
||||||
background: 'rgba(26,26,26,0.35)',
|
|
||||||
animation: 'fadeIn 0.15s ease',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Modal */}
|
|
||||||
<div style={{
|
|
||||||
position: 'fixed', inset: 0, zIndex: 51,
|
|
||||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
|
||||||
padding: 24, pointerEvents: 'none',
|
|
||||||
}}>
|
|
||||||
<div
|
|
||||||
onClick={e => e.stopPropagation()}
|
|
||||||
style={{
|
|
||||||
background: '#fff', borderRadius: 14,
|
|
||||||
boxShadow: '0 8px 40px rgba(26,26,26,0.14)',
|
|
||||||
padding: '32px 36px',
|
|
||||||
width: '100%', maxWidth: step === 2 ? 560 : 460,
|
|
||||||
fontFamily: 'Outfit, sans-serif',
|
|
||||||
pointerEvents: 'all',
|
|
||||||
animation: 'slideUp 0.18s cubic-bezier(0.4,0,0.2,1)',
|
|
||||||
transition: 'max-width 0.2s ease',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<style>{`
|
|
||||||
@keyframes fadeIn { from { opacity:0; } to { opacity:1; } }
|
|
||||||
@keyframes slideUp { from { opacity:0; transform:translateY(12px); } to { opacity:1; transform:translateY(0); } }
|
|
||||||
@keyframes spin { to { transform:rotate(360deg); } }
|
|
||||||
`}</style>
|
|
||||||
|
|
||||||
{/* Header */}
|
|
||||||
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', marginBottom: 24 }}>
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
|
||||||
{step === 2 && (
|
|
||||||
<button
|
|
||||||
onClick={() => setStep(1)}
|
|
||||||
style={{
|
|
||||||
background: 'none', border: 'none', cursor: 'pointer',
|
|
||||||
color: '#a09a90', fontSize: '1rem', padding: '2px 4px',
|
|
||||||
borderRadius: 4, transition: 'color 0.12s', lineHeight: 1,
|
|
||||||
}}
|
|
||||||
onMouseEnter={e => (e.currentTarget.style.color = '#1a1a1a')}
|
|
||||||
onMouseLeave={e => (e.currentTarget.style.color = '#a09a90')}
|
|
||||||
>
|
|
||||||
←
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
<div>
|
|
||||||
<h2 style={{ fontFamily: 'Newsreader, serif', fontSize: '1.3rem', fontWeight: 400, color: '#1a1a1a', marginBottom: 2 }}>
|
|
||||||
{step === 1 ? 'New project' : `What are you building?`}
|
|
||||||
</h2>
|
|
||||||
<p style={{ fontSize: '0.78rem', color: '#a09a90' }}>
|
|
||||||
{step === 1 ? 'Give your project a name to get started.' : `Choose the type that best fits "${productName}".`}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={() => onOpenChange(false)}
|
|
||||||
style={{
|
|
||||||
background: 'none', border: 'none', cursor: 'pointer',
|
|
||||||
color: '#b5b0a6', fontSize: '1.1rem', lineHeight: 1,
|
|
||||||
padding: '2px 4px', borderRadius: 4, transition: 'color 0.12s', flexShrink: 0,
|
|
||||||
}}
|
|
||||||
onMouseEnter={e => (e.currentTarget.style.color = '#6b6560')}
|
|
||||||
onMouseLeave={e => (e.currentTarget.style.color = '#b5b0a6')}
|
|
||||||
>
|
|
||||||
×
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Step 1 — Name */}
|
|
||||||
{step === 1 && (
|
|
||||||
<div>
|
|
||||||
<label style={{ display: 'block', fontSize: '0.72rem', fontWeight: 600, color: '#6b6560', marginBottom: 7, letterSpacing: '0.02em' }}>
|
|
||||||
Project name
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
ref={inputRef}
|
|
||||||
type="text"
|
|
||||||
value={productName}
|
|
||||||
onChange={e => setProductName(e.target.value)}
|
|
||||||
onKeyDown={e => { if (e.key === 'Enter' && productName.trim()) setStep(2); }}
|
|
||||||
placeholder="e.g. Foxglove, Meridian, OpsAI…"
|
|
||||||
style={{
|
|
||||||
width: '100%', padding: '11px 14px', marginBottom: 16,
|
|
||||||
borderRadius: 8, border: '1px solid #e0dcd4',
|
|
||||||
background: '#faf8f5', fontSize: '0.9rem',
|
|
||||||
fontFamily: 'Outfit, sans-serif', color: '#1a1a1a',
|
|
||||||
outline: 'none', transition: 'border-color 0.12s',
|
|
||||||
boxSizing: 'border-box',
|
|
||||||
}}
|
|
||||||
onFocus={e => (e.currentTarget.style.borderColor = '#1a1a1a')}
|
|
||||||
onBlur={e => (e.currentTarget.style.borderColor = '#e0dcd4')}
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
onClick={() => { if (productName.trim()) setStep(2); }}
|
|
||||||
disabled={!productName.trim()}
|
|
||||||
style={{
|
|
||||||
width: '100%', padding: '12px',
|
|
||||||
borderRadius: 8, border: 'none',
|
|
||||||
background: productName.trim() ? '#1a1a1a' : '#e0dcd4',
|
|
||||||
color: productName.trim() ? '#fff' : '#b5b0a6',
|
|
||||||
fontSize: '0.88rem', fontWeight: 600,
|
|
||||||
fontFamily: 'Outfit, sans-serif',
|
|
||||||
cursor: productName.trim() ? 'pointer' : 'not-allowed',
|
|
||||||
transition: 'opacity 0.15s, background 0.15s',
|
|
||||||
}}
|
|
||||||
onMouseEnter={e => { if (productName.trim()) (e.currentTarget.style.opacity = '0.85'); }}
|
|
||||||
onMouseLeave={e => { (e.currentTarget.style.opacity = '1'); }}
|
|
||||||
>
|
|
||||||
Next →
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Step 2 — Project type */}
|
|
||||||
{step === 2 && (
|
|
||||||
<div>
|
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 8, marginBottom: 20 }}>
|
|
||||||
{PROJECT_TYPES.map(type => {
|
|
||||||
const isSelected = projectType === type.id;
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={type.id}
|
|
||||||
onClick={() => setProjectType(type.id)}
|
|
||||||
style={{
|
|
||||||
display: 'flex', alignItems: 'flex-start', gap: 12,
|
|
||||||
padding: '14px 16px', borderRadius: 10, textAlign: 'left',
|
|
||||||
border: `1px solid ${isSelected ? '#1a1a1a' : '#e8e4dc'}`,
|
|
||||||
background: isSelected ? '#1a1a1a08' : '#fff',
|
|
||||||
boxShadow: isSelected ? '0 0 0 1px #1a1a1a' : '0 1px 2px #1a1a1a04',
|
|
||||||
cursor: 'pointer', transition: 'all 0.12s',
|
|
||||||
fontFamily: 'Outfit, sans-serif',
|
|
||||||
}}
|
|
||||||
onMouseEnter={e => { if (!isSelected) (e.currentTarget.style.borderColor = '#d0ccc4'); }}
|
|
||||||
onMouseLeave={e => { if (!isSelected) (e.currentTarget.style.borderColor = '#e8e4dc'); }}
|
|
||||||
>
|
|
||||||
<div style={{
|
|
||||||
width: 30, height: 30, borderRadius: 7, flexShrink: 0,
|
|
||||||
background: isSelected ? '#1a1a1a' : '#f6f4f0',
|
|
||||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
|
||||||
fontSize: '0.95rem', color: isSelected ? '#fff' : '#8a8478',
|
|
||||||
}}>
|
|
||||||
{type.icon}
|
|
||||||
</div>
|
|
||||||
<div style={{ flex: 1, minWidth: 0 }}>
|
|
||||||
<div style={{ fontSize: '0.84rem', fontWeight: 600, color: '#1a1a1a', marginBottom: 2 }}>
|
|
||||||
{type.label}
|
|
||||||
</div>
|
|
||||||
<div style={{ fontSize: '0.71rem', color: '#8a8478', lineHeight: 1.45 }}>
|
|
||||||
{type.desc}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button
|
|
||||||
onClick={handleCreate}
|
|
||||||
disabled={!projectType || loading}
|
|
||||||
style={{
|
|
||||||
width: '100%', padding: '12px',
|
|
||||||
borderRadius: 8, border: 'none',
|
|
||||||
background: projectType && !loading ? '#1a1a1a' : '#e0dcd4',
|
|
||||||
color: projectType && !loading ? '#fff' : '#b5b0a6',
|
|
||||||
fontSize: '0.88rem', fontWeight: 600,
|
|
||||||
fontFamily: 'Outfit, sans-serif',
|
|
||||||
cursor: projectType && !loading ? 'pointer' : 'not-allowed',
|
|
||||||
transition: 'opacity 0.15s, background 0.15s',
|
|
||||||
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 8,
|
|
||||||
}}
|
|
||||||
onMouseEnter={e => { if (projectType && !loading) (e.currentTarget.style.opacity = '0.85'); }}
|
|
||||||
onMouseLeave={e => { (e.currentTarget.style.opacity = '1'); }}
|
|
||||||
>
|
|
||||||
{loading ? (
|
|
||||||
<>
|
|
||||||
<span style={{ width: 14, height: 14, borderRadius: '50%', border: '2px solid #fff4', borderTopColor: '#fff', animation: 'spin 0.7s linear infinite', display: 'inline-block' }} />
|
|
||||||
Creating…
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
`Create ${productName} →`
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</>,
|
|
||||||
document.body
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
84
components/project-creation/ChatImportSetup.tsx
Normal file
84
components/project-creation/ChatImportSetup.tsx
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { SetupHeader, FieldLabel, TextInput, PrimaryButton, type SetupProps } from "./setup-shared";
|
||||||
|
|
||||||
|
export function ChatImportSetup({ workspace, onClose, onBack }: SetupProps) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [name, setName] = useState("");
|
||||||
|
const [chatText, setChatText] = useState("");
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
const canCreate = name.trim().length > 0 && chatText.trim().length > 20;
|
||||||
|
|
||||||
|
const handleCreate = async () => {
|
||||||
|
if (!canCreate) return;
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/projects/create", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
projectName: name.trim(),
|
||||||
|
projectType: "web-app",
|
||||||
|
slug: name.toLowerCase().replace(/[^a-z0-9]+/g, "-"),
|
||||||
|
product: { name: name.trim() },
|
||||||
|
creationMode: "chat-import",
|
||||||
|
sourceData: { chatText: chatText.trim() },
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = await res.json();
|
||||||
|
toast.error(err.error || "Failed to create project");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const data = await res.json();
|
||||||
|
onClose();
|
||||||
|
router.push(`/${workspace}/project/${data.projectId}/overview`);
|
||||||
|
} catch {
|
||||||
|
toast.error("Something went wrong");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ padding: "32px 36px 36px" }}>
|
||||||
|
<SetupHeader
|
||||||
|
icon="⌁" label="Import Chats" tagline="You've been thinking"
|
||||||
|
accent="#2e5a4a" onBack={onBack} onClose={onClose}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FieldLabel>Project name</FieldLabel>
|
||||||
|
<TextInput
|
||||||
|
value={name}
|
||||||
|
onChange={setName}
|
||||||
|
placeholder="What are you building?"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FieldLabel>Paste your chat history</FieldLabel>
|
||||||
|
<textarea
|
||||||
|
value={chatText}
|
||||||
|
onChange={e => setChatText(e.target.value)}
|
||||||
|
placeholder={"Paste conversations from ChatGPT, Claude, Gemini, or any AI tool.\n\nAtlas will extract decisions, ideas, open questions, and architecture notes."}
|
||||||
|
rows={8}
|
||||||
|
style={{
|
||||||
|
width: "100%", padding: "12px 14px", marginBottom: 20,
|
||||||
|
borderRadius: 8, border: "1px solid #e0dcd4",
|
||||||
|
background: "#faf8f5", fontSize: "0.85rem", lineHeight: 1.55,
|
||||||
|
fontFamily: "Outfit, sans-serif", color: "#1a1a1a",
|
||||||
|
outline: "none", resize: "vertical", boxSizing: "border-box",
|
||||||
|
}}
|
||||||
|
onFocus={e => (e.currentTarget.style.borderColor = "#1a1a1a")}
|
||||||
|
onBlur={e => (e.currentTarget.style.borderColor = "#e0dcd4")}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<PrimaryButton onClick={handleCreate} disabled={!canCreate} loading={loading}>
|
||||||
|
Extract & analyse →
|
||||||
|
</PrimaryButton>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
100
components/project-creation/CodeImportSetup.tsx
Normal file
100
components/project-creation/CodeImportSetup.tsx
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { SetupHeader, FieldLabel, TextInput, PrimaryButton, type SetupProps } from "./setup-shared";
|
||||||
|
|
||||||
|
export function CodeImportSetup({ workspace, onClose, onBack }: SetupProps) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [name, setName] = useState("");
|
||||||
|
const [repoUrl, setRepoUrl] = useState("");
|
||||||
|
const [pat, setPat] = useState("");
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
const isValidUrl = repoUrl.trim().startsWith("http");
|
||||||
|
const canCreate = name.trim().length > 0 && isValidUrl;
|
||||||
|
|
||||||
|
const handleCreate = async () => {
|
||||||
|
if (!canCreate) return;
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/projects/create", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
projectName: name.trim(),
|
||||||
|
projectType: "web-app",
|
||||||
|
slug: name.toLowerCase().replace(/[^a-z0-9]+/g, "-"),
|
||||||
|
product: { name: name.trim() },
|
||||||
|
creationMode: "code-import",
|
||||||
|
sourceData: { repoUrl: repoUrl.trim(), pat: pat.trim() || undefined },
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = await res.json();
|
||||||
|
toast.error(err.error || "Failed to create project");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const data = await res.json();
|
||||||
|
onClose();
|
||||||
|
router.push(`/${workspace}/project/${data.projectId}/overview`);
|
||||||
|
} catch {
|
||||||
|
toast.error("Something went wrong");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ padding: "32px 36px 36px" }}>
|
||||||
|
<SetupHeader
|
||||||
|
icon="⌘" label="Import Code" tagline="Already have a repo"
|
||||||
|
accent="#1a3a5c" onBack={onBack} onClose={onClose}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FieldLabel>Project name</FieldLabel>
|
||||||
|
<TextInput
|
||||||
|
value={name}
|
||||||
|
onChange={setName}
|
||||||
|
placeholder="What is this project called?"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FieldLabel>Repository URL</FieldLabel>
|
||||||
|
<TextInput
|
||||||
|
value={repoUrl}
|
||||||
|
onChange={setRepoUrl}
|
||||||
|
placeholder="https://github.com/yourorg/your-repo"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FieldLabel>
|
||||||
|
Personal Access Token{" "}
|
||||||
|
<span style={{ color: "#b5b0a6", fontWeight: 400 }}>(required for private repos)</span>
|
||||||
|
</FieldLabel>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={pat}
|
||||||
|
onChange={e => setPat(e.target.value)}
|
||||||
|
placeholder="ghp_… or similar"
|
||||||
|
style={{
|
||||||
|
width: "100%", padding: "11px 14px", marginBottom: 20,
|
||||||
|
borderRadius: 8, border: "1px solid #e0dcd4",
|
||||||
|
background: "#faf8f5", fontSize: "0.9rem",
|
||||||
|
fontFamily: "Outfit, sans-serif", color: "#1a1a1a",
|
||||||
|
outline: "none", boxSizing: "border-box",
|
||||||
|
}}
|
||||||
|
onFocus={e => (e.currentTarget.style.borderColor = "#1a1a1a")}
|
||||||
|
onBlur={e => (e.currentTarget.style.borderColor = "#e0dcd4")}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div style={{ fontSize: "0.75rem", color: "#a09a90", marginBottom: 20, lineHeight: 1.5, padding: "12px 14px", background: "#faf8f5", borderRadius: 8, border: "1px solid #f0ece4" }}>
|
||||||
|
Atlas will clone your repo, read key files, and build a full architecture map — tech stack, routes, database, auth, and third-party integrations. Tokens are used only for cloning and are not stored.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<PrimaryButton onClick={handleCreate} disabled={!canCreate} loading={loading}>
|
||||||
|
Import & map →
|
||||||
|
</PrimaryButton>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
106
components/project-creation/CreateProjectFlow.tsx
Normal file
106
components/project-creation/CreateProjectFlow.tsx
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import { createPortal } from "react-dom";
|
||||||
|
import { TypeSelector } from "./TypeSelector";
|
||||||
|
import { FreshIdeaSetup } from "./FreshIdeaSetup";
|
||||||
|
import { ChatImportSetup } from "./ChatImportSetup";
|
||||||
|
import { CodeImportSetup } from "./CodeImportSetup";
|
||||||
|
import { MigrateSetup } from "./MigrateSetup";
|
||||||
|
|
||||||
|
export type CreationMode = "fresh" | "chat-import" | "code-import" | "migration";
|
||||||
|
|
||||||
|
interface CreateProjectFlowProps {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
workspace: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
type Step = "select-type" | "setup";
|
||||||
|
|
||||||
|
export function CreateProjectFlow({ open, onOpenChange, workspace }: CreateProjectFlowProps) {
|
||||||
|
const [step, setStep] = useState<Step>("select-type");
|
||||||
|
const [mode, setMode] = useState<CreationMode | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
setStep("select-type");
|
||||||
|
setMode(null);
|
||||||
|
}
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
const handler = (e: KeyboardEvent) => { if (e.key === "Escape") onOpenChange(false); };
|
||||||
|
window.addEventListener("keydown", handler);
|
||||||
|
return () => window.removeEventListener("keydown", handler);
|
||||||
|
}, [open, onOpenChange]);
|
||||||
|
|
||||||
|
if (!open) return null;
|
||||||
|
|
||||||
|
const handleSelectType = (selected: CreationMode) => {
|
||||||
|
setMode(selected);
|
||||||
|
setStep("setup");
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBack = () => {
|
||||||
|
setStep("select-type");
|
||||||
|
setMode(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const setupProps = { workspace, onClose: () => onOpenChange(false), onBack: handleBack };
|
||||||
|
|
||||||
|
return createPortal(
|
||||||
|
<>
|
||||||
|
<style>{`
|
||||||
|
@keyframes vibn-fadeIn { from { opacity:0; } to { opacity:1; } }
|
||||||
|
@keyframes vibn-slideUp { from { opacity:0; transform:translateY(14px); } to { opacity:1; transform:translateY(0); } }
|
||||||
|
@keyframes vibn-spin { to { transform:rotate(360deg); } }
|
||||||
|
`}</style>
|
||||||
|
|
||||||
|
{/* Backdrop */}
|
||||||
|
<div
|
||||||
|
onClick={() => onOpenChange(false)}
|
||||||
|
style={{
|
||||||
|
position: "fixed", inset: 0, zIndex: 50,
|
||||||
|
background: "rgba(26,26,26,0.38)",
|
||||||
|
animation: "vibn-fadeIn 0.15s ease",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Modal container */}
|
||||||
|
<div style={{
|
||||||
|
position: "fixed", inset: 0, zIndex: 51,
|
||||||
|
display: "flex", alignItems: "center", justifyContent: "center",
|
||||||
|
padding: 24, pointerEvents: "none",
|
||||||
|
}}>
|
||||||
|
<div
|
||||||
|
onClick={e => e.stopPropagation()}
|
||||||
|
style={{
|
||||||
|
background: "#fff", borderRadius: 16,
|
||||||
|
boxShadow: "0 12px 48px rgba(26,26,26,0.16)",
|
||||||
|
width: "100%",
|
||||||
|
maxWidth: step === "select-type" ? 620 : 520,
|
||||||
|
fontFamily: "Outfit, sans-serif",
|
||||||
|
pointerEvents: "all",
|
||||||
|
animation: "vibn-slideUp 0.18s cubic-bezier(0.4,0,0.2,1)",
|
||||||
|
transition: "max-width 0.2s ease",
|
||||||
|
overflow: "hidden",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{step === "select-type" && (
|
||||||
|
<TypeSelector
|
||||||
|
onSelect={handleSelectType}
|
||||||
|
onClose={() => onOpenChange(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{step === "setup" && mode === "fresh" && <FreshIdeaSetup {...setupProps} />}
|
||||||
|
{step === "setup" && mode === "chat-import" && <ChatImportSetup {...setupProps} />}
|
||||||
|
{step === "setup" && mode === "code-import" && <CodeImportSetup {...setupProps} />}
|
||||||
|
{step === "setup" && mode === "migration" && <MigrateSetup {...setupProps} />}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>,
|
||||||
|
document.body
|
||||||
|
);
|
||||||
|
}
|
||||||
91
components/project-creation/FreshIdeaSetup.tsx
Normal file
91
components/project-creation/FreshIdeaSetup.tsx
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useRef, useState } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { SetupHeader, FieldLabel, TextInput, PrimaryButton, type SetupProps } from "./setup-shared";
|
||||||
|
|
||||||
|
export function FreshIdeaSetup({ workspace, onClose, onBack }: SetupProps) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [name, setName] = useState("");
|
||||||
|
const [description, setDescription] = useState("");
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const nameRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
const canCreate = name.trim().length > 0;
|
||||||
|
|
||||||
|
const handleCreate = async () => {
|
||||||
|
if (!canCreate) return;
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/projects/create", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
projectName: name.trim(),
|
||||||
|
projectType: "web-app",
|
||||||
|
slug: name.toLowerCase().replace(/[^a-z0-9]+/g, "-"),
|
||||||
|
product: { name: name.trim() },
|
||||||
|
creationMode: "fresh",
|
||||||
|
sourceData: { description: description.trim() },
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = await res.json();
|
||||||
|
toast.error(err.error || "Failed to create project");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const data = await res.json();
|
||||||
|
onClose();
|
||||||
|
router.push(`/${workspace}/project/${data.projectId}/overview`);
|
||||||
|
} catch {
|
||||||
|
toast.error("Something went wrong");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ padding: "32px 36px 36px" }}>
|
||||||
|
<SetupHeader
|
||||||
|
icon="✦" label="Fresh Idea" tagline="Start from scratch"
|
||||||
|
accent="#4a3728" onBack={onBack} onClose={onClose}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FieldLabel>Project name</FieldLabel>
|
||||||
|
<TextInput
|
||||||
|
value={name}
|
||||||
|
onChange={setName}
|
||||||
|
placeholder="e.g. Foxglove, Meridian, OpsAI…"
|
||||||
|
onKeyDown={e => { if (e.key === "Enter" && canCreate) handleCreate(); }}
|
||||||
|
inputRef={nameRef}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FieldLabel>One-line description <span style={{ color: "#b5b0a6", fontWeight: 400 }}>(optional)</span></FieldLabel>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={description}
|
||||||
|
onChange={e => setDescription(e.target.value)}
|
||||||
|
placeholder="A short description to kick off the conversation"
|
||||||
|
style={{
|
||||||
|
width: "100%", padding: "11px 14px", marginBottom: 20,
|
||||||
|
borderRadius: 8, border: "1px solid #e0dcd4",
|
||||||
|
background: "#faf8f5", fontSize: "0.9rem",
|
||||||
|
fontFamily: "Outfit, sans-serif", color: "#1a1a1a",
|
||||||
|
outline: "none", boxSizing: "border-box",
|
||||||
|
}}
|
||||||
|
onFocus={e => (e.currentTarget.style.borderColor = "#1a1a1a")}
|
||||||
|
onBlur={e => (e.currentTarget.style.borderColor = "#e0dcd4")}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div style={{ fontSize: "0.75rem", color: "#a09a90", marginBottom: 20, lineHeight: 1.5, padding: "12px 14px", background: "#faf8f5", borderRadius: 8, border: "1px solid #f0ece4" }}>
|
||||||
|
Atlas will guide you through 6 discovery phases — Big Picture, Users, Features, Business Model, Screens, and Risks — building your product plan as you go.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<PrimaryButton onClick={handleCreate} disabled={!canCreate} loading={loading}>
|
||||||
|
Start with Atlas →
|
||||||
|
</PrimaryButton>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
159
components/project-creation/MigrateSetup.tsx
Normal file
159
components/project-creation/MigrateSetup.tsx
Normal file
@@ -0,0 +1,159 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { SetupHeader, FieldLabel, TextInput, PrimaryButton, type SetupProps } from "./setup-shared";
|
||||||
|
|
||||||
|
const HOSTING_OPTIONS = [
|
||||||
|
{ value: "", label: "Select hosting provider" },
|
||||||
|
{ value: "vercel", label: "Vercel" },
|
||||||
|
{ value: "aws", label: "AWS (EC2 / ECS / Elastic Beanstalk)" },
|
||||||
|
{ value: "heroku", label: "Heroku" },
|
||||||
|
{ value: "digitalocean", label: "DigitalOcean (Droplet / App Platform)" },
|
||||||
|
{ 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" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function MigrateSetup({ workspace, onClose, onBack }: SetupProps) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [name, setName] = useState("");
|
||||||
|
const [repoUrl, setRepoUrl] = useState("");
|
||||||
|
const [liveUrl, setLiveUrl] = useState("");
|
||||||
|
const [hosting, setHosting] = useState("");
|
||||||
|
const [pat, setPat] = useState("");
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
const isValidRepo = repoUrl.trim().startsWith("http");
|
||||||
|
const isValidLive = liveUrl.trim().startsWith("http");
|
||||||
|
const canCreate = name.trim().length > 0 && (isValidRepo || isValidLive);
|
||||||
|
|
||||||
|
const handleCreate = async () => {
|
||||||
|
if (!canCreate) return;
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/projects/create", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
projectName: name.trim(),
|
||||||
|
projectType: "web-app",
|
||||||
|
slug: name.toLowerCase().replace(/[^a-z0-9]+/g, "-"),
|
||||||
|
product: { name: name.trim() },
|
||||||
|
creationMode: "migration",
|
||||||
|
sourceData: {
|
||||||
|
repoUrl: repoUrl.trim() || undefined,
|
||||||
|
liveUrl: liveUrl.trim() || undefined,
|
||||||
|
hosting: hosting || undefined,
|
||||||
|
pat: pat.trim() || undefined,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = await res.json();
|
||||||
|
toast.error(err.error || "Failed to create project");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const data = await res.json();
|
||||||
|
onClose();
|
||||||
|
router.push(`/${workspace}/project/${data.projectId}/overview`);
|
||||||
|
} catch {
|
||||||
|
toast.error("Something went wrong");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ padding: "32px 36px 36px" }}>
|
||||||
|
<SetupHeader
|
||||||
|
icon="⇢" label="Migrate Product" tagline="Move an existing product"
|
||||||
|
accent="#4a2a5a" onBack={onBack} onClose={onClose}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FieldLabel>Product name</FieldLabel>
|
||||||
|
<TextInput
|
||||||
|
value={name}
|
||||||
|
onChange={setName}
|
||||||
|
placeholder="What is this product called?"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FieldLabel>
|
||||||
|
Repository URL{" "}
|
||||||
|
<span style={{ color: "#b5b0a6", fontWeight: 400 }}>(recommended)</span>
|
||||||
|
</FieldLabel>
|
||||||
|
<TextInput
|
||||||
|
value={repoUrl}
|
||||||
|
onChange={setRepoUrl}
|
||||||
|
placeholder="https://github.com/yourorg/your-repo"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FieldLabel>
|
||||||
|
Live URL{" "}
|
||||||
|
<span style={{ color: "#b5b0a6", fontWeight: 400 }}>(optional)</span>
|
||||||
|
</FieldLabel>
|
||||||
|
<TextInput
|
||||||
|
value={liveUrl}
|
||||||
|
onChange={setLiveUrl}
|
||||||
|
placeholder="https://yourproduct.com"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 12, marginBottom: 4 }}>
|
||||||
|
<div>
|
||||||
|
<FieldLabel>Hosting provider</FieldLabel>
|
||||||
|
<select
|
||||||
|
value={hosting}
|
||||||
|
onChange={e => setHosting(e.target.value)}
|
||||||
|
style={{
|
||||||
|
width: "100%", padding: "11px 14px", marginBottom: 16,
|
||||||
|
borderRadius: 8, border: "1px solid #e0dcd4",
|
||||||
|
background: "#faf8f5", fontSize: "0.88rem",
|
||||||
|
fontFamily: "Outfit, sans-serif", color: hosting ? "#1a1a1a" : "#a09a90",
|
||||||
|
outline: "none", boxSizing: "border-box", appearance: "none",
|
||||||
|
backgroundImage: `url("data:image/svg+xml,%3Csvg width='10' height='6' viewBox='0 0 10 6' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M1 1l4 4 4-4' stroke='%23a09a90' strokeWidth='1.5' strokeLinecap='round' strokeLinejoin='round'/%3E%3C/svg%3E")`,
|
||||||
|
backgroundRepeat: "no-repeat", backgroundPosition: "right 12px center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{HOSTING_OPTIONS.map(o => (
|
||||||
|
<option key={o.value} value={o.value}>{o.label}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<FieldLabel>
|
||||||
|
PAT{" "}<span style={{ color: "#b5b0a6", fontWeight: 400 }}>(private repos)</span>
|
||||||
|
</FieldLabel>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={pat}
|
||||||
|
onChange={e => setPat(e.target.value)}
|
||||||
|
placeholder="ghp_…"
|
||||||
|
style={{
|
||||||
|
width: "100%", padding: "11px 14px", marginBottom: 16,
|
||||||
|
borderRadius: 8, border: "1px solid #e0dcd4",
|
||||||
|
background: "#faf8f5", fontSize: "0.9rem",
|
||||||
|
fontFamily: "Outfit, sans-serif", color: "#1a1a1a",
|
||||||
|
outline: "none", boxSizing: "border-box",
|
||||||
|
}}
|
||||||
|
onFocus={e => (e.currentTarget.style.borderColor = "#1a1a1a")}
|
||||||
|
onBlur={e => (e.currentTarget.style.borderColor = "#e0dcd4")}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ fontSize: "0.75rem", color: "#a09a90", marginBottom: 20, lineHeight: 1.5, padding: "12px 14px", background: "#faf8f5", borderRadius: 8, border: "1px solid #f0ece4" }}>
|
||||||
|
<strong style={{ color: "#4a2a5a" }}>Non-destructive.</strong> Atlas builds a full audit and migration plan. Your existing product stays live throughout the entire migration process.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<PrimaryButton onClick={handleCreate} disabled={!canCreate} loading={loading}>
|
||||||
|
Start migration plan →
|
||||||
|
</PrimaryButton>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
145
components/project-creation/TypeSelector.tsx
Normal file
145
components/project-creation/TypeSelector.tsx
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import type { CreationMode } from "./CreateProjectFlow";
|
||||||
|
|
||||||
|
interface TypeSelectorProps {
|
||||||
|
onSelect: (mode: CreationMode) => void;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const FLOW_TYPES: {
|
||||||
|
id: CreationMode;
|
||||||
|
icon: string;
|
||||||
|
label: string;
|
||||||
|
tagline: string;
|
||||||
|
desc: string;
|
||||||
|
accent: string;
|
||||||
|
}[] = [
|
||||||
|
{
|
||||||
|
id: "fresh",
|
||||||
|
icon: "✦",
|
||||||
|
label: "Fresh Idea",
|
||||||
|
tagline: "Start from scratch",
|
||||||
|
desc: "Talk through your idea with Atlas. We'll explore it together and shape it into a full product plan.",
|
||||||
|
accent: "#4a3728",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "chat-import",
|
||||||
|
icon: "⌁",
|
||||||
|
label: "Import Chats",
|
||||||
|
tagline: "You've been thinking",
|
||||||
|
desc: "Paste conversations from ChatGPT or Claude. Atlas extracts your decisions, ideas, and open questions.",
|
||||||
|
accent: "#2e5a4a",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "code-import",
|
||||||
|
icon: "⌘",
|
||||||
|
label: "Import Code",
|
||||||
|
tagline: "Already have a repo",
|
||||||
|
desc: "Point Atlas at your GitHub or Bitbucket repo. We'll map your stack and show what's missing.",
|
||||||
|
accent: "#1a3a5c",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "migration",
|
||||||
|
icon: "⇢",
|
||||||
|
label: "Migrate Product",
|
||||||
|
tagline: "Move an existing product",
|
||||||
|
desc: "Bring your live product into the VIBN infrastructure. Atlas builds a safe, phased migration plan.",
|
||||||
|
accent: "#4a2a5a",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export function TypeSelector({ onSelect, onClose }: TypeSelectorProps) {
|
||||||
|
return (
|
||||||
|
<div style={{ padding: "32px 36px 36px" }}>
|
||||||
|
{/* Header */}
|
||||||
|
<div style={{ display: "flex", alignItems: "flex-start", justifyContent: "space-between", marginBottom: 28 }}>
|
||||||
|
<div>
|
||||||
|
<h2 style={{
|
||||||
|
fontFamily: "Newsreader, serif", fontSize: "1.4rem", fontWeight: 400,
|
||||||
|
color: "#1a1a1a", margin: 0, marginBottom: 4,
|
||||||
|
}}>
|
||||||
|
Start a new project
|
||||||
|
</h2>
|
||||||
|
<p style={{ fontSize: "0.78rem", color: "#a09a90", margin: 0 }}>
|
||||||
|
How would you like to begin?
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
style={{
|
||||||
|
background: "none", border: "none", cursor: "pointer",
|
||||||
|
color: "#b5b0a6", fontSize: "1.2rem", lineHeight: 1,
|
||||||
|
padding: "2px 5px", borderRadius: 4,
|
||||||
|
}}
|
||||||
|
onMouseEnter={e => (e.currentTarget.style.color = "#6b6560")}
|
||||||
|
onMouseLeave={e => (e.currentTarget.style.color = "#b5b0a6")}
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Type cards */}
|
||||||
|
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 10 }}>
|
||||||
|
{FLOW_TYPES.map(type => (
|
||||||
|
<button
|
||||||
|
key={type.id}
|
||||||
|
onClick={() => onSelect(type.id)}
|
||||||
|
style={{
|
||||||
|
display: "flex", flexDirection: "column", alignItems: "flex-start",
|
||||||
|
gap: 0, padding: "20px", borderRadius: 12, textAlign: "left",
|
||||||
|
border: "1px solid #e8e4dc",
|
||||||
|
background: "#faf8f5",
|
||||||
|
cursor: "pointer",
|
||||||
|
transition: "all 0.14s",
|
||||||
|
fontFamily: "Outfit, sans-serif",
|
||||||
|
position: "relative",
|
||||||
|
overflow: "hidden",
|
||||||
|
}}
|
||||||
|
onMouseEnter={e => {
|
||||||
|
e.currentTarget.style.borderColor = "#d0ccc4";
|
||||||
|
e.currentTarget.style.background = "#fff";
|
||||||
|
e.currentTarget.style.boxShadow = "0 2px 12px rgba(26,26,26,0.07)";
|
||||||
|
}}
|
||||||
|
onMouseLeave={e => {
|
||||||
|
e.currentTarget.style.borderColor = "#e8e4dc";
|
||||||
|
e.currentTarget.style.background = "#faf8f5";
|
||||||
|
e.currentTarget.style.boxShadow = "none";
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Icon */}
|
||||||
|
<div style={{
|
||||||
|
width: 36, height: 36, borderRadius: 9, marginBottom: 14,
|
||||||
|
background: `${type.accent}10`,
|
||||||
|
display: "flex", alignItems: "center", justifyContent: "center",
|
||||||
|
fontSize: "1.1rem", color: type.accent,
|
||||||
|
}}>
|
||||||
|
{type.icon}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Label + tagline */}
|
||||||
|
<div style={{ fontSize: "0.88rem", fontWeight: 700, color: "#1a1a1a", marginBottom: 2 }}>
|
||||||
|
{type.label}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: "0.68rem", fontWeight: 600, color: type.accent, letterSpacing: "0.03em", marginBottom: 8, textTransform: "uppercase" }}>
|
||||||
|
{type.tagline}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Description */}
|
||||||
|
<div style={{ fontSize: "0.75rem", color: "#8a8478", lineHeight: 1.5 }}>
|
||||||
|
{type.desc}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Arrow */}
|
||||||
|
<div style={{
|
||||||
|
position: "absolute", right: 16, bottom: 16,
|
||||||
|
fontSize: "0.85rem", color: "#c5c0b8",
|
||||||
|
}}>
|
||||||
|
→
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
153
components/project-creation/setup-shared.tsx
Normal file
153
components/project-creation/setup-shared.tsx
Normal file
@@ -0,0 +1,153 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { ReactNode, CSSProperties } from "react";
|
||||||
|
|
||||||
|
export interface SetupProps {
|
||||||
|
workspace: string;
|
||||||
|
onClose: () => void;
|
||||||
|
onBack: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Shared modal header
|
||||||
|
export function SetupHeader({
|
||||||
|
icon,
|
||||||
|
label,
|
||||||
|
tagline,
|
||||||
|
accent,
|
||||||
|
onBack,
|
||||||
|
onClose,
|
||||||
|
}: {
|
||||||
|
icon: string;
|
||||||
|
label: string;
|
||||||
|
tagline: string;
|
||||||
|
accent: string;
|
||||||
|
onBack: () => void;
|
||||||
|
onClose: () => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div style={{ display: "flex", alignItems: "flex-start", justifyContent: "space-between", marginBottom: 28 }}>
|
||||||
|
<div style={{ display: "flex", alignItems: "center", gap: 12 }}>
|
||||||
|
<button
|
||||||
|
onClick={onBack}
|
||||||
|
style={{
|
||||||
|
background: "none", border: "none", cursor: "pointer",
|
||||||
|
color: "#b5b0a6", fontSize: "1rem", padding: "3px 5px",
|
||||||
|
borderRadius: 4, lineHeight: 1, flexShrink: 0,
|
||||||
|
}}
|
||||||
|
onMouseEnter={e => (e.currentTarget.style.color = "#1a1a1a")}
|
||||||
|
onMouseLeave={e => (e.currentTarget.style.color = "#b5b0a6")}
|
||||||
|
>
|
||||||
|
←
|
||||||
|
</button>
|
||||||
|
<div>
|
||||||
|
<h2 style={{
|
||||||
|
fontFamily: "Newsreader, serif", fontSize: "1.3rem", fontWeight: 400,
|
||||||
|
color: "#1a1a1a", margin: 0, marginBottom: 3,
|
||||||
|
}}>
|
||||||
|
{label}
|
||||||
|
</h2>
|
||||||
|
<p style={{ fontSize: "0.72rem", fontWeight: 600, color: accent, textTransform: "uppercase", letterSpacing: "0.04em", margin: 0 }}>
|
||||||
|
{tagline}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
style={{
|
||||||
|
background: "none", border: "none", cursor: "pointer",
|
||||||
|
color: "#b5b0a6", fontSize: "1.2rem", lineHeight: 1,
|
||||||
|
padding: "2px 5px", borderRadius: 4, flexShrink: 0,
|
||||||
|
}}
|
||||||
|
onMouseEnter={e => (e.currentTarget.style.color = "#6b6560")}
|
||||||
|
onMouseLeave={e => (e.currentTarget.style.color = "#b5b0a6")}
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FieldLabel({ children }: { children: ReactNode }) {
|
||||||
|
return (
|
||||||
|
<label style={{ display: "block", fontSize: "0.72rem", fontWeight: 600, color: "#6b6560", marginBottom: 6, letterSpacing: "0.02em" }}>
|
||||||
|
{children}
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TextInput({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
placeholder,
|
||||||
|
onKeyDown,
|
||||||
|
autoFocus,
|
||||||
|
inputRef,
|
||||||
|
}: {
|
||||||
|
value: string;
|
||||||
|
onChange: (v: string) => void;
|
||||||
|
placeholder?: string;
|
||||||
|
onKeyDown?: (e: React.KeyboardEvent<HTMLInputElement>) => void;
|
||||||
|
autoFocus?: boolean;
|
||||||
|
inputRef?: React.RefObject<HTMLInputElement>;
|
||||||
|
}) {
|
||||||
|
const base: CSSProperties = {
|
||||||
|
width: "100%", padding: "11px 14px", marginBottom: 16,
|
||||||
|
borderRadius: 8, border: "1px solid #e0dcd4",
|
||||||
|
background: "#faf8f5", fontSize: "0.9rem",
|
||||||
|
fontFamily: "Outfit, sans-serif", color: "#1a1a1a",
|
||||||
|
outline: "none", boxSizing: "border-box",
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
type="text"
|
||||||
|
value={value}
|
||||||
|
onChange={e => onChange(e.target.value)}
|
||||||
|
onKeyDown={onKeyDown}
|
||||||
|
placeholder={placeholder}
|
||||||
|
autoFocus={autoFocus}
|
||||||
|
style={base}
|
||||||
|
onFocus={e => (e.currentTarget.style.borderColor = "#1a1a1a")}
|
||||||
|
onBlur={e => (e.currentTarget.style.borderColor = "#e0dcd4")}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PrimaryButton({
|
||||||
|
onClick,
|
||||||
|
disabled,
|
||||||
|
loading,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
onClick: () => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
loading?: boolean;
|
||||||
|
children: ReactNode;
|
||||||
|
}) {
|
||||||
|
const active = !disabled && !loading;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={onClick}
|
||||||
|
disabled={!active}
|
||||||
|
style={{
|
||||||
|
width: "100%", padding: "12px",
|
||||||
|
borderRadius: 8, border: "none",
|
||||||
|
background: active ? "#1a1a1a" : "#e0dcd4",
|
||||||
|
color: active ? "#fff" : "#b5b0a6",
|
||||||
|
fontSize: "0.88rem", fontWeight: 600,
|
||||||
|
fontFamily: "Outfit, sans-serif",
|
||||||
|
cursor: active ? "pointer" : "not-allowed",
|
||||||
|
display: "flex", alignItems: "center", justifyContent: "center", gap: 8,
|
||||||
|
}}
|
||||||
|
onMouseEnter={e => { if (active) e.currentTarget.style.opacity = "0.85"; }}
|
||||||
|
onMouseLeave={e => { e.currentTarget.style.opacity = "1"; }}
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<>
|
||||||
|
<span style={{ width: 14, height: 14, borderRadius: "50%", border: "2px solid #fff4", borderTopColor: "#fff", animation: "vibn-spin 0.7s linear infinite", display: "inline-block" }} />
|
||||||
|
Creating…
|
||||||
|
</>
|
||||||
|
) : children}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
330
components/project-main/ChatImportMain.tsx
Normal file
330
components/project-main/ChatImportMain.tsx
Normal file
@@ -0,0 +1,330 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useRouter, useParams } from "next/navigation";
|
||||||
|
|
||||||
|
interface AnalysisResult {
|
||||||
|
decisions: string[];
|
||||||
|
ideas: string[];
|
||||||
|
openQuestions: string[];
|
||||||
|
architecture: string[];
|
||||||
|
targetUsers: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ChatImportMainProps {
|
||||||
|
projectId: string;
|
||||||
|
projectName: string;
|
||||||
|
sourceData?: { chatText?: string };
|
||||||
|
analysisResult?: AnalysisResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
type Stage = "intake" | "extracting" | "review";
|
||||||
|
|
||||||
|
function EditableList({
|
||||||
|
label,
|
||||||
|
items,
|
||||||
|
accent,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
items: string[];
|
||||||
|
accent: string;
|
||||||
|
onChange: (items: string[]) => void;
|
||||||
|
}) {
|
||||||
|
const handleEdit = (i: number, value: string) => {
|
||||||
|
const next = [...items];
|
||||||
|
next[i] = value;
|
||||||
|
onChange(next);
|
||||||
|
};
|
||||||
|
const handleDelete = (i: number) => {
|
||||||
|
onChange(items.filter((_, idx) => idx !== i));
|
||||||
|
};
|
||||||
|
const handleAdd = () => {
|
||||||
|
onChange([...items, ""]);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ marginBottom: 16 }}>
|
||||||
|
<div style={{ fontSize: "0.68rem", fontWeight: 700, color: accent, letterSpacing: "0.06em", textTransform: "uppercase", marginBottom: 8 }}>
|
||||||
|
{label}
|
||||||
|
</div>
|
||||||
|
{items.length === 0 && (
|
||||||
|
<p style={{ fontSize: "0.75rem", color: "#b5b0a6", fontFamily: "Outfit, sans-serif", margin: "0 0 6px" }}>
|
||||||
|
Nothing captured.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{items.map((item, i) => (
|
||||||
|
<div key={i} style={{ display: "flex", gap: 6, marginBottom: 5 }}>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={item}
|
||||||
|
onChange={e => handleEdit(i, e.target.value)}
|
||||||
|
style={{
|
||||||
|
flex: 1, padding: "7px 10px", borderRadius: 6,
|
||||||
|
border: "1px solid #e0dcd4", background: "#faf8f5",
|
||||||
|
fontSize: "0.81rem", fontFamily: "Outfit, sans-serif",
|
||||||
|
color: "#1a1a1a", outline: "none",
|
||||||
|
}}
|
||||||
|
onFocus={e => (e.currentTarget.style.borderColor = "#1a1a1a")}
|
||||||
|
onBlur={e => (e.currentTarget.style.borderColor = "#e0dcd4")}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={() => handleDelete(i)}
|
||||||
|
style={{ background: "none", border: "none", cursor: "pointer", color: "#c5c0b8", fontSize: "0.85rem", padding: "4px 6px" }}
|
||||||
|
onMouseEnter={e => (e.currentTarget.style.color = "#e53e3e")}
|
||||||
|
onMouseLeave={e => (e.currentTarget.style.color = "#c5c0b8")}
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<button
|
||||||
|
onClick={handleAdd}
|
||||||
|
style={{
|
||||||
|
background: "none", border: "1px dashed #e0dcd4", cursor: "pointer",
|
||||||
|
borderRadius: 6, padding: "5px 10px", fontSize: "0.72rem", color: "#a09a90",
|
||||||
|
fontFamily: "Outfit, sans-serif", width: "100%",
|
||||||
|
}}
|
||||||
|
onMouseEnter={e => (e.currentTarget.style.borderColor = "#b5b0a6")}
|
||||||
|
onMouseLeave={e => (e.currentTarget.style.borderColor = "#e0dcd4")}
|
||||||
|
>
|
||||||
|
+ Add
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ChatImportMain({
|
||||||
|
projectId,
|
||||||
|
projectName,
|
||||||
|
sourceData,
|
||||||
|
analysisResult: initialResult,
|
||||||
|
}: ChatImportMainProps) {
|
||||||
|
const router = useRouter();
|
||||||
|
const params = useParams();
|
||||||
|
const workspace = params?.workspace as string;
|
||||||
|
|
||||||
|
const hasChatText = !!sourceData?.chatText;
|
||||||
|
const [stage, setStage] = useState<Stage>(
|
||||||
|
initialResult ? "review" : hasChatText ? "extracting" : "intake"
|
||||||
|
);
|
||||||
|
const [chatText, setChatText] = useState(sourceData?.chatText ?? "");
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [result, setResult] = useState<AnalysisResult>(
|
||||||
|
initialResult ?? { decisions: [], ideas: [], openQuestions: [], architecture: [], targetUsers: [] }
|
||||||
|
);
|
||||||
|
|
||||||
|
// Kick off extraction automatically if chatText is ready
|
||||||
|
useEffect(() => {
|
||||||
|
if (stage === "extracting") {
|
||||||
|
runExtraction();
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [stage]);
|
||||||
|
|
||||||
|
const runExtraction = async () => {
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/projects/${projectId}/analyze-chats`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ chatText }),
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
if (!res.ok) throw new Error(data.error || "Extraction failed");
|
||||||
|
setResult(data.analysisResult);
|
||||||
|
setStage("review");
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : "Something went wrong");
|
||||||
|
setStage("intake");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePRD = () => router.push(`/${workspace}/project/${projectId}/prd`);
|
||||||
|
const handleMVP = () => router.push(`/${workspace}/project/${projectId}/build`);
|
||||||
|
|
||||||
|
// ── Stage: intake ─────────────────────────────────────────────────────────
|
||||||
|
if (stage === "intake") {
|
||||||
|
return (
|
||||||
|
<div style={{ height: "100%", overflow: "auto", display: "flex", alignItems: "center", justifyContent: "center", padding: 32 }}>
|
||||||
|
<div style={{ width: "100%", maxWidth: 640, fontFamily: "Outfit, sans-serif" }}>
|
||||||
|
<div style={{ marginBottom: 28 }}>
|
||||||
|
<h2 style={{ fontFamily: "Newsreader, serif", fontSize: "1.7rem", fontWeight: 400, color: "#1a1a1a", margin: 0, marginBottom: 6 }}>
|
||||||
|
Paste your chat history
|
||||||
|
</h2>
|
||||||
|
<p style={{ fontSize: "0.82rem", color: "#a09a90", margin: 0 }}>
|
||||||
|
{projectName} — Atlas will extract decisions, ideas, architecture notes, and more.
|
||||||
|
</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>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<textarea
|
||||||
|
value={chatText}
|
||||||
|
onChange={e => setChatText(e.target.value)}
|
||||||
|
placeholder={"Paste conversations from ChatGPT, Claude, Gemini, or any AI tool.\n\nCopy the full conversation — Atlas handles the cleanup."}
|
||||||
|
rows={14}
|
||||||
|
style={{
|
||||||
|
width: "100%", padding: "14px 16px", marginBottom: 16,
|
||||||
|
borderRadius: 10, border: "1px solid #e0dcd4",
|
||||||
|
background: "#faf8f5", fontSize: "0.85rem", lineHeight: 1.6,
|
||||||
|
fontFamily: "Outfit, sans-serif", color: "#1a1a1a",
|
||||||
|
outline: "none", resize: "vertical", boxSizing: "border-box",
|
||||||
|
}}
|
||||||
|
onFocus={e => (e.currentTarget.style.borderColor = "#1a1a1a")}
|
||||||
|
onBlur={e => (e.currentTarget.style.borderColor = "#e0dcd4")}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
if (chatText.trim().length > 20) {
|
||||||
|
setStage("extracting");
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
disabled={chatText.trim().length < 20}
|
||||||
|
style={{
|
||||||
|
width: "100%", padding: "13px",
|
||||||
|
borderRadius: 8, border: "none",
|
||||||
|
background: chatText.trim().length > 20 ? "#1a1a1a" : "#e0dcd4",
|
||||||
|
color: chatText.trim().length > 20 ? "#fff" : "#b5b0a6",
|
||||||
|
fontSize: "0.9rem", fontWeight: 600,
|
||||||
|
fontFamily: "Outfit, sans-serif",
|
||||||
|
cursor: chatText.trim().length > 20 ? "pointer" : "not-allowed",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Extract insights →
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Stage: extracting ─────────────────────────────────────────────────────
|
||||||
|
if (stage === "extracting") {
|
||||||
|
return (
|
||||||
|
<div style={{ height: "100%", display: "flex", alignItems: "center", justifyContent: "center", fontFamily: "Outfit, sans-serif" }}>
|
||||||
|
<div style={{ textAlign: "center" }}>
|
||||||
|
<div style={{
|
||||||
|
width: 48, height: 48, borderRadius: "50%",
|
||||||
|
border: "3px solid #e0dcd4", borderTopColor: "#1a1a1a",
|
||||||
|
animation: "vibn-chat-spin 0.8s linear infinite",
|
||||||
|
margin: "0 auto 20px",
|
||||||
|
}} />
|
||||||
|
<style>{`@keyframes vibn-chat-spin { to { transform:rotate(360deg); } }`}</style>
|
||||||
|
<h3 style={{ fontSize: "1.05rem", fontWeight: 600, color: "#1a1a1a", margin: "0 0 6px" }}>
|
||||||
|
Analysing your chats…
|
||||||
|
</h3>
|
||||||
|
<p style={{ fontSize: "0.8rem", color: "#a09a90", margin: 0 }}>
|
||||||
|
Atlas is extracting decisions, ideas, and insights
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Stage: review ─────────────────────────────────────────────────────────
|
||||||
|
return (
|
||||||
|
<div style={{ height: "100%", overflow: "auto", padding: "32px 40px", fontFamily: "Outfit, sans-serif" }}>
|
||||||
|
<div style={{ maxWidth: 760, margin: "0 auto" }}>
|
||||||
|
<div style={{ marginBottom: 28 }}>
|
||||||
|
<h2 style={{ fontFamily: "Newsreader, serif", fontSize: "1.7rem", fontWeight: 400, color: "#1a1a1a", margin: 0, marginBottom: 6 }}>
|
||||||
|
What Atlas found
|
||||||
|
</h2>
|
||||||
|
<p style={{ fontSize: "0.8rem", color: "#a09a90", margin: 0 }}>
|
||||||
|
Review and edit the extracted insights for <strong>{projectName}</strong>. These will seed your PRD or MVP plan.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 20, marginBottom: 28 }}>
|
||||||
|
{/* Left column */}
|
||||||
|
<div style={{ display: "flex", flexDirection: "column", gap: 16 }}>
|
||||||
|
<div style={{ background: "#fff", borderRadius: 12, border: "1px solid #e8e4dc", padding: "20px 22px" }}>
|
||||||
|
<EditableList
|
||||||
|
label="Decisions made"
|
||||||
|
items={result.decisions}
|
||||||
|
accent="#1a3a5c"
|
||||||
|
onChange={items => setResult(r => ({ ...r, decisions: items }))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div style={{ background: "#fff", borderRadius: 12, border: "1px solid #e8e4dc", padding: "20px 22px" }}>
|
||||||
|
<EditableList
|
||||||
|
label="Ideas & features"
|
||||||
|
items={result.ideas}
|
||||||
|
accent="#2e5a4a"
|
||||||
|
onChange={items => setResult(r => ({ ...r, ideas: items }))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/* Right column */}
|
||||||
|
<div style={{ display: "flex", flexDirection: "column", gap: 16 }}>
|
||||||
|
<div style={{ background: "#fff", borderRadius: 12, border: "1px solid #e8e4dc", padding: "20px 22px" }}>
|
||||||
|
<EditableList
|
||||||
|
label="Open questions"
|
||||||
|
items={result.openQuestions}
|
||||||
|
accent="#9a7b3a"
|
||||||
|
onChange={items => setResult(r => ({ ...r, openQuestions: items }))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div style={{ background: "#fff", borderRadius: 12, border: "1px solid #e8e4dc", padding: "20px 22px" }}>
|
||||||
|
<EditableList
|
||||||
|
label="Architecture notes"
|
||||||
|
items={result.architecture}
|
||||||
|
accent="#4a3728"
|
||||||
|
onChange={items => setResult(r => ({ ...r, architecture: items }))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div style={{ background: "#fff", borderRadius: 12, border: "1px solid #e8e4dc", padding: "20px 22px" }}>
|
||||||
|
<EditableList
|
||||||
|
label="Target users"
|
||||||
|
items={result.targetUsers}
|
||||||
|
accent="#4a2a5a"
|
||||||
|
onChange={items => setResult(r => ({ ...r, targetUsers: items }))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Decision buttons */}
|
||||||
|
<div style={{
|
||||||
|
background: "#1a1a1a", borderRadius: 12, padding: "22px 24px",
|
||||||
|
display: "flex", alignItems: "center", justifyContent: "space-between", gap: 16, flexWrap: "wrap",
|
||||||
|
}}>
|
||||||
|
<div>
|
||||||
|
<div style={{ fontSize: "0.88rem", fontWeight: 700, color: "#fff", marginBottom: 3 }}>Ready to move forward?</div>
|
||||||
|
<div style={{ fontSize: "0.75rem", color: "#8a8478" }}>Choose how you want to proceed with {projectName}.</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: "flex", gap: 10 }}>
|
||||||
|
<button
|
||||||
|
onClick={handlePRD}
|
||||||
|
style={{
|
||||||
|
padding: "11px 22px", borderRadius: 8, border: "none",
|
||||||
|
background: "#fff", color: "#1a1a1a",
|
||||||
|
fontSize: "0.85rem", fontWeight: 700, fontFamily: "Outfit, sans-serif", cursor: "pointer",
|
||||||
|
}}
|
||||||
|
onMouseEnter={e => (e.currentTarget.style.opacity = "0.88")}
|
||||||
|
onMouseLeave={e => (e.currentTarget.style.opacity = "1")}
|
||||||
|
>
|
||||||
|
Generate PRD →
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleMVP}
|
||||||
|
style={{
|
||||||
|
padding: "11px 22px", borderRadius: 8,
|
||||||
|
border: "1px solid rgba(255,255,255,0.2)", background: "transparent", color: "#fff",
|
||||||
|
fontSize: "0.85rem", fontWeight: 600, fontFamily: "Outfit, sans-serif", cursor: "pointer",
|
||||||
|
}}
|
||||||
|
onMouseEnter={e => (e.currentTarget.style.background = "rgba(255,255,255,0.08)")}
|
||||||
|
onMouseLeave={e => (e.currentTarget.style.background = "transparent")}
|
||||||
|
>
|
||||||
|
Plan MVP Test →
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
363
components/project-main/CodeImportMain.tsx
Normal file
363
components/project-main/CodeImportMain.tsx
Normal file
@@ -0,0 +1,363 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useRouter, useParams } from "next/navigation";
|
||||||
|
|
||||||
|
interface ArchRow {
|
||||||
|
category: string;
|
||||||
|
item: string;
|
||||||
|
status: "found" | "partial" | "missing";
|
||||||
|
detail?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AnalysisResult {
|
||||||
|
summary: string;
|
||||||
|
rows: ArchRow[];
|
||||||
|
suggestedSurfaces: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CodeImportMainProps {
|
||||||
|
projectId: string;
|
||||||
|
projectName: string;
|
||||||
|
sourceData?: { repoUrl?: string };
|
||||||
|
analysisResult?: AnalysisResult;
|
||||||
|
creationStage?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
type Stage = "input" | "cloning" | "mapping" | "surfaces";
|
||||||
|
|
||||||
|
const STATUS_COLORS = {
|
||||||
|
found: { bg: "#f0fdf4", text: "#15803d", label: "Found" },
|
||||||
|
partial: { bg: "#fffbeb", text: "#b45309", label: "Partial" },
|
||||||
|
missing: { bg: "#fff1f2", text: "#be123c", label: "Missing" },
|
||||||
|
};
|
||||||
|
|
||||||
|
const CATEGORY_ORDER = [
|
||||||
|
"Tech Stack", "Infrastructure", "Database", "API Surface",
|
||||||
|
"Frontend", "Auth", "Third-party", "Missing / Gaps",
|
||||||
|
];
|
||||||
|
|
||||||
|
const PROGRESS_STEPS = [
|
||||||
|
{ key: "cloning", label: "Cloning repository" },
|
||||||
|
{ key: "reading", label: "Reading key files" },
|
||||||
|
{ key: "analyzing", label: "Mapping architecture" },
|
||||||
|
{ key: "done", label: "Analysis complete" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function CodeImportMain({
|
||||||
|
projectId,
|
||||||
|
projectName,
|
||||||
|
sourceData,
|
||||||
|
analysisResult: initialResult,
|
||||||
|
creationStage,
|
||||||
|
}: CodeImportMainProps) {
|
||||||
|
const router = useRouter();
|
||||||
|
const params = useParams();
|
||||||
|
const workspace = params?.workspace as string;
|
||||||
|
|
||||||
|
const hasRepo = !!sourceData?.repoUrl;
|
||||||
|
const getInitialStage = (): Stage => {
|
||||||
|
if (initialResult) return "mapping";
|
||||||
|
if (creationStage === "surfaces") return "surfaces";
|
||||||
|
if (hasRepo) return "cloning";
|
||||||
|
return "input";
|
||||||
|
};
|
||||||
|
|
||||||
|
const [stage, setStage] = useState<Stage>(getInitialStage);
|
||||||
|
const [repoUrl, setRepoUrl] = useState(sourceData?.repoUrl ?? "");
|
||||||
|
const [progressStep, setProgressStep] = useState<string>("cloning");
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [result, setResult] = useState<AnalysisResult | null>(initialResult ?? null);
|
||||||
|
const [confirmedSurfaces, setConfirmedSurfaces] = useState<string[]>(
|
||||||
|
initialResult?.suggestedSurfaces ?? []
|
||||||
|
);
|
||||||
|
|
||||||
|
// Kick off analysis when in cloning stage
|
||||||
|
useEffect(() => {
|
||||||
|
if (stage !== "cloning") return;
|
||||||
|
startAnalysis();
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [stage]);
|
||||||
|
|
||||||
|
// Poll for analysis status when cloning
|
||||||
|
useEffect(() => {
|
||||||
|
if (stage !== "cloning") 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) {
|
||||||
|
setResult(data.analysisResult);
|
||||||
|
setConfirmedSurfaces(data.analysisResult.suggestedSurfaces ?? []);
|
||||||
|
clearInterval(interval);
|
||||||
|
setStage("mapping");
|
||||||
|
}
|
||||||
|
} catch { /* keep polling */ }
|
||||||
|
}, 2500);
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [stage]);
|
||||||
|
|
||||||
|
const startAnalysis = async () => {
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
await fetch(`/api/projects/${projectId}/analyze-repo`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ repoUrl }),
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : "Failed to start analysis");
|
||||||
|
setStage("input");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleConfirmSurfaces = async () => {
|
||||||
|
try {
|
||||||
|
await fetch(`/api/projects/${projectId}/design-surfaces`, {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ surfaces: confirmedSurfaces }),
|
||||||
|
});
|
||||||
|
router.push(`/${workspace}/project/${projectId}/design`);
|
||||||
|
} catch { /* navigate anyway */ }
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleSurface = (s: string) => {
|
||||||
|
setConfirmedSurfaces(prev =>
|
||||||
|
prev.includes(s) ? prev.filter(x => x !== s) : [...prev, s]
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Stage: input ──────────────────────────────────────────────────────────
|
||||||
|
if (stage === "input") {
|
||||||
|
const isValid = repoUrl.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: "Outfit, sans-serif" }}>
|
||||||
|
<div style={{ marginBottom: 28 }}>
|
||||||
|
<h2 style={{ fontFamily: "Newsreader, serif", fontSize: "1.7rem", fontWeight: 400, color: "#1a1a1a", margin: 0, marginBottom: 6 }}>
|
||||||
|
Import your repository
|
||||||
|
</h2>
|
||||||
|
<p style={{ fontSize: "0.82rem", color: "#a09a90", margin: 0 }}>
|
||||||
|
{projectName} — paste a clone URL to map your existing stack.
|
||||||
|
</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 (HTTPS)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={repoUrl}
|
||||||
|
onChange={e => setRepoUrl(e.target.value)}
|
||||||
|
placeholder="https://github.com/yourorg/your-repo"
|
||||||
|
style={{
|
||||||
|
width: "100%", padding: "12px 14px", marginBottom: 16,
|
||||||
|
borderRadius: 8, border: "1px solid #e0dcd4",
|
||||||
|
background: "#faf8f5", fontSize: "0.9rem",
|
||||||
|
fontFamily: "Outfit, sans-serif", color: "#1a1a1a",
|
||||||
|
outline: "none", boxSizing: "border-box",
|
||||||
|
}}
|
||||||
|
onFocus={e => (e.currentTarget.style.borderColor = "#1a1a1a")}
|
||||||
|
onBlur={e => (e.currentTarget.style.borderColor = "#e0dcd4")}
|
||||||
|
onKeyDown={e => { if (e.key === "Enter" && isValid) setStage("cloning"); }}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
<div style={{ fontSize: "0.75rem", color: "#a09a90", marginBottom: 20, lineHeight: 1.55, padding: "12px 14px", background: "#faf8f5", borderRadius: 8, border: "1px solid #f0ece4" }}>
|
||||||
|
Atlas will clone and map your stack — tech, database, auth, APIs, and what's missing for a complete go-to-market build.
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => { if (isValid) setStage("cloning"); }}
|
||||||
|
disabled={!isValid}
|
||||||
|
style={{
|
||||||
|
width: "100%", padding: "13px", borderRadius: 8, border: "none",
|
||||||
|
background: isValid ? "#1a1a1a" : "#e0dcd4",
|
||||||
|
color: isValid ? "#fff" : "#b5b0a6",
|
||||||
|
fontSize: "0.9rem", fontWeight: 600, fontFamily: "Outfit, sans-serif",
|
||||||
|
cursor: isValid ? "pointer" : "not-allowed",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Map this repo →
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Stage: cloning ────────────────────────────────────────────────────────
|
||||||
|
if (stage === "cloning") {
|
||||||
|
const currentIdx = PROGRESS_STEPS.findIndex(s => s.key === progressStep);
|
||||||
|
return (
|
||||||
|
<div style={{ height: "100%", display: "flex", alignItems: "center", justifyContent: "center", fontFamily: "Outfit, sans-serif" }}>
|
||||||
|
<div style={{ textAlign: "center", maxWidth: 400 }}>
|
||||||
|
<div style={{
|
||||||
|
width: 52, height: 52, borderRadius: "50%",
|
||||||
|
border: "3px solid #e0dcd4", borderTopColor: "#1a1a1a",
|
||||||
|
animation: "vibn-repo-spin 0.85s linear infinite",
|
||||||
|
margin: "0 auto 24px",
|
||||||
|
}} />
|
||||||
|
<style>{`@keyframes vibn-repo-spin { to { transform:rotate(360deg); } }`}</style>
|
||||||
|
<h3 style={{ fontSize: "1.1rem", fontWeight: 600, color: "#1a1a1a", margin: "0 0 8px" }}>
|
||||||
|
Mapping your codebase
|
||||||
|
</h3>
|
||||||
|
<p style={{ fontSize: "0.8rem", color: "#a09a90", margin: "0 0 28px" }}>
|
||||||
|
{repoUrl || sourceData?.repoUrl || "Repository"}
|
||||||
|
</p>
|
||||||
|
<div style={{ display: "flex", flexDirection: "column", gap: 8, textAlign: "left" }}>
|
||||||
|
{PROGRESS_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 ? "#1a1a1a" : active ? "#f6f4f0" : "#f6f4f0",
|
||||||
|
border: active ? "2px solid #1a1a1a" : 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: "#1a1a1a", 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: mapping ────────────────────────────────────────────────────────
|
||||||
|
if (stage === "mapping" && result) {
|
||||||
|
const byCategory: Record<string, ArchRow[]> = {};
|
||||||
|
for (const row of result.rows) {
|
||||||
|
const cat = row.category || "Other";
|
||||||
|
if (!byCategory[cat]) byCategory[cat] = [];
|
||||||
|
byCategory[cat].push(row);
|
||||||
|
}
|
||||||
|
const categories = [
|
||||||
|
...CATEGORY_ORDER.filter(c => byCategory[c]),
|
||||||
|
...Object.keys(byCategory).filter(c => !CATEGORY_ORDER.includes(c)),
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ height: "100%", overflow: "auto", padding: "32px 40px", fontFamily: "Outfit, sans-serif" }}>
|
||||||
|
<div style={{ maxWidth: 800, margin: "0 auto" }}>
|
||||||
|
<div style={{ marginBottom: 24 }}>
|
||||||
|
<h2 style={{ fontFamily: "Newsreader, serif", fontSize: "1.7rem", fontWeight: 400, color: "#1a1a1a", margin: 0, marginBottom: 6 }}>
|
||||||
|
Architecture map
|
||||||
|
</h2>
|
||||||
|
<p style={{ fontSize: "0.8rem", color: "#a09a90", margin: "0 0 4px" }}>
|
||||||
|
{projectName} — {result.summary}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ background: "#fff", borderRadius: 12, border: "1px solid #e8e4dc", overflow: "hidden", marginBottom: 24 }}>
|
||||||
|
{categories.map((cat, catIdx) => (
|
||||||
|
<div key={cat}>
|
||||||
|
{catIdx > 0 && <div style={{ height: 1, background: "#f0ece4" }} />}
|
||||||
|
<div style={{ padding: "12px 20px", background: "#faf8f5", fontSize: "0.68rem", fontWeight: 700, color: "#6b6560", letterSpacing: "0.06em", textTransform: "uppercase" }}>
|
||||||
|
{cat}
|
||||||
|
</div>
|
||||||
|
{byCategory[cat].map((row, i) => {
|
||||||
|
const sc = STATUS_COLORS[row.status];
|
||||||
|
return (
|
||||||
|
<div key={i} style={{ display: "flex", alignItems: "center", gap: 12, padding: "11px 20px", borderTop: "1px solid #f6f4f0" }}>
|
||||||
|
<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>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => setStage("surfaces")}
|
||||||
|
style={{
|
||||||
|
width: "100%", padding: "13px", borderRadius: 8, border: "none",
|
||||||
|
background: "#1a1a1a", color: "#fff",
|
||||||
|
fontSize: "0.9rem", fontWeight: 600, fontFamily: "Outfit, sans-serif", cursor: "pointer",
|
||||||
|
}}
|
||||||
|
onMouseEnter={e => (e.currentTarget.style.opacity = "0.88")}
|
||||||
|
onMouseLeave={e => (e.currentTarget.style.opacity = "1")}
|
||||||
|
>
|
||||||
|
Choose what to build next →
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Stage: surfaces ───────────────────────────────────────────────────────
|
||||||
|
const SURFACE_OPTIONS = [
|
||||||
|
{ id: "marketing", label: "Marketing Site", icon: "◎", desc: "Landing page, pricing, blog" },
|
||||||
|
{ id: "web-app", label: "Web App", icon: "⬡", desc: "Core SaaS product with auth" },
|
||||||
|
{ id: "admin", label: "Admin Panel", icon: "◫", desc: "Ops dashboard, content management" },
|
||||||
|
{ id: "api", label: "API Layer", icon: "⌁", desc: "REST/GraphQL endpoints" },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ height: "100%", overflow: "auto", display: "flex", alignItems: "center", justifyContent: "center", padding: 32 }}>
|
||||||
|
<div style={{ width: "100%", maxWidth: 540, fontFamily: "Outfit, sans-serif" }}>
|
||||||
|
<div style={{ marginBottom: 28 }}>
|
||||||
|
<h2 style={{ fontFamily: "Newsreader, serif", fontSize: "1.7rem", fontWeight: 400, color: "#1a1a1a", margin: 0, marginBottom: 6 }}>
|
||||||
|
What should Atlas build?
|
||||||
|
</h2>
|
||||||
|
<p style={{ fontSize: "0.82rem", color: "#a09a90", margin: 0 }}>
|
||||||
|
Based on the gap analysis, Atlas suggests the surfaces below. Confirm or adjust.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 10, marginBottom: 24 }}>
|
||||||
|
{SURFACE_OPTIONS.map(s => {
|
||||||
|
const selected = confirmedSurfaces.includes(s.id);
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={s.id}
|
||||||
|
onClick={() => toggleSurface(s.id)}
|
||||||
|
style={{
|
||||||
|
padding: "18px", borderRadius: 10, textAlign: "left",
|
||||||
|
border: `2px solid ${selected ? "#1a1a1a" : "#e8e4dc"}`,
|
||||||
|
background: selected ? "#1a1a1a08" : "#fff",
|
||||||
|
cursor: "pointer", fontFamily: "Outfit, sans-serif",
|
||||||
|
transition: "all 0.12s",
|
||||||
|
}}
|
||||||
|
onMouseEnter={e => { if (!selected) e.currentTarget.style.borderColor = "#d0ccc4"; }}
|
||||||
|
onMouseLeave={e => { if (!selected) e.currentTarget.style.borderColor = "#e8e4dc"; }}
|
||||||
|
>
|
||||||
|
<div style={{ fontSize: "1.2rem", marginBottom: 8 }}>{s.icon}</div>
|
||||||
|
<div style={{ fontSize: "0.84rem", fontWeight: 700, color: "#1a1a1a", marginBottom: 3 }}>{s.label}</div>
|
||||||
|
<div style={{ fontSize: "0.73rem", color: "#8a8478" }}>{s.desc}</div>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={handleConfirmSurfaces}
|
||||||
|
disabled={confirmedSurfaces.length === 0}
|
||||||
|
style={{
|
||||||
|
width: "100%", padding: "13px", borderRadius: 8, border: "none",
|
||||||
|
background: confirmedSurfaces.length > 0 ? "#1a1a1a" : "#e0dcd4",
|
||||||
|
color: confirmedSurfaces.length > 0 ? "#fff" : "#b5b0a6",
|
||||||
|
fontSize: "0.9rem", fontWeight: 600, fontFamily: "Outfit, sans-serif",
|
||||||
|
cursor: confirmedSurfaces.length > 0 ? "pointer" : "not-allowed",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Go to Design →
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
133
components/project-main/FreshIdeaMain.tsx
Normal file
133
components/project-main/FreshIdeaMain.tsx
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { AtlasChat } from "@/components/AtlasChat";
|
||||||
|
import { useRouter, useParams } from "next/navigation";
|
||||||
|
|
||||||
|
const DISCOVERY_PHASES = [
|
||||||
|
"big_picture",
|
||||||
|
"users_personas",
|
||||||
|
"features_scope",
|
||||||
|
"business_model",
|
||||||
|
"screens_data",
|
||||||
|
"risks_questions",
|
||||||
|
];
|
||||||
|
|
||||||
|
interface FreshIdeaMainProps {
|
||||||
|
projectId: string;
|
||||||
|
projectName: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FreshIdeaMain({ projectId, projectName }: FreshIdeaMainProps) {
|
||||||
|
const router = useRouter();
|
||||||
|
const params = useParams();
|
||||||
|
const workspace = params?.workspace as string;
|
||||||
|
|
||||||
|
const [savedPhaseIds, setSavedPhaseIds] = useState<Set<string>>(new Set());
|
||||||
|
const [allDone, setAllDone] = useState(false);
|
||||||
|
const [prdLoading, setPrdLoading] = useState(false);
|
||||||
|
const [dismissed, setDismissed] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const poll = () => {
|
||||||
|
fetch(`/api/projects/${projectId}/save-phase`)
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(d => {
|
||||||
|
const ids = new Set<string>((d.phases ?? []).map((p: { phase: string }) => p.phase));
|
||||||
|
setSavedPhaseIds(ids);
|
||||||
|
const done = DISCOVERY_PHASES.every(id => ids.has(id));
|
||||||
|
setAllDone(done);
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
|
};
|
||||||
|
poll();
|
||||||
|
const interval = setInterval(poll, 8_000);
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, [projectId]);
|
||||||
|
|
||||||
|
const handleGeneratePRD = async () => {
|
||||||
|
if (prdLoading) return;
|
||||||
|
setPrdLoading(true);
|
||||||
|
try {
|
||||||
|
router.push(`/${workspace}/project/${projectId}/prd`);
|
||||||
|
} finally {
|
||||||
|
setPrdLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMVP = () => {
|
||||||
|
router.push(`/${workspace}/project/${projectId}/build`);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ height: "100%", display: "flex", flexDirection: "column", position: "relative" }}>
|
||||||
|
{/* Decision banner — shown when all 6 phases are saved */}
|
||||||
|
{allDone && !dismissed && (
|
||||||
|
<div style={{
|
||||||
|
background: "linear-gradient(135deg, #1a1a1a 0%, #2d2d2d 100%)",
|
||||||
|
padding: "18px 28px",
|
||||||
|
display: "flex", alignItems: "center", justifyContent: "space-between",
|
||||||
|
gap: 16, flexShrink: 0, flexWrap: "wrap",
|
||||||
|
borderBottom: "1px solid #333",
|
||||||
|
}}>
|
||||||
|
<div>
|
||||||
|
<div style={{ fontSize: "0.88rem", fontWeight: 700, color: "#fff", fontFamily: "Outfit, sans-serif", marginBottom: 3 }}>
|
||||||
|
✦ Discovery complete — what's next?
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: "0.75rem", color: "#a09a90", fontFamily: "Outfit, sans-serif" }}>
|
||||||
|
Atlas has captured all 6 discovery phases. Choose your next step.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: "flex", gap: 10, flexShrink: 0 }}>
|
||||||
|
<button
|
||||||
|
onClick={handleGeneratePRD}
|
||||||
|
disabled={prdLoading}
|
||||||
|
style={{
|
||||||
|
padding: "10px 20px", borderRadius: 8, border: "none",
|
||||||
|
background: "#fff", color: "#1a1a1a",
|
||||||
|
fontSize: "0.84rem", fontWeight: 700,
|
||||||
|
fontFamily: "Outfit, sans-serif", cursor: "pointer",
|
||||||
|
transition: "opacity 0.12s",
|
||||||
|
}}
|
||||||
|
onMouseEnter={e => (e.currentTarget.style.opacity = "0.88")}
|
||||||
|
onMouseLeave={e => (e.currentTarget.style.opacity = "1")}
|
||||||
|
>
|
||||||
|
{prdLoading ? "Navigating…" : "Generate PRD →"}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleMVP}
|
||||||
|
style={{
|
||||||
|
padding: "10px 20px", borderRadius: 8,
|
||||||
|
border: "1px solid rgba(255,255,255,0.2)",
|
||||||
|
background: "transparent", color: "#fff",
|
||||||
|
fontSize: "0.84rem", fontWeight: 600,
|
||||||
|
fontFamily: "Outfit, sans-serif", cursor: "pointer",
|
||||||
|
transition: "background 0.12s",
|
||||||
|
}}
|
||||||
|
onMouseEnter={e => (e.currentTarget.style.background = "rgba(255,255,255,0.08)")}
|
||||||
|
onMouseLeave={e => (e.currentTarget.style.background = "transparent")}
|
||||||
|
>
|
||||||
|
Plan MVP Test →
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setDismissed(true)}
|
||||||
|
style={{
|
||||||
|
background: "none", border: "none", cursor: "pointer",
|
||||||
|
color: "#666", fontSize: "1rem", padding: "4px 6px",
|
||||||
|
fontFamily: "Outfit, sans-serif",
|
||||||
|
}}
|
||||||
|
title="Dismiss"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<AtlasChat
|
||||||
|
projectId={projectId}
|
||||||
|
projectName={projectName}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
353
components/project-main/MigrateMain.tsx
Normal file
353
components/project-main/MigrateMain.tsx
Normal file
@@ -0,0 +1,353 @@
|
|||||||
|
"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: "Outfit, sans-serif", fontSize: "0.85rem", color: "#1a1a1a", lineHeight: 1.7 }}>
|
||||||
|
{lines.map((line, i) => {
|
||||||
|
if (line.startsWith('## ')) return <h2 key={i} style={{ fontFamily: "Newsreader, 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: "Newsreader, 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: "Outfit, sans-serif" }}>
|
||||||
|
<div style={{ marginBottom: 28 }}>
|
||||||
|
<h2 style={{ fontFamily: "Newsreader, 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: "Outfit, 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: "Outfit, 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: "Outfit, 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: "Outfit, 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: "Outfit, 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: "Outfit, sans-serif" }}>
|
||||||
|
<div style={{ maxWidth: 760, margin: "0 auto" }}>
|
||||||
|
<div style={{ marginBottom: 24 }}>
|
||||||
|
<h2 style={{ fontFamily: "Newsreader, 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: "Outfit, 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: "Outfit, 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: "Outfit, 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: "Newsreader, 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: "Outfit, 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: "Outfit, sans-serif", cursor: "pointer" }}
|
||||||
|
>
|
||||||
|
Print / Export
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user