Skip to content

Commit aa49ba7

Browse files
Merge pull request #109 from pranitaurlam/feature/roadmap-filtering-search
feat: implement tag-based filtering and search functionality
2 parents 4f99387 + 01df38e commit aa49ba7

4 files changed

Lines changed: 221 additions & 25 deletions

File tree

client/src/hooks/useDebounce.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import { useEffect, useState } from "react";
2+
3+
export function useDebounce<T>(value: T, delay: number): T {
4+
const [debouncedValue, setDebouncedValue] = useState<T>(value);
5+
6+
useEffect(() => {
7+
const handler = setTimeout(() => {
8+
setDebouncedValue(value);
9+
}, delay);
10+
11+
return () => {
12+
clearTimeout(handler);
13+
};
14+
}, [value, delay]);
15+
16+
return debouncedValue;
17+
}

client/src/module/student/roadmap/RoadmapsLandingPage.tsx

Lines changed: 168 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,9 @@ import { canonicalUrl, SITE_URL } from "../../../lib/seo.utils";
1818
import api from "../../../lib/axios";
1919
import { useAuthStore } from "../../../lib/auth.store";
2020
import type { RoadmapListItem, RoadmapEnrollmentListItem } from "../../../lib/types";
21+
import { useSearchParams } from "react-router";
22+
import { useDebounce } from "../../../hooks/useDebounce";
23+
import { X } from "lucide-react";
2124

2225
interface ListResponse {
2326
roadmaps: RoadmapListItem[];
@@ -53,21 +56,37 @@ export default function RoadmapsLandingPage() {
5356
const { isAuthenticated, user } = useAuthStore();
5457
const isStudent = isAuthenticated && user?.role === "STUDENT";
5558
const { collapsed, sidebarWidth, sidebar } = useStudentSidebar();
59+
const [searchParams, setSearchParams] = useSearchParams();
5660

5761
const [roadmaps, setRoadmaps] = useState<RoadmapListItem[]>([]);
5862
const [enrollments, setEnrollments] = useState<RoadmapEnrollmentListItem[]>([]);
5963
const [loading, setLoading] = useState(true);
6064
const [error, setError] = useState<string | null>(null);
61-
const [search, setSearch] = useState("");
65+
66+
// Filter states from URL
67+
const [searchInput, setSearchInput] = useState(searchParams.get("search") || "");
68+
const debouncedSearch = useDebounce(searchInput, 400);
69+
70+
const level = (searchParams.get("level") || "ALL_LEVELS") as any;
71+
const tag = searchParams.get("tag") || "";
72+
const category = searchParams.get("category") || "";
6273

6374
useEffect(() => {
6475
let mounted = true;
65-
api.get<ListResponse>("/roadmaps", { params: { page: 1, limit: 50 } })
76+
setLoading(true);
77+
78+
const params: any = { page: 1, limit: 100 };
79+
if (debouncedSearch) params.search = debouncedSearch;
80+
if (level && level !== "ALL_LEVELS") params.level = level;
81+
if (tag) params.tag = tag;
82+
if (category) params.category = category;
83+
84+
api.get<ListResponse>("/roadmaps", { params })
6685
.then((res) => mounted && setRoadmaps(res.data.roadmaps))
6786
.catch(() => mounted && setError("Could not load roadmaps. Please try again."))
6887
.finally(() => mounted && setLoading(false));
6988
return () => { mounted = false; };
70-
}, []);
89+
}, [debouncedSearch, level, tag, category]);
7190

7291
useEffect(() => {
7392
if (!isStudent) return;
@@ -78,24 +97,68 @@ export default function RoadmapsLandingPage() {
7897
return () => { mounted = false; };
7998
}, [isStudent]);
8099

100+
// Sync search input with URL when typing
101+
useEffect(() => {
102+
const newParams = new URLSearchParams(searchParams);
103+
if (debouncedSearch) newParams.set("search", debouncedSearch);
104+
else newParams.delete("search");
105+
106+
// Only update if it actually changed to avoid unnecessary history entries
107+
if (newParams.toString() !== searchParams.toString()) {
108+
setSearchParams(newParams, { replace: true });
109+
}
110+
}, [debouncedSearch, setSearchParams, searchParams]);
111+
112+
const updateFilter = (key: string, value: string) => {
113+
const newParams = new URLSearchParams(searchParams);
114+
if (value && value !== "ALL_LEVELS") {
115+
newParams.set(key, value);
116+
} else {
117+
newParams.delete(key);
118+
}
119+
setSearchParams(newParams, { replace: true });
120+
};
121+
122+
const clearFilters = () => {
123+
setSearchInput("");
124+
setSearchParams({}, { replace: true });
125+
};
126+
81127
// Index of slugs the student is already enrolled in
82128
const enrolledBySlug = useMemo(() => {
83129
const map = new Map<string, RoadmapEnrollmentListItem>();
84130
for (const e of enrollments) map.set(e.roadmap.slug, e);
85131
return map;
86132
}, [enrollments]);
87133

88-
// Filtered + grouped: separate "in progress" from "available"
134+
// Secondary client-side filtering for ultra-smooth UX while API loads
89135
const filtered = useMemo(() => {
90-
const needle = search.trim().toLowerCase();
91-
if (!needle) return roadmaps;
92-
return roadmaps.filter(
93-
(r) =>
94-
r.title.toLowerCase().includes(needle) ||
95-
r.shortDescription.toLowerCase().includes(needle) ||
96-
r.tags.some((t) => t.toLowerCase().includes(needle)),
97-
);
98-
}, [roadmaps, search]);
136+
let result = roadmaps;
137+
const needle = debouncedSearch.trim().toLowerCase();
138+
139+
if (needle) {
140+
result = result.filter(
141+
(r) =>
142+
r.title.toLowerCase().includes(needle) ||
143+
r.shortDescription.toLowerCase().includes(needle) ||
144+
r.tags.some((t) => t.toLowerCase().includes(needle)),
145+
);
146+
}
147+
148+
if (level && level !== "ALL_LEVELS") {
149+
result = result.filter(r => r.level === level);
150+
}
151+
152+
if (tag) {
153+
result = result.filter(r => r.tags.includes(tag));
154+
}
155+
156+
if (category) {
157+
result = result.filter(r => r.tags.includes(category));
158+
}
159+
160+
return result;
161+
}, [roadmaps, debouncedSearch, level, tag, category]);
99162

100163
const inProgress = filtered.filter((r) => enrolledBySlug.has(r.slug));
101164
const available = filtered.filter((r) => !enrolledBySlug.has(r.slug));
@@ -225,29 +288,96 @@ export default function RoadmapsLandingPage() {
225288
</Link>
226289
</motion.div>
227290

228-
{/* Search */}
291+
{/* Search & Filters */}
229292
<motion.div
230293
initial={{ opacity: 0, y: 10 }}
231294
animate={{ opacity: 1, y: 0 }}
232295
transition={{ delay: 0.1, duration: 0.4 }}
233-
className="mb-10"
296+
className="mb-10 space-y-6"
234297
>
235298
<div className="relative">
236299
<Search className="absolute left-4 top-1/2 -translate-y-1/2 w-4 h-4 text-stone-400" aria-hidden />
237300
<input
238301
type="text"
239-
placeholder="Search roadmaps..."
240-
value={search}
241-
onChange={(e) => setSearch(e.target.value)}
302+
placeholder="Search roadmaps by title, tech, or keywords..."
303+
value={searchInput}
304+
onChange={(e) => setSearchInput(e.target.value)}
242305
aria-label="Search roadmaps"
243-
className="w-full pl-11 pr-4 py-3 bg-white dark:bg-stone-900 border border-stone-300 dark:border-white/10 rounded-md focus:outline-none focus:border-lime-400 transition-colors text-sm text-stone-900 dark:text-stone-50 placeholder-stone-400 dark:placeholder-stone-600"
306+
className="w-full pl-11 pr-12 py-4 bg-white dark:bg-stone-900 border border-stone-200 dark:border-white/10 rounded-lg focus:outline-none focus:ring-2 focus:ring-lime-400/20 focus:border-lime-400 transition-all text-base text-stone-900 dark:text-stone-50 placeholder-stone-400 dark:placeholder-stone-600 shadow-sm"
244307
/>
308+
{searchInput && (
309+
<button
310+
onClick={() => setSearchInput("")}
311+
className="absolute right-4 top-1/2 -translate-y-1/2 p-1 text-stone-400 hover:text-stone-600 dark:hover:text-stone-200 transition-colors"
312+
>
313+
<X className="w-4 h-4" />
314+
</button>
315+
)}
245316
</div>
246-
{search && (
247-
<p className="mt-2 text-[10px] font-mono uppercase tracking-widest text-stone-500">
248-
{filtered.length} match{filtered.length === 1 ? "" : "es"}
317+
318+
<div className="flex flex-col gap-4">
319+
{/* Category Filters */}
320+
<div className="flex flex-wrap items-center gap-2">
321+
<span className="text-[10px] font-mono uppercase tracking-widest text-stone-400 mr-2">Category:</span>
322+
{["Frontend", "Backend", "Fullstack", "AI", "Mobile", "DevOps", "Blockchain"].map((cat) => (
323+
<FilterChip
324+
key={cat}
325+
label={cat}
326+
active={category === cat}
327+
onClick={() => updateFilter("category", category === cat ? "" : cat)}
328+
/>
329+
))}
330+
</div>
331+
332+
{/* Tags Filters */}
333+
<div className="flex flex-wrap items-center gap-2">
334+
<span className="text-[10px] font-mono uppercase tracking-widest text-stone-400 mr-2">Tags:</span>
335+
{["React", "Node.js", "Python", "System Design", "AWS", "SQL"].map((t) => (
336+
<FilterChip
337+
key={t}
338+
label={t}
339+
active={tag === t}
340+
onClick={() => updateFilter("tag", tag === t ? "" : t)}
341+
/>
342+
))}
343+
</div>
344+
345+
<div className="flex flex-wrap items-center justify-between gap-4">
346+
{/* Level Filters */}
347+
<div className="flex flex-wrap items-center gap-2">
348+
<span className="text-[10px] font-mono uppercase tracking-widest text-stone-400 mr-2">Level:</span>
349+
{["ALL_LEVELS", "BEGINNER", "INTERMEDIATE", "ADVANCED"].map((l) => (
350+
<FilterChip
351+
key={l}
352+
label={l.replace("_", " ")}
353+
active={level === l}
354+
onClick={() => updateFilter("level", l)}
355+
/>
356+
))}
357+
</div>
358+
359+
{(searchInput || tag || category || (level && level !== "ALL_LEVELS")) && (
360+
<button
361+
onClick={clearFilters}
362+
className="text-[10px] font-mono uppercase tracking-widest text-red-500 hover:text-red-600 transition-colors flex items-center gap-1"
363+
>
364+
<X className="w-3 h-3" />
365+
Clear all filters
366+
</button>
367+
)}
368+
</div>
369+
</div>
370+
371+
<div className="flex items-center justify-between pt-2 border-t border-stone-200 dark:border-white/5">
372+
<p className="text-[10px] font-mono uppercase tracking-widest text-stone-500">
373+
{loading ? "Updating results..." : (
374+
<>
375+
Showing <span className="text-stone-900 dark:text-stone-50 font-bold">{filtered.length}</span> roadmap{filtered.length === 1 ? "" : "s"}
376+
{(searchInput || tag || category || level !== "ALL_LEVELS") && " matching filters"}
377+
</>
378+
)}
249379
</p>
250-
)}
380+
</div>
251381
</motion.div>
252382

253383
{/* Sections */}
@@ -459,3 +589,18 @@ function RoadmapCard({
459589
</motion.div>
460590
);
461591
}
592+
593+
function FilterChip({ label, active, onClick }: { label: string; active: boolean; onClick: () => void }) {
594+
return (
595+
<button
596+
onClick={onClick}
597+
className={`px-3 py-1 rounded-full text-[10px] font-mono uppercase tracking-widest transition-all ${
598+
active
599+
? "bg-lime-400 text-stone-950 font-bold border border-lime-500 shadow-sm"
600+
: "bg-white dark:bg-stone-900 text-stone-500 hover:text-stone-900 dark:hover:text-stone-300 border border-stone-200 dark:border-white/10"
601+
}`}
602+
>
603+
{label}
604+
</button>
605+
);
606+
}

server/src/module/roadmap/roadmap.service.ts

Lines changed: 33 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -76,11 +76,42 @@ export function buildWeeklyPlan(
7676
return { plan, targetEndDate };
7777
}
7878

79-
export async function listPublishedRoadmaps(opts: { page: number; limit: number; level?: string | undefined }) {
79+
export async function listPublishedRoadmaps(opts: {
80+
page: number;
81+
limit: number;
82+
level?: string | undefined;
83+
search?: string | undefined;
84+
tag?: string | undefined;
85+
category?: string | undefined;
86+
}) {
8087
const where: Prisma.roadmapWhereInput = { isPublished: true };
81-
if (opts.level) {
88+
const andConditions: Prisma.roadmapWhereInput[] = [];
89+
90+
if (opts.level && opts.level !== "ALL_LEVELS") {
8291
where.level = opts.level as "BEGINNER" | "INTERMEDIATE" | "ADVANCED" | "ALL_LEVELS";
8392
}
93+
if (opts.tag) {
94+
andConditions.push({ tags: { has: opts.tag } });
95+
}
96+
if (opts.category) {
97+
andConditions.push({ tags: { has: opts.category } });
98+
}
99+
if (opts.search) {
100+
const s = opts.search.trim();
101+
if (s) {
102+
andConditions.push({
103+
OR: [
104+
{ title: { contains: s, mode: "insensitive" } },
105+
{ shortDescription: { contains: s, mode: "insensitive" } },
106+
{ tags: { has: s } },
107+
],
108+
});
109+
}
110+
}
111+
112+
if (andConditions.length > 0) {
113+
where.AND = andConditions;
114+
}
84115

85116
const [roadmaps, total] = await Promise.all([
86117
prisma.roadmap.findMany({

server/src/module/roadmap/roadmap.validation.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,9 @@ export const listQuerySchema = z.object({
4747
page: z.coerce.number().int().min(1).default(1),
4848
limit: z.coerce.number().int().min(1).max(50).default(20),
4949
level: z.enum(["BEGINNER", "INTERMEDIATE", "ADVANCED", "ALL_LEVELS"]).optional(),
50+
search: z.string().optional(),
51+
tag: z.string().optional(),
52+
category: z.string().optional(),
5053
});
5154

5255
export const aiGenerateSchema = z.object({

0 commit comments

Comments
 (0)