@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.
- package/.storybook/theme.css +5 -1
- package/components.json +16 -0
- package/dist/valbuild-ui.cjs.d.ts +11 -7
- package/dist/valbuild-ui.cjs.js +43607 -33216
- package/dist/valbuild-ui.esm.js +48313 -37938
- package/fix-server-hack.js +45 -0
- package/fullscreen.vite.config.ts +11 -0
- package/index.html +13 -0
- package/package.json +52 -13
- package/server/dist/manifest.json +16 -0
- package/server/dist/style.css +2145 -0
- package/server/dist/valbuild-ui-main.cjs.js +74441 -0
- package/server/dist/valbuild-ui-main.esm.js +74442 -0
- package/server/dist/valbuild-ui-server.cjs.js +19 -2
- package/server/dist/valbuild-ui-server.esm.js +19 -2
- package/server.vite.config.ts +2 -0
- package/src/App.tsx +73 -0
- package/src/assets/icons/Logo.tsx +103 -0
- package/src/components/Button.tsx +10 -2
- package/src/components/Dropdown.tsx +2 -2
- package/src/components/{dashboard/Grid.stories.tsx → Grid.stories.tsx} +8 -17
- package/src/components/{dashboard/Grid.tsx → Grid.tsx} +36 -23
- package/src/components/RichTextEditor/ContentEditable.tsx +109 -1
- package/src/components/RichTextEditor/Plugins/Toolbar.tsx +2 -2
- package/src/components/RichTextEditor/RichTextEditor.tsx +1 -1
- package/src/components/ValFormField.tsx +576 -0
- package/src/components/ValFullscreen.tsx +1283 -0
- package/src/components/ValMenu.tsx +65 -13
- package/src/components/ValOverlay.tsx +32 -338
- package/src/components/ValWindow.tsx +12 -9
- package/src/components/dashboard/FormGroup.tsx +12 -6
- package/src/components/dashboard/Tree.tsx +2 -2
- package/src/components/ui/accordion.tsx +58 -0
- package/src/components/ui/alert-dialog.tsx +139 -0
- package/src/components/ui/avatar.tsx +48 -0
- package/src/components/ui/button.tsx +56 -0
- package/src/components/ui/calendar.tsx +62 -0
- package/src/components/ui/card.tsx +86 -0
- package/src/components/ui/checkbox.tsx +28 -0
- package/src/components/ui/command.tsx +153 -0
- package/src/components/ui/dialog.tsx +120 -0
- package/src/components/ui/dropdown-menu.tsx +198 -0
- package/src/components/ui/form.tsx +177 -0
- package/src/components/ui/input.tsx +24 -0
- package/src/components/ui/label.tsx +24 -0
- package/src/components/ui/popover.tsx +29 -0
- package/src/components/ui/progress.tsx +26 -0
- package/src/components/ui/radio-group.tsx +42 -0
- package/src/components/ui/scroll-area.tsx +51 -0
- package/src/components/ui/select.tsx +119 -0
- package/src/components/ui/switch.tsx +27 -0
- package/src/components/ui/tabs.tsx +53 -0
- package/src/components/ui/toggle.tsx +43 -0
- package/src/components/ui/tooltip.tsx +28 -0
- package/src/components/usePatch.ts +86 -0
- package/src/components/useTheme.ts +45 -0
- package/src/exports.ts +2 -1
- package/src/index.css +96 -60
- package/src/lib/IValStore.ts +6 -0
- package/src/lib/utils.ts +6 -0
- package/src/main.jsx +10 -0
- package/src/richtext/conversion/lexicalToRichTextSource.ts +0 -1
- package/src/richtext/shadowRootPolyFill.js +115 -0
- package/src/server.ts +39 -2
- package/src/utils/resolvePath.ts +0 -1
- package/src/vite-server.ts +20 -3
- package/tailwind.config.js +63 -51
- package/tsconfig.json +2 -1
- package/vite.config.ts +10 -0
- 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
|
+
}
|