Files
vibn-frontend/components/project-creation-modal.tsx

279 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use client';
import { useState, useEffect, useRef } from 'react';
import { createPortal } from 'react-dom';
import { useRouter } from 'next/navigation';
import { toast } from 'sonner';
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
);
}