AcadeDoc/apps/client/src/features/acadenice/backlinks/components/linked-references-panel.tsx
Corentin a1f2ee9e0a feat(backlinks): workspace reindex backfill and sub-page references
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>
2026-05-18 09:22:32 +00:00

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;