feat: add folder-level export to context menu

This commit is contained in:
CREDO23 2026-04-09 12:20:49 +02:00
parent c38239a995
commit 89f210bf7e
3 changed files with 65 additions and 0 deletions

View file

@ -4,6 +4,7 @@ import {
AlertCircle,
ChevronDown,
ChevronRight,
Download,
Eye,
EyeOff,
Folder,
@ -80,6 +81,7 @@ interface FolderNodeProps {
isWatched?: boolean;
onRescan?: (folder: FolderDisplay) => void | Promise<void>;
onStopWatching?: (folder: FolderDisplay) => void;
onExportFolder?: (folder: FolderDisplay) => void;
}
function getDropZone(
@ -120,6 +122,7 @@ export const FolderNode = React.memo(function FolderNode({
isWatched,
onRescan,
onStopWatching,
onExportFolder,
}: FolderNodeProps) {
const [renameValue, setRenameValue] = useState(folder.name);
const inputRef = useRef<HTMLInputElement>(null);
@ -408,6 +411,17 @@ export const FolderNode = React.memo(function FolderNode({
<Move className="mr-2 h-4 w-4" />
Move to...
</DropdownMenuItem>
{onExportFolder && (
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
onExportFolder(folder);
}}
>
<Download className="mr-2 h-4 w-4" />
Export folder
</DropdownMenuItem>
)}
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
@ -449,6 +463,12 @@ export const FolderNode = React.memo(function FolderNode({
<Move className="mr-2 h-4 w-4" />
Move to...
</ContextMenuItem>
{onExportFolder && (
<ContextMenuItem onClick={() => onExportFolder(folder)}>
<Download className="mr-2 h-4 w-4" />
Export folder
</ContextMenuItem>
)}
<ContextMenuItem onClick={() => onDelete(folder)}>
<Trash2 className="mr-2 h-4 w-4" />
Delete

View file

@ -44,6 +44,7 @@ interface FolderTreeViewProps {
watchedFolderIds?: Set<number>;
onRescanFolder?: (folder: FolderDisplay) => void;
onStopWatchingFolder?: (folder: FolderDisplay) => void;
onExportFolder?: (folder: FolderDisplay) => void;
}
function groupBy<T>(items: T[], keyFn: (item: T) => string | number): Record<string | number, T[]> {
@ -81,6 +82,7 @@ export function FolderTreeView({
watchedFolderIds,
onRescanFolder,
onStopWatchingFolder,
onExportFolder,
}: FolderTreeViewProps) {
const foldersByParent = useMemo(() => groupBy(folders, (f) => f.parentId ?? "root"), [folders]);
@ -259,6 +261,7 @@ export function FolderTreeView({
isWatched={watchedFolderIds?.has(f.id)}
onRescan={onRescanFolder}
onStopWatching={onStopWatchingFolder}
onExportFolder={onExportFolder}
/>
);

View file

@ -445,6 +445,47 @@ export function DocumentsSidebar({
}
}, [searchSpaceId, isExportingKB]);
const handleExportFolder = useCallback(
async (folder: FolderDisplay) => {
try {
const response = await authenticatedFetch(
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/search-spaces/${searchSpaceId}/export?folder_id=${folder.id}`,
{ method: "GET" }
);
if (!response.ok) {
const errorData = await response.json().catch(() => ({ detail: "Export failed" }));
throw new Error(errorData.detail || "Export failed");
}
const skipped = response.headers.get("X-Skipped-Documents");
if (skipped && Number(skipped) > 0) {
toast.warning(`${skipped} document(s) were skipped (still processing)`);
}
const blob = await response.blob();
const safeName =
folder.name
.replace(/[^a-zA-Z0-9 _-]/g, "_")
.trim()
.slice(0, 80) || "folder";
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `${safeName}.zip`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
toast.success(`Folder "${folder.name}" exported`);
} catch (err) {
console.error("Folder export failed:", err);
toast.error(err instanceof Error ? err.message : "Export failed");
}
},
[searchSpaceId]
);
const handleExportDocument = useCallback(
async (doc: DocumentNodeDoc, format: string) => {
const safeTitle =
@ -895,6 +936,7 @@ export function DocumentsSidebar({
watchedFolderIds={watchedFolderIds}
onRescanFolder={handleRescanFolder}
onStopWatchingFolder={handleStopWatching}
onExportFolder={handleExportFolder}
/>
</div>
</div>