feat: add note management functionality with BlockNote support

- Introduced a new ENUM value 'NOTE' for document types in the database.
- Implemented backend routes for creating, listing, and deleting notes.
- Added a new frontend page for creating notes with a BlockNote editor.
- Updated sidebar to include recent notes and an option to add new notes.
- Enhanced API service for notes with validation and request/response schemas.
- Updated translations to support new note-related terms.
This commit is contained in:
Anish Sarkar 2025-12-16 12:28:30 +05:30
parent 8aedead33e
commit 8eceb7a5cb
13 changed files with 948 additions and 36 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
@ -65,7 +65,28 @@ async def get_editor_content(
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,
"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)

View file

@ -0,0 +1,228 @@
"""
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

@ -0,0 +1,155 @@
"use client";
import { AlertCircle, FileText, Loader2, Plus, X } from "lucide-react";
import { motion } from "motion/react";
import { useParams, useRouter } from "next/navigation";
import { useState } from "react";
import { toast } from "sonner";
import { BlockNoteEditor } from "@/components/DynamicBlockNoteEditor";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Separator } from "@/components/ui/separator";
import { notesApiService } from "@/lib/apis/notes-api.service";
export default function NewNotePage() {
const params = useParams();
const router = useRouter();
const searchSpaceId = Number(params.search_space_id);
const [title, setTitle] = useState("");
const [editorContent, setEditorContent] = useState<any>(null);
const [creating, setCreating] = useState(false);
const [error, setError] = useState<string | null>(null);
const handleCreate = async () => {
if (!title.trim()) {
toast.error("Please enter a title for your note");
return;
}
setCreating(true);
setError(null);
try {
const note = await notesApiService.createNote({
search_space_id: searchSpaceId,
title: title.trim(),
blocknote_document: editorContent || undefined,
});
toast.success("Note created successfully!");
// Redirect to editor
router.push(`/dashboard/${searchSpaceId}/editor/${note.id}`);
} catch (error) {
console.error("Error creating note:", error);
const errorMessage =
error instanceof Error ? error.message : "Failed to create note. Please try again.";
setError(errorMessage);
toast.error(errorMessage);
} finally {
setCreating(false);
}
};
const handleCancel = () => {
router.back();
};
return (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
className="flex flex-col h-full w-full"
>
{/* Toolbar */}
<div className="sticky top-0 z-40 flex h-16 shrink-0 items-center gap-4 border-b bg-background/95 backdrop-blur supports-backdrop-filter:bg-background/60 px-6">
<div className="flex items-center gap-3 flex-1 min-w-0">
<FileText className="h-5 w-5 text-muted-foreground shrink-0" />
<div className="flex flex-col min-w-0">
<h1 className="text-lg font-semibold truncate">New Note</h1>
<p className="text-xs text-muted-foreground">Create a new note</p>
</div>
</div>
<Separator orientation="vertical" className="h-6" />
<div className="flex items-center gap-2">
<Button variant="outline" onClick={handleCancel} disabled={creating} className="gap-2">
<X className="h-4 w-4" />
Cancel
</Button>
<Button onClick={handleCreate} disabled={creating || !title.trim()} className="gap-2">
{creating ? (
<>
<Loader2 className="h-4 w-4 animate-spin" />
Creating...
</>
) : (
<>
<Plus className="h-4 w-4" />
Create Note
</>
)}
</Button>
</div>
</div>
{/* Content */}
<div className="flex-1 overflow-hidden relative">
<div className="h-full w-full overflow-auto p-6">
<div className="max-w-4xl mx-auto space-y-6">
{error && (
<motion.div
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
>
<Card className="border-destructive/50">
<CardHeader>
<div className="flex items-center gap-2">
<AlertCircle className="h-5 w-5 text-destructive" />
<CardTitle className="text-destructive">Error</CardTitle>
</div>
<CardDescription>{error}</CardDescription>
</CardHeader>
</Card>
</motion.div>
)}
<Card>
<CardHeader>
<CardTitle>Note Details</CardTitle>
<CardDescription>Enter a title for your note</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="title">Title</Label>
<Input
id="title"
placeholder="Enter note title..."
value={title}
onChange={(e) => setTitle(e.target.value)}
disabled={creating}
className="text-lg"
/>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Content</CardTitle>
<CardDescription>Start writing your note (optional)</CardDescription>
</CardHeader>
<CardContent>
<div className="min-h-[400px]">
<BlockNoteEditor initialContent={undefined} onChange={setEditorContent} />
</div>
</CardContent>
</Card>
</div>
</div>
</div>
</motion.div>
);
}

View file

@ -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";
@ -19,6 +20,7 @@ import {
} from "@/components/ui/dialog";
import { useUser } from "@/hooks";
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";
@ -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 { user } = useUser();
// 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: 10, // Get recent 10 notes
}),
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,41 @@ 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(() => {
return notesData?.items
? notesData.items.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}/notes/new`);
}, [router, searchSpaceId]);
// Memoized updated navSecondary
const updatedNavSecondary = useMemo(() => {
const updated = [...navSecondary];
@ -204,6 +258,7 @@ export function AppSidebarProvider({
navSecondary={navSecondary}
navMain={navMain}
RecentChats={[]}
RecentNotes={[]}
pageUsage={pageUsage}
/>
);
@ -216,6 +271,8 @@ export function AppSidebarProvider({
navSecondary={updatedNavSecondary}
navMain={navMain}
RecentChats={displayChats}
RecentNotes={recentNotes}
onAddNote={handleAddNote}
pageUsage={pageUsage}
/>

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";
@ -115,6 +114,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";
@ -209,6 +209,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> {
@ -240,6 +254,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;
@ -249,6 +275,7 @@ interface AppSidebarProps extends React.ComponentProps<typeof Sidebar> {
pagesUsed: number;
pagesLimit: number;
};
onAddNote?: () => void;
}
// Memoized AppSidebar component for better performance
@ -257,7 +284,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();
@ -295,6 +324,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");
@ -412,6 +451,10 @@ export const AppSidebar = memo(function AppSidebar({
<NavProjects chats={processedRecentChats} />
</div>
)}
<div className="space-y-2">
<NavNotes notes={processedRecentNotes} onAddNote={onAddNote} />
</div>
</SidebarContent>
<SidebarFooter>
{pageUsage && (

View file

@ -0,0 +1,214 @@
"use client";
import {
ChevronRight,
ExternalLink,
FileText,
type LucideIcon,
MoreHorizontal,
Plus,
RefreshCw,
Share,
Trash2,
} from "lucide-react";
import { useRouter } from "next/navigation";
import { useTranslations } from "next-intl";
import { useCallback, useState } 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";
// 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;
}
export function NavNotes({ notes, onAddNote, defaultOpen = true }: NavNotesProps) {
const t = useTranslations("sidebar");
const { isMobile } = useSidebar();
const router = useRouter();
const [isDeleting, setIsDeleting] = useState<number | null>(null);
const [isOpen, setIsOpen] = useState(defaultOpen);
// 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" : ""}`}
size="sm"
>
<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">
<Collapsible open={isOpen} onOpenChange={setIsOpen}>
<div className="flex items-center justify-between group/header">
<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 className="text-xs font-medium text-sidebar-foreground/70">
{t("notes") || "Notes"}
</span>
</SidebarGroupLabel>
</CollapsibleTrigger>
{onAddNote && (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onAddNote();
}}
className="opacity-0 group-hover/header:opacity-100 transition-opacity p-1 hover:bg-sidebar-accent rounded-md -mr-2 shrink-0"
aria-label="Add note"
>
<Plus className="h-3.5 w-3.5 text-muted-foreground" />
</button>
)}
</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"
size="sm"
>
<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" size="sm">
<FileText className="h-4 w-4" />
<span>{t("no_notes") || "No notes yet"}</span>
</SidebarMenuButton>
)}
</SidebarMenuItem>
)}
</SidebarMenu>
</SidebarGroupContent>
</CollapsibleContent>
</Collapsible>
</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

@ -1,6 +1,6 @@
"use client";
import { Mail } from "lucide-react";
import { ChevronRight, Mail } from "lucide-react";
import { Progress } from "@/components/ui/progress";
import {
SidebarGroup,
@ -8,6 +8,7 @@ import {
SidebarGroupLabel,
useSidebar,
} from "@/components/ui/sidebar";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
interface PageUsageDisplayProps {
pagesUsed: number;
@ -21,19 +22,16 @@ export function PageUsageDisplay({ pagesUsed, pagesLimit }: PageUsageDisplayProp
return (
<SidebarGroup>
<SidebarGroupLabel className="group-data-[collapsible=icon]:hidden">
Page Usage
</SidebarGroupLabel>
<SidebarGroupContent>
<div className="space-y-2 px-2 py-2">
{isCollapsed ? (
// Show only a compact progress indicator when collapsed
<div className="flex justify-center">
<Progress value={usagePercentage} className="h-2 w-8" />
</div>
) : (
// Show full details when expanded
<>
<Collapsible defaultOpen={false} className="group-data-[collapsible=icon]:hidden">
<CollapsibleTrigger asChild>
<SidebarGroupLabel className="cursor-pointer hover:bg-sidebar-accent rounded-md px-2 py-1.5 -mx-2 transition-colors flex items-center justify-between group">
<span>Page Usage</span>
<ChevronRight className="h-4 w-4 text-muted-foreground transition-transform duration-200 group-data-[state=open]:rotate-90" />
</SidebarGroupLabel>
</CollapsibleTrigger>
<CollapsibleContent>
<SidebarGroupContent>
<div className="space-y-2 px-2 py-2">
<div className="flex justify-between items-center text-xs">
<span className="text-muted-foreground">
{pagesUsed.toLocaleString()} / {pagesLimit.toLocaleString()} pages
@ -54,10 +52,18 @@ export function PageUsageDisplay({ pagesUsed, pagesLimit }: PageUsageDisplayProp
to increase limits
</p>
</div>
</>
)}
</div>
</SidebarGroupContent>
</div>
</SidebarGroupContent>
</CollapsibleContent>
</Collapsible>
{isCollapsed && (
// Show only a compact progress indicator when sidebar is collapsed
<SidebarGroupContent>
<div className="flex justify-center px-2 py-2">
<Progress value={usagePercentage} className="h-2 w-8" />
</div>
</SidebarGroupContent>
)}
</SidebarGroup>
);
}
}

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,10 @@
"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",
"no_notes": "No notes yet",
"create_new_note": "Create a new note"
},
"errors": {
"something_went_wrong": "Something went wrong",