add
This commit is contained in:
@@ -0,0 +1,147 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { faArrowUp } from '@fortawesome/free-solid-svg-icons';
|
||||
|
||||
interface BackToTopProps {
|
||||
scrollThreshold?: number; // 显示按钮的滚动阈值(像素)
|
||||
position?: 'bottom-right' | 'bottom-left' | 'top-right' | 'top-left'; // 按钮位置
|
||||
offset?: number; // 与屏幕边缘的距离(像素)
|
||||
containerRef?: React.RefObject<HTMLElement>; // 滚动容器引用,默认为window
|
||||
zIndex?: number; // 自定义z-index
|
||||
size?: 'small' | 'medium' | 'large'; // 按钮大小
|
||||
}
|
||||
|
||||
/**
|
||||
* 回到顶部组件
|
||||
*
|
||||
* 用法:
|
||||
* 1. 基本用法: <BackToTop />
|
||||
* 2. 自定义: <BackToTop position="bottom-left" offset={30} size="large" />
|
||||
* 3. 对特定容器:
|
||||
* const containerRef = useRef<HTMLDivElement>(null);
|
||||
* <div ref={containerRef} style={{height: '500px', overflow: 'auto'}}>
|
||||
* 内容
|
||||
* <BackToTop containerRef={containerRef} />
|
||||
* </div>
|
||||
*/
|
||||
const BackToTop: React.FC<BackToTopProps> = ({
|
||||
scrollThreshold = 300,
|
||||
position = 'bottom-right',
|
||||
offset = 20,
|
||||
containerRef,
|
||||
zIndex = 40,
|
||||
size = 'medium'
|
||||
}) => {
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
|
||||
// 生成位置样式
|
||||
const getPositionStyle = () => {
|
||||
const positionStyle: React.CSSProperties = {};
|
||||
|
||||
if (position.includes('bottom')) {
|
||||
positionStyle.bottom = offset;
|
||||
} else {
|
||||
positionStyle.top = offset;
|
||||
}
|
||||
|
||||
if (position.includes('right')) {
|
||||
positionStyle.right = offset;
|
||||
} else {
|
||||
positionStyle.left = offset;
|
||||
}
|
||||
|
||||
return positionStyle;
|
||||
};
|
||||
|
||||
// 根据大小获取样式类名
|
||||
const getSizeClassName = () => {
|
||||
switch (size) {
|
||||
case 'small':
|
||||
return 'w-8 h-8';
|
||||
case 'large':
|
||||
return 'w-12 h-12';
|
||||
case 'medium':
|
||||
default:
|
||||
return 'w-10 h-10';
|
||||
}
|
||||
};
|
||||
|
||||
// 处理滚动事件
|
||||
const handleScroll = () => {
|
||||
if (containerRef && containerRef.current) {
|
||||
setIsVisible(containerRef.current.scrollTop > scrollThreshold);
|
||||
} else {
|
||||
setIsVisible(window.scrollY > scrollThreshold);
|
||||
}
|
||||
};
|
||||
|
||||
// 回到顶部
|
||||
const scrollToTop = () => {
|
||||
if (containerRef && containerRef.current) {
|
||||
containerRef.current.scrollTo({
|
||||
top: 0,
|
||||
behavior: 'smooth'
|
||||
});
|
||||
} else {
|
||||
window.scrollTo({
|
||||
top: 0,
|
||||
behavior: 'smooth'
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// 设置滚动监听
|
||||
useEffect(() => {
|
||||
const scrollElement = containerRef?.current || window;
|
||||
scrollElement.addEventListener('scroll', handleScroll);
|
||||
|
||||
// 初始检查
|
||||
handleScroll();
|
||||
|
||||
return () => {
|
||||
scrollElement.removeEventListener('scroll', handleScroll);
|
||||
};
|
||||
}, [containerRef, scrollThreshold]);
|
||||
|
||||
// 如果不可见,不渲染
|
||||
if (!isVisible) return null;
|
||||
|
||||
const positionStyle = getPositionStyle();
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={scrollToTop}
|
||||
className={`
|
||||
fixed
|
||||
${getSizeClassName()}
|
||||
bg-gradient-to-r from-[#6366F1] to-[#8B5CF6]
|
||||
text-white
|
||||
rounded-full
|
||||
flex
|
||||
items-center
|
||||
justify-center
|
||||
shadow-lg
|
||||
hover:shadow-xl
|
||||
transition-all
|
||||
duration-300
|
||||
hover:scale-110
|
||||
focus:outline-none
|
||||
focus:ring-2
|
||||
focus:ring-[#6366F1]
|
||||
focus:ring-opacity-50
|
||||
`}
|
||||
style={{
|
||||
...positionStyle,
|
||||
zIndex
|
||||
}}
|
||||
aria-label="回到顶部"
|
||||
title="回到顶部"
|
||||
>
|
||||
<FontAwesomeIcon icon={faArrowUp} />
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
export default BackToTop;
|
||||
@@ -0,0 +1,45 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { createJSONEditor } from 'vanilla-jsoneditor';
|
||||
import type { JSONEditorPropsOptional } from 'vanilla-jsoneditor';
|
||||
|
||||
interface JsonEditorProps extends JSONEditorPropsOptional {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const JsonEditor = (props: JsonEditorProps) => {
|
||||
const { className, ...editorProps } = props;
|
||||
const refContainer = useRef<HTMLDivElement>(null);
|
||||
const refEditor = useRef<ReturnType<typeof createJSONEditor> | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
// 确保只在客户端渲染
|
||||
if (typeof window !== 'undefined' && refContainer.current && !refEditor.current) {
|
||||
// 创建编辑器
|
||||
refEditor.current = createJSONEditor({
|
||||
target: refContainer.current,
|
||||
props: editorProps
|
||||
});
|
||||
}
|
||||
|
||||
return () => {
|
||||
// 销毁编辑器
|
||||
if (refEditor.current) {
|
||||
refEditor.current.destroy();
|
||||
refEditor.current = null;
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
// 更新props
|
||||
useEffect(() => {
|
||||
if (refEditor.current) {
|
||||
refEditor.current.updateProps(editorProps);
|
||||
}
|
||||
}, [editorProps]);
|
||||
|
||||
return <div ref={refContainer} className={className}></div>;
|
||||
};
|
||||
|
||||
export default JsonEditor;
|
||||
@@ -0,0 +1,70 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { faLanguage } from '@fortawesome/free-solid-svg-icons';
|
||||
import { useLanguage } from '@/context/LanguageContext';
|
||||
import { Language } from '@/config/i18n';
|
||||
|
||||
export default function LanguageToggle() {
|
||||
const { language, changeLanguage, t } = useLanguage();
|
||||
const [showDropdown, setShowDropdown] = useState(false);
|
||||
|
||||
// 切换下拉菜单显示状态
|
||||
const toggleDropdown = () => {
|
||||
setShowDropdown(prev => !prev);
|
||||
};
|
||||
|
||||
// 选择语言
|
||||
const selectLanguage = (lang: Language) => {
|
||||
changeLanguage(lang);
|
||||
setShowDropdown(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<button
|
||||
className="btn-secondary w-10 h-10 rounded-full flex items-center justify-center group relative"
|
||||
onClick={toggleDropdown}
|
||||
aria-label={t('common.language.title')}
|
||||
title={t('common.language.title')}
|
||||
>
|
||||
<FontAwesomeIcon
|
||||
icon={faLanguage}
|
||||
className="text-[rgb(var(--color-primary))] text-xl"
|
||||
/>
|
||||
<span className="absolute left-1/2 -translate-x-1/2 top-full mt-2 px-2 py-1 rounded-md whitespace-nowrap opacity-0 group-hover:opacity-100 pointer-events-none transition-opacity duration-300 text-sm z-10"
|
||||
style={{
|
||||
backgroundColor: 'rgb(var(--color-bg-secondary))',
|
||||
color: 'rgb(var(--color-text-primary))'
|
||||
}}>
|
||||
{t('common.language.title')}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{/* 语言选择下拉菜单 */}
|
||||
{showDropdown && (
|
||||
<div
|
||||
className="absolute right-0 top-full mt-2 py-2 w-32 bg-[rgb(var(--color-bg-card))] shadow-lg rounded-lg border border-[rgba(var(--color-primary),0.2)] z-50"
|
||||
style={{
|
||||
backgroundColor: 'rgb(var(--color-bg-card))',
|
||||
border: '1px solid rgba(var(--color-primary), 0.2)'
|
||||
}}
|
||||
>
|
||||
<button
|
||||
className={`w-full text-left px-4 py-2 hover:bg-[rgba(var(--color-primary),0.1)] transition-colors duration-200 ${language === 'zh' ? 'text-[rgb(var(--color-primary))] font-medium' : ''}`}
|
||||
onClick={() => selectLanguage('zh')}
|
||||
>
|
||||
{t('common.language.zh')}
|
||||
</button>
|
||||
<button
|
||||
className={`w-full text-left px-4 py-2 hover:bg-[rgba(var(--color-primary),0.1)] transition-colors duration-200 ${language === 'en' ? 'text-[rgb(var(--color-primary))] font-medium' : ''}`}
|
||||
onClick={() => selectLanguage('en')}
|
||||
>
|
||||
{t('common.language.en')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
'use client';
|
||||
|
||||
import React, { useRef, useEffect } from 'react';
|
||||
|
||||
interface MarkdownPreviewProps {
|
||||
htmlContent: string;
|
||||
}
|
||||
|
||||
export default function MarkdownPreview({ htmlContent }: MarkdownPreviewProps) {
|
||||
const previewRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// 在渲染后处理所有可能的脚本和危险内容
|
||||
useEffect(() => {
|
||||
if (!previewRef.current) return;
|
||||
|
||||
const container = previewRef.current;
|
||||
|
||||
// 禁用所有链接默认行为
|
||||
const links = container.querySelectorAll('a');
|
||||
links.forEach(link => {
|
||||
link.setAttribute('target', '_blank');
|
||||
link.setAttribute('rel', 'noopener noreferrer');
|
||||
|
||||
// 阻止链接点击默认行为,除非特别需要允许
|
||||
link.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
});
|
||||
});
|
||||
|
||||
// 移除所有脚本标签
|
||||
const scripts = container.querySelectorAll('script');
|
||||
scripts.forEach(script => script.remove());
|
||||
|
||||
// 处理所有iframe,确保它们有sandbox属性
|
||||
const iframes = container.querySelectorAll('iframe');
|
||||
iframes.forEach(iframe => {
|
||||
iframe.setAttribute('sandbox', 'allow-scripts');
|
||||
iframe.setAttribute('loading', 'lazy');
|
||||
});
|
||||
|
||||
}, [htmlContent]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="markdown-preview-container prose prose-invert"
|
||||
ref={previewRef}
|
||||
dangerouslySetInnerHTML={{ __html: htmlContent }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
'use client';
|
||||
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { faLightbulb } from '@fortawesome/free-solid-svg-icons';
|
||||
import { faLightbulb as farLightbulb } from '@fortawesome/free-regular-svg-icons';
|
||||
import { useLanguage } from '@/context/LanguageContext';
|
||||
|
||||
export default function ThemeToggle() {
|
||||
const [theme, setTheme] = useState('dark');
|
||||
const [mounted, setMounted] = useState(false);
|
||||
const { t } = useLanguage();
|
||||
|
||||
// 组件挂载后执行
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
const savedTheme = localStorage.getItem('theme');
|
||||
if (savedTheme) {
|
||||
setTheme(savedTheme);
|
||||
document.documentElement.setAttribute('data-theme', savedTheme);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 切换主题
|
||||
const toggleTheme = () => {
|
||||
const newTheme = theme === 'dark' ? 'light' : 'dark';
|
||||
setTheme(newTheme);
|
||||
|
||||
// 保存到本地存储并设置 data-theme 属性
|
||||
localStorage.setItem('theme', newTheme);
|
||||
document.documentElement.setAttribute('data-theme', newTheme);
|
||||
};
|
||||
|
||||
// 如果组件尚未挂载,返回空白以避免服务器/客户端不匹配
|
||||
if (!mounted) return null;
|
||||
|
||||
return (
|
||||
<button
|
||||
className="btn-secondary w-10 h-10 rounded-full flex items-center justify-center group relative overflow-hidden"
|
||||
onClick={toggleTheme}
|
||||
aria-label={theme === 'dark' ? t('common.theme.light') : t('common.theme.dark')}
|
||||
title={theme === 'dark' ? t('common.theme.light') : t('common.theme.dark')}
|
||||
>
|
||||
{theme === 'dark' ? (
|
||||
// 灯泡图标 - 深色模式下显示(实心灯泡表示可以"点亮")
|
||||
<FontAwesomeIcon
|
||||
icon={faLightbulb}
|
||||
className="text-[rgb(var(--color-warning))] text-xl"
|
||||
/>
|
||||
) : (
|
||||
// 灯泡轮廓图标 - 浅色模式下显示(空心灯泡表示可以"关闭")
|
||||
<FontAwesomeIcon
|
||||
icon={farLightbulb}
|
||||
className="text-[rgb(var(--color-primary-light))] text-xl"
|
||||
/>
|
||||
)}
|
||||
<span className="absolute left-1/2 -translate-x-1/2 top-full mt-2 px-2 py-1 rounded-md whitespace-nowrap opacity-0 group-hover:opacity-100 pointer-events-none transition-opacity duration-300 text-sm z-10"
|
||||
style={{
|
||||
backgroundColor: 'rgb(var(--color-bg-secondary))',
|
||||
color: 'rgb(var(--color-text-primary))'
|
||||
}}>
|
||||
{theme === 'dark' ? t('common.theme.light') : t('common.theme.dark')}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { IconDefinition } from '@fortawesome/fontawesome-svg-core';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useLanguage } from '@/context/LanguageContext';
|
||||
|
||||
interface ToolHeaderProps {
|
||||
title: string;
|
||||
description: string;
|
||||
icon: IconDefinition;
|
||||
toolCode: string;
|
||||
}
|
||||
|
||||
export default function ToolHeader({ title, description, icon, toolCode }: ToolHeaderProps) {
|
||||
const router = useRouter();
|
||||
const { t } = useLanguage();
|
||||
|
||||
// 返回首页
|
||||
const goBack = () => {
|
||||
router.push('/');
|
||||
};
|
||||
|
||||
return (
|
||||
<header className="flex items-center gap-4 mb-8 rounded-lg p-4 shadow-md border">
|
||||
<button
|
||||
className="btn-secondary px-3 py-2"
|
||||
onClick={goBack}
|
||||
>
|
||||
{t('common.backToHome')}
|
||||
</button>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="icon-container w-10 h-10 flex-shrink-0">
|
||||
<FontAwesomeIcon icon={icon} className="icon" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-xl font-bold">{title || t(`tools.${toolCode}.title`)}</h1>
|
||||
<p className="text-sm"
|
||||
style={{color: 'rgb(var(--color-text-secondary))'}}
|
||||
>{description || t(`tools.${toolCode}.description`)}</p>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user