From e69f79f9db0590abd89ef8cca372da132d1fb86e Mon Sep 17 00:00:00 2001 From: cnliucheng Date: Wed, 4 Mar 2026 14:04:55 +0800 Subject: [PATCH] =?UTF-8?q?=E6=80=A7=E8=83=BD=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- prisma/dev.db | Bin 212992 -> 212992 bytes .../migration.sql | 5 + prisma/schema.prisma | 3 + src/app/api/pages/route.ts | 19 +- src/app/globals.css | 7 +- src/app/page.tsx | 203 +++++++++++------- src/components/editor/code-block.tsx | 6 +- src/components/sidebar.tsx | 21 +- src/components/sidebar/tree-view.tsx | 23 +- src/lib/store.ts | 92 ++++++-- 10 files changed, 270 insertions(+), 109 deletions(-) create mode 100644 prisma/migrations/20260304000000_add_page_indexes/migration.sql diff --git a/prisma/dev.db b/prisma/dev.db index f9a4daeb25f4ab378f26e95b4454359d06ebdc40..670b66f125c62984f71f77b6d4878932c21a0c07 100644 GIT binary patch delta 747 zcmYL{Ur19?7{Kp$cK647@44Ug&u}(#6Si_PY(t@XsX>&5l_riJY+x^Gp*8EFBEc=J zC|CX&c~?{*r7yAC8t#@a2__WOM0?N|gG7zMK&U4N)wv|Z;rHX?JLkZ6&i6$kG!mhM zcADfku5Jq)s@e_7bld2KUAWFsOtC!Y#XK(JXZ(iC_zssahi`EbKi~wWw$^hTPew!o0b`e*m;_gKt@^XCm zUK7ts^V~lQu8c6S8MY9!3O-ntPl8r zg+Pom^gv9L!Vm#XIZrk~>$wDd-s}d+qcOX3+{TZm!1pSZ$KveD?K4IyE1bcU;|ijt z946DzAE4!dO!B;}kdJC$o>(noRTVUN@O@CV)qqzG)xaLLu?5PtrFl|Kz@bG8$S@C& zwGx>;0RDtS3aSAoGzxlS!0hMDepPV74GZf*chK>xR0TrnwJ%P10xU=Sg6@Aguk`&< zWG#JtZRFP4=u>U@F7CgO&8Q_{sl5?$tnbzifI|^WQda$nviBl zh=inubo2<(606W5s12Q@MP^m>R&Fn}T7yO-SlHLQ)VWS_Oigr=D_ZM0_C?t+F;DH) l57sdBp{i!+GgQK;W#~^SV(25~W>heI41J~~E#Nau;a|4H!>j-R delta 3643 zcmZ`+e^3*57T@m*f+5L9p{*7zi=))`gn%IENn<_Fv3f`AiA-m%*AWB?)R;tmRGE5{ zMEOl1-~x;M5Q6-wN&-a_NDz+Go15$0Iqlud**~sxQ_OdFGt(LH+VQ4##@^d3EL6Oi z?91-H_kExDec#V}-|t#1DvL$c6s;O02qIp>9*7)D3EI%vylP*|TwKbL18n6T2D2ODYfvG>Di@ zAR^X+SQ`prqZ))J0tBT1LB9;*)r}z5hk*zo70TlTt*{djzf~~e^Z@ReWSXo zo}<20e621}eyn;c!bw#{B&%Z7FDUHF#}Nw3MZK?>pdvOX2`WWBsqBxajCi0t9`PXJ z!T5Sjw1O$B`!;09nk>X**KZHru(40kHfE%LfK)dzCv4@C_6wV&9e-kLLnJ{kmm7}- zHLbr!+r}04C#G;j>?4SMiXWS;+K_*zhN*AwN1H>;=_cLo)ZHnmDZ8a#;fDp{FKy4B zv~;aWC#q9G59-B7$uRg(xJx(JHB95W9^uj*zIKwc45e$~WqM(MMh5X={(~(13%R!OgG~z!|B?fO@WS% zr*V(h?`_O58gg=t^r5_>Qe*M_l#Od~aW<>q8sWxU12@|JUgtxL)$hB;_gZl`3-^L! znCqS5M{0usYeoE}7@Do~6L#}r9AX$(Io2KMs}P22Rsu_Lg1)8-kJ-6l2cEL~C+Y!Q z!jYr(Pc%xS`RgpUU&E7bT*Gqh(|o@djAb*JsDH-EwM_b_Ihpk4xjR zO&q|ny@4(ZS6h$khH;IDYa10Ir)MAwxM3VdaXkZkSG_QL39`qv)^Jz5z#w3`h?{Bx zea-x&Re}LL+~wu2xE@+A%JY)5ks+spvC667+N%9NH}0S0oesXKN*)EvASE2vbYh=Z zHsiVncwYtYt7c+Py=a~rXgKt`_ValwOKO;d2P?asNaEU`VWnQUrzz zLsi01tynK(7n$bKNHesUF*#n$G+=KFU<=M^!Rc5TE7VJ%vVn7ma|Td;$Op*-Gu&Uj zI?Ij2vP-^k6v`jl+Om$UbmzY_!?z5d$T1h`&lMT+=!2P=+|>%G6^N0aZWM+tE~OV* zYp@$>#kL6T@xT#bx)RrYvO4koZ8GYCBS0{&>n7jQ!__pc1eRziX*r9}%=;K%CRh6j z9&3dge#R%bn&HMj)eb>JKU`woQXEoOoa=DNU^rM%QtnnlNsV$TUWr!Axsv8%PKJJ3 ziz|nvE#aB&-?H2wAX{9GM{xEFF6&AVX+Dyc8z071HgR>X`0$<9Wvr!6-qI`wee~TE zeqTRtuC=Z7CM^r-gqcpkVH3PJ{JuMZ>$V{1PcyL4({fq%hFw+SPEywkH>>@f$}KK8 zl}cJDHhghdvWEc8&#bP*EB=N{n%DdkwCD=H^~(GdY)q>*$7SXoKa_dmgEBpR;Mh-S zCh^$st&;anFyI;oVB3*4iC1BlkTChMr}l~F9$;bZ(Qpr*Wi?PBkrmj zn9Eo{i!ygu=tV9}S@2Lj1T1awe4`8ZU4kYKMfdD1-ZtjtLtmtm_9UFAxtcbv^A;2| zUt0t0eRcs2rZb`mU7k~H2;P|B&9c$Pr4j)~3D+uNrwm;07xCE9I5KK8uB;DqR0SN9 ze5X@hqs7sTGJD64f}&D7S8p`r({cLJeZ~1EV~KcrD332I*2fi;?o;IE(HZ3#zhw4j zZ%Wv;b?Ja0H|ij&f4W<^-S!;+JHv_$C8arfy*NBKdDU>}F8m#*lqzg*4^-Qqbpl5c ztr41l362=zwo0exLh^J8`G$`Y^v2vAeY!UIf|fQH_G~oTngYtLIw17P%tHr{A3E_y zxv{J`z9c`lIKMRhbbfh!Zefn0AV0py5N|3r=9T4^78wm zmZa9eyx8nn@(}BsC4bI7nk8+_hEMmi_y0&9W?#8Wvh4I-@;Dp$TU5kuJxXShg-NXC zC>aC)p5CKmcBm2%6A1z>WSzTrnEmHtw39Xco%{{^#kVL<^BQmoIR=WsfoLD^7iab$xqJv!1L!ZQma;!eIJgx+#uHuj-te%TNK((gtm`aZK0$> z?|J_eS*RGKkw&S4q>^DLiFMYnlVjF-XRpm~;WgEx>HId#vB1%OPmN6ZI5bK|zBTWdnx|H#l(LRScF|w}wQ=BH?bW;ypsG zQM{*WSG}U_QT}Sox^B(db%|L&n>+2B$oz zzmHO8v0KiNAFIztDzjpvmvYNIv29bq*%yw4a2`p7@Ys4inWXUTzlAz&_;4S|wx1!- sdu(UX0&<(kus>`OKhUD#62He@hfDlci-Ic|u4pF4ALlumxW-NW51yfM-T(jq diff --git a/prisma/migrations/20260304000000_add_page_indexes/migration.sql b/prisma/migrations/20260304000000_add_page_indexes/migration.sql new file mode 100644 index 0000000..8b62b81 --- /dev/null +++ b/prisma/migrations/20260304000000_add_page_indexes/migration.sql @@ -0,0 +1,5 @@ +-- CreateIndex +CREATE INDEX IF NOT EXISTS "idx_page_parent_order" ON "Page"("parentId", "order"); + +-- CreateIndex +CREATE INDEX IF NOT EXISTS "idx_page_created_at" ON "Page"("createdAt"); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index e9135d3..1510a0e 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -22,6 +22,9 @@ model Page { updatedAt DateTime @updatedAt order Int @default(0) isLocked Boolean @default(false) + + @@index([parentId, order], map: "idx_page_parent_order") + @@index([createdAt], map: "idx_page_created_at") } model GlobalSettings { diff --git a/src/app/api/pages/route.ts b/src/app/api/pages/route.ts index 7649713..620d9f5 100644 --- a/src/app/api/pages/route.ts +++ b/src/app/api/pages/route.ts @@ -18,13 +18,28 @@ export async function GET(request: NextRequest) { // 默认行为:返回所有页面(向后兼容) const usePagination = limit > 0 && limit <= 1000 && page > 0; + const baseSelect = { + id: true, + title: true, + content: !lightweight, + tags: true, + icon: true, + type: true, + parentId: true, + createdAt: true, + updatedAt: true, + order: true, + isLocked: true, + } as const; + if (!usePagination) { const pages = await prisma.page.findMany({ + select: baseSelect, orderBy: [{ order: 'asc' }, { createdAt: 'desc' }], }); const parsedPages = pages.map((p) => ({ ...p, - content: lightweight ? '' : p.content, + content: lightweight ? '' : (p.content || ''), tags: safeParseTags(p.tags), })); return NextResponse.json(parsedPages); @@ -34,6 +49,7 @@ export async function GET(request: NextRequest) { const skip = (page - 1) * limit; const [pages, total] = await Promise.all([ prisma.page.findMany({ + select: baseSelect, skip, take: limit, orderBy: [{ order: 'asc' }, { createdAt: 'desc' }], @@ -133,4 +149,3 @@ export async function POST(request: Request) { } - diff --git a/src/app/globals.css b/src/app/globals.css index 706e59d..3e089dd 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -360,13 +360,18 @@ ul[data-type="taskList"], The component handles its own layout. */ /* Override Highlight.js background for a softer look */ -.ProseMirror pre { + .ProseMirror pre { background: hsl(var(--code-bg)) !important; border: 1px solid hsl(var(--border) / 0.5); border-radius: 0.5rem; padding: 0.65rem 0.75rem; margin: 0.95rem 0; overflow-x: auto; + color: #e6edf3; +} + +.ProseMirror pre code { + color: inherit; } .hljs { diff --git a/src/app/page.tsx b/src/app/page.tsx index 553c93e..e40ed88 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -25,7 +25,16 @@ const ICONS = [ ]; export default function Home() { - const { activePageId, pages, pageSync, updatePage, setActivePageId, addPage, retryPageSync, retryPendingSyncs, flushPendingSyncs } = useEditorStore(); + const activePageId = useEditorStore((s) => s.activePageId); + const pages = useEditorStore((s) => s.pages); + const pageSync = useEditorStore((s) => s.pageSync); + const updatePage = useEditorStore((s) => s.updatePage); + const setActivePageId = useEditorStore((s) => s.setActivePageId); + const addPage = useEditorStore((s) => s.addPage); + const retryPageSync = useEditorStore((s) => s.retryPageSync); + const retryPendingSyncs = useEditorStore((s) => s.retryPendingSyncs); + const flushPendingSyncs = useEditorStore((s) => s.flushPendingSyncs); + const fetchPageContent = useEditorStore((s) => s.fetchPageContent); const { openSearchWithTag } = useSearchStore(); const { timezone } = useSettingsStore(); @@ -36,6 +45,7 @@ export default function Home() { const [isAddingTag, setIsAddingTag] = useState(false); const [tagInput, setTagInput] = useState(""); const [isIconPickerOpen, setIsIconPickerOpen] = useState(false); + const [isLinksOpen, setIsLinksOpen] = useState(false); const [isHistoryOpen, setIsHistoryOpen] = useState(false); const [selectedHistoryIndex, setSelectedHistoryIndex] = useState(0); const [isMobileSidebarOpen, setIsMobileSidebarOpen] = useState(() => !activePageId); @@ -57,19 +67,22 @@ export default function Home() { }, [activePage, pages]); const outgoingLinks = useMemo(() => { + if (!isLinksOpen) return []; if (!activePage || activePage.type !== "file") return []; return getOutgoingLinks(activePage, pages); - }, [activePage, pages]); + }, [activePage, pages, isLinksOpen]); const backlinks = useMemo(() => { + if (!isLinksOpen) return []; if (!activePage || activePage.type !== "file") return []; return getBacklinks(activePage, pages); - }, [activePage, pages]); + }, [activePage, pages, isLinksOpen]); const unresolvedLinks = useMemo(() => { + if (!isLinksOpen) return []; if (!activePage || activePage.type !== "file") return []; return getUnresolvedLinkTitles(activePage, pages); - }, [activePage, pages]); + }, [activePage, pages, isLinksOpen]); const localHistory = useMemo(() => { if (!activePage || activePage.type !== "file") return []; @@ -125,6 +138,27 @@ export default function Home() { }; }, [flushPendingSyncs]); + useEffect(() => { + if (!activePageId) return; + const current = pages.find((p) => p.id === activePageId); + if (!current || current.type === "folder") return; + if (current.contentLoaded) return; + fetchPageContent(activePageId); + }, [activePageId, pages, fetchPageContent]); + + useEffect(() => { + if (!isLinksOpen) return; + const batchIds = pages + .filter((p) => p.type === "file" && !p.contentLoaded) + .slice(0, 8) + .map((p) => p.id); + if (batchIds.length === 0) return; + + Promise.all(batchIds.map((id) => fetchPageContent(id))).catch((e) => { + console.error("Failed to batch load pages for link analysis", e); + }); + }, [isLinksOpen, pages, fetchPageContent]); + const handleEdgeSwipeStart = (e: React.TouchEvent) => { if (isMobileSidebarOpen) return; const touch = e.touches[0]; @@ -411,80 +445,101 @@ export default function Home() { />
-
-
-
-

关联页面

- {outgoingLinks.length} +
+ - )) - ) : ( - 暂无 - )} -
-
+ + +

展开后计算关联页面、反向链接和未解析链接。

+ {isLinksOpen && ( +
+
+
+

关联页面

+ {outgoingLinks.length} +
+

正文写 `[[页面名]]` 可自动关联。

+
+ {outgoingLinks.length > 0 ? ( + outgoingLinks.map((item) => ( + + )) + ) : ( + 暂无 + )} +
+
-
-
-

反向链接

- {backlinks.length} -
-

被哪些页面引用。

-
- {backlinks.length > 0 ? ( - backlinks.map((item) => ( - - )) - ) : ( - 暂无 - )} -
-
+
+
+

反向链接

+ {backlinks.length} +
+

被哪些页面引用。

+
+ {backlinks.length > 0 ? ( + backlinks.map((item) => ( + + )) + ) : ( + 暂无 + )} +
+
-
-
-

未解析链接

- {unresolvedLinks.length} +
+
+

未解析链接

+ {unresolvedLinks.length} +
+

可一键创建缺失页面。

+
+ {unresolvedLinks.length > 0 ? ( + unresolvedLinks.map((title) => ( + + )) + ) : ( + 全部已解析 + )} +
-

可一键创建缺失页面。

-
- {unresolvedLinks.length > 0 ? ( - unresolvedLinks.map((title) => ( - - )) - ) : ( - 全部已解析 - )} -
-
+
+ )}
diff --git a/src/components/editor/code-block.tsx b/src/components/editor/code-block.tsx index bf1840b..feb1bf3 100644 --- a/src/components/editor/code-block.tsx +++ b/src/components/editor/code-block.tsx @@ -55,7 +55,7 @@ export function CodeBlockComponent({ return (
@@ -86,7 +86,7 @@ export function CodeBlockComponent({
-
+
{lineNumbers.map((line) => (
{line} @@ -94,7 +94,7 @@ export function CodeBlockComponent({ ))}
-
+                
                      void;
     onToggleSidebarCollapse?: () => void;
 }) {
-    const { pages, activePageId, fetchPages, addPage, isLoading, movePage, reorderPages } = useEditorStore();
+    const pages = useEditorStore((s) => s.pages);
+    const activePageId = useEditorStore((s) => s.activePageId);
+    const fetchPages = useEditorStore((s) => s.fetchPages);
+    const addPage = useEditorStore((s) => s.addPage);
+    const isLoading = useEditorStore((s) => s.isLoading);
+    const movePage = useEditorStore((s) => s.movePage);
+    const reorderPages = useEditorStore((s) => s.reorderPages);
     const { setOpen, openSearchWithTag } = useSearchStore();
     const { triggerImport, isImporting } = useImport();
     const { theme, setTheme } = useTheme();
@@ -359,18 +365,7 @@ export function SidebarContent({
                                         }}
                                     >
                                         
-                                        {isImporting ? "导入中..." : "导入到当前层级"}
-                                    
-                                     {
-                                            triggerImport(null);
-                                            handleItemClick();
-                                        }}
-                                    >
-                                        
-                                        {isImporting ? "导入中..." : "导入到根目录"}
+                                        {isImporting ? "导入中..." : "导入文档"}
                                     
                                     
                                      void;
     level?: number;
     folderFileCount: Record;
+    hasChildrenSet?: Set;
 }
 
 function buildFolderFileCount(pages: Page[]): Record {
@@ -65,6 +66,14 @@ function buildFolderFileCount(pages: Page[]): Record {
     return counts;
 }
 
+function buildHasChildrenSet(pages: Page[]): Set {
+    const result = new Set();
+    for (const page of pages) {
+        if (page.parentId) result.add(page.parentId);
+    }
+    return result;
+}
+
 function TreeNode({
     node,
     pages,
@@ -72,6 +81,7 @@ function TreeNode({
     expanded,
     toggleExpand,
     folderFileCount,
+    hasChildrenSet,
 }: {
     node: Page;
     pages: Page[];
@@ -79,15 +89,19 @@ function TreeNode({
     expanded: Record;
     toggleExpand: (id: string) => void;
     folderFileCount: Record;
+    hasChildrenSet: Set;
 }) {
-    const { activePageId, setActivePageId, addPage, deletePage } = useEditorStore();
+    const activePageId = useEditorStore((s) => s.activePageId);
+    const setActivePageId = useEditorStore((s) => s.setActivePageId);
+    const addPage = useEditorStore((s) => s.addPage);
+    const deletePage = useEditorStore((s) => s.deletePage);
     const router = useRouter();
     const pathname = usePathname();
     const { triggerImport } = useImport();
     const confirm = useConfirm();
 
     const isFolder = node.type === "folder";
-    const hasChildren = pages.some((p) => p.parentId === node.id);
+    const hasChildren = hasChildrenSet.has(node.id);
     const isExpanded = expanded[node.id];
     const isActive = activePageId === node.id;
 
@@ -259,14 +273,16 @@ function TreeNode({
                     expanded={expanded}
                     toggleExpand={toggleExpand}
                     folderFileCount={folderFileCount}
+                    hasChildrenSet={hasChildrenSet}
                 />
             )}
         
); } -export function TreeView({ pages, parentId, expanded, toggleExpand, level = 0, folderFileCount }: TreeViewProps) { +export function TreeView({ pages, parentId, expanded, toggleExpand, level = 0, folderFileCount, hasChildrenSet }: TreeViewProps) { const computedFolderFileCount = level === 0 ? buildFolderFileCount(pages) : folderFileCount; + const computedHasChildrenSet = level === 0 ? buildHasChildrenSet(pages) : hasChildrenSet || new Set(); const nodes = pages .filter((p) => p.parentId === parentId) .sort((a, b) => (a.order || 0) - (b.order || 0)); @@ -285,6 +301,7 @@ export function TreeView({ pages, parentId, expanded, toggleExpand, level = 0, f expanded={expanded} toggleExpand={toggleExpand} folderFileCount={computedFolderFileCount} + hasChildrenSet={computedHasChildrenSet} /> ))}
diff --git a/src/lib/store.ts b/src/lib/store.ts index dbaf634..aa6efd0 100644 --- a/src/lib/store.ts +++ b/src/lib/store.ts @@ -15,6 +15,7 @@ export interface Page { tags?: string[]; order?: number; isLocked?: boolean; + contentLoaded?: boolean; } export type PageSyncStatus = "saved" | "saving" | "pending" | "error"; @@ -31,7 +32,8 @@ interface EditorState { isLoading: boolean; pageSync: Record; - fetchPages: () => Promise; + fetchPages: (lightweight?: boolean) => Promise; + fetchPageContent: (id: string) => Promise; setActivePageId: (id: string | null) => void; addPage: ( parentId?: string | null, @@ -61,6 +63,8 @@ const CONTENT_RETRY_MAX_MS = 30000; const pendingContentTimers = new Map>(); const pendingContentPayloads = new Map(); +const snapshotCheckAtByPage = new Map(); +const SNAPSHOT_CHECK_INTERVAL_MS = 5000; function setPageSyncMeta(set: StoreSet, id: string, meta: PageSyncMeta): void { set((state) => ({ @@ -153,13 +157,30 @@ export const useEditorStore = create()( isLoading: false, pageSync: {}, - fetchPages: async () => { + fetchPages: async (lightweight = true) => { set({ isLoading: true }); try { - const res = await fetch("/api/pages"); + const query = lightweight ? "?lightweight=true" : ""; + const res = await fetch(`/api/pages${query}`); if (res.ok) { const data = await res.json(); - set({ pages: data }); + const prevById = new Map(get().pages.map((p) => [p.id, p])); + const merged = (Array.isArray(data) ? data : []).map((page: Page) => { + const prev = prevById.get(page.id); + const hasLoadedContent = typeof prev?.content === "string" && (prev.content.length > 0 || prev.contentLoaded); + if (lightweight && hasLoadedContent) { + return { + ...page, + content: prev?.content || "", + contentLoaded: true, + }; + } + return { + ...page, + contentLoaded: lightweight ? false : true, + }; + }); + set({ pages: merged }); } } catch (e) { console.error("Failed to fetch pages", e); @@ -168,6 +189,32 @@ export const useEditorStore = create()( } }, + fetchPageContent: async (id) => { + try { + const existing = get().pages.find((p) => p.id === id); + if (!existing || existing.type === "folder") return; + if (existing.contentLoaded) return; + + const res = await fetch(`/api/pages/${id}`); + if (!res.ok) return; + const page = (await res.json()) as Page; + set((state) => ({ + pages: state.pages.map((p) => + p.id === id + ? { + ...p, + content: page.content || "", + updatedAt: page.updatedAt || p.updatedAt, + contentLoaded: true, + } + : p + ), + })); + } catch (e) { + console.error("Failed to fetch page content", e); + } + }, + setActivePageId: (id) => set({ activePageId: id }), addPage: async (parentId = null, type = "file", initialData, order) => { @@ -186,12 +233,13 @@ export const useEditorStore = create()( }); if (res.ok) { const newPage = await res.json(); + const hydratedPage = { ...newPage, contentLoaded: true }; set((state) => ({ - pages: [newPage, ...state.pages], - activePageId: newPage.id, + pages: [hydratedPage, ...state.pages], + activePageId: hydratedPage.id, pageSync: { ...state.pageSync, - [newPage.id]: { + [hydratedPage.id]: { status: "saved", message: "已保存", lastSyncedAt: new Date().toISOString(), @@ -210,15 +258,33 @@ export const useEditorStore = create()( const isContentOnlyUpdate = data.content !== undefined && Object.keys(data).every((key) => key === "content"); if (current && (data.content !== undefined || data.title !== undefined)) { - capturePageSnapshot({ - id: current.id, - title: current.title, - content: current.content, - }); + const now = Date.now(); + const titleChanged = data.title !== undefined && data.title !== current.title; + const contentChanged = data.content !== undefined && data.content !== current.content; + const lastCheckedAt = snapshotCheckAtByPage.get(current.id) ?? 0; + const shouldCheckSnapshot = titleChanged || (contentChanged && now - lastCheckedAt >= SNAPSHOT_CHECK_INTERVAL_MS); + + if (shouldCheckSnapshot) { + snapshotCheckAtByPage.set(current.id, now); + capturePageSnapshot({ + id: current.id, + title: current.title, + content: current.content, + }); + } } set((state) => ({ - pages: state.pages.map((p) => (p.id === id ? { ...p, ...data, updatedAt: new Date().toISOString() } : p)), + pages: state.pages.map((p) => + p.id === id + ? { + ...p, + ...data, + updatedAt: new Date().toISOString(), + contentLoaded: data.content !== undefined ? true : p.contentLoaded, + } + : p + ), })); if (isContentOnlyUpdate) {