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 && (
)}
) : 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}
>
);
}