mirror of
https://github.com/docmost/docmost.git
synced 2026-05-15 13:14:11 +08:00
feat: page details section and backlinks (#2186)
* feat: page details section and backlinks
This commit is contained in:
@@ -8,6 +8,7 @@ import { TableOfContents } from "@/features/editor/components/table-of-contents/
|
||||
import { useAtomValue } from "jotai";
|
||||
import { pageEditorAtom } from "@/features/editor/atoms/editor-atoms.ts";
|
||||
import AsideChatPanel from "@/ee/ai-chat/components/aside-chat-panel";
|
||||
import { PageDetailsAside } from "@/features/page-details/components/page-details-aside.tsx";
|
||||
|
||||
export default function Aside() {
|
||||
const [{ tab }] = useAtom(asideStateAtom);
|
||||
@@ -30,6 +31,10 @@ export default function Aside() {
|
||||
component = <AsideChatPanel />;
|
||||
title = "AI Chat";
|
||||
break;
|
||||
case "details":
|
||||
component = <PageDetailsAside />;
|
||||
title = "Details";
|
||||
break;
|
||||
default:
|
||||
component = null;
|
||||
title = null;
|
||||
|
||||
@@ -147,7 +147,9 @@ export default function GlobalAppShell({
|
||||
? t("Table of contents")
|
||||
: asideTab === "chat"
|
||||
? t("AI Chat")
|
||||
: undefined
|
||||
: asideTab === "details"
|
||||
? t("Details")
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<Aside />
|
||||
|
||||
@@ -10,6 +10,7 @@ export const desktopSidebarAtom = atomWithWebStorage<boolean>(
|
||||
|
||||
export const desktopAsideAtom = atom<boolean>(false);
|
||||
|
||||
// Valid `tab` values: "" | "comments" | "toc" | "chat" | "details"
|
||||
type AsideStateType = {
|
||||
tab: string;
|
||||
isAsideOpen: boolean;
|
||||
|
||||
@@ -118,10 +118,20 @@ export function PageVerificationBadge({
|
||||
|
||||
if (status === "none" && readOnly) return null;
|
||||
|
||||
const tooltipLabel =
|
||||
status === "verified" && verificationInfo?.expiresAt
|
||||
? t("Verified until {{date}}", {
|
||||
date: new Date(verificationInfo.expiresAt).toLocaleDateString(
|
||||
undefined,
|
||||
{ month: "long", day: "numeric", year: "numeric" },
|
||||
),
|
||||
})
|
||||
: getStatusLabel(status, t);
|
||||
|
||||
return (
|
||||
<>
|
||||
{status !== "none" ? (
|
||||
<Tooltip label={getStatusLabel(status, t)} withArrow openDelay={250}>
|
||||
<Tooltip label={tooltipLabel} withArrow openDelay={250}>
|
||||
<Group
|
||||
gap={4}
|
||||
onClick={open}
|
||||
|
||||
@@ -3,14 +3,17 @@ import React from "react";
|
||||
import { TitleEditor } from "@/features/editor/title-editor";
|
||||
import PageEditor from "@/features/editor/page-editor";
|
||||
import {
|
||||
ActionIcon,
|
||||
Container,
|
||||
Divider,
|
||||
Group,
|
||||
Popover,
|
||||
Stack,
|
||||
Text,
|
||||
Tooltip,
|
||||
UnstyledButton,
|
||||
} from "@mantine/core";
|
||||
import { IconInfoCircle } from "@tabler/icons-react";
|
||||
import { useAtom } from "jotai";
|
||||
import { userAtom } from "@/features/user/atoms/current-user-atom.ts";
|
||||
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
|
||||
@@ -19,6 +22,8 @@ import { useTranslation } from "react-i18next";
|
||||
import { IContributor } from "@/features/page/types/page.types.ts";
|
||||
import { FixedToolbar } from "@/features/editor/components/fixed-toolbar/fixed-toolbar";
|
||||
import { PageEditMode } from "@/features/user/types/user.types.ts";
|
||||
import useToggleAside from "@/hooks/use-toggle-aside.tsx";
|
||||
import clsx from "clsx";
|
||||
|
||||
const MemoizedTitleEditor = React.memo(TitleEditor);
|
||||
const MemoizedPageEditor = React.memo(PageEditor);
|
||||
@@ -101,6 +106,7 @@ function PageByline({
|
||||
readOnly,
|
||||
}: PageBylineProps) {
|
||||
const { t } = useTranslation();
|
||||
const toggleAside = useToggleAside();
|
||||
|
||||
const otherContributors = (contributors ?? []).filter(
|
||||
(c) => c.id !== creator?.id,
|
||||
@@ -110,8 +116,8 @@ function PageByline({
|
||||
<Group
|
||||
gap="sm"
|
||||
mb="md"
|
||||
className="print-hide"
|
||||
style={{ marginTop: "-0.5em", paddingLeft: "3rem" }}
|
||||
className={clsx("print-hide", classes.byline)}
|
||||
style={{ marginTop: "-0.5em" }}
|
||||
>
|
||||
{creator && (
|
||||
<Popover position="bottom-start" shadow="md" width={280} withArrow>
|
||||
@@ -173,6 +179,17 @@ function PageByline({
|
||||
</Popover.Dropdown>
|
||||
</Popover>
|
||||
)}
|
||||
<Tooltip label={t("Details")} withArrow openDelay={250}>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
aria-label={t("Details")}
|
||||
onClick={() => toggleAside("details")}
|
||||
>
|
||||
<IconInfoCircle size={20} stroke={1.5} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
|
||||
<PageVerificationBadge readOnly={readOnly} />
|
||||
</Group>
|
||||
);
|
||||
|
||||
@@ -9,3 +9,15 @@
|
||||
}
|
||||
}
|
||||
|
||||
.byline {
|
||||
padding-left: 3rem;
|
||||
|
||||
@media (max-width: $mantine-breakpoint-sm) {
|
||||
padding-left: 1rem;
|
||||
}
|
||||
|
||||
@media print {
|
||||
padding-left: 0;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
import {
|
||||
Button,
|
||||
Center,
|
||||
Group,
|
||||
Loader,
|
||||
Stack,
|
||||
Text,
|
||||
UnstyledButton,
|
||||
} from "@mantine/core";
|
||||
import { Link } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useBacklinksQuery } from "@/features/page-details/queries/backlinks-query.ts";
|
||||
import {
|
||||
BacklinkDirection,
|
||||
IBacklinkPageItem,
|
||||
} from "@/features/page-details/types/backlink.types.ts";
|
||||
import { buildPageUrl } from "@/features/page/page.utils.ts";
|
||||
import { getPageIcon } from "@/lib";
|
||||
|
||||
interface BacklinksListProps {
|
||||
pageId: string;
|
||||
direction: BacklinkDirection;
|
||||
enabled: boolean;
|
||||
onItemClick: () => void;
|
||||
}
|
||||
|
||||
export function BacklinksList({
|
||||
pageId,
|
||||
direction,
|
||||
enabled,
|
||||
onItemClick,
|
||||
}: BacklinksListProps) {
|
||||
const { t } = useTranslation();
|
||||
const { data, isLoading, fetchNextPage, hasNextPage, isFetchingNextPage } =
|
||||
useBacklinksQuery(pageId, direction, enabled);
|
||||
|
||||
if (!enabled) return null;
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Center py="sm">
|
||||
<Loader size="sm" />
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
|
||||
const items: IBacklinkPageItem[] =
|
||||
data?.pages.flatMap((page) => page.items) ?? [];
|
||||
|
||||
if (items.length === 0) {
|
||||
return (
|
||||
<Text c="dimmed" size="sm" py="md">
|
||||
{direction === "incoming"
|
||||
? t("No pages link here yet.")
|
||||
: t("This page doesn't link to other pages yet.")}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
const handleClick = (e: React.MouseEvent) => {
|
||||
if (e.metaKey || e.ctrlKey || e.shiftKey || e.button === 1) {
|
||||
return;
|
||||
}
|
||||
onItemClick();
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack gap={4}>
|
||||
{items.map((item) => (
|
||||
<UnstyledButton
|
||||
key={item.id}
|
||||
component={Link}
|
||||
to={
|
||||
item.space?.slug
|
||||
? buildPageUrl(
|
||||
item.space.slug,
|
||||
item.slugId,
|
||||
item.title ?? undefined,
|
||||
)
|
||||
: "#"
|
||||
}
|
||||
onClick={handleClick}
|
||||
style={{ padding: "8px 4px", borderRadius: 4, userSelect: "none" }}
|
||||
>
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
{getPageIcon(item.icon ?? "")}
|
||||
<Stack gap={0} style={{ flex: 1, minWidth: 0 }}>
|
||||
<Text size="sm" fw={500} lineClamp={1}>
|
||||
{item.title || t("Untitled")}
|
||||
</Text>
|
||||
{item.space?.name && (
|
||||
<Text size="xs" c="dimmed" lineClamp={1}>
|
||||
{item.space.name}
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
</Group>
|
||||
</UnstyledButton>
|
||||
))}
|
||||
{hasNextPage && (
|
||||
<Button
|
||||
variant="subtle"
|
||||
size="xs"
|
||||
loading={isFetchingNextPage}
|
||||
onClick={() => fetchNextPage()}
|
||||
mt="xs"
|
||||
>
|
||||
{t("Load more")}
|
||||
</Button>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { Modal, Stack, Text } from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useBacklinksCountQuery } from "@/features/page-details/queries/backlinks-query.ts";
|
||||
import { BacklinksList } from "./backlinks-list";
|
||||
|
||||
interface BacklinksModalProps {
|
||||
pageId: string;
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function BacklinksModal({
|
||||
pageId,
|
||||
opened,
|
||||
onClose,
|
||||
}: BacklinksModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const { data: counts } = useBacklinksCountQuery(pageId);
|
||||
|
||||
return (
|
||||
<Modal.Root opened={opened} onClose={onClose} size={640} yOffset="10vh">
|
||||
<Modal.Overlay />
|
||||
<Modal.Content>
|
||||
<Modal.Header>
|
||||
<Modal.Title fw={500}>{t("Backlinks")}</Modal.Title>
|
||||
<Modal.CloseButton />
|
||||
</Modal.Header>
|
||||
<Modal.Body>
|
||||
<Stack gap="lg">
|
||||
<Stack gap="xs">
|
||||
<Text size="sm" fw={500} c="dimmed">
|
||||
{t("Incoming links ({{count}})", {
|
||||
count: counts?.incoming ?? 0,
|
||||
})}
|
||||
</Text>
|
||||
<BacklinksList
|
||||
pageId={pageId}
|
||||
direction="incoming"
|
||||
enabled={opened}
|
||||
onItemClick={onClose}
|
||||
/>
|
||||
</Stack>
|
||||
|
||||
<Stack gap="xs">
|
||||
<Text size="sm" fw={500} c="dimmed">
|
||||
{t("Outgoing links ({{count}})", {
|
||||
count: counts?.outgoing ?? 0,
|
||||
})}
|
||||
</Text>
|
||||
<BacklinksList
|
||||
pageId={pageId}
|
||||
direction="outgoing"
|
||||
enabled={opened}
|
||||
onItemClick={onClose}
|
||||
/>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Modal.Body>
|
||||
</Modal.Content>
|
||||
</Modal.Root>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
import {
|
||||
Divider,
|
||||
Group,
|
||||
Skeleton,
|
||||
Stack,
|
||||
Text,
|
||||
UnstyledButton,
|
||||
} from "@mantine/core";
|
||||
import { IconChevronRight } from "@tabler/icons-react";
|
||||
import { useDisclosure } from "@mantine/hooks";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { useParams } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { extractPageSlugId } from "@/lib";
|
||||
import { usePageQuery } from "@/features/page/queries/page-query.ts";
|
||||
import { pageEditorAtom } from "@/features/editor/atoms/editor-atoms.ts";
|
||||
import { useBacklinksCountQuery } from "@/features/page-details/queries/backlinks-query.ts";
|
||||
import { BacklinksModal } from "./backlinks-modal";
|
||||
import { formattedDate, timeAgo } from "@/lib/time.ts";
|
||||
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
|
||||
|
||||
export function PageDetailsAside() {
|
||||
const { pageSlug } = useParams();
|
||||
const { data: page } = usePageQuery({
|
||||
pageId: extractPageSlugId(pageSlug),
|
||||
});
|
||||
const pageEditor = useAtomValue(pageEditorAtom);
|
||||
const { data: counts, isLoading: countsLoading } = useBacklinksCountQuery(page?.id);
|
||||
const [modalOpened, { open: openModal, close: closeModal }] =
|
||||
useDisclosure(false);
|
||||
|
||||
if (!page) return null;
|
||||
|
||||
const wordCount: number =
|
||||
pageEditor?.storage?.characterCount?.words?.() ?? 0;
|
||||
const characterCount: number =
|
||||
pageEditor?.storage?.characterCount?.characters?.() ?? 0;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Stack gap="md">
|
||||
<PeopleSection
|
||||
creator={page.creator}
|
||||
lastUpdatedBy={page.lastUpdatedBy}
|
||||
/>
|
||||
|
||||
<Divider />
|
||||
|
||||
<StatsSection
|
||||
wordCount={wordCount}
|
||||
characterCount={characterCount}
|
||||
createdAt={page.createdAt}
|
||||
updatedAt={page.updatedAt}
|
||||
/>
|
||||
|
||||
<Divider />
|
||||
|
||||
<BacklinksSection
|
||||
incomingCount={counts?.incoming ?? 0}
|
||||
outgoingCount={counts?.outgoing ?? 0}
|
||||
isLoading={countsLoading}
|
||||
onClick={openModal}
|
||||
/>
|
||||
</Stack>
|
||||
|
||||
<BacklinksModal
|
||||
pageId={page.id}
|
||||
opened={modalOpened}
|
||||
onClose={closeModal}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function PeopleSection({
|
||||
creator,
|
||||
lastUpdatedBy,
|
||||
}: {
|
||||
creator: { id: string; name: string; avatarUrl: string } | null;
|
||||
lastUpdatedBy: { id: string; name: string; avatarUrl: string } | null;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<Stack gap="xs">
|
||||
<PersonRow label={t("Created by")} person={creator} />
|
||||
<PersonRow label={t("Last updated by")} person={lastUpdatedBy} />
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
function PersonRow({
|
||||
label,
|
||||
person,
|
||||
}: {
|
||||
label: string;
|
||||
person: { id: string; name: string; avatarUrl: string } | null;
|
||||
}) {
|
||||
return (
|
||||
<Group justify="space-between" wrap="nowrap">
|
||||
<Text size="sm" c="dimmed">
|
||||
{label}
|
||||
</Text>
|
||||
{person ? (
|
||||
<Group gap={6} wrap="nowrap">
|
||||
<CustomAvatar
|
||||
avatarUrl={person.avatarUrl}
|
||||
name={person.name}
|
||||
size={20}
|
||||
radius="xl"
|
||||
/>
|
||||
<Text size="sm" lineClamp={1}>
|
||||
{person.name}
|
||||
</Text>
|
||||
</Group>
|
||||
) : (
|
||||
<Text size="sm" c="dimmed">
|
||||
—
|
||||
</Text>
|
||||
)}
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
function StatsSection({
|
||||
wordCount,
|
||||
characterCount,
|
||||
createdAt,
|
||||
updatedAt,
|
||||
}: {
|
||||
wordCount: number;
|
||||
characterCount: number;
|
||||
createdAt: Date | string;
|
||||
updatedAt: Date | string;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<Stack gap="xs">
|
||||
<Text size="xs" fw={500} c="dimmed">
|
||||
{t("Stats")}
|
||||
</Text>
|
||||
<StatRow label={t("Word count")} value={String(wordCount)} />
|
||||
<StatRow label={t("Characters")} value={String(characterCount)} />
|
||||
<StatRow
|
||||
label={t("Created")}
|
||||
value={formattedDate(new Date(createdAt))}
|
||||
/>
|
||||
<StatRow
|
||||
label={t("Last updated")}
|
||||
value={timeAgo(new Date(updatedAt))}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
function StatRow({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<Group justify="space-between" wrap="nowrap">
|
||||
<Text size="sm" c="dimmed">
|
||||
{label}
|
||||
</Text>
|
||||
<Text size="sm">{value}</Text>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
function BacklinksSection({
|
||||
incomingCount,
|
||||
outgoingCount,
|
||||
isLoading,
|
||||
onClick,
|
||||
}: {
|
||||
incomingCount: number;
|
||||
outgoingCount: number;
|
||||
isLoading: boolean;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<Stack gap="xs">
|
||||
<Text size="xs" fw={500} c="dimmed">
|
||||
{t("Backlinks")}
|
||||
</Text>
|
||||
<BacklinksRow
|
||||
label={t("Incoming links")}
|
||||
count={incomingCount}
|
||||
isLoading={isLoading}
|
||||
onClick={onClick}
|
||||
/>
|
||||
<BacklinksRow
|
||||
label={t("Outgoing links")}
|
||||
count={outgoingCount}
|
||||
isLoading={isLoading}
|
||||
onClick={onClick}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
function BacklinksRow({
|
||||
label,
|
||||
count,
|
||||
isLoading,
|
||||
onClick,
|
||||
}: {
|
||||
label: string;
|
||||
count: number;
|
||||
isLoading: boolean;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
return (
|
||||
<UnstyledButton
|
||||
onClick={onClick}
|
||||
style={{
|
||||
padding: "4px 4px",
|
||||
borderRadius: 4,
|
||||
}}
|
||||
>
|
||||
<Group justify="space-between" wrap="nowrap">
|
||||
<Text size="sm" c="dimmed">
|
||||
{label}
|
||||
</Text>
|
||||
<Group gap={6} wrap="nowrap">
|
||||
{isLoading ? (
|
||||
<Skeleton height={18} width={20} />
|
||||
) : (
|
||||
<Text size="sm">{count}</Text>
|
||||
)}
|
||||
<IconChevronRight size={16} stroke={2} color="var(--mantine-color-dimmed)" />
|
||||
</Group>
|
||||
</Group>
|
||||
</UnstyledButton>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { useInfiniteQuery, useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
getBacklinks,
|
||||
getBacklinksCount,
|
||||
} from "@/features/page-details/services/backlinks-service.ts";
|
||||
import {
|
||||
BacklinkDirection,
|
||||
IBacklinkCount,
|
||||
} from "@/features/page-details/types/backlink.types.ts";
|
||||
|
||||
const BACKLINKS_STALE_TIME = 30 * 1000;
|
||||
const BACKLINKS_PAGE_LIMIT = 100;
|
||||
|
||||
export function useBacklinksCountQuery(pageId: string | undefined) {
|
||||
return useQuery<IBacklinkCount>({
|
||||
queryKey: ["backlinks-count", pageId],
|
||||
queryFn: () => getBacklinksCount(pageId as string),
|
||||
enabled: !!pageId,
|
||||
staleTime: BACKLINKS_STALE_TIME,
|
||||
});
|
||||
}
|
||||
|
||||
export function useBacklinksQuery(
|
||||
pageId: string | undefined,
|
||||
direction: BacklinkDirection,
|
||||
enabled: boolean,
|
||||
) {
|
||||
return useInfiniteQuery({
|
||||
queryKey: ["backlinks", pageId, direction],
|
||||
queryFn: ({ pageParam }) =>
|
||||
getBacklinks({
|
||||
pageId: pageId as string,
|
||||
direction,
|
||||
cursor: pageParam,
|
||||
limit: BACKLINKS_PAGE_LIMIT,
|
||||
}),
|
||||
enabled: enabled && !!pageId,
|
||||
initialPageParam: undefined as string | undefined,
|
||||
getNextPageParam: (lastPage) =>
|
||||
lastPage.meta.hasNextPage
|
||||
? (lastPage.meta.nextCursor ?? undefined)
|
||||
: undefined,
|
||||
staleTime: BACKLINKS_STALE_TIME,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import api from "@/lib/api-client";
|
||||
import { IPagination } from "@/lib/types.ts";
|
||||
import {
|
||||
IBacklinkCount,
|
||||
IBacklinkPageItem,
|
||||
IBacklinksListParams,
|
||||
} from "@/features/page-details/types/backlink.types.ts";
|
||||
|
||||
export async function getBacklinksCount(
|
||||
pageId: string,
|
||||
): Promise<IBacklinkCount> {
|
||||
const req = await api.post<IBacklinkCount>("/pages/backlinks-count", {
|
||||
pageId,
|
||||
});
|
||||
return req.data;
|
||||
}
|
||||
|
||||
export async function getBacklinks(
|
||||
params: IBacklinksListParams,
|
||||
): Promise<IPagination<IBacklinkPageItem>> {
|
||||
const req = await api.post<IPagination<IBacklinkPageItem>>(
|
||||
"/pages/backlinks",
|
||||
params,
|
||||
);
|
||||
return req.data;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
export type BacklinkDirection = "incoming" | "outgoing";
|
||||
|
||||
export interface IBacklinkCount {
|
||||
incoming: number;
|
||||
outgoing: number;
|
||||
}
|
||||
|
||||
export interface IBacklinkPageItem {
|
||||
id: string;
|
||||
slugId: string;
|
||||
title: string | null;
|
||||
icon: string | null;
|
||||
spaceId: string;
|
||||
space: { id: string; slug: string; name: string } | null;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface IBacklinksListParams {
|
||||
pageId: string;
|
||||
direction: BacklinkDirection;
|
||||
cursor?: string;
|
||||
limit?: number;
|
||||
}
|
||||
Reference in New Issue
Block a user