@cmssy/cli 0.10.1 → 0.12.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 (101) hide show
  1. package/config.d.ts +2 -2
  2. package/config.js +1 -1
  3. package/dist/cli.js +84 -32
  4. package/dist/cli.js.map +1 -1
  5. package/dist/commands/add-source.d.ts +6 -0
  6. package/dist/commands/add-source.d.ts.map +1 -1
  7. package/dist/commands/add-source.js +37 -2
  8. package/dist/commands/add-source.js.map +1 -1
  9. package/dist/commands/build.d.ts.map +1 -1
  10. package/dist/commands/build.js +23 -1
  11. package/dist/commands/build.js.map +1 -1
  12. package/dist/commands/configure.d.ts.map +1 -1
  13. package/dist/commands/configure.js +0 -3
  14. package/dist/commands/configure.js.map +1 -1
  15. package/dist/commands/dev.d.ts.map +1 -1
  16. package/dist/commands/dev.js +20 -0
  17. package/dist/commands/dev.js.map +1 -1
  18. package/dist/commands/doctor.d.ts +2 -0
  19. package/dist/commands/doctor.d.ts.map +1 -0
  20. package/dist/commands/doctor.js +223 -0
  21. package/dist/commands/doctor.js.map +1 -0
  22. package/dist/commands/init.d.ts.map +1 -1
  23. package/dist/commands/init.js +5 -12
  24. package/dist/commands/init.js.map +1 -1
  25. package/dist/commands/link.d.ts +8 -0
  26. package/dist/commands/link.d.ts.map +1 -0
  27. package/dist/commands/link.js +121 -0
  28. package/dist/commands/link.js.map +1 -0
  29. package/dist/commands/package.d.ts +7 -0
  30. package/dist/commands/package.d.ts.map +1 -1
  31. package/dist/commands/package.js +2 -2
  32. package/dist/commands/package.js.map +1 -1
  33. package/dist/commands/publish.d.ts +3 -0
  34. package/dist/commands/publish.d.ts.map +1 -1
  35. package/dist/commands/publish.js +222 -4
  36. package/dist/commands/publish.js.map +1 -1
  37. package/dist/commands/sync.js +1 -1
  38. package/dist/commands/sync.js.map +1 -1
  39. package/dist/commands/test.d.ts +8 -0
  40. package/dist/commands/test.d.ts.map +1 -0
  41. package/dist/commands/test.js +60 -0
  42. package/dist/commands/test.js.map +1 -0
  43. package/dist/commands/upload.d.ts +4 -0
  44. package/dist/commands/upload.d.ts.map +1 -1
  45. package/dist/commands/upload.js +6 -4
  46. package/dist/commands/upload.js.map +1 -1
  47. package/dist/commands/workspaces.d.ts.map +1 -1
  48. package/dist/commands/workspaces.js +1 -1
  49. package/dist/commands/workspaces.js.map +1 -1
  50. package/dist/utils/block-config.d.ts +7 -1
  51. package/dist/utils/block-config.d.ts.map +1 -1
  52. package/dist/utils/block-config.js +5 -3
  53. package/dist/utils/block-config.js.map +1 -1
  54. package/dist/utils/config.js +17 -12
  55. package/dist/utils/config.js.map +1 -1
  56. package/dist/utils/dependency-check.d.ts +21 -0
  57. package/dist/utils/dependency-check.d.ts.map +1 -0
  58. package/dist/utils/dependency-check.js +56 -0
  59. package/dist/utils/dependency-check.js.map +1 -0
  60. package/dist/utils/dev-generator/home-page.js +2 -2
  61. package/dist/utils/dev-generator/index.d.ts +1 -2
  62. package/dist/utils/dev-generator/index.d.ts.map +1 -1
  63. package/dist/utils/dev-generator/index.js +53 -21
  64. package/dist/utils/dev-generator/index.js.map +1 -1
  65. package/dist/utils/dev-generator/preview-pages.js +8 -2
  66. package/dist/utils/dev-generator/preview-pages.js.map +1 -1
  67. package/dist/utils/field-schema.d.ts.map +1 -1
  68. package/dist/utils/field-schema.js +0 -16
  69. package/dist/utils/field-schema.js.map +1 -1
  70. package/dist/utils/graphql.d.ts +3 -1
  71. package/dist/utils/graphql.d.ts.map +1 -1
  72. package/dist/utils/graphql.js +52 -1
  73. package/dist/utils/graphql.js.map +1 -1
  74. package/dist/utils/publish-helpers.js +1 -1
  75. package/dist/utils/publish-helpers.js.map +1 -1
  76. package/dist/utils/scanner.d.ts +6 -0
  77. package/dist/utils/scanner.d.ts.map +1 -1
  78. package/dist/utils/scanner.js +13 -0
  79. package/dist/utils/scanner.js.map +1 -1
  80. package/dist/utils/schema-diff.d.ts +22 -0
  81. package/dist/utils/schema-diff.d.ts.map +1 -0
  82. package/dist/utils/schema-diff.js +107 -0
  83. package/dist/utils/schema-diff.js.map +1 -0
  84. package/dist/utils/theme-builder.d.ts +17 -0
  85. package/dist/utils/theme-builder.d.ts.map +1 -0
  86. package/dist/utils/theme-builder.js +160 -0
  87. package/dist/utils/theme-builder.js.map +1 -0
  88. package/dist/utils/type-generator.d.ts.map +1 -1
  89. package/dist/utils/type-generator.js +0 -2
  90. package/dist/utils/type-generator.js.map +1 -1
  91. package/package.json +7 -3
  92. package/src/dev-app/app/api/blocks/[name]/config/route.ts +140 -0
  93. package/src/dev-app/app/api/blocks/route.ts +61 -0
  94. package/src/dev-app/app/api/config/route.ts +98 -0
  95. package/src/dev-app/app/api/context/route.ts +126 -0
  96. package/src/dev-app/app/api/preview/[name]/route.ts +137 -0
  97. package/src/dev-app/app/api/workspaces/route.ts +124 -0
  98. package/src/dev-app/app/layout.tsx +18 -0
  99. package/src/dev-app/app/page.tsx +1785 -0
  100. package/test.d.ts +1 -0
  101. package/test.js +2 -0
@@ -0,0 +1,1785 @@
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, 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" || field.type === "pageSelector"
568
+ ? "Enter ID..."
569
+ : "")
570
+ }
571
+ style={{
572
+ width: "100%",
573
+ padding: "8px",
574
+ border: "1px solid #ddd",
575
+ borderRadius: "4px",
576
+ fontSize: "13px",
577
+ }}
578
+ />
579
+ );
580
+ }
581
+
582
+ return (
583
+ <div
584
+ style={{
585
+ display: "grid",
586
+ gridTemplateColumns: `${showBlockList ? "280px" : "0px"} 1fr ${showEditor ? "400px" : "0px"}`,
587
+ height: "100vh",
588
+ transition: "grid-template-columns 0.2s ease",
589
+ }}
590
+ >
591
+ {/* Block List */}
592
+ <div
593
+ style={{
594
+ background: "#fff",
595
+ borderRight: showBlockList ? "1px solid #e0e0e0" : "none",
596
+ overflow: showBlockList ? "auto" : "hidden",
597
+ width: showBlockList ? "auto" : 0,
598
+ }}
599
+ >
600
+ <div
601
+ style={{
602
+ padding: "16px",
603
+ borderBottom: "1px solid #e0e0e0",
604
+ background: "#fafafa",
605
+ }}
606
+ >
607
+ <h1 style={{ fontSize: "18px", fontWeight: 600, margin: 0 }}>
608
+ Cmssy Dev
609
+ </h1>
610
+ <p style={{ fontSize: "13px", color: "#666", margin: "4px 0 0" }}>
611
+ {blocks.length} blocks
612
+ </p>
613
+ </div>
614
+ {/* Workspace Connection */}
615
+ <div
616
+ style={{
617
+ padding: "12px 16px",
618
+ borderBottom: "1px solid #e0e0e0",
619
+ fontSize: "12px",
620
+ }}
621
+ >
622
+ {wsLoading ? (
623
+ <div style={{ color: "#999" }}>Connecting...</div>
624
+ ) : wsInfo?.connected ? (
625
+ <div
626
+ style={{
627
+ display: "flex",
628
+ alignItems: "center",
629
+ justifyContent: "space-between",
630
+ }}
631
+ >
632
+ <div
633
+ style={{ display: "flex", alignItems: "center", gap: "6px" }}
634
+ >
635
+ <div
636
+ style={{
637
+ width: "8px",
638
+ height: "8px",
639
+ borderRadius: "50%",
640
+ background: "#22c55e",
641
+ flexShrink: 0,
642
+ }}
643
+ />
644
+ <span style={{ fontWeight: 500 }}>
645
+ {wsInfo.workspace?.name}
646
+ </span>
647
+ </div>
648
+ <button
649
+ type="button"
650
+ onClick={() => {
651
+ setWsLoading(true);
652
+ fetch("/api/workspaces")
653
+ .then((r) => r.json())
654
+ .then((data) => {
655
+ setWsInfo(data);
656
+ setWsLoading(false);
657
+ })
658
+ .catch(() => setWsLoading(false));
659
+ }}
660
+ style={{
661
+ background: "none",
662
+ border: "none",
663
+ cursor: "pointer",
664
+ color: "#667eea",
665
+ fontSize: "11px",
666
+ padding: "2px 4px",
667
+ }}
668
+ title="Refresh"
669
+ >
670
+ {"\u21BB"}
671
+ </button>
672
+ </div>
673
+ ) : (
674
+ <div style={{ display: "flex", alignItems: "center", gap: "6px" }}>
675
+ <div
676
+ style={{
677
+ width: "8px",
678
+ height: "8px",
679
+ borderRadius: "50%",
680
+ background: "#ef4444",
681
+ flexShrink: 0,
682
+ }}
683
+ />
684
+ <span style={{ color: "#999" }}>
685
+ {wsInfo?.reason === "no_token"
686
+ ? "Not configured"
687
+ : wsInfo?.reason === "auth_error"
688
+ ? "Invalid token"
689
+ : "Disconnected"}
690
+ </span>
691
+ </div>
692
+ )}
693
+ </div>
694
+ <div style={{ padding: "12px" }}>
695
+ {blocks.map((b) => (
696
+ <div
697
+ key={b.name}
698
+ onClick={() => {
699
+ handleSelect(b);
700
+ const url = new URL(window.location.href);
701
+ url.searchParams.set(
702
+ b.type === "template" ? "template" : "block",
703
+ b.name,
704
+ );
705
+ window.history.replaceState({}, "", url.toString());
706
+ }}
707
+ style={{
708
+ padding: "12px 16px",
709
+ marginBottom: "4px",
710
+ borderRadius: "8px",
711
+ cursor: "pointer",
712
+ background:
713
+ selected?.name === b.name ? "#667eea" : "transparent",
714
+ color: selected?.name === b.name ? "white" : "inherit",
715
+ }}
716
+ >
717
+ <div
718
+ style={{
719
+ display: "flex",
720
+ alignItems: "center",
721
+ justifyContent: "space-between",
722
+ }}
723
+ >
724
+ <div style={{ fontSize: "14px", fontWeight: 500 }}>
725
+ {b.displayName}
726
+ </div>
727
+ {wsInfo?.connected &&
728
+ (() => {
729
+ const status = getSyncStatus(b.name, b.version, wsInfo);
730
+ const colors: Record<
731
+ SyncStatus,
732
+ { bg: string; text: string; label: string }
733
+ > = {
734
+ published: {
735
+ bg:
736
+ selected?.name === b.name
737
+ ? "rgba(255,255,255,0.2)"
738
+ : "#dcfce7",
739
+ text: selected?.name === b.name ? "#fff" : "#166534",
740
+ label: "\u2713",
741
+ },
742
+ outdated: {
743
+ bg:
744
+ selected?.name === b.name
745
+ ? "rgba(255,255,255,0.2)"
746
+ : "#fef3c7",
747
+ text: selected?.name === b.name ? "#fff" : "#92400e",
748
+ label: "\u2191",
749
+ },
750
+ "local-only": {
751
+ bg: "transparent",
752
+ text: "transparent",
753
+ label: "",
754
+ },
755
+ };
756
+ const c = colors[status];
757
+ if (status === "local-only") return null;
758
+ return (
759
+ <span
760
+ title={status}
761
+ style={{
762
+ fontSize: "10px",
763
+ padding: "1px 6px",
764
+ borderRadius: "4px",
765
+ background: c.bg,
766
+ color: c.text,
767
+ fontWeight: 600,
768
+ }}
769
+ >
770
+ {c.label}
771
+ </span>
772
+ );
773
+ })()}
774
+ </div>
775
+ <div style={{ fontSize: "12px", opacity: 0.7 }}>
776
+ {b.type} &middot; v{b.version}
777
+ </div>
778
+ </div>
779
+ ))}
780
+ </div>
781
+ </div>
782
+
783
+ {/* Preview */}
784
+ <div
785
+ style={{
786
+ background: "#fafafa",
787
+ display: "flex",
788
+ flexDirection: "column",
789
+ }}
790
+ >
791
+ <div
792
+ style={{
793
+ padding: "10px 16px",
794
+ background: "white",
795
+ borderBottom: "1px solid #e0e0e0",
796
+ display: "flex",
797
+ alignItems: "center",
798
+ justifyContent: "space-between",
799
+ gap: "12px",
800
+ }}
801
+ >
802
+ <button
803
+ type="button"
804
+ onClick={() => setShowBlockList(!showBlockList)}
805
+ title={showBlockList ? "Hide block list" : "Show block list"}
806
+ style={{
807
+ background: showBlockList ? "#f0f0f0" : "#667eea",
808
+ color: showBlockList ? "#333" : "#fff",
809
+ border: "1px solid #ddd",
810
+ borderRadius: "6px",
811
+ padding: "6px 10px",
812
+ cursor: "pointer",
813
+ fontSize: "13px",
814
+ fontWeight: 500,
815
+ whiteSpace: "nowrap",
816
+ }}
817
+ >
818
+ {showBlockList ? "\u2190 Blocks" : "\u2192 Blocks"}
819
+ </button>
820
+ <div
821
+ style={{
822
+ flex: 1,
823
+ fontSize: "16px",
824
+ fontWeight: 600,
825
+ textAlign: "center",
826
+ overflow: "hidden",
827
+ textOverflow: "ellipsis",
828
+ whiteSpace: "nowrap",
829
+ }}
830
+ >
831
+ {selected?.displayName || "Preview"}
832
+ </div>
833
+ <button
834
+ type="button"
835
+ onClick={() => setShowEditor(!showEditor)}
836
+ title={showEditor ? "Hide editor" : "Show editor"}
837
+ style={{
838
+ background: showEditor ? "#f0f0f0" : "#667eea",
839
+ color: showEditor ? "#333" : "#fff",
840
+ border: "1px solid #ddd",
841
+ borderRadius: "6px",
842
+ padding: "6px 10px",
843
+ cursor: "pointer",
844
+ fontSize: "13px",
845
+ fontWeight: 500,
846
+ whiteSpace: "nowrap",
847
+ }}
848
+ >
849
+ {showEditor ? "Editor \u2192" : "Editor \u2190"}
850
+ </button>
851
+ <button
852
+ type="button"
853
+ onClick={() => setShowSettings(!showSettings)}
854
+ title="Settings"
855
+ style={{
856
+ background: showSettings ? "#667eea" : "#f0f0f0",
857
+ color: showSettings ? "#fff" : "#333",
858
+ border: "1px solid #ddd",
859
+ borderRadius: "6px",
860
+ padding: "6px 10px",
861
+ cursor: "pointer",
862
+ fontSize: "15px",
863
+ lineHeight: 1,
864
+ }}
865
+ >
866
+ {"\u2699"}
867
+ </button>
868
+ </div>
869
+ {/* Responsive viewport toolbar */}
870
+ {previewUrl && (
871
+ <div
872
+ style={{
873
+ padding: "6px 16px",
874
+ background: "#f8f8f8",
875
+ borderBottom: "1px solid #e0e0e0",
876
+ display: "flex",
877
+ flexWrap: "wrap",
878
+ alignItems: "center",
879
+ gap: "4px",
880
+ rowGap: "6px",
881
+ fontSize: "12px",
882
+ }}
883
+ >
884
+ <button
885
+ type="button"
886
+ aria-pressed={viewport === null}
887
+ onClick={() => setViewport(null)}
888
+ style={{
889
+ padding: "4px 10px",
890
+ border: "1px solid #ddd",
891
+ borderRadius: "4px",
892
+ background: viewport === null ? "#667eea" : "#fff",
893
+ color: viewport === null ? "#fff" : "#333",
894
+ cursor: "pointer",
895
+ fontSize: "12px",
896
+ fontWeight: 500,
897
+ }}
898
+ >
899
+ Full
900
+ </button>
901
+ {VIEWPORT_PRESETS.map((preset) => (
902
+ <button
903
+ key={preset.width}
904
+ type="button"
905
+ aria-pressed={viewport === preset.width}
906
+ onClick={() => setViewport(preset.width)}
907
+ style={{
908
+ padding: "4px 10px",
909
+ border: "1px solid #ddd",
910
+ borderRadius: "4px",
911
+ background: viewport === preset.width ? "#667eea" : "#fff",
912
+ color: viewport === preset.width ? "#fff" : "#333",
913
+ cursor: "pointer",
914
+ fontSize: "12px",
915
+ fontWeight: 500,
916
+ }}
917
+ >
918
+ {preset.label} {preset.width}
919
+ </button>
920
+ ))}
921
+ <div
922
+ style={{
923
+ display: "flex",
924
+ alignItems: "center",
925
+ gap: "4px",
926
+ marginLeft: "8px",
927
+ }}
928
+ >
929
+ <input
930
+ type="number"
931
+ value={viewport ?? ""}
932
+ aria-label="Viewport width in pixels"
933
+ onChange={(e) => {
934
+ const parsed = parseInt(e.target.value, 10);
935
+ const v =
936
+ Number.isFinite(parsed) && parsed > 0 ? parsed : null;
937
+ setViewport(v);
938
+ }}
939
+ placeholder="Custom"
940
+ style={{
941
+ width: "72px",
942
+ padding: "4px 6px",
943
+ border: "1px solid #ddd",
944
+ borderRadius: "4px",
945
+ fontSize: "12px",
946
+ textAlign: "center",
947
+ }}
948
+ />
949
+ <span style={{ color: "#999" }}>px</span>
950
+ </div>
951
+ </div>
952
+ )}
953
+ <div
954
+ style={{
955
+ flex: 1,
956
+ padding: "24px",
957
+ display: "flex",
958
+ alignItems: "center",
959
+ justifyContent: "center",
960
+ overflow: "auto",
961
+ }}
962
+ >
963
+ {previewUrl ? (
964
+ <div
965
+ style={{
966
+ width:
967
+ viewport !== null && viewport > 0 ? `${viewport}px` : "100%",
968
+ maxWidth: "100%",
969
+ height: "100%",
970
+ background: "white",
971
+ borderRadius: "12px",
972
+ boxShadow: "0 2px 8px rgba(0,0,0,0.1)",
973
+ overflow: "hidden",
974
+ transition: "width 0.2s ease",
975
+ }}
976
+ >
977
+ <iframe
978
+ ref={iframeRef}
979
+ src={previewUrl}
980
+ key={previewUrl}
981
+ onLoad={() => {
982
+ iframeLoadedRef.current = true;
983
+ }}
984
+ style={{ width: "100%", height: "100%", border: "none" }}
985
+ />
986
+ </div>
987
+ ) : (
988
+ <div style={{ textAlign: "center", color: "#999" }}>
989
+ <p>Select a block to preview</p>
990
+ </div>
991
+ )}
992
+ </div>
993
+ </div>
994
+
995
+ {/* Editor */}
996
+ <div
997
+ style={{
998
+ background: "#fff",
999
+ borderLeft: showEditor ? "1px solid #e0e0e0" : "none",
1000
+ overflow: showEditor ? "auto" : "hidden",
1001
+ width: showEditor ? "auto" : 0,
1002
+ }}
1003
+ >
1004
+ <div
1005
+ style={{
1006
+ padding: "16px",
1007
+ borderBottom: "1px solid #e0e0e0",
1008
+ background: "#fafafa",
1009
+ }}
1010
+ >
1011
+ <div style={{ display: "flex", gap: "0" }}>
1012
+ {(["content", "context"] as const).map((tab) => (
1013
+ <button
1014
+ key={tab}
1015
+ type="button"
1016
+ onClick={() => setEditorTab(tab)}
1017
+ style={{
1018
+ padding: "6px 16px",
1019
+ border: "none",
1020
+ background: editorTab === tab ? "#fff" : "transparent",
1021
+ borderBottom:
1022
+ editorTab === tab
1023
+ ? "2px solid #667eea"
1024
+ : "2px solid transparent",
1025
+ fontSize: "14px",
1026
+ fontWeight: editorTab === tab ? 600 : 400,
1027
+ cursor: "pointer",
1028
+ color: editorTab === tab ? "#333" : "#888",
1029
+ textTransform: "capitalize",
1030
+ }}
1031
+ >
1032
+ {tab}
1033
+ </button>
1034
+ ))}
1035
+ </div>
1036
+ </div>
1037
+ {/* Variant picker */}
1038
+ {selected && (
1039
+ <div
1040
+ style={{
1041
+ padding: "8px 16px",
1042
+ borderBottom: "1px solid #e0e0e0",
1043
+ display: "flex",
1044
+ flexWrap: "wrap",
1045
+ alignItems: "center",
1046
+ gap: "4px",
1047
+ fontSize: "12px",
1048
+ }}
1049
+ >
1050
+ <button
1051
+ type="button"
1052
+ onClick={() => setCurrentVariant(null)}
1053
+ style={{
1054
+ padding: "3px 8px",
1055
+ border: "1px solid #ddd",
1056
+ borderRadius: "4px",
1057
+ background: currentVariant === null ? "#667eea" : "#fff",
1058
+ color: currentVariant === null ? "#fff" : "#333",
1059
+ cursor: "pointer",
1060
+ fontSize: "11px",
1061
+ fontWeight: 500,
1062
+ }}
1063
+ >
1064
+ Default
1065
+ </button>
1066
+ {variants.map((v) => (
1067
+ <button
1068
+ key={v}
1069
+ type="button"
1070
+ onClick={() => setCurrentVariant(v)}
1071
+ style={{
1072
+ padding: "3px 8px",
1073
+ border: "1px solid #ddd",
1074
+ borderRadius: "4px",
1075
+ background: currentVariant === v ? "#667eea" : "#fff",
1076
+ color: currentVariant === v ? "#fff" : "#333",
1077
+ cursor: "pointer",
1078
+ fontSize: "11px",
1079
+ fontWeight: 500,
1080
+ }}
1081
+ >
1082
+ {v}
1083
+ </button>
1084
+ ))}
1085
+ <button
1086
+ type="button"
1087
+ onClick={async () => {
1088
+ const name = prompt("Variant name (e.g., long-text):");
1089
+ if (!name) return;
1090
+ const dataToSave = { ...configDataRef.current, ...previewData };
1091
+ try {
1092
+ const res = await fetch(
1093
+ `/api/preview/${selected.name}?action=save-variant`,
1094
+ {
1095
+ method: "POST",
1096
+ headers: { "Content-Type": "application/json" },
1097
+ body: JSON.stringify({
1098
+ variantName: name,
1099
+ data: dataToSave,
1100
+ }),
1101
+ },
1102
+ );
1103
+ if (!res.ok) {
1104
+ const err = await res.json().catch(() => ({}));
1105
+ alert(err.error || "Failed to save variant");
1106
+ return;
1107
+ }
1108
+ setVariants((prev) =>
1109
+ prev.includes(name) ? prev : [...prev, name],
1110
+ );
1111
+ setCurrentVariant(name);
1112
+ } catch {
1113
+ alert("Failed to save variant");
1114
+ }
1115
+ }}
1116
+ style={{
1117
+ padding: "3px 8px",
1118
+ border: "1px dashed #ccc",
1119
+ borderRadius: "4px",
1120
+ background: "transparent",
1121
+ color: "#667eea",
1122
+ cursor: "pointer",
1123
+ fontSize: "11px",
1124
+ fontWeight: 500,
1125
+ }}
1126
+ >
1127
+ + Save as...
1128
+ </button>
1129
+ </div>
1130
+ )}
1131
+ {/* Content tab */}
1132
+ {editorTab === "content" && (
1133
+ <div style={{ padding: "20px" }}>
1134
+ {!selected && (
1135
+ <p style={{ color: "#999" }}>Select a block to edit</p>
1136
+ )}
1137
+ {selected && configLoading && (
1138
+ <p style={{ color: "#999" }}>Loading...</p>
1139
+ )}
1140
+ {selected && !configLoading && selected.schema && (
1141
+ <div>
1142
+ {Object.entries(selected.schema).map(
1143
+ ([key, field]: [string, any]) => (
1144
+ <div key={key} style={{ marginBottom: "20px" }}>
1145
+ <label
1146
+ style={{
1147
+ display: "block",
1148
+ fontSize: "13px",
1149
+ fontWeight: 500,
1150
+ marginBottom: "6px",
1151
+ }}
1152
+ >
1153
+ {field.label || key}
1154
+ {field.required && (
1155
+ <span style={{ color: "#e53935" }}> *</span>
1156
+ )}
1157
+ </label>
1158
+ {renderField(field, previewData[key], (val) => {
1159
+ setPreviewData({ ...previewData, [key]: val });
1160
+ setIsDirty(true);
1161
+ })}
1162
+ {field.helpText && (
1163
+ <div
1164
+ style={{
1165
+ fontSize: "12px",
1166
+ color: "#666",
1167
+ marginTop: "4px",
1168
+ }}
1169
+ >
1170
+ {field.helpText}
1171
+ </div>
1172
+ )}
1173
+ </div>
1174
+ ),
1175
+ )}
1176
+ </div>
1177
+ )}
1178
+ </div>
1179
+ )}
1180
+
1181
+ {/* Context tab */}
1182
+ {editorTab === "context" && (
1183
+ <div style={{ padding: "20px" }}>
1184
+ {/* Presets */}
1185
+ <div style={{ marginBottom: "20px" }}>
1186
+ <label
1187
+ style={{
1188
+ display: "block",
1189
+ fontSize: "13px",
1190
+ fontWeight: 600,
1191
+ marginBottom: "8px",
1192
+ }}
1193
+ >
1194
+ Presets
1195
+ </label>
1196
+ <div style={{ display: "flex", flexWrap: "wrap", gap: "4px" }}>
1197
+ {contextPresets.map((preset) => (
1198
+ <button
1199
+ key={preset}
1200
+ type="button"
1201
+ onClick={async () => {
1202
+ const res = await fetch(
1203
+ `/api/context?preset=${encodeURIComponent(preset)}`,
1204
+ );
1205
+ const data = await res.json();
1206
+ setMockContext(data);
1207
+ }}
1208
+ style={{
1209
+ padding: "4px 10px",
1210
+ border: "1px solid #ddd",
1211
+ borderRadius: "4px",
1212
+ background: "#fff",
1213
+ cursor: "pointer",
1214
+ fontSize: "12px",
1215
+ }}
1216
+ >
1217
+ {preset}
1218
+ </button>
1219
+ ))}
1220
+ </div>
1221
+ </div>
1222
+
1223
+ {/* Locale */}
1224
+ <div style={{ marginBottom: "20px" }}>
1225
+ <label
1226
+ style={{
1227
+ display: "block",
1228
+ fontSize: "13px",
1229
+ fontWeight: 600,
1230
+ marginBottom: "8px",
1231
+ }}
1232
+ >
1233
+ Locale
1234
+ </label>
1235
+ <div style={{ marginBottom: "8px" }}>
1236
+ <label
1237
+ style={{
1238
+ display: "block",
1239
+ fontSize: "12px",
1240
+ color: "#666",
1241
+ marginBottom: "4px",
1242
+ }}
1243
+ >
1244
+ Current language
1245
+ </label>
1246
+ <input
1247
+ type="text"
1248
+ value={mockContext.locale?.current || "en"}
1249
+ onChange={(e) => {
1250
+ const updated = {
1251
+ ...mockContext,
1252
+ locale: {
1253
+ ...mockContext.locale,
1254
+ current: e.target.value,
1255
+ },
1256
+ };
1257
+ setMockContext(updated);
1258
+ }}
1259
+ style={{
1260
+ width: "100%",
1261
+ padding: "6px 8px",
1262
+ border: "1px solid #ddd",
1263
+ borderRadius: "4px",
1264
+ fontSize: "13px",
1265
+ boxSizing: "border-box",
1266
+ }}
1267
+ />
1268
+ </div>
1269
+ <div style={{ marginBottom: "8px" }}>
1270
+ <label
1271
+ style={{
1272
+ display: "block",
1273
+ fontSize: "12px",
1274
+ color: "#666",
1275
+ marginBottom: "4px",
1276
+ }}
1277
+ >
1278
+ Enabled languages (comma-separated)
1279
+ </label>
1280
+ <input
1281
+ type="text"
1282
+ value={(mockContext.locale?.enabled || ["en"]).join(", ")}
1283
+ onChange={(e) => {
1284
+ const langs = e.target.value
1285
+ .split(",")
1286
+ .map((s: string) => s.trim())
1287
+ .filter(Boolean);
1288
+ const updated = {
1289
+ ...mockContext,
1290
+ locale: { ...mockContext.locale, enabled: langs },
1291
+ };
1292
+ setMockContext(updated);
1293
+ }}
1294
+ style={{
1295
+ width: "100%",
1296
+ padding: "6px 8px",
1297
+ border: "1px solid #ddd",
1298
+ borderRadius: "4px",
1299
+ fontSize: "13px",
1300
+ boxSizing: "border-box",
1301
+ }}
1302
+ />
1303
+ </div>
1304
+ </div>
1305
+
1306
+ {/* Auth */}
1307
+ <div style={{ marginBottom: "20px" }}>
1308
+ <label
1309
+ style={{
1310
+ display: "flex",
1311
+ alignItems: "center",
1312
+ gap: "8px",
1313
+ fontSize: "13px",
1314
+ fontWeight: 600,
1315
+ marginBottom: "8px",
1316
+ cursor: "pointer",
1317
+ }}
1318
+ >
1319
+ <input
1320
+ type="checkbox"
1321
+ checked={!!mockContext.auth?.isAuthenticated}
1322
+ onChange={(e) => {
1323
+ const updated = e.target.checked
1324
+ ? {
1325
+ ...mockContext,
1326
+ auth: {
1327
+ isAuthenticated: true,
1328
+ member: mockContext.auth?.member || {
1329
+ id: "dev-member-1",
1330
+ email: "user@example.com",
1331
+ profile: {
1332
+ firstName: "Jane",
1333
+ lastName: "Doe",
1334
+ displayName: "Jane Doe",
1335
+ avatarUrl: "",
1336
+ },
1337
+ role: "member",
1338
+ verified: true,
1339
+ },
1340
+ },
1341
+ }
1342
+ : { ...mockContext, auth: null };
1343
+ setMockContext(updated);
1344
+ }}
1345
+ />
1346
+ Authenticated
1347
+ </label>
1348
+ {mockContext.auth?.isAuthenticated && (
1349
+ <div style={{ paddingLeft: "4px" }}>
1350
+ {(["email", "role"] as const).map((field) => (
1351
+ <div key={field} style={{ marginBottom: "6px" }}>
1352
+ <label
1353
+ style={{
1354
+ display: "block",
1355
+ fontSize: "12px",
1356
+ color: "#666",
1357
+ marginBottom: "2px",
1358
+ textTransform: "capitalize",
1359
+ }}
1360
+ >
1361
+ {field}
1362
+ </label>
1363
+ <input
1364
+ type="text"
1365
+ value={
1366
+ field === "email"
1367
+ ? mockContext.auth?.member?.email || ""
1368
+ : mockContext.auth?.member?.role || ""
1369
+ }
1370
+ onChange={(e) => {
1371
+ const updated = {
1372
+ ...mockContext,
1373
+ auth: {
1374
+ ...mockContext.auth,
1375
+ member: {
1376
+ ...mockContext.auth?.member,
1377
+ [field]: e.target.value,
1378
+ },
1379
+ },
1380
+ };
1381
+ setMockContext(updated);
1382
+ }}
1383
+ style={{
1384
+ width: "100%",
1385
+ padding: "4px 8px",
1386
+ border: "1px solid #ddd",
1387
+ borderRadius: "4px",
1388
+ fontSize: "12px",
1389
+ boxSizing: "border-box",
1390
+ }}
1391
+ />
1392
+ </div>
1393
+ ))}
1394
+ </div>
1395
+ )}
1396
+ </div>
1397
+
1398
+ {/* Raw JSON editor */}
1399
+ <div style={{ marginBottom: "20px" }}>
1400
+ <label
1401
+ style={{
1402
+ display: "block",
1403
+ fontSize: "13px",
1404
+ fontWeight: 600,
1405
+ marginBottom: "8px",
1406
+ }}
1407
+ >
1408
+ Full context (JSON)
1409
+ </label>
1410
+ <textarea
1411
+ value={JSON.stringify(mockContext, null, 2)}
1412
+ onChange={(e) => {
1413
+ try {
1414
+ const parsed = JSON.parse(e.target.value);
1415
+ setMockContext(parsed);
1416
+ } catch {
1417
+ // Invalid JSON, don't update
1418
+ }
1419
+ }}
1420
+ style={{
1421
+ width: "100%",
1422
+ minHeight: "200px",
1423
+ padding: "8px",
1424
+ border: "1px solid #ddd",
1425
+ borderRadius: "4px",
1426
+ fontSize: "12px",
1427
+ fontFamily: "monospace",
1428
+ resize: "vertical",
1429
+ boxSizing: "border-box",
1430
+ }}
1431
+ />
1432
+ </div>
1433
+ </div>
1434
+ )}
1435
+ </div>
1436
+
1437
+ {/* Settings Panel (slide-over) */}
1438
+ {showSettings && (
1439
+ <div
1440
+ style={{
1441
+ position: "fixed",
1442
+ top: 0,
1443
+ right: 0,
1444
+ bottom: 0,
1445
+ width: "420px",
1446
+ background: "#fff",
1447
+ boxShadow: "-4px 0 24px rgba(0,0,0,0.12)",
1448
+ zIndex: 1000,
1449
+ display: "flex",
1450
+ flexDirection: "column",
1451
+ animation: "slideIn 0.2s ease",
1452
+ }}
1453
+ >
1454
+ <style>{`@keyframes slideIn { from { transform: translateX(100%); } to { transform: translateX(0); } }`}</style>
1455
+ <div
1456
+ style={{
1457
+ padding: "16px 20px",
1458
+ borderBottom: "1px solid #e0e0e0",
1459
+ display: "flex",
1460
+ justifyContent: "space-between",
1461
+ alignItems: "center",
1462
+ background: "#fafafa",
1463
+ }}
1464
+ >
1465
+ <h2 style={{ fontSize: "16px", fontWeight: 600, margin: 0 }}>
1466
+ Settings
1467
+ </h2>
1468
+ <button
1469
+ type="button"
1470
+ onClick={() => setShowSettings(false)}
1471
+ style={{
1472
+ background: "none",
1473
+ border: "none",
1474
+ fontSize: "20px",
1475
+ cursor: "pointer",
1476
+ color: "#666",
1477
+ padding: "4px",
1478
+ }}
1479
+ >
1480
+ {"\u2715"}
1481
+ </button>
1482
+ </div>
1483
+ <div style={{ flex: 1, overflow: "auto", padding: "20px" }}>
1484
+ {/* Connection */}
1485
+ <div style={{ marginBottom: "24px" }}>
1486
+ <h3
1487
+ style={{
1488
+ fontSize: "13px",
1489
+ fontWeight: 600,
1490
+ textTransform: "uppercase",
1491
+ letterSpacing: "0.05em",
1492
+ color: "#666",
1493
+ marginBottom: "12px",
1494
+ }}
1495
+ >
1496
+ Connection
1497
+ </h3>
1498
+ <div style={{ marginBottom: "12px" }}>
1499
+ <label
1500
+ style={{
1501
+ display: "block",
1502
+ fontSize: "13px",
1503
+ fontWeight: 500,
1504
+ marginBottom: "4px",
1505
+ }}
1506
+ >
1507
+ API URL
1508
+ </label>
1509
+ <input
1510
+ type="text"
1511
+ value={settingsData.newApiUrl}
1512
+ onChange={(e) =>
1513
+ setSettingsData({
1514
+ ...settingsData,
1515
+ newApiUrl: e.target.value,
1516
+ })
1517
+ }
1518
+ style={{
1519
+ width: "100%",
1520
+ padding: "8px",
1521
+ border: "1px solid #ddd",
1522
+ borderRadius: "6px",
1523
+ fontSize: "13px",
1524
+ boxSizing: "border-box",
1525
+ }}
1526
+ />
1527
+ </div>
1528
+ <div style={{ marginBottom: "12px" }}>
1529
+ <label
1530
+ style={{
1531
+ display: "block",
1532
+ fontSize: "13px",
1533
+ fontWeight: 500,
1534
+ marginBottom: "4px",
1535
+ }}
1536
+ >
1537
+ API Token{" "}
1538
+ {settingsData.hasToken && (
1539
+ <span style={{ color: "#22c55e", fontSize: "11px" }}>
1540
+ ({settingsData.maskedToken})
1541
+ </span>
1542
+ )}
1543
+ </label>
1544
+ <input
1545
+ type="password"
1546
+ value={settingsData.newToken}
1547
+ onChange={(e) =>
1548
+ setSettingsData({
1549
+ ...settingsData,
1550
+ newToken: e.target.value,
1551
+ })
1552
+ }
1553
+ placeholder={
1554
+ settingsData.hasToken
1555
+ ? "Leave empty to keep current"
1556
+ : "bf_..."
1557
+ }
1558
+ style={{
1559
+ width: "100%",
1560
+ padding: "8px",
1561
+ border: "1px solid #ddd",
1562
+ borderRadius: "6px",
1563
+ fontSize: "13px",
1564
+ boxSizing: "border-box",
1565
+ }}
1566
+ />
1567
+ </div>
1568
+ <div style={{ display: "flex", gap: "8px" }}>
1569
+ <button
1570
+ type="button"
1571
+ disabled={settingsData.testing}
1572
+ onClick={async () => {
1573
+ setSettingsData((s) => ({
1574
+ ...s,
1575
+ testing: true,
1576
+ testResult: null,
1577
+ }));
1578
+ try {
1579
+ if (settingsData.newToken) {
1580
+ await fetch("/api/config", {
1581
+ method: "PUT",
1582
+ headers: { "Content-Type": "application/json" },
1583
+ body: JSON.stringify({
1584
+ apiToken: settingsData.newToken,
1585
+ apiUrl: settingsData.newApiUrl,
1586
+ }),
1587
+ });
1588
+ }
1589
+ const res = await fetch("/api/workspaces");
1590
+ const data = await res.json();
1591
+ if (data.connected) {
1592
+ setSettingsData((s) => ({
1593
+ ...s,
1594
+ testing: false,
1595
+ testResult: "success",
1596
+ }));
1597
+ setWsInfo(data);
1598
+ setWsLoading(false);
1599
+ } else {
1600
+ setSettingsData((s) => ({
1601
+ ...s,
1602
+ testing: false,
1603
+ testResult: data.error || data.reason || "failed",
1604
+ }));
1605
+ }
1606
+ } catch {
1607
+ setSettingsData((s) => ({
1608
+ ...s,
1609
+ testing: false,
1610
+ testResult: "Network error",
1611
+ }));
1612
+ }
1613
+ }}
1614
+ style={{
1615
+ padding: "8px 16px",
1616
+ border: "1px solid #ddd",
1617
+ borderRadius: "6px",
1618
+ background: "#f8f8f8",
1619
+ cursor: "pointer",
1620
+ fontSize: "13px",
1621
+ fontWeight: 500,
1622
+ }}
1623
+ >
1624
+ {settingsData.testing ? "Testing..." : "Test Connection"}
1625
+ </button>
1626
+ {settingsData.testResult && (
1627
+ <span
1628
+ style={{
1629
+ display: "flex",
1630
+ alignItems: "center",
1631
+ fontSize: "12px",
1632
+ color:
1633
+ settingsData.testResult === "success"
1634
+ ? "#22c55e"
1635
+ : "#ef4444",
1636
+ }}
1637
+ >
1638
+ {settingsData.testResult === "success"
1639
+ ? "\u2713 Connected"
1640
+ : "\u2717 " + settingsData.testResult}
1641
+ </span>
1642
+ )}
1643
+ </div>
1644
+ </div>
1645
+
1646
+ {/* Workspace */}
1647
+ <div style={{ marginBottom: "24px" }}>
1648
+ <h3
1649
+ style={{
1650
+ fontSize: "13px",
1651
+ fontWeight: 600,
1652
+ textTransform: "uppercase",
1653
+ letterSpacing: "0.05em",
1654
+ color: "#666",
1655
+ marginBottom: "12px",
1656
+ }}
1657
+ >
1658
+ Workspace
1659
+ </h3>
1660
+ {wsInfo?.connected &&
1661
+ wsInfo.workspaces &&
1662
+ wsInfo.workspaces.length > 0 ? (
1663
+ <select
1664
+ value={settingsData.newWorkspaceId}
1665
+ onChange={(e) =>
1666
+ setSettingsData({
1667
+ ...settingsData,
1668
+ newWorkspaceId: e.target.value,
1669
+ })
1670
+ }
1671
+ style={{
1672
+ width: "100%",
1673
+ padding: "8px",
1674
+ border: "1px solid #ddd",
1675
+ borderRadius: "6px",
1676
+ fontSize: "13px",
1677
+ }}
1678
+ >
1679
+ <option value="">Select workspace...</option>
1680
+ {wsInfo.workspaces.map((w: any) => (
1681
+ <option key={w.id} value={w.id}>
1682
+ {w.name} ({w.slug})
1683
+ </option>
1684
+ ))}
1685
+ </select>
1686
+ ) : (
1687
+ <p style={{ fontSize: "13px", color: "#999" }}>
1688
+ Connect to see available workspaces
1689
+ </p>
1690
+ )}
1691
+ </div>
1692
+
1693
+ {/* Project Info */}
1694
+ {settingsData.project && (
1695
+ <div style={{ marginBottom: "24px" }}>
1696
+ <h3
1697
+ style={{
1698
+ fontSize: "13px",
1699
+ fontWeight: 600,
1700
+ textTransform: "uppercase",
1701
+ letterSpacing: "0.05em",
1702
+ color: "#666",
1703
+ marginBottom: "12px",
1704
+ }}
1705
+ >
1706
+ Project
1707
+ </h3>
1708
+ <div
1709
+ style={{
1710
+ background: "#f8f8f8",
1711
+ borderRadius: "6px",
1712
+ padding: "12px",
1713
+ fontSize: "12px",
1714
+ fontFamily: "monospace",
1715
+ }}
1716
+ >
1717
+ {Object.entries(settingsData.project).map(([k, v]) => (
1718
+ <div key={k} style={{ marginBottom: "4px" }}>
1719
+ <span style={{ color: "#666" }}>{k}:</span>{" "}
1720
+ <span>
1721
+ {typeof v === "object" ? JSON.stringify(v) : String(v)}
1722
+ </span>
1723
+ </div>
1724
+ ))}
1725
+ </div>
1726
+ </div>
1727
+ )}
1728
+
1729
+ {/* Save */}
1730
+ <button
1731
+ type="button"
1732
+ disabled={settingsData.saving}
1733
+ onClick={async () => {
1734
+ setSettingsData((s) => ({ ...s, saving: true }));
1735
+ const body: any = { apiUrl: settingsData.newApiUrl };
1736
+ if (settingsData.newToken)
1737
+ body.apiToken = settingsData.newToken;
1738
+ if (settingsData.newWorkspaceId)
1739
+ body.workspaceId = settingsData.newWorkspaceId;
1740
+ try {
1741
+ await fetch("/api/config", {
1742
+ method: "PUT",
1743
+ headers: { "Content-Type": "application/json" },
1744
+ body: JSON.stringify(body),
1745
+ });
1746
+ setSettingsData((s) => ({
1747
+ ...s,
1748
+ saving: false,
1749
+ newToken: "",
1750
+ }));
1751
+ const res = await fetch("/api/workspaces");
1752
+ const data = await res.json();
1753
+ setWsInfo(data);
1754
+ const cfgRes = await fetch("/api/config");
1755
+ const cfgData = await cfgRes.json();
1756
+ setSettingsData((s) => ({
1757
+ ...s,
1758
+ hasToken: cfgData.env.hasToken,
1759
+ maskedToken: cfgData.env.maskedToken,
1760
+ workspaceId: cfgData.env.workspaceId,
1761
+ }));
1762
+ } catch {
1763
+ setSettingsData((s) => ({ ...s, saving: false }));
1764
+ }
1765
+ }}
1766
+ style={{
1767
+ width: "100%",
1768
+ padding: "10px",
1769
+ background: "#667eea",
1770
+ color: "#fff",
1771
+ border: "none",
1772
+ borderRadius: "6px",
1773
+ fontSize: "14px",
1774
+ fontWeight: 500,
1775
+ cursor: "pointer",
1776
+ }}
1777
+ >
1778
+ {settingsData.saving ? "Saving..." : "Save Settings"}
1779
+ </button>
1780
+ </div>
1781
+ </div>
1782
+ )}
1783
+ </div>
1784
+ );
1785
+ }