1bcc96685e
Outside the editor the UI did background work on every tree event, socket reconnect, and navigation. Tree infra (virtualization/memo/O(N) utils) was already good — the cost was in the subscriptions and duplicates around it. Client-only; behavior 1:1. - Setter-only atom subscriptions → useSetAtom: space-tree-row, use-tree-mutation, use-tree-socket no longer subscribe every visible row to the WHOLE treeDataAtom value (a tree event re-rendered all ~20-30 rows, bypassing the DocTreeRow memo). space-tree-node-menu / mention-list read the tree imperatively (store.get) in their handlers only. breadcrumb.tsx uses a selectAtom slice (ancestor chain + field equality) instead of the whole-tree subscription. - Socket handler cleanup (BUG): use-tree-socket + use-query-subscription now socket.off() their named handlers on cleanup (were accumulating listeners on every reconnect → duplicated invalidations/tree-walks). Mirrors use-notification-socket. - Field-update tree path: invalidateOnUpdatePage does a pointwise patch of the cached embed subtrees instead of a blanket invalidatePageTree() (refetch storm); structural events keep the blanket invalidate. - usePageMetaQuery: a content-less select slice for the 13 peripheral subscribers that read only title/permissions/id, so they stop re-rendering every ~3s while typing / on every collab page.updated (page.tsx keeps the full query for content). - page.tsx: skeleton + placeholderData keepPreviousData (no blank flash on nav). - Removed refetchOnMount:true where socket/mutation invalidation already keeps the cache fresh (favorite/space/space-watcher/workspace). KEPT it on the 3 queries with NO other freshness path (trash-list, created-by, recent-changes) — the global default is refetchOnMount:false, so those overrides are load-bearing. - Small: resize mousemove/up attached only while dragging; per-row emoji-picker keydown gated on `opened`; AiChatWindow queries enabled only when the window is open. Gate: client tsc 0, client vitest page+websocket 200 passed (+editor suites), build ok. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
161 lines
5.6 KiB
TypeScript
161 lines
5.6 KiB
TypeScript
import {
|
|
ActionIcon,
|
|
Button,
|
|
Group,
|
|
Paper,
|
|
Text,
|
|
Tooltip,
|
|
} from "@mantine/core";
|
|
import { IconClockHour4, IconTrash } from "@tabler/icons-react";
|
|
import { useState } from "react";
|
|
import { Trans, useTranslation } from "react-i18next";
|
|
import { useTimeAgo } from "@/hooks/use-time-ago.tsx";
|
|
import { usePageMetaQuery } from "@/features/page/queries/page-query.ts";
|
|
import { useTreeMutation } from "@/features/page/tree/hooks/use-tree-mutation.ts";
|
|
import {
|
|
useToggleTemporaryMutation,
|
|
syncTemporaryExpiresInCache,
|
|
} from "@/features/page-embed/queries/page-embed-query.ts";
|
|
import { useGetSpaceBySlugQuery } from "@/features/space/queries/space-query.ts";
|
|
import { useSpaceAbility } from "@/features/space/permissions/use-space-ability.ts";
|
|
import {
|
|
SpaceCaslAction,
|
|
SpaceCaslSubject,
|
|
} from "@/features/space/permissions/permissions.type.ts";
|
|
|
|
type TemporaryNoteBannerProps = {
|
|
slugId: string;
|
|
};
|
|
|
|
/**
|
|
* Banner shown on an open temporary note ("structure or die"). Mirrors
|
|
* DeletedPageBanner: it reads the page from the shared query cache and offers
|
|
* the explicit rescue action — "Make permanent". Children ride along to trash
|
|
* with the note, which is noted in the copy.
|
|
*/
|
|
export function TemporaryNoteBanner({ slugId }: TemporaryNoteBannerProps) {
|
|
const { t } = useTranslation();
|
|
const { data: page } = usePageMetaQuery({ pageId: slugId });
|
|
const { data: space } = useGetSpaceBySlugQuery(page?.space?.slug);
|
|
const spaceAbility = useSpaceAbility(space?.membership?.permissions);
|
|
const expiresTimeAgo = useTimeAgo(page?.temporaryExpiresAt);
|
|
const toggleTemporary = useToggleTemporaryMutation();
|
|
// Reuse the exact soft-delete path the tree/header menus use: optimistic
|
|
// tree removal, the "Page moved to trash" undo-toast, the deletedAt cache
|
|
// stamp, and the redirect to space home (which unmounts this banner).
|
|
const { handleDelete: trashPage } = useTreeMutation(page?.spaceId ?? "");
|
|
const [isDeleting, setIsDeleting] = useState(false);
|
|
|
|
// Don't show on a note that is already in trash; the deleted-page banner
|
|
// owns that state.
|
|
if (!page?.temporaryExpiresAt || page?.deletedAt) return null;
|
|
|
|
const canEdit = spaceAbility.can(SpaceCaslAction.Edit, SpaceCaslSubject.Page);
|
|
|
|
const handleTrashNow = async () => {
|
|
// No confirm modal by convention — the undo-toast is the safety net.
|
|
setIsDeleting(true);
|
|
try {
|
|
await trashPage(page.id);
|
|
} finally {
|
|
setIsDeleting(false);
|
|
}
|
|
};
|
|
|
|
const handleMakePermanent = async () => {
|
|
try {
|
|
const res = await toggleTemporary.mutateAsync({
|
|
pageId: page.id,
|
|
temporary: false,
|
|
});
|
|
syncTemporaryExpiresInCache(page, res.temporaryExpiresAt);
|
|
} catch {
|
|
// mutation surfaces the error via notifications
|
|
}
|
|
};
|
|
|
|
return (
|
|
<Paper radius="sm" mb="md" px="md" py="xs" bg="orange.0">
|
|
<Group justify="space-between" wrap="wrap" gap="sm">
|
|
{/* A non-zero flex-basis lets the outer wrap="wrap" drop the buttons to
|
|
their own row on narrow screens; flex:1 (basis 0) never wraps and
|
|
instead crushes the text into a one-word-per-line ladder. */}
|
|
<Group
|
|
gap="xs"
|
|
wrap="nowrap"
|
|
style={{ flex: "1 1 16rem", minWidth: 0 }}
|
|
>
|
|
<IconClockHour4
|
|
size={18}
|
|
stroke={1.5}
|
|
style={{
|
|
flexShrink: 0,
|
|
color: "var(--mantine-color-orange-7)",
|
|
}}
|
|
/>
|
|
<Text size="sm">
|
|
<Trans
|
|
i18nKey="This temporary note moves to trash {{time}} (with its sub-pages) unless made permanent."
|
|
values={{ time: expiresTimeAgo }}
|
|
/>
|
|
</Text>
|
|
</Group>
|
|
{canEdit && (
|
|
<>
|
|
{/* Desktop: full labeled buttons. */}
|
|
<Group gap="xs" wrap="nowrap" visibleFrom="sm">
|
|
<Button
|
|
size="xs"
|
|
variant="subtle"
|
|
color="red"
|
|
leftSection={<IconTrash size={16} />}
|
|
onClick={handleTrashNow}
|
|
loading={isDeleting}
|
|
>
|
|
{t("Move to trash")}
|
|
</Button>
|
|
<Button
|
|
size="xs"
|
|
variant="light"
|
|
color="orange"
|
|
leftSection={<IconClockHour4 size={16} />}
|
|
onClick={handleMakePermanent}
|
|
loading={toggleTemporary.isPending}
|
|
>
|
|
{t("Make permanent")}
|
|
</Button>
|
|
</Group>
|
|
{/* Mobile: icon-only actions so they never overflow the narrow row. */}
|
|
<Group gap="xs" wrap="nowrap" hiddenFrom="sm">
|
|
<Tooltip label={t("Move to trash")} withArrow>
|
|
<ActionIcon
|
|
size="lg"
|
|
variant="subtle"
|
|
color="red"
|
|
onClick={handleTrashNow}
|
|
loading={isDeleting}
|
|
aria-label={t("Move to trash")}
|
|
>
|
|
<IconTrash size={18} />
|
|
</ActionIcon>
|
|
</Tooltip>
|
|
<Tooltip label={t("Make permanent")} withArrow>
|
|
<ActionIcon
|
|
size="lg"
|
|
variant="light"
|
|
color="orange"
|
|
onClick={handleMakePermanent}
|
|
loading={toggleTemporary.isPending}
|
|
aria-label={t("Make permanent")}
|
|
>
|
|
<IconClockHour4 size={18} />
|
|
</ActionIcon>
|
|
</Tooltip>
|
|
</Group>
|
|
</>
|
|
)}
|
|
</Group>
|
|
</Paper>
|
|
);
|
|
}
|