From e9a5e22ba3ded15fdfac49910861fe6a81c8cc5b Mon Sep 17 00:00:00 2001 From: claude_code Date: Sun, 21 Jun 2026 16:00:27 +0300 Subject: [PATCH] =?UTF-8?q?feat(offline):=20PWA=20shell,=20Yjs-backed=20ti?= =?UTF-8?q?tles,=20and=20offline=20read=20cache=20(M0=E2=80=93M2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- apps/client/package.json | 4 + .../public/locales/en-US/translation.json | 9 + .../public/locales/ru-RU/translation.json | 9 + .../src/features/editor/atoms/editor-atoms.ts | 6 + .../contexts/editor-providers-context.tsx | 21 + .../src/features/editor/full-editor.tsx | 50 +- .../editor/hooks/use-page-collab-providers.ts | 194 +++++ .../src/features/editor/page-editor.tsx | 174 +--- .../src/features/editor/title-editor.tsx | 245 +++--- .../src/features/offline/make-offline.ts | 231 ++++++ .../src/features/offline/query-persister.ts | 45 + .../components/header/page-header-menu.tsx | 71 +- .../src/features/page/queries/page-query.ts | 6 - .../tree/components/space-tree-node-menu.tsx | 41 + apps/client/src/main.tsx | 28 +- apps/client/src/pwa/is-capacitor.ts | 23 + apps/client/src/pwa/pwa-update-prompt.tsx | 59 ++ apps/client/src/vite-env.d.ts | 2 + apps/client/vite.config.ts | 37 +- .../src/collaboration/collaboration.util.ts | 29 + .../extensions/persistence.extension.ts | 228 ++++- pnpm-lock.yaml | 779 +++++++++++++++++- 22 files changed, 1946 insertions(+), 345 deletions(-) create mode 100644 apps/client/src/features/editor/contexts/editor-providers-context.tsx create mode 100644 apps/client/src/features/editor/hooks/use-page-collab-providers.ts create mode 100644 apps/client/src/features/offline/make-offline.ts create mode 100644 apps/client/src/features/offline/query-persister.ts create mode 100644 apps/client/src/pwa/is-capacitor.ts create mode 100644 apps/client/src/pwa/pwa-update-prompt.tsx diff --git a/apps/client/package.json b/apps/client/package.json index 010cb5e4..e2482825 100644 --- a/apps/client/package.json +++ b/apps/client/package.json @@ -33,7 +33,9 @@ "@slidoapp/emoji-mart-data": "1.2.4", "@slidoapp/emoji-mart-react": "1.1.5", "@tabler/icons-react": "3.40.0", + "@tanstack/query-async-storage-persister": "5.90.17", "@tanstack/react-query": "5.90.17", + "@tanstack/react-query-persist-client": "5.90.17", "@tanstack/react-virtual": "3.13.24", "ai": "6.0.207", "alfaaz": "1.1.0", @@ -45,6 +47,7 @@ "highlightjs-sap-abap": "0.3.0", "i18next": "25.10.1", "i18next-http-backend": "3.0.6", + "idb-keyval": "6.2.5", "jotai": "2.18.1", "jotai-optics": "0.4.0", "js-cookie": "3.0.7", @@ -95,6 +98,7 @@ "typescript": "5.9.3", "typescript-eslint": "8.57.1", "vite": "8.0.5", + "vite-plugin-pwa": "1.3.0", "vitest": "4.1.6" } } diff --git a/apps/client/public/locales/en-US/translation.json b/apps/client/public/locales/en-US/translation.json index ce432829..b55cc336 100644 --- a/apps/client/public/locales/en-US/translation.json +++ b/apps/client/public/locales/en-US/translation.json @@ -464,6 +464,15 @@ "Move page": "Move page", "Move page to a different space.": "Move page to a different space.", "Real-time editor connection lost. Retrying...": "Real-time editor connection lost. Retrying...", + "Offline — changes are saved locally and will sync when you reconnect": "Offline — changes are saved locally and will sync when you reconnect", + "Syncing changes…": "Syncing changes…", + "All changes synced": "All changes synced", + "Update available": "Update available", + "Reload": "Reload", + "Make available offline": "Make available offline", + "Saving page for offline use...": "Saving page for offline use...", + "Page is now available offline": "Page is now available offline", + "Failed to make page available offline": "Failed to make page available offline", "Table of contents": "Table of contents", "Add headings (H1, H2, H3) to generate a table of contents.": "Add headings (H1, H2, H3) to generate a table of contents.", "Share": "Share", diff --git a/apps/client/public/locales/ru-RU/translation.json b/apps/client/public/locales/ru-RU/translation.json index fc2e6942..0e6a69b7 100644 --- a/apps/client/public/locales/ru-RU/translation.json +++ b/apps/client/public/locales/ru-RU/translation.json @@ -474,6 +474,15 @@ "Move page": "Переместить страницу", "Move page to a different space.": "Переместите страницу в другое пространство.", "Real-time editor connection lost. Retrying...": "Соединение с редактором в реальном времени потеряно. Повторная попытка...", + "Offline — changes are saved locally and will sync when you reconnect": "Нет сети — изменения сохраняются локально и синхронизируются при восстановлении соединения", + "Syncing changes…": "Синхронизация изменений…", + "All changes synced": "Все изменения синхронизированы", + "Update available": "Доступно обновление", + "Reload": "Перезагрузить", + "Make available offline": "Сделать доступным офлайн", + "Saving page for offline use...": "Сохраняем страницу для офлайн-доступа…", + "Page is now available offline": "Страница доступна офлайн", + "Failed to make page available offline": "Не удалось сделать страницу доступной офлайн", "Table of contents": "Оглавление", "Add headings (H1, H2, H3) to generate a table of contents.": "Добавьте заголовки (H1, H2, H3), чтобы создать оглавление.", "Share": "Поделиться", diff --git a/apps/client/src/features/editor/atoms/editor-atoms.ts b/apps/client/src/features/editor/atoms/editor-atoms.ts index c0873adf..718118cc 100644 --- a/apps/client/src/features/editor/atoms/editor-atoms.ts +++ b/apps/client/src/features/editor/atoms/editor-atoms.ts @@ -10,6 +10,12 @@ export const readOnlyEditorAtom = atom(null); export const yjsConnectionStatusAtom = atom(""); +// Local (IndexedDB) persistence sync state for the current page's Y.Doc. +export const isLocalSyncedAtom = atom(false); + +// Remote (Hocuspocus) sync state for the current page's Y.Doc. +export const isRemoteSyncedAtom = atom(false); + export const showAiMenuAtom = atom(false); export const showLinkMenuAtom = atom(false); diff --git a/apps/client/src/features/editor/contexts/editor-providers-context.tsx b/apps/client/src/features/editor/contexts/editor-providers-context.tsx new file mode 100644 index 00000000..7ed8322f --- /dev/null +++ b/apps/client/src/features/editor/contexts/editor-providers-context.tsx @@ -0,0 +1,21 @@ +import { createContext, useContext } from "react"; +import type { HocuspocusProvider } from "@hocuspocus/provider"; +import type * as Y from "yjs"; + +// Shared collaboration providers lifted above the title/body editors so that +// both siblings bind to the SAME Y.Doc and HocuspocusProvider. The title lives +// in a dedicated 'title' fragment of the same doc as the body. +export interface EditorProvidersContextValue { + ydoc: Y.Doc; + remote: HocuspocusProvider; + providersReady: boolean; +} + +export const EditorProvidersContext = + createContext(null); + +// Returns the shared providers, or null when rendered outside of a provider. +// Consumers must be null-safe (the body editor falls back to a non-collab mode). +export function useEditorProviders(): EditorProvidersContextValue | null { + return useContext(EditorProvidersContext); +} diff --git a/apps/client/src/features/editor/full-editor.tsx b/apps/client/src/features/editor/full-editor.tsx index 6cae0ee2..fa1718a0 100644 --- a/apps/client/src/features/editor/full-editor.tsx +++ b/apps/client/src/features/editor/full-editor.tsx @@ -33,6 +33,8 @@ import { pageEditorAtom, } from "@/features/editor/atoms/editor-atoms.ts"; import { DictationGroup } from "@/features/editor/components/fixed-toolbar/groups/dictation-group"; +import { usePageCollabProviders } from "@/features/editor/hooks/use-page-collab-providers"; +import { EditorProvidersContext } from "@/features/editor/contexts/editor-providers-context"; const MemoizedTitleEditor = React.memo(TitleEditor); const MemoizedPageEditor = React.memo(PageEditor); @@ -86,6 +88,10 @@ export function FullEditor({ user.settings?.preferences?.pageEditMode ?? PageEditMode.Edit; const isEditMode = currentPageEditMode === PageEditMode.Edit; + // Single shared Y.Doc + HocuspocusProvider for both the title and body + // editors (title lives in the 'title' fragment of the same doc). + const { ydoc, remote, providersReady } = usePageCollabProviders(pageId); + // Apply the user's saved preference only once on initial load, not on every // page navigation — so the mode sticks across navigations within a session. useEffect(() => { @@ -106,26 +112,30 @@ export function FullEditor({ )} - - - + + + + + ); } diff --git a/apps/client/src/features/editor/hooks/use-page-collab-providers.ts b/apps/client/src/features/editor/hooks/use-page-collab-providers.ts new file mode 100644 index 00000000..c59252a4 --- /dev/null +++ b/apps/client/src/features/editor/hooks/use-page-collab-providers.ts @@ -0,0 +1,194 @@ +import { useEffect, useRef, useState } from "react"; +import { IndexeddbPersistence } from "y-indexeddb"; +import * as Y from "yjs"; +import { + HocuspocusProvider, + onStatusParameters, + WebSocketStatus, + HocuspocusProviderWebsocket, + onSyncedParameters, + onStatelessParameters, +} from "@hocuspocus/provider"; +import { useAtom, useSetAtom } from "jotai"; +import useCollaborationUrl from "@/features/editor/hooks/use-collaboration-url"; +import { + isLocalSyncedAtom, + isRemoteSyncedAtom, + yjsConnectionStatusAtom, +} from "@/features/editor/atoms/editor-atoms"; +import { useCollabToken } from "@/features/auth/queries/auth-query.tsx"; +import { useDocumentVisibility } from "@mantine/hooks"; +import { useIdle } from "@/hooks/use-idle.ts"; +import { queryClient } from "@/main.tsx"; +import { IPage } from "@/features/page/types/page.types.ts"; +import { useParams } from "react-router-dom"; +import { extractPageSlugId } from "@/lib"; +import { FIVE_MINUTES } from "@/lib/constants.ts"; +import { jwtDecode } from "jwt-decode"; + +export interface PageCollabProviders { + ydoc: Y.Doc | null; + remote: HocuspocusProvider | null; + socket: HocuspocusProviderWebsocket | null; + providersReady: boolean; + isLocalSynced: boolean; + isRemoteSynced: boolean; +} + +/** + * Owns the full collaboration provider lifecycle for a page so that the title + * and body editors can share a single Y.Doc + HocuspocusProvider. The behavior + * is relocated verbatim from page-editor.tsx: it creates the providers once per + * pageId, connects/disconnects on idle/visibility, attaches each render, + * destroys on unmount, refreshes the collab token on auth failure, and applies + * the onStateless 'page.updated' cache update. + */ +export function usePageCollabProviders(pageId: string): PageCollabProviders { + const collaborationURL = useCollaborationUrl(); + const [isLocalSynced, setIsLocalSynced] = useState(false); + const [isRemoteSynced, setIsRemoteSynced] = useState(false); + const [yjsConnectionStatus, setYjsConnectionStatus] = useAtom( + yjsConnectionStatusAtom, + ); + const setIsLocalSyncedAtom = useSetAtom(isLocalSyncedAtom); + const setIsRemoteSyncedAtom = useSetAtom(isRemoteSyncedAtom); + const { data: collabQuery, refetch: refetchCollabToken } = useCollabToken(); + const { isIdle, resetIdle } = useIdle(FIVE_MINUTES, { initialState: false }); + const documentState = useDocumentVisibility(); + const { pageSlug } = useParams(); + const slugId = extractPageSlugId(pageSlug); + + // Providers only created once per pageId + const providersRef = useRef<{ + ydoc: Y.Doc; + local: IndexeddbPersistence; + remote: HocuspocusProvider; + socket: HocuspocusProviderWebsocket; + } | null>(null); + const [providersReady, setProvidersReady] = useState(false); + + // Mirror the local/remote sync flags into shared atoms so the header + // indicator can read them, and keep the local component state in sync too. + const setLocalSynced = (value: boolean) => { + setIsLocalSynced(value); + setIsLocalSyncedAtom(value); + }; + const setRemoteSynced = (value: boolean) => { + setIsRemoteSynced(value); + setIsRemoteSyncedAtom(value); + }; + + useEffect(() => { + if (!providersRef.current) { + const documentName = `page.${pageId}`; + const ydoc = new Y.Doc(); + const local = new IndexeddbPersistence(documentName, ydoc); + const socket = new HocuspocusProviderWebsocket({ + url: collaborationURL, + }); + const onLocalSyncedHandler = () => { + setLocalSynced(true); + }; + const onStatusHandler = (event: onStatusParameters) => { + setYjsConnectionStatus(event.status); + }; + const onSyncedHandler = (event: onSyncedParameters) => { + setRemoteSynced(event.state); + }; + const onStatelessHandler = ({ payload }: onStatelessParameters) => { + try { + const message = JSON.parse(payload); + if (message?.type !== "page.updated" || !message.updatedAt) return; + const pageData = queryClient.getQueryData(["pages", slugId]); + if (pageData) { + queryClient.setQueryData(["pages", slugId], { + ...pageData, + updatedAt: message.updatedAt, + ...(message.lastUpdatedBy && { + lastUpdatedBy: message.lastUpdatedBy, + }), + }); + } + } catch { + // ignore unrelated stateless messages + } + }; + const onAuthenticationFailedHandler = () => { + const payload = jwtDecode(collabQuery?.token); + const now = Date.now().valueOf() / 1000; + const isTokenExpired = now >= payload.exp; + if (isTokenExpired) { + refetchCollabToken().then((result) => { + if (result.data?.token) { + socket.disconnect(); + setTimeout(() => { + remote.configuration.token = result.data.token; + socket.connect(); + }, 100); + } + }); + } + }; + const remote = new HocuspocusProvider({ + websocketProvider: socket, + name: documentName, + document: ydoc, + token: collabQuery?.token, + onAuthenticationFailed: onAuthenticationFailedHandler, + onStatus: onStatusHandler, + onSynced: onSyncedHandler, + onStateless: onStatelessHandler, + }); + + local.on("synced", onLocalSyncedHandler); + providersRef.current = { ydoc, socket, local, remote }; + setProvidersReady(true); + } else { + setProvidersReady(true); + } + // Only destroy on final unmount + return () => { + providersRef.current?.socket.destroy(); + providersRef.current?.remote.destroy(); + providersRef.current?.local.destroy(); + providersRef.current = null; + // Reset shared sync state on page change/unmount. + setLocalSynced(false); + setRemoteSynced(false); + }; + }, [pageId]); + + // Only connect/disconnect on tab/idle, not destroy + useEffect(() => { + if (!providersReady || !providersRef.current) return; + const socket = providersRef.current.socket; + + if ( + isIdle && + documentState === "hidden" && + yjsConnectionStatus === WebSocketStatus.Connected + ) { + socket.disconnect(); + return; + } + if ( + documentState === "visible" && + yjsConnectionStatus === WebSocketStatus.Disconnected + ) { + resetIdle(); + socket.connect(); + } + }, [isIdle, documentState, providersReady, resetIdle]); + + // Attach here, to make sure the connection gets properly established + providersRef.current?.remote.attach(); + + return { + ydoc: providersRef.current?.ydoc ?? null, + remote: providersRef.current?.remote ?? null, + socket: providersRef.current?.socket ?? null, + providersReady, + isLocalSynced, + isRemoteSynced, + }; +} diff --git a/apps/client/src/features/editor/page-editor.tsx b/apps/client/src/features/editor/page-editor.tsx index cc7e7b5c..0b30d392 100644 --- a/apps/client/src/features/editor/page-editor.tsx +++ b/apps/client/src/features/editor/page-editor.tsx @@ -6,16 +6,7 @@ import React, { useRef, useState, } from "react"; -import { IndexeddbPersistence } from "y-indexeddb"; -import * as Y from "yjs"; -import { - HocuspocusProvider, - onStatusParameters, - WebSocketStatus, - HocuspocusProviderWebsocket, - onSyncedParameters, - onStatelessParameters, -} from "@hocuspocus/provider"; +import { WebSocketStatus } from "@hocuspocus/provider"; import { Editor, EditorContent, @@ -28,13 +19,15 @@ import { mainExtensions, } from "@/features/editor/extensions/extensions"; import { useAtom, useAtomValue } from "jotai"; -import useCollaborationUrl from "@/features/editor/hooks/use-collaboration-url"; import { currentUserAtom } from "@/features/user/atoms/current-user-atom"; import { currentPageEditModeAtom, + isLocalSyncedAtom, + isRemoteSyncedAtom, pageEditorAtom, yjsConnectionStatusAtom, } from "@/features/editor/atoms/editor-atoms"; +import { useEditorProviders } from "@/features/editor/contexts/editor-providers-context"; import { asideStateAtom } from "@/components/layouts/global/hooks/atoms/sidebar-atom"; import { activeCommentIdAtom, @@ -58,10 +51,8 @@ import { } from "@/features/editor/components/common/editor-paste-handler.tsx"; import ExcalidrawMenu from "./components/excalidraw/excalidraw-menu-lazy"; import DrawioMenu from "./components/drawio/drawio-menu"; -import { useCollabToken } from "@/features/auth/queries/auth-query.tsx"; import SearchAndReplaceDialog from "@/features/editor/components/search-and-replace/search-and-replace-dialog.tsx"; -import { useDebouncedCallback, useDocumentVisibility } from "@mantine/hooks"; -import { useIdle } from "@/hooks/use-idle.ts"; +import { useDebouncedCallback } from "@mantine/hooks"; import { queryClient } from "@/main.tsx"; import { IPage } from "@/features/page/types/page.types.ts"; import { useParams } from "react-router-dom"; @@ -72,9 +63,7 @@ import { GitmostInsertRecordingResult, gitmostInsertRecordingIntoEditor, } from "@/features/editor/gitmost/gitmost-recording.ts"; -import { FIVE_MINUTES } from "@/lib/constants.ts"; import { PageEditMode } from "@/features/user/types/user.types.ts"; -import { jwtDecode } from "jwt-decode"; import { searchSpotlight } from "@/features/search/constants.ts"; import { useEditorScroll } from "./hooks/use-editor-scroll"; import { EditorLinkMenu } from "@/features/editor/components/link/link-menu"; @@ -99,7 +88,6 @@ export default function PageEditor({ canComment, }: PageEditorProps) { const { t } = useTranslation(); - const collaborationURL = useCollaborationUrl(); const isComponentMounted = useRef(false); const editorRef = useRef(null); @@ -113,22 +101,10 @@ export default function PageEditor({ const [, setActiveCommentId] = useAtom(activeCommentIdAtom); const [showCommentPopup, setShowCommentPopup] = useAtom(showCommentPopupAtom); const [showReadOnlyCommentPopup] = useAtom(showReadOnlyCommentPopupAtom); - const [isLocalSynced, setIsLocalSynced] = useState(false); - const [isRemoteSynced, setIsRemoteSynced] = useState(false); const [yjsConnectionStatus, setYjsConnectionStatus] = useAtom( yjsConnectionStatusAtom, ); const menuContainerRef = useRef(null); - const { data: collabQuery, refetch: refetchCollabToken } = useCollabToken(); - // Always holds the latest collab token. The provider effect below runs once - // per pageId, so a handler created inside it would otherwise close over a - // stale `collabQuery`. Reading the ref gives the current token instead. - const collabTokenRef = useRef(undefined); - useEffect(() => { - collabTokenRef.current = collabQuery?.token; - }, [collabQuery?.token]); - const { isIdle, resetIdle } = useIdle(FIVE_MINUTES, { initialState: false }); - const documentState = useDocumentVisibility(); const { pageSlug } = useParams(); const slugId = extractPageSlugId(pageSlug); const currentPageEditMode = useAtomValue(currentPageEditModeAtom); @@ -137,141 +113,27 @@ export default function PageEditor({ [isComponentMounted], ); const { handleScrollTo } = useEditorScroll({ canScroll }); - // Providers only created once per pageId - const providersRef = useRef<{ - local: IndexeddbPersistence; - remote: HocuspocusProvider; - socket: HocuspocusProviderWebsocket; - } | null>(null); - const [providersReady, setProvidersReady] = useState(false); - useEffect(() => { - if (!providersRef.current) { - const documentName = `page.${pageId}`; - const ydoc = new Y.Doc(); - const local = new IndexeddbPersistence(documentName, ydoc); - const socket = new HocuspocusProviderWebsocket({ - url: collaborationURL, - }); - const onLocalSyncedHandler = () => { - setIsLocalSynced(true); - }; - const onStatusHandler = (event: onStatusParameters) => { - setYjsConnectionStatus(event.status); - }; - const onSyncedHandler = (event: onSyncedParameters) => { - setIsRemoteSynced(event.state); - }; - const onStatelessHandler = ({ payload }: onStatelessParameters) => { - try { - const message = JSON.parse(payload); - if (message?.type !== "page.updated" || !message.updatedAt) return; - const pageData = queryClient.getQueryData(["pages", slugId]); - if (pageData) { - queryClient.setQueryData(["pages", slugId], { - ...pageData, - updatedAt: message.updatedAt, - ...(message.lastUpdatedBy && { - lastUpdatedBy: message.lastUpdatedBy, - }), - }); - } - } catch { - // ignore unrelated stateless messages - } - }; - const onAuthenticationFailedHandler = () => { - // Read the latest token via the ref (the closure-captured `collabQuery` - // may be stale). Guard the decode: a missing or unparseable token must - // not throw "Invalid token specified" and should trigger a refresh so - // the editor reconnects even when the initial token fetch failed. - const token = collabTokenRef.current; - let needsRefresh = true; // no/unparseable token -> fetch a fresh one and reconnect - if (token) { - try { - // A token that decodes but lacks a numeric `exp` must be treated as - // expired (`Date.now()/1000 >= undefined` is `false`, which would - // otherwise skip the reconnect), so refresh on any missing/non-number exp. - const exp = jwtDecode<{ exp?: number }>(token).exp; - needsRefresh = typeof exp !== "number" || Date.now() / 1000 >= exp; - } catch { - needsRefresh = true; - } - } - if (!needsRefresh) return; - refetchCollabToken().then((result) => { - if (result.data?.token) { - socket.disconnect(); - setTimeout(() => { - remote.configuration.token = result.data.token; - socket.connect(); - }, 100); - } - }); - }; - const remote = new HocuspocusProvider({ - websocketProvider: socket, - name: documentName, - document: ydoc, - token: collabQuery?.token, - onAuthenticationFailed: onAuthenticationFailedHandler, - onStatus: onStatusHandler, - onSynced: onSyncedHandler, - onStateless: onStatelessHandler, - }); - - local.on("synced", onLocalSyncedHandler); - providersRef.current = { socket, local, remote }; - setProvidersReady(true); - } else { - setProvidersReady(true); - } - // Only destroy on final unmount - return () => { - providersRef.current?.socket.destroy(); - providersRef.current?.remote.destroy(); - providersRef.current?.local.destroy(); - providersRef.current = null; - }; - }, [pageId]); - - // Only connect/disconnect on tab/idle, not destroy - useEffect(() => { - if (!providersReady || !providersRef.current) return; - const socket = providersRef.current.socket; - - if ( - isIdle && - documentState === "hidden" && - yjsConnectionStatus === WebSocketStatus.Connected - ) { - socket.disconnect(); - return; - } - if ( - documentState === "visible" && - yjsConnectionStatus === WebSocketStatus.Disconnected - ) { - resetIdle(); - socket.connect(); - } - }, [isIdle, documentState, providersReady, resetIdle]); - - // Attach here, to make sure the connection gets properly established - providersRef.current?.remote.attach(); + // Shared providers + Y.Doc lifted into full-editor via context. The provider + // lifecycle (creation, idle/visibility connect, attach, destroy, token + // refresh) lives in usePageCollabProviders. Null-safe when rendered without + // the context (defensive) — in practice full-editor always provides it. + const editorProviders = useEditorProviders(); + const remote = editorProviders?.remote ?? null; + const providersReady = editorProviders?.providersReady ?? false; + const isLocalSynced = useAtomValue(isLocalSyncedAtom); + const isRemoteSynced = useAtomValue(isRemoteSyncedAtom); const extensions = useMemo(() => { - if (!providersReady || !providersRef.current || !currentUser?.user) { + if (!providersReady || !remote || !currentUser?.user) { return mainExtensions; } - const remoteProvider = providersRef.current.remote; - return [ ...mainExtensions, - ...collabExtensions(remoteProvider, currentUser?.user), + ...collabExtensions(remote, currentUser?.user), ]; - }, [providersReady, currentUser?.user]); + }, [providersReady, remote, currentUser?.user]); const editor = useEditor( { @@ -513,7 +375,7 @@ export default function PageEditor({ {editor && !editorIsEditable && (editable || canComment) && - providersRef.current && } + remote && } {showCommentPopup && ( )} diff --git a/apps/client/src/features/editor/title-editor.tsx b/apps/client/src/features/editor/title-editor.tsx index 0b1fb924..11a457ea 100644 --- a/apps/client/src/features/editor/title-editor.tsx +++ b/apps/client/src/features/editor/title-editor.tsx @@ -1,5 +1,5 @@ import "@/features/editor/styles/index.css"; -import React, { useCallback, useEffect, useState } from "react"; +import { useEffect } from "react"; import { EditorContent, useEditor } from "@tiptap/react"; import { Document } from "@tiptap/extension-document"; import { Heading } from "@tiptap/extension-heading"; @@ -11,14 +11,14 @@ import { pageEditorAtom, titleEditorAtom, } from "@/features/editor/atoms/editor-atoms"; -import { - updatePageData, - useUpdateTitlePageMutation, -} from "@/features/page/queries/page-query"; +import { updatePageData } from "@/features/page/queries/page-query"; import { useDebouncedCallback, getHotkeyHandler } from "@mantine/hooks"; import { useAtom } from "jotai"; import { useQueryEmit } from "@/features/websocket/use-query-emit.ts"; -import { History } from "@tiptap/extension-history"; +import { + Collaboration, + isChangeOrigin, +} from "@tiptap/extension-collaboration"; import { buildPageUrl } from "@/features/page/page.utils.ts"; import { useNavigate } from "react-router-dom"; import { useTranslation } from "react-i18next"; @@ -28,6 +28,9 @@ import localEmitter from "@/lib/local-emitter.ts"; import { PageEditMode } from "@/features/user/types/user.types.ts"; import { searchSpotlight } from "@/features/search/constants.ts"; import { platformModifierKey } from "@/lib"; +import { useEditorProviders } from "@/features/editor/contexts/editor-providers-context"; +import { queryClient } from "@/main.tsx"; +import { IPage } from "@/features/page/types/page.types.ts"; export interface TitleEditorProps { pageId: string; @@ -45,65 +48,83 @@ export function TitleEditor({ editable, }: TitleEditorProps) { const { t } = useTranslation(); - const { mutateAsync: updateTitlePageMutationAsync } = - useUpdateTitlePageMutation(); const pageEditor = useAtomValue(pageEditorAtom); const [, setTitleEditor] = useAtom(titleEditorAtom); const emit = useQueryEmit(); const navigate = useNavigate(); - const [activePageId, setActivePageId] = useState(pageId); const currentPageEditMode = useAtomValue(currentPageEditModeAtom); - const titleEditor = useEditor({ - extensions: [ - Document.extend({ - content: "heading", - }), - Heading.configure({ - levels: [1], - }), - Text, - Placeholder.configure({ - placeholder: t("Untitled"), - showOnlyWhenEditable: false, - }), - History.configure({ - depth: 20, - }), - EmojiCommand, - ], - onCreate({ editor }) { - if (editor) { - // @ts-ignore - setTitleEditor(editor); - setActivePageId(pageId); - } - }, - onUpdate({ editor }) { - debounceUpdate(); - }, - editable: editable, - content: title, - immediatelyRender: true, - shouldRerenderOnTransaction: false, - editorProps: { - attributes: { - "aria-label": t("Page title"), + // Shared Y.Doc (title lives in its own 'title' fragment of the same doc as + // the body). Yjs is the source of truth for the title content. + const editorProviders = useEditorProviders(); + const ydoc = editorProviders?.ydoc ?? null; + const providersReady = editorProviders?.providersReady ?? false; + + // Until the shared doc is ready, the collaborative editor binds nothing and + // would render an empty heading until the Yjs 'title' fragment hydrates. Show + // a non-editable static

with the `title` prop in the meantime. The prop + // is NEVER fed into the collaborative editor (Yjs stays the single source of + // truth — seeding it would duplicate the title). + const titleReady = providersReady && !!ydoc; + + const titleEditor = useEditor( + { + extensions: [ + Document.extend({ + content: "heading", + }), + Heading.configure({ + levels: [1], + }), + Text, + Placeholder.configure({ + placeholder: t("Untitled"), + showOnlyWhenEditable: false, + }), + // Bind the title to the dedicated 'title' fragment of the shared doc. + // Collaboration also manages undo/redo, so the History extension is + // intentionally omitted (it would conflict with Yjs). When the doc is + // not ready yet the editor renders empty until the doc arrives. + ...(ydoc + ? [Collaboration.configure({ document: ydoc, field: "title" })] + : []), + EmojiCommand, + ], + onCreate({ editor }) { + if (editor) { + // @ts-ignore + setTitleEditor(editor); + } }, - handleDOMEvents: { - keydown: (_view, event) => { - if (platformModifierKey(event) && event.code === "KeyS") { - event.preventDefault(); - return true; - } - if (platformModifierKey(event) && event.code === "KeyK") { - searchSpotlight.open(); - return true; - } + onUpdate({ editor, transaction }) { + // Drive URL + tree propagation only on genuine local edits; skip + // remote/collab-origin Yjs updates to avoid feedback loops. + if (transaction && isChangeOrigin(transaction)) return; + debouncedPropagateTitle(editor.getText()); + }, + editable: editable, + immediatelyRender: true, + shouldRerenderOnTransaction: false, + editorProps: { + attributes: { + "aria-label": t("Page title"), + }, + handleDOMEvents: { + keydown: (_view, event) => { + if (platformModifierKey(event) && event.code === "KeyS") { + event.preventDefault(); + return true; + } + if (platformModifierKey(event) && event.code === "KeyK") { + searchSpotlight.open(); + return true; + } + }, }, }, }, - }); + [pageId, ydoc], + ); useEffect(() => { const anchorId = window.location.hash @@ -113,59 +134,42 @@ export function TitleEditor({ navigate(pageSlug, { replace: true }); }, [title]); - const saveTitle = useCallback(() => { - if (!titleEditor || activePageId !== pageId) return; - - if ( - titleEditor.getText() === title || - (titleEditor.getText() === "" && title === null) - ) { - return; - } - - updateTitlePageMutationAsync({ - pageId: pageId, - title: titleEditor.getText(), - }).then((page) => { - const event: UpdateEvent = { - operation: "updateOne", - spaceId: page.spaceId, - entity: ["pages"], - id: page.id, - payload: { - title: page.title, - slugId: page.slugId, - parentPageId: page.parentPageId, - icon: page.icon, - }, - }; - - if (page.title !== titleEditor.getText()) return; - - updatePageData(page); - - localEmitter.emit("message", event); - emit(event); + // On a local title change: update the URL slug and propagate the change to + // the live tree/breadcrumbs for online users. No REST round-trip — the title + // itself is persisted through Yjs. Offline this simply no-ops the socket + // emit and the title syncs on reconnect. + const debouncedPropagateTitle = useDebouncedCallback((titleText: string) => { + const anchorId = window.location.hash + ? window.location.hash.substring(1) + : undefined; + navigate(buildPageUrl(spaceSlug, slugId, titleText, anchorId), { + replace: true, }); - }, [pageId, title, titleEditor]); - const debounceUpdate = useDebouncedCallback(saveTitle, 500); + const page = + queryClient.getQueryData(["pages", slugId]) ?? + queryClient.getQueryData(["pages", pageId]); + if (!page) return; - useEffect(() => { - // Do not overwrite the title while the user is actively editing it. The - // server rebroadcasts PAGE_UPDATED to the author too, and that echo can - // carry a title that lags behind what the user has just typed; resetting - // content from it here would drop in-progress characters and jump the - // cursor. Apply external title changes only when the field is not focused. - if ( - titleEditor && - !titleEditor.isDestroyed && - !titleEditor.isFocused && - title !== titleEditor.getText() - ) { - titleEditor.commands.setContent(title); - } - }, [pageId, title, titleEditor]); + const updatedPage: IPage = { ...page, title: titleText }; + + const event: UpdateEvent = { + operation: "updateOne", + spaceId: page.spaceId, + entity: ["pages"], + id: page.id, + payload: { + title: titleText, + slugId: page.slugId, + parentPageId: page.parentPageId, + icon: page.icon, + }, + }; + + updatePageData(updatedPage); + localEmitter.emit("message", event); + emit(event); + }, 500); useEffect(() => { setTimeout(() => { @@ -175,13 +179,6 @@ export function TitleEditor({ }, 300); }, [titleEditor]); - useEffect(() => { - return () => { - // force-save title on navigation - saveTitle(); - }; - }, [pageId]); - useEffect(() => { if (!titleEditor) return; titleEditor.setEditable(editable && currentPageEditMode === PageEditMode.Edit); @@ -248,16 +245,22 @@ export function TitleEditor({ return (
- { - // First handle the search hotkey - getHotkeyHandler([["mod+F", openSearchDialog]])(event); + {titleReady ? ( + { + // First handle the search hotkey + getHotkeyHandler([["mod+F", openSearchDialog]])(event); - // Then handle other key events - handleTitleKeyDown(event); - }} - /> + // Then handle other key events + handleTitleKeyDown(event); + }} + /> + ) : ( + // Static, non-editable fallback so the title is visible before Yjs + // hydrates the 'title' fragment. Not wired into the collaborative editor. +

{title}

+ )}
); } diff --git a/apps/client/src/features/offline/make-offline.ts b/apps/client/src/features/offline/make-offline.ts new file mode 100644 index 00000000..5eabaaf7 --- /dev/null +++ b/apps/client/src/features/offline/make-offline.ts @@ -0,0 +1,231 @@ +import * as Y from "yjs"; +import { IndexeddbPersistence } from "y-indexeddb"; +import { HocuspocusProvider } from "@hocuspocus/provider"; + +import { queryClient } from "@/main.tsx"; +import { + getPageById, + getPageBreadcrumbs, + getSidebarPages, + getAllSidebarPages, +} from "@/features/page/services/page-service"; +import { getSpaceById } from "@/features/space/services/space-service.ts"; +import { getPageComments } from "@/features/comment/services/comment-service"; +import { IPage } from "@/features/page/types/page.types"; +import { IPagination } from "@/lib/types.ts"; + +/** + * Fully paginate an infinite query and write the @tanstack InfiniteData cache + * shape ({ pages, pageParams }) that the matching useInfiniteQuery hook reads. + * + * The default prefetchInfiniteQuery only warms the FIRST page, which leaves + * hooks that treat hasNextPage as still-loading (e.g. the comments panel) + * spinning forever offline, and silently truncates large lists. This walks the + * cursor chain until it runs out (or hits maxPages) so the whole list is cached. + * + * Best-effort: any failure is swallowed so a partial/failed warm never throws. + */ +async function warmInfiniteAll( + queryKey: unknown[], + fetchPage: (cursor: string | undefined) => Promise>, + maxPages = 50, +): Promise { + try { + const pages: IPagination[] = []; + const pageParams: (string | undefined)[] = []; + let cursor: string | undefined = undefined; + + for (let i = 0; i < maxPages; i++) { + const res = await fetchPage(cursor); + pages.push(res); + pageParams.push(cursor); + cursor = res?.meta?.nextCursor ?? undefined; + if (!cursor) break; + } + + queryClient.setQueryData(queryKey, { pages, pageParams }); + } catch { + // best-effort + } +} + +export interface MakePageAvailableOfflineParams { + pageId: string; + slugId?: string; + spaceId?: string; + parentPageId?: string; +} + +/** + * Best-effort prefetch of a page's read queries so they get persisted to + * IndexedDB and become readable offline. + * + * Each prefetch is isolated in try/catch — this function NEVER throws to its + * caller. Only meaningful while online (the underlying requests must succeed). + */ +export async function makePageAvailableOffline({ + pageId, + spaceId, +}: MakePageAvailableOfflineParams): Promise { + // Fetch the page document ONCE and write it under BOTH cache keys, exactly + // like usePageQuery's onData effect. Every page consumer reads ["pages", + // ] (usePageQuery keys on the slugId for routed reads), so warming + // only ["pages", ] would leave the offline page blank. + let page: IPage | undefined; + try { + page = await getPageById({ pageId }); + queryClient.setQueryData(["pages", page.slugId], page); + queryClient.setQueryData(["pages", page.id], page); + } catch { + // best-effort + } + + // Warm the space — page.tsx renders nothing until the space query resolves + // (useGetSpaceBySlugQuery → ["space", ]). Awaited (not the + // fire-and-forget prefetchSpace) so the space is actually persisted before + // the caller fires its success toast. Matches the hook's key/fn exactly. + try { + const spaceSlug = page?.space?.slug; + if (spaceSlug) { + await queryClient.prefetchQuery({ + queryKey: ["space", spaceSlug], + queryFn: () => getSpaceById(spaceSlug), + }); + } + } catch { + // best-effort + } + + // Warm the sidebar tree root so the WHOLE root level renders offline (matches + // useGetRootSidebarPagesQuery's ["root-sidebar-pages", spaceId] infinite + // key/fn). Fully paginated so large root levels are not truncated at 100. + if (spaceId) { + await warmInfiniteAll(["root-sidebar-pages", spaceId], (cursor) => + getSidebarPages({ spaceId, cursor, limit: 100 }), + ); + } + + // Warm the children of the page and of every ancestor so the path to this + // page is expandable offline. We MIRROR fetchAllAncestorChildren exactly — + // same regular ["sidebar-pages", { pageId, spaceId }] key, same + // getAllSidebarPages fn (which aggregates ALL children pages, so nothing is + // truncated at 100), same 30min staleTime — otherwise the warmed cache would + // never be read by the offline tree. + const warmSidebarChildren = async (id: string) => { + try { + // Keep EXACTLY { pageId, spaceId } so the key hashes identically to + // fetchAllAncestorChildren's (no parentPageId, no extra fields). + const params = { pageId: id, spaceId }; + await queryClient.prefetchQuery({ + queryKey: ["sidebar-pages", params], + queryFn: () => getAllSidebarPages(params), + staleTime: 30 * 60 * 1000, + }); + } catch { + // best-effort per node + } + }; + + // The page's own children. + await warmSidebarChildren(pageId); + + // Each ancestor's children. Use the breadcrumbs endpoint ONLY to discover the + // ancestor ids — we intentionally do NOT cache the breadcrumbs themselves + // (the UI derives the path from the tree). + try { + const ancestors = (await getPageBreadcrumbs(pageId)) as + | Array<{ id?: string }> + | undefined; + for (const ancestor of ancestors ?? []) { + const ancestorId = ancestor?.id; + if (!ancestorId || ancestorId === pageId) continue; + await warmSidebarChildren(ancestorId); + } + } catch { + // best-effort + } + + // Comments (matches useCommentsQuery's ["comments", pageId] infinite cache). + // useCommentsQuery reports isLoading while hasNextPage is true, so warming + // only the first page leaves the offline comments panel spinning forever on + // pages with >100 comments. Fully paginate so the last cached page has no + // nextCursor and the panel settles offline. + await warmInfiniteAll(["comments", pageId], (cursor) => + getPageComments({ pageId, cursor, limit: 100 }), + ); +} + +/** + * Best-effort warm-up of the page's Yjs document into IndexedDB so the editor + * can open offline. + * + * Opens a local IndexeddbPersistence plus a transient HocuspocusProvider to + * pull the server state into IndexedDB, then tears both down once synced (or + * after a timeout). Entirely wrapped in try/catch — NEVER throws. + * + * Only meaningful when online at warm time; offline it is a no-op that resolves. + */ +export async function warmPageYdoc( + pageId: string, + collabUrl: string, + token?: string, +): Promise { + let ydoc: Y.Doc | null = null; + let local: IndexeddbPersistence | null = null; + let remote: HocuspocusProvider | null = null; + + try { + const documentName = `page.${pageId}`; + ydoc = new Y.Doc(); + local = new IndexeddbPersistence(documentName, ydoc); + remote = new HocuspocusProvider({ + url: collabUrl, + name: documentName, + document: ydoc, + token, + }); + + const provider = remote; + + await new Promise((resolve) => { + let settled = false; + let timeoutId: ReturnType | undefined; + const finish = () => { + if (settled) return; + settled = true; + // Clear the pending timeout and detach the listener so neither leaks + // after we resolve. + if (timeoutId !== undefined) clearTimeout(timeoutId); + try { + provider.off("synced", finish); + } catch { + // best-effort + } + resolve(); + }; + + // Resolve once the server state has synced into the local doc... + provider.on("synced", finish); + // ...or give up after a short timeout so we never hang. + timeoutId = setTimeout(finish, 8000); + }); + } catch { + // best-effort + } finally { + try { + remote?.destroy(); + } catch { + // best-effort + } + try { + local?.destroy(); + } catch { + // best-effort + } + try { + ydoc?.destroy(); + } catch { + // best-effort + } + } +} diff --git a/apps/client/src/features/offline/query-persister.ts b/apps/client/src/features/offline/query-persister.ts new file mode 100644 index 00000000..f8e84a3c --- /dev/null +++ b/apps/client/src/features/offline/query-persister.ts @@ -0,0 +1,45 @@ +import { get, set, del } from "idb-keyval"; +import { createAsyncStoragePersister } from "@tanstack/query-async-storage-persister"; + +// Structural subset of a TanStack Query we read when deciding what to persist. +// We avoid importing the branded `Query` class because the persist-client and +// react-query may resolve to different `@tanstack/query-core` copies, whose +// `Query` types are nominally incompatible (private brand). This structural +// shape stays assignable to whichever copy the persister expects. +type DehydratableQuery = { + state: { status: string }; + queryKey: readonly unknown[]; +}; + +// IndexedDB-backed storage adapter for TanStack Query's async persister. +const idbStorage = { + getItem: (key: string) => get(key).then((v) => v ?? null), + setItem: (key: string, value: string) => set(key, value), + removeItem: (key: string) => del(key), +}; + +export const queryPersister = createAsyncStoragePersister({ + storage: idbStorage, + key: "gitmost-rq-cache", + throttleTime: 1000, +}); + +// Only navigation/read query roots are persisted for offline reading. +// Volatile/auth queries (collab tokens, trash lists) are intentionally excluded. +export const OFFLINE_PERSIST_ROOTS = new Set([ + "pages", + "sidebar-pages", + "root-sidebar-pages", + "breadcrumbs", + "comments", + "space", + "spaces", + "recent-changes", +]); + +export function shouldDehydrateOfflineQuery(query: DehydratableQuery): boolean { + return ( + query.state.status === "success" && + OFFLINE_PERSIST_ROOTS.has(String(query.queryKey?.[0])) + ); +} diff --git a/apps/client/src/features/page/components/header/page-header-menu.tsx b/apps/client/src/features/page/components/header/page-header-menu.tsx index fae3d898..7e743093 100644 --- a/apps/client/src/features/page/components/header/page-header-menu.tsx +++ b/apps/client/src/features/page/components/header/page-header-menu.tsx @@ -12,6 +12,8 @@ import { IconList, IconMarkdown, IconPrinter, + IconCloud, + IconCloudCheck, IconStar, IconStarFilled, IconTrash, @@ -39,6 +41,8 @@ import { Trans, useTranslation } from "react-i18next"; import ExportModal from "@/components/common/export-modal"; import { htmlToMarkdown } from "@docmost/editor-ext"; import { + isLocalSyncedAtom, + isRemoteSyncedAtom, pageEditorAtom, yjsConnectionStatusAtom, } from "@/features/editor/atoms/editor-atoms.ts"; @@ -411,14 +415,16 @@ function PageActionMenu({ readOnly }: PageActionMenuProps) { function ConnectionWarning() { const { t } = useTranslation(); const yjsConnectionStatus = useAtomValue(yjsConnectionStatusAtom); + const isLocalSynced = useAtomValue(isLocalSyncedAtom); + const isRemoteSynced = useAtomValue(isRemoteSyncedAtom); const [showWarning, setShowWarning] = useState(false); const timeoutRef = useRef | null>(null); - useEffect(() => { - const isDisconnected = ["disconnected", "connecting"].includes( - yjsConnectionStatus, - ); + const isDisconnected = ["disconnected", "connecting"].includes( + yjsConnectionStatus, + ); + useEffect(() => { if (isDisconnected) { if (!timeoutRef.current) { timeoutRef.current = setTimeout(() => setShowWarning(true), 5000); @@ -430,7 +436,7 @@ function ConnectionWarning() { } setShowWarning(false); } - }, [yjsConnectionStatus]); + }, [isDisconnected]); // Cleanup only on unmount useEffect(() => { @@ -441,22 +447,59 @@ function ConnectionWarning() { }; }, []); - if (!showWarning) return null; + // State (1): offline/disconnected — changes are kept locally. Preserve the + // existing >5s debounce before surfacing this state. + if (isDisconnected) { + if (!showWarning) return null; + const offlineLabel = t( + "Offline — changes are saved locally and will sync when you reconnect", + ); + return ( + + + + + + ); + } + + // State (2): connected but the remote replica is not fully caught up yet. + if (!isRemoteSynced || !isLocalSynced) { + const syncingLabel = t("Syncing changes…"); + return ( + + + + + + ); + } + + // State (3): fully synced — subtle confirmation indicator. + const syncedLabel = t("All changes synced"); return ( - + - + ); diff --git a/apps/client/src/features/page/queries/page-query.ts b/apps/client/src/features/page/queries/page-query.ts index ee44b775..e15d3f01 100644 --- a/apps/client/src/features/page/queries/page-query.ts +++ b/apps/client/src/features/page/queries/page-query.ts @@ -103,12 +103,6 @@ export function updatePageData(data: IPage) { ); } -export function useUpdateTitlePageMutation() { - return useMutation>({ - mutationFn: (data) => updatePage(data), - }); -} - export function useUpdatePageMutation() { return useMutation>({ mutationFn: (data) => updatePage(data), diff --git a/apps/client/src/features/page/tree/components/space-tree-node-menu.tsx b/apps/client/src/features/page/tree/components/space-tree-node-menu.tsx index cd868746..d051a963 100644 --- a/apps/client/src/features/page/tree/components/space-tree-node-menu.tsx +++ b/apps/client/src/features/page/tree/components/space-tree-node-menu.tsx @@ -7,6 +7,7 @@ import { notifications } from "@mantine/notifications"; import { IconArrowRight, IconClockHour4, + IconCloudDownload, IconCopy, IconDotsVertical, IconFileExport, @@ -35,6 +36,12 @@ 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 { useTreeMutation } from "@/features/page/tree/hooks/use-tree-mutation.ts"; @@ -71,6 +78,29 @@ export function NodeMenu({ node, canEdit }: NodeMenuProps) { 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. + 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; @@ -231,6 +261,17 @@ export function NodeMenu({ node, canEdit }: NodeMenuProps) { {t("Export")} + } + onClick={(e) => { + e.preventDefault(); + e.stopPropagation(); + handleMakeAvailableOffline(); + }} + > + {t("Make available offline")} + + {canEdit && ( <> - + + {/* Skip SW registration inside the Capacitor native WebView — the + native shell serves assets itself; a browser SW would conflict. */} + {!isCapacitorNativePlatform() && } - + , diff --git a/apps/client/src/pwa/is-capacitor.ts b/apps/client/src/pwa/is-capacitor.ts new file mode 100644 index 00000000..e4ee1db4 --- /dev/null +++ b/apps/client/src/pwa/is-capacitor.ts @@ -0,0 +1,23 @@ +/** + * Detects whether the client is running inside a Capacitor native WebView + * (native iOS/Android shell from the feature/mobile-app-bootstrap branch). + * + * This is a pure runtime check against the global `Capacitor` object that the + * native bridge injects — no `@capacitor/*` dependency is added. On the plain + * browser / installed-PWA path `window.Capacitor` is undefined, so this returns + * false and the Workbox service worker registers normally. + * + * Inside the native WebView the SW must NOT register: it would layer a redundant + * (and conflicting) cache over Capacitor's own asset serving and interfere with + * the native auth/CORS flow. + */ +export function isCapacitorNativePlatform(): boolean { + try { + const cap = (globalThis as any)?.Capacitor; + return !!(cap && typeof cap.isNativePlatform === "function" + ? cap.isNativePlatform() + : cap?.isNativePlatform); + } catch { + return false; + } +} diff --git a/apps/client/src/pwa/pwa-update-prompt.tsx b/apps/client/src/pwa/pwa-update-prompt.tsx new file mode 100644 index 00000000..d4f131cc --- /dev/null +++ b/apps/client/src/pwa/pwa-update-prompt.tsx @@ -0,0 +1,59 @@ +import { useEffect } from "react"; +import { Button } from "@mantine/core"; +import { notifications } from "@mantine/notifications"; +import { useTranslation } from "react-i18next"; +import { useRegisterSW } from "virtual:pwa-register/react"; + +// Stable notification id so we can show/hide a single update prompt. +const UPDATE_NOTIFICATION_ID = "pwa-update-available"; + +/** + * Listens for a waiting service worker and surfaces a Mantine notification + * prompting the user to reload into the new version. + * + * Must be mounted inside the Mantine provider subtree (Notifications must be + * available). Renders nothing itself. + */ +export function PwaUpdatePrompt() { + const { t } = useTranslation(); + + const { + needRefresh: [needRefresh], + updateServiceWorker, + } = useRegisterSW({ + onRegisterError(error) { + // Best-effort: a failed registration must not break the app. + console.error("Service worker registration error:", error); + }, + }); + + useEffect(() => { + if (!needRefresh) return; + + notifications.show({ + id: UPDATE_NOTIFICATION_ID, + title: t("Update available"), + message: ( + + ), + autoClose: false, + withCloseButton: true, + }); + + // Hide the notification when the prompt is no longer needed / on cleanup. + return () => { + notifications.hide(UPDATE_NOTIFICATION_ID); + }; + }, [needRefresh, t, updateServiceWorker]); + + return null; +} + +export default PwaUpdatePrompt; diff --git a/apps/client/src/vite-env.d.ts b/apps/client/src/vite-env.d.ts index e16c0ce0..3c792312 100644 --- a/apps/client/src/vite-env.d.ts +++ b/apps/client/src/vite-env.d.ts @@ -1,2 +1,4 @@ /// +/// +/// declare const APP_VERSION: string diff --git a/apps/client/vite.config.ts b/apps/client/vite.config.ts index 8b9994ce..4e28dae2 100644 --- a/apps/client/vite.config.ts +++ b/apps/client/vite.config.ts @@ -1,5 +1,6 @@ import { defineConfig, loadEnv } from "vite"; import react from "@vitejs/plugin-react"; +import { VitePWA } from "vite-plugin-pwa"; import * as path from "path"; import { execSync } from "node:child_process"; @@ -53,7 +54,41 @@ export default defineConfig(({ mode }) => { }, APP_VERSION: JSON.stringify(resolveAppVersion(envPath)), }, - plugins: [react()], + plugins: [ + react(), + VitePWA({ + registerType: "prompt", + injectRegister: null, + strategies: "generateSW", + manifest: false, + workbox: { + globPatterns: ["**/*.{js,css,html,svg,png,ico,woff2,json}"], + navigateFallback: "index.html", + navigateFallbackDenylist: [/^\/api\//, /^\/collab\//, /^\/socket\.io\//], + cleanupOutdatedCaches: true, + clientsClaim: true, + runtimeCaching: [ + { urlPattern: ({ url }) => url.pathname.startsWith("/collab"), handler: "NetworkOnly" }, + { urlPattern: ({ url }) => url.pathname.startsWith("/socket.io"), handler: "NetworkOnly" }, + // M2 read-path: GET navigation API responses fall back to cache when offline. + // Only GET is cached; mutations always hit the network (Workbox caching handlers + // only match GET by default, but scope explicitly for clarity/safety). + { + urlPattern: ({ url, request }) => url.pathname.startsWith("/api") && request.method === "GET", + handler: "NetworkFirst", + options: { + cacheName: "api-get-cache", + networkTimeoutSeconds: 5, + expiration: { maxEntries: 200, maxAgeSeconds: 60 * 60 * 24 }, + }, + }, + // Any non-GET /api stays network-only (never served stale). + { urlPattern: ({ url }) => url.pathname.startsWith("/api"), handler: "NetworkOnly" }, + ], + }, + devOptions: { enabled: false }, + }), + ], build: { rolldownOptions: { output: { diff --git a/apps/server/src/collaboration/collaboration.util.ts b/apps/server/src/collaboration/collaboration.util.ts index a894aaea..8bb6caeb 100644 --- a/apps/server/src/collaboration/collaboration.util.ts +++ b/apps/server/src/collaboration/collaboration.util.ts @@ -59,6 +59,7 @@ import { generateHTML, generateJSON } from '../common/helpers/prosemirror/html'; import { Node, Schema } from '@tiptap/pm/model'; import * as Y from 'yjs'; import { Logger } from '@nestjs/common'; +import { TiptapTransformer } from '@hocuspocus/transformer'; export const tiptapExtensions = [ StarterKit.configure({ @@ -143,6 +144,34 @@ export function jsonToText(tiptapJson: JSONContent) { return generateText(tiptapJson, tiptapExtensions); } +/** + * Build a standalone Y.Doc that holds ONLY the page title, in a dedicated Yjs + * fragment named exactly 'title' (the collaborative title-editor contract with + * the client). The ProseMirror shape is a doc with a single level-1 heading + * whose text is the title (empty title => heading with no text child). + * + * The encoded state of the returned doc can be merged into a body doc via + * `Y.applyUpdate(doc, Y.encodeStateAsUpdate(titleSeed))` to seed the title + * fragment for legacy pages. Seeding MUST be guarded by an emptiness check on + * the existing 'title' fragment to avoid the Yjs duplication trap. + */ +export function buildTitleSeedYdoc(title: string): Y.Doc { + return TiptapTransformer.toYdoc( + { + type: 'doc', + content: [ + { + type: 'heading', + attrs: { level: 1 }, + content: title ? [{ type: 'text', text: title }] : [], + }, + ], + }, + 'title', + tiptapExtensions, + ); +} + export function jsonToNode(tiptapJson: JSONContent) { const schema = getSchema(tiptapExtensions); try { diff --git a/apps/server/src/collaboration/extensions/persistence.extension.ts b/apps/server/src/collaboration/extensions/persistence.extension.ts index f802f229..06458654 100644 --- a/apps/server/src/collaboration/extensions/persistence.extension.ts +++ b/apps/server/src/collaboration/extensions/persistence.extension.ts @@ -9,6 +9,7 @@ import * as Y from 'yjs'; import { Injectable, Logger } from '@nestjs/common'; import { TiptapTransformer } from '@hocuspocus/transformer'; import { + buildTitleSeedYdoc, getPageId, isEmptyParagraphDoc, jsonToText, @@ -133,10 +134,30 @@ export class PersistenceExtension implements Extension { const dbState = new Uint8Array(page.ydoc); Y.applyUpdate(doc, dbState); + + // Legacy pages persisted their title only in the `page.title` column; the + // ydoc has no 'title' fragment. Seed it once so the client's + // collaborative title editor can show/edit the title. This runs inside the + // ydoc branch (NOT gated by the top-level 'default' body guard) because a + // body that loaded from page.ydoc can still lack a title fragment. The + // seed persists back to the DB so it is one-shot per page. + const seeded = this.seedTitleFragment(doc, page.title); + if (seeded) { + await this.persistYdoc(doc, pageId); + } + return doc; } - // if no ydoc state in db convert json in page.content to Ydoc. + // NOTE (offline-sync M1, Goal 2): this per-load self-heal converts + + // title-seeds + persists every legacy page (content set, ydoc null) on its + // first open, which neutralizes the duplication trap incrementally. A + // proactive one-shot BATCH migration over all such pages could be added + // later, but it requires the tiptap schema + TiptapTransformer (Node/Yjs), + // which a Kysely SQL migration cannot run; no runnable-task/CLI convention + // exists in this repo yet, so we deliberately avoid a fragile migration. + // + // If no ydoc state in db, convert the JSON in page.content to a Y.Doc. if (page.content) { this.logger.debug(`converting json to ydoc: ${pageId}`); @@ -146,7 +167,20 @@ export class PersistenceExtension implements Extension { tiptapExtensions, ); - Y.encodeStateAsUpdate(ydoc); + // Seed the title fragment for legacy pages here too, so the freshly built + // ydoc carries the title from the page.title column. + this.seedTitleFragment(ydoc, page.title); + + // DUPLICATION TRAP (classic Yjs): this rebuild produces a ydoc with FRESH + // Yjs client-ids each time it runs. If we returned it WITHOUT persisting, + // a later load would rebuild again with different client-ids, and a + // long-offline client holding a ydoc derived from an EARLIER rebuild could + // merge its update and DUPLICATE all the content (the two states share no + // common ancestor). Persist the built ydoc to page.ydoc immediately so + // every subsequent load takes the page.ydoc branch above and this rebuild + // never runs again for this page (one-shot per page). + await this.persistYdoc(ydoc, pageId); + return ydoc; } @@ -154,6 +188,56 @@ export class PersistenceExtension implements Extension { return new Y.Doc(); } + /** + * Seed the 'title' fragment of `doc` from the `page.title` column for legacy + * pages whose persisted ydoc has no title fragment yet. + * + * Guarded STRICTLY by emptiness: we only seed when the existing 'title' + * fragment is empty AND there is a non-empty column title. Seeding a non-empty + * fragment would re-introduce the Yjs duplication trap, so we never do it. + * Returns true when a seed was applied (so the caller can persist). + * Defensive: a malformed title must not break document loading. + */ + private seedTitleFragment(doc: Y.Doc, title: string | null): boolean { + const trimmed = (title ?? '').trim(); + if (!trimmed) return false; + + try { + const titleFrag = doc.get('title', Y.XmlFragment); + if (titleFrag.length !== 0) return false; + + const titleSeed = buildTitleSeedYdoc(title); + Y.applyUpdate(doc, Y.encodeStateAsUpdate(titleSeed)); + this.logger.debug('seeded title fragment from page.title column'); + return true; + } catch (err) { + this.logger.warn(`failed to seed title fragment: ${err?.['message']}`); + return false; + } + } + + /** + * Persist the current state of `doc` into page.ydoc. Used by the one-shot + * rebuild/seed self-heal in onLoadDocument so the conversion is durable and + * never repeats. Defensive (try/catch + log): a persistence failure here must + * NOT break document loading — the in-memory doc is still returned and the + * next store will persist it anyway. + */ + private async persistYdoc(doc: Y.Doc, pageId: string): Promise { + try { + await this.pageRepo.updatePage( + { ydoc: Buffer.from(Y.encodeStateAsUpdate(doc)) }, + pageId, + ); + this.logger.debug(`persisted rebuilt/seeded ydoc: ${pageId}`); + } catch (err) { + this.logger.error( + `Failed to persist rebuilt/seeded ydoc for page ${pageId}`, + err, + ); + } + } + async onStoreDocument(data: onStoreDocumentPayload) { const { documentName, document, context } = data; @@ -171,7 +255,34 @@ export class PersistenceExtension implements Extension { this.logger.warn('jsonToText' + err?.['message']); } + // Title lives in the SAME Y.Doc as the body, in a dedicated 'title' fragment + // (the collaborative title-editor contract with the client). Extract it + // defensively: a malformed title fragment must NOT crash the document store. + // `hasTitleFragment` distinguishes "the doc actually carries a title + // fragment" from "legacy doc with no title fragment" — only the former may + // write page.title, so a legacy doc never clobbers the column with ''. + let titleText = ''; + let hasTitleFragment = false; + try { + const titleFrag = document.get('title', Y.XmlFragment); + hasTitleFragment = !!titleFrag && titleFrag.length > 0; + if (hasTitleFragment) { + const titleJson = TiptapTransformer.fromYdoc(document, 'title'); + titleText = titleJson ? jsonToText(titleJson).trim() : ''; + } + } catch (err) { + this.logger.warn('title extraction: ' + err?.['message']); + hasTitleFragment = false; + } + let page: Page = null; + // Tracks whether the BODY ('default') changed in this store. The heavy + // body-only side-effects (transclusion sync, mentions, RAG, history) stay + // gated on this so a title-only change does not trigger them. + let bodyChanged = false; + // Tracks a successful title-only persist so the post-tx contributor folding + // (collabHistory.addContributors) runs for the title-only case too. + let titleOnlyPersisted = false; const editingUserIds = this.consumeContributors(documentName); // Sticky agent marker: 'agent' if any agent edit landed in this window, OR // if the current writer is the agent (covers a store with no prior onChange @@ -205,11 +316,62 @@ export class PersistenceExtension implements Extension { return; } - if (isDeepStrictEqual(tiptapJson, page.content)) { + bodyChanged = !isDeepStrictEqual(tiptapJson, page.content); + // Only a populated 'title' fragment may update page.title; compare + // against the current column value (treat null as ''). + const titleChanged = + hasTitleFragment && titleText !== (page.title ?? ''); + + // No-op fast path: neither body nor title changed. + if (!bodyChanged && !titleChanged) { page = null; return; } + // Title-only change: the body is unchanged, so skip the heavy body + // history/contributor logic and persist just the new title and the + // ydoc (the title fragment edit lives in the same ydoc). The early-skip + // used to drop this case entirely, losing the title change. + if (!bodyChanged) { + // Fold the window's editing users into contributors the same way the + // body branch does, so a user who edited ONLY the title is not dropped + // from page.contributorIds. + const contributorIds = Array.from( + new Set([ + ...(page.contributorIds || []), + ...editingUserIds, + page.creatorId, + ]), + ); + await this.pageRepo.updatePage( + { + title: titleText, + ydoc: ydocState, + lastUpdatedById: context.user.id, + contributorIds, + // A title-only change is not a body-authorship transition; leave + // lastUpdatedSource/aiChatId untouched so the user->agent history + // boundary in the body branch is not bypassed. + }, + pageId, + trx, + // Mirror PageService.update's tree snapshot so a collaborative rename + // propagates to other users' sidebar/breadcrumbs like the REST rename. + { + treeUpdate: { + id: pageId, + slugId: page.slugId, + spaceId: page.spaceId, + parentPageId: page.parentPageId ?? null, + title: titleText, + }, + }, + ); + this.logger.debug(`Page title updated: ${pageId} - SlugId: ${page.slugId}`); + titleOnlyPersisted = true; + return; + } + let contributorIds = undefined; try { const existingContributors = page.contributorIds || []; @@ -227,29 +389,22 @@ export class PersistenceExtension implements Extension { // Approach A — boundary snapshot before the agent's first edit. // When this store is the agent's and the page's currently persisted // state was authored by a human, pin that human state as its own - // history version BEFORE the agent overwrites it. `page` still holds - // the OLD content/provenance here, so saveHistory(page) captures the - // pre-agent state tagged 'user'. The agent's new content is - // snapshotted later by the debounced PAGE_HISTORY job ('agent'). Skip - // if the prior state is already agent-authored (boundary already - // pinned on the user->agent transition), if the page is effectively - // empty, or if the latest existing snapshot already equals this human - // state (avoid duplicates). - if ( - lastUpdatedSource === 'agent' && - page.lastUpdatedSource !== 'agent' - ) { + // history version BEFORE the agent overwrites it. `page` still holds the + // OLD content/provenance here, so saveHistory(page) captures the + // pre-agent state tagged 'user'. The agent's new content is snapshotted + // later by the debounced PAGE_HISTORY job ('agent'). Skip if the prior + // state is already agent-authored (boundary already pinned on the + // user->agent transition), if the page is effectively empty, or if the + // latest existing snapshot already equals this human state (avoid + // duplicates). + if (lastUpdatedSource === 'agent' && page.lastUpdatedSource !== 'agent') { const lastHistory = await this.pageHistoryRepo.findPageLastHistory( pageId, { includeContent: true, trx }, ); const humanBaselineMissing = - !lastHistory || - !isDeepStrictEqual(lastHistory.content, page.content); - if ( - !isEmptyParagraphDoc(page.content as any) && - humanBaselineMissing - ) { + !lastHistory || !isDeepStrictEqual(lastHistory.content, page.content); + if (!isEmptyParagraphDoc(page.content as any) && humanBaselineMissing) { await this.pageHistoryRepo.saveHistory(page, { contributorIds: page.contributorIds ?? undefined, trx, @@ -267,9 +422,27 @@ export class PersistenceExtension implements Extension { lastUpdatedSource, lastUpdatedAiChatId: context?.aiChatId ?? null, contributorIds: contributorIds, + // Persist the title in the SAME transaction when the title fragment + // changed alongside the body. + ...(titleChanged ? { title: titleText } : {}), }, pageId, trx, + // Mirror PageService.update's tree snapshot so a collaborative rename + // propagates to other users' sidebar/breadcrumbs like the REST rename. + // Only attach when the title actually changed; a body-only save must + // not trigger a tree broadcast. + titleChanged + ? { + treeUpdate: { + id: pageId, + slugId: page.slugId, + spaceId: page.spaceId, + parentPageId: page.parentPageId ?? null, + title: titleText, + }, + } + : undefined, ); this.logger.debug(`Page updated: ${pageId} - SlugId: ${page.slugId}`); @@ -290,6 +463,8 @@ export class PersistenceExtension implements Extension { } } + // `page` is truthy whenever anything was persisted (body OR title-only), so + // the page.updated broadcast fires for a title-only change too. if (page) { document.broadcastStateless( JSON.stringify({ @@ -307,11 +482,20 @@ export class PersistenceExtension implements Extension { : undefined, }), ); + } + // Record the window's editing users in collab history for a title-only + // change too (the body branch does this below, gated on bodyChanged). + if (page && titleOnlyPersisted) { + await this.collabHistory.addContributors(pageId, editingUserIds); + } + + // Body-only side-effects: skip them for a title-only change (body unchanged). + if (page && bodyChanged) { await this.syncTransclusion(pageId, page.workspaceId, tiptapJson); } - if (page) { + if (page && bodyChanged) { await this.collabHistory.addContributors(pageId, editingUserIds); const mentions = extractMentions(tiptapJson); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4a55e7a0..937fbf4e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -314,9 +314,15 @@ importers: '@tabler/icons-react': specifier: 3.40.0 version: 3.40.0(react@18.3.1) + '@tanstack/query-async-storage-persister': + specifier: 5.90.17 + version: 5.90.17 '@tanstack/react-query': specifier: 5.90.17 version: 5.90.17(react@18.3.1) + '@tanstack/react-query-persist-client': + specifier: 5.90.17 + version: 5.90.17(@tanstack/react-query@5.90.17(react@18.3.1))(react@18.3.1) '@tanstack/react-virtual': specifier: 3.13.24 version: 3.13.24(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -350,6 +356,9 @@ importers: i18next-http-backend: specifier: 3.0.6 version: 3.0.6 + idb-keyval: + specifier: 6.2.5 + version: 6.2.5 jotai: specifier: 2.18.1 version: 2.18.1(@babel/core@7.28.5)(@babel/template@7.27.2)(@types/react@18.3.12)(react@18.3.1) @@ -495,6 +504,9 @@ importers: vite: specifier: 8.0.5 version: 8.0.5(@types/node@22.19.1)(esbuild@0.28.0)(jiti@2.4.2)(less@4.2.0)(sugarss@5.0.1(postcss@8.5.14))(terser@5.39.0)(tsx@4.21.0)(yaml@2.8.3) + vite-plugin-pwa: + specifier: 1.3.0 + version: 1.3.0(vite@8.0.5(@types/node@22.19.1)(esbuild@0.28.0)(jiti@2.4.2)(less@4.2.0)(sugarss@5.0.1(postcss@8.5.14))(terser@5.39.0)(tsx@4.21.0)(yaml@2.8.3))(workbox-build@7.4.1(@types/babel__core@7.20.5))(workbox-window@7.4.1) vitest: specifier: 4.1.6 version: 4.1.6(@opentelemetry/api@1.9.0)(@types/node@22.19.1)(happy-dom@20.8.9)(jsdom@25.0.0)(vite@8.0.5(@types/node@22.19.1)(esbuild@0.28.0)(jiti@2.4.2)(less@4.2.0)(sugarss@5.0.1(postcss@8.5.14))(terser@5.39.0)(tsx@4.21.0)(yaml@2.8.3)) @@ -1074,6 +1086,12 @@ packages: '@antfu/install-pkg@1.1.0': resolution: {integrity: sha512-MGQsmw10ZyI+EJo45CdSER4zEb+p31LpDAFp2Z3gkSd1yqVZGi0Ebx++YTEMonJy4oChEMLsxZ64j8FH6sSqtQ==} + '@apideck/better-ajv-errors@0.3.7': + resolution: {integrity: sha512-TajUJwGWbDwkCx/CZi7tRE8PVB7simCvKJfHUsSdvps+aTM/PDPP4gkLmKnc+x3CE//y9i/nj74GqdL/hwk7Iw==} + engines: {node: '>=10'} + peerDependencies: + ajv: 8.18.0 + '@asamuzakjp/css-color@2.8.3': resolution: {integrity: sha512-GIc76d9UI1hCvOATjZPyHFmE5qhRccp3/zGfMPapK3jBi+yocEzp6BBB0UnfRYP9NP4FANqUZYb0hnfs3TM3hw==} @@ -4312,6 +4330,180 @@ packages: '@rolldown/pluginutils@1.0.0-rc.7': resolution: {integrity: sha512-qujRfC8sFVInYSPPMLQByRh7zhwkGFS4+tyMQ83srV1qrxL4g8E2tyxVVyxd0+8QeBM1mIk9KbWxkegRr76XzA==} + '@rollup/plugin-babel@6.1.0': + resolution: {integrity: sha512-dFZNuFD2YRcoomP4oYf+DvQNSUA9ih+A3vUqopQx5EdtPGo3WBnQcI/S8pwpz91UsGfL0HsMSOlaMld8HrbubA==} + engines: {node: '>=14.0.0'} + peerDependencies: + '@babel/core': ^7.0.0 + '@types/babel__core': ^7.1.9 + rollup: ^1.20.0||^2.0.0||^3.0.0||^4.0.0 + peerDependenciesMeta: + '@types/babel__core': + optional: true + rollup: + optional: true + + '@rollup/plugin-node-resolve@16.0.3': + resolution: {integrity: sha512-lUYM3UBGuM93CnMPG1YocWu7X802BrNF3jW2zny5gQyLQgRFJhV1Sq0Zi74+dh/6NBx1DxFC4b4GXg9wUCG5Qg==} + engines: {node: '>=14.0.0'} + peerDependencies: + rollup: ^2.78.0||^3.0.0||^4.0.0 + peerDependenciesMeta: + rollup: + optional: true + + '@rollup/plugin-replace@6.0.3': + resolution: {integrity: sha512-J4RZarRvQAm5IF0/LwUUg+obsm+xZhYnbMXmXROyoSE1ATJe3oXSb9L5MMppdxP2ylNSjv6zFBwKYjcKMucVfA==} + engines: {node: '>=14.0.0'} + peerDependencies: + rollup: ^1.20.0||^2.0.0||^3.0.0||^4.0.0 + peerDependenciesMeta: + rollup: + optional: true + + '@rollup/plugin-terser@1.0.0': + resolution: {integrity: sha512-FnCxhTBx6bMOYQrar6C8h3scPt8/JwIzw3+AJ2K++6guogH5fYaIFia+zZuhqv0eo1RN7W1Pz630SyvLbDjhtQ==} + engines: {node: '>=20.0.0'} + peerDependencies: + rollup: ^2.0.0||^3.0.0||^4.0.0 + peerDependenciesMeta: + rollup: + optional: true + + '@rollup/pluginutils@5.4.0': + resolution: {integrity: sha512-MfPp06CjRLfXQ3wY0R8vJDYBy/MvVcc9OulEfR0B8Iv9ko+GCNaRZ+EpJYFl27LhKsZK0o420sYCRHCjfCgeUg==} + engines: {node: '>=14.0.0'} + peerDependencies: + rollup: ^1.20.0||^2.0.0||^3.0.0||^4.0.0 + peerDependenciesMeta: + rollup: + optional: true + + '@rollup/rollup-android-arm-eabi@4.62.2': + resolution: {integrity: sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg==} + cpu: [arm] + os: [android] + + '@rollup/rollup-android-arm64@4.62.2': + resolution: {integrity: sha512-BaH7BllCACHoH1LguOU56UItGfUWjujlO65kS9LAodViaN4bwIKd7oeW/ZHJ/4ljr/7MIiENnNy3HJ0zXv8Zkw==} + cpu: [arm64] + os: [android] + + '@rollup/rollup-darwin-arm64@4.62.2': + resolution: {integrity: sha512-v39RCCvj4He82I9sFmk+M1VZ0PLM9sfsLVikjfx2hYBNALhrrOR2D3JjQA6AhlaSOgcR+RzrKY7e1+bT6SUO/A==} + cpu: [arm64] + os: [darwin] + + '@rollup/rollup-darwin-x64@4.62.2': + resolution: {integrity: sha512-yl0y2vq3S3lHeuXhEdss6TWfKW8vkujImO12tn4ZkG/4oghr09LvdYm2RElVjokTQiUvDUGXLGsYeLqUMCKpGA==} + cpu: [x64] + os: [darwin] + + '@rollup/rollup-freebsd-arm64@4.62.2': + resolution: {integrity: sha512-tT4pvt4qXD+vEoezupCWi+a1F0vvDiksiHc+PxRlYTOH1I6/X4id9jPxTP+Fg+545euaFT1jJVs4CEdHZAU1vw==} + cpu: [arm64] + os: [freebsd] + + '@rollup/rollup-freebsd-x64@4.62.2': + resolution: {integrity: sha512-6nU5F2wCW+qvCBhTn1pdIU3bzsIoF7EUwsCDRxilWGprQR6yd508YnH9+OKFCwpfS8pjZqDUmnCAr7exax0XCg==} + cpu: [x64] + os: [freebsd] + + '@rollup/rollup-linux-arm-gnueabihf@4.62.2': + resolution: {integrity: sha512-n1GJHPOvpIfhi3TmrCeh6S6URt9BFCt0KQE3qvexyGCTAKpR4Lg+eWvNZEqu7epxwus/8ElT3hacYEucm49SZg==} + cpu: [arm] + os: [linux] + + '@rollup/rollup-linux-arm-musleabihf@4.62.2': + resolution: {integrity: sha512-JqgflS8wEB+UXV/vS1RpRbifGBeN4D5lz8D8oOFbFZw4vedvdOgCFAjfBmIMdW3yL10XpQQ0Ambepw6MXrhOnA==} + cpu: [arm] + os: [linux] + + '@rollup/rollup-linux-arm64-gnu@4.62.2': + resolution: {integrity: sha512-wnFJkogWvN4jm/hQRF2UBaeUmk20j5+DmHvoyWii2b8HJDyvz1MF2OU/6ynXt2KR63rbZLWkFpoytpdc/yBuSA==} + cpu: [arm64] + os: [linux] + + '@rollup/rollup-linux-arm64-musl@4.62.2': + resolution: {integrity: sha512-HVu2bp0zhvJ8xHEV9+UUs7S90VadmBSY3LcIMvozbPo4AuMGDWlz3ymHLHZPX4hR67TKTt8Qp5PJ5RBg/i+RMQ==} + cpu: [arm64] + os: [linux] + + '@rollup/rollup-linux-loong64-gnu@4.62.2': + resolution: {integrity: sha512-mQqqAV8QaoSgr9I2fKDLY2BAVvmKjWoGiu/cSYQonsLvtqwEn1E4QYfnCOcp5zoEqNhsDYin1s6jx/VJmrxlZg==} + cpu: [loong64] + os: [linux] + + '@rollup/rollup-linux-loong64-musl@4.62.2': + resolution: {integrity: sha512-IxKLoxCQ2IWi6bT2akyDUBGsOImDKB+sPp4EsTmwFQ/fMwpCKm8uLSSgP/Kx/QYUgKis6SEZ5/Nlhup0DIA0PQ==} + cpu: [loong64] + os: [linux] + + '@rollup/rollup-linux-ppc64-gnu@4.62.2': + resolution: {integrity: sha512-Mk5ha2RQSgyFfmYYLkBpPnUk8D8FriBxesO1u9O75X0mHgXL1UQcH5Itl2lurWL2tj0RxV9b9tJgipac0hRY9A==} + cpu: [ppc64] + os: [linux] + + '@rollup/rollup-linux-ppc64-musl@4.62.2': + resolution: {integrity: sha512-CjvEnqJL/0/TQ3TXX3OPIJ/kmBellrWd4heXUmHeJlTnmwjKpSJzoehLaL6Xk0ZnMHBu9dZuFADNOrtjF4v+2w==} + cpu: [ppc64] + os: [linux] + + '@rollup/rollup-linux-riscv64-gnu@4.62.2': + resolution: {integrity: sha512-1SiZbzwdkaDURsew/tSOrooKiYy7EQGT6m8ufavAi9NEyQb/6VuIxFXAL1fqa4iZe3g4NbNk4P7J32z2tw5Mgg==} + cpu: [riscv64] + os: [linux] + + '@rollup/rollup-linux-riscv64-musl@4.62.2': + resolution: {integrity: sha512-nQts12zJ3NQRoE6uYljOH89v7szzLDvG2JD/vsX+vGXU8w/At1GowTZ5/7qeFQ8m7L55rpR8Okugnuo5bgjy2Q==} + cpu: [riscv64] + os: [linux] + + '@rollup/rollup-linux-s390x-gnu@4.62.2': + resolution: {integrity: sha512-E9/ll019jhPIJgpzfZoIkBGhcz+kKNgVWYRY0zr9srBdPPFVpvOKW8VaJKUbeK+eZXyQF9ltME+Kk6affeaPgg==} + cpu: [s390x] + os: [linux] + + '@rollup/rollup-linux-x64-gnu@4.62.2': + resolution: {integrity: sha512-5BqxR/pshjey51iliyzTD5Xi3EN0aLmQ2lZ3lvefVV9c82BvrLo2/6OT55iifpWBufs6kdwWbuOKS841DrmK9A==} + cpu: [x64] + os: [linux] + + '@rollup/rollup-linux-x64-musl@4.62.2': + resolution: {integrity: sha512-uNN83XxQrRAh/w0/pmAfibcwyb6YWt4gP+dpnQKPVJshAloQ785ii8CT8ZCIxkGg9opVsvAlGhFitSm6D1Jjpg==} + cpu: [x64] + os: [linux] + + '@rollup/rollup-openbsd-x64@4.62.2': + resolution: {integrity: sha512-srjEIxSH3LRnJN6THczDHWQplqEMFiAJrTab0msUryh9kwNpkICf3Ea6q6MN/2cZwRFUNx5w+h6Hpi4QuHS6Zg==} + cpu: [x64] + os: [openbsd] + + '@rollup/rollup-openharmony-arm64@4.62.2': + resolution: {integrity: sha512-8hOJnxgbyObnCm5AlRA3A931xX19xq80RjVTKgJOvEKWqJruP/Uf12IbAOaDjjEXYRewwHLfmF0YRIdK3OwKWA==} + cpu: [arm64] + os: [openharmony] + + '@rollup/rollup-win32-arm64-msvc@4.62.2': + resolution: {integrity: sha512-mmF4AY1i0hG/bLWUctUq59gtmgaSIRa3cu/A3JFRp/sCNEme2bgDEiDS22P9FbnJB8NJNF4jPJiSP5RHQpUTDg==} + cpu: [arm64] + os: [win32] + + '@rollup/rollup-win32-ia32-msvc@4.62.2': + resolution: {integrity: sha512-DZgkknc6jhHrk46V25vbAM0zZkyP0nSDkJB8/dRkLTxv470dOmWDqGoEJl/9A0dFfS7yE3REOwNDxpHwSLSt0Q==} + cpu: [ia32] + os: [win32] + + '@rollup/rollup-win32-x64-gnu@4.62.2': + resolution: {integrity: sha512-T6xr6ucWSFto+VGajA8YH26LdpHRuP4YLHEKAtCWvJDOlnmWcDZVCI2Jmjr+IFHDlt2zRaTAKE4tfjTaWLgJBg==} + cpu: [x64] + os: [win32] + + '@rollup/rollup-win32-x64-msvc@4.62.2': + resolution: {integrity: sha512-BfzEnDJOt9T8M989/lA37EcJgat01wLRnoi5dQf3QzOH7jzpqTAzdDbVfRljVr5r+jzKqpbHeyOfAaXxAd0PAA==} + cpu: [x64] + os: [win32] + '@selderee/plugin-htmlparser2@0.11.0': resolution: {integrity: sha512-P33hHGdldxGabLFjPPpaTxVolMrzrcegejx+0GxjrIb9Zv48D8yAIA/QTDR2dFl7Uz7urX8aX6+5bCZslr+gWQ==} @@ -4497,9 +4689,24 @@ packages: typescript: optional: true + '@tanstack/query-async-storage-persister@5.90.17': + resolution: {integrity: sha512-cptA5Oc+VYaKJmxuayYp5FjXhKHBkGsL6wBMbLRH0RR9nTYTIREIFpUnT3xRSwyDa50tj1n6no5mMFw7sjNN8A==} + + '@tanstack/query-core@5.90.15': + resolution: {integrity: sha512-mInIZNUZftbERE+/Hbtswfse49uUQwch46p+27gP9DWJL927UjnaWEF2t3RMOqBcXbfMdcNkPe06VyUIAZTV1g==} + '@tanstack/query-core@5.90.17': resolution: {integrity: sha512-hDww+RyyYhjhUfoYQ4es6pbgxY7LNiPWxt4l1nJqhByjndxJ7HIjDxTBtfvMr5HwjYavMrd+ids5g4Rfev3lVQ==} + '@tanstack/query-persist-client-core@5.91.14': + resolution: {integrity: sha512-22vWKM2SXU29AUAzCv4pjKWx+mVam5QjPH+XZ1FQeH4DgO9HFPjWN8Bf9NVF2ki8eFGzTy+7FCBylEgV+LZgJw==} + + '@tanstack/react-query-persist-client@5.90.17': + resolution: {integrity: sha512-iipmRT7CpW/Is/RKHXPDuWS0FP7RrmJL/KofNb0aKYMl5dH88R0PZ7N0PvJ44SZCwl44p4VG/4ugGDISutCgtQ==} + peerDependencies: + '@tanstack/react-query': ^5.90.15 + react: ^18 || ^19 + '@tanstack/react-query@5.90.17': resolution: {integrity: sha512-PGc2u9KLwohDUSchjW9MZqeDQJfJDON7y4W7REdNBgiFKxQy+Pf7eGjiFWEj5xPqKzAeHYdAb62IWI1a9UJyGQ==} peerDependencies: @@ -4815,6 +5022,10 @@ packages: '@tokenizer/token@0.3.0': resolution: {integrity: sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A==} + '@trickfilm400/rollup-plugin-off-main-thread@3.0.0-pre1': + resolution: {integrity: sha512-/67zpWDBLV+oYAEL682s1ktXL0HgqX76f6gaVGkGnVZlBbm1zd0v4Bz8MFF2GGhoX9rvfq3KSQHubFHwa6w6/Q==} + engines: {node: '>=12'} + '@tsconfig/node10@1.0.9': resolution: {integrity: sha512-jNsYVVxU8v5g43Erja32laIDHXeoNvFEpX33OK4d6hljo3jDhCBDhx5dhCCTMWUojscpAagGiRkBKxpdl9fxqA==} @@ -5134,6 +5345,9 @@ packages: '@types/react@18.3.12': resolution: {integrity: sha512-D2wOSq/d6Agt28q7rSI3jhU7G6aiuzljDGZ2hTZHIkrTLUI+AF3WMeKkEZ9nN2fkBAlcktT6vcZjDFiIhMYEQw==} + '@types/resolve@1.20.2': + resolution: {integrity: sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q==} + '@types/send@0.17.4': resolution: {integrity: sha512-x2EM6TJOybec7c52BX0ZspPodMsQUd5L6PRwOunVyVUhXiBSKf3AezDL8Dgvgt5o0UfKNfuA0eMLr2wLT4AiBA==} @@ -5683,6 +5897,10 @@ packages: asynckit@0.4.0: resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} + at-least-node@1.0.0: + resolution: {integrity: sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg==} + engines: {node: '>= 4.0.0'} + atomic-sleep@1.0.0: resolution: {integrity: sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==} engines: {node: '>=8.0.0'} @@ -6071,6 +6289,10 @@ packages: resolution: {integrity: sha512-R2rze/hDX30uul4NZoIZ76ImSJLFxn/1/ZxtKC1L77y2X1k+yYu1joKbAtMA2Fg3hZrTOiw0I5mwVMo0cf250w==} engines: {node: '>= 6'} + common-tags@1.8.2: + resolution: {integrity: sha512-gk/Z852D2Wtb//0I+kRFNKKE9dIIVirjoqPoA1wJU+XePVXZfGeBpk45+A1rKO4Q43prqWBNY/MiIeRLbPWUaA==} + engines: {node: '>=4.0.0'} + component-emitter@1.3.1: resolution: {integrity: sha512-T0+barUSQRTUQASh8bx02dl+DhF54GtIDY13Y3m9oWTklKbb3Wv974meRpeZ3lp1JpLVECWWNHC4vaG2XHXouQ==} @@ -6198,6 +6420,10 @@ packages: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} + crypto-random-string@2.0.0: + resolution: {integrity: sha512-v1plID3y9r/lPhviJ1wrXpLeyUIGAZ2SHNYTEapm7/8A9nLPoyvVp3RK/EPFqn5kEznyWgYZNsRtYYIWbuG8KA==} + engines: {node: '>=8'} + css-select@5.1.0: resolution: {integrity: sha512-nwoRF1rvRRnnCqqY7updORDsuqKzqYJ28+oSMaJMMgOauh3fvwHqMS7EZpIPqK8GL+g9mKxF1vP/ZjSeNjEVHg==} @@ -6855,6 +7081,9 @@ packages: resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} engines: {node: '>=4.0'} + estree-walker@2.0.2: + resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==} + estree-walker@3.0.3: resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} @@ -6862,6 +7091,10 @@ packages: resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} engines: {node: '>=0.10.0'} + eta@4.6.0: + resolution: {integrity: sha512-lW6is4T1NFOYnmqGZIfvixqj7A7sSvScF+DN8EK6K58xI5MZ5UvYe0GjopxOXQtZvUn4eDdVuZ8XSoYWTMEKwA==} + engines: {node: '>=20'} + etag@1.8.1: resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==} engines: {node: '>= 0.6'} @@ -7102,6 +7335,10 @@ packages: resolution: {integrity: sha512-CTXd6rk/M3/ULNQj8FBqBWHYBVYybQ3VPBw0xGKFe3tuH7ytT6ACnvzpIQ3UZtB8yvUKC2cXn1a+x+5EVQLovA==} engines: {node: '>=14.14'} + fs-extra@9.1.0: + resolution: {integrity: sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==} + engines: {node: '>=10'} + fs-monkey@1.0.5: resolution: {integrity: sha512-8uMbBjrhzW76TYgEV27Y5E//W2f/lTFmx78P2w19FZSxarhI/798APGQyuGCwmkNxgwGRhrLfvWyLBvNtuOmew==} @@ -7140,6 +7377,9 @@ packages: resolution: {integrity: sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==} engines: {node: '>=6'} + get-own-enumerable-property-symbols@3.0.2: + resolution: {integrity: sha512-I0UBV/XOz1XkIJHEUDMZAbzCThU/H8DxmSfmdGcKPnVhu2VfFqr34jr9777IyaTYvxjedWhqVIilEDsCdP5G6g==} + get-package-type@0.1.0: resolution: {integrity: sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q==} engines: {node: '>=8.0.0'} @@ -7334,6 +7574,12 @@ packages: resolution: {integrity: sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==} engines: {node: '>=0.10.0'} + idb-keyval@6.2.5: + resolution: {integrity: sha512-eKQkTnS0relYsSOYomx8ozIbmdsQCKUdhyuIaQ2DZgKuaxtyQQMkyD/wlnQN32pO3yutN1b1L8uqwcDKaJd7/Q==} + + idb@7.1.1: + resolution: {integrity: sha512-gchesWBzyvGHRO9W8tzUWFDycow5gwjvFKfyV9FF32Y7F50yZMp7mP+T2mJIWFx49zicqyC4uefHM17o6xKIVQ==} + ieee754@1.2.1: resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} @@ -7489,6 +7735,9 @@ packages: resolution: {integrity: sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==} engines: {node: '>= 0.4'} + is-module@1.0.0: + resolution: {integrity: sha512-51ypPSPCoTEIN9dy5Oy+h4pShgJmPCygKfyRCISBI+JoWT/2oJvK8QPxmwv7b/p239jXrm9M1mlQbyKJ5A152g==} + is-negative-zero@2.0.3: resolution: {integrity: sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==} engines: {node: '>= 0.4'} @@ -7501,6 +7750,10 @@ packages: resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} engines: {node: '>=0.12.0'} + is-obj@1.0.1: + resolution: {integrity: sha512-l4RyHgRqGN4Y3+9JHVrNqO+tN0rV5My76uW5/nuO4K1b6vw5G8d/cmFjP9tRfEsdhZNt0IFdZuK/c2Vr4Nb+Qg==} + engines: {node: '>=0.10.0'} + is-potential-custom-element-name@1.0.1: resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==} @@ -7511,6 +7764,10 @@ packages: resolution: {integrity: sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==} engines: {node: '>= 0.4'} + is-regexp@1.0.0: + resolution: {integrity: sha512-7zjFAPO4/gwyQAAgRRmqeEeyIICSdmCqa3tsVHMdBzaXXRiqopZL4Cyghg/XulGWrtABTpbnYYzzIRffLkP4oA==} + engines: {node: '>=0.10.0'} + is-set@2.0.3: resolution: {integrity: sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg==} engines: {node: '>= 0.4'} @@ -7904,6 +8161,10 @@ packages: jsonfile@6.1.0: resolution: {integrity: sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==} + jsonpointer@5.0.1: + resolution: {integrity: sha512-p/nXbhSEcu3pZRdkW1OfJhpsVtW1gd4Wa1fnQc9YLiTfAjn0312eMKimbdIQzuZl9aa9xUGaRlP9T/CJE/ditQ==} + engines: {node: '>=0.10.0'} + jsonrepair@3.13.3: resolution: {integrity: sha512-BTznj0owIt2CBAH/LTo7+1I5pMvl1e1033LRl/HUowlZmJOIhzC0zbX5bxMngLkfT4WnzPP26QnW5wMr2g9tsQ==} hasBin: true @@ -8209,6 +8470,9 @@ packages: lodash.once@4.1.1: resolution: {integrity: sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==} + lodash.sortby@4.7.0: + resolution: {integrity: sha512-HDWXG8isMntAyRF5vZ7xKuEvOhT4AhlRt/3czTSjvGUxjYCBVRQY48ViDHyfYz9VIoBkW4TMGQNapx+l3RUwdA==} + lodash.throttle@4.1.1: resolution: {integrity: sha512-wIkUCfVKpVsWo3JSZlc+8MB5it+2AN5W8J7YVMST30UrvcQNZ1Okbj+rbVniijTWE6FGYy4XJq/rHkas8qJMLQ==} @@ -9048,6 +9312,14 @@ packages: engines: {node: '>=14'} hasBin: true + pretty-bytes@5.6.0: + resolution: {integrity: sha512-FFw039TmrBqFK8ma/7OL3sDz/VytdtJr044/QUJtH0wK9lb9jLq9tJyIxUwtQJHwar2BqtiA4iCWSwo9JLkzFg==} + engines: {node: '>=6'} + + pretty-bytes@6.1.1: + resolution: {integrity: sha512-mQUvGU6aUFQ+rNvTIAcZuWGRT9a6f6Yrg9bHs4ImKF+HZCEK+plBvnAZYSIQztknZF2qnzNtr6F8s0+IuptdlQ==} + engines: {node: ^14.13.1 || >=16.0.0} + pretty-format@27.5.1: resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==} engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} @@ -9477,6 +9749,11 @@ packages: engines: {node: ^20.19.0 || >=22.12.0} hasBin: true + rollup@4.62.2: + resolution: {integrity: sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==} + engines: {node: '>=18.0.0', npm: '>=8.0.0'} + hasBin: true + rope-sequence@1.3.4: resolution: {integrity: sha512-UT5EDe2cu2E/6O4igUr5PSFs23nvvukicWHx6GnOPlHAiiYbzNuCRQCuiUdHJQcqKalLKlrYJnjY0ySGsXNQXQ==} @@ -9595,6 +9872,10 @@ packages: resolution: {integrity: sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==} engines: {node: '>= 18'} + serialize-javascript@7.0.3: + resolution: {integrity: sha512-h+cZ/XXarqDgCjo+YSyQU/ulDEESGGf8AMK9pPNmhNSl/FzPl6L8pMp1leca5z6NuG6tvV/auC8/43tmovowww==} + engines: {node: '>=20.0.0'} + serve-static@2.2.1: resolution: {integrity: sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==} engines: {node: '>= 18'} @@ -9675,6 +9956,10 @@ packages: resolution: {integrity: sha512-VZBmZP8WU3sMOZm1bdgTadsQbcscK0UM8oKxKVBs4XAhUo2Xxzm/OFMGBkPusxw9xL3Uy8LrzEqGqJhclsr0yA==} deprecated: Unsupported + smob@1.6.2: + resolution: {integrity: sha512-RQsvleCbF8cVHEv+xuDGaA4pOizFqJ0GgjtMSRo6oP8pnN7WsigHgVGey6aILRBKv4W2YOMHLqbKdnB6hpB9fw==} + engines: {node: '>=20.0.0'} + socket.io-adapter@2.5.4: resolution: {integrity: sha512-wDNHGXGewWAjQPt3pyeYBtpWSq9cLE5UW1ZUPL/2eGK9jtse/FpXib7epSTsz0Q0m+6sg6Y4KtcFTlah1bdOVg==} @@ -9714,6 +9999,11 @@ packages: resolution: {integrity: sha512-l3BikUxvPOcn5E74dZiq5BGsTb5yEwhaTSzccU6t4sDOH8NWJCstKO5QT2CvtFoK6F0saL7p9xHAqHOlCPJygA==} engines: {node: '>= 8'} + source-map@0.8.0-beta.0: + resolution: {integrity: sha512-2ymg6oRBpebeZi9UUNsgQ89bhx01TcTkmNTGnNO88imTmbSgy4nfujrgVEFKWpMTEGA11EDkTt7mqObTPdigIA==} + engines: {node: '>= 8'} + deprecated: The work that was done in this beta branch won't be included in future versions + split2@4.2.0: resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==} engines: {node: '>= 10.x'} @@ -9781,6 +10071,10 @@ packages: string_decoder@1.3.0: resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==} + stringify-object@3.3.0: + resolution: {integrity: sha512-rHqiFh1elqCQ9WPLIC8I0Q/g/wj5J1eMkyoiD6eoQApWHP0FtlK7rqnhmabL5VUY9JQCcqwwvlOaSuutekgyrw==} + engines: {node: '>=4'} + strip-ansi@6.0.1: resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} engines: {node: '>=8'} @@ -9793,6 +10087,10 @@ packages: resolution: {integrity: sha512-3xurFv5tEgii33Zi8Jtp55wEIILR9eh34FAW00PZf+JnSsTmV/ioewSgQl97JHvgjoRGwPShsWm+IdrxB35d0w==} engines: {node: '>=8'} + strip-comments@2.0.1: + resolution: {integrity: sha512-ZprKx+bBLXv067WTCALv8SSz5l2+XhpYCsVtSqlMnkAXMWDq+/ekVbl1ghqP9rUHTzv6sm/DwCOiYutU/yp1fw==} + engines: {node: '>=10'} + strip-final-newline@2.0.0: resolution: {integrity: sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==} engines: {node: '>=6'} @@ -9889,6 +10187,14 @@ packages: resolution: {integrity: sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==} engines: {node: '>=6'} + temp-dir@2.0.0: + resolution: {integrity: sha512-aoBAniQmmwtcKp/7BzsH8Cxzv8OL736p7v1ihGb5e9DJ9kTwGWHrQrVB5+lfVDzfGrdRzXch+ig7LHaY1JTOrg==} + engines: {node: '>=8'} + + tempy@0.6.0: + resolution: {integrity: sha512-G13vtMYPT/J8A4X2SjdtBTphZlrp1gKv6hZiOjw14RCWg6GbHuQBGtjlx75xLbYV/wEc0D7G5K4rxKP/cXk8Bw==} + engines: {node: '>=10'} + terser-webpack-plugin@5.4.0: resolution: {integrity: sha512-Bn5vxm48flOIfkdl5CaD2+1CiUVbonWQ3KQPyP7/EuIl9Gbzq/gQFOzaMFUEgVjB1396tcK0SG8XcNJ/2kDH8g==} engines: {node: '>= 10.13.0'} @@ -10001,6 +10307,9 @@ packages: tr46@0.0.3: resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==} + tr46@1.0.1: + resolution: {integrity: sha512-dTpowEjclQ7Kgx5SdBkqRzVhERQXov8/l9Ft9dVM9fmg0W0KQSVaXX9T4i6twCPNtYiZM53lpSSUAwJbFPOHxA==} + tr46@5.1.1: resolution: {integrity: sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==} engines: {node: '>=18'} @@ -10104,6 +10413,10 @@ packages: resolution: {integrity: sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==} engines: {node: '>=4'} + type-fest@0.16.0: + resolution: {integrity: sha512-eaBzG6MxNzEn9kiwvtre90cXaNLkmadMWa1zQMs3XORCXNbsH/OewwbxC5ia9dCxIxnTAsSxXJaa/p5y8DlvJg==} + engines: {node: '>=10'} + type-fest@0.20.2: resolution: {integrity: sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==} engines: {node: '>=10'} @@ -10217,6 +10530,10 @@ packages: resolution: {integrity: sha512-6t3foTQI9qne+OZoVQB/8x8rk2k1eVy1gRXhV3oFQ5T6R1dqQ1xtin3XqSlx3+ATBkliTaR/hHyJBm+LVPNM8w==} engines: {node: '>=4'} + unique-string@2.0.0: + resolution: {integrity: sha512-uNaeirEPvpZWSgzwsPGtU2zVSTrn/8L5q/IexZmH0eH6SA73CmAA5U4GwORTxQAZs95TAXLNqeLoPPNO5gZfWg==} + engines: {node: '>=8'} + universalify@0.2.0: resolution: {integrity: sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg==} engines: {node: '>= 4.0.0'} @@ -10232,6 +10549,10 @@ packages: unrs-resolver@1.11.1: resolution: {integrity: sha512-bSjt9pjaEBnNiGgc9rUiHGKv5l4/TGzDmYw3RhnkJGtLhbnnA/5qJj7x3dNDCRx/PJxu774LlH8lCOlB4hEfKg==} + upath@1.2.0: + resolution: {integrity: sha512-aZwGpamFO61g3OlfT7OQCHqhGnW43ieH9WZeP7QxN/G/jS4jfqUkZxoryvJgVPEcrl5NL/ggHsSmLMHuH64Lhg==} + engines: {node: '>=4'} + update-browserslist-db@1.2.3: resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==} hasBin: true @@ -10326,6 +10647,18 @@ packages: resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} engines: {node: '>= 0.8'} + vite-plugin-pwa@1.3.0: + resolution: {integrity: sha512-c5kMgN+ITrOtHXp8PAtk2uOIEea6XjP/unCGxOWWBzQ6qa65qj/awHg0wf+QF9E/2u9vh86LqxPwzEPNbM2r5A==} + engines: {node: '>=16.0.0'} + peerDependencies: + '@vite-pwa/assets-generator': ^1.0.0 + vite: ^3.1.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0 + workbox-build: ^7.4.1 + workbox-window: ^7.4.1 + peerDependenciesMeta: + '@vite-pwa/assets-generator': + optional: true + vite@8.0.5: resolution: {integrity: sha512-nmu43Qvq9UopTRfMx2jOYW5l16pb3iDC1JH6yMuPkpVbzK0k+L7dfsEDH4jRgYFmsg0sTAqkojoZgzLMlwHsCQ==} engines: {node: ^20.19.0 || >=22.12.0} @@ -10457,6 +10790,9 @@ packages: webidl-conversions@3.0.1: resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==} + webidl-conversions@4.0.2: + resolution: {integrity: sha512-YQ+BmxuTgd6UXZW3+ICGfyqRyHXVlD5GtQr5+qjiNW7bF0cqrzX500HVXPBOvgXb5YnzDd+h0zqyv61KUD7+Sg==} + webidl-conversions@7.0.0: resolution: {integrity: sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==} engines: {node: '>=12'} @@ -10517,6 +10853,9 @@ packages: whatwg-url@5.0.0: resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==} + whatwg-url@7.1.0: + resolution: {integrity: sha512-WUu7Rg1DroM7oQvGWfOiAK21n74Gg+T4elXEQYkOhtyLeWiJFoOGLXPKI/9gzIie9CtwVLm8wtw6YJdKyxSjeg==} + when-exit@2.1.5: resolution: {integrity: sha512-VGkKJ564kzt6Ms1dbgPP/yuIoQCrsFAnRbptpC5wOEsDaNsbCB2bnfnaA8i/vRs5tjUSEOtIuvl9/MyVsvQZCg==} @@ -10556,6 +10895,55 @@ packages: wordwrap@1.0.0: resolution: {integrity: sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==} + workbox-background-sync@7.4.1: + resolution: {integrity: sha512-HhT7KE8tOWDm02wRNshXUnUPofMlhenF2DBdUnDPOubhizzPeItkYTmAB6td1Z2cjYPa98vzEiPLEuzn5hN66g==} + + workbox-broadcast-update@7.4.1: + resolution: {integrity: sha512-uAlgslKLvbQY+suirIdnBCSYrcgBhjp81Nj4l1lj/Jmj0MJO2CJERnCJjT0GFVwmReV0N+zs78K6gqd5gr9/+A==} + + workbox-build@7.4.1: + resolution: {integrity: sha512-SDhxIvEAde9Gy/5w4Yo1Jh/M49Z0qE3q0oteyE8zGq0DScxFqVBcCtIXFuLtmtxRQZCMbf0prco4VyEu3KBQuw==} + engines: {node: '>=20.0.0'} + + workbox-cacheable-response@7.4.1: + resolution: {integrity: sha512-8xaFoJdDc2OjrlbbL3gEeBO1WKcMwRqwLRupgqahYXu75yXajPLuwrbXMrIGZuWYXrQwk0xDjOxZ/ujCy/oJYw==} + + workbox-core@7.4.1: + resolution: {integrity: sha512-DT+vu46eh/2vRsSHTY4Xmc32Z1rr9PRlQUXr1Dx30ZuXRWwOsvZgGgcwxcasubQLQmbTNYZjv44LkBAQ4tT5tQ==} + + workbox-expiration@7.4.1: + resolution: {integrity: sha512-lRKUF7b+OGbeXkQk1s6MHXOa3d7Xxf7Of31W6c6hCfipfIyrtdWZ89stq21AHZMaoG7VNFoHply4Ox+rU31TWg==} + + workbox-google-analytics@7.4.1: + resolution: {integrity: sha512-Mks1JwLEt++ZAkF6sS1OpSh9RtAMIsiDgRpK+codiHGIPXeaUOgi4cPc3GFadUl8V5QPeypEk8Oxgl3HlwVzHw==} + + workbox-navigation-preload@7.4.1: + resolution: {integrity: sha512-C4KVsjPcYKJOhr631AxR9XoG2rLF3QiTk5aMv36MXOjtWvm8axwNFAtKUPGsWUwLXXAMgYM1En7fsvndaXeXRQ==} + + workbox-precaching@7.4.1: + resolution: {integrity: sha512-cdr/9qByww7yzEp7zg/qI4ukUrrNjQLgN+ONQRpjy/VqGQXwkgHwr00KksGJK8v0VifwDXBb8a4cWNZH71jn3Q==} + + workbox-range-requests@7.4.1: + resolution: {integrity: sha512-7i2oxAUE82gHdAJBCAQ04JzNOdRPqzuOzGfoUyJpFSmeqBNYGPrAH8GPoPjUQTfp+NycwrD2H68VtuF8qxv0vQ==} + + workbox-recipes@7.4.1: + resolution: {integrity: sha512-gnbVfmV4/TtmQaM4x9AtuXhcdstJsep3XMVeztOrQVPT+R6+6DeBjGTCQ7fFCXm+4GEHUA5VEBTyi5+4gWGeog==} + + workbox-routing@7.4.1: + resolution: {integrity: sha512-yubJGErZOusuidAenaL5ypfhQOa7urxP/f8E0ws7FPb4039RiWXUWBAyUkmUoOL/BcQGen3h0J8872d51IYxtA==} + + workbox-strategies@7.4.1: + resolution: {integrity: sha512-GZxpaw9NbmOelj7667uZ2kpk5BFpOGbO4X0qjwh5ls8XQ8C+Lha5LQchTiUzsTFSS+NlUpftYAyOVXvQUrcqOQ==} + + workbox-streams@7.4.1: + resolution: {integrity: sha512-HWWtraKUbJknd9kgqGcpQ3G114HOPYvqs8HaJMDs2ebLNAimDkVDaWfAXE6Ybl+m8U6KsCE6pWyLYuigWmnAXw==} + + workbox-sw@7.4.1: + resolution: {integrity: sha512-fez5f2DUlDJWTFYkCWQpY10N8gtztd849NswCbVFk0QlcSM4HT5A8x4g4ii650yem4I8tHY0R7JZahwp3ltIPw==} + + workbox-window@7.4.1: + resolution: {integrity: sha512-notZDH2u8VXaqyuD7xaqIfEFi6SRM4SUSd7ewe9PDsVqADuepxX2ZMY3uvuZGxzY5ZOsGC/vD3A/3smFtJt4/A==} + wrap-ansi@6.2.0: resolution: {integrity: sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==} engines: {node: '>=8'} @@ -10892,6 +11280,12 @@ snapshots: package-manager-detector: 1.3.0 tinyexec: 1.1.2 + '@apideck/better-ajv-errors@0.3.7(ajv@8.18.0)': + dependencies: + ajv: 8.18.0 + jsonpointer: 5.0.1 + leven: 3.1.0 + '@asamuzakjp/css-color@2.8.3': dependencies: '@csstools/css-calc': 2.1.1(@csstools/css-parser-algorithms@3.0.4(@csstools/css-tokenizer@3.0.3))(@csstools/css-tokenizer@3.0.3) @@ -14652,6 +15046,125 @@ snapshots: '@rolldown/pluginutils@1.0.0-rc.7': {} + '@rollup/plugin-babel@6.1.0(@babel/core@7.28.5)(@types/babel__core@7.20.5)(rollup@4.62.2)': + dependencies: + '@babel/core': 7.28.5 + '@babel/helper-module-imports': 7.27.1 + '@rollup/pluginutils': 5.4.0(rollup@4.62.2) + optionalDependencies: + '@types/babel__core': 7.20.5 + rollup: 4.62.2 + transitivePeerDependencies: + - supports-color + + '@rollup/plugin-node-resolve@16.0.3(rollup@4.62.2)': + dependencies: + '@rollup/pluginutils': 5.4.0(rollup@4.62.2) + '@types/resolve': 1.20.2 + deepmerge: 4.3.1 + is-module: 1.0.0 + resolve: 1.22.8 + optionalDependencies: + rollup: 4.62.2 + + '@rollup/plugin-replace@6.0.3(rollup@4.62.2)': + dependencies: + '@rollup/pluginutils': 5.4.0(rollup@4.62.2) + magic-string: 0.30.21 + optionalDependencies: + rollup: 4.62.2 + + '@rollup/plugin-terser@1.0.0(rollup@4.62.2)': + dependencies: + serialize-javascript: 7.0.3 + smob: 1.6.2 + terser: 5.39.0 + optionalDependencies: + rollup: 4.62.2 + + '@rollup/pluginutils@5.4.0(rollup@4.62.2)': + dependencies: + '@types/estree': 1.0.9 + estree-walker: 2.0.2 + picomatch: 4.0.4 + optionalDependencies: + rollup: 4.62.2 + + '@rollup/rollup-android-arm-eabi@4.62.2': + optional: true + + '@rollup/rollup-android-arm64@4.62.2': + optional: true + + '@rollup/rollup-darwin-arm64@4.62.2': + optional: true + + '@rollup/rollup-darwin-x64@4.62.2': + optional: true + + '@rollup/rollup-freebsd-arm64@4.62.2': + optional: true + + '@rollup/rollup-freebsd-x64@4.62.2': + optional: true + + '@rollup/rollup-linux-arm-gnueabihf@4.62.2': + optional: true + + '@rollup/rollup-linux-arm-musleabihf@4.62.2': + optional: true + + '@rollup/rollup-linux-arm64-gnu@4.62.2': + optional: true + + '@rollup/rollup-linux-arm64-musl@4.62.2': + optional: true + + '@rollup/rollup-linux-loong64-gnu@4.62.2': + optional: true + + '@rollup/rollup-linux-loong64-musl@4.62.2': + optional: true + + '@rollup/rollup-linux-ppc64-gnu@4.62.2': + optional: true + + '@rollup/rollup-linux-ppc64-musl@4.62.2': + optional: true + + '@rollup/rollup-linux-riscv64-gnu@4.62.2': + optional: true + + '@rollup/rollup-linux-riscv64-musl@4.62.2': + optional: true + + '@rollup/rollup-linux-s390x-gnu@4.62.2': + optional: true + + '@rollup/rollup-linux-x64-gnu@4.62.2': + optional: true + + '@rollup/rollup-linux-x64-musl@4.62.2': + optional: true + + '@rollup/rollup-openbsd-x64@4.62.2': + optional: true + + '@rollup/rollup-openharmony-arm64@4.62.2': + optional: true + + '@rollup/rollup-win32-arm64-msvc@4.62.2': + optional: true + + '@rollup/rollup-win32-ia32-msvc@4.62.2': + optional: true + + '@rollup/rollup-win32-x64-gnu@4.62.2': + optional: true + + '@rollup/rollup-win32-x64-msvc@4.62.2': + optional: true + '@selderee/plugin-htmlparser2@0.11.0': dependencies: domhandler: 5.0.3 @@ -14832,8 +15345,25 @@ snapshots: transitivePeerDependencies: - supports-color + '@tanstack/query-async-storage-persister@5.90.17': + dependencies: + '@tanstack/query-core': 5.90.15 + '@tanstack/query-persist-client-core': 5.91.14 + + '@tanstack/query-core@5.90.15': {} + '@tanstack/query-core@5.90.17': {} + '@tanstack/query-persist-client-core@5.91.14': + dependencies: + '@tanstack/query-core': 5.90.15 + + '@tanstack/react-query-persist-client@5.90.17(@tanstack/react-query@5.90.17(react@18.3.1))(react@18.3.1)': + dependencies: + '@tanstack/query-persist-client-core': 5.91.14 + '@tanstack/react-query': 5.90.17(react@18.3.1) + react: 18.3.1 + '@tanstack/react-query@5.90.17(react@18.3.1)': dependencies: '@tanstack/query-core': 5.90.17 @@ -15166,6 +15696,13 @@ snapshots: '@tokenizer/token@0.3.0': {} + '@trickfilm400/rollup-plugin-off-main-thread@3.0.0-pre1': + dependencies: + ejs: 3.1.10 + json5: 2.2.3 + magic-string: 0.30.21 + string.prototype.matchall: 4.0.12 + '@tsconfig/node10@1.0.9': {} '@tsconfig/node12@1.0.11': {} @@ -15549,6 +16086,8 @@ snapshots: '@types/prop-types': 15.7.11 csstype: 3.2.3 + '@types/resolve@1.20.2': {} + '@types/send@0.17.4': dependencies: '@types/mime': 1.3.5 @@ -15575,8 +16114,7 @@ snapshots: '@types/tough-cookie@4.0.5': {} - '@types/trusted-types@2.0.7': - optional: true + '@types/trusted-types@2.0.7': {} '@types/turndown@5.0.6': {} @@ -16190,6 +16728,8 @@ snapshots: asynckit@0.4.0: {} + at-least-node@1.0.0: {} + atomic-sleep@1.0.0: {} atomically@2.1.1: @@ -16637,6 +17177,8 @@ snapshots: array-timsort: 1.0.3 esprima: 4.0.1 + common-tags@1.8.2: {} + component-emitter@1.3.1: {} concat-map@0.0.1: {} @@ -16768,6 +17310,8 @@ snapshots: shebang-command: 2.0.0 which: 2.0.2 + crypto-random-string@2.0.0: {} + css-select@5.1.0: dependencies: boolbase: 1.0.0 @@ -17604,12 +18148,16 @@ snapshots: estraverse@5.3.0: {} + estree-walker@2.0.2: {} + estree-walker@3.0.3: dependencies: '@types/estree': 1.0.9 esutils@2.0.3: {} + eta@4.6.0: {} + etag@1.8.1: {} eventemitter2@6.4.9: {} @@ -17912,6 +18460,13 @@ snapshots: jsonfile: 6.1.0 universalify: 2.0.1 + fs-extra@9.1.0: + dependencies: + at-least-node: 1.0.0 + graceful-fs: 4.2.11 + jsonfile: 6.1.0 + universalify: 2.0.1 + fs-monkey@1.0.5: {} fsevents@2.3.3: @@ -17951,6 +18506,8 @@ snapshots: get-nonce@1.0.1: {} + get-own-enumerable-property-symbols@3.0.2: {} + get-package-type@0.1.0: {} get-proto@1.0.1: @@ -18156,6 +18713,10 @@ snapshots: dependencies: safer-buffer: 2.1.2 + idb-keyval@6.2.5: {} + + idb@7.1.1: {} + ieee754@1.2.1: {} ignore@5.3.1: {} @@ -18291,6 +18852,8 @@ snapshots: is-map@2.0.3: {} + is-module@1.0.0: {} + is-negative-zero@2.0.3: {} is-number-object@1.1.1: @@ -18300,6 +18863,8 @@ snapshots: is-number@7.0.0: {} + is-obj@1.0.1: {} + is-potential-custom-element-name@1.0.1: {} is-promise@4.0.0: {} @@ -18311,6 +18876,8 @@ snapshots: has-tostringtag: 1.0.2 hasown: 2.0.2 + is-regexp@1.0.0: {} + is-set@2.0.3: {} is-shared-array-buffer@1.0.4: @@ -18933,6 +19500,8 @@ snapshots: optionalDependencies: graceful-fs: 4.2.11 + jsonpointer@5.0.1: {} + jsonrepair@3.13.3: {} jsonwebtoken@9.0.3: @@ -19187,6 +19756,8 @@ snapshots: lodash.once@4.1.1: {} + lodash.sortby@4.7.0: {} + lodash.throttle@4.1.1: {} lodash@4.18.1: {} @@ -20072,6 +20643,10 @@ snapshots: prettier@3.8.1: {} + pretty-bytes@5.6.0: {} + + pretty-bytes@6.1.1: {} + pretty-format@27.5.1: dependencies: ansi-regex: 5.0.1 @@ -20638,6 +21213,37 @@ snapshots: '@rolldown/binding-win32-arm64-msvc': 1.0.0-rc.12 '@rolldown/binding-win32-x64-msvc': 1.0.0-rc.12 + rollup@4.62.2: + dependencies: + '@types/estree': 1.0.9 + optionalDependencies: + '@rollup/rollup-android-arm-eabi': 4.62.2 + '@rollup/rollup-android-arm64': 4.62.2 + '@rollup/rollup-darwin-arm64': 4.62.2 + '@rollup/rollup-darwin-x64': 4.62.2 + '@rollup/rollup-freebsd-arm64': 4.62.2 + '@rollup/rollup-freebsd-x64': 4.62.2 + '@rollup/rollup-linux-arm-gnueabihf': 4.62.2 + '@rollup/rollup-linux-arm-musleabihf': 4.62.2 + '@rollup/rollup-linux-arm64-gnu': 4.62.2 + '@rollup/rollup-linux-arm64-musl': 4.62.2 + '@rollup/rollup-linux-loong64-gnu': 4.62.2 + '@rollup/rollup-linux-loong64-musl': 4.62.2 + '@rollup/rollup-linux-ppc64-gnu': 4.62.2 + '@rollup/rollup-linux-ppc64-musl': 4.62.2 + '@rollup/rollup-linux-riscv64-gnu': 4.62.2 + '@rollup/rollup-linux-riscv64-musl': 4.62.2 + '@rollup/rollup-linux-s390x-gnu': 4.62.2 + '@rollup/rollup-linux-x64-gnu': 4.62.2 + '@rollup/rollup-linux-x64-musl': 4.62.2 + '@rollup/rollup-openbsd-x64': 4.62.2 + '@rollup/rollup-openharmony-arm64': 4.62.2 + '@rollup/rollup-win32-arm64-msvc': 4.62.2 + '@rollup/rollup-win32-ia32-msvc': 4.62.2 + '@rollup/rollup-win32-x64-gnu': 4.62.2 + '@rollup/rollup-win32-x64-msvc': 4.62.2 + fsevents: 2.3.3 + rope-sequence@1.3.4: {} roughjs@4.6.4: @@ -20778,6 +21384,8 @@ snapshots: transitivePeerDependencies: - supports-color + serialize-javascript@7.0.3: {} + serve-static@2.2.1: dependencies: encodeurl: 2.0.0 @@ -20867,6 +21475,8 @@ snapshots: sliced@1.0.1: {} + smob@1.6.2: {} + socket.io-adapter@2.5.4: dependencies: debug: 4.3.7 @@ -20933,6 +21543,10 @@ snapshots: source-map@0.7.4: {} + source-map@0.8.0-beta.0: + dependencies: + whatwg-url: 7.1.0 + split2@4.2.0: {} sprintf-js@1.0.3: {} @@ -21024,6 +21638,12 @@ snapshots: dependencies: safe-buffer: 5.2.1 + stringify-object@3.3.0: + dependencies: + get-own-enumerable-property-symbols: 3.0.2 + is-obj: 1.0.1 + is-regexp: 1.0.0 + strip-ansi@6.0.1: dependencies: ansi-regex: 5.0.1 @@ -21032,6 +21652,8 @@ snapshots: strip-bom@4.0.0: {} + strip-comments@2.0.1: {} + strip-final-newline@2.0.0: {} strip-indent@3.0.0: @@ -21127,6 +21749,15 @@ snapshots: inherits: 2.0.4 readable-stream: 3.6.2 + temp-dir@2.0.0: {} + + tempy@0.6.0: + dependencies: + is-stream: 2.0.1 + temp-dir: 2.0.0 + type-fest: 0.16.0 + unique-string: 2.0.0 + terser-webpack-plugin@5.4.0(@swc/core@1.5.25(@swc/helpers@0.5.5))(webpack@5.106.0(@swc/core@1.5.25(@swc/helpers@0.5.5))): dependencies: '@jridgewell/trace-mapping': 0.3.31 @@ -21224,6 +21855,10 @@ snapshots: tr46@0.0.3: {} + tr46@1.0.1: + dependencies: + punycode: 2.3.1 + tr46@5.1.1: dependencies: punycode: 2.3.1 @@ -21332,6 +21967,8 @@ snapshots: type-detect@4.0.8: {} + type-fest@0.16.0: {} + type-fest@0.20.2: {} type-fest@0.21.3: {} @@ -21457,6 +22094,10 @@ snapshots: unicode-property-aliases-ecmascript@2.1.0: {} + unique-string@2.0.0: + dependencies: + crypto-random-string: 2.0.0 + universalify@0.2.0: {} universalify@2.0.1: {} @@ -21487,6 +22128,8 @@ snapshots: '@unrs/resolver-binding-win32-ia32-msvc': 1.11.1 '@unrs/resolver-binding-win32-x64-msvc': 1.11.1 + upath@1.2.0: {} + update-browserslist-db@1.2.3(browserslist@4.28.1): dependencies: browserslist: 4.28.1 @@ -21560,6 +22203,17 @@ snapshots: vary@1.1.2: {} + vite-plugin-pwa@1.3.0(vite@8.0.5(@types/node@22.19.1)(esbuild@0.28.0)(jiti@2.4.2)(less@4.2.0)(sugarss@5.0.1(postcss@8.5.14))(terser@5.39.0)(tsx@4.21.0)(yaml@2.8.3))(workbox-build@7.4.1(@types/babel__core@7.20.5))(workbox-window@7.4.1): + dependencies: + debug: 4.4.3 + pretty-bytes: 6.1.1 + tinyglobby: 0.2.15 + vite: 8.0.5(@types/node@22.19.1)(esbuild@0.28.0)(jiti@2.4.2)(less@4.2.0)(sugarss@5.0.1(postcss@8.5.14))(terser@5.39.0)(tsx@4.21.0)(yaml@2.8.3) + workbox-build: 7.4.1(@types/babel__core@7.20.5) + workbox-window: 7.4.1 + transitivePeerDependencies: + - supports-color + vite@8.0.5(@types/node@22.19.1)(esbuild@0.28.0)(jiti@2.4.2)(less@4.2.0)(sugarss@5.0.1(postcss@8.5.14))(terser@5.39.0)(tsx@4.21.0)(yaml@2.8.3): dependencies: lightningcss: 1.32.0 @@ -21650,6 +22304,8 @@ snapshots: webidl-conversions@3.0.1: {} + webidl-conversions@4.0.2: {} + webidl-conversions@7.0.0: {} webidl-conversions@8.0.1: {} @@ -21719,6 +22375,12 @@ snapshots: tr46: 0.0.3 webidl-conversions: 3.0.1 + whatwg-url@7.1.0: + dependencies: + lodash.sortby: 4.7.0 + tr46: 1.0.1 + webidl-conversions: 4.0.2 + when-exit@2.1.5: {} which-boxed-primitive@1.1.1: @@ -21779,6 +22441,119 @@ snapshots: wordwrap@1.0.0: {} + workbox-background-sync@7.4.1: + dependencies: + idb: 7.1.1 + workbox-core: 7.4.1 + + workbox-broadcast-update@7.4.1: + dependencies: + workbox-core: 7.4.1 + + workbox-build@7.4.1(@types/babel__core@7.20.5): + dependencies: + '@apideck/better-ajv-errors': 0.3.7(ajv@8.18.0) + '@babel/core': 7.28.5 + '@babel/preset-env': 7.23.8(@babel/core@7.28.5) + '@babel/runtime': 7.29.2 + '@rollup/plugin-babel': 6.1.0(@babel/core@7.28.5)(@types/babel__core@7.20.5)(rollup@4.62.2) + '@rollup/plugin-node-resolve': 16.0.3(rollup@4.62.2) + '@rollup/plugin-replace': 6.0.3(rollup@4.62.2) + '@rollup/plugin-terser': 1.0.0(rollup@4.62.2) + '@trickfilm400/rollup-plugin-off-main-thread': 3.0.0-pre1 + ajv: 8.18.0 + common-tags: 1.8.2 + eta: 4.6.0 + fast-json-stable-stringify: 2.1.0 + fs-extra: 9.1.0 + glob: 13.0.6 + pretty-bytes: 5.6.0 + rollup: 4.62.2 + source-map: 0.8.0-beta.0 + stringify-object: 3.3.0 + strip-comments: 2.0.1 + tempy: 0.6.0 + upath: 1.2.0 + workbox-background-sync: 7.4.1 + workbox-broadcast-update: 7.4.1 + workbox-cacheable-response: 7.4.1 + workbox-core: 7.4.1 + workbox-expiration: 7.4.1 + workbox-google-analytics: 7.4.1 + workbox-navigation-preload: 7.4.1 + workbox-precaching: 7.4.1 + workbox-range-requests: 7.4.1 + workbox-recipes: 7.4.1 + workbox-routing: 7.4.1 + workbox-strategies: 7.4.1 + workbox-streams: 7.4.1 + workbox-sw: 7.4.1 + workbox-window: 7.4.1 + transitivePeerDependencies: + - '@types/babel__core' + - supports-color + + workbox-cacheable-response@7.4.1: + dependencies: + workbox-core: 7.4.1 + + workbox-core@7.4.1: {} + + workbox-expiration@7.4.1: + dependencies: + idb: 7.1.1 + workbox-core: 7.4.1 + + workbox-google-analytics@7.4.1: + dependencies: + workbox-background-sync: 7.4.1 + workbox-core: 7.4.1 + workbox-routing: 7.4.1 + workbox-strategies: 7.4.1 + + workbox-navigation-preload@7.4.1: + dependencies: + workbox-core: 7.4.1 + + workbox-precaching@7.4.1: + dependencies: + workbox-core: 7.4.1 + workbox-routing: 7.4.1 + workbox-strategies: 7.4.1 + + workbox-range-requests@7.4.1: + dependencies: + workbox-core: 7.4.1 + + workbox-recipes@7.4.1: + dependencies: + workbox-cacheable-response: 7.4.1 + workbox-core: 7.4.1 + workbox-expiration: 7.4.1 + workbox-precaching: 7.4.1 + workbox-routing: 7.4.1 + workbox-strategies: 7.4.1 + + workbox-routing@7.4.1: + dependencies: + workbox-core: 7.4.1 + + workbox-strategies@7.4.1: + dependencies: + workbox-core: 7.4.1 + + workbox-streams@7.4.1: + dependencies: + workbox-core: 7.4.1 + workbox-routing: 7.4.1 + + workbox-sw@7.4.1: {} + + workbox-window@7.4.1: + dependencies: + '@types/trusted-types': 2.0.7 + workbox-core: 7.4.1 + wrap-ansi@6.2.0: dependencies: ansi-styles: 4.3.0