From bec8b77b38ff3cefcbb4246725ec98a6a04a0072 Mon Sep 17 00:00:00 2001 From: barry Date: Fri, 3 Jul 2026 15:41:01 +0800 Subject: [PATCH] Polish File Meta Types UX and defer heavy web bundles. Improve file meta type views (gallery thumbnails, relations, calendar upload, folder picker), fix meta types navigation and editor mount timing, and lazy-load large UI chunks to shrink the web entry bundle. Co-authored-by: Cursor --- apps/web/src/root.tsx | 20 +- docs/file-meta-types.md | 30 +-- docs/roadmap.md | 16 +- packages/core/src/lib/meta-types.ts | 6 + packages/ui/src/components/app/app-layout.tsx | 32 +++- .../boards/board-view-column-records.tsx | 10 +- .../calendars/calendar-view-grid.tsx | 41 ++++- .../calendar-view-no-value-count.tsx | 6 +- .../calendars/calendar-view-no-value-list.tsx | 6 +- .../databases/calendars/calendar-view.tsx | 5 +- .../databases/gallery/file-gallery-card.tsx | 92 ++++++++++ .../databases/gallery/gallery-view.tsx | 13 +- .../databases/list/list-view-flat.tsx | 8 +- .../search/view-relation-field-filter.tsx | 52 ++++-- .../databases/tables/table-view-header.tsx | 9 +- .../tables/table-view-page-id-cell.tsx | 9 + .../tables/table-view-record-create-row.tsx | 5 +- .../components/documents/document-editor.tsx | 13 +- .../ui/src/components/documents/document.tsx | 26 ++- .../files/meta-type-file-upload-button.tsx | 96 ++++++---- .../files/meta-type-file-upload-dialog.tsx | 171 ++++++++++++++++++ .../src/components/nodes/node-breadcrumb.tsx | 35 ++-- .../src/components/nodes/node-container.tsx | 112 ++++++++---- .../pages/meta-type-create-button.tsx | 29 +++ .../records/relation-target-badge.tsx | 37 ++++ .../records/relation-target-search.tsx | 53 +++--- .../records/values/record-relation-value.tsx | 25 +-- packages/ui/src/editor/menus/action-menu.tsx | 59 ++++-- packages/ui/src/hooks/use-space-folders.ts | 38 ++++ packages/ui/src/lib/entity-node-fields.ts | 8 +- packages/ui/src/lib/folder-picker.ts | 38 ++++ packages/ui/src/lib/lowlight.ts | 70 ++++++- packages/ui/src/lib/meta-types.ts | 14 ++ packages/ui/src/routes/lazy-route.tsx | 26 +++ .../src/routes/workspace/assets-settings.tsx | 8 +- packages/ui/src/routes/workspace/node.tsx | 10 +- .../workspace/query-explorer-settings.tsx | 10 +- .../workspace/scheduled-tasks-settings.tsx | 10 +- .../workspace/script-explorer-settings.tsx | 10 +- packages/ui/test/folder-picker.test.ts | 33 ++++ 40 files changed, 1030 insertions(+), 261 deletions(-) create mode 100644 packages/ui/src/components/databases/gallery/file-gallery-card.tsx create mode 100644 packages/ui/src/components/files/meta-type-file-upload-dialog.tsx create mode 100644 packages/ui/src/components/pages/meta-type-create-button.tsx create mode 100644 packages/ui/src/components/records/relation-target-badge.tsx create mode 100644 packages/ui/src/hooks/use-space-folders.ts create mode 100644 packages/ui/src/lib/folder-picker.ts create mode 100644 packages/ui/src/routes/lazy-route.tsx create mode 100644 packages/ui/test/folder-picker.test.ts diff --git a/apps/web/src/root.tsx b/apps/web/src/root.tsx index 1f43422a4..41f89665c 100644 --- a/apps/web/src/root.tsx +++ b/apps/web/src/root.tsx @@ -1,9 +1,17 @@ -// A workaround to make the globals.css file work in the web app +import { lazy, Suspense } from 'react'; +import { useRegisterSW } from 'virtual:pwa-register/react'; + import '../../../packages/ui/src/styles/globals.css'; -import { useRegisterSW } from 'virtual:pwa-register/react'; +const App = lazy(() => + import('@colanode/ui').then((module) => ({ default: module.App })) +); -import { App } from '@colanode/ui'; +const RootLoading = () => ( +
+

Loading…

+
+); export const Root = () => { useRegisterSW({ @@ -12,5 +20,9 @@ export const Root = () => { }, }); - return ; + return ( + }> + + + ); }; diff --git a/docs/file-meta-types.md b/docs/file-meta-types.md index e106cf086..3a151071d 100644 --- a/docs/file-meta-types.md +++ b/docs/file-meta-types.md @@ -1,6 +1,6 @@ # File Meta Types — Design -> Status: **implemented (Phase 1 MVP)** +> Status: **implemented (Phase 1 MVP + Phase 2 polish)** > Branch target: `main` > Related: [page-meta-types.md](./page-meta-types.md), [asset-library.md](./asset-library.md), [roadmap.md](./roadmap.md) §4 @@ -302,7 +302,7 @@ export type MetaTypeInstanceKind = 'page' | 'file'; **Labels:** `isFileMetaType` branches — "Add file", "No files", "File ID" (parallel to page strings). -**Create:** `MetaTypeFileCreateButton` — upload dialog with meta type fixed to current schema; pick parent folder/space. +**Create:** `MetaTypeFileUploadButton` / `MetaTypeFileUploadDialog` — upload with space + optional folder picker; meta type fixed to current schema. ### File detail @@ -405,25 +405,25 @@ Meta type schema edits: same as page meta types (admin on schema DB). **Core** -- [ ] `instanceKind` on meta type databases (default `page`) -- [ ] `file.metaTypeId` + `fields` in core schema + immutability -- [ ] `ensureFileMetaTypes` + default **File** schema + backfill -- [ ] `resolveDatabaseCollectionSource` + SQL for `file` -- [ ] `file.byMetaType` query -- [ ] `file.create` accepts `metaTypeId` -- [ ] `database.query` maps file rows +- [x] `instanceKind` on meta type databases (default `page`) +- [x] `file.metaTypeId` + `fields` in core schema + immutability +- [x] `ensureFileMetaTypes` + default **File** schema + backfill +- [x] `resolveDatabaseCollectionSource` + SQL for `file` +- [x] `file.byMetaType` query +- [x] `file.create` accepts `metaTypeId` +- [x] `database.query` maps file rows **UI** -- [ ] Meta type create: pick Page / File -- [ ] `FileMetadata` + `FileFieldsProvider` -- [ ] Meta type views: gallery/table/list/**calendar**/board for files -- [ ] Upload with meta type picker (folder direct path) +- [x] Meta type create: pick Page / File +- [x] `FileMetadata` + `FileFieldsProvider` +- [x] Meta type views: gallery/table/list/**calendar**/board for files +- [x] Upload with meta type picker (folder direct path) **Docs** -- [ ] This doc → status **implemented** when done -- [ ] Update `roadmap.md`, `AGENTS.md`, skill reference +- [x] This doc → status **implemented** +- [x] Update `roadmap.md`, `AGENTS.md`, skill reference ### Phase 2 — Automation & polish diff --git a/docs/roadmap.md b/docs/roadmap.md index ac6327c38..52e823b68 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -31,6 +31,14 @@ - 本地 sync:首次 import 后再 `ensurePageMetaTypes`,重复默认 Page 去重 - 测试:`packages/client/test/page-meta-types.test.ts`、`field-defaults.test.ts`、`cloud-sync.test.ts` 等 +### File Meta Types(MVP + Phase 2) + +- Schema:`database.instanceKind: 'page' | 'file'`;`file.metaTypeId` + `fields` +- 聚合:meta type 数据库视图按 `metaTypeId` 拉取各 Space 下的 File(排除 Asset Library 子文件) +- 全视图(Table / Gallery / List / Board / Calendar)、board 拖拽、Relation 搜索/展示/筛选 +- Scheduled Tasks:`meta_type_files_created`、`meta_type_file_field_changed` +- 详见 [file-meta-types.md](./file-meta-types.md) + ### 其他已落地基础 - Workspace 级 Tags **未合入** Colapp 分支(见 [tags-implementation-guide.md](./tags-implementation-guide.md);当前「Tags」多为 Database `multi_select` 字段) @@ -48,7 +56,7 @@ | 1 | **MCP + Skills**(给 LLM 用) | 弱(偏研发工具) | 中 | 提升 AI 协作开发效率 | P1(若持续用 AI 改本仓库)— **薄 MVP 已落地** | | 2 | **消息通知 + 定时任务** | **强**(`JobService` / `job_schedules`) | 中 | 高(桌面自动化) | P1(产品向) | | 3 | **文档导入导出**(Markdown / PDF) | **较强**(Tiptap Markdown、Page CRDT) | 中→大 | 高(Obsidian 迁移) | P1–P2 | -| 4 | **目录 / 文件夹增强 + 资源 Meta Types** | 中(`folder` + 全局 Assets 已落地;Meta Types 现仅 Page) | **大** | 高,后续可增强批量标签、跨 Folder 搜索 | P2–P3 | +| 4 | **目录 / 文件夹增强 + 资源 Meta Types** | 中(`folder` + 全局 Assets 已落地;**File Meta Types 已落地**) | **大** | 高,后续可增强批量标签、跨 Folder 搜索 | P2–P3 | | 5 | **新类型:白板 / 画布** | 弱(新节点 + 新 CRDT) | **很大** | 高,周期长 | P3+ | ### 1. MCP + Skills @@ -119,7 +127,7 @@ **现状:** - `folder` 节点:`name` + 父级关系;Asset Library 中 Folder 作**过滤视图**(非独立资产库) -- Meta Types **当前只绑定 Page**(`page.metaTypeId`) +- Meta Types 已泛化到 **Page** 与 **File**(`page.metaTypeId` / `file.metaTypeId`);Asset Library 仍为独立静态资源子系统 - 已有 `file` 节点、`resource` 字段类型,但与「目录同步」不是同一概念 - **Markdown 目录导入(薄 MVP)**:Space 菜单 → Import Markdown folder;保留子目录为 Folder 树,每个 `.md` 导入为 Page(frontmatter → fields);桌面用目录选择器,Web 用文件夹选择 @@ -127,11 +135,11 @@ | 方案 | 说明 | |------|------| -| A | 将 Meta Types 泛化到 `file` / 新 `resource` 节点 | +| A | 将 Meta Types 泛化到 `file` / 新 `resource` 节点 | **File Meta Types 已落地**(见 [file-meta-types.md](./file-meta-types.md)) | | B | 目录导入仅生成 Page 索引 + 外链文件(轻量) | | C | 完整 DAM(元数据 + 预览 + 同步)——体量最大 | -**建议:** 在方向 3 完成「单文件 md 导入 + frontmatter」后,再决定是否泛化 Meta Types,避免与 Page Meta Types 双轨逻辑。 +**建议:** File Meta Types MVP 已合入;后续可增强上传选位置、Gallery 缩略图优化、跨 Folder 搜索等。 --- diff --git a/packages/core/src/lib/meta-types.ts b/packages/core/src/lib/meta-types.ts index f900158f0..3f0f5b6c5 100644 --- a/packages/core/src/lib/meta-types.ts +++ b/packages/core/src/lib/meta-types.ts @@ -6,6 +6,12 @@ export const META_TYPES_SPACE_MARKER = '__colanode_meta_types__'; export const DEFAULT_PAGE_META_TYPE_NAME = 'Page'; export const DEFAULT_FILE_META_TYPE_NAME = 'File'; +export const isMetaTypesSpace = (node: { + type?: string; + description?: string | null; +}): boolean => + node.type === 'space' && node.description === META_TYPES_SPACE_MARKER; + export const metaTypeInstanceKindSchema = z.enum(['page', 'file']); export type MetaTypeInstanceKind = z.infer; diff --git a/packages/ui/src/components/app/app-layout.tsx b/packages/ui/src/components/app/app-layout.tsx index 32064d367..e4bc63480 100644 --- a/packages/ui/src/components/app/app-layout.tsx +++ b/packages/ui/src/components/app/app-layout.tsx @@ -1,9 +1,23 @@ +import { lazy, Suspense } from 'react'; import { match } from 'ts-pattern'; import { AppType } from '@colanode/client/types'; -import { LayoutDesktop } from '@colanode/ui/components/layouts/layout-desktop'; -import { LayoutMobile } from '@colanode/ui/components/layouts/layout-mobile'; -import { LayoutWeb } from '@colanode/ui/components/layouts/layout-web'; + +const LayoutDesktop = lazy(() => + import('@colanode/ui/components/layouts/layout-desktop').then((module) => ({ + default: module.LayoutDesktop, + })) +); +const LayoutMobile = lazy(() => + import('@colanode/ui/components/layouts/layout-mobile').then((module) => ({ + default: module.LayoutMobile, + })) +); +const LayoutWeb = lazy(() => + import('@colanode/ui/components/layouts/layout-web').then((module) => ({ + default: module.LayoutWeb, + })) +); interface AppLayoutProps { type: AppType; @@ -12,11 +26,13 @@ interface AppLayoutProps { export const AppLayout = ({ type }: AppLayoutProps) => { return (
- {match(type) - .with('desktop', () => ) - .with('mobile', () => ) - .with('web', () => ) - .exhaustive()} + + {match(type) + .with('desktop', () => ) + .with('mobile', () => ) + .with('web', () => ) + .exhaustive()} +
); }; diff --git a/packages/ui/src/components/databases/boards/board-view-column-records.tsx b/packages/ui/src/components/databases/boards/board-view-column-records.tsx index 09a314739..1ddf20608 100644 --- a/packages/ui/src/components/databases/boards/board-view-column-records.tsx +++ b/packages/ui/src/components/databases/boards/board-view-column-records.tsx @@ -2,8 +2,7 @@ import { extractNodeRole } from '@colanode/core'; import { BoardViewCollectionCard } from '@colanode/ui/components/databases/boards/board-view-collection-card'; import { BoardViewRecordCreateCard } from '@colanode/ui/components/databases/boards/board-view-record-create-card'; import { FileFieldsProvider } from '@colanode/ui/components/files/file-fields-provider'; -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 { MetaTypeCreateButton } from '@colanode/ui/components/pages/meta-type-create-button'; import { PageFieldsProvider } from '@colanode/ui/components/pages/page-fields-provider'; import { RecordProvider } from '@colanode/ui/components/records/record-provider'; import { useBoardView } from '@colanode/ui/contexts/board-view'; @@ -53,12 +52,7 @@ export const BoardViewColumnRecords = () => { {database.canCreateRecord && ( )} - - diff --git a/packages/ui/src/components/databases/calendars/calendar-view-grid.tsx b/packages/ui/src/components/databases/calendars/calendar-view-grid.tsx index 86419daa7..4fffe3372 100644 --- a/packages/ui/src/components/databases/calendars/calendar-view-grid.tsx +++ b/packages/ui/src/components/databases/calendars/calendar-view-grid.tsx @@ -10,6 +10,7 @@ import { } from '@colanode/core'; import { CalendarViewDay } from '@colanode/ui/components/databases/calendars/calendar-view-day'; import { MetaTypePageCreateDialog } from '@colanode/ui/components/pages/meta-type-page-create-dialog'; +import { MetaTypeFileUploadDialog } from '@colanode/ui/components/files/meta-type-file-upload-dialog'; import { buttonVariants } from '@colanode/ui/components/ui/button'; import { useDatabase } from '@colanode/ui/contexts/database'; import { useDatabaseView } from '@colanode/ui/contexts/database-view'; @@ -29,6 +30,8 @@ const toUTCDate = (dateParam: Date | string): Date => { return new Date(Date.UTC(year, month, day, 0, 0, 0, 0)); }; +const CALENDAR_QUERY_LIMIT = 200; + interface CalendarViewGridProps { field: FieldAttributes; } @@ -43,6 +46,10 @@ export const CalendarViewGrid = ({ field }: CalendarViewGridProps) => { const [month, setMonth] = useState(new Date()); const [pageCreateOpen, setPageCreateOpen] = useState(false); + const [fileUploadOpen, setFileUploadOpen] = useState(false); + const [fileUploadFields, setFileUploadFields] = useState< + Record + >({}); const [pageCreateFields, setPageCreateFields] = useState< Record >({}); @@ -69,11 +76,18 @@ export const CalendarViewGrid = ({ field }: CalendarViewGridProps) => { [view.filters, field.id, first, last] ); - const { data } = useCollectionQuery(filters, view.sorts, 200); + const { data } = useCollectionQuery(filters, view.sorts, CALENDAR_QUERY_LIMIT); const rows = data; + const isQueryCapped = rows.length >= CALENDAR_QUERY_LIMIT; return ( <> + {isQueryCapped && ( +

+ Showing the first {CALENDAR_QUERY_LIMIT.toLocaleString()} items in this + month. Narrow filters to see more. +

+ )} { (field.type === 'date' || (field.type === 'created_at' && isSameDay(props.day.date, new Date()))); + const canCreateFile = + isMetaType && + database.canCreateFile && + (field.type === 'date' || + (field.type === 'created_at' && + isSameDay(props.day.date, new Date()))); const onCreate = canCreateRecord ? database.canCreateRecord @@ -222,7 +242,18 @@ export const CalendarViewGrid = ({ field }: CalendarViewGridProps) => { ); setPageCreateOpen(true); } - : undefined; + : canCreateFile + ? () => { + setFileUploadFields( + generateFieldValuesFromFilters( + database.fields, + [filter], + workspace.userId + ) + ); + setFileUploadOpen(true); + } + : undefined; return ( { onOpenChange={setPageCreateOpen} initialFields={pageCreateFields} /> + ); }; diff --git a/packages/ui/src/components/databases/calendars/calendar-view-no-value-count.tsx b/packages/ui/src/components/databases/calendars/calendar-view-no-value-count.tsx index ed7e27c32..c08b37fd4 100644 --- a/packages/ui/src/components/databases/calendars/calendar-view-no-value-count.tsx +++ b/packages/ui/src/components/databases/calendars/calendar-view-no-value-count.tsx @@ -22,6 +22,7 @@ import { useWorkspace } from '@colanode/ui/contexts/workspace'; import { useCollectionQuery } from '@colanode/ui/hooks/use-collection-query'; import { useLiveQuery } from '@colanode/ui/hooks/use-live-query'; import { filterCollectionRows } from '@colanode/ui/lib/collection-view-query'; +import { getMetaTypeEntityNames } from '@colanode/ui/lib/meta-types'; interface CalendarViewNoValueCountProps { field: FieldAttributes; @@ -78,6 +79,7 @@ export const CalendarViewNoValueCount = ({ const noValueCount = isMetaType ? metaTypeNoValueCount : (noValueCountQuery.data?.noValueCount ?? 0); + const entityNames = getMetaTypeEntityNames(database); if (noValueCount === 0) { return null; @@ -99,7 +101,9 @@ export const CalendarViewNoValueCount = ({ {view.name} - Record with no {field.name} value ({noValueCount.toLocaleString()}) + {entityNames.singular.charAt(0).toUpperCase() + + entityNames.singular.slice(1)}{' '} + with no {field.name} value ({noValueCount.toLocaleString()}) diff --git a/packages/ui/src/components/databases/calendars/calendar-view-no-value-list.tsx b/packages/ui/src/components/databases/calendars/calendar-view-no-value-list.tsx index 9d7ca01a8..2f731f680 100644 --- a/packages/ui/src/components/databases/calendars/calendar-view-no-value-list.tsx +++ b/packages/ui/src/components/databases/calendars/calendar-view-no-value-list.tsx @@ -1,8 +1,10 @@ import { DatabaseViewFilterAttributes, FieldAttributes } from '@colanode/core'; import { Avatar } from '@colanode/ui/components/avatars/avatar'; import { Link } from '@colanode/ui/components/ui/link'; +import { useDatabase } from '@colanode/ui/contexts/database'; import { useDatabaseView } from '@colanode/ui/contexts/database-view'; import { useCollectionQuery } from '@colanode/ui/hooks/use-collection-query'; +import { getMetaTypeEntityNames } from '@colanode/ui/lib/meta-types'; interface CalendarViewNoValueListProps { filters: DatabaseViewFilterAttributes[]; @@ -13,7 +15,9 @@ export const CalendarViewNoValueList = ({ filters, field, }: CalendarViewNoValueListProps) => { + const database = useDatabase(); const view = useDatabaseView(); + const entityNames = getMetaTypeEntityNames(database); const { data } = useCollectionQuery(filters, view.sorts); const rows = data; @@ -22,7 +26,7 @@ export const CalendarViewNoValueList = ({
{rows.length === 0 && (
- No records with no {field.name} value + No {entityNames.plural} with no {field.name} value
)} {rows.map((row) => { diff --git a/packages/ui/src/components/databases/calendars/calendar-view.tsx b/packages/ui/src/components/databases/calendars/calendar-view.tsx index eb77784a0..e4b20fd43 100644 --- a/packages/ui/src/components/databases/calendars/calendar-view.tsx +++ b/packages/ui/src/components/databases/calendars/calendar-view.tsx @@ -9,7 +9,7 @@ import { ViewSearchBar } from '@colanode/ui/components/databases/search/view-sea import { ViewSortButton } from '@colanode/ui/components/databases/search/view-sort-button'; import { ViewFullscreenButton } from '@colanode/ui/components/databases/view-fullscreen-button'; import { ViewTabs } from '@colanode/ui/components/databases/view-tabs'; -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'; @@ -40,8 +40,7 @@ export const CalendarView = () => { ) : ( )} -
diff --git a/packages/ui/src/components/databases/gallery/file-gallery-card.tsx b/packages/ui/src/components/databases/gallery/file-gallery-card.tsx new file mode 100644 index 000000000..61b347de9 --- /dev/null +++ b/packages/ui/src/components/databases/gallery/file-gallery-card.tsx @@ -0,0 +1,92 @@ +import { useNavigate } from '@tanstack/react-router'; + +import { LocalFileNode } from '@colanode/client/types'; +import { isRemindersRelationFieldId, RelationFieldAttributes } from '@colanode/core'; +import { CollectionViewPageId } from '@colanode/ui/components/databases/collection-view-page-id'; +import { FieldIcon } from '@colanode/ui/components/databases/fields/field-icon'; +import { FileIcon } from '@colanode/ui/components/files/file-icon'; +import { FileImageThumbnail } from '@colanode/ui/components/files/file-thumbnail'; +import { RemindersReadonlyCell } from '@colanode/ui/components/nodes/reminders-readonly-cell'; +import { RecordFieldValue } from '@colanode/ui/components/records/record-field-value'; +import { useDatabaseView } from '@colanode/ui/contexts/database-view'; +import { useWorkspace } from '@colanode/ui/contexts/workspace'; +import { CollectionRow } from '@colanode/ui/lib/collection-row'; +import { canPreviewFile } from '@colanode/ui/lib/files'; +import { cn } from '@colanode/ui/lib/utils'; + +interface FileGalleryCardProps { + row: CollectionRow & { nodeType: 'file'; source: LocalFileNode }; +} + +export const FileGalleryCard = ({ row }: FileGalleryCardProps) => { + const view = useDatabaseView(); + const workspace = useWorkspace(); + const navigate = useNavigate(); + const file = row.source; + + const name = row.name; + const hasName = name !== null && name !== ''; + + return ( + + ); +}; diff --git a/packages/ui/src/components/databases/gallery/gallery-view.tsx b/packages/ui/src/components/databases/gallery/gallery-view.tsx index b1c186038..c91c948c3 100644 --- a/packages/ui/src/components/databases/gallery/gallery-view.tsx +++ b/packages/ui/src/components/databases/gallery/gallery-view.tsx @@ -4,6 +4,7 @@ import { InView } from 'react-intersection-observer'; import { extractNodeRole } from '@colanode/core'; import { GalleryViewCollectionCard } from '@colanode/ui/components/databases/gallery/gallery-view-collection-card'; +import { FileGalleryCard } from '@colanode/ui/components/databases/gallery/file-gallery-card'; import { ViewFilterButton } from '@colanode/ui/components/databases/search/view-filter-button'; import { ViewSearchBar } from '@colanode/ui/components/databases/search/view-search-bar'; import { ViewSortButton } from '@colanode/ui/components/databases/search/view-sort-button'; @@ -11,9 +12,8 @@ import { ViewBasicSettings } from '@colanode/ui/components/databases/view-basic- import { ViewFullscreenButton } from '@colanode/ui/components/databases/view-fullscreen-button'; import { ViewTabs } from '@colanode/ui/components/databases/view-tabs'; import { AssetGalleryCard } from '@colanode/ui/components/folders/asset-gallery-card'; -import { MetaTypeFileUploadButton } from '@colanode/ui/components/files/meta-type-file-upload-button'; import { FileFieldsProvider } from '@colanode/ui/components/files/file-fields-provider'; -import { MetaTypePageCreateButton } from '@colanode/ui/components/pages/meta-type-page-create-button'; +import { MetaTypeCreateButton } from '@colanode/ui/components/pages/meta-type-create-button'; import { PageFieldsProvider } from '@colanode/ui/components/pages/page-fields-provider'; import { RecordProvider } from '@colanode/ui/components/records/record-provider'; import { useDatabase } from '@colanode/ui/contexts/database'; @@ -70,7 +70,7 @@ export const GalleryView = () => { if (row.nodeType === 'file') { return ( - + ); } @@ -95,12 +95,7 @@ export const GalleryView = () => { Add record )} - - diff --git a/packages/ui/src/components/databases/list/list-view-flat.tsx b/packages/ui/src/components/databases/list/list-view-flat.tsx index c06b924c0..1bd523964 100644 --- a/packages/ui/src/components/databases/list/list-view-flat.tsx +++ b/packages/ui/src/components/databases/list/list-view-flat.tsx @@ -4,8 +4,7 @@ import { InView } from 'react-intersection-observer'; import { extractNodeRole } from '@colanode/core'; import { ListViewCollectionRow } from '@colanode/ui/components/databases/list/list-view-collection-row'; import { FileFieldsProvider } from '@colanode/ui/components/files/file-fields-provider'; -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 { MetaTypeCreateButton } from '@colanode/ui/components/pages/meta-type-create-button'; import { PageFieldsProvider } from '@colanode/ui/components/pages/page-fields-provider'; import { RecordProvider } from '@colanode/ui/components/records/record-provider'; import { useDatabase } from '@colanode/ui/contexts/database'; @@ -63,10 +62,7 @@ export const ListViewFlat = () => { })} {database.canCreateRecord && } - - { - const name = record.name ?? 'Unnamed'; - return ( -
- -

{name}

-
- ); +type RelationTarget = RelationTargetBadgeTarget; + +const RelationBadge = ({ target }: { target: RelationTarget }) => { + return ; }; const isOperatorWithoutValue = (operator: string) => { @@ -79,7 +77,21 @@ export const ViewRelationFieldFilter = ({ [workspace.userId, field.databaseId, relationIds] ); - const relations = relationsQuery.data.map((node) => node as LocalRecordNode); + const relations = relationsQuery.data.flatMap((node) => { + if (node.type !== 'page' && node.type !== 'record' && node.type !== 'file') { + return []; + } + + return [ + { + id: node.id, + name: node.name ?? 'Unnamed', + avatar: node.type === 'file' ? undefined : node.avatar, + mimeType: node.type === 'file' ? node.mimeType : undefined, + kind: node.type, + }, + ]; + }); const hideInput = isOperatorWithoutValue(operator.value); if (!field.databaseId) { @@ -150,11 +162,11 @@ export const ViewRelationFieldFilter = ({
{relations.slice(0, 1).map((relation) => ( - + ))} {relations.length === 0 && (

- No records selected + Nothing selected

)} {relations.length > 1 && ( @@ -175,7 +187,7 @@ export const ViewRelationFieldFilter = ({ key={relation.id} className="flex w-full flex-row items-center gap-2" > - + { @@ -194,13 +206,13 @@ export const ViewRelationFieldFilter = ({
)} - { - const newRelations = relationIds.includes(record.id) - ? relationIds.filter((id) => id !== record.id) - : [...relationIds, record.id]; + onSelect={(target) => { + const newRelations = relationIds.includes(target.id) + ? relationIds.filter((id) => id !== target.id) + : [...relationIds, target.id]; updateFilter({ ...filter, diff --git a/packages/ui/src/components/databases/tables/table-view-header.tsx b/packages/ui/src/components/databases/tables/table-view-header.tsx index 9bf80d8a8..acb01aef6 100644 --- a/packages/ui/src/components/databases/tables/table-view-header.tsx +++ b/packages/ui/src/components/databases/tables/table-view-header.tsx @@ -12,16 +12,21 @@ export const TableViewHeader = () => { const database = useDatabase(); const view = useDatabaseView(); const isMetaType = (database.purpose ?? 'records') === 'meta_type'; + const metaTypeIdLabel = database.isFileMetaType + ? 'File ID' + : database.isPageMetaType + ? 'Page ID' + : null; return (
- {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 ( + + + + Upload files + + Choose where to store the files, then pick files to classify with + this meta type. + + + + {spaces.length > 0 ? ( +
+
+ + +
+ +
+ + +
+
+ ) : ( +

+ Create a space before uploading files. +

+ )} + + + + + +
+
+ ); +}; 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(''); }} > -
- -

{target.name}

-
+ ))} 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 ( -
- -

{name}

-
- ); + 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' }, + ]); + }); +});