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