page.tsx220 lines · main
1import { revalidatePath } from 'next/cache';
2import Link from 'next/link';
3
4import { ApiError, apiFetch, apiJson } from '../../../../../../lib/api';
5import { AssistantPanel } from './assistant-panel';
6import { CopySchemaButton } from './copy-schema-button';
7import { NewTableForm } from './new-table-form';
8
9interface TableSummary {
10 name: string;
11 approxRowCount: number;
12 bytes: number;
13}
14
15interface RelationshipEdge {
16 fromTable: string;
17 fromColumn: string;
18 toTable: string;
19 toColumn: string;
20}
21
22export const metadata = { title: 'studio' };
23export const dynamic = 'force-dynamic';
24
25export default async function StudioPage({
26 params,
27}: {
28 params: Promise<{ id: string }>;
29}) {
30 const { id } = await params;
31 const [tablesResult, relResult] = await Promise.all([
32 apiJson<{ tables: TableSummary[] }>(`/v1/projects/${id}/studio/tables`).catch((err) => {
33 if (err instanceof ApiError && (err.status === 401 || err.status === 403)) {
34 return { tables: [] as TableSummary[], forbidden: true as const };
35 }
36 return { tables: [] as TableSummary[], forbidden: false as const };
37 }),
38 apiJson<{ edges: RelationshipEdge[] }>(`/v1/projects/${id}/studio/relationships`).catch(
39 () => ({ edges: [] as RelationshipEdge[] }),
40 ),
41 ]);
42 const tables = tablesResult.tables;
43 const forbidden = 'forbidden' in tablesResult && tablesResult.forbidden;
44 const edges = relResult.edges;
45
46 async function applyTemplate(formData: FormData) {
47 'use server';
48 const { id } = await params;
49 const templateId = String(formData.get('templateId') ?? '');
50 const res = await apiFetch(`/v1/projects/${id}/studio/apply-template`, {
51 method: 'POST',
52 headers: { 'content-type': 'application/json' },
53 body: JSON.stringify({ templateId }),
54 });
55 if (!res.ok) {
56 const body = await res.text().catch(() => '');
57 throw new Error(body || `template failed: ${res.status}`);
58 }
59 revalidatePath(`/dashboard/projects/${id}/studio`);
60 }
61
62 return (
63 <section className="flex flex-col gap-6">
64 <header className="flex items-start justify-between gap-4">
65 <div>
66 <h2 className="font-mono text-lg tracking-tight">studio</h2>
67 <p className="mt-1 font-mono text-xs text-[var(--color-text-muted)]">
68 build your database from here — create tables, add columns, edit rows. or
69 keep using the CLI (<code>briven deploy</code> with a <code>schema.ts</code>);
70 both paths write to the same schema.
71 </p>
72 </div>
73 {tables.length > 0 ? (
74 <div className="flex items-start gap-2">
75 <Link
76 href={`/dashboard/projects/${id}/studio/schema`}
77 className="rounded-md border border-[var(--color-border)] px-3 py-1.5 font-mono text-xs text-[var(--color-text-muted)] hover:text-[var(--color-text)]"
78 >
79 schema overview →
80 </Link>
81 <Link
82 href={`/dashboard/projects/${id}/studio/sql`}
83 className="rounded-md border border-[var(--color-border)] px-3 py-1.5 font-mono text-xs text-[var(--color-text-muted)] hover:text-[var(--color-text)]"
84 >
85 sql editor →
86 </Link>
87 <CopySchemaButton projectId={id} />
88 <NewTableForm projectId={id} existingTables={tables.map((t) => t.name)} />
89 </div>
90 ) : null}
91 </header>
92
93 {!forbidden ? <AssistantPanel projectId={id} /> : null}
94
95 {forbidden ? (
96 <div className="rounded-md border border-dashed border-[var(--color-border)] p-6">
97 <p className="font-mono text-sm text-[var(--color-text)]">
98 admin role required.
99 </p>
100 <p className="mt-1 font-mono text-xs text-[var(--color-text-muted)]">
101 studio surfaces full row contents, so only project admins can open it.
102 ask a project admin to bump your role, or open another tab.
103 </p>
104 </div>
105 ) : tables.length === 0 ? (
106 <div className="flex flex-col gap-4 rounded-md border border-dashed border-[var(--color-border)] p-6">
107 <div>
108 <p className="font-mono text-sm text-[var(--color-text)]">
109 your database is empty.
110 </p>
111 <p className="mt-1 font-mono text-xs text-[var(--color-text-muted)]">
112 name your first table below to start. you can also deploy a{' '}
113 <code>briven/schema.ts</code> from the CLI — both paths land in the same
114 postgres schema for this project.
115 </p>
116 </div>
117 <div className="flex flex-col gap-2">
118 <p className="font-mono text-xs uppercase tracking-wider text-[var(--color-text-subtle)]">
119 start from a template
120 </p>
121 <div className="flex flex-wrap gap-2">
122 {[
123 { id: 'contacts-crm', label: '👥 contacts / crm' },
124 { id: 'inventory', label: '📦 inventory' },
125 { id: 'bookings', label: '📅 bookings' },
126 { id: 'tasks', label: '✅ tasks' },
127 ].map((tpl) => (
128 <form key={tpl.id} action={applyTemplate}>
129 <input type="hidden" name="templateId" value={tpl.id} />
130 <button
131 type="submit"
132 className="rounded-md border border-[var(--color-border-subtle)] bg-[var(--color-surface)] px-3 py-1.5 font-mono text-xs text-[var(--color-text-muted)] transition hover:border-[var(--color-primary)] hover:text-[var(--color-text)]"
133 >
134 {tpl.label}
135 </button>
136 </form>
137 ))}
138 </div>
139 <p className="font-mono text-[11px] text-[var(--color-text-subtle)]">
140 instantly fills your database with ready-made tables + example rows. you can edit or
141 delete anything afterwards.
142 </p>
143 </div>
144 <NewTableForm projectId={id} existingTables={tables.map((t) => t.name)} />
145 </div>
146 ) : (
147 <>
148 <div className="overflow-x-auto rounded-md border border-[var(--color-border-subtle)]">
149 <table className="w-full min-w-[480px] font-mono text-sm">
150 <thead className="bg-[var(--color-surface)]">
151 <tr className="text-left text-[var(--color-text-muted)]">
152 <th className="px-4 py-2 font-normal">table</th>
153 <th className="px-4 py-2 font-normal">approx rows</th>
154 <th className="px-4 py-2 font-normal">size</th>
155 </tr>
156 </thead>
157 <tbody>
158 {tables.map((t) => (
159 <tr
160 key={t.name}
161 className="border-t border-[var(--color-border-subtle)] hover:bg-[var(--color-surface)]"
162 >
163 <td className="px-4 py-2">
164 <Link
165 href={`/dashboard/projects/${id}/studio/${encodeURIComponent(t.name)}`}
166 className="text-[var(--color-text-link)] hover:underline"
167 >
168 {t.name}
169 </Link>
170 </td>
171 <td className="px-4 py-2 text-[var(--color-text-muted)]">
172 {t.approxRowCount.toLocaleString()}
173 </td>
174 <td className="px-4 py-2 text-[var(--color-text-muted)]">
175 {formatBytes(t.bytes)}
176 </td>
177 </tr>
178 ))}
179 </tbody>
180 </table>
181 </div>
182
183 {edges.length > 0 ? (
184 <section className="flex flex-col gap-2">
185 <h3 className="font-mono text-sm text-[var(--color-text-muted)]">relationships</h3>
186 <ul className="flex flex-col gap-1 rounded-md border border-[var(--color-border-subtle)] bg-[var(--color-surface)] p-3 font-mono text-xs">
187 {edges.map((e, i) => (
188 <li key={i} className="text-[var(--color-text-muted)]">
189 <Link
190 href={`/dashboard/projects/${id}/studio/${encodeURIComponent(e.fromTable)}`}
191 className="text-[var(--color-text)] hover:underline"
192 >
193 {e.fromTable}
194 </Link>
195 .{e.fromColumn}
196 <span className="mx-1 text-[var(--color-primary)]">→</span>
197 <Link
198 href={`/dashboard/projects/${id}/studio/${encodeURIComponent(e.toTable)}`}
199 className="text-[var(--color-text)] hover:underline"
200 >
201 {e.toTable}
202 </Link>
203 .{e.toColumn}
204 </li>
205 ))}
206 </ul>
207 </section>
208 ) : null}
209 </>
210 )}
211 </section>
212 );
213}
214
215function formatBytes(bytes: number): string {
216 if (bytes < 1024) return `${bytes} B`;
217 if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
218 if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
219 return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
220}