-
Notifications
You must be signed in to change notification settings - Fork 73
feat: course category taxonomy with CodeRabbit fixes #475
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,182 @@ | ||||||||||||||||||||||||||||||
| "use client"; | ||||||||||||||||||||||||||||||
| import { useEffect, useState, useMemo } from "react"; | ||||||||||||||||||||||||||||||
| import Link from "next/link"; | ||||||||||||||||||||||||||||||
| import { fetchCourses } from "@/lib/actions/courses/fetch-courses"; | ||||||||||||||||||||||||||||||
| import CourseCardSkeleton from "@/components/atoms/skeletons/CourseCardSkeleton"; | ||||||||||||||||||||||||||||||
| import NetworkErrorComp from "@/components/molecules/errors/NetworkError"; | ||||||||||||||||||||||||||||||
| import { | ||||||||||||||||||||||||||||||
| CATEGORY_GROUPS, | ||||||||||||||||||||||||||||||
| CATEGORIES, | ||||||||||||||||||||||||||||||
| getCategoryCounts, | ||||||||||||||||||||||||||||||
| } from "@/lib/categories"; | ||||||||||||||||||||||||||||||
| import { BookOpen, ArrowRight, LayoutGrid } from "lucide-react"; | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| export default function CategoryHubPage() { | ||||||||||||||||||||||||||||||
| const [courses, setCourses] = useState([]); | ||||||||||||||||||||||||||||||
| const [loading, setLoading] = useState(true); | ||||||||||||||||||||||||||||||
| const [error, setError] = useState(false); | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| const loadCourses = async () => { | ||||||||||||||||||||||||||||||
| setLoading(true); | ||||||||||||||||||||||||||||||
| setError(false); | ||||||||||||||||||||||||||||||
| try { | ||||||||||||||||||||||||||||||
| const data = await fetchCourses(); | ||||||||||||||||||||||||||||||
| if (!data) throw new Error("No data returned"); | ||||||||||||||||||||||||||||||
| setCourses(data); | ||||||||||||||||||||||||||||||
| } catch (err) { | ||||||||||||||||||||||||||||||
| console.error("[CategoryHub] Failed to load courses:", err); | ||||||||||||||||||||||||||||||
| setError(true); | ||||||||||||||||||||||||||||||
| } finally { | ||||||||||||||||||||||||||||||
| setLoading(false); | ||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||
| }; | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| useEffect(() => { | ||||||||||||||||||||||||||||||
| loadCourses(); | ||||||||||||||||||||||||||||||
| }, []); | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| // Derive counts from the fetched course list | ||||||||||||||||||||||||||||||
| const counts = useMemo(() => getCategoryCounts(courses), [courses]); | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| const totalCourses = courses.length; | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| if (error) { | ||||||||||||||||||||||||||||||
| return ( | ||||||||||||||||||||||||||||||
| <NetworkErrorComp | ||||||||||||||||||||||||||||||
| errMsg="Failed to load course categories, please try again." | ||||||||||||||||||||||||||||||
| reset={loadCourses} | ||||||||||||||||||||||||||||||
| /> | ||||||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| return ( | ||||||||||||||||||||||||||||||
| <div className="bg-muted min-h-full w-full"> | ||||||||||||||||||||||||||||||
| {/* ── Hero header ── */} | ||||||||||||||||||||||||||||||
| <div className="bg-gradient-to-br from-accent via-green-600 to-highlight px-6 py-10 text-white"> | ||||||||||||||||||||||||||||||
| <div className="max-w-4xl mx-auto"> | ||||||||||||||||||||||||||||||
| <div className="flex items-center gap-3 mb-3"> | ||||||||||||||||||||||||||||||
| <LayoutGrid className="w-8 h-8 opacity-90" /> | ||||||||||||||||||||||||||||||
| <h1 className="text-3xl md:text-4xl font-bold"> | ||||||||||||||||||||||||||||||
| Course Categories | ||||||||||||||||||||||||||||||
| </h1> | ||||||||||||||||||||||||||||||
| </div> | ||||||||||||||||||||||||||||||
| <p className="text-green-50 text-base md:text-lg max-w-xl"> | ||||||||||||||||||||||||||||||
| Browse authentic Islamic knowledge across{" "} | ||||||||||||||||||||||||||||||
| {CATEGORIES.length} categories grouped into{" "} | ||||||||||||||||||||||||||||||
| {CATEGORY_GROUPS.length} disciplines. | ||||||||||||||||||||||||||||||
| </p> | ||||||||||||||||||||||||||||||
| {!loading && ( | ||||||||||||||||||||||||||||||
| <p className="mt-2 text-green-100 text-sm"> | ||||||||||||||||||||||||||||||
| {totalCourses} course{totalCourses !== 1 ? "s" : ""} available | ||||||||||||||||||||||||||||||
| </p> | ||||||||||||||||||||||||||||||
| )} | ||||||||||||||||||||||||||||||
| <div className="mt-5"> | ||||||||||||||||||||||||||||||
| <Link | ||||||||||||||||||||||||||||||
| href="/dashboard/courses" | ||||||||||||||||||||||||||||||
| className="inline-flex items-center gap-2 bg-white text-accent font-semibold px-5 py-2.5 rounded-full text-sm hover:bg-green-50 transition-colors shadow" | ||||||||||||||||||||||||||||||
| > | ||||||||||||||||||||||||||||||
| <BookOpen className="w-4 h-4" /> | ||||||||||||||||||||||||||||||
| Browse All Courses | ||||||||||||||||||||||||||||||
| </Link> | ||||||||||||||||||||||||||||||
| </div> | ||||||||||||||||||||||||||||||
| </div> | ||||||||||||||||||||||||||||||
| </div> | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| {/* ── Loading skeletons ── */} | ||||||||||||||||||||||||||||||
| {loading ? ( | ||||||||||||||||||||||||||||||
| <div className="p-6 max-w-6xl mx-auto space-y-10"> | ||||||||||||||||||||||||||||||
| {[...Array(3)].map((_, gi) => ( | ||||||||||||||||||||||||||||||
| <div key={gi}> | ||||||||||||||||||||||||||||||
| <div className="h-6 w-48 bg-gray-200 rounded animate-pulse mb-4" /> | ||||||||||||||||||||||||||||||
| <div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-4"> | ||||||||||||||||||||||||||||||
| {[...Array(3)].map((_, ci) => ( | ||||||||||||||||||||||||||||||
| <CourseCardSkeleton key={ci} /> | ||||||||||||||||||||||||||||||
| ))} | ||||||||||||||||||||||||||||||
| </div> | ||||||||||||||||||||||||||||||
| </div> | ||||||||||||||||||||||||||||||
| ))} | ||||||||||||||||||||||||||||||
| </div> | ||||||||||||||||||||||||||||||
| ) : ( | ||||||||||||||||||||||||||||||
| /* ── Category groups ── */ | ||||||||||||||||||||||||||||||
| <div className="p-6 max-w-6xl mx-auto space-y-10"> | ||||||||||||||||||||||||||||||
| {CATEGORY_GROUPS.map((group) => { | ||||||||||||||||||||||||||||||
| const groupCategories = CATEGORIES.filter( | ||||||||||||||||||||||||||||||
| (c) => c.group === group | ||||||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||||||
| return ( | ||||||||||||||||||||||||||||||
| <section key={group}> | ||||||||||||||||||||||||||||||
| <h2 className="text-xl md:text-2xl font-bold mb-4 text-foreground border-b border-border pb-2"> | ||||||||||||||||||||||||||||||
| {group} | ||||||||||||||||||||||||||||||
|
Comment on lines
+102
to
+109
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🔴 Critical | ⚡ Quick win Use fields from the category group object.
Proposed fix {CATEGORY_GROUPS.map((group) => {
- const groupCategories = CATEGORIES.filter(
- (c) => c.group === group
- );
+ const groupCategories = group.categories;
return (
- <section key={group}>
+ <section key={group.id}>
<h2>
- {group}
+ {group.label}
</h2>📝 Committable suggestion
Suggested change
🧰 Tools🪛 ast-grep (0.45.2)[warning] 107-109: A list component should have a key to prevent re-rendering
|
||||||||||||||||||||||||||||||
| </h2> | ||||||||||||||||||||||||||||||
| <div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-4"> | ||||||||||||||||||||||||||||||
| {groupCategories.map((cat) => { | ||||||||||||||||||||||||||||||
| const count = counts[cat.slug] || 0; | ||||||||||||||||||||||||||||||
| const isEmpty = count === 0; | ||||||||||||||||||||||||||||||
| return ( | ||||||||||||||||||||||||||||||
| <Link | ||||||||||||||||||||||||||||||
| key={cat.slug} | ||||||||||||||||||||||||||||||
| href={`/dashboard/courses/category/${cat.slug}`} | ||||||||||||||||||||||||||||||
| className={`group flex flex-col rounded-2xl p-5 border transition-all ${ | ||||||||||||||||||||||||||||||
| isEmpty | ||||||||||||||||||||||||||||||
| ? "bg-muted/40 border-border opacity-60 hover:opacity-80" | ||||||||||||||||||||||||||||||
| : "bg-background border-border hover:border-accent hover:shadow-lg hover:scale-[1.02]" | ||||||||||||||||||||||||||||||
| }`} | ||||||||||||||||||||||||||||||
| aria-label={`${cat.label} — ${count} course${count !== 1 ? "s" : ""}`} | ||||||||||||||||||||||||||||||
| > | ||||||||||||||||||||||||||||||
| {/* Icon + count */} | ||||||||||||||||||||||||||||||
| <div className="flex items-start justify-between mb-3"> | ||||||||||||||||||||||||||||||
| <span | ||||||||||||||||||||||||||||||
| className="text-3xl" | ||||||||||||||||||||||||||||||
| role="img" | ||||||||||||||||||||||||||||||
| aria-label={cat.label} | ||||||||||||||||||||||||||||||
| > | ||||||||||||||||||||||||||||||
| {cat.icon} | ||||||||||||||||||||||||||||||
| </span> | ||||||||||||||||||||||||||||||
|
Comment on lines
+128
to
+134
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win Render each Lucide icon as a JSX element. The taxonomy stores Lucide component references. Rendering
🧰 Tools🪛 ast-grep (0.45.2)[warning] 126-143: A list component should have a key to prevent re-rendering {cat.icon} <span className={ text-xs font-bold px-2.5 py-1 rounded-full ${ isEmpty ? "bg-muted text-muted-foreground" : "bg-accent/10 text-accent" }}> {count} course{count !== 1 ? "s" : ""} Note: [CWE-710] Improper Adherence to Coding Standards. Security best practice. (list-component-needs-key) [warning] 127-133: A list component should have a key to prevent re-rendering (list-component-needs-key) [warning] 134-142: A list component should have a key to prevent re-rendering (list-component-needs-key) 📍 Affects 2 files
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||
| <span | ||||||||||||||||||||||||||||||
| className={`text-xs font-bold px-2.5 py-1 rounded-full ${ | ||||||||||||||||||||||||||||||
| isEmpty | ||||||||||||||||||||||||||||||
| ? "bg-muted text-muted-foreground" | ||||||||||||||||||||||||||||||
| : "bg-accent/10 text-accent" | ||||||||||||||||||||||||||||||
| }`} | ||||||||||||||||||||||||||||||
| > | ||||||||||||||||||||||||||||||
| {count} course{count !== 1 ? "s" : ""} | ||||||||||||||||||||||||||||||
| </span> | ||||||||||||||||||||||||||||||
| </div> | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| {/* Label + description */} | ||||||||||||||||||||||||||||||
| <h3 | ||||||||||||||||||||||||||||||
| className={`font-semibold text-base leading-tight mb-1 transition-colors ${ | ||||||||||||||||||||||||||||||
| isEmpty | ||||||||||||||||||||||||||||||
| ? "text-muted-foreground" | ||||||||||||||||||||||||||||||
| : "text-foreground group-hover:text-accent" | ||||||||||||||||||||||||||||||
| }`} | ||||||||||||||||||||||||||||||
| > | ||||||||||||||||||||||||||||||
| {cat.label} | ||||||||||||||||||||||||||||||
| </h3> | ||||||||||||||||||||||||||||||
| <p className="text-sm text-muted-foreground line-clamp-2 flex-1"> | ||||||||||||||||||||||||||||||
| {cat.description} | ||||||||||||||||||||||||||||||
| </p> | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| {/* CTA row */} | ||||||||||||||||||||||||||||||
| <div | ||||||||||||||||||||||||||||||
| className={`mt-4 flex items-center gap-1 text-xs font-semibold ${ | ||||||||||||||||||||||||||||||
| isEmpty | ||||||||||||||||||||||||||||||
| ? "text-muted-foreground" | ||||||||||||||||||||||||||||||
| : "text-accent group-hover:gap-2 transition-all" | ||||||||||||||||||||||||||||||
| }`} | ||||||||||||||||||||||||||||||
| > | ||||||||||||||||||||||||||||||
| {isEmpty ? "No courses yet" : "View courses"} | ||||||||||||||||||||||||||||||
| <ArrowRight className="w-3 h-3" /> | ||||||||||||||||||||||||||||||
| </div> | ||||||||||||||||||||||||||||||
| </Link> | ||||||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||||||
| })} | ||||||||||||||||||||||||||||||
| </div> | ||||||||||||||||||||||||||||||
| </section> | ||||||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||||||
| })} | ||||||||||||||||||||||||||||||
| </div> | ||||||||||||||||||||||||||||||
| )} | ||||||||||||||||||||||||||||||
| </div> | ||||||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🎯 Functional Correctness | 🟠 Major | ⚡ Quick win
Expose fetch failures separately from an empty course catalog.
fetchCourses()catches request errors and returns[]. An empty array is truthy, so every newif (!data)guard accepts the failure and renders an empty state instead ofNetworkErrorComp. MakefetchCourses()reject on request failure, or return an explicit result status.app/dashboard/courses/categories/page.jsx#L23-L25: handle an explicit failed result before settingcourses.app/dashboard/courses/category/[slug]/page.jsx#L61-L63: handle an explicit failed result before settingallCourses.app/[locale]/dashboard/courses/page.jsx#L65-L69: handle an explicit failed result before settingcourses.🧰 Tools
🪛 ast-grep (0.45.2)
[warning] 24-24: Avoid using the initial state variable in setState
Context: setCourses(data)
Note: [CWE-710] Improper Adherence to Coding Standards. Security best practice.
(setstate-same-var)
📍 Affects 3 files
app/dashboard/courses/categories/page.jsx#L23-L25(this comment)app/dashboard/courses/category/[slug]/page.jsx#L61-L63app/[locale]/dashboard/courses/page.jsx#L65-L69🤖 Prompt for AI Agents