page.tsx220 lines · main
| 1 | import { revalidatePath } from 'next/cache'; |
| 2 | import Link from 'next/link'; |
| 3 | |
| 4 | import { ApiError, apiFetch, apiJson } from '../../../../../../lib/api'; |
| 5 | import { AssistantPanel } from './assistant-panel'; |
| 6 | import { CopySchemaButton } from './copy-schema-button'; |
| 7 | import { NewTableForm } from './new-table-form'; |
| 8 | |
| 9 | interface TableSummary { |
| 10 | name: string; |
| 11 | approxRowCount: number; |
| 12 | bytes: number; |
| 13 | } |
| 14 | |
| 15 | interface RelationshipEdge { |
| 16 | fromTable: string; |
| 17 | fromColumn: string; |
| 18 | toTable: string; |
| 19 | toColumn: string; |
| 20 | } |
| 21 | |
| 22 | export const metadata = { title: 'studio' }; |
| 23 | export const dynamic = 'force-dynamic'; |
| 24 | |
| 25 | export 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 | |
| 215 | function 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 | } |