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);