diff --git a/apps/client/package.json b/apps/client/package.json index 5d626341..9401a8fb 100644 --- a/apps/client/package.json +++ b/apps/client/package.json @@ -24,6 +24,7 @@ "@mantine/spotlight": "^7.17.0", "@tabler/icons-react": "^3.22.0", "@tanstack/react-query": "^5.61.4", + "@tiptap/extension-character-count": "^2.11.5", "axios": "^1.7.9", "clsx": "^2.1.1", "emoji-mart": "^5.6.0", diff --git a/apps/client/public/locales/en-US/translation.json b/apps/client/public/locales/en-US/translation.json index 209280b1..b69d0b28 100644 --- a/apps/client/public/locales/en-US/translation.json +++ b/apps/client/public/locales/en-US/translation.json @@ -346,5 +346,10 @@ "Space deleted successfully": "Space deleted successfully", "Members added successfully": "Members added successfully", "Member removed successfully": "Member removed successfully", - "Member role updated successfully": "Member role updated successfully" + "Member role updated successfully": "Member role updated successfully", + "Created by: {{creatorName}}": "Created by: {{creatorName}}", + "Created at: {{time}}": "Created at: {{time}}", + "Edited by {{name}} {{time}}": "Edited by {{name}} {{time}}", + "Word count: {{wordCount}}": "Word count: {{wordCount}}", + "Character count: {{characterCount}}": "Character count: {{characterCount}}" } diff --git a/apps/client/src/features/editor/extensions/extensions.ts b/apps/client/src/features/editor/extensions/extensions.ts index 7a5d2363..ecdac0c1 100644 --- a/apps/client/src/features/editor/extensions/extensions.ts +++ b/apps/client/src/features/editor/extensions/extensions.ts @@ -71,6 +71,7 @@ import MentionView from "@/features/editor/components/mention/mention-view.tsx"; import i18n from "@/i18n.ts"; import { MarkdownClipboard } from "@/features/editor/extensions/markdown-clipboard.ts"; import EmojiCommand from "./emoji-command"; +import { CharacterCount } from "@tiptap/extension-character-count"; const lowlight = createLowlight(common); lowlight.register("mermaid", plaintext); @@ -211,6 +212,7 @@ export const mainExtensions = [ MarkdownClipboard.configure({ transformPastedText: true, }), + CharacterCount ] as any; type CollabExtensions = (provider: HocuspocusProvider, user: IUser) => any[]; diff --git a/apps/client/src/features/page/components/header/page-header-menu.tsx b/apps/client/src/features/page/components/header/page-header-menu.tsx index 5531b217..17db2fe9 100644 --- a/apps/client/src/features/page/components/header/page-header-menu.tsx +++ b/apps/client/src/features/page/components/header/page-header-menu.tsx @@ -1,4 +1,4 @@ -import { ActionIcon, Group, Menu, Tooltip } from "@mantine/core"; +import { ActionIcon, Group, Menu, Text, Tooltip } from "@mantine/core"; import { IconArrowsHorizontal, IconDots, @@ -24,9 +24,13 @@ import { extractPageSlugId } from "@/lib"; import { treeApiAtom } from "@/features/page/tree/atoms/tree-api-atom.ts"; import { useDeletePageModal } from "@/features/page/hooks/use-delete-page-modal.tsx"; import { PageWidthToggle } from "@/features/user/components/page-width-pref.tsx"; -import { useTranslation } from "react-i18next"; +import { Trans, useTranslation } from "react-i18next"; import ExportModal from "@/components/common/export-modal"; -import { yjsConnectionStatusAtom } from "@/features/editor/atoms/editor-atoms.ts"; +import { + pageEditorAtom, + yjsConnectionStatusAtom, +} from "@/features/editor/atoms/editor-atoms.ts"; +import { formattedDate, timeAgo } from "@/lib/time.ts"; interface PageHeaderMenuProps { readOnly?: boolean; @@ -79,6 +83,7 @@ function PageActionMenu({ readOnly }: PageActionMenuProps) { const [tree] = useAtom(treeApiAtom); const [exportOpened, { open: openExportModal, close: closeExportModal }] = useDisclosure(false); + const [pageEditor] = useAtom(pageEditorAtom); const handleCopyLink = () => { const pageUrl = @@ -108,7 +113,7 @@ function PageActionMenu({ readOnly }: PageActionMenuProps) { shadow="xl" position="bottom-end" offset={20} - width={200} + width={230} withArrow arrowPosition="center" > @@ -168,6 +173,41 @@ function PageActionMenu({ readOnly }: PageActionMenuProps) { )} + + + + <> + + +
+ + {t("Word count: {{wordCount}}", { + wordCount: pageEditor?.storage?.characterCount?.words(), + })} + + + + }} + /> + + + {t("Created at: {{time}}", { + time: formattedDate(page.createdAt), + })} + +
+
+
+ diff --git a/apps/client/src/features/page/types/page.types.ts b/apps/client/src/features/page/types/page.types.ts index acc0ca7b..540f9841 100644 --- a/apps/client/src/features/page/types/page.types.ts +++ b/apps/client/src/features/page/types/page.types.ts @@ -12,16 +12,28 @@ export interface IPage { spaceId: string; workspaceId: string; isLocked: boolean; - isPublic: boolean; - lastModifiedById: Date; + lastUpdatedById: Date; createdAt: Date; updatedAt: Date; deletedAt: Date; position: string; hasChildren: boolean; + creator: ICreator; + lastUpdatedBy: ILastUpdatedBy; space: Partial; } +interface ICreator { + id: string; + name: string; + avatarUrl: string; +} +interface ILastUpdatedBy { + id: string; + name: string; + avatarUrl: string; +} + export interface IMovePage { pageId: string; position?: string; diff --git a/apps/server/src/core/page/page.controller.ts b/apps/server/src/core/page/page.controller.ts index d26b8cac..21dfcaf2 100644 --- a/apps/server/src/core/page/page.controller.ts +++ b/apps/server/src/core/page/page.controller.ts @@ -44,6 +44,8 @@ export class PageController { const page = await this.pageRepo.findById(dto.pageId, { includeSpace: true, includeContent: true, + includeCreator: true, + includeLastUpdatedBy: true, }); if (!page) { diff --git a/apps/server/src/database/repos/page/page.repo.ts b/apps/server/src/database/repos/page/page.repo.ts index e02a3b2f..0e01c657 100644 --- a/apps/server/src/database/repos/page/page.repo.ts +++ b/apps/server/src/database/repos/page/page.repo.ts @@ -46,6 +46,8 @@ export class PageRepo { includeContent?: boolean; includeYdoc?: boolean; includeSpace?: boolean; + includeCreator?: boolean; + includeLastUpdatedBy?: boolean; withLock?: boolean; trx?: KyselyTransaction; }, @@ -58,6 +60,14 @@ export class PageRepo { .$if(opts?.includeContent, (qb) => qb.select('content')) .$if(opts?.includeYdoc, (qb) => qb.select('ydoc')); + if (opts?.includeCreator) { + query = query.select((eb) => this.withCreator(eb)); + } + + if (opts?.includeLastUpdatedBy) { + query = query.select((eb) => this.withLastUpdatedBy(eb)); + } + if (opts?.includeSpace) { query = query.select((eb) => this.withSpace(eb)); } @@ -161,6 +171,24 @@ export class PageRepo { ).as('space'); } + withCreator(eb: ExpressionBuilder) { + return jsonObjectFrom( + eb + .selectFrom('users') + .select(['users.id', 'users.name', 'users.avatarUrl']) + .whereRef('users.id', '=', 'pages.creatorId'), + ).as('creator'); + } + + withLastUpdatedBy(eb: ExpressionBuilder) { + return jsonObjectFrom( + eb + .selectFrom('users') + .select(['users.id', 'users.name', 'users.avatarUrl']) + .whereRef('users.id', '=', 'pages.lastUpdatedById'), + ).as('lastUpdatedBy'); + } + async getPageAndDescendants(parentPageId: string) { return this.db .withRecursive('page_hierarchy', (db) => diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 07bd4642..043f92d4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -242,6 +242,9 @@ importers: '@tanstack/react-query': specifier: ^5.61.4 version: 5.61.4(react@18.3.1) + '@tiptap/extension-character-count': + specifier: ^2.11.5 + version: 2.11.5(@tiptap/core@2.10.3(@tiptap/pm@2.10.3))(@tiptap/pm@2.10.3) axios: specifier: ^1.7.9 version: 1.7.9 @@ -3654,6 +3657,12 @@ packages: peerDependencies: '@tiptap/core': ^2.7.0 + '@tiptap/extension-character-count@2.11.5': + resolution: {integrity: sha512-Da2VGb7ClmKwXdQdQC2735qylYD8/MQAPA0skPEcHxcDTDuI8ibyIDnMPnczgS/hR5g0TYE2DQp/dkhJXeovkQ==} + peerDependencies: + '@tiptap/core': ^2.7.0 + '@tiptap/pm': ^2.7.0 + '@tiptap/extension-code-block-lowlight@2.10.3': resolution: {integrity: sha512-ieRSdfDW06pmKcsh73N506/EWNJrpMrZzyuFx3YGJtfM+Os0a9hMLy2TSuNleyRsihBi5mb+zvdeqeGdaJm7Ng==} peerDependencies: @@ -12802,6 +12811,11 @@ snapshots: dependencies: '@tiptap/core': 2.10.3(@tiptap/pm@2.10.3) + '@tiptap/extension-character-count@2.11.5(@tiptap/core@2.10.3(@tiptap/pm@2.10.3))(@tiptap/pm@2.10.3)': + dependencies: + '@tiptap/core': 2.10.3(@tiptap/pm@2.10.3) + '@tiptap/pm': 2.10.3 + '@tiptap/extension-code-block-lowlight@2.10.3(@tiptap/core@2.10.3(@tiptap/pm@2.10.3))(@tiptap/extension-code-block@2.10.3(@tiptap/core@2.10.3(@tiptap/pm@2.10.3))(@tiptap/pm@2.10.3))(@tiptap/pm@2.10.3)(highlight.js@11.10.0)(lowlight@3.2.0)': dependencies: '@tiptap/core': 2.10.3(@tiptap/pm@2.10.3)