diff --git a/surfsense_web/app/dashboard/[search_space_id]/documents/(manage)/components/DocumentsTableShell.tsx b/surfsense_web/app/dashboard/[search_space_id]/documents/(manage)/components/DocumentsTableShell.tsx index 203469c5f..2c515ea14 100644 --- a/surfsense_web/app/dashboard/[search_space_id]/documents/(manage)/components/DocumentsTableShell.tsx +++ b/surfsense_web/app/dashboard/[search_space_id]/documents/(manage)/components/DocumentsTableShell.tsx @@ -14,9 +14,10 @@ import { SearchX, Trash2, } from "lucide-react"; -import { useRouter } from "next/navigation"; +import { useSetAtom } from "jotai"; import { useTranslations } from "next-intl"; import React, { useCallback, useEffect, useRef, useState } from "react"; +import { openEditorPanelAtom } from "@/atoms/editor/editor-panel.atom"; import { toast } from "sonner"; import { useDocumentUploadDialog } from "@/components/assistant-ui/document-upload-popup"; import { JsonMetadataViewer } from "@/components/json-metadata-viewer"; @@ -37,7 +38,6 @@ import { ContextMenu, ContextMenuContent, ContextMenuItem, - ContextMenuSeparator, ContextMenuTrigger, } from "@/components/ui/context-menu"; import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"; @@ -223,16 +223,14 @@ function RowContextMenu({ onPreview, onDelete, searchSpaceId, - onEditNavigate, }: { doc: Document; children: React.ReactNode; onPreview: (doc: Document) => void; onDelete: (doc: Document) => void; searchSpaceId: string; - onEditNavigate?: () => void; }) { - const router = useRouter(); + const openEditor = useSetAtom(openEditorPanelAtom); const isEditable = EDITABLE_DOCUMENT_TYPES.includes( doc.document_type as (typeof EDITABLE_DOCUMENT_TYPES)[number] @@ -257,8 +255,11 @@ function RowContextMenu({ { if (!isEditDisabled) { - onEditNavigate?.(); - router.push(`/dashboard/${searchSpaceId}/editor/${doc.id}`); + openEditor({ + documentId: doc.id, + searchSpaceId: Number(searchSpaceId), + title: doc.title, + }); } }} disabled={isEditDisabled} @@ -268,16 +269,13 @@ function RowContextMenu({ )} {shouldShowDelete && ( - <> - - !isDeleteDisabled && onDelete(doc)} - disabled={isDeleteDisabled} - > - - Delete - - > + !isDeleteDisabled && onDelete(doc)} + disabled={isDeleteDisabled} + > + + Delete + )} @@ -327,7 +325,6 @@ export function DocumentsTableShell({ onLoadMore, mentionedDocIds, onToggleChatMention, - onEditNavigate, isSearchMode = false, }: { documents: Document[]; @@ -346,8 +343,6 @@ export function DocumentsTableShell({ mentionedDocIds?: Set; /** Toggle a document's mention in the chat (add if not mentioned, remove if mentioned) */ onToggleChatMention?: (doc: Document, mentioned: boolean) => void; - /** Called when user navigates to the editor via Edit — use to close containing sidebar/panel */ - onEditNavigate?: () => void; /** Whether results are filtered by a search query or type filters */ isSearchMode?: boolean; }) { @@ -374,7 +369,7 @@ export function DocumentsTableShell({ const [mobileActionDoc, setMobileActionDoc] = useState(null); const [bulkDeleteConfirmOpen, setBulkDeleteConfirmOpen] = useState(false); const [isBulkDeleting, setIsBulkDeleting] = useState(false); - const router = useRouter(); + const openEditor = useSetAtom(openEditorPanelAtom); const desktopSentinelRef = useRef(null); const mobileSentinelRef = useRef(null); @@ -701,7 +696,6 @@ export function DocumentsTableShell({ onPreview={handleViewDocument} onDelete={setDeleteDoc} searchSpaceId={searchSpaceId} - onEditNavigate={onEditNavigate} > { if (mobileActionDoc) { - onEditNavigate?.(); - router.push(`/dashboard/${searchSpaceId}/editor/${mobileActionDoc.id}`); + openEditor({ + documentId: mobileActionDoc.id, + searchSpaceId: Number(searchSpaceId), + title: mobileActionDoc.title, + }); setMobileActionDoc(null); } }} diff --git a/surfsense_web/app/dashboard/[search_space_id]/new-chat/[[...chat_id]]/page.tsx b/surfsense_web/app/dashboard/[search_space_id]/new-chat/[[...chat_id]]/page.tsx index 7a046bc9a..a96725619 100644 --- a/surfsense_web/app/dashboard/[search_space_id]/new-chat/[[...chat_id]]/page.tsx +++ b/surfsense_web/app/dashboard/[search_space_id]/new-chat/[[...chat_id]]/page.tsx @@ -30,9 +30,11 @@ import { // extractWriteTodosFromContent, } from "@/atoms/chat/plan-state.atom"; import { closeReportPanelAtom } from "@/atoms/chat/report-panel.atom"; +import { closeEditorPanelAtom } from "@/atoms/editor/editor-panel.atom"; import { membersAtom } from "@/atoms/members/members-query.atoms"; import { currentUserAtom } from "@/atoms/user/user-query.atoms"; import { Thread } from "@/components/assistant-ui/thread"; +import { MobileEditorPanel } from "@/components/editor-panel/editor-panel"; import { MobileReportPanel } from "@/components/report-panel/report-panel"; import type { ThinkingStep } from "@/components/tool-ui/deepagent-thinking"; import { DisplayImageToolUI } from "@/components/tool-ui/display-image"; @@ -195,6 +197,7 @@ export default function NewChatPage() { const setTargetCommentId = useSetAtom(setTargetCommentIdAtom); const clearTargetCommentId = useSetAtom(clearTargetCommentIdAtom); const closeReportPanel = useSetAtom(closeReportPanelAtom); + const closeEditorPanel = useSetAtom(closeEditorPanelAtom); // Get current user for author info in shared chats const { data: currentUser } = useAtomValue(currentUserAtom); @@ -286,6 +289,7 @@ export default function NewChatPage() { setMessageDocumentsMap({}); clearPlanOwnerRegistry(); closeReportPanel(); + closeEditorPanel(); try { if (urlChatId > 0) { @@ -351,6 +355,7 @@ export default function NewChatPage() { setMentionedDocuments, setSidebarDocuments, closeReportPanel, + closeEditorPanel, ]); // Initialize on mount @@ -1677,6 +1682,7 @@ export default function NewChatPage() { + ); diff --git a/surfsense_web/atoms/editor/editor-panel.atom.ts b/surfsense_web/atoms/editor/editor-panel.atom.ts new file mode 100644 index 000000000..7dc6add28 --- /dev/null +++ b/surfsense_web/atoms/editor/editor-panel.atom.ts @@ -0,0 +1,57 @@ +import { atom } from "jotai"; +import { rightPanelCollapsedAtom, rightPanelTabAtom } from "@/atoms/layout/right-panel.atom"; + +interface EditorPanelState { + isOpen: boolean; + documentId: number | null; + searchSpaceId: number | null; + title: string | null; +} + +const initialState: EditorPanelState = { + isOpen: false, + documentId: null, + searchSpaceId: null, + title: null, +}; + +export const editorPanelAtom = atom(initialState); + +export const editorPanelOpenAtom = atom((get) => get(editorPanelAtom).isOpen); + +const preEditorCollapsedAtom = atom(null); + +export const openEditorPanelAtom = atom( + null, + ( + get, + set, + { + documentId, + searchSpaceId, + title, + }: { documentId: number; searchSpaceId: number; title?: string } + ) => { + if (!get(editorPanelAtom).isOpen) { + set(preEditorCollapsedAtom, get(rightPanelCollapsedAtom)); + } + set(editorPanelAtom, { + isOpen: true, + documentId, + searchSpaceId, + title: title ?? null, + }); + set(rightPanelTabAtom, "editor"); + set(rightPanelCollapsedAtom, false); + } +); + +export const closeEditorPanelAtom = atom(null, (get, set) => { + set(editorPanelAtom, initialState); + set(rightPanelTabAtom, "sources"); + const prev = get(preEditorCollapsedAtom); + if (prev !== null) { + set(rightPanelCollapsedAtom, prev); + set(preEditorCollapsedAtom, null); + } +}); diff --git a/surfsense_web/atoms/layout/right-panel.atom.ts b/surfsense_web/atoms/layout/right-panel.atom.ts index 69335e026..fa1b80613 100644 --- a/surfsense_web/atoms/layout/right-panel.atom.ts +++ b/surfsense_web/atoms/layout/right-panel.atom.ts @@ -1,6 +1,6 @@ import { atom } from "jotai"; -export type RightPanelTab = "sources" | "report"; +export type RightPanelTab = "sources" | "report" | "editor"; export const rightPanelTabAtom = atom("sources"); diff --git a/surfsense_web/components/editor-panel/editor-panel.tsx b/surfsense_web/components/editor-panel/editor-panel.tsx new file mode 100644 index 000000000..7530a1d80 --- /dev/null +++ b/surfsense_web/components/editor-panel/editor-panel.tsx @@ -0,0 +1,295 @@ +"use client"; + +import { useAtomValue, useSetAtom } from "jotai"; +import { AlertCircle, XIcon } from "lucide-react"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { toast } from "sonner"; +import { closeEditorPanelAtom, editorPanelAtom } from "@/atoms/editor/editor-panel.atom"; +import { PlateEditor } from "@/components/editor/plate-editor"; +import { Button } from "@/components/ui/button"; +import { Drawer, DrawerContent, DrawerHandle, DrawerTitle } from "@/components/ui/drawer"; +import { useMediaQuery } from "@/hooks/use-media-query"; +import { authenticatedFetch, getBearerToken, redirectToLogin } from "@/lib/auth-utils"; + +interface EditorContent { + document_id: number; + title: string; + document_type?: string; + source_markdown: string; +} + +function EditorPanelSkeleton() { + return ( + + + + + + + + + + + + + + + + ); +} + +export function EditorPanelContent({ + documentId, + searchSpaceId, + title, + onClose, +}: { + documentId: number; + searchSpaceId: number; + title: string | null; + onClose?: () => void; +}) { + const [editorDoc, setEditorDoc] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + const [saving, setSaving] = useState(false); + + const [editedMarkdown, setEditedMarkdown] = useState(null); + const markdownRef = useRef(""); + const initialLoadDone = useRef(false); + const changeCountRef = useRef(0); + const [displayTitle, setDisplayTitle] = useState(title || "Untitled"); + + useEffect(() => { + let cancelled = false; + setIsLoading(true); + setError(null); + setEditorDoc(null); + setEditedMarkdown(null); + initialLoadDone.current = false; + changeCountRef.current = 0; + + const fetchContent = async () => { + const token = getBearerToken(); + if (!token) { + redirectToLogin(); + return; + } + + try { + const response = await authenticatedFetch( + `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/search-spaces/${searchSpaceId}/documents/${documentId}/editor-content`, + { method: "GET" } + ); + + if (cancelled) return; + + 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." + ); + setIsLoading(false); + return; + } + + markdownRef.current = data.source_markdown; + setDisplayTitle(data.title || title || "Untitled"); + setEditorDoc(data); + initialLoadDone.current = true; + } catch (err) { + if (cancelled) return; + console.error("Error fetching document:", err); + setError(err instanceof Error ? err.message : "Failed to fetch document"); + } finally { + if (!cancelled) setIsLoading(false); + } + }; + + fetchContent(); + return () => { + cancelled = true; + }; + }, [documentId, searchSpaceId, title]); + + const handleMarkdownChange = useCallback((md: string) => { + markdownRef.current = md; + if (!initialLoadDone.current) return; + changeCountRef.current += 1; + if (changeCountRef.current <= 1) return; + setEditedMarkdown(md); + }, []); + + const handleSave = useCallback(async () => { + const token = getBearerToken(); + if (!token) { + toast.error("Please login to save"); + redirectToLogin(); + return; + } + + setSaving(true); + try { + const response = await authenticatedFetch( + `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/search-spaces/${searchSpaceId}/documents/${documentId}/save`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ source_markdown: markdownRef.current }), + } + ); + + if (!response.ok) { + const errorData = await response + .json() + .catch(() => ({ detail: "Failed to save document" })); + throw new Error(errorData.detail || "Failed to save document"); + } + + setEditorDoc((prev) => (prev ? { ...prev, source_markdown: markdownRef.current } : prev)); + setEditedMarkdown(null); + toast.success("Document saved! Reindexing in background..."); + } catch (err) { + console.error("Error saving document:", err); + toast.error(err instanceof Error ? err.message : "Failed to save document"); + } finally { + setSaving(false); + } + }, [documentId, searchSpaceId]); + + return ( + <> + + + {displayTitle} + {editedMarkdown !== null && ( + Unsaved changes + )} + + {onClose && ( + + + Close editor panel + + )} + + + + {isLoading ? ( + + ) : error || !editorDoc ? ( + + + + Failed to load document + {error || "An unknown error occurred"} + + + ) : ( + + )} + + > + ); +} + +function DesktopEditorPanel() { + const panelState = useAtomValue(editorPanelAtom); + const closePanel = useSetAtom(closeEditorPanelAtom); + + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === "Escape") closePanel(); + }; + document.addEventListener("keydown", handleKeyDown); + return () => document.removeEventListener("keydown", handleKeyDown); + }, [closePanel]); + + if (!panelState.isOpen || !panelState.documentId || !panelState.searchSpaceId) return null; + + return ( + + + + ); +} + +function MobileEditorDrawer() { + const panelState = useAtomValue(editorPanelAtom); + const closePanel = useSetAtom(closeEditorPanelAtom); + + if (!panelState.documentId || !panelState.searchSpaceId) return null; + + return ( + { + if (!open) closePanel(); + }} + shouldScaleBackground={false} + > + + + {panelState.title || "Editor"} + + + + + + ); +} + +export function EditorPanel() { + const panelState = useAtomValue(editorPanelAtom); + const isDesktop = useMediaQuery("(min-width: 1024px)"); + + if (!panelState.isOpen || !panelState.documentId) return null; + + if (isDesktop) { + return ; + } + + return ; +} + +export function MobileEditorPanel() { + const panelState = useAtomValue(editorPanelAtom); + const isDesktop = useMediaQuery("(min-width: 1024px)"); + + if (isDesktop || !panelState.isOpen || !panelState.documentId) return null; + + return ; +} diff --git a/surfsense_web/components/layout/ui/right-panel/RightPanel.tsx b/surfsense_web/components/layout/ui/right-panel/RightPanel.tsx index 90a6e8886..e94982bc5 100644 --- a/surfsense_web/components/layout/ui/right-panel/RightPanel.tsx +++ b/surfsense_web/components/layout/ui/right-panel/RightPanel.tsx @@ -5,7 +5,9 @@ import { PanelRight, PanelRightClose } from "lucide-react"; import { startTransition, useEffect } from "react"; import { closeReportPanelAtom, reportPanelAtom } from "@/atoms/chat/report-panel.atom"; import { documentsSidebarOpenAtom } from "@/atoms/documents/ui.atoms"; +import { closeEditorPanelAtom, editorPanelAtom } from "@/atoms/editor/editor-panel.atom"; import { rightPanelCollapsedAtom, rightPanelTabAtom } from "@/atoms/layout/right-panel.atom"; +import { EditorPanelContent } from "@/components/editor-panel/editor-panel"; import { ReportPanelContent } from "@/components/report-panel/report-panel"; import { Button } from "@/components/ui/button"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; @@ -41,8 +43,10 @@ export function RightPanelExpandButton() { const [collapsed, setCollapsed] = useAtom(rightPanelCollapsedAtom); const documentsOpen = useAtomValue(documentsSidebarOpenAtom); const reportState = useAtomValue(reportPanelAtom); + const editorState = useAtomValue(editorPanelAtom); const reportOpen = reportState.isOpen && !!reportState.reportId; - const hasContent = documentsOpen || reportOpen; + const editorOpen = editorState.isOpen && !!editorState.documentId; + const hasContent = documentsOpen || reportOpen || editorOpen; if (!collapsed || !hasContent) return null; @@ -66,34 +70,42 @@ export function RightPanelExpandButton() { ); } -const PANEL_WIDTHS = { sources: 420, report: 640 } as const; +const PANEL_WIDTHS = { sources: 420, report: 640, editor: 640 } as const; export function RightPanel({ documentsPanel }: RightPanelProps) { const [activeTab] = useAtom(rightPanelTabAtom); const reportState = useAtomValue(reportPanelAtom); const closeReport = useSetAtom(closeReportPanelAtom); + const editorState = useAtomValue(editorPanelAtom); + const closeEditor = useSetAtom(closeEditorPanelAtom); const [collapsed, setCollapsed] = useAtom(rightPanelCollapsedAtom); const documentsOpen = documentsPanel?.open ?? false; const reportOpen = reportState.isOpen && !!reportState.reportId; + const editorOpen = editorState.isOpen && !!editorState.documentId; useEffect(() => { - if (!reportOpen) return; + if (!reportOpen && !editorOpen) return; const handleKeyDown = (e: KeyboardEvent) => { - if (e.key === "Escape") closeReport(); + if (e.key === "Escape") { + if (editorOpen) closeEditor(); + else if (reportOpen) closeReport(); + } }; document.addEventListener("keydown", handleKeyDown); return () => document.removeEventListener("keydown", handleKeyDown); - }, [reportOpen, closeReport]); + }, [reportOpen, editorOpen, closeReport, closeEditor]); - const isVisible = (documentsOpen || reportOpen) && !collapsed; + const isVisible = (documentsOpen || reportOpen || editorOpen) && !collapsed; - const effectiveTab = - activeTab === "report" && !reportOpen - ? "sources" - : activeTab === "sources" && !documentsOpen - ? "report" - : activeTab; + let effectiveTab = activeTab; + if (effectiveTab === "editor" && !editorOpen) { + effectiveTab = reportOpen ? "report" : "sources"; + } else if (effectiveTab === "report" && !reportOpen) { + effectiveTab = editorOpen ? "editor" : "sources"; + } else if (effectiveTab === "sources" && !documentsOpen) { + effectiveTab = editorOpen ? "editor" : reportOpen ? "report" : "sources"; + } const targetWidth = PANEL_WIDTHS[effectiveTab]; const collapseButton = setCollapsed(true)} />; @@ -126,6 +138,16 @@ export function RightPanel({ documentsPanel }: RightPanelProps) { /> )} + {effectiveTab === "editor" && editorOpen && ( + + + + )} ); diff --git a/surfsense_web/components/layout/ui/sidebar/DocumentsSidebar.tsx b/surfsense_web/components/layout/ui/sidebar/DocumentsSidebar.tsx index 05cfb890a..e19557aaa 100644 --- a/surfsense_web/components/layout/ui/sidebar/DocumentsSidebar.tsx +++ b/surfsense_web/components/layout/ui/sidebar/DocumentsSidebar.tsx @@ -358,7 +358,6 @@ export function DocumentsSidebar({ onLoadMore={onLoadMore} mentionedDocIds={mentionedDocIds} onToggleChatMention={handleToggleChatMention} - onEditNavigate={() => onOpenChange(false)} isSearchMode={isSearchMode || activeTypes.length > 0} />
Unsaved changes
Failed to load document
{error || "An unknown error occurred"}