diff --git a/package.json b/package.json index ca0d54527..77fde1fbb 100644 --- a/package.json +++ b/package.json @@ -42,6 +42,7 @@ "@mdi/react": "^1.6.1", "@mdx-js/react": "^3.1.1", "@mermaid-js/layout-elk": "^0.2.1", + "@pierre/trees": "^1.0.0-beta.6", "@sentry/react": "^10.64.0", "@sentry/webpack-plugin": "^3.3.1", "ace-builds": "^1.44.0", diff --git a/src/api/OfflineApi/index.ts b/src/api/OfflineApi/index.ts index fbb3491f4..f94ac11fb 100644 --- a/src/api/OfflineApi/index.ts +++ b/src/api/OfflineApi/index.ts @@ -13,7 +13,7 @@ import _ from 'es-toolkit/compat'; import { DB_NAME } from '@tdev-api/config'; const TIME_NOW = new Date().toISOString(); -const LOG_REQUESTS = false; +const LOG_REQUESTS = true; export const DEFAULT_OFFLINE_USER: User = { id: 'c23c0238-4aeb-457f-9a2c-3d2d5d8931c0', @@ -406,7 +406,7 @@ export default class OfflineApi { if (parts[0] === 'linkTo') { document = { ...document, - documentRootId: parts[1], + parentId: parts[1], updatedAt: updatedAt }; } else { diff --git a/src/components/HomepageFeatures/index.tsx b/src/components/HomepageFeatures/index.tsx index 8d37750df..17f4b56fa 100644 --- a/src/components/HomepageFeatures/index.tsx +++ b/src/components/HomepageFeatures/index.tsx @@ -1,4 +1,4 @@ -import clsx from 'clsx'; +import React from 'react'; import styles from './styles.module.scss'; import { useStore } from '@tdev-hooks/useStore'; import { observer } from 'mobx-react-lite'; @@ -10,76 +10,101 @@ import { useIsLive } from '@tdev-hooks/useIsLive'; import Card from '@tdev-components/shared/Card'; import customFields from '@tdev-components/utils/customFields'; const { BACKEND_URL, NO_AUTH, OFFLINE_API } = customFields; +import { useFirstMainDocument } from '@tdev-hooks/useFirstMainDocument'; +import { ModelMeta } from '@tdev-models/documents/FileSystem/Directory'; +import { MetaInit } from '@tdev-models/documents/FileSystem/iFileSystem'; +import DocumentFileTree from '@tdev-components/documents/FileSystem/DocumentFileTree'; const HomepageFeatures = observer(() => { const socketStore = useStore('socketStore'); const sessionStore = useStore('sessionStore'); const userStore = useStore('userStore'); const isLive = useIsLive(); + const [meta] = React.useState( + new ModelMeta({ id: '2686fc4e-10e7-4288-bf41-e6175e489b8e', readonly: false } as MetaInit) + ); + const root = useFirstMainDocument('2686fc4e-10e7-4288-bf41-e6175e489b8e', meta); + return ( -
- {sessionStore.apiMode === 'api' ? ( -
-

Socket.IO

- -
URL
-
{BACKEND_URL}
-
Connected?
-
- {isLive ? ( - - {' '} - Live - - ) : ( - - {' '} - Offline - - )} -
- {isLive && ( - <> -
Clients
-
- {socketStore.connectedClients.get(userStore.viewedUser?.id ?? '') ?? 0} -
- - )} -
Offline API
-
{OFFLINE_API || '-'}
-
No Auth
-
{NO_AUTH ? 'Ja' : 'Nein'}
-
Connection
-
-
-
-
-
-
- ) : ( - -

Willkommen 🥳

-
+ <> + {root && ( +
+ +
)} -
+
+ {sessionStore.apiMode === 'api' ? ( +
+

Socket.IO

+ +
URL
+
{BACKEND_URL}
+
Connected?
+
+ {isLive ? ( + + {' '} + Live + + ) : ( + + {' '} + Offline + + )} +
+ {isLive && ( + <> +
Clients
+
+ {socketStore.connectedClients.get(userStore.viewedUser?.id ?? '') ?? + 0} +
+ + )} +
Offline API
+
{OFFLINE_API || '-'}
+
No Auth
+
{NO_AUTH ? 'Ja' : 'Nein'}
+
Connection
+
+
+
+
+
+
+ ) : ( + +

Willkommen 🥳

+
+ )} +
+ ); }); diff --git a/src/components/documents/FileSystem/DocumentFileTree/index.tsx b/src/components/documents/FileSystem/DocumentFileTree/index.tsx new file mode 100644 index 000000000..3485c4c25 --- /dev/null +++ b/src/components/documents/FileSystem/DocumentFileTree/index.tsx @@ -0,0 +1,155 @@ +import React from 'react'; +import { useStore } from '@tdev-hooks/useStore'; +import { observer } from 'mobx-react-lite'; +import { FileTree, useFileTree } from '@pierre/trees/react'; +import Directory from '@tdev-models/documents/FileSystem/Directory'; +import { preparePresortedFileTreeInput } from '@pierre/trees'; +import { isFileSystemType } from '@tdev-models/documents/FileSystem/iFileSystem'; +import styles from './styles.module.scss'; +import clsx from 'clsx'; +import { default as FileModel } from '@tdev-models/documents/FileSystem/File'; +import File from '../File'; +import DocumentView from '../DocumentView'; +import { mdiExcalidraw } from '@tdev/excalidoc/Component'; + +interface Props { + dir: Directory; +} +const DocumentFileTree = observer((props: Props) => { + const { dir } = props; + const dirId = dir.id; + const rootId = dir.documentRootId; + const documentStore = useStore('documentStore'); + const docRootStore = useStore('documentRootStore'); + const [selected, setSelected] = React.useState(null); + const { model } = useFileTree({ + preparedInput: preparePresortedFileTreeInput(dir?.fileTree ?? []), + search: true, + id: dir.id, + initialVisibleRowCount: 11, + icons: { + set: 'complete', + colored: true, + spriteSheet: ` + + `, + byFileExtension: { + excalidraw: 'mdi-excalidraw' + } + }, + renaming: { + canRename: (item) => item.path !== 'package.json', + onRename: ({ sourcePath, destinationPath }) => { + const dir = documentStore.find(dirId) as Directory; + const file = dir.allFiles.find((d) => d.filePath === sourcePath); + const hasConflict = dir.allFiles.some((d) => d.filePath === destinationPath); + if (!file) { + console.error(`File not found for path: ${sourcePath}`); + return; + } + if (hasConflict) { + console.error(`Destination path already exists: ${destinationPath}`); + return; + } + const newName = destinationPath.replace(file.basePath, ''); + if (newName.includes('/')) { + console.error(`New name cannot contain slashes: ${newName}`); + return; + } + file.setName(newName); + }, + onError: (message) => { + console.error(message); + } + }, + dragAndDrop: { + canDrag: (draggedPaths) => true, + canDrop: ({ target }) => true, + onDropComplete: ({ draggedPaths, target }) => { + const targetDoc = + target.kind === 'root' + ? dir + : dir.allFiles.find((d) => d.filePath === target.directoryPath); + const files = dir.allFiles.filter((d) => draggedPaths.includes(d.filePath)); + if (targetDoc && files.length > 0) { + files.forEach((f) => { + documentStore.relinkParent(f, targetDoc); + }); + } + }, + onDropError: (message) => { + console.error(message); + } + }, + composition: { + contextMenu: { + enabled: true, + triggerMode: 'both', + buttonVisibility: 'when-needed' + } + }, + flattenEmptyDirectories: false, + onSelectionChange: (selectedPaths) => { + const root = docRootStore.find<'dir'>(rootId); + if (!root) { + return; + } + const selected = root.documents + .filter((d) => isFileSystemType(d)) + .filter((d) => selectedPaths.includes(d.filePath)); + if (selected.length === 1) { + if (selected[0].type === 'file') { + setSelected(selected[0]); + } + selected[0].setIsOpen(true); + } + } + }); + + /** + * useFileTree only builds the model once, from the initial props - it never + * reacts to prop changes. Paths often arrive asynchronously (e.g. after a + * page reload, before the document is fetched), so the tree must be + * re-synced whenever the paths change after mount. + */ + React.useEffect(() => { + const openPaths = dir?.allFiles + .filter((d) => d.type === 'dir' && d.filePath && d.isOpen) + .map((d) => d.filePath); + console.log('open', openPaths); + model.resetPaths({ + preparedInput: preparePresortedFileTreeInput(dir.fileTree ?? []), + initialExpandedPaths: openPaths + }); + }, [model, dir.fileTree]); + + return ( +
+ ( +
+ +
+ )} + /> +
{}
+
+ ); +}); + +export default DocumentFileTree; diff --git a/src/components/documents/FileSystem/DocumentFileTree/styles.module.scss b/src/components/documents/FileSystem/DocumentFileTree/styles.module.scss new file mode 100644 index 000000000..50483d362 --- /dev/null +++ b/src/components/documents/FileSystem/DocumentFileTree/styles.module.scss @@ -0,0 +1,30 @@ +.container { + display: flex; + align-items: flex-start; + width: 100%; + flex-wrap: wrap; + .tree { + --trees-search-bg: var(--ifm-background-surface-color); + --trees-bg: var(--ifm-background-color); + // --trees-search-fg + // --trees-border-color + // --trees-accent + // --trees-fg-muted + // --trees-bg-muted + color-scheme: light; + } + .selectedFile { + flex-grow: 1; + flex-shrink: 1; + flex-basis: 300px; + max-width: 1200px; + } +} + +[data-theme='dark'] { + .container { + .tree { + color-scheme: dark; + } + } +} diff --git a/src/components/documents/FileSystem/File/CodeEditorSelector.tsx b/src/components/documents/FileSystem/DocumentView/CodeEditorSelector.tsx similarity index 100% rename from src/components/documents/FileSystem/File/CodeEditorSelector.tsx rename to src/components/documents/FileSystem/DocumentView/CodeEditorSelector.tsx diff --git a/src/components/documents/FileSystem/DocumentView/index.tsx b/src/components/documents/FileSystem/DocumentView/index.tsx new file mode 100644 index 000000000..58862b574 --- /dev/null +++ b/src/components/documents/FileSystem/DocumentView/index.tsx @@ -0,0 +1,31 @@ +import React from 'react'; +import styles from './styles.module.scss'; +import { observer } from 'mobx-react-lite'; +import { QuillV2Component } from '../../QuillV2'; +import { ExcalidocComponent } from '@tdev/excalidoc/Component'; +import CodeEditorSelector from './CodeEditorSelector'; +import iCode from '@tdev-models/documents/iCode'; +import { DocumentModelType } from '@tdev-api/document'; + +interface Props { + document?: DocumentModelType; +} + +const DocumentView = observer((props: Props) => { + const { document } = props; + if (!document) { + return null; + } + if (document.type === 'script' || document.type === 'pyodide_code' || document.type === 'code') { + return ; + } + if (document.type === 'quill_v2') { + return ; + } + if (document.type === 'excalidoc') { + return ; + } + return null; +}); + +export default DocumentView; diff --git a/src/components/documents/FileSystem/DocumentView/styles.module.scss b/src/components/documents/FileSystem/DocumentView/styles.module.scss new file mode 100644 index 000000000..a31cbd5fc --- /dev/null +++ b/src/components/documents/FileSystem/DocumentView/styles.module.scss @@ -0,0 +1,3 @@ +.quill { + margin-bottom: 0; +} diff --git a/src/components/documents/FileSystem/File/index.tsx b/src/components/documents/FileSystem/File/index.tsx index 108d836da..c8bb1eeed 100644 --- a/src/components/documents/FileSystem/File/index.tsx +++ b/src/components/documents/FileSystem/File/index.tsx @@ -15,21 +15,15 @@ import { } from '@mdi/js'; import SyncStatus from '../../../SyncStatus'; import { DocumentType } from '@tdev-api/document'; -import { QuillV2Component } from '../../QuillV2'; import Actions from '../Actions'; import Name from '../Name'; import FsDetails from '../FsDetails'; -import { - ExcalidocComponent, - ExcalidrawColor, - mdiExcalidraw, - mdiExcalidrawOutline -} from '@tdev/excalidoc/Component'; -import CodeEditorSelector from './CodeEditorSelector'; -import iCode from '@tdev-models/documents/iCode'; +import { ExcalidrawColor, mdiExcalidraw, mdiExcalidrawOutline } from '@tdev/excalidoc/Component'; +import DocumentView from '../DocumentView'; interface Props { file: FileModel; + className?: string; } const getColor = (type?: DocumentType) => { @@ -72,11 +66,11 @@ const getOpenIcon = (type?: DocumentType) => { }; const File = observer((props: Props) => { - const { file } = props; + const { file, className } = props; return ( { } >
- {file.document && file.isOpen && ( - <> - {file.document.type === 'script' && ( - - )} - {file.document.type === 'quill_v2' && ( - - )} - {file.document.type === 'excalidoc' && ( - - )} - - )} + {file.isOpen && }
); diff --git a/src/components/documents/FileSystem/File/styles.module.scss b/src/components/documents/FileSystem/File/styles.module.scss index 395195743..ef64b76cf 100644 --- a/src/components/documents/FileSystem/File/styles.module.scss +++ b/src/components/documents/FileSystem/File/styles.module.scss @@ -7,8 +7,5 @@ } .content { padding: 1px; - .quill { - margin-bottom: 0; - } } } diff --git a/src/models/documents/FileSystem/Directory.ts b/src/models/documents/FileSystem/Directory.ts index 50b1b9203..f2ee7b7cd 100644 --- a/src/models/documents/FileSystem/Directory.ts +++ b/src/models/documents/FileSystem/Directory.ts @@ -1,9 +1,10 @@ import { computed } from 'mobx'; -import { DocumentType, Document as DocumentProps } from '@tdev-api/document'; +import { Document as DocumentProps } from '@tdev-api/document'; import DocumentStore from '@tdev-stores/DocumentStore'; import _ from 'es-toolkit/compat'; +import { orderBy } from 'es-toolkit/array'; import File from './File'; -import iFileSystem, { DefaultName, iFSMeta, MetaInit } from './iFileSystem'; +import iFileSystem, { DefaultName, iFSMeta, isFileSystemType, MetaInit } from './iFileSystem'; import { formatDateTime } from '@tdev-models/helpers/date'; export class ModelMeta extends iFSMeta<'dir'> { @@ -27,14 +28,22 @@ class Directory extends iFileSystem<'dir'> { return new ModelMeta({}); } + @computed + get allFiles(): iFileSystem[] { + const files = this.root?.documents.filter( + (d) => isFileSystemType(d) && d.filePath.startsWith(this.filePath) + ) as iFileSystem[]; + return orderBy(files || [], ['filePath'], ['asc']); + } + @computed get files() { if (!this.root) { return []; } - return _.orderBy( + return orderBy( this.root.documents.filter((d) => d.parentId === this.id && d.type === 'file') as File[], - [(f) => `${f.name}`.replace(/\d+/g, (n) => n.padStart(10, '0'))], + [(f) => `${f.name}`], ['asc'] ); } @@ -44,12 +53,27 @@ class Directory extends iFileSystem<'dir'> { if (!this.root) { return []; } - return _.orderBy( + return orderBy( this.root.documents.filter((d) => d.parentId === this.id && d.type === 'dir') as Directory[], - [(d) => `${d.name}`.replace(/\d+/g, (n) => n.padStart(10, '0'))], + [(d) => `${d.name}`], ['asc'] ); } + + @computed + get fileTree(): string[] { + // returns relative to self path all files having the current directory as parent. + const basePath = this.parentId ? `${this.name || this.id}/` : ''; + const directoryTrees = this.directories.flatMap((d) => { + return d.fileTree.map((p) => `${basePath}${p}`); + }); + const filePaths = this.files.map((f) => `${basePath}${f.name || f.id}`); + const n = directoryTrees.length + filePaths.length; + if (n === 0) { + return [basePath]; + } + return [...directoryTrees, ...filePaths]; + } } export default Directory; diff --git a/src/models/documents/FileSystem/File.ts b/src/models/documents/FileSystem/File.ts index ccb7928d5..e217774d5 100644 --- a/src/models/documents/FileSystem/File.ts +++ b/src/models/documents/FileSystem/File.ts @@ -1,5 +1,5 @@ import { computed } from 'mobx'; -import { DocumentType, Document as DocumentProps } from '@tdev-api/document'; +import { Document as DocumentProps } from '@tdev-api/document'; import DocumentStore from '@tdev-stores/DocumentStore'; import _ from 'es-toolkit/compat'; import iFileSystem, { DefaultName, iFSMeta, MetaInit } from './iFileSystem'; diff --git a/src/models/documents/FileSystem/iFileSystem.ts b/src/models/documents/FileSystem/iFileSystem.ts index 51c3485ce..c1aa72dc3 100644 --- a/src/models/documents/FileSystem/iFileSystem.ts +++ b/src/models/documents/FileSystem/iFileSystem.ts @@ -1,6 +1,11 @@ import { action, computed, observable } from 'mobx'; import iDocument, { Source } from '@tdev-models/iDocument'; -import { Document as DocumentProps, TypeDataMapping, Access, DocumentModelType } from '@tdev-api/document'; +import { + Document as DocumentProps, + TypeDataMapping, + DocumentType, + DocumentModelType +} from '@tdev-api/document'; import DocumentStore from '@tdev-stores/DocumentStore'; import { TypeMeta } from '@tdev-models/DocumentRoot'; import { formatDateTime } from '@tdev-models/helpers/date'; @@ -13,11 +18,13 @@ export interface MetaInit { type SystemType = 'file' | 'dir'; -export const DefaultName = { +export const DefaultName: Record = { ['file']: 'Dokument', ['dir']: 'Ordner' }; +export const SystemDocumentTypes: SystemType[] = Object.keys(DefaultName) as SystemType[]; + export class iFSMeta extends TypeMeta { readonly readonly?: boolean; readonly name: string; @@ -36,7 +43,7 @@ export class iFSMeta extends TypeMeta { } } -abstract class iFileSystem extends iDocument { +abstract class iFileSystem extends iDocument { @observable accessor name: string; @observable accessor isOpen: boolean = true; @observable accessor isEditing: boolean = false; @@ -77,7 +84,34 @@ abstract class iFileSystem extends iDocument { abstract get meta(): iFSMeta; @computed - get path() { + get filePath(): string { + if (!this.parentId) { + if (this.type === 'dir') { + return ''; + } + return this.name; + } + const name = this.type === 'dir' ? `${this.name}/` : this.name; + if (this.parent?.type !== 'dir') { + return name; + } + const basePath = this.parent.filePath ? this.parent.filePath : ''; + return `${basePath}${name}`; + } + + @computed + get basePath(): string { + if (!this.parentId) { + return ''; + } + if (this.parent?.type !== 'dir') { + return ''; + } + return this.parent.filePath; + } + + @computed + get path(): DocumentModelType[] { const path: DocumentModelType[] = []; let parent = this.parent; while (parent) { @@ -112,4 +146,8 @@ abstract class iFileSystem extends iDocument { } } +export const isFileSystemType = (doc: iDocument): doc is iFileSystem => { + return doc.type === 'file' || doc.type === 'dir'; +}; + export default iFileSystem; diff --git a/src/stores/DocumentStore.ts b/src/stores/DocumentStore.ts index 9d3aa8ad8..309f37c24 100644 --- a/src/stores/DocumentStore.ts +++ b/src/stores/DocumentStore.ts @@ -40,6 +40,8 @@ import ChoiceAnswer from '@tdev-models/documents/Assessable/ChoiceAnswer'; import TrueFalseAnswer from '@tdev-models/documents/Assessable/TrueFalseAnswer'; import Quiz from '@tdev-models/documents/Assessable/Quiz'; import { isStalledUpdate } from '@tdev/helpers/isStalledUpdate'; +import Unknown from '@tdev-models/documents/Unknown'; +import iFileSystem from '@tdev-models/documents/FileSystem/iFileSystem'; const IsNotUniqueError = (error: any) => { try { @@ -456,11 +458,13 @@ class DocumentStore extends iStore<`delete-${string}`> { } @action - relinkParent(document: DocumentModelType, newParent: DocumentModelType) { + relinkParent(document: DocumentModelType | iFileSystem, newParent: DocumentModelType | iFileSystem) { + console.log('Relinking', document.id, 'to new parent', newParent.id); return this.withAbortController(`save-${document.id}`, (sig) => { return apiLinkTo(document.id, newParent.id, sig.signal); }) .then((res) => { + console.log('Relinking successful', res.data); this.addToStore(res.data); }) .catch((err) => { diff --git a/yarn.lock b/yarn.lock index 75673d5c0..1c1d1c388 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4103,6 +4103,20 @@ tslib "^2.8.1" tsyringe "^4.10.0" +"@pierre/theming@1.0.0": + version "1.0.0" + resolved "https://registry.yarnpkg.com/@pierre/theming/-/theming-1.0.0.tgz#520e0f857c1b762e975f5b7a15f74a3e8320b825" + integrity sha512-WsdrnhKfjeyXGDikZmN9pkpeZ5S/cl6EE72feiSc0tlynT1tMYqXqouhuv/foK+PY9OEnebOAVRQn3+rAstR8g== + +"@pierre/trees@^1.0.0-beta.6": + version "1.0.0-beta.6" + resolved "https://registry.yarnpkg.com/@pierre/trees/-/trees-1.0.0-beta.6.tgz#6ba438cf6088b37b3f32f2272733afeff7d5fe07" + integrity sha512-zxeuSFM9TveM7b5XofweJALCtm/tGYV9HZzdbf7Uf+kBxIlUyz24/EHaGRjB0dsmmfDQl2ETz7AWwJ15lhSnpw== + dependencies: + "@pierre/theming" "1.0.0" + preact "11.0.0-beta.0" + preact-render-to-string "6.6.5" + "@pnpm/config.env-replace@^1.1.0": version "1.1.0" resolved "https://registry.yarnpkg.com/@pnpm/config.env-replace/-/config.env-replace-1.1.0.tgz#ab29da53df41e8948a00f2433f085f54de8b3a4c" @@ -13319,6 +13333,16 @@ postcss@^8.4.21, postcss@^8.4.24, postcss@^8.4.33, postcss@^8.5.25, postcss@^8.5 picocolors "^1.1.1" source-map-js "^1.2.1" +preact-render-to-string@6.6.5: + version "6.6.5" + resolved "https://registry.yarnpkg.com/preact-render-to-string/-/preact-render-to-string-6.6.5.tgz#1be84cc8565e3bcfcf2a33cb9c61185e6498ab31" + integrity sha512-O6MHzYNIKYaiSX3bOw0gGZfEbOmlIDtDfWwN1JJdc/T3ihzRT6tGGSEWE088dWrEDGa1u7101q+6fzQnO9XCPA== + +preact@11.0.0-beta.0: + version "11.0.0-beta.0" + resolved "https://registry.yarnpkg.com/preact/-/preact-11.0.0-beta.0.tgz#c6e5c6e8657abf18b4a637eae88b5b377d36973f" + integrity sha512-IcODoASASYwJ9kxz7+MJeiJhvLriwSb4y4mHIyxdgaRZp6kPUud7xytrk/6GZw8U3y6EFJaRb5wi9SrEK+8+lg== + prebuild-install@^7.1.1, prebuild-install@^7.1.3: version "7.1.3" resolved "https://registry.yarnpkg.com/prebuild-install/-/prebuild-install-7.1.3.tgz#d630abad2b147443f20a212917beae68b8092eec"