Security: - Clear the offline IndexedDB cache on sign-in (not only logout) so a previous user's persisted query cache and Yjs page bodies cannot leak to the next user on a shared device when the prior session ended without an explicit logout. Regressions: - Remove the double Yjs title write from the AI title-generation path: the title editor is bound to the Yjs `title` fragment and the server REST update reseeds it, so the local setContent raced that reseed and doubled/garbled the title. Conventions / i18n / docs: - Remove the unused showAiMenuAtom. - Register the 3 offline-fallback strings in en-US and ru-RU. - Fix the 5 broken links to the nonexistent docs/offline-sync-plan.md. Stability / simplification: - warmInfiniteAll now reports truncation (returns false) when it hits maxPages with a cursor still pending instead of silently succeeding. - space-tree make-offline catch logs the raw error and surfaces the real cause. - Move the Offline/Mobile/CORS CHANGELOG entries from the released 0.93.0 section into [Unreleased] (CORS is a documented breaking change). - Drop the pass-through sync-flag forwarders in use-page-collab-providers; set the atoms directly. - Collapse the three isSwaggerEnabled true-cases into it.each. Tests / architecture: - Extract collabTokenNeedsRefresh (pure) and cover all four token states. - Extract shouldPropagateTitleChange and cover the collab-origin skip; add a TitleEditor render test for the static-h1 vs collaborative-editor switch. - Add a use-auth test asserting the sign-in cache purge runs before login. - Add an OFFLINE_PERSIST_ROOTS guard test asserting every persisted root maps to an exported query-key factory; route make-offline's currentUser warm through a new userKeys factory. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
386 lines
12 KiB
TypeScript
386 lines
12 KiB
TypeScript
import { useAtom } from "jotai";
|
|
import { useTranslation } from "react-i18next";
|
|
import { useParams } from "react-router-dom";
|
|
import { ActionIcon, Menu, rem } from "@mantine/core";
|
|
import { useDisclosure } from "@mantine/hooks";
|
|
import { notifications } from "@mantine/notifications";
|
|
import {
|
|
IconArrowRight,
|
|
IconClockHour4,
|
|
IconCloudDownload,
|
|
IconCopy,
|
|
IconDotsVertical,
|
|
IconFileExport,
|
|
IconLink,
|
|
IconStar,
|
|
IconStarFilled,
|
|
IconTemplate,
|
|
IconTrash,
|
|
} from "@tabler/icons-react";
|
|
|
|
import ExportModal from "@/components/common/export-modal";
|
|
import MovePageModal from "@/features/page/components/move-page-modal.tsx";
|
|
import CopyPageModal from "@/features/page/components/copy-page-modal.tsx";
|
|
import { buildPageUrl } from "@/features/page/page.utils.ts";
|
|
import { duplicatePage } from "@/features/page/services/page-service.ts";
|
|
import { useClipboard } from "@/hooks/use-clipboard";
|
|
import { getAppUrl } from "@/lib/config.ts";
|
|
import { useQueryEmit } from "@/features/websocket/use-query-emit.ts";
|
|
import {
|
|
useFavoriteIds,
|
|
useAddFavoriteMutation,
|
|
useRemoveFavoriteMutation,
|
|
} from "@/features/favorite/queries/favorite-query";
|
|
|
|
import {
|
|
useToggleTemplateMutation,
|
|
useToggleTemporaryMutation,
|
|
} from "@/features/page-embed/queries/page-embed-query";
|
|
import { useCollabToken } from "@/features/auth/queries/auth-query.tsx";
|
|
import { getCollaborationUrl } from "@/lib/config.ts";
|
|
import {
|
|
makePageAvailableOffline,
|
|
warmPageYdoc,
|
|
} from "@/features/offline/make-offline";
|
|
import { treeDataAtom } from "@/features/page/tree/atoms/tree-data-atom.ts";
|
|
import { treeModel } from "@/features/page/tree/model/tree-model";
|
|
import { pageToTreeNode } from "@/features/page/tree/utils";
|
|
import { useTreeMutation } from "@/features/page/tree/hooks/use-tree-mutation.ts";
|
|
import type { SpaceTreeNode } from "@/features/page/tree/types.ts";
|
|
import classes from "@/features/page/tree/styles/tree.module.css";
|
|
|
|
export interface NodeMenuProps {
|
|
node: SpaceTreeNode;
|
|
canEdit: boolean;
|
|
}
|
|
|
|
export function NodeMenu({ node, canEdit }: NodeMenuProps) {
|
|
const { t } = useTranslation();
|
|
const clipboard = useClipboard({ timeout: 500 });
|
|
const { spaceSlug } = useParams();
|
|
const { handleDelete } = useTreeMutation(node.spaceId);
|
|
const [data, setData] = useAtom(treeDataAtom);
|
|
const emit = useQueryEmit();
|
|
const [exportOpened, { open: openExportModal, close: closeExportModal }] =
|
|
useDisclosure(false);
|
|
const [
|
|
movePageModalOpened,
|
|
{ open: openMovePageModal, close: closeMoveSpaceModal },
|
|
] = useDisclosure(false);
|
|
const [
|
|
copyPageModalOpened,
|
|
{ open: openCopyPageModal, close: closeCopySpaceModal },
|
|
] = useDisclosure(false);
|
|
const favoriteIds = useFavoriteIds("page", node.spaceId);
|
|
const addFavorite = useAddFavoriteMutation();
|
|
const removeFavorite = useRemoveFavoriteMutation();
|
|
const isFavorited = favoriteIds.has(node.id);
|
|
const toggleTemplate = useToggleTemplateMutation();
|
|
const isTemplate = !!node.isTemplate;
|
|
const toggleTemporary = useToggleTemporaryMutation();
|
|
const isTemporary = !!node.temporaryExpiresAt;
|
|
const { data: collabQuery } = useCollabToken();
|
|
|
|
const handleMakeAvailableOffline = async () => {
|
|
notifications.show({ message: t("Saving page for offline use...") });
|
|
try {
|
|
// Prefetch read queries so they get persisted to IndexedDB. The result
|
|
// reports whether every warm step succeeded.
|
|
const result = await makePageAvailableOffline({
|
|
pageId: node.id,
|
|
spaceId: node.spaceId,
|
|
});
|
|
// Best-effort: warm the page's Yjs document into IndexedDB.
|
|
await warmPageYdoc(node.id, getCollaborationUrl(), collabQuery?.token);
|
|
|
|
if (result.ok) {
|
|
notifications.show({ message: t("Page is now available offline") });
|
|
} else {
|
|
// Partial warm — the page may still be partly usable offline, but some
|
|
// queries failed to cache, so surface it as an error rather than a
|
|
// silent success.
|
|
notifications.show({
|
|
message: t("Failed to make page available offline"),
|
|
color: "red",
|
|
});
|
|
}
|
|
} catch (err) {
|
|
// makePageAvailableOffline no longer throws, but warmPageYdoc and other
|
|
// unexpected failures stay guarded here. Log the raw error and surface the
|
|
// real cause to the user instead of a bare generic string (AGENTS.md).
|
|
console.error("handleMakeAvailableOffline failed", err);
|
|
const reason =
|
|
(err as { response?: { data?: { message?: string } } })?.response?.data
|
|
?.message ?? (err instanceof Error ? err.message : String(err));
|
|
notifications.show({
|
|
message: `${t("Failed to make page available offline")}: ${reason}`,
|
|
color: "red",
|
|
});
|
|
}
|
|
};
|
|
|
|
const handleToggleTemplate = async () => {
|
|
const next = !isTemplate;
|
|
try {
|
|
await toggleTemplate.mutateAsync({ pageId: node.id, isTemplate: next });
|
|
// Reflect the new flag locally so the menu label updates immediately.
|
|
setData((prev) =>
|
|
treeModel.update(prev, node.id, { isTemplate: next } as any),
|
|
);
|
|
notifications.show({
|
|
message: next
|
|
? t("Page marked as template")
|
|
: t("Page is no longer a template"),
|
|
});
|
|
} catch {
|
|
// mutation surfaces the error via notifications
|
|
}
|
|
};
|
|
|
|
const handleToggleTemporary = async () => {
|
|
const next = !isTemporary;
|
|
try {
|
|
const res = await toggleTemporary.mutateAsync({
|
|
pageId: node.id,
|
|
temporary: next,
|
|
});
|
|
// Reflect the new deadline locally so the icon/menu update immediately.
|
|
setData((prev) =>
|
|
treeModel.update(prev, node.id, {
|
|
temporaryExpiresAt: res.temporaryExpiresAt,
|
|
} as any),
|
|
);
|
|
notifications.show({
|
|
message: next
|
|
? t("Note will move to trash unless made permanent")
|
|
: t("Note is now permanent"),
|
|
});
|
|
} catch {
|
|
// mutation surfaces the error via notifications
|
|
}
|
|
};
|
|
|
|
const handleCopyLink = () => {
|
|
const pageUrl =
|
|
getAppUrl() + buildPageUrl(spaceSlug, node.slugId, node.name);
|
|
clipboard.copy(pageUrl);
|
|
notifications.show({ message: t("Link copied") });
|
|
};
|
|
|
|
const handleDuplicatePage = async () => {
|
|
try {
|
|
const duplicatedPage = await duplicatePage({ pageId: node.id });
|
|
|
|
// figure out parent + insertion index
|
|
const siblings = treeModel.siblingsOf(data, node.id);
|
|
const parentId = siblings?.parentId ?? null;
|
|
const currentIndex = siblings?.index ?? 0;
|
|
const newIndex = currentIndex + 1;
|
|
|
|
// Routed through the canonical mapper so the field copy stays in lockstep
|
|
// with buildTree. The server does NOT arm a death timer on duplicate (the
|
|
// copy's `temporaryExpiresAt` defaults to null = permanent), so the mapper
|
|
// carries that null through and the duplicated node correctly shows no
|
|
// clock marker — matching the server without a reload.
|
|
const treeNodeData: SpaceTreeNode = pageToTreeNode(duplicatedPage, {
|
|
canEdit: true,
|
|
});
|
|
|
|
setData((prev) =>
|
|
treeModel.insert(prev, parentId, treeNodeData, newIndex),
|
|
);
|
|
|
|
setTimeout(() => {
|
|
emit({
|
|
operation: "addTreeNode",
|
|
spaceId: node.spaceId,
|
|
payload: {
|
|
parentId,
|
|
index: newIndex,
|
|
data: treeNodeData,
|
|
},
|
|
});
|
|
}, 50);
|
|
|
|
notifications.show({ message: t("Page duplicated successfully") });
|
|
} catch (err: any) {
|
|
notifications.show({
|
|
message: err?.response?.data?.message || "An error occurred",
|
|
color: "red",
|
|
});
|
|
}
|
|
};
|
|
|
|
return (
|
|
<>
|
|
<Menu shadow="md" width={200}>
|
|
<Menu.Target>
|
|
<ActionIcon
|
|
size={20}
|
|
variant="subtle"
|
|
color="gray"
|
|
className={classes.actionIcon}
|
|
aria-label={t("Page menu for {{name}}", { name: node.name || t("Untitled") })}
|
|
tabIndex={-1}
|
|
onClick={(e) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
}}
|
|
>
|
|
<IconDotsVertical
|
|
style={{ width: rem(20), height: rem(20) }}
|
|
stroke={2}
|
|
/>
|
|
</ActionIcon>
|
|
</Menu.Target>
|
|
|
|
<Menu.Dropdown>
|
|
<Menu.Item
|
|
leftSection={<IconLink size={16} />}
|
|
onClick={(e) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
handleCopyLink();
|
|
}}
|
|
>
|
|
{t("Copy link")}
|
|
</Menu.Item>
|
|
|
|
<Menu.Item
|
|
leftSection={
|
|
isFavorited ? <IconStarFilled size={16} /> : <IconStar size={16} />
|
|
}
|
|
onClick={(e) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
if (isFavorited) {
|
|
removeFavorite.mutate({ type: "page", pageId: node.id });
|
|
} else {
|
|
addFavorite.mutate({ type: "page", pageId: node.id });
|
|
}
|
|
}}
|
|
>
|
|
{isFavorited ? t("Remove from favorites") : t("Add to favorites")}
|
|
</Menu.Item>
|
|
|
|
<Menu.Item
|
|
leftSection={<IconFileExport size={16} />}
|
|
onClick={(e) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
openExportModal();
|
|
}}
|
|
>
|
|
{t("Export")}
|
|
</Menu.Item>
|
|
|
|
<Menu.Item
|
|
leftSection={<IconCloudDownload size={16} />}
|
|
onClick={(e) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
handleMakeAvailableOffline();
|
|
}}
|
|
>
|
|
{t("Make available offline")}
|
|
</Menu.Item>
|
|
|
|
{canEdit && (
|
|
<>
|
|
<Menu.Item
|
|
leftSection={<IconCopy size={16} />}
|
|
onClick={(e) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
handleDuplicatePage();
|
|
}}
|
|
>
|
|
{t("Duplicate")}
|
|
</Menu.Item>
|
|
|
|
<Menu.Item
|
|
leftSection={<IconArrowRight size={16} />}
|
|
onClick={(e) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
openMovePageModal();
|
|
}}
|
|
>
|
|
{t("Move to space")}
|
|
</Menu.Item>
|
|
|
|
<Menu.Item
|
|
leftSection={<IconCopy size={16} />}
|
|
onClick={(e) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
openCopyPageModal();
|
|
}}
|
|
>
|
|
{t("Copy to space")}
|
|
</Menu.Item>
|
|
|
|
<Menu.Item
|
|
leftSection={<IconTemplate size={16} />}
|
|
onClick={(e) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
handleToggleTemplate();
|
|
}}
|
|
>
|
|
{isTemplate ? t("Unset as template") : t("Make template")}
|
|
</Menu.Item>
|
|
|
|
<Menu.Item
|
|
leftSection={<IconClockHour4 size={16} />}
|
|
onClick={(e) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
handleToggleTemporary();
|
|
}}
|
|
>
|
|
{isTemporary ? t("Make permanent") : t("Make temporary")}
|
|
</Menu.Item>
|
|
|
|
<Menu.Divider />
|
|
<Menu.Item
|
|
c="red"
|
|
leftSection={<IconTrash size={16} />}
|
|
onClick={(e) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
handleDelete(node.id);
|
|
}}
|
|
>
|
|
{t("Move to trash")}
|
|
</Menu.Item>
|
|
</>
|
|
)}
|
|
</Menu.Dropdown>
|
|
</Menu>
|
|
|
|
<MovePageModal
|
|
pageId={node.id}
|
|
slugId={node.slugId}
|
|
currentSpaceSlug={spaceSlug}
|
|
onClose={closeMoveSpaceModal}
|
|
open={movePageModalOpened}
|
|
/>
|
|
|
|
<CopyPageModal
|
|
pageId={node.id}
|
|
currentSpaceSlug={spaceSlug}
|
|
onClose={closeCopySpaceModal}
|
|
open={copyPageModalOpened}
|
|
/>
|
|
|
|
<ExportModal
|
|
type="page"
|
|
id={node.id}
|
|
open={exportOpened}
|
|
onClose={closeExportModal}
|
|
/>
|
|
</>
|
|
);
|
|
}
|