"use client"; /** * Page-level queries/mutations. All block mutations flow through * use-blocks.ts; these cover page structure (tree, versions, favorite). */ import { useMemo } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { blockApi, pageApi, workspaceApi } from "@/lib/api"; import { sortByPosition } from "@/lib/utils"; import { useUiStore, type BulkAction } from "@/stores/ui-store"; import type { Page } from "@/lib/types"; export function usePageTree(workspaceId: string, parentId?: string | null) { const effectiveParent = parentId ?? null; return useQuery({ queryKey: ["workspaces", workspaceId, "page-tree", effectiveParent], queryFn: () => workspaceApi.pageTree(workspaceId, { parentId: effectiveParent }), enabled: workspaceId.length > 0 }); } /** Load a single page by id (404 → null, rendered by the route). */ export function usePage(id: string) { return useQuery({ queryKey: ["pages", id], queryFn: () => pageApi.get(id), enabled: id.length > 0 }); } /** * Direct-child blocks of a page (depth=1 under the requested parent), * sorted client-side by fractional position (ARCHITECTURE.md invariant 4). */ export function usePageBlocks(pageId: string, parentId: string | null = null) { const query = useQuery({ queryKey: ["pages", pageId, "blocks", parentId ?? "top"], queryFn: () => blockApi.list(pageId, { parentId, depth: 1 }), enabled: pageId.length > 0 }); const blocks = useMemo( () => (query.data ? sortByPosition(query.data) : []), [query.data] ); return { ...query, blocks }; } /** * T-140: permission-filtered backlinks (the pages that reference this one — * child_page / linked_database blocks + comment mentions; the server drops * sources the caller cannot view). 30 s staleTime mirrors the API's ≤60 s * version-keyed cache — a re-open refetches only after it lapses. */ export function useBacklinks(pageId: string) { return useQuery({ queryKey: ["pages", pageId, "backlinks"], queryFn: () => pageApi.backlinks(pageId), enabled: pageId.length > 0, staleTime: 30 * 1000 }); } export function usePageVersions(pageId: string) { return useQuery({ queryKey: ["pages", pageId, "versions"], queryFn: () => pageApi.versions(pageId), enabled: pageId.length > 0, staleTime: 60 * 1000 }); } export interface NewPageInput { workspaceId?: string; parentId?: string | null; title?: string; type?: "page" | "database" | "whiteboard"; } export function useCreatePage(workspaceId: string) { const qc = useQueryClient(); return useMutation({ mutationFn: (input: NewPageInput) => // The SERVER owns page positions (appendPagePosition + rebalance in // page.service.ts; CreatePageDto has no position field). No client // position math — the old positionForInsert call ran the shared // base-36 engine and would have thrown on any real position string. pageApi.create({ workspaceId: input.workspaceId ?? workspaceId, parentId: input.parentId ?? null, title: input.title ?? "", type: input.type }), onSuccess: (page) => { qc.invalidateQueries({ queryKey: ["workspaces", workspaceId, "page-tree"] }); qc.invalidateQueries({ queryKey: ["workspaces", workspaceId, "pages"] }); qc.setQueryData(["pages", page.id], page); } }); } export function useUpdatePage(workspaceId: string) { const qc = useQueryClient(); return useMutation({ mutationFn: (input: { id: string } & Partial> & { archived?: boolean }) => pageApi.update(input.id, { title: input.title, icon: input.icon, // `cover` is mapped to the server's `coverUrl` inside pageApi.update // (recorded conflict, T-192) — pass the web field through unchanged. cover: input.cover, parentId: input.parentId, position: input.position, // TASK-0045 context menu "Archive" (PATCH /pages/:id accepts // `archived` — the single-page archive endpoint from TASK-0035). ...(input.archived !== undefined ? { archived: input.archived } : {}) }), onSuccess: (page) => { qc.setQueryData(["pages", page.id], page); qc.invalidateQueries({ queryKey: ["workspaces", workspaceId, "page-tree"] }); } }); } /** * "Delete" moves the page to the workspace trash (type=trash under the * workspace root), matching PRODUCT-SPEC sidebar semantics. */ export function useDeletePage(workspaceId: string) { const qc = useQueryClient(); return useMutation({ mutationFn: (id: string) => pageApi.remove(id), onSuccess: (_data, id) => { qc.removeQueries({ queryKey: ["pages", id, "blocks"] }); qc.invalidateQueries({ queryKey: ["workspaces", workspaceId, "page-tree"] }); qc.invalidateQueries({ queryKey: ["pages", id] }); // T-192: a page trashed from the context menu must appear in an // already-mounted Trash view (the trash page's own restore/purge // mutations invalidate this list; the sidebar trash path did not). qc.invalidateQueries({ queryKey: ["workspaces", workspaceId, "trash"] }); } }); } /** * T-138: recursive duplicate (blocks, child pages, database snapshot). The * server defaults the copy's title to ` (copy)`; the page-tree cache is * invalidated so the new row appears where it belongs. */ export function useDuplicatePage(workspaceId: string) { const qc = useQueryClient(); return useMutation({ mutationFn: async (input: { id: string; parentId?: string | null; title?: string }) => { // The endpoint returns the BARE `{ id, sourcePageId }` (the tested // T-138 contract) — fetch the full new page (title etc.) by id. const { id: newId } = await pageApi.duplicate(input.id, { parentId: input.parentId ?? undefined, title: input.title }); return pageApi.get(newId); }, onSuccess: (page) => { qc.setQueryData(["pages", page.id], page); qc.invalidateQueries({ queryKey: ["workspaces", workspaceId, "page-tree"] }); qc.invalidateQueries({ queryKey: ["workspaces", workspaceId, "pages"] }); } }); } export function useToggleFavorite() { const qc = useQueryClient(); return useMutation({ mutationFn: (id: string) => pageApi.toggleFavorite(id), onSuccess: (data, id) => { qc.invalidateQueries({ queryKey: ["pages", id] }); qc.invalidateQueries({ queryKey: ["favorites"] }); } }); } /** * T-157: watchlist — is the caller watching this page (get notified of * updates)? VIEW-level, read from its own `GET /pages/:id/watch` (the page * object embeds `favorite` but not `watching`, so we don't touch the page * serializer). */ export function usePageWatch(pageId: string) { return useQuery({ queryKey: ["pages", pageId, "watch"], queryFn: () => pageApi.isWatching(pageId), enabled: pageId.length > 0 }); } /** T-157: start/stop watching a page. Flips the `watch` query cache. */ export function useToggleWatch() { const qc = useQueryClient(); return useMutation({ mutationFn: ({ id, watching }: { id: string; watching: boolean }) => watching ? pageApi.unwatch(id) : pageApi.watch(id), onSuccess: (data, { id }) => { qc.setQueryData(["pages", id, "watch"], { watching: data.watching }); } }); } /** * T-139: bulk page action (trash / archive / unarchive / favorite / * unfavorite). `pageApi.bulk` sends the ids + a `X-Factory-Workspace` header; * the server applies per-item permission and returns `{ succeeded, failed }` * (a page the caller lacks EDIT on is in `failed`, never silently skipped). * On success we clear the selection + the bulk dialog and refresh the tree. */ export function useBulkPages(workspaceId: string) { const qc = useQueryClient(); const setBulk = useUiStore((s) => s.setBulk); const clearPageSelection = useUiStore((s) => s.clearPageSelection); return useMutation({ mutationFn: (input: { action: BulkAction; ids: string[] }) => pageApi.bulk(workspaceId, input.action, input.ids), onSuccess: (_res, input) => { setBulk(false); clearPageSelection(); // trash/archive/favorite all change what the tree shows or its state. qc.invalidateQueries({ queryKey: ["workspaces", workspaceId, "page-tree"] }); qc.invalidateQueries({ queryKey: ["workspaces", workspaceId, "pages"] }); qc.invalidateQueries({ queryKey: ["favorites"] }); void input; } }); }