mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-05-17 18:35:19 +02:00
Merge pull request #589 from AnishSarkar22/feature/note-management
Feature: Note Management system
This commit is contained in:
commit
f18425d739
20 changed files with 1801 additions and 86 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
|
||||
|
||||
|
|
@ -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
|
||||
|
|
|
|||
236
surfsense_backend/app/routes/notes_routes.py
Normal file
236
surfsense_backend/app/routes/notes_routes.py
Normal 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}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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`,
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
|
||||
|
|
|
|||
397
surfsense_web/components/sidebar/all-notes-sidebar.tsx
Normal file
397
surfsense_web/components/sidebar/all-notes-sidebar.tsx
Normal 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);
|
||||
}
|
||||
|
|
@ -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 && (
|
||||
|
|
|
|||
246
surfsense_web/components/sidebar/nav-notes.tsx
Normal file
246
surfsense_web/components/sidebar/nav-notes.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 ? (
|
||||
|
|
|
|||
|
|
@ -60,4 +60,4 @@ export function PageUsageDisplay({ pagesUsed, pagesLimit }: PageUsageDisplayProp
|
|||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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":
|
||||
|
|
|
|||
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,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",
|
||||
|
|
|
|||
|
|
@ -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": "出错了",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue