@treenity/react 3.0.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/dist/AclEditor.d.ts +11 -0
- package/dist/AclEditor.d.ts.map +1 -0
- package/dist/AclEditor.js +152 -0
- package/dist/AclEditor.js.map +1 -0
- package/dist/App.d.ts +2 -0
- package/dist/App.d.ts.map +1 -0
- package/dist/App.js +521 -0
- package/dist/App.js.map +1 -0
- package/dist/Inspector.d.ts +12 -0
- package/dist/Inspector.d.ts.map +1 -0
- package/dist/Inspector.js +360 -0
- package/dist/Inspector.js.map +1 -0
- package/dist/Tree.d.ts +16 -0
- package/dist/Tree.d.ts.map +1 -0
- package/dist/Tree.js +100 -0
- package/dist/Tree.js.map +1 -0
- package/dist/ViewPage.d.ts +5 -0
- package/dist/ViewPage.d.ts.map +1 -0
- package/dist/ViewPage.js +13 -0
- package/dist/ViewPage.js.map +1 -0
- package/dist/bind/computed.d.ts +9 -0
- package/dist/bind/computed.d.ts.map +1 -0
- package/dist/bind/computed.js +61 -0
- package/dist/bind/computed.js.map +1 -0
- package/dist/bind/engine.d.ts +3 -0
- package/dist/bind/engine.d.ts.map +1 -0
- package/dist/bind/engine.js +184 -0
- package/dist/bind/engine.js.map +1 -0
- package/dist/bind/eval.d.ts +13 -0
- package/dist/bind/eval.d.ts.map +1 -0
- package/dist/bind/eval.js +97 -0
- package/dist/bind/eval.js.map +1 -0
- package/dist/bind/hook.d.ts +8 -0
- package/dist/bind/hook.d.ts.map +1 -0
- package/dist/bind/hook.js +99 -0
- package/dist/bind/hook.js.map +1 -0
- package/dist/bind/parse.d.ts +19 -0
- package/dist/bind/parse.d.ts.map +1 -0
- package/dist/bind/parse.js +86 -0
- package/dist/bind/parse.js.map +1 -0
- package/dist/bind/pipes.d.ts +4 -0
- package/dist/bind/pipes.d.ts.map +1 -0
- package/dist/bind/pipes.js +43 -0
- package/dist/bind/pipes.js.map +1 -0
- package/dist/cache.d.ts +27 -0
- package/dist/cache.d.ts.map +1 -0
- package/dist/cache.js +236 -0
- package/dist/cache.js.map +1 -0
- package/dist/client-tree.d.ts +9 -0
- package/dist/client-tree.d.ts.map +1 -0
- package/dist/client-tree.js +14 -0
- package/dist/client-tree.js.map +1 -0
- package/dist/client.d.ts +2 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +10 -0
- package/dist/client.js.map +1 -0
- package/dist/components/ui/accordion.d.ts +8 -0
- package/dist/components/ui/accordion.d.ts.map +1 -0
- package/dist/components/ui/accordion.js +18 -0
- package/dist/components/ui/accordion.js.map +1 -0
- package/dist/components/ui/badge.d.ts +10 -0
- package/dist/components/ui/badge.d.ts.map +1 -0
- package/dist/components/ui/badge.js +19 -0
- package/dist/components/ui/badge.js.map +1 -0
- package/dist/components/ui/button.d.ts +11 -0
- package/dist/components/ui/button.d.ts.map +1 -0
- package/dist/components/ui/button.js +31 -0
- package/dist/components/ui/button.js.map +1 -0
- package/dist/components/ui/checkbox.d.ts +4 -0
- package/dist/components/ui/checkbox.d.ts.map +1 -0
- package/dist/components/ui/checkbox.js +7 -0
- package/dist/components/ui/checkbox.js.map +1 -0
- package/dist/components/ui/dialog.d.ts +18 -0
- package/dist/components/ui/dialog.d.ts.map +1 -0
- package/dist/components/ui/dialog.js +37 -0
- package/dist/components/ui/dialog.js.map +1 -0
- package/dist/components/ui/drawer.d.ts +14 -0
- package/dist/components/ui/drawer.d.ts.map +1 -0
- package/dist/components/ui/drawer.js +35 -0
- package/dist/components/ui/drawer.js.map +1 -0
- package/dist/components/ui/input.d.ts +4 -0
- package/dist/components/ui/input.d.ts.map +1 -0
- package/dist/components/ui/input.js +7 -0
- package/dist/components/ui/input.js.map +1 -0
- package/dist/components/ui/label.d.ts +5 -0
- package/dist/components/ui/label.d.ts.map +1 -0
- package/dist/components/ui/label.js +8 -0
- package/dist/components/ui/label.js.map +1 -0
- package/dist/components/ui/popover.d.ts +11 -0
- package/dist/components/ui/popover.d.ts.map +1 -0
- package/dist/components/ui/popover.js +26 -0
- package/dist/components/ui/popover.js.map +1 -0
- package/dist/components/ui/progress.d.ts +5 -0
- package/dist/components/ui/progress.d.ts.map +1 -0
- package/dist/components/ui/progress.js +9 -0
- package/dist/components/ui/progress.js.map +1 -0
- package/dist/components/ui/select.d.ts +16 -0
- package/dist/components/ui/select.d.ts.map +1 -0
- package/dist/components/ui/select.js +39 -0
- package/dist/components/ui/select.js.map +1 -0
- package/dist/components/ui/slider.d.ts +5 -0
- package/dist/components/ui/slider.d.ts.map +1 -0
- package/dist/components/ui/slider.js +15 -0
- package/dist/components/ui/slider.js.map +1 -0
- package/dist/components/ui/sonner.d.ts +4 -0
- package/dist/components/ui/sonner.d.ts.map +1 -0
- package/dist/components/ui/sonner.js +21 -0
- package/dist/components/ui/sonner.js.map +1 -0
- package/dist/components/ui/switch.d.ts +7 -0
- package/dist/components/ui/switch.d.ts.map +1 -0
- package/dist/components/ui/switch.js +9 -0
- package/dist/components/ui/switch.js.map +1 -0
- package/dist/components/ui/textarea.d.ts +4 -0
- package/dist/components/ui/textarea.d.ts.map +1 -0
- package/dist/components/ui/textarea.js +7 -0
- package/dist/components/ui/textarea.js.map +1 -0
- package/dist/components/ui/tooltip.d.ts +8 -0
- package/dist/components/ui/tooltip.d.ts.map +1 -0
- package/dist/components/ui/tooltip.js +18 -0
- package/dist/components/ui/tooltip.js.map +1 -0
- package/dist/context/index.d.ts +31 -0
- package/dist/context/index.d.ts.map +1 -0
- package/dist/context/index.js +98 -0
- package/dist/context/index.js.map +1 -0
- package/dist/context.d.ts +2 -0
- package/dist/context.d.ts.map +1 -0
- package/dist/context.js +2 -0
- package/dist/context.js.map +1 -0
- package/dist/hooks.d.ts +21 -0
- package/dist/hooks.d.ts.map +1 -0
- package/dist/hooks.js +156 -0
- package/dist/hooks.js.map +1 -0
- package/dist/idb.d.ts +13 -0
- package/dist/idb.d.ts.map +1 -0
- package/dist/idb.js +67 -0
- package/dist/idb.js.map +1 -0
- package/dist/lib/minimd.d.ts +3 -0
- package/dist/lib/minimd.d.ts.map +1 -0
- package/dist/lib/minimd.js +97 -0
- package/dist/lib/minimd.js.map +1 -0
- package/dist/lib/utils.d.ts +3 -0
- package/dist/lib/utils.d.ts.map +1 -0
- package/dist/lib/utils.js +6 -0
- package/dist/lib/utils.js.map +1 -0
- package/dist/load-client.d.ts +2 -0
- package/dist/load-client.d.ts.map +1 -0
- package/dist/load-client.js +6 -0
- package/dist/load-client.js.map +1 -0
- package/dist/main.d.ts +4 -0
- package/dist/main.d.ts.map +1 -0
- package/dist/main.js +16 -0
- package/dist/main.js.map +1 -0
- package/dist/mods/editor-ui/client.d.ts +6 -0
- package/dist/mods/editor-ui/client.d.ts.map +1 -0
- package/dist/mods/editor-ui/client.js +8 -0
- package/dist/mods/editor-ui/client.js.map +1 -0
- package/dist/mods/editor-ui/default-view.d.ts +2 -0
- package/dist/mods/editor-ui/default-view.d.ts.map +1 -0
- package/dist/mods/editor-ui/default-view.js +71 -0
- package/dist/mods/editor-ui/default-view.js.map +1 -0
- package/dist/mods/editor-ui/dir-view.d.ts +2 -0
- package/dist/mods/editor-ui/dir-view.d.ts.map +1 -0
- package/dist/mods/editor-ui/dir-view.js +42 -0
- package/dist/mods/editor-ui/dir-view.js.map +1 -0
- package/dist/mods/editor-ui/form-fields.d.ts +6 -0
- package/dist/mods/editor-ui/form-fields.d.ts.map +1 -0
- package/dist/mods/editor-ui/form-fields.js +401 -0
- package/dist/mods/editor-ui/form-fields.js.map +1 -0
- package/dist/mods/editor-ui/layout-view.d.ts +2 -0
- package/dist/mods/editor-ui/layout-view.d.ts.map +1 -0
- package/dist/mods/editor-ui/layout-view.js +22 -0
- package/dist/mods/editor-ui/layout-view.js.map +1 -0
- package/dist/mods/editor-ui/list-items.d.ts +2 -0
- package/dist/mods/editor-ui/list-items.d.ts.map +1 -0
- package/dist/mods/editor-ui/list-items.js +38 -0
- package/dist/mods/editor-ui/list-items.js.map +1 -0
- package/dist/mods/editor-ui/node-utils.d.ts +10 -0
- package/dist/mods/editor-ui/node-utils.d.ts.map +1 -0
- package/dist/mods/editor-ui/node-utils.js +76 -0
- package/dist/mods/editor-ui/node-utils.js.map +1 -0
- package/dist/mods/editor-ui/user-view.d.ts +2 -0
- package/dist/mods/editor-ui/user-view.d.ts.map +1 -0
- package/dist/mods/editor-ui/user-view.js +47 -0
- package/dist/mods/editor-ui/user-view.js.map +1 -0
- package/dist/mods/treenity/client.d.ts +4 -0
- package/dist/mods/treenity/client.d.ts.map +1 -0
- package/dist/mods/treenity/client.js +6 -0
- package/dist/mods/treenity/client.js.map +1 -0
- package/dist/mods/treenity/groups/index.d.ts +2 -0
- package/dist/mods/treenity/groups/index.d.ts.map +1 -0
- package/dist/mods/treenity/groups/index.js +27 -0
- package/dist/mods/treenity/groups/index.js.map +1 -0
- package/dist/mods/treenity/preview.d.ts +6 -0
- package/dist/mods/treenity/preview.d.ts.map +1 -0
- package/dist/mods/treenity/preview.js +95 -0
- package/dist/mods/treenity/preview.js.map +1 -0
- package/dist/mods/treenity/ref-view.d.ts +2 -0
- package/dist/mods/treenity/ref-view.d.ts.map +1 -0
- package/dist/mods/treenity/ref-view.js +29 -0
- package/dist/mods/treenity/ref-view.js.map +1 -0
- package/dist/mods/treenity/schema-form.d.ts +2 -0
- package/dist/mods/treenity/schema-form.d.ts.map +1 -0
- package/dist/mods/treenity/schema-form.js +38 -0
- package/dist/mods/treenity/schema-form.js.map +1 -0
- package/dist/mods/treenity/seed.d.ts +2 -0
- package/dist/mods/treenity/seed.d.ts.map +1 -0
- package/dist/mods/treenity/seed.js +53 -0
- package/dist/mods/treenity/seed.js.map +1 -0
- package/dist/mods/treenity/server.d.ts +2 -0
- package/dist/mods/treenity/server.d.ts.map +1 -0
- package/dist/mods/treenity/server.js +2 -0
- package/dist/mods/treenity/server.js.map +1 -0
- package/dist/mods/treenity/type-view.d.ts +2 -0
- package/dist/mods/treenity/type-view.d.ts.map +1 -0
- package/dist/mods/treenity/type-view.js +36 -0
- package/dist/mods/treenity/type-view.js.map +1 -0
- package/dist/remote-tree.d.ts +6 -0
- package/dist/remote-tree.d.ts.map +1 -0
- package/dist/remote-tree.js +18 -0
- package/dist/remote-tree.js.map +1 -0
- package/dist/schema-loader.d.ts +19 -0
- package/dist/schema-loader.d.ts.map +1 -0
- package/dist/schema-loader.js +63 -0
- package/dist/schema-loader.js.map +1 -0
- package/dist/trpc.d.ts +187 -0
- package/dist/trpc.d.ts.map +1 -0
- package/dist/trpc.js +21 -0
- package/dist/trpc.js.map +1 -0
- package/package.json +88 -0
- package/src/AclEditor.tsx +330 -0
- package/src/App.tsx +775 -0
- package/src/CLAUDE.md +16 -0
- package/src/Inspector.tsx +857 -0
- package/src/Tree.tsx +237 -0
- package/src/ViewPage.tsx +45 -0
- package/src/bind/bind.test.ts +316 -0
- package/src/bind/computed.ts +64 -0
- package/src/bind/engine.ts +198 -0
- package/src/bind/eval.ts +108 -0
- package/src/bind/hook.ts +112 -0
- package/src/bind/parse.ts +104 -0
- package/src/bind/pipes.ts +71 -0
- package/src/cache.test.ts +139 -0
- package/src/cache.ts +244 -0
- package/src/client-tree.test.ts +116 -0
- package/src/client-tree.ts +24 -0
- package/src/client.ts +11 -0
- package/src/components/ui/accordion.tsx +63 -0
- package/src/components/ui/badge.tsx +27 -0
- package/src/components/ui/button.tsx +44 -0
- package/src/components/ui/checkbox.tsx +19 -0
- package/src/components/ui/dialog.tsx +156 -0
- package/src/components/ui/drawer.tsx +132 -0
- package/src/components/ui/input.tsx +19 -0
- package/src/components/ui/label.tsx +21 -0
- package/src/components/ui/popover.tsx +86 -0
- package/src/components/ui/progress.tsx +30 -0
- package/src/components/ui/select.tsx +189 -0
- package/src/components/ui/slider.tsx +62 -0
- package/src/components/ui/sonner.tsx +32 -0
- package/src/components/ui/switch.tsx +34 -0
- package/src/components/ui/textarea.tsx +17 -0
- package/src/components/ui/tooltip.tsx +56 -0
- package/src/context/index.tsx +131 -0
- package/src/context.ts +1 -0
- package/src/hooks.ts +208 -0
- package/src/idb.ts +80 -0
- package/src/index.html +14 -0
- package/src/lib/minimd.css +28 -0
- package/src/lib/minimd.ts +95 -0
- package/src/lib/utils.ts +6 -0
- package/src/load-client.ts +5 -0
- package/src/main.tsx +22 -0
- package/src/mods/editor-ui/CLAUDE.md +3 -0
- package/src/mods/editor-ui/client.ts +8 -0
- package/src/mods/editor-ui/default-view.tsx +148 -0
- package/src/mods/editor-ui/dir-view.tsx +91 -0
- package/src/mods/editor-ui/form-fields.tsx +861 -0
- package/src/mods/editor-ui/layout-view.tsx +62 -0
- package/src/mods/editor-ui/list-items.tsx +63 -0
- package/src/mods/editor-ui/node-utils.ts +84 -0
- package/src/mods/editor-ui/user-view.tsx +101 -0
- package/src/mods/treenity/CLAUDE.md +7 -0
- package/src/mods/treenity/client.ts +6 -0
- package/src/mods/treenity/groups/index.tsx +65 -0
- package/src/mods/treenity/preview.tsx +133 -0
- package/src/mods/treenity/ref-view.tsx +87 -0
- package/src/mods/treenity/schema-form.tsx +65 -0
- package/src/mods/treenity/seed.ts +56 -0
- package/src/mods/treenity/server.ts +1 -0
- package/src/mods/treenity/type-view.tsx +116 -0
- package/src/remote-tree.test.ts +142 -0
- package/src/remote-tree.ts +25 -0
- package/src/schema-loader.ts +84 -0
- package/src/style.css +1269 -0
- package/src/trpc.ts +27 -0
- package/src/vite-env.d.ts +3 -0
|
@@ -0,0 +1,861 @@
|
|
|
1
|
+
// react:form handlers — editable fields for inspector panel
|
|
2
|
+
import * as cache from '#cache';
|
|
3
|
+
import { tree as clientStore } from '#client';
|
|
4
|
+
import { useSchema } from '#schema-loader';
|
|
5
|
+
// react view handlers — readOnly display for same types
|
|
6
|
+
// Covers: string, text, textarea, number, integer, boolean, array, object, image, uri, url, select, timestamp, path
|
|
7
|
+
import { register, resolve as resolveHandler } from '@treenity/core/core';
|
|
8
|
+
import dayjs from 'dayjs';
|
|
9
|
+
import { X } from 'lucide-react';
|
|
10
|
+
import { createElement, useCallback, useEffect, useRef, useState, useSyncExternalStore } from 'react';
|
|
11
|
+
|
|
12
|
+
type FP = {
|
|
13
|
+
value: {
|
|
14
|
+
$type: string;
|
|
15
|
+
value: unknown;
|
|
16
|
+
label?: string;
|
|
17
|
+
placeholder?: string;
|
|
18
|
+
enum?: string[];
|
|
19
|
+
items?: { type?: string };
|
|
20
|
+
refType?: string; // component type — field can hold ref or embedded value of this type
|
|
21
|
+
};
|
|
22
|
+
onChange?: (next: any) => void;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
// ── View handlers (react context) — readOnly display ──
|
|
26
|
+
|
|
27
|
+
function StringView({ value }: FP) {
|
|
28
|
+
return <span className="text-xs text-foreground/70">{String(value.value ?? '')}</span>;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function NumberView({ value }: FP) {
|
|
32
|
+
return <span className="text-xs font-mono text-foreground/70">{String(value.value ?? 0)}</span>;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function BooleanView({ value }: FP) {
|
|
36
|
+
return <span className="text-xs text-foreground/70">{value.value ? 'true' : 'false'}</span>;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function ImageView({ value }: FP) {
|
|
40
|
+
const src = typeof value.value === 'string' ? value.value : '';
|
|
41
|
+
return src ? <img src={src} className="max-w-full max-h-[120px] rounded object-contain" /> : null;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function UriView({ value }: FP) {
|
|
45
|
+
const url = String(value.value ?? '');
|
|
46
|
+
return url
|
|
47
|
+
? <a href={url} target="_blank" rel="noopener" className="text-xs text-primary hover:underline truncate block">{url}</a>
|
|
48
|
+
: null;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function TimestampView({ value }: FP) {
|
|
52
|
+
const ts = Number(value.value ?? 0);
|
|
53
|
+
const formatted = ts ? dayjs(ts > 1e12 ? ts : ts * 1000).format('YYYY-MM-DD HH:mm:ss') : '—';
|
|
54
|
+
return <span className="text-xs font-mono text-foreground/70">{formatted}</span>;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function ArrayView({ value }: FP) {
|
|
58
|
+
const arr = Array.isArray(value.value) ? (value.value as unknown[]) : [];
|
|
59
|
+
if (arr.length === 0) return <span className="text-xs text-muted-foreground">[]</span>;
|
|
60
|
+
if (arr.every((v) => typeof v === 'string')) {
|
|
61
|
+
return (
|
|
62
|
+
<div className="flex flex-wrap gap-1">
|
|
63
|
+
{(arr as string[]).map((tag, i) => (
|
|
64
|
+
<span key={i} className="text-[11px] font-mono bg-muted text-foreground/70 px-1.5 py-0.5 rounded">{tag}</span>
|
|
65
|
+
))}
|
|
66
|
+
</div>
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
return (
|
|
70
|
+
<pre className="text-[11px] font-mono text-foreground/70 bg-muted rounded p-2 overflow-auto max-h-[200px]">
|
|
71
|
+
{JSON.stringify(arr, null, 2)}
|
|
72
|
+
</pre>
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function ObjectView({ value }: FP) {
|
|
77
|
+
const obj = (typeof value.value === 'object' && value.value !== null ? value.value : {}) as Record<string, unknown>;
|
|
78
|
+
const entries = Object.entries(obj);
|
|
79
|
+
if (entries.length === 0) return <span className="text-xs text-muted-foreground">{'{}'}</span>;
|
|
80
|
+
return (
|
|
81
|
+
<div className="space-y-0.5">
|
|
82
|
+
{entries.map(([k, v]) => (
|
|
83
|
+
<div key={k} className="flex gap-2 text-[11px]">
|
|
84
|
+
<span className="font-mono text-muted-foreground shrink-0">{k}:</span>
|
|
85
|
+
<span className="font-mono text-foreground/70 truncate">
|
|
86
|
+
{typeof v === 'object' ? JSON.stringify(v) : String(v ?? '')}
|
|
87
|
+
</span>
|
|
88
|
+
</div>
|
|
89
|
+
))}
|
|
90
|
+
</div>
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// ── Form handlers (react:form context) — editable ──
|
|
95
|
+
|
|
96
|
+
function StringForm({ value, onChange }: FP) {
|
|
97
|
+
// enum → select dropdown
|
|
98
|
+
if (value.enum && value.enum.length > 0) {
|
|
99
|
+
return (
|
|
100
|
+
<select
|
|
101
|
+
value={String(value.value ?? '')}
|
|
102
|
+
onChange={(e) => onChange?.({ ...value, value: e.target.value })}
|
|
103
|
+
>
|
|
104
|
+
<option value="">—</option>
|
|
105
|
+
{value.enum.map((v) => (
|
|
106
|
+
<option key={v} value={v}>{v}</option>
|
|
107
|
+
))}
|
|
108
|
+
</select>
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return (
|
|
113
|
+
<input
|
|
114
|
+
value={String(value.value ?? '')}
|
|
115
|
+
placeholder={value.placeholder}
|
|
116
|
+
onChange={(e) => onChange?.({ ...value, value: e.target.value })}
|
|
117
|
+
/>
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function TextForm({ value, onChange }: FP) {
|
|
122
|
+
return (
|
|
123
|
+
<textarea
|
|
124
|
+
value={String(value.value ?? '')}
|
|
125
|
+
placeholder={value.placeholder}
|
|
126
|
+
onChange={(e) => onChange?.({ ...value, value: e.target.value })}
|
|
127
|
+
/>
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function NumberForm({ value, onChange }: FP) {
|
|
132
|
+
return (
|
|
133
|
+
<input
|
|
134
|
+
type="number"
|
|
135
|
+
value={String(value.value ?? 0)}
|
|
136
|
+
onChange={(e) => onChange?.({ ...value, value: Number(e.target.value) })}
|
|
137
|
+
/>
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function IntegerForm({ value, onChange }: FP) {
|
|
142
|
+
return (
|
|
143
|
+
<input
|
|
144
|
+
type="number"
|
|
145
|
+
step="1"
|
|
146
|
+
value={String(value.value ?? 0)}
|
|
147
|
+
onChange={(e) => onChange?.({ ...value, value: Math.round(Number(e.target.value)) })}
|
|
148
|
+
/>
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function BooleanForm({ value, onChange }: FP) {
|
|
153
|
+
return (
|
|
154
|
+
<label className="flex items-center gap-2 cursor-pointer">
|
|
155
|
+
<input
|
|
156
|
+
type="checkbox"
|
|
157
|
+
className="w-auto"
|
|
158
|
+
checked={!!value.value}
|
|
159
|
+
onChange={(e) => onChange?.({ ...value, value: e.target.checked })}
|
|
160
|
+
/>
|
|
161
|
+
<span className="text-xs text-muted-foreground">{value.label}</span>
|
|
162
|
+
</label>
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function ImageForm({ value, onChange }: FP) {
|
|
167
|
+
const src = typeof value.value === 'string' ? value.value : '';
|
|
168
|
+
return (
|
|
169
|
+
<div className="space-y-2">
|
|
170
|
+
<input
|
|
171
|
+
value={src}
|
|
172
|
+
placeholder="Image URL"
|
|
173
|
+
onChange={(e) => onChange?.({ ...value, value: e.target.value })}
|
|
174
|
+
/>
|
|
175
|
+
{src && <img src={src} className="max-w-full max-h-[120px] rounded object-contain" />}
|
|
176
|
+
</div>
|
|
177
|
+
);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function UriForm({ value, onChange }: FP) {
|
|
181
|
+
const url = String(value.value ?? '');
|
|
182
|
+
return (
|
|
183
|
+
<div className="space-y-1">
|
|
184
|
+
<input
|
|
185
|
+
value={url}
|
|
186
|
+
placeholder={value.placeholder ?? 'https://...'}
|
|
187
|
+
onChange={(e) => onChange?.({ ...value, value: e.target.value })}
|
|
188
|
+
/>
|
|
189
|
+
{url && (
|
|
190
|
+
<a href={url} target="_blank" rel="noopener" className="text-[10px] text-primary hover:underline truncate block">
|
|
191
|
+
{url}
|
|
192
|
+
</a>
|
|
193
|
+
)}
|
|
194
|
+
</div>
|
|
195
|
+
);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function TimestampForm({ value, onChange }: FP) {
|
|
199
|
+
const ts = Number(value.value ?? 0);
|
|
200
|
+
const formatted = ts ? dayjs(ts > 1e12 ? ts : ts * 1000).format('YYYY-MM-DD HH:mm:ss') : '—';
|
|
201
|
+
return (
|
|
202
|
+
<div className="flex items-center gap-2">
|
|
203
|
+
<input
|
|
204
|
+
type="number"
|
|
205
|
+
className="flex-1"
|
|
206
|
+
value={String(ts)}
|
|
207
|
+
onChange={(e) => onChange?.({ ...value, value: Number(e.target.value) })}
|
|
208
|
+
/>
|
|
209
|
+
<span className="text-[10px] text-muted-foreground whitespace-nowrap">{formatted}</span>
|
|
210
|
+
</div>
|
|
211
|
+
);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function SelectForm({ value, onChange }: FP) {
|
|
215
|
+
const opts = value.enum ?? [];
|
|
216
|
+
return (
|
|
217
|
+
<select
|
|
218
|
+
value={String(value.value ?? '')}
|
|
219
|
+
onChange={(e) => onChange?.({ ...value, value: e.target.value })}
|
|
220
|
+
>
|
|
221
|
+
<option value="">—</option>
|
|
222
|
+
{opts.map((v) => (
|
|
223
|
+
<option key={v} value={v}>{v}</option>
|
|
224
|
+
))}
|
|
225
|
+
</select>
|
|
226
|
+
);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function ObjectForm({ value, onChange }: FP) {
|
|
230
|
+
const [mode, setMode] = useState<'fields' | 'json'>('fields');
|
|
231
|
+
const [jsonDraft, setJsonDraft] = useState('');
|
|
232
|
+
const [jsonError, setJsonError] = useState(false);
|
|
233
|
+
const [newKey, setNewKey] = useState('');
|
|
234
|
+
const obj = (typeof value.value === 'object' && value.value !== null ? value.value : {}) as Record<
|
|
235
|
+
string,
|
|
236
|
+
unknown
|
|
237
|
+
>;
|
|
238
|
+
const emit = (next: Record<string, unknown>) => onChange?.({ ...value, value: next });
|
|
239
|
+
const entries = Object.entries(obj);
|
|
240
|
+
|
|
241
|
+
const modeToggle = (
|
|
242
|
+
<div className="flex gap-1 mb-1">
|
|
243
|
+
<button
|
|
244
|
+
type="button"
|
|
245
|
+
className={`border-0 px-2 py-0.5 text-[10px] rounded ${mode === 'fields' ? 'bg-muted text-foreground' : 'bg-transparent text-muted-foreground hover:text-foreground'}`}
|
|
246
|
+
onClick={() => setMode('fields')}
|
|
247
|
+
>
|
|
248
|
+
Fields
|
|
249
|
+
</button>
|
|
250
|
+
<button
|
|
251
|
+
type="button"
|
|
252
|
+
className={`border-0 px-2 py-0.5 text-[10px] rounded ${mode === 'json' ? 'bg-muted text-foreground' : 'bg-transparent text-muted-foreground hover:text-foreground'}`}
|
|
253
|
+
onClick={() => {
|
|
254
|
+
setJsonDraft(JSON.stringify(obj, null, 2));
|
|
255
|
+
setJsonError(false);
|
|
256
|
+
setMode('json');
|
|
257
|
+
}}
|
|
258
|
+
>
|
|
259
|
+
JSON
|
|
260
|
+
</button>
|
|
261
|
+
</div>
|
|
262
|
+
);
|
|
263
|
+
|
|
264
|
+
if (mode === 'json') {
|
|
265
|
+
return (
|
|
266
|
+
<div className="rounded border border-border/50 bg-muted/30 p-2">
|
|
267
|
+
{modeToggle}
|
|
268
|
+
<textarea
|
|
269
|
+
className={`text-[11px] min-h-[60px] ${jsonError ? 'border-destructive' : ''}`}
|
|
270
|
+
value={jsonDraft}
|
|
271
|
+
onChange={(e) => {
|
|
272
|
+
setJsonDraft(e.target.value);
|
|
273
|
+
try {
|
|
274
|
+
const parsed = JSON.parse(e.target.value);
|
|
275
|
+
if (typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)) {
|
|
276
|
+
emit(parsed);
|
|
277
|
+
setJsonError(false);
|
|
278
|
+
} else {
|
|
279
|
+
setJsonError(true);
|
|
280
|
+
}
|
|
281
|
+
} catch {
|
|
282
|
+
setJsonError(true);
|
|
283
|
+
}
|
|
284
|
+
}}
|
|
285
|
+
/>
|
|
286
|
+
</div>
|
|
287
|
+
);
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
return (
|
|
291
|
+
<div className="rounded border border-border/50 bg-muted/30 p-2">
|
|
292
|
+
{modeToggle}
|
|
293
|
+
|
|
294
|
+
{entries.length > 0 && (
|
|
295
|
+
<div className="space-y-1 mb-1.5">
|
|
296
|
+
{entries.map(([k, v]) => (
|
|
297
|
+
<div key={k} className="flex gap-1.5 items-start group">
|
|
298
|
+
<span className="text-[11px] font-mono text-muted-foreground pt-[5px] min-w-[50px] truncate shrink-0">
|
|
299
|
+
{k}
|
|
300
|
+
</span>
|
|
301
|
+
{typeof v === 'boolean' ? (
|
|
302
|
+
<input
|
|
303
|
+
type="checkbox"
|
|
304
|
+
className="w-auto mt-1.5"
|
|
305
|
+
checked={v}
|
|
306
|
+
onChange={(e) => emit({ ...obj, [k]: e.target.checked })}
|
|
307
|
+
/>
|
|
308
|
+
) : typeof v === 'number' ? (
|
|
309
|
+
<input
|
|
310
|
+
type="number"
|
|
311
|
+
className="flex-1 min-w-0 text-[11px]"
|
|
312
|
+
value={String(v)}
|
|
313
|
+
onChange={(e) => emit({ ...obj, [k]: Number(e.target.value) })}
|
|
314
|
+
/>
|
|
315
|
+
) : typeof v === 'string' ? (
|
|
316
|
+
<input
|
|
317
|
+
className="flex-1 min-w-0 text-[11px]"
|
|
318
|
+
value={v}
|
|
319
|
+
onChange={(e) => emit({ ...obj, [k]: e.target.value })}
|
|
320
|
+
/>
|
|
321
|
+
) : (
|
|
322
|
+
<textarea
|
|
323
|
+
className="flex-1 min-w-0 text-[11px] font-mono min-h-[40px]"
|
|
324
|
+
value={JSON.stringify(v, null, 2)}
|
|
325
|
+
onChange={(e) => {
|
|
326
|
+
try {
|
|
327
|
+
emit({ ...obj, [k]: JSON.parse(e.target.value) });
|
|
328
|
+
} catch {
|
|
329
|
+
/* typing */
|
|
330
|
+
}
|
|
331
|
+
}}
|
|
332
|
+
/>
|
|
333
|
+
)}
|
|
334
|
+
<button
|
|
335
|
+
type="button"
|
|
336
|
+
className="border-0 bg-transparent p-0 mt-1 text-muted-foreground/30 opacity-0 group-hover:opacity-100 hover:text-destructive shrink-0 cursor-pointer transition-opacity"
|
|
337
|
+
onClick={() => {
|
|
338
|
+
const next = { ...obj };
|
|
339
|
+
delete next[k];
|
|
340
|
+
emit(next);
|
|
341
|
+
}}
|
|
342
|
+
>
|
|
343
|
+
<X className="h-3 w-3" />
|
|
344
|
+
</button>
|
|
345
|
+
</div>
|
|
346
|
+
))}
|
|
347
|
+
</div>
|
|
348
|
+
)}
|
|
349
|
+
|
|
350
|
+
<div className="flex gap-1 items-center border-t border-border/30 pt-1.5">
|
|
351
|
+
<input
|
|
352
|
+
className="flex-1 min-w-0 text-[11px] bg-transparent border-dashed"
|
|
353
|
+
placeholder="new key..."
|
|
354
|
+
value={newKey}
|
|
355
|
+
onChange={(e) => setNewKey(e.target.value)}
|
|
356
|
+
onKeyDown={(e) => {
|
|
357
|
+
if (e.key !== 'Enter') return;
|
|
358
|
+
e.preventDefault();
|
|
359
|
+
const k = newKey.trim();
|
|
360
|
+
if (k && !(k in obj)) {
|
|
361
|
+
emit({ ...obj, [k]: '' });
|
|
362
|
+
setNewKey('');
|
|
363
|
+
}
|
|
364
|
+
}}
|
|
365
|
+
/>
|
|
366
|
+
<button
|
|
367
|
+
type="button"
|
|
368
|
+
className="border-0 bg-transparent p-0 text-[11px] text-muted-foreground hover:text-foreground shrink-0 cursor-pointer"
|
|
369
|
+
onClick={() => {
|
|
370
|
+
const k = newKey.trim();
|
|
371
|
+
if (k && !(k in obj)) {
|
|
372
|
+
emit({ ...obj, [k]: '' });
|
|
373
|
+
setNewKey('');
|
|
374
|
+
}
|
|
375
|
+
}}
|
|
376
|
+
>
|
|
377
|
+
+
|
|
378
|
+
</button>
|
|
379
|
+
</div>
|
|
380
|
+
</div>
|
|
381
|
+
);
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
function ArrayForm({ value, onChange }: FP) {
|
|
385
|
+
const [input, setInput] = useState('');
|
|
386
|
+
const arr = Array.isArray(value.value) ? (value.value as unknown[]) : [];
|
|
387
|
+
const itemType = value.items?.type ?? 'string';
|
|
388
|
+
const emit = (next: unknown[]) => onChange?.({ ...value, value: next });
|
|
389
|
+
|
|
390
|
+
if (itemType === 'string') {
|
|
391
|
+
return (
|
|
392
|
+
<div className="rounded border border-border/50 bg-muted/30 p-2 space-y-1.5">
|
|
393
|
+
{arr.length > 0 && (
|
|
394
|
+
<div className="flex flex-wrap gap-1">
|
|
395
|
+
{(arr as string[]).map((tag, i) => (
|
|
396
|
+
<span
|
|
397
|
+
key={i}
|
|
398
|
+
className="inline-flex items-center gap-0.5 text-[11px] font-mono bg-muted text-foreground/70 px-1.5 py-0.5 rounded"
|
|
399
|
+
>
|
|
400
|
+
{tag}
|
|
401
|
+
<button
|
|
402
|
+
type="button"
|
|
403
|
+
className="ml-0.5 border-0 bg-transparent p-0 text-muted-foreground/40 hover:text-foreground leading-none cursor-pointer"
|
|
404
|
+
onClick={() => emit(arr.filter((_, j) => j !== i))}
|
|
405
|
+
>
|
|
406
|
+
×
|
|
407
|
+
</button>
|
|
408
|
+
</span>
|
|
409
|
+
))}
|
|
410
|
+
</div>
|
|
411
|
+
)}
|
|
412
|
+
<input
|
|
413
|
+
className="text-[11px] bg-transparent border-dashed"
|
|
414
|
+
placeholder="add item..."
|
|
415
|
+
value={input}
|
|
416
|
+
onChange={(e) => setInput(e.target.value)}
|
|
417
|
+
onKeyDown={(e) => {
|
|
418
|
+
if (e.key !== 'Enter') return;
|
|
419
|
+
e.preventDefault();
|
|
420
|
+
const t = input.trim();
|
|
421
|
+
if (t && !(arr as string[]).includes(t)) emit([...arr, t]);
|
|
422
|
+
setInput('');
|
|
423
|
+
}}
|
|
424
|
+
/>
|
|
425
|
+
</div>
|
|
426
|
+
);
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
if (itemType === 'number') {
|
|
430
|
+
return (
|
|
431
|
+
<div className="rounded border border-border/50 bg-muted/30 p-2 space-y-1">
|
|
432
|
+
{arr.map((item, i) => (
|
|
433
|
+
<div key={i} className="flex gap-1 items-center group">
|
|
434
|
+
<input
|
|
435
|
+
type="number"
|
|
436
|
+
className="flex-1 text-[11px]"
|
|
437
|
+
value={String(item ?? 0)}
|
|
438
|
+
onChange={(e) => emit(arr.map((v, j) => (j === i ? Number(e.target.value) : v)))}
|
|
439
|
+
/>
|
|
440
|
+
<button
|
|
441
|
+
type="button"
|
|
442
|
+
className="border-0 bg-transparent p-0 text-muted-foreground/30 opacity-0 group-hover:opacity-100 hover:text-destructive cursor-pointer transition-opacity"
|
|
443
|
+
onClick={() => emit(arr.filter((_, j) => j !== i))}
|
|
444
|
+
>
|
|
445
|
+
<X className="h-3 w-3" />
|
|
446
|
+
</button>
|
|
447
|
+
</div>
|
|
448
|
+
))}
|
|
449
|
+
<button
|
|
450
|
+
type="button"
|
|
451
|
+
className="border-0 bg-transparent p-0 text-[11px] text-muted-foreground hover:text-foreground cursor-pointer"
|
|
452
|
+
onClick={() => emit([...arr, 0])}
|
|
453
|
+
>
|
|
454
|
+
+ add
|
|
455
|
+
</button>
|
|
456
|
+
</div>
|
|
457
|
+
);
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
// object/other — textarea fallback
|
|
461
|
+
return (
|
|
462
|
+
<textarea
|
|
463
|
+
value={JSON.stringify(arr, null, 2)}
|
|
464
|
+
onChange={(e) => {
|
|
465
|
+
try {
|
|
466
|
+
emit(JSON.parse(e.target.value));
|
|
467
|
+
} catch {
|
|
468
|
+
/* let user keep typing */
|
|
469
|
+
}
|
|
470
|
+
}}
|
|
471
|
+
/>
|
|
472
|
+
);
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
// ── Path field — node reference with drag-and-drop + tree picker ──
|
|
476
|
+
|
|
477
|
+
function PathView({ value }: FP) {
|
|
478
|
+
const path = String(value.value ?? '');
|
|
479
|
+
if (!path) return <span className="text-xs text-muted-foreground">—</span>;
|
|
480
|
+
return <span className="text-xs font-mono text-primary truncate block">{path}</span>;
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
// Compact tree picker dropdown for selecting a node path
|
|
484
|
+
// Lazy-loads children via trpc on expand, caches into front/cache
|
|
485
|
+
export function MiniTree({ onSelect, onClose }: { onSelect: (path: string) => void; onClose: () => void }) {
|
|
486
|
+
const ref = useRef<HTMLDivElement>(null);
|
|
487
|
+
const [expanded, setExpanded] = useState<Set<string>>(new Set(['/']));
|
|
488
|
+
const [loaded, setLoaded] = useState<Set<string>>(new Set());
|
|
489
|
+
const [filter, setFilter] = useState('');
|
|
490
|
+
|
|
491
|
+
// subscribe to cache changes for reactivity
|
|
492
|
+
useSyncExternalStore(
|
|
493
|
+
useCallback((cb: () => void) => cache.subscribeGlobal(cb), []),
|
|
494
|
+
useCallback(() => cache.getVersion(), []),
|
|
495
|
+
);
|
|
496
|
+
|
|
497
|
+
// Load root children on mount
|
|
498
|
+
useEffect(() => {
|
|
499
|
+
fetchChildren('/');
|
|
500
|
+
}, []);
|
|
501
|
+
|
|
502
|
+
// Close on outside click
|
|
503
|
+
useEffect(() => {
|
|
504
|
+
const handler = (e: MouseEvent) => {
|
|
505
|
+
if (ref.current && !ref.current.contains(e.target as Node)) onClose();
|
|
506
|
+
};
|
|
507
|
+
document.addEventListener('mousedown', handler);
|
|
508
|
+
return () => document.removeEventListener('mousedown', handler);
|
|
509
|
+
}, [onClose]);
|
|
510
|
+
|
|
511
|
+
async function fetchChildren(path: string) {
|
|
512
|
+
if (loaded.has(path)) return;
|
|
513
|
+
const { items } = await clientStore.getChildren(path);
|
|
514
|
+
cache.putMany(items, path);
|
|
515
|
+
setLoaded((prev) => new Set(prev).add(path));
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
function toggleExpand(path: string) {
|
|
519
|
+
setExpanded((prev) => {
|
|
520
|
+
const next = new Set(prev);
|
|
521
|
+
if (next.has(path)) {
|
|
522
|
+
next.delete(path);
|
|
523
|
+
} else {
|
|
524
|
+
next.add(path);
|
|
525
|
+
fetchChildren(path);
|
|
526
|
+
}
|
|
527
|
+
return next;
|
|
528
|
+
});
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
const nodes = cache.raw();
|
|
532
|
+
const lf = filter.toLowerCase();
|
|
533
|
+
|
|
534
|
+
function getKids(path: string): string[] {
|
|
535
|
+
return cache.getChildren(path).map((n) => n.$path).filter((p) => p !== path).sort();
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
function matchesFilter(path: string): boolean {
|
|
539
|
+
if (!lf) return true;
|
|
540
|
+
const n = nodes.get(path);
|
|
541
|
+
if (!n) return false;
|
|
542
|
+
const name = path === '/' ? '/' : path.slice(path.lastIndexOf('/') + 1);
|
|
543
|
+
return name.toLowerCase().includes(lf) || n.$type.toLowerCase().includes(lf);
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
function hasMatch(path: string): boolean {
|
|
547
|
+
if (matchesFilter(path)) return true;
|
|
548
|
+
for (const c of getKids(path)) {
|
|
549
|
+
if (hasMatch(c)) return true;
|
|
550
|
+
}
|
|
551
|
+
return false;
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
function renderNode(path: string, depth: number) {
|
|
555
|
+
if (!hasMatch(path)) return null;
|
|
556
|
+
const n = nodes.get(path);
|
|
557
|
+
if (!n) return null;
|
|
558
|
+
const name = path === '/' ? '/' : path.slice(path.lastIndexOf('/') + 1);
|
|
559
|
+
const kids = getKids(path);
|
|
560
|
+
const isExp = expanded.has(path);
|
|
561
|
+
const hasKids = kids.length > 0 || !loaded.has(path);
|
|
562
|
+
|
|
563
|
+
return (
|
|
564
|
+
<div key={path}>
|
|
565
|
+
<div
|
|
566
|
+
className="flex items-center gap-1 px-1 py-0.5 hover:bg-muted/60 cursor-pointer rounded text-[11px]"
|
|
567
|
+
style={{ paddingLeft: depth * 12 + 4 }}
|
|
568
|
+
onClick={() => onSelect(path)}
|
|
569
|
+
>
|
|
570
|
+
{hasKids ? (
|
|
571
|
+
<span
|
|
572
|
+
className="text-muted-foreground w-3 text-center shrink-0 cursor-pointer"
|
|
573
|
+
onClick={(e) => {
|
|
574
|
+
e.stopPropagation();
|
|
575
|
+
toggleExpand(path);
|
|
576
|
+
}}
|
|
577
|
+
>
|
|
578
|
+
{isExp ? '\u25BE' : '\u25B8'}
|
|
579
|
+
</span>
|
|
580
|
+
) : (
|
|
581
|
+
<span className="w-3 shrink-0" />
|
|
582
|
+
)}
|
|
583
|
+
<span className="truncate">{name}</span>
|
|
584
|
+
<span className="text-muted-foreground text-[10px] ml-auto shrink-0">
|
|
585
|
+
{n.$type.includes('.') ? n.$type.slice(n.$type.lastIndexOf('.') + 1) : n.$type}
|
|
586
|
+
</span>
|
|
587
|
+
</div>
|
|
588
|
+
{isExp && kids.map((c) => renderNode(c, depth + 1))}
|
|
589
|
+
</div>
|
|
590
|
+
);
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
const rootKids = getKids('/');
|
|
594
|
+
const rootNode = nodes.get('/');
|
|
595
|
+
|
|
596
|
+
return (
|
|
597
|
+
<div
|
|
598
|
+
ref={ref}
|
|
599
|
+
className="absolute z-50 mt-1 w-64 max-h-60 overflow-auto bg-popover border border-border rounded-lg shadow-lg"
|
|
600
|
+
>
|
|
601
|
+
<div className="p-1.5 border-b border-border">
|
|
602
|
+
<input
|
|
603
|
+
className="text-[11px] w-full"
|
|
604
|
+
placeholder="Filter..."
|
|
605
|
+
value={filter}
|
|
606
|
+
onChange={(e) => setFilter(e.target.value)}
|
|
607
|
+
autoFocus
|
|
608
|
+
/>
|
|
609
|
+
</div>
|
|
610
|
+
<div className="p-1">
|
|
611
|
+
{rootNode && renderNode('/', 0)}
|
|
612
|
+
{!rootNode && rootKids.map((r) => renderNode(r, 0))}
|
|
613
|
+
</div>
|
|
614
|
+
</div>
|
|
615
|
+
);
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
// Inline typed editor for embedded object values
|
|
619
|
+
function EmbeddedFields({ data, type, setData }: {
|
|
620
|
+
data: Record<string, unknown>;
|
|
621
|
+
type: string;
|
|
622
|
+
setData: (fn: (prev: Record<string, unknown>) => Record<string, unknown>) => void;
|
|
623
|
+
}) {
|
|
624
|
+
const schema = useSchema(type);
|
|
625
|
+
if (schema === undefined) return null; // loading
|
|
626
|
+
|
|
627
|
+
if (schema && Object.keys(schema.properties).length > 0) {
|
|
628
|
+
return (
|
|
629
|
+
<div className="space-y-1.5">
|
|
630
|
+
{Object.entries(schema.properties).map(([field, prop]) => {
|
|
631
|
+
const p = prop as {
|
|
632
|
+
type: string; title: string; format?: string; description?: string;
|
|
633
|
+
readOnly?: boolean; enum?: string[]; items?: { type?: string };
|
|
634
|
+
};
|
|
635
|
+
const fieldType = p.format ?? p.type;
|
|
636
|
+
if (fieldType === 'path') return null; // avoid infinite nesting for now
|
|
637
|
+
const handler = resolveHandler(fieldType, 'react:form') ?? resolveHandler('string', 'react:form');
|
|
638
|
+
if (!handler) return null;
|
|
639
|
+
const fieldData = {
|
|
640
|
+
$type: fieldType,
|
|
641
|
+
value: data[field],
|
|
642
|
+
label: p.title ?? field,
|
|
643
|
+
placeholder: p.description,
|
|
644
|
+
...(p.items ? { items: p.items } : {}),
|
|
645
|
+
...(p.enum ? { enum: p.enum } : {}),
|
|
646
|
+
};
|
|
647
|
+
return (
|
|
648
|
+
<div key={field} className="field">
|
|
649
|
+
{fieldType !== 'boolean' && <label>{p.title ?? field}</label>}
|
|
650
|
+
{createElement(handler as any, {
|
|
651
|
+
value: fieldData,
|
|
652
|
+
onChange: p.readOnly
|
|
653
|
+
? undefined
|
|
654
|
+
: (next: { value: unknown }) => setData((prev) => ({ ...prev, [field]: next.value })),
|
|
655
|
+
})}
|
|
656
|
+
</div>
|
|
657
|
+
);
|
|
658
|
+
})}
|
|
659
|
+
</div>
|
|
660
|
+
);
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
// No schema — render plain key/value fields
|
|
664
|
+
const entries = Object.entries(data).filter(([k]) => !k.startsWith('$'));
|
|
665
|
+
if (entries.length === 0) return null;
|
|
666
|
+
return (
|
|
667
|
+
<div className="space-y-1">
|
|
668
|
+
{entries.map(([k, v]) => (
|
|
669
|
+
<div key={k} className="field">
|
|
670
|
+
<label>{k}</label>
|
|
671
|
+
<input
|
|
672
|
+
className="text-[11px]"
|
|
673
|
+
value={typeof v === 'string' ? v : JSON.stringify(v)}
|
|
674
|
+
onChange={(e) => setData((prev) => ({ ...prev, [k]: e.target.value }))}
|
|
675
|
+
/>
|
|
676
|
+
</div>
|
|
677
|
+
))}
|
|
678
|
+
</div>
|
|
679
|
+
);
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
function PathForm({ value, onChange }: FP) {
|
|
683
|
+
const raw = value.value;
|
|
684
|
+
const refType = value.refType; // expected component type from schema
|
|
685
|
+
const isValue = typeof raw === 'object' && raw !== null;
|
|
686
|
+
const refPath = isValue ? String((raw as Record<string, unknown>).$path ?? '') : String(raw ?? '');
|
|
687
|
+
const embeddedType = isValue ? String((raw as Record<string, unknown>).$type ?? '') : '';
|
|
688
|
+
const effectiveType = embeddedType || refType || '';
|
|
689
|
+
const [mode, setMode] = useState<'ref' | 'val'>(isValue ? 'val' : 'ref');
|
|
690
|
+
const [dragOver, setDragOver] = useState(false);
|
|
691
|
+
const [pickerOpen, setPickerOpen] = useState(false);
|
|
692
|
+
const wrapRef = useRef<HTMLDivElement>(null);
|
|
693
|
+
|
|
694
|
+
function setRef(p: string) {
|
|
695
|
+
onChange?.({ ...value, value: p });
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
async function setByValue(p: string) {
|
|
699
|
+
const node = await clientStore.get(p);
|
|
700
|
+
if (!node) return;
|
|
701
|
+
const copy: Record<string, unknown> = {};
|
|
702
|
+
for (const [k, v] of Object.entries(node)) {
|
|
703
|
+
if (k === '$rev') continue;
|
|
704
|
+
copy[k] = v;
|
|
705
|
+
}
|
|
706
|
+
onChange?.({ ...value, value: copy });
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
// Create empty embedded object from refType schema
|
|
710
|
+
function createEmpty() {
|
|
711
|
+
if (!refType) return;
|
|
712
|
+
onChange?.({ ...value, value: { $type: refType } });
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
function applyNode(path: string) {
|
|
716
|
+
if (mode === 'val') setByValue(path);
|
|
717
|
+
else setRef(path);
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
function updateEmbedded(fn: (prev: Record<string, unknown>) => Record<string, unknown>) {
|
|
721
|
+
if (!isValue) return;
|
|
722
|
+
const obj = raw as Record<string, unknown>;
|
|
723
|
+
onChange?.({ ...value, value: fn({ ...obj }) });
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
const hasValue = isValue || !!refPath;
|
|
727
|
+
|
|
728
|
+
return (
|
|
729
|
+
<div ref={wrapRef} className="relative">
|
|
730
|
+
<div
|
|
731
|
+
className={`rounded border transition-colors ${
|
|
732
|
+
dragOver ? 'border-primary ring-2 ring-primary/30 bg-primary/5' : 'border-border'
|
|
733
|
+
}`}
|
|
734
|
+
onDragOver={(e) => {
|
|
735
|
+
if (e.dataTransfer.types.includes('application/treenity-path')) {
|
|
736
|
+
e.preventDefault();
|
|
737
|
+
e.dataTransfer.dropEffect = 'link';
|
|
738
|
+
setDragOver(true);
|
|
739
|
+
}
|
|
740
|
+
}}
|
|
741
|
+
onDragLeave={() => setDragOver(false)}
|
|
742
|
+
onDrop={(e) => {
|
|
743
|
+
e.preventDefault();
|
|
744
|
+
setDragOver(false);
|
|
745
|
+
const dropped = e.dataTransfer.getData('application/treenity-path');
|
|
746
|
+
if (dropped) applyNode(dropped);
|
|
747
|
+
}}
|
|
748
|
+
>
|
|
749
|
+
{/* Header row: mode switch + input + controls */}
|
|
750
|
+
<div className="flex items-center gap-1">
|
|
751
|
+
{/* Mode toggle — always visible */}
|
|
752
|
+
<button
|
|
753
|
+
type="button"
|
|
754
|
+
className={`border-0 bg-transparent p-0 px-1 cursor-pointer shrink-0 text-[10px] font-bold font-mono ${
|
|
755
|
+
mode === 'val' ? 'text-amber-500' : 'text-primary'
|
|
756
|
+
}`}
|
|
757
|
+
onClick={() => {
|
|
758
|
+
if (mode === 'ref') {
|
|
759
|
+
setMode('val');
|
|
760
|
+
if (refPath) setByValue(refPath);
|
|
761
|
+
else if (!isValue) createEmpty();
|
|
762
|
+
} else {
|
|
763
|
+
setMode('ref');
|
|
764
|
+
if (refPath) setRef(refPath);
|
|
765
|
+
}
|
|
766
|
+
}}
|
|
767
|
+
title={mode === 'val' ? 'Value mode — embeds node data' : 'Ref mode — stores path'}
|
|
768
|
+
>
|
|
769
|
+
{mode}
|
|
770
|
+
</button>
|
|
771
|
+
|
|
772
|
+
{/* Type badge when refType is known */}
|
|
773
|
+
{refType && (
|
|
774
|
+
<span className="text-[9px] text-muted-foreground font-mono shrink-0">
|
|
775
|
+
{refType.includes('.') ? refType.slice(refType.lastIndexOf('.') + 1) : refType}
|
|
776
|
+
</span>
|
|
777
|
+
)}
|
|
778
|
+
|
|
779
|
+
{isValue ? (
|
|
780
|
+
<span className="flex-1 min-w-0 text-[11px] font-mono text-foreground/70 truncate py-1">
|
|
781
|
+
{refPath && <span className="text-muted-foreground">{refPath}</span>}
|
|
782
|
+
{embeddedType && (
|
|
783
|
+
<span className="ml-1 text-amber-500">{embeddedType}</span>
|
|
784
|
+
)}
|
|
785
|
+
</span>
|
|
786
|
+
) : (
|
|
787
|
+
<input
|
|
788
|
+
className="flex-1 min-w-0 text-[11px] font-mono border-0 bg-transparent"
|
|
789
|
+
value={refPath}
|
|
790
|
+
placeholder="drop or pick a node"
|
|
791
|
+
onChange={(e) => setRef(e.target.value)}
|
|
792
|
+
/>
|
|
793
|
+
)}
|
|
794
|
+
|
|
795
|
+
{hasValue && (
|
|
796
|
+
<button
|
|
797
|
+
type="button"
|
|
798
|
+
className="border-0 bg-transparent p-0 px-0.5 text-muted-foreground/40 hover:text-foreground cursor-pointer shrink-0"
|
|
799
|
+
onClick={() => { onChange?.({ ...value, value: '' }); setMode('ref'); }}
|
|
800
|
+
>
|
|
801
|
+
<X className="h-3 w-3" />
|
|
802
|
+
</button>
|
|
803
|
+
)}
|
|
804
|
+
<button
|
|
805
|
+
type="button"
|
|
806
|
+
className="border-0 bg-transparent p-0 px-1 text-muted-foreground hover:text-foreground cursor-pointer shrink-0 text-[11px]"
|
|
807
|
+
onClick={() => setPickerOpen((v) => !v)}
|
|
808
|
+
title="Browse tree"
|
|
809
|
+
>
|
|
810
|
+
☰
|
|
811
|
+
</button>
|
|
812
|
+
</div>
|
|
813
|
+
|
|
814
|
+
{/* Inline typed editor for embedded value */}
|
|
815
|
+
{isValue && (
|
|
816
|
+
<div className="border-t border-border/50 p-2">
|
|
817
|
+
<EmbeddedFields
|
|
818
|
+
data={raw as Record<string, unknown>}
|
|
819
|
+
type={effectiveType}
|
|
820
|
+
setData={updateEmbedded}
|
|
821
|
+
/>
|
|
822
|
+
</div>
|
|
823
|
+
)}
|
|
824
|
+
</div>
|
|
825
|
+
|
|
826
|
+
{pickerOpen && (
|
|
827
|
+
<MiniTree
|
|
828
|
+
onSelect={(p) => { applyNode(p); setPickerOpen(false); }}
|
|
829
|
+
onClose={() => setPickerOpen(false)}
|
|
830
|
+
/>
|
|
831
|
+
)}
|
|
832
|
+
</div>
|
|
833
|
+
);
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
// ── Registration ──
|
|
837
|
+
// Each entry: [type, viewHandler, formHandler]
|
|
838
|
+
|
|
839
|
+
const fields: [string, Function, Function][] = [
|
|
840
|
+
['string', StringView, StringForm],
|
|
841
|
+
['text', StringView, TextForm],
|
|
842
|
+
['textarea', StringView, TextForm],
|
|
843
|
+
['number', NumberView, NumberForm],
|
|
844
|
+
['integer', NumberView, IntegerForm],
|
|
845
|
+
['boolean', BooleanView, BooleanForm],
|
|
846
|
+
['array', ArrayView, ArrayForm],
|
|
847
|
+
['object', ObjectView, ObjectForm],
|
|
848
|
+
['image', ImageView, ImageForm],
|
|
849
|
+
['uri', UriView, UriForm],
|
|
850
|
+
['url', UriView, UriForm],
|
|
851
|
+
['select', StringView, SelectForm],
|
|
852
|
+
['timestamp', TimestampView, TimestampForm],
|
|
853
|
+
['path', PathView, PathForm],
|
|
854
|
+
];
|
|
855
|
+
|
|
856
|
+
export function registerFormFields() {
|
|
857
|
+
for (const [type, view, form] of fields) {
|
|
858
|
+
register(type, 'react', view as any);
|
|
859
|
+
register(type, 'react:form', form as any);
|
|
860
|
+
}
|
|
861
|
+
}
|