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: ,
entries: data.wikilinks,
});
}
if (data.mentions.length > 0) {
result.push({
key: 'mentions',
label: t('backlinks.group.mentions', 'Mentions'),
icon: ,
entries: data.mentions,
});
}
if (data.database_embeds.length > 0) {
result.push({
key: 'database_embeds',
label: t('backlinks.group.embeds', 'Database embeds'),
icon: ,
entries: data.database_embeds,
});
}
if (data.parentChild && data.parentChild.length > 0) {
result.push({
key: 'parent_child',
label: t('backlinks.group.subpages', 'Sub-pages'),
icon: ,
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 (
);
}
if (isError) {
return (
}
color="red"
variant="light"
py="xs"
data-testid="backlinks-error"
>
{t('backlinks.error', 'Could not load linked references.')}
refetch()} style={{ textDecoration: 'underline' }}>
{t('backlinks.retry', 'Retry')}
);
}
if (!data || data.total === 0) {
return (
{t('backlinks.empty', 'No pages link here yet.')}
);
}
return (
{t('backlinks.panel.title', 'Linked references')}
{data.total}
g.key)}
variant="default"
radius="md"
chevronSize={14}
>
{groups.map((group) => (
{group.label}
{group.entries.length}
{group.entries.map((entry) => (
))}
))}
);
}
interface BacklinkRowProps {
entry: BacklinkEntry;
onNavigate: (entry: BacklinkEntry) => void;
}
function BacklinkRow({ entry, onNavigate }: BacklinkRowProps) {
const { t } = useTranslation();
return (
onNavigate(entry)}
className={classes.backlinkRow}
data-testid={`backlink-row-${entry.source.id}`}
px="sm"
py={4}
w="100%"
>
{entry.source.icon ?? }
{entry.source.title ?? t('backlinks.untitled', 'Untitled')}
{entry.source.spaceName && (
{entry.source.spaceName}
)}
{entry.contextExcerpt && (
{entry.contextExcerpt}
)}
);
}
export default LinkedReferencesPanel;