"use client"; import { useCallback, useEffect, useRef, useState } from "react"; import Link from "next/link"; import { useRouter } from "next/navigation"; import { useQueryClient } from "@tanstack/react-query"; import type { Editor as TEditor } from "@tiptap/react"; import { Editor } from "@/components/editor/Editor"; import { api } from "@/lib/client/api"; import { toast } from "@/components/ui/toast"; import { Clock, Link2, MoreHorizontal, Trash2, RotateCcw, Image as ImageIcon, Smile, Star, Copy, Download, History, FileDown, Sparkles, Check } from "lucide-react"; import { Menu, MenuTrigger, MenuContent, MenuItem, MenuSeparator, Dialog, DialogContent, Badge, Popover, PopoverTrigger, PopoverContent } from "@/components/ui/primitives"; import { Button } from "@/components/ui/button"; import { timeAgo, cn, debounce } from "@/lib/utils"; import { markdownToDoc } from "@/lib/editor/markdown-client"; import { useUI } from "@/lib/client/store"; import { DatabaseView } from "@/components/database/DatabaseView"; import { PageProperties } from "@/components/page/PageProperties"; import { ResearchPanel } from "@/components/research/ResearchPanel"; import dynamic from "next/dynamic"; const PageGraphCard = dynamic(() => import("@/components/graph/PageGraphCard").then((m) => m.PageGraphCard), { ssr: false }); const EMOJI = ["๐Ÿ“„", "๐Ÿ“", "๐Ÿ“š", "๐Ÿ”ฌ", "๐Ÿง ", "๐Ÿ’ก", "๐Ÿ“Š", "๐Ÿ“ˆ", "๐Ÿ—‚", "๐Ÿงช", "๐Ÿ ", "๐Ÿฆ", "๐Ÿ“", "๐Ÿงฎ", "๐Ÿ—“", "โœ…", "๐Ÿš€", "โญ", "๐Ÿ”ฅ", "๐ŸŽฏ", "๐Ÿงญ", "๐Ÿ—บ", "๐Ÿ’ฌ", "๐Ÿค–", "๐Ÿงฉ", "๐Ÿ“Ž", "๐Ÿ”", "๐ŸŒ", "๐Ÿ›", "๐ŸŽ“"]; export function PageView({ page, breadcrumb, backlinks }: { page: any; breadcrumb: { id: string; title: string; icon: string | null }[]; backlinks: any[] }) { const r = useRouter(); const qc = useQueryClient(); const ui = useUI(); const [title, setTitle] = useState(page.title === "Untitled" ? "" : page.title); const [icon, setIcon] = useState(page.icon); const [cover, setCover] = useState(page.cover); const [status, setStatus] = useState<"saved" | "saving" | "error" | "offline">("saved"); const [versions, setVersions] = useState(null); const [preview, setPreview] = useState(null); const editorRef = useRef(null); const pending = useRef(null); const inflight = useRef(false); const flush = useCallback(async () => { if (!pending.current || inflight.current) return; const patch = pending.current; pending.current = null; inflight.current = true; setStatus("saving"); try { await api(`/api/pages/${page.id}`, { method: "PATCH", json: patch }); setStatus("saved"); if (patch.title !== undefined || patch.icon !== undefined) qc.invalidateQueries({ queryKey: ["tree"] }); } catch (e: any) { pending.current = { ...patch, ...(pending.current ?? {}) }; setStatus(navigator.onLine ? "error" : "offline"); try { localStorage.setItem(`note-pending-${page.id}`, JSON.stringify(pending.current)); } catch {} } finally { inflight.current = false; if (pending.current) setTimeout(flush, 1500); } }, [page.id, qc]); const schedule = useRef(debounce(flush, 800)).current; const queue = useCallback((patch: any) => { pending.current = { ...(pending.current ?? {}), ...patch }; setStatus("saving"); schedule(); }, [schedule]); useEffect(() => { const h = () => { if (pending.current) { navigator.sendBeacon?.(`/api/pages/${page.id}/beacon`, JSON.stringify(pending.current)); } }; window.addEventListener("pagehide", h); const online = () => flush(); window.addEventListener("online", online); return () => { window.removeEventListener("pagehide", h); window.removeEventListener("online", online); flush(); }; }, [flush, page.id]); useEffect(() => { try { const saved = localStorage.getItem(`note-pending-${page.id}`); if (saved) { localStorage.removeItem(`note-pending-${page.id}`); pending.current = JSON.parse(saved); flush(); } } catch {} }, [page.id, flush]); useEffect(() => { const h = (e: Event) => { const md = (e as CustomEvent).detail as string; const ed = editorRef.current; if (!ed) return; ed.chain().focus("end").insertContent([{ type: "aiBlock", attrs: { model: "assistant", at: new Date().toISOString() }, content: markdownToDoc(md) }]).run(); toast("Inserted into page"); }; window.addEventListener("note:insert-markdown", h); return () => window.removeEventListener("note:insert-markdown", h); }, []); useEffect(() => { if (location.hash.startsWith("#block-")) setTimeout(() => { const el = document.getElementById(location.hash.slice(1)); if (el) { el.scrollIntoView({ block: "center" }); el.classList.add("bg-accent-soft"); setTimeout(() => el.classList.remove("bg-accent-soft"), 2000); } }, 300); }, []); async function loadVersions() { setVersions(await api(`/api/pages/${page.id}/versions`)); } async function restore(v: number) { await api(`/api/pages/${page.id}/versions`, { method: "POST", json: { version: v } }); toast(`Restored version ${v}`); r.refresh(); setVersions(null); setPreview(null); } const isTrashed = Boolean(page.deletedAt); const isDb = page.type === "database"; return (
{cover &&
}
{breadcrumb.slice(0, -1).map((b) => {b.icon} {b.title}/)} {title || "Untitled"} {status === "saved" ? : status === "saving" ? : null}{status === "saved" ? `Saved ยท ${timeAgo(page.updatedAt)}` : status === "saving" ? "Savingโ€ฆ" : status === "offline" ? "Offline โ€” will retry" : "Save failed โ€” retrying"} {page.wordCount} words { await api(`/api/pages/${page.id}`, { method: "PATCH", json: { pinned: !page.pinned } }); qc.invalidateQueries({ queryKey: ["tree"] }); r.refresh(); }}> {page.pinned ? "Unpin" : "Pin"} Version history { const p = await api(`/api/pages/${page.id}?action=duplicate`, { method: "POST" }); qc.invalidateQueries({ queryKey: ["tree"] }); r.push(`/p/${p.id}`); }}> Duplicate window.open(`/api/export?format=md&pageId=${page.id}`, "_blank")}> Export Markdown window.open(`/api/export?format=pdf&pageId=${page.id}`, "_blank")}> Print / PDF {isTrashed ? { await api(`/api/pages/${page.id}?action=restore`, { method: "POST" }); qc.invalidateQueries({ queryKey: ["tree"] }); r.refresh(); }}> Restore : { await api(`/api/pages/${page.id}`, { method: "DELETE" }); qc.invalidateQueries({ queryKey: ["tree"] }); toast("Moved to trash"); r.push("/"); }}> Move to trash}
{isTrashed &&
This page is in the trash.
}
{!icon &&
{EMOJI.map((e) => )}
} {!cover && }
{icon &&
{EMOJI.map((e) => )}
}