Commit 57d488f2 authored by fisherdaddy's avatar fisherdaddy

chore: update

parent c717bfff
...@@ -3,9 +3,11 @@ import '../styles/Timeline.css'; // 复用已有的Timeline样式 ...@@ -3,9 +3,11 @@ import '../styles/Timeline.css'; // 复用已有的Timeline样式
import events from '../data/anthropic-releases.json'; import events from '../data/anthropic-releases.json';
import SEO from '../components/SEO'; import SEO from '../components/SEO';
import { useTranslation } from '../js/i18n'; import { useTranslation } from '../js/i18n';
import { useScrollToTop } from '../hooks/useScrollToTop';
const AnthropicTimeline = () => { const AnthropicTimeline = () => {
const { t } = useTranslation(); const { t } = useTranslation();
useScrollToTop();
return ( return (
<> <>
......
...@@ -3,6 +3,7 @@ import { removeBackground } from "@imgly/background-removal"; ...@@ -3,6 +3,7 @@ import { removeBackground } from "@imgly/background-removal";
import styled from 'styled-components'; import styled from 'styled-components';
import { useTranslation } from '../js/i18n'; import { useTranslation } from '../js/i18n';
import SEO from './SEO'; import SEO from './SEO';
import { useScrollToTop } from '../hooks/useScrollToTop';
// Reuse container style // Reuse container style
const Container = styled.div` const Container = styled.div`
...@@ -157,6 +158,7 @@ const PrivacyNote = styled.div` ...@@ -157,6 +158,7 @@ const PrivacyNote = styled.div`
`; `;
function BackgroundRemover() { function BackgroundRemover() {
useScrollToTop();
const { t } = useTranslation(); const { t } = useTranslation();
const [selectedImage, setSelectedImage] = useState(null); const [selectedImage, setSelectedImage] = useState(null);
const [removedBgImage, setRemovedBgImage] = useState(null); const [removedBgImage, setRemovedBgImage] = useState(null);
......
import React, { useState } from 'react'; import React, { useState, useEffect } from 'react';
import { usePageLoading } from '../hooks/usePageLoading';
import LoadingOverlay from './LoadingOverlay';
import '../styles/HandwriteGen.css'; import '../styles/HandwriteGen.css';
import html2canvas from 'html2canvas'; import html2canvas from 'html2canvas';
import { import {
...@@ -16,6 +18,7 @@ const { Option } = Select; ...@@ -16,6 +18,7 @@ const { Option } = Select;
const { Title } = Typography; const { Title } = Typography;
function HandwritingGenerator() { function HandwritingGenerator() {
const isLoading = usePageLoading();
const [text, setText] = useState(''); const [text, setText] = useState('');
const [font, setFont] = useState("'XINYE'"); const [font, setFont] = useState("'XINYE'");
const [paperType, setPaperType] = useState('Lined Paper'); // 默认值为横线纸 const [paperType, setPaperType] = useState('Lined Paper'); // 默认值为横线纸
...@@ -30,6 +33,15 @@ function HandwritingGenerator() { ...@@ -30,6 +33,15 @@ function HandwritingGenerator() {
const [lineSpacing, setLineSpacing] = useState(1.25); const [lineSpacing, setLineSpacing] = useState(1.25);
const [charSpacing, setCharSpacing] = useState(0); const [charSpacing, setCharSpacing] = useState(0);
useEffect(() => {
// Clear any loading states from previous navigation
const clearLoadingState = () => {
const event = new CustomEvent('clearLoadingState');
window.dispatchEvent(event);
};
clearLoadingState();
}, []);
const handleGenerate = () => { const handleGenerate = () => {
const previewElement = document.querySelector('.preview-area'); const previewElement = document.querySelector('.preview-area');
html2canvas(previewElement, { html2canvas(previewElement, {
...@@ -145,8 +157,9 @@ function HandwritingGenerator() { ...@@ -145,8 +157,9 @@ function HandwritingGenerator() {
const backgroundOffset = -(lineSpacing * fontSize - fontSize); const backgroundOffset = -(lineSpacing * fontSize - fontSize);
return ( return (
<div className="handwrite-container" style={{ paddingTop: '4rem' }}> <>
<Layout> {isLoading && <LoadingOverlay />}
<Layout className="min-h-screen pt-20">
<Sider width={300} className="site-layout-background"> <Sider width={300} className="site-layout-background">
<div className="settings-section"> <div className="settings-section">
<h2 className="title-label">手写字体生成器</h2> <h2 className="title-label">手写字体生成器</h2>
...@@ -314,7 +327,7 @@ function HandwritingGenerator() { ...@@ -314,7 +327,7 @@ function HandwritingGenerator() {
</Content> </Content>
</Layout> </Layout>
</Layout> </Layout>
</div> </>
); );
} }
......
// ImageBase64Converter.jsx // ImageBase64Converter.jsx
import React, { useState, useCallback, useRef } from 'react';
import React, { useState, useCallback } from 'react';
import { Title, Wrapper, Container } from '../js/SharedStyles';
import styled from 'styled-components';
import { useTranslation } from '../js/i18n'; import { useTranslation } from '../js/i18n';
import SEO from '../components/SEO'; import SEO from './SEO';
import styled from 'styled-components';
const ConverterContainer = styled(Container)` import { usePageLoading } from '../hooks/usePageLoading';
flex-direction: column; import LoadingOverlay from './LoadingOverlay';
gap: 24px;
padding: 24px; // 复用相同的样式组件
background: rgba(255, 255, 255, 0.8); const Container = styled.div`
backdrop-filter: blur(10px); min-height: 100vh;
border: 1px solid rgba(99, 102, 241, 0.1); background: linear-gradient(135deg, #f5f7ff 0%, #ffffff 100%);
border-radius: 12px; padding: 4rem 2rem 2rem;
padding-top: 4rem; // 添加顶部内边距 position: relative;
`;
&::before {
const Section = styled.div` content: '';
width: 100%; position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background:
linear-gradient(90deg, rgba(99, 102, 241, 0.05) 1px, transparent 1px),
linear-gradient(rgba(99, 102, 241, 0.05) 1px, transparent 1px);
background-size: 20px 20px;
pointer-events: none;
}
`; `;
const Label = styled.label` const ContentWrapper = styled.div`
font-weight: 500; max-width: 1400px;
font-size: 14px; margin: 0 auto;
color: #374151; position: relative;
margin-bottom: 12px; z-index: 1;
display: block;
letter-spacing: 0.1px;
`; `;
const StyledInputText = styled.textarea` const Title = styled.h2`
width: 100%; font-size: 1.8rem;
height: 120px; margin-bottom: 1.5rem;
font-size: 15px; background: linear-gradient(135deg, #6366F1 0%, #4F46E5 100%);
padding: 16px; -webkit-background-clip: text;
border: 1px solid rgba(99, 102, 241, 0.1); -webkit-text-fill-color: transparent;
border-radius: 12px; font-weight: 700;
background: rgba(255, 255, 255, 0.8); letter-spacing: -0.02em;
backdrop-filter: blur(10px); text-align: center;
box-sizing: border-box;
outline: none;
resize: none;
transition: all 0.3s ease;
line-height: 1.5;
&:focus {
border-color: rgba(99, 102, 241, 0.3);
box-shadow: 0 0 0 4px rgba(99, 102, 241, 0.1);
}
`; `;
const FileInputWrapper = styled.div` const FileInputWrapper = styled.label`
position: relative; position: relative;
width: 100%; width: 100%;
height: 120px; height: 200px;
border: 2px dashed rgba(99, 102, 241, 0.2); border: 2px dashed rgba(99, 102, 241, 0.2);
border-radius: 12px; border-radius: 12px;
display: flex; display: flex;
...@@ -62,6 +57,8 @@ const FileInputWrapper = styled.div` ...@@ -62,6 +57,8 @@ const FileInputWrapper = styled.div`
justify-content: center; justify-content: center;
cursor: pointer; cursor: pointer;
transition: all 0.3s ease; transition: all 0.3s ease;
background: rgba(255, 255, 255, 0.8);
backdrop-filter: blur(10px);
&:hover { &:hover {
border-color: rgba(99, 102, 241, 0.4); border-color: rgba(99, 102, 241, 0.4);
...@@ -78,255 +75,282 @@ const FileInputWrapper = styled.div` ...@@ -78,255 +75,282 @@ const FileInputWrapper = styled.div`
span { span {
color: #6366F1; color: #6366F1;
font-size: 14px; font-size: 1rem;
display: flex; display: flex;
align-items: center; align-items: center;
gap: 8px; gap: 8px;
pointer-events: none;
} }
`; `;
const ResultContainer = styled.div` const ResultArea = styled.div`
position: relative;
width: 100%; width: 100%;
min-height: 200px;
padding: 1rem;
background: rgba(255, 255, 255, 0.8); background: rgba(255, 255, 255, 0.8);
backdrop-filter: blur(10px); backdrop-filter: blur(10px);
border-radius: 12px;
border: 1px solid rgba(99, 102, 241, 0.1); border: 1px solid rgba(99, 102, 241, 0.1);
padding: 16px; border-radius: 12px;
min-height: 120px; position: relative;
textarea {
width: 100%;
min-height: 180px;
border: none;
background: transparent;
resize: vertical;
outline: none;
font-family: monospace;
font-size: 0.875rem;
line-height: 1.5;
}
`; `;
const ActionButton = styled.button` const ActionButton = styled.button`
background: rgba(99, 102, 241, 0.1); display: inline-flex;
border: none; align-items: center;
border-radius: 6px; gap: 1.5px;
padding: 6px 12px; padding: 6px 12px;
font-size: 0.875rem;
font-weight: 500;
border-radius: 0.5rem;
transition: all 0.2s;
border: none;
cursor: pointer; cursor: pointer;
display: flex;
align-items: center;
gap: 6px;
font-size: 13px;
color: #6366F1;
transition: all 0.3s ease;
&:hover {
background: rgba(99, 102, 241, 0.2);
}
&.active { ${props => props.variant === 'success' && `
background: #6366F1; background-color: #DEF7EC;
color: white; color: #03543F;
} `}
${props => !props.variant && `
background-color: rgba(255, 255, 255, 0.5);
color: #4B5563;
&:hover {
background-color: #EEF2FF;
color: #4F46E5;
}
`}
svg { &:disabled {
width: 14px; opacity: 0.5;
height: 14px; cursor: not-allowed;
} }
`; `;
const ImagePreviewContainer = styled.div` const PreviewImage = styled.img`
position: relative; max-width: 100%;
width: 100%; max-height: 300px;
margin-top: 8px; margin: 1rem 0;
display: flex;
flex-direction: column;
gap: 12px;
`;
const ThumbnailContainer = styled.div`
position: relative;
width: 120px;
height: 120px;
border-radius: 8px; border-radius: 8px;
overflow: hidden; box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
border: 1px solid rgba(99, 102, 241, 0.1);
`;
const PreviewActions = styled.div`
display: flex;
gap: 8px;
margin-top: 8px;
`;
const Thumbnail = styled.img`
width: 100%;
height: 100%;
object-fit: cover;
`; `;
const ImageDetails = styled.div` const ImageDetails = styled.div`
font-size: 13px; font-size: 0.875rem;
color: #6B7280; color: #6B7280;
margin-top: 4px; margin-top: 0.5rem;
`;
const ErrorText = styled.div`
color: #EF4444;
font-size: 14px;
margin-top: 8px;
`; `;
function ImageBase64Converter() { function ImageBase64Converter() {
const { t } = useTranslation(); const { t } = useTranslation();
const isLoading = usePageLoading();
// 图片转 Base64 的状态
const [imageFile, setImageFile] = useState(null);
const [base64String, setBase64String] = useState(''); const [base64String, setBase64String] = useState('');
const [previewUrl, setPreviewUrl] = useState('');
const [isCopied, setIsCopied] = useState(false); const [isCopied, setIsCopied] = useState(false);
const [imageFile, setImageFile] = useState(null);
// Base64 转图片的状态
const [inputBase64, setInputBase64] = useState('');
const [imageSrc, setImageSrc] = useState('');
const [error, setError] = useState(''); const [error, setError] = useState('');
const fileInputRef = useRef(null);
// 清除所有状态
const clearStates = () => {
setBase64String('');
setPreviewUrl('');
setImageFile(null);
setError('');
};
// 处理图片上传 // 处理图片转 Base64
const handleImageUpload = (e) => { const handleFileChange = (event) => {
const file = e.target.files[0]; const file = event.target.files[0];
if (file) { if (file) {
setImageFile(file); setImageFile(file);
const reader = new FileReader(); const reader = new FileReader();
reader.onloadend = () => { reader.onload = (e) => {
setBase64String(reader.result); const base64 = e.target.result;
setBase64String(base64);
setPreviewUrl(base64);
setError('');
};
reader.onerror = () => {
setError(t('tools.imageBase64Converter.readError'));
}; };
reader.readAsDataURL(file); reader.readAsDataURL(file);
} }
// 重置 input 的 value,这样同一个文件也能触发 change 事件
event.target.value = '';
}; };
// 复制 Base64 字符串 // 处理 Base64 转图片
const handleCopy = useCallback(() => { const handleBase64Input = (event) => {
navigator.clipboard.writeText(base64String).then(() => { const input = event.target.value;
setIsCopied(true); setBase64String(input);
setTimeout(() => setIsCopied(false), 2000); setError('');
});
}, [base64String]); if (!input) {
setPreviewUrl('');
setImageFile(null);
return;
}
// 处理 Base64 输入变化 try {
const handleBase64InputChange = (e) => { // 尝试验证和修复 base64 字符串
const input = e.target.value.trim(); let validBase64 = input.trim();
setInputBase64(input);
if (input) { // 如果不是以 data:image 开头,尝试添加
let src = input; if (!validBase64.startsWith('data:image')) {
if (!input.startsWith('data:image/')) { // 检查是否只包含 base64 字符
// 尝试自动推断图片类型 const base64Regex = /^[A-Za-z0-9+/=]+$/;
const match = input.match(/^data:(image\/[a-zA-Z]+);base64,/); if (base64Regex.test(validBase64)) {
let mimeType = 'image/png'; // 默认类型 validBase64 = `data:image/png;base64,${validBase64}`;
if (match && match[1]) {
mimeType = match[1];
} }
src = `data:${mimeType};base64,${input}`;
} }
setImageSrc(src);
setError('');
} else {
setImageSrc('');
setError('');
}
};
// 图片加载错误处理 // 创建一个新的图片对象来验证 base64 字符串
const handleImageError = () => { const img = new Image();
setError(t('tools.imageBase64Converter.invalidBase64')); img.onload = () => {
setImageSrc(''); setPreviewUrl(validBase64);
setError('');
};
img.onerror = () => {
setPreviewUrl('');
setError(t('tools.imageBase64Converter.invalidBase64'));
};
img.src = validBase64;
} catch (err) {
setPreviewUrl('');
setError(t('tools.imageBase64Converter.invalidBase64'));
}
}; };
// 下载图片 // 下载图片
const handleDownload = () => { const handleDownload = () => {
if (!previewUrl) return;
const link = document.createElement('a'); const link = document.createElement('a');
link.href = imageSrc; link.href = previewUrl;
// 尝试从 Base64 字符串中提取文件类型和扩展名 link.download = imageFile ? imageFile.name : 'image.png';
let fileName = 'downloaded_image';
const match = imageSrc.match(/^data:(image\/[a-zA-Z]+);base64,/);
if (match && match[1]) {
const mime = match[1];
const extension = mime.split('/')[1];
fileName += `.${extension}`;
} else {
fileName += `.png`;
}
link.download = fileName;
document.body.appendChild(link); document.body.appendChild(link);
link.click(); link.click();
document.body.removeChild(link); document.body.removeChild(link);
}; };
const handleCopy = useCallback(() => {
navigator.clipboard.writeText(base64String).then(() => {
setIsCopied(true);
setTimeout(() => setIsCopied(false), 2000);
});
}, [base64String]);
return ( return (
<> <>
{isLoading && <LoadingOverlay />}
<SEO <SEO
title={t('tools.imageBase64Converter.title')} title={t('tools.imageBase64Converter.title')}
description={t('tools.imageBase64Converter.description')} description={t('tools.imageBase64Converter.description')}
/> />
<Wrapper> <Container>
<Title>{t('tools.imageBase64Converter.title')}</Title> <ContentWrapper>
<ConverterContainer> <Title>{t('tools.imageBase64Converter.title')}</Title>
<Section>
<Label>{t('tools.imageBase64Converter.imageToBase64')}</Label> <div className="flex flex-col gap-6">
<FileInputWrapper> <div className="space-y-2">
<input <div className="block text-sm font-medium text-gray-700">
type="file" {t('tools.imageBase64Converter.imageToBase64')}
accept="image/*" </div>
onChange={handleImageUpload} <FileInputWrapper>
/> <input
<span> type="file"
<svg viewBox="0 0 24 24" width="24" height="24" fill="currentColor"> accept="image/*"
<path d="M19 7v2.99s-1.99.01-2 0V7h-3s.01-1.99 0-2h3V2h2v3h3v2h-3zm-3 4V8h-3V5H5c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2v-8h-3zM5 19l3-4 2 3 3-4 4 5H5z"/> onChange={handleFileChange}
</svg> />
{t('tools.imageBase64Converter.dragOrClick')} <span>
</span> <svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
</FileInputWrapper> <path fillRule="evenodd" d="M4 3a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V5a2 2 0 00-2-2H4zm12 12H4l4-8 3 6 2-4 3 6z" clipRule="evenodd" />
{base64String && ( </svg>
<ResultContainer> {t('tools.imageBase64Converter.dragOrClick')}
<pre>{base64String}</pre> </span>
<ActionButton onClick={handleCopy} className={isCopied ? 'active' : ''}> </FileInputWrapper>
</div>
<div className="space-y-2">
<label className="block text-sm font-medium text-gray-700">
{t('tools.imageBase64Converter.base64ToImage')}
</label>
<ResultArea>
<textarea
value={base64String}
onChange={handleBase64Input}
placeholder={t('tools.imageBase64Converter.base64InputPlaceholder')}
/>
</ResultArea>
<div className="flex justify-end gap-2">
{error && (
<div className="text-red-500 text-sm flex-1 pt-2">
{error}
</div>
)}
<ActionButton
onClick={handleCopy}
disabled={!base64String}
variant={isCopied ? 'success' : undefined}
>
{isCopied ? ( {isCopied ? (
<svg viewBox="0 0 24 24" fill="currentColor"> <>
<path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z"/> <svg className="w-4 h-4" viewBox="0 0 24 24" fill="currentColor">
</svg> <path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z"/>
</svg>
{t('copied')}
</>
) : ( ) : (
<svg viewBox="0 0 24 24" fill="currentColor"> <>
<path d="M16 1H4c-1.1 0-2 .9-2 2v14h2V3h12V1zm3 4H8c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h11c1.1 0 2-.9 2-2V7c0-1.1-.9-2-2-2zm0 16H8V7h11v14z"/> <svg className="w-4 h-4" viewBox="0 0 24 24" fill="currentColor">
</svg> <path d="M16 1H4c-1.1 0-2 .9-2 2v14h2V3h12V1zm3 4H8c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h11c1.1 0 2-.9 2-2V7c0-1.1-.9-2-2-2zm0 16H8V7h11v14z"/>
</svg>
{t('copy')}
</>
)} )}
{isCopied ? t('tools.jsonFormatter.copiedMessage') : t('tools.jsonFormatter.copyButton')}
</ActionButton> </ActionButton>
<ThumbnailContainer> </div>
<Thumbnail src={base64String} alt="Preview" /> </div>
</ThumbnailContainer>
{previewUrl && (
<div className="space-y-2">
<div className="flex justify-between items-center">
<label className="block text-sm font-medium text-gray-700">
{t('tools.imageBase64Converter.preview')}
</label>
<ActionButton
onClick={handleDownload}
>
<svg className="w-4 h-4" viewBox="0 0 24 24" fill="currentColor">
<path d="M19 9h-4V3H9v6H5l7 7 7-7zM5 18v2h14v-2H5z"/>
</svg>
{t('tools.imageBase64Converter.download')}
</ActionButton>
</div>
<PreviewImage src={previewUrl} alt="Preview" />
{imageFile && ( {imageFile && (
<ImageDetails> <ImageDetails>
{t('tools.imageBase64Converter.fileName')}: {imageFile.name}<br /> {t('tools.imageBase64Converter.fileName')}: {imageFile.name}<br />
{t('tools.imageBase64Converter.fileSize')}: {(imageFile.size / 1024).toFixed(2)} KB {t('tools.imageBase64Converter.fileSize')}: {(imageFile.size / 1024).toFixed(2)} KB
</ImageDetails> </ImageDetails>
)} )}
</ResultContainer> </div>
)}
</Section>
<Section>
<Label>{t('tools.imageBase64Converter.base64ToImage')}</Label>
<StyledInputText
value={inputBase64}
onChange={handleBase64InputChange}
placeholder={t('tools.imageBase64Converter.base64InputPlaceholder')}
/>
{error && <ErrorText>{error}</ErrorText>}
{imageSrc && (
<ImagePreviewContainer>
<ThumbnailContainer>
<Thumbnail src={imageSrc} alt="Thumbnail" onError={handleImageError} />
</ThumbnailContainer>
<PreviewActions>
<ActionButton onClick={handleDownload}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v2a2 2 0 002 2h12a2 2 0 002-2v-2M12 12v9m0 0l-3-3m3 3l3-3"/>
</svg>
{t('tools.imageBase64Converter.download')}
</ActionButton>
</PreviewActions>
</ImagePreviewContainer>
)} )}
</Section> </div>
</ConverterContainer> </ContentWrapper>
</Wrapper> </Container>
</> </>
); );
} }
......
...@@ -3,6 +3,8 @@ import styled from 'styled-components'; ...@@ -3,6 +3,8 @@ import styled from 'styled-components';
import { useTranslation } from '../js/i18n'; import { useTranslation } from '../js/i18n';
import SEO from './SEO'; import SEO from './SEO';
import imageCompression from 'browser-image-compression'; import imageCompression from 'browser-image-compression';
import { usePageLoading } from '../hooks/usePageLoading';
import LoadingOverlay from './LoadingOverlay';
// 复用 MarkdownToImage 的容器样式 // 复用 MarkdownToImage 的容器样式
const Container = styled.div` const Container = styled.div`
...@@ -402,6 +404,7 @@ const truncateFilename = (filename, maxLength = 10) => { ...@@ -402,6 +404,7 @@ const truncateFilename = (filename, maxLength = 10) => {
function ImageCompressor() { function ImageCompressor() {
const { t } = useTranslation(); const { t } = useTranslation();
const isLoading = usePageLoading();
const [images, setImages] = useState([]); // 修改为数组存储多张图片 const [images, setImages] = useState([]); // 修改为数组存储多张图片
const [compressedImages, setCompressedImages] = useState([]); const [compressedImages, setCompressedImages] = useState([]);
const [settings, setSettings] = useState({ const [settings, setSettings] = useState({
...@@ -523,6 +526,7 @@ function ImageCompressor() { ...@@ -523,6 +526,7 @@ function ImageCompressor() {
return ( return (
<> <>
{isLoading && <LoadingOverlay />}
<SEO <SEO
title={t('tools.imageCompressor.title')} title={t('tools.imageCompressor.title')}
description={t('tools.imageCompressor.description')} description={t('tools.imageCompressor.description')}
......
...@@ -2,6 +2,8 @@ import React, { useState, useRef, useCallback, useEffect } from 'react'; ...@@ -2,6 +2,8 @@ import React, { useState, useRef, useCallback, useEffect } from 'react';
import styled from 'styled-components'; import styled from 'styled-components';
import { useTranslation } from '../js/i18n'; import { useTranslation } from '../js/i18n';
import SEO from './SEO'; import SEO from './SEO';
import { usePageLoading } from '../hooks/usePageLoading';
import LoadingOverlay from './LoadingOverlay';
// 复用 MarkdownToImage 的基础容器样式 // 复用 MarkdownToImage 的基础容器样式
const Container = styled.div` const Container = styled.div`
...@@ -164,6 +166,7 @@ const PrivacyNote = styled.div` ...@@ -164,6 +166,7 @@ const PrivacyNote = styled.div`
function ImageWatermark() { function ImageWatermark() {
const { t } = useTranslation(); const { t } = useTranslation();
const isLoading = usePageLoading();
const [image, setImage] = useState(null); const [image, setImage] = useState(null);
const [watermarkText, setWatermarkText] = useState(''); const [watermarkText, setWatermarkText] = useState('');
const [watermarkImage, setWatermarkImage] = useState(null); const [watermarkImage, setWatermarkImage] = useState(null);
...@@ -339,6 +342,7 @@ function ImageWatermark() { ...@@ -339,6 +342,7 @@ function ImageWatermark() {
return ( return (
<> <>
{isLoading && <LoadingOverlay />}
<SEO <SEO
title={t('tools.imageWatermark.title')} title={t('tools.imageWatermark.title')}
description={t('tools.imageWatermark.description')} description={t('tools.imageWatermark.description')}
......
...@@ -2,6 +2,8 @@ import React, { useState, useEffect } from 'react'; ...@@ -2,6 +2,8 @@ import React, { useState, useEffect } from 'react';
import { useTranslation } from '../js/i18n'; import { useTranslation } from '../js/i18n';
import SEO from '../components/SEO'; import SEO from '../components/SEO';
import styled from 'styled-components'; import styled from 'styled-components';
import { usePageLoading } from '../hooks/usePageLoading';
import LoadingOverlay from './LoadingOverlay';
const Container = styled.div` const Container = styled.div`
min-height: 100vh; min-height: 100vh;
...@@ -48,6 +50,7 @@ function JsonFormatter() { ...@@ -48,6 +50,7 @@ function JsonFormatter() {
const [parsedJson, setParsedJson] = useState(null); const [parsedJson, setParsedJson] = useState(null);
const [isCopied, setIsCopied] = useState(false); const [isCopied, setIsCopied] = useState(false);
const [isCompressed, setIsCompressed] = useState(false); const [isCompressed, setIsCompressed] = useState(false);
const isLoading = usePageLoading();
useEffect(() => { useEffect(() => {
try { try {
...@@ -80,6 +83,7 @@ function JsonFormatter() { ...@@ -80,6 +83,7 @@ function JsonFormatter() {
return ( return (
<> <>
{isLoading && <LoadingOverlay />}
<SEO <SEO
title={t('tools.jsonFormatter.title')} title={t('tools.jsonFormatter.title')}
description={t('tools.jsonFormatter.description')} description={t('tools.jsonFormatter.description')}
......
...@@ -6,6 +6,8 @@ import DOMPurify from 'dompurify'; ...@@ -6,6 +6,8 @@ import DOMPurify from 'dompurify';
import SEO from './SEO'; import SEO from './SEO';
import { useTranslation } from '../js/i18n'; import { useTranslation } from '../js/i18n';
import html2canvas from 'html2canvas'; import html2canvas from 'html2canvas';
import { usePageLoading } from '../hooks/usePageLoading';
import LoadingOverlay from './LoadingOverlay';
// 容器样式 // 容器样式
const Container = styled.div` const Container = styled.div`
...@@ -144,6 +146,7 @@ const DownloadButton = styled.button` ...@@ -144,6 +146,7 @@ const DownloadButton = styled.button`
function HtmlPreview() { function HtmlPreview() {
const [html, setHtml] = useState(''); const [html, setHtml] = useState('');
const { t } = useTranslation(); const { t } = useTranslation();
const isLoading = usePageLoading();
// 处理 LaTeX 公式 // 处理 LaTeX 公式
const processLatex = (content) => { const processLatex = (content) => {
...@@ -269,6 +272,7 @@ function HtmlPreview() { ...@@ -269,6 +272,7 @@ function HtmlPreview() {
return ( return (
<> <>
{isLoading && <LoadingOverlay />}
<SEO <SEO
title={t('tools.latex2image.title')} title={t('tools.latex2image.title')}
description={t('tools.latex2image.description')} description={t('tools.latex2image.description')}
......
import React from 'react';
const LoadingOverlay = () => {
return (
<div className="fixed inset-0 bg-white/80 flex items-center justify-center z-50">
<div className="flex flex-col items-center gap-4">
<div className="w-12 h-12 border-4 border-indigo-500 border-t-transparent rounded-full animate-spin"></div>
<div className="text-indigo-600 font-medium">Loading...</div>
</div>
</div>
);
};
export default LoadingOverlay;
import React, { useState, useRef } from 'react'; import React, { useState, useRef, useEffect } from 'react';
import styled from 'styled-components'; import styled from 'styled-components';
import { marked } from 'marked'; import { marked } from 'marked';
import { useTranslation } from '../js/i18n'; import { useTranslation } from '../js/i18n';
import SEO from './SEO'; import SEO from './SEO';
import DOMPurify from 'dompurify'; import DOMPurify from 'dompurify';
import { usePageLoading } from '../hooks/usePageLoading';
import LoadingOverlay from './LoadingOverlay';
// 更新预设模板 // 更新预设模板
const templates = [ const templates = [
...@@ -303,11 +305,12 @@ const Preview = styled.div` ...@@ -303,11 +305,12 @@ const Preview = styled.div`
hyphens: auto; hyphens: auto;
`; `;
function TextToImage() { function MarkdownToImage() {
const { t } = useTranslation(); const { t } = useTranslation();
const [text, setText] = useState(''); const [text, setText] = useState('');
const [selectedTemplate, setSelectedTemplate] = useState(templates[0]); const [selectedTemplate, setSelectedTemplate] = useState(templates[0]);
const previewRef = useRef(null); const previewRef = useRef(null);
const isLoading = usePageLoading();
const formatText = (text) => { const formatText = (text) => {
return marked.parse(text, { return marked.parse(text, {
...@@ -419,6 +422,7 @@ function TextToImage() { ...@@ -419,6 +422,7 @@ function TextToImage() {
return ( return (
<> <>
{isLoading && <LoadingOverlay />}
<SEO <SEO
title={t('tools.markdown2image.title')} title={t('tools.markdown2image.title')}
description={t('tools.markdown2image.description')} description={t('tools.markdown2image.description')}
...@@ -473,4 +477,4 @@ function TextToImage() { ...@@ -473,4 +477,4 @@ function TextToImage() {
); );
} }
export default TextToImage; export default MarkdownToImage;
\ No newline at end of file \ No newline at end of file
import React from 'react'; import React from 'react';
import { useScrollToTop } from '../hooks/useScrollToTop';
import '../styles/Timeline.css'; import '../styles/Timeline.css';
import events from '../data/openai-releases.json'; import events from '../data/openai-releases.json';
import SEO from '../components/SEO'; import SEO from '../components/SEO';
import { useTranslation } from '../js/i18n'; import { useTranslation } from '../js/i18n';
const Timeline = () => { const Timeline = () => {
useScrollToTop();
const { t } = useTranslation(); const { t } = useTranslation();
return ( return (
......
// PricingChart.jsx // PricingChart.jsx
import React, { useState } from 'react'; import React, { useState } from 'react';
import { useScrollToTop } from '../hooks/useScrollToTop';
import '../styles/PricingChart.css'; import '../styles/PricingChart.css';
const ChartLegend = ({ onLegendClick, highlightedBarTypes }) => { const ChartLegend = ({ onLegendClick, highlightedBarTypes }) => {
...@@ -99,6 +100,7 @@ const GridLines = () => ( ...@@ -99,6 +100,7 @@ const GridLines = () => (
); );
const PricingChart = ({ data }) => { const PricingChart = ({ data }) => {
useScrollToTop();
const [highlightedBarTypes, setHighlightedBarTypes] = useState({ const [highlightedBarTypes, setHighlightedBarTypes] = useState({
input: true, input: true,
output: true, output: true,
......
import React from 'react'; import React from 'react';
import { useScrollToTop } from '../hooks/useScrollToTop';
import PricingChart from '../components/PricingChart'; import PricingChart from '../components/PricingChart';
import OpenaiPricing from '../data/openai-pricing.json'; import OpenaiPricing from '../data/openai-pricing.json';
import LLMPricing from '../data/llm-pricing.json'; import LLMPricing from '../data/llm-pricing.json';
...@@ -6,6 +7,7 @@ import VisionPricing from '../data/vision-model-pricing.json'; ...@@ -6,6 +7,7 @@ import VisionPricing from '../data/vision-model-pricing.json';
import SEO from '../components/SEO'; import SEO from '../components/SEO';
const PricingCharts = () => { const PricingCharts = () => {
useScrollToTop();
const lastUpdateTime = '2024-11-06 21:30'; const lastUpdateTime = '2024-11-06 21:30';
return ( return (
......
...@@ -2,6 +2,7 @@ import React, { useState, useRef } from 'react'; ...@@ -2,6 +2,7 @@ import React, { useState, useRef } from 'react';
import styled from 'styled-components'; import styled from 'styled-components';
import html2canvas from 'html2canvas'; import html2canvas from 'html2canvas';
import { useTranslation } from '../js/i18n'; import { useTranslation } from '../js/i18n';
import { useScrollToTop } from '../hooks/useScrollToTop';
// 更新中文字体数组,包含显示名称和 CSS 字体族名称 // 更新中文字体数组,包含显示名称和 CSS 字体族名称
const chineseFonts = [ const chineseFonts = [
...@@ -203,6 +204,7 @@ const DownloadButton = styled.button` ...@@ -203,6 +204,7 @@ const DownloadButton = styled.button`
`; `;
function QuoteCard() { function QuoteCard() {
useScrollToTop();
const { t } = useTranslation(); const { t } = useTranslation();
const [chineseText, setChineseText] = useState(''); const [chineseText, setChineseText] = useState('');
......
import React, { useState, useRef, useEffect } from 'react'; import React, { useState, useRef, useEffect } from 'react';
import styled from 'styled-components'; import styled from 'styled-components';
import { useTranslation } from '../js/i18n'; import { useTranslation } from '../js/i18n';
import { usePageLoading } from '../hooks/usePageLoading';
import LoadingOverlay from './LoadingOverlay';
import SEO from './SEO';
const SubtitleMaker = () => {
const { t } = useTranslation();
const [imageSrc, setImageSrc] = useState(null);
const [subtitles, setSubtitles] = useState([{ text: '' }]);
const canvasRef = useRef(null);
const [finalImage, setFinalImage] = useState(null);
const [globalSettings, setGlobalSettings] = useState({
fontSize: 48,
lineHeight: 100,
strokeWidth: 2,
textColor: '#FFE135',
strokeColor: '#000000'
});
const fileInputRef = useRef(null);
// 预设的字幕颜色选项 - 只保留最常用的几个
const presetColors = [
{ name: t('tools.subtitleGenerator.presetColors.classicYellow'), value: '#FFE135' },
{ name: t('tools.subtitleGenerator.presetColors.pureWhite'), value: '#FFFFFF' },
{ name: t('tools.subtitleGenerator.presetColors.vividOrange'), value: '#FFA500' },
{ name: t('tools.subtitleGenerator.presetColors.neonGreen'), value: '#ADFF2F' },
{ name: t('tools.subtitleGenerator.presetColors.lightBlue'), value: '#00FFFF' },
{ name: t('tools.subtitleGenerator.presetColors.brightPink'), value: '#FF69B4' },
];
// 处理图片上传
const handleImageUpload = (e) => {
const file = e.target.files[0];
if (file) {
const reader = new FileReader();
reader.onload = () => {
setImageSrc(reader.result);
}
reader.readAsDataURL(file);
}
}
// 更新字幕内容
const updateSubtitle = (index, text) => {
const newSubtitles = [...subtitles];
newSubtitles[index] = { text };
setSubtitles(newSubtitles);
};
// 删除字幕行
const removeSubtitleLine = (index) => {
setSubtitles(subtitles.filter((_, i) => i !== index));
};
// 增加字幕行
const addSubtitleLine = () => {
setSubtitles([...subtitles, { text: '' }]);
};
// 绘制字幕到canvas
useEffect(() => {
if (imageSrc) {
const canvas = canvasRef.current;
const ctx = canvas.getContext('2d');
const image = new Image();
image.src = imageSrc;
image.onload = () => {
// 计算总高度
const totalHeight = image.height +
subtitles.slice(1).length * (globalSettings.lineHeight + 1);
canvas.width = image.width;
canvas.height = totalHeight;
// 绘制图片
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.drawImage(image, 0, 0);
// 获取第一行字幕区域的背景图像数据
const subtitleBackgroundData = ctx.getImageData(
0,
image.height - globalSettings.lineHeight,
canvas.width,
globalSettings.lineHeight
);
// 设置字体和对齐方式
ctx.textAlign = 'center';
ctx.font = `${globalSettings.fontSize}px Arial`;
ctx.lineWidth = globalSettings.strokeWidth;
// 绘制第一行字幕
let yPosition = image.height - globalSettings.lineHeight;
ctx.strokeStyle = globalSettings.strokeColor;
ctx.fillStyle = globalSettings.textColor;
const firstTextY = yPosition + globalSettings.lineHeight / 2 + globalSettings.fontSize / 3;
ctx.strokeText(subtitles[0].text, canvas.width / 2, firstTextY);
ctx.fillText(subtitles[0].text, canvas.width / 2, firstTextY);
// 设置 yPosition 为图片底部,准备绘制后续字幕
yPosition = image.height;
// 绘制后续字幕行
subtitles.slice(1).forEach((subtitle) => {
// 绘制分隔线
ctx.fillStyle = '#e5e7eb';
ctx.fillRect(0, yPosition, canvas.width, 1);
yPosition += 0;
// 绘制背景(使用第一行字幕的背景)
ctx.putImageData(subtitleBackgroundData, 0, yPosition);
// 绘制字幕文字
ctx.strokeStyle = globalSettings.strokeColor;
ctx.fillStyle = globalSettings.textColor;
const textY = yPosition + globalSettings.lineHeight / 2 + globalSettings.fontSize / 3;
ctx.strokeText(subtitle.text, canvas.width / 2, textY);
ctx.fillText(subtitle.text, canvas.width / 2, textY);
yPosition += globalSettings.lineHeight;
});
setFinalImage(canvas.toDataURL('image/png'));
};
}
}, [imageSrc, subtitles, globalSettings]);
// 下载最终的图片
const downloadImage = () => {
if (finalImage) {
const link = document.createElement('a');
link.href = finalImage;
link.download = 'subtitle-image.png';
link.click();
}
}
return (
<Container>
<SettingsPanel>
<h3>{t('tools.subtitleGenerator.uploadImage')}</h3>
<UploadButton onClick={() => fileInputRef.current.click()}>
<HiddenFileInput
ref={fileInputRef}
type="file"
accept="image/*"
onChange={handleImageUpload}
/>
{t('tools.subtitleGenerator.dropOrClick')}
</UploadButton>
{imageSrc && (
<>
<h3>{t('tools.subtitleGenerator.globalSettings')}</h3>
<SettingGroup>
<label>{t('tools.subtitleGenerator.fontColor')}</label>
<ColorPickerContainer>
<ColorInput
type="color"
value={globalSettings.textColor}
onChange={(e) => setGlobalSettings({
...globalSettings,
textColor: e.target.value
})}
/>
<ColorPresets>
{presetColors.map((color) => (
<ColorPresetButton
key={color.value}
$color={color.value}
onClick={() => setGlobalSettings({
...globalSettings,
textColor: color.value
})}
title={color.name}
/>
))}
</ColorPresets>
</ColorPickerContainer>
</SettingGroup>
<SettingGroup>
<label>{t('tools.subtitleGenerator.fontSize')}: {globalSettings.fontSize}px</label>
<RangeInput
type="range"
value={globalSettings.fontSize}
onChange={(e) => setGlobalSettings({
...globalSettings,
fontSize: parseInt(e.target.value)
})}
min="48"
max="120"
/>
</SettingGroup>
<SettingGroup>
<label>{t('tools.subtitleGenerator.subtitleHeight')}: {globalSettings.lineHeight}px</label>
<RangeInput
type="range"
value={globalSettings.lineHeight}
onChange={(e) => setGlobalSettings({
...globalSettings,
lineHeight: parseInt(e.target.value)
})}
min="60"
max="160"
/>
</SettingGroup>
<h3>{t('tools.subtitleGenerator.subtitleSettings')}</h3>
{subtitles.map((subtitle, index) => (
<SubtitleInput key={index}>
<input
type="text"
value={subtitle.text}
onChange={(e) => updateSubtitle(index, e.target.value)}
placeholder={`字幕 ${index + 1}`}
/>
{index > 0 && (
<DeleteButton onClick={() => removeSubtitleLine(index)}>
删除
</DeleteButton>
)}
</SubtitleInput>
))}
<Button onClick={addSubtitleLine}>{t('tools.subtitleGenerator.addSubtitleLine')}</Button>
</>
)}
</SettingsPanel>
<PreviewPanel>
<h3>{t('tools.subtitleGenerator.preview')}</h3>
{finalImage && (
<>
<PreviewImage src={finalImage} alt="Preview" />
<Button onClick={downloadImage}>{t('tools.subtitleGenerator.downloadImage')}</Button>
</>
)}
<canvas ref={canvasRef} style={{ display: 'none' }} />
</PreviewPanel>
</Container>
);
};
// Styled Components
const Container = styled.div` const Container = styled.div`
display: flex;
gap: 2rem;
padding: 4rem 2rem 2rem;
background: linear-gradient(135deg, #f5f7ff 0%, #ffffff 100%);
min-height: 100vh; min-height: 100vh;
background: linear-gradient(135deg, #f5f7ff 0%, #ffffff 100%);
padding: 4rem 2rem 2rem;
position: relative; position: relative;
&::before { &::before {
...@@ -257,12 +16,37 @@ const Container = styled.div` ...@@ -257,12 +16,37 @@ const Container = styled.div`
position: absolute; position: absolute;
top: 0; top: 0;
left: 0; left: 0;
width: 100%; right: 0;
height: 4rem; bottom: 0;
background: linear-gradient(135deg, #f5f7ff 0%, #ffffff 100%); background:
z-index: -1; linear-gradient(90deg, rgba(99, 102, 241, 0.05) 1px, transparent 1px),
linear-gradient(rgba(99, 102, 241, 0.05) 1px, transparent 1px);
background-size: 20px 20px;
pointer-events: none;
} }
`;
const ContentWrapper = styled.div`
max-width: 1400px;
margin: 0 auto;
position: relative;
z-index: 1;
`;
const Title = styled.h2`
font-size: 1.8rem;
margin-bottom: 1.5rem;
background: linear-gradient(135deg, #6366F1 0%, #4F46E5 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
font-weight: 700;
letter-spacing: -0.02em;
text-align: center;
`;
const MainContent = styled.div`
display: flex;
gap: 2rem;
@media (max-width: 768px) { @media (max-width: 768px) {
flex-direction: column; flex-direction: column;
} }
...@@ -476,4 +260,270 @@ const ColorPresetButton = styled.button` ...@@ -476,4 +260,270 @@ const ColorPresetButton = styled.button`
} }
`; `;
const ControlPanel = styled.div`
flex: 1;
background: rgba(255, 255, 255, 0.8);
backdrop-filter: blur(10px);
border-radius: 16px;
padding: 1.5rem;
box-shadow: 0 8px 32px rgba(99, 102, 241, 0.1);
border: 1px solid rgba(255, 255, 255, 0.2);
`;
const SubtitleMaker = () => {
const { t } = useTranslation();
const isLoading = usePageLoading();
const [imageSrc, setImageSrc] = useState(null);
const [subtitles, setSubtitles] = useState([{ text: '' }]);
const canvasRef = useRef(null);
const [finalImage, setFinalImage] = useState(null);
const [globalSettings, setGlobalSettings] = useState({
fontSize: 48,
lineHeight: 100,
strokeWidth: 2,
textColor: '#FFE135',
strokeColor: '#000000'
});
const fileInputRef = useRef(null);
// 预设的字幕颜色选项 - 只保留最常用的几个
const presetColors = [
{ name: t('tools.subtitleGenerator.presetColors.classicYellow'), value: '#FFE135' },
{ name: t('tools.subtitleGenerator.presetColors.pureWhite'), value: '#FFFFFF' },
{ name: t('tools.subtitleGenerator.presetColors.vividOrange'), value: '#FFA500' },
{ name: t('tools.subtitleGenerator.presetColors.neonGreen'), value: '#ADFF2F' },
{ name: t('tools.subtitleGenerator.presetColors.lightBlue'), value: '#00FFFF' },
{ name: t('tools.subtitleGenerator.presetColors.brightPink'), value: '#FF69B4' },
];
// 处理图片上传
const handleImageUpload = (e) => {
const file = e.target.files[0];
if (file) {
const reader = new FileReader();
reader.onload = () => {
setImageSrc(reader.result);
}
reader.readAsDataURL(file);
}
}
// 更新字幕内容
const updateSubtitle = (index, text) => {
const newSubtitles = [...subtitles];
newSubtitles[index] = { text };
setSubtitles(newSubtitles);
};
// 删除字幕行
const removeSubtitleLine = (index) => {
setSubtitles(subtitles.filter((_, i) => i !== index));
};
// 增加字幕行
const addSubtitleLine = () => {
setSubtitles([...subtitles, { text: '' }]);
};
// 绘制字幕到canvas
useEffect(() => {
if (imageSrc) {
const canvas = canvasRef.current;
const ctx = canvas.getContext('2d');
const image = new Image();
image.src = imageSrc;
image.onload = () => {
// 计算总高度
const totalHeight = image.height +
subtitles.slice(1).length * (globalSettings.lineHeight + 1);
canvas.width = image.width;
canvas.height = totalHeight;
// 绘制图片
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.drawImage(image, 0, 0);
// 获取第一行字幕区域的背景图像数据
const subtitleBackgroundData = ctx.getImageData(
0,
image.height - globalSettings.lineHeight,
canvas.width,
globalSettings.lineHeight
);
// 设置字体和对齐方式
ctx.textAlign = 'center';
ctx.font = `${globalSettings.fontSize}px Arial`;
ctx.lineWidth = globalSettings.strokeWidth;
// 绘制第一行字幕
let yPosition = image.height - globalSettings.lineHeight;
ctx.strokeStyle = globalSettings.strokeColor;
ctx.fillStyle = globalSettings.textColor;
const firstTextY = yPosition + globalSettings.lineHeight / 2 + globalSettings.fontSize / 3;
ctx.strokeText(subtitles[0].text, canvas.width / 2, firstTextY);
ctx.fillText(subtitles[0].text, canvas.width / 2, firstTextY);
// 设置 yPosition 为图片底部,准备绘制后续字幕
yPosition = image.height;
// 绘制后续字幕行
subtitles.slice(1).forEach((subtitle) => {
// 绘制分隔线
ctx.fillStyle = '#e5e7eb';
ctx.fillRect(0, yPosition, canvas.width, 1);
yPosition += 0;
// 绘制背景(使用第一行字幕的背景)
ctx.putImageData(subtitleBackgroundData, 0, yPosition);
// 绘制字幕文字
ctx.strokeStyle = globalSettings.strokeColor;
ctx.fillStyle = globalSettings.textColor;
const textY = yPosition + globalSettings.lineHeight / 2 + globalSettings.fontSize / 3;
ctx.strokeText(subtitle.text, canvas.width / 2, textY);
ctx.fillText(subtitle.text, canvas.width / 2, textY);
yPosition += globalSettings.lineHeight;
});
setFinalImage(canvas.toDataURL('image/png'));
};
}
}, [imageSrc, subtitles, globalSettings]);
// 下载最终的图片
const downloadImage = () => {
if (finalImage) {
const link = document.createElement('a');
link.href = finalImage;
link.download = 'subtitle-image.png';
link.click();
}
}
return (
<>
{isLoading && <LoadingOverlay />}
<SEO
title={t('tools.subtitleGenerator.title')}
description={t('tools.subtitleGenerator.description')}
/>
<Container>
<ContentWrapper>
<Title>{t('tools.subtitleGenerator.title')}</Title>
<MainContent>
{/* Left Panel */}
<ControlPanel>
<h3>{t('tools.subtitleGenerator.uploadImage')}</h3>
<UploadButton onClick={() => fileInputRef.current.click()}>
<HiddenFileInput
ref={fileInputRef}
type="file"
accept="image/*"
onChange={handleImageUpload}
/>
{t('tools.subtitleGenerator.dropOrClick')}
</UploadButton>
{imageSrc && (
<>
<h3>{t('tools.subtitleGenerator.globalSettings')}</h3>
<SettingGroup>
<label>{t('tools.subtitleGenerator.fontColor')}</label>
<ColorPickerContainer>
<ColorInput
type="color"
value={globalSettings.textColor}
onChange={(e) => setGlobalSettings({
...globalSettings,
textColor: e.target.value
})}
/>
<ColorPresets>
{presetColors.map((color) => (
<ColorPresetButton
key={color.value}
$color={color.value}
onClick={() => setGlobalSettings({
...globalSettings,
textColor: color.value
})}
title={color.name}
/>
))}
</ColorPresets>
</ColorPickerContainer>
</SettingGroup>
<SettingGroup>
<label>{t('tools.subtitleGenerator.fontSize')}: {globalSettings.fontSize}px</label>
<RangeInput
type="range"
value={globalSettings.fontSize}
onChange={(e) => setGlobalSettings({
...globalSettings,
fontSize: parseInt(e.target.value)
})}
min="48"
max="120"
/>
</SettingGroup>
<SettingGroup>
<label>{t('tools.subtitleGenerator.subtitleHeight')}: {globalSettings.lineHeight}px</label>
<RangeInput
type="range"
value={globalSettings.lineHeight}
onChange={(e) => setGlobalSettings({
...globalSettings,
lineHeight: parseInt(e.target.value)
})}
min="60"
max="160"
/>
</SettingGroup>
<h3>{t('tools.subtitleGenerator.subtitleSettings')}</h3>
{subtitles.map((subtitle, index) => (
<SubtitleInput key={index}>
<input
type="text"
value={subtitle.text}
onChange={(e) => updateSubtitle(index, e.target.value)}
placeholder={`字幕 ${index + 1}`}
/>
{index > 0 && (
<DeleteButton onClick={() => removeSubtitleLine(index)}>
删除
</DeleteButton>
)}
</SubtitleInput>
))}
<Button onClick={addSubtitleLine}>{t('tools.subtitleGenerator.addSubtitleLine')}</Button>
</>
)}
</ControlPanel>
{/* Right Panel */}
<PreviewPanel>
<h3>{t('tools.subtitleGenerator.preview')}</h3>
{finalImage && (
<>
<PreviewImage src={finalImage} alt="Preview" />
<Button onClick={downloadImage}>{t('tools.subtitleGenerator.downloadImage')}</Button>
</>
)}
<canvas ref={canvasRef} style={{ display: 'none' }} />
</PreviewPanel>
</MainContent>
</ContentWrapper>
</Container>
</>
);
};
export default SubtitleMaker; export default SubtitleMaker;
...@@ -3,6 +3,8 @@ import { removeBackground } from "@imgly/background-removal"; ...@@ -3,6 +3,8 @@ import { removeBackground } from "@imgly/background-removal";
import styled from 'styled-components'; import styled from 'styled-components';
import { useTranslation } from '../js/i18n'; import { useTranslation } from '../js/i18n';
import '../styles/fonts.css'; import '../styles/fonts.css';
import { usePageLoading } from '../hooks/usePageLoading';
import LoadingOverlay from './LoadingOverlay';
// 复用现有的基础容器样式 // 复用现有的基础容器样式
const Container = styled.div` const Container = styled.div`
...@@ -255,6 +257,7 @@ const PrivacyNote = styled.div` ...@@ -255,6 +257,7 @@ const PrivacyNote = styled.div`
function TextBehindImage() { function TextBehindImage() {
const { t } = useTranslation(); const { t } = useTranslation();
const isLoading = usePageLoading();
const [selectedImage, setSelectedImage] = useState(null); const [selectedImage, setSelectedImage] = useState(null);
const [isImageSetupDone, setIsImageSetupDone] = useState(false); const [isImageSetupDone, setIsImageSetupDone] = useState(false);
const [removedBgImageUrl, setRemovedBgImageUrl] = useState(null); const [removedBgImageUrl, setRemovedBgImageUrl] = useState(null);
...@@ -460,256 +463,259 @@ function TextBehindImage() { ...@@ -460,256 +463,259 @@ function TextBehindImage() {
}, []); }, []);
return ( return (
<Container> <>
<ContentWrapper> {isLoading && <LoadingOverlay />}
<ControlPanel> <Container>
<Title>{t('tools.textBehindImage.title')}</Title> <ContentWrapper>
<ControlPanel>
<SettingsGroup> <Title>{t('tools.textBehindImage.title')}</Title>
<GroupTitle>{t('tools.textBehindImage.imageUpload')}</GroupTitle>
<ImageUploadArea onClick={() => fileInputRef.current.click()}> <SettingsGroup>
<input <GroupTitle>{t('tools.textBehindImage.imageUpload')}</GroupTitle>
type="file" <ImageUploadArea onClick={() => fileInputRef.current.click()}>
ref={fileInputRef} <input
style={{ display: 'none' }} type="file"
accept="image/*" ref={fileInputRef}
onChange={handleImageUpload} style={{ display: 'none' }}
/> accept="image/*"
{t('tools.textBehindImage.uploadPrompt')} onChange={handleImageUpload}
</ImageUploadArea>
{/* 添加隐私提示 */}
<PrivacyNote>
{t('tools.textBehindImage.privacyNote')}
</PrivacyNote>
</SettingsGroup>
{textSets.map(textSet => (
<SettingsGroup key={textSet.id}>
<GroupTitle>{t('tools.textBehindImage.textSettings')}</GroupTitle>
<InputWrapper>
<Label>
{t('tools.textBehindImage.text')}
<span>{textSet.text.length} {t('tools.textBehindImage.characters')}</span>
</Label>
<Input
type="text"
value={textSet.text}
onChange={(e) => updateTextSet(textSet.id, 'text', e.target.value)}
placeholder={t('tools.textBehindImage.textPlaceholder')}
/>
</InputWrapper>
<InputWrapper>
<Label>
{t('tools.textBehindImage.fontSize')}
<span>{textSet.fontSize}px</span>
</Label>
<Input
type="number"
value={textSet.fontSize}
onChange={(e) => updateTextSet(textSet.id, 'fontSize', Number(e.target.value))}
/>
</InputWrapper>
<InputWrapper>
<Label>
{t('tools.textBehindImage.fontWeight')}
<span>{textSet.fontWeight}</span>
</Label>
<Input
type="range"
min="100"
max="900"
step="100"
value={textSet.fontWeight}
onChange={(e) => updateTextSet(textSet.id, 'fontWeight', Number(e.target.value))}
/>
</InputWrapper>
<InputWrapper>
<Label>
{t('tools.textBehindImage.rotation')}
<span>{textSet.rotation}°</span>
</Label>
<Input
type="range"
min="-180"
max="180"
value={textSet.rotation}
onChange={(e) => updateTextSet(textSet.id, 'rotation', Number(e.target.value))}
/>
</InputWrapper>
<InputWrapper>
<Label>{t('tools.textBehindImage.color')}</Label>
<Input
type="color"
value={textSet.color}
onChange={(e) => updateTextSet(textSet.id, 'color', e.target.value)}
/>
</InputWrapper>
<InputWrapper>
<Label>
{t('tools.textBehindImage.opacity')}
<span>{textSet.opacity}</span>
</Label>
<Input
type="range"
min="0"
max="1"
step="0.1"
value={textSet.opacity}
onChange={(e) => updateTextSet(textSet.id, 'opacity', Number(e.target.value))}
/>
</InputWrapper>
<InputWrapper>
<Label>
{t('tools.textBehindImage.positionX')}
<span>{textSet.position.x}%</span>
</Label>
<Input
type="range"
min="0"
max="100"
value={textSet.position.x}
onChange={(e) => updateTextSet(textSet.id, 'position', {
...textSet.position,
x: Number(e.target.value)
})}
/> />
</InputWrapper> {t('tools.textBehindImage.uploadPrompt')}
</ImageUploadArea>
<InputWrapper>
<Label> {/* 添加隐私提示 */}
{t('tools.textBehindImage.positionY')} <PrivacyNote>
<span>{textSet.position.y}%</span> {t('tools.textBehindImage.privacyNote')}
</Label> </PrivacyNote>
<Input
type="range"
min="0"
max="100"
value={textSet.position.y}
onChange={(e) => updateTextSet(textSet.id, 'position', {
...textSet.position,
y: Number(e.target.value)
})}
/>
</InputWrapper>
</SettingsGroup> </SettingsGroup>
))}
{textSets.map(textSet => (
</ControlPanel> <SettingsGroup key={textSet.id}>
<GroupTitle>{t('tools.textBehindImage.textSettings')}</GroupTitle>
<PreviewArea className="preview-area">
<div className="preview-content">
{imageLoading ? (
<div className="loading-container">
<span> {t('tools.textBehindImage.processing')}</span>
</div>
) : selectedImage ? (
<div style={{
position: 'relative',
width: '100%',
height: '100%',
display: 'flex',
justifyContent: 'center',
alignItems: 'center'
}}>
<DownloadButton onClick={() => handleDownload('original')}>
{t('tools.textBehindImage.download')}
</DownloadButton>
<img
src={selectedImage}
alt="Background"
style={{
...calculateImageDimensions(),
objectFit: 'contain',
position: 'relative'
}}
/>
<div style={{ <InputWrapper>
position: 'absolute', <Label>
top: 0, {t('tools.textBehindImage.text')}
left: 0, <span>{textSet.text.length} {t('tools.textBehindImage.characters')}</span>
</Label>
<Input
type="text"
value={textSet.text}
onChange={(e) => updateTextSet(textSet.id, 'text', e.target.value)}
placeholder={t('tools.textBehindImage.textPlaceholder')}
/>
</InputWrapper>
<InputWrapper>
<Label>
{t('tools.textBehindImage.fontSize')}
<span>{textSet.fontSize}px</span>
</Label>
<Input
type="number"
value={textSet.fontSize}
onChange={(e) => updateTextSet(textSet.id, 'fontSize', Number(e.target.value))}
/>
</InputWrapper>
<InputWrapper>
<Label>
{t('tools.textBehindImage.fontWeight')}
<span>{textSet.fontWeight}</span>
</Label>
<Input
type="range"
min="100"
max="900"
step="100"
value={textSet.fontWeight}
onChange={(e) => updateTextSet(textSet.id, 'fontWeight', Number(e.target.value))}
/>
</InputWrapper>
<InputWrapper>
<Label>
{t('tools.textBehindImage.rotation')}
<span>{textSet.rotation}°</span>
</Label>
<Input
type="range"
min="-180"
max="180"
value={textSet.rotation}
onChange={(e) => updateTextSet(textSet.id, 'rotation', Number(e.target.value))}
/>
</InputWrapper>
<InputWrapper>
<Label>{t('tools.textBehindImage.color')}</Label>
<Input
type="color"
value={textSet.color}
onChange={(e) => updateTextSet(textSet.id, 'color', e.target.value)}
/>
</InputWrapper>
<InputWrapper>
<Label>
{t('tools.textBehindImage.opacity')}
<span>{textSet.opacity}</span>
</Label>
<Input
type="range"
min="0"
max="1"
step="0.1"
value={textSet.opacity}
onChange={(e) => updateTextSet(textSet.id, 'opacity', Number(e.target.value))}
/>
</InputWrapper>
<InputWrapper>
<Label>
{t('tools.textBehindImage.positionX')}
<span>{textSet.position.x}%</span>
</Label>
<Input
type="range"
min="0"
max="100"
value={textSet.position.x}
onChange={(e) => updateTextSet(textSet.id, 'position', {
...textSet.position,
x: Number(e.target.value)
})}
/>
</InputWrapper>
<InputWrapper>
<Label>
{t('tools.textBehindImage.positionY')}
<span>{textSet.position.y}%</span>
</Label>
<Input
type="range"
min="0"
max="100"
value={textSet.position.y}
onChange={(e) => updateTextSet(textSet.id, 'position', {
...textSet.position,
y: Number(e.target.value)
})}
/>
</InputWrapper>
</SettingsGroup>
))}
</ControlPanel>
<PreviewArea className="preview-area">
<div className="preview-content">
{imageLoading ? (
<div className="loading-container">
<span> {t('tools.textBehindImage.processing')}</span>
</div>
) : selectedImage ? (
<div style={{
position: 'relative',
width: '100%', width: '100%',
height: '100%', height: '100%',
pointerEvents: 'none' display: 'flex',
justifyContent: 'center',
alignItems: 'center'
}}> }}>
{textSets.map(textSet => { <DownloadButton onClick={() => handleDownload('original')}>
const imageElement = document.querySelector('img[alt="Background"]'); {t('tools.textBehindImage.download')}
const imageRect = imageElement?.getBoundingClientRect(); </DownloadButton>
const imageWidth = imageRect?.width || 0; <img
src={selectedImage}
// 计算文本的中心字符位置 alt="Background"
const text = textSet.text;
const centerIndex = Math.floor(text.length / 2);
const leftPart = text.substring(0, centerIndex);
const centerChar = text.charAt(centerIndex);
const rightPart = text.substring(centerIndex + 1);
return (
<div
key={textSet.id}
style={{
position: 'absolute',
left: `${textSet.position.x}%`,
top: `${textSet.position.y}%`,
transform: `translate(-50%, -50%) rotate(${textSet.rotation}deg)`,
color: textSet.color,
fontSize: `${textSet.fontSize}px`,
fontFamily: 'Inter',
fontWeight: textSet.fontWeight,
opacity: textSet.opacity,
zIndex: 1,
whiteSpace: 'pre',
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
transformOrigin: 'center',
}}
>
<span>{leftPart}</span>
<span style={{
display: 'inline-block',
position: 'relative'
}}>{centerChar}</span>
<span>{rightPart}</span>
</div>
);
})}
</div>
{removedBgImageUrl && (
<img
src={removedBgImageUrl}
alt="Foreground"
style={{ style={{
position: 'absolute',
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
...calculateImageDimensions(), ...calculateImageDimensions(),
objectFit: 'contain', objectFit: 'contain',
zIndex: 2 position: 'relative'
}} }}
/> />
)}
</div> <div style={{
) : ( position: 'absolute',
<div className="upload-prompt"> top: 0,
{t('tools.textBehindImage.noImage')} left: 0,
</div> width: '100%',
)} height: '100%',
<canvas ref={canvasRef} style={{ display: 'none' }} /> pointerEvents: 'none'
</div> }}>
</PreviewArea> {textSets.map(textSet => {
</ContentWrapper> const imageElement = document.querySelector('img[alt="Background"]');
</Container> const imageRect = imageElement?.getBoundingClientRect();
const imageWidth = imageRect?.width || 0;
// 计算文本的中心字符位置
const text = textSet.text;
const centerIndex = Math.floor(text.length / 2);
const leftPart = text.substring(0, centerIndex);
const centerChar = text.charAt(centerIndex);
const rightPart = text.substring(centerIndex + 1);
return (
<div
key={textSet.id}
style={{
position: 'absolute',
left: `${textSet.position.x}%`,
top: `${textSet.position.y}%`,
transform: `translate(-50%, -50%) rotate(${textSet.rotation}deg)`,
color: textSet.color,
fontSize: `${textSet.fontSize}px`,
fontFamily: 'Inter',
fontWeight: textSet.fontWeight,
opacity: textSet.opacity,
zIndex: 1,
whiteSpace: 'pre',
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
transformOrigin: 'center',
}}
>
<span>{leftPart}</span>
<span style={{
display: 'inline-block',
position: 'relative'
}}>{centerChar}</span>
<span>{rightPart}</span>
</div>
);
})}
</div>
{removedBgImageUrl && (
<img
src={removedBgImageUrl}
alt="Foreground"
style={{
position: 'absolute',
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
...calculateImageDimensions(),
objectFit: 'contain',
zIndex: 2
}}
/>
)}
</div>
) : (
<div className="upload-prompt">
{t('tools.textBehindImage.noImage')}
</div>
)}
<canvas ref={canvasRef} style={{ display: 'none' }} />
</div>
</PreviewArea>
</ContentWrapper>
</Container>
</>
); );
} }
......
...@@ -2,6 +2,8 @@ import React, { useState } from 'react'; ...@@ -2,6 +2,8 @@ import React, { useState } from 'react';
import styled from 'styled-components'; import styled from 'styled-components';
import { useTranslation } from '../js/i18n'; import { useTranslation } from '../js/i18n';
import SEO from './SEO'; import SEO from './SEO';
import { usePageLoading } from '../hooks/usePageLoading';
import LoadingOverlay from './LoadingOverlay';
const Container = styled.div` const Container = styled.div`
min-height: 100vh; min-height: 100vh;
...@@ -167,6 +169,7 @@ const DiffHeader = styled(TitleLabel)` ...@@ -167,6 +169,7 @@ const DiffHeader = styled(TitleLabel)`
function TextDiff() { function TextDiff() {
const { t } = useTranslation(); const { t } = useTranslation();
const isLoading = usePageLoading();
const [oldText, setOldText] = useState(''); const [oldText, setOldText] = useState('');
const [newText, setNewText] = useState(''); const [newText, setNewText] = useState('');
...@@ -180,6 +183,7 @@ function TextDiff() { ...@@ -180,6 +183,7 @@ function TextDiff() {
return ( return (
<> <>
{isLoading && <LoadingOverlay />}
<SEO <SEO
title={t('tools.textDiff.title')} title={t('tools.textDiff.title')}
description={t('tools.textDiff.description')} description={t('tools.textDiff.description')}
......
...@@ -2,6 +2,8 @@ import React, { useState, useCallback } from 'react'; ...@@ -2,6 +2,8 @@ import React, { useState, useCallback } from 'react';
import { useTranslation } from '../js/i18n'; import { useTranslation } from '../js/i18n';
import SEO from './SEO'; import SEO from './SEO';
import styled from 'styled-components'; import styled from 'styled-components';
import { usePageLoading } from '../hooks/usePageLoading';
import LoadingOverlay from './LoadingOverlay';
// 复用相同的样式组件 // 复用相同的样式组件
const Container = styled.div` const Container = styled.div`
...@@ -45,6 +47,7 @@ const Title = styled.h2` ...@@ -45,6 +47,7 @@ const Title = styled.h2`
function UrlEncoderDecoder() { function UrlEncoderDecoder() {
const { t } = useTranslation(); const { t } = useTranslation();
const isLoading = usePageLoading();
const [input, setInput] = useState(''); const [input, setInput] = useState('');
const [resultText, setResultText] = useState(''); const [resultText, setResultText] = useState('');
const [isCopied, setIsCopied] = useState(false); const [isCopied, setIsCopied] = useState(false);
...@@ -81,6 +84,7 @@ function UrlEncoderDecoder() { ...@@ -81,6 +84,7 @@ function UrlEncoderDecoder() {
return ( return (
<> <>
{isLoading && <LoadingOverlay />}
<SEO <SEO
title={t('tools.urlEncodeDecode.title')} title={t('tools.urlEncodeDecode.title')}
description={t('tools.urlEncodeDecode.description')} description={t('tools.urlEncodeDecode.description')}
......
import { useState, useEffect } from 'react';
export const usePageLoading = () => {
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
// Set loading to false after a short delay to ensure content is ready
const timer = setTimeout(() => {
setIsLoading(false);
}, 500);
return () => clearTimeout(timer);
}, []);
return isLoading;
};
import { useEffect } from 'react';
import { useLocation } from 'react-router-dom';
export const useScrollToTop = () => {
const { pathname } = useLocation();
useEffect(() => {
window.scrollTo(0, 0);
}, [pathname]);
};
...@@ -7,6 +7,8 @@ ...@@ -7,6 +7,8 @@
"login": "Login", "login": "Login",
"loginSubtitle": "Welcome to AI Toolbox, please log in for the full experience", "loginSubtitle": "Welcome to AI Toolbox, please log in for the full experience",
"logout": "Logout", "logout": "Logout",
"copy": "Copy",
"copied": "Copied",
"dev-tools": { "dev-tools": {
"title": "Development Tools", "title": "Development Tools",
"description": "A collection of popular and efficient development tools, including JSON formatter, Base64 to image converter, online Diff tool, LaTeX renderer, and more. Designed to help developers handle everyday coding tasks quickly and boost development efficiency." "description": "A collection of popular and efficient development tools, including JSON formatter, Base64 to image converter, online Diff tool, LaTeX renderer, and more. Designed to help developers handle everyday coding tasks quickly and boost development efficiency."
......
...@@ -99,7 +99,8 @@ ...@@ -99,7 +99,8 @@
"download": "Download Image", "download": "Download Image",
"dragOrClick": "Drag and drop or click to upload", "dragOrClick": "Drag and drop or click to upload",
"fileName": "File name", "fileName": "File name",
"fileSize": "File size" "fileSize": "File size",
"preview": "Preview"
}, },
"fisherai": { "fisherai": {
"title": "FisherAI", "title": "FisherAI",
...@@ -117,7 +118,7 @@ ...@@ -117,7 +118,7 @@
"title": "Subtitle Generator", "title": "Subtitle Generator",
"description": "Quickly generate multi-line subtitle images with customizable styles", "description": "Quickly generate multi-line subtitle images with customizable styles",
"uploadImage": "Upload Background Image", "uploadImage": "Upload Background Image",
"dragOrClick": "Drag and drop or click to upload", "dropOrClick": "Drag and drop or click to upload",
"removeImage": "Remove Image", "removeImage": "Remove Image",
"globalSettings": "Global Settings", "globalSettings": "Global Settings",
"fontColor": "Font Color", "fontColor": "Font Color",
......
...@@ -7,6 +7,8 @@ ...@@ -7,6 +7,8 @@
"login": "ログイン", "login": "ログイン",
"loginSubtitle": "AIツールボックスへようこそ。フル体験のためにログインしてください", "loginSubtitle": "AIツールボックスへようこそ。フル体験のためにログインしてください",
"logout": "ログアウト", "logout": "ログアウト",
"copy": "コピー",
"copied": "コピーされました",
"dev-tools": { "dev-tools": {
"title": "開発ツール", "title": "開発ツール",
"description": "JSONフォーマッター、Base64画像変換、オンラインDiffツール、LaTeXレンダリングなど、さまざまな人気で効率的な開発ツールを統合し、日常のコーディングタスクを迅速に処理し、開発効率を向上させます。" "description": "JSONフォーマッター、Base64画像変換、オンラインDiffツール、LaTeXレンダリングなど、さまざまな人気で効率的な開発ツールを統合し、日常のコーディングタスクを迅速に処理し、開発効率を向上させます。"
......
...@@ -99,7 +99,8 @@ ...@@ -99,7 +99,8 @@
"download": "画像をダウンロード", "download": "画像をダウンロード",
"dragOrClick": "画像をドラッグまたはクリックしてアップロード", "dragOrClick": "画像をドラッグまたはクリックしてアップロード",
"fileName": "ファイル名", "fileName": "ファイル名",
"fileSize": "ファイルサイズ" "fileSize": "ファイルサイズ",
"preview": "プレビュー"
}, },
"fisherai": { "fisherai": {
"title": "FisherAI", "title": "FisherAI",
......
...@@ -7,6 +7,8 @@ ...@@ -7,6 +7,8 @@
"login": "로그인", "login": "로그인",
"loginSubtitle": "AI 툴��스에 오신 것을 환영합니다. 전체 경험을 위해 로그인해 주세요", "loginSubtitle": "AI 툴��스에 오신 것을 환영합니다. 전체 경험을 위해 로그인해 주세요",
"logout": "로그아웃", "logout": "로그아웃",
"copy": "복사",
"copied": "복사된 메시지",
"dev-tools": { "dev-tools": {
"title": "개발 도구", "title": "개발 도구",
"description": "JSON 포맷터, Base64 이미지 변환기, 온라인 Diff 도구, LaTeX 렌더러 등 인기 있고 효율적인 개발 도구를 통합하여, 일상적인 코딩 작업을 신속하게 처리하고 개발 효율성을 높입니다." "description": "JSON 포맷터, Base64 이미지 변환기, 온라인 Diff 도구, LaTeX 렌더러 등 인기 있고 효율적인 개발 도구를 통합하여, 일상적인 코딩 작업을 신속하게 처리하고 개발 효율성을 높입니다."
......
...@@ -100,7 +100,8 @@ ...@@ -100,7 +100,8 @@
"download": "이미지 다운로드", "download": "이미지 다운로드",
"dragOrClick": "드래그 앤 드롭 또는 클릭하여 업로드", "dragOrClick": "드래그 앤 드롭 또는 클릭하여 업로드",
"fileName": "파일 이름", "fileName": "파일 이름",
"fileSize": "파일 크기" "fileSize": "파일 크기",
"preview": "미리보기"
}, },
"fisherai": { "fisherai": {
"title": "FisherAI", "title": "FisherAI",
......
...@@ -7,6 +7,8 @@ ...@@ -7,6 +7,8 @@
"login": "登录", "login": "登录",
"loginSubtitle": "欢迎使用 AI 工具箱,请登录以获得完整体验", "loginSubtitle": "欢迎使用 AI 工具箱,请登录以获得完整体验",
"logout": "退出登录", "logout": "退出登录",
"copy": "复制",
"copied": "已复制",
"dev-tools": { "dev-tools": {
"title": "开发工具", "title": "开发工具",
"description": "集成多种热门高效的开发工具,包括JSON格式化、Base64转图片、在线Diff对比、LaTeX渲染等,帮助开发者快速处理日常编码任务,提升开发效率。" "description": "集成多种热门高效的开发工具,包括JSON格式化、Base64转图片、在线Diff对比、LaTeX渲染等,帮助开发者快速处理日常编码任务,提升开发效率。"
......
...@@ -101,7 +101,8 @@ ...@@ -101,7 +101,8 @@
"download": "下载图片", "download": "下载图片",
"dragOrClick": "拖拽或点击上传图片", "dragOrClick": "拖拽或点击上传图片",
"fileName": "文件名", "fileName": "文件名",
"fileSize": "文件大小" "fileSize": "文件大小",
"preview": "预览"
}, },
"fisherai": { "fisherai": {
"title": "FisherAI", "title": "FisherAI",
...@@ -201,7 +202,7 @@ ...@@ -201,7 +202,7 @@
}, },
"textBehindImage": { "textBehindImage": {
"title": "文字穿越图片", "title": "文字穿越图片",
"description": "在图片主体与背景之间添加文字,创造3D效果", "description": "在图片主体与背景之间添加文字,3D效果",
"imageUpload": "图片上传", "imageUpload": "图片上传",
"uploadPrompt": "点击或拖拽上传图片", "uploadPrompt": "点击或拖拽上传图片",
"textSettings": "文字设置", "textSettings": "文字设置",
......
import React from 'react'; import React, { useState, useEffect } from 'react';
import { Link } from 'react-router-dom'; import { Link, useNavigate } from 'react-router-dom';
import { useTranslation } from '../js/i18n'; import { useTranslation } from '../js/i18n';
import SEO from '../components/SEO'; import SEO from '../components/SEO';
...@@ -25,10 +25,37 @@ const tools = [ ...@@ -25,10 +25,37 @@ const tools = [
const Home = () => { const Home = () => {
const { t } = useTranslation(); const { t } = useTranslation();
const [loading, setLoading] = useState('');
const navigate = useNavigate();
useEffect(() => {
const handleClearLoading = () => {
setLoading('');
};
window.addEventListener('clearLoadingState', handleClearLoading);
return () => {
window.removeEventListener('clearLoadingState', handleClearLoading);
};
}, []);
const handleNavigate = (tool) => {
if (tool.external) {
window.open(tool.path, '_blank', 'noopener,noreferrer');
return;
}
setLoading(tool.id);
window.scrollTo(0, 0);
navigate(tool.path);
};
const renderToolLink = (tool) => { const renderToolLink = (tool) => {
const content = ( const content = (
<div className="group flex items-center gap-4 p-6 bg-white rounded-xl shadow-lg hover:shadow-xl transform hover:-translate-y-1 transition-all duration-300"> <div className={`group flex items-center gap-4 p-6 bg-white rounded-xl shadow-lg hover:shadow-xl transform hover:-translate-y-1 transition-all duration-300 relative ${loading === tool.id ? 'pointer-events-none' : ''}`}>
{loading === tool.id && (
<div className="absolute inset-0 bg-white/80 rounded-xl flex items-center justify-center z-10">
<div className="w-8 h-8 border-4 border-indigo-500 border-t-transparent rounded-full animate-spin"></div>
</div>
)}
<img <img
src={tool.icon} src={tool.icon}
alt={`${t(`tools.${tool.id}.title`)} icon`} alt={`${t(`tools.${tool.id}.title`)} icon`}
...@@ -48,17 +75,15 @@ const Home = () => { ...@@ -48,17 +75,15 @@ const Home = () => {
return tool.external ? ( return tool.external ? (
<a <a
href={tool.path} onClick={() => handleNavigate(tool)}
className="block" className="block cursor-pointer"
target="_blank"
rel="noopener noreferrer"
> >
{content} {content}
</a> </a>
) : ( ) : (
<Link to={tool.path} className="block"> <div onClick={() => handleNavigate(tool)} className="block cursor-pointer">
{content} {content}
</Link> </div>
); );
}; };
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment