Skip to content

Commit

Permalink
Merge pull request #9 from TEDx-SJEC/admin
Browse files Browse the repository at this point in the history
Admin
  • Loading branch information
Vyshnav001 authored Sep 14, 2024
2 parents 89f9c41 + 6f9e984 commit 2335688
Show file tree
Hide file tree
Showing 9 changed files with 817 additions and 0 deletions.
453 changes: 453 additions & 0 deletions package-lock.json

Large diffs are not rendered by default.

4 changes: 4 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,15 +15,19 @@
"@next-auth/prisma-adapter": "^1.0.7",
"@prisma/adapter-libsql": "^5.19.1",
"@prisma/client": "^5.19.1",
"@radix-ui/react-avatar": "^1.1.0",
"@radix-ui/react-icons": "^1.3.0",
"@radix-ui/react-label": "^2.1.0",
"@radix-ui/react-popover": "^1.1.1",
"@radix-ui/react-slot": "^1.1.0",
"@radix-ui/react-tabs": "^1.1.0",
"@tanstack/react-query": "^5.56.2",
"@types/lodash.debounce": "^4.0.9",
"axios": "^1.7.7",
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.1",
"jest": "^29.7.0",
"lodash.debounce": "^4.0.8",
"lucide-react": "^0.441.0",
"next": "14.2.6",
"next-auth": "^4.24.7",
Expand Down
25 changes: 25 additions & 0 deletions src/app/actions/change-role.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
"use server";

import prisma from "@/server/db";
import { revalidatePath } from "next/cache";

async function updateUserRole(id: string, role: string) {
try {
const updatedUser = await prisma.user.update({
where: { id },
data: { role },
});
revalidatePath("/admin/users");
return updatedUser;
} catch (error) {
console.error("Error updating user role:", error);
return null;
}
}
export const makeAdmin = async (userId: string) => {
return await updateUserRole(userId, "ADMIN");
};

export const makeParticipant = async (userId: string) => {
return await updateUserRole(userId, "PARTICIPANT");
};
33 changes: 33 additions & 0 deletions src/app/admin/users/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import UsersList from "@/components/Admin/user-list";
import prisma from "@/server/db";

export default async function Users() {
let initialUserData = await prisma.user.findMany({
select: {
id: true,
name: true,
email: true,
role: true,
image: true,
},
take: 10,
});
if (initialUserData === null) {
initialUserData = [
{
id: "1",
name: "Test name",
email: "[email protected]",
role: "PARTICIPANT",
image: "https://i.pravatar.cc/300?img=1",
},
];
}
return (
<>
<div className="pt-20 flex min-h-screen w-full flex-col bg-background">
<UsersList initialUsers={initialUserData} initialPage={1} />
</div>
</>
);
}
20 changes: 20 additions & 0 deletions src/app/api/users/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import prisma from "@/server/db";

import { NextResponse } from "next/server";
export async function GET(req: Request) {
const { searchParams } = new URL(req.url);
const page = parseInt(searchParams.get("page") || "1");
const search = searchParams.get("search") || "";
const limit = 10;

const users = await prisma.user.findMany({
skip: (page - 1) * limit,
take: limit,
where: {
name: {
contains: search,
},
},
});
return NextResponse.json({ users });
}
34 changes: 34 additions & 0 deletions src/components/Admin/change-role.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
"use client";
import { makeAdmin, makeParticipant } from "@/app/actions/change-role";
import { Button } from "@/components/ui/button";

export default function ChangeRole({ userId, userRole }: { userId: string; userRole: string }) {
async function handleMakeAdmin() {
await makeAdmin(userId);
}

async function handleMakeParticipant() {
await makeParticipant(userId);
}

return (
<div className="">
<Button
className={`bg-foreground hover:bg-muted-foreground font-bold py-2 px-4 rounded dark:bg-black ${
userRole === "ADMIN" ? "hidden" : ""
}`}
onClick={handleMakeAdmin}
>
Make Admin
</Button>
<Button
className={`bg-foreground hover:bg-muted-foreground font-bold py-2 px-4 rounded dark:bg-black ${
userRole === "PARTICIPANT" ? "hidden" : ""
}`}
onClick={handleMakeParticipant}
>
Make Participant
</Button>
</div>
);
}
165 changes: 165 additions & 0 deletions src/components/Admin/user-list.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
/* eslint-disable react-hooks/exhaustive-deps */
"use client";

import { useState, useEffect, useRef, useCallback } from "react";
import axios from "axios";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "../ui/card";
import { Avatar, AvatarFallback, AvatarImage } from "../ui/avatar";
import { Popover, PopoverContent, PopoverTrigger } from "../ui/popover";
import { Button } from "../ui/button";
import { ChevronDownIcon, SearchIcon } from "lucide-react";
import { Input } from "../ui/input";
import debounce from "lodash.debounce";
import ChangeRole from "./change-role";

export interface User {
id: string;
name: string | null;
email: string | null;
role: string;
image: string | null;
}

interface UsersListProps {
initialUsers: User[];
initialPage: number;
}
export const dynamic = "force-dynamic";
const UsersList: React.FC<UsersListProps> = ({ initialUsers, initialPage }) => {
const [userList, setUserList] = useState<User[]>(initialUsers);
const [currentPage, setCurrentPage] = useState(initialPage);
const [loading, setLoading] = useState(false);
const [hasMore, setHasMore] = useState(true);
const [searchQuery, setSearchQuery] = useState(""); // Search query state
const loader = useRef<HTMLDivElement | null>(null);

const fetchUsers = async (page: number, query: string) => {
if (loading) return;
setLoading(true);
try {
const response = await axios.get(`/api/users?page=${page}&search=${encodeURIComponent(query)}`);
if (response.data.users.length > 0) {
setUserList((prevUsers) => [...prevUsers, ...response.data.users]);
setCurrentPage(page);
} else {
setHasMore(false);
}
} catch (error) {
console.error("Error fetching users:", error);
}
setLoading(false);
};
const loadMoreUsers = useCallback(() => {
if (hasMore) {
fetchUsers(currentPage + 1, searchQuery);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [currentPage, hasMore, searchQuery]);

const debouncedFetchUsers = useCallback(
debounce(async (query: string) => {
setCurrentPage(1); // Reset page number
setHasMore(true); // Reset hasMore
try {
const response = await axios.get(`/api/users?page=1&search=${encodeURIComponent(query)}`);
setUserList(response.data.users);
} catch (error) {
console.error("Error fetching users:", error);
}
}, 500),
[]
);
const handleSearchChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const query = e.target.value;
setSearchQuery(query);
debouncedFetchUsers(query); // Use debounced fetch function
};

// Observe scroll and load more users when scrolled to the bottom
useEffect(() => {
if (loader.current) {
const observer = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting && hasMore) {
loadMoreUsers();
}
},
{ threshold: 1.0 }
);
observer.observe(loader.current);
return () => observer.disconnect();
}
}, [loader.current, hasMore, loadMoreUsers]);

return (
<>
<div className="flex flex-1 overflow-hidden">
<main className="container grid flex-1 items-start gap-4 p-4 sm:px-6 sm:py-0 md:gap-8 mt-5">
<Card className="w-full">
<div className="flex justify-end gap-2 mt-5 p-5">
<div className="relative">
<SearchIcon className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
type="search"
placeholder="Search users..."
className="pl-8 sm:w-[200px] md:w-[300px]"
value={searchQuery}
onChange={handleSearchChange} // Handle search input change
/>
</div>
</div>
<CardHeader>
<CardTitle>Users</CardTitle>
<CardDescription>Manage user roles and permissions.</CardDescription>
</CardHeader>
<CardContent>
<div className="grid gap-4">
{userList.map((user) => (
<div
key={user.id}
className="grid grid-cols-[1fr_auto] items-center gap-4"
>
<div className="flex items-center gap-4">
<Avatar>
<AvatarImage src={user.image || "/placeholder-user.jpg"} />
<AvatarFallback>
{user.name ? user.name[0] : "N/A"}
</AvatarFallback>
</Avatar>
<div>
<p className="text-sm font-medium">
{user.name || "Unknown"}
</p>
<p className="text-sm text-muted-foreground">
{user.email || "No email"}
</p>
</div>
</div>
<Popover>
<PopoverTrigger asChild>
<Button variant="outline" size="sm">
{user.role}{" "}
<ChevronDownIcon className="w-4 h-4 ml-2 text-muted-foreground" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="center">
<ChangeRole userId={user.id} userRole={user.role} />
</PopoverContent>
</Popover>
</div>
))}
</div>
{hasMore && (
<div ref={loader} className="text-center">
{loading ? "Loading..." : "Load more"}
</div>
)}
</CardContent>
</Card>
</main>
</div>
</>
);
};

export default UsersList;
50 changes: 50 additions & 0 deletions src/components/ui/avatar.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
"use client"

import * as React from "react"
import * as AvatarPrimitive from "@radix-ui/react-avatar"

import { cn } from "@/lib/utils"

const Avatar = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Root>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Root
ref={ref}
className={cn(
"relative flex h-10 w-10 shrink-0 overflow-hidden rounded-full",
className
)}
{...props}
/>
))
Avatar.displayName = AvatarPrimitive.Root.displayName

const AvatarImage = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Image>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Image>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Image
ref={ref}
className={cn("aspect-square h-full w-full", className)}
{...props}
/>
))
AvatarImage.displayName = AvatarPrimitive.Image.displayName

const AvatarFallback = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Fallback>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Fallback>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Fallback
ref={ref}
className={cn(
"flex h-full w-full items-center justify-center rounded-full bg-muted",
className
)}
{...props}
/>
))
AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName

export { Avatar, AvatarImage, AvatarFallback }
33 changes: 33 additions & 0 deletions src/components/ui/popover.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
"use client"

import * as React from "react"
import * as PopoverPrimitive from "@radix-ui/react-popover"

import { cn } from "@/lib/utils"

const Popover = PopoverPrimitive.Root

const PopoverTrigger = PopoverPrimitive.Trigger

const PopoverAnchor = PopoverPrimitive.Anchor

const PopoverContent = React.forwardRef<
React.ElementRef<typeof PopoverPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
>(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
ref={ref}
align={align}
sideOffset={sideOffset}
className={cn(
"z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props}
/>
</PopoverPrimitive.Portal>
))
PopoverContent.displayName = PopoverPrimitive.Content.displayName

export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor }

0 comments on commit 2335688

Please sign in to comment.