mirror of
https://github.com/docmost/docmost.git
synced 2026-05-08 15:23:07 +08:00
Compare commits
40 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| fe3732add9 | |||
| 9db9eb852c | |||
| 3007060ac4 | |||
| ba9e58ede9 | |||
| f2bc0b5049 | |||
| eba5ae2eb3 | |||
| b6344f2e08 | |||
| db52b6036e | |||
| fe5b236d41 | |||
| b3c5ca6d5f | |||
| 483e39db1c | |||
| 56f476649c | |||
| 1b82959859 | |||
| 4d322e9157 | |||
| 20a7acfccc | |||
| 040ad04a27 | |||
| d3ca1ed72c | |||
| 207b1b593a | |||
| 6c664a366f | |||
| 4873f7b9ff | |||
| 718ca2b674 | |||
| e189d01ce0 | |||
| edd3754e46 | |||
| b2b147f1bd | |||
| 129e21f728 | |||
| 70124475ab | |||
| 7d7decb459 | |||
| 1eba6e93cc | |||
| cd52acc415 | |||
| a09e35ba8f | |||
| 8e2cf9bb02 | |||
| cff4ba8e50 | |||
| f32bb298e0 | |||
| 3178cad796 | |||
| 9d7f8c62c5 | |||
| 78b1c1a453 | |||
| 96ed98619f | |||
| 60501de992 | |||
| f44bb3f121 | |||
| 669ff0435f |
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "client",
|
"name": "client",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.24.1",
|
"version": "0.25.0-beta.1",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "tsc && vite build",
|
"build": "tsc && vite build",
|
||||||
|
|||||||
@@ -123,6 +123,11 @@
|
|||||||
"page": "page",
|
"page": "page",
|
||||||
"Page deleted successfully": "Page deleted successfully",
|
"Page deleted successfully": "Page deleted successfully",
|
||||||
"Page history": "Page history",
|
"Page history": "Page history",
|
||||||
|
"Version history for": "Version history for",
|
||||||
|
"document": "document",
|
||||||
|
"Select version": "Select version",
|
||||||
|
"Close": "Close",
|
||||||
|
"Highlight changes": "Highlight changes",
|
||||||
"Page import is in progress. Please do not close this tab.": "Page import is in progress. Please do not close this tab.",
|
"Page import is in progress. Please do not close this tab.": "Page import is in progress. Please do not close this tab.",
|
||||||
"Pages": "Pages",
|
"Pages": "Pages",
|
||||||
"pages": "pages",
|
"pages": "pages",
|
||||||
|
|||||||
@@ -2,17 +2,17 @@ import { Button, Group } from "@mantine/core";
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
export interface PagePaginationProps {
|
export interface PagePaginationProps {
|
||||||
currentPage: number;
|
|
||||||
hasPrevPage: boolean;
|
hasPrevPage: boolean;
|
||||||
hasNextPage: boolean;
|
hasNextPage: boolean;
|
||||||
onPageChange: (newPage: number) => void;
|
onPrev: () => void;
|
||||||
|
onNext: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Paginate({
|
export default function Paginate({
|
||||||
currentPage,
|
|
||||||
hasPrevPage,
|
hasPrevPage,
|
||||||
hasNextPage,
|
hasNextPage,
|
||||||
onPageChange,
|
onPrev,
|
||||||
|
onNext,
|
||||||
}: PagePaginationProps) {
|
}: PagePaginationProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
@@ -25,7 +25,7 @@ export default function Paginate({
|
|||||||
<Button
|
<Button
|
||||||
variant="default"
|
variant="default"
|
||||||
size="compact-sm"
|
size="compact-sm"
|
||||||
onClick={() => onPageChange(currentPage - 1)}
|
onClick={onPrev}
|
||||||
disabled={!hasPrevPage}
|
disabled={!hasPrevPage}
|
||||||
>
|
>
|
||||||
{t("Prev")}
|
{t("Prev")}
|
||||||
@@ -34,7 +34,7 @@ export default function Paginate({
|
|||||||
<Button
|
<Button
|
||||||
variant="default"
|
variant="default"
|
||||||
size="compact-sm"
|
size="compact-sm"
|
||||||
onClick={() => onPageChange(currentPage + 1)}
|
onClick={onNext}
|
||||||
disabled={!hasNextPage}
|
disabled={!hasNextPage}
|
||||||
>
|
>
|
||||||
{t("Next")}
|
{t("Next")}
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import { getShares } from "@/features/share/services/share-service.ts";
|
|||||||
import { getApiKeys } from "@/ee/api-key";
|
import { getApiKeys } from "@/ee/api-key";
|
||||||
|
|
||||||
export const prefetchWorkspaceMembers = () => {
|
export const prefetchWorkspaceMembers = () => {
|
||||||
const params = { limit: 100, page: 1, query: "" } as QueryParams;
|
const params: QueryParams = { limit: 100, query: "" };
|
||||||
queryClient.prefetchQuery({
|
queryClient.prefetchQuery({
|
||||||
queryKey: ["workspaceMembers", params],
|
queryKey: ["workspaceMembers", params],
|
||||||
queryFn: () => getWorkspaceMembers(params),
|
queryFn: () => getWorkspaceMembers(params),
|
||||||
@@ -22,15 +22,15 @@ export const prefetchWorkspaceMembers = () => {
|
|||||||
|
|
||||||
export const prefetchSpaces = () => {
|
export const prefetchSpaces = () => {
|
||||||
queryClient.prefetchQuery({
|
queryClient.prefetchQuery({
|
||||||
queryKey: ["spaces", { page: 1 }],
|
queryKey: ["spaces", {}],
|
||||||
queryFn: () => getSpaces({ page: 1 }),
|
queryFn: () => getSpaces({}),
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
export const prefetchGroups = () => {
|
export const prefetchGroups = () => {
|
||||||
queryClient.prefetchQuery({
|
queryClient.prefetchQuery({
|
||||||
queryKey: ["groups", { page: 1 }],
|
queryKey: ["groups", {}],
|
||||||
queryFn: () => getGroups({ page: 1 }),
|
queryFn: () => getGroups({}),
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -62,21 +62,21 @@ export const prefetchSsoProviders = () => {
|
|||||||
|
|
||||||
export const prefetchShares = () => {
|
export const prefetchShares = () => {
|
||||||
queryClient.prefetchQuery({
|
queryClient.prefetchQuery({
|
||||||
queryKey: ["share-list", { page: 1 }],
|
queryKey: ["share-list", {}],
|
||||||
queryFn: () => getShares({ page: 1, limit: 100 }),
|
queryFn: () => getShares({}),
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
export const prefetchApiKeys = () => {
|
export const prefetchApiKeys = () => {
|
||||||
queryClient.prefetchQuery({
|
queryClient.prefetchQuery({
|
||||||
queryKey: ["api-key-list", { page: 1 }],
|
queryKey: ["api-key-list", {}],
|
||||||
queryFn: () => getApiKeys({ page: 1 }),
|
queryFn: () => getApiKeys({}),
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
export const prefetchApiKeyManagement = () => {
|
export const prefetchApiKeyManagement = () => {
|
||||||
queryClient.prefetchQuery({
|
queryClient.prefetchQuery({
|
||||||
queryKey: ["api-key-list", { page: 1 }],
|
queryKey: ["api-key-list", { adminView: true }],
|
||||||
queryFn: () => getApiKeys({ page: 1, adminView: true }),
|
queryFn: () => getApiKeys({ adminView: true }),
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -10,19 +10,19 @@ import { ApiKeyCreatedModal } from "@/ee/api-key/components/api-key-created-moda
|
|||||||
import { UpdateApiKeyModal } from "@/ee/api-key/components/update-api-key-modal";
|
import { UpdateApiKeyModal } from "@/ee/api-key/components/update-api-key-modal";
|
||||||
import { RevokeApiKeyModal } from "@/ee/api-key/components/revoke-api-key-modal";
|
import { RevokeApiKeyModal } from "@/ee/api-key/components/revoke-api-key-modal";
|
||||||
import Paginate from "@/components/common/paginate";
|
import Paginate from "@/components/common/paginate";
|
||||||
import { usePaginateAndSearch } from "@/hooks/use-paginate-and-search";
|
import { useCursorPaginate } from "@/hooks/use-cursor-paginate";
|
||||||
import { useGetApiKeysQuery } from "@/ee/api-key/queries/api-key-query.ts";
|
import { useGetApiKeysQuery } from "@/ee/api-key/queries/api-key-query.ts";
|
||||||
import { IApiKey } from "@/ee/api-key";
|
import { IApiKey } from "@/ee/api-key";
|
||||||
|
|
||||||
export default function UserApiKeys() {
|
export default function UserApiKeys() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { page, setPage } = usePaginateAndSearch();
|
const { cursor, goNext, goPrev } = useCursorPaginate();
|
||||||
const [createModalOpened, setCreateModalOpened] = useState(false);
|
const [createModalOpened, setCreateModalOpened] = useState(false);
|
||||||
const [createdApiKey, setCreatedApiKey] = useState<IApiKey | null>(null);
|
const [createdApiKey, setCreatedApiKey] = useState<IApiKey | null>(null);
|
||||||
const [updateModalOpened, setUpdateModalOpened] = useState(false);
|
const [updateModalOpened, setUpdateModalOpened] = useState(false);
|
||||||
const [revokeModalOpened, setRevokeModalOpened] = useState(false);
|
const [revokeModalOpened, setRevokeModalOpened] = useState(false);
|
||||||
const [selectedApiKey, setSelectedApiKey] = useState<IApiKey | null>(null);
|
const [selectedApiKey, setSelectedApiKey] = useState<IApiKey | null>(null);
|
||||||
const { data, isLoading } = useGetApiKeysQuery({ page });
|
const { data, isLoading } = useGetApiKeysQuery({ cursor });
|
||||||
|
|
||||||
const handleCreateSuccess = (response: IApiKey) => {
|
const handleCreateSuccess = (response: IApiKey) => {
|
||||||
setCreatedApiKey(response);
|
setCreatedApiKey(response);
|
||||||
@@ -65,10 +65,10 @@ export default function UserApiKeys() {
|
|||||||
|
|
||||||
{data?.items.length > 0 && (
|
{data?.items.length > 0 && (
|
||||||
<Paginate
|
<Paginate
|
||||||
currentPage={page}
|
hasPrevPage={data?.meta?.hasPrevPage}
|
||||||
hasPrevPage={data?.meta.hasPrevPage}
|
hasNextPage={data?.meta?.hasNextPage}
|
||||||
hasNextPage={data?.meta.hasNextPage}
|
onNext={() => goNext(data?.meta?.nextCursor)}
|
||||||
onPageChange={setPage}
|
onPrev={goPrev}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -10,20 +10,20 @@ import { ApiKeyCreatedModal } from "@/ee/api-key/components/api-key-created-moda
|
|||||||
import { UpdateApiKeyModal } from "@/ee/api-key/components/update-api-key-modal";
|
import { UpdateApiKeyModal } from "@/ee/api-key/components/update-api-key-modal";
|
||||||
import { RevokeApiKeyModal } from "@/ee/api-key/components/revoke-api-key-modal";
|
import { RevokeApiKeyModal } from "@/ee/api-key/components/revoke-api-key-modal";
|
||||||
import Paginate from "@/components/common/paginate";
|
import Paginate from "@/components/common/paginate";
|
||||||
import { usePaginateAndSearch } from "@/hooks/use-paginate-and-search";
|
import { useCursorPaginate } from "@/hooks/use-cursor-paginate";
|
||||||
import { useGetApiKeysQuery } from "@/ee/api-key/queries/api-key-query.ts";
|
import { useGetApiKeysQuery } from "@/ee/api-key/queries/api-key-query.ts";
|
||||||
import { IApiKey } from "@/ee/api-key";
|
import { IApiKey } from "@/ee/api-key";
|
||||||
import useUserRole from '@/hooks/use-user-role.tsx';
|
import useUserRole from '@/hooks/use-user-role.tsx';
|
||||||
|
|
||||||
export default function WorkspaceApiKeys() {
|
export default function WorkspaceApiKeys() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { page, setPage } = usePaginateAndSearch();
|
const { cursor, goNext, goPrev } = useCursorPaginate();
|
||||||
const [createModalOpened, setCreateModalOpened] = useState(false);
|
const [createModalOpened, setCreateModalOpened] = useState(false);
|
||||||
const [createdApiKey, setCreatedApiKey] = useState<IApiKey | null>(null);
|
const [createdApiKey, setCreatedApiKey] = useState<IApiKey | null>(null);
|
||||||
const [updateModalOpened, setUpdateModalOpened] = useState(false);
|
const [updateModalOpened, setUpdateModalOpened] = useState(false);
|
||||||
const [revokeModalOpened, setRevokeModalOpened] = useState(false);
|
const [revokeModalOpened, setRevokeModalOpened] = useState(false);
|
||||||
const [selectedApiKey, setSelectedApiKey] = useState<IApiKey | null>(null);
|
const [selectedApiKey, setSelectedApiKey] = useState<IApiKey | null>(null);
|
||||||
const { data, isLoading } = useGetApiKeysQuery({ page, adminView: true });
|
const { data, isLoading } = useGetApiKeysQuery({ cursor, adminView: true });
|
||||||
const { isAdmin } = useUserRole();
|
const { isAdmin } = useUserRole();
|
||||||
|
|
||||||
if (!isAdmin) {
|
if (!isAdmin) {
|
||||||
@@ -76,10 +76,10 @@ export default function WorkspaceApiKeys() {
|
|||||||
|
|
||||||
{data?.items.length > 0 && (
|
{data?.items.length > 0 && (
|
||||||
<Paginate
|
<Paginate
|
||||||
currentPage={page}
|
hasPrevPage={data?.meta?.hasPrevPage}
|
||||||
hasPrevPage={data?.meta.hasPrevPage}
|
hasNextPage={data?.meta?.hasNextPage}
|
||||||
hasNextPage={data?.meta.hasNextPage}
|
onNext={() => goNext(data?.meta?.nextCursor)}
|
||||||
onPageChange={setPage}
|
onPrev={goPrev}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ export default function SsoProviderList() {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (data?.length === 0) {
|
if (data?.items.length === 0) {
|
||||||
return <Text c="dimmed">{t("No SSO providers found.")}</Text>;
|
return <Text c="dimmed">{t("No SSO providers found.")}</Text>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -81,7 +81,7 @@ export default function SsoProviderList() {
|
|||||||
</Table.Tr>
|
</Table.Tr>
|
||||||
</Table.Thead>
|
</Table.Thead>
|
||||||
<Table.Tbody>
|
<Table.Tbody>
|
||||||
{data
|
{data?.items
|
||||||
.sort((a, b) => {
|
.sort((a, b) => {
|
||||||
const enabledDiff = Number(b.isEnabled) - Number(a.isEnabled);
|
const enabledDiff = Number(b.isEnabled) - Number(a.isEnabled);
|
||||||
if (enabledDiff !== 0) return enabledDiff;
|
if (enabledDiff !== 0) return enabledDiff;
|
||||||
@@ -104,7 +104,11 @@ export default function SsoProviderList() {
|
|||||||
</Group>
|
</Group>
|
||||||
</Table.Td>
|
</Table.Td>
|
||||||
<Table.Td>
|
<Table.Td>
|
||||||
<Badge color={"gray"} variant="light" style={{ whiteSpace: "nowrap" }}>
|
<Badge
|
||||||
|
color={"gray"}
|
||||||
|
variant="light"
|
||||||
|
style={{ whiteSpace: "nowrap" }}
|
||||||
|
>
|
||||||
{provider.type.toUpperCase()}
|
{provider.type.toUpperCase()}
|
||||||
</Badge>
|
</Badge>
|
||||||
</Table.Td>
|
</Table.Td>
|
||||||
@@ -134,41 +138,41 @@ export default function SsoProviderList() {
|
|||||||
</Table.Td>
|
</Table.Td>
|
||||||
<Table.Td>
|
<Table.Td>
|
||||||
<Group gap="xs" wrap="nowrap">
|
<Group gap="xs" wrap="nowrap">
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
variant="subtle"
|
variant="subtle"
|
||||||
color="gray"
|
color="gray"
|
||||||
onClick={() => handleEdit(provider)}
|
onClick={() => handleEdit(provider)}
|
||||||
>
|
>
|
||||||
<IconPencil size={16} />
|
<IconPencil size={16} />
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
<Menu
|
<Menu
|
||||||
transitionProps={{ transition: "pop" }}
|
transitionProps={{ transition: "pop" }}
|
||||||
withArrow
|
withArrow
|
||||||
position="bottom-end"
|
position="bottom-end"
|
||||||
withinPortal
|
withinPortal
|
||||||
>
|
>
|
||||||
<Menu.Target>
|
<Menu.Target>
|
||||||
<ActionIcon variant="subtle" color="gray">
|
<ActionIcon variant="subtle" color="gray">
|
||||||
<IconDots size={16} />
|
<IconDots size={16} />
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
</Menu.Target>
|
</Menu.Target>
|
||||||
<Menu.Dropdown>
|
<Menu.Dropdown>
|
||||||
<Menu.Item
|
<Menu.Item
|
||||||
onClick={() => handleEdit(provider)}
|
onClick={() => handleEdit(provider)}
|
||||||
leftSection={<IconPencil size={16} />}
|
leftSection={<IconPencil size={16} />}
|
||||||
>
|
>
|
||||||
{t("Edit")}
|
{t("Edit")}
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
<Menu.Item
|
<Menu.Item
|
||||||
onClick={() => openDeleteModal(provider.id)}
|
onClick={() => openDeleteModal(provider.id)}
|
||||||
leftSection={<IconTrash size={16} />}
|
leftSection={<IconTrash size={16} />}
|
||||||
color="red"
|
color="red"
|
||||||
disabled={provider.type === SSO_PROVIDER.GOOGLE}
|
disabled={provider.type === SSO_PROVIDER.GOOGLE}
|
||||||
>
|
>
|
||||||
{t("Delete")}
|
{t("Delete")}
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
</Menu.Dropdown>
|
</Menu.Dropdown>
|
||||||
</Menu>
|
</Menu>
|
||||||
</Group>
|
</Group>
|
||||||
</Table.Td>
|
</Table.Td>
|
||||||
</Table.Tr>
|
</Table.Tr>
|
||||||
|
|||||||
@@ -13,8 +13,9 @@ import {
|
|||||||
} from "@/ee/security/services/security-service.ts";
|
} from "@/ee/security/services/security-service.ts";
|
||||||
import { notifications } from "@mantine/notifications";
|
import { notifications } from "@mantine/notifications";
|
||||||
import { IAuthProvider } from "@/ee/security/types/security.types.ts";
|
import { IAuthProvider } from "@/ee/security/types/security.types.ts";
|
||||||
|
import { IPagination } from "@/lib/types.ts";
|
||||||
|
|
||||||
export function useGetSsoProviders(): UseQueryResult<IAuthProvider[], Error> {
|
export function useGetSsoProviders(): UseQueryResult<IPagination<IAuthProvider>, Error> {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ["sso-providers"],
|
queryKey: ["sso-providers"],
|
||||||
queryFn: () => getSsoProviders(),
|
queryFn: () => getSsoProviders(),
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import api from "@/lib/api-client.ts";
|
import api from "@/lib/api-client.ts";
|
||||||
import { IAuthProvider } from "@/ee/security/types/security.types.ts";
|
import { IAuthProvider } from "@/ee/security/types/security.types.ts";
|
||||||
|
import { IPagination } from "@/lib/types.ts";
|
||||||
|
|
||||||
export async function getSsoProviderById(data: {
|
export async function getSsoProviderById(data: {
|
||||||
providerId: string;
|
providerId: string;
|
||||||
@@ -8,8 +9,8 @@ export async function getSsoProviderById(data: {
|
|||||||
return req.data;
|
return req.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getSsoProviders(): Promise<IAuthProvider[]> {
|
export async function getSsoProviders(): Promise<IPagination<IAuthProvider>> {
|
||||||
const req = await api.post<IAuthProvider[]>("/sso/providers");
|
const req = await api.post<IPagination<IAuthProvider>>("/sso/providers");
|
||||||
return req.data;
|
return req.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,26 +1,25 @@
|
|||||||
import { Table, Group, Text, Anchor } from "@mantine/core";
|
import { Table, Group, Text, Anchor } from "@mantine/core";
|
||||||
import { useGetGroupsQuery } from "@/features/group/queries/group-query";
|
import { useGetGroupsQuery } from "@/features/group/queries/group-query";
|
||||||
import { useState } from "react";
|
|
||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
|
import { useCursorPaginate } from "@/hooks/use-cursor-paginate";
|
||||||
import { IconGroupCircle } from "@/components/icons/icon-people-circle.tsx";
|
import { IconGroupCircle } from "@/components/icons/icon-people-circle.tsx";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { formatMemberCount } from "@/lib";
|
import { formatMemberCount } from "@/lib";
|
||||||
import { IGroup } from "@/features/group/types/group.types.ts";
|
import { IGroup } from "@/features/group/types/group.types.ts";
|
||||||
import Paginate from "@/components/common/paginate.tsx";
|
import Paginate from "@/components/common/paginate.tsx";
|
||||||
import { queryClient } from "@/main.tsx";
|
import { queryClient } from "@/main.tsx";
|
||||||
import { getSpaces } from "@/features/space/services/space-service.ts";
|
|
||||||
import { getGroupMembers } from "@/features/group/services/group-service.ts";
|
import { getGroupMembers } from "@/features/group/services/group-service.ts";
|
||||||
import { AutoTooltipText } from "@/components/ui/auto-tooltip-text.tsx";
|
import { AutoTooltipText } from "@/components/ui/auto-tooltip-text.tsx";
|
||||||
|
|
||||||
export default function GroupList() {
|
export default function GroupList() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [page, setPage] = useState(1);
|
const { cursor, goNext, goPrev } = useCursorPaginate();
|
||||||
const { data, isLoading } = useGetGroupsQuery({ page });
|
const { data, isLoading } = useGetGroupsQuery({ cursor });
|
||||||
|
|
||||||
const prefetchGroupMembers = (groupId: string) => {
|
const prefetchGroupMembers = (groupId: string) => {
|
||||||
queryClient.prefetchQuery({
|
queryClient.prefetchQuery({
|
||||||
queryKey: ["groupMembers", groupId, { page: 1 }],
|
queryKey: ["groupMembers", groupId, {}],
|
||||||
queryFn: () => getGroupMembers(groupId, { page: 1 }),
|
queryFn: () => getGroupMembers(groupId, {}),
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -85,10 +84,10 @@ export default function GroupList() {
|
|||||||
|
|
||||||
{data?.items.length > 0 && (
|
{data?.items.length > 0 && (
|
||||||
<Paginate
|
<Paginate
|
||||||
currentPage={page}
|
hasPrevPage={data?.meta?.hasPrevPage}
|
||||||
hasPrevPage={data?.meta.hasPrevPage}
|
hasNextPage={data?.meta?.hasNextPage}
|
||||||
hasNextPage={data?.meta.hasNextPage}
|
onNext={() => goNext(data?.meta?.nextCursor)}
|
||||||
onPageChange={setPage}
|
onPrev={goPrev}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import {
|
|||||||
useRemoveGroupMemberMutation,
|
useRemoveGroupMemberMutation,
|
||||||
} from "@/features/group/queries/group-query";
|
} from "@/features/group/queries/group-query";
|
||||||
import { useParams } from "react-router-dom";
|
import { useParams } from "react-router-dom";
|
||||||
import React, { useState } from "react";
|
import React from "react";
|
||||||
import { IconDots } from "@tabler/icons-react";
|
import { IconDots } from "@tabler/icons-react";
|
||||||
import { modals } from "@mantine/modals";
|
import { modals } from "@mantine/modals";
|
||||||
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
|
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
|
||||||
@@ -12,12 +12,13 @@ import useUserRole from "@/hooks/use-user-role.tsx";
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { IUser } from "@/features/user/types/user.types.ts";
|
import { IUser } from "@/features/user/types/user.types.ts";
|
||||||
import Paginate from "@/components/common/paginate.tsx";
|
import Paginate from "@/components/common/paginate.tsx";
|
||||||
|
import { useCursorPaginate } from "@/hooks/use-cursor-paginate";
|
||||||
|
|
||||||
export default function GroupMembersList() {
|
export default function GroupMembersList() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { groupId } = useParams();
|
const { groupId } = useParams();
|
||||||
const [page, setPage] = useState(1);
|
const { cursor, goNext, goPrev } = useCursorPaginate();
|
||||||
const { data, isLoading } = useGroupMembersQuery(groupId, { page });
|
const { data, isLoading } = useGroupMembersQuery(groupId, { cursor });
|
||||||
const removeGroupMember = useRemoveGroupMemberMutation();
|
const removeGroupMember = useRemoveGroupMemberMutation();
|
||||||
const { isAdmin } = useUserRole();
|
const { isAdmin } = useUserRole();
|
||||||
|
|
||||||
@@ -107,10 +108,10 @@ export default function GroupMembersList() {
|
|||||||
|
|
||||||
{data?.items.length > 0 && (
|
{data?.items.length > 0 && (
|
||||||
<Paginate
|
<Paginate
|
||||||
currentPage={page}
|
hasPrevPage={data?.meta?.hasPrevPage}
|
||||||
hasPrevPage={data?.meta.hasPrevPage}
|
hasNextPage={data?.meta?.hasNextPage}
|
||||||
hasNextPage={data?.meta.hasNextPage}
|
onNext={() => goNext(data?.meta?.nextCursor)}
|
||||||
onPageChange={setPage}
|
onPrev={goPrev}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -1,4 +1,9 @@
|
|||||||
import { atom } from "jotai";
|
import { atom } from "jotai";
|
||||||
|
|
||||||
export const historyAtoms = atom<boolean>(false);
|
export const historyAtoms = atom<boolean>(false);
|
||||||
export const activeHistoryIdAtom = atom<string>('');
|
export const activeHistoryIdAtom = atom<string>("");
|
||||||
|
export const activeHistoryPrevIdAtom = atom<string>("");
|
||||||
|
export const highlightChangesAtom = atom<boolean>(true);
|
||||||
|
|
||||||
|
export type DiffCounts = { added: number; deleted: number; total: number };
|
||||||
|
export const diffCountsAtom = atom<DiffCounts | null>(null);
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
.diffSummary {
|
||||||
|
border: rem(1px) solid
|
||||||
|
light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-4));
|
||||||
|
border-radius: rem(10px);
|
||||||
|
padding: rem(12px);
|
||||||
|
background: light-dark(
|
||||||
|
var(--mantine-color-gray-0),
|
||||||
|
var(--mantine-color-dark-7)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.history-diff-added) {
|
||||||
|
background: light-dark(#e1f3f2, #01654a) !important;
|
||||||
|
color: light-dark(#007b69, #cafff7) !important;
|
||||||
|
-webkit-box-decoration-break: clone;
|
||||||
|
box-decoration-break: clone;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.history-diff-deleted) {
|
||||||
|
text-decoration: line-through;
|
||||||
|
color: light-dark(var(--mantine-color-red-7), var(--mantine-color-red-4));
|
||||||
|
background: light-dark(var(--mantine-color-red-0), rgba(255, 0, 0, 0.1));
|
||||||
|
border-radius: rem(2px);
|
||||||
|
padding: 0 rem(2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.history-diff-node-added) {
|
||||||
|
outline: rem(2px) solid light-dark(var(--mantine-color-teal-5), var(--mantine-color-teal-7));
|
||||||
|
outline-offset: rem(2px);
|
||||||
|
border-radius: rem(4px);
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.history-diff-node-deleted) {
|
||||||
|
opacity: 0.5;
|
||||||
|
outline: rem(2px) dashed light-dark(var(--mantine-color-red-4), var(--mantine-color-red-6));
|
||||||
|
outline-offset: rem(4px);
|
||||||
|
border-radius: rem(4px);
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
.container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: calc(100vh - 60px);
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.selectorWrapper {
|
||||||
|
padding: var(--mantine-spacing-sm);
|
||||||
|
border-bottom: rem(1px) solid light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-4));
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.selector {
|
||||||
|
width: 100%;
|
||||||
|
text-align: left;
|
||||||
|
background-color: light-dark(var(--mantine-color-white), var(--mantine-color-dark-6));
|
||||||
|
padding: var(--mantine-spacing-xs) var(--mantine-spacing-sm);
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-5));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown {
|
||||||
|
max-height: rem(300px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.option {
|
||||||
|
padding: var(--mantine-spacing-xs) var(--mantine-spacing-sm);
|
||||||
|
|
||||||
|
&[data-combobox-selected] {
|
||||||
|
background-color: light-dark(var(--mantine-color-gray-1), var(--mantine-color-dark-5));
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: light-dark(var(--mantine-color-gray-1), var(--mantine-color-dark-5));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.editorArea {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editorContent {
|
||||||
|
padding: var(--mantine-spacing-md);
|
||||||
|
padding-bottom: rem(60px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.actionButtons {
|
||||||
|
padding: var(--mantine-spacing-sm) var(--mantine-spacing-md);
|
||||||
|
padding-bottom: rem(70px);
|
||||||
|
border-top: rem(1px) solid light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-4));
|
||||||
|
background-color: light-dark(var(--mantine-color-white), var(--mantine-color-dark-7));
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.floatingBar {
|
||||||
|
position: fixed;
|
||||||
|
bottom: var(--mantine-spacing-md);
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
z-index: 100;
|
||||||
|
background-color: light-dark(var(--mantine-color-white), var(--mantine-color-dark-6));
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
@@ -1,36 +1,204 @@
|
|||||||
import "@/features/editor/styles/index.css";
|
import "@/features/editor/styles/index.css";
|
||||||
import React, { useEffect } from "react";
|
import "./css/history-diff.module.css";
|
||||||
|
import { useEffect } from "react";
|
||||||
import { EditorContent, useEditor } from "@tiptap/react";
|
import { EditorContent, useEditor } from "@tiptap/react";
|
||||||
import { mainExtensions } from "@/features/editor/extensions/extensions";
|
import { mainExtensions } from "@/features/editor/extensions/extensions";
|
||||||
import { Title } from "@mantine/core";
|
import { Title } from "@mantine/core";
|
||||||
import classes from "./history.module.css";
|
import { Decoration, DecorationSet } from "@tiptap/pm/view";
|
||||||
|
import historyClasses from "./css/history.module.css";
|
||||||
|
import { recreateTransform } from "@docmost/editor-ext";
|
||||||
|
import { DOMSerializer, Node } from "@tiptap/pm/model";
|
||||||
|
import { ChangeSet, simplifyChanges } from "@tiptap/pm/changeset";
|
||||||
|
import { useAtom } from "jotai";
|
||||||
|
import {
|
||||||
|
diffCountsAtom,
|
||||||
|
highlightChangesAtom,
|
||||||
|
} from "@/features/page-history/atoms/history-atoms";
|
||||||
|
|
||||||
export interface HistoryEditorProps {
|
export interface HistoryEditorProps {
|
||||||
title: string;
|
title: string;
|
||||||
content: any;
|
content: any;
|
||||||
|
previousContent?: any;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function HistoryEditor({ title, content }: HistoryEditorProps) {
|
export function HistoryEditor({
|
||||||
|
title,
|
||||||
|
content,
|
||||||
|
previousContent,
|
||||||
|
}: HistoryEditorProps) {
|
||||||
|
const [highlightChanges] = useAtom(highlightChangesAtom);
|
||||||
|
const [, setDiffCounts] = useAtom(diffCountsAtom);
|
||||||
|
|
||||||
const editor = useEditor({
|
const editor = useEditor({
|
||||||
extensions: mainExtensions,
|
extensions: mainExtensions,
|
||||||
editable: false,
|
editable: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (editor && content) {
|
if (!editor || !content) return;
|
||||||
|
|
||||||
|
let decorationSet = DecorationSet.empty;
|
||||||
|
let addedCount = 0;
|
||||||
|
let deletedCount = 0;
|
||||||
|
|
||||||
|
if (previousContent) {
|
||||||
|
try {
|
||||||
|
const schema = editor.schema;
|
||||||
|
const docOld = Node.fromJSON(schema, previousContent);
|
||||||
|
const docNew = Node.fromJSON(schema, content);
|
||||||
|
|
||||||
|
const tr = recreateTransform(docOld, docNew, {
|
||||||
|
complexSteps: true,
|
||||||
|
wordDiffs: true,
|
||||||
|
simplifyDiff: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const changeSet = ChangeSet.create(docOld).addSteps(
|
||||||
|
tr.doc,
|
||||||
|
tr.mapping.maps,
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
const changes = simplifyChanges(changeSet.changes, docNew);
|
||||||
|
|
||||||
|
editor.commands.setContent(content);
|
||||||
|
|
||||||
|
const specialNodeTypes = new Set([
|
||||||
|
"image",
|
||||||
|
"attachment",
|
||||||
|
"video",
|
||||||
|
"excalidraw",
|
||||||
|
"drawio",
|
||||||
|
"mermaid",
|
||||||
|
"mathBlock",
|
||||||
|
"mathInline",
|
||||||
|
"table",
|
||||||
|
"details",
|
||||||
|
"callout",
|
||||||
|
]);
|
||||||
|
|
||||||
|
const decorations: Decoration[] = [];
|
||||||
|
let changeIndex = 0;
|
||||||
|
|
||||||
|
for (const change of changes) {
|
||||||
|
if (change.toB > change.fromB) {
|
||||||
|
changeIndex++;
|
||||||
|
const currentIndex = changeIndex;
|
||||||
|
let foundSpecialNode: { node: Node; pos: number } | null = null;
|
||||||
|
docNew.nodesBetween(change.fromB, change.toB, (node, pos) => {
|
||||||
|
if (specialNodeTypes.has(node.type.name)) {
|
||||||
|
const nodeEnd = pos + node.nodeSize;
|
||||||
|
if (change.fromB <= pos && change.toB >= nodeEnd) {
|
||||||
|
foundSpecialNode = { node, pos };
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (foundSpecialNode) {
|
||||||
|
const nodeEnd =
|
||||||
|
foundSpecialNode.pos + foundSpecialNode.node.nodeSize;
|
||||||
|
decorations.push(
|
||||||
|
Decoration.node(foundSpecialNode.pos, nodeEnd, {
|
||||||
|
class: "history-diff-node-added",
|
||||||
|
"data-diff-index": String(currentIndex),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
decorations.push(
|
||||||
|
Decoration.inline(change.fromB, change.toB, {
|
||||||
|
class: "history-diff-added",
|
||||||
|
"data-diff-index": String(currentIndex),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
addedCount += 1;
|
||||||
|
}
|
||||||
|
if (change.toA > change.fromA) {
|
||||||
|
changeIndex++;
|
||||||
|
const currentIndex = changeIndex;
|
||||||
|
let foundDeletedNode: { node: Node; pos: number } | null = null;
|
||||||
|
docOld.nodesBetween(change.fromA, change.toA, (node, pos) => {
|
||||||
|
if (specialNodeTypes.has(node.type.name)) {
|
||||||
|
const nodeEnd = pos + node.nodeSize;
|
||||||
|
if (change.fromA <= pos && change.toA >= nodeEnd) {
|
||||||
|
foundDeletedNode = { node, pos };
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (foundDeletedNode) {
|
||||||
|
decorations.push(
|
||||||
|
Decoration.widget(change.fromB, () => {
|
||||||
|
const wrapper = document.createElement("div");
|
||||||
|
wrapper.className = "history-diff-node-deleted";
|
||||||
|
wrapper.setAttribute("data-diff-index", String(currentIndex));
|
||||||
|
const serializer = DOMSerializer.fromSchema(schema);
|
||||||
|
const dom = serializer.serializeNode(foundDeletedNode!.node);
|
||||||
|
wrapper.appendChild(dom);
|
||||||
|
return wrapper;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
const deletedText = docOld.textBetween(
|
||||||
|
change.fromA,
|
||||||
|
change.toA,
|
||||||
|
"",
|
||||||
|
);
|
||||||
|
if (deletedText) {
|
||||||
|
decorations.push(
|
||||||
|
Decoration.widget(change.fromB, () => {
|
||||||
|
const span = document.createElement("span");
|
||||||
|
span.className = "history-diff-deleted";
|
||||||
|
span.setAttribute("data-diff-index", String(currentIndex));
|
||||||
|
span.textContent = deletedText;
|
||||||
|
return span;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
deletedCount += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
decorationSet = DecorationSet.create(docNew, decorations);
|
||||||
|
} catch (e) {
|
||||||
|
console.error("History diff failed:", e);
|
||||||
|
editor.commands.setContent(content);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
editor.commands.setContent(content);
|
editor.commands.setContent(content);
|
||||||
}
|
}
|
||||||
}, [title, content, editor]);
|
|
||||||
|
const total = addedCount + deletedCount;
|
||||||
|
// @ts-ignore
|
||||||
|
setDiffCounts({ added: addedCount, deleted: deletedCount, total });
|
||||||
|
|
||||||
|
editor.setOptions({
|
||||||
|
editorProps: {
|
||||||
|
...editor.options.editorProps,
|
||||||
|
decorations: () =>
|
||||||
|
highlightChanges ? decorationSet : DecorationSet.empty,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}, [
|
||||||
|
title,
|
||||||
|
content,
|
||||||
|
editor,
|
||||||
|
previousContent,
|
||||||
|
highlightChanges,
|
||||||
|
setDiffCounts,
|
||||||
|
]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<div>
|
||||||
<div>
|
<Title order={1}>{title}</Title>
|
||||||
<Title order={1}>{title}</Title>
|
{editor && (
|
||||||
|
<EditorContent
|
||||||
{editor && (
|
editor={editor}
|
||||||
<EditorContent editor={editor} className={classes.historyEditor} />
|
className={historyClasses.historyEditor}
|
||||||
)}
|
/>
|
||||||
</div>
|
)}
|
||||||
</>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,20 +1,42 @@
|
|||||||
import { Text, Group, UnstyledButton } from "@mantine/core";
|
import { Text, Group, UnstyledButton } from "@mantine/core";
|
||||||
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
|
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
|
||||||
import { formattedDate } from "@/lib/time";
|
import { formattedDate } from "@/lib/time";
|
||||||
import classes from "./history.module.css";
|
import classes from "./css/history.module.css";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
|
import { IPageHistory } from "@/features/page-history/types/page.types";
|
||||||
|
import { memo, useCallback } from "react";
|
||||||
|
|
||||||
interface HistoryItemProps {
|
interface HistoryItemProps {
|
||||||
historyItem: any;
|
historyItem: IPageHistory;
|
||||||
onSelect: (id: string) => void;
|
index: number;
|
||||||
|
onSelect: (id: string, index: number) => void;
|
||||||
|
onHover?: (id: string, index: number) => void;
|
||||||
|
onHoverEnd?: () => void;
|
||||||
isActive: boolean;
|
isActive: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
function HistoryItem({ historyItem, onSelect, isActive }: HistoryItemProps) {
|
const HistoryItem = memo(function HistoryItem({
|
||||||
|
historyItem,
|
||||||
|
index,
|
||||||
|
onSelect,
|
||||||
|
onHover,
|
||||||
|
onHoverEnd,
|
||||||
|
isActive,
|
||||||
|
}: HistoryItemProps) {
|
||||||
|
const handleClick = useCallback(() => {
|
||||||
|
onSelect(historyItem.id, index);
|
||||||
|
}, [onSelect, historyItem.id, index]);
|
||||||
|
|
||||||
|
const handleMouseEnter = useCallback(() => {
|
||||||
|
onHover?.(historyItem.id, index);
|
||||||
|
}, [onHover, historyItem.id, index]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<UnstyledButton
|
<UnstyledButton
|
||||||
p="xs"
|
p="xs"
|
||||||
onClick={() => onSelect(historyItem.id)}
|
onClick={handleClick}
|
||||||
|
onMouseEnter={handleMouseEnter}
|
||||||
|
onMouseLeave={onHoverEnd}
|
||||||
className={clsx(classes.history, { [classes.active]: isActive })}
|
className={clsx(classes.history, { [classes.active]: isActive })}
|
||||||
>
|
>
|
||||||
<Group wrap="nowrap">
|
<Group wrap="nowrap">
|
||||||
@@ -27,11 +49,11 @@ function HistoryItem({ historyItem, onSelect, isActive }: HistoryItemProps) {
|
|||||||
<Group gap={4} wrap="nowrap">
|
<Group gap={4} wrap="nowrap">
|
||||||
<CustomAvatar
|
<CustomAvatar
|
||||||
size="sm"
|
size="sm"
|
||||||
avatarUrl={historyItem.lastUpdatedBy.avatarUrl}
|
avatarUrl={historyItem.lastUpdatedBy?.avatarUrl}
|
||||||
name={historyItem.lastUpdatedBy.name}
|
name={historyItem.lastUpdatedBy?.name}
|
||||||
/>
|
/>
|
||||||
<Text size="sm" c="dimmed" lineClamp={1}>
|
<Text size="sm" c="dimmed" lineClamp={1}>
|
||||||
{historyItem.lastUpdatedBy.name}
|
{historyItem.lastUpdatedBy?.name}
|
||||||
</Text>
|
</Text>
|
||||||
</Group>
|
</Group>
|
||||||
</div>
|
</div>
|
||||||
@@ -39,6 +61,6 @@ function HistoryItem({ historyItem, onSelect, isActive }: HistoryItemProps) {
|
|||||||
</Group>
|
</Group>
|
||||||
</UnstyledButton>
|
</UnstyledButton>
|
||||||
);
|
);
|
||||||
}
|
});
|
||||||
|
|
||||||
export default HistoryItem;
|
export default HistoryItem;
|
||||||
|
|||||||
@@ -1,29 +1,27 @@
|
|||||||
import {
|
import {
|
||||||
usePageHistoryListQuery,
|
usePageHistoryListQuery,
|
||||||
usePageHistoryQuery,
|
prefetchPageHistory,
|
||||||
} from "@/features/page-history/queries/page-history-query";
|
} from "@/features/page-history/queries/page-history-query";
|
||||||
import HistoryItem from "@/features/page-history/components/history-item";
|
import HistoryItem from "@/features/page-history/components/history-item";
|
||||||
import {
|
import {
|
||||||
activeHistoryIdAtom,
|
activeHistoryIdAtom,
|
||||||
|
activeHistoryPrevIdAtom,
|
||||||
historyAtoms,
|
historyAtoms,
|
||||||
} from "@/features/page-history/atoms/history-atoms";
|
} from "@/features/page-history/atoms/history-atoms";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom, useSetAtom } from "jotai";
|
||||||
import { useCallback, useEffect } from "react";
|
import { useCallback, useEffect, useMemo, useRef } from "react";
|
||||||
import { Button, ScrollArea, Group, Divider, Text } from "@mantine/core";
|
|
||||||
import {
|
import {
|
||||||
pageEditorAtom,
|
Button,
|
||||||
titleEditorAtom,
|
ScrollArea,
|
||||||
} from "@/features/editor/atoms/editor-atoms";
|
Group,
|
||||||
import { modals } from "@mantine/modals";
|
Divider,
|
||||||
import { notifications } from "@mantine/notifications";
|
Loader,
|
||||||
|
Center,
|
||||||
|
} from "@mantine/core";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useSpaceAbility } from "@/features/space/permissions/use-space-ability.ts";
|
import { useHistoryRestore } from "@/features/page-history/hooks";
|
||||||
import { useSpaceQuery } from "@/features/space/queries/space-query.ts";
|
|
||||||
import { useParams } from "react-router-dom";
|
const PREFETCH_DELAY_MS = 150;
|
||||||
import {
|
|
||||||
SpaceCaslAction,
|
|
||||||
SpaceCaslSubject,
|
|
||||||
} from "@/features/space/permissions/permissions.type.ts";
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
pageId: string;
|
pageId: string;
|
||||||
@@ -32,62 +30,89 @@ interface Props {
|
|||||||
function HistoryList({ pageId }: Props) {
|
function HistoryList({ pageId }: Props) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [activeHistoryId, setActiveHistoryId] = useAtom(activeHistoryIdAtom);
|
const [activeHistoryId, setActiveHistoryId] = useAtom(activeHistoryIdAtom);
|
||||||
|
const setActiveHistoryPrevId = useSetAtom(activeHistoryPrevIdAtom);
|
||||||
|
const setHistoryModalOpen = useSetAtom(historyAtoms);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
data: pageHistoryList,
|
data: pageHistoryData,
|
||||||
isLoading,
|
isLoading,
|
||||||
isError,
|
isError,
|
||||||
|
fetchNextPage,
|
||||||
|
hasNextPage,
|
||||||
|
isFetchingNextPage,
|
||||||
} = usePageHistoryListQuery(pageId);
|
} = usePageHistoryListQuery(pageId);
|
||||||
const { data: activeHistoryData } = usePageHistoryQuery(activeHistoryId);
|
|
||||||
|
|
||||||
const [mainEditor] = useAtom(pageEditorAtom);
|
const historyItems = useMemo(
|
||||||
const [mainEditorTitle] = useAtom(titleEditorAtom);
|
() => pageHistoryData?.pages.flatMap((page) => page.items) ?? [],
|
||||||
const [, setHistoryModalOpen] = useAtom(historyAtoms);
|
[pageHistoryData],
|
||||||
|
);
|
||||||
|
|
||||||
const { spaceSlug } = useParams();
|
const loadMoreRef = useRef<HTMLDivElement>(null);
|
||||||
const { data: space } = useSpaceQuery(spaceSlug);
|
const prefetchTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
const spaceRules = space?.membership?.permissions;
|
|
||||||
const spaceAbility = useSpaceAbility(spaceRules);
|
|
||||||
|
|
||||||
const confirmModal = () =>
|
const { canRestore, confirmRestore } = useHistoryRestore();
|
||||||
modals.openConfirmModal({
|
|
||||||
title: t("Please confirm your action"),
|
|
||||||
children: (
|
|
||||||
<Text size="sm">
|
|
||||||
{t(
|
|
||||||
"Are you sure you want to restore this version? Any changes not versioned will be lost.",
|
|
||||||
)}
|
|
||||||
</Text>
|
|
||||||
),
|
|
||||||
labels: { confirm: t("Confirm"), cancel: t("Cancel") },
|
|
||||||
onConfirm: handleRestore,
|
|
||||||
});
|
|
||||||
|
|
||||||
const handleRestore = useCallback(() => {
|
const clearPrefetchTimeout = useCallback(() => {
|
||||||
if (activeHistoryData) {
|
if (prefetchTimeoutRef.current) {
|
||||||
mainEditorTitle
|
clearTimeout(prefetchTimeoutRef.current);
|
||||||
.chain()
|
prefetchTimeoutRef.current = null;
|
||||||
.clearContent()
|
|
||||||
.setContent(activeHistoryData.title, { emitUpdate: true })
|
|
||||||
.run();
|
|
||||||
mainEditor
|
|
||||||
.chain()
|
|
||||||
.clearContent()
|
|
||||||
.setContent(activeHistoryData.content)
|
|
||||||
.run();
|
|
||||||
setHistoryModalOpen(false);
|
|
||||||
notifications.show({ message: t("Successfully restored") });
|
|
||||||
}
|
}
|
||||||
}, [activeHistoryData]);
|
}, []);
|
||||||
|
|
||||||
|
const handleHover = useCallback(
|
||||||
|
(historyId: string, index: number) => {
|
||||||
|
clearPrefetchTimeout();
|
||||||
|
prefetchTimeoutRef.current = setTimeout(() => {
|
||||||
|
prefetchPageHistory(historyId);
|
||||||
|
const prevId = historyItems[index + 1]?.id;
|
||||||
|
if (prevId) {
|
||||||
|
prefetchPageHistory(prevId);
|
||||||
|
}
|
||||||
|
}, PREFETCH_DELAY_MS);
|
||||||
|
},
|
||||||
|
[clearPrefetchTimeout, historyItems],
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (
|
return clearPrefetchTimeout;
|
||||||
pageHistoryList &&
|
}, [clearPrefetchTimeout]);
|
||||||
pageHistoryList.items.length > 0 &&
|
|
||||||
!activeHistoryId
|
const handleSelect = useCallback(
|
||||||
) {
|
(id: string, index: number) => {
|
||||||
setActiveHistoryId(pageHistoryList.items[0].id);
|
setActiveHistoryId(id);
|
||||||
|
setActiveHistoryPrevId(historyItems[index + 1]?.id ?? "");
|
||||||
|
},
|
||||||
|
[historyItems, setActiveHistoryId, setActiveHistoryPrevId],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (historyItems.length > 0 && !activeHistoryId) {
|
||||||
|
setActiveHistoryId(historyItems[0].id);
|
||||||
|
setActiveHistoryPrevId(historyItems[1]?.id ?? "");
|
||||||
}
|
}
|
||||||
}, [pageHistoryList]);
|
}, [
|
||||||
|
historyItems,
|
||||||
|
activeHistoryId,
|
||||||
|
setActiveHistoryId,
|
||||||
|
setActiveHistoryPrevId,
|
||||||
|
]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const sentinel = loadMoreRef.current;
|
||||||
|
if (!sentinel || !hasNextPage) return;
|
||||||
|
|
||||||
|
const observer = new IntersectionObserver(
|
||||||
|
(entries) => {
|
||||||
|
if (entries[0].isIntersecting && !isFetchingNextPage) {
|
||||||
|
fetchNextPage();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ threshold: 0.1 },
|
||||||
|
);
|
||||||
|
|
||||||
|
observer.observe(sentinel);
|
||||||
|
return () => observer.disconnect();
|
||||||
|
}, [fetchNextPage, hasNextPage, isFetchingNextPage]);
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return <></>;
|
return <></>;
|
||||||
@@ -97,40 +122,45 @@ function HistoryList({ pageId }: Props) {
|
|||||||
return <div>{t("Error loading page history.")}</div>;
|
return <div>{t("Error loading page history.")}</div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!pageHistoryList || pageHistoryList.items.length === 0) {
|
if (historyItems.length === 0) {
|
||||||
return <>{t("No page history saved yet.")}</>;
|
return <>{t("No page history saved yet.")}</>;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<ScrollArea h={620} w="100%" type="scroll" scrollbarSize={5}>
|
<ScrollArea h={620} w="100%" type="scroll" scrollbarSize={5}>
|
||||||
{pageHistoryList &&
|
{historyItems.map((historyItem, index) => (
|
||||||
pageHistoryList.items.map((historyItem, index) => (
|
<HistoryItem
|
||||||
<HistoryItem
|
key={historyItem.id}
|
||||||
key={index}
|
historyItem={historyItem}
|
||||||
historyItem={historyItem}
|
index={index}
|
||||||
onSelect={setActiveHistoryId}
|
onSelect={handleSelect}
|
||||||
isActive={historyItem.id === activeHistoryId}
|
onHover={handleHover}
|
||||||
/>
|
onHoverEnd={clearPrefetchTimeout}
|
||||||
))}
|
isActive={historyItem.id === activeHistoryId}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
{hasNextPage && <div ref={loadMoreRef} style={{ height: 1 }} />}
|
||||||
|
{isFetchingNextPage && (
|
||||||
|
<Center py="sm">
|
||||||
|
<Loader size="sm" />
|
||||||
|
</Center>
|
||||||
|
)}
|
||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
|
|
||||||
{spaceAbility.cannot(
|
{canRestore && (
|
||||||
SpaceCaslAction.Manage,
|
|
||||||
SpaceCaslSubject.Page,
|
|
||||||
) ? null : (
|
|
||||||
<>
|
<>
|
||||||
<Divider />
|
<Divider />
|
||||||
<Group p="xs" wrap="nowrap">
|
<Group p="xs" wrap="nowrap">
|
||||||
<Button size="compact-md" onClick={confirmModal}>
|
|
||||||
{t("Restore")}
|
|
||||||
</Button>
|
|
||||||
<Button
|
<Button
|
||||||
variant="default"
|
variant="default"
|
||||||
size="compact-md"
|
size="compact-md"
|
||||||
onClick={() => setHistoryModalOpen(false)}
|
onClick={() => setHistoryModalOpen(false)}
|
||||||
>
|
>
|
||||||
{t("Cancel")}
|
{t("Close")}
|
||||||
|
</Button>
|
||||||
|
<Button size="compact-md" onClick={confirmRestore}>
|
||||||
|
{t("Restore")}
|
||||||
</Button>
|
</Button>
|
||||||
</Group>
|
</Group>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -1,21 +1,45 @@
|
|||||||
import { ScrollArea } from "@mantine/core";
|
import {
|
||||||
|
ActionIcon,
|
||||||
|
Group,
|
||||||
|
Paper,
|
||||||
|
ScrollArea,
|
||||||
|
Switch,
|
||||||
|
Text,
|
||||||
|
} from "@mantine/core";
|
||||||
import HistoryList from "@/features/page-history/components/history-list";
|
import HistoryList from "@/features/page-history/components/history-list";
|
||||||
import classes from "./history.module.css";
|
import classes from "./css/history.module.css";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom, useAtomValue } from "jotai";
|
||||||
import { activeHistoryIdAtom } from "@/features/page-history/atoms/history-atoms";
|
import {
|
||||||
|
activeHistoryIdAtom,
|
||||||
|
activeHistoryPrevIdAtom,
|
||||||
|
diffCountsAtom,
|
||||||
|
highlightChangesAtom,
|
||||||
|
} from "@/features/page-history/atoms/history-atoms";
|
||||||
import HistoryView from "@/features/page-history/components/history-view";
|
import HistoryView from "@/features/page-history/components/history-view";
|
||||||
import { useEffect } from "react";
|
import { useRef } from "react";
|
||||||
|
import { IconChevronUp, IconChevronDown } from "@tabler/icons-react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import {
|
||||||
|
useDiffNavigation,
|
||||||
|
useHistoryReset,
|
||||||
|
} from "@/features/page-history/hooks";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
pageId: string;
|
pageId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function HistoryModalBody({ pageId }: Props) {
|
export default function HistoryModalBody({ pageId }: Props) {
|
||||||
const [activeHistoryId, setActiveHistoryId] = useAtom(activeHistoryIdAtom);
|
const { t } = useTranslation();
|
||||||
|
const scrollViewportRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
const activeHistoryId = useAtomValue(activeHistoryIdAtom);
|
||||||
setActiveHistoryId("");
|
const activeHistoryPrevId = useAtomValue(activeHistoryPrevIdAtom);
|
||||||
}, [pageId]);
|
const [highlightChanges, setHighlightChanges] = useAtom(highlightChangesAtom);
|
||||||
|
const diffCounts = useAtomValue(diffCountsAtom);
|
||||||
|
|
||||||
|
useHistoryReset(pageId);
|
||||||
|
const { currentChangeIndex, handlePrevChange, handleNextChange } =
|
||||||
|
useDiffNavigation(scrollViewportRef);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={classes.sidebarFlex}>
|
<div className={classes.sidebarFlex}>
|
||||||
@@ -25,11 +49,63 @@ export default function HistoryModalBody({ pageId }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<ScrollArea h="650" w="100%" scrollbarSize={5}>
|
<div style={{ position: "relative", flex: 1 }}>
|
||||||
<div className={classes.sidebarRightSection}>
|
<ScrollArea
|
||||||
{activeHistoryId && <HistoryView historyId={activeHistoryId} />}
|
h={650}
|
||||||
</div>
|
w="100%"
|
||||||
</ScrollArea>
|
scrollbarSize={5}
|
||||||
|
viewportRef={scrollViewportRef}
|
||||||
|
>
|
||||||
|
<div className={classes.sidebarRightSection}>
|
||||||
|
{activeHistoryId && <HistoryView />}
|
||||||
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
|
|
||||||
|
{activeHistoryId && activeHistoryPrevId && (
|
||||||
|
<Paper
|
||||||
|
shadow="md"
|
||||||
|
radius="xl"
|
||||||
|
px="md"
|
||||||
|
py="xs"
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
bottom: 16,
|
||||||
|
left: "50%",
|
||||||
|
transform: "translateX(-50%)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Group gap="md" wrap="nowrap">
|
||||||
|
<Switch
|
||||||
|
label={t("Highlight changes")}
|
||||||
|
checked={highlightChanges}
|
||||||
|
onChange={(e) => setHighlightChanges(e.currentTarget.checked)}
|
||||||
|
styles={{ label: { userSelect: "none", whiteSpace: "nowrap" } }}
|
||||||
|
/>
|
||||||
|
{highlightChanges && diffCounts && diffCounts.total > 0 && (
|
||||||
|
<Group gap="xs" wrap="nowrap">
|
||||||
|
<Text size="sm" c="dimmed" style={{ whiteSpace: "nowrap" }}>
|
||||||
|
{currentChangeIndex} of {diffCounts.total}
|
||||||
|
</Text>
|
||||||
|
<ActionIcon
|
||||||
|
variant="subtle"
|
||||||
|
size="sm"
|
||||||
|
onClick={handlePrevChange}
|
||||||
|
>
|
||||||
|
<IconChevronUp size={16} />
|
||||||
|
</ActionIcon>
|
||||||
|
<ActionIcon
|
||||||
|
variant="subtle"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleNextChange}
|
||||||
|
>
|
||||||
|
<IconChevronDown size={16} />
|
||||||
|
</ActionIcon>
|
||||||
|
</Group>
|
||||||
|
)}
|
||||||
|
</Group>
|
||||||
|
</Paper>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,208 @@
|
|||||||
|
import {
|
||||||
|
ActionIcon,
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
Group,
|
||||||
|
Paper,
|
||||||
|
ScrollArea,
|
||||||
|
Select,
|
||||||
|
Switch,
|
||||||
|
Text,
|
||||||
|
} from "@mantine/core";
|
||||||
|
import { useAtom, useAtomValue, useSetAtom } from "jotai";
|
||||||
|
import {
|
||||||
|
activeHistoryIdAtom,
|
||||||
|
activeHistoryPrevIdAtom,
|
||||||
|
diffCountsAtom,
|
||||||
|
highlightChangesAtom,
|
||||||
|
historyAtoms,
|
||||||
|
} from "@/features/page-history/atoms/history-atoms";
|
||||||
|
import HistoryView from "@/features/page-history/components/history-view";
|
||||||
|
import { useCallback, useEffect, useMemo, useRef } from "react";
|
||||||
|
import { IconCheck, IconChevronDown, IconChevronUp } from "@tabler/icons-react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { usePageHistoryListQuery } from "@/features/page-history/queries/page-history-query";
|
||||||
|
import { formattedDate } from "@/lib/time";
|
||||||
|
import {
|
||||||
|
useDiffNavigation,
|
||||||
|
useHistoryReset,
|
||||||
|
useHistoryRestore,
|
||||||
|
} from "@/features/page-history/hooks";
|
||||||
|
import classes from "./css/history-mobile.module.css";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
pageId: string;
|
||||||
|
pageTitle?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function HistoryModalMobile({ pageId, pageTitle }: Props) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
const [activeHistoryId, setActiveHistoryId] = useAtom(activeHistoryIdAtom);
|
||||||
|
const setActiveHistoryPrevId = useSetAtom(activeHistoryPrevIdAtom);
|
||||||
|
const [highlightChanges, setHighlightChanges] = useAtom(highlightChangesAtom);
|
||||||
|
const diffCounts = useAtomValue(diffCountsAtom);
|
||||||
|
const setHistoryModalOpen = useSetAtom(historyAtoms);
|
||||||
|
|
||||||
|
const scrollViewportRef = useRef<HTMLDivElement>(null);
|
||||||
|
const dropdownViewportRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
const {
|
||||||
|
data: pageHistoryData,
|
||||||
|
isLoading,
|
||||||
|
fetchNextPage,
|
||||||
|
hasNextPage,
|
||||||
|
isFetchingNextPage,
|
||||||
|
} = usePageHistoryListQuery(pageId);
|
||||||
|
|
||||||
|
const historyItems = useMemo(
|
||||||
|
() => pageHistoryData?.pages.flatMap((page) => page.items) ?? [],
|
||||||
|
[pageHistoryData],
|
||||||
|
);
|
||||||
|
|
||||||
|
const selectData = useMemo(
|
||||||
|
() =>
|
||||||
|
historyItems.map((item) => ({
|
||||||
|
value: item.id,
|
||||||
|
label: formattedDate(new Date(item.createdAt)),
|
||||||
|
userName: item.lastUpdatedBy?.name,
|
||||||
|
})),
|
||||||
|
[historyItems],
|
||||||
|
);
|
||||||
|
|
||||||
|
useHistoryReset(pageId);
|
||||||
|
const { canRestore, confirmRestore } = useHistoryRestore();
|
||||||
|
const { currentChangeIndex, handlePrevChange, handleNextChange } =
|
||||||
|
useDiffNavigation(scrollViewportRef);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (historyItems.length > 0 && !activeHistoryId) {
|
||||||
|
setActiveHistoryId(historyItems[0].id);
|
||||||
|
setActiveHistoryPrevId(historyItems[1]?.id ?? "");
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
historyItems,
|
||||||
|
activeHistoryId,
|
||||||
|
setActiveHistoryId,
|
||||||
|
setActiveHistoryPrevId,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const handleDropdownScroll = useCallback(() => {
|
||||||
|
const viewport = dropdownViewportRef.current;
|
||||||
|
if (!viewport || !hasNextPage || isFetchingNextPage) return;
|
||||||
|
|
||||||
|
const { scrollTop, scrollHeight, clientHeight } = viewport;
|
||||||
|
const isNearBottom = scrollTop + clientHeight >= scrollHeight - 50;
|
||||||
|
|
||||||
|
if (isNearBottom) {
|
||||||
|
fetchNextPage();
|
||||||
|
}
|
||||||
|
}, [fetchNextPage, hasNextPage, isFetchingNextPage]);
|
||||||
|
|
||||||
|
const handleSelectVersion = useCallback(
|
||||||
|
(value: string | null) => {
|
||||||
|
if (!value) return;
|
||||||
|
const index = historyItems.findIndex((item) => item.id === value);
|
||||||
|
if (index >= 0) {
|
||||||
|
setActiveHistoryId(value);
|
||||||
|
setActiveHistoryPrevId(historyItems[index + 1]?.id ?? "");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[historyItems, setActiveHistoryId, setActiveHistoryPrevId],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box className={classes.container}>
|
||||||
|
<Box className={classes.selectorWrapper}>
|
||||||
|
<Select
|
||||||
|
data={selectData}
|
||||||
|
value={activeHistoryId}
|
||||||
|
onChange={handleSelectVersion}
|
||||||
|
placeholder={t("Select version")}
|
||||||
|
checkIconPosition="right"
|
||||||
|
maxDropdownHeight={300}
|
||||||
|
renderOption={({ option, checked }) => (
|
||||||
|
<Group justify="space-between" wrap="nowrap" w="100%">
|
||||||
|
<div>
|
||||||
|
<Text size="sm">{option.label}</Text>
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
{(option as { userName?: string }).userName}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
{checked && <IconCheck size={16} />}
|
||||||
|
</Group>
|
||||||
|
)}
|
||||||
|
comboboxProps={{ withinPortal: false }}
|
||||||
|
scrollAreaProps={{
|
||||||
|
viewportRef: dropdownViewportRef,
|
||||||
|
onScrollPositionChange: handleDropdownScroll,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<ScrollArea
|
||||||
|
className={classes.editorArea}
|
||||||
|
viewportRef={scrollViewportRef}
|
||||||
|
scrollbarSize={5}
|
||||||
|
>
|
||||||
|
<Box className={classes.editorContent}>
|
||||||
|
{activeHistoryId && <HistoryView />}
|
||||||
|
</Box>
|
||||||
|
</ScrollArea>
|
||||||
|
|
||||||
|
{canRestore && (
|
||||||
|
<Group className={classes.actionButtons} justify="flex-end" gap="sm">
|
||||||
|
<Button variant="default" onClick={() => setHistoryModalOpen(false)}>
|
||||||
|
{t("Close")}
|
||||||
|
</Button>
|
||||||
|
<Button onClick={confirmRestore}>{t("Restore")}</Button>
|
||||||
|
</Group>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeHistoryId && (
|
||||||
|
<Paper
|
||||||
|
shadow="sm"
|
||||||
|
radius="xl"
|
||||||
|
px="md"
|
||||||
|
py="xs"
|
||||||
|
className={classes.floatingBar}
|
||||||
|
>
|
||||||
|
<Group gap="sm" wrap="nowrap">
|
||||||
|
<Switch
|
||||||
|
label={t("Highlight changes")}
|
||||||
|
checked={highlightChanges}
|
||||||
|
onChange={(e) => setHighlightChanges(e.currentTarget.checked)}
|
||||||
|
size="sm"
|
||||||
|
styles={{ label: { userSelect: "none", whiteSpace: "nowrap" } }}
|
||||||
|
/>
|
||||||
|
{highlightChanges && diffCounts && diffCounts.total > 0 && (
|
||||||
|
<Group gap={4} wrap="nowrap">
|
||||||
|
<Text size="sm" c="dimmed" style={{ whiteSpace: "nowrap" }}>
|
||||||
|
{currentChangeIndex} of {diffCounts.total}
|
||||||
|
</Text>
|
||||||
|
<ActionIcon
|
||||||
|
variant="subtle"
|
||||||
|
size="sm"
|
||||||
|
onClick={handlePrevChange}
|
||||||
|
>
|
||||||
|
<IconChevronUp size={16} />
|
||||||
|
</ActionIcon>
|
||||||
|
<ActionIcon
|
||||||
|
variant="subtle"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleNextChange}
|
||||||
|
>
|
||||||
|
<IconChevronDown size={16} />
|
||||||
|
</ActionIcon>
|
||||||
|
</Group>
|
||||||
|
)}
|
||||||
|
</Group>
|
||||||
|
</Paper>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,21 +2,26 @@ import { Modal, Text } from "@mantine/core";
|
|||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import { historyAtoms } from "@/features/page-history/atoms/history-atoms";
|
import { historyAtoms } from "@/features/page-history/atoms/history-atoms";
|
||||||
import HistoryModalBody from "@/features/page-history/components/history-modal-body";
|
import HistoryModalBody from "@/features/page-history/components/history-modal-body";
|
||||||
|
import HistoryModalMobile from "@/features/page-history/components/history-modal-mobile";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { useMediaQuery } from "@mantine/hooks";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
pageId: string;
|
pageId: string;
|
||||||
|
pageTitle?: string;
|
||||||
}
|
}
|
||||||
export default function HistoryModal({ pageId }: Props) {
|
|
||||||
|
export default function HistoryModal({ pageId, pageTitle }: Props) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [isModalOpen, setModalOpen] = useAtom(historyAtoms);
|
const [isModalOpen, setModalOpen] = useAtom(historyAtoms);
|
||||||
|
const isMobile = useMediaQuery("(max-width: 800px)");
|
||||||
|
|
||||||
return (
|
if (isMobile) {
|
||||||
<>
|
return (
|
||||||
<Modal.Root
|
<Modal.Root
|
||||||
size={1200}
|
|
||||||
opened={isModalOpen}
|
opened={isModalOpen}
|
||||||
onClose={() => setModalOpen(false)}
|
onClose={() => setModalOpen(false)}
|
||||||
|
fullScreen
|
||||||
>
|
>
|
||||||
<Modal.Overlay />
|
<Modal.Overlay />
|
||||||
<Modal.Content style={{ overflow: "hidden" }}>
|
<Modal.Content style={{ overflow: "hidden" }}>
|
||||||
@@ -28,11 +33,37 @@ export default function HistoryModal({ pageId }: Props) {
|
|||||||
</Modal.Title>
|
</Modal.Title>
|
||||||
<Modal.CloseButton />
|
<Modal.CloseButton />
|
||||||
</Modal.Header>
|
</Modal.Header>
|
||||||
<Modal.Body>
|
<Modal.Body
|
||||||
<HistoryModalBody pageId={pageId} />
|
p={0}
|
||||||
|
style={{ height: "calc(100vh - 60px)", overflow: "hidden" }}
|
||||||
|
>
|
||||||
|
<HistoryModalMobile pageId={pageId} pageTitle={pageTitle} />
|
||||||
</Modal.Body>
|
</Modal.Body>
|
||||||
</Modal.Content>
|
</Modal.Content>
|
||||||
</Modal.Root>
|
</Modal.Root>
|
||||||
</>
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal.Root
|
||||||
|
size={1400}
|
||||||
|
opened={isModalOpen}
|
||||||
|
onClose={() => setModalOpen(false)}
|
||||||
|
>
|
||||||
|
<Modal.Overlay />
|
||||||
|
<Modal.Content style={{ overflow: "hidden" }}>
|
||||||
|
<Modal.Header>
|
||||||
|
<Modal.Title>
|
||||||
|
<Text size="md" fw={500}>
|
||||||
|
{t("Page history")}
|
||||||
|
</Text>
|
||||||
|
</Modal.Title>
|
||||||
|
<Modal.CloseButton />
|
||||||
|
</Modal.Header>
|
||||||
|
<Modal.Body>
|
||||||
|
<HistoryModalBody pageId={pageId} />
|
||||||
|
</Modal.Body>
|
||||||
|
</Modal.Content>
|
||||||
|
</Modal.Root>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,29 +1,44 @@
|
|||||||
import { usePageHistoryQuery } from "@/features/page-history/queries/page-history-query";
|
import { usePageHistoryQuery } from "@/features/page-history/queries/page-history-query";
|
||||||
import { HistoryEditor } from "@/features/page-history/components/history-editor";
|
import { HistoryEditor } from "@/features/page-history/components/history-editor";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { useAtomValue } from "jotai";
|
||||||
|
import {
|
||||||
|
activeHistoryIdAtom,
|
||||||
|
activeHistoryPrevIdAtom,
|
||||||
|
} from "@/features/page-history/atoms/history-atoms";
|
||||||
|
|
||||||
interface HistoryProps {
|
function HistoryView() {
|
||||||
historyId: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
function HistoryView({ historyId }: HistoryProps) {
|
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { data, isLoading, isError } = usePageHistoryQuery(historyId);
|
const historyId = useAtomValue(activeHistoryIdAtom);
|
||||||
|
const prevHistoryId = useAtomValue(activeHistoryPrevIdAtom);
|
||||||
|
|
||||||
if (isLoading) {
|
const {
|
||||||
|
data,
|
||||||
|
isLoading: isLoadingCurrent,
|
||||||
|
isError: isErrorCurrent,
|
||||||
|
} = usePageHistoryQuery(historyId);
|
||||||
|
const {
|
||||||
|
data: prevData,
|
||||||
|
isLoading: isLoadingPrev,
|
||||||
|
isError: isErrorPrev,
|
||||||
|
} = usePageHistoryQuery(prevHistoryId);
|
||||||
|
|
||||||
|
if (isLoadingCurrent || isLoadingPrev) {
|
||||||
return <></>;
|
return <></>;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isError || !data) {
|
if (isErrorCurrent || !data) {
|
||||||
return <div>{t("Error fetching page data.")}</div>;
|
return <div>{t("Error fetching page data.")}</div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
data && (
|
<div>
|
||||||
<div>
|
<HistoryEditor
|
||||||
<HistoryEditor content={data.content} title={data.title} />
|
content={data.content}
|
||||||
</div>
|
title={data.title}
|
||||||
)
|
previousContent={!isErrorPrev ? prevData?.content : undefined}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export { useDiffNavigation } from "./use-diff-navigation";
|
||||||
|
export { useHistoryRestore } from "./use-history-restore";
|
||||||
|
export { useHistoryReset } from "./use-history-reset";
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import { useAtomValue } from "jotai";
|
||||||
|
import { RefObject, useCallback, useEffect, useState } from "react";
|
||||||
|
import { diffCountsAtom } from "@/features/page-history/atoms/history-atoms";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Manages navigation between diff changes in the history view.
|
||||||
|
* Provides prev/next handlers and auto-scrolls to the current change.
|
||||||
|
*/
|
||||||
|
export function useDiffNavigation(
|
||||||
|
scrollViewportRef: RefObject<HTMLDivElement>,
|
||||||
|
) {
|
||||||
|
const diffCounts = useAtomValue(diffCountsAtom);
|
||||||
|
const [currentChangeIndex, setCurrentChangeIndex] = useState(0);
|
||||||
|
|
||||||
|
const scrollToChangeIndex = useCallback(
|
||||||
|
(index: number) => {
|
||||||
|
const viewport = scrollViewportRef.current;
|
||||||
|
if (!viewport || index < 1) return;
|
||||||
|
|
||||||
|
const element = viewport.querySelector(`[data-diff-index="${index}"]`);
|
||||||
|
if (element instanceof HTMLElement) {
|
||||||
|
const elementTop = element.offsetTop;
|
||||||
|
const viewportHeight = viewport.clientHeight;
|
||||||
|
const scrollTarget =
|
||||||
|
elementTop - viewportHeight / 2 + element.offsetHeight / 2;
|
||||||
|
viewport.scrollTo({ top: scrollTarget, behavior: "smooth" });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[scrollViewportRef],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (diffCounts && diffCounts.total > 0) {
|
||||||
|
setCurrentChangeIndex(1);
|
||||||
|
requestAnimationFrame(() => scrollToChangeIndex(1));
|
||||||
|
} else {
|
||||||
|
setCurrentChangeIndex(0);
|
||||||
|
}
|
||||||
|
}, [diffCounts, scrollToChangeIndex]);
|
||||||
|
|
||||||
|
const handlePrevChange = useCallback(() => {
|
||||||
|
if (!diffCounts || diffCounts.total === 0) return;
|
||||||
|
const newIndex =
|
||||||
|
currentChangeIndex <= 1 ? diffCounts.total : currentChangeIndex - 1;
|
||||||
|
setCurrentChangeIndex(newIndex);
|
||||||
|
scrollToChangeIndex(newIndex);
|
||||||
|
}, [diffCounts, currentChangeIndex, scrollToChangeIndex]);
|
||||||
|
|
||||||
|
const handleNextChange = useCallback(() => {
|
||||||
|
if (!diffCounts || diffCounts.total === 0) return;
|
||||||
|
const newIndex =
|
||||||
|
currentChangeIndex >= diffCounts.total ? 1 : currentChangeIndex + 1;
|
||||||
|
setCurrentChangeIndex(newIndex);
|
||||||
|
scrollToChangeIndex(newIndex);
|
||||||
|
}, [diffCounts, currentChangeIndex, scrollToChangeIndex]);
|
||||||
|
|
||||||
|
return { currentChangeIndex, handlePrevChange, handleNextChange };
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { useAtom } from "jotai";
|
||||||
|
import { useEffect } from "react";
|
||||||
|
import {
|
||||||
|
activeHistoryIdAtom,
|
||||||
|
activeHistoryPrevIdAtom,
|
||||||
|
diffCountsAtom,
|
||||||
|
} from "@/features/page-history/atoms/history-atoms";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resets history state when pageId changes.
|
||||||
|
* Clears active selection and diff counts.
|
||||||
|
*/
|
||||||
|
export function useHistoryReset(pageId: string) {
|
||||||
|
const [, setActiveHistoryId] = useAtom(activeHistoryIdAtom);
|
||||||
|
const [, setActiveHistoryPrevId] = useAtom(activeHistoryPrevIdAtom);
|
||||||
|
const [, setDiffCounts] = useAtom(diffCountsAtom);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setActiveHistoryId("");
|
||||||
|
setActiveHistoryPrevId("");
|
||||||
|
// @ts-ignore
|
||||||
|
setDiffCounts(null);
|
||||||
|
}, [pageId, setActiveHistoryId, setActiveHistoryPrevId, setDiffCounts]);
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
import { useAtom, useAtomValue, useSetAtom } from "jotai";
|
||||||
|
import { useCallback } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Text } from "@mantine/core";
|
||||||
|
import { modals } from "@mantine/modals";
|
||||||
|
import { notifications } from "@mantine/notifications";
|
||||||
|
import { useParams } from "react-router-dom";
|
||||||
|
import {
|
||||||
|
activeHistoryIdAtom,
|
||||||
|
historyAtoms,
|
||||||
|
} from "@/features/page-history/atoms/history-atoms";
|
||||||
|
import { usePageHistoryQuery } from "@/features/page-history/queries/page-history-query";
|
||||||
|
import {
|
||||||
|
pageEditorAtom,
|
||||||
|
titleEditorAtom,
|
||||||
|
} from "@/features/editor/atoms/editor-atoms";
|
||||||
|
import { useSpaceAbility } from "@/features/space/permissions/use-space-ability";
|
||||||
|
import { useSpaceQuery } from "@/features/space/queries/space-query";
|
||||||
|
import {
|
||||||
|
SpaceCaslAction,
|
||||||
|
SpaceCaslSubject,
|
||||||
|
} from "@/features/space/permissions/permissions.type";
|
||||||
|
|
||||||
|
export function useHistoryRestore() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
const activeHistoryId = useAtomValue(activeHistoryIdAtom);
|
||||||
|
const { data: activeHistoryData } = usePageHistoryQuery(activeHistoryId);
|
||||||
|
|
||||||
|
const mainEditor = useAtomValue(pageEditorAtom);
|
||||||
|
const mainEditorTitle = useAtomValue(titleEditorAtom);
|
||||||
|
const setHistoryModalOpen = useSetAtom(historyAtoms);
|
||||||
|
|
||||||
|
const { spaceSlug } = useParams();
|
||||||
|
const { data: space } = useSpaceQuery(spaceSlug);
|
||||||
|
const spaceAbility = useSpaceAbility(space?.membership?.permissions);
|
||||||
|
|
||||||
|
const canRestore = spaceAbility.can(
|
||||||
|
SpaceCaslAction.Manage,
|
||||||
|
SpaceCaslSubject.Page,
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleRestore = useCallback(() => {
|
||||||
|
if (!activeHistoryData) return;
|
||||||
|
|
||||||
|
mainEditorTitle
|
||||||
|
.chain()
|
||||||
|
.clearContent()
|
||||||
|
.setContent(activeHistoryData.title, { emitUpdate: true })
|
||||||
|
.run();
|
||||||
|
|
||||||
|
mainEditor
|
||||||
|
.chain()
|
||||||
|
.clearContent()
|
||||||
|
.setContent(activeHistoryData.content)
|
||||||
|
.run();
|
||||||
|
|
||||||
|
setHistoryModalOpen(false);
|
||||||
|
notifications.show({ message: t("Successfully restored") });
|
||||||
|
}, [activeHistoryData, mainEditor, mainEditorTitle, setHistoryModalOpen, t]);
|
||||||
|
|
||||||
|
const confirmRestore = useCallback(() => {
|
||||||
|
modals.openConfirmModal({
|
||||||
|
title: t("Please confirm your action"),
|
||||||
|
children: (
|
||||||
|
<Text size="sm">
|
||||||
|
{t(
|
||||||
|
"Are you sure you want to restore this version? Any changes not versioned will be lost.",
|
||||||
|
)}
|
||||||
|
</Text>
|
||||||
|
),
|
||||||
|
labels: { confirm: t("Confirm"), cancel: t("Cancel") },
|
||||||
|
onConfirm: handleRestore,
|
||||||
|
});
|
||||||
|
}, [t, handleRestore]);
|
||||||
|
|
||||||
|
return { canRestore, confirmRestore };
|
||||||
|
}
|
||||||
@@ -1,19 +1,38 @@
|
|||||||
import { useQuery, UseQueryResult } from "@tanstack/react-query";
|
import {
|
||||||
|
InfiniteData,
|
||||||
|
useInfiniteQuery,
|
||||||
|
UseInfiniteQueryResult,
|
||||||
|
useQuery,
|
||||||
|
UseQueryResult,
|
||||||
|
} from "@tanstack/react-query";
|
||||||
import {
|
import {
|
||||||
getPageHistoryById,
|
getPageHistoryById,
|
||||||
getPageHistoryList,
|
getPageHistoryList,
|
||||||
} from "@/features/page-history/services/page-history-service";
|
} from "@/features/page-history/services/page-history-service";
|
||||||
import { IPageHistory } from "@/features/page-history/types/page.types";
|
import { IPageHistory } from "@/features/page-history/types/page.types";
|
||||||
import { IPagination } from "@/lib/types.ts";
|
import { IPagination } from "@/lib/types.ts";
|
||||||
|
import { queryClient } from "@/main";
|
||||||
|
|
||||||
|
const HISTORY_STALE_TIME = 60 * 60 * 1000;
|
||||||
|
|
||||||
|
export function prefetchPageHistory(historyId: string) {
|
||||||
|
return queryClient.prefetchQuery({
|
||||||
|
queryKey: ["page-history", historyId],
|
||||||
|
queryFn: () => getPageHistoryById(historyId),
|
||||||
|
staleTime: HISTORY_STALE_TIME,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function usePageHistoryListQuery(
|
export function usePageHistoryListQuery(
|
||||||
pageId: string,
|
pageId: string,
|
||||||
): UseQueryResult<IPagination<IPageHistory>, Error> {
|
): UseInfiniteQueryResult<InfiniteData<IPagination<IPageHistory>, unknown>> {
|
||||||
return useQuery({
|
return useInfiniteQuery({
|
||||||
queryKey: ["page-history-list", pageId],
|
queryKey: ["page-history-list", pageId],
|
||||||
queryFn: () => getPageHistoryList(pageId),
|
queryFn: ({ pageParam }) => getPageHistoryList(pageId, pageParam),
|
||||||
enabled: !!pageId,
|
enabled: !!pageId,
|
||||||
gcTime: 0,
|
gcTime: 0,
|
||||||
|
initialPageParam: undefined,
|
||||||
|
getNextPageParam: (lastPage) => lastPage.meta?.nextCursor ?? undefined,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -24,6 +43,6 @@ export function usePageHistoryQuery(
|
|||||||
queryKey: ["page-history", historyId],
|
queryKey: ["page-history", historyId],
|
||||||
queryFn: () => getPageHistoryById(historyId),
|
queryFn: () => getPageHistoryById(historyId),
|
||||||
enabled: !!historyId,
|
enabled: !!historyId,
|
||||||
staleTime: 10 * 60 * 1000,
|
staleTime: HISTORY_STALE_TIME,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,9 +4,11 @@ import { IPagination } from "@/lib/types.ts";
|
|||||||
|
|
||||||
export async function getPageHistoryList(
|
export async function getPageHistoryList(
|
||||||
pageId: string,
|
pageId: string,
|
||||||
|
cursor?: string,
|
||||||
): Promise<IPagination<IPageHistory>> {
|
): Promise<IPagination<IPageHistory>> {
|
||||||
const req = await api.post("/pages/history", {
|
const req = await api.post("/pages/history", {
|
||||||
pageId,
|
pageId,
|
||||||
|
cursor,
|
||||||
});
|
});
|
||||||
return req.data;
|
return req.data;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -250,12 +250,10 @@ export function useGetSidebarPagesQuery(
|
|||||||
return useInfiniteQuery({
|
return useInfiniteQuery({
|
||||||
queryKey: ["sidebar-pages", data],
|
queryKey: ["sidebar-pages", data],
|
||||||
enabled: !!data?.pageId || !!data?.spaceId,
|
enabled: !!data?.pageId || !!data?.spaceId,
|
||||||
queryFn: ({ pageParam }) => getSidebarPages({ ...data, page: pageParam }),
|
queryFn: ({ pageParam }) => getSidebarPages({ ...data, cursor: pageParam }),
|
||||||
initialPageParam: 1,
|
initialPageParam: undefined,
|
||||||
getPreviousPageParam: (firstPage) =>
|
|
||||||
firstPage.meta.hasPrevPage ? firstPage.meta.page - 1 : undefined,
|
|
||||||
getNextPageParam: (lastPage) =>
|
getNextPageParam: (lastPage) =>
|
||||||
lastPage.meta.hasNextPage ? lastPage.meta.page + 1 : undefined,
|
lastPage.meta?.nextCursor ?? undefined,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -263,13 +261,11 @@ export function useGetRootSidebarPagesQuery(data: SidebarPagesParams) {
|
|||||||
return useInfiniteQuery({
|
return useInfiniteQuery({
|
||||||
queryKey: ["root-sidebar-pages", data.spaceId],
|
queryKey: ["root-sidebar-pages", data.spaceId],
|
||||||
queryFn: async ({ pageParam }) => {
|
queryFn: async ({ pageParam }) => {
|
||||||
return getSidebarPages({ spaceId: data.spaceId, page: pageParam });
|
return getSidebarPages({ spaceId: data.spaceId, cursor: pageParam });
|
||||||
},
|
},
|
||||||
initialPageParam: 1,
|
initialPageParam: undefined,
|
||||||
getPreviousPageParam: (firstPage) =>
|
|
||||||
firstPage.meta.hasPrevPage ? firstPage.meta.page - 1 : undefined,
|
|
||||||
getNextPageParam: (lastPage) =>
|
getNextPageParam: (lastPage) =>
|
||||||
lastPage.meta.hasNextPage ? lastPage.meta.page + 1 : undefined,
|
lastPage.meta?.nextCursor ?? undefined,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -72,22 +72,19 @@ export async function getSidebarPages(
|
|||||||
export async function getAllSidebarPages(
|
export async function getAllSidebarPages(
|
||||||
params: SidebarPagesParams,
|
params: SidebarPagesParams,
|
||||||
): Promise<InfiniteData<IPagination<IPage>, unknown>> {
|
): Promise<InfiniteData<IPagination<IPage>, unknown>> {
|
||||||
let page = 1;
|
let cursor: string | undefined = undefined;
|
||||||
let hasNextPage = false;
|
|
||||||
const pages: IPagination<IPage>[] = [];
|
const pages: IPagination<IPage>[] = [];
|
||||||
const pageParams: number[] = [];
|
const pageParams: (string | undefined)[] = [];
|
||||||
|
|
||||||
do {
|
do {
|
||||||
const req = await api.post("/pages/sidebar-pages", { ...params, page: page });
|
const req = await api.post("/pages/sidebar-pages", { ...params, cursor });
|
||||||
|
|
||||||
const data: IPagination<IPage> = req.data;
|
const data: IPagination<IPage> = req.data;
|
||||||
pages.push(data);
|
pages.push(data);
|
||||||
pageParams.push(page);
|
pageParams.push(cursor);
|
||||||
|
|
||||||
hasNextPage = data.meta.hasNextPage;
|
cursor = data.meta.nextCursor ?? undefined;
|
||||||
|
} while (cursor);
|
||||||
page += 1;
|
|
||||||
} while (hasNextPage);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
pageParams,
|
pageParams,
|
||||||
|
|||||||
@@ -30,15 +30,15 @@ import { useState } from "react";
|
|||||||
import TrashPageContentModal from "@/features/page/trash/components/trash-page-content-modal";
|
import TrashPageContentModal from "@/features/page/trash/components/trash-page-content-modal";
|
||||||
import { UserInfo } from "@/components/common/user-info.tsx";
|
import { UserInfo } from "@/components/common/user-info.tsx";
|
||||||
import Paginate from "@/components/common/paginate.tsx";
|
import Paginate from "@/components/common/paginate.tsx";
|
||||||
import { usePaginateAndSearch } from "@/hooks/use-paginate-and-search";
|
import { useCursorPaginate } from "@/hooks/use-cursor-paginate";
|
||||||
|
|
||||||
export default function Trash() {
|
export default function Trash() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { spaceSlug } = useParams();
|
const { spaceSlug } = useParams();
|
||||||
const { page, setPage } = usePaginateAndSearch();
|
const { cursor, goNext, goPrev } = useCursorPaginate();
|
||||||
const { data: space } = useGetSpaceBySlugQuery(spaceSlug);
|
const { data: space } = useGetSpaceBySlugQuery(spaceSlug);
|
||||||
const { data: deletedPages, isLoading } = useDeletedPagesQuery(space?.id, {
|
const { data: deletedPages, isLoading } = useDeletedPagesQuery(space?.id, {
|
||||||
page, limit: 50
|
cursor, limit: 50
|
||||||
});
|
});
|
||||||
const restorePageMutation = useRestorePageMutation();
|
const restorePageMutation = useRestorePageMutation();
|
||||||
const deletePageMutation = useDeletePageMutation();
|
const deletePageMutation = useDeletePageMutation();
|
||||||
@@ -206,10 +206,10 @@ export default function Trash() {
|
|||||||
|
|
||||||
{deletedPages && deletedPages.items.length > 0 && (
|
{deletedPages && deletedPages.items.length > 0 && (
|
||||||
<Paginate
|
<Paginate
|
||||||
currentPage={page}
|
hasPrevPage={deletedPages.meta?.hasPrevPage}
|
||||||
hasPrevPage={deletedPages.meta.hasPrevPage}
|
hasNextPage={deletedPages.meta?.hasNextPage}
|
||||||
hasNextPage={deletedPages.meta.hasNextPage}
|
onNext={() => goNext(deletedPages.meta?.nextCursor)}
|
||||||
onPageChange={setPage}
|
onPrev={goPrev}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ export interface ICopyPageToSpace {
|
|||||||
export interface SidebarPagesParams {
|
export interface SidebarPagesParams {
|
||||||
spaceId?: string;
|
spaceId?: string;
|
||||||
pageId?: string;
|
pageId?: string;
|
||||||
page?: number; // pagination
|
cursor?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IPageInput {
|
export interface IPageInput {
|
||||||
|
|||||||
@@ -18,7 +18,6 @@ import {
|
|||||||
IconFileDescription,
|
IconFileDescription,
|
||||||
IconSearch,
|
IconSearch,
|
||||||
IconCheck,
|
IconCheck,
|
||||||
IconSparkles,
|
|
||||||
} from "@tabler/icons-react";
|
} from "@tabler/icons-react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useDebouncedValue } from "@mantine/hooks";
|
import { useDebouncedValue } from "@mantine/hooks";
|
||||||
@@ -26,7 +25,7 @@ import { useGetSpacesQuery } from "@/features/space/queries/space-query";
|
|||||||
import { useLicense } from "@/ee/hooks/use-license";
|
import { useLicense } from "@/ee/hooks/use-license";
|
||||||
import classes from "./search-spotlight-filters.module.css";
|
import classes from "./search-spotlight-filters.module.css";
|
||||||
import { isCloud } from "@/lib/config.ts";
|
import { isCloud } from "@/lib/config.ts";
|
||||||
import { useAtom } from "jotai/index";
|
import { useAtom } from "jotai";
|
||||||
import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts";
|
import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts";
|
||||||
|
|
||||||
interface SearchSpotlightFiltersProps {
|
interface SearchSpotlightFiltersProps {
|
||||||
@@ -53,7 +52,6 @@ export function SearchSpotlightFilters({
|
|||||||
const [workspace] = useAtom(workspaceAtom);
|
const [workspace] = useAtom(workspaceAtom);
|
||||||
|
|
||||||
const { data: spacesData } = useGetSpacesQuery({
|
const { data: spacesData } = useGetSpacesQuery({
|
||||||
page: 1,
|
|
||||||
limit: 100,
|
limit: 100,
|
||||||
query: debouncedSpaceQuery,
|
query: debouncedSpaceQuery,
|
||||||
});
|
});
|
||||||
@@ -265,7 +263,9 @@ export function SearchSpotlightFilters({
|
|||||||
contentType !== option.value &&
|
contentType !== option.value &&
|
||||||
handleFilterChange("contentType", option.value)
|
handleFilterChange("contentType", option.value)
|
||||||
}
|
}
|
||||||
disabled={option.disabled || (isAiMode && option.value === "attachment")}
|
disabled={
|
||||||
|
option.disabled || (isAiMode && option.value === "attachment")
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<Group flex="1" gap="xs">
|
<Group flex="1" gap="xs">
|
||||||
<div>
|
<div>
|
||||||
@@ -275,11 +275,13 @@ export function SearchSpotlightFilters({
|
|||||||
{t("Enterprise")}
|
{t("Enterprise")}
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
{!option.disabled && isAiMode && option.value === "attachment" && (
|
{!option.disabled &&
|
||||||
<Text size="xs" mt={4}>
|
isAiMode &&
|
||||||
{t("Ask AI not available for attachments")}
|
option.value === "attachment" && (
|
||||||
</Text>
|
<Text size="xs" mt={4}>
|
||||||
)}
|
{t("Ask AI not available for attachments")}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
{contentType === option.value && <IconCheck size={20} />}
|
{contentType === option.value && <IconCheck size={20} />}
|
||||||
</Group>
|
</Group>
|
||||||
|
|||||||
@@ -10,8 +10,8 @@ import {
|
|||||||
export async function searchPage(
|
export async function searchPage(
|
||||||
params: IPageSearchParams,
|
params: IPageSearchParams,
|
||||||
): Promise<IPageSearch[]> {
|
): Promise<IPageSearch[]> {
|
||||||
const req = await api.post<IPageSearch[]>("/search", params);
|
const req = await api.post<{ items: IPageSearch[] }>("/search", params);
|
||||||
return req.data;
|
return req.data.items;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function searchSuggestions(
|
export async function searchSuggestions(
|
||||||
@@ -24,13 +24,13 @@ export async function searchSuggestions(
|
|||||||
export async function searchShare(
|
export async function searchShare(
|
||||||
params: IPageSearchParams,
|
params: IPageSearchParams,
|
||||||
): Promise<IPageSearch[]> {
|
): Promise<IPageSearch[]> {
|
||||||
const req = await api.post<IPageSearch[]>("/search/share-search", params);
|
const req = await api.post<{ items: IPageSearch[] }>("/search/share-search", params);
|
||||||
return req.data;
|
return req.data.items;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function searchAttachments(
|
export async function searchAttachments(
|
||||||
params: IPageSearchParams,
|
params: IPageSearchParams,
|
||||||
): Promise<IAttachmentSearch[]> {
|
): Promise<IAttachmentSearch[]> {
|
||||||
const req = await api.post<IAttachmentSearch[]>("/search-attachments", params);
|
const req = await api.post<{ items: IAttachmentSearch[] }>("/search-attachments", params);
|
||||||
return req.data;
|
return req.data.items;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { Table, Group, Text, Anchor } from "@mantine/core";
|
import { Table, Group, Text, Anchor } from "@mantine/core";
|
||||||
import React, { useState } from "react";
|
import React from "react";
|
||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import Paginate from "@/components/common/paginate.tsx";
|
import Paginate from "@/components/common/paginate.tsx";
|
||||||
|
import { useCursorPaginate } from "@/hooks/use-cursor-paginate";
|
||||||
import { useGetSharesQuery } from "@/features/share/queries/share-query.ts";
|
import { useGetSharesQuery } from "@/features/share/queries/share-query.ts";
|
||||||
import { ISharedItem } from "@/features/share/types/share.types.ts";
|
import { ISharedItem } from "@/features/share/types/share.types.ts";
|
||||||
import { format } from "date-fns";
|
import { format } from "date-fns";
|
||||||
@@ -14,8 +15,8 @@ import classes from "./share.module.css";
|
|||||||
|
|
||||||
export default function ShareList() {
|
export default function ShareList() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [page, setPage] = useState(1);
|
const { cursor, goNext, goPrev } = useCursorPaginate();
|
||||||
const { data, isLoading } = useGetSharesQuery({ page });
|
const { data, isLoading } = useGetSharesQuery({ cursor });
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -86,10 +87,10 @@ export default function ShareList() {
|
|||||||
|
|
||||||
{data?.items.length > 0 && (
|
{data?.items.length > 0 && (
|
||||||
<Paginate
|
<Paginate
|
||||||
currentPage={page}
|
hasPrevPage={data?.meta?.hasPrevPage}
|
||||||
hasPrevPage={data?.meta.hasPrevPage}
|
hasNextPage={data?.meta?.hasNextPage}
|
||||||
hasNextPage={data?.meta.hasNextPage}
|
onNext={() => goNext(data?.meta?.nextCursor)}
|
||||||
onPageChange={setPage}
|
onPrev={goPrev}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ export function useGetSharesQuery(
|
|||||||
params?: QueryParams,
|
params?: QueryParams,
|
||||||
): UseQueryResult<IPagination<ISharedItem>, Error> {
|
): UseQueryResult<IPagination<ISharedItem>, Error> {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ["share-list"],
|
queryKey: ["share-list", params],
|
||||||
queryFn: () => getShares(params),
|
queryFn: () => getShares(params),
|
||||||
placeholderData: keepPreviousData,
|
placeholderData: keepPreviousData,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import { AvatarIconType } from "@/features/attachments/types/attachment.types.ts
|
|||||||
|
|
||||||
export default function SpaceGrid() {
|
export default function SpaceGrid() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { data, isLoading } = useGetSpacesQuery({ page: 1, limit: 10 });
|
const { data, isLoading } = useGetSpacesQuery({ limit: 10 });
|
||||||
|
|
||||||
const cards = data?.items.slice(0, 9).map((space, index) => (
|
const cards = data?.items.slice(0, 9).map((space, index) => (
|
||||||
<Card
|
<Card
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Group, Table, Text } from "@mantine/core";
|
import { Group, Table, Text } from "@mantine/core";
|
||||||
import React, { useState } from "react";
|
import React, { useState } from "react";
|
||||||
|
import { useCursorPaginate } from "@/hooks/use-cursor-paginate";
|
||||||
import { useGetSpacesQuery } from "@/features/space/queries/space-query.ts";
|
import { useGetSpacesQuery } from "@/features/space/queries/space-query.ts";
|
||||||
import SpaceSettingsModal from "@/features/space/components/settings-modal.tsx";
|
import SpaceSettingsModal from "@/features/space/components/settings-modal.tsx";
|
||||||
import { useDisclosure } from "@mantine/hooks";
|
import { useDisclosure } from "@mantine/hooks";
|
||||||
@@ -12,8 +13,8 @@ import { AutoTooltipText } from "@/components/ui/auto-tooltip-text.tsx";
|
|||||||
|
|
||||||
export default function SpaceList() {
|
export default function SpaceList() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [page, setPage] = useState(1);
|
const { cursor, goNext, goPrev } = useCursorPaginate();
|
||||||
const { data, isLoading } = useGetSpacesQuery({ page });
|
const { data, isLoading } = useGetSpacesQuery({ cursor });
|
||||||
const [opened, { open, close }] = useDisclosure(false);
|
const [opened, { open, close }] = useDisclosure(false);
|
||||||
const [selectedSpaceId, setSelectedSpaceId] = useState<string>(null);
|
const [selectedSpaceId, setSelectedSpaceId] = useState<string>(null);
|
||||||
|
|
||||||
@@ -72,10 +73,10 @@ export default function SpaceList() {
|
|||||||
|
|
||||||
{data?.items.length > 0 && (
|
{data?.items.length > 0 && (
|
||||||
<Paginate
|
<Paginate
|
||||||
currentPage={page}
|
hasPrevPage={data?.meta?.hasPrevPage}
|
||||||
hasPrevPage={data?.meta.hasPrevPage}
|
hasNextPage={data?.meta?.hasNextPage}
|
||||||
hasNextPage={data?.meta.hasNextPage}
|
onNext={() => goNext(data?.meta?.nextCursor)}
|
||||||
onPageChange={setPage}
|
onPrev={goPrev}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -41,9 +41,9 @@ export default function SpaceMembersList({
|
|||||||
readOnly,
|
readOnly,
|
||||||
}: SpaceMembersProps) {
|
}: SpaceMembersProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { search, page, setPage, handleSearch } = usePaginateAndSearch();
|
const { search, cursor, goNext, goPrev, handleSearch } = usePaginateAndSearch();
|
||||||
const { data, isLoading } = useSpaceMembersQuery(spaceId, {
|
const { data, isLoading } = useSpaceMembersQuery(spaceId, {
|
||||||
page,
|
cursor,
|
||||||
limit: 100,
|
limit: 100,
|
||||||
query: search,
|
query: search,
|
||||||
});
|
});
|
||||||
@@ -206,10 +206,10 @@ export default function SpaceMembersList({
|
|||||||
|
|
||||||
{data?.items.length > 0 && (
|
{data?.items.length > 0 && (
|
||||||
<Paginate
|
<Paginate
|
||||||
currentPage={page}
|
hasPrevPage={data?.meta?.hasPrevPage}
|
||||||
hasPrevPage={data?.meta.hasPrevPage}
|
hasNextPage={data?.meta?.hasNextPage}
|
||||||
hasNextPage={data?.meta.hasNextPage}
|
onNext={() => goNext(data?.meta?.nextCursor)}
|
||||||
onPageChange={setPage}
|
onPrev={goPrev}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -28,19 +28,19 @@ import { AutoTooltipText } from "@/components/ui/auto-tooltip-text.tsx";
|
|||||||
interface AllSpacesListProps {
|
interface AllSpacesListProps {
|
||||||
spaces: any[];
|
spaces: any[];
|
||||||
onSearch: (query: string) => void;
|
onSearch: (query: string) => void;
|
||||||
page: number;
|
|
||||||
hasPrevPage?: boolean;
|
hasPrevPage?: boolean;
|
||||||
hasNextPage?: boolean;
|
hasNextPage?: boolean;
|
||||||
onPageChange: (page: number) => void;
|
onNext: () => void;
|
||||||
|
onPrev: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function AllSpacesList({
|
export default function AllSpacesList({
|
||||||
spaces,
|
spaces,
|
||||||
onSearch,
|
onSearch,
|
||||||
page,
|
|
||||||
hasPrevPage,
|
hasPrevPage,
|
||||||
hasNextPage,
|
hasNextPage,
|
||||||
onPageChange,
|
onNext,
|
||||||
|
onPrev,
|
||||||
}: AllSpacesListProps) {
|
}: AllSpacesListProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [settingsOpened, { open: openSettings, close: closeSettings }] =
|
const [settingsOpened, { open: openSettings, close: closeSettings }] =
|
||||||
@@ -145,10 +145,10 @@ export default function AllSpacesList({
|
|||||||
|
|
||||||
{spaces.length > 0 && (
|
{spaces.length > 0 && (
|
||||||
<Paginate
|
<Paginate
|
||||||
currentPage={page}
|
|
||||||
hasPrevPage={hasPrevPage}
|
hasPrevPage={hasPrevPage}
|
||||||
hasNextPage={hasNextPage}
|
hasNextPage={hasNextPage}
|
||||||
onPageChange={onPageChange}
|
onNext={onNext}
|
||||||
|
onPrev={onPrev}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
+8
-7
@@ -1,6 +1,6 @@
|
|||||||
import { Group, Table, Avatar, Text, Alert } from "@mantine/core";
|
import { Group, Table, Avatar, Text, Alert } from "@mantine/core";
|
||||||
import { useWorkspaceInvitationsQuery } from "@/features/workspace/queries/workspace-query.ts";
|
import { useWorkspaceInvitationsQuery } from "@/features/workspace/queries/workspace-query.ts";
|
||||||
import React, { useState } from "react";
|
import React from "react";
|
||||||
import { getUserRoleLabel } from "@/features/workspace/types/user-role-data.ts";
|
import { getUserRoleLabel } from "@/features/workspace/types/user-role-data.ts";
|
||||||
import InviteActionMenu from "@/features/workspace/components/members/components/invite-action-menu.tsx";
|
import InviteActionMenu from "@/features/workspace/components/members/components/invite-action-menu.tsx";
|
||||||
import { IconInfoCircle } from "@tabler/icons-react";
|
import { IconInfoCircle } from "@tabler/icons-react";
|
||||||
@@ -8,12 +8,13 @@ import { timeAgo } from "@/lib/time.ts";
|
|||||||
import useUserRole from "@/hooks/use-user-role.tsx";
|
import useUserRole from "@/hooks/use-user-role.tsx";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import Paginate from "@/components/common/paginate.tsx";
|
import Paginate from "@/components/common/paginate.tsx";
|
||||||
|
import { useCursorPaginate } from "@/hooks/use-cursor-paginate";
|
||||||
|
|
||||||
export default function WorkspaceInvitesTable() {
|
export default function WorkspaceInvitesTable() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [page, setPage] = useState(1);
|
const { cursor, goNext, goPrev } = useCursorPaginate();
|
||||||
const { data, isLoading } = useWorkspaceInvitationsQuery({
|
const { data, isLoading } = useWorkspaceInvitationsQuery({
|
||||||
page,
|
cursor,
|
||||||
limit: 100,
|
limit: 100,
|
||||||
});
|
});
|
||||||
const { isAdmin } = useUserRole();
|
const { isAdmin } = useUserRole();
|
||||||
@@ -65,10 +66,10 @@ export default function WorkspaceInvitesTable() {
|
|||||||
|
|
||||||
{data?.items.length > 0 && (
|
{data?.items.length > 0 && (
|
||||||
<Paginate
|
<Paginate
|
||||||
currentPage={page}
|
hasPrevPage={data?.meta?.hasPrevPage}
|
||||||
hasPrevPage={data?.meta.hasPrevPage}
|
hasNextPage={data?.meta?.hasNextPage}
|
||||||
hasNextPage={data?.meta.hasNextPage}
|
onNext={() => goNext(data?.meta?.nextCursor)}
|
||||||
onPageChange={setPage}
|
onPrev={goPrev}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
|||||||
+6
-6
@@ -21,9 +21,9 @@ import MemberActionMenu from "@/features/workspace/components/members/components
|
|||||||
|
|
||||||
export default function WorkspaceMembersTable() {
|
export default function WorkspaceMembersTable() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { search, page, setPage, handleSearch } = usePaginateAndSearch();
|
const { search, cursor, goNext, goPrev, handleSearch } = usePaginateAndSearch();
|
||||||
const { data, isLoading } = useWorkspaceMembersQuery({
|
const { data, isLoading } = useWorkspaceMembersQuery({
|
||||||
page,
|
cursor,
|
||||||
limit: 100,
|
limit: 100,
|
||||||
query: search,
|
query: search,
|
||||||
});
|
});
|
||||||
@@ -111,10 +111,10 @@ export default function WorkspaceMembersTable() {
|
|||||||
|
|
||||||
{data?.items.length > 0 && (
|
{data?.items.length > 0 && (
|
||||||
<Paginate
|
<Paginate
|
||||||
currentPage={page}
|
hasPrevPage={data?.meta?.hasPrevPage}
|
||||||
hasPrevPage={data?.meta.hasPrevPage}
|
hasNextPage={data?.meta?.hasNextPage}
|
||||||
hasNextPage={data?.meta.hasNextPage}
|
onNext={() => goNext(data?.meta?.nextCursor)}
|
||||||
onPageChange={setPage}
|
onPrev={goPrev}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { useState, useCallback } from "react";
|
||||||
|
|
||||||
|
export function useCursorPaginate() {
|
||||||
|
const [cursor, setCursor] = useState<string | undefined>(undefined);
|
||||||
|
const [cursorStack, setCursorStack] = useState<(string | undefined)[]>([]);
|
||||||
|
|
||||||
|
const goNext = useCallback((nextCursor: string | null | undefined) => {
|
||||||
|
if (nextCursor) {
|
||||||
|
setCursorStack((prev) => [...prev, cursor]);
|
||||||
|
setCursor(nextCursor);
|
||||||
|
}
|
||||||
|
}, [cursor]);
|
||||||
|
|
||||||
|
const goPrev = useCallback(() => {
|
||||||
|
setCursorStack((prev) => {
|
||||||
|
const next = prev.slice(0, -1);
|
||||||
|
setCursor(prev[prev.length - 1]);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const resetCursor = useCallback(() => {
|
||||||
|
setCursor(undefined);
|
||||||
|
setCursorStack([]);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return { cursor, goNext, goPrev, resetCursor };
|
||||||
|
}
|
||||||
@@ -2,16 +2,33 @@ import { useState, useRef, useCallback } from "react";
|
|||||||
|
|
||||||
export function usePaginateAndSearch(initialQuery: string = "") {
|
export function usePaginateAndSearch(initialQuery: string = "") {
|
||||||
const [search, setSearch] = useState(initialQuery);
|
const [search, setSearch] = useState(initialQuery);
|
||||||
const [page, setPage] = useState(1);
|
const [cursor, setCursor] = useState<string | undefined>(undefined);
|
||||||
|
const [cursorStack, setCursorStack] = useState<(string | undefined)[]>([]);
|
||||||
const prevSearchRef = useRef(search);
|
const prevSearchRef = useRef(search);
|
||||||
|
|
||||||
const handleSearch = useCallback((newQuery: string) => {
|
const handleSearch = useCallback((newQuery: string) => {
|
||||||
if (prevSearchRef.current !== newQuery) {
|
if (prevSearchRef.current !== newQuery) {
|
||||||
prevSearchRef.current = newQuery;
|
prevSearchRef.current = newQuery;
|
||||||
setSearch(newQuery);
|
setSearch(newQuery);
|
||||||
setPage(1);
|
setCursor(undefined);
|
||||||
|
setCursorStack([]);
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return { search, page, setPage, handleSearch };
|
const goNext = useCallback((nextCursor: string | null | undefined) => {
|
||||||
|
if (nextCursor) {
|
||||||
|
setCursorStack((prev) => [...prev, cursor]);
|
||||||
|
setCursor(nextCursor);
|
||||||
|
}
|
||||||
|
}, [cursor]);
|
||||||
|
|
||||||
|
const goPrev = useCallback(() => {
|
||||||
|
setCursorStack((prev) => {
|
||||||
|
const next = prev.slice(0, -1);
|
||||||
|
setCursor(prev[prev.length - 1]);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return { search, cursor, goNext, goPrev, handleSearch };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
export interface QueryParams {
|
export interface QueryParams {
|
||||||
query?: string;
|
query?: string;
|
||||||
page?: number;
|
cursor?: string;
|
||||||
|
beforeCursor?: string;
|
||||||
limit?: number;
|
limit?: number;
|
||||||
adminView?: boolean;
|
adminView?: boolean;
|
||||||
}
|
}
|
||||||
@@ -29,9 +30,10 @@ export interface ApiResponse<T> {
|
|||||||
|
|
||||||
export type IPaginationMeta = {
|
export type IPaginationMeta = {
|
||||||
limit: number;
|
limit: number;
|
||||||
page: number;
|
|
||||||
hasNextPage: boolean;
|
hasNextPage: boolean;
|
||||||
hasPrevPage: boolean;
|
hasPrevPage: boolean;
|
||||||
|
nextCursor: string | null;
|
||||||
|
prevCursor: string | null;
|
||||||
};
|
};
|
||||||
export type IPagination<T> = {
|
export type IPagination<T> = {
|
||||||
items: T[];
|
items: T[];
|
||||||
|
|||||||
@@ -11,10 +11,10 @@ import useUserRole from "@/hooks/use-user-role";
|
|||||||
export default function Spaces() {
|
export default function Spaces() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { isAdmin } = useUserRole();
|
const { isAdmin } = useUserRole();
|
||||||
const { search, page, setPage, handleSearch } = usePaginateAndSearch();
|
const { search, cursor, goNext, goPrev, handleSearch } = usePaginateAndSearch();
|
||||||
|
|
||||||
const { data, isLoading } = useGetSpacesQuery({
|
const { data, isLoading } = useGetSpacesQuery({
|
||||||
page,
|
cursor,
|
||||||
limit: 30,
|
limit: 30,
|
||||||
query: search,
|
query: search,
|
||||||
});
|
});
|
||||||
@@ -41,10 +41,10 @@ export default function Spaces() {
|
|||||||
<AllSpacesList
|
<AllSpacesList
|
||||||
spaces={data?.items || []}
|
spaces={data?.items || []}
|
||||||
onSearch={handleSearch}
|
onSearch={handleSearch}
|
||||||
page={page}
|
|
||||||
hasPrevPage={data?.meta?.hasPrevPage}
|
hasPrevPage={data?.meta?.hasPrevPage}
|
||||||
hasNextPage={data?.meta?.hasNextPage}
|
hasNextPage={data?.meta?.hasNextPage}
|
||||||
onPageChange={setPage}
|
onNext={() => goNext(data?.meta?.nextCursor)}
|
||||||
|
onPrev={goPrev}
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
</Container>
|
</Container>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "server",
|
"name": "server",
|
||||||
"version": "0.24.1",
|
"version": "0.25.0-beta.1",
|
||||||
"description": "",
|
"description": "",
|
||||||
"author": "",
|
"author": "",
|
||||||
"private": true,
|
"private": true,
|
||||||
|
|||||||
@@ -79,6 +79,8 @@ export class RedisSyncExtension<TCE extends CustomEvents> implements Extension {
|
|||||||
this.customEvents = (customEvents as any) ?? ({} as any as CustomEvents);
|
this.customEvents = (customEvents as any) ?? ({} as any as CustomEvents);
|
||||||
this.sub.subscribe(this.msgChannel, `${this.msgChannel}:${this.serverId}`);
|
this.sub.subscribe(this.msgChannel, `${this.msgChannel}:${this.serverId}`);
|
||||||
this.sub.on('messageBuffer', this.handleRedisMessage);
|
this.sub.on('messageBuffer', this.handleRedisMessage);
|
||||||
|
this.pub.on('error', () => {});
|
||||||
|
this.sub.on('error', () => {});
|
||||||
}
|
}
|
||||||
private getKey(documentName: string) {
|
private getKey(documentName: string) {
|
||||||
return `${this.lockPrefix}:${documentName}`;
|
return `${this.lockPrefix}:${documentName}`;
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { OnEvent } from '@nestjs/event-emitter';
|
|||||||
import { PageHistoryRepo } from '@docmost/db/repos/page/page-history.repo';
|
import { PageHistoryRepo } from '@docmost/db/repos/page/page-history.repo';
|
||||||
import { Page } from '@docmost/db/types/entity.types';
|
import { Page } from '@docmost/db/types/entity.types';
|
||||||
import { isDeepStrictEqual } from 'node:util';
|
import { isDeepStrictEqual } from 'node:util';
|
||||||
|
import { EnvironmentService } from '../../integrations/environment/environment.service';
|
||||||
|
|
||||||
export class UpdatedPageEvent {
|
export class UpdatedPageEvent {
|
||||||
page: Page;
|
page: Page;
|
||||||
@@ -12,7 +13,10 @@ export class UpdatedPageEvent {
|
|||||||
export class HistoryListener {
|
export class HistoryListener {
|
||||||
private readonly logger = new Logger(HistoryListener.name);
|
private readonly logger = new Logger(HistoryListener.name);
|
||||||
|
|
||||||
constructor(private readonly pageHistoryRepo: PageHistoryRepo) {}
|
constructor(
|
||||||
|
private readonly pageHistoryRepo: PageHistoryRepo,
|
||||||
|
private readonly environmentService: EnvironmentService,
|
||||||
|
) {}
|
||||||
|
|
||||||
@OnEvent('collab.page.updated')
|
@OnEvent('collab.page.updated')
|
||||||
async handleCreatePageHistory(event: UpdatedPageEvent) {
|
async handleCreatePageHistory(event: UpdatedPageEvent) {
|
||||||
@@ -20,7 +24,9 @@ export class HistoryListener {
|
|||||||
|
|
||||||
const pageCreationTime = new Date(page.createdAt).getTime();
|
const pageCreationTime = new Date(page.createdAt).getTime();
|
||||||
const currentTime = Date.now();
|
const currentTime = Date.now();
|
||||||
const FIVE_MINUTES = 5 * 60 * 1000;
|
const FIVE_MINUTES = this.environmentService.isDevelopment()
|
||||||
|
? 60 * 1000
|
||||||
|
: 5 * 60 * 1000;
|
||||||
|
|
||||||
if (currentTime - pageCreationTime < FIVE_MINUTES) {
|
if (currentTime - pageCreationTime < FIVE_MINUTES) {
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -19,7 +19,8 @@ async function bootstrap() {
|
|||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
bufferLogs: true,
|
logger: false,
|
||||||
|
bufferLogs: false,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -36,7 +37,8 @@ async function bootstrap() {
|
|||||||
const logger = new Logger('CollabServer');
|
const logger = new Logger('CollabServer');
|
||||||
|
|
||||||
const port = process.env.COLLAB_PORT || 3001;
|
const port = process.env.COLLAB_PORT || 3001;
|
||||||
await app.listen(port, '0.0.0.0', () => {
|
const host = process.env.HOST || '0.0.0.0';
|
||||||
|
await app.listen(port, host, () => {
|
||||||
logger.log(`Listening on http://127.0.0.1:${port}`);
|
logger.log(`Listening on http://127.0.0.1:${port}`);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,16 +9,14 @@ import { UpdateCommentDto } from './dto/update-comment.dto';
|
|||||||
import { CommentRepo } from '@docmost/db/repos/comment/comment.repo';
|
import { CommentRepo } from '@docmost/db/repos/comment/comment.repo';
|
||||||
import { Comment, Page, User } from '@docmost/db/types/entity.types';
|
import { Comment, Page, User } from '@docmost/db/types/entity.types';
|
||||||
import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
|
import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
|
||||||
import { PaginationResult } from '@docmost/db/pagination/pagination';
|
|
||||||
import { PageRepo } from '@docmost/db/repos/page/page.repo';
|
import { PageRepo } from '@docmost/db/repos/page/page.repo';
|
||||||
import { SpaceMemberRepo } from '@docmost/db/repos/space/space-member.repo';
|
import { CursorPaginationResult } from '@docmost/db/pagination/cursor-pagination';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class CommentService {
|
export class CommentService {
|
||||||
constructor(
|
constructor(
|
||||||
private commentRepo: CommentRepo,
|
private commentRepo: CommentRepo,
|
||||||
private pageRepo: PageRepo,
|
private pageRepo: PageRepo,
|
||||||
private spaceMemberRepo: SpaceMemberRepo,
|
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async findById(commentId: string) {
|
async findById(commentId: string) {
|
||||||
@@ -68,14 +66,14 @@ export class CommentService {
|
|||||||
async findByPageId(
|
async findByPageId(
|
||||||
pageId: string,
|
pageId: string,
|
||||||
pagination: PaginationOptions,
|
pagination: PaginationOptions,
|
||||||
): Promise<PaginationResult<Comment>> {
|
): Promise<CursorPaginationResult<Comment>> {
|
||||||
const page = await this.pageRepo.findById(pageId);
|
const page = await this.pageRepo.findById(pageId);
|
||||||
|
|
||||||
if (!page) {
|
if (!page) {
|
||||||
throw new BadRequestException('Page not found');
|
throw new BadRequestException('Page not found');
|
||||||
}
|
}
|
||||||
|
|
||||||
return await this.commentRepo.findPageComments(pageId, pagination);
|
return this.commentRepo.findPageComments(pageId, pagination);
|
||||||
}
|
}
|
||||||
|
|
||||||
async update(
|
async update(
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import { UpdateGroupDto } from '../dto/update-group.dto';
|
|||||||
import { KyselyTransaction } from '@docmost/db/types/kysely.types';
|
import { KyselyTransaction } from '@docmost/db/types/kysely.types';
|
||||||
import { GroupRepo } from '@docmost/db/repos/group/group.repo';
|
import { GroupRepo } from '@docmost/db/repos/group/group.repo';
|
||||||
import { Group, InsertableGroup, User } from '@docmost/db/types/entity.types';
|
import { Group, InsertableGroup, User } from '@docmost/db/types/entity.types';
|
||||||
import { PaginationResult } from '@docmost/db/pagination/pagination';
|
import { CursorPaginationResult } from '@docmost/db/pagination/cursor-pagination';
|
||||||
import { GroupUserService } from './group-user.service';
|
import { GroupUserService } from './group-user.service';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@@ -132,12 +132,8 @@ export class GroupService {
|
|||||||
async getWorkspaceGroups(
|
async getWorkspaceGroups(
|
||||||
workspaceId: string,
|
workspaceId: string,
|
||||||
paginationOptions: PaginationOptions,
|
paginationOptions: PaginationOptions,
|
||||||
): Promise<PaginationResult<Group>> {
|
): Promise<CursorPaginationResult<Group>> {
|
||||||
const groups = await this.groupRepo.getGroupsPaginated(
|
return this.groupRepo.getGroupsPaginated(workspaceId, paginationOptions);
|
||||||
workspaceId,
|
|
||||||
paginationOptions,
|
|
||||||
);
|
|
||||||
return groups;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async deleteGroup(groupId: string, workspaceId: string): Promise<void> {
|
async deleteGroup(groupId: string, workspaceId: string): Promise<void> {
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { Injectable } from '@nestjs/common';
|
|||||||
import { PageHistoryRepo } from '@docmost/db/repos/page/page-history.repo';
|
import { PageHistoryRepo } from '@docmost/db/repos/page/page-history.repo';
|
||||||
import { PageHistory } from '@docmost/db/types/entity.types';
|
import { PageHistory } from '@docmost/db/types/entity.types';
|
||||||
import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
|
import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
|
||||||
import { PaginationResult } from '@docmost/db/pagination/pagination';
|
import { CursorPaginationResult } from '@docmost/db/pagination/cursor-pagination';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class PageHistoryService {
|
export class PageHistoryService {
|
||||||
@@ -15,12 +15,10 @@ export class PageHistoryService {
|
|||||||
async findHistoryByPageId(
|
async findHistoryByPageId(
|
||||||
pageId: string,
|
pageId: string,
|
||||||
paginationOptions: PaginationOptions,
|
paginationOptions: PaginationOptions,
|
||||||
): Promise<PaginationResult<any>> {
|
): Promise<CursorPaginationResult<PageHistory>> {
|
||||||
const pageHistory = await this.pageHistoryRepo.findPageHistoryByPageId(
|
return this.pageHistoryRepo.findPageHistoryByPageId(
|
||||||
pageId,
|
pageId,
|
||||||
paginationOptions,
|
paginationOptions,
|
||||||
);
|
);
|
||||||
|
|
||||||
return pageHistory;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,9 +10,9 @@ import { PageRepo } from '@docmost/db/repos/page/page.repo';
|
|||||||
import { InsertablePage, Page, User } from '@docmost/db/types/entity.types';
|
import { InsertablePage, Page, User } from '@docmost/db/types/entity.types';
|
||||||
import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
|
import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
|
||||||
import {
|
import {
|
||||||
executeWithPagination,
|
CursorPaginationResult,
|
||||||
PaginationResult,
|
executeWithCursorPagination,
|
||||||
} from '@docmost/db/pagination/pagination';
|
} from '@docmost/db/pagination/cursor-pagination';
|
||||||
import { InjectKysely } from 'nestjs-kysely';
|
import { InjectKysely } from 'nestjs-kysely';
|
||||||
import { KyselyDB } from '@docmost/db/types/kysely.types';
|
import { KyselyDB } from '@docmost/db/types/kysely.types';
|
||||||
import { generateJitteredKeyBetween } from 'fractional-indexing-jittered';
|
import { generateJitteredKeyBetween } from 'fractional-indexing-jittered';
|
||||||
@@ -180,7 +180,7 @@ export class PageService {
|
|||||||
spaceId: string,
|
spaceId: string,
|
||||||
pagination: PaginationOptions,
|
pagination: PaginationOptions,
|
||||||
pageId?: string,
|
pageId?: string,
|
||||||
): Promise<any> {
|
): Promise<CursorPaginationResult<Partial<Page> & { hasChildren: boolean }>> {
|
||||||
let query = this.db
|
let query = this.db
|
||||||
.selectFrom('pages')
|
.selectFrom('pages')
|
||||||
.select([
|
.select([
|
||||||
@@ -195,7 +195,6 @@ export class PageService {
|
|||||||
'deletedAt',
|
'deletedAt',
|
||||||
])
|
])
|
||||||
.select((eb) => this.pageRepo.withHasChildren(eb))
|
.select((eb) => this.pageRepo.withHasChildren(eb))
|
||||||
.orderBy('position', (ob) => ob.collate('C').asc())
|
|
||||||
.where('deletedAt', 'is', null)
|
.where('deletedAt', 'is', null)
|
||||||
.where('spaceId', '=', spaceId);
|
.where('spaceId', '=', spaceId);
|
||||||
|
|
||||||
@@ -205,12 +204,19 @@ export class PageService {
|
|||||||
query = query.where('parentPageId', 'is', null);
|
query = query.where('parentPageId', 'is', null);
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = executeWithPagination(query, {
|
return executeWithCursorPagination(query, {
|
||||||
page: pagination.page,
|
|
||||||
perPage: 250,
|
perPage: 250,
|
||||||
|
cursor: pagination.cursor,
|
||||||
|
beforeCursor: pagination.beforeCursor,
|
||||||
|
fields: [
|
||||||
|
{ expression: 'position', direction: 'asc', orderModifier: (ob) => ob.collate('C').asc() },
|
||||||
|
{ expression: 'id', direction: 'asc' },
|
||||||
|
],
|
||||||
|
parseCursor: (cursor) => ({
|
||||||
|
position: cursor.position,
|
||||||
|
id: cursor.id,
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async movePageToSpace(rootPage: Page, spaceId: string) {
|
async movePageToSpace(rootPage: Page, spaceId: string) {
|
||||||
@@ -259,7 +265,7 @@ export class PageService {
|
|||||||
|
|
||||||
await this.aiQueue.add(QueueJob.PAGE_MOVED_TO_SPACE, {
|
await this.aiQueue.add(QueueJob.PAGE_MOVED_TO_SPACE, {
|
||||||
pageId: pageIds,
|
pageId: pageIds,
|
||||||
workspaceId: rootPage.workspaceId
|
workspaceId: rootPage.workspaceId,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -387,9 +393,14 @@ export class PageService {
|
|||||||
workspaceId: page.workspaceId,
|
workspaceId: page.workspaceId,
|
||||||
creatorId: authUser.id,
|
creatorId: authUser.id,
|
||||||
lastUpdatedById: authUser.id,
|
lastUpdatedById: authUser.id,
|
||||||
parentPageId: page.id === rootPage.id
|
parentPageId:
|
||||||
? (isDuplicateInSameSpace ? rootPage.parentPageId : null)
|
page.id === rootPage.id
|
||||||
: (page.parentPageId ? pageMap.get(page.parentPageId)?.newPageId : null),
|
? isDuplicateInSameSpace
|
||||||
|
? rootPage.parentPageId
|
||||||
|
: null
|
||||||
|
: page.parentPageId
|
||||||
|
? pageMap.get(page.parentPageId)?.newPageId
|
||||||
|
: null,
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
@@ -569,22 +580,22 @@ export class PageService {
|
|||||||
async getRecentSpacePages(
|
async getRecentSpacePages(
|
||||||
spaceId: string,
|
spaceId: string,
|
||||||
pagination: PaginationOptions,
|
pagination: PaginationOptions,
|
||||||
): Promise<PaginationResult<Page>> {
|
): Promise<CursorPaginationResult<Page>> {
|
||||||
return await this.pageRepo.getRecentPagesInSpace(spaceId, pagination);
|
return this.pageRepo.getRecentPagesInSpace(spaceId, pagination);
|
||||||
}
|
}
|
||||||
|
|
||||||
async getRecentPages(
|
async getRecentPages(
|
||||||
userId: string,
|
userId: string,
|
||||||
pagination: PaginationOptions,
|
pagination: PaginationOptions,
|
||||||
): Promise<PaginationResult<Page>> {
|
): Promise<CursorPaginationResult<Page>> {
|
||||||
return await this.pageRepo.getRecentPages(userId, pagination);
|
return this.pageRepo.getRecentPages(userId, pagination);
|
||||||
}
|
}
|
||||||
|
|
||||||
async getDeletedSpacePages(
|
async getDeletedSpacePages(
|
||||||
spaceId: string,
|
spaceId: string,
|
||||||
pagination: PaginationOptions,
|
pagination: PaginationOptions,
|
||||||
): Promise<PaginationResult<Page>> {
|
): Promise<CursorPaginationResult<Page>> {
|
||||||
return await this.pageRepo.getDeletedPagesInSpace(spaceId, pagination);
|
return this.pageRepo.getDeletedPagesInSpace(spaceId, pagination);
|
||||||
}
|
}
|
||||||
|
|
||||||
async forceDelete(pageId: string, workspaceId: string): Promise<void> {
|
async forceDelete(pageId: string, workspaceId: string): Promise<void> {
|
||||||
|
|||||||
@@ -26,11 +26,11 @@ export class SearchService {
|
|||||||
userId?: string;
|
userId?: string;
|
||||||
workspaceId: string;
|
workspaceId: string;
|
||||||
},
|
},
|
||||||
): Promise<SearchResponseDto[]> {
|
): Promise<{ items: SearchResponseDto[] }> {
|
||||||
const { query } = searchParams;
|
const { query } = searchParams;
|
||||||
|
|
||||||
if (query.length < 1) {
|
if (query.length < 1) {
|
||||||
return;
|
return { items: [] };
|
||||||
}
|
}
|
||||||
const searchQuery = tsquery(query.trim() + '*');
|
const searchQuery = tsquery(query.trim() + '*');
|
||||||
|
|
||||||
@@ -62,7 +62,7 @@ export class SearchService {
|
|||||||
)
|
)
|
||||||
.where('deletedAt', 'is', null)
|
.where('deletedAt', 'is', null)
|
||||||
.orderBy('rank', 'desc')
|
.orderBy('rank', 'desc')
|
||||||
.limit(searchParams.limit | 25)
|
.limit(searchParams.limit || 25)
|
||||||
.offset(searchParams.offset || 0);
|
.offset(searchParams.offset || 0);
|
||||||
|
|
||||||
if (!searchParams.shareId) {
|
if (!searchParams.shareId) {
|
||||||
@@ -86,7 +86,7 @@ export class SearchService {
|
|||||||
const shareId = searchParams.shareId;
|
const shareId = searchParams.shareId;
|
||||||
const share = await this.shareRepo.findById(shareId);
|
const share = await this.shareRepo.findById(shareId);
|
||||||
if (!share || share.workspaceId !== opts.workspaceId) {
|
if (!share || share.workspaceId !== opts.workspaceId) {
|
||||||
return [];
|
return { items: [] };
|
||||||
}
|
}
|
||||||
|
|
||||||
const pageIdsToSearch = [];
|
const pageIdsToSearch = [];
|
||||||
@@ -108,10 +108,10 @@ export class SearchService {
|
|||||||
.where('id', 'in', pageIdsToSearch)
|
.where('id', 'in', pageIdsToSearch)
|
||||||
.where('workspaceId', '=', opts.workspaceId);
|
.where('workspaceId', '=', opts.workspaceId);
|
||||||
} else {
|
} else {
|
||||||
return [];
|
return { items: [] };
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
return [];
|
return { items: [] };
|
||||||
}
|
}
|
||||||
|
|
||||||
//@ts-ignore
|
//@ts-ignore
|
||||||
@@ -127,7 +127,7 @@ export class SearchService {
|
|||||||
return result;
|
return result;
|
||||||
});
|
});
|
||||||
|
|
||||||
return searchResults;
|
return { items: searchResults };
|
||||||
}
|
}
|
||||||
|
|
||||||
async searchSuggestions(
|
async searchSuggestions(
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import { SpaceRepo } from '@docmost/db/repos/space/space.repo';
|
|||||||
import { RemoveSpaceMemberDto } from '../dto/remove-space-member.dto';
|
import { RemoveSpaceMemberDto } from '../dto/remove-space-member.dto';
|
||||||
import { UpdateSpaceMemberRoleDto } from '../dto/update-space-member-role.dto';
|
import { UpdateSpaceMemberRoleDto } from '../dto/update-space-member-role.dto';
|
||||||
import { SpaceRole } from '../../../common/helpers/types/permission';
|
import { SpaceRole } from '../../../common/helpers/types/permission';
|
||||||
import { PaginationResult } from '@docmost/db/pagination/pagination';
|
import { CursorPaginationResult } from '@docmost/db/pagination/cursor-pagination';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class SpaceMemberService {
|
export class SpaceMemberService {
|
||||||
@@ -68,18 +68,16 @@ export class SpaceMemberService {
|
|||||||
spaceId: string,
|
spaceId: string,
|
||||||
workspaceId: string,
|
workspaceId: string,
|
||||||
pagination: PaginationOptions,
|
pagination: PaginationOptions,
|
||||||
) {
|
): Promise<CursorPaginationResult<any>> {
|
||||||
const space = await this.spaceRepo.findById(spaceId, workspaceId);
|
const space = await this.spaceRepo.findById(spaceId, workspaceId);
|
||||||
if (!space) {
|
if (!space) {
|
||||||
throw new NotFoundException('Space not found');
|
throw new NotFoundException('Space not found');
|
||||||
}
|
}
|
||||||
|
|
||||||
const members = await this.spaceMemberRepo.getSpaceMembersPaginated(
|
return await this.spaceMemberRepo.getSpaceMembersPaginated(
|
||||||
spaceId,
|
spaceId,
|
||||||
pagination,
|
pagination,
|
||||||
);
|
);
|
||||||
|
|
||||||
return members;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async addMembersToSpaceBatch(
|
async addMembersToSpaceBatch(
|
||||||
@@ -276,7 +274,7 @@ export class SpaceMemberService {
|
|||||||
async getUserSpaces(
|
async getUserSpaces(
|
||||||
userId: string,
|
userId: string,
|
||||||
pagination: PaginationOptions,
|
pagination: PaginationOptions,
|
||||||
): Promise<PaginationResult<Space>> {
|
): Promise<CursorPaginationResult<Space>> {
|
||||||
return await this.spaceMemberRepo.getUserSpaces(userId, pagination);
|
return this.spaceMemberRepo.getUserSpaces(userId, pagination);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
|
|||||||
import { SpaceRepo } from '@docmost/db/repos/space/space.repo';
|
import { SpaceRepo } from '@docmost/db/repos/space/space.repo';
|
||||||
import { KyselyDB, KyselyTransaction } from '@docmost/db/types/kysely.types';
|
import { KyselyDB, KyselyTransaction } from '@docmost/db/types/kysely.types';
|
||||||
import { Space, User } from '@docmost/db/types/entity.types';
|
import { Space, User } from '@docmost/db/types/entity.types';
|
||||||
import { PaginationResult } from '@docmost/db/pagination/pagination';
|
|
||||||
import { UpdateSpaceDto } from '../dto/update-space.dto';
|
import { UpdateSpaceDto } from '../dto/update-space.dto';
|
||||||
import { executeTx } from '@docmost/db/utils';
|
import { executeTx } from '@docmost/db/utils';
|
||||||
import { InjectKysely } from 'nestjs-kysely';
|
import { InjectKysely } from 'nestjs-kysely';
|
||||||
@@ -17,6 +16,7 @@ import { SpaceRole } from '../../../common/helpers/types/permission';
|
|||||||
import { QueueJob, QueueName } from 'src/integrations/queue/constants';
|
import { QueueJob, QueueName } from 'src/integrations/queue/constants';
|
||||||
import { Queue } from 'bullmq';
|
import { Queue } from 'bullmq';
|
||||||
import { InjectQueue } from '@nestjs/bullmq';
|
import { InjectQueue } from '@nestjs/bullmq';
|
||||||
|
import { CursorPaginationResult } from '@docmost/db/pagination/cursor-pagination';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class SpaceService {
|
export class SpaceService {
|
||||||
@@ -130,13 +130,8 @@ export class SpaceService {
|
|||||||
async getWorkspaceSpaces(
|
async getWorkspaceSpaces(
|
||||||
workspaceId: string,
|
workspaceId: string,
|
||||||
pagination: PaginationOptions,
|
pagination: PaginationOptions,
|
||||||
): Promise<PaginationResult<Space>> {
|
): Promise<CursorPaginationResult<Space>> {
|
||||||
const spaces = await this.spaceRepo.getSpacesInWorkspace(
|
return this.spaceRepo.getSpacesInWorkspace(workspaceId, pagination);
|
||||||
workspaceId,
|
|
||||||
pagination,
|
|
||||||
);
|
|
||||||
|
|
||||||
return spaces;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async deleteSpace(spaceId: string, workspaceId: string): Promise<void> {
|
async deleteSpace(spaceId: string, workspaceId: string): Promise<void> {
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ import InvitationAcceptedEmail from '@docmost/transactional/emails/invitation-ac
|
|||||||
import { TokenService } from '../../auth/services/token.service';
|
import { TokenService } from '../../auth/services/token.service';
|
||||||
import { nanoIdGen } from '../../../common/helpers';
|
import { nanoIdGen } from '../../../common/helpers';
|
||||||
import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
|
import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
|
||||||
import { executeWithPagination } from '@docmost/db/pagination/pagination';
|
import { executeWithCursorPagination } from '@docmost/db/pagination/cursor-pagination';
|
||||||
import { DomainService } from 'src/integrations/environment/domain.service';
|
import { DomainService } from 'src/integrations/environment/domain.service';
|
||||||
import { InjectQueue } from '@nestjs/bullmq';
|
import { InjectQueue } from '@nestjs/bullmq';
|
||||||
import { QueueJob, QueueName } from '../../../integrations/queue/constants';
|
import { QueueJob, QueueName } from '../../../integrations/queue/constants';
|
||||||
@@ -64,12 +64,13 @@ export class WorkspaceInvitationService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = executeWithPagination(query, {
|
return executeWithCursorPagination(query, {
|
||||||
page: pagination.page,
|
|
||||||
perPage: pagination.limit,
|
perPage: pagination.limit,
|
||||||
|
cursor: pagination.cursor,
|
||||||
|
beforeCursor: pagination.beforeCursor,
|
||||||
|
fields: [{ expression: 'id', direction: 'asc' }],
|
||||||
|
parseCursor: (cursor) => ({ id: cursor.id }),
|
||||||
});
|
});
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async getInvitationById(invitationId: string, workspace: Workspace) {
|
async getInvitationById(invitationId: string, workspace: Workspace) {
|
||||||
|
|||||||
@@ -19,7 +19,6 @@ import { User } from '@docmost/db/types/entity.types';
|
|||||||
import { GroupUserRepo } from '@docmost/db/repos/group/group-user.repo';
|
import { GroupUserRepo } from '@docmost/db/repos/group/group-user.repo';
|
||||||
import { GroupRepo } from '@docmost/db/repos/group/group.repo';
|
import { GroupRepo } from '@docmost/db/repos/group/group.repo';
|
||||||
import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
|
import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
|
||||||
import { PaginationResult } from '@docmost/db/pagination/pagination';
|
|
||||||
import { UpdateWorkspaceUserRoleDto } from '../dto/update-workspace-user-role.dto';
|
import { UpdateWorkspaceUserRoleDto } from '../dto/update-workspace-user-role.dto';
|
||||||
import { UserRepo } from '@docmost/db/repos/user/user.repo';
|
import { UserRepo } from '@docmost/db/repos/user/user.repo';
|
||||||
import { EnvironmentService } from '../../../integrations/environment/environment.service';
|
import { EnvironmentService } from '../../../integrations/environment/environment.service';
|
||||||
@@ -28,12 +27,12 @@ import { jsonArrayFrom } from 'kysely/helpers/postgres';
|
|||||||
import { addDays } from 'date-fns';
|
import { addDays } from 'date-fns';
|
||||||
import { DISALLOWED_HOSTNAMES, WorkspaceStatus } from '../workspace.constants';
|
import { DISALLOWED_HOSTNAMES, WorkspaceStatus } from '../workspace.constants';
|
||||||
import { v4 } from 'uuid';
|
import { v4 } from 'uuid';
|
||||||
import { AttachmentType } from 'src/core/attachment/attachment.constants';
|
|
||||||
import { InjectQueue } from '@nestjs/bullmq';
|
import { InjectQueue } from '@nestjs/bullmq';
|
||||||
import { QueueJob, QueueName } from '../../../integrations/queue/constants';
|
import { QueueJob, QueueName } from '../../../integrations/queue/constants';
|
||||||
import { Queue } from 'bullmq';
|
import { Queue } from 'bullmq';
|
||||||
import { generateRandomSuffixNumbers } from '../../../common/helpers';
|
import { generateRandomSuffixNumbers } from '../../../common/helpers';
|
||||||
import { isPageEmbeddingsTableExists } from '@docmost/db/helpers/helpers';
|
import { isPageEmbeddingsTableExists } from '@docmost/db/helpers/helpers';
|
||||||
|
import { CursorPaginationResult } from '@docmost/db/pagination/cursor-pagination';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class WorkspaceService {
|
export class WorkspaceService {
|
||||||
@@ -376,13 +375,8 @@ export class WorkspaceService {
|
|||||||
async getWorkspaceUsers(
|
async getWorkspaceUsers(
|
||||||
workspaceId: string,
|
workspaceId: string,
|
||||||
pagination: PaginationOptions,
|
pagination: PaginationOptions,
|
||||||
): Promise<PaginationResult<User>> {
|
): Promise<CursorPaginationResult<User>> {
|
||||||
const users = await this.userRepo.getUsersPaginated(
|
return this.userRepo.getUsersPaginated(workspaceId, pagination);
|
||||||
workspaceId,
|
|
||||||
pagination,
|
|
||||||
);
|
|
||||||
|
|
||||||
return users;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async updateWorkspaceUserRole(
|
async updateWorkspaceUserRole(
|
||||||
|
|||||||
@@ -0,0 +1,348 @@
|
|||||||
|
// adapted from https://github.com/charlie-hadden/kysely-paginate/blob/main/src/cursor.ts - MIT
|
||||||
|
import {
|
||||||
|
OrderByDirection,
|
||||||
|
OrderByModifiers,
|
||||||
|
ReferenceExpression,
|
||||||
|
SelectQueryBuilder,
|
||||||
|
StringReference,
|
||||||
|
} from 'kysely';
|
||||||
|
|
||||||
|
type SortField<DB, TB extends keyof DB, O> =
|
||||||
|
| {
|
||||||
|
expression:
|
||||||
|
| (StringReference<DB, TB> & keyof O & string)
|
||||||
|
| (StringReference<DB, TB> & `${string}.${keyof O & string}`);
|
||||||
|
direction: OrderByDirection;
|
||||||
|
orderModifier?: OrderByModifiers;
|
||||||
|
key?: keyof O & string;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
expression: ReferenceExpression<DB, TB>;
|
||||||
|
direction: OrderByDirection;
|
||||||
|
orderModifier?: OrderByModifiers;
|
||||||
|
key: keyof O & string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ExtractSortFieldKey<
|
||||||
|
DB,
|
||||||
|
TB extends keyof DB,
|
||||||
|
O,
|
||||||
|
T extends SortField<DB, TB, O>,
|
||||||
|
> = T['key'] extends keyof O & string
|
||||||
|
? T['key']
|
||||||
|
: T['expression'] extends keyof O & string
|
||||||
|
? T['expression']
|
||||||
|
: T['expression'] extends `${string}.${infer K}`
|
||||||
|
? K extends keyof O & string
|
||||||
|
? K
|
||||||
|
: never
|
||||||
|
: never;
|
||||||
|
|
||||||
|
type Fields<DB, TB extends keyof DB, O> = ReadonlyArray<
|
||||||
|
Readonly<SortField<DB, TB, O>>
|
||||||
|
>;
|
||||||
|
|
||||||
|
type FieldNames<DB, TB extends keyof DB, O, T extends Fields<DB, TB, O>> = {
|
||||||
|
[TIndex in keyof T]: ExtractSortFieldKey<DB, TB, O, T[TIndex]>;
|
||||||
|
};
|
||||||
|
|
||||||
|
type EncodeCursorValues<
|
||||||
|
DB,
|
||||||
|
TB extends keyof DB,
|
||||||
|
O,
|
||||||
|
T extends Fields<DB, TB, O>,
|
||||||
|
> = {
|
||||||
|
[TIndex in keyof T]: [
|
||||||
|
ExtractSortFieldKey<DB, TB, O, T[TIndex]>,
|
||||||
|
O[ExtractSortFieldKey<DB, TB, O, T[TIndex]>],
|
||||||
|
];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CursorEncoder<
|
||||||
|
DB,
|
||||||
|
TB extends keyof DB,
|
||||||
|
O,
|
||||||
|
T extends Fields<DB, TB, O>,
|
||||||
|
> = (values: EncodeCursorValues<DB, TB, O, T>) => string;
|
||||||
|
|
||||||
|
type DecodedCursor<DB, TB extends keyof DB, O, T extends Fields<DB, TB, O>> = {
|
||||||
|
[TField in ExtractSortFieldKey<DB, TB, O, T[number]>]: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CursorDecoder<
|
||||||
|
DB,
|
||||||
|
TB extends keyof DB,
|
||||||
|
O,
|
||||||
|
T extends Fields<DB, TB, O>,
|
||||||
|
> = (
|
||||||
|
cursor: string,
|
||||||
|
fields: FieldNames<DB, TB, O, T>,
|
||||||
|
) => DecodedCursor<DB, TB, O, T>;
|
||||||
|
|
||||||
|
type ParsedCursorValues<
|
||||||
|
DB,
|
||||||
|
TB extends keyof DB,
|
||||||
|
O,
|
||||||
|
T extends Fields<DB, TB, O>,
|
||||||
|
> = {
|
||||||
|
[TField in ExtractSortFieldKey<DB, TB, O, T[number]>]: O[TField];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CursorParser<
|
||||||
|
DB,
|
||||||
|
TB extends keyof DB,
|
||||||
|
O,
|
||||||
|
T extends Fields<DB, TB, O>,
|
||||||
|
> = (cursor: DecodedCursor<DB, TB, O, T>) => ParsedCursorValues<DB, TB, O, T>;
|
||||||
|
|
||||||
|
type CursorPaginationResultRow<
|
||||||
|
TRow,
|
||||||
|
TCursorKey extends string | boolean | undefined,
|
||||||
|
> = TRow & {
|
||||||
|
[K in TCursorKey extends undefined
|
||||||
|
? never
|
||||||
|
: TCursorKey extends false
|
||||||
|
? never
|
||||||
|
: TCursorKey extends true
|
||||||
|
? '$cursor'
|
||||||
|
: TCursorKey]: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type CursorPaginationMeta = {
|
||||||
|
limit: number;
|
||||||
|
hasNextPage: boolean;
|
||||||
|
hasPrevPage: boolean;
|
||||||
|
nextCursor: string | null;
|
||||||
|
prevCursor: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CursorPaginationResult<
|
||||||
|
TRow,
|
||||||
|
TCursorKey extends string | boolean | undefined = undefined,
|
||||||
|
> = {
|
||||||
|
meta: CursorPaginationMeta;
|
||||||
|
items: CursorPaginationResultRow<TRow, TCursorKey>[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function executeWithCursorPagination<
|
||||||
|
DB,
|
||||||
|
TB extends keyof DB,
|
||||||
|
O,
|
||||||
|
const TFields extends Fields<DB, TB, O>,
|
||||||
|
TCursorKey extends string | boolean | undefined = undefined,
|
||||||
|
>(
|
||||||
|
qb: SelectQueryBuilder<DB, TB, O>,
|
||||||
|
opts: {
|
||||||
|
perPage: number;
|
||||||
|
cursor?: string;
|
||||||
|
beforeCursor?: string;
|
||||||
|
cursorPerRow?: TCursorKey;
|
||||||
|
fields: TFields;
|
||||||
|
encodeCursor?: CursorEncoder<DB, TB, O, TFields>;
|
||||||
|
decodeCursor?: CursorDecoder<DB, TB, O, TFields>;
|
||||||
|
parseCursor:
|
||||||
|
| CursorParser<DB, TB, O, TFields>
|
||||||
|
| { parse: CursorParser<DB, TB, O, TFields> };
|
||||||
|
},
|
||||||
|
): Promise<CursorPaginationResult<O, TCursorKey>> {
|
||||||
|
const encodeCursor = opts.encodeCursor ?? defaultEncodeCursor;
|
||||||
|
const decodeCursor = opts.decodeCursor ?? defaultDecodeCursor;
|
||||||
|
|
||||||
|
const parseCursor =
|
||||||
|
typeof opts.parseCursor === 'function'
|
||||||
|
? opts.parseCursor
|
||||||
|
: opts.parseCursor.parse;
|
||||||
|
|
||||||
|
const fields = opts.fields.map((field) => {
|
||||||
|
let key = field.key;
|
||||||
|
|
||||||
|
if (!key && typeof field.expression === 'string') {
|
||||||
|
const expressionParts = field.expression.split('.');
|
||||||
|
|
||||||
|
key = (expressionParts[1] ?? expressionParts[0]) as
|
||||||
|
| (keyof O & string)
|
||||||
|
| undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!key) throw new Error('missing key');
|
||||||
|
|
||||||
|
return { ...field, key };
|
||||||
|
});
|
||||||
|
|
||||||
|
function generateCursor(row: O): string {
|
||||||
|
const cursorFieldValues = fields.map(({ key }) => [
|
||||||
|
key,
|
||||||
|
row[key],
|
||||||
|
]) as EncodeCursorValues<DB, TB, O, TFields>;
|
||||||
|
|
||||||
|
return encodeCursor(cursorFieldValues);
|
||||||
|
}
|
||||||
|
|
||||||
|
const fieldNames = fields.map((field) => field.key) as FieldNames<
|
||||||
|
DB,
|
||||||
|
TB,
|
||||||
|
O,
|
||||||
|
TFields
|
||||||
|
>;
|
||||||
|
|
||||||
|
function applyCursor(
|
||||||
|
qb: SelectQueryBuilder<DB, TB, O>,
|
||||||
|
encoded: string,
|
||||||
|
defaultDirection: OrderByDirection,
|
||||||
|
) {
|
||||||
|
const decoded = decodeCursor(encoded, fieldNames);
|
||||||
|
const cursor = parseCursor(decoded);
|
||||||
|
|
||||||
|
return qb.where(({ and, or, eb }) => {
|
||||||
|
let expression;
|
||||||
|
|
||||||
|
for (let i = fields.length - 1; i >= 0; --i) {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||||
|
const field = fields[i]!;
|
||||||
|
|
||||||
|
const comparison = field.direction === defaultDirection ? '>' : '<';
|
||||||
|
const value = cursor[field.key as keyof typeof cursor];
|
||||||
|
|
||||||
|
const conditions = [eb(field.expression, comparison, value)];
|
||||||
|
|
||||||
|
if (expression) {
|
||||||
|
conditions.push(and([eb(field.expression, '=', value), expression]));
|
||||||
|
}
|
||||||
|
|
||||||
|
expression = or(conditions);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!expression) {
|
||||||
|
throw new Error('Error building cursor expression');
|
||||||
|
}
|
||||||
|
|
||||||
|
return expression;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (opts.cursor) qb = applyCursor(qb, opts.cursor, 'asc');
|
||||||
|
if (opts.beforeCursor) qb = applyCursor(qb, opts.beforeCursor, 'desc');
|
||||||
|
|
||||||
|
const reversed = !!opts.beforeCursor && !opts.cursor;
|
||||||
|
|
||||||
|
for (const { expression, direction, orderModifier } of fields) {
|
||||||
|
qb = qb.orderBy(
|
||||||
|
expression,
|
||||||
|
orderModifier ??
|
||||||
|
(reversed ? (direction === 'asc' ? 'desc' : 'asc') : direction),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = await qb.limit(opts.perPage + 1).execute();
|
||||||
|
|
||||||
|
const hasNextPage = rows.length > opts.perPage;
|
||||||
|
|
||||||
|
// If we fetched an extra row to determine if we have a next page, that
|
||||||
|
// shouldn't be in the returned results
|
||||||
|
if (rows.length > opts.perPage) rows.pop();
|
||||||
|
|
||||||
|
if (reversed) rows.reverse();
|
||||||
|
|
||||||
|
const startRow = rows[0];
|
||||||
|
const endRow = rows[rows.length - 1];
|
||||||
|
|
||||||
|
const hasPrevPage = !!opts.cursor;
|
||||||
|
const prevCursor = hasPrevPage && startRow ? generateCursor(startRow) : null;
|
||||||
|
const nextCursor = hasNextPage && endRow ? generateCursor(endRow) : null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
items: rows.map((row) => {
|
||||||
|
if (opts.cursorPerRow) {
|
||||||
|
const cursorKey =
|
||||||
|
typeof opts.cursorPerRow === 'string' ? opts.cursorPerRow : '$cursor';
|
||||||
|
|
||||||
|
(row as any)[cursorKey] = generateCursor(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
return row as CursorPaginationResultRow<O, TCursorKey>;
|
||||||
|
}),
|
||||||
|
meta: {
|
||||||
|
limit: opts.perPage,
|
||||||
|
hasNextPage,
|
||||||
|
hasPrevPage,
|
||||||
|
nextCursor,
|
||||||
|
prevCursor,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function defaultEncodeCursor<
|
||||||
|
DB,
|
||||||
|
TB extends keyof DB,
|
||||||
|
O,
|
||||||
|
T extends Fields<DB, TB, O>,
|
||||||
|
>(values: EncodeCursorValues<DB, TB, O, T>) {
|
||||||
|
const cursor = new URLSearchParams();
|
||||||
|
|
||||||
|
for (const [key, value] of values) {
|
||||||
|
switch (typeof value) {
|
||||||
|
case 'string':
|
||||||
|
cursor.set(key, value);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'number':
|
||||||
|
case 'bigint':
|
||||||
|
cursor.set(key, value.toString(10));
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'object': {
|
||||||
|
if (value instanceof Date) {
|
||||||
|
cursor.set(key, value.toISOString());
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// eslint-disable-next-line no-fallthrough
|
||||||
|
default:
|
||||||
|
throw new Error(`Unable to encode '${key.toString()}'`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Buffer.from(cursor.toString(), 'utf8').toString('base64url');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function defaultDecodeCursor<
|
||||||
|
DB,
|
||||||
|
TB extends keyof DB,
|
||||||
|
O,
|
||||||
|
T extends Fields<DB, TB, O>,
|
||||||
|
>(
|
||||||
|
cursor: string,
|
||||||
|
fields: FieldNames<DB, TB, O, T>,
|
||||||
|
): DecodedCursor<DB, TB, O, T> {
|
||||||
|
let parsed;
|
||||||
|
|
||||||
|
try {
|
||||||
|
parsed = [
|
||||||
|
...new URLSearchParams(
|
||||||
|
Buffer.from(cursor, 'base64url').toString('utf8'),
|
||||||
|
).entries(),
|
||||||
|
];
|
||||||
|
} catch {
|
||||||
|
throw new Error('Unparsable cursor');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parsed.length !== fields.length) {
|
||||||
|
throw new Error('Unexpected number of fields');
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let i = 0; i < fields.length; i++) {
|
||||||
|
const field = parsed[i];
|
||||||
|
const expectedName = fields[i];
|
||||||
|
|
||||||
|
if (!field) {
|
||||||
|
throw new Error('Unable to find field');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (field[0] !== expectedName) {
|
||||||
|
throw new Error('Unexpected field name');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Object.fromEntries(parsed) as DecodedCursor<DB, TB, O, T>;
|
||||||
|
}
|
||||||
@@ -9,11 +9,6 @@ import {
|
|||||||
} from 'class-validator';
|
} from 'class-validator';
|
||||||
|
|
||||||
export class PaginationOptions {
|
export class PaginationOptions {
|
||||||
@IsOptional()
|
|
||||||
@IsNumber()
|
|
||||||
@Min(1)
|
|
||||||
page = 1;
|
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsNumber()
|
@IsNumber()
|
||||||
@IsPositive()
|
@IsPositive()
|
||||||
@@ -21,6 +16,14 @@ export class PaginationOptions {
|
|||||||
@Max(100)
|
@Max(100)
|
||||||
limit = 20;
|
limit = 20;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
cursor?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
beforeCursor?: string;
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
query: string;
|
query: string;
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import {
|
|||||||
UpdatableComment,
|
UpdatableComment,
|
||||||
} from '@docmost/db/types/entity.types';
|
} from '@docmost/db/types/entity.types';
|
||||||
import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
|
import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
|
||||||
import { executeWithPagination } from '@docmost/db/pagination/pagination';
|
import { executeWithCursorPagination } from '@docmost/db/pagination/cursor-pagination';
|
||||||
import { ExpressionBuilder } from 'kysely';
|
import { ExpressionBuilder } from 'kysely';
|
||||||
import { DB } from '@docmost/db/types/db';
|
import { DB } from '@docmost/db/types/db';
|
||||||
import { jsonObjectFrom } from 'kysely/helpers/postgres';
|
import { jsonObjectFrom } from 'kysely/helpers/postgres';
|
||||||
@@ -37,15 +37,15 @@ export class CommentRepo {
|
|||||||
.selectAll('comments')
|
.selectAll('comments')
|
||||||
.select((eb) => this.withCreator(eb))
|
.select((eb) => this.withCreator(eb))
|
||||||
.select((eb) => this.withResolvedBy(eb))
|
.select((eb) => this.withResolvedBy(eb))
|
||||||
.where('pageId', '=', pageId)
|
.where('pageId', '=', pageId);
|
||||||
.orderBy('createdAt', 'asc');
|
|
||||||
|
|
||||||
const result = executeWithPagination(query, {
|
return executeWithCursorPagination(query, {
|
||||||
page: pagination.page,
|
|
||||||
perPage: pagination.limit,
|
perPage: pagination.limit,
|
||||||
|
cursor: pagination.cursor,
|
||||||
|
beforeCursor: pagination.beforeCursor,
|
||||||
|
fields: [{ expression: 'id', direction: 'asc' }],
|
||||||
|
parseCursor: (cursor) => ({ id: cursor.id }),
|
||||||
});
|
});
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async updateComment(
|
async updateComment(
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import { dbOrTx, executeTx } from '@docmost/db/utils';
|
|||||||
import { sql } from 'kysely';
|
import { sql } from 'kysely';
|
||||||
import { GroupUser, InsertableGroupUser } from '@docmost/db/types/entity.types';
|
import { GroupUser, InsertableGroupUser } from '@docmost/db/types/entity.types';
|
||||||
import { PaginationOptions } from '../../pagination/pagination-options';
|
import { PaginationOptions } from '../../pagination/pagination-options';
|
||||||
import { executeWithPagination } from '@docmost/db/pagination/pagination';
|
import { executeWithCursorPagination } from '@docmost/db/pagination/cursor-pagination';
|
||||||
import { GroupRepo } from '@docmost/db/repos/group/group.repo';
|
import { GroupRepo } from '@docmost/db/repos/group/group.repo';
|
||||||
import { UserRepo } from '@docmost/db/repos/user/user.repo';
|
import { UserRepo } from '@docmost/db/repos/user/user.repo';
|
||||||
|
|
||||||
@@ -52,8 +52,7 @@ export class GroupUserRepo {
|
|||||||
.selectFrom('groupUsers')
|
.selectFrom('groupUsers')
|
||||||
.innerJoin('users', 'users.id', 'groupUsers.userId')
|
.innerJoin('users', 'users.id', 'groupUsers.userId')
|
||||||
.selectAll('users')
|
.selectAll('users')
|
||||||
.where('groupId', '=', groupId)
|
.where('groupId', '=', groupId);
|
||||||
.orderBy('createdAt', 'asc');
|
|
||||||
|
|
||||||
if (pagination.query) {
|
if (pagination.query) {
|
||||||
query = query.where((eb) =>
|
query = query.where((eb) =>
|
||||||
@@ -61,9 +60,12 @@ export class GroupUserRepo {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await executeWithPagination(query, {
|
const result = await executeWithCursorPagination(query, {
|
||||||
page: pagination.page,
|
|
||||||
perPage: pagination.limit,
|
perPage: pagination.limit,
|
||||||
|
cursor: pagination.cursor,
|
||||||
|
beforeCursor: pagination.beforeCursor,
|
||||||
|
fields: [{ expression: 'users.id', direction: 'asc', key: 'id' }],
|
||||||
|
parseCursor: (cursor) => ({ id: cursor.id }),
|
||||||
});
|
});
|
||||||
|
|
||||||
result.items.map((user) => {
|
result.items.map((user) => {
|
||||||
|
|||||||
@@ -10,8 +10,8 @@ import {
|
|||||||
import { ExpressionBuilder, sql } from 'kysely';
|
import { ExpressionBuilder, sql } from 'kysely';
|
||||||
import { PaginationOptions } from '../../pagination/pagination-options';
|
import { PaginationOptions } from '../../pagination/pagination-options';
|
||||||
import { DB } from '@docmost/db/types/db';
|
import { DB } from '@docmost/db/types/db';
|
||||||
import { executeWithPagination } from '@docmost/db/pagination/pagination';
|
|
||||||
import { DefaultGroup } from '../../../core/group/dto/create-group.dto';
|
import { DefaultGroup } from '../../../core/group/dto/create-group.dto';
|
||||||
|
import { executeWithCursorPagination } from '@docmost/db/pagination/cursor-pagination';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class GroupRepo {
|
export class GroupRepo {
|
||||||
@@ -104,17 +104,19 @@ export class GroupRepo {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async getGroupsPaginated(workspaceId: string, pagination: PaginationOptions) {
|
async getGroupsPaginated(workspaceId: string, pagination: PaginationOptions) {
|
||||||
let query = this.db
|
let baseQuery = this.db
|
||||||
.selectFrom('groups')
|
.selectFrom('groups')
|
||||||
.selectAll('groups')
|
.selectAll('groups')
|
||||||
.select((eb) => this.withMemberCount(eb))
|
.select((eb) => this.withMemberCount(eb))
|
||||||
.where('workspaceId', '=', workspaceId)
|
.where('workspaceId', '=', workspaceId);
|
||||||
.orderBy('memberCount', 'desc')
|
|
||||||
.orderBy('createdAt', 'asc');
|
|
||||||
|
|
||||||
if (pagination.query) {
|
if (pagination.query) {
|
||||||
query = query.where((eb) =>
|
baseQuery = baseQuery.where((eb) =>
|
||||||
eb(sql`f_unaccent(name)`, 'ilike', sql`f_unaccent(${'%' + pagination.query + '%'})`).or(
|
eb(
|
||||||
|
sql`f_unaccent(name)`,
|
||||||
|
'ilike',
|
||||||
|
sql`f_unaccent(${'%' + pagination.query + '%'})`,
|
||||||
|
).or(
|
||||||
sql`f_unaccent(description)`,
|
sql`f_unaccent(description)`,
|
||||||
'ilike',
|
'ilike',
|
||||||
sql`f_unaccent(${'%' + pagination.query + '%'})`,
|
sql`f_unaccent(${'%' + pagination.query + '%'})`,
|
||||||
@@ -122,12 +124,24 @@ export class GroupRepo {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = executeWithPagination(query, {
|
const query = this.db.selectFrom(baseQuery.as('sub')).selectAll('sub');
|
||||||
page: pagination.page,
|
return executeWithCursorPagination(query, {
|
||||||
perPage: pagination.limit,
|
perPage: pagination.limit,
|
||||||
|
cursor: pagination.cursor,
|
||||||
|
beforeCursor: pagination.beforeCursor,
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
expression: 'sub.memberCount',
|
||||||
|
direction: 'desc',
|
||||||
|
key: 'memberCount',
|
||||||
|
},
|
||||||
|
{ expression: 'sub.id', direction: 'asc', key: 'id' },
|
||||||
|
],
|
||||||
|
parseCursor: (cursor) => ({
|
||||||
|
memberCount: parseInt(cursor.memberCount, 10),
|
||||||
|
id: cursor.id,
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
withMemberCount(eb: ExpressionBuilder<DB, 'groups'>) {
|
withMemberCount(eb: ExpressionBuilder<DB, 'groups'>) {
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import {
|
|||||||
PageHistory,
|
PageHistory,
|
||||||
} from '@docmost/db/types/entity.types';
|
} from '@docmost/db/types/entity.types';
|
||||||
import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
|
import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
|
||||||
import { executeWithPagination } from '@docmost/db/pagination/pagination';
|
import { executeWithCursorPagination } from '@docmost/db/pagination/cursor-pagination';
|
||||||
import { jsonObjectFrom } from 'kysely/helpers/postgres';
|
import { jsonObjectFrom } from 'kysely/helpers/postgres';
|
||||||
import { ExpressionBuilder } from 'kysely';
|
import { ExpressionBuilder } from 'kysely';
|
||||||
import { DB } from '@docmost/db/types/db';
|
import { DB } from '@docmost/db/types/db';
|
||||||
@@ -65,15 +65,15 @@ export class PageHistoryRepo {
|
|||||||
.selectFrom('pageHistory')
|
.selectFrom('pageHistory')
|
||||||
.selectAll()
|
.selectAll()
|
||||||
.select((eb) => this.withLastUpdatedBy(eb))
|
.select((eb) => this.withLastUpdatedBy(eb))
|
||||||
.where('pageId', '=', pageId)
|
.where('pageId', '=', pageId);
|
||||||
.orderBy('createdAt', 'desc');
|
|
||||||
|
|
||||||
const result = executeWithPagination(query, {
|
return executeWithCursorPagination(query, {
|
||||||
page: pagination.page,
|
|
||||||
perPage: pagination.limit,
|
perPage: pagination.limit,
|
||||||
|
cursor: pagination.cursor,
|
||||||
|
beforeCursor: pagination.beforeCursor,
|
||||||
|
fields: [{ expression: 'id', direction: 'desc' }],
|
||||||
|
parseCursor: (cursor) => ({ id: cursor.id }),
|
||||||
});
|
});
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async findPageLastHistory(pageId: string, trx?: KyselyTransaction) {
|
async findPageLastHistory(pageId: string, trx?: KyselyTransaction) {
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import {
|
|||||||
UpdatablePage,
|
UpdatablePage,
|
||||||
} from '@docmost/db/types/entity.types';
|
} from '@docmost/db/types/entity.types';
|
||||||
import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
|
import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
|
||||||
import { executeWithPagination } from '@docmost/db/pagination/pagination';
|
import { executeWithCursorPagination } from '@docmost/db/pagination/cursor-pagination';
|
||||||
import { validate as isValidUUID } from 'uuid';
|
import { validate as isValidUUID } from 'uuid';
|
||||||
import { ExpressionBuilder, sql } from 'kysely';
|
import { ExpressionBuilder, sql } from 'kysely';
|
||||||
import { DB } from '@docmost/db/types/db';
|
import { DB } from '@docmost/db/types/db';
|
||||||
@@ -281,15 +281,21 @@ export class PageRepo {
|
|||||||
.select(this.baseFields)
|
.select(this.baseFields)
|
||||||
.select((eb) => this.withSpace(eb))
|
.select((eb) => this.withSpace(eb))
|
||||||
.where('spaceId', '=', spaceId)
|
.where('spaceId', '=', spaceId)
|
||||||
.where('deletedAt', 'is', null)
|
.where('deletedAt', 'is', null);
|
||||||
.orderBy('updatedAt', 'desc');
|
|
||||||
|
|
||||||
const result = executeWithPagination(query, {
|
return executeWithCursorPagination(query, {
|
||||||
page: pagination.page,
|
|
||||||
perPage: pagination.limit,
|
perPage: pagination.limit,
|
||||||
|
cursor: pagination.cursor,
|
||||||
|
beforeCursor: pagination.beforeCursor,
|
||||||
|
fields: [
|
||||||
|
{ expression: 'updatedAt', direction: 'desc' },
|
||||||
|
{ expression: 'id', direction: 'desc' },
|
||||||
|
],
|
||||||
|
parseCursor: (cursor) => ({
|
||||||
|
updatedAt: new Date(cursor.updatedAt),
|
||||||
|
id: cursor.id,
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async getRecentPages(userId: string, pagination: PaginationOptions) {
|
async getRecentPages(userId: string, pagination: PaginationOptions) {
|
||||||
@@ -298,12 +304,20 @@ export class PageRepo {
|
|||||||
.select(this.baseFields)
|
.select(this.baseFields)
|
||||||
.select((eb) => this.withSpace(eb))
|
.select((eb) => this.withSpace(eb))
|
||||||
.where('spaceId', 'in', this.spaceMemberRepo.getUserSpaceIdsQuery(userId))
|
.where('spaceId', 'in', this.spaceMemberRepo.getUserSpaceIdsQuery(userId))
|
||||||
.where('deletedAt', 'is', null)
|
.where('deletedAt', 'is', null);
|
||||||
.orderBy('updatedAt', 'desc');
|
|
||||||
|
|
||||||
return executeWithPagination(query, {
|
return executeWithCursorPagination(query, {
|
||||||
page: pagination.page,
|
|
||||||
perPage: pagination.limit,
|
perPage: pagination.limit,
|
||||||
|
cursor: pagination.cursor,
|
||||||
|
beforeCursor: pagination.beforeCursor,
|
||||||
|
fields: [
|
||||||
|
{ expression: 'updatedAt', direction: 'desc' },
|
||||||
|
{ expression: 'id', direction: 'desc' },
|
||||||
|
],
|
||||||
|
parseCursor: (cursor) => ({
|
||||||
|
updatedAt: new Date(cursor.updatedAt),
|
||||||
|
id: cursor.id,
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -331,15 +345,21 @@ export class PageRepo {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
]),
|
]),
|
||||||
)
|
);
|
||||||
.orderBy('deletedAt', 'desc');
|
|
||||||
|
|
||||||
const result = executeWithPagination(query, {
|
return executeWithCursorPagination(query, {
|
||||||
page: pagination.page,
|
|
||||||
perPage: pagination.limit,
|
perPage: pagination.limit,
|
||||||
|
cursor: pagination.cursor,
|
||||||
|
beforeCursor: pagination.beforeCursor,
|
||||||
|
fields: [
|
||||||
|
{ expression: 'deletedAt', direction: 'desc' },
|
||||||
|
{ expression: 'id', direction: 'desc' },
|
||||||
|
],
|
||||||
|
parseCursor: (cursor) => ({
|
||||||
|
deletedAt: new Date(cursor.deletedAt),
|
||||||
|
id: cursor.id,
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
withSpace(eb: ExpressionBuilder<DB, 'pages'>) {
|
withSpace(eb: ExpressionBuilder<DB, 'pages'>) {
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import {
|
|||||||
UpdatableShare,
|
UpdatableShare,
|
||||||
} from '@docmost/db/types/entity.types';
|
} from '@docmost/db/types/entity.types';
|
||||||
import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
|
import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
|
||||||
import { executeWithPagination } from '@docmost/db/pagination/pagination';
|
import { executeWithCursorPagination } from '@docmost/db/pagination/cursor-pagination';
|
||||||
import { validate as isValidUUID } from 'uuid';
|
import { validate as isValidUUID } from 'uuid';
|
||||||
import { ExpressionBuilder, sql } from 'kysely';
|
import { ExpressionBuilder, sql } from 'kysely';
|
||||||
import { DB } from '@docmost/db/types/db';
|
import { DB } from '@docmost/db/types/db';
|
||||||
@@ -143,12 +143,20 @@ export class ShareRepo {
|
|||||||
.select((eb) => this.withPage(eb))
|
.select((eb) => this.withPage(eb))
|
||||||
.select((eb) => this.withSpace(eb, userId))
|
.select((eb) => this.withSpace(eb, userId))
|
||||||
.select((eb) => this.withCreator(eb))
|
.select((eb) => this.withCreator(eb))
|
||||||
.where('spaceId', 'in', this.spaceMemberRepo.getUserSpaceIdsQuery(userId))
|
.where('spaceId', 'in', this.spaceMemberRepo.getUserSpaceIdsQuery(userId));
|
||||||
.orderBy('updatedAt', 'desc');
|
|
||||||
|
|
||||||
return executeWithPagination(query, {
|
return executeWithCursorPagination(query, {
|
||||||
page: pagination.page,
|
|
||||||
perPage: pagination.limit,
|
perPage: pagination.limit,
|
||||||
|
cursor: pagination.cursor,
|
||||||
|
beforeCursor: pagination.beforeCursor,
|
||||||
|
fields: [
|
||||||
|
{ expression: 'updatedAt', direction: 'desc' },
|
||||||
|
{ expression: 'id', direction: 'desc' },
|
||||||
|
],
|
||||||
|
parseCursor: (cursor) => ({
|
||||||
|
updatedAt: new Date(cursor.updatedAt),
|
||||||
|
id: cursor.id,
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import {
|
|||||||
} from '@docmost/db/types/entity.types';
|
} from '@docmost/db/types/entity.types';
|
||||||
import { PaginationOptions } from '../../pagination/pagination-options';
|
import { PaginationOptions } from '../../pagination/pagination-options';
|
||||||
import { MemberInfo, UserSpaceRole } from './types';
|
import { MemberInfo, UserSpaceRole } from './types';
|
||||||
import { executeWithPagination } from '@docmost/db/pagination/pagination';
|
import { executeWithCursorPagination } from '@docmost/db/pagination/cursor-pagination';
|
||||||
import { GroupRepo } from '@docmost/db/repos/group/group.repo';
|
import { GroupRepo } from '@docmost/db/repos/group/group.repo';
|
||||||
import { SpaceRepo } from '@docmost/db/repos/space/space.repo';
|
import { SpaceRepo } from '@docmost/db/repos/space/space.repo';
|
||||||
|
|
||||||
@@ -98,7 +98,7 @@ export class SpaceMemberRepo {
|
|||||||
spaceId: string,
|
spaceId: string,
|
||||||
pagination: PaginationOptions,
|
pagination: PaginationOptions,
|
||||||
) {
|
) {
|
||||||
let query = this.db
|
let baseQuery = this.db
|
||||||
.selectFrom('spaceMembers')
|
.selectFrom('spaceMembers')
|
||||||
.leftJoin('users', 'users.id', 'spaceMembers.userId')
|
.leftJoin('users', 'users.id', 'spaceMembers.userId')
|
||||||
.leftJoin('groups', 'groups.id', 'spaceMembers.groupId')
|
.leftJoin('groups', 'groups.id', 'spaceMembers.groupId')
|
||||||
@@ -114,12 +114,11 @@ export class SpaceMemberRepo {
|
|||||||
'spaceMembers.createdAt',
|
'spaceMembers.createdAt',
|
||||||
])
|
])
|
||||||
.select((eb) => this.groupRepo.withMemberCount(eb))
|
.select((eb) => this.groupRepo.withMemberCount(eb))
|
||||||
.where('spaceId', '=', spaceId)
|
.select(sql<number>`case when groups.id is not null then 1 else 0 end`.as('isGroup'))
|
||||||
.orderBy((eb) => eb('groups.id', 'is not', null), 'desc')
|
.where('spaceId', '=', spaceId);
|
||||||
.orderBy('spaceMembers.createdAt', 'asc');
|
|
||||||
|
|
||||||
if (pagination.query) {
|
if (pagination.query) {
|
||||||
query = query.where((eb) =>
|
baseQuery = baseQuery.where((eb) =>
|
||||||
eb(
|
eb(
|
||||||
sql`f_unaccent(users.name)`,
|
sql`f_unaccent(users.name)`,
|
||||||
'ilike',
|
'ilike',
|
||||||
@@ -138,9 +137,20 @@ export class SpaceMemberRepo {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await executeWithPagination(query, {
|
const query = this.db.selectFrom(baseQuery.as('sub')).selectAll('sub');
|
||||||
page: pagination.page,
|
|
||||||
|
const result = await executeWithCursorPagination(query, {
|
||||||
perPage: pagination.limit,
|
perPage: pagination.limit,
|
||||||
|
cursor: pagination.cursor,
|
||||||
|
beforeCursor: pagination.beforeCursor,
|
||||||
|
fields: [
|
||||||
|
{ expression: 'sub.isGroup', direction: 'desc', key: 'isGroup' },
|
||||||
|
{ expression: 'sub.createdAt', direction: 'asc', key: 'createdAt' },
|
||||||
|
],
|
||||||
|
parseCursor: (cursor) => ({
|
||||||
|
isGroup: parseInt(cursor.isGroup, 10),
|
||||||
|
createdAt: new Date(cursor.createdAt),
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
let memberInfo: MemberInfo;
|
let memberInfo: MemberInfo;
|
||||||
@@ -235,8 +245,7 @@ export class SpaceMemberRepo {
|
|||||||
.selectFrom('spaces')
|
.selectFrom('spaces')
|
||||||
.selectAll()
|
.selectAll()
|
||||||
.select((eb) => [this.spaceRepo.withMemberCount(eb)])
|
.select((eb) => [this.spaceRepo.withMemberCount(eb)])
|
||||||
.where('id', 'in', this.getUserSpaceIdsQuery(userId))
|
.where('id', 'in', this.getUserSpaceIdsQuery(userId));
|
||||||
.orderBy('createdAt', 'asc');
|
|
||||||
|
|
||||||
if (pagination.query) {
|
if (pagination.query) {
|
||||||
query = query.where((eb) =>
|
query = query.where((eb) =>
|
||||||
@@ -252,9 +261,12 @@ export class SpaceMemberRepo {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return executeWithPagination(query, {
|
return executeWithCursorPagination(query, {
|
||||||
page: pagination.page,
|
|
||||||
perPage: pagination.limit,
|
perPage: pagination.limit,
|
||||||
|
cursor: pagination.cursor,
|
||||||
|
beforeCursor: pagination.beforeCursor,
|
||||||
|
fields: [{ expression: 'id', direction: 'asc' }],
|
||||||
|
parseCursor: (cursor) => ({ id: cursor.id }),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import {
|
|||||||
} from '@docmost/db/types/entity.types';
|
} from '@docmost/db/types/entity.types';
|
||||||
import { ExpressionBuilder, sql } from 'kysely';
|
import { ExpressionBuilder, sql } from 'kysely';
|
||||||
import { PaginationOptions } from '../../pagination/pagination-options';
|
import { PaginationOptions } from '../../pagination/pagination-options';
|
||||||
import { executeWithPagination } from '@docmost/db/pagination/pagination';
|
import { executeWithCursorPagination } from '@docmost/db/pagination/cursor-pagination';
|
||||||
import { DB } from '@docmost/db/types/db';
|
import { DB } from '@docmost/db/types/db';
|
||||||
import { validate as isValidUUID } from 'uuid';
|
import { validate as isValidUUID } from 'uuid';
|
||||||
import { EventEmitter2 } from '@nestjs/event-emitter';
|
import { EventEmitter2 } from '@nestjs/event-emitter';
|
||||||
@@ -110,8 +110,7 @@ export class SpaceRepo {
|
|||||||
.selectFrom('spaces')
|
.selectFrom('spaces')
|
||||||
.selectAll('spaces')
|
.selectAll('spaces')
|
||||||
.select((eb) => [this.withMemberCount(eb)])
|
.select((eb) => [this.withMemberCount(eb)])
|
||||||
.where('workspaceId', '=', workspaceId)
|
.where('workspaceId', '=', workspaceId);
|
||||||
.orderBy('createdAt', 'asc');
|
|
||||||
|
|
||||||
if (pagination.query) {
|
if (pagination.query) {
|
||||||
query = query.where((eb) =>
|
query = query.where((eb) =>
|
||||||
@@ -127,12 +126,13 @@ export class SpaceRepo {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = executeWithPagination(query, {
|
return executeWithCursorPagination(query, {
|
||||||
page: pagination.page,
|
|
||||||
perPage: pagination.limit,
|
perPage: pagination.limit,
|
||||||
|
cursor: pagination.cursor,
|
||||||
|
beforeCursor: pagination.beforeCursor,
|
||||||
|
fields: [{ expression: 'id', direction: 'asc' }],
|
||||||
|
parseCursor: (cursor) => ({ id: cursor.id }),
|
||||||
});
|
});
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
withMemberCount(eb: ExpressionBuilder<DB, 'spaces'>) {
|
withMemberCount(eb: ExpressionBuilder<DB, 'spaces'>) {
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import {
|
|||||||
User,
|
User,
|
||||||
} from '@docmost/db/types/entity.types';
|
} from '@docmost/db/types/entity.types';
|
||||||
import { PaginationOptions } from '../../pagination/pagination-options';
|
import { PaginationOptions } from '../../pagination/pagination-options';
|
||||||
import { executeWithPagination } from '@docmost/db/pagination/pagination';
|
import { executeWithCursorPagination } from '@docmost/db/pagination/cursor-pagination';
|
||||||
import { ExpressionBuilder, sql } from 'kysely';
|
import { ExpressionBuilder, sql } from 'kysely';
|
||||||
import { jsonObjectFrom } from 'kysely/helpers/postgres';
|
import { jsonObjectFrom } from 'kysely/helpers/postgres';
|
||||||
|
|
||||||
@@ -145,8 +145,7 @@ export class UserRepo {
|
|||||||
.selectFrom('users')
|
.selectFrom('users')
|
||||||
.select(this.baseFields)
|
.select(this.baseFields)
|
||||||
.where('workspaceId', '=', workspaceId)
|
.where('workspaceId', '=', workspaceId)
|
||||||
.where('deletedAt', 'is', null)
|
.where('deletedAt', 'is', null);
|
||||||
.orderBy('createdAt', 'asc');
|
|
||||||
|
|
||||||
if (pagination.query) {
|
if (pagination.query) {
|
||||||
query = query.where((eb) =>
|
query = query.where((eb) =>
|
||||||
@@ -162,12 +161,13 @@ export class UserRepo {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = executeWithPagination(query, {
|
return executeWithCursorPagination(query, {
|
||||||
page: pagination.page,
|
|
||||||
perPage: pagination.limit,
|
perPage: pagination.limit,
|
||||||
|
cursor: pagination.cursor,
|
||||||
|
beforeCursor: pagination.beforeCursor,
|
||||||
|
fields: [{ expression: 'id', direction: 'asc' }],
|
||||||
|
parseCursor: (cursor) => ({ id: cursor.id }),
|
||||||
});
|
});
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async updatePreference(
|
async updatePreference(
|
||||||
|
|||||||
+1
-1
Submodule apps/server/src/ee updated: f858f127b5...6d3eb76d4e
@@ -27,7 +27,7 @@ import { AuthUser } from '../../common/decorators/auth-user.decorator';
|
|||||||
import { FileTaskIdDto } from './dto/file-task-dto';
|
import { FileTaskIdDto } from './dto/file-task-dto';
|
||||||
import { SpaceMemberRepo } from '@docmost/db/repos/space/space-member.repo';
|
import { SpaceMemberRepo } from '@docmost/db/repos/space/space-member.repo';
|
||||||
import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
|
import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
|
||||||
import { executeWithPagination } from '@docmost/db/pagination/pagination';
|
import { executeWithCursorPagination } from '@docmost/db/pagination/cursor-pagination';
|
||||||
|
|
||||||
@Controller('file-tasks')
|
@Controller('file-tasks')
|
||||||
export class FileTaskController {
|
export class FileTaskController {
|
||||||
@@ -56,12 +56,14 @@ export class FileTaskController {
|
|||||||
const query = this.db
|
const query = this.db
|
||||||
.selectFrom('fileTasks')
|
.selectFrom('fileTasks')
|
||||||
.selectAll()
|
.selectAll()
|
||||||
.where('spaceId', 'in', this.spaceMemberRepo.getUserSpaceIdsQuery(user.id))
|
.where('spaceId', 'in', this.spaceMemberRepo.getUserSpaceIdsQuery(user.id));
|
||||||
.orderBy('createdAt', 'desc');
|
|
||||||
|
|
||||||
return executeWithPagination(query, {
|
return executeWithCursorPagination(query, {
|
||||||
page: pagination.page,
|
|
||||||
perPage: pagination.limit,
|
perPage: pagination.limit,
|
||||||
|
cursor: pagination.cursor,
|
||||||
|
beforeCursor: pagination.beforeCursor,
|
||||||
|
fields: [{ expression: 'id', direction: 'desc' }],
|
||||||
|
parseCursor: (cursor) => ({ id: cursor.id }),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+10
-3
@@ -24,7 +24,11 @@ async function bootstrap() {
|
|||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
rawBody: true,
|
rawBody: true,
|
||||||
bufferLogs: true,
|
// disable Nest logger so pino handles all logs
|
||||||
|
// bufferLogs must be false else pino will fail
|
||||||
|
// to log OnApplicationBootstrap logs
|
||||||
|
logger: false,
|
||||||
|
bufferLogs: false,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -100,8 +104,11 @@ async function bootstrap() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const port = process.env.PORT || 3000;
|
const port = process.env.PORT || 3000;
|
||||||
await app.listen(port, '0.0.0.0', () => {
|
const host = process.env.HOST || '0.0.0.0';
|
||||||
logger.log(`Listening on http://127.0.0.1:${port} / ${process.env.APP_URL}`);
|
await app.listen(port, host, () => {
|
||||||
|
logger.log(
|
||||||
|
`Listening on http://127.0.0.1:${port} / ${process.env.APP_URL}`,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -23,6 +23,9 @@ export class WsRedisIoAdapter extends IoAdapter {
|
|||||||
const pubClient = new Redis(process.env.REDIS_URL, options);
|
const pubClient = new Redis(process.env.REDIS_URL, options);
|
||||||
const subClient = new Redis(process.env.REDIS_URL, options);
|
const subClient = new Redis(process.env.REDIS_URL, options);
|
||||||
|
|
||||||
|
pubClient.on('error', (err) => () => {});
|
||||||
|
subClient.on('error', (err) => () => {});
|
||||||
|
|
||||||
this.adapterConstructor = createAdapter(pubClient, subClient);
|
this.adapterConstructor = createAdapter(pubClient, subClient);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+4
-1
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "docmost",
|
"name": "docmost",
|
||||||
"homepage": "https://docmost.com",
|
"homepage": "https://docmost.com",
|
||||||
"version": "0.24.1",
|
"version": "0.25.0-beta.1",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "nx run-many -t build",
|
"build": "nx run-many -t build",
|
||||||
@@ -60,6 +60,7 @@
|
|||||||
"bytes": "^3.1.2",
|
"bytes": "^3.1.2",
|
||||||
"cross-env": "^7.0.3",
|
"cross-env": "^7.0.3",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
|
"diff": "8.0.3",
|
||||||
"dompurify": "^3.2.6",
|
"dompurify": "^3.2.6",
|
||||||
"fractional-indexing-jittered": "^1.0.0",
|
"fractional-indexing-jittered": "^1.0.0",
|
||||||
"highlight.js": "^11.11.1",
|
"highlight.js": "^11.11.1",
|
||||||
@@ -70,6 +71,7 @@
|
|||||||
"marked": "13.0.3",
|
"marked": "13.0.3",
|
||||||
"ms": "3.0.0-canary.1",
|
"ms": "3.0.0-canary.1",
|
||||||
"qrcode": "^1.5.4",
|
"qrcode": "^1.5.4",
|
||||||
|
"rfc6902": "5.1.2",
|
||||||
"uuid": "^11.1.0",
|
"uuid": "^11.1.0",
|
||||||
"y-indexeddb": "^9.0.12",
|
"y-indexeddb": "^9.0.12",
|
||||||
"y-prosemirror": "1.3.7",
|
"y-prosemirror": "1.3.7",
|
||||||
@@ -98,6 +100,7 @@
|
|||||||
"overrides": {
|
"overrides": {
|
||||||
"jsdom": "25.0.1",
|
"jsdom": "25.0.1",
|
||||||
"jsonwebtoken": "9.0.3",
|
"jsonwebtoken": "9.0.3",
|
||||||
|
"prosemirror-changeset": "2.3.1",
|
||||||
"y-prosemirror": "1.3.7"
|
"y-prosemirror": "1.3.7"
|
||||||
},
|
},
|
||||||
"neverBuiltDependencies": []
|
"neverBuiltDependencies": []
|
||||||
|
|||||||
@@ -8,5 +8,6 @@
|
|||||||
},
|
},
|
||||||
"main": "dist/index.js",
|
"main": "dist/index.js",
|
||||||
"module": "./src/index.ts",
|
"module": "./src/index.ts",
|
||||||
"types": "dist/index.d.ts"
|
"types": "dist/index.d.ts",
|
||||||
|
"dependencies": {}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,3 +24,4 @@ export * from "./lib/highlight";
|
|||||||
export * from "./lib/heading/heading";
|
export * from "./lib/heading/heading";
|
||||||
export * from "./lib/unique-id";
|
export * from "./lib/unique-id";
|
||||||
export * from "./lib/shared-storage";
|
export * from "./lib/shared-storage";
|
||||||
|
export * from "./lib/recreate-transform";
|
||||||
|
|||||||
@@ -0,0 +1,145 @@
|
|||||||
|
# prosemirror-changeset
|
||||||
|
|
||||||
|
This is a helper module that can turn a sequence of document changes
|
||||||
|
into a set of insertions and deletions, for example to display them in
|
||||||
|
a change-tracking interface. Such a set can be built up incrementally,
|
||||||
|
in order to do such change tracking in a halfway performant way during
|
||||||
|
live editing.
|
||||||
|
|
||||||
|
This code is licensed under an [MIT
|
||||||
|
licence](https://github.com/ProseMirror/prosemirror-changeset/blob/master/LICENSE).
|
||||||
|
|
||||||
|
## Programming interface
|
||||||
|
|
||||||
|
Insertions and deletions are represented as ‘spans’—ranges in the
|
||||||
|
document. The deleted spans refer to the original document, whereas
|
||||||
|
the inserted ones point into the current document.
|
||||||
|
|
||||||
|
It is possible to associate arbitrary data values with such spans, for
|
||||||
|
example to track the user that made the change, the timestamp at which
|
||||||
|
it was made, or the step data necessary to invert it again.
|
||||||
|
|
||||||
|
### class Change`<Data = any>`
|
||||||
|
|
||||||
|
A replaced range with metadata associated with it.
|
||||||
|
|
||||||
|
* **`fromA`**`: number`\
|
||||||
|
The start of the range deleted/replaced in the old document.
|
||||||
|
|
||||||
|
* **`toA`**`: number`\
|
||||||
|
The end of the range in the old document.
|
||||||
|
|
||||||
|
* **`fromB`**`: number`\
|
||||||
|
The start of the range inserted in the new document.
|
||||||
|
|
||||||
|
* **`toB`**`: number`\
|
||||||
|
The end of the range in the new document.
|
||||||
|
|
||||||
|
* **`deleted`**`: readonly Span[]`\
|
||||||
|
Data associated with the deleted content. The length of these
|
||||||
|
spans adds up to `this.toA - this.fromA`.
|
||||||
|
|
||||||
|
* **`inserted`**`: readonly Span[]`\
|
||||||
|
Data associated with the inserted content. Length adds up to
|
||||||
|
`this.toB - this.fromB`.
|
||||||
|
|
||||||
|
* `static `**`merge`**`<Data>(x: readonly Change[], y: readonly Change[], combine: fn(dataA: Data, dataB: Data) → Data) → readonly Change[]`\
|
||||||
|
This merges two changesets (the end document of x should be the
|
||||||
|
start document of y) into a single one spanning the start of x to
|
||||||
|
the end of y.
|
||||||
|
|
||||||
|
|
||||||
|
### class Span`<Data = any>`
|
||||||
|
|
||||||
|
Stores metadata for a part of a change.
|
||||||
|
|
||||||
|
* **`length`**`: number`\
|
||||||
|
The length of this span.
|
||||||
|
|
||||||
|
* **`data`**`: Data`\
|
||||||
|
The data associated with this span.
|
||||||
|
|
||||||
|
|
||||||
|
### class ChangeSet`<Data = any>`
|
||||||
|
|
||||||
|
A change set tracks the changes to a document from a given point
|
||||||
|
in the past. It condenses a number of step maps down to a flat
|
||||||
|
sequence of replacements, and simplifies replacments that
|
||||||
|
partially undo themselves by comparing their content.
|
||||||
|
|
||||||
|
* **`changes`**`: readonly Change[]`\
|
||||||
|
Replaced regions.
|
||||||
|
|
||||||
|
* **`addSteps`**`(newDoc: Node, maps: readonly StepMap[], data: Data | readonly Data[]) → ChangeSet`\
|
||||||
|
Computes a new changeset by adding the given step maps and
|
||||||
|
metadata (either as an array, per-map, or as a single value to be
|
||||||
|
associated with all maps) to the current set. Will not mutate the
|
||||||
|
old set.
|
||||||
|
|
||||||
|
Note that due to simplification that happens after each add,
|
||||||
|
incrementally adding steps might create a different final set
|
||||||
|
than adding all those changes at once, since different document
|
||||||
|
tokens might be matched during simplification depending on the
|
||||||
|
boundaries of the current changed ranges.
|
||||||
|
|
||||||
|
* **`startDoc`**`: Node`\
|
||||||
|
The starting document of the change set.
|
||||||
|
|
||||||
|
* **`map`**`(f: fn(range: Span) → Data) → ChangeSet`\
|
||||||
|
Map the span's data values in the given set through a function
|
||||||
|
and construct a new set with the resulting data.
|
||||||
|
|
||||||
|
* **`changedRange`**`(b: ChangeSet, maps?: readonly StepMap[]) → {from: number, to: number}`\
|
||||||
|
Compare two changesets and return the range in which they are
|
||||||
|
changed, if any. If the document changed between the maps, pass
|
||||||
|
the maps for the steps that changed it as second argument, and
|
||||||
|
make sure the method is called on the old set and passed the new
|
||||||
|
set. The returned positions will be in new document coordinates.
|
||||||
|
|
||||||
|
* `static `**`create`**`<Data = any>(doc: Node, combine?: fn(dataA: Data, dataB: Data) → Data = (a, b) => a === b ? a : null as any, tokenEncoder?: TokenEncoder = DefaultEncoder) → ChangeSet`\
|
||||||
|
Create a changeset with the given base object and configuration.
|
||||||
|
|
||||||
|
The `combine` function is used to compare and combine metadata—it
|
||||||
|
should return null when metadata isn't compatible, and a combined
|
||||||
|
version for a merged range when it is.
|
||||||
|
|
||||||
|
When given, a token encoder determines how document tokens are
|
||||||
|
serialized and compared when diffing the content produced by
|
||||||
|
changes. The default is to just compare nodes by name and text
|
||||||
|
by character, ignoring marks and attributes.
|
||||||
|
|
||||||
|
|
||||||
|
* **`simplifyChanges`**`(changes: readonly Change[], doc: Node) → Change[]`\
|
||||||
|
Simplifies a set of changes for presentation. This makes the
|
||||||
|
assumption that having both insertions and deletions within a word
|
||||||
|
is confusing, and, when such changes occur without a word boundary
|
||||||
|
between them, they should be expanded to cover the entire set of
|
||||||
|
words (in the new document) they touch. An exception is made for
|
||||||
|
single-character replacements.
|
||||||
|
|
||||||
|
|
||||||
|
### interface TokenEncoder`<T>`
|
||||||
|
|
||||||
|
A token encoder can be passed when creating a `ChangeSet` in order
|
||||||
|
to influence the way the library runs its diffing algorithm. The
|
||||||
|
encoder determines how document tokens (such as nodes and
|
||||||
|
characters) are encoded and compared.
|
||||||
|
|
||||||
|
Note that both the encoding and the comparison may run a lot, and
|
||||||
|
doing non-trivial work in these functions could impact
|
||||||
|
performance.
|
||||||
|
|
||||||
|
* **`encodeCharacter`**`(char: number, marks: readonly Mark[]) → T`\
|
||||||
|
Encode a given character, with the given marks applied.
|
||||||
|
|
||||||
|
* **`encodeNodeStart`**`(node: Node) → T`\
|
||||||
|
Encode the start of a node or, if this is a leaf node, the
|
||||||
|
entire node.
|
||||||
|
|
||||||
|
* **`encodeNodeEnd`**`(node: Node) → T`\
|
||||||
|
Encode the end token for the given node. It is valid to encode
|
||||||
|
every end token in the same way.
|
||||||
|
|
||||||
|
* **`compareTokens`**`(a: T, b: T) → boolean`\
|
||||||
|
Compare the given tokens. Should return true when they count as
|
||||||
|
equal.
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
# prosemirror-recreate-transform
|
||||||
|
|
||||||
|
> reduced and modified fork of https://gitlab.com/mpapp-public/prosemirror-recreate-steps
|
||||||
|
|
||||||
|
This is a non-core module of [ProseMirror](http://prosemirror.net).
|
||||||
|
ProseMirror is a well-behaved rich semantic content editor based on
|
||||||
|
contentEditable, with support for collaborative editing and custom
|
||||||
|
document schemas.
|
||||||
|
|
||||||
|
Every change to the document is recorded by ProseMirror as a step.
|
||||||
|
This module allows recreating the steps needed to go from document
|
||||||
|
A to B should these not be available otherwise. Recreating steps
|
||||||
|
can be interesting for example in order to show the changes between
|
||||||
|
two document versions without having access to the original steps.
|
||||||
|
|
||||||
|
Recreating a `Transform` works this way:
|
||||||
|
|
||||||
|
```js
|
||||||
|
import { recreateTransform } from "@technik-sde/prosemirror-recreate-transform";
|
||||||
|
|
||||||
|
let tr = recreateTransform(
|
||||||
|
startDoc,
|
||||||
|
endDoc,
|
||||||
|
{
|
||||||
|
complexSteps: true, // Whether step types other than ReplaceStep are allowed.
|
||||||
|
wordDiffs: false, // Whether diffs in text nodes should cover entire words.
|
||||||
|
simplifyDiffs: true // Whether steps should be merged, where possible
|
||||||
|
}
|
||||||
|
);
|
||||||
|
```
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export function copy<T>(value: T): T {
|
||||||
|
return JSON.parse(JSON.stringify(value));
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { AnyObject } from "./types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* get target value from json-pointer (e.g. /content/0/content)
|
||||||
|
* @param {AnyObject} obj object to resolve path into
|
||||||
|
* @param {string} path json-pointer
|
||||||
|
* @return {any} target value
|
||||||
|
*/
|
||||||
|
export function getFromPath(obj: AnyObject, path: string): any {
|
||||||
|
const pathParts = path.split("/");
|
||||||
|
pathParts.shift(); // remove root-entry
|
||||||
|
while (pathParts.length) {
|
||||||
|
const property = pathParts.shift();
|
||||||
|
obj = obj[property];
|
||||||
|
}
|
||||||
|
return obj;
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { ReplaceStep } from "@tiptap/pm/transform";
|
||||||
|
import { Node } from "@tiptap/pm/model";
|
||||||
|
|
||||||
|
export function getReplaceStep(fromDoc: Node, toDoc: Node) {
|
||||||
|
let start = toDoc.content.findDiffStart(fromDoc.content);
|
||||||
|
if (start === null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// @ts-ignore property access to content
|
||||||
|
let { a: endA, b: endB } = toDoc.content.findDiffEnd(fromDoc.content);
|
||||||
|
const overlap = start - Math.min(endA, endB);
|
||||||
|
if (overlap > 0) {
|
||||||
|
// If there is an overlap, there is some freedom of choice in how to calculate the
|
||||||
|
// start/end boundary. for an inserted/removed slice. We choose the extreme with
|
||||||
|
// the lowest depth value.
|
||||||
|
if (
|
||||||
|
fromDoc.resolve(start - overlap).depth <
|
||||||
|
toDoc.resolve(endA + overlap).depth
|
||||||
|
) {
|
||||||
|
start -= overlap;
|
||||||
|
} else {
|
||||||
|
endA += overlap;
|
||||||
|
endB += overlap;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return new ReplaceStep(start, endB, toDoc.slice(start, endA));
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
// https://gitlab.com/mpapp-public/prosemirror-recreate-steps
|
||||||
|
// https://github.com/sueddeutsche/prosemirror-recreate-transform
|
||||||
|
export { recreateTransform, RecreateTransform } from "./recreateTransform";
|
||||||
|
export type { Options } from "./recreateTransform";
|
||||||
@@ -0,0 +1,279 @@
|
|||||||
|
import { Transform } from "@tiptap/pm/transform";
|
||||||
|
import { Node, Schema } from "@tiptap/pm/model";
|
||||||
|
import { applyPatch, createPatch, Operation } from "rfc6902";
|
||||||
|
import { diffWordsWithSpace, diffChars } from "diff";
|
||||||
|
import { AnyObject } from "./types";
|
||||||
|
import { getReplaceStep } from "./getReplaceStep";
|
||||||
|
import { simplifyTransform } from "./simplifyTransform";
|
||||||
|
import { removeMarks } from "./removeMarks";
|
||||||
|
import { getFromPath } from "./getFromPath";
|
||||||
|
import { copy } from "./copy";
|
||||||
|
|
||||||
|
export interface Options {
|
||||||
|
complexSteps?: boolean;
|
||||||
|
wordDiffs?: boolean;
|
||||||
|
simplifyDiff?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class RecreateTransform {
|
||||||
|
fromDoc: Node;
|
||||||
|
toDoc: Node;
|
||||||
|
complexSteps: boolean;
|
||||||
|
wordDiffs: boolean;
|
||||||
|
simplifyDiff: boolean;
|
||||||
|
schema: Schema;
|
||||||
|
tr: Transform;
|
||||||
|
/* current working document data, may get updated while recalculating node steps */
|
||||||
|
currentJSON: AnyObject;
|
||||||
|
/* final document as json data */
|
||||||
|
finalJSON: AnyObject;
|
||||||
|
ops: Array<Operation>;
|
||||||
|
|
||||||
|
constructor(fromDoc: Node, toDoc: Node, options: Options = {}) {
|
||||||
|
const o = {
|
||||||
|
complexSteps: true,
|
||||||
|
wordDiffs: false,
|
||||||
|
simplifyDiff: true,
|
||||||
|
...options,
|
||||||
|
};
|
||||||
|
|
||||||
|
this.fromDoc = fromDoc;
|
||||||
|
this.toDoc = toDoc;
|
||||||
|
this.complexSteps = o.complexSteps; // Whether to return steps other than ReplaceSteps
|
||||||
|
this.wordDiffs = o.wordDiffs; // Whether to make text diffs cover entire words
|
||||||
|
this.simplifyDiff = o.simplifyDiff;
|
||||||
|
this.schema = fromDoc.type.schema;
|
||||||
|
this.tr = new Transform(fromDoc);
|
||||||
|
}
|
||||||
|
|
||||||
|
init() {
|
||||||
|
if (this.complexSteps) {
|
||||||
|
// For First steps: we create versions of the documents without marks as
|
||||||
|
// these will only confuse the diffing mechanism and marks won't cause
|
||||||
|
// any mapping changes anyway.
|
||||||
|
this.currentJSON = removeMarks(this.fromDoc).toJSON();
|
||||||
|
this.finalJSON = removeMarks(this.toDoc).toJSON();
|
||||||
|
this.ops = createPatch(this.currentJSON, this.finalJSON);
|
||||||
|
this.recreateChangeContentSteps();
|
||||||
|
this.recreateChangeMarkSteps();
|
||||||
|
} else {
|
||||||
|
// We don't differentiate between mark changes and other changes.
|
||||||
|
this.currentJSON = this.fromDoc.toJSON();
|
||||||
|
this.finalJSON = this.toDoc.toJSON();
|
||||||
|
this.ops = createPatch(this.currentJSON, this.finalJSON);
|
||||||
|
this.recreateChangeContentSteps();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.simplifyDiff) {
|
||||||
|
this.tr = simplifyTransform(this.tr) || this.tr;
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.tr;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** convert json-diff to prosemirror steps */
|
||||||
|
recreateChangeContentSteps() {
|
||||||
|
// First step: find content changing steps.
|
||||||
|
let ops = [];
|
||||||
|
while (this.ops.length) {
|
||||||
|
// get next
|
||||||
|
let op = this.ops.shift();
|
||||||
|
ops.push(op);
|
||||||
|
|
||||||
|
let toDoc;
|
||||||
|
const afterStepJSON = copy(this.currentJSON); // working document receiving patches
|
||||||
|
const pathParts = op.path.split("/");
|
||||||
|
|
||||||
|
// collect operations until we receive a valid document:
|
||||||
|
// apply ops-patches until a valid prosemirror document is retrieved,
|
||||||
|
// then try to create a transformation step or retry with next operation
|
||||||
|
while (toDoc == null) {
|
||||||
|
applyPatch(afterStepJSON, [op]);
|
||||||
|
|
||||||
|
try {
|
||||||
|
toDoc = this.schema.nodeFromJSON(afterStepJSON);
|
||||||
|
toDoc.check();
|
||||||
|
} catch (error) {
|
||||||
|
toDoc = null;
|
||||||
|
if (this.ops.length > 0) {
|
||||||
|
op = this.ops.shift();
|
||||||
|
ops.push(op);
|
||||||
|
} else {
|
||||||
|
throw new Error(`No valid diff possible applying ${op.path}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// apply operation (ignoring afterStepJSON)
|
||||||
|
if (
|
||||||
|
this.complexSteps &&
|
||||||
|
ops.length === 1 &&
|
||||||
|
(pathParts.includes("attrs") || pathParts.includes("type"))
|
||||||
|
) {
|
||||||
|
// Node markup is changing
|
||||||
|
this.addSetNodeMarkup(); // a lost update is ignored
|
||||||
|
ops = [];
|
||||||
|
// console.log("%cop", logStyle, "- update node", ops);
|
||||||
|
} else if (
|
||||||
|
ops.length === 1 &&
|
||||||
|
op.op === "replace" &&
|
||||||
|
pathParts[pathParts.length - 1] === "text"
|
||||||
|
) {
|
||||||
|
// Text is being replaced, we apply text diffing to find the smallest possible diffs.
|
||||||
|
this.addReplaceTextSteps(op, afterStepJSON);
|
||||||
|
ops = [];
|
||||||
|
// console.log("%cop", logStyle, "- replace", ops);
|
||||||
|
} else if (this.addReplaceStep(toDoc, afterStepJSON)) {
|
||||||
|
// operations have been applied
|
||||||
|
ops = [];
|
||||||
|
// console.log("%cop", logStyle, "- other", ops);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** update node with attrs and marks, may also change type */
|
||||||
|
addSetNodeMarkup() {
|
||||||
|
// first diff in document is supposed to be a node-change (in type and/or attributes)
|
||||||
|
// thus simply find the first change and apply a node change step, then recalculate the diff
|
||||||
|
// after updating the document
|
||||||
|
const fromDoc = this.schema.nodeFromJSON(this.currentJSON);
|
||||||
|
const toDoc = this.schema.nodeFromJSON(this.finalJSON);
|
||||||
|
const start = toDoc.content.findDiffStart(fromDoc.content);
|
||||||
|
// @note start is the same (first) position for current and target document
|
||||||
|
const fromNode = fromDoc.nodeAt(start);
|
||||||
|
const toNode = toDoc.nodeAt(start);
|
||||||
|
|
||||||
|
if (start != null) {
|
||||||
|
// @note this completly updates all attributes in one step, by completely replacing node
|
||||||
|
const nodeType = fromNode.type === toNode.type ? null : toNode.type;
|
||||||
|
try {
|
||||||
|
this.tr.setNodeMarkup(start, nodeType, toNode.attrs, toNode.marks);
|
||||||
|
} catch (e) {
|
||||||
|
// if nodetypes differ, the updated node-type and contents might not be compatible
|
||||||
|
// with schema and requires a replace
|
||||||
|
if (nodeType && e.message.includes("Invalid content")) {
|
||||||
|
// @todo add test-case for this scenario
|
||||||
|
this.tr.replaceWith(start, start + fromNode.nodeSize, toNode);
|
||||||
|
} else {
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.currentJSON = removeMarks(this.tr.doc).toJSON();
|
||||||
|
// setting the node markup may have invalidated the following ops, so we calculate them again.
|
||||||
|
this.ops = createPatch(this.currentJSON, this.finalJSON);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
recreateChangeMarkSteps() {
|
||||||
|
// Now the documents should be the same, except their marks, so everything should map 1:1.
|
||||||
|
// Second step: Iterate through the toDoc and make sure all marks are the same in tr.doc
|
||||||
|
this.toDoc.descendants((tNode, tPos) => {
|
||||||
|
if (!tNode.isInline) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.tr.doc.nodesBetween(tPos, tPos + tNode.nodeSize, (fNode, fPos) => {
|
||||||
|
if (!fNode.isInline) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
const from = Math.max(tPos, fPos);
|
||||||
|
const to = Math.min(tPos + tNode.nodeSize, fPos + fNode.nodeSize);
|
||||||
|
fNode.marks.forEach((nodeMark) => {
|
||||||
|
if (!nodeMark.isInSet(tNode.marks)) {
|
||||||
|
this.tr.removeMark(from, to, nodeMark);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
tNode.marks.forEach((nodeMark) => {
|
||||||
|
if (!nodeMark.isInSet(fNode.marks)) {
|
||||||
|
this.tr.addMark(from, to, nodeMark);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* retrieve and possibly apply replace-step based from doc changes
|
||||||
|
* From http://prosemirror.net/examples/footnote/
|
||||||
|
*/
|
||||||
|
addReplaceStep(toDoc: Node, afterStepJSON: AnyObject) {
|
||||||
|
const fromDoc = this.schema.nodeFromJSON(this.currentJSON);
|
||||||
|
const step = getReplaceStep(fromDoc, toDoc);
|
||||||
|
|
||||||
|
if (!step) {
|
||||||
|
return false;
|
||||||
|
} else if (!this.tr.maybeStep(step).failed) {
|
||||||
|
this.currentJSON = afterStepJSON;
|
||||||
|
return true; // @change previously null
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error("No valid step found.");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** retrieve and possibly apply text replace-steps based from doc changes */
|
||||||
|
addReplaceTextSteps(op, afterStepJSON) {
|
||||||
|
// We find the position number of the first character in the string
|
||||||
|
const op1 = { ...op, value: "xx" };
|
||||||
|
const op2 = { ...op, value: "yy" };
|
||||||
|
const afterOP1JSON = copy(this.currentJSON);
|
||||||
|
const afterOP2JSON = copy(this.currentJSON);
|
||||||
|
applyPatch(afterOP1JSON, [op1]);
|
||||||
|
applyPatch(afterOP2JSON, [op2]);
|
||||||
|
const op1Doc = this.schema.nodeFromJSON(afterOP1JSON);
|
||||||
|
const op2Doc = this.schema.nodeFromJSON(afterOP2JSON);
|
||||||
|
|
||||||
|
// get text diffs
|
||||||
|
const finalText = op.value;
|
||||||
|
const currentText = getFromPath(this.currentJSON, op.path);
|
||||||
|
const textDiffs = this.wordDiffs
|
||||||
|
? diffWordsWithSpace(currentText, finalText)
|
||||||
|
: diffChars(currentText, finalText);
|
||||||
|
|
||||||
|
let offset = op1Doc.content.findDiffStart(op2Doc.content);
|
||||||
|
const marks = op1Doc.resolve(offset + 1).marks();
|
||||||
|
|
||||||
|
while (textDiffs.length) {
|
||||||
|
const diff = textDiffs.shift();
|
||||||
|
|
||||||
|
if (diff.added) {
|
||||||
|
const textNode = this.schema
|
||||||
|
.nodeFromJSON({ type: "text", text: diff.value })
|
||||||
|
.mark(marks);
|
||||||
|
|
||||||
|
if (textDiffs.length && textDiffs[0].removed) {
|
||||||
|
const nextDiff = textDiffs.shift();
|
||||||
|
this.tr.replaceWith(offset, offset + nextDiff.value.length, textNode);
|
||||||
|
} else {
|
||||||
|
this.tr.insert(offset, textNode);
|
||||||
|
}
|
||||||
|
offset += diff.value.length;
|
||||||
|
} else if (diff.removed) {
|
||||||
|
if (textDiffs.length && textDiffs[0].added) {
|
||||||
|
const nextDiff = textDiffs.shift();
|
||||||
|
const textNode = this.schema
|
||||||
|
.nodeFromJSON({ type: "text", text: nextDiff.value })
|
||||||
|
.mark(marks);
|
||||||
|
this.tr.replaceWith(offset, offset + diff.value.length, textNode);
|
||||||
|
offset += nextDiff.value.length;
|
||||||
|
} else {
|
||||||
|
this.tr.delete(offset, offset + diff.value.length);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
offset += diff.value.length;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.currentJSON = afterStepJSON;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function recreateTransform(
|
||||||
|
fromDoc: Node,
|
||||||
|
toDoc: Node,
|
||||||
|
options: Options = {},
|
||||||
|
): Transform {
|
||||||
|
const recreator = new RecreateTransform(fromDoc, toDoc, options);
|
||||||
|
return recreator.init();
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import { Transform } from "@tiptap/pm/transform";
|
||||||
|
import { Node } from "@tiptap/pm/model";
|
||||||
|
|
||||||
|
export function removeMarks(doc: Node) {
|
||||||
|
const tr = new Transform(doc);
|
||||||
|
tr.removeMark(0, doc.nodeSize - 2);
|
||||||
|
return tr.doc;
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { Transform, ReplaceStep, Step } from "@tiptap/pm/transform";
|
||||||
|
import { getReplaceStep } from "./getReplaceStep";
|
||||||
|
|
||||||
|
// join adjacent ReplaceSteps
|
||||||
|
export function simplifyTransform(tr: Transform) {
|
||||||
|
if (!tr.steps.length) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const newTr = new Transform(tr.docs[0]);
|
||||||
|
const oldSteps = tr.steps.slice();
|
||||||
|
|
||||||
|
while (oldSteps.length) {
|
||||||
|
let step = oldSteps.shift();
|
||||||
|
while (oldSteps.length && step.merge(oldSteps[0])) {
|
||||||
|
const addedStep = oldSteps.shift();
|
||||||
|
if (step instanceof ReplaceStep && addedStep instanceof ReplaceStep) {
|
||||||
|
step = getReplaceStep(
|
||||||
|
newTr.doc,
|
||||||
|
addedStep.apply(step.apply(newTr.doc).doc).doc,
|
||||||
|
// @ts-ignore
|
||||||
|
) as Step<any>;
|
||||||
|
} else {
|
||||||
|
step = step.merge(addedStep);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
newTr.step(step);
|
||||||
|
}
|
||||||
|
return newTr;
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export interface AnyObject {
|
||||||
|
[p: string]: any;
|
||||||
|
}
|
||||||
@@ -197,11 +197,15 @@ const replace = (
|
|||||||
});
|
});
|
||||||
|
|
||||||
const marks = Array.from(marksSet);
|
const marks = Array.from(marksSet);
|
||||||
|
|
||||||
// Delete the old text and insert new text with preserved marks
|
// Delete the old text
|
||||||
tr.delete(from, to);
|
tr.delete(from, to);
|
||||||
tr.insert(from, state.schema.text(replaceTerm, marks));
|
|
||||||
|
|
||||||
|
// Only insert new text if replaceTerm is not empty (allows for deletion when replaceTerm is empty)
|
||||||
|
if (replaceTerm) {
|
||||||
|
tr.insert(from, state.schema.text(replaceTerm, marks));
|
||||||
|
}
|
||||||
|
|
||||||
dispatch(tr);
|
dispatch(tr);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -228,10 +232,14 @@ const replaceAll = (
|
|||||||
});
|
});
|
||||||
|
|
||||||
const marks = Array.from(marksSet);
|
const marks = Array.from(marksSet);
|
||||||
|
|
||||||
// Delete and insert with preserved marks
|
// Delete the old text
|
||||||
tr.delete(from, to);
|
tr.delete(from, to);
|
||||||
tr.insert(from, tr.doc.type.schema.text(replaceTerm, marks));
|
|
||||||
|
// Only insert new text if replaceTerm is not empty (allows for deletion when replaceTerm is empty)
|
||||||
|
if (replaceTerm) {
|
||||||
|
tr.insert(from, tr.doc.type.schema.text(replaceTerm, marks));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
dispatch(tr);
|
dispatch(tr);
|
||||||
|
|||||||
Generated
+18
@@ -7,6 +7,7 @@ settings:
|
|||||||
overrides:
|
overrides:
|
||||||
jsdom: 25.0.1
|
jsdom: 25.0.1
|
||||||
jsonwebtoken: 9.0.3
|
jsonwebtoken: 9.0.3
|
||||||
|
prosemirror-changeset: 2.3.1
|
||||||
y-prosemirror: 1.3.7
|
y-prosemirror: 1.3.7
|
||||||
|
|
||||||
patchedDependencies:
|
patchedDependencies:
|
||||||
@@ -141,6 +142,9 @@ importers:
|
|||||||
date-fns:
|
date-fns:
|
||||||
specifier: ^4.1.0
|
specifier: ^4.1.0
|
||||||
version: 4.1.0
|
version: 4.1.0
|
||||||
|
diff:
|
||||||
|
specifier: 8.0.3
|
||||||
|
version: 8.0.3
|
||||||
dompurify:
|
dompurify:
|
||||||
specifier: ^3.2.6
|
specifier: ^3.2.6
|
||||||
version: 3.2.6
|
version: 3.2.6
|
||||||
@@ -171,6 +175,9 @@ importers:
|
|||||||
qrcode:
|
qrcode:
|
||||||
specifier: ^1.5.4
|
specifier: ^1.5.4
|
||||||
version: 1.5.4
|
version: 1.5.4
|
||||||
|
rfc6902:
|
||||||
|
specifier: 5.1.2
|
||||||
|
version: 5.1.2
|
||||||
uuid:
|
uuid:
|
||||||
specifier: ^11.1.0
|
specifier: ^11.1.0
|
||||||
version: 11.1.0
|
version: 11.1.0
|
||||||
@@ -6110,6 +6117,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-uIFDxqpRZGZ6ThOk84hEfqWoHx2devRFvpTZcTHur85vImfaxUbTW9Ryh4CpCuDnToOP1CEtXKIgytHBPVff5A==}
|
resolution: {integrity: sha512-uIFDxqpRZGZ6ThOk84hEfqWoHx2devRFvpTZcTHur85vImfaxUbTW9Ryh4CpCuDnToOP1CEtXKIgytHBPVff5A==}
|
||||||
engines: {node: '>=0.3.1'}
|
engines: {node: '>=0.3.1'}
|
||||||
|
|
||||||
|
diff@8.0.3:
|
||||||
|
resolution: {integrity: sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ==}
|
||||||
|
engines: {node: '>=0.3.1'}
|
||||||
|
|
||||||
dijkstrajs@1.0.3:
|
dijkstrajs@1.0.3:
|
||||||
resolution: {integrity: sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==}
|
resolution: {integrity: sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==}
|
||||||
|
|
||||||
@@ -9056,6 +9067,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==}
|
resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==}
|
||||||
engines: {iojs: '>=1.0.0', node: '>=0.10.0'}
|
engines: {iojs: '>=1.0.0', node: '>=0.10.0'}
|
||||||
|
|
||||||
|
rfc6902@5.1.2:
|
||||||
|
resolution: {integrity: sha512-zxcb+PWlE8PwX0tiKE6zP97THQ8/lHmeiwucRrJ3YFupWEmp25RmFSlB1dNTqjkovwqG4iq+u1gzJMBS3um8mA==}
|
||||||
|
|
||||||
rfdc@1.3.1:
|
rfdc@1.3.1:
|
||||||
resolution: {integrity: sha512-r5a3l5HzYlIC68TpmYKlxWjmOP6wiPJ1vWv2HeLhNsRZMrCkxeqxiHlQ21oXmQ4F3SiryXBHhAD7JZqvOJjFmg==}
|
resolution: {integrity: sha512-r5a3l5HzYlIC68TpmYKlxWjmOP6wiPJ1vWv2HeLhNsRZMrCkxeqxiHlQ21oXmQ4F3SiryXBHhAD7JZqvOJjFmg==}
|
||||||
|
|
||||||
@@ -16747,6 +16761,8 @@ snapshots:
|
|||||||
|
|
||||||
diff@5.2.0: {}
|
diff@5.2.0: {}
|
||||||
|
|
||||||
|
diff@8.0.3: {}
|
||||||
|
|
||||||
dijkstrajs@1.0.3: {}
|
dijkstrajs@1.0.3: {}
|
||||||
|
|
||||||
dingbat-to-unicode@1.0.1: {}
|
dingbat-to-unicode@1.0.1: {}
|
||||||
@@ -20318,6 +20334,8 @@ snapshots:
|
|||||||
|
|
||||||
reusify@1.1.0: {}
|
reusify@1.1.0: {}
|
||||||
|
|
||||||
|
rfc6902@5.1.2: {}
|
||||||
|
|
||||||
rfdc@1.3.1: {}
|
rfdc@1.3.1: {}
|
||||||
|
|
||||||
rimraf@3.0.2:
|
rimraf@3.0.2:
|
||||||
|
|||||||
Reference in New Issue
Block a user