Public sharing (#218): - Bind public-share content to the requested shareId. getSharedPage now enforces dto.shareId (forwarded from /share/:shareId/p/:slug): the page must be reachable THROUGH that exact share (its own share, or an includeSubPages ancestor that contains it). A forged/mismatched shareId 404s instead of rendering off the slug alone and no longer leaks the real canonical key via redirect. A request with no shareId keeps the legacy slug-capability path. - Trim /shares/page-info: drop internal metadata (creatorId, spaceId, workspaceId, contributorIds, lastUpdated*, parent/position, lock/template flags, timestamps) from the anonymous payload. - Default share-to-web includeSubPages to false (opt-in), so enabling a share no longer silently exposes the whole sub-tree (#216). Editor (#218): - Harden the new-page pre-sync window: the body editor is kept read-only until the collab provider is Connected and synced, so early keystrokes can't land only in local ProseMirror and then be clobbered by the server's empty doc. - Surface a "Connecting… (read-only)" affordance during the static phase so input isn't silently swallowed. Other: - Breadcrumb: resolve from the page's own ancestor data (/pages/breadcrumbs) instead of waiting for the lazily-built sidebar tree, so deep pages don't render a blank breadcrumb for seconds. - Pasting GitHub `> [!type]` callouts now converts to a callout node instead of a literal blockquote (new marked extension wired into markdownToHtml). Tests: editor-sync-state gate (client), getSharedPage share-binding (server), github-callout markdown conversion (editor-ext). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
94 lines
3.1 KiB
TypeScript
94 lines
3.1 KiB
TypeScript
import { useNavigate, useParams } from "react-router-dom";
|
|
import { Helmet } from "react-helmet-async";
|
|
import { useTranslation } from "react-i18next";
|
|
import { useSharePageQuery } from "@/features/share/queries/share-query.ts";
|
|
import { Container } from "@mantine/core";
|
|
import React, { useEffect } from "react";
|
|
import ReadonlyPageEditor from "@/features/editor/readonly-page-editor.tsx";
|
|
import { extractPageSlugId } from "@/lib";
|
|
import { Error404 } from "@/components/ui/error-404.tsx";
|
|
import ShareBranding from "@/features/share/components/share-branding.tsx";
|
|
import ShareAiWidget from "@/features/share/components/share-ai-widget.tsx";
|
|
import { useAtomValue } from "jotai";
|
|
import {
|
|
sharedPageFullWidthAtom,
|
|
sharedTreeDataAtom,
|
|
} from "@/features/share/atoms/shared-page-atom.ts";
|
|
import { isPageInTree } from "@/features/share/utils.ts";
|
|
|
|
export default function SharedPage() {
|
|
const { t } = useTranslation();
|
|
const { pageSlug } = useParams();
|
|
const { shareId } = useParams();
|
|
const navigate = useNavigate();
|
|
|
|
const { data, isLoading, isError, error } = useSharePageQuery({
|
|
pageId: extractPageSlugId(pageSlug),
|
|
// Forward the URL's shareId so the server binds content to this share
|
|
// (#218): a forged shareId 404s instead of rendering the page off its slug.
|
|
shareId,
|
|
});
|
|
|
|
const sharedTreeData = useAtomValue(sharedTreeDataAtom);
|
|
const fullWidth = useAtomValue(sharedPageFullWidthAtom);
|
|
|
|
useEffect(() => {
|
|
if (shareId && data) {
|
|
if (data.share.key !== shareId) {
|
|
|
|
// Check if the current page is part of the active sharing tree (sidebar) - If we are part of it, we will not redirect, keeping the sidebar visible.
|
|
const isPartOfTree =
|
|
sharedTreeData && isPageInTree(sharedTreeData, data.page.slugId);
|
|
|
|
if (!isPartOfTree) {
|
|
navigate(`/share/${data.share.key}/p/${pageSlug}`, { replace: true });
|
|
}
|
|
}
|
|
}
|
|
}, [shareId, data, sharedTreeData]);
|
|
|
|
if (isLoading) {
|
|
return <></>;
|
|
}
|
|
|
|
if (isError || !data) {
|
|
if ([401, 403, 404].includes(error?.["status"])) {
|
|
return <Error404 />;
|
|
}
|
|
return <div>{t("Error fetching page data.")}</div>;
|
|
}
|
|
|
|
return (
|
|
<div>
|
|
<Helmet>
|
|
<title>{`${data?.page?.title || t("untitled")}`}</title>
|
|
{!data?.share.searchIndexing && (
|
|
<meta name="robots" content="noindex" />
|
|
)}
|
|
</Helmet>
|
|
|
|
<Container fluid={fullWidth} size={fullWidth ? undefined : 900} p={0}>
|
|
<ReadonlyPageEditor
|
|
key={data.page.id}
|
|
title={data.page.title}
|
|
content={data.page.content}
|
|
pageId={data.page.id}
|
|
shareId={data.share.id}
|
|
/>
|
|
</Container>
|
|
|
|
{data && !shareId && !(data.features?.length > 0) && <ShareBranding />}
|
|
|
|
{/* Anonymous "Ask AI" widget — only when the workspace enables the
|
|
public-share assistant (server-resolved flag on /shares/page-info). */}
|
|
{data?.aiAssistant && data.share?.id && data.page?.id && (
|
|
<ShareAiWidget
|
|
shareId={data.share.id}
|
|
pageId={data.page.id}
|
|
assistantName={data.aiAssistantName ?? undefined}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|