import { NodeViewProps, NodeViewWrapper } from "@tiptap/react"; import { ActionIcon, Menu, Tooltip } from "@mantine/core"; import { IconAlertTriangle, IconArrowsMaximize, IconDots, IconExternalLink, IconEyeOff, IconInfoCircle, IconRefresh, IconRepeat, IconTrash, } from "@tabler/icons-react"; import { useState } from "react"; import { Link } from "react-router-dom"; import { useTranslation } from "react-i18next"; import { ErrorBoundary } from "react-error-boundary"; import { buildPageUrl } from "@/features/page/page.utils.ts"; import classes from "../transclusion/transclusion.module.css"; import { usePageEmbedLookup } from "./page-embed-lookup-context"; import { PageEmbedAncestryProvider, usePageEmbedAncestry, } from "./page-embed-ancestry-context"; import { decideEmbedState } from "./decide-embed-state"; import PageEmbedContent from "./page-embed-content"; function Placeholder({ icon, label, }: { icon: React.ReactNode; label: string; }) { return (
{icon} {label}
); } export default function PageEmbedView(props: NodeViewProps) { const isEditable = props.editor.isEditable; const sourcePageId: string | null = props.node.attrs.sourcePageId ?? null; const [openMenus, setOpenMenus] = useState(0); const trackOpen = (open: boolean) => setOpenMenus((n) => Math.max(0, n + (open ? 1 : -1))); return ( 0 ? "true" : "false"} contentEditable={false} > // Never swallow: log the full error with the offending source id. console.error("[pageEmbed] render error", { sourcePageId, err }) } fallback={ } label="Failed to load this embedded page" /> } > ); } function PageEmbedBody({ editor, node, deleteNode, trackOpen, }: NodeViewProps & { trackOpen: (open: boolean) => void }) { const { t } = useTranslation(); const sourcePageId: string | null = node.attrs.sourcePageId ?? null; const isEditable = editor.isEditable; const ancestry = usePageEmbedAncestry(); // @ts-ignore - editor.storage.pageId is set by the host editor const hostPageId: string | undefined = editor.storage?.pageId; const { result, refresh, available } = usePageEmbedLookup(sourcePageId); const [refreshing, setRefreshing] = useState(false); const handleRefresh = async () => { setRefreshing(true); try { await refresh(); } finally { setRefreshing(false); } }; // --- Cycle / depth / availability decision (pure, unit-tested) ------------ // Evaluated before any nested editor is rendered. const embedState = decideEmbedState({ sourcePageId, chain: ancestry.chain, hostPageId: ancestry.hostPageId, available, result, }); const sourceTitle = result && !("status" in result) ? result.title : null; const sourceIcon = result && !("status" in result) ? result.icon : null; // The app routes pages by slugId, not the raw UUID. Build the link from the // resolved slugId (the `/p/:pageSlug` route redirects to the full URL). const sourceSlugId = result && !("status" in result) ? result.slugId : null; const sourceHref = sourceSlugId ? buildPageUrl(undefined, sourceSlugId, sourceTitle ?? undefined) : null; const controls = isEditable ? (
e.preventDefault()} > {sourceHref && ( )} } onClick={() => deleteNode()} > {t("Remove from page")}
) : null; const header = sourceTitle || sourceIcon ? (
{sourceIcon ? `${sourceIcon} ` : } {sourceHref ? ( {sourceTitle || t("Untitled")} ) : ( sourceTitle || t("Untitled") )}
) : null; let body: React.ReactNode; if (embedState === "no_source") { body = ( } label={t("No page selected")} /> ); } else if (embedState === "cycle") { body = ( } label={t("Circular embed: this page is already shown above")} /> ); } else if (embedState === "too_deep") { body = ( } label={t("Embed nesting limit reached")} /> ); } else if (embedState === "unavailable") { // No lookup context (e.g. public share) → placeholder, no fetch in MVP. body = ( } label={t("Embedded page is not available here")} /> ); } else if (embedState === "loading") { body =
; } else if (embedState === "ok" && result && !("status" in result)) { body = ( ); } else if (embedState === "no_access") { body = ( } label={t("You don't have access to this page")} /> ); } else { body = ( } label={t("The embedded page no longer exists")} /> ); } return ( <> {controls} {header} {body} ); }