| // src/components/project/ProjectTimeline.tsx | |
| import { useState, useEffect, useCallback } from "react"; | |
| import { CheckCircle, Clock, Circle, Calendar, Loader2, Activity } from "lucide-react"; | |
| import { motion } from "framer-motion"; | |
| import { formatDistanceToNow } from "@/lib/date"; | |
| import api from "@/lib/api"; | |
| import { toast } from "react-toastify"; | |
| interface Task { | |
| id: number; | |
| title: string; | |
| status: string; | |
| priority: string; | |
| dueDate?: string; | |
| createdAt: string; | |
| assigneeUsername?: string; | |
| } | |
| interface ProjectTimelineProps { | |
| projectId: number; | |
| } | |
| export function ProjectTimeline({ projectId }: ProjectTimelineProps) { | |
| const [tasks, setTasks] = useState<Task[]>([]); | |
| const [loading, setLoading] = useState(true); | |
| const fetchTasks = useCallback(async () => { | |
| setLoading(true); | |
| try { | |
| const res = await api.get(`/projects/${projectId}/tasks`); | |
| const all = res.data?.tasks ?? res.data?.items ?? res.data ?? []; | |
| // Sort by createdAt descending | |
| setTasks([...all].sort((a: Task, b: Task) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())); | |
| } catch { | |
| toast.error("Failed to load timeline"); | |
| } finally { | |
| setLoading(false); | |
| } | |
| }, [projectId]); | |
| useEffect(() => { fetchTasks(); }, [fetchTasks]); | |
| const getStatusIcon = (status: string) => { | |
| if (status.includes("complet")) return <CheckCircle className="w-4 h-4 text-emerald-500" />; | |
| if (status.includes("progress")) return <Clock className="w-4 h-4 text-amber-500" />; | |
| return <Circle className="w-4 h-4 text-gray-400" />; | |
| }; | |
| const getStatusColor = (status: string) => { | |
| if (status.includes("complet")) return "border-emerald-500 bg-emerald-50 dark:bg-emerald-900/20"; | |
| if (status.includes("progress")) return "border-amber-500 bg-amber-50 dark:bg-amber-900/20"; | |
| return "border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-900"; | |
| }; | |
| const getPriorityDot = (priority: string) => { | |
| if (priority === "high") return "bg-red-400"; | |
| if (priority === "medium") return "bg-amber-400"; | |
| return "bg-emerald-400"; | |
| }; | |
| if (loading) { | |
| return ( | |
| <div className="flex items-center justify-center py-16"> | |
| <Loader2 className="w-8 h-8 animate-spin text-primary" /> | |
| </div> | |
| ); | |
| } | |
| if (tasks.length === 0) { | |
| return ( | |
| <div className="text-center py-16 text-gray-500"> | |
| <Activity className="w-12 h-12 mx-auto mb-3 opacity-30" /> | |
| <p>No timeline events yet</p> | |
| <p className="text-sm mt-1">Create tasks to see them in the timeline</p> | |
| </div> | |
| ); | |
| } | |
| return ( | |
| <div className="space-y-4"> | |
| <h3 className="text-base font-bold text-gray-900 dark:text-white">Project Timeline</h3> | |
| <p className="text-sm text-gray-500">{tasks.length} events</p> | |
| <div className="relative"> | |
| {/* Vertical line */} | |
| <div className="absolute left-5 top-0 bottom-0 w-0.5 bg-gradient-to-b from-primary/30 via-gray-200 to-transparent dark:via-gray-700" /> | |
| <div className="space-y-4"> | |
| {tasks.map((task, i) => ( | |
| <motion.div | |
| key={task.id} | |
| initial={{ opacity: 0, x: -20 }} | |
| animate={{ opacity: 1, x: 0 }} | |
| transition={{ delay: i * 0.06 }} | |
| className="relative flex gap-5" | |
| > | |
| {/* Icon */} | |
| <div className={`relative z-10 w-10 h-10 rounded-full border-2 flex items-center justify-center flex-shrink-0 shadow-sm ${getStatusColor(task.status)}`}> | |
| {getStatusIcon(task.status)} | |
| </div> | |
| {/* Content */} | |
| <div className="flex-1 bg-white/70 dark:bg-gray-900/70 backdrop-blur-md rounded-2xl border border-white/50 dark:border-gray-700/50 p-4 hover:shadow-sm transition-all"> | |
| <div className="flex items-start justify-between gap-2 flex-wrap"> | |
| <div className="flex items-center gap-2"> | |
| <span className={`w-2 h-2 rounded-full flex-shrink-0 ${getPriorityDot(task.priority)}`} /> | |
| <p className={`font-semibold text-sm ${task.status.includes("complet") ? "line-through text-gray-400" : "text-gray-900 dark:text-white"}`}> | |
| {task.title} | |
| </p> | |
| </div> | |
| <span className="text-xs text-gray-400 dark:text-gray-500 whitespace-nowrap"> | |
| {formatDistanceToNow(new Date(task.createdAt), { addSuffix: true })} | |
| </span> | |
| </div> | |
| <div className="flex items-center gap-4 mt-2 text-xs text-gray-400 flex-wrap"> | |
| {task.dueDate && ( | |
| <span className={`flex items-center gap-1 ${new Date(task.dueDate) < new Date() && !task.status.includes("complet") ? "text-red-500" : ""}`}> | |
| <Calendar className="w-3 h-3" /> | |
| Due {formatDistanceToNow(new Date(task.dueDate), { addSuffix: true })} | |
| </span> | |
| )} | |
| {task.assigneeUsername && ( | |
| <span>@{task.assigneeUsername}</span> | |
| )} | |
| <span className="capitalize px-2 py-0.5 rounded-full text-xs bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-400"> | |
| {task.status.replace("_", " ")} | |
| </span> | |
| </div> | |
| </div> | |
| </motion.div> | |
| ))} | |
| </div> | |
| </div> | |
| </div> | |
| ); | |
| } | |
Xet Storage Details
- Size:
- 5.51 kB
- Xet hash:
- 03c7cfb4bccf701853521f477b6065c12db3af4a50aef033067e44ab5229b618
·
Xet efficiently stores files, intelligently splitting them into unique chunks and accelerating uploads and downloads. More info.