mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-07-20 23:21:06 +02:00
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:
parent
8eceb7a5cb
commit
e1e813702a
6 changed files with 386 additions and 173 deletions
|
|
@ -59,6 +59,7 @@ async def get_editor_content(
|
||||||
return {
|
return {
|
||||||
"document_id": document.id,
|
"document_id": document.id,
|
||||||
"title": document.title,
|
"title": document.title,
|
||||||
|
"document_type": document.document_type.value,
|
||||||
"blocknote_document": document.blocknote_document,
|
"blocknote_document": document.blocknote_document,
|
||||||
"updated_at": document.updated_at.isoformat()
|
"updated_at": document.updated_at.isoformat()
|
||||||
if document.updated_at
|
if document.updated_at
|
||||||
|
|
@ -82,6 +83,7 @@ async def get_editor_content(
|
||||||
return {
|
return {
|
||||||
"document_id": document.id,
|
"document_id": document.id,
|
||||||
"title": document.title,
|
"title": document.title,
|
||||||
|
"document_type": document.document_type.value,
|
||||||
"blocknote_document": empty_blocknote,
|
"blocknote_document": empty_blocknote,
|
||||||
"updated_at": document.updated_at.isoformat() if document.updated_at else None,
|
"updated_at": document.updated_at.isoformat() if document.updated_at else None,
|
||||||
}
|
}
|
||||||
|
|
@ -123,6 +125,7 @@ async def get_editor_content(
|
||||||
return {
|
return {
|
||||||
"document_id": document.id,
|
"document_id": document.id,
|
||||||
"title": document.title,
|
"title": document.title,
|
||||||
|
"document_type": document.document_type.value,
|
||||||
"blocknote_document": blocknote_json,
|
"blocknote_document": blocknote_json,
|
||||||
"updated_at": document.updated_at.isoformat() if document.updated_at else None,
|
"updated_at": document.updated_at.isoformat() if document.updated_at else None,
|
||||||
}
|
}
|
||||||
|
|
@ -168,6 +171,27 @@ async def save_document(
|
||||||
if not blocknote_document:
|
if not blocknote_document:
|
||||||
raise HTTPException(status_code=400, detail="blocknote_document is required")
|
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
|
# Save BlockNote document
|
||||||
document.blocknote_document = blocknote_document
|
document.blocknote_document = blocknote_document
|
||||||
document.updated_at = datetime.now(UTC)
|
document.updated_at = datetime.now(UTC)
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,21 @@
|
||||||
"use client";
|
"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 { motion } from "motion/react";
|
||||||
import { useParams, useRouter } from "next/navigation";
|
import { useParams, useRouter } from "next/navigation";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { BlockNoteEditor } from "@/components/DynamicBlockNoteEditor";
|
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 { Button } from "@/components/ui/button";
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { Separator } from "@/components/ui/separator";
|
import { Separator } from "@/components/ui/separator";
|
||||||
|
|
@ -14,10 +24,40 @@ import { authenticatedFetch, getBearerToken, redirectToLogin } from "@/lib/auth-
|
||||||
interface EditorContent {
|
interface EditorContent {
|
||||||
document_id: number;
|
document_id: number;
|
||||||
title: string;
|
title: string;
|
||||||
|
document_type?: string;
|
||||||
blocknote_document: any;
|
blocknote_document: any;
|
||||||
updated_at: string | null;
|
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() {
|
export default function EditorPage() {
|
||||||
const params = useParams();
|
const params = useParams();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
@ -29,6 +69,7 @@ export default function EditorPage() {
|
||||||
const [editorContent, setEditorContent] = useState<any>(null);
|
const [editorContent, setEditorContent] = useState<any>(null);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false);
|
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false);
|
||||||
|
const [showUnsavedDialog, setShowUnsavedDialog] = useState(false);
|
||||||
|
|
||||||
// Fetch document content - DIRECT CALL TO FASTAPI
|
// Fetch document content - DIRECT CALL TO FASTAPI
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -90,6 +131,17 @@ export default function EditorPage() {
|
||||||
}
|
}
|
||||||
}, [editorContent, document]);
|
}, [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
|
// TODO: Maybe add Auto-save every 30 seconds - DIRECT CALL TO FASTAPI
|
||||||
|
|
||||||
// Save and exit - 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 (hasUnsavedChanges) {
|
||||||
if (confirm("You have unsaved changes. Are you sure you want to leave?")) {
|
setShowUnsavedDialog(true);
|
||||||
router.back();
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
router.back();
|
router.back();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleConfirmLeave = () => {
|
||||||
|
setShowUnsavedDialog(false);
|
||||||
|
router.back();
|
||||||
|
};
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center min-h-[400px] p-6">
|
<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">
|
<div className="flex items-center gap-3 flex-1 min-w-0">
|
||||||
<FileText className="h-5 w-5 text-muted-foreground shrink-0" />
|
<FileText className="h-5 w-5 text-muted-foreground shrink-0" />
|
||||||
<div className="flex flex-col min-w-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>}
|
{hasUnsavedChanges && <p className="text-xs text-muted-foreground">Unsaved changes</p>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Separator orientation="vertical" className="h-6" />
|
<Separator orientation="vertical" className="h-6" />
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Button variant="outline" onClick={handleCancel} disabled={saving} className="gap-2">
|
<Button variant="outline" onClick={handleBack} disabled={saving} className="gap-2">
|
||||||
<X className="h-4 w-4" />
|
<ArrowLeft className="h-4 w-4" />
|
||||||
Cancel
|
Back
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={handleSave} disabled={saving} className="gap-2">
|
<Button onClick={handleSave} disabled={saving} className="gap-2">
|
||||||
{saving ? (
|
{saving ? (
|
||||||
|
|
@ -247,10 +302,30 @@ export default function EditorPage() {
|
||||||
<div className="flex-1 overflow-hidden relative">
|
<div className="flex-1 overflow-hidden relative">
|
||||||
<div className="h-full w-full overflow-auto p-6">
|
<div className="h-full w-full overflow-auto p-6">
|
||||||
<div className="max-w-4xl mx-auto">
|
<div className="max-w-4xl mx-auto">
|
||||||
<BlockNoteEditor initialContent={editorContent} onChange={setEditorContent} />
|
<BlockNoteEditor
|
||||||
|
initialContent={editorContent}
|
||||||
|
onChange={setEditorContent}
|
||||||
|
useTitleBlock={isNote}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</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>
|
</motion.div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
@ -10,31 +10,119 @@ import { useCreateBlockNote } from "@blocknote/react";
|
||||||
interface BlockNoteEditorProps {
|
interface BlockNoteEditorProps {
|
||||||
initialContent?: any;
|
initialContent?: any;
|
||||||
onChange?: (content: any) => void;
|
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();
|
const { resolvedTheme } = useTheme();
|
||||||
|
|
||||||
// Track the initial content to prevent re-initialization
|
// Track the initial content to prevent re-initialization
|
||||||
const initialContentRef = useRef<any>(null);
|
const initialContentRef = useRef<any>(null);
|
||||||
const isInitializedRef = useRef(false);
|
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
|
// Creates a new editor instance - only use initialContent on first render
|
||||||
const editor = useCreateBlockNote({
|
const editor = useCreateBlockNote({
|
||||||
initialContent: initialContentRef.current === null ? initialContent || undefined : undefined,
|
initialContent: initialContentRef.current === null ? preparedInitialContent : undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Store initial content on first render only
|
// Store initial content on first render only
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (initialContent && initialContentRef.current === null) {
|
if (preparedInitialContent !== undefined && initialContentRef.current === null) {
|
||||||
initialContentRef.current = initialContent;
|
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;
|
isInitializedRef.current = true;
|
||||||
}
|
}
|
||||||
}, [initialContent]);
|
}, [preparedInitialContent]);
|
||||||
|
|
||||||
// Call onChange when document changes (but don't update from props)
|
// Call onChange when document changes (but don't update from props)
|
||||||
useEffect(() => {
|
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 = () => {
|
const handleChange = () => {
|
||||||
onChange(editor.document);
|
onChange(editor.document);
|
||||||
|
|
@ -43,6 +131,12 @@ export default function BlockNoteEditor({ initialContent, onChange }: BlockNoteE
|
||||||
// Subscribe to document changes
|
// Subscribe to document changes
|
||||||
const unsubscribe = editor.onChange(handleChange);
|
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 () => {
|
return () => {
|
||||||
unsubscribe();
|
unsubscribe();
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -213,7 +213,7 @@ export function AppSidebarProvider({
|
||||||
|
|
||||||
// Handle add note
|
// Handle add note
|
||||||
const handleAddNote = useCallback(() => {
|
const handleAddNote = useCallback(() => {
|
||||||
router.push(`/dashboard/${searchSpaceId}/notes/new`);
|
router.push(`/dashboard/${searchSpaceId}/editor/new`);
|
||||||
}, [router, searchSpaceId]);
|
}, [router, searchSpaceId]);
|
||||||
|
|
||||||
// Memoized updated navSecondary
|
// Memoized updated navSecondary
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue