@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.
Files changed (297) hide show
  1. package/dist/AclEditor.d.ts +11 -0
  2. package/dist/AclEditor.d.ts.map +1 -0
  3. package/dist/AclEditor.js +152 -0
  4. package/dist/AclEditor.js.map +1 -0
  5. package/dist/App.d.ts +2 -0
  6. package/dist/App.d.ts.map +1 -0
  7. package/dist/App.js +521 -0
  8. package/dist/App.js.map +1 -0
  9. package/dist/Inspector.d.ts +12 -0
  10. package/dist/Inspector.d.ts.map +1 -0
  11. package/dist/Inspector.js +360 -0
  12. package/dist/Inspector.js.map +1 -0
  13. package/dist/Tree.d.ts +16 -0
  14. package/dist/Tree.d.ts.map +1 -0
  15. package/dist/Tree.js +100 -0
  16. package/dist/Tree.js.map +1 -0
  17. package/dist/ViewPage.d.ts +5 -0
  18. package/dist/ViewPage.d.ts.map +1 -0
  19. package/dist/ViewPage.js +13 -0
  20. package/dist/ViewPage.js.map +1 -0
  21. package/dist/bind/computed.d.ts +9 -0
  22. package/dist/bind/computed.d.ts.map +1 -0
  23. package/dist/bind/computed.js +61 -0
  24. package/dist/bind/computed.js.map +1 -0
  25. package/dist/bind/engine.d.ts +3 -0
  26. package/dist/bind/engine.d.ts.map +1 -0
  27. package/dist/bind/engine.js +184 -0
  28. package/dist/bind/engine.js.map +1 -0
  29. package/dist/bind/eval.d.ts +13 -0
  30. package/dist/bind/eval.d.ts.map +1 -0
  31. package/dist/bind/eval.js +97 -0
  32. package/dist/bind/eval.js.map +1 -0
  33. package/dist/bind/hook.d.ts +8 -0
  34. package/dist/bind/hook.d.ts.map +1 -0
  35. package/dist/bind/hook.js +99 -0
  36. package/dist/bind/hook.js.map +1 -0
  37. package/dist/bind/parse.d.ts +19 -0
  38. package/dist/bind/parse.d.ts.map +1 -0
  39. package/dist/bind/parse.js +86 -0
  40. package/dist/bind/parse.js.map +1 -0
  41. package/dist/bind/pipes.d.ts +4 -0
  42. package/dist/bind/pipes.d.ts.map +1 -0
  43. package/dist/bind/pipes.js +43 -0
  44. package/dist/bind/pipes.js.map +1 -0
  45. package/dist/cache.d.ts +27 -0
  46. package/dist/cache.d.ts.map +1 -0
  47. package/dist/cache.js +236 -0
  48. package/dist/cache.js.map +1 -0
  49. package/dist/client-tree.d.ts +9 -0
  50. package/dist/client-tree.d.ts.map +1 -0
  51. package/dist/client-tree.js +14 -0
  52. package/dist/client-tree.js.map +1 -0
  53. package/dist/client.d.ts +2 -0
  54. package/dist/client.d.ts.map +1 -0
  55. package/dist/client.js +10 -0
  56. package/dist/client.js.map +1 -0
  57. package/dist/components/ui/accordion.d.ts +8 -0
  58. package/dist/components/ui/accordion.d.ts.map +1 -0
  59. package/dist/components/ui/accordion.js +18 -0
  60. package/dist/components/ui/accordion.js.map +1 -0
  61. package/dist/components/ui/badge.d.ts +10 -0
  62. package/dist/components/ui/badge.d.ts.map +1 -0
  63. package/dist/components/ui/badge.js +19 -0
  64. package/dist/components/ui/badge.js.map +1 -0
  65. package/dist/components/ui/button.d.ts +11 -0
  66. package/dist/components/ui/button.d.ts.map +1 -0
  67. package/dist/components/ui/button.js +31 -0
  68. package/dist/components/ui/button.js.map +1 -0
  69. package/dist/components/ui/checkbox.d.ts +4 -0
  70. package/dist/components/ui/checkbox.d.ts.map +1 -0
  71. package/dist/components/ui/checkbox.js +7 -0
  72. package/dist/components/ui/checkbox.js.map +1 -0
  73. package/dist/components/ui/dialog.d.ts +18 -0
  74. package/dist/components/ui/dialog.d.ts.map +1 -0
  75. package/dist/components/ui/dialog.js +37 -0
  76. package/dist/components/ui/dialog.js.map +1 -0
  77. package/dist/components/ui/drawer.d.ts +14 -0
  78. package/dist/components/ui/drawer.d.ts.map +1 -0
  79. package/dist/components/ui/drawer.js +35 -0
  80. package/dist/components/ui/drawer.js.map +1 -0
  81. package/dist/components/ui/input.d.ts +4 -0
  82. package/dist/components/ui/input.d.ts.map +1 -0
  83. package/dist/components/ui/input.js +7 -0
  84. package/dist/components/ui/input.js.map +1 -0
  85. package/dist/components/ui/label.d.ts +5 -0
  86. package/dist/components/ui/label.d.ts.map +1 -0
  87. package/dist/components/ui/label.js +8 -0
  88. package/dist/components/ui/label.js.map +1 -0
  89. package/dist/components/ui/popover.d.ts +11 -0
  90. package/dist/components/ui/popover.d.ts.map +1 -0
  91. package/dist/components/ui/popover.js +26 -0
  92. package/dist/components/ui/popover.js.map +1 -0
  93. package/dist/components/ui/progress.d.ts +5 -0
  94. package/dist/components/ui/progress.d.ts.map +1 -0
  95. package/dist/components/ui/progress.js +9 -0
  96. package/dist/components/ui/progress.js.map +1 -0
  97. package/dist/components/ui/select.d.ts +16 -0
  98. package/dist/components/ui/select.d.ts.map +1 -0
  99. package/dist/components/ui/select.js +39 -0
  100. package/dist/components/ui/select.js.map +1 -0
  101. package/dist/components/ui/slider.d.ts +5 -0
  102. package/dist/components/ui/slider.d.ts.map +1 -0
  103. package/dist/components/ui/slider.js +15 -0
  104. package/dist/components/ui/slider.js.map +1 -0
  105. package/dist/components/ui/sonner.d.ts +4 -0
  106. package/dist/components/ui/sonner.d.ts.map +1 -0
  107. package/dist/components/ui/sonner.js +21 -0
  108. package/dist/components/ui/sonner.js.map +1 -0
  109. package/dist/components/ui/switch.d.ts +7 -0
  110. package/dist/components/ui/switch.d.ts.map +1 -0
  111. package/dist/components/ui/switch.js +9 -0
  112. package/dist/components/ui/switch.js.map +1 -0
  113. package/dist/components/ui/textarea.d.ts +4 -0
  114. package/dist/components/ui/textarea.d.ts.map +1 -0
  115. package/dist/components/ui/textarea.js +7 -0
  116. package/dist/components/ui/textarea.js.map +1 -0
  117. package/dist/components/ui/tooltip.d.ts +8 -0
  118. package/dist/components/ui/tooltip.d.ts.map +1 -0
  119. package/dist/components/ui/tooltip.js +18 -0
  120. package/dist/components/ui/tooltip.js.map +1 -0
  121. package/dist/context/index.d.ts +31 -0
  122. package/dist/context/index.d.ts.map +1 -0
  123. package/dist/context/index.js +98 -0
  124. package/dist/context/index.js.map +1 -0
  125. package/dist/context.d.ts +2 -0
  126. package/dist/context.d.ts.map +1 -0
  127. package/dist/context.js +2 -0
  128. package/dist/context.js.map +1 -0
  129. package/dist/hooks.d.ts +21 -0
  130. package/dist/hooks.d.ts.map +1 -0
  131. package/dist/hooks.js +156 -0
  132. package/dist/hooks.js.map +1 -0
  133. package/dist/idb.d.ts +13 -0
  134. package/dist/idb.d.ts.map +1 -0
  135. package/dist/idb.js +67 -0
  136. package/dist/idb.js.map +1 -0
  137. package/dist/lib/minimd.d.ts +3 -0
  138. package/dist/lib/minimd.d.ts.map +1 -0
  139. package/dist/lib/minimd.js +97 -0
  140. package/dist/lib/minimd.js.map +1 -0
  141. package/dist/lib/utils.d.ts +3 -0
  142. package/dist/lib/utils.d.ts.map +1 -0
  143. package/dist/lib/utils.js +6 -0
  144. package/dist/lib/utils.js.map +1 -0
  145. package/dist/load-client.d.ts +2 -0
  146. package/dist/load-client.d.ts.map +1 -0
  147. package/dist/load-client.js +6 -0
  148. package/dist/load-client.js.map +1 -0
  149. package/dist/main.d.ts +4 -0
  150. package/dist/main.d.ts.map +1 -0
  151. package/dist/main.js +16 -0
  152. package/dist/main.js.map +1 -0
  153. package/dist/mods/editor-ui/client.d.ts +6 -0
  154. package/dist/mods/editor-ui/client.d.ts.map +1 -0
  155. package/dist/mods/editor-ui/client.js +8 -0
  156. package/dist/mods/editor-ui/client.js.map +1 -0
  157. package/dist/mods/editor-ui/default-view.d.ts +2 -0
  158. package/dist/mods/editor-ui/default-view.d.ts.map +1 -0
  159. package/dist/mods/editor-ui/default-view.js +71 -0
  160. package/dist/mods/editor-ui/default-view.js.map +1 -0
  161. package/dist/mods/editor-ui/dir-view.d.ts +2 -0
  162. package/dist/mods/editor-ui/dir-view.d.ts.map +1 -0
  163. package/dist/mods/editor-ui/dir-view.js +42 -0
  164. package/dist/mods/editor-ui/dir-view.js.map +1 -0
  165. package/dist/mods/editor-ui/form-fields.d.ts +6 -0
  166. package/dist/mods/editor-ui/form-fields.d.ts.map +1 -0
  167. package/dist/mods/editor-ui/form-fields.js +401 -0
  168. package/dist/mods/editor-ui/form-fields.js.map +1 -0
  169. package/dist/mods/editor-ui/layout-view.d.ts +2 -0
  170. package/dist/mods/editor-ui/layout-view.d.ts.map +1 -0
  171. package/dist/mods/editor-ui/layout-view.js +22 -0
  172. package/dist/mods/editor-ui/layout-view.js.map +1 -0
  173. package/dist/mods/editor-ui/list-items.d.ts +2 -0
  174. package/dist/mods/editor-ui/list-items.d.ts.map +1 -0
  175. package/dist/mods/editor-ui/list-items.js +38 -0
  176. package/dist/mods/editor-ui/list-items.js.map +1 -0
  177. package/dist/mods/editor-ui/node-utils.d.ts +10 -0
  178. package/dist/mods/editor-ui/node-utils.d.ts.map +1 -0
  179. package/dist/mods/editor-ui/node-utils.js +76 -0
  180. package/dist/mods/editor-ui/node-utils.js.map +1 -0
  181. package/dist/mods/editor-ui/user-view.d.ts +2 -0
  182. package/dist/mods/editor-ui/user-view.d.ts.map +1 -0
  183. package/dist/mods/editor-ui/user-view.js +47 -0
  184. package/dist/mods/editor-ui/user-view.js.map +1 -0
  185. package/dist/mods/treenity/client.d.ts +4 -0
  186. package/dist/mods/treenity/client.d.ts.map +1 -0
  187. package/dist/mods/treenity/client.js +6 -0
  188. package/dist/mods/treenity/client.js.map +1 -0
  189. package/dist/mods/treenity/groups/index.d.ts +2 -0
  190. package/dist/mods/treenity/groups/index.d.ts.map +1 -0
  191. package/dist/mods/treenity/groups/index.js +27 -0
  192. package/dist/mods/treenity/groups/index.js.map +1 -0
  193. package/dist/mods/treenity/preview.d.ts +6 -0
  194. package/dist/mods/treenity/preview.d.ts.map +1 -0
  195. package/dist/mods/treenity/preview.js +95 -0
  196. package/dist/mods/treenity/preview.js.map +1 -0
  197. package/dist/mods/treenity/ref-view.d.ts +2 -0
  198. package/dist/mods/treenity/ref-view.d.ts.map +1 -0
  199. package/dist/mods/treenity/ref-view.js +29 -0
  200. package/dist/mods/treenity/ref-view.js.map +1 -0
  201. package/dist/mods/treenity/schema-form.d.ts +2 -0
  202. package/dist/mods/treenity/schema-form.d.ts.map +1 -0
  203. package/dist/mods/treenity/schema-form.js +38 -0
  204. package/dist/mods/treenity/schema-form.js.map +1 -0
  205. package/dist/mods/treenity/seed.d.ts +2 -0
  206. package/dist/mods/treenity/seed.d.ts.map +1 -0
  207. package/dist/mods/treenity/seed.js +53 -0
  208. package/dist/mods/treenity/seed.js.map +1 -0
  209. package/dist/mods/treenity/server.d.ts +2 -0
  210. package/dist/mods/treenity/server.d.ts.map +1 -0
  211. package/dist/mods/treenity/server.js +2 -0
  212. package/dist/mods/treenity/server.js.map +1 -0
  213. package/dist/mods/treenity/type-view.d.ts +2 -0
  214. package/dist/mods/treenity/type-view.d.ts.map +1 -0
  215. package/dist/mods/treenity/type-view.js +36 -0
  216. package/dist/mods/treenity/type-view.js.map +1 -0
  217. package/dist/remote-tree.d.ts +6 -0
  218. package/dist/remote-tree.d.ts.map +1 -0
  219. package/dist/remote-tree.js +18 -0
  220. package/dist/remote-tree.js.map +1 -0
  221. package/dist/schema-loader.d.ts +19 -0
  222. package/dist/schema-loader.d.ts.map +1 -0
  223. package/dist/schema-loader.js +63 -0
  224. package/dist/schema-loader.js.map +1 -0
  225. package/dist/trpc.d.ts +187 -0
  226. package/dist/trpc.d.ts.map +1 -0
  227. package/dist/trpc.js +21 -0
  228. package/dist/trpc.js.map +1 -0
  229. package/package.json +88 -0
  230. package/src/AclEditor.tsx +330 -0
  231. package/src/App.tsx +775 -0
  232. package/src/CLAUDE.md +16 -0
  233. package/src/Inspector.tsx +857 -0
  234. package/src/Tree.tsx +237 -0
  235. package/src/ViewPage.tsx +45 -0
  236. package/src/bind/bind.test.ts +316 -0
  237. package/src/bind/computed.ts +64 -0
  238. package/src/bind/engine.ts +198 -0
  239. package/src/bind/eval.ts +108 -0
  240. package/src/bind/hook.ts +112 -0
  241. package/src/bind/parse.ts +104 -0
  242. package/src/bind/pipes.ts +71 -0
  243. package/src/cache.test.ts +139 -0
  244. package/src/cache.ts +244 -0
  245. package/src/client-tree.test.ts +116 -0
  246. package/src/client-tree.ts +24 -0
  247. package/src/client.ts +11 -0
  248. package/src/components/ui/accordion.tsx +63 -0
  249. package/src/components/ui/badge.tsx +27 -0
  250. package/src/components/ui/button.tsx +44 -0
  251. package/src/components/ui/checkbox.tsx +19 -0
  252. package/src/components/ui/dialog.tsx +156 -0
  253. package/src/components/ui/drawer.tsx +132 -0
  254. package/src/components/ui/input.tsx +19 -0
  255. package/src/components/ui/label.tsx +21 -0
  256. package/src/components/ui/popover.tsx +86 -0
  257. package/src/components/ui/progress.tsx +30 -0
  258. package/src/components/ui/select.tsx +189 -0
  259. package/src/components/ui/slider.tsx +62 -0
  260. package/src/components/ui/sonner.tsx +32 -0
  261. package/src/components/ui/switch.tsx +34 -0
  262. package/src/components/ui/textarea.tsx +17 -0
  263. package/src/components/ui/tooltip.tsx +56 -0
  264. package/src/context/index.tsx +131 -0
  265. package/src/context.ts +1 -0
  266. package/src/hooks.ts +208 -0
  267. package/src/idb.ts +80 -0
  268. package/src/index.html +14 -0
  269. package/src/lib/minimd.css +28 -0
  270. package/src/lib/minimd.ts +95 -0
  271. package/src/lib/utils.ts +6 -0
  272. package/src/load-client.ts +5 -0
  273. package/src/main.tsx +22 -0
  274. package/src/mods/editor-ui/CLAUDE.md +3 -0
  275. package/src/mods/editor-ui/client.ts +8 -0
  276. package/src/mods/editor-ui/default-view.tsx +148 -0
  277. package/src/mods/editor-ui/dir-view.tsx +91 -0
  278. package/src/mods/editor-ui/form-fields.tsx +861 -0
  279. package/src/mods/editor-ui/layout-view.tsx +62 -0
  280. package/src/mods/editor-ui/list-items.tsx +63 -0
  281. package/src/mods/editor-ui/node-utils.ts +84 -0
  282. package/src/mods/editor-ui/user-view.tsx +101 -0
  283. package/src/mods/treenity/CLAUDE.md +7 -0
  284. package/src/mods/treenity/client.ts +6 -0
  285. package/src/mods/treenity/groups/index.tsx +65 -0
  286. package/src/mods/treenity/preview.tsx +133 -0
  287. package/src/mods/treenity/ref-view.tsx +87 -0
  288. package/src/mods/treenity/schema-form.tsx +65 -0
  289. package/src/mods/treenity/seed.ts +56 -0
  290. package/src/mods/treenity/server.ts +1 -0
  291. package/src/mods/treenity/type-view.tsx +116 -0
  292. package/src/remote-tree.test.ts +142 -0
  293. package/src/remote-tree.ts +25 -0
  294. package/src/schema-loader.ts +84 -0
  295. package/src/style.css +1269 -0
  296. package/src/trpc.ts +27 -0
  297. 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}>&times;</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">&#9888;</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
+ &#8962; {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} &middot; <NodeCount /> nodes
725
+ </span>
726
+ <button className="sm ghost" onClick={() => setMenuOpen(v => !v)}>
727
+ &#9776;
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
+ }