340 lines
12 KiB
TypeScript
340 lines
12 KiB
TypeScript
import React, { createContext, useContext, useCallback, useState, useEffect, useMemo } from 'react';
|
|
import { useLocation } from 'react-router-dom';
|
|
import api from '../api';
|
|
import { useFileOperations, type FilePreviewResult } from '../hooks/useFiles';
|
|
import type { FolderInfo } from '../api/fileApi';
|
|
import type { FileNode } from '../components/FolderTree/FolderTree';
|
|
|
|
export type { FolderInfo };
|
|
|
|
interface FileContextType {
|
|
folders: FolderInfo[];
|
|
foldersLoading: boolean;
|
|
refreshFolders: () => Promise<void>;
|
|
|
|
treeFileNodes: FileNode[];
|
|
treeFilesLoading: boolean;
|
|
loadTreeFiles: (folderId: string) => Promise<void>;
|
|
refreshTreeFiles: () => Promise<void>;
|
|
updateTreeFileNode: (fileId: string, patch: Partial<FileNode>) => void;
|
|
|
|
expandedFolderIds: Set<string>;
|
|
toggleFolderExpanded: (id: string) => void;
|
|
|
|
handleCreateFolder: (name: string, parentId: string | null) => Promise<void>;
|
|
handleRenameFolder: (folderId: string, newName: string) => Promise<void>;
|
|
handleDeleteFolder: (folderId: string) => Promise<void>;
|
|
handleMoveFolder: (folderId: string, targetParentId: string | null) => Promise<void>;
|
|
handleMoveFolders: (folderIds: string[], targetParentId: string | null) => Promise<void>;
|
|
handleMoveFile: (fileId: string, targetFolderId: string | null) => Promise<void>;
|
|
handleMoveFiles: (fileIds: string[], targetFolderId: string | null) => Promise<void>;
|
|
handleDownloadFolder: (folderId: string, folderName: string) => Promise<void>;
|
|
handleFileDelete: (fileId: string, onOptimisticDelete?: () => void) => Promise<boolean>;
|
|
handleFileUpload: (file: File, workflowId?: string) => Promise<{ success: boolean; fileData?: any; error?: string }>;
|
|
handleFilePreview: (fileId: string, fileName: string, mimeType?: string) => Promise<FilePreviewResult>;
|
|
handleFileDownload: (fileId: string, fileName: string) => Promise<void>;
|
|
uploadingFile: boolean;
|
|
deletingFiles: Set<string>;
|
|
previewingFiles: Set<string>;
|
|
downloadingFiles: Set<string>;
|
|
}
|
|
|
|
export const FileContext = createContext<FileContextType | undefined>(undefined);
|
|
|
|
const _ROOT_KEY = '';
|
|
|
|
function _toFileNode(f: any): FileNode {
|
|
return {
|
|
id: f.id,
|
|
fileName: f.fileName || f.name || 'unknown',
|
|
mimeType: f.mimeType,
|
|
fileSize: f.fileSize,
|
|
folderId: f.folderId ?? null,
|
|
scope: f.scope,
|
|
neutralize: f.neutralize,
|
|
sysCreatedBy: f.sysCreatedBy,
|
|
};
|
|
}
|
|
|
|
export function FileProvider({ children }: { children: React.ReactNode }) {
|
|
const {
|
|
handleFileUpload: hookHandleFileUpload,
|
|
handleFileDelete: hookHandleFileDelete,
|
|
handleFilePreview,
|
|
handleFileDownload,
|
|
uploadingFile,
|
|
deletingFiles,
|
|
previewingFiles,
|
|
downloadingFiles,
|
|
} = useFileOperations();
|
|
|
|
// ── Derive a session-scoped storage key from the current feature-instance URL ──
|
|
const location = useLocation();
|
|
const storageKey = useMemo(() => {
|
|
const match = location.pathname.match(/^\/mandates\/([^/]+)\/([^/]+)\/([^/]+)/);
|
|
const instanceId = match ? match[3] : '_global';
|
|
return `folderTree-expandedIds-${instanceId}`;
|
|
}, [location.pathname]);
|
|
|
|
// ── Folder expanded state (persisted per feature-instance in sessionStorage) ──
|
|
const _loadExpanded = (key: string): Set<string> => {
|
|
try {
|
|
const stored = sessionStorage.getItem(key);
|
|
if (!stored) return new Set<string>();
|
|
const ids: string[] = JSON.parse(stored);
|
|
return new Set(ids.filter(id => id && id !== '__root__'));
|
|
} catch { return new Set<string>(); }
|
|
};
|
|
|
|
const [expandedFolderIds, setExpandedFolderIds] = useState<Set<string>>(() => _loadExpanded(storageKey));
|
|
|
|
useEffect(() => {
|
|
setExpandedFolderIds(_loadExpanded(storageKey));
|
|
setTreeFilesMap(new Map());
|
|
setFolders([]);
|
|
}, [storageKey]);
|
|
|
|
// ── Folder state ──────────────────────────────────────────────────────
|
|
const [folders, setFolders] = useState<FolderInfo[]>([]);
|
|
const [foldersLoading, setFoldersLoading] = useState(false);
|
|
|
|
const refreshFolders = useCallback(async () => {
|
|
setFoldersLoading(true);
|
|
try {
|
|
const response = await api.get('/api/files/folders');
|
|
const data = Array.isArray(response.data) ? response.data : [];
|
|
setFolders(data);
|
|
} catch (err) {
|
|
console.error('Failed to load folders:', err);
|
|
} finally {
|
|
setFoldersLoading(false);
|
|
}
|
|
}, []);
|
|
|
|
useEffect(() => { refreshFolders(); }, [refreshFolders, storageKey]);
|
|
|
|
// ── Tree files: lazy-loaded per expanded folder ───────────────────────
|
|
const [treeFilesMap, setTreeFilesMap] = useState<Map<string, FileNode[]>>(new Map());
|
|
const [treeFilesLoading, setTreeFilesLoading] = useState(false);
|
|
|
|
const loadTreeFiles = useCallback(async (folderId: string) => {
|
|
const key = folderId || _ROOT_KEY;
|
|
setTreeFilesLoading(true);
|
|
try {
|
|
const filterValue = folderId || null;
|
|
const resp = await api.get('/api/files/list', {
|
|
params: {
|
|
pagination: JSON.stringify({
|
|
page: 1,
|
|
pageSize: 500,
|
|
filters: { folderId: filterValue },
|
|
}),
|
|
},
|
|
});
|
|
const items: any[] = resp.data?.items || [];
|
|
setTreeFilesMap(prev => {
|
|
const next = new Map(prev);
|
|
next.set(key, items.map(_toFileNode));
|
|
return next;
|
|
});
|
|
} catch (err) {
|
|
console.error(`Failed to load tree files for folder ${folderId}:`, err);
|
|
} finally {
|
|
setTreeFilesLoading(false);
|
|
}
|
|
}, []);
|
|
|
|
const _removeTreeFiles = useCallback((folderId: string) => {
|
|
const key = folderId || _ROOT_KEY;
|
|
setTreeFilesMap(prev => {
|
|
const next = new Map(prev);
|
|
next.delete(key);
|
|
return next;
|
|
});
|
|
}, []);
|
|
|
|
const refreshTreeFiles = useCallback(async () => {
|
|
const keys = Array.from(treeFilesMap.keys());
|
|
if (!keys.includes(_ROOT_KEY)) keys.push(_ROOT_KEY);
|
|
await Promise.all(
|
|
keys.map(key => loadTreeFiles(key === _ROOT_KEY ? '' : key)),
|
|
);
|
|
}, [treeFilesMap, loadTreeFiles]);
|
|
|
|
const updateTreeFileNode = useCallback((fileId: string, patch: Partial<FileNode>) => {
|
|
setTreeFilesMap(prev => {
|
|
const next = new Map<string, FileNode[]>();
|
|
let found = false;
|
|
for (const [key, files] of prev) {
|
|
const updated = files.map(f => {
|
|
if (f.id === fileId) {
|
|
found = true;
|
|
return { ...f, ...patch };
|
|
}
|
|
return f;
|
|
});
|
|
next.set(key, updated);
|
|
}
|
|
return found ? next : prev;
|
|
});
|
|
}, []);
|
|
|
|
// Load root files on mount and on context change
|
|
useEffect(() => { loadTreeFiles(''); }, [loadTreeFiles, storageKey]);
|
|
|
|
// Load files for expanded folders on mount and context change
|
|
useEffect(() => {
|
|
expandedFolderIds.forEach(id => {
|
|
if (!treeFilesMap.has(id)) {
|
|
loadTreeFiles(id);
|
|
}
|
|
});
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [storageKey]);
|
|
|
|
const treeFileNodes: FileNode[] = useMemo(() => {
|
|
const result: FileNode[] = [];
|
|
for (const [, files] of treeFilesMap) {
|
|
for (const f of files) result.push(f);
|
|
}
|
|
return result;
|
|
}, [treeFilesMap]);
|
|
|
|
// ── Toggle expand: load/unload tree files ─────────────────────────────
|
|
const toggleFolderExpanded = useCallback((id: string) => {
|
|
setExpandedFolderIds(prev => {
|
|
const next = new Set(prev);
|
|
if (next.has(id)) {
|
|
next.delete(id);
|
|
} else {
|
|
next.add(id);
|
|
loadTreeFiles(id);
|
|
}
|
|
try { sessionStorage.setItem(storageKey, JSON.stringify([...next])); } catch {}
|
|
return next;
|
|
});
|
|
}, [storageKey, loadTreeFiles]);
|
|
|
|
// ── Folder operations ─────────────────────────────────────────────────
|
|
const handleCreateFolder = useCallback(async (name: string, parentId: string | null) => {
|
|
await api.post('/api/files/folders', { name, parentId: parentId || null });
|
|
await refreshFolders();
|
|
}, [refreshFolders]);
|
|
|
|
const handleRenameFolder = useCallback(async (folderId: string, newName: string) => {
|
|
await api.put(`/api/files/folders/${folderId}`, { name: newName });
|
|
await refreshFolders();
|
|
}, [refreshFolders]);
|
|
|
|
const handleDeleteFolder = useCallback(async (folderId: string) => {
|
|
await api.delete(`/api/files/folders/${folderId}`, { params: { recursive: true } });
|
|
_removeTreeFiles(folderId);
|
|
await refreshFolders();
|
|
}, [refreshFolders, _removeTreeFiles]);
|
|
|
|
const handleMoveFolder = useCallback(async (folderId: string, targetParentId: string | null) => {
|
|
await api.post(`/api/files/folders/${folderId}/move`, { targetParentId });
|
|
await refreshFolders();
|
|
}, [refreshFolders]);
|
|
|
|
const handleMoveFolders = useCallback(async (folderIds: string[], targetParentId: string | null) => {
|
|
await api.post('/api/files/batch-move', { folderIds, targetParentId });
|
|
await refreshFolders();
|
|
}, [refreshFolders]);
|
|
|
|
// ── File operations ───────────────────────────────────────────────────
|
|
const handleMoveFile = useCallback(async (fileId: string, targetFolderId: string | null) => {
|
|
await api.post(`/api/files/${fileId}/move`, { targetFolderId });
|
|
await refreshTreeFiles();
|
|
await refreshFolders();
|
|
}, [refreshTreeFiles, refreshFolders]);
|
|
|
|
const handleMoveFiles = useCallback(async (fileIds: string[], targetFolderId: string | null) => {
|
|
await api.post('/api/files/batch-move', { fileIds, targetFolderId });
|
|
await refreshTreeFiles();
|
|
await refreshFolders();
|
|
}, [refreshTreeFiles, refreshFolders]);
|
|
|
|
const handleFileUpload = useCallback(async (file: File, workflowId?: string) => {
|
|
const result = await hookHandleFileUpload(file, workflowId);
|
|
if (result.success) {
|
|
await refreshTreeFiles();
|
|
await refreshFolders();
|
|
}
|
|
return result;
|
|
}, [hookHandleFileUpload, refreshTreeFiles, refreshFolders]);
|
|
|
|
const handleFileDelete = useCallback(async (fileId: string, onOptimisticDelete?: () => void) => {
|
|
const success = await hookHandleFileDelete(fileId, () => {
|
|
onOptimisticDelete?.();
|
|
});
|
|
if (success) {
|
|
await refreshTreeFiles();
|
|
await refreshFolders();
|
|
}
|
|
return success;
|
|
}, [hookHandleFileDelete, refreshTreeFiles, refreshFolders]);
|
|
|
|
const handleDownloadFolder = useCallback(async (folderId: string, folderName: string) => {
|
|
try {
|
|
const response = await api.get(`/api/files/folders/${folderId}/download`, {
|
|
responseType: 'blob',
|
|
});
|
|
const url = window.URL.createObjectURL(response.data);
|
|
const link = document.createElement('a');
|
|
link.href = url;
|
|
link.setAttribute('download', `${folderName}.zip`);
|
|
document.body.appendChild(link);
|
|
link.click();
|
|
link.remove();
|
|
window.URL.revokeObjectURL(url);
|
|
} catch (err) {
|
|
console.error('Failed to download folder:', err);
|
|
}
|
|
}, []);
|
|
|
|
return (
|
|
<FileContext.Provider
|
|
value={{
|
|
folders,
|
|
foldersLoading,
|
|
refreshFolders,
|
|
treeFileNodes,
|
|
treeFilesLoading,
|
|
loadTreeFiles,
|
|
refreshTreeFiles,
|
|
updateTreeFileNode,
|
|
expandedFolderIds,
|
|
toggleFolderExpanded,
|
|
handleCreateFolder,
|
|
handleRenameFolder,
|
|
handleDeleteFolder,
|
|
handleMoveFolder,
|
|
handleMoveFolders,
|
|
handleMoveFile,
|
|
handleMoveFiles,
|
|
handleDownloadFolder,
|
|
handleFileDelete,
|
|
handleFileUpload,
|
|
handleFilePreview,
|
|
handleFileDownload: async (fileId: string, fileName: string) => {
|
|
await handleFileDownload(fileId, fileName);
|
|
},
|
|
uploadingFile,
|
|
deletingFiles,
|
|
previewingFiles,
|
|
downloadingFiles,
|
|
}}
|
|
>
|
|
{children}
|
|
</FileContext.Provider>
|
|
);
|
|
}
|
|
|
|
export function useFileContext() {
|
|
const context = useContext(FileContext);
|
|
if (context === undefined) {
|
|
throw new Error('useFileContext must be used within a FileProvider');
|
|
}
|
|
return context;
|
|
}
|