2026-03-27 01:39:15 -07:00
"use client" ;
2026-04-24 21:34:55 +05:30
import { Download , FileQuestionMark , FileText , Pencil , RefreshCw } from "lucide-react" ;
2026-04-08 00:01:30 -07:00
import { useRouter } from "next/navigation" ;
2026-06-05 14:57:52 +05:30
import { useCallback , useEffect , useMemo , useRef , useState } from "react" ;
2026-03-27 01:39:15 -07:00
import { toast } from "sonner" ;
import { PlateEditor } from "@/components/editor/plate-editor" ;
2026-06-05 14:23:18 +05:30
import { SourceCodeEditor } from "@/components/editor/source-code-editor" ;
2026-03-27 01:39:15 -07:00
import { MarkdownViewer } from "@/components/markdown-viewer" ;
2026-04-02 19:39:10 -07:00
import { Alert , AlertDescription } from "@/components/ui/alert" ;
2026-03-27 01:39:15 -07:00
import { Button } from "@/components/ui/button" ;
2026-04-22 23:06:49 +05:30
import { Spinner } from "@/components/ui/spinner" ;
2026-03-27 01:39:15 -07:00
import { authenticatedFetch , getBearerToken , redirectToLogin } from "@/lib/auth-utils" ;
2026-05-21 13:37:55 -07:00
import { BACKEND_URL } from "@/lib/env-config" ;
2026-05-28 19:21:29 -07:00
2026-04-02 19:39:10 -07:00
const LARGE_DOCUMENT_THRESHOLD = 2 * 1024 * 1024 ; // 2MB
2026-03-27 01:39:15 -07:00
interface DocumentContent {
document_id : number ;
title : string ;
document_type? : string ;
source_markdown : string ;
2026-04-02 19:39:10 -07:00
content_size_bytes? : number ;
chunk_count? : number ;
2026-06-05 14:23:18 +05:30
viewer_mode? : ViewerMode ;
2026-06-05 14:57:52 +05:30
editor_plate_max_bytes? : number ;
2026-03-27 01:39:15 -07:00
}
function DocumentSkeleton() {
return (
< div className = "space-y-6 p-8 max-w-4xl mx-auto" >
< div className = "h-8 w-3/4 rounded-md bg-muted/60 animate-pulse" / >
< div className = "space-y-3" >
< div className = "h-4 w-full rounded-md bg-muted/60 animate-pulse" / >
< div className = "h-4 w-[95%] rounded-md bg-muted/60 animate-pulse [animation-delay:100ms]" / >
< div className = "h-4 w-[88%] rounded-md bg-muted/60 animate-pulse [animation-delay:200ms]" / >
< div className = "h-4 w-[60%] rounded-md bg-muted/60 animate-pulse [animation-delay:300ms]" / >
< / div >
< div className = "h-6 w-2/5 rounded-md bg-muted/60 animate-pulse [animation-delay:400ms]" / >
< div className = "space-y-3" >
< div className = "h-4 w-full rounded-md bg-muted/60 animate-pulse [animation-delay:500ms]" / >
< div className = "h-4 w-[92%] rounded-md bg-muted/60 animate-pulse [animation-delay:600ms]" / >
< div className = "h-4 w-[75%] rounded-md bg-muted/60 animate-pulse [animation-delay:700ms]" / >
< / div >
< / div >
) ;
}
interface DocumentTabContentProps {
documentId : number ;
searchSpaceId : number ;
title? : string ;
}
2026-03-29 22:29:40 +05:30
const EDITABLE_DOCUMENT_TYPES = new Set ( [ "FILE" , "NOTE" ] ) ;
2026-06-05 14:23:18 +05:30
type ViewerMode = "plate" | "monaco" ;
2026-03-29 22:29:40 +05:30
2026-06-05 14:57:52 +05:30
function getUtf8ByteSize ( value : string ) : number {
return new TextEncoder ( ) . encode ( value ) . byteLength ;
}
function formatBytes ( bytes : number ) : string {
if ( bytes >= 1024 * 1024 ) {
return ` ${ ( bytes / 1024 / 1024 ) . toFixed ( 1 ) } MB ` ;
}
if ( bytes >= 1024 ) {
return ` ${ Math . round ( bytes / 1024 ) } KB ` ;
}
return ` ${ bytes } B ` ;
}
2026-03-27 01:39:15 -07:00
export function DocumentTabContent ( { documentId , searchSpaceId , title } : DocumentTabContentProps ) {
const [ doc , setDoc ] = useState < DocumentContent | null > ( null ) ;
const [ isLoading , setIsLoading ] = useState ( true ) ;
const [ error , setError ] = useState < string | null > ( null ) ;
const [ isEditing , setIsEditing ] = useState ( false ) ;
const [ saving , setSaving ] = useState ( false ) ;
2026-04-02 19:39:10 -07:00
const [ downloading , setDownloading ] = useState ( false ) ;
2026-03-27 01:39:15 -07:00
const [ editedMarkdown , setEditedMarkdown ] = useState < string | null > ( null ) ;
const markdownRef = useRef < string > ( "" ) ;
const initialLoadDone = useRef ( false ) ;
const changeCountRef = useRef ( 0 ) ;
2026-04-08 00:01:30 -07:00
const router = useRouter ( ) ;
2026-03-27 01:39:15 -07:00
2026-06-05 14:57:52 +05:30
const plateMaxBytes = doc ? . editor_plate_max_bytes ? ? LARGE_DOCUMENT_THRESHOLD ;
const isLargeDocument = ( doc ? . content_size_bytes ? ? 0 ) > plateMaxBytes ;
2026-06-05 14:23:18 +05:30
const viewerMode : ViewerMode = doc ? . viewer_mode ? ? ( isLargeDocument ? "monaco" : "plate" ) ;
2026-06-05 14:57:52 +05:30
const activeMarkdown = editedMarkdown ? ? doc ? . source_markdown ? ? "" ;
const activeMarkdownSizeBytes = useMemo ( ( ) = > getUtf8ByteSize ( activeMarkdown ) , [ activeMarkdown ] ) ;
const isNearPlateLimit = activeMarkdownSizeBytes >= plateMaxBytes * 0.9 ;
const isOverPlateLimit = activeMarkdownSizeBytes > plateMaxBytes ;
2026-04-02 19:39:10 -07:00
2026-03-27 01:39:15 -07:00
useEffect ( ( ) = > {
2026-04-03 14:29:41 +05:30
const controller = new AbortController ( ) ;
2026-03-27 01:39:15 -07:00
setIsLoading ( true ) ;
setError ( null ) ;
setDoc ( null ) ;
setIsEditing ( false ) ;
setEditedMarkdown ( null ) ;
initialLoadDone . current = false ;
changeCountRef . current = 0 ;
2026-04-03 14:29:41 +05:30
const doFetch = async ( ) = > {
2026-03-27 01:39:15 -07:00
const token = getBearerToken ( ) ;
if ( ! token ) {
redirectToLogin ( ) ;
return ;
}
try {
2026-04-02 19:39:10 -07:00
const url = new URL (
2026-05-19 01:29:31 +05:30
` ${ BACKEND_URL } /api/v1/search-spaces/ ${ searchSpaceId } /documents/ ${ documentId } /editor-content `
2026-03-27 01:39:15 -07:00
) ;
2026-04-02 19:39:10 -07:00
const response = await authenticatedFetch ( url . toString ( ) , { method : "GET" } ) ;
2026-03-27 01:39:15 -07:00
2026-04-03 14:29:41 +05:30
if ( controller . signal . aborted ) return ;
2026-03-27 01:39:15 -07:00
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 viewable content." ) ;
setIsLoading ( false ) ;
return ;
}
markdownRef . current = data . source_markdown ;
setDoc ( data ) ;
initialLoadDone . current = true ;
} catch ( err ) {
2026-04-03 14:29:41 +05:30
if ( controller . signal . aborted ) return ;
2026-03-27 01:39:15 -07:00
console . error ( "Error fetching document:" , err ) ;
setError ( err instanceof Error ? err . message : "Failed to fetch document" ) ;
} finally {
2026-04-03 14:29:41 +05:30
if ( ! controller . signal . aborted ) setIsLoading ( false ) ;
2026-03-27 01:39:15 -07:00
}
} ;
2026-04-03 14:29:41 +05:30
doFetch ( ) . catch ( ( ) = > { } ) ;
return ( ) = > controller . abort ( ) ;
2026-03-27 01:39:15 -07:00
} , [ documentId , searchSpaceId ] ) ;
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 (
2026-05-19 01:29:31 +05:30
` ${ BACKEND_URL } /api/v1/search-spaces/ ${ searchSpaceId } /documents/ ${ documentId } /save ` ,
2026-03-27 01:39:15 -07:00
{
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" ) ;
}
setDoc ( ( prev ) = > ( prev ? { . . . prev , source_markdown : markdownRef.current } : prev ) ) ;
setEditedMarkdown ( null ) ;
2026-06-05 14:57:52 +05:30
const savedSizeBytes = getUtf8ByteSize ( markdownRef . current ) ;
if ( savedSizeBytes > plateMaxBytes ) {
toast . success ( "Document saved. It will reopen in raw markdown mode." ) ;
} else {
toast . success ( "Document saved! Reindexing in background..." ) ;
}
2026-03-27 01:39:15 -07:00
} catch ( err ) {
console . error ( "Error saving document:" , err ) ;
toast . error ( err instanceof Error ? err . message : "Failed to save document" ) ;
} finally {
setSaving ( false ) ;
}
2026-06-05 14:57:52 +05:30
} , [ documentId , plateMaxBytes , searchSpaceId ] ) ;
2026-03-27 01:39:15 -07:00
if ( isLoading ) return < DocumentSkeleton / > ;
if ( error || ! doc ) {
2026-04-01 20:31:45 +05:30
const isProcessing = error ? . toLowerCase ( ) . includes ( "still being processed" ) ;
2026-03-27 01:39:15 -07:00
return (
2026-04-01 20:31:45 +05:30
< div className = "flex flex-1 flex-col items-center justify-center gap-4 p-8 text-center" >
< div className = "rounded-full bg-muted/50 p-4" >
{ isProcessing ? (
< RefreshCw className = "size-8 text-muted-foreground animate-spin" / >
) : (
< FileQuestionMark className = "size-8 text-muted-foreground" / >
) }
< / div >
< div className = "space-y-1.5 max-w-sm" >
< p className = "font-semibold text-foreground text-lg" >
{ isProcessing ? "Document is processing" : "Document unavailable" }
< / p >
2026-04-03 13:14:40 +05:30
< p className = "text-sm text-muted-foreground" > { error || "An unknown error occurred" } < / p >
2026-03-27 01:39:15 -07:00
< / div >
2026-04-01 20:31:45 +05:30
{ ! isProcessing && (
< Button
variant = "outline"
size = "sm"
className = "mt-1 gap-1.5"
2026-04-08 00:01:30 -07:00
onClick = { ( ) = > router . refresh ( ) }
2026-04-01 20:31:45 +05:30
>
< RefreshCw className = "size-3.5" / >
Retry
< / Button >
) }
2026-03-27 01:39:15 -07:00
< / div >
) ;
}
2026-06-05 14:23:18 +05:30
const isEditable = viewerMode === "plate" && EDITABLE_DOCUMENT_TYPES . has ( doc . document_type ? ? "" ) ;
2026-03-29 22:29:40 +05:30
2026-06-05 14:23:18 +05:30
if ( isEditing && viewerMode === "plate" ) {
2026-03-27 01:39:15 -07:00
return (
< div className = "flex flex-col h-full overflow-hidden" >
< div className = "flex items-center justify-between px-6 py-3 border-b shrink-0" >
< div className = "flex-1 min-w-0" >
2026-03-27 03:17:05 -07:00
< h1 className = "text-base font-semibold truncate" > { doc . title || title || "Untitled" } < / h1 >
2026-03-27 01:39:15 -07:00
{ editedMarkdown !== null && (
< p className = "text-xs text-muted-foreground" > Unsaved changes < / p >
) }
< / div >
< Button
variant = "outline"
size = "sm"
onClick = { ( ) = > {
setIsEditing ( false ) ;
setEditedMarkdown ( null ) ;
changeCountRef . current = 0 ;
} }
>
Done editing
< / Button >
< / div >
2026-06-05 14:57:52 +05:30
< div className = "flex-1 min-h-0 overflow-hidden flex flex-col" >
{ isNearPlateLimit && (
< Alert className = "m-4 mb-0 shrink-0" >
< FileText className = "size-4" / >
< AlertDescription >
{ isOverPlateLimit
? ` This document is ${ formatBytes ( activeMarkdownSizeBytes ) } , above the rich editor limit of ${ formatBytes ( plateMaxBytes ) } . You can save, but it will reopen in raw markdown mode. `
: ` This document is approaching the rich editor limit ( ${ formatBytes ( activeMarkdownSizeBytes ) } of ${ formatBytes ( plateMaxBytes ) } ). ` }
< / AlertDescription >
< / Alert >
) }
< div className = "min-h-0 flex-1 overflow-hidden" >
< PlateEditor
key = { ` edit- ${ documentId } ` }
preset = "full"
markdown = { doc . source_markdown }
onMarkdownChange = { handleMarkdownChange }
readOnly = { false }
placeholder = "Start writing..."
editorVariant = "default"
onSave = { handleSave }
hasUnsavedChanges = { editedMarkdown !== null }
isSaving = { saving }
defaultEditing = { true }
/ >
< / div >
2026-03-27 01:39:15 -07:00
< / div >
< / div >
) ;
}
return (
< div className = "flex flex-col h-full overflow-hidden" >
< div className = "flex items-center justify-between px-6 py-3 border-b shrink-0" >
< h1 className = "text-base font-semibold truncate flex-1 min-w-0" >
{ doc . title || title || "Untitled" }
< / h1 >
2026-03-29 22:29:40 +05:30
{ isEditable && (
2026-03-27 03:17:05 -07:00
< Button
variant = "outline"
size = "sm"
onClick = { ( ) = > setIsEditing ( true ) }
className = "gap-1.5"
>
2026-04-24 02:33:57 +05:30
< Pencil className = "size-3.5" / >
2026-03-27 01:39:15 -07:00
Edit
< / Button >
) }
< / div >
2026-06-05 14:23:18 +05:30
< div className = "flex-1 min-h-0 overflow-hidden" >
{ viewerMode === "monaco" ? (
< div className = "flex h-full min-h-0 flex-col" >
< Alert className = "m-4 shrink-0" >
< FileText className = "size-4" / >
< AlertDescription className = "flex items-center justify-between gap-4" >
< span >
This document is too large for the editor (
{ Math . round ( ( doc . content_size_bytes ? ? 0 ) / 1024 / 1024 ) } MB , { " " }
{ doc . chunk_count ? ? 0 } chunks ) . Showing raw markdown below .
< / span >
< Button
variant = "outline"
size = "sm"
className = "relative shrink-0"
disabled = { downloading }
onClick = { async ( ) = > {
setDownloading ( true ) ;
try {
const response = await authenticatedFetch (
` ${ BACKEND_URL } /api/v1/search-spaces/ ${ searchSpaceId } /documents/ ${ documentId } /download-markdown ` ,
{ method : "GET" }
) ;
if ( ! response . ok ) throw new Error ( "Download failed" ) ;
const blob = await response . blob ( ) ;
const url = URL . createObjectURL ( blob ) ;
const a = document . createElement ( "a" ) ;
a . href = url ;
const disposition = response . headers . get ( "content-disposition" ) ;
const match = disposition ? . match ( /filename="(.+)"/ ) ;
a . download = match ? . [ 1 ] ? ? ` ${ doc . title || "document" } .md ` ;
document . body . appendChild ( a ) ;
a . click ( ) ;
a . remove ( ) ;
URL . revokeObjectURL ( url ) ;
toast . success ( "Download started" ) ;
} catch {
toast . error ( "Failed to download document" ) ;
} finally {
setDownloading ( false ) ;
}
} }
>
< span className = { ` flex items-center gap-1.5 ${ downloading ? "opacity-0" : "" } ` } >
< Download className = "size-3.5" / >
Download . md
2026-04-02 19:39:10 -07:00
< / span >
2026-06-05 14:23:18 +05:30
{ downloading && < Spinner size = "sm" className = "absolute" / > }
< / Button >
< / AlertDescription >
< / Alert >
< div className = "min-h-0 flex-1 overflow-hidden" >
< SourceCodeEditor
path = { ` ${ doc . title || "document" } .md ` }
language = "markdown"
value = { doc . source_markdown }
readOnly
onChange = { ( ) = > { } }
/ >
< / div >
< / div >
) : (
< div className = "h-full overflow-auto" >
< div className = "max-w-4xl mx-auto px-6 py-6" >
2026-04-30 18:40:55 -07:00
< MarkdownViewer content = { doc . source_markdown } enableCitations / >
2026-06-05 14:23:18 +05:30
< / div >
< / div >
) }
2026-03-27 01:39:15 -07:00
< / div >
< / div >
) ;
}