feat: add multi-format document export functionality to editor routes and UI components

- Implemented a new export endpoint in the backend to support exporting documents in various formats (PDF, DOCX, HTML, LaTeX, EPUB, ODT, plain text).
- Enhanced DocumentNode and FolderTreeView components to include export options in context and dropdown menus.
- Created shared ExportMenuItems component for consistent export options across the application.
- Integrated loading indicators for export actions to improve user experience.
This commit is contained in:
Anish Sarkar 2026-03-28 02:58:38 +05:30
parent 0aa9cd6dfc
commit b5ef7afb1c
6 changed files with 411 additions and 81 deletions

View file

@ -1,21 +1,28 @@
"use client";
import { AlertCircle, Clock, Eye, MoreHorizontal, Move, PenLine, Trash2 } from "lucide-react";
import { AlertCircle, Clock, Download, Eye, MoreHorizontal, Move, PenLine, Trash2 } from "lucide-react";
import React, { useCallback, useRef, useState } from "react";
import { useDrag } from "react-dnd";
import { getDocumentTypeIcon } from "@/app/dashboard/[search_space_id]/documents/(manage)/components/DocumentTypeIcon";
import { ExportContextItems, ExportDropdownItems } from "@/components/shared/ExportMenuItems";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuSub,
ContextMenuSubContent,
ContextMenuSubTrigger,
ContextMenuTrigger,
} from "@/components/ui/context-menu";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Spinner } from "@/components/ui/spinner";
@ -41,6 +48,7 @@ interface DocumentNodeProps {
onEdit: (doc: DocumentNodeDoc) => void;
onDelete: (doc: DocumentNodeDoc) => void;
onMove: (doc: DocumentNodeDoc) => void;
onExport?: (doc: DocumentNodeDoc, format: string) => void;
contextMenuOpen?: boolean;
onContextMenuOpenChange?: (open: boolean) => void;
}
@ -54,6 +62,7 @@ export const DocumentNode = React.memo(function DocumentNode({
onEdit,
onDelete,
onMove,
onExport,
contextMenuOpen,
onContextMenuOpenChange,
}: DocumentNodeProps) {
@ -79,8 +88,19 @@ export const DocumentNode = React.memo(function DocumentNode({
const isProcessing = statusState === "pending" || statusState === "processing";
const [dropdownOpen, setDropdownOpen] = useState(false);
const [exporting, setExporting] = useState<string | null>(null);
const rowRef = useRef<HTMLButtonElement>(null);
const handleExport = useCallback(
(format: string) => {
if (!onExport) return;
setExporting(format);
onExport(doc, format);
setTimeout(() => setExporting(null), 2000);
},
[doc, onExport]
);
const attachRef = useCallback(
(node: HTMLButtonElement | null) => {
(rowRef as React.MutableRefObject<HTMLButtonElement | null>).current = node;
@ -167,7 +187,7 @@ export const DocumentNode = React.memo(function DocumentNode({
variant="ghost"
size="icon"
className={cn(
"hidden sm:inline-flex h-6 w-6 shrink-0 transition-opacity hover:bg-transparent",
"hidden sm:inline-flex h-6 w-6 shrink-0 hover:bg-transparent",
dropdownOpen ? "opacity-100 bg-accent hover:bg-accent" : "opacity-0 group-hover:opacity-100"
)}
onClick={(e) => e.stopPropagation()}
@ -190,6 +210,17 @@ export const DocumentNode = React.memo(function DocumentNode({
<Move className="mr-2 h-4 w-4" />
Move to...
</DropdownMenuItem>
{onExport && (
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<Download className="mr-2 h-4 w-4" />
Export
</DropdownMenuSubTrigger>
<DropdownMenuSubContent className="min-w-[180px]">
<ExportDropdownItems onExport={handleExport} exporting={exporting} />
</DropdownMenuSubContent>
</DropdownMenuSub>
)}
<DropdownMenuItem
className="text-destructive focus:text-destructive"
disabled={isProcessing}
@ -219,6 +250,17 @@ export const DocumentNode = React.memo(function DocumentNode({
<Move className="mr-2 h-4 w-4" />
Move to...
</ContextMenuItem>
{onExport && (
<ContextMenuSub>
<ContextMenuSubTrigger>
<Download className="mr-2 h-4 w-4" />
Export
</ContextMenuSubTrigger>
<ContextMenuSubContent className="min-w-[180px]">
<ExportContextItems onExport={handleExport} exporting={exporting} />
</ContextMenuSubContent>
</ContextMenuSub>
)}
<ContextMenuItem
className="text-destructive focus:text-destructive"
disabled={isProcessing}

View file

@ -28,6 +28,7 @@ interface FolderTreeViewProps {
onEditDocument: (doc: DocumentNodeDoc) => void;
onDeleteDocument: (doc: DocumentNodeDoc) => void;
onMoveDocument: (doc: DocumentNodeDoc) => void;
onExportDocument?: (doc: DocumentNodeDoc, format: string) => void;
activeTypes: DocumentTypeEnum[];
onDropIntoFolder?: (
itemType: "folder" | "document",
@ -62,6 +63,7 @@ export function FolderTreeView({
onEditDocument,
onDeleteDocument,
onMoveDocument,
onExportDocument,
activeTypes,
onDropIntoFolder,
onReorderFolder,
@ -181,6 +183,7 @@ export function FolderTreeView({
onEdit={onEditDocument}
onDelete={onDeleteDocument}
onMove={onMoveDocument}
onExport={onExportDocument}
contextMenuOpen={openContextMenuId === `doc-${d.id}`}
onContextMenuOpenChange={(open) => setOpenContextMenuId(open ? `doc-${d.id}` : null)}
/>

View file

@ -7,6 +7,7 @@ import { useParams } from "next/navigation";
import { useTranslations } from "next-intl";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { toast } from "sonner";
import { EXPORT_FILE_EXTENSIONS } from "@/components/shared/ExportMenuItems";
import { DocumentsFilters } from "@/app/dashboard/[search_space_id]/documents/(manage)/components/DocumentsFilters";
import {
DocumentsTableShell,
@ -33,6 +34,7 @@ import { useDocumentSearch } from "@/hooks/use-document-search";
import { useDocuments } from "@/hooks/use-documents";
import { useMediaQuery } from "@/hooks/use-media-query";
import { foldersApiService } from "@/lib/apis/folders-api.service";
import { authenticatedFetch } from "@/lib/auth-utils";
import { queries } from "@/zero/queries/index";
import { SidebarSlideOutPanel } from "./SidebarSlideOutPanel";
@ -234,6 +236,43 @@ export function DocumentsSidebar({
setFolderPickerOpen(true);
}, []);
const handleExportDocument = useCallback(
async (doc: DocumentNodeDoc, format: string) => {
const safeTitle =
doc.title
.replace(/[^a-zA-Z0-9 _-]/g, "_")
.trim()
.slice(0, 80) || "document";
const ext = EXPORT_FILE_EXTENSIONS[format] ?? format;
try {
const response = await authenticatedFetch(
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/search-spaces/${searchSpaceId}/documents/${doc.id}/export?format=${format}`,
{ method: "GET" }
);
if (!response.ok) {
const errorData = await response.json().catch(() => ({ detail: "Export failed" }));
throw new Error(errorData.detail || "Export failed");
}
const blob = await response.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `${safeTitle}.${ext}`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
} catch (err) {
console.error(`Export ${format} failed:`, err);
toast.error(err instanceof Error ? err.message : `Export failed`);
}
},
[searchSpaceId]
);
const handleFolderPickerSelect = useCallback(
async (targetFolderId: number | null) => {
if (!folderPickerTarget) return;
@ -606,6 +645,7 @@ export function DocumentsSidebar({
}}
onDeleteDocument={(doc) => handleDeleteDocument(doc.id)}
onMoveDocument={handleMoveDocument}
onExportDocument={handleExportDocument}
activeTypes={activeTypes}
onDropIntoFolder={handleDropIntoFolder}
onReorderFolder={handleReorderFolder}

View file

@ -15,10 +15,9 @@ import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { ExportDropdownItems, EXPORT_FILE_EXTENSIONS } from "@/components/shared/ExportMenuItems";
import { useMediaQuery } from "@/hooks/use-media-query";
import { baseApiService } from "@/lib/apis/base-api.service";
import { authenticatedFetch } from "@/lib/auth-utils";
@ -198,19 +197,6 @@ export function ReportPanelContent({
}
}, [currentMarkdown]);
// Maps backend format values to download file extensions
const FILE_EXTENSIONS: Record<string, string> = {
pdf: "pdf",
docx: "docx",
html: "html",
latex: "tex",
epub: "epub",
odt: "odt",
plain: "txt",
md: "md",
};
// Export report
const handleExport = useCallback(
async (format: string) => {
setExporting(format);
@ -219,7 +205,7 @@ export function ReportPanelContent({
.replace(/[^a-zA-Z0-9 _-]/g, "_")
.trim()
.slice(0, 80) || "report";
const ext = FILE_EXTENSIONS[format] ?? format;
const ext = EXPORT_FILE_EXTENSIONS[format] ?? format;
try {
if (format === "md") {
if (!currentMarkdown) return;
@ -329,68 +315,11 @@ export function ReportPanelContent({
align="start"
className={`min-w-[200px] select-none${insideDrawer ? " z-[100]" : ""}`}
>
{!shareToken && (
<>
<DropdownMenuLabel className="text-xs text-muted-foreground">
Documents
</DropdownMenuLabel>
<DropdownMenuItem
onClick={() => handleExport("pdf")}
disabled={exporting !== null}
>
PDF (.pdf)
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => handleExport("docx")}
disabled={exporting !== null}
>
Word (.docx)
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => handleExport("odt")}
disabled={exporting !== null}
>
OpenDocument (.odt)
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuLabel className="text-xs text-muted-foreground">
Web &amp; E-Book
</DropdownMenuLabel>
<DropdownMenuItem
onClick={() => handleExport("html")}
disabled={exporting !== null}
>
HTML (.html)
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => handleExport("epub")}
disabled={exporting !== null}
>
EPUB (.epub)
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuLabel className="text-xs text-muted-foreground">
Source &amp; Plain
</DropdownMenuLabel>
<DropdownMenuItem
onClick={() => handleExport("latex")}
disabled={exporting !== null}
>
LaTeX (.tex)
</DropdownMenuItem>
</>
)}
<DropdownMenuItem onClick={() => handleExport("md")} disabled={exporting !== null}>
Markdown (.md)
</DropdownMenuItem>
{!shareToken && (
<DropdownMenuItem
onClick={() => handleExport("plain")}
disabled={exporting !== null}
>
Plain Text (.txt)
</DropdownMenuItem>
)}
<ExportDropdownItems
onExport={handleExport}
exporting={exporting}
showAllFormats={!shareToken}
/>
</DropdownMenuContent>
</DropdownMenu>

View file

@ -0,0 +1,142 @@
"use client";
import { Loader2 } from "lucide-react";
import { DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator } from "@/components/ui/dropdown-menu";
import { ContextMenuItem } from "@/components/ui/context-menu";
export const EXPORT_FILE_EXTENSIONS: Record<string, string> = {
pdf: "pdf",
docx: "docx",
html: "html",
latex: "tex",
epub: "epub",
odt: "odt",
plain: "txt",
md: "md",
};
interface ExportMenuItemsProps {
onExport: (format: string) => void;
exporting: string | null;
/** Hide server-side formats (PDF, DOCX, etc.) — only show md */
showAllFormats?: boolean;
}
export function ExportDropdownItems({
onExport,
exporting,
showAllFormats = true,
}: ExportMenuItemsProps) {
const handle = (format: string) => (e: React.MouseEvent) => {
e.stopPropagation();
onExport(format);
};
return (
<>
{showAllFormats && (
<>
<DropdownMenuLabel className="text-xs text-muted-foreground">
Documents
</DropdownMenuLabel>
<DropdownMenuItem onClick={handle("pdf")} disabled={exporting !== null}>
{exporting === "pdf" && <Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" />}
PDF (.pdf)
</DropdownMenuItem>
<DropdownMenuItem onClick={handle("docx")} disabled={exporting !== null}>
{exporting === "docx" && <Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" />}
Word (.docx)
</DropdownMenuItem>
<DropdownMenuItem onClick={handle("odt")} disabled={exporting !== null}>
{exporting === "odt" && <Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" />}
OpenDocument (.odt)
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuLabel className="text-xs text-muted-foreground">
Web &amp; E-Book
</DropdownMenuLabel>
<DropdownMenuItem onClick={handle("html")} disabled={exporting !== null}>
{exporting === "html" && <Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" />}
HTML (.html)
</DropdownMenuItem>
<DropdownMenuItem onClick={handle("epub")} disabled={exporting !== null}>
{exporting === "epub" && <Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" />}
EPUB (.epub)
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuLabel className="text-xs text-muted-foreground">
Source &amp; Plain
</DropdownMenuLabel>
<DropdownMenuItem onClick={handle("latex")} disabled={exporting !== null}>
{exporting === "latex" && <Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" />}
LaTeX (.tex)
</DropdownMenuItem>
</>
)}
<DropdownMenuItem onClick={handle("md")} disabled={exporting !== null}>
{exporting === "md" && <Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" />}
Markdown (.md)
</DropdownMenuItem>
{showAllFormats && (
<DropdownMenuItem onClick={handle("plain")} disabled={exporting !== null}>
{exporting === "plain" && <Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" />}
Plain Text (.txt)
</DropdownMenuItem>
)}
</>
);
}
export function ExportContextItems({
onExport,
exporting,
showAllFormats = true,
}: ExportMenuItemsProps) {
const handle = (format: string) => (e: React.MouseEvent) => {
e.stopPropagation();
onExport(format);
};
return (
<>
{showAllFormats && (
<>
<ContextMenuItem onClick={handle("pdf")} disabled={exporting !== null}>
{exporting === "pdf" && <Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" />}
PDF (.pdf)
</ContextMenuItem>
<ContextMenuItem onClick={handle("docx")} disabled={exporting !== null}>
{exporting === "docx" && <Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" />}
Word (.docx)
</ContextMenuItem>
<ContextMenuItem onClick={handle("odt")} disabled={exporting !== null}>
{exporting === "odt" && <Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" />}
OpenDocument (.odt)
</ContextMenuItem>
<ContextMenuItem onClick={handle("html")} disabled={exporting !== null}>
{exporting === "html" && <Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" />}
HTML (.html)
</ContextMenuItem>
<ContextMenuItem onClick={handle("epub")} disabled={exporting !== null}>
{exporting === "epub" && <Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" />}
EPUB (.epub)
</ContextMenuItem>
<ContextMenuItem onClick={handle("latex")} disabled={exporting !== null}>
{exporting === "latex" && <Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" />}
LaTeX (.tex)
</ContextMenuItem>
</>
)}
<ContextMenuItem onClick={handle("md")} disabled={exporting !== null}>
{exporting === "md" && <Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" />}
Markdown (.md)
</ContextMenuItem>
{showAllFormats && (
<ContextMenuItem onClick={handle("plain")} disabled={exporting !== null}>
{exporting === "plain" && <Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" />}
Plain Text (.txt)
</ContextMenuItem>
)}
</>
);
}