vzcode 0.6.0 → 0.8.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/README.md +0 -1
- package/dist/assets/index-80e9e6df.js +89 -0
- package/dist/assets/index-ccd52a89.css +1 -0
- package/dist/index.html +3 -3
- package/package.json +18 -17
- package/src/client/App.tsx +40 -93
- package/src/client/CodeEditor/getOrCreateEditor.ts +115 -0
- package/src/client/CodeEditor/index.tsx +50 -0
- package/src/client/{json1PresenceDisplay.js → CodeEditor/json1PresenceDisplay.js} +2 -2
- package/src/client/CodeEditor/style.scss +11 -0
- package/src/client/CodeEditor/widgets.ts +48 -0
- package/src/client/{settings.jsx → Settings.tsx} +18 -39
- package/src/client/Sidebar/DirectoryListing.tsx +6 -5
- package/src/client/Sidebar/FileListing.tsx +1 -1
- package/src/client/Sidebar/Listing.tsx +3 -3
- package/src/client/Sidebar/index.tsx +13 -12
- package/src/client/TabList/index.tsx +82 -0
- package/src/client/TabList/style.scss +32 -0
- package/src/client/main.jsx +1 -1
- package/src/client/style.scss +0 -44
- package/src/client/themes.ts +31 -0
- package/src/client/{Sidebar/useOpenDirectories.ts → useOpenDirectories.ts} +17 -11
- package/src/client/useTabsState.ts +51 -0
- package/src/types.ts +12 -0
- package/dist/assets/index-41607816.js +0 -89
- package/dist/assets/index-8bac68c9.css +0 -1
- package/src/client/CodeEditor.jsx +0 -31
- package/src/client/getOrCreateEditor.js +0 -85
- /package/src/client/{json1PresenceBroadcast.js → CodeEditor/json1PresenceBroadcast.js} +0 -0
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { useCallback } from 'react';
|
|
1
|
+
import { useCallback, useMemo } from 'react';
|
|
2
2
|
import { Item } from './Item';
|
|
3
3
|
import { Listing } from './Listing';
|
|
4
4
|
import { DirectoryArrowSVG } from './DirectoryArrowSVG';
|
|
@@ -11,7 +11,7 @@ export const DirectoryListing = ({
|
|
|
11
11
|
handleRenameFileClick,
|
|
12
12
|
handleDeleteFileClick,
|
|
13
13
|
handleFileClick,
|
|
14
|
-
|
|
14
|
+
isDirectoryOpen,
|
|
15
15
|
toggleDirectory,
|
|
16
16
|
}: {
|
|
17
17
|
name: string;
|
|
@@ -20,7 +20,7 @@ export const DirectoryListing = ({
|
|
|
20
20
|
handleRenameFileClick: (fileId: string) => void;
|
|
21
21
|
handleDeleteFileClick: (fileId: string, event: React.MouseEvent) => void;
|
|
22
22
|
handleFileClick: (fileId: string) => void;
|
|
23
|
-
|
|
23
|
+
isDirectoryOpen: (path: string) => boolean;
|
|
24
24
|
toggleDirectory: (path: string) => void;
|
|
25
25
|
}) => {
|
|
26
26
|
const handleClick = useCallback(() => {
|
|
@@ -35,7 +35,8 @@ export const DirectoryListing = ({
|
|
|
35
35
|
console.log('TODO handleRenameDirectoryClick');
|
|
36
36
|
}, []);
|
|
37
37
|
|
|
38
|
-
const isOpen =
|
|
38
|
+
const isOpen = useMemo(() => isDirectoryOpen(path), [isDirectoryOpen, path]);
|
|
39
|
+
|
|
39
40
|
return (
|
|
40
41
|
<>
|
|
41
42
|
<Item
|
|
@@ -64,7 +65,7 @@ export const DirectoryListing = ({
|
|
|
64
65
|
handleRenameFileClick={handleRenameFileClick}
|
|
65
66
|
handleDeleteFileClick={handleDeleteFileClick}
|
|
66
67
|
handleFileClick={handleFileClick}
|
|
67
|
-
|
|
68
|
+
isDirectoryOpen={isDirectoryOpen}
|
|
68
69
|
toggleDirectory={toggleDirectory}
|
|
69
70
|
/>
|
|
70
71
|
);
|
|
@@ -9,14 +9,14 @@ export const Listing = ({
|
|
|
9
9
|
handleRenameFileClick,
|
|
10
10
|
handleDeleteFileClick,
|
|
11
11
|
handleFileClick,
|
|
12
|
-
|
|
12
|
+
isDirectoryOpen,
|
|
13
13
|
toggleDirectory,
|
|
14
14
|
}: {
|
|
15
15
|
entity: FileTree | FileTreeFile;
|
|
16
16
|
handleRenameFileClick: (fileId: FileId) => void;
|
|
17
17
|
handleDeleteFileClick: (fileId: FileId, event: React.MouseEvent) => void;
|
|
18
18
|
handleFileClick: (fileId: FileId) => void;
|
|
19
|
-
|
|
19
|
+
isDirectoryOpen: (path: string) => boolean;
|
|
20
20
|
toggleDirectory: (path: string) => void;
|
|
21
21
|
}) => {
|
|
22
22
|
const { name, file, fileId } = entity as FileTreeFile;
|
|
@@ -37,7 +37,7 @@ export const Listing = ({
|
|
|
37
37
|
handleRenameFileClick={handleRenameFileClick}
|
|
38
38
|
handleDeleteFileClick={handleDeleteFileClick}
|
|
39
39
|
handleFileClick={handleFileClick}
|
|
40
|
-
|
|
40
|
+
isDirectoryOpen={isDirectoryOpen}
|
|
41
41
|
toggleDirectory={toggleDirectory}
|
|
42
42
|
/>
|
|
43
43
|
);
|
|
@@ -1,9 +1,8 @@
|
|
|
1
|
-
import { useMemo } from 'react';
|
|
1
|
+
import { useCallback, useMemo } from 'react';
|
|
2
2
|
import { getFileTree } from '../getFileTree';
|
|
3
3
|
import { sortFileTree } from '../sortFileTree';
|
|
4
4
|
import { disableSettings } from '../featureFlags';
|
|
5
5
|
import { Listing } from './Listing';
|
|
6
|
-
import { useOpenDirectories } from './useOpenDirectories';
|
|
7
6
|
|
|
8
7
|
import './styles.scss';
|
|
9
8
|
import { FileId, FileTree, FileTreeFile, Files } from '../../types';
|
|
@@ -15,7 +14,8 @@ export const Sidebar = ({
|
|
|
15
14
|
handleDeleteFileClick,
|
|
16
15
|
handleFileClick,
|
|
17
16
|
setIsSettingsOpen,
|
|
18
|
-
|
|
17
|
+
isDirectoryOpen,
|
|
18
|
+
toggleDirectory,
|
|
19
19
|
}: {
|
|
20
20
|
files: Files;
|
|
21
21
|
createFile?: () => void;
|
|
@@ -23,14 +23,17 @@ export const Sidebar = ({
|
|
|
23
23
|
handleDeleteFileClick?: (fileId: FileId, event: React.MouseEvent) => void;
|
|
24
24
|
handleFileClick?: (fileId: FileId) => void;
|
|
25
25
|
setIsSettingsOpen?: (isSettingsOpen: boolean) => void;
|
|
26
|
-
|
|
26
|
+
isDirectoryOpen?: (path: string) => boolean;
|
|
27
|
+
toggleDirectory?: (path: string) => void;
|
|
27
28
|
}) => {
|
|
28
29
|
const fileTree = useMemo(
|
|
29
30
|
() => (files ? sortFileTree(getFileTree(files)) : null),
|
|
30
|
-
[files]
|
|
31
|
+
[files],
|
|
31
32
|
);
|
|
32
33
|
|
|
33
|
-
const
|
|
34
|
+
const handleSettingsClick = useCallback(() => {
|
|
35
|
+
setIsSettingsOpen(true);
|
|
36
|
+
}, []);
|
|
34
37
|
|
|
35
38
|
return (
|
|
36
39
|
<div className="vz-sidebar">
|
|
@@ -53,14 +56,15 @@ export const Sidebar = ({
|
|
|
53
56
|
? fileTree.children.map((entity) => {
|
|
54
57
|
const { fileId } = entity as FileTreeFile;
|
|
55
58
|
const { path } = entity as FileTree;
|
|
59
|
+
const key = fileId ? fileId : path;
|
|
56
60
|
return (
|
|
57
61
|
<Listing
|
|
58
62
|
entity={entity}
|
|
59
|
-
key={
|
|
63
|
+
key={key}
|
|
60
64
|
handleRenameFileClick={handleRenameFileClick}
|
|
61
65
|
handleDeleteFileClick={handleDeleteFileClick}
|
|
62
66
|
handleFileClick={handleFileClick}
|
|
63
|
-
|
|
67
|
+
isDirectoryOpen={isDirectoryOpen}
|
|
64
68
|
toggleDirectory={toggleDirectory}
|
|
65
69
|
/>
|
|
66
70
|
);
|
|
@@ -68,10 +72,7 @@ export const Sidebar = ({
|
|
|
68
72
|
: null}
|
|
69
73
|
</div>
|
|
70
74
|
{disableSettings ? null : (
|
|
71
|
-
<div
|
|
72
|
-
className="settings"
|
|
73
|
-
onClick={() => setIsSettingsOpen(!isSettingsOpen)}
|
|
74
|
-
>
|
|
75
|
+
<div className="settings" onClick={handleSettingsClick}>
|
|
75
76
|
Settings
|
|
76
77
|
</div>
|
|
77
78
|
)}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { useCallback, useMemo } from 'react';
|
|
2
|
+
import { FileId, Files } from '../../types';
|
|
3
|
+
import './style.scss';
|
|
4
|
+
|
|
5
|
+
// Supports adding the file's containing folder to the tab name
|
|
6
|
+
const fileNameSplit = (fileName) => {
|
|
7
|
+
const split = fileName.split('/');
|
|
8
|
+
if (split.length === 1) return split[split.length - 1];
|
|
9
|
+
return split[split.length - 2] + '/' + split[split.length - 1];
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
const Tab = ({
|
|
13
|
+
fileId,
|
|
14
|
+
isActive,
|
|
15
|
+
setActiveFileId,
|
|
16
|
+
closeTab,
|
|
17
|
+
fileName,
|
|
18
|
+
}: {
|
|
19
|
+
fileId: FileId;
|
|
20
|
+
isActive: boolean;
|
|
21
|
+
setActiveFileId: (fileId: FileId) => void;
|
|
22
|
+
closeTab: (fileId: FileId) => void;
|
|
23
|
+
fileName: string;
|
|
24
|
+
}) => {
|
|
25
|
+
const handleCloseClick = useCallback(
|
|
26
|
+
(event: React.MouseEvent) => {
|
|
27
|
+
// Stop propagation so that the outer listener doesn't fire.
|
|
28
|
+
event.stopPropagation();
|
|
29
|
+
|
|
30
|
+
closeTab(fileId);
|
|
31
|
+
},
|
|
32
|
+
[closeTab, fileId],
|
|
33
|
+
);
|
|
34
|
+
|
|
35
|
+
const tabName = useMemo(() => fileNameSplit(fileName), [fileName]);
|
|
36
|
+
|
|
37
|
+
return (
|
|
38
|
+
<div
|
|
39
|
+
className={isActive ? 'tab active' : 'tab'}
|
|
40
|
+
onClick={() => {
|
|
41
|
+
setActiveFileId(fileId);
|
|
42
|
+
}}
|
|
43
|
+
>
|
|
44
|
+
{tabName}
|
|
45
|
+
<div
|
|
46
|
+
className={isActive ? 'bx bx-x tab-close' : ''}
|
|
47
|
+
onClick={handleCloseClick}
|
|
48
|
+
></div>
|
|
49
|
+
</div>
|
|
50
|
+
);
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
// Displays the list of tabs above the code editor.
|
|
54
|
+
export const TabList = ({
|
|
55
|
+
files,
|
|
56
|
+
tabList,
|
|
57
|
+
activeFileId,
|
|
58
|
+
setActiveFileId,
|
|
59
|
+
closeTab,
|
|
60
|
+
}: {
|
|
61
|
+
files: Files;
|
|
62
|
+
tabList: FileId[];
|
|
63
|
+
activeFileId: FileId;
|
|
64
|
+
setActiveFileId: (fileId: FileId) => void;
|
|
65
|
+
closeTab: (fileId: FileId) => void;
|
|
66
|
+
}) => {
|
|
67
|
+
return (
|
|
68
|
+
<div className="vz-tab-list">
|
|
69
|
+
{files &&
|
|
70
|
+
tabList.map((fileId: FileId) => (
|
|
71
|
+
<Tab
|
|
72
|
+
key={fileId}
|
|
73
|
+
fileId={fileId}
|
|
74
|
+
isActive={fileId === activeFileId}
|
|
75
|
+
setActiveFileId={setActiveFileId}
|
|
76
|
+
closeTab={closeTab}
|
|
77
|
+
fileName={files[fileId].name}
|
|
78
|
+
/>
|
|
79
|
+
))}
|
|
80
|
+
</div>
|
|
81
|
+
);
|
|
82
|
+
};
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
.vz-tab-list {
|
|
2
|
+
display: flex;
|
|
3
|
+
background-color: rgb(37, 37, 38);
|
|
4
|
+
|
|
5
|
+
.tab {
|
|
6
|
+
background: rgb(45, 45, 45);
|
|
7
|
+
color: #fff;
|
|
8
|
+
font-family: var(--vzcode-font-family);
|
|
9
|
+
align-items: center;
|
|
10
|
+
display: flex;
|
|
11
|
+
padding-top: 2px;
|
|
12
|
+
padding-left: 10px;
|
|
13
|
+
padding-right: 10px;
|
|
14
|
+
border-top-left-radius: 10px;
|
|
15
|
+
border-top-right-radius: 10px;
|
|
16
|
+
margin-top: 5px;
|
|
17
|
+
margin-left: 2px;
|
|
18
|
+
margin-right: 2px;
|
|
19
|
+
cursor: pointer;
|
|
20
|
+
|
|
21
|
+
.tab-close {
|
|
22
|
+
margin-left: 5px;
|
|
23
|
+
}
|
|
24
|
+
.tab-close:hover {
|
|
25
|
+
border-radius: 30%;
|
|
26
|
+
background-color: rgb(37, 37, 38);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
.tab.active {
|
|
30
|
+
background-color: rgb(41, 44, 52);
|
|
31
|
+
}
|
|
32
|
+
}
|
package/src/client/main.jsx
CHANGED
package/src/client/style.scss
CHANGED
|
@@ -45,49 +45,5 @@ body,
|
|
|
45
45
|
flex: 1;
|
|
46
46
|
flex-direction: column;
|
|
47
47
|
min-width: 0;
|
|
48
|
-
|
|
49
|
-
.tab-list {
|
|
50
|
-
display: flex;
|
|
51
|
-
background-color: rgb(37, 37, 38);
|
|
52
|
-
|
|
53
|
-
.tab {
|
|
54
|
-
background: rgb(45, 45, 45);
|
|
55
|
-
color: #fff;
|
|
56
|
-
font-family: var(--vzcode-font-family);
|
|
57
|
-
align-items: center;
|
|
58
|
-
display: flex;
|
|
59
|
-
padding-top: 2px;
|
|
60
|
-
padding-left: 10px;
|
|
61
|
-
padding-right: 10px;
|
|
62
|
-
border-top-left-radius: 10px;
|
|
63
|
-
border-top-right-radius: 10px;
|
|
64
|
-
margin-top: 5px;
|
|
65
|
-
margin-left: 2px;
|
|
66
|
-
margin-right: 2px;
|
|
67
|
-
cursor: pointer;
|
|
68
|
-
|
|
69
|
-
.tab-close {
|
|
70
|
-
margin-left: 5px;
|
|
71
|
-
}
|
|
72
|
-
.tab-close:hover {
|
|
73
|
-
border-radius: 30%;
|
|
74
|
-
background-color: rgb(37, 37, 38);
|
|
75
|
-
}
|
|
76
|
-
}
|
|
77
|
-
.tab.active {
|
|
78
|
-
background-color: rgb(41, 44, 52);
|
|
79
|
-
}
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
.editor {
|
|
83
|
-
flex: 1;
|
|
84
|
-
display: flex;
|
|
85
|
-
font-family: var(--vzcode-font-family);
|
|
86
|
-
overflow-x: auto;
|
|
87
|
-
}
|
|
88
48
|
}
|
|
89
49
|
}
|
|
90
|
-
|
|
91
|
-
.themes {
|
|
92
|
-
color: rgb(37, 37, 38);
|
|
93
|
-
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { oneDark } from '@codemirror/theme-one-dark';
|
|
2
|
+
import { okaidia } from '@uiw/codemirror-theme-okaidia';
|
|
3
|
+
import { abcdef } from '@uiw/codemirror-theme-abcdef';
|
|
4
|
+
import { dracula } from '@uiw/codemirror-theme-dracula';
|
|
5
|
+
import { eclipse } from '@uiw/codemirror-theme-eclipse';
|
|
6
|
+
import { githubDark } from '@uiw/codemirror-theme-github';
|
|
7
|
+
import { material } from '@uiw/codemirror-theme-material';
|
|
8
|
+
import { nord } from '@uiw/codemirror-theme-nord';
|
|
9
|
+
import { xcodeLight } from '@uiw/codemirror-theme-xcode';
|
|
10
|
+
|
|
11
|
+
// CodeMirror themes
|
|
12
|
+
export const themes = [
|
|
13
|
+
{ value: abcdef, label: 'abcdef' },
|
|
14
|
+
{ value: dracula, label: 'darcula' },
|
|
15
|
+
{ value: eclipse, label: 'eclipse' },
|
|
16
|
+
{ value: material, label: 'material' },
|
|
17
|
+
{ value: nord, label: 'nord' },
|
|
18
|
+
{ value: oneDark, label: 'oneDark' },
|
|
19
|
+
{ value: okaidia, label: 'okaidia' },
|
|
20
|
+
{ value: githubDark, label: 'github' },
|
|
21
|
+
{ value: xcodeLight, label: 'xcode' },
|
|
22
|
+
];
|
|
23
|
+
|
|
24
|
+
// Map theme labels to theme values
|
|
25
|
+
export const themesByLabel = themes.reduce((acc, theme) => {
|
|
26
|
+
acc[theme.label] = theme.value;
|
|
27
|
+
return acc;
|
|
28
|
+
}, {});
|
|
29
|
+
|
|
30
|
+
// The default theme
|
|
31
|
+
export const defaultTheme = 'oneDark';
|
|
@@ -18,20 +18,26 @@ import { useState, useCallback } from 'react';
|
|
|
18
18
|
// Inspired by
|
|
19
19
|
// https://github.com/vizhub-core/vizhub/blob/main/vizhub-v2/packages/neoFrontend/src/pages/VizPage/Body/Editor/FilesSection/useOpenDirectories.js
|
|
20
20
|
export const useOpenDirectories = () => {
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
21
|
+
// The set of open directories by path/
|
|
22
|
+
const [openDirectories, setOpenDirectories] = useState<Set<string>>(
|
|
23
|
+
new Set(),
|
|
24
|
+
);
|
|
25
|
+
|
|
26
|
+
// Whether a directory is open.
|
|
27
|
+
const isDirectoryOpen: (path: string) => boolean = useCallback(
|
|
28
|
+
(path) => openDirectories.has(path),
|
|
29
|
+
[openDirectories],
|
|
30
|
+
);
|
|
24
31
|
|
|
25
|
-
//
|
|
32
|
+
// Toggle whether a directory is open.
|
|
26
33
|
const toggleDirectory = useCallback(
|
|
27
34
|
(path) => {
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
})
|
|
32
|
-
);
|
|
35
|
+
const newOpenDirectories = new Set(openDirectories);
|
|
36
|
+
newOpenDirectories[isDirectoryOpen(path) ? 'delete' : 'add'](path);
|
|
37
|
+
setOpenDirectories(newOpenDirectories);
|
|
33
38
|
},
|
|
34
|
-
[openDirectories]
|
|
39
|
+
[openDirectories, isDirectoryOpen],
|
|
35
40
|
);
|
|
36
|
-
|
|
41
|
+
|
|
42
|
+
return { isDirectoryOpen, toggleDirectory };
|
|
37
43
|
};
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { useCallback } from 'react';
|
|
2
|
+
import { FileId } from '../types';
|
|
3
|
+
|
|
4
|
+
// Encapsulates the state and logic for managing the list of open tabs.
|
|
5
|
+
export const useTabsState = (
|
|
6
|
+
activeFileId: FileId,
|
|
7
|
+
setActiveFileId: (fileId: FileId | null) => void,
|
|
8
|
+
tabList: Array<FileId>,
|
|
9
|
+
setTabList: (tabList: Array<FileId>) => void,
|
|
10
|
+
) => {
|
|
11
|
+
// Called when a tab is closed.
|
|
12
|
+
const closeTab = useCallback(
|
|
13
|
+
(fileIdToRemove: FileId) => {
|
|
14
|
+
const i = tabList.findIndex((fileId) => fileId === fileIdToRemove);
|
|
15
|
+
|
|
16
|
+
// Support calling `closeTab` on a fileId that is not open,
|
|
17
|
+
// such as when a file is deleted..
|
|
18
|
+
if (i !== -1) {
|
|
19
|
+
// Remove the tab from the tab list.
|
|
20
|
+
const newTabList = [...tabList.slice(0, i), ...tabList.slice(i + 1)];
|
|
21
|
+
setTabList(newTabList);
|
|
22
|
+
|
|
23
|
+
// If we are closing the active file,
|
|
24
|
+
if (activeFileId === fileIdToRemove) {
|
|
25
|
+
// set the new active file to the next tab over,
|
|
26
|
+
if (newTabList.length > 0) {
|
|
27
|
+
setActiveFileId(i === 0 ? newTabList[i] : newTabList[i - 1]);
|
|
28
|
+
} else {
|
|
29
|
+
// or clear out the active file
|
|
30
|
+
// if we've closed the last tab.
|
|
31
|
+
setActiveFileId(null);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
},
|
|
36
|
+
[tabList, activeFileId],
|
|
37
|
+
);
|
|
38
|
+
|
|
39
|
+
// Called when a tab is opened.
|
|
40
|
+
const openTab = useCallback(
|
|
41
|
+
(fileId: FileId) => {
|
|
42
|
+
setActiveFileId(fileId);
|
|
43
|
+
if (!tabList.includes(fileId)) {
|
|
44
|
+
setTabList([...tabList, fileId]);
|
|
45
|
+
}
|
|
46
|
+
},
|
|
47
|
+
[tabList],
|
|
48
|
+
);
|
|
49
|
+
|
|
50
|
+
return { tabList, closeTab, openTab };
|
|
51
|
+
};
|
package/src/types.ts
CHANGED
|
@@ -55,3 +55,15 @@ export interface FileTreeFile {
|
|
|
55
55
|
file: File;
|
|
56
56
|
fileId: FileId;
|
|
57
57
|
}
|
|
58
|
+
|
|
59
|
+
// `ShareDBDoc`
|
|
60
|
+
// A ShareDB Document.
|
|
61
|
+
// TODO better types? Integrate with upstream how?
|
|
62
|
+
// https://github.com/DefinitelyTyped/DefinitelyTyped/blob/master/types/sharedb/lib/sharedb.d.ts#L110
|
|
63
|
+
export type ShareDBDoc<T> = {
|
|
64
|
+
data: T;
|
|
65
|
+
ingestSnapshot: (snapshot: any, callback) => void;
|
|
66
|
+
subscribe: (callback) => void;
|
|
67
|
+
on: (event: string, callback: () => void) => void;
|
|
68
|
+
off: (event: string, callback: () => void) => void;
|
|
69
|
+
};
|