diff --git a/surfsense_web/app/dashboard/[search_space_id]/documents/(manage)/components/RowActions.tsx b/surfsense_web/app/dashboard/[search_space_id]/documents/(manage)/components/RowActions.tsx index d44fae373..d05dc7f7d 100644 --- a/surfsense_web/app/dashboard/[search_space_id]/documents/(manage)/components/RowActions.tsx +++ b/surfsense_web/app/dashboard/[search_space_id]/documents/(manage)/components/RowActions.tsx @@ -1,9 +1,10 @@ "use client"; +import { useSetAtom } from "jotai"; import { MoreHorizontal, PenLine, Trash2 } from "lucide-react"; -import { useRouter } from "next/navigation"; import { useState } from "react"; import { toast } from "sonner"; +import { openEditorPanelAtom } from "@/atoms/editor/editor-panel.atom"; import { AlertDialog, AlertDialogAction, @@ -40,7 +41,7 @@ export function RowActions({ }) { const [isDeleteOpen, setIsDeleteOpen] = useState(false); const [isDeleting, setIsDeleting] = useState(false); - const router = useRouter(); + const openEditorPanel = useSetAtom(openEditorPanelAtom); const isEditable = EDITABLE_DOCUMENT_TYPES.includes( document.document_type as (typeof EDITABLE_DOCUMENT_TYPES)[number] @@ -87,7 +88,11 @@ export function RowActions({ }; const handleEdit = () => { - router.push(`/dashboard/${searchSpaceId}/editor/${document.id}`); + openEditorPanel({ + documentId: document.id, + searchSpaceId: Number(searchSpaceId), + title: document.title, + }); }; return ( diff --git a/surfsense_web/app/dashboard/[search_space_id]/editor/[documentId]/page.tsx b/surfsense_web/app/dashboard/[search_space_id]/editor/[documentId]/page.tsx deleted file mode 100644 index 6bad6112a..000000000 --- a/surfsense_web/app/dashboard/[search_space_id]/editor/[documentId]/page.tsx +++ /dev/null @@ -1,505 +0,0 @@ -"use client"; - -import { useAtom } from "jotai"; -import { AlertCircle, ArrowLeft, FileText } from "lucide-react"; -import { motion } from "motion/react"; -import dynamic from "next/dynamic"; -import { useParams, useRouter } from "next/navigation"; -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { toast } from "sonner"; -import { hasUnsavedEditorChangesAtom, pendingEditorNavigationAtom } from "@/atoms/editor/ui.atoms"; -import { - AlertDialog, - AlertDialogAction, - AlertDialogCancel, - AlertDialogContent, - AlertDialogDescription, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogTitle, -} from "@/components/ui/alert-dialog"; -import { Button, buttonVariants } from "@/components/ui/button"; -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; -import { Skeleton } from "@/components/ui/skeleton"; -import { notesApiService } from "@/lib/apis/notes-api.service"; -import { authenticatedFetch, getBearerToken, redirectToLogin } from "@/lib/auth-utils"; - -// Dynamically import PlateEditor (uses 'use client' internally) -const PlateEditor = dynamic( - () => import("@/components/editor/plate-editor").then((mod) => ({ default: mod.PlateEditor })), - { - ssr: false, - loading: () => ( -
- -
- - - -
-
- - - -
-
- - -
-
- ), - } -); - -interface EditorContent { - document_id: number; - title: string; - document_type?: string; - source_markdown: string; - updated_at: string | null; -} - -/** Extract title from markdown: first # heading, or first non-empty line. */ -function extractTitleFromMarkdown(markdown: string | null | undefined): string { - if (!markdown) return "Untitled"; - for (const line of markdown.split("\n")) { - const trimmed = line.trim(); - if (trimmed.startsWith("# ")) return trimmed.slice(2).trim() || "Untitled"; - if (trimmed) return trimmed.slice(0, 100); - } - return "Untitled"; -} - -export default function EditorPage() { - const params = useParams(); - const router = useRouter(); - const documentId = params.documentId as string; - const searchSpaceId = Number(params.search_space_id); - const isNewNote = documentId === "new"; - - const [document, setDocument] = useState(null); - const [loading, setLoading] = useState(true); - const [saving, setSaving] = useState(false); - const [error, setError] = useState(null); - const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false); - const [showUnsavedDialog, setShowUnsavedDialog] = useState(false); - const [editorTitle, setEditorTitle] = useState("Untitled"); - - // Store the latest markdown from the editor - const markdownRef = useRef(""); - const initialLoadDone = useRef(false); - - // Global state for cross-component communication - const [, setGlobalHasUnsavedChanges] = useAtom(hasUnsavedEditorChangesAtom); - const [pendingNavigation, setPendingNavigation] = useAtom(pendingEditorNavigationAtom); - - // Sync local unsaved changes state with global atom - useEffect(() => { - setGlobalHasUnsavedChanges(hasUnsavedChanges); - }, [hasUnsavedChanges, setGlobalHasUnsavedChanges]); - - // Cleanup global state when component unmounts - useEffect(() => { - return () => { - setGlobalHasUnsavedChanges(false); - setPendingNavigation(null); - }; - }, [setGlobalHasUnsavedChanges, setPendingNavigation]); - - // Handle pending navigation from sidebar - useEffect(() => { - if (pendingNavigation) { - if (hasUnsavedChanges) { - setShowUnsavedDialog(true); - } else { - router.push(pendingNavigation); - setPendingNavigation(null); - } - } - }, [pendingNavigation, hasUnsavedChanges, router, setPendingNavigation]); - - // Reset state and fetch document content when documentId changes - useEffect(() => { - setDocument(null); - setError(null); - setHasUnsavedChanges(false); - setLoading(true); - initialLoadDone.current = false; - - async function fetchDocument() { - if (isNewNote) { - markdownRef.current = ""; - setEditorTitle("Untitled"); - setDocument({ - document_id: 0, - title: "Untitled", - document_type: "NOTE", - source_markdown: "", - updated_at: null, - }); - setLoading(false); - initialLoadDone.current = true; - return; - } - - const token = getBearerToken(); - if (!token) { - redirectToLogin(); - return; - } - - try { - const response = await authenticatedFetch( - `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/search-spaces/${params.search_space_id}/documents/${documentId}/editor-content`, - { method: "GET" } - ); - - if (!response.ok) { - const errorData = await response - .json() - .catch(() => ({ detail: "Failed to fetch document" })); - throw new Error(errorData.detail || "Failed to fetch document"); - } - - const data = await response.json(); - - if (data.source_markdown === undefined || data.source_markdown === null) { - setError( - "This document does not have editable content. Please re-upload to enable editing." - ); - setLoading(false); - return; - } - - markdownRef.current = data.source_markdown; - setEditorTitle(extractTitleFromMarkdown(data.source_markdown)); - setDocument(data); - setError(null); - initialLoadDone.current = true; - } catch (error) { - console.error("Error fetching document:", error); - setError( - error instanceof Error ? error.message : "Failed to fetch document. Please try again." - ); - } finally { - setLoading(false); - } - } - - if (documentId) { - fetchDocument(); - } - }, [documentId, params.search_space_id, isNewNote]); - - const isNote = isNewNote || document?.document_type === "NOTE"; - - const displayTitle = useMemo(() => { - if (isNote) return editorTitle; - return document?.title || "Untitled"; - }, [isNote, document?.title, editorTitle]); - - // Handle markdown changes from the Plate editor - const handleMarkdownChange = useCallback((md: string) => { - markdownRef.current = md; - if (initialLoadDone.current) { - setHasUnsavedChanges(true); - setEditorTitle(extractTitleFromMarkdown(md)); - } - }, []); - - // Save handler - const handleSave = useCallback(async () => { - const token = getBearerToken(); - if (!token) { - toast.error("Please login to save"); - redirectToLogin(); - return; - } - - setSaving(true); - setError(null); - - try { - const currentMarkdown = markdownRef.current; - - if (isNewNote) { - const title = extractTitleFromMarkdown(currentMarkdown); - - // Create the note - const note = await notesApiService.createNote({ - search_space_id: searchSpaceId, - title, - source_markdown: currentMarkdown || undefined, - }); - - // If there's content, save & trigger reindexing - if (currentMarkdown) { - const response = await authenticatedFetch( - `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/search-spaces/${searchSpaceId}/documents/${note.id}/save`, - { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ source_markdown: currentMarkdown }), - } - ); - - if (!response.ok) { - const errorData = await response - .json() - .catch(() => ({ detail: "Failed to save document" })); - throw new Error(errorData.detail || "Failed to save document"); - } - } - - setHasUnsavedChanges(false); - toast.success("Note created successfully! Reindexing in background..."); - router.push(`/dashboard/${searchSpaceId}/new-chat`); - } else { - // Existing document — save - const response = await authenticatedFetch( - `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/search-spaces/${params.search_space_id}/documents/${documentId}/save`, - { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ source_markdown: currentMarkdown }), - } - ); - - if (!response.ok) { - const errorData = await response - .json() - .catch(() => ({ detail: "Failed to save document" })); - throw new Error(errorData.detail || "Failed to save document"); - } - - setHasUnsavedChanges(false); - toast.success("Document saved! Reindexing in background..."); - router.push(`/dashboard/${searchSpaceId}/new-chat`); - } - } catch (error) { - console.error("Error saving document:", error); - const errorMessage = - error instanceof Error - ? error.message - : isNewNote - ? "Failed to create note. Please try again." - : "Failed to save document. Please try again."; - setError(errorMessage); - toast.error(errorMessage); - } finally { - setSaving(false); - } - }, [isNewNote, searchSpaceId, documentId, params.search_space_id, router]); - - const handleBack = () => { - if (hasUnsavedChanges) { - setShowUnsavedDialog(true); - } else { - router.push(`/dashboard/${searchSpaceId}/new-chat`); - } - }; - - const handleConfirmLeave = () => { - setShowUnsavedDialog(false); - setGlobalHasUnsavedChanges(false); - setHasUnsavedChanges(false); - - if (pendingNavigation) { - router.push(pendingNavigation); - setPendingNavigation(null); - } else { - router.push(`/dashboard/${searchSpaceId}/new-chat`); - } - }; - - const handleSaveAndLeave = async () => { - setShowUnsavedDialog(false); - setPendingNavigation(null); - await handleSave(); - }; - - const handleCancelLeave = () => { - setShowUnsavedDialog(false); - setPendingNavigation(null); - }; - - if (loading) { - return ( -
- {/* Top bar skeleton — real back button & file icon, skeleton title */} -
-
- - - -
-
- - {/* Fixed toolbar placeholder — matches real toolbar styling */} -
- - {/* Content area skeleton — mimics document text lines */} -
-
- {/* Title-like line */} - - {/* Paragraph lines */} -
- - - -
-
- - - -
-
- - -
-
-
-
- ); - } - - if (error && !document) { - return ( -
- - - -
- - Error -
- {error} -
- - - -
-
-
- ); - } - - if (!document && !isNewNote) { - return ( -
- - - -

Document not found

-
-
-
- ); - } - - return ( - - {/* Toolbar */} -
-
- - -
-

{displayTitle}

- {hasUnsavedChanges && ( -

Unsaved changes

- )} -
-
-
- - {/* Editor Container */} -
- {error && ( - -
- -

{error}

-
-
- )} -
- -
-
- - {/* Unsaved Changes Dialog */} - { - if (!open) handleCancelLeave(); - }} - > - - - Unsaved Changes - - You have unsaved changes. Are you sure you want to leave? - - - - Cancel - - Leave without saving - - Save - - - -
- ); -} diff --git a/surfsense_web/atoms/editor/ui.atoms.ts b/surfsense_web/atoms/editor/ui.atoms.ts deleted file mode 100644 index 81a89a945..000000000 --- a/surfsense_web/atoms/editor/ui.atoms.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { atom } from "jotai"; - -interface EditorUIState { - hasUnsavedChanges: boolean; - pendingNavigation: string | null; // URL to navigate to after user confirms -} - -export const editorUIAtom = atom({ - hasUnsavedChanges: false, - pendingNavigation: null, -}); - -// Derived atom for just the unsaved changes state -export const hasUnsavedEditorChangesAtom = atom( - (get) => get(editorUIAtom).hasUnsavedChanges, - (get, set, value: boolean) => { - set(editorUIAtom, { ...get(editorUIAtom), hasUnsavedChanges: value }); - } -); - -// Derived atom for pending navigation -export const pendingEditorNavigationAtom = atom( - (get) => get(editorUIAtom).pendingNavigation, - (get, set, value: string | null) => { - set(editorUIAtom, { ...get(editorUIAtom), pendingNavigation: value }); - } -);