code quality issues fixed

This commit is contained in:
Anish Sarkar 2025-11-23 16:39:23 +05:30
parent abbaa848f3
commit 3fac196c35
17 changed files with 495 additions and 493 deletions

View file

@ -4,6 +4,7 @@ Revision ID: 38
Revises: 37 Revises: 37
""" """
from collections.abc import Sequence from collections.abc import Sequence
import sqlalchemy as sa import sqlalchemy as sa
@ -12,30 +13,38 @@ from sqlalchemy.dialects import postgresql
from alembic import op from alembic import op
# revision identifiers, used by Alembic. # revision identifiers, used by Alembic.
revision: str = '38' revision: str = "38"
down_revision: str | None = '37' down_revision: str | None = "37"
branch_labels: str | Sequence[str] | None = None branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None depends_on: str | Sequence[str] | None = None
def upgrade() -> None: def upgrade() -> None:
"""Upgrade schema - Add BlockNote fields only.""" """Upgrade schema - Add BlockNote fields only."""
op.add_column( op.add_column(
'documents', "documents",
sa.Column('blocknote_document', postgresql.JSONB(astext_type=sa.Text()), nullable=True), sa.Column(
"blocknote_document", postgresql.JSONB(astext_type=sa.Text()), nullable=True
),
) )
op.add_column( op.add_column(
'documents', "documents",
sa.Column('content_needs_reindexing', sa.Boolean(), nullable=False, server_default=sa.false()), sa.Column(
"content_needs_reindexing",
sa.Boolean(),
nullable=False,
server_default=sa.false(),
),
) )
op.add_column( op.add_column(
'documents', "documents",
sa.Column('last_edited_at', sa.TIMESTAMP(timezone=True), nullable=True) sa.Column("last_edited_at", sa.TIMESTAMP(timezone=True), nullable=True),
) )
def downgrade() -> None: def downgrade() -> None:
"""Downgrade schema - Remove BlockNote fields.""" """Downgrade schema - Remove BlockNote fields."""
op.drop_column('documents', 'last_edited_at') op.drop_column("documents", "last_edited_at")
op.drop_column('documents', 'content_needs_reindexing') op.drop_column("documents", "content_needs_reindexing")
op.drop_column('documents', 'blocknote_document') op.drop_column("documents", "blocknote_document")

View file

@ -178,15 +178,15 @@ class Document(BaseModel, TimestampMixin):
content_hash = Column(String, nullable=False, index=True, unique=True) content_hash = Column(String, nullable=False, index=True, unique=True)
unique_identifier_hash = Column(String, nullable=True, index=True, unique=True) unique_identifier_hash = Column(String, nullable=True, index=True, unique=True)
embedding = Column(Vector(config.embedding_model_instance.dimension)) embedding = Column(Vector(config.embedding_model_instance.dimension))
# BlockNote live editing state (NULL when never edited) # BlockNote live editing state (NULL when never edited)
blocknote_document = Column(JSONB, nullable=True) blocknote_document = Column(JSONB, nullable=True)
# blocknote background reindex flag # blocknote background reindex flag
content_needs_reindexing = Column( content_needs_reindexing = Column(
Boolean, nullable=False, default=False, server_default=text("false") Boolean, nullable=False, default=False, server_default=text("false")
) )
# Track when blocknote document was last edited # Track when blocknote document was last edited
last_edited_at = Column(TIMESTAMP(timezone=True), nullable=True) last_edited_at = Column(TIMESTAMP(timezone=True), nullable=True)

View file

@ -1,6 +1,7 @@
""" """
Editor routes for BlockNote document editing. Editor routes for BlockNote document editing.
""" """
from datetime import UTC, datetime from datetime import UTC, datetime
from typing import Any from typing import Any
@ -26,7 +27,7 @@ async def get_editor_content(
): ):
""" """
Get document content for editing. Get document content for editing.
Returns BlockNote JSON document. If blocknote_document is NULL, Returns BlockNote JSON document. If blocknote_document is NULL,
attempts to convert from `content` - though this won't work well attempts to convert from `content` - though this won't work well
for old documents that only have summaries. for old documents that only have summaries.
@ -37,24 +38,26 @@ async def get_editor_content(
.filter(Document.id == document_id, SearchSpace.user_id == user.id) .filter(Document.id == document_id, SearchSpace.user_id == user.id)
) )
document = result.scalars().first() document = result.scalars().first()
if not document: if not document:
raise HTTPException(status_code=404, detail="Document not found") raise HTTPException(status_code=404, detail="Document not found")
# If blocknote_document exists, return it # If blocknote_document exists, return it
if document.blocknote_document: if document.blocknote_document:
return { return {
"document_id": document.id, "document_id": document.id,
"title": document.title, "title": document.title,
"blocknote_document": document.blocknote_document, "blocknote_document": document.blocknote_document,
"last_edited_at": document.last_edited_at.isoformat() if document.last_edited_at else None, "last_edited_at": document.last_edited_at.isoformat()
if document.last_edited_at
else None,
} }
# For old documents without blocknote_document, return error # For old documents without blocknote_document, return error
# (Can't convert summary back to full document) # (Can't convert summary back to full document)
raise HTTPException( raise HTTPException(
status_code=400, status_code=400,
detail="This document was uploaded before editing was enabled. Please re-upload to enable editing." detail="This document was uploaded before editing was enabled. Please re-upload to enable editing.",
) )
@ -75,21 +78,21 @@ async def update_blocknote_content(
.filter(Document.id == document_id, SearchSpace.user_id == user.id) .filter(Document.id == document_id, SearchSpace.user_id == user.id)
) )
document = result.scalars().first() document = result.scalars().first()
if not document: if not document:
raise HTTPException(status_code=404, detail="Document not found") raise HTTPException(status_code=404, detail="Document not found")
blocknote_document = data.get("blocknote_document") blocknote_document = data.get("blocknote_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")
# Update only blocknote_document and last_edited_at # Update only blocknote_document and last_edited_at
document.blocknote_document = blocknote_document document.blocknote_document = blocknote_document
document.last_edited_at = datetime.now(UTC) document.last_edited_at = datetime.now(UTC)
await session.commit() await session.commit()
await session.refresh(document) await session.refresh(document)
return {"status": "saved", "last_edited_at": document.last_edited_at.isoformat()} return {"status": "saved", "last_edited_at": document.last_edited_at.isoformat()}
@ -110,52 +113,51 @@ async def update_blocknote_content(
# .filter(Document.id == document_id, SearchSpace.user_id == user.id) # .filter(Document.id == document_id, SearchSpace.user_id == user.id)
# ) # )
# document = result.scalars().first() # document = result.scalars().first()
# if not document: # if not document:
# raise HTTPException(status_code=404, detail="Document not found") # raise HTTPException(status_code=404, detail="Document not found")
# if not document.blocknote_document: # if not document.blocknote_document:
# raise HTTPException( # raise HTTPException(
# status_code=400, # status_code=400,
# detail="Document has no BlockNote content to finalize" # detail="Document has no BlockNote content to finalize"
# ) # )
# # 1. Convert BlockNote JSON → Markdown # # 1. Convert BlockNote JSON → Markdown
# full_markdown = await convert_blocknote_to_markdown(document.blocknote_document) # full_markdown = await convert_blocknote_to_markdown(document.blocknote_document)
# if not full_markdown: # if not full_markdown:
# raise HTTPException( # raise HTTPException(
# status_code=500, # status_code=500,
# detail="Failed to convert BlockNote document to markdown" # detail="Failed to convert BlockNote document to markdown"
# ) # )
# # 2. Generate new summary from full markdown # # 2. Generate new summary from full markdown
# from app.services.llm_service import get_user_long_context_llm # from app.services.llm_service import get_user_long_context_llm
# from app.utils.document_converters import generate_document_summary # from app.utils.document_converters import generate_document_summary
# user_llm = await get_user_long_context_llm(session, str(user.id), document.search_space_id) # user_llm = await get_user_long_context_llm(session, str(user.id), document.search_space_id)
# if not user_llm: # if not user_llm:
# raise HTTPException( # raise HTTPException(
# status_code=500, # status_code=500,
# detail="No LLM configured for summary generation" # detail="No LLM configured for summary generation"
# ) # )
# document_metadata = document.document_metadata or {} # document_metadata = document.document_metadata or {}
# summary_content, summary_embedding = await generate_document_summary( # summary_content, summary_embedding = await generate_document_summary(
# full_markdown, user_llm, document_metadata # full_markdown, user_llm, document_metadata
# ) # )
# # 3. Update document fields # # 3. Update document fields
# document.content = summary_content # document.content = summary_content
# document.embedding = summary_embedding # document.embedding = summary_embedding
# document.content_needs_reindexing = True # Trigger chunk regeneration # document.content_needs_reindexing = True # Trigger chunk regeneration
# document.last_edited_at = datetime.now(UTC) # document.last_edited_at = datetime.now(UTC)
# await session.commit() # await session.commit()
# return { # return {
# "status": "finalized", # "status": "finalized",
# "message": "Document saved. Summary and chunks will be regenerated in the background.", # "message": "Document saved. Summary and chunks will be regenerated in the background.",
# "content_needs_reindexing": True, # "content_needs_reindexing": True,
# } # }

View file

@ -144,9 +144,9 @@ async def add_extension_received_document(
# Process chunks # Process chunks
chunks = await create_document_chunks(content.pageContent) chunks = await create_document_chunks(content.pageContent)
from app.utils.blocknote_converter import convert_markdown_to_blocknote from app.utils.blocknote_converter import convert_markdown_to_blocknote
# Convert markdown to BlockNote JSON # Convert markdown to BlockNote JSON
blocknote_json = await convert_markdown_to_blocknote(combined_document_string) blocknote_json = await convert_markdown_to_blocknote(combined_document_string)
if not blocknote_json: if not blocknote_json:

View file

@ -99,14 +99,15 @@ async def add_received_file_document_using_unstructured(
# Process chunks # Process chunks
chunks = await create_document_chunks(file_in_markdown) chunks = await create_document_chunks(file_in_markdown)
from app.utils.blocknote_converter import convert_markdown_to_blocknote from app.utils.blocknote_converter import convert_markdown_to_blocknote
# Convert markdown to BlockNote JSON # Convert markdown to BlockNote JSON
blocknote_json = await convert_markdown_to_blocknote(file_in_markdown) blocknote_json = await convert_markdown_to_blocknote(file_in_markdown)
if not blocknote_json: if not blocknote_json:
logging.warning(f"Failed to convert {file_name} to BlockNote JSON, document will not be editable") logging.warning(
f"Failed to convert {file_name} to BlockNote JSON, document will not be editable"
)
# Update or create document # Update or create document
if existing_document: if existing_document:
@ -223,14 +224,15 @@ async def add_received_file_document_using_llamacloud(
# Process chunks # Process chunks
chunks = await create_document_chunks(file_in_markdown) chunks = await create_document_chunks(file_in_markdown)
from app.utils.blocknote_converter import convert_markdown_to_blocknote from app.utils.blocknote_converter import convert_markdown_to_blocknote
# Convert markdown to BlockNote JSON # Convert markdown to BlockNote JSON
blocknote_json = await convert_markdown_to_blocknote(file_in_markdown) blocknote_json = await convert_markdown_to_blocknote(file_in_markdown)
if not blocknote_json: if not blocknote_json:
logging.warning(f"Failed to convert {file_name} to BlockNote JSON, document will not be editable") logging.warning(
f"Failed to convert {file_name} to BlockNote JSON, document will not be editable"
)
# Update or create document # Update or create document
if existing_document: if existing_document:
@ -372,14 +374,15 @@ async def add_received_file_document_using_docling(
# Process chunks # Process chunks
chunks = await create_document_chunks(file_in_markdown) chunks = await create_document_chunks(file_in_markdown)
from app.utils.blocknote_converter import convert_markdown_to_blocknote from app.utils.blocknote_converter import convert_markdown_to_blocknote
# Convert markdown to BlockNote JSON # Convert markdown to BlockNote JSON
blocknote_json = await convert_markdown_to_blocknote(file_in_markdown) blocknote_json = await convert_markdown_to_blocknote(file_in_markdown)
if not blocknote_json: if not blocknote_json:
logging.warning(f"Failed to convert {file_name} to BlockNote JSON, document will not be editable") logging.warning(
f"Failed to convert {file_name} to BlockNote JSON, document will not be editable"
)
# Update or create document # Update or create document
if existing_document: if existing_document:

View file

@ -109,14 +109,15 @@ async def add_received_markdown_file_document(
# Process chunks # Process chunks
chunks = await create_document_chunks(file_in_markdown) chunks = await create_document_chunks(file_in_markdown)
from app.utils.blocknote_converter import convert_markdown_to_blocknote from app.utils.blocknote_converter import convert_markdown_to_blocknote
# Convert to BlockNote JSON # Convert to BlockNote JSON
blocknote_json = await convert_markdown_to_blocknote(file_in_markdown) blocknote_json = await convert_markdown_to_blocknote(file_in_markdown)
if not blocknote_json: if not blocknote_json:
logging.warning(f"Failed to convert {file_name} to BlockNote JSON, document will not be editable") logging.warning(
f"Failed to convert {file_name} to BlockNote JSON, document will not be editable"
)
# Update or create document # Update or create document
if existing_document: if existing_document:

View file

@ -247,7 +247,7 @@ async def add_crawled_url_document(
f"Processing content chunks for URL: {url}", f"Processing content chunks for URL: {url}",
{"stage": "chunk_processing"}, {"stage": "chunk_processing"},
) )
from app.utils.blocknote_converter import convert_markdown_to_blocknote from app.utils.blocknote_converter import convert_markdown_to_blocknote
# Convert markdown to BlockNote JSON # Convert markdown to BlockNote JSON

View file

@ -290,7 +290,7 @@ async def add_youtube_video_document(
f"Processing content chunks for video: {video_data.get('title', 'YouTube Video')}", f"Processing content chunks for video: {video_data.get('title', 'YouTube Video')}",
{"stage": "chunk_processing"}, {"stage": "chunk_processing"},
) )
from app.utils.blocknote_converter import convert_markdown_to_blocknote from app.utils.blocknote_converter import convert_markdown_to_blocknote
# Convert transcript to BlockNote JSON # Convert transcript to BlockNote JSON

View file

@ -11,17 +11,17 @@ logger = logging.getLogger(__name__)
async def convert_markdown_to_blocknote(markdown: str) -> dict[str, Any] | None: async def convert_markdown_to_blocknote(markdown: str) -> dict[str, Any] | None:
""" """
Convert markdown to BlockNote JSON via Next.js API. Convert markdown to BlockNote JSON via Next.js API.
Args: Args:
markdown: Markdown string to convert markdown: Markdown string to convert
Returns: Returns:
BlockNote document as dict, or None if conversion fails BlockNote document as dict, or None if conversion fails
""" """
if not markdown or not markdown.strip(): if not markdown or not markdown.strip():
logger.warning("Empty markdown provided for conversion") logger.warning("Empty markdown provided for conversion")
return None return None
if not markdown or len(markdown) < 10: if not markdown or len(markdown) < 10:
logger.warning("Markdown became too short after sanitization") logger.warning("Markdown became too short after sanitization")
# Return a minimal BlockNote document # Return a minimal BlockNote document
@ -32,13 +32,13 @@ async def convert_markdown_to_blocknote(markdown: str) -> dict[str, Any] | None:
{ {
"type": "text", "type": "text",
"text": "Document content could not be converted for editing.", "text": "Document content could not be converted for editing.",
"styles": {} "styles": {},
} }
], ],
"children": [] "children": [],
} }
] ]
async with httpx.AsyncClient() as client: async with httpx.AsyncClient() as client:
try: try:
response = await client.post( response = await client.post(
@ -49,19 +49,23 @@ async def convert_markdown_to_blocknote(markdown: str) -> dict[str, Any] | None:
response.raise_for_status() response.raise_for_status()
data = response.json() data = response.json()
blocknote_document = data.get("blocknote_document") blocknote_document = data.get("blocknote_document")
if blocknote_document: if blocknote_document:
logger.info(f"Successfully converted markdown to BlockNote (original: {len(markdown)} chars, sanitized: {len(markdown)} chars)") logger.info(
f"Successfully converted markdown to BlockNote (original: {len(markdown)} chars, sanitized: {len(markdown)} chars)"
)
return blocknote_document return blocknote_document
else: else:
logger.warning("Next.js API returned empty blocknote_document") logger.warning("Next.js API returned empty blocknote_document")
return None return None
except httpx.TimeoutException: except httpx.TimeoutException:
logger.error("Timeout converting markdown to BlockNote after 30s") logger.error("Timeout converting markdown to BlockNote after 30s")
return None return None
except httpx.HTTPStatusError as e: except httpx.HTTPStatusError as e:
logger.error(f"HTTP error converting markdown to BlockNote: {e.response.status_code} - {e.response.text}") logger.error(
f"HTTP error converting markdown to BlockNote: {e.response.status_code} - {e.response.text}"
)
# Log first 1000 chars of problematic markdown for debugging # Log first 1000 chars of problematic markdown for debugging
logger.debug(f"Problematic markdown sample: {markdown[:1000]}") logger.debug(f"Problematic markdown sample: {markdown[:1000]}")
return None return None
@ -69,20 +73,23 @@ async def convert_markdown_to_blocknote(markdown: str) -> dict[str, Any] | None:
logger.error(f"Failed to convert markdown to BlockNote: {e}", exc_info=True) logger.error(f"Failed to convert markdown to BlockNote: {e}", exc_info=True)
return None return None
async def convert_blocknote_to_markdown(blocknote_document: dict[str, Any] | list[dict[str, Any]]) -> str | None:
async def convert_blocknote_to_markdown(
blocknote_document: dict[str, Any] | list[dict[str, Any]],
) -> str | None:
""" """
Convert BlockNote JSON to markdown via Next.js API. Convert BlockNote JSON to markdown via Next.js API.
Args: Args:
blocknote_document: BlockNote document as dict or list of blocks blocknote_document: BlockNote document as dict or list of blocks
Returns: Returns:
Markdown string, or None if conversion fails Markdown string, or None if conversion fails
""" """
if not blocknote_document: if not blocknote_document:
logger.warning("Empty BlockNote document provided for conversion") logger.warning("Empty BlockNote document provided for conversion")
return None return None
async with httpx.AsyncClient() as client: async with httpx.AsyncClient() as client:
try: try:
response = await client.post( response = await client.post(
@ -93,21 +100,24 @@ async def convert_blocknote_to_markdown(blocknote_document: dict[str, Any] | lis
response.raise_for_status() response.raise_for_status()
data = response.json() data = response.json()
markdown = data.get("markdown") markdown = data.get("markdown")
if markdown: if markdown:
logger.info(f"Successfully converted BlockNote to markdown ({len(markdown)} chars)") logger.info(
f"Successfully converted BlockNote to markdown ({len(markdown)} chars)"
)
return markdown return markdown
else: else:
logger.warning("Next.js API returned empty markdown") logger.warning("Next.js API returned empty markdown")
return None return None
except httpx.TimeoutException: except httpx.TimeoutException:
logger.error("Timeout converting BlockNote to markdown after 30s") logger.error("Timeout converting BlockNote to markdown after 30s")
return None return None
except httpx.HTTPStatusError as e: except httpx.HTTPStatusError as e:
logger.error(f"HTTP error converting BlockNote to markdown: {e.response.status_code} - {e.response.text}") logger.error(
f"HTTP error converting BlockNote to markdown: {e.response.status_code} - {e.response.text}"
)
return None return None
except Exception as e: except Exception as e:
logger.error(f"Failed to convert BlockNote to markdown: {e}", exc_info=True) logger.error(f"Failed to convert BlockNote to markdown: {e}", exc_info=True)
return None return None

View file

@ -2,42 +2,39 @@ import { ServerBlockNoteEditor } from "@blocknote/server-util";
import { type NextRequest, NextResponse } from "next/server"; import { type NextRequest, NextResponse } from "next/server";
export async function POST(request: NextRequest) { export async function POST(request: NextRequest) {
try { try {
const { markdown } = await request.json(); const { markdown } = await request.json();
if (!markdown || typeof markdown !== "string") { if (!markdown || typeof markdown !== "string") {
return NextResponse.json( return NextResponse.json({ error: "Markdown string is required" }, { status: 400 });
{ error: "Markdown string is required" }, }
{ status: 400 }
); // Log raw markdown input before conversion
} // console.log(`\n${"=".repeat(80)}`);
// console.log("RAW MARKDOWN INPUT (BEFORE CONVERSION):");
// Log raw markdown input before conversion // console.log("=".repeat(80));
// console.log(`\n${"=".repeat(80)}`); // console.log(markdown);
// console.log("RAW MARKDOWN INPUT (BEFORE CONVERSION):"); // console.log(`${"=".repeat(80)}\n`);
// console.log("=".repeat(80));
// console.log(markdown); // Create server-side editor instance
// console.log(`${"=".repeat(80)}\n`); const editor = ServerBlockNoteEditor.create();
// Create server-side editor instance // Convert markdown directly to BlockNote blocks
const editor = ServerBlockNoteEditor.create(); const blocks = await editor.tryParseMarkdownToBlocks(markdown);
// Convert markdown directly to BlockNote blocks if (!blocks || blocks.length === 0) {
const blocks = await editor.tryParseMarkdownToBlocks(markdown); throw new Error("Markdown parsing returned no blocks");
}
if (!blocks || blocks.length === 0) {
throw new Error("Markdown parsing returned no blocks"); return NextResponse.json({ blocknote_document: blocks });
} } catch (error: any) {
console.error("Failed to convert markdown to BlockNote:", error);
return NextResponse.json({ blocknote_document: blocks }); return NextResponse.json(
} catch (error: any) { {
console.error("Failed to convert markdown to BlockNote:", error); error: "Failed to convert markdown to BlockNote blocks",
return NextResponse.json( details: error.message,
{ },
error: "Failed to convert markdown to BlockNote blocks", { status: 500 }
details: error.message );
}, }
{ status: 500 }
);
}
} }

View file

@ -2,30 +2,27 @@ import { ServerBlockNoteEditor } from "@blocknote/server-util";
import { type NextRequest, NextResponse } from "next/server"; import { type NextRequest, NextResponse } from "next/server";
export async function POST(request: NextRequest) { export async function POST(request: NextRequest) {
try { try {
const { blocknote_document } = await request.json(); const { blocknote_document } = await request.json();
if (!blocknote_document || !Array.isArray(blocknote_document)) { if (!blocknote_document || !Array.isArray(blocknote_document)) {
return NextResponse.json( return NextResponse.json({ error: "BlockNote document array is required" }, { status: 400 });
{ error: "BlockNote document array is required" }, }
{ status: 400 }
); // Create server-side editor instance
} const editor = ServerBlockNoteEditor.create();
// Create server-side editor instance // Convert BlockNote blocks to markdown
const editor = ServerBlockNoteEditor.create(); const markdown = await editor.blocksToMarkdownLossy(blocknote_document);
// Convert BlockNote blocks to markdown return NextResponse.json({
const markdown = await editor.blocksToMarkdownLossy(blocknote_document); markdown,
});
return NextResponse.json({ } catch (error) {
markdown console.error("Failed to convert BlockNote to markdown:", error);
}); return NextResponse.json(
} catch (error) { { error: "Failed to convert BlockNote blocks to markdown" },
console.error("Failed to convert BlockNote to markdown:", error); { status: 500 }
return NextResponse.json( );
{ error: "Failed to convert BlockNote blocks to markdown" }, }
{ status: 500 }
);
}
} }

View file

@ -1,6 +1,6 @@
"use client"; "use client";
import { MoreHorizontal, Pencil, FileText, Trash2 } from "lucide-react"; import { FileText, MoreHorizontal, Pencil, Trash2 } from "lucide-react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { useState } from "react"; import { useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";

View file

@ -1,9 +1,9 @@
"use client"; "use client";
import { AlertCircle, FileText, Loader2, Save, X } from "lucide-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, useState } from "react";
import { motion } from "motion/react";
import { Loader2, Save, X, FileText, AlertCircle } from "lucide-react";
import { toast } from "sonner"; import { toast } from "sonner";
import { BlockNoteEditor } from "@/components/DynamicBlockNoteEditor"; import { BlockNoteEditor } from "@/components/DynamicBlockNoteEditor";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
@ -12,286 +12,281 @@ import { Separator } from "@/components/ui/separator";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
interface EditorContent { interface EditorContent {
document_id: number; document_id: number;
title: string; title: string;
blocknote_document: any; blocknote_document: any;
last_edited_at: string | null; last_edited_at: string | null;
} }
export default function EditorPage() { export default function EditorPage() {
const params = useParams(); const params = useParams();
const router = useRouter(); const router = useRouter();
const documentId = params.documentId as string; const documentId = params.documentId as string;
const [document, setDocument] = useState<EditorContent | null>(null); const [document, setDocument] = useState<EditorContent | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
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);
// Get auth token // Get auth token
const token = typeof window !== "undefined" const token =
? localStorage.getItem("surfsense_bearer_token") typeof window !== "undefined" ? localStorage.getItem("surfsense_bearer_token") : null;
: null;
// Fetch document content - DIRECT CALL TO FASTAPI
// Fetch document content - DIRECT CALL TO FASTAPI useEffect(() => {
useEffect(() => { async function fetchDocument() {
async function fetchDocument() { if (!token) {
if (!token) { console.error("No auth token found");
console.error("No auth token found"); setError("Please login to access the editor");
setError("Please login to access the editor"); setLoading(false);
setLoading(false); return;
return; }
}
try {
try { const response = await fetch(
const response = await fetch( `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/documents/${documentId}/editor-content`,
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/documents/${documentId}/editor-content`, {
{ headers: {
headers: { Authorization: `Bearer ${token}`,
Authorization: `Bearer ${token}`, },
}, }
} );
);
if (!response.ok) {
if (!response.ok) { const errorData = await response
const errorData = await response.json().catch(() => ({ detail: "Failed to fetch document" })); .json()
throw new Error(errorData.detail || "Failed to fetch document"); .catch(() => ({ detail: "Failed to fetch document" }));
} throw new Error(errorData.detail || "Failed to fetch document");
}
const data = await response.json();
const data = await response.json();
// Check if blocknote_document exists
if (!data.blocknote_document) { // Check if blocknote_document exists
setError("This document does not have BlockNote content. Please re-upload the document to enable editing."); if (!data.blocknote_document) {
setLoading(false); setError(
return; "This document does not have BlockNote content. Please re-upload the document to enable editing."
} );
setLoading(false);
setDocument(data); return;
setEditorContent(data.blocknote_document); }
setError(null);
} catch (error) { setDocument(data);
console.error("Error fetching document:", error); setEditorContent(data.blocknote_document);
setError(error instanceof Error ? error.message : "Failed to fetch document. Please try again."); setError(null);
} finally { } catch (error) {
setLoading(false); console.error("Error fetching document:", error);
} setError(
} error instanceof Error ? error.message : "Failed to fetch document. Please try again."
);
if (documentId && token) { } finally {
fetchDocument(); setLoading(false);
} }
}, [documentId, token]); }
// Track changes to mark as unsaved if (documentId && token) {
useEffect(() => { fetchDocument();
if (editorContent && document) { }
setHasUnsavedChanges(true); }, [documentId, token]);
}
}, [editorContent, document]); // Track changes to mark as unsaved
useEffect(() => {
// Auto-save every 30 seconds - DIRECT CALL TO FASTAPI if (editorContent && document) {
useEffect(() => { setHasUnsavedChanges(true);
if (!editorContent || !token || !hasUnsavedChanges) return; }
}, [editorContent, document]);
const interval = setInterval(async () => {
try { // Auto-save every 30 seconds - DIRECT CALL TO FASTAPI
const response = await fetch( useEffect(() => {
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/documents/${documentId}/blocknote-content`, if (!editorContent || !token || !hasUnsavedChanges) return;
{
method: "PUT", const interval = setInterval(async () => {
headers: { try {
"Content-Type": "application/json", const response = await fetch(
Authorization: `Bearer ${token}`, `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/documents/${documentId}/blocknote-content`,
}, {
body: JSON.stringify({ blocknote_document: editorContent }), method: "PUT",
} headers: {
); "Content-Type": "application/json",
Authorization: `Bearer ${token}`,
if (response.ok) { },
setHasUnsavedChanges(false); body: JSON.stringify({ blocknote_document: editorContent }),
toast.success("Auto-saved", { duration: 2000 }); }
} );
} catch (error) {
console.error("Auto-save failed:", error); if (response.ok) {
} setHasUnsavedChanges(false);
}, 30000); // 30 seconds toast.success("Auto-saved", { duration: 2000 });
}
return () => clearInterval(interval); } catch (error) {
}, [editorContent, documentId, token, hasUnsavedChanges]); console.error("Auto-save failed:", error);
}
// Save and exit - DIRECT CALL TO FASTAPI }, 30000); // 30 seconds
const handleSave = async () => {
if (!token) { return () => clearInterval(interval);
toast.error("Please login to save"); }, [editorContent, documentId, token, hasUnsavedChanges]);
return;
} // Save and exit - DIRECT CALL TO FASTAPI
const handleSave = async () => {
if (!editorContent) { if (!token) {
toast.error("No content to save"); toast.error("Please login to save");
return; return;
} }
setSaving(true); if (!editorContent) {
try { toast.error("No content to save");
// Save blocknote_document to database (without finalizing/reindexing) return;
const response = await fetch( }
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/documents/${documentId}/blocknote-content`,
{ setSaving(true);
method: "PUT", try {
headers: { // Save blocknote_document to database (without finalizing/reindexing)
"Content-Type": "application/json", const response = await fetch(
Authorization: `Bearer ${token}`, `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/documents/${documentId}/blocknote-content`,
}, {
body: JSON.stringify({ blocknote_document: editorContent }), method: "PUT",
} headers: {
); "Content-Type": "application/json",
Authorization: `Bearer ${token}`,
if (!response.ok) { },
const errorData = await response.json().catch(() => ({ detail: "Failed to save document" })); body: JSON.stringify({ blocknote_document: editorContent }),
throw new Error(errorData.detail || "Failed to save document"); }
} );
setHasUnsavedChanges(false); if (!response.ok) {
toast.success("Document saved successfully"); const errorData = await response
.json()
// Small delay before redirect to show success message .catch(() => ({ detail: "Failed to save document" }));
setTimeout(() => { throw new Error(errorData.detail || "Failed to save document");
router.push(`/dashboard/${params.search_space_id}/documents`); }
}, 500);
} catch (error) { setHasUnsavedChanges(false);
console.error("Error saving document:", error); toast.success("Document saved successfully");
toast.error(error instanceof Error ? error.message : "Failed to save document. Please try again.");
} finally { // Small delay before redirect to show success message
setSaving(false); setTimeout(() => {
} router.push(`/dashboard/${params.search_space_id}/documents`);
}; }, 500);
} catch (error) {
const handleCancel = () => { console.error("Error saving document:", error);
if (hasUnsavedChanges) { toast.error(
if (confirm("You have unsaved changes. Are you sure you want to leave?")) { error instanceof Error ? error.message : "Failed to save document. Please try again."
router.back(); );
} } finally {
} else { setSaving(false);
router.back(); }
} };
};
const handleCancel = () => {
if (loading) { if (hasUnsavedChanges) {
return ( if (confirm("You have unsaved changes. Are you sure you want to leave?")) {
<div className="flex items-center justify-center min-h-[400px] p-6"> router.back();
<Card className="w-full max-w-md"> }
<CardContent className="flex flex-col items-center justify-center py-12"> } else {
<Loader2 className="h-12 w-12 text-primary animate-spin mb-4" /> router.back();
<p className="text-muted-foreground">Loading editor...</p> }
</CardContent> };
</Card>
</div> if (loading) {
); return (
} <div className="flex items-center justify-center min-h-[400px] p-6">
<Card className="w-full max-w-md">
if (error) { <CardContent className="flex flex-col items-center justify-center py-12">
return ( <Loader2 className="h-12 w-12 text-primary animate-spin mb-4" />
<div className="flex items-center justify-center min-h-[400px] p-6"> <p className="text-muted-foreground">Loading editor...</p>
<motion.div </CardContent>
initial={{ opacity: 0, y: 20 }} </Card>
animate={{ opacity: 1, y: 0 }} </div>
className="w-full max-w-md" );
> }
<Card className="border-destructive/50">
<CardHeader> if (error) {
<div className="flex items-center gap-2"> return (
<AlertCircle className="h-5 w-5 text-destructive" /> <div className="flex items-center justify-center min-h-[400px] p-6">
<CardTitle className="text-destructive">Error</CardTitle> <motion.div
</div> initial={{ opacity: 0, y: 20 }}
<CardDescription>{error}</CardDescription> animate={{ opacity: 1, y: 0 }}
</CardHeader> className="w-full max-w-md"
<CardContent> >
<Button onClick={() => router.back()} variant="outline" className="w-full"> <Card className="border-destructive/50">
<X className="mr-2 h-4 w-4" /> <CardHeader>
Go Back <div className="flex items-center gap-2">
</Button> <AlertCircle className="h-5 w-5 text-destructive" />
</CardContent> <CardTitle className="text-destructive">Error</CardTitle>
</Card> </div>
</motion.div> <CardDescription>{error}</CardDescription>
</div> </CardHeader>
); <CardContent>
} <Button onClick={() => router.back()} variant="outline" className="w-full">
<X className="mr-2 h-4 w-4" />
if (!document) { Go Back
return ( </Button>
<div className="flex items-center justify-center min-h-[400px] p-6"> </CardContent>
<Card className="w-full max-w-md"> </Card>
<CardContent className="flex flex-col items-center justify-center py-12"> </motion.div>
<FileText className="h-12 w-12 text-muted-foreground mb-4" /> </div>
<p className="text-muted-foreground">Document not found</p> );
</CardContent> }
</Card>
</div> if (!document) {
); return (
} <div className="flex items-center justify-center min-h-[400px] p-6">
<Card className="w-full max-w-md">
return ( <CardContent className="flex flex-col items-center justify-center py-12">
<motion.div <FileText className="h-12 w-12 text-muted-foreground mb-4" />
initial={{ opacity: 0 }} <p className="text-muted-foreground">Document not found</p>
animate={{ opacity: 1 }} </CardContent>
className="flex flex-col h-full w-full" </Card>
> </div>
{/* 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" /> return (
<div className="flex flex-col min-w-0"> <motion.div
<h1 className="text-lg font-semibold truncate">{document.title}</h1> initial={{ opacity: 0 }}
{hasUnsavedChanges && ( animate={{ opacity: 1 }}
<p className="text-xs text-muted-foreground">Unsaved changes</p> className="flex flex-col h-full w-full"
)} >
</div> {/* Toolbar */}
</div> <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">
<Separator orientation="vertical" className="h-6" /> <div className="flex items-center gap-3 flex-1 min-w-0">
<div className="flex items-center gap-2"> <FileText className="h-5 w-5 text-muted-foreground shrink-0" />
<Button <div className="flex flex-col min-w-0">
variant="outline" <h1 className="text-lg font-semibold truncate">{document.title}</h1>
onClick={handleCancel} {hasUnsavedChanges && <p className="text-xs text-muted-foreground">Unsaved changes</p>}
disabled={saving} </div>
className="gap-2" </div>
> <Separator orientation="vertical" className="h-6" />
<X className="h-4 w-4" /> <div className="flex items-center gap-2">
Cancel <Button variant="outline" onClick={handleCancel} disabled={saving} className="gap-2">
</Button> <X className="h-4 w-4" />
<Button Cancel
onClick={handleSave} </Button>
disabled={saving} <Button onClick={handleSave} disabled={saving} className="gap-2">
className="gap-2" {saving ? (
> <>
{saving ? ( <Loader2 className="h-4 w-4 animate-spin" />
<> Saving...
<Loader2 className="h-4 w-4 animate-spin" /> </>
Saving... ) : (
</> <>
) : ( <Save className="h-4 w-4" />
<> Save & Exit
<Save className="h-4 w-4" /> </>
Save & Exit )}
</> </Button>
)} </div>
</Button> </div>
</div>
</div> {/* Editor Container */}
<div className="flex-1 overflow-hidden relative">
{/* Editor Container */} <div className="h-full w-full overflow-auto p-6">
<div className="flex-1 overflow-hidden relative"> <div className="max-w-4xl mx-auto">
<div className="h-full w-full overflow-auto p-6"> <BlockNoteEditor initialContent={editorContent} onChange={setEditorContent} />
<div className="max-w-4xl mx-auto"> </div>
<BlockNoteEditor </div>
initialContent={editorContent} </div>
onChange={setEditorContent} </motion.div>
/> );
</div>
</div>
</div>
</motion.div>
);
} }

View file

@ -1,71 +1,68 @@
"use client"; "use client";
import { useEffect, useRef, useMemo } from "react";
import { useTheme } from "next-themes"; import { useTheme } from "next-themes";
import { useEffect, useMemo, useRef } from "react";
import "@blocknote/core/fonts/inter.css"; import "@blocknote/core/fonts/inter.css";
import "@blocknote/mantine/style.css"; import "@blocknote/mantine/style.css";
import { useCreateBlockNote } from "@blocknote/react";
import { BlockNoteView } from "@blocknote/mantine"; import { BlockNoteView } from "@blocknote/mantine";
import { useCreateBlockNote } from "@blocknote/react";
interface BlockNoteEditorProps { interface BlockNoteEditorProps {
initialContent?: any; initialContent?: any;
onChange?: (content: any) => void; onChange?: (content: any) => void;
} }
export default function BlockNoteEditor({ export default function BlockNoteEditor({ initialContent, onChange }: BlockNoteEditorProps) {
initialContent, const { resolvedTheme } = useTheme();
onChange,
}: BlockNoteEditorProps) {
const { resolvedTheme } = useTheme();
// Track the initial content to prevent re-initialization
const initialContentRef = useRef<any>(null);
const isInitializedRef = useRef(false);
// Creates a new editor instance - only use initialContent on first render
const editor = useCreateBlockNote({
initialContent: initialContentRef.current === null ? (initialContent || undefined) : undefined,
});
// Store initial content on first render only
useEffect(() => {
if (initialContent && initialContentRef.current === null) {
initialContentRef.current = initialContent;
isInitializedRef.current = true;
}
}, [initialContent]);
// Call onChange when document changes (but don't update from props) // Track the initial content to prevent re-initialization
useEffect(() => { const initialContentRef = useRef<any>(null);
if (!onChange || !editor || !isInitializedRef.current) return; const isInitializedRef = useRef(false);
const handleChange = () => {
onChange(editor.document);
};
// Subscribe to document changes
const unsubscribe = editor.onChange(handleChange);
return () => {
unsubscribe();
};
}, [editor, onChange]);
// Determine theme for BlockNote with custom dark mode background // Creates a new editor instance - only use initialContent on first render
const blockNoteTheme = useMemo(() => { const editor = useCreateBlockNote({
if (resolvedTheme === "dark") { initialContent: initialContentRef.current === null ? initialContent || undefined : undefined,
// Custom dark theme - only override editor background, let BlockNote handle the rest });
return {
colors: {
editor: {
background: "#0A0A0A", // Custom dark background
},
},
};
}
return "light" as const;
}, [resolvedTheme]);
// Renders the editor instance // Store initial content on first render only
return <BlockNoteView editor={editor} theme={blockNoteTheme} />; useEffect(() => {
if (initialContent && initialContentRef.current === null) {
initialContentRef.current = initialContent;
isInitializedRef.current = true;
}
}, [initialContent]);
// Call onChange when document changes (but don't update from props)
useEffect(() => {
if (!onChange || !editor || !isInitializedRef.current) return;
const handleChange = () => {
onChange(editor.document);
};
// Subscribe to document changes
const unsubscribe = editor.onChange(handleChange);
return () => {
unsubscribe();
};
}, [editor, onChange]);
// Determine theme for BlockNote with custom dark mode background
const blockNoteTheme = useMemo(() => {
if (resolvedTheme === "dark") {
// Custom dark theme - only override editor background, let BlockNote handle the rest
return {
colors: {
editor: {
background: "#0A0A0A", // Custom dark background
},
},
};
}
return "light" as const;
}, [resolvedTheme]);
// Renders the editor instance
return <BlockNoteView editor={editor} theme={blockNoteTheme} />;
} }

View file

@ -3,7 +3,4 @@
import dynamic from "next/dynamic"; import dynamic from "next/dynamic";
// Dynamically import BlockNote editor with SSR disabled // Dynamically import BlockNote editor with SSR disabled
export const BlockNoteEditor = dynamic( export const BlockNoteEditor = dynamic(() => import("./BlockNoteEditor"), { ssr: false });
() => import("./BlockNoteEditor"),
{ ssr: false }
);

View file

@ -41,9 +41,8 @@ export function DashboardBreadcrumb() {
useEffect(() => { useEffect(() => {
if (segments[2] === "editor" && segments[3] && searchSpaceId) { if (segments[2] === "editor" && segments[3] && searchSpaceId) {
const documentId = segments[3]; const documentId = segments[3];
const token = typeof window !== "undefined" const token =
? localStorage.getItem("surfsense_bearer_token") typeof window !== "undefined" ? localStorage.getItem("surfsense_bearer_token") : null;
: null;
if (token) { if (token) {
fetch( fetch(
@ -110,7 +109,7 @@ export function DashboardBreadcrumb() {
// Handle sub-sections // Handle sub-sections
if (segments[3]) { if (segments[3]) {
const subSection = segments[3]; const subSection = segments[3];
// Handle editor sub-sections (document ID) // Handle editor sub-sections (document ID)
if (section === "editor") { if (section === "editor") {
const documentLabel = documentTitle || subSection; const documentLabel = documentTitle || subSection;

View file

@ -24,18 +24,13 @@ const nextConfig: NextConfig = {
], ],
}, },
// Mark BlockNote server packages as external // Mark BlockNote server packages as external
serverExternalPackages: [ serverExternalPackages: ["@blocknote/server-util"],
'@blocknote/server-util',
],
// Configure webpack to handle blocknote packages // Configure webpack to handle blocknote packages
webpack: (config, { isServer }) => { webpack: (config, { isServer }) => {
if (isServer) { if (isServer) {
// Don't bundle these packages on the server // Don't bundle these packages on the server
config.externals = [ config.externals = [...(config.externals || []), "@blocknote/server-util"];
...(config.externals || []),
'@blocknote/server-util',
];
} }
return config; return config;
}, },