- {isMetaType && (
+ {isMetaType && metaTypeIdLabel && (
- Page ID
+ {metaTypeIdLabel}
)}
diff --git a/packages/ui/src/components/databases/tables/table-view-page-id-cell.tsx b/packages/ui/src/components/databases/tables/table-view-page-id-cell.tsx
index eaddea694..caedad8eb 100644
--- a/packages/ui/src/components/databases/tables/table-view-page-id-cell.tsx
+++ b/packages/ui/src/components/databases/tables/table-view-page-id-cell.tsx
@@ -1,4 +1,5 @@
import { CollectionViewPageId } from '@colanode/ui/components/databases/collection-view-page-id';
+import { useDatabase } from '@colanode/ui/contexts/database';
import { CollectionRow } from '@colanode/ui/lib/collection-row';
interface TableViewPageIdCellProps {
@@ -6,10 +7,18 @@ interface TableViewPageIdCellProps {
}
export const TableViewPageIdCell = ({ row }: TableViewPageIdCellProps) => {
+ const database = useDatabase();
+ const label = database.isFileMetaType
+ ? 'File ID'
+ : database.isPageMetaType
+ ? 'Page ID'
+ : 'ID';
+
return (
);
};
diff --git a/packages/ui/src/components/databases/tables/table-view-record-create-row.tsx b/packages/ui/src/components/databases/tables/table-view-record-create-row.tsx
index b9c081965..d41f9aa52 100644
--- a/packages/ui/src/components/databases/tables/table-view-record-create-row.tsx
+++ b/packages/ui/src/components/databases/tables/table-view-record-create-row.tsx
@@ -1,6 +1,6 @@
import { Plus } from 'lucide-react';
-import { MetaTypePageCreateButton } from '@colanode/ui/components/pages/meta-type-page-create-button';
+import { MetaTypeCreateButton } from '@colanode/ui/components/pages/meta-type-create-button';
import { useDatabase } from '@colanode/ui/contexts/database';
import { useDatabaseView } from '@colanode/ui/contexts/database-view';
@@ -23,8 +23,7 @@ export const TableViewRecordCreateRow = () => {
Add record
)}
-
>
diff --git a/packages/ui/src/components/documents/document-editor.tsx b/packages/ui/src/components/documents/document-editor.tsx
index 3c751e0ea..2f2b973bf 100644
--- a/packages/ui/src/components/documents/document-editor.tsx
+++ b/packages/ui/src/components/documents/document-editor.tsx
@@ -7,7 +7,7 @@ import {
useEditor,
} from '@tiptap/react';
import { debounce, isEqual } from 'lodash-es';
-import { Fragment, useEffect, useMemo, useRef } from 'react';
+import { Fragment, useEffect, useMemo, useRef, useState } from 'react';
import { toast } from 'sonner';
import {
@@ -207,6 +207,7 @@ export const DocumentEditor = ({
const revisionRef = useRef(state?.revision ?? 0);
const ydocRef = useRef
(buildYDoc(state, updates));
const editorRef = useRef>(null);
+ const [editorReady, setEditorReady] = useState(false);
const debouncedSave = useMemo(
() =>
@@ -413,6 +414,12 @@ export const DocumentEditor = ({
editable: canEdit,
shouldRerenderOnTransaction: false,
autofocus: autoFocus,
+ onCreate: () => {
+ setEditorReady(true);
+ },
+ onDestroy: () => {
+ setEditorReady(false);
+ },
onUpdate: async ({ editor, transaction }) => {
if (transaction.docChanged) {
hasPendingChanges.current = true;
@@ -469,13 +476,13 @@ export const DocumentEditor = ({
return (
<>
- {editor && canEdit && (
+
+ {editor && editorReady && canEdit && (
)}
-
>
);
};
diff --git a/packages/ui/src/components/documents/document.tsx b/packages/ui/src/components/documents/document.tsx
index 48b778213..4a5241a67 100644
--- a/packages/ui/src/components/documents/document.tsx
+++ b/packages/ui/src/components/documents/document.tsx
@@ -1,10 +1,16 @@
+import { lazy, Suspense } from 'react';
import { FocusPosition } from '@tiptap/core';
import { LocalNode } from '@colanode/client/types';
-import { DocumentEditor } from '@colanode/ui/components/documents/document-editor';
import { useWorkspace } from '@colanode/ui/contexts/workspace';
import { useLiveQuery } from '@colanode/ui/hooks/use-live-query';
+const DocumentEditor = lazy(() =>
+ import('@colanode/ui/components/documents/document-editor').then((module) => ({
+ default: module.DocumentEditor,
+ }))
+);
+
interface DocumentProps {
node: LocalNode;
canEdit: boolean;
@@ -34,13 +40,15 @@ export const Document = ({ node, canEdit, autoFocus }: DocumentProps) => {
const updates = documentUpdatesQuery.data ?? [];
return (
-
+
+
+
);
};
diff --git a/packages/ui/src/components/files/meta-type-file-upload-button.tsx b/packages/ui/src/components/files/meta-type-file-upload-button.tsx
index 9a55948d5..ba965a96a 100644
--- a/packages/ui/src/components/files/meta-type-file-upload-button.tsx
+++ b/packages/ui/src/components/files/meta-type-file-upload-button.tsx
@@ -1,61 +1,83 @@
import { Plus } from 'lucide-react';
-import { useMutation } from '@tanstack/react-query';
-import { toast } from 'sonner';
+import { useState } from 'react';
+import { FieldValue } from '@colanode/core';
+import { MetaTypeFileUploadDialog } from '@colanode/ui/components/files/meta-type-file-upload-dialog';
import { useDatabase } from '@colanode/ui/contexts/database';
import { useWorkspace } from '@colanode/ui/contexts/workspace';
import { openFileDialog } from '@colanode/ui/lib/files';
+interface UploadMetaTypeFilesOptions {
+ userId: string;
+ parentId: string;
+ metaTypeId: string;
+ initialFields?: Record;
+}
+
+export const uploadMetaTypeFiles = async ({
+ userId,
+ parentId,
+ metaTypeId,
+ initialFields,
+}: UploadMetaTypeFilesOptions) => {
+ const result = await openFileDialog({ multiple: true });
+ if (result.type !== 'success') {
+ return;
+ }
+
+ for (const tempFile of result.files) {
+ const uploadResult = await window.colanode.executeMutation({
+ type: 'file.create',
+ userId,
+ parentId,
+ tempFileId: tempFile.id,
+ metaTypeId,
+ initialFields,
+ });
+
+ if (!uploadResult.success) {
+ throw new Error(uploadResult.error.message);
+ }
+ }
+};
+
interface MetaTypeFileUploadButtonProps {
label?: string;
className?: string;
+ initialFields?: Record;
}
export const MetaTypeFileUploadButton = ({
label = 'Add file',
className,
+ initialFields,
}: MetaTypeFileUploadButtonProps) => {
const database = useDatabase();
- const workspace = useWorkspace();
-
- const { mutate, isPending } = useMutation({
- mutationFn: async () => {
- const result = await openFileDialog({ multiple: true });
- if (result.type !== 'success') {
- return;
- }
-
- for (const tempFile of result.files) {
- await window.colanode.executeMutation({
- type: 'file.create',
- userId: workspace.userId,
- parentId: database.rootId,
- tempFileId: tempFile.id,
- metaTypeId: database.id,
- });
- }
- },
- onError: (error) => {
- toast.error(error.message);
- },
- });
+ const [open, setOpen] = useState(false);
if (!database.canCreateFile) {
return null;
}
return (
-
+ <>
+
+
+ >
);
};
diff --git a/packages/ui/src/components/files/meta-type-file-upload-dialog.tsx b/packages/ui/src/components/files/meta-type-file-upload-dialog.tsx
new file mode 100644
index 000000000..57cf97d62
--- /dev/null
+++ b/packages/ui/src/components/files/meta-type-file-upload-dialog.tsx
@@ -0,0 +1,171 @@
+import { Upload } from 'lucide-react';
+import { useEffect, useMemo, useState } from 'react';
+import { toast } from 'sonner';
+
+import { LocalDatabaseNode } from '@colanode/client/types';
+import { FieldValue } from '@colanode/core';
+import { uploadMetaTypeFiles } from '@colanode/ui/components/files/meta-type-file-upload-button';
+import { Button } from '@colanode/ui/components/ui/button';
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from '@colanode/ui/components/ui/dialog';
+import { Label } from '@colanode/ui/components/ui/label';
+import { useWorkspace } from '@colanode/ui/contexts/workspace';
+import { useSpaceFolders } from '@colanode/ui/hooks/use-space-folders';
+import { useWorkspaceSpaces } from '@colanode/ui/hooks/use-workspace-spaces';
+import { buildFolderPickerOptions } from '@colanode/ui/lib/folder-picker';
+import { resolveInitialPageFields } from '@colanode/ui/lib/page-field-values';
+
+interface MetaTypeFileUploadDialogProps {
+ metaTypeId: string;
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ initialFields?: Record;
+}
+
+export const MetaTypeFileUploadDialog = ({
+ metaTypeId,
+ open,
+ onOpenChange,
+ initialFields,
+}: MetaTypeFileUploadDialogProps) => {
+ const workspace = useWorkspace();
+ const { spaces } = useWorkspaceSpaces();
+ const [spaceId, setSpaceId] = useState('');
+ const [folderId, setFolderId] = useState('');
+ const [isUploading, setIsUploading] = useState(false);
+ const { folders } = useSpaceFolders(spaceId || undefined);
+ const folderOptions = useMemo(
+ () => (spaceId ? buildFolderPickerOptions(folders, spaceId) : []),
+ [folders, spaceId]
+ );
+
+ useEffect(() => {
+ if (!open) {
+ return;
+ }
+
+ if (spaces.length > 0) {
+ setSpaceId((current) => current || spaces[0]!.id);
+ }
+ }, [open, spaces]);
+
+ useEffect(() => {
+ setFolderId('');
+ }, [spaceId]);
+
+ const parentId = folderId || spaceId;
+
+ const handleUpload = async () => {
+ if (!parentId) {
+ toast.error('Select a space or folder for the new files.');
+ return;
+ }
+
+ const metaTypeNode = workspace.collections.nodes.get(metaTypeId);
+ const metaType =
+ metaTypeNode?.type === 'database'
+ ? (metaTypeNode as LocalDatabaseNode)
+ : null;
+
+ const resolvedFields = resolveInitialPageFields(metaType, { initialFields });
+
+ setIsUploading(true);
+
+ try {
+ await uploadMetaTypeFiles({
+ userId: workspace.userId,
+ parentId,
+ metaTypeId,
+ initialFields:
+ Object.keys(resolvedFields).length > 0 ? resolvedFields : undefined,
+ });
+ onOpenChange(false);
+ } catch (error) {
+ toast.error(
+ error instanceof Error ? error.message : 'Failed to upload files.'
+ );
+ } finally {
+ setIsUploading(false);
+ }
+ };
+
+ return (
+
+ );
+};
diff --git a/packages/ui/src/components/nodes/node-breadcrumb.tsx b/packages/ui/src/components/nodes/node-breadcrumb.tsx
index cd34cac13..fc9ab4e41 100644
--- a/packages/ui/src/components/nodes/node-breadcrumb.tsx
+++ b/packages/ui/src/components/nodes/node-breadcrumb.tsx
@@ -1,6 +1,7 @@
import { Fragment } from 'react';
import { LocalNode } from '@colanode/client/types';
+import { isMetaTypesSpace } from '@colanode/core';
import { NodeBreadcrumbItem } from '@colanode/ui/components/nodes/node-breadcrumb-item';
import {
Breadcrumb,
@@ -21,6 +22,22 @@ interface NodeBreadcrumbProps {
nodes: LocalNode[];
}
+const BreadcrumbNodeLink = ({ node }: { node: LocalNode }) => {
+ if (isMetaTypesSpace(node)) {
+ return (
+
+
+
+ );
+ }
+
+ return (
+
+
+
+ );
+};
+
export const NodeBreadcrumb = ({ nodes }: NodeBreadcrumbProps) => {
const showEllipsis = nodes.length > 3;
@@ -44,13 +61,7 @@ export const NodeBreadcrumb = ({ nodes }: NodeBreadcrumbProps) => {
{!isFirst && }
-
-
-
+
{showEllipsis && isFirst && (
@@ -64,15 +75,7 @@ export const NodeBreadcrumb = ({ nodes }: NodeBreadcrumbProps) => {
{ellipsisItems.map((ellipsisItem) => {
return (
-
-
-
-
-
+
);
})}
diff --git a/packages/ui/src/components/nodes/node-container.tsx b/packages/ui/src/components/nodes/node-container.tsx
index dad841869..b8eab00ee 100644
--- a/packages/ui/src/components/nodes/node-container.tsx
+++ b/packages/ui/src/components/nodes/node-container.tsx
@@ -1,23 +1,61 @@
+import { lazy, Suspense } from 'react';
import { Outlet } from '@tanstack/react-router';
-import { ChannelContainer } from '@colanode/ui/components/channels/channel-container';
-import { ChatContainer } from '@colanode/ui/components/chats/chat-container';
-import { DatabaseContainer } from '@colanode/ui/components/databases/database-container';
-import { FileContainer } from '@colanode/ui/components/files/file-container';
-import { FolderContainer } from '@colanode/ui/components/folders/folder-container';
import { Container } from '@colanode/ui/components/layouts/containers/container';
-import { MessageContainer } from '@colanode/ui/components/messages/message-container';
import { NodeArchiveBanner } from '@colanode/ui/components/nodes/node-archive-banner';
import { NodeBreadcrumb } from '@colanode/ui/components/nodes/node-breadcrumb';
import { NodeProvider } from '@colanode/ui/components/nodes/node-provider';
import { NodeSettings } from '@colanode/ui/components/nodes/node-settings';
-import { PageContainer } from '@colanode/ui/components/pages/page-container';
-import { RecordContainer } from '@colanode/ui/components/records/record-container';
-import { SpaceContainer } from '@colanode/ui/components/spaces/space-container';
import { ContainerType } from '@colanode/ui/contexts/container';
import { useNode } from '@colanode/ui/contexts/node';
import { useNodeRadar } from '@colanode/ui/hooks/use-node-radar';
+const ChannelContainer = lazy(() =>
+ import('@colanode/ui/components/channels/channel-container').then(
+ (module) => ({ default: module.ChannelContainer })
+ )
+);
+const ChatContainer = lazy(() =>
+ import('@colanode/ui/components/chats/chat-container').then((module) => ({
+ default: module.ChatContainer,
+ }))
+);
+const DatabaseContainer = lazy(() =>
+ import('@colanode/ui/components/databases/database-container').then(
+ (module) => ({ default: module.DatabaseContainer })
+ )
+);
+const FileContainer = lazy(() =>
+ import('@colanode/ui/components/files/file-container').then((module) => ({
+ default: module.FileContainer,
+ }))
+);
+const FolderContainer = lazy(() =>
+ import('@colanode/ui/components/folders/folder-container').then((module) => ({
+ default: module.FolderContainer,
+ }))
+);
+const MessageContainer = lazy(() =>
+ import('@colanode/ui/components/messages/message-container').then(
+ (module) => ({ default: module.MessageContainer })
+ )
+);
+const PageContainer = lazy(() =>
+ import('@colanode/ui/components/pages/page-container').then((module) => ({
+ default: module.PageContainer,
+ }))
+);
+const RecordContainer = lazy(() =>
+ import('@colanode/ui/components/records/record-container').then((module) => ({
+ default: module.RecordContainer,
+ }))
+);
+const SpaceContainer = lazy(() =>
+ import('@colanode/ui/components/spaces/space-container').then((module) => ({
+ default: module.SpaceContainer,
+ }))
+);
+
interface NodeContainerProps {
type: ContainerType;
nodeId: string;
@@ -40,33 +78,35 @@ const NodeContent = ({ type, onFullscreen }: NodeContentProps) => {
onFullscreen={onFullscreen}
>
- {data.node.type === 'space' && (
-
- )}
- {data.node.type === 'channel' && (
-
- )}
- {data.node.type === 'page' && (
-
- )}
- {data.node.type === 'database' && (
-
- )}
- {data.node.type === 'record' && (
-
- )}
- {data.node.type === 'chat' && (
-
- )}
- {data.node.type === 'folder' && (
-
- )}
- {data.node.type === 'message' && (
-
- )}
- {data.node.type === 'file' && (
-
- )}
+
+ {data.node.type === 'space' && (
+
+ )}
+ {data.node.type === 'channel' && (
+
+ )}
+ {data.node.type === 'page' && (
+
+ )}
+ {data.node.type === 'database' && (
+
+ )}
+ {data.node.type === 'record' && (
+
+ )}
+ {data.node.type === 'chat' && (
+
+ )}
+ {data.node.type === 'folder' && (
+
+ )}
+ {data.node.type === 'message' && (
+
+ )}
+ {data.node.type === 'file' && (
+
+ )}
+
);
};
diff --git a/packages/ui/src/components/pages/meta-type-create-button.tsx b/packages/ui/src/components/pages/meta-type-create-button.tsx
new file mode 100644
index 000000000..d51fb79d2
--- /dev/null
+++ b/packages/ui/src/components/pages/meta-type-create-button.tsx
@@ -0,0 +1,29 @@
+import { MetaTypeFileUploadButton } from '@colanode/ui/components/files/meta-type-file-upload-button';
+import { MetaTypePageCreateButton } from '@colanode/ui/components/pages/meta-type-page-create-button';
+import { useDatabase } from '@colanode/ui/contexts/database';
+
+interface MetaTypeCreateButtonProps {
+ pageLabel?: string;
+ fileLabel?: string;
+ className?: string;
+}
+
+export const MetaTypeCreateButton = ({
+ pageLabel = 'Add page',
+ fileLabel = 'Add file',
+ className,
+}: MetaTypeCreateButtonProps) => {
+ const database = useDatabase();
+
+ if (database.canCreatePage) {
+ return ;
+ }
+
+ if (database.canCreateFile) {
+ return (
+
+ );
+ }
+
+ return null;
+};
diff --git a/packages/ui/src/components/records/relation-target-badge.tsx b/packages/ui/src/components/records/relation-target-badge.tsx
new file mode 100644
index 000000000..508d65efd
--- /dev/null
+++ b/packages/ui/src/components/records/relation-target-badge.tsx
@@ -0,0 +1,37 @@
+import { Avatar } from '@colanode/ui/components/avatars/avatar';
+import { FileIcon } from '@colanode/ui/components/files/file-icon';
+
+export type RelationTargetBadgeTarget = {
+ id: string;
+ name: string;
+ avatar?: string | null;
+ mimeType?: string | null;
+ kind?: 'page' | 'record' | 'file';
+};
+
+interface RelationTargetBadgeProps {
+ target: RelationTargetBadgeTarget;
+}
+
+export const RelationTargetBadge = ({ target }: RelationTargetBadgeProps) => {
+ const name = target.name ?? 'Unnamed';
+
+ return (
+
+ {target.kind === 'file' ? (
+
+ ) : (
+
+ )}
+
{name}
+
+ );
+};
diff --git a/packages/ui/src/components/records/relation-target-search.tsx b/packages/ui/src/components/records/relation-target-search.tsx
index ec6afb49d..b971fc4a3 100644
--- a/packages/ui/src/components/records/relation-target-search.tsx
+++ b/packages/ui/src/components/records/relation-target-search.tsx
@@ -1,9 +1,12 @@
import { eq, useLiveQuery } from '@tanstack/react-db';
import { useState } from 'react';
-import { LocalDatabaseNode } from '@colanode/client/types';
+import { LocalDatabaseNode, LocalFileNode } from '@colanode/client/types';
import { getMetaTypeInstanceKind } from '@colanode/core';
-import { Avatar } from '@colanode/ui/components/avatars/avatar';
+import {
+ RelationTargetBadge,
+ RelationTargetBadgeTarget,
+} from '@colanode/ui/components/records/relation-target-badge';
import {
Command,
CommandEmpty,
@@ -20,16 +23,10 @@ import {
import { useWorkspace } from '@colanode/ui/contexts/workspace';
import { useQuery } from '@colanode/ui/hooks/use-query';
-interface RelationTarget {
- id: string;
- name: string;
- avatar?: string | null;
-}
-
interface RelationTargetSearchProps {
databaseId: string;
exclude?: string[];
- onSelect: (target: RelationTarget) => void;
+ onSelect: (target: RelationTargetBadgeTarget) => void;
}
export const RelationTargetSearch = ({
@@ -92,22 +89,26 @@ export const RelationTargetSearch = ({
{ enabled: isFileMetaType }
);
- const results: RelationTarget[] = isFileMetaType
- ? (fileSearchQuery.data ?? []).map((file) => ({
+ const results: RelationTargetBadgeTarget[] = isFileMetaType
+ ? (fileSearchQuery.data ?? []).map((file: LocalFileNode) => ({
id: file.id,
name: file.name,
+ mimeType: file.mimeType,
+ kind: 'file',
}))
: isMetaType
- ? (pageSearchQuery.data ?? []).map((page) => ({
- id: page.id,
- name: page.name,
- avatar: page.avatar,
- }))
- : (recordSearchQuery.data ?? []).map((record) => ({
- id: record.id,
- name: record.name,
- avatar: record.avatar,
- }));
+ ? (pageSearchQuery.data ?? []).map((page) => ({
+ id: page.id,
+ name: page.name,
+ avatar: page.avatar,
+ kind: 'page' as const,
+ }))
+ : (recordSearchQuery.data ?? []).map((record) => ({
+ id: record.id,
+ name: record.name,
+ avatar: record.avatar,
+ kind: 'record' as const,
+ }));
const emptyLabel = isFileMetaType
? 'No file found.'
@@ -141,15 +142,7 @@ export const RelationTargetSearch = ({
setQuery('');
}}
>
-
+
))}
diff --git a/packages/ui/src/components/records/values/record-relation-value.tsx b/packages/ui/src/components/records/values/record-relation-value.tsx
index 6944ef4c8..cf420544c 100644
--- a/packages/ui/src/components/records/values/record-relation-value.tsx
+++ b/packages/ui/src/components/records/values/record-relation-value.tsx
@@ -3,8 +3,11 @@ import { X } from 'lucide-react';
import { Fragment, useMemo, useState } from 'react';
import { RelationFieldAttributes, StringArrayFieldValue } from '@colanode/core';
-import { Avatar } from '@colanode/ui/components/avatars/avatar';
import { RelationTargetSearch } from '@colanode/ui/components/records/relation-target-search';
+import {
+ RelationTargetBadge,
+ RelationTargetBadgeTarget,
+} from '@colanode/ui/components/records/relation-target-badge';
import { Badge } from '@colanode/ui/components/ui/badge';
import {
Popover,
@@ -21,20 +24,10 @@ interface RecordRelationValueProps {
readOnly?: boolean;
}
-type RelationTarget = {
- id: string;
- name: string;
- avatar?: string | null;
-};
+type RelationTarget = RelationTargetBadgeTarget;
const RelationBadge = ({ target }: { target: RelationTarget }) => {
- const name = target.name ?? 'Unnamed';
- return (
-
- );
+ return ;
};
export const RecordRelationValue = ({
@@ -68,7 +61,7 @@ export const RecordRelationValue = ({
);
const relations = relationsQuery.data.flatMap((node) => {
- if (node.type !== 'page' && node.type !== 'record') {
+ if (node.type !== 'page' && node.type !== 'record' && node.type !== 'file') {
return [];
}
@@ -76,7 +69,9 @@ export const RecordRelationValue = ({
{
id: node.id,
name: node.name ?? 'Unnamed',
- avatar: node.avatar,
+ avatar: node.type === 'file' ? undefined : node.avatar,
+ mimeType: node.type === 'file' ? node.mimeType : undefined,
+ kind: node.type,
},
];
});
diff --git a/packages/ui/src/editor/menus/action-menu.tsx b/packages/ui/src/editor/menus/action-menu.tsx
index fe49150f4..6cc6c6550 100644
--- a/packages/ui/src/editor/menus/action-menu.tsx
+++ b/packages/ui/src/editor/menus/action-menu.tsx
@@ -47,7 +47,7 @@ const createMenuReference = (
});
export const ActionMenu = ({ editor }: ActionMenuProps) => {
- const view = useRef(editor!.view!);
+ const viewRef = useRef(null);
const [menuState, setMenuState] = useState({
show: false,
});
@@ -108,8 +108,16 @@ export const ActionMenu = ({ editor }: ActionMenuProps) => {
return;
}
+ const editorView = editor.view;
+ viewRef.current = editorView;
+
const handleMouseMove = (event: MouseEvent) => {
- const editorBounds = view.current.dom.getBoundingClientRect();
+ const view = viewRef.current;
+ if (!view) {
+ return;
+ }
+
+ const editorBounds = view.dom.getBoundingClientRect();
const mouseOverEditor =
event.clientX > editorBounds.left - LEFT_MARGIN &&
event.clientX < editorBounds.right &&
@@ -121,7 +129,7 @@ export const ActionMenu = ({ editor }: ActionMenuProps) => {
return;
}
- const pos = view.current.posAtDOM(event.target as Node, 0, 0);
+ const pos = view.posAtDOM(event.target as Node, 0, 0);
if (!pos) {
setMenuState((prev) => (prev.show ? { show: false } : prev));
return;
@@ -133,7 +141,7 @@ export const ActionMenu = ({ editor }: ActionMenuProps) => {
let nodePos = -1;
while (currentPos >= 0) {
- const node = view.current.state.doc.nodeAt(currentPos);
+ const node = view.state.doc.nodeAt(currentPos);
if (!node || !node.isBlock) {
currentPos--;
@@ -145,7 +153,7 @@ export const ActionMenu = ({ editor }: ActionMenuProps) => {
continue;
}
- const nodeDOM = view.current.nodeDOM(currentPos) as HTMLElement;
+ const nodeDOM = view.nodeDOM(currentPos) as HTMLElement;
const nodeDOMElement =
nodeDOM instanceof HTMLElement
? nodeDOM
@@ -190,12 +198,13 @@ export const ActionMenu = ({ editor }: ActionMenuProps) => {
});
};
- editor.view.dom.addEventListener('mousemove', handleMouseMove);
- editor.view.dom.addEventListener('scroll', handleScroll, true);
+ editorView.dom.addEventListener('mousemove', handleMouseMove);
+ editorView.dom.addEventListener('scroll', handleScroll, true);
return () => {
- editor.view.dom.removeEventListener('mousemove', handleMouseMove);
- editor.view.dom.removeEventListener('scroll', handleScroll, true);
+ editorView.dom.removeEventListener('mousemove', handleMouseMove);
+ editorView.dom.removeEventListener('scroll', handleScroll, true);
+ viewRef.current = null;
};
}, [editor]);
@@ -272,15 +281,20 @@ export const ActionMenu = ({ editor }: ActionMenuProps) => {
return;
}
- view.current.focus();
- view.current.dispatch(
- view.current.state.tr.setSelection(
- NodeSelection.create(view.current.state.doc, menuState.pos)
+ const view = viewRef.current;
+ if (!view) {
+ return;
+ }
+
+ view.focus();
+ view.dispatch(
+ view.state.tr.setSelection(
+ NodeSelection.create(view.state.doc, menuState.pos)
)
);
- const slice = view.current.state.selection.content();
- const { dom, text } = view.current.serializeForClipboard(slice);
+ const slice = view.state.selection.content();
+ const { dom, text } = view.serializeForClipboard(slice);
event.dataTransfer.clearData();
event.dataTransfer.effectAllowed = 'copyMove';
@@ -288,16 +302,21 @@ export const ActionMenu = ({ editor }: ActionMenuProps) => {
event.dataTransfer.setData('text/plain', text);
event.dataTransfer.setDragImage(menuState.domNode, 0, 0);
- view.current.dragging = { slice, move: true };
+ view.dragging = { slice, move: true };
}}
onDragEnd={() => {
- view.current.dispatch(
- view.current.state.tr.setSelection(
- TextSelection.create(view.current.state.doc, 1)
+ const view = viewRef.current;
+ if (!view) {
+ return;
+ }
+
+ view.dispatch(
+ view.state.tr.setSelection(
+ TextSelection.create(view.state.doc, 1)
)
);
- view.current.dom.blur();
+ view.dom.blur();
}}
>
diff --git a/packages/ui/src/hooks/use-space-folders.ts b/packages/ui/src/hooks/use-space-folders.ts
new file mode 100644
index 000000000..1a6e6c65d
--- /dev/null
+++ b/packages/ui/src/hooks/use-space-folders.ts
@@ -0,0 +1,38 @@
+import { eq, useLiveQuery } from '@tanstack/react-db';
+import { useMemo } from 'react';
+
+import { LocalFolderNode } from '@colanode/client/types';
+import { isNodeArchived } from '@colanode/core';
+import { useWorkspace } from '@colanode/ui/contexts/workspace';
+
+export const useSpaceFolders = (spaceId: string | undefined) => {
+ const workspace = useWorkspace();
+
+ const query = useLiveQuery(
+ (q) => {
+ if (!spaceId) {
+ return q
+ .from({ nodes: workspace.collections.nodes })
+ .where(({ nodes }) => eq(nodes.id, ''));
+ }
+
+ return q
+ .from({ nodes: workspace.collections.nodes })
+ .where(({ nodes }) => eq(nodes.rootId, spaceId));
+ },
+ [workspace.userId, spaceId]
+ );
+
+ const folders = useMemo(
+ () =>
+ (query.data ?? [])
+ .filter((node) => node.type === 'folder' && !isNodeArchived(node))
+ .map((node) => node as LocalFolderNode),
+ [query.data]
+ );
+
+ return {
+ isLoading: query.isLoading,
+ folders,
+ };
+};
diff --git a/packages/ui/src/lib/entity-node-fields.ts b/packages/ui/src/lib/entity-node-fields.ts
index 3052f7743..4372d8c25 100644
--- a/packages/ui/src/lib/entity-node-fields.ts
+++ b/packages/ui/src/lib/entity-node-fields.ts
@@ -6,7 +6,11 @@ export const applyFieldValueToNode = (
fieldId: string,
value: FieldValue | null
): void => {
- if (draft.type !== 'record' && draft.type !== 'page') {
+ if (
+ draft.type !== 'record' &&
+ draft.type !== 'page' &&
+ draft.type !== 'file'
+ ) {
return;
}
@@ -15,7 +19,7 @@ export const applyFieldValueToNode = (
if (value === null) {
const { [fieldId]: _removed, ...rest } = fields;
- if (draft.type === 'page') {
+ if (draft.type === 'page' || draft.type === 'file') {
draft.fields = Object.keys(rest).length === 0 ? undefined : rest;
} else {
draft.fields = rest;
diff --git a/packages/ui/src/lib/folder-picker.ts b/packages/ui/src/lib/folder-picker.ts
new file mode 100644
index 000000000..ede32905f
--- /dev/null
+++ b/packages/ui/src/lib/folder-picker.ts
@@ -0,0 +1,38 @@
+import { LocalFolderNode } from '@colanode/client/types';
+import { compareString } from '@colanode/core';
+
+export type FolderPickerOption = {
+ id: string;
+ label: string;
+};
+
+export const buildFolderPickerOptions = (
+ folders: LocalFolderNode[],
+ spaceId: string
+): FolderPickerOption[] => {
+ const byId = new Map(folders.map((folder) => [folder.id, folder]));
+
+ const buildPath = (folder: LocalFolderNode): string => {
+ const parts = [folder.name];
+ let current = folder;
+
+ while (current.parentId !== spaceId) {
+ const parent = byId.get(current.parentId);
+ if (!parent) {
+ break;
+ }
+
+ parts.unshift(parent.name);
+ current = parent;
+ }
+
+ return parts.join(' / ');
+ };
+
+ return folders
+ .map((folder) => ({
+ id: folder.id,
+ label: buildPath(folder),
+ }))
+ .sort((left, right) => compareString(left.label, right.label));
+};
diff --git a/packages/ui/src/lib/lowlight.ts b/packages/ui/src/lib/lowlight.ts
index bb28ceca6..13ddd469d 100644
--- a/packages/ui/src/lib/lowlight.ts
+++ b/packages/ui/src/lib/lowlight.ts
@@ -1,5 +1,71 @@
-import { all, createLowlight } from 'lowlight';
-export const lowlight = createLowlight(all);
+import { createLowlight } from 'lowlight';
+import arduino from 'highlight.js/lib/languages/arduino';
+import bash from 'highlight.js/lib/languages/bash';
+import c from 'highlight.js/lib/languages/c';
+import cpp from 'highlight.js/lib/languages/cpp';
+import csharp from 'highlight.js/lib/languages/csharp';
+import css from 'highlight.js/lib/languages/css';
+import diff from 'highlight.js/lib/languages/diff';
+import go from 'highlight.js/lib/languages/go';
+import graphql from 'highlight.js/lib/languages/graphql';
+import ini from 'highlight.js/lib/languages/ini';
+import java from 'highlight.js/lib/languages/java';
+import javascript from 'highlight.js/lib/languages/javascript';
+import json from 'highlight.js/lib/languages/json';
+import kotlin from 'highlight.js/lib/languages/kotlin';
+import less from 'highlight.js/lib/languages/less';
+import lua from 'highlight.js/lib/languages/lua';
+import makefile from 'highlight.js/lib/languages/makefile';
+import markdown from 'highlight.js/lib/languages/markdown';
+import perl from 'highlight.js/lib/languages/perl';
+import php from 'highlight.js/lib/languages/php';
+import plaintext from 'highlight.js/lib/languages/plaintext';
+import python from 'highlight.js/lib/languages/python';
+import r from 'highlight.js/lib/languages/r';
+import ruby from 'highlight.js/lib/languages/ruby';
+import rust from 'highlight.js/lib/languages/rust';
+import scss from 'highlight.js/lib/languages/scss';
+import shell from 'highlight.js/lib/languages/shell';
+import sql from 'highlight.js/lib/languages/sql';
+import swift from 'highlight.js/lib/languages/swift';
+import typescript from 'highlight.js/lib/languages/typescript';
+import xml from 'highlight.js/lib/languages/xml';
+import yaml from 'highlight.js/lib/languages/yaml';
+
+export const lowlight = createLowlight({
+ arduino,
+ bash,
+ c,
+ cpp,
+ csharp,
+ css,
+ diff,
+ go,
+ graphql,
+ ini,
+ java,
+ javascript,
+ json,
+ kotlin,
+ less,
+ lua,
+ makefile,
+ markdown,
+ perl,
+ php,
+ plaintext,
+ python,
+ r,
+ ruby,
+ rust,
+ scss,
+ shell,
+ sql,
+ swift,
+ typescript,
+ xml,
+ yaml,
+});
const lowlightLanguages = lowlight.listLanguages();
interface CodeHighlightNode {
diff --git a/packages/ui/src/lib/meta-types.ts b/packages/ui/src/lib/meta-types.ts
index 676495fe2..d8d67bb97 100644
--- a/packages/ui/src/lib/meta-types.ts
+++ b/packages/ui/src/lib/meta-types.ts
@@ -13,6 +13,20 @@ export const isFileMetaTypeDatabase = (
(database.purpose ?? 'records') === 'meta_type' &&
getMetaTypeInstanceKind(database) === 'file';
+export const getMetaTypeEntityNames = (
+ database: Pick
+): { singular: string; plural: string } => {
+ if (isFileMetaTypeDatabase(database)) {
+ return { singular: 'file', plural: 'files' };
+ }
+
+ if (isPageMetaTypeDatabase(database)) {
+ return { singular: 'page', plural: 'pages' };
+ }
+
+ return { singular: 'record', plural: 'records' };
+};
+
export const getMetaTypeEmptyLabel = (options: {
purpose?: LocalDatabaseNode['purpose'];
instanceKind?: LocalDatabaseNode['instanceKind'];
diff --git a/packages/ui/src/routes/lazy-route.tsx b/packages/ui/src/routes/lazy-route.tsx
new file mode 100644
index 000000000..01402d372
--- /dev/null
+++ b/packages/ui/src/routes/lazy-route.tsx
@@ -0,0 +1,26 @@
+import { ComponentType, lazy, Suspense } from 'react';
+import type { RouteComponent } from '@tanstack/react-router';
+
+export const lazyNamedRouteComponent = (
+ factory: () => Promise>,
+ exportName: string
+): RouteComponent => {
+ const LazyComponent = lazy(() =>
+ factory().then((module) => {
+ const component = module[exportName];
+ if (!component) {
+ throw new Error(`Route component "${exportName}" was not found.`);
+ }
+
+ return { default: component };
+ })
+ );
+
+ return function LazyRouteComponent() {
+ return (
+
+
+
+ );
+ };
+};
diff --git a/packages/ui/src/routes/workspace/assets-settings.tsx b/packages/ui/src/routes/workspace/assets-settings.tsx
index d5aec68ee..a89ef7a0c 100644
--- a/packages/ui/src/routes/workspace/assets-settings.tsx
+++ b/packages/ui/src/routes/workspace/assets-settings.tsx
@@ -1,13 +1,19 @@
import { createRoute, redirect } from '@tanstack/react-router';
-import { WorkspaceAssetsContainer } from '@colanode/ui/components/workspaces/assets/workspace-assets-container';
import { WorkspaceAssetsTab } from '@colanode/ui/components/workspaces/assets/workspace-assets-tab';
+import { lazyNamedRouteComponent } from '@colanode/ui/routes/lazy-route';
import { getWorkspaceUserId } from '@colanode/ui/routes/utils';
import {
workspaceRoute,
workspaceMaskRoute,
} from '@colanode/ui/routes/workspace';
+const WorkspaceAssetsContainer = lazyNamedRouteComponent(
+ () =>
+ import('@colanode/ui/components/workspaces/assets/workspace-assets-container'),
+ 'WorkspaceAssetsContainer'
+);
+
export const workspaceAssetsRoute = createRoute({
getParentRoute: () => workspaceRoute,
path: '/settings/assets',
diff --git a/packages/ui/src/routes/workspace/node.tsx b/packages/ui/src/routes/workspace/node.tsx
index caac26816..d5cc9ce4d 100644
--- a/packages/ui/src/routes/workspace/node.tsx
+++ b/packages/ui/src/routes/workspace/node.tsx
@@ -1,6 +1,6 @@
import { createRoute, redirect } from '@tanstack/react-router';
-import { TODO_LIST_SYSTEM_KIND, isAssetLibrarySystemDatabase } from '@colanode/core';
+import { TODO_LIST_SYSTEM_KIND, isAssetLibrarySystemDatabase, isMetaTypesSpace } from '@colanode/core';
import { NodeContainer } from '@colanode/ui/components/nodes/node-container';
import { NodeErrorContainer } from '@colanode/ui/components/nodes/node-error-container';
import { NodeTab } from '@colanode/ui/components/nodes/node-tab';
@@ -31,6 +31,14 @@ export const nodeRoute = createRoute({
});
}
+ if (node && isMetaTypesSpace(node)) {
+ throw redirect({
+ to: '/workspace/$userId/settings/meta-types',
+ params: { userId: params.userId },
+ replace: true,
+ });
+ }
+
if (
node?.type === 'database' &&
node.systemKind === TODO_LIST_SYSTEM_KIND
diff --git a/packages/ui/src/routes/workspace/query-explorer-settings.tsx b/packages/ui/src/routes/workspace/query-explorer-settings.tsx
index 0e20b9e64..5bb7ae1e4 100644
--- a/packages/ui/src/routes/workspace/query-explorer-settings.tsx
+++ b/packages/ui/src/routes/workspace/query-explorer-settings.tsx
@@ -1,13 +1,21 @@
import { createRoute, redirect } from '@tanstack/react-router';
-import { WorkspaceQueryExplorerContainer } from '@colanode/ui/components/workspaces/query-explorer/workspace-query-explorer-container';
import { WorkspaceQueryExplorerTab } from '@colanode/ui/components/workspaces/query-explorer/workspace-query-explorer-tab';
+import { lazyNamedRouteComponent } from '@colanode/ui/routes/lazy-route';
import { getWorkspaceUserId } from '@colanode/ui/routes/utils';
import {
workspaceRoute,
workspaceMaskRoute,
} from '@colanode/ui/routes/workspace';
+const WorkspaceQueryExplorerContainer = lazyNamedRouteComponent(
+ () =>
+ import(
+ '@colanode/ui/components/workspaces/query-explorer/workspace-query-explorer-container'
+ ),
+ 'WorkspaceQueryExplorerContainer'
+);
+
export const workspaceQueryExplorerRoute = createRoute({
getParentRoute: () => workspaceRoute,
path: '/settings/query-explorer',
diff --git a/packages/ui/src/routes/workspace/scheduled-tasks-settings.tsx b/packages/ui/src/routes/workspace/scheduled-tasks-settings.tsx
index efc2110c9..0a6fed483 100644
--- a/packages/ui/src/routes/workspace/scheduled-tasks-settings.tsx
+++ b/packages/ui/src/routes/workspace/scheduled-tasks-settings.tsx
@@ -1,13 +1,21 @@
import { createRoute, redirect } from '@tanstack/react-router';
-import { WorkspaceScheduledTasksContainer } from '@colanode/ui/components/workspaces/scheduled-tasks/workspace-scheduled-tasks-container';
import { WorkspaceScheduledTasksTab } from '@colanode/ui/components/workspaces/scheduled-tasks/workspace-scheduled-tasks-tab';
+import { lazyNamedRouteComponent } from '@colanode/ui/routes/lazy-route';
import { getWorkspaceUserId } from '@colanode/ui/routes/utils';
import {
workspaceRoute,
workspaceMaskRoute,
} from '@colanode/ui/routes/workspace';
+const WorkspaceScheduledTasksContainer = lazyNamedRouteComponent(
+ () =>
+ import(
+ '@colanode/ui/components/workspaces/scheduled-tasks/workspace-scheduled-tasks-container'
+ ),
+ 'WorkspaceScheduledTasksContainer'
+);
+
export const workspaceScheduledTasksRoute = createRoute({
getParentRoute: () => workspaceRoute,
path: '/settings/scheduled-tasks',
diff --git a/packages/ui/src/routes/workspace/script-explorer-settings.tsx b/packages/ui/src/routes/workspace/script-explorer-settings.tsx
index 12669d04c..10fb14daa 100644
--- a/packages/ui/src/routes/workspace/script-explorer-settings.tsx
+++ b/packages/ui/src/routes/workspace/script-explorer-settings.tsx
@@ -1,13 +1,21 @@
import { createRoute, redirect } from '@tanstack/react-router';
-import { WorkspaceScriptExplorerContainer } from '@colanode/ui/components/workspaces/script-explorer/workspace-script-explorer-container';
import { WorkspaceScriptExplorerTab } from '@colanode/ui/components/workspaces/script-explorer/workspace-script-explorer-tab';
+import { lazyNamedRouteComponent } from '@colanode/ui/routes/lazy-route';
import { getWorkspaceUserId } from '@colanode/ui/routes/utils';
import {
workspaceRoute,
workspaceMaskRoute,
} from '@colanode/ui/routes/workspace';
+const WorkspaceScriptExplorerContainer = lazyNamedRouteComponent(
+ () =>
+ import(
+ '@colanode/ui/components/workspaces/script-explorer/workspace-script-explorer-container'
+ ),
+ 'WorkspaceScriptExplorerContainer'
+);
+
export const workspaceScriptExplorerRoute = createRoute({
getParentRoute: () => workspaceRoute,
path: '/settings/script-explorer',
diff --git a/packages/ui/test/folder-picker.test.ts b/packages/ui/test/folder-picker.test.ts
new file mode 100644
index 000000000..ff57b0c4d
--- /dev/null
+++ b/packages/ui/test/folder-picker.test.ts
@@ -0,0 +1,33 @@
+import { describe, expect, it } from 'vitest';
+
+import { LocalFolderNode } from '@colanode/client/types';
+import { buildFolderPickerOptions } from '@colanode/ui/lib/folder-picker';
+
+const baseFolder = (
+ overrides: Partial & Pick
+): LocalFolderNode => ({
+ type: 'folder',
+ avatar: null,
+ rootId: 'space-1',
+ createdAt: '2026-01-01T00:00:00.000Z',
+ createdBy: 'user-1',
+ updatedAt: null,
+ updatedBy: null,
+ localRevision: '0',
+ serverRevision: '0',
+ ...overrides,
+});
+
+describe('buildFolderPickerOptions', () => {
+ it('builds nested folder paths within a space', () => {
+ const folders = [
+ baseFolder({ id: 'folder-a', name: 'Assets', parentId: 'space-1' }),
+ baseFolder({ id: 'folder-b', name: 'Contracts', parentId: 'folder-a' }),
+ ];
+
+ expect(buildFolderPickerOptions(folders, 'space-1')).toEqual([
+ { id: 'folder-a', label: 'Assets' },
+ { id: 'folder-b', label: 'Assets / Contracts' },
+ ]);
+ });
+});