From d76380334f91f64459a26a938f4bb0d84cffff09 Mon Sep 17 00:00:00 2001 From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com> Date: Sat, 21 Feb 2026 19:19:41 +0530 Subject: [PATCH] feat: implement pagination and loading skeletons for team management page --- .../dashboard/[search_space_id]/team/page.tsx | 458 ++++++++++++------ 1 file changed, 317 insertions(+), 141 deletions(-) diff --git a/surfsense_web/app/dashboard/[search_space_id]/team/page.tsx b/surfsense_web/app/dashboard/[search_space_id]/team/page.tsx index a9213654a..426c73e60 100644 --- a/surfsense_web/app/dashboard/[search_space_id]/team/page.tsx +++ b/surfsense_web/app/dashboard/[search_space_id]/team/page.tsx @@ -6,19 +6,22 @@ import { Calendar, Check, ChevronDown, + ChevronFirst, + ChevronLast, + ChevronLeft, + ChevronRight, Clock, Copy, - Crown, Hash, - Shield, + ShieldUser, Trash2, - UserMinus, UserPlus, + User, Users, } from "lucide-react"; import { motion } from "motion/react"; import Image from "next/image"; -import { useParams } from "next/navigation"; +import { useParams, useRouter } from "next/navigation"; import { useCallback, useEffect, useMemo, useState } from "react"; import { toast } from "sonner"; import { @@ -69,6 +72,7 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select"; +import { Skeleton } from "@/components/ui/skeleton"; import { Spinner } from "@/components/ui/spinner"; import { Table, @@ -143,6 +147,8 @@ function getInviteInitials(invite: Invite): string { return "IN"; } +const PAGE_SIZE = 10; + export default function TeamManagementPage() { const params = useParams(); const searchSpaceId = Number(params.search_space_id); @@ -237,6 +243,39 @@ export default function TeamManagementPage() { const canManageRoles = hasPermission("members:manage_roles"); const canRemove = hasPermission("members:remove"); + const owners = useMemo(() => members.filter((m) => m.is_owner), [members]); + const nonOwnerMembers = useMemo(() => members.filter((m) => !m.is_owner), [members]); + + const [pageIndex, setPageIndex] = useState(0); + const totalItems = nonOwnerMembers.length + activeInvites.length; + const lastPage = Math.max(0, Math.ceil(totalItems / PAGE_SIZE) - 1); + + useEffect(() => { + if (pageIndex > lastPage) setPageIndex(lastPage); + }, [pageIndex, lastPage]); + + const paginatedMembers = useMemo(() => { + const start = pageIndex * PAGE_SIZE; + const end = start + PAGE_SIZE; + return nonOwnerMembers.slice( + Math.min(start, nonOwnerMembers.length), + Math.min(end, nonOwnerMembers.length) + ); + }, [nonOwnerMembers, pageIndex]); + + const paginatedInvites = useMemo(() => { + const start = pageIndex * PAGE_SIZE; + const end = start + PAGE_SIZE; + const inviteStart = Math.max(0, start - nonOwnerMembers.length); + const inviteEnd = Math.max(0, end - nonOwnerMembers.length); + return activeInvites.slice(inviteStart, inviteEnd); + }, [activeInvites, nonOwnerMembers.length, pageIndex]); + + const displayStart = totalItems > 0 ? pageIndex * PAGE_SIZE + 1 : 0; + const displayEnd = Math.min((pageIndex + 1) * PAGE_SIZE, totalItems); + const canPrev = pageIndex > 0; + const canNext = displayEnd < totalItems; + useEffect(() => { if (members.length > 0 && !membersLoading) { const ownerCount = members.filter((m) => m.is_owner).length; @@ -246,16 +285,66 @@ export default function TeamManagementPage() { if (accessLoading || membersLoading) { return ( -
- - -

Loading team data...

-
-
+ +
+
+
+ + +
+
+ + + + + + + + + + +
+ +
+
+
+
+ + {Array.from({ length: PAGE_SIZE }).map((_, i) => ( + + +
+ +
+ + +
+
+
+ + + + +
+ +
+
+
+ ))} +
+
+
+
+
+
); } @@ -266,7 +355,7 @@ export default function TeamManagementPage() { transition={{ duration: 0.3 }} className="min-h-screen bg-background" > -
+
{/* Header row: Invite button on left, member count on right */}
@@ -284,54 +373,135 @@ export default function TeamManagementPage() {
{/* Members & Invites Table */} -
- - - - +
+
+ + + + + Name - - + + + + + Last logged in - - + + + + + Role - + + + + + + {owners.map((member, index) => ( + + ))} + {paginatedMembers.map((member, index) => ( + + ))} + {paginatedInvites.map((invite, index) => ( + + ))} + {members.length === 0 && activeInvites.length === 0 && ( + + +
+ +

No members yet

+
+
- - - {members.map((member) => ( - - ))} - {activeInvites.map((invite) => ( - - ))} - {members.length === 0 && activeInvites.length === 0 && ( - - -
- -

No members yet

-
-
-
- )} -
-
+ )} + +
+ + {/* Pagination */} + {totalItems > PAGE_SIZE && ( + + + {displayStart}-{displayEnd} of {totalItems} + +
+ + + + +
+
+ )}
@@ -347,6 +517,8 @@ function MemberRow({ canRemove, onUpdateRole, onRemoveMember, + searchSpaceId, + index, }: { member: Membership; roles: Role[]; @@ -354,19 +526,25 @@ function MemberRow({ canRemove: boolean; onUpdateRole: (membershipId: number, roleId: number | null) => Promise; onRemoveMember: (membershipId: number) => Promise; + searchSpaceId: number; + index: number; }) { + const router = useRouter(); const initials = getAvatarInitials(member); const avatarColor = getAvatarColor(member.user_id); const displayName = member.user_display_name || member.user_email || "Unknown"; const roleName = member.is_owner ? "Owner" : (member.role?.name || "No role"); - const showActions = canManageRoles || canRemove; + const showActions = !member.is_owner && (canManageRoles || canRemove); return ( - - {/* Name + Avatar */} - + +
-
+
{member.user_avatar_url ? ( )} - {member.is_owner && ( -
- -
- )}

{displayName}

@@ -400,14 +573,12 @@ function MemberRow({
- {/* Last logged in */} - + {formatRelativeDate(member.joined_at)} - {/* Role */} - - {showActions && !member.is_owner ? ( + + {showActions ? (