diff --git a/surfsense_backend/app/routes/editor_routes.py b/surfsense_backend/app/routes/editor_routes.py index 3d23f66a1..541a7a012 100644 --- a/surfsense_backend/app/routes/editor_routes.py +++ b/surfsense_backend/app/routes/editor_routes.py @@ -59,6 +59,7 @@ async def get_editor_content( return { "document_id": document.id, "title": document.title, + "document_type": document.document_type.value, "blocknote_document": document.blocknote_document, "updated_at": document.updated_at.isoformat() if document.updated_at @@ -82,6 +83,7 @@ async def get_editor_content( return { "document_id": document.id, "title": document.title, + "document_type": document.document_type.value, "blocknote_document": empty_blocknote, "updated_at": document.updated_at.isoformat() if document.updated_at else None, } @@ -123,6 +125,7 @@ async def get_editor_content( return { "document_id": document.id, "title": document.title, + "document_type": document.document_type.value, "blocknote_document": blocknote_json, "updated_at": document.updated_at.isoformat() if document.updated_at else None, } @@ -168,6 +171,27 @@ async def save_document( if not blocknote_document: raise HTTPException(status_code=400, detail="blocknote_document is required") + # For NOTE type documents, extract title from first block (heading) + if ( + document.document_type == DocumentType.NOTE + and blocknote_document + and len(blocknote_document) > 0 + ): + first_block = blocknote_document[0] + if first_block and first_block.get("content"): + # Extract text from first block content + title_parts = [] + for item in first_block["content"]: + if isinstance(item, str): + title_parts.append(item) + elif isinstance(item, dict) and "text" in item: + title_parts.append(item["text"]) + new_title = "".join(title_parts).strip() + if new_title: + document.title = new_title + else: + document.title = "Untitled" + # Save BlockNote document document.blocknote_document = blocknote_document document.updated_at = datetime.now(UTC) 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 index a6dbd4536..9e188b09a 100644 --- a/surfsense_web/app/dashboard/[search_space_id]/editor/[documentId]/page.tsx +++ b/surfsense_web/app/dashboard/[search_space_id]/editor/[documentId]/page.tsx @@ -1,11 +1,21 @@ "use client"; -import { AlertCircle, FileText, Loader2, Save, X } from "lucide-react"; +import { AlertCircle, ArrowLeft, FileText, Loader2, Save, X } from "lucide-react"; import { motion } from "motion/react"; import { useParams, useRouter } from "next/navigation"; -import { useEffect, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; import { toast } from "sonner"; import { BlockNoteEditor } from "@/components/DynamicBlockNoteEditor"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { Separator } from "@/components/ui/separator"; @@ -14,10 +24,40 @@ import { authenticatedFetch, getBearerToken, redirectToLogin } from "@/lib/auth- interface EditorContent { document_id: number; title: string; + document_type?: string; blocknote_document: any; updated_at: string | null; } +// Helper function to extract title from BlockNote document +// Takes the text content from the first block (should be a heading for notes) +function extractTitleFromBlockNote(blocknoteDocument: any[] | null | undefined): string { + if (!blocknoteDocument || !Array.isArray(blocknoteDocument) || blocknoteDocument.length === 0) { + return "Untitled"; + } + + const firstBlock = blocknoteDocument[0]; + if (!firstBlock) { + return "Untitled"; + } + + // Extract text from block content + // BlockNote blocks have a content array with inline content + if (firstBlock.content && Array.isArray(firstBlock.content)) { + const textContent = firstBlock.content + .map((item: any) => { + if (typeof item === "string") return item; + if (item?.text) return item.text; + return ""; + }) + .join("") + .trim(); + return textContent || "Untitled"; + } + + return "Untitled"; +} + export default function EditorPage() { const params = useParams(); const router = useRouter(); @@ -29,6 +69,7 @@ export default function EditorPage() { const [editorContent, setEditorContent] = useState(null); const [error, setError] = useState(null); const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false); + const [showUnsavedDialog, setShowUnsavedDialog] = useState(false); // Fetch document content - DIRECT CALL TO FASTAPI useEffect(() => { @@ -90,6 +131,17 @@ export default function EditorPage() { } }, [editorContent, document]); + // Check if this is a NOTE type document + const isNote = document?.document_type === "NOTE"; + + // Extract title dynamically from editor content for notes, otherwise use document title + const displayTitle = useMemo(() => { + if (isNote && editorContent) { + return extractTitleFromBlockNote(editorContent); + } + return document?.title || "Untitled"; + }, [isNote, editorContent, document?.title]); + // TODO: Maybe add Auto-save every 30 seconds - DIRECT CALL TO FASTAPI // Save and exit - DIRECT CALL TO FASTAPI @@ -142,16 +194,19 @@ export default function EditorPage() { } }; - const handleCancel = () => { + const handleBack = () => { if (hasUnsavedChanges) { - if (confirm("You have unsaved changes. Are you sure you want to leave?")) { - router.back(); - } + setShowUnsavedDialog(true); } else { router.back(); } }; + const handleConfirmLeave = () => { + setShowUnsavedDialog(false); + router.back(); + }; + if (loading) { return (
@@ -217,15 +272,15 @@ export default function EditorPage() {
-

{document.title}

+

{displayTitle}

{hasUnsavedChanges &&

Unsaved changes

}
- + +
+
+ + {/* Editor Container - matches editor page layout */} +
+
+ {error && ( + +
+ +

{error}

+
+
+ )} +
+ +
+
+
+ + ); +} \ No newline at end of file diff --git a/surfsense_web/app/dashboard/[search_space_id]/notes/new/page.tsx b/surfsense_web/app/dashboard/[search_space_id]/notes/new/page.tsx deleted file mode 100644 index c34b8167c..000000000 --- a/surfsense_web/app/dashboard/[search_space_id]/notes/new/page.tsx +++ /dev/null @@ -1,155 +0,0 @@ -"use client"; - -import { AlertCircle, FileText, Loader2, Plus, X } from "lucide-react"; -import { motion } from "motion/react"; -import { useParams, useRouter } from "next/navigation"; -import { useState } from "react"; -import { toast } from "sonner"; -import { BlockNoteEditor } from "@/components/DynamicBlockNoteEditor"; -import { Button } from "@/components/ui/button"; -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; -import { Input } from "@/components/ui/input"; -import { Label } from "@/components/ui/label"; -import { Separator } from "@/components/ui/separator"; -import { notesApiService } from "@/lib/apis/notes-api.service"; - -export default function NewNotePage() { - const params = useParams(); - const router = useRouter(); - const searchSpaceId = Number(params.search_space_id); - - const [title, setTitle] = useState(""); - const [editorContent, setEditorContent] = useState(null); - const [creating, setCreating] = useState(false); - const [error, setError] = useState(null); - - const handleCreate = async () => { - if (!title.trim()) { - toast.error("Please enter a title for your note"); - return; - } - - setCreating(true); - setError(null); - - try { - const note = await notesApiService.createNote({ - search_space_id: searchSpaceId, - title: title.trim(), - blocknote_document: editorContent || undefined, - }); - - toast.success("Note created successfully!"); - // Redirect to editor - router.push(`/dashboard/${searchSpaceId}/editor/${note.id}`); - } catch (error) { - console.error("Error creating note:", error); - const errorMessage = - error instanceof Error ? error.message : "Failed to create note. Please try again."; - setError(errorMessage); - toast.error(errorMessage); - } finally { - setCreating(false); - } - }; - - const handleCancel = () => { - router.back(); - }; - - return ( - - {/* Toolbar */} -
-
- -
-

New Note

-

Create a new note

-
-
- -
- - -
-
- - {/* Content */} -
-
-
- {error && ( - - - -
- - Error -
- {error} -
-
-
- )} - - - - Note Details - Enter a title for your note - - -
- - setTitle(e.target.value)} - disabled={creating} - className="text-lg" - /> -
-
-
- - - - Content - Start writing your note (optional) - - -
- -
-
-
-
-
-
-
- ); -} - diff --git a/surfsense_web/components/BlockNoteEditor.tsx b/surfsense_web/components/BlockNoteEditor.tsx index 8064a0dc4..04a54fd3e 100644 --- a/surfsense_web/components/BlockNoteEditor.tsx +++ b/surfsense_web/components/BlockNoteEditor.tsx @@ -10,31 +10,119 @@ import { useCreateBlockNote } from "@blocknote/react"; interface BlockNoteEditorProps { initialContent?: any; onChange?: (content: any) => void; + useTitleBlock?: boolean; // Whether to use first block as title (Notion-style) } -export default function BlockNoteEditor({ initialContent, onChange }: BlockNoteEditorProps) { +// Helper to ensure first block is a heading for title +function ensureTitleBlock(content: any[] | undefined): any[] { + if (!content || content.length === 0) { + // Return empty heading block for new notes + return [ + { + type: "heading", + props: { level: 1 }, + content: [], + children: [], + }, + ]; + } + + // If first block is not a heading, convert it to one + const firstBlock = content[0]; + if (firstBlock?.type !== "heading") { + // Extract text from first block + let titleText = ""; + if (firstBlock?.content && Array.isArray(firstBlock.content)) { + titleText = firstBlock.content + .map((item: any) => { + if (typeof item === "string") return item; + if (item?.text) return item.text; + return ""; + }) + .join("") + .trim(); + } + + // Create heading block with extracted text + const titleBlock = { + type: "heading", + props: { level: 1 }, + content: titleText + ? [ + { + type: "text", + text: titleText, + styles: {}, + }, + ] + : [], + children: [], + }; + + // Replace first block with heading, keep rest + return [titleBlock, ...content.slice(1)]; + } + + return content; +} + +export default function BlockNoteEditor({ initialContent, onChange, useTitleBlock = false }: BlockNoteEditorProps) { const { resolvedTheme } = useTheme(); // Track the initial content to prevent re-initialization const initialContentRef = useRef(null); const isInitializedRef = useRef(false); + // Prepare initial content - ensure first block is a heading if useTitleBlock is true + const preparedInitialContent = useMemo(() => { + if (initialContentRef.current !== null) { + return undefined; // Already initialized + } + if (initialContent === undefined) { + // New note - create empty heading block + return useTitleBlock + ? [ + { + type: "heading", + props: { level: 1 }, + content: [], + children: [], + }, + ] + : undefined; + } + // Existing note - ensure first block is heading + return useTitleBlock ? ensureTitleBlock(initialContent) : initialContent; + }, [initialContent, useTitleBlock]); + // Creates a new editor instance - only use initialContent on first render const editor = useCreateBlockNote({ - initialContent: initialContentRef.current === null ? initialContent || undefined : undefined, + initialContent: initialContentRef.current === null ? preparedInitialContent : undefined, }); // Store initial content on first render only useEffect(() => { - if (initialContent && initialContentRef.current === null) { - initialContentRef.current = initialContent; + if (preparedInitialContent !== undefined && initialContentRef.current === null) { + initialContentRef.current = preparedInitialContent; + isInitializedRef.current = true; + } else if (preparedInitialContent === undefined && initialContentRef.current === null) { + // Mark as initialized even when initialContent is undefined (for new notes) isInitializedRef.current = true; } - }, [initialContent]); + }, [preparedInitialContent]); // Call onChange when document changes (but don't update from props) useEffect(() => { - if (!onChange || !editor || !isInitializedRef.current) return; + if (!onChange || !editor) return; + + // For new notes (no initialContent), we need to wait for editor to be ready + // Use a small delay to ensure editor is fully initialized + if (!isInitializedRef.current) { + const timer = setTimeout(() => { + isInitializedRef.current = true; + }, 100); + return () => clearTimeout(timer); + } const handleChange = () => { onChange(editor.document); @@ -43,6 +131,12 @@ export default function BlockNoteEditor({ initialContent, onChange }: BlockNoteE // Subscribe to document changes const unsubscribe = editor.onChange(handleChange); + // Also call onChange once with current document to capture initial state + // This ensures we capture content even if user doesn't make changes + if (editor.document) { + onChange(editor.document); + } + return () => { unsubscribe(); }; diff --git a/surfsense_web/components/sidebar/AppSidebarProvider.tsx b/surfsense_web/components/sidebar/AppSidebarProvider.tsx index 27147a4b0..62c127120 100644 --- a/surfsense_web/components/sidebar/AppSidebarProvider.tsx +++ b/surfsense_web/components/sidebar/AppSidebarProvider.tsx @@ -213,7 +213,7 @@ export function AppSidebarProvider({ // Handle add note const handleAddNote = useCallback(() => { - router.push(`/dashboard/${searchSpaceId}/notes/new`); + router.push(`/dashboard/${searchSpaceId}/editor/new`); }, [router, searchSpaceId]); // Memoized updated navSecondary