Add POST /v1/pages/backlinks/reindex to rebuild acadenice_backlink for every non-deleted page of the workspace in one call; the per-save indexer never backfills pre-existing content so graph + backlinks stay empty otherwise. Surface direct sub-pages in the linked references panel via a parent_child group sourced live from pages.parent_page_id (same hierarchy the Knowledge Graph uses), giving Notion-like parity. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
230 lines
6.5 KiB
TypeScript
230 lines
6.5 KiB
TypeScript
import React, { useMemo } from 'react';
|
|
import {
|
|
Accordion,
|
|
ActionIcon,
|
|
Alert,
|
|
Badge,
|
|
Box,
|
|
Group,
|
|
Loader,
|
|
Paper,
|
|
Text,
|
|
UnstyledButton,
|
|
} from '@mantine/core';
|
|
import {
|
|
IconExternalLink,
|
|
IconFileDescription,
|
|
IconHash,
|
|
IconInfoCircle,
|
|
IconLink,
|
|
} from '@tabler/icons-react';
|
|
import { useNavigate } from 'react-router-dom';
|
|
import { useTranslation } from 'react-i18next';
|
|
import { useBacklinks } from '../queries/backlinks-query';
|
|
import type { BacklinkEntry } from '../queries/backlinks-query';
|
|
import classes from './linked-references-panel.module.css';
|
|
|
|
interface LinkedReferencesPanelProps {
|
|
pageId: string;
|
|
}
|
|
|
|
/**
|
|
* Panel displaying all backlinks pointing to the current page, grouped by link type.
|
|
*
|
|
* Placement: rendered inside the page sidebar or as a bottom-panel sticky below
|
|
* the editor (caller decides). The component is self-contained and stateless.
|
|
*
|
|
* Permission awareness: the backend already filters source pages the user cannot
|
|
* read. This component renders what it receives — no additional client-side
|
|
* permission check needed.
|
|
*/
|
|
export function LinkedReferencesPanel({ pageId }: LinkedReferencesPanelProps) {
|
|
const { t } = useTranslation();
|
|
const navigate = useNavigate();
|
|
const { data, isLoading, isError, refetch } = useBacklinks(pageId);
|
|
|
|
const groups = useMemo(() => {
|
|
if (!data) return [];
|
|
const result: Array<{ key: string; label: string; icon: React.ReactNode; entries: BacklinkEntry[] }> = [];
|
|
|
|
if (data.wikilinks.length > 0) {
|
|
result.push({
|
|
key: 'wikilinks',
|
|
label: t('backlinks.group.wikilinks', 'Wikilinks'),
|
|
icon: <IconLink size={14} />,
|
|
entries: data.wikilinks,
|
|
});
|
|
}
|
|
if (data.mentions.length > 0) {
|
|
result.push({
|
|
key: 'mentions',
|
|
label: t('backlinks.group.mentions', 'Mentions'),
|
|
icon: <IconHash size={14} />,
|
|
entries: data.mentions,
|
|
});
|
|
}
|
|
if (data.database_embeds.length > 0) {
|
|
result.push({
|
|
key: 'database_embeds',
|
|
label: t('backlinks.group.embeds', 'Database embeds'),
|
|
icon: <IconExternalLink size={14} />,
|
|
entries: data.database_embeds,
|
|
});
|
|
}
|
|
if (data.parentChild && data.parentChild.length > 0) {
|
|
result.push({
|
|
key: 'parent_child',
|
|
label: t('backlinks.group.subpages', 'Sub-pages'),
|
|
icon: <IconFileDescription size={14} />,
|
|
entries: data.parentChild,
|
|
});
|
|
}
|
|
return result;
|
|
}, [data, t]);
|
|
|
|
const handleNavigate = (entry: BacklinkEntry) => {
|
|
if (entry.source.slugId) {
|
|
// Navigate to the source page that links here.
|
|
// The full path includes the space slug — fall back to page UUID if slugId only.
|
|
const spacePart = entry.source.spaceSlug ? `/${entry.source.spaceSlug}` : '';
|
|
navigate(`${spacePart}/page/${entry.source.slugId}`);
|
|
}
|
|
};
|
|
|
|
if (isLoading) {
|
|
return (
|
|
<Box py="md" px="sm" data-testid="backlinks-loading">
|
|
<Loader size="xs" />
|
|
</Box>
|
|
);
|
|
}
|
|
|
|
if (isError) {
|
|
return (
|
|
<Alert
|
|
icon={<IconInfoCircle size={14} />}
|
|
color="red"
|
|
variant="light"
|
|
py="xs"
|
|
data-testid="backlinks-error"
|
|
>
|
|
<Group gap="xs">
|
|
<Text size="xs">{t('backlinks.error', 'Could not load linked references.')}</Text>
|
|
<UnstyledButton onClick={() => refetch()} style={{ textDecoration: 'underline' }}>
|
|
<Text size="xs">{t('backlinks.retry', 'Retry')}</Text>
|
|
</UnstyledButton>
|
|
</Group>
|
|
</Alert>
|
|
);
|
|
}
|
|
|
|
if (!data || data.total === 0) {
|
|
return (
|
|
<Box py="md" px="sm" data-testid="backlinks-empty">
|
|
<Text size="xs" c="dimmed">
|
|
{t('backlinks.empty', 'No pages link here yet.')}
|
|
</Text>
|
|
</Box>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<Box data-testid="backlinks-panel">
|
|
<Group gap="xs" px="sm" py="xs" align="center">
|
|
<Text size="xs" fw={600} tt="uppercase" c="dimmed">
|
|
{t('backlinks.panel.title', 'Linked references')}
|
|
</Text>
|
|
<Badge size="xs" variant="light" color="gray">
|
|
{data.total}
|
|
</Badge>
|
|
</Group>
|
|
|
|
<Accordion
|
|
multiple
|
|
defaultValue={groups.map((g) => g.key)}
|
|
variant="default"
|
|
radius="md"
|
|
chevronSize={14}
|
|
>
|
|
{groups.map((group) => (
|
|
<Accordion.Item key={group.key} value={group.key}>
|
|
<Accordion.Control icon={group.icon} py={4} px="sm">
|
|
<Text size="xs" fw={500}>
|
|
{group.label}
|
|
<Badge size="xs" variant="outline" color="gray" ml={6}>
|
|
{group.entries.length}
|
|
</Badge>
|
|
</Text>
|
|
</Accordion.Control>
|
|
<Accordion.Panel pb={4}>
|
|
{group.entries.map((entry) => (
|
|
<BacklinkRow
|
|
key={`${entry.source.id}-${entry.linkType}`}
|
|
entry={entry}
|
|
onNavigate={handleNavigate}
|
|
/>
|
|
))}
|
|
</Accordion.Panel>
|
|
</Accordion.Item>
|
|
))}
|
|
</Accordion>
|
|
</Box>
|
|
);
|
|
}
|
|
|
|
interface BacklinkRowProps {
|
|
entry: BacklinkEntry;
|
|
onNavigate: (entry: BacklinkEntry) => void;
|
|
}
|
|
|
|
function BacklinkRow({ entry, onNavigate }: BacklinkRowProps) {
|
|
const { t } = useTranslation();
|
|
|
|
return (
|
|
<UnstyledButton
|
|
onClick={() => onNavigate(entry)}
|
|
className={classes.backlinkRow}
|
|
data-testid={`backlink-row-${entry.source.id}`}
|
|
px="sm"
|
|
py={4}
|
|
w="100%"
|
|
>
|
|
<Group gap="xs" wrap="nowrap" align="flex-start">
|
|
<ActionIcon
|
|
variant="subtle"
|
|
component="div"
|
|
color="gray"
|
|
size="sm"
|
|
mt={1}
|
|
aria-hidden="true"
|
|
flex="0 0 auto"
|
|
>
|
|
{entry.source.icon ?? <IconFileDescription size={14} stroke={1.5} />}
|
|
</ActionIcon>
|
|
<Box style={{ minWidth: 0, flex: 1 }}>
|
|
<Text size="sm" fw={500} truncate>
|
|
{entry.source.title ?? t('backlinks.untitled', 'Untitled')}
|
|
</Text>
|
|
{entry.source.spaceName && (
|
|
<Text size="xs" c="dimmed" truncate>
|
|
{entry.source.spaceName}
|
|
</Text>
|
|
)}
|
|
{entry.contextExcerpt && (
|
|
<Text
|
|
size="xs"
|
|
c="dimmed"
|
|
lineClamp={2}
|
|
mt={2}
|
|
className={classes.excerpt}
|
|
>
|
|
{entry.contextExcerpt}
|
|
</Text>
|
|
)}
|
|
</Box>
|
|
</Group>
|
|
</UnstyledButton>
|
|
);
|
|
}
|
|
|
|
export default LinkedReferencesPanel;
|