@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.
- package/LICENSE +202 -0
- package/NOTICE +8 -0
- package/PUBLISHING.md +93 -0
- package/README.md +106 -2
- package/desktop-editor/editor.css +11 -0
- package/desktop-editor/index.html +14 -0
- package/desktop-editor/main.tsx +26 -0
- package/dist/desktop/editor.css +1 -0
- package/dist/desktop/editor.js +181 -0
- package/dist/desktop/fonts/geist-cyrillic-ext-wght-normal-RO72NYA2.woff2 +0 -0
- package/dist/desktop/fonts/geist-cyrillic-wght-normal-CK2XY27K.woff2 +0 -0
- package/dist/desktop/fonts/geist-latin-ext-wght-normal-XEQ56JUF.woff2 +0 -0
- package/dist/desktop/fonts/geist-latin-wght-normal-T72VVCUW.woff2 +0 -0
- package/dist/desktop/fonts/geist-mono-cyrillic-ext-wght-normal-QXKVIKFD.woff2 +0 -0
- package/dist/desktop/fonts/geist-mono-cyrillic-wght-normal-GOIAQAGU.woff2 +0 -0
- package/dist/desktop/fonts/geist-mono-latin-ext-wght-normal-HU3ZAWN6.woff2 +0 -0
- package/dist/desktop/fonts/geist-mono-latin-wght-normal-CJNQKXUG.woff2 +0 -0
- package/dist/desktop/fonts/geist-mono-symbols2-wght-normal-WZJJVLQO.woff2 +0 -0
- package/dist/desktop/fonts/geist-mono-vietnamese-wght-normal-KW5NM6YV.woff2 +0 -0
- package/dist/desktop/fonts/geist-vietnamese-wght-normal-Y2R7JZKV.woff2 +0 -0
- package/dist/desktop/fonts/shantell-sans-cyrillic-ext-wght-normal-ILPQWRJJ.woff2 +0 -0
- package/dist/desktop/fonts/shantell-sans-cyrillic-wght-normal-RKC6NDPM.woff2 +0 -0
- package/dist/desktop/fonts/shantell-sans-latin-ext-wght-normal-SNKHG3LM.woff2 +0 -0
- package/dist/desktop/fonts/shantell-sans-latin-wght-normal-7CHHGC3N.woff2 +0 -0
- package/dist/desktop/fonts/shantell-sans-vietnamese-wght-normal-IPFAQSBA.woff2 +0 -0
- package/dist/desktop/index.html +14 -0
- package/dist/desktop/styles.css +2 -0
- package/dist/desktop/wasm/isothrum_wasm.d.ts +60 -0
- package/dist/desktop/wasm/isothrum_wasm.js +312 -0
- package/dist/desktop/wasm/isothrum_wasm_bg.wasm +0 -0
- package/dist/index.js +7059 -0
- package/dist/index.js.map +7 -0
- package/dist/model.js +745 -0
- package/dist/model.js.map +7 -0
- package/dist/types/components/tool/canvas/cable-edge.d.ts +4 -0
- package/dist/types/components/tool/canvas/contexts.d.ts +12 -0
- package/dist/types/components/tool/canvas/device-icon.d.ts +17 -0
- package/dist/types/components/tool/canvas/inline-text.d.ts +15 -0
- package/dist/types/components/tool/canvas/lan-node.d.ts +8 -0
- package/dist/types/components/tool/canvas/machine-addresses.d.ts +14 -0
- package/dist/types/components/tool/canvas/machine-node.d.ts +4 -0
- package/dist/types/components/tool/canvas/note-node.d.ts +7 -0
- package/dist/types/components/tool/canvas/port-led.d.ts +10 -0
- package/dist/types/components/tool/canvas/router-node.d.ts +8 -0
- package/dist/types/components/tool/canvas/sketch.d.ts +50 -0
- package/dist/types/components/tool/canvas/status-dot.d.ts +5 -0
- package/dist/types/components/tool/canvas/switch-node.d.ts +8 -0
- package/dist/types/components/tool/canvas/use-measured-ports.d.ts +7 -0
- package/dist/types/components/tool/context-menus.d.ts +5 -0
- package/dist/types/components/tool/device-block.d.ts +12 -0
- package/dist/types/components/tool/editor-menu.d.ts +28 -0
- package/dist/types/components/tool/export/copy-button.d.ts +9 -0
- package/dist/types/components/tool/export/export-panel.d.ts +17 -0
- package/dist/types/components/tool/export/target-list.d.ts +15 -0
- package/dist/types/components/tool/floating-window.d.ts +13 -0
- package/dist/types/components/tool/flow-canvas.d.ts +6 -0
- package/dist/types/components/tool/hooks/use-cabling.d.ts +23 -0
- package/dist/types/components/tool/hooks/use-captures.d.ts +9 -0
- package/dist/types/components/tool/hooks/use-clipboard.d.ts +12 -0
- package/dist/types/components/tool/hooks/use-context-menus.d.ts +25 -0
- package/dist/types/components/tool/hooks/use-design.d.ts +10 -0
- package/dist/types/components/tool/hooks/use-desktop.d.ts +50 -0
- package/dist/types/components/tool/hooks/use-devices.d.ts +17 -0
- package/dist/types/components/tool/hooks/use-downloads.d.ts +9 -0
- package/dist/types/components/tool/hooks/use-editor.d.ts +609 -0
- package/dist/types/components/tool/hooks/use-engine.d.ts +9 -0
- package/dist/types/components/tool/hooks/use-history.d.ts +9 -0
- package/dist/types/components/tool/hooks/use-lab-actions.d.ts +25 -0
- package/dist/types/components/tool/hooks/use-lab-run.d.ts +25 -0
- package/dist/types/components/tool/hooks/use-lab.d.ts +18 -0
- package/dist/types/components/tool/hooks/use-lan-carry.d.ts +8 -0
- package/dist/types/components/tool/hooks/use-machine-actions.d.ts +26 -0
- package/dist/types/components/tool/hooks/use-placement.d.ts +27 -0
- package/dist/types/components/tool/hooks/use-project.d.ts +25 -0
- package/dist/types/components/tool/hooks/use-projects.d.ts +34 -0
- package/dist/types/components/tool/hooks/use-shared-link.d.ts +23 -0
- package/dist/types/components/tool/hooks/use-toast.d.ts +2 -0
- package/dist/types/components/tool/hooks/use-tools.d.ts +17 -0
- package/dist/types/components/tool/lab-canvas.d.ts +18 -0
- package/dist/types/components/tool/overlays.d.ts +5 -0
- package/dist/types/components/tool/projects-list.d.ts +9 -0
- package/dist/types/components/tool/text.d.ts +418 -0
- package/dist/types/components/tool/toolbar.d.ts +5 -0
- package/dist/types/components/tool/top-bars.d.ts +10 -0
- package/dist/types/components/tool/windows/conditions-fields.d.ts +6 -0
- package/dist/types/components/tool/windows/editor-windows.d.ts +5 -0
- package/dist/types/components/tool/windows/fields.d.ts +4 -0
- package/dist/types/components/tool/windows/lab-status.d.ts +6 -0
- package/dist/types/components/tool/windows/lab-windows.d.ts +13 -0
- package/dist/types/components/tool/windows/link-controls.d.ts +21 -0
- package/dist/types/components/tool/windows/machine-controls.d.ts +16 -0
- package/dist/types/components/tool/windows/machine-inspector.d.ts +11 -0
- package/dist/types/components/tool/windows/runs-fields.d.ts +18 -0
- package/dist/types/components/tool/windows/selected-windows.d.ts +21 -0
- package/dist/types/components/ui/button.d.ts +8 -0
- package/dist/types/components/ui/dropdown-menu.d.ts +21 -0
- package/dist/types/index.d.ts +7 -0
- package/dist/types/lib/auth.d.ts +38 -0
- package/dist/types/lib/brand.d.ts +19 -0
- package/dist/types/lib/i18n.d.ts +19 -0
- package/dist/types/lib/tool/blocks.d.ts +35 -0
- package/dist/types/lib/tool/cables.d.ts +48 -0
- package/dist/types/lib/tool/clone.d.ts +12 -0
- package/dist/types/lib/tool/conditions.d.ts +31 -0
- package/dist/types/lib/tool/create.d.ts +22 -0
- package/dist/types/lib/tool/desktop.d.ts +63 -0
- package/dist/types/lib/tool/draft.d.ts +20 -0
- package/dist/types/lib/tool/engine.d.ts +33 -0
- package/dist/types/lib/tool/examples.d.ts +11 -0
- package/dist/types/lib/tool/export-targets.d.ts +21 -0
- package/dist/types/lib/tool/facing.d.ts +9 -0
- package/dist/types/lib/tool/geometry.d.ts +36 -0
- package/dist/types/lib/tool/gns3-cables.d.ts +17 -0
- package/dist/types/lib/tool/gns3-import.d.ts +15 -0
- package/dist/types/lib/tool/gns3-parts.d.ts +25 -0
- package/dist/types/lib/tool/issues.d.ts +12 -0
- package/dist/types/lib/tool/layout.d.ts +52 -0
- package/dist/types/lib/tool/live.d.ts +21 -0
- package/dist/types/lib/tool/logos.d.ts +7 -0
- package/dist/types/lib/tool/model.d.ts +173 -0
- package/dist/types/lib/tool/os.d.ts +20 -0
- package/dist/types/lib/tool/pins.d.ts +5 -0
- package/dist/types/lib/tool/placement.d.ts +21 -0
- package/dist/types/lib/tool/ports.d.ts +21 -0
- package/dist/types/lib/tool/presets.d.ts +21 -0
- package/dist/types/lib/tool/projects-api.d.ts +37 -0
- package/dist/types/lib/tool/router-ports.d.ts +22 -0
- package/dist/types/lib/tool/selection.d.ts +19 -0
- package/dist/types/lib/tool/storage.d.ts +25 -0
- package/dist/types/lib/tool/switch-edit.d.ts +17 -0
- package/dist/types/lib/tool/text/de.d.ts +2 -0
- package/dist/types/lib/tool/text/en.d.ts +429 -0
- package/dist/types/lib/tool/text/es.d.ts +2 -0
- package/dist/types/lib/tool/text/fr.d.ts +2 -0
- package/dist/types/lib/tool/text/index.d.ts +419 -0
- package/dist/types/lib/tool/topology.d.ts +2 -0
- package/dist/types/lib/tool/vlans.d.ts +24 -0
- package/dist/types/lib/utils.d.ts +2 -0
- package/dist/types/model.d.ts +13 -0
- package/package.json +88 -4
- package/scripts/build-desktop-editor.mjs +43 -0
- package/scripts/build-lib.mjs +38 -0
- package/src/components/tool/canvas/cable-edge.tsx +112 -0
- package/src/components/tool/canvas/contexts.ts +18 -0
- package/src/components/tool/canvas/device-icon.tsx +105 -0
- package/src/components/tool/canvas/inline-text.tsx +69 -0
- package/src/components/tool/canvas/lan-node.tsx +148 -0
- package/src/components/tool/canvas/machine-addresses.tsx +117 -0
- package/src/components/tool/canvas/machine-node.tsx +114 -0
- package/src/components/tool/canvas/note-node.tsx +109 -0
- package/src/components/tool/canvas/port-led.tsx +21 -0
- package/src/components/tool/canvas/router-node.tsx +114 -0
- package/src/components/tool/canvas/sketch.tsx +130 -0
- package/src/components/tool/canvas/status-dot.tsx +29 -0
- package/src/components/tool/canvas/switch-node.tsx +143 -0
- package/src/components/tool/canvas/use-measured-ports.ts +25 -0
- package/src/components/tool/context-menus.tsx +142 -0
- package/src/components/tool/device-block.tsx +107 -0
- package/src/components/tool/editor-menu.tsx +191 -0
- package/src/components/tool/export/copy-button.tsx +45 -0
- package/src/components/tool/export/export-panel.tsx +127 -0
- package/src/components/tool/export/target-list.tsx +87 -0
- package/src/components/tool/floating-window.tsx +69 -0
- package/src/components/tool/flow-canvas.tsx +138 -0
- package/src/components/tool/hooks/use-cabling.ts +66 -0
- package/src/components/tool/hooks/use-captures.ts +24 -0
- package/src/components/tool/hooks/use-clipboard.ts +54 -0
- package/src/components/tool/hooks/use-context-menus.ts +37 -0
- package/src/components/tool/hooks/use-design.ts +24 -0
- package/src/components/tool/hooks/use-desktop.ts +100 -0
- package/src/components/tool/hooks/use-devices.ts +58 -0
- package/src/components/tool/hooks/use-downloads.ts +42 -0
- package/src/components/tool/hooks/use-editor.ts +114 -0
- package/src/components/tool/hooks/use-engine.ts +29 -0
- package/src/components/tool/hooks/use-history.ts +107 -0
- package/src/components/tool/hooks/use-lab-actions.ts +57 -0
- package/src/components/tool/hooks/use-lab-run.ts +61 -0
- package/src/components/tool/hooks/use-lab.ts +32 -0
- package/src/components/tool/hooks/use-lan-carry.ts +43 -0
- package/src/components/tool/hooks/use-machine-actions.ts +57 -0
- package/src/components/tool/hooks/use-placement.ts +80 -0
- package/src/components/tool/hooks/use-project.ts +112 -0
- package/src/components/tool/hooks/use-projects.ts +85 -0
- package/src/components/tool/hooks/use-shared-link.ts +64 -0
- package/src/components/tool/hooks/use-toast.ts +16 -0
- package/src/components/tool/hooks/use-tools.ts +60 -0
- package/src/components/tool/lab-canvas.tsx +96 -0
- package/src/components/tool/overlays.tsx +104 -0
- package/src/components/tool/projects-list.tsx +96 -0
- package/src/components/tool/text.ts +9 -0
- package/src/components/tool/toolbar.tsx +106 -0
- package/src/components/tool/top-bars.tsx +90 -0
- package/src/components/tool/windows/conditions-fields.tsx +43 -0
- package/src/components/tool/windows/editor-windows.tsx +73 -0
- package/src/components/tool/windows/fields.ts +6 -0
- package/src/components/tool/windows/lab-status.tsx +68 -0
- package/src/components/tool/windows/lab-windows.tsx +130 -0
- package/src/components/tool/windows/link-controls.tsx +69 -0
- package/src/components/tool/windows/machine-controls.tsx +116 -0
- package/src/components/tool/windows/machine-inspector.tsx +83 -0
- package/src/components/tool/windows/runs-fields.tsx +127 -0
- package/src/components/tool/windows/selected-windows.tsx +147 -0
- package/src/components/ui/button.tsx +59 -0
- package/src/components/ui/dropdown-menu.tsx +185 -0
- package/src/index.ts +21 -0
- package/src/lib/auth.tsx +43 -0
- package/src/lib/brand.tsx +46 -0
- package/src/lib/i18n.ts +53 -0
- package/src/lib/tool/blocks.test.ts +64 -0
- package/src/lib/tool/blocks.ts +78 -0
- package/src/lib/tool/cable-wiring.test.ts +74 -0
- package/src/lib/tool/cables.test.ts +75 -0
- package/src/lib/tool/cables.ts +215 -0
- package/src/lib/tool/clone.test.ts +54 -0
- package/src/lib/tool/clone.ts +48 -0
- package/src/lib/tool/conditions.test.ts +170 -0
- package/src/lib/tool/conditions.ts +132 -0
- package/src/lib/tool/create.test.ts +67 -0
- package/src/lib/tool/create.ts +83 -0
- package/src/lib/tool/desktop.test.ts +59 -0
- package/src/lib/tool/desktop.ts +99 -0
- package/src/lib/tool/draft.test.ts +150 -0
- package/src/lib/tool/draft.ts +109 -0
- package/src/lib/tool/engine.test.ts +39 -0
- package/src/lib/tool/engine.ts +58 -0
- package/src/lib/tool/examples.test.ts +35 -0
- package/src/lib/tool/examples.ts +71 -0
- package/src/lib/tool/export-targets.test.ts +47 -0
- package/src/lib/tool/export-targets.ts +84 -0
- package/src/lib/tool/facing.test.ts +72 -0
- package/src/lib/tool/facing.ts +100 -0
- package/src/lib/tool/geometry.test.ts +80 -0
- package/src/lib/tool/geometry.ts +109 -0
- package/src/lib/tool/gns3-cables.test.ts +47 -0
- package/src/lib/tool/gns3-cables.ts +81 -0
- package/src/lib/tool/gns3-import.test.ts +102 -0
- package/src/lib/tool/gns3-import.ts +198 -0
- package/src/lib/tool/gns3-layout.test.ts +62 -0
- package/src/lib/tool/gns3-parts.test.ts +47 -0
- package/src/lib/tool/gns3-parts.ts +68 -0
- package/src/lib/tool/issues.test.ts +35 -0
- package/src/lib/tool/issues.ts +60 -0
- package/src/lib/tool/layout.test.ts +128 -0
- package/src/lib/tool/layout.ts +229 -0
- package/src/lib/tool/live.test.ts +46 -0
- package/src/lib/tool/live.ts +49 -0
- package/src/lib/tool/logos.test.ts +30 -0
- package/src/lib/tool/logos.ts +99 -0
- package/src/lib/tool/model.test.ts +128 -0
- package/src/lib/tool/model.ts +238 -0
- package/src/lib/tool/os.test.ts +45 -0
- package/src/lib/tool/os.ts +57 -0
- package/src/lib/tool/parity.test.ts +145 -0
- package/src/lib/tool/pins.test.ts +24 -0
- package/src/lib/tool/pins.ts +11 -0
- package/src/lib/tool/placement.test.ts +79 -0
- package/src/lib/tool/placement.ts +60 -0
- package/src/lib/tool/ports.test.ts +39 -0
- package/src/lib/tool/ports.ts +48 -0
- package/src/lib/tool/presets.test.ts +50 -0
- package/src/lib/tool/presets.ts +77 -0
- package/src/lib/tool/projects-api.test.ts +59 -0
- package/src/lib/tool/projects-api.ts +47 -0
- package/src/lib/tool/router-ports.test.ts +63 -0
- package/src/lib/tool/router-ports.ts +88 -0
- package/src/lib/tool/selection.test.ts +44 -0
- package/src/lib/tool/selection.ts +34 -0
- package/src/lib/tool/storage.test.ts +123 -0
- package/src/lib/tool/storage.ts +127 -0
- package/src/lib/tool/switch-edit.test.ts +49 -0
- package/src/lib/tool/switch-edit.ts +38 -0
- package/src/lib/tool/text/de.ts +358 -0
- package/src/lib/tool/text/en.ts +371 -0
- package/src/lib/tool/text/es.ts +358 -0
- package/src/lib/tool/text/fr.ts +358 -0
- package/src/lib/tool/text/index.ts +21 -0
- package/src/lib/tool/text/text.test.ts +33 -0
- package/src/lib/tool/topology.test.ts +108 -0
- package/src/lib/tool/topology.ts +116 -0
- package/src/lib/tool/vlans.test.ts +64 -0
- package/src/lib/tool/vlans.ts +79 -0
- package/src/lib/utils.ts +6 -0
- package/src/model.ts +13 -0
- package/src/test/arbitrary.ts +41 -0
- package/src/test/engine.ts +48 -0
- package/src/test/lab.ts +101 -0
- package/styles/globals.css +258 -0
- package/styles/theme.css +192 -0
- package/tsconfig.build.json +12 -0
- package/tsconfig.json +22 -0
- package/wasm/isothrum_wasm.d.ts +60 -0
- package/wasm/isothrum_wasm.js +312 -0
- package/wasm/isothrum_wasm_bg.wasm +0 -0
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { useContext, useEffect } from "react";
|
|
2
|
+
import { useUpdateNodeInternals } from "@xyflow/react";
|
|
3
|
+
import { SidesContext } from "./contexts";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Which way each port of node `id` faces (`<id>:<port>` in the sides), as one key; React Flow
|
|
7
|
+
* caches where handles are, so they are measured again when it changes.
|
|
8
|
+
*/
|
|
9
|
+
export function usePortSides(id: string) {
|
|
10
|
+
const sides = useContext(SidesContext);
|
|
11
|
+
const facing = Object.keys(sides)
|
|
12
|
+
.filter((k) => k.startsWith(`${id}:`))
|
|
13
|
+
.sort()
|
|
14
|
+
.join(",");
|
|
15
|
+
useRemeasure(id, facing);
|
|
16
|
+
return sides;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Measures node `id`'s handles again whenever `key` changes. */
|
|
20
|
+
export function useRemeasure(id: string, key: string | undefined): void {
|
|
21
|
+
const updateNodeInternals = useUpdateNodeInternals();
|
|
22
|
+
useEffect(() => {
|
|
23
|
+
updateNodeInternals(id);
|
|
24
|
+
}, [id, key, updateNodeInternals]);
|
|
25
|
+
}
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useEffect, type ReactNode } from "react";
|
|
4
|
+
import { Monitor, Play, Plug, Radio, RotateCw, Square, SquareTerminal, Unplug, type LucideIcon } from "lucide-react";
|
|
5
|
+
import type { EditorModel } from "./hooks/use-editor";
|
|
6
|
+
import type { LinkMenuAt, MachineMenuAt } from "./hooks/use-context-menus";
|
|
7
|
+
import type { LinkAction, MachineAction } from "./hooks/use-machine-actions";
|
|
8
|
+
import { useText } from "./text";
|
|
9
|
+
|
|
10
|
+
/** A menu at the pointer (a right-click): Escape or a click elsewhere closes it. */
|
|
11
|
+
function ContextMenu({ at, title, onClose, children }: { at: { x: number; y: number }; title: string; onClose: () => void; children: ReactNode }) {
|
|
12
|
+
useEffect(() => {
|
|
13
|
+
const esc = (e: globalThis.KeyboardEvent) => {
|
|
14
|
+
if (e.key === "Escape") onClose();
|
|
15
|
+
};
|
|
16
|
+
window.addEventListener("keydown", esc);
|
|
17
|
+
return () => {
|
|
18
|
+
window.removeEventListener("keydown", esc);
|
|
19
|
+
};
|
|
20
|
+
}, [onClose]);
|
|
21
|
+
return (
|
|
22
|
+
<>
|
|
23
|
+
<div
|
|
24
|
+
className="fixed inset-0 z-40"
|
|
25
|
+
onClick={onClose}
|
|
26
|
+
onContextMenu={(e) => {
|
|
27
|
+
e.preventDefault();
|
|
28
|
+
onClose();
|
|
29
|
+
}}
|
|
30
|
+
/>
|
|
31
|
+
<div
|
|
32
|
+
role="menu"
|
|
33
|
+
aria-label={title}
|
|
34
|
+
style={{ left: Math.min(at.x, window.innerWidth - 248), top: Math.min(at.y, window.innerHeight - 260) }}
|
|
35
|
+
className="fixed z-50 w-60 rounded-lg border bg-popover p-1 text-popover-foreground shadow-[0_8px_30px_-8px_rgb(0_0_0/0.25)]"
|
|
36
|
+
onClick={onClose}
|
|
37
|
+
>
|
|
38
|
+
<p className="truncate px-2 pt-1 pb-1.5 font-mono text-xs text-muted-foreground">{title}</p>
|
|
39
|
+
{children}
|
|
40
|
+
</div>
|
|
41
|
+
</>
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function MenuItem({ icon: Icon, label, enabled = true, onClick }: { icon: LucideIcon; label: string; enabled?: boolean; onClick: () => void }) {
|
|
46
|
+
return (
|
|
47
|
+
<button
|
|
48
|
+
type="button"
|
|
49
|
+
role="menuitem"
|
|
50
|
+
disabled={!enabled}
|
|
51
|
+
onClick={onClick}
|
|
52
|
+
className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm hover:bg-muted disabled:pointer-events-none disabled:opacity-40"
|
|
53
|
+
>
|
|
54
|
+
<Icon className="size-4 text-muted-foreground" aria-hidden /> {label}
|
|
55
|
+
</button>
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const Divider = () => <div className="my-1 h-px bg-border" />;
|
|
60
|
+
|
|
61
|
+
/** Desktop only: a machine's menu, on a right-click, as in GNS3. */
|
|
62
|
+
function MachineMenu({ at, running, state, onClose, onConsole, onDisplay, onAction }: { at: MachineMenuAt; running: boolean; state: string | undefined; onClose: () => void; onConsole: () => void; onDisplay: () => void; onAction: (action: MachineAction) => void }) {
|
|
63
|
+
const t = useText().machineRun;
|
|
64
|
+
const up = running && state === "running";
|
|
65
|
+
return (
|
|
66
|
+
<ContextMenu at={at} title={at.machine} onClose={onClose}>
|
|
67
|
+
<MenuItem icon={SquareTerminal} label={t.console} enabled={up} onClick={onConsole} />
|
|
68
|
+
<MenuItem icon={Monitor} label={t.display} enabled={up} onClick={onDisplay} />
|
|
69
|
+
<Divider />
|
|
70
|
+
{up ? (
|
|
71
|
+
<>
|
|
72
|
+
<MenuItem icon={RotateCw} label={t.restart} onClick={() => { onAction("restart"); }} />
|
|
73
|
+
<MenuItem icon={Square} label={t.stop} onClick={() => { onAction("stop"); }} />
|
|
74
|
+
</>
|
|
75
|
+
) : (
|
|
76
|
+
<MenuItem icon={Play} label={t.start} enabled={running && !!state} onClick={() => { onAction("start"); }} />
|
|
77
|
+
)}
|
|
78
|
+
{!running && <p className="px-2 pt-1 pb-1 text-xs text-muted-foreground">{t.runFirst}</p>}
|
|
79
|
+
</ContextMenu>
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Desktop only: a cable's or a LAN's menu, on a right-click, as in GNS3: capture its traffic,
|
|
85
|
+
* suspend or resume it (a cable's ends, or every link of a LAN).
|
|
86
|
+
*/
|
|
87
|
+
function LinkMenu({ at, running, capturing, suspended, onClose, onCapture, onLink }: { at: LinkMenuAt; running: boolean; capturing: boolean; suspended: boolean; onClose: () => void; onCapture: () => void; onLink: (action: LinkAction) => void }) {
|
|
88
|
+
const t = useText();
|
|
89
|
+
const ends = at.machines;
|
|
90
|
+
const all = !ends;
|
|
91
|
+
return (
|
|
92
|
+
<ContextMenu at={at} title={at.title} onClose={onClose}>
|
|
93
|
+
{capturing ? <MenuItem icon={Square} label={t.capture.stop} onClick={onCapture} /> : <MenuItem icon={Radio} label={t.capture.start} enabled={running} onClick={onCapture} />}
|
|
94
|
+
{(!ends || ends.length > 0) && (
|
|
95
|
+
<>
|
|
96
|
+
<Divider />
|
|
97
|
+
{suspended ? (
|
|
98
|
+
<MenuItem icon={Plug} label={all ? t.link.resumeAll : t.link.resume} enabled={running} onClick={() => { onLink("resume"); }} />
|
|
99
|
+
) : (
|
|
100
|
+
<MenuItem icon={Unplug} label={all ? t.link.suspendAll : t.link.suspend} enabled={running} onClick={() => { onLink("suspend"); }} />
|
|
101
|
+
)}
|
|
102
|
+
</>
|
|
103
|
+
)}
|
|
104
|
+
{!running && <p className="px-2 pt-1 pb-1 text-xs text-muted-foreground">{t.capture.runFirst}</p>}
|
|
105
|
+
</ContextMenu>
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** The right-click menus of the running lab (desktop app only). */
|
|
110
|
+
export function RunningLabMenus({ e }: { e: EditorModel }) {
|
|
111
|
+
const { invoke, desk } = e;
|
|
112
|
+
const { machineMenu, linkMenu, setMachineMenu, setLinkMenu } = desk.menus;
|
|
113
|
+
const running = desk.state.run === "running";
|
|
114
|
+
const { captures, capture, stopCapture } = desk.captures;
|
|
115
|
+
if (!invoke) return null;
|
|
116
|
+
return (
|
|
117
|
+
<>
|
|
118
|
+
{machineMenu && (
|
|
119
|
+
<MachineMenu
|
|
120
|
+
at={machineMenu}
|
|
121
|
+
running={running}
|
|
122
|
+
state={desk.live.machineStates[machineMenu.machine]}
|
|
123
|
+
onClose={() => { setMachineMenu(null); }}
|
|
124
|
+
onConsole={() => void desk.actions.machineConsole(machineMenu.machine)}
|
|
125
|
+
onDisplay={() => void desk.actions.machineDisplay(machineMenu.machine)}
|
|
126
|
+
onAction={(action) => void desk.actions.machineAct(machineMenu.machine, action)}
|
|
127
|
+
/>
|
|
128
|
+
)}
|
|
129
|
+
{linkMenu && (
|
|
130
|
+
<LinkMenu
|
|
131
|
+
at={linkMenu}
|
|
132
|
+
running={running}
|
|
133
|
+
capturing={linkMenu.network in captures}
|
|
134
|
+
suspended={desk.live.links.some((l) => l.network === linkMenu.network && (!linkMenu.machines || linkMenu.machines.includes(l.machine)))}
|
|
135
|
+
onClose={() => { setLinkMenu(null); }}
|
|
136
|
+
onCapture={() => void (linkMenu.network in captures ? stopCapture(linkMenu.network) : capture(linkMenu.network))}
|
|
137
|
+
onLink={(action) => void desk.actions.linkAct(linkMenu.network, linkMenu.machines, action)}
|
|
138
|
+
/>
|
|
139
|
+
)}
|
|
140
|
+
</>
|
|
141
|
+
);
|
|
142
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { Check, ChevronDown, X } from "lucide-react";
|
|
4
|
+
import type { Block, BlockOption } from "@/lib/tool/blocks";
|
|
5
|
+
import { cn } from "@/lib/utils";
|
|
6
|
+
import { DropdownMenu, DropdownMenuContent, DropdownMenuGroup, DropdownMenuItem, DropdownMenuLabel, DropdownMenuShortcut, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
|
|
7
|
+
import { DeviceIcon } from "./canvas/device-icon";
|
|
8
|
+
import { PRESET_MIME } from "./hooks/use-placement";
|
|
9
|
+
import { useText } from "./text";
|
|
10
|
+
|
|
11
|
+
/** A kind's picture: the device's, or the switch's ports. */
|
|
12
|
+
function BlockIcon({ option }: { option: BlockOption }) {
|
|
13
|
+
if (option.preset) return <DeviceIcon data={option.preset.data} size="sm" />;
|
|
14
|
+
return (
|
|
15
|
+
<span className="flex size-8 shrink-0 items-center justify-center gap-[2px] rounded-md bg-background ring-1 ring-foreground/10" aria-hidden>
|
|
16
|
+
{["#7048e8", "#7048e8", "#2f9e44", "#2f9e44"].map((c, i) => (
|
|
17
|
+
<span key={i} className="h-3 w-1 rounded-[1px]" style={{ background: c }} />
|
|
18
|
+
))}
|
|
19
|
+
</span>
|
|
20
|
+
);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** One kind in a block's list: what it is, its key, and (a saved device) a way to forget it. */
|
|
24
|
+
function KindItem({ o, current, onPick, onForget }: { o: BlockOption; current: string; onPick: (tool: string) => void; onForget: (id: string) => void }) {
|
|
25
|
+
const t = useText().toolbar;
|
|
26
|
+
return (
|
|
27
|
+
<DropdownMenuItem onClick={() => { onPick(o.tool); }} className="gap-2.5">
|
|
28
|
+
<BlockIcon option={o} />
|
|
29
|
+
<span className="flex min-w-0 flex-1 flex-col">
|
|
30
|
+
<span className="truncate">{o.label}</span>
|
|
31
|
+
<span className="truncate text-xs text-muted-foreground">{o.hint}</span>
|
|
32
|
+
</span>
|
|
33
|
+
{o.tool === current && <Check className="size-4 text-muted-foreground" />}
|
|
34
|
+
{o.key && <DropdownMenuShortcut>{o.key}</DropdownMenuShortcut>}
|
|
35
|
+
{o.preset?.group === "mine" && (
|
|
36
|
+
<span
|
|
37
|
+
role="button"
|
|
38
|
+
tabIndex={-1}
|
|
39
|
+
aria-label={t.forgetLabel(o.label)}
|
|
40
|
+
title={t.forget(o.label)}
|
|
41
|
+
onClick={(e) => {
|
|
42
|
+
e.stopPropagation();
|
|
43
|
+
onForget(o.tool);
|
|
44
|
+
}}
|
|
45
|
+
className="grid size-5 place-items-center rounded text-muted-foreground hover:bg-muted hover:text-foreground"
|
|
46
|
+
>
|
|
47
|
+
<X className="size-3" />
|
|
48
|
+
</span>
|
|
49
|
+
)}
|
|
50
|
+
</DropdownMenuItem>
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** A block's arrow: every kind it places, with what it is and its key. */
|
|
55
|
+
function KindsMenu({ block, current, onPick, onForget }: { block: Block; current: string; onPick: (tool: string) => void; onForget: (id: string) => void }) {
|
|
56
|
+
const t = useText().toolbar;
|
|
57
|
+
return (
|
|
58
|
+
<DropdownMenu>
|
|
59
|
+
<DropdownMenuTrigger
|
|
60
|
+
aria-label={t.kindsOf(block.label)}
|
|
61
|
+
title={t.kindsOf(block.label)}
|
|
62
|
+
className="absolute -right-0.5 -bottom-0.5 grid size-4 place-items-center rounded-full border bg-popover text-muted-foreground shadow-sm hover:text-foreground"
|
|
63
|
+
>
|
|
64
|
+
<ChevronDown className="size-2.5" />
|
|
65
|
+
</DropdownMenuTrigger>
|
|
66
|
+
<DropdownMenuContent align="start" className="w-72">
|
|
67
|
+
<DropdownMenuGroup>
|
|
68
|
+
<DropdownMenuLabel>{block.label}</DropdownMenuLabel>
|
|
69
|
+
</DropdownMenuGroup>
|
|
70
|
+
{block.options.map((o) => (
|
|
71
|
+
<KindItem key={o.tool} o={o} current={current} onPick={onPick} onForget={onForget} />
|
|
72
|
+
))}
|
|
73
|
+
</DropdownMenuContent>
|
|
74
|
+
</DropdownMenu>
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* A toolbar block: one button that places the kind picked last (drag it, or click then click
|
|
80
|
+
* the canvas), and an arrow listing every kind with what it is and its key.
|
|
81
|
+
*/
|
|
82
|
+
export function DeviceBlock({ block, choice, active, onPick, onForget }: { block: Block; choice: string | undefined; active: string | null; onPick: (tool: string) => void; onForget: (id: string) => void }) {
|
|
83
|
+
const current = block.options.find((o) => o.tool === choice) ?? block.options.at(0);
|
|
84
|
+
if (!current) return null;
|
|
85
|
+
const on = block.options.some((o) => o.tool === active);
|
|
86
|
+
return (
|
|
87
|
+
<div className={cn("relative flex shrink-0 items-center rounded-lg", on && "bg-brand-100 ring-1 ring-signal dark:bg-brand-950")}>
|
|
88
|
+
<button
|
|
89
|
+
type="button"
|
|
90
|
+
draggable={!!current.preset}
|
|
91
|
+
onDragStart={(e) => {
|
|
92
|
+
if (!current.preset) return;
|
|
93
|
+
e.dataTransfer.setData(PRESET_MIME, current.preset.id);
|
|
94
|
+
e.dataTransfer.effectAllowed = "move";
|
|
95
|
+
}}
|
|
96
|
+
onClick={() => { onPick(current.tool); }}
|
|
97
|
+
aria-pressed={on}
|
|
98
|
+
aria-label={`${block.label}: ${current.label}`}
|
|
99
|
+
title={`${block.label}: ${current.label} (${current.hint})${current.key ? ` · ${current.key}` : ""}`}
|
|
100
|
+
className={cn("grid size-10 place-items-center rounded-lg transition-colors hover:bg-muted", on && "hover:bg-transparent")}
|
|
101
|
+
>
|
|
102
|
+
<BlockIcon option={current} />
|
|
103
|
+
</button>
|
|
104
|
+
{block.options.length > 1 && <KindsMenu block={block} current={current.tool} onPick={onPick} onForget={onForget} />}
|
|
105
|
+
</div>
|
|
106
|
+
);
|
|
107
|
+
}
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useEffect } from "react";
|
|
4
|
+
import { useTheme } from "next-themes";
|
|
5
|
+
import { Check, CopyPlus, FileUp, FolderOpen, Info, Languages, LayoutTemplate, Link2, LogIn, LogOut, Maximize, Menu, Moon, Newspaper, Palette, Save, Sun, Trash2 } from "lucide-react";
|
|
6
|
+
import { useAuthClient } from "@/lib/auth";
|
|
7
|
+
import { LOCALES, localeNames } from "@/lib/i18n";
|
|
8
|
+
import { DESIGNS, type Design } from "@/lib/tool/model";
|
|
9
|
+
import {
|
|
10
|
+
DropdownMenu,
|
|
11
|
+
DropdownMenuContent,
|
|
12
|
+
DropdownMenuGroup,
|
|
13
|
+
DropdownMenuItem,
|
|
14
|
+
DropdownMenuLabel,
|
|
15
|
+
DropdownMenuSeparator,
|
|
16
|
+
DropdownMenuShortcut,
|
|
17
|
+
DropdownMenuSub,
|
|
18
|
+
DropdownMenuSubContent,
|
|
19
|
+
DropdownMenuSubTrigger,
|
|
20
|
+
DropdownMenuTrigger,
|
|
21
|
+
} from "@/components/ui/dropdown-menu";
|
|
22
|
+
import { useText } from "./text";
|
|
23
|
+
|
|
24
|
+
/** Signed in: saving to and opening from the user's projects. */
|
|
25
|
+
interface ProjectActions {
|
|
26
|
+
onSave: () => void;
|
|
27
|
+
onSaveAs: () => void;
|
|
28
|
+
onOpen: () => void;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
interface EditorMenuProps {
|
|
32
|
+
lang: string;
|
|
33
|
+
onExample: () => void;
|
|
34
|
+
onVlanExample: () => void;
|
|
35
|
+
onShare: () => void;
|
|
36
|
+
onImportGns3: () => void;
|
|
37
|
+
design: Design;
|
|
38
|
+
onDesign: (d: Design) => void;
|
|
39
|
+
accounts: boolean;
|
|
40
|
+
projects: ProjectActions | null;
|
|
41
|
+
onClear: () => void;
|
|
42
|
+
onFit: () => void;
|
|
43
|
+
canClear: boolean;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Reports whether someone is signed in (mounted only where accounts exist). */
|
|
47
|
+
export function SessionWatch({ onChange }: { onChange: (signedIn: boolean) => void }) {
|
|
48
|
+
const authClient = useAuthClient();
|
|
49
|
+
const { data } = authClient.useSession();
|
|
50
|
+
useEffect(() => {
|
|
51
|
+
onChange(!!data);
|
|
52
|
+
}, [data, onChange]);
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Signed in through Cyber Auth, or the way to sign in (the editor works without an account). */
|
|
57
|
+
function AccountItems() {
|
|
58
|
+
const t = useText().account;
|
|
59
|
+
const authClient = useAuthClient();
|
|
60
|
+
const { data, isPending } = authClient.useSession();
|
|
61
|
+
if (isPending) return null;
|
|
62
|
+
const back = window.location.pathname;
|
|
63
|
+
return (
|
|
64
|
+
<>
|
|
65
|
+
<DropdownMenuSeparator />
|
|
66
|
+
{data ? (
|
|
67
|
+
<DropdownMenuGroup>
|
|
68
|
+
<DropdownMenuLabel className="font-normal text-muted-foreground">{t.signedInAs(data.user.name || data.user.email)}</DropdownMenuLabel>
|
|
69
|
+
<DropdownMenuItem onClick={() => void authClient.signOut()}>
|
|
70
|
+
<LogOut /> {t.signOut}
|
|
71
|
+
</DropdownMenuItem>
|
|
72
|
+
</DropdownMenuGroup>
|
|
73
|
+
) : (
|
|
74
|
+
<DropdownMenuItem onClick={() => void authClient.signIn.oauth2({ providerId: "cyber-auth", callbackURL: back, errorCallbackURL: back })}>
|
|
75
|
+
<LogIn /> {t.signIn}
|
|
76
|
+
</DropdownMenuItem>
|
|
77
|
+
)}
|
|
78
|
+
</>
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Saving to and opening from the user's projects. */
|
|
83
|
+
function ProjectItems({ projects }: { projects: ProjectActions }) {
|
|
84
|
+
const tp = useText().projects;
|
|
85
|
+
return (
|
|
86
|
+
<>
|
|
87
|
+
<DropdownMenuItem onClick={projects.onSave}>
|
|
88
|
+
<Save /> {tp.save}
|
|
89
|
+
<DropdownMenuShortcut>⌘S</DropdownMenuShortcut>
|
|
90
|
+
</DropdownMenuItem>
|
|
91
|
+
<DropdownMenuItem onClick={projects.onSaveAs}>
|
|
92
|
+
<CopyPlus /> {tp.saveAs}
|
|
93
|
+
</DropdownMenuItem>
|
|
94
|
+
<DropdownMenuItem onClick={projects.onOpen}>
|
|
95
|
+
<FolderOpen /> {tp.openList}
|
|
96
|
+
</DropdownMenuItem>
|
|
97
|
+
<DropdownMenuSeparator />
|
|
98
|
+
</>
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** The light or dark theme, and the canvas's look. */
|
|
103
|
+
function LookItems({ design, onDesign }: Pick<EditorMenuProps, "design" | "onDesign">) {
|
|
104
|
+
const t = useText().menu;
|
|
105
|
+
const { resolvedTheme, setTheme } = useTheme();
|
|
106
|
+
const dark = resolvedTheme === "dark";
|
|
107
|
+
return (
|
|
108
|
+
<>
|
|
109
|
+
<DropdownMenuItem onClick={() => { setTheme(dark ? "light" : "dark"); }}>
|
|
110
|
+
{dark ? <Sun /> : <Moon />} {dark ? t.light : t.dark}
|
|
111
|
+
</DropdownMenuItem>
|
|
112
|
+
<DropdownMenuSub>
|
|
113
|
+
<DropdownMenuSubTrigger>
|
|
114
|
+
<Palette /> {t.style}
|
|
115
|
+
</DropdownMenuSubTrigger>
|
|
116
|
+
<DropdownMenuSubContent>
|
|
117
|
+
{DESIGNS.map((d) => (
|
|
118
|
+
<DropdownMenuItem key={d} onClick={() => { onDesign(d); }}>
|
|
119
|
+
{d === design ? <Check /> : <span className="size-4" />} {t.styles[d]}
|
|
120
|
+
</DropdownMenuItem>
|
|
121
|
+
))}
|
|
122
|
+
</DropdownMenuSubContent>
|
|
123
|
+
</DropdownMenuSub>
|
|
124
|
+
</>
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** The editor in another language, and the site's pages about it. */
|
|
129
|
+
function SiteItems({ lang, accounts }: Pick<EditorMenuProps, "lang" | "accounts">) {
|
|
130
|
+
const t = useText().menu;
|
|
131
|
+
return (
|
|
132
|
+
<>
|
|
133
|
+
<DropdownMenuSub>
|
|
134
|
+
<DropdownMenuSubTrigger>
|
|
135
|
+
<Languages /> {t.language}
|
|
136
|
+
</DropdownMenuSubTrigger>
|
|
137
|
+
<DropdownMenuSubContent>
|
|
138
|
+
{LOCALES.map((l) => (
|
|
139
|
+
<DropdownMenuItem key={l} disabled={l === lang} render={<a href={`/${l}`} />}>
|
|
140
|
+
{localeNames[l]}
|
|
141
|
+
</DropdownMenuItem>
|
|
142
|
+
))}
|
|
143
|
+
</DropdownMenuSubContent>
|
|
144
|
+
</DropdownMenuSub>
|
|
145
|
+
{accounts && <AccountItems />}
|
|
146
|
+
<DropdownMenuSeparator />
|
|
147
|
+
<DropdownMenuItem render={<a href={`/${lang}/about`} />}>
|
|
148
|
+
<Info /> {t.about}
|
|
149
|
+
</DropdownMenuItem>
|
|
150
|
+
<DropdownMenuItem render={<a href={`/${lang}/blog`} />}>
|
|
151
|
+
<Newspaper /> {t.blog}
|
|
152
|
+
</DropdownMenuItem>
|
|
153
|
+
</>
|
|
154
|
+
);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** The editor's menu (top left): examples, projects, files, the look, the language. */
|
|
158
|
+
export function EditorMenu(p: EditorMenuProps) {
|
|
159
|
+
const t = useText().menu;
|
|
160
|
+
return (
|
|
161
|
+
<DropdownMenu>
|
|
162
|
+
<DropdownMenuTrigger aria-label={t.menu} className="grid size-9 place-items-center rounded-lg border bg-popover shadow-sm hover:bg-muted">
|
|
163
|
+
<Menu className="size-4" />
|
|
164
|
+
</DropdownMenuTrigger>
|
|
165
|
+
<DropdownMenuContent align="start" className="w-56">
|
|
166
|
+
<DropdownMenuItem onClick={p.onExample}>
|
|
167
|
+
<LayoutTemplate /> {t.example}
|
|
168
|
+
</DropdownMenuItem>
|
|
169
|
+
<DropdownMenuItem onClick={p.onVlanExample}>
|
|
170
|
+
<LayoutTemplate /> {t.vlanExample}
|
|
171
|
+
</DropdownMenuItem>
|
|
172
|
+
{p.projects && <ProjectItems projects={p.projects} />}
|
|
173
|
+
<DropdownMenuItem onClick={p.onImportGns3}>
|
|
174
|
+
<FileUp /> {t.gns3}
|
|
175
|
+
</DropdownMenuItem>
|
|
176
|
+
<DropdownMenuItem onClick={p.onShare} disabled={!p.canClear}>
|
|
177
|
+
<Link2 /> {t.share}
|
|
178
|
+
</DropdownMenuItem>
|
|
179
|
+
<DropdownMenuItem onClick={p.onFit}>
|
|
180
|
+
<Maximize /> {t.fit}
|
|
181
|
+
</DropdownMenuItem>
|
|
182
|
+
<DropdownMenuItem onClick={p.onClear} disabled={!p.canClear}>
|
|
183
|
+
<Trash2 /> {t.clear}
|
|
184
|
+
</DropdownMenuItem>
|
|
185
|
+
<DropdownMenuSeparator />
|
|
186
|
+
<LookItems design={p.design} onDesign={p.onDesign} />
|
|
187
|
+
<SiteItems lang={p.lang} accounts={p.accounts} />
|
|
188
|
+
</DropdownMenuContent>
|
|
189
|
+
</DropdownMenu>
|
|
190
|
+
);
|
|
191
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useState } from "react";
|
|
4
|
+
import { Check, Copy } from "lucide-react";
|
|
5
|
+
import { Button } from "@/components/ui/button";
|
|
6
|
+
import { useText } from "../text";
|
|
7
|
+
|
|
8
|
+
export function CopyButton({ text, label }: { text: string; label?: string }) {
|
|
9
|
+
const t = useText().exportPanel;
|
|
10
|
+
const [copied, setCopied] = useState(false);
|
|
11
|
+
return (
|
|
12
|
+
<Button
|
|
13
|
+
size="xs"
|
|
14
|
+
variant="ghost"
|
|
15
|
+
onClick={() => {
|
|
16
|
+
// There is no clipboard outside a secure context (plain http): then nothing happens.
|
|
17
|
+
const clipboard = navigator.clipboard as Clipboard | undefined;
|
|
18
|
+
void clipboard?.writeText(text).then(() => {
|
|
19
|
+
setCopied(true);
|
|
20
|
+
setTimeout(() => {
|
|
21
|
+
setCopied(false);
|
|
22
|
+
}, 1500);
|
|
23
|
+
});
|
|
24
|
+
}}
|
|
25
|
+
>
|
|
26
|
+
{copied ? <Check data-icon="inline-start" /> : <Copy data-icon="inline-start" />}
|
|
27
|
+
{copied ? t.copied : (label ?? t.copy)}
|
|
28
|
+
</Button>
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** A command to copy, under its label. */
|
|
33
|
+
export function Command({ label, text }: { label: string; text: string }) {
|
|
34
|
+
return (
|
|
35
|
+
<div className="rounded-md border bg-surface">
|
|
36
|
+
<div className="flex items-center justify-between border-b px-3 py-1">
|
|
37
|
+
<span className="text-xs text-muted-foreground">{label}</span>
|
|
38
|
+
<CopyButton text={text} />
|
|
39
|
+
</div>
|
|
40
|
+
<pre className="overflow-x-auto p-3 font-mono text-xs">
|
|
41
|
+
<code>{text}</code>
|
|
42
|
+
</pre>
|
|
43
|
+
</div>
|
|
44
|
+
);
|
|
45
|
+
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useState } from "react";
|
|
4
|
+
import { CircleAlert, Download, FileCode } from "lucide-react";
|
|
5
|
+
import type { GeneratedFile } from "@/lib/tool/engine";
|
|
6
|
+
import { groupByTarget } from "@/lib/tool/export-targets";
|
|
7
|
+
import { Button } from "@/components/ui/button";
|
|
8
|
+
import { useText } from "../text";
|
|
9
|
+
import { CopyButton } from "./copy-button";
|
|
10
|
+
import { TargetList, type Opened } from "./target-list";
|
|
11
|
+
|
|
12
|
+
/** One line to install the Isoloom command line (macOS, Linux; Windows binaries are on its releases page). */
|
|
13
|
+
const INSTALL = "curl -fsSL https://raw.githubusercontent.com/isoloom/isoloom/main/install.sh | sh";
|
|
14
|
+
|
|
15
|
+
interface ExportPanelProps {
|
|
16
|
+
labName: string;
|
|
17
|
+
onLabNameChange: (name: string) => void;
|
|
18
|
+
/** Containers and VMs together: only Vagrant can run it. */
|
|
19
|
+
mixed: boolean;
|
|
20
|
+
spec: string | null;
|
|
21
|
+
files: GeneratedFile[];
|
|
22
|
+
issues: string[];
|
|
23
|
+
onDownloadAll: () => void;
|
|
24
|
+
onDownloadSpec: () => void;
|
|
25
|
+
/** One target's zip: isoloom.yml and that target's folder. */
|
|
26
|
+
onDownloadTarget: (dir: string) => void;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** What to fix before the lab can be downloaded. */
|
|
30
|
+
function FixFirst({ issues }: { issues: string[] }) {
|
|
31
|
+
const t = useText().exportPanel;
|
|
32
|
+
return (
|
|
33
|
+
<div className="rounded-lg border border-warning/40 bg-warning/5 p-4 text-sm">
|
|
34
|
+
<p className="flex items-center gap-2 font-medium">
|
|
35
|
+
<CircleAlert className="size-4 text-warning" aria-hidden /> {t.fixFirst}
|
|
36
|
+
</p>
|
|
37
|
+
<ul className="mt-2 list-disc space-y-1 pl-6 text-muted-foreground">
|
|
38
|
+
{issues.map((p, i) => (
|
|
39
|
+
<li key={i}>{p}</li>
|
|
40
|
+
))}
|
|
41
|
+
</ul>
|
|
42
|
+
</div>
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Where it runs: one folder per target in the zip, how to install Isoloom, each target's commands. */
|
|
47
|
+
function WhereItRuns({ p, opened }: { p: ExportPanelProps; opened: Opened }) {
|
|
48
|
+
const t = useText().exportPanel;
|
|
49
|
+
return (
|
|
50
|
+
<section className="flex flex-col gap-3">
|
|
51
|
+
<div>
|
|
52
|
+
<h3 className="font-semibold">{t.where}</h3>
|
|
53
|
+
<p className="text-sm text-muted-foreground">{t.whereIntro}</p>
|
|
54
|
+
{p.mixed && <p className="mt-2 rounded-md bg-pastel-yellow p-2.5 text-sm">{t.mixed}</p>}
|
|
55
|
+
</div>
|
|
56
|
+
<div className="flex flex-col gap-1.5 rounded-md bg-pastel-violet p-3 text-xs">
|
|
57
|
+
<span>{t.install}</span>
|
|
58
|
+
<div className="flex items-center gap-2">
|
|
59
|
+
<code className="min-w-0 flex-1 truncate font-mono">{INSTALL}</code>
|
|
60
|
+
<CopyButton text={INSTALL} />
|
|
61
|
+
</div>
|
|
62
|
+
</div>
|
|
63
|
+
<TargetList targets={groupByTarget(p.files, t)} ready opened={opened} onDownloadTarget={p.onDownloadTarget} />
|
|
64
|
+
</section>
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** isoloom.yml: the lab as one readable file, to keep in Git or edit by hand. */
|
|
69
|
+
function SpecSection({ spec, show, onToggle }: { spec: string; show: boolean; onToggle: () => void }) {
|
|
70
|
+
const t = useText().exportPanel;
|
|
71
|
+
return (
|
|
72
|
+
<section className="flex flex-col gap-3">
|
|
73
|
+
<div className="flex items-center justify-between">
|
|
74
|
+
<div>
|
|
75
|
+
<h3 className="font-semibold">isoloom.yml</h3>
|
|
76
|
+
<p className="text-sm text-muted-foreground">{t.specIntro}</p>
|
|
77
|
+
</div>
|
|
78
|
+
</div>
|
|
79
|
+
<div className="rounded-lg border bg-surface">
|
|
80
|
+
<div className="flex items-center justify-between border-b px-3 py-1">
|
|
81
|
+
<button type="button" className="text-xs text-muted-foreground hover:text-foreground" onClick={onToggle}>
|
|
82
|
+
{show ? t.hideSpec : t.showSpec}
|
|
83
|
+
</button>
|
|
84
|
+
<CopyButton text={spec} />
|
|
85
|
+
</div>
|
|
86
|
+
{show && (
|
|
87
|
+
<pre className="max-h-96 overflow-auto p-3 font-mono text-xs">
|
|
88
|
+
<code>{spec}</code>
|
|
89
|
+
</pre>
|
|
90
|
+
)}
|
|
91
|
+
</div>
|
|
92
|
+
</section>
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** What the Export window shows: the lab's name, the downloads, where it runs and how. */
|
|
97
|
+
export function ExportPanel(p: ExportPanelProps) {
|
|
98
|
+
const t = useText().exportPanel;
|
|
99
|
+
const [openTarget, setOpenTarget] = useState<string | null>(null);
|
|
100
|
+
const [openFile, setOpenFile] = useState<string | null>(null);
|
|
101
|
+
const [showSpec, setShowSpec] = useState(false);
|
|
102
|
+
const ready = p.spec !== null && p.issues.length === 0;
|
|
103
|
+
const opened: Opened = { target: openTarget, setTarget: setOpenTarget, file: openFile, setFile: setOpenFile };
|
|
104
|
+
return (
|
|
105
|
+
<div className="flex flex-col gap-6 p-4">
|
|
106
|
+
<label className="flex flex-col gap-1 text-sm">
|
|
107
|
+
<span className="text-muted-foreground">{t.labName}</span>
|
|
108
|
+
<input
|
|
109
|
+
className="h-9 w-full rounded-md border border-input bg-background px-2.5 font-mono text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
110
|
+
value={p.labName}
|
|
111
|
+
onChange={(e) => { p.onLabNameChange(e.target.value); }}
|
|
112
|
+
/>
|
|
113
|
+
</label>
|
|
114
|
+
{!ready && <FixFirst issues={p.issues} />}
|
|
115
|
+
<div className="flex flex-col gap-2">
|
|
116
|
+
<Button size="lg" onClick={p.onDownloadAll} disabled={!ready}>
|
|
117
|
+
<Download data-icon="inline-start" /> {t.download(p.labName || "lab")}
|
|
118
|
+
</Button>
|
|
119
|
+
<Button size="lg" variant="outline" onClick={p.onDownloadSpec} disabled={p.spec === null}>
|
|
120
|
+
<FileCode data-icon="inline-start" /> {t.onlySpec}
|
|
121
|
+
</Button>
|
|
122
|
+
</div>
|
|
123
|
+
{ready && <WhereItRuns p={p} opened={opened} />}
|
|
124
|
+
{p.spec && <SpecSection spec={p.spec} show={showSpec} onToggle={() => { setShowSpec((s) => !s); }} />}
|
|
125
|
+
</div>
|
|
126
|
+
);
|
|
127
|
+
}
|