From 61a91cd0869091355b36244ded39f782cd97ea28 Mon Sep 17 00:00:00 2001 From: Philipinho <16838612+Philipinho@users.noreply.github.com> Date: Fri, 22 May 2026 14:54:52 +0100 Subject: [PATCH 01/56] fix: remove duplicate storage key --- .env.example | 1 - 1 file changed, 1 deletion(-) diff --git a/.env.example b/.env.example index e97dacccb..6c5756fad 100644 --- a/.env.example +++ b/.env.example @@ -22,7 +22,6 @@ AWS_S3_ENDPOINT= AWS_S3_FORCE_PATH_STYLE= # Azure Blob Storage driver config -STORAGE_DRIVER=azure AZURE_STORAGE_ACCOUNT_NAME= AZURE_STORAGE_ACCOUNT_KEY= AZURE_STORAGE_CONTAINER= From d7c4f0551e6f90dd90694f83f4ff234d656e77b6 Mon Sep 17 00:00:00 2001 From: Philipinho <16838612+Philipinho@users.noreply.github.com> Date: Fri, 22 May 2026 19:00:30 +0100 Subject: [PATCH 02/56] fix: strip html styles on paste --- .../editor/extensions/clean-styles.ts | 20 +++++++++++++++++++ .../features/editor/extensions/extensions.ts | 2 ++ 2 files changed, 22 insertions(+) create mode 100644 apps/client/src/features/editor/extensions/clean-styles.ts diff --git a/apps/client/src/features/editor/extensions/clean-styles.ts b/apps/client/src/features/editor/extensions/clean-styles.ts new file mode 100644 index 000000000..f36e9f500 --- /dev/null +++ b/apps/client/src/features/editor/extensions/clean-styles.ts @@ -0,0 +1,20 @@ +import { Extension } from "@tiptap/core"; +import { Plugin, PluginKey } from "@tiptap/pm/state"; + +export const CleanStyles = Extension.create({ + name: "cleanStyles", + priority: 80, + + addProseMirrorPlugins() { + return [ + new Plugin({ + key: new PluginKey("cleanStyles"), + props: { + transformPastedHTML(html) { + return html.replace(/\s+style="[^"]*"/gi, ""); + }, + }, + }), + ]; + }, +}); diff --git a/apps/client/src/features/editor/extensions/extensions.ts b/apps/client/src/features/editor/extensions/extensions.ts index ef3127c65..f991b653c 100644 --- a/apps/client/src/features/editor/extensions/extensions.ts +++ b/apps/client/src/features/editor/extensions/extensions.ts @@ -112,6 +112,7 @@ import EmojiCommand from "./emoji-command"; import { countWords } from "alfaaz"; import AutoJoiner from "@/features/editor/extensions/autojoiner.ts"; import GlobalDragHandle from "@/features/editor/extensions/drag-handle.ts"; +import { CleanStyles } from "@/features/editor/extensions/clean-styles.ts"; const lowlight = createLowlight(common); lowlight.register("mermaid", plaintext); @@ -383,6 +384,7 @@ export const mainExtensions = [ MarkdownClipboard.configure({ transformPastedText: true, }), + CleanStyles, CharacterCount.configure({ wordCounter: (text) => countWords(text), }), From 830b5b4d458a1302f42c8b68bff9ff559f6eb342 Mon Sep 17 00:00:00 2001 From: Philipinho <16838612+Philipinho@users.noreply.github.com> Date: Mon, 25 May 2026 19:17:14 +0100 Subject: [PATCH 03/56] fix synced block --- .../spec/transclusion.service.spec.ts | 320 ------------------ .../page/transclusion/transclusion.service.ts | 58 +++- 2 files changed, 49 insertions(+), 329 deletions(-) delete mode 100644 apps/server/src/core/page/transclusion/spec/transclusion.service.spec.ts diff --git a/apps/server/src/core/page/transclusion/spec/transclusion.service.spec.ts b/apps/server/src/core/page/transclusion/spec/transclusion.service.spec.ts deleted file mode 100644 index 2808dcd2f..000000000 --- a/apps/server/src/core/page/transclusion/spec/transclusion.service.spec.ts +++ /dev/null @@ -1,320 +0,0 @@ -import { Test } from '@nestjs/testing'; -import { TransclusionService } from '../transclusion.service'; -import { PageTransclusionsRepo } from '@docmost/db/repos/page-transclusions/page-transclusions.repo'; -import { PageTransclusionReferencesRepo } from '@docmost/db/repos/page-transclusions/page-transclusion-references.repo'; -import { PageRepo } from '@docmost/db/repos/page/page.repo'; -import { PagePermissionRepo } from '@docmost/db/repos/page/page-permission.repo'; -import { AttachmentRepo } from '@docmost/db/repos/attachment/attachment.repo'; -import { StorageService } from '../../../../integrations/storage/storage.service'; -import { PageAccessService } from '../../page-access/page-access.service'; - -describe('TransclusionService.syncPageTransclusions', () => { - let service: TransclusionService; - let repo: jest.Mocked; - - beforeEach(async () => { - const mockRepo: jest.Mocked> = { - findByPageId: jest.fn(), - insert: jest.fn(), - update: jest.fn(), - deleteByPageAndTransclusionIds: jest.fn(), - }; - const module = await Test.createTestingModule({ - providers: [ - TransclusionService, - { provide: PageTransclusionsRepo, useValue: mockRepo }, - { provide: PageTransclusionReferencesRepo, useValue: {} }, - { provide: PageRepo, useValue: {} }, - { provide: PagePermissionRepo, useValue: {} }, - { provide: AttachmentRepo, useValue: {} }, - { provide: StorageService, useValue: {} }, - { provide: PageAccessService, useValue: {} }, - ], - }).compile(); - service = module.get(TransclusionService); - repo = module.get(PageTransclusionsRepo); - }); - - const pageId = '00000000-0000-0000-0000-000000000001'; - const workspaceId = '00000000-0000-0000-0000-000000000099'; - - it('inserts new transclusions that did not exist before', async () => { - repo.findByPageId.mockResolvedValue([]); - const pm = { - type: 'doc', - content: [ - { - type: 'transclusionSource', - attrs: { id: 'a' }, - content: [{ type: 'paragraph' }], - }, - ], - }; - - const result = await service.syncPageTransclusions(pageId, workspaceId, pm); - - expect(result).toEqual({ inserted: 1, updated: 0, deleted: 0 }); - expect(repo.insert).toHaveBeenCalledTimes(1); - expect(repo.insert).toHaveBeenCalledWith( - expect.objectContaining({ - pageId, - transclusionId: 'a', - }), - undefined, - ); - expect(repo.update).not.toHaveBeenCalled(); - expect(repo.deleteByPageAndTransclusionIds).not.toHaveBeenCalled(); - }); - - it('updates transclusions whose content changed', async () => { - repo.findByPageId.mockResolvedValue([ - { - id: 'row1', - pageId, - transclusionId: 'a', - content: { type: 'doc', content: [{ type: 'paragraph' }] }, - createdAt: new Date(), - updatedAt: new Date(), - } as any, - ]); - const newContent = { - type: 'doc', - content: [ - { type: 'paragraph', content: [{ type: 'text', text: 'X' }] }, - ], - }; - const pm = { - type: 'doc', - content: [ - { - type: 'transclusionSource', - attrs: { id: 'a' }, - content: newContent.content, - }, - ], - }; - - const result = await service.syncPageTransclusions(pageId, workspaceId, pm); - - expect(result).toEqual({ inserted: 0, updated: 1, deleted: 0 }); - expect(repo.update).toHaveBeenCalledWith( - pageId, - 'a', - expect.objectContaining({ content: newContent }), - undefined, - ); - }); - - it('skips update when content is unchanged', async () => { - const sameContent = { - type: 'doc', - content: [{ type: 'paragraph' }], - }; - repo.findByPageId.mockResolvedValue([ - { - id: 'row1', - pageId, - transclusionId: 'a', - content: sameContent, - createdAt: new Date(), - updatedAt: new Date(), - } as any, - ]); - const pm = { - type: 'doc', - content: [ - { - type: 'transclusionSource', - attrs: { id: 'a' }, - content: sameContent.content, - }, - ], - }; - - const result = await service.syncPageTransclusions(pageId, workspaceId, pm); - - expect(result).toEqual({ inserted: 0, updated: 0, deleted: 0 }); - expect(repo.update).not.toHaveBeenCalled(); - }); - - it('deletes transclusions that no longer appear in the doc', async () => { - repo.findByPageId.mockResolvedValue([ - { - id: 'r', - pageId, - transclusionId: 'gone', - content: { type: 'doc', content: [] }, - createdAt: new Date(), - updatedAt: new Date(), - } as any, - ]); - const pm = { type: 'doc', content: [{ type: 'paragraph' }] }; - - const result = await service.syncPageTransclusions(pageId, workspaceId, pm); - - expect(result).toEqual({ inserted: 0, updated: 0, deleted: 1 }); - expect(repo.deleteByPageAndTransclusionIds).toHaveBeenCalledWith( - pageId, - ['gone'], - undefined, - ); - }); - - it('handles empty doc → noop', async () => { - repo.findByPageId.mockResolvedValue([]); - const result = await service.syncPageTransclusions(pageId, workspaceId, null); - expect(result).toEqual({ inserted: 0, updated: 0, deleted: 0 }); - expect(repo.insert).not.toHaveBeenCalled(); - expect(repo.update).not.toHaveBeenCalled(); - expect(repo.deleteByPageAndTransclusionIds).not.toHaveBeenCalled(); - }); -}); - -describe('TransclusionService.syncPageReferences', () => { - let service: TransclusionService; - let refRepo: jest.Mocked; - - beforeEach(async () => { - const mockTransclusionsRepo: Partial = {}; - const mockRefRepo: jest.Mocked> = { - findByReferencePageId: jest.fn(), - insertMany: jest.fn(), - deleteByReferenceAndKeys: jest.fn(), - }; - const module = await Test.createTestingModule({ - providers: [ - TransclusionService, - { provide: PageTransclusionsRepo, useValue: mockTransclusionsRepo }, - { provide: PageTransclusionReferencesRepo, useValue: mockRefRepo }, - { provide: PageRepo, useValue: {} }, - { provide: PagePermissionRepo, useValue: {} }, - { provide: AttachmentRepo, useValue: {} }, - { provide: StorageService, useValue: {} }, - { provide: PageAccessService, useValue: {} }, - ], - }).compile(); - service = module.get(TransclusionService); - refRepo = module.get(PageTransclusionReferencesRepo); - }); - - const referencePageId = '00000000-0000-0000-0000-000000000001'; - const workspaceId = '00000000-0000-0000-0000-000000000099'; - - it('inserts new loose references, no deletes when none existed', async () => { - refRepo.findByReferencePageId.mockResolvedValue([]); - const pm = { - type: 'doc', - content: [ - { - type: 'transclusionReference', - attrs: { sourcePageId: 'p1', transclusionId: 'e1' }, - }, - { - type: 'transclusionReference', - attrs: { sourcePageId: 'p2', transclusionId: 'e2' }, - }, - ], - }; - - const result = await service.syncPageReferences(referencePageId, workspaceId, pm); - - expect(result).toEqual({ inserted: 2, deleted: 0 }); - expect(refRepo.insertMany).toHaveBeenCalledWith( - [ - { - workspaceId, - referencePageId, - sourcePageId: 'p1', - transclusionId: 'e1', - }, - { - workspaceId, - referencePageId, - sourcePageId: 'p2', - transclusionId: 'e2', - }, - ], - undefined, - ); - expect(refRepo.deleteByReferenceAndKeys).not.toHaveBeenCalled(); - }); - - it('ignores references nested inside a source (schema-forbidden)', async () => { - refRepo.findByReferencePageId.mockResolvedValue([]); - const pm = { - type: 'doc', - content: [ - { - type: 'transclusionSource', - attrs: { id: 's1' }, - content: [ - { - type: 'transclusionReference', - attrs: { sourcePageId: 'p2', transclusionId: 'e2' }, - }, - ], - }, - ], - }; - - const result = await service.syncPageReferences(referencePageId, workspaceId, pm); - - expect(result).toEqual({ inserted: 0, deleted: 0 }); - expect(refRepo.insertMany).not.toHaveBeenCalled(); - }); - - it('deletes references that no longer appear', async () => { - refRepo.findByReferencePageId.mockResolvedValue([ - { - id: 'r1', - referencePageId, - sourcePageId: 'p1', - transclusionId: 'e1', - createdAt: new Date(), - } as any, - ]); - const pm = { type: 'doc', content: [{ type: 'paragraph' }] }; - - const result = await service.syncPageReferences(referencePageId, workspaceId, pm); - - expect(result).toEqual({ inserted: 0, deleted: 1 }); - expect(refRepo.deleteByReferenceAndKeys).toHaveBeenCalledWith( - referencePageId, - [ - { - sourcePageId: 'p1', - transclusionId: 'e1', - }, - ], - undefined, - ); - expect(refRepo.insertMany).not.toHaveBeenCalled(); - }); - - it('is a no-op when desired matches existing exactly', async () => { - refRepo.findByReferencePageId.mockResolvedValue([ - { - id: 'r', - referencePageId, - sourcePageId: 'p1', - transclusionId: 'e1', - createdAt: new Date(), - } as any, - ]); - const pm = { - type: 'doc', - content: [ - { - type: 'transclusionReference', - attrs: { sourcePageId: 'p1', transclusionId: 'e1' }, - }, - ], - }; - - const result = await service.syncPageReferences(referencePageId, workspaceId, pm); - - expect(result).toEqual({ inserted: 0, deleted: 0 }); - expect(refRepo.insertMany).not.toHaveBeenCalled(); - expect(refRepo.deleteByReferenceAndKeys).not.toHaveBeenCalled(); - }); -}); diff --git a/apps/server/src/core/page/transclusion/transclusion.service.ts b/apps/server/src/core/page/transclusion/transclusion.service.ts index d851a9848..e208707c0 100644 --- a/apps/server/src/core/page/transclusion/transclusion.service.ts +++ b/apps/server/src/core/page/transclusion/transclusion.service.ts @@ -6,11 +6,13 @@ import { } from '@nestjs/common'; import { isDeepStrictEqual } from 'node:util'; import { v7 as uuid7 } from 'uuid'; -import { KyselyTransaction } from '@docmost/db/types/kysely.types'; +import { InjectKysely } from 'nestjs-kysely'; +import { KyselyDB, KyselyTransaction } from '@docmost/db/types/kysely.types'; import { PageTransclusionsRepo } from '@docmost/db/repos/page-transclusions/page-transclusions.repo'; import { PageTransclusionReferencesRepo } from '@docmost/db/repos/page-transclusions/page-transclusion-references.repo'; import { PageRepo } from '@docmost/db/repos/page/page.repo'; import { PagePermissionRepo } from '@docmost/db/repos/page/page-permission.repo'; +import { SpaceMemberRepo } from '@docmost/db/repos/space/space-member.repo'; import { AttachmentRepo } from '@docmost/db/repos/attachment/attachment.repo'; import { StorageService } from '../../../integrations/storage/storage.service'; import { @@ -36,10 +38,12 @@ export class TransclusionService { private readonly logger = new Logger(TransclusionService.name); constructor( + @InjectKysely() private readonly db: KyselyDB, private readonly pageTransclusionsRepo: PageTransclusionsRepo, private readonly pageTransclusionReferencesRepo: PageTransclusionReferencesRepo, private readonly pageRepo: PageRepo, private readonly pagePermissionRepo: PagePermissionRepo, + private readonly spaceMemberRepo: SpaceMemberRepo, private readonly attachmentRepo: AttachmentRepo, private readonly storageService: StorageService, private readonly pageAccessService: PageAccessService, @@ -213,6 +217,40 @@ export class TransclusionService { return { inserted: rows.length }; } + /** + * Resolve viewer access for source page IDs supplied by an authenticated + * caller. Restricts candidates to pages the viewer can see at the space + * level before applying page-level restrictions, so a workspace member + * cannot read a sync block from a private space they don't belong to via + * an unrestricted source page. + */ + private async filterViewerAccessiblePageIds( + pageIds: string[], + viewerUserId: string, + workspaceId: string, + ): Promise { + if (pageIds.length === 0) return []; + + const spaceVisible = await this.db + .selectFrom('pages') + .select('id') + .where('id', 'in', pageIds) + .where('workspaceId', '=', workspaceId) + .where('deletedAt', 'is', null) + .where( + 'spaceId', + 'in', + this.spaceMemberRepo.getUserSpaceIdsQuery(viewerUserId), + ) + .execute(); + if (spaceVisible.length === 0) return []; + + return this.pagePermissionRepo.filterAccessiblePageIds({ + pageIds: spaceVisible.map((r) => r.id), + userId: viewerUserId, + }); + } + async lookup( references: Array<{ sourcePageId: string; transclusionId: string }>, viewerUserId: string, @@ -224,10 +262,11 @@ export class TransclusionService { new Set(references.map((r) => r.sourcePageId)), ); const accessibleSet = new Set( - await this.pagePermissionRepo.filterAccessiblePageIds({ - pageIds: candidatePageIds, - userId: viewerUserId, - }), + await this.filterViewerAccessiblePageIds( + candidatePageIds, + viewerUserId, + workspaceId, + ), ); return this.lookupWithAccessSet(references, accessibleSet, workspaceId); @@ -336,10 +375,11 @@ export class TransclusionService { new Set([sourcePageId, ...referencePageIds]), ); const accessibleSet = new Set( - await this.pagePermissionRepo.filterAccessiblePageIds({ - pageIds: candidatePageIds, - userId: viewerUserId, - }), + await this.filterViewerAccessiblePageIds( + candidatePageIds, + viewerUserId, + workspaceId, + ), ); const accessibleIds = candidatePageIds.filter((id) => From 33895b060790358869f54ae451c3848480b39503 Mon Sep 17 00:00:00 2001 From: Philip Okugbe <16838612+Philipinho@users.noreply.github.com> Date: Thu, 28 May 2026 16:20:37 +0100 Subject: [PATCH 04/56] bug fixes (#2250) * util * fix page position collation * support fixed toolbar in templates editor * date localization * fix clipped emoji in templates editor * fix page updated time object * fix flickers * fix: remove redundant breadcrumb from destination modal --- .../public/locales/en-US/translation.json | 1 + .../destination-picker-modal.tsx | 6 +- .../destination-picker.module.css | 8 --- .../destination-picker/destination-picker.tsx | 8 --- .../ee/api-key/components/api-key-table.tsx | 5 +- .../components/create-api-key-modal.tsx | 4 +- .../ee/billing/components/billing-details.tsx | 10 ++- .../ee/licence/components/license-details.tsx | 11 +++- .../components/expiration-fields.tsx | 5 +- .../components/manage-verification-form.tsx | 14 +++-- .../components/page-verification-modal.tsx | 3 +- .../components/verification-list-table.tsx | 14 +++-- .../ee/scim/components/scim-token-table.tsx | 5 +- .../template/pages/template-editor.module.css | 6 ++ .../src/ee/template/pages/template-editor.tsx | 20 ++++++ .../src/ee/template/queries/template-query.ts | 2 + .../components/bubble-menu/bubble-menu.tsx | 32 +++++----- .../fixed-toolbar/fixed-toolbar.tsx | 21 ++++--- .../fixed-toolbar/groups/link-group.tsx | 6 -- .../fixed-toolbar/groups/media-group.tsx | 63 +++++++++++-------- .../groups/more-inserts-group.tsx | 53 +++++++++------- .../components/slash-menu/menu-items.ts | 3 +- .../features/editor/extensions/extensions.ts | 3 +- .../src/features/editor/page-editor.tsx | 21 ++++++- .../features/label/utils/format-label-date.ts | 22 +++++-- .../notification/notification.utils.ts | 7 ++- .../components/page-details-aside.tsx | 9 ++- .../features/share/components/share-list.tsx | 10 ++- apps/client/src/lib/date-locale.ts | 62 ++++++++++++++++++ apps/client/src/lib/time.ts | 20 ++++-- .../extensions/persistence.extension.ts | 15 +++++ .../src/common/helpers/types/permission.ts | 5 ++ .../src/core/page/services/page.service.ts | 1 + .../src/core/workspace/dto/invitation.dto.ts | 4 +- .../services/workspace-invitation.service.ts | 6 ++ .../workspace/services/workspace.service.ts | 11 ++-- .../src/core/workspace/workspace.util.ts | 8 +++ .../database/pagination/cursor-pagination.ts | 7 ++- 38 files changed, 360 insertions(+), 151 deletions(-) delete mode 100644 apps/client/src/features/editor/components/fixed-toolbar/groups/link-group.tsx create mode 100644 apps/client/src/lib/date-locale.ts create mode 100644 apps/server/src/core/workspace/workspace.util.ts diff --git a/apps/client/public/locales/en-US/translation.json b/apps/client/public/locales/en-US/translation.json index ec40a1967..278021657 100644 --- a/apps/client/public/locales/en-US/translation.json +++ b/apps/client/public/locales/en-US/translation.json @@ -424,6 +424,7 @@ "Names do not match": "Names do not match", "Today, {{time}}": "Today, {{time}}", "Yesterday, {{time}}": "Yesterday, {{time}}", + "now": "now", "Space created successfully": "Space created successfully", "Space updated successfully": "Space updated successfully", "Space deleted successfully": "Space deleted successfully", diff --git a/apps/client/src/components/ui/destination-picker/destination-picker-modal.tsx b/apps/client/src/components/ui/destination-picker/destination-picker-modal.tsx index 198d29959..aa7a68433 100644 --- a/apps/client/src/components/ui/destination-picker/destination-picker-modal.tsx +++ b/apps/client/src/components/ui/destination-picker/destination-picker-modal.tsx @@ -1,5 +1,5 @@ import { useState, useEffect } from "react"; -import { Modal, Button, Group } from "@mantine/core"; +import { Modal, Button, Group, Divider } from "@mantine/core"; import { useTranslation } from "react-i18next"; import { DestinationPicker } from "./destination-picker"; import { @@ -52,7 +52,9 @@ export function DestinationPickerModal({ searchSpacesOnly={searchSpacesOnly} /> - + + + diff --git a/apps/client/src/components/ui/destination-picker/destination-picker.module.css b/apps/client/src/components/ui/destination-picker/destination-picker.module.css index fb868bc14..2582dfbb5 100644 --- a/apps/client/src/components/ui/destination-picker/destination-picker.module.css +++ b/apps/client/src/components/ui/destination-picker/destination-picker.module.css @@ -89,14 +89,6 @@ } } -.selectedIndicator { - padding: 8px 12px; - font-size: var(--mantine-font-size-sm); - color: light-dark(var(--mantine-color-gray-6), var(--mantine-color-dark-2)); - border-top: 1px solid light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-4)); - margin-top: var(--mantine-spacing-xs); -} - .emptyState { padding: 12px; text-align: center; diff --git a/apps/client/src/components/ui/destination-picker/destination-picker.tsx b/apps/client/src/components/ui/destination-picker/destination-picker.tsx index b16a25a48..2dd51182a 100644 --- a/apps/client/src/components/ui/destination-picker/destination-picker.tsx +++ b/apps/client/src/components/ui/destination-picker/destination-picker.tsx @@ -221,14 +221,6 @@ export function DestinationPicker({ )) )} - - {selection && ( -
- {selection.type === "space" - ? selection.space.name - : `${selection.space.name} / ${selection.page.title || t("Untitled")}`} -
- )} ); } diff --git a/apps/client/src/ee/api-key/components/api-key-table.tsx b/apps/client/src/ee/api-key/components/api-key-table.tsx index f17b3d8d1..efb774484 100644 --- a/apps/client/src/ee/api-key/components/api-key-table.tsx +++ b/apps/client/src/ee/api-key/components/api-key-table.tsx @@ -1,11 +1,11 @@ import { ActionIcon, Group, Menu, Table, Text } from "@mantine/core"; import { IconDots, IconEdit, IconTrash } from "@tabler/icons-react"; -import { format } from "date-fns"; import { useTranslation } from "react-i18next"; import { IApiKey } from "@/ee/api-key"; import { CustomAvatar } from "@/components/ui/custom-avatar.tsx"; import React from "react"; import NoTableResults from "@/components/common/no-table-results"; +import { formatLocalized, useDateFnsLocale } from "@/lib/date-locale.ts"; interface ApiKeyTableProps { apiKeys: IApiKey[]; @@ -23,10 +23,11 @@ export function ApiKeyTable({ onRevoke, }: ApiKeyTableProps) { const { t } = useTranslation(); + const locale = useDateFnsLocale(); const formatDate = (date: Date | string | null) => { if (!date) return t("Never"); - return format(new Date(date), "MMM dd, yyyy"); + return formatLocalized(date, "MMM dd, yyyy", "PP", locale); }; const isExpired = (expiresAt: string | null) => { diff --git a/apps/client/src/ee/api-key/components/create-api-key-modal.tsx b/apps/client/src/ee/api-key/components/create-api-key-modal.tsx index 0f639bf45..53341a614 100644 --- a/apps/client/src/ee/api-key/components/create-api-key-modal.tsx +++ b/apps/client/src/ee/api-key/components/create-api-key-modal.tsx @@ -31,7 +31,7 @@ export function CreateApiKeyModal({ onClose, onSuccess, }: CreateApiKeyModalProps) { - const { t } = useTranslation(); + const { t, i18n } = useTranslation(); const [expirationOption, setExpirationOption] = useState("30"); const createApiKeyMutation = useCreateApiKeyMutation(); @@ -59,7 +59,7 @@ export function CreateApiKeyModal({ const getExpirationLabel = (days: number) => { const date = new Date(); date.setDate(date.getDate() + days); - const formatted = date.toLocaleDateString("en-US", { + const formatted = date.toLocaleDateString(i18n.language, { month: "short", day: "2-digit", year: "numeric", diff --git a/apps/client/src/ee/billing/components/billing-details.tsx b/apps/client/src/ee/billing/components/billing-details.tsx index 0fb061471..e4a5e5f82 100644 --- a/apps/client/src/ee/billing/components/billing-details.tsx +++ b/apps/client/src/ee/billing/components/billing-details.tsx @@ -4,12 +4,13 @@ import { } from "@/ee/billing/queries/billing-query.ts"; import { Group, Text, SimpleGrid, Paper } from "@mantine/core"; import classes from "./billing.module.css"; -import { format } from "date-fns"; import { formatInterval } from "@/ee/billing/utils.ts"; +import { formatLocalized, useDateFnsLocale } from "@/lib/date-locale.ts"; export default function BillingDetails() { const { data: billing } = useBillingQuery(); const { data: plans } = useBillingPlans(); + const locale = useDateFnsLocale(); if (!billing || !plans) { return null; @@ -75,7 +76,12 @@ export default function BillingDetails() { : "Renewal date"} - {format(billing.periodEndAt, "dd MMM, yyyy")} + {formatLocalized( + billing.periodEndAt, + "dd MMM, yyyy", + "PP", + locale, + )}
diff --git a/apps/client/src/ee/licence/components/license-details.tsx b/apps/client/src/ee/licence/components/license-details.tsx index d3a936329..0a805de91 100644 --- a/apps/client/src/ee/licence/components/license-details.tsx +++ b/apps/client/src/ee/licence/components/license-details.tsx @@ -1,13 +1,14 @@ import { Badge, Table } from "@mantine/core"; -import { format } from "date-fns"; import { useLicenseInfo } from "@/ee/licence/queries/license-query.ts"; import { isLicenseExpired } from "@/ee/licence/license.utils.ts"; import { useAtom } from "jotai"; import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts"; +import { formatLocalized, useDateFnsLocale } from "@/lib/date-locale.ts"; export default function LicenseDetails() { const { data: license, isError } = useLicenseInfo(); const [workspace] = useAtom(workspaceAtom); + const locale = useDateFnsLocale(); if (!license) { return null; @@ -50,12 +51,16 @@ export default function LicenseDetails() { Issued at - {format(license.issuedAt, "dd MMMM, yyyy")} + + {formatLocalized(license.issuedAt, "dd MMMM, yyyy", "PPP", locale)} + Expires at - {format(license.expiresAt, "dd MMMM, yyyy")} + + {formatLocalized(license.expiresAt, "dd MMMM, yyyy", "PPP", locale)} + License ID diff --git a/apps/client/src/ee/page-verification/components/expiration-fields.tsx b/apps/client/src/ee/page-verification/components/expiration-fields.tsx index 9dc7a96fe..ad2102f9f 100644 --- a/apps/client/src/ee/page-verification/components/expiration-fields.tsx +++ b/apps/client/src/ee/page-verification/components/expiration-fields.tsx @@ -1,6 +1,7 @@ import { Group, NumberInput, Select, Text } from "@mantine/core"; import { DateInput } from "@mantine/dates"; import { useTranslation } from "react-i18next"; +import i18n from "@/i18n.ts"; import { ExpirationMode, PeriodUnit, @@ -30,7 +31,7 @@ export function addDays(days: number, from?: Date): Date { function formatShortDate(date: Date): string { const crossesYear = date.getFullYear() !== new Date().getFullYear(); - return date.toLocaleDateString(undefined, { + return date.toLocaleDateString(i18n.language, { month: "short", day: "numeric", ...(crossesYear && { year: "numeric" }), @@ -38,7 +39,7 @@ function formatShortDate(date: Date): string { } function formatLongDate(date: Date): string { - return date.toLocaleDateString(undefined, { + return date.toLocaleDateString(i18n.language, { month: "long", day: "numeric", year: "numeric", diff --git a/apps/client/src/ee/page-verification/components/manage-verification-form.tsx b/apps/client/src/ee/page-verification/components/manage-verification-form.tsx index f2fda1987..9d5214f7b 100644 --- a/apps/client/src/ee/page-verification/components/manage-verification-form.tsx +++ b/apps/client/src/ee/page-verification/components/manage-verification-form.tsx @@ -12,6 +12,7 @@ import { } from "@mantine/core"; import { modals } from "@mantine/modals"; import { useTranslation } from "react-i18next"; +import i18n from "@/i18n.ts"; import { useMarkObsoleteMutation, usePageVerificationInfoQuery, @@ -197,11 +198,14 @@ function ExpiringManageContent({ pageId, info, onClose }: ManageContentProps) { {info.expiresAt && ( {t(status === "expired" ? "Expired {{date}}" : "Expires {{date}}", { - date: new Date(info.expiresAt).toLocaleDateString(undefined, { - month: "long", - day: "numeric", - year: "numeric", - }), + date: new Date(info.expiresAt).toLocaleDateString( + i18n.language, + { + month: "long", + day: "numeric", + year: "numeric", + }, + ), })} )} diff --git a/apps/client/src/ee/page-verification/components/page-verification-modal.tsx b/apps/client/src/ee/page-verification/components/page-verification-modal.tsx index b8db0d8ce..a27d3a295 100644 --- a/apps/client/src/ee/page-verification/components/page-verification-modal.tsx +++ b/apps/client/src/ee/page-verification/components/page-verification-modal.tsx @@ -13,6 +13,7 @@ import { IconShieldCheck, } from "@tabler/icons-react"; import { useTranslation } from "react-i18next"; +import i18n from "@/i18n.ts"; import { useParams } from "react-router-dom"; import { extractPageSlugId } from "@/lib"; import { usePageQuery } from "@/features/page/queries/page-query"; @@ -127,7 +128,7 @@ export function PageVerificationBadge({ status === "verified" && verificationInfo?.expiresAt ? t("Verified until {{date}}", { date: new Date(verificationInfo.expiresAt).toLocaleDateString( - undefined, + i18n.language, { month: "long", day: "numeric", year: "numeric" }, ), }) diff --git a/apps/client/src/ee/page-verification/components/verification-list-table.tsx b/apps/client/src/ee/page-verification/components/verification-list-table.tsx index 675e05c98..832d7a4f7 100644 --- a/apps/client/src/ee/page-verification/components/verification-list-table.tsx +++ b/apps/client/src/ee/page-verification/components/verification-list-table.tsx @@ -16,9 +16,10 @@ import { } from "@/ee/page-verification/types/page-verification.types"; import { CustomAvatar } from "@/components/ui/custom-avatar"; import { buildPageUrl } from "@/features/page/page.utils"; -import { format } from "date-fns"; import NoTableResults from "@/components/common/no-table-results"; import rowClasses from "@/components/ui/clickable-table-row.module.css"; +import { formatLocalized, useDateFnsLocale } from "@/lib/date-locale.ts"; +import type { Locale } from "date-fns"; const MAX_VISIBLE_VERIFIERS = 5; @@ -48,7 +49,11 @@ function statusBadge(status: VerificationStatus | null, t: (s: string) => string } } -function verifiedUntilText(item: IVerificationListItem, t: (s: string) => string): string { +function verifiedUntilText( + item: IVerificationListItem, + t: (s: string) => string, + locale: Locale, +): string { if (item.type === "qms") { if (item.status === "approved") return t("Indefinitely"); return "—"; @@ -60,7 +65,7 @@ function verifiedUntilText(item: IVerificationListItem, t: (s: string) => string const now = new Date(); if (expires <= now) return t("Expired"); - return format(expires, "MMM d, yyyy"); + return formatLocalized(expires, "MMM d, yyyy", "PP", locale); } function TableSkeleton() { @@ -98,6 +103,7 @@ export default function VerificationListTable({ isLoading, }: VerificationListTableProps) { const { t } = useTranslation(); + const locale = useDateFnsLocale(); return ( @@ -200,7 +206,7 @@ export default function VerificationListTable({ - {verifiedUntilText(item, t)} + {verifiedUntilText(item, t, locale)} diff --git a/apps/client/src/ee/scim/components/scim-token-table.tsx b/apps/client/src/ee/scim/components/scim-token-table.tsx index eb36f4096..90572be3a 100644 --- a/apps/client/src/ee/scim/components/scim-token-table.tsx +++ b/apps/client/src/ee/scim/components/scim-token-table.tsx @@ -1,11 +1,11 @@ import { ActionIcon, Group, Menu, Table, Text } from "@mantine/core"; import { IconDots, IconEdit, IconTrash } from "@tabler/icons-react"; -import { format } from "date-fns"; import { useTranslation } from "react-i18next"; import { CustomAvatar } from "@/components/ui/custom-avatar.tsx"; import React from "react"; import NoTableResults from "@/components/common/no-table-results"; import { IScimToken } from "@/ee/scim/types/scim-token.types"; +import { formatLocalized, useDateFnsLocale } from "@/lib/date-locale.ts"; interface ScimTokenTableProps { tokens: IScimToken[]; @@ -21,10 +21,11 @@ export function ScimTokenTable({ onRevoke, }: ScimTokenTableProps) { const { t } = useTranslation(); + const locale = useDateFnsLocale(); const formatDate = (date: Date | string | null) => { if (!date) return t("Never"); - return format(new Date(date), "MMM dd, yyyy"); + return formatLocalized(date, "MMM dd, yyyy", "PP", locale); }; return ( diff --git a/apps/client/src/ee/template/pages/template-editor.module.css b/apps/client/src/ee/template/pages/template-editor.module.css index 5c5adddb3..8a746398b 100644 --- a/apps/client/src/ee/template/pages/template-editor.module.css +++ b/apps/client/src/ee/template/pages/template-editor.module.css @@ -32,6 +32,12 @@ margin-bottom: 0.25em; } +/* The emoji glyph renders larger than its font-size box; let the transparent + ActionIcon overflow so it isn't clipped on the edges. */ +.emojiButton button { + overflow: visible; +} + .titleInput { font-size: 2.5rem; font-weight: 700; diff --git a/apps/client/src/ee/template/pages/template-editor.tsx b/apps/client/src/ee/template/pages/template-editor.tsx index 439cbb964..cef891060 100644 --- a/apps/client/src/ee/template/pages/template-editor.tsx +++ b/apps/client/src/ee/template/pages/template-editor.tsx @@ -32,6 +32,12 @@ import { } from "../queries/template-query"; import { useGetSpacesQuery } from "@/features/space/queries/space-query"; import useUserRole from "@/hooks/use-user-role"; +import { useAtomValue } from "jotai"; +import { userAtom } from "@/features/user/atoms/current-user-atom"; +import { FixedToolbar } from "@/features/editor/components/fixed-toolbar/fixed-toolbar"; +import { EditorLinkMenu } from "@/features/editor/components/link/link-menu"; +import { EditorBubbleMenu } from "@/features/editor/components/bubble-menu/bubble-menu"; +import { EditorAiMenu } from "@/ee/ai/components/editor/ai-menu/ai-menu"; import classes from "./template-editor.module.css"; @@ -39,6 +45,9 @@ export default function TemplateEditor() { const { t } = useTranslation(); const { templateId } = useParams<{ templateId: string }>(); const { isAdmin: isWorkspaceAdmin } = useUserRole(); + const user = useAtomValue(userAtom); + const editorToolbarEnabled = + user?.settings?.preferences?.editorToolbar ?? false; const { data: existingTemplate } = useGetTemplateByIdQuery(templateId || ""); const { data: spaces } = useGetSpacesQuery({ limit: 100 }); @@ -238,6 +247,10 @@ export default function TemplateEditor() { + {editorToolbarEnabled && editor && ( + + )} +
@@ -379,6 +392,13 @@ export default function TemplateEditor() { )}
+ {editor && ( + <> + + + + + )}
diff --git a/apps/client/src/ee/template/queries/template-query.ts b/apps/client/src/ee/template/queries/template-query.ts index 237ca94d8..81bf73874 100644 --- a/apps/client/src/ee/template/queries/template-query.ts +++ b/apps/client/src/ee/template/queries/template-query.ts @@ -5,6 +5,7 @@ import { useQueryClient, UseQueryResult, InfiniteData, + keepPreviousData, } from "@tanstack/react-query"; import { useAtom, useStore } from "jotai"; import { @@ -35,6 +36,7 @@ export function useGetTemplatesQuery(params?: { spaceId?: string }) { initialPageParam: undefined as string | undefined, getNextPageParam: (lastPage) => lastPage.meta.hasNextPage ? lastPage.meta.nextCursor : undefined, + placeholderData: keepPreviousData, }); } diff --git a/apps/client/src/features/editor/components/bubble-menu/bubble-menu.tsx b/apps/client/src/features/editor/components/bubble-menu/bubble-menu.tsx index 27c4b3854..5ad966af9 100644 --- a/apps/client/src/features/editor/components/bubble-menu/bubble-menu.tsx +++ b/apps/client/src/features/editor/components/bubble-menu/bubble-menu.tsx @@ -38,9 +38,11 @@ export interface BubbleMenuItem { type EditorBubbleMenuProps = Omit & { editor: Editor | null; + templateMode?: boolean; }; export const EditorBubbleMenu: FC = (props) => { + const { templateMode = false } = props; const { t } = useTranslation(); const [showAiMenu, setShowAiMenu] = useAtom(showAiMenuAtom); const [showCommentPopup, setShowCommentPopup] = useAtom(showCommentPopupAtom); @@ -232,8 +234,6 @@ export const EditorBubbleMenu: FC = (props) => { ))} - - = (props) => { )} - - - - - + + + {!templateMode && ( + + + + + + )}
); diff --git a/apps/client/src/features/editor/components/fixed-toolbar/fixed-toolbar.tsx b/apps/client/src/features/editor/components/fixed-toolbar/fixed-toolbar.tsx index d72db0c7d..b425753ee 100644 --- a/apps/client/src/features/editor/components/fixed-toolbar/fixed-toolbar.tsx +++ b/apps/client/src/features/editor/components/fixed-toolbar/fixed-toolbar.tsx @@ -1,12 +1,12 @@ import { FC } from "react"; import { useAtomValue } from "jotai"; +import type { Editor } from "@tiptap/react"; import { pageEditorAtom } from "@/features/editor/atoms/editor-atoms"; import { useToolbarState } from "./use-toolbar-state"; import { BlockTypeGroup } from "./groups/block-type-group"; import { InlineMarksGroup } from "./groups/inline-marks-group"; import { ColorGroup } from "./groups/color-group"; import { ListsGroup } from "./groups/lists-group"; -import { LinkGroup } from "./groups/link-group"; import { AlignmentGroup } from "./groups/alignment-group"; import { MediaGroup } from "./groups/media-group"; import { QuickInsertsGroup } from "./groups/quick-inserts-group"; @@ -16,8 +16,17 @@ import { AskAiGroup } from "./groups/ask-ai-group"; import { workspaceAtom } from "@/features/user/atoms/current-user-atom"; import classes from "./fixed-toolbar.module.css"; -export const FixedToolbar: FC = () => { - const editor = useAtomValue(pageEditorAtom); +type FixedToolbarProps = { + editor?: Editor | null; + templateMode?: boolean; +}; + +export const FixedToolbar: FC = ({ + editor: editorProp, + templateMode = false, +}) => { + const editorFromAtom = useAtomValue(pageEditorAtom); + const editor = editorProp ?? editorFromAtom; const state = useToolbarState(editor); const workspace = useAtomValue(workspaceAtom); const isGenerativeAiEnabled = workspace?.settings?.ai?.generative === true; @@ -48,14 +57,12 @@ export const FixedToolbar: FC = () => {
- -
- +
- +
diff --git a/apps/client/src/features/editor/components/fixed-toolbar/groups/link-group.tsx b/apps/client/src/features/editor/components/fixed-toolbar/groups/link-group.tsx deleted file mode 100644 index 334765928..000000000 --- a/apps/client/src/features/editor/components/fixed-toolbar/groups/link-group.tsx +++ /dev/null @@ -1,6 +0,0 @@ -import { FC } from "react"; -import { LinkSelector } from "@/features/editor/components/bubble-menu/link-selector"; - -export const LinkGroup: FC = () => { - return ; -}; diff --git a/apps/client/src/features/editor/components/fixed-toolbar/groups/media-group.tsx b/apps/client/src/features/editor/components/fixed-toolbar/groups/media-group.tsx index 7740204e1..5d99aa079 100644 --- a/apps/client/src/features/editor/components/fixed-toolbar/groups/media-group.tsx +++ b/apps/client/src/features/editor/components/fixed-toolbar/groups/media-group.tsx @@ -17,6 +17,7 @@ import { uploadPdfAction } from "@/features/editor/components/pdf/upload-pdf-act interface Props { editor: Editor; + templateMode?: boolean; } type UploadFn = ( @@ -60,7 +61,7 @@ function pickFile( input.click(); } -export const MediaGroup: FC = ({ editor }) => { +export const MediaGroup: FC = ({ editor, templateMode }) => { const { t } = useTranslation(); return ( @@ -78,24 +79,30 @@ export const MediaGroup: FC = ({ editor }) => { - } - onClick={() => pickFile(editor, "image/*", true, uploadImageAction)} - > - {t("Image")} - - } - onClick={() => pickFile(editor, "video/*", true, uploadVideoAction)} - > - {t("Video")} - - } - onClick={() => pickFile(editor, "audio/*", true, uploadAudioAction)} - > - {t("Audio")} - + {!templateMode && ( + } + onClick={() => pickFile(editor, "image/*", true, uploadImageAction)} + > + {t("Image")} + + )} + {!templateMode && ( + } + onClick={() => pickFile(editor, "video/*", true, uploadVideoAction)} + > + {t("Video")} + + )} + {!templateMode && ( + } + onClick={() => pickFile(editor, "audio/*", true, uploadAudioAction)} + > + {t("Audio")} + + )} } onClick={() => @@ -104,14 +111,16 @@ export const MediaGroup: FC = ({ editor }) => { > PDF - } - onClick={() => - pickFile(editor, "", true, uploadAttachmentAction, true) - } - > - {t("File attachment")} - + {!templateMode && ( + } + onClick={() => + pickFile(editor, "", true, uploadAttachmentAction, true) + } + > + {t("File attachment")} + + )} ); diff --git a/apps/client/src/features/editor/components/fixed-toolbar/groups/more-inserts-group.tsx b/apps/client/src/features/editor/components/fixed-toolbar/groups/more-inserts-group.tsx index 86a452206..0b762be1c 100644 --- a/apps/client/src/features/editor/components/fixed-toolbar/groups/more-inserts-group.tsx +++ b/apps/client/src/features/editor/components/fixed-toolbar/groups/more-inserts-group.tsx @@ -32,16 +32,17 @@ import { useTranslation } from "react-i18next"; interface Props { editor: Editor; + templateMode?: boolean; } -export const MoreInsertsGroup: FC = ({ editor }) => { - const { t } = useTranslation(); +export const MoreInsertsGroup: FC = ({ editor, templateMode }) => { + const { t, i18n } = useTranslation(); const setEmbed = (provider: string) => editor.chain().focus().setEmbed({ provider }).run(); const insertDate = () => { - const currentDate = new Date().toLocaleDateString("en-US", { + const currentDate = new Date().toLocaleDateString(i18n.language, { year: "numeric", month: "long", day: "numeric", @@ -91,14 +92,16 @@ export const MoreInsertsGroup: FC = ({ editor }) => { > {t("Subpages")} - } - onClick={() => - editor.chain().focus().insertTransclusionSource().run() - } - > - {t("Synced block")} - + {!templateMode && ( + } + onClick={() => + editor.chain().focus().insertTransclusionSource().run() + } + > + {t("Synced block")} + + )} {t("Diagrams")} @@ -115,18 +118,22 @@ export const MoreInsertsGroup: FC = ({ editor }) => { > {t("Mermaid diagram")} - } - onClick={() => editor.chain().focus().setDrawio().run()} - > - Draw.io - - } - onClick={() => editor.chain().focus().setExcalidraw().run()} - > - Excalidraw - + {!templateMode && ( + } + onClick={() => editor.chain().focus().setDrawio().run()} + > + Draw.io + + )} + {!templateMode && ( + } + onClick={() => editor.chain().focus().setExcalidraw().run()} + > + Excalidraw + + )} {t("Embeds")} diff --git a/apps/client/src/features/editor/components/slash-menu/menu-items.ts b/apps/client/src/features/editor/components/slash-menu/menu-items.ts index cddddc35f..7f8567558 100644 --- a/apps/client/src/features/editor/components/slash-menu/menu-items.ts +++ b/apps/client/src/features/editor/components/slash-menu/menu-items.ts @@ -43,6 +43,7 @@ import IconMermaid from "@/components/icons/icon-mermaid"; import IconDrawio from "@/components/icons/icon-drawio"; import { IconColumns4 } from "@/components/icons/icon-columns-4"; import { IconColumns5 } from "@/components/icons/icon-columns-5"; +import i18n from "@/i18n.ts"; import { AirtableIcon, FigmaIcon, @@ -459,7 +460,7 @@ const CommandGroups: SlashMenuGroupedItemsType = { searchTerms: ["date", "today"], icon: IconCalendar, command: ({ editor, range }: CommandProps) => { - const currentDate = new Date().toLocaleDateString("en-US", { + const currentDate = new Date().toLocaleDateString(i18n.language, { year: "numeric", month: "long", day: "numeric", diff --git a/apps/client/src/features/editor/extensions/extensions.ts b/apps/client/src/features/editor/extensions/extensions.ts index f991b653c..87c7b9e5f 100644 --- a/apps/client/src/features/editor/extensions/extensions.ts +++ b/apps/client/src/features/editor/extensions/extensions.ts @@ -3,7 +3,7 @@ import { StarterKit } from "@tiptap/starter-kit"; import { Code } from "@tiptap/extension-code"; import { TextAlign } from "@tiptap/extension-text-align"; import { TaskList, TaskItem } from "@tiptap/extension-list"; -import { Placeholder, CharacterCount } from "@tiptap/extensions"; +import { Placeholder, CharacterCount, UndoRedo } from "@tiptap/extensions"; import { Superscript } from "@tiptap/extension-superscript"; import SubScript from "@tiptap/extension-subscript"; import { Typography } from "@tiptap/extension-typography"; @@ -437,6 +437,7 @@ const TemplateSlashCommand = Command.configure({ export const templateExtensions = [ ...mainExtensions.filter((ext: any) => ext !== SlashCommand), TemplateSlashCommand, + UndoRedo, ] as any; export const collabExtensions: CollabExtensions = (provider, user) => [ diff --git a/apps/client/src/features/editor/page-editor.tsx b/apps/client/src/features/editor/page-editor.tsx index a703561f5..9d1316943 100644 --- a/apps/client/src/features/editor/page-editor.tsx +++ b/apps/client/src/features/editor/page-editor.tsx @@ -14,6 +14,7 @@ import { WebSocketStatus, HocuspocusProviderWebsocket, onSyncedParameters, + onStatelessParameters, } from "@hocuspocus/provider"; import { Editor, @@ -145,6 +146,24 @@ export default function PageEditor({ const onSyncedHandler = (event: onSyncedParameters) => { setIsRemoteSynced(event.state); }; + const onStatelessHandler = ({ payload }: onStatelessParameters) => { + try { + const message = JSON.parse(payload); + if (message?.type !== "page.updated" || !message.updatedAt) return; + const pageData = queryClient.getQueryData(["pages", slugId]); + if (pageData) { + queryClient.setQueryData(["pages", slugId], { + ...pageData, + updatedAt: message.updatedAt, + ...(message.lastUpdatedBy && { + lastUpdatedBy: message.lastUpdatedBy, + }), + }); + } + } catch { + // ignore unrelated stateless messages + } + }; const onAuthenticationFailedHandler = () => { const payload = jwtDecode(collabQuery?.token); const now = Date.now().valueOf() / 1000; @@ -169,6 +188,7 @@ export default function PageEditor({ onAuthenticationFailed: onAuthenticationFailedHandler, onStatus: onStatusHandler, onSynced: onSyncedHandler, + onStateless: onStatelessHandler, }); local.on("synced", onLocalSyncedHandler); @@ -318,7 +338,6 @@ export default function PageEditor({ queryClient.setQueryData(["pages", slugId], { ...pageData, content: newContent, - updatedAt: new Date(), }); } }, 3000); diff --git a/apps/client/src/features/label/utils/format-label-date.ts b/apps/client/src/features/label/utils/format-label-date.ts index 1221c8ad8..af26fac91 100644 --- a/apps/client/src/features/label/utils/format-label-date.ts +++ b/apps/client/src/features/label/utils/format-label-date.ts @@ -1,15 +1,27 @@ -import { format, isThisYear, isToday, isYesterday } from "date-fns"; +import { isThisYear, isToday, isYesterday } from "date-fns"; import i18n from "@/i18n.ts"; +import { formatLocalized, getDateFnsLocale } from "@/lib/date-locale.ts"; export function formatLabelListDate(date: Date): string { + const locale = getDateFnsLocale(); if (isToday(date)) { - return i18n.t("Today, {{time}}", { time: format(date, "h:mma") }); + return i18n.t("Today, {{time}}", { + time: formatLocalized(date, "h:mma", "p", locale), + }); } if (isYesterday(date)) { - return i18n.t("Yesterday, {{time}}", { time: format(date, "h:mma") }); + return i18n.t("Yesterday, {{time}}", { + time: formatLocalized(date, "h:mma", "p", locale), + }); } if (isThisYear(date)) { - return format(date, "MMM dd"); + if (locale.code?.startsWith("en")) { + return formatLocalized(date, "MMM dd", "MMM dd", locale); + } + return new Intl.DateTimeFormat(i18n.language, { + month: "short", + day: "numeric", + }).format(date); } - return format(date, "MMM dd, yyyy"); + return formatLocalized(date, "MMM dd, yyyy", "PP", locale); } diff --git a/apps/client/src/features/notification/notification.utils.ts b/apps/client/src/features/notification/notification.utils.ts index 266bfc278..83b1b2891 100644 --- a/apps/client/src/features/notification/notification.utils.ts +++ b/apps/client/src/features/notification/notification.utils.ts @@ -1,3 +1,4 @@ +import i18n from "@/i18n.ts"; import { INotification } from "./types/notification.types"; export function formatRelativeTime(dateStr: string): string { @@ -8,15 +9,15 @@ export function formatRelativeTime(dateStr: string): string { const diffHours = Math.floor(diffMs / 3_600_000); const diffDays = Math.floor(diffMs / 86_400_000); - if (diffMin < 1) return "now"; + if (diffMin < 1) return i18n.t("now"); if (diffMin < 60) return `${diffMin}m`; if (diffHours < 24) return `${diffHours}h`; if (diffDays < 7) return `${diffDays}d`; - return date.toLocaleDateString(undefined, { + return new Intl.DateTimeFormat(i18n.language, { month: "short", day: "numeric", - }); + }).format(date); } type TimeGroup = "today" | "yesterday" | "this_week" | "older"; diff --git a/apps/client/src/features/page-details/components/page-details-aside.tsx b/apps/client/src/features/page-details/components/page-details-aside.tsx index 84209d7a6..89c51027c 100644 --- a/apps/client/src/features/page-details/components/page-details-aside.tsx +++ b/apps/client/src/features/page-details/components/page-details-aside.tsx @@ -16,7 +16,8 @@ import { usePageQuery } from "@/features/page/queries/page-query.ts"; import { pageEditorAtom } from "@/features/editor/atoms/editor-atoms.ts"; import { useBacklinksCountQuery } from "@/features/page-details/queries/backlinks-query.ts"; import { BacklinksModal } from "./backlinks-modal"; -import { formattedDate, timeAgo } from "@/lib/time.ts"; +import { formattedDate } from "@/lib/time.ts"; +import { useTimeAgo } from "@/hooks/use-time-ago.tsx"; import { CustomAvatar } from "@/components/ui/custom-avatar.tsx"; import { LabelsSection } from "@/features/label/components/labels-section.tsx"; @@ -139,6 +140,7 @@ function StatsSection({ updatedAt: Date | string; }) { const { t } = useTranslation(); + const lastUpdated = useTimeAgo(updatedAt); return ( @@ -150,10 +152,7 @@ function StatsSection({ label={t("Created")} value={formattedDate(new Date(createdAt))} /> - + ); } diff --git a/apps/client/src/features/share/components/share-list.tsx b/apps/client/src/features/share/components/share-list.tsx index 37ea4abf6..aab214cdd 100644 --- a/apps/client/src/features/share/components/share-list.tsx +++ b/apps/client/src/features/share/components/share-list.tsx @@ -7,8 +7,8 @@ import Paginate from "@/components/common/paginate.tsx"; import { useCursorPaginate } from "@/hooks/use-cursor-paginate"; import { useGetSharesQuery } from "@/features/share/queries/share-query.ts"; import { ISharedItem } from "@/features/share/types/share.types.ts"; -import { format } from "date-fns"; import ShareActionMenu from "@/features/share/components/share-action-menu.tsx"; +import { formatLocalized, useDateFnsLocale } from "@/lib/date-locale.ts"; import { buildSharedPageUrl } from "@/features/page/page.utils.ts"; import { getPageIcon } from "@/lib"; import { CustomAvatar } from "@/components/ui/custom-avatar.tsx"; @@ -20,6 +20,7 @@ export default function ShareList() { const { t } = useTranslation(); const { cursor, goNext, goPrev } = useCursorPaginate(); const { data, isLoading } = useGetSharesQuery({ cursor }); + const locale = useDateFnsLocale(); if (!isLoading && data?.items.length === 0) { return ; @@ -81,7 +82,12 @@ export default function ShareList() { - {format(new Date(share.createdAt), "MMM dd, yyyy")} + {formatLocalized( + share.createdAt, + "MMM dd, yyyy", + "PP", + locale, + )} diff --git a/apps/client/src/lib/date-locale.ts b/apps/client/src/lib/date-locale.ts new file mode 100644 index 000000000..7d683978b --- /dev/null +++ b/apps/client/src/lib/date-locale.ts @@ -0,0 +1,62 @@ +import { format as dateFnsFormat, type Locale } from "date-fns"; +import { + de, + enUS, + es, + fr, + it, + ja, + ko, + nl, + ptBR, + ru, + uk, + zhCN, +} from "date-fns/locale"; +import { useTranslation } from "react-i18next"; +import i18n from "@/i18n.ts"; + +const LOCALE_MAP: Record = { + "de-DE": de, + "en-US": enUS, + "es-ES": es, + "fr-FR": fr, + "it-IT": it, + "ja-JP": ja, + "ko-KR": ko, + "nl-NL": nl, + "pt-BR": ptBR, + "ru-RU": ru, + "uk-UA": uk, + "zh-CN": zhCN, +}; + +export function getDateFnsLocale(language?: string): Locale { + const lang = language ?? i18n.language ?? "en-US"; + return LOCALE_MAP[lang] ?? LOCALE_MAP[lang.split("-")[0]] ?? enUS; +} + +export function useDateFnsLocale(): Locale { + const { i18n: instance } = useTranslation(); + return getDateFnsLocale(instance.language); +} + +function isEnglishLocale(locale: Locale): boolean { + return locale.code === "en-US" || locale.code?.startsWith("en") === true; +} + +/** + * Picks `enUSPattern` for the English locale and `localizedPattern` for every + * other locale. Keeps existing en-US output byte-identical while letting other + * languages use date-fns localized format tokens (P, PP, p, PPp, …). + */ +export function formatLocalized( + date: Date | number | string, + enUSPattern: string, + localizedPattern: string, + locale?: Locale, +): string { + const effective = locale ?? getDateFnsLocale(); + const pattern = isEnglishLocale(effective) ? enUSPattern : localizedPattern; + return dateFnsFormat(new Date(date), pattern, { locale: effective }); +} diff --git a/apps/client/src/lib/time.ts b/apps/client/src/lib/time.ts index 0e320c1fa..a6056dd32 100644 --- a/apps/client/src/lib/time.ts +++ b/apps/client/src/lib/time.ts @@ -1,17 +1,25 @@ -import { formatDistanceStrict } from "date-fns"; -import { format, isToday, isYesterday } from "date-fns"; +import { formatDistanceStrict, isToday, isYesterday } from "date-fns"; import i18n from "@/i18n.ts"; +import { formatLocalized, getDateFnsLocale } from "@/lib/date-locale.ts"; export function timeAgo(date: Date) { - return formatDistanceStrict(new Date(date), new Date(), { addSuffix: true }); + return formatDistanceStrict(new Date(date), new Date(), { + addSuffix: true, + locale: getDateFnsLocale(), + }); } export function formattedDate(date: Date) { + const locale = getDateFnsLocale(); if (isToday(date)) { - return i18n.t("Today, {{time}}", { time: format(date, "h:mma") }); + return i18n.t("Today, {{time}}", { + time: formatLocalized(date, "h:mma", "p", locale), + }); } else if (isYesterday(date)) { - return i18n.t("Yesterday, {{time}}", { time: format(date, "h:mma") }); + return i18n.t("Yesterday, {{time}}", { + time: formatLocalized(date, "h:mma", "p", locale), + }); } else { - return format(date, "MMM dd, yyyy, h:mma"); + return formatLocalized(date, "MMM dd, yyyy, h:mma", "PPp", locale); } } diff --git a/apps/server/src/collaboration/extensions/persistence.extension.ts b/apps/server/src/collaboration/extensions/persistence.extension.ts index 53bc8b334..3a4df24a7 100644 --- a/apps/server/src/collaboration/extensions/persistence.extension.ts +++ b/apps/server/src/collaboration/extensions/persistence.extension.ts @@ -165,6 +165,21 @@ export class PersistenceExtension implements Extension { } if (page) { + document.broadcastStateless( + JSON.stringify({ + type: 'page.updated', + updatedAt: new Date().toISOString(), + lastUpdatedById: context?.user?.id, + lastUpdatedBy: context?.user + ? { + id: context.user?.id, + name: context.user?.name, + avatarUrl: context.user?.avatarUrl, + } + : undefined, + }), + ); + await this.syncTransclusion(pageId, page.workspaceId, tiptapJson); } diff --git a/apps/server/src/common/helpers/types/permission.ts b/apps/server/src/common/helpers/types/permission.ts index 5493bdb47..fd45d0fa4 100644 --- a/apps/server/src/common/helpers/types/permission.ts +++ b/apps/server/src/common/helpers/types/permission.ts @@ -4,6 +4,11 @@ export enum UserRole { MEMBER = 'member', } +export enum InviteUserRole { + ADMIN = 'admin', // can have owner permissions but cannot delete workspace + MEMBER = 'member', +} + export enum SpaceRole { ADMIN = 'admin', // can manage space settings, members, and delete space WRITER = 'writer', // can read and write pages in space diff --git a/apps/server/src/core/page/services/page.service.ts b/apps/server/src/core/page/services/page.service.ts index 7b285c7fb..0e0b24f07 100644 --- a/apps/server/src/core/page/services/page.service.ts +++ b/apps/server/src/core/page/services/page.service.ts @@ -310,6 +310,7 @@ export class PageService { expression: 'position', direction: 'asc', orderModifier: (ob) => ob.collate('C').asc(), + cursorExpression: sql`position collate "C"`, }, { expression: 'id', direction: 'asc' }, ], diff --git a/apps/server/src/core/workspace/dto/invitation.dto.ts b/apps/server/src/core/workspace/dto/invitation.dto.ts index 187688c4d..ced007ccd 100644 --- a/apps/server/src/core/workspace/dto/invitation.dto.ts +++ b/apps/server/src/core/workspace/dto/invitation.dto.ts @@ -11,7 +11,7 @@ import { MaxLength, MinLength, } from 'class-validator'; -import { UserRole } from '../../../common/helpers/types/permission'; +import { InviteUserRole } from '../../../common/helpers/types/permission'; import { NoUrls } from '../../../common/validators/no-urls.validator'; export class InviteUserDto { @@ -32,7 +32,7 @@ export class InviteUserDto { @IsUUID('all', { each: true }) groupIds: string[]; - @IsEnum(UserRole) + @IsEnum(InviteUserRole) role: string; } diff --git a/apps/server/src/core/workspace/services/workspace-invitation.service.ts b/apps/server/src/core/workspace/services/workspace-invitation.service.ts index 50ed49f05..2bc3d2dd9 100644 --- a/apps/server/src/core/workspace/services/workspace-invitation.service.ts +++ b/apps/server/src/core/workspace/services/workspace-invitation.service.ts @@ -1,5 +1,6 @@ import { BadRequestException, + ForbiddenException, Inject, Injectable, Logger, @@ -40,6 +41,7 @@ import { AUDIT_SERVICE, IAuditService, } from '../../../integrations/audit/audit.service'; +import { isAdminActingOnOwner } from '../workspace.util'; @Injectable() export class WorkspaceInvitationService { @@ -119,6 +121,10 @@ export class WorkspaceInvitationService { ): Promise { const { emails, role, groupIds } = inviteUserDto; + if (isAdminActingOnOwner(authUser.role, role)) { + throw new ForbiddenException(); + } + let invites: WorkspaceInvitation[] = []; try { diff --git a/apps/server/src/core/workspace/services/workspace.service.ts b/apps/server/src/core/workspace/services/workspace.service.ts index 267eb13b7..f3ab78e60 100644 --- a/apps/server/src/core/workspace/services/workspace.service.ts +++ b/apps/server/src/core/workspace/services/workspace.service.ts @@ -30,6 +30,7 @@ import { DomainService } from '../../../integrations/environment/domain.service' import { jsonArrayFrom } from 'kysely/helpers/postgres'; import { addDays } from 'date-fns'; import { DISALLOWED_HOSTNAMES, WorkspaceStatus } from '../workspace.constants'; +import { isAdminActingOnOwner } from '../workspace.util'; import { v4 } from 'uuid'; import { InjectQueue } from '@nestjs/bullmq'; import { QueueJob, QueueName } from '../../../integrations/queue/constants'; @@ -590,8 +591,8 @@ export class WorkspaceService { // prevent ADMIN from managing OWNER role if ( - (authUser.role === UserRole.ADMIN && newRole === UserRole.OWNER) || - (authUser.role === UserRole.ADMIN && user.role === UserRole.OWNER) + isAdminActingOnOwner(authUser.role, newRole) || + isAdminActingOnOwner(authUser.role, user.role) ) { throw new ForbiddenException(); } @@ -695,7 +696,7 @@ export class WorkspaceService { throw new BadRequestException('You cannot deactivate yourself'); } - if (authUser.role === UserRole.ADMIN && user.role === UserRole.OWNER) { + if (isAdminActingOnOwner(authUser.role, user.role)) { throw new BadRequestException( 'You cannot deactivate a user with owner role', ); @@ -753,7 +754,7 @@ export class WorkspaceService { throw new BadRequestException('User is not deactivated'); } - if (authUser.role === UserRole.ADMIN && user.role === UserRole.OWNER) { + if (isAdminActingOnOwner(authUser.role, user.role)) { throw new BadRequestException( 'You cannot activate a user with owner role', ); @@ -805,7 +806,7 @@ export class WorkspaceService { throw new BadRequestException('You cannot delete yourself'); } - if (authUser.role === UserRole.ADMIN && user.role === UserRole.OWNER) { + if (isAdminActingOnOwner(authUser.role, user.role)) { throw new BadRequestException('You cannot delete a user with owner role'); } diff --git a/apps/server/src/core/workspace/workspace.util.ts b/apps/server/src/core/workspace/workspace.util.ts new file mode 100644 index 000000000..b22aa3ceb --- /dev/null +++ b/apps/server/src/core/workspace/workspace.util.ts @@ -0,0 +1,8 @@ +import { UserRole } from '../../common/helpers/types/permission'; + +export function isAdminActingOnOwner( + authUserRole: string, + targetRole: string, +): boolean { + return authUserRole === UserRole.ADMIN && targetRole === UserRole.OWNER; +} diff --git a/apps/server/src/database/pagination/cursor-pagination.ts b/apps/server/src/database/pagination/cursor-pagination.ts index 4254702ec..cca94cf9c 100644 --- a/apps/server/src/database/pagination/cursor-pagination.ts +++ b/apps/server/src/database/pagination/cursor-pagination.ts @@ -14,12 +14,14 @@ type SortField = | (StringReference & `${string}.${keyof O & string}`); direction: OrderByDirection; orderModifier?: OrderByModifiers; + cursorExpression?: ReferenceExpression; key?: keyof O & string; } | { expression: ReferenceExpression; direction: OrderByDirection; orderModifier?: OrderByModifiers; + cursorExpression?: ReferenceExpression; key: keyof O & string; }; @@ -202,11 +204,12 @@ export async function executeWithCursorPagination< const comparison = field.direction === defaultDirection ? '>' : '<'; const value = cursor[field.key as keyof typeof cursor]; + const compareExpr = field.cursorExpression ?? field.expression; - const conditions = [eb(field.expression, comparison, value)]; + const conditions = [eb(compareExpr, comparison, value)]; if (expression) { - conditions.push(and([eb(field.expression, '=', value), expression])); + conditions.push(and([eb(compareExpr, '=', value), expression])); } expression = or(conditions); From db32910634469767ac2b482b0282c28258344ab3 Mon Sep 17 00:00:00 2001 From: Philipinho <16838612+Philipinho@users.noreply.github.com> Date: Thu, 28 May 2026 16:35:37 +0100 Subject: [PATCH 05/56] fix; change inline code text color --- apps/client/src/features/editor/styles/code.css | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/client/src/features/editor/styles/code.css b/apps/client/src/features/editor/styles/code.css index e84a71ff4..fba5db91d 100644 --- a/apps/client/src/features/editor/styles/code.css +++ b/apps/client/src/features/editor/styles/code.css @@ -103,13 +103,13 @@ margin: 0; @mixin where-light { - background-color: var(--code-bg, var(--mantine-color-gray-1)); - color: var(--mantine-color-pink-7); + background-color: var(--mantine-color-gray-1); + color: var(--mantine-color-text); } @mixin where-dark { - background-color: var(--mantine-color-dark-8); - color: var(--mantine-color-pink-7); + background-color: var(--mantine-color-dark-5) !important; + color: var(--mantine-color-text); } } } From 2b68879e725aa7bdfb34c40ae989ee7ed8b27b47 Mon Sep 17 00:00:00 2001 From: Philipinho <16838612+Philipinho@users.noreply.github.com> Date: Thu, 28 May 2026 16:36:18 +0100 Subject: [PATCH 06/56] 0.90.1 --- apps/client/package.json | 2 +- apps/server/package.json | 2 +- apps/server/src/ee | 2 +- package.json | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/client/package.json b/apps/client/package.json index c938bc448..feba0ef1b 100644 --- a/apps/client/package.json +++ b/apps/client/package.json @@ -1,7 +1,7 @@ { "name": "client", "private": true, - "version": "0.90.0", + "version": "0.90.1", "scripts": { "dev": "vite", "build": "tsc && vite build", diff --git a/apps/server/package.json b/apps/server/package.json index a321b232e..dabfe8ecf 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -1,6 +1,6 @@ { "name": "server", - "version": "0.90.0", + "version": "0.90.1", "description": "", "author": "", "private": true, diff --git a/apps/server/src/ee b/apps/server/src/ee index 9e5f64d95..9b83049b6 160000 --- a/apps/server/src/ee +++ b/apps/server/src/ee @@ -1 +1 @@ -Subproject commit 9e5f64d95d679406f160687804b73d9ca245a842 +Subproject commit 9b83049b6ab2efed713015376a9aee344223a76b diff --git a/package.json b/package.json index 8b497eaf1..54a9d3518 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "docmost", "homepage": "https://docmost.com", - "version": "0.90.0", + "version": "0.90.1", "private": true, "scripts": { "build": "nx run-many -t build", From b6760c63c4e6473a1576841a3f55889858b6c6d2 Mon Sep 17 00:00:00 2001 From: Philipinho <16838612+Philipinho@users.noreply.github.com> Date: Thu, 28 May 2026 16:39:47 +0100 Subject: [PATCH 07/56] fix: package updates --- apps/client/package.json | 2 +- package.json | 2 +- pnpm-lock.yaml | 24 ++++++++++++------------ 3 files changed, 14 insertions(+), 14 deletions(-) diff --git a/apps/client/package.json b/apps/client/package.json index feba0ef1b..bd6585f1f 100644 --- a/apps/client/package.json +++ b/apps/client/package.json @@ -43,7 +43,7 @@ "i18next-http-backend": "3.0.6", "jotai": "2.18.1", "jotai-optics": "0.4.0", - "js-cookie": "3.0.5", + "js-cookie": "3.0.7", "jwt-decode": "4.0.0", "katex": "0.16.40", "lowlight": "3.3.0", diff --git a/package.json b/package.json index 54a9d3518..02216ab65 100644 --- a/package.json +++ b/package.json @@ -103,7 +103,7 @@ "glob": "13.0.6", "ws": "8.20.1", "dompurify": "3.4.1", - "tmp": "0.2.5", + "tmp": "0.2.6", "hono": "4.12.18", "mermaid": "11.15.0", "nanoid@^3": "3.3.8", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 355154171..05c629436 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -10,7 +10,7 @@ overrides: glob: 13.0.6 ws: 8.20.1 dompurify: 3.4.1 - tmp: 0.2.5 + tmp: 0.2.6 hono: 4.12.18 mermaid: 11.15.0 nanoid@^3: 3.3.8 @@ -342,8 +342,8 @@ importers: specifier: 0.4.0 version: 0.4.0(jotai@2.18.1(@babel/core@7.28.5)(@babel/template@7.27.2)(@types/react@18.3.12)(react@18.3.1))(optics-ts@2.4.1) js-cookie: - specifier: 3.0.5 - version: 3.0.5 + specifier: 3.0.7 + version: 3.0.7 jwt-decode: specifier: 4.0.0 version: 4.0.0 @@ -7544,9 +7544,9 @@ packages: resolution: {integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==} engines: {node: '>=10'} - js-cookie@3.0.5: - resolution: {integrity: sha512-cEiJEAEoIbWfCZYKWhVwFuvPX1gETRYPw6LlaTKoxD3s2AkXzkCjnp6h0V77ozyqj0jakteJ4YqDJT830+lVGw==} - engines: {node: '>=14'} + js-cookie@3.0.7: + resolution: {integrity: sha512-z/wZZgDrkNV1eA0ULjM/F9/50Ya8fbzgKneSpoPsXSGd0KnpdtHfOZWK+GcwLk+EZbS4F9RBhU+K2RgzuDaItw==} + engines: {node: '>=20'} js-tiktoken@1.0.21: resolution: {integrity: sha512-biOj/6M5qdgx5TKjDnFT1ymSpM5tbd3ylwDtrQvFQSu0Z7bBYko2dF+W/aUkXUPuk6IVpRxk/3Q2sHOzGlS36g==} @@ -9657,8 +9657,8 @@ packages: tmp-promise@3.0.3: resolution: {integrity: sha512-RwM7MoPojPxsOBYnyd2hy0bxtIlVrihNs9pj5SUvY8Zz1sQcQG2tG1hSr8PDxfgEB8RNKDhqbIlroIarSNDNsQ==} - tmp@0.2.5: - resolution: {integrity: sha512-voyz6MApa1rQGUxT3E+BK7/ROe8itEx7vD8/HEvt4xwXucvQ5G5oeEiHkmHZJuBO21RpOf+YYm9MOivj709jow==} + tmp@0.2.6: + resolution: {integrity: sha512-5sJPdPjfI5Kx+qbrDesxkglRBxW//g7hCsqspEjwkewGvBMGIKMOTKzLt1hFVJzyadba3lDUN20O9qhvbQUSTA==} engines: {node: '>=14.14'} tmpl@1.0.5: @@ -18286,7 +18286,7 @@ snapshots: joycon@3.1.1: {} - js-cookie@3.0.5: {} + js-cookie@3.0.7: {} js-tiktoken@1.0.21: dependencies: @@ -18992,7 +18992,7 @@ snapshots: semver: 7.7.4 string-width: 4.2.3 tar-stream: 2.2.0 - tmp: 0.2.5 + tmp: 0.2.6 tree-kill: 1.2.2 tsconfig-paths: 4.2.0 tslib: 2.8.1 @@ -20615,9 +20615,9 @@ snapshots: tmp-promise@3.0.3: dependencies: - tmp: 0.2.5 + tmp: 0.2.6 - tmp@0.2.5: {} + tmp@0.2.6: {} tmpl@1.0.5: {} From ef04c22aeadfb82103f1e8c13a746d352e68a5f7 Mon Sep 17 00:00:00 2001 From: Philipinho <16838612+Philipinho@users.noreply.github.com> Date: Thu, 28 May 2026 16:57:59 +0100 Subject: [PATCH 08/56] sync --- apps/server/src/ee | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/server/src/ee b/apps/server/src/ee index 9b83049b6..e7320a5a0 160000 --- a/apps/server/src/ee +++ b/apps/server/src/ee @@ -1 +1 @@ -Subproject commit 9b83049b6ab2efed713015376a9aee344223a76b +Subproject commit e7320a5a0fa8296a737b5118db395aec80ffb25a From d86d51c27e6abcbf510d42933e9e013b84b4d7fc Mon Sep 17 00:00:00 2001 From: Peter Tripp Date: Wed, 3 Jun 2026 06:31:45 -0400 Subject: [PATCH 09/56] fix: Table jitter on edit/read toggle (#2252) --- apps/client/src/features/editor/styles/table.css | 4 ---- 1 file changed, 4 deletions(-) diff --git a/apps/client/src/features/editor/styles/table.css b/apps/client/src/features/editor/styles/table.css index 5d802e4ab..ac5b91d4c 100644 --- a/apps/client/src/features/editor/styles/table.css +++ b/apps/client/src/features/editor/styles/table.css @@ -204,10 +204,6 @@ opacity: 1; } -.ProseMirror table th:has(.tableReadonlySortChevron) { - padding-right: 30px; -} - .tableReadonlySortChevron:hover { background: light-dark( rgba(55, 53, 47, 0.16), From 6191acfa1401e5133b2c0ffc29721b5a4bb8abda Mon Sep 17 00:00:00 2001 From: Philip Okugbe <16838612+Philipinho@users.noreply.github.com> Date: Tue, 9 Jun 2026 22:51:55 +0100 Subject: [PATCH 10/56] fix: a11y (#2275) --- .../public/locales/en-US/translation.json | 4 +-- .../layouts/global/global-sidebar.tsx | 2 +- .../src/components/ui/custom-avatar.tsx | 28 ++++++++++++------- .../src/components/ui/radio-menu-item.tsx | 12 ++++++++ .../components/ai-chat-sidebar-item.tsx | 8 +++--- .../src/ee/ai-chat/components/chat-input.tsx | 13 +++++++-- .../src/ee/ai-chat/styles/ai-chat.module.css | 1 - .../group/components/group-members.tsx | 4 ++- .../components/notification-popover.tsx | 6 ++-- .../tree/components/space-tree-node-menu.tsx | 6 ++-- .../page/tree/components/space-tree-row.tsx | 10 ++++--- .../features/page/tree/styles/tree.module.css | 4 +++ .../components/search-spotlight-filters.tsx | 3 +- .../space/components/space-filter-menu.tsx | 5 ++-- .../space/components/space-members.tsx | 4 ++- .../components/members-action-menu.tsx | 9 ++++-- .../components/workspace-members-table.tsx | 1 + 17 files changed, 84 insertions(+), 36 deletions(-) create mode 100644 apps/client/src/components/ui/radio-menu-item.tsx diff --git a/apps/client/public/locales/en-US/translation.json b/apps/client/public/locales/en-US/translation.json index 278021657..a182138ac 100644 --- a/apps/client/public/locales/en-US/translation.json +++ b/apps/client/public/locales/en-US/translation.json @@ -978,7 +978,7 @@ "Search pages and spaces...": "Search pages and spaces...", "No results found": "No results found", "You don't have permission to create pages here": "You don't have permission to create pages here", - "Chat menu": "Chat menu", + "Chat menu for {{title}}": "Chat menu for {{title}}", "API key menu": "API key menu", "Jump to comment selection": "Jump to comment selection", "Slash commands": "Slash commands", @@ -1064,7 +1064,7 @@ "Filter": "Filter", "Page title": "Page title", "Page content": "Page content", - "Member actions": "Member actions", + "Member actions for {{name}}": "Member actions for {{name}}", "Toggle password visibility": "Toggle password visibility", "Send comment": "Send comment", "Token actions": "Token actions", diff --git a/apps/client/src/components/layouts/global/global-sidebar.tsx b/apps/client/src/components/layouts/global/global-sidebar.tsx index 4670dae40..5ec322a58 100644 --- a/apps/client/src/components/layouts/global/global-sidebar.tsx +++ b/apps/client/src/components/layouts/global/global-sidebar.tsx @@ -105,7 +105,7 @@ export default function GlobalSidebar() {
- {t("Favorite spaces")} + {t("Favorite spaces")} {!isFavoritesPending && sortedFavoriteSpaces.length === 0 ? ( {t("Favorite spaces appear here")} diff --git a/apps/client/src/components/ui/custom-avatar.tsx b/apps/client/src/components/ui/custom-avatar.tsx index 0cf20a51b..c708b1769 100644 --- a/apps/client/src/components/ui/custom-avatar.tsx +++ b/apps/client/src/components/ui/custom-avatar.tsx @@ -16,13 +16,10 @@ interface CustomAvatarProps { mt?: string | number; } -// `color.shade` pairs whose contrast meets WCAG AA (4.5:1) in BOTH variants: -// - filled: white text on the shade as bg -// - light: shade as text on the color's light-bg (10% color.6 over white) -// Avoids lime/yellow/green/orange — even their dark shades have weak -// contrast. grape and indigo were bumped from .7 to darker shades because -// the original picks failed: grape.7 was 4.02/3.61 (both fail) and -// indigo.7 was 4.98/4.39 (light fails by a hair). +// color.shade picks whose FILLED variant (white text on the shade) meets WCAG AA 4.5:1. +// Avoids lime/yellow/green/orange, too light even at dark shades. +// For non-filled variants, initials text is forced to the .9 shade at render time: +// Mantine otherwise caps light-variant placeholder text at .6, dropping contrast to ~3:1. const SAFE_INITIALS_COLORS: MantineColor[] = [ "blue.8", "cyan.9", @@ -54,12 +51,21 @@ function sanitizeInitialsSource(name: string) { export const CustomAvatar = React.forwardRef< HTMLInputElement, CustomAvatarProps ->(({ avatarUrl, name, type, color, ...props }: CustomAvatarProps, ref) => { +>(({ avatarUrl, name, type, color, variant, ...props }: CustomAvatarProps, ref) => { const avatarLink = getAvatarUrl(avatarUrl, type); - const resolvedColor = - !color || color === "initials" ? pickInitialsColor(name ?? "") : color; + const isInitials = !color || color === "initials"; + const resolvedColor = isInitials ? pickInitialsColor(name ?? "") : color; const initialsSource = sanitizeInitialsSource(name ?? ""); + const placeholderStyles = + isInitials && variant !== "filled" + ? { + placeholder: { + color: `var(--mantine-color-${resolvedColor.split(".")[0]}-9)`, + }, + } + : undefined; + return ( ); diff --git a/apps/client/src/components/ui/radio-menu-item.tsx b/apps/client/src/components/ui/radio-menu-item.tsx new file mode 100644 index 000000000..3f0ae7c8f --- /dev/null +++ b/apps/client/src/components/ui/radio-menu-item.tsx @@ -0,0 +1,12 @@ +import { UnstyledButton } from "@mantine/core"; +import { type ComponentPropsWithoutRef, forwardRef } from "react"; + +// Menu.Item hard-codes role="menuitem"; use as its `component` to restore role="menuitemradio" so aria-checked works. +export const RadioMenuItem = forwardRef< + HTMLButtonElement, + ComponentPropsWithoutRef<"button"> +>((props, ref) => ( + +)); + +RadioMenuItem.displayName = "RadioMenuItem"; diff --git a/apps/client/src/ee/ai-chat/components/ai-chat-sidebar-item.tsx b/apps/client/src/ee/ai-chat/components/ai-chat-sidebar-item.tsx index e2bd553c8..4f3d32af3 100644 --- a/apps/client/src/ee/ai-chat/components/ai-chat-sidebar-item.tsx +++ b/apps/client/src/ee/ai-chat/components/ai-chat-sidebar-item.tsx @@ -66,6 +66,8 @@ export default function AiChatSidebarItem({ [chat.updatedAt, i18n.language], ); + const chatTitle = chat.title || t("Untitled chat"); + useEffect(() => { if (renaming) { // Wait for the input to be mounted before selecting. @@ -120,9 +122,7 @@ export default function AiChatSidebarItem({ className={classes.chatItem} data-active={isActive || undefined} > - - {chat.title || t("Untitled chat")} - + {chatTitle} {formattedDate}
@@ -132,7 +132,7 @@ export default function AiChatSidebarItem({ size="xs" color="gray" onClick={(e) => e.preventDefault()} - aria-label={t("Chat menu")} + aria-label={t("Chat menu for {{title}}", { title: chatTitle })} > diff --git a/apps/client/src/ee/ai-chat/components/chat-input.tsx b/apps/client/src/ee/ai-chat/components/chat-input.tsx index e56d1fe06..88c270542 100644 --- a/apps/client/src/ee/ai-chat/components/chat-input.tsx +++ b/apps/client/src/ee/ai-chat/components/chat-input.tsx @@ -1,4 +1,4 @@ -import { useCallback, useRef, useEffect, useState } from "react"; +import { useCallback, useId, useRef, useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; import { IconArrowUp, IconPaperclip, IconPlayerStopFilled, IconX, IconFile, IconPhoto, IconPlus, IconAt, IconFileText } from "@tabler/icons-react"; import { Popover } from "@mantine/core"; @@ -107,6 +107,7 @@ export default function ChatInput({ const [isEmpty, setIsEmpty] = useState(true); const [pendingAttachments, setPendingAttachments] = useState([]); const [plusMenuOpen, setPlusMenuOpen] = useState(false); + const plusMenuId = useId(); const fileInputRef = useRef(null); const onSendRef = useRef(onSend); onSendRef.current = onSend; @@ -342,6 +343,7 @@ export default function ChatInput({ position="top-start" width={220} shadow="md" + withRoles={false} trapFocus returnFocus > @@ -351,13 +353,17 @@ export default function ChatInput({ className={classes.plusButton} onClick={() => setPlusMenuOpen((o) => !o)} aria-label="Add content" + aria-haspopup="menu" + aria-expanded={plusMenuOpen} + aria-controls={plusMenuOpen ? plusMenuId : undefined} > - + diff --git a/apps/client/src/ee/ai-chat/styles/ai-chat.module.css b/apps/client/src/ee/ai-chat/styles/ai-chat.module.css index 67f97fbb2..2daa0a2b8 100644 --- a/apps/client/src/ee/ai-chat/styles/ai-chat.module.css +++ b/apps/client/src/ee/ai-chat/styles/ai-chat.module.css @@ -76,7 +76,6 @@ padding: var(--mantine-spacing-xs) var(--mantine-spacing-lg) var(--mantine-spacing-lg); } -/* Empty state - Notion AI style centered layout */ .emptyState { flex: 1; display: flex; diff --git a/apps/client/src/features/group/components/group-members.tsx b/apps/client/src/features/group/components/group-members.tsx index 14c5903a7..3bf04b5ac 100644 --- a/apps/client/src/features/group/components/group-members.tsx +++ b/apps/client/src/features/group/components/group-members.tsx @@ -91,7 +91,9 @@ export default function GroupMembersList() { diff --git a/apps/client/src/features/notification/components/notification-popover.tsx b/apps/client/src/features/notification/components/notification-popover.tsx index 3c5286c48..5a5068de8 100644 --- a/apps/client/src/features/notification/components/notification-popover.tsx +++ b/apps/client/src/features/notification/components/notification-popover.tsx @@ -1,4 +1,4 @@ -import { useState } from "react"; +import { useId, useState } from "react"; import { ActionIcon, Group, @@ -31,6 +31,7 @@ import classes from "../notification.module.css"; export function NotificationPopover() { const { t } = useTranslation(); + const titleId = useId(); const [opened, setOpened] = useState(false); const [tab, setTab] = useState("direct"); const [filter, setFilter] = useState("all"); @@ -83,10 +84,11 @@ export function NotificationPopover() { - + <Title id={titleId} order={2} fz="sm" fw={600}> {t("Notifications")} diff --git a/apps/client/src/features/page/tree/components/space-tree-node-menu.tsx b/apps/client/src/features/page/tree/components/space-tree-node-menu.tsx index 27b0ce210..d65a9c418 100644 --- a/apps/client/src/features/page/tree/components/space-tree-node-menu.tsx +++ b/apps/client/src/features/page/tree/components/space-tree-node-menu.tsx @@ -34,6 +34,7 @@ import { treeDataAtom } from "@/features/page/tree/atoms/tree-data-atom.ts"; import { treeModel } from "@/features/page/tree/model/tree-model"; import { useTreeMutation } from "@/features/page/tree/hooks/use-tree-mutation.ts"; import type { SpaceTreeNode } from "@/features/page/tree/types.ts"; +import classes from "@/features/page/tree/styles/tree.module.css"; export interface NodeMenuProps { node: SpaceTreeNode; @@ -123,8 +124,9 @@ export function NodeMenu({ node, canEdit }: NodeMenuProps) { { diff --git a/apps/client/src/features/page/tree/components/space-tree-row.tsx b/apps/client/src/features/page/tree/components/space-tree-row.tsx index 3da690066..e55373c25 100644 --- a/apps/client/src/features/page/tree/components/space-tree-row.tsx +++ b/apps/client/src/features/page/tree/components/space-tree-row.tsx @@ -201,13 +201,13 @@ function PageArrow({ isOpen, hasChildren, onToggle }: PageArrowProps) { return ( @@ -220,7 +220,8 @@ function PageArrow({ isOpen, hasChildren, onToggle }: PageArrowProps) { { diff --git a/apps/client/src/features/page/tree/styles/tree.module.css b/apps/client/src/features/page/tree/styles/tree.module.css index e116a1352..6ed758e64 100644 --- a/apps/client/src/features/page/tree/styles/tree.module.css +++ b/apps/client/src/features/page/tree/styles/tree.module.css @@ -57,6 +57,10 @@ flex-shrink: 0; } +.actionIcon { + color: light-dark(var(--mantine-color-dark-3), var(--mantine-color-gray-4)); +} + .text { flex: 1; /* min-width: 0 lets a flex child shrink below its content size — required diff --git a/apps/client/src/features/search/components/search-spotlight-filters.tsx b/apps/client/src/features/search/components/search-spotlight-filters.tsx index d41bef7e7..0b2bcc48c 100644 --- a/apps/client/src/features/search/components/search-spotlight-filters.tsx +++ b/apps/client/src/features/search/components/search-spotlight-filters.tsx @@ -17,6 +17,7 @@ import { import { useTranslation } from "react-i18next"; import { useGetSpacesQuery } from "@/features/space/queries/space-query"; import { SpaceFilterMenu } from "@/features/space/components/space-filter-menu"; +import { RadioMenuItem } from "@/components/ui/radio-menu-item"; import { useHasFeature } from "@/ee/hooks/use-feature"; import { Feature } from "@/ee/features"; import classes from "./search-spotlight-filters.module.css"; @@ -175,7 +176,7 @@ export function SearchSpotlightFilters({ {contentTypeOptions.map((option) => ( !option.disabled && diff --git a/apps/client/src/features/space/components/space-filter-menu.tsx b/apps/client/src/features/space/components/space-filter-menu.tsx index 00a9f38bc..785ac791e 100644 --- a/apps/client/src/features/space/components/space-filter-menu.tsx +++ b/apps/client/src/features/space/components/space-filter-menu.tsx @@ -13,6 +13,7 @@ import { useDebouncedValue } from "@mantine/hooks"; import { IconCheck, IconSearch } from "@tabler/icons-react"; import { useTranslation } from "react-i18next"; import { useGetSpacesQuery } from "@/features/space/queries/space-query"; +import { RadioMenuItem } from "@/components/ui/radio-menu-item"; type SpaceFilterMenuProps = { value: string | null; @@ -75,7 +76,7 @@ export function SpaceFilterMenu({ onChange(null)} > @@ -103,7 +104,7 @@ export function SpaceFilterMenu({ {orderedSpaces.map((space) => ( onChange(space.id)} > diff --git a/apps/client/src/features/space/components/space-members.tsx b/apps/client/src/features/space/components/space-members.tsx index 8f1502574..cf431d049 100644 --- a/apps/client/src/features/space/components/space-members.tsx +++ b/apps/client/src/features/space/components/space-members.tsx @@ -210,7 +210,9 @@ export default function SpaceMembersList({ diff --git a/apps/client/src/features/workspace/components/members/components/members-action-menu.tsx b/apps/client/src/features/workspace/components/members/components/members-action-menu.tsx index ce1c588a0..f8fd035fb 100644 --- a/apps/client/src/features/workspace/components/members/components/members-action-menu.tsx +++ b/apps/client/src/features/workspace/components/members/components/members-action-menu.tsx @@ -12,9 +12,14 @@ import useUserRole from "@/hooks/use-user-role.tsx"; interface Props { userId: string; + name: string; deactivatedAt: Date | null; } -export default function MemberActionMenu({ userId, deactivatedAt }: Props) { +export default function MemberActionMenu({ + userId, + name, + deactivatedAt, +}: Props) { const { t } = useTranslation(); const deleteWorkspaceMemberMutation = useDeleteWorkspaceMemberMutation(); const deactivateMutation = useDeactivateWorkspaceMemberMutation(); @@ -86,7 +91,7 @@ export default function MemberActionMenu({ userId, deactivatedAt }: Props) { diff --git a/apps/client/src/features/workspace/components/members/components/workspace-members-table.tsx b/apps/client/src/features/workspace/components/members/components/workspace-members-table.tsx index 76a32ffb1..6423ebddc 100644 --- a/apps/client/src/features/workspace/components/members/components/workspace-members-table.tsx +++ b/apps/client/src/features/workspace/components/members/components/workspace-members-table.tsx @@ -111,6 +111,7 @@ export default function WorkspaceMembersTable() { {isAdmin && ( )} From 2ff8720832030b4be6cc16a504c931ac53207fdf Mon Sep 17 00:00:00 2001 From: Felix <66864107+flixz02@users.noreply.github.com> Date: Wed, 17 Jun 2026 15:17:13 +0200 Subject: [PATCH 11/56] fix: slash-menu suggestion search localization (#2280) --- .../components/slash-menu/menu-items.ts | 22 ++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/apps/client/src/features/editor/components/slash-menu/menu-items.ts b/apps/client/src/features/editor/components/slash-menu/menu-items.ts index 7f8567558..1f05dc62f 100644 --- a/apps/client/src/features/editor/components/slash-menu/menu-items.ts +++ b/apps/client/src/features/editor/components/slash-menu/menu-items.ts @@ -767,18 +767,34 @@ export const getSuggestionItems = ({ for (const [group, items] of Object.entries(CommandGroups)) { const filteredItems = items.filter((item) => { if (excludeItems?.has(item.title)) return false; + const translatedTitle = i18n.t(item.title); + const translatedDescription = i18n.t(item.description); return ( fuzzyMatch(search, item.title) || + fuzzyMatch(search, translatedTitle) || item.description.toLowerCase().includes(search) || + translatedDescription.toLowerCase().includes(search) || (item.searchTerms && - item.searchTerms.some((term: string) => term.includes(search))) + item.searchTerms.some( + (term: string) => + term.includes(search) || + i18n.t(term).toLowerCase().includes(search), + )) ); }); if (filteredItems.length) { filteredGroups[group] = filteredItems.sort((a, b) => { - const aTitle = a.title.toLowerCase().includes(search) ? 0 : 1; - const bTitle = b.title.toLowerCase().includes(search) ? 0 : 1; + const aTitle = + a.title.toLowerCase().includes(search) || + i18n.t(a.title).toLowerCase().includes(search) + ? 0 + : 1; + const bTitle = + b.title.toLowerCase().includes(search) || + i18n.t(b.title).toLowerCase().includes(search) + ? 0 + : 1; return aTitle - bTitle; }); } From 1867aa8bf6c37a00d0d8c8a2084a2046d3ded06d Mon Sep 17 00:00:00 2001 From: Mayank-2-16 <111053673+Mayank-2-16@users.noreply.github.com> Date: Wed, 17 Jun 2026 19:02:48 +0530 Subject: [PATCH 12/56] fix: pdf table header alignment issue (#2259) * fix: pdf table header alignment issue * cleanup --------- Co-authored-by: Philipinho <16838612+Philipinho@users.noreply.github.com> --- apps/client/src/ee/pdf-export/pdf-render-page.tsx | 1 + .../src/features/editor/readonly-page-editor.tsx | 10 ++++++++-- apps/client/src/features/editor/styles/table.css | 11 ++++++++--- 3 files changed, 17 insertions(+), 5 deletions(-) diff --git a/apps/client/src/ee/pdf-export/pdf-render-page.tsx b/apps/client/src/ee/pdf-export/pdf-render-page.tsx index 8705f9b6c..0f13cb71b 100644 --- a/apps/client/src/ee/pdf-export/pdf-render-page.tsx +++ b/apps/client/src/ee/pdf-export/pdf-render-page.tsx @@ -58,6 +58,7 @@ export default function PdfRenderPage() { title={data.title} content={data.content} pageId={data.pageId} + printMode /> ); diff --git a/apps/client/src/features/editor/readonly-page-editor.tsx b/apps/client/src/features/editor/readonly-page-editor.tsx index cd4878a9b..4b28bec9d 100644 --- a/apps/client/src/features/editor/readonly-page-editor.tsx +++ b/apps/client/src/features/editor/readonly-page-editor.tsx @@ -15,6 +15,7 @@ interface PageEditorProps { title: string; content: any; pageId?: string; + printMode?: boolean; /** * When rendering inside a public share, pass the share's id (or key). Lookups * for transclusion content then resolve against the share graph instead of @@ -28,6 +29,7 @@ export default function ReadonlyPageEditor({ title, content, pageId, + printMode = false, shareId, }: PageEditorProps) { const [, setReadOnlyEditor] = useAtom(readOnlyEditorAtom); @@ -48,8 +50,12 @@ export default function ReadonlyPageEditor({ }, []); const extensions = useMemo(() => { + const excludedExtensions = new Set([ + "uniqueID", + ...(printMode ? ["tableHeaderPin", "tableReadonlySort"] : []), + ]); const filteredExtensions = mainExtensions.filter( - (ext) => ext.name !== "uniqueID", + (ext) => !excludedExtensions.has(ext.name), ); return [ @@ -59,7 +65,7 @@ export default function ReadonlyPageEditor({ updateDocument: false, }), ]; - }, []); + }, [printMode]); const titleExtensions = [ Document.extend({ diff --git a/apps/client/src/features/editor/styles/table.css b/apps/client/src/features/editor/styles/table.css index ac5b91d4c..32a427936 100644 --- a/apps/client/src/features/editor/styles/table.css +++ b/apps/client/src/features/editor/styles/table.css @@ -163,8 +163,13 @@ @media print { .tableWrapper.tableHeaderPinned table tr:first-child { - position: static; - transform: none; + position: static !important; + top: auto !important; + transform: none !important; + } + + .tableReadonlySortChevron { + display: none !important; } } @@ -268,4 +273,4 @@ .prosemirror-dropcursor-inline { display: none; } -} \ No newline at end of file +} From aa5d52ad3e1e17c03506c0325020c7ee90dcf90f Mon Sep 17 00:00:00 2001 From: Philip Okugbe <16838612+Philipinho@users.noreply.github.com> Date: Sat, 20 Jun 2026 14:22:54 +0100 Subject: [PATCH 13/56] feat(editor): add /time slash command to insert current time (#2290) --- .../public/locales/en-US/translation.json | 2 ++ .../components/slash-menu/menu-items.ts | 20 +++++++++++++++++++ 2 files changed, 22 insertions(+) diff --git a/apps/client/public/locales/en-US/translation.json b/apps/client/public/locales/en-US/translation.json index a182138ac..aa20b9144 100644 --- a/apps/client/public/locales/en-US/translation.json +++ b/apps/client/public/locales/en-US/translation.json @@ -398,6 +398,8 @@ "Insert mermaid diagram": "Insert mermaid diagram", "Insert and design Drawio diagrams": "Insert and design Drawio diagrams", "Insert current date": "Insert current date", + "Time": "Time", + "Insert current time": "Insert current time", "Draw and sketch excalidraw diagrams": "Draw and sketch excalidraw diagrams", "Multiple": "Multiple", "Turn into": "Turn into", diff --git a/apps/client/src/features/editor/components/slash-menu/menu-items.ts b/apps/client/src/features/editor/components/slash-menu/menu-items.ts index 1f05dc62f..a85f4a710 100644 --- a/apps/client/src/features/editor/components/slash-menu/menu-items.ts +++ b/apps/client/src/features/editor/components/slash-menu/menu-items.ts @@ -21,6 +21,7 @@ import { IconMenu4, IconPageBreak, IconCalendar, + IconClock, IconAppWindow, IconSitemap, IconColumns3, @@ -474,6 +475,25 @@ const CommandGroups: SlashMenuGroupedItemsType = { .run(); }, }, + { + title: "Time", + description: "Insert current time", + searchTerms: ["time", "now", "clock"], + icon: IconClock, + command: ({ editor, range }: CommandProps) => { + const currentTime = new Date().toLocaleTimeString(i18n.language, { + hour: "numeric", + minute: "numeric", + }); + + editor + .chain() + .focus() + .deleteRange(range) + .insertContent(currentTime) + .run(); + }, + }, { title: "Status", description: "Insert inline status badge.", From 510199cf040ea6b987061b47a37d4d617873640b Mon Sep 17 00:00:00 2001 From: Philip Okugbe <16838612+Philipinho@users.noreply.github.com> Date: Sat, 20 Jun 2026 14:23:56 +0100 Subject: [PATCH 14/56] feat(ee): docx word export (#2294) * vendorize prosemirror-docx - wip * feat(ee): docx word export * sync --- .../src/components/common/export-modal.tsx | 90 +- apps/client/src/ee/features.ts | 1 + .../features/page/services/page-service.ts | 19 + .../src/features/page/types/page.types.ts | 1 + apps/client/src/lib/api-client.ts | 6 +- apps/client/tsconfig.json | 4 +- apps/server/package.json | 41 +- apps/server/src/common/features.ts | 1 + apps/server/src/ee | 2 +- package.json | 43 +- packages/editor-ext/package.json | 1 + packages/editor-ext/src/index.ts | 4 + .../src/lib/prosemirror-docx/README.md | 167 ++++ .../src/lib/prosemirror-docx/index.ts | 24 + .../src/lib/prosemirror-docx/numbering.ts | 47 + .../src/lib/prosemirror-docx/schema.ts | 250 +++++ .../src/lib/prosemirror-docx/serializer.ts | 925 ++++++++++++++++++ .../src/lib/prosemirror-docx/types.ts | 34 + .../src/lib/prosemirror-docx/utils.ts | 91 ++ pnpm-lock.yaml | 123 ++- 20 files changed, 1771 insertions(+), 103 deletions(-) create mode 100644 packages/editor-ext/src/lib/prosemirror-docx/README.md create mode 100644 packages/editor-ext/src/lib/prosemirror-docx/index.ts create mode 100644 packages/editor-ext/src/lib/prosemirror-docx/numbering.ts create mode 100644 packages/editor-ext/src/lib/prosemirror-docx/schema.ts create mode 100644 packages/editor-ext/src/lib/prosemirror-docx/serializer.ts create mode 100644 packages/editor-ext/src/lib/prosemirror-docx/types.ts create mode 100644 packages/editor-ext/src/lib/prosemirror-docx/utils.ts diff --git a/apps/client/src/components/common/export-modal.tsx b/apps/client/src/components/common/export-modal.tsx index 2a83debf9..bbd58b64d 100644 --- a/apps/client/src/components/common/export-modal.tsx +++ b/apps/client/src/components/common/export-modal.tsx @@ -6,13 +6,21 @@ import { Select, Switch, Divider, + Tooltip, + Badge, } from "@mantine/core"; -import { exportPage } from "@/features/page/services/page-service.ts"; +import { + exportPage, + exportPageToDocx, +} from "@/features/page/services/page-service.ts"; import { useState } from "react"; import { ExportFormat } from "@/features/page/types/page.types.ts"; import { notifications } from "@mantine/notifications"; import { exportSpace } from "@/features/space/services/space-service"; import { useTranslation } from "react-i18next"; +import { Feature } from "@/ee/features"; +import { useHasFeature } from "@/ee/hooks/use-feature"; +import { useUpgradeLabel } from "@/ee/hooks/use-upgrade-label"; interface ExportModalProps { id: string; @@ -32,17 +40,25 @@ export default function ExportModal({ const [includeAttachments, setIncludeAttachments] = useState(false); const [isExporting, setIsExporting] = useState(false); const { t } = useTranslation(); + const upgradeLabel = useUpgradeLabel(); + const isDocx = format === ExportFormat.Docx; + const docxEntitled = useHasFeature(Feature.DOCX_EXPORT); + const blockedByLicense = isDocx && !docxEntitled; const handleExport = async () => { setIsExporting(true); try { if (type === "page") { - await exportPage({ - pageId: id, - format, - includeChildren, - includeAttachments, - }); + if (format === ExportFormat.Docx) { + await exportPageToDocx({ pageId: id }); + } else { + await exportPage({ + pageId: id, + format, + includeChildren, + includeAttachments, + }); + } } if (type === "space") { await exportSpace({ spaceId: id, format, includeAttachments }); @@ -88,10 +104,15 @@ export default function ExportModal({
{t("Format")}
- + - {type === "page" && ( + {type === "page" && !isDocx && ( <> @@ -143,7 +164,16 @@ export default function ExportModal({ - + + + @@ -154,23 +184,49 @@ export default function ExportModal({ interface ExportFormatSelection { format: ExportFormat; onChange: (value: string) => void; + includeDocx?: boolean; + docxEntitled?: boolean; } -function ExportFormatSelection({ format, onChange }: ExportFormatSelection) { +function ExportFormatSelection({ + format, + onChange, + includeDocx, + docxEntitled, +}: ExportFormatSelection) { const { t } = useTranslation(); + const data = [ + { value: "markdown", label: "Markdown" }, + { value: "html", label: "HTML" }, + ...(includeDocx + ? [{ value: "docx", label: "Word (.docx)", disabled: !docxEntitled }] + : []), + ]; + return ( { + setValue(e.currentTarget.value); + debouncedCommit(); + }} + onFocus={() => { + focusedRef.current = true; + }} + onBlur={() => { + focusedRef.current = false; + commit(); + }} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault(); + e.currentTarget.blur(); + } + if (e.key === "Escape") { + setValue(page?.title ?? ""); + e.currentTarget.blur(); + } + }} + /> + ); +} diff --git a/apps/client/src/ee/base/components/base-table-skeleton.tsx b/apps/client/src/ee/base/components/base-table-skeleton.tsx new file mode 100644 index 000000000..cd7dfc6fd --- /dev/null +++ b/apps/client/src/ee/base/components/base-table-skeleton.tsx @@ -0,0 +1,92 @@ +import { Skeleton } from "@mantine/core"; +import gridClasses from "@/ee/base/styles/grid.module.css"; +import classes from "@/ee/base/styles/base-table-skeleton.module.css"; + +const ROW_NUMBER_WIDTH = 64; +const COLUMN_WIDTH = 180; +const DEFAULT_COLUMN_COUNT = 6; +const DEFAULT_ROW_COUNT = 10; + +// Deterministic widths prevent flicker between renders. +const CELL_WIDTH_RATIOS = [0.78, 0.62, 0.84, 0.55, 0.71, 0.66]; +const HEADER_WIDTH_RATIOS = [0.42, 0.58, 0.5, 0.64, 0.46, 0.54]; + +type BaseTableSkeletonProps = { + // Match the eventual content shape to avoid a jarring size jump on swap. + rows?: number; + columns?: number; +}; + +export function BaseTableSkeleton({ + rows = DEFAULT_ROW_COUNT, + columns = DEFAULT_COLUMN_COUNT, +}: BaseTableSkeletonProps = {}) { + const gridTemplateColumns = [ + `${ROW_NUMBER_WIDTH}px`, + ...Array.from({ length: columns }, () => `${COLUMN_WIDTH}px`), + ].join(" "); + + return ( +
+
+
+ + + +
+
+ + + + +
+
+ +
+
+
+
+ +
+
+ {Array.from({ length: columns }).map((_, colIndex) => ( +
+
+ + +
+
+ ))} + + {Array.from({ length: rows }).map((_, rowIndex) => ( +
+
+
+ +
+
+ {Array.from({ length: columns }).map((_, colIndex) => ( +
+
+ +
+
+ ))} +
+ ))} +
+
+
+ ); +} diff --git a/apps/client/src/ee/base/components/base-table.tsx b/apps/client/src/ee/base/components/base-table.tsx new file mode 100644 index 000000000..be449c3f9 --- /dev/null +++ b/apps/client/src/ee/base/components/base-table.tsx @@ -0,0 +1,70 @@ +import { GridContainer } from "@/ee/base/components/grid/grid-container"; +import { Table } from "@tanstack/react-table"; +import { + IBase, + IBaseRow, + IBaseView, +} from "@/ee/base/types/base.types"; + +type BaseTableProps = { + base: IBase; + rows: IBaseRow[]; + effectiveView: IBaseView | undefined; + table: Table; + pageId: string; + embedded?: boolean; + isFiltered: boolean; + hasNextPage: boolean; + isFetchingNextPage: boolean; + onFetchNextPage: () => void; + onCellUpdate: (rowId: string, propertyId: string, value: unknown) => void; + onAddRow: (afterRowId?: string, focusPropertyId?: string) => void; + onColumnReorder: (columnId: string, finishIndex: number) => void; + onResizeEnd: () => void; + onRowReorder: ( + rowId: string, + targetRowId: string, + dropPosition: "above" | "below", + ) => void; + persistViewConfig: () => void; + scrollportRef: React.RefObject; + aboveBand?: React.ReactNode; +}; + +export function BaseTable({ + base, + rows: _rows, + table, + pageId, + embedded, + isFiltered, + hasNextPage, + isFetchingNextPage, + onFetchNextPage, + onCellUpdate, + onAddRow, + onColumnReorder, + onResizeEnd, + onRowReorder, + scrollportRef, + aboveBand, +}: BaseTableProps) { + return ( + + ); +} diff --git a/apps/client/src/ee/base/components/base-toolbar.tsx b/apps/client/src/ee/base/components/base-toolbar.tsx new file mode 100644 index 000000000..213ce5874 --- /dev/null +++ b/apps/client/src/ee/base/components/base-toolbar.tsx @@ -0,0 +1,295 @@ +import { useState, useCallback, useMemo } from "react"; +import { ActionIcon, Tooltip, Badge } from "@mantine/core"; +import { Table } from "@tanstack/react-table"; +import { + IconSortAscending, + IconFilter, + IconEye, + IconDownload, + IconArrowsDiagonal, + IconLayoutColumns, + IconAdjustments, +} from "@tabler/icons-react"; +import { notifications } from "@mantine/notifications"; +import { + IBase, + IBaseRow, + IBaseView, + ViewSortConfig, + FilterCondition, + FilterGroup, +} from "@/ee/base/types/base.types"; +import { exportBaseToCsv } from "@/ee/base/services/base-service"; +import { getApiErrorMessage } from "@/lib/api-error"; +import { ViewTabs } from "@/ee/base/components/views/view-tabs"; +import { ViewSortConfigPopover } from "@/ee/base/components/views/view-sort-config"; +import { ViewFilterConfigPopover } from "@/ee/base/components/views/view-filter-config"; +import { ViewPropertyVisibility } from "@/ee/base/components/views/view-property-visibility"; +import { KanbanGroupByPicker } from "@/ee/base/components/kanban/kanban-group-by-picker"; +import { KanbanCardProperties } from "@/ee/base/components/kanban/kanban-card-properties"; +import { useTranslation } from "react-i18next"; +import classes from "@/ee/base/styles/grid.module.css"; +import toolbarClasses from "@/ee/base/styles/base-toolbar.module.css"; + +type BaseToolbarProps = { + base: IBase; + activeView: IBaseView | undefined; + views: IBaseView[]; + table?: Table; + onViewChange: (viewId: string) => void; + onAddView?: () => void; + canAddView?: boolean; + onPersistViewConfig: () => void; + onDraftSortsChange: (sorts: ViewSortConfig[] | undefined) => void; + onDraftFiltersChange: (filter: FilterGroup | undefined) => void; + onExpand?: () => void; + getViewShareUrl?: (viewId: string) => string | null; +}; + +export function BaseToolbar({ + base, + activeView, + views, + table, + onViewChange, + onAddView, + canAddView, + onPersistViewConfig, + onDraftSortsChange, + onDraftFiltersChange, + onExpand, + getViewShareUrl, +}: BaseToolbarProps) { + const { t } = useTranslation(); + const [sortOpened, setSortOpened] = useState(false); + const [filterOpened, setFilterOpened] = useState(false); + const [propertiesOpened, setPropertiesOpened] = useState(false); + const [cardPropertiesOpened, setCardPropertiesOpened] = useState(false); + const [exporting, setExporting] = useState(false); + + const isKanban = activeView?.type === "kanban"; + + const handleExport = useCallback(async () => { + if (exporting) return; + setExporting(true); + try { + await exportBaseToCsv(base.id); + } catch (err) { + notifications.show({ + color: "red", + message: getApiErrorMessage(err, t("Failed to export CSV")), + }); + } finally { + setExporting(false); + } + }, [base.id, exporting, t]); + + const openToolbar = useCallback((panel: "sort" | "filter" | "properties") => { + setSortOpened(panel === "sort" ? (v) => !v : false); + setFilterOpened(panel === "filter" ? (v) => !v : false); + setPropertiesOpened(panel === "properties" ? (v) => !v : false); + }, []); + + const sorts = activeView?.config?.sorts ?? []; + const conditions = useMemo(() => { + const filter = activeView?.config?.filter; + if (!filter || filter.op !== "and") return []; + return filter.children.filter( + (c): c is FilterCondition => !("children" in c), + ); + }, [activeView?.config?.filter]); + + const hiddenPropertyCount = useMemo(() => { + if (!table) return 0; + const cols = table.getAllLeafColumns().filter((col) => col.id !== "__row_number"); + return cols.filter((col) => col.getCanHide() && !col.getIsVisible()).length; + }, [table, table?.getState().columnVisibility]); + + const handleSortsChange = useCallback( + (newSorts: ViewSortConfig[]) => { + onDraftSortsChange(newSorts.length > 0 ? newSorts : undefined); + }, + [onDraftSortsChange], + ); + + const handleFiltersChange = useCallback( + (newConditions: FilterCondition[]) => { + const filter: FilterGroup | undefined = + newConditions.length > 0 + ? { op: "and", children: newConditions } + : undefined; + onDraftFiltersChange(filter); + }, + [onDraftFiltersChange], + ); + + return ( +
+ + +
+ + + + + + + setFilterOpened(false)} + conditions={conditions} + properties={base.properties} + onChange={handleFiltersChange} + > + + 0 ? "blue" : "gray"} + onClick={() => openToolbar("filter")} + > + + {conditions.length > 0 && ( + + {conditions.length} + + )} + + + + + {isKanban && activeView && ( + <> + + + + + + + + + setCardPropertiesOpened(false)} + base={base} + view={activeView} + pageId={base.id} + > + + setCardPropertiesOpened((v) => !v)} + > + + + + + + )} + + {!isKanban && ( + <> + setSortOpened(false)} + sorts={sorts} + properties={base.properties} + onChange={handleSortsChange} + > + + 0 ? "blue" : "gray"} + onClick={() => openToolbar("sort")} + > + + {sorts.length > 0 && ( + + {sorts.length} + + )} + + + + + {table && ( + setPropertiesOpened(false)} + table={table} + properties={base.properties} + onPersist={onPersistViewConfig} + > + + 0 ? "blue" : "gray"} + onClick={() => openToolbar("properties")} + > + + {hiddenPropertyCount > 0 && ( + + {hiddenPropertyCount} + + )} + + + + )} + + )} + + {onExpand && ( + + + + + + )} +
+
+ ); +} diff --git a/apps/client/src/ee/base/components/base-view-draft-banner.tsx b/apps/client/src/ee/base/components/base-view-draft-banner.tsx new file mode 100644 index 000000000..cda3c9f15 --- /dev/null +++ b/apps/client/src/ee/base/components/base-view-draft-banner.tsx @@ -0,0 +1,45 @@ +import { Group, Button, Tooltip } from "@mantine/core"; +import { useTranslation } from "react-i18next"; + +type BaseViewDraftBannerProps = { + isDirty: boolean; + canSave: boolean; + onReset: () => void; + onSave: () => void; + saving: boolean; +}; + +export function BaseViewDraftBanner({ + isDirty, + canSave, + onReset, + onSave, + saving, +}: BaseViewDraftBannerProps) { + const { t } = useTranslation(); + if (!isDirty) return null; + return ( + + + {canSave && ( + + + + )} + + ); +} diff --git a/apps/client/src/ee/base/components/base-view.tsx b/apps/client/src/ee/base/components/base-view.tsx new file mode 100644 index 000000000..22612f265 --- /dev/null +++ b/apps/client/src/ee/base/components/base-view.tsx @@ -0,0 +1,541 @@ +import { useCallback, useEffect, useMemo, useRef } from "react"; +import { Text, Stack } from "@mantine/core"; +import { useAtom } from "jotai"; +import { IconTable } from "@tabler/icons-react"; +import { useTranslation } from "react-i18next"; +import { notifications } from "@mantine/notifications"; +import { reorder } from "@atlaskit/pragmatic-drag-and-drop/reorder"; +import { generateJitteredKeyBetween } from "fractional-indexing-jittered"; +import { useBaseQuery } from "@/ee/base/queries/base-query"; +import { useBaseSocket } from "@/ee/base/hooks/use-base-socket"; +import { + FilterGroup, + ViewSortConfig, + EditingCell, + FocusedCell, + IBaseProperty, +} from "@/ee/base/types/base.types"; +import { + useBaseRowsQuery, + flattenRows, + useCreateRowMutation, + useUpdateRowMutation, + useReorderRowMutation, +} from "@/ee/base/queries/base-row-query"; +import { useUpdateViewMutation } from "@/ee/base/queries/base-view-query"; +import { + activeViewIdAtomFamily, + editingCellAtomFamily, + focusedCellAtomFamily, +} from "@/ee/base/atoms/base-atoms"; +import { useBaseTable } from "@/ee/base/hooks/use-base-table"; +import { isSystemPropertyType } from "@/ee/base/property-types/property-type.registry"; +import { useRowSelection } from "@/ee/base/hooks/use-row-selection"; +import useCurrentUser from "@/features/user/hooks/use-current-user"; +import { useHydrateCurrentUser } from "@/ee/base/reference/reference-store"; +import { useViewDraft } from "@/ee/base/hooks/use-view-draft"; +import { BaseToolbar } from "@/ee/base/components/base-toolbar"; +import { BaseViewDraftBanner } from "@/ee/base/components/base-view-draft-banner"; +import { BaseEmbedTitle } from "@/ee/base/components/base-embed-title"; +import { BaseTableSkeleton } from "@/ee/base/components/base-table-skeleton"; +import { ViewRenderer } from "@/ee/base/components/views/view-renderer"; +import { RowDetailModal } from "@/ee/base/components/row-detail-modal/row-detail-modal"; +import { useRowDetailModal } from "@/ee/base/hooks/use-row-detail-modal"; +import { BaseEditableProvider } from "@/ee/base/context/base-editable"; +import { RowExpandProvider } from "@/ee/base/context/row-expand"; +import { usePageQuery } from "@/features/page/queries/page-query"; +import { buildPageUrl } from "@/features/page/page.utils"; +import { getAppUrl } from "@/lib/config.ts"; +import { useNavigate } from "react-router-dom"; +import classes from "@/ee/base/styles/grid.module.css"; +import viewClasses from "@/ee/base/styles/base-view.module.css"; +import kanbanClasses from "@/ee/base/styles/kanban.module.css"; + +type BaseViewProps = { + pageId: string; + embedded?: boolean; + /** False makes the view read-only. Standalone passes page.permissions.canEdit; + * embedded ANDs that with the host editor's editability. */ + editable?: boolean; + titleSlot?: React.ReactNode; +}; + +export function BaseView({ pageId, embedded, editable = true, titleSlot }: BaseViewProps) { + const { t } = useTranslation(); + // Subscribe so other clients' edits, schema changes, and async-job completions reconcile into cache. + useBaseSocket(pageId); + const { data: base, isLoading: baseLoading, error: baseError } = + useBaseQuery(pageId); + + const navigate = useNavigate(); + const { data: page } = usePageQuery({ pageId }); + const handleExpand = useCallback(() => { + if (!page) return; + navigate(buildPageUrl(page.space?.slug, page.slugId, page.title)); + }, [navigate, page]); + + // Share URL for a specific view; always points at the standalone page where ?view= is honored. + const getViewShareUrl = useCallback( + (viewId: string) => + page + ? `${getAppUrl()}${buildPageUrl(page.space?.slug, page.slugId, page.title)}?view=${encodeURIComponent(viewId)}` + : null, + [page], + ); + + const [activeViewId, setActiveViewId] = useAtom( + activeViewIdAtomFamily(pageId), + ) as unknown as [string | null, (val: string | null) => void]; + + const [, setEditingCell] = useAtom( + editingCellAtomFamily(pageId), + ) as unknown as [EditingCell, (val: EditingCell) => void]; + + const [, setFocusedCell] = useAtom( + focusedCellAtomFamily(pageId), + ) as unknown as [FocusedCell, (val: FocusedCell) => void]; + + const views = useMemo( + () => + [...(base?.views ?? [])].sort((a, b) => + a.position < b.position ? -1 : a.position > b.position ? 1 : 0, + ), + [base?.views], + ); + const activeView = useMemo(() => { + if (!views.length) return undefined; + return views.find((v) => v.id === activeViewId) ?? views[0]; + }, [views, activeViewId]); + + const { data: currentUser } = useCurrentUser(); + useHydrateCurrentUser(pageId); + const { + effectiveFilter, + effectiveSorts, + isDirty, + setFilter: setDraftFilter, + setSorts: setDraftSorts, + reset: resetDraft, + buildPromotedConfig, + } = useViewDraft({ + userId: currentUser?.user.id, + pageId, + viewId: activeView?.id, + baselineFilter: activeView?.config?.filter, + baselineSorts: activeView?.config?.sorts, + }); + + // Baseline merged with local draft. Used for table state and toolbar badge counts. + // The real activeView remains the auto-persist baseline so drafts can't leak into layout writes. + const effectiveView = useMemo( + () => + activeView + ? { + ...activeView, + config: { + ...activeView.config, + filter: effectiveFilter, + sorts: effectiveSorts, + }, + } + : undefined, + [activeView, effectiveFilter, effectiveSorts], + ); + + const activeFilter = effectiveFilter; + const activeSorts = effectiveSorts; + + const canSave = editable; + + // Gate on base to avoid a "bland" list request before the active view's + // config resolves, which would double network traffic for sorted/filtered views. + const isKanban = activeView?.type === "kanban"; + + const { + data: rowsData, + isLoading: rowsLoading, + fetchNextPage, + hasNextPage, + isFetchingNextPage, + } = useBaseRowsQuery(base && !isKanban ? pageId : undefined, activeFilter, activeSorts); + + const updateRowMutation = useUpdateRowMutation(); + const createRowMutation = useCreateRowMutation(); + const reorderRowMutation = useReorderRowMutation(); + const updateViewMutation = useUpdateViewMutation(); + + useEffect(() => { + if (activeView && activeViewId !== activeView.id) { + setActiveViewId(activeView.id); + } + }, [activeView, activeViewId, setActiveViewId]); + + // Deep link: apply ?view= once after views load; skip if the id is + // unrecognised so we fall back to the default without fighting a later tab switch. + const appliedViewParamRef = useRef(false); + useEffect(() => { + if (appliedViewParamRef.current || views.length === 0) return; + const viewParam = new URLSearchParams(window.location.search).get("view"); + if (viewParam && views.some((v) => v.id === viewParam)) { + setActiveViewId(viewParam); + } + appliedViewParamRef.current = true; + }, [views, setActiveViewId]); + + const { clear: clearSelection } = useRowSelection(pageId); + useEffect(() => { + clearSelection(); + }, [pageId, activeView?.id, clearSelection]); + + const scrollportRef = useRef(null); + + const rows = useMemo(() => { + const flat = flattenRows(rowsData); + // With an active sort the server returns rows in sort order via keyset + // pagination; re-sorting by position on the client would break it as more + // pages load. Position sort only applies when no view sort is active. + if (activeSorts && activeSorts.length > 0) { + return flat; + } + return flat.sort((a, b) => + a.position < b.position ? -1 : a.position > b.position ? 1 : 0, + ); + }, [rowsData, activeSorts]); + const rowsRef = useRef(rows); + rowsRef.current = rows; + + const { table, persistViewConfig } = useBaseTable(base, rows, effectiveView); + + const guardedPersistViewConfig = useCallback(() => { + if (!editable) return; + persistViewConfig(); + }, [editable, persistViewConfig]); + + // Mutation result objects change identity every render; only .mutate is + // stable. Rows are memoized on these callbacks' identities, so they must + // not churn with unrelated re-renders. + const updateRow = updateRowMutation.mutate; + const handleCellUpdate = useCallback( + (rowId: string, propertyId: string, value: unknown) => { + if (!editable) return; + updateRow({ + rowId, + pageId, + cells: { [propertyId]: value }, + }); + }, + [editable, pageId, updateRow], + ); + + const handleAddRow = useCallback( + (afterRowId?: string, focusPropertyId?: string) => { + if (!editable) return; + createRowMutation.mutate( + { pageId, ...(afterRowId ? { afterRowId } : {}) }, + { + onSuccess: (newRow) => { + let propertyId = focusPropertyId; + if (!propertyId) { + const firstEditable = table.getVisibleLeafColumns().find((col) => { + if (col.id === "__row_number") return false; + const prop = col.columnDef.meta?.property as + | IBaseProperty + | undefined; + return ( + !!prop && + prop.type !== "checkbox" && + !isSystemPropertyType(prop.type) + ); + }); + propertyId = ( + firstEditable?.columnDef.meta?.property as + | IBaseProperty + | undefined + )?.id; + } + if (propertyId) { + setEditingCell({ rowId: newRow.id, propertyId }); + setFocusedCell({ rowId: newRow.id, propertyId }); + } + }, + }, + ); + }, + [editable, pageId, createRowMutation, table, setEditingCell, setFocusedCell], + ); + + const handleViewChange = useCallback( + (viewId: string) => { + setActiveViewId(viewId); + }, + [setActiveViewId], + ); + + const handleColumnReorder = useCallback( + (columnId: string, finishIndex: number) => { + const order = table.getState().columnOrder; + const startIndex = order.indexOf(columnId); + if (startIndex === -1 || startIndex === finishIndex) return; + table.setColumnOrder(reorder({ list: order, startIndex, finishIndex })); + guardedPersistViewConfig(); + }, + [table, guardedPersistViewConfig], + ); + + const handleResizeEnd = useCallback(() => { + guardedPersistViewConfig(); + }, [guardedPersistViewConfig]); + + const handleDraftSortsChange = useCallback( + (sorts: ViewSortConfig[] | undefined) => { + setDraftSorts(sorts && sorts.length > 0 ? sorts : undefined); + }, + [setDraftSorts], + ); + + const handleDraftFiltersChange = useCallback( + (filter: FilterGroup | undefined) => { + setDraftFilter(filter); + }, + [setDraftFilter], + ); + + const handleSaveDraft = useCallback(async () => { + if (!activeView || !base) return; + // Preserves non-draft baseline fields (widths/order/visibility), overwrites only filter/sorts. + const config = buildPromotedConfig(activeView.config); + try { + await updateViewMutation.mutateAsync({ + viewId: activeView.id, + pageId: base.id, + config, + }); + resetDraft(); + notifications.show({ message: t("View updated for everyone") }); + } catch { + // useUpdateViewMutation shows a toast and rolls back; keep the draft so the user can retry. + } + }, [ + activeView, + base, + buildPromotedConfig, + resetDraft, + t, + updateViewMutation, + ]); + + const { openRowId, openRow, closeRow } = useRowDetailModal(pageId); + // openRow's identity tracks searchParams; rows subscribe to the expand + // context, so hand them a stable wrapper instead. + const openRowRef = useRef(openRow); + openRowRef.current = openRow; + const handleExpandRow = useCallback((rowId: string) => { + openRowRef.current(rowId); + }, []); + const handleRowNavigate = useCallback((rowId: string) => { + openRowRef.current(rowId, { replace: true }); + }, []); + + const reorderRow = reorderRowMutation.mutate; + const handleRowReorder = useCallback( + (rowId: string, targetRowId: string, dropPosition: "above" | "below") => { + if (!editable) return; + const remainingRows = rowsRef.current.filter((r) => r.id !== rowId); + const targetIndex = remainingRows.findIndex((r) => r.id === targetRowId); + if (targetIndex === -1) return; + + let lowerPos: string | null = null; + let upperPos: string | null = null; + if (dropPosition === "above") { + lowerPos = + targetIndex > 0 ? remainingRows[targetIndex - 1]?.position : null; + upperPos = remainingRows[targetIndex]?.position ?? null; + } else { + lowerPos = remainingRows[targetIndex]?.position ?? null; + upperPos = + targetIndex < remainingRows.length - 1 + ? remainingRows[targetIndex + 1]?.position + : null; + } + + try { + let newPosition: string; + if (lowerPos && upperPos && lowerPos === upperPos) { + newPosition = generateJitteredKeyBetween(lowerPos, null); + } else { + newPosition = generateJitteredKeyBetween(lowerPos, upperPos); + } + reorderRow({ rowId, pageId, position: newPosition }); + } catch { + // Position computation failed; skip silently. + } + }, + [editable, pageId, reorderRow], + ); + + if (baseLoading || (!isKanban && rowsLoading)) { + return ; + } + if (baseError) { + return ( + + + {t("Failed to load base")} + + ); + } + if (!base) return null; + + // Ghost rows are an "empty base" affordance, not a "filter matched nothing" state. + const isFiltered = (activeFilter?.children?.length ?? 0) > 0; + + const banner = ( + + ); + + const toolbar = ( + + ); + + const kanbanBand = ( +
+ {embedded ? null : titleSlot} + {banner} + {toolbar} + {embedded ? : null} +
+ ); + + const viewRenderer = (folded: React.ReactNode) => ( + + ); + + if (embedded) { + if (isKanban) { + return ( + + + {kanbanBand} + {viewRenderer(null)} + + + + ); + } + + // Banner and toolbar go into aboveBand so they scroll with the host document; + // only the column-header row stays pinned (via --sticky-band-top). + return ( + + + {viewRenderer( + <> + {banner} + {toolbar} + + , + )} + + + + ); + } + + if (isKanban) { + return ( + +
+ + {kanbanBand} + {viewRenderer(null)} + +
+ +
+ ); + } + + // Standalone: title, banner, and toolbar go in aboveBand inside the scroll + // container so they scroll away; only the column-header row stays pinned. + return ( + +
+
+ + {viewRenderer( + <> + {titleSlot} + {banner} + {toolbar} + , + )} + +
+
+ +
+ ); +} diff --git a/apps/client/src/ee/base/components/cells/badge-overflow.tsx b/apps/client/src/ee/base/components/cells/badge-overflow.tsx new file mode 100644 index 000000000..278b84472 --- /dev/null +++ b/apps/client/src/ee/base/components/cells/badge-overflow.tsx @@ -0,0 +1,100 @@ +import { ReactElement, useLayoutEffect, useRef, useState } from "react"; +import { Tooltip } from "@mantine/core"; +import cellClasses from "@/ee/base/styles/cells.module.css"; + +export function computeVisibleBadgeCount( + itemWidths: number[], + gap: number, + available: number, + badgeWidth: number, +): number { + const count = itemWidths.length; + if (count === 0) return 0; + if (available <= 0) return count; + + let lineWidth = 0; + for (let i = 0; i < count; i++) { + lineWidth += itemWidths[i] + (i > 0 ? gap : 0); + } + if (lineWidth <= available) return count; + + let used = 0; + let fit = 0; + for (let i = 0; i < count; i++) { + const advance = itemWidths[i] + (i > 0 ? gap : 0); + if (used + advance + gap + badgeWidth <= available) { + used += advance; + fit = i + 1; + } else { + break; + } + } + return Math.max(fit, 1); +} + +const BADGE_GAP = 4; + +type BadgeOverflowListProps = { + chips: ReactElement[]; + measureKey: string; + tooltipLabel?: string; +}; + +export function BadgeOverflowList({ + chips, + measureKey, + tooltipLabel, +}: BadgeOverflowListProps) { + const containerRef = useRef(null); + const measureRef = useRef(null); + const [visibleCount, setVisibleCount] = useState(chips.length); + + useLayoutEffect(() => { + const container = containerRef.current; + const measure = measureRef.current; + if (!container || !measure) return; + + const recompute = () => { + const nodes = Array.from(measure.children) as HTMLElement[]; + const chipWidths = nodes.slice(0, -1).map((n) => n.offsetWidth); + const badgeWidth = nodes[nodes.length - 1]?.offsetWidth ?? 0; + setVisibleCount( + computeVisibleBadgeCount( + chipWidths, + BADGE_GAP, + container.clientWidth, + badgeWidth, + ), + ); + }; + + recompute(); + const observer = new ResizeObserver(recompute); + observer.observe(container); + return () => observer.disconnect(); + }, [measureKey]); + + const visible = chips.slice(0, visibleCount); + const overflow = chips.length - visibleCount; + + return ( + +
+
+ {chips} + +{chips.length} +
+ {visible} + {overflow > 0 && ( + +{overflow} + )} +
+
+ ); +} diff --git a/apps/client/src/ee/base/components/cells/cell-checkbox.tsx b/apps/client/src/ee/base/components/cells/cell-checkbox.tsx new file mode 100644 index 000000000..163359dbd --- /dev/null +++ b/apps/client/src/ee/base/components/cells/cell-checkbox.tsx @@ -0,0 +1,44 @@ +import { useCallback } from "react"; +import { Checkbox } from "@mantine/core"; +import { IBaseProperty } from "@/ee/base/types/base.types"; +import cellClasses from "@/ee/base/styles/cells.module.css"; + +type CellCheckboxProps = { + value: unknown; + property: IBaseProperty; + rowId: string; + isEditing: boolean; + readOnly?: boolean; + onCommit: (value: unknown) => void; + onCancel: () => void; +}; + +export function CellCheckbox({ value, readOnly, onCommit }: CellCheckboxProps) { + const checked = value === true; + + const handleChange = useCallback(() => { + if (readOnly) return; + onCommit(!checked); + }, [readOnly, checked, onCommit]); + + return ( +
+ {}} + size="xs" + tabIndex={-1} + styles={{ + input: { + cursor: readOnly ? "default" : "pointer", + pointerEvents: "none", + }, + }} + /> +
+ ); +} diff --git a/apps/client/src/ee/base/components/cells/cell-created-at.tsx b/apps/client/src/ee/base/components/cells/cell-created-at.tsx new file mode 100644 index 000000000..21286843c --- /dev/null +++ b/apps/client/src/ee/base/components/cells/cell-created-at.tsx @@ -0,0 +1,22 @@ +import { IBaseProperty } from "@/ee/base/types/base.types"; +import { formatTimestamp } from "@/ee/base/formatters/cell-formatters"; +import cellClasses from "@/ee/base/styles/cells.module.css"; + +type CellCreatedAtProps = { + value: unknown; + property: IBaseProperty; + rowId: string; + isEditing: boolean; + onCommit: (value: unknown) => void; + onCancel: () => void; +}; + +export function CellCreatedAt({ value }: CellCreatedAtProps) { + const formatted = formatTimestamp(typeof value === "string" ? value : null); + + if (!formatted) { + return ; + } + + return {formatted}; +} diff --git a/apps/client/src/ee/base/components/cells/cell-date.tsx b/apps/client/src/ee/base/components/cells/cell-date.tsx new file mode 100644 index 000000000..a4af25a15 --- /dev/null +++ b/apps/client/src/ee/base/components/cells/cell-date.tsx @@ -0,0 +1,146 @@ +import { useCallback } from "react"; +import { Popover } from "@mantine/core"; +import { DatePicker } from "@mantine/dates"; +import { + IBaseProperty, + DateTypeOptions, +} from "@/ee/base/types/base.types"; +import cellClasses from "@/ee/base/styles/cells.module.css"; + +type CellDateProps = { + value: unknown; + property: IBaseProperty; + rowId: string; + isEditing: boolean; + onCommit: (value: unknown) => void; + onCancel: () => void; +}; + +export function formatDateDisplay( + dateStr: string | null | undefined, + options: DateTypeOptions | undefined, +): string { + if (!dateStr) return ""; + try { + const date = new Date(dateStr); + if (isNaN(date.getTime())) return ""; + + const months = [ + "Jan", "Feb", "Mar", "Apr", "May", "Jun", + "Jul", "Aug", "Sep", "Oct", "Nov", "Dec", + ]; + const month = months[date.getMonth()]; + const day = date.getDate(); + const year = date.getFullYear(); + + let result = `${month} ${day}, ${year}`; + + if (options?.includeTime) { + if (options.timeFormat === "24h") { + const hours = String(date.getHours()).padStart(2, "0"); + const minutes = String(date.getMinutes()).padStart(2, "0"); + result += ` ${hours}:${minutes}`; + } else { + let hours = date.getHours(); + const ampm = hours >= 12 ? "PM" : "AM"; + hours = hours % 12 || 12; + const minutes = String(date.getMinutes()).padStart(2, "0"); + result += ` ${hours}:${minutes} ${ampm}`; + } + } + + return result; + } catch { + return ""; + } +} + +function toISODateString(dateStr: string | null): string | null { + if (!dateStr) return null; + try { + const date = new Date(dateStr); + if (isNaN(date.getTime())) return null; + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, "0"); + const day = String(date.getDate()).padStart(2, "0"); + return `${year}-${month}-${day}`; + } catch { + return null; + } +} + +export function CellDate({ + value, + property, + isEditing, + onCommit, + onCancel, +}: CellDateProps) { + const typeOptions = property.typeOptions as DateTypeOptions | undefined; + const dateStr = typeof value === "string" ? value : null; + const pickerValue = toISODateString(dateStr); + + const handleChange = useCallback( + (selected: string | null) => { + if (selected) { + const date = new Date(selected); + onCommit(date.toISOString()); + } else { + onCommit(null); + } + }, + [onCommit], + ); + + const handleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (e.key === "Escape") { + e.preventDefault(); + onCancel(); + } + }, + [onCancel], + ); + + if (isEditing) { + return ( + { + if (!o) onCancel(); + }} + onClose={onCancel} + position="bottom-start" + width="auto" + trapFocus + closeOnClickOutside + closeOnEscape + > + +
+ + {formatDateDisplay(dateStr, typeOptions)} + +
+
+ + + +
+ ); + } + + if (!dateStr) { + return ; + } + + return ( + + {formatDateDisplay(dateStr, typeOptions)} + + ); +} diff --git a/apps/client/src/ee/base/components/cells/cell-email.tsx b/apps/client/src/ee/base/components/cells/cell-email.tsx new file mode 100644 index 000000000..0d93f69fa --- /dev/null +++ b/apps/client/src/ee/base/components/cells/cell-email.tsx @@ -0,0 +1,61 @@ +import { IBaseProperty } from "@/ee/base/types/base.types"; +import { Tooltip } from "@mantine/core"; +import { useEditableTextCell } from "@/ee/base/hooks/use-editable-text-cell"; +import cellClasses from "@/ee/base/styles/cells.module.css"; + +type CellEmailProps = { + value: unknown; + property: IBaseProperty; + rowId: string; + isEditing: boolean; + onCommit: (value: unknown) => void; + onCancel: () => void; +}; + +const toDraft = (value: unknown) => (typeof value === "string" ? value : ""); +const parse = (draft: string) => draft || null; + +export function CellEmail({ value, property, rowId, isEditing, onCommit, onCancel }: CellEmailProps) { + const { draft, setDraft, inputRef, handleKeyDown, handleBlur } = + useEditableTextCell({ + value, + isEditing, + onCommit, + onCancel, + toDraft, + parse, + rowId, + propertyId: property.id, + }); + + if (isEditing) { + return ( + setDraft(e.target.value)} + onKeyDown={handleKeyDown} + onBlur={handleBlur} + /> + ); + } + + const displayValue = toDraft(value); + if (!displayValue) { + return ; + } + return ( + + e.stopPropagation()} + > + {displayValue} + + + ); +} diff --git a/apps/client/src/ee/base/components/cells/cell-file.tsx b/apps/client/src/ee/base/components/cells/cell-file.tsx new file mode 100644 index 000000000..3d3bcd247 --- /dev/null +++ b/apps/client/src/ee/base/components/cells/cell-file.tsx @@ -0,0 +1,236 @@ +import { useState, useRef, useCallback } from "react"; +import { Popover, ActionIcon, Text, UnstyledButton } from "@mantine/core"; +import { + IconPaperclip, + IconUpload, + IconFile, + IconX, +} from "@tabler/icons-react"; +import { IBaseProperty } from "@/ee/base/types/base.types"; +import cellClasses from "@/ee/base/styles/cells.module.css"; +import { uploadFile } from "@/features/page/services/page-service"; +import { getFileUrl } from "@/lib/config"; + +export type FileValue = { + id: string; + fileName: string; + mimeType?: string; + fileSize?: number; + url?: string; +}; + +function buildFileUrl(file: Pick): string { + return file.url ?? `/api/files/${file.id}/${encodeURIComponent(file.fileName)}`; +} + +type CellFileProps = { + value: unknown; + property: IBaseProperty; + rowId: string; + isEditing: boolean; + readOnly?: boolean; + onCommit: (value: unknown) => void; + onCancel: () => void; +}; + +function formatFileSize(bytes?: number): string { + if (!bytes) return ""; + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; + return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; +} + +function parseFiles(value: unknown): FileValue[] { + if (!Array.isArray(value)) return []; + return value.filter( + (f): f is FileValue => + f && typeof f === "object" && "id" in f && "fileName" in f, + ); +} + +export function CellFile({ + value, + property, + isEditing, + readOnly, + onCommit, + onCancel, +}: CellFileProps) { + const files = parseFiles(value); + const fileInputRef = useRef(null); + const [uploading, setUploading] = useState(false); + + const handleRemove = useCallback( + (fileId: string) => { + if (readOnly) return; + const updated = files.filter((f) => f.id !== fileId); + onCommit(updated.length > 0 ? updated : null); + }, + [readOnly, files, onCommit], + ); + + const handleUpload = useCallback( + async (fileList: FileList | null) => { + if (!fileList || fileList.length === 0) return; + setUploading(true); + + const newFiles: FileValue[] = [...files]; + + // Reuse the page-attachment upload pipeline: the base's pageId is passed + // to the standard /files/upload endpoint, which enforces the same edit + // access check as any other page attachment. + for (const file of Array.from(fileList)) { + try { + const attachment = await uploadFile(file, property.pageId); + newFiles.push({ + id: attachment.id, + fileName: attachment.fileName, + mimeType: attachment.mimeType, + fileSize: attachment.fileSize, + url: `/api/files/${attachment.id}/${encodeURIComponent(attachment.fileName)}`, + }); + } catch (err) { + console.error("File upload failed:", err); + } + } + + setUploading(false); + onCommit(newFiles.length > 0 ? newFiles : null); + }, + [files, property.pageId, onCommit], + ); + + const handleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (e.key === "Escape") { + e.preventDefault(); + onCancel(); + } + }, + [onCancel], + ); + + const MAX_VISIBLE = 2; + + if (isEditing) { + return ( + { + if (!o) onCancel(); + }} + onClose={onCancel} + position="bottom-start" + width={280} + trapFocus + closeOnClickOutside + closeOnEscape + hideDetached={false} + > + +
+ +
+
+ + {!readOnly && files.length === 0 && !uploading && ( + + No files attached + + )} + + {files.map((file) => ( + + ))} + + {!readOnly && ( + <> + { + handleUpload(e.target.files); + e.target.value = ""; + }} + /> + + fileInputRef.current?.click()} + disabled={uploading} + className={cellClasses.fileUploadBtn} + style={{ + color: uploading + ? "var(--mantine-color-gray-5)" + : "var(--mantine-color-blue-6)", + }} + > + + {uploading ? "Uploading..." : "Add file"} + + + )} + +
+ ); + } + + if (files.length === 0) { + return ; + } + + return ; +} + +function FileList({ + files, + maxVisible, +}: { + files: FileValue[]; + maxVisible: number; +}) { + const visible = files.slice(0, maxVisible); + const overflow = files.length - maxVisible; + + return ( +
+ {visible.map((file) => ( + + + {file.fileName} + + ))} + {overflow > 0 && ( + +{overflow} + )} +
+ ); +} diff --git a/apps/client/src/ee/base/components/cells/cell-formula.tsx b/apps/client/src/ee/base/components/cells/cell-formula.tsx new file mode 100644 index 000000000..bdaaf11cf --- /dev/null +++ b/apps/client/src/ee/base/components/cells/cell-formula.tsx @@ -0,0 +1,38 @@ +import { Badge, Tooltip } from "@mantine/core"; +import { + IBaseProperty, + isFormulaErrorCell, +} from "@/ee/base/types/base.types"; +import { CellText } from "./cell-text"; +import { CellNumber } from "./cell-number"; +import { CellCheckbox } from "./cell-checkbox"; +import { CellDate } from "./cell-date"; + +type Props = { + value: unknown; + property: IBaseProperty; + rowId: string; + isEditing: boolean; + onCommit: (value: unknown) => void; + onCancel: () => void; +}; + +export function CellFormula(props: Props) { + const { value, property } = props; + if (isFormulaErrorCell(value)) { + return ( + + + #ERROR + + + ); + } + const opts = (property.typeOptions ?? {}) as { resultType?: string }; + const resultType = opts.resultType ?? "null"; + const readOnlyProps = { ...props, isEditing: false }; + if (resultType === "number") return ; + if (resultType === "boolean") return ; + if (resultType === "date") return ; + return ; +} diff --git a/apps/client/src/ee/base/components/cells/cell-last-edited-at.tsx b/apps/client/src/ee/base/components/cells/cell-last-edited-at.tsx new file mode 100644 index 000000000..efad990de --- /dev/null +++ b/apps/client/src/ee/base/components/cells/cell-last-edited-at.tsx @@ -0,0 +1,22 @@ +import { IBaseProperty } from "@/ee/base/types/base.types"; +import { formatTimestamp } from "@/ee/base/formatters/cell-formatters"; +import cellClasses from "@/ee/base/styles/cells.module.css"; + +type CellLastEditedAtProps = { + value: unknown; + property: IBaseProperty; + rowId: string; + isEditing: boolean; + onCommit: (value: unknown) => void; + onCancel: () => void; +}; + +export function CellLastEditedAt({ value }: CellLastEditedAtProps) { + const formatted = formatTimestamp(typeof value === "string" ? value : null); + + if (!formatted) { + return ; + } + + return {formatted}; +} diff --git a/apps/client/src/ee/base/components/cells/cell-last-edited-by.tsx b/apps/client/src/ee/base/components/cells/cell-last-edited-by.tsx new file mode 100644 index 000000000..b6dc90c52 --- /dev/null +++ b/apps/client/src/ee/base/components/cells/cell-last-edited-by.tsx @@ -0,0 +1,41 @@ +import { Group, Tooltip } from "@mantine/core"; +import { IBaseProperty } from "@/ee/base/types/base.types"; +import { useReferenceStore } from "@/ee/base/reference/reference-store"; +import { CustomAvatar } from "@/components/ui/custom-avatar"; +import cellClasses from "@/ee/base/styles/cells.module.css"; + +type CellLastEditedByProps = { + value: unknown; + property: IBaseProperty; + rowId: string; + isEditing: boolean; + onCommit: (value: unknown) => void; + onCancel: () => void; +}; + +export function CellLastEditedBy({ value, property }: CellLastEditedByProps) { + const userId = typeof value === "string" ? value : null; + + const store = useReferenceStore(property.pageId); + const user = userId ? store.users[userId] ?? null : null; + + if (!userId) { + return ; + } + + const name = user?.name ?? userId.substring(0, 8); + + return ( + + + + {name} + + + ); +} diff --git a/apps/client/src/ee/base/components/cells/cell-long-text.tsx b/apps/client/src/ee/base/components/cells/cell-long-text.tsx new file mode 100644 index 000000000..2c3900562 --- /dev/null +++ b/apps/client/src/ee/base/components/cells/cell-long-text.tsx @@ -0,0 +1,151 @@ +import { useEffect, useRef, useState } from "react"; +import { Popover, Textarea, Group, CloseButton, Tooltip } from "@mantine/core"; +import { useDebouncedCallback } from "@mantine/hooks"; +import { IBaseProperty } from "@/ee/base/types/base.types"; +import { formatLongTextPreview } from "@/ee/base/formatters/cell-formatters"; +import cellClasses from "@/ee/base/styles/cells.module.css"; + +type CellLongTextProps = { + value: unknown; + property: IBaseProperty; + rowId: string; + isEditing: boolean; + onCommit: (value: unknown) => void; + onValueChange: (value: unknown) => void; + onCancel: () => void; + onTabNavigate?: (shiftKey: boolean) => void; +}; + +const toText = (value: unknown) => (typeof value === "string" ? value : ""); +const normalize = (s: string) => { + const trimmed = s.trim(); + return trimmed.length ? trimmed : null; +}; + +export function CellLongText({ + value, + isEditing, + onCommit, + onValueChange, + onCancel, + onTabNavigate, +}: CellLongTextProps) { + const [draft, setDraft] = useState(() => toText(value)); + const cancelledRef = useRef(false); + const committedRef = useRef(false); + const wasEditingRef = useRef(false); + const textareaRef = useRef(null); + + // Seed draft and focus on the false->true editing transition only; ignore + // value changes mid-edit so the user's typing is not clobbered. + useEffect(() => { + if (isEditing && !wasEditingRef.current) { + cancelledRef.current = false; + committedRef.current = false; + setDraft(toText(value)); + requestAnimationFrame(() => { + const el = textareaRef.current; + if (!el) return; + el.focus(); + el.setSelectionRange(el.value.length, el.value.length); + }); + } + wasEditingRef.current = isEditing; + }, [isEditing, value]); + + // Autosave after a typing pause; commit/cancel clear the pending fire so + // a closed editor can never write a stale or discarded draft. + const debouncedAutosave = useDebouncedCallback(() => { + onValueChange(normalize(draft)); + }, 10_000); + + const commit = () => { + if (committedRef.current) return; + committedRef.current = true; + debouncedAutosave.cancel(); + onCommit(normalize(draft)); + }; + const cancel = () => { + cancelledRef.current = true; + debouncedAutosave.cancel(); + onCancel(); + }; + + const preview = formatLongTextPreview(toText(value)); + + return ( + { + if (opened) return; + // Programmatic close after cancel must not re-commit. + if (cancelledRef.current) { + cancelledRef.current = false; + return; + } + commit(); + }} + position="bottom-start" + width={320} + shadow="md" + withinPortal + closeOnClickOutside + closeOnEscape={false} + trapFocus + > + +
+ {preview ? ( + + {preview} + + ) : ( + + )} +
+
+ e.stopPropagation()} + className={cellClasses.longTextDropdown} + > + {isEditing && ( + <> + + + +