fa
This commit is contained in:
260
src/components/AnalyticsDashboard.tsx
Normal file
260
src/components/AnalyticsDashboard.tsx
Normal file
@@ -0,0 +1,260 @@
|
||||
'use client';
|
||||
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import {
|
||||
LineChart, Line, AreaChart, Area, XAxis, YAxis, CartesianGrid,
|
||||
Tooltip, ResponsiveContainer, BarChart, Bar, Cell, PieChart, Pie
|
||||
} from 'recharts';
|
||||
import { motion } from 'framer-motion';
|
||||
import { format, subDays, isWithinInterval, startOfDay, endOfDay, eachDayOfInterval } from 'date-fns';
|
||||
import { enUS, zhCN } from 'date-fns/locale';
|
||||
import { Todo } from '@/lib/types';
|
||||
import { useSettings } from '@/contexts/SettingsContext';
|
||||
import { translations } from '@/lib/translations';
|
||||
import { Calendar, TrendingUp, CheckCircle2, Clock, Filter, ChevronLeft, ChevronRight, Eye, Users } from 'lucide-react';
|
||||
|
||||
interface AnalyticsDashboardProps {
|
||||
todos: Todo[];
|
||||
siteStats: { pv: number; uv: number };
|
||||
}
|
||||
|
||||
type Range = '7d' | '30d' | 'all';
|
||||
|
||||
export default function AnalyticsDashboard({ todos, siteStats }: AnalyticsDashboardProps) {
|
||||
const { settings } = useSettings();
|
||||
const t = translations[settings.language];
|
||||
const [range, setRange] = useState<Range>('7d');
|
||||
|
||||
const filteredData = useMemo(() => {
|
||||
const now = new Date();
|
||||
let startDate: Date;
|
||||
|
||||
if (range === '7d') startDate = subDays(now, 6);
|
||||
else if (range === '30d') startDate = subDays(now, 29);
|
||||
else return todos;
|
||||
|
||||
return todos.filter(todo =>
|
||||
todo.createdAt >= startDate.getTime()
|
||||
);
|
||||
}, [todos, range]);
|
||||
|
||||
const dailyStats = useMemo(() => {
|
||||
const now = new Date();
|
||||
const days = range === '7d' ? 7 : range === '30d' ? 30 : 45;
|
||||
const interval = {
|
||||
start: startOfDay(subDays(now, days - 1)),
|
||||
end: endOfDay(now)
|
||||
};
|
||||
|
||||
const dayList = eachDayOfInterval(interval);
|
||||
const locale = settings.language === 'zh' ? zhCN : enUS;
|
||||
|
||||
return dayList.map(day => {
|
||||
const dayStart = startOfDay(day).getTime();
|
||||
const dayEnd = endOfDay(day).getTime();
|
||||
|
||||
const createdCount = todos.filter(t => t.createdAt >= dayStart && t.createdAt <= dayEnd).length;
|
||||
const completedCount = todos.filter(t => t.completedAt && t.completedAt >= dayStart && t.completedAt <= dayEnd).length;
|
||||
|
||||
return {
|
||||
date: format(day, 'MMM dd', { locale }),
|
||||
created: createdCount,
|
||||
completed: completedCount,
|
||||
};
|
||||
});
|
||||
}, [todos, range, settings.language]);
|
||||
|
||||
const stats = useMemo(() => {
|
||||
const completed = filteredData.filter(t => t.completed).length;
|
||||
const total = filteredData.length;
|
||||
const rate = total > 0 ? Math.round((completed / total) * 100) : 0;
|
||||
|
||||
return { total, completed, rate };
|
||||
}, [filteredData]);
|
||||
|
||||
// Pro Max Custom Tooltip
|
||||
const CustomTooltip = ({ active, payload, label }: any) => {
|
||||
if (active && payload && payload.length) {
|
||||
return (
|
||||
<div className="bg-white/90 dark:bg-slate-900/90 backdrop-blur-xl p-4 rounded-2xl shadow-2xl border border-white/20 dark:border-slate-700/50 ring-1 ring-black/5">
|
||||
<p className="text-xs font-black uppercase tracking-widest text-slate-400 mb-2">{label}</p>
|
||||
{payload.map((entry: any, index: number) => (
|
||||
<div key={index} className="flex items-center gap-2 mb-1">
|
||||
<div className="w-2 h-2 rounded-full" style={{ backgroundColor: entry.color }} />
|
||||
<p className="text-sm font-bold text-slate-900 dark:text-white">
|
||||
<span className="opacity-60 capitalize">{entry.name}:</span> {entry.value}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-8 pb-20">
|
||||
{/* Range Selector Pro Max */}
|
||||
<div className="flex justify-center">
|
||||
<div className="bg-slate-100/50 dark:bg-slate-900/50 p-1.5 rounded-2xl flex gap-1 border border-slate-200/50 dark:border-slate-800/50 shadow-inner">
|
||||
{(['7d', '30d', 'all'] as const).map((r) => (
|
||||
<button
|
||||
key={r}
|
||||
onClick={() => setRange(r)}
|
||||
className={`px-6 py-2 rounded-xl text-xs font-black uppercase tracking-widest transition-all duration-300 cursor-pointer ${
|
||||
range === r
|
||||
? 'bg-white dark:bg-slate-800 text-slate-900 dark:text-white shadow-md ring-1 ring-black/5'
|
||||
: 'text-slate-400 hover:text-slate-600 dark:hover:text-slate-300'
|
||||
}`}
|
||||
>
|
||||
{r === '7d' ? t.past7Days : r === '30d' ? t.pastMonth : t.allTime}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* KPI Grid Pro Max - Improved Alignment & Consistency */}
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 gap-3 sm:gap-4 lg:gap-6">
|
||||
{[
|
||||
{ label: t.pv, value: siteStats.pv, icon: Eye, color: 'indigo', bg: 'bg-indigo-500/10', text: 'text-indigo-500' },
|
||||
{ label: t.uv, value: siteStats.uv, icon: Users, color: 'purple', bg: 'bg-purple-500/10', text: 'text-purple-500' },
|
||||
{ label: t.totalCreated, value: stats.total, icon: Calendar, color: 'blue', bg: 'bg-blue-500/10', text: 'text-blue-500' },
|
||||
{ label: t.completed, value: stats.completed, icon: CheckCircle2, color: 'emerald', bg: 'bg-emerald-500/10', text: 'text-emerald-500' },
|
||||
{ label: t.successRate, value: `${stats.rate}%`, icon: TrendingUp, color: 'orange', bg: 'bg-orange-500/10', text: 'text-orange-500' },
|
||||
].map((item, idx) => (
|
||||
<motion.div
|
||||
key={`${item.label}-${idx}`}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: idx * 0.1 }}
|
||||
className="glass-card p-4 sm:p-5 lg:p-6 rounded-2xl sm:rounded-[2rem] flex flex-col sm:flex-row items-start sm:items-center gap-3 sm:gap-4 hover-lift"
|
||||
>
|
||||
<div className={`p-3 sm:p-4 ${item.bg} ${item.text} rounded-xl sm:rounded-2xl shrink-0 transition-all duration-500`}>
|
||||
<item.icon size={20} className="sm:w-6 sm:h-6" strokeWidth={2.5} />
|
||||
</div>
|
||||
<div className="flex flex-col justify-center w-full">
|
||||
<p className="text-[9px] sm:text-[10px] font-black uppercase tracking-wide text-slate-400 mb-1 leading-tight whitespace-nowrap">
|
||||
{item.label}
|
||||
</p>
|
||||
<p className="text-xl sm:text-2xl lg:text-3xl font-black tabular-nums tracking-tighter text-slate-900 dark:text-white">
|
||||
{typeof item.value === 'number' ? item.value.toLocaleString() : item.value}
|
||||
</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Charts Section */}
|
||||
<div className="grid grid-cols-1 gap-8">
|
||||
{/* Line Chart: Daily Activity */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.95 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
className="glass-card p-8 rounded-[3rem] overflow-hidden relative"
|
||||
>
|
||||
<div className="mb-8">
|
||||
<h3 className="text-lg font-black uppercase tracking-tighter text-slate-900 dark:text-white flex items-center gap-2">
|
||||
{t.dailyActivity}
|
||||
<div className="px-2 py-0.5 bg-blue-500/10 text-blue-500 text-[10px] rounded-full">{t.trend}</div>
|
||||
</h3>
|
||||
<p className="text-xs font-bold text-slate-400 uppercase tracking-widest mt-1">{t.creationVsCompletion}</p>
|
||||
</div>
|
||||
|
||||
<div className="h-[350px] w-full">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<AreaChart data={dailyStats}>
|
||||
<defs>
|
||||
<linearGradient id="colorCreated" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor="#3b82f6" stopOpacity={0.3}/>
|
||||
<stop offset="95%" stopColor="#3b82f6" stopOpacity={0}/>
|
||||
</linearGradient>
|
||||
<linearGradient id="colorCompleted" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor="#10b981" stopOpacity={0.3}/>
|
||||
<stop offset="95%" stopColor="#10b981" stopOpacity={0}/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="#e2e8f0" className="dark:stroke-slate-800" />
|
||||
<XAxis
|
||||
dataKey="date"
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
tick={{ fontSize: 10, fontWeight: 700, fill: '#94a3b8' }}
|
||||
dy={10}
|
||||
/>
|
||||
<YAxis
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
tick={{ fontSize: 10, fontWeight: 700, fill: '#94a3b8' }}
|
||||
/>
|
||||
<Tooltip content={<CustomTooltip />} />
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="created"
|
||||
name={t.createdLabel}
|
||||
stroke="#3b82f6"
|
||||
strokeWidth={4}
|
||||
fillOpacity={1}
|
||||
fill="url(#colorCreated)"
|
||||
animationDuration={1500}
|
||||
/>
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="completed"
|
||||
name={t.completedLabel}
|
||||
stroke="#10b981"
|
||||
strokeWidth={4}
|
||||
fillOpacity={1}
|
||||
fill="url(#colorCompleted)"
|
||||
animationDuration={2000}
|
||||
/>
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* Simplified Timeline (Gantt-like) */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="glass-card p-8 rounded-[3rem]"
|
||||
>
|
||||
<div className="mb-8">
|
||||
<h3 className="text-lg font-black uppercase tracking-tighter text-slate-900 dark:text-white">
|
||||
{t.recentTaskTimeline}
|
||||
</h3>
|
||||
<p className="text-xs font-bold text-slate-400 uppercase tracking-widest mt-1">{t.completionDuration}</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
{todos.filter(t => t.completedAt).slice(0, 8).map((todo, idx) => {
|
||||
const duration = todo.completedAt! - todo.createdAt;
|
||||
const hours = Math.round(duration / (1000 * 60 * 60));
|
||||
const percentage = Math.min(Math.max((duration / (1000 * 60 * 60 * 24)) * 100, 10), 100);
|
||||
|
||||
return (
|
||||
<div key={todo.id} className="space-y-2">
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-sm font-bold text-slate-700 dark:text-slate-300 truncate max-w-[200px]">
|
||||
{todo.text}
|
||||
</span>
|
||||
<span className="text-[10px] font-black uppercase tracking-widest text-emerald-500">
|
||||
{t.took} {hours}{t.language === 'zh' ? t.hours : 'h'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-3 w-full bg-slate-100 dark:bg-slate-800 rounded-full overflow-hidden">
|
||||
<motion.div
|
||||
initial={{ width: 0 }}
|
||||
animate={{ width: `${percentage}%` }}
|
||||
transition={{ delay: 0.5 + idx * 0.1, duration: 1 }}
|
||||
className="h-full bg-gradient-to-r from-blue-500 to-emerald-500 rounded-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
155
src/components/AuthModal.tsx
Normal file
155
src/components/AuthModal.tsx
Normal file
@@ -0,0 +1,155 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { Lock, X, Eye, EyeOff, ShieldCheck, ShieldAlert } from 'lucide-react';
|
||||
import { useAuth } from '@/contexts/AuthContext';
|
||||
import { useSettings } from '@/contexts/SettingsContext';
|
||||
import { translations } from '@/lib/translations';
|
||||
|
||||
export default function AuthModal() {
|
||||
const { showAuthModal, authenticate, closeAuthModal } = useAuth();
|
||||
const { settings } = useSettings();
|
||||
const t = translations[settings.language];
|
||||
|
||||
const [password, setPassword] = useState('');
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!password.trim()) {
|
||||
setError(t.passwordRequired || 'Password is required');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
setError('');
|
||||
|
||||
const success = await authenticate(password);
|
||||
|
||||
if (!success) {
|
||||
setError(t.invalidPassword || 'Invalid password');
|
||||
}
|
||||
|
||||
setIsLoading(false);
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
setPassword('');
|
||||
setError('');
|
||||
closeAuthModal();
|
||||
};
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{showAuthModal && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="fixed inset-0 z-[100] flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm"
|
||||
onClick={handleClose}
|
||||
>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.9, y: 20 }}
|
||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||
exit={{ opacity: 0, scale: 0.9, y: 20 }}
|
||||
transition={{ type: 'spring', bounce: 0.3, duration: 0.5 }}
|
||||
className="w-full max-w-md glass-card p-8 rounded-[2rem] ring-1 ring-black/5 dark:ring-white/10 shadow-2xl"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-8">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="p-3 bg-amber-500/10 dark:bg-amber-500/20 rounded-2xl">
|
||||
<Lock className="w-6 h-6 text-amber-600 dark:text-amber-400" strokeWidth={2.5} />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-xl font-bold text-slate-900 dark:text-white">
|
||||
{t.authRequired || 'Authentication Required'}
|
||||
</h2>
|
||||
<p className="text-sm text-slate-500 dark:text-slate-400">
|
||||
{t.authDescription || 'Enter password to modify tasks'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleClose}
|
||||
className="p-2 rounded-xl hover:bg-slate-100 dark:hover:bg-slate-800 transition-colors cursor-pointer"
|
||||
>
|
||||
<X className="w-5 h-5 text-slate-400" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Form */}
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
<div className="relative">
|
||||
<input
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
value={password}
|
||||
onChange={(e) => {
|
||||
setPassword(e.target.value);
|
||||
setError('');
|
||||
}}
|
||||
placeholder={t.enterPassword || 'Enter password'}
|
||||
className="w-full bg-slate-100 dark:bg-slate-900/50 border-none rounded-2xl px-6 py-4 pr-12 text-slate-900 dark:text-white placeholder:text-slate-400 focus:outline-none focus:ring-2 focus:ring-amber-500/50"
|
||||
autoFocus
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="absolute right-4 top-1/2 -translate-y-1/2 p-1 text-slate-400 hover:text-slate-600 dark:hover:text-slate-300 transition-colors cursor-pointer"
|
||||
>
|
||||
{showPassword ? <EyeOff size={20} /> : <Eye size={20} />}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Error Message */}
|
||||
<AnimatePresence>
|
||||
{error && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -10 }}
|
||||
className="flex items-center gap-2 px-4 py-3 bg-red-500/10 dark:bg-red-500/20 rounded-xl"
|
||||
>
|
||||
<ShieldAlert className="w-5 h-5 text-red-500" />
|
||||
<span className="text-sm font-medium text-red-600 dark:text-red-400">
|
||||
{error}
|
||||
</span>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Submit Button */}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading || !password.trim()}
|
||||
className="w-full flex items-center justify-center gap-3 bg-slate-900 dark:bg-white text-white dark:text-slate-900 py-4 rounded-2xl font-bold transition-all duration-300 disabled:opacity-40 disabled:cursor-not-allowed hover:shadow-xl active:scale-[0.98] cursor-pointer"
|
||||
>
|
||||
{isLoading ? (
|
||||
<motion.div
|
||||
animate={{ rotate: 360 }}
|
||||
transition={{ duration: 1, repeat: Infinity, ease: 'linear' }}
|
||||
>
|
||||
<ShieldCheck className="w-5 h-5" />
|
||||
</motion.div>
|
||||
) : (
|
||||
<ShieldCheck className="w-5 h-5" />
|
||||
)}
|
||||
<span>{isLoading ? (t.verifying || 'Verifying...') : (t.verify || 'Verify')}</span>
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{/* Footer Hint */}
|
||||
<p className="mt-6 text-center text-xs text-slate-400">
|
||||
{t.authHint || 'Contact administrator if you forgot the password'}
|
||||
</p>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
141
src/components/StarkLogo.tsx
Normal file
141
src/components/StarkLogo.tsx
Normal file
@@ -0,0 +1,141 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { Zap } from 'lucide-react';
|
||||
import { useSettings } from '@/contexts/SettingsContext';
|
||||
import { translations } from '@/lib/translations';
|
||||
|
||||
interface StarkLogoProps {
|
||||
logoText?: string;
|
||||
}
|
||||
|
||||
const StarkLogo: React.FC<StarkLogoProps> = ({ logoText: customLogoText }) => {
|
||||
const { settings } = useSettings();
|
||||
const t = translations[settings.language];
|
||||
const logoText = customLogoText || settings.logoText || '蛋定-项目待办';
|
||||
const letters = logoText.split('');
|
||||
const contactUrl = 'https://qm.qq.com/q/IOiQPWjEwW';
|
||||
const letterVariants = {
|
||||
hidden: { opacity: 0, y: 20, filter: 'blur(10px)' },
|
||||
visible: (i: number) => ({
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
filter: 'blur(0px)',
|
||||
transition: {
|
||||
delay: i * 0.08,
|
||||
duration: 0.8,
|
||||
ease: [0.22, 1, 0.36, 1] as const, // 使用 as const 修复类型校验
|
||||
},
|
||||
}),
|
||||
};
|
||||
|
||||
const iconVariants = {
|
||||
hidden: { scale: 0.8, opacity: 0, rotate: -15 },
|
||||
visible: {
|
||||
scale: 1,
|
||||
opacity: 1,
|
||||
rotate: 0,
|
||||
transition: {
|
||||
delay: 0.4,
|
||||
duration: 0.7,
|
||||
ease: [0.22, 1, 0.36, 1] as const, // 使用 as const 修复类型校验
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-8 sm:py-12 md:py-16 select-none">
|
||||
{/* Icon + Text Logo */}
|
||||
<div className="flex items-center gap-3 sm:gap-4 md:gap-6 mb-4 sm:mb-6">
|
||||
<motion.div
|
||||
variants={iconVariants}
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
className="relative group"
|
||||
>
|
||||
{/* Animated background glow Pro Max */}
|
||||
<motion.div
|
||||
animate={{
|
||||
scale: [1, 1.15, 1],
|
||||
opacity: [0.3, 0.6, 0.3],
|
||||
}}
|
||||
transition={{
|
||||
duration: 4,
|
||||
repeat: Infinity,
|
||||
ease: 'easeInOut',
|
||||
}}
|
||||
className="absolute -inset-4 bg-blue-500/20 dark:bg-blue-400/10 rounded-full blur-2xl"
|
||||
/>
|
||||
|
||||
{/* Icon Pro Max */}
|
||||
<div className="relative bg-gradient-to-br from-blue-600 via-blue-500 to-blue-700 dark:from-blue-500 dark:to-blue-600 p-3 sm:p-4 rounded-2xl shadow-2xl shadow-blue-500/20 ring-1 ring-white/20">
|
||||
<img src="/egg-icon.ico" alt="Logo" className="w-7 h-7 sm:w-8 sm:h-8" />
|
||||
<motion.div
|
||||
animate={{ rotate: 360 }}
|
||||
transition={{ duration: 8, repeat: Infinity, ease: "linear" }}
|
||||
className="absolute -top-1 -right-1"
|
||||
>
|
||||
<Zap className="text-amber-300 fill-amber-300" size={14} />
|
||||
</motion.div>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* Text Logo with modern font Pro Max */}
|
||||
<div className="flex items-center">
|
||||
{letters.map((letter, i) => (
|
||||
<motion.span
|
||||
key={`${letter}-${i}`}
|
||||
custom={i}
|
||||
variants={letterVariants}
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
className="text-5xl sm:text-6xl md:text-7xl lg:text-8xl font-black tracking-tight bg-gradient-to-b from-slate-900 via-slate-800 to-slate-950 dark:from-white dark:via-slate-200 dark:to-slate-400 bg-clip-text text-transparent drop-shadow-[0_4px_4px_rgba(0,0,0,0.1)]"
|
||||
style={{
|
||||
fontFamily: "'Inter Tight', 'Inter', system-ui, sans-serif",
|
||||
fontWeight: 900,
|
||||
}}
|
||||
>
|
||||
{letter}
|
||||
</motion.span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Subtitle Pro Max */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 1, duration: 0.8 }}
|
||||
className="px-6 py-2 rounded-full bg-slate-100/50 dark:bg-slate-800/30 border border-slate-200/50 dark:border-slate-700/30 backdrop-blur-md"
|
||||
>
|
||||
{settings.language === 'zh' ? (
|
||||
<p className="text-xs sm:text-sm font-semibold tracking-wide text-slate-600 dark:text-slate-300">
|
||||
本站待办任务清单,如有任何需求和建议,欢迎
|
||||
<a
|
||||
href={contactUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-600 dark:text-blue-400 underline underline-offset-2"
|
||||
>
|
||||
联系
|
||||
</a>
|
||||
我。
|
||||
</p>
|
||||
) : (
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-xs sm:text-sm font-semibold tracking-wider uppercase text-slate-500 dark:text-slate-400">
|
||||
{t.subtitle1}
|
||||
</span>
|
||||
<div className="w-1 h-1 rounded-full bg-slate-300 dark:bg-slate-600" />
|
||||
<span className="text-xs sm:text-sm font-semibold tracking-wider uppercase text-blue-600 dark:text-blue-400">
|
||||
{t.subtitle2}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default StarkLogo;
|
||||
34
src/components/StatsTracker.tsx
Normal file
34
src/components/StatsTracker.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect } from 'react';
|
||||
|
||||
export default function StatsTracker() {
|
||||
useEffect(() => {
|
||||
const trackStats = async () => {
|
||||
try {
|
||||
// Check if user has visited before using localStorage
|
||||
const hasVisited = localStorage.getItem('stark_visited');
|
||||
const isNewVisitor = !hasVisited;
|
||||
|
||||
if (isNewVisitor) {
|
||||
localStorage.setItem('stark_visited', 'true');
|
||||
}
|
||||
|
||||
// Increment PV and UV (if new)
|
||||
await fetch('/api/stats', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ isNewVisitor }),
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[StatsTracker] Failed to track stats:', error);
|
||||
}
|
||||
};
|
||||
|
||||
trackStats();
|
||||
}, []);
|
||||
|
||||
return null; // This component doesn't render anything
|
||||
}
|
||||
Reference in New Issue
Block a user