mirror of
https://github.com/docmost/docmost.git
synced 2026-07-23 19:22:47 +10:00
feat: page verification workflow
This commit is contained in:
@@ -2,13 +2,31 @@ import classes from "@/features/editor/styles/editor.module.css";
|
||||
import React from "react";
|
||||
import { TitleEditor } from "@/features/editor/title-editor";
|
||||
import PageEditor from "@/features/editor/page-editor";
|
||||
import { Container } from "@mantine/core";
|
||||
import {
|
||||
Container,
|
||||
Divider,
|
||||
Group,
|
||||
Popover,
|
||||
Stack,
|
||||
Text,
|
||||
UnstyledButton,
|
||||
} from "@mantine/core";
|
||||
import { useAtom } from "jotai";
|
||||
import { userAtom } from "@/features/user/atoms/current-user-atom.ts";
|
||||
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
|
||||
import { PageVerificationBadge } from "@/ee/page-verification";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { IContributor } from "@/features/page/types/page.types.ts";
|
||||
|
||||
const MemoizedTitleEditor = React.memo(TitleEditor);
|
||||
const MemoizedPageEditor = React.memo(PageEditor);
|
||||
|
||||
type PageCreator = {
|
||||
id: string;
|
||||
name: string;
|
||||
avatarUrl: string;
|
||||
};
|
||||
|
||||
export interface FullEditorProps {
|
||||
pageId: string;
|
||||
slugId: string;
|
||||
@@ -16,6 +34,8 @@ export interface FullEditorProps {
|
||||
content: string;
|
||||
spaceSlug: string;
|
||||
editable: boolean;
|
||||
creator?: PageCreator;
|
||||
contributors?: IContributor[];
|
||||
}
|
||||
|
||||
export function FullEditor({
|
||||
@@ -25,6 +45,8 @@ export function FullEditor({
|
||||
content,
|
||||
spaceSlug,
|
||||
editable,
|
||||
creator,
|
||||
contributors,
|
||||
}: FullEditorProps) {
|
||||
const [user] = useAtom(userAtom);
|
||||
const fullPageWidth = user.settings?.preferences?.fullPageWidth;
|
||||
@@ -42,6 +64,11 @@ export function FullEditor({
|
||||
spaceSlug={spaceSlug}
|
||||
editable={editable}
|
||||
/>
|
||||
<PageByline
|
||||
creator={creator}
|
||||
contributors={contributors}
|
||||
readOnly={!editable}
|
||||
/>
|
||||
<MemoizedPageEditor
|
||||
pageId={pageId}
|
||||
editable={editable}
|
||||
@@ -50,3 +77,91 @@ export function FullEditor({
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
type PageBylineProps = {
|
||||
creator?: PageCreator;
|
||||
contributors?: IContributor[];
|
||||
readOnly?: boolean;
|
||||
};
|
||||
|
||||
function PageByline({
|
||||
creator,
|
||||
contributors,
|
||||
readOnly,
|
||||
}: PageBylineProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const otherContributors = (contributors ?? []).filter(
|
||||
(c) => c.id !== creator?.id,
|
||||
);
|
||||
|
||||
return (
|
||||
<Group
|
||||
gap="sm"
|
||||
mb="md"
|
||||
style={{ marginTop: "-0.5em", paddingLeft: "3rem" }}
|
||||
>
|
||||
{creator && (
|
||||
<Popover position="bottom-start" shadow="md" width={280} withArrow>
|
||||
<Popover.Target>
|
||||
<UnstyledButton>
|
||||
<Group gap={6}>
|
||||
<CustomAvatar
|
||||
avatarUrl={creator.avatarUrl}
|
||||
name={creator.name}
|
||||
size={22}
|
||||
/>
|
||||
<Text size="sm" c="dimmed">
|
||||
{t("By {{name}}", { name: creator.name })}
|
||||
</Text>
|
||||
</Group>
|
||||
</UnstyledButton>
|
||||
</Popover.Target>
|
||||
<Popover.Dropdown>
|
||||
<Stack gap="xs">
|
||||
<Group gap="sm">
|
||||
<CustomAvatar
|
||||
avatarUrl={creator.avatarUrl}
|
||||
name={creator.name}
|
||||
size={36}
|
||||
/>
|
||||
<div>
|
||||
<Text size="sm" fw={500}>
|
||||
{creator.name}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{otherContributors.length === 0
|
||||
? t("Owner, no contributors")
|
||||
: t("Owner")}
|
||||
</Text>
|
||||
</div>
|
||||
</Group>
|
||||
|
||||
{otherContributors.length > 0 && (
|
||||
<>
|
||||
<Divider />
|
||||
<Text size="xs" fw={500} c="dimmed" tt="uppercase">
|
||||
{t("Contributors")}
|
||||
</Text>
|
||||
<Stack gap={6}>
|
||||
{otherContributors.map((contributor) => (
|
||||
<Group gap="sm" key={contributor.id}>
|
||||
<CustomAvatar
|
||||
avatarUrl={contributor.avatarUrl}
|
||||
name={contributor.name}
|
||||
size={28}
|
||||
/>
|
||||
<Text size="sm">{contributor.name}</Text>
|
||||
</Group>
|
||||
))}
|
||||
</Stack>
|
||||
</>
|
||||
)}
|
||||
</Stack>
|
||||
</Popover.Dropdown>
|
||||
</Popover>
|
||||
)}
|
||||
<PageVerificationBadge readOnly={readOnly} />
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -50,6 +50,16 @@ export function NotificationItem({
|
||||
return notification.data?.role === "writer"
|
||||
? t("gave you edit access to a page")
|
||||
: t("gave you view access to a page");
|
||||
case "page.verification_expiring":
|
||||
return t("Page verification expires soon");
|
||||
case "page.verification_expired":
|
||||
return t("Page verification has expired");
|
||||
case "page.verified":
|
||||
return t("verified a page");
|
||||
case "page.approval_requested":
|
||||
return t("submitted a page for your approval");
|
||||
case "page.approval_rejected":
|
||||
return t("returned a page for revision");
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
@@ -95,9 +105,13 @@ export function NotificationItem({
|
||||
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<Text size="sm" lineClamp={2}>
|
||||
<Text span fw={600}>
|
||||
{notification.actor?.name}
|
||||
</Text>{" "}
|
||||
{notification.actor?.name ? (
|
||||
<>
|
||||
<Text span fw={600}>
|
||||
{notification.actor.name}
|
||||
</Text>{" "}
|
||||
</>
|
||||
) : null}
|
||||
{getNotificationMessage()}
|
||||
</Text>
|
||||
|
||||
|
||||
@@ -3,7 +3,12 @@ export type NotificationType =
|
||||
| "comment.created"
|
||||
| "comment.resolved"
|
||||
| "page.user_mention"
|
||||
| "page.permission_granted";
|
||||
| "page.permission_granted"
|
||||
| "page.verification_expiring"
|
||||
| "page.verification_expired"
|
||||
| "page.verified"
|
||||
| "page.approval_requested"
|
||||
| "page.approval_rejected";
|
||||
|
||||
export type INotification = {
|
||||
id: string;
|
||||
|
||||
@@ -40,6 +40,10 @@ import { PageStateSegmentedControl } from "@/features/user/components/page-state
|
||||
import MovePageModal from "@/features/page/components/move-page-modal.tsx";
|
||||
import { useTimeAgo } from "@/hooks/use-time-ago.tsx";
|
||||
import { PageShareModal } from "@/ee/page-permission";
|
||||
import {
|
||||
PageVerificationMenuItem,
|
||||
PageVerificationModal,
|
||||
} from "@/ee/page-verification";
|
||||
|
||||
interface PageHeaderMenuProps {
|
||||
readOnly?: boolean;
|
||||
@@ -121,6 +125,10 @@ function PageActionMenu({ readOnly }: PageActionMenuProps) {
|
||||
movePageModalOpened,
|
||||
{ open: openMovePageModal, close: closeMoveSpaceModal },
|
||||
] = useDisclosure(false);
|
||||
const [
|
||||
verificationOpened,
|
||||
{ open: openVerificationModal, close: closeVerificationModal },
|
||||
] = useDisclosure(false);
|
||||
const [pageEditor] = useAtom(pageEditorAtom);
|
||||
const pageUpdatedAt = useTimeAgo(page?.updatedAt);
|
||||
|
||||
@@ -200,6 +208,10 @@ function PageActionMenu({ readOnly }: PageActionMenuProps) {
|
||||
{t("Page history")}
|
||||
</Menu.Item>
|
||||
|
||||
{!readOnly && (
|
||||
<PageVerificationMenuItem onClick={openVerificationModal} />
|
||||
)}
|
||||
|
||||
<Menu.Divider />
|
||||
|
||||
{!readOnly && (
|
||||
@@ -289,6 +301,12 @@ function PageActionMenu({ readOnly }: PageActionMenuProps) {
|
||||
onClose={closeMoveSpaceModal}
|
||||
open={movePageModalOpened}
|
||||
/>
|
||||
|
||||
<PageVerificationModal
|
||||
pageId={page.id}
|
||||
opened={verificationOpened}
|
||||
onClose={closeVerificationModal}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ export interface IPage {
|
||||
creator: ICreator;
|
||||
lastUpdatedBy: ILastUpdatedBy;
|
||||
deletedBy: IDeletedBy;
|
||||
contributors?: IContributor[];
|
||||
space: Partial<ISpace>;
|
||||
permissions?: {
|
||||
canEdit: boolean;
|
||||
@@ -29,6 +30,12 @@ export interface IPage {
|
||||
};
|
||||
}
|
||||
|
||||
export interface IContributor {
|
||||
id: string;
|
||||
name: string;
|
||||
avatarUrl: string;
|
||||
}
|
||||
|
||||
interface ICreator {
|
||||
id: string;
|
||||
name: string;
|
||||
|
||||
@@ -85,6 +85,11 @@ export type RefetchRootTreeNodeEvent = {
|
||||
spaceId: string;
|
||||
};
|
||||
|
||||
export type VerificationUpdatedEvent = {
|
||||
operation: "verificationUpdated";
|
||||
pageId: string;
|
||||
};
|
||||
|
||||
export type WebSocketEvent =
|
||||
| InvalidateEvent
|
||||
| CommentCreatedEvent
|
||||
@@ -96,4 +101,5 @@ export type WebSocketEvent =
|
||||
| AddTreeNodeEvent
|
||||
| MoveTreeNodeEvent
|
||||
| DeleteTreeNodeEvent
|
||||
| RefetchRootTreeNodeEvent;
|
||||
| RefetchRootTreeNodeEvent
|
||||
| VerificationUpdatedEvent;
|
||||
|
||||
@@ -157,6 +157,11 @@ export const useQuerySubscription = () => {
|
||||
});
|
||||
break;
|
||||
}
|
||||
case "verificationUpdated":
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["page-verification-info", data.pageId],
|
||||
});
|
||||
break;
|
||||
}
|
||||
});
|
||||
}, [queryClient, socket]);
|
||||
|
||||
Reference in New Issue
Block a user