feat(offline): PWA shell, Yjs-backed titles, and offline read cache (M0–M2)

Implements docs/offline-sync-plan.md milestones M0–M2.

M0 (PWA shell):
- Add vite-plugin-pwa (generateSW, registerType: 'prompt', manifest:false);
  NetworkOnly for /api,/collab,/socket.io, NetworkFirst for GET /api,
  navigateFallback to index.html.
- Register SW via useRegisterSW with a Mantine update prompt; skip
  registration inside Capacitor native WebView (is-capacitor guard).

M1 (harden CRDT body + title into Yjs):
- Lift the per-page Y.Doc/Hocuspocus providers into a shared hook+context so
  body and title editors share one doc.
- Move the page title into a dedicated 'title' Yjs fragment (CRDT, offline-
  tolerant); drop the REST title save. Server persists the title fragment to
  page.title and seeds it for legacy pages (empty-fragment guard); a collab
  rename emits a treeUpdate so other users' tree/breadcrumbs refresh.
- Persist the rebuilt ydoc on the content->ydoc path to neutralize the Yjs
  duplication trap. Add a 3-state sync indicator.

M2 (offline read/navigation):
- Persist React Query to IndexedDB (idb-keyval persister, version buster,
  selected roots only).
- "Make available offline" action warms page, space, tree (root+ancestors+
  children) and comments under exact hook keys, plus the page ydoc.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
claude_code
2026-06-21 16:00:27 +03:00
parent e5bc82c7f1
commit eabdce13b4
22 changed files with 1933 additions and 294 deletions
@@ -6,6 +6,7 @@ import { useDisclosure } from "@mantine/hooks";
import { notifications } from "@mantine/notifications";
import {
IconArrowRight,
IconCloudDownload,
IconCopy,
IconDotsVertical,
IconFileExport,
@@ -32,6 +33,12 @@ import {
} from "@/features/favorite/queries/favorite-query";
import { useToggleTemplateMutation } 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 { useTreeMutation } from "@/features/page/tree/hooks/use-tree-mutation.ts";
@@ -67,6 +74,29 @@ export function NodeMenu({ node, canEdit }: NodeMenuProps) {
const isFavorited = favoriteIds.has(node.id);
const toggleTemplate = useToggleTemplateMutation();
const isTemplate = !!node.isTemplate;
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.
await makePageAvailableOffline({
pageId: node.id,
slugId: node.slugId,
spaceId: node.spaceId,
parentPageId: node.parentPageId,
});
// Best-effort: warm the page's Yjs document into IndexedDB.
await warmPageYdoc(node.id, getCollaborationUrl(), collabQuery?.token);
notifications.show({ message: t("Page is now available offline") });
} catch {
// makePageAvailableOffline / warmPageYdoc never throw, but stay safe.
notifications.show({
message: t("Failed to make page available offline"),
color: "red",
});
}
};
const handleToggleTemplate = async () => {
const next = !isTemplate;
@@ -204,6 +234,17 @@ export function NodeMenu({ node, canEdit }: NodeMenuProps) {
{t("Export page")}
</Menu.Item>
<Menu.Item
leftSection={<IconCloudDownload size={16} />}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
handleMakeAvailableOffline();
}}
>
{t("Make available offline")}
</Menu.Item>
{canEdit && (
<>
<Menu.Item