Skip to content

Commit e06376d

Browse files
committed
update: project pages ui and api endpoints
1 parent 4daa270 commit e06376d

33 files changed

Lines changed: 3523 additions & 1141 deletions

REACT_QUERY_MIGRATION_PLAN.md

Lines changed: 0 additions & 676 deletions
This file was deleted.

REACT_QUERY_SETUP_GUIDE.md

Lines changed: 475 additions & 42 deletions
Large diffs are not rendered by default.

app/(auth)/sign-up/page.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ const Page = () => (
1212
email: "",
1313
password: "",
1414
fullName: "",
15-
universityId: 0,
15+
universityId: undefined,
1616
universityCard: "",
1717
}}
1818
onSubmit={signUp}

app/admin/account-requests/AccountRequestsClient.tsx

Lines changed: 145 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,10 @@
1616
*/
1717

1818
import React, { useState } from "react";
19+
import { useRouter, useSearchParams } from "next/navigation";
20+
import { useQueryClient } from "@tanstack/react-query";
1921
import { Button } from "@/components/ui/button";
22+
import { Input } from "@/components/ui/input";
2023
import config from "@/lib/config";
2124
import { Card, CardContent, CardHeader } from "@/components/ui/card";
2225
import { Badge } from "@/components/ui/badge";
@@ -64,13 +67,71 @@ const AccountRequestsClient = ({
6467
successMessage,
6568
errorMessage,
6669
}: AccountRequestsClientProps) => {
70+
const router = useRouter();
71+
const searchParamsHook = useSearchParams();
72+
const queryClient = useQueryClient();
73+
74+
// Get current search params from URL
75+
const currentSearch = searchParamsHook.get("search") || "";
76+
77+
const [localSearch, setLocalSearch] = useState(currentSearch);
78+
const lastSyncedSearchRef = React.useRef(currentSearch);
79+
80+
// Sync localSearch with URL params when they change externally (e.g., browser back/forward)
81+
// Only sync if the change didn't come from our own debounced update
82+
React.useEffect(() => {
83+
// Only sync if:
84+
// 1. currentSearch changed from an external source (not our debounce)
85+
// 2. localSearch matches the last synced value (user isn't actively typing)
86+
// This prevents overwriting user input while typing
87+
if (
88+
currentSearch !== lastSyncedSearchRef.current &&
89+
localSearch === lastSyncedSearchRef.current
90+
) {
91+
setLocalSearch(currentSearch);
92+
lastSyncedSearchRef.current = currentSearch;
93+
}
94+
}, [currentSearch, localSearch]);
95+
96+
// Debounce search input for instant filtering
97+
React.useEffect(() => {
98+
const timer = setTimeout(() => {
99+
if (localSearch !== currentSearch) {
100+
const params = new URLSearchParams(searchParamsHook.toString());
101+
const trimmedSearch = localSearch.trim();
102+
103+
if (trimmedSearch) {
104+
params.set("search", trimmedSearch);
105+
} else {
106+
params.delete("search");
107+
}
108+
109+
const newUrl = `/admin/account-requests?${params.toString()}`;
110+
// Update ref before navigation to prevent sync effect from overwriting
111+
lastSyncedSearchRef.current = trimmedSearch;
112+
queryClient.invalidateQueries({ queryKey: ["pending-users"] });
113+
router.replace(newUrl, { scroll: false });
114+
}
115+
}, 300); // 300ms debounce
116+
117+
return () => clearTimeout(timer);
118+
}, [localSearch, currentSearch, searchParamsHook, queryClient, router]);
119+
120+
// Check if any filters are active
121+
const hasActiveFilters = currentSearch;
122+
123+
// Only use initialData on first load (when no filters are active)
124+
const initialUsersData = !hasActiveFilters && initialUsers
125+
? initialUsers
126+
: undefined;
127+
67128
// React Query hook with SSR initial data
68129
const {
69130
data: usersData,
70131
isLoading: usersLoading,
71132
isError: usersError,
72133
error: usersErrorData,
73-
} = usePendingUsers(initialUsers);
134+
} = usePendingUsers(initialUsersData, currentSearch || undefined);
74135

75136
// React Query mutations
76137
const approveUserMutation = useApproveUser();
@@ -83,6 +144,29 @@ const AccountRequestsClient = ({
83144
// usePendingUsers returns User[] directly (not wrapped in UsersListResponse)
84145
const users: UserType[] = ((usersData ?? initialUsers) || []) as UserType[];
85146

147+
// Update search params in URL and trigger refetch
148+
const updateSearchParams = (newParams: Record<string, string>) => {
149+
const params = new URLSearchParams(searchParamsHook.toString());
150+
151+
Object.entries(newParams).forEach(([key, value]) => {
152+
if (value && value !== "all") {
153+
params.set(key, value);
154+
} else {
155+
params.delete(key);
156+
}
157+
});
158+
159+
queryClient.invalidateQueries({ queryKey: ["pending-users"] });
160+
router.replace(`/admin/account-requests?${params.toString()}`, {
161+
scroll: false,
162+
});
163+
};
164+
165+
const clearFilters = () => {
166+
setLocalSearch("");
167+
router.push("/admin/account-requests");
168+
};
169+
86170
// Handler functions for mutations
87171
const handleApproveUser = async (userId: string) => {
88172
const user = users.find((u) => u.id === userId);
@@ -153,7 +237,7 @@ const AccountRequestsClient = ({
153237
<div className="mx-auto max-w-7xl">
154238
{/* Header */}
155239
<div className="mb-8">
156-
<div className="flex items-center justify-between">
240+
<div className="mb-6 flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
157241
<div>
158242
<h1 className="text-3xl font-bold text-gray-900">
159243
Account Requests
@@ -162,11 +246,30 @@ const AccountRequestsClient = ({
162246
Review and approve pending user registrations
163247
</p>
164248
</div>
165-
<div className="flex items-center space-x-2">
166-
<div className="rounded-full bg-orange-100 px-3 py-1">
167-
<span className="text-sm font-medium text-orange-800">
168-
{users.length} Pending
169-
</span>
249+
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
250+
{/* Search Input */}
251+
<form
252+
onSubmit={(e) => {
253+
e.preventDefault();
254+
const trimmedSearch = localSearch.trim();
255+
updateSearchParams({ search: trimmedSearch });
256+
}}
257+
className="flex-1 sm:min-w-[250px]"
258+
>
259+
<Input
260+
type="text"
261+
placeholder="Search by name, email, ID..."
262+
value={localSearch}
263+
onChange={(e) => setLocalSearch(e.target.value)}
264+
className="w-full rounded-md border border-gray-200 bg-white px-3 py-2 text-sm text-gray-900 placeholder:text-gray-500 focus:border-gray-300 focus:outline-none focus:ring-1 focus:ring-gray-300"
265+
/>
266+
</form>
267+
<div className="flex items-center space-x-2">
268+
<div className="rounded-full bg-orange-100 px-3 py-1">
269+
<span className="text-sm font-medium text-orange-800">
270+
{users.length} Pending
271+
</span>
272+
</div>
170273
</div>
171274
</div>
172275
</div>
@@ -210,11 +313,24 @@ const AccountRequestsClient = ({
210313
<User className="size-12 text-gray-400" />
211314
</div>
212315
<h3 className="mb-2 text-lg font-medium text-gray-900">
213-
No Pending Requests
316+
{hasActiveFilters
317+
? "No pending requests found matching your criteria."
318+
: "No Pending Requests"}
214319
</h3>
215-
<p className="text-gray-500">
216-
All account requests have been processed.
320+
<p className="mb-4 text-gray-500">
321+
{hasActiveFilters
322+
? "Try adjusting your search terms."
323+
: "All account requests have been processed."}
217324
</p>
325+
{hasActiveFilters && (
326+
<Button
327+
variant="outline"
328+
onClick={clearFilters}
329+
className="mt-2 border-gray-300 text-gray-700 hover:bg-gray-100"
330+
>
331+
Clear All Filters
332+
</Button>
333+
)}
218334
</CardContent>
219335
</Card>
220336
) : (
@@ -269,30 +385,32 @@ const AccountRequestCard = ({
269385
return (
270386
<Card className="group border-0 shadow-md transition-all duration-300 hover:shadow-lg">
271387
<CardHeader className="pb-4">
272-
<div className="flex items-start">
273-
<div className="flex flex-1 items-center space-x-3">
388+
<div className="space-y-3">
389+
{/* Badge on its own row */}
390+
<div className="flex justify-start">
391+
<Badge
392+
variant="pending"
393+
className="flex items-center space-x-1"
394+
>
395+
<Clock className="size-3" />
396+
<span>PENDING</span>
397+
</Badge>
398+
</div>
399+
{/* Avatar and user info with full width */}
400+
<div className="flex items-center space-x-3">
274401
<Avatar className="size-12">
275402
<AvatarImage src="" />
276403
<AvatarFallback className="bg-gradient-to-br from-blue-500 to-purple-600 font-semibold text-white">
277404
{getInitials(user.fullName)}
278405
</AvatarFallback>
279406
</Avatar>
280-
<div className="flex-1">
281-
<div className="flex items-center justify-between">
282-
<h3 className="text-lg font-semibold text-gray-900">
283-
{user.fullName}
284-
</h3>
285-
<Badge
286-
variant="pending"
287-
className="ml-2 flex items-center space-x-1"
288-
>
289-
<Clock className="size-3" />
290-
<span>PENDING</span>
291-
</Badge>
292-
</div>
407+
<div className="flex-1 min-w-0">
408+
<h3 className="text-lg font-semibold text-gray-900 truncate">
409+
{user.fullName}
410+
</h3>
293411
<div className="flex items-center space-x-1 text-sm text-gray-500">
294-
<Mail className="size-3" />
295-
<span>{user.email}</span>
412+
<Mail className="size-3 flex-shrink-0" />
413+
<span className="truncate">{user.email}</span>
296414
</div>
297415
</div>
298416
</div>

app/api/admin/borrow-requests/route.ts

Lines changed: 25 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
import { NextRequest, NextResponse } from "next/server";
1515
import { db } from "@/database/drizzle";
1616
import { borrowRecords, books, users } from "@/database/schema";
17-
import { eq, desc } from "drizzle-orm";
17+
import { eq, desc, and, or, sql } from "drizzle-orm";
1818
import { auth } from "@/auth";
1919

2020
export const runtime = "nodejs";
@@ -73,14 +73,34 @@ export async function GET(request: NextRequest) {
7373
const { searchParams } = new URL(request.url);
7474

7575
// Parse query parameters
76+
const search = searchParams.get("search") || "";
7677
const status = searchParams.get("status") as
7778
| "PENDING"
7879
| "BORROWED"
7980
| "RETURNED"
8081
| null;
8182

8283
// Build where conditions
83-
const whereConditions = status ? [eq(borrowRecords.status, status)] : [];
84+
const whereConditions = [];
85+
86+
// Status filter
87+
if (status) {
88+
whereConditions.push(eq(borrowRecords.status, status));
89+
}
90+
91+
// Search condition - case-insensitive using ILIKE
92+
if (search) {
93+
const searchPattern = `%${search}%`;
94+
whereConditions.push(
95+
or(
96+
sql`${books.title}::text ILIKE ${sql.raw(`'${searchPattern.replace(/'/g, "''")}'`)}`,
97+
sql`${books.author}::text ILIKE ${sql.raw(`'${searchPattern.replace(/'/g, "''")}'`)}`,
98+
sql`${users.fullName}::text ILIKE ${sql.raw(`'${searchPattern.replace(/'/g, "''")}'`)}`,
99+
sql`${users.email}::text ILIKE ${sql.raw(`'${searchPattern.replace(/'/g, "''")}'`)}`,
100+
sql`CAST(${users.universityId} AS TEXT) ILIKE ${sql.raw(`'${searchPattern.replace(/'/g, "''")}'`)}`
101+
)
102+
);
103+
}
84104

85105
// Fetch borrow records with user and book details
86106
const allBorrowRecords = await db
@@ -116,7 +136,9 @@ export async function GET(request: NextRequest) {
116136
.from(borrowRecords)
117137
.innerJoin(users, eq(borrowRecords.userId, users.id))
118138
.innerJoin(books, eq(borrowRecords.bookId, books.id))
119-
.where(whereConditions.length > 0 ? whereConditions[0] : undefined)
139+
.where(
140+
whereConditions.length > 0 ? and(...whereConditions) : undefined
141+
)
120142
.orderBy(desc(borrowRecords.createdAt));
121143

122144
// Transform to BorrowRecordWithDetails format

app/api/books/genres/route.ts

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
/**
2+
* Books Genres API Route
3+
*
4+
* GET /api/books/genres
5+
*
6+
* Purpose: Get a list of unique genres from all books.
7+
*
8+
* Returns: Array of unique genre strings
9+
*
10+
* IMPORTANT: This route uses Node.js runtime (not Edge) because it needs database access
11+
*/
12+
13+
import { NextRequest, NextResponse } from "next/server";
14+
import { db } from "@/database/drizzle";
15+
import { books } from "@/database/schema";
16+
import { asc } from "drizzle-orm";
17+
import { headers } from "next/headers";
18+
import ratelimit from "@/lib/ratelimit";
19+
20+
export const runtime = "nodejs";
21+
22+
/**
23+
* Get unique genres from all books
24+
*
25+
* @param _request - Next.js request object (unused)
26+
* @returns JSON response with genres array
27+
*/
28+
export async function GET(_request: NextRequest) {
29+
try {
30+
// Rate limiting to prevent abuse
31+
const ip = (await headers()).get("x-forwarded-for") || "127.0.0.1";
32+
const { success } = await ratelimit.limit(ip);
33+
34+
if (!success) {
35+
return NextResponse.json(
36+
{
37+
success: false,
38+
error: "Too Many Requests",
39+
message: "Rate limit exceeded. Please try again later.",
40+
},
41+
{ status: 429 }
42+
);
43+
}
44+
45+
// Get unique genres from all books
46+
const genresResult = await db
47+
.selectDistinct({ genre: books.genre })
48+
.from(books)
49+
.orderBy(asc(books.genre));
50+
51+
const genres = genresResult.map((g) => g.genre).filter(Boolean);
52+
53+
return NextResponse.json({
54+
success: true,
55+
genres,
56+
});
57+
} catch (error) {
58+
console.error("Error fetching genres:", error);
59+
return NextResponse.json(
60+
{
61+
success: false,
62+
error: "Failed to fetch genres",
63+
message:
64+
error instanceof Error ? error.message : "Unknown error occurred",
65+
},
66+
{ status: 500 }
67+
);
68+
}
69+
}
70+

app/api/books/route.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -65,10 +65,14 @@ export async function GET(request: NextRequest) {
6565
// Build where conditions
6666
const whereConditions = [];
6767

68-
// Search condition
68+
// Search condition - case-insensitive using ILIKE
6969
if (search) {
70+
const searchPattern = `%${search}%`;
7071
whereConditions.push(
71-
or(like(books.title, `%${search}%`), like(books.author, `%${search}%`))
72+
or(
73+
sql`${books.title}::text ILIKE ${sql.raw(`'${searchPattern.replace(/'/g, "''")}'`)}`,
74+
sql`${books.author}::text ILIKE ${sql.raw(`'${searchPattern.replace(/'/g, "''")}'`)}`
75+
)
7276
);
7377
}
7478

0 commit comments

Comments
 (0)