convex-translator.tsx139 lines · main
1'use client';
2
3import { useState } from 'react';
4
5interface Props {
6 apiOrigin: string;
7}
8
9interface TranslationResult {
10 brivenSchema: string;
11 warnings: string[];
12 tables: { name: string; columns: number }[];
13}
14
15/**
16 * Offline convex schema → briven schema DSL translator panel. The
17 * operator pastes the customer's convex/schema.ts source (delivered
18 * over email or screen-share during the migration call), the panel
19 * sends it to /v1/admin/translate-convex-schema and renders the
20 * draft briven/schema.ts plus per-column warnings the operator
21 * needs to resolve before deploy.
22 *
23 * Read-only operation — no step-up required, no audit log entry
24 * needed. Pure transform of input the operator already has.
25 */
26export function ConvexTranslator({ apiOrigin }: Props) {
27 const [source, setSource] = useState('');
28 const [busy, setBusy] = useState(false);
29 const [error, setError] = useState<string | null>(null);
30 const [result, setResult] = useState<TranslationResult | null>(null);
31 const [copied, setCopied] = useState(false);
32
33 async function run() {
34 if (!source.trim()) return;
35 setBusy(true);
36 setError(null);
37 setResult(null);
38 try {
39 const res = await fetch(`${apiOrigin}/v1/admin/translate-convex-schema`, {
40 method: 'POST',
41 credentials: 'include',
42 headers: { 'content-type': 'application/json' },
43 body: JSON.stringify({ source }),
44 });
45 if (!res.ok) {
46 const body = (await res.json().catch(() => null)) as { message?: string } | null;
47 throw new Error(body?.message ?? `translate failed: ${res.status}`);
48 }
49 const data = (await res.json()) as TranslationResult;
50 setResult(data);
51 } catch (err) {
52 setError(err instanceof Error ? err.message : 'translate failed');
53 } finally {
54 setBusy(false);
55 }
56 }
57
58 async function copy() {
59 if (!result) return;
60 try {
61 await navigator.clipboard.writeText(result.brivenSchema);
62 setCopied(true);
63 setTimeout(() => setCopied(false), 1500);
64 } catch {
65 // clipboard unavailable — operator can manual-select
66 }
67 }
68
69 return (
70 <details className="rounded-md border border-[var(--color-border-subtle)] bg-[var(--color-surface)] p-3 font-mono text-xs">
71 <summary className="cursor-pointer text-[var(--color-text)]">
72 translate convex schema → briven DSL
73 </summary>
74 <div className="mt-3 flex flex-col gap-3">
75 <p className="text-[var(--color-text-subtle)]">
76 paste the customer&apos;s convex/schema.ts source here. handles defineTable
77 with v.string / v.number / v.int64 / v.boolean / v.id / v.optional /
78 v.union / v.array / v.object. unrecognised types emit text() + a TODO.
79 </p>
80 <textarea
81 value={source}
82 onChange={(e) => setSource(e.target.value)}
83 rows={8}
84 maxLength={100_000}
85 placeholder="import { defineTable, defineSchema, v } from 'convex/schema';\nexport default defineSchema({ notes: defineTable({ ... }) });"
86 className="rounded-md border border-[var(--color-border)] bg-[var(--color-bg)] px-3 py-2 text-[var(--color-text)] focus:border-[var(--color-primary)] focus:outline-none"
87 />
88 <div className="flex flex-wrap items-center gap-2">
89 <button
90 type="button"
91 onClick={() => void run()}
92 disabled={busy || !source.trim()}
93 className="rounded-md bg-[var(--color-primary)] px-3 py-1.5 text-[var(--color-text-inverse)] hover:bg-[var(--color-primary-hover)] disabled:opacity-50"
94 >
95 {busy ? 'translating…' : 'translate'}
96 </button>
97 {result ? (
98 <button
99 type="button"
100 onClick={() => void copy()}
101 className="rounded-md border border-[var(--color-border-subtle)] px-3 py-1.5 text-[var(--color-text-muted)] hover:border-[var(--color-border-strong)] hover:text-[var(--color-text)]"
102 >
103 {copied ? 'copied' : 'copy briven schema'}
104 </button>
105 ) : null}
106 </div>
107 {error ? (
108 <p className="text-[var(--color-error)]">{error}</p>
109 ) : null}
110 {result ? (
111 <div className="flex flex-col gap-3">
112 <p className="text-[10px] text-[var(--color-text-subtle)]">
113 {result.tables.length} table{result.tables.length === 1 ? '' : 's'} parsed
114 {result.tables.length > 0
115 ? `: ${result.tables.map((t) => `${t.name}(${t.columns})`).join(', ')}`
116 : ''}
117 </p>
118 {result.warnings.length > 0 ? (
119 <div className="rounded-md border border-[var(--color-warning)] bg-[var(--color-warning)]/5 p-2">
120 <p className="font-medium text-[var(--color-warning)]">
121 {result.warnings.length} warning
122 {result.warnings.length === 1 ? '' : 's'}
123 </p>
124 <ul className="mt-1 flex list-disc flex-col gap-0.5 pl-4 text-[var(--color-text-muted)]">
125 {result.warnings.map((w, i) => (
126 <li key={i}>{w}</li>
127 ))}
128 </ul>
129 </div>
130 ) : null}
131 <pre className="overflow-x-auto rounded-md border border-[var(--color-border-subtle)] bg-[var(--color-bg)] p-3 text-[11px] text-[var(--color-text)]">
132 <code>{result.brivenSchema}</code>
133 </pre>
134 </div>
135 ) : null}
136 </div>
137 </details>
138 );
139}