@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,857 @@
|
|
|
1
|
+
// Inspector — view + edit panel for selected node (Unity-style inspector)
|
|
2
|
+
// Shell only: delegates rendering to registered views, provides generic edit UI
|
|
3
|
+
|
|
4
|
+
import { Render, RenderContext } from '#context';
|
|
5
|
+
import {
|
|
6
|
+
getActions,
|
|
7
|
+
getActionSchema,
|
|
8
|
+
getComponents,
|
|
9
|
+
getPlainFields,
|
|
10
|
+
getSchema,
|
|
11
|
+
getViewContexts,
|
|
12
|
+
pickDefaultContext,
|
|
13
|
+
} from '#mods/editor-ui/node-utils';
|
|
14
|
+
import { type ComponentData, type GroupPerm, type NodeData, resolve, resolveExact } from '@treenity/core/core';
|
|
15
|
+
import type { TypeSchema } from '@treenity/core/schema/types';
|
|
16
|
+
import { createElement, useEffect, useRef, useState } from 'react';
|
|
17
|
+
import { AclEditor } from './AclEditor';
|
|
18
|
+
import * as cache from './cache';
|
|
19
|
+
import { set, usePath } from './hooks';
|
|
20
|
+
import { useSchema } from './schema-loader';
|
|
21
|
+
import { trpc } from './trpc';
|
|
22
|
+
|
|
23
|
+
type AnyClass = { new(): Record<string, unknown> };
|
|
24
|
+
|
|
25
|
+
type Props = {
|
|
26
|
+
path: string | null;
|
|
27
|
+
currentUserId?: string;
|
|
28
|
+
onDelete: (path: string) => void;
|
|
29
|
+
onAddComponent: (path: string) => void;
|
|
30
|
+
onSelect: (path: string) => void;
|
|
31
|
+
onSetRoot?: (path: string) => void;
|
|
32
|
+
toast: (msg: string) => void;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
// Breadcrumb from path
|
|
36
|
+
function Breadcrumb({ path, onSelect }: { path: string; onSelect: (p: string) => void }) {
|
|
37
|
+
if (path === '/')
|
|
38
|
+
return (
|
|
39
|
+
<div className="editor-breadcrumb">
|
|
40
|
+
<span>/</span>
|
|
41
|
+
</div>
|
|
42
|
+
);
|
|
43
|
+
const parts = path.split('/').filter(Boolean);
|
|
44
|
+
const crumbs: { label: string; path: string }[] = [{ label: '/', path: '/' }];
|
|
45
|
+
let cur = '';
|
|
46
|
+
for (const p of parts) {
|
|
47
|
+
cur += '/' + p;
|
|
48
|
+
crumbs.push({ label: p, path: cur });
|
|
49
|
+
}
|
|
50
|
+
return (
|
|
51
|
+
<div className="editor-breadcrumb">
|
|
52
|
+
{crumbs.map((c, i) => (
|
|
53
|
+
<span key={c.path}>
|
|
54
|
+
{i > 0 && <span className="sep">/</span>}
|
|
55
|
+
<span onClick={() => onSelect(c.path)}>{c.label === '/' ? 'root' : c.label}</span>
|
|
56
|
+
</span>
|
|
57
|
+
))}
|
|
58
|
+
</div>
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Pretty-print action result value
|
|
63
|
+
function ResultView({ value }: { value: unknown }) {
|
|
64
|
+
if (value === undefined || value === null) return null;
|
|
65
|
+
if (typeof value !== 'object')
|
|
66
|
+
return <span className="font-mono text-[11px]">{String(value)}</span>;
|
|
67
|
+
|
|
68
|
+
// Object/array with typed $type → render via Render
|
|
69
|
+
if ('$type' in (value as any)) {
|
|
70
|
+
return <Render value={value as ComponentData} />;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Plain object — key/value pairs
|
|
74
|
+
const entries = Object.entries(value as Record<string, unknown>);
|
|
75
|
+
if (entries.length === 0) return <span className="text-muted-foreground text-[11px]">empty</span>;
|
|
76
|
+
|
|
77
|
+
return (
|
|
78
|
+
<div className="flex flex-col gap-0.5">
|
|
79
|
+
{entries.map(([k, v]) => (
|
|
80
|
+
<div key={k} className="flex gap-2 text-[11px]">
|
|
81
|
+
<span className="text-muted-foreground shrink-0">{k}</span>
|
|
82
|
+
<span className="font-mono text-foreground/80 truncate">
|
|
83
|
+
{typeof v === 'object' && v !== null ? JSON.stringify(v) : String(v ?? '')}
|
|
84
|
+
</span>
|
|
85
|
+
</div>
|
|
86
|
+
))}
|
|
87
|
+
</div>
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Action pills — compact action buttons that expand on click
|
|
92
|
+
function ActionCardList({
|
|
93
|
+
path,
|
|
94
|
+
componentName,
|
|
95
|
+
compType,
|
|
96
|
+
toast,
|
|
97
|
+
onActionComplete,
|
|
98
|
+
}: {
|
|
99
|
+
path: string;
|
|
100
|
+
componentName: string;
|
|
101
|
+
compType: string;
|
|
102
|
+
compData: Record<string, unknown>;
|
|
103
|
+
toast: (msg: string) => void;
|
|
104
|
+
onActionComplete?: () => void;
|
|
105
|
+
}) {
|
|
106
|
+
const schema = useSchema(compType);
|
|
107
|
+
const [expanded, setExpanded] = useState<string | null>(null);
|
|
108
|
+
const [paramsText, setParamsText] = useState<Record<string, string>>({});
|
|
109
|
+
const [schemaData, setSchemaData] = useState<Record<string, Record<string, unknown>>>({});
|
|
110
|
+
const [running, setRunning] = useState<string | null>(null);
|
|
111
|
+
const [results, setResults] = useState<Record<string, { ok: boolean; value: unknown }>>({});
|
|
112
|
+
const [resultMode, setResultMode] = useState<Record<string, 'pretty' | 'json'>>({});
|
|
113
|
+
|
|
114
|
+
if (schema === undefined) return null;
|
|
115
|
+
|
|
116
|
+
const actions = getActions(compType, schema);
|
|
117
|
+
if (actions.length === 0) return null;
|
|
118
|
+
|
|
119
|
+
async function run(a: string) {
|
|
120
|
+
setRunning(a);
|
|
121
|
+
try {
|
|
122
|
+
const actionSchema = getActionSchema(compType, a);
|
|
123
|
+
let data: unknown = {};
|
|
124
|
+
if (actionSchema) {
|
|
125
|
+
data = schemaData[a] ?? {};
|
|
126
|
+
} else {
|
|
127
|
+
const raw = (paramsText[a] ?? '').trim();
|
|
128
|
+
if (raw && raw !== '{}') {
|
|
129
|
+
try { data = JSON.parse(raw); }
|
|
130
|
+
catch { toast('Invalid JSON params'); setRunning(null); return; }
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
const result = await trpc.execute.mutate({ path, key: componentName, action: a, data });
|
|
134
|
+
const fresh = (await trpc.get.query({ path, watch: true })) as NodeData | undefined;
|
|
135
|
+
if (fresh) cache.put(fresh);
|
|
136
|
+
onActionComplete?.();
|
|
137
|
+
setResults((prev) => ({ ...prev, [a]: { ok: true, value: result } }));
|
|
138
|
+
setExpanded(a);
|
|
139
|
+
} catch (e) {
|
|
140
|
+
setResults((prev) => ({
|
|
141
|
+
...prev,
|
|
142
|
+
[a]: { ok: false, value: e instanceof Error ? e.message : String(e) },
|
|
143
|
+
}));
|
|
144
|
+
setExpanded(a);
|
|
145
|
+
} finally {
|
|
146
|
+
setRunning(null);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
return (
|
|
151
|
+
<div className="action-pills">
|
|
152
|
+
<div className="flex flex-wrap gap-1.5">
|
|
153
|
+
{actions.map((a) => (
|
|
154
|
+
<button
|
|
155
|
+
key={a}
|
|
156
|
+
className={`action-pill${expanded === a ? ' active' : ''}${running === a ? ' running' : ''}`}
|
|
157
|
+
onClick={() => setExpanded(expanded === a ? null : a)}
|
|
158
|
+
>
|
|
159
|
+
{running === a ? '...' : a}
|
|
160
|
+
{results[a] && !results[a].ok && expanded !== a && (
|
|
161
|
+
<span className="ml-1 text-destructive">!</span>
|
|
162
|
+
)}
|
|
163
|
+
{results[a]?.ok && expanded !== a && (
|
|
164
|
+
<span className="ml-1 text-primary/60">✓</span>
|
|
165
|
+
)}
|
|
166
|
+
</button>
|
|
167
|
+
))}
|
|
168
|
+
</div>
|
|
169
|
+
|
|
170
|
+
{expanded && (() => {
|
|
171
|
+
const a = expanded;
|
|
172
|
+
const actionSchema = getActionSchema(compType, a);
|
|
173
|
+
const hasParams = actionSchema !== null && Object.keys(actionSchema.properties).length > 0;
|
|
174
|
+
const noParams = actionSchema !== null && Object.keys(actionSchema.properties).length === 0;
|
|
175
|
+
const result = results[a];
|
|
176
|
+
const mode = resultMode[a] ?? 'pretty';
|
|
177
|
+
|
|
178
|
+
return (
|
|
179
|
+
<div className="action-detail">
|
|
180
|
+
{/* Params section */}
|
|
181
|
+
{hasParams && (
|
|
182
|
+
<div className="flex flex-col gap-1.5 mb-2">
|
|
183
|
+
{Object.entries(actionSchema!.properties).map(([field, prop]) => {
|
|
184
|
+
const p = prop as { type: string; title?: string; format?: string };
|
|
185
|
+
const val = (schemaData[a] ?? {})[field];
|
|
186
|
+
const setField = (v: unknown) =>
|
|
187
|
+
setSchemaData((prev) => ({
|
|
188
|
+
...prev,
|
|
189
|
+
[a]: { ...(prev[a] ?? {}), [field]: v },
|
|
190
|
+
}));
|
|
191
|
+
return (
|
|
192
|
+
<div key={field} className="action-detail-field">
|
|
193
|
+
<label>{p.title ?? field}</label>
|
|
194
|
+
{p.type === 'number' || p.format === 'number' ? (
|
|
195
|
+
<input type="number" value={String(val ?? 0)}
|
|
196
|
+
onChange={(e) => setField(Number(e.target.value))} />
|
|
197
|
+
) : p.type === 'boolean' ? (
|
|
198
|
+
<label className="flex items-center gap-1.5 cursor-pointer">
|
|
199
|
+
<input type="checkbox" checked={!!val} className="w-auto"
|
|
200
|
+
onChange={(e) => setField(e.target.checked)} />
|
|
201
|
+
<span className="text-[11px]">{val ? 'true' : 'false'}</span>
|
|
202
|
+
</label>
|
|
203
|
+
) : (
|
|
204
|
+
<input value={String(val ?? '')}
|
|
205
|
+
onChange={(e) => setField(e.target.value)} />
|
|
206
|
+
)}
|
|
207
|
+
</div>
|
|
208
|
+
);
|
|
209
|
+
})}
|
|
210
|
+
</div>
|
|
211
|
+
)}
|
|
212
|
+
|
|
213
|
+
{/* Free-form JSON params for untyped actions */}
|
|
214
|
+
{!hasParams && !noParams && (
|
|
215
|
+
<textarea
|
|
216
|
+
className="action-params-input mb-2"
|
|
217
|
+
value={paramsText[a] ?? '{}'}
|
|
218
|
+
onChange={(e) => setParamsText((prev) => ({ ...prev, [a]: e.target.value }))}
|
|
219
|
+
spellCheck={false}
|
|
220
|
+
rows={2}
|
|
221
|
+
/>
|
|
222
|
+
)}
|
|
223
|
+
|
|
224
|
+
{/* Run button */}
|
|
225
|
+
<button
|
|
226
|
+
className="action-run-btn"
|
|
227
|
+
disabled={running !== null}
|
|
228
|
+
onClick={() => run(a)}
|
|
229
|
+
>
|
|
230
|
+
{running === a ? '...' : '▶'} {a}
|
|
231
|
+
</button>
|
|
232
|
+
|
|
233
|
+
{/* Result */}
|
|
234
|
+
{result && (
|
|
235
|
+
<div className={`action-result-box${result.ok ? '' : ' error'}`}>
|
|
236
|
+
{!result.ok ? (
|
|
237
|
+
<span className="text-destructive font-mono text-[11px]">{String(result.value)}</span>
|
|
238
|
+
) : result.value === undefined || result.value === null ? (
|
|
239
|
+
<span className="text-primary text-[11px]">✓ done</span>
|
|
240
|
+
) : (
|
|
241
|
+
<>
|
|
242
|
+
<div className="flex items-center justify-between mb-1">
|
|
243
|
+
<span className="text-[10px] text-muted-foreground uppercase tracking-wider">Result</span>
|
|
244
|
+
{typeof result.value === 'object' && (
|
|
245
|
+
<div className="flex gap-0.5">
|
|
246
|
+
<button
|
|
247
|
+
className={`action-mode-btn${mode === 'pretty' ? ' active' : ''}`}
|
|
248
|
+
onClick={() => setResultMode((p) => ({ ...p, [a]: 'pretty' }))}
|
|
249
|
+
>View</button>
|
|
250
|
+
<button
|
|
251
|
+
className={`action-mode-btn${mode === 'json' ? ' active' : ''}`}
|
|
252
|
+
onClick={() => setResultMode((p) => ({ ...p, [a]: 'json' }))}
|
|
253
|
+
>JSON</button>
|
|
254
|
+
</div>
|
|
255
|
+
)}
|
|
256
|
+
</div>
|
|
257
|
+
{mode === 'json' ? (
|
|
258
|
+
<pre className="text-[11px] font-mono text-foreground/60 whitespace-pre-wrap break-all leading-relaxed">
|
|
259
|
+
{JSON.stringify(result.value, null, 2)}
|
|
260
|
+
</pre>
|
|
261
|
+
) : (
|
|
262
|
+
<ResultView value={result.value} />
|
|
263
|
+
)}
|
|
264
|
+
</>
|
|
265
|
+
)}
|
|
266
|
+
</div>
|
|
267
|
+
)}
|
|
268
|
+
</div>
|
|
269
|
+
);
|
|
270
|
+
})()}
|
|
271
|
+
</div>
|
|
272
|
+
);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
function renderField(
|
|
276
|
+
name: string,
|
|
277
|
+
fieldSchema: {
|
|
278
|
+
type: string;
|
|
279
|
+
label: string;
|
|
280
|
+
placeholder?: string;
|
|
281
|
+
readOnly?: boolean;
|
|
282
|
+
enum?: string[];
|
|
283
|
+
items?: { type?: string; properties?: Record<string, unknown> };
|
|
284
|
+
refType?: string;
|
|
285
|
+
},
|
|
286
|
+
plainData: Record<string, unknown>,
|
|
287
|
+
setPlainData: (fn: (prev: Record<string, unknown>) => Record<string, unknown>) => void,
|
|
288
|
+
) {
|
|
289
|
+
const ctx = fieldSchema.readOnly ? 'react' : 'react:form';
|
|
290
|
+
const handler = resolveExact(fieldSchema.type, ctx) ?? resolveExact('string', ctx);
|
|
291
|
+
if (!handler)
|
|
292
|
+
return (
|
|
293
|
+
<div key={name} className="text-[--danger] text-xs">
|
|
294
|
+
No form handler: {fieldSchema.type}
|
|
295
|
+
</div>
|
|
296
|
+
);
|
|
297
|
+
const fieldData: { $type: string; [k: string]: unknown } = {
|
|
298
|
+
$type: fieldSchema.type,
|
|
299
|
+
value: plainData[name],
|
|
300
|
+
label: fieldSchema.label,
|
|
301
|
+
placeholder: fieldSchema.placeholder,
|
|
302
|
+
};
|
|
303
|
+
if (fieldSchema.items) fieldData.items = fieldSchema.items;
|
|
304
|
+
if (fieldSchema.enum) fieldData.enum = fieldSchema.enum;
|
|
305
|
+
if (fieldSchema.refType) fieldData.refType = fieldSchema.refType;
|
|
306
|
+
const isComplex = fieldSchema.type === 'object' || fieldSchema.type === 'array';
|
|
307
|
+
return (
|
|
308
|
+
<div key={name} className={isComplex ? 'field stack' : 'field'}>
|
|
309
|
+
{fieldSchema.type !== 'boolean' && <label>{fieldSchema.label}</label>}
|
|
310
|
+
{createElement(handler as any, {
|
|
311
|
+
value: fieldData,
|
|
312
|
+
onChange: fieldSchema.readOnly
|
|
313
|
+
? undefined
|
|
314
|
+
: (next: { value: unknown }) => setPlainData((prev) => ({ ...prev, [name]: next.value })),
|
|
315
|
+
})}
|
|
316
|
+
</div>
|
|
317
|
+
);
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// Inline string-array editor for raw component fields without schema
|
|
321
|
+
function StringArrayField({
|
|
322
|
+
value,
|
|
323
|
+
onChange,
|
|
324
|
+
}: {
|
|
325
|
+
value: unknown[];
|
|
326
|
+
onChange: (next: unknown[]) => void;
|
|
327
|
+
}) {
|
|
328
|
+
const [input, setInput] = useState('');
|
|
329
|
+
const isStrings = value.every((v) => typeof v === 'string');
|
|
330
|
+
|
|
331
|
+
if (!isStrings) {
|
|
332
|
+
return (
|
|
333
|
+
<textarea
|
|
334
|
+
value={JSON.stringify(value, null, 2)}
|
|
335
|
+
onChange={(e) => {
|
|
336
|
+
try {
|
|
337
|
+
onChange(JSON.parse(e.target.value));
|
|
338
|
+
} catch {
|
|
339
|
+
/* typing */
|
|
340
|
+
}
|
|
341
|
+
}}
|
|
342
|
+
/>
|
|
343
|
+
);
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
const tags = value as string[];
|
|
347
|
+
return (
|
|
348
|
+
<div className="flex-1 space-y-1">
|
|
349
|
+
<div className="flex flex-wrap gap-1">
|
|
350
|
+
{tags.map((tag, i) => (
|
|
351
|
+
<span
|
|
352
|
+
key={i}
|
|
353
|
+
className="inline-flex items-center gap-0.5 text-[11px] font-mono bg-muted text-foreground/70 px-1.5 py-0.5 rounded"
|
|
354
|
+
>
|
|
355
|
+
{tag}
|
|
356
|
+
<button
|
|
357
|
+
type="button"
|
|
358
|
+
className="ml-0.5 border-0 bg-transparent p-0 text-muted-foreground/40 hover:text-foreground leading-none cursor-pointer"
|
|
359
|
+
onClick={() => onChange(tags.filter((_, j) => j !== i))}
|
|
360
|
+
>
|
|
361
|
+
×
|
|
362
|
+
</button>
|
|
363
|
+
</span>
|
|
364
|
+
))}
|
|
365
|
+
</div>
|
|
366
|
+
<input
|
|
367
|
+
className="text-xs w-full"
|
|
368
|
+
placeholder="Add item..."
|
|
369
|
+
value={input}
|
|
370
|
+
onChange={(e) => setInput(e.target.value)}
|
|
371
|
+
onKeyDown={(e) => {
|
|
372
|
+
if (e.key !== 'Enter') return;
|
|
373
|
+
e.preventDefault();
|
|
374
|
+
const t = input.trim();
|
|
375
|
+
if (t && !tags.includes(t)) onChange([...tags, t]);
|
|
376
|
+
setInput('');
|
|
377
|
+
}}
|
|
378
|
+
/>
|
|
379
|
+
</div>
|
|
380
|
+
);
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
function NodeCard({
|
|
384
|
+
path,
|
|
385
|
+
type,
|
|
386
|
+
onChangeType,
|
|
387
|
+
}: {
|
|
388
|
+
path: string;
|
|
389
|
+
type: string;
|
|
390
|
+
onChangeType: (t: string) => void;
|
|
391
|
+
}) {
|
|
392
|
+
const [open, setOpen] = useState(false);
|
|
393
|
+
return (
|
|
394
|
+
<div className="card">
|
|
395
|
+
<div
|
|
396
|
+
className="card-header cursor-pointer select-none"
|
|
397
|
+
onClick={() => setOpen((v) => !v)}
|
|
398
|
+
>
|
|
399
|
+
<span>Node</span>
|
|
400
|
+
<span className="flex items-center gap-2 normal-case tracking-normal font-normal text-[11px] font-mono text-foreground/50">
|
|
401
|
+
{path}
|
|
402
|
+
<span className="text-primary">{type}</span>
|
|
403
|
+
{open ? (
|
|
404
|
+
<svg className="h-3 w-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="m6 9 6 6 6-6"/></svg>
|
|
405
|
+
) : (
|
|
406
|
+
<svg className="h-3 w-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="m9 18 6-6-6-6"/></svg>
|
|
407
|
+
)}
|
|
408
|
+
</span>
|
|
409
|
+
</div>
|
|
410
|
+
{open && (
|
|
411
|
+
<div className="card-body">
|
|
412
|
+
<div className="field">
|
|
413
|
+
<label>$path</label>
|
|
414
|
+
<input value={path} readOnly />
|
|
415
|
+
</div>
|
|
416
|
+
<div className="field">
|
|
417
|
+
<label>$type</label>
|
|
418
|
+
<input value={type} onChange={(e) => onChangeType(e.target.value)} />
|
|
419
|
+
</div>
|
|
420
|
+
</div>
|
|
421
|
+
)}
|
|
422
|
+
</div>
|
|
423
|
+
);
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
function ComponentBody({
|
|
427
|
+
ctype, cdata, setCD, path, componentName, toast, onActionComplete,
|
|
428
|
+
}: {
|
|
429
|
+
ctype: string;
|
|
430
|
+
cdata: Record<string, unknown>;
|
|
431
|
+
setCD: (fn: (prev: Record<string, unknown>) => Record<string, unknown>) => void;
|
|
432
|
+
path: string;
|
|
433
|
+
componentName: string;
|
|
434
|
+
toast: (msg: string) => void;
|
|
435
|
+
onActionComplete?: () => void;
|
|
436
|
+
}) {
|
|
437
|
+
const cschema = useSchema(ctype);
|
|
438
|
+
if (cschema === undefined) return null;
|
|
439
|
+
|
|
440
|
+
return (
|
|
441
|
+
<div className="card-body">
|
|
442
|
+
{cschema && Object.keys(cschema.properties).length > 0 ? (
|
|
443
|
+
Object.entries(cschema.properties).map(([field, prop]) => {
|
|
444
|
+
const p = prop as {
|
|
445
|
+
type: string; title: string; format?: string; description?: string;
|
|
446
|
+
readOnly?: boolean; enum?: string[]; items?: { type?: string; properties?: Record<string, unknown> };
|
|
447
|
+
refType?: string;
|
|
448
|
+
};
|
|
449
|
+
return renderField(field, {
|
|
450
|
+
type: p.format ?? p.type, label: p.title ?? field, placeholder: p.description,
|
|
451
|
+
readOnly: p.readOnly, enum: p.enum, items: p.items, refType: p.refType,
|
|
452
|
+
}, cdata, setCD);
|
|
453
|
+
})
|
|
454
|
+
) : Object.keys(cdata).length > 0 ? (
|
|
455
|
+
Object.entries(cdata).map(([k, v]) => (
|
|
456
|
+
<div key={k} className={`field${Array.isArray(v) || (typeof v === 'object' && v !== null) ? ' stack' : ''}`}>
|
|
457
|
+
<label>{k}</label>
|
|
458
|
+
{typeof v === 'boolean' ? (
|
|
459
|
+
<label className="flex items-center gap-2 cursor-pointer">
|
|
460
|
+
<input type="checkbox" checked={!!cdata[k]} className="w-auto"
|
|
461
|
+
onChange={(e) => setCD((prev) => ({ ...prev, [k]: e.target.checked }))} />
|
|
462
|
+
{cdata[k] ? 'true' : 'false'}
|
|
463
|
+
</label>
|
|
464
|
+
) : typeof v === 'number' ? (
|
|
465
|
+
<input type="number" value={String(cdata[k] ?? 0)}
|
|
466
|
+
onChange={(e) => setCD((prev) => ({ ...prev, [k]: Number(e.target.value) }))} />
|
|
467
|
+
) : Array.isArray(v) ? (
|
|
468
|
+
<StringArrayField value={cdata[k] as unknown[]}
|
|
469
|
+
onChange={(next) => setCD((prev) => ({ ...prev, [k]: next }))} />
|
|
470
|
+
) : typeof v === 'object' ? (
|
|
471
|
+
(() => {
|
|
472
|
+
const h = resolve('object', 'react:form');
|
|
473
|
+
return h
|
|
474
|
+
? createElement(h as any, {
|
|
475
|
+
value: { $type: 'object', value: cdata[k] },
|
|
476
|
+
onChange: (next: { value: unknown }) => setCD((prev) => ({ ...prev, [k]: next.value })),
|
|
477
|
+
})
|
|
478
|
+
: <pre className="text-[11px] font-mono text-foreground/60">{JSON.stringify(cdata[k], null, 2)}</pre>;
|
|
479
|
+
})()
|
|
480
|
+
) : (
|
|
481
|
+
<input value={String(cdata[k] ?? '')}
|
|
482
|
+
onChange={(e) => setCD((prev) => ({ ...prev, [k]: e.target.value }))} />
|
|
483
|
+
)}
|
|
484
|
+
</div>
|
|
485
|
+
))
|
|
486
|
+
) : (
|
|
487
|
+
<pre className="text-[11px] font-mono text-foreground/60 bg-muted/30 rounded p-2 whitespace-pre-wrap">
|
|
488
|
+
{JSON.stringify(cdata, null, 2)}
|
|
489
|
+
</pre>
|
|
490
|
+
)}
|
|
491
|
+
<ActionCardList path={path} componentName={componentName} compType={ctype} compData={cdata} toast={toast} onActionComplete={onActionComplete} />
|
|
492
|
+
</div>
|
|
493
|
+
);
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
export function Inspector({ path, currentUserId, onDelete, onAddComponent, onSelect, onSetRoot, toast }: Props) {
|
|
497
|
+
const node = usePath(path);
|
|
498
|
+
|
|
499
|
+
const [context, setContext] = useState('react');
|
|
500
|
+
const [editing, setEditing] = useState(false);
|
|
501
|
+
const [nodeType, setNodeType] = useState('');
|
|
502
|
+
const [compTexts, setCompTexts] = useState<Record<string, string>>({});
|
|
503
|
+
const [compData, setCompData] = useState<Record<string, Record<string, unknown>>>({});
|
|
504
|
+
const [plainData, setPlainData] = useState<Record<string, unknown>>({});
|
|
505
|
+
const [tab, setTab] = useState<'properties' | 'json'>('properties');
|
|
506
|
+
const [jsonText, setJsonText] = useState('');
|
|
507
|
+
const [collapsed, setCollapsed] = useState<Set<string>>(new Set());
|
|
508
|
+
const [aclOwner, setAclOwner] = useState('');
|
|
509
|
+
const [aclRules, setAclRules] = useState<GroupPerm[]>([]);
|
|
510
|
+
const [dirty, setDirty] = useState(false);
|
|
511
|
+
const [stale, setStale] = useState(false);
|
|
512
|
+
const syncedPathRef = useRef<string | null>(null);
|
|
513
|
+
const syncedRevRef = useRef<unknown>(null);
|
|
514
|
+
|
|
515
|
+
function syncFromNode(n: NodeData) {
|
|
516
|
+
setNodeType(n.$type);
|
|
517
|
+
setAclOwner((n.$owner as string) ?? '');
|
|
518
|
+
setAclRules(n.$acl ? [...(n.$acl as GroupPerm[])] : []);
|
|
519
|
+
const texts: Record<string, string> = {};
|
|
520
|
+
const cdata: Record<string, Record<string, unknown>> = {};
|
|
521
|
+
for (const [name, comp] of getComponents(n)) {
|
|
522
|
+
texts[name] = JSON.stringify(comp, null, 2);
|
|
523
|
+
const d: Record<string, unknown> = {};
|
|
524
|
+
for (const [k, v] of Object.entries(comp)) {
|
|
525
|
+
if (!k.startsWith('$')) d[k] = v;
|
|
526
|
+
}
|
|
527
|
+
cdata[name] = d;
|
|
528
|
+
}
|
|
529
|
+
setCompTexts(texts);
|
|
530
|
+
setCompData(cdata);
|
|
531
|
+
setPlainData(getPlainFields(n));
|
|
532
|
+
setJsonText(JSON.stringify(n, null, 2));
|
|
533
|
+
setTab('properties');
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
useEffect(() => {
|
|
537
|
+
if (!node) return;
|
|
538
|
+
|
|
539
|
+
const pathChanged = node.$path !== syncedPathRef.current;
|
|
540
|
+
if (pathChanged) {
|
|
541
|
+
setContext(pickDefaultContext(node.$type));
|
|
542
|
+
syncFromNode(node);
|
|
543
|
+
syncedPathRef.current = node.$path;
|
|
544
|
+
syncedRevRef.current = node.$rev;
|
|
545
|
+
setDirty(false);
|
|
546
|
+
setStale(false);
|
|
547
|
+
return;
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
if (node.$rev !== syncedRevRef.current) {
|
|
551
|
+
if (dirty) {
|
|
552
|
+
setStale(true);
|
|
553
|
+
} else {
|
|
554
|
+
syncFromNode(node);
|
|
555
|
+
syncedRevRef.current = node.$rev;
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
}, [node?.$path, node?.$rev]);
|
|
559
|
+
|
|
560
|
+
// Dirty-tracking wrappers — mark form as edited on any user change
|
|
561
|
+
const dSetNodeType: typeof setNodeType = (v) => { setNodeType(v); setDirty(true); };
|
|
562
|
+
const dSetCompData: typeof setCompData = (v) => { setCompData(v); setDirty(true); };
|
|
563
|
+
const dSetPlainData: typeof setPlainData = (v) => { setPlainData(v); setDirty(true); };
|
|
564
|
+
const dSetJsonText: typeof setJsonText = (v) => { setJsonText(v); setDirty(true); };
|
|
565
|
+
const dSetAclOwner: typeof setAclOwner = (v) => { setAclOwner(v); setDirty(true); };
|
|
566
|
+
const dSetAclRules: typeof setAclRules = (v) => { setAclRules(v); setDirty(true); };
|
|
567
|
+
|
|
568
|
+
function handleReset() {
|
|
569
|
+
if (!node) return;
|
|
570
|
+
const current = cache.get(node.$path) ?? node;
|
|
571
|
+
syncFromNode(current);
|
|
572
|
+
syncedRevRef.current = current.$rev;
|
|
573
|
+
setDirty(false);
|
|
574
|
+
setStale(false);
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
if (!node) {
|
|
578
|
+
return (
|
|
579
|
+
<div className="editor">
|
|
580
|
+
<div className="editor-empty">
|
|
581
|
+
<div className="icon">☍</div>
|
|
582
|
+
<p>Select a node to inspect</p>
|
|
583
|
+
</div>
|
|
584
|
+
</div>
|
|
585
|
+
);
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
const nodeName = node.$path === '/' ? '/' : node.$path.slice(node.$path.lastIndexOf('/') + 1);
|
|
589
|
+
const components = getComponents(node);
|
|
590
|
+
const viewContexts = getViewContexts(node.$type, node);
|
|
591
|
+
const schemaHandler = resolve(node.$type, 'schema');
|
|
592
|
+
const schema = schemaHandler ? (schemaHandler() as TypeSchema) : null;
|
|
593
|
+
|
|
594
|
+
// Main component: when the node IS the component (its $type has a registered class).
|
|
595
|
+
// Show the class's fields (with defaults as fallback for unset fields).
|
|
596
|
+
const mainCompCls = resolve(node.$type, 'class') as AnyClass | null;
|
|
597
|
+
const mainCompDefaults = mainCompCls ? new mainCompCls() : null;
|
|
598
|
+
|
|
599
|
+
async function handleSave() {
|
|
600
|
+
if (!node) return;
|
|
601
|
+
let toSave: NodeData;
|
|
602
|
+
if (tab === 'json') {
|
|
603
|
+
try {
|
|
604
|
+
toSave = JSON.parse(jsonText);
|
|
605
|
+
} catch {
|
|
606
|
+
toast('Invalid JSON');
|
|
607
|
+
return;
|
|
608
|
+
}
|
|
609
|
+
} else {
|
|
610
|
+
toSave = { $path: node.$path, $type: nodeType, ...plainData };
|
|
611
|
+
if (aclOwner) toSave.$owner = aclOwner;
|
|
612
|
+
if (aclRules.length > 0) toSave.$acl = aclRules;
|
|
613
|
+
for (const [name, comp] of components) {
|
|
614
|
+
const ctype = (comp as ComponentData).$type;
|
|
615
|
+
const cschema = getSchema(ctype);
|
|
616
|
+
const cd = compData[name];
|
|
617
|
+
if ((cschema || (cd && Object.keys(cd).length > 0)) && cd) {
|
|
618
|
+
toSave[name] = { $type: ctype, ...cd };
|
|
619
|
+
} else {
|
|
620
|
+
const text = compTexts[name];
|
|
621
|
+
if (text === undefined) continue;
|
|
622
|
+
try {
|
|
623
|
+
toSave[name] = JSON.parse(text);
|
|
624
|
+
} catch {
|
|
625
|
+
toast(`Invalid JSON in component: ${name}`);
|
|
626
|
+
return;
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
await set(toSave);
|
|
632
|
+
const fresh = cache.get(node.$path);
|
|
633
|
+
if (fresh) {
|
|
634
|
+
syncFromNode(fresh);
|
|
635
|
+
syncedRevRef.current = fresh.$rev;
|
|
636
|
+
}
|
|
637
|
+
setDirty(false);
|
|
638
|
+
setStale(false);
|
|
639
|
+
toast('Saved');
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
function handleAdd() {
|
|
643
|
+
if (!node) return;
|
|
644
|
+
onAddComponent(node.$path);
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
function handleRemoveComponent(name: string) {
|
|
648
|
+
if (!node) return;
|
|
649
|
+
const next = { ...node };
|
|
650
|
+
delete next[name];
|
|
651
|
+
set(next);
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
function toggleCollapse(name: string) {
|
|
655
|
+
setCollapsed((prev) => {
|
|
656
|
+
const next = new Set(prev);
|
|
657
|
+
if (next.has(name)) next.delete(name);
|
|
658
|
+
else next.add(name);
|
|
659
|
+
return next;
|
|
660
|
+
});
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
return (
|
|
664
|
+
<div className="editor">
|
|
665
|
+
{/* Header */}
|
|
666
|
+
<div className="editor-header">
|
|
667
|
+
<Breadcrumb path={node.$path} onSelect={onSelect} />
|
|
668
|
+
<div className="editor-title">
|
|
669
|
+
<h2>{nodeName}</h2>
|
|
670
|
+
<span className="editor-type-badge">{node.$type}</span>
|
|
671
|
+
<a
|
|
672
|
+
href={node.$path}
|
|
673
|
+
target="_blank"
|
|
674
|
+
rel="noopener"
|
|
675
|
+
className="text-[11px] text-[--text-3] hover:text-[--accent] no-underline"
|
|
676
|
+
>
|
|
677
|
+
View ↗
|
|
678
|
+
</a>
|
|
679
|
+
{onSetRoot && (
|
|
680
|
+
<button
|
|
681
|
+
className="sm ghost text-[11px]"
|
|
682
|
+
onClick={() => onSetRoot(node.$path)}
|
|
683
|
+
title="Focus subtree"
|
|
684
|
+
>
|
|
685
|
+
⌂
|
|
686
|
+
</button>
|
|
687
|
+
)}
|
|
688
|
+
{viewContexts.length > 1 && (
|
|
689
|
+
<span className="context-buttons">
|
|
690
|
+
{viewContexts.map((c) => (
|
|
691
|
+
<button
|
|
692
|
+
key={c}
|
|
693
|
+
className={`sm context-btn${context === c ? ' active' : ''}`}
|
|
694
|
+
onClick={() => setContext(c)}
|
|
695
|
+
>
|
|
696
|
+
{c}
|
|
697
|
+
</button>
|
|
698
|
+
))}
|
|
699
|
+
</span>
|
|
700
|
+
)}
|
|
701
|
+
<span className="spacer" />
|
|
702
|
+
<button className={editing ? 'sm' : 'sm primary'} onClick={() => setEditing(!editing)}>
|
|
703
|
+
{editing ? 'Close' : 'Edit'}
|
|
704
|
+
</button>
|
|
705
|
+
<button
|
|
706
|
+
className="sm danger"
|
|
707
|
+
onClick={() => {
|
|
708
|
+
if (confirm(`Delete ${node.$path}?`)) onDelete(node.$path);
|
|
709
|
+
}}
|
|
710
|
+
>
|
|
711
|
+
Delete
|
|
712
|
+
</button>
|
|
713
|
+
</div>
|
|
714
|
+
</div>
|
|
715
|
+
|
|
716
|
+
{/* Rendered view */}
|
|
717
|
+
<div className="editor-body">
|
|
718
|
+
<RenderContext name={context}>
|
|
719
|
+
<div className="node-view">
|
|
720
|
+
<Render value={node} />
|
|
721
|
+
</div>
|
|
722
|
+
</RenderContext>
|
|
723
|
+
</div>
|
|
724
|
+
|
|
725
|
+
{/* Slide-out edit panel */}
|
|
726
|
+
<div className={`edit-panel${editing ? ' open' : ''}`}>
|
|
727
|
+
<div className="edit-panel-header">
|
|
728
|
+
<span>Edit {nodeName}</span>
|
|
729
|
+
<button className="sm ghost" onClick={() => setEditing(false)}>
|
|
730
|
+
✕
|
|
731
|
+
</button>
|
|
732
|
+
</div>
|
|
733
|
+
|
|
734
|
+
<div className="edit-panel-tabs">
|
|
735
|
+
<button
|
|
736
|
+
className={`editor-tab${tab === 'properties' ? ' active' : ''}`}
|
|
737
|
+
onClick={() => setTab('properties')}
|
|
738
|
+
>
|
|
739
|
+
Properties
|
|
740
|
+
</button>
|
|
741
|
+
<button
|
|
742
|
+
className={`editor-tab${tab === 'json' ? ' active' : ''}`}
|
|
743
|
+
onClick={() => {
|
|
744
|
+
setTab('json');
|
|
745
|
+
setJsonText(JSON.stringify({ ...node, ...plainData }, null, 2));
|
|
746
|
+
}}
|
|
747
|
+
>
|
|
748
|
+
JSON
|
|
749
|
+
</button>
|
|
750
|
+
</div>
|
|
751
|
+
|
|
752
|
+
<div className="edit-panel-body">
|
|
753
|
+
{tab === 'properties' ? (
|
|
754
|
+
<>
|
|
755
|
+
<NodeCard path={node.$path} type={nodeType} onChangeType={dSetNodeType} />
|
|
756
|
+
|
|
757
|
+
<AclEditor
|
|
758
|
+
path={node.$path}
|
|
759
|
+
owner={aclOwner}
|
|
760
|
+
rules={aclRules}
|
|
761
|
+
currentUserId={currentUserId}
|
|
762
|
+
onChange={(o, r) => {
|
|
763
|
+
dSetAclOwner(o);
|
|
764
|
+
dSetAclRules(r);
|
|
765
|
+
}}
|
|
766
|
+
/>
|
|
767
|
+
|
|
768
|
+
{(mainCompCls || schema) && (
|
|
769
|
+
<div className="card">
|
|
770
|
+
<div className="card-header">{node.$type}</div>
|
|
771
|
+
<ComponentBody
|
|
772
|
+
ctype={node.$type}
|
|
773
|
+
cdata={plainData}
|
|
774
|
+
setCD={(fn) => dSetPlainData(fn)}
|
|
775
|
+
path={node.$path}
|
|
776
|
+
componentName=""
|
|
777
|
+
toast={toast}
|
|
778
|
+
onActionComplete={handleReset}
|
|
779
|
+
/>
|
|
780
|
+
</div>
|
|
781
|
+
)}
|
|
782
|
+
|
|
783
|
+
{components.map(([name, comp]) => (
|
|
784
|
+
<div key={name} className="card">
|
|
785
|
+
<div className="card-header cursor-pointer select-none" onClick={() => toggleCollapse(name)}>
|
|
786
|
+
<span className="font-mono text-[12px]">{name}</span>
|
|
787
|
+
<span className="flex items-center gap-2">
|
|
788
|
+
<span className="component-type">{(comp as ComponentData).$type}</span>
|
|
789
|
+
<button
|
|
790
|
+
className="sm danger"
|
|
791
|
+
onClick={(e) => { e.stopPropagation(); handleRemoveComponent(name); }}
|
|
792
|
+
>
|
|
793
|
+
Remove
|
|
794
|
+
</button>
|
|
795
|
+
</span>
|
|
796
|
+
</div>
|
|
797
|
+
{!collapsed.has(name) && (
|
|
798
|
+
<ComponentBody
|
|
799
|
+
ctype={(comp as ComponentData).$type}
|
|
800
|
+
cdata={compData[name] ?? {}}
|
|
801
|
+
setCD={(fn) => dSetCompData((prev) => ({ ...prev, [name]: fn(prev[name] ?? {}) }))}
|
|
802
|
+
path={node.$path}
|
|
803
|
+
componentName={name}
|
|
804
|
+
toast={toast}
|
|
805
|
+
onActionComplete={handleReset}
|
|
806
|
+
/>
|
|
807
|
+
)}
|
|
808
|
+
</div>
|
|
809
|
+
))}
|
|
810
|
+
|
|
811
|
+
{!schema && !mainCompDefaults && Object.keys(plainData).length > 0 && (
|
|
812
|
+
<div className="card">
|
|
813
|
+
<div className="card-header">Data</div>
|
|
814
|
+
<div className="card-body">
|
|
815
|
+
{Object.entries(plainData).map(([k, v]) => (
|
|
816
|
+
<div key={k} className="field">
|
|
817
|
+
<label>{k}</label>
|
|
818
|
+
<input
|
|
819
|
+
value={typeof v === 'string' ? v : JSON.stringify(v)}
|
|
820
|
+
onChange={(e) =>
|
|
821
|
+
dSetPlainData((prev) => ({ ...prev, [k]: e.target.value }))
|
|
822
|
+
}
|
|
823
|
+
/>
|
|
824
|
+
</div>
|
|
825
|
+
))}
|
|
826
|
+
</div>
|
|
827
|
+
</div>
|
|
828
|
+
)}
|
|
829
|
+
</>
|
|
830
|
+
) : (
|
|
831
|
+
<div className="json-view">
|
|
832
|
+
<textarea
|
|
833
|
+
value={jsonText}
|
|
834
|
+
onChange={(e) => dSetJsonText(e.target.value)}
|
|
835
|
+
spellCheck={false}
|
|
836
|
+
/>
|
|
837
|
+
</div>
|
|
838
|
+
)}
|
|
839
|
+
</div>
|
|
840
|
+
|
|
841
|
+
<div className="edit-panel-actions">
|
|
842
|
+
{stale && (
|
|
843
|
+
<button className="ghost" onClick={handleReset} title="Node updated externally">
|
|
844
|
+
Reset
|
|
845
|
+
</button>
|
|
846
|
+
)}
|
|
847
|
+
<button className="primary" onClick={handleSave}>
|
|
848
|
+
Save
|
|
849
|
+
</button>
|
|
850
|
+
{tab === 'properties' && (
|
|
851
|
+
<button onClick={handleAdd}>+ Component</button>
|
|
852
|
+
)}
|
|
853
|
+
</div>
|
|
854
|
+
</div>
|
|
855
|
+
</div>
|
|
856
|
+
);
|
|
857
|
+
}
|