diff --git a/app/tools/CatgirlGenerator/page.tsx b/app/tools/CatgirlGenerator/page.tsx new file mode 100644 index 0000000..37320f8 --- /dev/null +++ b/app/tools/CatgirlGenerator/page.tsx @@ -0,0 +1,47 @@ +"use client"; + +import NicknameGenerator from '@/components/tools/CatgirlGenerator/NicknameGenerator' +import TextReversal from '@/components/tools/CatgirlGenerator/TextReversal' +import CustomTemplate from '@/components/tools/CatgirlGenerator/CustomTemplate' +import DarkModeToggle from '@/components/tools/CatgirlGenerator/DarkModeToggle' +import Footer from "@/components/tools/CatgirlGenerator/Footer"; +export default function Home() { + return ( +
+
+
+

扩展昵称生成器

+ +
+
+
+

原理说明

+

+ 效果:当别人@你时,会在@的文字后面加上一另段文字。下图结尾的「喵~」不是发送者输入的。 +

+
+ {['step1.jpg', 'step2.jpg', 'example.jpg'].map((img, index) => ( + {`Example + ))} +
+

+ 原理:用unicode控制字符 RLI 和 + LRI 包裹倒序的后缀部分, + 在别人@后,@后面的文本会跑到被包裹部分的前面去。 +

+
+ + + +
+
+
+ ) +} + diff --git a/app/tools/Compressor/page.tsx b/app/tools/Compressor/page.tsx new file mode 100644 index 0000000..63f02ed --- /dev/null +++ b/app/tools/Compressor/page.tsx @@ -0,0 +1,55 @@ +'use client' + +import { useState } from 'react' +import { ThemeProvider } from 'next-themes' +import FileUpload from '@/components/tools/compressor/FileUpload' +import CompressionOptions from '@/components/tools/compressor/CompressionOptions' +import CompressionResult from '@/components/tools/compressor/CompressionResult' +import ThemeToggle from '@/components/tools/compressor/ThemeToggle' +import Instructions from '@/components/tools/compressor/Instructions' +import useImageCompression from '@/hooks/useImageCompression' + +export default function ImageCompressor() { + const [file, setFile] = useState(null) + const { compressImage, compressedImage, isCompressing, progress, error } = useImageCompression() + + const handleCompress = async (format: string) => { + if (file) { + await compressImage(file, format) + } + } + + return ( + +
+
+
+

图片压缩工具

+ +
+
+ + + {file && ( + + )} + {compressedImage && ( + + )} + {error && ( +

{error}

+ )} +
+
+
+
+ ) +} + diff --git a/app/tools/hash-random/page.tsx b/app/tools/hash-random/page.tsx new file mode 100644 index 0000000..474eada --- /dev/null +++ b/app/tools/hash-random/page.tsx @@ -0,0 +1,119 @@ +"use client"; + +import { useState, useEffect } from "react"; +import RandomSeed from "@/components/tools/hash-random/RandomSeed"; +import ParticipantList from "@/components/tools/hash-random/ParticipantList"; +import ResultDisplay from "@/components/tools/hash-random/ResultDisplay"; +import HashTable from "@/components/tools/hash-random/HashTable"; +import CoreAlgorithm from "@/components/tools/hash-random/CoreAlgorithm"; +import { useHashCalculation } from "@/hooks/useHashCalculation"; +import DarkModeToggle from "@/components/tools/hash-random/DarkModeToggle"; +import SaveLoadSelection from "@/components/tools/hash-random/SaveLoadSelection"; +import Footer from "@/components/tools/hash-random/Footer"; +import { AnimatePresence, motion } from "framer-motion"; + +export default function Home() { + const [salt, setSalt] = useState(""); + const [count, setCount] = useState(1); + const [participants, setParticipants] = useState([]); + const { sortedResults, saltHash, isCalculating } = useHashCalculation( + salt, + participants, + count + ); + const [darkMode, setDarkMode] = useState(false); + + useEffect(() => { + const isDarkMode = localStorage.getItem("darkMode") === "true"; + setDarkMode(isDarkMode); + }, []); + + useEffect(() => { + document.documentElement.classList.toggle("dark", darkMode); + localStorage.setItem("darkMode", darkMode.toString()); + }, [darkMode]); + + return ( +
+
+
+

+ 哈希随机抽取 +

+ +
+
+ + 事前不可知 + + + 事后可复现 + + + 高度随机化 + +
+ +
+
+ + + +
+
+ + {isCalculating ? ( + +
+
+ ) : ( + + + + + )} +
+
+
+ + +
+
+
+ ); +} diff --git a/components/navigation.tsx b/components/navigation.tsx index ba65f19..8c7f693 100644 --- a/components/navigation.tsx +++ b/components/navigation.tsx @@ -33,7 +33,7 @@ export default function Component() { useEffect(() => { async function loadData() { try { - const response = await fetch('https://aps.icu/api/load-yaml-data'); + const response = await fetch('/api/load-yaml-data'); const yamlData = await response.json(); setData(yamlData); } catch (error) { diff --git a/components/tools/CatgirlGenerator/CustomTemplate.tsx b/components/tools/CatgirlGenerator/CustomTemplate.tsx new file mode 100644 index 0000000..542424e --- /dev/null +++ b/components/tools/CatgirlGenerator/CustomTemplate.tsx @@ -0,0 +1,69 @@ +'use client' + +import { useState } from 'react' +import { motion } from 'framer-motion' +import Toast from './Toast' + +export default function CustomTemplate() { + const [input, setInput] = useState('') + const [output, setOutput] = useState('') + const [showToast, setShowToast] = useState(false) + + const generateTemplate = () => { + setOutput(input.replace(/#r/g, '\u2067').replace(/#l/g, '\u2066')) + } + + const copyToClipboard = () => { + navigator.clipboard.writeText(output) + setShowToast(true) + setTimeout(() => setShowToast(false), 3000) + } + + return ( + +

自定义模板

+

+ 使用 #r 表示 RLI 字符, + 使用 #l 表示 LRI 字符。 +

+
+
+ + setInput(e.target.value)} + onKeyUp={generateTemplate} + className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-300 focus:ring focus:ring-indigo-200 focus:ring-opacity-50 dark:bg-gray-700 dark:border-gray-600 dark:text-white" + /> +
+
+ +
+ + +
+
+
+ +
+ ) +} + diff --git a/components/tools/CatgirlGenerator/DarkModeToggle.tsx b/components/tools/CatgirlGenerator/DarkModeToggle.tsx new file mode 100644 index 0000000..f6e781f --- /dev/null +++ b/components/tools/CatgirlGenerator/DarkModeToggle.tsx @@ -0,0 +1,36 @@ +'use client' + +import { useState, useEffect } from 'react' +import { motion } from 'framer-motion' +import { Moon, Sun } from 'lucide-react' + +export default function DarkModeToggle() { + const [darkMode, setDarkMode] = useState(false) + + useEffect(() => { + if (typeof window !== 'undefined') { + const isDarkMode = localStorage.getItem('darkMode') === 'true' + setDarkMode(isDarkMode) + document.documentElement.classList.toggle('dark', isDarkMode) + } + }, []) + + const toggleDarkMode = () => { + const newDarkMode = !darkMode + setDarkMode(newDarkMode) + localStorage.setItem('darkMode', newDarkMode.toString()) + document.documentElement.classList.toggle('dark', newDarkMode) + } + + return ( + + {darkMode ? : } + + ) +} + diff --git a/components/tools/CatgirlGenerator/Footer.tsx b/components/tools/CatgirlGenerator/Footer.tsx new file mode 100644 index 0000000..909d500 --- /dev/null +++ b/components/tools/CatgirlGenerator/Footer.tsx @@ -0,0 +1,22 @@ +export default function Footer() { + return ( +
+
+

+ © {new Date().getFullYear()} APS NAV. +

+

+ 创意来源:{" "} + + TaylorAndTony + +

+
+
+ ); +} diff --git a/components/tools/CatgirlGenerator/NicknameGenerator.tsx b/components/tools/CatgirlGenerator/NicknameGenerator.tsx new file mode 100644 index 0000000..dddd8b4 --- /dev/null +++ b/components/tools/CatgirlGenerator/NicknameGenerator.tsx @@ -0,0 +1,102 @@ +'use client' + +import { useState } from 'react' +import { motion } from 'framer-motion' +import Toast from './Toast' + +export default function NicknameGenerator() { + const [prefix, setPrefix] = useState('爱蜜莉雅') + const [suffix, setSuffix] = useState('碳~') + const [result, setResult] = useState('') + const [result2, setResult2] = useState('') + const [showToast, setShowToast] = useState(false) + + const generateNickname = () => { + const wrapped = '\u2067' + suffix.split('').reverse().join('') + '\u2066' + setResult(prefix + wrapped) + setResult2(prefix + '\u2067' + suffix + '\u2066') + } + + const copyToClipboard = (text: string) => { + navigator.clipboard.writeText(text) + setShowToast(true) + setTimeout(() => setShowToast(false), 3000) + } + + return ( + +

基本生成

+
+
+ + setPrefix(e.target.value)} + className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-300 focus:ring focus:ring-indigo-200 focus:ring-opacity-50 dark:bg-gray-700 dark:border-gray-600 dark:text-white" + /> +
+
+ + setSuffix(e.target.value)} + className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-300 focus:ring focus:ring-indigo-200 focus:ring-opacity-50 dark:bg-gray-700 dark:border-gray-600 dark:text-white" + /> +
+ +
+ +
+ + +
+
+
+ +
+ + +
+
+
+ +
+ ) +} + diff --git a/components/tools/CatgirlGenerator/TextReversal.tsx b/components/tools/CatgirlGenerator/TextReversal.tsx new file mode 100644 index 0000000..154c246 --- /dev/null +++ b/components/tools/CatgirlGenerator/TextReversal.tsx @@ -0,0 +1,65 @@ +'use client' + +import { useState } from 'react' +import { motion } from 'framer-motion' +import Toast from './Toast' + +export default function TextReversal() { + const [input, setInput] = useState('') + const [output, setOutput] = useState('') + const [showToast, setShowToast] = useState(false) + + const reverseText = () => { + setOutput(input.split('').reverse().join('')) + } + + const copyToClipboard = () => { + navigator.clipboard.writeText(output) + setShowToast(true) + setTimeout(() => setShowToast(false), 3000) + } + + return ( + +

文本反转

+
+
+ + setInput(e.target.value)} + onKeyUp={reverseText} + className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-300 focus:ring focus:ring-indigo-200 focus:ring-opacity-50 dark:bg-gray-700 dark:border-gray-600 dark:text-white" + /> +
+
+ +
+ + +
+
+
+ +
+ ) +} + diff --git a/components/tools/CatgirlGenerator/Toast.tsx b/components/tools/CatgirlGenerator/Toast.tsx new file mode 100644 index 0000000..e4027f4 --- /dev/null +++ b/components/tools/CatgirlGenerator/Toast.tsx @@ -0,0 +1,26 @@ +import { motion, AnimatePresence } from 'framer-motion' +import { CheckCircle } from 'lucide-react' + +interface ToastProps { + message: string + isVisible: boolean +} + +export default function Toast({ message, isVisible }: ToastProps) { + return ( + + {isVisible && ( + + + {message} + + )} + + ) +} + diff --git a/components/tools/compressor/CompressionOptions.tsx b/components/tools/compressor/CompressionOptions.tsx new file mode 100644 index 0000000..623e4a8 --- /dev/null +++ b/components/tools/compressor/CompressionOptions.tsx @@ -0,0 +1,51 @@ +import { useState } from 'react' +import { motion } from 'framer-motion' + +interface CompressionOptionsProps { + onCompress: (format: string) => void + isCompressing: boolean + progress: number +} + +export default function CompressionOptions({ onCompress, isCompressing, progress }: CompressionOptionsProps) { + const [format, setFormat] = useState('webp') + + return ( +
+
+
+ + +
+
+ onCompress(format)} + disabled={isCompressing} + className="w-full bg-blue-500 text-white py-2 px-4 rounded-md hover:bg-blue-600 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed dark:bg-blue-600 dark:hover:bg-blue-700" + whileHover={{ scale: 1.05 }} + whileTap={{ scale: 0.95 }} + > + {isCompressing ? '压缩中...' : '开始压缩'} + + {isCompressing && ( +
+
+
+
+

{progress}%

+
+ )} +
+ ) +} + diff --git a/components/tools/compressor/CompressionResult.tsx b/components/tools/compressor/CompressionResult.tsx new file mode 100644 index 0000000..23afee3 --- /dev/null +++ b/components/tools/compressor/CompressionResult.tsx @@ -0,0 +1,69 @@ +import { useState } from 'react' +import Image from 'next/image' + +interface CompressionResultProps { + originalImage: File + compressedImage: File +} + +export default function CompressionResult({ originalImage, compressedImage }: CompressionResultProps) { + const [showOriginal, setShowOriginal] = useState(false) + + const originalSizeKB = (originalImage.size / 1024).toFixed(2) + const compressedSizeKB = (compressedImage.size / 1024).toFixed(2) + const compressionRatio = ((1 - compressedImage.size / originalImage.size) * 100).toFixed(2) + + return ( +
+

压缩结果

+
+
+
+

原始大小

+

{originalSizeKB} KB

+
+
+

压缩后大小

+

{compressedSizeKB} KB

+
+
+

压缩比例

+

{compressionRatio}%

+
+
+

输出格式

+

{compressedImage.type.split('/')[1].toUpperCase()}

+
+
+
+ + 下载压缩后的图片 + +
+
+
+

图片对比

+
+ Compressed Image + +
+
+
+ ) +} + diff --git a/components/tools/compressor/FileUpload.tsx b/components/tools/compressor/FileUpload.tsx new file mode 100644 index 0000000..797426e --- /dev/null +++ b/components/tools/compressor/FileUpload.tsx @@ -0,0 +1,88 @@ +import { useCallback, useState } from "react"; +import { useDropzone } from "react-dropzone"; +import { motion, HTMLMotionProps } from "framer-motion"; + +interface FileUploadProps { + onFileSelect: (file: File) => void; + selectedFile: File | null; +} + +export default function FileUpload({ + onFileSelect, + selectedFile, +}: FileUploadProps) { + const [error, setError] = useState(null); + + const onDrop = useCallback( + (acceptedFiles: File[]) => { + if (acceptedFiles.length > 0) { + const file = acceptedFiles[0]; + if (file.type.startsWith("image/")) { + onFileSelect(file); + setError(null); + } else { + setError("请上传有效的图片文件。"); + } + } + }, + [onFileSelect] + ); + + const { getRootProps, getInputProps, isDragActive } = useDropzone({ + onDrop, + accept: { + "image/*": [".jpeg", ".jpg", ".png", ".gif", ".webp"], + }, + multiple: false, + }); + + return ( + <> + , "onDragStart">} + className={`border-2 border-dashed rounded-lg p-8 text-center cursor-pointer transition-colors ${ + isDragActive + ? "border-blue-500 bg-blue-50 dark:bg-blue-900" + : "border-gray-300 dark:border-gray-600 hover:border-gray-400 dark:hover:border-gray-500" + }`} + whileHover={{ scale: 1.02 }} + whileTap={{ scale: 0.98 }} + > + + {isDragActive ? ( +

拖放图片到这里 ...

+ ) : ( +

+ 拖放图片到这里,或点击选择图片 +

+ )} +
+ {error &&

{error}

} + {selectedFile && ( + +

+ 已成功上传:{" "} + {selectedFile.name} +

+

+ 文件大小: {(selectedFile.size / 1024 / 1024).toFixed(2)} MB +

+ {selectedFile.type.startsWith("image/") && ( +
+ {selectedFile.name} +
+ )} +
+ )} + + ); +} diff --git a/components/tools/compressor/Instructions.tsx b/components/tools/compressor/Instructions.tsx new file mode 100644 index 0000000..b2349db --- /dev/null +++ b/components/tools/compressor/Instructions.tsx @@ -0,0 +1,15 @@ +export default function Instructions() { + return ( +
+

使用说明

+
    +
  1. 拖放或点击上传按钮选择要压缩的图片
  2. +
  3. 选择输出格式(WebP、JPEG 或 PNG)
  4. +
  5. 点击"开始压缩"按钮
  6. +
  7. 等待压缩完成,查看结果并下载压缩后的图片
  8. +
+
+ ) + } + + \ No newline at end of file diff --git a/components/tools/compressor/ThemeToggle.tsx b/components/tools/compressor/ThemeToggle.tsx new file mode 100644 index 0000000..182d317 --- /dev/null +++ b/components/tools/compressor/ThemeToggle.tsx @@ -0,0 +1,28 @@ +'use client' + +import { useTheme } from 'next-themes' +import { useState, useEffect } from 'react' +import { Sun, Moon } from 'lucide-react' + +export default function ThemeToggle() { + const [mounted, setMounted] = useState(false) + const { theme, setTheme } = useTheme() + + useEffect(() => { + setMounted(true) + }, []) + + if (!mounted) { + return null + } + + return ( + + ) +} + diff --git a/components/tools/hash-random/CoreAlgorithm.tsx b/components/tools/hash-random/CoreAlgorithm.tsx new file mode 100644 index 0000000..d993a4c --- /dev/null +++ b/components/tools/hash-random/CoreAlgorithm.tsx @@ -0,0 +1,57 @@ +import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter' +import { tomorrow } from 'react-syntax-highlighter/dist/esm/styles/prism' + +export default function CoreAlgorithm() { + const distanceCode = ` +function calcMd5Distance(s1, s2) { + let distance = 0; + for (let i = 0; i < Math.min(s1.length, s2.length); i++) { + if (s1[i] === s2[i]) { + continue; + } + distance += Math.abs(s1.charCodeAt(i) - s2.charCodeAt(i)); + } + return distance; +} + ` + + const sortCode = ` +function makeSortedNameHashDistanceTuple(targetHash, names) { + let nameHashDistanceTuple = names.map((name) => { + let nameMd5 = makeMd5(name); + let distance = calcMd5Distance(targetHash, nameMd5); + return [name, nameMd5, distance]; + }); + // sort by distance + nameHashDistanceTuple.sort((a, b) => { + return a[2] - b[2]; + }); + return nameHashDistanceTuple; +} + ` + + return ( +
+

核心算法

+

md5 哈希计算依赖 js-md5 库。

+
+ + {''} + +
+
+

哈希距离计算算法

+ + {distanceCode} + +
+
+

哈希距离排序算法

+ + {sortCode} + +
+
+ ) +} + diff --git a/components/tools/hash-random/DarkModeToggle.tsx b/components/tools/hash-random/DarkModeToggle.tsx new file mode 100644 index 0000000..36f3c26 --- /dev/null +++ b/components/tools/hash-random/DarkModeToggle.tsx @@ -0,0 +1,19 @@ +import { Moon, Sun } from 'lucide-react' + +interface DarkModeToggleProps { + darkMode: boolean + setDarkMode: (darkMode: boolean) => void +} + +export default function DarkModeToggle({ darkMode, setDarkMode }: DarkModeToggleProps) { + return ( + + ) +} + diff --git a/components/tools/hash-random/Footer.tsx b/components/tools/hash-random/Footer.tsx new file mode 100644 index 0000000..b57921a --- /dev/null +++ b/components/tools/hash-random/Footer.tsx @@ -0,0 +1,22 @@ +export default function Footer() { + return ( +
+
+

+ © {new Date().getFullYear()} APS NAV. +

+

+ 创意来源:{" "} + + TaylorAndTony + +

+
+
+ ); +} diff --git a/components/tools/hash-random/HashTable.tsx b/components/tools/hash-random/HashTable.tsx new file mode 100644 index 0000000..63254da --- /dev/null +++ b/components/tools/hash-random/HashTable.tsx @@ -0,0 +1,37 @@ +interface HashTableProps { + results: [string, string, number][] + saltHash: string + } + + export default function HashTable({ results }: HashTableProps) { + return ( +
+

哈希表

+

为保持页面性能,仅显示前面最多 1000 位人员的哈希校验

+
+ + + + + + + + + + + {results.map(([name, hash, distance], index) => ( + + + + + + + ))} + +
序号参与人哈希值与种子的距离
{index + 1}{name}{hash}{distance}
+
+
+ ) + } + + \ No newline at end of file diff --git a/components/tools/hash-random/ParticipantList.tsx b/components/tools/hash-random/ParticipantList.tsx new file mode 100644 index 0000000..56b8ce6 --- /dev/null +++ b/components/tools/hash-random/ParticipantList.tsx @@ -0,0 +1,40 @@ +import { useState } from 'react' + +interface ParticipantListProps { + setParticipants: (participants: string[]) => void +} + +export default function ParticipantList({ setParticipants }: ParticipantListProps) { + const [participantText, setParticipantText] = useState('') + + const handleRoll = () => { + const participantArray = participantText.split(/[\n\s]+/).filter(Boolean) + setParticipants(participantArray) + } + + return ( +
+

参与人员

+
+
+ +