feat: enhance note creation and editing experience

- Added dynamic title extraction from the first block of BlockNote documents for NOTE type.
- Updated editor routes to support new note creation with a BlockNote editor.
- Implemented unsaved changes dialog for better user experience when navigating away from the editor.
- Refactored BlockNoteEditor to ensure the first block is a heading when creating new notes.
- Removed the old note creation page in favor of the new streamlined editor experience.
This commit is contained in:
Anish Sarkar 2025-12-16 13:57:06 +05:30
parent 8eceb7a5cb
commit e1e813702a
6 changed files with 386 additions and 173 deletions

View file

@ -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)

View file

@ -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<any>(null);
const [error, setError] = useState<string | null>(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 (
<div className="flex items-center justify-center min-h-[400px] p-6">
@ -217,15 +272,15 @@ export default function EditorPage() {
<div className="flex items-center gap-3 flex-1 min-w-0">
<FileText className="h-5 w-5 text-muted-foreground shrink-0" />
<div className="flex flex-col min-w-0">
<h1 className="text-lg font-semibold truncate">{document.title}</h1>
<h1 className="text-lg font-semibold truncate">{displayTitle}</h1>
{hasUnsavedChanges && <p className="text-xs text-muted-foreground">Unsaved changes</p>}
</div>
</div>
<Separator orientation="vertical" className="h-6" />
<div className="flex items-center gap-2">
<Button variant="outline" onClick={handleCancel} disabled={saving} className="gap-2">
<X className="h-4 w-4" />
Cancel
<Button variant="outline" onClick={handleBack} disabled={saving} className="gap-2">
<ArrowLeft className="h-4 w-4" />
Back
</Button>
<Button onClick={handleSave} disabled={saving} className="gap-2">
{saving ? (
@ -247,10 +302,30 @@ export default function EditorPage() {
<div className="flex-1 overflow-hidden relative">
<div className="h-full w-full overflow-auto p-6">
<div className="max-w-4xl mx-auto">
<BlockNoteEditor initialContent={editorContent} onChange={setEditorContent} />
<BlockNoteEditor
initialContent={editorContent}
onChange={setEditorContent}
useTitleBlock={isNote}
/>
</div>
</div>
</div>
{/* Unsaved Changes Dialog */}
<AlertDialog open={showUnsavedDialog} onOpenChange={setShowUnsavedDialog}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Unsaved Changes</AlertDialogTitle>
<AlertDialogDescription>
You have unsaved changes. Are you sure you want to leave?
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={handleConfirmLeave}>OK</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</motion.div>
);
}

View file

@ -0,0 +1,175 @@
"use client";
import { AlertCircle, ArrowLeft, FileText, Loader2, Plus } from "lucide-react";
import { motion } from "motion/react";
import { useParams, useRouter } from "next/navigation";
import { useMemo, useState } from "react";
import { toast } from "sonner";
import { BlockNoteEditor } from "@/components/DynamicBlockNoteEditor";
import { Button } from "@/components/ui/button";
import { Separator } from "@/components/ui/separator";
import { notesApiService } from "@/lib/apis/notes-api.service";
import { authenticatedFetch, getBearerToken, redirectToLogin } from "@/lib/auth-utils";
// Helper function to extract title from BlockNote document
// Takes the text content from the first block (should be a heading)
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 NewNotePage() {
const params = useParams();
const router = useRouter();
const searchSpaceId = Number(params.search_space_id);
const [editorContent, setEditorContent] = useState<any>(null);
const [creating, setCreating] = useState(false);
const [error, setError] = useState<string | null>(null);
// Extract title dynamically from editor content
const dynamicTitle = useMemo(() => {
return extractTitleFromBlockNote(editorContent);
}, [editorContent]);
const handleCreate = async () => {
setCreating(true);
setError(null);
try {
// Extract title from first block of editor content
const title = extractTitleFromBlockNote(editorContent);
// Create the note first
const note = await notesApiService.createNote({
search_space_id: searchSpaceId,
title: title,
blocknote_document: editorContent || undefined,
});
// If there's content, save it properly and trigger reindexing
if (editorContent) {
const token = getBearerToken();
if (!token) {
toast.error("Please login to save");
redirectToLogin();
return;
}
// Call the save endpoint to properly save blocknote_document and trigger reindexing
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({ blocknote_document: editorContent }),
}
);
if (!response.ok) {
const errorData = await response
.json()
.catch(() => ({ detail: "Failed to save document" }));
throw new Error(errorData.detail || "Failed to save document");
}
}
toast.success("Note created successfully! Reindexing in background...");
// 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 handleBack = () => {
router.back();
};
return (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
className="flex flex-col h-full w-full"
>
{/* Toolbar */}
<div className="sticky top-0 z-40 flex h-16 shrink-0 items-center gap-4 border-b bg-background/95 backdrop-blur supports-backdrop-filter:bg-background/60 px-6">
<div className="flex items-center gap-3 flex-1 min-w-0">
<FileText className="h-5 w-5 text-muted-foreground shrink-0" />
<div className="flex flex-col min-w-0">
<h1 className="text-lg font-semibold truncate">{dynamicTitle}</h1>
</div>
</div>
<Separator orientation="vertical" className="h-6" />
<div className="flex items-center gap-2">
<Button variant="outline" onClick={handleBack} disabled={creating} className="gap-2">
<ArrowLeft className="h-4 w-4" />
Back
</Button>
<Button onClick={handleCreate} disabled={creating} className="gap-2">
{creating ? (
<>
<Loader2 className="h-4 w-4 animate-spin" />
Creating...
</>
) : (
<>
<Plus className="h-4 w-4" />
Create Note
</>
)}
</Button>
</div>
</div>
{/* Editor Container - matches editor page layout */}
<div className="flex-1 overflow-hidden relative">
<div className="h-full w-full overflow-auto p-6">
{error && (
<motion.div
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
className="mb-6 max-w-4xl mx-auto"
>
<div className="flex items-center gap-2 p-4 rounded-lg border border-destructive/50 bg-destructive/10 text-destructive">
<AlertCircle className="h-5 w-5 shrink-0" />
<p className="text-sm">{error}</p>
</div>
</motion.div>
)}
<div className="max-w-4xl mx-auto">
<BlockNoteEditor initialContent={undefined} onChange={setEditorContent} useTitleBlock={true} />
</div>
</div>
</div>
</motion.div>
);
}

View file

@ -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<any>(null);
const [creating, setCreating] = useState(false);
const [error, setError] = useState<string | null>(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 (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
className="flex flex-col h-full w-full"
>
{/* Toolbar */}
<div className="sticky top-0 z-40 flex h-16 shrink-0 items-center gap-4 border-b bg-background/95 backdrop-blur supports-backdrop-filter:bg-background/60 px-6">
<div className="flex items-center gap-3 flex-1 min-w-0">
<FileText className="h-5 w-5 text-muted-foreground shrink-0" />
<div className="flex flex-col min-w-0">
<h1 className="text-lg font-semibold truncate">New Note</h1>
<p className="text-xs text-muted-foreground">Create a new note</p>
</div>
</div>
<Separator orientation="vertical" className="h-6" />
<div className="flex items-center gap-2">
<Button variant="outline" onClick={handleCancel} disabled={creating} className="gap-2">
<X className="h-4 w-4" />
Cancel
</Button>
<Button onClick={handleCreate} disabled={creating || !title.trim()} className="gap-2">
{creating ? (
<>
<Loader2 className="h-4 w-4 animate-spin" />
Creating...
</>
) : (
<>
<Plus className="h-4 w-4" />
Create Note
</>
)}
</Button>
</div>
</div>
{/* Content */}
<div className="flex-1 overflow-hidden relative">
<div className="h-full w-full overflow-auto p-6">
<div className="max-w-4xl mx-auto space-y-6">
{error && (
<motion.div
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
>
<Card className="border-destructive/50">
<CardHeader>
<div className="flex items-center gap-2">
<AlertCircle className="h-5 w-5 text-destructive" />
<CardTitle className="text-destructive">Error</CardTitle>
</div>
<CardDescription>{error}</CardDescription>
</CardHeader>
</Card>
</motion.div>
)}
<Card>
<CardHeader>
<CardTitle>Note Details</CardTitle>
<CardDescription>Enter a title for your note</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="title">Title</Label>
<Input
id="title"
placeholder="Enter note title..."
value={title}
onChange={(e) => setTitle(e.target.value)}
disabled={creating}
className="text-lg"
/>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Content</CardTitle>
<CardDescription>Start writing your note (optional)</CardDescription>
</CardHeader>
<CardContent>
<div className="min-h-[400px]">
<BlockNoteEditor initialContent={undefined} onChange={setEditorContent} />
</div>
</CardContent>
</Card>
</div>
</div>
</div>
</motion.div>
);
}

View file

@ -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<any>(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();
};

View file

@ -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