Spaces:
Running
Running
Upload 5 files
Browse files- components/ConfigPanel.tsx +131 -0
- components/FilePreview.tsx +96 -0
- components/FileUploader.tsx +126 -0
- components/RemoteFileList.tsx +78 -0
- components/UploadList.tsx +103 -0
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 |
+
};
|