@isothrum/editor 0.0.0-stage → 0.1.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 (293) hide show
  1. package/LICENSE +202 -0
  2. package/NOTICE +8 -0
  3. package/PUBLISHING.md +93 -0
  4. package/README.md +106 -2
  5. package/desktop-editor/editor.css +11 -0
  6. package/desktop-editor/index.html +14 -0
  7. package/desktop-editor/main.tsx +26 -0
  8. package/dist/desktop/editor.css +1 -0
  9. package/dist/desktop/editor.js +181 -0
  10. package/dist/desktop/fonts/geist-cyrillic-ext-wght-normal-RO72NYA2.woff2 +0 -0
  11. package/dist/desktop/fonts/geist-cyrillic-wght-normal-CK2XY27K.woff2 +0 -0
  12. package/dist/desktop/fonts/geist-latin-ext-wght-normal-XEQ56JUF.woff2 +0 -0
  13. package/dist/desktop/fonts/geist-latin-wght-normal-T72VVCUW.woff2 +0 -0
  14. package/dist/desktop/fonts/geist-mono-cyrillic-ext-wght-normal-QXKVIKFD.woff2 +0 -0
  15. package/dist/desktop/fonts/geist-mono-cyrillic-wght-normal-GOIAQAGU.woff2 +0 -0
  16. package/dist/desktop/fonts/geist-mono-latin-ext-wght-normal-HU3ZAWN6.woff2 +0 -0
  17. package/dist/desktop/fonts/geist-mono-latin-wght-normal-CJNQKXUG.woff2 +0 -0
  18. package/dist/desktop/fonts/geist-mono-symbols2-wght-normal-WZJJVLQO.woff2 +0 -0
  19. package/dist/desktop/fonts/geist-mono-vietnamese-wght-normal-KW5NM6YV.woff2 +0 -0
  20. package/dist/desktop/fonts/geist-vietnamese-wght-normal-Y2R7JZKV.woff2 +0 -0
  21. package/dist/desktop/fonts/shantell-sans-cyrillic-ext-wght-normal-ILPQWRJJ.woff2 +0 -0
  22. package/dist/desktop/fonts/shantell-sans-cyrillic-wght-normal-RKC6NDPM.woff2 +0 -0
  23. package/dist/desktop/fonts/shantell-sans-latin-ext-wght-normal-SNKHG3LM.woff2 +0 -0
  24. package/dist/desktop/fonts/shantell-sans-latin-wght-normal-7CHHGC3N.woff2 +0 -0
  25. package/dist/desktop/fonts/shantell-sans-vietnamese-wght-normal-IPFAQSBA.woff2 +0 -0
  26. package/dist/desktop/index.html +14 -0
  27. package/dist/desktop/styles.css +2 -0
  28. package/dist/desktop/wasm/isothrum_wasm.d.ts +60 -0
  29. package/dist/desktop/wasm/isothrum_wasm.js +312 -0
  30. package/dist/desktop/wasm/isothrum_wasm_bg.wasm +0 -0
  31. package/dist/index.js +7059 -0
  32. package/dist/index.js.map +7 -0
  33. package/dist/model.js +745 -0
  34. package/dist/model.js.map +7 -0
  35. package/dist/types/components/tool/canvas/cable-edge.d.ts +4 -0
  36. package/dist/types/components/tool/canvas/contexts.d.ts +12 -0
  37. package/dist/types/components/tool/canvas/device-icon.d.ts +17 -0
  38. package/dist/types/components/tool/canvas/inline-text.d.ts +15 -0
  39. package/dist/types/components/tool/canvas/lan-node.d.ts +8 -0
  40. package/dist/types/components/tool/canvas/machine-addresses.d.ts +14 -0
  41. package/dist/types/components/tool/canvas/machine-node.d.ts +4 -0
  42. package/dist/types/components/tool/canvas/note-node.d.ts +7 -0
  43. package/dist/types/components/tool/canvas/port-led.d.ts +10 -0
  44. package/dist/types/components/tool/canvas/router-node.d.ts +8 -0
  45. package/dist/types/components/tool/canvas/sketch.d.ts +50 -0
  46. package/dist/types/components/tool/canvas/status-dot.d.ts +5 -0
  47. package/dist/types/components/tool/canvas/switch-node.d.ts +8 -0
  48. package/dist/types/components/tool/canvas/use-measured-ports.d.ts +7 -0
  49. package/dist/types/components/tool/context-menus.d.ts +5 -0
  50. package/dist/types/components/tool/device-block.d.ts +12 -0
  51. package/dist/types/components/tool/editor-menu.d.ts +28 -0
  52. package/dist/types/components/tool/export/copy-button.d.ts +9 -0
  53. package/dist/types/components/tool/export/export-panel.d.ts +17 -0
  54. package/dist/types/components/tool/export/target-list.d.ts +15 -0
  55. package/dist/types/components/tool/floating-window.d.ts +13 -0
  56. package/dist/types/components/tool/flow-canvas.d.ts +6 -0
  57. package/dist/types/components/tool/hooks/use-cabling.d.ts +23 -0
  58. package/dist/types/components/tool/hooks/use-captures.d.ts +9 -0
  59. package/dist/types/components/tool/hooks/use-clipboard.d.ts +12 -0
  60. package/dist/types/components/tool/hooks/use-context-menus.d.ts +25 -0
  61. package/dist/types/components/tool/hooks/use-design.d.ts +10 -0
  62. package/dist/types/components/tool/hooks/use-desktop.d.ts +50 -0
  63. package/dist/types/components/tool/hooks/use-devices.d.ts +17 -0
  64. package/dist/types/components/tool/hooks/use-downloads.d.ts +9 -0
  65. package/dist/types/components/tool/hooks/use-editor.d.ts +609 -0
  66. package/dist/types/components/tool/hooks/use-engine.d.ts +9 -0
  67. package/dist/types/components/tool/hooks/use-history.d.ts +9 -0
  68. package/dist/types/components/tool/hooks/use-lab-actions.d.ts +25 -0
  69. package/dist/types/components/tool/hooks/use-lab-run.d.ts +25 -0
  70. package/dist/types/components/tool/hooks/use-lab.d.ts +18 -0
  71. package/dist/types/components/tool/hooks/use-lan-carry.d.ts +8 -0
  72. package/dist/types/components/tool/hooks/use-machine-actions.d.ts +26 -0
  73. package/dist/types/components/tool/hooks/use-placement.d.ts +27 -0
  74. package/dist/types/components/tool/hooks/use-project.d.ts +25 -0
  75. package/dist/types/components/tool/hooks/use-projects.d.ts +34 -0
  76. package/dist/types/components/tool/hooks/use-shared-link.d.ts +23 -0
  77. package/dist/types/components/tool/hooks/use-toast.d.ts +2 -0
  78. package/dist/types/components/tool/hooks/use-tools.d.ts +17 -0
  79. package/dist/types/components/tool/lab-canvas.d.ts +18 -0
  80. package/dist/types/components/tool/overlays.d.ts +5 -0
  81. package/dist/types/components/tool/projects-list.d.ts +9 -0
  82. package/dist/types/components/tool/text.d.ts +418 -0
  83. package/dist/types/components/tool/toolbar.d.ts +5 -0
  84. package/dist/types/components/tool/top-bars.d.ts +10 -0
  85. package/dist/types/components/tool/windows/conditions-fields.d.ts +6 -0
  86. package/dist/types/components/tool/windows/editor-windows.d.ts +5 -0
  87. package/dist/types/components/tool/windows/fields.d.ts +4 -0
  88. package/dist/types/components/tool/windows/lab-status.d.ts +6 -0
  89. package/dist/types/components/tool/windows/lab-windows.d.ts +13 -0
  90. package/dist/types/components/tool/windows/link-controls.d.ts +21 -0
  91. package/dist/types/components/tool/windows/machine-controls.d.ts +16 -0
  92. package/dist/types/components/tool/windows/machine-inspector.d.ts +11 -0
  93. package/dist/types/components/tool/windows/runs-fields.d.ts +18 -0
  94. package/dist/types/components/tool/windows/selected-windows.d.ts +21 -0
  95. package/dist/types/components/ui/button.d.ts +8 -0
  96. package/dist/types/components/ui/dropdown-menu.d.ts +21 -0
  97. package/dist/types/index.d.ts +7 -0
  98. package/dist/types/lib/auth.d.ts +38 -0
  99. package/dist/types/lib/brand.d.ts +19 -0
  100. package/dist/types/lib/i18n.d.ts +19 -0
  101. package/dist/types/lib/tool/blocks.d.ts +35 -0
  102. package/dist/types/lib/tool/cables.d.ts +48 -0
  103. package/dist/types/lib/tool/clone.d.ts +12 -0
  104. package/dist/types/lib/tool/conditions.d.ts +31 -0
  105. package/dist/types/lib/tool/create.d.ts +22 -0
  106. package/dist/types/lib/tool/desktop.d.ts +63 -0
  107. package/dist/types/lib/tool/draft.d.ts +20 -0
  108. package/dist/types/lib/tool/engine.d.ts +33 -0
  109. package/dist/types/lib/tool/examples.d.ts +11 -0
  110. package/dist/types/lib/tool/export-targets.d.ts +21 -0
  111. package/dist/types/lib/tool/facing.d.ts +9 -0
  112. package/dist/types/lib/tool/geometry.d.ts +36 -0
  113. package/dist/types/lib/tool/gns3-cables.d.ts +17 -0
  114. package/dist/types/lib/tool/gns3-import.d.ts +15 -0
  115. package/dist/types/lib/tool/gns3-parts.d.ts +25 -0
  116. package/dist/types/lib/tool/issues.d.ts +12 -0
  117. package/dist/types/lib/tool/layout.d.ts +52 -0
  118. package/dist/types/lib/tool/live.d.ts +21 -0
  119. package/dist/types/lib/tool/logos.d.ts +7 -0
  120. package/dist/types/lib/tool/model.d.ts +173 -0
  121. package/dist/types/lib/tool/os.d.ts +20 -0
  122. package/dist/types/lib/tool/pins.d.ts +5 -0
  123. package/dist/types/lib/tool/placement.d.ts +21 -0
  124. package/dist/types/lib/tool/ports.d.ts +21 -0
  125. package/dist/types/lib/tool/presets.d.ts +21 -0
  126. package/dist/types/lib/tool/projects-api.d.ts +37 -0
  127. package/dist/types/lib/tool/router-ports.d.ts +22 -0
  128. package/dist/types/lib/tool/selection.d.ts +19 -0
  129. package/dist/types/lib/tool/storage.d.ts +25 -0
  130. package/dist/types/lib/tool/switch-edit.d.ts +17 -0
  131. package/dist/types/lib/tool/text/de.d.ts +2 -0
  132. package/dist/types/lib/tool/text/en.d.ts +429 -0
  133. package/dist/types/lib/tool/text/es.d.ts +2 -0
  134. package/dist/types/lib/tool/text/fr.d.ts +2 -0
  135. package/dist/types/lib/tool/text/index.d.ts +419 -0
  136. package/dist/types/lib/tool/topology.d.ts +2 -0
  137. package/dist/types/lib/tool/vlans.d.ts +24 -0
  138. package/dist/types/lib/utils.d.ts +2 -0
  139. package/dist/types/model.d.ts +13 -0
  140. package/package.json +88 -4
  141. package/scripts/build-desktop-editor.mjs +43 -0
  142. package/scripts/build-lib.mjs +38 -0
  143. package/src/components/tool/canvas/cable-edge.tsx +112 -0
  144. package/src/components/tool/canvas/contexts.ts +18 -0
  145. package/src/components/tool/canvas/device-icon.tsx +105 -0
  146. package/src/components/tool/canvas/inline-text.tsx +69 -0
  147. package/src/components/tool/canvas/lan-node.tsx +148 -0
  148. package/src/components/tool/canvas/machine-addresses.tsx +117 -0
  149. package/src/components/tool/canvas/machine-node.tsx +114 -0
  150. package/src/components/tool/canvas/note-node.tsx +109 -0
  151. package/src/components/tool/canvas/port-led.tsx +21 -0
  152. package/src/components/tool/canvas/router-node.tsx +114 -0
  153. package/src/components/tool/canvas/sketch.tsx +130 -0
  154. package/src/components/tool/canvas/status-dot.tsx +29 -0
  155. package/src/components/tool/canvas/switch-node.tsx +143 -0
  156. package/src/components/tool/canvas/use-measured-ports.ts +25 -0
  157. package/src/components/tool/context-menus.tsx +142 -0
  158. package/src/components/tool/device-block.tsx +107 -0
  159. package/src/components/tool/editor-menu.tsx +191 -0
  160. package/src/components/tool/export/copy-button.tsx +45 -0
  161. package/src/components/tool/export/export-panel.tsx +127 -0
  162. package/src/components/tool/export/target-list.tsx +87 -0
  163. package/src/components/tool/floating-window.tsx +69 -0
  164. package/src/components/tool/flow-canvas.tsx +138 -0
  165. package/src/components/tool/hooks/use-cabling.ts +66 -0
  166. package/src/components/tool/hooks/use-captures.ts +24 -0
  167. package/src/components/tool/hooks/use-clipboard.ts +54 -0
  168. package/src/components/tool/hooks/use-context-menus.ts +37 -0
  169. package/src/components/tool/hooks/use-design.ts +24 -0
  170. package/src/components/tool/hooks/use-desktop.ts +100 -0
  171. package/src/components/tool/hooks/use-devices.ts +58 -0
  172. package/src/components/tool/hooks/use-downloads.ts +42 -0
  173. package/src/components/tool/hooks/use-editor.ts +114 -0
  174. package/src/components/tool/hooks/use-engine.ts +29 -0
  175. package/src/components/tool/hooks/use-history.ts +107 -0
  176. package/src/components/tool/hooks/use-lab-actions.ts +57 -0
  177. package/src/components/tool/hooks/use-lab-run.ts +61 -0
  178. package/src/components/tool/hooks/use-lab.ts +32 -0
  179. package/src/components/tool/hooks/use-lan-carry.ts +43 -0
  180. package/src/components/tool/hooks/use-machine-actions.ts +57 -0
  181. package/src/components/tool/hooks/use-placement.ts +80 -0
  182. package/src/components/tool/hooks/use-project.ts +112 -0
  183. package/src/components/tool/hooks/use-projects.ts +85 -0
  184. package/src/components/tool/hooks/use-shared-link.ts +64 -0
  185. package/src/components/tool/hooks/use-toast.ts +16 -0
  186. package/src/components/tool/hooks/use-tools.ts +60 -0
  187. package/src/components/tool/lab-canvas.tsx +96 -0
  188. package/src/components/tool/overlays.tsx +104 -0
  189. package/src/components/tool/projects-list.tsx +96 -0
  190. package/src/components/tool/text.ts +9 -0
  191. package/src/components/tool/toolbar.tsx +106 -0
  192. package/src/components/tool/top-bars.tsx +90 -0
  193. package/src/components/tool/windows/conditions-fields.tsx +43 -0
  194. package/src/components/tool/windows/editor-windows.tsx +73 -0
  195. package/src/components/tool/windows/fields.ts +6 -0
  196. package/src/components/tool/windows/lab-status.tsx +68 -0
  197. package/src/components/tool/windows/lab-windows.tsx +130 -0
  198. package/src/components/tool/windows/link-controls.tsx +69 -0
  199. package/src/components/tool/windows/machine-controls.tsx +116 -0
  200. package/src/components/tool/windows/machine-inspector.tsx +83 -0
  201. package/src/components/tool/windows/runs-fields.tsx +127 -0
  202. package/src/components/tool/windows/selected-windows.tsx +147 -0
  203. package/src/components/ui/button.tsx +59 -0
  204. package/src/components/ui/dropdown-menu.tsx +185 -0
  205. package/src/index.ts +21 -0
  206. package/src/lib/auth.tsx +43 -0
  207. package/src/lib/brand.tsx +46 -0
  208. package/src/lib/i18n.ts +53 -0
  209. package/src/lib/tool/blocks.test.ts +64 -0
  210. package/src/lib/tool/blocks.ts +78 -0
  211. package/src/lib/tool/cable-wiring.test.ts +74 -0
  212. package/src/lib/tool/cables.test.ts +75 -0
  213. package/src/lib/tool/cables.ts +215 -0
  214. package/src/lib/tool/clone.test.ts +54 -0
  215. package/src/lib/tool/clone.ts +48 -0
  216. package/src/lib/tool/conditions.test.ts +170 -0
  217. package/src/lib/tool/conditions.ts +132 -0
  218. package/src/lib/tool/create.test.ts +67 -0
  219. package/src/lib/tool/create.ts +83 -0
  220. package/src/lib/tool/desktop.test.ts +59 -0
  221. package/src/lib/tool/desktop.ts +99 -0
  222. package/src/lib/tool/draft.test.ts +150 -0
  223. package/src/lib/tool/draft.ts +109 -0
  224. package/src/lib/tool/engine.test.ts +39 -0
  225. package/src/lib/tool/engine.ts +58 -0
  226. package/src/lib/tool/examples.test.ts +35 -0
  227. package/src/lib/tool/examples.ts +71 -0
  228. package/src/lib/tool/export-targets.test.ts +47 -0
  229. package/src/lib/tool/export-targets.ts +84 -0
  230. package/src/lib/tool/facing.test.ts +72 -0
  231. package/src/lib/tool/facing.ts +100 -0
  232. package/src/lib/tool/geometry.test.ts +80 -0
  233. package/src/lib/tool/geometry.ts +109 -0
  234. package/src/lib/tool/gns3-cables.test.ts +47 -0
  235. package/src/lib/tool/gns3-cables.ts +81 -0
  236. package/src/lib/tool/gns3-import.test.ts +102 -0
  237. package/src/lib/tool/gns3-import.ts +198 -0
  238. package/src/lib/tool/gns3-layout.test.ts +62 -0
  239. package/src/lib/tool/gns3-parts.test.ts +47 -0
  240. package/src/lib/tool/gns3-parts.ts +68 -0
  241. package/src/lib/tool/issues.test.ts +35 -0
  242. package/src/lib/tool/issues.ts +60 -0
  243. package/src/lib/tool/layout.test.ts +128 -0
  244. package/src/lib/tool/layout.ts +229 -0
  245. package/src/lib/tool/live.test.ts +46 -0
  246. package/src/lib/tool/live.ts +49 -0
  247. package/src/lib/tool/logos.test.ts +30 -0
  248. package/src/lib/tool/logos.ts +99 -0
  249. package/src/lib/tool/model.test.ts +128 -0
  250. package/src/lib/tool/model.ts +238 -0
  251. package/src/lib/tool/os.test.ts +45 -0
  252. package/src/lib/tool/os.ts +57 -0
  253. package/src/lib/tool/parity.test.ts +145 -0
  254. package/src/lib/tool/pins.test.ts +24 -0
  255. package/src/lib/tool/pins.ts +11 -0
  256. package/src/lib/tool/placement.test.ts +79 -0
  257. package/src/lib/tool/placement.ts +60 -0
  258. package/src/lib/tool/ports.test.ts +39 -0
  259. package/src/lib/tool/ports.ts +48 -0
  260. package/src/lib/tool/presets.test.ts +50 -0
  261. package/src/lib/tool/presets.ts +77 -0
  262. package/src/lib/tool/projects-api.test.ts +59 -0
  263. package/src/lib/tool/projects-api.ts +47 -0
  264. package/src/lib/tool/router-ports.test.ts +63 -0
  265. package/src/lib/tool/router-ports.ts +88 -0
  266. package/src/lib/tool/selection.test.ts +44 -0
  267. package/src/lib/tool/selection.ts +34 -0
  268. package/src/lib/tool/storage.test.ts +123 -0
  269. package/src/lib/tool/storage.ts +127 -0
  270. package/src/lib/tool/switch-edit.test.ts +49 -0
  271. package/src/lib/tool/switch-edit.ts +38 -0
  272. package/src/lib/tool/text/de.ts +358 -0
  273. package/src/lib/tool/text/en.ts +371 -0
  274. package/src/lib/tool/text/es.ts +358 -0
  275. package/src/lib/tool/text/fr.ts +358 -0
  276. package/src/lib/tool/text/index.ts +21 -0
  277. package/src/lib/tool/text/text.test.ts +33 -0
  278. package/src/lib/tool/topology.test.ts +108 -0
  279. package/src/lib/tool/topology.ts +116 -0
  280. package/src/lib/tool/vlans.test.ts +64 -0
  281. package/src/lib/tool/vlans.ts +79 -0
  282. package/src/lib/utils.ts +6 -0
  283. package/src/model.ts +13 -0
  284. package/src/test/arbitrary.ts +41 -0
  285. package/src/test/engine.ts +48 -0
  286. package/src/test/lab.ts +101 -0
  287. package/styles/globals.css +258 -0
  288. package/styles/theme.css +192 -0
  289. package/tsconfig.build.json +12 -0
  290. package/tsconfig.json +22 -0
  291. package/wasm/isothrum_wasm.d.ts +60 -0
  292. package/wasm/isothrum_wasm.js +312 -0
  293. package/wasm/isothrum_wasm_bg.wasm +0 -0
@@ -0,0 +1,112 @@
1
+ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
2
+ import type { Draft } from "@/lib/tool/model";
3
+ import { saveProject as store } from "@/lib/tool/projects-api";
4
+ import { loadProject, saveProject as remember, type ProjectRef } from "@/lib/tool/storage";
5
+
6
+ /**
7
+ * Saved projects (isoloom/isothrum#31): the lab on the signed-in user's account. Once a lab is
8
+ * saved as a project, every change saves itself (a moment after the last one); each save keeps
9
+ * the previous version. The browser draft stays the working copy, signed in or not.
10
+ */
11
+
12
+ export type SaveState = "idle" | "saving" | "saved" | "error";
13
+
14
+ const AUTOSAVE_MS = 2500;
15
+
16
+ interface Deps {
17
+ signedIn: boolean;
18
+ /** The lab as it is now (its identity changes on every edit). */
19
+ draft: Draft;
20
+ /** The isoloom.yml the engine generated for it. */
21
+ yml: string | null;
22
+ }
23
+
24
+ /** The lab as last saved (its JSON; empty before the first save), to tell when it changed. */
25
+ function useLastSaved() {
26
+ const lastSaved = useRef("");
27
+ return useMemo(
28
+ () => ({
29
+ get: () => lastSaved.current,
30
+ set: (json: string) => {
31
+ lastSaved.current = json;
32
+ },
33
+ }),
34
+ [],
35
+ );
36
+ }
37
+
38
+ type LastSaved = ReturnType<typeof useLastSaved>;
39
+
40
+ /** Autosave: a moment after the last change, when the lab is a project. */
41
+ function useAutosave(enabled: boolean, draft: Draft, lastSaved: LastSaved, save: () => unknown): void {
42
+ useEffect(() => {
43
+ if (!enabled) return;
44
+ const now = JSON.stringify(draft);
45
+ if (now === lastSaved.get()) return;
46
+ if (!lastSaved.get()) {
47
+ // Just attached (a reload): what's here is what was saved, or newer; save it next change.
48
+ lastSaved.set(now);
49
+ return;
50
+ }
51
+ const timer = setTimeout(() => void save(), AUTOSAVE_MS);
52
+ return () => {
53
+ clearTimeout(timer);
54
+ };
55
+ }, [enabled, draft, lastSaved, save]);
56
+ }
57
+
58
+ /** The project the editor is attached to, and saving it. */
59
+ export function useProject({ signedIn, draft, yml }: Deps) {
60
+ const [project, setProject] = useState<ProjectRef | null>(null);
61
+ const [state, setState] = useState<SaveState>("idle");
62
+ useEffect(() => {
63
+ if (signedIn) queueMicrotask(() => { setProject(loadProject()); });
64
+ }, [signedIn]);
65
+ const attach = useCallback((p: ProjectRef | null) => {
66
+ setProject(p);
67
+ remember(p);
68
+ }, []);
69
+ // What a save sends: the lab as it is after the last render.
70
+ const latest = useRef({ draft, yml });
71
+ useEffect(() => {
72
+ latest.current = { draft, yml };
73
+ }, [draft, yml]);
74
+ const lastSaved = useLastSaved();
75
+ const save = useCallback(
76
+ async (asNew = false, name?: string): Promise<ProjectRef | null> => {
77
+ const now = latest.current;
78
+ setState("saving");
79
+ try {
80
+ const saved =
81
+ project && !asNew
82
+ ? await store(project.id, { draft: now.draft, isoloomYml: now.yml, ...(name ? { name } : {}) })
83
+ : await store(null, { name: name ?? project?.name ?? now.draft.name, draft: now.draft, isoloomYml: now.yml });
84
+ lastSaved.set(JSON.stringify(now.draft));
85
+ attach({ id: saved.id, name: saved.name });
86
+ setState("saved");
87
+ return saved;
88
+ } catch {
89
+ setState("error");
90
+ return null;
91
+ }
92
+ },
93
+ [project, attach, lastSaved],
94
+ );
95
+ useAutosave(signedIn && !!project, draft, lastSaved, save);
96
+ return {
97
+ project,
98
+ state,
99
+ save,
100
+ detach: () => {
101
+ attach(null);
102
+ lastSaved.set("");
103
+ setState("idle");
104
+ },
105
+ /** After opening a project: attached to it, nothing to save yet. */
106
+ opened: (p: ProjectRef, d: Draft) => {
107
+ lastSaved.set(JSON.stringify(d));
108
+ attach(p);
109
+ setState("saved");
110
+ },
111
+ };
112
+ }
@@ -0,0 +1,85 @@
1
+ import { useEffect, useEffectEvent, useMemo, useState } from "react";
2
+ import { normalizeDraft, plainDraft } from "@/lib/tool/draft";
3
+ import type { Design, Draft } from "@/lib/tool/model";
4
+ import type { Plan } from "@/lib/tool/engine";
5
+ import { projectFromLink } from "@/lib/tool/projects-api";
6
+ import type { ProjectRef } from "@/lib/tool/storage";
7
+ import type { EditorText } from "@/lib/tool/text";
8
+ import { useProject } from "./use-project";
9
+ import { clearHash } from "./use-shared-link";
10
+ import { replaceLab, type Lab } from "./use-lab";
11
+
12
+ interface Deps {
13
+ /** Accounts are on (the desktop's bundled editor has no site to ask). */
14
+ accounts: boolean;
15
+ lab: Lab;
16
+ design: Design;
17
+ setDesign: (d: Design) => void;
18
+ plan: Plan | null;
19
+ setToast: (message: string) => void;
20
+ fitWhenDrawn: () => void;
21
+ t: EditorText;
22
+ }
23
+
24
+ /** Saved projects, for a signed-in user (see `useProject`): opening, saving, Ctrl/⌘ S. */
25
+ export function useProjects({ accounts, lab, design, setDesign, plan, setToast, fitWhenDrawn, t }: Deps) {
26
+ const [signedInNow, setSignedInNow] = useState(false);
27
+ const signedIn = accounts && signedInNow;
28
+ const { labName, nodes, edges } = lab;
29
+ const savedDraft = useMemo(() => plainDraft({ name: labName, nodes, edges, design }), [labName, nodes, edges, design]);
30
+ const proj = useProject({ signedIn, draft: savedDraft, yml: plan?.ok ? plan.isoloom_yml : null });
31
+ const [projectsOpen, setProjectsOpen] = useState(false);
32
+ function openDraft(d: Draft) {
33
+ const opened = normalizeDraft(d);
34
+ if (!opened) return;
35
+ replaceLab(lab, opened);
36
+ if (opened.design) setDesign(opened.design);
37
+ fitWhenDrawn();
38
+ }
39
+ /** Opens a project, attached to it from now on. */
40
+ function openProject(p: ProjectRef, d: Draft) {
41
+ const opened = normalizeDraft(d);
42
+ if (!opened) return;
43
+ openDraft(opened);
44
+ proj.opened(p, plainDraft({ name: opened.name, nodes: opened.nodes, edges: opened.edges, design: opened.design ?? design }));
45
+ setToast(t.projects.opened(p.name));
46
+ }
47
+ useProjectLink(signedIn, openProject);
48
+ async function saveAs() {
49
+ const name = window.prompt(t.projects.saveAsPrompt, proj.project ? `${proj.project.name} (2)` : labName);
50
+ if (name?.trim()) await proj.save(true, name.trim());
51
+ }
52
+ useSaveKeys(signedIn, () => void proj.save(), () => void saveAs());
53
+ return { signedIn, setSignedInNow, proj, projectsOpen, setProjectsOpen, openDraft, openProject, saveAs };
54
+ }
55
+
56
+ /** A link to a project (#project=<id>, as the MCP server gives): opens it once signed in. */
57
+ function useProjectLink(signedIn: boolean, openProject: (p: ProjectRef, d: Draft) => void): void {
58
+ const open = useEffectEvent((id: string) => {
59
+ void projectFromLink(id).then((p) => {
60
+ if (p) openProject({ id: p.id, name: p.name }, p.draft as Draft);
61
+ });
62
+ });
63
+ useEffect(() => {
64
+ const m = /^#project=([\w-]+)$/.exec(window.location.hash);
65
+ if (!m || !signedIn) return;
66
+ clearHash();
67
+ open(m[1]);
68
+ }, [signedIn]);
69
+ }
70
+
71
+ /** Ctrl/⌘ S saves the project, Ctrl/⌘ Shift S saves it as a new one (signed in only). */
72
+ function useSaveKeys(signedIn: boolean, save: () => void, saveAs: () => void): void {
73
+ useEffect(() => {
74
+ function onKey(e: KeyboardEvent) {
75
+ if (!signedIn || !(e.metaKey || e.ctrlKey) || e.key.toLowerCase() !== "s") return;
76
+ e.preventDefault();
77
+ if (e.shiftKey) saveAs();
78
+ else save();
79
+ }
80
+ window.addEventListener("keydown", onKey);
81
+ return () => {
82
+ window.removeEventListener("keydown", onKey);
83
+ };
84
+ });
85
+ }
@@ -0,0 +1,64 @@
1
+ import { useEffect, useEffectEvent } from "react";
2
+ import { decodeLab, encodeLab } from "@/lib/tool/draft";
3
+ import type { Design } from "@/lib/tool/model";
4
+ import type { EditorText } from "@/lib/tool/text";
5
+ import { replaceLab, type Lab } from "./use-lab";
6
+
7
+ /** Removes the `#…` part of the address once it has been read. */
8
+ export function clearHash(): void {
9
+ window.history.replaceState(null, "", window.location.pathname + window.location.search);
10
+ }
11
+
12
+ interface Deps {
13
+ lab: Lab;
14
+ setDesign: (d: Design) => void;
15
+ setToast: (message: string) => void;
16
+ /** Fits the view once React Flow has measured the new lab. */
17
+ fitWhenDrawn: () => void;
18
+ t: EditorText;
19
+ }
20
+
21
+ /**
22
+ * A shared lab (#lab=… in the address) opens on arrival, and when a link is pasted into a tab
23
+ * already on the editor (only the part after # changes then, and the browser doesn't reload).
24
+ * Undo brings back what was there.
25
+ */
26
+ export function useSharedLink({ lab, setDesign, setToast, fitWhenDrawn, t }: Deps): void {
27
+ const open = useEffectEvent(() => {
28
+ const m = /^#lab=([A-Za-z0-9_-]+)$/.exec(window.location.hash);
29
+ if (!m) return;
30
+ void decodeLab(m[1]).then((shared) => {
31
+ clearHash();
32
+ if (!shared) {
33
+ setToast(t.toasts.brokenLink);
34
+ return;
35
+ }
36
+ replaceLab(lab, shared);
37
+ if (shared.design) setDesign(shared.design);
38
+ setToast(t.toasts.openedShared(shared.name));
39
+ fitWhenDrawn();
40
+ });
41
+ });
42
+ useEffect(() => {
43
+ const onHash = () => {
44
+ open();
45
+ };
46
+ onHash();
47
+ window.addEventListener("hashchange", onHash);
48
+ return () => {
49
+ window.removeEventListener("hashchange", onHash);
50
+ };
51
+ }, []);
52
+ }
53
+
54
+ /** Copies a link that opens this lab (its JSON, deflated, after `#lab=`). */
55
+ export async function copyShareLink(draft: Parameters<typeof encodeLab>[0], lang: string, setToast: (message: string) => void, t: EditorText) {
56
+ const code = await encodeLab(draft);
57
+ const url = `${window.location.origin}/${lang}#lab=${code}`;
58
+ try {
59
+ await navigator.clipboard.writeText(url);
60
+ setToast(t.toasts.linkCopied);
61
+ } catch {
62
+ window.prompt(t.toasts.copyPrompt, url);
63
+ }
64
+ }
@@ -0,0 +1,16 @@
1
+ import { useEffect, useState } from "react";
2
+
3
+ /** A message at the bottom of the canvas, for a few seconds (longer when it is long). */
4
+ export function useToast(): [string | null, (message: string | null) => void] {
5
+ const [toast, setToast] = useState<string | null>(null);
6
+ useEffect(() => {
7
+ if (!toast) return;
8
+ const timer = setTimeout(() => {
9
+ setToast(null);
10
+ }, Math.max(4000, toast.length * 60));
11
+ return () => {
12
+ clearTimeout(timer);
13
+ };
14
+ }, [toast]);
15
+ return [toast, setToast];
16
+ }
@@ -0,0 +1,60 @@
1
+ import { useEffect, useState } from "react";
2
+ import { LAN_TOOL, NOTE_TOOL, SWITCH_TOOL } from "@/lib/tool/blocks";
3
+ import { presetForKey } from "@/lib/tool/presets";
4
+ import { inTextField } from "./use-history";
5
+
6
+ /** Excalidraw's two modes: select (drag a box, Shift-click, Delete) or hand (drag moves the canvas). */
7
+ export type Mode = "select" | "hand";
8
+
9
+ /**
10
+ * The device being placed (Excalidraw-style tool): the next click on the canvas drops it there.
11
+ * Picking a tool selects again; picking the same one twice puts it down.
12
+ */
13
+ export function useTools() {
14
+ const [activeTool, setActiveTool] = useState<string | null>(null);
15
+ const [mode, setMode] = useState<Mode>("select");
16
+ const pick = (tool: string) => {
17
+ setMode("select");
18
+ setActiveTool(activeTool === tool ? null : tool);
19
+ };
20
+ const setModeOnly = (m: Mode) => {
21
+ setMode(m);
22
+ setActiveTool(null);
23
+ };
24
+ return { activeTool, setActiveTool, mode, setMode, pick, setModeOnly };
25
+ }
26
+
27
+ export type Tools = ReturnType<typeof useTools>;
28
+
29
+ /** The tool a key toggles: S a switch, L a LAN, T a note, 1–9 and 0 a device. */
30
+ function toolForKey(key: string): string | undefined {
31
+ const lower = key.toLowerCase();
32
+ if (lower === "s" && key.length === 1) return SWITCH_TOOL;
33
+ if (lower === "l" && key.length === 1) return LAN_TOOL;
34
+ if (lower === "t" && key.length === 1) return NOTE_TOOL;
35
+ return presetForKey(key)?.id;
36
+ }
37
+
38
+ /** Keyboard: 1–9 and 0 pick a device, S, L, T a switch, LAN or note, V selects, H the hand, Escape puts it down. Not while typing in a field. */
39
+ export function useToolKeys(tools: Tools): void {
40
+ const { setMode, setActiveTool } = tools;
41
+ useEffect(() => {
42
+ function onKey(e: KeyboardEvent) {
43
+ if (e.metaKey || e.ctrlKey || e.altKey || inTextField(e)) return;
44
+ if (e.key === "Escape") setActiveTool(null);
45
+ if (e.key === "v" || e.key === "V" || e.key === "h" || e.key === "H") {
46
+ setMode(e.key === "h" || e.key === "H" ? "hand" : "select");
47
+ setActiveTool(null);
48
+ }
49
+ const tool = toolForKey(e.key);
50
+ if (tool) {
51
+ setMode("select");
52
+ setActiveTool((cur) => (cur === tool ? null : tool));
53
+ }
54
+ }
55
+ window.addEventListener("keydown", onKey);
56
+ return () => {
57
+ window.removeEventListener("keydown", onKey);
58
+ };
59
+ }, [setMode, setActiveTool]);
60
+ }
@@ -0,0 +1,96 @@
1
+ "use client";
2
+
3
+ import { useRef } from "react";
4
+ import { ReactFlowProvider } from "@xyflow/react";
5
+ import { editorText } from "@/lib/tool/text";
6
+ import { AuthClientProvider, type AuthClient } from "@/lib/auth";
7
+ import { BrandMarkProvider, type BrandMark } from "@/lib/brand";
8
+ import { cn } from "@/lib/utils";
9
+ import { RunningLabMenus } from "./context-menus";
10
+ import { SessionWatch } from "./editor-menu";
11
+ import { FlowCanvas } from "./flow-canvas";
12
+ import { useEditor, type EditorModel } from "./hooks/use-editor";
13
+ import { Overlays } from "./overlays";
14
+ import { EditorTextContext } from "./text";
15
+ import { Toolbar } from "./toolbar";
16
+ import { TopLeft, TopRight } from "./top-bars";
17
+ import { EditorWindows } from "./windows/editor-windows";
18
+
19
+ /** The hidden file input a GNS3 project is picked with (the menu opens it). */
20
+ function Gns3Input({ e, input }: { e: EditorModel; input: React.RefObject<HTMLInputElement | null> }) {
21
+ return (
22
+ <input
23
+ ref={input}
24
+ type="file"
25
+ accept=".gns3,application/json"
26
+ hidden
27
+ aria-label={e.t.menu.gns3File}
28
+ onChange={(ev) => {
29
+ const file = ev.target.files?.[0];
30
+ if (file) void e.actions.importGns3(file);
31
+ ev.target.value = "";
32
+ }}
33
+ />
34
+ );
35
+ }
36
+
37
+ function Editor({ lang, accounts }: { lang: string; accounts: boolean }) {
38
+ const canvasRef = useRef<HTMLDivElement>(null);
39
+ const gns3Input = useRef<HTMLInputElement>(null);
40
+ const e = useEditor(lang, accounts, canvasRef);
41
+ const { tools } = e;
42
+ return (
43
+ <div
44
+ ref={canvasRef}
45
+ className={cn(
46
+ "fixed inset-0 bg-background",
47
+ e.tool && "[&_.react-flow__pane]:!cursor-crosshair",
48
+ tools.mode === "hand" && "[&_.react-flow__pane]:!cursor-grab [&_.react-flow__pane.dragging]:!cursor-grabbing",
49
+ )}
50
+ onDragOver={(ev) => {
51
+ ev.preventDefault();
52
+ }}
53
+ onDrop={e.placement.onDrop}
54
+ data-design={e.design.design}
55
+ >
56
+ <Gns3Input e={e} input={gns3Input} />
57
+ {accounts && <SessionWatch onChange={e.projects.setSignedInNow} />}
58
+ <FlowCanvas e={e} />
59
+ <TopLeft e={e} onImportGns3={() => gns3Input.current?.click()} />
60
+ <Toolbar e={e} />
61
+ <TopRight e={e} />
62
+ <Overlays e={e} />
63
+ <RunningLabMenus e={e} />
64
+ <EditorWindows e={e} />
65
+ </div>
66
+ );
67
+ }
68
+
69
+ /** The props of the lab editor. */
70
+ export interface LabCanvasProps {
71
+ /** The editor's language (one of the editor's locales); anything else falls back to English. */
72
+ lang: string;
73
+ /**
74
+ * Whether the accounts UI (sign in, save to projects) is shown. When `true`, pass `auth`.
75
+ * Defaults to `false`, for hosts without accounts (the desktop app, a standalone embed).
76
+ */
77
+ accounts?: boolean;
78
+ /** The host's auth client, required when `accounts` is `true`; ignored otherwise. */
79
+ auth?: AuthClient | null;
80
+ /** A product mark for the empty canvas; defaults to the Isothrum mark. */
81
+ brandMark?: BrandMark;
82
+ }
83
+
84
+ /** The lab editor: the editor's words in the page's language, for every part of it (windows, canvas, export). */
85
+ export function LabCanvas({ lang, accounts = false, auth = null, brandMark }: LabCanvasProps) {
86
+ const text = editorText(lang);
87
+ const inner = (
88
+ <EditorTextContext.Provider value={text}>
89
+ <ReactFlowProvider>
90
+ <Editor lang={lang} accounts={accounts} />
91
+ </ReactFlowProvider>
92
+ </EditorTextContext.Provider>
93
+ );
94
+ const withAuth = <AuthClientProvider client={auth}>{inner}</AuthClientProvider>;
95
+ return brandMark ? <BrandMarkProvider mark={brandMark}>{withAuth}</BrandMarkProvider> : withAuth;
96
+ }
@@ -0,0 +1,104 @@
1
+ "use client";
2
+
3
+ import { LayoutTemplate, X } from "lucide-react";
4
+ import { useBrandMark } from "@/lib/brand";
5
+ import { Button, buttonVariants } from "@/components/ui/button";
6
+ import type { EditorModel } from "./hooks/use-editor";
7
+ import { useText } from "./text";
8
+
9
+ /** Handwritten hints, pointing at the menu and the toolbar. */
10
+ function WelcomeHints() {
11
+ const t = useText().welcome;
12
+ return (
13
+ <>
14
+ <div aria-hidden className="pointer-events-none absolute top-14 left-6 z-0 hidden items-start gap-1 font-heading text-sm text-muted-foreground md:flex">
15
+ <svg viewBox="0 0 40 40" className="size-9 -scale-x-100" fill="none">
16
+ <path d="M34 36C30 22 22 12 8 6m0 0 2 8M8 6l8 0" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" />
17
+ </svg>
18
+ <span className="mt-6">{t.menuHint}</span>
19
+ </div>
20
+ <div aria-hidden className="pointer-events-none absolute top-24 left-1/2 z-0 hidden -translate-x-[calc(100%+40px)] items-start gap-1 font-heading text-sm text-muted-foreground md:flex">
21
+ <span className="mt-6">{t.toolbarHint}</span>
22
+ <svg viewBox="0 0 40 40" className="size-9" fill="none">
23
+ <path d="M6 36C12 24 22 14 34 8m0 0-8 0m8 0-2 8" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" />
24
+ </svg>
25
+ </div>
26
+ </>
27
+ );
28
+ }
29
+
30
+ /** The empty canvas: who we are, where to start, and handwritten pointers to the controls. */
31
+ function Welcome({ onExample, lang }: { onExample: () => void; lang: string }) {
32
+ const t = useText().welcome;
33
+ const LogoMark = useBrandMark();
34
+ return (
35
+ <>
36
+ <WelcomeHints />
37
+ <div className="pointer-events-none absolute inset-0 z-0 grid place-items-center p-6">
38
+ {/* Only the buttons take clicks: a drop or a click elsewhere goes to the canvas below. */}
39
+ <div className="flex max-w-md flex-col items-center text-center">
40
+ <LogoMark className="size-14" />
41
+ <p className="mt-4 font-heading text-4xl font-semibold lowercase">isothrum</p>
42
+ <p className="mt-3 text-muted-foreground text-pretty">{t.pitch}</p>
43
+ <div className="pointer-events-auto mt-6 flex flex-wrap justify-center gap-2">
44
+ <Button onClick={onExample}>
45
+ <LayoutTemplate data-icon="inline-start" /> {t.example}
46
+ </Button>
47
+ <a href={`/${lang}/about`} className={buttonVariants({ variant: "outline" })}>
48
+ {t.how}
49
+ </a>
50
+ </div>
51
+ </div>
52
+ </div>
53
+ </>
54
+ );
55
+ }
56
+
57
+ /** Desktop app: a newer release is out; restarting installs it. */
58
+ function UpdateBanner({ e, version }: { e: EditorModel; version: string }) {
59
+ const t = e.t.update;
60
+ const { invoke, updating, setUpdating, setUpdate } = e.desk.state;
61
+ async function install() {
62
+ if (!invoke) return;
63
+ setUpdating(true);
64
+ try {
65
+ await invoke("update_install");
66
+ } catch (err) {
67
+ e.setToast(String(err));
68
+ setUpdating(false);
69
+ }
70
+ }
71
+ return (
72
+ <div role="status" className="absolute right-4 bottom-4 z-30 flex items-center gap-3 rounded-lg border bg-popover px-3 py-2 text-sm shadow-sm">
73
+ <span>{t.available(version)}</span>
74
+ <Button size="sm" disabled={updating} onClick={() => void install()}>
75
+ {updating ? t.installing : t.restart}
76
+ </Button>
77
+ <button type="button" className="text-muted-foreground hover:text-foreground" aria-label={t.later} title={t.later} onClick={() => { setUpdate(null); }}>
78
+ <X className="size-4" />
79
+ </button>
80
+ </div>
81
+ );
82
+ }
83
+
84
+ /** What floats over the canvas's bottom: the welcome, an update, a toast, an engine that didn't load. */
85
+ export function Overlays({ e }: { e: EditorModel }) {
86
+ const { update } = e.desk.state;
87
+ return (
88
+ <>
89
+ {/* The empty canvas welcomes, as Excalidraw does. Hidden while a device is picked, so the whole canvas is free to click. */}
90
+ {e.empty && !e.tools.activeTool && <Welcome onExample={() => { e.actions.loadExample(); }} lang={e.lang} />}
91
+ {update && <UpdateBanner e={e} version={update} />}
92
+ {e.toast && (
93
+ <p role="status" className="absolute bottom-4 left-1/2 z-30 w-max max-w-[min(36rem,calc(100vw-2rem))] -translate-x-1/2 rounded-lg border bg-popover px-3 py-2 text-sm shadow-sm">
94
+ {e.toast}
95
+ </p>
96
+ )}
97
+ {e.engineError && (
98
+ <p role="alert" className="absolute bottom-4 left-1/2 z-10 -translate-x-1/2 rounded-lg border bg-popover px-3 py-2 text-sm text-destructive shadow-sm">
99
+ {e.t.engineDidntLoad(e.engineError)}
100
+ </p>
101
+ )}
102
+ </>
103
+ );
104
+ }
@@ -0,0 +1,96 @@
1
+ "use client";
2
+
3
+ import { useEffect, useState } from "react";
4
+ import { FolderOpen, History, Trash2 } from "lucide-react";
5
+ import type { Draft } from "@/lib/tool/model";
6
+ import { deleteProject, getProject, getVersion, listProjects, listVersions, type ProjectSummary, type ProjectVersion } from "@/lib/tool/projects-api";
7
+ import type { ProjectRef } from "@/lib/tool/storage";
8
+ import { Button } from "@/components/ui/button";
9
+ import { useText } from "./text";
10
+
11
+ const when = (iso: string) => new Date(iso).toLocaleString(undefined, { dateStyle: "medium", timeStyle: "short" });
12
+
13
+ /** A project: its name (and whether it is the one open), when it was saved, opening and deleting it. */
14
+ function ProjectRow({ p, current, onOpen, onDelete }: { p: ProjectSummary; current: ProjectRef | null; onOpen: () => void; onDelete: () => void }) {
15
+ const t = useText().projects;
16
+ return (
17
+ <li className="flex items-center gap-2 px-3 py-2">
18
+ <div className="min-w-0 flex-1">
19
+ <p className="truncate font-medium">
20
+ {p.name}
21
+ {current?.id === p.id && <span className="ml-1.5 text-xs font-normal text-signal">{t.open}</span>}
22
+ </p>
23
+ <p className="text-xs text-muted-foreground">{when(p.updatedAt)}</p>
24
+ </div>
25
+ <Button size="sm" variant="outline" onClick={onOpen}>
26
+ <FolderOpen data-icon="inline-start" /> {t.openButton}
27
+ </Button>
28
+ <Button size="icon-sm" variant="ghost" aria-label={t.delete(p.name)} title={t.delete(p.name)} onClick={onDelete}>
29
+ <Trash2 />
30
+ </Button>
31
+ </li>
32
+ );
33
+ }
34
+
35
+ /** The open project's earlier versions: listing them, and opening one. */
36
+ function VersionHistory({ current, versions, setVersions, onOpenVersion }: { current: ProjectRef; versions: ProjectVersion[] | null; setVersions: (v: ProjectVersion[]) => void; onOpenVersion: (draft: Draft) => void }) {
37
+ const t = useText().projects;
38
+ return (
39
+ <div className="flex flex-col gap-2">
40
+ <Button size="sm" variant="ghost" className="self-start" onClick={() => void listVersions(current.id).then(setVersions)}>
41
+ <History data-icon="inline-start" /> {t.history(current.name)}
42
+ </Button>
43
+ {versions &&
44
+ (versions.length === 0 ? (
45
+ <p className="text-xs text-muted-foreground">{t.noVersions}</p>
46
+ ) : (
47
+ <ul className="flex flex-col gap-1">
48
+ {versions.map((v) => (
49
+ <li key={v.id} className="flex items-center justify-between gap-2 text-xs">
50
+ <span className="text-muted-foreground">{when(v.savedAt)}</span>
51
+ <Button size="xs" variant="outline" onClick={() => void getVersion(current.id, v.id).then((r) => { onOpenVersion(r.draft); })}>
52
+ {t.restore}
53
+ </Button>
54
+ </li>
55
+ ))}
56
+ </ul>
57
+ ))}
58
+ <p className="text-xs text-muted-foreground">{t.historyHint}</p>
59
+ </div>
60
+ );
61
+ }
62
+
63
+ /** The user's projects, to open or delete one; and the open project's earlier versions. */
64
+ export function ProjectsList({ current, onOpen, onOpenVersion, onDeleted }: { current: ProjectRef | null; onOpen: (p: ProjectRef, draft: Draft) => void; onOpenVersion: (draft: Draft) => void; onDeleted: (id: string) => void }) {
65
+ const t = useText().projects;
66
+ const [list, setList] = useState<ProjectSummary[] | null>(null);
67
+ const [versions, setVersions] = useState<ProjectVersion[] | null>(null);
68
+ const [error, setError] = useState<string | null>(null);
69
+ useEffect(() => {
70
+ listProjects().then(setList, (e: unknown) => {
71
+ setError((e as Error).message);
72
+ });
73
+ }, []);
74
+ async function remove(p: ProjectSummary) {
75
+ if (!window.confirm(t.confirmDelete(p.name))) return;
76
+ await deleteProject(p.id);
77
+ setList((l) => l?.filter((x) => x.id !== p.id) ?? null);
78
+ onDeleted(p.id);
79
+ }
80
+ if (error) return <p className="text-sm text-destructive">{t.failed(error)}</p>;
81
+ if (!list) return <p className="text-sm text-muted-foreground">{t.loading}</p>;
82
+ return (
83
+ <div className="flex flex-col gap-4 text-sm">
84
+ {list.length === 0 ? (
85
+ <p className="text-muted-foreground">{t.none}</p>
86
+ ) : (
87
+ <ul className="flex flex-col divide-y rounded-lg border">
88
+ {list.map((p) => (
89
+ <ProjectRow key={p.id} p={p} current={current} onOpen={() => void getProject(p.id).then((full) => { onOpen({ id: full.id, name: full.name }, full.draft); })} onDelete={() => void remove(p)} />
90
+ ))}
91
+ </ul>
92
+ )}
93
+ {current && <VersionHistory current={current} versions={versions} setVersions={setVersions} onOpenVersion={onOpenVersion} />}
94
+ </div>
95
+ );
96
+ }
@@ -0,0 +1,9 @@
1
+ import { createContext, useContext } from "react";
2
+ import { defaultText, type EditorText } from "@/lib/tool/text";
3
+
4
+ /** The editor's words, set once by the editor from the page's language; English outside it. */
5
+ export const EditorTextContext = createContext<EditorText>(defaultText);
6
+
7
+ export function useText(): EditorText {
8
+ return useContext(EditorTextContext);
9
+ }