Merge pull request #589 from AnishSarkar22/feature/note-management

Feature: Note Management system
This commit is contained in:
Rohan Verma 2025-12-16 22:44:57 -08:00 committed by GitHub
commit f18425d739
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
20 changed files with 1801 additions and 86 deletions

View file

@ -0,0 +1,47 @@
"""48_add_note_to_documenttype_enum
Revision ID: 48
Revises: 47
Adds NOTE document type to support user-created BlockNote documents.
"""
from collections.abc import Sequence
from alembic import op
# revision identifiers, used by Alembic.
revision: str = "48"
down_revision: str | None = "47"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
# Define the ENUM type name and the new value
ENUM_NAME = "documenttype"
NEW_VALUE = "NOTE"
def upgrade() -> None:
"""Safely add 'NOTE' to documenttype enum if missing."""
op.execute(
f"""
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_type t
JOIN pg_enum e ON t.oid = e.enumtypid
WHERE t.typname = '{ENUM_NAME}' AND e.enumlabel = '{NEW_VALUE}'
) THEN
ALTER TYPE {ENUM_NAME} ADD VALUE '{NEW_VALUE}';
END IF;
END
$$;
"""
)
def downgrade() -> None:
"""
Downgrade logic not implemented since PostgreSQL
does not support removing enum values.
"""
pass

View file

@ -51,6 +51,7 @@ class DocumentType(str, Enum):
LUMA_CONNECTOR = "LUMA_CONNECTOR"
ELASTICSEARCH_CONNECTOR = "ELASTICSEARCH_CONNECTOR"
BOOKSTACK_CONNECTOR = "BOOKSTACK_CONNECTOR"
NOTE = "NOTE"
class SearchSourceConnectorType(str, Enum):

View file

@ -15,6 +15,7 @@ from .google_gmail_add_connector_route import (
from .llm_config_routes import router as llm_config_router
from .logs_routes import router as logs_router
from .luma_add_connector_route import router as luma_add_connector_router
from .notes_routes import router as notes_router
from .podcasts_routes import router as podcasts_router
from .rbac_routes import router as rbac_router
from .search_source_connectors_routes import router as search_source_connectors_router
@ -26,6 +27,7 @@ router.include_router(search_spaces_router)
router.include_router(rbac_router) # RBAC routes for roles, members, invites
router.include_router(editor_router)
router.include_router(documents_router)
router.include_router(notes_router)
router.include_router(podcasts_router)
router.include_router(chats_router)
router.include_router(search_source_connectors_router)

View file

@ -10,7 +10,7 @@ from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.db import Document, Permission, User, get_async_session
from app.db import Document, DocumentType, Permission, User, get_async_session
from app.users import current_active_user
from app.utils.rbac import check_permission
@ -59,13 +59,38 @@ 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
else None,
}
# Lazy migration: Try to generate blocknote_document from chunks
# For NOTE type documents, return empty BlockNote structure if no content exists
if document.document_type == DocumentType.NOTE:
# Return empty BlockNote structure
empty_blocknote = [
{
"type": "paragraph",
"content": [],
"children": [],
}
]
# Save empty structure if not already saved
if not document.blocknote_document:
document.blocknote_document = empty_blocknote
await session.commit()
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,
}
# Lazy migration: Try to generate blocknote_document from chunks (for other document types)
from app.utils.blocknote_converter import convert_markdown_to_blocknote
chunks = sorted(document.chunks, key=lambda c: c.id)
@ -102,6 +127,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,
}
@ -146,6 +172,43 @@ async def save_document(
blocknote_document = data.get("blocknote_document")
if not blocknote_document:
raise HTTPException(status_code=400, detail="blocknote_document is required")
# Add type validation
if not isinstance(blocknote_document, list):
raise HTTPException(status_code=400, detail="blocknote_document must be a list")
# 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")
and isinstance(first_block["content"], list)
):
# Extract text from first block content
# Match the frontend extractTitleFromBlockNote logic exactly
title_parts = []
for item in first_block["content"]:
if isinstance(item, str):
title_parts.append(item)
elif (
isinstance(item, dict)
and "text" in item
and isinstance(item["text"], str)
):
# BlockNote structure: {"type": "text", "text": "...", "styles": {}}
title_parts.append(item["text"])
new_title = "".join(title_parts).strip()
if new_title:
document.title = new_title
else:
# Only set to "Untitled" if content exists but is empty
document.title = "Untitled"
# Save BlockNote document
document.blocknote_document = blocknote_document

View file

@ -0,0 +1,236 @@
"""
Notes routes for creating and managing BlockNote documents.
"""
from datetime import UTC, datetime
from typing import Any
from fastapi import APIRouter, Body, Depends, HTTPException
from pydantic import BaseModel
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.db import Document, DocumentType, Permission, User, get_async_session
from app.schemas import DocumentRead, PaginatedResponse
from app.users import current_active_user
from app.utils.rbac import check_permission
router = APIRouter()
class CreateNoteRequest(BaseModel):
title: str
blocknote_document: list[dict[str, Any]] | None = None
@router.post("/search-spaces/{search_space_id}/notes", response_model=DocumentRead)
async def create_note(
search_space_id: int,
request: CreateNoteRequest,
session: AsyncSession = Depends(get_async_session),
user: User = Depends(current_active_user),
):
"""
Create a new note (BlockNote document).
Requires DOCUMENTS_CREATE permission.
"""
# Check RBAC permission
await check_permission(
session,
user,
search_space_id,
Permission.DOCUMENTS_CREATE.value,
"You don't have permission to create notes in this search space",
)
if not request.title or not request.title.strip():
raise HTTPException(status_code=400, detail="Title is required")
# Default empty BlockNote structure if not provided
blocknote_document = request.blocknote_document
if blocknote_document is None:
blocknote_document = [
{
"type": "paragraph",
"content": [],
"children": [],
}
]
# Generate content hash (use title for now, will be updated on save)
import hashlib
content_hash = hashlib.sha256(request.title.encode()).hexdigest()
# Create document with NOTE type
from app.config import config
document = Document(
search_space_id=search_space_id,
title=request.title.strip(),
document_type=DocumentType.NOTE,
content="", # Empty initially, will be populated on first save/reindex
content_hash=content_hash,
blocknote_document=blocknote_document,
content_needs_reindexing=False, # Will be set to True on first save
document_metadata={"NOTE": True},
embedding=None, # Will be generated on first reindex
updated_at=datetime.now(UTC),
)
session.add(document)
await session.commit()
await session.refresh(document)
return DocumentRead(
id=document.id,
title=document.title,
document_type=document.document_type,
content=document.content,
content_hash=document.content_hash,
unique_identifier_hash=document.unique_identifier_hash,
document_metadata=document.document_metadata,
search_space_id=document.search_space_id,
created_at=document.created_at,
updated_at=document.updated_at,
)
@router.get(
"/search-spaces/{search_space_id}/notes",
response_model=PaginatedResponse[DocumentRead],
)
async def list_notes(
search_space_id: int,
skip: int | None = None,
page: int | None = None,
page_size: int = 50,
session: AsyncSession = Depends(get_async_session),
user: User = Depends(current_active_user),
):
"""
List all notes in a search space.
Requires DOCUMENTS_READ permission.
"""
# Check RBAC permission
await check_permission(
session,
user,
search_space_id,
Permission.DOCUMENTS_READ.value,
"You don't have permission to read notes in this search space",
)
from sqlalchemy import func
# Build query
query = select(Document).where(
Document.search_space_id == search_space_id,
Document.document_type == DocumentType.NOTE,
)
# Get total count
count_query = select(func.count()).select_from(
select(Document)
.where(
Document.search_space_id == search_space_id,
Document.document_type == DocumentType.NOTE,
)
.subquery()
)
total_result = await session.execute(count_query)
total = total_result.scalar() or 0
# Apply pagination
if skip is not None:
query = query.offset(skip)
elif page is not None:
query = query.offset(page * page_size)
else:
query = query.offset(0)
if page_size > 0:
query = query.limit(page_size)
# Order by updated_at descending (most recent first)
query = query.order_by(Document.updated_at.desc())
# Execute query
result = await session.execute(query)
documents = result.scalars().all()
# Convert to response models
items = [
DocumentRead(
id=doc.id,
title=doc.title,
document_type=doc.document_type,
content=doc.content,
content_hash=doc.content_hash,
unique_identifier_hash=doc.unique_identifier_hash,
document_metadata=doc.document_metadata,
search_space_id=doc.search_space_id,
created_at=doc.created_at,
updated_at=doc.updated_at,
)
for doc in documents
]
# Calculate pagination info
actual_skip = (
skip if skip is not None else (page * page_size if page is not None else 0)
)
has_more = (actual_skip + len(items)) < total if page_size > 0 else False
return PaginatedResponse(
items=items,
total=total,
page=page
if page is not None
else (actual_skip // page_size if page_size > 0 else 0),
page_size=page_size,
has_more=has_more,
)
@router.delete("/search-spaces/{search_space_id}/notes/{note_id}")
async def delete_note(
search_space_id: int,
note_id: int,
session: AsyncSession = Depends(get_async_session),
user: User = Depends(current_active_user),
):
"""
Delete a note.
Requires DOCUMENTS_DELETE permission.
"""
# Check RBAC permission
await check_permission(
session,
user,
search_space_id,
Permission.DOCUMENTS_DELETE.value,
"You don't have permission to delete notes in this search space",
)
# Get document
result = await session.execute(
select(Document).where(
Document.id == note_id,
Document.search_space_id == search_space_id,
Document.document_type == DocumentType.NOTE,
)
)
document = result.scalars().first()
if not document:
raise HTTPException(status_code=404, detail="Note not found")
# Delete document (chunks will be cascade deleted)
await session.delete(document)
await session.commit()
return {"message": "Note deleted successfully", "note_id": note_id}

View file

@ -150,7 +150,7 @@ export function DocumentsTableShell({
<>
<div className="hidden md:block max-h-[60vh] overflow-auto">
<Table className="table-fixed w-full">
<TableHeader className="sticky top-0 bg-background z-10">
<TableHeader className="sticky top-0 bg-background">
<TableRow className="hover:bg-transparent">
<TableHead style={{ width: 28 }}>
<Checkbox

View file

@ -1,27 +1,75 @@
"use client";
import { AlertCircle, FileText, Loader2, Save, X } from "lucide-react";
import { useQueryClient } from "@tanstack/react-query";
import { AlertCircle, ArrowLeft, FileText, Loader2, Save } 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";
import { notesApiService } from "@/lib/apis/notes-api.service";
import { authenticatedFetch, getBearerToken, redirectToLogin } from "@/lib/auth-utils";
import { useLogs } from "@/hooks/use-logs";
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();
const queryClient = useQueryClient();
const documentId = params.documentId as string;
const searchSpaceId = Number(params.search_space_id);
const isNewNote = documentId === "new";
const { createLog } = useLogs(searchSpaceId);
const [document, setDocument] = useState<EditorContent | null>(null);
const [loading, setLoading] = useState(true);
@ -29,10 +77,26 @@ 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
// Skip fetching if this is a new note
useEffect(() => {
async function fetchDocument() {
// For new notes, initialize with empty state
if (isNewNote) {
setDocument({
document_id: 0,
title: "Untitled",
document_type: "NOTE",
blocknote_document: null,
updated_at: null,
});
setEditorContent(null);
setLoading(false);
return;
}
const token = getBearerToken();
if (!token) {
console.error("No auth token found");
@ -51,28 +115,101 @@ export default function EditorPage() {
const errorData = await response
.json()
.catch(() => ({ detail: "Failed to fetch document" }));
throw new Error(errorData.detail || "Failed to fetch document");
const errorMessage = errorData.detail || "Failed to fetch document";
// Log fetch error
try {
await createLog({
level: "ERROR",
status: "FAILED",
message: `Failed to fetch document: ${errorMessage}`,
source: "editor",
search_space_id: searchSpaceId,
log_metadata: {
document_id: documentId,
error_type: "fetch_error",
http_status: response.status,
},
});
} catch (err) {
console.error("Failed to create log:", err);
}
throw new Error(errorMessage);
}
const data = await response.json();
// Check if blocknote_document exists
if (!data.blocknote_document) {
setError(
"This document does not have BlockNote content. Please re-upload the document to enable editing."
);
const errorMsg = "This document does not have BlockNote content. Please re-upload the document to enable editing.";
// Log missing BlockNote content
try {
await createLog({
level: "WARNING",
status: "FAILED",
message: `Document ${documentId} does not have BlockNote content`,
source: "editor",
search_space_id: searchSpaceId,
log_metadata: {
document_id: documentId,
error_type: "missing_blocknote_content",
},
});
} catch (err) {
console.error("Failed to create log:", err);
}
setError(errorMsg);
setLoading(false);
return;
}
// Log successful fetch
try {
await createLog({
level: "INFO",
status: "SUCCESS",
message: `Document ${documentId} loaded successfully`,
source: "editor",
search_space_id: searchSpaceId,
log_metadata: {
document_id: documentId,
document_type: data.document_type,
title: data.title,
},
});
} catch (err) {
console.error("Failed to create log:", err);
}
setDocument(data);
setEditorContent(data.blocknote_document);
setError(null);
} catch (error) {
console.error("Error fetching document:", error);
setError(
error instanceof Error ? error.message : "Failed to fetch document. Please try again."
);
const errorMessage =
error instanceof Error ? error.message : "Failed to fetch document. Please try again.";
// Log general fetch error
try {
await createLog({
level: "ERROR",
status: "FAILED",
message: `Error fetching document: ${errorMessage}`,
source: "editor",
search_space_id: searchSpaceId,
log_metadata: {
document_id: documentId,
error_type: "fetch_exception",
},
});
} catch (err) {
console.error("Failed to create log:", err);
}
setError(errorMessage);
} finally {
setLoading(false);
}
@ -81,7 +218,7 @@ export default function EditorPage() {
if (documentId) {
fetchDocument();
}
}, [documentId, params.search_space_id]);
}, [documentId, params.search_space_id, isNewNote, searchSpaceId, createLog]);
// Track changes to mark as unsaved
useEffect(() => {
@ -90,9 +227,21 @@ export default function EditorPage() {
}
}, [editorContent, document]);
// Check if this is a NOTE type document
const isNote = isNewNote || 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
// For new notes, create the note first, then save
const handleSave = async () => {
const token = getBearerToken();
if (!token) {
@ -101,57 +250,217 @@ export default function EditorPage() {
return;
}
if (!editorContent) {
toast.error("No content to save");
return;
}
setSaving(true);
setError(null);
try {
// Save blocknote_document and trigger reindexing in background
const response = await authenticatedFetch(
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/search-spaces/${params.search_space_id}/documents/${documentId}/save`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ blocknote_document: editorContent }),
// If this is a new note, create it first
if (isNewNote) {
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 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" }));
// Log save error
try {
await createLog({
level: "ERROR",
status: "FAILED",
message: `Failed to save new note: ${errorData.detail || "Unknown error"}`,
source: "editor",
search_space_id: searchSpaceId,
log_metadata: {
document_id: note.id,
is_new_note: true,
action: "save",
http_status: response.status,
},
});
} catch (err) {
console.error("Failed to create log:", err);
}
throw new Error(errorData.detail || "Failed to save document");
}
}
);
if (!response.ok) {
const errorData = await response
.json()
.catch(() => ({ detail: "Failed to save document" }));
throw new Error(errorData.detail || "Failed to save document");
// Log successful note creation
try {
await createLog({
level: "INFO",
status: "SUCCESS",
message: `Note created successfully: ${title}`,
source: "editor",
search_space_id: searchSpaceId,
log_metadata: {
document_id: note.id,
is_new_note: true,
action: "save",
title: title,
},
});
} catch (err) {
console.error("Failed to create log:", err);
}
setHasUnsavedChanges(false);
toast.success("Note created successfully! Reindexing in background...");
// Invalidate notes query to refresh the sidebar
queryClient.invalidateQueries({
queryKey: ["notes", String(searchSpaceId)],
});
// Update URL to reflect the new document ID without navigation
window.history.replaceState({}, "", `/dashboard/${searchSpaceId}/editor/${note.id}`);
// Update document state to reflect the new ID
setDocument({
document_id: note.id,
title: title,
document_type: "NOTE",
blocknote_document: editorContent,
updated_at: new Date().toISOString(),
});
} else {
// Existing document - save normally
if (!editorContent) {
toast.error("No content to save");
setSaving(false);
return;
}
// Save blocknote_document and trigger reindexing in background
const response = await authenticatedFetch(
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/search-spaces/${params.search_space_id}/documents/${documentId}/save`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ blocknote_document: editorContent }),
}
);
if (!response.ok) {
const errorData = await response
.json()
.catch(() => ({ detail: "Failed to save document" }));
// Log save error
try {
await createLog({
level: "ERROR",
status: "FAILED",
message: `Failed to save document ${documentId}: ${errorData.detail || "Unknown error"}`,
source: "editor",
search_space_id: searchSpaceId,
log_metadata: {
document_id: documentId,
action: "save",
http_status: response.status,
},
});
} catch (err) {
console.error("Failed to create log:", err);
}
throw new Error(errorData.detail || "Failed to save document");
}
// Log successful save
try {
await createLog({
level: "INFO",
status: "SUCCESS",
message: `Document ${documentId} saved successfully`,
source: "editor",
search_space_id: searchSpaceId,
log_metadata: {
document_id: documentId,
action: "save",
title: document?.title,
},
});
} catch (err) {
console.error("Failed to create log:", err);
}
setHasUnsavedChanges(false);
toast.success("Document saved! Reindexing in background...");
// Invalidate notes query when updating notes to refresh the sidebar
if (isNote) {
queryClient.invalidateQueries({
queryKey: ["notes", String(searchSpaceId)],
});
}
}
setHasUnsavedChanges(false);
toast.success("Document saved! Reindexing in background...");
// Small delay before redirect to show success message
setTimeout(() => {
router.push(`/dashboard/${params.search_space_id}/documents`);
}, 500);
} catch (error) {
console.error("Error saving document:", error);
toast.error(
error instanceof Error ? error.message : "Failed to save document. Please try again."
);
const errorMessage =
error instanceof Error
? error.message
: isNewNote
? "Failed to create note. Please try again."
: "Failed to save document. Please try again.";
// Log save error
try {
await createLog({
level: "ERROR",
status: "FAILED",
message: `Error saving document: ${errorMessage}`,
source: "editor",
search_space_id: searchSpaceId,
log_metadata: {
document_id: isNewNote ? null : documentId,
is_new_note: isNewNote,
action: "save",
error_type: "save_exception",
},
});
} catch (err) {
console.error("Failed to create log:", err);
}
setError(errorMessage);
toast.error(errorMessage);
} finally {
setSaving(false);
}
};
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();
router.push(`/dashboard/${searchSpaceId}/researcher`);
}
};
const handleConfirmLeave = () => {
setShowUnsavedDialog(false);
router.push(`/dashboard/${searchSpaceId}/researcher`);
};
if (loading) {
return (
<div className="flex items-center justify-center min-h-[400px] p-6">
@ -182,9 +491,13 @@ export default function EditorPage() {
<CardDescription>{error}</CardDescription>
</CardHeader>
<CardContent>
<Button onClick={() => router.back()} variant="outline" className="w-full">
<X className="mr-2 h-4 w-4" />
Go Back
<Button
onClick={() => router.push(`/dashboard/${searchSpaceId}/researcher`)}
variant="outline"
className="gap-2"
>
<ArrowLeft className="h-4 w-4" />
Back
</Button>
</CardContent>
</Card>
@ -193,7 +506,7 @@ export default function EditorPage() {
);
}
if (!document) {
if (!document && !isNewNote) {
return (
<div className="flex items-center justify-center min-h-[400px] p-6">
<Card className="w-full max-w-md">
@ -217,26 +530,26 @@ 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 ? (
<>
<Loader2 className="h-4 w-4 animate-spin" />
Saving...
{isNewNote ? "Creating..." : "Saving..."}
</>
) : (
<>
<Save className="h-4 w-4" />
Save & Exit
Save
</>
)}
</Button>
@ -246,11 +559,43 @@ export default function EditorPage() {
{/* Editor Container */}
<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={editorContent} onChange={setEditorContent} />
<BlockNoteEditor
initialContent={isNewNote ? undefined : 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

@ -156,7 +156,12 @@ const DashboardPage = () => {
},
};
const { data: searchSpaces = [], isLoading: loading, error, refetch: refreshSearchSpaces } = useAtomValue(searchSpacesAtom);
const {
data: searchSpaces = [],
isLoading: loading,
error,
refetch: refreshSearchSpaces,
} = useAtomValue(searchSpacesAtom);
const { mutateAsync: deleteSearchSpace } = useAtomValue(deleteSearchSpaceMutationAtom);
const { data: user, isPending: isLoadingUser, error: userError } = useAtomValue(currentUserAtom);
@ -171,7 +176,7 @@ const DashboardPage = () => {
};
if (loading) return <LoadingScreen />;
if (error) return <ErrorScreen message={error?.message || 'Failed to load search spaces'} />;
if (error) return <ErrorScreen message={error?.message || "Failed to load search spaces"} />;
const handleDeleteSearchSpace = async (id: number) => {
await deleteSearchSpace({ id });

View file

@ -10,31 +10,123 @@ 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 +135,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

@ -44,6 +44,13 @@ export function DashboardBreadcrumb() {
useEffect(() => {
if (segments[2] === "editor" && segments[3] && searchSpaceId) {
const documentId = segments[3];
// Skip fetch for "new" notes
if (documentId === "new") {
setDocumentTitle(null);
return;
}
const token = getBearerToken();
if (token) {
@ -110,7 +117,14 @@ export function DashboardBreadcrumb() {
// Handle editor sub-sections (document ID)
if (section === "editor") {
const documentLabel = documentTitle || subSection;
// Handle special cases for editor
let documentLabel: string;
if (subSection === "new") {
documentLabel = "New Note";
} else {
documentLabel = documentTitle || subSection;
}
breadcrumbs.push({
label: t("documents"),
href: `/dashboard/${segments[1]}/documents`,

View file

@ -2,6 +2,7 @@
import { useAtom, useAtomValue, useSetAtom } from "jotai";
import { Trash2 } from "lucide-react";
import { useRouter } from "next/navigation";
import { useTranslations } from "next-intl";
import { useCallback, useEffect, useMemo, useState } from "react";
import { deleteChatMutationAtom } from "@/atoms/chats/chat-mutation.atoms";
@ -18,6 +19,7 @@ import {
DialogTitle,
} from "@/components/ui/dialog";
import { useQuery } from "@tanstack/react-query";
import { notesApiService } from "@/lib/apis/notes-api.service";
import { searchSpacesApiService } from "@/lib/apis/search-spaces-api.service";
import { cacheKeys } from "@/lib/query-client/cache-keys";
import { currentUserAtom } from "@/atoms/user/user-query.atoms";
@ -48,6 +50,7 @@ export function AppSidebarProvider({
}: AppSidebarProviderProps) {
const t = useTranslations("dashboard");
const tCommon = useTranslations("common");
const router = useRouter();
const setChatsQueryParams = useSetAtom(globalChatsQueryParamsAtom);
const { data: chats, error: chatError, isLoading: isLoadingChats } = useAtomValue(chatsAtom);
const [{ isPending: isDeletingChat, mutateAsync: deleteChat, error: deleteError }] =
@ -70,6 +73,22 @@ export function AppSidebarProvider({
const { data: user } = useAtomValue(currentUserAtom);
// Fetch notes
const {
data: notesData,
error: notesError,
isLoading: isLoadingNotes,
refetch: refetchNotes,
} = useQuery({
queryKey: ["notes", searchSpaceId],
queryFn: () =>
notesApiService.getNotes({
search_space_id: Number(searchSpaceId),
page_size: 5, // Get 5 notes (changed from 10)
}),
enabled: !!searchSpaceId,
});
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
const [chatToDelete, setChatToDelete] = useState<{ id: number; name: string } | null>(null);
const [isClient, setIsClient] = useState(false);
@ -162,6 +181,53 @@ export function AppSidebarProvider({
// Use fallback chats if there's an error or no chats
const displayChats = recentChats.length > 0 ? recentChats : fallbackChats;
// Transform notes to the format expected by NavNotes
const recentNotes = useMemo(() => {
if (!notesData?.items) return [];
// Sort notes by updated_at (most recent first), fallback to created_at if updated_at is null
const sortedNotes = [...notesData.items].sort((a, b) => {
const dateA = a.updated_at
? new Date(a.updated_at).getTime()
: new Date(a.created_at).getTime();
const dateB = b.updated_at
? new Date(b.updated_at).getTime()
: new Date(b.created_at).getTime();
return dateB - dateA; // Descending order (most recent first)
});
// Limit to 5 notes
return sortedNotes.slice(0, 5).map((note) => ({
name: note.title,
url: `/dashboard/${note.search_space_id}/editor/${note.id}`,
icon: "FileText",
id: note.id,
search_space_id: note.search_space_id,
actions: [
{
name: "Delete",
icon: "Trash2",
onClick: async () => {
try {
await notesApiService.deleteNote({
search_space_id: note.search_space_id,
note_id: note.id,
});
refetchNotes();
} catch (error) {
console.error("Error deleting note:", error);
}
},
},
],
}));
}, [notesData, refetchNotes]);
// Handle add note
const handleAddNote = useCallback(() => {
router.push(`/dashboard/${searchSpaceId}/editor/new`);
}, [router, searchSpaceId]);
// Memoized updated navSecondary
const updatedNavSecondary = useMemo(() => {
const updated = [...navSecondary];
@ -204,6 +270,7 @@ export function AppSidebarProvider({
navSecondary={navSecondary}
navMain={navMain}
RecentChats={[]}
RecentNotes={[]}
pageUsage={pageUsage}
/>
);
@ -216,6 +283,8 @@ export function AppSidebarProvider({
navSecondary={updatedNavSecondary}
navMain={navMain}
RecentChats={displayChats}
RecentNotes={recentNotes}
onAddNote={handleAddNote}
pageUsage={pageUsage}
/>

View file

@ -0,0 +1,397 @@
"use client";
import { FileText, type LucideIcon, MoreHorizontal, Plus, RefreshCw, Trash2 } from "lucide-react";
import { useRouter } from "next/navigation";
import { useTranslations } from "next-intl";
import { useCallback, useMemo, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { notesApiService } from "@/lib/apis/notes-api.service";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
SidebarGroup,
SidebarGroupContent,
SidebarMenu,
SidebarMenuAction,
SidebarMenuButton,
SidebarMenuItem,
} from "@/components/ui/sidebar";
import { Button } from "@/components/ui/button";
import { ScrollArea } from "@/components/ui/scroll-area";
import { cn } from "@/lib/utils";
import { useEffect, useRef } from "react";
import { createPortal } from "react-dom";
// Map of icon names to their components
const actionIconMap: Record<string, LucideIcon> = {
FileText,
Trash2,
MoreHorizontal,
RefreshCw,
};
interface NoteAction {
name: string;
icon: string;
onClick: () => void;
}
interface NoteItem {
name: string;
url: string;
icon: LucideIcon;
id?: number;
search_space_id?: number;
actions?: NoteAction[];
}
interface AllNotesSidebarProps {
open: boolean;
onOpenChange: (open: boolean) => void;
searchSpaceId: string;
onAddNote?: () => void;
hoverTimeoutRef?: React.MutableRefObject<NodeJS.Timeout | null>;
}
export function AllNotesSidebar({
open,
onOpenChange,
searchSpaceId,
onAddNote,
hoverTimeoutRef,
}: AllNotesSidebarProps) {
const t = useTranslations("sidebar");
const router = useRouter();
const [isDeleting, setIsDeleting] = useState<number | null>(null);
const sidebarRef = useRef<HTMLElement>(null);
const [sidebarLeft, setSidebarLeft] = useState(0); // Position from left edge of viewport
// Calculate the sidebar's right edge position
useEffect(() => {
if (typeof window === "undefined") return;
const updatePosition = () => {
// Find the actual sidebar element (the fixed positioned one)
const sidebarElement = document.querySelector(
'[data-slot="sidebar"][data-sidebar="sidebar"]'
) as HTMLElement;
if (sidebarElement) {
const rect = sidebarElement.getBoundingClientRect();
// Set the left position to be the right edge of the sidebar
setSidebarLeft(rect.right);
} else {
// Fallback: try to find any sidebar element
const fallbackSidebar = document.querySelector('[data-slot="sidebar"]') as HTMLElement;
if (fallbackSidebar) {
const rect = fallbackSidebar.getBoundingClientRect();
setSidebarLeft(rect.right);
} else {
// Final fallback: use CSS variable
const sidebarWidth = getComputedStyle(document.documentElement)
.getPropertyValue("--sidebar-width")
.trim();
if (sidebarWidth) {
const remValue = parseFloat(sidebarWidth);
setSidebarLeft(remValue * 16); // Convert rem to px
} else {
setSidebarLeft(256); // Default 16rem
}
}
}
};
updatePosition();
// Update on window resize and scroll
window.addEventListener("resize", updatePosition);
window.addEventListener("scroll", updatePosition, true);
// Use MutationObserver to watch for sidebar state changes
const observer = new MutationObserver(updatePosition);
const sidebarWrapper = document.querySelector('[data-slot="sidebar-wrapper"]');
if (sidebarWrapper) {
observer.observe(sidebarWrapper, {
attributes: true,
attributeFilter: ["data-state", "class"],
childList: true,
subtree: true,
});
}
// Also observe the sidebar element directly if it exists
const sidebarElement = document.querySelector('[data-slot="sidebar"]');
if (sidebarElement) {
observer.observe(sidebarElement, {
attributes: true,
attributeFilter: ["data-state", "class"],
childList: false,
subtree: false,
});
}
return () => {
window.removeEventListener("resize", updatePosition);
window.removeEventListener("scroll", updatePosition, true);
observer.disconnect();
};
}, []);
// Handle Escape key to close sidebar
useEffect(() => {
if (!open) return;
const handleEscape = (e: KeyboardEvent) => {
if (e.key === "Escape") {
onOpenChange(false);
}
};
window.addEventListener("keydown", handleEscape);
return () => window.removeEventListener("keydown", handleEscape);
}, [open, onOpenChange]);
// Fetch all notes
const {
data: notesData,
error: notesError,
isLoading: isLoadingNotes,
refetch: refetchNotes,
} = useQuery({
queryKey: ["all-notes", searchSpaceId],
queryFn: () =>
notesApiService.getNotes({
search_space_id: Number(searchSpaceId),
page_size: 1000, // Get all notes
}),
enabled: !!searchSpaceId && open, // Only fetch when sidebar is open
});
// Handle note deletion with loading state
const handleDeleteNote = useCallback(
async (noteId: number, deleteAction: () => void) => {
setIsDeleting(noteId);
try {
await deleteAction();
refetchNotes();
} finally {
setIsDeleting(null);
}
},
[refetchNotes]
);
// Transform notes to the format expected by the component
const allNotes = useMemo(() => {
return notesData?.items
? notesData.items.map((note) => ({
name: note.title,
url: `/dashboard/${note.search_space_id}/editor/${note.id}`,
icon: FileText as LucideIcon,
id: note.id,
search_space_id: note.search_space_id,
actions: [
{
name: "Delete",
icon: "Trash2",
onClick: async () => {
try {
await notesApiService.deleteNote({
search_space_id: note.search_space_id,
note_id: note.id,
});
} catch (error) {
console.error("Error deleting note:", error);
}
},
},
],
}))
: [];
}, [notesData]);
// Enhanced note item component
const NoteItemComponent = useCallback(
({ note }: { note: NoteItem }) => {
const isDeletingNote = isDeleting === note.id;
return (
<SidebarMenuItem key={note.id ? `note-${note.id}` : `note-${note.name}`}>
<SidebarMenuButton
onClick={() => {
router.push(note.url);
onOpenChange(false); // Close sidebar when navigating
}}
disabled={isDeletingNote}
className={cn("group/item relative", isDeletingNote && "opacity-50")}
>
<note.icon className="h-4 w-4 shrink-0" />
<span className={cn("truncate", isDeletingNote && "opacity-50")}>{note.name}</span>
</SidebarMenuButton>
{note.actions && note.actions.length > 0 && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<SidebarMenuAction
showOnHover
className="opacity-0 group-hover/item:opacity-100 transition-opacity"
>
<MoreHorizontal className="h-4 w-4" />
<span className="sr-only">More</span>
</SidebarMenuAction>
</DropdownMenuTrigger>
<DropdownMenuContent className="w-48" side="left" align="start">
{note.actions.map((action, actionIndex) => {
const ActionIcon = actionIconMap[action.icon] || FileText;
const isDeleteAction = action.name.toLowerCase().includes("delete");
return (
<DropdownMenuItem
key={`${action.name}-${actionIndex}`}
onClick={() => {
if (isDeleteAction) {
handleDeleteNote(note.id || 0, action.onClick);
} else {
action.onClick();
}
}}
disabled={isDeletingNote}
className={isDeleteAction ? "text-destructive" : ""}
>
<ActionIcon className="mr-2 h-4 w-4" />
<span>{isDeletingNote && isDeleteAction ? "Deleting..." : action.name}</span>
</DropdownMenuItem>
);
})}
</DropdownMenuContent>
</DropdownMenu>
)}
</SidebarMenuItem>
);
},
[isDeleting, router, onOpenChange, handleDeleteNote]
);
const sidebarContent = (
<section
ref={sidebarRef}
aria-label="All notes sidebar"
className={cn(
"fixed top-0 bottom-0 z-[100] w-80 bg-sidebar text-sidebar-foreground shadow-xl",
"transition-all duration-300 ease-out",
!open && "pointer-events-none"
)}
style={{
// Position it to slide from the right edge of the main sidebar
left: `${sidebarLeft}px`,
transform: open ? `scaleX(1)` : `scaleX(0)`,
transformOrigin: "left",
opacity: open ? 1 : 0,
}}
onMouseEnter={() => {
// Clear any pending close timeout when hovering over sidebar
if (hoverTimeoutRef?.current) {
clearTimeout(hoverTimeoutRef.current);
hoverTimeoutRef.current = null;
}
}}
onMouseLeave={() => {
// Close sidebar when mouse leaves
if (hoverTimeoutRef) {
hoverTimeoutRef.current = setTimeout(() => {
onOpenChange(false);
}, 200);
} else {
onOpenChange(false);
}
}}
>
<div className="flex h-full flex-col">
{/* Header */}
<div className="flex h-16 shrink-0 items-center justify-between px-4 border-b border-sidebar">
<h2 className="text-sm font-semibold">{t("all_notes") || "All Notes"}</h2>
</div>
{/* Content */}
<ScrollArea className="flex-1">
<div className="p-2">
<SidebarGroup>
<SidebarGroupContent>
{isLoadingNotes ? (
<SidebarMenuItem>
<SidebarMenuButton disabled>
<span className="text-xs text-muted-foreground">
{t("loading") || "Loading..."}
</span>
</SidebarMenuButton>
</SidebarMenuItem>
) : notesError ? (
<SidebarMenuItem>
<SidebarMenuButton disabled>
<span className="text-xs text-destructive">
{t("error_loading_notes") || "Error loading notes"}
</span>
</SidebarMenuButton>
</SidebarMenuItem>
) : allNotes.length > 0 ? (
<SidebarMenu className="list-none">
{allNotes.map((note) => (
<NoteItemComponent key={note.id || note.name} note={note} />
))}
</SidebarMenu>
) : (
<SidebarMenuItem className="list-none">
{onAddNote ? (
<SidebarMenuButton
onClick={() => {
onAddNote();
onOpenChange(false);
}}
className="text-muted-foreground hover:text-sidebar-foreground text-xs"
>
<Plus className="h-4 w-4" />
<span>{t("create_new_note") || "Create a new note"}</span>
</SidebarMenuButton>
) : (
<SidebarMenuButton disabled className="text-muted-foreground text-xs">
<FileText className="h-4 w-4" />
<span>{t("no_notes") || "No notes yet"}</span>
</SidebarMenuButton>
)}
</SidebarMenuItem>
)}
</SidebarGroupContent>
</SidebarGroup>
</div>
</ScrollArea>
{/* Footer with Add Note button */}
{onAddNote && (
<div className="p-2">
<Button
onClick={() => {
onAddNote();
onOpenChange(false);
}}
className="w-full"
size="sm"
>
<Plus className="mr-2 h-4 w-4" />
{t("create_new_note") || "Create a new note"}
</Button>
</div>
)}
</div>
</section>
);
// Render sidebar via portal to avoid stacking context issues
if (typeof window === "undefined") {
return null;
}
return createPortal(sidebarContent, document.body);
}

View file

@ -24,7 +24,6 @@ import {
UserPlus,
Users,
} from "lucide-react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useTheme } from "next-themes";
import { memo, useEffect, useMemo, useState } from "react";
@ -116,6 +115,7 @@ function UserAvatar({ email, size = 32 }: { email: string; size?: number }) {
}
import { NavMain } from "@/components/sidebar/nav-main";
import { NavNotes } from "@/components/sidebar/nav-notes";
import { NavProjects } from "@/components/sidebar/nav-projects";
import { NavSecondary } from "@/components/sidebar/nav-secondary";
import { PageUsageDisplay } from "@/components/sidebar/page-usage-display";
@ -139,13 +139,13 @@ export const iconMap: Record<string, LucideIcon> = {
MessageCircleMore,
Settings2,
SquareLibrary,
FileText,
SquareTerminal,
AlertCircle,
Info,
ExternalLink,
Trash2,
Podcast,
FileText,
Users,
};
@ -210,6 +210,20 @@ const defaultData = {
id: 1003,
},
],
RecentNotes: [
{
name: "Meeting Notes",
url: "#",
icon: "FileText",
id: 2001,
},
{
name: "Project Ideas",
url: "#",
icon: "FileText",
id: 2002,
},
],
};
interface AppSidebarProps extends React.ComponentProps<typeof Sidebar> {
@ -241,6 +255,18 @@ interface AppSidebarProps extends React.ComponentProps<typeof Sidebar> {
onClick: () => void;
}[];
}[];
RecentNotes?: {
name: string;
url: string;
icon: string;
id?: number;
search_space_id?: number;
actions?: {
name: string;
icon: string;
onClick: () => void;
}[];
}[];
user?: {
name: string;
email: string;
@ -250,6 +276,7 @@ interface AppSidebarProps extends React.ComponentProps<typeof Sidebar> {
pagesUsed: number;
pagesLimit: number;
};
onAddNote?: () => void;
}
// Memoized AppSidebar component for better performance
@ -258,7 +285,9 @@ export const AppSidebar = memo(function AppSidebar({
navMain = defaultData.navMain,
navSecondary = defaultData.navSecondary,
RecentChats = defaultData.RecentChats,
RecentNotes = defaultData.RecentNotes,
pageUsage,
onAddNote,
...props
}: AppSidebarProps) {
const router = useRouter();
@ -296,6 +325,16 @@ export const AppSidebar = memo(function AppSidebar({
);
}, [RecentChats]);
// Process RecentNotes to resolve icon names to components
const processedRecentNotes = useMemo(() => {
return (
RecentNotes?.map((item) => ({
...item,
icon: iconMap[item.icon] || FileText,
})) || []
);
}, [RecentNotes]);
// Get user display name from email
const userDisplayName = user?.email ? user.email.split("@")[0] : "User";
const userEmail = user?.email || (isLoadingUser ? "Loading..." : "Unknown");
@ -413,6 +452,10 @@ export const AppSidebar = memo(function AppSidebar({
<NavProjects chats={processedRecentChats} />
</div>
)}
<div className="space-y-2">
<NavNotes notes={processedRecentNotes} onAddNote={onAddNote} searchSpaceId={searchSpaceId} />
</div>
</SidebarContent>
<SidebarFooter>
{pageUsage && (

View file

@ -0,0 +1,246 @@
"use client";
import {
ChevronRight,
ExternalLink,
Eye,
FileText,
type LucideIcon,
MoreHorizontal,
Plus,
RefreshCw,
Share,
Trash2,
} from "lucide-react";
import { useRouter } from "next/navigation";
import { useTranslations } from "next-intl";
import { useCallback, useState, useRef } from "react";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
SidebarGroup,
SidebarGroupContent,
SidebarGroupLabel,
SidebarMenu,
SidebarMenuAction,
SidebarMenuButton,
SidebarMenuItem,
useSidebar,
} from "@/components/ui/sidebar";
import { AllNotesSidebar } from "./all-notes-sidebar";
// Map of icon names to their components
const actionIconMap: Record<string, LucideIcon> = {
ExternalLink,
FileText,
Share,
Trash2,
MoreHorizontal,
RefreshCw,
};
interface NoteAction {
name: string;
icon: string;
onClick: () => void;
}
interface NoteItem {
name: string;
url: string;
icon: LucideIcon;
id?: number;
search_space_id?: number;
actions?: NoteAction[];
}
interface NavNotesProps {
notes: NoteItem[];
onAddNote?: () => void;
defaultOpen?: boolean;
searchSpaceId?: string;
}
export function NavNotes({ notes, onAddNote, defaultOpen = true, searchSpaceId }: NavNotesProps) {
const t = useTranslations("sidebar");
const { isMobile } = useSidebar();
const router = useRouter();
const [isDeleting, setIsDeleting] = useState<number | null>(null);
const [isOpen, setIsOpen] = useState(defaultOpen);
const [isAllNotesSidebarOpen, setIsAllNotesSidebarOpen] = useState(false);
const hoverTimeoutRef = useRef<NodeJS.Timeout | null>(null);
// Handle note deletion with loading state
const handleDeleteNote = useCallback(async (noteId: number, deleteAction: () => void) => {
setIsDeleting(noteId);
try {
await deleteAction();
} finally {
setIsDeleting(null);
}
}, []);
// Enhanced note item component
const NoteItemComponent = useCallback(
({ note }: { note: NoteItem }) => {
const isDeletingNote = isDeleting === note.id;
return (
<SidebarMenuItem key={note.id ? `note-${note.id}` : `note-${note.name}`}>
<SidebarMenuButton
onClick={() => router.push(note.url)}
disabled={isDeletingNote}
className={`group/item relative ${isDeletingNote ? "opacity-50" : ""}`}
>
<note.icon className="h-4 w-4 shrink-0" />
<span className={`truncate ${isDeletingNote ? "opacity-50" : ""}`}>{note.name}</span>
</SidebarMenuButton>
{note.actions && note.actions.length > 0 && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<SidebarMenuAction
showOnHover
className="opacity-0 group-hover/item:opacity-100 transition-opacity"
>
<MoreHorizontal className="h-4 w-4" />
<span className="sr-only">More</span>
</SidebarMenuAction>
</DropdownMenuTrigger>
<DropdownMenuContent
className="w-48"
side={isMobile ? "bottom" : "right"}
align={isMobile ? "end" : "start"}
>
{note.actions.map((action, actionIndex) => {
const ActionIcon = actionIconMap[action.icon] || FileText;
const isDeleteAction = action.name.toLowerCase().includes("delete");
return (
<DropdownMenuItem
key={`${action.name}-${actionIndex}`}
onClick={() => {
if (isDeleteAction) {
handleDeleteNote(note.id || 0, action.onClick);
} else {
action.onClick();
}
}}
disabled={isDeletingNote}
className={isDeleteAction ? "text-destructive" : ""}
>
<ActionIcon className="mr-2 h-4 w-4" />
<span>{isDeletingNote && isDeleteAction ? "Deleting..." : action.name}</span>
</DropdownMenuItem>
);
})}
</DropdownMenuContent>
</DropdownMenu>
)}
</SidebarMenuItem>
);
},
[isDeleting, router, isMobile, handleDeleteNote]
);
return (
<SidebarGroup className="group-data-[collapsible=icon]:hidden relative">
<Collapsible open={isOpen} onOpenChange={setIsOpen}>
<div className="flex items-center group/header relative">
<CollapsibleTrigger asChild>
<SidebarGroupLabel className="cursor-pointer rounded-md px-2 py-1.5 -mx-2 transition-colors flex items-center gap-1.5">
<ChevronRight
className={`h-3.5 w-3.5 text-muted-foreground transition-all duration-200 shrink-0 hover:text-sidebar-foreground ${
isOpen ? "rotate-90" : ""
}`}
/>
<span>{t("notes") || "Notes"}</span>
</SidebarGroupLabel>
</CollapsibleTrigger>
<div className="absolute top-1.5 right-1 flex items-center gap-0.5 opacity-0 group-hover/header:opacity-100 transition-opacity">
{searchSpaceId && notes.length > 0 && (
<button
type="button"
onMouseEnter={(e) => {
e.stopPropagation();
// Clear any pending close timeout
if (hoverTimeoutRef.current) {
clearTimeout(hoverTimeoutRef.current);
hoverTimeoutRef.current = null;
}
setIsAllNotesSidebarOpen(true);
}}
onMouseLeave={(e) => {
e.stopPropagation();
// Add a small delay before closing to allow moving to the sidebar
hoverTimeoutRef.current = setTimeout(() => {
setIsAllNotesSidebarOpen(false);
}, 200);
}}
aria-label="View all notes"
className="text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground flex aspect-square w-5 items-center justify-center rounded-md p-0 outline-hidden transition-transform focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0 after:absolute after:-inset-2 md:after:hidden relative"
>
<Eye className="h-4 w-4" />
</button>
)}
{onAddNote && (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onAddNote();
}}
aria-label="Add note"
className="text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground flex aspect-square w-5 items-center justify-center rounded-md p-0 outline-hidden transition-transform focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0 after:absolute after:-inset-2 md:after:hidden relative"
>
<Plus className="h-4 w-4" />
</button>
)}
</div>
</div>
<CollapsibleContent>
<SidebarGroupContent>
<SidebarMenu>
{/* Note Items */}
{notes.length > 0 ? (
notes.map((note) => <NoteItemComponent key={note.id || note.name} note={note} />)
) : (
/* Empty state with create button */
<SidebarMenuItem>
{onAddNote ? (
<SidebarMenuButton
onClick={onAddNote}
className="text-muted-foreground hover:text-sidebar-foreground text-xs"
>
<Plus className="h-4 w-4" />
<span>{t("create_new_note") || "Create a new note"}</span>
</SidebarMenuButton>
) : (
<SidebarMenuButton disabled className="text-muted-foreground text-xs">
<FileText className="h-4 w-4" />
<span>{t("no_notes") || "No notes yet"}</span>
</SidebarMenuButton>
)}
</SidebarMenuItem>
)}
</SidebarMenu>
</SidebarGroupContent>
</CollapsibleContent>
</Collapsible>
{searchSpaceId && (
<AllNotesSidebar
open={isAllNotesSidebarOpen}
onOpenChange={setIsAllNotesSidebarOpen}
searchSpaceId={searchSpaceId}
onAddNote={onAddNote}
hoverTimeoutRef={hoverTimeoutRef}
/>
)}
</SidebarGroup>
);
}

View file

@ -148,19 +148,6 @@ export function NavProjects({ chats }: { chats: ChatItem[] }) {
return (
<SidebarGroup className="group-data-[collapsible=icon]:hidden">
<SidebarGroupLabel>{t("recent_chats")}</SidebarGroupLabel>
{/* Search Input */}
{showSearch && (
<div className="px-2 pb-2">
<SidebarInput
placeholder={t("search_chats")}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="h-8"
/>
</div>
)}
<SidebarMenu>
{/* Chat Items */}
{filteredChats.length > 0 ? (

View file

@ -60,4 +60,4 @@ export function PageUsageDisplay({ pagesUsed, pagesLimit }: PageUsageDisplayProp
</SidebarGroupContent>
</SidebarGroup>
);
}
}

View file

@ -17,7 +17,7 @@ import {
IconTicket,
IconWorldWww,
} from "@tabler/icons-react";
import { File, Globe, Link, Microscope, Search, Sparkles, Telescope, Webhook } from "lucide-react";
import { File, FileText, Globe, Link, Microscope, Search, Sparkles, Telescope, Webhook } from "lucide-react";
import { EnumConnectorName } from "./connector";
export const getConnectorIcon = (connectorType: EnumConnectorName | string, className?: string) => {
@ -71,6 +71,8 @@ export const getConnectorIcon = (connectorType: EnumConnectorName | string, clas
return <IconBrandYoutube {...iconProps} />;
case "FILE":
return <File {...iconProps} />;
case "NOTE":
return <FileText {...iconProps} />;
case "EXTENSION":
return <Webhook {...iconProps} />;
case "DEEP":

View file

@ -0,0 +1,148 @@
import { z } from "zod";
import { ValidationError } from "../error";
import { baseApiService } from "./base-api.service";
// Request/Response schemas
const createNoteRequest = z.object({
search_space_id: z.number(),
title: z.string().min(1),
blocknote_document: z.array(z.any()).optional(),
});
const createNoteResponse = z.object({
id: z.number(),
title: z.string(),
document_type: z.string(),
content: z.string(),
content_hash: z.string(),
unique_identifier_hash: z.string().nullable(),
document_metadata: z.record(z.any()).nullable(),
search_space_id: z.number(),
created_at: z.string(),
updated_at: z.string().nullable(),
});
const getNotesRequest = z.object({
search_space_id: z.number(),
skip: z.number().optional(),
page: z.number().optional(),
page_size: z.number().optional(),
});
const noteItem = z.object({
id: z.number(),
title: z.string(),
document_type: z.string(),
content: z.string(),
content_hash: z.string(),
unique_identifier_hash: z.string().nullable(),
document_metadata: z.record(z.any()).nullable(),
search_space_id: z.number(),
created_at: z.string(),
updated_at: z.string().nullable(),
});
const getNotesResponse = z.object({
items: z.array(noteItem),
total: z.number(),
page: z.number(),
page_size: z.number(),
has_more: z.boolean(),
});
const deleteNoteRequest = z.object({
search_space_id: z.number(),
note_id: z.number(),
});
const deleteNoteResponse = z.object({
message: z.string(),
note_id: z.number(),
});
// Type exports
export type CreateNoteRequest = z.infer<typeof createNoteRequest>;
export type CreateNoteResponse = z.infer<typeof createNoteResponse>;
export type GetNotesRequest = z.infer<typeof getNotesRequest>;
export type GetNotesResponse = z.infer<typeof getNotesResponse>;
export type NoteItem = z.infer<typeof noteItem>;
export type DeleteNoteRequest = z.infer<typeof deleteNoteRequest>;
export type DeleteNoteResponse = z.infer<typeof deleteNoteResponse>;
class NotesApiService {
/**
* Create a new note
*/
createNote = async (request: CreateNoteRequest) => {
const parsedRequest = createNoteRequest.safeParse(request);
if (!parsedRequest.success) {
console.error("Invalid request:", parsedRequest.error);
const errorMessage = parsedRequest.error.errors.map((err) => err.message).join(", ");
throw new ValidationError(`Invalid request: ${errorMessage}`);
}
const { search_space_id, title, blocknote_document } = parsedRequest.data;
// Send both title and blocknote_document in request body
const body = {
title,
...(blocknote_document && { blocknote_document }),
};
return baseApiService.post(
`/api/v1/search-spaces/${search_space_id}/notes`,
createNoteResponse,
{ body }
);
};
/**
* Get list of notes
*/
getNotes = async (request: GetNotesRequest) => {
const parsedRequest = getNotesRequest.safeParse(request);
if (!parsedRequest.success) {
console.error("Invalid request:", parsedRequest.error);
const errorMessage = parsedRequest.error.errors.map((err) => err.message).join(", ");
throw new ValidationError(`Invalid request: ${errorMessage}`);
}
const { search_space_id, skip, page, page_size } = parsedRequest.data;
// Build query params
const params = new URLSearchParams();
if (skip !== undefined) params.append("skip", String(skip));
if (page !== undefined) params.append("page", String(page));
if (page_size !== undefined) params.append("page_size", String(page_size));
return baseApiService.get(
`/api/v1/search-spaces/${search_space_id}/notes?${params.toString()}`,
getNotesResponse
);
};
/**
* Delete a note
*/
deleteNote = async (request: DeleteNoteRequest) => {
const parsedRequest = deleteNoteRequest.safeParse(request);
if (!parsedRequest.success) {
console.error("Invalid request:", parsedRequest.error);
const errorMessage = parsedRequest.error.errors.map((err) => err.message).join(", ");
throw new ValidationError(`Invalid request: ${errorMessage}`);
}
const { search_space_id, note_id } = parsedRequest.data;
return baseApiService.delete(
`/api/v1/search-spaces/${search_space_id}/notes/${note_id}`,
deleteNoteResponse
);
};
}
export const notesApiService = new NotesApiService();

View file

@ -642,7 +642,13 @@
"no_chats_found": "No chats found",
"no_recent_chats": "No recent chats",
"view_all_chats": "View All Chats",
"search_space": "Search Space"
"search_space": "Search Space",
"notes": "Notes",
"all_notes": "All Notes",
"no_notes": "No notes yet",
"create_new_note": "Create a new note",
"error_loading_notes": "Error loading notes",
"loading": "Loading..."
},
"errors": {
"something_went_wrong": "Something went wrong",

View file

@ -642,7 +642,13 @@
"no_chats_found": "未找到对话",
"no_recent_chats": "暂无最近对话",
"view_all_chats": "查看所有对话",
"search_space": "搜索空间"
"search_space": "搜索空间",
"notes": "笔记",
"all_notes": "所有笔记",
"no_notes": "暂无笔记",
"create_new_note": "创建新笔记",
"error_loading_notes": "加载笔记时出错",
"loading": "加载中..."
},
"errors": {
"something_went_wrong": "出错了",