mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-06-09 07:05:51 +08:00
Compare commits
1 Commits
v1.33.2
...
feature/fi
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c235b05e1d |
@@ -72,7 +72,7 @@ http {
|
|||||||
set $cloudcli_upstream http://127.0.0.1:3001;
|
set $cloudcli_upstream http://127.0.0.1:3001;
|
||||||
|
|
||||||
# Allow larger file uploads through the code editor/project file APIs.
|
# Allow larger file uploads through the code editor/project file APIs.
|
||||||
client_max_body_size 100m;
|
client_max_body_size 200m;
|
||||||
|
|
||||||
# Redirect /ai to /ai/ so relative browser URL resolution is stable.
|
# Redirect /ai to /ai/ so relative browser URL resolution is stable.
|
||||||
# [SUBPATH LITERAL] Change `/ai` if you change $cloudcli_subpath.
|
# [SUBPATH LITERAL] Change `/ai` if you change $cloudcli_subpath.
|
||||||
|
|||||||
@@ -84,6 +84,9 @@ const __dirname = getModuleDir(import.meta.url);
|
|||||||
// Resolving the app root once keeps every repo-level lookup below aligned across both layouts.
|
// Resolving the app root once keeps every repo-level lookup below aligned across both layouts.
|
||||||
const APP_ROOT = findAppRoot(__dirname);
|
const APP_ROOT = findAppRoot(__dirname);
|
||||||
const installMode = fs.existsSync(path.join(APP_ROOT, '.git')) ? 'git' : 'npm';
|
const installMode = fs.existsSync(path.join(APP_ROOT, '.git')) ? 'git' : 'npm';
|
||||||
|
const MAX_FILE_UPLOAD_SIZE_MB = 200;
|
||||||
|
const MAX_FILE_UPLOAD_SIZE_BYTES = MAX_FILE_UPLOAD_SIZE_MB * 1024 * 1024;
|
||||||
|
const MAX_FILE_UPLOAD_COUNT = 20;
|
||||||
|
|
||||||
console.log('SERVER_PORT from env:', process.env.SERVER_PORT);
|
console.log('SERVER_PORT from env:', process.env.SERVER_PORT);
|
||||||
|
|
||||||
@@ -897,27 +900,27 @@ const uploadFilesHandler = async (req, res) => {
|
|||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
limits: {
|
limits: {
|
||||||
fileSize: 50 * 1024 * 1024, // 50MB limit
|
fileSize: MAX_FILE_UPLOAD_SIZE_BYTES,
|
||||||
files: 20 // Max 20 files at once
|
files: MAX_FILE_UPLOAD_COUNT
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Use multer middleware
|
// Use multer middleware
|
||||||
uploadMiddleware.array('files', 20)(req, res, async (err) => {
|
uploadMiddleware.array('files', MAX_FILE_UPLOAD_COUNT)(req, res, async (err) => {
|
||||||
if (err) {
|
if (err) {
|
||||||
console.error('Multer error:', err);
|
console.error('Multer error:', err);
|
||||||
if (err.code === 'LIMIT_FILE_SIZE') {
|
if (err.code === 'LIMIT_FILE_SIZE') {
|
||||||
return res.status(400).json({ error: 'File too large. Maximum size is 50MB.' });
|
return res.status(400).json({ error: `File too large. Maximum size is ${MAX_FILE_UPLOAD_SIZE_MB}MB.` });
|
||||||
}
|
}
|
||||||
if (err.code === 'LIMIT_FILE_COUNT') {
|
if (err.code === 'LIMIT_FILE_COUNT') {
|
||||||
return res.status(400).json({ error: 'Too many files. Maximum is 20 files.' });
|
return res.status(400).json({ error: `Too many files. Maximum is ${MAX_FILE_UPLOAD_COUNT} files.` });
|
||||||
}
|
}
|
||||||
return res.status(500).json({ error: err.message });
|
return res.status(500).json({ error: err.message });
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { projectId } = req.params;
|
const { projectId } = req.params;
|
||||||
const { targetPath, relativePaths } = req.body;
|
const { targetPath, relativePaths, requestedFileCount: requestedFileCountRaw } = req.body;
|
||||||
|
|
||||||
// Parse relative paths if provided (for folder uploads)
|
// Parse relative paths if provided (for folder uploads)
|
||||||
let filePaths = [];
|
let filePaths = [];
|
||||||
@@ -941,6 +944,11 @@ const uploadFilesHandler = async (req, res) => {
|
|||||||
return res.status(400).json({ error: 'No files provided' });
|
return res.status(400).json({ error: 'No files provided' });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const parsedRequestedFileCount = Number.parseInt(requestedFileCountRaw, 10);
|
||||||
|
const requestedFileCount = Number.isFinite(parsedRequestedFileCount) && parsedRequestedFileCount > 0
|
||||||
|
? parsedRequestedFileCount
|
||||||
|
: req.files.length;
|
||||||
|
|
||||||
// Resolve the project directory through the DB using the new projectId.
|
// Resolve the project directory through the DB using the new projectId.
|
||||||
const projectRoot = await projectsDb.getProjectPathById(projectId);
|
const projectRoot = await projectsDb.getProjectPathById(projectId);
|
||||||
if (!projectRoot) {
|
if (!projectRoot) {
|
||||||
@@ -1019,8 +1027,10 @@ const uploadFilesHandler = async (req, res) => {
|
|||||||
res.json({
|
res.json({
|
||||||
success: true,
|
success: true,
|
||||||
files: uploadedFiles,
|
files: uploadedFiles,
|
||||||
|
uploadedCount: uploadedFiles.length,
|
||||||
|
requestedFileCount,
|
||||||
targetPath: resolvedTargetDir,
|
targetPath: resolvedTargetDir,
|
||||||
message: `Uploaded ${uploadedFiles.length} file(s) successfully`
|
message: `Uploaded ${uploadedFiles.length} ${uploadedFiles.length === 1 ? 'file' : 'files'} successfully`
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error uploading files:', error);
|
console.error('Error uploading files:', error);
|
||||||
|
|||||||
@@ -6,6 +6,14 @@ export const FILE_TREE_DEFAULT_VIEW_MODE: FileTreeViewMode = 'detailed';
|
|||||||
|
|
||||||
export const FILE_TREE_VIEW_MODES: FileTreeViewMode[] = ['simple', 'compact', 'detailed'];
|
export const FILE_TREE_VIEW_MODES: FileTreeViewMode[] = ['simple', 'compact', 'detailed'];
|
||||||
|
|
||||||
|
export const MAX_FILE_UPLOAD_SIZE_MB = 200;
|
||||||
|
|
||||||
|
export const MAX_FILE_UPLOAD_SIZE_BYTES = MAX_FILE_UPLOAD_SIZE_MB * 1024 * 1024;
|
||||||
|
|
||||||
|
export const MAX_FILE_UPLOAD_SIZE_LABEL = `${MAX_FILE_UPLOAD_SIZE_MB}MB`;
|
||||||
|
|
||||||
|
export const MAX_FILE_UPLOAD_COUNT = 20;
|
||||||
|
|
||||||
export const IMAGE_FILE_EXTENSIONS = new Set([
|
export const IMAGE_FILE_EXTENSIONS = new Set([
|
||||||
'png',
|
'png',
|
||||||
'jpg',
|
'jpg',
|
||||||
|
|||||||
@@ -1,6 +1,13 @@
|
|||||||
import { useCallback, useState, useRef } from 'react';
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
|
import type { DragEvent } from 'react';
|
||||||
|
|
||||||
|
import { IS_PLATFORM } from '../../../constants/config';
|
||||||
import type { Project } from '../../../types/app';
|
import type { Project } from '../../../types/app';
|
||||||
import { api } from '../../../utils/api';
|
import {
|
||||||
|
MAX_FILE_UPLOAD_COUNT,
|
||||||
|
MAX_FILE_UPLOAD_SIZE_BYTES,
|
||||||
|
MAX_FILE_UPLOAD_SIZE_LABEL,
|
||||||
|
} from '../constants/constants';
|
||||||
|
|
||||||
type UseFileTreeUploadOptions = {
|
type UseFileTreeUploadOptions = {
|
||||||
selectedProject: Project | null;
|
selectedProject: Project | null;
|
||||||
@@ -8,6 +15,141 @@ type UseFileTreeUploadOptions = {
|
|||||||
showToast: (message: string, type: 'success' | 'error') => void;
|
showToast: (message: string, type: 'success' | 'error') => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type FileTreeUploadProgressState = {
|
||||||
|
status: 'uploading' | 'complete' | 'error';
|
||||||
|
progress: number;
|
||||||
|
fileCount: number;
|
||||||
|
uploadedCount?: number;
|
||||||
|
fileName?: string;
|
||||||
|
targetPath?: string;
|
||||||
|
error?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type UploadResponse = {
|
||||||
|
error?: string;
|
||||||
|
message?: string;
|
||||||
|
files?: unknown[];
|
||||||
|
uploadedCount?: number;
|
||||||
|
requestedFileCount?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const COMPLETE_PROGRESS_CLEAR_DELAY_MS = 1400;
|
||||||
|
const ERROR_PROGRESS_CLEAR_DELAY_MS = 3200;
|
||||||
|
|
||||||
|
const pluralizeFiles = (count: number) => (count === 1 ? 'file' : 'files');
|
||||||
|
|
||||||
|
const getRelativePath = (file: File) => {
|
||||||
|
const fileWithRelativePath = file as File & { webkitRelativePath?: string };
|
||||||
|
return fileWithRelativePath.webkitRelativePath || file.name;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getFileDisplayName = (file: File) => {
|
||||||
|
const relativePath = getRelativePath(file);
|
||||||
|
return relativePath.split(/[\\/]/).pop() || file.name;
|
||||||
|
};
|
||||||
|
|
||||||
|
const validateFilesForUpload = (files: File[]): string | null => {
|
||||||
|
if (files.length > MAX_FILE_UPLOAD_COUNT) {
|
||||||
|
return `You can upload up to ${MAX_FILE_UPLOAD_COUNT} files at once.`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const oversizedFile = files.find((file) => file.size > MAX_FILE_UPLOAD_SIZE_BYTES);
|
||||||
|
if (oversizedFile) {
|
||||||
|
return `${getFileDisplayName(oversizedFile)} is larger than ${MAX_FILE_UPLOAD_SIZE_LABEL}.`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const parseUploadResponse = (xhr: XMLHttpRequest): UploadResponse => {
|
||||||
|
if (!xhr.responseText) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return JSON.parse(xhr.responseText) as UploadResponse;
|
||||||
|
} catch {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatUploadSuccessMessage = (uploadedCount: number, requestedFileCount: number) => {
|
||||||
|
if (uploadedCount !== requestedFileCount) {
|
||||||
|
return `Uploaded ${uploadedCount} of ${requestedFileCount} ${pluralizeFiles(requestedFileCount)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `Uploaded ${uploadedCount} ${pluralizeFiles(uploadedCount)} successfully`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildUploadFormData = (files: File[], targetPath: string) => {
|
||||||
|
const formData = new FormData();
|
||||||
|
const relativePaths: string[] = [];
|
||||||
|
|
||||||
|
formData.append('targetPath', targetPath);
|
||||||
|
formData.append('requestedFileCount', String(files.length));
|
||||||
|
|
||||||
|
files.forEach((file) => {
|
||||||
|
const relativePath = getRelativePath(file);
|
||||||
|
const cleanFile = new File([file], relativePath.split(/[\\/]/).pop() || file.name, {
|
||||||
|
type: file.type,
|
||||||
|
lastModified: file.lastModified,
|
||||||
|
});
|
||||||
|
|
||||||
|
formData.append('files', cleanFile);
|
||||||
|
relativePaths.push(relativePath);
|
||||||
|
});
|
||||||
|
|
||||||
|
formData.append('relativePaths', JSON.stringify(relativePaths));
|
||||||
|
|
||||||
|
return formData;
|
||||||
|
};
|
||||||
|
|
||||||
|
const uploadFormDataWithProgress = (
|
||||||
|
projectId: string,
|
||||||
|
formData: FormData,
|
||||||
|
onProgress: (progress: number) => void,
|
||||||
|
) =>
|
||||||
|
new Promise<UploadResponse>((resolve, reject) => {
|
||||||
|
const xhr = new XMLHttpRequest();
|
||||||
|
|
||||||
|
xhr.open('POST', `/api/projects/${encodeURIComponent(projectId)}/files/upload`);
|
||||||
|
|
||||||
|
const token = localStorage.getItem('auth-token');
|
||||||
|
if (!IS_PLATFORM && token) {
|
||||||
|
xhr.setRequestHeader('Authorization', `Bearer ${token}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
xhr.upload.onprogress = (event) => {
|
||||||
|
if (!event.lengthComputable) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keep 100% for the server response so the UI can distinguish transfer
|
||||||
|
// completion from the final write/refresh step.
|
||||||
|
onProgress(Math.min(99, Math.round((event.loaded / event.total) * 100)));
|
||||||
|
};
|
||||||
|
|
||||||
|
xhr.onload = () => {
|
||||||
|
const refreshedToken = xhr.getResponseHeader('X-Refreshed-Token');
|
||||||
|
if (refreshedToken) {
|
||||||
|
localStorage.setItem('auth-token', refreshedToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = parseUploadResponse(xhr);
|
||||||
|
if (xhr.status >= 200 && xhr.status < 300) {
|
||||||
|
resolve(payload);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
reject(new Error(payload.error || payload.message || `Upload failed with status ${xhr.status}`));
|
||||||
|
};
|
||||||
|
|
||||||
|
xhr.onerror = () => reject(new Error('Upload failed. Check your connection and try again.'));
|
||||||
|
xhr.onabort = () => reject(new Error('Upload canceled.'));
|
||||||
|
|
||||||
|
xhr.send(formData);
|
||||||
|
});
|
||||||
|
|
||||||
// Helper function to read all files from a directory entry recursively
|
// Helper function to read all files from a directory entry recursively
|
||||||
const readAllDirectoryEntries = async (directoryEntry: FileSystemDirectoryEntry, basePath = ''): Promise<File[]> => {
|
const readAllDirectoryEntries = async (directoryEntry: FileSystemDirectoryEntry, basePath = ''): Promise<File[]> => {
|
||||||
const files: File[] = [];
|
const files: File[] = [];
|
||||||
@@ -57,6 +199,48 @@ const readAllDirectoryEntries = async (directoryEntry: FileSystemDirectoryEntry,
|
|||||||
return files;
|
return files;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const collectDroppedFiles = async (dataTransfer: DataTransfer) => {
|
||||||
|
const files: File[] = [];
|
||||||
|
|
||||||
|
// Use DataTransferItemList for folder support
|
||||||
|
const { items } = dataTransfer;
|
||||||
|
if (items) {
|
||||||
|
for (const item of Array.from(items)) {
|
||||||
|
if (item.kind !== 'file') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const entry = item.webkitGetAsEntry ? item.webkitGetAsEntry() : null;
|
||||||
|
if (!entry) {
|
||||||
|
const file = item.getAsFile();
|
||||||
|
if (file) {
|
||||||
|
files.push(file);
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (entry.isFile) {
|
||||||
|
const file = await new Promise<File>((resolve, reject) => {
|
||||||
|
(entry as FileSystemFileEntry).file(resolve, reject);
|
||||||
|
});
|
||||||
|
files.push(file);
|
||||||
|
} else if (entry.isDirectory) {
|
||||||
|
// Pass the directory name as basePath so files include the folder path
|
||||||
|
const dirFiles = await readAllDirectoryEntries(entry as FileSystemDirectoryEntry, entry.name);
|
||||||
|
files.push(...dirFiles);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return files;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback for browsers that don't support webkitGetAsEntry
|
||||||
|
for (const file of Array.from(dataTransfer.files)) {
|
||||||
|
files.push(file);
|
||||||
|
}
|
||||||
|
|
||||||
|
return files;
|
||||||
|
};
|
||||||
|
|
||||||
export const useFileTreeUpload = ({
|
export const useFileTreeUpload = ({
|
||||||
selectedProject,
|
selectedProject,
|
||||||
onRefresh,
|
onRefresh,
|
||||||
@@ -65,20 +249,150 @@ export const useFileTreeUpload = ({
|
|||||||
const [isDragOver, setIsDragOver] = useState(false);
|
const [isDragOver, setIsDragOver] = useState(false);
|
||||||
const [dropTarget, setDropTarget] = useState<string | null>(null);
|
const [dropTarget, setDropTarget] = useState<string | null>(null);
|
||||||
const [operationLoading, setOperationLoading] = useState(false);
|
const [operationLoading, setOperationLoading] = useState(false);
|
||||||
|
const [uploadProgress, setUploadProgress] = useState<FileTreeUploadProgressState | null>(null);
|
||||||
const treeRef = useRef<HTMLDivElement>(null);
|
const treeRef = useRef<HTMLDivElement>(null);
|
||||||
|
const clearProgressTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
const handleDragEnter = useCallback((e: React.DragEvent) => {
|
const clearProgressTimer = useCallback(() => {
|
||||||
|
if (clearProgressTimerRef.current) {
|
||||||
|
clearTimeout(clearProgressTimerRef.current);
|
||||||
|
clearProgressTimerRef.current = null;
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const scheduleProgressClear = useCallback(
|
||||||
|
(delay: number) => {
|
||||||
|
clearProgressTimer();
|
||||||
|
clearProgressTimerRef.current = setTimeout(() => {
|
||||||
|
setUploadProgress(null);
|
||||||
|
clearProgressTimerRef.current = null;
|
||||||
|
}, delay);
|
||||||
|
},
|
||||||
|
[clearProgressTimer],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => clearProgressTimer, [clearProgressTimer]);
|
||||||
|
|
||||||
|
const setUploadError = useCallback(
|
||||||
|
(message: string, fileCount: number, targetPath = '', fileName?: string, progress = 0) => {
|
||||||
|
setUploadProgress({
|
||||||
|
status: 'error',
|
||||||
|
progress,
|
||||||
|
fileCount,
|
||||||
|
fileName,
|
||||||
|
targetPath,
|
||||||
|
error: message,
|
||||||
|
});
|
||||||
|
scheduleProgressClear(ERROR_PROGRESS_CLEAR_DELAY_MS);
|
||||||
|
},
|
||||||
|
[scheduleProgressClear],
|
||||||
|
);
|
||||||
|
|
||||||
|
const uploadFiles = useCallback(
|
||||||
|
async (files: File[], targetPath = '') => {
|
||||||
|
if (files.length === 0) {
|
||||||
|
setDropTarget(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fileName = files.length === 1 ? getFileDisplayName(files[0]) : undefined;
|
||||||
|
|
||||||
|
if (!selectedProject) {
|
||||||
|
const message = 'Select a project before uploading files.';
|
||||||
|
showToast(message, 'error');
|
||||||
|
setUploadError(message, files.length, targetPath, fileName);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const validationError = validateFilesForUpload(files);
|
||||||
|
if (validationError) {
|
||||||
|
showToast(validationError, 'error');
|
||||||
|
setUploadError(validationError, files.length, targetPath, fileName);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
clearProgressTimer();
|
||||||
|
setOperationLoading(true);
|
||||||
|
setUploadProgress({
|
||||||
|
status: 'uploading',
|
||||||
|
progress: 0,
|
||||||
|
fileCount: files.length,
|
||||||
|
fileName,
|
||||||
|
targetPath,
|
||||||
|
});
|
||||||
|
|
||||||
|
let latestProgress = 0;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await uploadFormDataWithProgress(
|
||||||
|
selectedProject.projectId,
|
||||||
|
buildUploadFormData(files, targetPath),
|
||||||
|
(progress) => {
|
||||||
|
latestProgress = progress;
|
||||||
|
setUploadProgress((current) =>
|
||||||
|
current && current.status === 'uploading'
|
||||||
|
? { ...current, progress }
|
||||||
|
: current,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const uploadedCount =
|
||||||
|
typeof response.uploadedCount === 'number' ? response.uploadedCount : response.files?.length ?? files.length;
|
||||||
|
const requestedFileCount =
|
||||||
|
typeof response.requestedFileCount === 'number' ? response.requestedFileCount : files.length;
|
||||||
|
|
||||||
|
setUploadProgress({
|
||||||
|
status: 'complete',
|
||||||
|
progress: 100,
|
||||||
|
fileCount: requestedFileCount,
|
||||||
|
uploadedCount,
|
||||||
|
fileName,
|
||||||
|
targetPath,
|
||||||
|
});
|
||||||
|
|
||||||
|
showToast(formatUploadSuccessMessage(uploadedCount, requestedFileCount), 'success');
|
||||||
|
scheduleProgressClear(COMPLETE_PROGRESS_CLEAR_DELAY_MS);
|
||||||
|
onRefresh();
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : 'Upload failed';
|
||||||
|
console.error('Upload error:', err);
|
||||||
|
showToast(message, 'error');
|
||||||
|
setUploadError(message, files.length, targetPath, fileName, latestProgress);
|
||||||
|
} finally {
|
||||||
|
setOperationLoading(false);
|
||||||
|
setDropTarget(null);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[
|
||||||
|
clearProgressTimer,
|
||||||
|
onRefresh,
|
||||||
|
scheduleProgressClear,
|
||||||
|
selectedProject,
|
||||||
|
setUploadError,
|
||||||
|
showToast,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleFileSelect = useCallback(
|
||||||
|
async (fileList: FileList | File[]) => {
|
||||||
|
await uploadFiles(Array.from(fileList), '');
|
||||||
|
},
|
||||||
|
[uploadFiles],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleDragEnter = useCallback((e: DragEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
setIsDragOver(true);
|
setIsDragOver(true);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleDragOver = useCallback((e: React.DragEvent) => {
|
const handleDragOver = useCallback((e: DragEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleDragLeave = useCallback((e: React.DragEvent) => {
|
const handleDragLeave = useCallback((e: DragEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
// Only set isDragOver to false if we're leaving the entire tree
|
// Only set isDragOver to false if we're leaving the entire tree
|
||||||
@@ -88,103 +402,35 @@ export const useFileTreeUpload = ({
|
|||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleDrop = useCallback(async (e: React.DragEvent) => {
|
const handleDrop = useCallback(
|
||||||
e.preventDefault();
|
async (e: DragEvent) => {
|
||||||
e.stopPropagation();
|
e.preventDefault();
|
||||||
setIsDragOver(false);
|
e.stopPropagation();
|
||||||
|
setIsDragOver(false);
|
||||||
|
|
||||||
const targetPath = dropTarget || '';
|
const targetPath = dropTarget || '';
|
||||||
setOperationLoading(true);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const files: File[] = [];
|
const files = await collectDroppedFiles(e.dataTransfer);
|
||||||
|
await uploadFiles(files, targetPath);
|
||||||
// Use DataTransferItemList for folder support
|
} catch (err) {
|
||||||
const items = e.dataTransfer.items;
|
const message = err instanceof Error ? err.message : 'Could not read dropped files';
|
||||||
if (items) {
|
console.error('Upload error:', err);
|
||||||
for (const item of Array.from(items)) {
|
showToast(message, 'error');
|
||||||
if (item.kind === 'file') {
|
setUploadError(message, 0, targetPath);
|
||||||
const entry = item.webkitGetAsEntry ? item.webkitGetAsEntry() : null;
|
|
||||||
|
|
||||||
if (entry) {
|
|
||||||
if (entry.isFile) {
|
|
||||||
const file = await new Promise<File>((resolve, reject) => {
|
|
||||||
(entry as FileSystemFileEntry).file(resolve, reject);
|
|
||||||
});
|
|
||||||
files.push(file);
|
|
||||||
} else if (entry.isDirectory) {
|
|
||||||
// Pass the directory name as basePath so files include the folder path
|
|
||||||
const dirFiles = await readAllDirectoryEntries(entry as FileSystemDirectoryEntry, entry.name);
|
|
||||||
files.push(...dirFiles);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Fallback for browsers that don't support webkitGetAsEntry
|
|
||||||
const fileList = e.dataTransfer.files;
|
|
||||||
for (const file of Array.from(fileList)) {
|
|
||||||
files.push(file);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (files.length === 0) {
|
|
||||||
setOperationLoading(false);
|
|
||||||
setDropTarget(null);
|
setDropTarget(null);
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
[dropTarget, setUploadError, showToast, uploadFiles],
|
||||||
|
);
|
||||||
|
|
||||||
const formData = new FormData();
|
const handleItemDragOver = useCallback((e: DragEvent, itemPath: string) => {
|
||||||
formData.append('targetPath', targetPath);
|
|
||||||
|
|
||||||
// Store relative paths separately since FormData strips path info from File.name
|
|
||||||
const relativePaths: string[] = [];
|
|
||||||
files.forEach((file) => {
|
|
||||||
// Create a new file with just the filename (without path) for FormData
|
|
||||||
// but store the relative path separately
|
|
||||||
const cleanFile = new File([file], file.name.split('/').pop()!, {
|
|
||||||
type: file.type,
|
|
||||||
lastModified: file.lastModified
|
|
||||||
});
|
|
||||||
formData.append('files', cleanFile);
|
|
||||||
relativePaths.push(file.name); // Keep the full relative path
|
|
||||||
});
|
|
||||||
|
|
||||||
// Send relative paths as a JSON array
|
|
||||||
formData.append('relativePaths', JSON.stringify(relativePaths));
|
|
||||||
|
|
||||||
const response = await api.post(
|
|
||||||
// File upload endpoint is keyed by DB projectId post-migration.
|
|
||||||
`/projects/${encodeURIComponent(selectedProject!.projectId)}/files/upload`,
|
|
||||||
formData
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const data = await response.json();
|
|
||||||
throw new Error(data.error || 'Upload failed');
|
|
||||||
}
|
|
||||||
|
|
||||||
showToast(
|
|
||||||
`Uploaded ${files.length} file(s)`,
|
|
||||||
'success'
|
|
||||||
);
|
|
||||||
onRefresh();
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Upload error:', err);
|
|
||||||
showToast(err instanceof Error ? err.message : 'Upload failed', 'error');
|
|
||||||
} finally {
|
|
||||||
setOperationLoading(false);
|
|
||||||
setDropTarget(null);
|
|
||||||
}
|
|
||||||
}, [dropTarget, selectedProject, onRefresh, showToast]);
|
|
||||||
|
|
||||||
const handleItemDragOver = useCallback((e: React.DragEvent, itemPath: string) => {
|
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
setDropTarget(itemPath);
|
setDropTarget(itemPath);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleItemDrop = useCallback((e: React.DragEvent, itemPath: string) => {
|
const handleItemDrop = useCallback((e: DragEvent, itemPath: string) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
setDropTarget(itemPath);
|
setDropTarget(itemPath);
|
||||||
@@ -194,7 +440,9 @@ export const useFileTreeUpload = ({
|
|||||||
isDragOver,
|
isDragOver,
|
||||||
dropTarget,
|
dropTarget,
|
||||||
operationLoading,
|
operationLoading,
|
||||||
|
uploadProgress,
|
||||||
treeRef,
|
treeRef,
|
||||||
|
handleFileSelect,
|
||||||
handleDragEnter,
|
handleDragEnter,
|
||||||
handleDragOver,
|
handleDragOver,
|
||||||
handleDragLeave,
|
handleDragLeave,
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useCallback, useState, useEffect, useRef } from 'react';
|
import { useCallback, useState, useEffect, useRef } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { AlertTriangle, Check, X, Loader2, Folder, Upload } from 'lucide-react';
|
import { AlertTriangle, Check, X, Loader2, Folder, Upload } from 'lucide-react';
|
||||||
|
|
||||||
import { cn } from '../../../lib/utils';
|
import { cn } from '../../../lib/utils';
|
||||||
import { ICON_SIZE_CLASS, getFileIconData } from '../constants/fileIcons';
|
import { ICON_SIZE_CLASS, getFileIconData } from '../constants/fileIcons';
|
||||||
import { useExpandedDirectories } from '../hooks/useExpandedDirectories';
|
import { useExpandedDirectories } from '../hooks/useExpandedDirectories';
|
||||||
@@ -13,10 +14,12 @@ import type { FileTreeImageSelection, FileTreeNode } from '../types/types';
|
|||||||
import { formatFileSize, formatRelativeTime, isImageFile } from '../utils/fileTreeUtils';
|
import { formatFileSize, formatRelativeTime, isImageFile } from '../utils/fileTreeUtils';
|
||||||
import { Project } from '../../../types/app';
|
import { Project } from '../../../types/app';
|
||||||
import { ScrollArea, Input } from '../../../shared/view/ui';
|
import { ScrollArea, Input } from '../../../shared/view/ui';
|
||||||
|
|
||||||
import FileTreeBody from './FileTreeBody';
|
import FileTreeBody from './FileTreeBody';
|
||||||
import FileTreeDetailedColumns from './FileTreeDetailedColumns';
|
import FileTreeDetailedColumns from './FileTreeDetailedColumns';
|
||||||
import FileTreeHeader from './FileTreeHeader';
|
import FileTreeHeader from './FileTreeHeader';
|
||||||
import FileTreeLoadingState from './FileTreeLoadingState';
|
import FileTreeLoadingState from './FileTreeLoadingState';
|
||||||
|
import FileTreeUploadProgress from './FileTreeUploadProgress';
|
||||||
import ImageViewer from './ImageViewer';
|
import ImageViewer from './ImageViewer';
|
||||||
|
|
||||||
|
|
||||||
@@ -66,6 +69,7 @@ export default function FileTree({ selectedProject, onFileOpen }: FileTreeProps)
|
|||||||
onRefresh: refreshFiles,
|
onRefresh: refreshFiles,
|
||||||
showToast,
|
showToast,
|
||||||
});
|
});
|
||||||
|
const operationLoading = operations.operationLoading || upload.operationLoading;
|
||||||
|
|
||||||
// Focus input when creating new item
|
// Focus input when creating new item
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -146,14 +150,19 @@ export default function FileTree({ selectedProject, onFileOpen }: FileTreeProps)
|
|||||||
onViewModeChange={changeViewMode}
|
onViewModeChange={changeViewMode}
|
||||||
searchQuery={searchQuery}
|
searchQuery={searchQuery}
|
||||||
onSearchQueryChange={setSearchQuery}
|
onSearchQueryChange={setSearchQuery}
|
||||||
|
onUploadFiles={upload.handleFileSelect}
|
||||||
onNewFile={() => operations.handleStartCreate('', 'file')}
|
onNewFile={() => operations.handleStartCreate('', 'file')}
|
||||||
onNewFolder={() => operations.handleStartCreate('', 'directory')}
|
onNewFolder={() => operations.handleStartCreate('', 'directory')}
|
||||||
onRefresh={refreshFiles}
|
onRefresh={refreshFiles}
|
||||||
onCollapseAll={collapseAll}
|
onCollapseAll={collapseAll}
|
||||||
loading={loading}
|
loading={loading}
|
||||||
operationLoading={operations.operationLoading}
|
operationLoading={operationLoading}
|
||||||
|
isUploading={upload.uploadProgress?.status === 'uploading'}
|
||||||
|
uploadProgress={upload.uploadProgress?.progress ?? null}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<FileTreeUploadProgress upload={upload.uploadProgress} />
|
||||||
|
|
||||||
{viewMode === 'detailed' && filteredFiles.length > 0 && <FileTreeDetailedColumns />}
|
{viewMode === 'detailed' && filteredFiles.length > 0 && <FileTreeDetailedColumns />}
|
||||||
|
|
||||||
<ScrollArea className="flex-1 px-2 py-1">
|
<ScrollArea className="flex-1 px-2 py-1">
|
||||||
@@ -184,7 +193,7 @@ export default function FileTree({ selectedProject, onFileOpen }: FileTreeProps)
|
|||||||
}, 100);
|
}, 100);
|
||||||
}}
|
}}
|
||||||
className="h-6 flex-1 text-sm"
|
className="h-6 flex-1 text-sm"
|
||||||
disabled={operations.operationLoading}
|
disabled={operationLoading}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -213,7 +222,7 @@ export default function FileTree({ selectedProject, onFileOpen }: FileTreeProps)
|
|||||||
handleConfirmRename={operations.handleConfirmRename}
|
handleConfirmRename={operations.handleConfirmRename}
|
||||||
handleCancelRename={operations.handleCancelRename}
|
handleCancelRename={operations.handleCancelRename}
|
||||||
renameInputRef={renameInputRef}
|
renameInputRef={renameInputRef}
|
||||||
operationLoading={operations.operationLoading}
|
operationLoading={operationLoading}
|
||||||
/>
|
/>
|
||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
|
|
||||||
@@ -251,17 +260,17 @@ export default function FileTree({ selectedProject, onFileOpen }: FileTreeProps)
|
|||||||
<div className="flex justify-end gap-2">
|
<div className="flex justify-end gap-2">
|
||||||
<button
|
<button
|
||||||
onClick={operations.handleCancelDelete}
|
onClick={operations.handleCancelDelete}
|
||||||
disabled={operations.operationLoading}
|
disabled={operationLoading}
|
||||||
className="rounded-md px-3 py-1.5 text-sm transition-colors hover:bg-accent"
|
className="rounded-md px-3 py-1.5 text-sm transition-colors hover:bg-accent"
|
||||||
>
|
>
|
||||||
{t('common.cancel', 'Cancel')}
|
{t('common.cancel', 'Cancel')}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={operations.handleConfirmDelete}
|
onClick={operations.handleConfirmDelete}
|
||||||
disabled={operations.operationLoading}
|
disabled={operationLoading}
|
||||||
className="flex items-center gap-2 rounded-md bg-red-600 px-3 py-1.5 text-sm text-white transition-colors hover:bg-red-700 disabled:opacity-50"
|
className="flex items-center gap-2 rounded-md bg-red-600 px-3 py-1.5 text-sm text-white transition-colors hover:bg-red-700 disabled:opacity-50"
|
||||||
>
|
>
|
||||||
{operations.operationLoading && <Loader2 className="h-4 w-4 animate-spin" />}
|
{operationLoading && <Loader2 className="h-4 w-4 animate-spin" />}
|
||||||
{t('fileTree.delete.confirm', 'Delete')}
|
{t('fileTree.delete.confirm', 'Delete')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,7 +1,11 @@
|
|||||||
import { ChevronDown, Eye, FileText, FolderPlus, List, RefreshCw, Search, TableProperties, X } from 'lucide-react';
|
import { useRef } from 'react';
|
||||||
|
import type { ChangeEvent } from 'react';
|
||||||
|
import { ChevronDown, Eye, FileText, FolderPlus, List, Loader2, RefreshCw, Search, TableProperties, Upload, X } from 'lucide-react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
import { Button, Input } from '../../../shared/view/ui';
|
import { Button, Input } from '../../../shared/view/ui';
|
||||||
import { cn } from '../../../lib/utils';
|
import { cn } from '../../../lib/utils';
|
||||||
|
import { MAX_FILE_UPLOAD_SIZE_LABEL } from '../constants/constants';
|
||||||
import type { FileTreeViewMode } from '../types/types';
|
import type { FileTreeViewMode } from '../types/types';
|
||||||
|
|
||||||
type FileTreeHeaderProps = {
|
type FileTreeHeaderProps = {
|
||||||
@@ -12,11 +16,14 @@ type FileTreeHeaderProps = {
|
|||||||
// Toolbar actions
|
// Toolbar actions
|
||||||
onNewFile?: () => void;
|
onNewFile?: () => void;
|
||||||
onNewFolder?: () => void;
|
onNewFolder?: () => void;
|
||||||
|
onUploadFiles?: (files: FileList) => void;
|
||||||
onRefresh?: () => void;
|
onRefresh?: () => void;
|
||||||
onCollapseAll?: () => void;
|
onCollapseAll?: () => void;
|
||||||
// Loading state
|
// Loading state
|
||||||
loading?: boolean;
|
loading?: boolean;
|
||||||
operationLoading?: boolean;
|
operationLoading?: boolean;
|
||||||
|
isUploading?: boolean;
|
||||||
|
uploadProgress?: number | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function FileTreeHeader({
|
export default function FileTreeHeader({
|
||||||
@@ -26,12 +33,24 @@ export default function FileTreeHeader({
|
|||||||
onSearchQueryChange,
|
onSearchQueryChange,
|
||||||
onNewFile,
|
onNewFile,
|
||||||
onNewFolder,
|
onNewFolder,
|
||||||
|
onUploadFiles,
|
||||||
onRefresh,
|
onRefresh,
|
||||||
onCollapseAll,
|
onCollapseAll,
|
||||||
loading,
|
loading,
|
||||||
operationLoading,
|
operationLoading,
|
||||||
|
isUploading,
|
||||||
|
uploadProgress,
|
||||||
}: FileTreeHeaderProps) {
|
}: FileTreeHeaderProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const uploadInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
const handleUploadInputChange = (event: ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const { files } = event.target;
|
||||||
|
if (files && files.length > 0) {
|
||||||
|
onUploadFiles?.(files);
|
||||||
|
}
|
||||||
|
event.target.value = '';
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-2 border-b border-border px-3 pb-2 pt-3">
|
<div className="space-y-2 border-b border-border px-3 pb-2 pt-3">
|
||||||
@@ -40,6 +59,50 @@ export default function FileTreeHeader({
|
|||||||
<h3 className="text-sm font-medium text-foreground">{t('fileTree.files')}</h3>
|
<h3 className="text-sm font-medium text-foreground">{t('fileTree.files')}</h3>
|
||||||
<div className="flex items-center gap-0.5">
|
<div className="flex items-center gap-0.5">
|
||||||
{/* Action buttons */}
|
{/* Action buttons */}
|
||||||
|
{onUploadFiles && (
|
||||||
|
<>
|
||||||
|
<input
|
||||||
|
ref={uploadInputRef}
|
||||||
|
type="file"
|
||||||
|
multiple
|
||||||
|
className="hidden"
|
||||||
|
onChange={handleUploadInputChange}
|
||||||
|
tabIndex={-1}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="relative h-7 w-7 p-0"
|
||||||
|
onClick={() => uploadInputRef.current?.click()}
|
||||||
|
title={
|
||||||
|
isUploading
|
||||||
|
? t('fileTree.uploadingFiles', 'Uploading files')
|
||||||
|
: t('fileTree.uploadFiles', 'Upload files (max {{size}} each)', {
|
||||||
|
size: MAX_FILE_UPLOAD_SIZE_LABEL,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
aria-label={t('fileTree.uploadFiles', 'Upload files (max {{size}} each)', {
|
||||||
|
size: MAX_FILE_UPLOAD_SIZE_LABEL,
|
||||||
|
})}
|
||||||
|
disabled={operationLoading}
|
||||||
|
>
|
||||||
|
{isUploading ? (
|
||||||
|
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Upload className="h-3.5 w-3.5" />
|
||||||
|
)}
|
||||||
|
{isUploading && typeof uploadProgress === 'number' && (
|
||||||
|
<span className="absolute bottom-0.5 left-1/2 h-0.5 w-4 -translate-x-1/2 overflow-hidden rounded-full bg-primary/20">
|
||||||
|
<span
|
||||||
|
className="block h-full rounded-full bg-primary transition-[width] duration-150"
|
||||||
|
style={{ width: `${uploadProgress}%` }}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
{onNewFile && (
|
{onNewFile && (
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
|
|||||||
90
src/components/file-tree/view/FileTreeUploadProgress.tsx
Normal file
90
src/components/file-tree/view/FileTreeUploadProgress.tsx
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
import { AlertCircle, CheckCircle2, Upload } from 'lucide-react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
import { cn } from '../../../lib/utils';
|
||||||
|
import type { FileTreeUploadProgressState } from '../hooks/useFileTreeUpload';
|
||||||
|
|
||||||
|
type FileTreeUploadProgressProps = {
|
||||||
|
upload: FileTreeUploadProgressState | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const clampProgress = (progress: number) => Math.min(100, Math.max(0, progress));
|
||||||
|
|
||||||
|
const pluralizeFiles = (count: number) => (count === 1 ? 'file' : 'files');
|
||||||
|
|
||||||
|
export default function FileTreeUploadProgress({ upload }: FileTreeUploadProgressProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
if (!upload) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const progress = clampProgress(upload.progress);
|
||||||
|
const isUploading = upload.status === 'uploading';
|
||||||
|
const isComplete = upload.status === 'complete';
|
||||||
|
const isError = upload.status === 'error';
|
||||||
|
const fileSummary =
|
||||||
|
upload.fileCount === 1 && upload.fileName
|
||||||
|
? upload.fileName
|
||||||
|
: `${upload.fileCount} ${pluralizeFiles(upload.fileCount)}`;
|
||||||
|
|
||||||
|
const title = isUploading
|
||||||
|
? t('fileTree.uploadingFiles', 'Uploading files')
|
||||||
|
: isComplete
|
||||||
|
? t('fileTree.uploadComplete', 'Upload complete')
|
||||||
|
: t('fileTree.uploadFailed', 'Upload failed');
|
||||||
|
|
||||||
|
const detail = isError
|
||||||
|
? upload.error
|
||||||
|
: isComplete && typeof upload.uploadedCount === 'number'
|
||||||
|
? t('fileTree.uploadedCount', 'Uploaded {{uploaded}} of {{total}} {{label}}', {
|
||||||
|
uploaded: upload.uploadedCount,
|
||||||
|
total: upload.fileCount,
|
||||||
|
label: pluralizeFiles(upload.fileCount),
|
||||||
|
})
|
||||||
|
: fileSummary;
|
||||||
|
|
||||||
|
const Icon = isError ? AlertCircle : isComplete ? CheckCircle2 : Upload;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'border-b px-3 py-2 transition-colors',
|
||||||
|
isError
|
||||||
|
? 'border-red-500/20 bg-red-500/10 text-red-700 dark:text-red-300'
|
||||||
|
: isComplete
|
||||||
|
? 'border-emerald-500/20 bg-emerald-500/10 text-emerald-700 dark:text-emerald-300'
|
||||||
|
: 'border-primary/20 bg-primary/10 text-foreground',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex min-h-[36px] items-center gap-2">
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'flex h-7 w-7 shrink-0 items-center justify-center rounded-md',
|
||||||
|
isError ? 'bg-red-500/15' : isComplete ? 'bg-emerald-500/15' : 'bg-primary/15',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Icon className={cn('h-3.5 w-3.5', isUploading && 'animate-pulse')} />
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<span className="truncate text-xs font-medium">{title}</span>
|
||||||
|
<span className="shrink-0 text-[11px] tabular-nums text-muted-foreground">
|
||||||
|
{isUploading ? `${progress}%` : isComplete ? t('common.done', 'Done') : t('common.failed', 'Failed')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 truncate text-[11px] text-muted-foreground">{detail}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 h-1.5 overflow-hidden rounded-full bg-background/80">
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'h-full rounded-full transition-[width] duration-200',
|
||||||
|
isError ? 'bg-red-500' : isComplete ? 'bg-emerald-500' : 'bg-primary',
|
||||||
|
)}
|
||||||
|
style={{ width: `${isError ? Math.max(progress, 8) : progress}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user