Issue 1 — the sidebar tree's temporary-note clock marker did not appear/ disappear until a page reload when a note's temporary state changed. - Make/unmake permanent from the page header menu and the in-page banner went through syncTemporaryExpiresInCache(), which patched the page query cache but never touched treeDataAtom, so the sidebar node kept its stale temporaryExpiresAt. Patch the tree node there too (via jotai's default store), so the marker updates without a reload. - Creating a note as temporary showed no marker until reload: the create flow's cache write (invalidateOnCreatePage) omitted temporaryExpiresAt, so the tree rebuild (buildTree -> mergeRootTrees) overwrote the optimistic/socket node's marker with undefined. Carry temporaryExpiresAt in that cached entry. - Thread temporaryExpiresAt through the server addTreeNode broadcast (PAGE_CREATED snapshot -> TreeNodeSnapshot -> broadcastPageCreated) so OTHER clients watching the space also render the marker immediately, and harden handleCreate's idempotency guard to patch the deadline if the broadcast won the insert race. Issue 2 — the home and space-overview "New note" / "New temporary note" buttons sat side-by-side and the temporary label clipped on narrow mobile widths. Lay them out full-width, stacked vertically, and tint the temporary button orange (matching the clock marker + banner) while the regular one stays neutral gray. Tests: extend tree-socket-reducers.test.ts (addTreeNode carries temporaryExpiresAt). Verified live with Playwright: marker appears on create and toggles both ways with no reload; mobile buttons are stacked, full-width, unclipped, and differently colored. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
464 lines
13 KiB
TypeScript
464 lines
13 KiB
TypeScript
import { ActionIcon, Button, Group, Menu, Text, ThemeIcon, Tooltip } from "@mantine/core";
|
|
import {
|
|
IconArrowRight,
|
|
IconArrowsHorizontal,
|
|
IconClockHour4,
|
|
IconDots,
|
|
IconEye,
|
|
IconEyeOff,
|
|
IconFileExport,
|
|
IconHistory,
|
|
IconLink,
|
|
IconList,
|
|
IconMarkdown,
|
|
IconPrinter,
|
|
IconStar,
|
|
IconStarFilled,
|
|
IconTrash,
|
|
IconWifiOff,
|
|
} from "@tabler/icons-react";
|
|
import React, { useEffect, useRef, useState } from "react";
|
|
import { useAsideTriggerProps } from "@/hooks/use-toggle-aside.tsx";
|
|
import { useAtom, useAtomValue } from "jotai";
|
|
import { historyAtoms } from "@/features/page-history/atoms/history-atoms.ts";
|
|
import { useDisclosure, useHotkeys } from "@mantine/hooks";
|
|
import { useClipboard } from "@/hooks/use-clipboard";
|
|
import { useParams } from "react-router-dom";
|
|
import { usePageQuery } from "@/features/page/queries/page-query.ts";
|
|
import {
|
|
useToggleTemporaryMutation,
|
|
syncTemporaryExpiresInCache,
|
|
} from "@/features/page-embed/queries/page-embed-query.ts";
|
|
import { buildPageUrl } from "@/features/page/page.utils.ts";
|
|
import { notifications } from "@mantine/notifications";
|
|
import { getAppUrl } from "@/lib/config.ts";
|
|
import { extractPageSlugId } from "@/lib";
|
|
import { useTreeMutation } from "@/features/page/tree/hooks/use-tree-mutation.ts";
|
|
import { PageWidthToggle } from "@/features/user/components/page-width-pref.tsx";
|
|
import { Trans, useTranslation } from "react-i18next";
|
|
import ExportModal from "@/components/common/export-modal";
|
|
import { htmlToMarkdown } from "@docmost/editor-ext";
|
|
import {
|
|
pageEditorAtom,
|
|
yjsConnectionStatusAtom,
|
|
} from "@/features/editor/atoms/editor-atoms.ts";
|
|
import { formattedDate } from "@/lib/time.ts";
|
|
import { PageEditModeToggle } from "@/features/user/components/page-state-pref.tsx";
|
|
import MovePageModal from "@/features/page/components/move-page-modal.tsx";
|
|
import { useTimeAgo } from "@/hooks/use-time-ago.tsx";
|
|
import {
|
|
useFavoriteIds,
|
|
useAddFavoriteMutation,
|
|
useRemoveFavoriteMutation,
|
|
} from "@/features/favorite/queries/favorite-query";
|
|
import {
|
|
useWatchStatusQuery,
|
|
useWatchPageMutation,
|
|
useUnwatchPageMutation,
|
|
} from "@/features/page/queries/watcher-query";
|
|
import ShareModal from "@/features/share/components/share-modal.tsx";
|
|
import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts";
|
|
|
|
interface PageHeaderMenuProps {
|
|
readOnly?: boolean;
|
|
}
|
|
export default function PageHeaderMenu({ readOnly }: PageHeaderMenuProps) {
|
|
const { t } = useTranslation();
|
|
const commentsTriggerProps = useAsideTriggerProps("comments");
|
|
const tocTriggerProps = useAsideTriggerProps("toc");
|
|
const { pageSlug } = useParams();
|
|
const { data: page } = usePageQuery({
|
|
pageId: extractPageSlugId(pageSlug),
|
|
});
|
|
const isDeleted = !!page?.deletedAt;
|
|
const [workspace] = useAtom(workspaceAtom);
|
|
// Community public-sharing entry point (replaces the removed EE PageShareModal)
|
|
const workspaceSharingDisabled = workspace?.settings?.sharing?.disabled === true;
|
|
|
|
useHotkeys(
|
|
[
|
|
[
|
|
"mod+F",
|
|
() => {
|
|
const event = new CustomEvent("openFindDialogFromEditor", {});
|
|
document.dispatchEvent(event);
|
|
},
|
|
],
|
|
[
|
|
"Escape",
|
|
() => {
|
|
const event = new CustomEvent("closeFindDialogFromEditor", {});
|
|
document.dispatchEvent(event);
|
|
},
|
|
{ preventDefault: false },
|
|
],
|
|
],
|
|
[],
|
|
);
|
|
|
|
if (isDeleted) {
|
|
return null;
|
|
}
|
|
|
|
return (
|
|
<>
|
|
<ConnectionWarning />
|
|
|
|
{!readOnly && <PageEditModeToggle size="xs" />}
|
|
|
|
{/* Hide the Share entry point for readers; the toggle inside is inert
|
|
without edit permission, so gate it like other edit-only actions
|
|
(issue #133) */}
|
|
{!readOnly && !workspaceSharingDisabled && (
|
|
<ShareModal readOnly={false} />
|
|
)}
|
|
|
|
<Button
|
|
variant="subtle"
|
|
color="dark"
|
|
size="compact-sm"
|
|
{...commentsTriggerProps}
|
|
>
|
|
{t("Comments")}
|
|
</Button>
|
|
|
|
<Tooltip label={t("Table of contents")} openDelay={250} withArrow>
|
|
<ActionIcon
|
|
variant="subtle"
|
|
color="dark"
|
|
aria-label={t("Table of contents")}
|
|
{...tocTriggerProps}
|
|
>
|
|
<IconList size={20} stroke={2} />
|
|
</ActionIcon>
|
|
</Tooltip>
|
|
|
|
<PageActionMenu readOnly={readOnly} />
|
|
</>
|
|
);
|
|
}
|
|
|
|
interface PageActionMenuProps {
|
|
readOnly?: boolean;
|
|
}
|
|
function PageActionMenu({ readOnly }: PageActionMenuProps) {
|
|
const { t } = useTranslation();
|
|
const [, setHistoryModalOpen] = useAtom(historyAtoms);
|
|
const clipboard = useClipboard({ timeout: 500 });
|
|
const { pageSlug, spaceSlug } = useParams();
|
|
const { data: page, isLoading } = usePageQuery({
|
|
pageId: extractPageSlugId(pageSlug),
|
|
});
|
|
const { handleDelete } = useTreeMutation(page?.spaceId ?? "");
|
|
const [exportOpened, { open: openExportModal, close: closeExportModal }] =
|
|
useDisclosure(false);
|
|
const [
|
|
movePageModalOpened,
|
|
{ open: openMovePageModal, close: closeMoveSpaceModal },
|
|
] = useDisclosure(false);
|
|
const [pageEditor] = useAtom(pageEditorAtom);
|
|
const pageUpdatedAt = useTimeAgo(page?.updatedAt);
|
|
const favoriteIds = useFavoriteIds("page", page?.spaceId);
|
|
const addFavoriteMutation = useAddFavoriteMutation();
|
|
const removeFavoriteMutation = useRemoveFavoriteMutation();
|
|
const isFavorited = page?.id ? favoriteIds.has(page.id) : false;
|
|
const { data: watchStatus } = useWatchStatusQuery(page?.id);
|
|
const watchPage = useWatchPageMutation();
|
|
const unwatchPage = useUnwatchPageMutation();
|
|
const toggleTemporary = useToggleTemporaryMutation();
|
|
const isTemporary = !!page?.temporaryExpiresAt;
|
|
|
|
const handleToggleTemporary = async () => {
|
|
if (!page?.id) return;
|
|
const next = !isTemporary;
|
|
try {
|
|
const res = await toggleTemporary.mutateAsync({
|
|
pageId: page.id,
|
|
temporary: next,
|
|
});
|
|
// Reflect the new deadline in the page cache (menu label + banner) AND in
|
|
// the sidebar tree node so its clock marker updates immediately, no reload.
|
|
syncTemporaryExpiresInCache(page, res.temporaryExpiresAt);
|
|
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, page.slugId, page.title);
|
|
|
|
clipboard.copy(pageUrl);
|
|
notifications.show({ message: t("Link copied") });
|
|
};
|
|
|
|
const handleCopyAsMarkdown = () => {
|
|
if (!pageEditor) return;
|
|
const html = pageEditor.getHTML();
|
|
const markdown = htmlToMarkdown(html);
|
|
const title = page?.title ? `# ${page.title}\n\n` : "";
|
|
clipboard.copy(`${title}${markdown}`);
|
|
notifications.show({ message: t("Copied") });
|
|
};
|
|
|
|
const handlePrint = () => {
|
|
setTimeout(() => {
|
|
window.print();
|
|
}, 250);
|
|
};
|
|
|
|
const openHistoryModal = () => {
|
|
setHistoryModalOpen(true);
|
|
};
|
|
|
|
const handleDeletePage = () => {
|
|
handleDelete(page.id);
|
|
};
|
|
|
|
const handleToggleFavorite = () => {
|
|
if (!page?.id) return;
|
|
const params = { type: "page" as const, pageId: page.id };
|
|
if (isFavorited) {
|
|
removeFavoriteMutation.mutate(params);
|
|
} else {
|
|
addFavoriteMutation.mutate(params);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<>
|
|
<Menu
|
|
shadow="xl"
|
|
position="bottom-end"
|
|
offset={20}
|
|
width={230}
|
|
withArrow
|
|
arrowPosition="center"
|
|
>
|
|
<Menu.Target>
|
|
<ActionIcon
|
|
variant="subtle"
|
|
color="dark"
|
|
aria-label={t("Page actions")}
|
|
>
|
|
<IconDots size={20} />
|
|
</ActionIcon>
|
|
</Menu.Target>
|
|
|
|
<Menu.Dropdown>
|
|
<Menu.Item
|
|
leftSection={<IconLink size={16} />}
|
|
onClick={handleCopyLink}
|
|
>
|
|
{t("Copy link")}
|
|
</Menu.Item>
|
|
|
|
<Menu.Item
|
|
leftSection={<IconMarkdown size={16} />}
|
|
onClick={handleCopyAsMarkdown}
|
|
>
|
|
{t("Copy as Markdown")}
|
|
</Menu.Item>
|
|
|
|
<Menu.Item
|
|
leftSection={
|
|
isFavorited ? (
|
|
<IconStarFilled size={16} color="var(--mantine-color-yellow-5)" />
|
|
) : (
|
|
<IconStar size={16} />
|
|
)
|
|
}
|
|
onClick={handleToggleFavorite}
|
|
>
|
|
{isFavorited ? t("Remove from favorites") : t("Add to favorites")}
|
|
</Menu.Item>
|
|
|
|
{watchStatus?.watching ? (
|
|
<Menu.Item
|
|
leftSection={<IconEyeOff size={16} />}
|
|
onClick={() => unwatchPage.mutate(page.id)}
|
|
>
|
|
{t("Stop watching")}
|
|
</Menu.Item>
|
|
) : (
|
|
<Menu.Item
|
|
leftSection={<IconEye size={16} />}
|
|
onClick={() => watchPage.mutate(page.id)}
|
|
>
|
|
{t("Watch page")}
|
|
</Menu.Item>
|
|
)}
|
|
|
|
<Menu.Divider />
|
|
|
|
<Menu.Item leftSection={<IconArrowsHorizontal size={16} />}>
|
|
<Group wrap="nowrap">
|
|
<PageWidthToggle label={t("Full width")} />
|
|
</Group>
|
|
</Menu.Item>
|
|
|
|
<Menu.Item
|
|
leftSection={<IconHistory size={16} />}
|
|
onClick={openHistoryModal}
|
|
>
|
|
{t("Page history")}
|
|
</Menu.Item>
|
|
|
|
<Menu.Divider />
|
|
|
|
{!readOnly && (
|
|
<Menu.Item
|
|
leftSection={<IconArrowRight size={16} />}
|
|
onClick={openMovePageModal}
|
|
>
|
|
{t("Move to space")}
|
|
</Menu.Item>
|
|
)}
|
|
|
|
<Menu.Item
|
|
leftSection={<IconFileExport size={16} />}
|
|
onClick={openExportModal}
|
|
>
|
|
{t("Export")}
|
|
</Menu.Item>
|
|
|
|
<Menu.Item
|
|
leftSection={<IconPrinter size={16} />}
|
|
onClick={handlePrint}
|
|
>
|
|
{t("Print PDF")}
|
|
</Menu.Item>
|
|
|
|
{!readOnly && (
|
|
<>
|
|
<Menu.Divider />
|
|
<Menu.Item
|
|
leftSection={<IconClockHour4 size={16} />}
|
|
onClick={handleToggleTemporary}
|
|
>
|
|
{isTemporary ? t("Make permanent") : t("Make temporary")}
|
|
</Menu.Item>
|
|
<Menu.Item
|
|
color={"red"}
|
|
leftSection={<IconTrash size={16} />}
|
|
onClick={handleDeletePage}
|
|
>
|
|
{t("Move to trash")}
|
|
</Menu.Item>
|
|
</>
|
|
)}
|
|
|
|
<Menu.Divider />
|
|
|
|
<>
|
|
<Group px="sm" wrap="nowrap" style={{ cursor: "pointer" }}>
|
|
<Tooltip
|
|
label={t("Edited by {{name}} {{time}}", {
|
|
name: page.lastUpdatedBy.name,
|
|
time: pageUpdatedAt,
|
|
})}
|
|
position="left-start"
|
|
>
|
|
<div style={{ width: 210 }}>
|
|
<Text size="xs" c="dimmed" truncate="end">
|
|
{t("Word count: {{wordCount}}", {
|
|
wordCount: pageEditor?.storage?.characterCount?.words(),
|
|
})}
|
|
</Text>
|
|
|
|
<Text size="xs" c="dimmed" lineClamp={1}>
|
|
<Trans
|
|
defaults="Created by: <b>{{creatorName}}</b>"
|
|
values={{ creatorName: page?.creator?.name }}
|
|
components={{ b: <Text span fw={500} /> }}
|
|
/>
|
|
</Text>
|
|
<Text size="xs" c="dimmed" truncate="end">
|
|
{t("Created at: {{time}}", {
|
|
time: formattedDate(page.createdAt),
|
|
})}
|
|
</Text>
|
|
</div>
|
|
</Tooltip>
|
|
</Group>
|
|
</>
|
|
</Menu.Dropdown>
|
|
</Menu>
|
|
|
|
<ExportModal
|
|
type="page"
|
|
id={page.id}
|
|
open={exportOpened}
|
|
onClose={closeExportModal}
|
|
/>
|
|
|
|
<MovePageModal
|
|
pageId={page.id}
|
|
slugId={page.slugId}
|
|
currentSpaceSlug={spaceSlug}
|
|
onClose={closeMoveSpaceModal}
|
|
open={movePageModalOpened}
|
|
/>
|
|
</>
|
|
);
|
|
}
|
|
|
|
function ConnectionWarning() {
|
|
const { t } = useTranslation();
|
|
const yjsConnectionStatus = useAtomValue(yjsConnectionStatusAtom);
|
|
const [showWarning, setShowWarning] = useState(false);
|
|
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
|
|
useEffect(() => {
|
|
const isDisconnected = ["disconnected", "connecting"].includes(
|
|
yjsConnectionStatus,
|
|
);
|
|
|
|
if (isDisconnected) {
|
|
if (!timeoutRef.current) {
|
|
timeoutRef.current = setTimeout(() => setShowWarning(true), 5000);
|
|
}
|
|
} else {
|
|
if (timeoutRef.current) {
|
|
clearTimeout(timeoutRef.current);
|
|
timeoutRef.current = null;
|
|
}
|
|
setShowWarning(false);
|
|
}
|
|
}, [yjsConnectionStatus]);
|
|
|
|
// Cleanup only on unmount
|
|
useEffect(() => {
|
|
return () => {
|
|
if (timeoutRef.current) {
|
|
clearTimeout(timeoutRef.current);
|
|
}
|
|
};
|
|
}, []);
|
|
|
|
if (!showWarning) return null;
|
|
|
|
return (
|
|
<Tooltip
|
|
label={t("Real-time editor connection lost. Retrying...")}
|
|
openDelay={250}
|
|
withArrow
|
|
>
|
|
<ThemeIcon
|
|
variant="default"
|
|
c="red"
|
|
role="status"
|
|
aria-label={t("Real-time editor connection lost. Retrying...")}
|
|
style={{ border: "none" }}
|
|
>
|
|
<IconWifiOff size={20} stroke={2} />
|
|
</ThemeIcon>
|
|
</Tooltip>
|
|
);
|
|
}
|