feat(project-creation): 3-path wizard — Build / OSS / Import
User feedback: the previous flow was a single-screen "name + audience"
dialog that gave AI no context about what the user actually wanted to
make. That worked for the demo but produced messy projects in practice
because everything was decided after the fact in chat.
The new flow asks the user one human question first ("How would you
like to begin?") and then captures the minimum context needed to seed
the AI's first conversation in the project.
Three paths, each is a 2-step setup screen with internal step dots:
- Build your own idea — Step 1: name + audience. Step 2: free-text
"what do you want to build". Becomes the project's vision and the
AI's first-message context.
- Run an open source tool — Step 1: name + audience. Step 2:
segmented tabs to either (a) paste a GitHub link or (b) describe
the kind of tool you want and have Vibn find one. Vision is set
to either "Install and host this open-source project: <url>" or
"Find and install an open-source tool that fits this need: <desc>"
so the AI knows which mode to operate in on first chat.
- Import existing code — Step 1: name + audience + repo URL.
Step 2: optional "what do you want to do with it" textarea.
Public repos only for v1; private-repo OAuth lands later.
Backend:
- /api/projects/create now accepts and persists `creationMode` and
`sourceData` on the project record under a `kickoff` blob:
{ mode, sourceData, vision, createdAt }
The chat endpoint will read this on first turn to seed the AI
with the user's stated intent rather than asking them to re-type
it in chat.
Cleanup:
- Removed FreshIdeaSetup, CodeImportSetup, ChatImportSetup,
MigrateSetup — replaced by BuildSetup, OssSetup, ImportSetup.
- Removed the unused initialWorkspacePath prop from
project-association-prompt (the new flow doesn't take it).
- TypeSelector defaults are restored — the modal opens on the
type-picker step now, not directly on a setup form.
UI building blocks added to setup-shared:
- TextArea (multi-line input)
- StepDots (page indicator)
- SegmentedTabs (generic-typed tab selector, used in OSS Step 2)
- SecondaryButton (used as ← Back inside Step 2)
Made-with: Cursor
This commit is contained in:
@@ -78,6 +78,8 @@ export async function POST(request: Request) {
|
||||
githubRepoUrl,
|
||||
githubDefaultBranch,
|
||||
githubToken,
|
||||
creationMode,
|
||||
sourceData,
|
||||
} = body;
|
||||
|
||||
// Check slug uniqueness
|
||||
@@ -239,6 +241,16 @@ export async function POST(request: Request) {
|
||||
// Coolify project — one per VIBN project, scopes all app services + DBs.
|
||||
// Apps are deployed on-demand via apps_create (no auto-scaffold).
|
||||
coolifyProjectUuid,
|
||||
// How this project was created — drives the AI's first-chat seed.
|
||||
// Shape: { mode: "build"|"oss"|"import", sourceData: {...} } where
|
||||
// sourceData is the path-specific payload from the wizard.
|
||||
creationMode: creationMode ?? null,
|
||||
kickoff: creationMode ? {
|
||||
mode: creationMode,
|
||||
sourceData: sourceData ?? null,
|
||||
vision: vision || null,
|
||||
createdAt: now,
|
||||
} : null,
|
||||
// Import metadata
|
||||
isImport: !!githubRepoUrl,
|
||||
importAnalysisStatus: githubRepoUrl ? 'pending' : null,
|
||||
|
||||
@@ -298,7 +298,6 @@ export function ProjectAssociationPrompt({ workspace }: { workspace: string }) {
|
||||
setUnassociatedWorkspace(null);
|
||||
}
|
||||
}}
|
||||
initialWorkspacePath={unassociatedWorkspace?.workspacePath}
|
||||
workspace={workspace}
|
||||
/>
|
||||
</>
|
||||
|
||||
132
components/project-creation/BuildSetup.tsx
Normal file
132
components/project-creation/BuildSetup.tsx
Normal file
@@ -0,0 +1,132 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { toast } from "sonner";
|
||||
import { JM } from "./modal-theme";
|
||||
import {
|
||||
SetupHeader, FieldLabel, TextInput, TextArea, ForWhomSelector,
|
||||
PrimaryButton, SecondaryButton, StepDots, type SetupProps,
|
||||
} from "./setup-shared";
|
||||
|
||||
/**
|
||||
* "Build your own idea" — two-step setup.
|
||||
* Step 1: project name + audience.
|
||||
* Step 2: describe the idea (free text). Becomes the seed message
|
||||
* for the first AI conversation in the project.
|
||||
*/
|
||||
export function BuildSetup({ workspace, onClose, onBack }: SetupProps) {
|
||||
const router = useRouter();
|
||||
const [step, setStep] = useState<0 | 1>(0);
|
||||
const [name, setName] = useState("");
|
||||
const [forWhom, setForWhom] = useState<"personal" | "client">("personal");
|
||||
const [idea, setIdea] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const canContinue = name.trim().length > 0;
|
||||
const canCreate = idea.trim().length > 4;
|
||||
|
||||
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, "-"),
|
||||
vision: idea.trim(),
|
||||
product: { name: name.trim(), isForClient: forWhom === "client" },
|
||||
creationMode: "build",
|
||||
sourceData: { idea: idea.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: 28 }}>
|
||||
<SetupHeader
|
||||
icon="✦" label="Build your own idea" tagline="Start from scratch"
|
||||
accent="#4338CA" onBack={step === 0 ? onBack : () => setStep(0)} onClose={onClose}
|
||||
/>
|
||||
|
||||
{step === 0 && (
|
||||
<>
|
||||
<FieldLabel>Project name</FieldLabel>
|
||||
<TextInput
|
||||
value={name}
|
||||
onChange={setName}
|
||||
placeholder="e.g. Pet Sitter Pro"
|
||||
onKeyDown={e => { if (e.key === "Enter" && canContinue) setStep(1); }}
|
||||
autoFocus
|
||||
/>
|
||||
|
||||
<ForWhomSelector value={forWhom} onChange={setForWhom} />
|
||||
|
||||
<FlowFooter step={0} total={2} primary={
|
||||
<PrimaryButton onClick={() => setStep(1)} disabled={!canContinue}>
|
||||
Next →
|
||||
</PrimaryButton>
|
||||
} />
|
||||
</>
|
||||
)}
|
||||
|
||||
{step === 1 && (
|
||||
<>
|
||||
<FieldLabel>What do you want to build?</FieldLabel>
|
||||
<TextArea
|
||||
value={idea}
|
||||
onChange={setIdea}
|
||||
placeholder="A booking site for my dog grooming business. Customers should be able to book online and pay a deposit by card."
|
||||
rows={6}
|
||||
autoFocus
|
||||
/>
|
||||
<p style={{ fontSize: 12, color: JM.muted, marginTop: -8, marginBottom: 18, lineHeight: 1.5 }}>
|
||||
Don't worry about the tech. Vibn will pick the tools and start building from this description.
|
||||
</p>
|
||||
|
||||
<FlowFooter
|
||||
step={1} total={2}
|
||||
secondary={<SecondaryButton onClick={() => setStep(0)}>← Back</SecondaryButton>}
|
||||
primary={
|
||||
<PrimaryButton onClick={handleCreate} disabled={!canCreate} loading={loading}>
|
||||
Start building →
|
||||
</PrimaryButton>
|
||||
}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FlowFooter({
|
||||
step, total, primary, secondary,
|
||||
}: {
|
||||
step: number; total: number;
|
||||
primary: React.ReactNode; secondary?: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 12, marginTop: 6 }}>
|
||||
<StepDots step={step} total={total} />
|
||||
<div style={{ flex: 1 }} />
|
||||
{secondary}
|
||||
<div style={{ minWidth: 160 }}>{primary}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,85 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { toast } from "sonner";
|
||||
import { JM } from "./modal-theme";
|
||||
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: 28 }}>
|
||||
<SetupHeader
|
||||
icon="⌁" label="Import Chats" tagline="You've been thinking"
|
||||
accent="#059669" 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\nVibn will extract decisions, ideas, open questions, and architecture notes."}
|
||||
rows={8}
|
||||
style={{
|
||||
width: "100%", padding: "10px 13px", marginBottom: 20,
|
||||
borderRadius: 8, border: `1px solid ${JM.border}`,
|
||||
background: JM.inputBg, fontSize: 13, lineHeight: 1.55,
|
||||
fontFamily: JM.fontSans, color: JM.ink,
|
||||
outline: "none", resize: "vertical", boxSizing: "border-box",
|
||||
}}
|
||||
onFocus={e => (e.currentTarget.style.borderColor = JM.indigo)}
|
||||
onBlur={e => (e.currentTarget.style.borderColor = JM.border)}
|
||||
/>
|
||||
|
||||
<PrimaryButton onClick={handleCreate} disabled={!canCreate} loading={loading}>
|
||||
Extract & analyse →
|
||||
</PrimaryButton>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,105 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { toast } from "sonner";
|
||||
import { JM } from "./modal-theme";
|
||||
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: 28 }}>
|
||||
<SetupHeader
|
||||
icon="⌘" label="Import Code" tagline="Already have a repo"
|
||||
accent="#1D4ED8" 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: JM.muted, 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: "10px 13px", marginBottom: 20,
|
||||
borderRadius: 8, border: `1px solid ${JM.border}`,
|
||||
background: JM.inputBg, fontSize: 14,
|
||||
fontFamily: JM.fontSans, color: JM.ink,
|
||||
outline: "none", boxSizing: "border-box",
|
||||
}}
|
||||
onFocus={e => (e.currentTarget.style.borderColor = JM.indigo)}
|
||||
onBlur={e => (e.currentTarget.style.borderColor = JM.border)}
|
||||
/>
|
||||
|
||||
<div style={{
|
||||
fontSize: 12, color: JM.mid, marginBottom: 20, lineHeight: 1.5,
|
||||
padding: "12px 14px", background: JM.cream, borderRadius: 8,
|
||||
border: `1px solid ${JM.border}`, fontFamily: JM.fontSans,
|
||||
}}>
|
||||
Vibn 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>
|
||||
);
|
||||
}
|
||||
@@ -4,10 +4,9 @@ import { useState, useEffect } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { Plus_Jakarta_Sans } from "next/font/google";
|
||||
import { TypeSelector } from "./TypeSelector";
|
||||
import { FreshIdeaSetup } from "./FreshIdeaSetup";
|
||||
import { ChatImportSetup } from "./ChatImportSetup";
|
||||
import { CodeImportSetup } from "./CodeImportSetup";
|
||||
import { MigrateSetup } from "./MigrateSetup";
|
||||
import { BuildSetup } from "./BuildSetup";
|
||||
import { OssSetup } from "./OssSetup";
|
||||
import { ImportSetup } from "./ImportSetup";
|
||||
import { JM } from "./modal-theme";
|
||||
|
||||
const modalFont = Plus_Jakarta_Sans({
|
||||
@@ -17,7 +16,16 @@ const modalFont = Plus_Jakarta_Sans({
|
||||
display: "swap",
|
||||
});
|
||||
|
||||
export type CreationMode = "fresh" | "chat-import" | "code-import" | "migration";
|
||||
/**
|
||||
* Three project-creation paths surfaced to the user:
|
||||
* - "build" — describe an idea, AI scaffolds and builds it.
|
||||
* - "oss" — install an open-source tool (link OR describe-it-and-find).
|
||||
* - "import" — bring a public GitHub repo in.
|
||||
*
|
||||
* Each path is its own setup screen with internal step dots (basics → describe).
|
||||
* The TypeSelector is the entry point; ESC closes; backdrop click closes.
|
||||
*/
|
||||
export type CreationMode = "build" | "oss" | "import";
|
||||
|
||||
interface CreateProjectFlowProps {
|
||||
open: boolean;
|
||||
@@ -28,13 +36,13 @@ interface CreateProjectFlowProps {
|
||||
type Step = "select-type" | "setup";
|
||||
|
||||
export function CreateProjectFlow({ open, onOpenChange, workspace }: CreateProjectFlowProps) {
|
||||
const [step, setStep] = useState<Step>("setup");
|
||||
const [mode, setMode] = useState<CreationMode | null>("fresh");
|
||||
const [step, setStep] = useState<Step>("select-type");
|
||||
const [mode, setMode] = useState<CreationMode | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setStep("setup");
|
||||
setMode("fresh");
|
||||
setStep("select-type");
|
||||
setMode(null);
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
@@ -67,7 +75,6 @@ export function CreateProjectFlow({ open, onOpenChange, workspace }: CreateProje
|
||||
@keyframes vibn-spin { to { transform:rotate(360deg); } }
|
||||
`}</style>
|
||||
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
onClick={() => onOpenChange(false)}
|
||||
style={{
|
||||
@@ -79,7 +86,6 @@ export function CreateProjectFlow({ open, onOpenChange, workspace }: CreateProje
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Modal container — matches justine/03_dashboard.html #modal-new */}
|
||||
<div style={{
|
||||
position: "fixed", inset: 0, zIndex: 201,
|
||||
display: "flex", alignItems: "center", justifyContent: "center",
|
||||
@@ -105,10 +111,9 @@ export function CreateProjectFlow({ open, onOpenChange, workspace }: CreateProje
|
||||
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} />}
|
||||
{step === "setup" && mode === "build" && <BuildSetup {...setupProps} />}
|
||||
{step === "setup" && mode === "oss" && <OssSetup {...setupProps} />}
|
||||
{step === "setup" && mode === "import" && <ImportSetup {...setupProps} />}
|
||||
</div>
|
||||
</div>
|
||||
</>,
|
||||
|
||||
@@ -1,92 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useRef, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { toast } from "sonner";
|
||||
import { JM } from "./modal-theme";
|
||||
import { FieldLabel, TextInput, PrimaryButton, ForWhomSelector, type SetupProps } from "./setup-shared";
|
||||
|
||||
export function FreshIdeaSetup({ workspace, onClose }: SetupProps) {
|
||||
const router = useRouter();
|
||||
const [name, setName] = useState("");
|
||||
const [forWhom, setForWhom] = useState<"personal" | "client">("personal");
|
||||
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(), isForClient: forWhom === "client" },
|
||||
creationMode: "fresh",
|
||||
sourceData: {},
|
||||
}),
|
||||
});
|
||||
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: 28 }}>
|
||||
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", marginBottom: 22 }}>
|
||||
<h3 style={{
|
||||
fontFamily: JM.fontDisplay, fontSize: 18, fontWeight: 700,
|
||||
color: JM.ink, margin: 0, letterSpacing: "-0.02em",
|
||||
}}>
|
||||
New project
|
||||
</h3>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
style={{
|
||||
background: "none", border: "none", cursor: "pointer",
|
||||
color: JM.muted, fontSize: 20, padding: 4, lineHeight: 1,
|
||||
fontFamily: JM.fontSans,
|
||||
}}
|
||||
onMouseEnter={e => (e.currentTarget.style.color = JM.mid)}
|
||||
onMouseLeave={e => (e.currentTarget.style.color = JM.muted)}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<FieldLabel>Project name</FieldLabel>
|
||||
<TextInput
|
||||
value={name}
|
||||
onChange={setName}
|
||||
placeholder="e.g. My SaaS App"
|
||||
onKeyDown={e => { if (e.key === "Enter" && canCreate) void handleCreate(); }}
|
||||
inputRef={nameRef}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
<ForWhomSelector value={forWhom} onChange={setForWhom} />
|
||||
|
||||
<PrimaryButton onClick={() => { void handleCreate(); }} disabled={!canCreate} loading={loading}>
|
||||
Create project →
|
||||
</PrimaryButton>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
145
components/project-creation/ImportSetup.tsx
Normal file
145
components/project-creation/ImportSetup.tsx
Normal file
@@ -0,0 +1,145 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { toast } from "sonner";
|
||||
import { JM } from "./modal-theme";
|
||||
import {
|
||||
SetupHeader, FieldLabel, TextInput, TextArea, ForWhomSelector,
|
||||
PrimaryButton, SecondaryButton, StepDots, type SetupProps,
|
||||
} from "./setup-shared";
|
||||
|
||||
/**
|
||||
* "Import existing code" — two-step setup.
|
||||
* Step 1: project name + audience + repo URL.
|
||||
* Step 2: describe what you want Vibn to focus on (optional, but
|
||||
* recommended). Becomes the seed for the first AI conversation.
|
||||
*
|
||||
* v1: public repos only. Private-repo OAuth lands in v2.
|
||||
*/
|
||||
export function ImportSetup({ workspace, onClose, onBack }: SetupProps) {
|
||||
const router = useRouter();
|
||||
const [step, setStep] = useState<0 | 1>(0);
|
||||
const [name, setName] = useState("");
|
||||
const [forWhom, setForWhom] = useState<"personal" | "client">("personal");
|
||||
const [repoUrl, setRepoUrl] = useState("");
|
||||
const [intent, setIntent] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const isValidUrl = /^https?:\/\//i.test(repoUrl.trim());
|
||||
const canContinue = name.trim().length > 0 && isValidUrl;
|
||||
|
||||
const handleCreate = async () => {
|
||||
if (!canContinue) 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, "-"),
|
||||
vision: intent.trim() || `Continue work on ${repoUrl.trim()}`,
|
||||
product: { name: name.trim(), isForClient: forWhom === "client" },
|
||||
creationMode: "import",
|
||||
githubRepoUrl: repoUrl.trim(),
|
||||
sourceData: { repoUrl: repoUrl.trim(), intent: intent.trim() || null },
|
||||
}),
|
||||
});
|
||||
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: 28 }}>
|
||||
<SetupHeader
|
||||
icon="⌘" label="Import existing code" tagline="From GitHub"
|
||||
accent="#1D4ED8" onBack={step === 0 ? onBack : () => setStep(0)} onClose={onClose}
|
||||
/>
|
||||
|
||||
{step === 0 && (
|
||||
<>
|
||||
<FieldLabel>Project name</FieldLabel>
|
||||
<TextInput
|
||||
value={name}
|
||||
onChange={setName}
|
||||
placeholder="What do you want to call this?"
|
||||
autoFocus
|
||||
/>
|
||||
|
||||
<ForWhomSelector value={forWhom} onChange={setForWhom} />
|
||||
|
||||
<FieldLabel>GitHub repository link</FieldLabel>
|
||||
<TextInput
|
||||
value={repoUrl}
|
||||
onChange={setRepoUrl}
|
||||
placeholder="https://github.com/yourname/your-repo"
|
||||
/>
|
||||
<p style={{ fontSize: 12, color: JM.muted, marginTop: -8, marginBottom: 18, lineHeight: 1.5 }}>
|
||||
Public repos work today. Private-repo support is coming soon.
|
||||
</p>
|
||||
|
||||
<FlowFooter step={0} total={2} primary={
|
||||
<PrimaryButton onClick={() => setStep(1)} disabled={!canContinue}>
|
||||
Next →
|
||||
</PrimaryButton>
|
||||
} />
|
||||
</>
|
||||
)}
|
||||
|
||||
{step === 1 && (
|
||||
<>
|
||||
<FieldLabel>What do you want to do with it? <span style={{ color: JM.muted, fontWeight: 400 }}>(optional)</span></FieldLabel>
|
||||
<TextArea
|
||||
value={intent}
|
||||
onChange={setIntent}
|
||||
placeholder="Add a payments page, fix the login bug, and clean up the dashboard."
|
||||
rows={6}
|
||||
autoFocus
|
||||
/>
|
||||
<p style={{ fontSize: 12, color: JM.muted, marginTop: -8, marginBottom: 18, lineHeight: 1.5 }}>
|
||||
This tells Vibn where to start. Skip it and you can guide things in chat once the project's open.
|
||||
</p>
|
||||
|
||||
<FlowFooter
|
||||
step={1} total={2}
|
||||
secondary={<SecondaryButton onClick={() => setStep(0)}>← Back</SecondaryButton>}
|
||||
primary={
|
||||
<PrimaryButton onClick={handleCreate} disabled={!canContinue} loading={loading}>
|
||||
Import & open →
|
||||
</PrimaryButton>
|
||||
}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FlowFooter({
|
||||
step, total, primary, secondary,
|
||||
}: {
|
||||
step: number; total: number;
|
||||
primary: React.ReactNode; secondary?: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 12, marginTop: 6 }}>
|
||||
<StepDots step={step} total={total} />
|
||||
<div style={{ flex: 1 }} />
|
||||
{secondary}
|
||||
<div style={{ minWidth: 160 }}>{primary}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,166 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { toast } from "sonner";
|
||||
import { JM } from "./modal-theme";
|
||||
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",
|
||||
githubRepoUrl: repoUrl.trim() || undefined,
|
||||
githubToken: pat.trim() || undefined,
|
||||
sourceData: {
|
||||
liveUrl: liveUrl.trim() || undefined,
|
||||
hosting: hosting || 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: 28 }}>
|
||||
<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: JM.muted, fontWeight: 400 }}>(recommended)</span>
|
||||
</FieldLabel>
|
||||
<TextInput
|
||||
value={repoUrl}
|
||||
onChange={setRepoUrl}
|
||||
placeholder="https://github.com/yourorg/your-repo"
|
||||
/>
|
||||
|
||||
<FieldLabel>
|
||||
Live URL{" "}
|
||||
<span style={{ color: JM.muted, 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: "10px 13px", marginBottom: 16,
|
||||
borderRadius: 8, border: `1px solid ${JM.border}`,
|
||||
background: JM.inputBg, fontSize: 13,
|
||||
fontFamily: JM.fontSans, color: hosting ? JM.ink : JM.muted,
|
||||
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='%239CA3AF' strokeWidth='1.5' strokeLinecap='round' strokeLinejoin='round'/%3E%3C/svg%3E")`,
|
||||
backgroundRepeat: "no-repeat", backgroundPosition: "right 12px center",
|
||||
}}
|
||||
onFocus={e => (e.currentTarget.style.borderColor = JM.indigo)}
|
||||
onBlur={e => (e.currentTarget.style.borderColor = JM.border)}
|
||||
>
|
||||
{HOSTING_OPTIONS.map(o => (
|
||||
<option key={o.value} value={o.value}>{o.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<FieldLabel>
|
||||
PAT{" "}<span style={{ color: JM.muted, fontWeight: 400 }}>(private repos)</span>
|
||||
</FieldLabel>
|
||||
<input
|
||||
type="password"
|
||||
value={pat}
|
||||
onChange={e => setPat(e.target.value)}
|
||||
placeholder="ghp_…"
|
||||
style={{
|
||||
width: "100%", padding: "10px 13px", marginBottom: 16,
|
||||
borderRadius: 8, border: `1px solid ${JM.border}`,
|
||||
background: JM.inputBg, fontSize: 14,
|
||||
fontFamily: JM.fontSans, color: JM.ink,
|
||||
outline: "none", boxSizing: "border-box",
|
||||
}}
|
||||
onFocus={e => (e.currentTarget.style.borderColor = JM.indigo)}
|
||||
onBlur={e => (e.currentTarget.style.borderColor = JM.border)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{
|
||||
fontSize: 12, color: JM.mid, marginBottom: 20, lineHeight: 1.5,
|
||||
padding: "12px 14px", background: JM.cream, borderRadius: 8,
|
||||
border: `1px solid ${JM.border}`, fontFamily: JM.fontSans,
|
||||
}}>
|
||||
<strong style={{ color: "#5B21B6" }}>Non-destructive.</strong> Vibn 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>
|
||||
);
|
||||
}
|
||||
172
components/project-creation/OssSetup.tsx
Normal file
172
components/project-creation/OssSetup.tsx
Normal file
@@ -0,0 +1,172 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { toast } from "sonner";
|
||||
import { JM } from "./modal-theme";
|
||||
import {
|
||||
SetupHeader, FieldLabel, TextInput, TextArea, ForWhomSelector,
|
||||
PrimaryButton, SecondaryButton, SegmentedTabs, StepDots, type SetupProps,
|
||||
} from "./setup-shared";
|
||||
|
||||
/**
|
||||
* "Run an open source tool" — two-step setup.
|
||||
* Step 1: project name + audience.
|
||||
* Step 2: either paste a link to the OSS repo (e.g. github.com/twentyhq/twenty)
|
||||
* OR describe the tool you want and let Vibn find one.
|
||||
* Both produce a free-text "what do you want to do with it?" seed
|
||||
* that we hand to the AI on first chat.
|
||||
*/
|
||||
export function OssSetup({ workspace, onClose, onBack }: SetupProps) {
|
||||
const router = useRouter();
|
||||
const [step, setStep] = useState<0 | 1>(0);
|
||||
const [name, setName] = useState("");
|
||||
const [forWhom, setForWhom] = useState<"personal" | "client">("personal");
|
||||
|
||||
const [tab, setTab] = useState<"link" | "describe">("link");
|
||||
const [repoUrl, setRepoUrl] = useState("");
|
||||
const [needs, setNeeds] = useState("");
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const canContinue = name.trim().length > 0;
|
||||
const isValidUrl = /^https?:\/\//i.test(repoUrl.trim());
|
||||
const canCreate =
|
||||
(tab === "link" && isValidUrl) ||
|
||||
(tab === "describe" && needs.trim().length > 4);
|
||||
|
||||
const handleCreate = async () => {
|
||||
if (!canCreate) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const seed = tab === "link"
|
||||
? `Install and host this open-source project: ${repoUrl.trim()}`
|
||||
: `Find and install an open-source tool that fits this need: ${needs.trim()}`;
|
||||
const res = await fetch("/api/projects/create", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
projectName: name.trim(),
|
||||
projectType: "service",
|
||||
slug: name.toLowerCase().replace(/[^a-z0-9]+/g, "-"),
|
||||
vision: seed,
|
||||
product: { name: name.trim(), isForClient: forWhom === "client" },
|
||||
creationMode: "oss",
|
||||
sourceData: tab === "link"
|
||||
? { kind: "link", repoUrl: repoUrl.trim() }
|
||||
: { kind: "describe", description: needs.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: 28 }}>
|
||||
<SetupHeader
|
||||
icon="◇" label="Run an open source tool" tagline="Install ready-made"
|
||||
accent="#059669" onBack={step === 0 ? onBack : () => setStep(0)} onClose={onClose}
|
||||
/>
|
||||
|
||||
{step === 0 && (
|
||||
<>
|
||||
<FieldLabel>Project name</FieldLabel>
|
||||
<TextInput
|
||||
value={name}
|
||||
onChange={setName}
|
||||
placeholder="e.g. My CRM"
|
||||
onKeyDown={e => { if (e.key === "Enter" && canContinue) setStep(1); }}
|
||||
autoFocus
|
||||
/>
|
||||
|
||||
<ForWhomSelector value={forWhom} onChange={setForWhom} />
|
||||
|
||||
<FlowFooter step={0} total={2} primary={
|
||||
<PrimaryButton onClick={() => setStep(1)} disabled={!canContinue}>
|
||||
Next →
|
||||
</PrimaryButton>
|
||||
} />
|
||||
</>
|
||||
)}
|
||||
|
||||
{step === 1 && (
|
||||
<>
|
||||
<SegmentedTabs<"link" | "describe">
|
||||
value={tab}
|
||||
onChange={setTab}
|
||||
options={[
|
||||
{ id: "link", label: "I have a link" },
|
||||
{ id: "describe", label: "Help me find one" },
|
||||
]}
|
||||
/>
|
||||
|
||||
{tab === "link" ? (
|
||||
<>
|
||||
<FieldLabel>Link to the open source project</FieldLabel>
|
||||
<TextInput
|
||||
value={repoUrl}
|
||||
onChange={setRepoUrl}
|
||||
placeholder="https://github.com/twentyhq/twenty"
|
||||
autoFocus
|
||||
/>
|
||||
<p style={{ fontSize: 12, color: JM.muted, marginTop: -8, marginBottom: 18, lineHeight: 1.5 }}>
|
||||
Paste a GitHub link. Vibn will check it works and host it for you.
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<FieldLabel>What kind of tool are you looking for?</FieldLabel>
|
||||
<TextArea
|
||||
value={needs}
|
||||
onChange={setNeeds}
|
||||
placeholder="A simple CRM where my sales team can track deals and notes on customers."
|
||||
rows={6}
|
||||
autoFocus
|
||||
/>
|
||||
<p style={{ fontSize: 12, color: JM.muted, marginTop: -8, marginBottom: 18, lineHeight: 1.5 }}>
|
||||
Vibn will suggest open-source projects that match and let you pick.
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
|
||||
<FlowFooter
|
||||
step={1} total={2}
|
||||
secondary={<SecondaryButton onClick={() => setStep(0)}>← Back</SecondaryButton>}
|
||||
primary={
|
||||
<PrimaryButton onClick={handleCreate} disabled={!canCreate} loading={loading}>
|
||||
{tab === "link" ? "Install →" : "Find tools →"}
|
||||
</PrimaryButton>
|
||||
}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FlowFooter({
|
||||
step, total, primary, secondary,
|
||||
}: {
|
||||
step: number; total: number;
|
||||
primary: React.ReactNode; secondary?: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 12, marginTop: 6 }}>
|
||||
<StepDots step={step} total={total} />
|
||||
<div style={{ flex: 1 }} />
|
||||
{secondary}
|
||||
<div style={{ minWidth: 160 }}>{primary}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -8,53 +8,36 @@ interface TypeSelectorProps {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const ALL_FLOW_TYPES: {
|
||||
const FLOW_TYPES: {
|
||||
id: CreationMode;
|
||||
icon: string;
|
||||
label: string;
|
||||
tagline: string;
|
||||
desc: string;
|
||||
accent: string;
|
||||
hidden?: boolean;
|
||||
}[] = [
|
||||
{
|
||||
id: "fresh",
|
||||
id: "build",
|
||||
icon: "✦",
|
||||
label: "Fresh Idea",
|
||||
tagline: "Start from scratch",
|
||||
desc: "Talk through your idea with Vibn. We'll explore it together and shape it into a full product plan.",
|
||||
label: "Build your own idea",
|
||||
desc: "Tell Vibn what you want to make. We'll scaffold the codebase and you'll guide it from there.",
|
||||
accent: "#4338CA",
|
||||
},
|
||||
{
|
||||
id: "chat-import",
|
||||
icon: "⌁",
|
||||
label: "Import Chats",
|
||||
tagline: "You've been thinking",
|
||||
desc: "Paste conversations from ChatGPT or Claude. Vibn extracts your decisions, ideas, and open questions.",
|
||||
id: "oss",
|
||||
icon: "◇",
|
||||
label: "Run an open source tool",
|
||||
desc: "Install something ready-made — like a CRM, automation, or analytics — and we'll host it for you.",
|
||||
accent: "#059669",
|
||||
},
|
||||
{
|
||||
id: "code-import",
|
||||
id: "import",
|
||||
icon: "⌘",
|
||||
label: "Import Code",
|
||||
tagline: "Already have a repo",
|
||||
desc: "Point Vibn at your GitHub or Bitbucket repo. We'll map your stack and show what's missing.",
|
||||
label: "Import existing code",
|
||||
desc: "Bring a project you already have on GitHub. We'll mirror it, host it, and pick up where you left off.",
|
||||
accent: "#1D4ED8",
|
||||
hidden: true,
|
||||
},
|
||||
{
|
||||
id: "migration",
|
||||
icon: "⇢",
|
||||
label: "Migrate Product",
|
||||
tagline: "Move an existing product",
|
||||
desc: "Bring your live product into the VIBN infrastructure. Vibn builds a safe, phased migration plan.",
|
||||
accent: "#7C3AED",
|
||||
hidden: true,
|
||||
},
|
||||
];
|
||||
|
||||
const FLOW_TYPES = ALL_FLOW_TYPES.filter(t => !t.hidden);
|
||||
|
||||
export function TypeSelector({ onSelect, onClose }: TypeSelectorProps) {
|
||||
return (
|
||||
<div style={{ padding: 28 }}>
|
||||
@@ -92,20 +75,19 @@ export function TypeSelector({ onSelect, onClose }: TypeSelectorProps) {
|
||||
type="button"
|
||||
onClick={() => onSelect(type.id)}
|
||||
style={{
|
||||
display: "flex", flexDirection: "column", alignItems: "flex-start",
|
||||
gap: 0, padding: 16, borderRadius: 12, textAlign: "left",
|
||||
display: "flex", alignItems: "center",
|
||||
gap: 14, padding: 14, borderRadius: 12, textAlign: "left",
|
||||
border: `1px solid ${JM.border}`,
|
||||
background: JM.inputBg,
|
||||
cursor: "pointer",
|
||||
transition: "border-color 0.15s, background 0.15s, box-shadow 0.15s",
|
||||
fontFamily: JM.fontSans,
|
||||
position: "relative",
|
||||
overflow: "hidden",
|
||||
}}
|
||||
onMouseEnter={e => {
|
||||
e.currentTarget.style.borderColor = JM.indigo;
|
||||
e.currentTarget.style.background = JM.cream;
|
||||
e.currentTarget.style.boxShadow = "0 2px 12px rgba(99,102,241,0.1)";
|
||||
e.currentTarget.style.borderColor = type.accent;
|
||||
e.currentTarget.style.background = "#fff";
|
||||
e.currentTarget.style.boxShadow = `0 2px 12px ${type.accent}1a`;
|
||||
}}
|
||||
onMouseLeave={e => {
|
||||
e.currentTarget.style.borderColor = JM.border;
|
||||
@@ -114,34 +96,24 @@ export function TypeSelector({ onSelect, onClose }: TypeSelectorProps) {
|
||||
}}
|
||||
>
|
||||
<div style={{
|
||||
width: 36, height: 36, borderRadius: 9, marginBottom: 12,
|
||||
background: "rgba(99,102,241,0.12)",
|
||||
width: 40, height: 40, borderRadius: 10,
|
||||
background: `${type.accent}1a`,
|
||||
display: "flex", alignItems: "center", justifyContent: "center",
|
||||
fontSize: "1.05rem", color: type.accent,
|
||||
fontSize: "1.15rem", color: type.accent, flexShrink: 0,
|
||||
}}>
|
||||
{type.icon}
|
||||
</div>
|
||||
|
||||
<div style={{ fontSize: 13.5, fontWeight: 600, color: JM.ink, marginBottom: 2 }}>
|
||||
{type.label}
|
||||
</div>
|
||||
<div style={{
|
||||
fontSize: 10.5, fontWeight: 600, color: type.accent,
|
||||
letterSpacing: "0.07em", marginBottom: 8, textTransform: "uppercase",
|
||||
}}>
|
||||
{type.tagline}
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ fontSize: 13.5, fontWeight: 600, color: JM.ink, marginBottom: 2 }}>
|
||||
{type.label}
|
||||
</div>
|
||||
<div style={{ fontSize: 12.5, color: JM.mid, lineHeight: 1.45 }}>
|
||||
{type.desc}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ fontSize: 12.5, color: JM.mid, lineHeight: 1.55 }}>
|
||||
{type.desc}
|
||||
</div>
|
||||
|
||||
<div style={{
|
||||
position: "absolute", right: 16, bottom: 16,
|
||||
fontSize: 14, color: JM.muted,
|
||||
}}>
|
||||
→
|
||||
</div>
|
||||
<div style={{ fontSize: 14, color: JM.muted, flexShrink: 0 }}>→</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -173,6 +173,121 @@ export function TextInput({
|
||||
);
|
||||
}
|
||||
|
||||
export function TextArea({
|
||||
value,
|
||||
onChange,
|
||||
placeholder,
|
||||
rows = 5,
|
||||
autoFocus,
|
||||
}: {
|
||||
value: string;
|
||||
onChange: (v: string) => void;
|
||||
placeholder?: string;
|
||||
rows?: number;
|
||||
autoFocus?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<textarea
|
||||
value={value}
|
||||
onChange={e => onChange(e.target.value)}
|
||||
placeholder={placeholder}
|
||||
rows={rows}
|
||||
autoFocus={autoFocus}
|
||||
style={{
|
||||
width: "100%", padding: "11px 13px", marginBottom: 16,
|
||||
borderRadius: 8, border: `1px solid ${JM.border}`,
|
||||
background: JM.inputBg, fontSize: 14,
|
||||
fontFamily: JM.fontSans, color: JM.ink,
|
||||
outline: "none", boxSizing: "border-box",
|
||||
resize: "vertical", lineHeight: 1.5,
|
||||
minHeight: 96,
|
||||
}}
|
||||
onFocus={e => (e.currentTarget.style.borderColor = JM.indigo)}
|
||||
onBlur={e => (e.currentTarget.style.borderColor = JM.border)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/** Page indicator dots for a multi-step setup screen. */
|
||||
export function StepDots({ step, total }: { step: number; total: number }) {
|
||||
return (
|
||||
<div style={{ display: "flex", gap: 5, alignItems: "center" }}>
|
||||
{Array.from({ length: total }).map((_, i) => (
|
||||
<span
|
||||
key={i}
|
||||
style={{
|
||||
width: i === step ? 18 : 6, height: 6, borderRadius: 3,
|
||||
background: i === step ? JM.indigo : JM.border,
|
||||
transition: "all 0.2s",
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Inline tab selector — used inside OSS setup to pick paste-link vs describe-it. */
|
||||
export function SegmentedTabs<T extends string>({
|
||||
value, onChange, options,
|
||||
}: {
|
||||
value: T;
|
||||
onChange: (v: T) => void;
|
||||
options: { id: T; label: string }[];
|
||||
}) {
|
||||
return (
|
||||
<div style={{
|
||||
display: "flex", padding: 3, marginBottom: 14,
|
||||
background: JM.inputBg, border: `1px solid ${JM.border}`,
|
||||
borderRadius: 9, gap: 3,
|
||||
}}>
|
||||
{options.map(opt => {
|
||||
const sel = opt.id === value;
|
||||
return (
|
||||
<button
|
||||
key={opt.id}
|
||||
type="button"
|
||||
onClick={() => onChange(opt.id)}
|
||||
style={{
|
||||
flex: 1, padding: "7px 10px", borderRadius: 6,
|
||||
border: "none", cursor: "pointer",
|
||||
background: sel ? "#fff" : "transparent",
|
||||
color: sel ? JM.ink : JM.mid,
|
||||
fontSize: 12.5, fontWeight: sel ? 600 : 500,
|
||||
fontFamily: JM.fontSans,
|
||||
boxShadow: sel ? "0 1px 3px rgba(0,0,0,0.06)" : "none",
|
||||
transition: "all 0.15s",
|
||||
}}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function SecondaryButton({
|
||||
onClick, children,
|
||||
}: { onClick: () => void; children: ReactNode }) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
style={{
|
||||
padding: "11px 16px", borderRadius: 8,
|
||||
border: `1px solid ${JM.border}`, background: "#fff",
|
||||
color: JM.mid, fontSize: 13.5, fontWeight: 600,
|
||||
fontFamily: JM.fontSans, cursor: "pointer",
|
||||
transition: "border-color 0.15s, color 0.15s",
|
||||
}}
|
||||
onMouseEnter={e => { e.currentTarget.style.borderColor = JM.mid; e.currentTarget.style.color = JM.ink; }}
|
||||
onMouseLeave={e => { e.currentTarget.style.borderColor = JM.border; e.currentTarget.style.color = JM.mid; }}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function PrimaryButton({
|
||||
onClick,
|
||||
disabled,
|
||||
|
||||
Reference in New Issue
Block a user