monorepo/web/maplefile-frontend/src/pages/User/FileManager/Files/FileUpload.jsx
2025-12-05 13:56:56 -05:00

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);