@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
package/src/App.tsx
ADDED
|
@@ -0,0 +1,775 @@
|
|
|
1
|
+
import { isOfType, type NodeData } from '@treenity/core/core';
|
|
2
|
+
import { applyPatch, type Operation } from 'fast-json-patch';
|
|
3
|
+
import { useCallback, useEffect, useRef, useState, useSyncExternalStore } from 'react';
|
|
4
|
+
import * as cache from './cache';
|
|
5
|
+
import { tree } from './client';
|
|
6
|
+
import { NavigateProvider } from './hooks';
|
|
7
|
+
import { Inspector } from './Inspector';
|
|
8
|
+
import { Tree } from './Tree';
|
|
9
|
+
import { AUTH_EXPIRED_EVENT, clearToken, getToken, setToken, trpc } from './trpc';
|
|
10
|
+
import { ViewPage } from './ViewPage';
|
|
11
|
+
|
|
12
|
+
// Hydrate from IDB before first render — fires bump() when done → reactive re-render
|
|
13
|
+
cache.hydrate();
|
|
14
|
+
|
|
15
|
+
type TypeInfo = { type: string; label: string };
|
|
16
|
+
|
|
17
|
+
async function loadTypes(): Promise<TypeInfo[]> {
|
|
18
|
+
const { items } = (await trpc.getChildren.query({ path: '/sys/types', limit: 0, depth: 99 })) as {
|
|
19
|
+
items: NodeData[];
|
|
20
|
+
total: number;
|
|
21
|
+
};
|
|
22
|
+
return items
|
|
23
|
+
.filter((n) => isOfType(n, 'type'))
|
|
24
|
+
.map((n) => {
|
|
25
|
+
const schema = n.schema as { $type: string; title?: string } | undefined;
|
|
26
|
+
const typeName = n.$path.slice('/sys/types/'.length).replace(/\//g, '.');
|
|
27
|
+
return { type: typeName, label: schema?.title ?? typeName };
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function TypePicker({
|
|
32
|
+
onSelect,
|
|
33
|
+
onCancel,
|
|
34
|
+
title = 'Create Node',
|
|
35
|
+
nameLabel = 'Node name',
|
|
36
|
+
action = 'Create',
|
|
37
|
+
}: {
|
|
38
|
+
onSelect: (name: string, type: string) => void;
|
|
39
|
+
onCancel: () => void;
|
|
40
|
+
title?: string;
|
|
41
|
+
nameLabel?: string;
|
|
42
|
+
action?: string;
|
|
43
|
+
}) {
|
|
44
|
+
const [types, setTypes] = useState<TypeInfo[]>([]);
|
|
45
|
+
const [loading, setLoading] = useState(true);
|
|
46
|
+
const [error, setError] = useState<string | null>(null);
|
|
47
|
+
const [filter, setFilter] = useState('');
|
|
48
|
+
const [name, setName] = useState('');
|
|
49
|
+
const [selectedType, setSelectedType] = useState<string | null>(null);
|
|
50
|
+
const nameRef = useRef<HTMLInputElement>(null);
|
|
51
|
+
|
|
52
|
+
useEffect(() => {
|
|
53
|
+
loadTypes()
|
|
54
|
+
.then(setTypes)
|
|
55
|
+
.catch((err) => {
|
|
56
|
+
console.error('Failed to load types:', err);
|
|
57
|
+
setError('Failed to load types');
|
|
58
|
+
})
|
|
59
|
+
.finally(() => setLoading(false));
|
|
60
|
+
}, []);
|
|
61
|
+
useEffect(() => {
|
|
62
|
+
nameRef.current?.focus();
|
|
63
|
+
}, []);
|
|
64
|
+
|
|
65
|
+
const lf = filter.toLowerCase();
|
|
66
|
+
const filtered = types.filter(
|
|
67
|
+
(t) => t.type.toLowerCase().includes(lf) || t.label.toLowerCase().includes(lf),
|
|
68
|
+
);
|
|
69
|
+
|
|
70
|
+
return (
|
|
71
|
+
<div className="type-picker-overlay" onClick={onCancel}>
|
|
72
|
+
<div className="type-picker" onClick={(e) => e.stopPropagation()}>
|
|
73
|
+
<div className="type-picker-header">{title}</div>
|
|
74
|
+
<div className="type-picker-search">
|
|
75
|
+
<input
|
|
76
|
+
ref={nameRef}
|
|
77
|
+
placeholder={nameLabel}
|
|
78
|
+
value={name}
|
|
79
|
+
onChange={(e) => setName(e.target.value)}
|
|
80
|
+
/>
|
|
81
|
+
<input
|
|
82
|
+
placeholder="Filter types..."
|
|
83
|
+
value={filter}
|
|
84
|
+
onChange={(e) => setFilter(e.target.value)}
|
|
85
|
+
/>
|
|
86
|
+
</div>
|
|
87
|
+
<div className="type-picker-list">
|
|
88
|
+
{filtered.map((t) => (
|
|
89
|
+
<div
|
|
90
|
+
key={t.type}
|
|
91
|
+
className={`type-picker-item${selectedType === t.type ? ' active' : ''}`}
|
|
92
|
+
onClick={() => setSelectedType(t.type)}
|
|
93
|
+
>
|
|
94
|
+
<span className="type-name">{t.type}</span>
|
|
95
|
+
{t.label !== t.type && <span className="type-label">{t.label}</span>}
|
|
96
|
+
</div>
|
|
97
|
+
))}
|
|
98
|
+
{loading && (
|
|
99
|
+
<div className="p-3 text-[--text-3] text-[13px]">Loading types...</div>
|
|
100
|
+
)}
|
|
101
|
+
{error && (
|
|
102
|
+
<div className="p-3 text-[--danger] text-[13px]">{error}</div>
|
|
103
|
+
)}
|
|
104
|
+
{!loading && !error && filtered.length === 0 && (
|
|
105
|
+
<div className="p-3 text-[--text-3] text-[13px]">No types found</div>
|
|
106
|
+
)}
|
|
107
|
+
</div>
|
|
108
|
+
<div className="type-picker-footer">
|
|
109
|
+
<button onClick={onCancel}>Cancel</button>
|
|
110
|
+
<button
|
|
111
|
+
className="primary"
|
|
112
|
+
disabled={!name || !selectedType}
|
|
113
|
+
onClick={() => onSelect(name, selectedType!)}
|
|
114
|
+
>
|
|
115
|
+
{action}
|
|
116
|
+
{name ? ` "${name}"` : ''}
|
|
117
|
+
{selectedType ? ` as ${selectedType}` : ''}
|
|
118
|
+
</button>
|
|
119
|
+
</div>
|
|
120
|
+
</div>
|
|
121
|
+
</div>
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function LoginForm({ onLogin }: { onLogin: (userId: string) => void }) {
|
|
126
|
+
const [mode, setMode] = useState<'login' | 'register'>('login');
|
|
127
|
+
const [userId, setUserId] = useState('');
|
|
128
|
+
const [password, setPassword] = useState('');
|
|
129
|
+
const [err, setErr] = useState<string | null>(null);
|
|
130
|
+
const [loading, setLoading] = useState(false);
|
|
131
|
+
|
|
132
|
+
async function handleSubmit(e: React.FormEvent) {
|
|
133
|
+
e.preventDefault();
|
|
134
|
+
if (!userId.trim() || !password) return;
|
|
135
|
+
setLoading(true);
|
|
136
|
+
setErr(null);
|
|
137
|
+
try {
|
|
138
|
+
const fn = mode === 'register' ? trpc.register : trpc.login;
|
|
139
|
+
const res = await fn.mutate({ userId: userId.trim(), password });
|
|
140
|
+
setToken(res.token);
|
|
141
|
+
onLogin(res.userId);
|
|
142
|
+
} catch (e) {
|
|
143
|
+
setErr(e instanceof Error ? e.message : 'Failed');
|
|
144
|
+
} finally {
|
|
145
|
+
setLoading(false);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
return (
|
|
150
|
+
<form className="login-box" onSubmit={handleSubmit}>
|
|
151
|
+
<div className="login-logo">
|
|
152
|
+
<img src="/treenity.svg" alt="" width="32" height="32" />
|
|
153
|
+
Treenity
|
|
154
|
+
</div>
|
|
155
|
+
<div className="field">
|
|
156
|
+
<label>User ID</label>
|
|
157
|
+
<input
|
|
158
|
+
autoFocus
|
|
159
|
+
placeholder="Enter your user ID"
|
|
160
|
+
value={userId}
|
|
161
|
+
onChange={(e) => setUserId(e.target.value)}
|
|
162
|
+
/>
|
|
163
|
+
</div>
|
|
164
|
+
<div className="field">
|
|
165
|
+
<label>Password</label>
|
|
166
|
+
<input
|
|
167
|
+
type="password"
|
|
168
|
+
placeholder="Enter password"
|
|
169
|
+
value={password}
|
|
170
|
+
onChange={(e) => setPassword(e.target.value)}
|
|
171
|
+
/>
|
|
172
|
+
</div>
|
|
173
|
+
{err && <div className="login-error">{err}</div>}
|
|
174
|
+
<button className="primary" type="submit" disabled={loading || !userId.trim() || !password}>
|
|
175
|
+
{loading ? '...' : mode === 'register' ? 'Create account' : 'Sign in'}
|
|
176
|
+
</button>
|
|
177
|
+
<button
|
|
178
|
+
type="button"
|
|
179
|
+
className="ghost"
|
|
180
|
+
onClick={() => {
|
|
181
|
+
setMode((m) => (m === 'login' ? 'register' : 'login'));
|
|
182
|
+
setErr(null);
|
|
183
|
+
}}
|
|
184
|
+
>
|
|
185
|
+
{mode === 'login' ? 'No account? Register' : 'Have an account? Sign in'}
|
|
186
|
+
</button>
|
|
187
|
+
</form>
|
|
188
|
+
);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function LoginScreen({ onLogin }: { onLogin: (userId: string) => void }) {
|
|
192
|
+
return (
|
|
193
|
+
<div className="login-screen">
|
|
194
|
+
<LoginForm onLogin={onLogin} />
|
|
195
|
+
</div>
|
|
196
|
+
);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function LoginModal({ onLogin, onClose }: { onLogin: (userId: string) => void; onClose: () => void }) {
|
|
200
|
+
return (
|
|
201
|
+
<div className="login-overlay" onMouseDown={(e) => { if (e.target === e.currentTarget) onClose(); }}>
|
|
202
|
+
<div className="login-modal">
|
|
203
|
+
<button className="login-modal-close" onClick={onClose}>×</button>
|
|
204
|
+
<LoginForm onLogin={onLogin} />
|
|
205
|
+
</div>
|
|
206
|
+
</div>
|
|
207
|
+
);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// Isolated component — global subscription re-renders only this, not the entire App
|
|
211
|
+
function NodeCount() {
|
|
212
|
+
return <>{useSyncExternalStore(cache.subscribeGlobal, cache.size)}</>;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
export function App() {
|
|
216
|
+
const [authed, setAuthed] = useState<string | null>(null);
|
|
217
|
+
const [authChecked, setAuthChecked] = useState(false);
|
|
218
|
+
|
|
219
|
+
useEffect(() => {
|
|
220
|
+
(async () => {
|
|
221
|
+
const token = getToken();
|
|
222
|
+
if (!token) {
|
|
223
|
+
// Auto-create anonymous session
|
|
224
|
+
const { token: anonToken, userId } = await trpc.anonLogin.mutate();
|
|
225
|
+
setToken(anonToken);
|
|
226
|
+
setAuthed(userId);
|
|
227
|
+
setAuthChecked(true);
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
try {
|
|
231
|
+
const res = await trpc.me.query();
|
|
232
|
+
setAuthed(res?.userId ?? null);
|
|
233
|
+
if (!res) clearToken();
|
|
234
|
+
} catch {
|
|
235
|
+
clearToken();
|
|
236
|
+
} finally {
|
|
237
|
+
setAuthChecked(true);
|
|
238
|
+
}
|
|
239
|
+
})();
|
|
240
|
+
}, []);
|
|
241
|
+
|
|
242
|
+
// ── Route detection ──
|
|
243
|
+
const [mode, setMode] = useState<'editor' | 'view' | 'preview'>(() => {
|
|
244
|
+
const p = location.pathname;
|
|
245
|
+
if (p.startsWith('/t')) return 'editor';
|
|
246
|
+
if (p.startsWith('/v/') || p === '/v') return 'preview';
|
|
247
|
+
return 'view';
|
|
248
|
+
});
|
|
249
|
+
const [viewPath, setViewPath] = useState<string>(() => {
|
|
250
|
+
const p = location.pathname;
|
|
251
|
+
if (p.startsWith('/v')) return p.slice(2) || '/';
|
|
252
|
+
if (!p.startsWith('/t')) return p || '/';
|
|
253
|
+
return '/';
|
|
254
|
+
});
|
|
255
|
+
const [root, setRoot] = useState<string>(() =>
|
|
256
|
+
new URLSearchParams(location.search).get('root') || '/',
|
|
257
|
+
);
|
|
258
|
+
|
|
259
|
+
const [selected, setSelected] = useState<string | null>(() => {
|
|
260
|
+
const p = location.pathname;
|
|
261
|
+
if (!p.startsWith('/t')) return null;
|
|
262
|
+
const rest = p.slice(2); // strip "/t"
|
|
263
|
+
return rest || '/';
|
|
264
|
+
});
|
|
265
|
+
const [expanded, setExpanded] = useState<Set<string>>(new Set());
|
|
266
|
+
const expandedRef = useRef(expanded);
|
|
267
|
+
expandedRef.current = expanded;
|
|
268
|
+
const selectedRef = useRef(selected);
|
|
269
|
+
selectedRef.current = selected;
|
|
270
|
+
const [loaded, setLoaded] = useState<Set<string>>(new Set());
|
|
271
|
+
const [error, setError] = useState<string | null>(null);
|
|
272
|
+
const [creatingAt, setCreatingAt] = useState<string | null>(null);
|
|
273
|
+
const [addingComponentAt, setAddingComponentAt] = useState<string | null>(null);
|
|
274
|
+
const [filter, setFilter] = useState('');
|
|
275
|
+
const [showHidden, setShowHidden] = useState(false);
|
|
276
|
+
const [toastMsg, setToastMsg] = useState<{ text: string; type: 'success' | 'error' } | null>(null);
|
|
277
|
+
|
|
278
|
+
// Granular: only re-render App when root node appears/disappears
|
|
279
|
+
const hasRootNode = useSyncExternalStore(
|
|
280
|
+
useCallback((cb: () => void) => cache.subscribePath(root, cb), [root]),
|
|
281
|
+
useCallback(() => cache.has(root), [root]),
|
|
282
|
+
);
|
|
283
|
+
|
|
284
|
+
const searchRef = useRef<HTMLInputElement>(null);
|
|
285
|
+
|
|
286
|
+
// Sync selected path to URL (push, not replace, so back/forward works)
|
|
287
|
+
const navFromPopstate = useRef(false);
|
|
288
|
+
useEffect(() => {
|
|
289
|
+
if (mode !== 'editor') return;
|
|
290
|
+
const base = selected ? `/t${selected === '/' ? '' : selected}` : '/';
|
|
291
|
+
const search = root !== '/' ? `?root=${encodeURIComponent(root)}` : '';
|
|
292
|
+
const url = base + search;
|
|
293
|
+
if (location.pathname + location.search !== url) {
|
|
294
|
+
if (navFromPopstate.current) navFromPopstate.current = false;
|
|
295
|
+
else history.pushState(null, '', url);
|
|
296
|
+
}
|
|
297
|
+
}, [selected, root, mode]);
|
|
298
|
+
|
|
299
|
+
// Handle browser back/forward
|
|
300
|
+
useEffect(() => {
|
|
301
|
+
const onPop = () => {
|
|
302
|
+
const p = location.pathname;
|
|
303
|
+
navFromPopstate.current = true;
|
|
304
|
+
if (p.startsWith('/t')) {
|
|
305
|
+
setMode('editor');
|
|
306
|
+
setSelected(p.slice(2) || '/');
|
|
307
|
+
setRoot(new URLSearchParams(location.search).get('root') || '/');
|
|
308
|
+
} else if (p.startsWith('/v/') || p === '/v') {
|
|
309
|
+
setMode('preview');
|
|
310
|
+
setViewPath(p.slice(2) || '/');
|
|
311
|
+
} else {
|
|
312
|
+
setMode('view');
|
|
313
|
+
setViewPath(p || '/');
|
|
314
|
+
}
|
|
315
|
+
};
|
|
316
|
+
window.addEventListener('popstate', onPop);
|
|
317
|
+
return () => window.removeEventListener('popstate', onPop);
|
|
318
|
+
}, []);
|
|
319
|
+
|
|
320
|
+
// Keyboard shortcuts: Cmd+/ add component
|
|
321
|
+
useEffect(() => {
|
|
322
|
+
function onKeyDown(e: KeyboardEvent) {
|
|
323
|
+
const meta = e.metaKey || e.ctrlKey;
|
|
324
|
+
if (!meta) return;
|
|
325
|
+
if (document.querySelector('.type-picker-overlay')) return;
|
|
326
|
+
if (e.key === '/' && selected) {
|
|
327
|
+
e.preventDefault();
|
|
328
|
+
setAddingComponentAt(selected);
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
window.addEventListener('keydown', onKeyDown);
|
|
332
|
+
return () => window.removeEventListener('keydown', onKeyDown);
|
|
333
|
+
}, [selected]);
|
|
334
|
+
|
|
335
|
+
const showToast = useCallback((msg: string, type: 'success' | 'error' = 'success') => {
|
|
336
|
+
setToastMsg({ text: msg, type });
|
|
337
|
+
setTimeout(() => setToastMsg(null), type === 'error' ? 5000 : 2000);
|
|
338
|
+
}, []);
|
|
339
|
+
|
|
340
|
+
// Catch unhandled promise rejections (e.g. tRPC 403/500 errors)
|
|
341
|
+
useEffect(() => {
|
|
342
|
+
const handler = (e: PromiseRejectionEvent) => {
|
|
343
|
+
const msg = e.reason?.message || String(e.reason);
|
|
344
|
+
showToast(msg, 'error');
|
|
345
|
+
};
|
|
346
|
+
window.addEventListener('unhandledrejection', handler);
|
|
347
|
+
return () => window.removeEventListener('unhandledrejection', handler);
|
|
348
|
+
}, [showToast]);
|
|
349
|
+
|
|
350
|
+
const loadChildren = useCallback(async (path: string) => {
|
|
351
|
+
const { items: children } = (await trpc.getChildren.query({
|
|
352
|
+
path,
|
|
353
|
+
watch: true,
|
|
354
|
+
watchNew: true,
|
|
355
|
+
})) as { items: NodeData[]; total: number };
|
|
356
|
+
cache.putMany(children, path); // Use specific parent path so query mounts index them correctly
|
|
357
|
+
setLoaded((prev) => new Set(prev).add(path));
|
|
358
|
+
}, []);
|
|
359
|
+
|
|
360
|
+
useEffect(() => {
|
|
361
|
+
if (!authed) return;
|
|
362
|
+
if (mode === 'view') return; // ViewPage fetches its own node
|
|
363
|
+
cache.clear();
|
|
364
|
+
setLoaded(new Set());
|
|
365
|
+
(async () => {
|
|
366
|
+
try {
|
|
367
|
+
const rootNode = (await trpc.get.query({ path: root, watch: true })) as NodeData | undefined;
|
|
368
|
+
if (rootNode) cache.put(rootNode);
|
|
369
|
+
await loadChildren(root);
|
|
370
|
+
|
|
371
|
+
// Restore path from URL, expand ancestors
|
|
372
|
+
const p = location.pathname;
|
|
373
|
+
const target = p.startsWith('/t') ? p.slice(2) || '/' : root;
|
|
374
|
+
const toExpand = new Set([root]);
|
|
375
|
+
|
|
376
|
+
// Expand ancestors between root and target
|
|
377
|
+
if (target !== root && target.startsWith(root === '/' ? '/' : root + '/')) {
|
|
378
|
+
const relative = root === '/' ? target : target.slice(root.length);
|
|
379
|
+
const parts = relative.split('/').filter(Boolean);
|
|
380
|
+
let cur = root === '/' ? '' : root;
|
|
381
|
+
for (let i = 0; i < parts.length - 1; i++) {
|
|
382
|
+
cur += '/' + parts[i];
|
|
383
|
+
toExpand.add(cur);
|
|
384
|
+
await loadChildren(cur);
|
|
385
|
+
}
|
|
386
|
+
const parent = cur || root;
|
|
387
|
+
if (!toExpand.has(parent)) await loadChildren(parent);
|
|
388
|
+
}
|
|
389
|
+
setExpanded(toExpand);
|
|
390
|
+
setSelected(target);
|
|
391
|
+
if (target !== root) {
|
|
392
|
+
const node = (await trpc.get.query({ path: target, watch: true })) as
|
|
393
|
+
| NodeData
|
|
394
|
+
| undefined;
|
|
395
|
+
if (node) cache.put(node);
|
|
396
|
+
}
|
|
397
|
+
} catch (e) {
|
|
398
|
+
setError(e instanceof Error ? e.message : 'Failed to connect to server');
|
|
399
|
+
}
|
|
400
|
+
})();
|
|
401
|
+
}, [authed, loadChildren, root, mode]);
|
|
402
|
+
|
|
403
|
+
// Live subscription — server push → cache
|
|
404
|
+
useEffect(() => {
|
|
405
|
+
if (!authed) return;
|
|
406
|
+
const sub = trpc.events.subscribe(undefined as void, {
|
|
407
|
+
onData(event) {
|
|
408
|
+
if (event.type === 'reconnect') {
|
|
409
|
+
if (!event.preserved) {
|
|
410
|
+
// Watches lost — force useChildren hooks to re-fetch and re-register
|
|
411
|
+
cache.signalReconnect();
|
|
412
|
+
// Re-register tree watches for expanded paths (editor mode)
|
|
413
|
+
for (const path of expandedRef.current) loadChildren(path);
|
|
414
|
+
// Re-watch the currently selected node
|
|
415
|
+
if (selectedRef.current) {
|
|
416
|
+
trpc.get.query({ path: selectedRef.current, watch: true }).then(n => {
|
|
417
|
+
if (n) cache.put(n as NodeData);
|
|
418
|
+
});
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
return;
|
|
422
|
+
}
|
|
423
|
+
if (event.type === 'set') {
|
|
424
|
+
cache.put({ $path: event.path, ...event.node } as NodeData);
|
|
425
|
+
if (event.addVps) event.addVps.forEach((vp: string) => cache.addToParent(event.path, vp));
|
|
426
|
+
if (event.rmVps) event.rmVps.forEach((vp: string) => cache.removeFromParent(event.path, vp));
|
|
427
|
+
} else if (event.type === 'patch') {
|
|
428
|
+
const existing = cache.get(event.path);
|
|
429
|
+
if (existing && event.patches) {
|
|
430
|
+
try {
|
|
431
|
+
const { newDocument } = applyPatch(structuredClone(existing), event.patches as Operation[]);
|
|
432
|
+
cache.put(newDocument as NodeData);
|
|
433
|
+
} catch (e) {
|
|
434
|
+
console.error('Failed to apply patches, fetching full node:', e);
|
|
435
|
+
trpc.get.query({ path: event.path }).then((n) => {
|
|
436
|
+
if (n) cache.put(n as NodeData);
|
|
437
|
+
});
|
|
438
|
+
}
|
|
439
|
+
} else {
|
|
440
|
+
trpc.get.query({ path: event.path }).then((n) => {
|
|
441
|
+
if (n) cache.put(n as NodeData);
|
|
442
|
+
});
|
|
443
|
+
}
|
|
444
|
+
if (event.addVps) event.addVps.forEach((vp: string) => cache.addToParent(event.path, vp));
|
|
445
|
+
if (event.rmVps) event.rmVps.forEach((vp: string) => cache.removeFromParent(event.path, vp));
|
|
446
|
+
} else if (event.type === 'remove') {
|
|
447
|
+
// Try to remove from anywhere
|
|
448
|
+
if (event.rmVps && event.rmVps.length > 0) {
|
|
449
|
+
event.rmVps.forEach((vp: string) => cache.removeFromParent(event.path, vp));
|
|
450
|
+
} else {
|
|
451
|
+
cache.remove(event.path);
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
},
|
|
455
|
+
});
|
|
456
|
+
return () => sub.unsubscribe();
|
|
457
|
+
}, [authed, loadChildren]);
|
|
458
|
+
|
|
459
|
+
const handleSelect = useCallback(
|
|
460
|
+
async (path: string) => {
|
|
461
|
+
setSelected(path);
|
|
462
|
+
if (!cache.has(path)) {
|
|
463
|
+
const node = (await trpc.get.query({ path, watch: true })) as NodeData | undefined;
|
|
464
|
+
if (node) cache.put(node);
|
|
465
|
+
}
|
|
466
|
+
// Preload children so editor can derive them from cache
|
|
467
|
+
await loadChildren(path);
|
|
468
|
+
},
|
|
469
|
+
[loadChildren],
|
|
470
|
+
);
|
|
471
|
+
|
|
472
|
+
const handleExpand = useCallback(
|
|
473
|
+
async (path: string) => {
|
|
474
|
+
const wasExpanded = expanded.has(path);
|
|
475
|
+
setExpanded((prev) => {
|
|
476
|
+
const next = new Set(prev);
|
|
477
|
+
if (next.has(path)) next.delete(path);
|
|
478
|
+
else next.add(path);
|
|
479
|
+
return next;
|
|
480
|
+
});
|
|
481
|
+
if (!wasExpanded) {
|
|
482
|
+
await loadChildren(path);
|
|
483
|
+
} else {
|
|
484
|
+
// Unsubscribe: prefix watch + exact watches on children
|
|
485
|
+
const childPaths = cache.getChildren(path).map(n => n.$path).filter(p => p !== path);
|
|
486
|
+
trpc.unwatchChildren.mutate({ paths: [path] });
|
|
487
|
+
if (childPaths.length) trpc.unwatch.mutate({ paths: childPaths });
|
|
488
|
+
}
|
|
489
|
+
},
|
|
490
|
+
[expanded, loadChildren],
|
|
491
|
+
);
|
|
492
|
+
|
|
493
|
+
const handleDelete = useCallback(
|
|
494
|
+
async (path: string) => {
|
|
495
|
+
await tree.remove(path);
|
|
496
|
+
cache.remove(path);
|
|
497
|
+
const parent = path === '/' ? null : path.slice(0, path.lastIndexOf('/')) || '/';
|
|
498
|
+
if (parent) await loadChildren(parent);
|
|
499
|
+
setSelected(parent);
|
|
500
|
+
},
|
|
501
|
+
[loadChildren],
|
|
502
|
+
);
|
|
503
|
+
|
|
504
|
+
const handleCreateChild = useCallback((parentPath: string) => {
|
|
505
|
+
setCreatingAt(parentPath);
|
|
506
|
+
}, []);
|
|
507
|
+
|
|
508
|
+
const handlePickType = useCallback(
|
|
509
|
+
async (name: string, type: string) => {
|
|
510
|
+
const parentPath = creatingAt!;
|
|
511
|
+
setCreatingAt(null);
|
|
512
|
+
const childPath = parentPath === '/' ? `/${name}` : `${parentPath}/${name}`;
|
|
513
|
+
await tree.set({ $path: childPath, $type: type } as NodeData);
|
|
514
|
+
await loadChildren(parentPath);
|
|
515
|
+
if (!expanded.has(parentPath)) {
|
|
516
|
+
setExpanded((prev) => new Set(prev).add(parentPath));
|
|
517
|
+
}
|
|
518
|
+
setSelected(childPath);
|
|
519
|
+
const node = (await trpc.get.query({ path: childPath, watch: true })) as NodeData | undefined;
|
|
520
|
+
if (node) cache.put(node);
|
|
521
|
+
showToast(`Created ${name}`);
|
|
522
|
+
},
|
|
523
|
+
[creatingAt, loadChildren, expanded, showToast],
|
|
524
|
+
);
|
|
525
|
+
|
|
526
|
+
const handleAddComponent = useCallback((path: string) => {
|
|
527
|
+
setAddingComponentAt(path);
|
|
528
|
+
}, []);
|
|
529
|
+
|
|
530
|
+
const handlePickComponent = useCallback(
|
|
531
|
+
async (name: string, type: string) => {
|
|
532
|
+
const path = addingComponentAt!;
|
|
533
|
+
setAddingComponentAt(null);
|
|
534
|
+
const node = cache.get(path);
|
|
535
|
+
if (!node) return;
|
|
536
|
+
const updated = { ...node, [name]: { $type: type } };
|
|
537
|
+
cache.put(updated);
|
|
538
|
+
await tree.set(updated);
|
|
539
|
+
showToast(`Added ${name}`);
|
|
540
|
+
},
|
|
541
|
+
[addingComponentAt, showToast],
|
|
542
|
+
);
|
|
543
|
+
|
|
544
|
+
const handleMove = useCallback(
|
|
545
|
+
async (fromPath: string, toPath: string) => {
|
|
546
|
+
const fromNode = cache.get(fromPath);
|
|
547
|
+
const toNode = cache.get(toPath);
|
|
548
|
+
if (!fromNode || !toNode) return;
|
|
549
|
+
const toParent = toPath === '/' ? '/' : toPath.slice(0, toPath.lastIndexOf('/')) || '/';
|
|
550
|
+
const fromName = fromPath.slice(fromPath.lastIndexOf('/') + 1);
|
|
551
|
+
const newPath = toParent === '/' ? `/${fromName}` : `${toParent}/${fromName}`;
|
|
552
|
+
if (newPath === fromPath) return;
|
|
553
|
+
await tree.remove(fromPath);
|
|
554
|
+
await tree.set({ ...fromNode, $path: newPath });
|
|
555
|
+
const oldParent =
|
|
556
|
+
fromPath === '/' ? '/' : fromPath.slice(0, fromPath.lastIndexOf('/')) || '/';
|
|
557
|
+
await loadChildren(oldParent);
|
|
558
|
+
await loadChildren(toParent);
|
|
559
|
+
setSelected(newPath);
|
|
560
|
+
showToast(`Moved to ${newPath}`);
|
|
561
|
+
},
|
|
562
|
+
[loadChildren, showToast],
|
|
563
|
+
);
|
|
564
|
+
|
|
565
|
+
const roots = hasRootNode ? [root] : [];
|
|
566
|
+
|
|
567
|
+
const handleCreateRoot = useCallback(async () => {
|
|
568
|
+
const type = prompt('Root node $type:', 'root');
|
|
569
|
+
if (!type) return;
|
|
570
|
+
try {
|
|
571
|
+
await tree.set({ $path: '/', $type: type } as NodeData);
|
|
572
|
+
const root = await tree.get('/');
|
|
573
|
+
if (root) cache.put(root);
|
|
574
|
+
setSelected('/');
|
|
575
|
+
setExpanded(new Set(['/']));
|
|
576
|
+
setError(null);
|
|
577
|
+
} catch (e) {
|
|
578
|
+
setError(e instanceof Error ? e.message : 'Failed to create root');
|
|
579
|
+
}
|
|
580
|
+
}, []);
|
|
581
|
+
|
|
582
|
+
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
|
|
583
|
+
const [menuOpen, setMenuOpen] = useState(false);
|
|
584
|
+
const [showLoginModal, setShowLoginModal] = useState(false);
|
|
585
|
+
const menuRef = useRef<HTMLDivElement>(null);
|
|
586
|
+
|
|
587
|
+
// Re-auth as anon + show login modal when session expires mid-use
|
|
588
|
+
useEffect(() => {
|
|
589
|
+
const handler = async () => {
|
|
590
|
+
if (showLoginModal) return;
|
|
591
|
+
clearToken();
|
|
592
|
+
const { token, userId } = await trpc.anonLogin.mutate();
|
|
593
|
+
setToken(token);
|
|
594
|
+
setAuthed(userId);
|
|
595
|
+
setShowLoginModal(true);
|
|
596
|
+
};
|
|
597
|
+
window.addEventListener(AUTH_EXPIRED_EVENT, handler);
|
|
598
|
+
return () => window.removeEventListener(AUTH_EXPIRED_EVENT, handler);
|
|
599
|
+
}, [showLoginModal]);
|
|
600
|
+
|
|
601
|
+
// Close menu on outside click
|
|
602
|
+
useEffect(() => {
|
|
603
|
+
if (!menuOpen) return;
|
|
604
|
+
const onDown = (e: MouseEvent) => {
|
|
605
|
+
if (menuRef.current && !menuRef.current.contains(e.target as HTMLElement)) setMenuOpen(false);
|
|
606
|
+
};
|
|
607
|
+
document.addEventListener('mousedown', onDown);
|
|
608
|
+
return () => document.removeEventListener('mousedown', onDown);
|
|
609
|
+
}, [menuOpen]);
|
|
610
|
+
|
|
611
|
+
const handleLogout = async () => {
|
|
612
|
+
clearToken();
|
|
613
|
+
setMenuOpen(false);
|
|
614
|
+
const { token, userId } = await trpc.anonLogin.mutate();
|
|
615
|
+
setToken(token);
|
|
616
|
+
setAuthed(userId);
|
|
617
|
+
setShowLoginModal(true);
|
|
618
|
+
};
|
|
619
|
+
|
|
620
|
+
const handleClearCache = () => {
|
|
621
|
+
cache.clear();
|
|
622
|
+
setMenuOpen(false);
|
|
623
|
+
showToast('Cache cleared');
|
|
624
|
+
location.reload();
|
|
625
|
+
};
|
|
626
|
+
|
|
627
|
+
const navigate = useCallback((path: string) => {
|
|
628
|
+
if (mode === 'editor') {
|
|
629
|
+
handleSelect(path);
|
|
630
|
+
} else {
|
|
631
|
+
setViewPath(path);
|
|
632
|
+
const prefix = mode === 'preview' ? '/v' : '';
|
|
633
|
+
history.pushState(null, '', prefix + path);
|
|
634
|
+
}
|
|
635
|
+
}, [mode, handleSelect]);
|
|
636
|
+
|
|
637
|
+
if (!authChecked) return null;
|
|
638
|
+
if (!authed || authed.startsWith('anon:')) return <LoginScreen onLogin={(uid) => setAuthed(uid)} />;
|
|
639
|
+
if (mode === 'view') return <NavigateProvider value={navigate}><ViewPage path={viewPath} /></NavigateProvider>;
|
|
640
|
+
if (mode === 'preview') return <NavigateProvider value={navigate}><ViewPage path={viewPath} editorLink /></NavigateProvider>;
|
|
641
|
+
|
|
642
|
+
const handleSetRoot = (path: string) => {
|
|
643
|
+
setRoot(path);
|
|
644
|
+
};
|
|
645
|
+
|
|
646
|
+
if (error) {
|
|
647
|
+
return (
|
|
648
|
+
<div className="app">
|
|
649
|
+
<div className="editor">
|
|
650
|
+
<div className="editor-empty">
|
|
651
|
+
<div className="icon">⚠</div>
|
|
652
|
+
<p className="text-[--danger]">{error}</p>
|
|
653
|
+
<button onClick={() => location.reload()}>Retry</button>
|
|
654
|
+
</div>
|
|
655
|
+
</div>
|
|
656
|
+
</div>
|
|
657
|
+
);
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
return (
|
|
661
|
+
<NavigateProvider value={navigate}>
|
|
662
|
+
<div className="app">
|
|
663
|
+
<div className={`sidebar${sidebarCollapsed ? ' collapsed' : ''}`}>
|
|
664
|
+
<div className="sidebar-header">
|
|
665
|
+
<span className="logo">
|
|
666
|
+
<img src="/treenity.svg" alt="" width="20" height="20" />
|
|
667
|
+
{!sidebarCollapsed && 'Treenity'}
|
|
668
|
+
</span>
|
|
669
|
+
{!sidebarCollapsed && root !== '/' && (
|
|
670
|
+
<button
|
|
671
|
+
className="sm ghost font-mono text-[11px]"
|
|
672
|
+
onClick={() => setRoot('/')}
|
|
673
|
+
title="Back to global root"
|
|
674
|
+
>
|
|
675
|
+
⌂ {root}
|
|
676
|
+
</button>
|
|
677
|
+
)}
|
|
678
|
+
{!sidebarCollapsed && roots.length === 0 && (
|
|
679
|
+
<button className="sm" onClick={handleCreateRoot}>
|
|
680
|
+
Create root
|
|
681
|
+
</button>
|
|
682
|
+
)}
|
|
683
|
+
<button
|
|
684
|
+
className="sm ghost sidebar-collapse-btn"
|
|
685
|
+
onClick={() => setSidebarCollapsed(v => !v)}
|
|
686
|
+
title={sidebarCollapsed ? 'Expand sidebar' : 'Collapse sidebar'}
|
|
687
|
+
>
|
|
688
|
+
{sidebarCollapsed ? '\u25B6' : '\u25C0'}
|
|
689
|
+
</button>
|
|
690
|
+
</div>
|
|
691
|
+
<div className="sidebar-search">
|
|
692
|
+
<input
|
|
693
|
+
ref={searchRef}
|
|
694
|
+
placeholder="Search nodes..."
|
|
695
|
+
value={filter}
|
|
696
|
+
onChange={(e) => setFilter(e.target.value)}
|
|
697
|
+
/>
|
|
698
|
+
<button
|
|
699
|
+
className="sidebar-search-toggle"
|
|
700
|
+
data-active={showHidden || undefined}
|
|
701
|
+
onClick={() => setShowHidden(v => !v)}
|
|
702
|
+
title={showHidden ? 'Hide _ prefixed nodes' : 'Show _ prefixed nodes'}
|
|
703
|
+
>
|
|
704
|
+
_
|
|
705
|
+
</button>
|
|
706
|
+
</div>
|
|
707
|
+
<div className="sidebar-tree">
|
|
708
|
+
<Tree
|
|
709
|
+
roots={roots}
|
|
710
|
+
expanded={expanded}
|
|
711
|
+
loaded={loaded}
|
|
712
|
+
selected={selected}
|
|
713
|
+
filter={filter}
|
|
714
|
+
showHidden={showHidden}
|
|
715
|
+
onSelect={handleSelect}
|
|
716
|
+
onExpand={handleExpand}
|
|
717
|
+
onCreateChild={handleCreateChild}
|
|
718
|
+
onDelete={handleDelete}
|
|
719
|
+
onMove={handleMove}
|
|
720
|
+
/>
|
|
721
|
+
</div>
|
|
722
|
+
<div className="sidebar-footer" ref={menuRef}>
|
|
723
|
+
<span>
|
|
724
|
+
{authed?.startsWith('anon:') ? `anon:${authed.slice(5, 13)}` : authed} · <NodeCount /> nodes
|
|
725
|
+
</span>
|
|
726
|
+
<button className="sm ghost" onClick={() => setMenuOpen(v => !v)}>
|
|
727
|
+
☰
|
|
728
|
+
</button>
|
|
729
|
+
{menuOpen && (
|
|
730
|
+
<div className="sidebar-menu">
|
|
731
|
+
<button onClick={handleLogout}>
|
|
732
|
+
{authed?.startsWith('anon:') ? 'Login' : 'Logout'}
|
|
733
|
+
</button>
|
|
734
|
+
<button onClick={handleClearCache}>
|
|
735
|
+
Clear cache
|
|
736
|
+
</button>
|
|
737
|
+
</div>
|
|
738
|
+
)}
|
|
739
|
+
</div>
|
|
740
|
+
</div>
|
|
741
|
+
|
|
742
|
+
<Inspector
|
|
743
|
+
path={selected}
|
|
744
|
+
currentUserId={authed ?? undefined}
|
|
745
|
+
onDelete={handleDelete}
|
|
746
|
+
onAddComponent={handleAddComponent}
|
|
747
|
+
onSelect={handleSelect}
|
|
748
|
+
onSetRoot={handleSetRoot}
|
|
749
|
+
toast={showToast}
|
|
750
|
+
/>
|
|
751
|
+
|
|
752
|
+
{creatingAt && <TypePicker onSelect={handlePickType} onCancel={() => setCreatingAt(null)} />}
|
|
753
|
+
|
|
754
|
+
{addingComponentAt && (
|
|
755
|
+
<TypePicker
|
|
756
|
+
title="Add Component"
|
|
757
|
+
nameLabel="Component name"
|
|
758
|
+
action="Add"
|
|
759
|
+
onSelect={handlePickComponent}
|
|
760
|
+
onCancel={() => setAddingComponentAt(null)}
|
|
761
|
+
/>
|
|
762
|
+
)}
|
|
763
|
+
|
|
764
|
+
{showLoginModal && (
|
|
765
|
+
<LoginModal
|
|
766
|
+
onLogin={(uid) => { setAuthed(uid); setShowLoginModal(false); }}
|
|
767
|
+
onClose={() => setShowLoginModal(false)}
|
|
768
|
+
/>
|
|
769
|
+
)}
|
|
770
|
+
|
|
771
|
+
{toastMsg && <div className={`toast ${toastMsg.type === 'error' ? 'toast-error' : ''}`}>{toastMsg.text}</div>}
|
|
772
|
+
</div>
|
|
773
|
+
</NavigateProvider>
|
|
774
|
+
);
|
|
775
|
+
}
|