Twan07 commited on
Commit
16d4b20
·
verified ·
1 Parent(s): 1b70be8

Upload 5 files

Browse files
components/ConfigPanel.tsx ADDED
@@ -0,0 +1,131 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, { useState } from 'react';
2
+ import { Key, FolderGit2, CheckCircle2, AlertCircle, Database, Box, Layout } from 'lucide-react';
3
+ import { HFConfig, RepoType } from '../types';
4
+
5
+ interface ConfigPanelProps {
6
+ config: HFConfig;
7
+ onConfigChange: (newConfig: HFConfig) => void;
8
+ }
9
+
10
+ export const ConfigPanel: React.FC<ConfigPanelProps> = ({ config, onConfigChange }) => {
11
+ const [showToken, setShowToken] = useState(false);
12
+
13
+ const handleTokenChange = (e: React.ChangeEvent<HTMLInputElement>) => {
14
+ onConfigChange({ ...config, token: e.target.value });
15
+ };
16
+
17
+ const handleRepoChange = (e: React.ChangeEvent<HTMLInputElement>) => {
18
+ onConfigChange({ ...config, repo: e.target.value });
19
+ };
20
+
21
+ const handleTypeChange = (type: RepoType) => {
22
+ onConfigChange({ ...config, repoType: type });
23
+ };
24
+
25
+ const isValid = config.token.length > 0 && config.repo.includes('/');
26
+
27
+ return (
28
+ <div className="bg-white p-6 rounded-xl shadow-sm border border-gray-100">
29
+ <h2 className="text-xl font-semibold mb-4 text-gray-800 flex items-center gap-2">
30
+ <Key className="w-5 h-5 text-yellow-500" />
31
+ Authentication & Target
32
+ </h2>
33
+
34
+ <div className="space-y-5">
35
+ {/* Repo Type Selector */}
36
+ <div>
37
+ <label className="block text-sm font-medium text-gray-700 mb-2">
38
+ Repository Type
39
+ </label>
40
+ <div className="flex bg-gray-100 p-1 rounded-lg">
41
+ <button
42
+ onClick={() => handleTypeChange('model')}
43
+ className={`flex-1 flex items-center justify-center gap-2 py-2 text-sm font-medium rounded-md transition-all ${
44
+ config.repoType === 'model'
45
+ ? 'bg-white text-gray-900 shadow-sm'
46
+ : 'text-gray-500 hover:text-gray-700'
47
+ }`}
48
+ >
49
+ <Box className="w-4 h-4" />
50
+ Model
51
+ </button>
52
+ <button
53
+ onClick={() => handleTypeChange('dataset')}
54
+ className={`flex-1 flex items-center justify-center gap-2 py-2 text-sm font-medium rounded-md transition-all ${
55
+ config.repoType === 'dataset'
56
+ ? 'bg-white text-red-600 shadow-sm'
57
+ : 'text-gray-500 hover:text-gray-700'
58
+ }`}
59
+ >
60
+ <Database className="w-4 h-4" />
61
+ Dataset
62
+ </button>
63
+ <button
64
+ onClick={() => handleTypeChange('space')}
65
+ className={`flex-1 flex items-center justify-center gap-2 py-2 text-sm font-medium rounded-md transition-all ${
66
+ config.repoType === 'space'
67
+ ? 'bg-white text-blue-600 shadow-sm'
68
+ : 'text-gray-500 hover:text-gray-700'
69
+ }`}
70
+ >
71
+ <Layout className="w-4 h-4" />
72
+ Space
73
+ </button>
74
+ </div>
75
+ </div>
76
+
77
+ {/* Token Input */}
78
+ <div>
79
+ <label className="block text-sm font-medium text-gray-700 mb-1">
80
+ Hugging Face Access Token (Write)
81
+ </label>
82
+ <div className="relative">
83
+ <input
84
+ type={showToken ? "text" : "password"}
85
+ value={config.token}
86
+ onChange={handleTokenChange}
87
+ placeholder="hf_..."
88
+ className="w-full pl-10 pr-12 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-yellow-400 focus:border-transparent outline-none transition-all"
89
+ />
90
+ <Key className="absolute left-3 top-2.5 w-4 h-4 text-gray-400" />
91
+ <button
92
+ type="button"
93
+ onClick={() => setShowToken(!showToken)}
94
+ className="absolute right-3 top-2.5 text-xs font-semibold text-gray-500 hover:text-gray-700"
95
+ >
96
+ {showToken ? "HIDE" : "SHOW"}
97
+ </button>
98
+ </div>
99
+ <p className="mt-1 text-xs text-gray-500">
100
+ Get your token from <a href="https://huggingface.co/settings/tokens" target="_blank" rel="noopener noreferrer" className="text-blue-600 hover:underline">HF Settings</a>. Ensure it has <strong>WRITE</strong> permissions.
101
+ </p>
102
+ </div>
103
+
104
+ {/* Repo Input */}
105
+ <div>
106
+ <label className="block text-sm font-medium text-gray-700 mb-1">
107
+ Repository ID
108
+ </label>
109
+ <div className="relative">
110
+ <input
111
+ type="text"
112
+ value={config.repo}
113
+ onChange={handleRepoChange}
114
+ placeholder={config.repoType === 'space' ? "username/space-name" : (config.repoType === 'dataset' ? "username/dataset-name" : "username/model-name")}
115
+ className="w-full pl-10 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-yellow-400 focus:border-transparent outline-none transition-all"
116
+ />
117
+ <FolderGit2 className="absolute left-3 top-2.5 w-4 h-4 text-gray-400" />
118
+ </div>
119
+ <p className="mt-1 text-xs text-gray-500">
120
+ Targeting: <span className="font-semibold capitalize">{config.repoType}</span>. Example: <code>jdoe/my-{config.repoType}</code>
121
+ </p>
122
+ </div>
123
+
124
+ <div className={`flex items-center gap-2 text-sm p-3 rounded-lg ${isValid ? 'bg-green-50 text-green-700 border border-green-100' : 'bg-gray-50 text-gray-500 border border-gray-200'}`}>
125
+ {isValid ? <CheckCircle2 className="w-4 h-4" /> : <AlertCircle className="w-4 h-4" />}
126
+ {isValid ? `Ready to upload to ${config.repoType}` : "Enter a valid token and repo ID to start."}
127
+ </div>
128
+ </div>
129
+ </div>
130
+ );
131
+ };
components/FilePreview.tsx ADDED
@@ -0,0 +1,96 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, { useEffect, useState } from 'react';
2
+ import { X, FileText, Image as ImageIcon, Code } from 'lucide-react';
3
+
4
+ interface FilePreviewProps {
5
+ isOpen: boolean;
6
+ fileName: string;
7
+ blob: Blob | null;
8
+ onClose: () => void;
9
+ }
10
+
11
+ export const FilePreview: React.FC<FilePreviewProps> = ({ isOpen, fileName, blob, onClose }) => {
12
+ const [content, setContent] = useState<string | null>(null);
13
+ const [objectUrl, setObjectUrl] = useState<string | null>(null);
14
+
15
+ useEffect(() => {
16
+ if (!blob) return;
17
+
18
+ const type = blob.type;
19
+
20
+ // Handle Images
21
+ if (type.startsWith('image/')) {
22
+ const url = URL.createObjectURL(blob);
23
+ setObjectUrl(url);
24
+ return () => URL.revokeObjectURL(url);
25
+ }
26
+
27
+ // Handle Text/JSON/Code
28
+ if (type.startsWith('text/') || type.includes('json') || type.includes('javascript') || type.includes('xml')) {
29
+ blob.text().then(text => setContent(text));
30
+ } else {
31
+ // Fallback for unknown text-like files
32
+ blob.text().then(text => setContent(text));
33
+ }
34
+ }, [blob]);
35
+
36
+ if (!isOpen || !blob) return null;
37
+
38
+ const isImage = blob.type.startsWith('image/');
39
+
40
+ return (
41
+ <div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm animate-in fade-in duration-200">
42
+ <div className="bg-white rounded-2xl shadow-2xl w-full max-w-4xl max-h-[85vh] flex flex-col overflow-hidden">
43
+ {/* Header */}
44
+ <div className="px-6 py-4 border-b border-gray-100 flex justify-between items-center bg-gray-50">
45
+ <div className="flex items-center gap-3">
46
+ <div className="p-2 bg-yellow-100 rounded-lg text-yellow-600">
47
+ {isImage ? <ImageIcon className="w-5 h-5" /> : <FileText className="w-5 h-5" />}
48
+ </div>
49
+ <div>
50
+ <h3 className="font-semibold text-gray-900 truncate max-w-md">{fileName}</h3>
51
+ <p className="text-xs text-gray-500 uppercase tracking-wider">{blob.type || 'Unknown Type'}</p>
52
+ </div>
53
+ </div>
54
+ <button
55
+ onClick={onClose}
56
+ className="p-2 text-gray-400 hover:text-gray-900 hover:bg-gray-200 rounded-full transition-colors"
57
+ >
58
+ <X className="w-5 h-5" />
59
+ </button>
60
+ </div>
61
+
62
+ {/* Content */}
63
+ <div className="flex-1 overflow-auto p-6 bg-gray-50/50 flex items-center justify-center min-h-[300px]">
64
+ {isImage && objectUrl && (
65
+ <img src={objectUrl} alt={fileName} className="max-w-full max-h-full rounded-lg shadow-sm object-contain" />
66
+ )}
67
+
68
+ {!isImage && content && (
69
+ <div className="w-full h-full bg-white border border-gray-200 rounded-lg p-4 overflow-auto shadow-inner">
70
+ <pre className="text-sm font-mono text-gray-800 whitespace-pre-wrap break-words">
71
+ {content.slice(0, 50000)}
72
+ {content.length > 50000 && <span className="text-gray-400 block mt-2 italic">...Content truncated...</span>}
73
+ </pre>
74
+ </div>
75
+ )}
76
+
77
+ {!isImage && !content && (
78
+ <div className="text-center text-gray-500">
79
+ <p>Binary file or loading...</p>
80
+ </div>
81
+ )}
82
+ </div>
83
+
84
+ {/* Footer */}
85
+ <div className="px-6 py-4 border-t border-gray-100 bg-white flex justify-end">
86
+ <button
87
+ onClick={onClose}
88
+ className="px-4 py-2 bg-gray-100 hover:bg-gray-200 text-gray-700 font-medium rounded-lg transition-colors"
89
+ >
90
+ Close Preview
91
+ </button>
92
+ </div>
93
+ </div>
94
+ </div>
95
+ );
96
+ };
components/FileUploader.tsx ADDED
@@ -0,0 +1,126 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, { useCallback, useState } from 'react';
2
+ import { UploadCloud } from 'lucide-react';
3
+ import { FileItem, UploadStatus } from '../types';
4
+
5
+ const generateId = () => Math.random().toString(36).substring(2, 15);
6
+
7
+ interface FileUploaderProps {
8
+ onFilesAdded: (files: FileItem[]) => void;
9
+ disabled: boolean;
10
+ }
11
+
12
+ /**
13
+ * Beautifies filenames:
14
+ * 1. Prepends current TIMESTAMP (Date.now())
15
+ * 2. Converts Vietnamese/Accents to English (e.g., "tài liệu" -> "tai-lieu")
16
+ * 3. Removes special chars and spaces
17
+ * Format: [TIMESTAMP]-[clean-name].[ext]
18
+ */
19
+ const sanitizeFileName = (fileName: string): string => {
20
+ const timestamp = Date.now();
21
+
22
+ // 1. Separate extension
23
+ const lastDotIndex = fileName.lastIndexOf('.');
24
+ const name = lastDotIndex !== -1 ? fileName.substring(0, lastDotIndex) : fileName;
25
+ const ext = lastDotIndex !== -1 ? fileName.substring(lastDotIndex) : '';
26
+
27
+ let cleanName = name;
28
+
29
+ // 2. Remove EXISTING leading digits/timestamps to avoid double timestamps (e.g. 123_123_name)
30
+ cleanName = cleanName.replace(/^\d+[-_.\s]*/, '');
31
+
32
+ // 3. Normalize Accents (Vietnamese, etc.) to ASCII
33
+ cleanName = cleanName.normalize("NFD").replace(/[\u0300-\u036f]/g, "")
34
+ .replace(/đ/g, 'd').replace(/Đ/g, 'D');
35
+
36
+ // 4. Replace non-alphanumeric characters with hyphens
37
+ cleanName = cleanName.replace(/[^a-zA-Z0-9]/g, '-');
38
+
39
+ // 5. Collapse multiple hyphens and trim edges
40
+ cleanName = cleanName.replace(/-+/g, '-').replace(/^-|-$/g, '');
41
+
42
+ // Fallback if name becomes empty
43
+ if (cleanName.length === 0) cleanName = 'file';
44
+
45
+ // 6. Construct final name: timestamp-slug.ext
46
+ return `${timestamp}-${cleanName}${ext}`.toLowerCase();
47
+ };
48
+
49
+ export const FileUploader: React.FC<FileUploaderProps> = ({ onFilesAdded, disabled }) => {
50
+ const [isDragging, setIsDragging] = useState(false);
51
+
52
+ const handleDragOver = useCallback((e: React.DragEvent) => {
53
+ e.preventDefault();
54
+ if (!disabled) setIsDragging(true);
55
+ }, [disabled]);
56
+
57
+ const handleDragLeave = useCallback((e: React.DragEvent) => {
58
+ e.preventDefault();
59
+ setIsDragging(false);
60
+ }, []);
61
+
62
+ const handleDrop = useCallback((e: React.DragEvent) => {
63
+ e.preventDefault();
64
+ setIsDragging(false);
65
+ if (disabled) return;
66
+
67
+ if (e.dataTransfer.files && e.dataTransfer.files.length > 0) {
68
+ processFiles(e.dataTransfer.files);
69
+ }
70
+ }, [disabled]);
71
+
72
+ const handleFileInput = (e: React.ChangeEvent<HTMLInputElement>) => {
73
+ if (e.target.files && e.target.files.length > 0) {
74
+ processFiles(e.target.files);
75
+ e.target.value = ''; // Reset input
76
+ }
77
+ };
78
+
79
+ const processFiles = (fileList: FileList) => {
80
+ const newFiles: FileItem[] = Array.from(fileList).map(file => {
81
+ const cleanPath = sanitizeFileName(file.name);
82
+ return {
83
+ id: generateId(),
84
+ file,
85
+ path: cleanPath, // Auto-formatted path with timestamp
86
+ status: UploadStatus.IDLE
87
+ };
88
+ });
89
+ onFilesAdded(newFiles);
90
+ };
91
+
92
+ return (
93
+ <div
94
+ onDragOver={handleDragOver}
95
+ onDragLeave={handleDragLeave}
96
+ onDrop={handleDrop}
97
+ className={`
98
+ relative border-2 border-dashed rounded-xl p-8 text-center transition-all duration-200
99
+ ${disabled ? 'opacity-50 cursor-not-allowed border-gray-200 bg-gray-50' : 'cursor-pointer'}
100
+ ${isDragging ? 'border-yellow-400 bg-yellow-50' : 'border-gray-300 hover:border-yellow-400 hover:bg-gray-50'}
101
+ `}
102
+ >
103
+ <input
104
+ type="file"
105
+ multiple
106
+ onChange={handleFileInput}
107
+ disabled={disabled}
108
+ className="absolute inset-0 w-full h-full opacity-0 cursor-pointer disabled:cursor-not-allowed"
109
+ />
110
+
111
+ <div className="flex flex-col items-center justify-center space-y-3 pointer-events-none">
112
+ <div className={`p-4 rounded-full ${isDragging ? 'bg-yellow-100 text-yellow-600' : 'bg-gray-100 text-gray-500'}`}>
113
+ <UploadCloud className="w-8 h-8" />
114
+ </div>
115
+ <div>
116
+ <p className="text-lg font-medium text-gray-700">
117
+ {isDragging ? 'Drop files here' : 'Drag & drop files or click to browse'}
118
+ </p>
119
+ <p className="text-sm text-gray-500 mt-1">
120
+ Files will be renamed: <code>timestamp-filename.ext</code>
121
+ </p>
122
+ </div>
123
+ </div>
124
+ </div>
125
+ );
126
+ };
components/RemoteFileList.tsx ADDED
@@ -0,0 +1,78 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React from 'react';
2
+ import { RemoteFile } from '../types';
3
+ import { File, Eye, Download, Loader2, Database } from 'lucide-react';
4
+
5
+ interface RemoteFileListProps {
6
+ files: RemoteFile[];
7
+ isLoading: boolean;
8
+ onPreview: (file: RemoteFile) => void;
9
+ }
10
+
11
+ export const RemoteFileList: React.FC<RemoteFileListProps> = ({ files, isLoading, onPreview }) => {
12
+ if (isLoading) {
13
+ return (
14
+ <div className="flex flex-col items-center justify-center p-8 bg-white rounded-xl shadow-sm border border-gray-100 h-64">
15
+ <Loader2 className="w-8 h-8 text-blue-500 animate-spin mb-3" />
16
+ <p className="text-gray-500 font-medium">Fetching dataset from Hugging Face...</p>
17
+ </div>
18
+ );
19
+ }
20
+
21
+ if (files.length === 0) {
22
+ return (
23
+ <div className="bg-white rounded-xl shadow-sm border border-gray-100 p-8 text-center">
24
+ <div className="bg-gray-100 p-3 rounded-full w-fit mx-auto mb-3">
25
+ <Database className="w-6 h-6 text-gray-400" />
26
+ </div>
27
+ <h3 className="text-gray-900 font-medium">No files found</h3>
28
+ <p className="text-gray-500 text-sm mt-1">The repository seems empty or files are loading.</p>
29
+ </div>
30
+ );
31
+ }
32
+
33
+ return (
34
+ <div className="bg-white rounded-xl shadow-sm border border-gray-100 overflow-hidden">
35
+ <div className="px-6 py-4 border-b border-gray-100 bg-gray-50 flex justify-between items-center">
36
+ <h3 className="font-semibold text-gray-800 flex items-center gap-2">
37
+ <Database className="w-4 h-4 text-blue-500" />
38
+ Server Files ({files.length})
39
+ </h3>
40
+ </div>
41
+ <div className="max-h-[400px] overflow-y-auto divide-y divide-gray-100">
42
+ {files.map((file) => (
43
+ <div key={file.path} className="px-6 py-3 hover:bg-blue-50/50 transition-colors flex items-center justify-between group">
44
+ <div className="flex items-center gap-3 min-w-0">
45
+ <File className="w-4 h-4 text-gray-400" />
46
+ <div className="min-w-0">
47
+ <p className="text-sm font-medium text-gray-700 truncate" title={file.path}>
48
+ {file.path}
49
+ </p>
50
+ <p className="text-xs text-gray-400">
51
+ {(file.size / 1024).toFixed(1)} KB
52
+ </p>
53
+ </div>
54
+ </div>
55
+ <div className="flex items-center gap-2 opacity-0 group-hover:opacity-100 transition-opacity">
56
+ <button
57
+ onClick={() => onPreview(file)}
58
+ className="p-2 text-gray-500 hover:text-blue-600 hover:bg-blue-100 rounded-lg transition-colors"
59
+ title="Preview File"
60
+ >
61
+ <Eye className="w-4 h-4" />
62
+ </button>
63
+ <a
64
+ href={file.url}
65
+ target="_blank"
66
+ rel="noopener noreferrer"
67
+ className="p-2 text-gray-500 hover:text-gray-900 hover:bg-gray-100 rounded-lg transition-colors"
68
+ title="Open on Hugging Face"
69
+ >
70
+ <Download className="w-4 h-4" />
71
+ </a>
72
+ </div>
73
+ </div>
74
+ ))}
75
+ </div>
76
+ </div>
77
+ );
78
+ };
components/UploadList.tsx ADDED
@@ -0,0 +1,103 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React from 'react';
2
+ import { FileItem, UploadStatus } from '../types';
3
+ import { FileText, Loader2, Check, AlertCircle, ExternalLink, X } from 'lucide-react';
4
+
5
+ interface UploadListProps {
6
+ files: FileItem[];
7
+ onRemove: (id: string) => void;
8
+ onPathChange: (id: string, newPath: string) => void;
9
+ }
10
+
11
+ export const UploadList: React.FC<UploadListProps> = ({ files, onRemove, onPathChange }) => {
12
+ if (files.length === 0) return null;
13
+
14
+ return (
15
+ <div className="bg-white rounded-xl shadow-sm border border-gray-100 overflow-hidden">
16
+ <div className="px-6 py-4 border-b border-gray-100 bg-gray-50 flex justify-between items-center">
17
+ <h3 className="font-semibold text-gray-800">Upload Queue ({files.length})</h3>
18
+ <span className="text-xs text-gray-500">You can rename the destination path below</span>
19
+ </div>
20
+ <div className="divide-y divide-gray-100">
21
+ {files.map((item) => (
22
+ <div key={item.id} className="p-4 hover:bg-gray-50 transition-colors flex items-center gap-4 group">
23
+ <div className="p-2 bg-blue-50 text-blue-600 rounded-lg">
24
+ <FileText className="w-5 h-5" />
25
+ </div>
26
+
27
+ <div className="flex-1 min-w-0">
28
+ <div className="flex items-center gap-2 mb-1">
29
+ <span className="font-medium text-gray-900 truncate max-w-[200px]" title={item.file.name}>
30
+ {item.file.name}
31
+ </span>
32
+ <span className="text-xs text-gray-400">
33
+ ({(item.file.size / 1024).toFixed(1)} KB)
34
+ </span>
35
+ </div>
36
+
37
+ {item.status === UploadStatus.IDLE && (
38
+ <div className="flex items-center gap-2">
39
+ <span className="text-xs text-gray-500">To:</span>
40
+ <input
41
+ type="text"
42
+ value={item.path}
43
+ onChange={(e) => onPathChange(item.id, e.target.value)}
44
+ className="text-xs py-1 px-2 border border-gray-200 rounded bg-white focus:border-yellow-400 outline-none w-full max-w-xs"
45
+ placeholder="path/to/file.ext"
46
+ />
47
+ </div>
48
+ )}
49
+
50
+ {item.status === UploadStatus.UPLOADING && (
51
+ <span className="text-xs text-blue-600 flex items-center gap-1">
52
+ Processing...
53
+ </span>
54
+ )}
55
+
56
+ {item.status === UploadStatus.SUCCESS && (
57
+ <a
58
+ href={item.url}
59
+ target="_blank"
60
+ rel="noopener noreferrer"
61
+ className="text-xs text-green-600 hover:text-green-700 hover:underline flex items-center gap-1"
62
+ >
63
+ View on Hub <ExternalLink className="w-3 h-3" />
64
+ </a>
65
+ )}
66
+
67
+ {item.status === UploadStatus.ERROR && (
68
+ <span className="text-xs text-red-600 truncate" title={item.error}>
69
+ {item.error}
70
+ </span>
71
+ )}
72
+ </div>
73
+
74
+ <div className="flex items-center gap-2">
75
+ {item.status === UploadStatus.UPLOADING && (
76
+ <Loader2 className="w-5 h-5 text-blue-500 animate-spin" />
77
+ )}
78
+ {item.status === UploadStatus.SUCCESS && (
79
+ <div className="p-1 bg-green-100 rounded-full">
80
+ <Check className="w-4 h-4 text-green-600" />
81
+ </div>
82
+ )}
83
+ {item.status === UploadStatus.ERROR && (
84
+ <div className="p-1 bg-red-100 rounded-full group-hover:hidden">
85
+ <AlertCircle className="w-4 h-4 text-red-600" />
86
+ </div>
87
+ )}
88
+
89
+ {item.status !== UploadStatus.UPLOADING && item.status !== UploadStatus.SUCCESS && (
90
+ <button
91
+ onClick={() => onRemove(item.id)}
92
+ className="p-1.5 text-gray-400 hover:text-red-500 hover:bg-red-50 rounded-lg transition-colors"
93
+ >
94
+ <X className="w-4 h-4" />
95
+ </button>
96
+ )}
97
+ </div>
98
+ </div>
99
+ ))}
100
+ </div>
101
+ </div>
102
+ );
103
+ };