feat: add reward code
This commit is contained in:
110
src/components/AuthModal.tsx
Normal file
110
src/components/AuthModal.tsx
Normal file
@@ -0,0 +1,110 @@
|
||||
import { useState } from 'react';
|
||||
import { X } from 'lucide-react';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
|
||||
interface AuthModalProps {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export default function AuthModal({ onClose }: AuthModalProps) {
|
||||
const { signIn, signUp } = useAuth();
|
||||
const [isSignUp, setIsSignUp] = useState(false);
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const { error } = isSignUp
|
||||
? await signUp(email, password)
|
||||
: await signIn(email, password);
|
||||
|
||||
if (error) {
|
||||
setError(error.message);
|
||||
} else {
|
||||
onClose();
|
||||
}
|
||||
} catch (err) {
|
||||
setError('发生错误,请重试');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-white rounded-xl shadow-xl max-w-md w-full p-6">
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<h2 className="text-2xl font-bold text-gray-900">
|
||||
{isSignUp ? '注册账号' : '登录账号'}
|
||||
</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-2 hover:bg-gray-100 rounded-lg transition-colors"
|
||||
>
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
邮箱
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
placeholder="your@email.com"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
密码
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
placeholder="••••••••"
|
||||
required
|
||||
minLength={6}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="p-3 bg-red-100 border border-red-400 text-red-700 rounded-lg text-sm font-medium animate-pulse">
|
||||
错误: {error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full py-3 px-4 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors font-medium disabled:opacity-80 disabled:cursor-wait"
|
||||
>
|
||||
{isSignUp ? '注册' : '登录'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div className="mt-4 text-center">
|
||||
<button
|
||||
onClick={() => setIsSignUp(!isSignUp)}
|
||||
className="text-sm text-blue-600 hover:text-blue-700"
|
||||
>
|
||||
{isSignUp ? '已有账号?去登录' : '没有账号?去注册'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
208
src/components/ExportSidebar.tsx
Normal file
208
src/components/ExportSidebar.tsx
Normal file
@@ -0,0 +1,208 @@
|
||||
import { useState } from 'react';
|
||||
import { X, Check, Copy, Download, Code2, Image as ImageIcon, FileText, ChevronRight } from 'lucide-react';
|
||||
import { RecognitionResult } from '../types';
|
||||
|
||||
interface ExportSidebarProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
result: RecognitionResult | null;
|
||||
}
|
||||
|
||||
type ExportCategory = 'Code' | 'Image' | 'File';
|
||||
|
||||
interface ExportOption {
|
||||
id: string;
|
||||
label: string;
|
||||
category: ExportCategory;
|
||||
getContent: (result: RecognitionResult) => string | null;
|
||||
isDownload?: boolean; // If true, triggers file download instead of copy
|
||||
extension?: string;
|
||||
}
|
||||
|
||||
export default function ExportSidebar({ isOpen, onClose, result }: ExportSidebarProps) {
|
||||
const [copiedId, setCopiedId] = useState<string | null>(null);
|
||||
|
||||
if (!result) return null;
|
||||
|
||||
const exportOptions: ExportOption[] = [
|
||||
// Code Category
|
||||
{
|
||||
id: 'markdown',
|
||||
label: 'Markdown',
|
||||
category: 'Code',
|
||||
getContent: (r) => r.markdown_content
|
||||
},
|
||||
{
|
||||
id: 'latex',
|
||||
label: 'Latex',
|
||||
category: 'Code',
|
||||
getContent: (r) => r.latex_content
|
||||
},
|
||||
{
|
||||
id: 'mathml',
|
||||
label: 'MathML',
|
||||
category: 'Code',
|
||||
getContent: (r) => r.mathml_content
|
||||
},
|
||||
{
|
||||
id: 'mathml_word',
|
||||
label: 'Word MathML',
|
||||
category: 'Code',
|
||||
getContent: (r) => r.mathml_word_content
|
||||
},
|
||||
// Image Category
|
||||
{
|
||||
id: 'rendered_image',
|
||||
label: 'Rendered Image',
|
||||
category: 'Image',
|
||||
getContent: (r) => r.rendered_image_path,
|
||||
// User requested "Copy button" for Image.
|
||||
// Special handling might be needed for actual image data copy,
|
||||
// but standard clipboard writeText is for text.
|
||||
// If we want to copy image data, we need to fetch it.
|
||||
// For now, I'll stick to the pattern, but if it's a URL, copying the URL is the fallback.
|
||||
// However, usually "Copy Image" implies the binary.
|
||||
// Let's treat it as a special case in handleAction.
|
||||
},
|
||||
// File Category
|
||||
{
|
||||
id: 'docx',
|
||||
label: 'DOCX',
|
||||
category: 'File',
|
||||
getContent: (r) => r.markdown_content, // Placeholder content for file conversion
|
||||
isDownload: true,
|
||||
extension: 'docx'
|
||||
},
|
||||
{
|
||||
id: 'pdf',
|
||||
label: 'PDF',
|
||||
category: 'File',
|
||||
getContent: (r) => r.markdown_content, // Placeholder
|
||||
isDownload: true,
|
||||
extension: 'pdf'
|
||||
}
|
||||
];
|
||||
|
||||
const handleAction = async (option: ExportOption) => {
|
||||
const content = option.getContent(result);
|
||||
if (!content) return;
|
||||
|
||||
try {
|
||||
if (option.category === 'Image' && !option.isDownload) {
|
||||
// Handle Image Copy
|
||||
if (content.startsWith('http') || content.startsWith('blob:')) {
|
||||
try {
|
||||
const response = await fetch(content);
|
||||
const blob = await response.blob();
|
||||
await navigator.clipboard.write([
|
||||
new ClipboardItem({
|
||||
[blob.type]: blob
|
||||
})
|
||||
]);
|
||||
} catch (err) {
|
||||
console.error('Failed to copy image:', err);
|
||||
// Fallback to copying URL
|
||||
await navigator.clipboard.writeText(content);
|
||||
}
|
||||
} else {
|
||||
await navigator.clipboard.writeText(content);
|
||||
}
|
||||
} else if (option.isDownload) {
|
||||
// Simulate download
|
||||
const blob = new Blob([content], { type: 'text/plain' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `export.${option.extension}`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
} else {
|
||||
// Standard Text Copy
|
||||
await navigator.clipboard.writeText(content);
|
||||
}
|
||||
|
||||
setCopiedId(option.id);
|
||||
setTimeout(() => {
|
||||
setCopiedId(null);
|
||||
onClose(); // Auto close after action
|
||||
}, 1000); // Small delay to show "Copied" state before closing
|
||||
|
||||
} catch (err) {
|
||||
console.error('Action failed:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const categories: { id: ExportCategory; icon: any; label: string }[] = [
|
||||
{ id: 'Code', icon: Code2, label: 'Code' },
|
||||
{ id: 'Image', icon: ImageIcon, label: 'Image' },
|
||||
{ id: 'File', icon: FileText, label: 'File' },
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Backdrop */}
|
||||
{isOpen && (
|
||||
<div
|
||||
className="absolute inset-0 bg-black/20 backdrop-blur-[1px] z-40 transition-opacity"
|
||||
onClick={onClose}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Sidebar Panel */}
|
||||
<div
|
||||
className={`
|
||||
absolute top-0 right-0 bottom-0 w-80 bg-white shadow-2xl z-50 transform transition-transform duration-300 ease-in-out border-l border-gray-100 flex flex-col
|
||||
${isOpen ? 'translate-x-0' : 'translate-x-full'}
|
||||
`}
|
||||
>
|
||||
<div className="flex items-center justify-between px-6 py-5 border-b border-gray-100 shrink-0">
|
||||
<h2 className="text-lg font-bold text-gray-900">Export</h2>
|
||||
<button onClick={onClose} className="p-2 hover:bg-gray-100 rounded-full transition-colors">
|
||||
<X size={20} className="text-gray-500" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-6 space-y-8">
|
||||
{categories.map((category) => (
|
||||
<div key={category.id} className="space-y-3">
|
||||
<div className="flex items-center gap-2 text-gray-400 px-1">
|
||||
<category.icon size={16} />
|
||||
<span className="text-xs font-semibold uppercase tracking-wider">{category.label}</span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
{exportOptions
|
||||
.filter(opt => opt.category === category.id)
|
||||
.map(option => (
|
||||
<button
|
||||
key={option.id}
|
||||
onClick={() => handleAction(option)}
|
||||
className="w-full flex items-center justify-between p-3 bg-gray-50 hover:bg-blue-50 hover:text-blue-600 border border-transparent hover:border-blue-200 rounded-lg group transition-all text-left"
|
||||
>
|
||||
<span className="text-sm font-medium text-gray-700 group-hover:text-blue-700">
|
||||
{option.label}
|
||||
</span>
|
||||
|
||||
<div className="text-gray-400 group-hover:text-blue-600">
|
||||
{copiedId === option.id ? (
|
||||
<Check size={16} className="text-green-500" />
|
||||
) : option.isDownload ? (
|
||||
<Download size={16} />
|
||||
) : (
|
||||
<Copy size={16} className="opacity-0 group-hover:opacity-100 transition-opacity" />
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
112
src/components/FilePreview.tsx
Normal file
112
src/components/FilePreview.tsx
Normal file
@@ -0,0 +1,112 @@
|
||||
import { useState } from 'react';
|
||||
import { ChevronLeft, ChevronRight, File as FileIcon, MinusCircle, PlusCircle } from 'lucide-react';
|
||||
import { FileRecord } from '../types';
|
||||
|
||||
interface FilePreviewProps {
|
||||
file: FileRecord | null;
|
||||
}
|
||||
|
||||
export default function FilePreview({ file }: FilePreviewProps) {
|
||||
const [zoom, setZoom] = useState(100);
|
||||
const [page, setPage] = useState(1);
|
||||
const totalPages = 1;
|
||||
|
||||
const handleZoomIn = () => setZoom((prev) => Math.min(prev + 10, 200));
|
||||
const handleZoomOut = () => setZoom((prev) => Math.max(prev - 10, 50));
|
||||
|
||||
if (!file) {
|
||||
return (
|
||||
<div className="flex-1 flex flex-col items-center justify-center bg-white p-8 text-center border border-white border-solid">
|
||||
<div className="w-32 h-32 bg-gray-100 rounded-full flex items-center justify-center mb-6 shadow-inner">
|
||||
<FileIcon size={48} className="text-gray-900" />
|
||||
</div>
|
||||
<h3 className="text-xl font-semibold text-gray-900 mb-2">Upload file</h3>
|
||||
<p className="text-gray-500 max-w-xs">
|
||||
Click, Drop, or Paste a file to start parsing
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full bg-gray-100/50">
|
||||
{/* Top Bar */}
|
||||
<div className="h-16 flex items-center justify-between px-6 bg-white border-b border-gray-200 z-10 relative">
|
||||
<div className="flex items-center gap-3 overflow-hidden z-20">
|
||||
<div className="p-2 bg-blue-50 text-blue-600 rounded-lg">
|
||||
<FileIcon size={18} />
|
||||
</div>
|
||||
<h2 className="text-sm font-semibold text-gray-900 truncate max-w-[200px]" title={file.filename}>
|
||||
{file.filename}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 flex items-center gap-4 z-10">
|
||||
{totalPages >= 1 && (
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={() => setPage(p => Math.max(1, p - 1))}
|
||||
disabled={page === 1}
|
||||
className="p-1 hover:bg-white hover:shadow-sm rounded-md text-gray-500 disabled:opacity-30 disabled:hover:bg-transparent disabled:hover:shadow-none transition-all"
|
||||
>
|
||||
<ChevronLeft size={16} />
|
||||
</button>
|
||||
<span className="text-xs font-medium text-gray-600 px-1 select-none min-w-[3rem] text-center">
|
||||
{page} / {totalPages}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setPage(p => Math.min(totalPages, p + 1))}
|
||||
disabled={page === totalPages}
|
||||
className="p-1 hover:bg-white hover:shadow-sm rounded-md text-gray-500 disabled:opacity-30 disabled:hover:bg-transparent disabled:hover:shadow-none transition-all"
|
||||
>
|
||||
<ChevronRight size={16} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={handleZoomOut}
|
||||
className="p-1 hover:bg-white hover:shadow-sm rounded-md text-gray-500 transition-all"
|
||||
title="缩小"
|
||||
>
|
||||
<MinusCircle size={16} />
|
||||
</button>
|
||||
<span className="text-xs font-medium text-gray-600 min-w-[2.5rem] text-center select-none">
|
||||
{zoom}%
|
||||
</span>
|
||||
<button
|
||||
onClick={handleZoomIn}
|
||||
className="p-1 hover:bg-white hover:shadow-sm rounded-md text-gray-500 transition-all"
|
||||
title="放大"
|
||||
>
|
||||
<PlusCircle size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Preview Content */}
|
||||
<div className="flex-1 overflow-auto p-8 relative custom-scrollbar flex items-center justify-center border-r border-gray-200">
|
||||
<div
|
||||
className="bg-white shadow-2xl shadow-gray-200/50 transition-transform duration-200 ease-out origin-center max-w-full max-h-full flex items-center justify-center"
|
||||
style={{ transform: `scale(${zoom / 100})` }}
|
||||
>
|
||||
{file.file_type === 'application/pdf' ? (
|
||||
// Placeholder for PDF rendering - ideally use react-pdf or similar
|
||||
<div className="w-[595px] h-[842px] flex items-center justify-center bg-gray-50 border border-gray-100">
|
||||
<p className="text-gray-400">PDF Preview Not Implemented</p>
|
||||
</div>
|
||||
) : (
|
||||
<img
|
||||
src={file.file_path || 'https://images.pexels.com/photos/326514/pexels-photo-326514.jpeg?auto=compress&cs=tinysrgb&w=800'}
|
||||
alt={file.filename}
|
||||
className="max-w-full max-h-full object-contain block"
|
||||
draggable={false}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
310
src/components/LeftSidebar.tsx
Normal file
310
src/components/LeftSidebar.tsx
Normal file
@@ -0,0 +1,310 @@
|
||||
import React, { useState, useRef, useCallback, useEffect } from 'react';
|
||||
import { Upload, LogIn, LogOut, FileText, Clock, ChevronLeft, ChevronRight, Settings, History, MousePointerClick, FileUp, ClipboardPaste, Loader2 } from 'lucide-react';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import { FileRecord } from '../types';
|
||||
import AuthModal from './AuthModal';
|
||||
|
||||
interface LeftSidebarProps {
|
||||
files: FileRecord[];
|
||||
selectedFileId: string | null;
|
||||
onFileSelect: (fileId: string) => void;
|
||||
onUploadClick: () => void;
|
||||
isCollapsed: boolean;
|
||||
onToggleCollapse: () => void;
|
||||
onUploadFiles: (files: File[]) => void;
|
||||
hasMore: boolean;
|
||||
loadingMore: boolean;
|
||||
onLoadMore: () => void;
|
||||
}
|
||||
|
||||
export default function LeftSidebar({
|
||||
files,
|
||||
selectedFileId,
|
||||
onFileSelect,
|
||||
onUploadClick,
|
||||
isCollapsed,
|
||||
onToggleCollapse,
|
||||
onUploadFiles,
|
||||
hasMore,
|
||||
loadingMore,
|
||||
onLoadMore,
|
||||
}: LeftSidebarProps) {
|
||||
const { user, signOut } = useAuth();
|
||||
const [showAuthModal, setShowAuthModal] = useState(false);
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const listRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Handle scroll to load more
|
||||
const handleScroll = useCallback(() => {
|
||||
if (!listRef.current || loadingMore || !hasMore) return;
|
||||
|
||||
const { scrollTop, scrollHeight, clientHeight } = listRef.current;
|
||||
// Trigger load more when scrolled to bottom (with 50px threshold)
|
||||
if (scrollHeight - scrollTop - clientHeight < 50) {
|
||||
onLoadMore();
|
||||
}
|
||||
}, [loadingMore, hasMore, onLoadMore]);
|
||||
|
||||
// Auto-load more if content doesn't fill the container
|
||||
useEffect(() => {
|
||||
if (!hasMore || loadingMore || !user || files.length === 0) return;
|
||||
|
||||
// Use requestAnimationFrame to ensure DOM has been updated
|
||||
const checkAndLoadMore = () => {
|
||||
requestAnimationFrame(() => {
|
||||
if (!listRef.current) return;
|
||||
|
||||
const { scrollHeight, clientHeight } = listRef.current;
|
||||
|
||||
// If content doesn't fill container and there's more data, load more
|
||||
if (scrollHeight <= clientHeight && hasMore && !loadingMore) {
|
||||
onLoadMore();
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// Small delay to ensure DOM is fully rendered
|
||||
const timer = setTimeout(checkAndLoadMore, 100);
|
||||
return () => clearTimeout(timer);
|
||||
}, [files.length, hasMore, loadingMore, onLoadMore, user]);
|
||||
|
||||
const handleDragOver = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
setIsDragging(true);
|
||||
};
|
||||
|
||||
const handleDragLeave = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
setIsDragging(false);
|
||||
};
|
||||
|
||||
const handleDrop = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
setIsDragging(false);
|
||||
|
||||
const droppedFiles = Array.from(e.dataTransfer.files);
|
||||
const validFiles = droppedFiles.filter(file =>
|
||||
file.type.startsWith('image/') || file.type === 'application/pdf'
|
||||
);
|
||||
|
||||
if (validFiles.length > 0) {
|
||||
onUploadFiles(validFiles);
|
||||
}
|
||||
};
|
||||
|
||||
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (e.target.files && e.target.files.length > 0) {
|
||||
onUploadFiles(Array.from(e.target.files));
|
||||
}
|
||||
// Reset input
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = '';
|
||||
}
|
||||
};
|
||||
|
||||
if (isCollapsed) {
|
||||
return (
|
||||
<div className="h-full flex flex-col items-center py-4 bg-gray-50/50">
|
||||
<button
|
||||
onClick={onToggleCollapse}
|
||||
className="p-2 mb-6 text-gray-500 hover:text-gray-900 hover:bg-gray-200 rounded-md transition-colors"
|
||||
>
|
||||
<ChevronRight size={20} />
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={onUploadClick}
|
||||
className="p-3 rounded-xl bg-blue-600 text-white hover:bg-blue-700 shadow-lg shadow-blue-600/20 transition-all mb-6"
|
||||
title="Upload"
|
||||
>
|
||||
<Upload size={20} />
|
||||
</button>
|
||||
|
||||
<div className="flex-1 w-full flex flex-col items-center gap-4">
|
||||
<button className="p-2 text-gray-400 hover:text-gray-900 transition-colors" title="History">
|
||||
<History size={20} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => !user && setShowAuthModal(true)}
|
||||
className="p-3 rounded-lg text-gray-600 hover:bg-gray-200 transition-colors mt-auto"
|
||||
title={user ? 'Signed In' : 'Sign In'}
|
||||
>
|
||||
<LogIn size={20} />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-col h-full bg-white">
|
||||
{/* Top Area: Title & Upload */}
|
||||
<div className="p-6 pb-4">
|
||||
<div className="flex items-start justify-between mb-6">
|
||||
<div>
|
||||
<h2 className="text-lg font-bold text-gray-900 leading-tight">Formula Recognize</h2>
|
||||
<p className="text-xs text-gray-500 mt-1">Support handwriting and printed formulas</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={onToggleCollapse}
|
||||
className="p-1.5 -mr-1.5 text-gray-400 hover:text-gray-900 hover:bg-gray-100 rounded-md transition-colors"
|
||||
>
|
||||
<ChevronLeft size={18} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mb-2">
|
||||
<div
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
className={`
|
||||
border-2 border-dashed rounded-xl p-6 text-center cursor-pointer transition-all duration-200 group
|
||||
${isDragging
|
||||
? 'border-blue-500 bg-blue-50'
|
||||
: 'border-gray-200 hover:border-blue-400 hover:bg-gray-50'
|
||||
}
|
||||
`}
|
||||
>
|
||||
<input
|
||||
type="file"
|
||||
ref={fileInputRef}
|
||||
className="hidden"
|
||||
onChange={handleFileChange}
|
||||
accept="image/*"
|
||||
multiple
|
||||
/>
|
||||
<div className="w-12 h-12 bg-gray-100 text-gray-600 rounded-full flex items-center justify-center mx-auto mb-3 group-hover:scale-110 transition-transform">
|
||||
<Upload size={24} />
|
||||
</div>
|
||||
<div className="flex items-center justify-center gap-4 mt-2 text-xs text-gray-400">
|
||||
<div className="flex items-center gap-1">
|
||||
<MousePointerClick className="w-3.5 h-3.5" />
|
||||
<span>Click</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<FileUp className="w-3.5 h-3.5" />
|
||||
<span>Drop</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<ClipboardPaste className="w-3.5 h-3.5" />
|
||||
<span>Paste</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Middle Area: History */}
|
||||
<div className="flex-1 overflow-hidden flex flex-col px-4">
|
||||
<div className="flex items-center gap-2 text-xs font-semibold text-gray-500 uppercase tracking-wider mb-3 px-2">
|
||||
<Clock size={14} />
|
||||
<span>History</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
ref={listRef}
|
||||
onScroll={handleScroll}
|
||||
className="flex-1 overflow-y-auto space-y-1 pr-2 -mr-2 custom-scrollbar"
|
||||
>
|
||||
{!user ? (
|
||||
<div className="text-center py-12 text-gray-400 text-sm">
|
||||
<div className="mb-2 opacity-50"><FileText size={40} className="mx-auto" /></div>
|
||||
Please login to view history
|
||||
</div>
|
||||
) : files.length === 0 ? (
|
||||
<div className="text-center py-12 text-gray-400 text-sm">
|
||||
<div className="mb-2 opacity-50"><FileText size={40} className="mx-auto" /></div>
|
||||
No history records
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{files.map((file) => (
|
||||
<button
|
||||
key={file.id}
|
||||
onClick={() => onFileSelect(file.id)}
|
||||
className={`w-full p-3 rounded-lg text-left transition-all border group relative ${selectedFileId === file.id
|
||||
? 'bg-blue-50 border-blue-200 shadow-sm'
|
||||
: 'bg-white border-transparent hover:bg-gray-50 hover:border-gray-100'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className={`p-2 rounded-lg ${selectedFileId === file.id ? 'bg-blue-100 text-blue-600' : 'bg-gray-100 text-gray-500 group-hover:bg-gray-200'}`}>
|
||||
<FileText size={18} />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className={`text-sm font-medium truncate ${selectedFileId === file.id ? 'text-blue-900' : 'text-gray-700'}`}>
|
||||
{file.filename}
|
||||
</p>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<span className="text-xs text-gray-400">
|
||||
{new Date(file.created_at).toLocaleDateString()}
|
||||
</span>
|
||||
<span className={`w-1.5 h-1.5 rounded-full ${file.status === 'completed' ? 'bg-green-500' :
|
||||
file.status === 'processing' ? 'bg-yellow-500' : 'bg-red-500'
|
||||
}`} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
{/* Loading more indicator */}
|
||||
{loadingMore && (
|
||||
<div className="flex items-center justify-center py-3 text-gray-400">
|
||||
<Loader2 size={18} className="animate-spin" />
|
||||
<span className="ml-2 text-xs">Loading...</span>
|
||||
</div>
|
||||
)}
|
||||
{/* End of list indicator */}
|
||||
{!hasMore && files.length > 0 && (
|
||||
<div className="text-center py-3 text-xs text-gray-400">
|
||||
No more records
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bottom Area: User/Login */}
|
||||
<div className="p-4 border-t border-gray-100 bg-gray-50/30">
|
||||
{user ? (
|
||||
<div className="flex items-center gap-3 p-2 rounded-lg bg-white border border-gray-100 shadow-sm">
|
||||
<div className="w-8 h-8 bg-gray-900 rounded-full flex items-center justify-center flex-shrink-0">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M12 12C14.21 12 16 10.21 16 8C16 5.79 14.21 4 12 4C9.79 4 8 5.79 8 8C8 10.21 9.79 12 12 12ZM12 14C9.33 14 4 15.34 4 18V20H20V18C20 15.34 14.67 14 12 14Z" fill="white" />
|
||||
</svg>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-gray-900 truncate">{user.email}</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => signOut()}
|
||||
className="p-1.5 text-gray-400 hover:text-red-500 hover:bg-red-50 rounded-md transition-colors"
|
||||
title="Logout"
|
||||
>
|
||||
<LogOut size={16} />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => setShowAuthModal(true)}
|
||||
className="w-full py-2.5 px-4 bg-gray-900 text-white rounded-lg hover:bg-gray-800 transition-colors flex items-center justify-center gap-2 text-sm font-medium shadow-lg shadow-gray-900/10"
|
||||
>
|
||||
<LogIn size={18} />
|
||||
Login / Register
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showAuthModal && (
|
||||
<AuthModal onClose={() => setShowAuthModal(false)} />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
148
src/components/Navbar.tsx
Normal file
148
src/components/Navbar.tsx
Normal file
@@ -0,0 +1,148 @@
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import { Mail, Users, MessageCircle, ChevronDown, Check, Heart, X } from 'lucide-react';
|
||||
|
||||
export default function Navbar() {
|
||||
const [showContact, setShowContact] = useState(false);
|
||||
const [showReward, setShowReward] = useState(false);
|
||||
const [copied, setCopied] = useState(false);
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const handleCopyQQ = async () => {
|
||||
await navigator.clipboard.writeText('1018282100');
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
};
|
||||
|
||||
// Close dropdown when clicking outside
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
|
||||
setShowContact(false);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="h-16 bg-white border-b border-gray-200 flex items-center justify-between px-6 flex-shrink-0 z-[60] relative">
|
||||
{/* Left: Logo */}
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="w-8 h-8 bg-blue-600 rounded-lg flex items-center justify-center text-white font-serif italic text-lg shadow-blue-600/30 shadow-md">
|
||||
T
|
||||
</span>
|
||||
<span className="text-xl font-bold text-gray-900 tracking-tight">
|
||||
TexPixel
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Right: Reward & Contact Buttons */}
|
||||
<div className="flex items-center gap-3">
|
||||
{/* Reward Button */}
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() => setShowReward(!showReward)}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-gradient-to-r from-rose-500 to-pink-500 hover:from-rose-600 hover:to-pink-600 rounded-lg text-white text-sm font-medium transition-all shadow-sm hover:shadow-md"
|
||||
>
|
||||
<Heart size={14} className="fill-white" />
|
||||
<span>赞赏</span>
|
||||
</button>
|
||||
|
||||
{/* Reward Modal */}
|
||||
{showReward && (
|
||||
<div
|
||||
className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-[70] p-4"
|
||||
onClick={() => setShowReward(false)}
|
||||
>
|
||||
<div
|
||||
className="bg-white rounded-xl shadow-xl max-w-sm w-full p-6 animate-in fade-in zoom-in-95 duration-200"
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<span className="text-lg font-bold text-gray-900">微信赞赏码</span>
|
||||
<button
|
||||
onClick={() => setShowReward(false)}
|
||||
className="p-2 hover:bg-gray-100 rounded-lg transition-colors"
|
||||
>
|
||||
<X size={20} className="text-gray-500" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex flex-col items-center">
|
||||
<img
|
||||
src="https://cdn.texpixel.com/public/rewardcode.png"
|
||||
alt="微信赞赏码"
|
||||
className="w-64 h-64 object-contain rounded-lg shadow-sm"
|
||||
/>
|
||||
<p className="text-sm text-gray-500 text-center mt-4">
|
||||
感谢您的支持与鼓励 ❤️<br />
|
||||
<span className="text-xs text-gray-400 mt-1 block">您的支持是我们持续更新的动力</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Contact Button with Dropdown */}
|
||||
<div className="relative" ref={dropdownRef}>
|
||||
<button
|
||||
onClick={() => setShowContact(!showContact)}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-gray-100 hover:bg-gray-200 rounded-lg text-gray-700 text-sm font-medium transition-colors"
|
||||
>
|
||||
<MessageCircle size={14} />
|
||||
<span>Contact Us</span>
|
||||
<ChevronDown
|
||||
size={14}
|
||||
className={`transition-transform duration-200 ${showContact ? 'rotate-180' : ''}`}
|
||||
/>
|
||||
</button>
|
||||
|
||||
{/* Contact Dropdown List */}
|
||||
{showContact && (
|
||||
<div className="absolute right-0 top-full mt-2 w-64 bg-white rounded-xl shadow-lg border border-gray-200 py-2 z-50 animate-in fade-in slide-in-from-top-2 duration-200">
|
||||
<a
|
||||
href="mailto:yogecoder@gmail.com"
|
||||
className="flex items-center gap-3 px-4 py-3 hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
<div className="w-8 h-8 bg-blue-100 rounded-lg flex items-center justify-center">
|
||||
<Mail size={16} className="text-blue-600" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-gray-500">Email</div>
|
||||
<div className="text-sm font-medium text-gray-900">yogecoder@gmail.com</div>
|
||||
</div>
|
||||
</a>
|
||||
<div
|
||||
className={`flex items-center gap-3 px-4 py-3 hover:bg-gray-50 transition-all cursor-pointer ${copied ? 'bg-green-50' : ''}`}
|
||||
onClick={handleCopyQQ}
|
||||
>
|
||||
<div className={`w-8 h-8 rounded-lg flex items-center justify-center transition-colors ${copied ? 'bg-green-500' : 'bg-green-100'}`}>
|
||||
{copied ? (
|
||||
<Check size={16} className="text-white" />
|
||||
) : (
|
||||
<Users size={16} className="text-green-600" />
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<div className={`text-xs transition-colors ${copied ? 'text-green-600 font-medium' : 'text-gray-500'}`}>
|
||||
{copied ? 'Copied!' : 'QQ Group (Click to Copy)'}
|
||||
</div>
|
||||
<div className="text-sm font-medium text-gray-900">1018282100</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
148
src/components/ResultPanel.tsx
Normal file
148
src/components/ResultPanel.tsx
Normal file
@@ -0,0 +1,148 @@
|
||||
import { useState } from 'react';
|
||||
import { Download, Code2, Check, Copy } from 'lucide-react';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import remarkMath from 'remark-math';
|
||||
import rehypeKatex from 'rehype-katex';
|
||||
import 'katex/dist/katex.min.css';
|
||||
import { RecognitionResult } from '../types';
|
||||
import ExportSidebar from './ExportSidebar';
|
||||
|
||||
interface ResultPanelProps {
|
||||
result: RecognitionResult | null;
|
||||
fileStatus?: 'pending' | 'processing' | 'completed' | 'failed';
|
||||
}
|
||||
|
||||
/**
|
||||
* Preprocess LaTeX content to fix common formatting issues
|
||||
* that may cause KaTeX rendering to fail
|
||||
*/
|
||||
function preprocessLatex(content: string): string {
|
||||
if (!content) return '';
|
||||
|
||||
let processed = content;
|
||||
|
||||
// Fix mixed display math delimiters: $$\[...\]$$ -> $$...$$
|
||||
// This handles cases where \[ \] are incorrectly nested inside $$ $$
|
||||
// Note: In JS replace(), $$ means "insert one $", so we need $$$$ to insert $$
|
||||
processed = processed.replace(/\$\$\s*\\\[/g, '$$$$');
|
||||
processed = processed.replace(/\\\]\s*\$\$/g, '$$$$');
|
||||
|
||||
// Also fix standalone \[ and \] that should be $$ for remark-math compatibility
|
||||
// Replace \[ with $$ at the start of display math (not inside other math)
|
||||
processed = processed.replace(/(?<!\$)\\\[(?!\$)/g, '$$$$');
|
||||
processed = processed.replace(/(?<!\$)\\\](?!\$)/g, '$$$$');
|
||||
|
||||
// IMPORTANT: remark-math requires $$ to be on its own line for block math
|
||||
// Ensure $$ followed by \begin has a newline: $$\begin -> $$\n\begin
|
||||
processed = processed.replace(/\$\$([^\n$])/g, '$$$$\n$1');
|
||||
// Ensure content followed by $$ has a newline: content$$ -> content\n$$
|
||||
processed = processed.replace(/([^\n$])\$\$/g, '$1\n$$$$');
|
||||
|
||||
// Fix: \left \{ -> \left\{ (remove space between \left and delimiter)
|
||||
processed = processed.replace(/\\left\s+\\/g, '\\left\\');
|
||||
processed = processed.replace(/\\left\s+\{/g, '\\left\\{');
|
||||
processed = processed.replace(/\\left\s+\[/g, '\\left[');
|
||||
processed = processed.replace(/\\left\s+\(/g, '\\left(');
|
||||
|
||||
// Fix: \right \} -> \right\} (remove space between \right and delimiter)
|
||||
processed = processed.replace(/\\right\s+\\/g, '\\right\\');
|
||||
processed = processed.replace(/\\right\s+\}/g, '\\right\\}');
|
||||
processed = processed.replace(/\\right\s+\]/g, '\\right]');
|
||||
processed = processed.replace(/\\right\s+\)/g, '\\right)');
|
||||
|
||||
// Fix: \begin{matrix} with mismatched \left/\right -> use \begin{array}
|
||||
// This is a more complex issue that requires proper \left/\right pairing
|
||||
// For now, we'll try to convert problematic patterns
|
||||
|
||||
// Replace \left( ... \right. text \right) pattern with ( ... \right. text )
|
||||
// This fixes the common mispairing issue
|
||||
processed = processed.replace(/\\left\(([^)]*?)\\right\.\s*(\\text\{[^}]*\})\s*\\right\)/g, '($1\\right. $2)');
|
||||
|
||||
return processed;
|
||||
}
|
||||
|
||||
export default function ResultPanel({ result, fileStatus }: ResultPanelProps) {
|
||||
const [isExportSidebarOpen, setIsExportSidebarOpen] = useState(false);
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const handleCopy = async () => {
|
||||
if (result?.markdown_content) {
|
||||
await navigator.clipboard.writeText(result.markdown_content);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
}
|
||||
};
|
||||
|
||||
if (!result) {
|
||||
if (fileStatus === 'processing' || fileStatus === 'pending') {
|
||||
return (
|
||||
<div className="h-full flex flex-col items-center justify-center bg-white text-center p-8">
|
||||
<div className="w-16 h-16 border-4 border-blue-600 border-t-transparent rounded-full animate-spin mb-6"></div>
|
||||
<h3 className="text-xl font-semibold text-gray-900 mb-2">
|
||||
{fileStatus === 'pending' ? 'Waiting in queue...' : 'Analyzing...'}
|
||||
</h3>
|
||||
<p className="text-gray-500 max-w-sm">
|
||||
{fileStatus === 'pending'
|
||||
? 'Your file is in the queue, please wait.'
|
||||
: 'Texpixel is processing your file, this may take a moment.'}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col items-center justify-center bg-white text-center p-8">
|
||||
<div className="w-32 h-32 bg-gray-100 rounded-full flex items-center justify-center mb-6 shadow-inner">
|
||||
<Code2 size={48} className="text-gray-900" />
|
||||
</div>
|
||||
<h3 className="text-xl font-semibold text-gray-900 mb-2">Waiting for recognition result</h3>
|
||||
<p className="text-gray-500 max-w-sm">
|
||||
After uploading the file, Texpixel will automatically recognize and display the result here
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full bg-white relative overflow-hidden">
|
||||
{/* Top Header */}
|
||||
<div className="h-16 px-6 border-b border-gray-200 flex items-center justify-between bg-white shrink-0 z-10">
|
||||
<div className="flex items-center gap-4">
|
||||
<h2 className="text-lg font-bold text-gray-900">Markdown</h2>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => setIsExportSidebarOpen(true)}
|
||||
className={`px-4 py-2 bg-gray-900 text-white text-sm font-medium rounded-lg hover:bg-gray-800 transition-colors flex items-center gap-2 shadow-sm ${isExportSidebarOpen ? 'opacity-0 pointer-events-none' : ''}`}
|
||||
>
|
||||
<Download size={16} />
|
||||
Export
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content Area - Rendered Markdown */}
|
||||
{
|
||||
<div className="flex-1 overflow-auto p-8 custom-scrollbar flex justify-center">
|
||||
<div className="prose prose-blue max-w-3xl w-full prose-headings:font-bold prose-h1:text-2xl prose-h2:text-xl prose-p:leading-relaxed prose-pre:bg-gray-50 prose-pre:border prose-pre:border-gray-100 [&_.katex-display]:text-center">
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkMath]}
|
||||
rehypePlugins={[[rehypeKatex, {
|
||||
throwOnError: false,
|
||||
errorColor: '#cc0000',
|
||||
strict: false
|
||||
}]]}
|
||||
>
|
||||
{preprocessLatex(result.markdown_content || '')}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<ExportSidebar
|
||||
isOpen={isExportSidebarOpen}
|
||||
onClose={() => setIsExportSidebarOpen(false)}
|
||||
result={result}
|
||||
/>
|
||||
</div >
|
||||
);
|
||||
}
|
||||
133
src/components/UploadModal.tsx
Normal file
133
src/components/UploadModal.tsx
Normal file
@@ -0,0 +1,133 @@
|
||||
import { useCallback, useState, useEffect, useRef } from 'react';
|
||||
import { Upload, X, MousePointerClick, FileUp, ClipboardPaste } from 'lucide-react';
|
||||
|
||||
interface UploadModalProps {
|
||||
onClose: () => void;
|
||||
onUpload: (files: File[]) => void;
|
||||
}
|
||||
|
||||
export default function UploadModal({ onClose, onUpload }: UploadModalProps) {
|
||||
const [dragActive, setDragActive] = useState(false);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const handlePaste = (e: ClipboardEvent) => {
|
||||
const items = e.clipboardData?.items;
|
||||
if (!items) return;
|
||||
|
||||
const files: File[] = [];
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
if (items[i].type.startsWith('image/') || items[i].type === 'application/pdf') {
|
||||
const file = items[i].getAsFile();
|
||||
if (file) files.push(file);
|
||||
}
|
||||
}
|
||||
|
||||
if (files.length > 0) {
|
||||
onUpload(files);
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('paste', handlePaste);
|
||||
return () => document.removeEventListener('paste', handlePaste);
|
||||
}, [onUpload, onClose]);
|
||||
|
||||
const handleDrag = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (e.type === 'dragenter' || e.type === 'dragover') {
|
||||
setDragActive(true);
|
||||
} else if (e.type === 'dragleave') {
|
||||
setDragActive(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleDrop = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setDragActive(false);
|
||||
|
||||
const files = Array.from(e.dataTransfer.files).filter((file) =>
|
||||
file.type.startsWith('image/') || file.type === 'application/pdf'
|
||||
);
|
||||
|
||||
if (files.length > 0) {
|
||||
onUpload(files);
|
||||
onClose();
|
||||
}
|
||||
}, [onUpload, onClose]);
|
||||
|
||||
const handleFileInput = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (e.target.files) {
|
||||
const files = Array.from(e.target.files).filter((file) =>
|
||||
file.type.startsWith('image/') || file.type === 'application/pdf'
|
||||
);
|
||||
|
||||
if (files.length > 0) {
|
||||
onUpload(files);
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-white rounded-xl shadow-xl max-w-2xl w-full p-6">
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<h2 className="text-2xl font-bold text-gray-900">上传文件</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-2 hover:bg-gray-100 rounded-lg transition-colors"
|
||||
>
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
onDragEnter={handleDrag}
|
||||
onDragLeave={handleDrag}
|
||||
onDragOver={handleDrag}
|
||||
onDrop={handleDrop}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
className={`border-2 border-dashed rounded-xl p-12 text-center transition-colors cursor-pointer group ${dragActive
|
||||
? 'border-blue-500 bg-blue-50'
|
||||
: 'border-gray-300 hover:border-blue-400 hover:bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
<div className="w-16 h-16 bg-gray-100 text-gray-600 rounded-full flex items-center justify-center mx-auto mb-4 group-hover:scale-110 transition-transform">
|
||||
<Upload size={32} />
|
||||
</div>
|
||||
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
multiple
|
||||
accept="image/*,application/pdf"
|
||||
onChange={handleFileInput}
|
||||
className="hidden"
|
||||
/>
|
||||
|
||||
<p className="text-xs text-gray-500 mt-4">
|
||||
Support JPG, PNG format
|
||||
</p>
|
||||
|
||||
<div className="flex items-center justify-center gap-4 mt-6 text-xs text-gray-400">
|
||||
<div className="flex items-center gap-1">
|
||||
<MousePointerClick className="w-3.5 h-3.5" />
|
||||
<span>Click</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<FileUp className="w-3.5 h-3.5" />
|
||||
<span>Drop</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<ClipboardPaste className="w-3.5 h-3.5" />
|
||||
<span>Paste</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user