Approve-with-comments follow-ups: - breadcrumb: fix the reverse regression where navigating A->B to a page absent from the lazily-built tree (before its ancestors load) left the previous page's clickable chain on screen. New pure computeBreadcrumbState clears a stale chain that doesn't end at the current page, while keeping one that does (no blank flash for an already-resolved page); unit-tested for the navigated-to-absent-page case. - share.service: getShareAncestorPage no longer swallows DB errors silently — now a live public-share path (isPageReachableThroughShare), so a transient error is logged with ancestor/child ids and still fails closed (caller 404s) instead of becoming a traceless misleading "not found". - i18n: register the new "Connecting… (read-only)" key (U+2026 ellipsis) in en-US (source of truth) and ru-RU (Подключение… (только чтение)). - share.service: correct the FUTURE note — 3 callers pass no shareId (share-alias.controller/.service, share-seo.controller); the two ai-chat callers already pass a real shareId. - CHANGELOG: add Unreleased Changed/Fixed/Security entries for #216 opt-in sub-pages default, #218 trimmed page-info payload + forged-shareId 404, #204 export internal-link name, #206/#218 breadcrumb, #192 callout paste, #218 editor pre-sync read-only gate. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
207 lines
6.1 KiB
TypeScript
207 lines
6.1 KiB
TypeScript
import { useAtomValue } from "jotai";
|
|
import { treeDataAtom } from "@/features/page/tree/atoms/tree-data-atom.ts";
|
|
import React, { useCallback, useEffect, useState } from "react";
|
|
import { computeBreadcrumbState } from "./breadcrumb.utils";
|
|
import {
|
|
Button,
|
|
Anchor,
|
|
Popover,
|
|
Breadcrumbs,
|
|
ActionIcon,
|
|
Text,
|
|
Tooltip,
|
|
} from "@mantine/core";
|
|
import { IconCornerDownRightDouble, IconDots } from "@tabler/icons-react";
|
|
import { Link, useParams } from "react-router-dom";
|
|
import classes from "./breadcrumb.module.css";
|
|
import { SpaceTreeNode } from "@/features/page/tree/types.ts";
|
|
import { IPage } from "@/features/page/types/page.types.ts";
|
|
import { buildPageUrl } from "@/features/page/page.utils.ts";
|
|
import {
|
|
usePageQuery,
|
|
usePageBreadcrumbsQuery,
|
|
} from "@/features/page/queries/page-query.ts";
|
|
import { extractPageSlugId } from "@/lib";
|
|
import { useMediaQuery } from "@mantine/hooks";
|
|
import { useTranslation } from "react-i18next";
|
|
|
|
function getTitle(name: string, icon: string) {
|
|
if (icon) {
|
|
return `${icon} ${name}`;
|
|
}
|
|
return name;
|
|
}
|
|
|
|
export default function Breadcrumb() {
|
|
const { t } = useTranslation();
|
|
const treeData = useAtomValue(treeDataAtom);
|
|
const [breadcrumbNodes, setBreadcrumbNodes] = useState<
|
|
SpaceTreeNode[] | null
|
|
>(null);
|
|
const { pageSlug, spaceSlug } = useParams();
|
|
const { data: currentPage } = usePageQuery({
|
|
pageId: extractPageSlugId(pageSlug),
|
|
});
|
|
// The page's own ancestor chain, fetched independently of the lazily-built
|
|
// sidebar tree so a deep page doesn't render a blank breadcrumb for seconds
|
|
// while the tree backfills (#218).
|
|
const { data: ancestors } = usePageBreadcrumbsQuery(currentPage?.id);
|
|
const isMobile = useMediaQuery("(max-width: 48em)");
|
|
|
|
useEffect(() => {
|
|
if (!currentPage) return;
|
|
|
|
// Selection/mapping + stale-clearing live in a pure, unit-tested helper
|
|
// (#218). It resolves the correct chain when possible and, on a transient
|
|
// miss, clears a chain left over from a previously-viewed page instead of
|
|
// showing the wrong trail — while keeping a chain already resolved for THIS
|
|
// page to avoid a blank flash.
|
|
setBreadcrumbNodes((previous) =>
|
|
computeBreadcrumbState(
|
|
treeData,
|
|
ancestors as IPage[] | undefined,
|
|
currentPage.id,
|
|
previous,
|
|
),
|
|
);
|
|
}, [currentPage?.id, treeData, ancestors]);
|
|
|
|
const HiddenNodesTooltipContent = () =>
|
|
breadcrumbNodes?.slice(1, -1).map((node) => (
|
|
<Button.Group orientation="vertical" key={node.id}>
|
|
<Button
|
|
justify="start"
|
|
component={Link}
|
|
to={buildPageUrl(spaceSlug, node.slugId, node.name)}
|
|
variant="default"
|
|
style={{ border: "none" }}
|
|
>
|
|
<Text fz={"sm"} className={classes.truncatedText}>
|
|
{getTitle(node.name, node.icon)}
|
|
</Text>
|
|
</Button>
|
|
</Button.Group>
|
|
));
|
|
|
|
const MobileHiddenNodesTooltipContent = () =>
|
|
breadcrumbNodes?.map((node) => (
|
|
<Button.Group orientation="vertical" key={node.id}>
|
|
<Button
|
|
justify="start"
|
|
component={Link}
|
|
to={buildPageUrl(spaceSlug, node.slugId, node.name)}
|
|
variant="default"
|
|
style={{ border: "none" }}
|
|
>
|
|
<Text fz={"sm"} className={classes.truncatedText}>
|
|
{getTitle(node.name, node.icon)}
|
|
</Text>
|
|
</Button>
|
|
</Button.Group>
|
|
));
|
|
|
|
const renderAnchor = useCallback(
|
|
(node: SpaceTreeNode, isCurrent = false) => (
|
|
<Tooltip label={node.name} key={node.id}>
|
|
<Anchor
|
|
component={Link}
|
|
to={buildPageUrl(spaceSlug, node.slugId, node.name)}
|
|
underline="never"
|
|
fz="sm"
|
|
key={node.id}
|
|
className={classes.truncatedText}
|
|
aria-current={isCurrent ? "page" : undefined}
|
|
>
|
|
{getTitle(node.name, node.icon)}
|
|
</Anchor>
|
|
</Tooltip>
|
|
),
|
|
[spaceSlug],
|
|
);
|
|
|
|
const getBreadcrumbItems = () => {
|
|
if (!breadcrumbNodes) return [];
|
|
|
|
if (breadcrumbNodes.length > 3) {
|
|
const firstNode = breadcrumbNodes[0];
|
|
//const secondLastNode = breadcrumbNodes[breadcrumbNodes.length - 2];
|
|
const lastNode = breadcrumbNodes[breadcrumbNodes.length - 1];
|
|
|
|
return [
|
|
renderAnchor(firstNode),
|
|
<Popover
|
|
width={250}
|
|
position="bottom"
|
|
withArrow
|
|
shadow="xl"
|
|
key="hidden-nodes"
|
|
>
|
|
<Popover.Target>
|
|
<ActionIcon
|
|
color="gray"
|
|
variant="transparent"
|
|
aria-label={t("Show hidden breadcrumbs")}
|
|
>
|
|
<IconDots size={20} stroke={2} />
|
|
</ActionIcon>
|
|
</Popover.Target>
|
|
<Popover.Dropdown>
|
|
<HiddenNodesTooltipContent />
|
|
</Popover.Dropdown>
|
|
</Popover>,
|
|
//renderAnchor(secondLastNode),
|
|
renderAnchor(lastNode, true),
|
|
];
|
|
}
|
|
|
|
return breadcrumbNodes.map((node, i) =>
|
|
renderAnchor(node, i === breadcrumbNodes.length - 1),
|
|
);
|
|
};
|
|
|
|
const getMobileBreadcrumbItems = () => {
|
|
if (!breadcrumbNodes) return [];
|
|
|
|
if (breadcrumbNodes.length > 0) {
|
|
return [
|
|
<Popover
|
|
width={250}
|
|
position="bottom"
|
|
withArrow
|
|
shadow="xl"
|
|
key="mobile-hidden-nodes"
|
|
>
|
|
<Popover.Target>
|
|
<Tooltip label={t("Breadcrumbs")}>
|
|
<ActionIcon
|
|
color="gray"
|
|
variant="transparent"
|
|
aria-label={t("Breadcrumbs")}
|
|
>
|
|
<IconCornerDownRightDouble size={20} stroke={2} />
|
|
</ActionIcon>
|
|
</Tooltip>
|
|
</Popover.Target>
|
|
<Popover.Dropdown>
|
|
<MobileHiddenNodesTooltipContent />
|
|
</Popover.Dropdown>
|
|
</Popover>,
|
|
];
|
|
}
|
|
|
|
return breadcrumbNodes.map((node, i) =>
|
|
renderAnchor(node, i === breadcrumbNodes.length - 1),
|
|
);
|
|
};
|
|
|
|
return (
|
|
<nav aria-label={t("Breadcrumb")} className={classes.breadcrumbDiv}>
|
|
{breadcrumbNodes && (
|
|
<Breadcrumbs className={classes.breadcrumbs}>
|
|
{isMobile ? getMobileBreadcrumbItems() : getBreadcrumbItems()}
|
|
</Breadcrumbs>
|
|
)}
|
|
</nav>
|
|
);
|
|
}
|