Commit c717bfff authored by fisherdaddy's avatar fisherdaddy

chore: 使用 tailwind css 首页样式

parent 637c7e34
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}
......@@ -2,7 +2,6 @@ import React, { Suspense, lazy } from 'react';
import { Routes, Route } from 'react-router-dom';
import Home from './pages/Home';
import Header from './components/Header';
import Footer from './components/Footer';
import NotFound from './pages/NotFound';
import Login from './pages/Login';
......@@ -33,42 +32,43 @@ function App() {
return (
<div className="app-container">
<Header />
<div className="content-wrapper">
<main>
<Suspense fallback={<div>Loading...</div>}>
<Routes>
<Route path="/" element={<Home />} />
<Route path="/login" element={<Login />} />
<Route path="/about" element={<About />} />
<div className="pt-4">
<div className="content-wrapper">
<main>
<Suspense fallback={<div>Loading...</div>}>
<Routes>
<Route path="/" element={<Home />} />
<Route path="/login" element={<Login />} />
<Route path="/about" element={<About />} />
<Route path="/dev-tools" element={<DevTools />} />
<Route path="/image-tools" element={<ImageTools />} />
<Route path="/ai-products" element={<AIProduct />} />
<Route path="/blog" element={<Blog />} />
<Route path="/dev-tools" element={<DevTools />} />
<Route path="/image-tools" element={<ImageTools />} />
<Route path="/ai-products" element={<AIProduct />} />
<Route path="/blog" element={<Blog />} />
<Route path="/markdown-to-image" element={<MarkdownToImage />} />
<Route path="/json-formatter" element={<JsonFormatter />} />
<Route path="/url-encode-and-decode" element={<UrlEnDecode />} />
<Route path="/openai-timeline" element={<OpenAITimeline />} />
<Route path="/llm-model-price" element={<PricingCharts />} />
<Route path="/handwriting" element={<HandwriteGen />} />
<Route path="/image-base64" element={<ImageBase64Converter />} />
<Route path="/quote-card" element={<QuoteCard />} />
<Route path="/latex-to-image" element={<LatexToImage />} />
<Route path="/text-diff" element={<TextDiff />} />
<Route path="/subtitle-to-image" element={<SubtitleGenerator />} />
<Route path="/image-compressor" element={<ImageCompressor />} />
<Route path="/image-watermark" element={<ImageWatermark />} />
<Route path="/text-behind-image" element={<TextBehindImage />} />
<Route path="/background-remover" element={<BackgroundRemover />} />
<Route path="/anthropic-timeline" element={<AnthropicTimeline />} />
<Route path="*" element={<NotFound />} />
<Route path="/markdown-to-image" element={<MarkdownToImage />} />
<Route path="/json-formatter" element={<JsonFormatter />} />
<Route path="/url-encode-and-decode" element={<UrlEnDecode />} />
<Route path="/openai-timeline" element={<OpenAITimeline />} />
<Route path="/llm-model-price" element={<PricingCharts />} />
<Route path="/handwriting" element={<HandwriteGen />} />
<Route path="/image-base64" element={<ImageBase64Converter />} />
<Route path="/quote-card" element={<QuoteCard />} />
<Route path="/latex-to-image" element={<LatexToImage />} />
<Route path="/text-diff" element={<TextDiff />} />
<Route path="/subtitle-to-image" element={<SubtitleGenerator />} />
<Route path="/image-compressor" element={<ImageCompressor />} />
<Route path="/image-watermark" element={<ImageWatermark />} />
<Route path="/text-behind-image" element={<TextBehindImage />} />
<Route path="/background-remover" element={<BackgroundRemover />} />
<Route path="/anthropic-timeline" element={<AnthropicTimeline />} />
<Route path="*" element={<NotFound />} />
</Routes>
</Suspense>
</main>-
</Routes>
</Suspense>
</main>-
</div>
</div>
<Footer />
</div>
);
}
......
......@@ -8,7 +8,7 @@ import SEO from './SEO';
const Container = styled.div`
min-height: 100vh;
background: linear-gradient(135deg, #f5f7ff 0%, #ffffff 100%);
padding: 2rem;
padding: 4rem 2rem 2rem;
position: relative;
&::before {
......
import React from 'react';
import { Link } from 'react-router-dom';
import { useTranslation } from '../js/i18n';
const Footer = React.memo(() => {
const { t } = useTranslation();
return (
<footer className="footer">
<p>
&copy; {new Date().getFullYear()} {t('footer.copyRight')}
<span className="footer-separator" />
<Link to="/about" className="footer-link">
{t('navigation.about')}
</Link>
</p>
</footer>
);
});
export default Footer;
\ No newline at end of file
......@@ -145,7 +145,7 @@ function HandwritingGenerator() {
const backgroundOffset = -(lineSpacing * fontSize - fontSize);
return (
<div className="handwrite-container">
<div className="handwrite-container" style={{ paddingTop: '4rem' }}>
<Layout>
<Sider width={300} className="site-layout-background">
<div className="settings-section">
......
......@@ -3,7 +3,6 @@ import React, { useState, useEffect, useRef } from 'react';
import { NavLink, useNavigate } from 'react-router-dom';
import LanguageSelector from './LanguageSelector';
import { useTranslation } from '../js/i18n';
import '../styles/Header.css';
import logo from '/assets/logo.png';
function Header() {
......@@ -45,81 +44,311 @@ function Header() {
setMobileMenuOpen(false);
};
// 添加键盘导航支持
const handleKeyDown = (e) => {
if (e.key === 'Escape') {
setMenuOpen(false);
setMobileMenuOpen(false);
}
};
// 添加焦点管理
useEffect(() => {
const handleFocusTrap = (e) => {
if (!mobileMenuOpen) return;
const mobileMenu = document.querySelector('[role="dialog"]');
const focusableElements = mobileMenu.querySelectorAll(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
);
const firstElement = focusableElements[0];
const lastElement = focusableElements[focusableElements.length - 1];
if (e.key === 'Tab') {
if (e.shiftKey && document.activeElement === firstElement) {
e.preventDefault();
lastElement.focus();
} else if (!e.shiftKey && document.activeElement === lastElement) {
e.preventDefault();
firstElement.focus();
}
}
};
document.addEventListener('keydown', handleKeyDown);
document.addEventListener('keydown', handleFocusTrap);
return () => {
document.removeEventListener('keydown', handleKeyDown);
document.removeEventListener('keydown', handleFocusTrap);
};
}, [mobileMenuOpen]);
// 处理滚动锁定
useEffect(() => {
if (mobileMenuOpen) {
document.body.style.overflow = 'hidden';
} else {
document.body.style.overflow = '';
}
return () => {
document.body.style.overflow = '';
};
}, [mobileMenuOpen]);
return (
<header>
<nav>
<div className="logo-title-container">
<NavLink to="/" className="title no-underline" onClick={handleNavClick}>
<img src={logo} alt="Logo" className="logo" />
{t('title')}
</NavLink>
</div>
<header className="fixed top-0 left-0 right-0 z-50">
<div className="absolute inset-0 bg-white/70 backdrop-blur-lg border-b border-gray-100"></div>
<nav
className="relative max-w-7xl mx-auto px-4 sm:px-6 lg:px-8"
role="navigation"
aria-label={t('navigation.main')}
>
<div className="flex justify-between items-center h-20">
<div className="flex items-center">
<div className="flex-shrink-0 mr-8">
<NavLink
to="/"
className="flex items-center space-x-3 group"
aria-label={t('navigation.home')}
>
<img
src={logo}
alt={t('logo.alt')}
className="w-10 h-10 object-contain transition transform group-hover:scale-105"
width="40"
height="40"
loading="eager"
/>
<span className="text-xl font-semibold bg-gradient-to-r from-indigo-500/90 to-indigo-600/80 bg-clip-text text-transparent">
{t('title')}
</span>
</NavLink>
</div>
<button className="mobile-menu-button" onClick={toggleMobileMenu}>
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
{mobileMenuOpen ? (
<path d="M6 18L18 6M6 6l12 12" />
) : (
<path d="M4 6h16M4 12h16M4 18h16" />
)}
</svg>
</button>
<div className={`menu-container ${mobileMenuOpen ? 'mobile-menu-open' : ''}`}>
<div className="menu-items">
<NavLink to="/dev-tools" onClick={handleNavClick}>
{t('dev-tools.title')}
</NavLink>
<NavLink to="/image-tools" onClick={handleNavClick}>
{t('image-tools.title')}
</NavLink>
<NavLink to="/ai-products" onClick={handleNavClick}>
{t('ai-products.title')}
</NavLink>
<NavLink to="/blog" onClick={handleNavClick}>
{t('blog.title')}
</NavLink>
<div className="hidden md:flex items-center space-x-6">
<NavLink
to="/dev-tools"
className={({isActive}) =>
`px-3 py-2 text-base font-medium transition-all duration-200 border-b-2 ${
isActive
? 'text-indigo-500 border-indigo-500'
: 'text-gray-600 border-transparent hover:text-indigo-500 hover:border-indigo-500'
}`
}
>
{t('dev-tools.title')}
</NavLink>
<NavLink
to="/image-tools"
className={({isActive}) =>
`px-3 py-2 text-base font-medium transition-all duration-200 border-b-2 ${
isActive
? 'text-indigo-500 border-indigo-500'
: 'text-gray-600 border-transparent hover:text-indigo-500 hover:border-indigo-500'
}`
}
onClick={handleNavClick}
>
{t('image-tools.title')}
</NavLink>
<NavLink
to="/ai-products"
className={({isActive}) =>
`px-3 py-2 text-base font-medium transition-all duration-200 border-b-2 ${
isActive
? 'text-indigo-500 border-indigo-500'
: 'text-gray-600 border-transparent hover:text-indigo-500 hover:border-indigo-500'
}`
}
onClick={handleNavClick}
>
{t('ai-products.title')}
</NavLink>
<NavLink
to="/blog"
className={({isActive}) =>
`px-3 py-2 text-base font-medium transition-all duration-200 border-b-2 ${
isActive
? 'text-indigo-500 border-indigo-500'
: 'text-gray-600 border-transparent hover:text-indigo-500 hover:border-indigo-500'
}`
}
onClick={handleNavClick}
>
{t('blog.title')}
</NavLink>
</div>
</div>
<div className="right-container">
<div className="flex items-center space-x-4">
<LanguageSelector />
<div className="auth-container">
{user ? (
<div className="user-info">
<div className="avatar-container" ref={menuRef}>
<img
src={user.picture}
alt="User Avatar"
className="avatar"
onClick={toggleMenu}
/>
<div className={`dropdown-menu ${menuOpen ? 'active' : ''}`}>
<button onClick={handleLogout}>
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" />
<polyline points="16 17 21 12 16 7" />
<line x1="21" y1="12" x2="9" y2="12" />
</svg>
{t('logout')}
</button>
</div>
{user ? (
<div className="relative" ref={menuRef}>
<button
onClick={toggleMenu}
className="flex items-center space-x-3 focus:outline-none"
>
<img
src={user.picture}
alt="User Avatar"
className="w-8 h-8 sm:w-10 sm:h-10 rounded-full ring-2 ring-offset-2 ring-indigo-500 transition transform hover:scale-105"
/>
</button>
{menuOpen && (
<div className="absolute right-0 mt-2 w-48 rounded-lg shadow-lg bg-white ring-1 ring-black ring-opacity-5 py-1 focus:outline-none">
<button
onClick={handleLogout}
className="w-full text-left px-4 py-2 text-sm text-gray-700 hover:bg-indigo-50 hover:text-indigo-600 transition-colors duration-150"
>
{t('logout')}
</button>
</div>
</div>
)}
</div>
) : (
<NavLink
to="/login"
className="hidden sm:inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-lg text-white bg-indigo-600 hover:bg-indigo-700 transition-colors duration-200 shadow-sm hover:shadow-md"
>
{t('login')}
</NavLink>
)}
</div>
<button
className="md:hidden p-2 rounded-md text-gray-600 hover:text-gray-900 hover:bg-gray-100 transition-colors duration-200"
onClick={toggleMobileMenu}
aria-expanded={mobileMenuOpen}
aria-controls="mobile-menu"
aria-label={mobileMenuOpen ? t('navigation.close') : t('navigation.menu')}
>
<span className="sr-only">
{mobileMenuOpen ? t('navigation.close') : t('navigation.menu')}
</span>
<svg
className="w-6 h-6"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
aria-hidden="true"
>
{mobileMenuOpen ? (
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
) : (
<NavLink to="/login" className="login-button">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M15 3h4a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2h-4" />
<polyline points="10 17 15 12 10 7" />
<line x1="15" y1="12" x2="3" y2="12" />
</svg>
{t('login')}
</NavLink>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h16M4 18h16" />
)}
</div>
</svg>
</button>
<div
id="mobile-menu"
role="dialog"
aria-label={t('navigation.mobile_menu')}
aria-modal="true"
className={`md:hidden fixed inset-0 z-40 transform ${
mobileMenuOpen ? 'translate-x-0' : 'translate-x-full'
} transition-transform duration-300 ease-in-out`}
>
<div
className="fixed inset-0 bg-gray-600 bg-opacity-75 transition-opacity"
aria-hidden="true"
onClick={() => setMobileMenuOpen(false)}
/>
<nav
className="relative flex flex-col h-full w-full max-w-sm ml-auto bg-white shadow-xl"
role="navigation"
aria-label={t('navigation.mobile')}
>
<div className="flex items-center justify-between p-4 border-b border-gray-200">
<span className="text-lg font-semibold text-gray-800">{t('navigation.menu')}</span>
<button
onClick={() => setMobileMenuOpen(false)}
className="p-2 rounded-md text-gray-600 hover:text-gray-900 hover:bg-gray-100"
aria-label={t('navigation.close')}
>
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<div className="flex-1 overflow-y-auto">
<div className="px-2 pt-2 pb-3 space-y-1">
<NavLink
to="/dev-tools"
className={({isActive}) =>
`block px-4 py-3 rounded-lg text-base font-medium transition-colors duration-200 ${
isActive
? 'bg-indigo-50 text-indigo-600'
: 'text-gray-700 hover:bg-gray-50 hover:text-indigo-600'
}`
}
onClick={() => setMobileMenuOpen(false)}
>
{t('dev-tools.title')}
</NavLink>
<NavLink
to="/image-tools"
className={({isActive}) =>
`block px-4 py-3 rounded-lg text-base font-medium transition-colors duration-200 ${
isActive
? 'bg-indigo-50 text-indigo-600'
: 'text-gray-700 hover:bg-gray-50 hover:text-indigo-600'
}`
}
onClick={() => setMobileMenuOpen(false)}
>
{t('image-tools.title')}
</NavLink>
<NavLink
to="/ai-products"
className={({isActive}) =>
`block px-4 py-3 rounded-lg text-base font-medium transition-colors duration-200 ${
isActive
? 'bg-indigo-50 text-indigo-600'
: 'text-gray-700 hover:bg-gray-50 hover:text-indigo-600'
}`
}
onClick={() => setMobileMenuOpen(false)}
>
{t('ai-products.title')}
</NavLink>
<NavLink
to="/blog"
className={({isActive}) =>
`block px-4 py-3 rounded-lg text-base font-medium transition-colors duration-200 ${
isActive
? 'bg-indigo-50 text-indigo-600'
: 'text-gray-700 hover:bg-gray-50 hover:text-indigo-600'
}`
}
onClick={() => setMobileMenuOpen(false)}
>
{t('blog.title')}
</NavLink>
</div>
</div>
<div className="border-t border-gray-200 p-4">
{!user && (
<NavLink
to="/login"
className="flex items-center justify-center px-4 py-2 border border-transparent text-base font-medium rounded-lg text-white bg-indigo-600 hover:bg-indigo-700 transition-colors duration-200 shadow-sm hover:shadow-md w-full"
onClick={() => setMobileMenuOpen(false)}
>
{t('login')}
</NavLink>
)}
</div>
</nav>
</div>
</div>
</nav>
</header>
);
}
export default Header;
......@@ -14,6 +14,7 @@ const ConverterContainer = styled(Container)`
backdrop-filter: blur(10px);
border: 1px solid rgba(99, 102, 241, 0.1);
border-radius: 12px;
padding-top: 4rem; // 添加顶部内边距
`;
const Section = styled.div`
......
......@@ -8,7 +8,7 @@ import imageCompression from 'browser-image-compression';
const Container = styled.div`
min-height: 100vh;
background: linear-gradient(135deg, #f5f7ff 0%, #ffffff 100%);
padding: 2rem;
padding: 4rem 2rem 2rem;
position: relative;
&::before {
......
......@@ -7,7 +7,7 @@ import SEO from './SEO';
const Container = styled.div`
min-height: 100vh;
background: linear-gradient(135deg, #f5f7ff 0%, #ffffff 100%);
padding: 2rem;
padding: 4rem 2rem 2rem;
position: relative;
&::before {
......
import React, { useState, useEffect } from 'react';
import styled from 'styled-components';
import { Title, Wrapper, Container, Preview } from '../js/SharedStyles';
import { useTranslation } from '../js/i18n';
import SEO from '../components/SEO';
import styled from 'styled-components';
const InputText = styled.textarea`
width: 100%;
height: 200px;
font-size: 15px;
padding: 16px;
border: 1px solid rgba(99, 102, 241, 0.1);
border-radius: 12px;
background: rgba(255, 255, 255, 0.8);
backdrop-filter: blur(10px);
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);
}
@media (min-width: 768px) {
width: 40%;
height: 100%;
}
`;
const PreviewContainer = styled.div`
width: 100%;
display: flex;
flex-direction: column;
background: rgba(255, 255, 255, 0.8);
backdrop-filter: blur(10px);
border-radius: 12px;
border: 1px solid rgba(99, 102, 241, 0.1);
padding: 16px;
box-sizing: border-box;
@media (min-width: 768px) {
width: 58%;
height: 100%;
}
`;
const ButtonGroup = styled.div`
display: flex;
gap: 8px;
position: absolute;
top: 12px;
right: 12px;
`;
const ActionButton = styled.button`
background: rgba(99, 102, 241, 0.1);
border: none;
border-radius: 6px;
padding: 6px 12px;
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 {
background: #6366F1;
color: white;
}
svg {
width: 14px;
height: 14px;
}
`;
const RelativePreviewContainer = styled(PreviewContainer)`
const Container = styled.div`
min-height: 100vh;
background: linear-gradient(135deg, #f5f7ff 0%, #ffffff 100%);
padding: 4rem 2rem 2rem;
position: relative;
`;
const ToggleButton = styled.button`
background: none;
border: none;
cursor: pointer;
color: #6366F1;
font-size: 13px;
padding: 2px 6px;
margin-right: 6px;
border-radius: 4px;
display: inline-flex;
align-items: center;
transition: all 0.2s ease;
&:hover {
background: rgba(99, 102, 241, 0.1);
}
svg {
width: 16px;
height: 16px;
&::before {
content: '';
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 JsonList = styled.ul`
list-style-type: none;
padding-left: 24px;
margin: 0;
font-size: 15px;
line-height: 1.6;
const ContentWrapper = styled.div`
max-width: 1400px;
margin: 0 auto;
position: relative;
z-index: 1;
`;
const Key = styled.span`
color: #6366F1;
font-weight: 500;
font-size: 15px;
`;
const Value = styled.span`
color: #374151;
font-size: 15px;
&:not(:last-child) {
margin-right: 4px;
}
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;
`;
function JsonFormatter() {
......@@ -142,8 +51,12 @@ function JsonFormatter() {
useEffect(() => {
try {
const parsed = JSON.parse(input);
setParsedJson(parsed);
if (input.trim()) {
const parsed = JSON.parse(input);
setParsedJson(parsed);
} else {
setParsedJson(null);
}
} catch (error) {
setParsedJson(null);
}
......@@ -171,135 +84,208 @@ function JsonFormatter() {
title={t('tools.jsonFormatter.title')}
description={t('tools.jsonFormatter.description')}
/>
<Wrapper>
<Title>{t('tools.jsonFormatter.title')}</Title>
<Container>
<InputText
placeholder={t('tools.jsonFormatter.inputPlaceholder')}
value={input}
onChange={(e) => setInput(e.target.value)}
/>
<RelativePreviewContainer>
{parsedJson ? (
<>
<Preview>
{isCompressed ? (
<pre style={{ margin: 0, whiteSpace: 'nowrap', overflowX: 'auto' }}>
{JSON.stringify(parsedJson)}
</pre>
) : (
<JsonView data={parsedJson} />
)}
</Preview>
<ButtonGroup>
<ActionButton
onClick={toggleCompression}
className={isCompressed ? 'active' : ''}
>
<Container>
<ContentWrapper>
<Title>{t('tools.jsonFormatter.title')}</Title>
<div className="flex flex-col lg:flex-row gap-4 lg:gap-6 h-[calc(100vh-220px)]">
<textarea
className="w-full lg:w-5/12 p-4 text-sm font-mono border border-indigo-100 rounded-xl bg-white/80 backdrop-blur-sm focus:border-indigo-300 focus:ring-4 focus:ring-indigo-100 outline-none resize-none transition duration-300"
placeholder={t('tools.jsonFormatter.inputPlaceholder')}
value={input}
onChange={(e) => setInput(e.target.value)}
/>
<div className="w-full lg:w-7/12 relative border border-indigo-100 rounded-xl bg-white/80 backdrop-blur-sm p-4">
{parsedJson ? (
<>
<div className="font-mono text-sm leading-relaxed overflow-auto">
{isCompressed ? (
<svg viewBox="0 0 24 24" fill="currentColor">
<path d="M4 9h16v2H4V9zm0 4h16v2H4v-2z"/>
</svg>
<pre className="m-0 whitespace-nowrap">
{JSON.stringify(parsedJson)}
</pre>
) : (
<svg viewBox="0 0 24 24" fill="currentColor">
<path d="M19 13H5v-2h14v2z"/>
</svg>
<JsonView data={parsedJson} />
)}
{isCompressed ? '展开' : '压缩'}
</ActionButton>
<ActionButton onClick={handleCopy} className={isCopied ? 'active' : ''}>
{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>
) : (
<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>
)}
{isCopied ? '已复制' : '复制'}
</ActionButton>
</ButtonGroup>
</>
) : (
<Preview>{t('tools.jsonFormatter.invalidJson')}</Preview>
)}
</RelativePreviewContainer>
</Container>
</Wrapper>
</div>
<div className="absolute top-4 right-4 flex gap-2">
<button
onClick={toggleCompression}
className={`
flex items-center gap-1.5 px-3 py-1.5 text-sm font-medium rounded-lg transition-all duration-200
${isCompressed
? 'bg-indigo-100 text-indigo-700'
: 'bg-white/50 hover:bg-indigo-50 text-gray-600 hover:text-indigo-600'
}
`}
>
{isCompressed ? (
<>
<svg className="w-4 h-4" viewBox="0 0 24 24" fill="currentColor">
<path d="M4 9h16v2H4V9zm0 4h16v2H4v-2z"/>
</svg>
{t('tools.jsonFormatter.expand')}
</>
) : (
<>
<svg className="w-4 h-4" viewBox="0 0 24 24" fill="currentColor">
<path d="M19 13H5v-2h14v2z"/>
</svg>
{t('tools.jsonFormatter.compress')}
</>
)}
</button>
<button
onClick={handleCopy}
className={`
flex items-center gap-1.5 px-3 py-1.5 text-sm font-medium rounded-lg transition-all duration-200
${isCopied
? 'bg-green-100 text-green-700'
: 'bg-white/50 hover:bg-indigo-50 text-gray-600 hover:text-indigo-600'
}
`}
>
{isCopied ? (
<>
<svg className="w-4 h-4" 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>
{t('tools.jsonFormatter.copied')}
</>
) : (
<>
<svg className="w-4 h-4" 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>
{t('tools.jsonFormatter.copy')}
</>
)}
</button>
</div>
</>
) : (
<div className="p-4 text-gray-500">
{input.trim() ? t('tools.jsonFormatter.invalidJson') : t('tools.jsonFormatter.emptyInput')}
</div>
)}
</div>
</div>
</ContentWrapper>
</Container>
</>
);
}
function JsonView({ data }) {
const [isExpanded, setIsExpanded] = useState(true);
if (data === null || data === undefined) return null;
const [collapsedKeys, setCollapsedKeys] = useState(new Set());
if (Array.isArray(data)) {
return (
<div>
<ToggleButton onClick={() => setIsExpanded(!isExpanded)}>
{isExpanded ? (
<svg viewBox="0 0 24 24" fill="currentColor">
<path d="M19 13H5v-2h14v2z"/>
</svg>
) : (
<svg viewBox="0 0 24 24" fill="currentColor">
<path d="M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z"/>
</svg>
)}
</ToggleButton>
{!isExpanded ? (
<span style={{ color: '#6B7280', fontSize: '15px' }}>Array [{data.length}]</span>
) : (
<JsonList>
const toggleCollapse = (key) => {
const newCollapsedKeys = new Set(collapsedKeys);
if (newCollapsedKeys.has(key)) {
newCollapsedKeys.delete(key);
} else {
newCollapsedKeys.add(key);
}
setCollapsedKeys(newCollapsedKeys);
};
const renderCollapsibleValue = (value, path = '') => {
if (value === null) return <span className="text-indigo-400">null</span>;
if (typeof value === 'boolean') return <span className="text-indigo-400">{value.toString()}</span>;
if (typeof value === 'number') return <span className="text-emerald-500">{value}</span>;
if (typeof value === 'string') return <span className="text-amber-500">"{value}"</span>;
const isCollapsed = collapsedKeys.has(path);
const hasChildren = Array.isArray(value) || (typeof value === 'object' && value !== null);
if (Array.isArray(value)) {
if (isCollapsed) {
return (
<div className="inline-flex items-center gap-1">
<button
onClick={() => toggleCollapse(path)}
className="w-4 h-4 inline-flex items-center justify-center text-gray-500 hover:text-gray-700"
>
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
</button>
<span className="text-gray-500">[...]</span>
<span className="text-gray-400 text-sm ml-1">({value.length} items)</span>
</div>
);
}
return (
<div className="ml-5">
<div className="inline-flex items-center gap-1">
<button
onClick={() => toggleCollapse(path)}
className="w-4 h-4 inline-flex items-center justify-center text-gray-500 hover:text-gray-700"
>
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</button>
[
{data.map((item, index) => (
<li key={index}>
<JsonView data={item} />
{index < data.length - 1 && ','}
</li>
))}
]
</JsonList>
)}
</div>
);
} else if (typeof data === 'object' && data !== null) {
const entries = Object.entries(data);
return (
<div>
<ToggleButton onClick={() => setIsExpanded(!isExpanded)}>
{isExpanded ? (
<svg viewBox="0 0 24 24" fill="currentColor">
<path d="M19 13H5v-2h14v2z"/>
</svg>
) : (
<svg viewBox="0 0 24 24" fill="currentColor">
<path d="M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z"/>
</svg>
)}
</ToggleButton>
{!isExpanded ? (
<span style={{ color: '#6B7280', fontSize: '15px' }}>Object {`{${entries.length}}`}</span>
) : (
<JsonList>
</div>
{value.map((item, index) => (
<div key={index} className="ml-5">
{renderCollapsibleValue(item, `${path}[${index}]`)}
{index < value.length - 1 ? ',' : ''}
</div>
))}
<div>]</div>
</div>
);
}
if (typeof value === 'object') {
const entries = Object.entries(value);
if (isCollapsed) {
return (
<div className="inline-flex items-center gap-1">
<button
onClick={() => toggleCollapse(path)}
className="w-4 h-4 inline-flex items-center justify-center text-gray-500 hover:text-gray-700"
>
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
</button>
<span className="text-gray-500">{'{...}'}</span>
<span className="text-gray-400 text-sm ml-1">({entries.length} properties)</span>
</div>
);
}
return (
<div className="ml-5">
<div className="inline-flex items-center gap-1">
<button
onClick={() => toggleCollapse(path)}
className="w-4 h-4 inline-flex items-center justify-center text-gray-500 hover:text-gray-700"
>
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</button>
{'{'}
{entries.map(([key, value], index) => (
<li key={key}>
<Key>"{key}"</Key>: <JsonView data={value} />
{index < entries.length - 1 && ','}
</li>
))}
{'}'}
</JsonList>
)}
</div>
);
} else if (typeof data === 'string') {
return <Value>"{data}"</Value>;
} else {
return <Value>{JSON.stringify(data)}</Value>;
}
</div>
{entries.map(([key, val], index) => (
<div key={key} className="ml-5">
<span className="text-pink-500">"{key}"</span>: {renderCollapsibleValue(val, `${path}.${key}`)}
{index < entries.length - 1 ? ',' : ''}
</div>
))}
<div>{'}'}</div>
</div>
);
}
return value;
};
return renderCollapsibleValue(data, 'root');
}
export default JsonFormatter;
......@@ -32,18 +32,50 @@ function LanguageSelector() {
}, []);
return (
<div className="language-selector">
<button onClick={() => setIsOpen(!isOpen)} className="language-button">
{languages[lang]}
<div className="relative" ref={dropdownRef}>
<button
onClick={() => setIsOpen(!isOpen)}
className="inline-flex items-center px-3 py-2 text-sm font-medium text-gray-600 hover:text-indigo-600 transition-colors duration-200 focus:outline-none"
aria-expanded={isOpen}
aria-haspopup="true"
>
<span>{languages[lang]}</span>
<svg
className={`ml-2 h-4 w-4 transform transition-transform duration-200 ${
isOpen ? 'rotate-180' : ''
}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M19 9l-7 7-7-7"
/>
</svg>
</button>
{isOpen && (
<ul className="language-dropdown" ref={dropdownRef}>
{Object.entries(languages).map(([code, name]) => (
<li key={code} onClick={() => handleLanguageChange(code)}>
{name}
</li>
))}
</ul>
<div className="absolute right-0 mt-2 w-40 rounded-lg bg-white shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none z-50">
<div className="py-1">
{Object.entries(languages).map(([code, name]) => (
<button
key={code}
onClick={() => handleLanguageChange(code)}
className={`w-full text-left px-4 py-2 text-sm hover:bg-indigo-50 transition-colors duration-150 ${
code === lang
? 'text-indigo-600 bg-indigo-50 font-medium'
: 'text-gray-700 hover:text-indigo-600'
}`}
>
{name}
</button>
))}
</div>
</div>
)}
</div>
);
......
......@@ -11,7 +11,7 @@ import html2canvas from 'html2canvas';
const Container = styled.div`
min-height: 100vh;
background: linear-gradient(135deg, #f5f7ff 0%, #ffffff 100%);
padding: 2rem;
padding: 4rem 2rem 2rem;
position: relative;
&::before {
......
......@@ -3,7 +3,7 @@ import styled from 'styled-components';
import { marked } from 'marked';
import { useTranslation } from '../js/i18n';
import SEO from './SEO';
import Marked from 'marked-react';
import DOMPurify from 'dompurify';
// 更新预设模板
const templates = [
......@@ -54,7 +54,7 @@ const templates = [
const Container = styled.div`
min-height: 100vh;
background: linear-gradient(135deg, #f5f7ff 0%, #ffffff 100%);
padding: 2rem;
padding: 4rem 2rem 2rem;
position: relative;
&::before {
......@@ -114,6 +114,16 @@ const Section = styled.div`
gap: 0.5rem;
`;
const TemplateSection = styled(Section)`
margin-bottom: 1rem;
`;
const EditorSection = styled(Section)`
flex: 1;
display: flex;
flex-direction: column;
`;
const Label = styled.label`
font-size: 1rem;
color: #333333;
......@@ -158,77 +168,141 @@ const TemplateItem = styled.button`
`}
`;
const MarkdownEditor = styled.textarea`
const Editor = styled.textarea`
width: 100%;
height: 100px;
padding: 0.5rem;
border: 1px solid #dadce0;
border-radius: 4px;
font-size: 16px;
color: #333333;
height: 500px;
padding: 1rem;
border: none;
background: transparent;
font-family: 'SF Mono', monospace;
font-size: 14px;
line-height: 1.5;
resize: vertical;
color: #1a1a1a;
overflow-y: auto;
&:focus {
outline: none;
}
&::placeholder {
color: #64748b;
}
`;
const DownloadButton = styled.button`
background: linear-gradient(135deg, #6366F1 0%, #4F46E5 100%);
color: white;
padding: 0.5rem 1rem;
border: none;
padding: 1rem;
border-radius: 8px;
font-weight: 600;
border-radius: 6px;
cursor: pointer;
transition: all 0.3s ease;
align-self: flex-end;
margin-top: 1rem;
font-weight: 600;
transition: opacity 0.2s;
font-size: 0.9rem;
position: absolute;
top: 1.5rem;
right: 1.5rem;
opacity: ${props => props.visible ? 1 : 0};
pointer-events: ${props => props.visible ? 'auto' : 'none'};
&:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(99, 102, 241, 0.2);
opacity: 0.9;
}
`;
const PreviewContainer = styled.div`
flex: 1;
background: ${(props) => props.bgColor || '#ffffff'};
padding: 2rem;
border-radius: 16px;
box-shadow: 0 8px 32px rgba(99, 102, 241, 0.1);
border: 1px solid rgba(99, 102, 241, 0.1);
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
min-height: 200px;
height: fit-content;
align-self: flex-start;
transition: all 0.3s ease;
margin-top: 1rem;
width: 100%;
const PreviewContainer = styled(InputContainer)`
overflow: auto;
position: relative;
min-height: 400px;
display: block;
&:hover {
transform: translateY(-5px);
box-shadow: 0 12px 24px rgba(99, 102, 241, 0.15);
img {
max-width: 100%;
height: auto;
display: block;
margin: 1em auto;
}
@media (max-width: 768px) {
margin-top: 2rem;
h1, h2, h3, h4, h5, h6 {
margin-top: 1.5em;
margin-bottom: 0.5em;
font-weight: 600;
line-height: 1.3;
}
p {
margin: 1em 0;
line-height: 1.6;
}
ul, ol {
margin: 1em 0;
padding-left: 2em;
}
ul {
list-style-type: disc;
}
ol {
list-style-type: decimal;
}
li {
margin: 0.5em 0;
line-height: 1.6;
}
pre, code {
background: rgba(0, 0, 0, 0.05);
border-radius: 4px;
padding: 0.2em 0.4em;
font-family: 'SF Mono', monospace;
}
pre code {
background: none;
padding: 0;
}
blockquote {
border-left: 4px solid #e2e8f0;
margin: 1em 0;
padding-left: 1em;
color: #64748b;
}
table {
border-collapse: collapse;
width: 100%;
margin: 1em 0;
}
th, td {
border: 1px solid #e2e8f0;
padding: 0.5em;
text-align: left;
}
th {
background: rgba(0, 0, 0, 0.05);
}
`;
const Preview = styled.div`
font-size: clamp(16px, 2.5vw, 24px);
margin-bottom: 16px;
color: ${(props) => props.$color || '#333333'};
text-align: center;
line-height: 1.5;
font-family: ${(props) => props.$fontFamily || '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif'};
white-space: pre-wrap;
font-family: ${(props) => props.$font || '-apple-system, system-ui, sans-serif'};
font-size: 16px;
line-height: 1.6;
color: ${(props) => props.$color || '#1a1a1a'};
background: ${(props) => props.$background || '#ffffff'};
padding: ${(props) => props.$padding || '40px'};
border-radius: 8px;
overflow-wrap: break-word;
word-wrap: break-word;
width: 100%;
hyphens: auto;
`;
function TextToImage() {
const { t } = useTranslation();
const [text, setText] = useState('');
......@@ -238,42 +312,111 @@ function TextToImage() {
const formatText = (text) => {
return marked.parse(text, {
breaks: true,
gfm: true
gfm: true,
headerIds: false,
mangle: false,
// 自定义图片渲染
renderer: {
image(href, title, text) {
// 处理相对路径
if (href.startsWith('/')) {
href = window.location.origin + href;
}
return `<img src="${href}" alt="${text}" title="${title || ''}" style="max-width: 100%; height: auto;" crossorigin="anonymous" />`;
}
}
});
};
const handleDownload = async () => {
const previewClone = previewRef.current.cloneNode(true);
document.body.appendChild(previewClone);
previewClone.style.position = 'absolute';
previewClone.style.left = '-9999px';
previewClone.style.width = 'auto';
previewClone.style.maxWidth = '800px';
previewClone.style.height = 'auto';
previewClone.style.whiteSpace = 'pre-wrap';
previewClone.style.backgroundColor = 'white';
previewClone.style.padding = '40px';
const previewElement = previewRef.current;
if (!previewElement) return;
try {
// 等待图片加载
const waitForImages = () => {
const images = previewElement.getElementsByTagName('img');
const promises = Array.from(images).map(img => {
if (img.complete) return Promise.resolve();
return new Promise((resolve, reject) => {
img.onload = resolve;
img.onerror = reject;
// 确保图片使用完整的 URL
if (img.src.startsWith('/')) {
img.src = window.location.origin + img.src;
}
// 添加跨域属性
img.crossOrigin = 'anonymous';
});
});
return Promise.all(promises);
};
await waitForImages();
// 等待渲染完成
await new Promise(resolve => setTimeout(resolve, 500));
const html2canvas = (await import('html2canvas')).default;
const canvas = await html2canvas(previewClone, {
backgroundColor: 'white',
const canvas = await html2canvas(previewElement, {
backgroundColor: selectedTemplate.bgColor,
scale: 2,
width: previewClone.offsetWidth,
height: previewClone.offsetHeight
useCORS: true,
allowTaint: false,
logging: false,
onclone: (clonedDoc) => {
const clonedElement = clonedDoc.querySelector('.markdown-content');
if (clonedElement) {
clonedElement.style.width = '100%';
clonedElement.style.position = 'relative';
clonedElement.style.transform = 'none';
clonedElement.style.transformOrigin = '0 0';
clonedElement.style.overflow = 'visible';
}
}
});
const link = document.createElement('a');
link.download = 'text_image.png';
link.download = 'markdown-preview.png';
link.href = canvas.toDataURL('image/png');
link.click();
} catch (error) {
console.error('Failed to load html2canvas:', error);
} finally {
document.body.removeChild(previewClone);
console.error('导出图片失败:', error);
}
};
const renderPreview = () => {
// 配置 marked 选项
marked.setOptions({
gfm: true, // 启用 GitHub 风格的 Markdown
breaks: true, // 启用换行符转换为 <br>
headerIds: true,
mangle: false,
pedantic: false,
smartLists: true, // 优化列表输出
smartypants: true, // 优化标点符号
});
// 使用 DOMPurify 清理 HTML
const cleanHtml = DOMPurify.sanitize(marked(text), {
ADD_TAGS: ['img'],
ADD_ATTR: ['src', 'alt'],
});
return (
<div
ref={previewRef}
dangerouslySetInnerHTML={{ __html: cleanHtml }}
style={{
fontFamily: selectedTemplate.font,
color: selectedTemplate.textColor,
background: selectedTemplate.bgColor,
padding: selectedTemplate.padding,
minHeight: '100%',
}}
/>
);
};
return (
<>
<SEO
......@@ -287,7 +430,7 @@ function TextToImage() {
<TitleLabel>{t('tools.markdown2image.title')}</TitleLabel>
{/* 模板选择 */}
<Section>
<TemplateSection>
<Label>{t('tools.markdown2image.selectTemplate')}</Label>
<TemplateGrid>
{templates.map(template => (
......@@ -302,39 +445,27 @@ function TextToImage() {
</TemplateItem>
))}
</TemplateGrid>
</Section>
</TemplateSection>
{/* Markdown 编辑器 */}
<Section>
<EditorSection>
<Label>{t('tools.markdown2image.inputLabel')}</Label>
<MarkdownEditor
<Editor
value={text}
onChange={(e) => setText(e.target.value)}
placeholder={t('tools.markdown2image.placeholder')}
/>
</Section>
<DownloadButton onClick={handleDownload}>
{t('tools.markdown2image.downloadButton')}
</DownloadButton>
</EditorSection>
</InputContainer>
<PreviewContainer
ref={previewRef}
bgColor={selectedTemplate.bgColor}
>
<div
style={{
padding: selectedTemplate.padding,
color: selectedTemplate.textColor,
fontFamily: selectedTemplate.font,
width: '100%'
}}
<PreviewContainer>
<DownloadButton
onClick={handleDownload}
visible={text.length > 0}
>
<Marked>
{text || t('tools.markdown2image.previewDefault')}
</Marked>
</div>
{t('tools.markdown2image.downloadButton')}
</DownloadButton>
{renderPreview()}
</PreviewContainer>
</ContentWrapper>
</Container>
......
......@@ -30,7 +30,7 @@ const backgroundOptions = [
const Container = styled.div`
min-height: 100vh;
background: linear-gradient(135deg, #f5f7ff 0%, #ffffff 100%);
padding: 2rem;
padding: 4rem 2rem 2rem;
position: relative;
&::before {
......
......@@ -247,9 +247,21 @@ useEffect(() => {
const Container = styled.div`
display: flex;
gap: 2rem;
padding: 2rem;
padding: 4rem 2rem 2rem;
background: linear-gradient(135deg, #f5f7ff 0%, #ffffff 100%);
min-height: 100vh;
position: relative;
&::before {
content: '';
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 4rem;
background: linear-gradient(135deg, #f5f7ff 0%, #ffffff 100%);
z-index: -1;
}
@media (max-width: 768px) {
flex-direction: column;
......
......@@ -8,7 +8,7 @@ import '../styles/fonts.css';
const Container = styled.div`
min-height: 100vh;
background: linear-gradient(135deg, #f5f7ff 0%, #ffffff 100%);
padding: 2rem;
padding: 4rem 2rem 2rem;
position: relative;
&::before {
......
......@@ -6,7 +6,7 @@ import SEO from './SEO';
const Container = styled.div`
min-height: 100vh;
background: linear-gradient(135deg, #f5f7ff 0%, #ffffff 100%);
padding: 2rem;
padding: 4rem 2rem 2rem;
position: relative;
&::before {
......
import React, { useState, useCallback } from 'react';
import { Title, Wrapper, Container, Preview } from '../js/SharedStyles';
import styled from 'styled-components';
import { useTranslation } from '../js/i18n';
import SEO from './SEO';
import styled from 'styled-components';
const EncoderDecoderContainer = styled(Container)`
flex-direction: column;
gap: 16px;
`;
const StyledInputText = styled.textarea`
width: 100%;
height: 120px;
font-size: 15px;
padding: 16px;
border: 1px solid rgba(99, 102, 241, 0.1);
border-radius: 12px;
background: rgba(255, 255, 255, 0.8);
backdrop-filter: blur(10px);
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 Label = styled.label`
font-weight: 500;
font-size: 14px;
color: #374151;
margin-bottom: 8px;
display: block;
letter-spacing: 0.1px;
`;
const ModeSwitcher = styled.div`
margin-bottom: 8px;
select {
padding: 8px 12px;
border-radius: 8px;
border: 1px solid rgba(99, 102, 241, 0.1);
font-size: 14px;
color: #374151;
background: rgba(255, 255, 255, 0.8);
backdrop-filter: blur(10px);
cursor: pointer;
transition: all 0.3s ease;
&:focus {
border-color: rgba(99, 102, 241, 0.3);
box-shadow: 0 0 0 4px rgba(99, 102, 241, 0.1);
outline: none;
}
}
`;
const ResultContainer = styled.div`
// 复用相同的样式组件
const Container = styled.div`
min-height: 100vh;
background: linear-gradient(135deg, #f5f7ff 0%, #ffffff 100%);
padding: 4rem 2rem 2rem;
position: relative;
width: 100%;
&::before {
content: '';
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 StyledPreview = styled.div`
background: rgba(255, 255, 255, 0.8);
backdrop-filter: blur(10px);
border-radius: 12px;
border: 1px solid rgba(99, 102, 241, 0.1);
padding: 16px;
font-size: 15px;
color: #374151;
min-height: 24px;
line-height: 1.5;
const ContentWrapper = styled.div`
max-width: 1400px;
margin: 0 auto;
position: relative;
z-index: 1;
`;
const ActionButton = styled.button`
position: absolute;
top: 12px;
right: 12px;
background: rgba(99, 102, 241, 0.1);
border: none;
border-radius: 6px;
padding: 6px 12px;
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 {
background: #6366F1;
color: white;
}
svg {
width: 14px;
height: 14px;
}
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;
`;
function UrlEncoderDecoder() {
......@@ -115,11 +48,10 @@ function UrlEncoderDecoder() {
const [input, setInput] = useState('');
const [resultText, setResultText] = useState('');
const [isCopied, setIsCopied] = useState(false);
const [mode, setMode] = useState('decode'); // 'encode' 或 'decode'
const [mode, setMode] = useState('decode');
const handleModeChange = (e) => {
setMode(e.target.value);
// 当模式切换时,清空输入和输出
setInput('');
setResultText('');
};
......@@ -153,53 +85,81 @@ function UrlEncoderDecoder() {
title={t('tools.urlEncodeDecode.title')}
description={t('tools.urlEncodeDecode.description')}
/>
<Wrapper>
<Title>{t('tools.urlEncodeDecode.title')}</Title>
<EncoderDecoderContainer>
<ModeSwitcher>
<Label>{t('tools.urlEncodeDecode.modeLabel')}</Label>
<select value={mode} onChange={handleModeChange}>
<option value="encode">{t('tools.urlEncodeDecode.encode')}</option>
<option value="decode">{t('tools.urlEncodeDecode.decode')}</option>
</select>
</ModeSwitcher>
<Container>
<ContentWrapper>
<Title>{t('tools.urlEncodeDecode.title')}</Title>
<div>
<Label>
{mode === 'decode' ? t('tools.urlDecode.inputLabel') : t('tools.urlEncode.inputLabel')}
</Label>
<StyledInputText
value={input}
onChange={handleInputChange}
placeholder={mode === 'decode' ? t('tools.urlDecode.inputLabel') : t('tools.urlEncode.inputLabel')}
/>
</div>
<div>
<Label>
{mode === 'decode' ? t('tools.urlDecode.resultLabel') : t('tools.urlEncode.resultLabel')}
</Label>
<ResultContainer>
<StyledPreview>{resultText}</StyledPreview>
<ActionButton
onClick={handleCopy}
className={isCopied ? 'active' : ''}
<div className="flex flex-col gap-6">
<div className="space-y-2">
<label className="block text-sm font-medium text-gray-700">
{t('tools.urlEncodeDecode.modeLabel')}
</label>
<select
value={mode}
onChange={handleModeChange}
className="w-full sm:w-48 px-3 py-2 bg-white/80 backdrop-blur-sm border border-indigo-100 rounded-xl
focus:ring-4 focus:ring-indigo-100 focus:border-indigo-300 focus:outline-none
text-sm text-gray-700 transition duration-300"
>
{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>
) : (
<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>
)}
{isCopied ? t('tools.jsonFormatter.copiedMessage') : t('tools.jsonFormatter.copyButton')}
</ActionButton>
</ResultContainer>
<option value="encode">{t('tools.urlEncodeDecode.encode')}</option>
<option value="decode">{t('tools.urlEncodeDecode.decode')}</option>
</select>
</div>
<div className="flex flex-col lg:flex-row gap-4 lg:gap-6">
<div className="w-full lg:w-1/2 space-y-2">
<label className="block text-sm font-medium text-gray-700">
{mode === 'decode' ? t('tools.urlDecode.inputLabel') : t('tools.urlEncode.inputLabel')}
</label>
<textarea
value={input}
onChange={handleInputChange}
placeholder={mode === 'decode' ? t('tools.urlDecode.inputLabel') : t('tools.urlEncode.inputLabel')}
className="w-full h-[calc(100vh-400px)] px-4 py-3 bg-white/80 backdrop-blur-sm border border-indigo-100 rounded-xl
focus:ring-4 focus:ring-indigo-100 focus:border-indigo-300 focus:outline-none
text-sm font-mono text-gray-700 transition duration-300 resize-none"
/>
</div>
<div className="w-full lg:w-1/2 space-y-2">
<label className="block text-sm font-medium text-gray-700">
{mode === 'decode' ? t('tools.urlDecode.resultLabel') : t('tools.urlEncode.resultLabel')}
</label>
<div className="relative h-[calc(100vh-400px)]">
<div className="h-full w-full px-4 py-3 bg-white/80 backdrop-blur-sm border border-indigo-100
rounded-xl text-sm font-mono text-gray-700 whitespace-pre-wrap break-all overflow-auto">
{resultText}
</div>
<button
onClick={handleCopy}
className={`absolute top-2 right-2 flex items-center gap-1.5 px-3 py-1.5 text-sm font-medium rounded-lg transition-all duration-200
${isCopied
? 'bg-green-100 text-green-700'
: 'bg-white/50 hover:bg-indigo-50 text-gray-600 hover:text-indigo-600'
}`}
>
{isCopied ? (
<>
<svg className="w-4 h-4" 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>
{t('tools.jsonFormatter.copied')}
</>
) : (
<>
<svg className="w-4 h-4" 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>
{t('tools.jsonFormatter.copy')}
</>
)}
</button>
</div>
</div>
</div>
</div>
</EncoderDecoderContainer>
</Wrapper>
</ContentWrapper>
</Container>
</>
);
}
......
......@@ -34,15 +34,29 @@ export const Container = styled.div`
display: flex;
flex-direction: column;
width: 100%;
background-color: white;
border-radius: 10px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
overflow: hidden;
margin: 10px auto;
min-height: 100vh;
background: linear-gradient(135deg, #f5f7ff 0%, #ffffff 100%);
padding: 4rem 2rem 2rem;
position: relative;
&::before {
content: '';
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;
z-index: -1;
}
@media (min-width: 768px) {
flex-direction: row;
height: 70vh;
height: 100vh;
}
`;
......
......@@ -5,8 +5,8 @@
"selectTemplate": "选择模板",
"inputLabel": "输入文本 (支持 Markdown)",
"placeholder": "# 标题\n## 子标题\n- 列表项\n**粗体** *斜体*",
"downloadButton": "生成图片",
"previewDefault": "# 预览区域\n输入文本后在这里预览效果",
"downloadButton": "导出图片",
"previewDefault": "输入文本后在这里预览效果",
"templates": {
"simple": "简约",
"ai-style": "AI风格",
......@@ -56,8 +56,11 @@
"description": "美化和验证 JSON 数据",
"inputPlaceholder": "输入 JSON 数据",
"invalidJson": "无效的 JSON",
"copyButton": "复制",
"copiedMessage": "已复制"
"emptyInput": "",
"copy": "复制",
"copied": "已复制",
"compress": "压缩",
"expand": "展开"
},
"urlEncodeDecode": {
"title": "URL 编码/解码",
......
......@@ -251,47 +251,47 @@ const AIProduct = () => {
title={t('ai-products.title')}
description={t('ai-products.description')}
/>
<main>
<section className="tools-section">
<main className="container mx-auto px-4 pt-16 pb-8">
<section className="mt-8">
{Object.keys(groupedTools).map(category => (
<div key={category} className="category-group">
<h2 className="category-title">{t(`categories.${category}`)}</h2>
<div className="tools-grid">
<div key={category} className="mb-8">
<h2 className="text-2xl font-semibold mb-4 px-4 text-gray-800">{t(`categories.${category}`)}</h2>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6 px-4">
{groupedTools[category].map(tool => (
tool.external ? (
<a
href={tool.path}
key={tool.id}
className="tool-card"
className="flex items-center p-4 bg-white/10 backdrop-blur-md rounded-xl border border-white/10 transition-all hover:translate-y-[-2px] hover:shadow-lg hover:bg-white/15"
target="_blank"
rel="noopener noreferrer"
>
<img
src={tool.icon}
alt={`${t(`aiproducts.${tool.id}.title`)} icon`}
className="tool-icon"
className="w-12 h-12 object-contain mr-4"
loading="lazy"
/>
<div className="tool-content">
<h3 className="tool-title">{t(`aiproducts.${tool.id}.title`)}</h3>
<p className="tool-description">{t(`aiproducts.${tool.id}.description`)}</p>
<div className="flex-1 min-w-0">
<h3 className="text-lg font-semibold mb-1 text-gray-800">{t(`aiproducts.${tool.id}.title`)}</h3>
<p className="text-sm text-gray-600 line-clamp-2">{t(`aiproducts.${tool.id}.description`)}</p>
</div>
</a>
) : (
<Link
to={tool.path}
key={tool.id}
className="tool-card"
className="flex items-center p-4 bg-white/10 backdrop-blur-md rounded-xl border border-white/10 transition-all hover:translate-y-[-2px] hover:shadow-lg hover:bg-white/15"
>
<img
src={tool.icon}
alt={`${t(`tools.${tool.id}.title`)} icon`}
className="tool-icon"
className="w-12 h-12 object-contain mr-4"
loading="lazy"
/>
<div className="tool-content">
<h3 className="tool-title">{t(`tools.${tool.id}.title`)}</h3>
<p className="tool-description">{t(`tools.${tool.id}.description`)}</p>
<div className="flex-1 min-w-0">
<h3 className="text-lg font-semibold mb-1 text-gray-800">{t(`tools.${tool.id}.title`)}</h3>
<p className="text-sm text-gray-600 line-clamp-2">{t(`tools.${tool.id}.description`)}</p>
</div>
</Link>
)
......
......@@ -12,7 +12,7 @@ const About = () => {
title={t('about.title')}
description={t('about.description')}
/>
<main>
<main className="container mx-auto px-4 pt-16 pb-8">
<section className="about-section">
<div className="about-header">
<h1>{t('about.title')}</h1>
......
......@@ -18,20 +18,24 @@ const Home = () => {
title={t('blog.title')}
description={t('blog.description')}
/>
<main>
<section className="tools-section">
<div className="tools-grid">
<main className="container mx-auto px-4 pt-16 pb-8">
<section className="mt-8">
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6 px-4">
{tools.map(tool => (
<Link to={tool.path} key={tool.id} className="tool-card">
<Link
to={tool.path}
key={tool.id}
className="flex items-center p-4 bg-white/10 backdrop-blur-md rounded-xl border border-white/10 transition-all hover:translate-y-[-2px] hover:shadow-lg hover:bg-white/15"
>
<img
src={tool.icon}
alt={`${t(`tools.${tool.id}.title`)} icon`}
className="tool-icon"
className="w-12 h-12 object-contain mr-4"
loading="lazy"
/>
<div className="tool-content">
<h3 className="tool-title">{t(`tools.${tool.id}.title`)}</h3>
<p className="tool-description">{t(`tools.${tool.id}.description`)}</p>
<div className="flex-1 min-w-0">
<h3 className="text-lg font-semibold mb-1 text-gray-800">{t(`tools.${tool.id}.title`)}</h3>
<p className="text-sm text-gray-600 line-clamp-2">{t(`tools.${tool.id}.description`)}</p>
</div>
</Link>
))}
......
......@@ -20,20 +20,24 @@ const DevTools = () => {
title={t('dev-tools.title')}
description={t('dev-tools.description')}
/>
<main>
<section className="tools-section">
<div className="tools-grid">
<main className="container mx-auto px-4 pt-16 pb-8">
<section className="mt-8">
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6 px-4">
{tools.map(tool => (
<Link to={tool.path} key={tool.id} className="tool-card">
<Link
to={tool.path}
key={tool.id}
className="flex items-center p-4 bg-white/10 backdrop-blur-md rounded-xl border border-white/10 transition-all hover:translate-y-[-2px] hover:shadow-lg hover:bg-white/15"
>
<img
src={tool.icon}
alt={`${t(`tools.${tool.id}.title`)} icon`}
className="tool-icon"
className="w-12 h-12 object-contain mr-4"
loading="lazy"
/>
<div className="tool-content">
<h3 className="tool-title">{t(`tools.${tool.id}.title`)}</h3>
<p className="tool-description">{t(`tools.${tool.id}.description`)}</p>
<div className="flex-1 min-w-0">
<h3 className="text-lg font-semibold mb-1 text-gray-800">{t(`tools.${tool.id}.title`)}</h3>
<p className="text-sm text-gray-600 line-clamp-2">{t(`tools.${tool.id}.description`)}</p>
</div>
</Link>
))}
......
......@@ -12,17 +12,15 @@ const tools = [
{ id: 'imageWatermark', icon: '/assets/icon/image-watermark.png', path: '/image-watermark' },
{ id: 'imageBackgroundRemover', icon: '/assets/icon/image-background-remover.png', path: '/background-remover' },
{ id: 'textBehindImage', icon: '/assets/icon/text-behind-image.png', path: '/text-behind-image' },
{ id: 'latex2image', icon: '/assets/icon/latex2image.png', path: '/latex-to-image' },
{ id: 'jsonFormatter', icon: '/assets/icon/json-format.png', path: '/json-formatter' },
{ id: 'urlEncodeDecode', icon: '/assets/icon/url-endecode.png', path: '/url-encode-and-decode' },
{ id: 'imageBase64Converter', icon: '/assets/icon/image-base64.png', path: '/image-base64' },
{ id: 'textDiff', icon: '/assets/icon/diff.png', path: '/text-diff' },
{ id: 'openAITimeline', icon: '/assets/icon/openai_small.svg', path: '/openai-timeline' },
{ id: 'anthropicTimeline', icon: '/assets/icon/anthropic_small.svg', path: '/anthropic-timeline' },
{ id: 'modelPrice', icon: '/assets/icon/openai_small.svg', path: '/llm-model-price' },
{ id: 'fisherai', icon: '/assets/icon/fisherai.png', path: 'https://chromewebstore.google.com/detail/fisherai-your-best-summar/ipfiijaobcenaibdpaacbbpbjefgekbj', external: true } // 新增外部链接
{ id: 'fisherai', icon: '/assets/icon/fisherai.png', path: 'https://chromewebstore.google.com/detail/fisherai-your-best-summar/ipfiijaobcenaibdpaacbbpbjefgekbj', external: true }
];
const Home = () => {
......@@ -30,31 +28,35 @@ const Home = () => {
const renderToolLink = (tool) => {
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">
<img
src={tool.icon}
alt={`${t(`tools.${tool.id}.title`)} icon`}
className="tool-icon"
className="w-12 h-12 object-contain group-hover:scale-110 transition-transform duration-300"
loading="lazy"
/>
<div className="tool-content">
<h3 className="tool-title">{t(`tools.${tool.id}.title`)}</h3>
<p className="tool-description">{t(`tools.${tool.id}.description`)}</p>
<div className="flex-1">
<h3 className="text-lg font-semibold text-gray-800 mb-1 group-hover:text-indigo-600 transition-colors duration-300">
{t(`tools.${tool.id}.title`)}
</h3>
<p className="text-sm text-gray-600 overflow-hidden text-ellipsis [-webkit-line-clamp:2] [display:-webkit-box] [-webkit-box-orient:vertical]">
{t(`tools.${tool.id}.description`)}
</p>
</div>
</>
</div>
);
return tool.external ? (
<a
href={tool.path}
className="tool-card"
className="block"
target="_blank"
rel="noopener noreferrer"
>
{content}
</a>
) : (
<Link to={tool.path} className="tool-card">
<Link to={tool.path} className="block">
{content}
</Link>
);
......@@ -66,17 +68,83 @@ const Home = () => {
title={t('title')}
description={t('slogan')}
/>
<main>
<section className="tools-section">
<div className="tools-grid">
<main className="min-h-screen bg-gradient-to-br from-indigo-50/50 via-white to-indigo-50/50 pt-16">
{/* Hero Section */}
<div className="relative overflow-hidden">
<div className="absolute inset-0 bg-grid-pattern opacity-5"></div>
<div className="max-w-7xl mx-auto px-4 pt-20 sm:pt-32 pb-12 sm:pb-20">
<div className="text-center relative z-10">
<h1 className="text-4xl sm:text-5xl font-bold text-indigo-900/90 mb-4 sm:mb-6 animate-fade-in">
AI Toolbox
</h1>
<p className="text-lg sm:text-xl text-indigo-800/80 max-w-2xl mx-auto mb-8 sm:mb-12 animate-fade-in-delay px-4">
{t('slogan')}
</p>
<div className="w-full h-0.5 max-w-xs mx-auto bg-gradient-to-r from-transparent via-indigo-400/50 to-transparent opacity-75"></div>
</div>
</div>
</div>
{/* Tools Grid */}
<div className="max-w-7xl mx-auto px-4 py-8 sm:py-16">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 sm:gap-8">
{tools.map(tool => (
<React.Fragment key={tool.id}>
{renderToolLink(tool)}
</React.Fragment>
))}
</div>
</section>
</div>
{/* Footer Navigation */}
<div className="max-w-7xl mx-auto px-4 pb-12 sm:pb-20">
<div className="flex flex-wrap justify-center gap-4 sm:gap-8">
<a
href="https://github.com/fisherdaddy/ai-toolbox"
target="_blank"
rel="noopener noreferrer"
className="group flex items-center px-6 py-3 rounded-full bg-white/80 hover:bg-white shadow-sm hover:shadow-md transition-all duration-300"
>
<svg className="w-5 h-5 mr-3 text-gray-700 group-hover:text-indigo-500 transition-colors" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 0C5.37 0 0 5.37 0 12c0 5.31 3.435 9.795 8.205 11.385.6.105.825-.255.825-.57 0-.285-.015-1.23-.015-2.235-3.015.555-3.795-.735-4.035-1.41-.135-.345-.72-1.41-1.23-1.695-.42-.225-1.02-.78-.015-.795.945-.015 1.62.87 1.845 1.23 1.08 1.815 2.805 1.305 3.495.99.105-.78.42-1.305.765-1.605-2.67-.3-5.46-1.335-5.46-5.925 0-1.305.465-2.385 1.23-3.225-.12-.3-.54-1.53.12-3.18 0 0 1.005-.315 3.3 1.23.96-.27 1.98-.405 3-.405s2.04.135 3 .405c2.295-1.56 3.3-1.23 3.3-1.23.66 1.65.24 2.88.12 3.18.765.84 1.23 1.905 1.23 3.225 0 4.605-2.805 5.625-5.475 5.925.435.375.81 1.095.81 2.22 0 1.605-.015 2.895-.015 3.3 0 .315.225.69.825.57A12.02 12.02 0 0024 12c0-6.63-5.37-12-12-12z"/>
</svg>
<span className="text-gray-700 group-hover:text-indigo-500 font-medium transition-colors">GitHub</span>
</a>
<Link
to="/about"
className="group flex items-center px-6 py-3 rounded-full bg-white/80 hover:bg-white shadow-sm hover:shadow-md transition-all duration-300"
>
<svg className="w-5 h-5 mr-3 text-gray-700 group-hover:text-indigo-500 transition-colors" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path strokeLinecap="round" strokeLinejoin="round" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span className="text-gray-700 group-hover:text-indigo-500 font-medium transition-colors">{t('navigation.about')}</span>
</Link>
</div>
</div>
</main>
<style jsx global>{`
.bg-grid-pattern {
background-image: radial-gradient(circle at 1px 1px, rgb(226 232 240 / 30%) 1px, transparent 0);
background-size: 24px 24px;
}
.animate-fade-in {
animation: fadeIn 0.8s ease-out;
}
.animate-fade-in-delay {
animation: fadeIn 0.8s ease-out 0.2s both;
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
`}</style>
</>
);
};
......
......@@ -18,51 +18,32 @@ const tools = [
const ImageTools = () => {
const { t } = useTranslation();
const renderToolLink = (tool) => {
const content = (
<>
<img
src={tool.icon}
alt={`${t(`tools.${tool.id}.title`)} icon`}
className="tool-icon"
loading="lazy"
/>
<div className="tool-content">
<h3 className="tool-title">{t(`tools.${tool.id}.title`)}</h3>
<p className="tool-description">{t(`tools.${tool.id}.description`)}</p>
</div>
</>
);
return tool.external ? (
<a
href={tool.path}
className="tool-card"
target="_blank"
rel="noopener noreferrer"
>
{content}
</a>
) : (
<Link to={tool.path} className="tool-card">
{content}
</Link>
);
};
return (
<>
<SEO
title={t('title')}
description={t('slogan')}
/>
<main>
<section className="tools-section">
<div className="tools-grid">
<main className="container mx-auto px-4 pt-16 pb-8">
<section className="mt-8">
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6 px-4">
{tools.map(tool => (
<React.Fragment key={tool.id}>
{renderToolLink(tool)}
</React.Fragment>
<Link
to={tool.path}
key={tool.id}
className="flex items-center p-4 bg-white/10 backdrop-blur-md rounded-xl border border-white/10 transition-all hover:translate-y-[-2px] hover:shadow-lg hover:bg-white/15"
>
<img
src={tool.icon}
alt={`${t(`tools.${tool.id}.title`)} icon`}
className="w-12 h-12 object-contain mr-4"
loading="lazy"
/>
<div className="flex-1 min-w-0">
<h3 className="text-lg font-semibold mb-1 text-gray-800">{t(`tools.${tool.id}.title`)}</h3>
<p className="text-sm text-gray-600 line-clamp-2">{t(`tools.${tool.id}.description`)}</p>
</div>
</Link>
))}
</div>
</section>
......
.no-underline {
text-decoration: none;
color: inherit;
}
.no-underline:hover {
text-decoration: none;
}
.logo {
width: 40px;
height: 40px;
margin-right: 15px;
object-fit: contain;
}
.title {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Helvetica', 'Arial', sans-serif;
font-size: 1.8rem;
font-weight: 600;
letter-spacing: 0.02em;
color: #6366F1; /* 使用与图片中紫色渐变相近的颜色 */
transition: color 0.2s ease;
}
.logo-title-container {
display: flex;
align-items: center;
animation: fadeIn 0.5s ease-in-out;
}
.logo-title-container .title {
display: flex;
align-items: center;
}
.logo-title-container:hover .title {
color: #4F46E5; /* 悬停时稍微深一点的紫色 */
}
/* 添加一个简单的动画效果 */
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
/* 为移动设备优化 */
@media (max-width: 768px) {
.title {
font-size: 1.3rem;
}
}
header nav {
display: flex;
align-items: center;
padding: 10px 20px;
}
.menu-items {
display: flex;
align-items: center;
margin-left: 40px; /* 调整导航菜单与标题之间的距离 */
}
.menu-items a {
color: #4B5563;
text-decoration: none;
font-size: 1.2rem;
font-weight: 500;
padding: 0.5rem 0;
position: relative;
transition: color 0.3s ease;
}
.menu-items a:hover {
color: #4F46E5; /* 悬停时的颜色,稍深的紫色 */
}
.menu-items a.active {
color: #6366F1; /* 高亮颜色,与网站主题一致 */
font-weight: bold;
border-bottom: 2px solid #6366F1; /* 添加下划线高亮 */
}
.right-container {
display: flex;
align-items: center;
margin-left: auto; /* 将右侧容器推到最右边 */
}
.right-container > * {
margin-left: 15px;
}
.auth-container {
display: flex;
align-items: center;
}
.user-info {
display: flex;
align-items: center;
}
.user-info span {
margin-right: 10px;
}
.user-info button {
padding: 5px 10px;
cursor: pointer;
}
@media (max-width: 768px) {
.right-container {
flex-direction: column;
align-items: flex-end;
}
.right-container > * {
margin-left: 0;
margin-top: 10px;
}
}
/* 头像容器 */
.avatar-container {
position: relative;
display: inline-block;
}
/* 用户头像 */
.avatar {
width: 32px;
height: 32px;
border-radius: 50%;
cursor: pointer;
}
/* 下拉菜单 */
.dropdown-menu {
position: absolute;
top: 100%; /* 紧贴在头像下方 */
left: 50%;
transform: translateX(-50%);
margin-top: 5px; /* 可选:头像和菜单之间的间距 */
background-color: white;
border: 1px solid #ccc;
padding: 10px;
min-width: 100px; /* 可选:设置最小宽度 */
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
z-index: 1000;
}
/* 下拉菜单中的按钮 */
.dropdown-menu button {
background: none;
border: none;
cursor: pointer;
font-size: 16px;
color: #333;
}
.dropdown-menu button:hover {
color: #007bff;
}
/* 调整导航栏布局 */
header nav {
display: flex;
align-items: center;
padding: 10px 20px;
}
.right-container {
margin-left: auto; /* 将右侧容器推到最右边 */
display: flex;
align-items: center;
}
/* 导航栏基础样式 */
header {
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: 1000;
background: rgba(255, 255, 255, 0.8);
backdrop-filter: blur(10px);
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
}
header nav {
max-width: 1200px;
margin: 0 auto;
display: flex;
align-items: center;
padding: 0.8rem 1.5rem;
}
/* Logo和标题容器 */
.logo-title-container {
display: flex;
align-items: center;
gap: 0.8rem;
}
.logo {
width: 35px;
height: 35px;
transition: transform 0.3s ease;
}
.logo:hover {
transform: scale(1.05);
}
.title {
font-size: 1.8rem;
font-weight: 600;
background: linear-gradient(135deg, #6366F1 0%, #4F46E5 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
letter-spacing: 0.02em;
}
/* 导航菜单 */
.menu-items {
display: flex;
align-items: center;
margin-left: 3rem;
gap: 2rem;
}
.menu-items a {
color: #4B5563;
text-decoration: none;
font-size: 1.2rem;
font-weight: 500;
padding: 0.5rem 0;
position: relative;
transition: color 0.3s ease;
}
.menu-items a:hover {
color: #6366F1;
}
.menu-items a::after {
content: '';
position: absolute;
bottom: 0;
left: 0;
width: 0;
height: 2px;
background: linear-gradient(135deg, #6366F1 0%, #4F46E5 100%);
transition: width 0.3s ease;
}
.menu-items a:hover::after {
width: 100%;
}
.menu-items a.active {
color: #6366F1;
}
.menu-items a.active::after {
width: 100%;
}
/* 右侧容器 */
.right-container {
margin-left: auto;
display: flex;
align-items: center;
gap: 1.5rem;
}
/* 用户头像和下拉菜单 */
.avatar-container {
position: relative;
}
.avatar {
width: 35px;
height: 35px;
border-radius: 50%;
cursor: pointer;
border: 2px solid transparent;
transition: all 0.3s ease;
}
.avatar:hover {
border-color: #6366F1;
transform: scale(1.05);
}
.dropdown-menu {
position: absolute;
top: 120%;
right: 0;
background: rgba(255, 255, 255, 0.95);
backdrop-filter: blur(10px);
border-radius: 8px;
padding: 0.5rem;
min-width: 120px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.1);
border: 1px solid rgba(255, 255, 255, 0.2);
opacity: 0;
transform: translateY(-10px);
transition: all 0.3s ease;
pointer-events: none;
}
.dropdown-menu.active {
opacity: 1;
transform: translateY(0);
pointer-events: auto;
}
.dropdown-menu button {
width: 100%;
padding: 0.6rem 1rem;
background: none;
border: none;
color: #4B5563;
font-size: 0.9rem;
text-align: left;
cursor: pointer;
border-radius: 4px;
transition: all 0.2s ease;
}
.dropdown-menu button:hover {
background: rgba(99, 102, 241, 0.1);
color: #6366F1;
}
/* 响应式设计 */
@media (max-width: 768px) {
.menu-items {
display: none;
}
.right-container {
gap: 1rem;
}
}
/* 登录按钮样式 */
.auth-container .login-button {
background: linear-gradient(135deg, #6366F1 0%, #4F46E5 100%);
color: white;
padding: 8px 20px;
border-radius: 8px;
font-size: 0.95rem;
font-weight: 500;
text-decoration: none;
transition: all 0.3s ease;
border: none;
display: inline-flex;
align-items: center;
gap: 8px;
}
.auth-container .login-button:hover {
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(99, 102, 241, 0.2);
background: linear-gradient(135deg, #4F46E5 0%, #4338CA 100%);
}
/* 头像和下拉菜单样式优化 */
.avatar-container {
position: relative;
cursor: pointer;
}
.avatar {
width: 35px;
height: 35px;
border-radius: 50%;
border: 2px solid transparent;
transition: all 0.3s ease;
}
.avatar:hover {
border-color: #6366F1;
transform: scale(1.05);
}
.dropdown-menu {
position: absolute;
top: calc(100% + 8px);
right: 0;
background: rgba(255, 255, 255, 0.95);
backdrop-filter: blur(10px);
border-radius: 8px;
padding: 8px 0;
min-width: 150px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.1);
border: 1px solid rgba(255, 255, 255, 0.2);
opacity: 0;
visibility: hidden;
transform: translateY(-10px);
transition: all 0.3s ease;
}
.dropdown-menu.active {
opacity: 1;
visibility: visible;
transform: translateY(0);
}
.dropdown-menu button {
width: 100%;
padding: 8px 16px;
background: none;
border: none;
color: #4B5563;
font-size: 0.95rem;
text-align: left;
cursor: pointer;
transition: all 0.2s ease;
display: flex;
align-items: center;
gap: 8px;
}
.dropdown-menu button:hover {
background: rgba(99, 102, 241, 0.1);
color: #6366F1;
}
@media screen and (max-width: 768px) {
header {
height: auto;
padding: 0;
}
header nav {
padding: 0.6rem 1rem;
}
.logo {
width: 30px;
height: 30px;
}
.title {
font-size: 1.4rem;
}
.menu-items {
position: fixed;
top: 60px;
left: 0;
right: 0;
background: rgba(255, 255, 255, 0.95);
backdrop-filter: blur(10px);
padding: 1rem;
flex-direction: column;
gap: 1rem;
border-bottom: 1px solid rgba(99, 102, 241, 0.1);
display: none;
}
.menu-items.active {
display: flex;
}
.menu-items a {
width: 100%;
text-align: center;
padding: 0.8rem;
}
.right-container {
gap: 0.8rem;
}
.auth-container .login-button {
padding: 6px 12px;
font-size: 0.9rem;
}
}
/* 移动端菜单按钮 */
.mobile-menu-button {
display: none;
background: none;
border: none;
padding: 8px;
cursor: pointer;
color: #4B5563;
transition: color 0.3s ease;
}
.mobile-menu-button:hover {
color: #6366F1;
}
/* 移动端适配 */
@media screen and (max-width: 768px) {
header {
position: fixed;
top: 0;
left: 0;
right: 0;
background: rgba(255, 255, 255, 0.95);
backdrop-filter: blur(10px);
z-index: 1000;
}
header nav {
padding: 0.8rem 1rem;
justify-content: space-between;
align-items: center;
}
.mobile-menu-button {
display: block;
z-index: 1001;
}
.menu-container {
position: fixed;
top: 60px; /* 导航栏高度 */
left: 0;
right: 0;
bottom: 0;
background: rgba(255, 255, 255, 0.98);
padding: 1rem;
transform: translateX(100%);
transition: transform 0.3s ease;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 1rem;
z-index: 1000;
}
.menu-container.mobile-menu-open {
transform: translateX(0);
}
.menu-items {
display: flex;
flex-direction: column;
margin: 0;
width: 100%;
gap: 0.5rem;
}
.menu-items a {
width: 100%;
padding: 1rem;
text-align: center;
}
.right-container {
margin-top: 1rem;
width: 100%;
}
}
/* 横屏模式优化 */
@media screen and (max-width: 768px) and (orientation: landscape) {
.menu-container {
top: 50px;
}
.menu-items {
flex-direction: row;
flex-wrap: wrap;
justify-content: center;
}
.menu-items a {
width: auto;
}
.right-container {
flex-direction: row;
justify-content: center;
}
}
/* 移动端样式统一处理 */
@media screen and (max-width: 768px) {
header {
position: fixed;
top: 0;
left: 0;
right: 0;
background: rgba(255, 255, 255, 0.95);
backdrop-filter: blur(10px);
z-index: 1000;
}
header nav {
padding: 0.8rem 1rem;
justify-content: space-between;
align-items: center;
}
.mobile-menu-button {
display: block;
z-index: 1001;
background: none;
border: none;
padding: 8px;
cursor: pointer;
color: #4B5563;
transition: color 0.3s ease;
}
.mobile-menu-button:hover {
color: #6366F1;
}
.menu-container {
position: fixed;
top: 60px;
left: 0;
right: 0;
bottom: 0;
background: rgba(255, 255, 255, 0.98);
backdrop-filter: blur(10px);
padding: 1rem;
transform: translateX(100%);
transition: transform 0.3s ease;
z-index: 999;
display: flex;
flex-direction: column;
overflow-y: auto;
}
.menu-container.mobile-menu-open {
transform: translateX(0);
}
.menu-items {
display: flex;
flex-direction: column;
margin: 0;
width: 100%;
gap: 1rem;
}
.menu-items a {
width: 100%;
text-align: center;
padding: 1rem;
font-size: 1.1rem;
}
.right-container {
margin-top: 1rem;
width: 100%;
display: flex;
flex-direction: column;
align-items: center;
gap: 1rem;
}
.auth-container {
width: 100%;
display: flex;
justify-content: center;
}
.login-button {
width: 100%;
justify-content: center;
}
}
/* 横屏模式优化 */
@media screen and (max-width: 768px) and (orientation: landscape) {
.menu-container {
top: 50px;
}
.menu-items {
flex-direction: row;
flex-wrap: wrap;
justify-content: center;
}
.menu-items a {
width: auto;
}
.right-container {
flex-direction: row;
justify-content: center;
}
}
/* 移动端菜单按钮 */
.mobile-menu-button {
display: none;
background: none;
border: none;
padding: 8px;
cursor: pointer;
color: #4B5563;
transition: color 0.3s ease;
}
/* 移动端菜单容器 */
.menu-container {
display: flex;
align-items: center;
flex: 1;
}
@media screen and (max-width: 768px) {
.mobile-menu-button {
display: block;
}
.menu-container {
position: fixed;
top: 60px;
left: 0;
right: 0;
bottom: 0;
background: rgba(255, 255, 255, 0.98);
padding: 1rem;
transform: translateX(100%);
transition: transform 0.3s ease;
z-index: 999;
display: flex;
flex-direction: column;
}
.menu-container.mobile-menu-open {
transform: translateX(0);
}
.menu-items {
display: flex !important; /* 覆盖之前的 display: none */
flex-direction: column;
margin: 0;
width: 100%;
gap: 1rem;
}
.menu-items a {
width: 100%;
text-align: center;
padding: 1rem;
}
.right-container {
margin-top: 1rem;
width: 100%;
}
}
/* 横屏模式优化 */
@media screen and (max-width: 768px) and (orientation: landscape) {
.menu-container {
top: 50px;
}
.menu-items {
flex-direction: row;
flex-wrap: wrap;
justify-content: center;
}
.menu-items a {
width: auto;
}
}
......@@ -187,7 +187,9 @@
}
.pricing-charts-container {
padding: 2rem;
padding: 4rem 2rem 2rem;
max-width: 1200px;
margin: 0 auto;
background: var(--bg-primary);
min-height: 100vh;
}
......
......@@ -3,7 +3,7 @@
line-height: 1.6;
max-width: 1000px;
margin: 0 auto;
padding: 2rem;
padding: 4rem 2rem 2rem;
position: relative;
background: linear-gradient(135deg, rgba(255, 255, 255, 0.9), rgba(255, 255, 255, 0.95));
min-height: 100vh;
......
:root {
--primary-color: #000;
--secondary-color: #06c;
--background-color: #fff;
--text-color: #1d1d1f;
--card-background: #fbfbfd;
--card-shadow: 0 4px 20px rgba(0, 0, 0, 0.1);
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
background-color: var(--background-color);
color: var(--text-color);
margin: 0;
padding: 0;
line-height: 1.47059;
font-weight: 400;
letter-spacing: -0.022em;
}
.app-container {
display: flex;
flex-direction: column;
min-height: 100vh;
}
.content-wrapper {
flex: 1;
padding-top: 44px;
padding-bottom: 20px;
}
header {
background-color: rgba(255, 255, 255, 0.8);
backdrop-filter: saturate(180%) blur(20px);
padding: 0 5%;
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: 1000;
height: 44px;
display: flex;
align-items: center;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}
nav {
display: flex;
justify-content: space-between;
align-items: center;
width: 100%;
}
main {
max-width: 1200px;
width: 100%;
margin: 0 auto;
padding: 1rem 1rem 1rem;
}
.tools-section h2 {
text-align: center;
font-size: 2.5rem;
margin-bottom: 2rem;
background: linear-gradient(135deg, #6366F1 0%, #4F46E5 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
font-weight: 700;
letter-spacing: -0.02em;
}
.tools-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 2rem;
padding: 0 2rem;
max-width: 1400px;
margin: 0 auto;
}
.tool-card {
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);
transition: all 0.3s ease;
display: flex;
align-items: center;
text-decoration: none;
height: 100%;
}
.tool-card:hover {
transform: translateY(-5px);
box-shadow: 0 12px 24px rgba(99, 102, 241, 0.15);
border-color: rgba(99, 102, 241, 0.3);
}
.tool-icon {
width: 40px; /* 调整图标宽度 */
height: 40px; /* 调整图标高度 */
object-fit: contain;
margin-right: 1rem; /* 缩小图标与文本之间的间距 */
}
.tool-content {
display: flex;
flex-direction: column;
}
.tool-title {
font-size: 1.4rem;
font-weight: 600;
margin: 0;
margin-bottom: 0.5rem;
background: linear-gradient(135deg, #1a1a1a 0%, #333333 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
letter-spacing: -0.01em;
}
.tool-description {
font-size: 1rem;
color: #4B5563;
line-height: 1.5;
margin: 0;
font-weight: 400;
}
footer {
background-color: var(--card-background);
color: #86868b;
text-align: center;
font-size: 0.9rem;
}
@media (max-width: 768px) {
.tools-grid {
grid-template-columns: 1fr;
}
.tool-card {
flex-direction: column; /* 在小屏幕上堆叠图标和文本 */
align-items: center; /* 居中对齐 */
text-align: center;
padding: 1rem;
}
.tool-icon {
margin-right: 0;
margin-bottom: 1rem;
}
.tool-title {
margin-bottom: 0.25rem; /* 减少标题与描述之间的间距 */
}
.tool-description {
line-height: 1.4; /* 保持一致的行高 */
}
}
.language-selector {
position: relative;
display: inline-block;
}
.language-button {
background: none;
border: none;
padding: 8px 12px;
font-size: 14px;
cursor: pointer;
color: #333;
}
.language-dropdown {
position: absolute;
top: 100%;
right: 0;
background-color: white;
border-radius: 4px;
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
list-style-type: none;
padding: 0;
margin: 4px 0 0;
z-index: 1000;
max-height: 200px;
overflow-y: auto;
min-width: 120px;
}
.language-dropdown li {
padding: 8px 16px;
cursor: pointer;
}
.language-dropdown li:hover {
background-color: #f5f5f5;
}
@media (max-width: 768px) {
.language-dropdown {
right: auto;
left: 0;
}
}
.footer-separator {
display: inline-block;
width: 1px;
height: 1em;
background-color: #ccc;
margin: 0 8px;
vertical-align: middle;
}
.footer-link {
text-decoration: none;
color: inherit;
}
.footer-link:hover {
text-decoration: none;
}
.footer a {
text-decoration: none;
color: inherit;
}
.footer a:hover {
text-decoration: none;
}
.category-group {
margin-bottom: 1rem;
margin-top: 6rem;
}
.category-group:first-child {
margin-top: 0;
}
/* 添加网格背景效果 */
.tools-section {
position: relative;
padding: 1rem 0;
background:
linear-gradient(rgba(255, 255, 255, 0.9), rgba(255, 255, 255, 0.9)),
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: 100% 100%, 20px 20px, 20px 20px;
}
/* 移动端适配基础设置 */
@media screen and (max-width: 768px) {
:root {
font-size: 14px; /* 调整基础字体大小 */
}
main {
padding: 1rem;
margin-top: 3.5rem; /* 为固定导航栏留出空间 */
}
.tools-section {
padding: 1rem 0;
}
.tools-section h2 {
font-size: 2rem;
margin-bottom: 1.5rem;
padding: 0 1rem;
}
.tools-grid {
grid-template-columns: 1fr;
gap: 1rem;
padding: 0 1rem;
}
.tool-card {
padding: 1.2rem;
}
.tool-icon {
width: 35px;
height: 35px;
}
.tool-title {
font-size: 1.2rem;
}
.tool-description {
font-size: 0.95rem;
}
}
/* 针对更小屏幕的优化 */
@media screen and (max-width: 480px) {
.tools-section h2 {
font-size: 1.8rem;
}
.tool-card {
padding: 1rem;
}
.tool-icon {
width: 30px;
height: 30px;
}
}
/* 针对横屏模式的优化 */
@media screen and (max-width: 768px) and (orientation: landscape) {
.tools-grid {
grid-template-columns: repeat(2, 1fr);
}
}
\ No newline at end of file
@tailwind base;
@tailwind components;
@tailwind utilities;
\ No newline at end of file
/** @type {import('tailwindcss').Config} */
export default {
content: [
"./src/**/*.{js,jsx,ts,tsx}",
],
theme: {
extend: {
colors: {
indigo: {
50: '#EEF2FF',
500: '#6366F1',
600: '#4F46E5',
},
},
},
},
plugins: [
// 注意:line-clamp 现在已经内置在 Tailwind CSS v3.3+ 中
// 不需要额外的插件了
],
}
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