Add project type selection step to creation modal

Made-with: Cursor
This commit is contained in:
2026-03-02 19:09:35 -08:00
parent 7602d81120
commit db21737f50

View File

@@ -11,21 +11,33 @@ interface ProjectCreationModalProps {
initialWorkspacePath?: 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) { export function ProjectCreationModal({ open, onOpenChange, workspace }: ProjectCreationModalProps) {
const router = useRouter(); const router = useRouter();
const [step, setStep] = useState<1 | 2>(1);
const [productName, setProductName] = useState(''); const [productName, setProductName] = useState('');
const [projectType, setProjectType] = useState<string | null>(null);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const inputRef = useRef<HTMLInputElement>(null); const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => { useEffect(() => {
if (open) { if (open) {
setStep(1);
setProductName(''); setProductName('');
setProjectType(null);
setLoading(false); setLoading(false);
setTimeout(() => inputRef.current?.focus(), 80); setTimeout(() => inputRef.current?.focus(), 80);
} }
}, [open]); }, [open]);
// Close on Escape
useEffect(() => { useEffect(() => {
if (!open) return; if (!open) return;
const handler = (e: KeyboardEvent) => { if (e.key === 'Escape') onOpenChange(false); }; const handler = (e: KeyboardEvent) => { if (e.key === 'Escape') onOpenChange(false); };
@@ -34,18 +46,17 @@ export function ProjectCreationModal({ open, onOpenChange, workspace }: ProjectC
}, [open, onOpenChange]); }, [open, onOpenChange]);
const handleCreate = async () => { const handleCreate = async () => {
const name = productName.trim(); if (!productName.trim() || !projectType) return;
if (!name) return;
setLoading(true); setLoading(true);
try { try {
const res = await fetch('/api/projects/create', { const res = await fetch('/api/projects/create', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify({
projectName: name, projectName: productName.trim(),
projectType: 'scratch', projectType,
slug: name.toLowerCase().replace(/[^a-z0-9]+/g, '-'), slug: productName.toLowerCase().replace(/[^a-z0-9]+/g, '-'),
product: { name }, product: { name: productName.trim(), type: projectType },
}), }),
}); });
if (!res.ok) { if (!res.ok) {
@@ -84,39 +95,56 @@ export function ProjectCreationModal({ open, onOpenChange, workspace }: ProjectC
padding: 24, pointerEvents: 'none', padding: 24, pointerEvents: 'none',
}}> }}>
<div <div
onClick={e => e.stopPropagation()}
style={{ style={{
background: '#fff', borderRadius: 14, background: '#fff', borderRadius: 14,
boxShadow: '0 8px 40px rgba(26,26,26,0.14)', boxShadow: '0 8px 40px rgba(26,26,26,0.14)',
padding: '36px 40px', padding: '32px 36px',
width: '100%', maxWidth: 480, width: '100%', maxWidth: step === 2 ? 560 : 460,
fontFamily: 'Outfit, sans-serif', fontFamily: 'Outfit, sans-serif',
pointerEvents: 'all', pointerEvents: 'all',
animation: 'slideUp 0.18s cubic-bezier(0.4,0,0.2,1)', animation: 'slideUp 0.18s cubic-bezier(0.4,0,0.2,1)',
transition: 'max-width 0.2s ease',
}} }}
onClick={e => e.stopPropagation()}
> >
<style>{` <style>{`
@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } } @keyframes fadeIn { from { opacity:0; } to { opacity:1; } }
@keyframes slideUp { from { opacity: 0; transform: translateY(12px); } to { opacity: 1; transform: translateY(0); } } @keyframes slideUp { from { opacity:0; transform:translateY(12px); } to { opacity:1; transform:translateY(0); } }
@keyframes spin { to { transform:rotate(360deg); } }
`}</style> `}</style>
{/* Header */} {/* Header */}
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', marginBottom: 28 }}> <div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', marginBottom: 24 }}>
<div> <div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<h2 style={{ fontFamily: 'Newsreader, serif', fontSize: '1.35rem', fontWeight: 400, color: '#1a1a1a', marginBottom: 4 }}> {step === 2 && (
New project <button
</h2> onClick={() => setStep(1)}
<p style={{ fontSize: '0.8rem', color: '#a09a90' }}> style={{
Give your project a name to get started. background: 'none', border: 'none', cursor: 'pointer',
</p> 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> </div>
<button <button
onClick={() => onOpenChange(false)} onClick={() => onOpenChange(false)}
style={{ style={{
background: 'none', border: 'none', cursor: 'pointer', background: 'none', border: 'none', cursor: 'pointer',
color: '#b5b0a6', fontSize: '1.1rem', lineHeight: 1, color: '#b5b0a6', fontSize: '1.1rem', lineHeight: 1,
padding: '2px 4px', borderRadius: 4, padding: '2px 4px', borderRadius: 4, transition: 'color 0.12s', flexShrink: 0,
transition: 'color 0.12s',
}} }}
onMouseEnter={e => (e.currentTarget.style.color = '#6b6560')} onMouseEnter={e => (e.currentTarget.style.color = '#6b6560')}
onMouseLeave={e => (e.currentTarget.style.color = '#b5b0a6')} onMouseLeave={e => (e.currentTarget.style.color = '#b5b0a6')}
@@ -125,59 +153,122 @@ export function ProjectCreationModal({ open, onOpenChange, workspace }: ProjectC
</button> </button>
</div> </div>
{/* Name input */} {/* Step 1 — Name */}
<div style={{ marginBottom: 20 }}> {step === 1 && (
<label style={{ display: 'block', fontSize: '0.72rem', fontWeight: 600, color: '#6b6560', marginBottom: 7, letterSpacing: '0.02em' }}> <div>
Project name <label style={{ display: 'block', fontSize: '0.72rem', fontWeight: 600, color: '#6b6560', marginBottom: 7, letterSpacing: '0.02em' }}>
</label> Project name
<input </label>
ref={inputRef} <input
type="text" ref={inputRef}
value={productName} type="text"
onChange={e => setProductName(e.target.value)} value={productName}
onKeyDown={e => { if (e.key === 'Enter' && productName.trim() && !loading) handleCreate(); }} onChange={e => setProductName(e.target.value)}
placeholder="e.g. Foxglove, Meridian, OpsAI…" onKeyDown={e => { if (e.key === 'Enter' && productName.trim()) setStep(2); }}
style={{ placeholder="e.g. Foxglove, Meridian, OpsAI…"
width: '100%', padding: '11px 14px', style={{
borderRadius: 8, border: '1px solid #e0dcd4', width: '100%', padding: '11px 14px', marginBottom: 16,
background: '#faf8f5', fontSize: '0.9rem', borderRadius: 8, border: '1px solid #e0dcd4',
fontFamily: 'Outfit, sans-serif', color: '#1a1a1a', background: '#faf8f5', fontSize: '0.9rem',
outline: 'none', transition: 'border-color 0.12s', fontFamily: 'Outfit, sans-serif', color: '#1a1a1a',
boxSizing: 'border-box', outline: 'none', transition: 'border-color 0.12s',
}} boxSizing: 'border-box',
onFocus={e => (e.currentTarget.style.borderColor = '#1a1a1a')} }}
onBlur={e => (e.currentTarget.style.borderColor = '#e0dcd4')} onFocus={e => (e.currentTarget.style.borderColor = '#1a1a1a')}
/> onBlur={e => (e.currentTarget.style.borderColor = '#e0dcd4')}
</div> />
<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>
)}
{/* Create button */} {/* Step 2 — Project type */}
<button {step === 2 && (
onClick={handleCreate} <div>
disabled={!productName.trim() || loading} <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 8, marginBottom: 20 }}>
style={{ {PROJECT_TYPES.map(type => {
width: '100%', padding: '12px', const isSelected = projectType === type.id;
borderRadius: 8, border: 'none', return (
background: productName.trim() && !loading ? '#1a1a1a' : '#e0dcd4', <button
color: productName.trim() && !loading ? '#fff' : '#b5b0a6', key={type.id}
fontSize: '0.88rem', fontWeight: 600, onClick={() => setProjectType(type.id)}
fontFamily: 'Outfit, sans-serif', style={{
cursor: productName.trim() && !loading ? 'pointer' : 'not-allowed', display: 'flex', alignItems: 'flex-start', gap: 12,
transition: 'opacity 0.15s, background 0.15s', padding: '14px 16px', borderRadius: 10, textAlign: 'left',
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 8, border: `1px solid ${isSelected ? '#1a1a1a' : '#e8e4dc'}`,
}} background: isSelected ? '#1a1a1a08' : '#fff',
onMouseEnter={e => { if (productName.trim() && !loading) (e.currentTarget.style.opacity = '0.85'); }} boxShadow: isSelected ? '0 0 0 1px #1a1a1a' : '0 1px 2px #1a1a1a04',
onMouseLeave={e => { (e.currentTarget.style.opacity = '1'); }} cursor: 'pointer', transition: 'all 0.12s',
> fontFamily: 'Outfit, sans-serif',
{loading ? ( }}
<> onMouseEnter={e => { if (!isSelected) (e.currentTarget.style.borderColor = '#d0ccc4'); }}
<span style={{ width: 14, height: 14, borderRadius: '50%', border: '2px solid #fff4', borderTopColor: '#fff', animation: 'spin 0.7s linear infinite', display: 'inline-block' }} /> onMouseLeave={e => { if (!isSelected) (e.currentTarget.style.borderColor = '#e8e4dc'); }}
<style>{`@keyframes spin { to { transform: rotate(360deg); } }`}</style> >
Creating <div style={{
</> width: 30, height: 30, borderRadius: 7, flexShrink: 0,
) : ( background: isSelected ? '#1a1a1a' : '#f6f4f0',
'Next →' display: 'flex', alignItems: 'center', justifyContent: 'center',
)} fontSize: '0.95rem', color: isSelected ? '#fff' : '#8a8478',
</button> }}>
{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>
</div> </div>
</> </>