936 lines
No EOL
30 KiB
TypeScript
936 lines
No EOL
30 KiB
TypeScript
import { useState, useEffect, useCallback } from 'react';
|
||
import api from '../api';
|
||
import { useToast } from '../contexts/ToastContext';
|
||
import { useLanguage } from '../providers/language/LanguageContext';
|
||
import { getUserDataCache } from '../utils/userCache';
|
||
import { useApiRequest } from './useApi';
|
||
import { usePermissions, type UserPermissions } from './usePermissions';
|
||
import {
|
||
fetchFileAttributes as _fetchFileAttributes,
|
||
fetchFiles as fetchFilesApi,
|
||
fetchFileById as fetchFileByIdApi,
|
||
updateFile as updateFileApi,
|
||
deleteFile as deleteFileApi,
|
||
deleteFiles as deleteFilesApi,
|
||
getFolderTree,
|
||
createFolder as createFolderApi,
|
||
renameFolder as renameFolderApi,
|
||
moveFolder as moveFolderApi,
|
||
deleteFolderCascade as deleteFolderCascadeApi,
|
||
patchFolderScope as patchFolderScopeApi,
|
||
patchFolderNeutralize as patchFolderNeutralizeApi,
|
||
moveFiles as moveFilesApi,
|
||
type FolderInfo,
|
||
} from '../api/fileApi';
|
||
|
||
export interface FilePreviewResult {
|
||
success: boolean;
|
||
previewUrl?: string;
|
||
blob?: Blob;
|
||
mimeType?: string;
|
||
textContent?: string | null;
|
||
error?: string;
|
||
}
|
||
|
||
// File interfaces - exactly matching backend FileItem model
|
||
export interface FileInfo {
|
||
id: string;
|
||
mandateId: string; // Required in backend
|
||
fileName: string; // Required in backend
|
||
mimeType: string;
|
||
fileHash: string;
|
||
fileSize: number;
|
||
creationDate: number; // Backend uses float for UTC timestamp in seconds
|
||
}
|
||
|
||
// UserFile is now just the backend response - no mapping needed
|
||
// Field names come directly from backend attributes
|
||
export type UserFile = any;
|
||
|
||
// Attribute definition interface (local definition, not imported to avoid conflicts)
|
||
export interface AttributeDefinition {
|
||
name: string;
|
||
label: string;
|
||
type: 'string' | 'number' | 'date' | 'boolean' | 'enum';
|
||
sortable?: boolean;
|
||
filterable?: boolean;
|
||
searchable?: boolean;
|
||
width?: number;
|
||
minWidth?: number;
|
||
maxWidth?: number;
|
||
filterOptions?: string[]; // For enum types
|
||
}
|
||
|
||
// Pagination parameters (local definition, not imported to avoid conflicts)
|
||
export interface PaginationParams {
|
||
page?: number;
|
||
pageSize?: number;
|
||
sort?: Array<{ field: string; direction: 'asc' | 'desc' }>;
|
||
filters?: Record<string, any>;
|
||
search?: string;
|
||
viewKey?: string;
|
||
}
|
||
|
||
// Files list hook
|
||
export function useUserFiles() {
|
||
const [files, setFiles] = useState<UserFile[]>([]);
|
||
const [attributes, setAttributes] = useState<AttributeDefinition[]>([]);
|
||
const [permissions, setPermissions] = useState<UserPermissions | null>(null);
|
||
const [pagination, setPagination] = useState<{
|
||
currentPage: number;
|
||
pageSize: number;
|
||
totalItems: number;
|
||
totalPages: number;
|
||
} | null>(null);
|
||
const [groupLayout, setGroupLayout] = useState<import('../api/connectionApi').GroupLayout | null>(null);
|
||
const [appliedView, setAppliedView] = useState<{ viewKey?: string; displayName?: string } | null>(null);
|
||
const { request, isLoading: loading, error } = useApiRequest<null, UserFile[]>();
|
||
const { checkPermission } = usePermissions();
|
||
|
||
|
||
// Fetch attributes from backend
|
||
const fetchAttributes = useCallback(async () => {
|
||
try {
|
||
// Note: fetchFileAttributes uses api.get directly due to response format handling
|
||
// For now, we'll use api.get directly here as well
|
||
const response = await api.get('/api/attributes/FileItem');
|
||
|
||
// Extract attributes from response - check if response.data.attributes exists, otherwise check if response.data is an array
|
||
let attrs: AttributeDefinition[] = [];
|
||
if (response.data?.attributes && Array.isArray(response.data.attributes)) {
|
||
attrs = response.data.attributes;
|
||
} else if (Array.isArray(response.data)) {
|
||
attrs = response.data;
|
||
} else if (response.data && typeof response.data === 'object') {
|
||
// Try to find any array property in the response
|
||
const keys = Object.keys(response.data);
|
||
for (const key of keys) {
|
||
if (Array.isArray(response.data[key])) {
|
||
attrs = response.data[key];
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
|
||
setAttributes(attrs);
|
||
return attrs;
|
||
} catch (error: any) {
|
||
console.error('Error fetching attributes:', error);
|
||
setAttributes([]);
|
||
return [];
|
||
}
|
||
}, []);
|
||
|
||
// Fetch permissions from backend
|
||
const fetchPermissions = useCallback(async () => {
|
||
try {
|
||
const perms = await checkPermission('DATA', 'FileItem');
|
||
setPermissions(perms);
|
||
return perms;
|
||
} catch (error: any) {
|
||
console.error('Error fetching permissions:', error);
|
||
const defaultPerms: UserPermissions = {
|
||
view: false,
|
||
read: 'n',
|
||
create: 'n',
|
||
update: 'n',
|
||
delete: 'n',
|
||
};
|
||
setPermissions(defaultPerms);
|
||
return defaultPerms;
|
||
}
|
||
}, [checkPermission]);
|
||
|
||
const fetchGroupSectionSummaries = useCallback(
|
||
async (base: {
|
||
search?: string;
|
||
filters?: Record<string, any>;
|
||
sort?: Array<{ field: string; direction: string }>;
|
||
viewKey?: string | null;
|
||
groupField: string;
|
||
groupDirection?: 'asc' | 'desc';
|
||
}) => {
|
||
const pObj: Record<string, unknown> = {
|
||
page: 1,
|
||
pageSize: 25,
|
||
groupByLevels: [
|
||
{
|
||
field: base.groupField,
|
||
nullLabel: '—',
|
||
direction: base.groupDirection || 'asc',
|
||
},
|
||
],
|
||
};
|
||
if (base.search) (pObj as { search?: string }).search = base.search;
|
||
if (base.filters && Object.keys(base.filters).length) (pObj as { filters: typeof base.filters }).filters = base.filters;
|
||
if (base.sort?.length) (pObj as { sort: typeof base.sort }).sort = base.sort;
|
||
if (base.viewKey) (pObj as { viewKey: string }).viewKey = base.viewKey;
|
||
const { data } = await api.get('/api/files/list', {
|
||
params: { mode: 'groupSummary', pagination: JSON.stringify(pObj) },
|
||
});
|
||
return Array.isArray(data?.groups) ? data.groups : [];
|
||
},
|
||
[],
|
||
);
|
||
|
||
const refetchForSection = useCallback(
|
||
async (
|
||
paginationParams: PaginationParams & { page: number; pageSize: number },
|
||
sectionFilter: Record<string, unknown>,
|
||
parentColumnFilters?: Record<string, unknown>,
|
||
) => {
|
||
const mergedFilters = {
|
||
...(parentColumnFilters || {}),
|
||
...(paginationParams.filters || {}),
|
||
...sectionFilter,
|
||
};
|
||
const pObj: Record<string, unknown> = {
|
||
page: paginationParams.page,
|
||
pageSize: paginationParams.pageSize,
|
||
filters: mergedFilters,
|
||
groupByLevels: [],
|
||
};
|
||
if (paginationParams.sort) (pObj as { sort: unknown }).sort = paginationParams.sort;
|
||
if (paginationParams.search) (pObj as { search: string }).search = paginationParams.search;
|
||
if (paginationParams.viewKey) (pObj as { viewKey: string }).viewKey = paginationParams.viewKey;
|
||
const { data } = await api.get('/api/files/list', {
|
||
params: { pagination: JSON.stringify(pObj) },
|
||
});
|
||
if (data && typeof data === 'object' && 'items' in data) {
|
||
return { items: data.items, pagination: data.pagination };
|
||
}
|
||
return { items: [], pagination: null };
|
||
},
|
||
[],
|
||
);
|
||
|
||
const fetchFiles = useCallback(async (params?: PaginationParams) => {
|
||
// Check if user is authenticated before fetching files
|
||
const cachedUser = getUserDataCache();
|
||
if (!cachedUser) {
|
||
// User is not authenticated, skip fetching files
|
||
setFiles([]);
|
||
// Note: loading and error are managed by useApiRequest hook
|
||
return;
|
||
}
|
||
|
||
try {
|
||
const data = await fetchFilesApi(request, params);
|
||
|
||
// Log the entire backend response for debugging
|
||
console.log('📦 Backend response from /api/files/list:', {
|
||
fullResponse: data,
|
||
responseType: typeof data,
|
||
isArray: Array.isArray(data),
|
||
hasItems: data && typeof data === 'object' && 'items' in data,
|
||
itemsCount: data && typeof data === 'object' && 'items' in data ? (Array.isArray(data.items) ? data.items.length : 'not an array') : 'no items property',
|
||
firstItem: data && typeof data === 'object' && 'items' in data && Array.isArray(data.items) && data.items.length > 0 ? data.items[0] : 'no first item',
|
||
pagination: data && typeof data === 'object' && 'pagination' in data ? data.pagination : 'no pagination'
|
||
});
|
||
|
||
|
||
// Handle paginated response
|
||
if (data && typeof data === 'object' && 'items' in data) {
|
||
const items = Array.isArray(data.items) ? data.items : [];
|
||
|
||
console.log('📋 Processing paginated response:', {
|
||
itemsCount: items.length,
|
||
firstItemRaw: items.length > 0 ? items[0] : null,
|
||
allItemsRaw: items
|
||
});
|
||
|
||
// Use backend data directly - no mapping needed, just like prompts
|
||
// Field names come from backend attributes
|
||
console.log('📊 Final files array (using backend data directly):', items);
|
||
setFiles(items);
|
||
if (data.pagination) {
|
||
setPagination(data.pagination);
|
||
}
|
||
setGroupLayout((data as any).groupLayout ?? null);
|
||
setAppliedView((data as any).appliedView ?? null);
|
||
} else {
|
||
const items = Array.isArray(data) ? data : [];
|
||
setFiles(items);
|
||
setPagination(null);
|
||
setGroupLayout(null);
|
||
setAppliedView(null);
|
||
}
|
||
} catch (error: any) {
|
||
setFiles([]);
|
||
setPagination(null);
|
||
setGroupLayout(null);
|
||
setAppliedView(null);
|
||
}
|
||
}, [request]);
|
||
|
||
// Optimistically remove a file from the local state
|
||
const removeFileOptimistically = (fileId: string) => {
|
||
setFiles(prevFiles => prevFiles.filter(file => file.id !== fileId));
|
||
};
|
||
|
||
// Optimistically update a file in the local state
|
||
const updateFileOptimistically = (fileId: string, updateData: any) => {
|
||
setFiles(prevFiles =>
|
||
prevFiles.map(file =>
|
||
file.id === fileId
|
||
? { ...file, ...updateData }
|
||
: file
|
||
)
|
||
);
|
||
};
|
||
|
||
// Fetch a single file by ID
|
||
const fetchFileById = useCallback(async (fileId: string): Promise<UserFile | null> => {
|
||
return await fetchFileByIdApi(request, fileId);
|
||
}, [request]);
|
||
|
||
// Generate edit fields from attributes dynamically
|
||
const generateEditFieldsFromAttributes = useCallback((): Array<{
|
||
key: string;
|
||
label: string;
|
||
type: 'string' | 'boolean' | 'email' | 'textarea' | 'date' | 'enum' | 'readonly';
|
||
editable?: boolean;
|
||
required?: boolean;
|
||
validator?: (value: any) => string | null;
|
||
minRows?: number;
|
||
maxRows?: number;
|
||
}> => {
|
||
if (!attributes || attributes.length === 0) {
|
||
return [];
|
||
}
|
||
|
||
const editableFields = attributes
|
||
.filter(attr => {
|
||
// Filter out non-editable fields (id, mandateId, fileHash, etc.)
|
||
const nonEditableFields = ['id', 'mandateId', 'fileHash', 'creationDate', 'source'];
|
||
return !nonEditableFields.includes(attr.name);
|
||
})
|
||
.map(attr => {
|
||
// Map attribute type to form field type
|
||
let fieldType: 'string' | 'boolean' | 'email' | 'textarea' | 'date' | 'enum' | 'readonly' = 'string';
|
||
|
||
if (attr.type === 'boolean') {
|
||
fieldType = 'boolean';
|
||
} else if (attr.type === 'date') {
|
||
fieldType = 'date';
|
||
} else if (attr.type === 'enum' && attr.filterOptions) {
|
||
fieldType = 'enum';
|
||
} else if (attr.name === 'fileName' || attr.name === 'file_name') {
|
||
fieldType = 'string';
|
||
}
|
||
|
||
// Define validators and required fields
|
||
let required = false;
|
||
let validator: ((value: any) => string | null) | undefined = undefined;
|
||
|
||
if (attr.name === 'fileName' || attr.name === 'file_name') {
|
||
required = true;
|
||
validator = (value: any) => {
|
||
if (!value || (typeof value === 'string' && value.trim() === '')) {
|
||
return 'File name cannot be empty';
|
||
}
|
||
if (typeof value === 'string' && value.length > 255) {
|
||
return 'File name cannot exceed 255 characters';
|
||
}
|
||
return null;
|
||
};
|
||
}
|
||
|
||
return {
|
||
key: attr.name,
|
||
label: attr.label || attr.name,
|
||
type: fieldType,
|
||
editable: true,
|
||
required,
|
||
validator
|
||
};
|
||
});
|
||
|
||
return editableFields;
|
||
}, [attributes]);
|
||
|
||
// Ensure attributes are loaded - can be called by EditActionButton
|
||
const ensureAttributesLoaded = useCallback(async () => {
|
||
// If attributes are already loaded, return them
|
||
if (attributes && attributes.length > 0) {
|
||
return attributes;
|
||
}
|
||
|
||
// Otherwise, fetch them and return the result
|
||
const fetchedAttributes = await fetchAttributes();
|
||
return fetchedAttributes;
|
||
}, [attributes, fetchAttributes]);
|
||
|
||
// Fetch attributes and permissions on mount
|
||
// Note: Do NOT fetch files here - let the table component control pagination
|
||
useEffect(() => {
|
||
fetchAttributes();
|
||
fetchPermissions();
|
||
}, [fetchAttributes, fetchPermissions]);
|
||
|
||
// Listen for file upload events and refresh the list
|
||
useEffect(() => {
|
||
const handleFileUploaded = (event: CustomEvent) => {
|
||
console.log('📁 File uploaded event received, refreshing list...', event.detail);
|
||
// Small delay to ensure backend has persisted the file
|
||
setTimeout(() => {
|
||
fetchFiles();
|
||
}, 100);
|
||
};
|
||
|
||
window.addEventListener('fileUploaded', handleFileUploaded as EventListener);
|
||
return () => {
|
||
window.removeEventListener('fileUploaded', handleFileUploaded as EventListener);
|
||
};
|
||
}, [fetchFiles]);
|
||
|
||
return {
|
||
data: files,
|
||
loading,
|
||
error,
|
||
refetch: fetchFiles,
|
||
removeFileOptimistically,
|
||
updateFileOptimistically,
|
||
attributes,
|
||
permissions,
|
||
pagination,
|
||
groupLayout,
|
||
appliedView,
|
||
fetchFileById,
|
||
generateEditFieldsFromAttributes,
|
||
ensureAttributesLoaded,
|
||
fetchGroupSectionSummaries,
|
||
refetchForSection,
|
||
};
|
||
}
|
||
|
||
// File operations hook
|
||
export function useFileOperations() {
|
||
const [downloadingFiles, setDownloadingFiles] = useState<Set<string>>(new Set());
|
||
const [deletingFiles, setDeletingFiles] = useState<Set<string>>(new Set());
|
||
const [editingFiles, setEditingFiles] = useState<Set<string>>(new Set());
|
||
const [uploadingFile, setUploadingFile] = useState(false);
|
||
const [isLoading] = useState(false);
|
||
const [downloadError, setDownloadError] = useState<string | null>(null);
|
||
const [deleteError, setDeleteError] = useState<string | null>(null);
|
||
const [uploadError, setUploadError] = useState<string | null>(null);
|
||
const [previewingFiles, setPreviewingFiles] = useState<Set<string>>(new Set());
|
||
const [previewError, setPreviewError] = useState<string | null>(null);
|
||
|
||
// Toast for notifications
|
||
const { showWarning } = useToast();
|
||
|
||
// Language context
|
||
const { t } = useLanguage();
|
||
|
||
// API request function for API layer calls
|
||
const { request } = useApiRequest();
|
||
|
||
const handleFileDownload = async (fileId: string, fileName: string) => {
|
||
setDownloadError(null);
|
||
setDownloadingFiles(prev => new Set(prev).add(fileId));
|
||
|
||
try {
|
||
|
||
// Try to get the file download
|
||
const response = await api.get(`/api/files/${fileId}/download`, {
|
||
responseType: 'blob',
|
||
validateStatus: function (status: number) {
|
||
return status >= 200 && status < 300; // default
|
||
}
|
||
});
|
||
const blob = response.data;
|
||
|
||
// Create a download link and trigger the download
|
||
const url = window.URL.createObjectURL(blob);
|
||
const link = document.createElement('a');
|
||
link.href = url;
|
||
link.setAttribute('download', fileName);
|
||
document.body.appendChild(link);
|
||
link.click();
|
||
document.body.removeChild(link);
|
||
window.URL.revokeObjectURL(url);
|
||
|
||
return true;
|
||
} catch (error: any) {
|
||
console.error(`❌ Download failed for ${fileName}:`, error);
|
||
let errorMessage = error.message;
|
||
|
||
if (error.response?.status === 404) {
|
||
errorMessage = `File "${fileName}" not found or has been deleted.`;
|
||
} else if (error.response?.status === 403) {
|
||
errorMessage = `No permission to download "${fileName}".`;
|
||
}
|
||
|
||
setDownloadError(errorMessage);
|
||
return false;
|
||
} finally {
|
||
setDownloadingFiles(prev => {
|
||
const newSet = new Set(prev);
|
||
newSet.delete(fileId);
|
||
return newSet;
|
||
});
|
||
}
|
||
};
|
||
|
||
const handleFileDelete = async (fileId: string, onOptimisticDelete?: () => void) => {
|
||
setDeleteError(null);
|
||
setDeletingFiles(prev => new Set(prev).add(fileId));
|
||
|
||
// Optimistically remove from UI if callback provided
|
||
if (onOptimisticDelete) {
|
||
onOptimisticDelete();
|
||
}
|
||
|
||
try {
|
||
await deleteFileApi(request, fileId);
|
||
|
||
// Add a small delay to ensure backend has time to process
|
||
await new Promise(resolve => setTimeout(resolve, 300));
|
||
return true;
|
||
} catch (error: any) {
|
||
console.error(`❌ Delete failed for file ID ${fileId}:`, error);
|
||
let errorMessage = error.message;
|
||
|
||
if (error.response?.status === 404) {
|
||
errorMessage = `File not found or has already been deleted.`;
|
||
// If file doesn't exist, consider it successfully "deleted"
|
||
return true;
|
||
} else if (error.response?.status === 403) {
|
||
errorMessage = `No permission to delete this file.`;
|
||
}
|
||
|
||
setDeleteError(errorMessage);
|
||
// If deletion failed and we optimistically removed it, we should refetch to restore the file
|
||
return false;
|
||
} finally {
|
||
setDeletingFiles(prev => {
|
||
const newSet = new Set(prev);
|
||
newSet.delete(fileId);
|
||
return newSet;
|
||
});
|
||
}
|
||
};
|
||
|
||
const handleFileDeleteMultiple = async (fileIds: string[], onOptimisticDelete?: (fileIds: string[]) => void) => {
|
||
setDeleteError(null);
|
||
setDeletingFiles(prev => {
|
||
const newSet = new Set(prev);
|
||
fileIds.forEach(id => newSet.add(id));
|
||
return newSet;
|
||
});
|
||
|
||
// Optimistically remove from UI if callback provided
|
||
if (onOptimisticDelete) {
|
||
onOptimisticDelete(fileIds);
|
||
}
|
||
|
||
try {
|
||
// Use API function for bulk delete
|
||
const results = await deleteFilesApi(request, fileIds);
|
||
|
||
// Check if any deletions failed
|
||
const failures = results.filter(result => !result.success);
|
||
|
||
if (failures.length > 0) {
|
||
console.error(`❌ ${failures.length} out of ${fileIds.length} files failed to delete`);
|
||
// For now, we'll consider it successful if at least some files were deleted
|
||
// In a more robust implementation, you might want to handle partial failures differently
|
||
}
|
||
|
||
// Add a small delay to ensure backend has time to process
|
||
await new Promise(resolve => setTimeout(resolve, 300));
|
||
return true;
|
||
} catch (error: any) {
|
||
console.error(`❌ Bulk delete failed:`, error);
|
||
setDeleteError(error.message || 'Bulk delete failed');
|
||
return false;
|
||
} finally {
|
||
setDeletingFiles(prev => {
|
||
const newSet = new Set(prev);
|
||
fileIds.forEach(id => newSet.delete(id));
|
||
return newSet;
|
||
});
|
||
}
|
||
};
|
||
|
||
/**
|
||
* File upload function - backend bug has been fixed!
|
||
*
|
||
* BACKEND FIXES APPLIED:
|
||
* - Fixed file.fileName → file.filename in routeDataFiles.py
|
||
* - Removed workflowId from FileItem creation in interfaceComponentObjects.py
|
||
* - Upload should now work correctly
|
||
*/
|
||
const handleFileUpload = async (
|
||
file: globalThis.File,
|
||
workflowId?: string,
|
||
featureInstanceId?: string,
|
||
) => {
|
||
setUploadError(null);
|
||
setUploadingFile(true);
|
||
|
||
try {
|
||
|
||
// Validate file before upload
|
||
if (!file || !file.name || file.name.trim() === '') {
|
||
throw new Error('Invalid file: File must have a valid name');
|
||
}
|
||
|
||
if (file.size === 0) {
|
||
throw new Error('Invalid file: File cannot be empty');
|
||
}
|
||
|
||
const formData = new FormData();
|
||
formData.append('file', file);
|
||
|
||
if (workflowId) {
|
||
formData.append('workflowId', workflowId);
|
||
}
|
||
if (featureInstanceId) {
|
||
formData.append('featureInstanceId', featureInstanceId);
|
||
}
|
||
|
||
// FormData is now correctly configured for backend
|
||
|
||
|
||
// Do NOT set Content-Type manually – axios sets multipart/form-data with boundary for FormData
|
||
const response = await api.post('/api/files/upload', formData);
|
||
const fileData = response.data;
|
||
|
||
// Check if the response indicates a duplicate file
|
||
if (fileData && fileData.isDuplicate && fileData.message) {
|
||
const fileName = fileData.originalFileName || file.name;
|
||
const messageTemplate = t('Die Datei "{fileName}" existiert bereits mit identischem Inhalt. Die vorhandene Datei wird wiederverwendet.');
|
||
const message = messageTemplate.replace('{fileName}', fileName);
|
||
showWarning(t('Datei bereits vorhanden'), message);
|
||
}
|
||
|
||
// Dispatch event to notify other components about the new file
|
||
window.dispatchEvent(new CustomEvent('fileUploaded', { detail: fileData }));
|
||
|
||
return { success: true, fileData };
|
||
} catch (error: any) {
|
||
console.error('Upload failed:', error);
|
||
|
||
let errorMessage = error.message;
|
||
|
||
if (error.response?.status === 500) {
|
||
if (errorMessage.includes('validation')) {
|
||
errorMessage = 'File validation failed. Please ensure the file is valid and try again.';
|
||
} else {
|
||
errorMessage = 'Server error during upload. Please try again later.';
|
||
}
|
||
}
|
||
|
||
setUploadError(errorMessage);
|
||
return { success: false, error: errorMessage };
|
||
} finally {
|
||
setUploadingFile(false);
|
||
}
|
||
};
|
||
|
||
const handleFileUpdate = async (fileId: string, updateData: Record<string, any>, _originalFileData?: any) => {
|
||
setUploadError(null);
|
||
setEditingFiles(prev => new Set(prev).add(fileId));
|
||
|
||
try {
|
||
const updatedFile = await updateFileApi(request, fileId, updateData);
|
||
|
||
return { success: true, fileData: updatedFile };
|
||
} catch (error: any) {
|
||
console.error(`Update failed for file ID ${fileId}:`, error);
|
||
|
||
let errorMessage = error.message;
|
||
|
||
if (error.response?.status === 404) {
|
||
errorMessage = `File not found or has been deleted.`;
|
||
} else if (error.response?.status === 403) {
|
||
errorMessage = `No permission to update this file.`;
|
||
} else if (error.response?.status === 400) {
|
||
errorMessage = `Invalid file update data: ${error.response?.data?.detail || error.response?.data || errorMessage}`;
|
||
} else if (error.response?.status === 422) {
|
||
errorMessage = `Validation error: ${error.response?.data?.detail || errorMessage}`;
|
||
} else if (error.response?.status === 500) {
|
||
errorMessage = `Server error: ${error.response?.data?.detail || errorMessage}`;
|
||
}
|
||
|
||
setUploadError(errorMessage);
|
||
return { success: false, error: errorMessage };
|
||
} finally {
|
||
setEditingFiles(prev => {
|
||
const newSet = new Set(prev);
|
||
newSet.delete(fileId);
|
||
return newSet;
|
||
});
|
||
}
|
||
};
|
||
|
||
const handleFilePreview = async (
|
||
fileId: string,
|
||
fileName: string,
|
||
_mimeType?: string,
|
||
): Promise<FilePreviewResult> => {
|
||
setPreviewError(null);
|
||
setPreviewingFiles(prev => new Set(prev).add(fileId));
|
||
|
||
try {
|
||
const response = await api.get(`/api/files/${fileId}/preview`, {
|
||
responseType: 'json',
|
||
});
|
||
const data = response.data;
|
||
|
||
if (!data || typeof data !== 'object' || typeof data.content !== 'string' || !data.mimeType) {
|
||
throw new Error('Invalid preview response from server');
|
||
}
|
||
|
||
const { content, mimeType: responseMime, isText } = data as {
|
||
content: string;
|
||
mimeType: string;
|
||
isText?: boolean;
|
||
encoding?: string | null;
|
||
};
|
||
|
||
let blob: Blob;
|
||
let textContent: string | null = null;
|
||
|
||
if (isText) {
|
||
textContent = content;
|
||
blob = new Blob([content], { type: responseMime });
|
||
} else {
|
||
const binaryString = atob(content);
|
||
const bytes = new Uint8Array(binaryString.length);
|
||
for (let i = 0; i < binaryString.length; i++) {
|
||
bytes[i] = binaryString.charCodeAt(i);
|
||
}
|
||
blob = new Blob([bytes], { type: responseMime });
|
||
}
|
||
|
||
const previewUrl = window.URL.createObjectURL(blob);
|
||
|
||
return {
|
||
success: true,
|
||
previewUrl,
|
||
blob,
|
||
mimeType: responseMime,
|
||
textContent,
|
||
};
|
||
} catch (error: any) {
|
||
console.error(`Preview failed for ${fileName}:`, error);
|
||
let errorMessage = error?.message ?? 'Unknown error';
|
||
|
||
if (error?.response?.status === 404) {
|
||
errorMessage = `File "${fileName}" not found or has been deleted.`;
|
||
} else if (error?.response?.status === 403) {
|
||
errorMessage = `No permission to preview "${fileName}".`;
|
||
} else if (error?.response?.status === 415) {
|
||
errorMessage = `File type "${fileName}" is not supported for preview.`;
|
||
}
|
||
|
||
setPreviewError(errorMessage);
|
||
return { success: false, error: errorMessage };
|
||
} finally {
|
||
setPreviewingFiles(prev => {
|
||
const newSet = new Set(prev);
|
||
newSet.delete(fileId);
|
||
return newSet;
|
||
});
|
||
}
|
||
};
|
||
|
||
const handleInlineUpdate = async (fileId: string, changes: Record<string, any>, _existingRow?: any) => {
|
||
const result = await handleFileUpdate(fileId, changes);
|
||
if (!result.success) {
|
||
throw new Error(result.error || 'Failed to update');
|
||
}
|
||
return result;
|
||
};
|
||
|
||
return {
|
||
downloadingFiles,
|
||
deletingFiles,
|
||
editingFiles,
|
||
uploadingFile,
|
||
downloadError,
|
||
deleteError,
|
||
uploadError,
|
||
previewingFiles,
|
||
previewError,
|
||
handleFileDownload,
|
||
handleFileDelete,
|
||
handleFileDeleteMultiple,
|
||
handleFileUpload,
|
||
handleFileUpdate,
|
||
handleFilePreview,
|
||
handleInlineUpdate,
|
||
isLoading
|
||
};
|
||
}
|
||
|
||
// Folder operations hook
|
||
export function useFolderOperations() {
|
||
const [folderLoading, setFolderLoading] = useState(false);
|
||
const [folderError, setFolderError] = useState<string | null>(null);
|
||
const { request } = useApiRequest();
|
||
|
||
const fetchOwnFolderTree = useCallback(async (): Promise<FolderInfo[]> => {
|
||
setFolderLoading(true);
|
||
setFolderError(null);
|
||
try {
|
||
return await getFolderTree(request, 'me');
|
||
} catch (err: any) {
|
||
const msg = err?.message ?? 'Failed to fetch own folder tree';
|
||
setFolderError(msg);
|
||
throw err;
|
||
} finally {
|
||
setFolderLoading(false);
|
||
}
|
||
}, [request]);
|
||
|
||
const fetchSharedFolderTree = useCallback(async (): Promise<FolderInfo[]> => {
|
||
setFolderLoading(true);
|
||
setFolderError(null);
|
||
try {
|
||
return await getFolderTree(request, 'shared');
|
||
} catch (err: any) {
|
||
const msg = err?.message ?? 'Failed to fetch shared folder tree';
|
||
setFolderError(msg);
|
||
throw err;
|
||
} finally {
|
||
setFolderLoading(false);
|
||
}
|
||
}, [request]);
|
||
|
||
const handleCreateFolder = useCallback(async (
|
||
name: string,
|
||
parentId?: string | null,
|
||
): Promise<FolderInfo> => {
|
||
setFolderLoading(true);
|
||
setFolderError(null);
|
||
try {
|
||
return await createFolderApi(request, name, parentId);
|
||
} catch (err: any) {
|
||
const msg = err?.message ?? 'Failed to create folder';
|
||
setFolderError(msg);
|
||
throw err;
|
||
} finally {
|
||
setFolderLoading(false);
|
||
}
|
||
}, [request]);
|
||
|
||
const handleRenameFolder = useCallback(async (
|
||
folderId: string,
|
||
name: string,
|
||
): Promise<FolderInfo> => {
|
||
setFolderLoading(true);
|
||
setFolderError(null);
|
||
try {
|
||
return await renameFolderApi(request, folderId, name);
|
||
} catch (err: any) {
|
||
const msg = err?.message ?? 'Failed to rename folder';
|
||
setFolderError(msg);
|
||
throw err;
|
||
} finally {
|
||
setFolderLoading(false);
|
||
}
|
||
}, [request]);
|
||
|
||
const handleMoveFolder = useCallback(async (
|
||
folderId: string,
|
||
parentId: string | null,
|
||
): Promise<FolderInfo> => {
|
||
setFolderLoading(true);
|
||
setFolderError(null);
|
||
try {
|
||
return await moveFolderApi(request, folderId, parentId);
|
||
} catch (err: any) {
|
||
const msg = err?.message ?? 'Failed to move folder';
|
||
setFolderError(msg);
|
||
throw err;
|
||
} finally {
|
||
setFolderLoading(false);
|
||
}
|
||
}, [request]);
|
||
|
||
const handleDeleteFolderCascade = useCallback(async (
|
||
folderId: string,
|
||
): Promise<{ deletedFolders: number; deletedFiles: number }> => {
|
||
setFolderLoading(true);
|
||
setFolderError(null);
|
||
try {
|
||
return await deleteFolderCascadeApi(request, folderId);
|
||
} catch (err: any) {
|
||
const msg = err?.message ?? 'Failed to delete folder';
|
||
setFolderError(msg);
|
||
throw err;
|
||
} finally {
|
||
setFolderLoading(false);
|
||
}
|
||
}, [request]);
|
||
|
||
const handlePatchFolderScope = useCallback(async (
|
||
folderId: string,
|
||
scope: string,
|
||
cascadeToFiles: boolean = false,
|
||
): Promise<{ folderId: string; scope: string; filesUpdated: number }> => {
|
||
setFolderLoading(true);
|
||
setFolderError(null);
|
||
try {
|
||
return await patchFolderScopeApi(request, folderId, scope, cascadeToFiles);
|
||
} catch (err: any) {
|
||
const msg = err?.message ?? 'Failed to patch folder scope';
|
||
setFolderError(msg);
|
||
throw err;
|
||
} finally {
|
||
setFolderLoading(false);
|
||
}
|
||
}, [request]);
|
||
|
||
const handlePatchFolderNeutralize = useCallback(async (
|
||
folderId: string,
|
||
neutralize: boolean,
|
||
): Promise<{ folderId: string; neutralize: boolean; filesUpdated: number }> => {
|
||
setFolderLoading(true);
|
||
setFolderError(null);
|
||
try {
|
||
return await patchFolderNeutralizeApi(request, folderId, neutralize);
|
||
} catch (err: any) {
|
||
const msg = err?.message ?? 'Failed to patch folder neutralize';
|
||
setFolderError(msg);
|
||
throw err;
|
||
} finally {
|
||
setFolderLoading(false);
|
||
}
|
||
}, [request]);
|
||
|
||
const handleMoveFiles = useCallback(async (
|
||
fileIds: string[],
|
||
targetFolderId: string | null,
|
||
): Promise<void> => {
|
||
setFolderLoading(true);
|
||
setFolderError(null);
|
||
try {
|
||
await moveFilesApi(request, fileIds, targetFolderId);
|
||
} catch (err: any) {
|
||
const msg = err?.message ?? 'Failed to move files';
|
||
setFolderError(msg);
|
||
throw err;
|
||
} finally {
|
||
setFolderLoading(false);
|
||
}
|
||
}, [request]);
|
||
|
||
return {
|
||
folderLoading,
|
||
folderError,
|
||
fetchOwnFolderTree,
|
||
fetchSharedFolderTree,
|
||
handleCreateFolder,
|
||
handleRenameFolder,
|
||
handleMoveFolder,
|
||
handleDeleteFolderCascade,
|
||
handlePatchFolderScope,
|
||
handlePatchFolderNeutralize,
|
||
handleMoveFiles,
|
||
};
|
||
} |