mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-04-25 00:36:31 +02:00
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:
parent
8aedead33e
commit
8eceb7a5cb
13 changed files with 948 additions and 36 deletions
|
|
@ -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
|
||||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
228
surfsense_backend/app/routes/notes_routes.py
Normal file
228
surfsense_backend/app/routes/notes_routes.py
Normal 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}
|
||||
|
||||
155
surfsense_web/app/dashboard/[search_space_id]/notes/new/page.tsx
Normal file
155
surfsense_web/app/dashboard/[search_space_id]/notes/new/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -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}
|
||||
/>
|
||||
|
||||
|
|
|
|||
|
|
@ -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 && (
|
||||
|
|
|
|||
214
surfsense_web/components/sidebar/nav-notes.tsx
Normal file
214
surfsense_web/components/sidebar/nav-notes.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -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 ? (
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
148
surfsense_web/lib/apis/notes-api.service.ts
Normal file
148
surfsense_web/lib/apis/notes-api.service.ts
Normal 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();
|
||||
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue