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)