mirror of
https://github.com/docmost/docmost.git
synced 2026-06-10 01:52:43 +08:00
Compare commits
21 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 743eaddc21 | |||
| 042c3e2bac | |||
| 7ca9156481 | |||
| 75ae997b3d | |||
| df960f659e | |||
| d41e454e10 | |||
| a31009ae91 | |||
| 12c18be6d0 | |||
| bb15e9c5eb | |||
| 2f9cb9aced | |||
| b489ce41ea | |||
| c924fe0a2d | |||
| ef04c22aea | |||
| b6760c63c4 | |||
| 2b68879e72 | |||
| db32910634 | |||
| 33895b0607 | |||
| 830b5b4d45 | |||
| d7c4f0551e | |||
| 61a91cd086 | |||
| f010f6a83a |
@@ -22,7 +22,6 @@ AWS_S3_ENDPOINT=
|
||||
AWS_S3_FORCE_PATH_STYLE=
|
||||
|
||||
# Azure Blob Storage driver config
|
||||
STORAGE_DRIVER=azure
|
||||
AZURE_STORAGE_ACCOUNT_NAME=
|
||||
AZURE_STORAGE_ACCOUNT_KEY=
|
||||
AZURE_STORAGE_CONTAINER=
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "client",
|
||||
"private": true,
|
||||
"version": "0.90.0",
|
||||
"version": "0.90.1",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
@@ -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",
|
||||
|
||||
@@ -424,6 +424,7 @@
|
||||
"Names do not match": "Namen stimmen nicht überein",
|
||||
"Today, {{time}}": "Heute, {{time}}",
|
||||
"Yesterday, {{time}}": "Gestern, {{time}}",
|
||||
"now": "now",
|
||||
"Space created successfully": "Bereich erfolgreich erstellt",
|
||||
"Space updated successfully": "Bereich erfolgreich aktualisiert",
|
||||
"Space deleted successfully": "Bereich erfolgreich gelöscht",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -424,6 +424,7 @@
|
||||
"Names do not match": "Los nombres no coinciden",
|
||||
"Today, {{time}}": "Hoy, {{time}}",
|
||||
"Yesterday, {{time}}": "Ayer, {{time}}",
|
||||
"now": "now",
|
||||
"Space created successfully": "Espacio creado correctamente",
|
||||
"Space updated successfully": "Espacio actualizado correctamente",
|
||||
"Space deleted successfully": "Espacio eliminado correctamente",
|
||||
|
||||
@@ -424,6 +424,7 @@
|
||||
"Names do not match": "Les noms ne correspondent pas",
|
||||
"Today, {{time}}": "Aujourd’hui, {{time}}",
|
||||
"Yesterday, {{time}}": "Hier, {{time}}",
|
||||
"now": "now",
|
||||
"Space created successfully": "Espace créé avec succès",
|
||||
"Space updated successfully": "Espace mis à jour avec succès",
|
||||
"Space deleted successfully": "Espace supprimé avec succès",
|
||||
|
||||
@@ -424,6 +424,7 @@
|
||||
"Names do not match": "I nomi non corrispondono",
|
||||
"Today, {{time}}": "Oggi, {{time}}",
|
||||
"Yesterday, {{time}}": "Ieri, {{time}}",
|
||||
"now": "now",
|
||||
"Space created successfully": "Spazio creato con successo",
|
||||
"Space updated successfully": "Spazio aggiornato con successo",
|
||||
"Space deleted successfully": "Spazio eliminato con successo",
|
||||
|
||||
@@ -424,6 +424,7 @@
|
||||
"Names do not match": "名前が一致しません",
|
||||
"Today, {{time}}": "今日 {{time}}",
|
||||
"Yesterday, {{time}}": "昨日 {{time}}",
|
||||
"now": "now",
|
||||
"Space created successfully": "スペースが正常に作成されました",
|
||||
"Space updated successfully": "スペースが正常に更新されました",
|
||||
"Space deleted successfully": "スペースが正常に削除されました",
|
||||
|
||||
@@ -424,6 +424,7 @@
|
||||
"Names do not match": "이름이 일치하지 않습니다",
|
||||
"Today, {{time}}": "오늘, {{time}}",
|
||||
"Yesterday, {{time}}": "어제, {{time}}",
|
||||
"now": "now",
|
||||
"Space created successfully": "스페이스가 성공적으로 생성되었습니다",
|
||||
"Space updated successfully": "스페이스가 성공적으로 업데이트되었습니다",
|
||||
"Space deleted successfully": "스페이스가 성공적으로 삭제되었습니다",
|
||||
|
||||
@@ -424,6 +424,7 @@
|
||||
"Names do not match": "Namen komen niet overeen",
|
||||
"Today, {{time}}": "Vandaag, {{time}}",
|
||||
"Yesterday, {{time}}": "Gisteren, {{time}}",
|
||||
"now": "now",
|
||||
"Space created successfully": "Ruimte succesvol aangemaakt",
|
||||
"Space updated successfully": "Ruimte succesvol bijgewerkt",
|
||||
"Space deleted successfully": "Ruimte succesvol verwijderd",
|
||||
|
||||
@@ -424,6 +424,7 @@
|
||||
"Names do not match": "Os nomes não correspondem",
|
||||
"Today, {{time}}": "Hoje, {{time}}",
|
||||
"Yesterday, {{time}}": "Ontem, {{time}}",
|
||||
"now": "now",
|
||||
"Space created successfully": "Espaço criado com sucesso",
|
||||
"Space updated successfully": "Espaço atualizado com sucesso",
|
||||
"Space deleted successfully": "Espaço excluído com sucesso",
|
||||
|
||||
@@ -424,6 +424,7 @@
|
||||
"Names do not match": "Названия не совпадают",
|
||||
"Today, {{time}}": "Сегодня, {{time}}",
|
||||
"Yesterday, {{time}}": "Вчера, {{time}}",
|
||||
"now": "сейчас",
|
||||
"Space created successfully": "Пространство успешно создано",
|
||||
"Space updated successfully": "Пространство успешно обновлено",
|
||||
"Space deleted successfully": "Пространство успешно удалено",
|
||||
|
||||
@@ -424,6 +424,7 @@
|
||||
"Names do not match": "Назви не збігаються",
|
||||
"Today, {{time}}": "Сьогодні, {{time}}",
|
||||
"Yesterday, {{time}}": "Вчора, {{time}}",
|
||||
"now": "now",
|
||||
"Space created successfully": "Простір успішно створено",
|
||||
"Space updated successfully": "Простір успішно оновлено",
|
||||
"Space deleted successfully": "Простір успішно видалено",
|
||||
|
||||
@@ -424,6 +424,7 @@
|
||||
"Names do not match": "名称不匹配",
|
||||
"Today, {{time}}": "今天,{{time}}",
|
||||
"Yesterday, {{time}}": "昨天,{{time}}",
|
||||
"now": "now",
|
||||
"Space created successfully": "空间创建成功",
|
||||
"Space updated successfully": "空间更新成功",
|
||||
"Space deleted successfully": "空间删除成功",
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -17,14 +17,26 @@ import ChatToolGroup from "./chat-tool-group";
|
||||
import classes from "../styles/chat-message.module.css";
|
||||
import CopyTextButton from "@/components/common/copy.tsx";
|
||||
|
||||
const PAGE_PATH_RE = /\/s\/[^/?#]+\/p\/[^/?#]+/;
|
||||
|
||||
const chatSanitizer = DOMPurify();
|
||||
chatSanitizer.addHook("afterSanitizeAttributes", (node) => {
|
||||
if (node.tagName === "A") {
|
||||
const href = node.getAttribute("href") || "";
|
||||
if (href.startsWith("http://") || href.startsWith("https://")) {
|
||||
node.setAttribute("target", "_blank");
|
||||
node.setAttribute("rel", "noopener noreferrer");
|
||||
}
|
||||
if (node.tagName !== "A") return;
|
||||
const href = node.getAttribute("href") || "";
|
||||
|
||||
// Recover the canonical /s/{slug}/p/{slugId} path if the model wrapped it
|
||||
// in a fabricated host (https://s/..., https://yoursite.com/s/..., //s/...).
|
||||
const m = href.match(PAGE_PATH_RE);
|
||||
if (m) {
|
||||
node.setAttribute("href", m[0]);
|
||||
node.removeAttribute("target");
|
||||
node.removeAttribute("rel");
|
||||
return;
|
||||
}
|
||||
|
||||
if (href.startsWith("http://") || href.startsWith("https://")) {
|
||||
node.setAttribute("target", "_blank");
|
||||
node.setAttribute("rel", "noopener noreferrer");
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
+30
-23
@@ -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",
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import { Extension } from "@tiptap/core";
|
||||
import { Plugin, PluginKey } from "@tiptap/pm/state";
|
||||
|
||||
export const CleanStyles = Extension.create({
|
||||
name: "cleanStyles",
|
||||
priority: 80,
|
||||
|
||||
addProseMirrorPlugins() {
|
||||
return [
|
||||
new Plugin({
|
||||
key: new PluginKey("cleanStyles"),
|
||||
props: {
|
||||
transformPastedHTML(html) {
|
||||
return html.replace(/\s+style="[^"]*"/gi, "");
|
||||
},
|
||||
},
|
||||
}),
|
||||
];
|
||||
},
|
||||
});
|
||||
@@ -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";
|
||||
@@ -112,6 +112,7 @@ import EmojiCommand from "./emoji-command";
|
||||
import { countWords } from "alfaaz";
|
||||
import AutoJoiner from "@/features/editor/extensions/autojoiner.ts";
|
||||
import GlobalDragHandle from "@/features/editor/extensions/drag-handle.ts";
|
||||
import { CleanStyles } from "@/features/editor/extensions/clean-styles.ts";
|
||||
|
||||
const lowlight = createLowlight(common);
|
||||
lowlight.register("mermaid", plaintext);
|
||||
@@ -383,6 +384,7 @@ export const mainExtensions = [
|
||||
MarkdownClipboard.configure({
|
||||
transformPastedText: true,
|
||||
}),
|
||||
CleanStyles,
|
||||
CharacterCount.configure({
|
||||
wordCounter: (text) => countWords(text),
|
||||
}),
|
||||
@@ -435,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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "server",
|
||||
"version": "0.90.0",
|
||||
"version": "0.90.1",
|
||||
"description": "",
|
||||
"author": "",
|
||||
"private": true,
|
||||
|
||||
@@ -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);
|
||||
|
||||
+1
-1
Submodule apps/server/src/ee updated: 9e5f64d95d...e7320a5a0f
+2
-2
@@ -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",
|
||||
|
||||
Generated
+12
-12
@@ -10,7 +10,7 @@ overrides:
|
||||
glob: 13.0.6
|
||||
ws: 8.20.1
|
||||
dompurify: 3.4.1
|
||||
tmp: 0.2.5
|
||||
tmp: 0.2.6
|
||||
hono: 4.12.18
|
||||
mermaid: 11.15.0
|
||||
nanoid@^3: 3.3.8
|
||||
@@ -342,8 +342,8 @@ importers:
|
||||
specifier: 0.4.0
|
||||
version: 0.4.0(jotai@2.18.1(@babel/core@7.28.5)(@babel/template@7.27.2)(@types/react@18.3.12)(react@18.3.1))(optics-ts@2.4.1)
|
||||
js-cookie:
|
||||
specifier: 3.0.5
|
||||
version: 3.0.5
|
||||
specifier: 3.0.7
|
||||
version: 3.0.7
|
||||
jwt-decode:
|
||||
specifier: 4.0.0
|
||||
version: 4.0.0
|
||||
@@ -7544,9 +7544,9 @@ packages:
|
||||
resolution: {integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
js-cookie@3.0.5:
|
||||
resolution: {integrity: sha512-cEiJEAEoIbWfCZYKWhVwFuvPX1gETRYPw6LlaTKoxD3s2AkXzkCjnp6h0V77ozyqj0jakteJ4YqDJT830+lVGw==}
|
||||
engines: {node: '>=14'}
|
||||
js-cookie@3.0.7:
|
||||
resolution: {integrity: sha512-z/wZZgDrkNV1eA0ULjM/F9/50Ya8fbzgKneSpoPsXSGd0KnpdtHfOZWK+GcwLk+EZbS4F9RBhU+K2RgzuDaItw==}
|
||||
engines: {node: '>=20'}
|
||||
|
||||
js-tiktoken@1.0.21:
|
||||
resolution: {integrity: sha512-biOj/6M5qdgx5TKjDnFT1ymSpM5tbd3ylwDtrQvFQSu0Z7bBYko2dF+W/aUkXUPuk6IVpRxk/3Q2sHOzGlS36g==}
|
||||
@@ -9657,8 +9657,8 @@ packages:
|
||||
tmp-promise@3.0.3:
|
||||
resolution: {integrity: sha512-RwM7MoPojPxsOBYnyd2hy0bxtIlVrihNs9pj5SUvY8Zz1sQcQG2tG1hSr8PDxfgEB8RNKDhqbIlroIarSNDNsQ==}
|
||||
|
||||
tmp@0.2.5:
|
||||
resolution: {integrity: sha512-voyz6MApa1rQGUxT3E+BK7/ROe8itEx7vD8/HEvt4xwXucvQ5G5oeEiHkmHZJuBO21RpOf+YYm9MOivj709jow==}
|
||||
tmp@0.2.6:
|
||||
resolution: {integrity: sha512-5sJPdPjfI5Kx+qbrDesxkglRBxW//g7hCsqspEjwkewGvBMGIKMOTKzLt1hFVJzyadba3lDUN20O9qhvbQUSTA==}
|
||||
engines: {node: '>=14.14'}
|
||||
|
||||
tmpl@1.0.5:
|
||||
@@ -18286,7 +18286,7 @@ snapshots:
|
||||
|
||||
joycon@3.1.1: {}
|
||||
|
||||
js-cookie@3.0.5: {}
|
||||
js-cookie@3.0.7: {}
|
||||
|
||||
js-tiktoken@1.0.21:
|
||||
dependencies:
|
||||
@@ -18992,7 +18992,7 @@ snapshots:
|
||||
semver: 7.7.4
|
||||
string-width: 4.2.3
|
||||
tar-stream: 2.2.0
|
||||
tmp: 0.2.5
|
||||
tmp: 0.2.6
|
||||
tree-kill: 1.2.2
|
||||
tsconfig-paths: 4.2.0
|
||||
tslib: 2.8.1
|
||||
@@ -20615,9 +20615,9 @@ snapshots:
|
||||
|
||||
tmp-promise@3.0.3:
|
||||
dependencies:
|
||||
tmp: 0.2.5
|
||||
tmp: 0.2.6
|
||||
|
||||
tmp@0.2.5: {}
|
||||
tmp@0.2.6: {}
|
||||
|
||||
tmpl@1.0.5: {}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user