Simplify project creation: name → create → redirect

- Remove GitHub step entirely; single input + Next button
- Creates project immediately, redirects to /overview on success
- Rewritten in Stackless inline style (no shadcn Dialog/Button/Input)

Made-with: Cursor
This commit is contained in:
2026-03-02 19:05:50 -08:00
parent 1ce4ad4c8b
commit 7602d81120

View File

@@ -1,362 +1,185 @@
'use client'; 'use client';
import { useState, useEffect } from 'react'; import { useState, useEffect, useRef } from 'react';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import {
Github,
Loader2,
CheckCircle2,
XCircle,
ExternalLink,
ChevronLeft,
} from 'lucide-react';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { Card, CardContent } from '@/components/ui/card';
import { Separator } from '@/components/ui/separator';
interface ProjectCreationModalProps { interface ProjectCreationModalProps {
open: boolean; open: boolean;
onOpenChange: (open: boolean) => void; onOpenChange: (open: boolean) => void;
initialWorkspacePath?: string;
workspace: string; workspace: string;
initialWorkspacePath?: string;
} }
export function ProjectCreationModal({ export function ProjectCreationModal({ open, onOpenChange, workspace }: ProjectCreationModalProps) {
open,
onOpenChange,
initialWorkspacePath,
workspace,
}: ProjectCreationModalProps) {
const router = useRouter(); const router = useRouter();
const [step, setStep] = useState(1);
const [productName, setProductName] = useState(''); const [productName, setProductName] = useState('');
const [selectedRepo, setSelectedRepo] = useState<any>(null);
const [createdProjectId, setCreatedProjectId] = useState<string | null>(null);
const [createdGiteaRepo, setCreatedGiteaRepo] = useState<{ repo: string; repoUrl: string } | null>(null);
const [createdTheiaUrl, setCreatedTheiaUrl] = useState<string | null>(null);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [githubConnected, setGithubConnected] = useState(false); const inputRef = useRef<HTMLInputElement>(null);
const [githubRepos, setGithubRepos] = useState<any[]>([]);
const [loadingGithub, setLoadingGithub] = useState(false);
useEffect(() => { useEffect(() => {
async function checkGitHub() { if (open) {
if (!open || step !== 2) return; setProductName('');
setLoadingGithub(true); setLoading(false);
try { setTimeout(() => inputRef.current?.focus(), 80);
const statusResponse = await fetch('/api/github/connect');
if (statusResponse.ok) {
const statusData = await statusResponse.json();
const isConnected = statusData.connected || false;
setGithubConnected(isConnected);
if (isConnected) {
const reposResponse = await fetch('/api/github/repos');
if (reposResponse.ok) {
const repos = await reposResponse.json();
setGithubRepos(Array.isArray(repos) ? repos : []);
} }
}
} else {
setGithubConnected(false);
}
} catch (error) {
console.error('GitHub check error:', error);
setGithubConnected(false);
} finally {
setLoadingGithub(false);
}
}
checkGitHub();
}, [open, step]);
useEffect(() => {
if (!open) setTimeout(resetModal, 200);
}, [open]); }, [open]);
const resetModal = () => { // Close on Escape
setStep(1); useEffect(() => {
setProductName(''); if (!open) return;
setSelectedRepo(null); const handler = (e: KeyboardEvent) => { if (e.key === 'Escape') onOpenChange(false); };
setCreatedProjectId(null); window.addEventListener('keydown', handler);
setCreatedGiteaRepo(null); return () => window.removeEventListener('keydown', handler);
setCreatedTheiaUrl(null); }, [open, onOpenChange]);
setLoading(false);
};
const handleCreateProject = async () => { const handleCreate = async () => {
if (!productName.trim()) { const name = productName.trim();
toast.error('Product name is required'); if (!name) return;
return;
}
setLoading(true); setLoading(true);
try { try {
const projectData = { const res = await fetch('/api/projects/create', {
projectName: productName.trim(),
projectType: selectedRepo ? 'existing' : 'scratch',
slug: productName.toLowerCase().replace(/[^a-z0-9]+/g, '-'),
product: { name: productName },
...(selectedRepo && {
githubRepo: selectedRepo.full_name,
githubRepoId: selectedRepo.id,
githubRepoUrl: selectedRepo.html_url,
githubDefaultBranch: selectedRepo.default_branch,
}),
};
const response = await fetch('/api/projects/create', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(projectData), body: JSON.stringify({
projectName: name,
projectType: 'scratch',
slug: name.toLowerCase().replace(/[^a-z0-9]+/g, '-'),
product: { name },
}),
}); });
if (!res.ok) {
if (response.ok) { const err = await res.json();
const data = await response.json(); toast.error(err.error || 'Failed to create project');
setCreatedProjectId(data.projectId); return;
if (data.gitea) setCreatedGiteaRepo(data.gitea);
if (data.theiaWorkspaceUrl) setCreatedTheiaUrl(data.theiaWorkspaceUrl);
toast.success('Project created!');
setStep(3);
} else {
const error = await response.json();
toast.error(error.error || 'Failed to create project');
} }
} catch (error) { const data = await res.json();
console.error('Error creating project:', error); onOpenChange(false);
toast.error('An error occurred'); router.push(`/${workspace}/project/${data.projectId}/overview`);
} catch {
toast.error('Something went wrong');
} finally { } finally {
setLoading(false); setLoading(false);
} }
}; };
const handleFinish = () => { if (!open) return null;
onOpenChange(false);
if (createdProjectId) {
router.push(`/${workspace}/project/${createdProjectId}/overview`);
}
};
return ( return (
<Dialog open={open} onOpenChange={onOpenChange}> <>
<DialogContent className="sm:max-w-[600px]"> {/* Backdrop */}
<DialogHeader> <div
<DialogTitle> onClick={() => onOpenChange(false)}
{step === 1 && 'Create New Project'} style={{
{step === 2 && 'Connect GitHub Repository'} position: 'fixed', inset: 0, zIndex: 50,
{step === 3 && 'Setup Complete!'} background: 'rgba(26,26,26,0.35)',
</DialogTitle> animation: 'fadeIn 0.15s ease',
<DialogDescription>
{step === 1 && 'Give your project a name to get started.'}
{step === 2 && 'Select a GitHub repository to connect (optional).'}
{step === 3 && 'Add the .vibn file to your project to enable tracking.'}
</DialogDescription>
</DialogHeader>
<div className="space-y-6 py-4">
{step === 1 && (
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="productName">Product Name *</Label>
<Input
id="productName"
placeholder="e.g., TaskMaster, HealthTracker, MyApp"
value={productName}
onChange={(e) => setProductName(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' && productName.trim()) {
setStep(2);
}
}} }}
autoFocus />
{/* Modal */}
<div style={{
position: 'fixed', inset: 0, zIndex: 51,
display: 'flex', alignItems: 'center', justifyContent: 'center',
padding: 24, pointerEvents: 'none',
}}>
<div
style={{
background: '#fff', borderRadius: 14,
boxShadow: '0 8px 40px rgba(26,26,26,0.14)',
padding: '36px 40px',
width: '100%', maxWidth: 480,
fontFamily: 'Outfit, sans-serif',
pointerEvents: 'all',
animation: 'slideUp 0.18s cubic-bezier(0.4,0,0.2,1)',
}}
onClick={e => e.stopPropagation()}
>
<style>{`
@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }
@keyframes slideUp { from { opacity: 0; transform: translateY(12px); } to { opacity: 1; transform: translateY(0); } }
`}</style>
{/* Header */}
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', marginBottom: 28 }}>
<div>
<h2 style={{ fontFamily: 'Newsreader, serif', fontSize: '1.35rem', fontWeight: 400, color: '#1a1a1a', marginBottom: 4 }}>
New project
</h2>
<p style={{ fontSize: '0.8rem', color: '#a09a90' }}>
Give your project a name to get started.
</p>
</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',
}}
onMouseEnter={e => (e.currentTarget.style.color = '#6b6560')}
onMouseLeave={e => (e.currentTarget.style.color = '#b5b0a6')}
>
×
</button>
</div>
{/* Name input */}
<div style={{ marginBottom: 20 }}>
<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() && !loading) handleCreate(); }}
placeholder="e.g. Foxglove, Meridian, OpsAI…"
style={{
width: '100%', padding: '11px 14px',
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')}
/> />
</div> </div>
<Button onClick={() => setStep(2)} disabled={!productName.trim()} className="w-full">
Next: {githubConnected ? 'Select Repository' : 'Connect GitHub'}
</Button>
<p className="text-xs text-muted-foreground text-center">
Connect GitHub or skip to continue
</p>
</div>
)}
{step === 2 && ( {/* Create button */}
<div className="space-y-4"> <button
<Button variant="ghost" size="sm" onClick={() => setStep(1)} className="mb-2"> onClick={handleCreate}
<ChevronLeft className="mr-1 h-4 w-4" /> disabled={!productName.trim() || loading}
Back style={{
</Button> width: '100%', padding: '12px',
borderRadius: 8, border: 'none',
{loadingGithub ? ( background: productName.trim() && !loading ? '#1a1a1a' : '#e0dcd4',
<div className="flex flex-col items-center justify-center py-8 space-y-4"> color: productName.trim() && !loading ? '#fff' : '#b5b0a6',
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" /> fontSize: '0.88rem', fontWeight: 600,
<p className="text-sm text-muted-foreground">Checking GitHub connection...</p> fontFamily: 'Outfit, sans-serif',
</div> cursor: productName.trim() && !loading ? 'pointer' : 'not-allowed',
) : ( transition: 'opacity 0.15s, background 0.15s',
<> display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 8,
<div className="space-y-2 max-h-[300px] overflow-y-auto">
<Button
variant={selectedRepo === null ? 'default' : 'outline'}
className="w-full justify-start"
onClick={() => setSelectedRepo(null)}
>
<CheckCircle2 className={`mr-2 h-4 w-4 ${selectedRepo === null ? '' : 'opacity-0'}`} />
Skip - No GitHub repository
</Button>
<Separator className="my-2" />
{githubRepos.length > 0 ? (
githubRepos.map((repo) => (
<Button
key={repo.id}
variant={selectedRepo?.id === repo.id ? 'default' : 'outline'}
className="w-full justify-start"
onClick={() => setSelectedRepo(repo)}
>
<CheckCircle2 className={`mr-2 h-4 w-4 ${selectedRepo?.id === repo.id ? '' : 'opacity-0'}`} />
<Github className="mr-2 h-4 w-4" />
<span className="truncate">{repo.full_name}</span>
</Button>
))
) : !githubConnected ? (
<div className="space-y-3 py-4">
<p className="text-sm text-muted-foreground text-center">GitHub not connected yet</p>
<Button
variant="outline"
className="w-full"
onClick={async () => {
try {
const response = await fetch('/api/github/oauth-url', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ returnTo: `/${workspace}/projects` }),
});
if (response.ok) {
const { authUrl } = await response.json();
window.location.href = authUrl;
} else {
toast.error('Failed to initiate GitHub OAuth');
}
} catch (error) {
toast.error('Error connecting to GitHub');
}
}} }}
onMouseEnter={e => { if (productName.trim() && !loading) (e.currentTarget.style.opacity = '0.85'); }}
onMouseLeave={e => { (e.currentTarget.style.opacity = '1'); }}
> >
<Github className="mr-2 h-4 w-4" />
Connect GitHub
</Button>
</div>
) : (
<p className="text-sm text-muted-foreground text-center py-4">No repositories found</p>
)}
</div>
<Button onClick={handleCreateProject} disabled={loading} className="w-full" size="lg">
{loading ? ( {loading ? (
<><Loader2 className="mr-2 h-4 w-4 animate-spin" />Creating...</> <>
) : ( <span style={{ width: 14, height: 14, borderRadius: '50%', border: '2px solid #fff4', borderTopColor: '#fff', animation: 'spin 0.7s linear infinite', display: 'inline-block' }} />
'Create Project' <style>{`@keyframes spin { to { transform: rotate(360deg); } }`}</style>
)} Creating
</Button>
</> </>
)}
</div>
)}
{step === 3 && createdProjectId && (
<div className="space-y-4">
<Card className="border-green-500/50 bg-green-500/5">
<CardContent className="pt-6">
<div className="flex items-start gap-3">
<CheckCircle2 className="h-5 w-5 text-green-600 mt-0.5 shrink-0" />
<div className="space-y-1">
<p className="font-medium text-green-900 dark:text-green-100">
{productName} is ready
</p>
{createdGiteaRepo ? (
<a
href={createdGiteaRepo.repoUrl}
target="_blank"
rel="noopener noreferrer"
className="text-sm text-green-700 dark:text-green-300 hover:underline flex items-center gap-1"
>
<Github className="h-3.5 w-3.5" />
{createdGiteaRepo.repo}
<ExternalLink className="h-3 w-3" />
</a>
) : ( ) : (
<p className="text-sm text-green-700 dark:text-green-300"> 'Next →'
Gitea repo provisioning skipped
</p>
)} )}
</button>
</div> </div>
</div> </div>
</CardContent> </>
</Card>
<div className="rounded-md border bg-muted/30 p-4 space-y-2 text-sm">
<p className="font-medium">What was provisioned:</p>
<ul className="space-y-1 text-muted-foreground text-xs">
<li className="flex items-center gap-2">
<CheckCircle2 className="h-3.5 w-3.5 text-green-500 shrink-0" />
Project record saved to database
</li>
<li className="flex items-center gap-2">
{createdGiteaRepo ? (
<CheckCircle2 className="h-3.5 w-3.5 text-green-500 shrink-0" />
) : (
<XCircle className="h-3.5 w-3.5 text-yellow-500 shrink-0" />
)}
Gitea repo created at git.vibnai.com
</li>
<li className="flex items-center gap-2">
{createdGiteaRepo ? (
<CheckCircle2 className="h-3.5 w-3.5 text-green-500 shrink-0" />
) : (
<XCircle className="h-3.5 w-3.5 text-yellow-500 shrink-0" />
)}
Webhook registered (push, PR, issues Vibn)
</li>
<li className="flex items-center gap-2">
{createdTheiaUrl ? (
<CheckCircle2 className="h-3.5 w-3.5 text-green-500 shrink-0" />
) : (
<Loader2 className="h-3.5 w-3.5 text-blue-400 shrink-0 animate-spin" />
)}
Dedicated IDE workspace{createdTheiaUrl
? ` at ${createdTheiaUrl.replace('https://', '')}`
: ' — provisioning in background (ready in ~30s)'}
</li>
</ul>
</div>
<div className="flex gap-3">
<a
href={createdTheiaUrl ?? 'https://theia.vibnai.com'}
target="_blank"
rel="noopener noreferrer"
className="flex-1"
>
<Button variant="outline" className="w-full">
Open IDE
</Button>
</a>
<Button onClick={handleFinish} className="flex-1" size="lg">
Go to Project
</Button>
</div>
</div>
)}
</div>
</DialogContent>
</Dialog>
); );
} }