Files
gitmost/apps/client/src/features/page/components/header/page-header-menu.tsx
claude code agent 227 12ff76fb89 fix(temporary-notes): live sidebar clock marker + stacked mobile create buttons
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>
2026-06-27 00:29:19 +03:00

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>
);
}