fa
Some checks failed
CI / lint (push) Has been cancelled
CI / build (push) Has been cancelled

This commit is contained in:
2026-02-26 21:29:54 +08:00
commit 2c4549ad79
85 changed files with 14664 additions and 0 deletions

View 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>
);
}

View 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>
);
}

View 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;

View 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
}