fa
This commit is contained in:
98
src/app/analytics/page.tsx
Normal file
98
src/app/analytics/page.tsx
Normal file
@@ -0,0 +1,98 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { motion } from 'framer-motion';
|
||||
import { ArrowLeft, BarChart3, Info } from 'lucide-react';
|
||||
import { Todo } from '@/lib/types';
|
||||
import { useSettings } from '@/contexts/SettingsContext';
|
||||
import { translations } from '@/lib/translations';
|
||||
import dynamic from 'next/dynamic';
|
||||
|
||||
// 动态导入图表组件以支持 SSR
|
||||
const AnalyticsDashboard = dynamic(() => import('@/components/AnalyticsDashboard'), {
|
||||
loading: () => {
|
||||
const { settings } = useSettings();
|
||||
const t = translations[settings.language];
|
||||
return (
|
||||
<div className="h-96 flex flex-col items-center justify-center gap-4">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-500" />
|
||||
<p className="text-slate-400 font-bold uppercase tracking-widest text-xs">{t.analyticsLoading}</p>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
ssr: false
|
||||
});
|
||||
|
||||
export default function AnalyticsPage() {
|
||||
const router = useRouter();
|
||||
const { settings } = useSettings();
|
||||
const t = translations[settings.language];
|
||||
const [todos, setTodos] = useState<Todo[]>([]);
|
||||
const [siteStats, setSiteStats] = useState({ pv: 0, uv: 0 });
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
async function fetchData() {
|
||||
try {
|
||||
const [todosRes, statsRes] = await Promise.all([
|
||||
fetch('/api/todos'),
|
||||
fetch('/api/stats')
|
||||
]);
|
||||
const todosData = await todosRes.json();
|
||||
const statsData = await statsRes.json();
|
||||
setTodos(todosData);
|
||||
setSiteStats(statsData);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch data:', error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
fetchData();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<main className="min-h-screen bg-light-primary selection:bg-blue-500/30">
|
||||
<div className="max-w-5xl mx-auto px-4 sm:px-6 py-12 sm:py-20">
|
||||
|
||||
{/* Header Pro Max */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: -20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
className="flex items-center justify-between mb-12 sm:mb-16"
|
||||
>
|
||||
<div className="flex items-center gap-6">
|
||||
<button
|
||||
onClick={() => router.push('/')}
|
||||
className="p-4 bg-white dark:bg-slate-800 rounded-2xl shadow-lg border border-slate-200 dark:border-slate-700 hover:scale-110 active:scale-95 transition-all cursor-pointer group"
|
||||
>
|
||||
<ArrowLeft size={24} className="group-hover:-translate-x-1 transition-transform" />
|
||||
</button>
|
||||
<div>
|
||||
<h1 className="text-4xl sm:text-5xl font-black tracking-tighter text-slate-900 dark:text-white uppercase italic flex items-center gap-4">
|
||||
<BarChart3 className="text-blue-500" size={40} strokeWidth={3} />
|
||||
{t.insights}
|
||||
</h1>
|
||||
<div className="h-1 w-12 bg-blue-500 rounded-full mt-2" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="hidden sm:flex items-center gap-3 px-4 py-2 rounded-2xl bg-blue-500/10 border border-blue-500/20 text-blue-600 dark:text-blue-400">
|
||||
<Info size={16} strokeWidth={3} />
|
||||
<span className="text-[10px] font-black uppercase tracking-widest">{t.liveDataEngine}</span>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="h-96 flex flex-col items-center justify-center gap-4">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-500" />
|
||||
<p className="text-slate-400 font-bold uppercase tracking-widest text-xs">{t.aggregatingRecords}</p>
|
||||
</div>
|
||||
) : (
|
||||
<AnalyticsDashboard todos={todos} siteStats={siteStats} />
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
362
src/app/api-docs/page.tsx
Normal file
362
src/app/api-docs/page.tsx
Normal file
@@ -0,0 +1,362 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import {
|
||||
ArrowLeft, Copy, Check, Lock, Unlock, Send,
|
||||
FileJson, Code2, Zap, Shield, Clock, Terminal
|
||||
} from 'lucide-react';
|
||||
import { useSettings } from '@/contexts/SettingsContext';
|
||||
import { translations } from '@/lib/translations';
|
||||
|
||||
interface Endpoint {
|
||||
method: 'GET' | 'POST' | 'PUT' | 'DELETE';
|
||||
path: string;
|
||||
description: string;
|
||||
descriptionZh: string;
|
||||
auth: boolean;
|
||||
requestBody?: {
|
||||
type: string;
|
||||
properties: { name: string; type: string; required: boolean; description: string }[];
|
||||
};
|
||||
responseExample: object;
|
||||
curlExample: string;
|
||||
}
|
||||
|
||||
const endpoints: Endpoint[] = [
|
||||
{
|
||||
method: 'GET',
|
||||
path: '/api/todos',
|
||||
description: 'Get all active todos',
|
||||
descriptionZh: '获取所有活跃的任务',
|
||||
auth: false,
|
||||
responseExample: [
|
||||
{ id: 'uuid', text: 'Task content', completed: false, createdAt: 1706000000000 }
|
||||
],
|
||||
curlExample: 'curl https://your-domain.com/api/todos',
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/api/todos',
|
||||
description: 'Create a new todo',
|
||||
descriptionZh: '创建新任务',
|
||||
auth: true,
|
||||
requestBody: {
|
||||
type: 'application/json',
|
||||
properties: [
|
||||
{ name: 'text', type: 'string', required: true, description: 'Task content' },
|
||||
{ name: 'createdAt', type: 'number', required: false, description: 'Custom timestamp (ms)' },
|
||||
],
|
||||
},
|
||||
responseExample: { id: 'uuid', text: 'New task', completed: false, createdAt: 1706000000000 },
|
||||
curlExample: `curl -X POST https://your-domain.com/api/todos \\
|
||||
-H "Content-Type: application/json" \\
|
||||
-H "X-API-Key: your-password" \\
|
||||
-d '{"text": "New task"}'`,
|
||||
},
|
||||
{
|
||||
method: 'PUT',
|
||||
path: '/api/todos',
|
||||
description: 'Update a todo',
|
||||
descriptionZh: '更新任务',
|
||||
auth: true,
|
||||
requestBody: {
|
||||
type: 'application/json',
|
||||
properties: [
|
||||
{ name: 'id', type: 'string', required: true, description: 'Todo ID' },
|
||||
{ name: 'text', type: 'string', required: false, description: 'New content' },
|
||||
{ name: 'completed', type: 'boolean', required: false, description: 'Completion status' },
|
||||
{ name: 'createdAt', type: 'number', required: false, description: 'Override created time' },
|
||||
{ name: 'completedAt', type: 'number', required: false, description: 'Custom completion time' },
|
||||
],
|
||||
},
|
||||
responseExample: { id: 'uuid', text: 'Updated', completed: true, createdAt: 1706000000000, completedAt: 1706100000000 },
|
||||
curlExample: `curl -X PUT https://your-domain.com/api/todos \\
|
||||
-H "Content-Type: application/json" \\
|
||||
-H "X-API-Key: your-password" \\
|
||||
-d '{"id": "todo-id", "completed": true}'`,
|
||||
},
|
||||
{
|
||||
method: 'DELETE',
|
||||
path: '/api/todos',
|
||||
description: 'Soft delete a todo',
|
||||
descriptionZh: '删除任务(软删除)',
|
||||
auth: true,
|
||||
responseExample: { success: true, id: 'deleted-id' },
|
||||
curlExample: `curl -X DELETE "https://your-domain.com/api/todos?id=todo-id" \\
|
||||
-H "X-API-Key: your-password"`,
|
||||
},
|
||||
{
|
||||
method: 'GET',
|
||||
path: '/api/stats',
|
||||
description: 'Get site statistics (PV/UV)',
|
||||
descriptionZh: '获取站点统计(PV/UV)',
|
||||
auth: false,
|
||||
responseExample: { pv: 1234, uv: 56 },
|
||||
curlExample: 'curl https://your-domain.com/api/stats',
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/api/auth',
|
||||
description: 'Verify password',
|
||||
descriptionZh: '验证密码',
|
||||
auth: false,
|
||||
requestBody: {
|
||||
type: 'application/json',
|
||||
properties: [
|
||||
{ name: 'password', type: 'string', required: true, description: 'Password to verify' },
|
||||
],
|
||||
},
|
||||
responseExample: { success: true },
|
||||
curlExample: `curl -X POST https://your-domain.com/api/auth \\
|
||||
-H "Content-Type: application/json" \\
|
||||
-d '{"password": "your-password"}'`,
|
||||
},
|
||||
];
|
||||
|
||||
const methodColors: Record<string, { bg: string; text: string; border: string }> = {
|
||||
GET: { bg: 'bg-emerald-500/10', text: 'text-emerald-500', border: 'border-emerald-500/30' },
|
||||
POST: { bg: 'bg-blue-500/10', text: 'text-blue-500', border: 'border-blue-500/30' },
|
||||
PUT: { bg: 'bg-amber-500/10', text: 'text-amber-500', border: 'border-amber-500/30' },
|
||||
DELETE: { bg: 'bg-red-500/10', text: 'text-red-500', border: 'border-red-500/30' },
|
||||
};
|
||||
|
||||
export default function ApiDocsPage() {
|
||||
const router = useRouter();
|
||||
const { settings } = useSettings();
|
||||
const t = translations[settings.language];
|
||||
const [expandedIndex, setExpandedIndex] = useState<number | null>(null);
|
||||
const [copiedIndex, setCopiedIndex] = useState<number | null>(null);
|
||||
|
||||
const copyToClipboard = async (text: string, index: number) => {
|
||||
await navigator.clipboard.writeText(text);
|
||||
setCopiedIndex(index);
|
||||
setTimeout(() => setCopiedIndex(null), 2000);
|
||||
};
|
||||
|
||||
return (
|
||||
<main className="min-h-screen bg-light-primary selection:bg-blue-500/30">
|
||||
<div className="max-w-5xl mx-auto px-4 sm:px-6 py-12 sm:py-20">
|
||||
{/* Header */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="flex items-center gap-6 mb-12"
|
||||
>
|
||||
<button
|
||||
onClick={() => router.push('/')}
|
||||
className="p-4 bg-white dark:bg-slate-800 rounded-2xl shadow-lg border border-slate-200 dark:border-slate-700 hover:scale-110 active:scale-95 transition-all cursor-pointer group"
|
||||
>
|
||||
<ArrowLeft size={24} className="group-hover:-translate-x-1 transition-transform" />
|
||||
</button>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-gradient-to-br from-blue-500 to-purple-600 rounded-xl">
|
||||
<Code2 size={24} className="text-white" />
|
||||
</div>
|
||||
<h1 className="text-3xl sm:text-4xl font-black tracking-tighter text-slate-900 dark:text-white">
|
||||
API {settings.language === 'zh' ? '文档' : 'Documentation'}
|
||||
</h1>
|
||||
</div>
|
||||
<p className="text-sm text-slate-500 mt-2 font-medium">
|
||||
{settings.language === 'zh'
|
||||
? 'RESTful API 接口说明 · 支持自定义时间戳'
|
||||
: 'RESTful API Reference · Custom Timestamps Supported'}
|
||||
</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* Auth Info Card */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.1 }}
|
||||
className="glass-card p-6 rounded-3xl mb-8 border-l-4 border-amber-500"
|
||||
>
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="p-3 bg-amber-500/10 rounded-2xl">
|
||||
<Shield size={24} className="text-amber-500" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-bold text-slate-900 dark:text-white mb-1">
|
||||
{settings.language === 'zh' ? '认证方式' : 'Authentication'}
|
||||
</h3>
|
||||
<p className="text-sm text-slate-500 mb-3">
|
||||
{settings.language === 'zh'
|
||||
? '需要认证的接口请在请求头中添加 API Key:'
|
||||
: 'For protected endpoints, add API key to request headers:'}
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<code className="px-3 py-1.5 bg-slate-100 dark:bg-slate-800 rounded-lg text-xs font-mono text-slate-700 dark:text-slate-300">
|
||||
X-API-Key: your-password
|
||||
</code>
|
||||
<span className="text-slate-400 self-center">{settings.language === 'zh' ? '或' : 'or'}</span>
|
||||
<code className="px-3 py-1.5 bg-slate-100 dark:bg-slate-800 rounded-lg text-xs font-mono text-slate-700 dark:text-slate-300">
|
||||
Authorization: Bearer your-password
|
||||
</code>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* Endpoints List */}
|
||||
<div className="space-y-4">
|
||||
{endpoints.map((endpoint, index) => {
|
||||
const colors = methodColors[endpoint.method];
|
||||
const isExpanded = expandedIndex === index;
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
key={`${endpoint.method}-${endpoint.path}`}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.1 + index * 0.05 }}
|
||||
className="glass-card rounded-3xl overflow-hidden"
|
||||
>
|
||||
{/* Endpoint Header */}
|
||||
<button
|
||||
onClick={() => setExpandedIndex(isExpanded ? null : index)}
|
||||
className="w-full p-5 flex items-center gap-4 cursor-pointer hover:bg-slate-50/50 dark:hover:bg-slate-800/30 transition-colors"
|
||||
>
|
||||
<span className={`px-3 py-1.5 rounded-lg text-xs font-black ${colors.bg} ${colors.text} border ${colors.border}`}>
|
||||
{endpoint.method}
|
||||
</span>
|
||||
<code className="font-mono text-sm text-slate-700 dark:text-slate-300 flex-1 text-left">
|
||||
{endpoint.path}
|
||||
</code>
|
||||
{endpoint.auth ? (
|
||||
<Lock size={16} className="text-amber-500" />
|
||||
) : (
|
||||
<Unlock size={16} className="text-emerald-500" />
|
||||
)}
|
||||
<span className="text-sm text-slate-500 hidden sm:block">
|
||||
{settings.language === 'zh' ? endpoint.descriptionZh : endpoint.description}
|
||||
</span>
|
||||
<motion.div
|
||||
animate={{ rotate: isExpanded ? 180 : 0 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
>
|
||||
<Zap size={18} className={`transition-colors ${isExpanded ? 'text-blue-500' : 'text-slate-400'}`} />
|
||||
</motion.div>
|
||||
</button>
|
||||
|
||||
{/* Expanded Content */}
|
||||
<AnimatePresence>
|
||||
{isExpanded && (
|
||||
<motion.div
|
||||
initial={{ height: 0, opacity: 0 }}
|
||||
animate={{ height: 'auto', opacity: 1 }}
|
||||
exit={{ height: 0, opacity: 0 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
className="border-t border-slate-200 dark:border-slate-700/50"
|
||||
>
|
||||
<div className="p-5 space-y-5">
|
||||
{/* Description on mobile */}
|
||||
<p className="text-sm text-slate-600 dark:text-slate-400 sm:hidden">
|
||||
{settings.language === 'zh' ? endpoint.descriptionZh : endpoint.description}
|
||||
</p>
|
||||
|
||||
{/* Request Body */}
|
||||
{endpoint.requestBody && (
|
||||
<div>
|
||||
<h4 className="text-xs font-black uppercase tracking-widest text-slate-400 mb-3 flex items-center gap-2">
|
||||
<FileJson size={14} />
|
||||
Request Body
|
||||
</h4>
|
||||
<div className="bg-slate-50 dark:bg-slate-900/50 rounded-2xl p-4 overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="text-left text-xs uppercase tracking-wider text-slate-400">
|
||||
<th className="pb-2 pr-4">Field</th>
|
||||
<th className="pb-2 pr-4">Type</th>
|
||||
<th className="pb-2 pr-4">Required</th>
|
||||
<th className="pb-2">Description</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="font-mono">
|
||||
{endpoint.requestBody.properties.map((prop) => (
|
||||
<tr key={prop.name} className="border-t border-slate-200 dark:border-slate-700/50">
|
||||
<td className="py-2 pr-4 text-blue-500">{prop.name}</td>
|
||||
<td className="py-2 pr-4 text-emerald-500">{prop.type}</td>
|
||||
<td className="py-2 pr-4">
|
||||
{prop.required ? (
|
||||
<span className="text-red-400">*</span>
|
||||
) : (
|
||||
<span className="text-slate-400">-</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="py-2 text-slate-500 font-sans text-xs">{prop.description}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Response Example */}
|
||||
<div>
|
||||
<h4 className="text-xs font-black uppercase tracking-widest text-slate-400 mb-3 flex items-center gap-2">
|
||||
<Send size={14} />
|
||||
Response Example
|
||||
</h4>
|
||||
<pre className="bg-slate-900 text-slate-100 rounded-2xl p-4 overflow-x-auto text-xs font-mono">
|
||||
{JSON.stringify(endpoint.responseExample, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
{/* cURL Example */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h4 className="text-xs font-black uppercase tracking-widest text-slate-400 flex items-center gap-2">
|
||||
<Terminal size={14} />
|
||||
cURL Example
|
||||
</h4>
|
||||
<button
|
||||
onClick={() => copyToClipboard(endpoint.curlExample, index)}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-slate-100 dark:bg-slate-800 text-slate-500 hover:text-slate-900 dark:hover:text-white transition-colors cursor-pointer text-xs font-bold"
|
||||
>
|
||||
{copiedIndex === index ? (
|
||||
<>
|
||||
<Check size={14} className="text-emerald-500" />
|
||||
Copied!
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Copy size={14} />
|
||||
Copy
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
<pre className="bg-slate-900 text-emerald-400 rounded-2xl p-4 overflow-x-auto text-xs font-mono whitespace-pre-wrap">
|
||||
{endpoint.curlExample}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</motion.div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ delay: 0.5 }}
|
||||
className="mt-12 text-center"
|
||||
>
|
||||
<p className="text-xs text-slate-400 font-medium">
|
||||
{settings.language === 'zh'
|
||||
? '基于 Next.js API Routes 构建 · 数据持久化于 JSON'
|
||||
: 'Built with Next.js API Routes · Data persisted in JSON'}
|
||||
</p>
|
||||
</motion.div>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
37
src/app/api/auth/route.ts
Normal file
37
src/app/api/auth/route.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
// 预设密码,可通过环境变量 AUTH_PASSWORD 配置
|
||||
// 默认密码: stark123
|
||||
const AUTH_PASSWORD = process.env.AUTH_PASSWORD || 'stark123';
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const { password } = await request.json();
|
||||
|
||||
if (!password) {
|
||||
return NextResponse.json(
|
||||
{ success: false, message: 'Password is required' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const isValid = password === AUTH_PASSWORD;
|
||||
|
||||
if (isValid) {
|
||||
console.log('[Auth] User authenticated successfully');
|
||||
return NextResponse.json({ success: true });
|
||||
} else {
|
||||
console.log('[Auth] Invalid password attempt');
|
||||
return NextResponse.json(
|
||||
{ success: false, message: 'Invalid password' },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[Auth] Error:', error);
|
||||
return NextResponse.json(
|
||||
{ success: false, message: 'Server error' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
82
src/app/api/groups/route.ts
Normal file
82
src/app/api/groups/route.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { getGroups, saveGroups, Group, getTodos, saveTodos } from '@/lib/storage';
|
||||
|
||||
const AUTH_PASSWORD = process.env.AUTH_PASSWORD || 'stark123';
|
||||
|
||||
function verifyApiKey(request: Request): boolean {
|
||||
const apiKey = request.headers.get('X-API-Key') || request.headers.get('Authorization')?.replace('Bearer ', '');
|
||||
return apiKey === AUTH_PASSWORD;
|
||||
}
|
||||
|
||||
function unauthorizedResponse() {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Unauthorized',
|
||||
message: 'Valid API key required.'
|
||||
},
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const groups = getGroups();
|
||||
return NextResponse.json(groups);
|
||||
} catch (error) {
|
||||
return NextResponse.json({ error: 'Failed to fetch groups' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
if (!verifyApiKey(request)) return unauthorizedResponse();
|
||||
|
||||
const { name } = await request.json();
|
||||
if (!name) return NextResponse.json({ error: 'Name is required' }, { status: 400 });
|
||||
|
||||
const groups = getGroups();
|
||||
const newGroup: Group = {
|
||||
id: crypto.randomUUID(),
|
||||
name,
|
||||
createdAt: Date.now(),
|
||||
};
|
||||
|
||||
groups.push(newGroup);
|
||||
saveGroups(groups);
|
||||
|
||||
return NextResponse.json(newGroup, { status: 201 });
|
||||
} catch (error) {
|
||||
return NextResponse.json({ error: 'Failed to create group' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(request: Request) {
|
||||
try {
|
||||
if (!verifyApiKey(request)) return unauthorizedResponse();
|
||||
|
||||
const { searchParams } = new URL(request.url);
|
||||
const id = searchParams.get('id');
|
||||
|
||||
if (!id || id === 'default') {
|
||||
return NextResponse.json({ error: 'Valid ID is required' }, { status: 400 });
|
||||
}
|
||||
|
||||
const groups = getGroups();
|
||||
const filteredGroups = groups.filter((g) => g.id !== id);
|
||||
saveGroups(filteredGroups);
|
||||
|
||||
// 迁移该分组下的任务到默认分组
|
||||
const todos = getTodos();
|
||||
const updatedTodos = todos.map(todo => {
|
||||
if (todo.groupId === id) {
|
||||
return { ...todo, groupId: 'default' };
|
||||
}
|
||||
return todo;
|
||||
});
|
||||
saveTodos(updatedTodos);
|
||||
|
||||
return NextResponse.json({ success: true, id });
|
||||
} catch (error) {
|
||||
return NextResponse.json({ error: 'Failed to delete group' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
34
src/app/api/stats/route.ts
Normal file
34
src/app/api/stats/route.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { getStats, updateStats } from '@/lib/storage';
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const stats = getStats();
|
||||
return NextResponse.json(stats);
|
||||
} catch (error) {
|
||||
console.error('[API Stats GET] Error:', error);
|
||||
return NextResponse.json({ error: 'Failed to fetch stats' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
let isNewVisitor = false;
|
||||
const rawBody = await request.text();
|
||||
|
||||
if (rawBody.trim()) {
|
||||
try {
|
||||
const body = JSON.parse(rawBody) as { isNewVisitor?: unknown };
|
||||
isNewVisitor = !!body.isNewVisitor;
|
||||
} catch {
|
||||
// Ignore invalid JSON and fall back to default behavior.
|
||||
}
|
||||
}
|
||||
|
||||
const stats = updateStats(isNewVisitor);
|
||||
return NextResponse.json(stats);
|
||||
} catch (error) {
|
||||
console.error('[API Stats POST] Error:', error);
|
||||
return NextResponse.json({ error: 'Failed to update stats' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
237
src/app/api/todos/route.ts
Normal file
237
src/app/api/todos/route.ts
Normal file
@@ -0,0 +1,237 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { getTodos, saveTodos, Todo, checkRateLimit, recordTaskAdd } from '@/lib/storage';
|
||||
import headers from 'next/headers';
|
||||
|
||||
// API 密码验证
|
||||
const AUTH_PASSWORD = process.env.AUTH_PASSWORD || 'stark123';
|
||||
|
||||
function verifyApiKey(request: Request): boolean {
|
||||
const apiKey = request.headers.get('X-API-Key') || request.headers.get('Authorization')?.replace('Bearer ', '');
|
||||
return apiKey === AUTH_PASSWORD;
|
||||
}
|
||||
|
||||
function unauthorizedResponse() {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Unauthorized',
|
||||
message: 'Valid API key required. Use header: X-API-Key: <password> or Authorization: Bearer <password>'
|
||||
},
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取客户端标识符
|
||||
* 优先使用 X-Forwarded-For(代理/负载均衡器),其次使用 X-Real-IP,最后使用生成的唯一ID
|
||||
*/
|
||||
function getClientId(request: Request): string {
|
||||
// 尝试从请求头获取真实IP
|
||||
const forwardedFor = request.headers.get('X-Forwarded-For');
|
||||
const realIp = request.headers.get('X-Real-IP');
|
||||
const cfConnectingIp = request.headers.get('CF-Connecting-Ip'); // Cloudflare
|
||||
|
||||
if (forwardedFor) {
|
||||
// X-Forwarded-For 可能包含多个IP,取第一个
|
||||
return forwardedFor.split(',')[0].trim();
|
||||
}
|
||||
|
||||
if (realIp) {
|
||||
return realIp.trim();
|
||||
}
|
||||
|
||||
if (cfConnectingIp) {
|
||||
return cfConnectingIp.trim();
|
||||
}
|
||||
|
||||
// 如果无法获取IP,使用请求的Cookie或生成一个临时ID
|
||||
// 对于匿名用户,会在前端生成并存储在 localStorage 和 Cookie 中
|
||||
const anonymousId = request.headers.get('X-Anonymous-ID');
|
||||
if (anonymousId) {
|
||||
return `anon_${anonymousId}`;
|
||||
}
|
||||
|
||||
// 如果都没有,使用一个通用标识(实际使用中不建议)
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
// GET - 获取所有任务(无需认证)
|
||||
export async function GET() {
|
||||
try {
|
||||
const todos = getTodos();
|
||||
// 只返回未删除的任务
|
||||
const activeTodos = todos.filter(t => !t.deleted);
|
||||
console.log(`[API GET] Returning ${activeTodos.length} active todos`);
|
||||
return NextResponse.json(activeTodos);
|
||||
} catch (error) {
|
||||
console.error('[API GET] Error:', error);
|
||||
return NextResponse.json({ error: 'Failed to fetch todos' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
// POST - 创建新任务(支持匿名用户,但有限流)
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const { text, createdAt, groupId, priority } = await request.json();
|
||||
if (!text) {
|
||||
return NextResponse.json({ error: 'Text is required' }, { status: 400 });
|
||||
}
|
||||
|
||||
const isAuthenticated = verifyApiKey(request);
|
||||
|
||||
// 如果未认证,应用限流检查
|
||||
if (!isAuthenticated) {
|
||||
const clientId = getClientId(request);
|
||||
const rateLimitCheck = checkRateLimit(clientId);
|
||||
|
||||
if (!rateLimitCheck.canAdd) {
|
||||
// 限流触发
|
||||
if (rateLimitCheck.reason === 'RATE_LIMIT_INTERVAL') {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Rate Limit Exceeded',
|
||||
message: `Please wait ${rateLimitCheck.retryAfter} seconds before adding another task`,
|
||||
reason: 'INTERVAL',
|
||||
retryAfter: rateLimitCheck.retryAfter,
|
||||
dailyRemaining: rateLimitCheck.dailyRemaining,
|
||||
},
|
||||
{ status: 429 }
|
||||
);
|
||||
} else if (rateLimitCheck.reason === 'RATE_LIMIT_DAILY') {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Daily Limit Reached',
|
||||
message: 'You have reached the maximum of 6 tasks for today. Please try again tomorrow.',
|
||||
reason: 'DAILY',
|
||||
dailyRemaining: 0,
|
||||
},
|
||||
{ status: 429 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 记录匿名用户添加任务
|
||||
recordTaskAdd(clientId);
|
||||
}
|
||||
|
||||
const todos = getTodos();
|
||||
const newTodo: Todo = {
|
||||
id: crypto.randomUUID(),
|
||||
text,
|
||||
completed: false,
|
||||
createdAt: createdAt || Date.now(), // 支持自定义创建时间
|
||||
groupId: groupId || 'default',
|
||||
priority: priority || 'P2',
|
||||
};
|
||||
|
||||
todos.push(newTodo);
|
||||
saveTodos(todos);
|
||||
|
||||
const authStatus = isAuthenticated ? 'authenticated' : 'anonymous';
|
||||
console.log(`[API POST] Created todo (${authStatus}): ${newTodo.id} - "${text}"`);
|
||||
return NextResponse.json(newTodo, { status: 201 });
|
||||
} catch (error) {
|
||||
console.error('[API POST] Error:', error);
|
||||
return NextResponse.json({ error: 'Failed to create todo' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
// PUT - 更新任务(需要认证)
|
||||
export async function PUT(request: Request) {
|
||||
try {
|
||||
// 验证 API Key
|
||||
if (!verifyApiKey(request)) {
|
||||
return unauthorizedResponse();
|
||||
}
|
||||
|
||||
const { id, completed, text, createdAt, completedAt, groupId, priority } = await request.json();
|
||||
|
||||
if (!id) {
|
||||
return NextResponse.json({ error: 'ID is required' }, { status: 400 });
|
||||
}
|
||||
|
||||
const todos = getTodos();
|
||||
const index = todos.findIndex((t) => t.id === id);
|
||||
|
||||
if (index === -1) {
|
||||
console.warn(`[API PUT] Todo not found: ${id}`);
|
||||
return NextResponse.json({ error: 'Todo not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
// 更新文本
|
||||
if (text !== undefined) {
|
||||
todos[index].text = text;
|
||||
}
|
||||
|
||||
// 更新创建时间(可选)
|
||||
if (createdAt !== undefined) {
|
||||
todos[index].createdAt = createdAt;
|
||||
}
|
||||
|
||||
// 更新完成状态
|
||||
if (completed !== undefined) {
|
||||
todos[index].completed = completed;
|
||||
if (completed) {
|
||||
// 支持自定义完成时间,否则使用当前时间
|
||||
todos[index].completedAt = completedAt || Date.now();
|
||||
} else {
|
||||
delete todos[index].completedAt;
|
||||
}
|
||||
} else if (completedAt !== undefined && todos[index].completed) {
|
||||
// 仅更新完成时间
|
||||
todos[index].completedAt = completedAt;
|
||||
}
|
||||
|
||||
// 更新分组和优先级
|
||||
if (groupId !== undefined) {
|
||||
todos[index].groupId = groupId;
|
||||
}
|
||||
if (priority !== undefined) {
|
||||
todos[index].priority = priority;
|
||||
}
|
||||
|
||||
saveTodos(todos);
|
||||
|
||||
console.log(`[API PUT] Updated todo: ${id}`);
|
||||
return NextResponse.json(todos[index]);
|
||||
} catch (error) {
|
||||
console.error('[API PUT] Error:', error);
|
||||
return NextResponse.json({ error: 'Failed to update todo' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
// DELETE - 删除任务(需要认证)
|
||||
export async function DELETE(request: Request) {
|
||||
try {
|
||||
// 验证 API Key
|
||||
if (!verifyApiKey(request)) {
|
||||
return unauthorizedResponse();
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(request.url);
|
||||
const id = searchParams.get('id');
|
||||
|
||||
if (!id) {
|
||||
return NextResponse.json({ error: 'ID is required' }, { status: 400 });
|
||||
}
|
||||
|
||||
const todos = getTodos();
|
||||
const index = todos.findIndex((t) => t.id === id);
|
||||
|
||||
if (index === -1) {
|
||||
console.warn(`[API DELETE] Todo not found: ${id}`);
|
||||
return NextResponse.json({ error: 'Todo not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
// 执行逻辑删除
|
||||
todos[index].deleted = true;
|
||||
todos[index].deletedAt = Date.now();
|
||||
|
||||
saveTodos(todos);
|
||||
|
||||
console.log(`[API DELETE] Deleted todo: ${id}`);
|
||||
return NextResponse.json({ success: true, id });
|
||||
} catch (error) {
|
||||
console.error('[API DELETE] Error:', error);
|
||||
return NextResponse.json({ error: 'Failed to delete todo' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
85
src/app/globals.css
Normal file
85
src/app/globals.css
Normal file
@@ -0,0 +1,85 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
/* Light Mode (Default) - Pro Max Refined */
|
||||
:root {
|
||||
--background: #ffffff;
|
||||
--background-secondary: #f8fafc;
|
||||
--foreground: #0f172a;
|
||||
--foreground-muted: #475569;
|
||||
--border: #e2e8f0;
|
||||
--card-bg: rgba(255, 255, 255, 0.85);
|
||||
--shadow: rgba(0, 0, 0, 0.03) 0px 2px 4px 0px, rgba(0, 0, 0, 0.05) 0px 1px 2px 0px;
|
||||
--primary: #2563eb;
|
||||
--primary-hover: #1d4ed8;
|
||||
--accent: #f97316;
|
||||
}
|
||||
|
||||
/* Dark Mode - Pro Max OLED Refined */
|
||||
:root.dark {
|
||||
--background: #020617;
|
||||
--background-secondary: #0f172a;
|
||||
--foreground: #f1f5f9;
|
||||
--foreground-muted: #94a3b8;
|
||||
--border: #1e293b;
|
||||
--card-bg: rgba(15, 23, 42, 0.6);
|
||||
--shadow: rgba(0, 0, 0, 0.5) 0px 4px 6px -1px, rgba(0, 0, 0, 0.3) 0px 2px 4px -1px;
|
||||
--primary: #3b82f6;
|
||||
--primary-hover: #60a5fa;
|
||||
--accent: #fb923c;
|
||||
}
|
||||
|
||||
body {
|
||||
color: var(--foreground);
|
||||
background: var(--background);
|
||||
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
overflow-x: hidden;
|
||||
letter-spacing: -0.011em; /* Pro Max Typography Refinement */
|
||||
}
|
||||
|
||||
/* Glassmorphism Pro Max */
|
||||
.glass-card {
|
||||
@apply backdrop-blur-xl border border-white/20 dark:border-slate-700/50 shadow-lg;
|
||||
background: var(--card-bg);
|
||||
}
|
||||
|
||||
/* Professional Background Gradients */
|
||||
.bg-light-primary {
|
||||
background: radial-gradient(circle at 50% 0%, #f8fafc 0%, #ffffff 100%);
|
||||
}
|
||||
|
||||
.dark .bg-light-primary {
|
||||
background: radial-gradient(circle at 50% 0%, #0f172a 0%, #020617 100%);
|
||||
}
|
||||
|
||||
/* Stable Hover Feedback */
|
||||
.hover-lift {
|
||||
@apply transition-all duration-300 ease-out hover:-translate-y-1 hover:shadow-xl active:translate-y-0 active:scale-95;
|
||||
}
|
||||
|
||||
/* Custom scrollbar Pro Max */
|
||||
::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
@apply bg-slate-200 dark:bg-slate-800 rounded-full hover:bg-slate-300 dark:hover:bg-slate-700 transition-colors;
|
||||
}
|
||||
|
||||
/* Respect user's motion preferences */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
* {
|
||||
animation-duration: 0.01ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
}
|
||||
}
|
||||
|
||||
50
src/app/layout.tsx
Normal file
50
src/app/layout.tsx
Normal file
@@ -0,0 +1,50 @@
|
||||
import type { Metadata } from "next";
|
||||
import "./globals.css";
|
||||
import { SettingsProvider } from "@/contexts/SettingsContext";
|
||||
import { AuthProvider } from "@/contexts/AuthContext";
|
||||
import StatsTracker from "@/components/StatsTracker";
|
||||
import AuthModal from "@/components/AuthModal";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "蛋定-项目待办",
|
||||
description: "现代化、高效的待办事项管理工具",
|
||||
icons: {
|
||||
icon: [
|
||||
{ url: '/egg-icon.ico', sizes: 'any' },
|
||||
],
|
||||
apple: [
|
||||
{ url: '/egg-icon.ico', sizes: 'any' },
|
||||
],
|
||||
},
|
||||
manifest: '/manifest.json',
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
return (
|
||||
<html lang="zh-CN" suppressHydrationWarning>
|
||||
<head>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=5.0, user-scalable=yes" />
|
||||
<meta name="theme-color" content="#3b82f6" />
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossOrigin="anonymous" />
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800;900&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
</head>
|
||||
<body className="antialiased" suppressHydrationWarning>
|
||||
<SettingsProvider>
|
||||
<AuthProvider>
|
||||
<StatsTracker />
|
||||
<AuthModal />
|
||||
{children}
|
||||
</AuthProvider>
|
||||
</SettingsProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
1007
src/app/page.tsx
Normal file
1007
src/app/page.tsx
Normal file
File diff suppressed because it is too large
Load Diff
248
src/app/settings/page.tsx
Normal file
248
src/app/settings/page.tsx
Normal file
@@ -0,0 +1,248 @@
|
||||
'use client';
|
||||
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { ArrowLeft, Globe, Type, Clock, Sun, Moon, Monitor, Check, ShieldCheck, ShieldOff } from 'lucide-react';
|
||||
import { useSettings } from '@/contexts/SettingsContext';
|
||||
import { useAuth } from '@/contexts/AuthContext';
|
||||
import { translations, Language } from '@/lib/translations';
|
||||
import { TIMEZONES } from '@/lib/timezones';
|
||||
|
||||
export default function SettingsPage() {
|
||||
const router = useRouter();
|
||||
const { settings, updateSettings } = useSettings();
|
||||
const { isAuthenticated, requestAuth } = useAuth();
|
||||
const [tempLogoText, setTempLogoText] = useState(settings.logoText);
|
||||
const [showSaved, setShowSaved] = useState(false);
|
||||
const t = translations[settings.language];
|
||||
|
||||
// Update tempLogoText when settings.logoText changes
|
||||
useEffect(() => {
|
||||
setTempLogoText(settings.logoText);
|
||||
}, [settings.logoText]);
|
||||
|
||||
const handleSave = () => {
|
||||
// 检查权限
|
||||
if (!isAuthenticated) {
|
||||
requestAuth();
|
||||
return;
|
||||
}
|
||||
updateSettings({ logoText: tempLogoText });
|
||||
showSaveNotification();
|
||||
};
|
||||
|
||||
const showSaveNotification = () => {
|
||||
setShowSaved(true);
|
||||
setTimeout(() => setShowSaved(false), 2000);
|
||||
};
|
||||
|
||||
const handleLanguageChange = (lang: Language) => {
|
||||
updateSettings({ language: lang });
|
||||
showSaveNotification();
|
||||
};
|
||||
|
||||
const handleTimezoneChange = (timezone: string) => {
|
||||
// 检查权限
|
||||
if (!isAuthenticated) {
|
||||
requestAuth();
|
||||
return;
|
||||
}
|
||||
updateSettings({ timezone });
|
||||
showSaveNotification();
|
||||
};
|
||||
|
||||
const handleThemeChange = (theme: 'light' | 'dark' | 'system') => {
|
||||
updateSettings({ theme });
|
||||
showSaveNotification();
|
||||
};
|
||||
|
||||
return (
|
||||
<main className="min-h-screen bg-light-primary selection:bg-blue-500/30">
|
||||
<div className="max-w-2xl mx-auto px-4 sm:px-6 py-12 sm:py-20">
|
||||
{/* Save Notification Pro Max */}
|
||||
<AnimatePresence>
|
||||
{showSaved && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -20, scale: 0.9, filter: 'blur(10px)' }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1, filter: 'blur(0px)' }}
|
||||
exit={{ opacity: 0, scale: 0.9, filter: 'blur(10px)' }}
|
||||
className="fixed top-8 left-1/2 -translate-x-1/2 z-50 bg-slate-900 dark:bg-white text-white dark:text-slate-900 px-8 py-4 rounded-3xl shadow-2xl flex items-center gap-3 ring-1 ring-white/20"
|
||||
>
|
||||
<div className="bg-emerald-500 rounded-full p-1">
|
||||
<Check className="text-white" size={16} strokeWidth={4} />
|
||||
</div>
|
||||
<span className="font-bold uppercase tracking-widest text-xs">
|
||||
{t.settingsSaved}
|
||||
</span>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Header Pro Max */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: -20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
className="flex items-center gap-6 mb-12 sm:mb-16"
|
||||
>
|
||||
<button
|
||||
onClick={() => router.push('/')}
|
||||
className="p-4 bg-white dark:bg-slate-800 rounded-2xl shadow-lg border border-slate-200 dark:border-slate-700 hover:scale-110 active:scale-95 transition-all cursor-pointer group"
|
||||
>
|
||||
<ArrowLeft size={24} className="group-hover:-translate-x-1 transition-transform" />
|
||||
</button>
|
||||
<div className="flex-1">
|
||||
<h1 className="text-4xl sm:text-5xl font-black tracking-tighter text-slate-900 dark:text-white uppercase italic">
|
||||
{t.settings}
|
||||
</h1>
|
||||
<div className="h-1 w-12 bg-blue-500 rounded-full mt-2" />
|
||||
</div>
|
||||
{/* Auth Status */}
|
||||
<button
|
||||
onClick={requestAuth}
|
||||
className={`p-3 rounded-2xl border shadow-lg transition-all cursor-pointer ${
|
||||
isAuthenticated
|
||||
? 'bg-emerald-500/10 dark:bg-emerald-500/20 border-emerald-300 dark:border-emerald-700'
|
||||
: 'bg-amber-500/10 dark:bg-amber-500/20 border-amber-300 dark:border-amber-700'
|
||||
}`}
|
||||
title={isAuthenticated ? t.authenticated : t.authRequired}
|
||||
>
|
||||
{isAuthenticated ? (
|
||||
<ShieldCheck size={22} className="text-emerald-600 dark:text-emerald-400" />
|
||||
) : (
|
||||
<ShieldOff size={22} className="text-amber-600 dark:text-amber-400" />
|
||||
)}
|
||||
</button>
|
||||
</motion.div>
|
||||
|
||||
{/* Settings Sections Pro Max */}
|
||||
<div className="space-y-6 sm:space-y-8">
|
||||
{[
|
||||
{
|
||||
id: 'language',
|
||||
icon: Globe,
|
||||
title: t.language,
|
||||
desc: t.languageDesc,
|
||||
color: 'blue',
|
||||
content: (
|
||||
<div className="flex gap-3">
|
||||
{(['zh', 'en'] as Language[]).map((lang) => (
|
||||
<button
|
||||
key={lang}
|
||||
onClick={() => handleLanguageChange(lang)}
|
||||
className={`flex-1 py-4 rounded-2xl font-black uppercase tracking-widest text-xs transition-all cursor-pointer ring-1 ring-inset ${
|
||||
settings.language === lang
|
||||
? 'bg-slate-900 dark:bg-white text-white dark:text-slate-900 shadow-xl ring-transparent'
|
||||
: 'bg-white dark:bg-slate-800/50 text-slate-400 dark:text-slate-500 hover:text-slate-600 dark:hover:text-slate-300 ring-slate-200 dark:ring-slate-700'
|
||||
}`}
|
||||
>
|
||||
{lang === 'zh' ? '中文' : 'English'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
},
|
||||
{
|
||||
id: 'logo',
|
||||
icon: Type,
|
||||
title: t.logoCustomization,
|
||||
desc: t.logoCustomizationDesc,
|
||||
color: 'purple',
|
||||
content: (
|
||||
<div className="flex gap-3">
|
||||
<input
|
||||
type="text"
|
||||
value={tempLogoText}
|
||||
onChange={(e) => setTempLogoText(e.target.value.toUpperCase().slice(0, 10))}
|
||||
placeholder={t.logoTextPlaceholder}
|
||||
maxLength={10}
|
||||
className="flex-1 bg-slate-100 dark:bg-slate-900/50 border-none rounded-2xl px-6 py-4 text-sm font-bold text-slate-900 dark:text-white focus:ring-2 focus:ring-blue-500 transition-all uppercase tracking-widest"
|
||||
/>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={tempLogoText === settings.logoText || !tempLogoText.trim()}
|
||||
className="px-8 bg-blue-600 hover:bg-blue-700 disabled:opacity-20 text-white rounded-2xl font-black uppercase tracking-widest text-xs transition-all cursor-pointer shadow-lg hover:shadow-xl active:scale-95"
|
||||
>
|
||||
{t.save}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
{
|
||||
id: 'timezone',
|
||||
icon: Clock,
|
||||
title: t.timezone,
|
||||
desc: t.timezoneDesc,
|
||||
color: 'emerald',
|
||||
content: (
|
||||
<select
|
||||
value={settings.timezone}
|
||||
onChange={(e) => handleTimezoneChange(e.target.value)}
|
||||
className="w-full bg-slate-100 dark:bg-slate-900/50 border-none rounded-2xl px-6 py-4 text-sm font-bold text-slate-900 dark:text-white focus:ring-2 focus:ring-blue-500 transition-all cursor-pointer appearance-none"
|
||||
>
|
||||
{TIMEZONES.map((tz) => (
|
||||
<option key={tz.value} value={tz.value}>
|
||||
{tz.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)
|
||||
},
|
||||
{
|
||||
id: 'theme',
|
||||
icon: Sun,
|
||||
title: t.themeMode,
|
||||
desc: t.themeModeDesc,
|
||||
color: 'orange',
|
||||
content: (
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
{[
|
||||
{ value: 'light' as const, icon: Sun, label: t.themeLight },
|
||||
{ value: 'dark' as const, icon: Moon, label: t.themeDark },
|
||||
{ value: 'system' as const, icon: Monitor, label: t.themeSystem },
|
||||
].map(({ value, icon: Icon, label }) => (
|
||||
<button
|
||||
key={value}
|
||||
onClick={() => handleThemeChange(value)}
|
||||
className={`flex flex-col items-center gap-3 p-5 rounded-2xl transition-all cursor-pointer ring-1 ring-inset ${
|
||||
settings.theme === value
|
||||
? 'bg-slate-900 dark:bg-white text-white dark:text-slate-900 shadow-xl ring-transparent'
|
||||
: 'bg-white dark:bg-slate-800/50 text-slate-400 dark:text-slate-500 ring-slate-200 dark:ring-slate-700'
|
||||
}`}
|
||||
>
|
||||
<Icon size={20} strokeWidth={2.5} />
|
||||
<span className="text-[10px] font-black uppercase tracking-widest">{label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
].map((section, idx) => (
|
||||
<motion.div
|
||||
key={section.id}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: idx * 0.1 }}
|
||||
className="glass-card p-6 sm:p-8 rounded-[2.5rem] ring-1 ring-black/5 dark:ring-white/5"
|
||||
>
|
||||
<div className="flex items-center gap-4 mb-6">
|
||||
<div className={`p-3 bg-${section.color}-500/10 rounded-2xl`}>
|
||||
<section.icon className={`text-${section.color}-500`} size={24} strokeWidth={2.5} />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-sm font-black uppercase tracking-widest text-slate-900 dark:text-white">
|
||||
{section.title}
|
||||
</h3>
|
||||
<p className="text-[10px] font-bold uppercase tracking-tight text-slate-400">
|
||||
{section.desc}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{section.content}
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
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
|
||||
}
|
||||
124
src/contexts/AuthContext.tsx
Normal file
124
src/contexts/AuthContext.tsx
Normal file
@@ -0,0 +1,124 @@
|
||||
'use client';
|
||||
|
||||
import React, { createContext, useContext, useState, useEffect, useCallback } from 'react';
|
||||
|
||||
interface AuthContextType {
|
||||
isAuthenticated: boolean;
|
||||
showAuthModal: boolean;
|
||||
authenticate: (password: string) => Promise<boolean>;
|
||||
logout: () => void;
|
||||
requestAuth: () => void;
|
||||
closeAuthModal: () => void;
|
||||
getAuthHeaders: () => Record<string, string>;
|
||||
}
|
||||
|
||||
const AUTH_STORAGE_KEY = 'stark-todo-auth';
|
||||
const AUTH_PASSWORD_KEY = 'stark-todo-pwd';
|
||||
|
||||
// 默认值,用于 SSR
|
||||
const defaultContextValue: AuthContextType = {
|
||||
isAuthenticated: false,
|
||||
showAuthModal: false,
|
||||
authenticate: async () => false,
|
||||
logout: () => {},
|
||||
requestAuth: () => {},
|
||||
closeAuthModal: () => {},
|
||||
getAuthHeaders: () => ({}),
|
||||
};
|
||||
|
||||
const AuthContext = createContext<AuthContextType>(defaultContextValue);
|
||||
|
||||
export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
const [isAuthenticated, setIsAuthenticated] = useState(false);
|
||||
const [showAuthModal, setShowAuthModal] = useState(false);
|
||||
const [isClient, setIsClient] = useState(false);
|
||||
const [storedPassword, setStoredPassword] = useState<string | null>(null);
|
||||
|
||||
// 客户端初始化
|
||||
useEffect(() => {
|
||||
setIsClient(true);
|
||||
const savedAuth = localStorage.getItem(AUTH_STORAGE_KEY);
|
||||
const savedPwd = localStorage.getItem(AUTH_PASSWORD_KEY);
|
||||
if (savedAuth === 'true' && savedPwd) {
|
||||
setIsAuthenticated(true);
|
||||
setStoredPassword(savedPwd);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 验证密码
|
||||
const authenticate = useCallback(async (password: string): Promise<boolean> => {
|
||||
try {
|
||||
const response = await fetch('/api/auth', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ password }),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
setIsAuthenticated(true);
|
||||
setStoredPassword(password);
|
||||
localStorage.setItem(AUTH_STORAGE_KEY, 'true');
|
||||
localStorage.setItem(AUTH_PASSWORD_KEY, password);
|
||||
setShowAuthModal(false);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
} catch (error) {
|
||||
console.error('[Auth] Verification failed:', error);
|
||||
return false;
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 登出
|
||||
const logout = useCallback(() => {
|
||||
setIsAuthenticated(false);
|
||||
setStoredPassword(null);
|
||||
localStorage.removeItem(AUTH_STORAGE_KEY);
|
||||
localStorage.removeItem(AUTH_PASSWORD_KEY);
|
||||
}, []);
|
||||
|
||||
// 请求验证(显示弹窗)
|
||||
const requestAuth = useCallback(() => {
|
||||
if (!isAuthenticated) {
|
||||
setShowAuthModal(true);
|
||||
}
|
||||
}, [isAuthenticated]);
|
||||
|
||||
// 关闭弹窗
|
||||
const closeAuthModal = useCallback(() => {
|
||||
setShowAuthModal(false);
|
||||
}, []);
|
||||
|
||||
// 获取认证请求头
|
||||
const getAuthHeaders = useCallback((): Record<string, string> => {
|
||||
if (storedPassword) {
|
||||
return { 'X-API-Key': storedPassword };
|
||||
}
|
||||
return {};
|
||||
}, [storedPassword]);
|
||||
|
||||
// 始终提供 Context,确保子组件可以访问
|
||||
const contextValue: AuthContextType = isClient
|
||||
? {
|
||||
isAuthenticated,
|
||||
showAuthModal,
|
||||
authenticate,
|
||||
logout,
|
||||
requestAuth,
|
||||
closeAuthModal,
|
||||
getAuthHeaders,
|
||||
}
|
||||
: defaultContextValue;
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={contextValue}>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useAuth() {
|
||||
return useContext(AuthContext);
|
||||
}
|
||||
154
src/contexts/SettingsContext.tsx
Normal file
154
src/contexts/SettingsContext.tsx
Normal file
@@ -0,0 +1,154 @@
|
||||
'use client';
|
||||
|
||||
import React, { createContext, useContext, useState, useEffect, ReactNode } from 'react';
|
||||
|
||||
export interface Settings {
|
||||
language: 'zh' | 'en';
|
||||
logoText: string;
|
||||
timezone: string;
|
||||
theme: 'light' | 'dark' | 'system';
|
||||
}
|
||||
|
||||
interface SettingsContextType {
|
||||
settings: Settings;
|
||||
updateSettings: (newSettings: Partial<Settings>) => void;
|
||||
}
|
||||
|
||||
const defaultSettings: Settings = {
|
||||
language: 'zh',
|
||||
logoText: '蛋定-项目待办',
|
||||
timezone: 'Asia/Shanghai',
|
||||
theme: 'system',
|
||||
};
|
||||
|
||||
const SettingsContext = createContext<SettingsContextType | undefined>(undefined);
|
||||
|
||||
export function SettingsProvider({ children }: { children: ReactNode }) {
|
||||
const [settings, setSettings] = useState<Settings>(defaultSettings);
|
||||
const [isClient, setIsClient] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setIsClient(true);
|
||||
|
||||
// Load settings from localStorage
|
||||
const savedSettings = localStorage.getItem('stark-settings');
|
||||
if (savedSettings) {
|
||||
try {
|
||||
const parsed = JSON.parse(savedSettings);
|
||||
// Merge with default settings to ensure all fields exist
|
||||
const mergedSettings = { ...defaultSettings, ...parsed };
|
||||
setSettings(mergedSettings);
|
||||
console.log('[Settings] Loaded from localStorage:', mergedSettings);
|
||||
// Apply theme immediately after loading
|
||||
requestAnimationFrame(() => {
|
||||
applyTheme(mergedSettings.theme);
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[Settings] Failed to parse settings:', error);
|
||||
// Use default settings on error
|
||||
setSettings(defaultSettings);
|
||||
applyTheme(defaultSettings.theme);
|
||||
}
|
||||
} else {
|
||||
console.log('[Settings] No saved settings, using defaults:', defaultSettings);
|
||||
// Save default settings to localStorage
|
||||
localStorage.setItem('stark-settings', JSON.stringify(defaultSettings));
|
||||
applyTheme(defaultSettings.theme);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isClient) return;
|
||||
|
||||
// Save settings to localStorage whenever they change
|
||||
try {
|
||||
localStorage.setItem('stark-settings', JSON.stringify(settings));
|
||||
console.log('[Settings] Saved to localStorage:', settings);
|
||||
} catch (error) {
|
||||
console.error('[Settings] Failed to save settings:', error);
|
||||
}
|
||||
|
||||
// Apply theme
|
||||
applyTheme(settings.theme);
|
||||
}, [settings, isClient]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isClient) return;
|
||||
|
||||
// Listen to system theme changes
|
||||
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
|
||||
|
||||
const handleChange = () => {
|
||||
if (settings.theme === 'system') {
|
||||
applyTheme('system');
|
||||
}
|
||||
};
|
||||
|
||||
mediaQuery.addEventListener('change', handleChange);
|
||||
|
||||
return () => mediaQuery.removeEventListener('change', handleChange);
|
||||
}, [settings.theme, isClient]);
|
||||
|
||||
const applyTheme = (theme: 'light' | 'dark' | 'system') => {
|
||||
if (typeof window === 'undefined') return;
|
||||
|
||||
const root = document.documentElement;
|
||||
|
||||
// 强制移除过渡效果,确保立即切换
|
||||
root.style.setProperty('transition', 'none');
|
||||
|
||||
if (theme === 'system') {
|
||||
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
if (prefersDark) {
|
||||
root.classList.add('dark');
|
||||
} else {
|
||||
root.classList.remove('dark');
|
||||
}
|
||||
} else if (theme === 'dark') {
|
||||
root.classList.add('dark');
|
||||
} else {
|
||||
root.classList.remove('dark');
|
||||
}
|
||||
|
||||
// 强制重绘,确保立即生效
|
||||
void root.offsetHeight;
|
||||
|
||||
// 恢复过渡效果
|
||||
requestAnimationFrame(() => {
|
||||
root.style.removeProperty('transition');
|
||||
});
|
||||
|
||||
console.log(`[Theme] Applied theme: ${theme}, dark class: ${root.classList.contains('dark')}`);
|
||||
};
|
||||
|
||||
const updateSettings = (newSettings: Partial<Settings>) => {
|
||||
setSettings(prev => {
|
||||
const updated = { ...prev, ...newSettings };
|
||||
|
||||
console.log('[Settings] Updating settings:', { old: prev, new: newSettings, updated });
|
||||
|
||||
// 如果更新了主题,立即应用
|
||||
if (newSettings.theme !== undefined) {
|
||||
requestAnimationFrame(() => {
|
||||
applyTheme(newSettings.theme!);
|
||||
});
|
||||
}
|
||||
|
||||
return updated;
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<SettingsContext.Provider value={{ settings, updateSettings }}>
|
||||
{children}
|
||||
</SettingsContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useSettings() {
|
||||
const context = useContext(SettingsContext);
|
||||
if (!context) {
|
||||
throw new Error('useSettings must be used within SettingsProvider');
|
||||
}
|
||||
return context;
|
||||
}
|
||||
44
src/lib/stats.ts
Normal file
44
src/lib/stats.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
const DATA_DIR = process.env.DATA_DIR || process.cwd();
|
||||
const STATS_FILE = path.join(DATA_DIR, 'stats.json');
|
||||
|
||||
export interface Stats {
|
||||
pv: number;
|
||||
uv: number;
|
||||
visitors: string[]; // Store hashed IPs or session IDs
|
||||
}
|
||||
|
||||
export const getStats = (): Stats => {
|
||||
try {
|
||||
if (!fs.existsSync(STATS_FILE)) {
|
||||
const initialStats: Stats = { pv: 0, uv: 0, visitors: [] };
|
||||
fs.writeFileSync(STATS_FILE, JSON.stringify(initialStats, null, 2));
|
||||
return initialStats;
|
||||
}
|
||||
const data = fs.readFileSync(STATS_FILE, 'utf-8');
|
||||
return JSON.parse(data);
|
||||
} catch (error) {
|
||||
console.error('[Stats] Error reading stats:', error);
|
||||
return { pv: 0, uv: 0, visitors: [] };
|
||||
}
|
||||
};
|
||||
|
||||
export const recordHit = (visitorId: string): Stats => {
|
||||
try {
|
||||
const stats = getStats();
|
||||
stats.pv += 1;
|
||||
|
||||
if (!stats.visitors.includes(visitorId)) {
|
||||
stats.visitors.push(visitorId);
|
||||
stats.uv = stats.visitors.length;
|
||||
}
|
||||
|
||||
fs.writeFileSync(STATS_FILE, JSON.stringify(stats, null, 2));
|
||||
return stats;
|
||||
} catch (error) {
|
||||
console.error('[Stats] Error recording hit:', error);
|
||||
return getStats();
|
||||
}
|
||||
};
|
||||
332
src/lib/storage.ts
Normal file
332
src/lib/storage.ts
Normal file
@@ -0,0 +1,332 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { Todo, Group, Stats, DEFAULT_GROUP_ID } from './types';
|
||||
|
||||
// 支持 Docker 数据目录 and 本地开发
|
||||
const DATA_DIR = process.env.DATA_DIR || process.cwd();
|
||||
const DATA_FILE = path.join(DATA_DIR, 'todos.json');
|
||||
const STATS_FILE = path.join(DATA_DIR, 'stats.json');
|
||||
const GROUPS_FILE = path.join(DATA_DIR, 'groups.json');
|
||||
const RATE_LIMITS_FILE = path.join(DATA_DIR, 'rate-limits.json');
|
||||
|
||||
// 限流数据结构
|
||||
export interface RateLimitData {
|
||||
lastAddTime: number; // 上次添加任务的时间戳
|
||||
dailyCount: number; // 当天已添加的任务数
|
||||
lastResetDate: string; // 上次重置日期 (YYYY-MM-DD)
|
||||
history: number[]; // 添加任务的历史时间戳
|
||||
}
|
||||
|
||||
export interface RateLimits {
|
||||
[clientId: string]: RateLimitData;
|
||||
}
|
||||
|
||||
// 限流配置
|
||||
export const RATE_LIMIT_CONFIG = {
|
||||
INTERVAL_MS: 10 * 60 * 1000, // 10分钟
|
||||
DAILY_MAX: 6, // 每天6个任务
|
||||
};
|
||||
|
||||
export { DEFAULT_GROUP_ID };
|
||||
export type { Todo, Group, Stats };
|
||||
|
||||
export const getTodos = (): Todo[] => {
|
||||
try {
|
||||
// 确保目录存在
|
||||
const dir = path.dirname(DATA_FILE);
|
||||
if (!fs.existsSync(dir)) {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
|
||||
// 如果文件不存在,创建空数组
|
||||
if (!fs.existsSync(DATA_FILE)) {
|
||||
fs.writeFileSync(DATA_FILE, '[]', 'utf-8');
|
||||
console.log(`[Storage] Created new todos file: ${DATA_FILE}`);
|
||||
return [];
|
||||
}
|
||||
|
||||
const data = fs.readFileSync(DATA_FILE, 'utf-8');
|
||||
let todos: Todo[] = JSON.parse(data);
|
||||
|
||||
// 迁移逻辑:确保所有任务都有 groupId,如果没有则归入默认分组
|
||||
let needsSave = false;
|
||||
todos = todos.map(todo => {
|
||||
if (!todo.groupId) {
|
||||
todo.groupId = DEFAULT_GROUP_ID;
|
||||
needsSave = true;
|
||||
}
|
||||
return todo;
|
||||
});
|
||||
|
||||
if (needsSave) {
|
||||
saveTodos(todos);
|
||||
}
|
||||
|
||||
console.log(`[Storage] Loaded ${todos.length} todos from ${DATA_FILE}`);
|
||||
return todos;
|
||||
} catch (error) {
|
||||
console.error('[Storage] Error reading todos:', error);
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
export const saveTodos = (todos: Todo[]) => {
|
||||
try {
|
||||
// 确保目录存在
|
||||
const dir = path.dirname(DATA_FILE);
|
||||
if (!fs.existsSync(dir)) {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
|
||||
fs.writeFileSync(DATA_FILE, JSON.stringify(todos, null, 2), 'utf-8');
|
||||
console.log(`[Storage] Saved ${todos.length} todos to ${DATA_FILE}`);
|
||||
} catch (error) {
|
||||
console.error('[Storage] Error saving todos:', error);
|
||||
throw error; // 抛出错误以便 API 能捕获
|
||||
}
|
||||
};
|
||||
|
||||
export const getGroups = (): Group[] => {
|
||||
try {
|
||||
const dir = path.dirname(GROUPS_FILE);
|
||||
if (!fs.existsSync(dir)) {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
|
||||
if (!fs.existsSync(GROUPS_FILE)) {
|
||||
const defaultGroups: Group[] = [{ id: DEFAULT_GROUP_ID, name: 'Default', createdAt: Date.now() }];
|
||||
fs.writeFileSync(GROUPS_FILE, JSON.stringify(defaultGroups, null, 2), 'utf-8');
|
||||
return defaultGroups;
|
||||
}
|
||||
|
||||
const data = fs.readFileSync(GROUPS_FILE, 'utf-8');
|
||||
return JSON.parse(data);
|
||||
} catch (error) {
|
||||
console.error('[Storage] Error reading groups:', error);
|
||||
return [{ id: DEFAULT_GROUP_ID, name: 'Default', createdAt: Date.now() }];
|
||||
}
|
||||
};
|
||||
|
||||
export const saveGroups = (groups: Group[]) => {
|
||||
try {
|
||||
const dir = path.dirname(GROUPS_FILE);
|
||||
if (!fs.existsSync(dir)) {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
fs.writeFileSync(GROUPS_FILE, JSON.stringify(groups, null, 2), 'utf-8');
|
||||
} catch (error) {
|
||||
console.error('[Storage] Error saving groups:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const getStats = (): Stats => {
|
||||
try {
|
||||
// 确保数据目录存在
|
||||
const dir = path.dirname(STATS_FILE);
|
||||
if (!fs.existsSync(dir)) {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
console.log(`[Storage] Created data directory: ${dir}`);
|
||||
}
|
||||
|
||||
if (!fs.existsSync(STATS_FILE)) {
|
||||
const initialStats = { pv: 0, uv: 0 };
|
||||
fs.writeFileSync(STATS_FILE, JSON.stringify(initialStats, null, 2), 'utf-8');
|
||||
console.log(`[Storage] Created new stats file: ${STATS_FILE}`);
|
||||
return initialStats;
|
||||
}
|
||||
|
||||
const data = fs.readFileSync(STATS_FILE, 'utf-8');
|
||||
const stats = JSON.parse(data);
|
||||
console.log(`[Storage] Loaded stats from ${STATS_FILE}: PV=${stats.pv}, UV=${stats.uv}`);
|
||||
return stats;
|
||||
} catch (error) {
|
||||
console.error('[Storage] Error reading stats:', error);
|
||||
return { pv: 0, uv: 0 };
|
||||
}
|
||||
};
|
||||
|
||||
export const updateStats = (isNewVisitor: boolean): Stats => {
|
||||
try {
|
||||
// 确保数据目录存在
|
||||
const dir = path.dirname(STATS_FILE);
|
||||
if (!fs.existsSync(dir)) {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
|
||||
const stats = getStats();
|
||||
stats.pv += 1;
|
||||
if (isNewVisitor) {
|
||||
stats.uv += 1;
|
||||
}
|
||||
fs.writeFileSync(STATS_FILE, JSON.stringify(stats, null, 2), 'utf-8');
|
||||
console.log(`[Storage] Updated stats: PV=${stats.pv}, UV=${stats.uv}`);
|
||||
return stats;
|
||||
} catch (error) {
|
||||
console.error('[Storage] Error updating stats:', error);
|
||||
return { pv: 0, uv: 0 };
|
||||
}
|
||||
};
|
||||
|
||||
// ============ 限流管理函数 ============
|
||||
|
||||
/**
|
||||
* 获取所有限流数据
|
||||
*/
|
||||
export const getRateLimits = (): RateLimits => {
|
||||
try {
|
||||
const dir = path.dirname(RATE_LIMITS_FILE);
|
||||
if (!fs.existsSync(dir)) {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
|
||||
if (!fs.existsSync(RATE_LIMITS_FILE)) {
|
||||
const initialData: RateLimits = {};
|
||||
fs.writeFileSync(RATE_LIMITS_FILE, JSON.stringify(initialData, null, 2), 'utf-8');
|
||||
console.log(`[Storage] Created new rate limits file: ${RATE_LIMITS_FILE}`);
|
||||
return initialData;
|
||||
}
|
||||
|
||||
const data = fs.readFileSync(RATE_LIMITS_FILE, 'utf-8');
|
||||
const rateLimits = JSON.parse(data);
|
||||
console.log(`[Storage] Loaded rate limits for ${Object.keys(rateLimits).length} clients`);
|
||||
return rateLimits;
|
||||
} catch (error) {
|
||||
console.error('[Storage] Error reading rate limits:', error);
|
||||
return {};
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 保存限流数据
|
||||
*/
|
||||
export const saveRateLimits = (rateLimits: RateLimits): void => {
|
||||
try {
|
||||
const dir = path.dirname(RATE_LIMITS_FILE);
|
||||
if (!fs.existsSync(dir)) {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
|
||||
fs.writeFileSync(RATE_LIMITS_FILE, JSON.stringify(rateLimits, null, 2), 'utf-8');
|
||||
console.log(`[Storage] Saved rate limits for ${Object.keys(rateLimits).length} clients`);
|
||||
} catch (error) {
|
||||
console.error('[Storage] Error saving rate limits:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取当前日期字符串 (YYYY-MM-DD)
|
||||
*/
|
||||
const getCurrentDate = (): string => {
|
||||
const now = new Date();
|
||||
const year = now.getFullYear();
|
||||
const month = String(now.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(now.getDate()).padStart(2, '0');
|
||||
return `${year}-${month}-${day}`;
|
||||
};
|
||||
|
||||
/**
|
||||
* 清理过期的限流历史记录(保留最近24小时)
|
||||
*/
|
||||
const cleanupOldHistory = (history: number[], currentTime: number): number[] => {
|
||||
const oneDayAgo = currentTime - (24 * 60 * 60 * 1000);
|
||||
return history.filter(timestamp => timestamp > oneDayAgo);
|
||||
};
|
||||
|
||||
/**
|
||||
* 检查客户端是否可以添加任务
|
||||
* @param clientId 客户端标识(IP地址或唯一ID)
|
||||
* @returns { canAdd: boolean, reason?: string, retryAfter?: number }
|
||||
*/
|
||||
export const checkRateLimit = (clientId: string): {
|
||||
canAdd: boolean;
|
||||
reason?: string;
|
||||
retryAfter?: number;
|
||||
dailyRemaining?: number;
|
||||
} => {
|
||||
const rateLimits = getRateLimits();
|
||||
const currentTime = Date.now();
|
||||
const currentDate = getCurrentDate();
|
||||
|
||||
// 获取或初始化客户端数据
|
||||
let clientData = rateLimits[clientId];
|
||||
if (!clientData) {
|
||||
clientData = {
|
||||
lastAddTime: 0,
|
||||
dailyCount: 0,
|
||||
lastResetDate: currentDate,
|
||||
history: [],
|
||||
};
|
||||
rateLimits[clientId] = clientData;
|
||||
}
|
||||
|
||||
// 检查日期是否变更,如果是则重置计数
|
||||
if (clientData.lastResetDate !== currentDate) {
|
||||
clientData.dailyCount = 0;
|
||||
clientData.lastResetDate = currentDate;
|
||||
clientData.history = [];
|
||||
}
|
||||
|
||||
// 清理过期的历史记录
|
||||
clientData.history = cleanupOldHistory(clientData.history, currentTime);
|
||||
|
||||
// 检查1:时间间隔限制(10分钟)
|
||||
const timeSinceLastAdd = currentTime - clientData.lastAddTime;
|
||||
if (timeSinceLastAdd < RATE_LIMIT_CONFIG.INTERVAL_MS) {
|
||||
const retryAfter = Math.ceil((RATE_LIMIT_CONFIG.INTERVAL_MS - timeSinceLastAdd) / 1000); // 秒
|
||||
return {
|
||||
canAdd: false,
|
||||
reason: 'RATE_LIMIT_INTERVAL',
|
||||
retryAfter,
|
||||
dailyRemaining: RATE_LIMIT_CONFIG.DAILY_MAX - clientData.dailyCount,
|
||||
};
|
||||
}
|
||||
|
||||
// 检查2:每日限制(6个任务)
|
||||
if (clientData.dailyCount >= RATE_LIMIT_CONFIG.DAILY_MAX) {
|
||||
return {
|
||||
canAdd: false,
|
||||
reason: 'RATE_LIMIT_DAILY',
|
||||
dailyRemaining: 0,
|
||||
};
|
||||
}
|
||||
|
||||
// 可以添加
|
||||
return {
|
||||
canAdd: true,
|
||||
dailyRemaining: RATE_LIMIT_CONFIG.DAILY_MAX - clientData.dailyCount - 1,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* 记录客户端添加任务
|
||||
* @param clientId 客户端标识
|
||||
*/
|
||||
export const recordTaskAdd = (clientId: string): void => {
|
||||
const rateLimits = getRateLimits();
|
||||
const currentTime = Date.now();
|
||||
const currentDate = getCurrentDate();
|
||||
|
||||
let clientData = rateLimits[clientId];
|
||||
if (!clientData) {
|
||||
clientData = {
|
||||
lastAddTime: 0,
|
||||
dailyCount: 0,
|
||||
lastResetDate: currentDate,
|
||||
history: [],
|
||||
};
|
||||
}
|
||||
|
||||
clientData.lastAddTime = currentTime;
|
||||
clientData.dailyCount += 1;
|
||||
clientData.history.push(currentTime);
|
||||
clientData.history = cleanupOldHistory(clientData.history, currentTime);
|
||||
|
||||
rateLimits[clientId] = clientData;
|
||||
saveRateLimits(rateLimits);
|
||||
|
||||
console.log(`[Storage] Recorded task add for client ${clientId}: ` +
|
||||
`dailyCount=${clientData.dailyCount}, ` +
|
||||
`dailyRemaining=${RATE_LIMIT_CONFIG.DAILY_MAX - clientData.dailyCount}`);
|
||||
};
|
||||
16
src/lib/timezones.ts
Normal file
16
src/lib/timezones.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
export const TIMEZONES = [
|
||||
{ value: 'UTC', label: 'UTC (UTC+0)' },
|
||||
{ value: 'America/New_York', label: 'New York (UTC-5)' },
|
||||
{ value: 'America/Los_Angeles', label: 'Los Angeles (UTC-8)' },
|
||||
{ value: 'America/Chicago', label: 'Chicago (UTC-6)' },
|
||||
{ value: 'Europe/London', label: 'London (UTC+0)' },
|
||||
{ value: 'Europe/Paris', label: 'Paris (UTC+1)' },
|
||||
{ value: 'Europe/Berlin', label: 'Berlin (UTC+1)' },
|
||||
{ value: 'Asia/Dubai', label: 'Dubai (UTC+4)' },
|
||||
{ value: 'Asia/Shanghai', label: 'Shanghai (UTC+8)' },
|
||||
{ value: 'Asia/Tokyo', label: 'Tokyo (UTC+9)' },
|
||||
{ value: 'Asia/Hong_Kong', label: 'Hong Kong (UTC+8)' },
|
||||
{ value: 'Asia/Singapore', label: 'Singapore (UTC+8)' },
|
||||
{ value: 'Australia/Sydney', label: 'Sydney (UTC+11)' },
|
||||
{ value: 'Pacific/Auckland', label: 'Auckland (UTC+13)' },
|
||||
];
|
||||
187
src/lib/translations.ts
Normal file
187
src/lib/translations.ts
Normal file
@@ -0,0 +1,187 @@
|
||||
export const translations = {
|
||||
zh: {
|
||||
// Common
|
||||
save: '保存',
|
||||
cancel: '取消',
|
||||
close: '关闭',
|
||||
|
||||
// Home
|
||||
addTask: '添加新任务',
|
||||
addTaskPlaceholder: '添加新任务...',
|
||||
all: '全部',
|
||||
allTasks: '全部任务',
|
||||
active: '进行中',
|
||||
completed: '已完成',
|
||||
noTasks: '暂无任务,开始记录你的灵感吧',
|
||||
created: '创建',
|
||||
completedAt: '完成',
|
||||
subtitle1: '极简任务管理',
|
||||
subtitle2: '高效生活方式',
|
||||
|
||||
// Settings
|
||||
settings: '设置',
|
||||
language: '语言',
|
||||
languageDesc: '选择界面语言',
|
||||
logoCustomization: 'Logo 自定义',
|
||||
logoCustomizationDesc: '自定义主页 Logo 文字',
|
||||
logoTextLabel: 'Logo 文字',
|
||||
logoTextPlaceholder: '输入自定义文字(如 STARK)',
|
||||
timezone: '时区',
|
||||
timezoneDesc: '选择显示时区',
|
||||
themeMode: '主题模式',
|
||||
themeModeDesc: '选择外观主题',
|
||||
themeLight: '浅色模式',
|
||||
themeDark: '深色模式',
|
||||
themeSystem: '跟随系统',
|
||||
|
||||
// Analytics
|
||||
insights: '数据分析',
|
||||
analyticsLoading: '正在加载分析数据...',
|
||||
aggregatingRecords: '正在汇总数据...',
|
||||
past7Days: '过去 7 天',
|
||||
pastMonth: '过去一个月',
|
||||
allTime: '所有时间',
|
||||
totalCreated: '总创建数',
|
||||
successRate: '完成率',
|
||||
dailyActivity: '每日动态',
|
||||
creationVsCompletion: '创建与完成对比',
|
||||
recentTaskTimeline: '近期任务时间轴',
|
||||
completionDuration: '任务完成耗时分析',
|
||||
took: '耗时约',
|
||||
hours: '小时',
|
||||
liveDataEngine: '实时数据引擎',
|
||||
trend: '趋势',
|
||||
createdLabel: '已创建',
|
||||
completedLabel: '已完成',
|
||||
|
||||
// Notifications & Status
|
||||
settingsSaved: '设置已保存',
|
||||
syncingTasks: '正在同步任务...',
|
||||
noActiveTasks: '暂无进行中的任务',
|
||||
noCompletedTasks: '暂无已完成的任务',
|
||||
pv: '访问量 (PV)',
|
||||
uv: '访客数 (UV)',
|
||||
|
||||
// Auth
|
||||
authRequired: '需要验证权限',
|
||||
authDescription: '请输入密码以修改任务',
|
||||
enterPassword: '请输入密码',
|
||||
passwordRequired: '请输入密码',
|
||||
invalidPassword: '密码错误',
|
||||
verify: '验证',
|
||||
verifying: '验证中...',
|
||||
authHint: '如忘记密码,请联系管理员',
|
||||
authSuccess: '验证成功',
|
||||
logout: '退出登录',
|
||||
authenticated: '已验证',
|
||||
notAuthenticated: '未验证',
|
||||
|
||||
// Groups & Priority
|
||||
groups: '分组',
|
||||
addGroup: '添加分组',
|
||||
deleteGroup: '删除分组',
|
||||
defaultGroup: '默认分组',
|
||||
selectGroup: '选择分组',
|
||||
priority: '优先级',
|
||||
p0: '高优先级 (P0)',
|
||||
p1: '中优先级 (P1)',
|
||||
p2: '低优先级 (P2)',
|
||||
manageGroups: '管理分组',
|
||||
newGroupName: '新分组名称',
|
||||
noPriority: '无优先级',
|
||||
},
|
||||
en: {
|
||||
// Common
|
||||
save: 'Save',
|
||||
cancel: 'Cancel',
|
||||
close: 'Close',
|
||||
|
||||
// Home
|
||||
addTask: 'Add Task',
|
||||
addTaskPlaceholder: 'Add a new task...',
|
||||
all: 'All',
|
||||
allTasks: 'All Tasks',
|
||||
active: 'Active',
|
||||
completed: 'Completed',
|
||||
noTasks: 'No tasks yet. Start tracking your ideas!',
|
||||
created: 'Created',
|
||||
completedAt: 'Completed',
|
||||
subtitle1: 'Minimal Task Management',
|
||||
subtitle2: 'Efficient Lifestyle',
|
||||
|
||||
// Settings
|
||||
settings: 'Settings',
|
||||
language: 'Language',
|
||||
languageDesc: 'Choose interface language',
|
||||
logoCustomization: 'Logo Customization',
|
||||
logoCustomizationDesc: 'Customize homepage logo text',
|
||||
logoTextLabel: 'Logo Text',
|
||||
logoTextPlaceholder: 'Enter custom text (e.g., STARK)',
|
||||
timezone: 'Timezone',
|
||||
timezoneDesc: 'Select display timezone',
|
||||
themeMode: 'Theme Mode',
|
||||
themeModeDesc: 'Choose appearance theme',
|
||||
themeLight: 'Light Mode',
|
||||
themeDark: 'Dark Mode',
|
||||
themeSystem: 'Follow System',
|
||||
|
||||
// Analytics
|
||||
insights: 'Insights',
|
||||
analyticsLoading: 'Loading analytics data...',
|
||||
aggregatingRecords: 'Aggregating records...',
|
||||
past7Days: 'Past 7 Days',
|
||||
pastMonth: 'Past Month',
|
||||
allTime: 'All Time',
|
||||
totalCreated: 'Total Created',
|
||||
successRate: 'Success Rate',
|
||||
dailyActivity: 'Daily Activity',
|
||||
creationVsCompletion: 'Creation vs Completion',
|
||||
recentTaskTimeline: 'Recent Task Timeline',
|
||||
completionDuration: 'Completion Time Analysis',
|
||||
took: 'Took ~',
|
||||
hours: 'h',
|
||||
liveDataEngine: 'Live Data Engine',
|
||||
trend: 'Trend',
|
||||
createdLabel: 'Created',
|
||||
completedLabel: 'Completed',
|
||||
|
||||
// Notifications & Status
|
||||
settingsSaved: 'Settings Saved',
|
||||
syncingTasks: 'Syncing tasks...',
|
||||
noActiveTasks: 'No active tasks',
|
||||
noCompletedTasks: 'No completed tasks',
|
||||
pv: 'Page Views (PV)',
|
||||
uv: 'Unique Visitors (UV)',
|
||||
|
||||
// Auth
|
||||
authRequired: 'Authentication Required',
|
||||
authDescription: 'Enter password to modify tasks',
|
||||
enterPassword: 'Enter password',
|
||||
passwordRequired: 'Password is required',
|
||||
invalidPassword: 'Invalid password',
|
||||
verify: 'Verify',
|
||||
verifying: 'Verifying...',
|
||||
authHint: 'Contact administrator if you forgot the password',
|
||||
authSuccess: 'Verified successfully',
|
||||
logout: 'Logout',
|
||||
authenticated: 'Authenticated',
|
||||
notAuthenticated: 'Not authenticated',
|
||||
|
||||
// Groups & Priority
|
||||
groups: 'Groups',
|
||||
addGroup: 'Add Group',
|
||||
deleteGroup: 'Delete Group',
|
||||
defaultGroup: 'Default Group',
|
||||
selectGroup: 'Select Group',
|
||||
priority: 'Priority',
|
||||
p0: 'High (P0)',
|
||||
p1: 'Medium (P1)',
|
||||
p2: 'Low (P2)',
|
||||
manageGroups: 'Manage Groups',
|
||||
newGroupName: 'New Group Name',
|
||||
noPriority: 'No Priority',
|
||||
},
|
||||
};
|
||||
|
||||
export type Language = keyof typeof translations;
|
||||
export type TranslationKey = keyof typeof translations.zh;
|
||||
26
src/lib/types.ts
Normal file
26
src/lib/types.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
export type Priority = 'P0' | 'P1' | 'P2';
|
||||
|
||||
export interface Todo {
|
||||
id: string;
|
||||
text: string;
|
||||
completed: boolean;
|
||||
createdAt: number;
|
||||
completedAt?: number;
|
||||
deleted?: boolean;
|
||||
deletedAt?: number;
|
||||
groupId?: string;
|
||||
priority?: Priority;
|
||||
}
|
||||
|
||||
export interface Group {
|
||||
id: string;
|
||||
name: string;
|
||||
createdAt: number;
|
||||
}
|
||||
|
||||
export const DEFAULT_GROUP_ID = 'default';
|
||||
|
||||
export interface Stats {
|
||||
pv: number;
|
||||
uv: number;
|
||||
}
|
||||
Reference in New Issue
Block a user