mirror of
https://github.com/laoxong/nofx.git
synced 2026-06-07 19:27:55 +08:00
fix: resolve Web UI display issues (#365)
## Fixes ### 1. Typewriter Component - Missing First Character - Fix character loss issue where first character of each line was missing - Add proper state reset logic before starting typing animation - Extract character before setState to avoid closure issues - Add setTimeout(0) to ensure state is updated before typing starts - Change dependency from `lines` to `sanitizedLines` for correct updates - Use `??` instead of `||` for safer null handling ### 2. Chinese Translation - Leading Spaces - Remove leading spaces from startupMessages1/2/3 in Chinese translations - Ensures proper display of startup messages in terminal simulation ### 3. Dynamic GitHub Stats with Animation - Add useGitHubStats hook to fetch real-time GitHub repository data - Add useCounterAnimation hook with easeOutExpo easing for smooth number animation - Display dynamic star count with smooth counter animation (2s duration) - Display dynamic days count (static, no animation) - Support bilingual display (EN/ZH) with proper formatting ## Changes - web/src/components/Typewriter.tsx: Fix first character loss bug - web/src/i18n/translations.ts: Remove leading spaces in Chinese messages - web/src/components/landing/HeroSection.tsx: Add dynamic GitHub stats - web/src/hooks/useGitHubStats.ts: New hook for GitHub API integration - web/src/hooks/useCounterAnimation.ts: New hook for number animations Fixes #365 Co-Authored-By: tinkle-community <tinklefund@gmail.com>
This commit is contained in:
@@ -24,13 +24,19 @@ export default function Typewriter({
|
||||
const sanitizedLines = useMemo(() => lines.map((l) => String(l ?? '')), [lines])
|
||||
|
||||
useEffect(() => {
|
||||
// 重置状态
|
||||
lineIndexRef.current = 0
|
||||
charIndexRef.current = 0
|
||||
setTypedLines([''])
|
||||
|
||||
function typeNext() {
|
||||
const currentLine = sanitizedLines[lineIndexRef.current] ?? ''
|
||||
if (charIndexRef.current < currentLine.length) {
|
||||
const ch = currentLine.charAt(charIndexRef.current)
|
||||
setTypedLines((prev) => {
|
||||
const next = [...prev]
|
||||
const ch = currentLine.charAt(charIndexRef.current)
|
||||
next[next.length - 1] = (next[next.length - 1] || '') + ch
|
||||
const lastIndex = next.length - 1
|
||||
next[lastIndex] = (next[lastIndex] ?? '') + ch
|
||||
return next
|
||||
})
|
||||
charIndexRef.current += 1
|
||||
@@ -49,7 +55,8 @@ export default function Typewriter({
|
||||
}
|
||||
}
|
||||
|
||||
typeNext()
|
||||
// 延迟一帧开始打字,确保状态已重置
|
||||
timerRef.current = window.setTimeout(typeNext, 0)
|
||||
|
||||
// 光标闪烁
|
||||
blinkRef.current = window.setInterval(() => {
|
||||
@@ -60,7 +67,7 @@ export default function Typewriter({
|
||||
if (timerRef.current) window.clearTimeout(timerRef.current)
|
||||
if (blinkRef.current) window.clearInterval(blinkRef.current)
|
||||
}
|
||||
}, [lines, typingSpeed, lineDelay])
|
||||
}, [sanitizedLines, typingSpeed, lineDelay])
|
||||
|
||||
const displayText = useMemo(() => typedLines.join('\n').replace(/undefined/g, ''), [typedLines])
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { motion, useScroll, useTransform, useAnimation } from 'framer-motion'
|
||||
import { Sparkles } from 'lucide-react'
|
||||
import { t, Language } from '../../i18n/translations'
|
||||
import { useGitHubStats } from '../../hooks/useGitHubStats'
|
||||
import { useCounterAnimation } from '../../hooks/useCounterAnimation'
|
||||
|
||||
interface HeroSectionProps {
|
||||
language: Language
|
||||
@@ -11,6 +13,14 @@ export default function HeroSection({ language }: HeroSectionProps) {
|
||||
const opacity = useTransform(scrollYProgress, [0, 0.2], [1, 0])
|
||||
const scale = useTransform(scrollYProgress, [0, 0.2], [1, 0.8])
|
||||
const handControls = useAnimation()
|
||||
const { stars, daysOld, isLoading } = useGitHubStats('NoFxAiOS', 'nofx')
|
||||
|
||||
// 动画数字 - 仅对 stars 添加动画
|
||||
const animatedStars = useCounterAnimation({
|
||||
start: 0,
|
||||
end: stars,
|
||||
duration: 2000,
|
||||
})
|
||||
|
||||
const fadeInUp = {
|
||||
initial: { opacity: 0, y: 60 },
|
||||
@@ -33,7 +43,20 @@ export default function HeroSection({ language }: HeroSectionProps) {
|
||||
>
|
||||
<Sparkles className='w-4 h-4' style={{ color: 'var(--brand-yellow)' }} />
|
||||
<span className='text-sm font-semibold' style={{ color: 'var(--brand-yellow)' }}>
|
||||
{t('githubStarsInDays', language)}
|
||||
{isLoading ? (
|
||||
t('githubStarsInDays', language)
|
||||
) : language === 'zh' ? (
|
||||
<>
|
||||
{daysOld} 天内{' '}
|
||||
<span className='inline-block tabular-nums'>{(animatedStars / 1000).toFixed(1)}</span>
|
||||
K+ GitHub Stars
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className='inline-block tabular-nums'>{(animatedStars / 1000).toFixed(1)}</span>
|
||||
K+ GitHub Stars in {daysOld} days
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
interface UseCounterAnimationOptions {
|
||||
start?: number
|
||||
end: number
|
||||
duration?: number
|
||||
decimals?: number
|
||||
}
|
||||
|
||||
export function useCounterAnimation({
|
||||
start = 0,
|
||||
end,
|
||||
duration = 2000,
|
||||
decimals = 0,
|
||||
}: UseCounterAnimationOptions): number {
|
||||
const [count, setCount] = useState(start)
|
||||
|
||||
useEffect(() => {
|
||||
if (end === 0) return
|
||||
|
||||
let startTime: number | null = null
|
||||
let animationFrame: number
|
||||
|
||||
const animate = (currentTime: number) => {
|
||||
if (startTime === null) startTime = currentTime
|
||||
const progress = Math.min((currentTime - startTime) / duration, 1)
|
||||
|
||||
// 使用 easeOutExpo 缓动函数,让数字快速启动后缓慢停止
|
||||
const easeOutExpo = progress === 1 ? 1 : 1 - Math.pow(2, -10 * progress)
|
||||
|
||||
const currentCount = start + (end - start) * easeOutExpo
|
||||
setCount(currentCount)
|
||||
|
||||
if (progress < 1) {
|
||||
animationFrame = requestAnimationFrame(animate)
|
||||
} else {
|
||||
setCount(end)
|
||||
}
|
||||
}
|
||||
|
||||
animationFrame = requestAnimationFrame(animate)
|
||||
|
||||
return () => {
|
||||
if (animationFrame) {
|
||||
cancelAnimationFrame(animationFrame)
|
||||
}
|
||||
}
|
||||
}, [start, end, duration])
|
||||
|
||||
return decimals > 0 ? parseFloat(count.toFixed(decimals)) : Math.floor(count)
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
|
||||
interface GitHubStats {
|
||||
stars: number
|
||||
forks: number
|
||||
createdAt: string
|
||||
daysOld: number
|
||||
isLoading: boolean
|
||||
error: string | null
|
||||
}
|
||||
|
||||
export function useGitHubStats(owner: string, repo: string): GitHubStats {
|
||||
const [stats, setStats] = useState<GitHubStats>({
|
||||
stars: 0,
|
||||
forks: 0,
|
||||
createdAt: '',
|
||||
daysOld: 0,
|
||||
isLoading: true,
|
||||
error: null,
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
const fetchGitHubStats = async () => {
|
||||
try {
|
||||
const response = await fetch(`https://api.github.com/repos/${owner}/${repo}`)
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch GitHub stats')
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
// Calculate days since creation
|
||||
const createdDate = new Date(data.created_at)
|
||||
const now = new Date()
|
||||
const diffTime = Math.abs(now.getTime() - createdDate.getTime())
|
||||
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24))
|
||||
|
||||
setStats({
|
||||
stars: data.stargazers_count,
|
||||
forks: data.forks_count,
|
||||
createdAt: data.created_at,
|
||||
daysOld: diffDays,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Error fetching GitHub stats:', error)
|
||||
setStats(prev => ({
|
||||
...prev,
|
||||
isLoading: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
fetchGitHubStats()
|
||||
}, [owner, repo])
|
||||
|
||||
return stats
|
||||
}
|
||||
@@ -802,9 +802,9 @@ export const translations = {
|
||||
nofxDescription5: '贡献获积分奖励)。',
|
||||
youFullControl: '你 100% 掌控',
|
||||
fullControlDesc: '完全掌控 AI 提示词和资金',
|
||||
startupMessages1: ' 启动自动交易系统...',
|
||||
startupMessages2: ' API服务器启动在端口 8080',
|
||||
startupMessages3: ' Web 控制台 http://localhost:3000',
|
||||
startupMessages1: '启动自动交易系统...',
|
||||
startupMessages2: 'API服务器启动在端口 8080',
|
||||
startupMessages3: 'Web 控制台 http://localhost:3000',
|
||||
|
||||
// How It Works Section
|
||||
howToStart: '如何开始使用 NOFX',
|
||||
|
||||
Reference in New Issue
Block a user