@valbuild/ui 0.21.2 → 0.22.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 (70) hide show
  1. package/.storybook/theme.css +5 -1
  2. package/components.json +16 -0
  3. package/dist/valbuild-ui.cjs.d.ts +11 -7
  4. package/dist/valbuild-ui.cjs.js +43607 -33216
  5. package/dist/valbuild-ui.esm.js +48313 -37938
  6. package/fix-server-hack.js +45 -0
  7. package/fullscreen.vite.config.ts +11 -0
  8. package/index.html +13 -0
  9. package/package.json +52 -13
  10. package/server/dist/manifest.json +16 -0
  11. package/server/dist/style.css +2145 -0
  12. package/server/dist/valbuild-ui-main.cjs.js +74441 -0
  13. package/server/dist/valbuild-ui-main.esm.js +74442 -0
  14. package/server/dist/valbuild-ui-server.cjs.js +19 -2
  15. package/server/dist/valbuild-ui-server.esm.js +19 -2
  16. package/server.vite.config.ts +2 -0
  17. package/src/App.tsx +73 -0
  18. package/src/assets/icons/Logo.tsx +103 -0
  19. package/src/components/Button.tsx +10 -2
  20. package/src/components/Dropdown.tsx +2 -2
  21. package/src/components/{dashboard/Grid.stories.tsx → Grid.stories.tsx} +8 -17
  22. package/src/components/{dashboard/Grid.tsx → Grid.tsx} +36 -23
  23. package/src/components/RichTextEditor/ContentEditable.tsx +109 -1
  24. package/src/components/RichTextEditor/Plugins/Toolbar.tsx +2 -2
  25. package/src/components/RichTextEditor/RichTextEditor.tsx +1 -1
  26. package/src/components/ValFormField.tsx +576 -0
  27. package/src/components/ValFullscreen.tsx +1283 -0
  28. package/src/components/ValMenu.tsx +65 -13
  29. package/src/components/ValOverlay.tsx +32 -338
  30. package/src/components/ValWindow.tsx +12 -9
  31. package/src/components/dashboard/FormGroup.tsx +12 -6
  32. package/src/components/dashboard/Tree.tsx +2 -2
  33. package/src/components/ui/accordion.tsx +58 -0
  34. package/src/components/ui/alert-dialog.tsx +139 -0
  35. package/src/components/ui/avatar.tsx +48 -0
  36. package/src/components/ui/button.tsx +56 -0
  37. package/src/components/ui/calendar.tsx +62 -0
  38. package/src/components/ui/card.tsx +86 -0
  39. package/src/components/ui/checkbox.tsx +28 -0
  40. package/src/components/ui/command.tsx +153 -0
  41. package/src/components/ui/dialog.tsx +120 -0
  42. package/src/components/ui/dropdown-menu.tsx +198 -0
  43. package/src/components/ui/form.tsx +177 -0
  44. package/src/components/ui/input.tsx +24 -0
  45. package/src/components/ui/label.tsx +24 -0
  46. package/src/components/ui/popover.tsx +29 -0
  47. package/src/components/ui/progress.tsx +26 -0
  48. package/src/components/ui/radio-group.tsx +42 -0
  49. package/src/components/ui/scroll-area.tsx +51 -0
  50. package/src/components/ui/select.tsx +119 -0
  51. package/src/components/ui/switch.tsx +27 -0
  52. package/src/components/ui/tabs.tsx +53 -0
  53. package/src/components/ui/toggle.tsx +43 -0
  54. package/src/components/ui/tooltip.tsx +28 -0
  55. package/src/components/usePatch.ts +86 -0
  56. package/src/components/useTheme.ts +45 -0
  57. package/src/exports.ts +2 -1
  58. package/src/index.css +96 -60
  59. package/src/lib/IValStore.ts +6 -0
  60. package/src/lib/utils.ts +6 -0
  61. package/src/main.jsx +10 -0
  62. package/src/richtext/conversion/lexicalToRichTextSource.ts +0 -1
  63. package/src/richtext/shadowRootPolyFill.js +115 -0
  64. package/src/server.ts +39 -2
  65. package/src/utils/resolvePath.ts +0 -1
  66. package/src/vite-server.ts +20 -3
  67. package/tailwind.config.js +63 -51
  68. package/tsconfig.json +2 -1
  69. package/vite.config.ts +10 -0
  70. package/src/components/dashboard/ValDashboard.tsx +0 -150
@@ -0,0 +1,1283 @@
1
+ "use client";
2
+ import {
3
+ AnyRichTextOptions,
4
+ ApiTreeResponse,
5
+ FileSource,
6
+ FILE_REF_PROP,
7
+ ImageMetadata,
8
+ Internal,
9
+ ModuleId,
10
+ RichText,
11
+ RichTextNode,
12
+ RichTextSource,
13
+ SerializedRecordSchema,
14
+ SerializedSchema,
15
+ SourcePath,
16
+ VAL_EXTENSION,
17
+ } from "@valbuild/core";
18
+ import {
19
+ SerializedArraySchema,
20
+ SerializedObjectSchema,
21
+ Json,
22
+ JsonArray,
23
+ JsonObject,
24
+ } from "@valbuild/core";
25
+ import { ValApi } from "@valbuild/core";
26
+ import { FC, Fragment, useCallback, useEffect, useState } from "react";
27
+ import { Grid } from "./Grid";
28
+ import { result } from "@valbuild/core/fp";
29
+ import { Tree } from "./dashboard/Tree";
30
+ import { OnSubmit, ValFormField } from "./ValFormField";
31
+ import React from "react";
32
+ import { parseRichTextSource } from "../exports";
33
+ import { createPortal } from "react-dom";
34
+ import Logo from "../assets/icons/Logo";
35
+ import { ScrollArea } from "./ui/scroll-area";
36
+ import { Switch } from "./ui/switch";
37
+ import { Card } from "./ui/card";
38
+ import { ChevronLeft } from "lucide-react";
39
+ import { ValOverlayContext } from "./ValOverlayContext";
40
+ import { useNavigate, useParams } from "react-router";
41
+ import { useTheme } from "./useTheme";
42
+ import classNames from "classnames";
43
+ import { ValMenu } from "./ValMenu";
44
+
45
+ interface ValFullscreenProps {
46
+ valApi: ValApi;
47
+ }
48
+
49
+ // TODO: move SerializedModuleContent to core
50
+ type SerializedModuleContent = ApiTreeResponse["modules"][ModuleId];
51
+ export const ValModulesContext = React.createContext<ValModules>(null);
52
+
53
+ export const useValModuleFromPath = (
54
+ sourcePath: SourcePath
55
+ ): {
56
+ moduleId: ModuleId;
57
+ moduleSource: Json | undefined;
58
+ moduleSchema: SerializedSchema | undefined;
59
+ } => {
60
+ const modules = React.useContext(ValModulesContext);
61
+ const [moduleId, modulePath] =
62
+ Internal.splitModuleIdAndModulePath(sourcePath);
63
+ const moduleSource = modules?.[moduleId]?.source;
64
+ const moduleSchema = modules?.[moduleId]?.schema;
65
+ if (!moduleSource || !moduleSchema) {
66
+ throw Error("Could not find module: " + moduleId);
67
+ }
68
+ const resolvedPath = Internal.resolvePath(
69
+ modulePath,
70
+ moduleSource,
71
+ moduleSchema
72
+ );
73
+ return {
74
+ moduleId,
75
+ moduleSource: resolvedPath.source,
76
+ moduleSchema: resolvedPath.schema,
77
+ };
78
+ };
79
+
80
+ type ValModules = Record<ModuleId, SerializedModuleContent> | null;
81
+
82
+ type InitOnSubmit = (path: SourcePath) => OnSubmit;
83
+ export const ValFullscreen: FC<ValFullscreenProps> = ({ valApi }) => {
84
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
85
+ const { "*": pathFromParams } = useParams();
86
+ const [modules, setModules] = useState<ValModules>(null);
87
+ const [error, setError] = useState<string | null>(null);
88
+ const [selectedPath, setSelectedPath] = useState<SourcePath | ModuleId>();
89
+ const [selectedModuleId] = selectedPath
90
+ ? Internal.splitModuleIdAndModulePath(selectedPath as SourcePath)
91
+ : [undefined, undefined];
92
+ const moduleSource = selectedModuleId && modules?.[selectedModuleId]?.source;
93
+ const moduleSchema = selectedModuleId && modules?.[selectedModuleId]?.schema;
94
+ const fatalErrors = Object.entries(modules || {}).flatMap(([, module]) => {
95
+ return module.errors
96
+ ? module.errors.fatal
97
+ ? module.errors.fatal
98
+ : []
99
+ : [];
100
+ });
101
+ const validationErrors = Object.entries(modules || {}).flatMap(
102
+ ([, module]) => {
103
+ return module.errors && module.errors.validation
104
+ ? [module.errors.validation]
105
+ : [];
106
+ }
107
+ );
108
+
109
+ if (fatalErrors && fatalErrors.length > 0) {
110
+ const message =
111
+ fatalErrors.length === 1
112
+ ? fatalErrors[0].message
113
+ : `Multiple errors detected:\n${fatalErrors
114
+ .map((f, i) => `${i + 1}. ${f.message}`)
115
+ .join("\n")}\n\nShowing stack trace of: 0. ${
116
+ fatalErrors[0].message
117
+ }`;
118
+ const error = new Error(message);
119
+ error.stack = fatalErrors[0].stack;
120
+ throw error;
121
+ }
122
+
123
+ if (validationErrors && validationErrors.length > 0) {
124
+ console.warn("Val encountered validation errors:", validationErrors);
125
+ }
126
+ //
127
+ useEffect(() => {
128
+ setSelectedPath(
129
+ pathFromParams ? (`/${pathFromParams}` as ModuleId) : selectedPath
130
+ );
131
+ }, [pathFromParams]);
132
+
133
+ const [hmrHash, setHmrHash] = useState(null);
134
+ useEffect(() => {
135
+ if (modules) {
136
+ console.log("->", hmrHash);
137
+ }
138
+ }, [modules]);
139
+ useEffect(() => {
140
+ try {
141
+ // use websocket to update modules
142
+ const hot = new WebSocket(
143
+ `${window.location.origin.replace(
144
+ "http://",
145
+ "ws://"
146
+ )}/_next/webpack-hmr`
147
+ );
148
+ hot.addEventListener("message", (e) => {
149
+ let data;
150
+ try {
151
+ data = JSON.parse(e.data);
152
+ } catch (err) {
153
+ console.error("Failed to parse HMR");
154
+ }
155
+ if (typeof data?.hash === "string" && data?.action === "built") {
156
+ setHmrHash(data.hash);
157
+ }
158
+ });
159
+ } catch (err) {
160
+ // could not set up dev mode
161
+ console.warn("Failed to initialize HMR", err);
162
+ }
163
+ }, []);
164
+ useEffect(() => {
165
+ console.log("(Re)-fetching modules");
166
+ valApi
167
+ .getModules({ patch: true, includeSchema: true, includeSource: true })
168
+ .then((res) => {
169
+ if (result.isOk(res)) {
170
+ setModules(res.value.modules);
171
+ } else {
172
+ setError("Could not load modules: " + res.error.message);
173
+ console.error(res.error);
174
+ }
175
+ });
176
+ }, [hmrHash]);
177
+
178
+ const navigate = useNavigate();
179
+ const [theme, setTheme] = useTheme();
180
+
181
+ useEffect(() => {
182
+ const popStateListener = (event: PopStateEvent) => {
183
+ console.log("popstate", event);
184
+ };
185
+
186
+ window.addEventListener("popstate", popStateListener);
187
+
188
+ return () => {
189
+ window.removeEventListener("popstate", popStateListener);
190
+ };
191
+ }, []);
192
+
193
+ const hoverElemRef = React.useRef<HTMLDivElement | null>(null);
194
+
195
+ const initOnSubmit: InitOnSubmit = useCallback(
196
+ (path) => async (callback) => {
197
+ const [moduleId, modulePath] = Internal.splitModuleIdAndModulePath(path);
198
+ const patch = await callback(Internal.createPatchJSONPath(modulePath));
199
+ return valApi
200
+ .postPatches(moduleId, patch)
201
+ .then((res) => {
202
+ if (result.isErr(res)) {
203
+ throw res.error;
204
+ } else {
205
+ console.log("submitted", patch);
206
+ // TODO: we need to revisit this a bit, HMR might not be the best solution here
207
+ if (!hmrHash) {
208
+ // TODO: we should only refresh the module that was updated
209
+ return valApi
210
+ .getModules({
211
+ patch: true,
212
+ includeSchema: true,
213
+ includeSource: true,
214
+ })
215
+ .then((res) => {
216
+ if (result.isOk(res)) {
217
+ setModules(res.value.modules);
218
+ } else {
219
+ setError("Could not load modules: " + res.error.message);
220
+ console.error(res.error);
221
+ }
222
+ });
223
+ }
224
+ }
225
+ })
226
+ .catch((e) => {
227
+ console.error(e);
228
+ });
229
+ },
230
+ []
231
+ );
232
+
233
+ return (
234
+ <ValOverlayContext.Provider
235
+ value={{
236
+ theme,
237
+ setTheme,
238
+ api: valApi,
239
+ editMode: "full",
240
+ session: { status: "not-asked" },
241
+ highlight: false,
242
+ setHighlight: () => {
243
+ //
244
+ },
245
+ setEditMode: () => {
246
+ //
247
+ },
248
+ setWindowSize: () => {
249
+ //
250
+ },
251
+ }}
252
+ >
253
+ <div
254
+ id="val-fullscreen-container"
255
+ className="relative font-serif antialiased"
256
+ data-mode={theme}
257
+ >
258
+ <div className="fixed -translate-x-1/2 z-overlay left-1/2 bottom-4">
259
+ <ValMenu api={valApi} />
260
+ </div>
261
+ <div id="val-fullscreen-hover" ref={hoverElemRef}></div>
262
+ <ValFullscreenHoverContext.Provider
263
+ value={{
264
+ hoverElem: hoverElemRef?.current,
265
+ }}
266
+ >
267
+ <div className="text-primary bg-background">
268
+ <Grid>
269
+ <div className="px-4 h-[50px] flex items-center justify-center">
270
+ <Logo />
271
+ </div>
272
+ <ScrollArea className="px-4">
273
+ {modules ? (
274
+ <PathTree
275
+ paths={Object.keys(modules)}
276
+ setSelectedModuleId={(path) => {
277
+ navigate(path);
278
+ }}
279
+ />
280
+ ) : (
281
+ !error && <div className="py-4">Loading...</div>
282
+ )}
283
+ </ScrollArea>
284
+ <div className="flex items-center justify-start w-full h-[50px] gap-2 font-serif text-xs">
285
+ <button
286
+ onClick={() => {
287
+ history.back();
288
+ }}
289
+ >
290
+ <ChevronLeft />
291
+ </button>
292
+ <p>{selectedPath || "/"}</p>
293
+ </div>
294
+ <div className="p-4">
295
+ {error && (
296
+ <div className="text-lg text-destructive-foreground">
297
+ ERROR: {error}
298
+ </div>
299
+ )}
300
+ {modules &&
301
+ selectedPath &&
302
+ selectedModuleId &&
303
+ moduleSource !== undefined &&
304
+ moduleSchema !== undefined && (
305
+ <ValModulesContext.Provider value={modules}>
306
+ <ValModule
307
+ path={selectedPath}
308
+ source={moduleSource}
309
+ schema={moduleSchema}
310
+ setSelectedPath={setSelectedPath}
311
+ initOnSubmit={initOnSubmit}
312
+ />
313
+ </ValModulesContext.Provider>
314
+ )}
315
+ </div>
316
+ </Grid>
317
+ </div>
318
+ </ValFullscreenHoverContext.Provider>
319
+ </div>
320
+ </ValOverlayContext.Provider>
321
+ );
322
+ };
323
+
324
+ const ValFullscreenHoverContext = React.createContext<{
325
+ hoverElem: HTMLElement | null;
326
+ }>({
327
+ hoverElem: null,
328
+ });
329
+
330
+ const useValFullscreenHover = () => {
331
+ return React.useContext(ValFullscreenHoverContext);
332
+ };
333
+
334
+ function ValModule({
335
+ path,
336
+ source: moduleSource,
337
+ schema: moduleSchema,
338
+ setSelectedPath,
339
+ initOnSubmit,
340
+ }: {
341
+ path: SourcePath | ModuleId;
342
+ source: Json;
343
+ schema: SerializedSchema;
344
+ setSelectedPath: (path: SourcePath | ModuleId) => void;
345
+ initOnSubmit: InitOnSubmit;
346
+ }): React.ReactElement {
347
+ const [, modulePath] = Internal.splitModuleIdAndModulePath(
348
+ path as SourcePath
349
+ );
350
+ const resolvedPath = Internal.resolvePath(
351
+ modulePath,
352
+ moduleSource,
353
+ moduleSchema
354
+ );
355
+ if (!resolvedPath) {
356
+ throw Error("Could not resolve module: " + path);
357
+ }
358
+ return (
359
+ <AnyVal
360
+ path={path as SourcePath}
361
+ source={resolvedPath.source}
362
+ schema={resolvedPath.schema as SerializedSchema}
363
+ setSelectedPath={setSelectedPath}
364
+ initOnSubmit={initOnSubmit}
365
+ top
366
+ />
367
+ );
368
+ }
369
+
370
+ function AnyVal({
371
+ path,
372
+ source,
373
+ schema,
374
+ setSelectedPath,
375
+ field,
376
+ initOnSubmit,
377
+ top,
378
+ }: {
379
+ path: SourcePath;
380
+ source: Json;
381
+ schema: SerializedSchema;
382
+ setSelectedPath: (path: SourcePath | ModuleId) => void;
383
+ field?: string;
384
+ initOnSubmit: InitOnSubmit;
385
+ top?: boolean;
386
+ }): React.ReactElement {
387
+ if (source === null || schema.opt) {
388
+ return (
389
+ <ValOptional
390
+ path={path}
391
+ source={source}
392
+ schema={schema}
393
+ field={field}
394
+ initOnSubmit={initOnSubmit}
395
+ setSelectedPath={setSelectedPath}
396
+ />
397
+ );
398
+ }
399
+ if (schema.type === "object") {
400
+ if (typeof source !== "object" || isJsonArray(source)) {
401
+ return <div>ERROR: expected object, but found {typeof source}</div>;
402
+ }
403
+ return (
404
+ <ValObject
405
+ source={source}
406
+ path={path}
407
+ schema={schema}
408
+ initOnSubmit={initOnSubmit}
409
+ setSelectedPath={setSelectedPath}
410
+ top={top}
411
+ />
412
+ );
413
+ } else if (schema.type === "array") {
414
+ if (typeof source !== "object" || !isJsonArray(source)) {
415
+ return <div>ERROR: expected array, but found {typeof source}</div>;
416
+ }
417
+ if (field) {
418
+ <div>
419
+ <div className="text-left">{field || path}</div>
420
+ <ValList
421
+ source={source}
422
+ path={path}
423
+ schema={schema}
424
+ setSelectedPath={setSelectedPath}
425
+ />
426
+ </div>;
427
+ }
428
+ return (
429
+ <ValList
430
+ source={source}
431
+ path={path}
432
+ schema={schema}
433
+ setSelectedPath={setSelectedPath}
434
+ />
435
+ );
436
+ } else if (schema.type === "record") {
437
+ if (typeof source !== "object") {
438
+ return (
439
+ <div>
440
+ ERROR: expected object for {schema.type}, but found {typeof source}
441
+ </div>
442
+ );
443
+ }
444
+ if (isJsonArray(source)) {
445
+ return <div>ERROR: did not expect array for {schema.type}</div>;
446
+ }
447
+ if (field) {
448
+ <div>
449
+ <div className="text-left">{field || path}</div>
450
+ <ValRecord
451
+ source={source}
452
+ path={path}
453
+ schema={schema}
454
+ setSelectedPath={setSelectedPath}
455
+ />
456
+ </div>;
457
+ }
458
+ return (
459
+ <ValRecord
460
+ source={source}
461
+ path={path}
462
+ schema={schema}
463
+ setSelectedPath={setSelectedPath}
464
+ />
465
+ );
466
+ }
467
+
468
+ return (
469
+ <div className="py-2 gap-y-4">
470
+ <div className="text-left">{field || path}</div>
471
+ <ValFormField
472
+ path={path}
473
+ disabled={false}
474
+ source={source}
475
+ schema={schema}
476
+ onSubmit={initOnSubmit(path)}
477
+ />
478
+ </div>
479
+ );
480
+ }
481
+
482
+ function ValObject({
483
+ path,
484
+ source,
485
+ schema,
486
+ setSelectedPath,
487
+ initOnSubmit,
488
+ top,
489
+ }: {
490
+ source: JsonObject;
491
+ path: SourcePath;
492
+ schema: SerializedObjectSchema;
493
+ setSelectedPath: (path: SourcePath | ModuleId) => void;
494
+ initOnSubmit: InitOnSubmit;
495
+ top?: boolean;
496
+ }): React.ReactElement {
497
+ return (
498
+ <div
499
+ key={path}
500
+ className={classNames("flex flex-col gap-y-8", {
501
+ "border-l-2 border-border pl-6": !top,
502
+ })}
503
+ >
504
+ {Object.entries(schema.items).map(([key, property]) => {
505
+ const subPath = createValPathOfItem(path, key);
506
+ return (
507
+ <AnyVal
508
+ key={subPath}
509
+ path={subPath}
510
+ source={source[key]}
511
+ schema={property}
512
+ setSelectedPath={setSelectedPath}
513
+ field={key}
514
+ initOnSubmit={initOnSubmit}
515
+ />
516
+ );
517
+ })}
518
+ </div>
519
+ );
520
+ }
521
+ function ValRecord({
522
+ path,
523
+ source,
524
+ schema,
525
+ setSelectedPath,
526
+ }: {
527
+ source: JsonObject;
528
+ path: SourcePath;
529
+ schema: SerializedRecordSchema;
530
+ setSelectedPath: (path: SourcePath | ModuleId) => void;
531
+ }): React.ReactElement {
532
+ const navigate = useNavigate();
533
+ return (
534
+ <div key={path} className="flex flex-col gap-4 p-2">
535
+ {Object.entries(source).map(([key, item]) => {
536
+ const subPath = createValPathOfItem(path, key);
537
+ return (
538
+ <button
539
+ key={subPath}
540
+ onClick={() => {
541
+ setSelectedPath(subPath);
542
+ navigate(subPath);
543
+ }}
544
+ >
545
+ <ValRecordItem
546
+ recordKey={key}
547
+ path={subPath}
548
+ source={item}
549
+ schema={schema.item}
550
+ />
551
+ </button>
552
+ );
553
+ })}
554
+ </div>
555
+ );
556
+ }
557
+
558
+ const RECORD_ITEM_MAX_HEIGHT = 170;
559
+ function ValRecordItem({
560
+ recordKey,
561
+ path,
562
+ source,
563
+ schema,
564
+ }: {
565
+ recordKey: string;
566
+ source: Json | null;
567
+ path: SourcePath;
568
+ schema: SerializedSchema;
569
+ }): React.ReactElement {
570
+ const ref = React.useRef<HTMLDivElement>(null);
571
+ const [isTruncated, setIsTruncated] = useState<boolean>(false);
572
+ useEffect(() => {
573
+ if (ref.current) {
574
+ const height = ref.current.getBoundingClientRect().height;
575
+ if (height >= RECORD_ITEM_MAX_HEIGHT) {
576
+ setIsTruncated(true);
577
+ }
578
+ }
579
+ }, []);
580
+ return (
581
+ <Card
582
+ key={path}
583
+ ref={ref}
584
+ className="relative px-4 pt-2 pb-4 overflow-hidden border gap-y-2"
585
+ style={{
586
+ maxHeight: RECORD_ITEM_MAX_HEIGHT,
587
+ }}
588
+ >
589
+ <div className="pb-4 font-serif text-left text-accent">{recordKey}</div>
590
+ <div className="text-xs">
591
+ <ValPreview path={path} source={source} schema={schema} />
592
+ </div>
593
+ {isTruncated && (
594
+ <div className="absolute bottom-0 left-0 w-full h-[20px] bg-gradient-to-b from-transparent to-background"></div>
595
+ )}
596
+ </Card>
597
+ );
598
+ }
599
+
600
+ function ValList({
601
+ path,
602
+ source,
603
+ schema,
604
+ setSelectedPath,
605
+ }: {
606
+ source: JsonArray;
607
+ path: SourcePath;
608
+ schema: SerializedArraySchema;
609
+ setSelectedPath: (path: SourcePath | ModuleId) => void;
610
+ }): React.ReactElement {
611
+ const navigate = useNavigate();
612
+ return (
613
+ <div key={path} className="flex flex-col gap-4 p-2">
614
+ {source.map((item, index) => {
615
+ const subPath = createValPathOfItem(path, index);
616
+ return (
617
+ <button
618
+ key={subPath}
619
+ onClick={() => {
620
+ setSelectedPath(subPath);
621
+ navigate(subPath);
622
+ }}
623
+ >
624
+ <ValListItem
625
+ index={index}
626
+ key={subPath}
627
+ path={subPath}
628
+ source={item}
629
+ schema={schema.item}
630
+ />
631
+ </button>
632
+ );
633
+ })}
634
+ </div>
635
+ );
636
+ }
637
+
638
+ const LIST_ITEM_MAX_HEIGHT = RECORD_ITEM_MAX_HEIGHT;
639
+ function ValListItem({
640
+ index,
641
+ path,
642
+ source,
643
+ schema,
644
+ }: {
645
+ index: number;
646
+ source: Json | null;
647
+ path: SourcePath;
648
+ schema: SerializedSchema;
649
+ }): React.ReactElement {
650
+ const ref = React.useRef<HTMLDivElement>(null);
651
+ const [isTruncated, setIsTruncated] = useState<boolean>(false);
652
+ useEffect(() => {
653
+ if (ref.current) {
654
+ const height = ref.current.getBoundingClientRect().height;
655
+ if (height >= LIST_ITEM_MAX_HEIGHT) {
656
+ setIsTruncated(true);
657
+ }
658
+ }
659
+ }, []);
660
+ return (
661
+ <Card
662
+ ref={ref}
663
+ className="relative px-4 pt-2 pb-4 overflow-hidden border gap-y-2"
664
+ style={{
665
+ maxHeight: LIST_ITEM_MAX_HEIGHT,
666
+ }}
667
+ >
668
+ <div className="pb-4 font-serif text-left uppercase text-accent">
669
+ {index + 1 < 10 ? `0${index + 1}` : index + 1}
670
+ </div>
671
+ <div className="text-xs">
672
+ <ValPreview path={path} source={source} schema={schema} />
673
+ </div>
674
+ {isTruncated && (
675
+ <div className="absolute bottom-0 left-0 w-full h-[20px] bg-gradient-to-b from-transparent to-background"></div>
676
+ )}
677
+ </Card>
678
+ );
679
+ }
680
+
681
+ function createValPathOfItem(
682
+ arrayPath: SourcePath | undefined,
683
+ prop: string | number | symbol
684
+ ) {
685
+ const val = Internal.createValPathOfItem(arrayPath, prop);
686
+ if (!val) {
687
+ // Should never happen
688
+ throw Error(
689
+ `Could not create val path: ${arrayPath} of ${prop?.toString()}`
690
+ );
691
+ }
692
+ return val;
693
+ }
694
+
695
+ function ValPreview({
696
+ path,
697
+ source,
698
+ schema,
699
+ }: {
700
+ source: Json | null;
701
+ path: SourcePath;
702
+ schema: SerializedSchema;
703
+ }): React.ReactElement {
704
+ const [isMouseOver, setIsMouseOver] = useState<{
705
+ x: number;
706
+ y: number;
707
+ } | null>(null);
708
+ const { hoverElem } = useValFullscreenHover();
709
+
710
+ if (schema.type === "object") {
711
+ return (
712
+ <div
713
+ key={path}
714
+ className="grid grid-cols-[min-content_1fr] gap-2 text-left"
715
+ >
716
+ {Object.entries(schema.items).map(([key]) => {
717
+ return (
718
+ <Fragment key={createValPathOfItem(path, key)}>
719
+ <span className="text-muted">{key}:</span>
720
+ <span>
721
+ <ValPreview
722
+ source={(source as JsonObject | null)?.[key] ?? null}
723
+ schema={schema.items[key]}
724
+ path={createValPathOfItem(path, key)}
725
+ />
726
+ </span>
727
+ </Fragment>
728
+ );
729
+ })}
730
+ </div>
731
+ );
732
+ } else if (schema.type === "array") {
733
+ if (source === null) {
734
+ return (
735
+ <span key={path} className="text-accent">
736
+ Empty
737
+ </span>
738
+ );
739
+ }
740
+ if (Array.isArray(source)) {
741
+ return (
742
+ <span key={path}>
743
+ <span className="text-accent">{source.length}</span>
744
+ <span>{source.length === 1 ? " item" : " items"}</span>
745
+ </span>
746
+ );
747
+ }
748
+ return (
749
+ <span
750
+ key={path}
751
+ className="px-2 bg-destructive text-destructive-foreground"
752
+ >
753
+ Unknown length
754
+ </span>
755
+ );
756
+ } else if (schema.type === "richtext") {
757
+ if (source === null) {
758
+ return (
759
+ <span key={path} className="text-accent">
760
+ Empty
761
+ </span>
762
+ );
763
+ }
764
+ if (typeof source !== "object") {
765
+ return (
766
+ <div
767
+ key={path}
768
+ className="p-4 text-destructive-foreground bg-destructive"
769
+ >
770
+ ERROR: {typeof source} not an object
771
+ </div>
772
+ );
773
+ }
774
+ if (!(VAL_EXTENSION in source) || source[VAL_EXTENSION] !== "richtext") {
775
+ return (
776
+ <div
777
+ key={path}
778
+ className="p-4 text-destructive-foreground bg-destructive"
779
+ >
780
+ ERROR: object is not richtext
781
+ </div>
782
+ );
783
+ }
784
+ return (
785
+ <ValRichText key={path}>
786
+ {parseRichTextSource(source as RichTextSource<AnyRichTextOptions>)}
787
+ </ValRichText>
788
+ );
789
+ } else if (schema.type === "string") {
790
+ if (source === null) {
791
+ return (
792
+ <span key={path} className="text-accent">
793
+ Empty
794
+ </span>
795
+ );
796
+ }
797
+ return <span>{source as string}</span>;
798
+ } else if (schema.type === "image") {
799
+ if (source === null) {
800
+ return (
801
+ <span key={path} className="text-accent">
802
+ Empty
803
+ </span>
804
+ );
805
+ }
806
+ if (typeof source !== "object") {
807
+ return (
808
+ <div
809
+ key={path}
810
+ className="p-4 text-destructive-foreground bg-destructive"
811
+ >
812
+ ERROR: not an object
813
+ </div>
814
+ );
815
+ }
816
+ if (
817
+ !(FILE_REF_PROP in source) ||
818
+ typeof source[FILE_REF_PROP] !== "string"
819
+ ) {
820
+ return (
821
+ <div
822
+ key={path}
823
+ className="p-4 text-destructive-foreground bg-destructive"
824
+ >
825
+ ERROR: object is not an image
826
+ </div>
827
+ );
828
+ }
829
+ const url = Internal.convertFileSource(
830
+ source as FileSource<ImageMetadata>
831
+ ).url;
832
+ return (
833
+ <span
834
+ key={path}
835
+ onMouseOver={(ev) => {
836
+ setIsMouseOver({
837
+ x: ev.clientX,
838
+ y: ev.clientY,
839
+ });
840
+ }}
841
+ onMouseLeave={() => {
842
+ setIsMouseOver(null);
843
+ }}
844
+ className="relative flex items-center justify-start gap-1"
845
+ >
846
+ <a href={url} className="overflow-hidden underline truncate ">
847
+ {source[FILE_REF_PROP]}
848
+ </a>
849
+ {isMouseOver &&
850
+ hoverElem &&
851
+ createPortal(
852
+ <img
853
+ className="absolute z-[5] max-w-[10vw]"
854
+ style={{
855
+ left: isMouseOver.x + 10,
856
+ top: isMouseOver.y + 10,
857
+ }}
858
+ src={url}
859
+ ></img>,
860
+ hoverElem
861
+ )}
862
+ </span>
863
+ );
864
+ } else if (schema.type === "boolean") {
865
+ if (source === null) {
866
+ return (
867
+ <span key={path} className="text-accent">
868
+ Empty
869
+ </span>
870
+ );
871
+ }
872
+ return (
873
+ <span key={path} className="text-accent">
874
+ {source ? "true" : "false"}
875
+ </span>
876
+ );
877
+ } else if (schema.type === "number") {
878
+ if (source === null) {
879
+ return (
880
+ <span key={path} className="text-accent">
881
+ Empty
882
+ </span>
883
+ );
884
+ }
885
+ return <span className="text-accent">{source.toString()}</span>;
886
+ } else if (schema.type === "keyOf") {
887
+ if (source === null) {
888
+ return (
889
+ <span key={path} className="text-accent">
890
+ Empty
891
+ </span>
892
+ );
893
+ }
894
+ return (
895
+ <span key={path} className="text-accent">
896
+ {source.toString()}
897
+ </span>
898
+ );
899
+ }
900
+
901
+ return <div key={path}>TODO: {schema.type}</div>;
902
+ }
903
+
904
+ function ValOptional({
905
+ path,
906
+ source,
907
+ schema,
908
+ setSelectedPath,
909
+ initOnSubmit,
910
+ field,
911
+ }: {
912
+ path: SourcePath;
913
+ source: Json;
914
+ schema: SerializedSchema;
915
+ setSelectedPath: (path: SourcePath | ModuleId) => void;
916
+ initOnSubmit: InitOnSubmit;
917
+ field?: string;
918
+ }) {
919
+ const [enable, setEnable] = useState<boolean>(source !== null);
920
+
921
+ return (
922
+ <div className="flex flex-col gap-y-6" key={path}>
923
+ {field ? (
924
+ <div className="flex items-center justify-start gap-x-4">
925
+ <Switch
926
+ checked={enable}
927
+ onClick={() => {
928
+ setEnable((prev) => !prev);
929
+ }}
930
+ />
931
+ <span>{field}</span>
932
+ </div>
933
+ ) : (
934
+ <Switch
935
+ checked={enable}
936
+ onClick={() => {
937
+ setEnable((prev) => !prev);
938
+ }}
939
+ />
940
+ )}
941
+ {enable && (
942
+ <ValDefaultOf
943
+ source={source}
944
+ schema={schema}
945
+ path={path}
946
+ setSelectedPath={setSelectedPath}
947
+ initOnSubmit={initOnSubmit}
948
+ />
949
+ )}
950
+ </div>
951
+ );
952
+ }
953
+
954
+ function ValDefaultOf({
955
+ source,
956
+ path,
957
+ schema,
958
+ setSelectedPath,
959
+ initOnSubmit,
960
+ }: {
961
+ source: Json;
962
+ path: SourcePath;
963
+ schema: SerializedSchema;
964
+ setSelectedPath: (path: SourcePath | ModuleId) => void;
965
+ initOnSubmit: InitOnSubmit;
966
+ }): React.ReactElement {
967
+ if (schema.type === "array") {
968
+ if (
969
+ typeof source === "object" &&
970
+ (source === null || isJsonArray(source))
971
+ ) {
972
+ return (
973
+ <ValList
974
+ source={source === null ? [] : source}
975
+ path={path}
976
+ schema={schema}
977
+ setSelectedPath={setSelectedPath}
978
+ />
979
+ );
980
+ }
981
+ } else if (schema.type === "object") {
982
+ if (
983
+ typeof source === "object" &&
984
+ (source === null || !isJsonArray(source))
985
+ ) {
986
+ return (
987
+ <ValObject
988
+ source={source as JsonObject}
989
+ path={path}
990
+ schema={schema}
991
+ setSelectedPath={setSelectedPath}
992
+ initOnSubmit={initOnSubmit}
993
+ />
994
+ );
995
+ }
996
+ } else if (
997
+ schema.type === "richtext" ||
998
+ schema.type === "string" ||
999
+ schema.type === "image" ||
1000
+ schema.type === "number" ||
1001
+ schema.type === "keyOf"
1002
+ ) {
1003
+ return (
1004
+ <ValFormField
1005
+ key={path}
1006
+ path={path}
1007
+ disabled={false}
1008
+ source={source}
1009
+ schema={schema}
1010
+ onSubmit={initOnSubmit(path)}
1011
+ />
1012
+ );
1013
+ }
1014
+
1015
+ return (
1016
+ <div className="p-4 bg-destructive text-destructive-foreground">
1017
+ ERROR: unexpected source type {typeof source} for schema type{" "}
1018
+ {schema.type}
1019
+ </div>
1020
+ );
1021
+ }
1022
+
1023
+ function isJsonArray(source: JsonArray | JsonObject): source is JsonArray {
1024
+ return Array.isArray(source);
1025
+ }
1026
+
1027
+ type Tree = {
1028
+ [key: string]: Tree;
1029
+ };
1030
+ function pathsToTree(paths: string[]): Tree {
1031
+ const tree: Tree = {};
1032
+ paths.forEach((path) => {
1033
+ const parts = path.split("/").filter((part) => part !== "");
1034
+ let current = tree;
1035
+ parts.forEach((part) => {
1036
+ if (!current[part]) {
1037
+ current[part] = {};
1038
+ }
1039
+ current = current[part] as Tree;
1040
+ });
1041
+ });
1042
+ return tree;
1043
+ }
1044
+
1045
+ function PathTree({
1046
+ paths,
1047
+ setSelectedModuleId,
1048
+ }: {
1049
+ paths: string[];
1050
+ setSelectedModuleId: (path: ModuleId | SourcePath) => void;
1051
+ }): React.ReactElement {
1052
+ const tree = pathsToTree(paths);
1053
+ return (
1054
+ <Tree>
1055
+ {Object.entries(tree).map(([name, subTree]) => (
1056
+ <div className="px-4 py-2" key={`/${name}`}>
1057
+ <PathNode
1058
+ name={name}
1059
+ tree={subTree}
1060
+ moduleId={`/${name}` as ModuleId}
1061
+ setSelectedModuleId={setSelectedModuleId}
1062
+ />
1063
+ </div>
1064
+ ))}
1065
+ </Tree>
1066
+ );
1067
+ }
1068
+
1069
+ function PathNode({
1070
+ name,
1071
+ tree,
1072
+ moduleId,
1073
+ setSelectedModuleId,
1074
+ }: {
1075
+ name: string;
1076
+ tree: Tree;
1077
+ moduleId: ModuleId;
1078
+ setSelectedModuleId: (moduleId: ModuleId | SourcePath) => void;
1079
+ }): React.ReactElement {
1080
+ return (
1081
+ <div>
1082
+ <button
1083
+ onClick={() => {
1084
+ setSelectedModuleId(moduleId);
1085
+ }}
1086
+ >
1087
+ {name}
1088
+ </button>
1089
+ {Object.entries(tree).map(([childName, childTree]) => (
1090
+ <div className="px-4 py-1" key={`${moduleId}/${childName}` as ModuleId}>
1091
+ <PathNode
1092
+ name={childName}
1093
+ tree={childTree}
1094
+ moduleId={`${moduleId}/${childName}` as ModuleId}
1095
+ setSelectedModuleId={setSelectedModuleId}
1096
+ />
1097
+ </div>
1098
+ ))}
1099
+ </div>
1100
+ );
1101
+ }
1102
+
1103
+ const theme: { tags: Record<string, string>; classes: Record<string, string> } =
1104
+ {
1105
+ tags: {
1106
+ h1: "font-bold",
1107
+ h2: "font-bold",
1108
+ h3: "font-bold",
1109
+ h4: "font-bold",
1110
+ h5: "font-bold",
1111
+ h6: "font-bold",
1112
+ p: "",
1113
+ },
1114
+ classes: {
1115
+ bold: "font-bold",
1116
+ italic: "italic",
1117
+ lineThrough: "line-through",
1118
+ },
1119
+ };
1120
+
1121
+ export function ValRichText({
1122
+ children,
1123
+ }: {
1124
+ children: RichText<AnyRichTextOptions>;
1125
+ }) {
1126
+ const root = children as RichText<AnyRichTextOptions> & {
1127
+ valPath: SourcePath;
1128
+ };
1129
+ function withRenderTag(clazz: string, current?: string) {
1130
+ const renderClass = theme.tags[clazz];
1131
+ if (renderClass && current) {
1132
+ return [current, renderClass].join(" ");
1133
+ }
1134
+ if (renderClass) {
1135
+ return renderClass;
1136
+ }
1137
+ return current;
1138
+ }
1139
+ function withRenderClass(clazz: string, current?: string) {
1140
+ const renderClass = theme.classes[clazz];
1141
+ if (renderClass && current) {
1142
+ return [current, renderClass].join(" ");
1143
+ }
1144
+ if (renderClass) {
1145
+ return renderClass;
1146
+ }
1147
+ return current;
1148
+ }
1149
+
1150
+ function toReact(
1151
+ node: RichTextNode<AnyRichTextOptions>,
1152
+ key: number | string
1153
+ ): React.ReactNode {
1154
+ if (typeof node === "string") {
1155
+ return node;
1156
+ }
1157
+ if (node.tag === "p") {
1158
+ return (
1159
+ <p className={withRenderTag("p")} key={key}>
1160
+ {node.children.map((child, key) => toReact(child, key))}
1161
+ </p>
1162
+ );
1163
+ }
1164
+ if (node.tag === "img") {
1165
+ return <img className={withRenderTag("img")} key={key} src={node.src} />;
1166
+ }
1167
+ if (node.tag === "ul") {
1168
+ return (
1169
+ <ul className={withRenderTag("ul")} key={key}>
1170
+ {node.children.map((child, key) => toReact(child, key))}
1171
+ </ul>
1172
+ );
1173
+ }
1174
+ if (node.tag === "ol") {
1175
+ return (
1176
+ <ol className={withRenderTag("ol")} key={key}>
1177
+ {node.children.map((child, key) => toReact(child, key))}
1178
+ </ol>
1179
+ );
1180
+ }
1181
+ if (node.tag === "li") {
1182
+ return (
1183
+ <li className={withRenderTag("li")} key={key}>
1184
+ {node.children.map((child, key) => toReact(child, key))}
1185
+ </li>
1186
+ );
1187
+ }
1188
+ if (node.tag === "span") {
1189
+ return (
1190
+ <span
1191
+ key={key}
1192
+ className={node.classes
1193
+ .map((nodeClass) => {
1194
+ switch (nodeClass) {
1195
+ case "bold":
1196
+ return withRenderClass("bold");
1197
+ case "line-through":
1198
+ return withRenderClass("lineThrough");
1199
+ case "italic":
1200
+ return withRenderClass("italic");
1201
+ }
1202
+ })
1203
+ .join(" ")}
1204
+ >
1205
+ {node.children.map((child, key) => toReact(child, key))}
1206
+ </span>
1207
+ );
1208
+ }
1209
+ if (node.tag === "h1") {
1210
+ return (
1211
+ <h1 className={withRenderTag("h1")} key={key}>
1212
+ {node.children.map((child, key) => toReact(child, key))}
1213
+ </h1>
1214
+ );
1215
+ }
1216
+ if (node.tag === "h2") {
1217
+ return (
1218
+ <h2 className={withRenderTag("h2")} key={key}>
1219
+ {node.children.map((child, key) => toReact(child, key))}
1220
+ </h2>
1221
+ );
1222
+ }
1223
+ if (node.tag === "h3") {
1224
+ return (
1225
+ <h3 className={withRenderTag("h3")} key={key}>
1226
+ {node.children.map((child, key) => toReact(child, key))}
1227
+ </h3>
1228
+ );
1229
+ }
1230
+ if (node.tag === "h4") {
1231
+ return (
1232
+ <h4 className={withRenderTag("h4")} key={key}>
1233
+ {node.children.map((child, key) => toReact(child, key))}
1234
+ </h4>
1235
+ );
1236
+ }
1237
+ if (node.tag === "h5") {
1238
+ return (
1239
+ <h5 className={withRenderTag("h5")} key={key}>
1240
+ {node.children.map((child, key) => toReact(child, key))}
1241
+ </h5>
1242
+ );
1243
+ }
1244
+ if (node.tag === "h6") {
1245
+ return (
1246
+ <h6 className={withRenderTag("h6")} key={key}>
1247
+ {node.children.map((child, key) => toReact(child, key))}
1248
+ </h6>
1249
+ );
1250
+ }
1251
+
1252
+ if (node.tag === "br") {
1253
+ return <br key={key} />;
1254
+ }
1255
+ if (node.tag === "a") {
1256
+ return (
1257
+ <a href={node.href} key={key}>
1258
+ {node.children.map((child, key) => toReact(child, key))}
1259
+ </a>
1260
+ );
1261
+ }
1262
+ console.error("Unknown tag", node.tag);
1263
+ const _exhaustiveCheck: never = node.tag;
1264
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
1265
+ const anyNode = _exhaustiveCheck as any;
1266
+ if (!anyNode?.tag) {
1267
+ return null;
1268
+ }
1269
+ return React.createElement(anyNode.tag, {
1270
+ key,
1271
+ className: anyNode.class?.join(" "),
1272
+ children: anyNode.children?.map(toReact),
1273
+ });
1274
+ }
1275
+
1276
+ return (
1277
+ <span data-val-path={root.valPath}>
1278
+ {root.children.map((child, i) => {
1279
+ return toReact(child, i);
1280
+ })}
1281
+ </span>
1282
+ );
1283
+ }