tahamajs/IE / Projects /front /src /components /project /ProjectMembers.tsx
tahamajs's picture
download
raw
7.65 kB
// 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.