diff --git a/apps/petrinaut-website/src/examples/embedded-example-page.tsx b/apps/petrinaut-website/src/examples/embedded-example-page.tsx new file mode 100644 index 00000000000..26937e40a20 --- /dev/null +++ b/apps/petrinaut-website/src/examples/embedded-example-page.tsx @@ -0,0 +1,92 @@ +import { lazy, Suspense, type FunctionComponent } from "react"; + +import { css } from "@hashintel/ds-helpers/css"; + +import { getReadonlyExampleHandle } from "./readonly-example-handle"; +import { useSharedSearchNavigation } from "./use-shared-search-navigation"; + +import type { LoadedExample } from "./catalog"; +import type { SharedExampleSearch } from "./example-search"; + +const LazyPetrinaut = lazy(async () => { + const { Petrinaut } = await import("@hashintel/petrinaut/ui"); + return { default: Petrinaut }; +}); + +// The page frame and the loading fallback render outside Petrinaut, and the +// design-system tokens are declared on `.petrinaut-root` (`cssVarRoot` in +// `scopedThemeConfig`), so token values do not resolve here. These use +// literals, as the site's other chrome does. +const pageStyle = css({ + width: "[100vw]", + height: "[100vh]", + minWidth: "0", + minHeight: "0", + overflow: "hidden", + backgroundColor: "[#f6f7f8]", +}); + +const loadingStyle = css({ + display: "flex", + width: "[100%]", + height: "[100%]", + alignItems: "center", + justifyContent: "center", + color: "[#4b5563]", + fontSize: "[14px]", +}); + +const embedTitleStyle = css({ + minWidth: "0", + overflow: "hidden", + color: "neutral.s90", + fontSize: "sm", + fontWeight: "medium", + textOverflow: "ellipsis", + whiteSpace: "nowrap", +}); + +export type EmbeddedExamplePageProps = { + example: LoadedExample; + /** Writes the shared search subset back to the embed URL. */ + onSearchChange: ( + search: SharedExampleSearch, + history: "push" | "replace", + ) => void; + search: SharedExampleSearch; +}; + +/** + * Embed of an example: the full Petrinaut component in its read-only + * presentation, navigated through the shared search contract. + */ +export const EmbeddedExamplePage: FunctionComponent< + EmbeddedExamplePageProps +> = ({ example, onSearchChange, search }) => { + const handle = getReadonlyExampleHandle(example); + const navigation = useSharedSearchNavigation(search, onSearchChange, { + // The embed lives in an iframe; it must not grow the host page's history. + historyPolicy: () => "replace", + }); + + return ( +
+ Loading Petrinaut…} + > + {example.catalog.title} + ), + }} + title={example.catalog.title} + /> + +
+ ); +}; diff --git a/apps/petrinaut-website/src/routes/-embed-status-panel.tsx b/apps/petrinaut-website/src/routes/-embed-status-panel.tsx new file mode 100644 index 00000000000..52330d2fc67 --- /dev/null +++ b/apps/petrinaut-website/src/routes/-embed-status-panel.tsx @@ -0,0 +1,49 @@ +import { css } from "@hashintel/ds-helpers/css"; + +// This panel replaces the embed when there is no Petrinaut to render, so it +// sits outside `.petrinaut-root`, where the design-system tokens are declared +// (`cssVarRoot` in `scopedThemeConfig`). Token values would not resolve, so +// these are literals, matching the site's own not-found page. +const panelStyle = css({ + display: "flex", + width: "[100%]", + height: "[100%]", + alignItems: "center", + justifyContent: "center", + padding: "[24px]", + backgroundColor: "[#f6f7f8]", + fontFamily: "[Inter, system-ui, sans-serif]", + textAlign: "center", +}); + +const titleStyle = css({ + color: "[#1f2933]", + fontSize: "[14px]", + fontWeight: "[600]", +}); + +const bodyStyle = css({ + marginTop: "[4px]", + color: "[#4b5563]", + fontSize: "[12px]", +}); + +/** + * Fallback for the embed route, sized for a frame rather than a page. The + * embed renders inside someone else's layout, so a failure stays small and + * carries no link: a link here would navigate the frame to this site. + */ +export const EmbedStatusPanel = ({ + body, + title, +}: { + body: string; + title: string; +}) => ( +
+
+

{title}

+

{body}

+
+
+); diff --git a/apps/petrinaut-website/src/routes/embed.examples.$slug.tsx b/apps/petrinaut-website/src/routes/embed.examples.$slug.tsx new file mode 100644 index 00000000000..e37432ba788 --- /dev/null +++ b/apps/petrinaut-website/src/routes/embed.examples.$slug.tsx @@ -0,0 +1,69 @@ +import { + createFileRoute, + notFound, + useLoaderData, + useNavigate, + useSearch, +} from "@tanstack/react-router"; + +import { isExampleSlug, loadExample } from "../examples/catalog"; +import { EmbeddedExamplePage } from "../examples/embedded-example-page"; +import { validateSharedExampleSearch } from "../examples/example-search"; +import { EmbedStatusPanel } from "./-embed-status-panel"; + +const routePath = "/embed/examples/$slug" as const; + +function EmbeddedExampleRoute() { + const navigate = useNavigate({ from: routePath }); + const example = useLoaderData({ from: routePath }); + + return ( + { + void navigate({ replace: history === "replace", search }); + }} + search={useSearch({ from: routePath })} + /> + ); +} + +export const Route = createFileRoute("/embed/examples/$slug")({ + beforeLoad: ({ params }) => { + if (!isExampleSlug(params.slug)) { + throw notFound(); + } + }, + component: EmbeddedExampleRoute, + // The editor is imported lazily, so a chunk that fails to load throws after + // mount. Without a boundary here the root unmounts and the frame goes blank + // on someone else's page. + errorComponent: () => ( + + ), + loader: ({ params }) => { + if (!isExampleSlug(params.slug)) { + throw notFound(); + } + return loadExample(params.slug); + }, + // The site-wide not-found page is a full viewport panel with a link that + // would navigate the embedder's frame, so the embed answers for itself. + notFoundComponent: () => ( + + ), + // Unknown query params are dropped by `validateSearch` and left in the URL + // on purpose. Canonicalizing them with a `redirect({ href })` reloads the + // document inside the frame, and TanStack Router only rewrites the URL when + // the parsed search changes, so such a redirect re-triggers itself. + validateSearch: validateSharedExampleSearch, +});