| // src/components/project/ProjectOverview.tsx | |
| import { useState } from "react"; | |
| import { Edit2, Check, X, Calendar, Users, CheckSquare, FileText, TrendingUp, Award, Clock, Target } from "lucide-react"; | |
| import { motion, AnimatePresence } from "framer-motion"; | |
| import { formatDistanceToNow } from "@/lib/date"; | |
| import api from "@/lib/api"; | |
| import { toast } from "react-toastify"; | |
| interface Project { | |
| id: number; | |
| name: string; | |
| description: string; | |
| status: string; | |
| visibility: string; | |
| memberCount: number; | |
| taskCount: number; | |
| completedTaskCount: number; | |
| fileCount: number; | |
| createdAt: string; | |
| updatedAt: string; | |
| tags?: string[]; | |
| ownerUsername?: string; | |
| } | |
| interface ProjectOverviewProps { | |
| project: Project; | |
| isOwner: boolean; | |
| onUpdate: (updates: any) => void; | |
| } | |
| export function ProjectOverview({ project, isOwner, onUpdate }: ProjectOverviewProps) { | |
| const [editing, setEditing] = useState(false); | |
| const [name, setName] = useState(project.name); | |
| const [description, setDescription] = useState(project.description || ""); | |
| const [saving, setSaving] = useState(false); | |
| const progress = project.taskCount > 0 ? Math.round((project.completedTaskCount / project.taskCount) * 100) : 0; | |
| const handleSave = async () => { | |
| if (!name.trim()) return; | |
| setSaving(true); | |
| try { | |
| await onUpdate({ name: name.trim(), description: description.trim() }); | |
| setEditing(false); | |
| } catch { | |
| toast.error("Failed to save"); | |
| } finally { | |
| setSaving(false); | |
| } | |
| }; | |
| const stats = [ | |
| { label: "Members", value: project.memberCount ?? 0, icon: Users, color: "text-blue-500 bg-blue-500/10" }, | |
| { label: "Tasks", value: project.taskCount ?? 0, icon: CheckSquare, color: "text-emerald-500 bg-emerald-500/10" }, | |
| { label: "Completed", value: project.completedTaskCount ?? 0, icon: Award, color: "text-purple-500 bg-purple-500/10" }, | |
| { label: "Files", value: project.fileCount ?? 0, icon: FileText, color: "text-orange-500 bg-orange-500/10" }, | |
| ]; | |
| return ( | |
| <div className="space-y-6"> | |
| {/* Stats Grid */} | |
| <div className="grid grid-cols-2 md:grid-cols-4 gap-4"> | |
| {stats.map((stat, i) => ( | |
| <motion.div | |
| key={stat.label} | |
| initial={{ opacity: 0, y: 20 }} | |
| animate={{ opacity: 1, y: 0 }} | |
| transition={{ delay: i * 0.07 }} | |
| className="bg-white/60 dark:bg-gray-900/60 backdrop-blur-md rounded-2xl border border-white/30 dark:border-gray-700/50 p-4 shadow-sm" | |
| > | |
| <div className={`w-10 h-10 rounded-xl ${stat.color} flex items-center justify-center mb-3`}> | |
| <stat.icon className="w-5 h-5" /> | |
| </div> | |
| <div className="text-2xl font-bold text-gray-900 dark:text-white">{stat.value}</div> | |
| <div className="text-xs text-gray-500 dark:text-gray-400 font-medium mt-0.5">{stat.label}</div> | |
| </motion.div> | |
| ))} | |
| </div> | |
| {/* Progress Card */} | |
| <motion.div | |
| initial={{ opacity: 0, y: 20 }} | |
| animate={{ opacity: 1, y: 0 }} | |
| transition={{ delay: 0.3 }} | |
| className="bg-gradient-to-br from-primary/5 via-transparent to-indigo-500/5 rounded-2xl border border-primary/10 dark:border-primary/20 p-6" | |
| > | |
| <div className="flex items-center justify-between mb-4"> | |
| <div className="flex items-center gap-2"> | |
| <TrendingUp className="w-5 h-5 text-primary" /> | |
| <h3 className="font-semibold text-gray-900 dark:text-white">Overall Progress</h3> | |
| </div> | |
| <span className="text-2xl font-black text-primary">{progress}%</span> | |
| </div> | |
| <div className="h-3 bg-gray-100 dark:bg-gray-800 rounded-full overflow-hidden"> | |
| <motion.div | |
| initial={{ width: 0 }} | |
| animate={{ width: `${progress}%` }} | |
| transition={{ duration: 1, ease: "easeOut" }} | |
| className="h-full bg-gradient-to-r from-primary to-indigo-500 rounded-full" | |
| /> | |
| </div> | |
| <p className="text-sm text-gray-500 dark:text-gray-400 mt-2"> | |
| {project.completedTaskCount} of {project.taskCount} tasks completed | |
| </p> | |
| </motion.div> | |
| {/* Description Card */} | |
| <motion.div | |
| initial={{ opacity: 0, y: 20 }} | |
| animate={{ opacity: 1, y: 0 }} | |
| transition={{ delay: 0.4 }} | |
| className="bg-white/60 dark:bg-gray-900/60 backdrop-blur-md rounded-2xl border border-white/30 dark:border-gray-700/50 p-6 shadow-sm" | |
| > | |
| <div className="flex items-center justify-between mb-3"> | |
| <h3 className="font-semibold text-gray-900 dark:text-white flex items-center gap-2"> | |
| <Target className="w-4 h-4 text-primary" /> | |
| Project Description | |
| </h3> | |
| {isOwner && !editing && ( | |
| <button | |
| onClick={() => setEditing(true)} | |
| className="flex items-center gap-1 text-xs text-primary hover:underline transition" | |
| > | |
| <Edit2 className="w-3 h-3" /> Edit | |
| </button> | |
| )} | |
| </div> | |
| <AnimatePresence mode="wait"> | |
| {editing ? ( | |
| <motion.div key="edit" initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} className="space-y-3"> | |
| <input | |
| value={name} | |
| onChange={e => setName(e.target.value)} | |
| className="w-full px-3 py-2 rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:ring-2 focus:ring-primary focus:border-transparent" | |
| placeholder="Project name" | |
| /> | |
| <textarea | |
| value={description} | |
| onChange={e => setDescription(e.target.value)} | |
| rows={4} | |
| className="w-full px-3 py-2 rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:ring-2 focus:ring-primary focus:border-transparent resize-none" | |
| placeholder="Describe your project..." | |
| /> | |
| <div className="flex gap-2"> | |
| <button | |
| onClick={handleSave} | |
| disabled={saving || !name.trim()} | |
| className="flex items-center gap-1.5 px-4 py-2 bg-primary text-white rounded-xl text-sm font-medium hover:bg-primary/90 disabled:opacity-50 transition" | |
| > | |
| <Check className="w-4 h-4" /> {saving ? "Saving..." : "Save"} | |
| </button> | |
| <button | |
| onClick={() => { setEditing(false); setName(project.name); setDescription(project.description || ""); }} | |
| className="flex items-center gap-1.5 px-4 py-2 bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300 rounded-xl text-sm font-medium hover:bg-gray-200 dark:hover:bg-gray-600 transition" | |
| > | |
| <X className="w-4 h-4" /> Cancel | |
| </button> | |
| </div> | |
| </motion.div> | |
| ) : ( | |
| <motion.p key="view" initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} | |
| className="text-gray-600 dark:text-gray-400 leading-relaxed" | |
| > | |
| {project.description || <span className="text-gray-400 italic">No description provided. {isOwner ? "Click Edit to add one." : ""}</span>} | |
| </motion.p> | |
| )} | |
| </AnimatePresence> | |
| </motion.div> | |
| {/* Meta Info */} | |
| <motion.div | |
| initial={{ opacity: 0, y: 20 }} | |
| animate={{ opacity: 1, y: 0 }} | |
| transition={{ delay: 0.5 }} | |
| className="grid grid-cols-1 sm:grid-cols-2 gap-4" | |
| > | |
| <div className="bg-white/60 dark:bg-gray-900/60 backdrop-blur-md rounded-2xl border border-white/30 dark:border-gray-700/50 p-4 shadow-sm"> | |
| <div className="flex items-center gap-2 text-sm text-gray-500 dark:text-gray-400 mb-1"> | |
| <Clock className="w-4 h-4" /> | |
| Created | |
| </div> | |
| <p className="font-semibold text-gray-900 dark:text-white"> | |
| {formatDistanceToNow(new Date(project.createdAt), { addSuffix: true })} | |
| </p> | |
| </div> | |
| <div className="bg-white/60 dark:bg-gray-900/60 backdrop-blur-md rounded-2xl border border-white/30 dark:border-gray-700/50 p-4 shadow-sm"> | |
| <div className="flex items-center gap-2 text-sm text-gray-500 dark:text-gray-400 mb-1"> | |
| <Calendar className="w-4 h-4" /> | |
| Last Updated | |
| </div> | |
| <p className="font-semibold text-gray-900 dark:text-white"> | |
| {formatDistanceToNow(new Date(project.updatedAt), { addSuffix: true })} | |
| </p> | |
| </div> | |
| </motion.div> | |
| {/* Tags */} | |
| {project.tags && project.tags.length > 0 && ( | |
| <motion.div | |
| initial={{ opacity: 0, y: 20 }} | |
| animate={{ opacity: 1, y: 0 }} | |
| transition={{ delay: 0.6 }} | |
| className="bg-white/60 dark:bg-gray-900/60 backdrop-blur-md rounded-2xl border border-white/30 dark:border-gray-700/50 p-4 shadow-sm" | |
| > | |
| <h3 className="text-sm font-semibold text-gray-500 dark:text-gray-400 mb-3 uppercase tracking-wider">Tags</h3> | |
| <div className="flex flex-wrap gap-2"> | |
| {project.tags.map(tag => ( | |
| <span key={tag} className="px-3 py-1 text-sm bg-primary/10 text-primary border border-primary/20 rounded-full"> | |
| #{tag} | |
| </span> | |
| ))} | |
| </div> | |
| </motion.div> | |
| )} | |
| </div> | |
| ); | |
| } | |
Xet Storage Details
- Size:
- 9.6 kB
- Xet hash:
- 7e8e34c8c4b359a5ab38f91db3065f6246fb2518a32a3cd58a088b3031065d8b
·
Xet efficiently stores files, intelligently splitting them into unique chunks and accelerating uploads and downloads. More info.