feat: page details section and backlinks (#2186)

* feat: page details section and backlinks
This commit is contained in:
Philip Okugbe
2026-05-09 17:03:08 +01:00
committed by GitHub
parent bdc369fce0
commit 537e45bc11
21 changed files with 942 additions and 10 deletions
@@ -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;
}