page.tsx170 lines · main
1import Link from 'next/link';
2import { cookies } from 'next/headers';
3import { redirect } from 'next/navigation';
4
5import { apiFetch, apiJson } from '../../../../../../lib/api';
6
7export const metadata = {
8 title: 'new blank project',
9};
10
11interface Org {
12 id: string;
13 slug: string;
14 name: string;
15 personal: boolean;
16}
17
18interface StorageBootstrap {
19 endpoint: string;
20 bucket: string;
21 accessKey: string;
22 secretKey: string;
23 record?: { name: string };
24}
25
26async function createProject(formData: FormData) {
27 'use server';
28 const name = String(formData.get('name') ?? '').trim();
29 const slug = String(formData.get('slug') ?? '').trim() || undefined;
30 const region = String(formData.get('region') ?? '').trim() || undefined;
31 const orgId = String(formData.get('orgId') ?? '').trim() || undefined;
32
33 const res = await apiFetch('/v1/projects', {
34 method: 'POST',
35 headers: { 'content-type': 'application/json' },
36 body: JSON.stringify({ name, slug, region, orgId }),
37 });
38
39 if (!res.ok) {
40 const body = await res.text().catch(() => '');
41 throw new Error(`project create failed (${res.status}): ${body}`);
42 }
43
44 const data = (await res.json()) as {
45 project: { id: string };
46 storage?: StorageBootstrap | null;
47 };
48
49 // One-time flash: secret is only available at mint. Show on S3 page, then drop.
50 if (data.storage?.secretKey) {
51 const jar = await cookies();
52 jar.set(
53 `briven_storage_once_${data.project.id}`,
54 JSON.stringify({
55 endpoint: data.storage.endpoint,
56 bucket: data.storage.bucket,
57 accessKey: data.storage.accessKey,
58 secretKey: data.storage.secretKey,
59 name: data.storage.record?.name ?? 'default',
60 }),
61 {
62 httpOnly: true,
63 secure: true,
64 sameSite: 'lax',
65 path: '/',
66 maxAge: 600, // 10 minutes
67 },
68 );
69 redirect(`/dashboard/projects/${data.project.id}/storage?new_key=1`);
70 }
71
72 redirect(`/dashboard/projects/${data.project.id}`);
73}
74
75export default async function NewProjectPage() {
76 // Load every org the user belongs to so they can pick where the
77 // project lives. Personal first; teams sorted after.
78 const { orgs } = await apiJson<{ orgs: Org[] }>('/v1/me/orgs');
79 const sorted = [
80 ...orgs.filter((o) => o.personal),
81 ...orgs.filter((o) => !o.personal),
82 ];
83
84 return (
85 <section className="max-w-lg">
86 <p className="mb-4 font-mono text-xs text-[var(--color-text-muted)]">
87 <Link href="/dashboard/projects/new" className="hover:text-[var(--color-text)]">
88 ← back
89 </Link>
90 </p>
91 <header className="mb-8">
92 <h1 className="font-mono text-xl tracking-tight">new project · new database</h1>
93 <p className="mt-1 font-mono text-sm text-[var(--color-text-muted)]">
94 one project = one database + function runtime + private S3 bucket (with a default
95 storage key). name it here, then use studio or <code>briven deploy</code> for tables.
96 </p>
97 </header>
98
99 <form action={createProject} className="flex flex-col gap-5">
100 <label className="flex flex-col gap-2">
101 <span className="font-mono text-xs text-[var(--color-text-muted)]">org</span>
102 <select
103 name="orgId"
104 defaultValue={sorted[0]?.id}
105 className="rounded-md border border-[var(--color-border)] bg-[var(--color-surface)] px-3 py-2 font-mono text-sm outline-none focus:border-[var(--color-primary)]"
106 >
107 {sorted.map((o) => (
108 <option key={o.id} value={o.id}>
109 {o.name} {o.personal ? '· personal' : '· team'}
110 </option>
111 ))}
112 </select>
113 <span className="font-mono text-[11px] text-[var(--color-text-subtle)]">
114 projects belong to an org. members of the org can collaborate; billing rolls up to
115 the org. create a team at <em>/dashboard/teams</em> if you need a separate
116 workspace from your personal one.
117 </span>
118 </label>
119
120 <label className="flex flex-col gap-2">
121 <span className="font-mono text-xs text-[var(--color-text-muted)]">name</span>
122 <input
123 name="name"
124 type="text"
125 required
126 maxLength={80}
127 placeholder="my app"
128 className="rounded-md border border-[var(--color-border)] bg-[var(--color-surface)] px-3 py-2 font-mono text-sm outline-none focus:border-[var(--color-primary)]"
129 />
130 </label>
131
132 <label className="flex flex-col gap-2">
133 <span className="font-mono text-xs text-[var(--color-text-muted)]">
134 slug{' '}
135 <span className="text-[var(--color-text-subtle)]">(optional — generated if blank)</span>
136 </span>
137 <input
138 name="slug"
139 type="text"
140 pattern="[a-z0-9](?:[a-z0-9\-]{0,30}[a-z0-9])?"
141 maxLength={32}
142 placeholder="my-app"
143 className="rounded-md border border-[var(--color-border)] bg-[var(--color-surface)] px-3 py-2 font-mono text-sm outline-none focus:border-[var(--color-primary)]"
144 />
145 </label>
146
147 <label className="flex flex-col gap-2">
148 <span className="font-mono text-xs text-[var(--color-text-muted)]">region</span>
149 <select
150 name="region"
151 defaultValue="eu-west-1"
152 className="rounded-md border border-[var(--color-border)] bg-[var(--color-surface)] px-3 py-2 font-mono text-sm outline-none focus:border-[var(--color-primary)]"
153 >
154 <option value="eu-west-1">eu-west-1 · frankfurt</option>
155 <option value="us-east-1">us-east-1 · virginia</option>
156 </select>
157 </label>
158
159 <div className="flex gap-3">
160 <button
161 type="submit"
162 className="rounded-md bg-[var(--color-primary)] px-4 py-2 font-mono text-sm font-medium text-[var(--color-text-inverse)] transition hover:bg-[var(--color-primary-hover)]"
163 >
164 create
165 </button>
166 </div>
167 </form>
168 </section>
169 );
170}