1373 lines
50 KiB
JavaScript
1373 lines
50 KiB
JavaScript
// File: monorepo/web/maplefile-backend/src/pages/User/FileManager/Files/FileUpload.jsx
|
|
// UIX version - Theme-aware File Upload with Layout
|
|
import React, {
|
|
useState,
|
|
useCallback,
|
|
useEffect,
|
|
useRef,
|
|
useMemo,
|
|
} from "react";
|
|
import { useNavigate, useLocation, useSearchParams } from "react-router";
|
|
import { useServices } from "../../../../services/Services";
|
|
import withPasswordProtection from "../../../../hocs/withPasswordProtection";
|
|
import Layout from "../../../../components/Layout/Layout";
|
|
import {
|
|
Button,
|
|
Alert,
|
|
useUIXTheme,
|
|
Breadcrumb,
|
|
Card,
|
|
Select,
|
|
Checkbox,
|
|
} from "../../../../components/UIX";
|
|
import {
|
|
CloudArrowUpIcon,
|
|
FolderIcon,
|
|
ArrowLeftIcon,
|
|
DocumentIcon,
|
|
XMarkIcon,
|
|
PhotoIcon,
|
|
FilmIcon,
|
|
MusicalNoteIcon,
|
|
DocumentTextIcon,
|
|
ArrowUpTrayIcon,
|
|
ExclamationTriangleIcon,
|
|
CheckCircleIcon,
|
|
HomeIcon,
|
|
TagIcon,
|
|
} from "@heroicons/react/24/outline";
|
|
import TagSelector from "../../../../components/UIX/TagSelector/TagSelector";
|
|
|
|
const FileUpload = () => {
|
|
const navigate = useNavigate();
|
|
const location = useLocation();
|
|
const [searchParams] = useSearchParams();
|
|
const { getThemeClasses } = useUIXTheme();
|
|
const fileInputRef = useRef(null);
|
|
const isMountedRef = useRef(true);
|
|
|
|
const {
|
|
createFileManager,
|
|
createCollectionManager,
|
|
listCollectionManager,
|
|
authManager,
|
|
dashboardManager,
|
|
} = useServices();
|
|
// Note: Tags are now embedded at file creation time via the API,
|
|
// so we don't need fileTagManager for post-upload assignment
|
|
|
|
const preSelectedCollectionId = searchParams.get("collection");
|
|
const preSelectedCollectionInfo = location.state?.preSelectedCollection;
|
|
|
|
const [fileManager, setFileManager] = useState(null);
|
|
const [files, setFiles] = useState([]);
|
|
const [selectedCollection, setSelectedCollection] = useState(
|
|
preSelectedCollectionId || "",
|
|
);
|
|
const [availableCollections, setAvailableCollections] = useState([]);
|
|
const [isLoadingCollections, setIsLoadingCollections] = useState(true); // Start with loading true
|
|
const [isCollectionsInitialized, setIsCollectionsInitialized] = useState(false);
|
|
const [isUploading, setIsUploading] = useState(false);
|
|
const [isDragging, setIsDragging] = useState(false);
|
|
const [error, setError] = useState("");
|
|
const [success, setSuccess] = useState("");
|
|
const [gdprConsent, setGdprConsent] = useState(false);
|
|
const [uploadProgress, setUploadProgress] = useState({}); // Track progress per file
|
|
const [selectedTagIds, setSelectedTagIds] = useState([]); // Tags to apply to all uploaded files
|
|
|
|
useEffect(() => {
|
|
isMountedRef.current = true;
|
|
return () => {
|
|
isMountedRef.current = false;
|
|
};
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
const initializeManager = async () => {
|
|
if (!authManager.isAuthenticated()) return;
|
|
|
|
try {
|
|
const { default: CreateFileManager } =
|
|
await import("../../../../services/Manager/File/CreateFileManager.js");
|
|
|
|
const manager = new CreateFileManager(authManager);
|
|
await manager.initialize();
|
|
|
|
if (isMountedRef.current) {
|
|
setFileManager(manager);
|
|
}
|
|
} catch (err) {
|
|
if (isMountedRef.current) {
|
|
setError("Could not initialize upload service");
|
|
}
|
|
}
|
|
};
|
|
|
|
initializeManager();
|
|
}, [authManager]);
|
|
|
|
const loadCollections = useCallback(async () => {
|
|
if (!listCollectionManager) return;
|
|
|
|
setIsLoadingCollections(true);
|
|
try {
|
|
const result = await listCollectionManager.listCollections(false);
|
|
if (isMountedRef.current) {
|
|
if (result.collections && result.collections.length > 0) {
|
|
setAvailableCollections(result.collections);
|
|
}
|
|
setIsCollectionsInitialized(true);
|
|
}
|
|
} catch (err) {
|
|
if (isMountedRef.current) {
|
|
if (import.meta.env.DEV) {
|
|
console.error("[FileUpload] Could not load folders:", err);
|
|
}
|
|
setError("Could not load folders");
|
|
setIsCollectionsInitialized(true);
|
|
}
|
|
} finally {
|
|
if (isMountedRef.current) {
|
|
setIsLoadingCollections(false);
|
|
}
|
|
}
|
|
}, [listCollectionManager]);
|
|
|
|
// Load collections when manager becomes available
|
|
useEffect(() => {
|
|
if (createCollectionManager && listCollectionManager && !isCollectionsInitialized) {
|
|
loadCollections();
|
|
}
|
|
}, [createCollectionManager, listCollectionManager, isCollectionsInitialized, loadCollections]);
|
|
|
|
const handleDragOver = useCallback((e) => {
|
|
e.preventDefault();
|
|
setIsDragging(true);
|
|
}, []);
|
|
|
|
const handleDragLeave = useCallback((e) => {
|
|
e.preventDefault();
|
|
setIsDragging(false);
|
|
}, []);
|
|
|
|
// Helper function to generate file fingerprint for deduplication
|
|
const getFileFingerprint = useCallback((file) => {
|
|
return `${file.name}|${file.size}|${file.type}|${file.lastModified || ""}`;
|
|
}, []);
|
|
|
|
const addFiles = useCallback(
|
|
(newFiles) => {
|
|
const maxSize = 5 * 1024 * 1024 * 1024; // 5GB
|
|
|
|
// Blocked file extensions for security
|
|
const BLOCKED_EXTENSIONS = [
|
|
".exe",
|
|
".bat",
|
|
".sh",
|
|
".cmd",
|
|
".com",
|
|
".scr",
|
|
".vbs",
|
|
".ps1",
|
|
".app",
|
|
".deb",
|
|
".rpm",
|
|
".dmg",
|
|
".pkg",
|
|
".msi",
|
|
".apk",
|
|
];
|
|
|
|
// Get fingerprints of existing files for deduplication
|
|
const existingFingerprints = new Set(
|
|
files.map((f) => getFileFingerprint(f.file)),
|
|
);
|
|
|
|
const validFiles = newFiles.filter((file) => {
|
|
// Check for duplicates
|
|
const fingerprint = getFileFingerprint(file);
|
|
if (existingFingerprints.has(fingerprint)) {
|
|
setError(`${file.name} is already in the upload list`);
|
|
return false;
|
|
}
|
|
|
|
// Check file size
|
|
if (file.size > maxSize) {
|
|
setError(`${file.name} is too large (max 5GB)`);
|
|
return false;
|
|
}
|
|
|
|
// Check file extension for security
|
|
const extension = file.name
|
|
.toLowerCase()
|
|
.substring(file.name.lastIndexOf("."));
|
|
if (BLOCKED_EXTENSIONS.includes(extension)) {
|
|
setError(`${file.name} is not allowed (blocked file type)`);
|
|
return false;
|
|
}
|
|
|
|
return true;
|
|
});
|
|
|
|
const fileObjects = validFiles.map((file) => ({
|
|
id: crypto.randomUUID(), // Cryptographically secure random ID
|
|
file,
|
|
name: file.name,
|
|
size: file.size,
|
|
type: file.type,
|
|
status: "pending",
|
|
}));
|
|
|
|
setFiles((prev) => [...prev, ...fileObjects]);
|
|
if (fileInputRef.current) fileInputRef.current.value = "";
|
|
},
|
|
[files, getFileFingerprint],
|
|
);
|
|
|
|
const handleDrop = useCallback(
|
|
(e) => {
|
|
e.preventDefault();
|
|
setIsDragging(false);
|
|
const droppedFiles = Array.from(e.dataTransfer.files);
|
|
addFiles(droppedFiles);
|
|
},
|
|
[addFiles],
|
|
);
|
|
|
|
const handleFileSelect = useCallback(
|
|
(e) => {
|
|
const selectedFiles = Array.from(e.target.files);
|
|
addFiles(selectedFiles);
|
|
},
|
|
[addFiles],
|
|
);
|
|
|
|
const removeFile = useCallback((fileId) => {
|
|
setFiles((files) => files.filter((f) => f.id !== fileId));
|
|
}, []);
|
|
|
|
const formatFileSize = useCallback((bytes) => {
|
|
if (!bytes) return "0 B";
|
|
const sizes = ["B", "KB", "MB", "GB"];
|
|
const i = Math.floor(Math.log(bytes) / Math.log(1024));
|
|
return `${Math.round(bytes / Math.pow(1024, i))} ${sizes[i]}`;
|
|
}, []);
|
|
|
|
const getFileIcon = useCallback(
|
|
(file) => {
|
|
const iconClass = "h-5 w-5";
|
|
|
|
if (file.type.startsWith("image/")) {
|
|
return (
|
|
<PhotoIcon
|
|
className={`${iconClass} ${getThemeClasses("text-accent")}`}
|
|
/>
|
|
);
|
|
}
|
|
if (file.type.startsWith("video/")) {
|
|
return (
|
|
<FilmIcon
|
|
className={`${iconClass} ${getThemeClasses("text-warning")}`}
|
|
/>
|
|
);
|
|
}
|
|
if (file.type.startsWith("audio/")) {
|
|
return (
|
|
<MusicalNoteIcon
|
|
className={`${iconClass} ${getThemeClasses("text-success")}`}
|
|
/>
|
|
);
|
|
}
|
|
if (file.type.includes("pdf")) {
|
|
return (
|
|
<DocumentIcon
|
|
className={`${iconClass} ${getThemeClasses("text-error")}`}
|
|
/>
|
|
);
|
|
}
|
|
if (file.type.includes("text") || file.name.endsWith(".txt")) {
|
|
return (
|
|
<DocumentTextIcon
|
|
className={`${iconClass} ${getThemeClasses("text-secondary")}`}
|
|
/>
|
|
);
|
|
}
|
|
return (
|
|
<DocumentIcon
|
|
className={`${iconClass} ${getThemeClasses("text-info")}`}
|
|
/>
|
|
);
|
|
},
|
|
[getThemeClasses],
|
|
);
|
|
|
|
const clearAllRelevantCaches = useCallback(async () => {
|
|
if (import.meta.env.DEV) {
|
|
console.log("[FileUpload] Starting comprehensive cache clearing...");
|
|
}
|
|
|
|
try {
|
|
// Clear ListCollectionManager cache
|
|
if (listCollectionManager) {
|
|
if (import.meta.env.DEV) {
|
|
console.log("[FileUpload] Clearing ListCollectionManager cache");
|
|
}
|
|
listCollectionManager.clearAllCache();
|
|
}
|
|
|
|
// Clear dashboard cache
|
|
if (dashboardManager) {
|
|
if (import.meta.env.DEV) {
|
|
console.log("[FileUpload] Clearing dashboard cache");
|
|
}
|
|
dashboardManager.clearAllCaches();
|
|
}
|
|
|
|
// Clear file-related caches from localStorage with secure filtering
|
|
if (import.meta.env.DEV) {
|
|
console.log("[FileUpload] Clearing file caches from localStorage");
|
|
}
|
|
|
|
// Use exact prefixes for security (whitelist approach)
|
|
const knownPrefixes = [
|
|
"mapleopentech_file_list_",
|
|
"mapleopentech_file_cache_",
|
|
"mapleopentech_dashboard_",
|
|
"mapleopentech_collection_",
|
|
];
|
|
|
|
const fileListKeys = Object.keys(localStorage).filter((key) =>
|
|
knownPrefixes.some((prefix) => key.startsWith(prefix)),
|
|
);
|
|
|
|
fileListKeys.forEach((key) => {
|
|
try {
|
|
localStorage.removeItem(key);
|
|
if (import.meta.env.DEV) {
|
|
console.log("[FileUpload] Removed cache key:", key);
|
|
}
|
|
} catch (error) {
|
|
if (import.meta.env.DEV) {
|
|
console.warn(
|
|
`[FileUpload] Failed to remove cache key: ${key}`,
|
|
error,
|
|
);
|
|
}
|
|
}
|
|
});
|
|
|
|
// Safely access and validate window.mapleopentechServices
|
|
const services = window.mapleopentechServices;
|
|
if (
|
|
services &&
|
|
typeof services === "object" &&
|
|
!Array.isArray(services) &&
|
|
services.constructor === Object
|
|
) {
|
|
if (import.meta.env.DEV) {
|
|
console.log(
|
|
"[FileUpload] Clearing services caches via window.mapleopentechServices",
|
|
);
|
|
}
|
|
|
|
// Safely clear collection manager cache
|
|
if (
|
|
services.getCollectionManager &&
|
|
typeof services.getCollectionManager.clearAllCache === "function"
|
|
) {
|
|
try {
|
|
services.getCollectionManager.clearAllCache();
|
|
if (import.meta.env.DEV) {
|
|
console.log("[FileUpload] GetCollectionManager cache cleared");
|
|
}
|
|
} catch (error) {
|
|
if (import.meta.env.DEV) {
|
|
console.warn(
|
|
"[FileUpload] Failed to clear GetCollectionManager cache",
|
|
error,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Safely clear file manager cache
|
|
if (
|
|
services.getFileManager &&
|
|
typeof services.getFileManager.clearAllCaches === "function"
|
|
) {
|
|
try {
|
|
services.getFileManager.clearAllCaches();
|
|
if (import.meta.env.DEV) {
|
|
console.log("[FileUpload] GetFileManager cache cleared");
|
|
}
|
|
} catch (error) {
|
|
if (import.meta.env.DEV) {
|
|
console.warn(
|
|
"[FileUpload] Failed to clear GetFileManager cache",
|
|
error,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Safely clear dashboard cache
|
|
if (
|
|
services.dashboardManager &&
|
|
typeof services.dashboardManager.clearAllCaches === "function"
|
|
) {
|
|
try {
|
|
services.dashboardManager.clearAllCaches();
|
|
if (import.meta.env.DEV) {
|
|
console.log("[FileUpload] DashboardManager cache cleared");
|
|
}
|
|
} catch (error) {
|
|
if (import.meta.env.DEV) {
|
|
console.warn(
|
|
"[FileUpload] Failed to clear DashboardManager cache",
|
|
error,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
} else if (import.meta.env.DEV) {
|
|
console.warn(
|
|
"[FileUpload] window.mapleopentechServices is not available or invalid",
|
|
);
|
|
}
|
|
|
|
if (import.meta.env.DEV) {
|
|
console.log("[FileUpload] ✅ Comprehensive cache clearing completed");
|
|
}
|
|
} catch (error) {
|
|
if (import.meta.env.DEV) {
|
|
console.warn("[FileUpload] ⚠️ Some cache clearing failed:", error);
|
|
}
|
|
}
|
|
}, [listCollectionManager, dashboardManager]);
|
|
|
|
const triggerDashboardRefresh = useCallback(() => {
|
|
if (import.meta.env.DEV) {
|
|
console.log("[FileUpload] Triggering dashboard refresh event");
|
|
}
|
|
|
|
// Dispatch custom event for dashboard refresh
|
|
const refreshEvent = new CustomEvent("dashboardRefresh", {
|
|
detail: { reason: "file_upload_completed", timestamp: Date.now() },
|
|
});
|
|
window.dispatchEvent(refreshEvent);
|
|
|
|
// Also store in localStorage to signal refresh across tabs
|
|
const refreshSignal = {
|
|
event: "file_upload_completed",
|
|
timestamp: Date.now(),
|
|
collection: selectedCollection,
|
|
fileCount: files.length,
|
|
};
|
|
localStorage.setItem(
|
|
"mapleopentech_upload_refresh_signal",
|
|
JSON.stringify(refreshSignal),
|
|
);
|
|
|
|
// Remove the signal after a short delay
|
|
setTimeout(() => {
|
|
localStorage.removeItem("mapleopentech_upload_refresh_signal");
|
|
}, 5000);
|
|
}, [selectedCollection, files.length]);
|
|
|
|
// Helper function to upload a single file with progress tracking
|
|
const uploadSingleFile = useCallback(
|
|
async (fileObj, tagIds = []) => {
|
|
try {
|
|
// Set initial progress
|
|
setUploadProgress((prev) => ({ ...prev, [fileObj.id]: 0 }));
|
|
|
|
setFiles((prev) =>
|
|
prev.map((f) =>
|
|
f.id === fileObj.id ? { ...f, status: "uploading" } : f,
|
|
),
|
|
);
|
|
|
|
// Simulate progress for better UX (since actual progress requires backend support)
|
|
const progressInterval = setInterval(() => {
|
|
setUploadProgress((prev) => {
|
|
const currentProgress = prev[fileObj.id] || 0;
|
|
if (currentProgress < 90) {
|
|
return { ...prev, [fileObj.id]: currentProgress + 10 };
|
|
}
|
|
return prev;
|
|
});
|
|
}, 200);
|
|
|
|
// Pass tagIds to createAndUploadFileFromFile for embedding at creation time
|
|
const uploadResult = await fileManager.createAndUploadFileFromFile(
|
|
fileObj.file,
|
|
selectedCollection,
|
|
null,
|
|
tagIds, // Tags are embedded at file creation time on the backend
|
|
);
|
|
|
|
clearInterval(progressInterval);
|
|
setUploadProgress((prev) => ({ ...prev, [fileObj.id]: 100 }));
|
|
|
|
if (import.meta.env.DEV && tagIds.length > 0) {
|
|
console.log(
|
|
`[FileUpload] File ${uploadResult?.file?.id} created with ${tagIds.length} embedded tags`,
|
|
);
|
|
}
|
|
|
|
setFiles((prev) =>
|
|
prev.map((f) =>
|
|
f.id === fileObj.id ? { ...f, status: "complete" } : f,
|
|
),
|
|
);
|
|
|
|
return {
|
|
success: true,
|
|
fileId: fileObj.id,
|
|
uploadedFileId: uploadResult?.file?.id,
|
|
};
|
|
} catch (err) {
|
|
setUploadProgress((prev) => {
|
|
const newProgress = { ...prev };
|
|
delete newProgress[fileObj.id];
|
|
return newProgress;
|
|
});
|
|
|
|
setFiles((prev) =>
|
|
prev.map((f) =>
|
|
f.id === fileObj.id
|
|
? { ...f, status: "error", error: err.message }
|
|
: f,
|
|
),
|
|
);
|
|
|
|
return { success: false, fileId: fileObj.id, error: err.message };
|
|
}
|
|
},
|
|
[fileManager, selectedCollection],
|
|
);
|
|
|
|
const startUpload = useCallback(async () => {
|
|
if (!fileManager || !selectedCollection || files.length === 0) {
|
|
setError("Please select files and a folder");
|
|
return;
|
|
}
|
|
|
|
setIsUploading(true);
|
|
setError("");
|
|
setSuccess("");
|
|
|
|
const filesToUpload = files.filter((f) => f.status === "pending");
|
|
|
|
// Upload files concurrently in batches of 3
|
|
const CONCURRENT_UPLOADS = 3;
|
|
const results = [];
|
|
|
|
for (let i = 0; i < filesToUpload.length; i += CONCURRENT_UPLOADS) {
|
|
const batch = filesToUpload.slice(i, i + CONCURRENT_UPLOADS);
|
|
const batchResults = await Promise.all(
|
|
batch.map((fileObj) => uploadSingleFile(fileObj, selectedTagIds)),
|
|
);
|
|
results.push(...batchResults);
|
|
}
|
|
|
|
const successCount = results.filter((r) => r.success).length;
|
|
setIsUploading(false);
|
|
|
|
if (successCount === filesToUpload.length && successCount > 0) {
|
|
setSuccess("All files uploaded successfully!");
|
|
setGdprConsent(false); // Reset consent for next upload
|
|
|
|
if (preSelectedCollectionId) {
|
|
if (import.meta.env.DEV) {
|
|
console.log(
|
|
`[FileUpload] 📁 Upload successful - preparing redirect for ${successCount} files`,
|
|
);
|
|
}
|
|
|
|
await clearAllRelevantCaches();
|
|
triggerDashboardRefresh();
|
|
|
|
setTimeout(() => {
|
|
if (import.meta.env.DEV) {
|
|
console.log(
|
|
`[FileUpload] 🔄 Redirecting to collection with comprehensive refresh state`,
|
|
);
|
|
}
|
|
|
|
navigate(`/file-manager/collections/${preSelectedCollectionId}`, {
|
|
state: {
|
|
refresh: true,
|
|
refreshFiles: true,
|
|
forceFileRefresh: true,
|
|
uploadedFileCount: successCount,
|
|
uploadTimestamp: Date.now(),
|
|
cacheCleared: true,
|
|
refreshDashboard: true,
|
|
},
|
|
replace: false,
|
|
});
|
|
}, 1500);
|
|
} else {
|
|
setTimeout(async () => {
|
|
await clearAllRelevantCaches();
|
|
triggerDashboardRefresh();
|
|
|
|
navigate("/dashboard", {
|
|
state: {
|
|
refreshDashboard: true,
|
|
uploadCompleted: true,
|
|
uploadedFileCount: successCount,
|
|
uploadTimestamp: Date.now(),
|
|
},
|
|
replace: false,
|
|
});
|
|
}, 1500);
|
|
}
|
|
} else {
|
|
if (successCount > 0) {
|
|
setSuccess(`${successCount} of ${filesToUpload.length} files uploaded`);
|
|
await clearAllRelevantCaches();
|
|
triggerDashboardRefresh();
|
|
}
|
|
}
|
|
}, [
|
|
fileManager,
|
|
selectedCollection,
|
|
files,
|
|
preSelectedCollectionId,
|
|
clearAllRelevantCaches,
|
|
triggerDashboardRefresh,
|
|
navigate,
|
|
uploadSingleFile,
|
|
selectedTagIds,
|
|
]);
|
|
|
|
const getBackUrl = useCallback(() => {
|
|
return preSelectedCollectionId
|
|
? `/file-manager/collections/${preSelectedCollectionId}`
|
|
: "/file-manager";
|
|
}, [preSelectedCollectionId]);
|
|
|
|
// Memoized file computations for performance
|
|
const fileStats = useMemo(() => {
|
|
const totalSize = files.reduce((sum, file) => sum + file.size, 0);
|
|
const pendingFiles = files.filter((f) => f.status === "pending");
|
|
const uploadingFiles = files.filter((f) => f.status === "uploading");
|
|
const completedFiles = files.filter((f) => f.status === "complete");
|
|
const errorFiles = files.filter((f) => f.status === "error");
|
|
|
|
return {
|
|
totalSize,
|
|
pendingFiles,
|
|
uploadingFiles,
|
|
completedFiles,
|
|
errorFiles,
|
|
};
|
|
}, [files]);
|
|
|
|
const {
|
|
totalSize,
|
|
pendingFiles,
|
|
uploadingFiles,
|
|
completedFiles,
|
|
errorFiles,
|
|
} = fileStats;
|
|
|
|
// Memoize collection options for Select component
|
|
const collectionOptions = useMemo(() => {
|
|
return availableCollections.map((collection) => ({
|
|
value: collection.id,
|
|
label: collection.name || "Unnamed Folder",
|
|
}));
|
|
}, [availableCollections]);
|
|
|
|
// Build breadcrumb
|
|
const breadcrumbItems = [
|
|
{
|
|
label: "My Files",
|
|
to: "/file-manager",
|
|
icon: HomeIcon,
|
|
},
|
|
];
|
|
|
|
if (preSelectedCollectionInfo?.name) {
|
|
breadcrumbItems.push({
|
|
label: preSelectedCollectionInfo.name,
|
|
to: `/file-manager/collections/${preSelectedCollectionId}`,
|
|
});
|
|
}
|
|
|
|
breadcrumbItems.push({
|
|
label: "Upload Files",
|
|
isActive: true,
|
|
});
|
|
|
|
return (
|
|
<Layout>
|
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
|
{/* Breadcrumb Navigation */}
|
|
<Breadcrumb items={breadcrumbItems} />
|
|
|
|
{/* Main Card */}
|
|
<Card>
|
|
{/* Header with icon, title, and back button */}
|
|
<div
|
|
className={`p-6 border-b ${getThemeClasses("border-secondary")}`}
|
|
>
|
|
<div className="flex flex-col space-y-4 lg:flex-row lg:items-center lg:justify-between lg:space-y-0">
|
|
<div className="flex-1 min-w-0">
|
|
<div className="flex items-start">
|
|
{/* Icon */}
|
|
<div
|
|
className={`p-3 rounded-2xl shadow-lg mr-4 flex-shrink-0 ${getThemeClasses("bg-gradient-secondary")}`}
|
|
>
|
|
<CloudArrowUpIcon className="h-8 w-8 sm:h-10 sm:w-10 text-white" />
|
|
</div>
|
|
{/* Title and subtitle */}
|
|
<div>
|
|
<h1
|
|
className={`text-2xl sm:text-3xl lg:text-4xl font-bold ${getThemeClasses("text-primary")} leading-tight`}
|
|
>
|
|
Upload Files
|
|
</h1>
|
|
<p
|
|
className={`mt-2 text-base sm:text-lg ${getThemeClasses("text-secondary")}`}
|
|
>
|
|
Drag and drop or click to select files
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{/* Back button */}
|
|
<div className="flex-shrink-0">
|
|
<Button
|
|
onClick={() => navigate(getBackUrl())}
|
|
variant="secondary"
|
|
size="sm"
|
|
>
|
|
<span className="inline-flex items-center gap-2">
|
|
<ArrowLeftIcon className="h-4 w-4" />
|
|
<span>
|
|
Back to {preSelectedCollectionInfo?.name || "My Files"}
|
|
</span>
|
|
</span>
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Messages */}
|
|
<div className="px-6 pt-6">
|
|
{error && (
|
|
<Alert type="error" className="mb-4">
|
|
{error}
|
|
</Alert>
|
|
)}
|
|
{success && (
|
|
<Alert type="success" className="mb-4">
|
|
{success}
|
|
</Alert>
|
|
)}
|
|
</div>
|
|
|
|
{/* Content */}
|
|
<div className="px-6 pb-6 pt-5">
|
|
{/* No Folders - Show create folder prompt instead of upload UI */}
|
|
{!preSelectedCollectionId && isCollectionsInitialized && !isLoadingCollections && availableCollections.length === 0 ? (
|
|
<div className="text-center py-12">
|
|
<div className={`h-20 w-20 ${getThemeClasses("bg-muted")} rounded-2xl flex items-center justify-center mx-auto mb-4`}>
|
|
<FolderIcon className="h-10 w-10 text-gray-400" />
|
|
</div>
|
|
<h3 className={`text-xl font-semibold mb-2 ${getThemeClasses("text-primary")}`}>
|
|
No folders yet
|
|
</h3>
|
|
<p className={`mb-6 max-w-md mx-auto ${getThemeClasses("text-secondary")}`}>
|
|
You need to create a folder before you can upload files. Folders help you organize your encrypted files.
|
|
</p>
|
|
<Button
|
|
onClick={() => navigate("/file-manager/collections/create")}
|
|
variant="primary"
|
|
icon={FolderIcon}
|
|
>
|
|
Create Your First Folder
|
|
</Button>
|
|
</div>
|
|
) : (
|
|
<>
|
|
<div className={`grid grid-cols-1 gap-8 ${preSelectedCollectionId ? "lg:grid-cols-3" : ""}`}>
|
|
{/* Upload Area */}
|
|
<div className={`space-y-6 ${preSelectedCollectionId ? "lg:col-span-2" : ""}`}>
|
|
{/* Collection Selector with Upload Button (only show if no pre-selected collection) */}
|
|
{!preSelectedCollectionId && (
|
|
<div className="flex items-end gap-3">
|
|
<Select
|
|
label="Select destination folder"
|
|
value={selectedCollection}
|
|
onChange={setSelectedCollection}
|
|
options={collectionOptions}
|
|
disabled={isLoadingCollections || isUploading}
|
|
placeholder="Choose a folder..."
|
|
size="md"
|
|
className="flex-1"
|
|
/>
|
|
<Button
|
|
onClick={startUpload}
|
|
disabled={
|
|
!selectedCollection ||
|
|
!fileManager ||
|
|
files.length === 0 ||
|
|
isUploading ||
|
|
pendingFiles.length === 0 ||
|
|
!gdprConsent
|
|
}
|
|
variant="primary"
|
|
className="flex-shrink-0 h-[50px]"
|
|
>
|
|
<span className="inline-flex items-center justify-center gap-2">
|
|
{isUploading ? (
|
|
<>
|
|
<div className="animate-spin rounded-full h-5 w-5 border-2 border-white border-t-transparent"></div>
|
|
<span>
|
|
Uploading {uploadingFiles.length} of {files.length}...
|
|
</span>
|
|
</>
|
|
) : (
|
|
<>
|
|
<ArrowUpTrayIcon className="h-5 w-5" />
|
|
<span>
|
|
Upload {pendingFiles.length} File
|
|
{pendingFiles.length !== 1 ? "s" : ""}
|
|
</span>
|
|
</>
|
|
)}
|
|
</span>
|
|
</Button>
|
|
</div>
|
|
)}
|
|
|
|
{/* Drop Zone */}
|
|
<div
|
|
role="button"
|
|
tabIndex={0}
|
|
aria-label={isDragging ? "Drop files here to upload" : "Click or drag files here to upload"}
|
|
aria-disabled={isUploading}
|
|
onDragOver={handleDragOver}
|
|
onDragLeave={handleDragLeave}
|
|
onDrop={handleDrop}
|
|
onClick={() => !isUploading && fileInputRef.current?.click()}
|
|
onKeyDown={(e) => {
|
|
if ((e.key === "Enter" || e.key === " ") && !isUploading) {
|
|
e.preventDefault();
|
|
fileInputRef.current?.click();
|
|
}
|
|
}}
|
|
className={`${getThemeClasses("bg-muted")} rounded-lg border-2 border-dashed p-12 text-center cursor-pointer transition-all duration-300 ${
|
|
isDragging
|
|
? `${getThemeClasses("border-primary")} ${getThemeClasses("bg-accent-light")} scale-105 shadow-lg`
|
|
: `${getThemeClasses("border-secondary")} hover:${getThemeClasses("border-primary")} hover:shadow-md`
|
|
} ${isUploading ? "opacity-50 cursor-not-allowed" : ""}`}
|
|
>
|
|
<div
|
|
className={`h-16 w-16 rounded-2xl mx-auto mb-4 flex items-center justify-center transition-all duration-300 shadow-lg ${
|
|
isDragging
|
|
? getThemeClasses("bg-gradient-secondary") +
|
|
" scale-110"
|
|
: getThemeClasses("bg-gradient-secondary")
|
|
}`}
|
|
>
|
|
<CloudArrowUpIcon className="h-8 w-8 text-white" />
|
|
</div>
|
|
<h3
|
|
className={`text-lg font-semibold mb-2 ${getThemeClasses("text-primary")}`}
|
|
>
|
|
{isDragging ? "Drop files here" : "Upload your files"}
|
|
</h3>
|
|
<p
|
|
className={`text-sm ${getThemeClasses("text-secondary")} mb-4`}
|
|
>
|
|
Drag and drop or click to browse
|
|
</p>
|
|
<p className={`text-xs ${getThemeClasses("text-muted")}`}>
|
|
Maximum file size: 5GB • All file types supported
|
|
</p>
|
|
{preSelectedCollectionId &&
|
|
preSelectedCollectionInfo?.name && (
|
|
<p
|
|
className={`text-sm font-bold mt-3 ${getThemeClasses("text-primary")}`}
|
|
>
|
|
Uploading to: {preSelectedCollectionInfo.name}
|
|
</p>
|
|
)}
|
|
<input
|
|
ref={fileInputRef}
|
|
type="file"
|
|
multiple
|
|
onChange={handleFileSelect}
|
|
disabled={isUploading}
|
|
className="sr-only"
|
|
aria-label="Select files to upload"
|
|
id="file-upload-input"
|
|
/>
|
|
</div>
|
|
|
|
{/* Files List */}
|
|
{files.length > 0 && (
|
|
<div
|
|
className={`${getThemeClasses("bg-card")} rounded-lg border ${getThemeClasses("border-secondary")} shadow-sm`}
|
|
>
|
|
<div
|
|
className={`p-4 border-b ${getThemeClasses("border-secondary")}`}
|
|
>
|
|
<div className="flex items-center justify-between">
|
|
<h3
|
|
className={`font-semibold ${getThemeClasses("text-primary")}`}
|
|
>
|
|
{files.length} file{files.length !== 1 ? "s" : ""}{" "}
|
|
selected
|
|
</h3>
|
|
<span
|
|
className={`text-sm ${getThemeClasses("text-secondary")}`}
|
|
>
|
|
Total: {formatFileSize(totalSize)}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div
|
|
role="list"
|
|
aria-label="Selected files for upload"
|
|
className={`divide-y ${getThemeClasses("border-secondary")} max-h-96 overflow-y-auto`}
|
|
>
|
|
{files.map((file) => (
|
|
<div
|
|
key={file.id}
|
|
role="listitem"
|
|
aria-label={`${file.name}, ${formatFileSize(file.size)}, status: ${file.status}`}
|
|
className={`p-4 ${getThemeClasses("hover:bg-muted")} transition-colors duration-200`}
|
|
>
|
|
<div className="flex items-center space-x-4">
|
|
<div className="flex-shrink-0">
|
|
<div
|
|
className={`h-10 w-10 rounded-lg ${getThemeClasses("bg-muted")} flex items-center justify-center`}
|
|
>
|
|
{getFileIcon(file)}
|
|
</div>
|
|
</div>
|
|
<div className="flex-1 min-w-0">
|
|
<div className="flex items-center justify-between mb-1">
|
|
<h4
|
|
className={`text-sm font-medium truncate pr-2 ${getThemeClasses("text-primary")}`}
|
|
>
|
|
{file.name}
|
|
</h4>
|
|
<span
|
|
className={`text-xs ${getThemeClasses("text-muted")} flex-shrink-0`}
|
|
>
|
|
{formatFileSize(file.size)}
|
|
</span>
|
|
</div>
|
|
{/* Progress bar for uploading files */}
|
|
{file.status === "uploading" &&
|
|
uploadProgress[file.id] !== undefined && (
|
|
<div className="mt-2">
|
|
<div
|
|
className={`w-full ${getThemeClasses("bg-muted")} rounded-full h-2 overflow-hidden`}
|
|
>
|
|
<div
|
|
className={`h-full ${getThemeClasses("bg-gradient-secondary")} transition-all duration-300`}
|
|
style={{
|
|
width: `${uploadProgress[file.id]}%`,
|
|
}}
|
|
></div>
|
|
</div>
|
|
<p
|
|
className={`text-xs ${getThemeClasses("text-info")} mt-1`}
|
|
>
|
|
{uploadProgress[file.id]}% uploaded
|
|
</p>
|
|
</div>
|
|
)}
|
|
{file.status === "error" && file.error && (
|
|
<p
|
|
className={`text-xs ${getThemeClasses("text-error")} mt-1 truncate`}
|
|
>
|
|
{file.error}
|
|
</p>
|
|
)}
|
|
</div>
|
|
<div className="flex-shrink-0">
|
|
{file.status === "pending" && (
|
|
<Button
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
removeFile(file.id);
|
|
}}
|
|
disabled={isUploading}
|
|
variant="ghost"
|
|
size="sm"
|
|
aria-label={`Remove ${file.name}`}
|
|
className={`${getThemeClasses("hover:text-error")}`}
|
|
>
|
|
<XMarkIcon className="h-5 w-5" />
|
|
</Button>
|
|
)}
|
|
{file.status === "uploading" && (
|
|
<div className="p-2">
|
|
<div
|
|
className={`animate-spin rounded-full h-5 w-5 border-2 ${getThemeClasses("border-primary")} border-t-transparent`}
|
|
></div>
|
|
</div>
|
|
)}
|
|
{file.status === "complete" && (
|
|
<div className="p-2">
|
|
<CheckCircleIcon
|
|
className={`h-5 w-5 ${getThemeClasses("text-success")}`}
|
|
/>
|
|
</div>
|
|
)}
|
|
{file.status === "error" && (
|
|
<div className="p-2">
|
|
<ExclamationTriangleIcon
|
|
className={`h-5 w-5 ${getThemeClasses("text-error")}`}
|
|
/>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Sidebar - only show as separate column when collection is pre-selected */}
|
|
{preSelectedCollectionId && (
|
|
<div className="space-y-6">
|
|
{files.length > 0 && (
|
|
<div
|
|
className={`${getThemeClasses("bg-card")} rounded-lg border ${getThemeClasses("border-secondary")} shadow-sm p-6`}
|
|
>
|
|
<h3
|
|
className={`font-semibold mb-4 ${getThemeClasses("text-primary")}`}
|
|
>
|
|
Upload Status
|
|
</h3>
|
|
<div className="space-y-3">
|
|
<div className="flex items-center justify-between">
|
|
<span
|
|
className={`text-sm ${getThemeClasses("text-secondary")}`}
|
|
>
|
|
Pending
|
|
</span>
|
|
<span
|
|
className={`text-sm font-medium ${getThemeClasses("text-primary")}`}
|
|
>
|
|
{pendingFiles.length}
|
|
</span>
|
|
</div>
|
|
<div className="flex items-center justify-between">
|
|
<span
|
|
className={`text-sm ${getThemeClasses("text-secondary")}`}
|
|
>
|
|
Uploading
|
|
</span>
|
|
<span
|
|
className={`text-sm font-medium ${getThemeClasses("text-info")}`}
|
|
>
|
|
{uploadingFiles.length}
|
|
</span>
|
|
</div>
|
|
<div className="flex items-center justify-between">
|
|
<span
|
|
className={`text-sm ${getThemeClasses("text-secondary")}`}
|
|
>
|
|
Completed
|
|
</span>
|
|
<span
|
|
className={`text-sm font-medium ${getThemeClasses("text-success")}`}
|
|
>
|
|
{completedFiles.length}
|
|
</span>
|
|
</div>
|
|
{errorFiles.length > 0 && (
|
|
<div className="flex items-center justify-between">
|
|
<span
|
|
className={`text-sm ${getThemeClasses("text-secondary")}`}
|
|
>
|
|
Errors
|
|
</span>
|
|
<span
|
|
className={`text-sm font-medium ${getThemeClasses("text-error")}`}
|
|
>
|
|
{errorFiles.length}
|
|
</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
{completedFiles.length + errorFiles.length ===
|
|
files.length &&
|
|
files.length > 0 &&
|
|
!isUploading &&
|
|
pendingFiles.length === 0 && (
|
|
<div
|
|
className={`mt-4 p-3 ${getThemeClasses("alert-success-bg")} border ${getThemeClasses("alert-success-border")} rounded-lg`}
|
|
>
|
|
<p
|
|
className={`text-sm ${getThemeClasses("text-success")} flex items-center`}
|
|
>
|
|
<CheckCircleIcon className="h-4 w-4 mr-2" />
|
|
All uploads processed!
|
|
</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* Tag Selection */}
|
|
{files.length > 0 && pendingFiles.length > 0 && (
|
|
<div
|
|
className={`${getThemeClasses("bg-card")} rounded-lg border ${getThemeClasses("border-secondary")} shadow-sm p-6`}
|
|
>
|
|
<div className="flex items-center gap-2 mb-4">
|
|
<TagIcon
|
|
className={`h-5 w-5 ${getThemeClasses("text-secondary")}`}
|
|
/>
|
|
<h3
|
|
className={`font-semibold ${getThemeClasses("text-primary")}`}
|
|
>
|
|
Add Tags
|
|
</h3>
|
|
</div>
|
|
<p
|
|
className={`text-xs ${getThemeClasses("text-secondary")} mb-3`}
|
|
>
|
|
Tags will be applied to all uploaded files
|
|
</p>
|
|
<TagSelector
|
|
value={selectedTagIds}
|
|
onChange={setSelectedTagIds}
|
|
disabled={isUploading}
|
|
label=""
|
|
placeholder="Select tags..."
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
{/* Consent & Security Notice */}
|
|
{files.length > 0 && pendingFiles.length > 0 && (
|
|
<div
|
|
className={`p-4 ${getThemeClasses("bg-accent-light")} rounded-lg border ${getThemeClasses("border-accent")}`}
|
|
>
|
|
<div className="flex items-start space-x-3">
|
|
<Checkbox
|
|
checked={gdprConsent}
|
|
onChange={setGdprConsent}
|
|
className="mt-0.5"
|
|
/>
|
|
<div className="flex-1">
|
|
<p
|
|
className={`text-sm font-medium ${getThemeClasses("text-primary")}`}
|
|
>
|
|
I consent to encrypted upload
|
|
</p>
|
|
<p
|
|
className={`text-xs ${getThemeClasses("text-secondary")} mt-1`}
|
|
>
|
|
Files are encrypted end-to-end and only you and those
|
|
you share with can decrypt them.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Upload button in sidebar */}
|
|
<Button
|
|
onClick={startUpload}
|
|
disabled={
|
|
!selectedCollection ||
|
|
!fileManager ||
|
|
files.length === 0 ||
|
|
isUploading ||
|
|
pendingFiles.length === 0 ||
|
|
!gdprConsent
|
|
}
|
|
variant="primary"
|
|
className="w-full"
|
|
>
|
|
<span className="inline-flex items-center justify-center gap-2">
|
|
{isUploading ? (
|
|
<>
|
|
<div className="animate-spin rounded-full h-5 w-5 border-2 border-white border-t-transparent"></div>
|
|
<span>
|
|
Uploading {uploadingFiles.length} of {files.length}...
|
|
</span>
|
|
</>
|
|
) : (
|
|
<>
|
|
<ArrowUpTrayIcon className="h-5 w-5" />
|
|
<span>
|
|
Upload {pendingFiles.length} File
|
|
{pendingFiles.length !== 1 ? "s" : ""}
|
|
</span>
|
|
</>
|
|
)}
|
|
</span>
|
|
</Button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Options section - show below content when no pre-selected collection */}
|
|
{!preSelectedCollectionId && files.length > 0 && (
|
|
<div className="mt-6 grid grid-cols-1 md:grid-cols-3 gap-6">
|
|
{/* Upload Status */}
|
|
<div
|
|
className={`${getThemeClasses("bg-card")} rounded-lg border ${getThemeClasses("border-secondary")} shadow-sm p-6`}
|
|
>
|
|
<h3
|
|
className={`font-semibold mb-4 ${getThemeClasses("text-primary")}`}
|
|
>
|
|
Upload Status
|
|
</h3>
|
|
<div className="space-y-3">
|
|
<div className="flex items-center justify-between">
|
|
<span
|
|
className={`text-sm ${getThemeClasses("text-secondary")}`}
|
|
>
|
|
Pending
|
|
</span>
|
|
<span
|
|
className={`text-sm font-medium ${getThemeClasses("text-primary")}`}
|
|
>
|
|
{pendingFiles.length}
|
|
</span>
|
|
</div>
|
|
<div className="flex items-center justify-between">
|
|
<span
|
|
className={`text-sm ${getThemeClasses("text-secondary")}`}
|
|
>
|
|
Uploading
|
|
</span>
|
|
<span
|
|
className={`text-sm font-medium ${getThemeClasses("text-info")}`}
|
|
>
|
|
{uploadingFiles.length}
|
|
</span>
|
|
</div>
|
|
<div className="flex items-center justify-between">
|
|
<span
|
|
className={`text-sm ${getThemeClasses("text-secondary")}`}
|
|
>
|
|
Completed
|
|
</span>
|
|
<span
|
|
className={`text-sm font-medium ${getThemeClasses("text-success")}`}
|
|
>
|
|
{completedFiles.length}
|
|
</span>
|
|
</div>
|
|
{errorFiles.length > 0 && (
|
|
<div className="flex items-center justify-between">
|
|
<span
|
|
className={`text-sm ${getThemeClasses("text-secondary")}`}
|
|
>
|
|
Errors
|
|
</span>
|
|
<span
|
|
className={`text-sm font-medium ${getThemeClasses("text-error")}`}
|
|
>
|
|
{errorFiles.length}
|
|
</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
{completedFiles.length + errorFiles.length ===
|
|
files.length &&
|
|
files.length > 0 &&
|
|
!isUploading &&
|
|
pendingFiles.length === 0 && (
|
|
<div
|
|
className={`mt-4 p-3 ${getThemeClasses("alert-success-bg")} border ${getThemeClasses("alert-success-border")} rounded-lg`}
|
|
>
|
|
<p
|
|
className={`text-sm ${getThemeClasses("text-success")} flex items-center`}
|
|
>
|
|
<CheckCircleIcon className="h-4 w-4 mr-2" />
|
|
All uploads processed!
|
|
</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Tag Selection */}
|
|
{pendingFiles.length > 0 && (
|
|
<div
|
|
className={`${getThemeClasses("bg-card")} rounded-lg border ${getThemeClasses("border-secondary")} shadow-sm p-6`}
|
|
>
|
|
<div className="flex items-center gap-2 mb-4">
|
|
<TagIcon
|
|
className={`h-5 w-5 ${getThemeClasses("text-secondary")}`}
|
|
/>
|
|
<h3
|
|
className={`font-semibold ${getThemeClasses("text-primary")}`}
|
|
>
|
|
Add Tags
|
|
</h3>
|
|
</div>
|
|
<p
|
|
className={`text-xs ${getThemeClasses("text-secondary")} mb-3`}
|
|
>
|
|
Tags will be applied to all uploaded files
|
|
</p>
|
|
<TagSelector
|
|
value={selectedTagIds}
|
|
onChange={setSelectedTagIds}
|
|
disabled={isUploading}
|
|
label=""
|
|
placeholder="Select tags..."
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
{/* Consent & Security Notice */}
|
|
{pendingFiles.length > 0 && (
|
|
<div
|
|
className={`p-4 ${getThemeClasses("bg-accent-light")} rounded-lg border ${getThemeClasses("border-accent")} h-fit`}
|
|
>
|
|
<div className="flex items-start space-x-3">
|
|
<Checkbox
|
|
checked={gdprConsent}
|
|
onChange={setGdprConsent}
|
|
className="mt-0.5"
|
|
/>
|
|
<div className="flex-1">
|
|
<p
|
|
className={`text-sm font-medium ${getThemeClasses("text-primary")}`}
|
|
>
|
|
I consent to encrypted upload
|
|
</p>
|
|
<p
|
|
className={`text-xs ${getThemeClasses("text-secondary")} mt-1`}
|
|
>
|
|
Files are encrypted end-to-end and only you and those
|
|
you share with can decrypt them.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</>
|
|
)}
|
|
</div>
|
|
</Card>
|
|
</div>
|
|
</Layout>
|
|
);
|
|
};
|
|
|
|
export default withPasswordProtection(FileUpload);
|