| // src/components/project/ProjectMembers.tsx | |
| import { useState, useEffect, useCallback } from "react"; | |
| import { UserPlus, Crown, Trash2, Shield, Eye, Loader2, Users } 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 Member { | |
| id: number; | |
| userId: number; | |
| username: string; | |
| email?: string; | |
| fullName?: string; | |
| role: "owner" | "admin" | "member" | "viewer"; | |
| joinedAt: string; | |
| avatarUrl?: string; | |
| } | |
| interface ProjectMembersProps { | |
| projectId: number; | |
| isOwner: boolean; | |
| onMemberUpdate: () => void; | |
| onAddMember: () => void; | |
| } | |
| const ROLE_INFO: Record<string, { label: string; icon: any; style: string }> = { | |
| owner: { label: "Owner", icon: Crown, style: "bg-amber-100 text-amber-700 dark:bg-amber-900/20 dark:text-amber-400" }, | |
| admin: { label: "Admin", icon: Shield, style: "bg-blue-100 text-blue-700 dark:bg-blue-900/20 dark:text-blue-400" }, | |
| member: { label: "Member", icon: Users, style: "bg-emerald-100 text-emerald-700 dark:bg-emerald-900/20 dark:text-emerald-400" }, | |
| viewer: { label: "Viewer", icon: Eye, style: "bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-400" }, | |
| }; | |
| export function ProjectMembers({ projectId, isOwner, onMemberUpdate, onAddMember }: ProjectMembersProps) { | |
| const [members, setMembers] = useState<Member[]>([]); | |
| const [loading, setLoading] = useState(true); | |
| const fetchMembers = useCallback(async () => { | |
| setLoading(true); | |
| try { | |
| const res = await api.get(`/projects/${projectId}/members`); | |
| setMembers(res.data?.members ?? res.data?.items ?? res.data ?? []); | |
| } catch { | |
| toast.error("Failed to load members"); | |
| } finally { | |
| setLoading(false); | |
| } | |
| }, [projectId]); | |
| useEffect(() => { fetchMembers(); }, [fetchMembers]); | |
| const handleRemove = async (memberId: number, username: string) => { | |
| if (!confirm(`Remove ${username} from this project?`)) return; | |
| try { | |
| await api.delete(`/projects/${projectId}/members/${memberId}`); | |
| await fetchMembers(); | |
| onMemberUpdate(); | |
| toast.success(`${username} removed`); | |
| } catch { | |
| toast.error("Failed to remove member"); | |
| } | |
| }; | |
| const handleRoleChange = async (memberId: number, newRole: string) => { | |
| try { | |
| await api.put(`/projects/${projectId}/members/${memberId}`, { role: newRole }); | |
| await fetchMembers(); | |
| onMemberUpdate(); | |
| toast.success("Role updated"); | |
| } catch { | |
| toast.error("Failed to update role"); | |
| } | |
| }; | |
| if (loading) { | |
| return ( | |
| <div className="flex items-center justify-center py-16"> | |
| <Loader2 className="w-8 h-8 animate-spin text-primary" /> | |
| </div> | |
| ); | |
| } | |
| return ( | |
| <div className="space-y-5"> | |
| {/* Header */} | |
| <div className="flex items-center justify-between"> | |
| <div> | |
| <h3 className="text-base font-bold text-gray-900 dark:text-white">Team Members</h3> | |
| <p className="text-sm text-gray-500 dark:text-gray-400">{members.length} member{members.length !== 1 ? "s" : ""}</p> | |
| </div> | |
| {isOwner && ( | |
| <motion.button | |
| whileHover={{ scale: 1.03 }} | |
| whileTap={{ scale: 0.97 }} | |
| onClick={onAddMember} | |
| className="flex items-center gap-2 px-4 py-2.5 bg-gradient-to-r from-primary to-indigo-600 text-white rounded-xl text-sm font-semibold shadow-sm shadow-primary/20 hover:shadow-primary/40 transition-all" | |
| > | |
| <UserPlus className="w-4 h-4" /> | |
| Add Member | |
| </motion.button> | |
| )} | |
| </div> | |
| {/* Members List */} | |
| <div className="space-y-2.5"> | |
| <AnimatePresence> | |
| {members.length === 0 ? ( | |
| <motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} className="text-center py-16 text-gray-500"> | |
| <Users className="w-12 h-12 mx-auto mb-3 opacity-30" /> | |
| <p>No members yet</p> | |
| {isOwner && ( | |
| <button onClick={onAddMember} className="mt-3 text-sm text-primary hover:underline font-medium"> | |
| + Invite your first member | |
| </button> | |
| )} | |
| </motion.div> | |
| ) : ( | |
| members.map((member, i) => { | |
| const roleInfo = ROLE_INFO[member.role] ?? ROLE_INFO.member; | |
| const RoleIcon = roleInfo.icon; | |
| return ( | |
| <motion.div | |
| key={member.id} | |
| layout | |
| initial={{ opacity: 0, x: -20 }} | |
| animate={{ opacity: 1, x: 0 }} | |
| exit={{ opacity: 0, x: 20 }} | |
| transition={{ delay: i * 0.05 }} | |
| className="group 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:border-primary/20 hover:shadow-sm transition-all" | |
| > | |
| <div className="flex items-center gap-4"> | |
| {/* Avatar */} | |
| <div className="w-11 h-11 rounded-xl bg-gradient-to-br from-primary to-indigo-600 flex items-center justify-center text-white font-bold text-lg flex-shrink-0"> | |
| {member.username.charAt(0).toUpperCase()} | |
| </div> | |
| {/* Info */} | |
| <div className="flex-1 min-w-0"> | |
| <div className="flex items-center gap-2"> | |
| <p className="font-semibold text-gray-900 dark:text-white truncate">{member.username}</p> | |
| <span className={`flex items-center gap-1 px-2 py-0.5 text-xs rounded-full font-medium ${roleInfo.style}`}> | |
| <RoleIcon className="w-3 h-3" /> {roleInfo.label} | |
| </span> | |
| </div> | |
| <p className="text-xs text-gray-400 dark:text-gray-500 mt-0.5"> | |
| {member.email && <span className="mr-2">{member.email}</span>} | |
| Joined {formatDistanceToNow(new Date(member.joinedAt), { addSuffix: true })} | |
| </p> | |
| </div> | |
| {/* Actions */} | |
| {isOwner && member.role !== "owner" && ( | |
| <div className="flex items-center gap-2 opacity-0 group-hover:opacity-100 transition-all"> | |
| <select | |
| value={member.role} | |
| onChange={e => handleRoleChange(member.id, e.target.value)} | |
| className="text-xs px-2 py-1 rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-300" | |
| onClick={e => e.stopPropagation()} | |
| > | |
| <option value="admin">Admin</option> | |
| <option value="member">Member</option> | |
| <option value="viewer">Viewer</option> | |
| </select> | |
| <button | |
| onClick={() => handleRemove(member.id, member.username)} | |
| className="p-1.5 rounded-lg hover:bg-red-50 dark:hover:bg-red-900/20 text-gray-300 hover:text-red-500 transition" | |
| > | |
| <Trash2 className="w-4 h-4" /> | |
| </button> | |
| </div> | |
| )} | |
| </div> | |
| </motion.div> | |
| ); | |
| }) | |
| )} | |
| </AnimatePresence> | |
| </div> | |
| </div> | |
| ); | |
| } | |
Xet Storage Details
- Size:
- 7.65 kB
- Xet hash:
- efb11e6fd83096eb7ea1384d18696a393df8999a626d49f83723c415ae99295d
·
Xet efficiently stores files, intelligently splitting them into unique chunks and accelerating uploads and downloads. More info.