Compare commits

...
11 Commits
Author SHA1 Message Date
Philipinho 943c22c5a5 chore: migrate to Mantine 9 and React 19 2026-06-19 23:14:08 +01:00
Mayank-2-16andPhilipinho 1867aa8bf6 fix: pdf table header alignment issue (#2259)
* fix: pdf table header alignment issue
* cleanup

---------

Co-authored-by: Philipinho <16838612+Philipinho@users.noreply.github.com>
2026-06-17 14:32:48 +01:00
Felix 2ff8720832 fix: slash-menu suggestion search localization (#2280) 2026-06-17 14:17:13 +01:00
Philip Okugbe 6191acfa14 fix: a11y (#2275) 2026-06-09 22:51:55 +01:00
Peter Tripp d86d51c27e fix: Table jitter on edit/read toggle (#2252) 2026-06-03 11:31:45 +01:00
Philipinho ef04c22aea sync 2026-05-28 16:57:59 +01:00
Philipinho b6760c63c4 fix: package updates 2026-05-28 16:39:47 +01:00
Philipinho 2b68879e72 0.90.1 2026-05-28 16:36:18 +01:00
Philipinho db32910634 fix; change inline code text color 2026-05-28 16:35:37 +01:00
Philip Okugbe 33895b0607 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
2026-05-28 16:20:37 +01:00
Philipinho 830b5b4d45 fix synced block 2026-05-25 19:17:14 +01:00
76 changed files with 1502 additions and 1514 deletions
+13 -13
View File
@@ -1,7 +1,7 @@
{
"name": "client",
"private": true,
"version": "0.90.0",
"version": "0.90.1",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
@@ -20,13 +20,13 @@
"@casl/react": "5.0.1",
"@docmost/editor-ext": "workspace:*",
"@excalidraw/excalidraw": "0.18.0-3a5ef40",
"@mantine/core": "8.3.18",
"@mantine/dates": "8.3.18",
"@mantine/form": "8.3.18",
"@mantine/hooks": "8.3.18",
"@mantine/modals": "8.3.18",
"@mantine/notifications": "8.3.18",
"@mantine/spotlight": "8.3.18",
"@mantine/core": "9.3.2",
"@mantine/dates": "9.3.2",
"@mantine/form": "9.3.2",
"@mantine/hooks": "9.3.2",
"@mantine/modals": "9.3.2",
"@mantine/notifications": "9.3.2",
"@mantine/spotlight": "9.3.2",
"@slidoapp/emoji-mart": "5.8.7",
"@slidoapp/emoji-mart-data": "1.2.4",
"@slidoapp/emoji-mart-react": "1.1.5",
@@ -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",
@@ -51,9 +51,9 @@
"mermaid": "11.15.0",
"mitt": "3.0.1",
"posthog-js": "1.372.2",
"react": "18.3.1",
"react": "19.2.7",
"react-clear-modal": "^2.0.18",
"react-dom": "^18.3.1",
"react-dom": "19.2.7",
"react-drawio": "1.0.7",
"react-error-boundary": "6.1.1",
"react-helmet-async": "3.0.0",
@@ -73,8 +73,8 @@
"@types/js-cookie": "3.0.6",
"@types/katex": "0.16.8",
"@types/node": "22.19.1",
"@types/react": "18.3.12",
"@types/react-dom": "18.3.1",
"@types/react": "19.2.17",
"@types/react-dom": "19.2.3",
"@vitejs/plugin-react": "6.0.1",
"eslint": "9.28.0",
"eslint-plugin-react": "7.37.5",
@@ -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",
@@ -977,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",
@@ -1063,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",
@@ -105,7 +105,7 @@ export default function GlobalSidebar() {
<Divider my="xs" />
<div className={classes.section}>
<Text className={classes.sectionHeader}>{t("Favorite spaces")}</Text>
<Text component="h2" className={classes.sectionHeader}>{t("Favorite spaces")}</Text>
{!isFavoritesPending && sortedFavoriteSpaces.length === 0 ? (
<Text size="xs" c="dimmed" pl="xs" py={4}>
{t("Favorite spaces appear here")}
+18 -10
View File
@@ -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 (
<Avatar
ref={ref}
@@ -67,6 +73,8 @@ export const CustomAvatar = React.forwardRef<
name={initialsSource}
alt={name}
color={resolvedColor}
variant={variant}
styles={placeholderStyles}
{...props}
/>
);
@@ -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}
/>
<Group justify="flex-end" mt="md">
<Divider my="md" />
<Group justify="flex-end">
<Button variant="default" onClick={onClose}>
{t("Close")}
</Button>
@@ -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;
@@ -221,14 +221,6 @@ export function DestinationPicker({
))
)}
</ScrollArea>
{selection && (
<div className={classes.selectedIndicator}>
{selection.type === "space"
? selection.space.name
: `${selection.space.name} / ${selection.page.title || t("Untitled")}`}
</div>
)}
</>
);
}
@@ -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) => (
<UnstyledButton ref={ref} {...props} role="menuitemradio" />
));
RadioMenuItem.displayName = "RadioMenuItem";
@@ -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}
>
<span className={classes.chatItemTitle}>
{chat.title || t("Untitled chat")}
</span>
<span className={classes.chatItemTitle}>{chatTitle}</span>
<span className={classes.chatItemDate}>{formattedDate}</span>
<div className={classes.chatItemActions}>
<Menu position="bottom-end" withinPortal>
@@ -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 })}
>
<IconDots size={14} />
</ActionIcon>
@@ -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<PendingAttachment[]>([]);
const [plusMenuOpen, setPlusMenuOpen] = useState(false);
const plusMenuId = useId();
const fileInputRef = useRef<HTMLInputElement>(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}
>
<IconPlus size={14} />
</button>
</Popover.Target>
<Popover.Dropdown p={4}>
<Popover.Dropdown id={plusMenuId} role="menu" p={4}>
<button
type="button"
role="menuitem"
className={classes.plusMenuItem}
onClick={() => {
fileInputRef.current?.click();
@@ -377,6 +383,7 @@ export default function ChatInput({
</button>
<button
type="button"
role="menuitem"
className={classes.plusMenuItem}
onClick={() => {
editor?.commands.insertContent("@");
@@ -385,7 +392,7 @@ export default function ChatInput({
}}
>
<IconAt size={16} className={classes.plusMenuIcon} />
Mention a page
{t("Mention a page")}
</button>
</Popover.Dropdown>
</Popover>
@@ -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;
@@ -1,7 +1,14 @@
import { Editor } from "@tiptap/react";
import { ActionIcon, TextInput } from "@mantine/core";
import { useDebouncedCallback, useMediaQuery } from "@mantine/hooks";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
useCallback,
useEffect,
useMemo,
useRef,
useState,
type JSX,
} from "react";
import { createPortal } from "react-dom";
import { useAtom } from "jotai";
import { IconArrowUp } from "@tabler/icons-react";
@@ -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) => {
@@ -31,7 +31,7 @@ export function CreateApiKeyModal({
onClose,
onSuccess,
}: CreateApiKeyModalProps) {
const { t } = useTranslation();
const { t, i18n } = useTranslation();
const [expirationOption, setExpirationOption] = useState<string>("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",
@@ -301,7 +301,7 @@ export default function AuditLogsTable({
{expandable && (
<Table.Tr className={classes.detailRow}>
<Table.Td colSpan={4} p={0}>
<Collapse in={isExpanded}>
<Collapse expanded={isExpanded}>
<Box
px="md"
py="sm"
@@ -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"}
</Text>
<Text fw={700} fz="lg">
{format(billing.periodEndAt, "dd MMM, yyyy")}
{formatLocalized(
billing.periodEndAt,
"dd MMM, yyyy",
"PP",
locale,
)}
</Text>
</div>
</Group>
@@ -103,7 +103,7 @@ export default function BillingPlans() {
label="Team size"
description="Select the number of users"
value={selectedTierValue}
onChange={setSelectedTierValue}
onChange={(value) => setSelectedTierValue(value)}
data={selectData}
w={250}
size="md"
@@ -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() {
<Table.Tr>
<Table.Th>Issued at</Table.Th>
<Table.Td>{format(license.issuedAt, "dd MMMM, yyyy")}</Table.Td>
<Table.Td>
{formatLocalized(license.issuedAt, "dd MMMM, yyyy", "PPP", locale)}
</Table.Td>
</Table.Tr>
<Table.Tr>
<Table.Th>Expires at</Table.Th>
<Table.Td>{format(license.expiresAt, "dd MMMM, yyyy")}</Table.Td>
<Table.Td>
{formatLocalized(license.expiresAt, "dd MMMM, yyyy", "PPP", locale)}
</Table.Td>
</Table.Tr>
<Table.Tr>
<Table.Th>License ID</Table.Th>
@@ -193,7 +193,7 @@ export function MfaSetupModal({
</Group>
</UnstyledButton>
<Collapse in={manualEntryOpen}>
<Collapse expanded={manualEntryOpen}>
<Alert
icon={<IconAlertCircle size={20} />}
color="gray"
@@ -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",
@@ -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 && (
<Text size="xs" c="dimmed">
{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",
},
),
})}
</Text>
)}
@@ -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" },
),
})
@@ -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 (
<Table.ScrollContainer minWidth={600}>
@@ -200,7 +206,7 @@ export default function VerificationListTable({
<Table.Td>
<Text fz="sm" style={{ whiteSpace: "nowrap" }}>
{verifiedUntilText(item, t)}
{verifiedUntilText(item, t, locale)}
</Text>
</Table.Td>
@@ -58,6 +58,7 @@ export default function PdfRenderPage() {
title={data.title}
content={data.content}
pageId={data.pageId}
printMode
/>
</Container>
);
@@ -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 (
@@ -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;
@@ -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() {
</title>
</Helmet>
{editorToolbarEnabled && editor && (
<FixedToolbar editor={editor} templateMode />
)}
<div className={classes.header}>
<Container size={900} h="100%" px={0}>
<Group justify="space-between" h="100%" wrap="nowrap">
@@ -379,6 +392,13 @@ export default function TemplateEditor() {
)}
</div>
<EditorContent editor={editor} />
{editor && (
<>
<EditorAiMenu editor={editor} />
<EditorBubbleMenu editor={editor} templateMode />
<EditorLinkMenu editor={editor} />
</>
)}
<div style={{ paddingBottom: "20vh" }} />
</Container>
</>
@@ -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,
});
}
@@ -38,9 +38,11 @@ export interface BubbleMenuItem {
type EditorBubbleMenuProps = Omit<BubbleMenuProps, "children" | "editor"> & {
editor: Editor | null;
templateMode?: boolean;
};
export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (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<EditorBubbleMenuProps> = (props) => {
))}
</ActionIcon.Group>
<LinkSelector />
<ColorSelector
editor={props.editor}
isOpen={isColorSelectorOpen}
@@ -246,18 +246,22 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
</>
)}
<Tooltip label={t(commentItem.name)} withArrow withinPortal={false}>
<ActionIcon
variant="default"
size="lg"
radius="6px"
aria-label={t(commentItem.name)}
style={{ border: "none" }}
onClick={commentItem.command}
>
<IconMessage size={16} stroke={2} />
</ActionIcon>
</Tooltip>
<LinkSelector />
{!templateMode && (
<Tooltip label={t(commentItem.name)} withArrow withinPortal={false}>
<ActionIcon
variant="default"
size="lg"
radius="6px"
aria-label={t(commentItem.name)}
style={{ border: "none" }}
onClick={commentItem.command}
>
<IconMessage size={16} stroke={2} />
</ActionIcon>
</Tooltip>
)}
</div>
</BubbleMenu>
);
@@ -69,7 +69,7 @@ export function ColumnsMenu({ editor }: EditorMenuProps) {
const { t } = useTranslation();
const [isCountOpen, setIsCountOpen] = useState(false);
const [copied, setCopied] = useState(false);
const copyTimerRef = useRef<ReturnType<typeof setTimeout>>();
const copyTimerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
const nodesWithMenus = [
"callout",
@@ -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<FixedToolbarProps> = ({
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 = () => {
<div className={classes.divider} />
<ListsGroup editor={editor} state={state} />
<div className={classes.divider} />
<LinkGroup />
<div className={classes.divider} />
<AlignmentGroup editor={editor} />
<div className={classes.divider} />
<MediaGroup editor={editor} />
<MediaGroup editor={editor} templateMode={templateMode} />
<div className={classes.divider} />
<QuickInsertsGroup editor={editor} />
<MoreInsertsGroup editor={editor} />
<MoreInsertsGroup editor={editor} templateMode={templateMode} />
<div className={classes.divider} />
<HistoryGroup editor={editor} state={state} />
</div>
@@ -1,6 +0,0 @@
import { FC } from "react";
import { LinkSelector } from "@/features/editor/components/bubble-menu/link-selector";
export const LinkGroup: FC = () => {
return <LinkSelector />;
};
@@ -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<Props> = ({ editor }) => {
export const MediaGroup: FC<Props> = ({ editor, templateMode }) => {
const { t } = useTranslation();
return (
@@ -78,24 +79,30 @@ export const MediaGroup: FC<Props> = ({ editor }) => {
</Tooltip>
</Menu.Target>
<Menu.Dropdown>
<Menu.Item
leftSection={<IconPhoto size={16} />}
onClick={() => pickFile(editor, "image/*", true, uploadImageAction)}
>
{t("Image")}
</Menu.Item>
<Menu.Item
leftSection={<IconMovie size={16} />}
onClick={() => pickFile(editor, "video/*", true, uploadVideoAction)}
>
{t("Video")}
</Menu.Item>
<Menu.Item
leftSection={<IconMusic size={16} />}
onClick={() => pickFile(editor, "audio/*", true, uploadAudioAction)}
>
{t("Audio")}
</Menu.Item>
{!templateMode && (
<Menu.Item
leftSection={<IconPhoto size={16} />}
onClick={() => pickFile(editor, "image/*", true, uploadImageAction)}
>
{t("Image")}
</Menu.Item>
)}
{!templateMode && (
<Menu.Item
leftSection={<IconMovie size={16} />}
onClick={() => pickFile(editor, "video/*", true, uploadVideoAction)}
>
{t("Video")}
</Menu.Item>
)}
{!templateMode && (
<Menu.Item
leftSection={<IconMusic size={16} />}
onClick={() => pickFile(editor, "audio/*", true, uploadAudioAction)}
>
{t("Audio")}
</Menu.Item>
)}
<Menu.Item
leftSection={<IconFileTypePdf size={16} />}
onClick={() =>
@@ -104,14 +111,16 @@ export const MediaGroup: FC<Props> = ({ editor }) => {
>
PDF
</Menu.Item>
<Menu.Item
leftSection={<IconPaperclip size={16} />}
onClick={() =>
pickFile(editor, "", true, uploadAttachmentAction, true)
}
>
{t("File attachment")}
</Menu.Item>
{!templateMode && (
<Menu.Item
leftSection={<IconPaperclip size={16} />}
onClick={() =>
pickFile(editor, "", true, uploadAttachmentAction, true)
}
>
{t("File attachment")}
</Menu.Item>
)}
</Menu.Dropdown>
</Menu>
);
@@ -32,16 +32,17 @@ import { useTranslation } from "react-i18next";
interface Props {
editor: Editor;
templateMode?: boolean;
}
export const MoreInsertsGroup: FC<Props> = ({ editor }) => {
const { t } = useTranslation();
export const MoreInsertsGroup: FC<Props> = ({ 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<Props> = ({ editor }) => {
>
{t("Subpages")}
</Menu.Item>
<Menu.Item
leftSection={<IconRotate2 size={16} />}
onClick={() =>
editor.chain().focus().insertTransclusionSource().run()
}
>
{t("Synced block")}
</Menu.Item>
{!templateMode && (
<Menu.Item
leftSection={<IconRotate2 size={16} />}
onClick={() =>
editor.chain().focus().insertTransclusionSource().run()
}
>
{t("Synced block")}
</Menu.Item>
)}
<Menu.Divider />
<Menu.Label>{t("Diagrams")}</Menu.Label>
@@ -115,18 +118,22 @@ export const MoreInsertsGroup: FC<Props> = ({ editor }) => {
>
{t("Mermaid diagram")}
</Menu.Item>
<Menu.Item
leftSection={<IconDrawio size={16} />}
onClick={() => editor.chain().focus().setDrawio().run()}
>
Draw.io
</Menu.Item>
<Menu.Item
leftSection={<IconExcalidraw size={16} />}
onClick={() => editor.chain().focus().setExcalidraw().run()}
>
Excalidraw
</Menu.Item>
{!templateMode && (
<Menu.Item
leftSection={<IconDrawio size={16} />}
onClick={() => editor.chain().focus().setDrawio().run()}
>
Draw.io
</Menu.Item>
)}
{!templateMode && (
<Menu.Item
leftSection={<IconExcalidraw size={16} />}
onClick={() => editor.chain().focus().setExcalidraw().run()}
>
Excalidraw
</Menu.Item>
)}
<Menu.Divider />
<Menu.Label>{t("Embeds")}</Menu.Label>
@@ -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",
@@ -766,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;
});
}
@@ -1,7 +1,7 @@
import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus";
import { posToDOMRect, findParentNode } from "@tiptap/react";
import { Node as PMNode } from "@tiptap/pm/model";
import React, { useCallback } from "react";
import React, { useCallback, type JSX } from "react";
import { ActionIcon, Tooltip } from "@mantine/core";
import { IconTrash } from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
@@ -1,4 +1,4 @@
import React, { useCallback } from "react";
import React, { useCallback, type JSX } from "react";
import {
EditorMenuProps,
ShouldShowProps,
@@ -1,6 +1,6 @@
import { posToDOMRect, findParentNode } from "@tiptap/react";
import { Node as PMNode } from "@tiptap/pm/model";
import React, { useCallback } from "react";
import React, { useCallback, type JSX } from "react";
import {
EditorMenuProps,
ShouldShowProps,
@@ -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) => [
@@ -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<IPage>(["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);
@@ -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({
@@ -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);
}
}
}
@@ -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;
}
}
@@ -204,10 +209,6 @@
opacity: 1;
}
.ProseMirror table th:has(.tableReadonlySortChevron) {
padding-right: 30px;
}
.tableReadonlySortChevron:hover {
background: light-dark(
rgba(55, 53, 47, 0.16),
@@ -272,4 +273,4 @@
.prosemirror-dropcursor-inline {
display: none;
}
}
}
@@ -91,7 +91,9 @@ export default function GroupMembersList() {
<ActionIcon
variant="subtle"
c="gray"
aria-label={t("Member actions")}
aria-label={t("Member actions for {{name}}", {
name: user.name,
})}
>
<IconDots size={20} stroke={2} />
</ActionIcon>
@@ -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);
}
@@ -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<NotificationTab>("direct");
const [filter, setFilter] = useState<NotificationFilter>("all");
@@ -83,10 +84,11 @@ export function NotificationPopover() {
<Popover.Dropdown
p={0}
aria-labelledby={titleId}
style={{ width: "min(420px, calc(100vw - 24px))" }}
>
<Group justify="space-between" px="md" py="sm">
<Title order={2} fz="sm" fw={600}>
<Title id={titleId} order={2} fz="sm" fw={600}>
{t("Notifications")}
</Title>
<Group gap={4}>
@@ -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";
@@ -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 (
<Stack gap="xs">
<Text size="xs" fw={500} c="dimmed">
@@ -150,10 +152,7 @@ function StatsSection({
label={t("Created")}
value={formattedDate(new Date(createdAt))}
/>
<StatRow
label={t("Last updated")}
value={timeAgo(new Date(updatedAt))}
/>
<StatRow label={t("Last updated")} value={lastUpdated} />
</Stack>
);
}
@@ -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) {
<Menu shadow="md" width={200}>
<Menu.Target>
<ActionIcon
variant="transparent"
c="gray"
variant="subtle"
color="gray"
className={classes.actionIcon}
aria-label={t("Page menu for {{name}}", { name: node.name || t("untitled") })}
tabIndex={-1}
onClick={(e) => {
@@ -201,13 +201,13 @@ function PageArrow({ isOpen, hasChildren, onToggle }: PageArrowProps) {
return (
<span
aria-hidden
className={classes.actionIcon}
style={{
width: 20,
height: 20,
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
color: "var(--mantine-color-gray-6)",
flexShrink: 0,
}}
>
@@ -220,7 +220,8 @@ function PageArrow({ isOpen, hasChildren, onToggle }: PageArrowProps) {
<ActionIcon
size={20}
variant="subtle"
c="gray"
color="gray"
className={classes.actionIcon}
aria-label={isOpen ? t("Collapse") : t("Expand")}
aria-expanded={isOpen}
tabIndex={-1}
@@ -272,8 +273,9 @@ function CreateNode({
return (
<ActionIcon
variant="transparent"
c="gray"
variant="subtle"
color="gray"
className={classes.actionIcon}
aria-label={t("Create subpage of {{name}}", { name: node.name || t("untitled") })}
tabIndex={-1}
onClick={(e) => {
@@ -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
@@ -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) => (
<Menu.Item
key={option.value}
role="menuitemradio"
component={RadioMenuItem}
aria-checked={contentType === option.value}
onClick={() =>
!option.disabled &&
@@ -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 <EmptyState icon={IconWorld} title={t("No shared pages")} />;
@@ -81,7 +82,12 @@ export default function ShareList() {
</Table.Td>
<Table.Td>
<Text fz="sm" style={{ whiteSpace: "nowrap" }}>
{format(new Date(share.createdAt), "MMM dd, yyyy")}
{formatLocalized(
share.createdAt,
"MMM dd, yyyy",
"PP",
locale,
)}
</Text>
</Table.Td>
<Table.Td>
@@ -64,7 +64,7 @@ export default function ShareShell({
const [fullWidth, setFullWidth] = useAtom(sharedPageFullWidthAtom);
const [sidebarWidth, setSidebarWidth] = useAtom(sidebarWidthAtom);
const [isResizing, setIsResizing] = useState(false);
const sidebarRef = useRef<HTMLElement | null>(null);
const sidebarRef = useRef<HTMLDivElement>(null);
const startResizing = useCallback((e: React.MouseEvent) => {
e.preventDefault();
@@ -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({
<ScrollArea.Autosize mah={280}>
<Menu.Item
role="menuitemradio"
component={RadioMenuItem}
aria-checked={!value}
onClick={() => onChange(null)}
>
@@ -103,7 +104,7 @@ export function SpaceFilterMenu({
{orderedSpaces.map((space) => (
<Menu.Item
key={space.id}
role="menuitemradio"
component={RadioMenuItem}
aria-checked={value === space.id}
onClick={() => onChange(space.id)}
>
@@ -210,7 +210,9 @@ export default function SpaceMembersList({
<ActionIcon
variant="subtle"
c="gray"
aria-label={t("Member actions")}
aria-label={t("Member actions for {{name}}", {
name: member.name,
})}
>
<IconDots size={20} stroke={2} />
</ActionIcon>
@@ -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) {
<ActionIcon
variant="subtle"
c="gray"
aria-label={t("Member actions")}
aria-label={t("Member actions for {{name}}", { name })}
>
<IconDots size={20} stroke={2} />
</ActionIcon>
@@ -80,7 +80,7 @@ export function WorkspaceInviteForm({ onClose }: Props) {
defaultValue={UserRole.MEMBER}
allowDeselect={false}
checkIconPosition="right"
onChange={setRole}
onChange={(value) => setRole(value)}
/>
<MultiGroupSelect
@@ -111,6 +111,7 @@ export default function WorkspaceMembersTable() {
{isAdmin && (
<MemberActionMenu
userId={user.id}
name={user.name}
deactivatedAt={user.deactivatedAt}
/>
)}
+62
View File
@@ -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<string, Locale> = {
"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 });
}
+14 -6
View File
@@ -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);
}
}
+4
View File
@@ -3,6 +3,7 @@ import {
CSSVariablesResolver,
MantineColorsTuple,
Tabs,
v8CssVariablesResolver,
} from "@mantine/core";
const blue: MantineColorsTuple = [
@@ -68,9 +69,11 @@ export const theme = createTheme({
export const mantineCssResolver: CSSVariablesResolver = (theme) => ({
variables: {
...v8CssVariablesResolver(theme).variables,
"--input-error-size": theme.fontSizes.sm,
},
light: {
...v8CssVariablesResolver(theme).light,
"--mantine-color-dimmed": "#4b5563",
"--mantine-color-dark-light-color": "#4e5359",
"--mantine-color-dark-light-hover": "var(--mantine-color-gray-light-hover)",
@@ -105,6 +108,7 @@ export const mantineCssResolver: CSSVariablesResolver = (theme) => ({
"--mantine-color-green-light-color": "#1B5E20",
},
dark: {
...v8CssVariablesResolver(theme).dark,
"--mantine-color-dark-light-color": "var(--mantine-color-gray-4)",
"--mantine-color-dark-light-hover": "var(--mantine-color-default-hover)",
},
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "server",
"version": "0.90.0",
"version": "0.90.1",
"description": "",
"author": "",
"private": true,
@@ -106,7 +106,7 @@
"pino-pretty": "^13.1.3",
"postgres": "^3.4.8",
"postmark": "^4.0.7",
"react": "^18.3.1",
"react": "19.2.7",
"react-email": "6.0.8",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.2",
@@ -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);
}
@@ -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
@@ -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' },
],
@@ -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<PageTransclusionsRepo>;
beforeEach(async () => {
const mockRepo: jest.Mocked<Partial<PageTransclusionsRepo>> = {
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<PageTransclusionReferencesRepo>;
beforeEach(async () => {
const mockTransclusionsRepo: Partial<PageTransclusionsRepo> = {};
const mockRefRepo: jest.Mocked<Partial<PageTransclusionReferencesRepo>> = {
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();
});
});
@@ -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<string[]> {
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) =>
@@ -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;
}
@@ -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<void> {
const { emails, role, groupIds } = inviteUserDto;
if (isAdminActingOnOwner(authUser.role, role)) {
throw new ForbiddenException();
}
let invites: WorkspaceInvitation[] = [];
try {
@@ -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');
}
@@ -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;
}
@@ -14,12 +14,14 @@ type SortField<DB, TB extends keyof DB, O> =
| (StringReference<DB, TB> & `${string}.${keyof O & string}`);
direction: OrderByDirection;
orderModifier?: OrderByModifiers;
cursorExpression?: ReferenceExpression<DB, TB>;
key?: keyof O & string;
}
| {
expression: ReferenceExpression<DB, TB>;
direction: OrderByDirection;
orderModifier?: OrderByModifiers;
cursorExpression?: ReferenceExpression<DB, TB>;
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);
+2 -2
View File
@@ -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",
@@ -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",
+930 -954
View File
File diff suppressed because it is too large Load Diff