diff --git a/app/admin/users/page.tsx b/app/admin/users/page.tsx index 3b388e2..62c419f 100644 --- a/app/admin/users/page.tsx +++ b/app/admin/users/page.tsx @@ -25,6 +25,29 @@ export const metadata: Metadata = { title: 'Manage Users | Admin', }; +type SortBy = + | 'user' + | 'joinedDate' + | 'workspacesOwned' + | 'invitedMembers' + | 'projectsOwned' + | 'totalComments' + | 'bunnyUpload' + | 'mediaStorage'; + +type SortDirection = 'asc' | 'desc'; + +const SORTABLE_COLUMNS: SortBy[] = [ + 'user', + 'joinedDate', + 'workspacesOwned', + 'invitedMembers', + 'projectsOwned', + 'totalComments', + 'bunnyUpload', + 'mediaStorage', +]; + function formatBytes(bytes: number, decimals = 2) { if (bytes < 0) return 'Error Fetching'; if (!+bytes) return '0 Bytes'; @@ -35,31 +58,55 @@ function formatBytes(bytes: number, decimals = 2) { return `${parseFloat((bytes / Math.pow(k, i)).toFixed(dm))} ${sizes[i]}`; } +function isSortBy(value: string | undefined): value is SortBy { + return !!value && SORTABLE_COLUMNS.includes(value as SortBy); +} + +function getDefaultSortDirection(sortBy: SortBy): SortDirection { + return sortBy === 'user' ? 'asc' : 'desc'; +} + +function getSortIndicator(column: SortBy, activeSortBy: SortBy, activeSortDirection: SortDirection): string { + if (column !== activeSortBy) return '↕'; + return activeSortDirection === 'asc' ? '↑' : '↓'; +} + export default async function AdminUsersPage({ searchParams }: { - searchParams: { page?: string } + searchParams: Promise<{ page?: string; sortBy?: string; sortDirection?: string }> }) { const session = await auth(); if (!session?.user?.isAdmin) { redirect('/'); } - const page = Number(searchParams?.page) || 1; + const resolvedSearchParams = await searchParams; + const requestedPage = Number(resolvedSearchParams?.page) || 1; const pageSize = 20; - const skip = (page - 1) * pageSize; + const sortBy: SortBy = isSortBy(resolvedSearchParams?.sortBy) ? resolvedSearchParams.sortBy : 'joinedDate'; + const sortDirection: SortDirection = + resolvedSearchParams?.sortDirection === 'asc' || resolvedSearchParams?.sortDirection === 'desc' + ? resolvedSearchParams.sortDirection + : getDefaultSortDirection(sortBy); - // Fetch users with their counts and total count - const [users, totalUsers] = await Promise.all([ + const [users, userStorage, userBunnyStorage, bunnyStorageStats] = await Promise.all([ db.user.findMany({ - skip, - take: pageSize, orderBy: { createdAt: 'desc' }, select: { id: true, name: true, email: true, createdAt: true, + ownedWorkspaces: { + select: { + _count: { + select: { + members: true, + } + } + } + }, _count: { select: { ownedWorkspaces: true, @@ -69,18 +116,82 @@ export default async function AdminUsersPage({ } } }), - db.user.count() - ]); - - const totalPages = Math.ceil(totalUsers / pageSize); - - // Determine per-user storage usage (cached) - const [userStorage, userBunnyStorage, bunnyStorageStats] = await Promise.all([ getCachedUserMediaStorage(), getCachedUserBunnyStorage(), getCachedBunnyStorageStats(), ]); + const usersWithMetrics = users.map((user) => ({ + ...user, + invitedMembersCount: user.ownedWorkspaces.reduce( + (total, workspace) => total + workspace._count.members, + 0 + ), + bunnyUploadBytes: userBunnyStorage[user.id] || 0, + mediaStorageBytes: userStorage[user.id]?.total || 0, + })); + + const sortedUsers = [...usersWithMetrics].sort((a, b) => { + let comparison = 0; + + if (sortBy === 'user') { + const aLabel = (a.name || a.email || 'Anonymous').toLowerCase(); + const bLabel = (b.name || b.email || 'Anonymous').toLowerCase(); + comparison = aLabel.localeCompare(bLabel); + } else if (sortBy === 'joinedDate') { + comparison = new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime(); + } else if (sortBy === 'workspacesOwned') { + comparison = a._count.ownedWorkspaces - b._count.ownedWorkspaces; + } else if (sortBy === 'invitedMembers') { + comparison = a.invitedMembersCount - b.invitedMembersCount; + } else if (sortBy === 'projectsOwned') { + comparison = a._count.projects - b._count.projects; + } else if (sortBy === 'totalComments') { + comparison = a._count.comments - b._count.comments; + } else if (sortBy === 'bunnyUpload') { + comparison = a.bunnyUploadBytes - b.bunnyUploadBytes; + } else if (sortBy === 'mediaStorage') { + comparison = a.mediaStorageBytes - b.mediaStorageBytes; + } + + if (comparison === 0) { + comparison = new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(); + } + + return sortDirection === 'asc' ? comparison : -comparison; + }); + + const totalUsers = sortedUsers.length; + const totalPages = Math.max(1, Math.ceil(totalUsers / pageSize)); + const page = Math.min(Math.max(1, requestedPage), totalPages); + const skip = (page - 1) * pageSize; + const paginatedUsers = sortedUsers.slice(skip, skip + pageSize); + + const buildUsersPageHref = ( + targetPage: number, + targetSortBy: SortBy = sortBy, + targetSortDirection: SortDirection = sortDirection + ): string => { + const params = new URLSearchParams({ + page: String(targetPage), + sortBy: targetSortBy, + sortDirection: targetSortDirection, + }); + + return `/admin/users?${params.toString()}`; + }; + + const buildSortHref = (column: SortBy): string => { + const nextDirection: SortDirection = + column === sortBy + ? sortDirection === 'asc' + ? 'desc' + : 'asc' + : getDefaultSortDirection(column); + + return buildUsersPageHref(1, column, nextDirection); + }; + return (
@@ -122,24 +233,65 @@ export default async function AdminUsersPage({ - User - Joined Date - Workspaces Owned - Projects Owned - Total Comments - Bunny Upload - Media Storage + + + User + {getSortIndicator('user', sortBy, sortDirection)} + + + + + Joined Date + {getSortIndicator('joinedDate', sortBy, sortDirection)} + + + + + Workspaces Owned + {getSortIndicator('workspacesOwned', sortBy, sortDirection)} + + + + + Invited Members + {getSortIndicator('invitedMembers', sortBy, sortDirection)} + + + + + Projects Owned + {getSortIndicator('projectsOwned', sortBy, sortDirection)} + + + + + Total Comments + {getSortIndicator('totalComments', sortBy, sortDirection)} + + + + + Bunny Upload + {getSortIndicator('bunnyUpload', sortBy, sortDirection)} + + + + + Media Storage + {getSortIndicator('mediaStorage', sortBy, sortDirection)} + + - {users.length === 0 ? ( + {paginatedUsers.length === 0 ? ( - + No users found. ) : ( - users.map((user) => ( + paginatedUsers.map((user) => (
@@ -151,14 +303,15 @@ export default async function AdminUsersPage({ {format(new Date(user.createdAt), 'MMM dd, yyyy')} {user._count.ownedWorkspaces} + {user.invitedMembersCount} {user._count.projects} {user._count.comments} - {formatBytes(userBunnyStorage[user.id] || 0)} + {formatBytes(user.bunnyUploadBytes)}
- {formatBytes(userStorage[user.id]?.total || 0)} + {formatBytes(user.mediaStorageBytes)} {(userStorage[user.id]?.voice > 0 || userStorage[user.id]?.image > 0) && ( {userStorage[user.id]?.voice > 0 && 🎤 {formatBytes(userStorage[user.id]?.voice)}} @@ -184,7 +337,7 @@ export default async function AdminUsersPage({ asChild={page > 1} > {page > 1 ? ( - Previous + Previous ) : ( "Previous" )} @@ -199,7 +352,7 @@ export default async function AdminUsersPage({ asChild={page < totalPages} > {page < totalPages ? ( - Next + Next ) : ( "Next" )} diff --git a/components/LandingPage.tsx b/components/LandingPage.tsx index c7c5a79..eeb4b3a 100644 --- a/components/LandingPage.tsx +++ b/components/LandingPage.tsx @@ -327,7 +327,7 @@ export function LandingPage({ isLoggedIn }: LandingPageProps) {

- VIDEO FEEDBACK PROTOCOL + OPEN SOURCE VIDEO REVIEW TOOL

Review Video. Without the Chaos. @@ -428,7 +428,7 @@ export function LandingPage({ isLoggedIn }: LandingPageProps) {

- Collaborative video review as precision software. + Fast, precise collaborative video review.

Guest commenting via shareable links and workspace-level permissions are available on every project.