darabos commited on
Commit
05acf81
·
1 Parent(s): da515b9

Save workspaces.

Browse files
server/main.py CHANGED
@@ -1,6 +1,7 @@
1
  from typing import Optional
2
  import dataclasses
3
  import fastapi
 
4
  import pathlib
5
  import pydantic
6
  import traceback
@@ -78,11 +79,33 @@ def execute(ws):
78
  data.view = output
79
 
80
 
 
 
 
 
 
 
 
 
 
 
 
81
  @app.post("/api/save")
82
- def save(ws: Workspace):
83
- print(ws)
84
- execute(ws)
85
- print('exec done', ws)
 
 
 
 
 
 
 
 
 
 
 
86
  return ws
87
 
88
  DATA_PATH = pathlib.Path.cwd() / 'data'
@@ -99,3 +122,11 @@ def list_dir(path: str):
99
  return sorted([
100
  DirectoryEntry(p.relative_to(DATA_PATH), 'directory' if p.is_dir() else 'workspace')
101
  for p in path.iterdir()])
 
 
 
 
 
 
 
 
 
1
  from typing import Optional
2
  import dataclasses
3
  import fastapi
4
+ import json
5
  import pathlib
6
  import pydantic
7
  import traceback
 
79
  data.view = output
80
 
81
 
82
+ class SaveRequest(BaseConfig):
83
+ path: str
84
+ ws: Workspace
85
+
86
+ def save(req: SaveRequest):
87
+ path = DATA_PATH / req.path
88
+ assert path.is_relative_to(DATA_PATH)
89
+ j = req.ws.model_dump_json(indent=2)
90
+ with open(path, 'w') as f:
91
+ f.write(j)
92
+
93
  @app.post("/api/save")
94
+ def save_and_execute(req: SaveRequest):
95
+ save(req)
96
+ execute(req.ws)
97
+ save(req)
98
+ return req.ws
99
+
100
+ @app.get("/api/load")
101
+ def load(path: str):
102
+ path = DATA_PATH / path
103
+ assert path.is_relative_to(DATA_PATH)
104
+ if not path.exists():
105
+ return Workspace(nodes=[], edges=[])
106
+ with open(path) as f:
107
+ j = f.read()
108
+ ws = Workspace.model_validate_json(j)
109
  return ws
110
 
111
  DATA_PATH = pathlib.Path.cwd() / 'data'
 
122
  return sorted([
123
  DirectoryEntry(p.relative_to(DATA_PATH), 'directory' if p.is_dir() else 'workspace')
124
  for p in path.iterdir()])
125
+
126
+ @app.post("/api/dir/mkdir")
127
+ def make_dir(req: dict):
128
+ path = DATA_PATH / req['path']
129
+ assert path.is_relative_to(DATA_PATH)
130
+ assert not path.exists()
131
+ path.mkdir()
132
+ return list_dir(path.parent)
server/ops.py CHANGED
@@ -52,9 +52,7 @@ class Bundle:
52
  @classmethod
53
  def from_nx(cls, graph: nx.Graph):
54
  edges = nx.to_pandas_edgelist(graph)
55
- print(edges)
56
  nodes = pd.DataFrame({'id': list(graph.nodes)})
57
- print(nodes)
58
  return cls(
59
  dfs={'edges': edges, 'nodes': nodes},
60
  edges=[
 
52
  @classmethod
53
  def from_nx(cls, graph: nx.Graph):
54
  edges = nx.to_pandas_edgelist(graph)
 
55
  nodes = pd.DataFrame({'id': list(graph.nodes)})
 
56
  return cls(
57
  dfs={'edges': edges, 'nodes': nodes},
58
  edges=[
web/package-lock.json CHANGED
@@ -8,7 +8,7 @@
8
  "name": "vite-svelte-flow-template",
9
  "version": "0.0.0",
10
  "dependencies": {
11
- "@xyflow/svelte": "^0.0.39",
12
  "fuse.js": "^7.0.0",
13
  "graphology": "^0.25.4",
14
  "graphology-library": "^0.8.0",
@@ -612,12 +612,12 @@
612
  }
613
  },
614
  "node_modules/@xyflow/svelte": {
615
- "version": "0.0.39",
616
- "resolved": "https://registry.npmjs.org/@xyflow/svelte/-/svelte-0.0.39.tgz",
617
- "integrity": "sha512-Kam9VMXIrKjIpBvalJLNrxqbI/ASHaYHj6ZRkdGsnAx3aYgB+de0McAqiJToKdlOeZyHoQtxzSRX9D+ZTSEVZw==",
618
  "dependencies": {
619
  "@svelte-put/shortcut": "^3.1.0",
620
- "@xyflow/system": "0.0.20",
621
  "classcat": "^5.0.4",
622
  "svelte-preprocess": "^5.1.3"
623
  },
@@ -626,9 +626,9 @@
626
  }
627
  },
628
  "node_modules/@xyflow/system": {
629
- "version": "0.0.20",
630
- "resolved": "https://registry.npmjs.org/@xyflow/system/-/system-0.0.20.tgz",
631
- "integrity": "sha512-OQ9irX0HtZqAzOKtnNi7WpDT6SEp7VpR16VRatd7oImw5vahyjmggUSY7as9XvJnAz0D9H0g1qjRX99moabvQA==",
632
  "dependencies": {
633
  "@types/d3": "^7.4.0",
634
  "@types/d3-drag": "^3.0.1",
@@ -3107,20 +3107,20 @@
3107
  "integrity": "sha512-2WALfTl4xo2SkGCYRt6rDTFfk9R1czmBvUQy12gK2KuRKIpWEhcbbzy8EZXtz/jkRqHX8bFEc6FC1HjX4TUWYw=="
3108
  },
3109
  "@xyflow/svelte": {
3110
- "version": "0.0.39",
3111
- "resolved": "https://registry.npmjs.org/@xyflow/svelte/-/svelte-0.0.39.tgz",
3112
- "integrity": "sha512-Kam9VMXIrKjIpBvalJLNrxqbI/ASHaYHj6ZRkdGsnAx3aYgB+de0McAqiJToKdlOeZyHoQtxzSRX9D+ZTSEVZw==",
3113
  "requires": {
3114
  "@svelte-put/shortcut": "^3.1.0",
3115
- "@xyflow/system": "0.0.20",
3116
  "classcat": "^5.0.4",
3117
  "svelte-preprocess": "^5.1.3"
3118
  }
3119
  },
3120
  "@xyflow/system": {
3121
- "version": "0.0.20",
3122
- "resolved": "https://registry.npmjs.org/@xyflow/system/-/system-0.0.20.tgz",
3123
- "integrity": "sha512-OQ9irX0HtZqAzOKtnNi7WpDT6SEp7VpR16VRatd7oImw5vahyjmggUSY7as9XvJnAz0D9H0g1qjRX99moabvQA==",
3124
  "requires": {
3125
  "@types/d3": "^7.4.0",
3126
  "@types/d3-drag": "^3.0.1",
 
8
  "name": "vite-svelte-flow-template",
9
  "version": "0.0.0",
10
  "dependencies": {
11
+ "@xyflow/svelte": "^0.0.41",
12
  "fuse.js": "^7.0.0",
13
  "graphology": "^0.25.4",
14
  "graphology-library": "^0.8.0",
 
612
  }
613
  },
614
  "node_modules/@xyflow/svelte": {
615
+ "version": "0.0.41",
616
+ "resolved": "https://registry.npmjs.org/@xyflow/svelte/-/svelte-0.0.41.tgz",
617
+ "integrity": "sha512-6YE8XJVebBRJnio7y6zgfN/L+J65Tw8F21TGj95c4kxMRVcU1cNRbGT/CJJ4xk3g4bSaxStlblgS/Z2I7mlUHA==",
618
  "dependencies": {
619
  "@svelte-put/shortcut": "^3.1.0",
620
+ "@xyflow/system": "0.0.21",
621
  "classcat": "^5.0.4",
622
  "svelte-preprocess": "^5.1.3"
623
  },
 
626
  }
627
  },
628
  "node_modules/@xyflow/system": {
629
+ "version": "0.0.21",
630
+ "resolved": "https://registry.npmjs.org/@xyflow/system/-/system-0.0.21.tgz",
631
+ "integrity": "sha512-IvvJkC495u8mIA4Xm35dnQp0a5JUwzRm8eDBWKNyI3lAw93dOr85cKSrCNSuQ5M5SWNy2teFCFvnQEgVjwK3dg==",
632
  "dependencies": {
633
  "@types/d3": "^7.4.0",
634
  "@types/d3-drag": "^3.0.1",
 
3107
  "integrity": "sha512-2WALfTl4xo2SkGCYRt6rDTFfk9R1czmBvUQy12gK2KuRKIpWEhcbbzy8EZXtz/jkRqHX8bFEc6FC1HjX4TUWYw=="
3108
  },
3109
  "@xyflow/svelte": {
3110
+ "version": "0.0.41",
3111
+ "resolved": "https://registry.npmjs.org/@xyflow/svelte/-/svelte-0.0.41.tgz",
3112
+ "integrity": "sha512-6YE8XJVebBRJnio7y6zgfN/L+J65Tw8F21TGj95c4kxMRVcU1cNRbGT/CJJ4xk3g4bSaxStlblgS/Z2I7mlUHA==",
3113
  "requires": {
3114
  "@svelte-put/shortcut": "^3.1.0",
3115
+ "@xyflow/system": "0.0.21",
3116
  "classcat": "^5.0.4",
3117
  "svelte-preprocess": "^5.1.3"
3118
  }
3119
  },
3120
  "@xyflow/system": {
3121
+ "version": "0.0.21",
3122
+ "resolved": "https://registry.npmjs.org/@xyflow/system/-/system-0.0.21.tgz",
3123
+ "integrity": "sha512-IvvJkC495u8mIA4Xm35dnQp0a5JUwzRm8eDBWKNyI3lAw93dOr85cKSrCNSuQ5M5SWNy2teFCFvnQEgVjwK3dg==",
3124
  "requires": {
3125
  "@types/d3": "^7.4.0",
3126
  "@types/d3-drag": "^3.0.1",
web/package.json CHANGED
@@ -21,7 +21,7 @@
21
  "vite": "^5.2.8"
22
  },
23
  "dependencies": {
24
- "@xyflow/svelte": "^0.0.39",
25
  "fuse.js": "^7.0.0",
26
  "graphology": "^0.25.4",
27
  "graphology-library": "^0.8.0",
 
21
  "vite": "^5.2.8"
22
  },
23
  "dependencies": {
24
+ "@xyflow/svelte": "^0.0.41",
25
  "fuse.js": "^7.0.0",
26
  "graphology": "^0.25.4",
27
  "graphology-library": "^0.8.0",
web/src/App.svelte CHANGED
@@ -10,8 +10,7 @@
10
  if (parts.length > 1) {
11
  parameters = Object.fromEntries(new URLSearchParams(parts[1]));
12
  }
13
- console.log(parameters);
14
- }
15
  onHashChange();
16
  </script>
17
 
 
10
  if (parts.length > 1) {
11
  parameters = Object.fromEntries(new URLSearchParams(parts[1]));
12
  }
13
+ }
 
14
  onHashChange();
15
  </script>
16
 
web/src/Directory.svelte CHANGED
@@ -3,7 +3,9 @@
3
  import logo from './assets/logo.png';
4
  import Home from 'virtual:icons/tabler/home'
5
  import Folder from 'virtual:icons/tabler/folder'
 
6
  import File from 'virtual:icons/tabler/file'
 
7
 
8
  export let path = '';
9
  async function fetchList(path) {
@@ -23,6 +25,30 @@
23
  function shortName(item) {
24
  return item.name.split('/').pop();
25
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
26
  </script>
27
 
28
  <div class="directory-page">
@@ -31,10 +57,14 @@
31
  <div class="tagline">The Complete Graph Data Science Platform</div>
32
  </div>
33
  <div class="entry-list">
34
- {#if path} <div class="breadcrumbs"><a href="#dir"><Home /></a> {path} </div> {/if}
35
  {#await list}
36
  <div>Loading...</div>
37
  {:then list}
 
 
 
 
 
38
  {#each list as item}
39
  <a class="entry" href={link(item)}>
40
  {#if item.type === 'directory'}
@@ -58,7 +88,7 @@
58
  background-color: white;
59
  border-radius: 10px;
60
  box-shadow: 0px 2px 4px;
61
- padding: 10px 0;
62
  }
63
  @media (min-width: 768px) {
64
  .entry-list {
@@ -90,9 +120,27 @@
90
  }
91
  }
92
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
93
  .breadcrumbs {
94
  padding-left: 10px;
95
  font-size: 20px;
 
 
 
 
96
  }
97
  .entry-list .entry {
98
  display: block;
@@ -118,5 +166,6 @@
118
  }
119
  a {
120
  color: black;
 
121
  }
122
  </style>
 
3
  import logo from './assets/logo.png';
4
  import Home from 'virtual:icons/tabler/home'
5
  import Folder from 'virtual:icons/tabler/folder'
6
+ import FolderPlus from 'virtual:icons/tabler/folder-plus'
7
  import File from 'virtual:icons/tabler/file'
8
+ import FilePlus from 'virtual:icons/tabler/file-plus'
9
 
10
  export let path = '';
11
  async function fetchList(path) {
 
25
  function shortName(item) {
26
  return item.name.split('/').pop();
27
  }
28
+ function newName(list) {
29
+ let i = 0;
30
+ while (true) {
31
+ const name = `Untitled${i ? ` ${i}` : ''}`;
32
+ if (!list.find(item => item.name === name)) {
33
+ return name;
34
+ }
35
+ i++;
36
+ }
37
+ }
38
+ function newWorkspaceIn(path, list) {
39
+ const pathSlash = path ? `${path}/` : '';
40
+ return `#edit?path=${pathSlash}${newName(list)}`;
41
+ }
42
+ async function newFolderIn(path, list) {
43
+ const pathSlash = path ? `${path}/` : '';
44
+ const name = newName(list);
45
+ const res = await fetch(`/api/dir/mkdir`, {
46
+ method: 'POST',
47
+ headers: { 'Content-Type': 'application/json' },
48
+ body: JSON.stringify({ path: pathSlash + name }),
49
+ });
50
+ list = await res.json();
51
+ }
52
  </script>
53
 
54
  <div class="directory-page">
 
57
  <div class="tagline">The Complete Graph Data Science Platform</div>
58
  </div>
59
  <div class="entry-list">
 
60
  {#await list}
61
  <div>Loading...</div>
62
  {:then list}
63
+ <div class="actions">
64
+ <a href="{newWorkspaceIn(path, list)}"><FilePlus /> New workspace</a>
65
+ <a href on:click="{newFolderIn(path, list)}"><FolderPlus /> New folder</a>
66
+ </div>
67
+ {#if path} <div class="breadcrumbs"><a href="#dir"><Home /></a> {path} </div> {/if}
68
  {#each list as item}
69
  <a class="entry" href={link(item)}>
70
  {#if item.type === 'directory'}
 
88
  background-color: white;
89
  border-radius: 10px;
90
  box-shadow: 0px 2px 4px;
91
+ padding: 0 0 10px 0;
92
  }
93
  @media (min-width: 768px) {
94
  .entry-list {
 
120
  }
121
  }
122
 
123
+ .actions {
124
+ display: flex;
125
+ justify-content: space-evenly;
126
+ padding: 5px;
127
+ }
128
+ .actions a {
129
+ padding: 2px 10px;
130
+ border-radius: 5px;
131
+ }
132
+ .actions a:hover {
133
+ background: #39bcf3;
134
+ color: white;
135
+ }
136
+
137
  .breadcrumbs {
138
  padding-left: 10px;
139
  font-size: 20px;
140
+ background: #002a4c20;
141
+ }
142
+ .breadcrumbs a:hover {
143
+ color: #39bcf3;
144
  }
145
  .entry-list .entry {
146
  display: block;
 
166
  }
167
  a {
168
  color: black;
169
+ text-decoration: none;
170
  }
171
  </style>
web/src/LynxKiteFlow.svelte CHANGED
@@ -26,22 +26,20 @@
26
  table_view: NodeWithTableView,
27
  };
28
 
 
29
  const nodes = writable<Node[]>([]);
30
-
31
- const edges = writable<Edge[]>([
32
- {
33
- id: '3-1',
34
- source: '3',
35
- target: '1',
36
- // markerEnd: { type: MarkerType.ArrowClosed },
37
- },
38
- {
39
- id: '3-4',
40
- source: '1',
41
- target: '4',
42
- // markerEnd: { type: MarkerType.ArrowClosed },
43
- },
44
- ]);
45
 
46
  function closeNodeSearch() {
47
  nodeSearchPos = undefined;
@@ -52,17 +50,9 @@
52
  return;
53
  }
54
  event.preventDefault();
55
- const width = 500;
56
- const height = 200;
57
  nodeSearchPos = {
58
- top: event.clientY < height - 200 ? event.clientY : undefined,
59
- left: event.clientX < width - 200 ? event.clientX : undefined,
60
- right: event.clientX >= width - 200 ? width - event.clientX : undefined,
61
- bottom: event.clientY >= height - 200 ? height - event.clientY : undefined
62
- };
63
- nodeSearchPos = {
64
- top: event.clientY,
65
- left: event.clientX - 150,
66
  };
67
  }
68
  function addNode(e) {
@@ -99,6 +89,9 @@
99
  return JSON.stringify(obj, Array.from(allKeys).sort());
100
  }
101
  graph.subscribe(async (g) => {
 
 
 
102
  const dragging = g.nodes.find((n) => n.dragging);
103
  if (dragging) return;
104
  g = JSON.parse(JSON.stringify(g));
@@ -107,13 +100,14 @@
107
  }
108
  const ws = orderedJSON(g);
109
  if (ws === backendWorkspace) return;
 
110
  backendWorkspace = ws;
111
  const res = await fetch('/api/save', {
112
  method: 'POST',
113
  headers: {
114
  'Content-Type': 'application/json',
115
  },
116
- body: ws,
117
  });
118
  const j = await res.json();
119
  backendWorkspace = orderedJSON(j);
@@ -121,7 +115,7 @@
121
  });
122
  </script>
123
 
124
- <div style:height="100vh">
125
  <SvelteFlow {nodes} {edges} {nodeTypes} fitView
126
  on:paneclick={toggleNodeSearch}
127
  proOptions={{ hideAttribution: true }}
 
26
  table_view: NodeWithTableView,
27
  };
28
 
29
+ export let path = '';
30
  const nodes = writable<Node[]>([]);
31
+ const edges = writable<Edge[]>([]);
32
+ let workspaceLoaded = false;
33
+ async function fetchWorkspace(path) {
34
+ if (!path) return;
35
+ const res = await fetch(`/api/load?path=${path}`);
36
+ const j = await res.json();
37
+ nodes.set(j.nodes);
38
+ edges.set(j.edges);
39
+ backendWorkspace = orderedJSON(j);
40
+ workspaceLoaded = true;
41
+ }
42
+ $: fetchWorkspace(path);
 
 
 
43
 
44
  function closeNodeSearch() {
45
  nodeSearchPos = undefined;
 
50
  return;
51
  }
52
  event.preventDefault();
 
 
53
  nodeSearchPos = {
54
+ top: event.offsetY,
55
+ left: event.offsetX - 155,
 
 
 
 
 
 
56
  };
57
  }
58
  function addNode(e) {
 
89
  return JSON.stringify(obj, Array.from(allKeys).sort());
90
  }
91
  graph.subscribe(async (g) => {
92
+ if (!workspaceLoaded) {
93
+ return;
94
+ }
95
  const dragging = g.nodes.find((n) => n.dragging);
96
  if (dragging) return;
97
  g = JSON.parse(JSON.stringify(g));
 
100
  }
101
  const ws = orderedJSON(g);
102
  if (ws === backendWorkspace) return;
103
+ console.log('save', '\n' + ws, '\n' + backendWorkspace);
104
  backendWorkspace = ws;
105
  const res = await fetch('/api/save', {
106
  method: 'POST',
107
  headers: {
108
  'Content-Type': 'application/json',
109
  },
110
+ body: JSON.stringify({ path, ws: g }),
111
  });
112
  const j = await res.json();
113
  backendWorkspace = orderedJSON(j);
 
115
  });
116
  </script>
117
 
118
+ <div style:height="100%">
119
  <SvelteFlow {nodes} {edges} {nodeTypes} fitView
120
  on:paneclick={toggleNodeSearch}
121
  proOptions={{ hideAttribution: true }}
web/src/NodeSearch.svelte CHANGED
@@ -23,13 +23,21 @@
23
  e.preventDefault();
24
  selectedIndex = Math.max(selectedIndex - 1, 0);
25
  } else if (e.key === 'Enter') {
26
- const node = {...hits[selectedIndex].item};
27
- node.position = {x: pos.left, y: pos.top};
28
- dispatch('add', node);
29
  } else if (e.key === 'Escape') {
30
  dispatch('cancel');
31
  }
32
  }
 
 
 
 
 
 
 
 
 
 
33
 
34
  </script>
35
 
@@ -40,10 +48,18 @@ style="top: {pos.top}px; left: {pos.left}px; right: {pos.right}px; bottom: {pos.
40
  bind:this={searchBox}
41
  on:input={onInput}
42
  on:keydown={onKeyDown}
43
- on:focusout={() => dispatch('cancel')}
44
  placeholder="Search for box">
45
  {#each hits as box, index}
46
- <div class="search-result" class:selected={index == selectedIndex}>{index} {box.item.data.title}</div>
 
 
 
 
 
 
 
 
47
  {/each}
48
  </div>
49
 
@@ -58,6 +74,7 @@ style="top: {pos.top}px; left: {pos.left}px; right: {pos.right}px; bottom: {pos.
58
  }
59
  .search-result {
60
  padding: 4px;
 
61
  }
62
  .search-result.selected {
63
  background-color: #f80;
 
23
  e.preventDefault();
24
  selectedIndex = Math.max(selectedIndex - 1, 0);
25
  } else if (e.key === 'Enter') {
26
+ addSelected();
 
 
27
  } else if (e.key === 'Escape') {
28
  dispatch('cancel');
29
  }
30
  }
31
+ function addSelected() {
32
+ const node = {...hits[selectedIndex].item};
33
+ node.position = {x: pos.left, y: pos.top};
34
+ dispatch('add', node);
35
+ }
36
+ async function lostFocus(e) {
37
+ // If it's a click on a result, let the click handler handle it.
38
+ if (e.relatedTarget && e.relatedTarget.closest('.node-search')) return;
39
+ dispatch('cancel');
40
+ }
41
 
42
  </script>
43
 
 
48
  bind:this={searchBox}
49
  on:input={onInput}
50
  on:keydown={onKeyDown}
51
+ on:focusout={lostFocus}
52
  placeholder="Search for box">
53
  {#each hits as box, index}
54
+ <div
55
+ tabindex="0"
56
+ on:focus={() => selectedIndex = index}
57
+ on:mouseenter={() => selectedIndex = index}
58
+ on:click={addSelected}
59
+ class="search-result"
60
+ class:selected={index == selectedIndex}>
61
+ {box.item.data.title}
62
+ </div>
63
  {/each}
64
  </div>
65
 
 
74
  }
75
  .search-result {
76
  padding: 4px;
77
+ cursor: pointer;
78
  }
79
  .search-result.selected {
80
  background-color: #f80;
web/src/Workspace.svelte CHANGED
@@ -1,9 +1,58 @@
1
  <script lang="ts">
2
  // This is the whole LynxKite workspace editor page.
3
  import { SvelteFlowProvider } from '@xyflow/svelte';
 
 
 
4
  import LynxKiteFlow from './LynxKiteFlow.svelte';
 
 
5
  </script>
6
 
7
- <SvelteFlowProvider>
8
- <LynxKiteFlow />
9
- </SvelteFlowProvider>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  <script lang="ts">
2
  // This is the whole LynxKite workspace editor page.
3
  import { SvelteFlowProvider } from '@xyflow/svelte';
4
+ import ArrowBack from 'virtual:icons/tabler/arrow-back'
5
+ import Backspace from 'virtual:icons/tabler/backspace'
6
+ import Atom from 'virtual:icons/tabler/Atom'
7
  import LynxKiteFlow from './LynxKiteFlow.svelte';
8
+ export let path = '';
9
+ $: parent = path.split('/').slice(0, -1).join('/');
10
  </script>
11
 
12
+ <div class="page">
13
+ <div class="top-bar">
14
+ <div class="ws-name">
15
+ <a href><img src="/favicon.ico"></a>
16
+ {path}
17
+ </div>
18
+ <div class="tools">
19
+ <a href><Atom /></a>
20
+ <a href><Backspace /></a>
21
+ <a href="#dir?path={parent}"><ArrowBack /></a>
22
+ </div>
23
+ </div>
24
+ <SvelteFlowProvider>
25
+ <LynxKiteFlow path={path} />
26
+ </SvelteFlowProvider>
27
+ </div>
28
+
29
+ <style>
30
+ .top-bar {
31
+ display: flex;
32
+ justify-content: space-between;
33
+ background: #002a4c;
34
+ color: white;
35
+ }
36
+ .ws-name {
37
+ font-size: 1.5em;
38
+ }
39
+ .ws-name img {
40
+ height: 1.5em;
41
+ vertical-align: middle;
42
+ margin: 4px;
43
+ }
44
+ .page {
45
+ display: flex;
46
+ flex-direction: column;
47
+ height: 100vh;
48
+ }
49
+
50
+ .tools {
51
+ display: flex;
52
+ align-items: center;
53
+ }
54
+ .tools a {
55
+ color: white;
56
+ font-size: 1.5em;
57
+ }
58
+ </style>