diff --git a/surfsense_web/app/dashboard/[search_space_id]/documents/(manage)/components/RowActions.tsx b/surfsense_web/app/dashboard/[search_space_id]/documents/(manage)/components/RowActions.tsx
index d44fae373..d05dc7f7d 100644
--- a/surfsense_web/app/dashboard/[search_space_id]/documents/(manage)/components/RowActions.tsx
+++ b/surfsense_web/app/dashboard/[search_space_id]/documents/(manage)/components/RowActions.tsx
@@ -1,9 +1,10 @@
"use client";
+import { useSetAtom } from "jotai";
import { MoreHorizontal, PenLine, Trash2 } from "lucide-react";
-import { useRouter } from "next/navigation";
import { useState } from "react";
import { toast } from "sonner";
+import { openEditorPanelAtom } from "@/atoms/editor/editor-panel.atom";
import {
AlertDialog,
AlertDialogAction,
@@ -40,7 +41,7 @@ export function RowActions({
}) {
const [isDeleteOpen, setIsDeleteOpen] = useState(false);
const [isDeleting, setIsDeleting] = useState(false);
- const router = useRouter();
+ const openEditorPanel = useSetAtom(openEditorPanelAtom);
const isEditable = EDITABLE_DOCUMENT_TYPES.includes(
document.document_type as (typeof EDITABLE_DOCUMENT_TYPES)[number]
@@ -87,7 +88,11 @@ export function RowActions({
};
const handleEdit = () => {
- router.push(`/dashboard/${searchSpaceId}/editor/${document.id}`);
+ openEditorPanel({
+ documentId: document.id,
+ searchSpaceId: Number(searchSpaceId),
+ title: document.title,
+ });
};
return (
diff --git a/surfsense_web/app/dashboard/[search_space_id]/editor/[documentId]/page.tsx b/surfsense_web/app/dashboard/[search_space_id]/editor/[documentId]/page.tsx
deleted file mode 100644
index 6bad6112a..000000000
--- a/surfsense_web/app/dashboard/[search_space_id]/editor/[documentId]/page.tsx
+++ /dev/null
@@ -1,505 +0,0 @@
-"use client";
-
-import { useAtom } from "jotai";
-import { AlertCircle, ArrowLeft, FileText } from "lucide-react";
-import { motion } from "motion/react";
-import dynamic from "next/dynamic";
-import { useParams, useRouter } from "next/navigation";
-import { useCallback, useEffect, useMemo, useRef, useState } from "react";
-import { toast } from "sonner";
-import { hasUnsavedEditorChangesAtom, pendingEditorNavigationAtom } from "@/atoms/editor/ui.atoms";
-import {
- AlertDialog,
- AlertDialogAction,
- AlertDialogCancel,
- AlertDialogContent,
- AlertDialogDescription,
- AlertDialogFooter,
- AlertDialogHeader,
- AlertDialogTitle,
-} from "@/components/ui/alert-dialog";
-import { Button, buttonVariants } from "@/components/ui/button";
-import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
-import { Skeleton } from "@/components/ui/skeleton";
-import { notesApiService } from "@/lib/apis/notes-api.service";
-import { authenticatedFetch, getBearerToken, redirectToLogin } from "@/lib/auth-utils";
-
-// Dynamically import PlateEditor (uses 'use client' internally)
-const PlateEditor = dynamic(
- () => import("@/components/editor/plate-editor").then((mod) => ({ default: mod.PlateEditor })),
- {
- ssr: false,
- loading: () => (
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- ),
- }
-);
-
-interface EditorContent {
- document_id: number;
- title: string;
- document_type?: string;
- source_markdown: string;
- updated_at: string | null;
-}
-
-/** Extract title from markdown: first # heading, or first non-empty line. */
-function extractTitleFromMarkdown(markdown: string | null | undefined): string {
- if (!markdown) return "Untitled";
- for (const line of markdown.split("\n")) {
- const trimmed = line.trim();
- if (trimmed.startsWith("# ")) return trimmed.slice(2).trim() || "Untitled";
- if (trimmed) return trimmed.slice(0, 100);
- }
- return "Untitled";
-}
-
-export default function EditorPage() {
- const params = useParams();
- const router = useRouter();
- const documentId = params.documentId as string;
- const searchSpaceId = Number(params.search_space_id);
- const isNewNote = documentId === "new";
-
- const [document, setDocument] = useState(null);
- const [loading, setLoading] = useState(true);
- const [saving, setSaving] = useState(false);
- const [error, setError] = useState(null);
- const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false);
- const [showUnsavedDialog, setShowUnsavedDialog] = useState(false);
- const [editorTitle, setEditorTitle] = useState("Untitled");
-
- // Store the latest markdown from the editor
- const markdownRef = useRef("");
- const initialLoadDone = useRef(false);
-
- // Global state for cross-component communication
- const [, setGlobalHasUnsavedChanges] = useAtom(hasUnsavedEditorChangesAtom);
- const [pendingNavigation, setPendingNavigation] = useAtom(pendingEditorNavigationAtom);
-
- // Sync local unsaved changes state with global atom
- useEffect(() => {
- setGlobalHasUnsavedChanges(hasUnsavedChanges);
- }, [hasUnsavedChanges, setGlobalHasUnsavedChanges]);
-
- // Cleanup global state when component unmounts
- useEffect(() => {
- return () => {
- setGlobalHasUnsavedChanges(false);
- setPendingNavigation(null);
- };
- }, [setGlobalHasUnsavedChanges, setPendingNavigation]);
-
- // Handle pending navigation from sidebar
- useEffect(() => {
- if (pendingNavigation) {
- if (hasUnsavedChanges) {
- setShowUnsavedDialog(true);
- } else {
- router.push(pendingNavigation);
- setPendingNavigation(null);
- }
- }
- }, [pendingNavigation, hasUnsavedChanges, router, setPendingNavigation]);
-
- // Reset state and fetch document content when documentId changes
- useEffect(() => {
- setDocument(null);
- setError(null);
- setHasUnsavedChanges(false);
- setLoading(true);
- initialLoadDone.current = false;
-
- async function fetchDocument() {
- if (isNewNote) {
- markdownRef.current = "";
- setEditorTitle("Untitled");
- setDocument({
- document_id: 0,
- title: "Untitled",
- document_type: "NOTE",
- source_markdown: "",
- updated_at: null,
- });
- setLoading(false);
- initialLoadDone.current = true;
- return;
- }
-
- const token = getBearerToken();
- if (!token) {
- redirectToLogin();
- return;
- }
-
- try {
- const response = await authenticatedFetch(
- `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/search-spaces/${params.search_space_id}/documents/${documentId}/editor-content`,
- { method: "GET" }
- );
-
- if (!response.ok) {
- const errorData = await response
- .json()
- .catch(() => ({ detail: "Failed to fetch document" }));
- throw new Error(errorData.detail || "Failed to fetch document");
- }
-
- const data = await response.json();
-
- if (data.source_markdown === undefined || data.source_markdown === null) {
- setError(
- "This document does not have editable content. Please re-upload to enable editing."
- );
- setLoading(false);
- return;
- }
-
- markdownRef.current = data.source_markdown;
- setEditorTitle(extractTitleFromMarkdown(data.source_markdown));
- setDocument(data);
- setError(null);
- initialLoadDone.current = true;
- } catch (error) {
- console.error("Error fetching document:", error);
- setError(
- error instanceof Error ? error.message : "Failed to fetch document. Please try again."
- );
- } finally {
- setLoading(false);
- }
- }
-
- if (documentId) {
- fetchDocument();
- }
- }, [documentId, params.search_space_id, isNewNote]);
-
- const isNote = isNewNote || document?.document_type === "NOTE";
-
- const displayTitle = useMemo(() => {
- if (isNote) return editorTitle;
- return document?.title || "Untitled";
- }, [isNote, document?.title, editorTitle]);
-
- // Handle markdown changes from the Plate editor
- const handleMarkdownChange = useCallback((md: string) => {
- markdownRef.current = md;
- if (initialLoadDone.current) {
- setHasUnsavedChanges(true);
- setEditorTitle(extractTitleFromMarkdown(md));
- }
- }, []);
-
- // Save handler
- const handleSave = useCallback(async () => {
- const token = getBearerToken();
- if (!token) {
- toast.error("Please login to save");
- redirectToLogin();
- return;
- }
-
- setSaving(true);
- setError(null);
-
- try {
- const currentMarkdown = markdownRef.current;
-
- if (isNewNote) {
- const title = extractTitleFromMarkdown(currentMarkdown);
-
- // Create the note
- const note = await notesApiService.createNote({
- search_space_id: searchSpaceId,
- title,
- source_markdown: currentMarkdown || undefined,
- });
-
- // If there's content, save & trigger reindexing
- if (currentMarkdown) {
- 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({ source_markdown: currentMarkdown }),
- }
- );
-
- if (!response.ok) {
- const errorData = await response
- .json()
- .catch(() => ({ detail: "Failed to save document" }));
- throw new Error(errorData.detail || "Failed to save document");
- }
- }
-
- setHasUnsavedChanges(false);
- toast.success("Note created successfully! Reindexing in background...");
- router.push(`/dashboard/${searchSpaceId}/new-chat`);
- } else {
- // Existing document — save
- 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({ source_markdown: currentMarkdown }),
- }
- );
-
- if (!response.ok) {
- const errorData = await response
- .json()
- .catch(() => ({ detail: "Failed to save document" }));
- throw new Error(errorData.detail || "Failed to save document");
- }
-
- setHasUnsavedChanges(false);
- toast.success("Document saved! Reindexing in background...");
- router.push(`/dashboard/${searchSpaceId}/new-chat`);
- }
- } catch (error) {
- console.error("Error saving document:", error);
- const errorMessage =
- error instanceof Error
- ? error.message
- : isNewNote
- ? "Failed to create note. Please try again."
- : "Failed to save document. Please try again.";
- setError(errorMessage);
- toast.error(errorMessage);
- } finally {
- setSaving(false);
- }
- }, [isNewNote, searchSpaceId, documentId, params.search_space_id, router]);
-
- const handleBack = () => {
- if (hasUnsavedChanges) {
- setShowUnsavedDialog(true);
- } else {
- router.push(`/dashboard/${searchSpaceId}/new-chat`);
- }
- };
-
- const handleConfirmLeave = () => {
- setShowUnsavedDialog(false);
- setGlobalHasUnsavedChanges(false);
- setHasUnsavedChanges(false);
-
- if (pendingNavigation) {
- router.push(pendingNavigation);
- setPendingNavigation(null);
- } else {
- router.push(`/dashboard/${searchSpaceId}/new-chat`);
- }
- };
-
- const handleSaveAndLeave = async () => {
- setShowUnsavedDialog(false);
- setPendingNavigation(null);
- await handleSave();
- };
-
- const handleCancelLeave = () => {
- setShowUnsavedDialog(false);
- setPendingNavigation(null);
- };
-
- if (loading) {
- return (
-
- {/* Top bar skeleton — real back button & file icon, skeleton title */}
-
-
-
-
-
-
-
-
- {/* Fixed toolbar placeholder — matches real toolbar styling */}
-
-
- {/* Content area skeleton — mimics document text lines */}
-
-
- {/* Title-like line */}
-
- {/* Paragraph lines */}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- );
- }
-
- if (error && !document) {
- return (
-
-
-
-
-
- {error}
-
-
-
-
-
-
-
- );
- }
-
- if (!document && !isNewNote) {
- return (
-
-
-
-
- Document not found
-
-
-
- );
- }
-
- return (
-
- {/* Toolbar */}
-
-
-
-
-
-
{displayTitle}
- {hasUnsavedChanges && (
-
Unsaved changes
- )}
-
-
-
-
- {/* Editor Container */}
-
- {error && (
-
-
-
- )}
-
-
-
- {/* Unsaved Changes Dialog */}
- {
- if (!open) handleCancelLeave();
- }}
- >
-
-
- Unsaved Changes
-
- You have unsaved changes. Are you sure you want to leave?
-
-
-
- Cancel
-
- Leave without saving
-
- Save
-
-
-
-
- );
-}
diff --git a/surfsense_web/atoms/editor/ui.atoms.ts b/surfsense_web/atoms/editor/ui.atoms.ts
deleted file mode 100644
index 81a89a945..000000000
--- a/surfsense_web/atoms/editor/ui.atoms.ts
+++ /dev/null
@@ -1,27 +0,0 @@
-import { atom } from "jotai";
-
-interface EditorUIState {
- hasUnsavedChanges: boolean;
- pendingNavigation: string | null; // URL to navigate to after user confirms
-}
-
-export const editorUIAtom = atom({
- hasUnsavedChanges: false,
- pendingNavigation: null,
-});
-
-// Derived atom for just the unsaved changes state
-export const hasUnsavedEditorChangesAtom = atom(
- (get) => get(editorUIAtom).hasUnsavedChanges,
- (get, set, value: boolean) => {
- set(editorUIAtom, { ...get(editorUIAtom), hasUnsavedChanges: value });
- }
-);
-
-// Derived atom for pending navigation
-export const pendingEditorNavigationAtom = atom(
- (get) => get(editorUIAtom).pendingNavigation,
- (get, set, value: string | null) => {
- set(editorUIAtom, { ...get(editorUIAtom), pendingNavigation: value });
- }
-);