feat: add translate

This commit is contained in:
liuyuanchuang
2026-01-24 13:53:50 +08:00
parent 6747205bd0
commit 42850c4460
12 changed files with 572 additions and 66 deletions

View File

@@ -1,6 +1,7 @@
import { useState } from 'react';
import { X } from 'lucide-react';
import { useAuth } from '../contexts/AuthContext';
import { useLanguage } from '../contexts/LanguageContext';
interface AuthModalProps {
onClose: () => void;
@@ -8,6 +9,7 @@ interface AuthModalProps {
export default function AuthModal({ onClose }: AuthModalProps) {
const { signIn, signUp } = useAuth();
const { t } = useLanguage();
const [isSignUp, setIsSignUp] = useState(false);
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
@@ -41,7 +43,7 @@ export default function AuthModal({ onClose }: AuthModalProps) {
<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 ? '注册账号' : '登录账号'}
{isSignUp ? t.auth.signUpTitle : t.auth.signInTitle}
</h2>
<button
onClick={onClose}
@@ -54,7 +56,7 @@ export default function AuthModal({ onClose }: AuthModalProps) {
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
{t.auth.email}
</label>
<input
type="email"
@@ -68,7 +70,7 @@ export default function AuthModal({ onClose }: AuthModalProps) {
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
{t.auth.password}
</label>
<input
type="password"
@@ -83,7 +85,7 @@ export default function AuthModal({ onClose }: AuthModalProps) {
{error && (
<div className="p-3 bg-red-100 border border-red-400 text-red-700 rounded-lg text-sm font-medium animate-pulse">
: {error}
{t.auth.error}: {error}
</div>
)}
@@ -92,7 +94,7 @@ export default function AuthModal({ onClose }: AuthModalProps) {
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 ? '注册' : '登录'}
{isSignUp ? t.auth.signUp : t.auth.signIn}
</button>
</form>
@@ -101,7 +103,7 @@ export default function AuthModal({ onClose }: AuthModalProps) {
onClick={() => setIsSignUp(!isSignUp)}
className="text-sm text-blue-600 hover:text-blue-700"
>
{isSignUp ? '已有账号?去登录' : '没有账号?去注册'}
{isSignUp ? t.auth.hasAccount : t.auth.noAccount}
</button>
</div>
</div>

View File

@@ -5,6 +5,7 @@ import { convertMathmlToOmml, wrapOmmlForClipboard } from '../lib/ommlConverter'
import { generateImageFromElement, copyImageToClipboard, downloadImage } from '../lib/imageGenerator';
import { API_BASE_URL } from '../config/env';
import { tokenManager } from '../lib/api';
import { useLanguage } from '../contexts/LanguageContext';
interface ExportSidebarProps {
isOpen: boolean;
@@ -24,6 +25,7 @@ interface ExportOption {
}
export default function ExportSidebar({ isOpen, onClose, result }: ExportSidebarProps) {
const { t } = useLanguage();
const [copiedId, setCopiedId] = useState<string | null>(null);
const [exportingId, setExportingId] = useState<string | null>(null);
@@ -127,7 +129,7 @@ export default function ExportSidebar({ isOpen, onClose, result }: ExportSidebar
}, 1000);
} catch (err) {
console.error('Export failed:', err);
alert('导出失败,请重试');
alert(t.export.failed);
} finally {
setExportingId(null);
}
@@ -160,7 +162,7 @@ export default function ExportSidebar({ isOpen, onClose, result }: ExportSidebar
}, 1000);
} catch (err) {
console.error('Failed to generate image:', err);
alert(`生成图片失败: ${err}`);
alert(`${t.export.imageFailed}: ${err}`);
} finally {
setExportingId(null);
}
@@ -228,9 +230,9 @@ export default function ExportSidebar({ isOpen, onClose, result }: ExportSidebar
};
const categories: { id: ExportCategory; icon: LucideIcon; label: string }[] = [
{ id: 'Code', icon: Code2, label: 'Code' },
{ id: 'Image', icon: ImageIcon, label: 'Image' },
{ id: 'File', icon: FileText, label: 'File' },
{ id: 'Code', icon: Code2, label: t.export.categories.code },
{ id: 'Image', icon: ImageIcon, label: t.export.categories.image },
{ id: 'File', icon: FileText, label: t.export.categories.file },
];
return (
@@ -251,7 +253,7 @@ export default function ExportSidebar({ isOpen, onClose, result }: ExportSidebar
`}
>
<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>
<h2 className="text-lg font-bold text-gray-900">{t.export.title}</h2>
<button onClick={onClose} className="p-2 hover:bg-gray-100 rounded-full transition-colors">
<X size={20} className="text-gray-500" />
</button>

View File

@@ -1,12 +1,14 @@
import { useState } from 'react';
import { ChevronLeft, ChevronRight, File as FileIcon, MinusCircle, PlusCircle } from 'lucide-react';
import { FileRecord } from '../types';
import { useLanguage } from '../contexts/LanguageContext';
interface FilePreviewProps {
file: FileRecord | null;
}
export default function FilePreview({ file }: FilePreviewProps) {
const { t } = useLanguage();
const [zoom, setZoom] = useState(100);
const [page, setPage] = useState(1);
const totalPages = 1;
@@ -16,13 +18,13 @@ export default function FilePreview({ file }: FilePreviewProps) {
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="flex-1 flex flex-col items-center justify-center bg-white p-8 text-center border border-white border-solid" id="file-preview-empty">
<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>
<h3 className="text-xl font-semibold text-gray-900 mb-2">{t.common.upload}</h3>
<p className="text-gray-500 max-w-xs">
Click, Drop, or Paste a file to start parsing
{t.sidebar.uploadInstruction}
</p>
</div>
);
@@ -68,7 +70,7 @@ export default function FilePreview({ file }: FilePreviewProps) {
<button
onClick={handleZoomOut}
className="p-1 hover:bg-white hover:shadow-sm rounded-md text-gray-500 transition-all"
title="缩小"
title={t.common.preview}
>
<MinusCircle size={16} />
</button>
@@ -78,7 +80,7 @@ export default function FilePreview({ file }: FilePreviewProps) {
<button
onClick={handleZoomIn}
className="p-1 hover:bg-white hover:shadow-sm rounded-md text-gray-500 transition-all"
title="放大"
title={t.common.preview}
>
<PlusCircle size={16} />
</button>

View File

@@ -1,6 +1,7 @@
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 { useLanguage } from '../contexts/LanguageContext';
import { FileRecord } from '../types';
import AuthModal from './AuthModal';
@@ -30,11 +31,13 @@ export default function LeftSidebar({
onLoadMore,
}: LeftSidebarProps) {
const { user, signOut } = useAuth();
const { t } = useLanguage();
const [showAuthModal, setShowAuthModal] = useState(false);
const [isDragging, setIsDragging] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const listRef = useRef<HTMLDivElement>(null);
// ... (rest of the logic remains the same)
// Handle scroll to load more
const handleScroll = useCallback(() => {
if (!listRef.current || loadingMore || !hasMore) return;
@@ -116,13 +119,13 @@ export default function LeftSidebar({
<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"
title={t.common.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">
<button className="p-2 text-gray-400 hover:text-gray-900 transition-colors" title={t.common.history}>
<History size={20} />
</button>
</div>
@@ -130,7 +133,7 @@ export default function LeftSidebar({
<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'}
title={user ? 'Signed In' : t.common.login}
>
<LogIn size={20} />
</button>
@@ -145,8 +148,8 @@ export default function LeftSidebar({
<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>
<h2 className="text-lg font-bold text-gray-900 leading-tight">{t.sidebar.title}</h2>
<p className="text-xs text-gray-500 mt-1">{t.sidebar.subtitle}</p>
</div>
<button
onClick={onToggleCollapse}
@@ -156,7 +159,7 @@ export default function LeftSidebar({
</button>
</div>
<div className="mb-2">
<div className="mb-2" id="sidebar-upload-area">
<div
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
@@ -181,18 +184,19 @@ export default function LeftSidebar({
<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">
<p className="text-xs text-gray-500 mb-2">{t.sidebar.uploadInstruction}</p>
<div className="flex items-center justify-center gap-4 text-xs text-gray-400">
<div className="flex items-center gap-1">
<MousePointerClick className="w-3.5 h-3.5" />
<span>Click</span>
<span>{t.common.click}</span>
</div>
<div className="flex items-center gap-1">
<FileUp className="w-3.5 h-3.5" />
<span>Drop</span>
<span>{t.common.drop}</span>
</div>
<div className="flex items-center gap-1">
<ClipboardPaste className="w-3.5 h-3.5" />
<span>Paste</span>
<span>{t.common.paste}</span>
</div>
</div>
</div>
@@ -200,10 +204,10 @@ export default function LeftSidebar({
</div>
{/* Middle Area: History */}
<div className="flex-1 overflow-hidden flex flex-col px-4">
<div className="flex-1 overflow-hidden flex flex-col px-4" id="sidebar-history">
<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>
<span>{t.sidebar.historyHeader}</span>
</div>
<div
@@ -214,12 +218,12 @@ export default function LeftSidebar({
{!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
{t.sidebar.pleaseLogin}
</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
{t.sidebar.noHistory}
</div>
) : (
<>
@@ -256,13 +260,13 @@ export default function LeftSidebar({
{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>
<span className="ml-2 text-xs">{t.common.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
{t.sidebar.noMore}
</div>
)}
</>
@@ -285,7 +289,7 @@ export default function LeftSidebar({
<button
onClick={() => signOut()}
className="p-1.5 text-gray-400 hover:text-red-500 hover:bg-red-50 rounded-md transition-colors"
title="Logout"
title={t.common.logout}
>
<LogOut size={16} />
</button>
@@ -296,7 +300,7 @@ export default function LeftSidebar({
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
{t.common.login}
</button>
)}
</div>

View File

@@ -1,11 +1,15 @@
import { useState, useRef, useEffect } from 'react';
import { Mail, Users, MessageCircle, ChevronDown, Check, Heart, X } from 'lucide-react';
import { Mail, Users, MessageCircle, ChevronDown, Check, Heart, X, Languages, HelpCircle } from 'lucide-react';
import { useLanguage } from '../contexts/LanguageContext';
export default function Navbar() {
const { language, setLanguage, t } = useLanguage();
const [showContact, setShowContact] = useState(false);
const [showReward, setShowReward] = useState(false);
const [showLangMenu, setShowLangMenu] = useState(false);
const [copied, setCopied] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(null);
const langMenuRef = useRef<HTMLDivElement>(null);
const handleCopyQQ = async () => {
await navigator.clipboard.writeText('1018282100');
@@ -19,6 +23,9 @@ export default function Navbar() {
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
setShowContact(false);
}
if (langMenuRef.current && !langMenuRef.current.contains(event.target as Node)) {
setShowLangMenu(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
@@ -37,8 +44,59 @@ export default function Navbar() {
</span>
</div>
{/* Right: Reward & Contact Buttons */}
{/* Right: Actions */}
<div className="flex items-center gap-3">
{/* Language Switcher */}
<div className="relative" ref={langMenuRef}>
<button
onClick={() => setShowLangMenu(!showLangMenu)}
className="flex items-center gap-2 px-3 py-2 hover:bg-gray-100 rounded-lg text-gray-700 text-sm font-medium transition-colors"
title="Switch Language"
>
<Languages size={18} />
<span className="hidden sm:inline">{language === 'en' ? 'English' : '简体中文'}</span>
<ChevronDown size={14} className={`transition-transform duration-200 ${showLangMenu ? 'rotate-180' : ''}`} />
</button>
{showLangMenu && (
<div className="absolute right-0 top-full mt-2 w-32 bg-white rounded-xl shadow-lg border border-gray-200 py-1 z-50 animate-in fade-in slide-in-from-top-2 duration-200">
<button
onClick={() => {
setLanguage('en');
setShowLangMenu(false);
}}
className={`w-full flex items-center justify-between px-4 py-2 text-sm transition-colors hover:bg-gray-50 ${language === 'en' ? 'text-blue-600 font-medium' : 'text-gray-700'}`}
>
English
{language === 'en' && <Check size={14} />}
</button>
<button
onClick={() => {
setLanguage('zh');
setShowLangMenu(false);
}}
className={`w-full flex items-center justify-between px-4 py-2 text-sm transition-colors hover:bg-gray-50 ${language === 'zh' ? 'text-blue-600 font-medium' : 'text-gray-700'}`}
>
{language === 'zh' && <Check size={14} />}
</button>
</div>
)}
</div>
{/* User Guide Button */}
<button
id="guide-button"
className="flex items-center gap-2 px-3 py-2 hover:bg-gray-100 rounded-lg text-gray-700 text-sm font-medium transition-colors"
onClick={() => {
// This will be handled in App.tsx via a custom event or shared state
window.dispatchEvent(new CustomEvent('start-user-guide'));
}}
>
<HelpCircle size={18} />
<span className="hidden sm:inline">{t.common.guide}</span>
</button>
{/* Reward Button */}
<div className="relative">
<button
@@ -46,7 +104,7 @@ export default function Navbar() {
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>
<span>{t.common.reward}</span>
</button>
{/* Reward Modal */}
@@ -60,7 +118,7 @@ export default function Navbar() {
onClick={e => e.stopPropagation()}
>
<div className="flex items-center justify-between mb-4">
<span className="text-lg font-bold text-gray-900"></span>
<span className="text-lg font-bold text-gray-900">{t.navbar.rewardTitle}</span>
<button
onClick={() => setShowReward(false)}
className="p-2 hover:bg-gray-100 rounded-lg transition-colors"
@@ -71,12 +129,12 @@ export default function Navbar() {
<div className="flex flex-col items-center">
<img
src="https://cdn.texpixel.com/public/rewardcode.png"
alt="微信赞赏码"
alt={t.navbar.rewardTitle}
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>
{t.navbar.rewardThanks}<br />
<span className="text-xs text-gray-400 mt-1 block">{t.navbar.rewardSubtitle}</span>
</p>
</div>
</div>
@@ -91,7 +149,7 @@ export default function Navbar() {
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>
<span>{t.common.contactUs}</span>
<ChevronDown
size={14}
className={`transition-transform duration-200 ${showContact ? 'rotate-180' : ''}`}
@@ -109,7 +167,7 @@ export default function Navbar() {
<Mail size={16} className="text-blue-600" />
</div>
<div>
<div className="text-xs text-gray-500">Email</div>
<div className="text-xs text-gray-500">{t.common.email}</div>
<div className="text-sm font-medium text-gray-900">yogecoder@gmail.com</div>
</div>
</a>
@@ -126,7 +184,7 @@ export default function Navbar() {
</div>
<div>
<div className={`text-xs transition-colors ${copied ? 'text-green-600 font-medium' : 'text-gray-500'}`}>
{copied ? 'Copied!' : 'QQ Group (Click to Copy)'}
{copied ? t.common.copied : t.common.qqGroup}
</div>
<div className="text-sm font-medium text-gray-900">1018282100</div>
</div>

View File

@@ -7,6 +7,7 @@ import rehypeKatex from 'rehype-katex';
import 'katex/dist/katex.min.css';
import { RecognitionResult } from '../types';
import ExportSidebar from './ExportSidebar';
import { useLanguage } from '../contexts/LanguageContext';
interface ResultPanelProps {
result: RecognitionResult | null;
@@ -67,6 +68,7 @@ function preprocessLatex(content: string): string {
}
export default function ResultPanel({ result, fileStatus }: ResultPanelProps) {
const { t } = useLanguage();
const [isExportSidebarOpen, setIsExportSidebarOpen] = useState(false);
if (!result) {
@@ -75,44 +77,45 @@ export default function ResultPanel({ result, fileStatus }: ResultPanelProps) {
<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...'}
{fileStatus === 'pending' ? t.resultPanel.waitingQueue : t.resultPanel.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.'}
? t.resultPanel.queueSubtitle
: t.resultPanel.processingSubtitle}
</p>
</div>
);
}
return (
<div className="h-full flex flex-col items-center justify-center bg-white text-center p-8">
<div className="h-full flex flex-col items-center justify-center bg-white text-center p-8" id="result-empty-state">
<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>
<h3 className="text-xl font-semibold text-gray-900 mb-2">{t.resultPanel.waitingTitle}</h3>
<p className="text-gray-500 max-w-sm">
After uploading the file, Texpixel will automatically recognize and display the result here
{t.resultPanel.waitingSubtitle}
</p>
</div>
);
}
return (
<div className="flex flex-col h-full bg-white relative overflow-hidden">
<div className="flex flex-col h-full bg-white relative overflow-hidden" id="result-panel-content">
{/* 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>
<h2 className="text-lg font-bold text-gray-900">{t.resultPanel.markdown}</h2>
</div>
<button
id="export-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
{t.common.export}
</button>
</div>

View File

@@ -1,5 +1,6 @@
import { useCallback, useState, useEffect, useRef } from 'react';
import { Upload, X, MousePointerClick, FileUp, ClipboardPaste } from 'lucide-react';
import { useLanguage } from '../contexts/LanguageContext';
interface UploadModalProps {
onClose: () => void;
@@ -7,6 +8,7 @@ interface UploadModalProps {
}
export default function UploadModal({ onClose, onUpload }: UploadModalProps) {
const { t } = useLanguage();
const [dragActive, setDragActive] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
@@ -75,7 +77,7 @@ export default function UploadModal({ onClose, onUpload }: UploadModalProps) {
<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>
<h2 className="text-2xl font-bold text-gray-900">{t.uploadModal.title}</h2>
<button
onClick={onClose}
className="p-2 hover:bg-gray-100 rounded-lg transition-colors"
@@ -99,6 +101,7 @@ export default function UploadModal({ onClose, onUpload }: UploadModalProps) {
<Upload size={32} />
</div>
<p className="text-sm text-gray-600 mb-1">{t.sidebar.uploadInstruction}</p>
<input
ref={fileInputRef}
type="file"
@@ -108,22 +111,22 @@ export default function UploadModal({ onClose, onUpload }: UploadModalProps) {
className="hidden"
/>
<p className="text-xs text-gray-500 mt-4">
Support JPG, PNG format
<p className="text-xs text-gray-500 mb-4">
{t.uploadModal.supportFormats}
</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>
<span>{t.common.click}</span>
</div>
<div className="flex items-center gap-1">
<FileUp className="w-3.5 h-3.5" />
<span>Drop</span>
<span>{t.common.drop}</span>
</div>
<div className="flex items-center gap-1">
<ClipboardPaste className="w-3.5 h-3.5" />
<span>Paste</span>
<span>{t.common.paste}</span>
</div>
</div>
</div>

View File

@@ -0,0 +1,174 @@
import React, { useState, useEffect, useCallback } from 'react';
import { X, ChevronRight, ChevronLeft } from 'lucide-react';
import { useLanguage } from '../contexts/LanguageContext';
interface Step {
id: string;
title: string;
content: string;
position: 'top' | 'bottom' | 'left' | 'right';
}
export default function UserGuide({ isOpen, onClose }: { isOpen: boolean; onClose: () => void }) {
const { t } = useLanguage();
const [currentStep, setCurrentStep] = useState(0);
const [highlightStyle, setHighlightStyle] = useState<React.CSSProperties>({});
const steps: Step[] = [
{
id: 'sidebar-upload-area',
title: t.guide.step1Title,
content: t.guide.step1Content,
position: 'right',
},
{
id: 'sidebar-history',
title: t.guide.step2Title,
content: t.guide.step2Content,
position: 'right',
},
{
id: 'file-preview-empty',
title: t.guide.step3Title,
content: t.guide.step3Content,
position: 'right',
},
{
id: 'result-empty-state',
title: t.guide.step4Title,
content: t.guide.step4Content,
position: 'left',
},
{
id: 'export-button',
title: t.guide.stepExportTitle,
content: t.guide.stepExportContent,
position: 'left',
},
];
const updateHighlight = useCallback(() => {
if (!isOpen || steps.length === 0) return;
const element = document.getElementById(steps[currentStep].id);
if (element) {
const rect = element.getBoundingClientRect();
setHighlightStyle({
top: rect.top - 8,
left: rect.left - 8,
width: rect.width + 16,
height: rect.height + 16,
opacity: 1,
});
element.scrollIntoView({ behavior: 'smooth', block: 'center' });
} else {
setHighlightStyle({ opacity: 0 });
}
}, [currentStep, isOpen, steps, t.guide]);
useEffect(() => {
if (isOpen) {
updateHighlight();
window.addEventListener('resize', updateHighlight);
}
return () => window.removeEventListener('resize', updateHighlight);
}, [isOpen, updateHighlight]);
if (!isOpen) return null;
const handleNext = () => {
if (currentStep < steps.length - 1) {
setCurrentStep(currentStep + 1);
} else {
onClose();
setCurrentStep(0);
}
};
const handlePrev = () => {
if (currentStep > 0) {
setCurrentStep(currentStep - 1);
}
};
return (
<div className="fixed inset-0 z-[100] pointer-events-none">
{/* Backdrop with hole */}
<div className="absolute inset-0 bg-black/60 pointer-events-auto" onClick={onClose} style={{
clipPath: highlightStyle.top !== undefined ? `polygon(
0% 0%, 0% 100%,
${highlightStyle.left}px 100%,
${highlightStyle.left}px ${highlightStyle.top}px,
${(highlightStyle.left as number) + (highlightStyle.width as number)}px ${highlightStyle.top}px,
${(highlightStyle.left as number) + (highlightStyle.width as number)}px ${(highlightStyle.top as number) + (highlightStyle.height as number)}px,
${highlightStyle.left}px ${(highlightStyle.top as number) + (highlightStyle.height as number)}px,
${highlightStyle.left}px 100%,
100% 100%, 100% 0%
)` : 'none'
}} />
{/* Highlight border */}
<div
className="absolute border-2 border-blue-500 rounded-xl transition-all duration-300 shadow-[0_0_0_9999px_rgba(0,0,0,0.5)]"
style={highlightStyle}
/>
{/* Tooltip */}
<div
className="absolute pointer-events-auto bg-white rounded-xl shadow-2xl p-6 w-80 transition-all duration-300 animate-in fade-in zoom-in-95"
style={highlightStyle.top !== undefined ? {
top: steps[currentStep].position === 'bottom'
? (highlightStyle.top as number) + (highlightStyle.height as number) + 16
: steps[currentStep].position === 'top'
? (highlightStyle.top as number) - 200 // approximation
: (highlightStyle.top as number),
left: steps[currentStep].position === 'right'
? (highlightStyle.left as number) + (highlightStyle.width as number) + 16
: steps[currentStep].position === 'left'
? (highlightStyle.left as number) - 336
: (highlightStyle.left as number),
} : {
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
}}
>
<button
onClick={onClose}
className="absolute top-4 right-4 text-gray-400 hover:text-gray-600"
>
<X size={18} />
</button>
<div className="mb-4">
<span className="text-xs font-bold text-blue-600 uppercase tracking-wider">
Step {currentStep + 1} of {steps.length}
</span>
<h3 className="text-lg font-bold text-gray-900 mt-1">{steps[currentStep].title}</h3>
<p className="text-sm text-gray-600 mt-2 leading-relaxed">
{steps[currentStep].content}
</p>
</div>
<div className="flex items-center justify-between mt-6">
<button
onClick={handlePrev}
disabled={currentStep === 0}
className={`flex items-center gap-1 text-sm font-medium ${currentStep === 0 ? 'text-gray-300 cursor-not-allowed' : 'text-gray-600 hover:text-gray-900'}`}
>
<ChevronLeft size={16} />
{t.guide.prev}
</button>
<button
onClick={handleNext}
className="px-4 py-2 bg-blue-600 text-white text-sm font-medium rounded-lg hover:bg-blue-700 transition-colors flex items-center gap-1"
>
{currentStep === steps.length - 1 ? t.guide.finish : t.guide.next}
{currentStep < steps.length - 1 && <ChevronRight size={16} />}
</button>
</div>
</div>
</div>
);
}