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.title ?? t('backlinks.untitled', 'Untitled')} {entry.source.spaceName && ( {entry.source.spaceName} )} {entry.contextExcerpt && ( {entry.contextExcerpt} )} ); } export default LinkedReferencesPanel;