@cmssy/cli 0.10.1 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (81) hide show
  1. package/dist/cli.js +84 -32
  2. package/dist/cli.js.map +1 -1
  3. package/dist/commands/add-source.d.ts +6 -0
  4. package/dist/commands/add-source.d.ts.map +1 -1
  5. package/dist/commands/add-source.js +37 -2
  6. package/dist/commands/add-source.js.map +1 -1
  7. package/dist/commands/build.d.ts.map +1 -1
  8. package/dist/commands/build.js +11 -0
  9. package/dist/commands/build.js.map +1 -1
  10. package/dist/commands/configure.d.ts.map +1 -1
  11. package/dist/commands/configure.js +0 -3
  12. package/dist/commands/configure.js.map +1 -1
  13. package/dist/commands/dev.d.ts.map +1 -1
  14. package/dist/commands/dev.js +20 -0
  15. package/dist/commands/dev.js.map +1 -1
  16. package/dist/commands/doctor.d.ts +2 -0
  17. package/dist/commands/doctor.d.ts.map +1 -0
  18. package/dist/commands/doctor.js +223 -0
  19. package/dist/commands/doctor.js.map +1 -0
  20. package/dist/commands/init.d.ts.map +1 -1
  21. package/dist/commands/init.js +5 -12
  22. package/dist/commands/init.js.map +1 -1
  23. package/dist/commands/link.d.ts +8 -0
  24. package/dist/commands/link.d.ts.map +1 -0
  25. package/dist/commands/link.js +121 -0
  26. package/dist/commands/link.js.map +1 -0
  27. package/dist/commands/package.d.ts +7 -0
  28. package/dist/commands/package.d.ts.map +1 -1
  29. package/dist/commands/package.js +2 -2
  30. package/dist/commands/package.js.map +1 -1
  31. package/dist/commands/publish.d.ts +3 -0
  32. package/dist/commands/publish.d.ts.map +1 -1
  33. package/dist/commands/publish.js +192 -4
  34. package/dist/commands/publish.js.map +1 -1
  35. package/dist/commands/sync.js +1 -1
  36. package/dist/commands/sync.js.map +1 -1
  37. package/dist/commands/test.d.ts +8 -0
  38. package/dist/commands/test.d.ts.map +1 -0
  39. package/dist/commands/test.js +60 -0
  40. package/dist/commands/test.js.map +1 -0
  41. package/dist/commands/upload.d.ts +4 -0
  42. package/dist/commands/upload.d.ts.map +1 -1
  43. package/dist/commands/upload.js +6 -4
  44. package/dist/commands/upload.js.map +1 -1
  45. package/dist/commands/workspaces.d.ts.map +1 -1
  46. package/dist/commands/workspaces.js +1 -1
  47. package/dist/commands/workspaces.js.map +1 -1
  48. package/dist/utils/block-config.d.ts +5 -1
  49. package/dist/utils/block-config.d.ts.map +1 -1
  50. package/dist/utils/block-config.js.map +1 -1
  51. package/dist/utils/config.js +17 -12
  52. package/dist/utils/config.js.map +1 -1
  53. package/dist/utils/dependency-check.d.ts +21 -0
  54. package/dist/utils/dependency-check.d.ts.map +1 -0
  55. package/dist/utils/dependency-check.js +56 -0
  56. package/dist/utils/dependency-check.js.map +1 -0
  57. package/dist/utils/dev-generator/index.d.ts +1 -2
  58. package/dist/utils/dev-generator/index.d.ts.map +1 -1
  59. package/dist/utils/dev-generator/index.js +53 -21
  60. package/dist/utils/dev-generator/index.js.map +1 -1
  61. package/dist/utils/dev-generator/preview-pages.js +8 -2
  62. package/dist/utils/dev-generator/preview-pages.js.map +1 -1
  63. package/dist/utils/graphql.d.ts +1 -1
  64. package/dist/utils/graphql.d.ts.map +1 -1
  65. package/dist/utils/graphql.js +7 -1
  66. package/dist/utils/graphql.js.map +1 -1
  67. package/dist/utils/schema-diff.d.ts +22 -0
  68. package/dist/utils/schema-diff.d.ts.map +1 -0
  69. package/dist/utils/schema-diff.js +107 -0
  70. package/dist/utils/schema-diff.js.map +1 -0
  71. package/package.json +6 -2
  72. package/src/dev-app/app/api/blocks/[name]/config/route.ts +140 -0
  73. package/src/dev-app/app/api/blocks/route.ts +61 -0
  74. package/src/dev-app/app/api/config/route.ts +98 -0
  75. package/src/dev-app/app/api/context/route.ts +126 -0
  76. package/src/dev-app/app/api/preview/[name]/route.ts +137 -0
  77. package/src/dev-app/app/api/workspaces/route.ts +124 -0
  78. package/src/dev-app/app/layout.tsx +18 -0
  79. package/src/dev-app/app/page.tsx +1788 -0
  80. package/test.d.ts +1 -0
  81. package/test.js +2 -0
@@ -0,0 +1,1788 @@
1
+ "use client";
2
+
3
+ import { useState, useEffect, useCallback, useRef } from "react";
4
+
5
+ interface Block {
6
+ type: "block" | "template";
7
+ name: string;
8
+ displayName: string;
9
+ description?: string;
10
+ category?: string;
11
+ tags?: string[];
12
+ version: string;
13
+ hasConfig?: boolean;
14
+ schema?: Record<string, any>;
15
+ pages?: Array<{ name: string; slug: string; blocksCount: number }>;
16
+ layoutPositions?: Array<{ position: string; type: string }>;
17
+ }
18
+
19
+ interface WorkspaceInfo {
20
+ connected: boolean;
21
+ reason?: string;
22
+ error?: string;
23
+ workspace?: {
24
+ id: string;
25
+ slug: string;
26
+ name: string;
27
+ myRole?: { name: string };
28
+ };
29
+ workspaces?: any[];
30
+ publishedBlocks?: Array<{ blockType: string; name: string; version: string }>;
31
+ }
32
+
33
+ type SyncStatus = "local-only" | "published" | "outdated";
34
+
35
+ function getSyncStatus(
36
+ blockName: string,
37
+ localVersion: string,
38
+ wsInfo: WorkspaceInfo | null,
39
+ ): SyncStatus {
40
+ if (!wsInfo?.connected || !wsInfo.publishedBlocks) return "local-only";
41
+ const remote = wsInfo.publishedBlocks.find(
42
+ (b) => b.blockType === blockName || b.name === blockName,
43
+ );
44
+ if (!remote) return "local-only";
45
+ if (remote.version === localVersion) return "published";
46
+ return "outdated";
47
+ }
48
+
49
+ const VIEWPORT_STORAGE_KEY = "cmssy-dev-viewport";
50
+
51
+ const VIEWPORT_PRESETS = [
52
+ { label: "Desktop", width: 1440 },
53
+ { label: "Laptop", width: 1024 },
54
+ { label: "Tablet", width: 768 },
55
+ { label: "Mobile", width: 375 },
56
+ ] as const;
57
+
58
+ export default function DevHome() {
59
+ const [blocks, setBlocks] = useState<Block[]>([]);
60
+ const [selected, setSelected] = useState<Block | null>(null);
61
+ const [previewData, setPreviewData] = useState<Record<string, unknown>>({});
62
+ const [configLoading, setConfigLoading] = useState(false);
63
+ const configDataRef = useRef<Record<string, unknown>>({});
64
+ const [variants, setVariants] = useState<string[]>([]);
65
+ const [currentVariant, setCurrentVariant] = useState<string | null>(null);
66
+ const iframeRef = useRef<HTMLIFrameElement>(null);
67
+ const iframeLoadedRef = useRef(false);
68
+ const [isDirty, setIsDirty] = useState(false);
69
+ const [showBlockList, setShowBlockList] = useState(true);
70
+ const [showEditor, setShowEditor] = useState(true);
71
+ const [editorTab, setEditorTab] = useState<"content" | "context">("content");
72
+ const [mockContext, setMockContext] = useState<Record<string, any>>({});
73
+ const [contextPresets, setContextPresets] = useState<string[]>([]);
74
+ const [viewport, setViewport] = useState<number | null>(null);
75
+
76
+ // Load saved viewport from localStorage after mount (avoid SSR mismatch)
77
+ useEffect(() => {
78
+ const saved = localStorage.getItem(VIEWPORT_STORAGE_KEY);
79
+ if (!saved) return;
80
+ const parsed = parseInt(saved, 10);
81
+ if (Number.isFinite(parsed) && parsed > 0) {
82
+ setViewport(parsed);
83
+ } else {
84
+ localStorage.removeItem(VIEWPORT_STORAGE_KEY);
85
+ }
86
+ }, []);
87
+
88
+ // Persist viewport changes
89
+ useEffect(() => {
90
+ if (viewport === null) {
91
+ localStorage.removeItem(VIEWPORT_STORAGE_KEY);
92
+ } else {
93
+ localStorage.setItem(VIEWPORT_STORAGE_KEY, String(viewport));
94
+ }
95
+ }, [viewport]);
96
+
97
+ const [wsInfo, setWsInfo] = useState<WorkspaceInfo | null>(null);
98
+ const [wsLoading, setWsLoading] = useState(true);
99
+ const [showSettings, setShowSettings] = useState(false);
100
+ const [settingsData, setSettingsData] = useState<{
101
+ apiUrl: string;
102
+ hasToken: boolean;
103
+ maskedToken: string | null;
104
+ workspaceId: string | null;
105
+ newToken: string;
106
+ newApiUrl: string;
107
+ newWorkspaceId: string;
108
+ saving: boolean;
109
+ testing: boolean;
110
+ testResult: string | null;
111
+ project: any;
112
+ }>({
113
+ apiUrl: "",
114
+ hasToken: false,
115
+ maskedToken: null,
116
+ workspaceId: null,
117
+ newToken: "",
118
+ newApiUrl: "",
119
+ newWorkspaceId: "",
120
+ saving: false,
121
+ testing: false,
122
+ testResult: null,
123
+ project: null,
124
+ });
125
+
126
+ // Load config when settings opened
127
+ useEffect(() => {
128
+ if (!showSettings) return;
129
+ fetch("/api/config")
130
+ .then((r) => r.json())
131
+ .then((data) => {
132
+ setSettingsData((prev) => ({
133
+ ...prev,
134
+ apiUrl: data.env.apiUrl,
135
+ hasToken: data.env.hasToken,
136
+ maskedToken: data.env.maskedToken,
137
+ workspaceId: data.env.workspaceId,
138
+ newApiUrl: data.env.apiUrl,
139
+ newWorkspaceId: data.env.workspaceId || "",
140
+ project: data.project,
141
+ }));
142
+ })
143
+ .catch(console.error);
144
+ }, [showSettings]);
145
+
146
+ // Load workspace info
147
+ useEffect(() => {
148
+ fetch("/api/workspaces")
149
+ .then((r) => r.json())
150
+ .then((data) => {
151
+ setWsInfo(data);
152
+ setWsLoading(false);
153
+ })
154
+ .catch(() => setWsLoading(false));
155
+ }, []);
156
+
157
+ // Load mock context + presets
158
+ useEffect(() => {
159
+ fetch("/api/context")
160
+ .then((r) => r.json())
161
+ .then(setMockContext)
162
+ .catch(() => {});
163
+ fetch("/api/context?presets=true")
164
+ .then((r) => r.json())
165
+ .then((data) => setContextPresets(data.presets || []))
166
+ .catch(() => {});
167
+ }, []);
168
+
169
+ // Debounced context persistence
170
+ const contextInitRef = useRef(false);
171
+ useEffect(() => {
172
+ // Skip initial render (context loaded from API)
173
+ if (!contextInitRef.current) {
174
+ contextInitRef.current = true;
175
+ return;
176
+ }
177
+ const t = setTimeout(() => {
178
+ fetch("/api/context", {
179
+ method: "PUT",
180
+ headers: { "Content-Type": "application/json" },
181
+ body: JSON.stringify(mockContext),
182
+ }).catch(() => {});
183
+ }, 500);
184
+ return () => clearTimeout(t);
185
+ }, [mockContext]);
186
+
187
+ // Load blocks list
188
+ useEffect(() => {
189
+ fetch("/api/blocks")
190
+ .then((r) => r.json())
191
+ .then(setBlocks)
192
+ .catch(console.error);
193
+ }, []);
194
+
195
+ // Load config + preview data when block selected or variant changes
196
+ useEffect(() => {
197
+ if (!selected || selected.type === "template") return;
198
+ setConfigLoading(true);
199
+
200
+ const variantParam = currentVariant
201
+ ? `?variant=${encodeURIComponent(currentVariant)}`
202
+ : "";
203
+
204
+ Promise.all([
205
+ fetch(`/api/blocks/${selected.name}/config`).then((r) => r.json()),
206
+ fetch(`/api/preview/${selected.name}${variantParam}`).then((r) =>
207
+ r.json(),
208
+ ),
209
+ ])
210
+ .then(([config, preview]) => {
211
+ setSelected((prev) =>
212
+ prev ? { ...prev, schema: config.schema } : null,
213
+ );
214
+ const data = preview.data || config.previewData || {};
215
+ configDataRef.current = data;
216
+ setPreviewData(data);
217
+ setVariants(preview.variants || []);
218
+ setConfigLoading(false);
219
+ })
220
+ .catch(() => setConfigLoading(false));
221
+ }, [selected?.name, currentVariant]);
222
+
223
+ // Select block — templates redirect to full-page preview
224
+ const handleSelect = useCallback((block: Block) => {
225
+ if (block.type === "template") {
226
+ window.location.href = `/preview/${block.name}`;
227
+ return;
228
+ }
229
+ setSelected(block);
230
+ setPreviewData({});
231
+ configDataRef.current = {};
232
+ setIsDirty(false);
233
+ setCurrentVariant(null);
234
+ setVariants([]);
235
+ iframeLoadedRef.current = false;
236
+ }, []);
237
+
238
+ // Send props + context to iframe
239
+ useEffect(() => {
240
+ if (!iframeRef.current?.contentWindow || !iframeLoadedRef.current) return;
241
+ if (!previewData || Object.keys(previewData).length === 0) return;
242
+ iframeRef.current.contentWindow.postMessage(
243
+ { type: "UPDATE_PROPS", props: previewData, context: mockContext },
244
+ window.location.origin,
245
+ );
246
+ }, [previewData, mockContext]);
247
+
248
+ // Auto-save preview data
249
+ useEffect(() => {
250
+ if (
251
+ !isDirty ||
252
+ !selected ||
253
+ Object.keys(configDataRef.current).length === 0
254
+ )
255
+ return;
256
+ const t = setTimeout(async () => {
257
+ const dataToSave = { ...configDataRef.current, ...previewData };
258
+ const variantParam = currentVariant
259
+ ? `?variant=${encodeURIComponent(currentVariant)}`
260
+ : "";
261
+ await fetch(`/api/preview/${selected.name}${variantParam}`, {
262
+ method: "POST",
263
+ headers: { "Content-Type": "application/json" },
264
+ body: JSON.stringify(dataToSave),
265
+ });
266
+ setIsDirty(false);
267
+ }, 500);
268
+ return () => clearTimeout(t);
269
+ }, [previewData, selected, isDirty]);
270
+
271
+ // URL state
272
+ useEffect(() => {
273
+ if (blocks.length === 0) return;
274
+ const params = new URLSearchParams(window.location.search);
275
+ const name = params.get("block") || params.get("template");
276
+ if (name) {
277
+ const b = blocks.find((x) => x.name === name);
278
+ if (b) handleSelect(b);
279
+ }
280
+ }, [blocks, handleSelect]);
281
+
282
+ const previewUrl = selected ? `/preview/${selected.name}` : null;
283
+
284
+ function renderField(field: any, value: any, onChange: (val: any) => void) {
285
+ if (field.type === "multiLine" || field.type === "richText") {
286
+ return (
287
+ <textarea
288
+ value={(value as string) || ""}
289
+ onChange={(e) => onChange(e.target.value)}
290
+ placeholder={field.placeholder}
291
+ style={{
292
+ width: "100%",
293
+ padding: "8px",
294
+ border: "1px solid #ddd",
295
+ borderRadius: "4px",
296
+ fontSize: "13px",
297
+ minHeight: "60px",
298
+ resize: "vertical",
299
+ fontFamily: "inherit",
300
+ }}
301
+ />
302
+ );
303
+ }
304
+ if (field.type === "boolean") {
305
+ return (
306
+ <label
307
+ style={{
308
+ display: "flex",
309
+ alignItems: "center",
310
+ gap: "8px",
311
+ cursor: "pointer",
312
+ }}
313
+ >
314
+ <input
315
+ type="checkbox"
316
+ checked={!!value}
317
+ onChange={(e) => onChange(e.target.checked)}
318
+ />
319
+ {field.label}
320
+ </label>
321
+ );
322
+ }
323
+ if (field.type === "select") {
324
+ return (
325
+ <select
326
+ value={(value as string) || ""}
327
+ onChange={(e) => onChange(e.target.value)}
328
+ style={{
329
+ width: "100%",
330
+ padding: "8px",
331
+ border: "1px solid #ddd",
332
+ borderRadius: "4px",
333
+ fontSize: "13px",
334
+ }}
335
+ >
336
+ <option value="">Select...</option>
337
+ {field.options?.map((opt: any) => (
338
+ <option key={opt.value} value={opt.value}>
339
+ {opt.label}
340
+ </option>
341
+ ))}
342
+ </select>
343
+ );
344
+ }
345
+ if (field.type === "multiselect") {
346
+ const selected = Array.isArray(value) ? value : [];
347
+ return (
348
+ <div
349
+ style={{
350
+ border: "1px solid #ddd",
351
+ borderRadius: "4px",
352
+ padding: "8px",
353
+ display: "flex",
354
+ flexWrap: "wrap",
355
+ gap: "6px",
356
+ }}
357
+ >
358
+ {field.options?.map((opt: any) => (
359
+ <label
360
+ key={opt.value}
361
+ style={{
362
+ display: "flex",
363
+ alignItems: "center",
364
+ gap: "4px",
365
+ fontSize: "13px",
366
+ cursor: "pointer",
367
+ }}
368
+ >
369
+ <input
370
+ type="checkbox"
371
+ checked={selected.includes(opt.value)}
372
+ onChange={(e) => {
373
+ const next = e.target.checked
374
+ ? [...selected, opt.value]
375
+ : selected.filter((v: string) => v !== opt.value);
376
+ onChange(next);
377
+ }}
378
+ />
379
+ {opt.label}
380
+ </label>
381
+ ))}
382
+ {!field.options?.length && (
383
+ <span style={{ color: "#999", fontSize: "12px" }}>
384
+ No options defined
385
+ </span>
386
+ )}
387
+ </div>
388
+ );
389
+ }
390
+ if (field.type === "date") {
391
+ return (
392
+ <input
393
+ type="date"
394
+ value={(value as string) || ""}
395
+ onChange={(e) => onChange(e.target.value)}
396
+ style={{
397
+ width: "100%",
398
+ padding: "8px",
399
+ border: "1px solid #ddd",
400
+ borderRadius: "4px",
401
+ fontSize: "13px",
402
+ }}
403
+ />
404
+ );
405
+ }
406
+ if (field.type === "media") {
407
+ return (
408
+ <div>
409
+ {value && (
410
+ <div
411
+ style={{
412
+ marginBottom: "6px",
413
+ borderRadius: "4px",
414
+ overflow: "hidden",
415
+ border: "1px solid #ddd",
416
+ }}
417
+ >
418
+ <img
419
+ src={value as string}
420
+ alt=""
421
+ style={{
422
+ maxWidth: "100%",
423
+ maxHeight: "120px",
424
+ objectFit: "contain",
425
+ display: "block",
426
+ }}
427
+ />
428
+ </div>
429
+ )}
430
+ <input
431
+ type="text"
432
+ value={(value as string) || ""}
433
+ onChange={(e) => onChange(e.target.value)}
434
+ placeholder="Image URL"
435
+ style={{
436
+ width: "100%",
437
+ padding: "8px",
438
+ border: "1px solid #ddd",
439
+ borderRadius: "4px",
440
+ fontSize: "13px",
441
+ }}
442
+ />
443
+ </div>
444
+ );
445
+ }
446
+ if (field.type === "repeater" && field.schema) {
447
+ const items = (Array.isArray(value) ? value : []) as any[];
448
+ return (
449
+ <div
450
+ style={{
451
+ border: "1px solid #ddd",
452
+ borderRadius: "6px",
453
+ overflow: "hidden",
454
+ }}
455
+ >
456
+ {items.map((item: any, idx: number) => (
457
+ <div
458
+ key={idx}
459
+ style={{
460
+ padding: "12px",
461
+ borderBottom: "1px solid #eee",
462
+ background: idx % 2 === 0 ? "#fafafa" : "#fff",
463
+ }}
464
+ >
465
+ <div
466
+ style={{
467
+ display: "flex",
468
+ justifyContent: "space-between",
469
+ alignItems: "center",
470
+ marginBottom: "8px",
471
+ }}
472
+ >
473
+ <span
474
+ style={{ fontSize: "12px", fontWeight: 600, color: "#888" }}
475
+ >
476
+ #{idx + 1}
477
+ </span>
478
+ <button
479
+ type="button"
480
+ onClick={() => {
481
+ const newItems = [...items];
482
+ newItems.splice(idx, 1);
483
+ onChange(newItems);
484
+ }}
485
+ style={{
486
+ fontSize: "11px",
487
+ color: "#e53935",
488
+ background: "none",
489
+ border: "none",
490
+ cursor: "pointer",
491
+ }}
492
+ >
493
+ &times; Remove
494
+ </button>
495
+ </div>
496
+ {Object.entries(field.schema).map(
497
+ ([subKey, subField]: [string, any]) => (
498
+ <div key={subKey} style={{ marginBottom: "8px" }}>
499
+ <label
500
+ style={{
501
+ display: "block",
502
+ fontSize: "11px",
503
+ fontWeight: 500,
504
+ marginBottom: "4px",
505
+ color: "#666",
506
+ }}
507
+ >
508
+ {subField.label || subKey}
509
+ </label>
510
+ {renderField(subField, item[subKey], (subVal) => {
511
+ const newItems = [...items];
512
+ newItems[idx] = { ...newItems[idx], [subKey]: subVal };
513
+ onChange(newItems);
514
+ })}
515
+ </div>
516
+ ),
517
+ )}
518
+ </div>
519
+ ))}
520
+ <button
521
+ type="button"
522
+ onClick={() => {
523
+ const newItem: any = {};
524
+ Object.entries(field.schema).forEach(([k, f]: [string, any]) => {
525
+ newItem[k] = (f as any).type === "repeater" ? [] : "";
526
+ });
527
+ onChange([...items, newItem]);
528
+ }}
529
+ style={{
530
+ width: "100%",
531
+ padding: "10px",
532
+ fontSize: "13px",
533
+ color: "#667eea",
534
+ background: "none",
535
+ border: "none",
536
+ cursor: "pointer",
537
+ fontWeight: 500,
538
+ }}
539
+ >
540
+ + Add item
541
+ </button>
542
+ </div>
543
+ );
544
+ }
545
+ // singleLine, link, numeric, color, form, emailTemplate, emailConfiguration, pageSelector
546
+ return (
547
+ <input
548
+ type={
549
+ field.type === "numeric"
550
+ ? "number"
551
+ : field.type === "color"
552
+ ? "color"
553
+ : field.type === "link"
554
+ ? "url"
555
+ : "text"
556
+ }
557
+ value={(value as string) || ""}
558
+ onChange={(e) =>
559
+ onChange(
560
+ field.type === "numeric" ? Number(e.target.value) : e.target.value,
561
+ )
562
+ }
563
+ placeholder={
564
+ field.placeholder ||
565
+ (field.type === "link"
566
+ ? "https://..."
567
+ : field.type === "form" ||
568
+ field.type === "emailTemplate" ||
569
+ field.type === "emailConfiguration" ||
570
+ field.type === "pageSelector"
571
+ ? "Enter ID..."
572
+ : "")
573
+ }
574
+ style={{
575
+ width: "100%",
576
+ padding: "8px",
577
+ border: "1px solid #ddd",
578
+ borderRadius: "4px",
579
+ fontSize: "13px",
580
+ }}
581
+ />
582
+ );
583
+ }
584
+
585
+ return (
586
+ <div
587
+ style={{
588
+ display: "grid",
589
+ gridTemplateColumns: `${showBlockList ? "280px" : "0px"} 1fr ${showEditor ? "400px" : "0px"}`,
590
+ height: "100vh",
591
+ transition: "grid-template-columns 0.2s ease",
592
+ }}
593
+ >
594
+ {/* Block List */}
595
+ <div
596
+ style={{
597
+ background: "#fff",
598
+ borderRight: showBlockList ? "1px solid #e0e0e0" : "none",
599
+ overflow: showBlockList ? "auto" : "hidden",
600
+ width: showBlockList ? "auto" : 0,
601
+ }}
602
+ >
603
+ <div
604
+ style={{
605
+ padding: "16px",
606
+ borderBottom: "1px solid #e0e0e0",
607
+ background: "#fafafa",
608
+ }}
609
+ >
610
+ <h1 style={{ fontSize: "18px", fontWeight: 600, margin: 0 }}>
611
+ Cmssy Dev
612
+ </h1>
613
+ <p style={{ fontSize: "13px", color: "#666", margin: "4px 0 0" }}>
614
+ {blocks.length} blocks
615
+ </p>
616
+ </div>
617
+ {/* Workspace Connection */}
618
+ <div
619
+ style={{
620
+ padding: "12px 16px",
621
+ borderBottom: "1px solid #e0e0e0",
622
+ fontSize: "12px",
623
+ }}
624
+ >
625
+ {wsLoading ? (
626
+ <div style={{ color: "#999" }}>Connecting...</div>
627
+ ) : wsInfo?.connected ? (
628
+ <div
629
+ style={{
630
+ display: "flex",
631
+ alignItems: "center",
632
+ justifyContent: "space-between",
633
+ }}
634
+ >
635
+ <div
636
+ style={{ display: "flex", alignItems: "center", gap: "6px" }}
637
+ >
638
+ <div
639
+ style={{
640
+ width: "8px",
641
+ height: "8px",
642
+ borderRadius: "50%",
643
+ background: "#22c55e",
644
+ flexShrink: 0,
645
+ }}
646
+ />
647
+ <span style={{ fontWeight: 500 }}>
648
+ {wsInfo.workspace?.name}
649
+ </span>
650
+ </div>
651
+ <button
652
+ type="button"
653
+ onClick={() => {
654
+ setWsLoading(true);
655
+ fetch("/api/workspaces")
656
+ .then((r) => r.json())
657
+ .then((data) => {
658
+ setWsInfo(data);
659
+ setWsLoading(false);
660
+ })
661
+ .catch(() => setWsLoading(false));
662
+ }}
663
+ style={{
664
+ background: "none",
665
+ border: "none",
666
+ cursor: "pointer",
667
+ color: "#667eea",
668
+ fontSize: "11px",
669
+ padding: "2px 4px",
670
+ }}
671
+ title="Refresh"
672
+ >
673
+ {"\u21BB"}
674
+ </button>
675
+ </div>
676
+ ) : (
677
+ <div style={{ display: "flex", alignItems: "center", gap: "6px" }}>
678
+ <div
679
+ style={{
680
+ width: "8px",
681
+ height: "8px",
682
+ borderRadius: "50%",
683
+ background: "#ef4444",
684
+ flexShrink: 0,
685
+ }}
686
+ />
687
+ <span style={{ color: "#999" }}>
688
+ {wsInfo?.reason === "no_token"
689
+ ? "Not configured"
690
+ : wsInfo?.reason === "auth_error"
691
+ ? "Invalid token"
692
+ : "Disconnected"}
693
+ </span>
694
+ </div>
695
+ )}
696
+ </div>
697
+ <div style={{ padding: "12px" }}>
698
+ {blocks.map((b) => (
699
+ <div
700
+ key={b.name}
701
+ onClick={() => {
702
+ handleSelect(b);
703
+ const url = new URL(window.location.href);
704
+ url.searchParams.set(
705
+ b.type === "template" ? "template" : "block",
706
+ b.name,
707
+ );
708
+ window.history.replaceState({}, "", url.toString());
709
+ }}
710
+ style={{
711
+ padding: "12px 16px",
712
+ marginBottom: "4px",
713
+ borderRadius: "8px",
714
+ cursor: "pointer",
715
+ background:
716
+ selected?.name === b.name ? "#667eea" : "transparent",
717
+ color: selected?.name === b.name ? "white" : "inherit",
718
+ }}
719
+ >
720
+ <div
721
+ style={{
722
+ display: "flex",
723
+ alignItems: "center",
724
+ justifyContent: "space-between",
725
+ }}
726
+ >
727
+ <div style={{ fontSize: "14px", fontWeight: 500 }}>
728
+ {b.displayName}
729
+ </div>
730
+ {wsInfo?.connected &&
731
+ (() => {
732
+ const status = getSyncStatus(b.name, b.version, wsInfo);
733
+ const colors: Record<
734
+ SyncStatus,
735
+ { bg: string; text: string; label: string }
736
+ > = {
737
+ published: {
738
+ bg:
739
+ selected?.name === b.name
740
+ ? "rgba(255,255,255,0.2)"
741
+ : "#dcfce7",
742
+ text: selected?.name === b.name ? "#fff" : "#166534",
743
+ label: "\u2713",
744
+ },
745
+ outdated: {
746
+ bg:
747
+ selected?.name === b.name
748
+ ? "rgba(255,255,255,0.2)"
749
+ : "#fef3c7",
750
+ text: selected?.name === b.name ? "#fff" : "#92400e",
751
+ label: "\u2191",
752
+ },
753
+ "local-only": {
754
+ bg: "transparent",
755
+ text: "transparent",
756
+ label: "",
757
+ },
758
+ };
759
+ const c = colors[status];
760
+ if (status === "local-only") return null;
761
+ return (
762
+ <span
763
+ title={status}
764
+ style={{
765
+ fontSize: "10px",
766
+ padding: "1px 6px",
767
+ borderRadius: "4px",
768
+ background: c.bg,
769
+ color: c.text,
770
+ fontWeight: 600,
771
+ }}
772
+ >
773
+ {c.label}
774
+ </span>
775
+ );
776
+ })()}
777
+ </div>
778
+ <div style={{ fontSize: "12px", opacity: 0.7 }}>
779
+ {b.type} &middot; v{b.version}
780
+ </div>
781
+ </div>
782
+ ))}
783
+ </div>
784
+ </div>
785
+
786
+ {/* Preview */}
787
+ <div
788
+ style={{
789
+ background: "#fafafa",
790
+ display: "flex",
791
+ flexDirection: "column",
792
+ }}
793
+ >
794
+ <div
795
+ style={{
796
+ padding: "10px 16px",
797
+ background: "white",
798
+ borderBottom: "1px solid #e0e0e0",
799
+ display: "flex",
800
+ alignItems: "center",
801
+ justifyContent: "space-between",
802
+ gap: "12px",
803
+ }}
804
+ >
805
+ <button
806
+ type="button"
807
+ onClick={() => setShowBlockList(!showBlockList)}
808
+ title={showBlockList ? "Hide block list" : "Show block list"}
809
+ style={{
810
+ background: showBlockList ? "#f0f0f0" : "#667eea",
811
+ color: showBlockList ? "#333" : "#fff",
812
+ border: "1px solid #ddd",
813
+ borderRadius: "6px",
814
+ padding: "6px 10px",
815
+ cursor: "pointer",
816
+ fontSize: "13px",
817
+ fontWeight: 500,
818
+ whiteSpace: "nowrap",
819
+ }}
820
+ >
821
+ {showBlockList ? "\u2190 Blocks" : "\u2192 Blocks"}
822
+ </button>
823
+ <div
824
+ style={{
825
+ flex: 1,
826
+ fontSize: "16px",
827
+ fontWeight: 600,
828
+ textAlign: "center",
829
+ overflow: "hidden",
830
+ textOverflow: "ellipsis",
831
+ whiteSpace: "nowrap",
832
+ }}
833
+ >
834
+ {selected?.displayName || "Preview"}
835
+ </div>
836
+ <button
837
+ type="button"
838
+ onClick={() => setShowEditor(!showEditor)}
839
+ title={showEditor ? "Hide editor" : "Show editor"}
840
+ style={{
841
+ background: showEditor ? "#f0f0f0" : "#667eea",
842
+ color: showEditor ? "#333" : "#fff",
843
+ border: "1px solid #ddd",
844
+ borderRadius: "6px",
845
+ padding: "6px 10px",
846
+ cursor: "pointer",
847
+ fontSize: "13px",
848
+ fontWeight: 500,
849
+ whiteSpace: "nowrap",
850
+ }}
851
+ >
852
+ {showEditor ? "Editor \u2192" : "Editor \u2190"}
853
+ </button>
854
+ <button
855
+ type="button"
856
+ onClick={() => setShowSettings(!showSettings)}
857
+ title="Settings"
858
+ style={{
859
+ background: showSettings ? "#667eea" : "#f0f0f0",
860
+ color: showSettings ? "#fff" : "#333",
861
+ border: "1px solid #ddd",
862
+ borderRadius: "6px",
863
+ padding: "6px 10px",
864
+ cursor: "pointer",
865
+ fontSize: "15px",
866
+ lineHeight: 1,
867
+ }}
868
+ >
869
+ {"\u2699"}
870
+ </button>
871
+ </div>
872
+ {/* Responsive viewport toolbar */}
873
+ {previewUrl && (
874
+ <div
875
+ style={{
876
+ padding: "6px 16px",
877
+ background: "#f8f8f8",
878
+ borderBottom: "1px solid #e0e0e0",
879
+ display: "flex",
880
+ flexWrap: "wrap",
881
+ alignItems: "center",
882
+ gap: "4px",
883
+ rowGap: "6px",
884
+ fontSize: "12px",
885
+ }}
886
+ >
887
+ <button
888
+ type="button"
889
+ aria-pressed={viewport === null}
890
+ onClick={() => setViewport(null)}
891
+ style={{
892
+ padding: "4px 10px",
893
+ border: "1px solid #ddd",
894
+ borderRadius: "4px",
895
+ background: viewport === null ? "#667eea" : "#fff",
896
+ color: viewport === null ? "#fff" : "#333",
897
+ cursor: "pointer",
898
+ fontSize: "12px",
899
+ fontWeight: 500,
900
+ }}
901
+ >
902
+ Full
903
+ </button>
904
+ {VIEWPORT_PRESETS.map((preset) => (
905
+ <button
906
+ key={preset.width}
907
+ type="button"
908
+ aria-pressed={viewport === preset.width}
909
+ onClick={() => setViewport(preset.width)}
910
+ style={{
911
+ padding: "4px 10px",
912
+ border: "1px solid #ddd",
913
+ borderRadius: "4px",
914
+ background: viewport === preset.width ? "#667eea" : "#fff",
915
+ color: viewport === preset.width ? "#fff" : "#333",
916
+ cursor: "pointer",
917
+ fontSize: "12px",
918
+ fontWeight: 500,
919
+ }}
920
+ >
921
+ {preset.label} {preset.width}
922
+ </button>
923
+ ))}
924
+ <div
925
+ style={{
926
+ display: "flex",
927
+ alignItems: "center",
928
+ gap: "4px",
929
+ marginLeft: "8px",
930
+ }}
931
+ >
932
+ <input
933
+ type="number"
934
+ value={viewport ?? ""}
935
+ aria-label="Viewport width in pixels"
936
+ onChange={(e) => {
937
+ const parsed = parseInt(e.target.value, 10);
938
+ const v =
939
+ Number.isFinite(parsed) && parsed > 0 ? parsed : null;
940
+ setViewport(v);
941
+ }}
942
+ placeholder="Custom"
943
+ style={{
944
+ width: "72px",
945
+ padding: "4px 6px",
946
+ border: "1px solid #ddd",
947
+ borderRadius: "4px",
948
+ fontSize: "12px",
949
+ textAlign: "center",
950
+ }}
951
+ />
952
+ <span style={{ color: "#999" }}>px</span>
953
+ </div>
954
+ </div>
955
+ )}
956
+ <div
957
+ style={{
958
+ flex: 1,
959
+ padding: "24px",
960
+ display: "flex",
961
+ alignItems: "center",
962
+ justifyContent: "center",
963
+ overflow: "auto",
964
+ }}
965
+ >
966
+ {previewUrl ? (
967
+ <div
968
+ style={{
969
+ width:
970
+ viewport !== null && viewport > 0 ? `${viewport}px` : "100%",
971
+ maxWidth: "100%",
972
+ height: "100%",
973
+ background: "white",
974
+ borderRadius: "12px",
975
+ boxShadow: "0 2px 8px rgba(0,0,0,0.1)",
976
+ overflow: "hidden",
977
+ transition: "width 0.2s ease",
978
+ }}
979
+ >
980
+ <iframe
981
+ ref={iframeRef}
982
+ src={previewUrl}
983
+ key={previewUrl}
984
+ onLoad={() => {
985
+ iframeLoadedRef.current = true;
986
+ }}
987
+ style={{ width: "100%", height: "100%", border: "none" }}
988
+ />
989
+ </div>
990
+ ) : (
991
+ <div style={{ textAlign: "center", color: "#999" }}>
992
+ <p>Select a block to preview</p>
993
+ </div>
994
+ )}
995
+ </div>
996
+ </div>
997
+
998
+ {/* Editor */}
999
+ <div
1000
+ style={{
1001
+ background: "#fff",
1002
+ borderLeft: showEditor ? "1px solid #e0e0e0" : "none",
1003
+ overflow: showEditor ? "auto" : "hidden",
1004
+ width: showEditor ? "auto" : 0,
1005
+ }}
1006
+ >
1007
+ <div
1008
+ style={{
1009
+ padding: "16px",
1010
+ borderBottom: "1px solid #e0e0e0",
1011
+ background: "#fafafa",
1012
+ }}
1013
+ >
1014
+ <div style={{ display: "flex", gap: "0" }}>
1015
+ {(["content", "context"] as const).map((tab) => (
1016
+ <button
1017
+ key={tab}
1018
+ type="button"
1019
+ onClick={() => setEditorTab(tab)}
1020
+ style={{
1021
+ padding: "6px 16px",
1022
+ border: "none",
1023
+ background: editorTab === tab ? "#fff" : "transparent",
1024
+ borderBottom:
1025
+ editorTab === tab
1026
+ ? "2px solid #667eea"
1027
+ : "2px solid transparent",
1028
+ fontSize: "14px",
1029
+ fontWeight: editorTab === tab ? 600 : 400,
1030
+ cursor: "pointer",
1031
+ color: editorTab === tab ? "#333" : "#888",
1032
+ textTransform: "capitalize",
1033
+ }}
1034
+ >
1035
+ {tab}
1036
+ </button>
1037
+ ))}
1038
+ </div>
1039
+ </div>
1040
+ {/* Variant picker */}
1041
+ {selected && (
1042
+ <div
1043
+ style={{
1044
+ padding: "8px 16px",
1045
+ borderBottom: "1px solid #e0e0e0",
1046
+ display: "flex",
1047
+ flexWrap: "wrap",
1048
+ alignItems: "center",
1049
+ gap: "4px",
1050
+ fontSize: "12px",
1051
+ }}
1052
+ >
1053
+ <button
1054
+ type="button"
1055
+ onClick={() => setCurrentVariant(null)}
1056
+ style={{
1057
+ padding: "3px 8px",
1058
+ border: "1px solid #ddd",
1059
+ borderRadius: "4px",
1060
+ background: currentVariant === null ? "#667eea" : "#fff",
1061
+ color: currentVariant === null ? "#fff" : "#333",
1062
+ cursor: "pointer",
1063
+ fontSize: "11px",
1064
+ fontWeight: 500,
1065
+ }}
1066
+ >
1067
+ Default
1068
+ </button>
1069
+ {variants.map((v) => (
1070
+ <button
1071
+ key={v}
1072
+ type="button"
1073
+ onClick={() => setCurrentVariant(v)}
1074
+ style={{
1075
+ padding: "3px 8px",
1076
+ border: "1px solid #ddd",
1077
+ borderRadius: "4px",
1078
+ background: currentVariant === v ? "#667eea" : "#fff",
1079
+ color: currentVariant === v ? "#fff" : "#333",
1080
+ cursor: "pointer",
1081
+ fontSize: "11px",
1082
+ fontWeight: 500,
1083
+ }}
1084
+ >
1085
+ {v}
1086
+ </button>
1087
+ ))}
1088
+ <button
1089
+ type="button"
1090
+ onClick={async () => {
1091
+ const name = prompt("Variant name (e.g., long-text):");
1092
+ if (!name) return;
1093
+ const dataToSave = { ...configDataRef.current, ...previewData };
1094
+ try {
1095
+ const res = await fetch(
1096
+ `/api/preview/${selected.name}?action=save-variant`,
1097
+ {
1098
+ method: "POST",
1099
+ headers: { "Content-Type": "application/json" },
1100
+ body: JSON.stringify({
1101
+ variantName: name,
1102
+ data: dataToSave,
1103
+ }),
1104
+ },
1105
+ );
1106
+ if (!res.ok) {
1107
+ const err = await res.json().catch(() => ({}));
1108
+ alert(err.error || "Failed to save variant");
1109
+ return;
1110
+ }
1111
+ setVariants((prev) =>
1112
+ prev.includes(name) ? prev : [...prev, name],
1113
+ );
1114
+ setCurrentVariant(name);
1115
+ } catch {
1116
+ alert("Failed to save variant");
1117
+ }
1118
+ }}
1119
+ style={{
1120
+ padding: "3px 8px",
1121
+ border: "1px dashed #ccc",
1122
+ borderRadius: "4px",
1123
+ background: "transparent",
1124
+ color: "#667eea",
1125
+ cursor: "pointer",
1126
+ fontSize: "11px",
1127
+ fontWeight: 500,
1128
+ }}
1129
+ >
1130
+ + Save as...
1131
+ </button>
1132
+ </div>
1133
+ )}
1134
+ {/* Content tab */}
1135
+ {editorTab === "content" && (
1136
+ <div style={{ padding: "20px" }}>
1137
+ {!selected && (
1138
+ <p style={{ color: "#999" }}>Select a block to edit</p>
1139
+ )}
1140
+ {selected && configLoading && (
1141
+ <p style={{ color: "#999" }}>Loading...</p>
1142
+ )}
1143
+ {selected && !configLoading && selected.schema && (
1144
+ <div>
1145
+ {Object.entries(selected.schema).map(
1146
+ ([key, field]: [string, any]) => (
1147
+ <div key={key} style={{ marginBottom: "20px" }}>
1148
+ <label
1149
+ style={{
1150
+ display: "block",
1151
+ fontSize: "13px",
1152
+ fontWeight: 500,
1153
+ marginBottom: "6px",
1154
+ }}
1155
+ >
1156
+ {field.label || key}
1157
+ {field.required && (
1158
+ <span style={{ color: "#e53935" }}> *</span>
1159
+ )}
1160
+ </label>
1161
+ {renderField(field, previewData[key], (val) => {
1162
+ setPreviewData({ ...previewData, [key]: val });
1163
+ setIsDirty(true);
1164
+ })}
1165
+ {field.helpText && (
1166
+ <div
1167
+ style={{
1168
+ fontSize: "12px",
1169
+ color: "#666",
1170
+ marginTop: "4px",
1171
+ }}
1172
+ >
1173
+ {field.helpText}
1174
+ </div>
1175
+ )}
1176
+ </div>
1177
+ ),
1178
+ )}
1179
+ </div>
1180
+ )}
1181
+ </div>
1182
+ )}
1183
+
1184
+ {/* Context tab */}
1185
+ {editorTab === "context" && (
1186
+ <div style={{ padding: "20px" }}>
1187
+ {/* Presets */}
1188
+ <div style={{ marginBottom: "20px" }}>
1189
+ <label
1190
+ style={{
1191
+ display: "block",
1192
+ fontSize: "13px",
1193
+ fontWeight: 600,
1194
+ marginBottom: "8px",
1195
+ }}
1196
+ >
1197
+ Presets
1198
+ </label>
1199
+ <div style={{ display: "flex", flexWrap: "wrap", gap: "4px" }}>
1200
+ {contextPresets.map((preset) => (
1201
+ <button
1202
+ key={preset}
1203
+ type="button"
1204
+ onClick={async () => {
1205
+ const res = await fetch(
1206
+ `/api/context?preset=${encodeURIComponent(preset)}`,
1207
+ );
1208
+ const data = await res.json();
1209
+ setMockContext(data);
1210
+ }}
1211
+ style={{
1212
+ padding: "4px 10px",
1213
+ border: "1px solid #ddd",
1214
+ borderRadius: "4px",
1215
+ background: "#fff",
1216
+ cursor: "pointer",
1217
+ fontSize: "12px",
1218
+ }}
1219
+ >
1220
+ {preset}
1221
+ </button>
1222
+ ))}
1223
+ </div>
1224
+ </div>
1225
+
1226
+ {/* Locale */}
1227
+ <div style={{ marginBottom: "20px" }}>
1228
+ <label
1229
+ style={{
1230
+ display: "block",
1231
+ fontSize: "13px",
1232
+ fontWeight: 600,
1233
+ marginBottom: "8px",
1234
+ }}
1235
+ >
1236
+ Locale
1237
+ </label>
1238
+ <div style={{ marginBottom: "8px" }}>
1239
+ <label
1240
+ style={{
1241
+ display: "block",
1242
+ fontSize: "12px",
1243
+ color: "#666",
1244
+ marginBottom: "4px",
1245
+ }}
1246
+ >
1247
+ Current language
1248
+ </label>
1249
+ <input
1250
+ type="text"
1251
+ value={mockContext.locale?.current || "en"}
1252
+ onChange={(e) => {
1253
+ const updated = {
1254
+ ...mockContext,
1255
+ locale: {
1256
+ ...mockContext.locale,
1257
+ current: e.target.value,
1258
+ },
1259
+ };
1260
+ setMockContext(updated);
1261
+ }}
1262
+ style={{
1263
+ width: "100%",
1264
+ padding: "6px 8px",
1265
+ border: "1px solid #ddd",
1266
+ borderRadius: "4px",
1267
+ fontSize: "13px",
1268
+ boxSizing: "border-box",
1269
+ }}
1270
+ />
1271
+ </div>
1272
+ <div style={{ marginBottom: "8px" }}>
1273
+ <label
1274
+ style={{
1275
+ display: "block",
1276
+ fontSize: "12px",
1277
+ color: "#666",
1278
+ marginBottom: "4px",
1279
+ }}
1280
+ >
1281
+ Enabled languages (comma-separated)
1282
+ </label>
1283
+ <input
1284
+ type="text"
1285
+ value={(mockContext.locale?.enabled || ["en"]).join(", ")}
1286
+ onChange={(e) => {
1287
+ const langs = e.target.value
1288
+ .split(",")
1289
+ .map((s: string) => s.trim())
1290
+ .filter(Boolean);
1291
+ const updated = {
1292
+ ...mockContext,
1293
+ locale: { ...mockContext.locale, enabled: langs },
1294
+ };
1295
+ setMockContext(updated);
1296
+ }}
1297
+ style={{
1298
+ width: "100%",
1299
+ padding: "6px 8px",
1300
+ border: "1px solid #ddd",
1301
+ borderRadius: "4px",
1302
+ fontSize: "13px",
1303
+ boxSizing: "border-box",
1304
+ }}
1305
+ />
1306
+ </div>
1307
+ </div>
1308
+
1309
+ {/* Auth */}
1310
+ <div style={{ marginBottom: "20px" }}>
1311
+ <label
1312
+ style={{
1313
+ display: "flex",
1314
+ alignItems: "center",
1315
+ gap: "8px",
1316
+ fontSize: "13px",
1317
+ fontWeight: 600,
1318
+ marginBottom: "8px",
1319
+ cursor: "pointer",
1320
+ }}
1321
+ >
1322
+ <input
1323
+ type="checkbox"
1324
+ checked={!!mockContext.auth?.isAuthenticated}
1325
+ onChange={(e) => {
1326
+ const updated = e.target.checked
1327
+ ? {
1328
+ ...mockContext,
1329
+ auth: {
1330
+ isAuthenticated: true,
1331
+ member: mockContext.auth?.member || {
1332
+ id: "dev-member-1",
1333
+ email: "user@example.com",
1334
+ profile: {
1335
+ firstName: "Jane",
1336
+ lastName: "Doe",
1337
+ displayName: "Jane Doe",
1338
+ avatarUrl: "",
1339
+ },
1340
+ role: "member",
1341
+ verified: true,
1342
+ },
1343
+ },
1344
+ }
1345
+ : { ...mockContext, auth: null };
1346
+ setMockContext(updated);
1347
+ }}
1348
+ />
1349
+ Authenticated
1350
+ </label>
1351
+ {mockContext.auth?.isAuthenticated && (
1352
+ <div style={{ paddingLeft: "4px" }}>
1353
+ {(["email", "role"] as const).map((field) => (
1354
+ <div key={field} style={{ marginBottom: "6px" }}>
1355
+ <label
1356
+ style={{
1357
+ display: "block",
1358
+ fontSize: "12px",
1359
+ color: "#666",
1360
+ marginBottom: "2px",
1361
+ textTransform: "capitalize",
1362
+ }}
1363
+ >
1364
+ {field}
1365
+ </label>
1366
+ <input
1367
+ type="text"
1368
+ value={
1369
+ field === "email"
1370
+ ? mockContext.auth?.member?.email || ""
1371
+ : mockContext.auth?.member?.role || ""
1372
+ }
1373
+ onChange={(e) => {
1374
+ const updated = {
1375
+ ...mockContext,
1376
+ auth: {
1377
+ ...mockContext.auth,
1378
+ member: {
1379
+ ...mockContext.auth?.member,
1380
+ [field]: e.target.value,
1381
+ },
1382
+ },
1383
+ };
1384
+ setMockContext(updated);
1385
+ }}
1386
+ style={{
1387
+ width: "100%",
1388
+ padding: "4px 8px",
1389
+ border: "1px solid #ddd",
1390
+ borderRadius: "4px",
1391
+ fontSize: "12px",
1392
+ boxSizing: "border-box",
1393
+ }}
1394
+ />
1395
+ </div>
1396
+ ))}
1397
+ </div>
1398
+ )}
1399
+ </div>
1400
+
1401
+ {/* Raw JSON editor */}
1402
+ <div style={{ marginBottom: "20px" }}>
1403
+ <label
1404
+ style={{
1405
+ display: "block",
1406
+ fontSize: "13px",
1407
+ fontWeight: 600,
1408
+ marginBottom: "8px",
1409
+ }}
1410
+ >
1411
+ Full context (JSON)
1412
+ </label>
1413
+ <textarea
1414
+ value={JSON.stringify(mockContext, null, 2)}
1415
+ onChange={(e) => {
1416
+ try {
1417
+ const parsed = JSON.parse(e.target.value);
1418
+ setMockContext(parsed);
1419
+ } catch {
1420
+ // Invalid JSON, don't update
1421
+ }
1422
+ }}
1423
+ style={{
1424
+ width: "100%",
1425
+ minHeight: "200px",
1426
+ padding: "8px",
1427
+ border: "1px solid #ddd",
1428
+ borderRadius: "4px",
1429
+ fontSize: "12px",
1430
+ fontFamily: "monospace",
1431
+ resize: "vertical",
1432
+ boxSizing: "border-box",
1433
+ }}
1434
+ />
1435
+ </div>
1436
+ </div>
1437
+ )}
1438
+ </div>
1439
+
1440
+ {/* Settings Panel (slide-over) */}
1441
+ {showSettings && (
1442
+ <div
1443
+ style={{
1444
+ position: "fixed",
1445
+ top: 0,
1446
+ right: 0,
1447
+ bottom: 0,
1448
+ width: "420px",
1449
+ background: "#fff",
1450
+ boxShadow: "-4px 0 24px rgba(0,0,0,0.12)",
1451
+ zIndex: 1000,
1452
+ display: "flex",
1453
+ flexDirection: "column",
1454
+ animation: "slideIn 0.2s ease",
1455
+ }}
1456
+ >
1457
+ <style>{`@keyframes slideIn { from { transform: translateX(100%); } to { transform: translateX(0); } }`}</style>
1458
+ <div
1459
+ style={{
1460
+ padding: "16px 20px",
1461
+ borderBottom: "1px solid #e0e0e0",
1462
+ display: "flex",
1463
+ justifyContent: "space-between",
1464
+ alignItems: "center",
1465
+ background: "#fafafa",
1466
+ }}
1467
+ >
1468
+ <h2 style={{ fontSize: "16px", fontWeight: 600, margin: 0 }}>
1469
+ Settings
1470
+ </h2>
1471
+ <button
1472
+ type="button"
1473
+ onClick={() => setShowSettings(false)}
1474
+ style={{
1475
+ background: "none",
1476
+ border: "none",
1477
+ fontSize: "20px",
1478
+ cursor: "pointer",
1479
+ color: "#666",
1480
+ padding: "4px",
1481
+ }}
1482
+ >
1483
+ {"\u2715"}
1484
+ </button>
1485
+ </div>
1486
+ <div style={{ flex: 1, overflow: "auto", padding: "20px" }}>
1487
+ {/* Connection */}
1488
+ <div style={{ marginBottom: "24px" }}>
1489
+ <h3
1490
+ style={{
1491
+ fontSize: "13px",
1492
+ fontWeight: 600,
1493
+ textTransform: "uppercase",
1494
+ letterSpacing: "0.05em",
1495
+ color: "#666",
1496
+ marginBottom: "12px",
1497
+ }}
1498
+ >
1499
+ Connection
1500
+ </h3>
1501
+ <div style={{ marginBottom: "12px" }}>
1502
+ <label
1503
+ style={{
1504
+ display: "block",
1505
+ fontSize: "13px",
1506
+ fontWeight: 500,
1507
+ marginBottom: "4px",
1508
+ }}
1509
+ >
1510
+ API URL
1511
+ </label>
1512
+ <input
1513
+ type="text"
1514
+ value={settingsData.newApiUrl}
1515
+ onChange={(e) =>
1516
+ setSettingsData({
1517
+ ...settingsData,
1518
+ newApiUrl: e.target.value,
1519
+ })
1520
+ }
1521
+ style={{
1522
+ width: "100%",
1523
+ padding: "8px",
1524
+ border: "1px solid #ddd",
1525
+ borderRadius: "6px",
1526
+ fontSize: "13px",
1527
+ boxSizing: "border-box",
1528
+ }}
1529
+ />
1530
+ </div>
1531
+ <div style={{ marginBottom: "12px" }}>
1532
+ <label
1533
+ style={{
1534
+ display: "block",
1535
+ fontSize: "13px",
1536
+ fontWeight: 500,
1537
+ marginBottom: "4px",
1538
+ }}
1539
+ >
1540
+ API Token{" "}
1541
+ {settingsData.hasToken && (
1542
+ <span style={{ color: "#22c55e", fontSize: "11px" }}>
1543
+ ({settingsData.maskedToken})
1544
+ </span>
1545
+ )}
1546
+ </label>
1547
+ <input
1548
+ type="password"
1549
+ value={settingsData.newToken}
1550
+ onChange={(e) =>
1551
+ setSettingsData({
1552
+ ...settingsData,
1553
+ newToken: e.target.value,
1554
+ })
1555
+ }
1556
+ placeholder={
1557
+ settingsData.hasToken
1558
+ ? "Leave empty to keep current"
1559
+ : "bf_..."
1560
+ }
1561
+ style={{
1562
+ width: "100%",
1563
+ padding: "8px",
1564
+ border: "1px solid #ddd",
1565
+ borderRadius: "6px",
1566
+ fontSize: "13px",
1567
+ boxSizing: "border-box",
1568
+ }}
1569
+ />
1570
+ </div>
1571
+ <div style={{ display: "flex", gap: "8px" }}>
1572
+ <button
1573
+ type="button"
1574
+ disabled={settingsData.testing}
1575
+ onClick={async () => {
1576
+ setSettingsData((s) => ({
1577
+ ...s,
1578
+ testing: true,
1579
+ testResult: null,
1580
+ }));
1581
+ try {
1582
+ if (settingsData.newToken) {
1583
+ await fetch("/api/config", {
1584
+ method: "PUT",
1585
+ headers: { "Content-Type": "application/json" },
1586
+ body: JSON.stringify({
1587
+ apiToken: settingsData.newToken,
1588
+ apiUrl: settingsData.newApiUrl,
1589
+ }),
1590
+ });
1591
+ }
1592
+ const res = await fetch("/api/workspaces");
1593
+ const data = await res.json();
1594
+ if (data.connected) {
1595
+ setSettingsData((s) => ({
1596
+ ...s,
1597
+ testing: false,
1598
+ testResult: "success",
1599
+ }));
1600
+ setWsInfo(data);
1601
+ setWsLoading(false);
1602
+ } else {
1603
+ setSettingsData((s) => ({
1604
+ ...s,
1605
+ testing: false,
1606
+ testResult: data.error || data.reason || "failed",
1607
+ }));
1608
+ }
1609
+ } catch {
1610
+ setSettingsData((s) => ({
1611
+ ...s,
1612
+ testing: false,
1613
+ testResult: "Network error",
1614
+ }));
1615
+ }
1616
+ }}
1617
+ style={{
1618
+ padding: "8px 16px",
1619
+ border: "1px solid #ddd",
1620
+ borderRadius: "6px",
1621
+ background: "#f8f8f8",
1622
+ cursor: "pointer",
1623
+ fontSize: "13px",
1624
+ fontWeight: 500,
1625
+ }}
1626
+ >
1627
+ {settingsData.testing ? "Testing..." : "Test Connection"}
1628
+ </button>
1629
+ {settingsData.testResult && (
1630
+ <span
1631
+ style={{
1632
+ display: "flex",
1633
+ alignItems: "center",
1634
+ fontSize: "12px",
1635
+ color:
1636
+ settingsData.testResult === "success"
1637
+ ? "#22c55e"
1638
+ : "#ef4444",
1639
+ }}
1640
+ >
1641
+ {settingsData.testResult === "success"
1642
+ ? "\u2713 Connected"
1643
+ : "\u2717 " + settingsData.testResult}
1644
+ </span>
1645
+ )}
1646
+ </div>
1647
+ </div>
1648
+
1649
+ {/* Workspace */}
1650
+ <div style={{ marginBottom: "24px" }}>
1651
+ <h3
1652
+ style={{
1653
+ fontSize: "13px",
1654
+ fontWeight: 600,
1655
+ textTransform: "uppercase",
1656
+ letterSpacing: "0.05em",
1657
+ color: "#666",
1658
+ marginBottom: "12px",
1659
+ }}
1660
+ >
1661
+ Workspace
1662
+ </h3>
1663
+ {wsInfo?.connected &&
1664
+ wsInfo.workspaces &&
1665
+ wsInfo.workspaces.length > 0 ? (
1666
+ <select
1667
+ value={settingsData.newWorkspaceId}
1668
+ onChange={(e) =>
1669
+ setSettingsData({
1670
+ ...settingsData,
1671
+ newWorkspaceId: e.target.value,
1672
+ })
1673
+ }
1674
+ style={{
1675
+ width: "100%",
1676
+ padding: "8px",
1677
+ border: "1px solid #ddd",
1678
+ borderRadius: "6px",
1679
+ fontSize: "13px",
1680
+ }}
1681
+ >
1682
+ <option value="">Select workspace...</option>
1683
+ {wsInfo.workspaces.map((w: any) => (
1684
+ <option key={w.id} value={w.id}>
1685
+ {w.name} ({w.slug})
1686
+ </option>
1687
+ ))}
1688
+ </select>
1689
+ ) : (
1690
+ <p style={{ fontSize: "13px", color: "#999" }}>
1691
+ Connect to see available workspaces
1692
+ </p>
1693
+ )}
1694
+ </div>
1695
+
1696
+ {/* Project Info */}
1697
+ {settingsData.project && (
1698
+ <div style={{ marginBottom: "24px" }}>
1699
+ <h3
1700
+ style={{
1701
+ fontSize: "13px",
1702
+ fontWeight: 600,
1703
+ textTransform: "uppercase",
1704
+ letterSpacing: "0.05em",
1705
+ color: "#666",
1706
+ marginBottom: "12px",
1707
+ }}
1708
+ >
1709
+ Project
1710
+ </h3>
1711
+ <div
1712
+ style={{
1713
+ background: "#f8f8f8",
1714
+ borderRadius: "6px",
1715
+ padding: "12px",
1716
+ fontSize: "12px",
1717
+ fontFamily: "monospace",
1718
+ }}
1719
+ >
1720
+ {Object.entries(settingsData.project).map(([k, v]) => (
1721
+ <div key={k} style={{ marginBottom: "4px" }}>
1722
+ <span style={{ color: "#666" }}>{k}:</span>{" "}
1723
+ <span>
1724
+ {typeof v === "object" ? JSON.stringify(v) : String(v)}
1725
+ </span>
1726
+ </div>
1727
+ ))}
1728
+ </div>
1729
+ </div>
1730
+ )}
1731
+
1732
+ {/* Save */}
1733
+ <button
1734
+ type="button"
1735
+ disabled={settingsData.saving}
1736
+ onClick={async () => {
1737
+ setSettingsData((s) => ({ ...s, saving: true }));
1738
+ const body: any = { apiUrl: settingsData.newApiUrl };
1739
+ if (settingsData.newToken)
1740
+ body.apiToken = settingsData.newToken;
1741
+ if (settingsData.newWorkspaceId)
1742
+ body.workspaceId = settingsData.newWorkspaceId;
1743
+ try {
1744
+ await fetch("/api/config", {
1745
+ method: "PUT",
1746
+ headers: { "Content-Type": "application/json" },
1747
+ body: JSON.stringify(body),
1748
+ });
1749
+ setSettingsData((s) => ({
1750
+ ...s,
1751
+ saving: false,
1752
+ newToken: "",
1753
+ }));
1754
+ const res = await fetch("/api/workspaces");
1755
+ const data = await res.json();
1756
+ setWsInfo(data);
1757
+ const cfgRes = await fetch("/api/config");
1758
+ const cfgData = await cfgRes.json();
1759
+ setSettingsData((s) => ({
1760
+ ...s,
1761
+ hasToken: cfgData.env.hasToken,
1762
+ maskedToken: cfgData.env.maskedToken,
1763
+ workspaceId: cfgData.env.workspaceId,
1764
+ }));
1765
+ } catch {
1766
+ setSettingsData((s) => ({ ...s, saving: false }));
1767
+ }
1768
+ }}
1769
+ style={{
1770
+ width: "100%",
1771
+ padding: "10px",
1772
+ background: "#667eea",
1773
+ color: "#fff",
1774
+ border: "none",
1775
+ borderRadius: "6px",
1776
+ fontSize: "14px",
1777
+ fontWeight: 500,
1778
+ cursor: "pointer",
1779
+ }}
1780
+ >
1781
+ {settingsData.saving ? "Saving..." : "Save Settings"}
1782
+ </button>
1783
+ </div>
1784
+ </div>
1785
+ )}
1786
+ </div>
1787
+ );
1788
+ }