Files
wursor/packages/app/src/components/shell/ProjectSettingsForm.tsx
T
2026-04-27 04:52:15 +01:00

243 lines
8.5 KiB
TypeScript

'use client';
import { useState, useCallback, useEffect, useRef } from 'react';
import { useRouter } from 'next/navigation';
interface ProjectSettingsFormProps {
workspaceId: string;
projectId: string;
initialName: string;
initialDescription: string;
initialAppUrl: string;
initialFramework: string;
memberRole: string;
}
const SECTION: React.CSSProperties = {
background: '#FFFFFF',
border: '1px solid rgba(0,0,0,0.07)',
borderRadius: 14,
padding: '24px 28px',
marginBottom: 20,
};
const LABEL: React.CSSProperties = {
display: 'block',
fontSize: '0.8125rem',
fontWeight: 600,
color: '#0A0A0A',
marginBottom: 6,
};
const INPUT: React.CSSProperties = {
width: '100%',
fontSize: '0.875rem',
padding: '9px 12px',
border: '1px solid rgba(0,0,0,0.12)',
borderRadius: 9,
outline: 'none',
fontFamily: "'Inter', -apple-system, sans-serif",
color: '#0A0A0A',
background: '#FAFAFA',
boxSizing: 'border-box',
};
const BTN_PRIMARY: React.CSSProperties = {
display: 'inline-flex', alignItems: 'center', gap: 6,
background: '#0A0A0A', color: '#FFFFFF',
fontSize: '0.875rem', fontWeight: 600,
padding: '9px 20px', borderRadius: 9,
border: 'none', cursor: 'pointer', letterSpacing: '-0.01em',
};
const FRAMEWORKS = ['', 'Next.js', 'Vite + React', 'Remix', 'SvelteKit', 'Nuxt', 'Other'] as const;
export function ProjectSettingsForm({
workspaceId,
projectId,
initialName,
initialDescription,
initialAppUrl,
initialFramework,
memberRole,
}: ProjectSettingsFormProps) {
const router = useRouter();
const isOwnerOrDev = memberRole === 'OWNER' || memberRole === 'DEVELOPER' || memberRole === 'DESIGNER';
/* ── Form state ── */
const [name, setName] = useState(initialName);
const [description, setDescription] = useState(initialDescription);
const [appUrl, setAppUrl] = useState(initialAppUrl);
const [framework, setFramework] = useState(initialFramework);
const [saveStatus, setSaveStatus] = useState<'idle' | 'saving' | 'saved' | 'error'>('idle');
const saveTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
useEffect(() => { return () => { clearTimeout(saveTimer.current); }; }, []);
const isDirty =
name.trim() !== initialName ||
description !== initialDescription ||
appUrl !== initialAppUrl ||
framework !== initialFramework;
const save = useCallback(async () => {
if (!isDirty || !name.trim()) return;
clearTimeout(saveTimer.current);
setSaveStatus('saving');
try {
const res = await fetch(`/api/workspace/${workspaceId}/projects/${projectId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: name.trim(),
description: description || null,
app_url: appUrl || null,
framework: framework || null,
}),
});
if (!res.ok) throw new Error('Save failed');
setSaveStatus('saved');
router.refresh();
saveTimer.current = setTimeout(() => setSaveStatus('idle'), 2500);
} catch {
setSaveStatus('error');
saveTimer.current = setTimeout(() => setSaveStatus('idle'), 3000);
}
}, [isDirty, name, description, appUrl, framework, workspaceId, projectId, router]);
/* ── Delete ── */
const [deleteConfirm, setDeleteConfirm] = useState('');
const [deleting, setDeleting] = useState(false);
// Guard against deleting an unsaved name: always compare against the persisted name.
const deleteProject = useCallback(async () => {
if (deleteConfirm.trim() !== initialName.trim()) return;
setDeleting(true);
try {
const res = await fetch(`/api/workspace/${workspaceId}/projects/${projectId}`, { method: 'DELETE' });
if (!res.ok) throw new Error('Delete failed');
router.push(`/workspace/${workspaceId}`);
} catch {
setDeleting(false);
window.alert('Delete failed — please try again.');
}
}, [deleteConfirm, initialName, workspaceId, projectId, router]);
return (
<>
{/* General */}
<div style={SECTION}>
<h2 style={{ fontSize: '1rem', fontWeight: 600, color: '#0A0A0A', margin: '0 0 20px' }}>General</h2>
<label style={LABEL} htmlFor="proj-name">Project name</label>
<input
id="proj-name"
style={{ ...INPUT, marginBottom: 16 }}
value={name}
onChange={e => setName(e.target.value)}
disabled={!isOwnerOrDev}
/>
<label style={LABEL} htmlFor="proj-desc">Description</label>
<textarea
id="proj-desc"
rows={3}
style={{ ...INPUT, marginBottom: 16, resize: 'vertical' } as React.CSSProperties}
value={description}
onChange={e => setDescription(e.target.value)}
placeholder="What does this project do?"
disabled={!isOwnerOrDev}
/>
<div style={{ display: 'flex', gap: 16, marginBottom: 16 }}>
<div style={{ flex: 1 }}>
<label style={LABEL} htmlFor="proj-url">App URL</label>
<input
id="proj-url"
style={INPUT}
value={appUrl}
onChange={e => setAppUrl(e.target.value)}
placeholder="https://your-app.vercel.app"
disabled={!isOwnerOrDev}
/>
</div>
<div style={{ flex: 1 }}>
<label style={LABEL} htmlFor="proj-framework">Framework</label>
<select
id="proj-framework"
style={{ ...INPUT }}
value={framework}
onChange={e => setFramework(e.target.value)}
disabled={!isOwnerOrDev}
>
{FRAMEWORKS.map(f => (
<option key={f} value={f}>{f || '— select —'}</option>
))}
</select>
</div>
</div>
{isOwnerOrDev && (
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<button
onClick={() => void save()}
disabled={!isDirty || saveStatus === 'saving' || !name.trim()}
style={{
...BTN_PRIMARY,
opacity: (!isDirty || !name.trim() || saveStatus === 'saving') ? 0.4 : 1,
}}
>
{saveStatus === 'saving' ? 'Saving…' : saveStatus === 'saved' ? '✓ Saved' : 'Save changes'}
</button>
{saveStatus === 'error' && (
<span style={{ fontSize: '0.8125rem', color: '#EF4444' }}>Save failed try again</span>
)}
</div>
)}
{!isOwnerOrDev && (
<span style={{ fontSize: '0.75rem', color: '#71717A' }}>Only Designers, Developers, and Owners can edit project settings.</span>
)}
</div>
{/* Danger zone — owners only */}
{memberRole === 'OWNER' && (
<div style={{ ...SECTION, borderColor: 'rgba(239,68,68,0.2)', background: 'rgba(239,68,68,0.02)' }}>
<h2 style={{ fontSize: '1rem', fontWeight: 600, color: '#EF4444', margin: '0 0 8px' }}>
Danger zone
</h2>
<p style={{ fontSize: '0.8125rem', color: '#71717A', margin: '0 0 16px', lineHeight: 1.6 }}>
Deleting this project is permanent. All artboards, diffs, and origins will be removed. To confirm,
type the project name below.
</p>
<label style={{ ...LABEL, color: '#EF4444' }} htmlFor="proj-delete-confirm">
Type <strong>{initialName}</strong> to confirm
</label>
<div style={{ display: 'flex', gap: 10 }}>
<input
id="proj-delete-confirm"
style={INPUT}
value={deleteConfirm}
onChange={e => setDeleteConfirm(e.target.value)}
placeholder={initialName}
/>
<button
onClick={() => void deleteProject()}
disabled={deleteConfirm.trim() !== initialName.trim() || deleting}
style={{
fontSize: '0.875rem', fontWeight: 600, padding: '9px 20px', borderRadius: 9,
border: '1px solid rgba(239,68,68,0.35)', background: 'transparent',
color: '#EF4444', cursor: deleteConfirm.trim() === initialName.trim() ? 'pointer' : 'not-allowed',
flexShrink: 0,
opacity: deleteConfirm.trim() !== name.trim() || deleting ? 0.4 : 1,
}}
>
{deleting ? 'Deleting…' : 'Delete project'}
</button>
</div>
</div>
)}
</>
);
}